feat: 赛事项目切换 + 记住上次打开的赛事(修「导入赛事包后没数据」)
根因:服务端启动只认 -project(缺省 default),Console 从不传;导入只是把包解到
event_projs/<名字>,没有任何切换入口 → 导入的赛事永远打不开,页面看到的是空的 default。
- 服务端 -project 缺省改为空串;解析顺序:显式 flag(并写回记忆)> <dataDir>/active_project.txt
> default;backup/passwd 口径一致;启动日志打印生效项目与来源
- GET /api/projects(admin)列出各赛事(是否当前/是否有 event.db/大小/更新时间)
- POST /api/projects/activate(admin):校验后原子写记忆文件 → 审计 → 返回
{ok,name,restarting:true} → 触发优雅关闭;Console 对非用户发起的退出会自动拉起,
重启后即打开新赛事(无需改 Console)。本来就是当前赛事则 restarting:false 不重启
- 管理端「导出」页新增赛事项目列表与「切换为当前赛事」;导入成功后提示「已导入为 X,
但还不是当前赛事」并给「立即切换并重启服务端」;切换后轮询 /api/health 等它回来再刷新
- 文档:使用手册(导入≠切换、生效四步)、常见问题(导入后看不到数据)
自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 66/66;console 14 文件通过。
This commit is contained in:
1 parent
005b51b2ec
commit
2d207fd654
14 files changed
+1363
-24
No files matched your search
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatDateTime } from '@/shared'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { formatDateTime, type ProjectInfo } from '@/shared'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
projectApi,
|
||||
resultsExportApi,
|
||||
rulesApi,
|
||||
waitForServerOnline,
|
||||
type ProjectImportResult,
|
||||
type ResultExportKind,
|
||||
type RuleApplyResult,
|
||||
@@ -26,6 +27,9 @@ import { useToastStore } from '../stores/toast'
|
||||
*
|
||||
* 三块互不相干:结果导出是只读下载(Guest 也能用),赛事包与规则包是 admin 级别的
|
||||
* 整库/整规则替换,必须二次确认,且规则导入后服务端会全量重算积分。
|
||||
*
|
||||
* 赛事包导入只是把目录解到数据目录里,服务端只打开「当前赛事」那一项,
|
||||
* 因此导入完成后还要在项目列表里切换,页面才看得到导入的数据。
|
||||
*/
|
||||
|
||||
type TabKey = 'results' | 'project' | 'rules'
|
||||
@@ -86,6 +90,16 @@ const confirmProjectImport = ref(false)
|
||||
const projectResult = ref<ProjectImportResult | null>(null)
|
||||
const projectError = ref<string | null>(null)
|
||||
|
||||
// 服务端一次只打开一个赛事项目(其余目录只是躺在数据目录里的数据),
|
||||
// 导入不会改变「当前赛事」,所以这里列出全部项目供切换。
|
||||
const projects = ref<ProjectInfo[]>([])
|
||||
const projectsLoading = ref(false)
|
||||
const projectsError = ref<string | null>(null)
|
||||
const switchTarget = ref<string | null>(null)
|
||||
const switchError = ref<string | null>(null)
|
||||
/** 已触发重启、正在等服务端回来;期间所有操作都要挡住。 */
|
||||
const restarting = ref(false)
|
||||
|
||||
// ── 规则包 ──────────────────────────────────────────────────────────────────
|
||||
const rulesFileInput = ref<HTMLInputElement | null>(null)
|
||||
const rulesFileName = ref('')
|
||||
@@ -101,6 +115,18 @@ const confirmRulesApply = ref(false)
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||
|
||||
const activeProjectName = computed(() => projects.value.find((item) => item.active)?.name ?? null)
|
||||
|
||||
/**
|
||||
* 刚导入的赛事还不是当前赛事时,页面上的数据仍旧来自原来的当前赛事,需要就地给出切换入口。
|
||||
* 列表读不到时也按「还不是当前」处理:宁可多提示一次,也不要谎报已生效。
|
||||
*/
|
||||
const importedNotActive = computed(() => {
|
||||
const imported = projectResult.value
|
||||
if (imported === null) return null
|
||||
return activeProjectName.value === imported.name ? null : imported
|
||||
})
|
||||
|
||||
const CHANGE_LABEL: Record<string, string> = {
|
||||
added: '新增',
|
||||
removed: '删除',
|
||||
@@ -206,6 +232,8 @@ async function runProjectImport(): Promise<void> {
|
||||
toast.success(`已导入「${result.data.name}」`, result.mock ? '示例数据:未真正解压落盘' : null)
|
||||
projectFile.value = null
|
||||
if (projectFileInput.value !== null) projectFileInput.value.value = ''
|
||||
// 新导入的项目要出现在列表里,用户才能就地切成当前赛事。
|
||||
void loadProjects()
|
||||
} catch (caught) {
|
||||
projectError.value = describeError(caught)
|
||||
} finally {
|
||||
@@ -213,6 +241,81 @@ async function runProjectImport(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
async function loadProjects(): Promise<void> {
|
||||
if (!isAdmin.value) return
|
||||
projectsLoading.value = true
|
||||
projectsError.value = null
|
||||
try {
|
||||
projects.value = await adminApi.client.projects.list()
|
||||
} catch (caught) {
|
||||
projects.value = []
|
||||
projectsError.value = describeError(caught)
|
||||
} finally {
|
||||
projectsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 页面挂载时 AdminLayout 还在探测会话,isAdmin 之后才变 true,所以要跟着它再拉一次列表。
|
||||
watch(
|
||||
isAdmin,
|
||||
(value) => {
|
||||
if (value) void loadProjects()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* 切换当前赛事。
|
||||
*
|
||||
* 服务端会把项目名写进记忆文件后优雅退出,由 Console 自动重启服务端并打开新赛事;
|
||||
* 这段时间页面必须轮询健康检查等它回来,不能像普通写操作那样立刻认为已经生效。
|
||||
*/
|
||||
async function activateProject(name: string): Promise<void> {
|
||||
if (!isAdmin.value) {
|
||||
toast.warning('只有管理员可以切换当前赛事')
|
||||
return
|
||||
}
|
||||
if (restarting.value || switchTarget.value !== null) return
|
||||
switchTarget.value = name
|
||||
switchError.value = null
|
||||
try {
|
||||
const result = await adminApi.client.projects.activate(name)
|
||||
if (!result.restarting) {
|
||||
// 本来就是当前赛事,服务端不会退出,直接刷新拿最新数据。
|
||||
toast.success(`「${result.name}」已是当前赛事`)
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
restarting.value = true
|
||||
toast.info('正在重启服务端…', `正在切换到「${result.name}」,请勿关闭服务管理窗口`)
|
||||
const online = await waitForServerOnline({
|
||||
probe: () => adminApi.client.health(),
|
||||
waitForDown: true,
|
||||
})
|
||||
if (online) {
|
||||
toast.success('服务端已恢复,正在刷新页面')
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
restarting.value = false
|
||||
switchError.value =
|
||||
'等待服务端重启超时(60 秒)。请打开服务管理窗口确认服务端状态,待其「运行中」后手动刷新本页。'
|
||||
toast.error('服务端重启未在 60 秒内完成', '请在服务管理窗口查看服务端状态')
|
||||
void loadProjects()
|
||||
} catch (caught) {
|
||||
switchError.value = describeError(caught)
|
||||
} finally {
|
||||
switchTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRules(): Promise<void> {
|
||||
rulesExporting.value = true
|
||||
rulesError.value = null
|
||||
@@ -377,6 +480,84 @@ async function applyRules(): Promise<void> {
|
||||
|
||||
<!-- 赛事项目包 -->
|
||||
<div v-else-if="activeTab === 'project'" class="project-grid">
|
||||
<section class="mx-card wide">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">赛事项目列表</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!isAdmin || projectsLoading || restarting"
|
||||
@click="loadProjects"
|
||||
>
|
||||
<MxIcon name="refresh" :size="14" /> {{ projectsLoading ? '读取中…' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<p class="hint">
|
||||
服务端一次只打开一个赛事项目,页面上看到的数据全部来自标记为「当前」的那一项。
|
||||
导入项目包只是把数据解到数据目录,必须切换为当前赛事才会生效。
|
||||
</p>
|
||||
<p v-if="!isAdmin" class="notice">切换当前赛事会让服务端重启,只有管理员可操作。</p>
|
||||
|
||||
<p v-if="restarting" class="notice warn">
|
||||
正在重启服务端…切换赛事需要服务端退出后由服务管理窗口自动拉起,请勿关闭该窗口(最多等待 60 秒)。
|
||||
</p>
|
||||
<p v-if="switchError !== null" class="notice error">{{ switchError }}</p>
|
||||
<p v-if="projectsError !== null" class="notice error">读取赛事项目列表失败:{{ projectsError }}</p>
|
||||
|
||||
<div v-if="projects.length === 0 && projectsError === null" class="mx-empty">
|
||||
<div class="mx-empty-title">
|
||||
{{
|
||||
!isAdmin
|
||||
? '赛事项目列表仅管理员可见'
|
||||
: projectsLoading
|
||||
? '正在读取赛事项目…'
|
||||
: '没有可切换的赛事项目'
|
||||
}}
|
||||
</div>
|
||||
<div v-if="isAdmin && !projectsLoading" class="mx-empty-desc">
|
||||
导入一个赛事项目包后,它会出现在这个列表里
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="projects.length > 0" class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目名</th>
|
||||
<th>状态</th>
|
||||
<th>更新时间</th>
|
||||
<th>大小</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in projects" :key="item.name">
|
||||
<td class="mono">{{ item.name }}</td>
|
||||
<td>
|
||||
<span v-if="item.active" class="mx-badge mx-badge-success">当前</span>
|
||||
<span v-else-if="!item.hasEvent" class="mx-badge mx-badge-error">缺 event.db</span>
|
||||
<span v-else class="mx-badge mx-badge-neutral">未打开</span>
|
||||
</td>
|
||||
<td>{{ formatDateTime(item.updatedAt) }}</td>
|
||||
<td>{{ formatBytes(item.sizeBytes) }}</td>
|
||||
<td>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!isAdmin || item.active || !item.hasEvent || restarting || switchTarget !== null"
|
||||
@click="activateProject(item.name)"
|
||||
>
|
||||
{{ switchTarget === item.name ? '切换中…' : '切换为当前赛事' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">导出项目包</span>
|
||||
@@ -388,7 +569,12 @@ async function applyRules(): Promise<void> {
|
||||
<code class="mono">uploads/</code> + <code class="mono">rules.yaml</code>(字体可选)。
|
||||
整包拷走即可在另一台机器复现整场赛事。
|
||||
</p>
|
||||
<button class="mx-btn mx-btn-primary full" type="button" :disabled="projectExporting" @click="exportProject">
|
||||
<button
|
||||
class="mx-btn mx-btn-primary full"
|
||||
type="button"
|
||||
:disabled="projectExporting || restarting"
|
||||
@click="exportProject"
|
||||
>
|
||||
<MxIcon name="export" :size="15" /> {{ projectExporting ? '打包中…' : '导出 zip' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -442,7 +628,7 @@ async function applyRules(): Promise<void> {
|
||||
<button
|
||||
class="mx-btn mx-btn-primary full"
|
||||
type="button"
|
||||
:disabled="!isAdmin || projectImporting || projectFile === null"
|
||||
:disabled="!isAdmin || projectImporting || projectFile === null || restarting"
|
||||
@click="requestProjectImport"
|
||||
>
|
||||
{{ projectImporting ? '导入中…' : '导入' }}
|
||||
@@ -455,7 +641,28 @@ async function applyRules(): Promise<void> {
|
||||
<div><dt>落盘目录</dt><dd class="mono">{{ projectResult.dir }}</dd></div>
|
||||
<div><dt>数据库</dt><dd class="mono">{{ projectResult.eventDB }}</dd></div>
|
||||
</dl>
|
||||
<p class="hint">如需立即切换到这个赛事,请到「系统 · 赛事信息」确认配置后重启服务端。</p>
|
||||
|
||||
<p v-if="importedNotActive === null" class="hint">
|
||||
「{{ projectResult.name }}」已经是当前赛事,直接刷新即可看到数据。
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="notice warn">
|
||||
已导入为「{{ projectResult.name }}」,但它还不是当前赛事:服务端当前打开的是「{{
|
||||
activeProjectName ?? 'default'
|
||||
}}」,页面上看到的是那一项的数据。
|
||||
</p>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary full"
|
||||
type="button"
|
||||
:disabled="!isAdmin || restarting || switchTarget !== null"
|
||||
@click="activateProject(projectResult.name)"
|
||||
>
|
||||
<MxIcon name="refresh" :size="15" />
|
||||
{{ switchTarget === projectResult.name ? '切换中…' : '立即切换并重启服务端' }}
|
||||
</button>
|
||||
<p class="hint">切换会让服务端退出并由服务管理窗口自动拉起,期间页面会短暂连不上。</p>
|
||||
</template>
|
||||
<p v-if="!isAdmin" class="notice">只有管理员可以切换当前赛事。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -665,6 +872,10 @@ async function applyRules(): Promise<void> {
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
/* 项目列表与导出 / 导入卡片不同,是整行的总览表。 */
|
||||
.project-grid > .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in new issue
Block a user