feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入

This commit is contained in:
AskaEth committed 2026-09-09 19:51:50 +08:00
1 parent f3813171d3
commit 72462792ec
39 files changed
+10001 -28

No files matched your search

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import MxIcon from '../MxIcon.vue'
/**
* 基础数据的分区表单弹窗。
*
* 只负责外壳与提交态:字段校验、错误文案由各分区自己算好后传进来,
* 弹窗不感知业务,避免七个分区各写一份弹窗逻辑。
*/
const props = withDefaults(
defineProps<{
open: boolean
title: string
subtitle?: string
submitText?: string
pending?: boolean
error?: string | null
wide?: boolean
}>(),
{
subtitle: '',
submitText: '保存',
pending: false,
error: null,
wide: false,
},
)
const emit = defineEmits<{ submit: []; cancel: [] }>()
function onKeydown(event: KeyboardEvent): void {
if (!props.open) return
if (event.key === 'Escape' && !props.pending) emit('cancel')
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<div class="mx-modal-overlay" :class="{ open }" @click.self="pending ? null : emit('cancel')">
<div class="mx-modal" :class="{ 'mx-modal-lg': wide }" role="dialog" aria-modal="true">
<div class="mx-modal-head">
<div>
<span class="mx-modal-title">{{ title }}</span>
<p v-if="subtitle" class="mx-modal-sub">{{ subtitle }}</p>
</div>
<button class="mx-modal-close" type="button" :disabled="pending" @click="emit('cancel')">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body">
<div v-if="error" class="mx-badge mx-badge-error form-error">{{ error }}</div>
<slot />
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" :disabled="pending" @click="emit('cancel')">
取消
</button>
<button class="mx-btn mx-btn-primary" type="button" :disabled="pending" @click="emit('submit')">
{{ pending ? '保存中…' : submitText }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.mx-modal-sub {
margin-top: 4px;
font-size: 12px;
color: var(--mx-t3);
}
.form-error {
display: block;
margin-bottom: 14px;
padding: 10px 12px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
/** 表单字段外壳:标签 + 控件 + 校验错误。错误文案由分区组件算好后传入。 */
withDefaults(
defineProps<{
label: string
hint?: string
error?: string | null
required?: boolean
wide?: boolean
}>(),
{ hint: '', error: null, required: false, wide: false },
)
</script>
<template>
<label class="field" :class="{ 'span-2': wide }">
<span class="mx-label">
{{ label }}
<span v-if="required" class="required">*</span>
</span>
<slot />
<span v-if="error" class="field-error">{{ error }}</span>
<span v-else-if="hint" class="field-hint">{{ hint }}</span>
</label>
</template>
<style scoped>
.field {
display: flex;
flex-direction: column;
min-width: 0;
}
.required {
color: var(--mx-red);
margin-left: 2px;
}
.field-error {
margin-top: 5px;
font-size: 12px;
color: var(--mx-red);
}
.field-hint {
margin-top: 5px;
font-size: 12px;
color: var(--mx-t3);
}
</style>
@@ -0,0 +1,736 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type {
CompareMode,
Event,
EventInput,
EventStatus,
EventType,
ResultMode,
ResultType,
Sport,
SportInput,
} from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import FormDialog from './FormDialog.vue'
import FormField from './FormField.vue'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
import type { BaseDataTabKey } from './tabs'
const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>()
const data = useBaseData()
const toast = useToastStore()
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,24}$/
const EVENT_TYPE_OPTIONS: Array<{ value: EventType; label: string }> = [
{ value: 'individual', label: '个人' },
{ value: 'team', label: '团体' },
{ value: 'match', label: '对抗 / 比赛' },
]
const RESULT_MODE_OPTIONS: Array<{ value: ResultMode; label: string }> = [
{ value: 'auto', label: '自动排名(录成绩)' },
{ value: 'manual', label: '手动名次(直接录名次)' },
]
const RESULT_TYPE_OPTIONS: Array<{ value: ResultType; label: string }> = [
{ value: 'time', label: '时间' },
{ value: 'distance', label: '距离' },
{ value: 'count', label: '计数' },
{ value: 'score', label: '得分' },
{ value: 'none', label: '无成绩' },
]
const COMPARE_OPTIONS: Array<{ value: CompareMode; label: string }> = [
{ value: 'asc', label: '越小越好(asc)' },
{ value: 'desc', label: '越大越好(desc)' },
{ value: 'none', label: '不比较(none)' },
]
const STATUS_OPTIONS: Array<{ value: EventStatus; label: string }> = [
{ value: 'pending', label: '未开始' },
{ value: 'ongoing', label: '进行中' },
{ value: 'finished', label: '已结束' },
{ value: 'published', label: '已发布' },
]
function labelOf<T extends string>(options: Array<{ value: T; label: string }>, value: T): string {
return options.find((option) => option.value === value)?.label ?? value
}
const selectedSportId = ref<number | null>(null)
watch(
() => data.sports.value,
(list) => {
if (selectedSportId.value === null || !list.some((sport) => sport.id === selectedSportId.value)) {
selectedSportId.value = list[0]?.id ?? null
}
},
{ immediate: true },
)
watch(
() => data.selectedEventId.value,
(id) => {
const event = data.events.value.find((item) => item.id === id)
if (event !== undefined) selectedSportId.value = event.sportId
if (id !== null) void data.loadEventDetail(id)
},
{ immediate: true },
)
const selectedSport = computed(() => data.sportById(selectedSportId.value) ?? null)
const sportEvents = computed(() => data.events.value.filter((event) => event.sportId === selectedSportId.value))
const selectedEvent = computed(() => data.eventById(data.selectedEventId.value) ?? null)
const selectedChannels = computed(() => data.channelsOf(data.selectedEventId.value))
const selectedCategories = computed(() => data.categoriesOf(data.selectedEventId.value))
const awardSummary = computed(() => {
const ranks = selectedChannels.value
.map((channel) => data.profileById(channel.profileId)?.awardRanks ?? 0)
.filter((rank) => rank > 0)
if (ranks.length === 0) return '未配置'
return `前 ${Math.max(...ranks)} 名`
})
const channelSummary = computed(() =>
selectedChannels.value.map((channel) => {
const profile = data.profileById(channel.profileId)
return {
id: channel.id,
scope: channel.scope === 'athlete' ? '个人名次' : '团体名次',
basis: channel.basis === 'result' ? '按成绩排名' : channel.basis === 'manual' ? '手动名次' : '团体汇总',
profile: profile?.name ?? `模板 #${channel.profileId}`,
award: profile?.awardRanks ?? null,
}
}),
)
function pickSport(id: number): void {
selectedSportId.value = id
const first = data.events.value.find((event) => event.sportId === id)
if (first !== undefined && first.id !== data.selectedEventId.value) {
void data.selectEvent(first.id)
}
}
// ── 大项 ────────────────────────────────────────────────────────────────────
interface SportForm {
code: string
name: string
shortName: string
defaultProfileId: string
sortOrder: string
active: boolean
}
type SportField = keyof SportForm
const sportDialog = ref(false)
const sportEditingId = ref<number | null>(null)
const sportForm = ref<SportForm>(emptySportForm())
const sportErrors = ref<Partial<Record<SportField, string>>>({})
const sportFormError = ref<string | null>(null)
const sportPending = ref(false)
const sportConfirmOpen = ref(false)
const sportConfirmPending = ref(false)
function emptySportForm(): SportForm {
return { code: '', name: '', shortName: '', defaultProfileId: '', sortOrder: '0', active: true }
}
function openCreateSport(): void {
sportEditingId.value = null
sportForm.value = emptySportForm()
sportForm.value.sortOrder = String(data.sports.value.length + 1)
sportErrors.value = {}
sportFormError.value = null
sportDialog.value = true
}
function openEditSport(sport: Sport): void {
sportEditingId.value = sport.id
sportForm.value = {
code: sport.code,
name: sport.name,
shortName: sport.shortName ?? '',
defaultProfileId: sport.defaultProfileId === null ? '' : String(sport.defaultProfileId),
sortOrder: String(sport.sortOrder),
active: sport.active,
}
sportErrors.value = {}
sportFormError.value = null
sportDialog.value = true
}
function validateSport(): boolean {
const next: Partial<Record<SportField, string>> = {}
const code = sportForm.value.code.trim()
if (code === '') next.code = '代码必填'
else if (!CODE_PATTERN.test(code)) next.code = '只能用字母、数字、下划线或短横线'
else if (data.sports.value.some((sport) => sport.id !== sportEditingId.value && sport.code.toLowerCase() === code.toLowerCase())) {
next.code = '代码已存在,必须唯一'
}
if (sportForm.value.name.trim() === '') next.name = '名称必填'
const sortOrder = Number(sportForm.value.sortOrder)
if (!Number.isInteger(sortOrder) || sortOrder < 0 || sortOrder > 9999) next.sortOrder = '排序为 0–9999 的整数'
sportErrors.value = next
return Object.keys(next).length === 0
}
async function submitSport(): Promise<void> {
if (!validateSport()) return
sportPending.value = true
sportFormError.value = null
const input: SportInput = {
code: sportForm.value.code.trim(),
name: sportForm.value.name.trim(),
shortName: sportForm.value.shortName.trim() === '' ? null : sportForm.value.shortName.trim(),
defaultProfileId: sportForm.value.defaultProfileId === '' ? null : Number(sportForm.value.defaultProfileId),
sortOrder: Number(sportForm.value.sortOrder),
active: sportForm.value.active,
}
try {
const saved = await data.saveSport(input, sportEditingId.value)
toast.success(sportEditingId.value === null ? '大项已新增' : '大项已保存')
sportDialog.value = false
if (sportEditingId.value === null) selectedSportId.value = saved.id
} catch (caught) {
sportFormError.value = caught instanceof Error ? caught.message : '保存失败'
} finally {
sportPending.value = false
}
}
function askDeleteSport(): void {
if (selectedSport.value === null) return
if (sportEvents.value.length > 0) {
toast.warning('该大项下还有小项', `请先删除 ${sportEvents.value.length} 个小项,再删除大项`)
return
}
sportConfirmOpen.value = true
}
async function confirmDeleteSport(): Promise<void> {
if (selectedSport.value === null) return
sportConfirmPending.value = true
try {
await data.removeSport(selectedSport.value.id)
toast.success('大项已删除')
sportConfirmOpen.value = false
} catch (caught) {
toast.error('删除失败', caught instanceof Error ? caught.message : null)
} finally {
sportConfirmPending.value = false
}
}
// ── 小项 ────────────────────────────────────────────────────────────────────
interface EventForm {
sportId: string
code: string
name: string
eventType: EventType
resultMode: ResultMode
resultType: ResultType
compare: CompareMode
scored: boolean
recordEligible: boolean
multiplier: string
status: EventStatus
scheduledAt: string
venue: string
sortOrder: string
remark: string
}
type EventField = keyof EventForm
const eventDialog = ref(false)
const eventEditingId = ref<number | null>(null)
const eventForm = ref<EventForm>(emptyEventForm())
const eventErrors = ref<Partial<Record<EventField, string>>>({})
const eventFormError = ref<string | null>(null)
const eventPending = ref(false)
const eventConfirmOpen = ref(false)
const eventConfirmTarget = ref<Event | null>(null)
const eventConfirmPending = ref(false)
function emptyEventForm(): EventForm {
return {
sportId: '',
code: '',
name: '',
eventType: 'individual',
resultMode: 'auto',
resultType: 'time',
compare: 'asc',
scored: true,
recordEligible: true,
multiplier: '1',
status: 'pending',
scheduledAt: '',
venue: '',
sortOrder: '0',
remark: '',
}
}
function toLocalInput(iso: string | null): string {
if (iso === null || iso === '') return ''
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
const pad = (value: number): string => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function fromLocalInput(value: string): string | null {
const trimmed = value.trim()
if (trimmed === '') return null
const date = new Date(trimmed)
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
function openCreateEvent(): void {
eventEditingId.value = null
eventForm.value = emptyEventForm()
eventForm.value.sportId = selectedSportId.value === null ? '' : String(selectedSportId.value)
eventForm.value.sortOrder = String(sportEvents.value.length + 1)
eventErrors.value = {}
eventFormError.value = null
eventDialog.value = true
}
function openEditEvent(event: Event): void {
eventEditingId.value = event.id
eventForm.value = {
sportId: String(event.sportId),
code: event.code,
name: event.name,
eventType: event.eventType,
resultMode: event.resultMode,
resultType: event.resultType,
compare: event.compare,
scored: event.scored,
recordEligible: event.recordEligible,
multiplier: String(event.multiplier),
status: event.status,
scheduledAt: toLocalInput(event.scheduledAt),
venue: event.venue ?? '',
sortOrder: String(event.sortOrder),
remark: event.remark ?? '',
}
eventErrors.value = {}
eventFormError.value = null
eventDialog.value = true
}
function validateEvent(): boolean {
const next: Partial<Record<EventField, string>> = {}
if (eventForm.value.sportId === '') next.sportId = '必须归属一个大项'
const code = eventForm.value.code.trim()
if (code === '') next.code = '代码必填'
else if (!CODE_PATTERN.test(code)) next.code = '只能用字母、数字、下划线或短横线'
else if (data.events.value.some((event) => event.id !== eventEditingId.value && event.code.toLowerCase() === code.toLowerCase())) {
next.code = '代码已存在,必须唯一'
}
if (eventForm.value.name.trim() === '') next.name = '名称必填'
const multiplier = Number(eventForm.value.multiplier)
if (!Number.isFinite(multiplier) || multiplier <= 0 || multiplier > 100) next.multiplier = '权重为 0–100 的正数'
const sortOrder = Number(eventForm.value.sortOrder)
if (!Number.isInteger(sortOrder) || sortOrder < 0 || sortOrder > 9999) next.sortOrder = '排序为 0–9999 的整数'
eventErrors.value = next
return Object.keys(next).length === 0
}
async function submitEvent(): Promise<void> {
if (!validateEvent()) return
eventPending.value = true
eventFormError.value = null
const input: EventInput = {
sportId: Number(eventForm.value.sportId),
code: eventForm.value.code.trim(),
name: eventForm.value.name.trim(),
eventType: eventForm.value.eventType,
resultMode: eventForm.value.resultMode,
resultType: eventForm.value.resultType,
compare: eventForm.value.compare,
recordEligible: eventForm.value.recordEligible,
scored: eventForm.value.scored,
multiplier: Number(eventForm.value.multiplier),
status: eventForm.value.status,
scheduledAt: fromLocalInput(eventForm.value.scheduledAt),
venue: eventForm.value.venue.trim() === '' ? null : eventForm.value.venue.trim(),
sortOrder: Number(eventForm.value.sortOrder),
remark: eventForm.value.remark.trim() === '' ? null : eventForm.value.remark.trim(),
}
try {
const saved = await data.saveEvent(input, eventEditingId.value)
toast.success(eventEditingId.value === null ? '小项已新增' : '小项已保存')
eventDialog.value = false
selectedSportId.value = saved.sportId
} catch (caught) {
eventFormError.value = caught instanceof Error ? caught.message : '保存失败'
} finally {
eventPending.value = false
}
}
function askDeleteEvent(event: Event): void {
eventConfirmTarget.value = event
eventConfirmOpen.value = true
}
async function confirmDeleteEvent(): Promise<void> {
const target = eventConfirmTarget.value
if (target === null) return
eventConfirmPending.value = true
try {
await data.removeEvent(target.id)
toast.success(`已删除「${target.name}」`)
eventConfirmOpen.value = false
} catch (caught) {
toast.error('删除失败', caught instanceof Error ? caught.message : null)
} finally {
eventConfirmPending.value = false
}
}
</script>
<template>
<div class="bd-split">
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">大项</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click="openCreateSport">
<MxIcon name="entry" :size="14" /> 新增
</button>
</div>
<div class="mx-card-body">
<div v-if="data.loading.value && data.sports.value.length === 0" class="mx-empty bd-empty">
<span class="mx-spinner" />
<div class="mx-empty-title">加载中…</div>
</div>
<div v-else-if="data.sports.value.length === 0" class="mx-empty bd-empty">
<div class="mx-empty-title">还没有大项</div>
<div class="mx-empty-desc">大项是项目分组,例如田径 / 球类 / 趣味</div>
</div>
<div v-else class="bd-list">
<button
v-for="sport in data.sports.value"
:key="sport.id"
class="bd-list-item"
:class="{ active: sport.id === selectedSportId }"
type="button"
@click="pickSport(sport.id)"
>
<span class="bd-list-main">
<span>{{ sport.name }}</span>
<small>{{ sport.code }} · {{ data.events.value.filter((event) => event.sportId === sport.id).length }} 个小项</small>
</span>
<span v-if="!sport.active" class="mx-badge mx-badge-neutral">停用</span>
</button>
</div>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">
小项{{ selectedSport === null ? '' : ` · ${selectedSport.name}` }}
</span>
<div class="bd-toolbar-right">
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || selectedSport === null"
@click="selectedSport && openEditSport(selectedSport)"
>
编辑大项
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm danger-text"
type="button"
:disabled="!data.canWrite.value || selectedSport === null"
@click="askDeleteSport"
>
删除大项
</button>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || selectedSportId === null"
@click="openCreateEvent"
>
<MxIcon name="entry" :size="14" /> 新增小项
</button>
</div>
</div>
<div class="mx-card-body">
<div v-if="selectedEvent !== null" class="bd-detail-strip">
<span>当前小项:<strong>{{ selectedEvent.name }}</strong>({{ selectedEvent.code }})</span>
<span>类型:<strong>{{ labelOf(EVENT_TYPE_OPTIONS, selectedEvent.eventType) }}</strong></span>
<span>录入:<strong>{{ labelOf(RESULT_MODE_OPTIONS, selectedEvent.resultMode) }}</strong></span>
<span>成绩:<strong>{{ labelOf(RESULT_TYPE_OPTIONS, selectedEvent.resultType) }}</strong></span>
<span>比较:<strong>{{ labelOf(COMPARE_OPTIONS, selectedEvent.compare) }}</strong></span>
<span>计分:<strong>{{ selectedEvent.scored ? `是(权重 ${selectedEvent.multiplier})` : '否(只排名)' }}</strong></span>
<span>组别:<strong>{{ selectedCategories.length }}</strong></span>
<span>通道:<strong>{{ selectedChannels.length }}</strong> · 录取名次:<strong>{{ awardSummary }}</strong></span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="emit('goTab', 'categories')">配置组别</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="emit('goTab', 'channels')">配置计分通道</button>
</div>
<p v-else class="bd-note">在下方表格中点击一行,或在上方选择小项,即可查看组别与计分通道。</p>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>代码</th>
<th>名称</th>
<th>类型</th>
<th>录入方式</th>
<th>成绩</th>
<th>比较</th>
<th class="center">计分</th>
<th class="right">权重</th>
<th>状态</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="event in sportEvents"
:key="event.id"
:class="{ selected: event.id === data.selectedEventId.value }"
class="clickable"
@click="data.selectEvent(event.id)"
>
<td class="bd-mono">{{ event.code }}</td>
<td>{{ event.name }}</td>
<td>{{ labelOf(EVENT_TYPE_OPTIONS, event.eventType) }}</td>
<td>{{ labelOf(RESULT_MODE_OPTIONS, event.resultMode) }}</td>
<td>{{ labelOf(RESULT_TYPE_OPTIONS, event.resultType) }}</td>
<td>{{ labelOf(COMPARE_OPTIONS, event.compare) }}</td>
<td class="center">
<span class="mx-badge" :class="event.scored ? 'mx-badge-info' : 'mx-badge-neutral'">
{{ event.scored ? '计分' : '不计分' }}
</span>
</td>
<td class="right bd-mono">{{ event.multiplier }}</td>
<td>{{ labelOf(STATUS_OPTIONS, event.status) }}</td>
<td>
<div class="bd-actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click.stop="openEditEvent(event)">
编辑
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm danger-text"
type="button"
:disabled="!data.canWrite.value"
@click.stop="askDeleteEvent(event)"
>
删除
</button>
</div>
</td>
</tr>
<tr v-if="sportEvents.length === 0">
<td colspan="10">
<div class="mx-empty bd-empty">
<div class="mx-empty-title">
{{ selectedSportId === null ? '请先选择或新增大项' : '该大项下还没有小项' }}
</div>
<div class="mx-empty-desc">小项是最小计分单位,组别与计分通道都挂在它下面</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="channelSummary.length > 0" class="channel-list">
<p class="bd-note">当前小项的计分通道(名次来源 → 积分模板 → 录取名次):</p>
<ul>
<li v-for="channel in channelSummary" :key="channel.id">
<span class="mx-badge mx-badge-info">{{ channel.scope }}</span>
<span>{{ channel.basis }}</span>
<span class="bd-mono">{{ channel.profile }}</span>
<span class="bd-note">录取 {{ channel.award === null ? '—' : `前 ${channel.award} 名` }}</span>
</li>
</ul>
</div>
</div>
</section>
<FormDialog
:open="sportDialog"
:title="sportEditingId === null ? '新增大项' : '编辑大项'"
subtitle="大项只做分组与默认积分模板,具体计分规则配在小项的通道上"
:pending="sportPending"
:error="sportFormError"
@submit="submitSport"
@cancel="sportDialog = false"
>
<div class="bd-form-grid">
<FormField label="代码" required :error="sportErrors.code">
<input v-model="sportForm.code" class="mx-cell-input" placeholder="例如 TJ" />
</FormField>
<FormField label="名称" required :error="sportErrors.name">
<input v-model="sportForm.name" class="mx-cell-input" placeholder="例如 田径" />
</FormField>
<FormField label="简称">
<input v-model="sportForm.shortName" class="mx-cell-input" placeholder="例如 田径" />
</FormField>
<FormField label="默认积分模板" hint="新建小项时作为参考,可留空">
<select v-model="sportForm.defaultProfileId" class="mx-cell-input">
<option value="">不指定</option>
<option v-for="profile in data.profiles.value" :key="profile.id" :value="String(profile.id)">
{{ profile.name }}
</option>
</select>
</FormField>
<FormField label="排序" :error="sportErrors.sortOrder">
<input v-model="sportForm.sortOrder" class="mx-cell-input" type="number" min="0" max="9999" />
</FormField>
<FormField label="状态">
<label class="bd-check"><input v-model="sportForm.active" type="checkbox" /> 启用</label>
</FormField>
</div>
</FormDialog>
<FormDialog
:open="eventDialog"
:title="eventEditingId === null ? '新增小项' : '编辑小项'"
subtitle="录入方式与成绩类型决定成绩页的输入与自动排名行为"
:pending="eventPending"
:error="eventFormError"
wide
@submit="submitEvent"
@cancel="eventDialog = false"
>
<div class="bd-form-grid">
<FormField label="所属大项" required :error="eventErrors.sportId">
<select v-model="eventForm.sportId" class="mx-cell-input">
<option value="" disabled>请选择大项</option>
<option v-for="sport in data.sports.value" :key="sport.id" :value="String(sport.id)">{{ sport.name }}</option>
</select>
</FormField>
<FormField label="代码" required :error="eventErrors.code" hint="报名导入按此列匹配">
<input v-model="eventForm.code" class="mx-cell-input" placeholder="例如 M100" />
</FormField>
<FormField label="名称" required :error="eventErrors.name" wide>
<input v-model="eventForm.name" class="mx-cell-input" placeholder="例如 男子 100 米" />
</FormField>
<FormField label="项目类型" hint="仅用于展示归类">
<select v-model="eventForm.eventType" class="mx-cell-input">
<option v-for="option in EVENT_TYPE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</FormField>
<FormField label="录入方式" hint="manual 时直接录名次,不按成绩排序">
<select v-model="eventForm.resultMode" class="mx-cell-input">
<option v-for="option in RESULT_MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</FormField>
<FormField label="成绩类型">
<select v-model="eventForm.resultType" class="mx-cell-input">
<option v-for="option in RESULT_TYPE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</FormField>
<FormField label="比较方式" hint="asc 越小越好 / desc 越大越好">
<select v-model="eventForm.compare" class="mx-cell-input">
<option v-for="option in COMPARE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</FormField>
<FormField label="权重" :error="eventErrors.multiplier" hint="最终得分 = 模板分值 × 权重">
<input v-model="eventForm.multiplier" class="mx-cell-input" type="number" min="0.1" step="0.1" />
</FormField>
<FormField label="排序" :error="eventErrors.sortOrder">
<input v-model="eventForm.sortOrder" class="mx-cell-input" type="number" min="0" max="9999" />
</FormField>
<FormField label="状态">
<select v-model="eventForm.status" class="mx-cell-input">
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</FormField>
<FormField label="计划时间">
<input v-model="eventForm.scheduledAt" class="mx-cell-input" type="datetime-local" />
</FormField>
<FormField label="场地">
<input v-model="eventForm.venue" class="mx-cell-input" placeholder="例如 田径场" />
</FormField>
<FormField label="备注" wide>
<input v-model="eventForm.remark" class="mx-cell-input" placeholder="可留空" />
</FormField>
<FormField label="选项" wide>
<div class="check-row">
<label class="bd-check"><input v-model="eventForm.scored" type="checkbox" /> 计分(取消则只排名不计分)</label>
<label class="bd-check"><input v-model="eventForm.recordEligible" type="checkbox" /> 参与纪录榜</label>
</div>
</FormField>
</div>
</FormDialog>
<ConfirmDialog
:open="sportConfirmOpen"
tone="danger"
title="删除该大项?"
:message="`将删除大项「${selectedSport?.name ?? ''}」,此操作不可恢复。`"
confirm-text="删除大项"
:pending="sportConfirmPending"
@confirm="confirmDeleteSport"
@cancel="sportConfirmOpen = false"
/>
<ConfirmDialog
:open="eventConfirmOpen"
tone="danger"
title="删除该小项?"
:message="`将删除小项「${eventConfirmTarget?.name ?? ''}」及其组别、计分通道配置。`"
:details="['若该项目已有报名数据,服务端会拒绝删除', '删除后需要重新配置组别与计分通道']"
confirm-text="删除小项"
:pending="eventConfirmPending"
@confirm="confirmDeleteEvent"
@cancel="eventConfirmOpen = false"
/>
</div>
</template>
<style scoped>
.danger-text {
color: var(--mx-red);
}
tr.selected {
background: var(--mx-primary-glow);
}
tr.clickable {
cursor: pointer;
}
.channel-list {
margin-top: 14px;
}
.channel-list ul {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.channel-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s2);
font-size: 12px;
color: var(--mx-t2);
}
.check-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,321 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Team, TeamInput } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import FormDialog from './FormDialog.vue'
import FormField from './FormField.vue'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
const data = useBaseData()
const toast = useToastStore()
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,16}$/
const COLOR_PATTERN = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
interface TeamForm {
code: string
name: string
shortName: string
logoPath: string
color: string
sortOrder: string
active: boolean
}
type TeamField = keyof TeamForm
const search = ref('')
const statusFilter = ref<'all' | 'active' | 'inactive'>('all')
const dialogOpen = ref(false)
const editingId = ref<number | null>(null)
const form = ref<TeamForm>(emptyForm())
const errors = ref<Partial<Record<TeamField, string>>>({})
const formError = ref<string | null>(null)
const pending = ref(false)
const confirmOpen = ref(false)
const confirmTarget = ref<Team | null>(null)
const confirmPending = ref(false)
function emptyForm(): TeamForm {
return { code: '', name: '', shortName: '', logoPath: '', color: '#3482FF', sortOrder: '0', active: true }
}
const filtered = computed(() => {
const keyword = search.value.trim().toLowerCase()
return data.teams.value.filter((team) => {
if (statusFilter.value === 'active' && !team.active) return false
if (statusFilter.value === 'inactive' && team.active) return false
if (keyword === '') return true
return (
team.code.toLowerCase().includes(keyword) ||
team.name.toLowerCase().includes(keyword) ||
(team.shortName ?? '').toLowerCase().includes(keyword)
)
})
})
const activeCount = computed(() => data.teams.value.filter((team) => team.active).length)
function openCreate(): void {
editingId.value = null
form.value = emptyForm()
form.value.sortOrder = String(data.teams.value.length + 1)
errors.value = {}
formError.value = null
dialogOpen.value = true
}
function openEdit(team: Team): void {
editingId.value = team.id
form.value = {
code: team.code,
name: team.name,
shortName: team.shortName ?? '',
logoPath: team.logoPath ?? '',
color: team.color ?? '',
sortOrder: String(team.sortOrder),
active: team.active,
}
errors.value = {}
formError.value = null
dialogOpen.value = true
}
function validate(): boolean {
const next: Partial<Record<TeamField, string>> = {}
const code = form.value.code.trim()
if (code === '') next.code = '代码必填'
else if (!CODE_PATTERN.test(code)) next.code = '只能用字母、数字、下划线或短横线,最长 16 位'
else if (data.teams.value.some((team) => team.id !== editingId.value && team.code.toLowerCase() === code.toLowerCase())) {
next.code = '代码已存在,必须唯一'
}
if (form.value.name.trim() === '') next.name = '全称必填'
const color = form.value.color.trim()
if (color !== '' && !COLOR_PATTERN.test(color)) next.color = '颜色格式应为 #RGB 或 #RRGGBB'
const sortOrder = Number(form.value.sortOrder)
if (!Number.isInteger(sortOrder) || sortOrder < 0 || sortOrder > 9999) next.sortOrder = '排序为 0–9999 的整数'
errors.value = next
return Object.keys(next).length === 0
}
async function submit(): Promise<void> {
if (!validate()) return
pending.value = true
formError.value = null
const input: TeamInput = {
code: form.value.code.trim(),
name: form.value.name.trim(),
shortName: form.value.shortName.trim() === '' ? null : form.value.shortName.trim(),
logoPath: form.value.logoPath.trim() === '' ? null : form.value.logoPath.trim(),
color: form.value.color.trim() === '' ? null : form.value.color.trim(),
sortOrder: Number(form.value.sortOrder),
active: form.value.active,
}
try {
await data.saveTeam(input, editingId.value)
toast.success(editingId.value === null ? '单位已新增' : '单位已保存')
dialogOpen.value = false
} catch (caught) {
formError.value = caught instanceof Error ? caught.message : '保存失败'
} finally {
pending.value = false
}
}
function askToggle(team: Team): void {
if (team.active) {
confirmTarget.value = team
confirmOpen.value = true
return
}
void toggleActive(team)
}
async function toggleActive(team: Team): Promise<void> {
confirmPending.value = true
try {
await data.saveTeam(
{
code: team.code,
name: team.name,
shortName: team.shortName,
logoPath: team.logoPath,
color: team.color,
sortOrder: team.sortOrder,
active: !team.active,
},
team.id,
)
toast.success(team.active ? `已停用「${team.name}」` : `已启用「${team.name}」`)
confirmOpen.value = false
} catch (caught) {
toast.error('操作失败', caught instanceof Error ? caught.message : null)
} finally {
confirmPending.value = false
}
}
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">参赛单位</span>
<span class="mx-badge mx-badge-neutral">{{ activeCount }} / {{ data.teams.value.length }} 启用</span>
</div>
<div class="mx-card-body">
<div class="bd-toolbar">
<div class="bd-toolbar-left">
<div class="mx-input-wrap bd-search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索代码 / 全称 / 简称" />
</div>
<select v-model="statusFilter" class="mx-cell-input bd-select">
<option value="all">全部状态</option>
<option value="active">仅启用</option>
<option value="inactive">仅停用</option>
</select>
</div>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-primary" type="button" :disabled="!data.canWrite.value" @click="openCreate">
<MxIcon name="entry" :size="15" /> 新增单位
</button>
</div>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>代码</th>
<th>全称</th>
<th>简称</th>
<th>主题色</th>
<th class="right">排序</th>
<th>状态</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="data.loading.value && data.teams.value.length === 0">
<td colspan="7">
<div class="mx-empty bd-empty">
<span class="mx-spinner" />
<div class="mx-empty-title">加载中…</div>
</div>
</td>
</tr>
<template v-else>
<tr v-for="team in filtered" :key="team.id">
<td class="bd-mono">{{ team.code }}</td>
<td>{{ team.name }}</td>
<td>{{ team.shortName ?? '—' }}</td>
<td>
<span v-if="team.color" class="bd-color" :style="{ background: team.color }" :title="team.color" />
<span v-else class="bd-note">—</span>
</td>
<td class="right bd-mono">{{ team.sortOrder }}</td>
<td>
<span class="mx-badge" :class="team.active ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ team.active ? '启用' : '已停用' }}
</span>
</td>
<td>
<div class="bd-actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click="openEdit(team)">
编辑
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
:class="{ 'danger-text': team.active }"
type="button"
:disabled="!data.canWrite.value"
@click="askToggle(team)"
>
{{ team.active ? '停用' : '启用' }}
</button>
</div>
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="7">
<div class="mx-empty bd-empty">
<div class="mx-empty-title">{{ search === '' ? '还没有参赛单位' : '没有匹配的单位' }}</div>
<div class="mx-empty-desc">单位代码用于导出与排序,新增后不可重复</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<p class="bd-note">停用为软删除:历史成绩与积分流水保留,新报名不再可选该单位。</p>
</div>
<FormDialog
:open="dialogOpen"
:title="editingId === null ? '新增参赛单位' : '编辑参赛单位'"
subtitle="代码唯一且用于导出;简称优先用于大屏显示"
:pending="pending"
:error="formError"
@submit="submit"
@cancel="dialogOpen = false"
>
<div class="bd-form-grid">
<FormField label="代码" required :error="errors.code" hint="字母 / 数字 / _ / -,最长 16 位">
<input v-model="form.code" class="mx-cell-input" placeholder="例如 F1" />
</FormField>
<FormField label="全称" required :error="errors.name">
<input v-model="form.name" class="mx-cell-input" placeholder="例如 第一分公司" />
</FormField>
<FormField label="简称" hint="大屏空间有限时优先显示">
<input v-model="form.shortName" class="mx-cell-input" placeholder="例如 一分公司" />
</FormField>
<FormField label="主题色" :error="errors.color" hint="用于图表与积分榜标识">
<div class="color-row">
<input v-model="form.color" class="mx-cell-input" placeholder="#3482FF" />
<span v-if="form.color" class="bd-color" :style="{ background: form.color }" />
</div>
</FormField>
<FormField label="Logo 路径" hint="相对 uploads/,可留空">
<input v-model="form.logoPath" class="mx-cell-input" placeholder="logos/team1.png" />
</FormField>
<FormField label="排序" :error="errors.sortOrder" hint="数字越小越靠前">
<input v-model="form.sortOrder" class="mx-cell-input" type="number" min="0" max="9999" />
</FormField>
<FormField label="状态" wide>
<label class="bd-check">
<input v-model="form.active" type="checkbox" />
启用(取消勾选即停用)
</label>
</FormField>
</div>
</FormDialog>
<ConfirmDialog
:open="confirmOpen"
tone="warning"
title="停用该参赛单位?"
:message="`停用「${confirmTarget?.name ?? ''}」后,新报名不再可选该单位;历史成绩与积分流水保留,可随时重新启用。`"
confirm-text="停用"
:pending="confirmPending"
@confirm="confirmTarget && toggleActive(confirmTarget)"
@cancel="confirmOpen = false"
/>
</section>
</template>
<style scoped>
.color-row {
display: flex;
align-items: center;
gap: 10px;
}
.danger-text {
color: var(--mx-red);
}
</style>
@@ -0,0 +1,211 @@
/*
* 基础数据页的共享布局类。
*
* 七个分区组件各自 scoped 样式,但表格工具栏、两栏分栏、表单网格必须一致,
* 否则同一页里每块的间距都会不一样;这里统一,并在页面里全局引入一次。
*/
.bd-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.bd-toolbar-left,
.bd-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.bd-search {
width: 240px;
}
.bd-select {
width: 180px;
}
.bd-split {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.bd-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.bd-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.bd-form-grid .span-2 {
grid-column: 1 / -1;
}
.bd-note {
font-size: 12px;
line-height: 1.6;
color: var(--mx-t3);
}
.bd-warn {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: var(--mx-r-sm);
background: rgba(251, 191, 36, 0.08);
color: var(--mx-amber);
font-size: 12px;
line-height: 1.6;
margin-bottom: 12px;
}
.bd-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.bd-mono {
font-family: var(--mx-mono);
font-size: 12px;
}
.bd-color {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--mx-divider);
vertical-align: middle;
}
.bd-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 62dvh;
overflow-y: auto;
padding: 4px;
}
.bd-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 9px 10px;
border-radius: var(--mx-r-sm);
background: transparent;
border: 1px solid transparent;
color: var(--mx-t2);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: all var(--mx-d1) var(--mx-ease);
}
.bd-list-item:hover {
background: var(--mx-s-hover);
color: var(--mx-text);
}
.bd-list-item.active {
background: var(--mx-primary-glow);
border-color: var(--mx-primary);
color: var(--mx-primary);
font-weight: 600;
}
.bd-list-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.bd-list-main small {
font-size: 11px;
color: var(--mx-t3);
}
.bd-cell-narrow {
width: 88px;
}
.bd-cell-mini {
width: 72px;
}
.bd-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.bd-detail-strip {
display: flex;
flex-wrap: wrap;
gap: 10px 24px;
padding: 12px 14px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
font-size: 12px;
color: var(--mx-t3);
margin-bottom: 14px;
}
.bd-detail-strip strong {
color: var(--mx-text);
font-weight: 600;
}
.bd-empty {
padding: 32px 0;
}
.bd-check {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--mx-t2);
cursor: pointer;
}
.bd-check input {
width: 16px;
height: 16px;
accent-color: var(--mx-primary);
}
@media (max-width: 1279px) {
.bd-split {
grid-template-columns: 1fr;
}
.bd-list {
max-height: none;
}
}
@media (max-width: 767px) {
.bd-form-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,132 @@
import { ref } from 'vue'
import { ApiError, adminApi, mockMode, mockReason } from '../../api/client'
import type {
Athlete,
AthleteInput,
EntryImportResult,
Event,
EventCategory,
EventCategoryInput,
EventInput,
ScoringProfile,
ScoringProfileInput,
ScoreChannel,
ScoreChannelInput,
Sport,
SportInput,
Team,
TeamInput,
} from '@/shared'
import { mockBaseData } from './mockBaseData'
/**
* 基础数据接口入口。
*
* 复用 adminApi.client 的 REST 方法,但补上与管理端一致的降级策略:
* 只有「服务端未就绪」(网络/超时/解析失败/404)才落回示例数据,
* 业务错误(鉴权、校验、冲突)原样抛出,避免把真实报错伪装成示例数据。
*
* 之所以不直接用 adminApi 的封装:api/client.ts 不在本页允许修改的范围内,
* 且这里需要区分「基础数据接口没实现」与「整个后端没起来」两种降级。
*/
const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED']
/**
* 本页接口的降级标记。
*
* 不能只看全局 mockMode:服务端可能整体正常、只有基础数据接口尚未实现,
* 此时 adminApi.bootstrap 会把 mockMode 重置为 false,页面却仍在用示例数据。
*/
export const baseDataFallback = ref(false)
function isFallback(error: unknown): boolean {
// 404 表示接口在当前服务端尚未实现,与整个后端没起来一样按「未就绪」处理。
return error instanceof ApiError && (FALLBACK_CODES.includes(error.code) || error.status === 404)
}
async function call<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
try {
const value = await real()
baseDataFallback.value = false
if (mockMode.value) {
mockMode.value = false
mockReason.value = null
}
return value
} catch (error) {
if (!isFallback(error)) throw error
baseDataFallback.value = true
mockMode.value = true
mockReason.value = error instanceof Error ? error.message : '无法连接服务端'
return await mock()
}
}
export const baseDataApi = {
teams: {
list: (): Promise<Team[]> => call(() => adminApi.client.teams.list(), () => mockBaseData.listTeams()),
create: (input: TeamInput): Promise<Team> =>
call(() => adminApi.client.teams.create(input), () => mockBaseData.createTeam(input)),
update: (id: number, input: TeamInput): Promise<Team> =>
call(() => adminApi.client.teams.update(id, input), () => mockBaseData.updateTeam(id, input)),
},
sports: {
list: (): Promise<Sport[]> => call(() => adminApi.client.sports.list(), () => mockBaseData.listSports()),
create: (input: SportInput): Promise<Sport> =>
call(() => adminApi.client.sports.create(input), () => mockBaseData.createSport(input)),
update: (id: number, input: SportInput): Promise<Sport> =>
call(() => adminApi.client.sports.update(id, input), () => mockBaseData.updateSport(id, input)),
remove: (id: number): Promise<void> =>
call(() => adminApi.client.sports.remove(id), () => mockBaseData.removeSport(id)),
},
events: {
list: (): Promise<Event[]> => call(() => adminApi.client.events.list(), () => mockBaseData.listEvents()),
get: (id: number): Promise<Event> => call(() => adminApi.client.events.get(id), () => mockBaseData.getEvent(id)),
create: (input: EventInput): Promise<Event> =>
call(() => adminApi.client.events.create(input), () => mockBaseData.createEvent(input)),
update: (id: number, input: EventInput): Promise<Event> =>
call(() => adminApi.client.events.update(id, input), () => mockBaseData.updateEvent(id, input)),
remove: (id: number): Promise<void> =>
call(() => adminApi.client.events.remove(id), () => mockBaseData.removeEvent(id)),
saveCategories: (eventId: number, inputs: EventCategoryInput[]): Promise<EventCategory[]> =>
call(
() => adminApi.client.events.saveCategories(eventId, inputs),
() => mockBaseData.saveCategories(eventId, inputs),
),
saveChannels: (eventId: number, inputs: ScoreChannelInput[]): Promise<ScoreChannel[]> =>
call(
() => adminApi.client.events.saveChannels(eventId, inputs),
() => mockBaseData.saveChannels(eventId, inputs),
),
},
profiles: {
list: (): Promise<ScoringProfile[]> =>
call(() => adminApi.client.scoringProfiles.list(), () => mockBaseData.listProfiles()),
create: (input: ScoringProfileInput): Promise<ScoringProfile> =>
call(() => adminApi.client.scoringProfiles.create(input), () => mockBaseData.createProfile(input)),
update: (id: number, input: ScoringProfileInput): Promise<ScoringProfile> =>
call(() => adminApi.client.scoringProfiles.update(id, input), () => mockBaseData.updateProfile(id, input)),
},
athletes: {
list: (): Promise<Athlete[]> => call(() => adminApi.client.athletes.list(), () => mockBaseData.listAthletes()),
create: (input: AthleteInput): Promise<Athlete> =>
call(() => adminApi.client.athletes.create(input), () => mockBaseData.createAthlete(input)),
update: (id: number, input: AthleteInput): Promise<Athlete> =>
call(() => adminApi.client.athletes.update(id, input), () => mockBaseData.updateAthlete(id, input)),
remove: (id: number): Promise<void> =>
call(() => adminApi.client.athletes.remove(id), () => mockBaseData.removeAthlete(id)),
},
entries: {
importFile: (file: File): Promise<EntryImportResult> =>
call(
() => adminApi.client.entries.importFile(file),
() => mockBaseData.importEntries(file),
),
},
}
@@ -0,0 +1,529 @@
import type {
Athlete,
AthleteInput,
EntryImportResult,
Event,
EventCategory,
EventCategoryInput,
EventInput,
ScoringProfile,
ScoringProfileInput,
ScoreChannel,
ScoreChannelInput,
Sport,
SportInput,
Team,
TeamInput,
} from '@/shared'
/**
* 基础数据页的离线示例数据源。
*
* 后端未就绪(连接失败 / 接口未实现)时页面降级到这里,保证每个分区都能走通
* 「列表 → 新增 → 编辑 → 停用/删除 → 整体保存」的完整交互。数据只在内存里,
* 刷新即重置;界面必须显式标注「示例数据」。
*/
const TEAM_COLORS = ['#3482FF', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#FB923C']
const SEED_TEAMS: Team[] = [
['JG', '机关本部', '机关'],
['F1', '第一分公司', '一分公司'],
['F2', '第二分公司', '二分公司'],
['F3', '第三分公司', '三分公司'],
['F4', '第四分公司', '四分公司'],
['F5', '第五分公司', '五分公司'],
['F6', '第六分公司', '六分公司'],
].map(([code, name, short], index) => ({
id: index + 1,
code: code ?? '',
name: name ?? '',
shortName: short ?? null,
logoPath: null,
color: TEAM_COLORS[index] ?? null,
sortOrder: index + 1,
active: true,
}))
const SEED_SPORTS: Sport[] = [
{ id: 1, code: 'TJ', name: '田径', shortName: '田径', defaultProfileId: 1, sortOrder: 1, active: true },
{ id: 2, code: 'QL', name: '球类', shortName: '球类', defaultProfileId: 2, sortOrder: 2, active: true },
{ id: 3, code: 'QW', name: '趣味', shortName: '趣味', defaultProfileId: 2, sortOrder: 3, active: true },
]
const SEED_PROFILES: ScoringProfile[] = [
{
id: 1,
code: 'individual',
name: '个人项目前六名',
tieMode: 'skip',
recordBonusMode: 'max',
awardRanks: 6,
lastPlaceZero: false,
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 8 },
{ rank: 3, points: 6 },
{ rank: 4, points: 3 },
{ rank: 5, points: 2 },
{ rank: 6, points: 1 },
],
},
{
id: 2,
code: 'team',
name: '团体项目前六名',
tieMode: 'skip',
recordBonusMode: 'max',
awardRanks: 6,
lastPlaceZero: false,
rules: [
{ rank: 1, points: 15 },
{ rank: 2, points: 10 },
{ rank: 3, points: 8 },
{ rank: 4, points: 4 },
{ rank: 5, points: 2 },
{ rank: 6, points: 1 },
],
},
]
interface EventSeed {
event: Omit<Event, 'id' | 'categories' | 'channels'>
category: string
scope: ScoreChannel['scope']
basis: ScoreChannel['basis']
profileId: number
/** 示例报名条数:服务端有报名就不允许删项目 / 整体替换组别,这里同步模拟。 */
entryCount: number
}
const SEED_EVENTS: EventSeed[] = [
{
event: {
sportId: 1, code: 'M100', name: '男子 100 米', eventType: 'individual', resultMode: 'auto',
resultType: 'time', compare: 'asc', recordEligible: true, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 1, remark: null,
},
category: '男子组', scope: 'athlete', basis: 'result', profileId: 1,
entryCount: 7,
},
{
event: {
sportId: 1, code: 'W100', name: '女子 100 米', eventType: 'individual', resultMode: 'auto',
resultType: 'time', compare: 'asc', recordEligible: true, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 2, remark: null,
},
category: '女子组', scope: 'athlete', basis: 'result', profileId: 1,
entryCount: 0,
},
{
event: {
sportId: 1, code: 'M800', name: '男子 800 米', eventType: 'individual', resultMode: 'auto',
resultType: 'time', compare: 'asc', recordEligible: true, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 3, remark: null,
},
category: '男子组', scope: 'athlete', basis: 'result', profileId: 1,
entryCount: 5,
},
{
event: {
sportId: 1, code: 'LJ', name: '跳远', eventType: 'individual', resultMode: 'auto',
resultType: 'distance', compare: 'desc', recordEligible: true, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 4, remark: null,
},
category: '男子组', scope: 'athlete', basis: 'result', profileId: 1,
entryCount: 0,
},
{
event: {
sportId: 3, code: 'PU', name: '引体向上', eventType: 'individual', resultMode: 'auto',
resultType: 'count', compare: 'desc', recordEligible: false, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '体育馆', sortOrder: 5, remark: null,
},
category: '男子组', scope: 'athlete', basis: 'result', profileId: 1,
entryCount: 0,
},
{
event: {
sportId: 2, code: 'BB', name: '篮球(团体)', eventType: 'team', resultMode: 'manual',
resultType: 'score', compare: 'none', recordEligible: false, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '体育馆', sortOrder: 6, remark: null,
},
category: '混合组', scope: 'team', basis: 'manual', profileId: 2,
entryCount: 3,
},
{
event: {
sportId: 3, code: 'FUN', name: '趣味接力(不计分)', eventType: 'team', resultMode: 'manual',
resultType: 'none', compare: 'none', recordEligible: false, scored: false, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '体育馆', sortOrder: 7, remark: null,
},
category: '混合组', scope: 'team', basis: 'manual', profileId: 2,
entryCount: 0,
},
]
const ATHLETE_NAMES = [
'张伟', '李强', '王芳', '刘洋', '陈晨', '赵磊', '孙倩', '周涛',
'吴敏', '郑浩', '冯雪', '褚健', '卫东', '蒋楠', '沈莉', '韩旭',
'杨帆', '朱琳', '秦峰', '许诺', '何静', '吕明', '施雨', '张晓',
]
/** 报名表模板列,与服务端 importColumns 保持一致(大小写不敏感)。 */
const IMPORT_COLUMNS = [
'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names',
]
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
class MockBaseData {
private teams: Team[] = SEED_TEAMS.map((team) => ({ ...team }))
private sports: Sport[] = SEED_SPORTS.map((sport) => ({ ...sport }))
private profiles: ScoringProfile[] = SEED_PROFILES.map((profile) => ({
...profile,
rules: profile.rules.map((rule) => ({ ...rule })),
}))
private events: Event[] = []
private categories: EventCategory[] = []
private channels: ScoreChannel[] = []
private athletes: Athlete[] = []
private nextTeamId = 100
private nextSportId = 100
private nextEventId = 1000
private nextCategoryId = 1000
private nextChannelId = 1000
private nextProfileId = 100
private nextAthleteId = 1000
/** 每个小项的示例报名条数,用于复刻服务端「有报名就不能删项目 / 替换组别」的约束。 */
private readonly entryCounts = new Map<number, number>()
constructor() {
SEED_EVENTS.forEach((seed, index) => {
const id = 101 + index
this.events.push({ ...seed.event, id })
this.entryCounts.set(id, seed.entryCount)
this.categories.push({
id: (this.nextCategoryId += 1),
eventId: id,
name: seed.category,
multiplier: 1,
gender: seed.category.startsWith('男子') ? '男' : seed.category.startsWith('女子') ? '女' : null,
ageMin: null,
ageMax: null,
sortOrder: 1,
})
this.channels.push({
id: (this.nextChannelId += 1),
eventId: id,
scope: seed.scope,
basis: seed.basis,
profileId: seed.profileId,
sortOrder: 1,
})
})
this.teams.forEach((team, teamIndex) => {
ATHLETE_NAMES.slice(teamIndex * 3, teamIndex * 3 + 3).forEach((name, nameIndex) => {
this.athletes.push({
id: (this.nextAthleteId += 1),
name,
gender: nameIndex % 2 === 0 ? '男' : '女',
teamId: team.id,
employeeNo: `${team.code}-${String(nameIndex + 1).padStart(3, '0')}`,
active: true,
})
})
})
}
// ── 参赛单位 ──────────────────────────────────────────────────────────────
listTeams(): Team[] {
return this.teams.map((team) => ({ ...team }))
}
createTeam(input: TeamInput): Team {
if (this.teams.some((team) => team.code === input.code)) throw new Error(`单位代码 ${input.code} 已存在`)
const team: Team = { ...input, id: (this.nextTeamId += 1) }
this.teams = [...this.teams, team]
return { ...team }
}
updateTeam(id: number, input: TeamInput): Team {
const index = this.teams.findIndex((team) => team.id === id)
if (index < 0) throw new Error(`单位 ${id} 不存在`)
if (this.teams.some((team) => team.id !== id && team.code === input.code)) {
throw new Error(`单位代码 ${input.code} 已存在`)
}
const team: Team = { ...input, id }
this.teams[index] = team
return { ...team }
}
// ── 大项 ──────────────────────────────────────────────────────────────────
listSports(): Sport[] {
return this.sports.map((sport) => ({ ...sport }))
}
createSport(input: SportInput): Sport {
if (this.sports.some((sport) => sport.code === input.code)) throw new Error(`大项代码 ${input.code} 已存在`)
const sport: Sport = { ...input, id: (this.nextSportId += 1) }
this.sports = [...this.sports, sport]
return { ...sport }
}
updateSport(id: number, input: SportInput): Sport {
const index = this.sports.findIndex((sport) => sport.id === id)
if (index < 0) throw new Error(`大项 ${id} 不存在`)
const sport: Sport = { ...input, id }
this.sports[index] = sport
return { ...sport }
}
removeSport(id: number): void {
const index = this.sports.findIndex((sport) => sport.id === id)
if (index < 0) throw new Error(`大项 ${id} 不存在`)
if (this.events.some((event) => event.sportId === id)) throw new Error('该大项下还有小项,请先删除小项')
this.sports.splice(index, 1)
}
// ── 小项 ──────────────────────────────────────────────────────────────────
listEvents(): Event[] {
return this.events.map((event) => ({ ...event }))
}
getEvent(id: number): Event {
const event = this.events.find((item) => item.id === id)
if (event === undefined) throw new Error(`小项 ${id} 不存在`)
return {
...event,
categories: this.categories.filter((item) => item.eventId === id).map((item) => ({ ...item })),
channels: this.channels.filter((item) => item.eventId === id).map((item) => ({ ...item })),
}
}
createEvent(input: EventInput): Event {
if (this.events.some((event) => event.code === input.code)) throw new Error(`小项代码 ${input.code} 已存在`)
const id = (this.nextEventId += 1)
const event: Event = { ...input, id }
this.events = [...this.events, event]
this.entryCounts.set(id, 0)
return { ...event }
}
updateEvent(id: number, input: EventInput): Event {
const index = this.events.findIndex((event) => event.id === id)
if (index < 0) throw new Error(`小项 ${id} 不存在`)
if (this.events.some((event) => event.id !== id && event.code === input.code)) {
throw new Error(`小项代码 ${input.code} 已存在`)
}
const event: Event = { ...input, id }
this.events[index] = event
return { ...event }
}
removeEvent(id: number): void {
const index = this.events.findIndex((event) => event.id === id)
if (index < 0) throw new Error(`小项 ${id} 不存在`)
if ((this.entryCounts.get(id) ?? 0) > 0) throw new Error('小项已有报名数据,不能删除')
this.events.splice(index, 1)
this.entryCounts.delete(id)
this.categories = this.categories.filter((item) => item.eventId !== id)
this.channels = this.channels.filter((item) => item.eventId !== id)
}
saveCategories(eventId: number, inputs: EventCategoryInput[]): EventCategory[] {
this.getEvent(eventId)
if ((this.entryCounts.get(eventId) ?? 0) > 0) throw new Error('该项目已有报名数据,不能整体替换组别')
const names = new Set<string>()
for (const input of inputs) {
const name = input.name.trim()
if (name === '') throw new Error('组别名称必填')
if (names.has(name)) throw new Error(`组别名称重复:${name}`)
names.add(name)
}
this.categories = this.categories.filter((item) => item.eventId !== eventId)
for (const input of inputs) {
this.categories.push({ ...input, id: (this.nextCategoryId += 1), eventId })
}
return this.getEvent(eventId).categories ?? []
}
saveChannels(eventId: number, inputs: ScoreChannelInput[]): ScoreChannel[] {
this.getEvent(eventId)
const combinations = new Set<string>()
for (const input of inputs) {
const key = `${input.scope}:${input.basis}`
if (combinations.has(key)) throw new Error(`同一 scope+basis 只能配置一个通道:${key}`)
combinations.add(key)
}
this.channels = this.channels.filter((item) => item.eventId !== eventId)
for (const input of inputs) {
this.channels.push({ ...input, id: (this.nextChannelId += 1), eventId })
}
return this.getEvent(eventId).channels ?? []
}
// ── 积分模板 ──────────────────────────────────────────────────────────────
listProfiles(): ScoringProfile[] {
return this.profiles.map((profile) => ({ ...profile, rules: profile.rules.map((rule) => ({ ...rule })) }))
}
createProfile(input: ScoringProfileInput): ScoringProfile {
if (this.profiles.some((profile) => profile.code === input.code)) throw new Error(`模板代码 ${input.code} 已存在`)
const profile: ScoringProfile = {
...input,
id: (this.nextProfileId += 1),
rules: input.rules.map((rule) => ({ ...rule })),
}
this.profiles = [...this.profiles, profile]
return { ...profile, rules: profile.rules.map((rule) => ({ ...rule })) }
}
updateProfile(id: number, input: ScoringProfileInput): ScoringProfile {
const index = this.profiles.findIndex((profile) => profile.id === id)
if (index < 0) throw new Error(`积分模板 ${id} 不存在`)
const profile: ScoringProfile = {
...input,
id,
rules: input.rules.map((rule) => ({ ...rule })),
}
this.profiles[index] = profile
return { ...profile, rules: profile.rules.map((rule) => ({ ...rule })) }
}
// ── 运动员 ────────────────────────────────────────────────────────────────
listAthletes(): Athlete[] {
return this.athletes.map((athlete) => ({ ...athlete }))
}
createAthlete(input: AthleteInput): Athlete {
const athlete: Athlete = { ...input, id: (this.nextAthleteId += 1) }
this.athletes = [...this.athletes, athlete]
return { ...athlete }
}
updateAthlete(id: number, input: AthleteInput): Athlete {
const index = this.athletes.findIndex((athlete) => athlete.id === id)
if (index < 0) throw new Error(`运动员 ${id} 不存在`)
const athlete: Athlete = { ...input, id }
this.athletes[index] = athlete
return { ...athlete }
}
removeAthlete(id: number): void {
const index = this.athletes.findIndex((athlete) => athlete.id === id)
if (index < 0) throw new Error(`运动员 ${id} 不存在`)
this.athletes.splice(index, 1)
}
// ── 报名导入 ──────────────────────────────────────────────────────────────
async importEntries(file: File): Promise<EntryImportResult> {
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
if (extension !== '.csv') {
return { created: 0, updated: 0, skipped: 0, errors: [XLSX_HINT] }
}
const rows = parseCsv(await file.text())
if (rows.length < 2) throw new Error('报名表为空')
const header = (rows[0] ?? []).map((cell) => cell.replace(/^\uFEFF/, '').trim().toLowerCase())
const index = new Map(header.map((name, position) => [name, position]))
for (const column of IMPORT_COLUMNS) {
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
}
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [] }
rows.slice(1).forEach((row, offset) => {
const line = offset + 2
if (row.every((cell) => cell.trim() === '')) return
const cell = (name: string): string => {
const position = index.get(name)
return position === undefined ? '' : (row[position] ?? '').trim()
}
const eventCode = cell('event_code')
const teamCode = cell('team_code')
const categoryName = cell('category')
if (eventCode === '' || teamCode === '') {
result.skipped += 1
result.errors.push(`第 ${line} 行:event_code / team_code 必填`)
return
}
const event = this.events.find((item) => item.code === eventCode)
if (event === undefined) {
result.skipped += 1
result.errors.push(`第 ${line} 行:小项 ${eventCode} 不存在`)
return
}
const team = this.teams.find((item) => item.code === teamCode)
if (team === undefined) {
result.skipped += 1
result.errors.push(`第 ${line} 行:单位 ${teamCode} 不存在`)
return
}
const categories = this.categories.filter((item) => item.eventId === event.id)
if (categoryName !== '' && !categories.some((item) => item.name === categoryName)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:组别 ${categoryName} 不存在`)
return
}
if (categoryName === '' && categories.length === 0) {
result.skipped += 1
result.errors.push(`第 ${line} 行:小项 ${eventCode} 没有组别`)
return
}
result.created += 1
})
return result
}
}
/** 够用的 CSV 解析:支持双引号包裹与 `""` 转义,报名表不会出现更复杂的结构。 */
function parseCsv(text: string): string[][] {
const rows: string[][] = []
let row: string[] = []
let cell = ''
let quoted = false
for (let index = 0; index < text.length; index += 1) {
const char = text[index]
if (quoted) {
if (char === '"') {
if (text[index + 1] === '"') {
cell += '"'
index += 1
} else {
quoted = false
}
} else {
cell += char
}
continue
}
if (char === '"') {
quoted = true
} else if (char === ',') {
row.push(cell)
cell = ''
} else if (char === '\n') {
row.push(cell)
rows.push(row)
row = []
cell = ''
} else if (char !== '\r') {
cell += char
}
}
if (cell !== '' || row.length > 0) {
row.push(cell)
rows.push(row)
}
return rows
}
export const mockBaseData = new MockBaseData()
+29
View File
@@ -0,0 +1,29 @@
/**
* 基础数据页的分区定义。
*
* 单独成文件是为了让各分区组件能 emit 一个类型安全的跳转目标,
* 不必反向 import 页面组件(那会形成页面 ↔ 分区的循环依赖)。
*/
export type BaseDataTabKey =
| 'teams'
| 'events'
| 'categories'
| 'channels'
| 'profiles'
| 'athletes'
| 'import'
export interface BaseDataTab {
key: BaseDataTabKey
label: string
}
export const BASE_DATA_TABS: BaseDataTab[] = [
{ key: 'teams', label: '参赛单位' },
{ key: 'events', label: '大项 / 小项' },
{ key: 'categories', label: '组别' },
{ key: 'channels', label: '计分通道' },
{ key: 'profiles', label: '积分模板' },
{ key: 'athletes', label: '运动员' },
{ key: 'import', label: '报名导入' },
]
@@ -0,0 +1,312 @@
import { computed, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type {
Athlete,
AthleteInput,
EntryImportResult,
Event,
EventCategory,
EventCategoryInput,
EventInput,
ScoringProfile,
ScoringProfileInput,
ScoreChannel,
ScoreChannelInput,
Sport,
SportInput,
Team,
TeamInput,
} from '@/shared'
import { mockMode, mockReason } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import { useEventStore } from '../../stores/event'
import { baseDataApi, baseDataFallback } from './baseDataApi'
/**
* 基础数据页的共享状态。
*
* 七个分区(单位 / 大项 / 小项 / 组别 / 通道 / 模板 / 运动员 / 导入)读写同一份列表,
* 且「组别」「通道」依赖「当前选中的小项」,所以状态提到模块级单例,
* 避免逐层透传 props 把分区组件焊死在页面上。
*/
const teams = ref<Team[]>([])
const sports = ref<Sport[]>([])
const events = ref<Event[]>([])
const profiles = ref<ScoringProfile[]>([])
const athletes = ref<Athlete[]>([])
const loading = ref(false)
const loaded = ref(false)
const error = ref<string | null>(null)
const selectedEventId = ref<number | null>(null)
/** 小项详情(含组别 / 通道)按需拉取并缓存,保存后原地更新。 */
const details = ref<Record<number, Event>>({})
function sortTeams(list: Team[]): Team[] {
return [...list].sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code))
}
function sortSports(list: Sport[]): Sport[] {
return [...list].sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code))
}
function sortEvents(list: Event[]): Event[] {
return [...list].sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code))
}
function sortAthletes(list: Athlete[]): Athlete[] {
return [...list].sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
}
async function load(force = false): Promise<void> {
if (loaded.value && !force) return
loading.value = true
error.value = null
try {
const [nextTeams, nextSports, nextEvents, nextProfiles, nextAthletes] = await Promise.all([
baseDataApi.teams.list(),
baseDataApi.sports.list(),
baseDataApi.events.list(),
baseDataApi.profiles.list(),
baseDataApi.athletes.list(),
])
teams.value = sortTeams(nextTeams)
sports.value = sortSports(nextSports)
events.value = sortEvents(nextEvents)
profiles.value = nextProfiles
athletes.value = sortAthletes(nextAthletes)
loaded.value = true
if (selectedEventId.value === null || !events.value.some((event) => event.id === selectedEventId.value)) {
selectedEventId.value = events.value[0]?.id ?? null
}
} catch (caught) {
error.value = caught instanceof Error ? caught.message : '基础数据加载失败'
} finally {
loading.value = false
}
}
async function reloadTeams(): Promise<void> {
teams.value = sortTeams(await baseDataApi.teams.list())
}
async function reloadSports(): Promise<void> {
sports.value = sortSports(await baseDataApi.sports.list())
}
async function reloadEvents(): Promise<void> {
events.value = sortEvents(await baseDataApi.events.list())
// 项目列表是共享 store 的一部分,改动后同步过去,成绩录入页不必等整页刷新。
void useEventStore().refreshEvents()
}
async function reloadProfiles(): Promise<void> {
profiles.value = await baseDataApi.profiles.list()
}
async function reloadAthletes(): Promise<void> {
athletes.value = sortAthletes(await baseDataApi.athletes.list())
}
async function loadEventDetail(id: number, force = false): Promise<Event | null> {
if (!force && details.value[id] !== undefined) return details.value[id] ?? null
try {
const detail = await baseDataApi.events.get(id)
details.value = { ...details.value, [id]: detail }
return detail
} catch (caught) {
error.value = caught instanceof Error ? caught.message : '小项详情加载失败'
return null
}
}
async function selectEvent(id: number): Promise<void> {
selectedEventId.value = id
await loadEventDetail(id)
}
function eventById(id: number | null): Event | undefined {
if (id === null) return undefined
return details.value[id] ?? events.value.find((event) => event.id === id)
}
function categoriesOf(id: number | null): EventCategory[] {
if (id === null) return []
const detail = details.value[id]
if (detail?.categories === undefined) return []
return [...detail.categories].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
}
function channelsOf(id: number | null): ScoreChannel[] {
if (id === null) return []
const detail = details.value[id]
if (detail?.channels === undefined) return []
return [...detail.channels].sort((a, b) => a.sortOrder - b.sortOrder)
}
function profileById(id: number | null): ScoringProfile | undefined {
if (id === null) return undefined
return profiles.value.find((profile) => profile.id === id)
}
function teamById(id: number | null): Team | undefined {
if (id === null) return undefined
return teams.value.find((team) => team.id === id)
}
function sportById(id: number | null): Sport | undefined {
if (id === null) return undefined
return sports.value.find((sport) => sport.id === id)
}
async function saveTeam(input: TeamInput, id: number | null): Promise<Team> {
const saved = id === null ? await baseDataApi.teams.create(input) : await baseDataApi.teams.update(id, input)
await reloadTeams()
return saved
}
async function saveSport(input: SportInput, id: number | null): Promise<Sport> {
const saved = id === null ? await baseDataApi.sports.create(input) : await baseDataApi.sports.update(id, input)
await reloadSports()
return saved
}
async function removeSport(id: number): Promise<void> {
await baseDataApi.sports.remove(id)
await reloadSports()
}
async function saveEvent(input: EventInput, id: number | null): Promise<Event> {
const saved = id === null ? await baseDataApi.events.create(input) : await baseDataApi.events.update(id, input)
await reloadEvents()
if (id === null) {
selectedEventId.value = saved.id
await loadEventDetail(saved.id, true)
} else {
// 详情缓存里可能有旧值,改完必须重拉,否则列表与组别/通道页显示不一致。
await loadEventDetail(id, true)
}
return saved
}
async function removeEvent(id: number): Promise<void> {
await baseDataApi.events.remove(id)
const nextDetails = { ...details.value }
delete nextDetails[id]
details.value = nextDetails
await reloadEvents()
if (selectedEventId.value === id) selectedEventId.value = events.value[0]?.id ?? null
}
async function saveCategories(eventId: number, inputs: EventCategoryInput[]): Promise<void> {
const saved = await baseDataApi.events.saveCategories(eventId, inputs)
const detail = details.value[eventId]
if (detail !== undefined) details.value = { ...details.value, [eventId]: { ...detail, categories: saved } }
}
async function saveChannels(eventId: number, inputs: ScoreChannelInput[]): Promise<void> {
const saved = await baseDataApi.events.saveChannels(eventId, inputs)
const detail = details.value[eventId]
if (detail !== undefined) details.value = { ...details.value, [eventId]: { ...detail, channels: saved } }
}
async function saveProfile(input: ScoringProfileInput, id: number | null): Promise<ScoringProfile> {
const saved =
id === null
? await baseDataApi.profiles.create(input)
: await baseDataApi.profiles.update(id, input)
await reloadProfiles()
return saved
}
async function saveAthlete(input: AthleteInput, id: number | null): Promise<Athlete> {
const saved = id === null ? await baseDataApi.athletes.create(input) : await baseDataApi.athletes.update(id, input)
await reloadAthletes()
return saved
}
async function removeAthlete(id: number): Promise<void> {
await baseDataApi.athletes.remove(id)
await reloadAthletes()
}
function importEntries(file: File): Promise<EntryImportResult> {
return baseDataApi.entries.importFile(file)
}
export interface BaseDataController {
teams: Ref<Team[]>
sports: Ref<Sport[]>
events: Ref<Event[]>
profiles: Ref<ScoringProfile[]>
athletes: Ref<Athlete[]>
loading: Ref<boolean>
error: Ref<string | null>
selectedEventId: Ref<number | null>
isMock: ComputedRef<boolean>
mockLabel: ComputedRef<string | null>
canWrite: ComputedRef<boolean>
load: (force?: boolean) => Promise<void>
loadEventDetail: (id: number, force?: boolean) => Promise<Event | null>
selectEvent: (id: number) => Promise<void>
eventById: (id: number | null) => Event | undefined
categoriesOf: (id: number | null) => EventCategory[]
channelsOf: (id: number | null) => ScoreChannel[]
profileById: (id: number | null) => ScoringProfile | undefined
teamById: (id: number | null) => Team | undefined
sportById: (id: number | null) => Sport | undefined
saveTeam: (input: TeamInput, id: number | null) => Promise<Team>
saveSport: (input: SportInput, id: number | null) => Promise<Sport>
removeSport: (id: number) => Promise<void>
saveEvent: (input: EventInput, id: number | null) => Promise<Event>
removeEvent: (id: number) => Promise<void>
saveCategories: (eventId: number, inputs: EventCategoryInput[]) => Promise<void>
saveChannels: (eventId: number, inputs: ScoreChannelInput[]) => Promise<void>
saveProfile: (input: ScoringProfileInput, id: number | null) => Promise<ScoringProfile>
saveAthlete: (input: AthleteInput, id: number | null) => Promise<Athlete>
removeAthlete: (id: number) => Promise<void>
importEntries: (file: File) => Promise<EntryImportResult>
}
export function useBaseData(): BaseDataController {
const auth = useAuthStore()
const shared = useEventStore()
return {
teams,
sports,
events,
profiles,
athletes,
loading,
error,
selectedEventId,
isMock: computed(() => baseDataFallback.value || mockMode.value),
mockLabel: computed(() =>
baseDataFallback.value || mockMode.value ? `示例数据(${mockReason.value ?? '服务端未就绪'})` : null,
),
// 示例数据模式允许本地写入,否则整页只能看不能改,交互无从验证。
canWrite: computed(() => !auth.readOnly && (baseDataFallback.value || shared.canWrite)),
load,
loadEventDetail,
selectEvent,
eventById,
categoriesOf,
channelsOf,
profileById,
teamById,
sportById,
saveTeam,
saveSport,
removeSport,
saveEvent,
removeEvent,
saveCategories,
saveChannels,
saveProfile,
saveAthlete,
removeAthlete,
importEntries,
}
}
@@ -0,0 +1,111 @@
<script setup lang="ts">
import {
CENTER_Y,
ROW_HEIGHT,
curvePath,
laneColor,
laneX,
type GraphRow,
} from './graph'
/**
* 左侧时间轴:一条竖线代表一条泳道,圆点是修订节点。
* 只负责画线与选中态,行内容由父级通过默认插槽渲染。
*/
defineProps<{
rows: GraphRow[]
width: number
selectedId: number | null
}>()
const emit = defineEmits<{ select: [id: number] }>()
</script>
<template>
<div class="revision-graph">
<button
v-for="row in rows"
:key="row.revision.id"
class="graph-row"
:class="{ selected: row.revision.id === selectedId, branch: row.isBranch }"
type="button"
@click="emit('select', row.revision.id)"
>
<svg class="graph-svg" :width="width" :height="ROW_HEIGHT" :viewBox="`0 0 ${width} ${ROW_HEIGHT}`">
<line
v-for="(segment, index) in row.segments"
:key="`s-${index}`"
:x1="laneX(segment.lane)"
:x2="laneX(segment.lane)"
:y1="segment.from"
:y2="segment.to"
:stroke="laneColor(segment.lane)"
/>
<path
v-for="(curve, index) in row.curves"
:key="`c-${index}`"
:d="curvePath(curve.fromLane, curve.toLane)"
:stroke="laneColor(curve.fromLane)"
/>
<circle
:cx="laneX(row.lane)"
:cy="CENTER_Y"
r="5.5"
:fill="laneColor(row.lane)"
class="graph-dot"
/>
</svg>
<span class="graph-content">
<slot :row="row" />
</span>
</button>
</div>
</template>
<style scoped>
.revision-graph {
display: flex;
flex-direction: column;
}
.graph-row {
display: flex;
align-items: stretch;
gap: 8px;
height: 64px;
width: 100%;
padding: 0;
text-align: left;
cursor: pointer;
border-radius: var(--mx-r-xs);
color: var(--mx-t2);
transition: background var(--mx-d1) var(--mx-ease);
}
.graph-row:hover {
background: var(--mx-s-hover);
}
.graph-row.selected {
background: var(--mx-primary-glow);
}
.graph-svg {
flex-shrink: 0;
display: block;
}
.graph-svg line,
.graph-svg path {
stroke-width: 2;
fill: none;
}
.graph-dot {
stroke: var(--mx-bg);
stroke-width: 2;
}
.graph-row.branch .graph-dot {
stroke: var(--mx-amber);
}
.graph-content {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
}
</style>
+188
View File
@@ -0,0 +1,188 @@
import type { DiffKind, Revision, RevisionSnapshotRow } from './types'
/**
* 仿 Git Graph 的布局计算(纯函数,便于单独推理)。
*
* 每个小项一条主线:父节点是当前主线末端的修订沿主线前进,
* 否则(父节点已被别的修订占用,或没有父节点)另开一条泳道——这就是「撤销后重新发布分出新分支」。
*/
export const LANE_WIDTH = 16
export const ROW_HEIGHT = 64
export const PAD_X = 10
export const CENTER_Y = ROW_HEIGHT / 2
export const LANE_COLORS = ['#3482FF', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#FB923C']
export interface GraphSegment {
lane: number
/** 竖线的起止 y(行内坐标)。 */
from: number
to: number
}
export interface GraphCurve {
fromLane: number
toLane: number
}
export interface GraphRow {
revision: Revision
lane: number
parentLane: number | null
isBranch: boolean
segments: GraphSegment[]
curves: GraphCurve[]
}
export interface GraphLayout {
rows: GraphRow[]
laneCount: number
width: number
}
function compare(a: Revision, b: Revision): number {
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1
return a.id - b.id
}
export function buildGraph(revisions: readonly Revision[]): GraphLayout {
const chronological = [...revisions].sort(compare)
const laneOf = new Map<number, number>()
const tips: Array<number | null> = []
for (const revision of chronological) {
let lane: number | null = null
if (revision.parentId !== null) {
const parentLane = laneOf.get(revision.parentId)
if (parentLane !== undefined && tips[parentLane] === revision.parentId) lane = parentLane
}
if (lane === null) {
const free = tips.indexOf(null)
if (free >= 0) {
lane = free
} else {
tips.push(null)
lane = tips.length - 1
}
}
tips[lane] = revision.id
laneOf.set(revision.id, lane)
}
// 展示顺序:最新在上,父节点在下方。
const ordered = [...chronological].reverse()
const indexOf = new Map<number, number>()
ordered.forEach((revision, index) => indexOf.set(revision.id, index))
const laneCount = ordered.reduce((max, revision) => Math.max(max, (laneOf.get(revision.id) ?? 0) + 1), 1)
const segments: GraphSegment[][] = ordered.map(() => [])
const curves: GraphCurve[][] = ordered.map(() => [])
for (let childIndex = 0; childIndex < ordered.length; childIndex += 1) {
const revision = ordered[childIndex]
if (revision === undefined || revision.parentId === null) continue
const parentIndex = indexOf.get(revision.parentId)
if (parentIndex === undefined) continue
const childLane = laneOf.get(revision.id) ?? 0
const parentLane = laneOf.get(revision.parentId) ?? childLane
for (let row = childIndex; row <= parentIndex; row += 1) {
const from = row === childIndex ? CENTER_Y : 0
const to = row === parentIndex ? CENTER_Y : ROW_HEIGHT
if (from === to) continue
const bucket = segments[row]
if (bucket !== undefined) bucket.push({ lane: childLane, from, to })
}
if (childLane !== parentLane) {
const bucket = curves[parentIndex]
if (bucket !== undefined) bucket.push({ fromLane: childLane, toLane: parentLane })
}
}
const rows: GraphRow[] = ordered.map((revision, index) => {
const lane = laneOf.get(revision.id) ?? 0
const parentLane = revision.parentId === null ? null : (laneOf.get(revision.parentId) ?? null)
return {
revision,
lane,
parentLane,
isBranch: parentLane !== null && parentLane !== lane,
segments: segments[index] ?? [],
curves: curves[index] ?? [],
}
})
return { rows, laneCount, width: laneCount * LANE_WIDTH + PAD_X * 2 }
}
export function laneColor(lane: number): string {
return LANE_COLORS[lane % LANE_COLORS.length] ?? LANE_COLORS[0] ?? '#3482FF'
}
export function laneX(lane: number): number {
return PAD_X + lane * LANE_WIDTH + LANE_WIDTH / 2
}
/** 两泳道之间的换道曲线,画在父节点那一行。 */
export function curvePath(fromLane: number, toLane: number): string {
const ax = laneX(fromLane)
const bx = laneX(toLane)
const c1 = ax + (bx - ax) * 0.4
const c2 = ax + (bx - ax) * 0.6
return `M ${ax} ${CENTER_Y} C ${c1} ${CENTER_Y} ${c2} ${CENTER_Y} ${bx} ${CENTER_Y}`
}
export interface DiffEntry {
entryId: number
kind: DiffKind
before: RevisionSnapshotRow | null
after: RevisionSnapshotRow | null
}
function sameRow(a: RevisionSnapshotRow, b: RevisionSnapshotRow): boolean {
return (
a.resultText === b.resultText &&
a.rank === b.rank &&
a.status === b.status &&
a.state === b.state
)
}
/** 当前快照 vs 父快照;父为空时全部视为新增(首次发布)。 */
export function diffSnapshots(
current: readonly RevisionSnapshotRow[],
parent: readonly RevisionSnapshotRow[] | null,
): DiffEntry[] {
const before = new Map(parent?.map((row) => [row.entryId, row]) ?? [])
const after = new Map(current.map((row) => [row.entryId, row]))
const ids = new Set<number>([...before.keys(), ...after.keys()])
const entries: DiffEntry[] = []
for (const entryId of ids) {
const previous = before.get(entryId) ?? null
const next = after.get(entryId) ?? null
if (previous === null && next === null) continue
if (previous === null) {
entries.push({ entryId, kind: 'added', before: null, after: next })
continue
}
if (next === null) {
entries.push({ entryId, kind: 'removed', before: previous, after: null })
continue
}
if (!sameRow(previous, next)) entries.push({ entryId, kind: 'changed', before: previous, after: next })
}
return entries.sort((a, b) => a.entryId - b.entryId)
}
export function describeRow(row: RevisionSnapshotRow | null): string {
if (row === null) return '—'
const parts: string[] = []
if (row.resultText !== '') parts.push(row.resultText)
if (row.rank !== null) parts.push(`第 ${row.rank} 名`)
if (row.status !== null && row.status !== 'ok') parts.push(row.status.toUpperCase())
if (row.state === 'draft') parts.push('草稿')
return parts.length === 0 ? '—' : parts.join(' · ')
}
@@ -0,0 +1,178 @@
import type { Revision, RevisionSnapshotRow } from './types'
/**
* `/api/revisions` 未就绪时的降级数据。
*
* 只用于把「时间轴 + 分支 + diff」走通,界面必须显式标注「示例数据」;
* entryId 按示例数据的约定生成(eventId * 1000 + teamId * 10 + 1),单位名由全局数据补齐。
*/
function iso(hoursAgo: number): string {
return new Date(Date.now() - hoursAgo * 3_600_000).toISOString()
}
function row(
eventId: number,
teamId: number,
rank: number | null,
resultText: string,
resultValue: number | null,
points: number | null,
state: string,
): RevisionSnapshotRow {
return {
entryId: eventId * 1000 + teamId * 10 + 1,
teamId,
rank,
resultText,
resultValue,
points,
status: 'ok',
state,
}
}
const TIMES_101 = ['11.58', '11.92', '12.01', '12.20', '12.35', '12.48', '12.60']
const POINTS_101 = [10, 8, 6, 3, 2, 1, 0]
function snapshot101(state: string, order: readonly number[]): RevisionSnapshotRow[] {
// order 是「名次顺序」的单位 id;用于演示编辑后名次与积分的变化。
return order.map((teamId, index) =>
row(101, teamId, index + 1, TIMES_101[index] ?? '—', Number(TIMES_101[index] ?? 0), POINTS_101[index] ?? 0, state),
)
}
export function createMockRevisions(): Revision[] {
return [
{
id: 9,
parentId: null,
eventId: 106,
action: 'publish',
snapshot: [
row(106, 3, 1, '第一名', null, 15, 'published'),
row(106, 1, 2, '第二名', null, 10, 'published'),
row(106, 5, 3, '第三名', null, 8, 'published'),
],
ledgerIds: [901, 902, 903],
actorId: 2,
actorName: '张裁判',
message: '篮球团体名次确认',
createdAt: iso(8),
},
{
id: 8,
parentId: 7,
eventId: 103,
action: 'publish',
snapshot: [
row(103, 2, 1, '2:13.90', 133.9, 10, 'published'),
row(103, 1, 2, '2:16.05', 136.05, 8, 'published'),
row(103, 4, 3, '2:18.77', 138.77, 6, 'published'),
row(103, 3, 4, '2:20.10', 140.1, 3, 'published'),
row(103, 5, 5, '2:22.44', 142.44, 2, 'published'),
],
ledgerIds: [801, 802, 803, 804, 805],
actorId: 2,
actorName: '张裁判',
message: '更正第二名成绩后重新发布',
createdAt: iso(12),
},
{
id: 7,
parentId: 6,
eventId: 103,
action: 'edit',
snapshot: [
row(103, 2, 1, '2:13.90', 133.9, null, 'draft'),
row(103, 1, 2, '2:16.05', 136.05, null, 'draft'),
row(103, 4, 3, '2:18.77', 138.77, null, 'draft'),
row(103, 3, 4, '2:20.10', 140.1, null, 'draft'),
row(103, 5, 5, '2:22.44', 142.44, null, 'draft'),
],
ledgerIds: [],
actorId: 2,
actorName: '张裁判',
message: '第 2 名成绩由 2:14.30 更正为 2:13.90',
createdAt: iso(14),
},
{
id: 6,
parentId: null,
eventId: 103,
action: 'publish',
snapshot: [
row(103, 2, 1, '2:14.30', 134.3, 10, 'published'),
row(103, 1, 2, '2:16.05', 136.05, 8, 'published'),
row(103, 4, 3, '2:18.77', 138.77, 6, 'published'),
row(103, 3, 4, '2:20.10', 140.1, 3, 'published'),
row(103, 5, 5, '2:22.44', 142.44, 2, 'published'),
],
ledgerIds: [601, 602, 603, 604, 605],
actorId: 1,
actorName: '李管理员',
message: null,
createdAt: iso(20),
},
{
id: 5,
parentId: 4,
eventId: 101,
action: 'publish',
snapshot: snapshot101('published', [1, 2, 4, 3, 5, 6, 7]),
ledgerIds: [501, 502, 503, 504, 505, 506, 507],
actorId: 2,
actorName: '张裁判',
message: '修正后重新发布',
createdAt: iso(22),
},
{
id: 4,
parentId: 2,
eventId: 101,
action: 'edit',
snapshot: snapshot101('draft', [1, 2, 3, 4, 5, 6, 7]),
ledgerIds: [],
actorId: 2,
actorName: '张裁判',
message: '第三、四名成绩录入对调,等待重新发布',
createdAt: iso(24),
},
{
id: 3,
parentId: 2,
eventId: 101,
action: 'publish',
snapshot: snapshot101('published', [1, 2, 4, 3, 5, 6, 7]),
ledgerIds: [301, 302, 303, 304, 305, 306, 307],
actorId: 1,
actorName: '李管理员',
message: null,
createdAt: iso(26),
},
{
id: 2,
parentId: 1,
eventId: 101,
action: 'unpublish',
snapshot: snapshot101('draft', [1, 2, 3, 4, 5, 6, 7]),
ledgerIds: [],
actorId: 1,
actorName: '李管理员',
message: '成绩有误,撤销发布',
createdAt: iso(28),
},
{
id: 1,
parentId: null,
eventId: 101,
action: 'publish',
snapshot: snapshot101('published', [1, 2, 3, 4, 5, 6, 7]),
ledgerIds: [101, 102, 103, 104, 105, 106, 107],
actorId: 1,
actorName: '李管理员',
message: null,
createdAt: iso(30),
},
]
}
@@ -0,0 +1,35 @@
import { parseRevisions, type Revision } from './types'
/**
* `GET /api/revisions`(05 §7 的变更历史接口,由服务端另一处实现)。
*
* 直接 fetch 而不是走 adminApi:client.ts 的接口表里暂时没有这个方法,
* 而 api/** 不在本页的改动范围内;解析失败 / 404 时由页面降级到示例数据。
*/
export interface RevisionQuery {
eventId?: number
from?: string
to?: string
}
const REVISIONS_PATH = '/api/revisions'
function buildUrl(query: RevisionQuery): string {
const params = new URLSearchParams()
if (query.eventId !== undefined) params.set('eventId', String(query.eventId))
if (query.from !== undefined) params.set('from', query.from)
if (query.to !== undefined) params.set('to', query.to)
const search = params.toString()
return search === '' ? REVISIONS_PATH : `${REVISIONS_PATH}?${search}`
}
export async function fetchRevisions(query: RevisionQuery = {}): Promise<Revision[]> {
const response = await fetch(buildUrl(query), {
headers: { Accept: 'application/json' },
credentials: 'include',
})
if (!response.ok) throw new Error(`变更历史接口返回 HTTP ${response.status}`)
const body: unknown = await response.json()
return parseRevisions(body)
}
+167
View File
@@ -0,0 +1,167 @@
/**
* 变更历史(result_revisions,见 04-数据模型)的前端类型与容错解析。
*
* 该接口由服务端另一处实现,字段以 04-数据模型为准;这里把响应当 unknown 解析,
* 缺字段就降级而不是抛异常——历史页只读,展示不出来比整页崩掉好。
*/
export type RevisionAction = 'publish' | 'unpublish' | 'edit'
/** 快照里的一行成绩,对应服务端 resultDTO 的子集。 */
export interface RevisionSnapshotRow {
entryId: number
teamId: number | null
rank: number | null
resultText: string
resultValue: number | null
points: number | null
status: string | null
state: string | null
}
export interface Revision {
id: number
parentId: number | null
eventId: number
action: RevisionAction
snapshot: RevisionSnapshotRow[]
ledgerIds: number[]
actorId: number | null
/** 服务端可能联表带出操作人姓名;没有就显示 id。 */
actorName: string | null
message: string | null
createdAt: string
}
export const ACTION_LABELS: Record<RevisionAction, string> = {
publish: '发布',
unpublish: '撤销发布',
edit: '编辑',
}
export type DiffKind = 'added' | 'removed' | 'changed'
/** diff 的展示行:单位 / 运动员名由页面查全局数据补齐。 */
export interface RevisionDiffRow {
entryId: number
kind: DiffKind
teamLabel: string
athleteLabel: string
beforeText: string
afterText: string
beforePoints: number | null
afterPoints: number | null
pointsDelta: number
}
export interface TeamImpact {
teamId: number
label: string
delta: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readNumber(source: Record<string, unknown>, key: string): number | null {
const value = source[key]
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return null
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key]
return typeof value === 'string' ? value : null
}
function readAction(value: unknown): RevisionAction {
return value === 'unpublish' || value === 'edit' ? value : 'publish'
}
/** 快照可能是 JSON 文本、数组,或包在 results / rows 里,逐一收窄。 */
function parseSnapshot(value: unknown): RevisionSnapshotRow[] {
let source: unknown = value
if (typeof source === 'string') {
try {
source = JSON.parse(source)
} catch {
return []
}
}
if (isRecord(source)) {
source = source.results ?? source.rows ?? source.items ?? []
}
if (!Array.isArray(source)) return []
const rows: RevisionSnapshotRow[] = []
for (const item of source) {
if (!isRecord(item)) continue
const entryId = readNumber(item, 'entryId')
if (entryId === null) continue
rows.push({
entryId,
teamId: readNumber(item, 'teamId'),
rank: readNumber(item, 'rank'),
resultText: readString(item, 'resultText') ?? '',
resultValue: readNumber(item, 'resultValue'),
points: readNumber(item, 'points'),
status: readString(item, 'status'),
state: readString(item, 'state'),
})
}
return rows
}
function parseLedgerIds(value: unknown): number[] {
let source: unknown = value
if (typeof source === 'string') {
try {
source = JSON.parse(source)
} catch {
return []
}
}
if (!Array.isArray(source)) return []
return source
.map((item) => (typeof item === 'number' && Number.isFinite(item) ? item : null))
.filter((item): item is number => item !== null)
}
function parseRevision(value: unknown): Revision | null {
if (!isRecord(value)) return null
const id = readNumber(value, 'id')
const eventId = readNumber(value, 'eventId')
if (id === null || eventId === null) return null
return {
id,
parentId: readNumber(value, 'parentId'),
eventId,
action: readAction(value['action']),
snapshot: parseSnapshot(value['snapshotJson'] ?? value['snapshot'] ?? value['snapshot_json']),
ledgerIds: parseLedgerIds(value['ledgerIds'] ?? value['ledger_ids']),
actorId: readNumber(value, 'actorId'),
actorName: readString(value, 'actorName') ?? readString(value, 'actor'),
message: readString(value, 'message'),
createdAt: readString(value, 'createdAt') ?? readString(value, 'created_at') ?? '',
}
}
function extractList(body: unknown): unknown[] {
if (Array.isArray(body)) return body
if (!isRecord(body)) return []
if (Array.isArray(body['items'])) return body['items']
if (Array.isArray(body['revisions'])) return body['revisions']
// 兼容未拆包的 { ok, data } 统一响应。
if ('data' in body) return extractList(body['data'])
return []
}
export function parseRevisions(body: unknown): Revision[] {
return extractList(body)
.map(parseRevision)
.filter((item): item is Revision => item !== null)
}
@@ -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 }),
)
},
}