624a05aa66
需求:「设计开发平台运营人员对租户的管理功能,还有日志的详情查看功能。」 ⚠️ **口径变更**(覆盖 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)两种映射;**学校管理员视角两张表都没有「单位」列**。
386 lines
11 KiB
Vue
386 lines
11 KiB
Vue
<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>
|