commit
build / build (push) Successful in 9m55s

This commit is contained in:
wanghu
2026-10-10 15:58:02 +08:00
parent 6faa332d2b
commit 2e36d7a9c8
1842 changed files with 70537 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+76
View File
@@ -0,0 +1,76 @@
// 整壳预览入口:路由 + 应用外壳(layout) + mock 菜单数据,用于验收
// 「平台菜单点击 → 内部路由跳转 → 侧栏高亮 / 面包屑」整条链路。
//
// 存在的理由:业务 dev(8079) 挂在平台 CAS 下,无登录态的自动化浏览器拿不到
// 真实页面,而这次的问题恰恰只在「侧栏点击」这一步暴露——单独渲染某个页面
// 是验不出来的。
//
// 用法:
// node preview-ut/mock-server.mjs # mock 数据(8109)
// npx vite --config preview.vite.config.ts --port 8099
// 打开 http://localhost:8099/preview-app.html#/__probe_start
// (起始 hash 故意用未匹配路径:每个菜单项都走 push 分支,避免 router.go(0)
// 刷新页面导致探针重入)
//
// 断言结果写入 document.title,用无头浏览器读取:
// msedge --headless=new --disable-gpu --virtual-time-budget=20000 --dump-dom <url>
import { createApp } from "vue";
import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss";
import "@/assets/styles/layout.css";
import "@/assets/styles/icons.css";
import "virtual:uno.css";
import "virtual:svg-icons-register";
import App from "@/App.vue";
import router from "@/router";
import utils from "@/utils";
import mittBus from "@/utils/mitt";
import directive from "@/utils/directive/index";
import config from "@/assets/config/host.config";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(ViewUIPlus);
app.config.globalProperties.$conf = config;
app.config.globalProperties.$utils = utils;
app.config.globalProperties.$bus = mittBus;
directive(app);
app.mount("#app");
/** 侧栏解析失败时 layout.linkMenu 弹出的提示文案,出现即视为该菜单项未配置有效路径 */
const WARN_TEXT = "未配置有效页面路径";
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function probe() {
await wait(1500); // 等 App 拉完 GetUserMod 并渲染侧栏
const links = Array.from(document.querySelectorAll<HTMLElement>(".left-menu .menu-item > a"));
const out: string[] = [];
let warned = 0;
for (const a of links) {
const name = a.querySelector(".menu-text")?.textContent?.trim() || "?";
a.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await wait(400);
const warn = document.body.innerText.includes(WARN_TEXT);
if (warn) warned++;
// 清掉提示条,避免污染下一项判定
document.querySelectorAll(".ivu-message").forEach((n) => n.remove());
const path = (location.hash || "").replace(/^#/, "") || "/";
const active = document.querySelector(".left-menu li.active .menu-text")?.textContent?.trim() || "-";
const crumb = (document.querySelector(".path-nav")?.textContent || "").replace(/\s+/g, "").trim();
// 流程管理菜单的落地 tab(请假设置页仅 flow tab 用 liucheng 图标)
const tab = document.querySelector(".sett-tabs .ivu-tabs-tab-active")?.textContent?.trim() || "-";
out.push(`${name}=>${path},active=${active},crumb=${crumb},tab=${tab}${warn ? ",!WARN" : ""}`);
}
document.title = `APP|menus=${links.length}|warned=${warned}|` + out.join(" | ");
}
probe();
+36
View File
@@ -0,0 +1,36 @@
// 独立预览入口:绕过 SSO/登录,直接渲染「我要请假」页以核对分段控件(请假类型等)样式
// 数据来自 preview-ut/mock-server.mjs(8109),由 preview.vite.config.ts 代理
import { createApp, h } from "vue";
import { createRouter, createMemoryHistory, RouterView } from "vue-router";
import ViewUIPlus from "view-ui-plus";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss";
import "@/assets/styles/layout.css";
import "@/assets/styles/icons.css";
import "virtual:svg-icons-register";
import ApplyLeave from "@/views/leave/ApplyLeave.vue";
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", redirect: "/apply" },
{ path: "/apply", component: ApplyLeave },
],
});
router.push("/apply");
const app = createApp({ render: () => h(RouterView) });
app.use(router);
app.use(ViewUIPlus);
app.mount("#app");
// 便于截图核对:撑开容器 + 选中第二个类型观察选中态
setTimeout(() => {
const host = document.getElementById("app");
if (host) {
host.style.padding = "20px 24px 40px";
host.style.minHeight = "100vh";
host.style.boxSizing = "border-box";
host.style.background = "#f5f7fa";
}
}, 300);
+317
View File
@@ -0,0 +1,317 @@
// 独立预览入口:一次性纵向渲染 6 个含搜索区的列表页,用于核对 .search-wrap 视觉规范。
//
// 存在的理由:业务 dev 端口(8081/8078/8079)挂在平台 CAS 下,无登录态会被重定向到
// SSO,自动化浏览器截不到真实页面;且各页面分散在 6 条路由上,逐一截图低效。
// 这里直接把 6 个组件竖排挂载到同一页,一次滚动即可比对「搜索区是否风格统一」。
//
// 用法(无需 mock 服务、无需登录,任意端口都可):
// npx vite --config preview.vite.config.ts --port 8099 → http://localhost:8099/preview-list.html
// 业务 dev 端口同样可访问(页面在项目根目录):http://localhost:8081/preview-list.html
// 加 ?probe=1 跑交互断言并把结果写入 document.title
//
// 接口由 ./mock-api 在页内以 fetch 拦截提供,不经网络。
import { createApp, h, defineComponent, ref, onMounted, nextTick } from "vue";
import { createRouter, createMemoryHistory, RouterView } from "vue-router";
import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss";
import "@/assets/styles/layout.css";
import "@/assets/styles/icons.css";
import "virtual:uno.css";
import "virtual:svg-icons-register";
import utils from "@/utils";
import config from "@/assets/config/host.config";
import { installApiStub } from "./mock-api";
import MyLeave from "@/views/leave/MyLeave.vue";
import AllLeave from "@/views/leave/AllLeave.vue";
import LeaveCc from "@/views/leave/LeaveCc.vue";
import LeaveAudit from "@/views/leave/LeaveAudit.vue";
import TimeLog from "@/views/leave/TimeLog.vue";
import LeaveStats from "@/views/leave/LeaveStats.vue";
// 必须在任何组件挂载(即任何请求发出)之前接管 fetch:
// 否则 dev 端口下 /api 会代理到受 CAS 保护的后端,返回 code:302 触发整页跳转 SSO
installApiStub();
const PAGES = [
{ key: "myleave", name: "我的请假", path: "/myleave", comp: MyLeave },
{ key: "allleave", name: "全部请假", path: "/allleave", comp: AllLeave },
{ key: "cc", name: "抄送列表", path: "/cc", comp: LeaveCc },
{ key: "audit", name: "审批列表", path: "/audit", comp: LeaveAudit },
{ key: "timelog", name: "时长日志", path: "/timelog", comp: TimeLog },
{ key: "stats", name: "统计分析", path: "/stats", comp: LeaveStats },
];
/** 运行期错误收集:探针把条数写进 title,避免「页面看着正常但控制台在报错」被漏掉 */
const RUNTIME_ERRORS: string[] = [];
window.addEventListener("error", (e) => RUNTIME_ERRORS.push(String(e.message || e)));
window.addEventListener("unhandledrejection", (e) => RUNTIME_ERRORS.push(`reject:${e.reason}`));
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: "/", component: { render: () => h("div") } }, ...PAGES.map((p) => ({ path: p.path, component: p.comp }))],
});
router.push("/");
// 每页一个独立挂载点:与业务里「一页一路由」等价(page-wrapper 是绝对定位上下文,
// 直接平铺会重叠,因此各自套一个高度受控的容器)
const Section = defineComponent({
props: { page: { type: Object, required: true } },
setup(props) {
const host = ref<HTMLElement>();
onMounted(async () => {
await nextTick();
const inner = document.createElement("div");
host.value!.appendChild(inner);
const sub = createApp({ render: () => h(props.page.comp) });
sub.use(createPinia());
sub.use(ViewUIPlus);
sub.config.globalProperties.$conf = config;
sub.config.globalProperties.$utils = utils;
sub.mount(inner);
});
return () =>
h("div", { style: "margin-bottom:18px" }, [
h(
"div",
{
style:
"font:600 13px -apple-system,'Microsoft YaHei',sans-serif;color:#17233d;" +
"padding:8px 14px;background:#eef3ff;border-left:3px solid #577afb;border-radius:0 6px 6px 0;margin-bottom:10px",
},
`${props.page.name} · ${props.page.path}`
),
h("div", { ref: host, style: "position:relative;height:520px;overflow:hidden;border:1px solid #e6ebf4;border-radius:8px;background:#fff" }),
]);
},
});
const app = createApp({
render: () =>
h(
"div",
{ style: "padding:16px 20px 60px;background:#f5f7fa;min-height:100vh;box-sizing:border-box" },
PAGES.map((p) => h(Section, { page: p, key: p.key }))
),
});
app.use(createPinia());
app.use(router);
app.use(ViewUIPlus);
app.config.globalProperties.$conf = config;
app.config.globalProperties.$utils = utils;
app.mount("#app");
// 探针:核对 6 页搜索区的实测盒模型 + 关键子控件的计算样式是否与规范一致
if (new URLSearchParams(location.search).has("probe")) {
setTimeout(() => {
const out: string[] = [];
let bad = 0;
const say = (ok: boolean, msg: string) => {
if (!ok) bad++;
out.push(`${msg}${ok ? "=ok" : "=FAIL"}`);
};
const wraps = document.querySelectorAll<HTMLElement>(".search-wrap");
say(wraps.length === 6, `wraps(${wraps.length})`);
wraps.forEach((el, i) => {
const cs = getComputedStyle(el);
const h = Math.round(el.getBoundingClientRect().height);
const radius = Math.round(parseFloat(cs.borderTopLeftRadius) || 0);
// 容器不得出现横向溢出(flex 换行会把高度顶破 56px)
const overflow = el.scrollWidth > el.clientWidth + 1;
say(h === 56 && radius === 10 && !overflow, `#${i + 1}[h${h},r${radius}${overflow ? ",OVF" : ""}]`);
});
// 控件高度统一 32px + 圆角 8px
const ctrl = document.querySelector<HTMLElement>(".search-wrap .ivu-input");
if (ctrl) {
const cs = getComputedStyle(ctrl);
say(Math.round(parseFloat(cs.height)) === 32 && Math.round(parseFloat(cs.borderTopLeftRadius)) === 8, `input`);
}
// 缩放按钮:幽灵态(白底 + 灰字),非 disabled
const btn = document.querySelector<HTMLElement>(".search-wrap_reset .ivu-btn");
if (btn) {
const cs = getComputedStyle(btn);
say(!btn.classList.contains("ivu-btn-disabled") && Math.round(parseFloat(cs.height)) === 32, `reset`);
}
// 列表真的渲染出行(接口 mock 是否接上)
const rows = document.querySelectorAll(".search-wrap ~ .ivu-table-wrapper .ivu-table-row").length;
say(rows > 0, `rows(${rows})`);
// 关键词搜索图标存在(搜索框右侧放大镜);统计分析页用「查询」按钮,故为 5 个
const icon = document.querySelectorAll(".search-wrap_kw .ivu-input-search-icon").length;
say(icon === 5, `searchIcon(${icon})`);
// 重置按钮 6 页齐全 + 查询按钮 1 个(统计分析)
say(document.querySelectorAll(".search-wrap_reset").length === 5, "resetBtns");
say(document.querySelectorAll(".search-wrap .ivu-btn-primary").length === 1, "queryBtn");
// ===== 起止两个日期输入框:等宽、等高、垂直对齐(本次核心诉求)=====
const pairs = Array.from(document.querySelectorAll<HTMLElement>(".search-wrap_dates"));
say(pairs.length === 5, `datePairs(${pairs.length})`);
let pairBad = 0;
let tilde = 0;
pairs.forEach((pair) => {
const inputs = Array.from(pair.querySelectorAll<HTMLElement>(".ivu-input"));
const r = inputs.map((el) => el.getBoundingClientRect());
const two = r.length === 2;
const sameW = two && Math.abs(r[0].width - r[1].width) < 0.5;
const sameH = two && Math.abs(r[0].height - r[1].height) < 0.5;
// 顶边对齐 + 垂直中心线一致,避免两框错行
const aligned = two && Math.abs(r[0].top - r[1].top) < 0.5 &&
Math.abs(r[0].top + r[0].height / 2 - (r[1].top + r[1].height / 2)) < 0.5;
if (!(sameW && sameH && aligned)) pairBad++;
tilde += pair.querySelectorAll(".search-wrap_tilde").length;
});
say(pairBad === 0, `pairsAligned(${pairs.length - pairBad}/${pairs.length})`);
say(tilde === 5, `tilde(${tilde})`);
// 同一对内两个框的计算样式必须逐项一致(边框/圆角/字号/内边距)
const p0 = pairs[0];
if (p0) {
const cs = Array.from(p0.querySelectorAll<HTMLElement>(".ivu-input")).map((el) => getComputedStyle(el));
const keys = ["height", "borderTopWidth", "borderTopColor", "borderTopLeftRadius", "fontSize", "color"] as const;
say(cs.length === 2 && keys.every((k) => cs[0][k] === cs[1][k]), "pairStyleSame");
}
// 日期输入框:确认样式命中(32 高 / 8 圆角 / 140 宽)
const dateInput = document.querySelector<HTMLElement>(".search-wrap_dates .ivu-input");
if (dateInput) {
const c = getComputedStyle(dateInput);
const w = Math.round(dateInput.getBoundingClientRect().width);
say(Math.round(parseFloat(c.height)) === 32 && Math.round(parseFloat(c.borderTopLeftRadius)) === 8 && w === 140, `dateBox(${w}x${Math.round(parseFloat(c.height))})`);
}
// 交互:点击日期输入框本体应弹出单月历(可编辑态,走 handleFocus)
const dateInput2 = document.querySelector<HTMLElement>(".search-wrap_dates .ivu-input");
if (dateInput2) {
dateInput2.click();
setTimeout(() => {
// 面板根类名是 ivu-picker-panel-body-wrapper;transfer 会把所有页面板挂到 body,需按可见性过滤
const all = Array.from(document.querySelectorAll<HTMLElement>(".ivu-picker-panel-body-wrapper"));
const open = all.filter((p) => p.offsetParent !== null);
const cells = open.reduce((n, p) => n + p.querySelectorAll(".ivu-date-picker-cells").length, 0);
say(open.length === 1 && cells === 1, `datePanel(${open.length}p/${cells}c)`);
// 组件内部状态:点击输入框即置为打开
const rootEl = document.querySelector<HTMLElement>(".search-wrap_dates .ivu-date-picker");
const vm: any = rootEl && (rootEl as any).__vueParentComponent;
const p = vm?.proxy || vm?.ctx;
say(!!p && p.opened === true, "pickerOpens");
// 搜索图标:确认样式命中(iView 用 .ivu-input-search-icon,无 suffix 包裹层)
const si = document.querySelector<HTMLElement>(".search-wrap_kw .ivu-input-search-icon");
if (si) {
const c = getComputedStyle(si);
say(Math.round(parseFloat(c.width)) === 34 && Math.round(parseFloat(c.height)) === 32, "searchIconBox");
say(c.color === "rgb(153, 162, 181)", "searchIconColor");
}
// 重置按钮:幽灵态 —— 32px 高、白底
const rb = document.querySelector<HTMLElement>(".search-wrap_reset.ivu-btn");
if (rb) {
const c = getComputedStyle(rb);
say(Math.round(parseFloat(c.height)) === 32 && c.backgroundColor === "rgb(255, 255, 255)", "resetGhost");
}
// 搜索区卡片:白底 + 1px 边框 + 圆角 + 极淡投影
const sw = wraps[0];
if (sw) {
const c = getComputedStyle(sw);
say(c.backgroundColor === "rgb(255, 255, 255)" && c.borderTopWidth === "1px" && c.boxShadow !== "none", "card");
}
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
document.body.click();
finish();
}, 800);
return;
}
// 交互:往关键词框输入内容 → 点「重置」,输入应被清空且期间不抛错
function finish() {
const resetBtn = document.querySelector<HTMLElement>(".search-wrap_reset.ivu-btn");
const kwInput = document.querySelector<HTMLInputElement>(".search-wrap_kw .ivu-input");
if (resetBtn && kwInput) {
kwInput.value = "事假";
kwInput.dispatchEvent(new Event("input", { bubbles: true }));
setTimeout(() => {
kwInput.dispatchEvent(new Event("input", { bubbles: true }));
resetBtn.click();
setTimeout(() => {
say(!document.querySelector<HTMLInputElement>(".search-wrap_kw .ivu-input")!.value, "resetClears");
say(RUNTIME_ERRORS.length === 0, `errs(${RUNTIME_ERRORS.length})`);
report();
}, 600);
}, 300);
return;
}
say(RUNTIME_ERRORS.length === 0, `errs(${RUNTIME_ERRORS.length})`);
report();
}
function report() {
document.title = `LIST|bad=${bad}|` + out.join("|") + (RUNTIME_ERRORS.length ? "|" + RUNTIME_ERRORS.slice(0, 2).join("~") : "");
}
}, 2600);
}
// 对齐诊断(?align=1):逐控件量出盒模型 + 文字基线相对容器的偏移,
// 用于定位「哪个元素没对齐」——无头环境下肉眼截图不可靠,直接量数值最准。
if (new URLSearchParams(location.search).has("align")) {
setTimeout(() => {
const n1 = (v: number) => Math.round(v * 10) / 10;
const bars = Array.from(document.querySelectorAll<HTMLElement>(".search-wrap"));
const lines: string[] = [];
bars.forEach((bar, bi) => {
const br = bar.getBoundingClientRect();
const cy = br.top + br.height / 2;
lines.push(`=bar${bi + 1}(h${n1(br.height)},cy${n1(cy)})`);
// 逐个「控件级」元素:取到盒模型中心与容器中心的偏差
const nodes = Array.from(
bar.querySelectorAll<HTMLElement>(
".search-wrap_dates .ivu-date-picker, .search-wrap_tilde, .search-wrap_sel, .search-wrap_reset.ivu-btn, .search-wrap_kw, .search-wrap .ivu-btn-primary, .stats-tip"
)
);
nodes.forEach((el) => {
const r = el.getBoundingClientRect();
const off = r.top + r.height / 2 - cy;
// 文字行盒(用 Range 量文本本身,能反映真实基线差异)
let textOff = "";
const txt = el.querySelector<HTMLElement>("span, .ivu-select-selected-value, .ivu-select-placeholder");
const target = txt || el;
if (target && target.firstChild && target.firstChild.nodeType === 3) {
const rg = document.createRange();
rg.selectNodeContents(target);
const tr = rg.getBoundingClientRect();
if (tr.height) textOff = `,txt${n1(tr.top + tr.height / 2 - cy)}`;
}
const cls = (el.className || "").toString().split(" ").filter((c) => /search-wrap|stats-tip|ivu-btn/.test(c)).join(".");
lines.push(`${cls}(t${n1(r.top - br.top)},h${n1(r.height)},off${n1(off)}${textOff})`);
});
// 日期输入框的「内容区」中心(input 无法用 Range 取文字,用内容盒中心代替)
bar.querySelectorAll<HTMLElement>(".search-wrap_dates .ivu-input").forEach((el, i) => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
const bt = parseFloat(cs.borderTopWidth);
const pt = parseFloat(cs.paddingTop);
const pb = parseFloat(cs.paddingBottom);
const bb = parseFloat(cs.borderBottomWidth);
const contentTop = r.top + bt + pt;
const contentH = r.height - bt - bb - pt - pb;
lines.push(`dateInput${i + 1}(t${n1(r.top - br.top)},h${n1(r.height)},contentC${n1(contentTop + contentH / 2 - cy)})`);
});
});
document.title = "ALIGN|" + lines.join("|");
}, 2800);
}
+86
View File
@@ -0,0 +1,86 @@
// 「平台菜单 → 内部路由」解析验收脚本(独立预览,不参与业务构建)
//
// 背景:基础平台 GetUserMod 返回的模块 path 是历史路径(/leave/shenhe、
// /leave/liucheng…),modId 又是环境相关的自增 id(本环境 97–105,与路由
// meta 里的历史编号并不一致)。只要有模块解析不出内部路由,侧栏点击就会弹
// 「菜单「x」未配置有效页面路径」。本脚本把平台真实返回原样喂给解析器,
// 逐项核对解析结果 + 路由是否可 resolve,结果写入 document.title 供无头
// 浏览器 --dump-dom 读取(DOM 渲染中文在无头环境不稳定,标题更可靠)。
//
// 用法:
// npx vite --config preview.vite.config.ts --port 8099
// 打开 http://localhost:8099/preview-menu.html(人眼)或加 --dump-dom(断言)
import { createRouter, createMemoryHistory } from "vue-router";
import routes from "@/router/routes";
import { resolveMenuPage } from "@/utils/menuRoute";
/** 平台 GetUserMod 原样返回(2026-10 环境) */
const MODS: any[] = [
{ id: 97, modId: 97, name: "我要请假", page: "/leave/" },
{ id: 98, modId: 98, name: "我的请假", page: "/leave/myleave" },
{ id: 99, modId: 99, name: "审批列表", page: "/leave/shenhe" },
{ id: 100, modId: 100, name: "抄送列表", page: "/leave/leave_send" },
{ id: 101, modId: 101, name: "全部请假", page: "/leave/totallist" },
{ id: 102, modId: 102, name: "流程管理", page: "/leave/liucheng" },
{ id: 103, modId: 103, name: "请假设置", page: "/leave/leave_timeSetting" },
{ id: 104, modId: 104, name: "时长日志", page: "/leave/leave_amendant" },
{ id: 105, modId: 105, name: "统计分析", page: "/leave/tongji" },
];
const router = createRouter({ history: createMemoryHistory(), routes });
const rows = MODS.map((m) => {
const r = resolveMenuPage(m.page, m.modId ?? m.id, m.name);
// 路由层能否 resolve(含重定向穿透),用于区分「解析器不认」与「路由表没有」
const rv = router.resolve(m.page || "/");
return {
name: m.name,
modId: m.modId,
page: m.page,
resolved: r.path,
external: r.external,
routerPath: rv.path,
matched: rv.matched.length,
};
});
const bad = rows.filter((r) => !r.resolved);
document.title =
`MENU|bad=${bad.length}|` +
rows.map((r) => `${r.name}=${r.resolved || "ERR"}${r.external ? "(ext)" : ""}`).join(" | ");
// 人眼核对用的表格
const app = document.getElementById("app")!;
app.innerHTML = `
<div style="font:13px/1.6 -apple-system,'Microsoft YaHei',sans-serif;padding:18px 22px">
<h3 style="margin:0 0 10px">平台菜单 → 内部路由 解析结果
<span style="font-weight:400;color:${bad.length ? "#f5222d" : "#19be6b"}">
未解析 ${bad.length} 项
</span>
</h3>
<table style="border-collapse:collapse;width:100%">
<thead><tr style="background:#f5f7fa">
${["平台模块名", "modId", "平台配置 page", "解析结果 path", "外链", "router.resolve", "matched"]
.map((h) => `<th style="border:1px solid #e8eaec;padding:6px 10px;text-align:left">${h}</th>`)
.join("")}
</tr></thead>
<tbody>
${rows
.map(
(r) => `<tr>
<td style="border:1px solid #e8eaec;padding:6px 10px">${r.name}</td>
<td style="border:1px solid #e8eaec;padding:6px 10px">${r.modId}</td>
<td style="border:1px solid #e8eaec;padding:6px 10px;color:#808695">${r.page}</td>
<td style="border:1px solid #e8eaec;padding:6px 10px;font-weight:600;color:${r.resolved ? "#2d8cf0" : "#f5222d"}">${r.resolved || "解析失败"}</td>
<td style="border:1px solid #e8eaec;padding:6px 10px">${r.external ? "是" : "-"}</td>
<td style="border:1px solid #e8eaec;padding:6px 10px">${r.routerPath}${r.matched ? "" : "(无匹配)"}</td>
<td style="border:1px solid #e8eaec;padding:6px 10px">${r.matched}</td>
</tr>`
)
.join("")}
</tbody>
</table>
<p style="margin:12px 0 0;color:#808695">
期望:9 项全部解析到注册路由;「流程管理」应落在 /flow(打开请假设置页的流程管理 tab)。
</p>
</div>`;
+98
View File
@@ -0,0 +1,98 @@
// 独立预览入口:打印请假单页(LeavePrint.vue)的视觉/交互验收。
//
// 与 main-list.ts 同一套思路:业务 dev 端口受 CAS 保护,自动化浏览器截不到真实页面,
// 这里用 mock-api 的页内 fetch 拦截独立渲染(无需登录、无需 mock-server)。
//
// 用法:
// npx vite --config preview.vite.config.ts --port 8099
// 打开 http://localhost:8099/preview-print.html → 屏幕预览(A4 纸张样式)
// 加 ?probe=1 跑断言并把结果写入 document.title
//
// 说明:window.print() 会弹浏览器打印对话框,自动化环境不可控,探针只断言
// 「打印 CSS 已注入 + html.leave-print-mode 已挂载」,纸张效果以屏幕截图核对。
import { createApp, h } from "vue";
import { createRouter, createMemoryHistory, RouterView } from "vue-router";
import { createPinia } from "pinia";
import ViewUIPlus from "view-ui-plus";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss";
import "@/assets/styles/layout.css";
import "@/assets/styles/icons.css";
import "virtual:uno.css";
import "virtual:svg-icons-register";
import utils from "@/utils";
import config from "@/assets/config/host.config";
import { installApiStub } from "./mock-api";
import LeavePrint from "@/views/leave/LeavePrint.vue";
installApiStub();
const RUNTIME_ERRORS: string[] = [];
window.addEventListener("error", (e) => RUNTIME_ERRORS.push(String(e.message || e)));
window.addEventListener("unhandledrejection", (e) => RUNTIME_ERRORS.push(`reject:${e.reason}`));
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: "/print", component: LeavePrint }],
});
const app = createApp({ render: () => h(RouterView) });
app.use(createPinia());
app.use(router);
app.use(ViewUIPlus);
app.config.globalProperties.$conf = config;
app.config.globalProperties.$utils = utils;
// 先 install 再跳转:install 前的 push 在部分 vue-router 4 小版本上会丢初始导航
router.push("/print?leaveId=4");
router.isReady().then(() => app.mount("#app"));
if (new URLSearchParams(location.search).has("probe")) {
setTimeout(() => {
const out: string[] = [];
let bad = 0;
const say = (ok: boolean, msg: string) => {
if (!ok) bad++;
out.push(`${msg}${ok ? "=ok" : "=FAIL"}`);
};
// 挂载期间应给 html 打上打印模式类(print CSS 作用域开关)
say(document.documentElement.classList.contains("leave-print-mode"), "modeClass");
// 全局打印规则已注入(非 scoped 块存在 @media print + .header-container 隐藏)
let printRule = false;
for (const sheet of Array.from(document.styleSheets)) {
try {
for (const rule of Array.from(sheet.cssRules || [])) {
const r = rule as CSSMediaRule;
if (r.type === CSSRule.MEDIA_RULE && /print/.test(r.conditionText || "")) {
if (/header-container/.test(r.cssText)) printRule = true;
}
}
} catch {
/* 跨域样式表忽略 */
}
}
say(printRule, "printRule");
// 单据渲染:标题 / 信息表 / 审批记录 / 签字栏
say(!!document.querySelector(".print-sheet"), "sheet");
const title = document.querySelector<HTMLElement>(".sheet-title");
say(!!title && title.textContent!.includes("教职工请假单"), `title(${title?.textContent})`);
say(document.querySelectorAll(".sheet-table").length >= 3, `tables(${document.querySelectorAll(".sheet-table").length})`);
say(document.querySelectorAll(".flow-table tbody tr").length === 3, `steps(${document.querySelectorAll(".flow-table tbody tr").length})`);
say(!!document.querySelector(".status-stamp"), "stamp");
// 数据正确性:产假 80 小时 + 状态「已通过」
const bodyText = document.querySelector(".print-sheet")?.textContent || "";
say(bodyText.includes("产假"), "typeName");
say(bodyText.includes("80 小时"), "hours");
say(bodyText.includes("已通过"), "statusName");
say(bodyText.includes("2026-10-09 19:28"), "applyTime");
// 工具栏存在(打印/返回按钮)
say(document.querySelectorAll(".print-toolbar .ivu-btn").length === 2, "toolbar");
say(RUNTIME_ERRORS.length === 0, `errs(${RUNTIME_ERRORS.length})`);
document.title = `PRINT|bad=${bad}|` + out.join("|") + (RUNTIME_ERRORS.length ? "|" + RUNTIME_ERRORS.slice(0, 2).join("~") : "");
}, 2200);
}
+94
View File
@@ -0,0 +1,94 @@
// 「教师时长」页独立预览入口 —— 仅用于本地视觉/交互验收,不参与业务构建
//
// 为什么需要它:
// 业务 dev(8079) 打开设置页会经后端 CAS 过滤器跳 SSO,无登录态的自动化浏览器
// 拿不到页面,导致「改完只能靠静态检查」。这里用mock 服务提供数据、独立 vite
// 配置单独起实例,从而在无登录态下真实渲染 + 交互 + 截图。
//
// 用法(三条命令,两个终端):
// node preview-ut/mock-server.mjs # 终端 1:mock 数据 8109
// npx vite --config preview.vite.config.ts# 终端 2:预览服务 8099
// 打开 http://localhost:8099/preview-ut.html
//
// 截图(无头 Edge,Windows):
// msedge --headless=new --disable-gpu --hide-scrollbars --window-size=1500,880 \
// --virtual-time-budget=16000 --screenshot=out.png http://localhost:8099/preview-ut.html
import { createApp } from "vue";
import ViewUIPlus from "view-ui-plus";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss";
import "@/assets/styles/layout.css";
import "@/assets/styles/icons.css";
import "virtual:svg-icons-register";
import LeaveSetting from "@/views/leave/LeaveSetting.vue";
const app = createApp(LeaveSetting);
app.use(ViewUIPlus);
app.mount("#app");
/** 自动验收脚本:?probe=1 教师时长 / ?probe=flow 流程管理,结果写入 document.title 供 --dump-dom 读取 */
const PROBE = new URLSearchParams(location.search).get("probe");
// 按 ?tab= 切换 tab(flow=流程管理 / 其他值按文案匹配),默认「教师时长」,便于截图核对
setTimeout(() => {
const want = new URLSearchParams(location.search).get("tab");
const label = !want || want === "usertime" ? "教师时长" : want === "flow" ? "流程管理" : want;
document.querySelectorAll(".sett-tabs .ivu-tabs-tab").forEach((t) => {
if ((t.textContent || "").includes(label)) (t as HTMLElement).click();
});
const host = document.getElementById("app");
if (host) {
host.style.padding = "16px 20px 28px";
host.style.minHeight = "100vh";
host.style.boxSizing = "border-box";
}
}, 500);
if (PROBE === "flow") {
// 流程管理自动验收:点第 4 行(通知流程,mock 默认「未启用」)的启用绑定开关,
// 等 loadFlowManage 刷新后核对开关是否翻为选中态(链路:Switch → Enable 接口 → mock → List 刷新)。
setTimeout(() => {
const sw = document.querySelectorAll(".flow-table tbody tr")[3]?.querySelector(".ivu-switch");
sw?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
setTimeout(() => {
const rows2 = document.querySelectorAll(".flow-table tbody tr");
const sw2 = rows2[3]?.querySelector(".ivu-switch");
document.title = [
"flowProbe",
"switches=" + document.querySelectorAll(".flow-table .ivu-switch").length,
"enabledChecked=" + (sw2?.classList.contains("ivu-switch-checked") ? 1 : 0),
].join(" | ");
}, 1800);
}, 1600);
}
if (PROBE && PROBE !== "flow") {
// 自动验收:点 +4 / +1 / -1,核对高亮行、调整后预览、底部统计、提交按钮启用态。
// 注意:必须读原始数据源(userTimes)而非 DOM——view-ui-plus Table 传���cell 的
// row 是深拷贝副本,DOM 上的值仅用于视觉,不反映真实状态。
setTimeout(() => {
const rows = document.querySelectorAll("tbody tr");
const btnsOf = (r: Element) => Array.from(r.querySelectorAll(".q-btn"));
const click = (i: number, b: number) => {
const r = rows[i];
const btn = r && btnsOf(r)[b];
btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
};
click(0, 1); // 王雅静 +4
click(1, 0); // 李国栋 +1
click(2, 2); // 张明远 -1
setTimeout(() => {
const q = (s: string) => Array.from(document.querySelectorAll(s)).map((e) => e.textContent).join(",");
const foot = document.querySelector(".usertime-footer");
document.title = [
"editedRows=" + document.querySelectorAll("tr.row-edited").length,
"pf=[" + q(".pf-num") + "]",
"delta=[" + q(".pf-delta") + "]",
"resetLinks=" + document.querySelectorAll(".act-link.reset").length,
"submitDisabled=" + foot?.querySelector(".ivu-btn-primary")?.hasAttribute("disabled"),
"footer=" + (foot?.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80),
].join(" | ");
}, 1000);
}, 1600);
}
+210
View File
@@ -0,0 +1,210 @@
// 预览专用:把页面会调用的 /api/* 接口就地 mock 掉。
//
// 为什么需要它:业务 dev 端口(8081/8078/8079)的 /api 会经 vite 代理打到真实后端,
// 未登录时后端返回 {code:302,urlToRedirectTo},而 alova 的 onSuccess 对该 code 会执行
// `window.location.href = urlToRedirectTo`(见 src/utils/alova/alova-core.ts),整页被
// 跳去 SSO —— 预览页什么都看不到。
//
// 因此在预览入口里用 fetch 拦截替换掉网络层,使该 harness 在任意端口(含 8081)
// 都能独立渲染,不再依赖 mock-server 或真实后端。
const LEAVE_TYPES = [
{ k: "sj", v: "事假" },
{ k: "bj", v: "病假" },
{ k: "tx", v: "调休" },
{ k: "hq", v: "婚假" },
{ k: "cq", v: "产假" },
{ k: "sq", v: "丧假" },
];
const TYPE_NAMES: Record<string, string> = Object.fromEntries(LEAVE_TYPES.map((t) => [t.k, t.v]));
const STATUS_NAMES = ["审批中", "已通过", "已拒绝", "已销假", "已撤回"];
const SEED = [
{ type: "sj", start: "2026-10-22 08:00:00", end: "2026-10-22 12:00:00", hours: 4, reason: "子女家长会", status: 0 },
{ type: "sj", start: "2026-10-21 08:00:00", end: "2026-10-21 12:00:00", hours: 4, reason: "家中有事需处理", status: 4 },
{ type: "tx", start: "2026-10-20 08:00:00", end: "2026-10-20 12:00:00", hours: 4, reason: "上月加班调休", status: 0 },
{ type: "cq", start: "2026-10-09 08:00:00", end: "2026-10-18 17:00:00", hours: 80, reason: "产假", status: 1 },
{ type: "bj", start: "2026-09-15 13:00:00", end: "2026-09-15 17:00:00", hours: 4, reason: "感冒发热,需居家休息", status: 1 },
{ type: "hq", start: "2026-08-03 08:00:00", end: "2026-08-07 17:00:00", hours: 40, reason: "婚假(含往返)", status: 2 },
{ type: "sq", start: "2026-06-11 08:00:00", end: "2026-06-13 17:00:00", hours: 24, reason: "家中长辈丧事", status: 3 },
{ type: "sj", start: "2026-05-06 13:00:00", end: "2026-05-06 17:00:00", hours: 4, reason: "子女家长会", status: 1 },
];
const NAMES = ["王雅静", "李国栋", "张明远", "陈思彤", "周建华", "孙丽娟", "吴强", "郑小满"];
/** 复刻 LeaveVo */
function leaveRows(scope: string) {
return SEED.map((s, i) => ({
id: i + 1,
userId: 1,
userName: scope === "user" ? "王雅静" : NAMES[i % NAMES.length],
leaveType: s.type,
leaveTypeName: TYPE_NAMES[s.type],
startTime: s.start,
endTime: s.end,
totalHours: s.hours,
reason: s.reason,
status: s.status,
statusName: STATUS_NAMES[s.status],
applyTime: `2026-10-0${(i % 9) + 1} 0${(i % 9) + 1}:3${i % 10}:1${i}`,
ccTime: scope === "cc" ? `2026-10-0${(i % 9) + 1} 10:0${i}:00` : undefined,
canEdit: s.status === 0 || s.status === 4,
canWithdraw: s.status === 0,
canCancel: s.status === 1,
canDelete: s.status !== 1,
}));
}
/** 复刻 TodoVo */
function todoRows() {
return SEED.slice(0, 5).map((s, i) => ({
taskId: 900 + i,
instanceId: 500 + i,
leaveId: i + 1,
userName: NAMES[i % NAMES.length],
leaveType: s.type,
leaveTypeName: TYPE_NAMES[s.type],
startTime: s.start,
endTime: s.end,
totalHours: s.hours,
reason: s.reason,
status: 0,
statusName: "审批中",
applyTime: `2026-10-0${i + 1} 09:0${i}:00`,
}));
}
/** 复刻 UserTimeRecordVo */
function recordRows() {
return SEED.map((s, i) => ({
id: i + 1,
userId: 1,
userName: NAMES[i % NAMES.length],
leaveId: i + 1,
leaveType: s.type,
leaveTypeName: TYPE_NAMES[s.type],
editHours: [-8, 8, -4, 4, 2, -2, 1, -1][i],
oldHours: 16 + i,
newHours: 16 + i + [-8, 8, -4, 4, 2, -2, 1, -1][i],
describeText: i % 2 ? "请假扣减" : "管理员调增",
operatorName: "系统",
occurTime: s.end,
}));
}
/** 复刻 LeaveStatsVo */
function stats() {
const types = LEAVE_TYPES.map((t, i) => ({
type: t.k,
typeName: t.v,
count: 3 + i,
hours: 12 + i * 6,
}));
const months = Array.from({ length: 6 }, (_, i) => ({
month: `${i + 5}月`,
count: 2 + (i % 4),
hours: 8 + i * 5,
}));
return {
totalCount: 18,
totalHours: 176.5,
totalDays: 22,
topTypeName: "事假",
typeStats: types,
monthStats: months,
};
}
/** 复刻 LeaveDetailVo(打印预览用;含流程 steps 与抄送人) */
function detailRow(id: number) {
const s = SEED[(id - 1 + SEED.length) % SEED.length];
return {
id,
userId: 1,
userName: "王雅静",
leaveType: s.type,
leaveTypeName: TYPE_NAMES[s.type],
startTime: s.start,
endTime: s.end,
totalHours: s.hours,
reason: s.reason,
courseArrange: 2,
courseArrangeName: "教务安排",
status: s.status,
statusName: STATUS_NAMES[s.status],
applyTime: "2026-10-09 19:28:00",
files: [],
ccUsers: [
{ userId: 2, userName: "李国栋" },
{ userId: 3, userName: "张明远" },
],
progress: {
instanceId: 500,
flowName: "教职工请假流程",
currentNodeName: "校长审批",
submitterName: "王雅静",
startTime: "2026-10-09 19:28:00",
steps: [
{ nodeName: "发起申请", assigneeName: "王雅静", action: "submit", opinion: "", time: "2026-10-09 19:28:00", finished: true },
{ nodeName: "部门审核", assigneeName: "李国栋", action: "approve", opinion: "情况属实,同意", time: "2026-10-09 20:15:00", finished: true },
{ nodeName: "校长审批", assigneeName: "周建华", action: "approve", opinion: "同意", time: "2026-10-09 21:02:00", finished: true },
],
},
canCancel: s.status === 1,
};
}
function page<T>(rows: T[], url: string) {
const q = new URL(url, "http://x").searchParams;
const pageIndex = Number(q.get("pageIndex") || 1);
const pageSize = Number(q.get("pageSize") || 10);
return { content: rows.slice((pageIndex - 1) * pageSize, pageIndex * pageSize), total: rows.length };
}
/** 按 URL 解析出 mock 数据;返回 undefined 表示「无数据」 */
export function resolveMock(rawUrl: string): any {
const url = rawUrl;
const q = new URL(url, "http://x").searchParams;
if (/leave\/todoList/i.test(url)) return page(todoRows(), url);
if (/leave\/auditedList/i.test(url)) return page(leaveRows("all").filter((r) => r.status !== 0), url);
if (/leave\/list/i.test(url)) {
const scope = q.get("scope") || "user";
let rows = leaveRows(scope);
const status = q.get("status");
const kw = (q.get("keyword") || "").trim();
const type = q.get("leaveType");
if (status !== null && status !== "") rows = rows.filter((r) => String(r.status) === String(status));
if (type) rows = rows.filter((r) => r.leaveType === type);
if (kw) rows = rows.filter((r) => (r.reason || "").includes(kw) || r.leaveTypeName.includes(kw) || (r.userName || "").includes(kw));
return page(rows, url);
}
if (/leave\/time\/records/i.test(url)) return page(recordRows(), url);
if (/leave\/detail/i.test(url)) return detailRow(Number(q.get("leaveId") || 4));
if (/leave\/setting\/getoption/i.test(url)) return { reasonMust: 1, showExplain: 1, enableCourse: 1, allowExchange: 0, timeLimitEnabled: 0, timeManagedTypes: "sj,bj", calcWay: 1 };
if (/leave\/time\/sum/i.test(url)) return { addHours: 24, remainHours: 16.5 };
if (/leave\/statis/i.test(url)) return stats();
if (/dict\/list/i.test(url)) return LEAVE_TYPES;
if (/base\/getuserinfo/i.test(url)) return { id: 1, name: "王雅静", profilePicture: "" };
return undefined;
}
/** 安装 fetch 拦截:仅接管 /api/* ,其余原样放行 */
export function installApiStub() {
const w = window as any;
if (w.__apiStubInstalled) return;
const original = w.fetch.bind(window);
w.__apiStubHit = 0;
w.fetch = async (input: any, init?: any) => {
const url: string = typeof input === "string" ? input : input?.url || "";
if (!/\/api\//.test(url)) return original(input, init);
w.__apiStubHit++;
const data = resolveMock(url);
return new Response(JSON.stringify({ code: 0, msg: "ok", data: data === undefined ? null : data }), {
status: 200,
headers: { "Content-Type": "application/json;charset=utf-8" },
});
};
w.__apiStubInstalled = true;
}
+226
View File
@@ -0,0 +1,226 @@
// 本地 mock 服务:仅为预览「教师时长」页视觉,供 preview-ut 专用 vite 代理使用
// 用法:node preview-ut/mock-server.mjs8109
import http from "node:http";
const USERS = [
{ userId: 1, userName: "王雅静", usableHours: 24, remark: "语文组教研组长" },
{ userId: 2, userName: "李国栋", usableHours: 16, remark: null },
{ userId: 3, userName: "张明远", usableHours: 6, remark: null },
{ userId: 4, userName: "陈思彤", usableHours: 0, remark: null },
{ userId: 5, userName: "周建华", usableHours: 40, remark: null },
{ userId: 6, userName: "孙丽娟", usableHours: 12.5, remark: null },
{ userId: 7, userName: "吴强", usableHours: 3, remark: null },
{ userId: 8, userName: "郑小满", usableHours: 32, remark: null },
];
const TYPES = [
{ SerID: "1", CodeID: "sj", Name: "事假", Enable: 1 },
{ SerID: "2", CodeID: "bj", Name: "病假", Enable: 1 },
{ SerID: "3", CodeID: "tx", Name: "调休", Enable: 1 },
{ SerID: "4", CodeID: "hq", Name: "婚假", Enable: 1 },
{ SerID: "5", CodeID: "cq", Name: "产假", Enable: 1 },
{ SerID: "6", CodeID: "sq", Name: "丧假", Enable: 1 },
];
// 复刻后端 UserCenterService.getSchoolAllTree 的扁平结构:
// 组 type=1/isParent=true,教师 type=2,用 pid 指向父组
const TREE = [
{ id: 100, name: "云海中学", pid: 0, type: 1, isParent: true },
{ id: 200, name: "语文组", pid: 100, type: 1, isParent: true },
{ id: 201, name: "数学组", pid: 100, type: 1, isParent: true },
{ id: 202, name: "英语组", pid: 100, type: 1, isParent: true },
{ id: 203, name: "综合组", pid: 100, type: 1, isParent: true },
{ id: 1, name: "王雅静", pid: 200, type: 2 },
{ id: 2, name: "李国栋", pid: 200, type: 2 },
{ id: 5, name: "周建华", pid: 200, type: 2 },
{ id: 3, name: "张明远", pid: 201, type: 2 },
{ id: 4, name: "陈思彤", pid: 201, type: 2 },
{ id: 6, name: "孙丽娟", pid: 202, type: 2 },
{ id: 7, name: "吴强", pid: 202, type: 2 },
{ id: 8, name: "郑小满", pid: 203, type: 2 },
];
const OPTION = { reasonMust: 1, showExplain: 1, timeLimitEnabled: 1, timeManagedTypes: "sj,bj,tx", enableCourse: 0, allowExchange: 0, calcWay: 1 };
const TIMESET = { forenoon1: "08:00", forenoon2: "12:00", afternoon1: "13:00", afternoon2: "17:00", intervalMinutes: 10, minMinutes: 0 };
// 平台 GetUserMod 的真实返回(2026-10 环境):扁平一级菜单,id = modId = 97–105,
// page 是历史路径。用于整壳验收侧栏「点击 → 跳转 → 高亮」链路(preview-app.html)。
const USER_MOD = [
{ id: 97, modId: 97, name: "我要请假", page: "/leave/", parentId: 0, level: 1, orderNumber: 100, usable: true, children: [] },
{ id: 98, modId: 98, name: "我的请假", page: "/leave/myleave", parentId: 0, level: 1, orderNumber: 110, usable: true, children: [] },
{ id: 99, modId: 99, name: "审批列表", page: "/leave/shenhe", parentId: 0, level: 1, orderNumber: 120, usable: true, children: [] },
{ id: 100, modId: 100, name: "抄送列表", page: "/leave/leave_send", parentId: 0, level: 1, orderNumber: 130, usable: true, children: [] },
{ id: 101, modId: 101, name: "全部请假", page: "/leave/totallist", parentId: 0, level: 1, orderNumber: 140, usable: true, children: [] },
{ id: 102, modId: 102, name: "流程管理", page: "/leave/liucheng", parentId: 0, level: 1, orderNumber: 160, usable: true, children: [] },
{ id: 103, modId: 103, name: "请假设置", page: "/leave/leave_timeSetting", parentId: 0, level: 1, orderNumber: 170, usable: true, children: [] },
{ id: 104, modId: 104, name: "时长日志", page: "/leave/leave_amendant", parentId: 0, level: 1, orderNumber: 180, usable: true, children: [] },
{ id: 105, modId: 105, name: "统计分析", page: "/leave/tongji", parentId: 0, level: 1, orderNumber: 190, usable: true, children: [] },
];
const USER_INFO = { id: 1, name: "王雅静", profilePicture: "" };
// ===== 流程管理(/api/leave/flow/*):内存态,可交互(开关/绑定即时生效)=====
// 复刻流程管理页 FlowManageVo;通知流程默认「未启用」,演示 oapi 混入非本应用流程的场景
const CENTER_FLOWS = [
{ flowGuid: "35dc167902ef436e8767809eb6bf11ee", name: "设课(5天)", version: 6, nodeSummary: "审核中", enabled: true },
{ flowGuid: "28bc3ca8acc24611809d7dd1b6b9a597", name: "产假(10天)", version: 5, nodeSummary: "审核中", enabled: true },
{ flowGuid: "d2237a5b2b95492e845da9caeaa35590", name: "请假申请(小于3天或大于等于3天)", version: 4, nodeSummary: "审批节点—222", enabled: true },
{ flowGuid: "fdb2c9046ede4db583b0a4899d065f75", name: "通知流程", version: 2, nodeSummary: "审批节点", enabled: false },
];
let BIND_SEQ = 3;
const FLOW_BINDS = [
{ id: 1, scope: 0, scopeKey: "-", scopeName: "全校默认", flowGuid: "d2237a5b2b95492e845da9caeaa35590", flowName: "请假申请(小于3天或大于等于3天)", flowVersion: 4, nodeSummary: "审批节点—222", existsInCenter: true },
{ id: 2, scope: 1, scopeKey: "cq", scopeName: "产假", flowGuid: "28bc3ca8acc24611809d7dd1b6b9a597", flowName: "产假(10天)", flowVersion: 5, nodeSummary: "审核中", existsInCenter: true },
];
function flowManageData() {
const flows = CENTER_FLOWS.map((f) => {
const typeKeys = FLOW_BINDS.filter((b) => b.scope === 1 && b.flowGuid === f.flowGuid).map((b) => b.scopeKey);
return {
...f,
defaultBind: FLOW_BINDS.some((b) => b.scope === 0 && b.flowGuid === f.flowGuid),
typeKeys,
typeNames: typeKeys.map((k) => TYPES.find((t) => t.CodeID === k)?.Name || k),
};
});
return {
bizType: "leave",
configFlowGuid: "d2237a5b2b95492e845da9caeaa35590",
centerReachable: true,
centerError: null,
flows,
binds: FLOW_BINDS.map((b) => ({ ...b })),
leaveTypes: TYPES.map((t) => ({ key: t.CodeID, name: t.Name })),
};
}
/** 写操作:返回 true 表示已识别并处理(POST /api/leave/flow/*) */
function flowMutate(url, body) {
const guid = body?.flowGuid;
if (/flow\/Enable/i.test(url)) {
const f = CENTER_FLOWS.find((x) => x.flowGuid === guid);
if (f) f.enabled = true;
return true;
}
if (/flow\/Disable/i.test(url)) {
const f = CENTER_FLOWS.find((x) => x.flowGuid === guid);
if (f) f.enabled = false;
for (let i = FLOW_BINDS.length - 1; i >= 0; i--) if (FLOW_BINDS[i].flowGuid === guid) FLOW_BINDS.splice(i, 1);
return true;
}
if (/flow\/Bind/i.test(url)) {
const scope = Number(body?.scope ?? 0);
const scopeKey = scope === 1 ? String(body?.scopeKey || "") : "-";
const f = CENTER_FLOWS.find((x) => x.flowGuid === guid);
if (!f) return true;
const hit = FLOW_BINDS.find((b) => b.scope === scope && b.scopeKey === scopeKey);
const row = {
id: hit ? hit.id : BIND_SEQ++,
scope,
scopeKey,
scopeName: scope === 0 ? "全校默认" : TYPES.find((t) => t.CodeID === scopeKey)?.Name || scopeKey,
flowGuid: f.flowGuid,
flowName: f.name,
flowVersion: f.version,
nodeSummary: f.nodeSummary,
existsInCenter: true,
};
if (hit) Object.assign(hit, row);
else FLOW_BINDS.push(row);
return true;
}
if (/flow\/Unbind/i.test(url)) {
const scope = Number(body?.scope ?? 0);
const scopeKey = scope === 1 ? String(body?.scopeKey || "") : "-";
for (let i = FLOW_BINDS.length - 1; i >= 0; i--) {
if (FLOW_BINDS[i].scope === scope && FLOW_BINDS[i].scopeKey === scopeKey) FLOW_BINDS.splice(i, 1);
}
return true;
}
return false;
}
// ===== 请假列表(/api/leave/List):我的请假 / 全部请假 / 抄送列表 / 审批列表 共用一个接口 =====
// 复刻 LeaveVo;状态 0审批中 1已通过 2已拒绝 3已销假 4已撤回
const LEAVE_ROWS = [
{ id: 1, leaveType: "sj", leaveTypeName: "事假", startTime: "2026-10-22 08:00:00", endTime: "2026-10-22 12:00:00", totalHours: 4, reason: "E2E扣减链路验证-事假", status: 0, canEdit: true, canWithdraw: true, canDelete: true },
{ id: 2, leaveType: "sj", leaveTypeName: "事假", startTime: "2026-10-21 08:00:00", endTime: "2026-10-21 12:00:00", totalHours: 4, reason: "E2E扣减链路验证-事假", status: 4, canEdit: true, canDelete: true },
{ id: 3, leaveType: "tx", leaveTypeName: "调休", startTime: "2026-10-20 08:00:00", endTime: "2026-10-20 12:00:00", totalHours: 4, reason: "E2E回调链路验证-调休", status: 0, canEdit: true, canWithdraw: true, canDelete: true },
{ id: 4, leaveType: "cq", leaveTypeName: "产假", startTime: "2026-10-09 08:00:00", endTime: "2026-10-18 17:00:00", totalHours: 80, reason: "1111", status: 1, canCancel: true },
{ id: 5, leaveType: "bj", leaveTypeName: "病假", startTime: "2026-09-15 13:00:00", endTime: "2026-09-15 17:00:00", totalHours: 4, reason: "感冒发热,需居家休息", status: 1, canCancel: true },
{ id: 6, leaveType: "hq", leaveTypeName: "婚假", startTime: "2026-08-03 08:00:00", endTime: "2026-08-07 17:00:00", totalHours: 40, reason: "婚假(含往返)", status: 2 },
{ id: 7, leaveType: "sq", leaveTypeName: "丧假", startTime: "2026-06-11 08:00:00", endTime: "2026-06-13 17:00:00", totalHours: 24, reason: "家中长辈丧事", status: 3 },
{ id: 8, leaveType: "sj", leaveTypeName: "事假", startTime: "2026-05-06 13:00:00", endTime: "2026-05-06 17:00:00", totalHours: 4, reason: "子女家长会", status: 1, canCancel: true },
];
function leaveList(url) {
const q = new URL(url, "http://x").searchParams;
const scope = q.get("scope") || "user";
const status = q.get("status");
const kw = (q.get("keyword") || "").trim();
const type = q.get("leaveType");
const pageIndex = Number(q.get("pageIndex") || 1);
const pageSize = Number(q.get("pageSize") || 10);
let rows = LEAVE_ROWS.map((r, i) => ({
...r,
userId: 1,
userName: scope === "user" ? "王雅静" : USERS[i % USERS.length].userName,
statusName: ["审批中", "已通过", "已拒绝", "已销假", "已撤回"][r.status],
applyTime: `2026-10-0${(i % 9) + 1} 0${(i % 9) + 1}:3${i % 10}:${10 + i}`,
}));
if (scope === "cc") rows = rows.map((r) => ({ ...r, ccTime: r.applyTime }));
if (status !== null && status !== undefined && status !== "") rows = rows.filter((r) => String(r.status) === String(status));
if (type) rows = rows.filter((r) => r.leaveType === type);
if (kw) rows = rows.filter((r) => (r.reason || "").includes(kw) || r.leaveTypeName.includes(kw) || r.userName.includes(kw));
const total = rows.length;
const content = rows.slice((pageIndex - 1) * pageSize, pageIndex * pageSize);
return { content, total, pageIndex, pageSize };
}
function resolve(url) {
// 应用外壳:用户信息 / 授权模块(整壳验收用)
if (/Base\/GetUserMod|base\/GetUserMod/i.test(url)) return USER_MOD;
if (/leave\/List/i.test(url)) return leaveList(url);
if (/Base\/GetUserInfo|base\/GetUserInfo/i.test(url)) return USER_INFO;
if (/GetSchoolAll/.test(url)) return TREE;
if (/UserTimeList/.test(url)) {
const kw = new URL(url, "http://x").searchParams.get("keyword");
return kw ? USERS.filter((u) => u.userName.includes(kw)) : USERS;
}
// 下拉接口返回 { k, v }(与后端 DictService.List 一致);管理列表返回完整字段
if (/Dict\/List/i.test(url)) return TYPES.map((t) => ({ k: t.CodeID, v: t.Name }));
if (/\/dict|Dict/.test(url)) return TYPES;
if (/Option/.test(url)) return OPTION;
if (/TimeSet/.test(url)) return TIMESET;
if (/Section/.test(url)) return [];
if (/leave\/flow\/List/i.test(url)) return flowManageData();
return null;
}
http
.createServer((req, res) => {
console.log("[mock] ->", req.method, req.url);
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
let body = null;
const raw = Buffer.concat(chunks).toString("utf8");
if (raw) {
try {
body = JSON.parse(raw);
} catch {
body = Object.fromEntries(new URLSearchParams(raw));
}
}
const url = req.url || "";
let data;
if (req.method === "POST" && /leave\/flow\/(Enable|Disable|Bind|Unbind)/i.test(url)) {
flowMutate(url, body);
data = undefined; // 写接口返回 data:null
} else {
data = resolve(url);
}
res.setHeader("Content-Type", "application/json;charset=utf-8");
res.setHeader("Access-Control-Allow-Origin", "*");
res.end(JSON.stringify({ code: 0, msg: "ok", data: data === undefined ? null : data }));
});
})
.listen(8109, () => console.log("mock server on http://localhost:8109"));