Files
xiaotutongxue/frontend-pc/src/views/log/index.vue
T
lovife 0bbac7d228 feat(pc,platform): 验收走查修复(平台按学校代管 + 菜单/403/日志范围/分页)
客户在验收里点出来的问题,一次改完。两段改动:

一、平台运营与租户范围(后端为主)
- 新增请求头 X-Tenant-Id:平台运营是唯一跨租户身份,租户拦截器对它整体关掉了过滤,
  于是「组织架构 / 账号管理 / 工作台」拿到的是所有租户混在一起的数据 —— 而只有一所
  学校有数据时,它和学校管理员看到的完全一样,看不出错(这是最危险的一点)。
  现在这些页面必须先选定学校,服务端再把本次请求的租户上下文换成目标租户:
  只认平台身份(其它身份带头一律忽略)、目标租户必须存在且启用。
- OrgService.tree / UserService.list 补显式 tenant_id 条件(原来只靠拦截器,而拦截器对
  平台不生效);TaskService.seesWholeTenant 改用新的 LoginUser.isPlatformStaff() ——
  isPlatformAdmin 回答"当前租户是不是平台租户",代管时它为 false,会让平台退化成普通
  用户(任务列表掉进"班主任只看自己带的班"分支,得到空列表);PLATFORM_ADMIN 补
  TASK_VIEW(PRD §3.2「查看填报进度看板:平台运营 ✅」)。
- 操作日志对平台永远是空的:SQL 写死 tenant_id = 当前租户,而平台的 tenantId 是 0。
  按契约 §13.4 补 tenantId 参数与 log:viewAll:平台默认看全量、可指定某所学校;
  学校管理员恒为本租户,传别家返回 40301(不静默忽略,否则会让人以为"筛过了")。
- 开放接口"推送重试"从未生效(F-112):定时扫描缺 @InterceptorIgnore,异常被 log.warn
  吞掉;同一方法里"接入方已停用"那条 update 也没有租户上下文,会每 10 秒重试到永远。
  E2E 补了"真的重试了"的断言(原来只断言"安排了下次重试")。

二、PC 端走查体验
- 去掉 3 条无用菜单(名单管理 / 表单设计器 / 材料规则):功能都在「收集任务」行内入口,
  菜单里那一条点进去只有指路;后端菜单键同步删除,entryAt 那套机制一并删掉。
- 班主任点工作台必弹 403:工作台无条件调了要 org:view / progress:viewAll 的两个接口。
  403 走 HTTP 状态码,try/catch 拦不住它的弹窗 —— 改成按权限决定发不发请求。
- 操作日志:分页器被裁在可视区外(新增 .g-list-card 让表体自己滚、表头钉住、分页器留在
  卡片底部)+ 去掉页面上那段免责声明;收集任务的状态切换改成单独一行 tab;去掉顶栏假数据
  铃铛;配色按客户要求还原。

顺带修两处"检查器自己不严":check-wiring 新增第 ⑦ 项(PC 端引用的权限码必须存在于
PermissionDef —— 本轮我自己把 progress:viewAll 写成 progress:view:all,判断恒为 false,
而编译 / typecheck / lint / E2E 全都不红);verify-docs 的表格列数检查不再把 GFM 转义
竖线当成列分隔符。两个检查器都拿"故意写错的样例"试过,确认真的会红。

验证:typecheck / mvn package / check-wiring(未接线声明 0 条)/ verify-docs(22 项)全过;
e2e-m0 36、e2e-m1-orgs 48、e2e-m0-user 55、e2e-m5-logs 44、e2e-m5-notify-wiring 43、
e2e-m1-tenant-domain 6、e2e-m8-open 61 全绿;浏览器按角色逐个真点复验。
详见 docs/交接摘要.md §一·三十三 与 §一·三十四。
2026-10-09 17:29:20 +08:00

345 lines
12 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.
<template>
<GiPageLayout>
<a-card :bordered="false" class="g-list-card">
<template #title>操作日志</template>
<template #extra>
<span class="log__hint">
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
</span>
</template>
<div class="g-row-tool">
<a-space wrap>
<a-input-number v-model="query.targetId" placeholder="对象 ID" size="small"
class="log__num" :min="1" hide-button @change="search" />
<a-select v-model="query.targetType" placeholder="对象类型" allow-clear size="small"
class="log__sel" @change="search">
<a-option value="ROSTER">名册项</a-option>
<a-option value="TASK">任务</a-option>
<a-option value="SUBMISSION">填报单</a-option>
<a-option value="EXPORT">导出</a-option>
<a-option value="USER">账号</a-option>
</a-select>
<a-select v-model="query.action" placeholder="动作" allow-clear size="small"
class="log__sel2" @change="search">
<a-option v-for="a in OPERATION_ACTIONS" :key="a.value" :value="a.value">
{{ a.label }}
</a-option>
</a-select>
<a-select v-model="query.module" placeholder="模块" allow-clear size="small"
class="log__sel3" @change="search">
<a-option value="ROSTER">名册</a-option>
<a-option value="TASK">任务</a-option>
<a-option value="AUDIT">审核</a-option>
<a-option value="EXPORT">导出</a-option>
<a-option value="JOIN">加入申请</a-option>
</a-select>
<!-- 起止日期用两个独立日期框,**不用 range 模式**:
Arco 的 range 模式实际值是数组、但类型声明只写了单值,
改哪边都要么 as any(掩盖问题)、要么被类型挡住。
而且两个框各自能单独清空,核查时更好用 -->
<a-date-picker v-model="fromDate" size="small" mode="date"
placeholder="开始日期" class="log__date" @change="search" />
<a-date-picker v-model="toDate" size="small" mode="date"
placeholder="结束日期" class="log__date" @change="search" />
<a-button size="small" @click="reset">重置</a-button>
<a-button size="small" @click="load">
<template #icon><IconRefresh /></template>
刷新
</a-button>
</a-space>
</div>
<!--
`y: '100%'` 不是装饰:它让 Arco 把表头与表体**拆开**
(内部 `splitTable`),表体自己滚、表头钉住、分页器留在卡片底部。
没有它的话,`.arco-table-content` 的 `overflow-y` 是 hidden(横向滚动条专用),
多出来的行会被**直接裁掉**,而且没有任何滚动条能救回来。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: isPlatform ? 1340 : 1200, y: '100%' }" @page-change="onPageChange"
@page-size-change="onPageSizeChange">
<template #columns>
<!--
「租户」列**只给平台运营看**:它看的是全量日志(跨租户),
不显示单位就分不清"这条是哪个学校做的";学校管理员永远是本租户,列没有意义。
-->
<a-table-column v-if="isPlatform" title="租户" :width="140">
<template #cell="{ record }">
<span class="log__muted">
{{ tenantNames[record.tenantId] ?? (record.tenantId != null ? `#${record.tenantId}` : '—') }}
</span>
</template>
</a-table-column>
<a-table-column title="时间" :width="150">
<template #cell="{ record }">
<span class="log__muted">{{ fmt(record.createTime) }}</span>
</template>
</a-table-column>
<a-table-column title="操作人" :width="120">
<template #cell="{ record }">
<!--
⚠️ 只显示**账号/姓名**,不显示用户 ID(2026-10-09 客户要求)。
数字 ID 对看日志的人没有意义 —— 他要知道的是"哪个账号做的",
ID 只在排查时要,而那种时候查库比看这一列快。
-->
<span>{{ record.operatorName || '—' }}</span>
</template>
</a-table-column>
<a-table-column title="动作" :width="150">
<template #cell="{ record }">
<a-tag size="small" :color="isSensitive(record.action) ? 'red' : 'arcoblue'">
{{ actionText(record.action) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="对象" :width="130">
<template #cell="{ record }">
<span v-if="record.targetType" class="log__muted">
{{ record.targetType }} #{{ record.targetId }}
</span>
<span v-else class="log__muted">—</span>
</template>
</a-table-column>
<a-table-column title="变更前 → 变更后" :width="280">
<template #cell="{ record }">
<div v-if="record.beforeVal || record.afterVal" class="log__diff">
<span class="log__before">{{ record.beforeVal || '(空)' }}</span>
<span class="log__arrow">→</span>
<span class="log__after">{{ record.afterVal || '(空)' }}</span>
</div>
<span v-else class="log__muted">—</span>
</template>
</a-table-column>
<a-table-column title="来源" :width="200">
<template #cell="{ record }">
<div class="log__muted">{{ record.ip || '—' }}</div>
<div class="log__ua" :title="record.userAgent || ''">{{ record.userAgent || '' }}</div>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
</GiPageLayout>
</template>
<script setup lang="ts">
import type { OperationLog } from '@/apis/xt/log'
import { IconRefresh } from '@arco-design/web-vue/es/icon'
import { computed, reactive, ref } from 'vue'
import {
actionText,
getOperationLogs,
OPERATION_ACTIONS,
SENSITIVE_ACTIONS
} from '@/apis/xt/log'
import { getTenantList } from '@/apis/xt/tenant'
import { useUserStore } from '@/stores'
/**
* 操作日志(《03-接口设计》§13.4)。
*
* ## 为什么把「对象 ID」放在筛选栏最左边
*
* 因为这是这一屏**唯一不可替代**的查询方式。合规问题问的是
* 「这张名册项的证件号被谁从什么改成了什么」,答案只能靠
* `target_type + target_id` 定位;而"搜关键字"会把
* 「改了它」和「顺带提到它」混在一起 —— 那种匹配撑不起合规结论。
*
* ## 「变更前 → 变更后」单独一列
*
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击,
* 而合规核查往往是几十条连着看的。
*
* ## 范围:平台运营看**全量**,学校管理员看**本租户**
*
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定(`LogController.logScopeTenantId`)。
* ⚠️ 平台运营看到的每条日志属于哪个单位,靠这一列「租户」——它**只对平台显示**。
* 以前平台的 tenantId 是 0 而日志挂在各租户下,于是这一屏对平台**永远是空的**,
* 且不报任何错(PRD 要求的却是「全量操作日志 ✅」)。
*/
defineOptions({ name: 'OperationLogs' })
const userStore = useUserStore()
/** 平台运营:看全量日志,因此要显示「租户」列并把 id 翻成单位名 */
const isPlatform = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const tenantNames = ref<Record<number, string>>({})
const loading = ref(false)
const list = ref<OperationLog[]>([])
const total = ref(0)
const fromDate = ref<string | undefined>(undefined)
const toDate = ref<string | undefined>(undefined)
const query = reactive<{
targetId?: number
targetType?: string
action?: string
module?: string
from?: string
to?: string
pageNum: number
pageSize: number
}>({
targetId: undefined,
targetType: undefined,
action: undefined,
module: undefined,
from: undefined,
to: undefined,
pageNum: 1,
pageSize: 20
})
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 19) : '—'
}
function isSensitive(action?: string) {
return !!action && SENSITIVE_ACTIONS.includes(action)
}
async function load() {
loading.value = true
try {
const res = await getOperationLogs({
targetId: query.targetId,
targetType: query.targetType,
action: query.action,
module: query.module,
// 后端 `to` 会补到当天 23:59:59,所以这里只给日期就够
from: fromDate.value || undefined,
to: toDate.value || undefined,
pageNum: query.pageNum,
pageSize: query.pageSize
})
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
loading.value = false
}
}
function search() {
query.pageNum = 1
load()
}
function reset() {
query.targetId = undefined
query.targetType = undefined
query.action = undefined
query.module = undefined
fromDate.value = undefined
toDate.value = undefined
search()
}
function onPageChange(page: number) {
query.pageNum = page
load()
}
/**
* 改每页条数。
*
* ⚠️ **必须处理这个事件**,不能只处理 `page-change`:
* Arco 的表格在 `pagination` 非 false 时会**自己再切一次数据**
* (`flattenData`:`data.length > pageSize` 就 slice)。
* 只在本地改每页条数、不重新取数的话,会出现
* 「分页器显示 50 条/页、表里还是上一次那 20 条」这种自相矛盾的画面 ——
* 看起来就是"分页器坏了"。所以这里一律回到第 1 页重新取。
*/
function onPageSizeChange(size: number) {
query.pageSize = size
query.pageNum = 1
load()
}
// 平台运营才需要"租户 id → 单位名"。取不到也不影响看日志:列里退回显示 #id
onMounted(async () => {
if (!isPlatform.value) return
try {
const res = await getTenantList({ pageNum: 1, pageSize: 200 })
tenantNames.value = Object.fromEntries((res.data?.list ?? []).map((t) => [t.id, t.tenantName]))
} catch {
tenantNames.value = {}
}
})
load()
</script>
<style lang="scss" scoped>
/* 卡片/表格的高度链路(分页器为什么必须钉在底部)见 `styles/global.scss` 的 `.g-list-card` */
.log {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__num {
width: 110px;
}
&__sel {
width: 120px;
}
&__sel2 {
width: 150px;
}
&__sel3 {
width: 100px;
}
&__date {
width: 240px;
}
&__muted {
font-size: 12px;
color: var(--color-text-3);
}
&__diff {
font-size: 13px;
line-height: 1.5;
word-break: break-all;
}
&__before {
color: var(--color-text-3);
text-decoration: line-through;
}
&__arrow {
margin: 0 6px;
color: var(--color-text-3);
}
&__after {
color: rgb(var(--red-6));
font-weight: 500;
}
&__ua {
overflow: hidden;
font-size: 12px;
color: var(--color-text-3);
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>