update:翻译备份

This commit is contained in:
2026-11-05 18:25:03 +08:00
parent 53ac4473f9
commit d1c689cfda
9 changed files with 3947 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
// 回归截图中的小程序循环作用域问题:执行真实 render,再按 WXML 作用域取文本。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const {baseParse} = require('@vue/compiler-dom');
const root = path.resolve(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const en = JSON.parse(read('src/locales/en.json'));
const sourceKeys = JSON.parse(read('src/locales/source-keys.json'));
const localeCode = read('src/locales/index.js').replace(/^import .*;\r?\n/gm, '').replace(/export /g, '');
const locale = new Function('createI18n','en','sourceKeys',localeCode+'\nreturn {t,formatBattleTip};')(
() => ({global: {t: key => key}}), en, sourceKeys);
const computed = getter => ({get value() {return getter();}});
const vendor = {
computed, unref: value => value,
e: (...parts) => Object.assign({}, ...parts),
f: (items, render) => (typeof items === 'number' ? Array.from({length: items}, (_,i) => i+1) : items)
.map((item,index) => render(item,index,index)),
t: value => value == null ? '' : String(value),
_export_sfc: component => component,
};
function loadComponent(file, dependencies) {
let component;
vm.runInNewContext(read(file), {
exports: {}, wx: {createComponent: value => {component = value;}},
require: id => {
for (const [suffix,value] of Object.entries(dependencies)) if (id.endsWith(suffix)) return value;
throw Error('Unexpected component dependency: '+id);
},
}, {filename: file});
assert(component, 'No compiled component: '+file);
return component;
}
const prefix = 'dist/build/mp-weixin/pages/team-battle/components/';
const footer = loadComponent(prefix+'BattleFooter.js', {
'common/vendor.js': vendor,
'locales/index.js': locale,
'constants.js': {RoundImages: {round1:'round1',round2:'round2',round3:'round3',gold1:'gold1'}},
});
const wxml = baseParse(read(prefix+'BattleFooter.wxml'));
function evaluate(expression, scope) {
return new Function('scope', 'with(scope){return ('+expression+');}')(scope);
}
function renderedTexts(node,scope) {
const attr = name => (node.props || []).find(p => p.name === name)?.value?.content;
const forValue = attr('wx:for');
if (forValue) {
const rows = evaluate(forValue.slice(2,-2),scope);
return rows.flatMap((item,index) => renderedTexts({...node,props:node.props.filter(p=>p.name!=='wx:for')},
{...scope,[attr('wx:for-item') || 'item']:item,[attr('wx:for-index') || 'index']:index}));
}
const ifValue = attr('wx:if');
if (ifValue && !evaluate(ifValue.slice(2,-2),scope)) return [];
if (node.tag === 'text') {
return [(node.children || []).map(child => {
const value = child.type === 5 ? evaluate(child.content.content,scope) : child.content || '';
assert(value === null || typeof value !== 'object', 'WXML rendered a loop object in a text node');
return value == null ? '' : String(value);
}).join('')];
}
return (node.children || []).flatMap(child => renderedTexts(child,scope));
}
for (const count of [0,1,2,3,4]) {
const roundResults = Array.from({length:count},(_,index)=>({
ifGold:index===3, goldRound:index===3 ? 1 : 0,
shoots:{1:[{ring:index===0 ? 10 : 0}],2:[{ring:8}]},
}));
const props = {roundResults,bluePoints:0,redPoints:0,goldenRound:count===4 ? 1 : 0};
const data = footer.setup(props)(props,{});
const texts = renderedTexts(wxml,data);
assert.equal(texts.filter(text=>text===en.pts).length, Math.max(3,count)*2);
assert(!texts.join('').includes('[object Object]'));
if (count) assert(texts.includes('10'), 'Completed round score lost');
if (count>1) assert(texts.includes('0'), 'Zero score lost');
}
// 顶部只转换显示值,监听器仍接收原始中文提示,语音控制不增加播放调用。
for (const tip of ['请蓝队射箭','请红队射箭你重回']) {
const store = {game:{currentShot:1,totalShot:3,tips:tip}};
const header = loadComponent(prefix+'HeaderProgress.js', {
'common/vendor.js': {...vendor,
ref:value=>({value}), watch:(source,callback)=>callback(source()),
onMounted:()=>{},onBeforeUnmount:()=>{},o:handler=>handler,
},
'locales/index.js':locale,
'store.js':{useStore:()=>store},
'audioManager.js':{audioManager:{setMuted:()=>{}}},
});
const data = header.setup({})({},{});
assert.equal(data.a,locale.formatBattleTip(tip));
assert(!/[\u3400-\u9fff]/.test(data.a));
assert.equal(store.game.tips,tip);
assert.equal(data.c,'1');
assert.equal(data.d,'3');
}
console.log('Battle display checks passed: compiled JS + WXML, 0–3 rounds and golden round, blank/zero scores, English tips and original Chinese state preserved.');
+220
View File
@@ -0,0 +1,220 @@
// 英文文案与业务边界检查:不调用网络、设备或支付接口。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const { parse, compileScript } = require('@vue/compiler-sfc');
const { baseParse } = require('@vue/compiler-dom');
const { createI18n } = require('vue-i18n');
const root = path.resolve(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const en = JSON.parse(read('src/locales/en.json'));
const zh = JSON.parse(read('src/locales/zh-CN.json'));
const sourceKeys = JSON.parse(read('src/locales/source-keys.json'));
const han = /[\p{Script=Han}]/u;
const manifest=JSON.parse(read('src/manifest.json'));
assert.equal(manifest['mp-weixin'].permission['scope.userLocation'].desc,en.wifi_location_permission);
// 微信上传会校验权限用途说明,英文也最多允许 30 个字符。
for (const [scope, permission] of Object.entries(manifest['mp-weixin'].permission || {})) {
assert.equal(typeof permission.desc, 'string', `${scope}: permission description must be a string`);
assert(permission.desc.length > 0 && permission.desc.length <= 30,
`${scope}: permission description must contain 1–30 characters (got ${permission.desc.length})`);
}
const params = value => [...value.matchAll(/\{(p\d+)\}/g)].map(m=>m[1]).sort();
assert.deepEqual(Object.keys(en).sort(), Object.keys(zh).sort());
const localeCode = read('src/locales/index.js').replace(/^import .*;\r?\n/gm, '').replace(/export /g, '');
const api = new Function('createI18n','en','sourceKeys', localeCode+'\nreturn {t,translateStatic,formatBattleTip};')(createI18n,en,sourceKeys);
// 使用当前 uni-app 实际打包的消息编译器复现问题,避免只测 Node 完整版。
const miniRuntimePath = path.join(path.dirname(require.resolve('@dcloudio/uni-cli-shared/package.json')),
'lib/vue-i18n/dist/vue-i18n.runtime.esm-bundler.js');
const miniRuntimeCode = fs.readFileSync(miniRuntimePath, 'utf8');
let miniCompilerCode;
traverse(parser.parse(miniRuntimeCode, {sourceType: 'module'}), {
ObjectProperty(p) {
if (p.node.key.name === 'messageCompiler' && p.node.value.type === 'FunctionExpression') {
miniCompilerCode = miniRuntimeCode.slice(p.node.value.start, p.node.value.end);
}
},
});
assert(miniCompilerCode, 'Cannot locate uni-app message compiler; review runtime compatibility test');
const miniCompiler = new Function('return (' + miniCompilerCode + ');')();
const createMiniI18n = ({locale, messages}) => ({global: {t: (key, args = {}) => {
const message = messages[locale][key];
return message === undefined ? key : miniCompiler(message)({
normalize: values => values.join(''),
interpolate: value => String(value),
named: name => args[name],
});
}}});
const miniApi = new Function('createI18n','en','sourceKeys', localeCode+'\nreturn {t,translateStatic,formatBattleTip};')(createMiniI18n,en,sourceKeys);
const referenceI18n = createI18n({legacy: false, locale: 'en', fallbackLocale: 'en', messages: {en}});
const parameterized = Object.entries(en).filter(([,value]) => params(value).length);
for (const [key,value] of parameterized) {
for (const example of [0, 7, -2, '', false, 'Bronze', '$& {p1}']) {
const args = Object.fromEntries(params(value).map(name => [name, example]));
const expected = referenceI18n.global.t(key, args);
assert.equal(api.t(key, args), expected, key + ': full runtime interpolation');
assert.equal(miniApi.t(key, args), expected, key + ': mini runtime interpolation');
}
}
assert.equal(miniApi.t('value_value_matches', {p0: 'Bronze', p1: 0}), 'Bronze, 0 matches');
assert.equal(miniApi.t('value_value_matches', {p0: 'Bronze', p1: 12}), 'Bronze, 12 matches');
assert.equal(miniApi.t('value_value_matches', {p0: '{p1}', p1: 12}), '{p1}, 12 matches');
assert.equal(miniApi.t('value_value_matches', {p0: '', p1: 0}), ', 0 matches');
assert.equal(miniApi.t('value_value_matches', {p0: null, p1: 0}), ', 0 matches');
assert.equal(miniApi.t('value_value_matches', {p0: 'Bronze'}), 'Bronze, {p1} matches');
assert.equal(miniApi.t('value_value_matches', Object.create({p0: 'Inherited', p1: 12})), en.value_value_matches);
assert.equal(miniApi.formatBattleTip('请开始射箭第三轮'), 'Start shooting round 3');
console.log(`Interpolation compatibility passed: ${parameterized.length} messages, full and uni-app mini compilers, zero/empty/boolean/literal-brace values.`);
// 构建后检查真实产物模块;只替换平台启动依赖,沿用上方实际小程序编译器。
if (process.argv.includes('--built')) {
const builtExports = {};
vm.runInNewContext(read('dist/build/mp-weixin/locales/index.js'), {
exports: builtExports,
require: id => {
assert(/common\/vendor\.js$/.test(id), 'Unexpected built locale dependency: ' + id);
return {createI18n: createMiniI18n};
},
}, {filename: 'dist/build/mp-weixin/locales/index.js'});
for (const [key,value] of parameterized) {
for (const example of [0, 12, '', false, 'Bronze', '$& {p1}']) {
const args = Object.fromEntries(params(value).map(name => [name, example]));
assert.equal(builtExports.t(key, args), referenceI18n.global.t(key, args), key + ': built module interpolation');
}
}
console.log(`Built WeChat locale module passed: ${parameterized.length} parameterized messages.`);
}
for (const [key,value] of Object.entries(en)) {
assert(!han.test(value), 'English contains Chinese: '+key);
assert.deepEqual(params(value), params(zh[key]), 'Placeholder mismatch: '+key);
const args=Object.fromEntries(params(value).map(name=>[name, name.toUpperCase()]));
const output=api.t(key,args);
for (const name of params(value)) assert(output.includes(args[name]),'Interpolation failed: '+key);
}
for (const [source,key] of Object.entries(sourceKeys)) assert.equal(zh[key],source);
assert.equal(api.formatBattleTip('请红队射箭你重回'),'Red team, shoot');
assert.equal(api.formatBattleTip('上半场:请先射6箭'),'First half: shoot 6 arrows first');
assert.equal(api.formatBattleTip('请开始射箭第三轮'),'Start shooting round 3');
assert.equal(api.translateStatic('An unknown server value'),'An unknown server value');
let vueCount=0, references=0;
const files=[];
function walk(dir) {
for (const item of fs.readdirSync(path.join(root,dir),{withFileTypes:true})) {
const file=dir+'/'+item.name;
if (item.isDirectory()) {if (!['static','locales'].includes(item.name)) walk(file);}
else if (/\.(vue|js)$/.test(file) && !/match\.min\.js/.test(file)) files.push(file);
}
}
walk('src');
function checkJs(code,file,expression=false) {
let ast;
try {ast=parser.parse(expression?'('+code+')':code,{sourceType:'module'});} catch(error) {if (expression) return;throw error;}
traverse(ast,{CallExpression(p) {
const n=p.node;
if (!['t','translate'].includes(n.callee.name) || n.arguments[0]?.type!=='StringLiteral') return;
const binding=p.scope.getBinding(n.callee.name);
if (!expression) assert(binding && binding.path.node.type==='ImportSpecifier' && /locales/.test(binding.path.parent.source.value),file+': translation function shadowed');
const key=n.arguments[0].value;
assert(Object.hasOwn(en,key),file+': missing key '+key);references++;
const supplied=n.arguments[1]?.properties?.map(prop=>prop.key.name)||[];
assert.deepEqual(supplied.sort(),params(en[key]),file+': wrong parameters for '+key);
}});
}
for (const file of files) {
const code=read(file);
if (!file.endsWith('.vue')) {checkJs(code,file);continue;}
const {descriptor,errors}=parse(code,{filename:file});
assert.equal(errors.length,0,file+': SFC parse failed');vueCount++;
if (descriptor.script || descriptor.scriptSetup) {
compileScript(descriptor,{id:file});
for (const b of [descriptor.script,descriptor.scriptSetup]) if (b) checkJs(b.content,file);
}
if (!descriptor.template) continue;
function visit(n) {
if (n.type===2) assert(!han.test(n.content),file+': untranslated template text '+n.content);
if (n.type===5) checkJs(n.content.content,file,true);
for (const prop of n.props||[]) {
if (prop.exp) checkJs(prop.exp.content,file,true);
if (prop.type===6 && prop.value && !/^(src|class|id|style|key|name|value|tips|end-audio-key|practice-end-audio-key)$/.test(prop.name)) assert(!han.test(prop.value.content),file+': untranslated attribute '+prop.name);
}
for (const c of n.children||[]) visit(c);
}
visit(baseParse(descriptor.template.content));
}
// 固件与比赛继续使用中文语音键,英文仅用于展示。
const utilCode=read('src/util.js').replace(/^import .*;\r?\n/gm,'').replace(/export /g,'');
const uni={getMenuButtonBoundingClientRect:()=>({top:20}),getAccountInfoSync:()=>({miniProgram:{envVersion:'release'}}),showToast:()=>{}};
const util=new Function('t','translateStatic','uni',utilCode+'\nreturn {getDirectionText,getShootValidation,getDistanceCheckAudioKey,getDistanceCheckText,getInvalidShotAudioKey,getInvalidShotText,isLimitError,canEenter};')(api.t,api.translateStatic,uni);
for (const [angle,expected] of [[0,'下'],[45,'左下'],[90,'左'],[135,'左上'],[180,'上'],[225,'右上'],[270,'右'],[315,'右下']]) assert.equal(util.getDirectionText(angle),expected);
for (const shot of [null,{}, {distance:0}, {distance:-1}, {distance:499,targetOk:true}, {distance:500,targetOk:true}, {distance:600,targetOk:false}]) {
assert(han.test(util.getInvalidShotAudioKey(shot)));
assert(han.test(util.getDistanceCheckAudioKey(shot)));
assert(!han.test(util.getInvalidShotText(shot)));
assert(!han.test(util.getDistanceCheckText(shot)));
}
assert.equal(util.getShootValidation({distance:499,targetOk:true}).distanceOk,false);
assert.equal(util.getShootValidation({distance:500,targetOk:true}).distanceOk,true);
assert(util.isLimitError({message:'今日次数已达上限'}));
assert(!util.isLimitError({message:'other error'}));
assert.equal(util.canEenter({}, {}, true),false);
assert.equal(util.canEenter({trio:1},{deviceId:'test'},true),true);
assert.equal(util.canEenter({trio:1},{deviceId:'test'},false),false);
assert.equal(util.canEenter({trio:0},{deviceId:'test'},true),false);
// 对照 Git 基线检查协议资源未改动,只检查当前变更涉及的源码文件。
const urls=code=>[...code.matchAll(/https?:\/\/[^\s"'`<>]+/g)].map(m=>m[0]).sort();
function scriptShapes(code,file) {
const blocks=file.endsWith('.vue') ? ((d)=>[d.script,d.scriptSetup].filter(Boolean).map(b=>b.content))(parse(code).descriptor) : [code];
return blocks.map(block=>{
const ast=parser.parse(block,{sourceType:'module'});
function clean(value) {
if (Array.isArray(value)) return value.map(clean).filter(v=>v!==undefined);
if (!value || typeof value!=='object') return value;
if (value.type==='ImportDeclaration' && /locales/.test(value.source.value)) return undefined;
if (file==='src/pages/training/index.vue' && value.type==='ObjectProperty' && value.key.name==='staticLabel') return undefined;
if (file==='src/canvas.js' && value.type==='CallExpression' && value.callee.name==='renderText') {
const key=value.arguments[1]?.arguments?.[0]?.value;
if (key==='today_s_check_in') { assert.equal(value.arguments[2].value,9); value.arguments[2].value=14; }
if (key==='total') { assert.equal(value.arguments[4].value,102); value.arguments[4].value=122; }
}
if (value.type==='ExpressionStatement' && value.expression.callee?.property?.name==='use' && value.expression.arguments?.[0]?.name==='i18n') return undefined;
if (value.type==='CallExpression') {
if (value.callee.name==='translateStatic') return clean(value.arguments[0]);
if (['t','translate'].includes(value.callee.name) && value.arguments[0]?.type==='StringLiteral') {
const source=zh[value.arguments[0].value];
assert.notEqual(source,undefined,file+': unknown source');
const matches=[...source.matchAll(/\{(p\d+)\}/g)];
if (!matches.length) return {type:'StringLiteral',value:source};
const argumentsMap=Object.fromEntries((value.arguments[1]?.properties||[]).map(p=>[p.key.name,p.value]));
let cursor=0;
const quasis=matches.map(m=>{const text=source.slice(cursor,m.index);cursor=m.index+m[0].length;return {type:'TemplateElement',value:{cooked:text},tail:false};});
quasis.push({type:'TemplateElement',value:{cooked:source.slice(cursor)},tail:true});
return clean({type:'TemplateLiteral',quasis,expressions:matches.map(m=>argumentsMap[m[1]])});
}
}
if (value.type==='TemplateLiteral' && !value.expressions.length) return {type:'StringLiteral',value:value.quasis[0].value.cooked};
const result={};
for (const [key,item] of Object.entries(value)) {
if (['start','end','loc','extra','leadingComments','innerComments','trailingComments','comments','tokens','errors','raw'].includes(key)) continue;
const next=clean(item);
if (next!==undefined) result[key]=next;
}
// 唯一调整的脚本标点位于测距展示文本,不参与判断。
if (result.type==='TemplateElement' && result.value.cooked===',') result.value.cooked='; ';
return result;
}
return clean(ast.program);
});
}
const baselinePath=process.argv.slice(2).find(arg => arg !== '--built');
if (baselinePath) {
const baseline=JSON.parse(fs.readFileSync(baselinePath,'utf8').replace(/^\uFEFF/,''));
for (const [file,original] of Object.entries(baseline)) {
assert.deepEqual(urls(read(file)),urls(original),file+': URL changed');
assert.deepEqual(scriptShapes(read(file),file),scriptShapes(original,file),file+': non-localization script change');
if (file==='src/audioManager.js') assert.equal(read(file).replace(/\r\n/g,'\n'),original.replace(/\r\n/g,'\n'),'Audio resources or keys changed');
}
console.log('Git baseline media URLs and audio resources verified.');
}
console.log(`Localization checks passed: ${Object.keys(en).length} entries, ${references} references, ${vueCount} Vue files; placeholders, static templates, audio keys, shooting validation and entry conditions verified.`);
+136
View File
@@ -0,0 +1,136 @@
// 段位多语言集成回归:请求使用本地桩,缓存通过真实 Pinia 持久化插件恢复。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createPinia, defineStore } = require('pinia');
const { createSSRApp } = require('vue');
const persist = require('pinia-plugin-persistedstate').default;
const root = path.resolve(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const requests = [];
let hasToken = true;
let nextFailure;
const rank = {rankLvl: 1, rankName: 'Bronze I', rankIcon: 'https://example.test/青铜1.png'};
const apiUni = {
getAccountInfoSync: () => ({miniProgram: {envVersion: 'trial'}}),
getStorageSync: key => hasToken && key === 'trial_token' ? 'existing-token' : '',
request(options) {
requests.push(options);
if (options.url.endsWith('/user/device/aimRenew')) return {};
if (nextFailure) {
const error = nextFailure;
nextFailure = undefined;
options.success({statusCode: 200, data: error});
return;
}
const data = options.url.includes('/myHome')
? {...rank, user: {id: 42, scores: 150, rankLvl: 1, nickName: '原昵称'}}
: {list: [{...rank}], randInfos: [{rank_id: 1, upgrade_scores: 100, name: 'Bronze I', icoin: rank.rankIcon}]};
options.success({statusCode: 200, data: {code: 0, data}});
},
};
const apiCode = read('src/apis.js').replace(/^import .*;\r?\n/gm, '').replace(/^export /gm, '');
const api = new Function('uni', 't', 'normalizeBattleApiResult', apiCode + `
return {request, getAppConfig, getHomeData, getScoreRankList, getTenRingRankList, getMvpRankList,
getMyScoreRank, getMyTenRingRank, getMyMvpRank, getSeasonStats, getProvinceData, getDailyCountAPI, aimRenewAPI};
`)(apiUni, key => key, data => data);
const storeCode = read('src/store.js').replace(/^import .*;\r?\n/gm, '').replace('export default defineStore', 'return defineStore');
function restore(cached) {
const storage = new Map([
['store', JSON.stringify(cached)],
['trial_token', 'existing-token'],
['point-book', JSON.stringify({draft: true})],
]);
const uni = {getStorageSync: key => storage.get(key), setStorageSync: (key, value) => storage.set(key, value)};
const useStore = new Function('defineStore', 'uni', storeCode)(defineStore, uni);
const pinia = createPinia();
pinia.use(persist);
createSSRApp({}).use(pinia);
return {store: useStore(pinia), storage};
}
async function main() {
const config = await api.getAppConfig();
const home = await api.getHomeData(42);
await api.getScoreRankList(42, 2, 20);
await api.getTenRingRankList(42, 2, 20);
await api.getMvpRankList(42, 2, 20);
await api.getMyScoreRank(42);
await api.getMyTenRingRank(42);
await api.getMyMvpRank(42);
await api.getSeasonStats(0);
await api.request('GET', '/index/myHome?seasonId=42');
for (const request of requests) {
assert.equal(request.header['Accept-Language'], 'en-US');
assert.equal(request.header.Authorization, 'Bearer existing-token');
assert.equal(request.method, 'GET');
assert(request.url.startsWith('https://apitest.shelingxingqiu.com/api/shoot/'));
}
assert(requests[1].url.endsWith('/user/myHome?seasonId=42'), 'Existing home route must remain unchanged');
assert.deepEqual(requests[2].data, {seasonId: 42, page: 2, perPage: 20});
assert.deepEqual(requests[8].data, {seasonId: 0});
assert.equal(home.user.rankName, rank.rankName);
assert.equal(home.user.rankIcon, rank.rankIcon);
assert.equal(home.user.nickName, '原昵称');
await api.getProvinceData();
await api.getDailyCountAPI();
await api.request('GET', '/index/appConfig/other');
await api.request('POST', '/index/appConfig');
await api.request('PUT', '/test/update', {value: 0});
await api.request('DELETE', '/test/delete', {id: 42});
for (const request of requests.slice(10)) {
assert.equal(request.header['Accept-Language'], 'en-US', 'All HTTP requests must declare English');
assert.equal(request.header.Authorization, 'Bearer existing-token');
}
assert.deepEqual(requests.at(-2).data, {value: 0});
assert.deepEqual(requests.at(-1).data, {id: 42});
// 续期请求即使未收到响应,也必须保持同步返回且不弹失败提示。
assert.equal(api.aimRenewAPI(), undefined);
assert.equal(requests.at(-1).header['Accept-Language'], 'en-US');
assert.equal(requests.at(-1).header.Authorization, 'Bearer existing-token');
assert.equal(requests.at(-1).method, 'POST');
assert.deepEqual(requests.at(-1).data, {});
assert.equal(requests.at(-1).timeout, 10000);
assert.equal(requests.at(-1).fail({errMsg: 'timeout'}), undefined);
hasToken = false;
await api.getAppConfig();
assert.deepEqual(requests.at(-1).header, {'Accept-Language': 'en-US'});
assert.equal(api.aimRenewAPI(), undefined);
assert.deepEqual(requests.at(-1).header, {'Accept-Language': 'en-US'});
hasToken = true;
nextFailure = {code: 400, message: '设备未连接', data: {reason: 'offline'}};
await assert.rejects(api.request('POST', '/test/error', {}, undefined, undefined, {showErrorToast: false}),
error => error.code === 400 && error.message === '设备未连接' && error.data.reason === 'offline');
assert.equal(requests.at(-1).header['Accept-Language'], 'en-US');
const legacy = {
user: {id: 42, nickName: '原昵称', avatar: 'avatar.png', trio: 1, scores: 150, rankLvl: 1,
lvlName: '倔强青铜I', rankName: '倔强青铜I', nextRankName: '倔强青铜II',
rankIcon: rank.rankIcon, nextRankIcon: 'next.png', lvlImage: 'level.png', vip: true},
device: {deviceId: 'unchanged-device', deviceName: '我的弓'},
};
const {store, storage} = restore(legacy);
for (const [field, value] of Object.entries(legacy.user)) {
assert.deepEqual(store.user[field], ['lvlName','rankName','nextRankName'].includes(field) ? '' : value);
}
assert.deepEqual({...store.device}, legacy.device);
assert.equal(storage.get('trial_token'), 'existing-token');
assert.equal(storage.get('point-book'), JSON.stringify({draft: true}));
assert.deepEqual([...storage.keys()].sort(), ['store', 'trial_token', 'point-book'].sort());
await store.updateConfig(config);
await store.updateUser(home.user);
assert.equal(store.user.rankName, 'Bronze I');
assert.equal(store.user.lvlName, 'Bronze I');
assert.equal(store.user.scores, 150);
assert.equal(store.user.rankLvl, 1);
store.$persist();
const next = restore(JSON.parse(storage.get('store'))).store;
assert.equal(next.user.rankName, 'Bronze I', 'English cached labels must survive cold start');
assert.equal(next.user.lvlName, 'Bronze I');
assert.equal(next.user.nickName, '原昵称');
assert.deepEqual({...next.device}, legacy.device);
assert.equal(restore({user: {id: 7, rankName: '', lvlName: null}, device: {}}).store.user.lvlName, null);
console.log('Rank i18n checks passed: global HTTP English headers (GET/POST/PUT/DELETE + aim renewal), auth/query/pagination and original errors preserved; non-blocking renewal and real Pinia cache hydration verified.');
}
main().catch(error => {console.error(error); process.exitCode = 1;});
+63
View File
@@ -0,0 +1,63 @@
// 只读提取源码中的中文候选;不扫描图片、生成物或第三方依赖。
const fs = require('node:fs');
const path = require('node:path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const sfc = require('@vue/compiler-sfc');
const dom = require('@vue/compiler-dom');
const han = /[\p{Script=Han}]/u;
const root = path.resolve(__dirname, '../src');
const entries = [];
function scripts(code, file, offset = 0, context = 'script') {
if (context === 'expression') { code = '(' + code + ')'; offset -= 1; }
const ast = parser.parse(code, {sourceType: 'module', plugins: ['typescript'], errorRecovery: true});
traverse(ast, {
StringLiteral(p) { add(p, p.node.value, 'string'); },
TemplateLiteral(p) {
if (!p.node.quasis.some(q => han.test(q.value.cooked || q.value.raw))) return;
const text = p.node.quasis.map((q, i) => (q.value.cooked ?? q.value.raw) + (i < p.node.expressions.length ? `{p${i}}` : '')).join('');
add(p, text, 'template');
},
});
function add(p, text, kind) {
if (!han.test(text) || /^https?:/.test(text)) return;
const ancestors = [];
let q = p;
for (let i = 0; q && i < 5; i++, q = q.parentPath) {
const n = q.node;
ancestors.push(n.type + (n.key?.name ? ':' + n.key.name : '') + (n.type === 'CallExpression' ? ':' + code.slice(n.callee.start, n.callee.end) : ''));
}
entries.push({file, start: offset + p.node.start, end: offset + p.node.end, kind, text, context, ancestors: ancestors.join(' > '), snippet: code.slice(Math.max(0, p.node.start - 65), Math.min(code.length, p.node.end + 65))});
}
}
function inspect(file) {
const code = fs.readFileSync(path.join(root, file), 'utf8');
if (file.endsWith('.js')) return scripts(code, file);
const {descriptor} = sfc.parse(code);
for (const block of [descriptor.script, descriptor.scriptSetup]) if (block) scripts(block.content, file, block.loc.start.offset);
const block = descriptor.template;
if (!block) return;
const ast = dom.baseParse(block.content);
function walk(n) {
if (n.type === 2 && han.test(n.content)) entries.push({file, start: block.loc.start.offset + n.loc.start.offset, end: block.loc.start.offset + n.loc.end.offset, kind:'text', text:n.content, context:'template'});
if (n.type === 5 && han.test(n.content.content)) scripts(n.content.content, file, block.loc.start.offset + n.content.loc.start.offset, 'expression');
for (const prop of n.props || []) {
if (prop.type === 6 && prop.value && han.test(prop.value.content) && !/^(src|class|id|style|key|name|value)$/.test(prop.name)) entries.push({file, start:block.loc.start.offset + prop.loc.start.offset, end:block.loc.start.offset + prop.loc.end.offset, kind:'attribute', text:prop.value.content, attribute:prop.name, context:'template'});
if (prop.type === 7 && prop.exp && han.test(prop.exp.content)) {
try { scripts(prop.exp.content, file, block.loc.start.offset + prop.exp.loc.start.offset, 'expression'); } catch (e) { /* v-for 等非独立表达式保留人工检查 */ }
}
}
for (const child of n.children || []) walk(child);
}
walk(ast);
}
function walk(dir) {
for (const item of fs.readdirSync(path.join(root, dir), {withFileTypes:true})) {
const file = path.posix.join(dir, item.name);
if (item.isDirectory()) { if (item.name !== 'static' && item.name !== 'locales') walk(file); }
else if (/\.(vue|js)$/.test(file) && !/match\.min\.js|audioManager\.js/.test(file)) inspect(file);
}
}
walk('');
if (require.main === module) process.stdout.write(JSON.stringify(entries, null, 2));
module.exports = entries;