Files
Scoreboard/web/src/admin/components/baseData/ProfilesSection.vue
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

586 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { liveOnPublishOf } from '../system/systemApi'
import { parseIndividualToTeamWeight, useBaseData } from './useBaseData'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
const data = useBaseData()
const auth = useAuthStore()
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)
// ── 榜单口径:全局设置,不随某张模板保存 ──────────────────────────────────
const isAdmin = computed(() => auth.user?.role === 'admin')
const weightInput = ref<number | string>('')
const weightSaving = ref(false)
const weightError = ref<string | null>(null)
/** 库里现有的系数;settings 仅管理员可见,非管理员拿到 null。 */
const storedWeight = computed<number | null>(() => data.settings.value?.standings.individualToTeamWeight ?? null)
const parsedWeight = computed(() => parseIndividualToTeamWeight(weightInput.value))
const weightDirty = computed(
() => parsedWeight.value !== null && storedWeight.value !== null && parsedWeight.value !== storedWeight.value,
)
// 服务端设置变化(规则包导入、别处保存)时回填;本地已改动的输入不被覆盖。
watch(
() => data.settings.value?.standings.individualToTeamWeight,
(value) => {
if (value === undefined) return
if (!weightDirty.value) weightInput.value = value
},
{ immediate: true },
)
// ── 分批结算方式:全局设置,同样只写 standings 子对象 ────────────────────
const liveOnPublish = ref(true)
const liveSaving = ref(false)
const liveError = ref<string | null>(null)
/** 库里现在的结算方式;settings 仅管理员可见,非管理员拿到 null。 */
const storedLiveOnPublish = computed<boolean | null>(() =>
data.settings.value === null ? null : liveOnPublishOf(data.settings.value),
)
const liveDirty = computed(
() => storedLiveOnPublish.value !== null && liveOnPublish.value !== storedLiveOnPublish.value,
)
// 服务端设置变化(别处保存 / 规则包导入)时回填;本地已改动的勾选不被覆盖。
watch(
() => data.settings.value,
(value) => {
if (value === null) return
if (!liveDirty.value) liveOnPublish.value = liveOnPublishOf(value)
},
{ immediate: true },
)
async function saveLiveOnPublishSetting(): Promise<void> {
liveSaving.value = true
liveError.value = null
try {
await data.saveLiveOnPublish(liveOnPublish.value)
toast.success(
'结算方式已保存',
liveOnPublish.value ? '每发布一批就重算名次与榜单' : '等该组全部录完后才更新名次与榜单',
)
} catch (caught) {
liveError.value = caught instanceof Error ? caught.message : '保存失败'
} finally {
liveSaving.value = false
}
}
async function saveWeight(): Promise<void> {
const value = parsedWeight.value
if (value === null) {
weightError.value = '折算系数需为 0–100 的数字,例如 1 或 0.5'
return
}
// 服务端对 ≤0 直接 400(0 与负数会被拒绝),本地先拦下,别让用户以为能「按 1 处理」。
if (value <= 0) {
weightError.value = '折算系数必须大于 0,服务端会拒绝 0 与负数'
return
}
weightSaving.value = true
weightError.value = null
try {
await data.saveIndividualToTeamWeight(value)
toast.success('榜单口径已保存', '个人项目得分已按新系数折算计入单位总分')
} catch (caught) {
weightError.value = caught instanceof Error ? caught.message : '保存失败'
} finally {
weightSaving.value = false
}
}
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>
<section class="mx-card global-card">
<div class="mx-card-head">
<span class="mx-card-title">榜单口径<span class="mx-badge mx-badge-neutral global-badge">全局设置</span></span>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!isAdmin || !weightDirty || weightSaving"
@click="saveWeight"
>
{{ weightSaving ? '保存中…' : '保存榜单口径' }}
</button>
</div>
<div class="mx-card-body">
<div class="bd-form-grid">
<FormField
label="个人项目得分折算系数"
:error="weightError"
hint="个人项目得分按该系数折算计入单位总分(全局设置,对所有模板生效)。默认 1,步进 0.1;服务端要求大于 0、不超过 100。"
>
<input
v-model="weightInput"
class="mx-cell-input"
type="number"
min="0"
max="100"
step="0.1"
:disabled="!isAdmin || weightSaving"
placeholder="1"
/>
</FormField>
<FormField
label="分批发布时实时更新榜单"
:error="liveError"
hint="开(默认):每发布一批成绩就重算名次、各单位总分与奖牌榜。关掉 = 延迟结算:发布只落成绩,等该项目该组所有批次都录完才一起更新名次与榜单;没录完时可在成绩录入页点「立即结算」。"
>
<div class="settle-row">
<label class="bd-check">
<input
v-model="liveOnPublish"
type="checkbox"
:disabled="!isAdmin || liveSaving"
/>
发布后立即结算
</label>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!isAdmin || !liveDirty || liveSaving"
@click="saveLiveOnPublishSetting"
>
{{ liveSaving ? '保存中…' : '保存结算方式' }}
</button>
</div>
</FormField>
</div>
<p class="bd-note">
这是<strong>全局</strong>设置(standings.individual_to_team_weight),不随某一张积分模板保存,所有模板与所有计分通道共用:
单位总分 = Σ 只记单位的流水 + 系数 × Σ 记到个人的流水。导入规则包时,规则包里的同名配置会覆盖这里。
</p>
<p v-if="!isAdmin" class="bd-note weight-readonly">
只有管理员能读写赛事设置(GET / PUT /api/settings 仅管理员可用),当前账号为只读:读不到具体系数,榜单按库里已有取值折算(未配置时为 1)。
</p>
<p v-else-if="storedWeight === null" class="bd-note">
赛事设置尚未加载(未登录或服务端未连接),暂时读不到当前系数。
</p>
</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);
}
/* 榜单口径是全局设置,横跨列表与明细两栏,避免被当成某张模板的一部分。 */
.global-card {
grid-column: 1 / -1;
}
.global-badge {
margin-left: 8px;
vertical-align: middle;
}
.weight-readonly {
color: var(--mx-amber);
}
.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;
}
.settle-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
</style>