fix(server): 赛事包导出传入项目名,修复 GET /api/export/project.zip 恒 500

This commit is contained in:
AskaEth committed 2026-09-09 19:57:23 +08:00
1 parent 72462792ec
commit 5ae55b8057
36 files changed
+2672 -108

No files matched your search

@@ -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>