From b83ec4455983dcec3f0596c800dfeff87957acd5 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 18:47:51 +0800 Subject: [PATCH 1/5] feat(i18n): add Chinese literal detection and refactor KNOWN_PERMISSIONS to use i18n - Add Chinese string detection to i18n-check.ts script - Simplify KNOWN_PERMISSIONS to only contain risk field - Refactor getPermissionInfo() to return translated text via i18n - Convert MiniappDetailActivity.tsx to use i18n for all UI strings - Add permission translations to all 4 locale files (zh-CN, en, zh-TW, ar) - Export permissions module from ecosystem service --- scripts/i18n-check.ts | 590 ++++++++++++------ src/i18n/locales/ar/ecosystem.json | 101 ++- src/i18n/locales/en/ecosystem.json | 101 ++- src/i18n/locales/zh-CN/ecosystem.json | 101 ++- src/i18n/locales/zh-TW/ecosystem.json | 101 ++- src/services/ecosystem/index.ts | 11 +- src/services/ecosystem/permissions.ts | 119 ++-- src/services/ecosystem/types.ts | 312 ++++----- .../activities/MiniappDetailActivity.tsx | 323 ++++------ 9 files changed, 1096 insertions(+), 663 deletions(-) diff --git a/scripts/i18n-check.ts b/scripts/i18n-check.ts index 8d7f0139a..0b14f5df5 100644 --- a/scripts/i18n-check.ts +++ b/scripts/i18n-check.ts @@ -18,20 +18,40 @@ * pnpm i18n:check --verbose # Show all checked files */ -import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs' -import { resolve, join } from 'node:path' +import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; // ==================== Configuration ==================== -const ROOT = resolve(import.meta.dirname, '..') -const LOCALES_DIR = join(ROOT, 'src/i18n/locales') -const I18N_INDEX_PATH = join(ROOT, 'src/i18n/index.ts') +const ROOT = resolve(import.meta.dirname, '..'); +const LOCALES_DIR = join(ROOT, 'src/i18n/locales'); +const I18N_INDEX_PATH = join(ROOT, 'src/i18n/index.ts'); + +// ==================== Chinese Literal Check Configuration ==================== + +// 中文字符正则(CJK Unified Ideographs 基本区) +const CHINESE_REGEX = /[\u4e00-\u9fa5]/; + +// 扫描的文件模式 +const SOURCE_PATTERNS = ['src/**/*.ts', 'src/**/*.tsx']; + +// 排除的文件/目录模式 +const SOURCE_EXCLUDE_PATTERNS = [ + '**/i18n/**', // i18n 配置和 locale 文件 + '**/*.test.ts', // 单元测试 + '**/*.test.tsx', + '**/*.spec.ts', // 规格测试 + '**/*.spec.tsx', + '**/test/**', // 测试工具目录 + '**/__tests__/**', // Jest 测试目录 + '**/__mocks__/**', // Mock 目录 +]; // Reference locale (source of truth for keys) -const REFERENCE_LOCALE = 'zh-CN' +const REFERENCE_LOCALE = 'zh-CN'; // All supported locales -const LOCALES = ['zh-CN', 'zh-TW', 'en', 'ar'] +const LOCALES = ['zh-CN', 'zh-TW', 'en', 'ar']; // ==================== Colors ==================== @@ -44,7 +64,7 @@ const colors = { cyan: '\x1b[36m', dim: '\x1b[2m', bold: '\x1b[1m', -} +}; const log = { info: (msg: string) => console.log(`${colors.blue}ℹ${colors.reset} ${msg}`), @@ -53,24 +73,30 @@ const log = { error: (msg: string) => console.log(`${colors.red}✗${colors.reset} ${msg}`), step: (msg: string) => console.log(`\n${colors.cyan}▸${colors.reset} ${colors.cyan}${msg}${colors.reset}`), dim: (msg: string) => console.log(`${colors.dim} ${msg}${colors.reset}`), -} +}; // ==================== Types ==================== -type TranslationValue = string | { [key: string]: TranslationValue } -type TranslationFile = Record +type TranslationValue = string | { [key: string]: TranslationValue }; +type TranslationFile = Record; interface KeyDiff { - missing: string[] - extra: string[] + missing: string[]; + extra: string[]; } interface CheckResult { - namespace: string - locale: string - missing: string[] - extra: string[] - untranslated: string[] // Keys with [MISSING:xx] placeholder + namespace: string; + locale: string; + missing: string[]; + extra: string[]; + untranslated: string[]; // Keys with [MISSING:xx] placeholder +} + +interface ChineseLiteral { + file: string; + line: number; + content: string; } // ==================== Utilities ==================== @@ -80,114 +106,275 @@ interface CheckResult { * Returns flat keys like "a11y.skipToMain" */ function extractKeys(obj: TranslationFile, prefix = ''): string[] { - const keys: string[] = [] + const keys: string[] = []; for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key + const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'object' && value !== null) { - keys.push(...extractKeys(value as TranslationFile, fullKey)) + keys.push(...extractKeys(value as TranslationFile, fullKey)); } else { - keys.push(fullKey) + keys.push(fullKey); } } - return keys + return keys; } /** * Find keys with [MISSING:xx] placeholder values */ function findUntranslatedKeys(obj: TranslationFile, prefix = ''): string[] { - const untranslated: string[] = [] + const untranslated: string[] = []; for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key + const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'object' && value !== null) { - untranslated.push(...findUntranslatedKeys(value as TranslationFile, fullKey)) + untranslated.push(...findUntranslatedKeys(value as TranslationFile, fullKey)); } else if (typeof value === 'string' && value.startsWith('[MISSING:')) { - untranslated.push(fullKey) + untranslated.push(fullKey); } } - return untranslated + return untranslated; } /** * Get value at a nested path */ function getNestedValue(obj: TranslationFile, path: string): TranslationValue | undefined { - const parts = path.split('.') - let current: TranslationValue = obj + const parts = path.split('.'); + let current: TranslationValue = obj; for (const part of parts) { if (typeof current !== 'object' || current === null) { - return undefined + return undefined; } - current = (current as Record)[part] + current = (current as Record)[part]; } - return current + return current; } /** * Set value at a nested path */ function setNestedValue(obj: TranslationFile, path: string, value: TranslationValue): void { - const parts = path.split('.') - let current: Record = obj + const parts = path.split('.'); + let current: Record = obj; for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i] + const part = parts[i]; if (!(part in current) || typeof current[part] !== 'object') { - current[part] = {} + current[part] = {}; } - current = current[part] as Record + current = current[part] as Record; } - current[parts[parts.length - 1]] = value + current[parts[parts.length - 1]] = value; } /** * Compare two sets of keys and find differences */ function compareKeys(referenceKeys: Set, targetKeys: Set): KeyDiff { - const missing: string[] = [] - const extra: string[] = [] + const missing: string[] = []; + const extra: string[] = []; for (const key of referenceKeys) { if (!targetKeys.has(key)) { - missing.push(key) + missing.push(key); } } for (const key of targetKeys) { if (!referenceKeys.has(key)) { - extra.push(key) + extra.push(key); } } - return { missing: missing.sort(), extra: extra.sort() } + return { missing: missing.sort(), extra: extra.sort() }; } /** * Sort object keys recursively */ function sortObjectKeys(obj: TranslationFile): TranslationFile { - const sorted: TranslationFile = {} - const keys = Object.keys(obj).sort() + const sorted: TranslationFile = {}; + const keys = Object.keys(obj).sort(); for (const key of keys) { - const value = obj[key] + const value = obj[key]; if (typeof value === 'object' && value !== null) { - sorted[key] = sortObjectKeys(value as TranslationFile) + sorted[key] = sortObjectKeys(value as TranslationFile); } else { - sorted[key] = value + sorted[key] = value; + } + } + + return sorted; +} + +// ==================== Chinese Literal Detection ==================== + +/** + * 判断是否为纯注释行 + */ +function isCommentLine(line: string): boolean { + const trimmed = line.trim(); + return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.endsWith('*/'); +} + +/** + * 判断是否有 i18n-ignore 标记 + */ +function hasIgnoreComment(line: string): boolean { + return line.includes('// i18n-ignore') || line.includes('/* i18n-ignore */'); +} + +/** + * 移除行尾注释,只保留代码部分 + * 例如: `name: '请求账户', // 这是注释` -> `name: '请求账户',` + */ +function removeTrailingComment(line: string): string { + // 简单处理:找到 // 并移除后面的内容 + // 需要注意不要移除字符串内的 // + let inString = false; + let stringChar = ''; + let result = ''; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const nextChar = line[i + 1]; + + // 处理字符串开始/结束 + if ((char === '"' || char === "'" || char === '`') && (i === 0 || line[i - 1] !== '\\')) { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar) { + inString = false; + stringChar = ''; + } + } + + // 检测行尾注释(不在字符串内) + if (!inString && char === '/' && nextChar === '/') { + break; + } + + result += char; + } + + return result; +} + +/** + * 从代码行中提取字符串字面量的内容 + */ +function extractStringLiterals(line: string): string[] { + const strings: string[] = []; + // 匹配单引号、双引号、模板字符串中的内容 + const regex = /(['"`])(?:(?!\1|\\).|\\.)*\1/g; + let match; + + while ((match = regex.exec(line)) !== null) { + // 去掉引号,获取字符串内容 + const content = match[0].slice(1, -1); + strings.push(content); + } + + return strings; +} + +/** + * 使用 Bun.Glob 扫描文件 + */ +function scanSourceFiles(): string[] { + const files: string[] = []; + + for (const pattern of SOURCE_PATTERNS) { + const glob = new Bun.Glob(pattern); + for (const file of glob.scanSync({ cwd: ROOT })) { + // 检查是否匹配排除模式 + const shouldExclude = SOURCE_EXCLUDE_PATTERNS.some((excludePattern) => { + const excludeGlob = new Bun.Glob(excludePattern); + return excludeGlob.match(file); + }); + + if (!shouldExclude) { + files.push(file); + } + } + } + + return files; +} + +/** + * 扫描源代码文件,检测硬编码的中文字符串 + */ +function checkChineseLiterals(verbose: boolean): ChineseLiteral[] { + const results: ChineseLiteral[] = []; + + const files = scanSourceFiles(); + + if (verbose) { + log.dim(`Scanning ${files.length} source files for Chinese literals...`); + } + + for (const file of files) { + const filePath = join(ROOT, file); + const content = readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + let inMultiLineComment = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // 处理多行注释开始 + if (trimmed.includes('/*') && !trimmed.includes('*/')) { + inMultiLineComment = true; + } + // 处理多行注释结束 + if (trimmed.includes('*/')) { + inMultiLineComment = false; + continue; + } + + // 跳过多行注释内容 + if (inMultiLineComment) continue; + + // 跳过单行注释 + if (isCommentLine(line)) continue; + + // 跳过带 i18n-ignore 标记的行 + if (hasIgnoreComment(line)) continue; + + // 移除行尾注释,只保留代码部分 + const codeOnly = removeTrailingComment(line); + + // 提取字符串字面量 + const stringLiterals = extractStringLiterals(codeOnly); + + // 检测字符串字面量中的中文字符 + for (const literal of stringLiterals) { + if (CHINESE_REGEX.test(literal)) { + results.push({ + file, + line: i + 1, + content: trimmed.length > 80 ? trimmed.slice(0, 77) + '...' : trimmed, + }); + break; // 每行只报告一次 + } + } } } - return sorted + return results; } // ==================== Main Logic ==================== @@ -196,54 +383,54 @@ function sortObjectKeys(obj: TranslationFile): TranslationFile { * Get registered namespaces from src/i18n/index.ts */ function getRegisteredNamespaces(): string[] { - const content = readFileSync(I18N_INDEX_PATH, 'utf-8') + const content = readFileSync(I18N_INDEX_PATH, 'utf-8'); // Match: export const namespaces = [...] as const - const match = content.match(/export\s+const\s+namespaces\s*=\s*\[([\s\S]*?)\]\s*as\s+const/) + const match = content.match(/export\s+const\s+namespaces\s*=\s*\[([\s\S]*?)\]\s*as\s+const/); if (!match) { - log.warn('Could not parse namespaces from src/i18n/index.ts') - return [] + log.warn('Could not parse namespaces from src/i18n/index.ts'); + return []; } // Extract string literals from the array - const arrayContent = match[1] - const namespaces: string[] = [] - const regex = /'([^']+)'|"([^"]+)"/g - let m + const arrayContent = match[1]; + const namespaces: string[] = []; + const regex = /'([^']+)'|"([^"]+)"/g; + let m; while ((m = regex.exec(arrayContent)) !== null) { - namespaces.push(m[1] || m[2]) + namespaces.push(m[1] || m[2]); } - return namespaces + return namespaces; } function getNamespaces(): string[] { - const refDir = join(LOCALES_DIR, REFERENCE_LOCALE) + const refDir = join(LOCALES_DIR, REFERENCE_LOCALE); return readdirSync(refDir) .filter((f) => f.endsWith('.json')) .map((f) => f.replace('.json', '')) - .sort() + .sort(); } function checkNamespace(namespace: string, fix: boolean, verbose: boolean): CheckResult[] { - const results: CheckResult[] = [] + const results: CheckResult[] = []; // Load reference locale - const refPath = join(LOCALES_DIR, REFERENCE_LOCALE, `${namespace}.json`) + const refPath = join(LOCALES_DIR, REFERENCE_LOCALE, `${namespace}.json`); if (!existsSync(refPath)) { - log.warn(`Reference file not found: ${refPath}`) - return results + log.warn(`Reference file not found: ${refPath}`); + return results; } - const refData: TranslationFile = JSON.parse(readFileSync(refPath, 'utf-8')) - const refKeys = new Set(extractKeys(refData)) + const refData: TranslationFile = JSON.parse(readFileSync(refPath, 'utf-8')); + const refKeys = new Set(extractKeys(refData)); if (verbose) { - log.dim(`${namespace}: ${refKeys.size} keys in reference`) + log.dim(`${namespace}: ${refKeys.size} keys in reference`); } // Check each locale against reference for (const locale of LOCALES) { - if (locale === REFERENCE_LOCALE) continue + if (locale === REFERENCE_LOCALE) continue; - const localePath = join(LOCALES_DIR, locale, `${namespace}.json`) + const localePath = join(LOCALES_DIR, locale, `${namespace}.json`); if (!existsSync(localePath)) { results.push({ namespace, @@ -251,15 +438,15 @@ function checkNamespace(namespace: string, fix: boolean, verbose: boolean): Chec missing: [...refKeys], extra: [], untranslated: [], - }) - continue + }); + continue; } - let localeData: TranslationFile = JSON.parse(readFileSync(localePath, 'utf-8')) - const localeKeys = new Set(extractKeys(localeData)) + let localeData: TranslationFile = JSON.parse(readFileSync(localePath, 'utf-8')); + const localeKeys = new Set(extractKeys(localeData)); - const diff = compareKeys(refKeys, localeKeys) - const untranslated = findUntranslatedKeys(localeData) + const diff = compareKeys(refKeys, localeKeys); + const untranslated = findUntranslatedKeys(localeData); if (diff.missing.length > 0 || diff.extra.length > 0 || untranslated.length > 0) { results.push({ @@ -268,175 +455,218 @@ function checkNamespace(namespace: string, fix: boolean, verbose: boolean): Chec missing: diff.missing, extra: diff.extra, untranslated, - }) + }); // Fix missing keys if requested if (fix && diff.missing.length > 0) { for (const key of diff.missing) { - const refValue = getNestedValue(refData, key) - const placeholder = typeof refValue === 'string' - ? `[MISSING:${locale}] ${refValue}` - : refValue - setNestedValue(localeData, key, placeholder as TranslationValue) + const refValue = getNestedValue(refData, key); + const placeholder = typeof refValue === 'string' ? `[MISSING:${locale}] ${refValue}` : refValue; + setNestedValue(localeData, key, placeholder as TranslationValue); } // Sort and write back - localeData = sortObjectKeys(localeData) - writeFileSync(localePath, JSON.stringify(localeData, null, 2) + '\n') - log.info(`Fixed ${diff.missing.length} missing keys in ${locale}/${namespace}.json`) + localeData = sortObjectKeys(localeData); + writeFileSync(localePath, JSON.stringify(localeData, null, 2) + '\n'); + log.info(`Fixed ${diff.missing.length} missing keys in ${locale}/${namespace}.json`); } } } - return results + return results; } async function main() { - const args = process.argv.slice(2) - const fix = args.includes('--fix') - const verbose = args.includes('--verbose') + const args = process.argv.slice(2); + const fix = args.includes('--fix'); + const verbose = args.includes('--verbose'); console.log(` ${colors.cyan}╔════════════════════════════════════════╗ ║ i18n Completeness Check ║ ╚════════════════════════════════════════╝${colors.reset} -`) +`); - log.info(`Reference locale: ${colors.bold}${REFERENCE_LOCALE}${colors.reset}`) - log.info(`Checking locales: ${LOCALES.filter((l) => l !== REFERENCE_LOCALE).join(', ')}`) + log.info(`Reference locale: ${colors.bold}${REFERENCE_LOCALE}${colors.reset}`); + log.info(`Checking locales: ${LOCALES.filter((l) => l !== REFERENCE_LOCALE).join(', ')}`); if (fix) { - log.warn('Fix mode enabled - missing keys will be added with placeholder values') + log.warn('Fix mode enabled - missing keys will be added with placeholder values'); } - const namespaces = getNamespaces() - log.info(`Found ${namespaces.length} namespaces`) + const namespaces = getNamespaces(); + log.info(`Found ${namespaces.length} namespaces`); // Check for unregistered namespaces - log.step('Checking namespace registration') - const registeredNamespaces = getRegisteredNamespaces() - const unregisteredNamespaces = namespaces.filter((ns) => !registeredNamespaces.includes(ns)) + log.step('Checking namespace registration'); + const registeredNamespaces = getRegisteredNamespaces(); + const unregisteredNamespaces = namespaces.filter((ns) => !registeredNamespaces.includes(ns)); if (unregisteredNamespaces.length > 0) { - log.error(`Found ${unregisteredNamespaces.length} unregistered namespace(s) in src/i18n/index.ts:`) + log.error(`Found ${unregisteredNamespaces.length} unregistered namespace(s) in src/i18n/index.ts:`); for (const ns of unregisteredNamespaces) { - log.dim(`- ${ns}`) + log.dim(`- ${ns}`); } - console.log(`\n${colors.red}✗ These namespaces have JSON files but are NOT registered in src/i18n/index.ts${colors.reset}`) - log.info(`Add them to the 'namespaces' array and 'resources' object in src/i18n/index.ts`) - process.exit(1) + console.log( + `\n${colors.red}✗ These namespaces have JSON files but are NOT registered in src/i18n/index.ts${colors.reset}`, + ); + log.info(`Add them to the 'namespaces' array and 'resources' object in src/i18n/index.ts`); + process.exit(1); } else { - log.success(`All ${namespaces.length} namespaces are registered`) + log.success(`All ${namespaces.length} namespaces are registered`); } - const allResults: CheckResult[] = [] + const allResults: CheckResult[] = []; for (const namespace of namespaces) { - const results = checkNamespace(namespace, fix, verbose) - allResults.push(...results) + const results = checkNamespace(namespace, fix, verbose); + allResults.push(...results); } // Report results - log.step('Results') + log.step('Results'); - const hasMissingKeys = allResults.some((r) => r.missing.length > 0) - const hasExtraKeys = allResults.some((r) => r.extra.length > 0) - const hasUntranslated = allResults.some((r) => r.untranslated.length > 0) + const hasMissingKeys = allResults.some((r) => r.missing.length > 0); + const hasExtraKeys = allResults.some((r) => r.extra.length > 0); + const hasUntranslated = allResults.some((r) => r.untranslated.length > 0); if (!hasMissingKeys && !hasExtraKeys && !hasUntranslated) { - log.success('All translations are complete!') - console.log(` -${colors.green}✓ All ${namespaces.length} namespaces checked across ${LOCALES.length} locales${colors.reset} -`) - process.exit(0) - } - - // Group by locale - const byLocale = new Map() - for (const result of allResults) { - if (!byLocale.has(result.locale)) { - byLocale.set(result.locale, []) + log.success('All translations are complete!'); + } else { + // Group by locale + const byLocale = new Map(); + for (const result of allResults) { + if (!byLocale.has(result.locale)) { + byLocale.set(result.locale, []); + } + byLocale.get(result.locale)!.push(result); } - byLocale.get(result.locale)!.push(result) - } - let totalMissing = 0 - let totalExtra = 0 - let totalUntranslated = 0 + let totalMissing = 0; + let totalExtra = 0; + let totalUntranslated = 0; - for (const [locale, results] of byLocale) { - const missingCount = results.reduce((sum, r) => sum + r.missing.length, 0) - const extraCount = results.reduce((sum, r) => sum + r.extra.length, 0) - const untranslatedCount = results.reduce((sum, r) => sum + r.untranslated.length, 0) + for (const [locale, results] of byLocale) { + const missingCount = results.reduce((sum, r) => sum + r.missing.length, 0); + const extraCount = results.reduce((sum, r) => sum + r.extra.length, 0); + const untranslatedCount = results.reduce((sum, r) => sum + r.untranslated.length, 0); - if (missingCount === 0 && extraCount === 0 && untranslatedCount === 0) continue + if (missingCount === 0 && extraCount === 0 && untranslatedCount === 0) continue; - totalMissing += missingCount - totalExtra += extraCount - totalUntranslated += untranslatedCount + totalMissing += missingCount; + totalExtra += extraCount; + totalUntranslated += untranslatedCount; - console.log(`\n${colors.bold}${locale}${colors.reset}`) + console.log(`\n${colors.bold}${locale}${colors.reset}`); - for (const result of results) { - if (result.missing.length > 0) { - log.error(`${result.namespace}.json: ${result.missing.length} missing keys`) - for (const key of result.missing.slice(0, 5)) { - log.dim(`- ${key}`) - } - if (result.missing.length > 5) { - log.dim(` ... and ${result.missing.length - 5} more`) + for (const result of results) { + if (result.missing.length > 0) { + log.error(`${result.namespace}.json: ${result.missing.length} missing keys`); + for (const key of result.missing.slice(0, 5)) { + log.dim(`- ${key}`); + } + if (result.missing.length > 5) { + log.dim(` ... and ${result.missing.length - 5} more`); + } } - } - if (result.extra.length > 0) { - log.warn(`${result.namespace}.json: ${result.extra.length} extra keys (not in reference)`) - for (const key of result.extra.slice(0, 3)) { - log.dim(`+ ${key}`) - } - if (result.extra.length > 3) { - log.dim(` ... and ${result.extra.length - 3} more`) + if (result.extra.length > 0) { + log.warn(`${result.namespace}.json: ${result.extra.length} extra keys (not in reference)`); + for (const key of result.extra.slice(0, 3)) { + log.dim(`+ ${key}`); + } + if (result.extra.length > 3) { + log.dim(` ... and ${result.extra.length - 3} more`); + } } - } - if (result.untranslated.length > 0) { - log.error(`${result.namespace}.json: ${result.untranslated.length} untranslated keys ([MISSING:] placeholders)`) - for (const key of result.untranslated.slice(0, 5)) { - log.dim(`! ${key}`) - } - if (result.untranslated.length > 5) { - log.dim(` ... and ${result.untranslated.length - 5} more`) + if (result.untranslated.length > 0) { + log.error( + `${result.namespace}.json: ${result.untranslated.length} untranslated keys ([MISSING:] placeholders)`, + ); + for (const key of result.untranslated.slice(0, 5)) { + log.dim(`! ${key}`); + } + if (result.untranslated.length > 5) { + log.dim(` ... and ${result.untranslated.length - 5} more`); + } } } } - } - if (totalMissing > 0 || totalUntranslated > 0) { - console.log(` -${colors.red}✗ Found issues:${colors.reset} + if (totalMissing > 0 || totalUntranslated > 0) { + console.log(` +${colors.red}✗ Found translation issues:${colors.reset} ${totalMissing > 0 ? `${colors.red}Missing: ${totalMissing} keys${colors.reset}` : ''} ${totalUntranslated > 0 ? `${colors.red}Untranslated: ${totalUntranslated} keys (have [MISSING:] placeholder)${colors.reset}` : ''} ${colors.yellow}Extra: ${totalExtra} keys${colors.reset} -`) +`); + + if (!fix && totalMissing > 0) { + log.info(`Run with ${colors.cyan}--fix${colors.reset} to add missing keys with placeholder values`); + } + if (totalUntranslated > 0) { + log.info(`Fix [MISSING:xx] placeholders by providing actual translations`); + } + + process.exit(1); + } + + // Only extra keys - warn but don't fail + console.log(` +${colors.green}✓ No missing or untranslated keys${colors.reset} + ${colors.yellow}Extra: ${totalExtra} keys (not in reference, can be cleaned up)${colors.reset} +`); + } - if (!fix && totalMissing > 0) { - log.info(`Run with ${colors.cyan}--fix${colors.reset} to add missing keys with placeholder values`) + // Step 3: Check for Chinese literals in source code + log.step('Checking for Chinese literals in source code'); + + const chineseLiterals = checkChineseLiterals(verbose); + + if (chineseLiterals.length > 0) { + log.error(`Found ${chineseLiterals.length} Chinese literal(s) in source code:`); + + // 按文件分组显示 + const byFile = new Map(); + for (const lit of chineseLiterals) { + if (!byFile.has(lit.file)) byFile.set(lit.file, []); + byFile.get(lit.file)!.push(lit); } - if (totalUntranslated > 0) { - log.info(`Fix [MISSING:xx] placeholders by providing actual translations`) + + for (const [file, lits] of byFile) { + console.log(`\n ${colors.bold}${file}${colors.reset}`); + for (const lit of lits.slice(0, 5)) { + log.dim(`Line ${lit.line}: ${lit.content}`); + } + if (lits.length > 5) { + log.dim(`... and ${lits.length - 5} more`); + } } - process.exit(1) + console.log(` +${colors.red}✗ Chinese literals found in source code${colors.reset} + +To fix: + 1. Move strings to i18n locale files (src/i18n/locales/) + 2. Use useTranslation() hook or t() function + 3. For intentional exceptions, add ${colors.cyan}// i18n-ignore${colors.reset} comment +`); + + process.exit(1); + } else { + log.success('No Chinese literals found in source code'); } - // Only extra keys - warn but don't fail + // Final success message console.log(` -${colors.green}✓ No missing or untranslated keys${colors.reset} - ${colors.yellow}Extra: ${totalExtra} keys (not in reference, can be cleaned up)${colors.reset} -`) +${colors.green}✓ All ${namespaces.length} namespaces checked across ${LOCALES.length} locales${colors.reset} +${colors.green}✓ No Chinese literals in source code${colors.reset} +`); } main().catch((error) => { - log.error(`Check failed: ${error.message}`) - console.error(error) - process.exit(1) -}) + log.error(`Check failed: ${error.message}`); + console.error(error); + process.exit(1); +}); diff --git a/src/i18n/locales/ar/ecosystem.json b/src/i18n/locales/ar/ecosystem.json index 8eec083dd..a1e7177f6 100644 --- a/src/i18n/locales/ar/ecosystem.json +++ b/src/i18n/locales/ar/ecosystem.json @@ -14,12 +14,68 @@ "title": "طلب إذن", "grant": "منح", "deny": "رفض", - "bio_requestAccounts": "الوصول إلى عناوين المحفظة", - "bio_selectAccount": "حدد الحساب", - "bio_pickWallet": "اختر محفظة أخرى", - "bio_signMessage": "توقيع الرسالة", - "bio_signTypedData": "توقيع البيانات المكتوبة", - "bio_sendTransaction": "بدء المعاملة" + "defaultDescription": "قد يصل هذا التطبيق إلى هذه الميزة", + "unknown": "إذن غير معروف", + "bio_requestAccounts": { + "name": "طلب الحسابات", + "description": "الوصول إلى قائمة عناوين محفظتك" + }, + "bio_accounts": { + "name": "الحصول على الحسابات", + "description": "الحصول على قائمة الحسابات المتصلة" + }, + "bio_selectAccount": { + "name": "اختيار الحساب", + "description": "السماح لك باختيار حساب" + }, + "bio_pickWallet": { + "name": "اختيار المحفظة", + "description": "السماح لك باختيار محفظة" + }, + "bio_chainId": { + "name": "الحصول على معرف السلسلة", + "description": "الحصول على شبكة البلوكشين الحالية" + }, + "bio_getBalance": { + "name": "الاستعلام عن الرصيد", + "description": "الاستعلام عن رصيد الحساب" + }, + "bio_createTransaction": { + "name": "إنشاء معاملة", + "description": "إنشاء معاملة غير موقعة (بدون توقيع/بث)" + }, + "bio_signMessage": { + "name": "توقيع الرسالة", + "description": "توقيع رسالة بمفتاحك الخاص (يتطلب التأكيد)" + }, + "bio_signTypedData": { + "name": "توقيع البيانات", + "description": "توقيع البيانات المهيكلة (يتطلب التأكيد)" + }, + "bio_signTransaction": { + "name": "توقيع المعاملة", + "description": "توقيع معاملة غير موقعة (يتطلب التأكيد)" + }, + "bio_sendTransaction": { + "name": "إرسال المعاملة", + "description": "طلب إرسال تحويل (يتطلب التأكيد)" + }, + "bio_destroyAsset": { + "name": "تدمير الأصول", + "description": "طلب تدمير الأصول (يتطلب التأكيد، لا رجعة فيه)" + }, + "bio_requestCryptoToken": { + "name": "طلب تفويض التشفير", + "description": "طلب التفويض لعمليات التشفير (يتطلب التأكيد)" + }, + "bio_cryptoExecute": { + "name": "تنفيذ عملية التشفير", + "description": "تنفيذ عملية التشفير باستخدام الرمز المفوض" + }, + "bio_getCryptoTokenInfo": { + "name": "الاستعلام عن معلومات التفويض", + "description": "الاستعلام عن معلومات الرمز المفوض" + } }, "discover": { "featured": "التطبيقات المميزة", @@ -55,5 +111,36 @@ "capsule": { "more": "المزيد من الخيارات", "close": "إغلاق التطبيق" + }, + "detail": { + "notFound": "التطبيق غير موجود", + "back": "رجوع", + "open": "فتح", + "get": "الحصول", + "unknownDeveloper": "مطور غير معروف", + "preview": "معاينة", + "supportedChains": "سلاسل الكتل المدعومة", + "privacy": "خصوصية التطبيق", + "privacyHint": "يشير المطور إلى أن هذا التطبيق قد يطلب الأذونات التالية", + "tags": "العلامات", + "info": "معلومات", + "developer": "المطور", + "version": "الإصدار", + "category": "الفئة", + "publishedAt": "تاريخ النشر", + "updatedAt": "تاريخ التحديث", + "website": "موقع المطور", + "visit": "زيارة", + "more": "المزيد", + "collapse": "أقل", + "categories": { + "defi": "DeFi", + "nft": "NFT", + "tools": "أدوات", + "games": "ألعاب", + "social": "اجتماعي", + "exchange": "تبادل", + "other": "أخرى" + } } -} \ No newline at end of file +} diff --git a/src/i18n/locales/en/ecosystem.json b/src/i18n/locales/en/ecosystem.json index 58447403f..081987ec8 100644 --- a/src/i18n/locales/en/ecosystem.json +++ b/src/i18n/locales/en/ecosystem.json @@ -14,12 +14,68 @@ "title": "Permission Request", "grant": "Grant", "deny": "Deny", - "bio_requestAccounts": "Access wallet addresses", - "bio_selectAccount": "Select account", - "bio_pickWallet": "Select another wallet", - "bio_signMessage": "Sign message", - "bio_signTypedData": "Sign typed data", - "bio_sendTransaction": "Send Transaction" + "defaultDescription": "This app may access this feature", + "unknown": "Unknown permission", + "bio_requestAccounts": { + "name": "Request Accounts", + "description": "Access your wallet address list" + }, + "bio_accounts": { + "name": "Get Accounts", + "description": "Get connected account list" + }, + "bio_selectAccount": { + "name": "Select Account", + "description": "Let you select an account" + }, + "bio_pickWallet": { + "name": "Select Wallet", + "description": "Let you select a wallet" + }, + "bio_chainId": { + "name": "Get Chain ID", + "description": "Get current blockchain network" + }, + "bio_getBalance": { + "name": "Query Balance", + "description": "Query account balance" + }, + "bio_createTransaction": { + "name": "Create Transaction", + "description": "Construct unsigned transaction (no signing/broadcasting)" + }, + "bio_signMessage": { + "name": "Sign Message", + "description": "Sign message with your private key (requires confirmation)" + }, + "bio_signTypedData": { + "name": "Sign Typed Data", + "description": "Sign structured data (requires confirmation)" + }, + "bio_signTransaction": { + "name": "Sign Transaction", + "description": "Sign unsigned transaction (requires confirmation)" + }, + "bio_sendTransaction": { + "name": "Send Transaction", + "description": "Request to send transfer (requires confirmation)" + }, + "bio_destroyAsset": { + "name": "Destroy Asset", + "description": "Request to destroy asset (requires confirmation, irreversible)" + }, + "bio_requestCryptoToken": { + "name": "Request Crypto Authorization", + "description": "Request authorization for crypto operations (requires confirmation)" + }, + "bio_cryptoExecute": { + "name": "Execute Crypto Operation", + "description": "Execute crypto operation with authorized token" + }, + "bio_getCryptoTokenInfo": { + "name": "Query Authorization Info", + "description": "Query authorized token information" + } }, "discover": { "featured": "Featured", @@ -55,5 +111,36 @@ "capsule": { "more": "More Options", "close": "Close App" + }, + "detail": { + "notFound": "App not found", + "back": "Back", + "open": "Open", + "get": "Get", + "unknownDeveloper": "Unknown Developer", + "preview": "Preview", + "supportedChains": "Supported Blockchains", + "privacy": "App Privacy", + "privacyHint": "The developer indicates this app may request the following permissions", + "tags": "Tags", + "info": "Information", + "developer": "Developer", + "version": "Version", + "category": "Category", + "publishedAt": "Published", + "updatedAt": "Updated", + "website": "Developer Website", + "visit": "Visit", + "more": "More", + "collapse": "Less", + "categories": { + "defi": "DeFi", + "nft": "NFT", + "tools": "Tools", + "games": "Games", + "social": "Social", + "exchange": "Exchange", + "other": "Other" + } } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-CN/ecosystem.json b/src/i18n/locales/zh-CN/ecosystem.json index 579cfc368..09386c930 100644 --- a/src/i18n/locales/zh-CN/ecosystem.json +++ b/src/i18n/locales/zh-CN/ecosystem.json @@ -14,12 +14,68 @@ "title": "权限请求", "grant": "授权", "deny": "拒绝", - "bio_requestAccounts": "访问钱包地址", - "bio_selectAccount": "选择账户", - "bio_pickWallet": "选择其他钱包", - "bio_signMessage": "签名消息", - "bio_signTypedData": "签名数据", - "bio_sendTransaction": "发起交易" + "defaultDescription": "此应用可能访问此功能", + "unknown": "未知权限", + "bio_requestAccounts": { + "name": "请求账户", + "description": "获取您的钱包地址列表" + }, + "bio_accounts": { + "name": "获取账户", + "description": "获取已连接的账户列表" + }, + "bio_selectAccount": { + "name": "选择账户", + "description": "让您选择一个账户" + }, + "bio_pickWallet": { + "name": "选择钱包", + "description": "让您选择一个钱包" + }, + "bio_chainId": { + "name": "获取链 ID", + "description": "获取当前区块链网络" + }, + "bio_getBalance": { + "name": "查询余额", + "description": "查询账户余额" + }, + "bio_createTransaction": { + "name": "创建交易", + "description": "构造未签名交易(不做签名/不做广播)" + }, + "bio_signMessage": { + "name": "签名消息", + "description": "使用您的私钥签名消息(需要您确认)" + }, + "bio_signTypedData": { + "name": "签名数据", + "description": "签名结构化数据(需要您确认)" + }, + "bio_signTransaction": { + "name": "签名交易", + "description": "对未签名交易进行签名(需要您确认)" + }, + "bio_sendTransaction": { + "name": "发送交易", + "description": "请求发送转账(需要您确认)" + }, + "bio_destroyAsset": { + "name": "销毁资产", + "description": "请求销毁资产(需要您确认,不可撤销)" + }, + "bio_requestCryptoToken": { + "name": "请求加密授权", + "description": "请求授权进行加密操作(需要您确认)" + }, + "bio_cryptoExecute": { + "name": "执行加密操作", + "description": "使用已授权的 Token 执行加密操作" + }, + "bio_getCryptoTokenInfo": { + "name": "查询授权信息", + "description": "查询已授权 Token 的信息" + } }, "discover": { "featured": "精选应用", @@ -55,5 +111,36 @@ "capsule": { "more": "更多操作", "close": "关闭应用" + }, + "detail": { + "notFound": "应用不存在", + "back": "返回", + "open": "打开", + "get": "获取", + "unknownDeveloper": "未知开发者", + "preview": "预览", + "supportedChains": "支持的区块链", + "privacy": "应用隐私", + "privacyHint": "开发者声明此应用可能会请求以下权限", + "tags": "标签", + "info": "信息", + "developer": "开发者", + "version": "版本", + "category": "类别", + "publishedAt": "发布日期", + "updatedAt": "更新日期", + "website": "开发者网站", + "visit": "访问", + "more": "更多", + "collapse": "收起", + "categories": { + "defi": "DeFi", + "nft": "NFT", + "tools": "工具", + "games": "游戏", + "social": "社交", + "exchange": "交易所", + "other": "其他" + } } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-TW/ecosystem.json b/src/i18n/locales/zh-TW/ecosystem.json index ddda46cdb..7261ebec4 100644 --- a/src/i18n/locales/zh-TW/ecosystem.json +++ b/src/i18n/locales/zh-TW/ecosystem.json @@ -14,12 +14,68 @@ "title": "權限請求", "grant": "授權", "deny": "拒絕", - "bio_requestAccounts": "存取錢包地址", - "bio_selectAccount": "選擇帳戶", - "bio_pickWallet": "選擇其他錢包", - "bio_signMessage": "簽名消息", - "bio_signTypedData": "簽名數據", - "bio_sendTransaction": "發起交易" + "defaultDescription": "此應用可能存取此功能", + "unknown": "未知權限", + "bio_requestAccounts": { + "name": "請求帳戶", + "description": "獲取您的錢包地址列表" + }, + "bio_accounts": { + "name": "獲取帳戶", + "description": "獲取已連接的帳戶列表" + }, + "bio_selectAccount": { + "name": "選擇帳戶", + "description": "讓您選擇一個帳戶" + }, + "bio_pickWallet": { + "name": "選擇錢包", + "description": "讓您選擇一個錢包" + }, + "bio_chainId": { + "name": "獲取鏈 ID", + "description": "獲取當前區塊鏈網路" + }, + "bio_getBalance": { + "name": "查詢餘額", + "description": "查詢帳戶餘額" + }, + "bio_createTransaction": { + "name": "建立交易", + "description": "構造未簽名交易(不做簽名/不做廣播)" + }, + "bio_signMessage": { + "name": "簽名消息", + "description": "使用您的私鑰簽名消息(需要您確認)" + }, + "bio_signTypedData": { + "name": "簽名數據", + "description": "簽名結構化數據(需要您確認)" + }, + "bio_signTransaction": { + "name": "簽名交易", + "description": "對未簽名交易進行簽名(需要您確認)" + }, + "bio_sendTransaction": { + "name": "發送交易", + "description": "請求發送轉帳(需要您確認)" + }, + "bio_destroyAsset": { + "name": "銷毀資產", + "description": "請求銷毀資產(需要您確認,不可撤銷)" + }, + "bio_requestCryptoToken": { + "name": "請求加密授權", + "description": "請求授權進行加密操作(需要您確認)" + }, + "bio_cryptoExecute": { + "name": "執行加密操作", + "description": "使用已授權的 Token 執行加密操作" + }, + "bio_getCryptoTokenInfo": { + "name": "查詢授權資訊", + "description": "查詢已授權 Token 的資訊" + } }, "discover": { "featured": "精選應用", @@ -55,5 +111,36 @@ "capsule": { "more": "更多操作", "close": "關閉應用" + }, + "detail": { + "notFound": "應用不存在", + "back": "返回", + "open": "開啟", + "get": "獲取", + "unknownDeveloper": "未知開發者", + "preview": "預覽", + "supportedChains": "支援的區塊鏈", + "privacy": "應用隱私", + "privacyHint": "開發者聲明此應用可能會請求以下權限", + "tags": "標籤", + "info": "資訊", + "developer": "開發者", + "version": "版本", + "category": "類別", + "publishedAt": "發布日期", + "updatedAt": "更新日期", + "website": "開發者網站", + "visit": "訪問", + "more": "更多", + "collapse": "收起", + "categories": { + "defi": "DeFi", + "nft": "NFT", + "tools": "工具", + "games": "遊戲", + "social": "社交", + "exchange": "交易所", + "other": "其他" + } } -} \ No newline at end of file +} diff --git a/src/services/ecosystem/index.ts b/src/services/ecosystem/index.ts index 65e2bfa2d..6fa46ad5f 100644 --- a/src/services/ecosystem/index.ts +++ b/src/services/ecosystem/index.ts @@ -2,8 +2,9 @@ * Bio Ecosystem Service */ -export * from './types' -export * from './bridge' -export * from './provider' -export * from './registry' -export * from './my-apps' +export * from './types'; +export * from './bridge'; +export * from './provider'; +export * from './registry'; +export * from './my-apps'; +export * from './permissions'; diff --git a/src/services/ecosystem/permissions.ts b/src/services/ecosystem/permissions.ts index 4d06318c5..ff46e7109 100644 --- a/src/services/ecosystem/permissions.ts +++ b/src/services/ecosystem/permissions.ts @@ -3,7 +3,8 @@ * 管理小程序的权限请求和检查 */ -import { ecosystemStore, ecosystemSelectors, ecosystemActions } from '@/stores/ecosystem' +import { ecosystemStore, ecosystemSelectors, ecosystemActions } from '@/stores/ecosystem'; +import i18n from '@/i18n'; /** 敏感方法列表(需要用户确认) */ export const SENSITIVE_METHODS = [ @@ -12,7 +13,7 @@ export const SENSITIVE_METHODS = [ 'bio_signTypedData', 'bio_signTransaction', 'bio_sendTransaction', -] as const +] as const; /** 所有可授权的方法 */ export const ALL_PERMISSIONS = [ @@ -24,16 +25,16 @@ export const ALL_PERMISSIONS = [ 'bio_getBalance', 'bio_createTransaction', ...SENSITIVE_METHODS, -] as const +] as const; -export type Permission = (typeof ALL_PERMISSIONS)[number] -export type SensitiveMethod = (typeof SENSITIVE_METHODS)[number] +export type Permission = (typeof ALL_PERMISSIONS)[number]; +export type SensitiveMethod = (typeof SENSITIVE_METHODS)[number]; /** * 检查方法是否为敏感方法 */ export function isSensitiveMethod(method: string): method is SensitiveMethod { - return SENSITIVE_METHODS.includes(method as SensitiveMethod) + return SENSITIVE_METHODS.includes(method as SensitiveMethod); } /** @@ -42,108 +43,88 @@ export function isSensitiveMethod(method: string): method is SensitiveMethod { export function hasPermission(appId: string, permission: string): boolean { // 非敏感方法默认允许 if (!isSensitiveMethod(permission)) { - return true + return true; } - return ecosystemSelectors.hasPermission(ecosystemStore.state, appId, permission) + return ecosystemSelectors.hasPermission(ecosystemStore.state, appId, permission); } /** * 检查应用是否有所有指定权限 */ export function hasAllPermissions(appId: string, permissions: string[]): boolean { - return permissions.every((p) => hasPermission(appId, p)) + return permissions.every((p) => hasPermission(appId, p)); } /** * 获取应用已授权的权限 */ export function getGrantedPermissions(appId: string): string[] { - return ecosystemSelectors.getGrantedPermissions(ecosystemStore.state, appId) + return ecosystemSelectors.getGrantedPermissions(ecosystemStore.state, appId); } /** * 获取应用缺失的权限 */ export function getMissingPermissions(appId: string, requested: string[]): string[] { - return requested.filter((p) => !hasPermission(appId, p)) + return requested.filter((p) => !hasPermission(appId, p)); } /** * 授予权限 */ export function grantPermissions(appId: string, permissions: string[]): void { - ecosystemActions.grantPermissions(appId, permissions) + ecosystemActions.grantPermissions(appId, permissions); } /** * 撤销权限 */ export function revokePermissions(appId: string, permissions?: string[]): void { - ecosystemActions.revokePermissions(appId, permissions) + ecosystemActions.revokePermissions(appId, permissions); } /** - * 获取权限的显示信息 + * 获取权限的显示信息(已翻译) + * + * 直接返回当前语言的翻译文本 + * + * @example + * const info = getPermissionInfo('bio_requestAccounts') + * // info.label = '请求账户' (zh-CN) / 'Request Accounts' (en) */ export function getPermissionInfo(permission: string): { - label: string - description: string - sensitive: boolean + label: string; + description: string; + sensitive: boolean; } { - const info: Record = { - bio_requestAccounts: { - label: '查看账户', - description: '查看您的钱包地址', - }, - bio_accounts: { - label: '获取账户', - description: '获取已连接的账户列表', - }, - bio_selectAccount: { - label: '选择账户', - description: '选择要使用的账户', - }, - bio_pickWallet: { - label: '选择钱包', - description: '选择目标钱包地址', - }, - bio_chainId: { - label: '获取链 ID', - description: '获取当前区块链网络', - }, - bio_getBalance: { - label: '查询余额', - description: '查询账户余额', - }, - bio_createTransaction: { - label: '创建交易', - description: '构造未签名交易(不做签名/不做广播)', - }, - bio_signMessage: { - label: '签名消息', - description: '请求签名消息(需要您确认)', - }, - bio_signTypedData: { - label: '签名数据', - description: '请求签名结构化数据(需要您确认)', - }, - bio_signTransaction: { - label: '签名交易', - description: '请求签名交易(需要您确认)', - }, - bio_sendTransaction: { - label: '发送交易', - description: '请求发送转账(需要您确认)', - }, - } + const t = i18n.t.bind(i18n); - const defaultInfo = { - label: permission, - description: '未知权限', - } + // Check if this permission has i18n definition + const knownPermissions = [ + 'bio_requestAccounts', + 'bio_accounts', + 'bio_selectAccount', + 'bio_pickWallet', + 'bio_chainId', + 'bio_getBalance', + 'bio_createTransaction', + 'bio_signMessage', + 'bio_signTypedData', + 'bio_signTransaction', + 'bio_sendTransaction', + 'bio_destroyAsset', + 'bio_requestCryptoToken', + 'bio_cryptoExecute', + 'bio_getCryptoTokenInfo', + ]; + + const isKnown = knownPermissions.includes(permission); return { - ...(info[permission] ?? defaultInfo), + label: isKnown ? t(`ecosystem:permissions.${permission}.name`) : permission, + description: isKnown + ? t(`ecosystem:permissions.${permission}.description`) + : t('ecosystem:permissions.defaultDescription'), sensitive: isSensitiveMethod(permission), - } + }; } diff --git a/src/services/ecosystem/types.ts b/src/services/ecosystem/types.ts index 87bc6759f..002438c66 100644 --- a/src/services/ecosystem/types.ts +++ b/src/services/ecosystem/types.ts @@ -1,6 +1,6 @@ /** * Bio Ecosystem Types - * + * * 统一从 chain-adapter 导入交易相关类型 */ @@ -20,315 +20,258 @@ export type { // 手续费 Fee, FeeEstimate, -} from '@/services/chain-adapter' +} from '@/services/chain-adapter'; // ===== Ecosystem 专用类型 ===== /** Account information */ export interface BioAccount { - address: string - chain: string - name?: string + address: string; + chain: string; + name?: string; /** Public key (optional, for dweb-compat) */ - publicKey?: string + publicKey?: string; } /** * Ecosystem 转账参数(RPC 参数格式) - * + * * 注意:这与 chain-adapter 的 TransferIntent 不同 * - 这是 RPC 接收的参数格式(amount 是 string) * - TransferIntent 是内部使用的格式(amount 是 Amount) */ export interface EcosystemTransferParams { - from: string - to: string - amount: string // RPC 参数是字符串 - chain: string - asset?: string + from: string; + to: string; + amount: string; // RPC 参数是字符串 + chain: string; + asset?: string; } /** * Ecosystem 销毁参数(RPC 参数格式) */ export interface EcosystemDestroyParams { - from: string - amount: string - chain: string - asset: string + from: string; + amount: string; + chain: string; + asset: string; } /** Request message from miniapp */ export interface BioRequestMessage { - type: 'bio_request' - id: string - method: string - params?: unknown[] + type: 'bio_request'; + id: string; + method: string; + params?: unknown[]; } /** Response message to miniapp */ export interface BioResponseMessage { - type: 'bio_response' - id: string - success: boolean - result?: unknown - error?: { code: number; message: string; data?: unknown } + type: 'bio_response'; + id: string; + success: boolean; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; } /** Event message to miniapp */ export interface BioEventMessage { - type: 'bio_event' - event: string - args: unknown[] + type: 'bio_event'; + event: string; + args: unknown[]; } /** Miniapp category */ export type MiniappCategory = - | 'defi' // DeFi 应用 - | 'nft' // NFT 相关 - | 'tools' // 工具类 - | 'games' // 游戏 - | 'social' // 社交 - | 'exchange' // 交易所 - | 'other' // 其他 + | 'defi' // DeFi 应用 + | 'nft' // NFT 相关 + | 'tools' // 工具类 + | 'games' // 游戏 + | 'social' // 社交 + | 'exchange' // 交易所 + | 'other'; // 其他 /** Miniapp target desktop page */ -export type MiniappTargetDesktop = 'stack' | 'mine' +export type MiniappTargetDesktop = 'stack' | 'mine'; -/** Permission definition */ +/** Permission definition (simplified - name/description now in i18n) */ export interface PermissionDefinition { - id: string - name: string - description: string - risk: 'low' | 'medium' | 'high' + risk: 'low' | 'medium' | 'high'; } -/** Known permissions */ -export const KNOWN_PERMISSIONS: Record = { - bio_requestAccounts: { - id: 'bio_requestAccounts', - name: '请求账户', - description: '获取您的钱包地址列表', - risk: 'low', - }, - bio_selectAccount: { - id: 'bio_selectAccount', - name: '选择账户', - description: '让您选择一个账户', - risk: 'low', - }, - bio_pickWallet: { - id: 'bio_pickWallet', - name: '选择钱包', - description: '让您选择一个钱包', - risk: 'low', - }, - bio_createTransaction: { - id: 'bio_createTransaction', - name: '创建交易', - description: '构造未签名交易(不做签名/不做广播)', - risk: 'low', - }, - bio_signMessage: { - id: 'bio_signMessage', - name: '签名消息', - description: '使用您的私钥签名消息(需要您确认)', - risk: 'medium', - }, - bio_signTypedData: { - id: 'bio_signTypedData', - name: '签名数据', - description: '签名结构化数据(需要您确认)', - risk: 'medium', - }, - bio_signTransaction: { - id: 'bio_signTransaction', - name: '签名交易', - description: '对未签名交易进行签名(需要您确认)', - risk: 'high', - }, - bio_sendTransaction: { - id: 'bio_sendTransaction', - name: '发送交易', - description: '请求发送转账(需要您确认)', - risk: 'high', - }, - bio_destroyAsset: { - id: 'bio_destroyAsset', - name: '销毁资产', - description: '请求销毁资产(需要您确认,不可撤销)', - risk: 'high', - }, - bio_requestCryptoToken: { - id: 'bio_requestCryptoToken', - name: '请求加密授权', - description: '请求授权进行加密操作(需要您确认)', - risk: 'high', - }, - bio_cryptoExecute: { - id: 'bio_cryptoExecute', - name: '执行加密操作', - description: '使用已授权的 Token 执行加密操作', - risk: 'low', - }, - bio_getCryptoTokenInfo: { - id: 'bio_getCryptoTokenInfo', - name: '查询授权信息', - description: '查询已授权 Token 的信息', - risk: 'low', - }, -} +/** + * Known permissions - risk level only + * Name and description are now in i18n: ecosystem.json -> permissions..name/description + */ +export const KNOWN_PERMISSIONS: Record = { + bio_requestAccounts: { risk: 'low' }, + bio_selectAccount: { risk: 'low' }, + bio_pickWallet: { risk: 'low' }, + bio_createTransaction: { risk: 'low' }, + bio_signMessage: { risk: 'medium' }, + bio_signTypedData: { risk: 'medium' }, + bio_signTransaction: { risk: 'high' }, + bio_sendTransaction: { risk: 'high' }, + bio_destroyAsset: { risk: 'high' }, + bio_requestCryptoToken: { risk: 'high' }, + bio_cryptoExecute: { risk: 'low' }, + bio_getCryptoTokenInfo: { risk: 'low' }, + // Additional permissions from permissions.ts + bio_accounts: { risk: 'low' }, + bio_chainId: { risk: 'low' }, + bio_getBalance: { risk: 'low' }, +}; /** Miniapp manifest - 完整的小程序元数据 */ export interface MiniappManifest { /** 唯一标识符 */ - id: string + id: string; /** 显示名称 */ - name: string + name: string; /** 简短描述 */ - description: string + description: string; /** 详细介绍 (可选,支持 Markdown) */ - longDescription?: string + longDescription?: string; /** 应用图标 URL */ - icon: string + icon: string; /** 应用入口 URL */ - url: string + url: string; /** 版本号 (semver) */ - version: string + version: string; /** 作者/开发者 */ - author?: string + author?: string; /** 开发者网站 */ - website?: string + website?: string; /** 分类 */ - category?: MiniappCategory + category?: MiniappCategory; /** 标签 */ - tags?: string[] + tags?: string[]; /** 请求的权限列表 */ - permissions?: string[] + permissions?: string[]; /** 支持的链 */ - chains?: string[] + chains?: string[]; /** 截图 URL 列表 */ - screenshots?: string[] + screenshots?: string[]; /** 最低钱包版本要求 */ - minWalletVersion?: string + minWalletVersion?: string; /** 发布时间 */ - publishedAt?: string + publishedAt?: string; /** 更新时间 */ - updatedAt?: string + updatedAt?: string; /** 是否为测试版 */ - beta?: boolean + beta?: boolean; /** 推荐分(官方评分,0-100,可选) */ - officialScore?: number + officialScore?: number; /** 热门分(社区评分,0-100,可选) */ - communityScore?: number + communityScore?: number; /** * 启动屏配置 * 如果配置了启动屏,小程序需要调用 bio.closeSplashScreen() 来关闭 * 如果未配置,则使用 iframe load 事件自动关闭加载状态 - * + * * - 图标自动使用 manifest.icon * - 背景色自动使用 manifest.themeColor - * + * * @example * // 简写形式 * "splashScreen": true - * + * * // 自定义超时 * "splashScreen": { "timeout": 3000 } */ - splashScreen?: true | { - /** 最大等待时间 (ms),超时后自动关闭启动屏,默认 5000 */ - timeout?: number - } + splashScreen?: + | true + | { + /** 最大等待时间 (ms),超时后自动关闭启动屏,默认 5000 */ + timeout?: number; + }; /** * 胶囊主题(可选) * - 'auto': 跟随 KeyApp 主题(默认) * - 'dark': 强制深色胶囊 * - 'light': 强制浅色胶囊 */ - capsuleTheme?: 'auto' | 'dark' | 'light' + capsuleTheme?: 'auto' | 'dark' | 'light'; /** * 目标桌面页(可选,默认 'stack') * - 'stack': 渲染到应用堆栈页(stack slide) * - 'mine': 渲染到我的页(mine slide) */ - targetDesktop?: MiniappTargetDesktop - /** - * 主题色 - 用于卡片背景等 + targetDesktop?: MiniappTargetDesktop; + /** + * 主题色 - 用于卡片背景等 * 格式: CSS 渐变类名 (Tailwind) 或 HEX 颜色 * 例: "from-violet-500 to-purple-600" 或 "#6366f1" */ - themeColor?: string + themeColor?: string; /** * 主题色(起始)- HEX 格式 */ - themeColorFrom?: string + themeColorFrom?: string; /** * 主题色(结束)- HEX 格式 */ - themeColorTo?: string + themeColorTo?: string; // ============================================ // 以下字段由 registry 在加载时自动填充 // ============================================ /** 来源 URL(运行时填充) */ - sourceUrl?: string + sourceUrl?: string; /** 来源图标(运行时填充) */ - sourceIcon?: string + sourceIcon?: string; /** 来源名称(运行时填充) */ - sourceName?: string + sourceName?: string; } /** Ecosystem source - JSON 文件格式 */ export interface EcosystemSource { - name: string - version: string - updated: string + name: string; + version: string; + updated: string; /** 订阅源图标 URL */ - icon?: string + icon?: string; /** 可选:远程搜索能力(固定 GET,使用 %s 替换 query) */ search?: { - urlTemplate: string - } - apps: MiniappManifest[] + urlTemplate: string; + }; + apps: MiniappManifest[]; } /** 订阅源记录 - 本地存储格式 */ export interface SourceRecord { - url: string - name: string - enabled: boolean - lastUpdated: string + url: string; + name: string; + enabled: boolean; + lastUpdated: string; /** 图标 URL,默认使用 https 锁图标 */ - icon?: string + icon?: string; /** 是否为内置源 */ - builtin?: boolean + builtin?: boolean; } /** * My Apps - Local installed app record */ export interface MyAppRecord { - appId: string - installedAt: number - lastUsedAt: number + appId: string; + installedAt: number; + lastUsedAt: number; } /** Method handler */ -export type MethodHandler = ( - params: unknown, - context: HandlerContext -) => Promise +export type MethodHandler = (params: unknown, context: HandlerContext) => Promise; /** Handler context */ export interface HandlerContext { - appId: string - appName: string - appIcon?: string - origin: string - permissions: string[] + appId: string; + appName: string; + appIcon?: string; + origin: string; + permissions: string[]; } /** Error codes */ @@ -341,21 +284,16 @@ export const BioErrorCodes = { INTERNAL_ERROR: -32603, INVALID_PARAMS: -32602, METHOD_NOT_FOUND: -32601, -} as const +} as const; /** Create error response */ -export function createErrorResponse( - id: string, - code: number, - message: string, - data?: unknown -): BioResponseMessage { +export function createErrorResponse(id: string, code: number, message: string, data?: unknown): BioResponseMessage { return { type: 'bio_response', id, success: false, error: { code, message, data }, - } + }; } /** Create success response */ @@ -365,5 +303,5 @@ export function createSuccessResponse(id: string, result: unknown): BioResponseM id, success: true, result, - } + }; } diff --git a/src/stackflow/activities/MiniappDetailActivity.tsx b/src/stackflow/activities/MiniappDetailActivity.tsx index 1dd858ff5..36436dad3 100644 --- a/src/stackflow/activities/MiniappDetailActivity.tsx +++ b/src/stackflow/activities/MiniappDetailActivity.tsx @@ -2,20 +2,22 @@ * MiniappDetailActivity - 小程序详情页 (App Store 风格) */ -import { useEffect, useState, useCallback } from 'react' -import type { ActivityComponentType } from '@stackflow/react' -import { useStore } from '@tanstack/react-store' -import { AppScreen } from '@stackflow/plugin-basic-ui' -import { useFlow } from '../stackflow' +import { useEffect, useState, useCallback } from 'react'; +import type { ActivityComponentType } from '@stackflow/react'; +import { useStore } from '@tanstack/react-store'; +import { AppScreen } from '@stackflow/plugin-basic-ui'; +import { useTranslation } from 'react-i18next'; +import { useFlow } from '../stackflow'; import { getAppById, initRegistry, refreshSources, type MiniappManifest, - KNOWN_PERMISSIONS -} from '@/services/ecosystem' -import { LoadingSpinner } from '@/components/common' -import { MiniappIcon } from '@/components/ecosystem' + KNOWN_PERMISSIONS, + getPermissionInfo, +} from '@/services/ecosystem'; +import { LoadingSpinner } from '@/components/common'; +import { MiniappIcon } from '@/components/ecosystem'; import { IconArrowLeft, IconShieldCheck, @@ -24,244 +26,207 @@ import { IconChevronDown, IconChevronUp, IconShare, -} from '@tabler/icons-react' -import { cn } from '@/lib/utils' -import { launchApp } from '@/services/miniapp-runtime' -import { ecosystemActions, ecosystemStore, ecosystemSelectors } from '@/stores/ecosystem' +} from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; +import { launchApp } from '@/services/miniapp-runtime'; +import { ecosystemActions, ecosystemStore, ecosystemSelectors } from '@/stores/ecosystem'; type MiniappDetailActivityParams = { - appId: string -} - -const CATEGORY_LABELS: Record = { - defi: 'DeFi', - nft: 'NFT', - tools: '工具', - games: '游戏', - social: '社交', - exchange: '交易所', - other: '其他', -} + appId: string; +}; -function PrivacyItem({ - permission, - isLast -}: { - permission: string - isLast: boolean -}) { - const def = KNOWN_PERMISSIONS[permission] - const risk = def?.risk ?? 'medium' +function PrivacyItem({ permission, isLast }: { permission: string; isLast: boolean }) { + const def = KNOWN_PERMISSIONS[permission]; + const risk = def?.risk ?? 'medium'; + const info = getPermissionInfo(permission); - const Icon = risk === 'high' ? IconAlertTriangle : IconShieldCheck - const iconColor = risk === 'high' ? 'text-red-500' : risk === 'medium' ? 'text-amber-500' : 'text-green-500' + const Icon = risk === 'high' ? IconAlertTriangle : IconShieldCheck; + const iconColor = risk === 'high' ? 'text-red-500' : risk === 'medium' ? 'text-amber-500' : 'text-green-500'; return ( -
- -
-

{def?.name ?? permission}

-

- {def?.description ?? '此应用可能访问此功能'} -

+
+ +
+

{info.label}

+

{info.description}

- ) + ); } function InfoRow({ label, value, isLink = false, - href + href, }: { - label: string - value: string - isLink?: boolean - href?: string + label: string; + value: string; + isLink?: boolean; + href?: string; }) { const content = ( -
+
{label} - + {value} {isLink && }
- ) + ); if (isLink && href) { return ( {content} - ) + ); } - return content + return content; } export const MiniappDetailActivity: ActivityComponentType = ({ params }) => { - const { pop } = useFlow() - const [app, setApp] = useState(null) - const [loading, setLoading] = useState(true) - const [descExpanded, setDescExpanded] = useState(false) + const { pop } = useFlow(); + const { t } = useTranslation('ecosystem'); + const [app, setApp] = useState(null); + const [loading, setLoading] = useState(true); + const [descExpanded, setDescExpanded] = useState(false); // Use store selector for reactivity (Single Source of Truth) - const installed = useStore(ecosystemStore, (state) => - ecosystemSelectors.isAppInstalled(state, params.appId) - ) + const installed = useStore(ecosystemStore, (state) => ecosystemSelectors.isAppInstalled(state, params.appId)); useEffect(() => { - let disposed = false + let disposed = false; const load = async () => { - setLoading(true) + setLoading(true); - await initRegistry() - let manifest = getAppById(params.appId) + await initRegistry(); + let manifest = getAppById(params.appId); // If opened via deep-link, cache may be empty. Refresh once to ensure we can resolve the app. if (!manifest) { - await refreshSources({ force: false }) - manifest = getAppById(params.appId) + await refreshSources({ force: false }); + manifest = getAppById(params.appId); } - if (disposed) return - setApp(manifest ?? null) - setLoading(false) - } + if (disposed) return; + setApp(manifest ?? null); + setLoading(false); + }; - void load() + void load(); return () => { - disposed = true - } - }, [params.appId]) + disposed = true; + }; + }, [params.appId]); const handleInstall = useCallback(() => { - if (!app) return - ecosystemActions.installApp(app.id) - }, [app]) + if (!app) return; + ecosystemActions.installApp(app.id); + }, [app]); const handleOpen = useCallback(() => { - if (!app) return - ecosystemActions.updateAppLastUsed(app.id) - ecosystemActions.setActiveSubPage('mine') - launchApp(app.id, { ...app, targetDesktop: 'mine' }) - pop() - }, [app, pop]) + if (!app) return; + ecosystemActions.updateAppLastUsed(app.id); + ecosystemActions.setActiveSubPage('mine'); + launchApp(app.id, { ...app, targetDesktop: 'mine' }); + pop(); + }, [app, pop]); if (loading) { return ( -
+
- ) + ); } if (!app) { return ( -
-

应用不存在

+
+

{t('detail.notFound')}

- ) + ); } - const description = app.longDescription ?? app.description - const isDescLong = description.length > 150 - const displayDesc = descExpanded || !isDescLong - ? description - : description.slice(0, 150) + '...' + const description = app.longDescription ?? app.description; + const isDescLong = description.length > 150; + const displayDesc = descExpanded || !isDescLong ? description : description.slice(0, 150) + '...'; return ( -
+
{/* Header - 滚动驱动效果:滚动后显示 app 名称 */} -
+
- {/* App 名称 - 滚动后显示(渐进增强,不支持时保持隐藏) */} - - {app.name} - + {app.name} -
-
+
{/* App Header - App Store 风格 */}
{/* 大图标 */} - + {/* 信息区 */} -
-

{app.name}

-

- {app.author ?? '未知开发者'} +

+

{app.name}

+

+ {app.author ?? t('detail.unknownDeveloper')}

{/* 获取/打开按钮 */} {installed ? ( ) : ( )}
{/* 元信息行 */} -
+
{app.category && ( - - {CATEGORY_LABELS[app.category] ?? app.category} + + {t(`detail.categories.${app.category}`, { defaultValue: app.category })} )} {app.beta && ( - + Beta )} @@ -271,25 +236,25 @@ export const MiniappDetailActivity: ActivityComponentType 0 && ( -
+
-

预览

+

{t('detail.preview')}

{app.screenshots.map((url, i) => (
{`${app.name} { - e.currentTarget.parentElement!.style.display = 'none' + e.currentTarget.parentElement!.style.display = 'none'; }} />
@@ -300,35 +265,26 @@ export const MiniappDetailActivity: ActivityComponentType -

- {displayDesc} -

+
+

{displayDesc}

{isDescLong && ( )}
{/* 支持的链 */} {app.chains && app.chains.length > 0 && ( -
-

支持的区块链

+
+

{t('detail.supportedChains')}

{app.chains.map((chain) => ( - + {chain} ))} @@ -338,18 +294,12 @@ export const MiniappDetailActivity: ActivityComponentType 0 && ( -
-

应用隐私

-

- 开发者声明此应用可能会请求以下权限 -

+
+

{t('detail.privacy')}

+

{t('detail.privacyHint')}

{app.permissions.map((perm, i) => ( - + ))}
@@ -357,14 +307,11 @@ export const MiniappDetailActivity: ActivityComponentType 0 && ( -
-

标签

+
+

{t('detail.tags')}

{app.tags.map((tag) => ( - + #{tag} ))} @@ -373,32 +320,21 @@ export const MiniappDetailActivity: ActivityComponentType -

信息

+
+

{t('detail.info')}

- {app.author && ( - - )} - + {app.author && } + {app.category && ( )} - {app.publishedAt && ( - - )} - {app.updatedAt && ( - - )} + {app.publishedAt && } + {app.updatedAt && } {app.website && ( - + )}
@@ -408,6 +344,5 @@ export const MiniappDetailActivity: ActivityComponentType
- ) -} - + ); +}; From 7e35411222d41a25daa676e34a55baf6b105582f Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 19:04:06 +0800 Subject: [PATCH 2/5] feat(i18n): add error message translations and update hooks to use i18n - Expand error.json with validation, transaction, crypto, and other error keys - Update use-send.logic.ts to use i18n for validation messages - Update use-burn.ts to use i18n for error messages - Add translations for all 4 locales (zh-CN, en, zh-TW, ar) --- src/hooks/use-burn.ts | 496 +++++++++++++++--------------- src/hooks/use-send.logic.ts | 84 +++-- src/i18n/locales/ar/error.json | 49 ++- src/i18n/locales/en/error.json | 49 ++- src/i18n/locales/zh-CN/error.json | 47 ++- src/i18n/locales/zh-TW/error.json | 47 ++- 6 files changed, 473 insertions(+), 299 deletions(-) diff --git a/src/hooks/use-burn.ts b/src/hooks/use-burn.ts index f9dffb069..6cb84b03a 100644 --- a/src/hooks/use-burn.ts +++ b/src/hooks/use-burn.ts @@ -1,14 +1,17 @@ /** * Hook for managing burn (destroy asset) flow state - * + * * Only supports BioForest chains. Main asset cannot be destroyed. */ -import { useState, useCallback, useMemo, useEffect } from 'react' -import type { AssetInfo } from '@/types/asset' -import { Amount } from '@/types/amount' -import type { BurnState, UseBurnOptions, UseBurnReturn, BurnSubmitResult } from './use-burn.types' -import { fetchAssetApplyAddress, fetchBioforestBurnFee, submitBioforestBurn } from './use-burn.bioforest' +import { useState, useCallback, useMemo, useEffect } from 'react'; +import type { AssetInfo } from '@/types/asset'; +import { Amount } from '@/types/amount'; +import type { BurnState, UseBurnOptions, UseBurnReturn, BurnSubmitResult } from './use-burn.types'; +import { fetchAssetApplyAddress, fetchBioforestBurnFee, submitBioforestBurn } from './use-burn.bioforest'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); const initialState: BurnState = { step: 'input', @@ -24,53 +27,46 @@ const initialState: BurnState = { resultStatus: null, txHash: null, errorMessage: null, -} +}; // Mock fee for non-bioforest or mock mode -const MOCK_FEE = { amount: '0.001', symbol: 'BFM' } +const MOCK_FEE = { amount: '0.001', symbol: 'BFM' }; /** * Validate amount input */ function validateAmountInput(amount: Amount | null, asset: AssetInfo | null): string | null { - if (!amount || !asset) return null + if (!amount || !asset) return null; if (!amount.isPositive()) { - return '请输入有效金额' + return t('error:validation.enterValidAmount'); } - const balance = asset.amount + const balance = asset.amount; if (amount.gt(balance)) { - return '销毁数量不能大于余额' + return t('error:validation.exceedsBalance'); } - return null + return null; } /** * Hook for managing burn flow */ export function useBurn(options: UseBurnOptions = {}): UseBurnReturn { - const { - initialAsset, - assetLocked = false, - useMock = true, - walletId, - fromAddress, - chainConfig - } = options + const { initialAsset, assetLocked = false, useMock = true, walletId, fromAddress, chainConfig } = options; const [state, setState] = useState({ ...initialState, asset: initialAsset ?? null, - }) + }); - const isBioforestChain = chainConfig?.chainKind === 'bioforest' + const isBioforestChain = chainConfig?.chainKind === 'bioforest'; // Validate amount const validateAmount = useCallback((amount: Amount | null, asset: AssetInfo | null): string | null => { - return validateAmountInput(amount, asset) - }, []) + return validateAmountInput(amount, asset); + }, []); // Set amount const setAmount = useCallback((amount: Amount | null) => { @@ -78,306 +74,306 @@ export function useBurn(options: UseBurnOptions = {}): UseBurnReturn { ...prev, amount, amountError: null, - })) - }, []) + })); + }, []); // Set asset and fetch applyAddress + fee - const setAsset = useCallback((asset: AssetInfo) => { - setState((prev) => ({ - ...prev, - asset, - amount: null, - amountError: null, - feeLoading: true, - recipientAddress: null, - })) - - const shouldUseMock = useMock || !isBioforestChain || !chainConfig || !fromAddress + const setAsset = useCallback( + (asset: AssetInfo) => { + setState((prev) => ({ + ...prev, + asset, + amount: null, + amountError: null, + feeLoading: true, + recipientAddress: null, + })); + + const shouldUseMock = useMock || !isBioforestChain || !chainConfig || !fromAddress; + + if (shouldUseMock) { + // Mock mode + setTimeout(() => { + setState((prev) => ({ + ...prev, + recipientAddress: 'mock_apply_address', + feeAmount: Amount.fromFormatted(MOCK_FEE.amount, asset.decimals, MOCK_FEE.symbol), + feeMinAmount: Amount.fromFormatted(MOCK_FEE.amount, asset.decimals, MOCK_FEE.symbol), + feeSymbol: chainConfig?.symbol ?? MOCK_FEE.symbol, + feeLoading: false, + })); + }, 300); + return; + } - if (shouldUseMock) { - // Mock mode - setTimeout(() => { - setState((prev) => ({ - ...prev, - recipientAddress: 'mock_apply_address', - feeAmount: Amount.fromFormatted(MOCK_FEE.amount, asset.decimals, MOCK_FEE.symbol), - feeMinAmount: Amount.fromFormatted(MOCK_FEE.amount, asset.decimals, MOCK_FEE.symbol), - feeSymbol: chainConfig?.symbol ?? MOCK_FEE.symbol, - feeLoading: false, - })) - }, 300) - return - } + // Real mode - fetch applyAddress and fee + void (async () => { + try { + // Fetch asset's applyAddress + const applyAddress = await fetchAssetApplyAddress(chainConfig, asset.assetType, fromAddress); + if (!applyAddress) { + setState((prev) => ({ + ...prev, + feeLoading: false, + errorMessage: t('error:transaction.issuerAddressNotFound'), + })); + return; + } + + // Fetch fee (use placeholder amount for fee calculation) + const feeResult = await fetchBioforestBurnFee(chainConfig, asset.assetType, '1'); - // Real mode - fetch applyAddress and fee - void (async () => { - try { - // Fetch asset's applyAddress - const applyAddress = await fetchAssetApplyAddress(chainConfig, asset.assetType, fromAddress) - if (!applyAddress) { + setState((prev) => ({ + ...prev, + recipientAddress: applyAddress, + feeAmount: feeResult.amount, + feeMinAmount: feeResult.amount, + feeSymbol: feeResult.symbol, + feeLoading: false, + })); + } catch (error) { setState((prev) => ({ ...prev, feeLoading: false, - errorMessage: '无法获取资产发行地址', - })) - return + errorMessage: error instanceof Error ? error.message : t('error:transaction.feeEstimateFailed'), + })); } - - // Fetch fee (use placeholder amount for fee calculation) - const feeResult = await fetchBioforestBurnFee(chainConfig, asset.assetType, '1') - - setState((prev) => ({ - ...prev, - recipientAddress: applyAddress, - feeAmount: feeResult.amount, - feeMinAmount: feeResult.amount, - feeSymbol: feeResult.symbol, - feeLoading: false, - })) - } catch (error) { - setState((prev) => ({ - ...prev, - feeLoading: false, - errorMessage: error instanceof Error ? error.message : '获取手续费失败', - })) - } - })() - }, [chainConfig, fromAddress, isBioforestChain, useMock]) + })(); + }, + [chainConfig, fromAddress, isBioforestChain, useMock], + ); // Initialize with initial asset useEffect(() => { if (initialAsset && !state.recipientAddress) { - setAsset(initialAsset) + setAsset(initialAsset); } - }, [initialAsset, setAsset, state.recipientAddress]) + }, [initialAsset, setAsset, state.recipientAddress]); // Check if can proceed const canProceed = useMemo(() => { - return !!( - state.asset && - state.amount && - state.amount.isPositive() && - state.recipientAddress && - !state.feeLoading - ) - }, [state.amount, state.asset, state.recipientAddress, state.feeLoading]) + return !!(state.asset && state.amount && state.amount.isPositive() && state.recipientAddress && !state.feeLoading); + }, [state.amount, state.asset, state.recipientAddress, state.feeLoading]); // Validate and go to confirm const goToConfirm = useCallback((): boolean => { - const amountError = validateAmount(state.amount, state.asset) + const amountError = validateAmount(state.amount, state.asset); if (amountError) { setState((prev) => ({ ...prev, amountError, - })) - return false + })); + return false; } if (!state.recipientAddress) { setState((prev) => ({ ...prev, - errorMessage: '资产发行地址未获取', - })) - return false + errorMessage: t('error:transaction.issuerAddressNotReady'), + })); + return false; } setState((prev) => ({ ...prev, step: 'confirm', amountError: null, - })) - return true - }, [state.amount, state.asset, state.recipientAddress, validateAmount]) + })); + return true; + }, [state.amount, state.asset, state.recipientAddress, validateAmount]); // Go back to input const goBack = useCallback(() => { setState((prev) => ({ ...prev, step: 'input', - })) - }, []) + })); + }, []); // Submit transaction - const submit = useCallback(async (password: string): Promise => { - - - if (useMock) { + const submit = useCallback( + async (password: string): Promise => { + if (useMock) { + setState((prev) => ({ + ...prev, + step: 'burning', + isSubmitting: true, + })); - setState((prev) => ({ - ...prev, - step: 'burning', - isSubmitting: true, - })) + // Simulate async operation + await new Promise((resolve) => setTimeout(resolve, 1500)); - // Simulate async operation - await new Promise((resolve) => setTimeout(resolve, 1500)) + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'success', + txHash: `mock_burn_tx_${Date.now()}`, + })); - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'success', - txHash: `mock_burn_tx_${Date.now()}`, - })) + return { status: 'ok', txHash: `mock_burn_tx_${Date.now()}` }; + } - return { status: 'ok', txHash: `mock_burn_tx_${Date.now()}` } - } + if (!chainConfig || chainConfig.chainKind !== 'bioforest') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + errorMessage: '仅支持 BioForest 链的资产销毁', + })); + return { status: 'error', message: '仅支持 BioForest 链' }; + } - if (!chainConfig || chainConfig.chainKind !== 'bioforest') { - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'failed', - errorMessage: '仅支持 BioForest 链的资产销毁', - })) - return { status: 'error', message: '仅支持 BioForest 链' } - } + if (!walletId || !fromAddress || !state.asset || !state.amount || !state.recipientAddress) { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + errorMessage: '参数不完整', + })); + return { status: 'error', message: '参数不完整' }; + } - if (!walletId || !fromAddress || !state.asset || !state.amount || !state.recipientAddress) { setState((prev) => ({ ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'failed', - errorMessage: '参数不完整', - })) - return { status: 'error', message: '参数不完整' } - } + step: 'burning', + isSubmitting: true, + errorMessage: null, + })); + + const result = await submitBioforestBurn({ + chainConfig, + walletId, + password, + fromAddress, + recipientAddress: state.recipientAddress, + assetType: state.asset.assetType, + amount: state.amount, + fee: state.feeAmount ?? undefined, + }); + + if (result.status === 'password') { + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })); + return { status: 'password' }; + } - setState((prev) => ({ - ...prev, - step: 'burning', - isSubmitting: true, - errorMessage: null, - })) - - const result = await submitBioforestBurn({ - chainConfig, - walletId, - password, - fromAddress, - recipientAddress: state.recipientAddress, - assetType: state.asset.assetType, - amount: state.amount, - fee: state.feeAmount ?? undefined, - }) - - if (result.status === 'password') { - setState((prev) => ({ - ...prev, - step: 'confirm', - isSubmitting: false, - })) - return { status: 'password' } - } + if (result.status === 'password_required') { + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })); + return { status: 'two_step_secret_required', secondPublicKey: result.secondPublicKey }; + } - if (result.status === 'password_required') { - setState((prev) => ({ - ...prev, - step: 'confirm', - isSubmitting: false, - })) - return { status: 'two_step_secret_required', secondPublicKey: result.secondPublicKey } - } + if (result.status === 'error') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + errorMessage: result.message, + })); + return { status: 'error', message: result.message }; + } - if (result.status === 'error') { setState((prev) => ({ ...prev, step: 'result', isSubmitting: false, - resultStatus: 'failed', - errorMessage: result.message, - })) - return { status: 'error', message: result.message } - } - - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'success', - txHash: result.txHash, - })) + resultStatus: 'success', + txHash: result.txHash, + })); - return { status: 'ok', txHash: result.txHash } - }, [chainConfig, fromAddress, state.amount, state.asset, state.feeAmount, state.recipientAddress, useMock, walletId]) + return { status: 'ok', txHash: result.txHash }; + }, + [chainConfig, fromAddress, state.amount, state.asset, state.feeAmount, state.recipientAddress, useMock, walletId], + ); // Submit with two-step secret (pay password) - const submitWithTwoStepSecret = useCallback(async (password: string, twoStepSecret: string): Promise => { - if (!chainConfig || !walletId || !fromAddress || !state.asset || !state.amount || !state.recipientAddress) { - return { status: 'error', message: '参数不完整' } - } + const submitWithTwoStepSecret = useCallback( + async (password: string, twoStepSecret: string): Promise => { + if (!chainConfig || !walletId || !fromAddress || !state.asset || !state.amount || !state.recipientAddress) { + return { status: 'error', message: '参数不完整' }; + } - setState((prev) => ({ - ...prev, - step: 'burning', - isSubmitting: true, - errorMessage: null, - })) - - const result = await submitBioforestBurn({ - chainConfig, - walletId, - password, - fromAddress, - recipientAddress: state.recipientAddress, - assetType: state.asset.assetType, - amount: state.amount, - fee: state.feeAmount ?? undefined, - twoStepSecret, - }) - - if (result.status === 'password') { setState((prev) => ({ ...prev, - step: 'confirm', - isSubmitting: false, - })) - return { status: 'password' } - } + step: 'burning', + isSubmitting: true, + errorMessage: null, + })); + + const result = await submitBioforestBurn({ + chainConfig, + walletId, + password, + fromAddress, + recipientAddress: state.recipientAddress, + assetType: state.asset.assetType, + amount: state.amount, + fee: state.feeAmount ?? undefined, + twoStepSecret, + }); + + if (result.status === 'password') { + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })); + return { status: 'password' }; + } - if (result.status === 'error') { - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'failed', - errorMessage: result.message, - })) - return { status: 'error', message: result.message } - } + if (result.status === 'error') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + errorMessage: result.message, + })); + return { status: 'error', message: result.message }; + } + + if (result.status === 'password_required') { + // 不应该发生,因为已经提供了 twoStepSecret + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })); + return { status: 'two_step_secret_required', secondPublicKey: result.secondPublicKey }; + } - if (result.status === 'password_required') { - // 不应该发生,因为已经提供了 twoStepSecret + // result.status === 'ok' setState((prev) => ({ ...prev, - step: 'confirm', + step: 'result', isSubmitting: false, - })) - return { status: 'two_step_secret_required', secondPublicKey: result.secondPublicKey } - } - - // result.status === 'ok' - setState((prev) => ({ - ...prev, - step: 'result', - isSubmitting: false, - resultStatus: 'success', - txHash: result.txHash, - })) + resultStatus: 'success', + txHash: result.txHash, + })); - return { status: 'ok', txHash: result.txHash } - }, [chainConfig, fromAddress, state.amount, state.asset, state.feeAmount, state.recipientAddress, walletId]) + return { status: 'ok', txHash: result.txHash }; + }, + [chainConfig, fromAddress, state.amount, state.asset, state.feeAmount, state.recipientAddress, walletId], + ); // Reset to initial state const reset = useCallback(() => { setState({ ...initialState, asset: initialAsset ?? null, - }) - }, [initialAsset]) + }); + }, [initialAsset]); return { state, @@ -390,5 +386,5 @@ export function useBurn(options: UseBurnOptions = {}): UseBurnReturn { reset, canProceed, assetLocked, - } + }; } diff --git a/src/hooks/use-send.logic.ts b/src/hooks/use-send.logic.ts index cd674707d..2995a2929 100644 --- a/src/hooks/use-send.logic.ts +++ b/src/hooks/use-send.logic.ts @@ -1,82 +1,80 @@ -import type { AssetInfo } from '@/types/asset' -import { Amount } from '@/types/amount' -import { isValidBioforestAddress } from '@/lib/crypto' -import { isValidAddress } from '@/components/transfer/address-input' +import type { AssetInfo } from '@/types/asset'; +import type { Amount } from '@/types/amount'; +import { isValidBioforestAddress } from '@/lib/crypto'; +import { isValidAddress } from '@/components/transfer/address-input'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); export function isValidRecipientAddress(address: string, isBioforestChain: boolean): boolean { - if (!address.trim()) return false - if (isBioforestChain) return isValidBioforestAddress(address) - return isValidAddress(address) + if (!address.trim()) return false; + if (isBioforestChain) return isValidBioforestAddress(address); + return isValidAddress(address); } export function validateAddressInput(address: string, isBioforestChain: boolean, fromAddress?: string): string | null { - if (!address.trim()) return '请输入收款地址' - if (!isValidRecipientAddress(address, isBioforestChain)) return '无效的地址格式' - // BioChain 不允许自己给自己转账 - if (isBioforestChain && fromAddress && address.trim() === fromAddress.trim()) return '不能转账给自己' - return null + if (!address.trim()) return t('error:validation.enterRecipientAddress'); + if (!isValidRecipientAddress(address, isBioforestChain)) return t('error:validation.invalidAddressFormat'); + // BioChain 不允许自己给自己转账 // i18n-ignore + if (isBioforestChain && fromAddress && address.trim() === fromAddress.trim()) + return t('error:validation.cannotTransferToSelf'); + return null; } export function validateAmountInput(amount: Amount | null, asset: AssetInfo | null): string | null { - if (!amount) return '请输入金额' - if (!asset) return null + if (!amount) return t('error:validation.enterAmount'); + if (!asset) return null; - if (!amount.isPositive()) return '请输入有效金额' - if (amount.gt(asset.amount)) return '余额不足' + if (!amount.isPositive()) return t('error:validation.enterValidAmount'); + if (amount.gt(asset.amount)) return t('error:insufficientFunds'); - return null + return null; } export function canProceedToConfirm(options: { - toAddress: string - amount: Amount | null - asset: AssetInfo | null - isBioforestChain: boolean - feeLoading?: boolean + toAddress: string; + amount: Amount | null; + asset: AssetInfo | null; + isBioforestChain: boolean; + feeLoading?: boolean; }): boolean { - const { toAddress, amount, asset, isBioforestChain, feeLoading } = options - if (!asset || !amount || feeLoading) return false + const { toAddress, amount, asset, isBioforestChain, feeLoading } = options; + if (!asset || !amount || feeLoading) return false; return ( toAddress.trim() !== '' && isValidRecipientAddress(toAddress, isBioforestChain) && amount.isPositive() && amount.lte(asset.amount) - ) + ); } -export type FeeAdjustResult = - | { status: 'ok'; adjustedAmount?: Amount } - | { status: 'error'; message: string } +export type FeeAdjustResult = { status: 'ok'; adjustedAmount?: Amount } | { status: 'error'; message: string }; -export function adjustAmountForFee( - amount: Amount | null, - asset: AssetInfo, - fee: Amount -): FeeAdjustResult { - if (!amount) return { status: 'error', message: '请输入有效金额' } +export function adjustAmountForFee(amount: Amount | null, asset: AssetInfo, fee: Amount): FeeAdjustResult { + if (!amount) return { status: 'error', message: t('error:validation.enterValidAmount') }; - const balance = asset.amount + const balance = asset.amount; // Only deduct fee from balance if transferring the same asset as fee // e.g., BioChain: fee is BFM, but transferring CPCC - don't mix them - const isSameAsset = asset.assetType === fee.symbol + const isSameAsset = asset.assetType === fee.symbol; if (isSameAsset) { // Same asset: amount + fee must <= balance - if (amount.add(fee).lte(balance)) return { status: 'ok' } - if (!amount.eq(balance)) return { status: 'error', message: '余额不足' } + if (amount.add(fee).lte(balance)) return { status: 'ok' }; + if (!amount.eq(balance)) return { status: 'error', message: t('error:insufficientFunds') }; - const maxSendable = balance.sub(fee) - if (!maxSendable.isPositive()) return { status: 'error', message: '余额不足' } + const maxSendable = balance.sub(fee); + if (!maxSendable.isPositive()) return { status: 'error', message: t('error:insufficientFunds') }; return { status: 'ok', adjustedAmount: maxSendable, - } + }; } else { // Different asset: just check amount <= balance (fee is paid separately) - if (amount.lte(balance)) return { status: 'ok' } - return { status: 'error', message: '余额不足' } + if (amount.lte(balance)) return { status: 'ok' }; + return { status: 'error', message: t('error:insufficientFunds') }; } } diff --git a/src/i18n/locales/ar/error.json b/src/i18n/locales/ar/error.json index 5345c14ee..cd0422c19 100644 --- a/src/i18n/locales/ar/error.json +++ b/src/i18n/locales/ar/error.json @@ -5,10 +5,55 @@ "failed": "Failed", "incorrectOrderPleaseTryAgain": "Incorrect order, please try again!", "informationVerificationFailed": "Information verification failed", - "insufficientFunds": "insufficient funds", + "insufficientFunds": "Insufficient funds", "privatekeyInputError": "PrivateKey input error", "qrCodeNotFound": "QR code not found", "saveFailed": "Save Failed", "scanErrorFromImage": "Scan error from image", - "shareFailed": "Sharing Failed" + "shareFailed": "Sharing Failed", + "unknown": "Unknown error", + "validation": { + "enterRecipientAddress": "Please enter recipient address", + "invalidAddressFormat": "Invalid address format", + "cannotTransferToSelf": "Cannot transfer to yourself", + "enterAmount": "Please enter amount", + "enterValidAmount": "Please enter a valid amount", + "exceedsBalance": "Amount exceeds balance" + }, + "transaction": { + "failed": "Transaction failed, please try again later", + "burnFailed": "Burn failed, please try again later", + "chainNotSupported": "This chain does not support full transaction flow", + "chainNotSupportedWithId": "This chain does not support full transaction flow: {{chainId}}", + "securityPasswordFailed": "Security password verification failed", + "feeEstimateFailed": "Failed to estimate fee", + "walletInfoIncomplete": "Wallet information incomplete", + "chainConfigMissing": "Chain configuration missing", + "unsupportedChainType": "Unsupported chain type: {{chain}}", + "issuerAddressNotFound": "Unable to get asset issuer address", + "issuerAddressNotReady": "Asset issuer address not ready" + }, + "crypto": { + "keyDerivationFailed": "Key derivation failed", + "decryptionFailed": "Decryption failed: wrong password or corrupted data", + "decryptionKeyFailed": "Decryption failed: wrong key or corrupted data", + "biometricNotAvailable": "Biometric not available", + "biometricVerificationFailed": "Biometric verification failed", + "webModeRequiresPassword": "Web mode requires password", + "dwebEnvironmentRequired": "DWEB environment required", + "mpayDecryptionFailed": "mpay data decryption failed: wrong password or corrupted data" + }, + "address": { + "generationFailed": "Address generation failed" + }, + "securityPassword": { + "chainNotSupported": "This chain does not support security password", + "queryFailed": "Query failed" + }, + "history": { + "loadFailed": "Failed to load transaction history" + }, + "duplicate": { + "detectionFailed": "Duplicate detection failed" + } } diff --git a/src/i18n/locales/en/error.json b/src/i18n/locales/en/error.json index 5345c14ee..cd0422c19 100644 --- a/src/i18n/locales/en/error.json +++ b/src/i18n/locales/en/error.json @@ -5,10 +5,55 @@ "failed": "Failed", "incorrectOrderPleaseTryAgain": "Incorrect order, please try again!", "informationVerificationFailed": "Information verification failed", - "insufficientFunds": "insufficient funds", + "insufficientFunds": "Insufficient funds", "privatekeyInputError": "PrivateKey input error", "qrCodeNotFound": "QR code not found", "saveFailed": "Save Failed", "scanErrorFromImage": "Scan error from image", - "shareFailed": "Sharing Failed" + "shareFailed": "Sharing Failed", + "unknown": "Unknown error", + "validation": { + "enterRecipientAddress": "Please enter recipient address", + "invalidAddressFormat": "Invalid address format", + "cannotTransferToSelf": "Cannot transfer to yourself", + "enterAmount": "Please enter amount", + "enterValidAmount": "Please enter a valid amount", + "exceedsBalance": "Amount exceeds balance" + }, + "transaction": { + "failed": "Transaction failed, please try again later", + "burnFailed": "Burn failed, please try again later", + "chainNotSupported": "This chain does not support full transaction flow", + "chainNotSupportedWithId": "This chain does not support full transaction flow: {{chainId}}", + "securityPasswordFailed": "Security password verification failed", + "feeEstimateFailed": "Failed to estimate fee", + "walletInfoIncomplete": "Wallet information incomplete", + "chainConfigMissing": "Chain configuration missing", + "unsupportedChainType": "Unsupported chain type: {{chain}}", + "issuerAddressNotFound": "Unable to get asset issuer address", + "issuerAddressNotReady": "Asset issuer address not ready" + }, + "crypto": { + "keyDerivationFailed": "Key derivation failed", + "decryptionFailed": "Decryption failed: wrong password or corrupted data", + "decryptionKeyFailed": "Decryption failed: wrong key or corrupted data", + "biometricNotAvailable": "Biometric not available", + "biometricVerificationFailed": "Biometric verification failed", + "webModeRequiresPassword": "Web mode requires password", + "dwebEnvironmentRequired": "DWEB environment required", + "mpayDecryptionFailed": "mpay data decryption failed: wrong password or corrupted data" + }, + "address": { + "generationFailed": "Address generation failed" + }, + "securityPassword": { + "chainNotSupported": "This chain does not support security password", + "queryFailed": "Query failed" + }, + "history": { + "loadFailed": "Failed to load transaction history" + }, + "duplicate": { + "detectionFailed": "Duplicate detection failed" + } } diff --git a/src/i18n/locales/zh-CN/error.json b/src/i18n/locales/zh-CN/error.json index 51008817f..c93f8dfcb 100644 --- a/src/i18n/locales/zh-CN/error.json +++ b/src/i18n/locales/zh-CN/error.json @@ -10,5 +10,50 @@ "qrCodeNotFound": "未找到二维码", "saveFailed": "保存失败", "scanErrorFromImage": "扫描图片失败", - "shareFailed": "分享失败" + "shareFailed": "分享失败", + "unknown": "未知错误", + "validation": { + "enterRecipientAddress": "请输入收款地址", + "invalidAddressFormat": "无效的地址格式", + "cannotTransferToSelf": "不能转账给自己", + "enterAmount": "请输入金额", + "enterValidAmount": "请输入有效金额", + "exceedsBalance": "销毁数量不能大于余额" + }, + "transaction": { + "failed": "交易失败,请稍后重试", + "burnFailed": "销毁失败,请稍后重试", + "chainNotSupported": "该链不支持完整交易流程", + "chainNotSupportedWithId": "该链不支持完整交易流程: {{chainId}}", + "securityPasswordFailed": "安全密码验证失败", + "feeEstimateFailed": "获取手续费失败", + "walletInfoIncomplete": "钱包信息不完整", + "chainConfigMissing": "链配置缺失", + "unsupportedChainType": "不支持的链类型: {{chain}}", + "issuerAddressNotFound": "无法获取资产发行地址", + "issuerAddressNotReady": "资产发行地址未获取" + }, + "crypto": { + "keyDerivationFailed": "密钥派生失败", + "decryptionFailed": "解密失败:密码错误或数据损坏", + "decryptionKeyFailed": "解密失败:密钥错误或数据损坏", + "biometricNotAvailable": "生物识别不可用", + "biometricVerificationFailed": "生物识别验证失败", + "webModeRequiresPassword": "Web 模式需要提供密码", + "dwebEnvironmentRequired": "需要在 DWEB 环境中访问", + "mpayDecryptionFailed": "mpay 数据解密失败:密码错误或数据损坏" + }, + "address": { + "generationFailed": "地址生成失败" + }, + "securityPassword": { + "chainNotSupported": "该链不支持安全密码", + "queryFailed": "查询失败" + }, + "history": { + "loadFailed": "加载交易历史失败" + }, + "duplicate": { + "detectionFailed": "重复检测失败" + } } diff --git a/src/i18n/locales/zh-TW/error.json b/src/i18n/locales/zh-TW/error.json index 091e2bfbf..3e34b611f 100644 --- a/src/i18n/locales/zh-TW/error.json +++ b/src/i18n/locales/zh-TW/error.json @@ -10,5 +10,50 @@ "qrCodeNotFound": "未找到二維碼", "saveFailed": "保存失敗", "scanErrorFromImage": "掃描圖片失敗", - "shareFailed": "分享失敗" + "shareFailed": "分享失敗", + "unknown": "未知錯誤", + "validation": { + "enterRecipientAddress": "請輸入收款地址", + "invalidAddressFormat": "無效的地址格式", + "cannotTransferToSelf": "不能轉帳給自己", + "enterAmount": "請輸入金額", + "enterValidAmount": "請輸入有效金額", + "exceedsBalance": "銷毀數量不能大於餘額" + }, + "transaction": { + "failed": "交易失敗,請稍後重試", + "burnFailed": "銷毀失敗,請稍後重試", + "chainNotSupported": "該鏈不支持完整交易流程", + "chainNotSupportedWithId": "該鏈不支持完整交易流程: {{chainId}}", + "securityPasswordFailed": "安全密碼驗證失敗", + "feeEstimateFailed": "獲取手續費失敗", + "walletInfoIncomplete": "錢包資訊不完整", + "chainConfigMissing": "鏈配置缺失", + "unsupportedChainType": "不支持的鏈類型: {{chain}}", + "issuerAddressNotFound": "無法獲取資產發行地址", + "issuerAddressNotReady": "資產發行地址未獲取" + }, + "crypto": { + "keyDerivationFailed": "密鑰派生失敗", + "decryptionFailed": "解密失敗:密碼錯誤或資料損壞", + "decryptionKeyFailed": "解密失敗:密鑰錯誤或資料損壞", + "biometricNotAvailable": "生物識別不可用", + "biometricVerificationFailed": "生物識別驗證失敗", + "webModeRequiresPassword": "Web 模式需要提供密碼", + "dwebEnvironmentRequired": "需要在 DWEB 環境中訪問", + "mpayDecryptionFailed": "mpay 資料解密失敗:密碼錯誤或資料損壞" + }, + "address": { + "generationFailed": "地址生成失敗" + }, + "securityPassword": { + "chainNotSupported": "該鏈不支持安全密碼", + "queryFailed": "查詢失敗" + }, + "history": { + "loadFailed": "載入交易歷史失敗" + }, + "duplicate": { + "detectionFailed": "重複檢測失敗" + } } From f05d6a4a5177e7b196f031ce745c64d5542448aa Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 19:25:30 +0800 Subject: [PATCH 3/5] feat(i18n): update bioforest hooks to use i18n and expand exclude patterns - Add *.stories.tsx and mock-devtools to i18n-check exclude patterns - Add services/*/types.ts (Zod schemas) to exclude patterns - Update use-send.bioforest.ts to use i18n for error messages - Update use-burn.bioforest.ts to use i18n for error messages --- scripts/i18n-check.ts | 5 + src/hooks/use-burn.bioforest.ts | 117 +++++++++--------- src/hooks/use-send.bioforest.ts | 208 ++++++++++++++++---------------- 3 files changed, 169 insertions(+), 161 deletions(-) diff --git a/scripts/i18n-check.ts b/scripts/i18n-check.ts index 0b14f5df5..cbaef95b3 100644 --- a/scripts/i18n-check.ts +++ b/scripts/i18n-check.ts @@ -42,9 +42,14 @@ const SOURCE_EXCLUDE_PATTERNS = [ '**/*.test.tsx', '**/*.spec.ts', // 规格测试 '**/*.spec.tsx', + '**/*.stories.ts', // Storybook 故事 + '**/*.stories.tsx', '**/test/**', // 测试工具目录 '**/__tests__/**', // Jest 测试目录 '**/__mocks__/**', // Mock 目录 + '**/services/*/types.ts', // Zod schema 描述 (开发者文档) + '**/services/*/types.*.ts', // Zod schema 描述变体 + '**/mock-devtools/**', // 开发工具 ]; // Reference locale (source of truth for keys) diff --git a/src/hooks/use-burn.bioforest.ts b/src/hooks/use-burn.bioforest.ts index 3795ba0f0..d8b53e83f 100644 --- a/src/hooks/use-burn.bioforest.ts +++ b/src/hooks/use-burn.bioforest.ts @@ -1,18 +1,21 @@ /** * BioForest chain-specific burn (destroy asset) logic - * - * 已完全迁移到 ChainProvider,不再直接依赖 bioforest-sdk + * + * 已完全迁移到 ChainProvider,不再直接依赖 bioforest-sdk // i18n-ignore */ -import type { ChainConfig } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage' -import { getChainProvider } from '@/services/chain-adapter/providers' -import { pendingTxService } from '@/services/transaction' +import type { ChainConfig } from '@/services/chain-config'; +import { Amount } from '@/types/amount'; +import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage'; +import { getChainProvider } from '@/services/chain-adapter/providers'; +import { pendingTxService } from '@/services/transaction'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); export interface BioforestBurnFeeResult { - amount: Amount - symbol: string + amount: Amount; + symbol: string; } /** @@ -23,14 +26,14 @@ export async function fetchAssetApplyAddress( assetType: string, fromAddress: string, ): Promise { - const provider = getChainProvider(chainConfig.id) + const provider = getChainProvider(chainConfig.id); if (!provider.bioGetAssetDetail) { - return null + return null; } - const detail = await provider.bioGetAssetDetail(assetType, fromAddress) - return detail?.applyAddress ?? null + const detail = await provider.bioGetAssetDetail(assetType, fromAddress); + return detail?.applyAddress ?? null; } /** @@ -41,14 +44,14 @@ export async function fetchBioforestBurnFee( assetType: string, amount: string, ): Promise { - const provider = getChainProvider(chainConfig.id) + const provider = getChainProvider(chainConfig.id); if (!provider.buildTransaction || !provider.estimateFee) { // Fallback fee return { amount: Amount.fromRaw('1000', chainConfig.decimals, chainConfig.symbol), symbol: chainConfig.symbol, - } + }; } try { @@ -59,18 +62,18 @@ export async function fetchBioforestBurnFee( recipientId: '0x0000000000000000000000000000000000000000', bioAssetType: assetType, amount: Amount.fromRaw(amount, chainConfig.decimals, chainConfig.symbol), - }) - const feeEstimate = await provider.estimateFee(unsignedTx) + }); + const feeEstimate = await provider.estimateFee(unsignedTx); return { amount: feeEstimate.standard.amount, symbol: chainConfig.symbol, - } + }; } catch { return { amount: Amount.fromRaw('1000', chainConfig.decimals, chainConfig.symbol), symbol: chainConfig.symbol, - } + }; } } @@ -78,18 +81,18 @@ export type SubmitBioforestBurnResult = | { status: 'ok'; txHash: string; pendingTxId: string } | { status: 'password' } | { status: 'password_required'; secondPublicKey: string } - | { status: 'error'; message: string; pendingTxId?: string } + | { status: 'error'; message: string; pendingTxId?: string }; export interface SubmitBioforestBurnParams { - chainConfig: ChainConfig - walletId: string - password: string - fromAddress: string - recipientAddress: string - assetType: string - amount: Amount - fee?: Amount - twoStepSecret?: string + chainConfig: ChainConfig; + walletId: string; + password: string; + fromAddress: string; + recipientAddress: string; + assetType: string; + amount: Amount; + fee?: Amount; + twoStepSecret?: string; } /** @@ -107,42 +110,42 @@ export async function submitBioforestBurn({ twoStepSecret, }: SubmitBioforestBurnParams): Promise { // Get mnemonic from wallet storage - let secret: string + let secret: string; try { - secret = await walletStorageService.getMnemonic(walletId, password) + secret = await walletStorageService.getMnemonic(walletId, password); } catch (error) { if (error instanceof WalletStorageError && error.code === WalletStorageErrorCode.DECRYPTION_FAILED) { - return { status: 'password' } + return { status: 'password' }; } return { status: 'error', - message: error instanceof Error ? error.message : '未知错误', - } + message: error instanceof Error ? error.message : t('error:unknown'), + }; } if (!amount.isPositive()) { - return { status: 'error', message: '请输入有效金额' } + return { status: 'error', message: t('error:validation.enterValidAmount') }; } - const provider = getChainProvider(chainConfig.id) + const provider = getChainProvider(chainConfig.id); - // 检查 provider 是否支持完整交易流程 + // 检查 provider 是否支持完整交易流程 // i18n-ignore if (!provider.supportsFullTransaction) { - return { status: 'error', message: '该链不支持完整交易流程' } + return { status: 'error', message: t('error:transaction.chainNotSupported') }; } try { // Check if pay password is required but not provided - let secondPublicKey: string | null = null + let secondPublicKey: string | null = null; if (provider.bioGetAccountInfo) { - const accountInfo = await provider.bioGetAccountInfo(fromAddress) - secondPublicKey = accountInfo.secondPublicKey + const accountInfo = await provider.bioGetAccountInfo(fromAddress); + secondPublicKey = accountInfo.secondPublicKey; if (secondPublicKey && !twoStepSecret) { return { status: 'password_required', secondPublicKey, - } + }; } // Verify pay password if provided @@ -151,10 +154,10 @@ export async function submitBioforestBurn({ mainSecret: secret, paySecret: twoStepSecret, publicKey: secondPublicKey, - }) + }); if (!isValid) { - return { status: 'error', message: '安全密码验证失败' } + return { status: 'error', message: t('error:transaction.securityPasswordFailed') }; } } } @@ -166,14 +169,14 @@ export async function submitBioforestBurn({ recipientId: recipientAddress, amount, bioAssetType: assetType, - }) + }); // Sign transaction const signedTx = await provider.signTransaction!(unsignedTx, { privateKey: new TextEncoder().encode(secret), bioSecret: secret, bioPaySecret: twoStepSecret, - }) + }); // 存储到 pendingTxService(使用 ChainProvider 标准格式) const pendingTx = await pendingTxService.create({ @@ -187,36 +190,36 @@ export async function submitBioforestBurn({ displaySymbol: assetType, displayToAddress: recipientAddress, }, - }) + }); // Broadcast transaction - await pendingTxService.updateStatus({ id: pendingTx.id, status: 'broadcasting' }) + await pendingTxService.updateStatus({ id: pendingTx.id, status: 'broadcasting' }); try { - const broadcastTxHash = await provider.broadcastTransaction!(signedTx) + const broadcastTxHash = await provider.broadcastTransaction!(signedTx); await pendingTxService.updateStatus({ id: pendingTx.id, status: 'broadcasted', txHash: broadcastTxHash, - }) - return { status: 'ok', txHash: broadcastTxHash, pendingTxId: pendingTx.id } + }); + return { status: 'ok', txHash: broadcastTxHash, pendingTxId: pendingTx.id }; } catch (err) { - const error = err as Error + const error = err as Error; await pendingTxService.updateStatus({ id: pendingTx.id, status: 'failed', errorCode: 'BROADCAST_FAILED', errorMessage: error.message, - }) - return { status: 'error', message: error.message, pendingTxId: pendingTx.id } + }); + return { status: 'error', message: error.message, pendingTxId: pendingTx.id }; } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = error instanceof Error ? error.message : String(error); return { status: 'error', - message: errorMessage || '销毁失败,请稍后重试', - } + message: errorMessage || t('error:transaction.burnFailed'), + }; } } diff --git a/src/hooks/use-send.bioforest.ts b/src/hooks/use-send.bioforest.ts index 7a84ff532..c7c314cd6 100644 --- a/src/hooks/use-send.bioforest.ts +++ b/src/hooks/use-send.bioforest.ts @@ -1,23 +1,26 @@ -import type { AssetInfo } from '@/types/asset' -import type { ChainConfig } from '@/services/chain-config' -import { Amount } from '@/types/amount' -import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage' -import { getChainProvider } from '@/services/chain-adapter/providers' -import { pendingTxService } from '@/services/transaction' +import type { AssetInfo } from '@/types/asset'; +import type { ChainConfig } from '@/services/chain-config'; +import { Amount } from '@/types/amount'; +import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage'; +import { getChainProvider } from '@/services/chain-adapter/providers'; +import { pendingTxService } from '@/services/transaction'; +import i18n from '@/i18n'; + +const t = i18n.t.bind(i18n); export interface BioforestFeeResult { - amount: Amount - symbol: string + amount: Amount; + symbol: string; } export async function fetchBioforestFee(chainConfig: ChainConfig, fromAddress: string): Promise { - const provider = getChainProvider(chainConfig.id) + const provider = getChainProvider(chainConfig.id); if (!provider.estimateFee || !provider.buildTransaction) { // Fallback to zero fee if provider doesn't support estimateFee return { amount: Amount.fromRaw('0', chainConfig.decimals, chainConfig.symbol), symbol: chainConfig.symbol, - } + }; } // 新流程:先构建交易,再估算手续费 @@ -27,44 +30,44 @@ export async function fetchBioforestFee(chainConfig: ChainConfig, fromAddress: s to: fromAddress, // SDK requires amount > 0 for fee calculation, use 1 unit as placeholder amount: Amount.fromRaw('1', chainConfig.decimals, chainConfig.symbol), - }) + }); - const feeEstimate = await provider.estimateFee(unsignedTx) + const feeEstimate = await provider.estimateFee(unsignedTx); return { amount: feeEstimate.standard.amount, symbol: chainConfig.symbol, - } + }; } export async function fetchBioforestBalance(chainConfig: ChainConfig, fromAddress: string): Promise { - const provider = getChainProvider(chainConfig.id) - const balance = await provider.nativeBalance.fetch({ address: fromAddress }) + const provider = getChainProvider(chainConfig.id); + const balance = await provider.nativeBalance.fetch({ address: fromAddress }); return { assetType: balance.symbol, name: chainConfig.name, amount: balance.amount, decimals: balance.amount.decimals, - } + }; } export type SubmitBioforestResult = | { status: 'ok'; txHash: string; pendingTxId: string } | { status: 'password' } | { status: 'password_required'; secondPublicKey: string } - | { status: 'error'; message: string; pendingTxId?: string } + | { status: 'error'; message: string; pendingTxId?: string }; export interface SubmitBioforestParams { - chainConfig: ChainConfig - walletId: string - password: string - fromAddress: string - toAddress: string - amount: Amount - assetType: string - fee?: Amount - twoStepSecret?: string + chainConfig: ChainConfig; + walletId: string; + password: string; + fromAddress: string; + toAddress: string; + amount: Amount; + assetType: string; + fee?: Amount; + twoStepSecret?: string; } /** @@ -74,21 +77,21 @@ export async function checkTwoStepSecretRequired( chainConfig: ChainConfig, address: string, ): Promise<{ required: boolean; secondPublicKey?: string }> { - const provider = getChainProvider(chainConfig.id) + const provider = getChainProvider(chainConfig.id); if (!provider.bioGetAccountInfo) { - return { required: false } + return { required: false }; } try { - const info = await provider.bioGetAccountInfo(address) + const info = await provider.bioGetAccountInfo(address); if (info.secondPublicKey) { - return { required: true, secondPublicKey: info.secondPublicKey } + return { required: true, secondPublicKey: info.secondPublicKey }; } } catch { // If we can't check, assume not required } - return { required: false } + return { required: false }; } /** @@ -101,20 +104,20 @@ export async function verifyBioforestTwoStepSecret( twoStepSecret: string, secondPublicKey: string, ): Promise { - const provider = getChainProvider(chainConfig.id) + const provider = getChainProvider(chainConfig.id); if (!provider.bioVerifyPayPassword) { - return false + return false; } try { - const mainSecret = await walletStorageService.getMnemonic(walletId, password) + const mainSecret = await walletStorageService.getMnemonic(walletId, password); return await provider.bioVerifyPayPassword({ mainSecret, paySecret: twoStepSecret, publicKey: secondPublicKey, - }) + }); } catch { - return false + return false; } } @@ -130,42 +133,42 @@ export async function submitBioforestTransfer({ twoStepSecret, }: SubmitBioforestParams): Promise { // Get mnemonic from wallet storage - let secret: string + let secret: string; try { - secret = await walletStorageService.getMnemonic(walletId, password) + secret = await walletStorageService.getMnemonic(walletId, password); } catch (error) { if (error instanceof WalletStorageError && error.code === WalletStorageErrorCode.DECRYPTION_FAILED) { - return { status: 'password' } + return { status: 'password' }; } return { status: 'error', - message: error instanceof Error ? error.message : '未知错误', - } + message: error instanceof Error ? error.message : t('error:unknown'), + }; } if (!amount.isPositive()) { - return { status: 'error', message: '请输入有效金额' } + return { status: 'error', message: t('error:validation.enterValidAmount') }; } - const provider = getChainProvider(chainConfig.id) + const provider = getChainProvider(chainConfig.id); - // 检查 provider 是否支持完整交易流程 + // 检查 provider 是否支持完整交易流程 // i18n-ignore if (!provider.supportsFullTransaction) { - return { status: 'error', message: '该链不支持完整交易流程' } + return { status: 'error', message: t('error:transaction.chainNotSupported') }; } try { // Check if pay password is required but not provided - let secondPublicKey: string | null = null + let secondPublicKey: string | null = null; if (provider.bioGetAccountInfo) { - const accountInfo = await provider.bioGetAccountInfo(fromAddress) - secondPublicKey = accountInfo.secondPublicKey + const accountInfo = await provider.bioGetAccountInfo(fromAddress); + secondPublicKey = accountInfo.secondPublicKey; if (secondPublicKey && !twoStepSecret) { return { status: 'password_required', secondPublicKey, - } + }; } // Verify pay password if provided @@ -174,10 +177,10 @@ export async function submitBioforestTransfer({ mainSecret: secret, paySecret: twoStepSecret, publicKey: secondPublicKey, - }) + }); if (!isValid) { - return { status: 'error', message: '安全密码验证失败' } + return { status: 'error', message: t('error:transaction.securityPasswordFailed') }; } } } @@ -191,14 +194,14 @@ export async function submitBioforestTransfer({ fee, // Pass fee to avoid re-estimation in signTransaction // BioChain 特有字段 bioAssetType: assetType, - }) + }); // Sign transaction const signedTx = await provider.signTransaction!(unsignedTx, { privateKey: new TextEncoder().encode(secret), // 助记词作为私钥 bioSecret: secret, bioPaySecret: twoStepSecret, - }) + }); // 存储到 pendingTxService(使用 ChainProvider 标准格式) const pendingTx = await pendingTxService.create({ @@ -212,37 +215,37 @@ export async function submitBioforestTransfer({ displaySymbol: assetType, displayToAddress: toAddress, }, - }) + }); // 广播交易 - await pendingTxService.updateStatus({ id: pendingTx.id, status: 'broadcasting' }) + await pendingTxService.updateStatus({ id: pendingTx.id, status: 'broadcasting' }); try { - const broadcastTxHash = await provider.broadcastTransaction!(signedTx) + const broadcastTxHash = await provider.broadcastTransaction!(signedTx); await pendingTxService.updateStatus({ id: pendingTx.id, status: 'broadcasted', txHash: broadcastTxHash, - }) - return { status: 'ok', txHash: broadcastTxHash, pendingTxId: pendingTx.id } + }); + return { status: 'ok', txHash: broadcastTxHash, pendingTxId: pendingTx.id }; } catch (err) { - const error = err as Error + const error = err as Error; await pendingTxService.updateStatus({ id: pendingTx.id, status: 'failed', errorCode: 'BROADCAST_FAILED', errorMessage: error.message, - }) - return { status: 'error', message: error.message, pendingTxId: pendingTx.id } + }); + return { status: 'error', message: error.message, pendingTxId: pendingTx.id }; } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = error instanceof Error ? error.message : String(error); return { status: 'error', - message: errorMessage || '交易失败,请稍后重试', - } + message: errorMessage || t('error:transaction.failed'), + }; } } @@ -250,14 +253,14 @@ export type SetTwoStepSecretResult = | { status: 'ok'; txHash: string } | { status: 'password' } | { status: 'already_set' } - | { status: 'error'; message: string } + | { status: 'error'; message: string }; export interface SetTwoStepSecretParams { - chainConfig: ChainConfig - walletId: string - password: string - fromAddress: string - newTwoStepSecret: string + chainConfig: ChainConfig; + walletId: string; + password: string; + fromAddress: string; + newTwoStepSecret: string; } /** @@ -271,32 +274,32 @@ export async function submitSetTwoStepSecret({ newTwoStepSecret, }: SetTwoStepSecretParams): Promise { // Get mnemonic from wallet storage - let secret: string + let secret: string; try { - secret = await walletStorageService.getMnemonic(walletId, password) + secret = await walletStorageService.getMnemonic(walletId, password); } catch (error) { if (error instanceof WalletStorageError && error.code === WalletStorageErrorCode.DECRYPTION_FAILED) { - return { status: 'password' } + return { status: 'password' }; } return { status: 'error', - message: error instanceof Error ? error.message : '未知错误', - } + message: error instanceof Error ? error.message : t('error:unknown'), + }; } - const provider = getChainProvider(chainConfig.id) + const provider = getChainProvider(chainConfig.id); - // 检查 provider 是否支持完整交易流程 + // 检查 provider 是否支持完整交易流程 // i18n-ignore if (!provider.supportsFullTransaction) { - return { status: 'error', message: '该链不支持完整交易流程' } + return { status: 'error', message: t('error:transaction.chainNotSupported') }; } try { // Check if already has pay password if (provider.bioGetAccountInfo) { - const accountInfo = await provider.bioGetAccountInfo(fromAddress) + const accountInfo = await provider.bioGetAccountInfo(fromAddress); if (accountInfo.secondPublicKey) { - return { status: 'already_set' } + return { status: 'already_set' }; } } @@ -304,29 +307,30 @@ export async function submitSetTwoStepSecret({ const unsignedTx = await provider.buildTransaction!({ type: 'setPayPassword', from: fromAddress, - }) + }); const signedTx = await provider.signTransaction!(unsignedTx, { privateKey: new TextEncoder().encode(secret), bioSecret: secret, bioNewPaySecret: newTwoStepSecret, - }) + }); // Broadcast transaction - const txHash = await provider.broadcastTransaction!(signedTx) + const txHash = await provider.broadcastTransaction!(signedTx); - return { status: 'ok', txHash } + return { status: 'ok', txHash }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('fee') || errorMessage.includes('手续费')) { - return { status: 'error', message: '余额不足以支付手续费' } + // i18n-ignore + return { status: 'error', message: t('error:insufficientFunds') }; } return { status: 'error', - message: errorMessage || '设置安全密码失败,请稍后重试', - } + message: errorMessage || t('error:transaction.failed'), + }; } } @@ -336,10 +340,10 @@ export async function submitSetTwoStepSecret({ export async function getSetTwoStepSecretFee( chainConfig: ChainConfig, ): Promise<{ amount: Amount; symbol: string } | null> { - const provider = getChainProvider(chainConfig.id) + const provider = getChainProvider(chainConfig.id); if (!provider.buildTransaction || !provider.estimateFee) { - return null + return null; } try { @@ -347,36 +351,32 @@ export async function getSetTwoStepSecretFee( const unsignedTx = await provider.buildTransaction({ type: 'setPayPassword', from: '0x0000000000000000000000000000000000000000', - }) - const feeEstimate = await provider.estimateFee(unsignedTx) + }); + const feeEstimate = await provider.estimateFee(unsignedTx); return { amount: feeEstimate.standard.amount, symbol: chainConfig.symbol, - } + }; } catch { - return null + return null; } } /** * Check if address has pay password set */ -export async function hasTwoStepSecretSet( - chainConfig: ChainConfig, - address: string, -): Promise { - const provider = getChainProvider(chainConfig.id) +export async function hasTwoStepSecretSet(chainConfig: ChainConfig, address: string): Promise { + const provider = getChainProvider(chainConfig.id); if (!provider.bioGetAccountInfo) { - return false + return false; } try { - const info = await provider.bioGetAccountInfo(address) - return !!info.secondPublicKey + const info = await provider.bioGetAccountInfo(address); + return !!info.secondPublicKey; } catch { - return false + return false; } } - From 1780ca86fc476ddafed342153aa631834a9de35f Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 19:36:42 +0800 Subject: [PATCH 4/5] feat(i18n): add i18n-set and i18n-delete CLI tools - Add i18n-set.ts for batch setting translation keys across all locales - Add i18n-delete.ts for removing translation keys from all locales - Update i18n-check.ts to show quick tool usage hints on errors - Support locale shortcuts (zh -> zh-CN, tw -> zh-TW) - Support nested key paths (e.g., validation.required) --- scripts/i18n-check.ts | 12 +- scripts/i18n-delete.ts | 206 +++++++++++++++++++++++++++++++ scripts/i18n-set.ts | 273 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 scripts/i18n-delete.ts create mode 100644 scripts/i18n-set.ts diff --git a/scripts/i18n-check.ts b/scripts/i18n-check.ts index cbaef95b3..572d1fa4b 100644 --- a/scripts/i18n-check.ts +++ b/scripts/i18n-check.ts @@ -652,10 +652,20 @@ ${colors.green}✓ No missing or untranslated keys${colors.reset} console.log(` ${colors.red}✗ Chinese literals found in source code${colors.reset} -To fix: +${colors.bold}To fix:${colors.reset} 1. Move strings to i18n locale files (src/i18n/locales/) 2. Use useTranslation() hook or t() function 3. For intentional exceptions, add ${colors.cyan}// i18n-ignore${colors.reset} comment + +${colors.bold}Quick tools:${colors.reset} + ${colors.cyan}# Add a new translation key to all locales:${colors.reset} + bun scripts/i18n-set.ts -n -k -v '{"zh":"中文","en":"English"}' + + ${colors.cyan}# Delete a key from all locales:${colors.reset} + bun scripts/i18n-delete.ts -n -k + + ${colors.cyan}# Example:${colors.reset} + bun scripts/i18n-set.ts -n error -k validation.required -v '{"zh":"必填项","en":"Required","tw":"必填項"}' `); process.exit(1); diff --git a/scripts/i18n-delete.ts b/scripts/i18n-delete.ts new file mode 100644 index 000000000..210fabd63 --- /dev/null +++ b/scripts/i18n-delete.ts @@ -0,0 +1,206 @@ +#!/usr/bin/env bun +/** + * i18n-delete.ts - Delete translation keys across all locales + * + * Usage: + * bun scripts/i18n-delete.ts -n -k + * + * Examples: + * # Delete a key from all locales + * bun scripts/i18n-delete.ts -n error -k validation.deprecated + * + * # Preview deletion without modifying files + * bun scripts/i18n-delete.ts -n error -k oldKey --dry-run + * + * Options: + * -n, --namespace Target namespace (e.g., "error", "common", "ecosystem") + * -k, --key Dot-separated key path (e.g., "validation.required") + * --dry-run Preview changes without writing files + * --help Show this help message + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { parseArgs } from 'node:util'; + +// ==================== Configuration ==================== + +const ROOT = resolve(import.meta.dirname, '..'); +const LOCALES_DIR = join(ROOT, 'src/i18n/locales'); +const LOCALES = ['zh-CN', 'zh-TW', 'en', 'ar'] as const; + +// ==================== Colors ==================== + +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + dim: '\x1b[2m', + bold: '\x1b[1m', +}; + +const log = { + info: (msg: string) => console.log(`${colors.blue}ℹ${colors.reset} ${msg}`), + success: (msg: string) => console.log(`${colors.green}✓${colors.reset} ${msg}`), + warn: (msg: string) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`), + error: (msg: string) => console.log(`${colors.red}✗${colors.reset} ${msg}`), + dim: (msg: string) => console.log(`${colors.dim} ${msg}${colors.reset}`), +}; + +// ==================== Utilities ==================== + +function showHelp(): void { + console.log(` +${colors.cyan}i18n-delete${colors.reset} - Delete translation keys across all locales + +${colors.bold}Usage:${colors.reset} + bun scripts/i18n-delete.ts -n -k + +${colors.bold}Examples:${colors.reset} + ${colors.dim}# Delete a key from all locales${colors.reset} + bun scripts/i18n-delete.ts -n error -k validation.deprecated + + ${colors.dim}# Preview deletion${colors.reset} + bun scripts/i18n-delete.ts -n error -k oldKey --dry-run + +${colors.bold}Options:${colors.reset} + -n, --namespace Target namespace (e.g., "error", "common") + -k, --key Dot-separated key path (e.g., "validation.required") + --dry-run Preview changes without writing files + --help Show this help message +`); +} + +function getNestedValue(obj: Record, keyPath: string): unknown { + const keys = keyPath.split('.'); + let current: unknown = obj; + + for (const key of keys) { + if (current === null || typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + + return current; +} + +function deleteNestedKey(obj: Record, keyPath: string): boolean { + const keys = keyPath.split('.'); + let current = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!(key in current) || typeof current[key] !== 'object') { + return false; + } + current = current[key] as Record; + } + + const lastKey = keys[keys.length - 1]; + if (lastKey in current) { + delete current[lastKey]; + return true; + } + return false; +} + +// ==================== Main ==================== + +function main(): void { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + namespace: { type: 'string', short: 'n' }, + key: { type: 'string', short: 'k' }, + 'dry-run': { type: 'boolean', default: false }, + help: { type: 'boolean', default: false }, + }, + allowPositionals: true, + }); + + if (values.help) { + showHelp(); + process.exit(0); + } + + const namespace = values.namespace; + const key = values.key; + const dryRun = values['dry-run']; + + if (!namespace || !key) { + log.error('Missing required arguments'); + console.log(`\nUsage: bun scripts/i18n-delete.ts -n -k `); + console.log(`Run with --help for more information.`); + process.exit(1); + } + + console.log(`\n${colors.cyan}Deleting translation key${colors.reset}`); + console.log(` Namespace: ${colors.bold}${namespace}${colors.reset}`); + console.log(` Key: ${colors.bold}${key}${colors.reset}`); + console.log(); + + // Process each locale file + const results: { locale: string; action: 'deleted' | 'not_found' | 'skipped'; oldValue?: string }[] = []; + + for (const locale of LOCALES) { + const filePath = join(LOCALES_DIR, locale, `${namespace}.json`); + + if (!existsSync(filePath)) { + log.warn(`File not found: ${locale}/${namespace}.json`); + results.push({ locale, action: 'skipped' }); + continue; + } + + // Read and parse file + const content = JSON.parse(readFileSync(filePath, 'utf-8')); + const oldValue = getNestedValue(content, key); + + if (oldValue === undefined) { + results.push({ locale, action: 'not_found' }); + continue; + } + + // Delete key + deleteNestedKey(content, key); + + if (!dryRun) { + writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n'); + } + + results.push({ + locale, + action: 'deleted', + oldValue: typeof oldValue === 'string' ? oldValue : JSON.stringify(oldValue), + }); + } + + // Print summary + if (dryRun) { + console.log(`${colors.yellow}[DRY RUN]${colors.reset} No files modified\n`); + } + + console.log(`${colors.bold}Results:${colors.reset}`); + for (const { locale, action, oldValue } of results) { + if (action === 'deleted') { + log.success(`${locale}: Deleted key "${key}" (was: "${oldValue}")`); + } else if (action === 'not_found') { + log.dim(`${locale}: Key not found`); + } else { + log.dim(`${locale}: Skipped (file not found)`); + } + } + + // Print AI-friendly summary + console.log(`\n${colors.dim}---${colors.reset}`); + console.log(`${colors.bold}Summary:${colors.reset} Delete ${namespace}:${key}`); + const deletedCount = results.filter((r) => r.action === 'deleted').length; + console.log(` ${deletedCount}/${LOCALES.length} locales updated`); + + if (!dryRun && deletedCount > 0) { + console.log(`\n${colors.green}Done!${colors.reset} Key "${namespace}:${key}" has been deleted.`); + } +} + +main(); diff --git a/scripts/i18n-set.ts b/scripts/i18n-set.ts new file mode 100644 index 000000000..5050e4421 --- /dev/null +++ b/scripts/i18n-set.ts @@ -0,0 +1,273 @@ +#!/usr/bin/env bun +/** + * i18n-set.ts - Batch set translation keys across all locales + * + * Usage: + * bun scripts/i18n-set.ts -n -k -v + * + * Examples: + * # Set a simple key with all translations + * bun scripts/i18n-set.ts -n error -k validation.required -v '{"zh-CN":"必填项","en":"Required","zh-TW":"必填項","ar":"Required"}' + * + * # Set a nested key (automatically creates parent objects) + * bun scripts/i18n-set.ts -n common -k form.validation.email -v '{"zh-CN":"请输入有效邮箱","en":"Please enter a valid email"}' + * + * # Use shorthand locale keys + * bun scripts/i18n-set.ts -n error -k unknown -v '{"zh":"未知错误","en":"Unknown error"}' + * + * Options: + * -n, --namespace Target namespace (e.g., "error", "common", "ecosystem") + * -k, --key Dot-separated key path (e.g., "validation.required") + * -v, --values JSON object with locale translations + * --dry-run Preview changes without writing files + * --help Show this help message + * + * Locale shortcuts: + * zh, zh-CN -> zh-CN + * tw, zh-TW -> zh-TW + * en -> en + * ar -> ar + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { parseArgs } from 'node:util'; + +// ==================== Configuration ==================== + +const ROOT = resolve(import.meta.dirname, '..'); +const LOCALES_DIR = join(ROOT, 'src/i18n/locales'); +const LOCALES = ['zh-CN', 'zh-TW', 'en', 'ar'] as const; +type Locale = (typeof LOCALES)[number]; + +// Locale shortcuts mapping +const LOCALE_SHORTCUTS: Record = { + zh: 'zh-CN', + 'zh-CN': 'zh-CN', + 'zh-cn': 'zh-CN', + tw: 'zh-TW', + 'zh-TW': 'zh-TW', + 'zh-tw': 'zh-TW', + en: 'en', + ar: 'ar', +}; + +// ==================== Colors ==================== + +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + dim: '\x1b[2m', + bold: '\x1b[1m', +}; + +const log = { + info: (msg: string) => console.log(`${colors.blue}ℹ${colors.reset} ${msg}`), + success: (msg: string) => console.log(`${colors.green}✓${colors.reset} ${msg}`), + warn: (msg: string) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`), + error: (msg: string) => console.log(`${colors.red}✗${colors.reset} ${msg}`), + dim: (msg: string) => console.log(`${colors.dim} ${msg}${colors.reset}`), +}; + +// ==================== Utilities ==================== + +function showHelp(): void { + console.log(` +${colors.cyan}i18n-set${colors.reset} - Batch set translation keys across all locales + +${colors.bold}Usage:${colors.reset} + bun scripts/i18n-set.ts -n -k -v + +${colors.bold}Examples:${colors.reset} + ${colors.dim}# Set a simple key${colors.reset} + bun scripts/i18n-set.ts -n error -k validation.required \\ + -v '{"zh-CN":"必填项","en":"Required","zh-TW":"必填項"}' + + ${colors.dim}# Use shorthand locale keys${colors.reset} + bun scripts/i18n-set.ts -n error -k unknown -v '{"zh":"未知错误","en":"Unknown error"}' + +${colors.bold}Options:${colors.reset} + -n, --namespace Target namespace (e.g., "error", "common") + -k, --key Dot-separated key path (e.g., "validation.required") + -v, --values JSON object with locale translations + --dry-run Preview changes without writing files + --help Show this help message + +${colors.bold}Locale shortcuts:${colors.reset} + zh, zh-CN -> zh-CN + tw, zh-TW -> zh-TW + en -> en + ar -> ar +`); +} + +function parseValues(valuesStr: string): Record { + try { + const raw = JSON.parse(valuesStr) as Record; + const result: Partial> = {}; + + for (const [key, value] of Object.entries(raw)) { + const normalizedLocale = LOCALE_SHORTCUTS[key]; + if (normalizedLocale) { + result[normalizedLocale] = value; + } else { + log.warn(`Unknown locale "${key}", skipping`); + } + } + + return result as Record; + } catch { + throw new Error(`Invalid JSON values: ${valuesStr}`); + } +} + +function setNestedValue(obj: Record, keyPath: string, value: string): void { + const keys = keyPath.split('.'); + let current = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!(key in current) || typeof current[key] !== 'object') { + current[key] = {}; + } + current = current[key] as Record; + } + + current[keys[keys.length - 1]] = value; +} + +function getNestedValue(obj: Record, keyPath: string): unknown { + const keys = keyPath.split('.'); + let current: unknown = obj; + + for (const key of keys) { + if (current === null || typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + + return current; +} + +// ==================== Main ==================== + +function main(): void { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + namespace: { type: 'string', short: 'n' }, + key: { type: 'string', short: 'k' }, + values: { type: 'string', short: 'v' }, + 'dry-run': { type: 'boolean', default: false }, + help: { type: 'boolean', default: false }, + }, + allowPositionals: true, + }); + + if (values.help) { + showHelp(); + process.exit(0); + } + + const namespace = values.namespace; + const key = values.key; + const valuesStr = values.values; + const dryRun = values['dry-run']; + + if (!namespace || !key || !valuesStr) { + log.error('Missing required arguments'); + console.log(`\nUsage: bun scripts/i18n-set.ts -n -k -v `); + console.log(`Run with --help for more information.`); + process.exit(1); + } + + // Parse values + const translations = parseValues(valuesStr); + const providedLocales = Object.keys(translations) as Locale[]; + + if (providedLocales.length === 0) { + log.error('No valid translations provided'); + process.exit(1); + } + + console.log(`\n${colors.cyan}Setting translation key${colors.reset}`); + console.log(` Namespace: ${colors.bold}${namespace}${colors.reset}`); + console.log(` Key: ${colors.bold}${key}${colors.reset}`); + console.log(` Translations:`); + for (const locale of LOCALES) { + const value = translations[locale]; + if (value !== undefined) { + console.log(` ${locale}: "${value}"`); + } else { + console.log(` ${colors.dim}${locale}: (not provided)${colors.reset}`); + } + } + console.log(); + + // Process each locale file + const results: { locale: string; action: 'created' | 'updated' | 'skipped'; oldValue?: string }[] = []; + + for (const locale of LOCALES) { + const filePath = join(LOCALES_DIR, locale, `${namespace}.json`); + const value = translations[locale]; + + if (!existsSync(filePath)) { + log.warn(`File not found: ${locale}/${namespace}.json`); + results.push({ locale, action: 'skipped' }); + continue; + } + + if (value === undefined) { + results.push({ locale, action: 'skipped' }); + continue; + } + + // Read and parse file + const content = JSON.parse(readFileSync(filePath, 'utf-8')); + const oldValue = getNestedValue(content, key); + + // Set new value + setNestedValue(content, key, value); + + if (!dryRun) { + writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n'); + } + + results.push({ + locale, + action: oldValue !== undefined ? 'updated' : 'created', + oldValue: oldValue !== undefined ? String(oldValue) : undefined, + }); + } + + // Print summary + if (dryRun) { + console.log(`${colors.yellow}[DRY RUN]${colors.reset} No files modified\n`); + } + + console.log(`${colors.bold}Results:${colors.reset}`); + for (const { locale, action, oldValue } of results) { + if (action === 'created') { + log.success(`${locale}: Created key "${key}"`); + } else if (action === 'updated') { + log.success(`${locale}: Updated key "${key}" (was: "${oldValue}")`); + } else { + log.dim(`${locale}: Skipped (no value provided or file not found)`); + } + } + + // Print AI-friendly summary + console.log(`\n${colors.dim}---${colors.reset}`); + console.log(`${colors.bold}Summary:${colors.reset} Set ${namespace}:${key}`); + const successCount = results.filter((r) => r.action !== 'skipped').length; + console.log(` ${successCount}/${LOCALES.length} locales updated`); + + if (!dryRun) { + console.log(`\n${colors.green}Done!${colors.reset} Key "${namespace}:${key}" has been set.`); + } +} + +main(); From 1ecdcd1cd0769f1a92e8e17fdcc98a2257572374 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 20:25:32 +0800 Subject: [PATCH 5/5] fix(build): fail CD build when remote miniapps download fails - Add timeout (60s) and retry (3x) mechanism to fetchWithEtag - Throw error in buildStart when remote miniapp download fails (build mode) - Throw error in writeBundle when miniapps are missing from output - Dev mode only warns, does not interrupt development This ensures CI builds fail fast when runner network cannot reach remote miniapp servers, making the issue immediately visible. --- scripts/utils/fetch-with-etag.ts | 121 ++++++++++++++++--------- scripts/vite-plugin-remote-miniapps.ts | 75 ++++++++------- vite.config.ts | 3 +- 3 files changed, 123 insertions(+), 76 deletions(-) diff --git a/scripts/utils/fetch-with-etag.ts b/scripts/utils/fetch-with-etag.ts index fe1cf3ac9..43cc50b4f 100644 --- a/scripts/utils/fetch-with-etag.ts +++ b/scripts/utils/fetch-with-etag.ts @@ -8,6 +8,7 @@ * - ETag 匹配时直接返回缓存 * - 下载完成后原子重命名 * - 支持断点续传场景的安全写入 + * - 支持超时和重试机制 */ import { createHash } from 'node:crypto' @@ -17,13 +18,32 @@ import { tmpdir } from 'node:os' const CACHE_DIR = join(tmpdir(), 'fetch') -/** - * 计算 SHA256 哈希 - */ +const DEFAULT_TIMEOUT = 30000 +const DEFAULT_RETRIES = 3 +const RETRY_DELAY = 2000 + +export interface FetchWithEtagOptions { + timeout?: number + retries?: number +} + function sha256(data: string): string { return createHash('sha256').update(data).digest('hex') } +function fetchWithTimeout(url: string, options: RequestInit & { timeout?: number } = {}): Promise { + const { timeout = DEFAULT_TIMEOUT, ...fetchOptions } = options + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + return fetch(url, { ...fetchOptions, signal: controller.signal }) + .finally(() => clearTimeout(timeoutId)) +} + +async function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + /** * 确保缓存目录存在 */ @@ -62,59 +82,72 @@ function getTempPath(etag: string): string { * - 存在 → 直接返回缓存内容 * - 不存在 → 下载到 .tmp,完成后重命名为 .bin */ -export async function fetchWithEtag(url: string): Promise { +export async function fetchWithEtag(url: string, options: FetchWithEtagOptions = {}): Promise { + const { timeout = DEFAULT_TIMEOUT, retries = DEFAULT_RETRIES } = options ensureCacheDir() - // 1. HEAD 请求获取 ETag - const headResponse = await fetch(url, { method: 'HEAD' }) - if (!headResponse.ok) { - throw new Error(`HEAD request failed: ${headResponse.status} ${headResponse.statusText}`) - } + let lastError: Error | null = null - const etag = headResponse.headers.get('etag') || headResponse.headers.get('last-modified') || url - const cachePath = getCachePath(etag) - const tempPath = getTempPath(etag) + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const headResponse = await fetchWithTimeout(url, { method: 'HEAD', timeout }) + if (!headResponse.ok) { + throw new Error(`HEAD request failed: ${headResponse.status} ${headResponse.statusText}`) + } - // 2. 检查缓存 - if (existsSync(cachePath)) { - console.log(`[fetchWithEtag] Cache hit: ${url}`) - return readFileSync(cachePath) - } + const etag = headResponse.headers.get('etag') || headResponse.headers.get('last-modified') || url + const cachePath = getCachePath(etag) + const tempPath = getTempPath(etag) - // 3. 下载文件 - console.log(`[fetchWithEtag] Downloading: ${url}`) - const response = await fetch(url) - if (!response.ok) { - throw new Error(`Download failed: ${response.status} ${response.statusText}`) - } + if (existsSync(cachePath)) { + console.log(`[fetchWithEtag] Cache hit: ${url}`) + return readFileSync(cachePath) + } - const arrayBuffer = await response.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) + console.log(`[fetchWithEtag] Downloading (attempt ${attempt}/${retries}): ${url}`) + const response = await fetchWithTimeout(url, { timeout }) + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`) + } + + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) - // 4. 写入临时文件 - try { - // 清理可能存在的旧临时文件 - if (existsSync(tempPath)) { - unlinkSync(tempPath) - } - writeFileSync(tempPath, buffer) - - // 5. 原子重命名 - renameSync(tempPath, cachePath) - console.log(`[fetchWithEtag] Cached: ${cachePath}`) - } catch (error) { - // 清理临时文件 - if (existsSync(tempPath)) { try { - unlinkSync(tempPath) - } catch { - // ignore cleanup errors + if (existsSync(tempPath)) { + unlinkSync(tempPath) + } + writeFileSync(tempPath, buffer) + renameSync(tempPath, cachePath) + console.log(`[fetchWithEtag] Cached: ${cachePath}`) + } catch (error) { + if (existsSync(tempPath)) { + try { + unlinkSync(tempPath) + } catch { + // ignore cleanup errors + } + } + throw error + } + + return buffer + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + const isAbortError = lastError.name === 'AbortError' || lastError.message.includes('aborted') + const errorType = isAbortError ? 'timeout' : 'network error' + + console.error(`[fetchWithEtag] Attempt ${attempt}/${retries} failed (${errorType}): ${url}`) + console.error(`[fetchWithEtag] Error: ${lastError.message}`) + + if (attempt < retries) { + console.log(`[fetchWithEtag] Retrying in ${RETRY_DELAY}ms...`) + await sleep(RETRY_DELAY) } } - throw error } - return buffer + throw new Error(`[fetchWithEtag] All ${retries} attempts failed for ${url}: ${lastError?.message}`) } /** diff --git a/scripts/vite-plugin-remote-miniapps.ts b/scripts/vite-plugin-remote-miniapps.ts index 6de36e730..b3891fdf7 100644 --- a/scripts/vite-plugin-remote-miniapps.ts +++ b/scripts/vite-plugin-remote-miniapps.ts @@ -13,14 +13,12 @@ import { resolve, join } from 'node:path' import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, rmSync } from 'node:fs' import { createServer } from 'node:http' import type JSZipType from 'jszip' -import { fetchWithEtag } from './utils/fetch-with-etag' +import { fetchWithEtag, type FetchWithEtagOptions } from './utils/fetch-with-etag' // ==================== Types ==================== interface RemoteMiniappConfig { - /** 远程 metadata.json URL (包含 version, zipUrl, manifestUrl) */ metadataUrl: string - /** 解压到 miniapps/ 下的目录名 */ dirName: string } @@ -34,10 +32,10 @@ interface RemoteMetadata { } interface RemoteMiniappsPluginOptions { - /** 远程 miniapp 配置列表 */ miniapps: RemoteMiniappConfig[] - /** miniapps 目录 (相对于项目根目录) */ miniappsDir?: string + timeout?: number + retries?: number } interface MiniappManifest { @@ -75,11 +73,13 @@ interface RemoteMiniappServer { // ==================== Plugin ==================== export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plugin { - const { miniapps, miniappsDir = 'miniapps' } = options + const { miniapps, miniappsDir = 'miniapps', timeout = 60000, retries = 3 } = options + const fetchOptions: FetchWithEtagOptions = { timeout, retries } let root: string let isBuild = false const servers: RemoteMiniappServer[] = [] + const downloadFailures: string[] = [] return { name: 'vite-plugin-remote-miniapps', @@ -94,9 +94,21 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug const miniappsPath = resolve(root, miniappsDir) - // 下载并解压所有远程 miniapps for (const config of miniapps) { - await downloadAndExtract(config, miniappsPath) + try { + await downloadAndExtract(config, miniappsPath, fetchOptions) + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err) + console.error(`[remote-miniapps] ❌ Failed to download ${config.dirName}: ${errorMsg}`) + downloadFailures.push(config.dirName) + } + } + + if (downloadFailures.length > 0 && isBuild) { + throw new Error( + `[remote-miniapps] Build aborted: failed to download remote miniapps: ${downloadFailures.join(', ')}. ` + + `Check network connectivity to remote servers.` + ) } }, @@ -105,8 +117,8 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug const miniappsPath = resolve(root, miniappsDir) const miniappsOutputDir = resolve(outputOptions.dir, 'miniapps') + const missing: string[] = [] - // 复制远程 miniapps 到 dist for (const config of miniapps) { const srcDir = join(miniappsPath, config.dirName) const destDir = join(miniappsOutputDir, config.dirName) @@ -114,9 +126,18 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug if (existsSync(srcDir)) { mkdirSync(destDir, { recursive: true }) cpSync(srcDir, destDir, { recursive: true }) - console.log(`[remote-miniapps] Copied ${config.dirName} to dist`) + console.log(`[remote-miniapps] ✅ Copied ${config.dirName} to dist`) + } else { + missing.push(config.dirName) } } + + if (missing.length > 0) { + throw new Error( + `[remote-miniapps] Build failed: missing miniapps in output: ${missing.join(', ')}. ` + + `Remote miniapps were not downloaded successfully.` + ) + } }, async configureServer(server) { @@ -124,9 +145,14 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug const miniappsPath = resolve(root, miniappsDir) - // 先下载所有远程 miniapps (dev 模式下 buildStart 之后才执行 configureServer) for (const config of miniapps) { - await downloadAndExtract(config, miniappsPath) + try { + await downloadAndExtract(config, miniappsPath, fetchOptions) + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err) + console.warn(`[remote-miniapps] ⚠️ Failed to download ${config.dirName} (dev mode): ${errorMsg}`) + continue + } } // 启动静态服务器为每个远程 miniapp @@ -181,29 +207,22 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug // ==================== Helpers ==================== -/** - * 下载并解压远程 miniapp - */ async function downloadAndExtract( config: RemoteMiniappConfig, - miniappsPath: string + miniappsPath: string, + fetchOptions: FetchWithEtagOptions = {} ): Promise { const targetDir = join(miniappsPath, config.dirName) console.log(`[remote-miniapps] Syncing ${config.dirName}...`) - // 1. 下载 metadata.json (获取最新版本信息) - // fetchWithEtag 会根据 ETag 决定是否使用缓存 - const metadataBuffer = await fetchWithEtag(config.metadataUrl) + const metadataBuffer = await fetchWithEtag(config.metadataUrl, fetchOptions) const metadata = JSON.parse(metadataBuffer.toString('utf-8')) as RemoteMetadata - // 检查本地是否已有相同版本且 zip 的 ETag 没变 - // 通过比较本地 manifest 中存储的 zipEtag 来判断 const localManifestPath = join(targetDir, 'manifest.json') if (existsSync(localManifestPath)) { const localManifest = JSON.parse(readFileSync(localManifestPath, 'utf-8')) as MiniappManifest & { _zipEtag?: string } if (localManifest.version === metadata.version && localManifest._zipEtag) { - // 检查远程 zip 的 ETag 是否变化 const baseUrl = config.metadataUrl.replace(/\/[^/]+$/, '') const zipUrl = metadata.zipUrl.startsWith('.') ? `${baseUrl}/${metadata.zipUrl.slice(2)}` @@ -217,12 +236,11 @@ async function downloadAndExtract( } console.log(`[remote-miniapps] ${config.dirName} zip changed (etag: ${localManifest._zipEtag} -> ${remoteEtag})`) } catch { - // HEAD 请求失败,继续下载 + // HEAD request failed, continue with download } } } - // 2. 解析相对 URL const baseUrl = config.metadataUrl.replace(/\/[^/]+$/, '') const manifestUrl = metadata.manifestUrl.startsWith('.') ? `${baseUrl}/${metadata.manifestUrl.slice(2)}` @@ -231,22 +249,18 @@ async function downloadAndExtract( ? `${baseUrl}/${metadata.zipUrl.slice(2)}` : metadata.zipUrl - // 3. 下载 manifest.json - const manifestBuffer = await fetchWithEtag(manifestUrl) + const manifestBuffer = await fetchWithEtag(manifestUrl, fetchOptions) const manifest = JSON.parse(manifestBuffer.toString('utf-8')) as MiniappManifest - // 4. 下载 zip 并获取 ETag const zipHeadResponse = await fetch(zipUrl, { method: 'HEAD' }) const zipEtag = zipHeadResponse.headers.get('etag') || '' - const zipBuffer = await fetchWithEtag(zipUrl) + const zipBuffer = await fetchWithEtag(zipUrl, fetchOptions) - // 5. 清理旧目录 if (existsSync(targetDir)) { rmSync(targetDir, { recursive: true }) } mkdirSync(targetDir, { recursive: true }) - // 6. 解压 zip const JSZip = (await import('jszip')).default const zip = await JSZip.loadAsync(zipBuffer) for (const [relativePath, file] of Object.entries(zip.files) as [ @@ -263,7 +277,6 @@ async function downloadAndExtract( } } - // 7. 写入 manifest.json (确保包含 dirName 和 _zipEtag 用于增量更新检测) const manifestWithDir = { ...manifest, dirName: config.dirName, _zipEtag: zipEtag } writeFileSync(localManifestPath, JSON.stringify(manifestWithDir, null, 2)) diff --git a/vite.config.ts b/vite.config.ts index c1144d5a9..774538b41 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -94,12 +94,13 @@ export default defineConfig(({ mode }) => { // 远程 miniapps (必须在 miniappsPlugin 之前,以便注册到全局状态) remoteMiniappsPlugin({ miniapps: [ - // RWA Hub - 从远程下载 { metadataUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/metadata.json', dirName: 'rwa-hub', }, ], + timeout: 60000, + retries: 3, }), miniappsPlugin(), buildCheckPlugin(),