0bbac7d228
客户在验收里点出来的问题,一次改完。两段改动:
一、平台运营与租户范围(后端为主)
- 新增请求头 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 §一·三十三 与 §一·三十四。
345 lines
12 KiB
Vue
345 lines
12 KiB
Vue
<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>
|