// 独立预览入口:一次性纵向渲染 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(); 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(".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(".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(".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(".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(".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(".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(".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(".search-wrap_dates .ivu-input"); if (dateInput2) { dateInput2.click(); setTimeout(() => { // 面板根类名是 ivu-picker-panel-body-wrapper;transfer 会把所有页面板挂到 body,需按可见性过滤 const all = Array.from(document.querySelectorAll(".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(".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(".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(".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(".search-wrap_reset.ivu-btn"); const kwInput = document.querySelector(".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(".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(".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( ".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("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(".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); }