feat(pc,platform): 验收走查修复(平台按学校代管 + 菜单/403/日志范围/分页)

客户在验收里点出来的问题,一次改完。两段改动:

一、平台运营与租户范围(后端为主)
- 新增请求头 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 §一·三十三 与 §一·三十四。
This commit is contained in:
2026-10-09 17:29:20 +08:00
parent db8aecc30a
commit 0bbac7d228
41 changed files with 1387 additions and 372 deletions
@@ -30,6 +30,28 @@ public record LoginUser(
return tenantId != null && tenantId == PLATFORM_TENANT_ID; return tenantId != null && tenantId == PLATFORM_TENANT_ID;
} }
/**
* 这个人是不是<b>平台运营</b> —— 包括"正在代管某所学校"的那种。
*
* <p>⚠️ 与 {@link #isPlatformAdmin()} 的区别非常重要,两者回答的是不同的问题:
* <ul>
* <li>{@code isPlatformAdmin()} = "当前上下文的租户<b>就是平台租户</b>" ——
* 它是租户拦截器要不要<b>整体关掉过滤</b>的开关(跨租户看数据);</li>
* <li>{@code isPlatformStaff()} = "这个人<b>是平台运营</b>" ——
* 数据范围是全量,但**租户过滤按当前上下文**照常生效。</li>
* </ul>
*
* <p>代管某所学校时(请求头 {@code X-Tenant-Id},见 {@code ActingTenantResolver})
* tenantId 被换成那所学校,于是 {@code isPlatformAdmin()} 变成 false。
* 这时如果各处只判前者,平台就会**退化成"某个普通用户"**:例如任务列表会走
* "班主任只看自己带的班"那条分支,而平台在该校没有班级 → 空列表(看起来像"这所学校没有任务")。
*
* <p>所以凡是"按身份决定数据范围"的判断,用这一个;凡是"决定要不要关掉租户过滤"的,用前者。
*/
public boolean isPlatformStaff() {
return isPlatformAdmin() || hasRole("PLATFORM_ADMIN");
}
public boolean hasRole(String roleCode) { public boolean hasRole(String roleCode) {
return roles != null && roles.contains(roleCode); return roles != null && roles.contains(roleCode);
} }
@@ -1,12 +1,15 @@
package cn.tianbaotong.fill.module.log.controller; package cn.tianbaotong.fill.module.log.controller;
import cn.tianbaotong.fill.common.api.ApiResult; 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.api.PageResult;
import cn.tianbaotong.fill.common.context.LoginUser; import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext; 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.dto.LogDtos;
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper; import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
import cn.tianbaotong.fill.module.log.mapper.OperationLogMapper; 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.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm; import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -25,8 +28,8 @@ import java.util.List;
/** /**
* 日志查询(《03-接口设计》§13.4)。 * 日志查询(《03-接口设计》§13.4)。
* *
* <p>权限用 {@code log:viewTenant}(本租户操作日志)。 * <p>权限是**两个码的任一个**:{@code log:viewTenant}(本租户操作日志,学校管理员/教务处)
* 平台运维要看全量日志时用 {@code log:viewAll} —— 那是另一个接口(P2)。 * 或 {@code log:viewAll}(全量,平台运营)。范围由 {@link #logScopeTenantId} 决定。
* 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。 * 日志里含**别人的操作轨迹与 IP**,不是所有管理员都该看到。
* *
* <p>⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel * <p>⚠️ 一期只做**查询**,不做导出:把操作日志导出成一个 Excel
@@ -41,15 +44,19 @@ public class LogController {
private final OperationLogMapper operationLogMapper; private final OperationLogMapper operationLogMapper;
private final LoginLogMapper loginLogMapper; private final LoginLogMapper loginLogMapper;
private final PermissionChecker permissionChecker;
@GetMapping("/operations") @GetMapping("/operations")
@RequirePerm(PermissionDef.LOG_VIEW_TENANT) @RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
@Operation(summary = "操作日志", @Operation(summary = "操作日志",
description = "Query:`operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。" description = "Query:`tenantId` `operatorId` `module` `action` `targetType` `targetId` `from` `to` + 分页。"
+ "**按 targetType+targetId 查**是这个接口最重要的用法 —— " + "**按 targetType+targetId 查**是这个接口最重要的用法 —— "
+ "「这张名册项的证件号被谁从什么改成了什么」要靠它回答," + "「这张名册项的证件号被谁从什么改成了什么」要靠它回答,"
+ "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)") + "而不是拿 request_param 全文搜(那会把「改了它」和「顺带提到它」混在一起)。"
+ "⚠️ 范围:只有 `log:viewAll`(平台运营)能跨租户看**全量**,"
+ "也只有它能用 `tenantId` 指定某所学校;学校管理员恒为本租户")
public ApiResult<PageResult<LogDtos.OperationLogVO>> operations( public ApiResult<PageResult<LogDtos.OperationLogVO>> operations(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long operatorId, @RequestParam(required = false) Long operatorId,
@RequestParam(required = false) String module, @RequestParam(required = false) String module,
@RequestParam(required = false) String action, @RequestParam(required = false) String action,
@@ -59,20 +66,48 @@ public class LogController {
@RequestParam(required = false) String to, @RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) { @RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require(); Long scope = logScopeTenantId(tenantId);
int pn = Math.max(1, pageNum); int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200); int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from); LocalDateTime f = parseFrom(from);
LocalDateTime t = parseTo(to); LocalDateTime t = parseTo(to);
List<LogDtos.OperationLogVO> list = operationLogMapper.listOperations( List<LogDtos.OperationLogVO> list = operationLogMapper.listOperations(
me.tenantId(), operatorId, module, action, targetType, targetId, scope, operatorId, module, action, targetType, targetId,
f, t, ps, (pn - 1) * ps); f, t, ps, (pn - 1) * ps);
long total = operationLogMapper.countOperations( 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)); return ApiResult.ok(PageResult.of(list, total, pn, ps));
} }
/**
* 日志查询的<b>租户范围</b>:{@code null} = 全量(跨租户)。
*
* <p>这是"平台运营看不到任何日志"那个 bug 的修法(2026-10-09):
* 以前控制器恒传 {@code me.tenantId()},而平台运营的 tenantId 是 <b>0</b>,
* 日志却都挂在各租户下 → 列表永远空白、且不报错。
* 而 PRD §3.2 对平台运营要求的恰恰是「<b>全量</b>操作日志 ✅」。
*
* <p>规则三条:
* <ol>
* <li>有 {@code log:viewAll}(平台运营):<b>默认全量</b>;显式传 {@code tenantId} 时只看那一所;</li>
* <li>只有 {@code log:viewTenant}(学校管理员、教务处):<b>恒为本租户</b>;</li>
* <li>学校管理员传了**别的**租户 id → 直接 {@code 40301}。
* <b>不静默忽略</b>:静默忽略会让人以为"我按这个单位筛过了",其实没筛 ——
* 而这一屏是拿去回答合规问题的,不能有这种"看起来筛了"的错觉。</li>
* </ol>
*/
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") @GetMapping("/logins")
@RequirePerm(PermissionDef.LOG_VIEW_TENANT) @RequirePerm({PermissionDef.LOG_VIEW_TENANT, PermissionDef.LOG_VIEW_ALL})
@Operation(summary = "登录日志", @Operation(summary = "登录日志",
description = "Query:`userId` `userType` `result` `from` `to` + 分页。" description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— " + "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。" + "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了") + "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins( public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long userId, @RequestParam(required = false) Long userId,
@RequestParam(required = false) Integer userType, @RequestParam(required = false) Integer userType,
@RequestParam(required = false) Boolean result, @RequestParam(required = false) Boolean result,
@@ -122,7 +159,7 @@ public class LogController {
@RequestParam(required = false) String to, @RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) { @RequestParam(defaultValue = "20") int pageSize) {
LoginUser me = UserContext.require(); Long scope = logScopeTenantId(tenantId);
int pn = Math.max(1, pageNum); int pn = Math.max(1, pageNum);
int ps = Math.min(Math.max(1, pageSize), 200); int ps = Math.min(Math.max(1, pageSize), 200);
LocalDateTime f = parseFrom(from); LocalDateTime f = parseFrom(from);
@@ -131,8 +168,8 @@ public class LogController {
Integer ok = result == null ? null : (result ? 1 : 0); Integer ok = result == null ? null : (result ? 1 : 0);
List<LogDtos.LoginLogVO> list = loginLogMapper.listLogins( List<LogDtos.LoginLogVO> list = loginLogMapper.listLogins(
me.tenantId(), userId, userType, ok, f, t, ps, (pn - 1) * ps); scope, userId, userType, ok, f, t, ps, (pn - 1) * ps);
long total = loginLogMapper.countLogins(me.tenantId(), userId, userType, ok, f, t); long total = loginLogMapper.countLogins(scope, userId, userType, ok, f, t);
return ApiResult.ok(PageResult.of(list, total, pn, ps)); return ApiResult.ok(PageResult.of(list, total, pn, ps));
} }
} }
@@ -60,7 +60,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
l.create_time AS loginTime l.create_time AS loginTime
FROM sys_login_log l FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0 LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId} WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="userId != null"> <if test="userId != null">
AND l.user_id = #{userId} AND l.user_id = #{userId}
</if> </if>
@@ -96,7 +99,10 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
SELECT COUNT(*) SELECT COUNT(*)
FROM sys_login_log l FROM sys_login_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0 LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId} WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="userId != null"> <if test="userId != null">
AND l.user_id = #{userId} AND l.user_id = #{userId}
</if> </if>
@@ -18,6 +18,15 @@ import java.util.List;
* <p>查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}: * <p>查询走手写 SQL 并 {@code @InterceptorIgnore} + 显式 {@code tenant_id}:
* 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清, * 靠拦截器隐式补条件会让"查不到数据"和"确实没有数据"分不清,
* 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。 * 而日志查询恰恰是要能对客户说清"这段时间有没有人动过这张名册"的场景。
*
* <p>⚠️ {@code tenantId} 是<b>可选</b>条件({@code <if test="tenantId != null">}):
* 契约 §13.4 本来就把 {@code tenantId} 定义成查询参数。不传 = <b>全量</b>,
* 只有平台运营({@code log:viewAll})能走到这一支;学校管理员由
* {@code LogController} 恒填本租户。
*
* <p>以前这里写死 {@code l.tenant_id = #{tenantId}}、控制器恒传 {@code me.tenantId()} ——
* 而平台运营的 tenantId 是 0,于是它<b>一条日志都看不到</b>(列表空白,
* 没有任何报错),而 PRD §3.2 对平台运营要求的恰恰是「<b>全量</b>操作日志 ✅」。
*/ */
public interface OperationLogMapper extends BaseMapper<SysOperationLog> { public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
@@ -57,7 +66,10 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
l.create_time AS createTime l.create_time AS createTime
FROM sys_operation_log l FROM sys_operation_log l
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0 LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
WHERE l.tenant_id = #{tenantId} WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="operatorId != null"> <if test="operatorId != null">
AND l.user_id = #{operatorId} AND l.user_id = #{operatorId}
</if> </if>
@@ -100,7 +112,10 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
<script> <script>
SELECT COUNT(*) SELECT COUNT(*)
FROM sys_operation_log l FROM sys_operation_log l
WHERE l.tenant_id = #{tenantId} WHERE 1 = 1
<if test="tenantId != null">
AND l.tenant_id = #{tenantId}
</if>
<if test="operatorId != null"> <if test="operatorId != null">
AND l.user_id = #{operatorId} AND l.user_id = #{operatorId}
</if> </if>
@@ -11,7 +11,21 @@ import java.util.List;
/** 推送记录(契约 §14.4)。 */ /** 推送记录(契约 §14.4)。 */
public interface OpenPushLogMapper extends BaseMapper<CollectOpenPushLog> { public interface OpenPushLogMapper extends BaseMapper<CollectOpenPushLog> {
/** 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。 */ /**
* 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。
*
* <p>⚠️ {@code @InterceptorIgnore(tenantLine = "true")} <b>不能省</b>:
* 定时任务**没有租户上下文**(它是"没有请求也会发生的事"),而租户拦截器的姿态是
* <b>默认拒绝</b> —— 少了这个注解,这条查询每次都抛 {@code 40101},
* 被 {@code OpenPushService.retryDue} 的 {@code catch (RuntimeException)} 吞成一行 warn。
* 表现是:**"安排了下次重试"却从来没有重试过**,而日志里只有每 10 秒一次的噪音。
*
* <p>这与 {@link #findBySubmissionIgnoreTenant} 是同一个规律:
* <b>"先找归属、再摆上下文"里,找归属那一步必须能无租户执行</b>。
* 跨租户的候选扫描集中在这一个方法里,逐条处理时再由
* {@code TenantScope.runAs} 摆好目标租户的上下文 —— 保护没有被放弃。
*/
@InterceptorIgnore(tenantLine = "true")
@Select(""" @Select("""
SELECT * FROM collect_open_push_log SELECT * FROM collect_open_push_log
WHERE deleted = 0 AND status = 2 WHERE deleted = 0 AND status = 2
@@ -212,11 +212,18 @@ public class OpenPushService {
// 定时任务没有租户上下文,用忽略租户的查询(范围仅此一处) // 定时任务没有租户上下文,用忽略租户的查询(范围仅此一处)
CollectOpenApp app = appMapper.findByIdIgnoreTenant(row.getAppId()); CollectOpenApp app = appMapper.findByIdIgnoreTenant(row.getAppId());
if (app == null || !app.enabled()) { if (app == null || !app.enabled()) {
// 接入方没了/停了:不再重试,标成最终失败并说明原因 // 接入方没了/停了:不再重试,标成最终失败并说明原因。
//
// ⚠️ 这一步**也必须摆租户上下文**:`updateById` 会走租户拦截器,
// 而在"接入方已不存在"的情况下我们不能从 app 拿租户 —— 所以用
// **推送记录自己的 tenant_id**。少了它,这条 update 会抛 40101,
// 被下面的 catch 吞成一行 warn,于是这条记录**每 10 秒重试一次、永远停不下来**。
TenantScope.runAs(row.getTenantId(), null, Set.of(), () -> {
row.setStatus(CollectOpenPushLog.STATUS_FAILED); row.setStatus(CollectOpenPushLog.STATUS_FAILED);
row.setLastError("接入方不存在或已停用,停止重试"); row.setLastError("接入方不存在或已停用,停止重试");
row.setNextRetryAt(null); row.setNextRetryAt(null);
pushLogMapper.updateById(row); pushLogMapper.updateById(row);
});
continue; continue;
} }
TenantScope.runAs(app.getTenantId(), null, Set.of(), () -> attempt(app, row)); TenantScope.runAs(app.getTenantId(), null, Set.of(), () -> attempt(app, row));
@@ -42,7 +42,17 @@ public class OrgService {
* 一次查完更快也更好控制;递归查询在深度上没有上界,容易被构造出深树打爆。 * 一次查完更快也更好控制;递归查询在深度上没有上界,容易被构造出深树打爆。
*/ */
public List<OrgTreeVO> tree(boolean withCount, Long rootId) { public List<OrgTreeVO> tree(boolean withCount, Long rootId) {
// ★ 显式带上租户条件,不依赖拦截器。
//
// 拦截器对**平台运营**(tenantId=0)整个关掉了过滤(它是唯一跨租户的身份),
// 于是这里会返回"所有租户的组织混在一棵树里" —— 而只有一所学校有数据时,
// 它看起来和学校管理员看到的完全一样,**看不出错**。
// 组织树本来就是租户内的东西:平台要看某所学校的组织,得先在页面上选定学校
// (请求头 X-Tenant-Id → 见 ActingTenantResolver),那时 tenantId 就是那所学校。
// 没选学校时这里返回空树,而不是"把所有人的组织端出来"。
long tenantId = UserContext.tenantId();
List<SysOrg> all = orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery() List<SysOrg> all = orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery()
.eq(SysOrg::getTenantId, tenantId)
.orderByAsc(SysOrg::getSortNo) .orderByAsc(SysOrg::getSortNo)
.orderByAsc(SysOrg::getId)); .orderByAsc(SysOrg::getId));
@@ -215,10 +215,18 @@ public class TaskService {
return o instanceof Number n ? n.longValue() : null; return o instanceof Number n ? n.longValue() : null;
} }
/** 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。 */ /**
* 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。
*
* <p>⚠️ 平台这一支必须用 {@link LoginUser#isPlatformStaff()} 而不是
* {@code isPlatformAdmin()}:平台**代管某所学校**时(请求头 {@code X-Tenant-Id})
* tenantId 被换成了那所学校,{@code isPlatformAdmin()} 就是 false 了 ——
* 那时会掉进下面"只看自己带的班"的分支,而平台在该校没有班级,
* 于是工作台的任务下拉变成**空列表**,看起来像"这所学校没有任务"。
*/
private boolean seesWholeTenant() { private boolean seesWholeTenant() {
LoginUser me = UserContext.require(); LoginUser me = UserContext.require();
return me.isPlatformAdmin() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR"); return me.isPlatformStaff() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR");
} }
// ── 详情 ────────────────────────────────────── // ── 详情 ──────────────────────────────────────
@@ -164,7 +164,11 @@ public class UserService {
final String kw = (raw == null || raw.isEmpty()) ? null : raw; final String kw = (raw == null || raw.isEmpty()) ? null : raw;
LambdaQueryWrapper<SysUser> q = Wrappers.lambdaQuery(); LambdaQueryWrapper<SysUser> q = Wrappers.lambdaQuery();
q.eq(orgId != null, SysUser::getOrgId, orgId) // ★ 显式租户条件(与 OrgService.tree 同一个理由):拦截器对平台运营
// (tenantId=0)整个关掉了过滤,不加这一条就会把**所有租户的账号**混在一起返回。
// 平台要看某所学校的账号,先在页面上选定学校(X-Tenant-Id),那时 tenantId 就是它。
q.eq(SysUser::getTenantId, UserContext.tenantId())
.eq(orgId != null, SysUser::getOrgId, orgId)
.eq(userType != null, SysUser::getUserType, userType) .eq(userType != null, SysUser::getUserType, userType)
.eq(status != null, SysUser::getStatus, status); .eq(status != null, SysUser::getStatus, status);
@@ -31,6 +31,7 @@ public class AuthInterceptor implements HandlerInterceptor {
private final TokenBlacklist blacklist; private final TokenBlacklist blacklist;
private final PermissionChecker permissionChecker; private final PermissionChecker permissionChecker;
private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver; private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver;
private final cn.tianbaotong.fill.support.ActingTenantResolver actingTenantResolver;
@Override @Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
@@ -52,6 +53,21 @@ public class AuthInterceptor implements HandlerInterceptor {
LoginUser user = jwtService.parse(token); LoginUser user = jwtService.parse(token);
// ★ 平台运营的「当前学校」(请求头 X-Tenant-Id)。
//
// 平台是唯一跨租户的身份,租户拦截器因此对它关掉了过滤 —— 于是「组织架构」
// 「账号管理」这类租户内的页面会看到**所有租户混在一起**的数据,而且看不出错
// (只有一所学校有数据时,它和学校管理员看到的一模一样)。
// 所以要代管某所学校时必须显式指明:换掉 tenantId 之后 isPlatformAdmin() 变 false
// → 租户过滤自动生效,权限仍按 PLATFORM_ADMIN 那套判。
//
// ⚠️ 必须在下面的一致性校验**之前**:切换后要拿"生效租户"去和域名比,
// 否则在 t2 域名上代管 t1 会被放过去。
Long actingTenant = actingTenantResolver.resolve(request, user);
if (actingTenant != null) {
user = new LoginUser(user.uid(), actingTenant, user.orgIds(), user.roles(), user.userType());
}
// ★ 租户域名与 token 的租户必须一致 —— 这是"用域名区分租户"唯一必须守住的点。 // ★ 租户域名与 token 的租户必须一致 —— 这是"用域名区分租户"唯一必须守住的点。
// //
// Host 只用来**定位**(这个请求落在谁的站点上),授权一律以 token 为准。 // Host 只用来**定位**(这个请求落在谁的站点上),授权一律以 token 为准。
@@ -24,6 +24,15 @@ public enum BuiltinRole {
PermissionDef.ORG_MANAGE, PermissionDef.ORG_VIEW, PermissionDef.ORG_MANAGE, PermissionDef.ORG_VIEW,
PermissionDef.USER_MANAGE, PermissionDef.USER_VIEW, PermissionDef.USER_MANAGE, PermissionDef.USER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_ALL, PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_ALL,
// ⚠️ TASK_VIEW 是**读**权限(任务列表 / 详情 / 覆盖班级 / 规则模板 / 任务统计),
// PRD §3.2 的「查看填报进度看板」对平台运营是 ✅,而工作台的数据看板
// 要拿任务列表才能选任务 —— 少了它,平台点工作台会拿到 `/tasks` 40301。
//
// 它以前被"平台=超管"这个捷径掩盖着(`PermissionChecker.has` 对平台恒真),
// 直到平台开始**代管某所学校**(`X-Tenant-Id` → 不再是 isPlatformAdmin)
// 才暴露出来:那时只认角色声明的权限,于是任务列表直接 403。
// 只补读权限:TASK_MANAGE / TASK_CLONE 依旧不给(PRD 里平台运营是「—」)。
PermissionDef.TASK_VIEW,
PermissionDef.LOG_VIEW_ALL), PermissionDef.LOG_VIEW_ALL),
Set.of("dashboard", "tenant", "org", "user", "log")), Set.of("dashboard", "tenant", "org", "user", "log")),
@@ -41,8 +50,16 @@ public enum BuiltinRole {
PermissionDef.FILE_PROXY_UPLOAD, PermissionDef.FILE_PROXY_UPLOAD,
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN, PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN,
PermissionDef.LOG_VIEW_TENANT), PermissionDef.LOG_VIEW_TENANT),
Set.of("dashboard", "org", "user", "role", "task", "form", "material", Set.of("dashboard", "org", "user", "role", "task",
"roster", "join", "audit", "export", "cleanup", "log", "join", "audit", "export", "cleanup", "log",
// ⚠️ 顶层菜单里**没有**「名单管理 / 表单设计 / 材料规则」这三条。
// 它们的功能早就做好了,但都是**按任务**的:入口在「收集任务」
// 每一行的链接里(名单管理 / 表单设计 / 材料规则)。
// 以前菜单里挂着三条"功能在别处"的占位项,点进去只有一句指路 ——
// 那既占了导航,又让人以为"这里应该有事可做"。现在直接去掉。
// ⚠️ 这与权限无关:task/form/material/roster 相关权限码照旧保留,
// 只是不再有对应的菜单键。
//
// ⚠️ 这两个菜单键以前**没有任何角色持有** —— 而 PC 端的菜单可见性 // ⚠️ 这两个菜单键以前**没有任何角色持有** —— 而 PC 端的菜单可见性
// 就是拿这里拼出来的集合过滤的(`buildXtRoutes` 按 menuKeys 过滤), // 就是拿这里拼出来的集合过滤的(`buildXtRoutes` 按 menuKeys 过滤),
// 于是「字典管理」与「通知记录」两个页面**做得再好也点不到**: // 于是「字典管理」与「通知记录」两个页面**做得再好也点不到**:
@@ -56,7 +73,7 @@ public enum BuiltinRole {
PermissionDef.ROSTER_MANAGE_OWN, PermissionDef.ROSTER_VIEW, PermissionDef.ROSTER_MANAGE_OWN, PermissionDef.ROSTER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_OWN, PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD), PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "roster", "audit")), Set.of("dashboard", "task", "audit")),
/** 副班主任:协助初审与代传,但不发通知(避免一个班两个人都来催报)。 */ /** 副班主任:协助初审与代传,但不发通知(避免一个班两个人都来催报)。 */
DEPUTY_TEACHER("DEPUTY_TEACHER", "副班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 30, DEPUTY_TEACHER("DEPUTY_TEACHER", "副班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 30,
@@ -64,14 +81,14 @@ public enum BuiltinRole {
PermissionDef.ROSTER_VIEW, PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_OWN, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD), PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "roster", "audit")), Set.of("dashboard", "task", "audit")),
/** 审核员:专做审核,不给任何管理权限(最小权限原则)。 */ /** 审核员:专做审核,不给任何管理权限(最小权限原则)。 */
AUDITOR("AUDITOR", "审核员", UserType.SCHOOL_ADMIN, DataScope.TENANT, 40, AUDITOR("AUDITOR", "审核员", UserType.SCHOOL_ADMIN, DataScope.TENANT, 40,
Set.of(PermissionDef.TASK_VIEW, PermissionDef.ROSTER_VIEW, Set.of(PermissionDef.TASK_VIEW, PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_ALL, PermissionDef.PROGRESS_VIEW_ALL,
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL), PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL),
Set.of("dashboard", "task", "roster", "audit")), Set.of("dashboard", "task", "audit")),
/** 学生:只能操作本人那一份。 */ /** 学生:只能操作本人那一份。 */
STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90, STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90,
@@ -0,0 +1,88 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 平台运营的「当前学校」(请求头 {@code X-Tenant-Id})。
*
* <h3>它解决什么</h3>
*
* <p>平台运营({@code tenantId = 0})是唯一跨租户的身份,而租户拦截器对它
* <b>整个关掉了租户过滤</b>(见 {@code MybatisPlusConfig.FillTenantLineHandler})。
* 于是「组织架构」「账号管理」这类<b>本来就是租户内</b>的页面,在平台账号下看到的是
* <b>所有租户混在一起</b>的数据 —— 而这<b>看不出错</b>:当只有一所学校有数据时,
* 它和学校管理员看到的一模一样。
*
* <p>所以平台要操作某所学校的组织/账号时,必须<b>显式指明是哪一所</b>:
* 请求头带上目标租户 id,本类把它解析出来,{@code AuthInterceptor} 用它把
* {@link LoginUser} 的 tenantId 换成目标租户 —— 之后租户过滤、上下文写入、
* 权限判定全部按"我就是那所学校"走,<b>不需要每个接口各加一个参数</b>。
*
* <h3>四条约束(安全相关,不要松)</h3>
*
* <ol>
* <li><b>只认平台身份</b>:非平台用户带这个头一律忽略 —— 否则改一个数字就能越租户,
* 那正是本系统最不能出的一类漏洞;</li>
* <li>目标租户必须<b>存在且启用</b>,否则 {@code 40001},且不区分"不存在"与"被停用";</li>
* <li>换租户之后 {@code isPlatformAdmin()} 变成 false → 租户过滤自动生效。
* 这是<b>关键机制</b>(不是副作用):无需在别处再加一个"是否已切换"的判断;</li>
* <li><b>不带这个头时行为完全不变</b> —— 平台照旧可以跨租户看租户列表、全量日志。</li>
* </ol>
*
* <p>与 {@link TenantDomainResolver} 的分工:那个是"这条请求落在谁的域名上"(定位,匿名可用),
* 这个是"平台此刻要代管哪所学校"(显式选择,仅平台可用)。两者都会改变租户上下文,
* 所以都在 {@code AuthInterceptor} 里、<b>与 token 的租户一致性校验一起</b>完成。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ActingTenantResolver {
/** 平台运营指定"当前学校"的请求头。 */
public static final String HEADER = "X-Tenant-Id";
private final TenantMapper tenantMapper;
/**
* 解析本次请求的目标租户。
*
* @return 目标租户 id;<b>不是平台身份 / 没带头 / 头就是平台自身</b> 时返回 {@code null}
* (表示"不切换",按原有语义走)
*/
public Long resolve(HttpServletRequest req, LoginUser user) {
if (user == null || !user.isPlatformAdmin()) {
// 非平台身份带头一律忽略:这一条是安全边界,不是"顺手支持一下"
return null;
}
String raw = req == null ? null : req.getHeader(HEADER);
if (raw == null || raw.isBlank()) {
return null;
}
long target;
try {
target = Long.parseLong(raw.trim());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
HEADER + " 不是合法的租户号:" + raw);
}
if (target == LoginUser.PLATFORM_TENANT_ID) {
// 指着平台自己 = 不切换(而不是报错:前端清空选择时可能发 0)
return null;
}
SysTenant t = tenantMapper.selectById(target);
if (t == null || t.getStatus() == null || t.getStatus() != 1) {
// 两种原因给同一句话:不额外透露"这个租户存在但被停用了"
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "目标单位不存在或已停用");
}
log.info("平台运营切换租户:uid={} → tenantId={}({})", user.uid(), target, t.getTenantName());
return target;
}
}
+1
View File
@@ -68,6 +68,7 @@
| 2026-09-28 | **P5 开发任务拆解** | 完成 `05-开发任务拆解.md`(27.6KB / 468 行)。**94 个任务 / 380 人日 / 含缓冲 456**,工期 **14 周**。关键结论:**工期由关键路径(67 人日串行)决定,不能按人日总数摊人**;压缩周期只能砍范围不能加人。含 3 个客户演示点(A 第 5 周 / B 第 10 周 / C 第 14 周)与 §9 可复现核算口径 | | 2026-09-28 | **P5 开发任务拆解** | 完成 `05-开发任务拆解.md`(27.6KB / 468 行)。**94 个任务 / 380 人日 / 含缓冲 456**,工期 **14 周**。关键结论:**工期由关键路径(67 人日串行)决定,不能按人日总数摊人**;压缩周期只能砍范围不能加人。含 3 个客户演示点(A 第 5 周 / B 第 10 周 / C 第 14 周)与 §9 可复现核算口径 |
| 2026-09-28 | **P4 核心模块设计** | 完成 `04-核心模块设计说明.md`(54KB / 1218 行)。六个模块全部伪代码级:M1 命名(含证件号三源裁决表)、M2 压缩(五档+二分降质+二次有损防护)、M3 识别(四层递进+成本闸)、M4 门禁(**并发绑定必须 UPDATE...WHERE bind_user_id IS NULL**)、M5 校验、M6 导出(快照先固化+中文文件名编码)。**校验中发现并修正三处真问题**,见下条教训 | | 2026-09-28 | **P4 核心模块设计** | 完成 `04-核心模块设计说明.md`(54KB / 1218 行)。六个模块全部伪代码级:M1 命名(含证件号三源裁决表)、M2 压缩(五档+二分降质+二次有损防护)、M3 识别(四层递进+成本闸)、M4 门禁(**并发绑定必须 UPDATE...WHERE bind_user_id IS NULL**)、M5 校验、M6 导出(快照先固化+中文文件名编码)。**校验中发现并修正三处真问题**,见下条教训 |
| 2026-09-28 | **P3 接口设计** | 完成 `03-接口设计.md`(102KB / 2626 行)。**117 个接口**全部字段级定义(PRD 原 96 + 新增 21);新增 §2 枚举全集(含「无」的三条铁律)、§3 通用数据结构(12 个 VO,字段名与 DDL 列一一对应)、§15 错误码全集(50 个,新增 30);**自校验通过**:模块表合计=声称=实际唯一数=117,无真重复,引用字段名 116 个全部存在于 DDL | | 2026-09-28 | **P3 接口设计** | 完成 `03-接口设计.md`(102KB / 2626 行)。**117 个接口**全部字段级定义(PRD 原 96 + 新增 21);新增 §2 枚举全集(含「无」的三条铁律)、§3 通用数据结构(12 个 VO,字段名与 DDL 列一一对应)、§15 错误码全集(50 个,新增 30);**自校验通过**:模块表合计=声称=实际唯一数=117,无真重复,引用字段名 116 个全部存在于 DDL |
| 2026-10-09 | **验收走查后的四项修正** | ① **去掉 3 条无用菜单**(名单管理 / 表单设计器 / 材料规则)—— 功能都在「收集任务」行内入口,菜单里那一条只是指路;后端菜单键同步删除;② **修掉班主任点工作台必弹 403**(工作台无条件调了要 `org:view` / `progress:viewAll` 的两个接口,班主任都没这两个权限)→ 改为按权限决定发不发请求;③ **新增请求头 `X-Tenant-Id`(§1.2)**:平台运营的「组织架构 / 账号管理 / 工作台」原先拿到的是**所有租户混在一起**的数据,现在必须先在页面上选定学校;④ **修掉开放接口的"推送重试"从未生效**(F-112:定时扫描缺 `@InterceptorIgnore`,异常被 warn 吞掉;E2E 补了"真的重试了"的断言)。详见 `交接摘要` §一·三十三 |
--- ---
+29 -1
View File
@@ -70,6 +70,25 @@
**Token 有效期**:PC 端 8 小时(可续期)/ 小程序端 7 天(静默续期) **Token 有效期**:PC 端 8 小时(可续期)/ 小程序端 7 天(静默续期)
#### `X-Tenant-Id`:平台运营的「当前学校」(可选,仅平台运营)
平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户过滤对它整体关闭。于是
「组织架构」「账号管理」这类**本身就是租户内**的接口,平台不指明学校时会拿到
**所有租户混在一起**的数据 —— 而这看不出错(只有一所学校有数据时,它与学校管理员看到的完全一样)。
| 项 | 约定 |
|---|---|
| Header | `X-Tenant-Id: <tenantId>` |
| 谁能用 | **只认平台运营**。其它身份带这个头一律忽略(改了也不生效,不是"报错") |
| 作用 | 本次请求的租户上下文换成该租户:`/orgs/**`、`/users/**`、`/tasks/**` 等按它过滤与写入 |
| 不带 / 传 `0` | 不切换(`0` 视为"平台自己")。平台的租户列表、全量日志等跨租户能力照旧 |
| 非法值 | 非数字、或租户不存在/已停用 → `40001` |
| 与 token 的关系 | 只对平台生效,**不能**用来访问别的租户(学校管理员带它不会越权) |
| 权限怎么判 | 切换之后**不再享受"平台超管"捷径**,一律按 `PLATFORM_ADMIN` 声明的权限码判 —— 所以那些码必须真的够用(例如「查看填报进度看板」要 `task:view` + `progress:viewAll`) |
> 例:`GET /api/v1/orgs/tree` + `X-Tenant-Id: 1` → 返回租户 1 的组织树;
> 不带该头 → 返回平台自己租户的组织(空树),**不再**把各租户的组织混在一起。
### 1.3 统一响应 ### 1.3 统一响应
```json ```json
@@ -2634,6 +2653,13 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页 **Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页
**范围**(权限二选一,`@RequirePerm({log:viewTenant, log:viewAll})`):
| 调用者 | 权限 | 看到什么 |
|---|---|---|
| 平台运营 | `log:viewAll` | **默认全量**(跨租户);传 `tenantId` 则只看该单位 |
| 学校管理员 / 教务处 | `log:viewTenant` | **恒为本租户**;传了别的 `tenantId` → `40301`(**不静默忽略**:静默会让人以为"筛过了") |
**响应** **响应**
```json ```json
@@ -2669,7 +2695,9 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
#### `GET /api/v1/logs/logins` #### `GET /api/v1/logs/logins`
**Query**:`userId`、`userType`、`result`、`from` / `to`、分页 **Query**:`tenantId`、`userId`、`userType`、`result`、`from` / `to`、分页
**范围**:与操作日志同一口径(`log:viewAll` 可跨租户,学校管理员恒为本租户)。
**响应**:`{ list: [{ id, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }` **响应**:`{ list: [{ id, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
+252
View File
@@ -2527,3 +2527,255 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
3. **降级逻辑越完善,接线类的错越安静**:F-109 的 NPE 被 try/catch 吞掉、 3. **降级逻辑越完善,接线类的错越安静**:F-109 的 NPE 被 try/catch 吞掉、
F-104 的沉默被 `if (!tasks.isEmpty())` 挡住、F-106 的错只在"点菜单"时才出现 —— F-104 的沉默被 `if (!tasks.isEmpty())` 挡住、F-106 的错只在"点菜单"时才出现 ——
所以"从入口走一遍"这件事只能靠 E2E,不能靠 review。 所以"从入口走一遍"这件事只能靠 E2E,不能靠 review。
---
## §一·三十三 验收走查后的三项修正 + 平台「选择学校」(2026-10-09)
> 起点是客户自己点着看了一圈,报了三件事:**菜单里有几条没用**、
> **班主任点工作台弹 403**、**平台运营与学校管理员看到的组织架构一模一样**。
> 三件事都不是"功能没做",而是"做完了但口径不对" —— 与前几轮同一族。
### 一、去掉 3 条无用菜单(客户明确要求)
| 菜单 | 为什么去掉 |
|---|---|
| 名单管理 | 名册按任务,入口在「收集任务」每行的「名单管理」 |
| 表单设计器 | 表单按任务,入口在任务行的「表单设计」 |
| 材料规则 | 材料按任务,入口在任务行的「材料规则」 |
⚠️ **这一条推翻了 §一·二十三 里"顶层菜单保留「待建」页更诚实"的取舍** ——
当时的理由是"说'还没做'而功能在隔壁会让人得出错误结论"。客户看了之后的判断是:
一条点进去只有指路、没有任何操作的菜单,**本身就是导航噪音**。于是直接删掉。
- 后端 `BuiltinRole` 的 `menus` 同步删除这三个键(`check-wiring.py` ⑤ 双向核对,
只删一边会红)。⚠️ **权限码一个都没动**:`task:manage` / `form:manage` /
`material:manage` / `roster:*` 照旧,删的只是菜单键(可见性)。
- PC 端 `XT_MENUS` 删掉三条;**顺带删掉 `entryAt` 机制**("功能已就绪 + 跳转按钮"
那段)—— 它当初就是为这三条写的,留着就是一段永远走不到的代码。
「待建」页现在只服务「角色与授权」(二期)。
- 班主任/副班主任/审核员的菜单也一起去掉了 `roster`:他们本来也点不进去(没有 `roster` 页)。
### 二、班主任点工作台必弹 403 —— 前端调了"自己没有权限"的接口
| 接口 | 要求权限 | 班主任有吗 |
|---|---|---|
| `GET /orgs/tree`(工作台的"组织节点"卡片) | `org:view` | ❌ |
| `GET /audit/statistics`(数据看板的"待审/审核情况") | `progress:view:all` | ❌(只有 `progress:view:own`)|
工作台是**所有角色共用的落地页**,而这两个调用是无条件的 —— 于是班主任一进首页就连吃两个 403。
⚠️ **`try/catch` 拦不住它**:403 走的是 HTTP 状态码,`utils/http.ts` 的传输层分支
**先 `Message.error('拒绝访问(403)')` 再 reject**,异常被 catch 了、提示仍然弹出来。
前端唯一正确的做法是**按权限决定发不发这个请求**:
- 卡片值如实显示 `—`,并把原因写在卡片脚注("需要「组织查看」权限");
- 「审核情况」对没有 `progress:view:all` 的角色显示一句说明,而不是一张空表。
> 教训(与 §一·三十二 的接线检查同一族):**"共用页面 + 接口级权限"是一对必踩的组合**。
> 页面对所有角色都渲染,但里面的接口各要各的权限 —— 这类错只有"换个角色登进去点一遍"
> 才看得见,E2E 全绿也抓不到(E2E 一直用管理员身份跑工作台)。
### 三、平台运营的「组织架构 / 账号管理」:新增 `X-Tenant-Id`
**根因**:平台运营(`tenantId = 0`)是唯一跨租户身份,租户拦截器对它
**整体关闭过滤**(`MybatisPlusConfig.FillTenantLineHandler.ignoreTable`)。
于是 `/orgs/tree` 返回的是**所有租户混在一棵树里**,`/users` 是**所有租户的账号**。
⚠️ 这**看不出错**:当时只有 demo 一所学校有数据,平台看到的正好就是那 22 个节点 ——
和学校管理员一模一样。等第二所学校建了组织,它会看到两所学校的班级混在同一层。
**做法(客户选定"先选学校,再看该校数据")**:
```
前端:平台运营的「组织架构 / 账号管理」页顶部多一个「选择学校」下拉(GiTenantPicker)
→ 选中后所有请求带 X-Tenant-Id: <tenantId>
后端:ActingTenantResolver 解析这个头(support/,与 TenantDomainResolver 并列)
→ AuthInterceptor 把 LoginUser 的 tenantId 换成目标租户
→ isPlatformAdmin() 随之变 false → 租户过滤自动生效(这是机制,不是副作用)
```
**四条必须守住的**(都写了断言,`scripts/_verify-fixes.py`):
1. **只认平台身份**:学校管理员/班主任带这个头一律忽略 —— 否则改一个数字就能越租户;
2. 目标租户必须存在且启用,否则 `40001`(不区分"不存在"与"停用");
3. 不带这个头时**平台行为完全不变**(租户列表、全量操作日志照旧跨租户);
4. `OrgService.tree()` 与 `UserService.list()` 补了**显式** `tenant_id` 条件 ——
原来它们只靠拦截器,而拦截器对平台是不生效的;显式写死之后
"平台没选学校"得到的是**空树/自己的账号**,而不是"所有人的数据"。
⚠️ 角色可选范围也跟着走:给某所学校建账号时只能发学校那套角色
(发 `PLATFORM_ADMIN` 会被服务端的租户白名单拒掉,报 40301)。
### 四、`OpenPushService` 的"推送重试"从来没重试过(**已修,F-112**)
后端每 10 秒刷一次堆栈:`OpenPushService 扫描推送重试失败 → 未登录或登录已失效`。
- 根因:`OpenPushService.retryDue()` 的扫描查询 `pushLogMapper.dueRetries()`
**没有租户上下文**,而该方法**漏了 `@InterceptorIgnore(tenantLine="true")`** ——
同一个文件里 `findBySubmissionIgnoreTenant` 有,注释还正好写着这条规律
("先找归属、再摆上下文"里找归属那一步必须能无租户执行)。
- 后果:`docs/08` 记的**第 8 项定时任务「开放接口推送重试」从未真正重试**,
只是每 10 秒打一行 warn(异常被 `catch (RuntimeException) → log.warn` 吞掉)。
- **顺带修掉同一个方法里的第二处**:`接入方已删除/停用` 那条分支直接
`pushLogMapper.updateById(row)`,它同样没有租户上下文 —— 少了上下文这句 update 会抛 40101
被同一个 catch 吞掉,于是那条记录**每 10 秒重试一次、永远停不下来**。
改成用**推送记录自己的 `tenant_id`** 摆上下文再写。
- **E2E 补了"真的发生了"的断言**:`e2e-m8-open.py` 原来只断言"失败后**安排了**下次重试"
(`next_retry_at` 被写上),现在多一条 —— 等 16 秒(扫描间隔 10s + 首次退避 1s + 余量),
断言 `attempts` **真的增加**。**"安排了"与"发生了"是两件事**,只断言前者的话,
60/60 全绿也照样漏掉这个 bug(F-104 是同一族的第二次)。
修后 `e2e-m8-open.py` → **61/61**。
### 五、平台运营的**工作台**也要"选择学校"
客户要求"工作台的数据看板也按学校看"。改完之后连着暴露了两个**更深的问题**:
| # | 现象 | 根因 | 修法 |
|:--:|---|---|---|
| 1 | 平台选了学校后,任务下拉**空**(`/tasks` 40301) | 切到代管租户后 `isPlatformAdmin()` 变 false,权限判定**只认平台角色声明的权限** —— 而 `PLATFORM_ADMIN` 里**没有 `TASK_VIEW`**。它以前被"平台=超管"(`PermissionChecker.has` 对平台恒真)这个捷径掩盖着 | 给 `PLATFORM_ADMIN` 补 **`TASK_VIEW`**(纯读:任务列表/详情/统计)。PRD §3.2「查看填报进度看板:平台运营 ✅」正是这件事;`TASK_MANAGE` / `TASK_CLONE` 依旧不给(PRD 里平台是「—」) |
| 2 | 补了权限后 `/tasks` 返回 200,但**还是 0 条** | `TaskService.seesWholeTenant()` 写的是 `isPlatformAdmin() \|\| SCHOOL_ADMIN/AUDITOR` —— 代管时前者为 false,于是**掉进"班主任只看自己带的班"那条分支**,而平台在该校没有班级 → 空列表(看起来像"这所学校没有任务") | 新增 `LoginUser.isPlatformStaff()`(= `isPlatformAdmin() \|\| hasRole("PLATFORM_ADMIN")`),并把这条判据换成它 |
⚠️ **`isPlatformAdmin()` 与 `isPlatformStaff()` 的区别要记住**(两者回答不同的问题):
- `isPlatformAdmin()` = "当前上下文的租户**就是平台租户**" → 它是租户拦截器要不要
**整体关掉过滤**的开关(跨租户看数据);
- `isPlatformStaff()` = "这个人**是平台运营**" → 数据范围是全量,但**租户过滤照常生效**。
**凡是"按身份决定数据范围"的判断用后者**;否则平台一代管某校就会**退化成普通用户**
(空列表、少一半数据,而界面上没有任何提示)。前端也一并按学校取:工作台顶部同样放
`GiTenantPicker`(与组织架构/账号管理共用 `useActingTenantStore`),任务列表、任务统计、
审核统计都带上 `X-Tenant-Id`。
### 六、⭐ 把"权限码写错"变成命令:`check-wiring.py` 第 ⑦ 项
本轮我自己踩了一个**最该被工具抓住**的错:给工作台加"按权限决定发不发请求"时,
把 `progress:viewAll` 写成了 **`progress:view:all`**(多一个冒号)。后果:
那个判断**恒为 false** →「审核情况」整块**对所有人**都不显示 ——
而编译、typecheck、lint、后端日志、E2E **全都不红**(字符串对不上是纯运行期的事;
"权限不足就少显示一块"看起来还很像设计)。
所以给 `check-wiring.py` 加了第 ⑦ 项:**PC 端引用的权限码必须存在于 `PermissionDef`**
(认两种写法:`permissions.includes('x')` 与 `v-hasPerm="['x']"`)。
两个"检查器自己"的细节值得记:
1. **它第一版报了 5 条假阳性** —— 全都来自 `directives/hasPerm.ts` 里的
**注释与示例字符串**(模板自带的 `home:btn:add`)。这与本脚本 2026-10-08 修过的
第 ③ 条(Java 注释里的提及被当成接线)是同一个教训:
**检查器必须能区分"代码"与"解释代码的文字"**。修法:先按引号感知地剥掉 JS 注释
(`strip_js_comments`,不能见 `//` 就砍),并且 `v-hasPerm` **只在 `.vue` 里找**
(那是模板语法,`.ts` 里的同名文本必然是示例或报错文案)。
2. **加完要拿一个故意写错的码试一次**(本仓库的老规矩):把 `progress:viewAll` 改回错的
→ 检查器红、指向 `dashboard/index.vue:196`;改回来 → 0 条。**它真的会红,才算加上。**
### 七、平台运营的「操作日志」永远是空的(**已修**)
客户点出来的现象:**平台运营进「操作日志」一条都没有**(页面正常、不报错、就是空表)。
- 根因:`LogController` 恒把 `me.tenantId()` 传给查询,SQL 又写死 `WHERE l.tenant_id = #{tenantId}` ——
而平台运营的 tenantId 是 **0**,日志却都挂在各租户下 → **恒为 0 条**。
- 而契约 §13.4 **本来就把 `tenantId` 定义成查询参数**,PRD §3.2 对平台运营要求的也是
「**全量**操作日志 ✅」(学校管理员才是「🔸 本租户」)。也就是说:**参数没实现、权限码只挂了一半**。
- 修法(两个接口一起:`/logs/operations` 与 `/logs/logins`):
1. 注解改成 **`@RequirePerm({LOG_VIEW_TENANT, LOG_VIEW_ALL})`**(任一即可);
2. 新增 `LogController#logScopeTenantId(tenantId)` —— 有 `log:viewAll` → **默认全量**、
显式传 `tenantId` 时只看那一所;只有 `log:viewTenant` → **恒为本租户**,
传别人的租户 **`40301`**(⚠️ **不静默忽略**:这一屏是拿去回答合规问题的,
"看起来筛过了"比报错更糟);
3. 两条手写 SQL 的租户条件改成可选(`WHERE 1 = 1` + `<if test="tenantId != null">`)。
- 前端:平台运营多一列「**租户**」(它看的是跨租户全量,不显示单位就分不清这条属于谁;
学校管理员永远是本租户,不显示这一列),单位名取自 `/tenants`,取不到就退回 `#id`。
- 按客户要求,**同时删掉了这一屏顶部那段说明**(脱敏值/指纹、IP 可伪造、真正的证据是…)——
那段话本身没错,但它是"给审查者看的免责声明",挂在日常操作界面上属于噪音。
⚠️ 结论性的知识(IP 可伪造、登录日志一期只有 PC)**保留在 `apis/xt/log.ts` 的注释与契约里**,
不是删掉了事。
### 八、验证
| 手段 | 结果 |
|---|---|
| `scripts/_verify-fixes.py`(本轮临时脚本,**34 条断言**) | 全过。含 4 条安全边界(非平台带头不生效、非法值 40001、`0` 视为不切换、平台跨租户能力不变)、4 条**写路径**断言(平台代管新建组织落在租户 1、不带头落在租户 0、代管时建班主任账号绑的是**租户 1 的角色行**、代管时发 `PLATFORM_ADMIN` 被 40301 拒)、**8 条日志范围断言**(平台不带头=全量 1250 条、有行、每条带 tenantId、`tenantId=159`→0 条;学校管理员=本租户、指定别家 → 40301;登录日志同口径) |
| `scripts/_check-platform-tasks.py` | 平台不带头的任务列表 31 条(跨租户);带 `X-Tenant-Id: 1` → 31 条(该校的);带 `159`(没有任务的租户)→ **0 条** → 头真的在按租户收窄 |
| 浏览器(真点,非"看代码") | ① 班主任工作台:**不再弹 403**,组织节点/待审显示 `—` 并写明原因,左侧菜单只剩「收集任务 / 审核」;② 学校管理员菜单:名单管理/表单设计器/材料规则三条消失,其余不变;③ 平台「组织架构」:未选学校时提示"请先选择学校",选定 demo 后拿到该校的组织树;切到「账号管理」**选择被保留**(共用 store);④ 平台**工作台**选定学校后:组织节点、任务下拉、待审 **3**、审核情况表出数(改前是空/`—`);⑤ 学校管理员工作台复验:照旧显示(说明权限码那个错**对所有人**的影响都已修回);⑥ 平台「操作日志」:**出数**、多一列「租户」(显示"示范职业学校")、顶部那段说明**已消失**;学校管理员同一页:只有本租户、**没有**「租户」列、说明同样已消失 |
| `pnpm typecheck` | 通过 |
| `mvn -DskipTests package` | 通过(jar 已重启) |
| E2E 回归 | `e2e-m0` 36/36(含 A8/A9/A10 菜单断言)、`e2e-m1-orgs` 48/48、`e2e-m0-user` 55/55、`e2e-m5-notify-wiring` 43/43(dict/notify 菜单仍在)、**`e2e-m5-logs` 44/44**(日志接口本尊)、`e2e-m1-tenant-domain` 6/6(就在我改动的 `AuthInterceptor` 旁边)、`e2e-m8-open` **61/61**(含新增的"真的重试了"断言) |
| `check-wiring.py` / `verify-docs.py` | 未接线声明 **0 条**(第 ⑦ 项:PC 端 7 个权限码全部命中 `PermissionDef`);文档一致性 22 项全过 |
> ⚠️ 三条**自己踩出来的**教训(都属于"看起来正常,其实不生效"):
> ① 把"平台看到 22 个节点""租户 5 个"**写死**在验证脚本里 —— 跑完 E2E 之后它自己变红了
> (E2E 会往 demo 租户里加组织、加租户),而**代码一行没改**。改成从库里算期望值(F-110 同族)。
> ② 「选择了学校」必须放在 store 而不是各页面 `ref`:否则切页面要重选,
> 更糟的是**两个页面各看一所学校**,看起来像数据不一致。
> ③ **平台的"超管"捷径会掩盖权限缺口**:只要平台还在自己的租户上下文里,
> `PermissionChecker` 对它恒真,缺哪个权限码都看不出来;一旦开始代管某所学校,
> 缺口立刻变成 403 或空列表。**要验平台,就得在"代管态"下验一遍。**
> ④ 同一族的第四例:**"按租户写死"的接口,对平台就是"永远查不到"**(本轮的操作日志)。
> 平台运营的 tenantId 是 0,任何 `WHERE tenant_id = 当前租户` 都会静默返回空。
> 已在 `OrgService.tree` / `UserService.list` / `TaskService.seesWholeTenant` / `LogController` 四处统一处理;
> **下次新增"租户内"的查询接口时,先问一句:平台运营调它会返回什么?**
---
## §一·三十四 PC 端四项走查修复(2026-10-09 晚)
> 客户在验收里又点了 4 件事,三件已改;**第 3 件(配色)改了之后客户要求整体还原** ——
> 第四节专门记了这条"不要再动",免得下一个会话好心重做一遍。
### 一、平台运维「操作日志」:去掉用户 ID + 分页器根本看不见
| 现象 | 根因 | 修法 |
|---|---|---|
| 「操作人」列是 `张校长 / ID 2` 两行 | 数字 ID 对看日志的人没有意义(要 ID 的时候查库更快) | 只留账号/姓名(`operatorName`);接口照旧返回 `operatorId`,只是不显示 |
| **分页器"没有正确展示"** | 它**在 DOM 里一直是好的**(`共 1245 条 … 20 条/页`),但被裁在可视区之外:`GiPageLayout` 的内容区是 `overflow: hidden`(页面本身不滚),而「一页 20 条 + 工具栏 + 提示条」比屏幕高 → 卡片连着分页器一起被切掉,怎么滚都看不到 | 新增全局类 **`.g-list-card`**(`styles/global.scss`):卡片撑满内容区 → 表体自己滚 → 分页器钉在卡片底部;表格改 `:scroll="{ x, y: '100%' }"` |
| 「N 条/页」改了以后数字对不上 | Arco 表格在 `pagination` 非 false 时**自己也会再切一次数据**(内部 `flattenData` 会 `slice`),只改本地每页条数、不重新取数,就会得到"分页器说 50 条/页、表里还是 20 条" | 补 `@page-size-change` → 回到第 1 页重新取数 |
⚠️ `y: '100%'` 不是可选项:**字符串**形式的 `y` 才会让 Arco 把表头与表体**拆开**
(内部 `splitTable`,表头钉住、表体自己滚)。不写它时 `.arco-table-content` 的
`overflow-y` 是 `hidden`(那是给横向滚动条留的),多出来的行会被**直接裁掉且无法滚动**。
### 二、收集任务:状态切换改成单独一行的 tab
原来「全部 / 草稿 / …」是和「新建任务 / 批次号 / 搜索」挤在同一行的一排按钮 ——
一排长得一样的按钮分不清"哪个是当前视图、哪个是动作"。
现在 `<a-tabs hide-content>` 单独占一行,数据源仍是 `statusFilter`
(tab 的 key ↔ status 的映射只在 `STATUS_TABS` 一处定义,别再写第二份)。
⚠️ **加了这一行之后,任务页也踩了同一个分页器坑**(卡片变高 → 分页器被顶出可视区):
同一处 `.g-list-card` 一并修掉。**改这类"往卡片里加东西"的活,先看分页器还在不在视野里。**
### 三、通知铃铛:按客户要求先去掉
删掉顶栏铃铛与 `HeaderRightBar/Notice.vue`(那是 Gi 模板的假数据:写死 9 条、点了没反应,
和站内信没有任何关系)。将来要做,数据源是「通知记录」(`/collect/notify`,接口与页面都已就绪),
**不要恢复成写死的假列表**。
### 四、配色:改过一版腾讯云深蓝外壳,**客户要求整体还原**(不要再改)
本轮先按"左侧菜单栏 + 顶栏用腾讯云控制台深蓝、不要纯白"改了一版
(新增 `--xt-shell-*` 变量、`.g-shell`、菜单恒定 `theme="dark"`、并删掉设置抽屉里的「深色菜单」开关),
**客户看到后明确要求还原配色**,已按文件 `git checkout` 回原样:外壳仍是纯白,
`isMenuDark` / `.app-menu-dark` / 「深色菜单」开关**都保持原样**。
> **不要再"顺手"改配色。** 要改先问。
### 五、这一轮踩到的两个坑(都值得记)
1. **HMR 下 `Drawer.open({ content: () => h(SettingDrawerPanel) })` 不会热更新**:
该组件只出现在闭包里、不在任何模板里 —— 改了它,页面看起来毫无变化,
很容易把"没生效"误判成"改错了/改不动"。
**验证前先真重载**(`about:blank` → 目标 URL;同 URL 再 open 不一定会重新拉模块)。
2. **演示账号不是 `frontend-pc/README.md` 里那个**:那里的 `admin / 123456` 是 Gi 模板的旧值,
本项目是 **`admin / Admin@123` + 租户码 `demo`**
(`python scripts/dev-login.py` 用的就是它)。账号在多个单位下重名时,
登录页才会出现「租户码」输入框(服务端 `40012`)。
### 六、未做(同一族的问题,留给下一轮)
「通知记录」与「账号管理」两页的分页器**会被同样地裁掉**
(实测分页器顶边 1612 / 886,视口高 900)。修法是给这两页的卡片加同一个 `.g-list-card`
并把表格改成 `y: '100%'`,本轮没动(客户只报了操作日志那一处)。
+32
View File
@@ -0,0 +1,32 @@
import type { AxiosRequestConfig } from 'axios'
/**
* 平台运营的「当前学校」——请求头 `X-Tenant-Id`。
*
* ## 为什么需要它
*
* 平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户拦截器因此对它
* **整个关掉了租户过滤**(`MybatisPlusConfig`)。于是「组织架构」「账号管理」
* 这类**本来就是租户内**的页面,在平台账号下拿到的是**所有租户混在一起**的数据 ——
* 而且看不出错:只有一所学校有数据时,它和学校管理员看到的完全一样。
*
* 所以平台要代管某所学校时,必须**显式指明是哪一所**:带上这个头,服务端会把
* 本次请求的租户上下文换成目标租户(`ActingTenantResolver` + `AuthInterceptor`),
* 之后租户过滤、写入归属、权限判定都按"我就是那所学校"走。
*
* ## 三条使用约定
*
* 1. **只有平台运营的页面才传它**(页面用 `userStore.roles.includes('PLATFORM_ADMIN')` 判断)。
* 非平台身份即使传了,服务端也一律忽略 —— 那是安全边界,不是"顺手支持一下"。
* 2. **不传 / 传空 = 不发这个头**:平台照旧可以跨租户看租户列表、全量日志。
* 3. 学校管理员的页面**不要**传(他们的租户由 token 决定,传了也没用)。
*/
export const ACTING_TENANT_HEADER = 'X-Tenant-Id'
/** 组装"代管租户"请求配置;没有租户时返回空对象(不发头) */
export function actingTenantConfig(tenantId?: number | null): AxiosRequestConfig {
if (!tenantId) {
return {}
}
return { headers: { [ACTING_TENANT_HEADER]: String(tenantId) } }
}
+3 -2
View File
@@ -24,6 +24,7 @@
* 数字只用于选色。这样即便枚举有调整,界面也不会说错话。 * 数字只用于选色。这样即便枚举有调整,界面也不会说错话。
*/ */
import http from '@/utils/http' import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 带业务码的错误(`@/utils/http` 的拦截器会把 `code` 挂到 error 上)。 */ /** 带业务码的错误(`@/utils/http` 的拦截器会把 `code` 挂到 error 上)。 */
export type BizError = Error & { bizCode?: number, bizData?: unknown } export type BizError = Error & { bizCode?: number, bizData?: unknown }
@@ -422,6 +423,6 @@ export function getAuditStatistics(params: {
orgId?: number orgId?: number
from?: string from?: string
to?: string to?: string
}) { }, tenantId?: number | null) {
return http.get<AuditStatisticsVO>('/audit/statistics', params) return http.get<AuditStatisticsVO>('/audit/statistics', params, actingTenantConfig(tenantId))
} }
+14 -9
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。 * 分页 `{ list, total }`(适配层会补一份 `records`)。
*/ */
import http from '@/utils/http' import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 组织树节点(§6.1) */ /** 组织树节点(§6.1) */
export interface OrgTreeVO { export interface OrgTreeVO {
@@ -34,24 +35,28 @@ export interface OrgUpsertReq {
/** /**
* 组织树。 * 组织树。
* @param withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开) * @param withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开)
* @param tenantId **仅平台运营**:代管哪个学校的组织(见 `acting-tenant.ts`)。
* 不传 = 不代管 —— 此时服务端只会返回平台自己租户的组织(空树),
* **不会**再把所有租户的组织混在一起端出来。
*/ */
export function getOrgTree(params: { withCount?: boolean, rootId?: number } = {}) { export function getOrgTree(params: { withCount?: boolean, rootId?: number, tenantId?: number | null } = {}) {
return http.get<OrgTreeVO[]>('/orgs/tree', params) const { tenantId, ...query } = params
return http.get<OrgTreeVO[]>('/orgs/tree', query, actingTenantConfig(tenantId))
} }
/** 新增,返回新节点 id */ /** 新增,返回新节点 id(`tenantId` 仅平台运营:新建到哪个学校下) */
export function addOrg(data: OrgUpsertReq) { export function addOrg(data: OrgUpsertReq, tenantId?: number | null) {
return http.post<number>('/orgs', data) return http.post<number>('/orgs', data, actingTenantConfig(tenantId))
} }
/** 修改;变更层级时服务端会自动重算整棵子树的 ancestor_path */ /** 修改;变更层级时服务端会自动重算整棵子树的 ancestor_path */
export function updateOrg(id: number, data: OrgUpsertReq) { export function updateOrg(id: number, data: OrgUpsertReq, tenantId?: number | null) {
return http.put<void>(`/orgs/${id}`, data) return http.put<void>(`/orgs/${id}`, data, actingTenantConfig(tenantId))
} }
/** 删除;有下级组织或账号时返回 40015 并给出占用清单 */ /** 删除;有下级组织或账号时返回 40015 并给出占用清单 */
export function deleteOrg(id: number) { export function deleteOrg(id: number, tenantId?: number | null) {
return http.delete<void>(`/orgs/${id}`) return http.delete<void>(`/orgs/${id}`, actingTenantConfig(tenantId))
} }
/** 组织类型 → 中文名(与 DDL 的 org_type 对齐) */ /** 组织类型 → 中文名(与 DDL 的 org_type 对齐) */
+11 -5
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。 * 分页 `{ list, total }`(适配层会补一份 `records`)。
*/ */
import http from '@/utils/http' import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
import { getUserList } from './user' import { getUserList } from './user'
/** 审核流配置(§3.2) */ /** 审核流配置(§3.2) */
@@ -117,9 +118,14 @@ export interface TaskListQuery {
pageSize?: number pageSize?: number
} }
/** 任务分页列表。班主任只会拿到自己带的班级的任务 */ /**
export function getTaskList(params: TaskListQuery) { * 任务分页列表。班主任只会拿到自己带的班级的任务
return http.get<{ list: TaskVO[], total: number }>('/tasks', params) *
* @param tenantId **仅平台运营**:看哪所学校的任务(见 `./acting-tenant`)。
* 不传 = 平台自己租户(空列表)—— 不再把各租户的任务混在一起。
*/
export function getTaskList(params: TaskListQuery, tenantId?: number | null) {
return http.get<{ list: TaskVO[], total: number }>('/tasks', params, actingTenantConfig(tenantId))
} }
export function getTaskDetail(id: number) { export function getTaskDetail(id: number) {
@@ -343,6 +349,6 @@ export interface TaskStatisticsVO {
* ⚠️ `submitRate`(已提交/应填)与 `passRate`(已通过/应填)**必须分开显示**: * ⚠️ `submitRate`(已提交/应填)与 `passRate`(已通过/应填)**必须分开显示**:
* 已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。 * 已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。
*/ */
export function getTaskStatistics(taskId: number) { export function getTaskStatistics(taskId: number, tenantId?: number | null) {
return http.get<TaskStatisticsVO>(`/tasks/${taskId}/statistics`) return http.get<TaskStatisticsVO>(`/tasks/${taskId}/statistics`, undefined, actingTenantConfig(tenantId))
} }
+28 -13
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。 * 分页 `{ list, total }`(适配层会补一份 `records`)。
*/ */
import http from '@/utils/http' import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 账号列表项(§5.6) */ /** 账号列表项(§5.6) */
export interface UserVO { export interface UserVO {
@@ -75,43 +76,57 @@ export interface UserListQuery {
pageSize?: number pageSize?: number
} }
// ── 账号接口 ────────────────────────────────────────
//
// ⚠️ 每个接口的最后都有一个可选 `tenantId`:**仅平台运营**用来指明"代管哪所学校"
// (见 `acting-tenant.ts`)。学校管理员的页面一律不传 —— 他们的租户由 token 决定。
// 不传时服务端只返回平台自己租户的账号,不会再把所有租户的账号混在一起。
/** 账号分页列表 */ /** 账号分页列表 */
export function getUserList(params: UserListQuery) { export function getUserList(params: UserListQuery, tenantId?: number | null) {
return http.get<{ list: UserVO[], total: number }>('/users', params) return http.get<{ list: UserVO[], total: number }>('/users', params, actingTenantConfig(tenantId))
} }
/** 新增账号,返回初始密码(仅此一次) */ /** 新增账号,返回初始密码(仅此一次) */
export function addUser(data: UserUpsertReq) { export function addUser(data: UserUpsertReq, tenantId?: number | null) {
return http.post<UserCreateResult>('/users', data) return http.post<UserCreateResult>('/users', data, actingTenantConfig(tenantId))
} }
/** 修改账号;登录名与密码不在此接口修改 */ /** 修改账号;登录名与密码不在此接口修改 */
export function updateUser(id: number, data: UserUpsertReq) { export function updateUser(id: number, data: UserUpsertReq, tenantId?: number | null) {
return http.put<void>(`/users/${id}`, data) return http.put<void>(`/users/${id}`, data, actingTenantConfig(tenantId))
} }
/** 停用(软删)。有在办任务返回 40014;不能停用自己或最后一个管理员 */ /** 停用(软删)。有在办任务返回 40014;不能停用自己或最后一个管理员 */
export function deleteUser(id: number) { export function deleteUser(id: number, tenantId?: number | null) {
return http.delete<void>(`/users/${id}`) return http.delete<void>(`/users/${id}`, actingTenantConfig(tenantId))
} }
/** 启用 / 停用(只改 status,数据保留可再启用) */ /** 启用 / 停用(只改 status,数据保留可再启用) */
export function setUserStatus(id: number, status: number) { export function setUserStatus(id: number, status: number, tenantId?: number | null) {
// status 后端设计为查询参数(PUT /users/{id}/status?status=0), // status 后端设计为查询参数(PUT /users/{id}/status?status=0),
// 所以走 config.params 而不是 body // 所以走 config.params 而不是 body
return http.put<void>(`/users/${id}/status`, undefined, { params: { status } }) return http.put<void>(`/users/${id}/status`, undefined, {
...actingTenantConfig(tenantId),
params: { status }
})
} }
/** 重置密码。mode 省略即 GENERATE(服务端生成)会同时解除该账号的登录锁定 */ /** 重置密码。mode 省略即 GENERATE(服务端生成)会同时解除该账号的登录锁定 */
export function resetUserPassword(id: number, data: { mode?: string, newPassword?: string } = {}) { export function resetUserPassword(
return http.post<UserResetResult>(`/users/${id}/reset-password`, data) id: number,
data: { mode?: string, newPassword?: string } = {},
tenantId?: number | null
) {
return http.post<UserResetResult>(`/users/${id}/reset-password`, data, actingTenantConfig(tenantId))
} }
/** 批量导入账号(Excel ≤10MB),返回体检报告 */ /** 批量导入账号(Excel ≤10MB),返回体检报告 */
export function importUsers(file: File, defaultRoleCode?: string) { export function importUsers(file: File, defaultRoleCode?: string, tenantId?: number | null) {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
return http.post<UserImportResult>('/users/import', fd, { return http.post<UserImportResult>('/users/import', fd, {
...actingTenantConfig(tenantId),
params: defaultRoleCode ? { defaultRoleCode } : undefined params: defaultRoleCode ? { defaultRoleCode } : undefined
}) })
} }
+2
View File
@@ -91,6 +91,7 @@ declare module 'vue' {
GiTable: typeof import('./components/GiTable/GiTable.vue')['default'] GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default'] GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
GiTag: typeof import('./components/GiTag/GiTag.tsx')['default'] GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
GiTenantPicker: typeof import('./components/GiTenantPicker/GiTenantPicker.vue')['default']
GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default'] GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
@@ -179,6 +180,7 @@ declare global {
const GiTable: typeof import('./components/GiTable/GiTable.vue')['default'] const GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
const GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default'] const GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
const GiTag: typeof import('./components/GiTag/GiTag.tsx')['default'] const GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
const GiTenantPicker: typeof import('./components/GiTenantPicker/GiTenantPicker.vue')['default']
const GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default'] const GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink'] const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView'] const RouterView: typeof import('vue-router')['RouterView']
@@ -0,0 +1,74 @@
<template>
<a-select
v-model="model"
class="gi-tenant-picker"
:placeholder="props.placeholder"
:loading="loading"
:disabled="props.disabled"
:style="{ width: props.width }"
allow-search
allow-clear
>
<a-option v-for="t in options" :key="t.id" :value="t.id" :disabled="t.status === 0">
{{ t.tenantName }}({{ t.tenantCode }}){{ t.status === 0 ? ' · 已停用' : '' }}
</a-option>
</a-select>
</template>
<script setup lang="ts">
import type { TenantVO } from '@/apis/xt/tenant'
import { getTenantList } from '@/apis/xt/tenant'
/**
* 「选择学校」——平台运营的当前租户。
*
* 为什么要有这个东西:平台运营是跨租户身份,服务端对它的租户过滤是**关掉的**,
* 所以它必须**先指明看哪所学校**,否则「组织架构」「账号管理」会拿到所有租户混在一起的数据。
* 选中的值由页面带进请求头 `X-Tenant-Id`(见 `@/apis/xt/acting-tenant`)。
*
* ⚠️ 只有平台运营的页面才渲染它 —— 学校管理员的租户由 token 决定,给他一个
* "选择学校"的下拉框是纯粹的困惑(而且服务端会忽略他传的值)。
*
* ⚠️ 租户列表接口只对平台运营开放(学校管理员调它得 40301),所以本组件
* **不要**在非平台页面里渲染。
*/
defineOptions({ name: 'GiTenantPicker' })
const model = defineModel<number | undefined>({ default: undefined })
const props = withDefaults(defineProps<{
placeholder?: string
disabled?: boolean
width?: string
}>(), {
placeholder: '选择学校',
disabled: false,
width: '100%'
})
const loading = ref(false)
const options = ref<TenantVO[]>([])
async function load() {
loading.value = true
try {
// 一期租户规模是"几十所"量级(接口上限 200),一次取完比做分页下拉简单得多。
// 已停用的也列出来但不可选 —— 服务端会拒("目标单位不存在或已停用"),
// 与其让人选完才知道,不如在选择时就说明。
const res = await getTenantList({ pageNum: 1, pageSize: 200 })
options.value = res.data?.list ?? []
} finally {
loading.value = false
}
}
onMounted(load)
defineExpose({ reload: load })
</script>
<style lang="scss" scoped>
.gi-tenant-picker {
min-width: 180px;
}
</style>
+1 -71
View File
@@ -30,20 +30,6 @@ export interface XtMenu {
ready: boolean ready: boolean
/** 待建说明 */ /** 待建说明 */
note?: string note?: string
/**
* 这个功能**其实已经有了**,只是入口不在左侧菜单里(例如"名单管理"是按任务的,
* 入口在「收集任务」每一行的链接里)。
*
* ⚠️ 有它的时候,「待建」页就不再显示"还没做",而是给出真实入口的指路 + 跳转按钮。
* 没有它 = 确实还没做。两者的区别很重要:说"还没做"而功能其实在隔壁,
* 会让人**得出错误结论**(比找不到更糟)。
*/
entryAt?: {
/** 跳到哪个路由 */
path: string
/** 到了之后该点哪里(要具体到"点哪一行里的哪个链接") */
hint: string
}
} }
/** 分组顺序(决定左侧菜单顺序) */ /** 分组顺序(决定左侧菜单顺序) */
@@ -67,22 +53,6 @@ export const XT_MENUS: XtMenu[] = [
sort: 11, sort: 11,
ready: true 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', key: 'join',
title: '加入申请', 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', key: 'cleanup',
title: '历史整理器', title: '历史整理器',
@@ -338,17 +278,7 @@ function readyItem(menu: XtMenu): UserRouteItem {
} as 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 { export function findMenuByPath(path: string): XtMenu | undefined {
return XT_MENUS.find((m) => m.path === path) return XT_MENUS.find((m) => m.path === path)
} }
@@ -1,85 +0,0 @@
<!--
@file Message 组件
@description 消息通知组件,包含通知、关注和待办三个标签页
-->
<template>
<div class="message">
<!-- 消息标签页 -->
<a-tabs :active-key="activeTab" style="background-color: transparent;" @change="handleTabChange">
<a-tab-pane v-for="tab in tabs" :key="tab.key">
<template #title>{{ tab.title }}({{ tab.count }})</template>
</a-tab-pane>
</a-tabs>
<!-- 消息列表 -->
<section class="message-list">
<a-comment v-for="item in messageList" :key="item.id" :author="item.name" :content="item.content"
:datetime="item.datetime">
<template #avatar>
<a-avatar>
<img :src="item.avatar" :alt="item.name">
</a-avatar>
</template>
</a-comment>
</section>
</div>
</template>
<script setup lang="ts">
import type { TabsInstance } from '@arco-design/web-vue'
/** 组件名称 */
defineOptions({ name: 'Message' })
/** 消息类型定义 */
interface MessageItem {
id?: string | number
name: string
datetime: string
content: string
avatar: string
actionText?: string
}
/** 标签页配置 */
const tabs = [
{ key: '1', title: '通知', count: 1 },
{ key: '2', title: '关注', count: 1 },
{ key: '3', title: '待办', count: 2 }
]
/** 组件状态 */
const activeTab = ref('1')
/** 消息列表数据 */
const messageList = ref<MessageItem[]>([
{
id: 1,
name: 'Socrates',
datetime: '1小时之前',
content: 'Comment body content.',
avatar: 'https://img1.baidu.com/it/u=1817951587,3188870642&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
},
{
id: 2,
name: '木木糖醇',
datetime: '2小时之前',
content: '关注了你',
avatar: 'https://s1.ax1x.com/2022/06/14/XhteeO.jpg'
},
{
id: 3,
name: '徐欣',
datetime: '2个半小时之前',
content: '收藏了你的文章',
avatar: 'https://s1.ax1x.com/2022/06/14/XhtSwF.jpg'
}
])
/** 处理标签页切换 */
const handleTabChange: TabsInstance['onChange'] = (key) => {
activeTab.value = key as string
}
</script>
<style lang="scss" scoped></style>
@@ -14,19 +14,12 @@
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<!-- 消息通知下拉框 --> <!--
<a-popover position="bottom" trigger="click"> 消息通知(铃铛)**暂时去掉**(2026-10-09 客户要求):
<a-badge :count="9" dot> 原来的铃铛是一个写死 9 条假数据的下拉框,站内信还没接到这个入口上。
<a-button size="mini" class="g-hover-btn"> 真要做的时候,数据源是「通知记录」(`/collect/notify`,接口已实现),
<template #icon> 不要恢复成写死的假列表。
<icon-notification :size="18" /> -->
</template>
</a-button>
</a-badge>
<template #content>
<Notice></Notice>
</template>
</a-popover>
<!-- 全屏切换按钮 --> <!-- 全屏切换按钮 -->
<a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom"> <a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom">
@@ -87,7 +80,6 @@ import { useFullscreen } from '@vueuse/core'
import { useBreakpoint, useTheme } from '@/hooks' import { useBreakpoint, useTheme } from '@/hooks'
import { useAppStore, useUserStore } from '@/stores' import { useAppStore, useUserStore } from '@/stores'
import ChangePasswordModal from './ChangePasswordModal.vue' import ChangePasswordModal from './ChangePasswordModal.vue'
import Notice from './Notice.vue'
import SettingDrawerPanel from './SettingDrawerPanel.vue' import SettingDrawerPanel from './SettingDrawerPanel.vue'
/** 组件名称 */ /** 组件名称 */
+1
View File
@@ -1,6 +1,7 @@
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export * from './useActingTenantStore'
export * from './useAppStore' export * from './useAppStore'
export * from './useDictStore' export * from './useDictStore'
export * from './useMenuBadgeStore' export * from './useMenuBadgeStore'
@@ -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<number | undefined>(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 })
+4
View File
@@ -10,6 +10,7 @@ import { buildXtRoutes } from '@/config/xt-routes'
import { resetRouter } from '@/router' import { resetRouter } from '@/router'
import { clearToken, getToken, setToken } from '@/utils/auth' import { clearToken, getToken, setToken } from '@/utils/auth'
import { isHttp } from '@/utils/validate' import { isHttp } from '@/utils/validate'
import { useActingTenantStore } from './useActingTenantStore'
import { useRouteStore } from './useRouteStore' import { useRouteStore } from './useRouteStore'
/** 登录参数:password 必须是 RSA 加密后的密文(见 @/utils/rsa) */ /** 登录参数:password 必须是 RSA 加密后的密文(见 @/utils/rsa) */
@@ -101,6 +102,9 @@ const storeSetup = () => {
resetRouter() resetRouter()
profile.value = null profile.value = null
menuKeys.value = [] menuKeys.value = []
// 平台运营的"当前学校"属于**上一次身份**的上下文,退出时一起清掉:
// 留着它,下一个登录的人(可能是学校管理员)会继承一个自己没选过的租户
useActingTenantStore().clear()
} }
return true return true
} }
+47
View File
@@ -190,6 +190,53 @@
margin-bottom: 12px; margin-bottom: 12px;
} }
/**
* 列表页卡片(卡片里装着「工具栏 + 表格 + 分页器」)。
*
* 为什么必须有它:`GiPageLayout` 的内容区是 `overflow: hidden`(页面本身不滚),
* 而一页 10~20 条比屏幕高 —— 不加这层高度链路,**分页器会被裁到可视区之外**:
* DOM 里明明有(`共 1245 条 … 20 条/页`),人却看不见,看着就像"分页器没做/坏了"。
* 操作日志页(20 条/页)与收集任务页(加了状态 tab 之后)都踩过这一脚。
*
* 用法:`<a-card class="g-list-card">`,表格必须给 `: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权重优先级问题 // 以下这些类名要放到下面,涉及到css权重优先级问题
.g-mt { .g-mt {
margin-top: var(--margin); margin-top: var(--margin);
+16 -65
View File
@@ -8,43 +8,17 @@
</a-space> </a-space>
</template> </template>
<template #extra> <template #extra>
<a-tag :color="menu?.entryAt ? 'green' : 'orange'"> <a-tag color="orange">{{ milestone }}</a-tag>
{{ menu?.entryAt ? '功能已就绪' : milestone }}
</a-tag>
</template> </template>
<!-- <a-alert type="info" :show-icon="false">
⚠️ 两种说法必须分开,不能都说「还没做」。
名单管理 / 材料规则 / 表单设计 这三个菜单项对应的功能**早就做好了**,
只是它们**按任务**(入口在「收集任务」每一行的链接里)。
以前这里一律说"这个页面还没做" + 说"已实现的页面只有工作台、组织架构、租户管理"——
而那句话早就过期了,且**没有任何机制会让它不过期**。
说"还没做"而功能其实在隔壁,比找不到更糟:**它让人得出错误结论。**
-->
<a-alert v-if="menu?.entryAt" type="success" :show-icon="false">
<template #title>
<strong>这个功能已经有了,只是入口不在这里。</strong>
{{ menu?.note }}
</template>
</a-alert>
<a-alert v-else type="info" :show-icon="false">
<template #title> <template #title>
<strong>这个页面还没做。</strong> <strong>这个页面还没做。</strong>
{{ menu?.note ?? '该功能尚未实现。' }} {{ menu?.note ?? '该功能尚未实现。' }}
</template> </template>
</a-alert> </a-alert>
<div v-if="menu?.entryAt" class="coming-soon__go"> <p class="coming-soon__why">
<a-button type="primary" @click="goEntry">
<template #icon><IconArrowRight /></template>
去「{{ entryTitle }}」
</a-button>
<div class="coming-soon__hint">{{ menu?.entryAt?.hint }}</div>
</div>
<p v-else class="coming-soon__why">
左侧菜单是按 <strong>服务端返回的角色权限</strong> 渲染的,所以你能看到它, 左侧菜单是按 <strong>服务端返回的角色权限</strong> 渲染的,所以你能看到它,
说明你的角色确实有这个权限 —— 只是界面还没排到。 说明你的角色确实有这个权限 —— 只是界面还没排到。
</p> </p>
@@ -53,7 +27,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { IconArrowRight, IconClockCircle } from '@arco-design/web-vue/es/icon' import { IconClockCircle } from '@arco-design/web-vue/es/icon'
import { findMenuByPath } from '@/config/xt-routes' import { findMenuByPath } from '@/config/xt-routes'
/** /**
@@ -64,40 +38,27 @@ import { findMenuByPath } from '@/config/xt-routes'
* 2. 隐藏会让人以为"没这个功能",而显示"排队中 + 属于哪个里程碑"是更准确的信息; * 2. 隐藏会让人以为"没这个功能",而显示"排队中 + 属于哪个里程碑"是更准确的信息;
* 3. 一个页面服务所有占位路由,成本极低。 * 3. 一个页面服务所有占位路由,成本极低。
* *
* ⚠️ 但这个页面**过去在说谎**:它对"功能已经有了、只是入口在别处"的菜单项 * ⚠️ 这一页**只服务"确实还没做"的菜单**(现在只剩「角色与授权」)。
* 也说"还没做",还列了一句早已过期的"已实现的页面"清单。 * 曾经它还要区分另一种情况 —— "功能已经有了、只是入口在别处"(名单管理 / 表单设计 /
* 现在按 `entryAt` 分两种说法,并**删掉了那句清单** —— * 材料规则,都在「收集任务」行内的链接里)。那三条菜单已按客户要求**直接去掉**:
* 任何"现状清单"写死在代码里都会静默过期,而没人会记得回来改它。 * 导航里挂着一条点进去只有指路的条目,既占位置又让人以为这里应该有事可做。
*/ * 所以 `entryAt` 那套分支("功能已就绪"标签 + 跳转按钮)也一并删了 ——
defineOptions({ name: 'ComingSoon' }) * 留着就是一段永远不会被走到的代码。
*
const route = useRoute()
const router = useRouter()
/**
* ⚠️ 按**路径**找,不要按 `route.meta.menu`。 * ⚠️ 按**路径**找,不要按 `route.meta.menu`。
* *
* 原来写的是 `findMenu(route.meta.menu ?? '')`,而 `formatAsyncRoutes` 构造的 meta 里 * 原来写的是 `findMenu(route.meta.menu ?? '')`,而 `formatAsyncRoutes` 构造的 meta 里
* **没有 `menu` 这个键**(只有 hidden/keepAlive/title/icon/... )—— * **没有 `menu` 这个键**(只有 hidden/keepAlive/title/icon/... )——
* 于是这里永远拿到 `undefined`,页面永远显示兜底文案 * 于是这里永远拿到 `undefined`,页面永远显示兜底文案
* 「功能 / 规划中 / 该功能尚未实现」,**每个菜单项自己的 note 从来没显示出来过**。 * 「功能 / 规划中 / 该功能尚未实现」,**每个菜单项自己的 note 从来没显示出来过**。
* 而 note 恰恰是这一页最有用的东西(它写着"真实入口在哪")。 * 而 note 恰恰是这一页最有用的东西。
*/ */
defineOptions({ name: 'ComingSoon' })
const route = useRoute()
const menu = computed(() => findMenuByPath(route.path)) const menu = computed(() => findMenuByPath(route.path))
/** 跳过去之后那个页面的标题(从路由表里取,免得两处各写一遍)。 */
const entryTitle = computed(() => {
const path = menu.value?.entryAt?.path
return path ? (findMenuByPath(path)?.title ?? '目标页面') : '目标页面'
})
function goEntry() {
const path = menu.value?.entryAt?.path
if (path) {
router.push(path)
}
}
/** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */ /** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */
const MILESTONES: Record<string, string> = { const MILESTONES: Record<string, string> = {
role: '二期' role: '二期'
@@ -117,14 +78,4 @@ const milestone = computed(() => MILESTONES[menu.value?.key ?? ''] ?? '规划中
color: var(--color-text-1); color: var(--color-text-1);
} }
} }
.coming-soon__go {
margin-top: 20px;
}
.coming-soon__hint {
margin-top: 8px;
font-size: 13px;
color: var(--color-text-3);
}
</style> </style>
+116 -21
View File
@@ -57,20 +57,33 @@
<template #title>数据看板</template> <template #title>数据看板</template>
<template #extra> <template #extra>
<a-space> <a-space>
<!--
平台运营必须先选学校:任务、统计、审核数都是**租户内**的数据,
而服务端对平台的租户过滤是关掉的 —— 不选就会把各学校的任务混在一起。
-->
<GiTenantPicker
v-if="isPlatform"
v-model="actingStore.tenantId"
width="200px"
placeholder="选择学校(平台运营)"
/>
<a-select <a-select
v-model="boardTaskId" v-model="boardTaskId"
placeholder="选择一个任务" placeholder="选择一个任务"
allow-search allow-search
style="width: 260px" style="width: 260px"
:loading="taskLoading" :loading="taskLoading"
:disabled="needPickTenant"
:options="taskOptions" :options="taskOptions"
@change="loadBoard" @change="loadBoard"
/> />
<a-button :loading="boardLoading" @click="loadBoard">刷新</a-button> <a-button :loading="boardLoading" :disabled="needPickTenant" @click="loadBoard">刷新</a-button>
</a-space> </a-space>
</template> </template>
<a-empty v-if="!boardTaskId" description="选一个任务,看它的填报进度与审核情况" /> <a-empty v-if="needPickTenant" description="平台运营看的是某一所学校的进度:请先选择学校" />
<a-empty v-else-if="!boardTaskId" description="选一个任务,看它的填报进度与审核情况" />
<a-spin v-else :loading="boardLoading" style="display: block"> <a-spin v-else :loading="boardLoading" style="display: block">
<!-- 进度四格:应填 / 已提交 / 已通过 / 待审 --> <!-- 进度四格:应填 / 已提交 / 已通过 / 待审 -->
@@ -116,6 +129,7 @@
</span> </span>
</div> </div>
<a-table <a-table
v-if="canViewAllProgress"
:data="auditRows" :data="auditRows"
:pagination="false" :pagination="false"
size="small" size="small"
@@ -127,6 +141,11 @@
<a-table-column title="说明" data-index="foot" ellipsis tooltip /> <a-table-column title="说明" data-index="foot" ellipsis tooltip />
</template> </template>
</a-table> </a-table>
<!--
班主任(只有 progress:viewOwn)在这里看到的是**空表**,而空表不解释任何事。
如实说明"这里为什么不给你看",比留一张空表更清楚。
-->
<a-empty v-else description="审核统计按全租户口径,需要「全租户进度」权限" />
</a-col> </a-col>
</a-row> </a-row>
@@ -151,7 +170,7 @@ import * as orgApi from '@/apis/xt/org'
import * as taskApi from '@/apis/xt/task' import * as taskApi from '@/apis/xt/task'
import * as tenantApi from '@/apis/xt/tenant' import * as tenantApi from '@/apis/xt/tenant'
import { ROLE_NAMES, XT_MENUS } from '@/config/xt-routes' import { ROLE_NAMES, XT_MENUS } from '@/config/xt-routes'
import { useUserStore } from '@/stores' import { useActingTenantStore, useUserStore } from '@/stores'
/** /**
* 工作台。 * 工作台。
@@ -177,6 +196,47 @@ const roleLabel = computed(() =>
// 只会让这个判断恒为 false(之前就是这样:平台管理员也看到"仅平台运营可见") // 只会让这个判断恒为 false(之前就是这样:平台管理员也看到"仅平台运营可见")
const isPlatform = computed(() => userStore.menuKeys?.includes('tenant') ?? false) const isPlatform = computed(() => userStore.menuKeys?.includes('tenant') ?? false)
/**
* ⚠️ 两个"能不能调"的判断,缺一个就会在页面上弹 403。
*
* 工作台是所有角色共用的落地页,但它里面有两个接口是**按角色开放的**:
* · `GET /orgs/tree` 要 `org:view` —— 班主任/副班主任/审核员都没有;
* · `GET /audit/statistics` 要 `progress:viewAll` —— 班主任只有 `progress:viewOwn`。
*
* 以前这两个是无条件调的,于是**班主任一进工作台就弹「拒绝访问(403)」**(实测)。
* 而 403 来自 HTTP 状态码,`try/catch` 拦不住它的 toast —— 前端唯一正确的做法是
* **不去调它**,并把那一格如实显示成"—"(本页的取舍就是"只显示真实能查到的数字")。
*/
const canViewOrg = computed(() => userStore.permissions.includes('org:view'))
/**
* ⚠️ 权限码是 **`progress:viewAll`**(驼峰),不是 `progress:view:all` ——
* 权威来源是后端 `security/PermissionDef`(`PROGRESS_VIEW_ALL("progress:viewAll", ...)`)。
* 我第一版就写成了带冒号的,结果这个判断**恒为 false**:「审核情况」对**所有人**都不显示,
* 而 typecheck / lint / 后端全都不报错(字符串对不上是纯运行期的事)。
* 现在 `scripts/check-wiring.py` 会做这层对齐检查。
*/
const canViewAllProgress = computed(() => userStore.permissions.includes('progress:viewAll'))
/**
* 平台运营的「当前学校」(请求头 `X-Tenant-Id`,见 `@/apis/xt/acting-tenant`)。
*
* ⚠️ 工作台也必须有这个选择:这里的**任务、统计、审核数**全是租户内的数据,
* 而服务端对平台的租户过滤是关掉的 —— 不给选择就会把各学校的任务混在一个下拉里,
* 选中之后看到的进度是"另一所学校的",而界面上没有任何地方会提示这件事。
*
* 选择与「组织架构 / 账号管理」共用同一个 store(见 `useActingTenantStore`)。
*/
const actingStore = useActingTenantStore()
const needPickTenant = computed(() => isPlatform.value && !actingStore.tenantId)
/**
* 「组织节点」这一格能不能显示真实数字。
*
* 平台运营要先选定学校(选定后就是那所学校的节点数);没选时显示"—",
* 而不是让 `/orgs/tree` 返回一个"0 个"(那会让人以为全平台没有组织)。
*/
const orgCardShowable = computed(() => canViewOrg.value && !needPickTenant.value)
/** 已实现且当前角色可访问的页面 */ /** 已实现且当前角色可访问的页面 */
const shortcuts = computed(() => const shortcuts = computed(() =>
XT_MENUS.filter((m) => m.ready && userStore.menuKeys.includes(m.key)) XT_MENUS.filter((m) => m.ready && userStore.menuKeys.includes(m.key))
@@ -186,9 +246,11 @@ const stats = computed(() => [
{ {
label: '组织节点', label: '组织节点',
icon: IconFolder, icon: IconFolder,
value: loading.value ? '—' : (orgCount.value ?? '—'), value: orgCardShowable.value && !loading.value ? (orgCount.value ?? '—') : '—',
unit: '个', unit: orgCardShowable.value ? '个' : '',
foot: '学校 / 院系 / 班级', foot: isPlatform.value
? (needPickTenant.value ? '选定学校后展示该校的组织' : '所选学校的节点数')
: (canViewOrg.value ? '学校 / 院系 / 班级' : '需要「组织查看」权限'),
tone: '' tone: ''
}, },
{ {
@@ -250,10 +312,10 @@ const boardCards = computed(() => {
}, },
{ {
label: '待审', label: '待审',
value: audit.value?.pendingCount ?? '—', value: canViewAllProgress.value ? (audit.value?.pendingCount ?? '—') : '—',
foot: audit.value?.timeoutCount foot: canViewAllProgress.value
? `其中超时 ${audit.value.timeoutCount} 件` ? (audit.value?.timeoutCount ? `其中超时 ${audit.value.timeoutCount} 件` : '没有超时的')
: '没有超时的', : '需要「全租户进度」权限',
tone: audit.value?.timeoutCount ? 'is-warn' : '' tone: audit.value?.timeoutCount ? 'is-warn' : ''
} }
] ]
@@ -294,10 +356,33 @@ function fmtHours(h: number | null | undefined): string {
return h >= 10 ? `${Math.round(h)} 小时` : `${h} 小时` return h >= 10 ? `${Math.round(h)} 小时` : `${h} 小时`
} }
/** 组织节点数(平台运营按所选学校;其它角色就是自己租户的) */
async function loadOrgCount() {
// 组织树要 `org:view`(班主任/副班主任/审核员都没有);平台没选学校时也不该问
if (!orgCardShowable.value) {
orgCount.value = null
return
}
try {
const res = await orgApi.getOrgTree({ tenantId: actingStore.tenantId })
orgCount.value = countNodes(res.data ?? [])
} catch {
orgCount.value = null
}
}
async function loadTasks() { async function loadTasks() {
// 平台没选学校时不发请求:拿到的是空列表,界面上会显示成"这所学校没有任务"
if (needPickTenant.value) {
taskOptions.value = []
boardTaskId.value = undefined
stat.value = null
audit.value = null
return
}
taskLoading.value = true taskLoading.value = true
try { try {
const res = await taskApi.getTaskList({ pageNum: 1, pageSize: 100 }) const res = await taskApi.getTaskList({ pageNum: 1, pageSize: 100 }, actingStore.tenantId)
const list = res.data?.list ?? [] const list = res.data?.list ?? []
taskOptions.value = list.map((x) => ({ taskOptions.value = list.map((x) => ({
label: `${x.taskName}(${taskApi.TASK_STATUS_TEXT[x.status] ?? ''})`, label: `${x.taskName}(${taskApi.TASK_STATUS_TEXT[x.status] ?? ''})`,
@@ -308,6 +393,10 @@ async function loadTasks() {
if (collecting) { if (collecting) {
boardTaskId.value = collecting.id boardTaskId.value = collecting.id
loadBoard() loadBoard()
} else {
boardTaskId.value = undefined
stat.value = null
audit.value = null
} }
} finally { } finally {
taskLoading.value = false taskLoading.value = false
@@ -318,13 +407,17 @@ async function loadBoard() {
if (!boardTaskId.value) return if (!boardTaskId.value) return
boardLoading.value = true boardLoading.value = true
try { try {
// 两个接口互不依赖,并行取;任一个失败不影响另一个显示 // ⚠️ 审核统计要 `progress:viewAll`,班主任没有 —— 不能"调了再 catch":
// 403 是 HTTP 状态码,`http.ts` 的传输层分支会**先弹一个「拒绝访问(403)」**,
// catch 只能拦住异常,拦不住那个提示。所以按权限**决定发不发这个请求**。
const [s, a] = await Promise.allSettled([ const [s, a] = await Promise.allSettled([
taskApi.getTaskStatistics(boardTaskId.value), taskApi.getTaskStatistics(boardTaskId.value, actingStore.tenantId),
auditApi.getAuditStatistics({ taskId: boardTaskId.value }) canViewAllProgress.value
? auditApi.getAuditStatistics({ taskId: boardTaskId.value }, actingStore.tenantId)
: Promise.resolve(null)
]) ])
stat.value = s.status === 'fulfilled' ? s.value.data : null stat.value = s.status === 'fulfilled' ? s.value.data : null
audit.value = a.status === 'fulfilled' ? a.value.data : null audit.value = a.status === 'fulfilled' ? (a.value?.data ?? null) : null
} finally { } finally {
boardLoading.value = false boardLoading.value = false
} }
@@ -334,13 +427,15 @@ function countNodes(nodes: OrgTreeVO[]): number {
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0) return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0)
} }
// 换学校 = 换一整套数字(组织节点、任务下拉、看板)。不清空的话,
// 上一所学校的任务还留在下拉里,选中它看到的是**另一所学校**的进度
watch(() => actingStore.tenantId, () => {
loadOrgCount()
loadTasks()
})
onMounted(async () => { onMounted(async () => {
try { await loadOrgCount()
const res = await orgApi.getOrgTree()
orgCount.value = countNodes(res.data ?? [])
} catch {
orgCount.value = null
}
if (isPlatform.value) { if (isPlatform.value) {
try { try {
const res = await tenantApi.getTenantList({ pageNum: 1, pageSize: 1 }) const res = await tenantApi.getTenantList({ pageNum: 1, pageSize: 1 })
+69 -16
View File
@@ -1,6 +1,6 @@
<template> <template>
<GiPageLayout> <GiPageLayout>
<a-card :bordered="false"> <a-card :bordered="false" class="g-list-card">
<template #title>操作日志</template> <template #title>操作日志</template>
<template #extra> <template #extra>
<span class="log__hint"> <span class="log__hint">
@@ -8,14 +8,6 @@
</span> </span>
</template> </template>
<a-alert type="normal" class="log__tip">
<template #title>
证件号在这里**只有脱敏值与指纹**(日志泄露不该比业务库泄露更严重)。
IP 取代理链第一跳、**可以被伪造** —— 用于排查,不作合规证据;
真正的证据是「哪个账号在什么时候做了什么」。
</template>
</a-alert>
<div class="g-row-tool"> <div class="g-row-tool">
<a-space wrap> <a-space wrap>
<a-input-number v-model="query.targetId" placeholder="对象 ID" size="small" <a-input-number v-model="query.targetId" placeholder="对象 ID" size="small"
@@ -58,9 +50,27 @@
</a-space> </a-space>
</div> </div>
<!--
`y: '100%'` 不是装饰:它让 Arco 把表头与表体**拆开**
(内部 `splitTable`),表体自己滚、表头钉住、分页器留在卡片底部。
没有它的话,`.arco-table-content` 的 `overflow-y` 是 hidden(横向滚动条专用),
多出来的行会被**直接裁掉**,而且没有任何滚动条能救回来。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination" <a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 1200 }" @page-change="onPageChange"> :scroll="{ x: isPlatform ? 1340 : 1200, y: '100%' }" @page-change="onPageChange"
@page-size-change="onPageSizeChange">
<template #columns> <template #columns>
<!--
「租户」列**只给平台运营看**:它看的是全量日志(跨租户),
不显示单位就分不清"这条是哪个学校做的";学校管理员永远是本租户,列没有意义。
-->
<a-table-column v-if="isPlatform" title="租户" :width="140">
<template #cell="{ record }">
<span class="log__muted">
{{ tenantNames[record.tenantId] ?? (record.tenantId != null ? `#${record.tenantId}` : '—') }}
</span>
</template>
</a-table-column>
<a-table-column title="时间" :width="150"> <a-table-column title="时间" :width="150">
<template #cell="{ record }"> <template #cell="{ record }">
<span class="log__muted">{{ fmt(record.createTime) }}</span> <span class="log__muted">{{ fmt(record.createTime) }}</span>
@@ -68,8 +78,12 @@
</a-table-column> </a-table-column>
<a-table-column title="操作人" :width="120"> <a-table-column title="操作人" :width="120">
<template #cell="{ record }"> <template #cell="{ record }">
<div>{{ record.operatorName || '—' }}</div> <!--
<div class="log__muted">ID {{ record.operatorId ?? '—' }}</div> ⚠️ 只显示**账号/姓名**,不显示用户 ID(2026-10-09 客户要求)。
数字 ID 对看日志的人没有意义 —— 他要知道的是"哪个账号做的",
ID 只在排查时要,而那种时候查库比看这一列快。
-->
<span>{{ record.operatorName || '—' }}</span>
</template> </template>
</a-table-column> </a-table-column>
<a-table-column title="动作" :width="150"> <a-table-column title="动作" :width="150">
@@ -119,6 +133,8 @@ import {
OPERATION_ACTIONS, OPERATION_ACTIONS,
SENSITIVE_ACTIONS SENSITIVE_ACTIONS
} from '@/apis/xt/log' } from '@/apis/xt/log'
import { getTenantList } from '@/apis/xt/tenant'
import { useUserStore } from '@/stores'
/** /**
* 操作日志(《03-接口设计》§13.4)。 * 操作日志(《03-接口设计》§13.4)。
@@ -134,9 +150,22 @@ import {
* *
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击, * 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击,
* 而合规核查往往是几十条连着看的。 * 而合规核查往往是几十条连着看的。
*
* ## 范围:平台运营看**全量**,学校管理员看**本租户**
*
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定(`LogController.logScopeTenantId`)。
* ⚠️ 平台运营看到的每条日志属于哪个单位,靠这一列「租户」——它**只对平台显示**。
* 以前平台的 tenantId 是 0 而日志挂在各租户下,于是这一屏对平台**永远是空的**,
* 且不报任何错(PRD 要求的却是「全量操作日志 ✅」)。
*/ */
defineOptions({ name: 'OperationLogs' }) defineOptions({ name: 'OperationLogs' })
const userStore = useUserStore()
/** 平台运营:看全量日志,因此要显示「租户」列并把 id 翻成单位名 */
const isPlatform = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const tenantNames = ref<Record<number, string>>({})
const loading = ref(false) const loading = ref(false)
const list = ref<OperationLog[]>([]) const list = ref<OperationLog[]>([])
const total = ref(0) const total = ref(0)
@@ -220,20 +249,44 @@ function onPageChange(page: number) {
load() load()
} }
/**
* 改每页条数。
*
* ⚠️ **必须处理这个事件**,不能只处理 `page-change`:
* Arco 的表格在 `pagination` 非 false 时会**自己再切一次数据**
* (`flattenData`:`data.length > pageSize` 就 slice)。
* 只在本地改每页条数、不重新取数的话,会出现
* 「分页器显示 50 条/页、表里还是上一次那 20 条」这种自相矛盾的画面 ——
* 看起来就是"分页器坏了"。所以这里一律回到第 1 页重新取。
*/
function onPageSizeChange(size: number) {
query.pageSize = size
query.pageNum = 1
load()
}
// 平台运营才需要"租户 id → 单位名"。取不到也不影响看日志:列里退回显示 #id
onMounted(async () => {
if (!isPlatform.value) return
try {
const res = await getTenantList({ pageNum: 1, pageSize: 200 })
tenantNames.value = Object.fromEntries((res.data?.list ?? []).map((t) => [t.id, t.tenantName]))
} catch {
tenantNames.value = {}
}
})
load() load()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* 卡片/表格的高度链路(分页器为什么必须钉在底部)见 `styles/global.scss` 的 `.g-list-card` */
.log { .log {
&__hint { &__hint {
font-size: 12px; font-size: 12px;
color: var(--color-text-3); color: var(--color-text-3);
} }
&__tip {
margin-bottom: 12px;
}
&__num { &__num {
width: 110px; width: 110px;
} }
+9 -2
View File
@@ -52,9 +52,16 @@ import { ORG_TYPE_NAMES } from '@/apis/xt/org'
* 1. 组织编码租户内唯一 * 1. 组织编码租户内唯一
* 2. 组织类型必须比上级更低一级 * 2. 组织类型必须比上级更低一级
* 3. 修改时不提供改上级(避免误操作把整棵子树搬走) * 3. 修改时不提供改上级(避免误操作把整棵子树搬走)
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:它决定这个组织建到哪所学校下
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。学校管理员传 null,行为与以前一致。
*/ */
defineOptions({ name: 'OrgFormModal' }) defineOptions({ name: 'OrgFormModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'save-success'): void (e: 'save-success'): void
}>() }>()
@@ -132,7 +139,7 @@ async function confirm() {
orgType: form.orgType, orgType: form.orgType,
orgCode: form.orgCode.trim(), orgCode: form.orgCode.trim(),
sortNo: form.sortNo ?? 0 sortNo: form.sortNo ?? 0
}) }, props.tenantId)
Message.success('已保存') Message.success('已保存')
} else { } else {
await orgApi.addOrg({ await orgApi.addOrg({
@@ -141,7 +148,7 @@ async function confirm() {
orgType: form.orgType, orgType: form.orgType,
orgCode: form.orgCode.trim(), orgCode: form.orgCode.trim(),
sortNo: form.sortNo ?? 0 sortNo: form.sortNo ?? 0
}) }, props.tenantId)
Message.success('已新增') Message.success('已新增')
} }
visible.value = false visible.value = false
+67 -7
View File
@@ -13,11 +13,34 @@
<span class="org__hint">{{ totalNodes }} 个节点</span> <span class="org__hint">{{ totalNodes }} 个节点</span>
</template> </template>
<!--
平台运营必须先选学校:组织树本来就是租户内的东西,而平台的租户过滤是关掉的。
不选就请求,拿到的是"所有租户的组织混在一棵树里"—— 只有一所学校有数据时,
它和学校管理员看到的完全一样,看不出错。
-->
<GiTenantPicker
v-if="isPlatform"
v-model="actingStore.tenantId"
class="org__tenant"
placeholder="选择学校(平台运营)"
/>
<a-input-search v-model="keyword" placeholder="搜索名称或编码" allow-clear class="org__search" /> <a-input-search v-model="keyword" placeholder="搜索名称或编码" allow-clear class="org__search" />
<a-spin v-if="loading" class="org__loading" /> <!--
⚠️ Arco 的 `a-empty` 里一旦给了默认插槽,`description` 就**不再渲染**
(它的实现是 `<slot>{{ description }}</slot>`)—— 所以文案要写在插槽里,
否则页面上只剩半句话。
-->
<a-empty v-if="needPickTenant">
<div class="org__empty-title">请先选择学校</div>
<span class="org__hint">组织架构按学校隔离,选定学校后即可查看与维护</span>
</a-empty>
<a-empty v-else-if="!tree.length" description="还没有组织"> <a-spin v-else-if="loading" class="org__loading" />
<a-empty v-else-if="!tree.length">
<div class="org__empty-title">还没有组织</div>
<a-button v-if="canManage" type="primary" size="small" @click="onAdd(null)"> <a-button v-if="canManage" type="primary" size="small" @click="onAdd(null)">
新增顶级组织 新增顶级组织
</a-button> </a-button>
@@ -35,7 +58,7 @@
</a-tree> </a-tree>
<template #footer> <template #footer>
<a-button v-if="canManage" long size="small" @click="onAdd(null)"> <a-button v-if="canManage && !needPickTenant" long size="small" @click="onAdd(null)">
<template #icon><IconPlus /></template> <template #icon><IconPlus /></template>
新增顶级组织 新增顶级组织
</a-button> </a-button>
@@ -111,7 +134,7 @@
</a-card> </a-card>
</template> </template>
<OrgFormModal ref="formModalRef" @save-success="load" /> <OrgFormModal ref="formModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
</GiPageLayout> </GiPageLayout>
</template> </template>
@@ -121,7 +144,7 @@ import { Message } from '@arco-design/web-vue'
import { IconDelete, IconEdit, IconFolder, IconPlus } from '@arco-design/web-vue/es/icon' import { IconDelete, IconEdit, IconFolder, IconPlus } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org' import * as orgApi from '@/apis/xt/org'
import { ORG_TYPE_NAMES } from '@/apis/xt/org' import { ORG_TYPE_NAMES } from '@/apis/xt/org'
import { useUserStore } from '@/stores' import { useActingTenantStore, useUserStore } from '@/stores'
import OrgFormModal from './OrgFormModal.vue' import OrgFormModal from './OrgFormModal.vue'
/** /**
@@ -135,6 +158,18 @@ defineOptions({ name: 'Org' })
const userStore = useUserStore() const userStore = useUserStore()
/**
* 平台运营的「当前学校」。
*
* 服务端对平台关掉了租户过滤,所以平台**必须先指明看哪所学校**才能拿到租户内的数据
* (见 `@/apis/xt/acting-tenant`)。选择存在 `useActingTenantStore` 里,
* 与「账号管理」共用 —— 切页面不用重选,也不会出现两个页面各看一所学校。
* 学校管理员的 `isPlatform` 为 false,这个值恒为 undefined —— 不传头,行为与以前一致。
*/
const actingStore = useActingTenantStore()
const isPlatform = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const needPickTenant = computed(() => isPlatform.value && !actingStore.tenantId)
const loading = ref(false) const loading = ref(false)
const tree = ref<OrgTreeVO[]>([]) const tree = ref<OrgTreeVO[]>([])
const selected = ref<OrgTreeVO | null>(null) const selected = ref<OrgTreeVO | null>(null)
@@ -174,9 +209,16 @@ function findById(nodes: OrgTreeVO[], id: number): OrgTreeVO | null {
} }
async function load() { async function load() {
// 平台没选学校时不发请求(服务端会返回空树,徒增一次往返与一次困惑)
if (needPickTenant.value) {
tree.value = []
selected.value = null
selectedKeys.value = []
return
}
loading.value = true loading.value = true
try { try {
const res = await orgApi.getOrgTree({ withCount: true }) const res = await orgApi.getOrgTree({ withCount: true, tenantId: actingStore.tenantId })
tree.value = res.data ?? [] tree.value = res.data ?? []
// 刷新后保持选中 // 刷新后保持选中
if (selected.value) { if (selected.value) {
@@ -187,6 +229,15 @@ async function load() {
} }
} }
// 换学校 = 换一整棵树:不清空的话,上一所学校的节点会留在页面上,
// 而"点进去看到的是别人学校的组织"这种事没有任何报错会提醒你
watch(() => actingStore.tenantId, () => {
selected.value = null
selectedKeys.value = []
keyword.value = ''
load()
})
/** /**
* 树节点选中。 * 树节点选中。
* *
@@ -214,7 +265,7 @@ function onEdit(row: OrgTreeVO) {
} }
async function onDelete(row: OrgTreeVO) { async function onDelete(row: OrgTreeVO) {
await orgApi.deleteOrg(row.id) await orgApi.deleteOrg(row.id, actingStore.tenantId)
Message.success('已删除') Message.success('已删除')
if (selected.value?.id === row.id) { if (selected.value?.id === row.id) {
selected.value = null selected.value = null
@@ -236,6 +287,10 @@ onMounted(load)
margin-bottom: 10px; margin-bottom: 10px;
} }
&__tenant {
margin-bottom: 10px;
}
&__loading { &__loading {
display: block; display: block;
padding: 40px 0; padding: 40px 0;
@@ -247,6 +302,11 @@ onMounted(load)
color: var(--color-text-3); color: var(--color-text-3);
} }
&__empty-title {
margin-bottom: 4px;
color: var(--color-text-1);
}
&__node { &__node {
display: inline-flex; display: inline-flex;
gap: 6px; gap: 6px;
+69 -12
View File
@@ -1,11 +1,20 @@
<template> <template>
<GiPageLayout> <GiPageLayout>
<a-card :bordered="false"> <a-card :bordered="false" class="g-list-card">
<template #title>收集任务</template> <template #title>收集任务</template>
<template #extra> <template #extra>
<span class="task__hint">共 {{ total }} 个任务</span> <span class="task__hint">共 {{ total }} 个任务</span>
</template> </template>
<!--
状态切换:**单独一行的 tab**(2026-10-09 客户要求),不再和
「新建任务 / 批次号 / 搜索」挤在同一行做成一个个按钮 ——
一排长得一样的按钮分不清"哪个是当前视图、哪个是动作"。
-->
<a-tabs class="task__tabs" :active-key="activeStatusKey" hide-content @change="onStatusChange">
<a-tab-pane v-for="t in STATUS_TABS" :key="t.key" :title="t.label" />
</a-tabs>
<div class="g-row-tool"> <div class="g-row-tool">
<a-space wrap> <a-space wrap>
<a-button v-if="canManage" type="primary" size="small" @click="onAdd"> <a-button v-if="canManage" type="primary" size="small" @click="onAdd">
@@ -15,10 +24,6 @@
</a-space> </a-space>
<a-space wrap> <a-space wrap>
<a-button v-for="c in statusChips" :key="String(c.value)"
:type="statusFilter === c.value ? 'primary' : 'outline'" size="small" @click="setStatus(c.value)">
{{ c.label }}
</a-button>
<a-input v-model="query.batchNo" placeholder="批次号" allow-clear class="task__batch" <a-input v-model="query.batchNo" placeholder="批次号" allow-clear class="task__batch"
@press-enter="search" /> @press-enter="search" />
<a-input-search v-model="query.keyword" placeholder="搜索任务名 / 编码 / 专业" allow-clear <a-input-search v-model="query.keyword" placeholder="搜索任务名 / 编码 / 专业" allow-clear
@@ -38,8 +43,13 @@
名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置) 名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置)
· 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。 · 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。
--> -->
<!--
`y: '100%'`:让 Arco 拆开表头与表体(表体自己滚、表头钉住),
配合卡片上的 `.g-list-card` 把分页器留在卡片底部。原因见 `styles/global.scss`。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination" <a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 820 }" @page-change="onPageChange"> :scroll="{ x: 820, y: '100%' }" @page-change="onPageChange"
@page-size-change="onPageSizeChange">
<template #columns> <template #columns>
<a-table-column title="任务" :width="200" ellipsis tooltip> <a-table-column title="任务" :width="200" ellipsis tooltip>
<template #cell="{ record }"> <template #cell="{ record }">
@@ -181,6 +191,13 @@ import TaskRosterDrawer from './TaskRosterDrawer.vue'
*/ */
defineOptions({ name: 'Task' }) defineOptions({ name: 'Task' })
/** 状态 tab 的一项:tab 的 key 是字符串,发给接口的是 status */
interface StatusTab {
key: string
label: string
value: number | undefined
}
const userStore = useUserStore() const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('task:manage')) const canManage = computed(() => userStore.permissions.includes('task:manage'))
@@ -214,14 +231,26 @@ const BLOCKING_HINTS: Record<number, string> = {
40020: '命名规则有问题,请点「编辑」检查文件命名模板' 40020: '命名规则有问题,请点「编辑」检查文件命名模板'
} }
const statusChips = [ /**
{ label: '全部', value: undefined }, * 状态 tab。
{ label: '草稿', value: TASK_STATUS_DRAFT }, *
{ label: '收集中', value: TASK_STATUS_COLLECTING }, * `key` 必须是字符串(Arco 的 tab key 不支持 undefined),
{ label: '已暂停', value: TASK_STATUS_PAUSED }, * `value` 才是发给接口的 `status` —— 「全部」= 不带 status 参数,
{ label: '已截止', value: TASK_STATUS_CLOSED } * 这个映射只在这一处定义,别在别处再写一份 `?? undefined`。
*/
const STATUS_TABS: StatusTab[] = [
{ key: 'all', label: '全部', value: undefined },
{ key: String(TASK_STATUS_DRAFT), label: '草稿', value: TASK_STATUS_DRAFT },
{ key: String(TASK_STATUS_COLLECTING), label: '收集中', value: TASK_STATUS_COLLECTING },
{ key: String(TASK_STATUS_PAUSED), label: '已暂停', value: TASK_STATUS_PAUSED },
{ key: String(TASK_STATUS_CLOSED), label: '已截止', value: TASK_STATUS_CLOSED }
] ]
/** 当前选中的 tab(由 statusFilter 反推,保持单一数据源) */
const activeStatusKey = computed(() =>
STATUS_TABS.find((t) => t.value === statusFilter.value)?.key ?? 'all'
)
const pagination = computed(() => ({ const pagination = computed(() => ({
current: query.pageNum, current: query.pageNum,
pageSize: query.pageSize, pageSize: query.pageSize,
@@ -261,6 +290,23 @@ function setStatus(v: number | undefined) {
search() search()
} }
/** 状态 tab 切换 */
function onStatusChange(key: string | number) {
setStatus(STATUS_TABS.find((t) => t.key === String(key))?.value)
}
/**
* 改每页条数。
*
* ⚠️ 与「操作日志」同一处坑:Arco 表格在 `pagination` 非 false 时**自己也会切数据**,
* 不重新取数就会出现「分页器说 50 条/页、表里还是 10 条」的自相矛盾画面。
*/
function onPageSizeChange(size: number) {
query.pageSize = size
query.pageNum = 1
load()
}
function onPageChange(page: number) { function onPageChange(page: number) {
query.pageNum = page query.pageNum = page
load() load()
@@ -407,6 +453,17 @@ onMounted(load)
color: var(--color-text-3); color: var(--color-text-3);
} }
/* 状态 tab 自己占一行(下面的工具栏是另一行) */
&__tabs {
:deep(.arco-tabs-nav) {
margin-bottom: 12px;
}
:deep(.arco-tabs-nav::before) {
background-color: var(--color-border-2);
}
}
&__batch { &__batch {
width: 120px; width: 120px;
} }
+27 -4
View File
@@ -150,9 +150,17 @@ import { useUserStore } from '@/stores'
* 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定, * 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定,
* 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号, * 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号,
* 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。 * 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"这个账号建到哪所学校下"
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定角色的可选范围:
* 给某所学校建账号时只能发**学校那套角色**,发 PLATFORM_ADMIN 会被服务端拒(40301)。
*/ */
defineOptions({ name: 'UserFormModal' }) defineOptions({ name: 'UserFormModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'save-success'): void (e: 'save-success'): void
}>() }>()
@@ -187,8 +195,17 @@ const getInitForm = () => ({
// 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前 // 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前
const form = reactive(getInitForm()) const form = reactive(getInitForm())
/**
* 可选角色。
*
* ⚠️ 判据不只是"我是不是平台租户",还要看**有没有代管学校**:
* · 平台运营 + 没选学校 → 建的是平台自己的账号 → 只能发 PLATFORM_ADMIN;
* · 平台运营 + 选了学校 → 建的是**那所学校**的账号 → 只能发学校那套角色
* (发 PLATFORM_ADMIN 会被服务端按租户白名单拒掉,报 40301);
* · 学校管理员 → 学校那套。
*/
const roleOptions = computed(() => const roleOptions = computed(() =>
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS isPlatformTenant.value && !props.tenantId ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS
) )
/** /**
@@ -228,10 +245,16 @@ const rules: FormInstance['rules'] = {
async function loadOrgTree() { async function loadOrgTree() {
if (orgTree.value.length) return if (orgTree.value.length) return
const res = await getOrgTree() const res = await getOrgTree({ tenantId: props.tenantId })
orgTree.value = res.data ?? [] orgTree.value = res.data ?? []
} }
// 换了学校就把缓存的组织树丢掉:留着上一所学校的树,用户会在"组织"下拉里
// 看到别人学校的班级,而选中它提交会得到一个指向不存在组织的 orgId
watch(() => props.tenantId, () => {
orgTree.value = []
})
/** 新增 */ /** 新增 */
async function add() { async function add() {
detailId.value = null detailId.value = null
@@ -306,7 +329,7 @@ async function confirm() {
roleCodes: form.roleCodes, roleCodes: form.roleCodes,
userType, userType,
status: form.status status: form.status
}) }, props.tenantId)
Message.success('已保存') Message.success('已保存')
visible.value = false visible.value = false
emit('save-success') emit('save-success')
@@ -320,7 +343,7 @@ async function confirm() {
orgId: form.orgId ?? null, orgId: form.orgId ?? null,
roleCodes: form.roleCodes, roleCodes: form.roleCodes,
userType userType
}) }, props.tenantId)
const data = res.data const data = res.data
if (!data) { if (!data) {
Message.error('创建失败,请重试') Message.error('创建失败,请重试')
+12 -2
View File
@@ -174,9 +174,16 @@ import { useUserStore } from '@/stores'
* ② 哪几行没进去、为什么 * ② 哪几行没进去、为什么
* ③ 新账号的初始密码(只此一次) * ③ 新账号的初始密码(只此一次)
* 三件事摊开给用户看,让他当场就能判断"这次导入能不能信"。 * 三件事摊开给用户看,让他当场就能判断"这次导入能不能信"。
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"导进哪所学校"
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定默认角色的可选范围。
*/ */
defineOptions({ name: 'UserImportModal' }) defineOptions({ name: 'UserImportModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'save-success'): void (e: 'save-success'): void
}>() }>()
@@ -191,8 +198,11 @@ const report = ref<UserImportResult | null>(null)
const fileInput = useTemplateRef<HTMLInputElement>('fileInput') const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN')) const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
/** 与 UserFormModal 同一口径:代管某所学校时只能发学校那套角色 */
const roleOptions = computed(() => const roleOptions = computed(() =>
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS.filter((r) => r.value !== 'STUDENT') isPlatformTenant.value && !props.tenantId
? PLATFORM_ROLE_OPTIONS
: SCHOOL_ROLE_OPTIONS.filter((r) => r.value !== 'STUDENT')
) )
/** a-form 需要一个 model;文件选择走原生 input,不进这个对象 */ /** a-form 需要一个 model;文件选择走原生 input,不进这个对象 */
@@ -228,7 +238,7 @@ async function submit() {
if (!file.value) return if (!file.value) return
submitting.value = true submitting.value = true
try { try {
const res = await userApi.importUsers(file.value, formModel.defaultRoleCode) const res = await userApi.importUsers(file.value, formModel.defaultRoleCode, props.tenantId)
report.value = res.data ?? null report.value = res.data ?? null
emit('save-success') emit('save-success')
} finally { } finally {
+48 -9
View File
@@ -10,7 +10,17 @@
</a-space> </a-space>
</template> </template>
<a-spin v-if="orgLoading" class="user__loading" /> <!-- 平台运营必须先选学校:账号与组织都是租户内的数据(见 @/apis/xt/acting-tenant) -->
<GiTenantPicker
v-if="isPlatformTenant"
v-model="actingStore.tenantId"
class="user__tenant"
placeholder="选择学校(平台运营)"
/>
<a-empty v-if="needPickTenant" description="请先选择学校" />
<a-spin v-else-if="orgLoading" class="user__loading" />
<template v-else> <template v-else>
<a-button long size="small" :type="orgId === undefined ? 'primary' : 'outline'" <a-button long size="small" :type="orgId === undefined ? 'primary' : 'outline'"
@@ -151,8 +161,8 @@
</a-table> </a-table>
</a-card> </a-card>
<UserFormModal ref="formModalRef" @save-success="load" /> <UserFormModal ref="formModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
<UserImportModal ref="importModalRef" @save-success="load" /> <UserImportModal ref="importModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
<!-- 重置密码结果:与创建一样,只显示这一次 --> <!-- 重置密码结果:与创建一样,只显示这一次 -->
<a-modal v-model:visible="resetVisible" title="密码已重置" :width="460" :mask-closable="false" <a-modal v-model:visible="resetVisible" title="密码已重置" :width="460" :mask-closable="false"
@@ -192,7 +202,7 @@ import { IconCopy, IconFolder, IconPlus, IconUpload } from '@arco-design/web-vue
import { getOrgTree } from '@/apis/xt/org' import { getOrgTree } from '@/apis/xt/org'
import * as userApi from '@/apis/xt/user' import * as userApi from '@/apis/xt/user'
import { PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS, USER_STATUS } from '@/apis/xt/user' import { PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS, USER_STATUS } from '@/apis/xt/user'
import { useUserStore } from '@/stores' import { useActingTenantStore, useUserStore } from '@/stores'
import UserFormModal from './UserFormModal.vue' import UserFormModal from './UserFormModal.vue'
import UserImportModal from './UserImportModal.vue' import UserImportModal from './UserImportModal.vue'
@@ -215,6 +225,17 @@ const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('user:manage')) const canManage = computed(() => userStore.permissions.includes('user:manage'))
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN')) const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
/**
* 平台运营的「当前学校」(请求头 `X-Tenant-Id`,见 `@/apis/xt/acting-tenant`)。
*
* 服务端对平台是关掉租户过滤的,不指明学校就会把**所有租户的账号**混在一起返回 ——
* 而那种错看起来很正常(列表有数据、分页也对)。学校管理员不带这个头,行为不变。
*
* 选择与「组织架构」共用同一个 store:切页面不用重选,两个页面也不会各看一所学校。
*/
const actingStore = useActingTenantStore()
const needPickTenant = computed(() => isPlatformTenant.value && !actingStore.tenantId)
const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' } const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
const loading = ref(false) const loading = ref(false)
@@ -255,9 +276,13 @@ const pagination = computed(() => ({
})) }))
async function loadOrgTree() { async function loadOrgTree() {
if (needPickTenant.value) {
orgTree.value = []
return
}
orgLoading.value = true orgLoading.value = true
try { try {
const res = await getOrgTree() const res = await getOrgTree({ tenantId: actingStore.tenantId })
orgTree.value = res.data ?? [] orgTree.value = res.data ?? []
} finally { } finally {
orgLoading.value = false orgLoading.value = false
@@ -265,6 +290,11 @@ async function loadOrgTree() {
} }
async function load() { async function load() {
if (needPickTenant.value) {
list.value = []
total.value = 0
return
}
loading.value = true loading.value = true
try { try {
const res = await userApi.getUserList({ const res = await userApi.getUserList({
@@ -274,7 +304,7 @@ async function load() {
status: statusFilter.value, status: statusFilter.value,
pageNum: query.pageNum, pageNum: query.pageNum,
pageSize: query.pageSize pageSize: query.pageSize
}) }, actingStore.tenantId)
list.value = res.data?.list ?? [] list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0 total.value = res.data?.total ?? 0
} finally { } finally {
@@ -282,6 +312,15 @@ async function load() {
} }
} }
// 换学校 = 换一整套数据(组织树、列表、已选的班级都要重来)。
// 留着上一所学校的 orgId 去查新学校,会得到"一个空的列表" —— 看起来像这所学校没有账号。
watch(() => actingStore.tenantId, () => {
orgId.value = undefined
query.pageNum = 1
loadOrgTree()
load()
})
function search() { function search() {
query.pageNum = 1 query.pageNum = 1
load() load()
@@ -325,7 +364,7 @@ async function onToggleStatus(row: UserVO, status: number) {
content: '停用后该账号将无法登录,但数据保留,可以随时再启用。', content: '停用后该账号将无法登录,但数据保留,可以随时再启用。',
hideCancel: false, hideCancel: false,
onBeforeOk: async () => { onBeforeOk: async () => {
await userApi.setUserStatus(row.id, status) await userApi.setUserStatus(row.id, status, actingStore.tenantId)
Message.success(`已${action}`) Message.success(`已${action}`)
await load() await load()
resolve() resolve()
@@ -335,7 +374,7 @@ async function onToggleStatus(row: UserVO, status: number) {
}) })
}) })
} }
await userApi.setUserStatus(row.id, status) await userApi.setUserStatus(row.id, status, actingStore.tenantId)
Message.success(`已${action}`) Message.success(`已${action}`)
await load() await load()
} }
@@ -346,7 +385,7 @@ async function onResetPassword(row: UserVO) {
content: '系统会生成一个新的初始密码,旧密码立即失效。该账号的登录锁定也会一并解除。', content: '系统会生成一个新的初始密码,旧密码立即失效。该账号的登录锁定也会一并解除。',
hideCancel: false, hideCancel: false,
onBeforeOk: async () => { onBeforeOk: async () => {
const res = await userApi.resetUserPassword(row.id, { mode: 'GENERATE' }) const res = await userApi.resetUserPassword(row.id, { mode: 'GENERATE' }, actingStore.tenantId)
resetResult.value = res.data ?? null resetResult.value = res.data ?? null
resetVisible.value = true resetVisible.value = true
return true return true
+102 -1
View File
@@ -21,7 +21,7 @@
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表, 逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
而不是靠下次再想一遍)。 而不是靠下次再想一遍)。
## 查五件事 ## 查七件事
| # | 查什么 | 没接上的后果 | | # | 查什么 | 没接上的后果 |
|:--:|---|---| |:--:|---|---|
@@ -31,6 +31,17 @@
| 4 | `NotifyTemplates` 的站内信模板零发送点 | 模板页能改,但那条通知根本不发 / 不走模板 | | 4 | `NotifyTemplates` 的站内信模板零发送点 | 模板页能改,但那条通知根本不发 / 不走模板 |
| 5 | `CollectMessage.BIZ_*` 零使用 | 消息中心那个分类永远是空的 | | 5 | `CollectMessage.BIZ_*` 零使用 | 消息中心那个分类永远是空的 |
| 6 | 内置角色声明的菜单键 ↔ PC 端 `XtMenu` 的 key | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 | | 6 | 内置角色声明的菜单键 ↔ PC 端 `XtMenu` 的 key | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 |
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
### 第 7 项是 2026-10-09 补的(自己踩了一次)
在工作台按权限决定"发不发请求"时,我把 `progress:viewAll` 写成了 `progress:view:all`
(多一个冒号)。后果是那个判断**恒为 false**:「审核情况」整块对**所有人**都不显示 ——
而编译、typecheck、lint、后端日志、甚至 E2E **全都不红**。
这一族错的特征与上面六项完全一样:**声明对不上,谁都不报错**;
而"权限不足就少显示一块"看起来又很像设计(不是崩溃,是安静地少一块)。
## 它**不查**什么(免得被当成"全绿就没事") ## 它**不查**什么(免得被当成"全绿就没事")
@@ -79,6 +90,9 @@ PERM_NO_CHECK = {
} }
# 权限码:被校验了,但没有被任何内置角色分配(= 谁都调不了) # 权限码:被校验了,但没有被任何内置角色分配(= 谁都调不了)
PERM_NO_ROLE = {} PERM_NO_ROLE = {}
# PC 端引用的权限码:PermissionDef 里没有的(**应当保持为空** ——
# 写错一个码的后果是"那个判断恒为 false",整块界面静默消失,见文件头第 7 项)
FE_PERM_EXCEPTION = {}
ERROR_CODE_UNUSED = { ERROR_CODE_UNUSED = {
'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)', 'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)',
'ID_CARD_MISMATCH_ROSTER': '同上(M4)', 'ID_CARD_MISMATCH_ROSTER': '同上(M4)',
@@ -227,6 +241,53 @@ def strip_line_comments(block):
return '\n'.join(re.sub(r'//.*$', '', ln) for ln in block.split('\n')) return '\n'.join(re.sub(r'//.*$', '', ln) for ln in block.split('\n'))
def strip_js_comments(src):
"""去掉 JS/TS 的注释,**但要认引号**(不能见到 `//` 就砍)。
⚠️ 为什么需要它:第 ⑦ 项第一版把 `directives/hasPerm.ts` 里的
**注释与示例字符串**(`<button v-hasPerm="['home:btn:add']">`、报错文案里的示例)
当成了真实用法,一口气报了 5 条假阳性 —— 模板作者的示例码当然不在 PermissionDef 里。
这与本脚本 2026-10-08 修过的第 ③ 条是同一个教训(当时是 Java 注释里的提及
被当成接线):**检查器必须能区分"代码"与"解释代码的文字"**,
否则它要么天天误报、要么被人加一堆例外表绕过去。
"""
out = []
i, n = 0, len(src)
quote = None # 当前是否在字符串里(' 或 ")
while i < n:
c = src[i]
if quote:
out.append(c)
if c == '\\' and i + 1 < n: # 转义:连同下一个字符一起吞掉
out.append(src[i + 1])
i += 2
continue
if c == quote:
quote = None
i += 1
continue
if c in '\'"`':
quote = c
out.append(c)
i += 1
continue
if c == '/' and i + 1 < n and src[i + 1] == '/':
while i < n and src[i] != '\n':
i += 1
continue
if c == '/' and i + 1 < n and src[i + 1] == '*':
i += 2
while i + 1 < n and not (src[i] == '*' and src[i + 1] == '/'):
i += 1
i += 2
out.append(' ')
continue
out.append(c)
i += 1
return ''.join(out)
def template_send_points(sources, code, tpl_file): def template_send_points(sources, code, tpl_file):
"""站内信模板的"发送点" = 代码里有人把这个码当成模板码用。 """站内信模板的"发送点" = 代码里有人把这个码当成模板码用。
@@ -425,6 +486,46 @@ def main():
for k in sorted(menu_keys & pc_keys): for k in sorted(menu_keys & pc_keys):
say(' ✓ %s' % k) say(' ✓ %s' % k)
# ── ⑦ PC 端引用的权限码 ↔ PermissionDef ─────────────────────
#
# 两个写法都认:`userStore.permissions.includes('org:view')` 与
# `v-hasPerm="['audit:final']"`(后者是模板里写的指令参数)。
#
# ⚠️ 这个检查器自己也要能被验:**故意写错一个码,它必须红**。
# 2026-10-09 加它的时候正是拿一个写错的码试过的(那时是真的写错了)。
perm_codes = set(re.findall(r'\(\s*"([^"]+)"\s*,\s*"', strip_java_comments(read(PERM_DEF))))
fe_codes = {} # code -> 出现位置(文件:行)
for p in sorted(glob.glob('frontend-pc/src/**/*.vue', recursive=True)
+ glob.glob('frontend-pc/src/**/*.ts', recursive=True)):
try:
raw = read(p)
except Exception:
continue
# 先去掉注释,再找用法 —— 否则注释/示例文案里的码会被当成真用法(假阳性)
src = strip_js_comments(raw)
is_vue = p.endswith('.vue')
for i, line in enumerate(src.split('\n'), 1):
for m in re.finditer(r"permissions\.includes\(\s*'([^']+)'", line):
fe_codes.setdefault(m.group(1), []).append('%s:%d' % (p, i))
# `v-hasPerm` 是**模板**里的写法:只在 .vue 里找,别去 .ts 的示例/报错文案里找
if is_vue:
for m in re.finditer(r"v-hasPerm\s*=\s*\"\[([^\]]*)\]\"", line):
for c in re.findall(r"'([^']+)'", m.group(1)):
fe_codes.setdefault(c, []).append('%s:%d' % (p, i))
say('\n【⑦】PC 端引用的权限码 %d 个 / PermissionDef 声明 %d 个'
% (len(fe_codes), len(perm_codes)))
for code in sorted(fe_codes):
where = fe_codes[code][0]
if code in perm_codes:
say(' ✓ %-22s %s' % (code, where))
elif code in FE_PERM_EXCEPTION:
notes.append('PC 端权限码 %s —— 已知例外:%s' % (code, FE_PERM_EXCEPTION[code]))
say(' — %-22s %s' % (code, FE_PERM_EXCEPTION[code]))
else:
problems.append(('前端权限码', code,
'PermissionDef 里没有这个码(判断会恒为 false:%s)' % where))
say(' ✗ %-22s PermissionDef 里没有它 → 这个判断永远是 false %s' % (code, where))
# ── 汇总 ──────────────────────────────────────────────── # ── 汇总 ────────────────────────────────────────────────
print('\n' + '=' * 88) print('\n' + '=' * 88)
if notes: if notes:
+20
View File
@@ -472,6 +472,26 @@ def main():
rp2 = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret, body={}) rp2 = open_call("POST", "/api/v1/open/v1/callback/push", secret=secret, body={})
check("人工重推不传 submissionId → 明确报错", (rp2[1] or {}).get("code") != 0, check("人工重推不传 submissionId → 明确报错", (rp2[1] or {}).get("code") != 0,
json.dumps(rp2[1], ensure_ascii=False)[:200]) json.dumps(rp2[1], ensure_ascii=False)[:200])
# ⭐ 定时扫描**真的会重试**(F-112,2026-10-09 修)
#
# 这一条与上面那条"安排了下次重试"是**两件事**:上面断言的是 next_retry_at 被写上,
# 而这里断言的是到点之后**真的有人来重试**。
#
# 修之前:`OpenPushLogMapper.dueRetries()` 少了 @InterceptorIgnore,定时任务没有租户上下文
# → 每次扫描抛 40101 → 被 retryDue 的 catch 吞成一行 warn。于是"安排了"永远不等于"发生了",
# 而 E2E 只断言了前者,所以 60/60 全绿也照样漏掉了这个 bug。
#
# 等待时间 = 定时任务间隔(fixedDelay 10s)+ 首次退避(1s)+ 余量
attempts_before = int(sql("select attempts from collect_open_push_log "
"where submission_id=%d order by id desc limit 1;" % sid) or 0)
time.sleep(16)
attempts_after = int(sql("select attempts from collect_open_push_log "
"where submission_id=%d order by id desc limit 1;" % sid) or 0)
check("⭐ 定时扫描真的重试了:attempts 增加(不是只安排了下次重试)",
attempts_after > attempts_before,
"attempts %d → %d(16 秒内至少应被扫描到一次)" % (attempts_before, attempts_after))
check("⭐ 推送报文里没有明文证件号(与 §14.2 同口径)", check("⭐ 推送报文里没有明文证件号(与 §14.2 同口径)",
"身份证" not in (sql("select coalesce(payload_json,'') from collect_open_push_log " "身份证" not in (sql("select coalesce(payload_json,'') from collect_open_push_log "
"where submission_id=%d limit 1;" % sid) or "") "where submission_id=%d limit 1;" % sid) or "")
+12 -2
View File
@@ -50,6 +50,16 @@ def read(*parts: str) -> str:
# ── 1. 表格列数 ──────────────────────────────────────── # ── 1. 表格列数 ────────────────────────────────────────
def table_pipes(line: str) -> int:
"""数表格竖线,**跳过转义过的 `\\|`**。
GFM 里 `\\|` 表示"内容里的竖线",不是列分隔符(本仓库用手写表格,
正文里写 `a \\|\\| b` 是很自然的事)。第一版直接 `line.count("|")`,
于是把这种写法报成"列数不一致" —— **检查器自己要先认得它检查的语法**。
"""
return len(re.findall(r"(?<!\\)\|", line))
def check_tables(name: str, text: str) -> None: def check_tables(name: str, text: str) -> None:
lines = text.split("\n") lines = text.split("\n")
bad_rows = 0 bad_rows = 0
@@ -57,10 +67,10 @@ def check_tables(name: str, text: str) -> None:
while i < len(lines): while i < len(lines):
if lines[i].strip().startswith("|") and i + 1 < len(lines) \ if lines[i].strip().startswith("|") and i + 1 < len(lines) \
and re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()): and re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()):
n = lines[i].count("|") n = table_pipes(lines[i])
j = i + 2 j = i + 2
while j < len(lines) and lines[j].strip().startswith("|"): while j < len(lines) and lines[j].strip().startswith("|"):
if lines[j].count("|") != n: if table_pipes(lines[j]) != n:
bad_rows += 1 bad_rows += 1
j += 1 j += 1
i = j i = j