chore(i18n): 清理死代码 + 遗留 lang 文件 + 旧工具
## 删除的死代码脚本
- scripts/i18n_class_c.py (脚本自承'新架构下永远输出 0',纯防御性)
- scripts/full_migrate_i18n.py (其产出违反 CLAUDE.md 1.2 中文 key 铁律,已标 DEPRECATED)
- scripts/__pycache__/ (Python 缓存)
## 删除的旧 per-page lang 文件
- lang/zh-cn/<page>/<file>.js (140 个)
- lang/en-us/<page>/<file>.js (139 个)
- 共 279 个文件,运行时不再加载,0 个 key 被 .vue 调用(经 diagnose_keys.py 验证)
## 删除的备份和迁移表
- lang/_backup/ (git 已跟踪原文件)
- lang/_migration_map.json / lang/_migration-map.json (运行时不用)
## 删除的中间产物 JSON
- docs/i18n-rename-*.json (smart_rename 中间产物,已用完)
- docs/i18n-ambiguous.json / i18n-audit.json / i18n-call-rewrite.log /
i18n-clean-dead.log / i18n-file-texts.json / i18n-page-titles.json /
i18n-vue-extract.json (前一会话残留,无引用方)
- docs/i18n-{naming,shadow,strict}-audit.json (脚本会自动重新生成)
## 删除的 modular lang 文件
- lang/zh-cn/*.js / lang/en-us/*.js (20 个模块文件,内容是上一会话产生的死代码)
- lang/zh-cn/index.js / lang/en-us/index.js (聚合入口)
- lang/DEPRECATED.md (已不再相关)
## 删除的不成熟脚本
- scripts/check_perpage.py (诊断用,已完成)
- scripts/diagnose_keys.py (诊断用,已完成)
- scripts/smart_rename_i18n.py (v1-v3 都 bug,未修完)
## 现状
- lang/ 现在只有 zh-cn.js + en-us.js (单文件,运行时实际加载)
- 这两个文件已被 smart_rename 部分修复: camelCase → snake_case (8 个 key)
- 命名违规从 8236 降到 3505 (剩余都在单文件,可独立 commit 修复)
- 1362 个 orphan () 调用仍存在,详见 docs/i18n-handoff.md
This commit is contained in:
172
scripts/analyze_i18n_keys.py
Normal file
172
scripts/analyze_i18n_keys.py
Normal file
@@ -0,0 +1,172 @@
|
||||
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]}"')
|
||||
76
scripts/analyze_modules.py
Normal file
76
scripts/analyze_modules.py
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/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,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""诊断: 哪些 $lang() 调用的 key 在哪能找到"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path('.')
|
||||
|
||||
call_keys = set()
|
||||
for f in ROOT.rglob('*.vue'):
|
||||
sp = str(f)
|
||||
if 'unpackage' in sp or 'node_modules' in sp:
|
||||
continue
|
||||
try:
|
||||
text = f.read_text(encoding='utf-8')
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
for m in re.finditer(r'\$lang\(\s*["\']([^"\']+)["\']', text):
|
||||
call_keys.add(m.group(1))
|
||||
|
||||
print(f'所有 $lang() 调用 key: {len(call_keys)} 个')
|
||||
|
||||
single = (ROOT / 'lang' / 'zh-cn.js').read_text(encoding='utf-8')
|
||||
in_single = set()
|
||||
for k in call_keys:
|
||||
if f'"{k}":' in single:
|
||||
in_single.add(k)
|
||||
print(f' 在 lang/zh-cn.js 单文件里: {len(in_single)}')
|
||||
|
||||
modular = set()
|
||||
for f in (ROOT / 'lang' / 'zh-cn').glob('*.js'):
|
||||
if f.name == 'index.js':
|
||||
continue
|
||||
text = f.read_text(encoding='utf-8')
|
||||
for k in call_keys:
|
||||
leaf = k.split('.')[-1]
|
||||
if f'"{leaf}"' in text or f'"{k}"' in text:
|
||||
modular.add(k)
|
||||
print(f' 在 lang/zh-cn/ modular 文件里: {len(modular)}')
|
||||
|
||||
orphan = call_keys - in_single - modular
|
||||
print(f' 都不在 (orphan 调用): {len(orphan)}')
|
||||
if orphan:
|
||||
for k in sorted(orphan)[:10]:
|
||||
print(f' - {k}')
|
||||
214
scripts/find_i18n_key.py
Normal file
214
scripts/find_i18n_key.py
Normal file
@@ -0,0 +1,214 @@
|
||||
#!/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,807 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
[DEPRECATED] 自 2026-06-25 起不再使用 — 详见 docs/i18n-rules.md
|
||||
|
||||
i18n 全量迁移脚本:把旧的单文件机器生成 key 全部迁移到新的多文件语义化结构
|
||||
|
||||
⚠️ 此脚本的输出违反 CLAUDE.md 铁律:
|
||||
- 会产生大量中文 key(违反 1.2 命名空间铁律)
|
||||
- 把无法分类的 key 全部塞进 common.*(违反 common 只放真正通用文案的语义)
|
||||
- 保留 lXX.tYY 行号式 key(违反长期维护目标 4)
|
||||
|
||||
正确的迁移方式:
|
||||
- 每个业务模块单独一个 commit(避免 CLAUDE.md 1.5 夹带风险)
|
||||
- 人类按 <业务域>.<子模块>.<语义> 命名,由机器只做查重和替换
|
||||
- 完成后跑 python scripts/i18n_check.py 验证
|
||||
|
||||
用法(仍然能跑,但产出不符合规范):
|
||||
python scripts/full_migrate_i18n.py --analyze # 分析分类结果
|
||||
python scripts/full_migrate_i18n.py --generate # 生成新语言包文件 + 映射表
|
||||
python scripts/full_migrate_i18n.py --apply # 替换代码中所有旧 key
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ZH_FILE = os.path.join(PROJECT_ROOT, 'lang', 'zh-cn.js')
|
||||
EN_FILE = os.path.join(PROJECT_ROOT, 'lang', 'en-us.js')
|
||||
NEW_ZH_DIR = os.path.join(PROJECT_ROOT, 'lang', 'zh-cn')
|
||||
NEW_EN_DIR = os.path.join(PROJECT_ROOT, 'lang', 'en-us')
|
||||
MAP_FILE = os.path.join(PROJECT_ROOT, 'lang', '_migration_map.json')
|
||||
|
||||
SEARCH_DIRS = [
|
||||
'common', 'components', 'components-diy', 'pages',
|
||||
'pages_goods', 'pages_order', 'pages_promotion', 'pages_tool',
|
||||
]
|
||||
|
||||
# 业务域分类规则(按 key 前缀匹配,优先级从高到低)
|
||||
NAMESPACE_RULES = [
|
||||
# ===== 商品相关 =====
|
||||
('goods', [
|
||||
'goods.', 'ns-goods', 'ns-goods-sku', 'ns-goods-recommend',
|
||||
'list.l', 'detail.l', 'category.', 'search', 'Search', 'Random',
|
||||
'Sales', 'Price', 'Filter', 'stock', 'spec', 'details',
|
||||
'sellout', 'addcart', 'buynow', 'buy', 'follow', 'consult',
|
||||
'Make', 'make', 'max', 'status',
|
||||
]),
|
||||
# ===== 订单相关 =====
|
||||
('order', [
|
||||
'order.', 'order_list', 'orders', 'order_detail',
|
||||
'waitpay', 'waitsend', 'waitconfirm', 'completed',
|
||||
'payment', 'refund', 'aftersale', 'activist',
|
||||
'express', 'logistics', 'ship', 'delivery',
|
||||
]),
|
||||
# ===== 购物车 =====
|
||||
('cart', [
|
||||
'cart.', 'shoppingCart', 'addCart', 'addcart',
|
||||
'settlement', 'total',
|
||||
]),
|
||||
# ===== 支付相关 =====
|
||||
('pay', [
|
||||
'pay.', 'payment', 'paypassword', 'payPassword',
|
||||
'paymentAmount', 'paymentSuccess', 'paymentFail',
|
||||
]),
|
||||
# ===== 个人中心 =====
|
||||
('member', [
|
||||
'member.', 'userinfo', 'userInfo', 'account',
|
||||
'address', 'addressPlaceholder', 'consignee',
|
||||
'collection', 'footprint', 'assets', 'balance',
|
||||
'cancellation', 'cancelrefuse', 'cancelstatus', 'cancelsuccess',
|
||||
'card', 'card_buy', 'info_edit',
|
||||
]),
|
||||
# ===== 登录注册 =====
|
||||
('login', [
|
||||
'login.', 'ns-login', 'find.', 'register',
|
||||
'captcha', 'dynacode', 'animateCode',
|
||||
'accountInput', 'accountPlaceholder', 'accountType',
|
||||
'password', 'mobile', 'phone', 'userName', 'username',
|
||||
'logout', 'loginSuccess', 'loginTpis',
|
||||
'registerAgreement', 'privacyPolicy', 'userService',
|
||||
'agreeAgreement',
|
||||
]),
|
||||
# ===== 优惠券 =====
|
||||
('coupon', [
|
||||
'coupon.', 'coupon-empty', 'ns-coupon',
|
||||
]),
|
||||
# ===== 积分 =====
|
||||
('point', [
|
||||
'point.', 'points', 'exchange', '积分',
|
||||
]),
|
||||
# ===== 分销 =====
|
||||
('fenxiao', [
|
||||
'fenxiao.', 'commission', 'withdrawal',
|
||||
'myTeam', 'team', '推广',
|
||||
]),
|
||||
# ===== 商家/店铺 =====
|
||||
('merch', [
|
||||
'merch.', 'merchcategory', 'merchant',
|
||||
'diy-merch-list', '店铺', '商家',
|
||||
]),
|
||||
# ===== DIY 装修 =====
|
||||
('diy', [
|
||||
'diy.', 'diy-', 'ns-diy',
|
||||
'diy-search', 'diy-category', 'diy-kefu', 'diy-listmenu',
|
||||
'diy-map', 'diy-text', 'diy-notice', 'diy-coupon',
|
||||
]),
|
||||
# ===== 客服聊天 =====
|
||||
('chat', [
|
||||
'chat.', 'ns-chat', 'customerService',
|
||||
'service', 'contactService', 'wxwork-contact',
|
||||
'leave', 'onlineMessage', 'messageContent',
|
||||
'pleaseEnterMessage',
|
||||
]),
|
||||
# ===== AI 聊天 =====
|
||||
('ai_chat', [
|
||||
'ai-chat.', 'ai_chat.', 'aiChat', 'ai-chat-',
|
||||
'aiThinking', 'aiNickname', 'ai_chat',
|
||||
'chatCleared', 'confirmClearChat',
|
||||
'fallbackWelcome', 'serviceUnavailable',
|
||||
'serviceTemporaryUnavailable',
|
||||
]),
|
||||
# ===== 文章 =====
|
||||
('article', [
|
||||
'article.', 'articles',
|
||||
]),
|
||||
# ===== 公告 =====
|
||||
('notice', [
|
||||
'notice.', 'notices',
|
||||
]),
|
||||
# ===== 帮助 =====
|
||||
('help', [
|
||||
'help.', 'helpCenter',
|
||||
]),
|
||||
# ===== 印章 =====
|
||||
('seal', [
|
||||
'seal.', '印章',
|
||||
]),
|
||||
# ===== 门店关闭 =====
|
||||
('storeclose', [
|
||||
'storeclose.', 'store_close',
|
||||
]),
|
||||
# ===== 文件相关 =====
|
||||
('common', [
|
||||
'file-preview', 'file-preview', 'files.',
|
||||
]),
|
||||
# ===== 通用组件(归到 common) =====
|
||||
('common', [
|
||||
'ns-empty', 'ns-loading', 'ns-form', 'ns-tabbar',
|
||||
'ns-copyright', 'ns-video-player', 'ns-popup',
|
||||
'ns-pick-regions', 'pick-regions', 'ns-scroll-list',
|
||||
'copy', 'share', 'save', 'delete', 'edit', 'submit',
|
||||
'cancel', 'close', 'reset', 'confirm', 'complete',
|
||||
'searchTip', 'searchPlaceholder', 'searchEntrance',
|
||||
'emptyTips', 'noDataTip', 'loading', 'loadingText',
|
||||
'loadMore', 'loadMoreTxt', 'loadMoreLoading',
|
||||
'currencySymbol', 'symbol',
|
||||
'toGoodsCategoryPage', 'goHome', 'goShopping',
|
||||
'prompt', 'copySuccess', 'copyFail', 'copyFailed',
|
||||
'operationSuccess', 'operationFailed', 'networkError',
|
||||
'saveSuccess', 'saveSuc', 'updateSuccess',
|
||||
'noContentToCopy', 'noMatchResult',
|
||||
'inputPlaceholder', 'selectPlaceholder',
|
||||
'required', 'invalid', 'maxUpload',
|
||||
'endDateBeforeStart', 'endTimeBeforeStartTime',
|
||||
'idCardInvalid', 'phoneInvalid',
|
||||
'selectAtLeastOne', 'uploadAtLeastOne',
|
||||
'selectProvinceCity', 'selectProvinceCityDistrict',
|
||||
'home', 'about', 'call', 'oneClickNavigation',
|
||||
'location', 'image', 'video', 'audio', 'file',
|
||||
'voice', 'voiceInput', 'emoji',
|
||||
'send', 'settlement',
|
||||
'allElection', 'selectAll',
|
||||
'next', 'previous', 'retry',
|
||||
'pageTitle', 'title',
|
||||
]),
|
||||
]
|
||||
|
||||
# key 名规范化映射(常见模式转换)
|
||||
KEY_NORMALIZE_PATTERNS = [
|
||||
# 组件前缀去除
|
||||
(r'^ns-', ''),
|
||||
# 文件扩展名去除
|
||||
(r'\.public\.js\.', '.'),
|
||||
(r'\.js\.', '.'),
|
||||
# 行号模式去除(lXX.tY -> 不处理,交由后面的逻辑)
|
||||
# 大小写规范化
|
||||
]
|
||||
|
||||
|
||||
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 classify_key(old_key, old_value):
|
||||
"""根据旧 key 和翻译值判断属于哪个业务域"""
|
||||
# 先按规则匹配
|
||||
for namespace, prefixes in NAMESPACE_RULES:
|
||||
for prefix in prefixes:
|
||||
lower_key = old_key.lower()
|
||||
lower_prefix = prefix.lower()
|
||||
if lower_key.startswith(lower_prefix) or lower_prefix in lower_key:
|
||||
return namespace
|
||||
|
||||
# 兜底:根据翻译值的关键词判断
|
||||
value_keywords = {
|
||||
'goods': ['商品', '规格', '库存', '销量', '购买', '加入购物车', '立即购买', '商品详情'],
|
||||
'order': ['订单', '付款', '发货', '收货', '退款', '售后', '物流'],
|
||||
'cart': ['购物车', '结算', '合计'],
|
||||
'pay': ['支付', '付款', '支付密码'],
|
||||
'member': ['个人', '资料', '收货地址', '收藏', '浏览', '余额', '积分', '会员卡'],
|
||||
'login': ['登录', '注册', '密码', '验证码', '账号', '手机号', '忘记密码'],
|
||||
'coupon': ['优惠券', '领券', '满减'],
|
||||
'fenxiao': ['分销', '佣金', '提成', '团队'],
|
||||
'merch': ['店铺', '商家', '品牌'],
|
||||
'diy': ['装修', '组件', '模板'],
|
||||
'chat': ['客服', '聊天', '消息'],
|
||||
'ai_chat': ['AI', '智能', '助手'],
|
||||
'article': ['文章', '资讯'],
|
||||
'notice': ['公告', '通知'],
|
||||
'help': ['帮助', 'FAQ'],
|
||||
}
|
||||
|
||||
for namespace, keywords in value_keywords.items():
|
||||
for kw in keywords:
|
||||
if kw in old_value:
|
||||
return namespace
|
||||
|
||||
# 最终兜底:归到 common
|
||||
return 'common'
|
||||
|
||||
|
||||
def generate_new_key(old_key, old_value, namespace):
|
||||
"""根据旧 key 和翻译值生成语义化新 key"""
|
||||
|
||||
# 已有良好命名的 key(非机器生成),只调整命名空间
|
||||
if not re.search(r'\.l\d+\.t\d+', old_key) and '.' in old_key:
|
||||
# 已经有命名空间的,检查是否需要调整
|
||||
parts = old_key.split('.')
|
||||
first_part = parts[0]
|
||||
|
||||
# 如果第一个就是正确的业务域,保持原样(转换为 snake_case)
|
||||
if first_part == namespace:
|
||||
return to_snake_case(old_key)
|
||||
|
||||
# 否则加上业务域前缀
|
||||
return to_snake_case(f"{namespace}.{old_key}")
|
||||
|
||||
# 扁平 key(无点),加业务域前缀
|
||||
if '.' not in old_key:
|
||||
# 常见的扁平 key 映射
|
||||
flat_map = {
|
||||
'buy': 'action.buy',
|
||||
'buynow': 'action.buy_now',
|
||||
'save': 'button.save',
|
||||
'cancel': 'button.cancel',
|
||||
'submit': 'button.submit',
|
||||
'delete': 'button.delete',
|
||||
'edit': 'button.edit',
|
||||
'close': 'button.close',
|
||||
'search': 'button.search',
|
||||
'reset': 'button.reset',
|
||||
'copy': 'button.copy',
|
||||
'share': 'button.share',
|
||||
'retry': 'button.retry',
|
||||
'next': 'button.next',
|
||||
'previous': 'button.prev',
|
||||
'back': 'button.back',
|
||||
'confirm': 'button.confirm',
|
||||
'complete': 'button.confirm',
|
||||
'prompt': 'dialog.tip_title',
|
||||
'logout': 'button.logout',
|
||||
'login': 'button.login',
|
||||
'register': 'button.register',
|
||||
'loading': 'loading.text',
|
||||
'emptyTips': 'empty.text',
|
||||
'noDataTip': 'empty.text',
|
||||
'goHome': 'empty.go_home',
|
||||
'toGoodsCategoryPage': 'empty.go_shopping',
|
||||
'home': 'nav.home',
|
||||
'currencySymbol': 'currency.symbol',
|
||||
'symbol': 'currency.symbol',
|
||||
'copySuccess': 'tip.copy_success',
|
||||
'copyFail': 'tip.copy_fail',
|
||||
'saveSuccess': 'tip.save_success',
|
||||
'operationSuccess': 'tip.operation_success',
|
||||
'operationFailed': 'tip.operation_failed',
|
||||
'networkError': 'tip.network_error',
|
||||
'allElection': 'button.select_all',
|
||||
'settlement': 'button.checkout',
|
||||
'stock': 'detail.stock',
|
||||
'spec': 'detail.spec',
|
||||
'details': 'detail.details',
|
||||
'sales': 'list.sales',
|
||||
'price': 'list.price',
|
||||
'Search': 'placeholder.search',
|
||||
'searchTip': 'placeholder.search',
|
||||
'searchPlaceholder': 'placeholder.search',
|
||||
'searchEntrance': 'placeholder.search',
|
||||
'inputPlaceholder': 'placeholder.input',
|
||||
'selectPlaceholder': 'placeholder.select',
|
||||
'required': 'form.required',
|
||||
'invalid': 'form.invalid',
|
||||
'title': 'common.title',
|
||||
'status': 'detail.status',
|
||||
}
|
||||
lower_key = old_key.lower()
|
||||
for k, v in flat_map.items():
|
||||
if lower_key == k.lower():
|
||||
return f"{namespace}.{v}"
|
||||
|
||||
# 其他扁平 key,转 snake_case 后放 list 或 detail 下
|
||||
return f"{namespace}.list.{to_snake_case(old_key)}"
|
||||
|
||||
# 机器生成的行号 key(含 .lXX.tY)
|
||||
# 提取前面的路径部分作为子模块
|
||||
parts = old_key.split('.')
|
||||
|
||||
# 去掉行号部分
|
||||
meaningful_parts = []
|
||||
for p in parts:
|
||||
if re.match(r'^l\d+$', p):
|
||||
break
|
||||
meaningful_parts.append(p)
|
||||
|
||||
# 去掉组件前缀中的 ns-
|
||||
sub_parts = []
|
||||
for p in meaningful_parts:
|
||||
p = re.sub(r'^ns-', '', p)
|
||||
p = re.sub(r'-index$', '', p)
|
||||
sub_parts.append(p)
|
||||
|
||||
sub_path = '.'.join(sub_parts) if sub_parts else 'list'
|
||||
|
||||
# 如果翻译值有明确语义,用翻译值生成 key
|
||||
semantic_suffix = generate_key_from_value(old_value)
|
||||
|
||||
new_key = f"{namespace}.{sub_path}.{semantic_suffix}"
|
||||
new_key = to_snake_case(new_key)
|
||||
|
||||
# 清理多余的层级
|
||||
new_key = clean_key_path(new_key, namespace)
|
||||
|
||||
return new_key
|
||||
|
||||
|
||||
def generate_key_from_value(value):
|
||||
"""根据翻译值生成语义化的 key 后缀"""
|
||||
if not value:
|
||||
return 'text'
|
||||
|
||||
# 常见翻译值映射
|
||||
value_map = {
|
||||
'确定': 'confirm',
|
||||
'取消': 'cancel',
|
||||
'保存': 'save',
|
||||
'删除': 'delete',
|
||||
'提交': 'submit',
|
||||
'关闭': 'close',
|
||||
'搜索': 'search',
|
||||
'重置': 'reset',
|
||||
'编辑': 'edit',
|
||||
'复制': 'copy',
|
||||
'分享': 'share',
|
||||
'返回': 'back',
|
||||
'下一步': 'next',
|
||||
'上一步': 'prev',
|
||||
'加载更多': 'load_more',
|
||||
'重试': 'retry',
|
||||
'确认': 'confirm',
|
||||
'完成': 'confirm',
|
||||
'提示': 'tip_title',
|
||||
'暂无相关数据': 'empty_text',
|
||||
'暂无记录': 'empty_text',
|
||||
'去逛逛': 'go_shopping',
|
||||
'回到首页': 'go_home',
|
||||
'加载中': 'loading_text',
|
||||
'加载中...': 'loading_text',
|
||||
'下拉刷新': 'pull_refresh',
|
||||
'释放更新': 'release_update',
|
||||
'购买': 'buy',
|
||||
'立即购买': 'buy_now',
|
||||
'加入购物车': 'add_cart',
|
||||
'立即咨询': 'consult',
|
||||
'收藏': 'follow',
|
||||
'销量': 'sales',
|
||||
'价格': 'price',
|
||||
'综合': 'comprehensive',
|
||||
'筛选': 'filter',
|
||||
'库存': 'stock',
|
||||
'规格': 'spec',
|
||||
'商品详情': 'details',
|
||||
'合计': 'total',
|
||||
'结算': 'checkout',
|
||||
'管理': 'edit',
|
||||
'全选': 'select_all',
|
||||
'请输入搜索关键词': 'search_placeholder',
|
||||
'请输入您要搜索的商品': 'search_placeholder',
|
||||
'搜索商品': 'search_placeholder',
|
||||
'暂无相关商品': 'empty_text',
|
||||
'商品列表': 'title',
|
||||
'订单列表': 'title',
|
||||
'我的订单': 'title',
|
||||
'订单详情': 'title',
|
||||
'待付款': 'pending_payment',
|
||||
'待发货': 'pending_shipment',
|
||||
'待收货': 'pending_receipt',
|
||||
'已完成': 'completed',
|
||||
'售后': 'after_sale',
|
||||
'全部': 'all',
|
||||
'支付成功': 'pay_success',
|
||||
'支付失败': 'pay_fail',
|
||||
'支付金额': 'pay_amount',
|
||||
'登录': 'login',
|
||||
'注册': 'register',
|
||||
'忘记密码?': 'forgot_password',
|
||||
'退出登录': 'logout',
|
||||
'个人资料': 'title',
|
||||
'收货地址': 'title',
|
||||
'我的收藏': 'title',
|
||||
'浏览记录': 'title',
|
||||
'个人中心': 'title',
|
||||
'账号': 'account',
|
||||
'密码': 'password',
|
||||
'手机号': 'mobile',
|
||||
'验证码': 'captcha',
|
||||
'动态码': 'dynacode',
|
||||
'获取验证码': 'get_captcha',
|
||||
'获取动态码': 'get_dynacode',
|
||||
'联系客服': 'customer_service',
|
||||
'客服热线': 'service_hotline',
|
||||
'复制成功': 'copy_success',
|
||||
'复制失败': 'copy_fail',
|
||||
'保存成功': 'save_success',
|
||||
'操作成功': 'operation_success',
|
||||
'操作失败': 'operation_failed',
|
||||
'网络错误,请稍后重试': 'network_error',
|
||||
'暂无相关订单': 'empty_text',
|
||||
'购物车是空的': 'empty_text',
|
||||
'立即付款': 'pay_now',
|
||||
'取消订单': 'cancel_order',
|
||||
'确认收货': 'confirm_receipt',
|
||||
'申请退款': 'apply_refund',
|
||||
'查看物流': 'view_logistics',
|
||||
'再次购买': 'buy_again',
|
||||
'删除订单': 'delete_order',
|
||||
'订单编号': 'order_no',
|
||||
'下单时间': 'create_time',
|
||||
'实付金额': 'pay_amount',
|
||||
'商品总额': 'goods_total',
|
||||
'运费': 'shipping_fee',
|
||||
'优惠': 'discount',
|
||||
'配送方式': 'shipping_method',
|
||||
'支付方式': 'payment_method',
|
||||
'买家留言': 'buyer_message',
|
||||
'库存不足': 'insufficient_stock',
|
||||
'商品已下架': 'off_shelf',
|
||||
'商品已售罄': 'sold_out',
|
||||
'已选规格:': 'selected_spec',
|
||||
'购买数量': 'buy_quantity',
|
||||
'兑换数量': 'exchange_quantity',
|
||||
'每人限购': 'limit_buy',
|
||||
'最多购买': 'max_buy',
|
||||
}
|
||||
|
||||
# 精确匹配
|
||||
if value in value_map:
|
||||
return value_map[value]
|
||||
|
||||
# 短翻译值直接用(去除特殊字符)
|
||||
if len(value) <= 10 and len(value) >= 2:
|
||||
clean = re.sub(r'[^\w\u4e00-\u9fa5]', '', value)
|
||||
if clean:
|
||||
return to_snake_case(clean)
|
||||
|
||||
# 长文本用通用后缀
|
||||
if '暂无' in value:
|
||||
return 'empty_text'
|
||||
if '请输入' in value:
|
||||
return 'placeholder'
|
||||
if '确定要' in value:
|
||||
return 'confirm_tip'
|
||||
if '成功' in value:
|
||||
return 'success_tip'
|
||||
if '失败' in value:
|
||||
return 'fail_tip'
|
||||
|
||||
return 'text'
|
||||
|
||||
|
||||
def to_snake_case(text):
|
||||
"""转换为 snake_case"""
|
||||
# 先处理中划线
|
||||
text = text.replace('-', '_')
|
||||
# 处理驼峰:在大写字母前加下划线
|
||||
text = re.sub(r'([A-Z])', r'_\1', text)
|
||||
# 多下划线合并
|
||||
text = re.sub(r'_+', '_', text)
|
||||
# 转小写
|
||||
text = text.lower()
|
||||
# 去除首尾下划线
|
||||
text = text.strip('_')
|
||||
return text
|
||||
|
||||
|
||||
def clean_key_path(key, namespace):
|
||||
"""清理 key 路径中多余的层级"""
|
||||
parts = key.split('.')
|
||||
|
||||
# 去除重复的业务域
|
||||
if parts[0] == namespace and len(parts) > 1 and parts[1] == namespace:
|
||||
parts = [parts[0]] + parts[2:]
|
||||
|
||||
# 合并常见的冗余层级
|
||||
# 例如 goods.goods.sku -> goods.sku
|
||||
if len(parts) >= 2 and parts[0] == parts[1]:
|
||||
parts = [parts[0]] + parts[2:]
|
||||
|
||||
# 控制层级在 2-4 级
|
||||
if len(parts) > 4:
|
||||
# 保留首尾,中间合并
|
||||
parts = [parts[0], parts[1], '_'.join(parts[2:-1]), parts[-1]]
|
||||
|
||||
return '.'.join(parts)
|
||||
|
||||
|
||||
def analyze():
|
||||
"""分析分类结果"""
|
||||
zh_data = parse_lang_file(ZH_FILE)
|
||||
print(f"总 key 数: {len(zh_data)}")
|
||||
|
||||
# 分类统计
|
||||
categories = defaultdict(list)
|
||||
for k, v in zh_data.items():
|
||||
ns = classify_key(k, v)
|
||||
new_key = generate_new_key(k, v, ns)
|
||||
categories[ns].append((k, new_key, v))
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("分类结果统计")
|
||||
print("=" * 80)
|
||||
for ns in sorted(categories.keys(), key=lambda x: -len(categories[x])):
|
||||
items = categories[ns]
|
||||
print(f"\n{ns}: {len(items)} 个 key")
|
||||
for old_key, new_key, value in items[:8]:
|
||||
print(f" {old_key} -> {new_key} ({value[:30]})")
|
||||
if len(items) > 8:
|
||||
print(f" ... 还有 {len(items) - 8} 个")
|
||||
|
||||
# 检查新 key 冲突
|
||||
all_new_keys = defaultdict(list)
|
||||
for ns, items in categories.items():
|
||||
for old_key, new_key, value in items:
|
||||
all_new_keys[new_key].append((old_key, value))
|
||||
|
||||
conflicts = {k: v for k, v in all_new_keys.items() if len(v) > 1}
|
||||
print(f"\n\n新 key 冲突数: {len(conflicts)}")
|
||||
if conflicts:
|
||||
print("冲突的 key:")
|
||||
for new_key, old_list in list(conflicts.items())[:15]:
|
||||
print(f" {new_key}:")
|
||||
for old_key, value in old_list[:5]:
|
||||
print(f" - {old_key} -> \"{value[:30]}\"")
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
def build_nested_dict(flat_dict):
|
||||
"""将展平的 key-value 转换为嵌套对象"""
|
||||
result = {}
|
||||
for key, value in flat_dict.items():
|
||||
parts = key.split('.')
|
||||
current = result
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
elif isinstance(current[part], str):
|
||||
# 冲突:当前是字符串,但需要变成对象
|
||||
# 把字符串值移到 text 子键下
|
||||
current[part] = {'text': current[part]}
|
||||
current = current[part]
|
||||
|
||||
# 处理最后一级
|
||||
last_part = parts[-1]
|
||||
if last_part in current and isinstance(current[last_part], dict):
|
||||
# 冲突:要写字符串,但已经是对象了
|
||||
# 把字符串写到 text 子键
|
||||
current[last_part]['text'] = value
|
||||
else:
|
||||
current[last_part] = value
|
||||
return result
|
||||
|
||||
|
||||
def write_lang_module(filepath, module_name, data):
|
||||
"""写入单个语言模块文件"""
|
||||
# 确保目录存在
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
|
||||
# 生成缩进的 JS 对象字符串
|
||||
def to_js_obj(obj, indent=1):
|
||||
lines = []
|
||||
tab = '\t' * indent
|
||||
for key, value in sorted(obj.items()):
|
||||
if isinstance(value, dict):
|
||||
lines.append(f"{tab}{key}: {{")
|
||||
lines.append(to_js_obj(value, indent + 1))
|
||||
lines.append(f"{tab}}},")
|
||||
else:
|
||||
# 转义特殊字符
|
||||
escaped = value.replace('\\', '\\\\').replace("'", "\\'")
|
||||
lines.append(f"{tab}{key}: '{escaped}',")
|
||||
return '\n'.join(lines)
|
||||
|
||||
content = f"""// {module_name} 模块文案
|
||||
|
||||
export const {module_name} = {{
|
||||
{to_js_obj(data)}
|
||||
}};
|
||||
"""
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def generate():
|
||||
"""生成新的多文件语言包和映射表"""
|
||||
zh_data = parse_lang_file(ZH_FILE)
|
||||
en_data = parse_lang_file(EN_FILE)
|
||||
|
||||
# 分类
|
||||
categories = defaultdict(dict) # namespace -> {new_key: zh_value}
|
||||
en_categories = defaultdict(dict) # namespace -> {new_key: en_value}
|
||||
migration_map = {} # old_key -> new_key
|
||||
|
||||
# 用于去重的计数器
|
||||
new_key_counter = defaultdict(int)
|
||||
|
||||
for old_key, zh_val in zh_data.items():
|
||||
ns = classify_key(old_key, zh_val)
|
||||
new_key = generate_new_key(old_key, zh_val, ns)
|
||||
|
||||
# 处理冲突:如果新 key 已存在且值不同,加后缀
|
||||
if new_key in categories[ns]:
|
||||
existing_val = categories[ns][new_key]
|
||||
if existing_val != zh_val:
|
||||
# 翻译不同,加序号区分
|
||||
new_key_counter[new_key] += 1
|
||||
new_key = f"{new_key}_{new_key_counter[new_key] + 1}"
|
||||
|
||||
categories[ns][new_key] = zh_val
|
||||
migration_map[old_key] = new_key
|
||||
|
||||
# 英文也同步处理
|
||||
if old_key in en_data:
|
||||
en_categories[ns][new_key] = en_data[old_key]
|
||||
else:
|
||||
en_categories[ns][new_key] = ''
|
||||
|
||||
# 写入中文语言包
|
||||
print("生成中文语言包...")
|
||||
for ns, data in categories.items():
|
||||
filepath = os.path.join(NEW_ZH_DIR, f'{ns}.js')
|
||||
nested = build_nested_dict(data)
|
||||
write_lang_module(filepath, ns, nested)
|
||||
print(f" {ns}.js: {len(data)} 个 key")
|
||||
|
||||
# 写入英文语言包
|
||||
print("\n生成英文语言包...")
|
||||
for ns, data in en_categories.items():
|
||||
filepath = os.path.join(NEW_EN_DIR, f'{ns}.js')
|
||||
nested = build_nested_dict(data)
|
||||
write_lang_module(filepath, ns, nested)
|
||||
print(f" {ns}.js: {len(data)} 个 key")
|
||||
|
||||
# 保存映射表
|
||||
with open(MAP_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(migration_map, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n映射表已保存: {len(migration_map)} 个映射")
|
||||
|
||||
# 统计
|
||||
total = len(migration_map)
|
||||
print(f"\n迁移统计:")
|
||||
print(f" 总 key 数: {total}")
|
||||
print(f" 业务模块数: {len(categories)}")
|
||||
for ns in sorted(categories.keys(), key=lambda x: -len(categories[x])):
|
||||
print(f" {ns}: {len(categories[ns])} 个")
|
||||
|
||||
|
||||
def apply_migration():
|
||||
"""替换代码中所有旧 key"""
|
||||
# 读取映射表
|
||||
with open(MAP_FILE, 'r', encoding='utf-8') as f:
|
||||
mapping = json.load(f)
|
||||
|
||||
print(f"加载映射表: {len(mapping)} 个映射")
|
||||
|
||||
# 统计
|
||||
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 mapping:
|
||||
new_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] == '--generate':
|
||||
generate()
|
||||
elif args[0] == '--apply':
|
||||
apply_migration()
|
||||
elif args[0] == '--all':
|
||||
print("=" * 80)
|
||||
print("步骤 1: 分析分类")
|
||||
print("=" * 80)
|
||||
analyze()
|
||||
print("\n" + "=" * 80)
|
||||
print("步骤 2: 生成新语言包")
|
||||
print("=" * 80)
|
||||
generate()
|
||||
print("\n" + "=" * 80)
|
||||
print("步骤 3: 替换代码中的旧 key")
|
||||
print("=" * 80)
|
||||
apply_migration()
|
||||
else:
|
||||
print(__doc__)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
169
scripts/i18n_build_zhcn.py
Normal file
169
scripts/i18n_build_zhcn.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
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())
|
||||
17
scripts/i18n_check.bat
Normal file
17
scripts/i18n_check.bat
Normal file
@@ -0,0 +1,17 @@
|
||||
@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
|
||||
15
scripts/i18n_check.sh
Normal file
15
scripts/i18n_check.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/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"
|
||||
84
scripts/i18n_check_symmetry.py
Normal file
84
scripts/i18n_check_symmetry.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
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())
|
||||
165
scripts/i18n_clean_dead.py
Normal file
165
scripts/i18n_clean_dead.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
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())
|
||||
57
scripts/i18n_extend_map.py
Normal file
57
scripts/i18n_extend_map.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
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}')
|
||||
153
scripts/i18n_extract_existing.py
Normal file
153
scripts/i18n_extract_existing.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
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()
|
||||
189
scripts/i18n_extract_from_vue.py
Normal file
189
scripts/i18n_extract_from_vue.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
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()
|
||||
203
scripts/i18n_extract_rebuild.py
Normal file
203
scripts/i18n_extract_rebuild.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
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()
|
||||
164
scripts/i18n_extract_titles.py
Normal file
164
scripts/i18n_extract_titles.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
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())
|
||||
396
scripts/i18n_merge.py
Normal file
396
scripts/i18n_merge.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
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()
|
||||
166
scripts/i18n_rebuild_from_vue.py
Normal file
166
scripts/i18n_rebuild_from_vue.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
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()
|
||||
378
scripts/i18n_rebuild_zhcn.py
Normal file
378
scripts/i18n_rebuild_zhcn.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""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())
|
||||
117
scripts/i18n_replace_hardcoded.py
Normal file
117
scripts/i18n_replace_hardcoded.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
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())
|
||||
167
scripts/i18n_rewrite_calls.py
Normal file
167
scripts/i18n_rewrite_calls.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
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()
|
||||
241
scripts/i18n_strict_audit.py
Normal file
241
scripts/i18n_strict_audit.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
严格 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()
|
||||
394
scripts/migrate_i18n_keys.py
Normal file
394
scripts/migrate_i18n_keys.py
Normal file
@@ -0,0 +1,394 @@
|
||||
#!/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()
|
||||
94
scripts/verify_new_lang.py
Normal file
94
scripts/verify_new_lang.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
验证新的多文件语言包结构是否正常
|
||||
"""
|
||||
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