Files
Scoreboard/web/src/admin/pages/LogsPage.vue
T
AskaEth 13f1f03cd0 fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
2026-09-09 22:16:58 +08:00

320 lines
10 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, onMounted, ref } from 'vue'
import { formatDateTime, type AuditLog } from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import MockBadge from '../components/system/MockBadge.vue'
import JsonDiff from '../components/system/JsonDiff.vue'
import { describeError } from '../components/system/systemApi'
import { adminApi } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
/**
* 审计日志(devdocs/08 第 3 节)。
*
* 审计日志只读、不可删;所有写操作都记录操作人、时间、IP、request_id 与 before/after。
* 服务端的 /api/audit-logs 只支持 entityType / userId 过滤,没有 action 参数,
* 所以这里一次取最近 200 条再在前端按动作与实体筛选——宁可把窗口写清楚,也不假装能全库筛。
*/
const PAGE_SIZE = 20
const WINDOW_SIZE = 200
/** 08 第 3 节点名的关键动作,单独标出来便于复盘。 */
const CRITICAL_ACTIONS = ['publish', 'unpublish', 'void', 'adjustment', 'user']
const auth = useAuthStore()
const toast = useToastStore()
const rows = ref<AuditLog[]>([])
const loading = ref(false)
const loadError = ref<string | null>(null)
const usingMock = ref(false)
const page = ref(1)
const actionFilter = ref('')
const entityFilter = ref('')
const expandedId = ref<number | null>(null)
const actionOptions = computed(() => {
const set = new Set(rows.value.map((row) => row.action))
return [...set].sort((a, b) => a.localeCompare(b))
})
const entityOptions = computed(() => {
const set = new Set(rows.value.map((row) => row.entityType))
return [...set].sort((a, b) => a.localeCompare(b))
})
const filtered = computed(() =>
rows.value.filter(
(row) =>
(actionFilter.value === '' || row.action === actionFilter.value) &&
(entityFilter.value === '' || row.entityType === entityFilter.value),
),
)
const pageCount = computed(() => Math.max(1, Math.ceil(filtered.value.length / PAGE_SIZE)))
const pageRows = computed(() => {
const start = (page.value - 1) * PAGE_SIZE
return filtered.value.slice(start, start + PAGE_SIZE)
})
const operatorLabel = (row: AuditLog): string => {
if (row.userId === null) return '系统'
if (auth.user !== null && auth.user.id === row.userId) return `${auth.user.displayName}(我)`
return `用户 #${row.userId}`
}
const isCritical = (action: string): boolean =>
CRITICAL_ACTIONS.some((keyword) => action.toLowerCase().includes(keyword))
function resetFilters(): void {
actionFilter.value = ''
entityFilter.value = ''
page.value = 1
}
function onFilterChange(): void {
// 筛选变化后停留在旧页码会看到空白页,直接回到第一页。
page.value = 1
}
const isAdmin = computed(() => auth.user?.role === 'admin')
async function load(): Promise<void> {
// /api/audit-logs 是 admin-only;非 admin 直接不请求,避免常驻 403 报错。
if (!isAdmin.value) return
loading.value = true
loadError.value = null
try {
// 用局部 mock 标记判断本批数据是不是示例,不读会被并发请求清掉的全局状态。
const result = await adminApi.auditLogsWithMock({ page: 1, size: WINDOW_SIZE })
rows.value = result.data.items
usingMock.value = result.mock
if (page.value > pageCount.value) page.value = 1
} catch (caught) {
loadError.value = describeError(caught)
toast.error('加载审计日志失败', describeError(caught))
} finally {
loading.value = false
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div>
<PageHeader title="日志" subtitle="审计日志:谁在什么时候改了什么(只读,不可删除)">
<template #actions>
<MockBadge v-if="usingMock" />
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
</template>
</PageHeader>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">审计日志</span>
<span class="mx-badge mx-badge-neutral">{{ filtered.length }} 条</span>
</div>
<div class="mx-card-body">
<div class="filter-row">
<div class="filter">
<label class="mx-label" for="log-action">动作</label>
<div class="mx-input-wrap">
<select id="log-action" v-model="actionFilter" class="mx-input" @change="onFilterChange">
<option value="">全部动作</option>
<option v-for="action in actionOptions" :key="action" :value="action">{{ action }}</option>
</select>
</div>
</div>
<div class="filter">
<label class="mx-label" for="log-entity">实体</label>
<div class="mx-input-wrap">
<select id="log-entity" v-model="entityFilter" class="mx-input" @change="onFilterChange">
<option value="">全部实体</option>
<option v-for="entity in entityOptions" :key="entity" :value="entity">{{ entity }}</option>
</select>
</div>
</div>
<button class="mx-btn mx-btn-ghost clear" type="button" @click="resetFilters">清除筛选</button>
</div>
<p class="hint">
服务端 <code class="mono">/api/audit-logs</code> 只支持按实体与操作人过滤,暂不支持 action 参数,
因此筛选与分页作用于最近 {{ WINDOW_SIZE }} 条记录。
</p>
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
<div v-if="loading" class="state-block"><span class="mx-activity-dot"></span> 正在加载审计日志…</div>
<div v-else-if="rows.length === 0" class="mx-empty">
<div class="mx-empty-title">还没有审计记录</div>
<div class="mx-empty-desc">发布成绩、修改设置、管理用户等写操作都会留痕</div>
</div>
<div v-else-if="filtered.length === 0" class="mx-empty">
<div class="mx-empty-title">没有匹配的记录</div>
<div class="mx-empty-desc">换个动作或实体试试,或点「清除筛选」</div>
</div>
<template v-else>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>时间</th>
<th>操作人</th>
<th>IP</th>
<th>动作</th>
<th>实体</th>
<th>请求 ID</th>
<th class="right">详情</th>
</tr>
</thead>
<tbody>
<template v-for="row in pageRows" :key="row.id">
<tr
class="log-row"
:class="{ expanded: expandedId === row.id }"
@click="expandedId = expandedId === row.id ? null : row.id"
>
<td class="mono muted">{{ formatDateTime(row.createdAt) }}</td>
<td>{{ operatorLabel(row) }}</td>
<td class="mono muted">{{ row.ip ?? '—' }}</td>
<td>
<span class="action mono">{{ row.action }}</span>
<span v-if="isCritical(row.action)" class="mx-tag mx-tag-warning mx-tag-sm">关键</span>
</td>
<td class="mono">
{{ row.entityType }}<span v-if="row.entityId !== null"> #{{ row.entityId }}</span>
</td>
<td class="mono muted req">{{ row.requestId ?? '—' }}</td>
<td class="right">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button">
{{ expandedId === row.id ? '收起' : '对比' }}
</button>
</td>
</tr>
<tr v-if="expandedId === row.id" class="detail-row">
<td colspan="7">
<JsonDiff :before="row.beforeJson" :after="row.afterJson" />
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="pager">
<span class="mx-page-info">第 {{ page }} / {{ pageCount }} 页</span>
<button
class="mx-page-btn"
type="button"
:disabled="page <= 1"
@click="page -= 1"
>
上一页
</button>
<button
class="mx-page-btn"
type="button"
:disabled="page >= pageCount"
@click="page += 1"
>
下一页
</button>
</div>
</template>
</div>
</section>
</div>
</template>
<style scoped>
.mx-card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.filter-row {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.filter {
display: flex;
flex-direction: column;
min-width: 200px;
}
.clear {
margin-bottom: 2px;
}
.hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
}
.notice {
font-size: 12px;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
color: var(--mx-t3);
}
.notice.error {
color: var(--mx-error);
background: rgba(248, 113, 113, 0.1);
}
.state-block {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--mx-t3);
padding: 18px 0;
}
.log-row {
cursor: pointer;
}
.log-row.expanded {
background: var(--mx-primary-glow);
}
.right {
text-align: right;
}
.muted {
color: var(--mx-t3);
font-size: 12px;
}
.req {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action {
font-size: 12px;
}
.detail-row td {
padding: 0 12px 12px;
}
.pager {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
font-size: 12px;
color: var(--mx-t3);
}
</style>