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
+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);
}