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)两种映射;**学校管理员视角两张表都没有「单位」列**。
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
├── code-ts/SKILL.md # TS 命名 / 导入 / 显式 import
|
||||
├── code-vue/SKILL.md # SFC 写法与模板约定
|
||||
├── code-css/SKILL.md # SCSS 与样式边界
|
||||
├── project-guide/SKILL.md # 技术栈 / 目录 / 七条最容易写错的约定
|
||||
├── project-guide/SKILL.md # 技术栈 / 目录 / 九条最容易写错的约定
|
||||
├── project-crud/SKILL.md # 列表 + 表单的标准写法
|
||||
├── project-api/SKILL.md # 接口调用(没有 Mock)
|
||||
└── git-commit/SKILL.md # 提交规范(与框架无关)
|
||||
|
||||
@@ -63,6 +63,29 @@ naive-ui 2.45.3 里,`tab="文字"` **属性**写法渲染不出任何文字(
|
||||
原因:属性值走 `normalizeVNode(() => render(mergedTab ?? name))`,而这个 thunk 返回
|
||||
Text VNode,被当成块 `createBlock(vnode)` 处理;插槽那条分支返回数组,所以正常。
|
||||
|
||||
### 页面内的「页签」优先用工具条上的分段按钮
|
||||
|
||||
如果只是在一屏里切换两张表(例:日志页的「操作日志 / 登录日志」),
|
||||
**用两个 `NButton` 做分段控件,不要用 `NTabs`**:
|
||||
|
||||
- `NTabPane` 的内容要额外 flex 化才能维持 `.page-card` 的高度链,
|
||||
否则表格高度算成 0、**一行数据都看不见**(本仓踩过这个坑);
|
||||
- 上面那个 `tab="..."` 的属性坑也在同一条路上,能绕开就绕开。
|
||||
|
||||
两个按钮在视觉上就是页签,而且不动高度链。各页签的 `list` / `total` / `pageNum` /
|
||||
`loading` / 筛选条件要**分开维护**,切换时重置到第 1 页。
|
||||
|
||||
## 详情用抽屉,不要为详情新开路由
|
||||
|
||||
`NDrawer` + `NDrawerContent` + `NDescriptions` / `NDescriptionsItem`,
|
||||
模板抄 `src/views/audit/AuditDetailDrawer.vue`。列表里的入口是**操作列**里的一个
|
||||
`text` 按钮(`size="small"`)。
|
||||
|
||||
⚠️ 抽屉打开时该调哪个接口要看清楚:**列表接口可能刻意不返回某个字段**。
|
||||
例:`/api/v1/tenants` 的 `contactPhone` **恒为 `null`**(联系电话以密文入库,
|
||||
列表不能成为批量拉取联系方式的通道),脱敏值只有 `/api/v1/tenants/{id}` 给 ——
|
||||
拿行数据凑详情,界面上会显示成"没有联系电话",而这种错看起来像数据问题,不像代码问题。
|
||||
|
||||
## 权限与状态色
|
||||
|
||||
```ts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: project-guide
|
||||
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及七条最容易写错的约定。开始任何 PC 端改动前加载本技能。
|
||||
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及九条最容易写错的约定。开始任何 PC 端改动前加载本技能。
|
||||
---
|
||||
|
||||
# 项目总览(PC 管理端)
|
||||
@@ -42,7 +42,7 @@ src/
|
||||
- 平台运营要**先选学校**(`stores/acting-tenant.ts` → 请求头 `X-Tenant-Id`);
|
||||
不选就不发请求,否则会把各租户数据混在一起且看起来正常。
|
||||
|
||||
## 七条最容易写错的约定
|
||||
## 九条最容易写错的约定
|
||||
|
||||
1. **没有 auto-import**:组件与 API 全显式 import,标签写 **PascalCase**。
|
||||
漏 import 是**静默故障**(未知自定义元素 → 样式整块丢掉,typecheck/lint 全绿);
|
||||
@@ -60,6 +60,13 @@ src/
|
||||
**不要换回文字字形标**;页签用 `public/favicon.svg`(加粗版),改一个要同步另一个。
|
||||
⚠️ SVG 注释里**不能出现连续两个减号**(XML 规范禁止)→ 整个文件 not well-formed,
|
||||
图片裂开而控制台一句话都不报;`python scripts/check-wiring.py` 第 ⑨ 项会核对。
|
||||
8. **详情用抽屉、不新开路由**:`NDrawer` + `NDrawerContent` + `NDescriptions`,
|
||||
抄 `src/views/audit/AuditDetailDrawer.vue`;入口是操作列里的 `text` 按钮(`size="small"`)。
|
||||
⚠️ 先确认**列表接口给没给详情要的字段** —— 例:`/tenants` 的 `contactPhone` 恒为 `null`,
|
||||
脱敏值只有 `/tenants/{id}` 给;拿行数据凑详情会显示成"没有联系电话"。
|
||||
9. **页面内的页签用工具条分段按钮,不要 `NTabs`**:`NTabPane` 要额外 flex 化才能维持
|
||||
`.page-card` 高度链,否则表格高度算成 0、**一行都看不见**(本仓踩过);
|
||||
且第 5 条那个 `tab="..."` 的坑也在这条路上。各页签的 list/page 状态分开维护。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
+13
-1
@@ -43,7 +43,7 @@ pnpm lint # ESLint(@antfu/eslint-config)
|
||||
- 接口模块在 `src/apis/xt/`(按业务域分文件),统一从 `@/apis/xt` 或 `@/apis/xt/<域>` 引
|
||||
- 演示账号:`admin / Admin@123` + 租户码 `demo`(⚠️ **不是** Gi 模板 README 里的 `admin/123456`)
|
||||
|
||||
## 七条最容易写错的约定
|
||||
## 九条最容易写错的约定
|
||||
|
||||
1. **没有 auto-import**:naive-ui 组件、Vue API、工具函数**全部显式 `import`**,
|
||||
标签统一写 **PascalCase**(`<NButton>`)。
|
||||
@@ -70,3 +70,15 @@ pnpm lint # ESLint(@antfu/eslint-config)
|
||||
⚠️ **SVG 的注释里不能出现连续两个减号**(XML 规范禁止):会让整个文件 not well-formed,
|
||||
表现是图片裂开、**控制台一句话都不报**,typecheck/lint 也全绿。
|
||||
`python scripts/check-wiring.py` 第 ⑨ 项会核对这件事。
|
||||
8. **详情用抽屉,不要为详情新开路由**:`NDrawer` + `NDrawerContent` + `NDescriptions`,
|
||||
模板抄 `src/views/audit/AuditDetailDrawer.vue`。列表页的"详情"入口是操作列里的一个
|
||||
`text` 按钮(`size="small"`,见第 6 条的行内按钮约定)。
|
||||
⚠️ 抽屉打开时该调哪个接口要看清楚:**列表接口可能刻意不返回某个字段**
|
||||
(例:`/tenants` 的 `contactPhone` 恒为 `null`,脱敏值只有 `/tenants/{id}` 给),
|
||||
拿行数据凑详情会显示成"没有联系电话"。
|
||||
9. **页面内的页签用工具条上的分段按钮,不要用 `NTabs` / `NTabPane`**:
|
||||
① `NTabPane` 的内容要额外 flex 化才能维持 `.page-card` 的高度链,
|
||||
否则表格高度算成 0、**一行数据都看不见**(本仓踩过);
|
||||
② 见第 5 条,naive 2.45.3 的 `tab="..."` 属性写法本身也是坏的。
|
||||
两个按钮做的分段控件在视觉上就是页签,且不动高度链。
|
||||
多个页签各自的 `list` / `total` / `pageNum` / `loading` 要分开维护,切换时重置到第 1 页。
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
*
|
||||
* ## ⚠️ 一期的已知边界(界面上要说明,不能让人以为系统坏了)
|
||||
*
|
||||
* - 登录日志**只有 PC 账号密码登录**;学生从微信进来目前不写这张表;
|
||||
* - `location`(IP 归属地)**恒为 null**:没接 IP 库,宁可留空也不编一个;
|
||||
* - IP 取的是代理链第一跳,**可以伪造** —— 用于排查,不作合规证据。
|
||||
* 真正的证据是"哪个账号在什么时候做了什么"。
|
||||
* - `request_param` / `duration_ms` 两列**没有任何写入点**(全库 0 行有值),
|
||||
* 所以界面上不展示、也不做筛选,别为它们写查询。
|
||||
*/
|
||||
import http from '@/utils/http'
|
||||
|
||||
@@ -35,6 +37,12 @@ export interface OperationLog {
|
||||
afterVal?: string
|
||||
ip?: string
|
||||
userAgent?: string
|
||||
/** 请求方法 GET / POST / PUT / DELETE —— 详情里展示 */
|
||||
requestMethod?: string
|
||||
/** 请求路径(不含 query string) */
|
||||
requestUri?: string
|
||||
/** 补充上下文(JSON 串);不是每条都有,可能是合法的 `null` */
|
||||
extraJson?: string
|
||||
bizCode?: number
|
||||
traceId?: string
|
||||
createTime?: string
|
||||
@@ -47,6 +55,13 @@ export interface OperationLogPage {
|
||||
|
||||
export interface LoginLog {
|
||||
id: number
|
||||
/**
|
||||
* ⚠️ `0` = **平台**(不是 null)。
|
||||
*
|
||||
* 平台运营看到的是**跨单位**的登录日志,没有它分不清一行是哪所学校/单位的
|
||||
* (《03-接口设计》§13.4 `GET /logs/logins`:`tenantId`(`0` = 平台)**必须有**)。
|
||||
*/
|
||||
tenantId?: number
|
||||
userId?: number
|
||||
userName?: string
|
||||
userType?: number
|
||||
@@ -66,6 +81,8 @@ export interface LoginLogPage {
|
||||
}
|
||||
|
||||
export function getOperationLogs(params: {
|
||||
/** ⚠️ 只有 `log:viewAll`(平台运营)能指定别的租户;学校管理员传别人的租户号会 40301 */
|
||||
tenantId?: number
|
||||
operatorId?: number
|
||||
module?: string
|
||||
action?: string
|
||||
@@ -80,6 +97,7 @@ export function getOperationLogs(params: {
|
||||
}
|
||||
|
||||
export function getLoginLogs(params: {
|
||||
tenantId?: number
|
||||
userId?: number
|
||||
userType?: number
|
||||
result?: boolean
|
||||
@@ -91,7 +109,12 @@ export function getLoginLogs(params: {
|
||||
return http.get<LoginLogPage>('/logs/logins', params)
|
||||
}
|
||||
|
||||
/** 敏感动作 → 文案。**与后端 OperationLogService 的常量一致**。 */
|
||||
/**
|
||||
* 动作码 → 文案。
|
||||
*
|
||||
* ⚠️ **必须与后端 `OperationLogService` 的 `ACTION_*` 常量逐字一致**(`check-wiring.py` 第 ⑩ 项双向核对)。
|
||||
* 缺一个不会报错 —— `actionText` 会原样回显那个码,看起来像"系统没翻译"。
|
||||
*/
|
||||
export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
|
||||
{ value: 'UPDATE_IDCARD', label: '改名册证件号' },
|
||||
{ value: 'UPDATE_ROSTER_NAME', label: '改名册姓名' },
|
||||
@@ -100,9 +123,13 @@ export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
|
||||
{ value: 'PAUSE_TASK', label: '暂停任务' },
|
||||
{ value: 'RESUME_TASK', label: '恢复任务' },
|
||||
{ value: 'CLOSE_TASK', label: '截止任务' },
|
||||
{ value: 'AUTO_CLOSE', label: '自动截止任务' },
|
||||
{ value: 'STATUS_CHANGE', label: '改任务状态' },
|
||||
{ value: 'ARCHIVE_TASK', label: '归档任务' },
|
||||
{ value: 'DELETE_TASK', label: '删除已归档任务' },
|
||||
{ value: 'REVOKE_AUDIT', label: '撤回审核' },
|
||||
{ value: 'EXPORT_CREATE', label: '创建导出' },
|
||||
{ value: 'EXPORT_DOWNLOAD', label: '下载导出文件' },
|
||||
{ value: 'PROXY_UPLOAD', label: '代传材料' },
|
||||
{ value: 'VIEW_IDCARD_FULL', label: '查看完整证件号' },
|
||||
{ value: 'CONFIG_COMPRESS', label: '改压缩配置' },
|
||||
@@ -113,18 +140,51 @@ export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
|
||||
{ value: 'DELETE_ROLE', label: '删除角色' },
|
||||
{ value: 'GRANT_ROLE', label: '改角色授权' },
|
||||
{ value: 'UPDATE_ROLE_MENU', label: '改角色菜单' },
|
||||
{ value: 'TOGGLE_ROLE', label: '停用/启用角色' }
|
||||
{ value: 'TOGGLE_ROLE', label: '停用/启用角色' },
|
||||
{ value: 'CREATE_TENANT', label: '开通学校' },
|
||||
{ value: 'UPDATE_TENANT', label: '修改学校' },
|
||||
{ value: 'DISABLE_TENANT', label: '停用学校' },
|
||||
{ value: 'ENABLE_TENANT', label: '启用学校' }
|
||||
]
|
||||
|
||||
export function actionText(v?: string) {
|
||||
return OPERATION_ACTIONS.find((a) => a.value === v)?.label || v || '—'
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块筛选的可选项。
|
||||
*
|
||||
* ⚠️ **只列后端真的会写的模块**(有 `OperationLogService.MODULE_*` 调用点)。
|
||||
* `MODULE_AUDIT` / `MODULE_EXPORT` / `MODULE_USER` / `MODULE_JOIN` 四个常量**一个调用点都没有** ——
|
||||
* 把它们列进下拉,选中后只会得到一张空表,让人以为"这段时间没有审核动作"。
|
||||
* (加模块时先确认后端有写入口,再往这里加。)
|
||||
*/
|
||||
export const MODULE_OPTIONS: Array<{ value: string, label: string }> = [
|
||||
{ value: 'TASK', label: '任务' },
|
||||
{ value: 'ROSTER', label: '名册' },
|
||||
{ value: 'FILE', label: '材料' },
|
||||
{ value: 'ROLE', label: '角色与授权' },
|
||||
{ value: 'TENANT', label: '租户(平台)' }
|
||||
]
|
||||
|
||||
/** 对象类型筛选的可选项。口径同 `MODULE_OPTIONS`:只列真有写入点的。 */
|
||||
export const TARGET_TYPE_OPTIONS: Array<{ value: string, label: string }> = [
|
||||
{ value: 'TASK', label: '任务' },
|
||||
{ value: 'ROSTER', label: '名册项' },
|
||||
{ value: 'ROLE', label: '角色' },
|
||||
{ value: 'TENANT', label: '租户' }
|
||||
]
|
||||
|
||||
export const LOGIN_TYPES: Record<string, string> = {
|
||||
PASSWORD: '账号密码',
|
||||
WECHAT: '微信授权',
|
||||
PHONE: '手机号快捷'
|
||||
}
|
||||
|
||||
/** 需要重点看的动作(改证件号、看明文证件号)——列表里高亮。 */
|
||||
export const SENSITIVE_ACTIONS = ['UPDATE_IDCARD', 'VIEW_IDCARD_FULL', 'DELETE_ROSTER']
|
||||
/**
|
||||
* 需要重点看的动作 —— 列表里高亮。
|
||||
*
|
||||
* `DISABLE_TENANT`:停用一所学校 = 该校所有学生与老师**立刻登不进来**,
|
||||
* 是单个动作里影响面最大的一类,必须一眼看见。
|
||||
*/
|
||||
export const SENSITIVE_ACTIONS = ['UPDATE_IDCARD', 'VIEW_IDCARD_FULL', 'DELETE_ROSTER', 'DISABLE_TENANT']
|
||||
|
||||
@@ -59,6 +59,66 @@ export function getTenantDetail(id: number) {
|
||||
return http.get<TenantVO>(`/tenants/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改租户(§6.8 的 `PUT /tenants/{id}`)。
|
||||
*
|
||||
* ⚠️ 三个「只在这里能看出来的口径」:
|
||||
* - `tenantCode` **不可修改**(不在请求体里):它出现在小程序扫码落地页与对接文档里;
|
||||
* - `contactPhone` **留空 = 不修改**(后端非空才写,且写进去的是密文);
|
||||
* - `status` 可以给 `2`(试用)—— 与 `TENANT_STATUS` 的三个值一致。
|
||||
*/
|
||||
export interface TenantUpdateReq {
|
||||
tenantName?: string
|
||||
tenantType?: number
|
||||
contactName?: string
|
||||
/** 留空 = 不修改 */
|
||||
contactPhone?: string
|
||||
packageId?: number
|
||||
expireTime?: string
|
||||
/** 0 已停用 / 1 正常 / 2 试用 */
|
||||
status?: number
|
||||
}
|
||||
|
||||
export function updateTenant(id: number, data: TenantUpdateReq) {
|
||||
return http.put<TenantVO>(`/tenants/${id}`, data)
|
||||
}
|
||||
|
||||
/** 用量统计(§6.7)。`ocrCount` / `smsCount` 一期**恒为 0**,见响应里的 `notes`。 */
|
||||
export interface TenantUsage {
|
||||
tenantId: number
|
||||
tenantName: string
|
||||
/** 统计周期,回显用:`2026-09` 或 `2026Q3` */
|
||||
period: string
|
||||
/** 实时值:按证件号去重的人数 */
|
||||
studentCount: number
|
||||
/** 实时值:材料规范件的体积合计(MB) */
|
||||
storageMb: number
|
||||
ocrCount: number
|
||||
smsCount: number
|
||||
quota: {
|
||||
studentLimit: number
|
||||
storageLimitMb: number
|
||||
ocrLimit: number
|
||||
smsLimit: number
|
||||
}
|
||||
alerts: Array<{ type: string, level: number, ratio: string, used: number, limit: number }>
|
||||
/** 某几项为什么是 0(避免把"没通道"当成"没用量")—— 界面要如实展示 */
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 用量统计。
|
||||
*
|
||||
* ⚠️ `studentCount` / `storageMb` 是**实时**算的,`period` 只影响计费项(OCR / 短信)
|
||||
* 的统计周期标签 —— 界面上必须写清楚,否则会以为「2026Q3 的学生数」是历史值。
|
||||
*
|
||||
* @param id 租户 id
|
||||
* @param period `2026-09`(月)或 `2026Q3`(季);不传 = 当月
|
||||
*/
|
||||
export function getTenantUsage(id: number, period?: string) {
|
||||
return http.get<TenantUsage>(`/tenants/${id}/usage`, period ? { period } : {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 开通租户:一次请求完成「建租户 + 发放 6 个内置角色 + 创建首个管理员」。
|
||||
* 返回的 `adminInitialPassword` 只在这一次响应里,界面必须让用户当场记下。
|
||||
|
||||
@@ -142,7 +142,10 @@ export const XT_MENUS: XtMenu[] = [
|
||||
},
|
||||
{
|
||||
key: 'log',
|
||||
title: '操作日志',
|
||||
// ⚠️ `key` 保持 'log' 不动(后端 BuiltinRole 的菜单键与 check-wiring.py 第 ⑥ 项都按它核对,
|
||||
// 改 key 会让平台角色的菜单静默少一项);改的只是**显示名** ——
|
||||
// 这一页现在有「操作日志 / 登录日志」两个页签,叫「操作日志」会名不副实。
|
||||
title: '日志',
|
||||
icon: 'log',
|
||||
path: '/platform/log',
|
||||
component: 'log/index',
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
<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>
|
||||
+535
-150
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { OperationLog } from '@/apis/xt/log'
|
||||
import type { LoginLog, OperationLog } from '@/apis/xt/log'
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
@@ -11,54 +11,77 @@ import {
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, h, onMounted, reactive, ref, useTemplateRef } from 'vue'
|
||||
import {
|
||||
actionText,
|
||||
getLoginLogs,
|
||||
getOperationLogs,
|
||||
LOGIN_TYPES,
|
||||
MODULE_OPTIONS,
|
||||
OPERATION_ACTIONS,
|
||||
SENSITIVE_ACTIONS
|
||||
SENSITIVE_ACTIONS,
|
||||
TARGET_TYPE_OPTIONS
|
||||
} from '@/apis/xt/log'
|
||||
import { getTenantList } from '@/apis/xt/tenant'
|
||||
import { USER_TYPE_NAMES } from '@/apis/xt/user'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import LogDetailDrawer from './LogDetailDrawer.vue'
|
||||
|
||||
/**
|
||||
* 操作日志。
|
||||
* 日志(操作日志 / 登录日志)。
|
||||
*
|
||||
* ## 为什么把「对象 ID」放在筛选栏最左边
|
||||
* ## 为什么模式切换用两个按钮,而不是 `NTabs`
|
||||
*
|
||||
* 因为这是这一屏**唯一不可替代**的查询方式。合规问题问的是
|
||||
* 「这张名册项的证件号被谁从什么改成了什么」,答案只能靠 `target_type + target_id` 定位;
|
||||
* 而"搜关键字"会把「改了它」和「顺带提到它」混在一起 —— 那种匹配撑不起合规结论。
|
||||
* 这是本仓踩过的两个坑叠在一起,刻意绕开:
|
||||
* ① 页签内容需要额外 flex 化才能维持 `.page-card` 的高度链,否则表体高度算成 **0** ——
|
||||
* 行在 DOM 里、数据也查得到,屏幕上一行都看不见;
|
||||
* ② naive 2.45.3 的 `NTabs` 用 `tab="文字"` **属性**只渲染一个空注释节点(必须用 `#tab` 插槽)。
|
||||
* 两个按钮在视觉上就是分段控件,且完全不动高度链(与「租户」页的状态筛选同一个写法)。
|
||||
*
|
||||
* ## 「变更前 → 变更后」单独一列
|
||||
* ## 两个模式的状态**完全独立**
|
||||
*
|
||||
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于每次查看多一次点击,
|
||||
* 而合规核查往往是几十条连着看的。
|
||||
* 各自的 `list/total/pageNum/pageSize/loading/筛选条件`,切换时把要进入的那个模式重置到第 1 页 ——
|
||||
* 否则会出现"分页器停在第 5 页、而这张表只有 2 页"的空表,看起来就像"这个模式没数据"。
|
||||
*
|
||||
* ## 范围:平台运营看**全量**,学校管理员看**本租户**
|
||||
*
|
||||
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定。⚠️ 平台看到的每条日志属于哪个单位,
|
||||
* 靠「租户」这一列 —— 它**只对平台显示**(学校管理员永远是本租户,列没有意义)。
|
||||
* 靠两张表里那一列「单位」(`0` → 「平台」)—— 它**只对平台显示**:
|
||||
* 学校管理员永远是本租户,整列都是同一个值,加了是噪音。
|
||||
*
|
||||
* 两张表的这一列**用同一个词**(「单位」),与筛选栏那两个下拉的占位符一致。
|
||||
* 不叫「租户」是因为它要能装下 `tenantId = 0` 的「平台」—— 平台不是租户,
|
||||
* 表头写「租户」而格子里写着「平台」,读起来是矛盾的。
|
||||
*/
|
||||
defineOptions({ name: 'OperationLogs' })
|
||||
defineOptions({ name: 'Logs' })
|
||||
|
||||
type LogMode = 'operation' | 'login'
|
||||
|
||||
const message = useMessage()
|
||||
const user = useUserStore()
|
||||
const detailRef = useTemplateRef('detailRef')
|
||||
|
||||
/** 平台运营:看全量日志,因此要显示「租户」列并把 id 翻成单位名 */
|
||||
/** 平台运营:看全量日志,因此要显示「单位」列并把 id 翻成单位名 */
|
||||
const isPlatform = computed(() => user.isPlatform)
|
||||
/** 平台运营的权限码口径(与后端 `logScopeTenantId` 同一个码):多一个「单位」筛选 */
|
||||
const canViewAll = computed(() => user.has('log:viewAll'))
|
||||
|
||||
const tenantNames = ref<Record<number, string>>({})
|
||||
const tenantOptions = ref<Array<{ label: string, value: number }>>([])
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<OperationLog[]>([])
|
||||
const total = ref(0)
|
||||
const mode = ref<LogMode>('operation')
|
||||
|
||||
const fromDate = ref<string | null>(null)
|
||||
const toDate = ref<string | null>(null)
|
||||
// ── 操作日志 ─────────────────────────────────────────
|
||||
const opLoading = ref(false)
|
||||
const opList = ref<OperationLog[]>([])
|
||||
const opTotal = ref(0)
|
||||
const opFrom = ref<string | null>(null)
|
||||
const opTo = ref<string | null>(null)
|
||||
|
||||
const query = reactive<{
|
||||
const opQuery = reactive<{
|
||||
tenantId: number | null
|
||||
targetId: number | null
|
||||
targetType: string | null
|
||||
action: string | null
|
||||
@@ -66,6 +89,7 @@ const query = reactive<{
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}>({
|
||||
tenantId: null,
|
||||
targetId: null,
|
||||
targetType: null,
|
||||
action: null,
|
||||
@@ -74,24 +98,45 @@ const query = reactive<{
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
const targetTypeOptions = [
|
||||
{ label: '名册项', value: 'ROSTER' },
|
||||
{ label: '任务', value: 'TASK' },
|
||||
{ label: '填报单', value: 'SUBMISSION' },
|
||||
{ label: '导出', value: 'EXPORT' },
|
||||
{ label: '账号', value: 'USER' }
|
||||
]
|
||||
// ── 登录日志 ─────────────────────────────────────────
|
||||
const loginLoading = ref(false)
|
||||
const loginList = ref<LoginLog[]>([])
|
||||
const loginTotal = ref(0)
|
||||
const loginFrom = ref<string | null>(null)
|
||||
const loginTo = ref<string | null>(null)
|
||||
|
||||
const loginQuery = reactive<{
|
||||
tenantId: number | null
|
||||
userType: number | null
|
||||
result: number | null
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}>({
|
||||
tenantId: null,
|
||||
userType: null,
|
||||
result: null,
|
||||
pageNum: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
const actionOptions = OPERATION_ACTIONS.map((a) => ({ label: a.label, value: a.value }))
|
||||
|
||||
/** 加入申请(JOIN)已随需求收敛下线,这里不再列它 */
|
||||
const moduleOptions = [
|
||||
{ label: '名册', value: 'ROSTER' },
|
||||
{ label: '任务', value: 'TASK' },
|
||||
{ label: '审核', value: 'AUDIT' },
|
||||
{ label: '导出', value: 'EXPORT' }
|
||||
/**
|
||||
* 登录结果的筛选值用 `1 / 0`,**不用 `true / false`** ——
|
||||
* naive 的 `SelectOption.value` 只接受 `string | number`,传布尔会在 `vue-tsc` 里报类型错;
|
||||
* 翻成后端要的 `Boolean` 只在调接口那一处做一次。
|
||||
*/
|
||||
const resultOptions = [
|
||||
{ label: '成功', value: 1 },
|
||||
{ label: '失败', value: 0 }
|
||||
]
|
||||
|
||||
/** 用户类型映射复用 `@/apis/xt/user`(1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生) */
|
||||
const userTypeOptions = Object.entries(USER_TYPE_NAMES).map(([code, name]) => ({
|
||||
label: name,
|
||||
value: Number(code)
|
||||
}))
|
||||
|
||||
function fmt(v?: string): string {
|
||||
return v ? v.replace('T', ' ').slice(0, 19) : '—'
|
||||
}
|
||||
@@ -100,16 +145,36 @@ function isSensitive(action?: string): boolean {
|
||||
return !!action && SENSITIVE_ACTIONS.includes(action)
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<OperationLog>>(() => {
|
||||
/**
|
||||
* 单位显示(操作日志、登录日志两张表共用同一个函数,口径必须一致)。
|
||||
*
|
||||
* ⚠️ 平台自己的动作(开通/停用学校)写的是 `tenant_id = 0`,**不是 null** ——
|
||||
* 照直渲染就是 `#0`,看不出"这是平台自己干的"。
|
||||
*/
|
||||
function tenantLabel(id?: number): string {
|
||||
if (id == null) {
|
||||
return '—'
|
||||
}
|
||||
if (id === 0) {
|
||||
return '平台'
|
||||
}
|
||||
return tenantNames.value[id] ?? `#${id}`
|
||||
}
|
||||
|
||||
function openDetail(row: OperationLog): void {
|
||||
detailRef.value?.open(row)
|
||||
}
|
||||
|
||||
const operationColumns = computed<DataTableColumns<OperationLog>>(() => {
|
||||
const cols: DataTableColumns<OperationLog> = []
|
||||
|
||||
// 「租户」列只给平台运营看
|
||||
// 「单位」列只给平台运营看
|
||||
if (isPlatform.value) {
|
||||
cols.push({
|
||||
title: '租户',
|
||||
title: '单位',
|
||||
key: 'tenantId',
|
||||
width: 150,
|
||||
render: (row) => h('span', { class: 'log__muted' }, tenantNames.value[row.tenantId ?? 0] ?? (row.tenantId != null ? `#${row.tenantId}` : '—'))
|
||||
render: (row) => h('span', { class: 'log__muted' }, tenantLabel(row.tenantId))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,7 +190,8 @@ const columns = computed<DataTableColumns<OperationLog>>(() => {
|
||||
key: 'operatorName',
|
||||
width: 130,
|
||||
// ⚠️ 只显示账号/姓名,不显示用户 ID(客户走查要求):
|
||||
// 数字 ID 对看日志的人没有意义,他要知道的是"哪个账号做的"
|
||||
// 数字 ID 对看日志的人没有意义,他要知道的是"哪个账号做的"。
|
||||
// 需要 ID 时点「详情」—— 那里带 `#id`,与库里那一行对得上
|
||||
render: (row) => row.operatorName || '—'
|
||||
},
|
||||
{
|
||||
@@ -167,6 +233,105 @@ const columns = computed<DataTableColumns<OperationLog>>(() => {
|
||||
h('div', { class: 'log__muted' }, row.ip || '—'),
|
||||
h('div', { class: 'log__ua', title: row.userAgent || '' }, row.userAgent || '')
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'ops',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
// 固定在右侧:列宽合计超过容器时,横向滚动不该把「详情」滚出可视区
|
||||
fixed: 'right',
|
||||
render: (row) => h(
|
||||
NButton,
|
||||
{ text: true, size: 'small', onClick: () => openDetail(row) },
|
||||
{ default: () => '详情' }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return cols
|
||||
})
|
||||
|
||||
const loginColumns = computed<DataTableColumns<LoginLog>>(() => {
|
||||
const cols: DataTableColumns<LoginLog> = []
|
||||
|
||||
cols.push({
|
||||
title: '时间',
|
||||
key: 'loginTime',
|
||||
width: 160,
|
||||
render: (row) => h('span', { class: 'log__muted' }, fmt(row.loginTime))
|
||||
})
|
||||
|
||||
// 「单位」列(与操作日志同款、同一个判断):平台看的是跨单位的登录日志,
|
||||
// 少了它分不清一行是哪所学校的;学校管理员恒为本租户,整列一个值,不加
|
||||
if (isPlatform.value) {
|
||||
cols.push({
|
||||
title: '单位',
|
||||
key: 'tenantId',
|
||||
width: 150,
|
||||
// ⚠️ 后端本次才把这个字段加进 `LoginLogVO`,旧 jar 下这里是 undefined → 显示「—」
|
||||
render: (row) => h('span', { class: 'log__muted' }, tenantLabel(row.tenantId))
|
||||
})
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
title: '用户',
|
||||
key: 'userName',
|
||||
width: 150,
|
||||
render: (row) => h('div', null, [
|
||||
h('div', null, row.userName || '—'),
|
||||
// 用户 ID 放在姓名下面:账号重名、学生用家长的手机登录时,靠它才能确认是哪个人
|
||||
h('div', { class: 'log__muted' }, row.userId != null ? `#${row.userId}` : '—')
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '用户类型',
|
||||
key: 'userType',
|
||||
width: 110,
|
||||
render: (row) => USER_TYPE_NAMES[row.userType ?? 0] ?? '—'
|
||||
},
|
||||
{
|
||||
title: '登录方式',
|
||||
key: 'loginType',
|
||||
width: 120,
|
||||
// 认不出就原样回显那个码(后端将来加登录方式时,界面不会显示成空白)
|
||||
render: (row) => LOGIN_TYPES[row.loginType ?? ''] ?? row.loginType ?? '—'
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
key: 'result',
|
||||
width: 90,
|
||||
render: (row) => {
|
||||
if (row.result !== true && row.result !== false) {
|
||||
return h('span', { class: 'log__muted' }, '—')
|
||||
}
|
||||
return h(
|
||||
NTag,
|
||||
{ size: 'small', bordered: false, type: row.result ? 'success' : 'error' },
|
||||
{ default: () => (row.result ? '成功' : '失败') }
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
key: 'ip',
|
||||
width: 140,
|
||||
render: (row) => h('span', { class: 'log__muted' }, row.ip || '—')
|
||||
},
|
||||
{
|
||||
title: '设备',
|
||||
key: 'device',
|
||||
width: 180,
|
||||
// 与操作日志的 UA 列同款:单行 + 省略号,完整内容放 `title`(鼠标悬停可看)
|
||||
render: (row) => h('span', { class: 'log__ua', title: row.device || '' }, row.device || '—')
|
||||
},
|
||||
{
|
||||
title: '失败原因',
|
||||
key: 'failReason',
|
||||
width: 200,
|
||||
// 登录成功的那一行本来就没有失败原因,留空比整列铺满「—」好读
|
||||
render: (row) => (row.failReason ? h('span', null, row.failReason) : '')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -180,84 +345,189 @@ const columns = computed<DataTableColumns<OperationLog>>(() => {
|
||||
* 同时处理 `@update:page` 与 `@update:page-size`(改每页条数一律回到第 1 页重新取数,
|
||||
* 否则会出现"分页器说 50 条/页、表里还是上一次那 20 条")。
|
||||
*/
|
||||
const pagination = computed(() => listPagination({
|
||||
page: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
itemCount: total.value,
|
||||
const opPagination = computed(() => listPagination({
|
||||
page: opQuery.pageNum,
|
||||
pageSize: opQuery.pageSize,
|
||||
itemCount: opTotal.value,
|
||||
pageSizes: [20, 50, 100]
|
||||
}))
|
||||
|
||||
function onPage(page: number): void {
|
||||
query.pageNum = page
|
||||
void load()
|
||||
}
|
||||
const loginPagination = computed(() => listPagination({
|
||||
page: loginQuery.pageNum,
|
||||
pageSize: loginQuery.pageSize,
|
||||
itemCount: loginTotal.value,
|
||||
pageSizes: [20, 50, 100]
|
||||
}))
|
||||
|
||||
function onPageSize(size: number): void {
|
||||
query.pageSize = size
|
||||
query.pageNum = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
async function loadOperations(): Promise<void> {
|
||||
opLoading.value = true
|
||||
try {
|
||||
const page = await getOperationLogs({
|
||||
targetId: query.targetId ?? undefined,
|
||||
targetType: query.targetType ?? undefined,
|
||||
action: query.action ?? undefined,
|
||||
module: query.module ?? undefined,
|
||||
// ⚠️ `?? undefined` 而不是 `||`:0 是「平台」这个**有效值**,用 `||` 会被当成"没选"
|
||||
tenantId: opQuery.tenantId ?? undefined,
|
||||
targetId: opQuery.targetId ?? undefined,
|
||||
targetType: opQuery.targetType ?? undefined,
|
||||
action: opQuery.action ?? undefined,
|
||||
module: opQuery.module ?? undefined,
|
||||
// 后端 `to` 会补到当天 23:59:59,所以这里只给日期就够
|
||||
from: fromDate.value || undefined,
|
||||
to: toDate.value || undefined,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
from: opFrom.value || undefined,
|
||||
to: opTo.value || undefined,
|
||||
pageNum: opQuery.pageNum,
|
||||
pageSize: opQuery.pageSize
|
||||
})
|
||||
list.value = page.list ?? []
|
||||
total.value = page.total ?? 0
|
||||
opList.value = page.list ?? []
|
||||
opTotal.value = page.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
opLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search(): void {
|
||||
query.pageNum = 1
|
||||
void load()
|
||||
async function loadLogins(): Promise<void> {
|
||||
loginLoading.value = true
|
||||
try {
|
||||
const page = await getLoginLogs({
|
||||
tenantId: loginQuery.tenantId ?? undefined,
|
||||
userType: loginQuery.userType ?? undefined,
|
||||
// 筛选值是 1/0(见 `resultOptions`),接口要的是 Boolean
|
||||
result: loginQuery.result == null ? undefined : loginQuery.result === 1,
|
||||
from: loginFrom.value || undefined,
|
||||
to: loginTo.value || undefined,
|
||||
pageNum: loginQuery.pageNum,
|
||||
pageSize: loginQuery.pageSize
|
||||
})
|
||||
loginList.value = page.list ?? []
|
||||
loginTotal.value = page.total ?? 0
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
query.targetId = null
|
||||
query.targetType = null
|
||||
query.action = null
|
||||
query.module = null
|
||||
fromDate.value = null
|
||||
toDate.value = null
|
||||
search()
|
||||
/**
|
||||
* 切换模式。
|
||||
*
|
||||
* ⚠️ 进入哪个模式,就把它**自己**的页码重置到第 1 页:
|
||||
* 两个模式各自记着上次翻到第几页,不重置就会出现"切过来是第 5 页、而这张表只有 2 页"的空表 ——
|
||||
* 那看起来就是"登录日志一条都没有"。筛选条件保留(切回来还是刚才那批条件),只有页码归零。
|
||||
*/
|
||||
function switchMode(next: LogMode): void {
|
||||
if (next === mode.value) {
|
||||
return
|
||||
}
|
||||
mode.value = next
|
||||
if (next === 'operation') {
|
||||
opQuery.pageNum = 1
|
||||
void loadOperations()
|
||||
} else {
|
||||
loginQuery.pageNum = 1
|
||||
void loadLogins()
|
||||
}
|
||||
}
|
||||
|
||||
function searchOperations(): void {
|
||||
opQuery.pageNum = 1
|
||||
void loadOperations()
|
||||
}
|
||||
|
||||
function searchLogins(): void {
|
||||
loginQuery.pageNum = 1
|
||||
void loadLogins()
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
if (mode.value === 'operation') {
|
||||
opQuery.tenantId = null
|
||||
opQuery.targetId = null
|
||||
opQuery.targetType = null
|
||||
opQuery.action = null
|
||||
opQuery.module = null
|
||||
opFrom.value = null
|
||||
opTo.value = null
|
||||
searchOperations()
|
||||
} else {
|
||||
loginQuery.tenantId = null
|
||||
loginQuery.userType = null
|
||||
loginQuery.result = null
|
||||
loginFrom.value = null
|
||||
loginTo.value = null
|
||||
searchLogins()
|
||||
}
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (mode.value === 'operation') {
|
||||
void loadOperations()
|
||||
} else {
|
||||
void loadLogins()
|
||||
}
|
||||
}
|
||||
|
||||
function onOperationPage(page: number): void {
|
||||
opQuery.pageNum = page
|
||||
void loadOperations()
|
||||
}
|
||||
|
||||
function onOperationPageSize(size: number): void {
|
||||
opQuery.pageSize = size
|
||||
opQuery.pageNum = 1
|
||||
void loadOperations()
|
||||
}
|
||||
|
||||
function onLoginPage(page: number): void {
|
||||
loginQuery.pageNum = page
|
||||
void loadLogins()
|
||||
}
|
||||
|
||||
function onLoginPageSize(size: number): void {
|
||||
loginQuery.pageSize = size
|
||||
loginQuery.pageNum = 1
|
||||
void loadLogins()
|
||||
}
|
||||
|
||||
/**
|
||||
* 单位下拉的选项。
|
||||
*
|
||||
* ⚠️ 除了租户列表,必须**额外加一项「平台」(`tenantId = 0`)**:
|
||||
* 平台自己的动作(开通/停用学校)写的是 `tenant_id = 0`,而租户表里没有 0 这一行 ——
|
||||
* 少了这一项,「平台做了哪些事」就永远筛不出来。放在最前面,因为它最常用。
|
||||
*/
|
||||
async function loadTenants(): Promise<void> {
|
||||
if (!canViewAll.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const page = await getTenantList({ pageNum: 1, pageSize: 200 })
|
||||
const tenants = page.list ?? []
|
||||
tenantNames.value = Object.fromEntries(tenants.map((t) => [t.id, t.tenantName]))
|
||||
tenantOptions.value = [
|
||||
{ label: '平台', value: 0 },
|
||||
...tenants.map((t) => ({ label: t.tenantName, value: t.id }))
|
||||
]
|
||||
} catch {
|
||||
// 取不到也不影响看日志:租户列退回显示 #id,下拉至少还能按「平台」筛
|
||||
tenantNames.value = {}
|
||||
tenantOptions.value = [{ label: '平台', value: 0 }]
|
||||
message.warning('单位名称没取到,租户列先显示编号')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 平台运营才需要"租户 id → 单位名"。取不到也不影响看日志:列里退回显示 #id
|
||||
if (isPlatform.value) {
|
||||
try {
|
||||
const page = await getTenantList({ pageNum: 1, pageSize: 200 })
|
||||
tenantNames.value = Object.fromEntries((page.list ?? []).map((t) => [t.id, t.tenantName]))
|
||||
} catch {
|
||||
tenantNames.value = {}
|
||||
message.warning('单位名称没取到,租户列先显示编号')
|
||||
}
|
||||
}
|
||||
await load()
|
||||
await loadTenants()
|
||||
await loadOperations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard class="page-card log" :bordered="false" size="small">
|
||||
<template #header>
|
||||
操作日志
|
||||
日志
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="log__hint">
|
||||
<span v-if="mode === 'operation'" class="log__hint">
|
||||
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
|
||||
</span>
|
||||
<span v-else class="log__hint">
|
||||
登录日志用来回答「谁在什么时候从哪里登了进来」;IP 归属地一期没有采集,所以没有这一列
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
@@ -269,62 +539,136 @@ onMounted(async () => {
|
||||
否则换行后每行会被"两端对齐"抻开,看起来像随机的空档。
|
||||
-->
|
||||
<div class="toolbar log__filters">
|
||||
<!-- 对象 ID 放最左:合规问题只能靠 target_type + target_id 定位 -->
|
||||
<NInputNumber
|
||||
v-model:value="query.targetId"
|
||||
class="log__num"
|
||||
:min="1"
|
||||
clearable
|
||||
placeholder="对象 ID"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.targetType"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="对象类型"
|
||||
:options="targetTypeOptions"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.action"
|
||||
class="log__sel2"
|
||||
clearable
|
||||
placeholder="动作"
|
||||
:options="actionOptions"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.module"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="模块"
|
||||
:options="moduleOptions"
|
||||
@update:value="search"
|
||||
/>
|
||||
<!-- 起止日期用两个独立日期框,不用 range:两个框各自能单独清空,核查时更好用 -->
|
||||
<NDatePicker
|
||||
v-model:formatted-value="fromDate"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="开始日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="search"
|
||||
/>
|
||||
<NDatePicker
|
||||
v-model:formatted-value="toDate"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="结束日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="search"
|
||||
/>
|
||||
<NButton @click="reset">
|
||||
<!--
|
||||
模式切换:用两个按钮做的分段控件(刻意不用 NTabs,原因见文件头注释)。
|
||||
右侧那条竖线把它与「筛选条件」分开 —— 一个是"看哪张表",一个是"表里筛什么"。
|
||||
-->
|
||||
<div class="log__seg">
|
||||
<NButton :type="mode === 'operation' ? 'primary' : 'default'" @click="switchMode('operation')">
|
||||
操作日志
|
||||
</NButton>
|
||||
<NButton :type="mode === 'login' ? 'primary' : 'default'" @click="switchMode('login')">
|
||||
登录日志
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- 操作日志的筛选条件 -->
|
||||
<template v-if="mode === 'operation'">
|
||||
<!-- 单位:只有平台能跨租户查(学校管理员恒为本租户,服务端会 40301) -->
|
||||
<NSelect
|
||||
v-if="canViewAll"
|
||||
v-model:value="opQuery.tenantId"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="单位"
|
||||
:options="tenantOptions"
|
||||
@update:value="searchOperations"
|
||||
/>
|
||||
<!-- 对象 ID 放最左:合规问题只能靠 target_type + target_id 定位 -->
|
||||
<NInputNumber
|
||||
v-model:value="opQuery.targetId"
|
||||
class="log__num"
|
||||
:min="1"
|
||||
clearable
|
||||
placeholder="对象 ID"
|
||||
@update:value="searchOperations"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="opQuery.targetType"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="对象类型"
|
||||
:options="TARGET_TYPE_OPTIONS"
|
||||
@update:value="searchOperations"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="opQuery.action"
|
||||
class="log__sel2"
|
||||
clearable
|
||||
placeholder="动作"
|
||||
:options="actionOptions"
|
||||
@update:value="searchOperations"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="opQuery.module"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="模块"
|
||||
:options="MODULE_OPTIONS"
|
||||
@update:value="searchOperations"
|
||||
/>
|
||||
<!-- 起止日期用两个独立日期框,不用 range:两个框各自能单独清空,核查时更好用 -->
|
||||
<NDatePicker
|
||||
v-model:formatted-value="opFrom"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="开始日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="searchOperations"
|
||||
/>
|
||||
<NDatePicker
|
||||
v-model:formatted-value="opTo"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="结束日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="searchOperations"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 登录日志的筛选条件:**没有**「归属地」—— 一期 location 恒为 null,不给一个永远筛不出东西的框 -->
|
||||
<template v-else>
|
||||
<NSelect
|
||||
v-if="canViewAll"
|
||||
v-model:value="loginQuery.tenantId"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="单位"
|
||||
:options="tenantOptions"
|
||||
@update:value="searchLogins"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="loginQuery.userType"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="用户类型"
|
||||
:options="userTypeOptions"
|
||||
@update:value="searchLogins"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="loginQuery.result"
|
||||
class="log__sel"
|
||||
clearable
|
||||
placeholder="结果"
|
||||
:options="resultOptions"
|
||||
@update:value="searchLogins"
|
||||
/>
|
||||
<NDatePicker
|
||||
v-model:formatted-value="loginFrom"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="开始日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="searchLogins"
|
||||
/>
|
||||
<NDatePicker
|
||||
v-model:formatted-value="loginTo"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
clearable
|
||||
placeholder="结束日期"
|
||||
class="log__date"
|
||||
@update:formatted-value="searchLogins"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<NButton @click="resetFilters">
|
||||
重置
|
||||
</NButton>
|
||||
<NButton @click="load">
|
||||
<NButton @click="refresh">
|
||||
<template #icon>
|
||||
<AppIcon name="refresh" :size="16" />
|
||||
</template>
|
||||
@@ -332,23 +676,52 @@ onMounted(async () => {
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
高度链:`.page-card__body` → `n-data-table[flex-height]`(见 styles/index.scss 的 .page-card 注释)。
|
||||
两个表格用 `v-if / v-else` 而不是同时挂着 —— 只有一张表是 DOM 里的直接子元素,
|
||||
`.page-card__body > .n-data-table` 那条全局规则才不会失效(失效的表现就是"一行都看不见")。
|
||||
-->
|
||||
<div class="page-card__body">
|
||||
<!--
|
||||
`:scroll-x` 是**列宽合计**:横向滚动条按它算。
|
||||
两张表都是"平台比学校管理员多一列「单位」(150)",所以两个数各有一对;
|
||||
操作日志末尾还含固定的「操作」列 90(登录日志没有这一列)。
|
||||
-->
|
||||
<NDataTable
|
||||
v-if="mode === 'operation'"
|
||||
flex-height
|
||||
style="height: 100%"
|
||||
remote
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:columns="operationColumns"
|
||||
:data="opList"
|
||||
:loading="opLoading"
|
||||
:row-key="row => row.id"
|
||||
:scroll-x="isPlatform ? 1240 : 1090"
|
||||
:pagination="pagination"
|
||||
:scroll-x="isPlatform ? 1330 : 1180"
|
||||
:pagination="opPagination"
|
||||
size="small"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
@update:page="onOperationPage"
|
||||
@update:page-size="onOperationPageSize"
|
||||
/>
|
||||
<NDataTable
|
||||
v-else
|
||||
flex-height
|
||||
style="height: 100%"
|
||||
remote
|
||||
:columns="loginColumns"
|
||||
:data="loginList"
|
||||
:loading="loginLoading"
|
||||
:row-key="row => row.id"
|
||||
:scroll-x="isPlatform ? 1300 : 1150"
|
||||
:pagination="loginPagination"
|
||||
size="small"
|
||||
@update:page="onLoginPage"
|
||||
@update:page-size="onLoginPageSize"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<!-- 详情不发请求:整行数据已经查回来了,抽屉只负责把被截断/没展示的字段摊开 -->
|
||||
<LogDetailDrawer ref="detailRef" :tenant-names="tenantNames" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -384,6 +757,18 @@ onMounted(async () => {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模式切换(分段控件)。
|
||||
* 只放 2 个按钮,所以它自己不需要换行 —— 后面那条竖线用来与"筛选条件"分区。
|
||||
*/
|
||||
.log__seg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-right: 12px;
|
||||
margin-right: 4px;
|
||||
border-right: 1px solid var(--xt-card-border-weak);
|
||||
}
|
||||
|
||||
.log__num {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from 'naive-ui'
|
||||
import type { TenantUsage, TenantVO } from '@/apis/xt/tenant'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NDrawer,
|
||||
NDrawerContent,
|
||||
NEmpty,
|
||||
NSelect,
|
||||
NSpin,
|
||||
NTag,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import * as tenantApi from '@/apis/xt/tenant'
|
||||
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
|
||||
import { fmtTime } from '@/utils/format'
|
||||
import { tagOf } from '@/utils/tag'
|
||||
|
||||
/**
|
||||
* 租户详情抽屉(平台运营)。
|
||||
*
|
||||
* ## 为什么打开时要**同时调两个接口**
|
||||
*
|
||||
* `getTenantDetail(id)` **必须**调,不是"顺手也拉一下":
|
||||
* 列表接口的 `contactPhone` 恒为 `null`(见 `apis/xt/tenant.ts` 的注释)——
|
||||
* **脱敏后的联系电话只有详情接口会给**。少了它,抽屉里的"联系电话"永远是空的,
|
||||
* 运营会以为客户没留电话。
|
||||
*
|
||||
* ## 用量区必须写清"哪一项是实时值"
|
||||
*
|
||||
* 服务端口径(`TenantUsageService` 的类注释):`studentCount` / `storageMb` 是**实时**算的
|
||||
* (它们决定"还能不能再收人"),`ocrCount` / `smsCount` 才是**按期**算的(计费项)。
|
||||
* 不写这句话,运营会以为"我选了 2026Q3,所以看到的是三季度末的学生数"——
|
||||
* 而它其实是"此刻"。周期选择器只影响计费项。
|
||||
*/
|
||||
defineOptions({ name: 'TenantDetailDrawer' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 用 `v-model:show` 与父页面同步 */
|
||||
show: boolean
|
||||
tenantId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(false)
|
||||
const usageLoading = ref(false)
|
||||
/** 错误态:http 层已经弹过提示,但抽屉里不能只剩一片空白(用户会以为"这个单位没用量") */
|
||||
const loadError = ref(false)
|
||||
const usageError = ref(false)
|
||||
const detail = ref<TenantVO | null>(null)
|
||||
const usage = ref<TenantUsage | null>(null)
|
||||
|
||||
/**
|
||||
* "当前这份详情属于哪个租户"的账。
|
||||
*
|
||||
* 父页面点「详情」时是先改 `tenant-id` 再开 `show`(同一批更新里两个都变),
|
||||
* 下面「开抽屉」与「换租户」两个 watch 会**各触发一次** ——
|
||||
* 没有这道判断就会白拉两轮接口。
|
||||
*/
|
||||
let drawId: number | null = null
|
||||
|
||||
/** 小程序码参数:孩子在手机上扫的那个码里就是这一串(`t{id}`,与开通成功弹窗给的是同一个值) */
|
||||
const scene = computed(() => (props.tenantId === null ? '—' : `t${props.tenantId}`))
|
||||
|
||||
const statusTag = computed(() => tagOf(TENANT_STATUS, detail.value?.status))
|
||||
const typeText = computed(() => {
|
||||
const t = detail.value?.tenantType
|
||||
return t === null || t === undefined ? '—' : (TENANT_TYPE_NAMES[t] ?? '—')
|
||||
})
|
||||
|
||||
/**
|
||||
* 到期提示。
|
||||
*
|
||||
* `expireTime` 是**日期**(不含时间),所以"还剩几天"按天算;
|
||||
* 已过期 / 30 天内到期分别给红 / 橙 —— 与列表里到期列的颜色是同一套口径
|
||||
* (列表只有 120px 宽,放不下文字,只能靠颜色)。
|
||||
*/
|
||||
const expireState = computed<'expired' | 'soon' | 'ok' | 'none'>(() => {
|
||||
const t = detail.value?.expireTime
|
||||
if (!t) {
|
||||
return 'none'
|
||||
}
|
||||
const end = Date.parse(`${t.slice(0, 10)}T23:59:59`)
|
||||
if (Number.isNaN(end)) {
|
||||
return 'none'
|
||||
}
|
||||
const days = (end - Date.now()) / 86400000
|
||||
if (days < 0) {
|
||||
return 'expired'
|
||||
}
|
||||
return days <= 30 ? 'soon' : 'ok'
|
||||
})
|
||||
|
||||
// ── 周期选择:只给三个固定选项 ──
|
||||
|
||||
interface PeriodOption extends SelectOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** 当月的接口口径(`2026-09`)。不传就是当月,但显式传更好排查 */
|
||||
function thisMonth(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
|
||||
}
|
||||
|
||||
function lastMonth(): string {
|
||||
const d = new Date()
|
||||
// `new Date(y, m - 1, 1)` 会自动跨年(1 月 → 上一年 12 月),不用自己判
|
||||
const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1)
|
||||
return `${prev.getFullYear()}-${pad(prev.getMonth() + 1)}`
|
||||
}
|
||||
|
||||
/** 本季(`2026Q3`)。客户的结算周期是季度,所以这个必须给 */
|
||||
function thisQuarter(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}Q${Math.floor(d.getMonth() / 3) + 1}`
|
||||
}
|
||||
|
||||
/** ⚠️ 只给这三个固定选项,**不做自由文本输入**:手输的格式错一次,接口就回一句业务错误 */
|
||||
const periodOptions: PeriodOption[] = [
|
||||
{ label: '本月', value: thisMonth() },
|
||||
{ label: '上月', value: lastMonth() },
|
||||
{ label: '本季', value: thisQuarter() }
|
||||
]
|
||||
|
||||
const period = ref<string | number | null>(thisMonth())
|
||||
const periodText = computed(() => periodOptions.find((o) => o.value === period.value)?.label ?? '')
|
||||
|
||||
// ── 占用比例(学生数 / 存储)──
|
||||
|
||||
interface QuotaRow {
|
||||
used: number
|
||||
limit: number
|
||||
percent: number
|
||||
/** 超 80% 标橙、超 100% 标红 —— 与服务端 `alerts` 的阈值(0.8 / 1.0)同一套账 */
|
||||
level: 0 | 1 | 2
|
||||
}
|
||||
|
||||
function quotaRow(used: number, limit: number): QuotaRow {
|
||||
if (limit <= 0) {
|
||||
return { used, limit, percent: 0, level: 0 }
|
||||
}
|
||||
const ratio = used / limit
|
||||
const percent = Math.min(Math.round(ratio * 1000) / 10, 100)
|
||||
const level: 0 | 1 | 2 = ratio >= 1 ? 2 : ratio >= 0.8 ? 1 : 0
|
||||
return { used, limit, percent, level }
|
||||
}
|
||||
|
||||
const studentQuota = computed(() => quotaRow(usage.value?.studentCount ?? 0, usage.value?.quota?.studentLimit ?? 0))
|
||||
const storageQuota = computed(() => quotaRow(usage.value?.storageMb ?? 0, usage.value?.quota?.storageLimitMb ?? 0))
|
||||
|
||||
function quotaClass(row: QuotaRow): string {
|
||||
if (row.level === 2) {
|
||||
return 'tdraw__quota--over'
|
||||
}
|
||||
return row.level === 1 ? 'tdraw__quota--warn' : ''
|
||||
}
|
||||
|
||||
function loadDetail(): void {
|
||||
const id = props.tenantId
|
||||
if (id === null) {
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
drawId = id
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
// 先清空:避免上一次看的那所学校的联系人 / 电话留在抽屉里被误读成这一所的
|
||||
detail.value = null
|
||||
void tenantApi.getTenantDetail(id)
|
||||
.then((vo) => {
|
||||
detail.value = vo
|
||||
})
|
||||
.catch(() => {
|
||||
detail.value = null
|
||||
loadError.value = true
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function loadUsage(): void {
|
||||
const id = props.tenantId
|
||||
if (id === null) {
|
||||
usage.value = null
|
||||
return
|
||||
}
|
||||
usageLoading.value = true
|
||||
usageError.value = false
|
||||
usage.value = null
|
||||
const p = typeof period.value === 'string' ? period.value : undefined
|
||||
void tenantApi.getTenantUsage(id, p)
|
||||
.then((vo) => {
|
||||
usage.value = vo
|
||||
})
|
||||
.catch(() => {
|
||||
usage.value = null
|
||||
usageError.value = true
|
||||
})
|
||||
.finally(() => {
|
||||
usageLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
async function copyScene(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(scene.value)
|
||||
message.success('已复制到剪贴板')
|
||||
} catch {
|
||||
// 与开通成功弹窗同一处理:http 之外的权限问题,别静默失败
|
||||
message.warning('浏览器不允许自动复制,请手动选中后复制')
|
||||
}
|
||||
}
|
||||
|
||||
function onPeriodChange(v: string | number | null): void {
|
||||
period.value = v
|
||||
loadUsage()
|
||||
}
|
||||
|
||||
// 打开时同时拉两个接口;换一个租户时周期回到"本月"(否则会带着上一所学校的选项)
|
||||
watch(() => props.show, (v) => {
|
||||
if (!v) {
|
||||
return
|
||||
}
|
||||
period.value = thisMonth()
|
||||
loadDetail()
|
||||
loadUsage()
|
||||
})
|
||||
|
||||
/**
|
||||
* 父页面在抽屉开着时点了另一行的「详情」——两个接口都要重拉。
|
||||
* 与上面共用 `drawId` 那道账,避免同一次更新里拉两遍。
|
||||
*/
|
||||
watch(() => props.tenantId, (id) => {
|
||||
if (props.show && id !== drawId) {
|
||||
loadDetail()
|
||||
loadUsage()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer
|
||||
:show="props.show"
|
||||
:width="900"
|
||||
placement="right"
|
||||
:mask-closable="false"
|
||||
@update:show="(v: boolean) => emit('update:show', v)"
|
||||
>
|
||||
<NDrawerContent :native-scrollbar="false" closable>
|
||||
<template #header>
|
||||
<span>{{ detail?.tenantName || '租户详情' }}</span>
|
||||
<span v-if="detail" class="tdraw__head-id">#{{ detail.id }}</span>
|
||||
</template>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<!-- 空态 / 错误态:不要只留一片空白(用户会读成"这个单位什么都没有") -->
|
||||
<NAlert v-if="!loading && loadError" type="error" :show-icon="false" class="tdraw__alert">
|
||||
没有拿到这个租户的信息,请关闭后重试。
|
||||
</NAlert>
|
||||
<NEmpty v-else-if="!loading && !detail" description="没有拿到这个租户的信息" class="tdraw__empty" />
|
||||
|
||||
<template v-else-if="detail">
|
||||
<!--
|
||||
顶部告警按"最严重的先说"排序:已停用 → 已过期 → 30 天内到期。
|
||||
停用与过期是两件事(停用的租户就算没过期也登不进来),所以并列显示而不是 else-if。
|
||||
-->
|
||||
<NAlert
|
||||
v-if="detail.status === 0"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
class="tdraw__alert"
|
||||
>
|
||||
该单位<strong>已停用</strong>:所有学生与老师<strong>立刻无法登录</strong>。
|
||||
恢复要在「编辑」里把状态改回正常或试用。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-if="expireState === 'expired'"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
class="tdraw__alert"
|
||||
>
|
||||
该单位<strong>已过期</strong>(到期时间 {{ fmtTime(detail.expireTime, 'YYYY-MM-DD') }})。
|
||||
过期后账号将无法登录,请联系客户续期。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-else-if="expireState === 'soon'"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
class="tdraw__alert"
|
||||
>
|
||||
该单位将在 <strong>30 天内到期</strong>({{ fmtTime(detail.expireTime, 'YYYY-MM-DD') }}),
|
||||
可以提前联系客户续期。
|
||||
</NAlert>
|
||||
|
||||
<NDescriptions :column="2" size="small" label-placement="left" bordered>
|
||||
<NDescriptionsItem label="单位名称">{{ detail.tenantName }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="租户 ID">{{ detail.id }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="租户编码">{{ detail.tenantCode }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="类型">{{ typeText }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="状态">
|
||||
<NTag :type="statusTag.type" size="small" :bordered="false">
|
||||
{{ statusTag.text }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="到期时间">{{ fmtTime(detail.expireTime, 'YYYY-MM-DD') }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="联系人">{{ detail.contactName || '—' }}</NDescriptionsItem>
|
||||
<!--
|
||||
脱敏后的联系电话**只有详情接口会给**(列表接口恒为 null),
|
||||
所以这一行能不能有值,取决于上面有没有真的调 getTenantDetail。
|
||||
-->
|
||||
<NDescriptionsItem label="联系电话(脱敏)">
|
||||
{{ detail.contactPhone || '未登记' }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="套餐 ID">
|
||||
{{ detail.packageId === null ? '未配套餐' : detail.packageId }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="创建时间">{{ fmtTime(detail.createTime) }}</NDescriptionsItem>
|
||||
<NDescriptionsItem label="小程序码参数" :span="2">
|
||||
<span class="tdraw__scene">{{ scene }}</span>
|
||||
<NButton text size="small" type="primary" class="tdraw__copy" @click="copyScene">
|
||||
复制
|
||||
</NButton>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
<!-- ── 用量 ── -->
|
||||
<div class="section-title tdraw__usage-title">
|
||||
用量
|
||||
<span class="section-title__note">学生数与存储是实时值,周期只影响计费项</span>
|
||||
</div>
|
||||
|
||||
<div class="tdraw__band">
|
||||
<div class="tdraw__period">
|
||||
<span class="tdraw__period-label">统计周期</span>
|
||||
<NSelect
|
||||
:value="period"
|
||||
:options="periodOptions"
|
||||
class="tdraw__period-select"
|
||||
@update:value="onPeriodChange"
|
||||
/>
|
||||
<span class="tdraw__period-hint">
|
||||
只影响计费项;学生数与存储永远是"此刻"的值
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<NSpin :show="usageLoading">
|
||||
<NAlert v-if="!usageLoading && usageError" type="error" :show-icon="false">
|
||||
没有拿到用量数据,换个周期或稍后重试。
|
||||
</NAlert>
|
||||
<NEmpty v-else-if="!usageLoading && !usage" description="没有拿到用量数据" class="tdraw__empty" />
|
||||
|
||||
<template v-else-if="usage">
|
||||
<div class="tdraw__quotas">
|
||||
<div class="tdraw__quota">
|
||||
<div class="tdraw__quota-label">
|
||||
学生数
|
||||
</div>
|
||||
<div class="tdraw__quota-value" :class="quotaClass(studentQuota)">
|
||||
{{ studentQuota.used }} / {{ studentQuota.limit }} 人
|
||||
<span class="tdraw__quota-pct">{{ studentQuota.percent }}%</span>
|
||||
</div>
|
||||
<div class="tdraw__quota-note">
|
||||
实时值(按身份证去重),不限周期
|
||||
</div>
|
||||
</div>
|
||||
<div class="tdraw__quota">
|
||||
<div class="tdraw__quota-label">
|
||||
存储
|
||||
</div>
|
||||
<div class="tdraw__quota-value" :class="quotaClass(storageQuota)">
|
||||
{{ storageQuota.used }} / {{ storageQuota.limit }} MB
|
||||
<span class="tdraw__quota-pct">{{ storageQuota.percent }}%</span>
|
||||
</div>
|
||||
<div class="tdraw__quota-note">
|
||||
实时值(材料规范件合计),不限周期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tdraw__counts">
|
||||
<span>OCR 识别 {{ usage.ocrCount }} 次</span>
|
||||
<span>短信 {{ usage.smsCount }} 条</span>
|
||||
<span class="tdraw__counts-period">({{ periodText }} {{ usage.period }})</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
告警逐条展示。level 2 = 已超限(红),1 = 接近上限(橙)——
|
||||
与服务端 `addAlert` 的阈值(0.8 / 1.0)对应。
|
||||
-->
|
||||
<ul v-if="usage.alerts.length" class="tdraw__alerts">
|
||||
<li
|
||||
v-for="(a, i) in usage.alerts"
|
||||
:key="`${a.type}-${i}`"
|
||||
:class="a.level === 2 ? 'tdraw__alert-item--over' : 'tdraw__alert-item--warn'"
|
||||
>
|
||||
<strong>{{ a.type === 'STUDENT' ? '学生数' : '存储' }}</strong>
|
||||
已占用 {{ a.ratio }}({{ a.used }} / {{ a.limit }}),
|
||||
{{ a.level === 2 ? '已超出套餐额度' : '接近套餐额度' }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!--
|
||||
notes 如实全部展示(灰色小字),不做折叠、不吞掉:
|
||||
它解释的是"为什么 ocrCount / smsCount 是 0"——
|
||||
藏起来运营就会以为那是"真的没用",然后拿假数据去谈续费。
|
||||
-->
|
||||
<ul v-if="usage.notes.length" class="tdraw__notes">
|
||||
<li v-for="(n, i) in usage.notes" :key="i">
|
||||
{{ n }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tdraw__head-id {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tdraw__empty {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.tdraw__scene {
|
||||
padding: 2px 8px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
background: #fff;
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tdraw__copy {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tdraw__usage-title {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用量区的外框。
|
||||
*
|
||||
* ⚠️ 这里**刻意不用 `NCard`**:抽屉里再套一张卡会出现两层"浮起 + 浅灰头",
|
||||
* 比不分组还乱(与开通弹窗的「首个管理员」块同一套写法)。
|
||||
*/
|
||||
.tdraw__band {
|
||||
padding: 14px 16px 16px;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tdraw__period {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tdraw__period-label {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__period-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.tdraw__period-hint {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__quotas {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tdraw__quota {
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tdraw__quota-label {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__quota-value {
|
||||
margin-top: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 接近上限(≥80%)标橙、超限(≥100%)标红 —— 与列表到期列的配色同一套口径 */
|
||||
.tdraw__quota--warn {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.tdraw__quota--over {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.tdraw__quota-pct {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tdraw__quota-note {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__counts {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--xt-text-2);
|
||||
}
|
||||
|
||||
.tdraw__counts span + span {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.tdraw__counts-period {
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tdraw__alerts,
|
||||
.tdraw__notes {
|
||||
padding-left: 18px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tdraw__alerts li,
|
||||
.tdraw__notes li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tdraw__alert-item--over {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.tdraw__alert-item--warn {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.tdraw__notes {
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormRules } from 'naive-ui'
|
||||
import type { TenantUpdateReq, TenantVO } from '@/apis/xt/tenant'
|
||||
import { NButton, NDatePicker, NForm, NFormItem, NInput, NModal, NSelect, NSpace, useMessage } from 'naive-ui'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import * as tenantApi from '@/apis/xt/tenant'
|
||||
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
|
||||
import { fmtDate } from '@/utils/format'
|
||||
|
||||
/**
|
||||
* 租户编辑(平台运营)。
|
||||
*
|
||||
* ## 三个"不显然"的地方
|
||||
*
|
||||
* 1. **联系电话留空 = 不修改,不是清空**。库里的 `contact_phone` 是 AES 密文列,
|
||||
* 而**列表接口返回的 `contactPhone` 恒为 null**(脱敏值只有详情接口给)——
|
||||
* 所以这里既不能回填、也不能把"看起来是空的"提交上去:
|
||||
* 一旦提交空白,服务端虽然按"空 = 不修改"跳过,但界面上必须说清,
|
||||
* 否则运营会以为自己在"清空电话"。要改就整条重填。
|
||||
* 2. **租户编码不可修改**,只能只读展示。它出现在小程序扫码落地页与对接文档里,
|
||||
* 改了会让已经发出去的文档和配好的域名全部失效 —— 所以连输入框都不给,
|
||||
* 而不是"给个禁用输入框"(禁用输入框会让人以为"申请一下就能解锁")。
|
||||
* 3. **状态能改成"试用"(2)**。后端 `TenantUsageService.update` 里放行 0/1/2,
|
||||
* 少给一个选项运营就没法把客户从试用转正,也没法退回试用。
|
||||
*/
|
||||
defineOptions({ name: 'TenantEditModal' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 用 `v-model:show` 与父页面同步;父页面负责传当前行的租户 */
|
||||
show: boolean
|
||||
/** 要编辑的租户(列表行即可 —— `expireTime` / `status` 列表接口都给) */
|
||||
tenant: TenantVO | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const formRef = ref<InstanceType<typeof NForm> | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
/**
|
||||
* 表单模型。
|
||||
*
|
||||
* `expireTime` 声明成 `string | null`:`n-date-picker` 清空时回传的是 `null`,
|
||||
* 声明成 `string` 运行期没事、但 vue-tsc 会在 `v-model` 上报类型不兼容。
|
||||
*/
|
||||
interface TenantEditForm {
|
||||
tenantName: string
|
||||
tenantType: number
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
expireTime: string | null
|
||||
status: number
|
||||
}
|
||||
|
||||
function emptyForm(): TenantEditForm {
|
||||
return {
|
||||
tenantName: '',
|
||||
tenantType: 1,
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
expireTime: null,
|
||||
status: 1
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive<TenantEditForm>(emptyForm())
|
||||
|
||||
/**
|
||||
* ⚠️ 类型 / 状态这两个数字下拉的规则**必须**写 `type: 'number'`。
|
||||
*
|
||||
* 只写 `required` 时 async-validator 会把 `rule.type` 默认成 `'string'`
|
||||
* (`Schema.validate` 里的 `rule.type = this.getType(rule)`),接着按
|
||||
* `typeof value !== rule.type` 判错 —— 而选项的 value 是数字。
|
||||
* 结果是"看起来填了、就是提交不出去",且错误文案被 `#feedback` 插槽挡着看不见。
|
||||
*
|
||||
* `contactPhone` **刻意没有** required:它留空的含义是"不修改",
|
||||
* 加了必填就等于逼运营重填一遍电话(那是密文,我们拿不到明文回填)。
|
||||
*/
|
||||
const rules: FormRules = {
|
||||
tenantName: [{ required: true, message: '请填写单位名称', trigger: ['blur', 'input'] }],
|
||||
tenantType: [{ type: 'number', required: true, message: '请选择类型', trigger: ['change'] }],
|
||||
status: [{ type: 'number', required: true, message: '请选择状态', trigger: ['change'] }]
|
||||
}
|
||||
|
||||
const typeOptions = Object.entries(TENANT_TYPE_NAMES).map(([code, name]) => ({
|
||||
label: name,
|
||||
value: Number(code)
|
||||
}))
|
||||
|
||||
/** 状态三项:0 停用 / 1 正常 / 2 试用(与后端放行的取值一字不差) */
|
||||
const statusOptions = Object.entries(TENANT_STATUS).map(([code, item]) => ({
|
||||
label: item.text,
|
||||
value: Number(code)
|
||||
}))
|
||||
|
||||
/** 把列表行的值灌进表单(打开时调用) */
|
||||
function fillFrom(row: TenantVO): void {
|
||||
Object.assign(form, emptyForm())
|
||||
form.tenantName = row.tenantName
|
||||
form.tenantType = row.tenantType
|
||||
form.contactName = row.contactName ?? ''
|
||||
// 刻意不回填:列表接口的 contactPhone 恒为 null(脱敏值只在详情接口给),
|
||||
// 回填一个空串会让人以为"这个单位没留电话",而其实是"我们看不到明文"
|
||||
form.contactPhone = ''
|
||||
form.expireTime = row.expireTime ? fmtDate(row.expireTime) : null
|
||||
form.status = row.status
|
||||
}
|
||||
|
||||
watch(() => props.show, (v) => {
|
||||
if (v && props.tenant) {
|
||||
fillFrom(props.tenant)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* ⚠️ 本仓的 `NFormItem` 一旦用了 `#feedback` 插槽,**校验错误会被完全遮住**
|
||||
* (插槽存在时 naive 只渲染插槽内容,不再渲染错误文案)。
|
||||
* 所以校验失败必须在这里自己把第一条错误弹出来 —— 否则界面上只剩一个红框,
|
||||
* 用户看到的是"点了保存没反应"。
|
||||
*/
|
||||
function firstError(errors: unknown): string | null {
|
||||
if (!Array.isArray(errors) || !errors.length) {
|
||||
return null
|
||||
}
|
||||
const first = errors[0] as { message?: unknown } | undefined
|
||||
return typeof first?.message === 'string' ? first.message : null
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch (errors) {
|
||||
message.error(firstError(errors) ?? '请检查表单填写')
|
||||
return
|
||||
}
|
||||
|
||||
const tenant = props.tenant
|
||||
if (!tenant) {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求体按"只传真的改了的语义"组装:
|
||||
* · `contactPhone` 留空就**不带这个字段**(后端"非空才写",带空串等于没传,但显式省略更清楚);
|
||||
* · `expireTime` 也只在有值时才带 —— 日期控件清空时为 null,
|
||||
* 传 `''` 后端解析会炸,传 null 又会被 MyBatis-Plus 的默认更新策略忽略(清不掉)。
|
||||
* 所以"清空到期日"这件事本轮做不到,界面上也不承诺能清。
|
||||
*/
|
||||
const payload: TenantUpdateReq = {
|
||||
tenantName: form.tenantName.trim(),
|
||||
tenantType: form.tenantType,
|
||||
contactName: form.contactName.trim(),
|
||||
status: form.status
|
||||
}
|
||||
const phone = form.contactPhone.trim()
|
||||
if (phone) {
|
||||
payload.contactPhone = phone
|
||||
}
|
||||
if (form.expireTime) {
|
||||
payload.expireTime = fmtDate(form.expireTime)
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await tenantApi.updateTenant(tenant.id, payload)
|
||||
message.success('已保存')
|
||||
// 先让父页面重新拉列表,再关弹窗:否则关掉的一瞬间列表还是旧值
|
||||
emit('saved')
|
||||
emit('update:show', false)
|
||||
} catch {
|
||||
// 提示已由 http 层给出(如"状态只能是 0(停用)/ 1(正常)/ 2(试用)")
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
:show="props.show"
|
||||
preset="card"
|
||||
title="编辑租户"
|
||||
:style="{ width: 'min(620px, calc(100vw - 32px))' }"
|
||||
:mask-closable="false"
|
||||
@update:show="(v: boolean) => emit('update:show', v)"
|
||||
>
|
||||
<NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<div class="tedit__grid">
|
||||
<NFormItem label="单位名称" path="tenantName">
|
||||
<NInput v-model:value="form.tenantName" placeholder="如 江西机电职业技术学院" clearable />
|
||||
</NFormItem>
|
||||
<NFormItem label="类型" path="tenantType">
|
||||
<NSelect v-model:value="form.tenantType" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="联系人" path="contactName">
|
||||
<NInput v-model:value="form.contactName" clearable />
|
||||
</NFormItem>
|
||||
<NFormItem label="联系电话" path="contactPhone">
|
||||
<NInput v-model:value="form.contactPhone" placeholder="留空则不修改" clearable />
|
||||
<!--
|
||||
⚠️ 用了 `#feedback` 之后,naive **不再渲染校验错误文案**
|
||||
(插槽存在就以插槽为准)—— 所以 `onSubmit` 的 catch 里必须自己
|
||||
`message.error(...)` 把第一条错误弹出来,否则校验失败就是"点了没反应"。
|
||||
-->
|
||||
<template #feedback>
|
||||
<span class="tedit__fb">留空 = 不修改;填了就整条替换(库里存的是密文,看不到原文)</span>
|
||||
</template>
|
||||
</NFormItem>
|
||||
<NFormItem label="到期时间" path="expireTime">
|
||||
<NDatePicker
|
||||
v-model:formatted-value="form.expireTime"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
class="tedit__full"
|
||||
clearable
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="状态" path="status">
|
||||
<NSelect v-model:value="form.status" :options="statusOptions" />
|
||||
</NFormItem>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
租户编码只读展示。刻意用 NDescriptions 而不是禁用的 NInput:
|
||||
禁用输入框会让人以为"权限够了就能改",而这条编码是**永远不能改**的 ——
|
||||
它出现在小程序扫码落地页与对接文档里,改了会让已经发出去的文档、
|
||||
配好的域名全部失效,而那是客户那边的事,我们无法逐个通知到。
|
||||
-->
|
||||
<div class="tedit__readonly">
|
||||
<div class="tedit__readonly-label">
|
||||
租户编码
|
||||
</div>
|
||||
<div class="tedit__readonly-value">
|
||||
{{ props.tenant?.tenantCode || '—' }}
|
||||
</div>
|
||||
<div class="tedit__readonly-note">
|
||||
编码出现在小程序扫码落地页与对接文档里,开通后不可修改。
|
||||
</div>
|
||||
</div>
|
||||
</NForm>
|
||||
|
||||
<div class="tedit__footer">
|
||||
<NSpace justify="end">
|
||||
<NButton @click="emit('update:show', false)">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="submitting" @click="onSubmit">
|
||||
保存
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tedit__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.tedit__full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tedit__fb {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读的租户编码块。
|
||||
*
|
||||
* ⚠️ 这里**刻意不用 `NCard`**:弹窗本身就是一张卡片,卡里套卡会出现两层"浮起 + 浅灰头"。
|
||||
* 改用浅灰底 + 弱描边的小格子(与开通弹窗的「首个管理员」块同一套写法)。
|
||||
*/
|
||||
.tedit__readonly {
|
||||
padding: 12px 14px;
|
||||
background: var(--xt-band-bg);
|
||||
border: 1px solid var(--xt-card-border-weak);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tedit__readonly-label {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tedit__readonly-value {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tedit__readonly-note {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
.tedit__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NTag,
|
||||
@@ -23,8 +24,11 @@ import * as tenantApi from '@/apis/xt/tenant'
|
||||
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { fmtDate } from '@/utils/format'
|
||||
import { tagOf } from '@/utils/tag'
|
||||
import TenantDetailDrawer from './TenantDetailDrawer.vue'
|
||||
import TenantEditModal from './TenantEditModal.vue'
|
||||
|
||||
/**
|
||||
* 租户管理(仅平台运营)。
|
||||
@@ -35,11 +39,29 @@ import { tagOf } from '@/utils/tag'
|
||||
* 2. **租户列表是跨租户的**(`sys_tenant` 没有 `tenant_id`),权限把关全在服务端;
|
||||
* 学校管理员调它会拿到 40301,左侧菜单也不会给他这一项。
|
||||
*
|
||||
* ⚠️ 列表**没有操作列**:不提供编辑 / 停用入口(客户 2026-10 走查确认的口径)。
|
||||
* ⚠️ **口径变更(本轮,覆盖 2026-10 走查的旧结论)**:列表**有操作列**了 ——
|
||||
* 详情 / 编辑 / 停用·启用 三个入口。
|
||||
* **旧口径**是"列表不提供编辑 / 停用入口";推翻它的原因是运营的真实诉求:
|
||||
* ① 学校到期日变了,运营要能自己改,而不是每次找开发;
|
||||
* ② 客户欠费 / 停止合作时要能**当场停掉**(停用会立刻挡住这所学校的
|
||||
* 学生与老师登录,服务端还会为此单独记一条 WARN 日志);
|
||||
* ③ 每周核对"谁快到期了"时,光看列表不够,要能点进详情看用量。
|
||||
* 所以旧注释不是 rebase 丢的,是**被本轮需求明确改掉的**。
|
||||
* 权限门控:`tenant:view` 管「详情」,`tenant:manage` 管「编辑 / 停用 / 启用 / 开通」;
|
||||
* 拿不到 `tenant:manage` 的人操作列显示"只读",而不是留一个点了报 403 的按钮。
|
||||
*/
|
||||
defineOptions({ name: 'Tenant' })
|
||||
|
||||
const message = useMessage()
|
||||
const userStore = useUserStore()
|
||||
|
||||
/**
|
||||
* 权限门控(**不是**权限码的"定义",字符串必须与后端 `PermissionDef` 逐字一致)。
|
||||
*
|
||||
* 没有 `v-hasPerm` 指令,判断就是一次函数调用;`computed` 是为了模板里别每次渲染都算。
|
||||
*/
|
||||
const canView = computed(() => userStore.has('tenant:view'))
|
||||
const canManage = computed(() => userStore.has('tenant:manage'))
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -48,6 +70,28 @@ const total = ref(0)
|
||||
const statusFilter = ref<number | undefined>(undefined)
|
||||
const keyword = ref('')
|
||||
|
||||
/**
|
||||
* 「即将到期」筛选(默认 30 天内)。
|
||||
*
|
||||
* ⚠️ 它与状态筛选是**互斥的两个维度**:同时下发 `status` 与 `expireBefore`
|
||||
* 会让结果集小到运营以为"这批学校都停用了",而且界面上两个按钮同时高亮,
|
||||
* 谁也说不清到底在按什么筛。所以点一个就清掉另一个,并在工具条右侧
|
||||
* 用一行小字把"当前按什么筛的"写出来。
|
||||
*
|
||||
* 布尔量而不是 `expireBefore` 字符串:判断"是不是在按到期筛"只需要一个开关,
|
||||
* 存日期字符串反而要在多处比较是否等于"今天+30 天",第二天就失效。
|
||||
*/
|
||||
const expireOnly = ref(false)
|
||||
|
||||
/** `yyyy-MM-dd`(**含当天**:服务端 `parseExpireBefore` 的语义是 ≤ 这个日期) */
|
||||
function todayPlus30(): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 30)
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
|
||||
const query = reactive({ pageNum: 1, pageSize: 10 })
|
||||
|
||||
const visible = ref(false)
|
||||
@@ -55,6 +99,12 @@ const created = ref<TenantCreateResult | null>(null)
|
||||
const createdName = ref('')
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
|
||||
// 详情抽屉与编辑弹窗各自的状态(都靠 `v-model:show` 与子组件同步)
|
||||
const detailVisible = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
const editVisible = ref(false)
|
||||
const editing = ref<TenantVO | null>(null)
|
||||
|
||||
/**
|
||||
* 「首个管理员的登录账号」默认值**按租户编码生成**(`admin_jxjd`),不是写死的 `admin`。
|
||||
*
|
||||
@@ -134,6 +184,27 @@ const statusChips = computed(() => [
|
||||
{ label: '已停用', value: 0 }
|
||||
])
|
||||
|
||||
/**
|
||||
* 「即将到期」的阈值与状态筛选的 30 天必须是同一个数:
|
||||
* 列表里的橙色与按钮筛出来的行要对得上,否则会出现"筛出来的行一半不是橙的"。
|
||||
*/
|
||||
const EXPIRE_WARN_DAYS = 30
|
||||
|
||||
/**
|
||||
* 工具条右侧那行小字:把"当前在按什么筛"写出来。
|
||||
*
|
||||
* 为什么必须有:状态筛选与「即将到期」是互斥的两个维度,靠按钮高亮区分不够 ——
|
||||
* 两组按钮并排放着,用户很容易以为"我全选上了"。
|
||||
*/
|
||||
const filterHint = computed(() => {
|
||||
if (expireOnly.value) {
|
||||
// 把具体的截止日写出来:只说"即将到期",运营不知道到底包到哪一天
|
||||
return `当前:${todayPlus30()} 前到期(含当天)`
|
||||
}
|
||||
const chip = statusChips.value.find((c) => c.value === statusFilter.value)
|
||||
return statusFilter.value === undefined ? '' : `当前:${chip?.label ?? ''}`
|
||||
})
|
||||
|
||||
const columns: DataTableColumns<TenantVO> = [
|
||||
{
|
||||
title: '单位名称',
|
||||
@@ -154,8 +225,16 @@ const columns: DataTableColumns<TenantVO> = [
|
||||
{
|
||||
title: '到期',
|
||||
key: 'expireTime',
|
||||
width: 120,
|
||||
render: (row) => fmtDate(row.expireTime)
|
||||
width: 130,
|
||||
/**
|
||||
* 到期列的颜色提示:已过期(红)/ 30 天内到期(橙)/ 正常(灰)。
|
||||
*
|
||||
* 为什么只上色、不加文字:这一列只有 130px,塞进"还有 12 天"会把编码列挤走;
|
||||
* 而"快到期了"这件事运营是扫一眼列表找的,颜色足够,细节在详情抽屉里。
|
||||
* ⚠️ 阈值必须与工具条「即将到期」的 30 天一致,否则会出现
|
||||
* "按钮筛出来的行里有一半不是橙的"这种自相矛盾的画面。
|
||||
*/
|
||||
render: (row) => h('span', { class: expireClass(row.expireTime) }, fmtDate(row.expireTime))
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -165,9 +244,87 @@ const columns: DataTableColumns<TenantVO> = [
|
||||
const s = tagOf(TENANT_STATUS, row.status)
|
||||
return h(NTag, { size: 'small', bordered: false, type: s.type }, { default: () => s.text })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: actionCell
|
||||
}
|
||||
]
|
||||
|
||||
/** 一行里可用的操作数最多 3 个(详情/编辑/停用),横排间距小一点不折行 */
|
||||
const S_ACTIONS = 'display: flex; flex-wrap: wrap; gap: 4px 10px; justify-content: center'
|
||||
const S_MUTED = 'font-size: 12px; color: #8a8a8a'
|
||||
|
||||
/**
|
||||
* 到期列的样式类。
|
||||
*
|
||||
* 空值走 `expire--none`(与"正常"同一个灰)—— 没填到期日的租户不是"已过期",
|
||||
* 把它标红会让运营去追一个根本不存在的续费问题。
|
||||
*/
|
||||
function expireClass(expireTime: string | null | undefined): string {
|
||||
if (!expireTime) {
|
||||
return 'tenant__expire--none'
|
||||
}
|
||||
const end = Date.parse(`${expireTime.slice(0, 10)}T23:59:59`)
|
||||
if (Number.isNaN(end)) {
|
||||
return 'tenant__expire--none'
|
||||
}
|
||||
const days = (end - Date.now()) / 86400000
|
||||
if (days < 0) {
|
||||
return 'tenant__expire--over'
|
||||
}
|
||||
return days <= EXPIRE_WARN_DAYS ? 'tenant__expire--soon' : 'tenant__expire--none'
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内动作:`详情` / `编辑` / `停用`或`启用`。
|
||||
*
|
||||
* 三个刻意的地方:
|
||||
* · 按钮用 `text` + `size="small"`(本仓行内动作的统一写法,见「收集任务」页);
|
||||
* · 停用 / 启用走 `NPopconfirm` 二次确认,**停用的文案要写清后果** ——
|
||||
* 它一下挡住的是这所学校所有学生与老师的登录,不是一次普通的"改状态";
|
||||
* · 没有 `tenant:manage` 时显示"只读"而不是干脆不给这一列:
|
||||
* 平台运营里有只读账号(`tenant:view`),"什么都没有"会让人以为是页面坏了。
|
||||
*/
|
||||
function actionCell(row: TenantVO) {
|
||||
const items = []
|
||||
if (canView.value) {
|
||||
items.push(h(NButton, {
|
||||
text: true,
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => onDetail(row)
|
||||
}, { default: () => '详情' }))
|
||||
}
|
||||
if (canManage.value) {
|
||||
items.push(h(NButton, {
|
||||
text: true,
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => onEdit(row)
|
||||
}, { default: () => '编辑' }))
|
||||
// 已停用显示「启用」(主色),正常/试用显示「停用」(警示色)
|
||||
items.push(h(NPopconfirm, {
|
||||
onPositiveClick: () => onToggleStatus(row)
|
||||
}, {
|
||||
trigger: () => h(NButton, {
|
||||
text: true,
|
||||
size: 'small',
|
||||
type: row.status === 0 ? 'primary' : 'warning'
|
||||
}, { default: () => (row.status === 0 ? '启用' : '停用') }),
|
||||
default: () => (row.status === 0
|
||||
? '恢复这所学校的登录?学生与老师马上就能重新登录。'
|
||||
: '停用后该校所有学生与老师会立刻无法登录,确认停用?')
|
||||
}))
|
||||
}
|
||||
if (!items.length) {
|
||||
return h('span', { style: S_MUTED }, '只读')
|
||||
}
|
||||
return h('div', { style: S_ACTIONS }, items)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页器统一走 `listPagination`(见 `@/composables/pagination`)——
|
||||
* 手写 `prefix` 的类型最容易写错(naive 给的 `itemCount` 是 `number | undefined`)。
|
||||
@@ -198,7 +355,8 @@ async function load(): Promise<void> {
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
status: statusFilter.value
|
||||
status: statusFilter.value,
|
||||
expireBefore: expireOnly.value ? todayPlus30() : undefined
|
||||
})
|
||||
list.value = page.list ?? []
|
||||
total.value = page.total ?? 0
|
||||
@@ -212,11 +370,56 @@ function search(): void {
|
||||
void load()
|
||||
}
|
||||
|
||||
/** 点状态筛选:清掉「即将到期」,否则两个维度叠加后结果集小得让人以为数据丢了 */
|
||||
function setStatus(v: number | undefined): void {
|
||||
expireOnly.value = false
|
||||
statusFilter.value = v
|
||||
search()
|
||||
}
|
||||
|
||||
/** 点「即将到期」:这是"切换",再点一次取消;同时清掉状态筛选(互斥的另一半) */
|
||||
function toggleExpireOnly(): void {
|
||||
expireOnly.value = !expireOnly.value
|
||||
if (expireOnly.value) {
|
||||
statusFilter.value = undefined
|
||||
}
|
||||
search()
|
||||
}
|
||||
|
||||
function onDetail(row: TenantVO): void {
|
||||
detailId.value = row.id
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function onEdit(row: TenantVO): void {
|
||||
editing.value = row
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用 / 启用。
|
||||
*
|
||||
* 只发 `status` 一个字段:`PUT /tenants/{id}` 是**部分更新**(非 null 才写),
|
||||
* 所以不需要把整行原样回传 —— 回传反而会把 `contactName` 这类
|
||||
* "列表里是空、库里其实有值"的字段覆盖成空。
|
||||
* 也**不能**用 `updateTenant` 去改 `expireTime`:那是编辑弹窗的事。
|
||||
*/
|
||||
async function onToggleStatus(row: TenantVO): Promise<void> {
|
||||
const next = row.status === 0 ? 1 : 0
|
||||
try {
|
||||
await tenantApi.updateTenant(row.id, { status: next })
|
||||
message.success(next === 0 ? '已停用,该校用户已无法登录' : '已启用')
|
||||
await load()
|
||||
} catch {
|
||||
// 提示已由 http 层给出
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑保存成功:重新拉列表(到期日 / 状态都可能变了) */
|
||||
function onEdited(): void {
|
||||
void load()
|
||||
}
|
||||
|
||||
function onAdd(): void {
|
||||
Object.assign(form, emptyForm())
|
||||
// 每次打开弹窗都重新开始"跟着编码走"的状态,否则第二次打开时人会发现自己改不动账号名
|
||||
@@ -307,22 +510,33 @@ onMounted(load)
|
||||
工具条:主操作在左、筛选在右(与收集任务页同一口径)。
|
||||
同一行里所有控件统一**默认尺寸** —— 全是 28px 的小控件挤在 52px 的一行里,
|
||||
白底小按钮贴在白底卡片上几乎看不出是按钮。
|
||||
|
||||
「即将到期」与状态按钮组是**互斥的两个筛选维度**(点一个清另一个),
|
||||
所以它们之间用一条竖线分开,右侧再用一行小字说明"当前在按什么筛"——
|
||||
光靠按钮高亮区分不够,用户会以为两组是叠加的。
|
||||
-->
|
||||
<div class="toolbar">
|
||||
<NSpace :size="8">
|
||||
<NButton type="primary" @click="onAdd">
|
||||
<NButton v-if="canManage" type="primary" @click="onAdd">
|
||||
<template #icon>
|
||||
<AppIcon name="plus" :size="16" />
|
||||
</template>
|
||||
开通租户
|
||||
</NButton>
|
||||
<NButton
|
||||
:type="expireOnly ? 'primary' : 'default'"
|
||||
@click="toggleExpireOnly"
|
||||
>
|
||||
即将到期
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NSpace :size="8" align="center">
|
||||
<span v-if="filterHint" class="tenant__filter-hint">{{ filterHint }}</span>
|
||||
<NButton
|
||||
v-for="c in statusChips"
|
||||
:key="String(c.value)"
|
||||
:type="statusFilter === c.value ? 'primary' : 'default'"
|
||||
:type="!expireOnly && statusFilter === c.value ? 'primary' : 'default'"
|
||||
@click="setStatus(c.value)"
|
||||
>
|
||||
{{ c.label }}
|
||||
@@ -342,6 +556,8 @@ onMounted(load)
|
||||
<!--
|
||||
⚠️ Naive 的表格**没有** `scroll` prop(那是 Arco 的 API):
|
||||
横向用 `:scroll-x`(数值),纵向靠 `flex-height` + 高度链。
|
||||
加了「操作」列(200px)后总宽从 800 涨到 1040,`:scroll-x` 要跟着改 ——
|
||||
不改的话窄屏下按钮会被压成竖排的两三个字。
|
||||
-->
|
||||
<NDataTable
|
||||
flex-height
|
||||
@@ -351,7 +567,7 @@ onMounted(load)
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="row => row.id"
|
||||
:scroll-x="800"
|
||||
:scroll-x="1040"
|
||||
:pagination="pagination"
|
||||
size="small"
|
||||
@update:page="onPage"
|
||||
@@ -359,6 +575,15 @@ onMounted(load)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
详情抽屉 / 编辑弹窗。
|
||||
两个都用 `v-model:show`(而不是像审核详情那样的 `defineExpose({ open })` 命令式打开):
|
||||
详情要跟着"点的是哪一行"走,`tenant-id` 一变就重拉 —— 命令式在那个场景下
|
||||
要么多一个 open(row) 的参数,要么在父组件里手工把 id 与开关对齐,更容易错。
|
||||
-->
|
||||
<TenantDetailDrawer v-model:show="detailVisible" :tenant-id="detailId" />
|
||||
<TenantEditModal v-model:show="editVisible" :tenant="editing" @saved="onEdited" />
|
||||
|
||||
<!-- 开通租户:成功态与表单态共用一个弹窗,弱化"关掉就看不到密码了"的风险 -->
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
@@ -500,6 +725,30 @@ onMounted(load)
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
/* 「当前按什么筛」那行小字。放在按钮组左边,读起来像"这批数据的条件" */
|
||||
.tenant__filter-hint {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 到期列的三档颜色。
|
||||
*
|
||||
* 用 `:deep()` 是因为它们在表格 render 出来的节点上(`scoped` 的属性选择器
|
||||
* 加不到 `h()` 生成的元素上 —— 这是本仓列表页的通用坑,不写就静默不上色)。
|
||||
*/
|
||||
:deep(.tenant__expire--over) {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
:deep(.tenant__expire--soon) {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
:deep(.tenant__expire--none) {
|
||||
color: var(--xt-text-2);
|
||||
}
|
||||
|
||||
.tenant__id,
|
||||
:deep(.tenant__id) {
|
||||
font-size: 12px;
|
||||
|
||||
Reference in New Issue
Block a user