chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View 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.");

View 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();

View 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();

View 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);