Files
lucky_shop/scripts/migrate_i18n_keys.py
ZF sun 5f41febd70 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
2026-06-25 10:48:15 +08:00

395 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()