diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..da9f55d7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.{cmd,bat} text eol=crlf diff --git a/cli.js b/cli.js index ab58bf2b..3c0df825 100644 --- a/cli.js +++ b/cli.js @@ -10461,6 +10461,7 @@ function assertRequestAuthorized(req, res) { function isProtectedWebSurfacePath(requestPath) { return requestPath === '/' + || requestPath === '/session' || requestPath === '/web-ui/index.html' || requestPath.startsWith('/web-ui/') || requestPath.startsWith('/res/'); @@ -11889,8 +11890,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }); fs.createReadStream(filePath).pipe(res); } else { - // Only serve HTML for root path; /web-ui returns 404. - if (requestPath === '/') { + // Serve the SPA shell for routable entry points. Keep /web-ui as 404. + if (requestPath === '/' || requestPath === '/session') { try { const html = readBundledWebUiHtml(htmlPath); res.writeHead(200, { diff --git a/package-lock.json b/package-lock.json index 4d862295..5ba50bf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "codexmate", - "version": "0.0.42", + "version": "0.0.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codexmate", - "version": "0.0.42", + "version": "0.0.43", "license": "Apache-2.0", "dependencies": { "@iarna/toml": "^2.2.5", - "@vue/compiler-dom": "^3.5.30", "json5": "^2.2.3", "yauzl": "^3.2.1", "zip-lib": "^1.2.1" @@ -19,6 +18,7 @@ "codexmate": "cli.js" }, "devDependencies": { + "@vue/compiler-dom": "^3.5.30", "vitepress": "^1.6.4" }, "engines": { @@ -287,6 +287,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -296,6 +297,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -305,6 +307,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -320,6 +323,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1329,6 +1333,7 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -1342,6 +1347,7 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-core": "3.5.30", @@ -1465,6 +1471,7 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "dev": true, "license": "MIT" }, "node_modules/@vueuse/core": { @@ -1720,6 +1727,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -1771,6 +1779,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, "license": "MIT" }, "node_modules/focus-trap": { @@ -2236,6 +2245,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index a393e822..f7da8ca9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexmate", - "version": "0.0.42", + "version": "0.0.43", "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具", "main": "cli.js", "bin": { @@ -46,7 +46,6 @@ }, "dependencies": { "@iarna/toml": "^2.2.5", - "@vue/compiler-dom": "^3.5.30", "json5": "^2.2.3", "yauzl": "^3.2.1", "zip-lib": "^1.2.1" @@ -72,6 +71,7 @@ "author": "ymkiux", "license": "Apache-2.0", "devDependencies": { + "@vue/compiler-dom": "^3.5.30", "vitepress": "^1.6.4" } } diff --git a/tests/unit/agents-modal-guards.test.mjs b/tests/unit/agents-modal-guards.test.mjs index f9ee3ed5..896d2bc6 100644 --- a/tests/unit/agents-modal-guards.test.mjs +++ b/tests/unit/agents-modal-guards.test.mjs @@ -11,6 +11,9 @@ const { createAgentsMethods } = await import( const { createCodexConfigMethods } = await import( pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.codex-config.mjs')) ); +const { createI18nMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs')) +); test('closeConfigTemplateModal ignores user close attempts while template apply is busy', () => { const methods = createCodexConfigMethods({ @@ -20,7 +23,9 @@ test('closeConfigTemplateModal ignores user close attempts while template apply } }); const context = { + ...createI18nMethods(), ...methods, + lang: 'zh', showConfigTemplateModal: true, configTemplateApplying: true, configTemplateContent: 'draft-template' @@ -52,7 +57,9 @@ test('applyConfigTemplate force closes the modal after a successful apply', asyn } }); const context = { + ...createI18nMethods(), ...methods, + lang: 'zh', showConfigTemplateModal: true, configTemplateApplying: false, configTemplateContent: 'draft-template', @@ -103,7 +110,9 @@ test('applyConfigTemplate keeps the successful apply result when only the refres } }); const context = { + ...createI18nMethods(), ...methods, + lang: 'zh', showConfigTemplateModal: true, configTemplateApplying: false, configTemplateContent: 'draft-template', @@ -161,7 +170,9 @@ test('applyConfigTemplate applies immediately when diff confirm is disabled', as } }); const context = { + ...createI18nMethods(), ...methods, + lang: 'zh', showConfigTemplateModal: true, configTemplateApplying: false, configTemplateContent: 'draft-template', @@ -192,7 +203,9 @@ test('runHealthCheck treats backend error payloads as failures', async () => { } }); const context = { + ...createI18nMethods(), ...methods, + lang: 'zh', providersList: ['alpha'], speedResults: {}, speedLoading: {}, @@ -227,7 +240,9 @@ test('runHealthCheck skips Claude speed tests when the primary health check alre }); let claudeSpeedTestCalls = 0; const context = { + ...createI18nMethods(), ...methods, + lang: 'zh', providersList: ['alpha'], speedResults: {}, speedLoading: {}, @@ -276,7 +291,9 @@ test('runHealthCheck preserves backend remote health result while appending spee } }); const context = { + ...createI18nMethods(), ...methods, + lang: 'zh', providersList: ['alpha', 'beta'], speedResults: {}, speedLoading: {}, @@ -324,7 +341,9 @@ test('applyCodexConfigDirect keeps the successful apply result when only the ref } }); const context = { + ...createI18nMethods(), ...methods, + lang: 'zh', codexApplying: false, _pendingCodexApplyOptions: null, currentProvider: 'alpha', diff --git a/tests/unit/claude-settings-sync.test.mjs b/tests/unit/claude-settings-sync.test.mjs index 58f67631..e74de2f0 100644 --- a/tests/unit/claude-settings-sync.test.mjs +++ b/tests/unit/claude-settings-sync.test.mjs @@ -1,6 +1,15 @@ import assert from 'assert'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; import { readBundledWebUiScript, readProjectFile } from './helpers/web-ui-source.mjs'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const { createI18nMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs')) +); + const appSource = readBundledWebUiScript(); const claudeConfigModuleSource = readProjectFile('web-ui/modules/app.methods.claude-config.mjs'); @@ -224,7 +233,10 @@ test('addClaudeConfig requires a visible model value before saving', () => { const addClaudeConfig = instantiateFunction(source, 'addClaudeConfig'); const messages = []; let saveCount = 0; + const i18nMethods = createI18nMethods(); const context = { + ...i18nMethods, + lang: 'zh', newClaudeConfig: { name: 'Claude Test', apiKey: 'sk-test', @@ -256,7 +268,10 @@ ${extractMethodAsFunction(appSource, 'claudeConfigFieldError')}`; ${extractMethodAsFunction(appSource, 'canSubmitClaudeConfig')}`; const claudeConfigFieldError = instantiateFunction(fieldErrorSource, 'claudeConfigFieldError'); const canSubmitClaudeConfig = instantiateFunction(canSubmitSource, 'canSubmitClaudeConfig'); + const i18nMethods = createI18nMethods(); const context = { + ...i18nMethods, + lang: 'zh', newClaudeConfig: { name: 'Existing', apiKey: '', @@ -297,7 +312,10 @@ ${extractMethodAsFunction(appSource, 'claudeConfigFieldError')}`; ${extractMethodAsFunction(appSource, 'canSubmitClaudeConfig')}`; const claudeConfigFieldError = instantiateFunction(fieldErrorSource, 'claudeConfigFieldError'); const canSubmitClaudeConfig = instantiateFunction(canSubmitSource, 'canSubmitClaudeConfig'); + const i18nMethods = createI18nMethods(); const context = { + ...i18nMethods, + lang: 'zh', newClaudeConfig: { name: '', apiKey: '', baseUrl: '', model: '' }, editingConfig: { name: 'Imported Auth Token', @@ -350,7 +368,10 @@ test('addClaudeConfig trims and persists the entered model', () => { let saveCount = 0; let closed = false; let refreshed = false; + const i18nMethods = createI18nMethods(); const context = { + ...i18nMethods, + lang: 'zh', newClaudeConfig: { name: 'Claude Test', apiKey: 'sk-test', @@ -548,7 +569,10 @@ test('saveAndApplyConfig writes the edited Claude model through apply api', asyn let saveCount = 0; let closed = false; let refreshCount = 0; + const i18nMethods = createI18nMethods(); const context = { + ...i18nMethods, + lang: 'zh', editingConfig: { name: 'UI Claude Use', apiKey: 'sk-test', @@ -607,7 +631,10 @@ test('saveAndApplyConfig saves external credential config without api key', asyn let saveCount = 0; let closed = false; let refreshCount = 0; + const i18nMethods = createI18nMethods(); const context = { + ...i18nMethods, + lang: 'zh', editingConfig: { name: 'Imported Auth Token', apiKey: '', @@ -654,7 +681,10 @@ test('applyClaudeConfig reports informative message for external credential only const messages = []; let refreshCount = 0; + const i18nMethods = createI18nMethods(); const context = { + ...i18nMethods, + lang: 'zh', claudeConfigs: { imported: { apiKey: '', @@ -689,7 +719,10 @@ test('onClaudeModelChange applies external credential config without api key', ( let updateCount = 0; const applyCalls = []; const messages = []; + const i18nMethods = createI18nMethods(); const context = { + ...i18nMethods, + lang: 'zh', currentClaudeConfig: 'imported', currentClaudeModel: ' claude-opus-4-6 ', claudeConfigs: { @@ -735,7 +768,10 @@ test('onClaudeModelChange still requires api key when no external credential is let updateCount = 0; const applyCalls = []; const messages = []; + const i18nMethods = createI18nMethods(); const context = { + ...i18nMethods, + lang: 'zh', currentClaudeConfig: 'local', currentClaudeModel: ' claude-opus-4-6 ', claudeConfigs: { diff --git a/tests/unit/openclaw-persist-regression.test.mjs b/tests/unit/openclaw-persist-regression.test.mjs index b3724dfd..62ff7e90 100644 --- a/tests/unit/openclaw-persist-regression.test.mjs +++ b/tests/unit/openclaw-persist-regression.test.mjs @@ -8,6 +8,9 @@ const __dirname = path.dirname(__filename); const { createOpenclawPersistMethods, DEFAULT_OPENCLAW_CONFIG_NAME } = await import( pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.openclaw-persist.mjs')) ); +const { createI18nMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs')) +); function deferred() { let resolve; @@ -21,7 +24,9 @@ function deferred() { function createContext(methods, overrides = {}) { return { + ...createI18nMethods(), ...methods, + lang: 'zh', openclawConfigs: { saved: { content: 'saved-local' diff --git a/tests/unit/provider-switch-regression.test.mjs b/tests/unit/provider-switch-regression.test.mjs index ef2b9ed3..19609c82 100644 --- a/tests/unit/provider-switch-regression.test.mjs +++ b/tests/unit/provider-switch-regression.test.mjs @@ -1,9 +1,18 @@ import assert from 'assert'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; import { captureCurrentBundledAppOptions, withGlobalOverrides } from './helpers/web-ui-app-options.mjs'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const { createI18nMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs')) +); + const currentAppOptions = await captureCurrentBundledAppOptions(); const currentMethods = currentAppOptions.methods; const currentComputed = currentAppOptions.computed; @@ -13,6 +22,8 @@ function createProviderSwitchContext() { const messages = []; return { + ...createI18nMethods(), + lang: 'zh', currentProvider: 'alpha', currentModel: 'alpha-model', models: ['alpha-model'], @@ -58,6 +69,8 @@ function createProviderSwitchContext() { function createProviderUpdateContext() { const messages = []; return { + ...createI18nMethods(), + lang: 'zh', editingProvider: { name: 'alpha', url: ' https://api.example.com/v1 ', diff --git a/tests/unit/providers-validation.test.mjs b/tests/unit/providers-validation.test.mjs index da40ed0f..3d42dedf 100644 --- a/tests/unit/providers-validation.test.mjs +++ b/tests/unit/providers-validation.test.mjs @@ -1,11 +1,22 @@ import assert from 'assert'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; import { createProvidersMethods } from '../../web-ui/modules/app.methods.providers.mjs'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const { createI18nMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs')) +); + function createContext(overrides = {}, apiImpl = async () => ({ success: true })) { const messages = []; const loadAllCalls = []; const methods = createProvidersMethods({ api: apiImpl }); const context = { + ...createI18nMethods(), + lang: 'zh', providersList: [], codexAuthProfiles: [], showAddModal: true, diff --git a/tests/unit/run.mjs b/tests/unit/run.mjs index 7376712f..06085f8c 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -25,6 +25,7 @@ await import(pathToFileURL(path.join(__dirname, 'config-tabs-ui.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'compact-layout-ui.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'web-ui-source-bundle.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'startup-claude-star-prompt.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'toast-message-translation.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'install-methods.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'cli-help.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'analytics-export-args.test.mjs'))); diff --git a/tests/unit/session-trash-state.test.mjs b/tests/unit/session-trash-state.test.mjs index 187c48d9..88e5f54e 100644 --- a/tests/unit/session-trash-state.test.mjs +++ b/tests/unit/session-trash-state.test.mjs @@ -1,5 +1,6 @@ import assert from 'assert'; import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; import { readBundledWebUiCss, readBundledWebUiHtml, @@ -7,6 +8,13 @@ import { readProjectFile } from './helpers/web-ui-source.mjs'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const { createI18nMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs')) +); + const appSource = readBundledWebUiScript(); const cliSource = readProjectFile('cli.js'); const indexHtmlSource = readBundledWebUiHtml(); @@ -1904,7 +1912,10 @@ test('cloneSession keeps success message when refresh fails after clone succeeds }); const messages = []; + const i18nMethods = createI18nMethods(); const context = { + ...i18nMethods, + lang: 'zh', sessionCloning: {}, sessionsList: [], isCloneAvailable: () => true, @@ -1941,7 +1952,10 @@ test('cloneSession keeps success message when selecting the cloned session fails }); const messages = []; + const i18nMethods = createI18nMethods(); const context = { + ...i18nMethods, + lang: 'zh', sessionCloning: {}, sessionsList: [], isCloneAvailable: () => true, diff --git a/tests/unit/toast-message-translation.test.mjs b/tests/unit/toast-message-translation.test.mjs new file mode 100644 index 00000000..6874ed2b --- /dev/null +++ b/tests/unit/toast-message-translation.test.mjs @@ -0,0 +1,30 @@ +import assert from 'assert'; +import { translateUiMessage } from '../../web-ui/modules/app.methods.runtime.mjs'; + +const zhContext = { + t(key) { + const table = { + 'toast.copy.ok': '已复制', + 'toast.operation.success': '操作成功', + 'toast.apply.fail': '应用配置失败', + 'toast.provider.addFail': '添加失败' + }; + return table[key] || key; + } +}; + +test('translateUiMessage uses current toast i18n keys for copied-link prefixes', () => { + assert.strictEqual(translateUiMessage(zhContext, '已复制链接'), '已复制链接'); + assert.strictEqual(translateUiMessage(zhContext, '已复制路径'), '已复制路径'); +}); + +test('translateUiMessage uses current toast i18n keys for exact messages', () => { + assert.strictEqual(translateUiMessage(zhContext, '操作成功'), '操作成功'); + assert.strictEqual(translateUiMessage(zhContext, '应用失败'), '应用配置失败'); + assert.strictEqual(translateUiMessage(zhContext, '添加失败'), '添加失败'); +}); + +test('translateUiMessage falls back to the original text when a mapped key is missing', () => { + assert.strictEqual(translateUiMessage(zhContext, '配置已加载'), '配置已加载'); + assert.strictEqual(translateUiMessage(zhContext, '配置已加载,请刷新'), '配置已加载,请刷新'); +}); diff --git a/tests/unit/web-run-host.test.mjs b/tests/unit/web-run-host.test.mjs index 5d831a6c..27e04fb9 100644 --- a/tests/unit/web-run-host.test.mjs +++ b/tests/unit/web-run-host.test.mjs @@ -135,10 +135,19 @@ const releaseRunPortIfNeededSource = extractFunctionBySignature( 'function releaseRunPortIfNeeded(port, host, deps = {}) {', 'releaseRunPortIfNeeded' ); +const isProtectedWebSurfacePathSource = extractFunctionBySignature( + cliContent, + 'function isProtectedWebSurfacePath(requestPath) {', + 'isProtectedWebSurfacePath' +); const resolveWebHost = instantiateFunction(resolveWebHostSource, 'resolveWebHost', { DEFAULT_WEB_HOST: defaultHostMatch[1], process: { env: {} } }); +const isProtectedWebSurfacePath = instantiateFunction( + isProtectedWebSurfacePathSource, + 'isProtectedWebSurfacePath' +); test('resolveWebHost defaults to LAN host', () => { assert.strictEqual(resolveWebHost({}), '127.0.0.1'); @@ -891,6 +900,7 @@ function mockWriteJsonResponse(res, statusCode, payload, headers = {}) { function mockIsProtectedWebSurfacePath(requestPath) { return requestPath === '/' + || requestPath === '/session' || requestPath === '/web-ui/index.html' || requestPath.startsWith('/web-ui/') || requestPath.startsWith('/res/'); @@ -932,6 +942,11 @@ test('assertRequestAuthorized returns a basic auth challenge for unauthorized re assert.strictEqual(response.body, '{\n "error": "Unauthorized"\n}'); }); +test('isProtectedWebSurfacePath protects standalone session links', () => { + assert.strictEqual(isProtectedWebSurfacePath('/session'), true); + assert.strictEqual(isProtectedWebSurfacePath('/session/extra'), false); +}); + test('resolveSkillTarget still falls back to default target when target is omitted', () => { assert.deepStrictEqual(resolveSkillTarget({}), SKILL_TARGETS[0]); assert.deepStrictEqual(resolveSkillTarget({ items: [] }), SKILL_TARGETS[0]); @@ -984,6 +999,44 @@ test('createWebServer redirects bundled index URL to the canonical root URL', () assert.deepStrictEqual(errors, []); }); +test('createWebServer serves the SPA shell for standalone session links', () => { + const { requestHandler, errors } = createWebServerHarness({ + htmlReader() { + return 'standalone session'; + } + }); + const response = createMockResponse(); + + requestHandler({ url: '/session?source=codex&sessionId=session-1' }, response); + + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body, 'standalone session'); + assert.deepStrictEqual(errors, []); +}); + +test('createWebServer requires auth before serving standalone session links to remote clients', () => { + const calls = []; + const { requestHandler, errors } = createWebServerHarness({ + htmlReader() { + throw new Error('html should not be read before auth'); + }, + authorizeRequest(req, res) { + calls.push(req.url); + res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end('{"error":"Unauthorized"}'); + return { ok: false, mode: 'unauthorized' }; + } + }); + const response = createMockResponse(); + + requestHandler({ url: '/session?source=codex&sessionId=session-1', socket: { remoteAddress: '192.0.2.10' } }, response); + + assert.strictEqual(response.statusCode, 401); + assert.deepStrictEqual(calls, ['/session?source=codex&sessionId=session-1']); + assert.strictEqual(response.body, '{"error":"Unauthorized"}'); + assert.deepStrictEqual(errors, []); +}); + test('createWebServer requires auth before serving root page to remote clients', () => { const calls = []; const { requestHandler, errors } = createWebServerHarness({ diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index 4e3b1598..a84ad213 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -1,10 +1,19 @@ import assert from 'assert'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; import { captureBehaviorParityBaselineAppOptions, captureCurrentBundledAppOptions, withGlobalOverrides } from './helpers/web-ui-app-options.mjs'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const { createI18nMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs')) +); + const currentAppOptions = await captureCurrentBundledAppOptions(); const parityBaseline = await captureBehaviorParityBaselineAppOptions(); const headAppOptions = parityBaseline.options; @@ -289,6 +298,8 @@ function createDownloadEnvironment() { function createCopyActionContext(methods) { const messages = createMessagesRecorder(); return { + ...createI18nMethods(), + lang: 'zh', ...messages, sessionResumeWithYolo: true, shareCommandPrefix: 'npm start', diff --git a/tests/unit/web-ui-startup-init.test.mjs b/tests/unit/web-ui-startup-init.test.mjs index e3d0b6fd..cfead0c3 100644 --- a/tests/unit/web-ui-startup-init.test.mjs +++ b/tests/unit/web-ui-startup-init.test.mjs @@ -203,3 +203,148 @@ test('mounted skips auxiliary startup requests when loadAll fails', async () => assert.strictEqual(syncDefaultOpenclawCalls, 0); }); }); + +function createStartupMountContext(overrides = {}) { + return { + mainTab: 'dashboard', + configMode: 'codex', + settingsTab: 'general', + sessionResumeWithYolo: true, + claudeConfigs: {}, + openclawConfigs: { + '默认配置': { + content: '' + } + }, + currentOpenclawConfig: '', + switchedTabs: [], + initSessionStandaloneCalls: [], + initSessionStandalone() { + this.initSessionStandaloneCalls.push(window.location.href); + }, + switchMainTab(tab) { + this.switchedTabs.push(tab); + this.mainTab = tab; + }, + updateCompactLayoutMode() {}, + restoreSessionFilterCache() {}, + restoreSessionPinnedMap() {}, + normalizeShareCommandPrefix(value) { + return value || 'npm start'; + }, + normalizeSessionTrashEnabled(value) { + return value !== '0' && value !== 'false'; + }, + normalizeSessionTrashRetentionDays(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 1) return 30; + return Math.min(365, Math.max(1, Math.floor(numeric))); + }, + onWindowResize() {}, + handleGlobalKeydown() {}, + handleBeforeUnload() {}, + refreshClaudeSelectionFromSettings() { + return Promise.resolve(); + }, + syncDefaultOpenclawConfigEntry() { + return Promise.resolve(); + }, + loadAll() { + return Promise.resolve(true); + }, + ...overrides + }; +} + +function createMutableWindowLocation(initialHref) { + const location = { + href: '', + origin: '', + pathname: '', + search: '', + hash: '', + replace(nextHref) { + apply(nextHref); + } + }; + function apply(nextHref) { + const url = new URL(nextHref, location.href || initialHref); + location.href = url.href; + location.origin = url.origin; + location.pathname = url.pathname; + location.search = url.search; + location.hash = url.hash; + } + apply(initialHref); + return { location, apply }; +} + +function createStartupMountGlobals(initialHref, replacements = []) { + const mutable = createMutableWindowLocation(initialHref); + return { + document: { + readyState: 'complete' + }, + localStorage: { + getItem() { + return null; + }, + setItem() {}, + removeItem() {} + }, + window: { + location: mutable.location, + history: { + replaceState(_state, _title, nextHref) { + replacements.push(String(nextHref)); + mutable.apply(nextHref); + } + }, + addEventListener() {}, + removeEventListener() {} + }, + requestAnimationFrame(callback) { + return callback; + }, + cancelAnimationFrame() {}, + setTimeout(callback) { + return callback; + }, + clearTimeout() {} + }; +} + +test('mounted preserves standalone session query parameters before standalone initialization', async () => { + const appOptions = await captureCurrentBundledAppOptions(); + const context = createStartupMountContext(); + const replacements = []; + + await withGlobalOverrides( + createStartupMountGlobals('http://127.0.0.1:3737/session?source=codex&sessionId=pr187-browser-link', replacements), + async () => { + appOptions.mounted.call(context); + } + ); + + assert.deepStrictEqual(replacements, []); + assert.deepStrictEqual(context.initSessionStandaloneCalls, [ + 'http://127.0.0.1:3737/session?source=codex&sessionId=pr187-browser-link' + ]); +}); + +test('mounted consumes shareable sessions tab URLs before canonical cleanup', async () => { + const appOptions = await captureCurrentBundledAppOptions(); + const context = createStartupMountContext(); + const replacements = []; + + await withGlobalOverrides( + createStartupMountGlobals('http://127.0.0.1:3737/?tab=sessions', replacements), + async () => { + appOptions.mounted.call(context); + } + ); + + assert.deepStrictEqual(replacements, []); + assert.deepStrictEqual(context.switchedTabs, ['sessions']); + assert.strictEqual(context.mainTab, 'sessions'); +}); diff --git a/web-ui/app.js b/web-ui/app.js index 37593db4..7126f3ae 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -366,7 +366,21 @@ document.addEventListener('DOMContentLoaded', () => { codexDownloadProgress: 0, codexDownloadTimer: null, settingsTab: 'general', - toolConfigPermissions: { codex: false, claude: false }, + toolConfigPermissions: (function() { + try { + const cached = localStorage.getItem('toolConfigPermissions'); + if (cached) { + const parsed = JSON.parse(cached); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { + codex: parsed.codex === true, + claude: parsed.claude === true + }; + } + } + } catch (_) {} + return { codex: false, claude: false }; + })(), toolConfigPermissionSaving: { codex: false, claude: false }, sessionTrashEnabled: true, sessionTrashItems: [], @@ -445,13 +459,9 @@ document.addEventListener('DOMContentLoaded', () => { window.location.replace(url.toString()); return; } - // 清理任何查询参数和 hash,保持 URL 为 / - if (window.location.search || window.location.hash) { - const url = new URL(window.location.href); - url.search = ''; - url.hash = ''; - window.history.replaceState(null, '', url.toString()); - } + // Do not strip query/hash during startup: /session uses them to identify the + // standalone session, and shareable tab/filter URLs are consumed below before + // later runtime canonicalization can clean the address bar. } catch (_) {} if (typeof this.initI18n === 'function') { diff --git a/web-ui/modules/app.methods.agents.mjs b/web-ui/modules/app.methods.agents.mjs index f89e2a42..4374f28a 100644 --- a/web-ui/modules/app.methods.agents.mjs +++ b/web-ui/modules/app.methods.agents.mjs @@ -60,7 +60,7 @@ export function createAgentsMethods(options = {}) { if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { return; } - this.showMessage('加载文件失败', 'error'); + this.showMessage(this.t('toast.load.fail'), 'error'); } finally { if (isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { this.agentsLoading = false; @@ -92,7 +92,7 @@ export function createAgentsMethods(options = {}) { if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { return; } - this.showMessage('加载文件失败', 'error'); + this.showMessage(this.t('toast.load.fail'), 'error'); } finally { if (isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { this.agentsLoading = false; @@ -127,7 +127,7 @@ export function createAgentsMethods(options = {}) { if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { return; } - this.showMessage('加载文件失败', 'error'); + this.showMessage(this.t('toast.load.fail'), 'error'); } finally { if (isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { this.agentsLoading = false; @@ -171,7 +171,7 @@ export function createAgentsMethods(options = {}) { if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { return; } - this.showMessage('加载文件失败', 'error'); + this.showMessage(this.t('toast.load.fail'), 'error'); } finally { if (isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) { this.agentsLoading = false; @@ -588,7 +588,7 @@ export function createAgentsMethods(options = {}) { } if (!this.agentsDiffVisible) { if (!this.hasAgentsContentChanged()) { - this.showMessage('未检测到改动', 'info'); + this.showMessage(this.t('toast.noChanges'), 'info'); return; } await this.prepareAgentsDiff(); @@ -642,7 +642,7 @@ export function createAgentsMethods(options = {}) { this.showMessage(successLabel, 'success'); this.closeAgentsModal({ force: true }); } catch (e) { - this.showMessage('保存失败', 'error'); + this.showMessage(this.t('toast.save.fail'), 'error'); } finally { this.agentsSaving = false; } diff --git a/web-ui/modules/app.methods.claude-config.mjs b/web-ui/modules/app.methods.claude-config.mjs index d1f0c4e2..7c25bbb9 100644 --- a/web-ui/modules/app.methods.claude-config.mjs +++ b/web-ui/modules/app.methods.claude-config.mjs @@ -31,23 +31,23 @@ function getClaudeConfigValidationForContext(vm, mode = 'add') { }; if (!name) { - errors.name = '配置名称不能为空'; + errors.name = vm.t('validation.claude.nameRequired'); } else if (mode === 'add' && vm.claudeConfigs && vm.claudeConfigs[name]) { - errors.name = '名称已存在'; + errors.name = vm.t('validation.claude.nameExists'); } if (!apiKey && !externalCredentialType) { - errors.apiKey = 'API Key 必填'; + errors.apiKey = vm.t('validation.claude.apiKeyRequired'); } if (!baseUrl) { - errors.baseUrl = 'Base URL 必填'; + errors.baseUrl = vm.t('validation.claude.baseUrlRequired'); } else if (!isValidClaudeHttpUrl(baseUrl)) { - errors.baseUrl = 'Base URL 仅支持 http/https'; + errors.baseUrl = vm.t('validation.claude.baseUrlHttpOnly'); } if (!model) { - errors.model = '模型名称必填'; + errors.model = vm.t('validation.claude.modelRequired'); } return { @@ -79,7 +79,7 @@ export function createClaudeConfigMethods(options = {}) { } const model = (this.currentClaudeModel || '').trim(); if (!model) { - this.showMessage('请输入模型', 'error'); + this.showMessage(this.t('toast.claude.modelRequired'), 'error'); return; } const existing = this.claudeConfigs[name] || {}; @@ -89,7 +89,7 @@ export function createClaudeConfigMethods(options = {}) { this.saveClaudeConfigs(); this.updateClaudeModelsCurrent(); if (!this.claudeConfigs[name].apiKey && !this.claudeConfigs[name].externalCredentialType) { - this.showMessage('请先配置 API Key', 'error'); + this.showMessage(this.t('toast.claude.apiKeyRequired'), 'error'); return; } this.applyClaudeConfig(name); @@ -153,7 +153,7 @@ export function createClaudeConfigMethods(options = {}) { updateConfig() { const validation = getClaudeConfigValidationForContext(this, 'edit'); if (!validation.ok) { - return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || '请检查 Claude 配置', 'error'); + return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || this.t('toast.claude.checkConfig'), 'error'); } const name = validation.name; this.editingConfig.apiKey = validation.apiKey; @@ -161,7 +161,7 @@ export function createClaudeConfigMethods(options = {}) { this.editingConfig.model = validation.model; this.claudeConfigs[name] = this.mergeClaudeConfig(this.claudeConfigs[name], this.editingConfig); this.saveClaudeConfigs(); - this.showMessage('操作成功', 'success'); + this.showMessage(this.t('toast.operation.success'), 'success'); this.closeEditConfigModal(); if (name === this.currentClaudeConfig) { this.refreshClaudeModelContext(); @@ -181,7 +181,7 @@ export function createClaudeConfigMethods(options = {}) { async saveAndApplyConfig() { const validation = getClaudeConfigValidationForContext(this, 'edit'); if (!validation.ok) { - return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || '请检查 Claude 配置', 'error'); + return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || this.t('toast.claude.checkConfig'), 'error'); } const name = validation.name; this.editingConfig.apiKey = validation.apiKey; @@ -192,7 +192,7 @@ export function createClaudeConfigMethods(options = {}) { const config = this.claudeConfigs[name]; if (!config.apiKey) { - this.showMessage('已保存(未填写 API Key)', 'info'); + this.showMessage(this.t('toast.claude.savedWithoutKey'), 'info'); this.closeEditConfigModal(); if (name === this.currentClaudeConfig) { this.refreshClaudeModelContext(); @@ -204,25 +204,25 @@ export function createClaudeConfigMethods(options = {}) { try { const res = await api('apply-claude-config', { config }); if (res.error || res.success === false) { - this.showMessage(res.error || '应用配置失败', 'error'); + this.showMessage(res.error || this.t('toast.apply.fail'), 'error'); } else { this.currentClaudeConfig = name; if (this._lastAppliedClaudeKey !== _claudeKey) { - this.showMessage('Claude 配置已生效', 'success'); + this.showMessage(this.t('toast.claude.applied'), 'success'); this._lastAppliedClaudeKey = _claudeKey; } this.closeEditConfigModal(); this.refreshClaudeModelContext(); } } catch (_) { - this.showMessage('应用配置失败', 'error'); + this.showMessage(this.t('toast.apply.fail'), 'error'); } }, addClaudeConfig() { const validation = getClaudeConfigValidationForContext(this, 'add'); if (!validation.ok) { - return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || '请检查 Claude 配置', 'error'); + return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || this.t('toast.claude.checkConfig'), 'error'); } this.newClaudeConfig.name = validation.name; this.newClaudeConfig.apiKey = validation.apiKey; @@ -231,27 +231,27 @@ export function createClaudeConfigMethods(options = {}) { const name = validation.name; const duplicateName = this.findDuplicateClaudeConfigName(this.newClaudeConfig); if (duplicateName) { - return this.showMessage('配置已存在', 'info'); + return this.showMessage(this.t('toast.claude.exists'), 'info'); } this.claudeConfigs[name] = this.mergeClaudeConfig({}, this.newClaudeConfig); this.currentClaudeConfig = name; this.saveClaudeConfigs(); - this.showMessage('操作成功', 'success'); + this.showMessage(this.t('toast.operation.success'), 'success'); this.closeClaudeConfigModal(); this.refreshClaudeModelContext(); }, async deleteClaudeConfig(name) { if (Object.keys(this.claudeConfigs).length <= 1) { - return this.showMessage('至少保留一项', 'error'); + return this.showMessage(this.t('toast.claude.keepOne'), 'error'); } const confirmed = await this.requestConfirmDialog({ - title: '删除 Claude 配置', - message: `确定删除配置 "${name}"?`, - confirmText: '删除', - cancelText: '取消', + title: this.t('modal.claudeDelete.title'), + message: this.t('modal.claudeDelete.message', { name }), + confirmText: this.t('modal.claudeDelete.confirm'), + cancelText: this.t('modal.claudeDelete.cancel'), danger: true }); if (!confirmed) return; @@ -261,7 +261,7 @@ export function createClaudeConfigMethods(options = {}) { this.currentClaudeConfig = Object.keys(this.claudeConfigs)[0]; } this.saveClaudeConfigs(); - this.showMessage('操作成功', 'success'); + this.showMessage(this.t('toast.operation.success'), 'success'); this.refreshClaudeModelContext(); }, @@ -273,24 +273,24 @@ export function createClaudeConfigMethods(options = {}) { if (!config.apiKey) { if (config.externalCredentialType) { - return this.showMessage('使用外部认证,无需 API Key', 'info'); + return this.showMessage(this.t('toast.claude.externalAuth'), 'info'); } - return this.showMessage('请先配置 API Key', 'error'); + return this.showMessage(this.t('toast.claude.apiKeyRequired'), 'error'); } const _claudeKey2 = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}`; try { const res = await api('apply-claude-config', { config }); if (res.error || res.success === false) { - this.showMessage(res.error || '应用配置失败', 'error'); + this.showMessage(res.error || this.t('toast.apply.fail'), 'error'); } else { if (this._lastAppliedClaudeKey !== _claudeKey2) { - this.showMessage('配置已应用', 'success'); + this.showMessage(this.t('toast.apply.success'), 'success'); this._lastAppliedClaudeKey = _claudeKey2; } } } catch (_) { - this.showMessage('应用配置失败', 'error'); + this.showMessage(this.t('toast.apply.fail'), 'error'); } }, @@ -328,12 +328,12 @@ export function createClaudeConfigMethods(options = {}) { return; } if (enable) { - this.showMessage('Claude 本地负载均衡已启用', 'success'); + this.showMessage(this.t('toast.claude.balanceEnabled'), 'success'); } else { - this.showMessage('Claude 本地负载均衡已关闭', 'success'); + this.showMessage(this.t('toast.claude.balanceDisabled'), 'success'); } } catch (e) { - this.showMessage('操作失败', 'error'); + this.showMessage(this.t('toast.operation.fail'), 'error'); } }, @@ -379,18 +379,18 @@ export function createClaudeConfigMethods(options = {}) { const candidates = this.claudeLocalBridgeCandidateProviders(); if (candidates.length === 0) { - return this.showMessage('请先添加并配置至少一个 Claude 提供商', 'error'); + return this.showMessage(this.t('toast.claude.balanceRequireProvider'), 'error'); } try { const res = await api('claude-local-bridge-toggle', { enable: true }); if (res.error) { - this.showMessage(res.error || '启用本地负载均衡失败', 'error'); + this.showMessage(res.error || this.t('toast.claude.balanceEnableFail'), 'error'); return; } - this.showMessage('Claude 本地负载均衡已启用', 'success'); + this.showMessage(this.t('toast.claude.balanceEnabled'), 'success'); } catch (e) { - this.showMessage('启用本地负载均衡失败', 'error'); + this.showMessage(this.t('toast.claude.balanceEnableFail'), 'error'); } }, @@ -405,7 +405,7 @@ export function createClaudeConfigMethods(options = {}) { this.configTemplateContext = 'claude'; this.showConfigTemplateModal = true; } catch (e) { - this.showMessage('加载 Claude settings 失败', 'error'); + this.showMessage(this.t('toast.claude.loadSettingsFail'), 'error'); } } }; diff --git a/web-ui/modules/app.methods.codex-config.mjs b/web-ui/modules/app.methods.codex-config.mjs index 3f0faded..f5fc87c7 100644 --- a/web-ui/modules/app.methods.codex-config.mjs +++ b/web-ui/modules/app.methods.codex-config.mjs @@ -66,7 +66,7 @@ export function createCodexConfigMethods(options = {}) { const maxLabel = res.maxMessages === 'all' ? 'all' : res.maxMessages; this.showMessage(`会话导出完成(已截断:最多 ${maxLabel} 条消息)`, 'info'); } else { - this.showMessage('操作成功', 'success'); + this.showMessage(this.t('toast.operation.success'), 'success'); } } catch (e) { this.showMessage('导出失败', 'error'); @@ -325,7 +325,7 @@ export function createCodexConfigMethods(options = {}) { if (hasResponseError(res)) { this.healthCheckResult = null; if (!silent) { - this.showMessage(getResponseMessage(res, '检查失败'), 'error'); + this.showMessage(getResponseMessage(res, this.t('toast.check.fail')), 'error'); } return; } @@ -342,13 +342,13 @@ export function createCodexConfigMethods(options = {}) { this.healthCheckBatchDone = total; this.healthCheckBatchFailed = errors + warns; if (!silent && res.ok) { - this.showMessage('检查通过', 'success'); + this.showMessage(this.t('toast.check.success'), 'success'); } return; } this.healthCheckResult = null; if (!silent) { - this.showMessage('检查失败', 'error'); + this.showMessage(this.t('toast.check.fail'), 'error'); } return; } @@ -473,7 +473,7 @@ export function createCodexConfigMethods(options = {}) { } else { this.healthCheckResult = null; if (!silent) { - this.showMessage('检查失败', 'error'); + this.showMessage(this.t('toast.check.fail'), 'error'); } } } catch (e) { @@ -561,7 +561,7 @@ export function createCodexConfigMethods(options = {}) { this.configTemplateContext = 'codex'; this.showConfigTemplateModal = true; } catch (e) { - this.showMessage('加载模板失败', 'error'); + this.showMessage(this.t('toast.template.loadFail'), 'error'); } }, @@ -797,7 +797,7 @@ export function createCodexConfigMethods(options = {}) { return; } if (!this.configTemplateContent || !this.configTemplateContent.trim()) { - this.showMessage('模板不能为空', 'error'); + this.showMessage(this.t('toast.template.empty'), 'error'); return; } @@ -822,15 +822,15 @@ export function createCodexConfigMethods(options = {}) { this.showMessage(res.error, 'error'); return; } - this.showMessage('模板已应用', 'success'); + this.showMessage(this.t('toast.template.applied'), 'success'); this.closeConfigTemplateModal({ force: true }); try { await this.loadAll(); } catch (_) { - this.showMessage('模板已应用,但界面刷新失败,请手动刷新', 'error'); + this.showMessage(this.t('toast.template.appliedButRefreshFail'), 'error'); } } catch (e) { - this.showMessage('应用模板失败', 'error'); + this.showMessage(this.t('toast.template.applyFail'), 'error'); } finally { this.configTemplateApplying = false; } diff --git a/web-ui/modules/app.methods.openclaw-persist.mjs b/web-ui/modules/app.methods.openclaw-persist.mjs index 929e1d8d..f9142ea4 100644 --- a/web-ui/modules/app.methods.openclaw-persist.mjs +++ b/web-ui/modules/app.methods.openclaw-persist.mjs @@ -274,7 +274,7 @@ export function createOpenclawPersistMethods(options = {}) { try { const name = this.persistOpenclawConfig(); if (!name) return; - this.showMessage('操作成功', 'success'); + this.showMessage(this.t('toast.operation.success'), 'success'); } finally { this.openclawSaving = false; } diff --git a/web-ui/modules/app.methods.providers.mjs b/web-ui/modules/app.methods.providers.mjs index b01e95d7..c2fb76ea 100644 --- a/web-ui/modules/app.methods.providers.mjs +++ b/web-ui/modules/app.methods.providers.mjs @@ -170,7 +170,7 @@ export function createProvidersMethods(options = {}) { normalizeProviderDraftState(this.newProvider); const validation = getProviderValidationForContext(this, 'add'); if (!validation.ok) { - return this.showMessage(validation.errors.name || validation.errors.url || validation.errors.key || validation.errors.model || '名称、URL、API Key 和模型名称必填', 'error'); + return this.showMessage(validation.errors.name || validation.errors.url || validation.errors.key || validation.errors.model || this.t('toast.provider.fieldsRequired'), 'error'); } try { @@ -206,7 +206,7 @@ export function createProvidersMethods(options = {}) { }; this.providersList = [...this.providersList, newProvider]; - this.showMessage('操作成功', 'success'); + this.showMessage(this.t('toast.operation.success'), 'success'); this.closeAddModal(); if (suggestedModel) { @@ -214,7 +214,7 @@ export function createProvidersMethods(options = {}) { this.currentModels[validation.name] = suggestedModel; } } catch (e) { - this.showMessage('添加失败', 'error'); + this.showMessage(this.t('toast.provider.addFail'), 'error'); } }, @@ -275,7 +275,7 @@ export function createProvidersMethods(options = {}) { async deleteProvider(name) { if (this.isNonDeletableProvider(name)) { - this.showMessage('该 provider 为保留项,不可删除', 'info'); + this.showMessage(this.t('toast.provider.notDeletable'), 'info'); return; } try { @@ -301,12 +301,13 @@ export function createProvidersMethods(options = {}) { ...p, current: p.name === res.provider })); - this.showMessage(`已删除提供商,自动切换到 ${res.provider}${res.model ? ` / ${res.model}` : ''}`, 'success'); + const modelSuffix = res.model ? ` / ${res.model}` : ''; + this.showMessage(this.t('toast.provider.deletedAndSwitched', { provider: res.provider, model: modelSuffix }), 'success'); } else { - this.showMessage('操作成功', 'success'); + this.showMessage(this.t('toast.operation.success'), 'success'); } } catch (_) { - this.showMessage('删除失败', 'error'); + this.showMessage(this.t('toast.delete.fail'), 'error'); } }, @@ -330,7 +331,7 @@ export function createProvidersMethods(options = {}) { const requestId = Symbol('openEditModal'); this._openEditModalRequestId = requestId; if (!this.shouldShowProviderEdit(provider)) { - this.showMessage('该 provider 为保留项,不可编辑', 'info'); + this.showMessage(this.t('toast.provider.notEditable'), 'info'); return; } const isTransformProvider = (() => { @@ -398,14 +399,14 @@ export function createProvidersMethods(options = {}) { async updateProvider() { if (this.editingProvider.readOnly || this.editingProvider.nonEditable) { - this.showMessage('该 provider 为保留项,不可编辑', 'error'); + this.showMessage(this.t('toast.provider.notEditable'), 'error'); this.closeEditModal(); return; } normalizeProviderDraftState(this.editingProvider); const validation = getProviderValidationForContext(this, 'edit'); if (!validation.ok) { - return this.showMessage(validation.errors.name || validation.errors.url || 'URL 必填', 'error'); + return this.showMessage(validation.errors.name || validation.errors.url || this.t('toast.provider.urlRequired'), 'error'); } const params = { name: validation.name, url: validation.url }; @@ -441,9 +442,9 @@ export function createProvidersMethods(options = {}) { }); this.closeEditModal(); - this.showMessage('操作成功', 'success'); + this.showMessage(this.t('toast.operation.success'), 'success'); } catch (e) { - this.showMessage('更新失败', 'error'); + this.showMessage(this.t('toast.provider.updateFail'), 'error'); } }, @@ -469,10 +470,10 @@ export function createProvidersMethods(options = {}) { return; } const backup = res.backupFile ? `(已备份: ${res.backupFile})` : ''; - this.showMessage(`配置已重装${backup}`, 'success'); + this.showMessage(this.t('toast.provider.resetSuccess', { backup }), 'success'); await this.loadAll(); } catch (e) { - this.showMessage('重装失败', 'error'); + this.showMessage(this.t('toast.provider.resetFail'), 'error'); } finally { this.resetConfigLoading = false; } @@ -501,7 +502,7 @@ export function createProvidersMethods(options = {}) { } return p; }); - this.showMessage('操作成功', 'success'); + this.showMessage(this.t('toast.operation.success'), 'success'); this.closeModelModal(); } } catch (_) { @@ -525,7 +526,7 @@ export function createProvidersMethods(options = {}) { } return p; }); - this.showMessage('操作成功', 'success'); + this.showMessage(this.t('toast.operation.success'), 'success'); } } catch (_) { this.showMessage('删除模型失败', 'error'); diff --git a/web-ui/modules/app.methods.runtime.mjs b/web-ui/modules/app.methods.runtime.mjs index cc5e66c2..34bbb103 100644 --- a/web-ui/modules/app.methods.runtime.mjs +++ b/web-ui/modules/app.methods.runtime.mjs @@ -4,21 +4,21 @@ import { } from '../logic.mjs'; const UI_MESSAGE_KEY_BY_TEXT = Object.freeze({ - '操作成功': 'toast.operationSuccess', - '操作失败': 'toast.operationFailed', - '添加失败': 'toast.addFailed', - '更新失败': 'toast.updateFailed', - '删除失败': 'toast.deleteFailed', - '已删除': 'toast.deleted', - '已复制': 'toast.copied', - '复制失败': 'toast.copyFailed', + '操作成功': 'toast.operation.success', + '操作失败': 'toast.operation.fail', + '添加失败': 'toast.provider.addFail', + '更新失败': 'toast.provider.updateFail', + '删除失败': 'toast.delete.fail', + '已删除': 'toast.delete.ok', + '已复制': 'toast.copy.ok', + '复制失败': 'toast.copy.fail', '剪贴板为空': 'toast.clipboardEmpty', '无法读取剪贴板': 'toast.clipboardReadFailed', '已粘贴': 'toast.pasted', '未检测到改动': 'toast.noChanges', - '配置已应用': 'toast.configApplied', - '应用配置失败': 'toast.applyConfigFailed', - '应用失败': 'toast.applyFailed', + '配置已应用': 'toast.apply.success', + '应用配置失败': 'toast.apply.fail', + '应用失败': 'toast.apply.fail', '配置已加载': 'toast.configLoaded', '配置就绪': 'toast.configReady', '加载配置失败': 'toast.loadConfigFailed', @@ -26,12 +26,12 @@ const UI_MESSAGE_KEY_BY_TEXT = Object.freeze({ '读取配置超时': 'toast.readConfigTimeout', '备份失败': 'toast.backupFailed', '备份成功,开始下载': 'toast.backupReadyDownload', - '导入失败': 'toast.importFailed', - '导入成功': 'toast.importSuccess', + '导入失败': 'toast.import.fail', + '导入成功': 'toast.import.ok', '导入 skill 失败': 'toast.importSkillFailed', - '导出失败': 'toast.exportFailed', - '保存失败': 'toast.saveFailed', - '加载文件失败': 'toast.loadFileFailed', + '导出失败': 'toast.export.fail', + '保存失败': 'toast.save.fail', + '加载文件失败': 'toast.load.fail', '请填写名称': 'toast.nameRequired', '请输入名称': 'toast.nameRequired', '名称已存在': 'toast.nameExists', @@ -45,8 +45,8 @@ const UI_MESSAGE_KEY_BY_TEXT = Object.freeze({ '不可分享': 'toast.notShareable', '已移入回收站': 'toast.movedToTrash', '生成命令失败': 'toast.commandGenerationFailed', - '没有可复制内容': 'toast.nothingToCopy', - '没有可导出内容': 'toast.nothingToExport', + '没有可复制内容': 'toast.copy.empty', + '没有可导出内容': 'toast.export.empty', '会话已恢复': 'toast.sessionRestored', '恢复失败': 'toast.restoreFailed', '已彻底删除': 'toast.purged', @@ -66,16 +66,22 @@ const UI_MESSAGE_PREFIX_ENTRIES = Object.freeze( Object.entries(UI_MESSAGE_KEY_BY_TEXT).sort((a, b) => b[0].length - a[0].length) ); -function translateUiMessage(context, text) { +export function translateUiMessage(context, text) { if (!context || typeof context.t !== 'function' || typeof text !== 'string') return text; + const translateKey = (key) => { + const translated = context.t(key); + return typeof translated === 'string' && translated && translated !== key ? translated : ''; + }; const exactKey = UI_MESSAGE_KEY_BY_TEXT[text]; - if (exactKey) return context.t(exactKey); + if (exactKey) return translateKey(exactKey) || text; const prefixEntry = UI_MESSAGE_PREFIX_ENTRIES.find(([sourceText]) => { return text.length > sourceText.length && text.startsWith(sourceText); }); if (!prefixEntry) return text; const [sourceText, key] = prefixEntry; - return `${context.t(key)}${text.slice(sourceText.length)}`; + const translatedPrefix = translateKey(key); + if (!translatedPrefix) return text; + return `${translatedPrefix}${text.slice(sourceText.length)}`; } function clearProgressResetTimer(context, timerKey) { diff --git a/web-ui/modules/app.methods.session-actions.mjs b/web-ui/modules/app.methods.session-actions.mjs index 31edaea7..9864e83d 100644 --- a/web-ui/modules/app.methods.session-actions.mjs +++ b/web-ui/modules/app.methods.session-actions.mjs @@ -126,7 +126,7 @@ export function createSessionActionMethods(options = {}) { return; } } catch (_) {} - this.showMessage('复制失败', 'error'); + this.showMessage(this.t('toast.copy.fail'), 'error'); }, getSessionFilePath(session) { @@ -152,7 +152,7 @@ export function createSessionActionMethods(options = {}) { return; } } catch (_) {} - this.showMessage('复制失败', 'error'); + this.showMessage(this.t('toast.copy.fail'), 'error'); }, getSessionExportKey(session) { @@ -312,15 +312,15 @@ export function createSessionActionMethods(options = {}) { copyAgentsContent() { const text = typeof this.agentsContent === 'string' ? this.agentsContent : ''; if (!text) { - this.showMessage('没有可复制内容', 'info'); + this.showMessage(this.t('toast.copy.empty'), 'info'); return; } const ok = this.fallbackCopyText(text); if (ok) { - this.showMessage('已复制', 'success'); + this.showMessage(this.t('toast.copy.ok'), 'success'); return; } - this.showMessage('复制失败', 'error'); + this.showMessage(this.t('toast.copy.fail'), 'error'); }, exportAgentsContent() { @@ -344,7 +344,7 @@ export function createSessionActionMethods(options = {}) { async copyInstallCommand(cmd) { const text = typeof cmd === 'string' ? cmd.trim() : ''; if (!text) { - this.showMessage('没有可复制内容', 'info'); + this.showMessage(this.t('toast.copy.empty'), 'info'); return; } try { @@ -359,7 +359,7 @@ export function createSessionActionMethods(options = {}) { this.showMessage('已复制命令', 'success'); return; } - this.showMessage('复制失败', 'error'); + this.showMessage(this.t('toast.copy.fail'), 'error'); }, async copyResumeCommand(session) { @@ -370,17 +370,17 @@ export function createSessionActionMethods(options = {}) { const command = this.buildResumeCommand(session); const ok = this.fallbackCopyText(command); if (ok) { - this.showMessage('已复制', 'success'); + this.showMessage(this.t('toast.copy.ok'), 'success'); return; } try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(command); - this.showMessage('已复制', 'success'); + this.showMessage(this.t('toast.copy.ok'), 'success'); return; } } catch (_) {} - this.showMessage('复制失败', 'error'); + this.showMessage(this.t('toast.copy.fail'), 'error'); }, buildProviderShareCommand(payload) { @@ -446,17 +446,17 @@ export function createSessionActionMethods(options = {}) { } const ok = this.fallbackCopyText(command); if (ok) { - this.showMessage('已复制', 'success'); + this.showMessage(this.t('toast.copy.ok'), 'success'); return; } try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(command); - this.showMessage('已复制', 'success'); + this.showMessage(this.t('toast.copy.ok'), 'success'); return; } } catch (_) {} - this.showMessage('复制失败', 'error'); + this.showMessage(this.t('toast.copy.fail'), 'error'); } catch (_) { this.showMessage('生成命令失败', 'error'); } finally { @@ -485,17 +485,17 @@ export function createSessionActionMethods(options = {}) { } const ok = this.fallbackCopyText(command); if (ok) { - this.showMessage('已复制', 'success'); + this.showMessage(this.t('toast.copy.ok'), 'success'); return; } try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(command); - this.showMessage('已复制', 'success'); + this.showMessage(this.t('toast.copy.ok'), 'success'); return; } } catch (_) {} - this.showMessage('复制失败', 'error'); + this.showMessage(this.t('toast.copy.fail'), 'error'); } catch (_) { this.showMessage('生成命令失败', 'error'); } finally { @@ -524,7 +524,7 @@ export function createSessionActionMethods(options = {}) { return; } - this.showMessage('操作成功', 'success'); + this.showMessage(this.t('toast.operation.success'), 'success'); if (typeof this.invalidateSessionsUsageData === 'function') { this.invalidateSessionsUsageData({ preserveList: true }); } @@ -608,7 +608,7 @@ export function createSessionActionMethods(options = {}) { // The delete already succeeded remotely; keep the success result. } } catch (_) { - this.showMessage('删除失败', 'error'); + this.showMessage(this.t('toast.delete.fail'), 'error'); } finally { this.sessionDeleting[key] = false; } diff --git a/web-ui/modules/app.methods.startup-claude.mjs b/web-ui/modules/app.methods.startup-claude.mjs index 444feb44..4a89eed4 100644 --- a/web-ui/modules/app.methods.startup-claude.mjs +++ b/web-ui/modules/app.methods.startup-claude.mjs @@ -125,6 +125,9 @@ export function createStartupClaudeMethods(options = {}) { codex: statusRes.toolConfigPermissions.codex === true, claude: statusRes.toolConfigPermissions.claude === true }; + try { + localStorage.setItem('toolConfigPermissions', JSON.stringify(this.toolConfigPermissions)); + } catch (_) {} } this.providersList = listRes.providers; if (typeof this.loadLocalBridgeExcluded === 'function') { this.loadLocalBridgeExcluded(); } diff --git a/web-ui/modules/app.methods.tool-config-permissions.mjs b/web-ui/modules/app.methods.tool-config-permissions.mjs index 1dbc6d25..174ca4fd 100644 --- a/web-ui/modules/app.methods.tool-config-permissions.mjs +++ b/web-ui/modules/app.methods.tool-config-permissions.mjs @@ -64,6 +64,9 @@ export function createToolConfigPermissionMethods(options = {}) { return; } this.toolConfigPermissions = normalizePermissions(res && res.permissions); + try { + localStorage.setItem('toolConfigPermissions', JSON.stringify(this.toolConfigPermissions)); + } catch (_) {} this.showMessage( nextAllowWrite ? this.t('toolConfig.allowToast') diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs index 86b9baab..b9081eb9 100644 --- a/web-ui/modules/i18n/locales/en.mjs +++ b/web-ui/modules/i18n/locales/en.mjs @@ -61,6 +61,8 @@ const en = Object.freeze({ 'common.notExistsWillCreateOnApply': 'Not found. Will be created on apply.', 'common.notExistsWillCreateOnSave': 'Not found. Will be created on save.', 'common.none': 'None', + 'common.configured': 'Configured', + 'common.notConfigured': 'Not configured', 'cli.missing.title': '{name} CLI not installed', 'cli.missing.subtitle': 'Install {name} CLI before using this page.', 'cli.missing.openDocs': 'Open install guide', @@ -358,7 +360,7 @@ const en = Object.freeze({ 'plugins.builtin.commentPolish.desc': 'Polish the following code comments {{code}}', 'plugins.builtin.commentPolish.line1': 'Polish the following code comments', 'plugins.builtin.ruleAck.name': 'Rule acknowledgement', - 'plugins.builtin.ruleAck.desc': 'Please follow 【{{rule}}】, reply when received', + 'plugins.builtin.ruleAck.desc': 'Generate rule acknowledgement reply', 'plugins.builtin.ruleAck.line1': 'Please follow 【{{rule}}】, reply when received', // Toasts @@ -385,6 +387,50 @@ const en = Object.freeze({ 'toast.templates.nameRequired': 'Template name is required', 'toast.templates.builtinNotDuplicable': 'Built-in templates cannot be duplicated', 'toast.templates.builtinNotDeletable': 'Built-in templates cannot be deleted', + 'toast.operation.success': 'Operation successful', + 'toast.load.fail': 'Failed to load file', + 'toast.apply.success': 'Configuration applied', + 'toast.apply.fail': 'Failed to apply configuration', + 'toast.check.success': 'Check passed', + 'toast.check.fail': 'Check failed', + 'toast.noChanges': 'No changes detected', + 'toast.template.loadFail': 'Failed to load template', + 'toast.template.empty': 'Template cannot be empty', + 'toast.template.applied': 'Template applied', + 'toast.template.appliedButRefreshFail': 'Template applied, but UI refresh failed. Please refresh manually', + 'toast.template.applyFail': 'Failed to apply template', + 'toast.provider.addFail': 'Failed to add', + 'toast.provider.notDeletable': 'This provider is reserved and cannot be deleted', + 'toast.provider.deletedAndSwitched': 'Provider deleted, auto-switched to {provider}{model}', + 'toast.provider.notEditable': 'This provider is reserved and cannot be edited', + 'toast.provider.updateFail': 'Failed to update', + 'toast.provider.resetSuccess': 'Config reset{backup}', + 'toast.provider.resetFail': 'Reset failed', + 'toast.provider.fieldsRequired': 'Name, URL, API Key and model are required', + 'toast.provider.urlRequired': 'URL is required', + 'toast.claude.modelRequired': 'Please enter model', + 'toast.claude.apiKeyRequired': 'Please configure API Key first', + 'toast.claude.checkConfig': 'Please check Claude configuration', + 'toast.claude.savedWithoutKey': 'Saved (API Key not filled)', + 'toast.claude.applied': 'Claude config applied', + 'toast.claude.exists': 'Config already exists', + 'toast.claude.keepOne': 'Keep at least one', + 'toast.claude.externalAuth': 'Using external auth, no API Key needed', + 'toast.claude.balanceEnabled': 'Claude local load balancing enabled', + 'toast.claude.balanceDisabled': 'Claude local load balancing disabled', + 'toast.claude.balanceEnableFail': 'Failed to enable load balancing', + 'toast.claude.balanceRequireProvider': 'Please add and configure at least one Claude provider', + 'toast.claude.loadSettingsFail': 'Failed to load Claude settings', + 'validation.claude.nameRequired': 'Config name is required', + 'validation.claude.nameExists': 'Name already exists', + 'validation.claude.apiKeyRequired': 'API Key is required', + 'validation.claude.baseUrlRequired': 'Base URL is required', + 'validation.claude.baseUrlHttpOnly': 'Base URL only supports http/https', + 'validation.claude.modelRequired': 'Model name is required', + 'modal.claudeDelete.title': 'Delete Claude config', + 'modal.claudeDelete.message': 'Delete config "{name}"?', + 'modal.claudeDelete.confirm': 'Delete', + 'modal.claudeDelete.cancel': 'Cancel', 'toast.templates.deleteTitle': 'Delete template', 'toast.templates.deleteMessage': 'Delete “{name}”? This action cannot be undone.', 'toast.templates.deleteConfirm': 'Delete', diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs index 376af5ed..91d9a89c 100644 --- a/web-ui/modules/i18n/locales/ja.mjs +++ b/web-ui/modules/i18n/locales/ja.mjs @@ -62,6 +62,8 @@ const ja = Object.freeze({ 'common.notExistsWillCreateOnApply': '存在しません。適用時に作成されます', 'common.notExistsWillCreateOnSave': '存在しません。保存時に作成されます', 'common.none': 'なし', + 'common.configured': '設定済み', + 'common.notConfigured': '未設定', 'cli.missing.title': '{name} CLI がインストールされていません', 'cli.missing.subtitle': '{name} CLI をインストールしてからこのページをご利用ください。', 'cli.missing.openDocs': 'インストールガイドを開く', @@ -360,7 +362,7 @@ const ja = Object.freeze({ 'plugins.builtin.commentPolish.desc': '以下のコードコメントを軽く整えてください {{code}}', 'plugins.builtin.commentPolish.line1': '以下のコードコメントを軽く整えてください', 'plugins.builtin.ruleAck.name': 'ルール確認返信', - 'plugins.builtin.ruleAck.desc': '【{{rule}}】に従って、受信確認を返してください', + 'plugins.builtin.ruleAck.desc': 'ルールに従うよう確認返信を指示', 'plugins.builtin.ruleAck.line1': '【{{rule}}】に従って、受信確認を返してください', // Toasts @@ -391,6 +393,50 @@ const ja = Object.freeze({ 'toast.templates.deleteMessage': '「{name}」を削除しますか?この操作は取り消せません。', 'toast.templates.deleteConfirm': '削除', 'toast.templates.deleteCancel': 'キャンセル', + 'toast.operation.success': '操作が成功しました', + 'toast.load.fail': 'ファイルの読み込みに失敗しました', + 'toast.apply.success': '設定が適用されました', + 'toast.apply.fail': '設定の適用に失敗しました', + 'toast.check.success': 'チェック成功', + 'toast.check.fail': 'チェック失敗', + 'toast.noChanges': '変更が検出されませんでした', + 'toast.template.loadFail': 'テンプレートの読み込みに失敗しました', + 'toast.template.empty': 'テンプレートは空にできません', + 'toast.template.applied': 'テンプレートが適用されました', + 'toast.template.appliedButRefreshFail': 'テンプレートは適用されましたが、UI の更新に失敗しました。手動で更新してください', + 'toast.template.applyFail': 'テンプレートの適用に失敗しました', + 'toast.provider.addFail': '追加に失敗しました', + 'toast.provider.notDeletable': 'このプロバイダーは予約済みのため削除できません', + 'toast.provider.deletedAndSwitched': 'プロバイダーを削除し、{provider}{model}に自動切り替えしました', + 'toast.provider.notEditable': 'このプロバイダーは予約済みのため編集できません', + 'toast.provider.updateFail': '更新に失敗しました', + 'toast.provider.resetSuccess': '設定をリセットしました{backup}', + 'toast.provider.resetFail': 'リセットに失敗しました', + 'toast.provider.fieldsRequired': '名前、URL、API Key、モデル名は必須です', + 'toast.provider.urlRequired': 'URL は必須です', + 'toast.claude.modelRequired': 'モデルを入力してください', + 'toast.claude.apiKeyRequired': '先に API Key を設定してください', + 'toast.claude.checkConfig': 'Claude 設定を確認してください', + 'toast.claude.savedWithoutKey': '保存しました(API Key 未入力)', + 'toast.claude.applied': 'Claude 設定が適用されました', + 'toast.claude.exists': '設定が既に存在します', + 'toast.claude.keepOne': '最低1つは残してください', + 'toast.claude.externalAuth': '外部認証を使用するため、API Key は不要です', + 'toast.claude.balanceEnabled': 'Claude ローカル負荷分散が有効になりました', + 'toast.claude.balanceDisabled': 'Claude ローカル負荷分散が無効になりました', + 'toast.claude.balanceEnableFail': '負荷分散の有効化に失敗しました', + 'toast.claude.balanceRequireProvider': '最低1つの Claude プロバイダーを追加・設定してください', + 'toast.claude.loadSettingsFail': 'Claude settings の読み込みに失敗しました', + 'validation.claude.nameRequired': '設定名は必須です', + 'validation.claude.nameExists': '名前が既に存在します', + 'validation.claude.apiKeyRequired': 'API Key は必須です', + 'validation.claude.baseUrlRequired': 'Base URL は必須です', + 'validation.claude.baseUrlHttpOnly': 'Base URL は http/https のみサポートします', + 'validation.claude.modelRequired': 'モデル名は必須です', + 'modal.claudeDelete.title': 'Claude 設定の削除', + 'modal.claudeDelete.message': '設定 "{name}" を削除しますか?', + 'modal.claudeDelete.confirm': '削除', + 'modal.claudeDelete.cancel': 'キャンセル', // Basic modals 'modal.providerAdd.title': 'プロバイダー追加', diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs index cf6b2a8a..5a7422fa 100644 --- a/web-ui/modules/i18n/locales/vi.mjs +++ b/web-ui/modules/i18n/locales/vi.mjs @@ -6,7 +6,7 @@ const vi = Object.freeze({ 'plugins.builtin.commentPolish.desc': 'Chỉnh nhẹ các chú thích mã sau {{code}}', 'plugins.builtin.commentPolish.line1': 'Chỉnh nhẹ các chú thích mã sau', 'plugins.builtin.ruleAck.name': 'Xác nhận quy tắc', - 'plugins.builtin.ruleAck.desc': 'Hãy làm theo【{{rule}}】, nhận được thì phản hồi', + 'plugins.builtin.ruleAck.desc': 'Tạo phản hồi xác nhận quy tắc', 'plugins.builtin.ruleAck.line1': 'Hãy làm theo【{{rule}}】, nhận được thì phản hồi', // Global 'lang.zh': 'Tiếng Trung', @@ -71,6 +71,8 @@ const vi = Object.freeze({ 'common.notExistsWillCreateOnApply': 'Không tồn tại. Sẽ được tạo khi áp dụng.', 'common.notExistsWillCreateOnSave': 'Không tồn tại. Sẽ được tạo khi lưu.', 'common.none': 'Không có', + 'common.configured': 'Đã cấu hình', + 'common.notConfigured': 'Chưa cấu hình', 'common.notSelected': 'Chưa chọn', // Roles / labels @@ -233,7 +235,60 @@ const vi = Object.freeze({ 'dashboard.doctor.title': 'Doctor', 'dashboard.doctor.runChecks': 'Chạy kiểm tra', 'dashboard.doctor.checking': 'Đang kiểm tra...', - 'dashboard.doctor.export': 'Xuất báo cáo' + 'dashboard.doctor.export': 'Xuất báo cáo', + + // Toasts + 'toast.copy.empty': 'Không có gì để sao chép', + 'toast.copy.ok': 'Đã sao chép', + 'toast.copy.fail': 'Sao chép thất bại', + 'toast.save.ok': 'Đã lưu', + 'toast.save.fail': 'Lưu thất bại', + 'toast.delete.ok': 'Đã xóa', + 'toast.delete.fail': 'Xóa thất bại', + 'toast.operation.success': 'Thao tác thành công', + 'toast.load.fail': 'Tải tệp thất bại', + 'toast.apply.success': 'Đã áp dụng cấu hình', + 'toast.apply.fail': 'Áp dụng cấu hình thất bại', + 'toast.check.success': 'Kiểm tra thành công', + 'toast.check.fail': 'Kiểm tra thất bại', + 'toast.noChanges': 'Không phát hiện thay đổi', + 'toast.template.loadFail': 'Tải mẫu thất bại', + 'toast.template.empty': 'Mẫu không được để trống', + 'toast.template.applied': 'Đã áp dụng mẫu', + 'toast.template.appliedButRefreshFail': 'Đã áp dụng mẫu, nhưng làm mới giao diện thất bại. Vui lòng làm mới thủ công', + 'toast.template.applyFail': 'Áp dụng mẫu thất bại', + 'toast.provider.addFail': 'Thêm thất bại', + 'toast.provider.notDeletable': 'Provider này là mục dành riêng, không thể xóa', + 'toast.provider.deletedAndSwitched': 'Đã xóa provider, tự động chuyển sang {provider}{model}', + 'toast.provider.notEditable': 'Provider này là mục dành riêng, không thể chỉnh sửa', + 'toast.provider.updateFail': 'Cập nhật thất bại', + 'toast.provider.resetSuccess': 'Đã cài đặt lại cấu hình{backup}', + 'toast.provider.resetFail': 'Cài đặt lại thất bại', + 'toast.provider.fieldsRequired': 'Tên, URL, API Key và tên mô hình là bắt buộc', + 'toast.provider.urlRequired': 'URL là bắt buộc', + 'toast.claude.modelRequired': 'Vui lòng nhập mô hình', + 'toast.claude.apiKeyRequired': 'Vui lòng cấu hình API Key trước', + 'toast.claude.checkConfig': 'Vui lòng kiểm tra cấu hình Claude', + 'toast.claude.savedWithoutKey': 'Đã lưu (chưa điền API Key)', + 'toast.claude.applied': 'Cấu hình Claude đã được áp dụng', + 'toast.claude.exists': 'Cấu hình đã tồn tại', + 'toast.claude.keepOne': 'Giữ ít nhất một mục', + 'toast.claude.externalAuth': 'Sử dụng xác thực bên ngoài, không cần API Key', + 'toast.claude.balanceEnabled': 'Đã bật cân bằng tải cục bộ Claude', + 'toast.claude.balanceDisabled': 'Đã tắt cân bằng tải cục bộ Claude', + 'toast.claude.balanceEnableFail': 'Không thể bật cân bằng tải', + 'toast.claude.balanceRequireProvider': 'Vui lòng thêm và cấu hình ít nhất một nhà cung cấp Claude', + 'toast.claude.loadSettingsFail': 'Tải cài đặt Claude thất bại', + 'validation.claude.nameRequired': 'Tên cấu hình là bắt buộc', + 'validation.claude.nameExists': 'Tên đã tồn tại', + 'validation.claude.apiKeyRequired': 'API Key là bắt buộc', + 'validation.claude.baseUrlRequired': 'Base URL là bắt buộc', + 'validation.claude.baseUrlHttpOnly': 'Base URL chỉ hỗ trợ http/https', + 'validation.claude.modelRequired': 'Tên mô hình là bắt buộc', + 'modal.claudeDelete.title': 'Xóa cấu hình Claude', + 'modal.claudeDelete.message': 'Xóa cấu hình "{name}"?', + 'modal.claudeDelete.confirm': 'Xóa', + 'modal.claudeDelete.cancel': 'Hủy', }); export { vi }; diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs index 7b17aca3..500cd1e1 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -61,6 +61,8 @@ const zh = Object.freeze({ 'common.notExistsWillCreateOnApply': '不存在,将在应用时创建', 'common.notExistsWillCreateOnSave': '不存在,将在保存时创建', 'common.none': '暂无', + 'common.configured': '已配置', + 'common.notConfigured': '未配置', 'cli.missing.title': '{name} CLI 未安装', 'cli.missing.subtitle': '请先安装 {name} CLI 后再继续使用此页面。', 'cli.missing.openDocs': '打开安装指南', @@ -358,7 +360,7 @@ const zh = Object.freeze({ 'plugins.builtin.commentPolish.desc': '轻微收敛以下代码注释 {{code}}', 'plugins.builtin.commentPolish.line1': '轻微收敛以下代码注释', 'plugins.builtin.ruleAck.name': '规则确认回复', - 'plugins.builtin.ruleAck.desc': '请根据【{{rule}}】,收到请回复', + 'plugins.builtin.ruleAck.desc': '生成规则确认回复', 'plugins.builtin.ruleAck.line1': '请根据【{{rule}}】,收到请回复', // Toasts @@ -386,9 +388,53 @@ const zh = Object.freeze({ 'toast.templates.builtinNotDuplicable': '内置模板不可复制', 'toast.templates.builtinNotDeletable': '内置模板不可删除', 'toast.templates.deleteTitle': '删除模板', - 'toast.templates.deleteMessage': '删除“{name}”?此操作无法撤销。', + 'toast.templates.deleteMessage': '删除”{name}”?此操作无法撤销。', 'toast.templates.deleteConfirm': '删除', 'toast.templates.deleteCancel': '取消', + 'toast.operation.success': '操作成功', + 'toast.load.fail': '加载文件失败', + 'toast.apply.success': '配置已应用', + 'toast.apply.fail': '应用配置失败', + 'toast.check.success': '检查通过', + 'toast.check.fail': '检查失败', + 'toast.noChanges': '未检测到改动', + 'toast.template.loadFail': '加载模板失败', + 'toast.template.empty': '模板不能为空', + 'toast.template.applied': '模板已应用', + 'toast.template.appliedButRefreshFail': '模板已应用,但界面刷新失败,请手动刷新', + 'toast.template.applyFail': '应用模板失败', + 'toast.provider.addFail': '添加失败', + 'toast.provider.notDeletable': '该 provider 为保留项,不可删除', + 'toast.provider.deletedAndSwitched': '已删除提供商,自动切换到 {provider}{model}', + 'toast.provider.notEditable': '该 provider 为保留项,不可编辑', + 'toast.provider.updateFail': '更新失败', + 'toast.provider.resetSuccess': '配置已重装{backup}', + 'toast.provider.resetFail': '重装失败', + 'toast.provider.fieldsRequired': '名称、URL、API Key 和模型名称必填', + 'toast.provider.urlRequired': 'URL 必填', + 'toast.claude.modelRequired': '请输入模型', + 'toast.claude.apiKeyRequired': '请先配置 API Key', + 'toast.claude.checkConfig': '请检查 Claude 配置', + 'toast.claude.savedWithoutKey': '已保存(未填写 API Key)', + 'toast.claude.applied': 'Claude 配置已生效', + 'toast.claude.exists': '配置已存在', + 'toast.claude.keepOne': '至少保留一项', + 'toast.claude.externalAuth': '使用外部认证,无需 API Key', + 'toast.claude.balanceEnabled': 'Claude 本地负载均衡已启用', + 'toast.claude.balanceDisabled': 'Claude 本地负载均衡已关闭', + 'toast.claude.balanceEnableFail': '启用本地负载均衡失败', + 'toast.claude.balanceRequireProvider': '请先添加并配置至少一个 Claude 提供商', + 'toast.claude.loadSettingsFail': '加载 Claude settings 失败', + 'validation.claude.nameRequired': '配置名称不能为空', + 'validation.claude.nameExists': '名称已存在', + 'validation.claude.apiKeyRequired': 'API Key 必填', + 'validation.claude.baseUrlRequired': 'Base URL 必填', + 'validation.claude.baseUrlHttpOnly': 'Base URL 仅支持 http/https', + 'validation.claude.modelRequired': '模型名称必填', + 'modal.claudeDelete.title': '删除 Claude 配置', + 'modal.claudeDelete.message': '确定删除配置 "{name}"?', + 'modal.claudeDelete.confirm': '删除', + 'modal.claudeDelete.cancel': '取消', // Basic modals 'modal.providerAdd.title': '添加提供商', diff --git a/web-ui/partials/index/panel-config-claude.html b/web-ui/partials/index/panel-config-claude.html index 090f4424..5f1ebc07 100644 --- a/web-ui/partials/index/panel-config-claude.html +++ b/web-ui/partials/index/panel-config-claude.html @@ -154,7 +154,7 @@
{{ formatLatency(claudeSpeedResults[name]) }} - {{ config.hasKey ? t('claude.configured') : t('claude.notConfigured') }} + {{ config.hasKey ? t('common.configured') : t('common.notConfigured') }}