From 624a05aa661edfc84d8cb7319ba08f275e4a385a Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 13 Oct 2026 14:04:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(pc):=20=E5=B9=B3=E5=8F=B0=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E7=9A=84=E7=A7=9F=E6=88=B7=E7=AE=A1=E7=90=86=EF=BC=88?= =?UTF-8?q?=E8=AF=A6=E6=83=85/=E7=94=A8=E9=87=8F/=E7=BC=96=E8=BE=91/?= =?UTF-8?q?=E5=81=9C=E7=94=A8=EF=BC=89=E4=B8=8E=E6=97=A5=E5=BF=97=E8=AF=A6?= =?UTF-8?q?=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:「设计开发平台运营人员对租户的管理功能,还有日志的详情查看功能。」 ⚠️ **口径变更**(覆盖 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 格式化成
(解析失败原样展示);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)两种映射;**学校管理员视角两张表都没有「单位」列**。
---
frontend-pc/.agents/README.md | 2 +-
frontend-pc/.agents/skills/code-vue/SKILL.md | 23 +
.../.agents/skills/project-guide/SKILL.md | 11 +-
frontend-pc/AGENTS.md | 14 +-
frontend-pc/src/apis/xt/log.ts | 70 +-
frontend-pc/src/apis/xt/tenant.ts | 60 ++
frontend-pc/src/config/xt-routes.ts | 5 +-
frontend-pc/src/views/log/LogDetailDrawer.vue | 385 ++++++++++
frontend-pc/src/views/log/index.vue | 685 ++++++++++++++----
.../src/views/tenant/TenantDetailDrawer.vue | 585 +++++++++++++++
.../src/views/tenant/TenantEditModal.vue | 310 ++++++++
frontend-pc/src/views/tenant/index.vue | 263 ++++++-
12 files changed, 2246 insertions(+), 167 deletions(-)
create mode 100644 frontend-pc/src/views/log/LogDetailDrawer.vue
create mode 100644 frontend-pc/src/views/tenant/TenantDetailDrawer.vue
create mode 100644 frontend-pc/src/views/tenant/TenantEditModal.vue
diff --git a/frontend-pc/.agents/README.md b/frontend-pc/.agents/README.md
index 858fd4a..5c47d9c 100644
--- a/frontend-pc/.agents/README.md
+++ b/frontend-pc/.agents/README.md
@@ -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 # 提交规范(与框架无关)
diff --git a/frontend-pc/.agents/skills/code-vue/SKILL.md b/frontend-pc/.agents/skills/code-vue/SKILL.md
index b3cb502..719e21c 100644
--- a/frontend-pc/.agents/skills/code-vue/SKILL.md
+++ b/frontend-pc/.agents/skills/code-vue/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
diff --git a/frontend-pc/.agents/skills/project-guide/SKILL.md b/frontend-pc/.agents/skills/project-guide/SKILL.md
index df39480..56c65be 100644
--- a/frontend-pc/.agents/skills/project-guide/SKILL.md
+++ b/frontend-pc/.agents/skills/project-guide/SKILL.md
@@ -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 状态分开维护。
## 开发
diff --git a/frontend-pc/AGENTS.md b/frontend-pc/AGENTS.md
index 975a8dc..964739d 100644
--- a/frontend-pc/AGENTS.md
+++ b/frontend-pc/AGENTS.md
@@ -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**(``)。
@@ -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 页。
diff --git a/frontend-pc/src/apis/xt/log.ts b/frontend-pc/src/apis/xt/log.ts
index 97edb3f..60781e4 100644
--- a/frontend-pc/src/apis/xt/log.ts
+++ b/frontend-pc/src/apis/xt/log.ts
@@ -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('/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 = {
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']
diff --git a/frontend-pc/src/apis/xt/tenant.ts b/frontend-pc/src/apis/xt/tenant.ts
index b0b13aa..7ef0596 100644
--- a/frontend-pc/src/apis/xt/tenant.ts
+++ b/frontend-pc/src/apis/xt/tenant.ts
@@ -59,6 +59,66 @@ export function getTenantDetail(id: number) {
return http.get(`/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(`/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(`/tenants/${id}/usage`, period ? { period } : {})
+}
+
/**
* 开通租户:一次请求完成「建租户 + 发放 6 个内置角色 + 创建首个管理员」。
* 返回的 `adminInitialPassword` 只在这一次响应里,界面必须让用户当场记下。
diff --git a/frontend-pc/src/config/xt-routes.ts b/frontend-pc/src/config/xt-routes.ts
index c6d21ec..0958637 100644
--- a/frontend-pc/src/config/xt-routes.ts
+++ b/frontend-pc/src/config/xt-routes.ts
@@ -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',
diff --git a/frontend-pc/src/views/log/LogDetailDrawer.vue b/frontend-pc/src/views/log/LogDetailDrawer.vue
new file mode 100644
index 0000000..6b9f3a1
--- /dev/null
+++ b/frontend-pc/src/views/log/LogDetailDrawer.vue
@@ -0,0 +1,385 @@
+
+
+
+
+
+
+ 日志详情
+
+ {{ actTag.text }}
+
+ {{ fmtTime(log.createTime) }}
+
+
+
+
+ 基本信息
+
+
+
+ {{ fmtTime(log.createTime) }}
+
+
+ {{ log.id }}
+
+
+ {{ tenantText }}
+
+
+ {{ operatorText }}
+
+
+ {{ moduleText }}
+
+
+
+ {{ actTag.text }}
+
+
+
+ {{ targetText }}
+
+
+
+
+ 变更前后
+
+ 证件号/手机号已在服务端脱敏;超过 512 字符的值在服务端就被截断了
+
+
+
+
+
+ 变更前
+
+ 复制
+
+
+
+ {{ log.beforeVal || '—' }}
+
+
+
+
+ 变更后
+
+ 复制
+
+
+
+ {{ log.afterVal || '—' }}
+
+
+
+
+
+ 请求
+
+
+ {{ log.requestMethod || '—' }}
+ {{ log.requestUri || '—' }}
+
+
+
+ 请求参数 / 耗时:本版本未采集
+
+
+
+ 来源
+
+
+
+ {{ log.ip || '—' }}
+
+
+
+ {{ log.userAgent || '—' }}
+
+
+
+
+ 排查信息
+
+
+
+ {{ log.traceId || '—' }}
+
+ 复制
+
+
+
+ {{ log.bizCode ?? '—' }}
+
+
+
+
+
+
+ 补充上下文
+
+ {{ extraPretty }}
+
+
+
+
+
+
+
diff --git a/frontend-pc/src/views/log/index.vue b/frontend-pc/src/views/log/index.vue
index 19e0bbd..a6a39a9 100644
--- a/frontend-pc/src/views/log/index.vue
+++ b/frontend-pc/src/views/log/index.vue
@@ -1,6 +1,6 @@
- 操作日志
+ 日志
-
+
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
+
+ 登录日志用来回答「谁在什么时候从哪里登了进来」;IP 归属地一期没有采集,所以没有这一列
+
+
+
+
+
+
+
diff --git a/frontend-pc/src/views/tenant/TenantEditModal.vue b/frontend-pc/src/views/tenant/TenantEditModal.vue
new file mode 100644
index 0000000..117416b
--- /dev/null
+++ b/frontend-pc/src/views/tenant/TenantEditModal.vue
@@ -0,0 +1,310 @@
+
+
+
+ emit('update:show', v)"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 留空 = 不修改;填了就整条替换(库里存的是密文,看不到原文)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 租户编码
+
+
+ {{ props.tenant?.tenantCode || '—' }}
+
+
+ 编码出现在小程序扫码落地页与对接文档里,开通后不可修改。
+
+
+
+
+
+
+
+
+
diff --git a/frontend-pc/src/views/tenant/index.vue b/frontend-pc/src/views/tenant/index.vue
index 3cb50cf..e8bebf5 100644
--- a/frontend-pc/src/views/tenant/index.vue
+++ b/frontend-pc/src/views/tenant/index.vue
@@ -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(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(null)
const createdName = ref('')
const formRef = ref(null)
+// 详情抽屉与编辑弹窗各自的状态(都靠 `v-model:show` 与子组件同步)
+const detailVisible = ref(false)
+const detailId = ref(null)
+const editVisible = ref(false)
+const editing = ref(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 = [
{
title: '单位名称',
@@ -154,8 +225,16 @@ const columns: DataTableColumns = [
{
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 = [
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 {
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 {
+ 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 的一行里,
白底小按钮贴在白底卡片上几乎看不出是按钮。
+
+ 「即将到期」与状态按钮组是**互斥的两个筛选维度**(点一个清另一个),
+ 所以它们之间用一条竖线分开,右侧再用一行小字说明"当前在按什么筛"——
+ 光靠按钮高亮区分不够,用户会以为两组是叠加的。
-->
+
+
+
+