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 d1f0a907df5b7de860de651c9c40c255fa4c80df Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 20 Jan 2026 21:23:01 +0800 Subject: [PATCH 5/5] fix(dweb): use metadata.json for dweb://install links - Copy metadata.json from dists/ to release/ in build.ts - Update cd.yml to copy metadata.json and fix Release Notes links - Fix docs/download.md dweb install links to point to metadata.json - Refactor set-secret.ts with Bun native ANSI TUI --- .github/workflows/cd.yml | 14 +- docs/download.md | 4 +- scripts/build.ts | 9 + scripts/set-secret.ts | 614 ++++++++++++++++----------------------- 4 files changed, 279 insertions(+), 362 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index e7a6c7dee..94579d30f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -167,6 +167,11 @@ jobs: fi cp release/bfmpay-dweb-beta.zip "release/bfmpay-dweb-${VERSION}-beta.zip" fi + + # Copy metadata.json for dweb://install link + if [ -f "dists/metadata.json" ]; then + cp dists/metadata.json release/metadata.json + fi # 上传 DWEB 到 SFTP 服务器 - name: Upload DWEB to SFTP @@ -234,7 +239,7 @@ jobs: ### DWEB 安装 在 DWEB 浏览器中打开以下链接安装: ``` - dweb://install?url=https://github.com/${{ github.repository }}/releases/download/${{ steps.channel.outputs.channel == 'stable' && format('v{0}', steps.version.outputs.version) || 'beta' }}/bfmpay-dweb${{ steps.channel.outputs.channel == 'beta' && '-beta' || '' }}.zip + dweb://install?url=https://github.com/${{ github.repository }}/releases/download/${{ steps.channel.outputs.channel == 'stable' && format('v{0}', steps.version.outputs.version) || 'beta' }}/metadata.json ``` # ==================== GitHub-hosted 标准构建 ==================== @@ -442,6 +447,11 @@ jobs: cp release/bfmpay-dweb-beta.zip "release/bfmpay-dweb-${VERSION}-beta.zip" fi + # Copy metadata.json for dweb://install link + if [ -f "dists/metadata.json" ]; then + cp dists/metadata.json release/metadata.json + fi + echo "=== Release artifacts ===" ls -la release/ @@ -530,5 +540,5 @@ jobs: ### DWEB 安装 在 DWEB 浏览器中打开以下链接安装: ``` - dweb://install?url=https://github.com/${{ github.repository }}/releases/download/${{ needs.build-standard.outputs.channel == 'stable' && format('v{0}', needs.build-standard.outputs.version) || 'beta' }}/bfmpay-dweb${{ needs.build-standard.outputs.channel == 'beta' && '-beta' || '' }}.zip + dweb://install?url=https://github.com/${{ github.repository }}/releases/download/${{ needs.build-standard.outputs.channel == 'stable' && format('v{0}', needs.build-standard.outputs.version) || 'beta' }}/metadata.json ``` diff --git a/docs/download.md b/docs/download.md index 1ca50ce80..1511b9ef1 100644 --- a/docs/download.md +++ b/docs/download.md @@ -41,7 +41,7 @@

DWEB 稳定版

经过充分测试的稳定版本,推荐日常使用。

- 安装到 DWEB + 安装到 DWEB 下载 ZIP 文件
@@ -51,7 +51,7 @@

DWEB 测试版

包含最新功能,每次代码更新自动发布。

- 安装 Beta 版 + 安装 Beta 版 下载 ZIP 文件
diff --git a/scripts/build.ts b/scripts/build.ts index d1ed22586..1309f1f52 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -322,6 +322,15 @@ async function createReleaseArtifacts(webDir: string, dwebDir: string) { const dwebVersionZipPath = join(releaseDir, `bfmpay-dweb-${version}${suffix}.zip`) cpSync(dwebZipPath, dwebVersionZipPath) + // 复制 metadata.json 到 release 目录(plaoc bundle 自动生成) + const metadataSrc = join(DISTS_DIR, 'metadata.json') + if (existsSync(metadataSrc)) { + cpSync(metadataSrc, join(releaseDir, 'metadata.json')) + log.info(` - metadata.json`) + } else { + log.warn('metadata.json 未找到,dweb://install 链接可能无法正常工作') + } + log.success(`Release 产物创建完成: ${releaseDir}`) log.info(` - ${webZipName}`) log.info(` - ${dwebZipName}`) diff --git a/scripts/set-secret.ts b/scripts/set-secret.ts index 1b598e6f9..bd7eb7582 100644 --- a/scripts/set-secret.ts +++ b/scripts/set-secret.ts @@ -1,192 +1,81 @@ #!/usr/bin/env bun -/** - * 交互式配置管理工具 - * - * 用法: - * pnpm set-secret # 交互式选择要配置的项目 - * pnpm set-secret --list # 列出当前配置状态 - * - * 支持配置: - * - E2E 测试账号(助记词、地址、安全密码) - * - DWEB 发布账号(SFTP 正式版/开发版) - * - * 配置目标: - * - 本地: .env.local - * - CI/CD: GitHub Secrets - */ import { execSync } from 'node:child_process' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { select, checkbox, input, password, confirm } from '@inquirer/prompts' - -// ==================== 配置 ==================== const ROOT = process.cwd() const ENV_LOCAL_PATH = join(ROOT, '.env.local') -// 颜色输出 -const colors = { +const ANSI = { + clear: '\x1b[2J', + home: '\x1b[H', + hideCursor: '\x1b[?25l', + showCursor: '\x1b[?25h', + clearLine: '\x1b[2K', + moveTo: (row: number, col: number) => `\x1b[${row};${col}H`, + moveUp: (n: number) => `\x1b[${n}A`, + bold: '\x1b[1m', + dim: '\x1b[2m', reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', - dim: '\x1b[2m', -} - -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}`), + bgBlue: '\x1b[44m', + white: '\x1b[37m', } -// ==================== 配置项定义 ==================== - -interface SecretDefinition { +interface SecretField { key: string - description: string - category: string + label: string + isPassword: boolean required: boolean - isPassword?: boolean - validate?: (value: string) => string | true + validate?: (value: string) => string | null } -const SECRET_DEFINITIONS: SecretDefinition[] = [ - // E2E 测试 - { - key: 'E2E_TEST_MNEMONIC', - description: '测试钱包助记词(24个词)', - category: 'e2e', - required: true, - validate: (v) => { - const words = v.split(/\s+/).filter(Boolean) - if (words.length !== 24 && words.length !== 12) { - return `助记词应为 12 或 24 个词,当前: ${words.length} 个` - } - return true - }, - }, - { - key: 'E2E_TEST_ADDRESS', - description: '测试钱包地址(从助记词派生)', - category: 'e2e', - required: false, - }, - { - key: 'E2E_TEST_SECOND_SECRET', - description: '安全密码/二次密钥(如果账号设置了 secondPublicKey)', - category: 'e2e', - required: false, - isPassword: true, - }, - - // DWEB 发布 - 正式版 - { - key: 'DWEB_SFTP_USER', - description: 'SFTP 正式版用户名', - category: 'dweb-stable', - required: true, - }, - { - key: 'DWEB_SFTP_PASS', - description: 'SFTP 正式版密码', - category: 'dweb-stable', - required: true, - isPassword: true, - }, - - // DWEB 发布 - 开发版 - { - key: 'DWEB_SFTP_USER_DEV', - description: 'SFTP 开发版用户名', - category: 'dweb-dev', - required: true, - }, - { - key: 'DWEB_SFTP_PASS_DEV', - description: 'SFTP 开发版密码', - category: 'dweb-dev', - required: true, - isPassword: true, - }, - - // API Keys - { - key: 'TRONGRID_API_KEY', - description: 'TronGrid API Key(支持逗号分隔多个,启动时随机选中一个)', - category: 'api-keys', - required: false, - isPassword: true, - validate: (v) => { - if (!v) return true - const keys = v.split(',').map(k => k.trim()).filter(Boolean) - for (const key of keys) { - if (!/^[a-f0-9-]{36}$/i.test(key)) { - return `API Key 格式无效: ${key}(应为 UUID)` - } - } - return true - }, - }, - { - key: 'ETHERSCAN_API_KEY', - description: 'Etherscan API Key(支持逗号分隔多个,启动时随机选中一个)', - category: 'api-keys', - required: false, - isPassword: true, - validate: (v) => { - if (!v) return true - const keys = v.split(',').map(k => k.trim()).filter(Boolean) - for (const key of keys) { - if (!/^[A-Z0-9]{34}$/i.test(key)) { - return `API Key 格式无效: ${key}(应为 34 位字母数字)` - } - } - return true - }, - }, +const FIELDS: SecretField[] = [ + { key: 'DWEB_SFTP_USER', label: 'DWEB SFTP 用户名 (正式)', isPassword: false, required: true }, + { key: 'DWEB_SFTP_PASS', label: 'DWEB SFTP 密码 (正式)', isPassword: true, required: true }, + { key: 'DWEB_SFTP_USER_DEV', label: 'DWEB SFTP 用户名 (开发)', isPassword: false, required: false }, + { key: 'DWEB_SFTP_PASS_DEV', label: 'DWEB SFTP 密码 (开发)', isPassword: true, required: false }, + { key: 'E2E_TEST_MNEMONIC', label: 'E2E 测试助记词', isPassword: false, required: false, validate: validateMnemonic }, + { key: 'E2E_TEST_SECOND_SECRET', label: 'E2E 安全密码', isPassword: true, required: false }, + { key: 'TRONGRID_API_KEY', label: 'TronGrid API Key', isPassword: true, required: false }, + { key: 'ETHERSCAN_API_KEY', label: 'Etherscan API Key', isPassword: true, required: false }, ] -interface CategoryDefinition { - id: string - name: string - description: string +function validateMnemonic(v: string): string | null { + if (!v) return null + const words = v.split(/\s+/).filter(Boolean) + if (words.length !== 12 && words.length !== 24) { + return `需要 12 或 24 个词,当前 ${words.length} 个` + } + return null } -const CATEGORIES: CategoryDefinition[] = [ - { - id: 'e2e', - name: 'E2E 测试', - description: '端到端测试所需的测试钱包配置', - }, - { - id: 'dweb-stable', - name: 'DWEB 正式版发布', - description: 'SFTP 正式服务器账号(用于 pnpm release)', - }, - { - id: 'dweb-dev', - name: 'DWEB 开发版发布', - description: 'SFTP 开发服务器账号(用于日常 CI/CD)', - }, - { - id: 'api-keys', - name: 'API Keys', - description: '第三方 API 密钥(TronGrid 等)', - }, -] - -// ==================== 工具函数 ==================== +interface State { + cursor: number + values: Map + editing: boolean + editBuffer: string + editCursor: number + message: string + messageType: 'info' | 'error' | 'success' + localEnv: Map + ghSecrets: Set + hasGhCli: boolean + target: 'local' | 'github' | 'both' + saved: boolean +} -// Note: exec utility function available if needed -// function _exec(cmd: string, silent = false): string { ... } +function getTermSize(): { cols: number; rows: number } { + return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 } +} function checkGhCli(): boolean { try { - execSync('gh --version', { stdio: 'pipe' }) execSync('gh auth status', { stdio: 'pipe' }) return true } catch { @@ -194,272 +83,281 @@ function checkGhCli(): boolean { } } -function getGitHubSecrets(): Map { - const secrets = new Map() +function getGitHubSecrets(): Set { + const secrets = new Set() try { const output = execSync('gh secret list', { encoding: 'utf-8', stdio: 'pipe' }) for (const line of output.split('\n')) { - const [name, updatedAt] = line.split('\t') - if (name) { - secrets.set(name.trim(), updatedAt?.trim() || '') - } + const name = line.split('\t')[0]?.trim() + if (name) secrets.add(name) } - } catch { - // gh cli not available or not authenticated - } + } catch {} return secrets } function getLocalEnv(): Map { const env = new Map() if (!existsSync(ENV_LOCAL_PATH)) return env - const content = readFileSync(ENV_LOCAL_PATH, 'utf-8') for (const line of content.split('\n')) { - const match = line.match(/^([A-Z_]+)="(.*)"\s*$/) - if (match) { - env.set(match[1], match[2]) - } + const match = line.match(/^([A-Z_][A-Z0-9_]*)="(.*)"$/) + if (match) env.set(match[1], match[2]) } return env } -function updateLocalEnv(updates: Map): void { - let content = '' - if (existsSync(ENV_LOCAL_PATH)) { - content = readFileSync(ENV_LOCAL_PATH, 'utf-8') - } - - for (const [key, value] of updates) { - const regex = new RegExp(`^${key}=".*"\\s*$`, 'm') +function saveLocalEnv(values: Map): void { + let content = existsSync(ENV_LOCAL_PATH) ? readFileSync(ENV_LOCAL_PATH, 'utf-8') : '' + for (const [key, value] of values) { + if (!value) continue + const regex = new RegExp(`^${key}=".*"$`, 'm') const newLine = `${key}="${value}"` - if (regex.test(content)) { content = content.replace(regex, newLine) } else { content = content.trimEnd() + '\n' + newLine + '\n' } } - writeFileSync(ENV_LOCAL_PATH, content) } -async function setGitHubSecret(key: string, value: string): Promise { - try { - execSync(`gh secret set ${key} --body "${value.replace(/"/g, '\\"')}"`, { - cwd: ROOT, - stdio: 'pipe', - }) - return true - } catch { - return false +function saveGitHubSecrets(values: Map): number { + let count = 0 + for (const [key, value] of values) { + if (!value) continue + try { + execSync(`gh secret set ${key}`, { input: value, stdio: ['pipe', 'pipe', 'pipe'] }) + count++ + } catch {} } + return count } -// ==================== 地址派生 ==================== - -async function deriveAddress(mnemonic: string): Promise { - try { - const { getBioforestCore, setGenesisBaseUrl } = await import('../src/services/bioforest-sdk/index.js') - - const genesisPath = `file://${join(ROOT, 'public/configs/genesis')}` - setGenesisBaseUrl(genesisPath, { with: { type: 'json' } }) - - const core = await getBioforestCore('bfmeta') - const accountHelper = core.accountBaseHelper() - return await accountHelper.getAddressFromSecret(mnemonic) - } catch (error) { - log.warn(`无法派生地址: ${error instanceof Error ? error.message : error}`) - return '' - } +function maskValue(v: string, isPassword: boolean): string { + if (!v) return '' + if (isPassword) return '•'.repeat(Math.min(v.length, 16)) + if (v.length > 20) return v.slice(0, 17) + '...' + return v } -// ==================== 状态显示 ==================== +function render(state: State): void { + const { cols } = getTermSize() + const width = Math.min(cols - 4, 76) + let out = ANSI.home + ANSI.hideCursor + + const title = ' BFM Pay 配置管理 ' + const padTitle = Math.floor((width - Bun.stringWidth(title)) / 2) + out += `${ANSI.cyan}┌${'─'.repeat(width)}┐${ANSI.reset}\n` + out += `${ANSI.cyan}│${' '.repeat(padTitle)}${ANSI.bold}${title}${ANSI.reset}${ANSI.cyan}${' '.repeat(width - padTitle - Bun.stringWidth(title))}│${ANSI.reset}\n` + out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` + + const targetLabel = state.target === 'both' ? 'Local + GitHub' : state.target === 'local' ? 'Local only' : 'GitHub only' + out += `${ANSI.cyan}│${ANSI.reset} Target: ${ANSI.yellow}${targetLabel}${ANSI.reset}${' '.repeat(width - 10 - targetLabel.length)}${ANSI.cyan}│${ANSI.reset}\n` + out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` + + for (let i = 0; i < FIELDS.length; i++) { + const field = FIELDS[i] + const isSelected = i === state.cursor + const value = state.values.get(field.key) || '' + const hasLocal = state.localEnv.has(field.key) + const hasGh = state.ghSecrets.has(field.key) + + const prefix = isSelected ? `${ANSI.cyan}▸${ANSI.reset}` : ' ' + const labelWidth = 28 + const label = field.label.padEnd(labelWidth).slice(0, labelWidth) + + let displayValue: string + if (state.editing && isSelected) { + const buf = field.isPassword ? '•'.repeat(state.editBuffer.length) : state.editBuffer + displayValue = `[${buf}█]` + } else { + const masked = maskValue(value, field.isPassword) + displayValue = value ? `[${masked}]` : `[${ANSI.dim}空${ANSI.reset}]` + } -async function showStatus(): Promise { - console.log(` -${colors.cyan}╔════════════════════════════════════════╗ -║ 配置状态 ║ -╚════════════════════════════════════════╝${colors.reset} -`) + const localStatus = hasLocal || value ? `${ANSI.green}L${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` + const ghStatus = !state.hasGhCli ? `${ANSI.dim}?${ANSI.reset}` : hasGh || value ? `${ANSI.green}G${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` + const reqMark = field.required ? `${ANSI.red}*${ANSI.reset}` : ' ' - const localEnv = getLocalEnv() - const ghSecrets = getGitHubSecrets() - const hasGhCli = checkGhCli() - - for (const category of CATEGORIES) { - console.log(`\n${colors.blue}▸ ${category.name}${colors.reset} ${colors.dim}(${category.description})${colors.reset}`) + const lineContent = `${prefix} ${reqMark}${label} ${displayValue} ${localStatus}${ghStatus}` + const lineLen = 3 + 1 + labelWidth + 1 + Bun.stringWidth(displayValue.replace(/\x1b\[[0-9;]*m/g, '')) + 3 + const pad = Math.max(0, width - lineLen) + out += `${ANSI.cyan}│${ANSI.reset}${lineContent}${' '.repeat(pad)}${ANSI.cyan}│${ANSI.reset}\n` + } - const secrets = SECRET_DEFINITIONS.filter((s) => s.category === category.id) - for (const secret of secrets) { - const localValue = localEnv.get(secret.key) - const ghValue = ghSecrets.get(secret.key) + out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` - const localStatus = localValue - ? `${colors.green}✓${colors.reset}` - : `${colors.dim}✗${colors.reset}` + const msgColor = state.messageType === 'error' ? ANSI.red : state.messageType === 'success' ? ANSI.green : ANSI.dim + const msgText = state.message || '↑↓ 移动 Enter 编辑 K 保留 D 清空 S 保存 T 切换目标 Q 退出' + const msgLen = Bun.stringWidth(msgText.replace(/\x1b\[[0-9;]*m/g, '')) + out += `${ANSI.cyan}│${ANSI.reset} ${msgColor}${msgText}${ANSI.reset}${' '.repeat(Math.max(0, width - msgLen - 2))}${ANSI.cyan}│${ANSI.reset}\n` + out += `${ANSI.cyan}└${'─'.repeat(width)}┘${ANSI.reset}\n` - const ghStatus = !hasGhCli - ? `${colors.dim}?${colors.reset}` - : ghValue - ? `${colors.green}✓${colors.reset}` - : `${colors.dim}✗${colors.reset}` + process.stdout.write(out) +} - console.log( - ` ${secret.key.padEnd(25)} Local: ${localStatus} GitHub: ${ghStatus} ${colors.dim}${secret.description}${colors.reset}`, - ) - } +async function run(): Promise { + const args = process.argv.slice(2) + if (args.includes('--list') || args.includes('-l')) { + showListMode() + return } - if (!hasGhCli) { - console.log(`\n${colors.yellow}⚠ GitHub CLI 未安装或未登录,无法显示 GitHub Secrets 状态${colors.reset}`) - console.log(` 安装: brew install gh && gh auth login`) + const state: State = { + cursor: 0, + values: new Map(), + editing: false, + editBuffer: '', + editCursor: 0, + message: '', + messageType: 'info', + localEnv: getLocalEnv(), + ghSecrets: getGitHubSecrets(), + hasGhCli: checkGhCli(), + target: 'both', + saved: false, } - console.log('') -} - -// ==================== 配置流程 ==================== - -async function configureCategory(categoryId: string, target: 'local' | 'github' | 'both'): Promise { - const category = CATEGORIES.find((c) => c.id === categoryId) - if (!category) return + for (const field of FIELDS) { + const existing = state.localEnv.get(field.key) + if (existing) state.values.set(field.key, existing) + } - console.log(`\n${colors.cyan}▸ 配置 ${category.name}${colors.reset}\n`) + process.stdout.write(ANSI.clear) + process.stdin.setRawMode(true) + process.stdin.resume() - const secrets = SECRET_DEFINITIONS.filter((s) => s.category === categoryId) - const values = new Map() + render(state) - for (const secret of secrets) { - let value: string + process.stdin.on('data', (data: Buffer) => { + const key = data.toString() - if (secret.isPassword) { - value = await password({ - message: `${secret.description}:`, - }) + if (state.editing) { + handleEditMode(state, key) } else { - value = await input({ - message: `${secret.description}:`, - validate: (v) => { - if (secret.required && !v.trim()) { - return '此项必填' - } - if (secret.validate) { - return secret.validate(v) - } - return true - }, - }) + handleNavigationMode(state, key) } - if (value) { - values.set(secret.key, value) - - // 特殊处理:从助记词派生地址 - if (secret.key === 'E2E_TEST_MNEMONIC') { - log.info('派生地址...') - const address = await deriveAddress(value) - if (address) { - values.set('E2E_TEST_ADDRESS', address) - log.success(`地址: ${address}`) - } - } - } - } - - // 保存到本地 - if (target === 'local' || target === 'both') { - updateLocalEnv(values) - log.success(`已更新 .env.local`) - } + render(state) + }) - // 保存到 GitHub - if (target === 'github' || target === 'both') { - if (!checkGhCli()) { - log.error('GitHub CLI 未安装或未登录') - log.info('安装: brew install gh && gh auth login') - return - } + process.on('exit', () => { + process.stdout.write(ANSI.showCursor) + process.stdin.setRawMode(false) + }) +} - for (const [key, value] of values) { - const ok = await setGitHubSecret(key, value) - if (ok) { - log.success(`GitHub Secret: ${key}`) - } else { - log.error(`GitHub Secret: ${key} 设置失败`) +function handleEditMode(state: State, key: string): void { + if (key === '\r' || key === '\n') { + const field = FIELDS[state.cursor] + if (field.validate) { + const err = field.validate(state.editBuffer) + if (err) { + state.message = err + state.messageType = 'error' + return } } + state.values.set(field.key, state.editBuffer) + state.editing = false + state.message = `${field.key} 已更新` + state.messageType = 'success' + } else if (key === '\x1b') { + state.editing = false + state.editBuffer = '' + state.message = '取消编辑' + state.messageType = 'info' + } else if (key === '\x7f') { + state.editBuffer = state.editBuffer.slice(0, -1) + } else if (key.length === 1 && key.charCodeAt(0) >= 32) { + state.editBuffer += key } } -// ==================== 主程序 ==================== +function handleNavigationMode(state: State, key: string): void { + state.message = '' + state.messageType = 'info' + + if (key === '\x1b[A' || key === 'k') { + state.cursor = Math.max(0, state.cursor - 1) + } else if (key === '\x1b[B' || key === 'j') { + state.cursor = Math.min(FIELDS.length - 1, state.cursor + 1) + } else if (key === '\r' || key === '\n' || key === 'e') { + state.editing = true + state.editBuffer = state.values.get(FIELDS[state.cursor].key) || '' + state.message = 'Enter 确认 | Esc 取消' + state.messageType = 'info' + } else if (key === 'd' || key === 'D') { + const field = FIELDS[state.cursor] + state.values.delete(field.key) + state.message = `${field.key} 已清空` + state.messageType = 'info' + } else if (key === 't' || key === 'T') { + if (state.target === 'both') state.target = 'local' + else if (state.target === 'local') state.target = 'github' + else state.target = 'both' + } else if (key === 's' || key === 'S') { + save(state) + } else if (key === 'q' || key === 'Q' || key === '\x03') { + process.stdout.write(ANSI.showCursor + '\n') + if (!state.saved && state.values.size > 0) { + console.log(`${ANSI.yellow}未保存的更改已丢弃${ANSI.reset}`) + } + process.exit(0) + } +} -async function main(): Promise { - const args = process.argv.slice(2) +function save(state: State): void { + let localCount = 0 + let ghCount = 0 - // 显示状态 - if (args.includes('--list') || args.includes('-l')) { - await showStatus() - return + if (state.target === 'local' || state.target === 'both') { + saveLocalEnv(state.values) + localCount = state.values.size } - console.log(` -${colors.cyan}╔════════════════════════════════════════╗ -║ 配置管理工具 ║ -╚════════════════════════════════════════╝${colors.reset} -`) - - // 选择要配置的类别 - const selectedCategories = await checkbox({ - message: '选择要配置的项目 (空格选中,回车确认):', - choices: CATEGORIES.map((c) => ({ - value: c.id, - name: `${c.name} - ${c.description}`, - })), - required: true, - }) + if ((state.target === 'github' || state.target === 'both') && state.hasGhCli) { + ghCount = saveGitHubSecrets(state.values) + } - // 选择配置目标 - const target = await select({ - message: '配置保存到:', - choices: [ - { value: 'both' as const, name: '本地 + GitHub(推荐)' }, - { value: 'local' as const, name: '仅本地 (.env.local)' }, - { value: 'github' as const, name: '仅 GitHub Secrets' }, - ], - }) + state.saved = true + state.localEnv = getLocalEnv() + state.ghSecrets = getGitHubSecrets() - // 检查 GitHub CLI - if ((target === 'github' || target === 'both') && !checkGhCli()) { - log.error('GitHub CLI 未安装或未登录') - log.info('安装: brew install gh && gh auth login') + if (state.target === 'both') { + state.message = `已保存 Local:${localCount} GitHub:${ghCount}` + } else if (state.target === 'local') { + state.message = `已保存到 .env.local (${localCount} 项)` + } else { + state.message = `已保存到 GitHub Secrets (${ghCount} 项)` + } + state.messageType = 'success' +} - if (target === 'github') { - return - } +function showListMode(): void { + const localEnv = getLocalEnv() + const ghSecrets = getGitHubSecrets() + const hasGhCli = checkGhCli() - const continueLocal = await confirm({ - message: '是否仅配置本地?', - default: true, - }) + console.log(`\n${ANSI.cyan}${ANSI.bold}配置状态${ANSI.reset}\n`) - if (!continueLocal) { - return - } + for (const field of FIELDS) { + const hasLocal = localEnv.has(field.key) + const hasGh = ghSecrets.has(field.key) + const localStatus = hasLocal ? `${ANSI.green}✓${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` + const ghStatus = !hasGhCli ? `${ANSI.dim}?${ANSI.reset}` : hasGh ? `${ANSI.green}✓${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` + const reqMark = field.required ? `${ANSI.red}*${ANSI.reset}` : ' ' + console.log(` ${reqMark}${field.key.padEnd(25)} L:${localStatus} G:${ghStatus} ${ANSI.dim}${field.label}${ANSI.reset}`) } - // 逐个配置 - for (const categoryId of selectedCategories) { - await configureCategory(categoryId, target as 'local' | 'github' | 'both') + if (!hasGhCli) { + console.log(`\n${ANSI.yellow}⚠ GitHub CLI 未登录${ANSI.reset}`) } - - console.log(`\n${colors.green}✓ 配置完成!${colors.reset}\n`) - - // 显示最终状态 - await showStatus() + console.log() } -main().catch((error) => { - log.error(`配置失败: ${error.message}`) +run().catch((err) => { + process.stdout.write(ANSI.showCursor) + console.error(`${ANSI.red}错误: ${err.message}${ANSI.reset}`) process.exit(1) })