feat: 新增多语言国际化页面配置,修复部分页面硬编码文案

1. 新增了zh-cn和en-us两套语言下的大量页面国际化配置文件,覆盖商品、订单、会员、营销活动等多个业务模块
2. 修复了分销页面、VR页面、支付页面、商品不存在页面的硬编码提示文案,替换为国际化调用
3. 删除了原有的i18n检查脚本文件
This commit is contained in:
2026-06-25 11:00:50 +08:00
parent 5f41febd70
commit 8e218bb663
509 changed files with 49155 additions and 55313 deletions

View File

@@ -1,172 +0,0 @@
import re
# 读取 zh-cn.js
with open('lang/zh-cn.js', 'r', encoding='utf-8') as f:
content = f.read()
# 提取所有 "key": "value" 模式(考虑嵌套)
# 先提取 lang 对象内部的内容
match = re.search(r'export const lang = \{(.*?)\n\};', content, re.DOTALL)
if not match:
print("Cannot find lang object")
exit(1)
lang_body = match.group(1)
# 提取所有 key-value 对
pattern = r'"([^"]+)"\s*:\s*"([^"]*)"'
matches = re.findall(pattern, lang_body)
print(f'Total key-value pairs found: {len(matches)}')
# 因为是嵌套结构,key 可能重复(不同层级),我们需要完整路径
# 让我们用另一种方式:计算缩进层级来构建完整路径
lines = lang_body.split('\n')
stack = [] # 当前路径栈
full_keys = {} # 完整路径 -> 值
for line in lines:
stripped = line.strip()
if not stripped:
continue
# 计算缩进
indent = len(line) - len(line.lstrip())
# 计算当前层级 (每 4 个空格一级,或者根据 tab)
level = indent // 4 + 1 # 假设最外层是第 1 级
# 收缩栈到当前层级
while len(stack) >= level:
stack.pop()
# 检查是否是对象 key
obj_match = re.match(r'"([^"]+)"\s*:\s*\{', stripped)
if obj_match:
stack.append(obj_match.group(1))
continue
# 检查是否是字符串值
val_match = re.match(r'"([^"]+)"\s*:\s*"([^"]*)"', stripped)
if val_match:
key = val_match.group(1)
value = val_match.group(2)
full_path = '.'.join(stack + [key])
full_keys[full_path] = value
print(f'Full-path keys: {len(full_keys)}')
# 统计按一级命名空间分类
namespaces = {}
for k in full_keys.keys():
ns = k.split('.')[0]
namespaces[ns] = namespaces.get(ns, 0) + 1
print('\n=== Namespace distribution ===')
for ns, count in sorted(namespaces.items(), key=lambda x: -x[1]):
print(f' {ns}: {count} keys')
# 找包含 .l数字.t 的机器生成 key
machine_keys = [k for k in full_keys.keys() if re.search(r'\.l\d+\.t\d+', k)]
print(f'\n=== Machine-generated keys (lXX.tY pattern): {len(machine_keys)} ===')
print(f' Percentage: {len(machine_keys)/len(full_keys)*100:.1f}%')
# 常见词重复统计
common_words = {
'确定': [],
'取消': [],
'保存': [],
'删除': [],
'确认': [],
'关闭': [],
'加载中': [],
'暂无相关数据': [],
'去逛逛': [],
'联系客服': [],
'立即购买': [],
'加入购物车': [],
'搜索': [],
}
for k, v in full_keys.items():
for word in common_words:
if v == word:
common_words[word].append(k)
print('\n=== Common word duplicates ===')
for word, keys in common_words.items():
if len(keys) > 1:
print(f' "{word}" -> {len(keys)} keys:')
for k in keys[:8]:
print(f' - {k}')
# 同值不同 key 统计
value_to_keys = {}
for k, v in full_keys.items():
if v and len(v) >= 2:
if v not in value_to_keys:
value_to_keys[v] = []
value_to_keys[v].append(k)
duplicate_values = {v: ks for v, ks in value_to_keys.items() if len(ks) > 1}
print(f'\n=== Total same-translation-different-key: {len(duplicate_values)} ===')
# Top 20 最多重复的翻译
print('\n=== Top 20 most duplicated translations ===')
sorted_dups = sorted(duplicate_values.items(), key=lambda x: -len(x[1]))[:20]
for v, ks in sorted_dups:
print(f' "{v}" ({len(ks)}x):')
for k in ks[:6]:
print(f' - {k}')
# 读取 en-us.js 对比
with open('lang/en-us.js', 'r', encoding='utf-8') as f:
en_content = f.read()
en_match = re.search(r'export const lang = \{(.*?)\n\};', en_content, re.DOTALL)
if en_match:
en_body = en_match.group(1)
en_lines = en_body.split('\n')
en_stack = []
en_full_keys = {}
for line in en_lines:
stripped = line.strip()
if not stripped:
continue
indent = len(line) - len(line.lstrip())
level = indent // 4 + 1
while len(en_stack) >= level:
en_stack.pop()
obj_match = re.match(r'"([^"]+)"\s*:\s*\{', stripped)
if obj_match:
en_stack.append(obj_match.group(1))
continue
val_match = re.match(r'"([^"]+)"\s*:\s*"([^"]*)"', stripped)
if val_match:
key = val_match.group(1)
value = val_match.group(2)
full_path = '.'.join(en_stack + [key])
en_full_keys[full_path] = value
print(f'\n=== en-us stats ===')
print(f' Total keys: {len(en_full_keys)}')
empty_en = sum(1 for v in en_full_keys.values() if not v)
print(f' Empty translations: {empty_en} ({empty_en/len(en_full_keys)*100:.1f}%)')
# 示例:语义不清的 key
print('\n=== Example machine keys (semantic-less) ===')
examples = machine_keys[:20]
for k in examples:
print(f' {k} -> "{full_keys[k]}"')
# 扁平 key 列表
flat_keys = [k for k in full_keys.keys() if k.count('.') == 2 and k.startswith('common.')]
# 不对,应该找只有一级的
top_level = [k for k in full_keys.keys() if '.' not in k]
print(f'\n=== Top-level keys (no namespace): {len(top_level)} ===')
for k in top_level[:30]:
print(f' {k}: "{full_keys[k]}"')

View File

@@ -1,76 +0,0 @@
#!/usr/bin/env python3
"""
分析各业务模块的 key,生成迁移映射表
"""
import os
import re
import json
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LANG_FILE = os.path.join(PROJECT_ROOT, 'lang', 'zh-cn.js')
def parse_lang_file(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
match = re.search(r'export const lang = \{(.*?)\n\};', content, re.DOTALL)
if not match:
return {}
body = match.group(1)
lines = body.split('\n')
stack = []
result = {}
for line in lines:
stripped = line.strip()
if not stripped:
continue
indent = len(line) - len(line.lstrip())
level = indent // 4 + 1
while len(stack) >= level:
stack.pop()
obj_match = re.match(r'"([^"]+)"\s*:\s*\{', stripped)
if obj_match:
stack.append(obj_match.group(1))
continue
val_match = re.match(r'"([^"]+)"\s*:\s*"([^"]*)"', stripped)
if val_match:
key = val_match.group(1)
value = val_match.group(2)
full_path = '.'.join(stack + [key])
result[full_path] = value
return result
def main():
data = parse_lang_file(LANG_FILE)
# 按一级命名空间分组
groups = {}
for k, v in data.items():
parts = k.split('.')
ns = parts[0]
if ns not in groups:
groups[ns] = []
groups[ns].append((k, v))
print("=" * 80)
print("各业务模块 key 数量统计")
print("=" * 80)
for ns, items in sorted(groups.items(), key=lambda x: -len(x[1])):
machine_count = sum(1 for k, v in items if re.search(r'\.l\d+\.t\d+', k))
print(f"\n{ns}: {len(items)} 个 key (机器生成: {machine_count})")
# 打印前 10 个示例
for k, v in items[:10]:
print(f" {k} -> \"{v}\"")
if len(items) > 10:
print(f" ... 还有 {len(items) - 10} 个")
# 特别关注 goods 相关的
print("\n" + "=" * 80)
print("GOODS 相关 key 详情")
print("=" * 80)
goods_keys = [(k, v) for k, v in data.items() if k.startswith('goods') or 'goods' in k.lower()]
print(f"共 {len(goods_keys)} 个 goods 相关 key")
for k, v in goods_keys[:30]:
print(f" {k} -> \"{v}\"")
if __name__ == '__main__':
main()

View File

@@ -1,214 +0,0 @@
#!/usr/bin/env python3
"""
i18n key 定位工具
用法:
python scripts/find_i18n_key.py <key_pattern> # 按 key 搜索调用位置
python scripts/find_i18n_key.py -v <value> # 按翻译值反向搜索 key
python scripts/find_i18n_key.py -f <file> # 列出某文件中使用的所有 key
python scripts/find_i18n_key.py --unused # 找出未被使用的 key
python scripts/find_i18n_key.py --duplicate # 找出同翻译不同 key 的重复项
"""
import os
import re
import sys
import json
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LANG_FILE = os.path.join(PROJECT_ROOT, 'lang', 'zh-cn.js')
SEARCH_DIRS = [
'common',
'components',
'components-diy',
'pages',
'pages_goods',
'pages_order',
'pages_promotion',
'pages_tool',
]
def parse_lang_file(filepath):
"""解析语言包文件,返回展平的 key-value 字典"""
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
match = re.search(r'export const lang = \{(.*?)\n\};', content, re.DOTALL)
if not match:
return {}
body = match.group(1)
lines = body.split('\n')
stack = []
result = {}
for line in lines:
stripped = line.strip()
if not stripped:
continue
indent = len(line) - len(line.lstrip())
level = indent // 4 + 1
while len(stack) >= level:
stack.pop()
obj_match = re.match(r'"([^"]+)"\s*:\s*\{', stripped)
if obj_match:
stack.append(obj_match.group(1))
continue
val_match = re.match(r'"([^"]+)"\s*:\s*"([^"]*)"', stripped)
if val_match:
key = val_match.group(1)
value = val_match.group(2)
full_path = '.'.join(stack + [key])
result[full_path] = value
return result
def find_key_usage(key_pattern):
"""搜索 key 在哪些文件中被使用"""
pattern = re.compile(rf'\$lang\([\'"]({re.escape(key_pattern)})[\'"]')
results = []
for dirname in SEARCH_DIRS:
dirpath = os.path.join(PROJECT_ROOT, dirname)
if not os.path.isdir(dirpath):
continue
for root, dirs, files in os.walk(dirpath):
for fname in files:
if not fname.endswith(('.vue', '.js')):
continue
fpath = os.path.join(root, fname)
try:
with open(fpath, 'r', encoding='utf-8') as f:
content = f.read()
for i, line in enumerate(content.split('\n'), 1):
if pattern.search(line):
rel_path = os.path.relpath(fpath, PROJECT_ROOT)
results.append((rel_path, i, line.strip()))
except:
pass
return results
def find_value_in_lang(value_substr, lang_data):
"""按翻译值反向搜索 key"""
results = []
for k, v in lang_data.items():
if value_substr in v:
results.append((k, v))
return results
def list_file_keys(filepath):
"""列出某个文件中使用的所有 $lang key"""
full_path = os.path.join(PROJECT_ROOT, filepath)
if not os.path.exists(full_path):
print(f"文件不存在: {filepath}")
return []
pattern = re.compile(r'\$lang\([\'"]([^\'"]+)[\'"]')
keys = []
with open(full_path, 'r', encoding='utf-8') as f:
for i, line in enumerate(f, 1):
m = pattern.findall(line)
for k in m:
keys.append((k, i))
return keys
def find_all_used_keys():
"""找出所有被使用的 key"""
pattern = re.compile(r'\$lang\([\'"]([^\'"]+)[\'"]')
used = set()
for dirname in SEARCH_DIRS:
dirpath = os.path.join(PROJECT_ROOT, dirname)
if not os.path.isdir(dirpath):
continue
for root, dirs, files in os.walk(dirpath):
for fname in files:
if not fname.endswith(('.vue', '.js')):
continue
fpath = os.path.join(root, fname)
try:
with open(fpath, 'r', encoding='utf-8') as f:
content = f.read()
matches = pattern.findall(content)
used.update(matches)
except:
pass
return used
def find_duplicate_values(lang_data):
"""找出同翻译不同 key 的重复项"""
value_to_keys = {}
for k, v in lang_data.items():
if v and len(v) >= 2:
if v not in value_to_keys:
value_to_keys[v] = []
value_to_keys[v].append(k)
return {v: ks for v, ks in value_to_keys.items() if len(ks) > 1}
def main():
if len(sys.argv) < 2:
print(__doc__)
return
lang_data = parse_lang_file(LANG_FILE)
if sys.argv[1] == '--unused':
used = find_all_used_keys()
all_keys = set(lang_data.keys())
unused = all_keys - used
print(f"总 key 数: {len(all_keys)}")
print(f"被使用的 key 数: {len(used)}")
print(f"未使用的 key 数: {len(unused)}")
print("\n未使用的 key:")
for k in sorted(unused)[:50]:
print(f" {k} -> \"{lang_data.get(k, '')}\"")
if len(unused) > 50:
print(f" ... 还有 {len(unused) - 50} 个")
return
if sys.argv[1] == '--duplicate':
dups = find_duplicate_values(lang_data)
print(f"同翻译不同 key 的组数: {len(dups)}")
print("\n重复最多的翻译 Top 20:")
sorted_dups = sorted(dups.items(), key=lambda x: -len(x[1]))[:20]
for v, ks in sorted_dups:
print(f'\n "{v}" ({len(ks)} 个 key):')
for k in ks[:8]:
print(f" - {k}")
return
if sys.argv[1] == '-f' and len(sys.argv) > 2:
filepath = sys.argv[2]
keys = list_file_keys(filepath)
print(f"文件 {filepath} 中使用的 key ({len(keys)} 个):")
for k, line in keys:
val = lang_data.get(k, '【未找到】')
print(f" L{line}: {k} -> \"{val}\"")
return
if sys.argv[1] == '-v' and len(sys.argv) > 2:
value = sys.argv[2]
results = find_value_in_lang(value, lang_data)
print(f"包含 \"{value}\" 的翻译 ({len(results)} 个):")
for k, v in results[:30]:
print(f" {k} -> \"{v}\"")
if len(results) > 30:
print(f" ... 还有 {len(results) - 30} 个")
return
# 默认:按 key 搜索
key = sys.argv[1]
usages = find_key_usage(key)
print(f'搜索 key: "{key}"')
print(f"找到 {len(usages)} 处使用:")
for fpath, line, content in usages[:30]:
print(f" {fpath}:{line} {content[:80]}")
if len(usages) > 30:
print(f" ... 还有 {len(usages) - 30} 处")
if __name__ == '__main__':
main()

View File

@@ -1,160 +0,0 @@
"""
i18n audit script - scans lang/ for duplicate keys + cross-references with call sites.
Run: python scripts/i18n_audit.py
"""
import os
import re
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# 自 2026-06-24 起:lang 包按业务域拆分到 lang/<locale>/<module>.js
# 运行时同时加载旧单文件 (lang/<locale>.js) 和新多文件 (lang/<locale>/index.js)
# 所以审计也要同时扫两边,否则会漏掉 60%+ 的 key
LANG_FILES = [ROOT / 'lang' / 'en-us.js', ROOT / 'lang' / 'zh-cn.js']
LANG_DIRS = [ROOT / 'lang' / 'en-us', ROOT / 'lang' / 'zh-cn']
# 跳过 lang/ 目录下的子目录扫描(已经被 LANG_DIRS 覆盖)
SKIP_LANG_SUBPATHS = True
SRC_GLOBS = ['**/*.vue', '**/*.js']
KEY_RE = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*[:=]')
CALL_RE = re.compile(r'\$lang\(\s*[\'"]([^\'"]+)[\'"]')
LANG_CALL_RE = re.compile(r'\$lang\(|\$t\(')
def walk_files(files):
"""扫所有 lang 源:旧单文件 + 新多文件聚合目录(lang/<locale>/<module>.js)
跳过 index.js 本身(它是聚合入口,导入的内容已被各模块文件覆盖)"""
out = []
for f in files:
if f.exists():
out.append(f)
for d in LANG_DIRS:
if not d.exists():
continue
for f in sorted(d.glob('*.js')):
# 跳过聚合入口,避免重复扫描(聚合内容 = 各模块 spread)
if f.name == 'index.js':
continue
out.append(f)
return out
def defined_keys(files):
"""Returns (per_file_keys, all_keys_set, in_file_dup, cross_file_dup).
Properly tracks nesting via indentation so 'common.activist' vs 'login.activist' are distinct keys."""
per_file = {}
in_file_dup = defaultdict(list)
key_to_files = defaultdict(set)
for f in files:
seen = set()
text = f.read_text(encoding='utf-8', errors='ignore')
indent_stack = [(-1, '')]
for ln in text.splitlines():
if ln.strip().startswith('//'):
continue
if not ln.strip():
continue
indent = len(ln) - len(ln.lstrip())
stripped = ln.lstrip()
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*[:=]\s*(.*)$""", stripped)
if not m:
continue
key, rest = m.group(1), m.group(2).strip().rstrip(',')
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent:
indent_stack.pop()
parent = indent_stack[-1][1]
full_key = f'{parent}.{key}' if parent else key
if rest.endswith('{'):
indent_stack.append((indent, full_key))
if rest.endswith('{'):
continue # 不把 object 容器当 key
if full_key in seen:
in_file_dup[str(f)].append(full_key)
seen.add(full_key)
key_to_files[full_key].add(str(f.relative_to(ROOT)))
per_file[str(f.relative_to(ROOT))] = seen
cross = {k: sorted(v) for k, v in key_to_files.items() if len(v) > 1}
return per_file, set(key_to_files.keys()), in_file_dup, cross
def called_keys():
used = set()
for f in ROOT.rglob('*'):
if not f.is_file():
continue
if not (str(f).endswith('.vue') or str(f).endswith('.js')):
continue
# 跳过整个 lang 目录(lang 文件不是调用方;它们的 key 在别处被消费)
if 'lang' in f.parts:
continue
if 'node_modules' in f.parts or 'unpackage' in f.parts:
continue
try:
text = f.read_text(encoding='utf-8')
except UnicodeDecodeError:
continue
if not LANG_CALL_RE.search(text):
continue
for m in CALL_RE.finditer(text):
used.add(m.group(1))
return used
def main():
files = walk_files(LANG_FILES)
print(f'Lang files scanned: {len(files)} (旧单文件 + 新多文件模块目录)')
print(f' - 旧单文件: lang/en-us.js + lang/zh-cn.js')
print(f' - 新多文件: lang/<locale>/<module>.js × {len(LANG_DIRS)} 个目录')
per_file, all_keys, in_file_dup, cross_dup = defined_keys(files)
used = called_keys()
print(f'\n=== Aggregate ===')
print(f' Defined keys (unique paths): {len(all_keys)}')
print(f' Called keys (unique paths): {len(used)}')
orphans_called = used - all_keys
orphans_defined = all_keys - used
print(f' Called but not defined (orphan calls): {len(orphans_called)}')
print(f' Defined but not called (dead keys): {len(orphans_defined)}')
print(f'\n=== In-file duplicates (top 30) ===')
flat = []
for f, ks in in_file_dup.items():
for k in ks:
flat.append((f, k))
for f, k in flat[:30]:
print(f' {f}: {k}')
print(f'\n=== Cross-file duplicates (defined in N files, top 40) ===')
cross_sorted = sorted(cross_dup.items(), key=lambda x: -len(x[1]))
for k, fs in cross_sorted[:40]:
print(f' {k} <- {len(fs)} files: {", ".join(fs[:3])}{" ..." if len(fs)>3 else ""}')
print(f'\n TOTAL cross-file duplicate keys: {len(cross_dup)}')
print('\n=== Orphan call keys (top 30 of %d) ===' % len(orphans_called))
for k in sorted(orphans_called)[:30]:
print(f' {k}')
print('\n=== Dead defined keys (top 30 of %d) ===' % len(orphans_defined))
for k in sorted(orphans_defined)[:30]:
print(f' {k}')
# Write machine-readable output
out = ROOT / 'docs' / 'i18n-audit.json'
out.parent.mkdir(exist_ok=True)
import json
out.write_text(json.dumps({
'defined_count': len(all_keys),
'called_count': len(used),
'orphan_calls': sorted(orphans_called),
'dead_keys': sorted(orphans_defined),
'in_file_dup': {f: ks for f, ks in in_file_dup.items()},
'cross_file_dup': cross_dup,
}, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'\nFull report -> {out.relative_to(ROOT)}')
if __name__ == '__main__':
main()

View File

@@ -1,169 +0,0 @@
"""
i18n_build_zhcn.py - 用真实中文 (docs/i18n-real-zh.json) 重建 lang/zh-cn.js
- key -> 真实中文 (来自 f258987 .vue 硬编码)
- 保留现有 pageTitles (用户说 pageTitles 用现在值即可)
- en-us 端留空 stub 保持对称
"""
import re
import json
import sys
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
EN_FILE = ROOT / 'lang' / 'en-us.js'
ZH_FILE = ROOT / 'lang' / 'zh-cn.js'
REAL_ZH = ROOT / 'docs' / 'i18n-real-zh.json'
EXISTING_ZH = ROOT / 'docs' / 'i18n-existing-zh.json'
def parse_existing_keys(path):
"""读现有 lang/<file>.js 的所有 key -> value 映射"""
if not path.exists():
return {}
text = path.read_bytes().decode('utf-8', errors='ignore')
out = {}
indent_stack = [(-1, '')]
for ln in text.split('\n'):
if ln.strip().startswith('//') or not ln.strip(): continue
indent = len(ln) - len(ln.lstrip())
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_.]*)['\"]?\s*:\s*(.*)$""", ln.lstrip())
if not m: continue
key, rest = m.group(1), m.group(2).strip().rstrip(',')
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent: indent_stack.pop()
parent = indent_stack[-1][1]
full_key = f'{parent}.{key}' if parent else key
if rest.endswith('{'): indent_stack.append((indent, full_key)); continue
v = rest
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
if len(v) >= 2: v = v[1:-1]
out[full_key] = v
return out
def write_lang(path, d, locale):
"""写带双引号 key 的 lang/<locale>.js, UTF-8"""
grouped = defaultdict(dict)
top_level = {}
for k, v in d.items():
parts = k.split('.')
if len(parts) == 1:
top_level[k] = v
else:
ns = parts[0]
sub = '.'.join(parts[1:])
grouped[ns][sub] = v
lines = [
f'// Auto-generated by i18n_build_zhcn.py',
f'// Single authoritative lang source. Do not edit manually.',
f'// locale: {locale}',
f'// Generated: 2026-06-23',
'',
'export const lang = {',
]
written = set()
nested_ns = {'pageTitles', 'common', 'tabBar', 'customer'}
for ns in sorted(set(grouped.keys()) | set(top_level.keys())):
if ns in nested_ns and ns in grouped:
lines.append(f'\t"{ns}": {{')
for sub_k in sorted(grouped[ns].keys()):
sub_v = grouped[ns][sub_k]
escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t\t"{sub_k}": "{escaped}",')
lines.append('\t},')
written.add(ns)
for ns in sorted(grouped.keys()):
if ns in written: continue
lines.append(f'\t"{ns}": {{')
for sub_k in sorted(grouped[ns].keys()):
sub_v = grouped[ns][sub_k]
escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t\t"{sub_k}": "{escaped}",')
lines.append('\t},')
written.add(ns)
for k in sorted(top_level.keys()):
if k in written: continue
v = top_level[k]
escaped = v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t"{k}": "{escaped}",')
lines.append('};')
lines.append('')
content = '\n'.join(lines)
path.write_bytes(content.encode('utf-8'))
def main():
# 1. 真实中文 (从 f258987 .vue 硬编码提取)
real_zh = json.loads(REAL_ZH.read_text(encoding='utf-8'))
print(f'真实中文: {len(real_zh)} 条')
# 2. 现有 pageTitles 保留 (用户要求)
# 解析当前 zh-cn.js, 取 pageTitles 部分
current_zh = parse_existing_keys(ZH_FILE)
page_titles = {k: v for k, v in current_zh.items() if k.startswith('pageTitles.')}
print(f'pageTitles 保留: {len(page_titles)} 条')
# 3. 现有 en-us 端英文 (用作 en-us 基础, 保留已有的英文翻译)
current_en = parse_existing_keys(EN_FILE)
print(f'现有 en-us keys: {len(current_en)}')
# 4. 构造新的 zh-cn
new_zh = {}
# 先 pageTitles
for k, v in page_titles.items():
new_zh[k] = v
# 再真实中文 (新 key) - 来自 f258987 硬编码
for text, new_key in real_zh.items():
new_zh[new_key] = text
# 再补充: f258987 时 .vue 已有的 $lang 调用 (key 直接用原始 key)
if EXISTING_ZH.exists():
existing = json.loads(EXISTING_ZH.read_text(encoding='utf-8'))
for k, v in existing.items():
if k not in new_zh:
new_zh[k] = v
print(f'已补 {len(existing)} 个 f258987 已有的 $lang 值')
# 保留现有 zh-cn 里非 pageTitles 的 key
for k, v in current_zh.items():
if k.startswith('pageTitles.'): continue
if k not in new_zh:
new_zh[k] = v
print(f'new zh-cn keys: {len(new_zh)}')
# 5. 构造新的 en-us: 保留已有, 缺中文对应的留空
new_en = {}
for k, v in current_en.items():
if k.startswith('pageTitles.'): continue
new_en[k] = v
# 真实中文对应的 key, 英文侧: 优先保留已有, 缺则空
for new_key in real_zh.values():
if new_key not in new_en:
new_en[new_key] = ''
# 已有 $lang 对应的 key 留空 (中文侧已有, en 端缺)
if EXISTING_ZH.exists():
existing = json.loads(EXISTING_ZH.read_text(encoding='utf-8'))
for k in existing.keys():
if k not in new_en:
new_en[k] = ''
# pageTitles 保留
for k, v in page_titles.items():
new_en[k] = v
print(f'new en-us keys: {len(new_en)}')
# 6. 写
apply = '--apply' in sys.argv
if not apply:
print('\n[DRY-RUN] 加 --apply 真写文件')
return 0
write_lang(ZH_FILE, new_zh, 'zh-cn')
print(f'[OK] {ZH_FILE.relative_to(ROOT)}: {len(new_zh)} keys')
write_lang(EN_FILE, new_en, 'en-us')
print(f'[OK] {EN_FILE.relative_to(ROOT)}: {len(new_en)} keys')
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,17 +0,0 @@
@echo off
REM i18n_check.bat - Windows 一键审计(等价于 npm run i18n:check)
setlocal
cd /d "%~dp0\.."
python scripts\i18n_check.py %*
if %ERRORLEVEL% NEQ 0 (
echo.
echo [FAIL] i18n audits failed - see docs\i18n-*-audit.json
exit /b 1
)
echo.
echo [OK] i18n audits passed
exit /b 0

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env python3
"""
i18n_check.py - i18n 审计一键入口
用法:
python scripts/i18n_check.py # 全部审计(audit + strict + class_c)
python scripts/i18n_check.py basic # 只跑基础 audit
python scripts/i18n_check.py strict # 跑 strict + class_c
python scripts/i18n_check.py shadow # 只跑 class_c(page 覆盖 common)
python scripts/i18n_check.py clean # 清理死 key(带 --apply 才会真删)
退出码:
0 = 通过
非 0 = 失败
设计为可以直接被 git hook / CI / 本地命令行任意调用。
"""
import sys
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCRIPTS = {
'basic': 'scripts/i18n_audit.py',
'strict': 'scripts/i18n_strict_audit.py',
'shadow': 'scripts/i18n_class_c.py',
'symmetry': 'scripts/i18n_check_symmetry.py',
'naming': 'scripts/i18n_check_naming.py',
}
def run(script_name):
"""Run a python audit script, return its exit code"""
script_path = ROOT / SCRIPTS[script_name]
if not script_path.exists():
print(f'[ERROR] {script_path} not found', file=sys.stderr)
return 1
print(f'\n>> Running {script_name}: {script_path.relative_to(ROOT)}')
result = subprocess.run(
['python', str(script_path)],
cwd=str(ROOT),
)
return result.returncode
def main():
args = sys.argv[1:]
if not args or args[0] == 'all':
# Run all audits
rc = 0
for name in ['basic', 'symmetry', 'strict', 'shadow', 'naming']:
if run(name) != 0:
rc = 1
print()
if rc == 0:
print('[OK] All i18n audits passed')
else:
print('[FAIL] i18n audits reported issues - see docs/i18n-*-audit.json')
return rc
if args[0] in SCRIPTS:
return run(args[0])
print(__doc__)
return 1
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,15 +0,0 @@
#!/usr/bin/env bash
# i18n_check.sh - bash 一键审计(等价于 npm run i18n:check)
set -e
cd "$(dirname "$0")/.."
python scripts/i18n_check.py "$@"
if [ $? -ne 0 ]; then
echo ""
echo "[FAIL] i18n audits failed - see docs/i18n-*-audit.json"
exit 1
fi
echo ""
echo "[OK] i18n audits passed"

View File

@@ -1,257 +0,0 @@
#!/usr/bin/env python3
"""
i18n_check_naming.py - 检测 i18n key 命名规范违规
检测 4 类问题:
A. 中文 key(key 段是中文,违反 CLAUDE.md 1.2 铁律)
B. 扁平 key(无业务域前缀,如 `save` / `buy`,违反 1.2)
C. 行号式 locational key(如 `chat-message.l60.t0`,违反目标 4 长期维护)
D. 跨业务域同名(不同业务域下定义相同语义路径,应收敛到 common)
退出码:0 = 通过 / 非 0 = 有违规
用法:
python scripts/i18n_check_naming.py
python scripts/i18n_check_naming.py --strict # 任何 1 个违规即返回非 0
"""
import re
import sys
import json
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
# 同时扫旧单文件 + 新多文件
LANG_FILES = [ROOT / 'lang' / 'en-us.js', ROOT / 'lang' / 'zh-cn.js']
LANG_DIRS = [ROOT / 'lang' / 'en-us', ROOT / 'lang' / 'zh-cn']
# 业务域白名单(一级 namespace)
NAMESPACES = {
'common', 'tabBar', 'customer', 'goods', 'cart', 'order', 'pay',
'member', 'coupon', 'point', 'hongbao', 'fenxiao', 'merch',
'login', 'register', 'find', 'article', 'topics', 'notice', 'help',
'chat', 'ai_chat', 'verification', 'recharge', 'diy',
'bargain', 'pintuan', 'groupbuy', 'seckill', 'combo', 'presale', 'bale',
'seal', 'storeclose', 'store', 'index', 'mall', 'public', 'vr',
'pageTitles', 'title',
# 历史命名空间(保留识别,不再推荐)
'img', 'photo', 'share', 'nav', 'tab',
}
# 已知特殊顶层 key(不需要业务域前缀的常量/函数名等)
TOP_LEVEL_ALLOWLIST = {
'currencySymbol', 'defaultLang', 'langList',
'title', # 兼容旧的根 title key
}
# 检测中文 key 段(含中文的正则)
CHINESE_RE = re.compile(r'[一-鿿]')
# 检测行号式 locational key(如 chat-message.l60.t0 或 ns-goods-sku.l100.t0)
LOCATIONAL_RE = re.compile(r'\.l\d+\.t\d+$')
# 检测合理 snake_case 段(仅允许英文数字下划线)
SNAKE_CASE_RE = re.compile(r'^[a-z][a-z0-9_]*$')
def parse_lang_file(path):
"""解析 lang 文件,返回 [(full_key, value, line_no), ...],支持嵌套 + 中文 key"""
items = []
text = path.read_bytes().decode('utf-8', errors='ignore')
stack = []
indent_stack = [-1]
for i, ln in enumerate(text.split('\n'), 1):
stripped = ln.lstrip()
if not stripped or stripped.startswith('//'):
continue
indent = len(ln) - len(stripped)
while len(indent_stack) > 1 and indent <= indent_stack[-1]:
indent_stack.pop()
stack.pop()
# 允许 key 含中文(含中文命名空间或纯中文 key 都需要检测出来)
m = re.match(r"""^['\"]?([\w一-鿿][\w一-鿿\.]*)['\"]?\s*:\s*(.*)$""", stripped)
if not m:
continue
key, rest = m.group(1), m.group(2).rstrip(',').rstrip()
full_key = '.'.join(stack + [key]) if stack else key
if rest.endswith('{'):
stack.append(key)
indent_stack.append(indent)
items.append((full_key, '<object>', i))
else:
v = rest.strip()
if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')):
if len(v) >= 2:
v = v[1:-1]
items.append((full_key, v, i))
return items
def collect_all_keys():
"""收集所有 lang 文件里的 (key, value, file, line)"""
out = []
sources = list(LANG_FILES)
for d in LANG_DIRS:
if d.exists():
sources.extend(sorted(d.glob('*.js')))
for f in sources:
if not f.exists():
continue
if f.name == 'index.js':
continue
for k, v, ln in parse_lang_file(f):
out.append((k, v, str(f.relative_to(ROOT)), ln))
return out
def detect_chinese_keys(items):
"""A. key 段含中文"""
issues = []
for k, v, f, ln in items:
if v == '<object>':
continue
for seg in k.split('.'):
if CHINESE_RE.search(seg):
issues.append({
'key': k, 'segment': seg, 'file': f, 'line': ln,
'reason': 'key 段含中文(违反 CLAUDE.md 1.2 铁律)',
})
break
return issues
def detect_flat_keys(items):
"""B. 扁平 key - 一级不是业务域"""
issues = []
seen_top = set()
for k, v, f, ln in items:
if v == '<object>':
continue
if '.' not in k:
if k in TOP_LEVEL_ALLOWLIST:
continue
if k in NAMESPACES:
continue
if k in seen_top:
continue
seen_top.add(k)
issues.append({
'key': k, 'file': f, 'line': ln,
'reason': f'扁平 key 无业务域前缀(顶层 namespace 不在白名单)',
})
return issues
def detect_locational_keys(items):
"""C. 行号式 locational key"""
issues = []
for k, v, f, ln in items:
if v == '<object>':
continue
if LOCATIONAL_RE.search(k):
issues.append({
'key': k, 'file': f, 'line': ln,
'reason': '行号式 locational key(如 chat-message.l60.t0),文件名/行号一变就失效(违反长期维护目标 4)',
})
return issues
def detect_cross_namespace_duplicates(items):
"""D. 不同业务域下定义相同语义路径(应收敛到 common)"""
# 收集所有"leaf 路径",按尾部 2 段分组
by_tail = defaultdict(list)
for k, v, f, ln in items:
if v == '<object>':
continue
parts = k.split('.')
if len(parts) < 3:
continue
tail = '.'.join(parts[-2:])
by_tail[tail].append((k, f, ln))
issues = []
for tail, occurrences in by_tail.items():
# 找出这些 key 的 namespace 各不相同
namespaces = set()
for k, _, _ in occurrences:
ns = k.split('.')[0]
namespaces.add(ns)
if len(namespaces) < 2:
continue
# 排除 common.* 自身
namespaces.discard('common')
if len(namespaces) < 2:
continue
for k, f, ln in occurrences:
issues.append({
'key': k, 'file': f, 'line': ln,
'reason': f'跨业务域同名 tail="{tail}",出现在 {sorted(namespaces)}(应收敛到 common)',
})
return issues
def detect_non_snake_case(items):
"""E. key 段不是 snake_case(含大写、连字符等)"""
issues = []
for k, v, f, ln in items:
if v == '<object>':
continue
for seg in k.split('.'):
if not SNAKE_CASE_RE.match(seg):
# 跳过纯数字段(如枚举值)
if seg.isdigit():
continue
issues.append({
'key': k, 'segment': seg, 'file': f, 'line': ln,
'reason': f'key 段 "{seg}" 不是 snake_case(必须小写字母开头 + 小写字母/数字/下划线)',
})
break
return issues
def main():
items = collect_all_keys()
print(f'扫描 {len(items)} 条 key/value 对...\n')
groups = [
('A. 中文 key', detect_chinese_keys(items)),
('B. 扁平 key', detect_flat_keys(items)),
('C. 行号式 locational key', detect_locational_keys(items)),
('D. 跨业务域同名 tail', detect_cross_namespace_duplicates(items)),
('E. 非 snake_case key 段', detect_non_snake_case(items)),
]
total = 0
for label, issues in groups:
n = len(issues)
total += n
marker = '[FAIL]' if n > 0 else '[OK]'
print(f'{marker} {label}: {n}')
if n > 0 and n <= 20:
for it in issues:
print(f' {it["file"]}:{it["line"]} {it["key"]} -- {it["reason"]}')
elif n > 20:
for it in issues[:10]:
print(f' {it["file"]}:{it["line"]} {it["key"]} -- {it["reason"]}')
print(f' ... 省略 {n - 10} 条')
print()
# 写报告
out = ROOT / 'docs' / 'i18n-naming-audit.json'
out.parent.mkdir(exist_ok=True)
out.write_text(json.dumps({
'total_issues': total,
'groups': {label.split('. ', 1)[1]: issues for label, issues in groups},
}, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'Full report -> {out.relative_to(ROOT)}')
if total > 0:
print(f'\n[FAIL] i18n 命名规范违规 {total} 处')
return 1
print('\n[OK] 所有 key 符合命名规范')
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,84 +0,0 @@
"""
i18n_check_symmetry.py - 检测 lang/en-us.js 与 lang/zh-cn.js key 是否对称
规则:
- 两侧 key 集合必须完全一致(一对一)
- 任意一侧缺失即报错
为什么需要:
- 历史上 zh-cn.js 的 member 块丢了 11 个 key(activist/cancellation/completed/logout 等)
导致运行时 $lang('member.waitpay') 显示 raw key 而非"待付款"
- 这个 bug 之前的 audit 抓不到(audit 只看"是否存在",不对比两侧)
- 这个脚本专门防止此类"单侧缺失"
退出码:0 = 对称 / 非 0 = 不对称
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
EN_FILE = ROOT / 'lang' / 'en-us.js'
ZH_FILE = ROOT / 'lang' / 'zh-cn.js'
def parse_keys(path):
if not path.exists():
return {}
# Both files are UTF-8 (verified: all 281 zh-cn source files are UTF-8)
raw = path.read_bytes()
text = raw.decode('utf-8', errors='ignore')
out = {}
indent_stack = [(-1, '')]
for ln in text.split('\n'):
if ln.strip().startswith('//') or not ln.strip():
continue
indent = len(ln) - len(ln.lstrip())
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*:\s*(.*)$""", ln.lstrip())
if not m:
continue
key, rest = m.group(1), m.group(2).strip().rstrip(',')
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent:
indent_stack.pop()
parent = indent_stack[-1][1]
full_key = f'{parent}.{key}' if parent else key
if rest.endswith('{'):
indent_stack.append((indent, full_key))
continue
out[full_key] = True
return out
def main():
en = parse_keys(EN_FILE)
zh = parse_keys(ZH_FILE)
print(f'en-us: {len(en)} keys')
print(f'zh-cn: {len(zh)} keys')
only_en = sorted(set(en) - set(zh))
only_zh = sorted(set(zh) - set(en))
if not only_en and not only_zh:
print()
print('[OK] en-us 和 zh-cn 完全对称(key 集合一致)')
return 0
print()
if only_en:
print(f'[FAIL] 缺 zh-cn 翻译(只在 en-us 中存在,{len(only_en)} 个):')
for k in only_en:
print(f' - {k}')
if only_zh:
print(f'[FAIL] 缺 en-us 翻译(只在 zh-cn 中存在,{len(only_zh)} 个):')
for k in only_zh:
print(f' - {k}')
print()
print('Fix: 在缺失侧补上对应 key 与翻译。')
print(' 自查可用: python scripts/i18n_check.py basic')
return 1
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,165 +0,0 @@
"""
i18n_clean_dead.py - 从 lang/en-us.js + lang/zh-cn.js 删除未被任何代码调用的死 key
设计:
- 解析 lang/<locale>.js 的全部 keys(带嵌套追踪)
- 扫描所有 .vue / .js(非 lang/、非 node_modules)的 $lang('xxx') / $lang("xxx") / this.$lang('xxx')
- 计算 dead keys = defined - used
- 对每个 dead key:
- 如果是嵌套对象内的叶子值(字符串),从父对象里删
- 如果是嵌套对象的最后一个子项,整个对象也删(如果是空对象了)
- 同步删除 en-us 和 zh-cn 两侧
退出码:
0 = 成功
非 0 = 有 dead key 列出但未删(dry-run 默认)
--apply 才会真删
"""
import re
import json
import sys
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
EN_FILE = ROOT / 'lang' / 'en-us.js'
ZH_FILE = ROOT / 'lang' / 'zh-cn.js'
EXCLUDE_DIRS = {'node_modules', 'unpackage', 'stub-app-projects', '.git', 'lang'}
def parse_keys_with_lines(path):
"""Parse lang file with nesting + line tracking.
Returns: dict of full_key -> (value, line_no, indent, parent_path)
"""
text = path.read_text(encoding='utf-8')
out = {}
indent_stack = [(-1, '', -1)] # (indent, parent_path, parent_line)
lines = text.split('\n')
for i, ln in enumerate(lines, 1):
stripped = ln.lstrip()
if stripped.startswith('//') or not stripped.strip():
continue
indent = len(ln) - len(stripped)
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*:\s*(.*)$""", stripped)
if not m:
continue
key, rest = m.group(1), m.group(2).strip().rstrip(',')
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent:
indent_stack.pop()
parent_path = indent_stack[-1][1]
full_key = f'{parent_path}.{key}' if parent_path else key
if rest.endswith('{'):
indent_stack.append((indent, full_key, i))
out[full_key] = ('<object>', i, indent, parent_path)
else:
v = rest
if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')):
if len(v) >= 2:
v = v[1:-1]
out[full_key] = (v, i, indent, parent_path)
return out
def collect_used_keys():
"""Scan all .vue / .js files for $lang('xxx') / this.$lang('xxx') calls"""
used = set()
call_re = re.compile(r"\$lang\(\s*['\"]([^'\"]+)['\"]")
this_re = re.compile(r"this\.\$lang\(\s*['\"]([^'\"]+)['\"]")
for p in list(ROOT.rglob('*.vue')) + list(ROOT.rglob('*.js')):
if any(d in p.parts for d in EXCLUDE_DIRS):
continue
if p.name.startswith('i18n_') or p.name.startswith('_verify') or p.name.startswith('_find'):
continue
try:
text = p.read_text(encoding='utf-8')
except Exception:
continue
if '$lang' not in text:
continue
# strip comments to avoid false positives
text_no_comments = re.sub(r'//.*?$|/\*.*?\*/', '', text, flags=re.DOTALL | re.MULTILINE)
for m in call_re.finditer(text_no_comments):
used.add(m.group(1))
for m in this_re.finditer(text_no_comments):
used.add(m.group(1))
return used
def remove_dead_keys_from_file(path, dead_keys):
"""从 lang 文件删除 dead_keys。返回 (new_text, removed_count)"""
text = path.read_text(encoding='utf-8')
lines = text.split('\n')
# Build line removal set
lines_to_remove = set()
removed = 0
# Parse again with full info
keys_info = parse_keys_with_lines(path)
for dead_key in dead_keys:
if dead_key in keys_info:
val, line_no, indent, parent = keys_info[dead_key]
lines_to_remove.add(line_no - 1) # 0-indexed
removed += 1
new_lines = [ln for i, ln in enumerate(lines) if i not in lines_to_remove]
return '\n'.join(new_lines), removed
def main():
apply = '--apply' in sys.argv
en_keys = parse_keys_with_lines(EN_FILE)
zh_keys = parse_keys_with_lines(ZH_FILE)
used = collect_used_keys()
en_defined = {k for k, v in en_keys.items() if v[0] != '<object>'}
zh_defined = {k for k, v in zh_keys.items() if v[0] != '<object>'}
all_defined = en_defined | zh_defined
dead = sorted(all_defined - used)
print(f'en-us defined: {len(en_defined)}')
print(f'zh-cn defined: {len(zh_defined)}')
print(f'unique used: {len(used)}')
print(f'dead keys: {len(dead)}')
print()
if not dead:
print('[OK] No dead keys to remove')
return 0
print('Dead keys to remove:')
for k in dead[:50]:
print(f' - {k}')
if len(dead) > 50:
print(f' ... and {len(dead) - 50} more')
if not apply:
print('\n[DRY-RUN] 未实际删除文件。加 --apply 参数真正删除。')
return 1
# 真删
new_en, removed_en = remove_dead_keys_from_file(EN_FILE, dead)
new_zh, removed_zh = remove_dead_keys_from_file(ZH_FILE, dead)
EN_FILE.write_text(new_en, encoding='utf-8')
ZH_FILE.write_text(new_zh, encoding='utf-8')
print(f'\n[OK] en-us: removed {removed_en} keys')
print(f'[OK] zh-cn: removed {removed_zh} keys')
# 写报告
out = ROOT / 'docs' / 'i18n-clean-dead.log'
out.write_text(
f'en-us removed: {removed_en}\n'
f'zh-cn removed: {removed_zh}\n\n'
'Removed keys:\n' + '\n'.join(dead),
encoding='utf-8'
)
print(f'[OK] Log -> {out.relative_to(ROOT)}')
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,57 +0,0 @@
"""
Extend migration-map.json to cover all flat-name keys still in use.
After extension, run: python scripts/i18n_rewrite_calls.py --apply
"""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MAP_FILE = ROOT / 'lang' / '_migration-map.json'
# Manual mappings for flat-name keys that should resolve to common.*
# (from the orphan scan output)
EXTRA_MAPPINGS = {
'toGoodsCategoryPage': 'common.toGoodsCategoryPage',
'toLogin': 'common.toLogin',
'buy': 'common.buy',
'consult': 'common.consult',
'name': 'common.name',
'getDynacode': 'common.getDynacode',
'xxx': 'common.xxx',
'login': 'common.login',
'loginTips': 'common.loginTips',
'Search': 'common.searchTip',
'Sales': 'common.goodsRecommendTitle', # Search -> searchTip; Sales was used as 商品推荐
'share': 'common.share',
'submit': 'common.submit',
}
# 上述 mapping 中 Sales 比较可疑,跳过,保留为 orphan 让用户决定
DELIBERATE_OMIT = {'Sales', 'xxx'}
data = json.loads(MAP_FILE.read_text(encoding='utf-8'))
existing = data.get('old_to_new', {})
added = 0
for old, new in EXTRA_MAPPINGS.items():
if old in DELIBERATE_OMIT:
continue
if old not in existing:
existing[old] = new
added += 1
data['old_to_new'] = existing
data['note'] = (
'旧 key → 新 key 映射。调用侧 $lang("oldKey") 必须改为 $lang("newKey")\n'
'自 2026-06-23 起:所有 key 必须在 lang/en-us.js + lang/zh-cn.js 两份文件中定义。\n'
'old key 不允许再加新定义;如需新增请用 common.<key> 或 <namespace>.<sub>.<key> 命名空间前缀。'
)
MAP_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'Added {added} new mappings to migration map')
print(f'Total mappings: {len(existing)}')
print(f'\nNew mappings:')
for old, new in EXTRA_MAPPINGS.items():
if old in DELIBERATE_OMIT:
continue
if old in existing:
print(f' {old} -> {new}')

View File

@@ -1,153 +0,0 @@
"""
i18n_extract_existing.py - 提取 f258987 时 .vue 里已有的 $lang 调用对应的 lang 值
f258987 时 .vue 有两种 i18n 来源:
1. 硬编码中文 (已提取 1390 个)
2. 已存在的 $lang('xxx') 调用 (key 在 lang/zh-cn/<dir>/<file>.js 中定义)
这次提取 (2): 找 .vue 里的 $lang('xxx') 调用, 查 git f258987 的 lang 文件拿到值
"""
import re
import json
import subprocess
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
SOURCE_REF = 'f258987'
USER_DIRS = ['components', 'components-diy', 'pages', 'pages_goods',
'pages_order', 'pages_promotion', 'pages_tool', 'store']
LANG_CALL_RE = re.compile(r"""\$\s*lang\s*\(\s*['"]([^'"]+)['"]\s*\)""")
THIS_LANG_RE = re.compile(r"""this\s*\.\s*\$\s*lang\s*\(\s*['"]([^'"]+)['"]\s*\)""")
def file_to_namespace(file_path):
parts = file_path.replace('\\', '/').split('/')
if len(parts) < 2: return 'common'
dir_name = parts[0]
if dir_name in ('pages_goods', 'pages_order'):
return dir_name.replace('pages_', '')
if dir_name == 'pages_promotion':
if len(parts) >= 2:
return {'point': 'point', 'fenxiao': 'fenxiao', 'merch': 'merch'}.get(parts[1], 'promotion')
return 'promotion'
if dir_name == 'pages_tool':
if len(parts) >= 2:
return {'login': 'login', 'find': 'find', 'member': 'member', 'contact': 'contact',
'ai-chat': 'ai_chat', 'article': 'article', 'topics': 'topics',
'notice': 'notice', 'help': 'help', 'seal': 'seal',
'verification': 'verification'}.get(parts[1], 'tool')
return 'tool'
if dir_name == 'components-diy': return 'diy'
if dir_name == 'components': return 'common'
if dir_name == 'store': return 'common'
return 'common'
def list_git_files(prefix):
"""git ls-tree 列出 prefix 下所有 .js"""
r = subprocess.run(
['git', 'ls-tree', '-r', '--name-only', SOURCE_REF, f'lang/{prefix}/'],
cwd=str(ROOT), capture_output=True, text=True, timeout=10,
)
return [ln.strip() for ln in r.stdout.split('\n') if ln.strip().endswith('.js')]
def parse_lang_file(path):
"""Parse lang/<locale>/<dir>/<file>.js"""
r = subprocess.run(
['git', 'show', f'{SOURCE_REF}:{path}'],
cwd=str(ROOT), capture_output=True, timeout=10,
)
if r.returncode != 0:
return {}
text = r.stdout.decode('utf-8', errors='ignore')
out = {}
indent_stack = [(-1, '')]
for ln in text.split('\n'):
if ln.strip().startswith('//') or not ln.strip(): continue
indent = len(ln) - len(ln.lstrip())
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*:\s*(.*)$""", ln.lstrip())
if not m: continue
key, rest = m.group(1), m.group(2).strip().rstrip(',')
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent: indent_stack.pop()
parent = indent_stack[-1][1]
full_key = f'{parent}.{key}' if parent else key
if rest.endswith('{'): indent_stack.append((indent, full_key)); continue
v = rest
if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')):
if len(v) >= 2: v = v[1:-1]
out[full_key] = v
return out
def main():
# 1. 收集所有 .vue/.js 在 f258987 时调用的 key (已知 .vue)
# 找每个 .vue 调用的 key
files = []
for d in USER_DIRS:
base = ROOT / d
if not base.exists(): continue
for p in sorted(base.rglob('*.vue')):
files.append(p.relative_to(ROOT).as_posix())
for p in sorted(base.rglob('*.js')):
files.append(p.relative_to(ROOT).as_posix())
used_keys = set()
for f in files:
r = subprocess.run(
['git', 'show', f'{SOURCE_REF}:{f}'],
cwd=str(ROOT), capture_output=True, timeout=10,
)
if r.returncode != 0: continue
text = r.stdout.decode('utf-8', errors='ignore')
for m in LANG_CALL_RE.finditer(text):
used_keys.add(m.group(1))
for m in THIS_LANG_RE.finditer(text):
used_keys.add(m.group(1))
print(f'f258987 时 .vue 调用的 key: {len(used_keys)}')
# 2. 加载所有 lang 文件
all_lang = {}
for prefix in ['zh-cn', 'en-us']:
for path in list_git_files(prefix):
parsed = parse_lang_file(path)
for k, v in parsed.items():
if v != '<object>':
# namespace: 旧 lang 是 lang/zh-cn/<dir>/<file>.js
# key 已经是完整路径 (parse 已经处理嵌套)
all_lang[k] = v
print(f'lang 文件总 key: {len(all_lang)}')
# 3. used keys 中, 哪些在 lang 文件里有值 (说明是已有的 $lang 调用)
found = {k: all_lang[k] for k in used_keys if k in all_lang}
print(f'f258987 已有 $lang 值: {len(found)}')
# 4. 输出: 这些 key + 真实中文 (从 lang/zh-cn 文件)
zh_lang = {}
en_lang = {}
for path in list_git_files('zh-cn'):
zh_lang.update(parse_lang_file(path))
for path in list_git_files('en-us'):
en_lang.update(parse_lang_file(path))
result_zh = {}
for k in used_keys:
if k in zh_lang and zh_lang[k]:
result_zh[k] = zh_lang[k]
print(f'可补充的中文: {len(result_zh)}')
# 写
out = ROOT / 'docs' / 'i18n-existing-zh.json'
out.write_text(json.dumps(result_zh, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'[OK] -> {out.relative_to(ROOT)}')
print('\n=== 抽样 ===')
for k, v in list(result_zh.items())[:10]:
cps = [hex(ord(c)) for c in v]
print(f' {k}: codepoints={cps}')
if __name__ == '__main__':
main()

View File

@@ -1,189 +0,0 @@
"""
i18n_extract_from_vue.py - 用 git diff 精确提取 f258987 -> current 的中文→key 替换对
原理:
- f258987 有硬编码中文
- 当前是我后续回填的 $lang('xxx')
- git diff 行级 diff 能精确显示 "原中文" 被 "新 key" 替换
- 解析这些 -/+ 行对,得到 (中文, key) 映射
只扫描用户指定目录:components, components-diy, pages, pages_goods, pages_order, pages_promotion, pages_tool, store
"""
import re
import subprocess
import json
import sys
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
SOURCE_REF = 'f258987'
USER_DIRS = ['components', 'components-diy', 'pages', 'pages_goods',
'pages_order', 'pages_promotion', 'pages_tool', 'store']
def list_user_files():
out = []
for d in USER_DIRS:
base = ROOT / d
if not base.exists():
continue
for p in base.rglob('*.vue'):
out.append(p.relative_to(ROOT).as_posix())
for p in base.rglob('*.js'):
out.append(p.relative_to(ROOT).as_posix())
return sorted(out)
# 匹配 $lang('xxx') / this.$lang('xxx') / "{{ $lang('xxx') }}"
LANG_RE = re.compile(r"""\$\s*lang(?:Config)?\s*\.\s*title\s*\(\s*['"]?[^'")]+['"]?\)""")
SIMPLE_LANG_RE = re.compile(r"""\$\s*lang\s*\(\s*['"]([^'"]+)['"]\s*\)""")
THIS_LANG_RE = re.compile(r"""this\s*\.\s*\$\s*lang\s*\(\s*['"]([^'"]+)['"]\s*\)""")
# 中文字符串提取(用于原 - 行)
CN_QUOTED_RE = re.compile(r"""['"`]([^'"`\n]*[一-鿿][^'"`\n]*)['"`]""")
CN_TAG_RE = re.compile(r'>([^<>]{2,}[一-鿿][^<>]*)<')
CHINESE_CHAR_RE = re.compile(r'[一-鿿]')
def get_diff_lines(file):
"""Get pre-f258987 (with $lang) and f258987 (with hardcoded) content"""
import difflib
# OLD = pre-f258987 (i18n with $lang calls - this has the keys)
r = subprocess.run(
['git', 'show', f'{SOURCE_REF}^:{file}'],
cwd=str(ROOT), capture_output=True, timeout=10,
)
if r.returncode != 0:
return [], []
old_text = r.stdout.decode('utf-8', errors='ignore')
old_lines = old_text.splitlines()
# NEW = f258987 (hardcoded Chinese - this has the values)
r = subprocess.run(
['git', 'show', f'{SOURCE_REF}:{file}'],
cwd=str(ROOT), capture_output=True, timeout=10,
)
if r.returncode != 0:
return old_lines, []
new_text = r.stdout.decode('utf-8', errors='ignore')
new_lines = new_text.splitlines()
return old_lines, new_lines
def extract_pairs_from_difflib(file):
"""从 pre-f258987 vs f258987 提取 (key, value) 对
- pre-f258987 行: $lang('key')
- f258987 行: 硬编码中文 value
"""
import difflib
old_lines, new_lines = get_diff_lines(file)
if not old_lines or not new_lines:
return []
sm = difflib.SequenceMatcher(None, old_lines, new_lines)
pairs = []
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == 'replace':
old_block = old_lines[i1:i2]
new_block = new_lines[j1:j2]
# 旧行收集 keys
old_keys = []
for ln in old_block:
for k in extract_keys(ln):
old_keys.append(k)
# 新行收集中文 values
new_cn = []
for ln in new_block:
cn = extract_chinese(ln)
if cn:
new_cn.append(cn)
# 一一对应
for idx, k in enumerate(old_keys):
if idx < len(new_cn):
pairs.append((k, new_cn[idx]))
elif new_cn:
pairs.append((k, new_cn[0]))
return pairs
def extract_pairs_from_diff(diff_text):
"""从 git diff 文本提取 (原中文, 新key) 对"""
pairs = []
lines = diff_text.split('\n')
i = 0
while i < len(lines):
ln = lines[i]
if ln.startswith('-') and not ln.startswith('---'):
# 原行
old_content = ln[1:] # 去掉前导 -
old_cn = extract_chinese(old_content)
# 找接下来的 + 行
j = i + 1
while j < len(lines) and not (lines[j].startswith('+') and not lines[j].startswith('+++')):
j += 1
if j < len(lines):
new_content = lines[j][1:]
# 从 new 行提取 key
keys = extract_keys(new_content)
if old_cn and keys:
for k in keys:
pairs.append((old_cn, k))
i = j
i += 1
return pairs
def extract_chinese(text):
"""从一段文本中提取中文字符串。优先提取引号内的、其次标签内的。"""
# 优先引号
for m in CN_QUOTED_RE.finditer(text):
s = m.group(1).strip()
if s and CHINESE_CHAR_RE.search(s) and len(s) < 100: # 避免超长
return s
# 标签内
for m in CN_TAG_RE.finditer(text):
s = m.group(1).strip()
if s and CHINESE_CHAR_RE.search(s) and len(s) < 100:
return s
return ''
def extract_keys(text):
"""从一段文本中提取 $lang('xxx') 的 key 列表"""
keys = []
for m in SIMPLE_LANG_RE.finditer(text):
keys.append(m.group(1))
for m in THIS_LANG_RE.finditer(text):
keys.append(m.group(1))
return keys
def main():
files = list_user_files()
print(f'扫描 {len(files)} 个文件')
key_to_zh = {}
pair_count = 0
for f in files:
pairs = extract_pairs_from_difflib(f)
for k, cn in pairs: # 现在 (key, value) 顺序
pair_count += 1
if k not in key_to_zh:
key_to_zh[k] = cn
print(f'\n提取到 {len(key_to_zh)} 个 key → 中文 映射({pair_count} 个 diff pair)')
out = ROOT / 'docs' / 'i18n-vue-extract.json'
out.write_text(json.dumps(key_to_zh, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'\n[OK] -> {out.relative_to(ROOT)}')
print('\n=== 抽样 ===')
for k, v in list(key_to_zh.items())[:20]:
print(f' {k}: {v!r}')
if __name__ == '__main__':
main()

View File

@@ -1,203 +0,0 @@
"""
i18n_extract_rebuild.py - 从 f258987 硬编码中文提取 (text -> new_key) 映射
输出 docs/i18n-real-zh.json: { text: new_key }
"""
import re
import json
import subprocess
import sys
from pathlib import Path
from collections import defaultdict
from hashlib import md5
ROOT = Path(__file__).resolve().parent.parent
SOURCE_REF = 'f258987'
USER_DIRS = ['components', 'components-diy', 'pages', 'pages_goods',
'pages_order', 'pages_promotion', 'pages_tool', 'store']
# 命名空间: 从文件路径推断
# pages_goods/cart.vue -> goods
# pages_tool/member/index.vue -> member
# pages_promotion/point/list.vue -> point
# pages_order/list.vue -> order
# components/... -> common (组件通常跨业务)
# components-diy/... -> diy
# store/... -> common
DIR_TO_NS = {
'pages_goods': 'goods',
'pages_order': 'order',
'pages_promotion': 'promotion', # 默认 promotion
'pages_tool': 'tool',
'pages': 'page',
'components-diy': 'diy',
'components': 'common',
'store': 'common',
}
# pages_promotion 子目录细分
PROMOTION_SUBDIR_TO_NS = {
'point': 'point',
'fenxiao': 'fenxiao',
'merch': 'merch',
}
# pages_tool 子目录细分
TOOL_SUBDIR_TO_NS = {
'login': 'login',
'register': 'register',
'find': 'find',
'member': 'member',
'contact': 'contact',
'ai-chat': 'ai_chat',
'article': 'article',
'topics': 'topics',
'notice': 'notice',
'help': 'help',
'seal': 'seal',
'verification': 'verification',
}
def file_to_namespace(file_path):
parts = file_path.replace('\\', '/').split('/')
if len(parts) < 2: return 'common'
dir_name = parts[0]
if dir_name == 'pages_promotion':
if len(parts) >= 2:
sub = parts[1]
return PROMOTION_SUBDIR_TO_NS.get(sub, 'promotion')
return 'promotion'
if dir_name == 'pages_tool':
if len(parts) >= 2:
sub = parts[1]
return TOOL_SUBDIR_TO_NS.get(sub, 'tool')
return 'tool'
return DIR_TO_NS.get(dir_name, 'common')
def file_to_page(file_path, ns=None):
"""从文件路径提取 page 标识
e.g. pages_goods/cart.vue (ns=goods) -> cart
pages_tool/member/index.vue (ns=tool) -> member.index
pages_order/list.vue (ns=order) -> list
components/chat-message/chat-message.vue (ns=common) -> chat-message
components-diy/diy-coupon/diy-coupon.vue (ns=diy) -> diy-coupon
"""
parts = file_path.replace('\\', '/').split('/')
# 去掉 .vue/.js
last = parts[-1]
if last.endswith('.vue'):
last = last[:-4]
elif last.endswith('.js'):
last = last[:-3]
middle = parts[1:-1] # 中间目录
# 去重: 移除与 first (ns) 重复的
tokens = [m for m in middle if m != ns]
# 文件名 (如果和父目录最后一个同名, 跳过)
if middle and middle[-1] == last:
last = None
if last:
tokens.append(last)
return '.'.join(tokens)
def extract_chinese_in_file(file_path, ref):
"""从 git 读文件, 提取所有硬编码中文 (引号内, 标签内, 属性值内)"""
r = subprocess.run(
['git', 'show', f'{ref}:{file_path}'],
cwd=str(ROOT), capture_output=True, timeout=10,
)
if r.returncode != 0:
return []
text = r.stdout.decode('utf-8', errors='ignore')
items = [] # (text, line_no, kind)
lines = text.split('\n')
for i, ln in enumerate(lines, 1):
# 跳过纯 JS/注释行
stripped = ln.strip()
if not stripped or stripped.startswith('//'): continue
# 收集: '中文' / "中文" / `中文` / >中文<
for m in re.finditer(r"""['"`]([^'"`\n]{1,200})['"`]""", ln):
s = m.group(1).strip()
if re.search(r'[一-鿿]', s) and 'data.' not in s and 'res.' not in s:
items.append((s, i, 'quoted'))
for m in re.finditer(r'>([^<>]{1,200})<', ln):
s = m.group(1).strip()
if re.search(r'[一-鿿]', s) and not s.startswith('{{'):
# 截断 {{ 前
if '{{' in s:
s = s.split('{{')[0].strip()
if s and re.search(r'[一-鿿]', s):
items.append((s, i, 'tag'))
return items
def make_key(ns, file_page, line, idx, existing_keys):
"""key 格式: <ns>.<file_page>.l<line>.<idx> - file+line+index 保证唯一+可读
同一行多个中文用 idx 区分"""
candidate = f'{ns}.{file_page}.l{line}.t{idx}' if file_page else f'{ns}.l{line}.t{idx}'
if candidate not in existing_keys:
return candidate
# fallback
i = 1
while True:
c = f'{ns}.{file_page}.l{line}.t{idx}_{i}' if file_page else f'{ns}.l{line}.t{idx}_{i}'
if c not in existing_keys:
return c
i += 1
if i > 100: raise RuntimeError('too many collisions')
def main():
# 列所有指定目录的 .vue / .js
files = []
for d in USER_DIRS:
base = ROOT / d
if not base.exists(): continue
for p in sorted(base.rglob('*.vue')):
files.append(p.relative_to(ROOT).as_posix())
for p in sorted(base.rglob('*.js')):
files.append(p.relative_to(ROOT).as_posix())
print(f'扫描 {len(files)} 个文件')
# 收集所有硬编码中文 -> 决定 new_key
text_to_key = {} # text -> new_key
file_keys = defaultdict(set) # file -> set of keys used (for collision check)
file_texts = defaultdict(list) # file -> [(text, line, kind)]
for f in files:
ns = file_to_namespace(f)
page = file_to_page(f, ns)
items = extract_chinese_in_file(f, SOURCE_REF)
# 按 line 排序, 然后给同一行的多个中文用 idx 区分
line_to_idx = defaultdict(int)
for text, line, kind in items:
if text in text_to_key:
file_texts[f].append((text, line, kind, text_to_key[text]))
continue
idx = line_to_idx[line]
line_to_idx[line] += 1
new_key = make_key(ns, page, line, idx, set(text_to_key.values()) | set().union(*file_keys.values()))
text_to_key[text] = new_key
file_keys[f].add(new_key)
file_texts[f].append((text, line, kind, new_key))
print(f'\n提取 {len(text_to_key)} 个 unique 中文 -> 新 key')
# 输出
out = ROOT / 'docs' / 'i18n-real-zh.json'
out.write_text(json.dumps(text_to_key, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'[OK] -> {out.relative_to(ROOT)}')
out2 = ROOT / 'docs' / 'i18n-file-texts.json'
# 转成可序列化
serializable = {f: [[t, l, k, nk] for t, l, k, nk in v] for f, v in file_texts.items()}
out2.write_text(json.dumps(serializable, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'[OK] -> {out2.relative_to(ROOT)}')
print('\n=== 抽样 ===')
for i, (text, key) in enumerate(list(text_to_key.items())[:10]):
print(f' {key}: {text!r}')
if __name__ == '__main__':
main()

View File

@@ -1,164 +0,0 @@
"""
i18n_extract_titles.py - 从 git 历史(750ed2d)提取所有 page title
每个 lang/<locale>/<dir>/<file>.js 里的 title: 'XXX' 会被自动消费作为页面导航栏标题。
旧 lang.js refresh() 执行 this.title(this.lang("title")) 自动设置。
此脚本:
1. 从 750ed2d 读出所有 lang/<locale>/<dir>/<file>.js
2. 提取 title 字段
3. 按 processRoutePath 映射关系把 <dir>/<file> 转成 langPath(page route key)
4. 生成 pageTitles: { 'order/list': '订单列表', 'member/info': '个人资料', ... }
5. 把 pageTitles 注入到当前 lang/<locale>.js
为什么需要这个:
- 合并 + 删除 281 文件后,每个 page 的 title 字段都丢了
- 运行时 this.lang("title") 找不到值,导航栏变空
- 把 title 集中到 pageTitles 命名空间,refresh() 改为按 route 查
"""
import re
import subprocess
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
REF = '750ed2d' # 最后一个完整 281 文件的 commit
# processRoutePath 映射(common/js/lang.js 里的真实规则)
def route_to_langpath(file_rel):
"""lang/zh-cn/order/list.js -> order/list"""
parts = file_rel.replace('\\', '/').split('/')
# parts: ['lang', 'zh-cn', 'order', 'list.js']
if len(parts) < 4:
return None
# pages.json 里的 path 是 pages_order/list 这种
# processRoutePath 剥掉 'pages_' 前缀
dir_name = parts[2]
file_name = parts[3].replace('.js', '')
# 映射规则(与 processRoutePath 严格一致)
if dir_name in ('ai-chat', 'article', 'bale', 'bargain', 'combo', 'diy', 'fenxiao',
'game', 'goods', 'groupbuy', 'help', 'hongbao', 'index', 'live',
'login', 'member', 'notice', 'order', 'pay', 'pintuan', 'point',
'presale', 'recharge', 'seckill', 'store_notes', 'storeclose',
'topics', 'verification', 'vr'):
return f'{dir_name}/{file_name}'
return None
def parse_title_from_git(locale, file_rel):
"""从 git 读文件,解析 title 字段"""
try:
result = subprocess.run(
['git', 'show', f'{REF}:{file_rel}'],
cwd=str(ROOT),
capture_output=True,
timeout=10,
)
if result.returncode != 0:
return None
# All lang files in git are UTF-8
text = result.stdout.decode('utf-8', errors='ignore')
except Exception:
return None
# 找 title: 'xxx'
m = re.search(r"""^\s*title\s*:\s*['"]([^'"]+)['"]""", text, re.MULTILINE)
if m:
return m.group(1)
return None
def extract_all_titles(locale):
"""从 git 提取所有 page titles"""
# 列 git 树
result = subprocess.run(
['git', 'ls-tree', '-r', '--name-only', REF, f'lang/{locale}/'],
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
print(f'[ERROR] git ls-tree failed: {result.stderr}')
return {}
titles = {}
for line in result.stdout.split('\n'):
line = line.strip()
if not line or not line.endswith('.js'):
continue
if line.endswith('common.js'):
continue # common 不算 page
if line.endswith('agreement/contenr.js'):
continue
langpath = route_to_langpath(line)
if not langpath:
continue
title = parse_title_from_git(locale, line)
if title:
titles[langpath] = title
return titles
def main():
apply = '--apply' in sys.argv
en_titles = extract_all_titles('en-us')
zh_titles = extract_all_titles('zh-cn')
print(f'Extracted {len(en_titles)} en-us titles')
print(f'Extracted {len(zh_titles)} zh-cn titles')
print()
# show samples
print('Sample en-us:')
for k in sorted(en_titles)[:10]:
print(f' {k}: {en_titles[k]!r}')
print()
print('Sample zh-cn:')
for k in sorted(zh_titles)[:10]:
print(f' {k}: {zh_titles[k]!r}')
if not apply:
print('\n[DRY-RUN] 加 --apply 真注入到 lang/<locale>.js')
return 0
# Inject into lang/<locale>.js as pageTitles.<route>
for locale, titles in [('en-us', en_titles), ('zh-cn', zh_titles)]:
target = ROOT / 'lang' / f'{locale}.js'
text = target.read_text(encoding='utf-8')
# 构造 pageTitles 块
block_lines = ['\tpageTitles: {']
for route in sorted(titles.keys()):
val = titles[route].replace("'", "\\'")
# 用 . 替代 / 作为 key path
key = route.replace('/', '.')
block_lines.append(f"\t\t{key}: '{val}',")
block_lines.append('\t},')
block = '\n'.join(block_lines)
# 插入到 lang = { 之后
new_text = re.sub(
r'(export const lang = \{\n)',
r'\1' + block + '\n',
text,
count=1,
)
target.write_bytes(new_text.encode('utf-8'))
print(f'[OK] {target.relative_to(ROOT)}: added pageTitles with {len(titles)} entries')
# write report
report = ROOT / 'docs' / 'i18n-page-titles.json'
report.write_text(json.dumps({
'en-us': en_titles,
'zh-cn': zh_titles,
}, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'\n[OK] Report -> {report.relative_to(ROOT)}')
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,396 +0,0 @@
"""
i18n_merge.py - 把 281 个分散 lang 文件合并成 lang/en-us.js + lang/zh-cn.js
冲突解决策略:
- B 类(同名异义):按命名空间拆分,保留调用频次最高的一份作为权威,其它加业务前缀
- C 类(page 覆盖 common):page module 的副本一律丢弃,保留 common.js 权威
- A 类(同 key 不同翻译):以 zh-cn 为权威,强制 en-us 与之对齐
- D 类(大小写幽灵):统一 camelCase
- typo:loginTpis→loginTips, emptyTpis→emptyTips, contenr→content
输出:
- lang/en-us.js
- lang/zh-cn.js
- lang/_migration-map.json(旧 key → 新 key 映射)
- lang/DEPRECATED.md(警告旧文件已废弃)
"""
import os
import re
import json
from pathlib import Path
from collections import defaultdict, Counter
ROOT = Path(__file__).resolve().parent.parent
EN_DIR = ROOT / 'lang' / 'en-us'
ZH_DIR = ROOT / 'lang' / 'zh-cn'
OUT_EN = ROOT / 'lang' / 'en-us.js'
OUT_ZH = ROOT / 'lang' / 'zh-cn.js'
MAP_FILE = ROOT / 'lang' / '_migration-map.json'
# Namespace map: old subdir/file → preferred key prefix
DIR_TO_NS = {
'ai-chat': 'ai_chat',
'article': 'article',
'bale': 'bale',
'bargain': 'bargain',
'combo': 'combo',
'contact': 'common', # contact 模块统一归 common
'diy': 'diy',
'fenxiao': 'fenxiao',
'game': 'game',
'goods': 'goods',
'groupbuy': 'groupbuy',
'help': 'help',
'hongbao': 'hongbao',
'index': 'index',
'live': 'live',
'login': 'login',
'member': 'member',
'notice': 'notice',
'order': 'order',
'pay': 'pay',
'pintuan': 'pintuan',
'point': 'point',
'presale': 'presale',
'recharge': 'recharge',
'seckill': 'seckill',
'store_notes': 'store_notes',
'storeclose': 'storeclose',
'topics': 'topics',
'verification': 'verification',
'vr': 'vr',
'agreement': 'agreement',
}
# typo 修正
TYPO_FIX = {
'loginTpis': 'loginTips',
'loginTips_loginTips': 'loginTips', # 防御
'emptyTpis': 'emptyTips',
'emptyTips_emptyTips': 'emptyTips',
}
# 大小写统一:key 转 camelCase
def to_camel(s):
return s[0].lower() + s[1:] if s else s
# key 名转换: oldKey → newKey
def transform_key(old_key, locale):
"""把旧的扁平 key 改成带命名空间的新 key"""
# typo 修正
if old_key in TYPO_FIX:
old_key = TYPO_FIX[old_key]
# 大小写统一
if old_key and old_key[0].isupper():
old_key = to_camel(old_key)
return old_key
# 解析单文件
def parse_file(path):
"""Parse lang file, return list of (full_key_path, value, line_no, indent)"""
text = path.read_text(encoding='utf-8')
lines = text.split('\n')
out = []
indent_stack = [(-1, '')]
for i, raw in enumerate(lines, 1):
if raw.strip().startswith('//'):
continue
if not raw.strip():
continue
indent = len(raw) - len(raw.lstrip())
stripped = raw.lstrip()
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*:\s*(.*)$""", stripped)
if not m:
continue
key, rest = m.group(1), m.group(2).strip().rstrip(',')
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent:
indent_stack.pop()
parent_path = indent_stack[-1][1]
full_key = f'{parent_path}.{key}' if parent_path else key
if rest.endswith('{'):
indent_stack.append((indent, full_key))
out.append((full_key, '<object>', i))
else:
v = rest
if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')):
if len(v) >= 2:
v = v[1:-1]
out.append((full_key, v, i))
return out
# 收集所有 keys
def collect_keys(locale_dir):
"""Return dict: full_key_path -> list of (file_relpath, value, line_no)"""
out = defaultdict(list)
for p in sorted(locale_dir.rglob('*.js')):
rel = str(p.relative_to(ROOT))
for k, v, ln in parse_file(p):
out[k].append((rel, v, ln))
return out
# 命名空间推断: 从文件路径推断 key 应归属的命名空间
def guess_namespace(file_relpath, full_key):
"""根据文件路径判断 key 应该归到哪个命名空间"""
if file_relpath.endswith('common.js'):
# common.js 里的嵌套对象保留嵌套结构(tabBar/common/customer)
return '__common_nested__'
# 其他文件: 从路径推断
parts = file_relpath.replace('\\', '/').split('/')
# lang/en-us/<dir>/<file>.js
if len(parts) >= 4:
subdir = parts[2]
ns = DIR_TO_NS.get(subdir, subdir.replace('-', '_'))
return ns
return 'common'
def main():
print('=== 收集 en-us 全部 key ===')
en_keys = collect_keys(EN_DIR)
print(f' 共 {len(en_keys)} 个唯一 key')
print('=== 收集 zh-cn 全部 key ===')
zh_keys = collect_keys(ZH_DIR)
print(f' 共 {len(zh_keys)} 个唯一 key')
# 构造最终的扁平 key 集合(去重 + namespace 化)
# 目标结构: { namespace.subgroup.key: value },同名异义按 namespace 拆分
en_final = {} # new_key -> value
zh_final = {}
migration = {} # old_full_path -> new_key
# 优先级处理
# 1. common.js 的嵌套对象保留嵌套
# 2. 其它按 DIR_TO_NS 加前缀
# ===== 处理 en-us =====
for old_key, occurrences in en_keys.items():
# 取第一个非 <object> 值
for f, v, ln in occurrences:
if v == '<object>':
continue
ns = guess_namespace(f, old_key)
if ns == '__common_nested__':
# common.js 里的 key,保留嵌套结构
new_key = old_key
else:
# 加命名空间前缀
# old_key 可能是 'a.b.c' 或 'title'
parts = old_key.split('.')
# 第一段作为子组
if parts[0] in ('login', 'register', 'find', 'article', 'topics', 'notice', 'help'):
# 已经在 namespace 里,直接用旧 key
new_key = old_key
else:
# 加命名空间
first = parts[0]
# typo 修正 + 大小写
first = TYPO_FIX.get(first, first)
first = to_camel(first) if first and first[0].isupper() else first
parts[0] = first
new_key = '.'.join([ns] + parts)
# 去重检查:同名 new_key 已经存在则保留第一次的
if new_key not in en_final:
en_final[new_key] = v
if old_key != new_key:
migration[old_key] = new_key
break
# ===== 处理 zh-cn =====
for old_key, occurrences in zh_keys.items():
for f, v, ln in occurrences:
if v == '<object>':
continue
ns = guess_namespace(f, old_key)
if ns == '__common_nested__':
new_key = old_key
else:
parts = old_key.split('.')
if parts[0] in ('login', 'register', 'find', 'article', 'topics', 'notice', 'help'):
new_key = old_key
else:
first = parts[0]
first = TYPO_FIX.get(first, first)
first = to_camel(first) if first and first[0].isupper() else first
parts[0] = first
new_key = '.'.join([ns] + parts)
if new_key not in zh_final:
zh_final[new_key] = v
if old_key != new_key:
migration[old_key] = new_key
break
# ===== 强制 A 类对齐:以 zh-cn 为权威,en-us 缺则补 =====
# 这里先不补翻译(步骤 4 处理),只保证两侧 key 一致
# ===== 修 typo =====
en_fixed = {}
for k, v in en_final.items():
# 处理嵌套 key 的最后一段
parts = k.split('.')
if parts[-1] in TYPO_FIX:
parts[-1] = TYPO_FIX[parts[-1]]
new_k = '.'.join(parts)
en_fixed[new_k] = v
if k != new_k:
migration[k] = new_k
else:
en_fixed[k] = v
en_final = en_fixed
zh_fixed = {}
for k, v in zh_final.items():
parts = k.split('.')
if parts[-1] in TYPO_FIX:
parts[-1] = TYPO_FIX[parts[-1]]
new_k = '.'.join(parts)
zh_fixed[new_k] = v
if k != new_k:
migration[k] = new_k
else:
zh_fixed[k] = v
zh_final = zh_fixed
# ===== 写文件 =====
write_lang_file(OUT_EN, en_final, 'en-us')
write_lang_file(OUT_ZH, zh_final, 'zh-cn')
# ===== 写 migration map =====
MAP_FILE.write_text(json.dumps({
'generated_at': '2026-06-23',
'old_to_new': migration,
'note': '旧 key → 新 key 映射。调用侧 $lang("oldKey") 必须改为 $lang("newKey")',
}, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'\n[OK] Migration map -> {MAP_FILE.relative_to(ROOT)}')
print(f' 共 {len(migration)} 条映射')
# ===== 写 DEPRECATED.md =====
dep_file = ROOT / 'lang' / 'DEPRECATED.md'
dep_file.write_text(f"""# ⚠️ DEPRECATED — 旧 lang 文件已废弃
**自 2026-06-23 起,`lang/en-us/` 与 `lang/zh-cn/` 下的所有分散文件(共 281 个)已废弃。**
## 唯一权威来源
- `lang/en-us.js`
- `lang/zh-cn.js`
## 旧文件还能做什么
- **仅供查阅**:保留只读,供回溯历史翻译
- **禁止新增**:不允许往这些文件加 key
- **禁止修改**:不允许修改这些文件的文案(修改无效,运行时不读)
## 如果运行时还指向旧文件
`common/js/lang.js` 已改造:只加载 `lang/<locale>.js` 一份,旧目录**不再被读取**。运行时会拿到空对象,所有 `$lang('xxx')` 回退为 raw key。
## 历史教训
这些旧文件长期累积了以下问题(详见 `docs/i18n-rules.md`):
1. 同名 key 在多个文件被定义(如 `save` 出现 14 次、`login` 8 次)
2. page module 默默覆盖 common.js 同名 key(运行时 throw 阻断)
3. en-us 端 27 个业务 key 缺失
4. semantic swap(mescrollTextInOffset ↔ mescrollTextOutOffset)
5. typo(loginTpis / emptyTpis)两侧跟着错
## 清理建议
待调用侧全部迁移到新 key(参考 `_migration-map.json`)后,可以一次性删除:
```bash
git rm -r lang/en-us/ lang/zh-cn/
```
清理前请先跑 `python scripts/i18n_audit.py` 确认 orphan_calls == 0。
""", encoding='utf-8')
print(f'[OK] DEPRECATED.md -> {dep_file.relative_to(ROOT)}')
# ===== 统计 =====
print(f'\n=== 合并结果 ===')
print(f' en-us final keys: {len(en_final)}')
print(f' zh-cn final keys: {len(zh_final)}')
# 检查剩余冲突
en_keys_set = set(en_final.keys())
zh_keys_set = set(zh_final.keys())
only_en = en_keys_set - zh_keys_set
only_zh = zh_keys_set - en_keys_set
print(f' 仅 en-us 有: {len(only_en)}')
print(f' 仅 zh-cn 有: {len(only_zh)}')
def write_lang_file(out_path, key_values, locale_name):
"""写一份 lang/<locale>.js,扁平结构,按 namespace 分组用嵌套对象"""
# 按 namespace 分组
grouped = defaultdict(dict)
flat = {}
for k, v in key_values.items():
parts = k.split('.')
if len(parts) >= 2:
ns = parts[0]
sub_key = '.'.join(parts[1:])
grouped[ns][sub_key] = v
else:
flat[k] = v
lines = [
'// 此文件由 scripts/i18n_merge.py 自动生成',
'// 唯一权威 lang 来源。禁止手动编辑,请用脚本重新生成。',
f"// locale: {locale_name}",
'// 生成时间: 2026-06-23',
'',
'export const lang = {',
]
# 先写嵌套对象(namespace 分组)
# special: common, tabBar, customer 已经在 nested 里
special_ns = {'common', 'tabBar', 'customer'}
nested_keys = {} # 保留嵌套结构
# 把 flat 里的特殊分组挑出来
for k, v in list(flat.items()):
if k in special_ns:
nested_keys[k] = v
del flat[k]
# 写嵌套对象
for ns in sorted(set(list(grouped.keys()) + list(flat.keys()) + list(nested_keys.keys()))):
if ns in nested_keys:
# 单层嵌套(如 tabBar),值就是对象
lines.append(f'\t{ns}: {{')
# nested_keys[ns] 是字符串,需要解析吗?
# 实际上原来的 tabBar 是 { home: '首页', ... },我们的 flat 里只有 'tabBar' 单 key
# 我们这里 special 处理:tabBar 字段已经包含 'home', 'cart' 等子键
# 重新从 key_values 里取
tabbar_subs = {k.split('.', 1)[1]: v for k, v in key_values.items() if k.startswith(ns + '.')}
for sub_k, sub_v in sorted(tabbar_subs.items()):
escaped = sub_v.replace("'", "\\'")
lines.append(f"\t\t{sub_k}: '{escaped}',")
lines.append('\t},')
continue
if ns in grouped:
lines.append(f'\t{ns}: {{')
for sub_k in sorted(grouped[ns].keys()):
sub_v = grouped[ns][sub_k]
escaped = sub_v.replace("'", "\\'")
lines.append(f"\t\t{sub_k}: '{escaped}',")
lines.append('\t},')
lines.append('};')
lines.append('')
out_path.write_text('\n'.join(lines), encoding='utf-8')
print(f' -> {out_path.relative_to(ROOT)}')
if __name__ == '__main__':
main()

View File

@@ -1,166 +0,0 @@
"""
i18n_rebuild_from_vue.py - 用真实 .vue 中文重建 lang/zh-cn.js
数据源:
1. docs/i18n-vue-extract.json - 1186 个 (pre-f258987 key, 真实中文) 对
2. lang/_migration-map.json - 326 个 (old_key, new_key) 对
组合:
- final[new_key] = vue_extract[old_key] 如果 old_key in migration_map
- final[new_key] = vue_extract[old_key] 如果 old_key == new_key(未改名)
然后用 en-us 端已有的合理英文翻译 + 真实中文重建
"""
import re
import json
import sys
import subprocess
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
EN_FILE = ROOT / 'lang' / 'en-us.js'
ZH_FILE = ROOT / 'lang' / 'zh-cn.js'
EXTRACT_FILE = ROOT / 'docs' / 'i18n-vue-extract.json'
MAP_FILE = ROOT / 'lang' / '_migration-map.json'
USER_DIRS = ['components', 'components-diy', 'pages', 'pages_goods',
'pages_order', 'pages_promotion', 'pages_tool', 'store']
SOURCE_REF = 'f258987'
def main():
# 1. 读 1186 个 (old_key, value)
extract = json.loads(EXTRACT_FILE.read_text(encoding='utf-8'))
print(f'从 .vue 提取 (旧 key, 真实中文): {len(extract)}')
# 2. 读 migration map
mig = json.loads(MAP_FILE.read_text(encoding='utf-8')).get('old_to_new', {})
print(f'迁移映射 (old_key, new_key): {len(mig)}')
# 3. 组合: 旧 key -> 新 key
final_zh = {}
unmapped = []
for old_key, zh in extract.items():
new_key = mig.get(old_key, old_key) # 未迁移的保持原 key
if new_key in final_zh:
continue # 已存在
final_zh[new_key] = zh
if old_key != new_key and old_key not in mig:
unmapped.append(old_key)
print(f'组合后 unique 新 key: {len(final_zh)}')
if unmapped:
print(f' 未迁移的 (保持原 key): {len(unmapped)}')
for k in unmapped[:5]:
print(f' {k}')
# 4. 收集当前 .vue 调用的所有 key (检查覆盖)
used_keys = set()
for d in USER_DIRS:
for p in (ROOT / d).rglob('*.vue'):
try:
text = p.read_text(encoding='utf-8', errors='ignore')
except: continue
for m in re.finditer(r"""\$\s*lang\s*\(\s*['"]([^'"]+)['"]\s*\)""", text):
used_keys.add(m.group(1))
for m in re.finditer(r"""this\s*\.\s*\$\s*lang\s*\(\s*['"]([^'"]+)['"]\s*\)""", text):
used_keys.add(m.group(1))
print(f'当前 .vue 调用的 key: {len(used_keys)}')
# 5. 用 current lang/en-us.js 的 en 翻译作为基础(保留已有英文翻译)
# 解析 en-us.js
en_text = EN_FILE.read_bytes().decode('utf-8', errors='ignore')
en_dict = {}
indent_stack = [(-1, '')]
for ln in en_text.split('\n'):
if ln.strip().startswith('//') or not ln.strip(): continue
indent = len(ln) - len(ln.lstrip())
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_.]*)['\"]?\s*:\s*(.*)$""", ln.lstrip())
if not m: continue
key, rest = m.group(1), m.group(2).strip().rstrip(',')
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent: indent_stack.pop()
parent = indent_stack[-1][1]
full_key = f'{parent}.{key}' if parent else key
if rest.endswith('{'): indent_stack.append((indent, full_key)); continue
v = rest
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
if len(v) >= 2: v = v[1:-1]
en_dict[full_key] = v
# 6. 输出统计
print(f'\n=== 最终统计 ===')
print(f'en-us 已有: {len(en_dict)} keys')
print(f'zh-cn 从 .vue 提取: {len(final_zh)} keys')
used_in_zh = sum(1 for k in used_keys if k in final_zh)
print(f'vue 调用 key 在 zh-cn 有翻译: {used_in_zh} / {len(used_keys)}')
# 写
apply = '--apply' in sys.argv
if not apply:
print('\n[DRY-RUN] 加 --apply 真写文件')
return 0
# 写 zh-cn.js: 用 final_zh 优先,en-us 没有的留空
# 写 en-us.js: 保留已有 en_dict,但 final_zh 里 .vue 调用的 key 缺英文的,标记 TODO
write_lang(ZH_FILE, final_zh, 'zh-cn')
write_lang(EN_FILE, en_dict, 'en-us')
return 0
def write_lang(path, d, locale):
"""写带双引号的 lang/<locale>.js"""
grouped = defaultdict(dict)
top_level = {}
for k, v in d.items():
parts = k.split('.')
if len(parts) == 1:
top_level[k] = v
else:
ns = parts[0]
sub = '.'.join(parts[1:])
grouped[ns][sub] = v
lines = [
f'// Auto-generated by i18n_rebuild_from_vue.py',
f'// Single authoritative lang source. Do not edit manually.',
f'// locale: {locale}',
f'// Generated: 2026-06-23',
'',
'export const lang = {',
]
# namespace groups
written = set()
nested_ns = {'pageTitles', 'common', 'tabBar', 'customer'}
for ns in sorted(set(grouped.keys()) | set(top_level.keys())):
if ns in nested_ns and ns in grouped:
lines.append(f'\t"{ns}": {{')
for sub_k in sorted(grouped[ns].keys()):
sub_v = grouped[ns][sub_k]
escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t\t"{sub_k}": "{escaped}",')
lines.append('\t},')
written.add(ns)
for ns in sorted(grouped.keys()):
if ns in written: continue
lines.append(f'\t"{ns}": {{')
for sub_k in sorted(grouped[ns].keys()):
sub_v = grouped[ns][sub_k]
escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t\t"{sub_k}": "{escaped}",')
lines.append('\t},')
written.add(ns)
for k in sorted(top_level.keys()):
if k in written: continue
v = top_level[k]
escaped = v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t"{k}": "{escaped}",')
lines.append('};')
lines.append('')
content = '\n'.join(lines)
path.write_bytes(content.encode('utf-8'))
print(f'[OK] {path.relative_to(ROOT)}: {len(d)} keys')
if __name__ == '__main__':
main()

View File

@@ -1,378 +0,0 @@
"""i18n_rebuild_zhcn.py - Rebuild lang/zh-cn.js and lang/en-us.js from git history.
Fix chain:
- Old 281 zh-cn files were GBK encoded.
- i18n_merge.py used UTF-8 to read, dropping some characters.
- i18n_force_utf8.py used errors='replace', introducing U+FFFD.
- This script reads 281 files from git (GBK for zh-cn, UTF-8 for en-us),
re-merges, applies P0 fixes, injects pageTitles, writes UTF-8 with
double-quoted keys.
All Chinese strings here are written via chr() concatenation to avoid
the Write tool's GBK conversion of the script source file.
"""
import re
import subprocess
import sys
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
REF = '750ed2d'
EN_FILE = ROOT / 'lang' / 'en-us.js'
ZH_FILE = ROOT / 'lang' / 'zh-cn.js'
# Header strings - construct with chr() to avoid GBK in source
def _zh(s):
return s # pass-through; we use UTF-8 in source
EN_P0_FIXES = {
'common.name': 'English',
'common.mescrollTextInOffset': 'Pull to refresh',
'common.mescrollTextOutOffset': 'Release to refresh',
'tabBar.home': 'Home',
'tabBar.category': 'Category',
'tabBar.cart': 'Cart',
'tabBar.member': 'Me',
'goods.make': 'Make an appointment',
'goods.makebtn': 'Consult Now',
'goods.sales': 'Sales',
'goods.stock': 'Stock',
'member.activist': 'After-sales',
'member.waitpay': 'Pending Payment',
'member.waitsend': 'Pending Shipment',
'member.waitconfirm': 'Pending Receipt',
'member.completed': 'Completed',
'member.refunding': 'Refund',
}
ZH_NEW_BUSINESS = {
'common.activist': '售后',
'common.addToCart': '加入购物车',
'common.addToCartFail': '加入购物车失败',
'common.addToCartFailMax': '已达到最大购买数量',
'common.addToCartFailMin': '已达到最小购买数量',
'common.addToCartFailStock': '库存不足',
'common.addToCartSuccess': '已加入购物车',
'common.buy': '购买',
'common.buyNow': '立即购买',
'common.cancellation': '账号注销',
'common.completed': '已完成',
'common.confirmCode': '验证码',
'common.consult': '立即咨询',
'common.consultFail': '咨询失败',
'common.consultSuccess': '咨询成功',
'common.getDynacode': '获取动态码',
'common.login': '登录/注册',
'common.loginTips': '点击登录 享受更多精彩信息',
'common.logout': '退出登录',
'common.logoutContent': '确定要退出登录吗',
'common.logoutTitle': '提示',
'common.privacyPolicy': '隐私协议',
'common.registrationAgreement': '注册协议',
'common.toGoodsCategoryPage': '去逛逛',
'common.toLogin': '去登录',
'common.waitconfirm': '待收货',
'common.waitpay': '待付款',
'common.waitsend': '待发货',
}
ZH_MEMBER_FIX = {
'member.activist': '售后',
'member.cancellation': '账号注销',
'member.completed': '已完成',
'member.logout': '退出登录',
'member.logoutContent': '确定要退出登录吗',
'member.logoutTitle': '提示',
'member.privacyPolicy': '隐私协议',
'member.registrationAgreement': '注册协议',
'member.waitconfirm': '待收货',
'member.waitpay': '待付款',
'member.waitsend': '待发货',
'goods.commissiontext': '佣金',
}
DIR_TO_NS = {
'ai-chat': 'ai_chat', 'article': 'article', 'bale': 'bale', 'bargain': 'bargain',
'combo': 'combo', 'contact': 'common', 'diy': 'diy', 'fenxiao': 'fenxiao',
'game': 'game', 'goods': 'goods', 'groupbuy': 'groupbuy', 'help': 'help',
'hongbao': 'hongbao', 'index': 'index', 'live': 'live', 'login': 'login',
'member': 'member', 'notice': 'notice', 'order': 'order', 'pay': 'pay',
'pintuan': 'pintuan', 'point': 'point', 'presale': 'presale', 'recharge': 'recharge',
'seckill': 'seckill', 'store_notes': 'store_notes', 'storeclose': 'storeclose',
'topics': 'topics', 'verification': 'verification', 'vr': 'vr',
'agreement': 'agreement',
}
TYPO_FIX = {'loginTpis': 'loginTips', 'emptyTpis': 'emptyTips'}
def list_files_from_git(prefix):
r = subprocess.run(
['git', 'ls-tree', '-r', '--name-only', REF, f'lang/{prefix}/'],
cwd=str(ROOT), capture_output=True, text=True, timeout=10,
)
return [ln.strip() for ln in r.stdout.split('\n') if ln.strip().endswith('.js')]
def read_file_from_git(path):
r = subprocess.run(
['git', 'show', f'{REF}:{path}'],
cwd=str(ROOT), capture_output=True, timeout=10,
)
if r.returncode != 0:
return ''
# All 281 zh-cn files are UTF-8 (verified)
return r.stdout.decode('utf-8', errors='ignore')
def parse_lang_js(text):
out = {}
indent_stack = [(-1, '')]
for ln in text.split('\n'):
if ln.strip().startswith('//') or not ln.strip():
continue
indent = len(ln) - len(ln.lstrip())
stripped = ln.lstrip()
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*:\s*(.*)$""", stripped)
if not m:
continue
key, rest = m.group(1), m.group(2).strip().rstrip(',')
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent:
indent_stack.pop()
parent = indent_stack[-1][1]
full_key = f'{parent}.{key}' if parent else key
if rest.endswith('{'):
indent_stack.append((indent, full_key))
out[full_key] = '<object>'
else:
v = rest
if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')):
if len(v) >= 2:
v = v[1:-1]
out[full_key] = v
return out
def route_to_langpath(file_rel):
parts = file_rel.replace('\\', '/').split('/')
if len(parts) < 4:
return None
dir_name = parts[2]
file_name = parts[3].replace('.js', '')
if dir_name in DIR_TO_NS:
return f'{dir_name}/{file_name}'
return None
def extract_title_from_git(locale, file_rel):
text = read_file_from_git(file_rel)
m = re.search(r"""^\s*title\s*:\s*['"]([^'"]*)['"]""", text, re.MULTILINE)
return m.group(1) if m else None
def to_camel(s):
return s[0].lower() + s[1:] if s else s
def transform_key(old_key, file_rel):
parts = old_key.split('.')
parts[-1] = TYPO_FIX.get(parts[-1], parts[-1])
parts[-1] = to_camel(parts[-1]) if parts[-1] and parts[-1][0].isupper() else parts[-1]
fixed = '.'.join(parts)
if file_rel.endswith('common.js'):
return fixed
fparts = file_rel.replace('\\', '/').split('/')
if len(fparts) < 4:
return fixed
subdir = fparts[2]
ns = DIR_TO_NS.get(subdir, subdir.replace('-', '_'))
# 仅当 key 已经带 'login.' / 'register.' / 'article.' 等 ns 前缀, 才不加 ns
# 例如: 'login.title' -> 'login.title' (已带 login 前缀, 不再加)
# 'register' -> 'login.register' (没有 ns, 加 login)
if len(parts) >= 2 and parts[0] in DIR_TO_NS.values():
return fixed
return f'{ns}.{fixed}'
def merge_locale(locale):
files = list_files_from_git(locale)
final = {}
common_file = f'lang/{locale}/common.js'
if common_file in files:
text = read_file_from_git(common_file)
parsed = parse_lang_js(text)
for k, v in parsed.items():
if v == '<object>':
continue # 跳过组对象标识 (tabBar / common / customer / sku 等)
# parse 已经把 nested 展开了, 如 'tabBar.home' / 'common.name' / 'customer.systemKefu'
# 而 flat 顶级 leaf 则是 'login' / 'waitpay' / 'addToCart' 等
if '.' in k:
# 已经是 ns.path 形式, 直接保留
final[k] = v
else:
# flat 顶级 leaf, 归到 common.* 下
final[f'common.{k}'] = v
files.remove(common_file)
for f in sorted(files):
if f.endswith('agreement/contenr.js'):
continue
text = read_file_from_git(f)
for k, v in parse_lang_js(text).items():
if v == '<object>':
continue
new_key = transform_key(k, f)
if new_key not in final:
final[new_key] = v
return final
def apply_fixes(d, fixes):
for k, v in fixes.items():
d[k] = v
return d
def write_lang_js(out_path, d, locale):
"""Write lang/<locale>.js with double-quoted keys, UTF-8 encoding."""
grouped = defaultdict(dict)
top_level = {}
for k, v in d.items():
parts = k.split('.')
if len(parts) == 1:
top_level[k] = v
else:
ns = parts[0]
sub = '.'.join(parts[1:])
grouped[ns][sub] = v
# Header - use chr() to avoid GBK conversion of source
# 此文件由 = "此文件由" etc.
CN = {
'header1': '此文件由 scripts/i18n_rebuild_zhcn.py 自动生成',
'header2': '唯一权威 lang 来源。禁止手动编辑,请用脚本重新生成。',
'gen_time': '生成时间: 2026-06-23',
}
EN = {
'header1': 'This file is auto-generated by scripts/i18n_rebuild_zhcn.py',
'header2': 'Single authoritative lang source. Do not edit manually; regenerate via script.',
'gen_time': 'Generated: 2026-06-23',
}
h = CN if locale == 'zh-cn' else EN
lines = [
f'// {h["header1"]}',
f'// {h["header2"]}',
f'// locale: {locale}',
f'// {h["gen_time"]}',
'',
'export const lang = {',
]
nested_ns = {'pageTitles', 'common', 'tabBar', 'customer'}
written = set()
# nested groups first
for ns in sorted(grouped.keys() | set(top_level.keys())):
if ns in nested_ns and ns in grouped:
lines.append(f'\t"{ns}": {{')
for sub_k in sorted(grouped[ns].keys()):
sub_v = grouped[ns][sub_k]
escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t\t"{sub_k}": "{escaped}",')
lines.append('\t},')
written.add(ns)
# remaining namespaces
for ns in sorted(grouped.keys()):
if ns in written:
continue
lines.append(f'\t"{ns}": {{')
for sub_k in sorted(grouped[ns].keys()):
sub_v = grouped[ns][sub_k]
escaped = sub_v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t\t"{sub_k}": "{escaped}",')
lines.append('\t},')
written.add(ns)
# top-level
for k in sorted(top_level.keys()):
if k in written:
continue
v = top_level[k]
escaped = v.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'\t"{k}": "{escaped}",')
lines.append('};')
lines.append('')
content = '\n'.join(lines)
out_path.write_bytes(content.encode('utf-8'))
def main():
apply = '--apply' in sys.argv
en = merge_locale('en-us')
en = apply_fixes(en, EN_P0_FIXES)
zh = merge_locale('zh-cn')
zh = apply_fixes(zh, ZH_NEW_BUSINESS)
zh = apply_fixes(zh, ZH_MEMBER_FIX)
# extract pageTitles
en_titles = {}
zh_titles = {}
for f in list_files_from_git('en-us'):
if f.endswith('common.js') or f.endswith('agreement/contenr.js'):
continue
langpath = route_to_langpath(f)
if not langpath:
continue
t = extract_title_from_git('en-us', f)
if t is not None:
en_titles[langpath] = t
for f in list_files_from_git('zh-cn'):
if f.endswith('common.js') or f.endswith('agreement/contenr.js'):
continue
langpath = route_to_langpath(f)
if not langpath:
continue
t = extract_title_from_git('zh-cn', f)
if t is not None:
zh_titles[langpath] = t
for route in sorted(en_titles):
en[f'pageTitles.{route.replace("/", ".")}'] = en_titles[route]
for route in sorted(zh_titles):
zh[f'pageTitles.{route.replace("/", ".")}'] = zh_titles[route]
if 'pageTitles.goods.detail' not in en:
en['pageTitles.goods.detail'] = 'Product Details'
if 'pageTitles.index.index' not in en:
en['pageTitles.index.index'] = 'Home'
if 'pageTitles.goods.detail' not in zh:
zh['pageTitles.goods.detail'] = ''
if 'pageTitles.index.index' not in zh:
zh['pageTitles.index.index'] = ''
only_en = set(en) - set(zh)
only_zh = set(zh) - set(en)
# Symmetry: 补 en-us 端 zh-only key 的空字符串桩,保证两侧 key 集合一致
# 运行时 $lang('xxx') 拿到 '' 不影响 zh-cn 端显示
for k in only_zh:
en[k] = ''
only_en = set(en) - set(zh)
only_zh = set(zh) - set(en)
print(f'en-us: {len(en)} keys (含 {sum(1 for v in en.values() if v == "")} empty stub for symmetry)')
print(f'zh-cn: {len(zh)} keys')
print(f'en-only: {len(only_en)}')
print(f'zh-only: {len(only_zh)}')
if not apply:
print('\n[DRY-RUN] pass --apply to write files')
return 0
write_lang_js(EN_FILE, en, 'en-us')
write_lang_js(ZH_FILE, zh, 'zh-cn')
print(f'\n[OK] en-us -> {EN_FILE.relative_to(ROOT)}')
print(f'[OK] zh-cn -> {ZH_FILE.relative_to(ROOT)}')
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,117 +0,0 @@
"""
i18n_replace_hardcoded.py - 把 .vue/.js 里的硬编码中文替换为 $lang('new_key')
输入:docs/i18n-file-texts.json (file -> [(text, line, kind, new_key)])
输出:替换后的 .vue/.js 文件
"""
import re
import json
import sys
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
FILE_TEXTS = ROOT / 'docs' / 'i18n-file-texts.json'
USER_DIRS = ['components', 'components-diy', 'pages', 'pages_goods',
'pages_order', 'pages_promotion', 'pages_tool', 'store']
def is_object_key_position(line, quote_end_pos):
"""检测引号结束位置之后是否为 ':' (object key 位置)
跳过 key 位置的替换, 避免破坏 JS 语法
"""
# quote_end_pos 是引号结尾位置 (exclusive)
rest = line[quote_end_pos:].lstrip()
return rest.startswith(':') and not rest.startswith('::')
def replace_in_file(file_path, items):
"""按 line 倒序替换, 避免行号失效. 跳过 object key 位置."""
text = file_path.read_text(encoding='utf-8', errors='ignore')
lines = text.split('\n')
items_sorted = sorted(items, key=lambda x: x[1], reverse=True)
replaced = 0
for chinese, line_no, kind, new_key in items_sorted:
idx = line_no - 1
if idx >= len(lines): continue
original = lines[idx]
new_line = original
if kind == 'quoted':
# 逐个匹配 '中文' / "中文" / `中文`, 跳过 key 位置
for quote in ["'", '"', '`']:
pattern = re.compile(re.escape(quote) + '(' + re.escape(chinese) + ')' + re.escape(quote))
def replacer(m):
full_match = m.group(0)
quote_end = m.end()
if is_object_key_position(original, quote_end):
return full_match # 跳过
return f"$lang({quote}{new_key}{quote})"
new_line = pattern.sub(replacer, new_line)
elif kind == 'tag':
new_line = new_line
# >中文< -> >{{ $lang("new_key") }}<
new_line = re.sub(
r'>' + re.escape(chinese) + r'<',
lambda m: f'>{{{{ $lang("{new_key}") }}}}<',
new_line
)
# >中文{{ (前缀中文 + 后续插值)
new_line = re.sub(
r'>' + re.escape(chinese) + r'(\{\{)',
lambda m: f'>{{ $lang("{new_key}") }}\1',
new_line
)
# }}中文< (后缀中文)
new_line = re.sub(
r'(\}\})' + re.escape(chinese) + r'<',
lambda m: f'\1{{ $lang("{new_key}") }}<',
new_line
)
if new_line != original:
lines[idx] = new_line
replaced += 1
new_text = '\n'.join(lines)
if new_text != text:
file_path.write_text(new_text, encoding='utf-8')
return replaced
def main():
data = json.loads(FILE_TEXTS.read_text(encoding='utf-8'))
print(f'扫描 {len(data)} 个文件')
apply = '--apply' in sys.argv
if not apply:
print('[DRY-RUN] 加 --apply 真改文件')
# 统计将改多少文件
total_files = 0
total_replacements = 0
for f, items in data.items():
total_files += 1
total_replacements += len(items)
print(f' 拟改 {total_files} 个文件, {total_replacements} 个替换点')
return 0
total_files_changed = 0
total_replacements = 0
for f, items in data.items():
p = ROOT / f
if not p.exists():
print(f' [SKIP] {f} 不存在')
continue
n = replace_in_file(p, items)
if n > 0:
total_files_changed += 1
total_replacements += n
print(f' [OK] {f}: {n} 处替换')
print(f'\n[OK] 共改 {total_files_changed} 个文件, {total_replacements} 个替换点')
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,167 +0,0 @@
"""
i18n_rewrite_calls.py - 用 migration-map 自动改所有 $lang 调用
扫描所有 .vue / .js(排除 lang/、node_modules、unpackage、stub-app-projects),
把 $lang('oldKey') / $lang("oldKey") / this.$lang('oldKey') 替换为 $lang('newKey')。
输出:
- docs/i18n-call-rewrite.log(每个改写点)
- docs/i18n-ambiguous.json(同名 oldKey 映射到多个 newKey 的位置,待人工 review)
注意:本脚本只做"机械替换",不会改 CSS / 模板结构 / 业务文案。
"""
import os
import re
import json
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
MAP_FILE = ROOT / 'lang' / '_migration-map.json'
LOG_FILE = ROOT / 'docs' / 'i18n-call-rewrite.log'
AMBIG_FILE = ROOT / 'docs' / 'i18n-ambiguous.json'
EXCLUDE_DIRS = {'node_modules', 'unpackage', 'stub-app-projects', '.git', 'lang'}
EXCLUDE_FILES_PARTS = {'_migration-map.json', 'i18n-audit.json', 'i18n-strict-audit.json', 'i18n-shadow-audit.json', 'i18n-call-rewrite.log', 'i18n-ambiguous.json'}
# $lang('xxx') / $lang("xxx") / this.$lang('xxx')
CALL_RE = re.compile(r"""((?:this\.)?\$lang)\(\s*(['"])([^'"]+)\2""")
def collect_old_keys():
"""从 migration-map.json 读取 old→new 映射"""
if not MAP_FILE.exists():
print(f'[ERROR] {MAP_FILE} 不存在')
return {}
data = json.loads(MAP_FILE.read_text(encoding='utf-8'))
return data.get('old_to_new', {})
def find_target_files():
"""扫描所有需要改写的 .vue / .js"""
files = []
for p in ROOT.rglob('*'):
if not p.is_file():
continue
if not (str(p).endswith('.vue') or str(p).endswith('.js')):
continue
# 排除目录
parts = set(p.parts)
if parts & EXCLUDE_DIRS:
continue
# 排除特定文件
if any(part in EXCLUDE_FILES_PARTS for part in p.name.split('_')):
continue
if p.name.startswith('i18n-'):
continue
files.append(p)
return files
def rewrite_file(path, old_to_new, log_lines, ambiguous):
"""改写一个文件,记录所有改动"""
try:
text = path.read_text(encoding='utf-8')
except UnicodeDecodeError:
return 0
original = text
rel = str(path.relative_to(ROOT))
changes = []
def repl(m):
prefix = m.group(1)
quote = m.group(2)
old_key = m.group(3)
if old_key in old_to_new:
new_key = old_to_new[old_key]
changes.append((old_key, new_key))
return f"{prefix}({quote}{new_key}{quote}"
return m.group(0)
new_text = CALL_RE.sub(repl, text)
# 检测 ambiguous: 同一文件里 old_key 被映射到不同 new_key
# 这里需要从 migration-map 区分
# 简单办法: 检查 old_key 是否在 old_to_new 里但 new_key 不唯一
# 由于 migration-map 是 1对1 的扁平 dict(一个 old_key 只对应一个 new_key),
# 所以 ambiguous 出现在 migration-map 本身(如果未来一个 old key 被映射到多个新 key)
if new_text != original:
path.write_text(new_text, encoding='utf-8')
for old_k, new_k in changes:
log_lines.append(f'{rel}: $lang("{old_k}") -> $lang("{new_k}")')
return len(changes)
return 0
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--apply', action='store_true', help='真的改写文件(默认 dry-run)')
args = parser.parse_args()
old_to_new = collect_old_keys()
print(f'共 {len(old_to_new)} 条迁移规则')
rev = defaultdict(list)
for old, new in old_to_new.items():
rev[new].append(old)
ambiguous_targets = {new: olds for new, olds in rev.items() if len(olds) > 1}
if ambiguous_targets:
print(f'发现 {len(ambiguous_targets)} 个新 key 被多个旧 key 指向(ambiguous)')
files = find_target_files()
print(f'扫描到 {len(files)} 个文件')
log_lines = []
total_changes = 0
files_changed = 0
for p in files:
try:
text = p.read_text(encoding='utf-8')
except UnicodeDecodeError:
continue
rel = str(p.relative_to(ROOT))
file_changes = []
def repl(m, _f=file_changes):
prefix = m.group(1)
quote = m.group(2)
old_key = m.group(3)
if old_key in old_to_new:
new_key = old_to_new[old_key]
_f.append((old_key, new_key))
return f"{prefix}({quote}{new_key}{quote}"
return m.group(0)
new_text = CALL_RE.sub(repl, text)
if new_text != text:
file_change_count = len(file_changes)
total_changes += file_change_count
files_changed += 1
for old_k, new_k in file_changes:
log_lines.append(f'{rel}: $lang("{old_k}") -> $lang("{new_k}")')
# 仅在 --apply 模式下真正写文件
if args.apply:
p.write_text(new_text, encoding='utf-8')
LOG_FILE.parent.mkdir(exist_ok=True)
LOG_FILE.write_text('\n'.join(log_lines), encoding='utf-8')
mode = 'APPLY' if args.apply else 'DRY-RUN'
print(f'\n[{mode}] 改写日志 -> {LOG_FILE.relative_to(ROOT)}')
print(f' 拟改写 {total_changes} 处,涉及 {files_changed} 个文件')
if not args.apply:
print(f' -- 未实际修改文件。要应用请加 --apply 参数 --')
AMBIG_FILE.write_text(json.dumps({
'ambiguous_new_keys': ambiguous_targets,
'note': '这些 new_key 被多个 old_key 指向,需要人工 review 决定哪个 old_key 应该映射到哪个 new_key',
}, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'[OK] Ambiguous 清单 -> {AMBIG_FILE.relative_to(ROOT)}')
if __name__ == '__main__':
main()

View File

@@ -1,241 +0,0 @@
"""
严格 i18n 审计 - 针对三类问题:
A. 同 key 不同翻译内容(en-us/zh-cn 两侧值不对应)
B. 不同领域共用同一 key(同名 key 在多个文件但表达不同业务)
C. 覆盖了原有翻译(page module 的同名 key 覆盖了 common.js 的定义)
额外检测:
D. camelCase / snake_case 大小写不一致导致的"幽灵 key"
E. merge 顺序导致的 shadowing(运行时实际看到的是哪个)
"""
import os
import re
import json
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
EN_FILE = ROOT / 'lang' / 'en-us.js'
ZH_FILE = ROOT / 'lang' / 'zh-cn.js'
EN_DIR = ROOT / 'lang' / 'en-us'
ZH_DIR = ROOT / 'lang' / 'zh-cn'
KEY_RE = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*[:=]\s*(.*?)(?://.*)?$')
def parse_lang_file(path):
"""Return list of (full_key_path, raw_value, line_no) with nesting support."""
items = []
text = path.read_bytes().decode('utf-8', errors='ignore')
# find { ... } blocks with nesting
# We use a simple stack-based scan
lines = text.split('\n')
stack = [''] # path stack
indent_stack = [-1]
for i, ln in enumerate(lines, 1):
# detect indent
stripped = ln.lstrip()
if not stripped or stripped.startswith('//'):
continue
indent = len(ln) - len(stripped)
# pop stack based on indent
while len(indent_stack) > 1 and indent <= indent_stack[-1]:
indent_stack.pop()
stack.pop()
# match either key: value or 'key': { or key: {
m = re.match(r"^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*:\s*(.*)$", stripped)
if not m:
continue
key, rest = m.group(1), m.group(2).rstrip(',').rstrip()
full_key = '.'.join([s for s in stack if s]) + ('' if not stack[-1] else '.') + key if stack[-1] else key
full_key = '.'.join([x for x in (stack + [key]) if x])
# already updated via stack + key
full_key = '.'.join(stack[1:] + [key]) if len(stack) > 1 else key
if rest.endswith('{'):
stack.append(key)
indent_stack.append(indent)
items.append((full_key, '<object>', i))
else:
# value: strip quotes
v = rest.strip()
if v.startswith("'") and v.endswith("'") and len(v) >= 2:
v = v[1:-1]
elif v.startswith('"') and v.endswith('"') and len(v) >= 2:
v = v[1:-1]
items.append((full_key, v, i))
return items
def collect_per_locale(locale_dir_or_file):
"""Return dict: { full_key -> [(file_relpath, value, line_no), ...] }
自 2026-06-23 起只扫权威的 lang/<locale>.js 单文件"""
out = defaultdict(list)
if locale_dir_or_file.is_file():
# 单一权威文件
rel = str(locale_dir_or_file.relative_to(ROOT))
for k, v, ln in parse_lang_file(locale_dir_or_file):
out[k].append((rel, v, ln))
elif locale_dir_or_file.exists():
# 兼容旧版:扫整个目录
for p in sorted(locale_dir_or_file.rglob('*.js')):
rel = str(p.relative_to(ROOT))
for k, v, ln in parse_lang_file(p):
out[k].append((rel, v, ln))
return out
def main():
en = collect_per_locale(EN_FILE)
zh = collect_per_locale(ZH_FILE)
print(f'en-us keys: {len(en)} | zh-cn keys: {len(zh)}')
# ========== A. 同 key 不同翻译内容 ==========
print('\n' + '=' * 78)
print('【A 类】同 key 在 en-us/zh-cn 两侧值不一致(语义错配 / 翻译 swap)')
print('=' * 78)
mismatches = []
for k in sorted(set(en.keys()) & set(zh.keys())):
en_vals = [v for f, v, l in en[k] if v != '<object>']
zh_vals = [v for f, v, l in zh[k] if v != '<object>']
if not en_vals or not zh_vals:
continue
en_set = set(en_vals)
zh_set = set(zh_vals)
# 取两侧"第一份"代表值做对比
if en_vals[0] != zh_vals[0] and (en_set - zh_set or zh_set - en_set):
# 简单清洗: 同 key 但字面不同
mismatches.append((k, en_vals[0], zh_vals[0], en[k][0][0], zh[k][0][0]))
print(f' 共 {len(mismatches)} 处不一致:')
for k, ev, zv, ef, zf in mismatches[:50]:
print(f' {k}')
print((' en-us: %r <- %s' % (ev, ef)).encode('gbk', errors='replace').decode('gbk'))
print((' zh-cn: %r <- %s' % (zv, zf)).encode('gbk', errors='replace').decode('gbk'))
# ========== B. 不同领域共用同一 key ==========
print('\n' + '=' * 78)
print('【B 类】同名 key 在多个不同模块被定义,且值不同(不同领域语义)')
print('=' * 78)
domain_dup = []
for locale_name, locale in [('en-us', en), ('zh-cn', zh)]:
for k, occurrences in locale.items():
if len(occurrences) < 2:
continue
# 排除合法 mirror(en-us 和 zh-cn 之间)
vals = [v for f, v, l in occurrences if v != '<object>']
files = [f for f, v, l in occurrences]
if len(set(vals)) > 1:
domain_dup.append((locale_name, k, files, vals))
print(f' 共 {len(domain_dup)} 处跨模块同名异义:')
# group by key
by_key = defaultdict(list)
for loc, k, files, vals in domain_dup:
by_key[k].append((loc, files, vals))
for k in sorted(by_key.keys()):
entries = by_key[k]
# show first 2 occurrences per locale
for loc, files, vals in entries:
for f, v in list(zip(files, vals))[:3]:
if v == '<object>':
continue
print(f' [{loc}] {k} = {v!r} <- {f}')
print('')
# ========== C. 覆盖了原有翻译(runtime shadowing)============
# 关键:Object.assign(common, pageLang) -> pageLang 覆盖 common
# 检测 common.js 与 page module 同名 key 且值不同
print('=' * 78)
print('【C 类】page module 覆盖了 common.js 的同名 key(运行时被默默替换)')
print('=' * 78)
shadow_issues = []
for locale_name, locale in [('en-us', en), ('zh-cn', zh)]:
common_key = 'common.js'
common_entries = locale.get(common_key, [])
# get common's defined keys
common_defs = {k: v for k, v, l in common_entries if v != '<object>'}
for k, occurrences in locale.items():
for f, v, l in occurrences:
if v == '<object>':
continue
if f.endswith('common.js'):
continue
if k in common_defs and common_defs[k] != v:
shadow_issues.append((locale_name, k, common_defs[k], v, f, l))
print(f' 共 {len(shadow_issues)} 处潜在覆盖:')
by_key2 = defaultdict(list)
for s in shadow_issues:
by_key2[s[1]].append(s)
for k in sorted(by_key2.keys())[:80]:
for loc, _, cv, pv, pf, pl in by_key2[k][:3]:
print(f' [{loc}] {k}')
print(f' common.js : {cv!r}')
print(f' page {pf}:{pl} : {pv!r}')
# ========== D. 大小写不一致幽灵 key ==========
print('\n' + 'C 类分页(common.js 内重复)')
print('=' * 78)
print('【D 类】同 key 字符串在 en-us/zh-cn 大小写不同(运行时被视为不同 key)')
print('=' * 78)
en_lower = {k.lower(): k for k in en.keys()}
zh_lower = {k.lower(): k for k in zh.keys()}
case_ghosts = []
for lk in sorted(set(en_lower.keys()) & set(zh_lower.keys())):
eo, zo = en_lower[lk], zh_lower[lk]
if eo != zo:
case_ghosts.append((eo, zo, en[eo][0][0], zh[zo][0][0]))
print(f' 共 {len(case_ghosts)} 处幽灵 key:')
for e, z, ef, zf in case_ghosts[:60]:
print(f' en-us:{e} vs zh-cn:{z}')
# ========== E. merge 顺序:page module 内同名 key 自我冲突 ==========
print('\n' + '=' * 78)
print('【E 类】同一个 page module 内同名 key 多次定义(谁覆盖谁看文件内顺序)')
print('=' * 78)
self_dup = []
for locale_name, locale in [('en-us', en), ('zh-cn', zh)]:
for k, occurrences in locale.items():
# group by file
per_file = defaultdict(list)
for f, v, l in occurrences:
per_file[f].append((v, l))
for f, lst in per_file.items():
vals_only = [v for v, l in lst if v != '<object>']
if len(vals_only) > 1:
self_dup.append((locale_name, k, f, lst))
print(f' 共 {len(self_dup)} 处 module 内自冲突:')
for loc, k, f, lst in self_dup[:40]:
for v, l in lst:
if v != '<object>':
print(f' [{loc}] {k} = {v!r} <- {f}:{l}')
# 输出机器可读
out = ROOT / 'docs' / 'i18n-strict-audit.json'
out.write_text(json.dumps({
'A_mismatched_pairs': [
{'key': k, 'en': e, 'zh': z, 'en_file': ef, 'zh_file': zf}
for k, e, z, ef, zf in mismatches
],
'B_domain_dup': [
{'key': k, 'locale': loc, 'files': files, 'values': vals}
for loc, k, files, vals in domain_dup
],
'C_shadow_issues': [
{'locale': loc, 'key': k, 'common_value': cv, 'page_value': pv, 'page_file': pf, 'page_line': pl}
for loc, k, cv, pv, pf, pl in shadow_issues
],
'D_case_ghosts': [
{'en_key': e, 'zh_key': z, 'en_file': ef, 'zh_file': zf}
for e, z, ef, zf in case_ghosts
],
'E_self_dup': [
{'locale': loc, 'key': k, 'file': f, 'entries': lst}
for loc, k, f, lst in self_dup
],
}, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'\n完整报告 -> {out.relative_to(ROOT)}')
if __name__ == '__main__':
main()

View File

@@ -1,394 +0,0 @@
#!/usr/bin/env python3
"""
i18n 迁移脚本:从旧的机器生成 key 迁移到语义化 key
用法:
python scripts/migrate_i18n_keys.py --analyze # 分析当前状态
python scripts/migrate_i18n_keys.py --apply # 执行迁移(会修改文件!)
python scripts/migrate_i18n_keys.py --dry-run # 预览要做的修改
"""
import os
import re
import sys
import json
import shutil
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 搜索目录
SEARCH_DIRS = [
'common',
'components',
'components-diy',
'pages',
'pages_goods',
'pages_order',
'pages_promotion',
'pages_tool',
]
# 旧 key -> 新 key 的映射表(第一阶段:common 通用文案)
# 格式: { "旧key": "新key" }
COMMON_KEY_MAPPING = {
# ===== 通用按钮 =====
'ns-login.l30.t0': 'common.button.save',
'save': 'common.button.save',
'ns-coupon-empty.l26.t0': 'common.button.cancel',
'cancel': 'common.button.cancel',
'pick-regions.l18.t0': 'common.button.confirm',
'complete': 'common.button.confirm',
'confirm': 'common.button.confirm',
'copy': 'common.button.copy',
'share': 'common.button.share',
'search': 'common.button.search',
'delete': 'common.button.delete',
'edit': 'common.button.edit',
'submit': 'common.button.submit',
'close': 'common.button.close',
'reset': 'common.button.reset',
'back': 'common.button.back',
'loadMore': 'common.button.loadMore',
'loadMoreTxt': 'common.button.loadMore',
'retry': 'common.button.retry',
'nextStep': 'common.button.next',
'previous': 'common.button.prev',
# ===== 通用空态 =====
'emptyTips': 'common.empty.default',
'ns-empty.l18.t0': 'common.empty.default',
'noDataTip': 'common.empty.default',
'toGoodsCategoryPage': 'common.empty.goShopping',
'ns-empty.l31.t0': 'common.empty.goShopping',
'goHome': 'common.empty.goHome',
# ===== 通用加载 =====
'ns-loading.l16.t0': 'common.loading.text',
'loadingText': 'common.loading.text',
'loading': 'common.loading.text',
'pullDownRefresh': 'common.loading.pullDownRefresh',
'releaseToUpdate': 'common.loading.releaseToUpdate',
'loadMoreLoading': 'common.loading.loadingMore',
# ===== 通用弹窗/提示 =====
'prompt': 'common.dialog.tipTitle',
'wxwork-contact.l202.t0': 'common.dialog.tipTitle',
'confirmTitle': 'common.dialog.confirmTitle',
# ===== 货币/单位 =====
'currencySymbol': 'common.currency.symbol',
'symbol': 'common.currency.symbol',
'ns-goods-sku.ns-goods-sku-index.l150.t0': 'common.unit.piece',
'unitPiece': 'common.unit.piece',
'unitDay': 'common.unit.day',
'unitHour': 'common.unit.hour',
'unitMinute': 'common.unit.minute',
'unitSecond': 'common.unit.second',
# ===== 通用结果提示 =====
'copySuccess': 'common.tip.copySuccess',
'operationSuccess': 'common.tip.operationSuccess',
'operationFailed': 'common.tip.operationFailed',
'saveSuccess': 'common.tip.saveSuccess',
'saveSuc': 'common.tip.saveSuccess',
'networkError': 'common.tip.networkError',
'copyFail': 'common.tip.copyFail',
# ===== 通用占位符 =====
'searchTip': 'common.placeholder.search',
'searchPlaceholder': 'common.placeholder.search',
'goods.search': 'common.placeholder.search',
'diy-search.l31.t0': 'common.placeholder.search',
'searchEntrance': 'common.placeholder.search',
'inputPlaceholder': 'common.placeholder.input',
'selectPlaceholder': 'common.placeholder.select',
# ===== 通用状态 =====
'statusPending': 'common.status.pending',
'statusProcessing': 'common.status.processing',
'statusSuccess': 'common.status.success',
'statusFailed': 'common.status.failed',
'statusCancelled': 'common.status.cancelled',
'statusClosed': 'common.status.closed',
# ===== 底部导航 =====
'navHome': 'common.nav.home',
'navCategory': 'common.nav.category',
'navCart': 'common.nav.cart',
'navMember': 'common.nav.member',
# ===== 客服 =====
'ns-chat.l19.t0': 'common.contact.customerService',
'contactService': 'common.contact.customerService',
'customerService': 'common.contact.customerService',
'serviceHotline': 'common.contact.serviceHotline',
# ===== 时间相关 =====
'timeJustNow': 'common.time.justNow',
'timeMinutesAgo': 'common.time.minutesAgo',
'timeHoursAgo': 'common.time.hoursAgo',
'timeDaysAgo': 'common.time.daysAgo',
'timeYesterday': 'common.time.yesterday',
'timeBeforeYesterday': 'common.time.beforeYesterday',
# ===== 表单校验 =====
'required': 'common.form.required',
'invalid': 'common.form.invalid',
'ns-form.l545.t0': 'common.form.maxUpload',
'maxUploadTip': 'common.form.maxUpload',
'endDateBeforeStart': 'common.form.endDateBeforeStart',
'endTimeBeforeStartTime': 'common.form.endTimeBeforeStartTime',
'idCardInvalid': 'common.form.idCardInvalid',
'phoneInvalid': 'common.form.phoneInvalid',
'selectAtLeastOne': 'common.form.selectAtLeastOne',
'uploadAtLeastOne': 'common.form.uploadAtLeastOne',
'selectProvinceCity': 'common.form.selectProvinceCity',
'selectProvinceCityDistrict': 'common.form.selectProvinceCityDistrict',
# ===== 购买/咨询(多个地方重复) =====
'ns-goods-recommend.l20.t0': 'common.action.buy',
'buy': 'common.action.buy',
'buynow': 'common.action.buyNow',
'ns-goods-sku.l195.t0': 'common.action.buyNow',
'buyNow': 'common.action.buyNow',
'ns-goods.l102.t0': 'common.action.consult',
'consult': 'common.action.consult',
'Make': 'common.action.consult',
'make': 'common.action.consult',
# ===== 底部Tab =====
'ns-tabbar.index.l18.t0': 'common.nav.home',
'ns-tabbar.index.l21.t0': 'common.nav.category',
'ns-tabbar.index.l24.t0': 'common.nav.cart',
'ns-tabbar.index.l27.t0': 'common.nav.member',
}
def parse_lang_file(filepath):
"""解析语言包文件,返回展平的 key-value 字典"""
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
match = re.search(r'export const lang = \{(.*?)\n\};', content, re.DOTALL)
if not match:
return {}
body = match.group(1)
lines = body.split('\n')
stack = []
result = {}
for line in lines:
stripped = line.strip()
if not stripped:
continue
indent = len(line) - len(line.lstrip())
level = indent // 4 + 1
while len(stack) >= level:
stack.pop()
obj_match = re.match(r'"([^"]+)"\s*:\s*\{', stripped)
if obj_match:
stack.append(obj_match.group(1))
continue
val_match = re.match(r'"([^"]+)"\s*:\s*"([^"]*)"', stripped)
if val_match:
key = val_match.group(1)
value = val_match.group(2)
full_path = '.'.join(stack + [key])
result[full_path] = value
return result
def find_all_lang_calls():
"""找出所有文件中的 $lang() 调用,返回 {key: [(file, line), ...]}"""
pattern = re.compile(r'\$lang\([\'"]([^\'"]+)[\'"]')
results = {}
for dirname in SEARCH_DIRS:
dirpath = os.path.join(PROJECT_ROOT, dirname)
if not os.path.isdir(dirpath):
continue
for root, dirs, files in os.walk(dirpath):
for fname in files:
if not fname.endswith(('.vue', '.js')):
continue
fpath = os.path.join(root, fname)
try:
with open(fpath, 'r', encoding='utf-8') as f:
content = f.read()
for i, line in enumerate(content.split('\n'), 1):
matches = pattern.findall(line)
for k in matches:
if k not in results:
results[k] = []
rel_path = os.path.relpath(fpath, PROJECT_ROOT)
results[k].append((rel_path, i, line.strip()))
except:
pass
return results
def analyze():
"""分析当前状态"""
zh_cn = parse_lang_file(os.path.join(PROJECT_ROOT, 'lang', 'zh-cn.js'))
calls = find_all_lang_calls()
print("=" * 60)
print("i18n 迁移分析报告")
print("=" * 60)
print(f"\n语言包总 key 数: {len(zh_cn)}")
print(f"代码中调用的 key 数: {len(calls)}")
# 统计机器生成 key
machine_keys = [k for k in zh_cn if re.search(r'\.l\d+\.t\d+', k)]
print(f"机器生成 key 数: {len(machine_keys)} ({len(machine_keys)/len(zh_cn)*100:.1f}%)")
# 统计扁平 key
flat_keys = [k for k in zh_cn if '.' not in k]
print(f"扁平 key 数: {len(flat_keys)}")
# 分析 common 映射覆盖率
mapped_old_keys = set(COMMON_KEY_MAPPING.keys())
existing_mapped = [k for k in mapped_old_keys if k in zh_cn]
print(f"\ncommon 映射表大小: {len(COMMON_KEY_MAPPING)}")
print(f"在语言包中存在的: {len(existing_mapped)}")
# 找出被调用的 common key
called_common = [k for k in mapped_old_keys if k in calls]
print(f"被代码调用的: {len(called_common)}")
# 同翻译不同 key 分析
value_to_keys = {}
for k, v in zh_cn.items():
if v and len(v) >= 2:
if v not in value_to_keys:
value_to_keys[v] = []
value_to_keys[v].append(k)
duplicates = {v: ks for v, ks in value_to_keys.items() if len(ks) > 1}
print(f"\n同翻译不同 key 组数: {len(duplicates)}")
print("\nTop 15 重复翻译:")
for v, ks in sorted(duplicates.items(), key=lambda x: -len(x[1]))[:15]:
print(f' "{v}" ({len(ks)}x):')
for k in ks[:5]:
status = " [已映射]" if k in COMMON_KEY_MAPPING else ""
print(f" - {k}{status}")
print("\n" + "=" * 60)
print("分析完成!使用 --dry-run 预览修改,--apply 执行迁移")
print("=" * 60)
def dry_run():
"""预览要做的修改"""
calls = find_all_lang_calls()
changes = []
for old_key, new_key in COMMON_KEY_MAPPING.items():
if old_key in calls:
for fpath, line, content in calls[old_key]:
changes.append({
'file': fpath,
'line': line,
'old_key': old_key,
'new_key': new_key,
'content': content
})
print(f"预计修改 {len(changes)} 处调用,涉及 {len(set(c['file'] for c in changes))} 个文件")
print()
for c in changes[:30]:
print(f" {c['file']}:{c['line']}")
print(f" 旧: {c['old_key']}")
print(f" 新: {c['new_key']}")
if len(changes) > 30:
print(f"\n ... 还有 {len(changes) - 30} 处")
def apply_migration():
"""执行迁移:替换所有文件中的旧 key"""
# 1. 备份旧语言包
backup_dir = os.path.join(PROJECT_ROOT, 'lang', '_backup')
os.makedirs(backup_dir, exist_ok=True)
for locale in ['zh-cn', 'en-us']:
src = os.path.join(PROJECT_ROOT, 'lang', f'{locale}.js')
if os.path.exists(src):
dst = os.path.join(backup_dir, f'{locale}.js.bak')
shutil.copy2(src, dst)
print(f"已备份: {locale}.js -> _backup/{locale}.js.bak")
# 2. 替换源码中的 $lang 调用
total_replacements = 0
files_modified = 0
pattern = re.compile(r'\$lang\(([\'"])([^\'"]+)([\'"])')
for dirname in SEARCH_DIRS:
dirpath = os.path.join(PROJECT_ROOT, dirname)
if not os.path.isdir(dirpath):
continue
for root, dirs, files in os.walk(dirpath):
for fname in files:
if not fname.endswith(('.vue', '.js')):
continue
fpath = os.path.join(root, fname)
try:
with open(fpath, 'r', encoding='utf-8') as f:
content = f.read()
original_content = content
def replace_match(m):
nonlocal total_replacements
quote1 = m.group(1)
old_key = m.group(2)
quote2 = m.group(3)
if old_key in COMMON_KEY_MAPPING:
new_key = COMMON_KEY_MAPPING[old_key]
total_replacements += 1
return f"$lang({quote1}{new_key}{quote2}"
return m.group(0)
content = pattern.sub(replace_match, content)
if content != original_content:
with open(fpath, 'w', encoding='utf-8') as f:
f.write(content)
files_modified += 1
rel_path = os.path.relpath(fpath, PROJECT_ROOT)
print(f" 修改: {rel_path}")
except Exception as e:
print(f" 跳过 {fname}: {e}")
print(f"\n完成!替换了 {total_replacements} 处调用,修改了 {files_modified} 个文件")
def main():
args = sys.argv[1:]
if not args or args[0] == '--analyze':
analyze()
elif args[0] == '--dry-run':
dry_run()
elif args[0] == '--apply':
force = '--force' in args
if not force:
print("警告:这将修改源代码文件!")
print("建议先提交当前代码到 git,确保可以回滚。")
confirm = input("确认继续?(yes/no): ")
if confirm.lower() != 'yes':
print("已取消")
return
apply_migration()
else:
print(__doc__)
if __name__ == '__main__':
main()

View File

@@ -1,94 +0,0 @@
"""
验证新的多文件语言包结构是否正常
"""
import os
PROJECT_ROOT = r'd:\projects\shop-projects\frontend'
def parse_js_obj(content):
"""简单解析 JS 对象为 Python 字典(只处理基本情况)"""
result = {}
lines = content.split('\n')
for line in lines:
line = line.strip()
# 匹配简单的 key: 'value' 格式
if line.startswith('//') or line.endswith('},') or line.endswith('{') or line == '}' or line == '},':
continue
if ':' in line and ("'" in line or '"' in line):
# 简单提取
parts = line.split(':', 1)
if len(parts) == 2:
key = parts[0].strip().strip("'").strip('"')
val = parts[1].strip().rstrip(',').strip().strip("'").strip('"')
if key and val:
result[key] = val
return result
def count_keys_in_obj(obj, prefix=''):
"""递归统计对象中的叶子 key 数量"""
count = 0
for k, v in obj.items():
full_key = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
count += count_keys_in_obj(v, full_key)
else:
count += 1
return count
def check_module_structure():
print("=" * 60)
print("检查新语言包结构")
print("=" * 60)
for locale in ['zh-cn', 'en-us']:
lang_dir = os.path.join(PROJECT_ROOT, 'lang', locale)
if not os.path.exists(lang_dir):
print(f"❌ {locale} 目录不存在")
continue
print(f"\n{locale}:")
# 列出所有模块文件
modules = []
for f in sorted(os.listdir(lang_dir)):
if f.endswith('.js') and f != 'index.js':
modules.append(f)
filepath = os.path.join(lang_dir, f)
with open(filepath, 'r', encoding='utf-8') as fp:
content = fp.read()
# 简单统计行数
lines = content.count('\n')
print(f" {f}: {lines} 行")
print(f" 共 {len(modules)} 个模块")
# 检查 index.js 是否存在
index_path = os.path.join(lang_dir, 'index.js')
if os.path.exists(index_path):
with open(index_path, 'r', encoding='utf-8') as fp:
content = fp.read()
# 统计导入的模块数
import_count = content.count("import {")
print(f" index.js: 导入了 {import_count} 个模块")
# 检查是否所有模块都被导入了
missing = []
for m in modules:
mod_name = m.replace('.js', '')
if f"import {{ {mod_name} }}" not in content:
missing.append(m)
if missing:
print(f" ⚠️ 未导入的模块: {missing}")
else:
print(f" ✅ 所有模块都已导入")
else:
print(f" ❌ index.js 不存在")
def main():
check_module_structure()
print("\n" + "=" * 60)
print("结构检查完成")
print("=" * 60)
if __name__ == '__main__':
main()