## 删除的死代码脚本
- 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
395 lines
14 KiB
Python
395 lines
14 KiB
Python
#!/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()
|