chore: initialize qiming workspace repository
This commit is contained in:
283
qiming-mobile/scripts/i18n-audit.mjs
Normal file
283
qiming-mobile/scripts/i18n-audit.mjs
Normal file
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const includeDirs = ["pages", "subpackages", "components", "utils", "servers"];
|
||||
const includeFiles = ["App.uvue"];
|
||||
const includeExt = new Set([".uvue", ".vue", ".uts"]);
|
||||
const excludeFiles = new Set([
|
||||
"components/pane-tabs/example.uvue",
|
||||
"utils/mockApiService.uts",
|
||||
"utils/pinyin.uts",
|
||||
]);
|
||||
const zhLocalePath = "constants/i18n-locales/zh-CN.uts";
|
||||
const enLocalePath = "constants/i18n-locales/en-US.uts";
|
||||
|
||||
const hasChinese = /[\u4e00-\u9fff]/;
|
||||
const visibleLine = new RegExp(
|
||||
[
|
||||
"showToast",
|
||||
"showModal",
|
||||
"showLoading",
|
||||
"showError\\(",
|
||||
"setNavigationBarTitle",
|
||||
"throw\\s+new\\s+Error",
|
||||
"reject\\s*\\(\\s*new\\s+Error",
|
||||
"confirmText",
|
||||
"cancelText",
|
||||
"placeholder=",
|
||||
"title=",
|
||||
"alt=",
|
||||
"label\\s*:",
|
||||
"content\\s*:",
|
||||
"description\\s*:",
|
||||
"steps\\s*:",
|
||||
"error\\s*:\\s*['\"`]",
|
||||
"message\\s*:\\s*['\"`]",
|
||||
"<text",
|
||||
"<button",
|
||||
"<input",
|
||||
"<textarea",
|
||||
].join("|"),
|
||||
);
|
||||
const hardcodedVisibleLiteral = new RegExp(
|
||||
[
|
||||
"title\\s*:\\s*['\"](?!Mobile\\.)[^'\"\\n]+['\"]",
|
||||
"content\\s*:\\s*['\"](?!Mobile\\.)[^'\"\\n]+['\"]",
|
||||
"text\\s*:\\s*['\"](?!Mobile\\.)[^'\"\\n]+['\"]",
|
||||
"showError\\s*\\(\\s*['\"][^'\"\\n]+['\"]\\s*\\)",
|
||||
"showError\\s*\\(\\s*`[^`\\n]+`\\s*\\)",
|
||||
"new\\s+Error\\s*\\(\\s*['\"][^'\"\\n]+['\"]\\s*\\)",
|
||||
"new\\s+Error\\s*\\(\\s*`[^`\\n]+`\\s*\\)",
|
||||
"confirmText\\s*:\\s*['\"](?!Mobile\\.)[^'\"\\n]+['\"]",
|
||||
"cancelText\\s*:\\s*['\"](?!Mobile\\.)[^'\"\\n]+['\"]",
|
||||
"(^|\\s)placeholder\\s*=\\s*['\"][^'\"\\n]+['\"]",
|
||||
"(^|\\s)alt\\s*=\\s*['\"][^'\"\\n]+['\"]",
|
||||
"(^|\\s):placeholder\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
|
||||
"(^|\\s):alt\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
|
||||
"(^|\\s):title\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
|
||||
"(^|\\s):content\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
|
||||
"(^|\\s):confirmText\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
|
||||
"(^|\\s):cancelText\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
|
||||
].join("|"),
|
||||
);
|
||||
|
||||
const stripComments = (content) => {
|
||||
return content
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
};
|
||||
|
||||
const walkFiles = (dir) => {
|
||||
const absDir = path.join(rootDir, dir);
|
||||
if (!fs.existsSync(absDir)) return [];
|
||||
|
||||
const results = [];
|
||||
const stack = [absDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
entries.forEach((entry) => {
|
||||
const absPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(absPath);
|
||||
return;
|
||||
}
|
||||
const relPath = path.relative(rootDir, absPath).replace(/\\/g, "/");
|
||||
if (excludeFiles.has(relPath)) return;
|
||||
if (!includeExt.has(path.extname(relPath))) return;
|
||||
results.push(relPath);
|
||||
});
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const collectLocaleKeyLines = (relPath) => {
|
||||
const absPath = path.join(rootDir, relPath);
|
||||
if (!fs.existsSync(absPath)) return new Map();
|
||||
const content = fs.readFileSync(absPath, "utf8");
|
||||
const keyLineMap = new Map();
|
||||
const keyRegex = /"((?:Mobile|System)\.[A-Za-z0-9_.]+)"\s*:/g;
|
||||
let match;
|
||||
while ((match = keyRegex.exec(content)) !== null) {
|
||||
const key = match[1];
|
||||
if (!keyLineMap.has(key)) {
|
||||
keyLineMap.set(key, content.slice(0, match.index).split(/\r?\n/).length);
|
||||
}
|
||||
}
|
||||
return keyLineMap;
|
||||
};
|
||||
|
||||
const findings = [];
|
||||
const usedKeyLocations = new Map();
|
||||
const files = Array.from(
|
||||
new Set([
|
||||
...includeDirs.flatMap((dir) => walkFiles(dir)),
|
||||
...includeFiles.filter((file) => {
|
||||
const relPath = file.replace(/\\/g, "/");
|
||||
return (
|
||||
fs.existsSync(path.join(rootDir, relPath)) &&
|
||||
includeExt.has(path.extname(relPath))
|
||||
);
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
files.forEach((relPath) => {
|
||||
const absPath = path.join(rootDir, relPath);
|
||||
const raw = fs.readFileSync(absPath, "utf8");
|
||||
const content = stripComments(raw);
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
const keyRegex = /["'`](Mobile\.[A-Za-z0-9_.]+)["'`]/g;
|
||||
let keyMatch;
|
||||
while ((keyMatch = keyRegex.exec(content)) !== null) {
|
||||
const key = keyMatch[1];
|
||||
if (!usedKeyLocations.has(key)) {
|
||||
usedKeyLocations.set(key, {
|
||||
file: relPath,
|
||||
line: content.slice(0, keyMatch.index).split(/\r?\n/).length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const legacyKeyRegex = /["'`](System\.[A-Za-z0-9_.]+)["'`]/g;
|
||||
let legacyMatch;
|
||||
while ((legacyMatch = legacyKeyRegex.exec(content)) !== null) {
|
||||
findings.push({
|
||||
file: relPath,
|
||||
line: content.slice(0, legacyMatch.index).split(/\r?\n/).length,
|
||||
text: `legacy i18n key prefix is forbidden: ${legacyMatch[1]}`,
|
||||
});
|
||||
}
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const code = line.replace(/\/\/.*$/, "");
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed || trimmed.startsWith("//")) return;
|
||||
|
||||
if (hardcodedVisibleLiteral.test(code)) {
|
||||
findings.push({
|
||||
file: relPath,
|
||||
line: index + 1,
|
||||
text: code.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasChinese.test(code)) return;
|
||||
if (!visibleLine.test(code)) return;
|
||||
|
||||
findings.push({
|
||||
file: relPath,
|
||||
line: index + 1,
|
||||
text: code.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
const imageTagRegex = /<image\b[\s\S]*?>/g;
|
||||
let tagMatch;
|
||||
while ((tagMatch = imageTagRegex.exec(content)) !== null) {
|
||||
const imageTag = tagMatch[0];
|
||||
if (!/(^|\s)(:alt|alt)\s*=/.test(imageTag)) {
|
||||
findings.push({
|
||||
file: relPath,
|
||||
line: content.slice(0, tagMatch.index).split(/\r?\n/).length,
|
||||
text: imageTag.replace(/\s+/g, " ").trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const contentWithoutSelfClosedTextNode = content.replace(
|
||||
/<(text|button)\b(?:[^>"']|"[^"]*"|'[^']*')*\/>/g,
|
||||
(match) => match.replace(/[^\n]/g, " "),
|
||||
);
|
||||
const textNodeRegex =
|
||||
/<(text|button)\b(?:[^>"']|"[^"]*"|'[^']*')*>([\s\S]*?)<\/\1>/g;
|
||||
let textNodeMatch;
|
||||
while ((textNodeMatch = textNodeRegex.exec(contentWithoutSelfClosedTextNode)) !== null) {
|
||||
const rawInnerText = textNodeMatch[2] || "";
|
||||
const cleanedInnerText = rawInnerText
|
||||
.replace(/\{\{[\s\S]*?\}\}/g, "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.trim();
|
||||
if (!cleanedInnerText) continue;
|
||||
findings.push({
|
||||
file: relPath,
|
||||
line: contentWithoutSelfClosedTextNode
|
||||
.slice(0, textNodeMatch.index)
|
||||
.split(/\r?\n/).length,
|
||||
text: `${textNodeMatch[1]} node contains hardcoded text: ${cleanedInnerText}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const zhKeyLineMap = collectLocaleKeyLines(zhLocalePath);
|
||||
const enKeyLineMap = collectLocaleKeyLines(enLocalePath);
|
||||
|
||||
const zhKeys = new Set(
|
||||
[...zhKeyLineMap.keys()].filter((key) => key.startsWith("Mobile.")),
|
||||
);
|
||||
const enKeys = new Set(
|
||||
[...enKeyLineMap.keys()].filter((key) => key.startsWith("Mobile.")),
|
||||
);
|
||||
|
||||
const legacyLocaleKeyList = [
|
||||
...[...zhKeyLineMap.keys()].filter((key) => key.startsWith("System.")),
|
||||
...[...enKeyLineMap.keys()].filter((key) => key.startsWith("System.")),
|
||||
];
|
||||
legacyLocaleKeyList.forEach((key) => {
|
||||
findings.push({
|
||||
file: key.startsWith("System.")
|
||||
? zhKeyLineMap.has(key)
|
||||
? zhLocalePath
|
||||
: enLocalePath
|
||||
: zhLocalePath,
|
||||
line: zhKeyLineMap.get(key) || enKeyLineMap.get(key) || 1,
|
||||
text: `legacy locale key prefix is forbidden: ${key}`,
|
||||
});
|
||||
});
|
||||
|
||||
[...zhKeys]
|
||||
.filter((key) => !enKeys.has(key))
|
||||
.forEach((key) => {
|
||||
findings.push({
|
||||
file: enLocalePath,
|
||||
line: 1,
|
||||
text: `missing key in en-us locale: ${key}`,
|
||||
});
|
||||
});
|
||||
|
||||
[...enKeys]
|
||||
.filter((key) => !zhKeys.has(key))
|
||||
.forEach((key) => {
|
||||
findings.push({
|
||||
file: zhLocalePath,
|
||||
line: 1,
|
||||
text: `missing key in zh-cn locale: ${key}`,
|
||||
});
|
||||
});
|
||||
|
||||
usedKeyLocations.forEach((location, key) => {
|
||||
if (!zhKeys.has(key) || !enKeys.has(key)) {
|
||||
findings.push({
|
||||
file: location.file,
|
||||
line: location.line,
|
||||
text: `missing locale definition for key: ${key}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (findings.length > 0) {
|
||||
console.error(
|
||||
`[i18n audit] failed: found ${findings.length} i18n coverage issue(s).`,
|
||||
);
|
||||
findings.forEach((item) => {
|
||||
console.error(`${item.file}:${item.line}: ${item.text}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("[i18n audit] passed: 0 i18n coverage issues.");
|
||||
129
qiming-mobile/scripts/i18n-export-defaults.mjs
Normal file
129
qiming-mobile/scripts/i18n-export-defaults.mjs
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
|
||||
const localeConfig = {
|
||||
"zh-cn": {
|
||||
file: path.join(rootDir, "constants/i18n-locales/zh-CN.uts"),
|
||||
exportName: "I18N_ZH_CN",
|
||||
},
|
||||
"en-us": {
|
||||
file: path.join(rootDir, "constants/i18n-locales/en-US.uts"),
|
||||
exportName: "I18N_EN_US",
|
||||
},
|
||||
};
|
||||
|
||||
const outputCsv = path.join(rootDir, "docs/i18n-platform-default-import.csv");
|
||||
const outputJson = path.join(rootDir, "docs/i18n-platform-default-import.json");
|
||||
|
||||
const parseLocaleBundle = (filePath, exportName) => {
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const exportRegex = new RegExp(
|
||||
`export\\s+const\\s+${exportName}\\s*:\\s*Record<[^>]+>\\s*=`,
|
||||
"m",
|
||||
);
|
||||
|
||||
if (!exportRegex.test(source)) {
|
||||
throw new Error(`Cannot find export \"${exportName}\" in ${filePath}`);
|
||||
}
|
||||
|
||||
const executable = source.replace(exportRegex, "module.exports =");
|
||||
const sandbox = {
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
};
|
||||
|
||||
vm.runInNewContext(executable, sandbox, {
|
||||
filename: filePath,
|
||||
timeout: 1000,
|
||||
});
|
||||
|
||||
const bundle = sandbox.module.exports;
|
||||
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
|
||||
throw new Error(`Invalid locale bundle in ${filePath}`);
|
||||
}
|
||||
|
||||
return bundle;
|
||||
};
|
||||
|
||||
const csvEscape = (value) => {
|
||||
const normalized = String(value ?? "")
|
||||
.replace(/\r?\n/g, "\\n")
|
||||
.replace(/"/g, '""');
|
||||
return `"${normalized}"`;
|
||||
};
|
||||
|
||||
const main = () => {
|
||||
const zhBundle = parseLocaleBundle(
|
||||
localeConfig["zh-cn"].file,
|
||||
localeConfig["zh-cn"].exportName,
|
||||
);
|
||||
const enBundle = parseLocaleBundle(
|
||||
localeConfig["en-us"].file,
|
||||
localeConfig["en-us"].exportName,
|
||||
);
|
||||
|
||||
const zhKeys = Object.keys(zhBundle).sort();
|
||||
const enKeys = Object.keys(enBundle).sort();
|
||||
|
||||
const missingInEn = zhKeys.filter((key) => !Object.hasOwn(enBundle, key));
|
||||
const missingInZh = enKeys.filter((key) => !Object.hasOwn(zhBundle, key));
|
||||
|
||||
if (missingInEn.length > 0 || missingInZh.length > 0) {
|
||||
const detail = [
|
||||
missingInEn.length > 0
|
||||
? `Missing in en-us: ${missingInEn.slice(0, 10).join(", ")}${missingInEn.length > 10 ? " ..." : ""}`
|
||||
: "",
|
||||
missingInZh.length > 0
|
||||
? `Missing in zh-cn: ${missingInZh.slice(0, 10).join(", ")}${missingInZh.length > 10 ? " ..." : ""}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
throw new Error(`Locale key mismatch. ${detail}`);
|
||||
}
|
||||
|
||||
const rows = zhKeys.map((key) => ({
|
||||
key,
|
||||
"zh-cn": zhBundle[key] || "",
|
||||
"en-us": enBundle[key] || "",
|
||||
}));
|
||||
|
||||
const csvLines = [
|
||||
"key,zh-cn,en-us",
|
||||
...rows.map((row) =>
|
||||
[
|
||||
csvEscape(row.key),
|
||||
csvEscape(row["zh-cn"]),
|
||||
csvEscape(row["en-us"]),
|
||||
].join(","),
|
||||
),
|
||||
];
|
||||
|
||||
const jsonPayload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
totalKeys: rows.length,
|
||||
source: {
|
||||
"zh-cn": path.relative(rootDir, localeConfig["zh-cn"].file),
|
||||
"en-us": path.relative(rootDir, localeConfig["en-us"].file),
|
||||
},
|
||||
rows,
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputCsv, `${csvLines.join("\n")}\n`, "utf8");
|
||||
fs.writeFileSync(
|
||||
outputJson,
|
||||
`${JSON.stringify(jsonPayload, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[i18n export] wrote ${rows.length} keys to ${path.relative(rootDir, outputCsv)} and ${path.relative(rootDir, outputJson)}`,
|
||||
);
|
||||
};
|
||||
|
||||
main();
|
||||
308
qiming-mobile/scripts/sync-i18n-diff.js
Normal file
308
qiming-mobile/scripts/sync-i18n-diff.js
Normal file
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* i18n 多语言同步对比工具
|
||||
*
|
||||
* 对比平台导出的完整 i18n JSON 与本地 locale 文件,生成新增/修改的差异 JSON。
|
||||
* 仅处理 Mobile.* 前缀的 key。支持 zh-CN / en-US / zh-TW 三种语言。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/sync-i18n-diff.js --lang zh-CN --source ./i18n-config-zh-CN.json
|
||||
* node scripts/sync-i18n-diff.js --lang en-US --source ./i18n-config-export.json --batch-size 100
|
||||
*
|
||||
* 输出文件(在 source 同目录):
|
||||
* - i18n-diff-{lang}-added.json 新增的 key
|
||||
* - i18n-diff-{lang}-modified.json 值有变化的 key
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ── 参数解析 ──────────────────────────────────────────────
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const opts = { lang: 'zh-CN', batchSize: 0 };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--lang':
|
||||
opts.lang = args[++i];
|
||||
break;
|
||||
case '--source':
|
||||
opts.source = args[++i];
|
||||
break;
|
||||
case '--batch-size':
|
||||
opts.batchSize = parseInt(args[++i], 10) || 0;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
console.log(`
|
||||
i18n 多语言同步对比工具
|
||||
|
||||
对比平台导出的完整 i18n JSON 与本地 locale 文件,生成新增/修改的差异 JSON。
|
||||
仅处理 Mobile.* 前缀的 key,支持 zh-CN / en-US / zh-TW 三种语言。
|
||||
|
||||
用法:
|
||||
node scripts/sync-i18n-diff.js --lang <语言> --source <导出文件> [--batch-size <数量>]
|
||||
|
||||
参数:
|
||||
--lang <lang> 目标语言: zh-CN | en-US | zh-TW (默认 zh-CN)
|
||||
--source <file> 平台导出的 JSON 文件路径
|
||||
--batch-size <n> 分批大小 (0 = 不分批,默认 0)
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
# zh-CN 对比(不分批)
|
||||
node scripts/sync-i18n-diff.js --lang zh-CN --source .temp/i18n-platform/source/i18n-config-zh-CN.json
|
||||
|
||||
# en-US 对比,每批 100 个
|
||||
node scripts/sync-i18n-diff.js --lang en-US --source .temp/i18n-platform/source/i18n-config-en-US.json --batch-size 100
|
||||
|
||||
# zh-TW 对比,每批 100 个
|
||||
node scripts/sync-i18n-diff.js --lang zh-TW --source .temp/i18n-platform/source/i18n-config-zh-TW.json --batch-size 100
|
||||
|
||||
输出:
|
||||
差异 JSON 输出到 source 文件同目录下:
|
||||
i18n-diff-{lang}-added.json 新增的 key
|
||||
i18n-diff-{lang}-modified.json 值有变化的 key
|
||||
i18n-diff-{lang}-modified-N-of-M.json 分批输出(--batch-size > 0 时)
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
if (!opts.source) {
|
||||
console.error('Error: --source is required');
|
||||
process.exit(1);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
const LANG_FILE_MAP = {
|
||||
'zh-CN': 'zh-cn.uts',
|
||||
'en-US': 'en-us.uts',
|
||||
'zh-TW': 'zh-tw.uts',
|
||||
};
|
||||
|
||||
// ── 解析平台导出 JSON ────────────────────────────────────
|
||||
|
||||
function parseExport(filePath, targetLang) {
|
||||
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
const configs = raw.configs || raw;
|
||||
const map = {};
|
||||
for (const c of configs) {
|
||||
if (c.lang !== targetLang) continue;
|
||||
const key = c.fieldKey;
|
||||
if (!key || !key.startsWith('Mobile.')) continue;
|
||||
map[key] = c.fieldValue;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── 解析本地 locale TS 文件 ──────────────────────────────
|
||||
|
||||
function parseLocaleFile(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const map = {};
|
||||
// 匹配 "Mobile.xxx": "value" 或 "Mobile.xxx": 'value'
|
||||
const re = /^[\s]*"(Mobile\.[^"]+)":\s*"((?:[^"\\]|\\.)*)"/gm;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
map[m[1]] = m[2];
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── 对比 ──────────────────────────────────────────────────
|
||||
|
||||
function diffKeys(exportMap, localMap) {
|
||||
const added = {};
|
||||
const modified = {};
|
||||
const localOnly = {};
|
||||
|
||||
for (const [key, value] of Object.entries(exportMap)) {
|
||||
if (!(key in localMap)) {
|
||||
added[key] = value;
|
||||
} else if (localMap[key] !== value) {
|
||||
modified[key] = { local: localMap[key], remote: value };
|
||||
}
|
||||
}
|
||||
|
||||
// 本地有但平台没有的 key
|
||||
for (const [key, value] of Object.entries(localMap)) {
|
||||
if (!(key in exportMap)) {
|
||||
localOnly[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { added, modified, localOnly };
|
||||
}
|
||||
|
||||
// ── 分批 ──────────────────────────────────────────────────
|
||||
|
||||
function splitBatches(obj, batchSize) {
|
||||
const entries = Object.entries(obj);
|
||||
if (!batchSize || batchSize <= 0 || entries.length <= batchSize) {
|
||||
return [obj];
|
||||
}
|
||||
const batches = [];
|
||||
for (let i = 0; i < entries.length; i += batchSize) {
|
||||
const batch = {};
|
||||
for (const [k, v] of entries.slice(i, i + batchSize)) {
|
||||
batch[k] = v;
|
||||
}
|
||||
batches.push(batch);
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
// ── 主流程 ────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs();
|
||||
|
||||
const localeFileName = LANG_FILE_MAP[opts.lang];
|
||||
if (!localeFileName) {
|
||||
console.error(
|
||||
`Error: unsupported lang "${opts.lang}". Use zh-CN | en-US | zh-TW.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sourcePath = path.resolve(opts.source);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
console.error(`Error: source file not found: ${sourcePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const localePath = path.resolve(
|
||||
__dirname,
|
||||
`../constants/i18n-locales/${localeFileName}`,
|
||||
);
|
||||
if (!fs.existsSync(localePath)) {
|
||||
console.error(`Error: locale file not found: ${localePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n=== i18n Diff Report (${opts.lang}) ===`);
|
||||
console.log(`Source: ${sourcePath}`);
|
||||
console.log(`Local: ${localePath}\n`);
|
||||
|
||||
const exportMap = parseExport(sourcePath, opts.lang);
|
||||
const localMap = parseLocaleFile(localePath);
|
||||
const { added, modified, localOnly } = diffKeys(exportMap, localMap);
|
||||
|
||||
const addedCount = Object.keys(added).length;
|
||||
const modifiedCount = Object.keys(modified).length;
|
||||
const localOnlyCount = Object.keys(localOnly).length;
|
||||
const unchangedCount =
|
||||
Object.keys(exportMap).length - addedCount - modifiedCount;
|
||||
|
||||
console.log(`Export Mobile.* keys: ${Object.keys(exportMap).length}`);
|
||||
console.log(`Local Mobile.* keys: ${Object.keys(localMap).length}`);
|
||||
console.log(` Added (platform has, local missing): ${addedCount}`);
|
||||
console.log(` Modified (value differs): ${modifiedCount}`);
|
||||
console.log(` Local-only (local has, platform missing): ${localOnlyCount}`);
|
||||
console.log(` Unchanged: ${unchangedCount}\n`);
|
||||
|
||||
if (addedCount > 0) {
|
||||
console.log('--- Added Keys (sample) ---');
|
||||
for (const [k, v] of Object.entries(added).slice(0, 5)) {
|
||||
console.log(` + ${k}: ${v.slice(0, 60)}`);
|
||||
}
|
||||
if (addedCount > 5) console.log(` ... and ${addedCount - 5} more`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (modifiedCount > 0) {
|
||||
console.log('--- Modified Keys (sample) ---');
|
||||
for (const [k, v] of Object.entries(modified).slice(0, 5)) {
|
||||
console.log(` ~ ${k}:`);
|
||||
console.log(` local: ${v.local.slice(0, 60)}`);
|
||||
console.log(` remote: ${v.remote.slice(0, 60)}`);
|
||||
}
|
||||
if (modifiedCount > 5) console.log(` ... and ${modifiedCount - 5} more`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (localOnlyCount > 0) {
|
||||
console.log('--- Local-only Keys (sample) ---');
|
||||
for (const [k, v] of Object.entries(localOnly).slice(0, 5)) {
|
||||
console.log(` - ${k}: ${v.slice(0, 60)}`);
|
||||
}
|
||||
if (localOnlyCount > 5) console.log(` ... and ${localOnlyCount - 5} more`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// 写入差异 JSON — 输出到 .temp/i18n-platform/diff/{lang}/
|
||||
const baseDir = path.resolve(__dirname, '../.temp/i18n-platform');
|
||||
const outDir = path.join(baseDir, 'diff', opts.lang);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
if (addedCount > 0) {
|
||||
const addedBatches = splitBatches(added, opts.batchSize);
|
||||
if (addedBatches.length === 1) {
|
||||
const outFile = path.join(outDir, `i18n-diff-${opts.lang}-added.json`);
|
||||
fs.writeFileSync(outFile, JSON.stringify(added, null, 2), 'utf8');
|
||||
console.log(`Written: ${outFile} (${addedCount} keys)`);
|
||||
} else {
|
||||
addedBatches.forEach((batch, i) => {
|
||||
const outFile = path.join(
|
||||
outDir,
|
||||
`i18n-diff-${opts.lang}-added-${i + 1}-of-${
|
||||
addedBatches.length
|
||||
}.json`,
|
||||
);
|
||||
fs.writeFileSync(outFile, JSON.stringify(batch, null, 2), 'utf8');
|
||||
console.log(`Written: ${outFile} (${Object.keys(batch).length} keys)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (modifiedCount > 0) {
|
||||
const modifiedBatches = splitBatches(modified, opts.batchSize);
|
||||
if (modifiedBatches.length === 1) {
|
||||
const outFile = path.join(outDir, `i18n-diff-${opts.lang}-modified.json`);
|
||||
fs.writeFileSync(outFile, JSON.stringify(modified, null, 2), 'utf8');
|
||||
console.log(`Written: ${outFile} (${modifiedCount} keys)`);
|
||||
} else {
|
||||
modifiedBatches.forEach((batch, i) => {
|
||||
const outFile = path.join(
|
||||
outDir,
|
||||
`i18n-diff-${opts.lang}-modified-${i + 1}-of-${
|
||||
modifiedBatches.length
|
||||
}.json`,
|
||||
);
|
||||
fs.writeFileSync(outFile, JSON.stringify(batch, null, 2), 'utf8');
|
||||
console.log(`Written: ${outFile} (${Object.keys(batch).length} keys)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (localOnlyCount > 0) {
|
||||
const localOnlyBatches = splitBatches(localOnly, opts.batchSize);
|
||||
if (localOnlyBatches.length === 1) {
|
||||
const outFile = path.join(
|
||||
outDir,
|
||||
`i18n-diff-${opts.lang}-local-only.json`,
|
||||
);
|
||||
fs.writeFileSync(outFile, JSON.stringify(localOnly, null, 2), 'utf8');
|
||||
console.log(`Written: ${outFile} (${localOnlyCount} keys)`);
|
||||
} else {
|
||||
localOnlyBatches.forEach((batch, i) => {
|
||||
const outFile = path.join(
|
||||
outDir,
|
||||
`i18n-diff-${opts.lang}-local-only-${i + 1}-of-${
|
||||
localOnlyBatches.length
|
||||
}.json`,
|
||||
);
|
||||
fs.writeFileSync(outFile, JSON.stringify(batch, null, 2), 'utf8');
|
||||
console.log(`Written: ${outFile} (${Object.keys(batch).length} keys)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (addedCount === 0 && modifiedCount === 0 && localOnlyCount === 0) {
|
||||
console.log('No differences found. Local and platform are in sync.');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
204
qiming-mobile/scripts/verify-redirect-logic.mjs
Normal file
204
qiming-mobile/scripts/verify-redirect-logic.mjs
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 验证双向跳转脚本逻辑(与 index.html / qiming config 内联脚本一致)
|
||||
* 用法: node scripts/verify-redirect-logic.mjs
|
||||
*/
|
||||
|
||||
const agentDetailPathMobile = '/pages/agent-detail/agent-detail';
|
||||
const chatTempPathMobile = '/pages/chat-temp/chat-temp';
|
||||
const newAgentDetailPathMobile = '/subpackages' + agentDetailPathMobile;
|
||||
const newChatTempPathMobile = '/subpackages' + chatTempPathMobile;
|
||||
|
||||
// 应用详情页路径
|
||||
const appDetailsPathMobile = '/subpackages/pages/app-details/app-details';
|
||||
|
||||
function runRedirectLogic(protocol, host, href, hash, userAgent) {
|
||||
const baseUrl = protocol + '//' + host;
|
||||
const isMobile = /Android|iPhone|iPad|iPod|Mobile|Tablet/i.test(userAgent);
|
||||
let replaceUrl = null;
|
||||
|
||||
const location = {
|
||||
get protocol() { return protocol; },
|
||||
get host() { return host; },
|
||||
get href() { return href; },
|
||||
get hash() { return hash; },
|
||||
replace(url) { replaceUrl = url; },
|
||||
};
|
||||
|
||||
// 兼容分包前的页面
|
||||
if (isMobile && href.includes('/m/#/pages/')) {
|
||||
let mHash = hash;
|
||||
if (hash.indexOf(agentDetailPathMobile) !== -1) {
|
||||
mHash = mHash.replace(agentDetailPathMobile, newAgentDetailPathMobile);
|
||||
replaceUrl = baseUrl + '/m/' + mHash;
|
||||
return replaceUrl;
|
||||
}
|
||||
if (hash.indexOf(chatTempPathMobile) !== -1) {
|
||||
mHash = mHash.replace(chatTempPathMobile, newChatTempPathMobile);
|
||||
replaceUrl = baseUrl + '/m/' + mHash;
|
||||
return replaceUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// PC 端访问 M 页面 => 跳转 PC
|
||||
if (!isMobile && href.includes('/m/')) {
|
||||
// 智能体详情页
|
||||
if (hash && hash.indexOf(agentDetailPathMobile) !== -1) {
|
||||
const matchId = hash.match(new RegExp('[?&]id=([^&#]+)'));
|
||||
const matchConversationId = hash.match(new RegExp('[?&]conversationId=([^&#]+)'));
|
||||
if (matchId && matchId[1]) {
|
||||
const agentId = matchId[1];
|
||||
if (matchConversationId && matchConversationId[1]) {
|
||||
const conversationId = matchConversationId[1];
|
||||
replaceUrl = baseUrl + '/home/chat/' + conversationId + '/' + agentId;
|
||||
} else {
|
||||
replaceUrl = baseUrl + '/agent/' + agentId;
|
||||
}
|
||||
return replaceUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// 应用详情页
|
||||
if (hash && hash.indexOf(appDetailsPathMobile) !== -1) {
|
||||
const matchId = hash.match(new RegExp('[?&]id=([^&#]+)'));
|
||||
const matchConversationId = hash.match(new RegExp('[?&]conversationId=([^&#]+)'));
|
||||
|
||||
if (matchId && matchId[1]) {
|
||||
const agentId = matchId[1];
|
||||
if (matchConversationId && matchConversationId[1]) {
|
||||
const conversationId = matchConversationId[1];
|
||||
replaceUrl = baseUrl + '/app/chat/' + agentId + '/' + conversationId;
|
||||
} else {
|
||||
replaceUrl = baseUrl + '/app/' + agentId;
|
||||
}
|
||||
return replaceUrl;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 临时会话页
|
||||
if (hash && hash.indexOf(chatTempPathMobile) !== -1) {
|
||||
const matchChatTemp = hash.match(new RegExp('[?&]chatKey=([^&#]+)'));
|
||||
if (matchChatTemp && matchChatTemp[1]) {
|
||||
replaceUrl = baseUrl + '/chat-temp/' + matchChatTemp[1];
|
||||
return replaceUrl;
|
||||
}
|
||||
}
|
||||
replaceUrl = baseUrl + '/';
|
||||
return replaceUrl;
|
||||
}
|
||||
|
||||
// 移动端访问 PC 页面 => 跳转 M
|
||||
if (isMobile && !href.includes('/m/')) {
|
||||
// 智能体详情页
|
||||
const matchAgent = href.match(new RegExp('/agent/([^/?#]+)'));
|
||||
if (matchAgent) {
|
||||
replaceUrl = baseUrl + '/m/#' + newAgentDetailPathMobile + '?id=' + matchAgent[1];
|
||||
return replaceUrl;
|
||||
}
|
||||
|
||||
// 应用会话页 (这里必须要放在应用详情页匹配之前,否则会匹配到应用详情页)
|
||||
const matchAppChat = href.match(new RegExp('app/chat/([^/]+)/([^/]+)'));
|
||||
if (matchAppChat) {
|
||||
const agentId = matchAppChat[1];
|
||||
const conversationId = matchAppChat[2];
|
||||
replaceUrl = baseUrl + '/m/#' + appDetailsPathMobile + '?id=' + agentId + '&conversationId=' + conversationId;
|
||||
return replaceUrl;
|
||||
}
|
||||
|
||||
// 应用详情页
|
||||
const matchAppDetails = href.match(new RegExp('/app/([^/?#]+)'));
|
||||
if (matchAppDetails) {
|
||||
replaceUrl = baseUrl + '/m/#' + appDetailsPathMobile + '?id=' + matchAppDetails[1];
|
||||
return replaceUrl;
|
||||
}
|
||||
|
||||
// 临时会话页
|
||||
const matchChatTemp = href.match(new RegExp('/chat-temp/([^/?#]+)'));
|
||||
if (matchChatTemp) {
|
||||
replaceUrl = baseUrl + '/m/#' + newChatTempPathMobile + '?chatKey=' + matchChatTemp[1];
|
||||
return replaceUrl;
|
||||
}
|
||||
const matchHomeChat = href.match(new RegExp('home/chat/([^/]+)/([^/]+)'));
|
||||
if (matchHomeChat) {
|
||||
const conversationId = matchHomeChat[1];
|
||||
const agentId = matchHomeChat[2];
|
||||
replaceUrl = baseUrl + '/m/#' + newAgentDetailPathMobile + '?id=' + agentId + '&conversationId=' + conversationId;
|
||||
return replaceUrl;
|
||||
}
|
||||
replaceUrl = baseUrl + '/m/';
|
||||
return replaceUrl;
|
||||
}
|
||||
|
||||
return replaceUrl;
|
||||
}
|
||||
|
||||
const base = 'https://testagent.xspaceagi.com';
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: '移动端打开 H5 会话详情 /home/chat/1534879/1602 → M 端会话页',
|
||||
protocol: 'https:',
|
||||
host: 'testagent.xspaceagi.com',
|
||||
href: base + '/home/chat/1534879/1602',
|
||||
hash: '',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
expect: base + '/m/#/subpackages/pages/agent-detail/agent-detail?id=1602&conversationId=1534879',
|
||||
},
|
||||
{
|
||||
name: 'PC 打开 M 端会话链接(带 conversationId)→ /home/chat/conversationId/agentId',
|
||||
protocol: 'https:',
|
||||
host: 'testagent.xspaceagi.com',
|
||||
href: base + '/m/#/subpackages/pages/agent-detail/agent-detail?id=1602&conversationId=1534879',
|
||||
hash: '#/subpackages/pages/agent-detail/agent-detail?id=1602&conversationId=1534879',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0',
|
||||
expect: base + '/home/chat/1534879/1602',
|
||||
},
|
||||
{
|
||||
name: 'PC 打开 M 端仅智能体(无 conversationId)→ /agent/agentId',
|
||||
protocol: 'https:',
|
||||
host: 'testagent.xspaceagi.com',
|
||||
href: base + '/m/#/subpackages/pages/agent-detail/agent-detail?id=1602',
|
||||
hash: '#/subpackages/pages/agent-detail/agent-detail?id=1602',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0',
|
||||
expect: base + '/agent/1602',
|
||||
},
|
||||
{
|
||||
name: '移动端打开 /agent/1602 → M 端智能体详情(无会话)',
|
||||
protocol: 'https:',
|
||||
host: 'testagent.xspaceagi.com',
|
||||
href: base + '/agent/1602',
|
||||
hash: '',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)',
|
||||
expect: base + '/m/#/subpackages/pages/agent-detail/agent-detail?id=1602',
|
||||
},
|
||||
{
|
||||
name: '移动端打开未知 PC 路径 → /m/ 首页',
|
||||
protocol: 'https:',
|
||||
host: 'testagent.xspaceagi.com',
|
||||
href: base + '/other',
|
||||
hash: '',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)',
|
||||
expect: base + '/m/',
|
||||
},
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const c of cases) {
|
||||
const got = runRedirectLogic(c.protocol, c.host, c.href, c.hash, c.userAgent);
|
||||
const ok = got === c.expect;
|
||||
if (ok) {
|
||||
passed++;
|
||||
console.log('✓', c.name);
|
||||
} else {
|
||||
failed++;
|
||||
console.log('✗', c.name);
|
||||
console.log(' 期望:', c.expect);
|
||||
console.log(' 实际:', got);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + passed + ' 通过, ' + failed + ' 失败');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user