Files
xiaotutongxue/frontend-pc/src/views/log/LogDetailDrawer.vue
T
lovife 624a05aa66 feat(pc): 平台运营的租户管理(详情/用量/编辑/停用)与日志详情
需求:「设计开发平台运营人员对租户的管理功能,还有日志的详情查看功能。」

⚠️ **口径变更**(覆盖 2026-10 走查的旧结论):views/tenant/index.vue 原先写着「列表**没有操作列**:
不提供编辑 / 停用入口(客户 2026-10 走查确认的口径)」。本轮用户明确选了全量范围,所以列表现在
有操作列(详情 / 编辑 / 停用·启用)。旧注释是**改写而不是删掉**,并写明推翻理由 —— 运营的真实
诉求是:① 到期日变了要能自己改,而不是每次找开发;② 欠费 / 停止合作时要能当场停掉;
③ 每周核对"谁快到期了"时要能点进详情看用量。

1) 租户管理:操作列用 tenant:view 门控「详情」、tenant:manage 门控「编辑 / 停用 / 启用」,
   拿不到管理权限时整列显示"只读",而不是留一个点了报 403 的按钮。停用走 NPopconfirm 二次确认
   且文案写明后果("停用后该校所有学生与老师会立刻无法登录");只发 {status} 一个字段 ——
   PUT 是部分更新,回传整行会把列表里看不见的字段覆盖成空。

2) 详情抽屉 TenantDetailDrawer:打开时**同时**调 getTenantDetail 与 getTenantUsage ——
   列表接口的 contactPhone **恒为 null**(电话以密文入库,列表不能成为批量拉取联系方式的通道),
   脱敏值只有详情接口给,拿行数据凑详情会显示成"没有联系电话"。用量区写明"学生数与存储是**实时值**,
   周期只影响计费项",否则会以为"2026Q3 的学生数"是历史值;notes 如实全列(它解释"为什么这几项是 0")。
   统计周期只给本月 / 上月 / 本季三个固定选项,不做自由文本输入。

3) 编辑弹窗 TenantEditModal:类型 4 选项、状态 3 选项;数字下拉的校验规则必须带 type: 'number'
   (只写 required 会"看起来填了但校验不过",而且 #feedback 会把报错遮住);联系电话留空 = 不修改;
   租户编码只读展示并注明"出现在扫码落地页与对接文档里,开通后不可修改"。不做"清空到期日"
   (MyBatis-Plus 忽略 null 更新,清不掉就不在界面上承诺)。

4) 日志页:操作日志 / 登录日志两个模式,各自维护 list / total / pageNum / pageSize / loading /
   筛选条件,切换时重置到第 1 页。⚠️ 页签用工具条上的**分段按钮**而不是 NTabs / NTabPane:
   ① NTabPane 的内容要额外 flex 化才能维持 .page-card 的高度链,否则表格高度算成 0、
   **一行数据都看不见**(本仓踩过);② naive 2.45.3 的 tab="..." 属性写法本身也是坏的,
   必须用 #tab 插槽。

5) 日志详情 LogDetailDrawer:变更前后**完整不截断** + break-all + 各一个复制按钮;
   extraJson 格式化成 <pre>(解析失败原样展示);traceId 带复制;request_param / duration_ms
   如实写"本版本未采集"(这两列从来没有写入点),不编。抽屉**不再打接口** —— 列表 SELECT 已经
   把抽屉要的字段都带出来了。

6) 「单位」列只对平台显示(0 → 「平台」),两张表统一用「单位」这个词:这一列要装得下
   tenant_id = 0 的「平台」,表头写「租户」而格子里写「平台」是自相矛盾的;且与筛选栏两个下拉的
   占位符、租户管理页的「单位名称」一致。登录日志刻意**不要「归属地」列**(location 恒为 null,
   整列空白看起来像功能坏了)。

7) 日志页的模块下拉换成导出的 MODULE_OPTIONS / TARGET_TYPE_OPTIONS:原先那 4 项里 AUDIT / EXPORT
   **后端一个写入点都没有**,而真会写的 FILE(代传材料)与 ROLE(6 个角色动作)**不在里面** ——
   选中死项只会得到一张空表,让人以为"这段时间没有审核动作"。

8) 菜单显示名「操作日志」→「日志」(key 保持 'log' 不动:后端 BuiltinRole 的菜单键与
   check-wiring 第 ⑥ 项都按它核对,改 key 会让平台角色的菜单静默少一项)。

同步 frontend-pc/AGENTS.md 与 .agents/:「最容易写错的约定」由 7 条扩到 **9 条** —— 新增
第 8 条「详情用抽屉、不要为详情新开路由」(含"列表接口可能刻意不返回某个字段"的坑)与
第 9 条「页面内的页签用工具条分段按钮」(写明高度链的原因)。

验证:pnpm typecheck **0 错**、pnpm lint **0 错 0 警告**、pnpm build 通过。
浏览器实测(平台账号,5173 + 8080):租户页出现操作列且三档到期配色正确、卡片上内边距仍是 16px、
高度链未破(表格 570px / 日志表 528px);详情抽屉显示脱敏电话 138****8000 —— 而库里存的是
**历史明文** 13800138000,也就是说这条同时验了"读路径容错"(否则是永久 500);停用二次确认文案
正确;清空单位名称提交后弹窗不关且错误可见;登录日志同一张表里同时出现「示范职业学校」
(tenant_id=1)与「平台」(tenant_id=0)两种映射;**学校管理员视角两张表都没有「单位」列**。
2026-10-13 14:04:53 +08:00

386 lines
11 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 type { OperationLog } from '@/apis/xt/log'
import {
NButton,
NDescriptions,
NDescriptionsItem,
NDrawer,
NDrawerContent,
NTag,
useMessage
} from 'naive-ui'
import { computed, ref } from 'vue'
import { actionText, MODULE_OPTIONS, SENSITIVE_ACTIONS } from '@/apis/xt/log'
/**
* 操作日志详情。
*
* 输入是**一整行 `OperationLog`**(列表已经把它全查回来了),所以这里**不再打接口** ——
* 详情只是"把列表里被截断/没展示的字段摊开",多点一次网络请求没有任何信息增益。
*
* ## 为什么几处刻意"不截断"
*
* 这一屏是排查用的:
* - `userAgent` 要能整条读完 —— 兼容性问题全在尾巴上(内核版本、各家小程序的 webview 标识),
* 截成省略号等于把最有用的一段藏掉;
* - `beforeVal → afterVal` 要能整段读完 —— 列表里那是单行摘要,这里再截一次就白点进来了。
* 两者统一 `word-break: break-all`。
*
* ## 为什么灰字写「请求参数 / 耗时:本版本未采集」
*
* 库里 `request_param` / `duration_ms` 两列**没有任何写入点**(全库 0 行有值,见 `apis/xt/log.ts` 的说明)。
* 空着会让人以为"这条日志恰好没采到",从而一直等一个永远不会出现的字段 —— 如实说没有,比留白有用。
*/
defineOptions({ name: 'LogDetailDrawer' })
const props = withDefaults(defineProps<{
/**
* 租户 id → 单位名。
*
* 列表页(平台身份)已经查过一次,这里复用同一个映射,**不为一个名字再打一次 `/tenants`**;
* 拿不到时退回 `#id` —— 详情页显示 `#3` 不好看,但比编一个名字安全。
*/
tenantNames?: Record<number, string>
}>(), { tenantNames: () => ({}) })
const message = useMessage()
const visible = ref(false)
const log = ref<OperationLog | null>(null)
/** 动作标签:敏感动作(改证件号 / 看完整证件号 / 删名册项 / 停用学校)在列表与详情里**同一套口径** */
const actTag = computed<{ text: string, type: 'info' | 'error' }>(() => {
const action = log.value?.action
return {
text: actionText(action),
type: action && SENSITIVE_ACTIONS.includes(action) ? 'error' : 'info'
}
})
/**
* 单位(列表里那一列的口径,与筛选下拉同词)。
*
* ⚠️ 平台自己的动作(开通/停用学校这类)写的是 `tenant_id = 0`,**不是 null** ——
* 直接显示会变成莫名其妙的 `#0`。
*/
const tenantText = computed(() => {
const id = log.value?.tenantId
if (id === 0) {
return '平台'
}
if (id == null) {
return '—'
}
return props.tenantNames[id] ?? `#${id}`
})
/** 操作人:详情里**带上 `#id`**(与列表相反)—— 排查时要能对上库里那一行 */
const operatorText = computed(() => {
const row = log.value
if (!row) {
return '—'
}
const id = row.operatorId != null ? `#${row.operatorId}` : ''
return [row.operatorName, id].filter(Boolean).join(' ') || '—'
})
/** 模块:查不到就**原样回显那个码**,不编中文名(否则和后端日志里的码对不上) */
const moduleText = computed(() => {
const m = log.value?.module
if (!m) {
return '—'
}
return MODULE_OPTIONS.find((o) => o.value === m)?.label ?? m
})
const targetText = computed(() => {
const row = log.value
if (!row?.targetType) {
return '—'
}
return `${row.targetType} #${row.targetId ?? '—'}`
})
/**
* `extraJson` 美化。
*
* 解析失败就**原样展示**:日志里最忌讳"这一段我帮你藏起来了" ——
* 后端将来多写一种格式时,原样展示至少还能被人肉读出来。
*/
const extraPretty = computed(() => {
const raw = log.value?.extraJson
if (!raw) {
return ''
}
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
})
/** 时间到**秒**:排查时"12:00"与"12:00:31"是两件事 */
function fmtTime(v?: string): string {
return v ? v.replace('T', ' ').slice(0, 19) : '—'
}
async function copy(text: string | undefined, label: string): Promise<void> {
if (!text) {
message.warning(`${label}为空,没有可复制的内容`)
return
}
try {
await navigator.clipboard.writeText(text)
message.success(`已复制${label}`)
} catch {
message.warning('浏览器不允许自动复制,请手动选中后复制')
}
}
function open(row: OperationLog): void {
log.value = row
visible.value = true
}
defineExpose({ open })
</script>
<template>
<NDrawer v-model:show="visible" :width="760" placement="right">
<NDrawerContent :native-scrollbar="false" closable>
<template #header>
<span>日志详情</span>
<NTag v-if="log" class="logd__head-tag" size="small" :bordered="false" :type="actTag.type">
{{ actTag.text }}
</NTag>
<span v-if="log" class="logd__head-sub">{{ fmtTime(log.createTime) }}</span>
</template>
<template v-if="log">
<div class="section-title logd__sec">
基本信息
</div>
<NDescriptions :column="2" size="small" bordered>
<NDescriptionsItem label="时间">
{{ fmtTime(log.createTime) }}
</NDescriptionsItem>
<NDescriptionsItem label="日志 ID">
{{ log.id }}
</NDescriptionsItem>
<NDescriptionsItem label="单位">
{{ tenantText }}
</NDescriptionsItem>
<NDescriptionsItem label="操作人">
{{ operatorText }}
</NDescriptionsItem>
<NDescriptionsItem label="模块">
{{ moduleText }}
</NDescriptionsItem>
<NDescriptionsItem label="动作">
<NTag size="small" :bordered="false" :type="actTag.type">
{{ actTag.text }}
</NTag>
</NDescriptionsItem>
<NDescriptionsItem label="对象" :span="2">
{{ targetText }}
</NDescriptionsItem>
</NDescriptions>
<div class="section-title logd__sec">
变更前后
<span class="section-title__note">
证件号/手机号已在服务端脱敏;超过 512 字符的值在服务端就被截断了
</span>
</div>
<div class="logd__diff">
<div class="logd__col">
<div class="logd__col-head">
<span class="logd__col-label">变更前</span>
<NButton text size="small" :disabled="!log.beforeVal" @click="copy(log.beforeVal, '变更前')">
复制
</NButton>
</div>
<div class="logd__val" :class="{ 'logd__val--empty': !log.beforeVal }">
{{ log.beforeVal || '—' }}
</div>
</div>
<div class="logd__col">
<div class="logd__col-head">
<span class="logd__col-label">变更后</span>
<NButton text size="small" :disabled="!log.afterVal" @click="copy(log.afterVal, '变更后')">
复制
</NButton>
</div>
<div class="logd__val" :class="{ 'logd__val--empty': !log.afterVal }">
{{ log.afterVal || '—' }}
</div>
</div>
</div>
<div class="section-title logd__sec">
请求
</div>
<div class="logd__req">
<span class="logd__method">{{ log.requestMethod || '—' }}</span>
<span class="logd__uri">{{ log.requestUri || '—' }}</span>
</div>
<!-- 如实说没采集:空着会被当成"这条恰好没采到",然后一直等一个不存在的字段 -->
<div class="logd__note">
请求参数 / 耗时:本版本未采集
</div>
<div class="section-title logd__sec">
来源
</div>
<NDescriptions :column="1" size="small" bordered>
<NDescriptionsItem label="IP">
{{ log.ip || '—' }}
</NDescriptionsItem>
<NDescriptionsItem label="User-Agent">
<!-- 整条展示、不截断:兼容性问题全在 UA 的尾巴上 -->
<span class="logd__ua">{{ log.userAgent || '—' }}</span>
</NDescriptionsItem>
</NDescriptions>
<div class="section-title logd__sec">
排查信息
</div>
<NDescriptions :column="2" size="small" bordered>
<NDescriptionsItem label="traceId">
<span class="logd__code">{{ log.traceId || '—' }}</span>
<NButton v-if="log.traceId" text size="small" class="logd__copy" @click="copy(log.traceId, 'traceId')">
复制
</NButton>
</NDescriptionsItem>
<NDescriptionsItem label="bizCode">
{{ log.bizCode ?? '—' }}
</NDescriptionsItem>
</NDescriptions>
<!-- 没有 extraJson 就整块不显示:一个空标题只会让人以为"内容加载失败" -->
<template v-if="extraPretty">
<div class="section-title logd__sec">
补充上下文
</div>
<pre class="logd__json">{{ extraPretty }}</pre>
</template>
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped>
.logd__head-tag {
margin-left: 8px;
}
.logd__head-sub {
margin-left: 12px;
font-size: 12px;
font-weight: 400;
color: var(--xt-text-3);
}
/* 全局 `.section-title` 只有下间距,抽屉里的分区会与上一块内容贴在一起 */
.logd__sec {
margin-top: 18px;
}
/* 变更前后:两块并排,各占一半(窄屏下会自动变窄,值本身换行不截断) */
.logd__diff {
display: flex;
gap: 12px;
}
.logd__col {
flex: 1;
min-width: 0;
padding: 10px 12px;
background: var(--xt-band-bg);
border: 1px solid var(--xt-card-border-weak);
border-radius: 8px;
}
.logd__col-head {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.logd__col-label {
font-size: 12px;
color: var(--xt-text-2);
}
.logd__val {
font-size: 13px;
line-height: 1.6;
/* 不截断:整段展示,长串靠 break-all 折行 */
word-break: break-all;
white-space: pre-wrap;
}
.logd__val--empty {
color: var(--xt-text-3);
}
.logd__req {
display: flex;
gap: 8px;
align-items: baseline;
}
.logd__method {
font-family: ui-monospace, sfmono-regular, menlo, monospace;
font-size: 12px;
font-weight: 600;
color: var(--xt-primary, #0052d9);
}
.logd__uri {
font-family: ui-monospace, sfmono-regular, menlo, monospace;
font-size: 13px;
word-break: break-all;
}
.logd__note {
margin-top: 6px;
font-size: 12px;
color: var(--xt-text-3);
}
.logd__ua {
font-size: 12px;
line-height: 1.6;
word-break: break-all;
}
.logd__code {
font-family: ui-monospace, sfmono-regular, menlo, monospace;
font-size: 12px;
word-break: break-all;
}
.logd__copy {
margin-left: 8px;
}
/* 补充上下文:等宽 + 可滚动(值可能很长,撑高抽屉不如让它自己滚) */
.logd__json {
max-height: 320px;
padding: 12px;
margin: 0;
overflow: auto;
font-family: ui-monospace, sfmono-regular, menlo, monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
background: var(--xt-band-bg);
border: 1px solid var(--xt-card-border-weak);
border-radius: 8px;
}
</style>