- 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 处
808 lines
27 KiB
Python
808 lines
27 KiB
Python
#!/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()
|