用户给出的 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。
586 lines
21 KiB
Vue
586 lines
21 KiB
Vue
<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>
|