fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件

This commit is contained in:
AskaEth committed 2026-09-09 20:57:58 +08:00
1 parent c121111f51
commit b80c64a115
39 files changed
+4217 -345

No files matched your search

+20 -3
View File
@@ -69,7 +69,8 @@ async function call<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Prom
}
export interface Bootstrap {
settings: EventSettings
/** settings 是 admin-only;访客/录入员读不到时为 null,其余赛事数据照常返回。 */
settings: EventSettings | null
teams: Team[]
sports: Sport[]
events: Event[]
@@ -78,11 +79,17 @@ export interface Bootstrap {
athleteStandings: AthleteStanding[]
}
/** 除 settings 外都视为必需:失败时交给 call 决定降级到示例数据还是抛出业务错误。 */
function required<T>(result: PromiseSettledResult<T>): T {
if (result.status === 'fulfilled') return result.value
throw result.reason
}
async function bootstrap(): Promise<Bootstrap> {
return call(
async () => {
const [settings, teams, sports, events, profiles, standings, athleteStandings] =
await Promise.all([
await Promise.allSettled([
client.settings.get(),
client.teams.list(),
client.sports.list(),
@@ -91,7 +98,17 @@ async function bootstrap(): Promise<Bootstrap> {
client.standings.teams(),
client.standings.athletes(),
])
return { settings, teams, sports, events, profiles, standings, athleteStandings }
// GET /api/settings 是 admin-only,guest/recorder 必然 403。
// 这是权限边界而非服务不可用,不能让整页数据跟着失败,所以 settings 单独降级为 null。
return {
settings: settings.status === 'fulfilled' ? settings.value : null,
teams: required(teams),
sports: required(sports),
events: required(events),
profiles: required(profiles),
standings: required(standings),
athleteStandings: required(athleteStandings),
}
},
() => mockDb.bootstrap(),
)
+36 -23
View File
@@ -8,48 +8,51 @@ import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
/** 大屏页面表里的规范值;写错会被大屏静默丢弃,所以用字面量联合锁死。 */
type DisplayPage = 'standings' | 'team-medals' | 'athlete-standings' | 'event-results'
interface ScreenNode {
key: string
name: string
/** null = 未知;后端没有实例上报接口,占位节点恒为未知。 */
online: boolean | null
lastSeen: string | null
/** 本机最后一次下发指令的时间,不是大屏心跳。 */
lastCommandAt: string | null
}
const PAGES: Array<{ key: string; label: string }> = [
const PAGES: Array<{ key: DisplayPage; label: string }> = [
{ key: 'standings', label: '总积分榜' },
{ key: 'medals', label: '团体奖牌榜' },
{ key: 'athletes', label: '个人积分榜' },
{ key: 'latest', label: '最新成绩' },
{ key: 'team-medals', label: '团体奖牌榜' },
{ key: 'athlete-standings', label: '个人积分榜' },
{ key: 'event-results', label: '最新成绩' },
]
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const currentPage = ref('standings')
const currentPage = ref<DisplayPage>('standings')
const locked = ref(false)
const announceText = ref('')
const announceSeconds = ref(15)
const pending = ref<string | null>(null)
/**
* 在线看板。
* 在线看板(占位)。
*
* devdocs/05 §7 未定义「大屏在线列表」接口,管理端拿不到每块屏的心跳,
* 因此这里只登记本地已知的屏,并用「最近一次命令是否送达」推断在线状态。
* 后端补上接口后,把 screens 换成接口数据即可。
* 所以这里只登记本地已知的屏,在线状态恒为「未知」——指令送达不等于大屏在线。
* 后端补上实例上报接口后,把 screens 换成接口数据、hasInstanceData 才可能为真。
*/
const screens = ref<ScreenNode[]>([{ key: 'main', name: '主屏(本地登记)', online: null, lastSeen: null }])
const screens = ref<ScreenNode[]>([{ key: 'main', name: '主屏(本地登记)', online: null, lastCommandAt: null }])
const canWrite = computed(() => !auth.readOnly && data.canWrite)
/** 只有真实实例上报(online 非 null)才算有在线数据,占位节点一律不谎报在线。 */
const hasInstanceData = computed(() => screens.value.some((screen) => screen.online !== null))
const onlineCount = computed(() => screens.value.filter((screen) => screen.online === true).length)
function markSeen(): void {
screens.value = screens.value.map((screen) => ({
...screen,
online: true,
lastSeen: new Date().toISOString(),
}))
function recordDispatch(): void {
screens.value = screens.value.map((screen) => ({ ...screen, lastCommandAt: new Date().toISOString() }))
}
async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'): Promise<void> {
@@ -57,9 +60,19 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
toast.error('当前不可下发指令', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (data.isMock) {
// 示例数据模式下指令不会真正下发,不能提示「已切换」。
toast.warning('示例数据模式未真正下发', '后端未就绪,指令不会到达大屏')
return
}
pending.value = action
try {
if (action === 'goto') {
// 页面值不在大屏别名表内会被静默丢弃,这里先拦下并明确报错,避免谎报成功。
if (!PAGES.some((page) => page.key === currentPage.value)) {
toast.error('大屏不认识该页面', `页面值「${currentPage.value}」不在大屏页面表内`)
return
}
await adminApi.displayCommand({ action, payload: { page: currentPage.value } })
toast.success(`已切换大屏到「${PAGES.find((page) => page.key === currentPage.value)?.label ?? currentPage.value}」`)
} else if (action === 'lock' || action === 'unlock') {
@@ -79,7 +92,7 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
announceText.value = ''
toast.success('公告已推送')
}
markSeen()
recordDispatch()
} catch (caught) {
toast.error('指令下发失败', caught instanceof Error ? caught.message : null)
} finally {
@@ -195,14 +208,14 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">在线看板</span>
<span class="mx-badge" :class="onlineCount > 0 ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ onlineCount }} / {{ screens.length }} 在线
<span class="mx-badge" :class="hasInstanceData ? (onlineCount > 0 ? 'mx-badge-success' : 'mx-badge-error') : 'mx-badge-neutral'">
{{ hasInstanceData ? `${onlineCount} / ${screens.length} 在线` : '在线状态未知(占位)' }}
</span>
</div>
<div class="mx-card-body">
<div class="board-note">
<MxIcon name="warning" :size="14" />
<span>后端未提供大屏在线接口(devdocs/05 §7 未定义),下表为本地登记节点,在线状态按最近一次指令送达推断。</span>
<span>后端未提供大屏实例上报接口(devdocs/05 §7 未定义),下表是本地占位节点:「在线」无法判定,也不按指令送达推断;「最近下发」只是本机下发时间,不代表大屏在线。</span>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
@@ -210,7 +223,7 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
<tr>
<th>大屏</th>
<th>状态</th>
<th>最后心跳</th>
<th>最近下发</th>
</tr>
</thead>
<tbody>
@@ -224,14 +237,14 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
{{ screen.online === true ? '在线' : screen.online === false ? '掉线' : '未知' }}
</span>
</td>
<td class="mono muted">{{ screen.lastSeen === null ? '—' : formatDateTime(screen.lastSeen) }}</td>
<td class="mono muted">{{ screen.lastCommandAt === null ? '—' : formatDateTime(screen.lastCommandAt) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="board-hint">
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 页面 {{ currentPage }}</p>
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 页面 {{ PAGES.find((page) => page.key === currentPage)?.label ?? currentPage }}</p>
<p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p>
</div>
</div>
+30 -4
View File
@@ -35,7 +35,6 @@ interface EditableRow {
isTie: boolean
note: string
resultState: ResultState | null
serverPoints: number | null
original: string
}
@@ -134,11 +133,18 @@ const score = computed(() => {
})
})
/**
* 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。
*
* 不能用 result.points:服务端从不返回该字段(恒为 0),否则已发布分会被重复计算。
* 这里改用与预览同一套算法算出的 rowPoints,口径与服务端计分一致。
*/
const publishedContribution = computed(() => {
const map = new Map<number, number>()
const rowPoints = score.value?.rowPoints
for (const row of visibleRows.value) {
if (row.resultState !== 'published') continue
const points = row.serverPoints ?? 0
const points = rowPoints?.get(row.entryId) ?? 0
map.set(row.teamId, (map.get(row.teamId) ?? 0) + (isIndividual.value ? points * weight.value : points))
}
return map
@@ -250,7 +256,6 @@ function toRow(entry: EntryWithResult): EditableRow {
isTie: result?.isTie ?? false,
note: result?.note ?? '',
resultState: result?.state ?? null,
serverPoints: result?.points ?? null,
original: '',
}
row.original = snapshotOf(entry)
@@ -347,6 +352,11 @@ async function saveDraft(silent = false): Promise<boolean> {
function requestPublish(): void {
if (selectedEventId.value === null) return
if (data.isMock) {
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
return
}
if (!canWrite.value) {
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
return
@@ -515,7 +525,7 @@ watch(
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || publishing"
:disabled="!canWrite || publishing || data.isMock"
@click="requestPublish"
>
<MxIcon name="push" :size="15" /> 发布
@@ -523,6 +533,11 @@ watch(
</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>
@@ -778,6 +793,17 @@ watch(
</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);
+14 -3
View File
@@ -31,7 +31,8 @@ interface SuspectedBreak {
resultValue: number
level: RecordLevel
recordText: string
bonusPoints: number
/** null = 规则包读不到,分值未知,以服务端为准。 */
bonusPoints: number | null
}
interface RecordGroup {
@@ -75,8 +76,18 @@ const rejectedKeys = ref<string[]>([])
const approvedKeys = ref<string[]>([])
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const recordBonusEnabled = computed(() => rulePack.value?.recordBonus.enabled ?? false)
const bonusMode = computed(() => (rulePack.value?.recordBonus.mode === 'sum' ? '多档累加' : '取最高档'))
/**
* 破纪录加分开关:true / false / null(未知)。
*
* 规则包只有 admin 能读(GET /api/rules/export 是 admin-only),
* 录入员必然 403——不能据此断言「已关闭」,那会把已启用的功能谎报成关闭。
* 读不到时为 null,界面如实显示「未知」,最终由服务端校验决定是否加分。
* 后端补一个 recorder 可读的只读开关端点后,把 loadRules 换成读该端点即可。
*/
const recordBonusEnabled = computed<boolean | null>(() => rulePack.value?.recordBonus.enabled ?? null)
const bonusMode = computed(() =>
rulePack.value === null ? '—' : rulePack.value.recordBonus.mode === 'sum' ? '多档累加' : '取最高档',
)
const filteredRecords = computed(() => {
const keyword = search.value.trim()
+24 -2
View File
@@ -163,10 +163,11 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
const individual = event.eventType === 'individual'
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
// result.points 服务端从不返回(恒为 0),改用与预览同一套算法算出的 rowPoints。
const published = new Map<number, number>()
for (const entry of scoped) {
if (entry.result?.state !== 'published') continue
const points = (entry.result.points ?? 0) * (individual ? weight.value : 1)
const points = round(score.rowPoints.get(entry.id) ?? 0) * (individual ? weight.value : 1)
published.set(entry.teamId, (published.get(entry.teamId) ?? 0) + points)
}
@@ -267,6 +268,11 @@ function isReturned(group: DraftGroup): boolean {
}
function requestPublish(group: DraftGroup): void {
if (data.isMock) {
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
return
}
if (!canWrite.value) {
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
return
@@ -337,6 +343,11 @@ watch(
</template>
</PageHeader>
<div v-if="data.isMock" class="mock-note">
<MxIcon name="warning" :size="15" />
<span>{{ data.mockLabel }},示例数据模式下「发布整组」已停用,草稿不会写入真实赛事。</span>
</div>
<div v-if="error !== null" class="error-banner">
<MxIcon name="warning" :size="15" />
<span>{{ error }}</span>
@@ -427,7 +438,7 @@ watch(
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || publishing"
:disabled="!canWrite || publishing || data.isMock"
@click="requestPublish(selectedGroup)"
>
<MxIcon name="push" :size="14" /> 发布整组
@@ -717,6 +728,17 @@ tr.selected {
font-size: 13px;
margin-top: 14px;
}
.mock-note {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: rgba(251, 191, 36, 0.1);
color: var(--mx-amber);
font-size: 13px;
margin-top: 14px;
}
@media (max-width: 1279px) {
.review-grid {
grid-template-columns: 1fr;
+7 -2
View File
@@ -16,8 +16,13 @@ export const useAuthStore = defineStore('admin-auth', () => {
/** 会话只在应用启动时探测一次;否则登录后布局重新挂载会把刚登录的用户清掉。 */
const initialized = ref(false)
const isGuest = computed(() => user.value === null)
const readOnly = computed(() => user.value === null)
/**
* 未登录时 /auth/me 返回 200 + guest DTO 而不是 401,
* 所以「访客」必须按角色判定:只看 user === null 会把访客当成已登录,
* 只读横幅与 useBaseData.canWrite 的写保护都会失效。
*/
const isGuest = computed(() => user.value === null || user.value.role === 'guest')
const readOnly = computed(() => isGuest.value)
const displayName = computed(() => user.value?.displayName ?? '访客')
const roleLabel = computed(() => (isGuest.value ? '只读访客' : (user.value?.role ?? '用户')))