feat: 新增多语言国际化页面配置,修复部分页面硬编码文案
1. 新增了zh-cn和en-us两套语言下的大量页面国际化配置文件,覆盖商品、订单、会员、营销活动等多个业务模块 2. 修复了分销页面、VR页面、支付页面、商品不存在页面的硬编码提示文案,替换为国际化调用 3. 删除了原有的i18n检查脚本文件
This commit is contained in:
@@ -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]}"')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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}')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user