Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>`;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"));
|
||||
Reference in New Issue
Block a user