From 0bbac7d228250d902e6f2b84430585b6441dfd86 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Fri, 9 Oct 2026 17:29:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(pc,platform):=20=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E8=B5=B0=E6=9F=A5=E4=BF=AE=E5=A4=8D=EF=BC=88=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E6=8C=89=E5=AD=A6=E6=A0=A1=E4=BB=A3=E7=AE=A1=20+=20=E8=8F=9C?= =?UTF-8?q?=E5=8D=95/403/=E6=97=A5=E5=BF=97=E8=8C=83=E5=9B=B4/=E5=88=86?= =?UTF-8?q?=E9=A1=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户在验收里点出来的问题,一次改完。两段改动: 一、平台运营与租户范围(后端为主) - 新增请求头 X-Tenant-Id:平台运营是唯一跨租户身份,租户拦截器对它整体关掉了过滤, 于是「组织架构 / 账号管理 / 工作台」拿到的是所有租户混在一起的数据 —— 而只有一所 学校有数据时,它和学校管理员看到的完全一样,看不出错(这是最危险的一点)。 现在这些页面必须先选定学校,服务端再把本次请求的租户上下文换成目标租户: 只认平台身份(其它身份带头一律忽略)、目标租户必须存在且启用。 - OrgService.tree / UserService.list 补显式 tenant_id 条件(原来只靠拦截器,而拦截器对 平台不生效);TaskService.seesWholeTenant 改用新的 LoginUser.isPlatformStaff() —— isPlatformAdmin 回答"当前租户是不是平台租户",代管时它为 false,会让平台退化成普通 用户(任务列表掉进"班主任只看自己带的班"分支,得到空列表);PLATFORM_ADMIN 补 TASK_VIEW(PRD §3.2「查看填报进度看板:平台运营 ✅」)。 - 操作日志对平台永远是空的:SQL 写死 tenant_id = 当前租户,而平台的 tenantId 是 0。 按契约 §13.4 补 tenantId 参数与 log:viewAll:平台默认看全量、可指定某所学校; 学校管理员恒为本租户,传别家返回 40301(不静默忽略,否则会让人以为"筛过了")。 - 开放接口"推送重试"从未生效(F-112):定时扫描缺 @InterceptorIgnore,异常被 log.warn 吞掉;同一方法里"接入方已停用"那条 update 也没有租户上下文,会每 10 秒重试到永远。 E2E 补了"真的重试了"的断言(原来只断言"安排了下次重试")。 二、PC 端走查体验 - 去掉 3 条无用菜单(名单管理 / 表单设计器 / 材料规则):功能都在「收集任务」行内入口, 菜单里那一条点进去只有指路;后端菜单键同步删除,entryAt 那套机制一并删掉。 - 班主任点工作台必弹 403:工作台无条件调了要 org:view / progress:viewAll 的两个接口。 403 走 HTTP 状态码,try/catch 拦不住它的弹窗 —— 改成按权限决定发不发请求。 - 操作日志:分页器被裁在可视区外(新增 .g-list-card 让表体自己滚、表头钉住、分页器留在 卡片底部)+ 去掉页面上那段免责声明;收集任务的状态切换改成单独一行 tab;去掉顶栏假数据 铃铛;配色按客户要求还原。 顺带修两处"检查器自己不严":check-wiring 新增第 ⑦ 项(PC 端引用的权限码必须存在于 PermissionDef —— 本轮我自己把 progress:viewAll 写成 progress:view:all,判断恒为 false, 而编译 / typecheck / lint / E2E 全都不红);verify-docs 的表格列数检查不再把 GFM 转义 竖线当成列分隔符。两个检查器都拿"故意写错的样例"试过,确认真的会红。 验证:typecheck / mvn package / check-wiring(未接线声明 0 条)/ verify-docs(22 项)全过; e2e-m0 36、e2e-m1-orgs 48、e2e-m0-user 55、e2e-m5-logs 44、e2e-m5-notify-wiring 43、 e2e-m1-tenant-domain 6、e2e-m8-open 61 全绿;浏览器按角色逐个真点复验。 详见 docs/交接摘要.md §一·三十三 与 §一·三十四。 --- .../fill/common/context/LoginUser.java | 22 ++ .../module/log/controller/LogController.java | 63 ++++- .../module/log/mapper/LoginLogMapper.java | 10 +- .../module/log/mapper/OperationLogMapper.java | 19 +- .../module/open/mapper/OpenPushLogMapper.java | 16 +- .../module/open/service/OpenPushService.java | 17 +- .../fill/module/org/service/OrgService.java | 10 + .../fill/module/task/service/TaskService.java | 12 +- .../fill/module/user/service/UserService.java | 6 +- .../fill/security/AuthInterceptor.java | 16 ++ .../fill/security/BuiltinRole.java | 27 +- .../fill/support/ActingTenantResolver.java | 88 ++++++ docs/00-交付计划.md | 1 + docs/03-接口设计.md | 30 ++- docs/交接摘要.md | 252 ++++++++++++++++++ frontend-pc/src/apis/xt/acting-tenant.ts | 32 +++ frontend-pc/src/apis/xt/audit.ts | 5 +- frontend-pc/src/apis/xt/org.ts | 23 +- frontend-pc/src/apis/xt/task.ts | 16 +- frontend-pc/src/apis/xt/user.ts | 41 ++- frontend-pc/src/components.d.ts | 2 + .../GiTenantPicker/GiTenantPicker.vue | 74 +++++ frontend-pc/src/config/xt-routes.ts | 72 +---- .../components/HeaderRightBar/Notice.vue | 85 ------ .../components/HeaderRightBar/index.vue | 20 +- frontend-pc/src/stores/index.ts | 1 + .../src/stores/useActingTenantStore.ts | 40 +++ frontend-pc/src/stores/useUserStore.ts | 4 + frontend-pc/src/styles/global.scss | 47 ++++ frontend-pc/src/views/coming-soon/index.vue | 81 ++---- frontend-pc/src/views/dashboard/index.vue | 137 ++++++++-- frontend-pc/src/views/log/index.vue | 85 ++++-- frontend-pc/src/views/org/OrgFormModal.vue | 11 +- frontend-pc/src/views/org/index.vue | 74 ++++- frontend-pc/src/views/task/index.vue | 81 +++++- frontend-pc/src/views/user/UserFormModal.vue | 31 ++- .../src/views/user/UserImportModal.vue | 14 +- frontend-pc/src/views/user/index.vue | 57 +++- scripts/check-wiring.py | 103 ++++++- scripts/e2e-m8-open.py | 20 ++ scripts/verify-docs.py | 14 +- 41 files changed, 1387 insertions(+), 372 deletions(-) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/ActingTenantResolver.java create mode 100644 frontend-pc/src/apis/xt/acting-tenant.ts create mode 100644 frontend-pc/src/components/GiTenantPicker/GiTenantPicker.vue delete mode 100644 frontend-pc/src/layout/components/HeaderRightBar/Notice.vue create mode 100644 frontend-pc/src/stores/useActingTenantStore.ts diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java index 256510f..3d275d6 100644 --- a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java @@ -30,6 +30,28 @@ public record LoginUser( return tenantId != null && tenantId == PLATFORM_TENANT_ID; } + /** + * 这个人是不是平台运营 —— 包括"正在代管某所学校"的那种。 + * + *

⚠️ 与 {@link #isPlatformAdmin()} 的区别非常重要,两者回答的是不同的问题: + *

+ * + *

代管某所学校时(请求头 {@code X-Tenant-Id},见 {@code ActingTenantResolver}) + * tenantId 被换成那所学校,于是 {@code isPlatformAdmin()} 变成 false。 + * 这时如果各处只判前者,平台就会**退化成"某个普通用户"**:例如任务列表会走 + * "班主任只看自己带的班"那条分支,而平台在该校没有班级 → 空列表(看起来像"这所学校没有任务")。 + * + *

所以凡是"按身份决定数据范围"的判断,用这一个;凡是"决定要不要关掉租户过滤"的,用前者。 + */ + public boolean isPlatformStaff() { + return isPlatformAdmin() || hasRole("PLATFORM_ADMIN"); + } + public boolean hasRole(String roleCode) { return roles != null && roles.contains(roleCode); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java index 00a98cc..93a9a39 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/controller/LogController.java @@ -1,12 +1,15 @@ package cn.tianbaotong.fill.module.log.controller; import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.common.api.ErrorCode; import cn.tianbaotong.fill.common.api.PageResult; import cn.tianbaotong.fill.common.context.LoginUser; import cn.tianbaotong.fill.common.context.UserContext; +import cn.tianbaotong.fill.common.exception.BizException; import cn.tianbaotong.fill.module.log.dto.LogDtos; import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper; import cn.tianbaotong.fill.module.log.mapper.OperationLogMapper; +import cn.tianbaotong.fill.security.PermissionChecker; import cn.tianbaotong.fill.security.PermissionDef; import cn.tianbaotong.fill.security.RequirePerm; import io.swagger.v3.oas.annotations.Operation; @@ -25,8 +28,8 @@ import java.util.List; /** * 日志查询(《03-接口设计》§13.4)。 * - *

权限用 {@code log:viewTenant}(本租户操作日志)。 - * 平台运维要看全量日志时用 {@code log:viewAll} —— 那是另一个接口(P2)。 + *

权限是**两个码的任一个**:{@code log:viewTenant}(本租户操作日志,学校管理员/教务处) + * 或 {@code log:viewAll}(全量,平台运营)。范围由 {@link #logScopeTenantId} 决定。 * 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。 * *

⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel @@ -41,15 +44,19 @@ public class LogController { private final OperationLogMapper operationLogMapper; private final LoginLogMapper loginLogMapper; + private final PermissionChecker permissionChecker; @GetMapping("/operations") - @RequirePerm(PermissionDef.LOG_VIEW_TENANT) + @RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL}) @Operation(summary = "操作日志", - description = "Query:`operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。" + description = "Query:`tenantId` `operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。" + "**按 targetType+targetId 查**是这个接口最重要的用法 —— " + "「这张名册项的证件号被谁从什么改成了什么」要靠它回答," - + "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)") + + "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)。" + + "⚠️ 范围:只有 `log:viewAll`(平台运营)能跨租户看**全量**," + + "也只有它能用 `tenantId` 指定某所学校;学校管理员恒为本租户") public ApiResult> operations( + @RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long operatorId, @RequestParam(required = false) String module, @RequestParam(required = false) String action, @@ -59,20 +66,48 @@ public class LogController { @RequestParam(required = false) String to, @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "20") int pageSize) { - LoginUser me = UserContext.require(); + Long scope = logScopeTenantId(tenantId); int pn = Math.max(1, pageNum); int ps = Math.min(Math.max(1, pageSize), 200); LocalDateTime f = parseFrom(from); LocalDateTime t = parseTo(to); List list = operationLogMapper.listOperations( - me.tenantId(), operatorId, module, action, targetType, targetId, + scope, operatorId, module, action, targetType, targetId, f, t, ps, (pn - 1) * ps); long total = operationLogMapper.countOperations( - me.tenantId(), operatorId, module, action, targetType, targetId, f, t); + scope, operatorId, module, action, targetType, targetId, f, t); return ApiResult.ok(PageResult.of(list, total, pn, ps)); } + /** + * 日志查询的租户范围:{@code null} = 全量(跨租户)。 + * + *

这是"平台运营看不到任何日志"那个 bug 的修法(2026-10-09): + * 以前控制器恒传 {@code me.tenantId()},而平台运营的 tenantId 是 0, + * 日志却都挂在各租户下 → 列表永远空白、且不报错。 + * 而 PRD §3.2 对平台运营要求的恰恰是「全量操作日志 ✅」。 + * + *

规则三条: + *

    + *
  1. 有 {@code log:viewAll}(平台运营):默认全量;显式传 {@code tenantId} 时只看那一所;
  2. + *
  3. 只有 {@code log:viewTenant}(学校管理员、教务处):恒为本租户;
  4. + *
  5. 学校管理员传了**别的**租户 id → 直接 {@code 40301}。 + * 不静默忽略:静默忽略会让人以为"我按这个单位筛过了",其实没筛 —— + * 而这一屏是拿去回答合规问题的,不能有这种"看起来筛了"的错觉。
  6. + *
+ */ + private Long logScopeTenantId(Long requested) { + LoginUser me = UserContext.require(); + if (permissionChecker.has(PermissionDef.LOG_VIEW_ALL)) { + return requested; + } + if (requested != null && !requested.equals(me.tenantId())) { + throw new BizException(ErrorCode.FORBIDDEN, "只能查看本单位的日志"); + } + return me.tenantId(); + } + /** * 日期入参解析。 * @@ -108,13 +143,15 @@ public class LogController { } @GetMapping("/logins") - @RequirePerm(PermissionDef.LOG_VIEW_TENANT) + @RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL}) @Operation(summary = "登录日志", - description = "Query:`userId` `userType` `result` `from` `to` + 分页。" + description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。" + + "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。" + "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— " + "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。" + "所以「学生登录记录为空」是已知情况,不是查询条件写错了") public ApiResult> logins( + @RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long userId, @RequestParam(required = false) Integer userType, @RequestParam(required = false) Boolean result, @@ -122,7 +159,7 @@ public class LogController { @RequestParam(required = false) String to, @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "20") int pageSize) { - LoginUser me = UserContext.require(); + Long scope = logScopeTenantId(tenantId); int pn = Math.max(1, pageNum); int ps = Math.min(Math.max(1, pageSize), 200); LocalDateTime f = parseFrom(from); @@ -131,8 +168,8 @@ public class LogController { Integer ok = result == null ? null : (result ? 1 : 0); List list = loginLogMapper.listLogins( - me.tenantId(), userId, userType, ok, f, t, ps, (pn - 1) * ps); - long total = loginLogMapper.countLogins(me.tenantId(), userId, userType, ok, f, t); + scope, userId, userType, ok, f, t, ps, (pn - 1) * ps); + long total = loginLogMapper.countLogins(scope, userId, userType, ok, f, t); return ApiResult.ok(PageResult.of(list, total, pn, ps)); } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java index 5c10f51..9b34f4b 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java @@ -60,7 +60,10 @@ public interface LoginLogMapper extends BaseMapper { l.create_time AS loginTime FROM sys_login_log l LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0 - WHERE l.tenant_id = #{tenantId} + WHERE 1 = 1 + + AND l.tenant_id = #{tenantId} + AND l.user_id = #{userId} @@ -96,7 +99,10 @@ public interface LoginLogMapper extends BaseMapper { SELECT COUNT(*) FROM sys_login_log l LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0 - WHERE l.tenant_id = #{tenantId} + WHERE 1 = 1 + + AND l.tenant_id = #{tenantId} + AND l.user_id = #{userId} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java index 841166e..b0f5f89 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java @@ -18,6 +18,15 @@ import java.util.List; *

查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}: * 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清, * 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。 + * + *

⚠️ {@code tenantId} 是可选条件({@code }): + * 契约 §13.4 本来就把 {@code tenantId} 定义成查询参数。不传 = 全量, + * 只有平台运营({@code log:viewAll})能走到这一支;学校管理员由 + * {@code LogController} 恒填本租户。 + * + *

以前这里写死 {@code l.tenant_id = #{tenantId}}、控制器恒传 {@code me.tenantId()} —— + * 而平台运营的 tenantId 是 0,于是它一条日志都看不到(列表空白, + * 没有任何报错),而 PRD §3.2 对平台运营要求的恰恰是「全量操作日志 ✅」。 */ public interface OperationLogMapper extends BaseMapper { @@ -57,7 +66,10 @@ public interface OperationLogMapper extends BaseMapper { l.create_time AS createTime FROM sys_operation_log l LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0 - WHERE l.tenant_id = #{tenantId} + WHERE 1 = 1 + + AND l.tenant_id = #{tenantId} + AND l.user_id = #{operatorId} @@ -100,7 +112,10 @@ public interface OperationLogMapper extends BaseMapper { + + diff --git a/frontend-pc/src/config/xt-routes.ts b/frontend-pc/src/config/xt-routes.ts index c4dc92c..7f4a72d 100644 --- a/frontend-pc/src/config/xt-routes.ts +++ b/frontend-pc/src/config/xt-routes.ts @@ -30,20 +30,6 @@ export interface XtMenu { ready: boolean /** 待建说明 */ note?: string - /** - * 这个功能**其实已经有了**,只是入口不在左侧菜单里(例如"名单管理"是按任务的, - * 入口在「收集任务」每一行的链接里)。 - * - * ⚠️ 有它的时候,「待建」页就不再显示"还没做",而是给出真实入口的指路 + 跳转按钮。 - * 没有它 = 确实还没做。两者的区别很重要:说"还没做"而功能其实在隔壁, - * 会让人**得出错误结论**(比找不到更糟)。 - */ - entryAt?: { - /** 跳到哪个路由 */ - path: string - /** 到了之后该点哪里(要具体到"点哪一行里的哪个链接") */ - hint: string - } } /** 分组顺序(决定左侧菜单顺序) */ @@ -67,22 +53,6 @@ export const XT_MENUS: XtMenu[] = [ sort: 11, ready: true }, - { - key: 'roster', - title: '名单管理', - icon: 'icon-park-outline:user-business', - path: '/collect/roster', - component: 'roster/index', - group: 'collect', - sort: 12, - ready: false, - note: '名册是按任务的:入口在「收集任务」每一行里点「名单管理」。' - + '导入、体检报告、名册列表都在那里;报考范围确认(M2-04/05)会接着做进同一个抽屉。', - entryAt: { - path: '/collect/task', - hint: '在「收集任务」里找到那个任务,点它这一行的「名单管理」' - } - }, { key: 'join', title: '加入申请', @@ -128,36 +98,6 @@ export const XT_MENUS: XtMenu[] = [ }, // ── 配置 ───────────────────────────────────────── - { - key: 'form', - title: '表单设计器', - icon: 'icon-park-outline:form-one', - path: '/setting/form', - component: 'form/index', - group: 'setting', - sort: 21, - ready: false, - note: '内置字段锁定、扩展字段自由 —— 按任务设计,入口在「收集任务」每一行的「表单设计」。', - entryAt: { - path: '/collect/task', - hint: '在「收集任务」里找到那个任务,点它这一行的「表单设计」' - } - }, - { - key: 'material', - title: '材料规则', - icon: 'icon-park-outline:file-editing', - path: '/setting/material', - component: 'material/index', - group: 'setting', - sort: 22, - ready: false, - note: '命名模板、别名、格式白名单、体积上限、压缩档位 —— 按任务配置,入口在「收集任务」每一行的「材料规则」。', - entryAt: { - path: '/collect/task', - hint: '在「收集任务」里找到那个任务,点它这一行的「材料规则」' - } - }, { key: 'cleanup', title: '历史整理器', @@ -338,17 +278,7 @@ function readyItem(menu: XtMenu): UserRouteItem { } as UserRouteItem } -/** 按菜单键取定义(给"待建"页显示说明用) */ -export function findMenu(key: string): XtMenu | undefined { - return XT_MENUS.find((m) => m.key === key) -} - -/** - * 按路由路径取菜单定义(「待建」页要显示"去哪个页面")。 - * - * ⚠️ 按钮上的目标页名**必须从这里取**,不要在页面里写死 —— - * 菜单哪天改名,写死的那个名字就会指向一个不存在的页面。 - */ +/** 按路由路径取菜单定义(「待建」页要显示说明用) */ export function findMenuByPath(path: string): XtMenu | undefined { return XT_MENUS.find((m) => m.path === path) } diff --git a/frontend-pc/src/layout/components/HeaderRightBar/Notice.vue b/frontend-pc/src/layout/components/HeaderRightBar/Notice.vue deleted file mode 100644 index 7112e24..0000000 --- a/frontend-pc/src/layout/components/HeaderRightBar/Notice.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - diff --git a/frontend-pc/src/layout/components/HeaderRightBar/index.vue b/frontend-pc/src/layout/components/HeaderRightBar/index.vue index 50de56f..cb2aa41 100644 --- a/frontend-pc/src/layout/components/HeaderRightBar/index.vue +++ b/frontend-pc/src/layout/components/HeaderRightBar/index.vue @@ -14,19 +14,12 @@ - - - - - - - - - + @@ -87,7 +80,6 @@ import { useFullscreen } from '@vueuse/core' import { useBreakpoint, useTheme } from '@/hooks' import { useAppStore, useUserStore } from '@/stores' import ChangePasswordModal from './ChangePasswordModal.vue' -import Notice from './Notice.vue' import SettingDrawerPanel from './SettingDrawerPanel.vue' /** 组件名称 */ diff --git a/frontend-pc/src/stores/index.ts b/frontend-pc/src/stores/index.ts index 04e3879..e05e0e5 100644 --- a/frontend-pc/src/stores/index.ts +++ b/frontend-pc/src/stores/index.ts @@ -1,6 +1,7 @@ import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' +export * from './useActingTenantStore' export * from './useAppStore' export * from './useDictStore' export * from './useMenuBadgeStore' diff --git a/frontend-pc/src/stores/useActingTenantStore.ts b/frontend-pc/src/stores/useActingTenantStore.ts new file mode 100644 index 0000000..fed823e --- /dev/null +++ b/frontend-pc/src/stores/useActingTenantStore.ts @@ -0,0 +1,40 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +/** + * 平台运营的「当前学校」。 + * + * ## 为什么要有这个状态 + * + * 平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户过滤对它整体关闭。 + * 所以「组织架构」「账号管理」这类**本身就是租户内**的页面,平台必须先指明看哪所学校 —— + * 否则拿到的是所有租户混在一起的数据,而且看不出错。 + * + * 这个值是**跟着请求走的**(`X-Tenant-Id` 请求头,见 `@/apis/xt/acting-tenant`), + * 不是登录态的一部分:token 里的 tenantId 始终是 0。 + * + * ## 为什么放在 store 而不是各页面自己 `ref` + * + * 两个页面都要用同一个选择。各写一份的话,用户在「组织架构」选了学校、 + * 切到「账号管理」又得重选一次 —— 而更糟的是**看起来像是两所学校的数据不一致**。 + * + * ⚠️ `persist: false`:刷新页面就忘掉。这是刻意的 —— + * 一个跨越登录会话的"当前学校"会在下次登录时静默生效, + * 而那时候用户心里想的可能是另一所学校。 + */ +const storeSetup = () => { + /** 当前代管的租户 id;`undefined` = 未选择(此时不应请求租户内数据) */ + const tenantId = ref(undefined) + + const setTenant = (id?: number) => { + tenantId.value = id + } + + const clear = () => { + tenantId.value = undefined + } + + return { tenantId, setTenant, clear } +} + +export const useActingTenantStore = defineStore('actingTenant', storeSetup, { persist: false }) diff --git a/frontend-pc/src/stores/useUserStore.ts b/frontend-pc/src/stores/useUserStore.ts index 5a8ba82..b0bc34d 100644 --- a/frontend-pc/src/stores/useUserStore.ts +++ b/frontend-pc/src/stores/useUserStore.ts @@ -10,6 +10,7 @@ import { buildXtRoutes } from '@/config/xt-routes' import { resetRouter } from '@/router' import { clearToken, getToken, setToken } from '@/utils/auth' import { isHttp } from '@/utils/validate' +import { useActingTenantStore } from './useActingTenantStore' import { useRouteStore } from './useRouteStore' /** 登录参数:password 必须是 RSA 加密后的密文(见 @/utils/rsa) */ @@ -101,6 +102,9 @@ const storeSetup = () => { resetRouter() profile.value = null menuKeys.value = [] + // 平台运营的"当前学校"属于**上一次身份**的上下文,退出时一起清掉: + // 留着它,下一个登录的人(可能是学校管理员)会继承一个自己没选过的租户 + useActingTenantStore().clear() } return true } diff --git a/frontend-pc/src/styles/global.scss b/frontend-pc/src/styles/global.scss index 0141919..5314720 100644 --- a/frontend-pc/src/styles/global.scss +++ b/frontend-pc/src/styles/global.scss @@ -190,6 +190,53 @@ margin-bottom: 12px; } +/** + * 列表页卡片(卡片里装着「工具栏 + 表格 + 分页器」)。 + * + * 为什么必须有它:`GiPageLayout` 的内容区是 `overflow: hidden`(页面本身不滚), + * 而一页 10~20 条比屏幕高 —— 不加这层高度链路,**分页器会被裁到可视区之外**: + * DOM 里明明有(`共 1245 条 … 20 条/页`),人却看不见,看着就像"分页器没做/坏了"。 + * 操作日志页(20 条/页)与收集任务页(加了状态 tab 之后)都踩过这一脚。 + * + * 用法:``,表格必须给 `:scroll="{ x: 宽度, y: '100%' }"`—— + * `y` 是字符串时 Arco 会把表头与表体**拆开**(内部 `splitTable`),表体自己滚、表头钉住; + * 不写 y 的话 `.arco-table-content` 的 `overflow-y` 是 hidden(那是横向滚动条专用的), + * 多出来的行会被直接裁掉且无法滚动。 + */ +.g-list-card { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + + .arco-card-body { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; + } + + // ⚠️ `.arco-spin` 必须在链路里:Arco 用 Spin 包住「表体 + 分页器」 + .arco-table, + .arco-spin { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + } + + .arco-table-container { + flex: 1; + min-height: 0; + } + + // 分页器不参与伸缩,永远留在卡片底部 + .arco-table-pagination { + flex: none; + } +} + // 以下这些类名要放到下面,涉及到css权重优先级问题 .g-mt { margin-top: var(--margin); diff --git a/frontend-pc/src/views/coming-soon/index.vue b/frontend-pc/src/views/coming-soon/index.vue index 270e5c7..e5de047 100644 --- a/frontend-pc/src/views/coming-soon/index.vue +++ b/frontend-pc/src/views/coming-soon/index.vue @@ -8,43 +8,17 @@ - - - - - + -

- - - 去「{{ entryTitle }}」 - -
{{ menu?.entryAt?.hint }}
-
- -

+

左侧菜单是按 服务端返回的角色权限 渲染的,所以你能看到它, 说明你的角色确实有这个权限 —— 只是界面还没排到。

@@ -53,7 +27,7 @@