修复 R124 企业微信移动入口与部署迁移门禁
这个提交包含在:
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const routerPath = path.join(rootDir, "client/src/router/index.tsx");
|
||||
const pagesDir = path.join(rootDir, "client/src/pages");
|
||||
const findings = [];
|
||||
|
||||
function toPosix(value) {
|
||||
return value.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function addFinding({ rule, file, line = 1, message, evidence }) {
|
||||
findings.push({
|
||||
rule,
|
||||
file: toPosix(path.relative(rootDir, file)),
|
||||
line,
|
||||
message,
|
||||
evidence,
|
||||
});
|
||||
}
|
||||
|
||||
function lineNumber(source, index) {
|
||||
return source.slice(0, index).split("\n").length;
|
||||
}
|
||||
|
||||
function collectFiles(dir) {
|
||||
if (!existsSync(dir)) return [];
|
||||
const files = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...collectFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
if (/Mobile.*\.tsx$/.test(entry)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractRouteBlock(source, routePath) {
|
||||
const marker = `path="${routePath}"`;
|
||||
const start = source.indexOf(marker);
|
||||
if (start < 0) return null;
|
||||
const nextRoute = source.indexOf("\n <Route", start + marker.length);
|
||||
const end = nextRoute >= 0 ? nextRoute : source.indexOf("\n </Route>", start);
|
||||
return {
|
||||
start,
|
||||
block: source.slice(start, end >= 0 ? end : start + 1200),
|
||||
};
|
||||
}
|
||||
|
||||
function scanRouter() {
|
||||
if (!existsSync(routerPath)) {
|
||||
addFinding({
|
||||
rule: "mobile.router.exists",
|
||||
file: routerPath,
|
||||
message: "找不到 client/src/router/index.tsx",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const source = readFileSync(routerPath, "utf8");
|
||||
const mobileRoutePaths = Array.from(source.matchAll(/path="(mobile[^"]*)"/g)).map((match) => match[1]);
|
||||
const routeComponents = [];
|
||||
|
||||
for (const routePath of mobileRoutePaths) {
|
||||
const route = extractRouteBlock(source, routePath);
|
||||
if (!route) continue;
|
||||
const components = Array.from(route.block.matchAll(/<([A-Z][A-Za-z0-9]+Page)\s*\/>/g)).map((match) => match[1]);
|
||||
routeComponents.push({ routePath, components });
|
||||
|
||||
for (const component of components) {
|
||||
if (!component.startsWith("Mobile")) {
|
||||
addFinding({
|
||||
rule: "mobile.router.no_pc_page",
|
||||
file: routerPath,
|
||||
line: lineNumber(source, route.start),
|
||||
message: `/mobile 路由不能直挂 PC 页面组件:${routePath} -> ${component}`,
|
||||
evidence: component,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requiredRoutes = new Map([
|
||||
["mobile/profile", "MobileProfilePage"],
|
||||
["mobile/notifications", "MobileNotificationsPage"],
|
||||
["mobile/capture", "MobileCapturePage"],
|
||||
["mobile/reimbursements", "MobileReimbursementsPage"],
|
||||
["mobile/me", "MobileMePage"],
|
||||
["mobile/approvals", "MobileApprovalsPage"],
|
||||
]);
|
||||
|
||||
for (const [routePath, expectedComponent] of requiredRoutes) {
|
||||
const route = routeComponents.find((item) => item.routePath === routePath);
|
||||
if (!route) {
|
||||
addFinding({
|
||||
rule: "mobile.router.required_route",
|
||||
file: routerPath,
|
||||
message: `缺少企业微信移动入口路由:${routePath}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!route.components.includes(expectedComponent)) {
|
||||
addFinding({
|
||||
rule: "mobile.router.required_component",
|
||||
file: routerPath,
|
||||
line: lineNumber(source, source.indexOf(`path="${routePath}"`)),
|
||||
message: `${routePath} 应挂载 ${expectedComponent}`,
|
||||
evidence: route.components.join(", ") || "(none)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[mobile-route-contract] routes");
|
||||
for (const item of routeComponents) {
|
||||
console.log(` ${item.routePath} -> ${item.components.join(", ") || "(no page component found)"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scanMobilePages() {
|
||||
for (const file of collectFiles(pagesDir)) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const patterns = [
|
||||
{
|
||||
regex: /\bfrom ["']\.\.\/components\/ui\/table["']/g,
|
||||
rule: "mobile.page.no_table_component",
|
||||
message: "移动页面不应导入桌面表格组件。",
|
||||
},
|
||||
{
|
||||
regex: /<\s*(?:Table|table)\b/g,
|
||||
rule: "mobile.page.no_visible_table_markup",
|
||||
message: "移动页面不应渲染桌面表格。",
|
||||
},
|
||||
{
|
||||
regex: /page-shell-grid/g,
|
||||
rule: "mobile.page.no_pc_shell_grid",
|
||||
message: "移动页面不应使用 PC 页面双栏 shell 布局。",
|
||||
},
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
let match = pattern.regex.exec(source);
|
||||
while (match) {
|
||||
addFinding({
|
||||
rule: pattern.rule,
|
||||
file,
|
||||
line: lineNumber(source, match.index),
|
||||
message: pattern.message,
|
||||
evidence: match[0],
|
||||
});
|
||||
match = pattern.regex.exec(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanRouter();
|
||||
scanMobilePages();
|
||||
|
||||
if (findings.length) {
|
||||
console.log(`[mobile-route-contract] ${findings.length} finding(s)`);
|
||||
for (const finding of findings) {
|
||||
console.log(`ERROR ${finding.rule} ${finding.file}:${finding.line} ${finding.message}`);
|
||||
if (finding.evidence) {
|
||||
console.log(` evidence: ${finding.evidence}`);
|
||||
}
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("[mobile-route-contract] no findings");
|
||||
}
|
||||
@@ -16,6 +16,9 @@ const scanRoots = [
|
||||
const sourceExtensions = new Set([".js", ".jsx", ".ts", ".tsx"]);
|
||||
const forbiddenCopy = ["先处理", "清爽"];
|
||||
const userFacingBusinessTermFiles = new Set([
|
||||
"client/src/pages/MobileCapturePage.tsx",
|
||||
"client/src/pages/MobileNotificationsPage.tsx",
|
||||
"client/src/pages/MobileProfilePage.tsx",
|
||||
"client/src/pages/MobileReimbursementDetailPage.tsx",
|
||||
"client/src/pages/ReimbursementDetailPage.tsx",
|
||||
"client/src/components/travel/TravelPaymentTrackCard.tsx",
|
||||
@@ -127,6 +130,30 @@ function scanNativeFileInputs(file, source) {
|
||||
}
|
||||
}
|
||||
|
||||
function scanMobileCapturePdfInputs(file, source) {
|
||||
const normalized = toPosix(path.relative(rootDir, file));
|
||||
if (normalized !== "client/src/pages/MobileCapturePage.tsx") {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInputPattern = /<input\b[^>]*\btype=["']file["'][^>]*>/g;
|
||||
let match = fileInputPattern.exec(source);
|
||||
while (match) {
|
||||
const inputSource = match[0];
|
||||
if (/application\/pdf/i.test(inputSource) && /\bcapture\b/i.test(inputSource)) {
|
||||
addFinding({
|
||||
rule: "form.no_pdf_capture_input",
|
||||
severity: "error",
|
||||
file,
|
||||
line: lineNumber(source, match.index),
|
||||
message: "PDF 上传入口不能带 capture,否则企业微信容易直接打开摄像头。",
|
||||
evidence: inputSource.replace(/\s+/g, " ").slice(0, 160),
|
||||
});
|
||||
}
|
||||
match = fileInputPattern.exec(source);
|
||||
}
|
||||
}
|
||||
|
||||
function scanUserFacingBusinessTerms(file, source) {
|
||||
const normalized = toPosix(path.relative(rootDir, file));
|
||||
if (!userFacingBusinessTermFiles.has(normalized)) {
|
||||
@@ -226,6 +253,7 @@ function scanFile(file) {
|
||||
scanUserFacingBusinessTerms(file, source);
|
||||
scanMobileDarkControls(file, source);
|
||||
scanNativeFileInputs(file, source);
|
||||
scanMobileCapturePdfInputs(file, source);
|
||||
scanRawBusinessIdentifiers(file, source);
|
||||
scanNavigationTitleCoverage(file, source);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户