feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入
This commit is contained in:
1 parent
f3813171d3
commit
72462792ec
39 files changed
+10001
-28
No files matched your search
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { formatDateTime } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import MockBadge from './MockBadge.vue'
|
||||
import { backupsApi, type BackupItem } from './systemApi'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
|
||||
/**
|
||||
* 备份(devdocs/08 第 6、8 节)。
|
||||
*
|
||||
* 自动备份每 5 分钟一次、发布前再备一次,滚动保留 20 份;
|
||||
* 但服务端目前只提供「列出 + 立即备份」,没有恢复接口,
|
||||
* 所以恢复只能按流程手工执行,页面把它写成可照做的步骤而不是假装有按钮。
|
||||
*/
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const rows = ref<BackupItem[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
const usingMock = ref(false)
|
||||
const backingUp = ref(false)
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||
const latest = computed<BackupItem | null>(() => rows.value[0] ?? null)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await backupsApi.list()
|
||||
rows.value = result.data
|
||||
usingMock.value = result.mock
|
||||
} catch (caught) {
|
||||
loadError.value = caught instanceof Error ? caught.message : '加载备份列表失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup(): Promise<void> {
|
||||
backingUp.value = true
|
||||
try {
|
||||
const result = await backupsApi.create()
|
||||
usingMock.value = usingMock.value || result.mock
|
||||
toast.success('已生成备份', result.data.name)
|
||||
await load()
|
||||
} catch (caught) {
|
||||
toast.error('备份失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
backingUp.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes <= 0) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="backup-grid">
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">备份列表</span>
|
||||
<div class="head-actions">
|
||||
<MockBadge v-if="usingMock" />
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="loading" @click="load">
|
||||
<MxIcon name="refresh" :size="14" /> 刷新
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!isAdmin || backingUp"
|
||||
@click="createBackup"
|
||||
>
|
||||
<MxIcon name="database" :size="14" /> {{ backingUp ? '备份中…' : '立即备份' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-card-body">
|
||||
<p v-if="!isAdmin" class="notice">只有管理员能查看与创建备份。</p>
|
||||
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
|
||||
<p v-if="latest !== null" class="latest">
|
||||
最近一次备份:{{ formatDateTime(latest.createdAt) }}
|
||||
<span class="muted">({{ latest.reason === 'manual' ? '手动' : '自动' }})</span>
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="state-block"><span class="mx-activity-dot"></span> 正在读取备份目录…</div>
|
||||
|
||||
<div v-else-if="rows.length === 0" class="mx-empty">
|
||||
<div class="mx-empty-title">还没有备份</div>
|
||||
<div class="mx-empty-desc">服务端每 5 分钟自动备份一次,也可以点「立即备份」马上生成一份</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名</th>
|
||||
<th>来源</th>
|
||||
<th class="right">大小</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.name">
|
||||
<td class="mono name">{{ row.name }}</td>
|
||||
<td>
|
||||
<span class="mx-badge" :class="row.reason === 'manual' ? 'mx-badge-info' : 'mx-badge-neutral'">
|
||||
{{ row.reason === 'manual' ? '手动' : '自动' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="right mono">{{ formatSize(row.size) }}</td>
|
||||
<td class="mono muted">{{ formatDateTime(row.createdAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head"><span class="mx-card-title">恢复说明</span></div>
|
||||
<div class="mx-card-body">
|
||||
<p class="notice">
|
||||
备份文件存放在 <code class="mono">data/backups/</code>,滚动保留最近 20 份,不随 git 提交。
|
||||
服务端暂未提供一键恢复接口,恢复需在停机状态下手工执行:
|
||||
</p>
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<strong>停服务</strong>
|
||||
<span>先停掉后端进程(systemd:<code class="mono">systemctl stop scoreboard</code>),避免恢复过程中继续写入。</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>留存现场</strong>
|
||||
<span>把当前的 <code class="mono">event.db</code> 改名备份,确认恢复失败还能退回。</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>替换数据库</strong>
|
||||
<span>选一份备份文件复制为 <code class="mono">event.db</code>,同时删除 <code class="mono">event.db-wal</code> 与 <code class="mono">event.db-shm</code>。</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>重启并核对</strong>
|
||||
<span>启动服务后检查积分榜与最近一次发布记录,确认数据时间点符合预期。</span>
|
||||
</li>
|
||||
</ol>
|
||||
<p class="notice warn">
|
||||
恢复会丢弃备份时间点之后的所有改动(自动备份间隔 5 分钟)。现场先改 IP 切换备机、再考虑恢复,
|
||||
通常比原地恢复更快。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mx-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
.name {
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.muted {
|
||||
color: var(--mx-t3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.latest {
|
||||
font-size: 13px;
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
.notice {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
line-height: 1.7;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--mx-r-xs);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.notice.error {
|
||||
color: var(--mx-error);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
.notice.warn {
|
||||
color: var(--mx-amber);
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
}
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
.steps li {
|
||||
list-style: decimal;
|
||||
}
|
||||
.steps strong {
|
||||
display: block;
|
||||
color: var(--mx-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.steps span {
|
||||
color: var(--mx-t3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.state-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--mx-t3);
|
||||
padding: 18px 0;
|
||||
}
|
||||
@media (max-width: 1279px) {
|
||||
.backup-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { EventSettings } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import MockBadge from './MockBadge.vue'
|
||||
import { settingsApi, type EventSettingsPatch } from './systemApi'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useEventStore } from '../../stores/event'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
|
||||
/**
|
||||
* 赛事信息(GET/PUT /api/settings)。
|
||||
*
|
||||
* 数据源直接用 event store 的 bootstrap 结果:它已经带降级逻辑,
|
||||
* 这里再单独拉一次只会让「示例数据」的标记前后不一致。
|
||||
* 保存时提交完整子对象而不是字段级 patch,避免服务端把「没传」误判成「清空」。
|
||||
*/
|
||||
|
||||
const auth = useAuthStore()
|
||||
const data = useEventStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const form = ref<EventSettings | null>(null)
|
||||
const saving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const usingMock = ref(false)
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||
const dirty = computed(() => {
|
||||
if (form.value === null || data.settings === null) return false
|
||||
return JSON.stringify(form.value) !== JSON.stringify(data.settings)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => data.settings,
|
||||
(value) => {
|
||||
// 只在服务端数据变化时重置表单;本地编辑不被覆盖。
|
||||
if (value !== null && (form.value === null || !dirty.value)) form.value = clone(value)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function clone(value: EventSettings): EventSettings {
|
||||
return JSON.parse(JSON.stringify(value)) as EventSettings
|
||||
}
|
||||
|
||||
/** 可选文本:空串在接口里等于「没有」,必须转成 null,否则大屏会显示空白标题。 */
|
||||
function optional(value: string | null): string | null {
|
||||
const trimmed = (value ?? '').trim()
|
||||
return trimmed === '' ? null : trimmed
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
if (data.settings !== null) form.value = clone(data.settings)
|
||||
saveError.value = null
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const current = data.settings
|
||||
const next = form.value
|
||||
if (current === null || next === null) return
|
||||
if (next.event.name.trim() === '') {
|
||||
saveError.value = '赛事名称不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
const patch: EventSettingsPatch = {
|
||||
event: {
|
||||
...next.event,
|
||||
name: next.event.name.trim(),
|
||||
subtitle: optional(next.event.subtitle),
|
||||
host: optional(next.event.host),
|
||||
},
|
||||
display: { ...next.display, title: next.display.title.trim(), subtitle: optional(next.display.subtitle) },
|
||||
standings: { ...next.standings },
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
saveError.value = null
|
||||
try {
|
||||
const result = await settingsApi.save(patch, current)
|
||||
usingMock.value = result.mock
|
||||
data.settings = result.data
|
||||
form.value = clone(result.data)
|
||||
toast.success('赛事信息已保存', result.mock ? '示例数据:改动仅保留在本地' : null)
|
||||
} catch (caught) {
|
||||
saveError.value = caught instanceof Error ? caught.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">赛事信息</span>
|
||||
<div class="head-actions">
|
||||
<MockBadge v-if="usingMock || data.isMock" />
|
||||
<span v-if="dirty" class="mx-badge mx-badge-warning">未保存</span>
|
||||
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="!dirty || saving" @click="reset">
|
||||
放弃修改
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!isAdmin || !dirty || saving"
|
||||
@click="save"
|
||||
>
|
||||
<MxIcon name="check" :size="14" /> {{ saving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-card-body">
|
||||
<p v-if="!isAdmin" class="notice">只有管理员能修改赛事信息,当前账号为只读。</p>
|
||||
<p v-if="saveError !== null" class="notice error">{{ saveError }}</p>
|
||||
|
||||
<div v-if="form === null" class="state-block"><span class="mx-activity-dot"></span> 正在加载赛事信息…</div>
|
||||
|
||||
<template v-else>
|
||||
<h3 class="group-title">基本信息</h3>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label class="mx-label" for="event-name">赛事名称</label>
|
||||
<div class="mx-input-wrap">
|
||||
<input id="event-name" v-model="form.event.name" class="mx-input" placeholder="如:2026 年职工运动会" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="mx-label" for="event-subtitle">副标题</label>
|
||||
<div class="mx-input-wrap">
|
||||
<input id="event-subtitle" v-model="form.event.subtitle" class="mx-input" placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="mx-label" for="event-host">主办方</label>
|
||||
<div class="mx-input-wrap">
|
||||
<input id="event-host" v-model="form.event.host" class="mx-input" placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="group-title">大屏配置</h3>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label class="mx-label" for="display-title">大屏标题</label>
|
||||
<div class="mx-input-wrap">
|
||||
<input id="display-title" v-model="form.display.title" class="mx-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="mx-label" for="display-subtitle">大屏副标题</label>
|
||||
<div class="mx-input-wrap">
|
||||
<input id="display-subtitle" v-model="form.display.subtitle" class="mx-input" placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="mx-label" for="display-rotate">轮播间隔(秒)</label>
|
||||
<div class="mx-input-wrap">
|
||||
<input
|
||||
id="display-rotate"
|
||||
v-model.number="form.display.rotateSeconds"
|
||||
class="mx-input"
|
||||
type="number"
|
||||
min="5"
|
||||
max="120"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="mx-label" for="display-color">主题色</label>
|
||||
<div class="mx-input-wrap">
|
||||
<input id="display-color" v-model="form.display.primaryColor" class="mx-input mono" placeholder="#3482FF" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field switch-field">
|
||||
<span class="mx-label">个人积分榜上屏</span>
|
||||
<span class="switch-row">
|
||||
<label class="mx-switch">
|
||||
<input v-model="form.standings.showIndividualBoard" type="checkbox" />
|
||||
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
|
||||
</label>
|
||||
<span class="switch-text">{{ form.standings.showIndividualBoard ? '展示' : '隐藏' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="notice">
|
||||
修改大屏标题、轮播间隔或主题色会立即影响所有在线大屏,保存前请确认现场没有正在进行的颁奖或播报。
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mx-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.group-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--mx-t2);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--mx-border);
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.switch-field {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
}
|
||||
.switch-text {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.notice {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
line-height: 1.7;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--mx-r-xs);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.notice.error {
|
||||
color: var(--mx-error);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
.state-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--mx-t3);
|
||||
padding: 18px 0;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 审计日志的 before / after 对比。
|
||||
*
|
||||
* 库里存的是 JSON 文本,字段顺序和缩进都不稳定,逐行 diff 会满屏噪声;
|
||||
* 所以先解析成「路径 → 值」再按路径比对,一行一个字段,改了什么一眼可见。
|
||||
* 解析失败(历史脏数据)时退回原文对照,不能因为一条坏记录就整页报错。
|
||||
*/
|
||||
const props = defineProps<{ before: string | null; after: string | null }>()
|
||||
|
||||
type ChangeKind = 'added' | 'removed' | 'modified' | 'unchanged'
|
||||
|
||||
interface DiffRow {
|
||||
path: string
|
||||
before: string
|
||||
after: string
|
||||
change: ChangeKind
|
||||
}
|
||||
|
||||
const showUnchanged = ref(false)
|
||||
|
||||
interface Parsed {
|
||||
ok: boolean
|
||||
value: unknown
|
||||
}
|
||||
|
||||
/** 空文本按 null 处理(新增/删除操作的另一侧就是空的),只有非法 JSON 才算解析失败。 */
|
||||
function parseJson(text: string | null): Parsed {
|
||||
if (text === null || text.trim() === '') return { ok: true, value: null }
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(text) as unknown }
|
||||
} catch {
|
||||
return { ok: false, value: null }
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = computed(() => ({
|
||||
before: parseJson(props.before),
|
||||
after: parseJson(props.after),
|
||||
}))
|
||||
|
||||
const parseFailed = computed(() => !parsed.value.before.ok || !parsed.value.after.ok)
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function formatLeaf(value: unknown): string {
|
||||
if (value === null) return 'null'
|
||||
if (typeof value === 'string') return value
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function flatten(value: unknown, prefix: string, out: Map<string, string>): void {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
out.set(prefix === '' ? '(根)' : prefix, '[]')
|
||||
return
|
||||
}
|
||||
value.forEach((item, index) => flatten(item, `${prefix}[${index}]`, out))
|
||||
return
|
||||
}
|
||||
if (isPlainRecord(value)) {
|
||||
const keys = Object.keys(value)
|
||||
if (keys.length === 0) {
|
||||
out.set(prefix === '' ? '(根)' : prefix, '{}')
|
||||
return
|
||||
}
|
||||
for (const key of keys) {
|
||||
flatten(value[key], prefix === '' ? key : `${prefix}.${key}`, out)
|
||||
}
|
||||
return
|
||||
}
|
||||
out.set(prefix === '' ? '(根)' : prefix, formatLeaf(value))
|
||||
}
|
||||
|
||||
const rows = computed<DiffRow[]>(() => {
|
||||
const { before, after } = parsed.value
|
||||
if (!before.ok || !after.ok) return []
|
||||
|
||||
const beforeMap = new Map<string, string>()
|
||||
const afterMap = new Map<string, string>()
|
||||
flatten(before.value, '', beforeMap)
|
||||
flatten(after.value, '', afterMap)
|
||||
|
||||
const paths = [...new Set([...beforeMap.keys(), ...afterMap.keys()])].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
)
|
||||
|
||||
return paths.map((path) => {
|
||||
const hasBefore = beforeMap.has(path)
|
||||
const hasAfter = afterMap.has(path)
|
||||
const beforeText = beforeMap.get(path) ?? '—'
|
||||
const afterText = afterMap.get(path) ?? '—'
|
||||
let change: ChangeKind = 'unchanged'
|
||||
if (!hasBefore) change = 'added'
|
||||
else if (!hasAfter) change = 'removed'
|
||||
else if (beforeText !== afterText) change = 'modified'
|
||||
return { path, before: beforeText, after: afterText, change }
|
||||
})
|
||||
})
|
||||
|
||||
const visibleRows = computed(() =>
|
||||
showUnchanged.value ? rows.value : rows.value.filter((row) => row.change !== 'unchanged'),
|
||||
)
|
||||
|
||||
const changedCount = computed(() => rows.value.filter((row) => row.change !== 'unchanged').length)
|
||||
|
||||
const CHANGE_LABEL: Record<ChangeKind, string> = {
|
||||
added: '新增',
|
||||
removed: '删除',
|
||||
modified: '修改',
|
||||
unchanged: '未变',
|
||||
}
|
||||
|
||||
const CHANGE_TONE: Record<ChangeKind, string> = {
|
||||
added: 'mx-badge-success',
|
||||
removed: 'mx-badge-error',
|
||||
modified: 'mx-badge-warning',
|
||||
unchanged: 'mx-badge-neutral',
|
||||
}
|
||||
|
||||
function pretty(text: string | null): string {
|
||||
const result = parseJson(text)
|
||||
if (!result.ok) return text ?? '—'
|
||||
return JSON.stringify(result.value, null, 2)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="json-diff">
|
||||
<div class="diff-head">
|
||||
<span class="mx-badge" :class="changedCount > 0 ? 'mx-badge-info' : 'mx-badge-neutral'">
|
||||
{{ changedCount > 0 ? `${changedCount} 处变化` : '无变化' }}
|
||||
</span>
|
||||
<label class="toggle">
|
||||
<input v-model="showUnchanged" type="checkbox" />
|
||||
显示未变化字段
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="parseFailed" class="fallback-note">
|
||||
before / after 不是合法 JSON,下面按原文对照显示。
|
||||
</p>
|
||||
|
||||
<div v-if="parseFailed" class="raw-grid">
|
||||
<div class="raw-col">
|
||||
<div class="raw-title">变更前</div>
|
||||
<pre class="raw mono">{{ before ?? '—' }}</pre>
|
||||
</div>
|
||||
<div class="raw-col">
|
||||
<div class="raw-title">变更后</div>
|
||||
<pre class="raw mono">{{ after ?? '—' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="visibleRows.length === 0" class="mx-empty">
|
||||
<div class="mx-empty-title">字段无变化</div>
|
||||
<div class="mx-empty-desc">该操作只影响记录状态,未修改任何字段值</div>
|
||||
</div>
|
||||
<div v-else class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>字段</th>
|
||||
<th>变更前</th>
|
||||
<th>变更后</th>
|
||||
<th>类型</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in visibleRows" :key="row.path">
|
||||
<td class="path mono">{{ row.path }}</td>
|
||||
<td class="value mono before">{{ row.before }}</td>
|
||||
<td class="value mono after">{{ row.after }}</td>
|
||||
<td>
|
||||
<span class="mx-badge" :class="CHANGE_TONE[row.change]">
|
||||
{{ CHANGE_LABEL[row.change] }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<details class="raw-details">
|
||||
<summary>查看原始 JSON</summary>
|
||||
<div class="raw-grid">
|
||||
<div class="raw-col">
|
||||
<div class="raw-title">变更前</div>
|
||||
<pre class="raw mono">{{ pretty(before) }}</pre>
|
||||
</div>
|
||||
<div class="raw-col">
|
||||
<div class="raw-title">变更后</div>
|
||||
<pre class="raw mono">{{ pretty(after) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.json-diff {
|
||||
padding: 12px 14px;
|
||||
background: var(--mx-s2);
|
||||
border-radius: var(--mx-r-sm);
|
||||
}
|
||||
.diff-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.path {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t2);
|
||||
word-break: break-all;
|
||||
}
|
||||
.value {
|
||||
font-size: 12px;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.before {
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.after {
|
||||
color: var(--mx-green);
|
||||
}
|
||||
.fallback-note {
|
||||
font-size: 12px;
|
||||
color: var(--mx-amber);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.raw-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.raw-title {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.raw {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
background: var(--mx-s3);
|
||||
border-radius: var(--mx-r-xs);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.raw-details summary {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.raw-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 局部「示例数据」标记。
|
||||
*
|
||||
* 全局降级标记在 AdminLayout 的横幅里,但三个页面各面板的降级时机不同,
|
||||
* 面板自带的标记才能说清「哪一块是假的」。
|
||||
*/
|
||||
withDefaults(defineProps<{ text?: string }>(), { text: '示例数据' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="mx-badge mx-badge-warning mock-badge" title="后端未就绪,本区域内容仅用于界面演示">
|
||||
{{ text }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mock-badge {
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,446 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { formatDateTime } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import MockBadge from './MockBadge.vue'
|
||||
import { USER_ROLES, usersApi, type AdminUser, type AdminUserInput } from './systemApi'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
|
||||
/**
|
||||
* 用户管理(devdocs/08 第 2 节)。
|
||||
*
|
||||
* 用户接口尚未进共享 ApiClient,走后端就绪时走 /api/users,未就绪时降级示例数据。
|
||||
* 角色只提供文档里定义的三档,避免现场出现「看着是管理员、其实没权限」的自造角色。
|
||||
*/
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const rows = ref<AdminUser[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
const usingMock = ref(false)
|
||||
|
||||
const editorOpen = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const form = ref<AdminUserInput>({ username: '', displayName: '', role: 'recorder', active: true, password: '' })
|
||||
const saving = ref(false)
|
||||
const formError = ref<string | null>(null)
|
||||
const confirmDisable = ref(false)
|
||||
|
||||
const resetTarget = ref<AdminUser | null>(null)
|
||||
const resetting = ref(false)
|
||||
const revealed = ref<{ username: string; password: string } | null>(null)
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||
const editorTitle = computed(() => (editingId.value === null ? '新建用户' : `编辑 ${form.value.username}`))
|
||||
const roleLabel = (role: string): string => USER_ROLES.find((item) => item.value === role)?.label ?? role
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await usersApi.list()
|
||||
rows.value = result.data
|
||||
usingMock.value = result.mock
|
||||
} catch (caught) {
|
||||
loadError.value = caught instanceof Error ? caught.message : '加载用户列表失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
form.value = { username: '', displayName: '', role: 'recorder', active: true, password: '' }
|
||||
formError.value = null
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: AdminUser): void {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
username: row.username,
|
||||
displayName: row.displayName,
|
||||
role: row.role,
|
||||
active: row.active,
|
||||
password: '',
|
||||
}
|
||||
formError.value = null
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function validate(): string | null {
|
||||
if (form.value.username.trim() === '') return '账号不能为空'
|
||||
if (form.value.displayName.trim() === '') return '显示名不能为空'
|
||||
if (editingId.value === null && form.value.password.length < 8) return '新建用户必须设置至少 8 位的初始密码'
|
||||
return null
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const message = validate()
|
||||
if (message !== null) {
|
||||
formError.value = message
|
||||
return
|
||||
}
|
||||
// 停用是危险操作:现有会话可能还在现场跑,必须二次确认。
|
||||
const current = rows.value.find((row) => row.id === editingId.value)
|
||||
if (current !== undefined && current.active && !form.value.active) {
|
||||
confirmDisable.value = true
|
||||
return
|
||||
}
|
||||
await commitSave()
|
||||
}
|
||||
|
||||
async function commitSave(): Promise<void> {
|
||||
confirmDisable.value = false
|
||||
saving.value = true
|
||||
formError.value = null
|
||||
try {
|
||||
if (editingId.value === null) {
|
||||
const created = await usersApi.create({ ...form.value, username: form.value.username.trim() })
|
||||
usingMock.value = usingMock.value || created.mock
|
||||
toast.success(`已新建用户 ${created.data.username}`, usingMock.value ? '示例数据' : null)
|
||||
} else {
|
||||
const updated = await usersApi.update(editingId.value, {
|
||||
displayName: form.value.displayName.trim(),
|
||||
role: form.value.role,
|
||||
active: form.value.active,
|
||||
})
|
||||
usingMock.value = usingMock.value || updated.mock
|
||||
toast.success(`已更新 ${updated.data.username}`, usingMock.value ? '示例数据' : null)
|
||||
}
|
||||
editorOpen.value = false
|
||||
await load()
|
||||
} catch (caught) {
|
||||
formError.value = caught instanceof Error ? caught.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(): Promise<void> {
|
||||
const target = resetTarget.value
|
||||
if (target === null) return
|
||||
resetting.value = true
|
||||
try {
|
||||
const result = await usersApi.resetPassword(target.id)
|
||||
usingMock.value = usingMock.value || result.mock
|
||||
resetTarget.value = null
|
||||
// 明文只在这一次响应里存在,关闭弹窗即丢弃,服务端也不会再返回。
|
||||
revealed.value = { username: target.username, password: result.data.password }
|
||||
await load()
|
||||
} catch (caught) {
|
||||
toast.error('重置密码失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPassword(): Promise<void> {
|
||||
const value = revealed.value?.password
|
||||
if (value === undefined) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast.success('已复制到剪贴板')
|
||||
} catch {
|
||||
toast.warning('浏览器拒绝访问剪贴板', '请手动选中复制')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">用户管理</span>
|
||||
<div class="head-actions">
|
||||
<MockBadge v-if="usingMock" />
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="loading" @click="load">
|
||||
<MxIcon name="refresh" :size="14" /> 刷新
|
||||
</button>
|
||||
<button class="mx-btn mx-btn-primary mx-btn-sm" type="button" :disabled="!isAdmin" @click="openCreate">
|
||||
<MxIcon name="user" :size="14" /> 新建用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-card-body">
|
||||
<p v-if="!isAdmin" class="notice">只有管理员能管理用户,当前账号为只读。</p>
|
||||
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
|
||||
|
||||
<div v-if="loading" class="state-block">
|
||||
<span class="mx-activity-dot"></span> 正在加载用户列表…
|
||||
</div>
|
||||
|
||||
<div v-else-if="rows.length === 0" class="mx-empty">
|
||||
<div class="mx-empty-title">还没有用户</div>
|
||||
<div class="mx-empty-desc">首次启动会自动生成管理员账号,也可以在这里新建录入员</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>账号</th>
|
||||
<th>显示名</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>最近登录</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td class="mono">
|
||||
{{ row.username }}
|
||||
<span v-if="row.mustChangePassword" class="mx-tag mx-tag-warning mx-tag-sm">待改密</span>
|
||||
</td>
|
||||
<td>{{ row.displayName }}</td>
|
||||
<td>{{ roleLabel(row.role) }}</td>
|
||||
<td>
|
||||
<span class="mx-badge" :class="row.active ? 'mx-badge-success' : 'mx-badge-neutral'">
|
||||
{{ row.active ? '启用' : '停用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="muted mono">{{ row.lastLoginAt === null ? '从未登录' : formatDateTime(row.lastLoginAt) }}</td>
|
||||
<td class="right actions">
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!isAdmin" @click="openEdit(row)">
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!isAdmin"
|
||||
@click="resetTarget = row"
|
||||
>
|
||||
重置密码
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="editorOpen" class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="mx-card-title">{{ editorTitle }}</span>
|
||||
<button class="mx-modal-close" type="button" @click="editorOpen = false">
|
||||
<MxIcon name="close" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label class="mx-label" for="user-username">账号</label>
|
||||
<input
|
||||
id="user-username"
|
||||
v-model="form.username"
|
||||
class="mx-input"
|
||||
:disabled="editingId !== null"
|
||||
placeholder="登录名,创建后不可改"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="mx-label" for="user-display">显示名</label>
|
||||
<input id="user-display" v-model="form.displayName" class="mx-input" placeholder="界面与审计日志里显示的名字" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="mx-label" for="user-role">角色</label>
|
||||
<div class="mx-input-wrap">
|
||||
<select id="user-role" v-model="form.role" class="mx-input">
|
||||
<option v-for="role in USER_ROLES" :key="role.value" :value="role.value">{{ role.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editingId === null" class="field">
|
||||
<label class="mx-label" for="user-password">初始密码</label>
|
||||
<input
|
||||
id="user-password"
|
||||
v-model="form.password"
|
||||
class="mx-input"
|
||||
type="text"
|
||||
autocomplete="new-password"
|
||||
placeholder="至少 8 位,首次登录强制修改"
|
||||
/>
|
||||
</div>
|
||||
<div class="field switch-field">
|
||||
<span class="mx-label">启用</span>
|
||||
<span class="switch-row">
|
||||
<label class="mx-switch">
|
||||
<input v-model="form.active" type="checkbox" />
|
||||
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
|
||||
</label>
|
||||
<span class="switch-text">{{ form.active ? '可登录' : '停用后立即失效' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="formError !== null" class="notice error">{{ formError }}</p>
|
||||
|
||||
<div class="editor-foot">
|
||||
<button class="mx-btn mx-btn-secondary" type="button" :disabled="saving" @click="editorOpen = false">
|
||||
取消
|
||||
</button>
|
||||
<button class="mx-btn mx-btn-primary" type="button" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="resetTarget !== null"
|
||||
tone="danger"
|
||||
title="重置该用户密码?"
|
||||
:message="`将为 ${resetTarget?.username ?? ''} 生成一个新的随机密码,旧密码立即失效,该用户的所有登录会话同时作废。`"
|
||||
confirm-text="重置密码"
|
||||
:pending="resetting"
|
||||
@confirm="resetPassword"
|
||||
@cancel="resetTarget = null"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirmDisable"
|
||||
tone="danger"
|
||||
title="停用该用户?"
|
||||
:message="`停用后 ${form.username} 将无法登录,正在使用的会话也会失效。`"
|
||||
confirm-text="停用"
|
||||
:pending="saving"
|
||||
@confirm="commitSave"
|
||||
@cancel="confirmDisable = false"
|
||||
/>
|
||||
|
||||
<div class="mx-modal-overlay" :class="{ open: revealed !== null }" @click.self="revealed = null">
|
||||
<div class="mx-modal mx-modal-sm" role="dialog" aria-modal="true">
|
||||
<div class="mx-modal-head">
|
||||
<span class="mx-modal-title">新密码(只显示一次)</span>
|
||||
<button class="mx-modal-close" type="button" @click="revealed = null">
|
||||
<MxIcon name="close" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-modal-body">
|
||||
<p class="reveal-hint">请立即抄送给 {{ revealed?.username }},关闭后无法再次查看。</p>
|
||||
<div class="reveal-value mono">{{ revealed?.password }}</div>
|
||||
<button class="mx-btn mx-btn-secondary full" type="button" @click="copyPassword">复制密码</button>
|
||||
</div>
|
||||
<div class="mx-modal-foot">
|
||||
<button class="mx-btn mx-btn-primary" type="button" @click="revealed = null">我已记下</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mx-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.muted {
|
||||
color: var(--mx-t3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.notice {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--mx-r-xs);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.notice.error {
|
||||
color: var(--mx-error);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
.state-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--mx-t3);
|
||||
padding: 18px 0;
|
||||
}
|
||||
.editor {
|
||||
border: 1px solid var(--mx-border);
|
||||
border-radius: var(--mx-r-sm);
|
||||
padding: 14px;
|
||||
background: var(--mx-s2);
|
||||
}
|
||||
.editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.switch-field {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
}
|
||||
.switch-text {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.editor-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.reveal-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.reveal-value {
|
||||
padding: 12px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
font-size: 16px;
|
||||
letter-spacing: 1px;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.full {
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,804 @@
|
||||
import { ApiError, type ApiErrorCode, type EventSettings } from '@/shared'
|
||||
import { mockMode, mockReason } from '../../api/client'
|
||||
|
||||
/**
|
||||
* 系统管理页的补充接口。
|
||||
*
|
||||
* `api/client.ts` 只封装了 ApiClient 已有的端点,用户管理 / 备份列表 / 赛事项目包 /
|
||||
* 规则包这几组接口还没进共享客户端(且本任务不允许改 api 层),所以在组件目录里补一层薄封装。
|
||||
*
|
||||
* 降级策略与 client.ts 完全一致:只有「连不上 / 超时 / 响应不是约定结构 / 404 未实现」
|
||||
* 才降级示例数据;403、校验失败这类业务回答必须原样抛出,否则会把「没权限」伪装成「示例数据」。
|
||||
*/
|
||||
|
||||
const BASE_URL = '/api'
|
||||
const DEFAULT_TIMEOUT_MS = 15_000
|
||||
/** 上传整包可能上百 MB,超时必须放宽,否则现场弱网必然失败。 */
|
||||
const UPLOAD_TIMEOUT_MS = 120_000
|
||||
|
||||
const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED']
|
||||
|
||||
const API_ERROR_CODES: readonly string[] = [
|
||||
'AUTH_REQUIRED',
|
||||
'AUTH_FAILED',
|
||||
'FORBIDDEN',
|
||||
'VALIDATION_FAILED',
|
||||
'CONFLICT',
|
||||
'STATE_INVALID',
|
||||
'RATE_LIMITED',
|
||||
'INTERNAL',
|
||||
'NETWORK',
|
||||
'TIMEOUT',
|
||||
'PARSE_FAILED',
|
||||
]
|
||||
|
||||
function isApiErrorCode(value: unknown): value is ApiErrorCode {
|
||||
return typeof value === 'string' && API_ERROR_CODES.includes(value)
|
||||
}
|
||||
|
||||
export function isBackendUnavailable(error: unknown): boolean {
|
||||
return error instanceof ApiError && (FALLBACK_CODES.includes(error.code) || error.status === 404)
|
||||
}
|
||||
|
||||
export interface MockAware<T> {
|
||||
data: T
|
||||
/** 本次调用是否真的降级到了示例数据,供各面板显示局部标记。 */
|
||||
mock: boolean
|
||||
}
|
||||
|
||||
function markMock(reason: string): void {
|
||||
mockMode.value = true
|
||||
mockReason.value = reason
|
||||
}
|
||||
|
||||
/** 与 client.ts 的 call() 同构,额外把「是否降级」返回给调用方。 */
|
||||
export async function withMockInfo<T>(
|
||||
real: () => Promise<T>,
|
||||
mock: () => T | Promise<T>,
|
||||
): Promise<MockAware<T>> {
|
||||
try {
|
||||
const data = await real()
|
||||
if (mockMode.value) {
|
||||
mockMode.value = false
|
||||
mockReason.value = null
|
||||
}
|
||||
return { data, mock: false }
|
||||
} catch (error) {
|
||||
if (!isBackendUnavailable(error)) throw error
|
||||
markMock(error instanceof Error ? error.message : '无法连接服务端')
|
||||
return { data: await mock(), mock: true }
|
||||
}
|
||||
}
|
||||
|
||||
export async function withMock<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
|
||||
return (await withMockInfo(real, mock)).data
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 底层 HTTP(补齐 ApiClient 未覆盖的路径)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
type QueryValue = string | number | boolean | undefined
|
||||
|
||||
interface RawOptions {
|
||||
method?: Method
|
||||
query?: Record<string, QueryValue>
|
||||
json?: unknown
|
||||
body?: BodyInit
|
||||
contentType?: string
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readString(source: Record<string, unknown>, key: string): string | null {
|
||||
const value = source[key]
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
function readNumber(source: Record<string, unknown>, key: string): number | null {
|
||||
const value = source[key]
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item): item is string => typeof item === 'string')
|
||||
}
|
||||
|
||||
function readCsrfToken(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
|
||||
const raw = match?.[1]
|
||||
return raw === undefined ? null : decodeURIComponent(raw)
|
||||
}
|
||||
|
||||
function newRequestId(): string {
|
||||
const cryptoApi: Crypto | undefined = globalThis.crypto
|
||||
if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') {
|
||||
return cryptoApi.randomUUID()
|
||||
}
|
||||
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function buildUrl(path: string, query?: Record<string, QueryValue>): string {
|
||||
const base = path.startsWith('/') ? `${BASE_URL}${path}` : `${BASE_URL}/${path}`
|
||||
if (query === undefined) return base
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined) params.append(key, String(value))
|
||||
}
|
||||
const search = params.toString()
|
||||
return search === '' ? base : `${base}?${search}`
|
||||
}
|
||||
|
||||
function toApiError(body: unknown, status: number): ApiError {
|
||||
if (isRecord(body) && isRecord(body.error)) {
|
||||
return new ApiError(
|
||||
isApiErrorCode(body.error.code) ? body.error.code : 'INTERNAL',
|
||||
readString(body.error, 'message') ?? `请求失败(HTTP ${status})`,
|
||||
status,
|
||||
body.error.detail,
|
||||
)
|
||||
}
|
||||
return new ApiError('INTERNAL', `请求失败(HTTP ${status})`, status, body)
|
||||
}
|
||||
|
||||
async function send(path: string, options: RawOptions = {}): Promise<Response> {
|
||||
const method = options.method ?? 'GET'
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (options.json !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
|
||||
else if (options.contentType !== undefined) headers['Content-Type'] = options.contentType
|
||||
|
||||
if (method !== 'GET') {
|
||||
const token = readCsrfToken()
|
||||
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
|
||||
headers['X-Request-Id'] = newRequestId()
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const timer = window.setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
return await fetch(buildUrl(path, options.query), {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: options.json !== undefined ? JSON.stringify(options.json) : options.body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new ApiError('TIMEOUT', `请求超时(${timeoutMs}ms)`, 0, error)
|
||||
}
|
||||
throw new ApiError('NETWORK', '无法连接服务端', 0, error)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function readBody(response: Response): Promise<unknown> {
|
||||
if (response.status === 204) return null
|
||||
const text = await response.text()
|
||||
if (text.trim() === '') return null
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function unwrap<T>(body: unknown, status: number): T {
|
||||
if (!isRecord(body) || typeof body.ok !== 'boolean') {
|
||||
throw new ApiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', status, body)
|
||||
}
|
||||
if (body.ok) return body.data as T
|
||||
throw toApiError(body, status)
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, options: RawOptions = {}): Promise<T> {
|
||||
const response = await send(path, options)
|
||||
const body = await readBody(response)
|
||||
if (!response.ok) throw toApiError(body, response.status)
|
||||
return unwrap<T>(body, response.status)
|
||||
}
|
||||
|
||||
async function requestVoid(path: string, options: RawOptions = {}): Promise<void> {
|
||||
const response = await send(path, options)
|
||||
const body = await readBody(response)
|
||||
if (!response.ok) throw toApiError(body, response.status)
|
||||
if (body === null || body === undefined) return
|
||||
unwrap<unknown>(body, response.status)
|
||||
}
|
||||
|
||||
async function requestBlob(path: string, options: RawOptions = {}): Promise<Blob> {
|
||||
const response = await send(path, options)
|
||||
if (!response.ok) throw toApiError(await readBody(response), response.status)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
async function requestText(path: string, options: RawOptions = {}): Promise<string> {
|
||||
const response = await send(path, options)
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
let body: unknown = text
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
// 非 JSON 的错误体原样透传,报错里带上原文才定位得到问题。
|
||||
}
|
||||
throw toApiError(body, response.status)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 用户管理(devdocs/08 第 2 节角色:guest / recorder / admin)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AdminUser {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
active: boolean
|
||||
mustChangePassword: boolean
|
||||
lastLoginAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminUserInput {
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
active: boolean
|
||||
password: string
|
||||
}
|
||||
|
||||
export const USER_ROLES: ReadonlyArray<{ value: string; label: string }> = [
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'recorder', label: '录入员' },
|
||||
{ value: 'guest', label: '只读访客' },
|
||||
]
|
||||
|
||||
function toAdminUser(value: unknown): AdminUser | null {
|
||||
if (!isRecord(value)) return null
|
||||
const id = readNumber(value, 'id')
|
||||
const username = readString(value, 'username')
|
||||
if (id === null || username === null) return null
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
displayName: readString(value, 'displayName') ?? username,
|
||||
role: readString(value, 'role') ?? 'recorder',
|
||||
// 后端未返回 active 时按「启用」处理,避免老接口把所有人显示成停用。
|
||||
active: value.active !== false,
|
||||
mustChangePassword: value.mustChangePassword === true,
|
||||
lastLoginAt: readString(value, 'lastLoginAt'),
|
||||
}
|
||||
}
|
||||
|
||||
function randomPassword(): string {
|
||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
|
||||
const bytes = new Uint8Array(12)
|
||||
crypto.getRandomValues(bytes)
|
||||
let out = ''
|
||||
for (const byte of bytes) out += alphabet[byte % alphabet.length]
|
||||
return out
|
||||
}
|
||||
|
||||
let mockUserSeq = 3
|
||||
const mockUserRows: AdminUser[] = [
|
||||
{
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
displayName: '裁判长',
|
||||
role: 'admin',
|
||||
active: true,
|
||||
mustChangePassword: false,
|
||||
lastLoginAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'recorder01',
|
||||
displayName: '录入员一',
|
||||
role: 'recorder',
|
||||
active: true,
|
||||
mustChangePassword: false,
|
||||
lastLoginAt: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: 'lookup',
|
||||
displayName: '只读账号',
|
||||
role: 'guest',
|
||||
active: false,
|
||||
mustChangePassword: true,
|
||||
lastLoginAt: null,
|
||||
},
|
||||
]
|
||||
|
||||
function mockUsers() {
|
||||
return {
|
||||
list: (): AdminUser[] => mockUserRows.map((row) => ({ ...row })),
|
||||
create: (input: AdminUserInput): AdminUser => {
|
||||
const created: AdminUser = {
|
||||
id: (mockUserSeq += 1),
|
||||
username: input.username,
|
||||
displayName: input.displayName === '' ? input.username : input.displayName,
|
||||
role: input.role,
|
||||
active: input.active,
|
||||
mustChangePassword: input.password !== '',
|
||||
lastLoginAt: null,
|
||||
}
|
||||
mockUserRows.push(created)
|
||||
return { ...created }
|
||||
},
|
||||
update: (id: number, patch: Partial<AdminUserInput>): AdminUser => {
|
||||
const index = mockUserRows.findIndex((row) => row.id === id)
|
||||
if (index < 0) throw new ApiError('VALIDATION_FAILED', '用户不存在', 0)
|
||||
const current = mockUserRows[index] as AdminUser
|
||||
const next: AdminUser = {
|
||||
...current,
|
||||
displayName: patch.displayName ?? current.displayName,
|
||||
role: patch.role ?? current.role,
|
||||
active: patch.active ?? current.active,
|
||||
}
|
||||
mockUserRows[index] = next
|
||||
return { ...next }
|
||||
},
|
||||
resetPassword: (id: number): { password: string } => {
|
||||
const index = mockUserRows.findIndex((row) => row.id === id)
|
||||
if (index < 0) throw new ApiError('VALIDATION_FAILED', '用户不存在', 0)
|
||||
const current = mockUserRows[index] as AdminUser
|
||||
mockUserRows[index] = { ...current, mustChangePassword: true }
|
||||
return { password: randomPassword() }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
async list(): Promise<MockAware<AdminUser[]>> {
|
||||
return withMockInfo(
|
||||
async () => {
|
||||
const body = await requestJson<unknown>('/users')
|
||||
const raw = Array.isArray(body)
|
||||
? body
|
||||
: isRecord(body) && Array.isArray(body.items)
|
||||
? body.items
|
||||
: null
|
||||
if (raw === null) throw new ApiError('PARSE_FAILED', '用户列表响应格式不符', 0, body)
|
||||
return raw.map(toAdminUser).filter((row): row is AdminUser => row !== null)
|
||||
},
|
||||
() => mockUsers().list(),
|
||||
)
|
||||
},
|
||||
|
||||
async create(input: AdminUserInput): Promise<MockAware<AdminUser>> {
|
||||
return withMockInfo(
|
||||
async () => {
|
||||
const body = await requestJson<unknown>('/users', { method: 'POST', json: input })
|
||||
const created = toAdminUser(body)
|
||||
if (created === null) throw new ApiError('PARSE_FAILED', '新建用户响应格式不符', 0, body)
|
||||
return created
|
||||
},
|
||||
() => mockUsers().create(input),
|
||||
)
|
||||
},
|
||||
|
||||
async update(id: number, patch: Partial<AdminUserInput>): Promise<MockAware<AdminUser>> {
|
||||
return withMockInfo(
|
||||
async () => {
|
||||
const body = await requestJson<unknown>(`/users/${id}`, { method: 'PUT', json: patch })
|
||||
const updated = toAdminUser(body)
|
||||
if (updated === null) throw new ApiError('PARSE_FAILED', '更新用户响应格式不符', 0, body)
|
||||
return updated
|
||||
},
|
||||
() => mockUsers().update(id, patch),
|
||||
)
|
||||
},
|
||||
|
||||
async resetPassword(id: number): Promise<MockAware<{ password: string }>> {
|
||||
return withMockInfo(
|
||||
async () => {
|
||||
const body = await requestJson<unknown>(`/users/${id}/password`, { method: 'POST', json: {} })
|
||||
const password = isRecord(body) ? readString(body, 'password') : null
|
||||
if (password === null) throw new ApiError('PARSE_FAILED', '重置密码响应缺少 password', 0, body)
|
||||
return { password }
|
||||
},
|
||||
() => mockUsers().resetPassword(id),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 备份(devdocs/08 第 6 节)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BackupItem {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
reason: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function toBackupItem(value: unknown): BackupItem | null {
|
||||
if (!isRecord(value)) return null
|
||||
const name = readString(value, 'name')
|
||||
const createdAt = readString(value, 'createdAt')
|
||||
if (name === null || createdAt === null) return null
|
||||
return {
|
||||
name,
|
||||
path: readString(value, 'path') ?? name,
|
||||
size: readNumber(value, 'size') ?? 0,
|
||||
reason: readString(value, 'reason') ?? '',
|
||||
createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
function mockBackupList(): BackupItem[] {
|
||||
const now = Date.now()
|
||||
return [0, 1, 2].map((offset) => ({
|
||||
name: `event-示例-${offset + 1}.db`,
|
||||
path: `data/backups/event-示例-${offset + 1}.db`,
|
||||
size: 1_482_752 - offset * 2048,
|
||||
reason: offset === 0 ? 'manual' : 'auto',
|
||||
createdAt: new Date(now - offset * 5 * 60_000).toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
export const backupsApi = {
|
||||
async list(): Promise<MockAware<BackupItem[]>> {
|
||||
return withMockInfo(
|
||||
async () => {
|
||||
const body = await requestJson<unknown>('/export/backups')
|
||||
if (!Array.isArray(body)) throw new ApiError('PARSE_FAILED', '备份列表响应格式不符', 0, body)
|
||||
return body.map(toBackupItem).filter((row): row is BackupItem => row !== null)
|
||||
},
|
||||
() => mockBackupList(),
|
||||
)
|
||||
},
|
||||
|
||||
async create(): Promise<MockAware<BackupItem>> {
|
||||
return withMockInfo(
|
||||
async () => {
|
||||
const body = await requestJson<unknown>('/backup', { method: 'POST', json: {} })
|
||||
const created = toBackupItem(body)
|
||||
if (created === null) throw new ApiError('PARSE_FAILED', '备份响应格式不符', 0, body)
|
||||
return created
|
||||
},
|
||||
() => {
|
||||
const createdAt = new Date().toISOString()
|
||||
return {
|
||||
name: `event-${createdAt.replace(/[:.]/g, '-')}.db`,
|
||||
path: `data/backups/event-${createdAt.replace(/[:.]/g, '-')}.db`,
|
||||
size: 1_486_848,
|
||||
reason: 'manual',
|
||||
createdAt,
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 结果导出(xlsx)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ResultExportKind = 'standings' | 'all' | 'event' | 'team'
|
||||
|
||||
export interface ResultExportRequest {
|
||||
kind: ResultExportKind
|
||||
eventId?: number
|
||||
teamId?: number
|
||||
}
|
||||
|
||||
export interface DownloadFile {
|
||||
blob: Blob
|
||||
filename: string
|
||||
}
|
||||
|
||||
const RESULT_EXPORT_FILENAME: Record<ResultExportKind, string> = {
|
||||
standings: '总积分榜.xlsx',
|
||||
all: '赛事成绩总表.xlsx',
|
||||
event: '分项成绩表.xlsx',
|
||||
team: '单位积分明细.xlsx',
|
||||
}
|
||||
|
||||
function mockDownload(request: ResultExportRequest, context: string): DownloadFile {
|
||||
const lines = [
|
||||
'示例数据 · 后端未就绪,这里不会生成真正的 xlsx',
|
||||
'',
|
||||
`导出项:${context}`,
|
||||
request.eventId === undefined ? '' : `小项 ID:${request.eventId}`,
|
||||
request.teamId === undefined ? '' : `单位 ID:${request.teamId}`,
|
||||
'',
|
||||
'服务端接口恢复后,本文件将替换为按预设模板生成的 Excel 工作簿。',
|
||||
]
|
||||
return {
|
||||
blob: new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/plain;charset=utf-8' }),
|
||||
filename: RESULT_EXPORT_FILENAME[request.kind].replace(/\.xlsx$/, '(示例数据).txt'),
|
||||
}
|
||||
}
|
||||
|
||||
export const resultsExportApi = {
|
||||
async download(request: ResultExportRequest): Promise<MockAware<DownloadFile>> {
|
||||
return withMockInfo(
|
||||
async () => {
|
||||
let path = '/export/standings.xlsx'
|
||||
if (request.kind === 'all') path = '/export/all.xlsx'
|
||||
if (request.kind === 'event') {
|
||||
if (request.eventId === undefined) {
|
||||
throw new ApiError('VALIDATION_FAILED', '请先选择要导出的小项', 0)
|
||||
}
|
||||
path = `/export/events/${request.eventId}`
|
||||
}
|
||||
if (request.kind === 'team') {
|
||||
if (request.teamId === undefined) {
|
||||
throw new ApiError('VALIDATION_FAILED', '请先选择要导出的单位', 0)
|
||||
}
|
||||
path = `/export/teams/${request.teamId}`
|
||||
}
|
||||
const blob = await requestBlob(path, { timeoutMs: 60_000 })
|
||||
return { blob, filename: RESULT_EXPORT_FILENAME[request.kind] }
|
||||
},
|
||||
() => mockDownload(request, RESULT_EXPORT_FILENAME[request.kind]),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 赛事项目包(zip)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProjectImportResult {
|
||||
name: string
|
||||
dir: string
|
||||
eventDB: string
|
||||
meta: unknown
|
||||
}
|
||||
|
||||
export interface ProjectImportOptions {
|
||||
overwrite: boolean
|
||||
rename: string
|
||||
}
|
||||
|
||||
export const projectApi = {
|
||||
async exportZip(): Promise<MockAware<DownloadFile>> {
|
||||
return withMockInfo(
|
||||
async () => ({
|
||||
blob: await requestBlob('/export/project.zip', { timeoutMs: 120_000 }),
|
||||
filename: '赛事项目包.zip',
|
||||
}),
|
||||
() => ({
|
||||
blob: new Blob(
|
||||
[
|
||||
'\uFEFF示例数据 · 后端未就绪,这里不会生成真正的 zip。\n\n' +
|
||||
'真实包内含 meta.yaml + event.db + uploads/ + rules.yaml,整包拷走即可在另一台机器复现。',
|
||||
],
|
||||
{ type: 'text/plain;charset=utf-8' },
|
||||
),
|
||||
filename: '赛事项目包(示例数据).txt',
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async importZip(file: File, options: ProjectImportOptions): Promise<MockAware<ProjectImportResult>> {
|
||||
return withMockInfo(
|
||||
async () => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('overwrite', options.overwrite ? '1' : '0')
|
||||
const rename = options.rename.trim()
|
||||
if (rename !== '') form.append('rename', rename)
|
||||
const body = await requestJson<unknown>('/export/project/import', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
timeoutMs: UPLOAD_TIMEOUT_MS,
|
||||
})
|
||||
if (!isRecord(body)) throw new ApiError('PARSE_FAILED', '导入响应格式不符', 0, body)
|
||||
return {
|
||||
name: readString(body, 'name') ?? (rename === '' ? file.name : rename),
|
||||
dir: readString(body, 'dir') ?? '',
|
||||
eventDB: readString(body, 'eventDB') ?? '',
|
||||
meta: body.meta,
|
||||
}
|
||||
},
|
||||
() => ({
|
||||
name: options.rename.trim() === '' ? file.name.replace(/\.zip$/i, '') : options.rename.trim(),
|
||||
dir: `data/event_projs/${options.rename.trim() === '' ? file.name.replace(/\.zip$/i, '') : options.rename.trim()}`,
|
||||
eventDB: 'event.db',
|
||||
meta: { note: '示例数据:未真正解压落盘' },
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 规则包(YAML)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RuleDiffRow {
|
||||
kind: string
|
||||
target: string
|
||||
change: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface RulePreview {
|
||||
compatible: boolean
|
||||
affectedEventCount: number
|
||||
rows: RuleDiffRow[]
|
||||
unknownEvents: string[]
|
||||
}
|
||||
|
||||
export interface RuleApplyResult extends RulePreview {
|
||||
recalculatedLedgerRows: number
|
||||
}
|
||||
|
||||
const MOCK_RULE_YAML = `# 示例数据 · 后端未就绪时导出的占位规则包
|
||||
format: scoreboard-rules
|
||||
formatVersion: 1
|
||||
meta:
|
||||
code: demo
|
||||
name: 示例规则包
|
||||
version: 1.0.0
|
||||
updatedAt: "2026-09-09"
|
||||
standings:
|
||||
individualToTeamWeight: 1
|
||||
tieBreak: [points, gold, silver, bronze, teamCode]
|
||||
medalCountsTeamEvents: true
|
||||
showIndividualBoard: true
|
||||
showIndividualMedals: true
|
||||
profiles: []
|
||||
recordBonus:
|
||||
enabled: false
|
||||
mode: none
|
||||
levels: []
|
||||
manualAdjustment:
|
||||
enabled: false
|
||||
reasonRequired: true
|
||||
channelTemplates: []
|
||||
eventOverrides: []
|
||||
`
|
||||
|
||||
function collectChanges(
|
||||
raw: unknown,
|
||||
kind: string,
|
||||
keyField: string,
|
||||
out: RuleDiffRow[],
|
||||
): void {
|
||||
if (!Array.isArray(raw)) return
|
||||
for (const item of raw) {
|
||||
if (!isRecord(item)) continue
|
||||
const target = readString(item, keyField) ?? '—'
|
||||
const change = readString(item, 'kind') ?? 'modified'
|
||||
const fields = readStringArray(item.fields)
|
||||
let detail = '整体替换'
|
||||
if (fields.length > 0) detail = `字段:${fields.join('、')}`
|
||||
else if (typeof item.before === 'string' && typeof item.after === 'string') {
|
||||
detail = `${item.before} → ${item.after}`
|
||||
}
|
||||
out.push({ kind, target, change, detail })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容两种预览响应:
|
||||
* 1. 服务端 rules.ChangeSet(profiles / channels / settings);
|
||||
* 2. shared/types 里定义的 RulePackImportPreview(diffs 平铺数组)。
|
||||
* 后端与共享类型的字段名目前并不一致,这里做一次归一,页面只认 RuleDiffRow。
|
||||
*/
|
||||
function toRulePreview(value: unknown): RulePreview {
|
||||
if (!isRecord(value)) throw new ApiError('PARSE_FAILED', '规则预览响应格式不符', 0, value)
|
||||
const rows: RuleDiffRow[] = []
|
||||
if (Array.isArray(value.diffs)) {
|
||||
for (const item of value.diffs) {
|
||||
if (!isRecord(item)) continue
|
||||
rows.push({
|
||||
kind: readString(item, 'kind') ?? '其他',
|
||||
target: readString(item, 'target') ?? '—',
|
||||
change: readString(item, 'change') ?? 'modified',
|
||||
detail: readString(item, 'detail') ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
collectChanges(value.profiles, '积分模板', 'code', rows)
|
||||
collectChanges(value.channels, '计分通道', 'eventCode', rows)
|
||||
collectChanges(value.settings, '赛事设置', 'key', rows)
|
||||
|
||||
return {
|
||||
compatible: value.compatible !== false,
|
||||
affectedEventCount: readNumber(value, 'affectedEventCount') ?? 0,
|
||||
rows,
|
||||
unknownEvents: readStringArray(value.unknownEvents),
|
||||
}
|
||||
}
|
||||
|
||||
function toRuleApplyResult(value: unknown): RuleApplyResult {
|
||||
const preview = toRulePreview(value)
|
||||
const recalculated = isRecord(value) ? (readNumber(value, 'recalculatedLedgerRows') ?? 0) : 0
|
||||
return { ...preview, recalculatedLedgerRows: recalculated }
|
||||
}
|
||||
|
||||
function mockPreview(): RulePreview {
|
||||
return {
|
||||
compatible: true,
|
||||
affectedEventCount: 3,
|
||||
rows: [
|
||||
{ kind: '积分模板', target: 'individual_top6', change: 'modified', detail: '字段:rules、awardRanks' },
|
||||
{ kind: '积分模板', target: 'team_top6', change: 'removed', detail: '整体替换' },
|
||||
{ kind: '计分通道', target: 'M100', change: 'added', detail: '整体替换' },
|
||||
{ kind: '赛事设置', target: 'showIndividualBoard', change: 'modified', detail: 'true → false' },
|
||||
],
|
||||
unknownEvents: ['EVT_UNKNOWN'],
|
||||
}
|
||||
}
|
||||
|
||||
export const rulesApi = {
|
||||
async exportYaml(): Promise<MockAware<DownloadFile>> {
|
||||
return withMockInfo(
|
||||
async () => ({
|
||||
blob: new Blob([await requestText('/rules/export', { timeoutMs: 30_000 })], {
|
||||
type: 'application/yaml;charset=utf-8',
|
||||
}),
|
||||
filename: 'rules.yaml',
|
||||
}),
|
||||
() => ({
|
||||
blob: new Blob([MOCK_RULE_YAML], { type: 'application/yaml;charset=utf-8' }),
|
||||
filename: 'rules(示例数据).yaml',
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async preview(yaml: string): Promise<MockAware<RulePreview>> {
|
||||
return withMockInfo(
|
||||
() =>
|
||||
requestJson<unknown>('/rules/import', {
|
||||
method: 'POST',
|
||||
query: { preview: 1 },
|
||||
body: yaml,
|
||||
contentType: 'application/yaml; charset=utf-8',
|
||||
timeoutMs: 60_000,
|
||||
}).then(toRulePreview),
|
||||
() => mockPreview(),
|
||||
)
|
||||
},
|
||||
|
||||
async apply(yaml: string): Promise<MockAware<RuleApplyResult>> {
|
||||
return withMockInfo(
|
||||
() =>
|
||||
requestJson<unknown>('/rules/import', {
|
||||
method: 'POST',
|
||||
body: yaml,
|
||||
contentType: 'application/yaml; charset=utf-8',
|
||||
timeoutMs: 60_000,
|
||||
}).then(toRuleApplyResult),
|
||||
() => ({ ...mockPreview(), recalculatedLedgerRows: 128 }),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 赛事设置
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EventSettingsPatch = Partial<EventSettings>
|
||||
|
||||
export const settingsApi = {
|
||||
async save(patch: EventSettingsPatch, current: EventSettings): Promise<MockAware<EventSettings>> {
|
||||
return withMockInfo(
|
||||
async () => {
|
||||
const body = await requestJson<unknown>('/settings', { method: 'PUT', json: patch })
|
||||
if (!isRecord(body)) throw new ApiError('PARSE_FAILED', '设置响应格式不符', 0, body)
|
||||
// 服务端返回的字段与 shared/types 的 EventSettings 一一对应,这里只做结构校验后透传。
|
||||
return body as unknown as EventSettings
|
||||
},
|
||||
// 示例模式下把改动合并进当前设置,让表单交互能完整走通。
|
||||
() => ({ ...current, ...patch }),
|
||||
)
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user