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 isPlatformAdmin()} = "当前上下文的租户就是平台租户" ——
+ * 它是租户拦截器要不要整体关掉过滤的开关(跨租户看数据);
+ * - {@code isPlatformStaff()} = "这个人是平台运营" ——
+ * 数据范围是全量,但**租户过滤按当前上下文**照常生效。
+ *
+ *
+ * 代管某所学校时(请求头 {@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 对平台运营要求的恰恰是「全量操作日志 ✅」。
+ *
+ *
规则三条:
+ *
+ * - 有 {@code log:viewAll}(平台运营):默认全量;显式传 {@code tenantId} 时只看那一所;
+ * - 只有 {@code log:viewTenant}(学校管理员、教务处):恒为本租户;
+ * - 学校管理员传了**别的**租户 id → 直接 {@code 40301}。
+ * 不静默忽略:静默忽略会让人以为"我按这个单位筛过了",其实没筛 ——
+ * 而这一屏是拿去回答合规问题的,不能有这种"看起来筛了"的错觉。
+ *
+ */
+ 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 @@
-
-
-
-
-
-
- {{ tab.title }}({{ tab.count }})
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
-
- {{ menu?.entryAt ? '功能已就绪' : milestone }}
-
+ {{ milestone }}
-
-
-
- 这个功能已经有了,只是入口不在这里。
- {{ menu?.note }}
-
-
-
+
这个页面还没做。
{{ menu?.note ?? '该功能尚未实现。' }}
-
-
-
- 去「{{ entryTitle }}」
-
-
{{ menu?.entryAt?.hint }}
-
-
-
+
左侧菜单是按 服务端返回的角色权限 渲染的,所以你能看到它,
说明你的角色确实有这个权限 —— 只是界面还没排到。
@@ -53,7 +27,7 @@