fix(server): 赛事包导出传入项目名,修复 GET /api/export/project.zip 恒 500
This commit is contained in:
1 parent
72462792ec
commit
5ae55b8057
36 files changed
+2672
-108
No files matched your search
@@ -0,0 +1,297 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Athlete, AthleteInput, Gender } 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()
|
||||
|
||||
interface AthleteForm {
|
||||
name: string
|
||||
gender: '' | Gender
|
||||
teamId: string
|
||||
employeeNo: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
type AthleteField = keyof AthleteForm
|
||||
|
||||
const search = ref('')
|
||||
const teamFilter = ref<string>('all')
|
||||
const statusFilter = ref<'all' | 'active' | 'inactive'>('all')
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const form = ref<AthleteForm>(emptyForm())
|
||||
const errors = ref<Partial<Record<AthleteField, string>>>({})
|
||||
const formError = ref<string | null>(null)
|
||||
const pending = ref(false)
|
||||
|
||||
const confirmOpen = ref(false)
|
||||
const confirmTarget = ref<Athlete | null>(null)
|
||||
const confirmPending = ref(false)
|
||||
|
||||
function emptyForm(): AthleteForm {
|
||||
return { name: '', gender: '', teamId: '', employeeNo: '', active: true }
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
const keyword = search.value.trim().toLowerCase()
|
||||
return data.athletes.value.filter((athlete) => {
|
||||
if (teamFilter.value !== 'all' && String(athlete.teamId) !== teamFilter.value) return false
|
||||
if (statusFilter.value === 'active' && !athlete.active) return false
|
||||
if (statusFilter.value === 'inactive' && athlete.active) return false
|
||||
if (keyword === '') return true
|
||||
const team = data.teamById(athlete.teamId)
|
||||
return (
|
||||
athlete.name.toLowerCase().includes(keyword) ||
|
||||
(athlete.employeeNo ?? '').toLowerCase().includes(keyword) ||
|
||||
(team?.name ?? '').toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const activeCount = computed(() => data.athletes.value.filter((athlete) => athlete.active).length)
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
form.value = emptyForm()
|
||||
form.value.teamId = data.teams.value[0] === undefined ? '' : String(data.teams.value[0].id)
|
||||
errors.value = {}
|
||||
formError.value = null
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(athlete: Athlete): void {
|
||||
editingId.value = athlete.id
|
||||
form.value = {
|
||||
name: athlete.name,
|
||||
gender: athlete.gender ?? '',
|
||||
teamId: String(athlete.teamId),
|
||||
employeeNo: athlete.employeeNo ?? '',
|
||||
active: athlete.active,
|
||||
}
|
||||
errors.value = {}
|
||||
formError.value = null
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const next: Partial<Record<AthleteField, string>> = {}
|
||||
if (form.value.name.trim() === '') next.name = '姓名必填'
|
||||
if (form.value.teamId === '' || data.teamById(Number(form.value.teamId)) === undefined) {
|
||||
next.teamId = '必须选择一个参赛单位'
|
||||
}
|
||||
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: AthleteInput = {
|
||||
name: form.value.name.trim(),
|
||||
gender: form.value.gender === '' ? null : form.value.gender,
|
||||
teamId: Number(form.value.teamId),
|
||||
employeeNo: form.value.employeeNo.trim() === '' ? null : form.value.employeeNo.trim(),
|
||||
active: form.value.active,
|
||||
}
|
||||
try {
|
||||
await data.saveAthlete(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(athlete: Athlete): void {
|
||||
if (athlete.active) {
|
||||
confirmTarget.value = athlete
|
||||
confirmOpen.value = true
|
||||
return
|
||||
}
|
||||
void toggleActive(athlete)
|
||||
}
|
||||
|
||||
async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
confirmPending.value = true
|
||||
try {
|
||||
await data.saveAthlete(
|
||||
{
|
||||
name: athlete.name,
|
||||
gender: athlete.gender,
|
||||
teamId: athlete.teamId,
|
||||
employeeNo: athlete.employeeNo,
|
||||
active: !athlete.active,
|
||||
},
|
||||
athlete.id,
|
||||
)
|
||||
toast.success(athlete.active ? `已停用「${athlete.name}」` : `已启用「${athlete.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.athletes.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="teamFilter" class="mx-cell-input bd-select">
|
||||
<option value="all">全部单位</option>
|
||||
<option v-for="team in data.teams.value" :key="team.id" :value="String(team.id)">{{ team.name }}</option>
|
||||
</select>
|
||||
<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 || data.teams.value.length === 0" @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 class="bd-cell-mini">性别</th>
|
||||
<th>所属单位</th>
|
||||
<th>工号 / 学号</th>
|
||||
<th>状态</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="data.loading.value && data.athletes.value.length === 0">
|
||||
<td colspan="6">
|
||||
<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="athlete in filtered" :key="athlete.id">
|
||||
<td>{{ athlete.name }}</td>
|
||||
<td>{{ athlete.gender ?? '—' }}</td>
|
||||
<td>{{ data.teamById(athlete.teamId)?.name ?? '未知单位' }}</td>
|
||||
<td class="bd-mono">{{ athlete.employeeNo ?? '—' }}</td>
|
||||
<td>
|
||||
<span class="mx-badge" :class="athlete.active ? 'mx-badge-success' : 'mx-badge-neutral'">
|
||||
{{ athlete.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(athlete)">
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||
:class="{ 'danger-text': athlete.active }"
|
||||
type="button"
|
||||
:disabled="!data.canWrite.value"
|
||||
@click="askToggle(athlete)"
|
||||
>
|
||||
{{ athlete.active ? '停用' : '启用' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filtered.length === 0">
|
||||
<td colspan="6">
|
||||
<div class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">{{ data.athletes.value.length === 0 ? '还没有运动员' : '没有匹配的运动员' }}</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.name">
|
||||
<input v-model="form.name" class="mx-cell-input" placeholder="例如 张伟" />
|
||||
</FormField>
|
||||
<FormField label="性别">
|
||||
<select v-model="form.gender" class="mx-cell-input">
|
||||
<option value="">未设置</option>
|
||||
<option value="男">男</option>
|
||||
<option value="女">女</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="所属单位" required :error="errors.teamId">
|
||||
<select v-model="form.teamId" class="mx-cell-input">
|
||||
<option value="" disabled>请选择单位</option>
|
||||
<option v-for="team in data.teams.value" :key="team.id" :value="String(team.id)">
|
||||
{{ team.name }}{{ team.active ? '' : '(已停用)' }}
|
||||
</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="工号 / 学号" hint="可留空,用于对账">
|
||||
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
|
||||
</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>
|
||||
.danger-text {
|
||||
color: var(--mx-red);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { EventCategory, EventCategoryInput, Gender } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.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()
|
||||
|
||||
interface CategoryRow {
|
||||
key: number
|
||||
name: string
|
||||
multiplier: string
|
||||
gender: '' | Gender
|
||||
ageMin: string
|
||||
ageMax: string
|
||||
sortOrder: string
|
||||
}
|
||||
|
||||
interface RowError {
|
||||
name?: string
|
||||
multiplier?: string
|
||||
age?: string
|
||||
}
|
||||
|
||||
let nextKey = 1
|
||||
|
||||
const rows = ref<CategoryRow[]>([])
|
||||
const original = ref<EventCategory[]>([])
|
||||
const errors = ref<Record<number, RowError>>({})
|
||||
const saving = ref(false)
|
||||
const confirmOpen = ref(false)
|
||||
|
||||
const selectedEvent = computed(() => data.eventById(data.selectedEventId.value) ?? null)
|
||||
|
||||
const removedNames = computed(() => {
|
||||
const current = new Set(rows.value.map((row) => row.name.trim()).filter((name) => name !== ''))
|
||||
return original.value.filter((category) => !current.has(category.name)).map((category) => category.name)
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => data.selectedEventId.value, () => data.categoriesOf(data.selectedEventId.value)],
|
||||
() => {
|
||||
const list = data.categoriesOf(data.selectedEventId.value)
|
||||
original.value = list
|
||||
rows.value = list.map((category) => toRow(category))
|
||||
errors.value = {}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function toRow(category: EventCategory): CategoryRow {
|
||||
return {
|
||||
key: (nextKey += 1),
|
||||
name: category.name,
|
||||
multiplier: String(category.multiplier),
|
||||
gender: category.gender ?? '',
|
||||
ageMin: category.ageMin === null ? '' : String(category.ageMin),
|
||||
ageMax: category.ageMax === null ? '' : String(category.ageMax),
|
||||
sortOrder: String(category.sortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
function addRow(): void {
|
||||
rows.value = [
|
||||
...rows.value,
|
||||
{
|
||||
key: (nextKey += 1),
|
||||
name: '',
|
||||
multiplier: '1',
|
||||
gender: '',
|
||||
ageMin: '',
|
||||
ageMax: '',
|
||||
sortOrder: String(rows.value.length + 1),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function removeRow(key: number): void {
|
||||
rows.value = rows.value.filter((row) => row.key !== key)
|
||||
}
|
||||
|
||||
function moveRow(index: number, delta: number): void {
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= rows.value.length) return
|
||||
const next = [...rows.value]
|
||||
const current = next[index]
|
||||
const swap = next[target]
|
||||
if (current === undefined || swap === undefined) return
|
||||
next[index] = swap
|
||||
next[target] = current
|
||||
rows.value = next
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const next: Record<number, RowError> = {}
|
||||
const seen = new Set<string>()
|
||||
rows.value.forEach((row) => {
|
||||
const rowError: RowError = {}
|
||||
const name = row.name.trim()
|
||||
if (name === '') rowError.name = '名称必填'
|
||||
else if (seen.has(name)) rowError.name = '组别名称重复'
|
||||
else seen.add(name)
|
||||
|
||||
const multiplier = Number(row.multiplier)
|
||||
if (!Number.isFinite(multiplier) || multiplier <= 0 || multiplier > 100) {
|
||||
rowError.multiplier = '权重为 0–100 的正数'
|
||||
}
|
||||
|
||||
const ageMin = row.ageMin.trim() === '' ? null : Number(row.ageMin)
|
||||
const ageMax = row.ageMax.trim() === '' ? null : Number(row.ageMax)
|
||||
if (ageMin !== null && (!Number.isInteger(ageMin) || ageMin < 0 || ageMin > 120)) rowError.age = '年龄为 0–120 的整数'
|
||||
else if (ageMax !== null && (!Number.isInteger(ageMax) || ageMax < 0 || ageMax > 120)) rowError.age = '年龄为 0–120 的整数'
|
||||
else if (ageMin !== null && ageMax !== null && ageMin > ageMax) rowError.age = '起始年龄不能大于结束年龄'
|
||||
|
||||
if (Object.keys(rowError).length > 0) next[row.key] = rowError
|
||||
})
|
||||
errors.value = next
|
||||
return Object.keys(next).length === 0
|
||||
}
|
||||
|
||||
function buildInputs(): EventCategoryInput[] {
|
||||
return rows.value.map((row, index) => ({
|
||||
name: row.name.trim(),
|
||||
multiplier: Number(row.multiplier),
|
||||
gender: row.gender === '' ? null : row.gender,
|
||||
ageMin: row.ageMin.trim() === '' ? null : Number(row.ageMin),
|
||||
ageMax: row.ageMax.trim() === '' ? null : Number(row.ageMax),
|
||||
sortOrder: Number(row.sortOrder) || index + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
function requestSave(): void {
|
||||
if (data.selectedEventId.value === null) return
|
||||
if (!validate()) {
|
||||
toast.error('组别校验未通过', '请先修正标红的字段')
|
||||
return
|
||||
}
|
||||
if (removedNames.value.length > 0) {
|
||||
confirmOpen.value = true
|
||||
return
|
||||
}
|
||||
void save()
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const eventId = data.selectedEventId.value
|
||||
if (eventId === null) return
|
||||
saving.value = true
|
||||
try {
|
||||
await data.saveCategories(eventId, buildInputs())
|
||||
toast.success('组别已保存')
|
||||
confirmOpen.value = false
|
||||
} catch (caught) {
|
||||
toast.error('保存组别失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">
|
||||
组别{{ selectedEvent === null ? '' : ` · ${selectedEvent.name}` }}
|
||||
</span>
|
||||
<div class="bd-toolbar-right">
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value || selectedEvent === null" @click="addRow">
|
||||
<MxIcon name="entry" :size="14" /> 新增组别
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!data.canWrite.value || selectedEvent === null || saving"
|
||||
@click="requestSave"
|
||||
>
|
||||
{{ saving ? '保存中…' : '保存全部' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-card-body">
|
||||
<div v-if="selectedEvent === null" class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">请先选择一个小项</div>
|
||||
<div class="mx-empty-desc">组别挂在具体小项下,名次只在同一组别内有效</div>
|
||||
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="emit('goTab', 'events')">去选小项</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="bd-warn">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
<span>
|
||||
保存会整体替换该小项的组别配置(PUT /api/events/{{ selectedEvent.id }}/categories)。
|
||||
若该项目已有报名数据,服务端会拒绝替换。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th class="bd-cell-narrow">权重</th>
|
||||
<th class="bd-cell-narrow">性别</th>
|
||||
<th class="bd-cell-narrow">年龄下限</th>
|
||||
<th class="bd-cell-narrow">年龄上限</th>
|
||||
<th class="bd-cell-mini">排序</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in rows" :key="row.key">
|
||||
<td>
|
||||
<input
|
||||
v-model="row.name"
|
||||
class="mx-cell-input"
|
||||
:class="{ 'is-invalid': errors[row.key]?.name }"
|
||||
placeholder="例如 男子组"
|
||||
/>
|
||||
<span v-if="errors[row.key]?.name" class="row-error">{{ errors[row.key]?.name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<input v-model="row.multiplier" class="mx-cell-input bd-mono" :class="{ 'is-invalid': errors[row.key]?.multiplier }" type="number" min="0.1" step="0.1" />
|
||||
</td>
|
||||
<td>
|
||||
<select v-model="row.gender" class="mx-cell-input">
|
||||
<option value="">不限</option>
|
||||
<option value="男">男</option>
|
||||
<option value="女">女</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input v-model="row.ageMin" class="mx-cell-input bd-mono" :class="{ 'is-invalid': errors[row.key]?.age }" type="number" min="0" max="120" placeholder="—" />
|
||||
</td>
|
||||
<td>
|
||||
<input v-model="row.ageMax" class="mx-cell-input bd-mono" :class="{ 'is-invalid': errors[row.key]?.age }" type="number" min="0" max="120" placeholder="—" />
|
||||
<span v-if="errors[row.key]?.age" class="row-error">{{ errors[row.key]?.age }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<input v-model="row.sortOrder" class="mx-cell-input bd-mono" type="number" min="0" max="9999" />
|
||||
</td>
|
||||
<td>
|
||||
<div class="bd-actions">
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="index === 0" @click="moveRow(index, -1)">上移</button>
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="index === rows.length - 1" @click="moveRow(index, 1)">下移</button>
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm danger-text" type="button" :disabled="!data.canWrite.value" @click="removeRow(row.key)">移除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td colspan="7">
|
||||
<div class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">该小项还没有组别</div>
|
||||
<div class="mx-empty-desc">至少需要一个组别,报名项与名次都挂在组别上</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="bd-note">组别权重会乘在通道得分上;性别与年龄仅用于展示与筛选。</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirmOpen"
|
||||
tone="danger"
|
||||
title="保存将移除组别?"
|
||||
:message="`本次保存会删除 ${removedNames.length} 个组别,并整体替换该小项的组别配置。`"
|
||||
:details="removedNames"
|
||||
confirm-text="确认保存"
|
||||
:pending="saving"
|
||||
@confirm="save"
|
||||
@cancel="confirmOpen = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.danger-text {
|
||||
color: var(--mx-red);
|
||||
}
|
||||
.row-error {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--mx-red);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { ChannelBasis, ChannelScope, ScoreChannel, ScoreChannelInput } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.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 SCOPE_OPTIONS: Array<{ value: ChannelScope; label: string }> = [
|
||||
{ value: 'athlete', label: '个人名次' },
|
||||
{ value: 'team', label: '团体名次' },
|
||||
]
|
||||
const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [
|
||||
{ value: 'result', label: '按成绩自动排名' },
|
||||
{ value: 'manual', label: '手动录名次' },
|
||||
{ value: 'aggregate', label: '按团体汇总(预留)' },
|
||||
]
|
||||
|
||||
interface ChannelRow {
|
||||
key: number
|
||||
scope: ChannelScope
|
||||
basis: ChannelBasis
|
||||
profileId: string
|
||||
sortOrder: string
|
||||
}
|
||||
|
||||
interface RowError {
|
||||
scope?: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
let nextKey = 1
|
||||
|
||||
const rows = ref<ChannelRow[]>([])
|
||||
const original = ref<ScoreChannel[]>([])
|
||||
const errors = ref<Record<number, RowError>>({})
|
||||
const saving = ref(false)
|
||||
const confirmOpen = ref(false)
|
||||
|
||||
const selectedEvent = computed(() => data.eventById(data.selectedEventId.value) ?? null)
|
||||
|
||||
const removedKeys = computed(() => {
|
||||
const current = new Set(rows.value.map((row) => `${row.scope}:${row.basis}`))
|
||||
return original.value
|
||||
.filter((channel) => !current.has(`${channel.scope}:${channel.basis}`))
|
||||
.map((channel) => `${scopeLabel(channel.scope)} / ${basisLabel(channel.basis)}`)
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => data.selectedEventId.value, () => data.channelsOf(data.selectedEventId.value)],
|
||||
() => {
|
||||
const list = data.channelsOf(data.selectedEventId.value)
|
||||
original.value = list
|
||||
rows.value = list.map((channel) => toRow(channel))
|
||||
errors.value = {}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function scopeLabel(scope: ChannelScope): string {
|
||||
return SCOPE_OPTIONS.find((option) => option.value === scope)?.label ?? scope
|
||||
}
|
||||
|
||||
function basisLabel(basis: ChannelBasis): string {
|
||||
return BASIS_OPTIONS.find((option) => option.value === basis)?.label ?? basis
|
||||
}
|
||||
|
||||
function toRow(channel: ScoreChannel): ChannelRow {
|
||||
return {
|
||||
key: (nextKey += 1),
|
||||
scope: channel.scope,
|
||||
basis: channel.basis,
|
||||
profileId: String(channel.profileId),
|
||||
sortOrder: String(channel.sortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
function addRow(): void {
|
||||
rows.value = [
|
||||
...rows.value,
|
||||
{
|
||||
key: (nextKey += 1),
|
||||
scope: 'athlete',
|
||||
basis: 'result',
|
||||
profileId: data.profiles.value[0] === undefined ? '' : String(data.profiles.value[0].id),
|
||||
sortOrder: String(rows.value.length + 1),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function removeRow(key: number): void {
|
||||
rows.value = rows.value.filter((row) => row.key !== key)
|
||||
}
|
||||
|
||||
function profileSummary(profileId: string): string {
|
||||
const profile = data.profileById(profileId === '' ? null : Number(profileId))
|
||||
if (profile === undefined) return '—'
|
||||
const rules = [...profile.rules]
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
.map((rule) => `${rule.rank}:${rule.points}`)
|
||||
.join(' / ')
|
||||
return `录取前 ${profile.awardRanks ?? rules.length} 名 · ${rules || '无明细'}`
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const next: Record<number, RowError> = {}
|
||||
const seen = new Set<string>()
|
||||
rows.value.forEach((row) => {
|
||||
const rowError: RowError = {}
|
||||
const key = `${row.scope}:${row.basis}`
|
||||
if (seen.has(key)) rowError.scope = '同一「名次范围 + 名次来源」只能配置一个通道'
|
||||
else seen.add(key)
|
||||
if (row.profileId === '' || data.profileById(Number(row.profileId)) === undefined) {
|
||||
rowError.profile = '必须选择一个积分模板'
|
||||
}
|
||||
if (Object.keys(rowError).length > 0) next[row.key] = rowError
|
||||
})
|
||||
errors.value = next
|
||||
return Object.keys(next).length === 0
|
||||
}
|
||||
|
||||
function buildInputs(): ScoreChannelInput[] {
|
||||
return rows.value.map((row, index) => ({
|
||||
scope: row.scope,
|
||||
basis: row.basis,
|
||||
profileId: Number(row.profileId),
|
||||
sortOrder: Number(row.sortOrder) || index + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
function requestSave(): void {
|
||||
if (data.selectedEventId.value === null) return
|
||||
if (!validate()) {
|
||||
toast.error('计分通道校验未通过', '请先修正标红的字段')
|
||||
return
|
||||
}
|
||||
if (removedKeys.value.length > 0) {
|
||||
confirmOpen.value = true
|
||||
return
|
||||
}
|
||||
void save()
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const eventId = data.selectedEventId.value
|
||||
if (eventId === null) return
|
||||
saving.value = true
|
||||
try {
|
||||
await data.saveChannels(eventId, buildInputs())
|
||||
toast.success('计分通道已保存')
|
||||
confirmOpen.value = false
|
||||
} catch (caught) {
|
||||
toast.error('保存计分通道失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">
|
||||
计分通道{{ selectedEvent === null ? '' : ` · ${selectedEvent.name}` }}
|
||||
</span>
|
||||
<div class="bd-toolbar-right">
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value || selectedEvent === null" @click="addRow">
|
||||
<MxIcon name="entry" :size="14" /> 新增通道
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!data.canWrite.value || selectedEvent === null || saving"
|
||||
@click="requestSave"
|
||||
>
|
||||
{{ saving ? '保存中…' : '保存全部' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-card-body">
|
||||
<div v-if="selectedEvent === null" class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">请先选择一个小项</div>
|
||||
<div class="mx-empty-desc">通道 = 排谁的名次 + 名次从哪来 + 用哪张积分模板</div>
|
||||
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="emit('goTab', 'events')">去选小项</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="bd-warn">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
<span>
|
||||
保存会整体替换该小项的计分通道(PUT /api/events/{{ selectedEvent.id }}/channels)。
|
||||
同一「名次范围 + 名次来源」只能有一个通道;一个项目可配 0–N 个通道。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedEvent.scored" class="bd-note scored-off">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
该小项标记为「不计分」,通道配置会保存但不参与积分计算。
|
||||
</div>
|
||||
|
||||
<div class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bd-select">名次范围</th>
|
||||
<th class="bd-select">名次来源</th>
|
||||
<th>积分模板</th>
|
||||
<th class="bd-cell-mini">排序</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.key">
|
||||
<td>
|
||||
<select v-model="row.scope" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.scope }">
|
||||
<option v-for="option in SCOPE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select v-model="row.basis" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.scope }">
|
||||
<option v-for="option in BASIS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
<span v-if="errors[row.key]?.scope" class="row-error">{{ errors[row.key]?.scope }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<select v-model="row.profileId" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.profile }">
|
||||
<option value="" disabled>请选择积分模板</option>
|
||||
<option v-for="profile in data.profiles.value" :key="profile.id" :value="String(profile.id)">{{ profile.name }}</option>
|
||||
</select>
|
||||
<span class="profile-summary bd-note">{{ profileSummary(row.profileId) }}</span>
|
||||
<span v-if="errors[row.key]?.profile" class="row-error">{{ errors[row.key]?.profile }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<input v-model="row.sortOrder" class="mx-cell-input bd-mono" type="number" min="0" max="9999" />
|
||||
</td>
|
||||
<td>
|
||||
<div class="bd-actions">
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm danger-text" type="button" :disabled="!data.canWrite.value" @click="removeRow(row.key)">
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td colspan="5">
|
||||
<div class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">该小项没有计分通道</div>
|
||||
<div class="mx-empty-desc">只排名不计分的项目保持为空即可</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirmOpen"
|
||||
tone="danger"
|
||||
title="保存将移除计分通道?"
|
||||
:message="`本次保存会删除 ${removedKeys.length} 个计分通道,并整体替换该小项的通道配置。`"
|
||||
:details="removedKeys"
|
||||
confirm-text="确认保存"
|
||||
:pending="saving"
|
||||
@confirm="save"
|
||||
@cancel="confirmOpen = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.danger-text {
|
||||
color: var(--mx-red);
|
||||
}
|
||||
.row-error {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--mx-red);
|
||||
}
|
||||
.profile-summary {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.scored-off {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--mx-amber);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { EntryImportResult } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import { useBaseData } from './useBaseData'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
|
||||
const data = useBaseData()
|
||||
const toast = useToastStore()
|
||||
|
||||
/** 与服务端 handlers_config.go 的 importColumns 一致;缺任何一列服务端会直接拒绝。 */
|
||||
const REQUIRED_COLUMNS = ['event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names']
|
||||
|
||||
const TEMPLATE_CSV = [
|
||||
REQUIRED_COLUMNS.join(','),
|
||||
'M100,男子组,F1,张伟,101,,registered,',
|
||||
'BB,混合组,F2,,,种子1,registered,第一分公司代表队',
|
||||
].join('\n')
|
||||
|
||||
const MAX_SIZE = 16 * 1024 * 1024
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const picked = ref<File | null>(null)
|
||||
const dragging = ref(false)
|
||||
const importing = ref(false)
|
||||
const result = ref<EntryImportResult | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const fileLabel = computed(() => (picked.value === null ? '' : `${picked.value.name} · ${formatSize(picked.value.size)}`))
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
function chooseFile(): void {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function acceptFile(file: File): void {
|
||||
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
||||
if (extension !== '.csv' && extension !== '.xlsx') {
|
||||
error.value = '只支持 .csv 或 .xlsx 报名表'
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
error.value = '文件超过 16MB,服务端会拒绝解析'
|
||||
return
|
||||
}
|
||||
picked.value = file
|
||||
result.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function onPick(event: Event): void {
|
||||
const input = event.target
|
||||
if (!(input instanceof HTMLInputElement)) return
|
||||
const file = input.files?.[0]
|
||||
if (file !== undefined) acceptFile(file)
|
||||
// 清空 value,否则同一个文件再次选择不会触发 change。
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): void {
|
||||
dragging.value = false
|
||||
const file = event.dataTransfer?.files?.[0]
|
||||
if (file !== undefined) acceptFile(file)
|
||||
}
|
||||
|
||||
async function runImport(): Promise<void> {
|
||||
if (picked.value === null) return
|
||||
importing.value = true
|
||||
error.value = null
|
||||
result.value = null
|
||||
try {
|
||||
result.value = await data.importEntries(picked.value)
|
||||
toast.success('导入完成', `新增 ${result.value.created} · 更新 ${result.value.updated} · 跳过 ${result.value.skipped}`)
|
||||
} catch (caught) {
|
||||
error.value = caught instanceof Error ? caught.message : '导入失败'
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTemplate(): void {
|
||||
// 带 BOM,Excel 打开才不会把中文表头识别成乱码。
|
||||
const blob = new Blob([`\uFEFF${TEMPLATE_CSV}\n`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = 'entries-template.csv'
|
||||
anchor.rel = 'noopener'
|
||||
anchor.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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" @click="downloadTemplate">
|
||||
<MxIcon name="export" :size="14" /> 下载 CSV 模板
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mx-card-body">
|
||||
<div class="bd-warn">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
<span>
|
||||
表头必须包含:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。
|
||||
服务端按 event_code、team_code 与组别名称匹配;匹配不到的行会跳过并回显行号。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="drop-zone"
|
||||
:class="{ dragging }"
|
||||
@click="chooseFile"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<MxIcon name="export" :size="26" />
|
||||
<div class="drop-title">点击选择,或把 CSV / XLSX 拖到这里</div>
|
||||
<div class="bd-note">单个文件不超过 16MB;xlsx 由服务端解析第一个工作表</div>
|
||||
<input ref="fileInput" class="file-input" type="file" accept=".csv,.xlsx" @change="onPick" />
|
||||
</div>
|
||||
|
||||
<div v-if="picked !== null" class="picked">
|
||||
<span class="bd-mono">{{ fileLabel }}</span>
|
||||
<div class="bd-toolbar-right">
|
||||
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="importing" @click="picked = null">移除</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!data.canWrite.value || importing"
|
||||
@click="runImport"
|
||||
>
|
||||
{{ importing ? '导入中…' : '开始导入' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!data.canWrite.value" class="bd-note readonly-hint">
|
||||
<MxIcon name="lock" :size="14" />
|
||||
当前不可写入(未登录或服务端未连接),导入按钮已停用。
|
||||
</div>
|
||||
|
||||
<div v-if="error !== null" class="mx-badge mx-badge-error import-error">{{ error }}</div>
|
||||
|
||||
<div v-if="result !== null" class="result">
|
||||
<div class="result-cards">
|
||||
<div class="result-card">
|
||||
<span class="result-label">新增</span>
|
||||
<strong class="bd-mono">{{ result.created }}</strong>
|
||||
</div>
|
||||
<div class="result-card">
|
||||
<span class="result-label">更新</span>
|
||||
<strong class="bd-mono">{{ result.updated }}</strong>
|
||||
</div>
|
||||
<div class="result-card">
|
||||
<span class="result-label">跳过</span>
|
||||
<strong class="bd-mono">{{ result.skipped }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="result.errors.length > 0" class="errors">
|
||||
<div class="errors-head">
|
||||
<span class="mx-badge mx-badge-warning">{{ result.errors.length }} 行有问题</span>
|
||||
<span class="bd-note">这些行未导入,修正后可重新上传</span>
|
||||
</div>
|
||||
<ul class="error-list">
|
||||
<li v-for="(line, index) in result.errors" :key="index">{{ line }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-else class="bd-note">全部行导入成功,没有错误行。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 32px 20px;
|
||||
border: 1px dashed var(--mx-border);
|
||||
border-radius: var(--mx-r);
|
||||
background: var(--mx-s2);
|
||||
color: var(--mx-t2);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--mx-d2) var(--mx-ease);
|
||||
}
|
||||
.drop-zone:hover,
|
||||
.drop-zone.dragging {
|
||||
border-color: var(--mx-primary);
|
||||
background: var(--mx-primary-glow);
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
.drop-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
.picked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
font-size: 13px;
|
||||
}
|
||||
.readonly-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
.import-error {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.result {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.result-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.result-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.result-label {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.result-card strong {
|
||||
font-size: 22px;
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
.errors-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.error-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s2);
|
||||
font-size: 12px;
|
||||
color: var(--mx-t2);
|
||||
font-family: var(--mx-mono);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.result-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,412 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { RecordBonusMode, ScoringProfile, ScoringProfileInput, TieMode } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.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,32}$/
|
||||
|
||||
const TIE_MODE_OPTIONS: Array<{ value: TieMode; label: string }> = [
|
||||
{ value: 'skip', label: '跳位(并列后空出后续名次)' },
|
||||
{ value: 'average', label: '均分(并列者取平均分)' },
|
||||
{ value: 'higher', label: '取高(并列者都按较高名次)' },
|
||||
]
|
||||
const BONUS_MODE_OPTIONS: Array<{ value: RecordBonusMode; label: string }> = [
|
||||
{ value: 'max', label: '取最高一次' },
|
||||
{ value: 'sum', label: '累加全部' },
|
||||
]
|
||||
|
||||
interface RuleRow {
|
||||
key: number
|
||||
rank: string
|
||||
points: string
|
||||
}
|
||||
|
||||
interface ProfileForm {
|
||||
code: string
|
||||
name: string
|
||||
tieMode: TieMode
|
||||
recordBonusMode: RecordBonusMode
|
||||
awardRanks: string
|
||||
lastPlaceZero: boolean
|
||||
rules: RuleRow[]
|
||||
}
|
||||
|
||||
type FormFieldKey = 'code' | 'name' | 'awardRanks' | 'rules'
|
||||
|
||||
let nextKey = 1
|
||||
|
||||
const selectedId = ref<number | null>(null)
|
||||
const creating = ref(false)
|
||||
const form = ref<ProfileForm>(emptyForm())
|
||||
const errors = ref<Partial<Record<FormFieldKey, string>>>({})
|
||||
const formError = ref<string | null>(null)
|
||||
const pending = ref(false)
|
||||
const confirmOpen = ref(false)
|
||||
const removedRules = ref<string[]>([])
|
||||
|
||||
const selectedProfile = computed(() => data.profileById(selectedId.value) ?? null)
|
||||
const isEditing = computed(() => creating.value || selectedProfile.value !== null)
|
||||
|
||||
watch(
|
||||
() => data.profiles.value,
|
||||
(list) => {
|
||||
if (creating.value) return
|
||||
const current = list.find((profile) => profile.id === selectedId.value)
|
||||
if (current === undefined) {
|
||||
const first = list[0]
|
||||
if (first === undefined) {
|
||||
selectedId.value = null
|
||||
form.value = emptyForm()
|
||||
} else {
|
||||
loadProfile(first)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (form.value.code === '' && form.value.name === '') loadProfile(current)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function emptyForm(): ProfileForm {
|
||||
return {
|
||||
code: '',
|
||||
name: '',
|
||||
tieMode: 'skip',
|
||||
recordBonusMode: 'max',
|
||||
awardRanks: '6',
|
||||
lastPlaceZero: false,
|
||||
rules: [
|
||||
{ key: (nextKey += 1), rank: '1', points: '10' },
|
||||
{ key: (nextKey += 1), rank: '2', points: '8' },
|
||||
{ key: (nextKey += 1), rank: '3', points: '6' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function loadProfile(profile: ScoringProfile): void {
|
||||
creating.value = false
|
||||
selectedId.value = profile.id
|
||||
form.value = {
|
||||
code: profile.code,
|
||||
name: profile.name,
|
||||
tieMode: profile.tieMode,
|
||||
recordBonusMode: profile.recordBonusMode,
|
||||
awardRanks: String(profile.awardRanks ?? profile.rules.length),
|
||||
lastPlaceZero: profile.lastPlaceZero ?? false,
|
||||
rules: [...profile.rules]
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
.map((rule) => ({ key: (nextKey += 1), rank: String(rule.rank), points: String(rule.points) })),
|
||||
}
|
||||
errors.value = {}
|
||||
formError.value = null
|
||||
}
|
||||
|
||||
function startCreate(): void {
|
||||
creating.value = true
|
||||
selectedId.value = null
|
||||
form.value = emptyForm()
|
||||
errors.value = {}
|
||||
formError.value = null
|
||||
}
|
||||
|
||||
function addRule(): void {
|
||||
const nextRank = form.value.rules.length + 1
|
||||
form.value.rules = [
|
||||
...form.value.rules,
|
||||
{ key: (nextKey += 1), rank: String(nextRank), points: '0' },
|
||||
]
|
||||
}
|
||||
|
||||
function removeRule(key: number): void {
|
||||
form.value.rules = form.value.rules.filter((rule) => rule.key !== key)
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const next: Partial<Record<FormFieldKey, string>> = {}
|
||||
const code = form.value.code.trim()
|
||||
if (code === '') next.code = '代码必填'
|
||||
else if (!CODE_PATTERN.test(code)) next.code = '只能用字母、数字、下划线或短横线'
|
||||
else if (data.profiles.value.some((profile) => profile.id !== selectedId.value && profile.code.toLowerCase() === code.toLowerCase())) {
|
||||
next.code = '代码已存在,必须唯一'
|
||||
}
|
||||
if (form.value.name.trim() === '') next.name = '名称必填'
|
||||
|
||||
const awardRanks = Number(form.value.awardRanks)
|
||||
if (!Number.isInteger(awardRanks) || awardRanks < 0 || awardRanks > 100) next.awardRanks = '录取名次为 0–100 的整数'
|
||||
|
||||
if (form.value.rules.length === 0) {
|
||||
next.rules = '至少配置一条名次分值'
|
||||
} else {
|
||||
const ranks = new Set<number>()
|
||||
for (const rule of form.value.rules) {
|
||||
const rank = Number(rule.rank)
|
||||
const points = Number(rule.points)
|
||||
if (!Number.isInteger(rank) || rank < 1 || rank > 1000) {
|
||||
next.rules = '名次为 1–1000 的整数'
|
||||
break
|
||||
}
|
||||
if (ranks.has(rank)) {
|
||||
next.rules = `名次 ${rank} 重复`
|
||||
break
|
||||
}
|
||||
ranks.add(rank)
|
||||
if (!Number.isFinite(points) || points < 0 || points > 10000) {
|
||||
next.rules = '分值为 0–10000 的数'
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errors.value = next
|
||||
return Object.keys(next).length === 0
|
||||
}
|
||||
|
||||
function buildInput(): ScoringProfileInput {
|
||||
return {
|
||||
code: form.value.code.trim(),
|
||||
name: form.value.name.trim(),
|
||||
tieMode: form.value.tieMode,
|
||||
recordBonusMode: form.value.recordBonusMode,
|
||||
awardRanks: Number(form.value.awardRanks),
|
||||
lastPlaceZero: form.value.lastPlaceZero,
|
||||
rules: [...form.value.rules]
|
||||
.map((rule) => ({ rank: Number(rule.rank), points: Number(rule.points) }))
|
||||
.sort((a, b) => a.rank - b.rank),
|
||||
}
|
||||
}
|
||||
|
||||
function requestSave(): void {
|
||||
if (!validate()) {
|
||||
toast.error('积分模板校验未通过', '请先修正标红的字段')
|
||||
return
|
||||
}
|
||||
const currentRanks = new Set(form.value.rules.map((rule) => Number(rule.rank)))
|
||||
removedRules.value =
|
||||
selectedProfile.value === null
|
||||
? []
|
||||
: selectedProfile.value.rules
|
||||
.filter((rule) => !currentRanks.has(rule.rank))
|
||||
.map((rule) => `第 ${rule.rank} 名(${rule.points} 分)`)
|
||||
if (removedRules.value.length > 0) {
|
||||
confirmOpen.value = true
|
||||
return
|
||||
}
|
||||
void save()
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
pending.value = true
|
||||
formError.value = null
|
||||
try {
|
||||
const saved = await data.saveProfile(buildInput(), selectedId.value)
|
||||
toast.success(creating.value ? '积分模板已新增' : '积分模板已保存')
|
||||
creating.value = false
|
||||
confirmOpen.value = false
|
||||
loadProfile(saved)
|
||||
} catch (caught) {
|
||||
formError.value = caught instanceof Error ? caught.message : '保存失败'
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const totalTop = computed(() => {
|
||||
const first = form.value.rules.find((rule) => Number(rule.rank) === 1)
|
||||
return first === undefined ? null : Number(first.points)
|
||||
})
|
||||
</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="startCreate">
|
||||
<MxIcon name="entry" :size="14" /> 新增
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<div v-if="data.loading.value && data.profiles.value.length === 0" class="mx-empty bd-empty">
|
||||
<span class="mx-spinner" />
|
||||
<div class="mx-empty-title">加载中…</div>
|
||||
</div>
|
||||
<div v-else-if="data.profiles.value.length === 0 && !creating" 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-if="creating"
|
||||
class="bd-list-item active"
|
||||
type="button"
|
||||
>
|
||||
<span class="bd-list-main">
|
||||
<span>新模板</span>
|
||||
<small>未保存</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="profile in data.profiles.value"
|
||||
:key="profile.id"
|
||||
class="bd-list-item"
|
||||
:class="{ active: !creating && profile.id === selectedId }"
|
||||
type="button"
|
||||
@click="loadProfile(profile)"
|
||||
>
|
||||
<span class="bd-list-main">
|
||||
<span>{{ profile.name }}</span>
|
||||
<small>{{ profile.code }} · 录取前 {{ profile.awardRanks ?? profile.rules.length }} 名 · {{ profile.rules.length }} 条明细</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">{{ creating ? '新建积分模板' : (selectedProfile?.name ?? '积分模板明细') }}</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!data.canWrite.value || !isEditing || pending"
|
||||
@click="requestSave"
|
||||
>
|
||||
{{ pending ? '保存中…' : '保存模板' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mx-card-body">
|
||||
<div v-if="!isEditing" class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">选择左侧模板查看明细</div>
|
||||
<div class="mx-empty-desc">或点「新增」创建一张新模板</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="formError" class="mx-badge mx-badge-error form-error">{{ formError }}</div>
|
||||
|
||||
<div class="bd-form-grid">
|
||||
<FormField label="代码" required :error="errors.code" hint="规则包跨赛事引用用代码,不用 id">
|
||||
<input v-model="form.code" class="mx-cell-input" placeholder="例如 individual" />
|
||||
</FormField>
|
||||
<FormField label="名称" required :error="errors.name">
|
||||
<input v-model="form.name" class="mx-cell-input" placeholder="例如 个人项目前六名" />
|
||||
</FormField>
|
||||
<FormField label="并列处理" hint="多人同名次时如何分配后续名次">
|
||||
<select v-model="form.tieMode" class="mx-cell-input">
|
||||
<option v-for="option in TIE_MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="破纪录加分" hint="一个项目多次破纪录时的取值方式">
|
||||
<select v-model="form.recordBonusMode" class="mx-cell-input">
|
||||
<option v-for="option in BONUS_MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="录取名次" :error="errors.awardRanks" hint="超过该名次不计分">
|
||||
<input v-model="form.awardRanks" class="mx-cell-input" type="number" min="0" max="100" />
|
||||
</FormField>
|
||||
<FormField label="末位归零" hint="最后一名是否计 0 分">
|
||||
<label class="bd-check"><input v-model="form.lastPlaceZero" type="checkbox" /> 最后一名记 0 分</label>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="rules-head">
|
||||
<span class="mx-label">名次 → 分值</span>
|
||||
<div class="rules-meta">
|
||||
<span v-if="totalTop !== null" class="bd-note">第一名 {{ totalTop }} 分</span>
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click="addRule">
|
||||
<MxIcon name="entry" :size="14" /> 增加一行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="errors.rules" class="row-error">{{ errors.rules }}</span>
|
||||
|
||||
<div class="mx-table-wrap rules-table">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bd-cell-narrow">名次</th>
|
||||
<th class="bd-cell-narrow">分值</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rule in form.rules" :key="rule.key">
|
||||
<td><input v-model="rule.rank" class="mx-cell-input bd-mono" type="number" min="1" max="1000" /></td>
|
||||
<td><input v-model="rule.points" class="mx-cell-input bd-mono" type="number" min="0" step="0.5" /></td>
|
||||
<td>
|
||||
<div class="bd-actions">
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm danger-text" type="button" :disabled="!data.canWrite.value" @click="removeRule(rule.key)">
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="form.rules.length === 0">
|
||||
<td colspan="3">
|
||||
<div class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">没有分值明细</div>
|
||||
<div class="mx-empty-desc">至少配置一条,否则通道无法算分</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirmOpen"
|
||||
tone="warning"
|
||||
title="保存将移除分值明细?"
|
||||
:message="`本次保存会删除 ${removedRules.length} 条名次分值,之后的计分将不再包含这些名次。`"
|
||||
:details="[...removedRules, '已发布的成绩不会自动重算,需要重新发布才生效']"
|
||||
confirm-text="确认保存"
|
||||
:pending="pending"
|
||||
@confirm="save"
|
||||
@cancel="confirmOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.danger-text {
|
||||
color: var(--mx-red);
|
||||
}
|
||||
.form-error {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rules-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 18px 0 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rules-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.row-error {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-red);
|
||||
}
|
||||
.rules-table {
|
||||
max-width: 420px;
|
||||
}
|
||||
</style>
|
||||
@@ -242,6 +242,7 @@ export interface BaseDataController {
|
||||
profiles: Ref<ScoringProfile[]>
|
||||
athletes: Ref<Athlete[]>
|
||||
loading: Ref<boolean>
|
||||
loaded: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
selectedEventId: Ref<number | null>
|
||||
isMock: ComputedRef<boolean>
|
||||
@@ -280,6 +281,7 @@ export function useBaseData(): BaseDataController {
|
||||
profiles,
|
||||
athletes,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
selectedEventId,
|
||||
isMock: computed(() => baseDataFallback.value || mockMode.value),
|
||||
|
||||
Reference in new issue
Block a user