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:
2026-06-25 10:48:15 +08:00
parent a74263016b
commit 5f41febd70
516 changed files with 79257 additions and 50306 deletions

View 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]}"')

View 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()

View File

@@ -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
View 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()

View File

@@ -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
View 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
View 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
View 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"

View 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
View 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())

View 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}')

View 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()

View 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()

View 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()

View 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
View 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()

View 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()

View 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())

View 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())

View 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()

View 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()

View 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()

View 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()