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 @@ + + + + + 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 @@ 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 @@ + + + + + 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 的一行里, 白底小按钮贴在白底卡片上几乎看不出是按钮。 + + 「即将到期」与状态按钮组是**互斥的两个筛选维度**(点一个清另一个), + 所以它们之间用一条竖线分开,右侧再用一行小字说明"当前在按什么筛"—— + 光靠按钮高亮区分不够,用户会以为两组是叠加的。 -->
- + 开通租户 + + 即将到期 + + {{ filterHint }} {{ c.label }} @@ -342,6 +556,8 @@ onMounted(load)
+ + + +