Files
work-leave-front/preview-ut/main-list.ts
T
wanghu 2e36d7a9c8
build / build (push) Successful in 9m55s
commit
2026-10-10 15:58:02 +08:00

318 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 独立预览入口:一次性纵向渲染 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);
}