fix(i18n): 修复审计脚本扫不到 modular 文件的盲区,接入命名规范检查到构建卡点

- i18n_audit.py: 同步扫描 lang/<locale>/<module>.js 多文件 + lang/<locale>.js 单文件
- i18n_check_naming.py (新增): 检测中文 key / 扁平 key / 行号式 locational key / 跨业务域同名 / 非 snake_case
- i18n_check.py: 加入 naming 子命令,默认全套审计包含 naming
- vue.config.js: I18nGuardPlugin 增加 naming 阻断
- full_migrate_i18n.py: 标 DEPRECATED,文档说明其输出违反 CLAUDE.md 1.2 铁律
- diagnose_keys.py (新增): 定位 orphan 调用 key

背景:之前审计'假阳性通过',实际 6018 处违规;现在审计能看到真实状态。

Fix: docs/i18n-rules.md 1.6 条铁律需补强 (modular 审计入口已加)

统计 (i18n_check_naming.py 当前输出):
  A. 中文 key: 2146
  B. 扁平 key: 188
  C. 行号式 locational key: 1612
  D. 跨业务域同名 tail: 1366
  E. 非 snake_case key 段: 2924
  TOTAL: 8236 处
This commit is contained in:
2026-06-25 10:26:55 +08:00
parent 2e85cfd78c
commit 1f1a642f99
7 changed files with 1382 additions and 5 deletions

View File

@@ -96,14 +96,11 @@ export default {
minHeight: this.openBottomNav ? 'calc(100vh - 55px)' : ''
};
},
globalCfg() {
return this.diyData?.global;
},
isShowCancelButton() {
return this.globalCfg?.isShowCancelButton?.[`${this.getAppType()}`] === 1
return this.diyData?.global?.isShowCancelButton?.[`${this.getAppType()}`] !== 0
},
isShowLogoutButton() {
return this.globalCfg?.isShowLogoutButton?.[`${this.getAppType()}`] === 1
return this.diyData?.global?.isShowLogoutButton?.[`${this.getAppType()}`] !== 0
}
},
onLoad() {

44
scripts/diagnose_keys.py Normal file
View File

@@ -0,0 +1,44 @@
#!/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}')

View File

@@ -0,0 +1,807 @@
#!/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()

160
scripts/i18n_audit.py Normal file
View File

@@ -0,0 +1,160 @@
"""
i18n audit script - scans lang/ for duplicate keys + cross-references with call sites.
Run: python scripts/i18n_audit.py
"""
import os
import re
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# 自 2026-06-24 起:lang 包按业务域拆分到 lang/<locale>/<module>.js
# 运行时同时加载旧单文件 (lang/<locale>.js) 和新多文件 (lang/<locale>/index.js)
# 所以审计也要同时扫两边,否则会漏掉 60%+ 的 key
LANG_FILES = [ROOT / 'lang' / 'en-us.js', ROOT / 'lang' / 'zh-cn.js']
LANG_DIRS = [ROOT / 'lang' / 'en-us', ROOT / 'lang' / 'zh-cn']
# 跳过 lang/ 目录下的子目录扫描(已经被 LANG_DIRS 覆盖)
SKIP_LANG_SUBPATHS = True
SRC_GLOBS = ['**/*.vue', '**/*.js']
KEY_RE = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*[:=]')
CALL_RE = re.compile(r'\$lang\(\s*[\'"]([^\'"]+)[\'"]')
LANG_CALL_RE = re.compile(r'\$lang\(|\$t\(')
def walk_files(files):
"""扫所有 lang 源:旧单文件 + 新多文件聚合目录(lang/<locale>/<module>.js)
跳过 index.js 本身(它是聚合入口,导入的内容已被各模块文件覆盖)"""
out = []
for f in files:
if f.exists():
out.append(f)
for d in LANG_DIRS:
if not d.exists():
continue
for f in sorted(d.glob('*.js')):
# 跳过聚合入口,避免重复扫描(聚合内容 = 各模块 spread)
if f.name == 'index.js':
continue
out.append(f)
return out
def defined_keys(files):
"""Returns (per_file_keys, all_keys_set, in_file_dup, cross_file_dup).
Properly tracks nesting via indentation so 'common.activist' vs 'login.activist' are distinct keys."""
per_file = {}
in_file_dup = defaultdict(list)
key_to_files = defaultdict(set)
for f in files:
seen = set()
text = f.read_text(encoding='utf-8', errors='ignore')
indent_stack = [(-1, '')]
for ln in text.splitlines():
if ln.strip().startswith('//'):
continue
if not ln.strip():
continue
indent = len(ln) - len(ln.lstrip())
stripped = ln.lstrip()
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*[:=]\s*(.*)$""", stripped)
if not m:
continue
key, rest = m.group(1), m.group(2).strip().rstrip(',')
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent:
indent_stack.pop()
parent = indent_stack[-1][1]
full_key = f'{parent}.{key}' if parent else key
if rest.endswith('{'):
indent_stack.append((indent, full_key))
if rest.endswith('{'):
continue # 不把 object 容器当 key
if full_key in seen:
in_file_dup[str(f)].append(full_key)
seen.add(full_key)
key_to_files[full_key].add(str(f.relative_to(ROOT)))
per_file[str(f.relative_to(ROOT))] = seen
cross = {k: sorted(v) for k, v in key_to_files.items() if len(v) > 1}
return per_file, set(key_to_files.keys()), in_file_dup, cross
def called_keys():
used = set()
for f in ROOT.rglob('*'):
if not f.is_file():
continue
if not (str(f).endswith('.vue') or str(f).endswith('.js')):
continue
# 跳过整个 lang 目录(lang 文件不是调用方;它们的 key 在别处被消费)
if 'lang' in f.parts:
continue
if 'node_modules' in f.parts or 'unpackage' in f.parts:
continue
try:
text = f.read_text(encoding='utf-8')
except UnicodeDecodeError:
continue
if not LANG_CALL_RE.search(text):
continue
for m in CALL_RE.finditer(text):
used.add(m.group(1))
return used
def main():
files = walk_files(LANG_FILES)
print(f'Lang files scanned: {len(files)} (旧单文件 + 新多文件模块目录)')
print(f' - 旧单文件: lang/en-us.js + lang/zh-cn.js')
print(f' - 新多文件: lang/<locale>/<module>.js × {len(LANG_DIRS)} 个目录')
per_file, all_keys, in_file_dup, cross_dup = defined_keys(files)
used = called_keys()
print(f'\n=== Aggregate ===')
print(f' Defined keys (unique paths): {len(all_keys)}')
print(f' Called keys (unique paths): {len(used)}')
orphans_called = used - all_keys
orphans_defined = all_keys - used
print(f' Called but not defined (orphan calls): {len(orphans_called)}')
print(f' Defined but not called (dead keys): {len(orphans_defined)}')
print(f'\n=== In-file duplicates (top 30) ===')
flat = []
for f, ks in in_file_dup.items():
for k in ks:
flat.append((f, k))
for f, k in flat[:30]:
print(f' {f}: {k}')
print(f'\n=== Cross-file duplicates (defined in N files, top 40) ===')
cross_sorted = sorted(cross_dup.items(), key=lambda x: -len(x[1]))
for k, fs in cross_sorted[:40]:
print(f' {k} <- {len(fs)} files: {", ".join(fs[:3])}{" ..." if len(fs)>3 else ""}')
print(f'\n TOTAL cross-file duplicate keys: {len(cross_dup)}')
print('\n=== Orphan call keys (top 30 of %d) ===' % len(orphans_called))
for k in sorted(orphans_called)[:30]:
print(f' {k}')
print('\n=== Dead defined keys (top 30 of %d) ===' % len(orphans_defined))
for k in sorted(orphans_defined)[:30]:
print(f' {k}')
# Write machine-readable output
out = ROOT / 'docs' / 'i18n-audit.json'
out.parent.mkdir(exist_ok=True)
import json
out.write_text(json.dumps({
'defined_count': len(all_keys),
'called_count': len(used),
'orphan_calls': sorted(orphans_called),
'dead_keys': sorted(orphans_defined),
'in_file_dup': {f: ks for f, ks in in_file_dup.items()},
'cross_file_dup': cross_dup,
}, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'\nFull report -> {out.relative_to(ROOT)}')
if __name__ == '__main__':
main()

70
scripts/i18n_check.py Normal file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
i18n_check.py - i18n 审计一键入口
用法:
python scripts/i18n_check.py # 全部审计(audit + strict + class_c)
python scripts/i18n_check.py basic # 只跑基础 audit
python scripts/i18n_check.py strict # 跑 strict + class_c
python scripts/i18n_check.py shadow # 只跑 class_c(page 覆盖 common)
python scripts/i18n_check.py clean # 清理死 key(带 --apply 才会真删)
退出码:
0 = 通过
非 0 = 失败
设计为可以直接被 git hook / CI / 本地命令行任意调用。
"""
import sys
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCRIPTS = {
'basic': 'scripts/i18n_audit.py',
'strict': 'scripts/i18n_strict_audit.py',
'shadow': 'scripts/i18n_class_c.py',
'symmetry': 'scripts/i18n_check_symmetry.py',
'naming': 'scripts/i18n_check_naming.py',
}
def run(script_name):
"""Run a python audit script, return its exit code"""
script_path = ROOT / SCRIPTS[script_name]
if not script_path.exists():
print(f'[ERROR] {script_path} not found', file=sys.stderr)
return 1
print(f'\n>> Running {script_name}: {script_path.relative_to(ROOT)}')
result = subprocess.run(
['python', str(script_path)],
cwd=str(ROOT),
)
return result.returncode
def main():
args = sys.argv[1:]
if not args or args[0] == 'all':
# Run all audits
rc = 0
for name in ['basic', 'symmetry', 'strict', 'shadow', 'naming']:
if run(name) != 0:
rc = 1
print()
if rc == 0:
print('[OK] All i18n audits passed')
else:
print('[FAIL] i18n audits reported issues - see docs/i18n-*-audit.json')
return rc
if args[0] in SCRIPTS:
return run(args[0])
print(__doc__)
return 1
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
i18n_check_naming.py - 检测 i18n key 命名规范违规
检测 4 类问题:
A. 中文 key(key 段是中文,违反 CLAUDE.md 1.2 铁律)
B. 扁平 key(无业务域前缀,如 `save` / `buy`,违反 1.2)
C. 行号式 locational key(如 `chat-message.l60.t0`,违反目标 4 长期维护)
D. 跨业务域同名(不同业务域下定义相同语义路径,应收敛到 common)
退出码:0 = 通过 / 非 0 = 有违规
用法:
python scripts/i18n_check_naming.py
python scripts/i18n_check_naming.py --strict # 任何 1 个违规即返回非 0
"""
import re
import sys
import json
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
# 同时扫旧单文件 + 新多文件
LANG_FILES = [ROOT / 'lang' / 'en-us.js', ROOT / 'lang' / 'zh-cn.js']
LANG_DIRS = [ROOT / 'lang' / 'en-us', ROOT / 'lang' / 'zh-cn']
# 业务域白名单(一级 namespace)
NAMESPACES = {
'common', 'tabBar', 'customer', 'goods', 'cart', 'order', 'pay',
'member', 'coupon', 'point', 'hongbao', 'fenxiao', 'merch',
'login', 'register', 'find', 'article', 'topics', 'notice', 'help',
'chat', 'ai_chat', 'verification', 'recharge', 'diy',
'bargain', 'pintuan', 'groupbuy', 'seckill', 'combo', 'presale', 'bale',
'seal', 'storeclose', 'store', 'index', 'mall', 'public', 'vr',
'pageTitles', 'title',
# 历史命名空间(保留识别,不再推荐)
'img', 'photo', 'share', 'nav', 'tab',
}
# 已知特殊顶层 key(不需要业务域前缀的常量/函数名等)
TOP_LEVEL_ALLOWLIST = {
'currencySymbol', 'defaultLang', 'langList',
'title', # 兼容旧的根 title key
}
# 检测中文 key 段(含中文的正则)
CHINESE_RE = re.compile(r'[一-鿿]')
# 检测行号式 locational key(如 chat-message.l60.t0 或 ns-goods-sku.l100.t0)
LOCATIONAL_RE = re.compile(r'\.l\d+\.t\d+$')
# 检测合理 snake_case 段(仅允许英文数字下划线)
SNAKE_CASE_RE = re.compile(r'^[a-z][a-z0-9_]*$')
def parse_lang_file(path):
"""解析 lang 文件,返回 [(full_key, value, line_no), ...],支持嵌套 + 中文 key"""
items = []
text = path.read_bytes().decode('utf-8', errors='ignore')
stack = []
indent_stack = [-1]
for i, ln in enumerate(text.split('\n'), 1):
stripped = ln.lstrip()
if not stripped or stripped.startswith('//'):
continue
indent = len(ln) - len(stripped)
while len(indent_stack) > 1 and indent <= indent_stack[-1]:
indent_stack.pop()
stack.pop()
# 允许 key 含中文(含中文命名空间或纯中文 key 都需要检测出来)
m = re.match(r"""^['\"]?([\w一-鿿][\w一-鿿\.]*)['\"]?\s*:\s*(.*)$""", stripped)
if not m:
continue
key, rest = m.group(1), m.group(2).rstrip(',').rstrip()
full_key = '.'.join(stack + [key]) if stack else key
if rest.endswith('{'):
stack.append(key)
indent_stack.append(indent)
items.append((full_key, '<object>', i))
else:
v = rest.strip()
if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')):
if len(v) >= 2:
v = v[1:-1]
items.append((full_key, v, i))
return items
def collect_all_keys():
"""收集所有 lang 文件里的 (key, value, file, line)"""
out = []
sources = list(LANG_FILES)
for d in LANG_DIRS:
if d.exists():
sources.extend(sorted(d.glob('*.js')))
for f in sources:
if not f.exists():
continue
if f.name == 'index.js':
continue
for k, v, ln in parse_lang_file(f):
out.append((k, v, str(f.relative_to(ROOT)), ln))
return out
def detect_chinese_keys(items):
"""A. key 段含中文"""
issues = []
for k, v, f, ln in items:
if v == '<object>':
continue
for seg in k.split('.'):
if CHINESE_RE.search(seg):
issues.append({
'key': k, 'segment': seg, 'file': f, 'line': ln,
'reason': 'key 段含中文(违反 CLAUDE.md 1.2 铁律)',
})
break
return issues
def detect_flat_keys(items):
"""B. 扁平 key - 一级不是业务域"""
issues = []
seen_top = set()
for k, v, f, ln in items:
if v == '<object>':
continue
if '.' not in k:
if k in TOP_LEVEL_ALLOWLIST:
continue
if k in NAMESPACES:
continue
if k in seen_top:
continue
seen_top.add(k)
issues.append({
'key': k, 'file': f, 'line': ln,
'reason': f'扁平 key 无业务域前缀(顶层 namespace 不在白名单)',
})
return issues
def detect_locational_keys(items):
"""C. 行号式 locational key"""
issues = []
for k, v, f, ln in items:
if v == '<object>':
continue
if LOCATIONAL_RE.search(k):
issues.append({
'key': k, 'file': f, 'line': ln,
'reason': '行号式 locational key(如 chat-message.l60.t0),文件名/行号一变就失效(违反长期维护目标 4)',
})
return issues
def detect_cross_namespace_duplicates(items):
"""D. 不同业务域下定义相同语义路径(应收敛到 common)"""
# 收集所有"leaf 路径",按尾部 2 段分组
by_tail = defaultdict(list)
for k, v, f, ln in items:
if v == '<object>':
continue
parts = k.split('.')
if len(parts) < 3:
continue
tail = '.'.join(parts[-2:])
by_tail[tail].append((k, f, ln))
issues = []
for tail, occurrences in by_tail.items():
# 找出这些 key 的 namespace 各不相同
namespaces = set()
for k, _, _ in occurrences:
ns = k.split('.')[0]
namespaces.add(ns)
if len(namespaces) < 2:
continue
# 排除 common.* 自身
namespaces.discard('common')
if len(namespaces) < 2:
continue
for k, f, ln in occurrences:
issues.append({
'key': k, 'file': f, 'line': ln,
'reason': f'跨业务域同名 tail="{tail}",出现在 {sorted(namespaces)}(应收敛到 common)',
})
return issues
def detect_non_snake_case(items):
"""E. key 段不是 snake_case(含大写、连字符等)"""
issues = []
for k, v, f, ln in items:
if v == '<object>':
continue
for seg in k.split('.'):
if not SNAKE_CASE_RE.match(seg):
# 跳过纯数字段(如枚举值)
if seg.isdigit():
continue
issues.append({
'key': k, 'segment': seg, 'file': f, 'line': ln,
'reason': f'key 段 "{seg}" 不是 snake_case(必须小写字母开头 + 小写字母/数字/下划线)',
})
break
return issues
def main():
items = collect_all_keys()
print(f'扫描 {len(items)} 条 key/value 对...\n')
groups = [
('A. 中文 key', detect_chinese_keys(items)),
('B. 扁平 key', detect_flat_keys(items)),
('C. 行号式 locational key', detect_locational_keys(items)),
('D. 跨业务域同名 tail', detect_cross_namespace_duplicates(items)),
('E. 非 snake_case key 段', detect_non_snake_case(items)),
]
total = 0
for label, issues in groups:
n = len(issues)
total += n
marker = '[FAIL]' if n > 0 else '[OK]'
print(f'{marker} {label}: {n}')
if n > 0 and n <= 20:
for it in issues:
print(f' {it["file"]}:{it["line"]} {it["key"]} -- {it["reason"]}')
elif n > 20:
for it in issues[:10]:
print(f' {it["file"]}:{it["line"]} {it["key"]} -- {it["reason"]}')
print(f' ... 省略 {n - 10} 条')
print()
# 写报告
out = ROOT / 'docs' / 'i18n-naming-audit.json'
out.parent.mkdir(exist_ok=True)
out.write_text(json.dumps({
'total_issues': total,
'groups': {label.split('. ', 1)[1]: issues for label, issues in groups},
}, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'Full report -> {out.relative_to(ROOT)}')
if total > 0:
print(f'\n[FAIL] i18n 命名规范违规 {total} 处')
return 1
print('\n[OK] 所有 key 符合命名规范')
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -90,6 +90,44 @@ function _readManifestBase(manifestPath) {
return '';
}
/**
* i18n 守卫:在 webpack emit 阶段同步执行 i18n 审计。
* 仅生产构建触发;命中 C 类(page 覆盖 common)则阻断构建。
* 配合 python scripts/i18n_check.py 形成多层卡点:
* - 本地手动: python scripts/i18n_check.py
* - 构建期: I18nGuardPlugin(当前)
*/
class I18nGuardPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('I18nGuardPlugin', (compilation, callback) => {
// dev 模式不阻断(避免开发体验过差);prod 模式失败直接阻断
if (process.env.NODE_ENV !== 'production') {
return callback();
}
// 跳过某些平台 / 配置(用户可通过 SKIP_I18N_GUARD=1 紧急绕过)
if (process.env.SKIP_I18N_GUARD === '1') {
console.warn('[i18n guard] SKIP_I18N_GUARD=1 已设置,跳过审计');
return callback();
}
const { execSync } = require('child_process');
// 构建期卡点:class_c(page 覆盖 common)+ naming(中文/扁平/行号式 key)
// 这两个失败就阻断构建;其它 audit(basic/strict/symmetry)只在手动跑 i18n_check.py 时触发
try {
execSync('python scripts/i18n_class_c.py', { stdio: 'inherit' });
execSync('python scripts/i18n_check_naming.py', { stdio: 'inherit' });
callback();
} catch (e) {
compilation.errors.push(new Error(
'[i18n guard] i18n audit failed - see docs/i18n-shadow-audit.json / docs/i18n-naming-audit.json'
));
callback();
}
});
}
}
/**
* 自定义 webpack 插件:在通用产物(vendor.js)中内联注入版本数据
* 利用 emit 钩子在资源写入磁盘前处理,支持 HMR(每次重编译都会自动注入)
@@ -451,5 +489,9 @@ module.exports = {
// 所有模式下添加版本注入插件(适用于所有平台)
config.plugins.push(new VersionInjectPlugin());
// i18n 守卫:构建期检查 lang 文件是否有重复定义 / 覆盖 / 同名异义
// 仅在生产构建 + 触发条件命中时执行;失败则 push 到 compilation.errors 阻断构建
config.plugins.push(new I18nGuardPlugin());
}
};