feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入
This commit is contained in:
1 parent
f3813171d3
commit
72462792ec
39 files changed
+10001
-28
No files matched your search
@@ -0,0 +1,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()
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user