up
This commit is contained in:
+9
-3
@@ -592,11 +592,11 @@ app
|
||||
intent && typeof intent === 'object' && 'action' in intent ? intent.action : null
|
||||
try {
|
||||
const result = await online!.act(intent)
|
||||
if (['download', 'check', 'remove'].includes(String(action)))
|
||||
if (['download', 'check', 'remove', 'clear-games'].includes(String(action)))
|
||||
diagnostics?.record('package', { action, status: 'success' })
|
||||
return result
|
||||
} catch (error) {
|
||||
if (['download', 'check', 'remove'].includes(String(action)))
|
||||
if (['download', 'check', 'remove', 'clear-games'].includes(String(action)))
|
||||
diagnostics?.record('package', { action, status: 'failed' })
|
||||
throw error
|
||||
} finally {
|
||||
@@ -622,7 +622,13 @@ app
|
||||
requireControl(event)
|
||||
return packages
|
||||
.list()
|
||||
.map((pkg) => ({ key: pkg.key, version: pkg.version, name: pkg.package.game.name }))
|
||||
.map((pkg) => ({
|
||||
key: pkg.key,
|
||||
version: pkg.version,
|
||||
name: pkg.package.game.name,
|
||||
downloadedFiles: pkg.package.assets.filter((asset) => packages.canRead(pkg.key, asset.id)).length,
|
||||
totalFiles: pkg.package.assets.length,
|
||||
}))
|
||||
})
|
||||
ipcMain.handle('select-package', async (event, key: unknown) => {
|
||||
requireControl(event)
|
||||
|
||||
+113
-1
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test, type TestContext } from 'node:test'
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Controller } from './controller.js'
|
||||
@@ -443,6 +443,79 @@ test('список материалов показывает отдельную
|
||||
assert.deepEqual(f.online.snapshot().materials, [])
|
||||
})
|
||||
|
||||
test('полная очистка без сети удаляет игры и локальные данные, сохраняя вход после перезапуска', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const directory = join(f.directory, 'connection')
|
||||
const key = f.controller.state.catalog.key!
|
||||
await writeFile(join(directory, `plan-${key}.json`), '{}')
|
||||
await writeFile(join(directory, `results-${'a'.repeat(64)}.json.tmp`), '{}')
|
||||
const credentials = JSON.parse(f.secrets.decrypt(await readFile(join(directory, 'credentials.bin'))))
|
||||
f.model.offline = true
|
||||
const requests = f.requests.length
|
||||
const revision = f.online.snapshot().packagesRevision
|
||||
const result = await f.online.act({ action: 'clear-games' })
|
||||
assert.equal(result.authenticated, true)
|
||||
assert.equal(result.screenId, null)
|
||||
assert.equal(result.game, null)
|
||||
assert.deepEqual(result.materials, [])
|
||||
assert.equal(result.packagesRevision, revision + 1)
|
||||
assert.equal(result.notice, 'Все сохранённые игры удалены')
|
||||
assert.equal(f.controller.state.catalog.key, null)
|
||||
assert.deepEqual(f.store.list(), [])
|
||||
assert.equal(f.requests.length, requests)
|
||||
assert.ok(!(await readdir(directory)).some((name) => /^(plan|results)-/.test(name)))
|
||||
const saved = JSON.parse(f.secrets.decrypt(await readFile(join(directory, 'credentials.bin'))))
|
||||
assert.equal(saved.access, credentials.access)
|
||||
assert.equal(saved.screen, null)
|
||||
await f.online.close()
|
||||
await f.store.initialize()
|
||||
await f.online.initialize()
|
||||
assert.equal(f.controller.state.catalog.key, null)
|
||||
assert.equal(f.online.snapshot().authenticated, true)
|
||||
assert.deepEqual(f.store.list(), [])
|
||||
await f.online.act({ action: 'logout' })
|
||||
await f.online.act({ action: 'clear-games' })
|
||||
})
|
||||
|
||||
test('ошибка очистки обновляет список и позволяет повторить удаление', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const clear = f.store.clear.bind(f.store)
|
||||
f.store.clear = async () => {
|
||||
assert.equal(f.online.blocksOutput, true)
|
||||
throw new Error('disk failure')
|
||||
}
|
||||
const revision = f.online.snapshot().packagesRevision
|
||||
await assert.rejects(f.online.act({ action: 'clear-games' }), /Очистка не завершена/)
|
||||
assert.equal(f.online.blocksOutput, false)
|
||||
assert.equal(f.online.snapshot().packagesRevision, revision + 1)
|
||||
assert.equal(f.controller.state.catalog.key, null)
|
||||
assert.equal(f.store.list().length, 1)
|
||||
assert.equal(f.online.snapshot().notice, '')
|
||||
f.store.clear = clear
|
||||
await f.online.act({ action: 'clear-games' })
|
||||
assert.deepEqual(f.store.list(), [])
|
||||
})
|
||||
|
||||
test('очистка отклоняется во время показа и подготовки материалов', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const key = f.controller.state.catalog.key
|
||||
f.controller.state.outputOpen = true
|
||||
await assert.rejects(f.online.act({ action: 'clear-games' }), /Закройте показ/)
|
||||
assert.equal(f.controller.state.catalog.key, key)
|
||||
assert.equal(f.store.list().length, 1)
|
||||
f.controller.state.outputOpen = false
|
||||
f.online.state.preparation.active = true
|
||||
await assert.rejects(f.online.act({ action: 'clear-games' }), /Подождите/)
|
||||
assert.equal(f.store.list().length, 1)
|
||||
f.online.state.preparation.active = false
|
||||
f.online.state.busy = true
|
||||
await assert.rejects(f.online.act({ action: 'clear-games' }), /Подождите/)
|
||||
f.online.state.busy = false
|
||||
})
|
||||
|
||||
test('готовый пакет показывает все материалы загруженными без входа', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
@@ -451,6 +524,45 @@ test('готовый пакет показывает все материалы
|
||||
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
|
||||
})
|
||||
|
||||
test('локальную игру можно выбрать без сети и входа, но нельзя сменить во время показа', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const key = f.controller.state.catalog.key!
|
||||
await f.online.act({ action: 'logout' })
|
||||
await f.online.select(null)
|
||||
f.model.offline = true
|
||||
const requests = f.requests.length
|
||||
await f.online.select(key)
|
||||
assert.equal(f.controller.state.catalog.key, key)
|
||||
assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready'))
|
||||
assert.equal(f.requests.length, requests)
|
||||
f.controller.state.outputOpen = true
|
||||
await assert.rejects(f.online.select(null), /Закройте показ/)
|
||||
assert.equal(f.controller.state.catalog.key, key)
|
||||
f.controller.state.outputOpen = false
|
||||
})
|
||||
|
||||
test('частично скачанная игра выбирается без сети после перезапуска', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.login()
|
||||
await f.online.act({ action: 'connect', gameId: 7 })
|
||||
const key = f.controller.state.catalog.key!
|
||||
const asset = f.online.snapshot().materials.find((m) => m.usages.some((u) => u.name === 'Вступление'))!
|
||||
await f.online.act({ action: 'download-material', key, assetId: asset.id })
|
||||
await f.online.act({ action: 'logout' })
|
||||
await f.online.select(null)
|
||||
await f.online.close()
|
||||
f.model.offline = true
|
||||
await f.store.initialize()
|
||||
await f.online.initialize()
|
||||
const requests = f.requests.length
|
||||
await f.online.select(key)
|
||||
const materials = f.online.snapshot().materials
|
||||
assert.equal(materials.find((m) => m.id === asset.id)!.status, 'ready')
|
||||
assert.ok(materials.some((m) => m.status === 'missing'))
|
||||
assert.equal(f.requests.length, requests)
|
||||
})
|
||||
|
||||
test('один файл сохраняет назначения в группе игры и плейлиста без дублирования счётчика', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.login()
|
||||
|
||||
+37
-2
@@ -2,7 +2,7 @@ import { requestErrorContext } from './request-error.js'
|
||||
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
||||
import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
|
||||
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
DEFAULT_SERVER,
|
||||
@@ -916,8 +916,9 @@ export class Online {
|
||||
return true
|
||||
}
|
||||
}
|
||||
private clearingGames = false
|
||||
get blocksOutput() {
|
||||
return this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key
|
||||
return this.clearingGames || (this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key)
|
||||
}
|
||||
get busy() {
|
||||
return this.state.busy
|
||||
@@ -1054,6 +1055,7 @@ export class Online {
|
||||
}
|
||||
async select(key: string | null, persist = true) {
|
||||
if (this.busy) throw new Error('Подождите завершения подготовки материалов')
|
||||
if (this.controller.state.outputOpen) throw new Error('Закройте показ перед выбором игры')
|
||||
let pkg
|
||||
try {
|
||||
pkg =
|
||||
@@ -1238,6 +1240,8 @@ export class Online {
|
||||
return this.snapshot()
|
||||
}
|
||||
if (this.busy) throw new Error('Подождите завершения текущего действия')
|
||||
if (data.action === 'clear-games' && (this.state.preparation.active || this.resultsBusy || this.planBusy > 0))
|
||||
throw new Error('Подождите завершения подготовки материалов и результатов')
|
||||
const isBackgroundRefresh = data.action === 'playlists'
|
||||
this.state.busy = true
|
||||
const messages: Record<OnlineIntent['action'], string> = {
|
||||
@@ -1263,6 +1267,7 @@ export class Online {
|
||||
check: 'Проверка материалов игры…',
|
||||
'retry-materials': 'Подготовка материалов игры…',
|
||||
remove: 'Удаление локальных материалов…',
|
||||
'clear-games': 'Удаление всех сохранённых игр…',
|
||||
sound: 'Изменение назначения звука…',
|
||||
results: 'Загрузка результатов игры…',
|
||||
cancel: 'Отмена загрузки…',
|
||||
@@ -1453,6 +1458,36 @@ export class Online {
|
||||
this.state.packagesRevision++
|
||||
return
|
||||
}
|
||||
case 'clear-games': {
|
||||
if (this.controller.state.outputOpen) throw new Error('Закройте показ перед очисткой игр')
|
||||
this.clearingGames = true
|
||||
this.detach()
|
||||
this.controller.selectPackage(null)
|
||||
this.controller.setResults(null)
|
||||
this.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0 }
|
||||
this.cachedPlan = ''
|
||||
this.planNotice = ''
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
this.materialRefresh, this.materialWork, this.mainWork, this.musicWork, this.planWrites,
|
||||
])
|
||||
await this.saveSelection()
|
||||
await this.persistSecrets()
|
||||
await this.packages.clear()
|
||||
for (const name of await readdir(this.directory)) {
|
||||
if (/^(plan|results)-[a-f0-9]{64}\.json(?:\.tmp)?$/.test(name))
|
||||
await rm(join(this.directory, name), { force: true })
|
||||
}
|
||||
this.state.notice = 'Все сохранённые игры удалены'
|
||||
} catch {
|
||||
await this.packages.initialize().catch(() => {})
|
||||
throw new Error('Очистка не завершена. Повторите удаление оставшихся данных')
|
||||
} finally {
|
||||
this.clearingGames = false
|
||||
this.state.packagesRevision++
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'retry-materials':
|
||||
this.lastMaterials = -Infinity
|
||||
await this.refreshMaterials()
|
||||
|
||||
@@ -70,6 +70,29 @@ async function fixture(t: TestContext) {
|
||||
return { root, dir, state, calls, delays, dependencies, store, pkg, key }
|
||||
}
|
||||
|
||||
test('полная очистка удаляет пакеты, частичный кеш, повреждённые остатки и ссылки, сохраняя внешние файлы', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.store.download(f.pkg)
|
||||
const partial = description('https://partial.test')
|
||||
await f.store.remember(partial)
|
||||
await f.store.ensureAsset(partial, partial.assets[0]!.id, new AbortController().signal)
|
||||
const broken = join(f.root, 'a'.repeat(64))
|
||||
await mkdir(broken)
|
||||
await writeFile(join(broken, 'current.json'), 'broken')
|
||||
const outside = join(f.dir, 'outside')
|
||||
await mkdir(outside)
|
||||
await writeFile(join(outside, 'keep'), 'keep')
|
||||
await symlink(outside, join(f.root, 'b'.repeat(64)))
|
||||
await f.store.clear()
|
||||
assert.deepEqual(f.store.list(), [])
|
||||
assert.deepEqual(await readdir(f.root), [])
|
||||
assert.equal(await readFile(join(outside, 'keep'), 'utf8'), 'keep')
|
||||
await assert.rejects(f.store.localFile(f.key, f.pkg.assets[0]!.id))
|
||||
await f.store.initialize()
|
||||
assert.deepEqual(f.store.list(), [])
|
||||
await f.store.clear()
|
||||
})
|
||||
|
||||
test('запуск без сети проверяет отдельный кеш всех игр и сообщает прогресс', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const other = description('https://other.test')
|
||||
|
||||
@@ -304,14 +304,29 @@ export class PackageStore {
|
||||
await this.exclusive(async () => {
|
||||
const dir = await this.path(key)
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
this.ready.delete(key)
|
||||
this.descriptions.delete(key)
|
||||
for (const token of this.checked.keys())
|
||||
if (token.startsWith(`${key}/`)) this.checked.delete(token)
|
||||
for (const token of this.repaired)
|
||||
if (token.startsWith(`${key}/`)) this.repaired.delete(token)
|
||||
for (const token of this.assetErrors.keys())
|
||||
if (token.startsWith(`${key}/`)) this.assetErrors.delete(token)
|
||||
this.forget(key)
|
||||
})
|
||||
}
|
||||
private forget(key: string) {
|
||||
this.ready.delete(key)
|
||||
this.descriptions.delete(key)
|
||||
for (const token of this.checked.keys())
|
||||
if (token.startsWith(`${key}/`)) this.checked.delete(token)
|
||||
for (const token of this.repaired)
|
||||
if (token.startsWith(`${key}/`)) this.repaired.delete(token)
|
||||
for (const token of this.assetErrors.keys())
|
||||
if (token.startsWith(`${key}/`)) this.assetErrors.delete(token)
|
||||
}
|
||||
async clear(): Promise<void> {
|
||||
await this.exclusive(async () => {
|
||||
await Promise.allSettled([...this.loading.values(), ...this.remembering.values()])
|
||||
const root = await this.path()
|
||||
for (const key of await readdir(root)) {
|
||||
if (!hex.test(key)) continue
|
||||
// Удаляется сама ссылка, без перехода к её цели.
|
||||
await rm(join(root, key), { recursive: true, force: true })
|
||||
this.forget(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
private descriptionPackage(pkg: ScreenPackage): LocalPackage {
|
||||
|
||||
+30
-1
@@ -219,6 +219,25 @@ app.whenReady().then(async () => {
|
||||
(await evaluate('!!window.screenBridge && !!document.querySelector("main.control")')),
|
||||
'control UI',
|
||||
)
|
||||
if (process.argv.includes('--clear-games')) {
|
||||
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
|
||||
await poll(() => evaluate('!!document.querySelector("[data-testid=clear-games]:not([disabled])")'), 'clear games button')
|
||||
const clickDialog = (label) => evaluate(`Array.from(document.querySelectorAll('.n-dialog__action button')).find(b => b.textContent.trim() === ${JSON.stringify(label)}).click()`)
|
||||
await evaluate('document.querySelector("[data-testid=clear-games]").click()')
|
||||
await poll(() => evaluate('!!document.querySelector(".n-dialog")'), 'clear games confirmation')
|
||||
await clickDialog('Отмена')
|
||||
assert.equal((await evaluate('window.screenBridge.packages()')).length, 1)
|
||||
await poll(() => evaluate('!document.querySelector(".n-dialog")'), 'clear games cancelled')
|
||||
await evaluate('document.querySelector("[data-testid=clear-games]").click()')
|
||||
await poll(() => evaluate('!!document.querySelector(".n-dialog")'), 'clear games confirmation again')
|
||||
await clickDialog('Удалить всё')
|
||||
await poll(async () => (await evaluate('window.screenBridge.packages()')).length === 0, 'packages cleared')
|
||||
await poll(() => evaluate('document.body.textContent.includes("Скачанных игр пока нет")'), 'empty local games UI')
|
||||
assert.equal((await evaluate('window.screenBridge.state()')).catalog.key, null)
|
||||
console.log('SCREEN_CLEAR_GAMES_PASS')
|
||||
app.exit(0)
|
||||
return
|
||||
}
|
||||
if (manual) {
|
||||
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
|
||||
console.log(
|
||||
@@ -293,7 +312,17 @@ app.whenReady().then(async () => {
|
||||
await command({ action: 'pause', channel: 'main' })
|
||||
assert.equal(await evaluate('document.body.textContent.includes("Материалы показа")'), false)
|
||||
assert.equal(await evaluate('document.body.textContent.includes("Вступление и видео")'), false)
|
||||
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
|
||||
await assert.rejects(evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`))
|
||||
await evaluate('window.screenBridge.closeOutput()')
|
||||
const localPackages = await evaluate('window.screenBridge.packages()')
|
||||
assert.equal(localPackages[0].downloadedFiles, localPackages[0].totalFiles)
|
||||
await poll(() => evaluate('!!document.querySelector("[data-testid=local-game-select] .n-base-selection")'), 'local game selector')
|
||||
await evaluate('document.querySelector("[data-testid=local-game-select] .n-base-selection").click()')
|
||||
await poll(() => evaluate('!!document.querySelector(".n-base-select-option")'), 'local game option')
|
||||
await evaluate('document.querySelector(".n-base-select-option").click()')
|
||||
await evaluate('document.querySelector("[data-testid=local-game-confirm]").click()')
|
||||
await poll(async () => (await state()).catalog.key === offlinePackage.key, 'local game selected')
|
||||
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
|
||||
await poll(async () => {
|
||||
const s = await state()
|
||||
return (
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
NTag,
|
||||
NTable,
|
||||
useMessage,
|
||||
useDialog,
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { PackageSummary } from '../types/screen'
|
||||
import ScreenConnectionPanel from './ScreenConnectionPanel.vue'
|
||||
import GameControlPanel from './GameControlPanel.vue'
|
||||
import {
|
||||
@@ -29,10 +31,50 @@ const props = defineProps<{
|
||||
selectedKey: string | null
|
||||
selectedName: string | null
|
||||
canConnectCurrent: boolean
|
||||
outputOpen: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: []; resetGame: [] }>()
|
||||
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: []; resetGame: []; selectLocal: [key: string] }>()
|
||||
const bridge = window.screenBridge
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const cannotClearGames = computed(() => props.isBusy || props.state.busy || props.outputOpen || props.state.preparation.active)
|
||||
function clearGames() {
|
||||
if (cannotClearGames.value) return
|
||||
dialog.warning({
|
||||
title: 'Удалить все скачанные игры?',
|
||||
content: 'Удалить все сохранённые игры, материалы, локальные планы экранов и снимки результатов? Для повторной загрузки потребуется сеть. Данные на сервере сохранятся.',
|
||||
positiveText: 'Удалить всё',
|
||||
negativeText: 'Отмена',
|
||||
onPositiveClick: () => {
|
||||
if (!cannotClearGames.value) run({ action: 'clear-games' })
|
||||
},
|
||||
})
|
||||
}
|
||||
const localPackages = ref<PackageSummary[]>([])
|
||||
const localKey = ref<string | null>(null)
|
||||
const localPackagesLoading = ref(false)
|
||||
const localPackagesError = ref('')
|
||||
const localOptions = computed(() => localPackages.value.map((pkg) => ({
|
||||
label: `${pkg.name} · скачано ${pkg.downloadedFiles} из ${pkg.totalFiles}`,
|
||||
value: pkg.key,
|
||||
})))
|
||||
watch(
|
||||
[() => props.isReady, () => props.state.packagesRevision, () => props.state.busy, () => props.selectedKey],
|
||||
async () => {
|
||||
if (!bridge || !props.isReady || props.state.busy) return
|
||||
localPackagesLoading.value = true
|
||||
localPackagesError.value = ''
|
||||
try {
|
||||
localPackages.value = await bridge.packages()
|
||||
if (!localPackages.value.some((pkg) => pkg.key === localKey.value)) localKey.value = null
|
||||
} catch (error) {
|
||||
localPackagesError.value = error instanceof Error ? error.message : 'Не удалось прочитать скачанные игры'
|
||||
} finally {
|
||||
localPackagesLoading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
async function openMaterialsFolder() {
|
||||
if (!bridge || !props.selectedKey) return
|
||||
try {
|
||||
@@ -223,8 +265,34 @@ const gameOptions = computed(() => {
|
||||
<div class="connection-content">
|
||||
<div v-if="selectedKey" class="current-game">
|
||||
<span data-testid="control-game">Текущая игра: {{ selectedName }}</span>
|
||||
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')">Сменить игру</n-button>
|
||||
<n-button :disabled="isBusy || state.busy || outputOpen" @click="emit('resetGame')">Сменить игру</n-button>
|
||||
</div>
|
||||
<template v-if="!selectedKey">
|
||||
<span>Скачанные игры</span>
|
||||
<n-alert v-if="localPackagesError" type="warning">{{ localPackagesError }}</n-alert>
|
||||
<div class="game-selection">
|
||||
<n-select
|
||||
v-model:value="localKey"
|
||||
data-testid="local-game-select"
|
||||
class="game-select"
|
||||
clearable
|
||||
:options="localOptions"
|
||||
:loading="localPackagesLoading"
|
||||
:disabled="isBusy || state.busy || outputOpen || localPackagesLoading"
|
||||
placeholder="Выберите скачанную игру"
|
||||
:input-props="{ 'aria-label': 'Скачанные игры' }"
|
||||
/>
|
||||
<n-button
|
||||
data-testid="local-game-confirm"
|
||||
:disabled="!localKey || isBusy || state.busy || outputOpen || localPackagesLoading"
|
||||
@click="emit('selectLocal', localKey!)"
|
||||
>Выбрать скачанную игру</n-button>
|
||||
</div>
|
||||
<span v-if="!localPackagesLoading && !localPackagesError && !localPackages.length">Скачанных игр пока нет</span>
|
||||
</template>
|
||||
<p v-if="(selectedKey && loadedMaterials < state.materials.length) || (!selectedKey && localPackages.some((pkg) => pkg.key === localKey && pkg.downloadedFiles < pkg.totalFiles))">
|
||||
Без сети доступны только скачанные материалы.
|
||||
</p>
|
||||
<template v-if="state.authenticated">
|
||||
<div v-if="!selectedKey" class="game-selection">
|
||||
<n-select
|
||||
@@ -256,14 +324,12 @@ const gameOptions = computed(() => {
|
||||
<path d="M6.1 6.1A8 8 0 0 1 20 12M4 12a8 8 0 0 0 13.9 5.9" />
|
||||
</svg>
|
||||
</n-button>
|
||||
<n-button
|
||||
:disabled="gameId === null || isBusy || state.busy"
|
||||
@click="run({ action: 'select-game', gameId: gameId! })"
|
||||
>Выбрать игру</n-button
|
||||
>
|
||||
</div>
|
||||
<n-button
|
||||
v-if="!selectedKey"
|
||||
class="game-confirm"
|
||||
:disabled="gameId === null || isBusy || state.busy"
|
||||
@click="run({ action: 'select-game', gameId: gameId! })"
|
||||
>Выбрать игру</n-button
|
||||
>
|
||||
</template>
|
||||
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
|
||||
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
|
||||
@@ -389,6 +455,15 @@ const gameOptions = computed(() => {
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
|
||||
<n-button
|
||||
class="clear-games"
|
||||
data-testid="clear-games"
|
||||
text
|
||||
type="error"
|
||||
size="small"
|
||||
:disabled="cannotClearGames"
|
||||
@click="clearGames"
|
||||
>Удалить все скачанные игры</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
<GameControlPanel v-if="bridge && isReady" :state="state" :is-busy="isBusy" :selected-key="selectedKey" :selected-name="selectedName" @action="run">
|
||||
@@ -405,6 +480,10 @@ const gameOptions = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.clear-games {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.materials-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -525,10 +604,6 @@ const gameOptions = computed(() => {
|
||||
}
|
||||
|
||||
.connection-content > .auth-action,
|
||||
.connection-content > .game-confirm {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.connection-error {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -198,6 +198,14 @@ async function resetGame() {
|
||||
onlineState.value = await bridge.onlineState()
|
||||
})
|
||||
}
|
||||
async function selectLocalGame(key: string) {
|
||||
if (!bridge || isBusy.value || isOnlineBusy.value || onlineState.value.busy) return
|
||||
await run(async () => {
|
||||
acceptState(await bridge.selectPackage(key))
|
||||
onlineState.value = await bridge.onlineState()
|
||||
await refreshHost()
|
||||
})
|
||||
}
|
||||
async function refreshHost() {
|
||||
if (!bridge) return
|
||||
host.value = await bridge.host()
|
||||
@@ -249,6 +257,8 @@ onUnmounted(() => {
|
||||
@action="runOnline"
|
||||
@cancel="cancelOnline"
|
||||
@reset-game="resetGame"
|
||||
@select-local="selectLocalGame"
|
||||
:output-open="state?.outputOpen ?? false"
|
||||
:selected-key="state?.catalog.key ?? null"
|
||||
:selected-name="state?.catalog.name ?? null"
|
||||
:can-connect-current="
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export type OnlineIntent =
|
||||
| { action: 'login'; server: string; email: string; password: string }
|
||||
| { action: 'retry-materials' }
|
||||
| { action: 'connect'; gameId?: number }
|
||||
| { action: 'logout' | 'games' | 'disconnect' | 'sound' | 'results' | 'cancel' }
|
||||
| { action: 'logout' | 'games' | 'disconnect' | 'sound' | 'results' | 'cancel' | 'clear-games' }
|
||||
| { action: 'select-game' | 'download' | 'check'; gameId: number }
|
||||
| { action: 'remove'; key: string }
|
||||
export interface OnlineState {
|
||||
|
||||
@@ -142,6 +142,8 @@ export interface PackageSummary {
|
||||
key: string
|
||||
version: string
|
||||
name: string
|
||||
downloadedFiles: number
|
||||
totalFiles: number
|
||||
}
|
||||
export interface ScreenPlan {
|
||||
steps: ScreenStep[]
|
||||
|
||||
Reference in New Issue
Block a user