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:
@@ -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
44
scripts/diagnose_keys.py
Normal 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}')
|
||||
807
scripts/full_migrate_i18n.py
Normal file
807
scripts/full_migrate_i18n.py
Normal 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
160
scripts/i18n_audit.py
Normal 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
70
scripts/i18n_check.py
Normal 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())
|
||||
257
scripts/i18n_check_naming.py
Normal file
257
scripts/i18n_check_naming.py
Normal 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())
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user