|
| 1 | +import { z } from 'zod' |
| 2 | +import useTranslate from '../useTranslate' |
| 3 | +import { createOutputSchema, createSuccessResponse, createErrorResponse } from './commonSchema' |
| 4 | + |
| 5 | +// 输入 Schema:统一保存工具,支持 add / update / upsert 与合并策略 |
| 6 | +const inputSchema = z.object({ |
| 7 | + key: z |
| 8 | + .string() |
| 9 | + .describe( |
| 10 | + '唯一的 i18n 键名,用于标识翻译词条。建议使用点分割命名规范,如 "common.button.save" 或 "page.user.title"' |
| 11 | + ), |
| 12 | + operation: z |
| 13 | + .enum(['add', 'update', 'upsert']) |
| 14 | + .optional() |
| 15 | + .describe( |
| 16 | + '操作模式:add=仅新增(键已存在时报错),update=仅更新(键不存在时报错),upsert=智能新增或更新(默认推荐)' |
| 17 | + ), |
| 18 | + translations: z |
| 19 | + .record(z.string()) |
| 20 | + .optional() |
| 21 | + .describe( |
| 22 | + '多语言翻译映射对象,键为语言代码(如 zh_CN, en_US),值为对应翻译文本。优先级高于单独的 zh_CN/en_US 字段' |
| 23 | + ), |
| 24 | + zh_CN: z |
| 25 | + .string() |
| 26 | + .optional() |
| 27 | + .describe('中文翻译文本(语法糖字段)。当只需设置中文时使用,会被 translations 中的 zh_CN 覆盖'), |
| 28 | + en_US: z |
| 29 | + .string() |
| 30 | + .optional() |
| 31 | + .describe('英文翻译文本(语法糖字段)。当只需设置英文时使用,会被 translations 中的 en_US 覆盖'), |
| 32 | + mergeStrategy: z |
| 33 | + .enum(['partial', 'replace']) |
| 34 | + .optional() |
| 35 | + .describe( |
| 36 | + '更新现有词条时的合并策略:partial=部分更新(仅更新提供的语言,其他语言保持不变,默认),replace=替换更新(清空未提供的语言)' |
| 37 | + ), |
| 38 | + idempotent: z |
| 39 | + .boolean() |
| 40 | + .optional() |
| 41 | + .describe( |
| 42 | + '幂等模式开关。true=遇到边界冲突时静默处理不报错(如 add 已存在的键、update 不存在的键),false=严格模式会抛出错误(默认)' |
| 43 | + ) |
| 44 | +}) |
| 45 | + |
| 46 | +// 输出 Schema:统一数据结构 |
| 47 | +const dataSchema = z.object({ |
| 48 | + key: z.string(), |
| 49 | + type: z.string(), |
| 50 | + translations: z.record(z.string()), |
| 51 | + operation: z.enum(['add', 'update', 'upsert']), |
| 52 | + mergeStrategy: z.enum(['partial', 'replace']), |
| 53 | + originalEntry: z.record(z.any()).optional(), |
| 54 | + noOp: z.boolean().optional() |
| 55 | +}) |
| 56 | + |
| 57 | +const outputSchema = createOutputSchema(dataSchema) |
| 58 | + |
| 59 | +export const saveI18n = { |
| 60 | + name: 'save_i18n', |
| 61 | + title: '保存 I18n 词条', |
| 62 | + description: `在当前 TinyEngine 低代码应用中创建或更新国际化(i18n)翻译词条。 |
| 63 | +
|
| 64 | +适用场景: |
| 65 | +• 添加新的多语言文本(按钮文本、提示信息、页面标题等) |
| 66 | +• 更新现有翻译内容(修正翻译、优化措辞) |
| 67 | +• 批量管理多语言词条(支持中英文及其他语言) |
| 68 | +
|
| 69 | +核心特性: |
| 70 | +• 支持 add/update/upsert 三种操作模式,满足不同业务需求 |
| 71 | +• 提供 partial/replace 合并策略,灵活控制更新范围 |
| 72 | +• 内置幂等性支持,避免重复操作引起的错误 |
| 73 | +• 支持语法糖字段(zh_CN/en_US)和完整翻译映射(translations) |
| 74 | +
|
| 75 | +调用时机: |
| 76 | +• 当需要为界面元素添加多语言支持时 |
| 77 | +• 当需要修改现有的翻译文本时 |
| 78 | +• 当需要确保翻译词条存在且内容正确时(使用 upsert 模式)`, |
| 79 | + inputSchema: inputSchema.shape, |
| 80 | + outputSchema: outputSchema.shape, |
| 81 | + annotations: { |
| 82 | + title: 'Save I18n Entry', |
| 83 | + readOnlyHint: false, |
| 84 | + destructiveHint: false, |
| 85 | + idempotentHint: true, |
| 86 | + openWorldHint: false |
| 87 | + }, |
| 88 | + callback: async (args: z.infer<typeof inputSchema>) => { |
| 89 | + const { |
| 90 | + key, |
| 91 | + operation = 'upsert', |
| 92 | + translations: inputTranslations, |
| 93 | + zh_CN, |
| 94 | + en_US, |
| 95 | + mergeStrategy = 'partial', |
| 96 | + idempotent = false |
| 97 | + } = args |
| 98 | + |
| 99 | + try { |
| 100 | + const { getLangs, i18nResource, ensureI18n } = useTranslate() |
| 101 | + const langs = getLangs() as Record<string, any> |
| 102 | + const locales = (i18nResource?.locales || []).map((l: any) => l.lang) |
| 103 | + |
| 104 | + // 归并顶层糖衣字段到 translations,translations 优先 |
| 105 | + const mergedInputTranslations: Record<string, string> = { |
| 106 | + ...(zh_CN !== undefined ? { zh_CN } : {}), |
| 107 | + ...(en_US !== undefined ? { en_US } : {}), |
| 108 | + ...(inputTranslations || {}) |
| 109 | + } |
| 110 | + |
| 111 | + const existingEntry = langs[key] |
| 112 | + |
| 113 | + // 边界条件处理(严格模式与幂等模式) |
| 114 | + if (operation === 'add' && existingEntry) { |
| 115 | + if (idempotent) { |
| 116 | + return createSuccessResponse('未更改:词条已存在', { |
| 117 | + key, |
| 118 | + type: existingEntry.type || 'i18n', |
| 119 | + translations: Object.fromEntries(locales.map((lg) => [lg, existingEntry[lg] || ''])), |
| 120 | + operation: 'add', |
| 121 | + mergeStrategy, |
| 122 | + originalEntry: existingEntry, |
| 123 | + noOp: true |
| 124 | + }) |
| 125 | + } |
| 126 | + return createErrorResponse('I18n 键已存在', `键 "${key}" 已在 i18n 字典中定义`) |
| 127 | + } |
| 128 | + |
| 129 | + if (operation === 'update' && !existingEntry) { |
| 130 | + if (idempotent) { |
| 131 | + return createSuccessResponse('未更改:词条不存在', { |
| 132 | + key, |
| 133 | + type: 'i18n', |
| 134 | + translations: Object.fromEntries(locales.map((lg) => [lg, ''])), |
| 135 | + operation: 'update', |
| 136 | + mergeStrategy, |
| 137 | + noOp: true |
| 138 | + }) |
| 139 | + } |
| 140 | + return createErrorResponse('未找到 I18n 键', `键 "${key}" 不存在于 i18n 字典中`) |
| 141 | + } |
| 142 | + |
| 143 | + // 计算最终写入的 translations(保护 partial 合并不被空串覆盖) |
| 144 | + const baseTranslations: Record<string, string> = Object.fromEntries( |
| 145 | + locales.map((lg: string) => [lg, existingEntry?.[lg] ?? '']) |
| 146 | + ) |
| 147 | + |
| 148 | + const finalTranslations: Record<string, string> = { ...baseTranslations } |
| 149 | + |
| 150 | + if (mergeStrategy === 'partial') { |
| 151 | + Object.entries(mergedInputTranslations).forEach(([lg, val]) => { |
| 152 | + if (locales.includes(lg)) finalTranslations[lg] = val |
| 153 | + }) |
| 154 | + } else { |
| 155 | + // replace:仅对提供的语言键进行覆盖,其它语言保持原值 |
| 156 | + Object.entries(mergedInputTranslations).forEach(([lg, val]) => { |
| 157 | + if (locales.includes(lg)) finalTranslations[lg] = val |
| 158 | + }) |
| 159 | + } |
| 160 | + |
| 161 | + const finalEntry = { key, type: existingEntry?.type || 'i18n', ...finalTranslations } |
| 162 | + |
| 163 | + // 写入(ensureI18n 内部会根据是否存在选择 create 或 update) |
| 164 | + await ensureI18n(finalEntry, true) |
| 165 | + |
| 166 | + const resData = { |
| 167 | + key, |
| 168 | + type: finalEntry.type, |
| 169 | + translations: Object.fromEntries(locales.map((lg) => [lg, finalTranslations[lg] || ''])), |
| 170 | + operation: existingEntry ? (operation === 'add' ? 'add' : 'update') : operation === 'update' ? 'update' : 'add', |
| 171 | + mergeStrategy, |
| 172 | + ...(existingEntry ? { originalEntry: existingEntry } : {}) |
| 173 | + } |
| 174 | + |
| 175 | + return createSuccessResponse('I18n 词条保存成功', resData) |
| 176 | + } catch (error) { |
| 177 | + const errorMessage = error instanceof Error ? error.message : '发生未知错误' |
| 178 | + return createErrorResponse('保存 i18n 词条失败', errorMessage) |
| 179 | + } |
| 180 | + } |
| 181 | +} |
0 commit comments