口径(用户确认): - 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数) - 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分 - 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、 没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存 服务端: - 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team; events 加 avg_mode(默认 valid) - 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team → 默认 athlete(result, 不计入单位) + team(aggregate 队内平均) - 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名 (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与) 前端: - 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」 - 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存 - 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致) - 大屏/管理端团体成绩只显示数值,不出现「平均」字样 自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
1212 lines
41 KiB
Vue
1212 lines
41 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import {
|
||
eventInputOf,
|
||
formatPoints,
|
||
hasAggregateChannel,
|
||
parseResultValue,
|
||
type AvgMode,
|
||
type DraftResultInput,
|
||
type EntryWithResult,
|
||
type Event,
|
||
type ResultState,
|
||
type ResultStatus,
|
||
} from '@/shared'
|
||
import MxIcon from '../components/MxIcon.vue'
|
||
import PageHeader from '../components/PageHeader.vue'
|
||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode, type AvgModeState } from './avgMode'
|
||
import { adminApi } from '../api/client'
|
||
import { computeEventScore, type ScorableRow } from '../api/scoring'
|
||
import { entryRowLabel } from '../components/baseData/entrySelection'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import { useEventStore } from '../stores/event'
|
||
import { useToastStore } from '../stores/toast'
|
||
|
||
/** 表格里的一行:报名项 + 正在编辑的成绩。 */
|
||
interface EditableRow {
|
||
entryId: number
|
||
categoryId: number
|
||
bib: string | null
|
||
teamId: number
|
||
athleteId: number | null
|
||
teamName: string
|
||
teamShort: string
|
||
athleteName: string
|
||
memberNames: string[] | null
|
||
resultText: string
|
||
rank: number | null
|
||
status: ResultStatus
|
||
isTie: boolean
|
||
note: string
|
||
resultState: ResultState | null
|
||
original: string
|
||
}
|
||
|
||
interface Issue {
|
||
entryId: number | null
|
||
level: 'error' | 'warn'
|
||
message: string
|
||
}
|
||
|
||
interface PreviewRow {
|
||
teamId: number
|
||
name: string
|
||
current: number
|
||
delta: number
|
||
next: number
|
||
}
|
||
|
||
const STATUS_OPTIONS: Array<{ value: ResultStatus; label: string }> = [
|
||
{ value: 'ok', label: '有效' },
|
||
{ value: 'dns', label: 'DNS 弃赛' },
|
||
{ value: 'dq', label: 'DQ 取消' },
|
||
{ value: 'dnf', label: 'DNF 未完赛' },
|
||
{ value: 'withdrawn', label: '退赛' },
|
||
]
|
||
|
||
const route = useRoute()
|
||
const data = useEventStore()
|
||
const auth = useAuthStore()
|
||
const toast = useToastStore()
|
||
|
||
const selectedEventId = ref<number | null>(null)
|
||
const detail = ref<Event | null>(null)
|
||
const selectedCategoryId = ref<number | null>(null)
|
||
const allRows = ref<EditableRow[]>([])
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const publishing = ref(false)
|
||
const unpublishing = ref(false)
|
||
const publishDialog = ref(false)
|
||
const unpublishDialog = ref(false)
|
||
const focusedEntryId = ref<number | null>(null)
|
||
const tableRef = ref<HTMLElement | null>(null)
|
||
const recordForEvent = ref<{ resultText: string; resultValue: number } | null>(null)
|
||
/** 项目切换的请求序号:只有最新一次请求的响应才允许写入页面状态。 */
|
||
let requestSeq = 0
|
||
|
||
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||
|
||
const eventGroups = computed(() => {
|
||
const groups = data.sports
|
||
.map((sport) => ({ key: sport.id, name: sport.name, events: data.events.filter((e) => e.sportId === sport.id) }))
|
||
.filter((group) => group.events.length > 0)
|
||
if (groups.length > 0) return groups
|
||
return [{ key: 0, name: '全部项目', events: data.events }]
|
||
})
|
||
|
||
const selectedEvent = computed(() => detail.value ?? (selectedEventId.value === null ? null : (data.eventById(selectedEventId.value) ?? null)))
|
||
const categories = computed(() => detail.value?.categories ?? [])
|
||
const channels = computed(() => detail.value?.channels ?? [])
|
||
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
|
||
const manualRank = computed(() => selectedEvent.value?.resultMode === 'manual' || selectedEvent.value?.compare === 'none')
|
||
const avgMode = ref<AvgModeState>(loadAvgMode(null, 'valid'))
|
||
/** 只有配了「按队内平均」通道的小项才需要选平均口径。 */
|
||
const showAvgMode = computed(() => hasAggregateChannel(channels.value))
|
||
|
||
function unitOf(type: Event['resultType']): string {
|
||
if (type === 'time') return 's'
|
||
if (type === 'distance') return 'm'
|
||
if (type === 'count') return '次'
|
||
if (type === 'score') return 'pts'
|
||
return ''
|
||
}
|
||
|
||
const unit = computed(() => unitOf(selectedEvent.value?.resultType ?? 'none'))
|
||
|
||
const visibleRows = computed(() =>
|
||
allRows.value.filter((row) => selectedCategoryId.value === null || row.categoryId === selectedCategoryId.value),
|
||
)
|
||
|
||
const scorable = computed<ScorableRow[]>(() =>
|
||
visibleRows.value.map((row) => ({
|
||
entryId: row.entryId,
|
||
teamId: row.teamId,
|
||
athleteId: row.athleteId,
|
||
resultValue: parseResultValue(row.resultText, unit.value),
|
||
rank: row.rank,
|
||
status: row.status,
|
||
isTie: row.isTie,
|
||
})),
|
||
)
|
||
|
||
const score = computed(() => {
|
||
const event = selectedEvent.value
|
||
if (event === null) return null
|
||
return computeEventScore(scorable.value, {
|
||
event,
|
||
category: categories.value.find((item) => item.id === selectedCategoryId.value) ?? null,
|
||
channels: channels.value,
|
||
profiles: data.profiles,
|
||
weight: weight.value,
|
||
})
|
||
})
|
||
|
||
/**
|
||
* 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。
|
||
*
|
||
* 用 rowTeamPoints(各通道原始分,个人通道已按权重折算)而不是服务端 result.points:
|
||
* 后者只覆盖已发布行,且编辑中的行服务端还没算分,两边口径无法混用。
|
||
*/
|
||
const publishedContribution = computed(() => {
|
||
const map = new Map<number, number>()
|
||
const rowTeamPoints = score.value?.rowTeamPoints
|
||
for (const row of visibleRows.value) {
|
||
if (row.resultState !== 'published') continue
|
||
map.set(row.teamId, (map.get(row.teamId) ?? 0) + (rowTeamPoints?.get(row.entryId) ?? 0))
|
||
}
|
||
return map
|
||
})
|
||
|
||
const preview = computed<PreviewRow[]>(() => {
|
||
const current = new Map(data.standings.map((row) => [row.teamId, row.totalPoints]))
|
||
const next = score.value?.teamPoints ?? new Map<number, number>()
|
||
return data.teams
|
||
.map((team) => {
|
||
const now = current.get(team.id) ?? 0
|
||
const delta = round((next.get(team.id) ?? 0) - (publishedContribution.value.get(team.id) ?? 0))
|
||
return { teamId: team.id, name: team.shortName ?? team.name, current: now, delta, next: round(now + delta) }
|
||
})
|
||
.sort((a, b) => b.next - a.next || a.name.localeCompare(b.name))
|
||
})
|
||
|
||
const maxDelta = computed(() => preview.value.reduce((max, row) => Math.max(max, Math.abs(row.delta)), 0))
|
||
const affectedTeams = computed(() => preview.value.filter((row) => row.delta !== 0).length)
|
||
const isPublished = computed(() => visibleRows.value.some((row) => row.resultState === 'published'))
|
||
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
|
||
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
|
||
|
||
/** 已发布的行由服务端锁定:只能先撤销发布,不能就地改草稿。 */
|
||
function rowLocked(row: EditableRow): boolean {
|
||
return row.resultState === 'published'
|
||
}
|
||
|
||
const issues = computed<Issue[]>(() => {
|
||
const list: Issue[] = []
|
||
const event = selectedEvent.value
|
||
if (event === null) return list
|
||
const seenRanks = new Map<number, number>()
|
||
|
||
for (const row of visibleRows.value) {
|
||
if (row.status !== 'ok') continue
|
||
const needsRank = manualRank.value
|
||
if (row.resultText.trim() === '') {
|
||
// 手动录名次的项目(如篮球)允许只填名次、不填成绩文本。
|
||
if (!(needsRank && row.rank !== null)) {
|
||
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:缺少成绩` })
|
||
}
|
||
} else {
|
||
const value = parseResultValue(row.resultText, unit.value)
|
||
if (value === null) {
|
||
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:成绩格式无法解析` })
|
||
} else if (value <= 0) {
|
||
list.push({ entryId: row.entryId, level: 'warn', message: `${rowLabel(row)}:成绩 ≤ 0,疑似录入错误` })
|
||
}
|
||
}
|
||
if (needsRank && row.rank === null) {
|
||
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:缺少名次` })
|
||
}
|
||
if (needsRank && row.rank !== null) {
|
||
seenRanks.set(row.rank, (seenRanks.get(row.rank) ?? 0) + 1)
|
||
}
|
||
}
|
||
|
||
for (const [rank, count] of seenRanks) {
|
||
if (count > 1) {
|
||
list.push({ entryId: null, level: 'warn', message: `名次 ${rank} 重复 ${count} 次,请确认是否并列(并列需勾选「并列」)` })
|
||
}
|
||
}
|
||
|
||
const record = recordForEvent.value
|
||
if (record !== null && record.resultValue > 0) {
|
||
for (const row of visibleRows.value) {
|
||
const value = parseResultValue(row.resultText, unit.value)
|
||
if (value === null || row.status !== 'ok') continue
|
||
const ratio = value / record.resultValue
|
||
if (ratio < 0.5 || ratio > 2) {
|
||
list.push({
|
||
entryId: row.entryId,
|
||
level: 'warn',
|
||
message: `${rowLabel(row)}:${row.resultText} 与赛会纪录 ${record.resultText} 差距过大,疑似录入错误`,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return list
|
||
})
|
||
|
||
const errors = computed(() => issues.value.filter((issue) => issue.level === 'error'))
|
||
const warnings = computed(() => issues.value.filter((issue) => issue.level === 'warn'))
|
||
|
||
/** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */
|
||
function rowLabel(row: EditableRow): string {
|
||
return entryRowLabel(row)
|
||
}
|
||
|
||
function round(value: number): number {
|
||
return Number(value.toFixed(2))
|
||
}
|
||
|
||
/** 空缺成绩的快照必须与 rowDirty 拼出的默认行一致,否则一进页面就亮「已修改」。 */
|
||
function snapshotOf(entry: EntryWithResult): string {
|
||
const result = entry.result
|
||
if (result === null) return ['', '', 'ok', '0', ''].join('|')
|
||
return [result.resultText, result.rank ?? '', result.status, result.isTie ? '1' : '0', result.note ?? ''].join('|')
|
||
}
|
||
|
||
function toRow(entry: EntryWithResult): EditableRow {
|
||
const result = entry.result
|
||
const row: EditableRow = {
|
||
entryId: entry.id,
|
||
categoryId: entry.categoryId,
|
||
bib: entry.bib,
|
||
teamId: entry.teamId,
|
||
athleteId: entry.athleteId,
|
||
teamName: entry.teamName ?? data.teamById(entry.teamId)?.name ?? '未知单位',
|
||
teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '',
|
||
athleteName: entry.athleteName ?? '',
|
||
memberNames: entry.memberNames,
|
||
resultText: result?.resultText ?? '',
|
||
rank: result?.rank ?? null,
|
||
status: result?.status ?? 'ok',
|
||
isTie: result?.isTie ?? false,
|
||
note: result?.note ?? '',
|
||
resultState: result?.state ?? null,
|
||
original: '',
|
||
}
|
||
row.original = snapshotOf(entry)
|
||
return row
|
||
}
|
||
|
||
function rowDirty(row: EditableRow): boolean {
|
||
return row.original !== [row.resultText, row.rank ?? '', row.status, row.isTie ? '1' : '0', row.note].join('|')
|
||
}
|
||
|
||
/** 名次输入框清空时 v-model.number 会给出空串,必须在入口就归一为 null。 */
|
||
function rankInputValue(event: InputEvent): number | null {
|
||
const raw = (event.target as HTMLInputElement).value.trim()
|
||
if (raw === '') return null
|
||
const value = Number(raw)
|
||
return Number.isFinite(value) ? Math.trunc(value) : null
|
||
}
|
||
|
||
function rankOf(row: EditableRow): number | null {
|
||
if (manualRank.value) return row.rank
|
||
return score.value?.ranks.get(row.entryId) ?? null
|
||
}
|
||
|
||
function pointsOf(row: EditableRow): number {
|
||
return round(score.value?.rowPoints.get(row.entryId) ?? 0)
|
||
}
|
||
|
||
function issueFor(entryId: number): Issue | undefined {
|
||
return issues.value.find((issue) => issue.entryId === entryId)
|
||
}
|
||
|
||
/** 切换项目会丢弃当前编辑,先让操作员确认。 */
|
||
function confirmDiscard(): boolean {
|
||
if (!dirty.value) return true
|
||
return window.confirm('当前有未保存的成绩修改,切换项目会丢弃这些修改。确定继续吗?')
|
||
}
|
||
|
||
async function selectEvent(id: number): Promise<void> {
|
||
if (selectedEventId.value === id && detail.value !== null) return
|
||
if (!confirmDiscard()) return
|
||
selectedEventId.value = id
|
||
const seq = (requestSeq += 1)
|
||
loading.value = true
|
||
try {
|
||
const [event, entries, records] = await Promise.all([
|
||
adminApi.eventDetail(id),
|
||
adminApi.entries(id),
|
||
adminApi.records(),
|
||
])
|
||
// 切换过快时旧响应可能后到,只有最新一次请求的结果才允许覆盖页面。
|
||
if (seq !== requestSeq) return
|
||
detail.value = event
|
||
avgMode.value = loadAvgMode(id, event.avgMode)
|
||
selectedCategoryId.value = event.categories?.[0]?.id ?? null
|
||
allRows.value = entries.map(toRow)
|
||
const record = records.find((item) => item.eventId === id && item.isCurrent) ?? null
|
||
recordForEvent.value = record === null ? null : { resultText: record.resultText, resultValue: record.resultValue }
|
||
} catch (caught) {
|
||
if (seq !== requestSeq) return
|
||
toast.error(caught instanceof Error ? caught.message : '加载项目失败')
|
||
} finally {
|
||
if (seq === requestSeq) loading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 切换平均值口径:立即整体回写该小项(PUT /api/events/{id}),成功后 toast。
|
||
*
|
||
* 与成绩草稿的保存完全分离——这里既不改录入行,也不走 saveDraft;
|
||
* 失败时把选择退回原值,避免页面显示的口径与服务端实际生效的口径不一致。
|
||
*/
|
||
async function pickAvgMode(mode: AvgMode): Promise<void> {
|
||
const event = detail.value
|
||
if (event === null || !canWrite.value) return
|
||
if (data.isMock) {
|
||
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报;与「发布」一致直接停用。
|
||
toast.error('示例数据模式不能保存口径', '后端未就绪,改动不会写入真实赛事')
|
||
return
|
||
}
|
||
const next = selectAvgMode(avgMode.value, mode)
|
||
avgMode.value = next.state
|
||
if (!next.shouldSave) return
|
||
try {
|
||
const saved = await adminApi.client.events.update(event.id, eventInputOf(event, { avgMode: mode }))
|
||
avgMode.value = settleAvgMode(avgMode.value, true)
|
||
// 服务端返回的是小项本身,组别 / 通道仍以当前页加载的详情为准。
|
||
detail.value = {
|
||
...saved,
|
||
categories: saved.categories ?? event.categories,
|
||
channels: saved.channels ?? event.channels,
|
||
}
|
||
await data.refreshEvents()
|
||
toast.success(
|
||
'平均值口径已保存',
|
||
mode === 'all' ? '没成绩的按 0 计入平均' : '只算有有效成绩的人',
|
||
)
|
||
} catch (caught) {
|
||
avgMode.value = settleAvgMode(avgMode.value, false)
|
||
toast.error('保存平均值口径失败', caught instanceof Error ? caught.message : null)
|
||
}
|
||
}
|
||
|
||
async function reload(): Promise<void> {
|
||
if (selectedEventId.value === null) return
|
||
const id = selectedEventId.value
|
||
const seq = requestSeq
|
||
loading.value = true
|
||
try {
|
||
const entries = await adminApi.entries(id)
|
||
if (seq !== requestSeq) return
|
||
allRows.value = entries.map(toRow)
|
||
} catch (caught) {
|
||
if (seq !== requestSeq) return
|
||
toast.error(caught instanceof Error ? caught.message : '重新加载失败')
|
||
} finally {
|
||
if (seq === requestSeq) loading.value = false
|
||
}
|
||
}
|
||
|
||
function buildDraftPayload(): DraftResultInput[] {
|
||
return visibleRows.value.map((row) => ({
|
||
entryId: row.entryId,
|
||
rank: manualRank.value ? row.rank : null,
|
||
resultText: row.resultText.trim(),
|
||
resultValue: parseResultValue(row.resultText, unit.value),
|
||
// 不传 unit 时服务端会把已有单位覆盖成 NULL,大屏 100 米就会显示成 66.33。
|
||
unit: unit.value,
|
||
status: row.status,
|
||
isTie: row.isTie,
|
||
note: row.note.trim() === '' ? null : row.note.trim(),
|
||
}))
|
||
}
|
||
|
||
async function saveDraft(silent = false): Promise<boolean> {
|
||
if (selectedEventId.value === null) return false
|
||
if (saving.value) return false
|
||
if (isPublished.value) {
|
||
// 已发布行由服务端锁定:就地存草稿会清掉名次却不重算积分,必须先撤销发布。
|
||
toast.error('该组已发布', '修改前请先撤销发布')
|
||
return false
|
||
}
|
||
if (!canWrite.value) {
|
||
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
|
||
return false
|
||
}
|
||
saving.value = true
|
||
try {
|
||
await adminApi.saveDraft({
|
||
eventId: selectedEventId.value,
|
||
categoryId: selectedCategoryId.value ?? undefined,
|
||
results: buildDraftPayload(),
|
||
})
|
||
await reload()
|
||
if (!silent) toast.success('草稿已保存')
|
||
return true
|
||
} catch (caught) {
|
||
toast.error('保存草稿失败', caught instanceof Error ? caught.message : null)
|
||
return false
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function requestPublish(): void {
|
||
if (selectedEventId.value === null) return
|
||
if (data.isMock) {
|
||
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
|
||
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
|
||
return
|
||
}
|
||
if (!canWrite.value) {
|
||
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
|
||
return
|
||
}
|
||
if (isPublished.value) {
|
||
toast.error('该组已发布', '如需修改请先撤销发布')
|
||
return
|
||
}
|
||
if (errors.value.length > 0) {
|
||
toast.error(`有 ${errors.value.length} 处校验未通过`, errors.value[0]?.message ?? null)
|
||
return
|
||
}
|
||
publishDialog.value = true
|
||
}
|
||
|
||
async function confirmPublish(): Promise<void> {
|
||
if (selectedEventId.value === null) return
|
||
publishing.value = true
|
||
try {
|
||
// 发布作用于服务端已保存的草稿,所以必须先整组落草稿再发布(07 第 3 节)。
|
||
await adminApi.saveDraft({
|
||
eventId: selectedEventId.value,
|
||
categoryId: selectedCategoryId.value ?? undefined,
|
||
results: buildDraftPayload(),
|
||
})
|
||
await adminApi.publish({
|
||
eventId: selectedEventId.value,
|
||
categoryId: selectedCategoryId.value ?? undefined,
|
||
})
|
||
publishDialog.value = false
|
||
await reload()
|
||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||
toast.success('发布成功,积分榜已更新')
|
||
} catch (caught) {
|
||
toast.error('发布失败', caught instanceof Error ? caught.message : null)
|
||
} finally {
|
||
publishing.value = false
|
||
}
|
||
}
|
||
|
||
async function confirmUnpublish(): Promise<void> {
|
||
if (selectedEventId.value === null) return
|
||
unpublishing.value = true
|
||
try {
|
||
await adminApi.unpublish({
|
||
eventId: selectedEventId.value,
|
||
categoryId: selectedCategoryId.value ?? undefined,
|
||
})
|
||
unpublishDialog.value = false
|
||
await reload()
|
||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||
toast.warning('已撤销发布,大屏将回退该组成绩')
|
||
} catch (caught) {
|
||
toast.error('撤销发布失败', caught instanceof Error ? caught.message : null)
|
||
} finally {
|
||
unpublishing.value = false
|
||
}
|
||
}
|
||
|
||
function autoSort(): void {
|
||
const direction = selectedEvent.value?.compare === 'asc' ? 1 : -1
|
||
allRows.value = [...allRows.value].sort((a, b) => {
|
||
const av = parseResultValue(a.resultText, unit.value)
|
||
const bv = parseResultValue(b.resultText, unit.value)
|
||
if (av === null && bv === null) return 0
|
||
if (av === null) return 1
|
||
if (bv === null) return -1
|
||
return direction * (av - bv)
|
||
})
|
||
toast.info('已按成绩重排(名次仍以比较值为准)')
|
||
}
|
||
|
||
/** Enter 在单元格之间移动;Tab 交给浏览器原生行为。 */
|
||
function onCellKeydown(event: KeyboardEvent, element: HTMLElement): void {
|
||
if (event.key !== 'Enter' || event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) return
|
||
event.preventDefault()
|
||
const cells = Array.from(tableRef.value?.querySelectorAll<HTMLElement>('[data-cell]') ?? [])
|
||
const index = cells.indexOf(element)
|
||
const next = cells[index + 1]
|
||
if (next !== undefined) {
|
||
next.focus()
|
||
if (next instanceof HTMLInputElement) next.select()
|
||
}
|
||
}
|
||
|
||
function onPageKeydown(event: KeyboardEvent): void {
|
||
if (!event.ctrlKey && !event.altKey) return
|
||
if (event.key !== 'Enter') return
|
||
event.preventDefault()
|
||
if (saving.value || publishing.value || unpublishing.value) return
|
||
if (event.altKey) requestPublish()
|
||
else void saveDraft()
|
||
}
|
||
|
||
/** 关闭/刷新页面前拦住未保存的编辑,避免一整组成绩静默丢失。 */
|
||
function onBeforeUnload(event: BeforeUnloadEvent): void {
|
||
if (!dirty.value) return
|
||
event.preventDefault()
|
||
event.returnValue = ''
|
||
}
|
||
|
||
/**
|
||
* Excel 粘贴:两列及以上时,第一列按单位名匹配行,最后一列作为成绩;
|
||
* 单列时从当前行往下顺序填充(07 第 2 节第 4 条)。
|
||
*/
|
||
function onPaste(event: ClipboardEvent, startEntryId: number): void {
|
||
const text = event.clipboardData?.getData('text/plain') ?? ''
|
||
if (text.trim() === '') return
|
||
event.preventDefault()
|
||
|
||
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
|
||
let cursor = visibleRows.value.findIndex((row) => row.entryId === startEntryId)
|
||
|
||
for (const line of lines) {
|
||
const columns = line.split('\t').map((column) => column.trim())
|
||
if (cursor < 0 || cursor >= visibleRows.value.length) break
|
||
let target = visibleRows.value[cursor]
|
||
if (target === undefined) break
|
||
|
||
if (columns.length >= 2) {
|
||
const key = columns[0] ?? ''
|
||
const matched = visibleRows.value.find(
|
||
(row) => row.teamName.includes(key) || (row.teamShort !== '' && key.includes(row.teamShort)),
|
||
)
|
||
if (matched !== undefined) target = matched
|
||
target.resultText = columns[columns.length - 1] ?? ''
|
||
} else {
|
||
target.resultText = columns[0] ?? ''
|
||
}
|
||
cursor = visibleRows.value.indexOf(target) + 1
|
||
}
|
||
toast.info(`已粘贴 ${lines.length} 行`)
|
||
}
|
||
|
||
function moveFocusBy(delta: number): void {
|
||
const cells = Array.from(tableRef.value?.querySelectorAll<HTMLElement>('[data-cell]') ?? [])
|
||
if (cells.length === 0) return
|
||
const active = document.activeElement
|
||
const index = active instanceof HTMLElement ? cells.indexOf(active) : -1
|
||
const next = cells[Math.min(Math.max(index + delta, 0), cells.length - 1)]
|
||
next?.focus()
|
||
}
|
||
|
||
/** 默认聚焦上次编辑的项目(07 第 7 节);URL 带 eventId 时优先。 */
|
||
async function pickInitialEvent(): Promise<void> {
|
||
if (selectedEventId.value !== null) return
|
||
const query = route.query.eventId
|
||
const fromQuery = typeof query === 'string' ? Number(query) : Number.NaN
|
||
if (Number.isFinite(fromQuery) && data.eventById(fromQuery) !== undefined) {
|
||
await selectEvent(fromQuery)
|
||
return
|
||
}
|
||
const fallback = data.events.find((event) => event.status === 'pending') ?? data.events[0]
|
||
if (fallback !== undefined) await selectEvent(fallback.id)
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('beforeunload', onBeforeUnload)
|
||
void pickInitialEvent()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||
})
|
||
|
||
// 项目列表由布局层异步加载,加载完成后补一次默认选中。
|
||
watch(
|
||
() => data.loaded,
|
||
(loaded) => {
|
||
if (loaded) void pickInitialEvent()
|
||
},
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="entry-page" @keydown="onPageKeydown">
|
||
<PageHeader title="成绩录入" :subtitle="selectedEvent?.name ?? '选择左侧项目开始录入'">
|
||
<template #actions>
|
||
<span v-if="dirty" class="mx-badge mx-badge-warning">有未保存修改</span>
|
||
<button
|
||
class="mx-btn mx-btn-secondary"
|
||
type="button"
|
||
:disabled="!canWrite || saving || isPublished"
|
||
:title="isPublished ? '该组已发布,请先撤销发布' : ''"
|
||
@click="saveDraft()"
|
||
>
|
||
<MxIcon name="check" :size="15" /> 存草稿
|
||
</button>
|
||
<button
|
||
class="mx-btn mx-btn-primary"
|
||
type="button"
|
||
:disabled="!canWrite || publishing || data.isMock || isPublished"
|
||
@click="requestPublish"
|
||
>
|
||
<MxIcon name="push" :size="15" /> 发布
|
||
</button>
|
||
</template>
|
||
</PageHeader>
|
||
|
||
<div v-if="data.isMock" class="mock-note">
|
||
<MxIcon name="warning" :size="15" />
|
||
<span>{{ data.mockLabel }},示例数据模式下「发布」已停用,成绩不会写入真实赛事。</span>
|
||
</div>
|
||
|
||
<div class="entry-grid">
|
||
<aside class="mx-card project-panel">
|
||
<div class="mx-card-head"><span class="mx-card-title">项目</span></div>
|
||
<div class="project-list">
|
||
<div v-for="group in eventGroups" :key="group.key" class="project-group">
|
||
<div class="mx-sidebar-section">{{ group.name }}</div>
|
||
<button
|
||
v-for="event in group.events"
|
||
:key="event.id"
|
||
class="project-item"
|
||
:class="{ active: event.id === selectedEventId }"
|
||
type="button"
|
||
@click="selectEvent(event.id)"
|
||
>
|
||
<span class="project-name">{{ event.name }}</span>
|
||
<span class="mx-badge" :class="event.status === 'published' ? 'mx-badge-success' : 'mx-badge-warning'">
|
||
{{ event.status === 'published' ? '已发布' : '待录入' }}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div v-if="data.events.length === 0" class="mx-empty">
|
||
<div class="mx-empty-title">没有可录入的项目</div>
|
||
<div class="mx-empty-desc">请先在「基础数据」创建小项</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="entry-main">
|
||
<div v-if="selectedEvent === null" class="mx-card">
|
||
<div class="mx-card-body">
|
||
<div class="mx-empty">
|
||
<div class="mx-empty-icon"><MxIcon name="entry" :size="28" /></div>
|
||
<div class="mx-empty-title">请选择一个项目</div>
|
||
<div class="mx-empty-desc">左侧列表按大项分组,红色标记为待录入</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div class="entry-toolbar">
|
||
<div class="mx-tabs" v-if="categories.length > 1">
|
||
<button
|
||
v-for="category in categories"
|
||
:key="category.id"
|
||
class="mx-tab"
|
||
:class="{ active: category.id === selectedCategoryId }"
|
||
type="button"
|
||
@click="selectedCategoryId = category.id"
|
||
>
|
||
{{ category.name }}
|
||
</button>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<span v-if="selectedEvent.resultType !== 'none'" class="unit-hint mono">单位:{{ unit || '—' }}</span>
|
||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="autoSort">
|
||
<MxIcon name="adjust" :size="14" /> 按成绩重排
|
||
</button>
|
||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="moveFocusBy(1)">
|
||
<MxIcon name="keyboard" :size="14" /> 下一格
|
||
</button>
|
||
<button
|
||
v-if="isPublished"
|
||
class="mx-btn mx-btn-danger mx-btn-sm"
|
||
type="button"
|
||
:disabled="!canWrite"
|
||
@click="unpublishDialog = true"
|
||
>
|
||
撤销发布
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="showAvgMode" class="avg-mode-bar">
|
||
<span class="avg-mode-title"><MxIcon name="adjust" :size="15" /> 平均值口径</span>
|
||
<label
|
||
v-for="option in AVG_MODE_OPTIONS"
|
||
:key="option.value"
|
||
class="avg-mode-option"
|
||
:class="{ active: avgMode.mode === option.value }"
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="avg-mode"
|
||
:value="option.value"
|
||
:checked="avgMode.mode === option.value"
|
||
:disabled="!canWrite || avgMode.saving"
|
||
@change="pickAvgMode(option.value)"
|
||
/>
|
||
<span class="avg-mode-text">{{ option.label }}</span>
|
||
<span class="avg-mode-hint">{{ option.hint }}</span>
|
||
</label>
|
||
<span v-if="avgMode.saving" class="avg-mode-hint">保存中…</span>
|
||
<span class="avg-mode-note">团体成绩 = 该单位成员成绩的平均值</span>
|
||
</div>
|
||
|
||
<div v-if="isPublished" class="published-note">
|
||
<MxIcon name="lock" :size="15" />
|
||
该组已发布,成绩与名次已锁定上屏:如需修改,请先「撤销发布」。草稿态不会上屏。
|
||
</div>
|
||
|
||
<div class="entry-body">
|
||
<div class="mx-card table-card">
|
||
<div ref="tableRef" class="mx-table-wrap">
|
||
<table class="mx-table mx-table-compact mx-table-entry">
|
||
<thead>
|
||
<tr>
|
||
<th>号码</th>
|
||
<th>单位</th>
|
||
<th>运动员 / 队</th>
|
||
<th>成绩输入</th>
|
||
<th class="center">名次</th>
|
||
<th>状态</th>
|
||
<th class="center">积分(自动)</th>
|
||
<th>备注</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="row in visibleRows"
|
||
:key="row.entryId"
|
||
:class="{ 'is-dirty': rowDirty(row), 'is-focus': focusedEntryId === row.entryId }"
|
||
>
|
||
<td class="mono">{{ row.bib ?? '—' }}</td>
|
||
<td>
|
||
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
||
</td>
|
||
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
|
||
<td>
|
||
<input
|
||
v-model="row.resultText"
|
||
data-cell
|
||
class="mx-cell-input mono"
|
||
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error' }"
|
||
:disabled="!canWrite || rowLocked(row)"
|
||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||
:placeholder="unit === 's' ? '1:06.33' : unit"
|
||
@focus="focusedEntryId = row.entryId"
|
||
@blur="focusedEntryId = null"
|
||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||
@paste="onPaste($event, row.entryId)"
|
||
/>
|
||
</td>
|
||
<td class="center">
|
||
<input
|
||
v-if="manualRank"
|
||
:value="row.rank ?? ''"
|
||
data-cell
|
||
type="number"
|
||
min="1"
|
||
class="mx-cell-input rank-input"
|
||
:disabled="!canWrite || rowLocked(row)"
|
||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||
@input="row.rank = rankInputValue($event)"
|
||
@focus="focusedEntryId = row.entryId"
|
||
@blur="focusedEntryId = null"
|
||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||
/>
|
||
<span v-else class="rank-auto mono">{{ rankOf(row) ?? '—' }}</span>
|
||
</td>
|
||
<td>
|
||
<select
|
||
v-model="row.status"
|
||
data-cell
|
||
class="mx-cell-input status-select"
|
||
:disabled="!canWrite || rowLocked(row)"
|
||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||
@focus="focusedEntryId = row.entryId"
|
||
@blur="focusedEntryId = null"
|
||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||
>
|
||
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
</td>
|
||
<td class="center">
|
||
<span class="points-cell mono" :class="{ zero: pointsOf(row) === 0 }">
|
||
{{ formatPoints(pointsOf(row)) }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<input
|
||
v-model="row.note"
|
||
data-cell
|
||
class="mx-cell-input"
|
||
:disabled="!canWrite || rowLocked(row)"
|
||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||
placeholder="—"
|
||
@focus="focusedEntryId = row.entryId"
|
||
@blur="focusedEntryId = null"
|
||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||
/>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!loading && visibleRows.length === 0">
|
||
<td colspan="8">
|
||
<div class="mx-empty">
|
||
<div class="mx-empty-title">该组还没有报名项</div>
|
||
<div class="mx-empty-desc">请先导入报名表或在基础数据里添加</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<aside class="mx-card preview-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">实时积分预览</span>
|
||
<span class="mx-badge mx-badge-info">{{ affectedTeams }} 个单位受影响</span>
|
||
</div>
|
||
<div class="mx-card-body">
|
||
<p class="preview-hint">
|
||
若现在发布,各单位积分变化(最大分差 <strong>{{ formatPoints(maxDelta) }}</strong> 分)
|
||
</p>
|
||
<ul class="preview-list">
|
||
<li v-for="row in preview" :key="row.teamId" class="preview-item">
|
||
<span class="preview-name">{{ row.name }}</span>
|
||
<span class="preview-delta mono" :class="row.delta > 0 ? 'up' : row.delta < 0 ? 'down' : ''">
|
||
{{ row.delta > 0 ? '+' : '' }}{{ formatPoints(row.delta) }}
|
||
</span>
|
||
<span class="preview-total mono">{{ formatPoints(row.next) }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<div v-if="issues.length > 0" class="mx-card issues-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">防呆校验</span>
|
||
<span class="issue-counts">
|
||
<span v-if="errors.length > 0" class="mx-badge mx-badge-error">{{ errors.length }} 项阻止发布</span>
|
||
<span v-if="warnings.length > 0" class="mx-badge mx-badge-warning">{{ warnings.length }} 项提醒</span>
|
||
</span>
|
||
</div>
|
||
<div class="mx-card-body">
|
||
<ul class="issue-list">
|
||
<li v-for="(issue, index) in issues" :key="index" :class="issue.level">
|
||
<MxIcon :name="issue.level === 'error' ? 'warning' : 'bell'" :size="14" />
|
||
<span>{{ issue.message }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="hotkey-hint">
|
||
<MxIcon name="keyboard" :size="14" />
|
||
<kbd>Tab</kbd>/<kbd>Enter</kbd> 移动 · <kbd>Ctrl</kbd>+<kbd>Enter</kbd> 存草稿 ·
|
||
<kbd>Alt</kbd>+<kbd>Enter</kbd> 发布 · 支持从 Excel 粘贴「单位 + 成绩」两列
|
||
</p>
|
||
</template>
|
||
</section>
|
||
</div>
|
||
|
||
<ConfirmDialog
|
||
:open="publishDialog"
|
||
tone="warning"
|
||
title="确认发布该组成绩?"
|
||
:message="`本次将影响 ${affectedTeams} 个单位,最大分差变化 ${formatPoints(maxDelta)} 分。发布后立即上屏并重算积分榜。`"
|
||
:details="warnings.map((issue) => issue.message)"
|
||
confirm-text="确认发布"
|
||
:pending="publishing"
|
||
@confirm="confirmPublish"
|
||
@cancel="publishDialog = false"
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
:open="unpublishDialog"
|
||
tone="danger"
|
||
title="确认撤销发布?"
|
||
message="撤销发布将作废该组已产生的积分流水,大屏会回退到发布前的状态。"
|
||
:details="[`影响单位:${affectedTeams} 个`, `积分回退:${formatPoints(maxDelta)} 分`]"
|
||
confirm-text="撤销发布"
|
||
:pending="unpublishing"
|
||
@confirm="confirmUnpublish"
|
||
@cancel="unpublishDialog = false"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.mock-note {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
margin-bottom: 14px;
|
||
border-radius: var(--mx-r-sm);
|
||
font-size: 13px;
|
||
background: rgba(251, 191, 36, 0.1);
|
||
color: var(--mx-amber);
|
||
}
|
||
.entry-grid {
|
||
display: grid;
|
||
grid-template-columns: 264px minmax(0, 1fr);
|
||
gap: 16px;
|
||
align-items: start;
|
||
}
|
||
.project-panel {
|
||
position: sticky;
|
||
top: 0;
|
||
max-height: calc(100dvh - 140px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.project-list {
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
.project-group + .project-group {
|
||
margin-top: 6px;
|
||
}
|
||
.project-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
width: 100%;
|
||
padding: 9px 10px;
|
||
border-radius: var(--mx-r-sm);
|
||
cursor: pointer;
|
||
color: var(--mx-t2);
|
||
font-size: 13px;
|
||
text-align: left;
|
||
transition: all var(--mx-d1) var(--mx-ease);
|
||
}
|
||
.project-item:hover {
|
||
background: var(--mx-s-hover);
|
||
color: var(--mx-text);
|
||
}
|
||
.project-item.active {
|
||
background: var(--mx-primary-glow);
|
||
color: var(--mx-primary);
|
||
font-weight: 600;
|
||
}
|
||
.project-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.entry-main {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
.entry-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.toolbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.unit-hint {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.avg-mode-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 10px 16px;
|
||
padding: 10px 14px;
|
||
border-radius: var(--mx-r-sm);
|
||
border: 1px solid var(--mx-primary);
|
||
background: var(--mx-primary-glow);
|
||
}
|
||
.avg-mode-title {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--mx-primary);
|
||
}
|
||
.avg-mode-option {
|
||
display: inline-flex;
|
||
align-items: baseline;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: var(--mx-t2);
|
||
cursor: pointer;
|
||
}
|
||
.avg-mode-option.active .avg-mode-text {
|
||
color: var(--mx-primary);
|
||
font-weight: 600;
|
||
}
|
||
.avg-mode-hint {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.avg-mode-note {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.published-note {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 14px;
|
||
border-radius: var(--mx-r-sm);
|
||
background: rgba(52, 211, 153, 0.08);
|
||
color: var(--mx-green);
|
||
font-size: 13px;
|
||
}
|
||
.entry-body {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 300px;
|
||
gap: 14px;
|
||
align-items: start;
|
||
}
|
||
.table-card {
|
||
min-width: 0;
|
||
}
|
||
.table-card .mx-table-wrap {
|
||
max-height: calc(100dvh - 320px);
|
||
overflow: auto;
|
||
}
|
||
.team-cell {
|
||
font-weight: 600;
|
||
}
|
||
.center {
|
||
text-align: center;
|
||
}
|
||
.rank-input {
|
||
width: 72px;
|
||
text-align: center;
|
||
}
|
||
.status-select {
|
||
width: 110px;
|
||
}
|
||
.rank-auto {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--mx-primary);
|
||
}
|
||
.points-cell {
|
||
font-size: 15px;
|
||
font-weight: 800;
|
||
color: var(--mx-green);
|
||
}
|
||
.points-cell.zero {
|
||
color: var(--mx-t3);
|
||
font-weight: 600;
|
||
}
|
||
.preview-card {
|
||
position: sticky;
|
||
top: 0;
|
||
}
|
||
.preview-hint {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
line-height: 1.6;
|
||
margin-bottom: 12px;
|
||
}
|
||
.preview-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.preview-item {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto auto;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 7px 10px;
|
||
border-radius: var(--mx-r-xs);
|
||
background: var(--mx-s3);
|
||
font-size: 13px;
|
||
}
|
||
.preview-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.preview-delta {
|
||
font-weight: 700;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
min-width: 42px;
|
||
text-align: right;
|
||
}
|
||
.preview-delta.up {
|
||
color: var(--mx-green);
|
||
}
|
||
.preview-delta.down {
|
||
color: var(--mx-red);
|
||
}
|
||
.preview-total {
|
||
font-weight: 800;
|
||
min-width: 46px;
|
||
text-align: right;
|
||
}
|
||
.issues-card .mx-card-body {
|
||
padding-top: 12px;
|
||
}
|
||
.issue-counts {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
.issue-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.issue-list li {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
.issue-list li.error {
|
||
color: var(--mx-red);
|
||
}
|
||
.issue-list li.warn {
|
||
color: var(--mx-amber);
|
||
}
|
||
.hotkey-hint {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
flex-wrap: wrap;
|
||
}
|
||
kbd {
|
||
padding: 1px 6px;
|
||
border: 1px solid var(--mx-border);
|
||
border-radius: 5px;
|
||
background: var(--mx-s3);
|
||
font-family: var(--mx-mono);
|
||
font-size: 11px;
|
||
}
|
||
@media (max-width: 1279px) {
|
||
.entry-body {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.preview-card {
|
||
position: static;
|
||
}
|
||
}
|
||
@media (max-width: 1023px) {
|
||
.entry-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.project-panel {
|
||
position: static;
|
||
max-height: none;
|
||
}
|
||
}
|
||
</style>
|