feat(M5): P0 契约层 + 审批/应用设置下线

- types/homeworkV2.ts:V2 全量类型契约(五态/枚举/矩阵/版本/通知)
- teacherHomework/studentHomework/notification 三客户端(28 端点)
- §6 审批功能下线:删 ApprovalList/ApprovalSetting/ApprovalFlowEditor,
  types/api/menu/menuRoute/App/mock 同步清理,DROPPED_MOD_IDS={5,6}
- §7 应用设置下线:删 HomeworkSetting/TypeSetBox/UserCheckInput,
  SettingApi 与 dict/contacts 死代码清理,DROPPED_MOD_IDS 扩 {21}
- 新增 V2 组件:TeacherHomeworks(已发布+草稿箱)、ReviewModal(批阅弹窗)、
  AttachmentUpload(附件上传)——尚未挂路由,页面切换随后续阶段
This commit is contained in:
wanghu
2026-09-16 02:29:32 +08:00
parent d75aab8d07
commit e6639f7925
40 changed files with 7734 additions and 1535 deletions
@@ -0,0 +1,163 @@
import routes from "@/router/routes";
/**
* 平台模块配置 → 前端内部路由的统一解析器。
*
* 基础平台里配置的模块 path 格式不可控(可能带 /homework 旧前缀、带
* index.html#/hash、是应用入口地址甚至完整 URL、或干脆配错),直接
* router.push(item.page) 匹配不到就会白屏或被重定向兜到默认页。所有消费
* 模块 page 的地方(侧边栏跳转/高亮、登录后默认跳转)必须先经过这里。
*
* 解析顺序:路径精确匹配 → 模块名匹配 → 外链判定 → modId 契约匹配。
* 全部落空时返回空结果并 console.warn,绝不返回未注册的原始路径——
* 把它 push 给路由器会被 / 重定向吞掉,表象就是"点了菜单跳到默认页"。
*/
/** 路由 path 集合:重定向路由穿透到目标 path,保证解析结果可直接用于高亮对照 */
const ROUTE_PATHS = routes
.map((r) => (typeof r.redirect === "string" ? r.redirect : r.path))
.filter((p): p is string => !!p && !p.includes(":"));
/** modId → 路由 path(取该 modId 的第一个非重定向路由) */
const ROUTE_BY_MODID = new Map<number, string>();
for (const r of routes) {
const modId = (r.meta as any)?.modId as number | undefined;
if (!modId || !r.path || r.path.includes(":") || r.redirect) continue;
if (!ROUTE_BY_MODID.has(modId)) ROUTE_BY_MODID.set(modId, r.path);
}
/** 标题 → 路由 path(重复标题取先注册的,如 发作业 → /list/new) */
const ROUTE_BY_TITLE = new Map<string, string>();
for (const r of routes) {
if (r.redirect || !r.path || r.path.includes(":")) continue;
const title = (r.meta as any)?.title as string | undefined;
if (title && !ROUTE_BY_TITLE.has(title)) ROUTE_BY_TITLE.set(title, r.path);
}
/** 平台历史模块名 → 路由(旧菜单叫法与现页面标题不一致的对照) */
const NAME_ALIASES: Record<string, string> = {
作业发送: "/list/new",
发作业: "/list/new",
已发作业: "/list",
已收作业: "/accept",
作业列表: "/homeworklist",
统计分析: "/stats",
};
const EXTERNAL_RE = /^(https?:)?\/\//i;
/** 从平台原始配置里提取内部路由候选串(去掉 hash、query、尾斜杠) */
function extractCandidate(raw: string): string {
let p = raw.trim();
try {
p = decodeURIComponent(p);
} catch {
/* 含非法编码时保持原样 */
}
// 带 hash 的地址(.../index.html#/setting):取 # 后的 SPA 路由
const hashIdx = p.indexOf("#");
if (hashIdx >= 0) {
p = p.slice(hashIdx + 1);
} else if (EXTERNAL_RE.test(p)) {
// 无 hash 的完整 URL:取 pathname(如 https://host/setting)
try {
p = new URL(p, "http://placeholder.local").pathname;
} catch {
/* 保持原样 */
}
}
const qIdx = p.indexOf("?");
if (qIdx >= 0) p = p.slice(0, qIdx);
// 入口文件名去掉后即可命中根重定向路由
p = p.replace(/index\.html?$/i, "");
p = p.replace(/\/+$/, "");
if (p && !p.startsWith("/")) p = "/" + p;
return p;
}
/** 候选串 → 已注册路由 path;未命中返回 null */
function matchRoute(candidate: string): string | null {
if (!candidate) return null;
const lower = candidate.toLowerCase();
// 1. 精确匹配(含重定向穿透后的目标路径)
const exact = ROUTE_PATHS.find((p) => p.toLowerCase() === lower);
if (exact) return exact;
// 2. 兼容旧 /homework 前缀(/homework/setting → /setting)。
// 不做通用后缀匹配,避免 /stats/OA/Homework 这类外部路径被误匹配
if (lower.startsWith("/homework/")) {
const stripped = lower.slice("/homework".length);
return ROUTE_PATHS.find((p) => p.toLowerCase() === stripped) ?? null;
}
return null;
}
/** 按模块名解析路由:精确标题 → 历史别名 → 双向包含(命中多个则放弃) */
function matchByName(name?: string | null): string | null {
const n = String(name ?? "").trim();
if (!n) return null;
const byTitle = ROUTE_BY_TITLE.get(n);
if (byTitle) return byTitle;
const alias = NAME_ALIASES[n];
if (alias) return alias;
const hits = [...ROUTE_BY_TITLE.entries()].filter(
([title]) => title.length >= 2 && (n.includes(title) || title.includes(n))
);
return hits.length === 1 ? hits[0][1] : null;
}
/** 是否为本应用入口类地址(/、/homework、…/index.html):只表示"打开应用",
* 不携带页面信息,不能当作有效跳转目标 */
function isAppEntryCandidate(candidate: string): boolean {
return !candidate || candidate === "/" || candidate === "/homework";
}
export interface ResolvedMenuPage {
/** 内部路由 path;解析不出时为空串 */
path: string;
/** 外部链接(完整 URL 且不属于本应用路由),需整页跳转而非 router.push */
external: boolean;
}
/** 空配置的统一返回值 */
export const EMPTY_MENU_PAGE: ResolvedMenuPage = { path: "", external: false };
export function resolveMenuPage(
page?: string | null,
modId?: number | null,
name?: string | null
): ResolvedMenuPage {
const raw = String(page ?? "").trim();
const byName = (): string | null => matchByName(name);
const byModId = (): string | null => (modId != null ? ROUTE_BY_MODID.get(modId) ?? null : null);
if (raw) {
const candidate = extractCandidate(raw);
const byPath = matchRoute(candidate);
if (byPath) return { path: byPath, external: false };
// 完整 URL:指向本应用入口的不算有效配置(继续用名字/modId 识别),
// 其余按外部链接整页打开
if (EXTERNAL_RE.test(raw) && !raw.includes("#")) {
if (!isAppEntryCandidate(candidate)) {
return { path: raw, external: true };
}
}
const fallback = byName() ?? byModId();
if (fallback) return { path: fallback, external: false };
console.warn(
"[menuRoute] 模块路径/名称/modId 均无法匹配前端路由,请检查基础平台模块配置:",
raw,
"modId =", modId,
"name =", name
);
return EMPTY_MENU_PAGE;
}
// 未配置 path:按模块名 → modId 契约解析
const fallback = byName() ?? byModId();
if (fallback) return { path: fallback, external: false };
console.warn("[menuRoute] 模块未配置路径且名称/modId 无法识别:", name, "modId =", modId);
return EMPTY_MENU_PAGE;
}