fix(frontend): 补齐英文失败路径

This commit is contained in:
2026-09-05 09:48:27 +08:00
parent ef961d322b
commit 89e475c0c2
17 changed files with 83 additions and 70 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ pnpm test
pnpm build pnpm build
``` ```
阶段 F 合并时的回归基线为后端 559 项、前端 103 项测试通过,TypeScript 类型检查与生产构建通过。存在一条既有 Starlette/httpx 弃用提示和 Vite 大 bundle 提示;测试数量以当前分支实际输出和 CI 为准。 当前回归基线为后端 559 项、前端 106 项测试通过,TypeScript 类型检查与生产构建通过。存在一条既有 Starlette/httpx 弃用提示和 Vite 大 bundle 提示;测试数量以当前分支实际输出和 CI 为准。
## 文档 ## 文档
+3 -1
View File
@@ -1,3 +1,5 @@
import { t } from '@/i18n'
export interface ServiceStatus { export interface ServiceStatus {
name: string name: string
version: string version: string
@@ -10,7 +12,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
export async function getServiceStatus(): Promise<ServiceStatus> { export async function getServiceStatus(): Promise<ServiceStatus> {
const response = await fetch(`${apiBaseUrl}/api/status`) const response = await fetch(`${apiBaseUrl}/api/status`)
if (!response.ok) { if (!response.ok) {
throw new Error(`后端请求失败:HTTP ${response.status}`) throw new Error(`${t('后端请求失败:', 'Backend request failed: ')}HTTP ${response.status}`)
} }
return response.json() as Promise<ServiceStatus> return response.json() as Promise<ServiceStatus>
} }
+30 -29
View File
@@ -1,4 +1,5 @@
import type { McpServerInput } from '@/contracts' import type { McpServerInput } from '@/contracts'
import { t } from '@/i18n'
export type SecretKind = 'environment' | 'header' export type SecretKind = 'environment' | 'header'
export interface ImportedSecret { kind: SecretKind; key: string; value: string } export interface ImportedSecret { kind: SecretKind; key: string; value: string }
@@ -26,38 +27,38 @@ export function emptyMcpConfig(): McpServerInput {
} }
function object(value: unknown, label: string): Record<string, unknown> { function object(value: unknown, label: string): Record<string, unknown> {
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}必须是 JSON 对象`) if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}${t('必须是 JSON 对象', ' must be a JSON object')}`)
return value as Record<string, unknown> return value as Record<string, unknown>
} }
function strings(value: unknown, label: string): string[] { function strings(value: unknown, label: string): string[] {
if (value === undefined) return [] if (value === undefined) return []
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串数组`) if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串数组', ' must be a string array')}`)
return [...value] return [...value]
} }
function entries(value: unknown, label: string): Record<string, string> { function entries(value: unknown, label: string): Record<string, string> {
if (value === undefined) return {} if (value === undefined) return {}
const result = object(value, label) const result = object(value, label)
if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`) if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串键值 JSON 对象', ' must be a JSON object with string keys and values')}`)
return { ...result } as Record<string, string> return { ...result } as Record<string, string>
} }
function timeout(value: unknown, fallback: number, max: number, label: string): number { function timeout(value: unknown, fallback: number, max: number, label: string): number {
if (value === undefined) return fallback if (value === undefined) return fallback
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}必须是 1${max} 秒之间的数字`) if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}${t(`必须是 1${max} 秒之间的数字`, ` must be a number from 1 to ${max} seconds`)}`)
return value return value
} }
// Do not silently rewrite executable arguments or secret values copied from chat. // Do not silently rewrite executable arguments or secret values copied from chat.
function checkUrl(value: string, label: string) { function checkUrl(value: string, label: string) {
if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}请填写纯 URL,不要粘贴 Markdown 链接`) if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}${t('请填写纯 URL,不要粘贴 Markdown 链接', ': enter a plain URL instead of a Markdown link')}`)
} }
export function parseMcpJson(raw: string, fallbackName = '', requireConnection = true) { export function parseMcpJson(raw: string, fallbackName = '', requireConnection = true) {
let parsed: unknown let parsed: unknown
try { parsed = JSON.parse(raw) } try { parsed = JSON.parse(raw) }
catch { throw new Error('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义') } catch { throw new Error(t('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义', 'The server configuration is not valid JSON. Check commas, quotes, and invalid \\_ escapes.')) }
return normalizeMcpConfig(parsed, fallbackName, requireConnection) return normalizeMcpConfig(parsed, fallbackName, requireConnection)
} }
@@ -65,54 +66,54 @@ export function parseMcpJson(raw: string, fallbackName = '', requireConnection =
* Inline secrets leave the public config here and are sent only to the Secret API. * Inline secrets leave the public config here and are sent only to the Secret API.
*/ */
export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) { export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) {
let raw = object(parsed, '服务器配置') let raw = object(parsed, t('服务器配置', 'Server configuration'))
if ('mcpServers' in raw) { if ('mcpServers' in raw) {
const servers = Object.entries(object(raw.mcpServers, 'mcpServers')) const servers = Object.entries(object(raw.mcpServers, 'mcpServers'))
if (servers.length !== 1) throw new Error('请一次导入一个 MCP 服务器') if (servers.length !== 1) throw new Error(t('请一次导入一个 MCP 服务器', 'Import one MCP server at a time'))
fallbackName = servers[0]![0] fallbackName = servers[0]![0]
raw = object(servers[0]![1], '服务器配置') raw = object(servers[0]![1], t('服务器配置', 'Server configuration'))
} }
const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout']) const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout'])
if (Object.keys(raw).some(key => !allowed.has(key))) { if (Object.keys(raw).some(key => !allowed.has(key))) {
// Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys. // Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys.
throw new Error('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层') throw new Error(t('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层', 'The server configuration contains unsupported fields. Put API keys in the corresponding env/environment variables, not at the top level.'))
} }
if (raw.env !== undefined && raw.environment !== undefined) throw new Error('env 与 environment 请只保留一个,避免覆盖配置') if (raw.env !== undefined && raw.environment !== undefined) throw new Error(t('env 与 environment 请只保留一个,避免覆盖配置', 'Keep either env or environment, not both'))
const transport = raw.transport ?? raw.type ?? (raw.url ? 'streamable_http' : 'stdio') const transport = raw.transport ?? raw.type ?? (raw.url ? 'streamable_http' : 'stdio')
if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error('transport 必须是 stdio、streamable_http 或 sse') if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error(t('transport 必须是 stdio、streamable_http 或 sse', 'transport must be stdio, streamable_http, or sse'))
const config = emptyMcpConfig() const config = emptyMcpConfig()
config.transport = transport as McpServerInput['transport'] config.transport = transport as McpServerInput['transport']
const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : 'MCP 服务器')) const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : t('MCP 服务器', 'MCP Server')))
if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error('服务器名称必须为 180 个字符') if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error(t('服务器名称必须为 180 个字符', 'The server name must contain 180 characters'))
config.name = name.trim() config.name = name.trim()
for (const key of ['command', 'url'] as const) { for (const key of ['command', 'url'] as const) {
const value = raw[key] const value = raw[key]
if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}必须是字符串`) if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}${t('必须是字符串', ' must be a string')}`)
config[key] = typeof value === 'string' ? value.trim() : null config[key] = typeof value === 'string' ? value.trim() : null
} }
config.args = strings(raw.args, 'args') config.args = strings(raw.args, 'args')
if (config.args.length > 64) throw new Error('args 最多允许 64 项') if (config.args.length > 64) throw new Error(t('args 最多允许 64 项', 'args allows at most 64 items'))
for (const value of config.args) checkUrl(value, 'args 中的地址') for (const value of config.args) checkUrl(value, t('args 中的地址', 'URL in args'))
config.environment = entries(raw.environment ?? raw.env, 'environment/env') config.environment = entries(raw.environment ?? raw.env, 'environment/env')
config.headers = entries(raw.headers, 'headers') config.headers = entries(raw.headers, 'headers')
config.secret_environment_keys = [...new Set(strings(raw.secret_environment_keys, 'secret_environment_keys'))] config.secret_environment_keys = [...new Set(strings(raw.secret_environment_keys, 'secret_environment_keys'))]
config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))] config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))]
config.permissions = strings(raw.permissions, 'permissions') config.permissions = strings(raw.permissions, 'permissions')
config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, '启动超时') config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, t('启动超时', 'Startup timeout'))
// Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting. // Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting.
config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, '工具超时') config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, t('工具超时', 'Tool timeout'))
if (config.transport === 'stdio') { if (config.transport === 'stdio') {
if (requireConnection && !config.command) throw new Error('stdio 配置必须填写 command') if (requireConnection && !config.command) throw new Error(t('stdio 配置必须填写 command', 'stdio configuration requires command'))
if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error('stdio 配置不能包含 URL 或 HTTP Header') if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error(t('stdio 配置不能包含 URL 或 HTTP Header', 'stdio configuration cannot contain a URL or HTTP headers'))
} else { } else {
if (requireConnection && !config.url) throw new Error('HTTP/SSE 配置必须填写 url') if (requireConnection && !config.url) throw new Error(t('HTTP/SSE 配置必须填写 url', 'HTTP/SSE configuration requires a URL'))
if (config.url) { if (config.url) {
checkUrl(config.url, 'url') checkUrl(config.url, 'url')
let url: URL let url: URL
try { url = new URL(config.url) } catch { throw new Error('url 必须是有效的 HTTP(S) 地址') } try { url = new URL(config.url) } catch { throw new Error(t('url 必须是有效的 HTTP(S) 地址', 'url must be a valid HTTP(S) address')) }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error('url 必须为不含账号密码或片段的 HTTP(S) 地址') if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error(t('url 必须为不含账号密码或片段的 HTTP(S) 地址', 'url must be an HTTP(S) address without credentials or a fragment'))
} }
if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error('HTTP/SSE 配置不能包含 command、args 或环境变量') if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error(t('HTTP/SSE 配置不能包含 command、args 或环境变量', 'HTTP/SSE configuration cannot contain command, args, or environment variables'))
} }
const secrets: ImportedSecret[] = [] const secrets: ImportedSecret[] = []
for (const kind of ['environment', 'header'] as const) { for (const kind of ['environment', 'header'] as const) {
@@ -120,19 +121,19 @@ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireCo
const keys = kind === 'environment' ? config.secret_environment_keys : config.secret_header_keys const keys = kind === 'environment' ? config.secret_environment_keys : config.secret_header_keys
const identity = (key: string) => kind === 'header' ? key.toLowerCase() : key const identity = (key: string) => kind === 'header' ? key.toLowerCase() : key
const allKeys = [...Object.keys(values), ...keys] const allKeys = [...Object.keys(values), ...keys]
if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error('HTTP Header 名称不能仅大小写不同而重复声明') if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error(t('HTTP Header 名称不能仅大小写不同而重复声明', 'HTTP header names cannot be duplicated with case-only differences'))
const validKey = kind === 'environment' ? /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ : /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/ const validKey = kind === 'environment' ? /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ : /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/
if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? '环境变量' : 'Header'}名称无效;敏感变量名只能填名称,不能填密钥值`) if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? t('环境变量', 'Environment variable') : 'Header'}${t('名称无效;敏感变量名只能填名称,不能填密钥值', ' name is invalid; secret variable declarations accept names only, not secret values')}`)
for (const [key, value] of Object.entries(values)) { for (const [key, value] of Object.entries(values)) {
const declared = keys.find(item => identity(item) === identity(key)) const declared = keys.find(item => identity(item) === identity(key))
const sensitive = /api[_-]?key|token|secret|password|authorization|cookie|credential/i.test(key) const sensitive = /api[_-]?key|token|secret|password|authorization|cookie|credential/i.test(key)
if (declared || sensitive) { if (declared || sensitive) {
if (!value || value.length > 32768) throw new Error('密钥值必须为 132768 个字符') if (!value || value.length > 32768) throw new Error(t('密钥值必须为 132768 个字符', 'Secret values must contain 132768 characters'))
const secretKey = declared ?? key const secretKey = declared ?? key
if (!declared) keys.push(key) if (!declared) keys.push(key)
secrets.push({ kind, key: secretKey, value }) secrets.push({ kind, key: secretKey, value })
delete values[key] delete values[key]
} else if (/host|url|endpoint/i.test(key)) checkUrl(value, '环境变量或 Header 地址') } else if (/host|url|endpoint/i.test(key)) checkUrl(value, t('环境变量或 Header 地址', 'Environment variable or Header URL'))
} }
} }
return { config, secrets } return { config, secrets }
@@ -81,7 +81,7 @@ async function loadActive() {
} }
} }
} catch (reason) { } catch (reason) {
if (version === loadVersion) feedback(message(reason, 'MCP 数据加载失败')) if (version === loadVersion) feedback(message(reason, t('MCP 数据加载失败', 'Failed to load MCP data')))
} finally { } finally {
if (version === loadVersion) loading.value = false if (version === loadVersion) loading.value = false
} }
@@ -94,7 +94,7 @@ async function restartHost() {
host.value = await pluginService.getPluginHostStatus(props.plugin.plugin_id) host.value = await pluginService.getPluginHostStatus(props.plugin.plugin_id)
await pluginStore.loadPlugins() await pluginStore.loadPlugins()
notice.value = t('MCP Host 已重启。', 'MCP Host restarted.') notice.value = t('MCP Host 已重启。', 'MCP Host restarted.')
} catch (reason) { feedback(message(reason, 'MCP Host 重启失败')) } finally { busy.value = '' } } catch (reason) { feedback(message(reason, t('MCP Host 重启失败', 'Failed to restart MCP Host'))) } finally { busy.value = '' }
} }
function updateValue(field: PluginSettingField, raw: string | boolean) { function updateValue(field: PluginSettingField, raw: string | boolean) {
values.value[field.key] = field.type === 'number' && typeof raw === 'string' ? (raw === '' ? null : Number(raw)) : raw values.value[field.key] = field.type === 'number' && typeof raw === 'string' ? (raw === '' ? null : Number(raw)) : raw
@@ -107,7 +107,7 @@ async function saveSettings() {
schema.value = await pluginService.updatePluginSettings(props.plugin.plugin_id, schema.value.schema_version, values.value) schema.value = await pluginService.updatePluginSettings(props.plugin.plugin_id, schema.value.schema_version, values.value)
values.value = { ...schema.value.values } values.value = { ...schema.value.values }
notice.value = t('普通设置已保存。', 'Settings saved.') notice.value = t('普通设置已保存。', 'Settings saved.')
} catch (reason) { feedback(message(reason, '设置保存失败')) } finally { busy.value = '' } } catch (reason) { feedback(message(reason, t('设置保存失败', 'Failed to save settings'))) } finally { busy.value = '' }
} }
async function saveSecret(field: PluginSettingField) { async function saveSecret(field: PluginSettingField) {
const secret = secrets.value[field.key]?.trim() const secret = secrets.value[field.key]?.trim()
@@ -119,7 +119,7 @@ async function saveSecret(field: PluginSettingField) {
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured } if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = '' secrets.value[field.key] = ''
notice.value = field.label + t('已加密保存。', ' encrypted and saved.') notice.value = field.label + t('已加密保存。', ' encrypted and saved.')
} catch (reason) { feedback(message(reason, '密钥保存失败')) } finally { busy.value = '' } } catch (reason) { feedback(message(reason, t('密钥保存失败', 'Failed to save secret'))) } finally { busy.value = '' }
} }
async function deleteSecret(field: PluginSettingField) { async function deleteSecret(field: PluginSettingField) {
if (!confirm(t('删除已保存的', 'Delete saved ') + field.label + '')) return if (!confirm(t('删除已保存的', 'Delete saved ') + field.label + '')) return
@@ -130,7 +130,7 @@ async function deleteSecret(field: PluginSettingField) {
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured } if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = '' secrets.value[field.key] = ''
notice.value = field.label + t('已删除。', ' deleted.') notice.value = field.label + t('已删除。', ' deleted.')
} catch (reason) { feedback(message(reason, '密钥删除失败')) } finally { busy.value = '' } } catch (reason) { feedback(message(reason, t('密钥删除失败', 'Failed to delete secret'))) } finally { busy.value = '' }
} }
function properties(command: PluginCommand): Record<string, Record<string, unknown>> { function properties(command: PluginCommand): Record<string, Record<string, unknown>> {
const result = command.parameters.properties const result = command.parameters.properties
@@ -178,7 +178,7 @@ async function execute(command: PluginCommand) {
await loadActive() await loadActive()
notice.value = t('相关数据已刷新。', 'Related data refreshed.') notice.value = t('相关数据已刷新。', 'Related data refreshed.')
} else notice.value = t('命令执行完成。', 'Command completed.') } else notice.value = t('命令执行完成。', 'Command completed.')
} catch (reason) { feedback(message(reason, '命令执行失败')) } finally { busy.value = '' } } catch (reason) { feedback(message(reason, t('命令执行失败', 'Command failed'))) } finally { busy.value = '' }
} }
</script> </script>
@@ -54,7 +54,7 @@ async function load() {
applyResponse(routing) applyResponse(routing)
conflict.value = false conflict.value = false
} catch (reason) { } catch (reason) {
if (active) error.value = `加载失败:${reason instanceof Error ? reason.message : '无法读取模型路由或提供商'}` if (active) error.value = `${t('加载失败:', 'Load failed: ')}${reason instanceof Error ? reason.message : t('无法读取模型路由或提供商', 'Could not read model routes or providers')}`
} finally { loading.value = false } } finally { loading.value = false }
} }
@@ -74,11 +74,11 @@ function bindingFor(capability: RoutingCapability): ModelBinding | null {
if (!draft.provider_id) return null if (!draft.provider_id) return null
if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error(t('请选择已启用且协议可用的提供商,或切换到本地。', 'Select an enabled provider with a supported protocol, or switch to local.')) if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error(t('请选择已启用且协议可用的提供商,或切换到本地。', 'Select an enabled provider with a supported protocol, or switch to local.'))
if (!draft.model.trim()) throw new Error(t('请填写所选 API 的模型 ID。', 'Enter the model ID for the selected API.')) if (!draft.model.trim()) throw new Error(t('请填写所选 API 的模型 ID。', 'Enter the model ID for the selected API.'))
if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。') if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error(t('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。', 'Endpoint must be a relative path beginning with / and containing only letters, numbers, underscores, hyphens, and /.'))
const binding: ModelBinding = { provider_id: draft.provider_id, model: draft.model.trim(), endpoint: draft.endpoint } const binding: ModelBinding = { provider_id: draft.provider_id, model: draft.model.trim(), endpoint: draft.endpoint }
if (capability === 'embedding') { if (capability === 'embedding') {
const dimension = String(draft.dimensions).trim() const dimension = String(draft.dimensions).trim()
if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。') if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error(t('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。', 'Embedding dimensions must be an integer from 1 to 16384, or blank to use the API default.'))
binding.dimensions = dimension ? Number(dimension) : null binding.dimensions = dimension ? Number(dimension) : null
} }
return binding return binding
@@ -100,8 +100,8 @@ async function save() {
if (!active) return if (!active) return
conflict.value = reason instanceof ApiErrorClass && /CONFLICT|VERSION|HTTP_409/i.test(reason.code) conflict.value = reason instanceof ApiErrorClass && /CONFLICT|VERSION|HTTP_409/i.test(reason.code)
error.value = conflict.value error.value = conflict.value
? '配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。' ? t('配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。', 'Configuration conflict: another window changed these routes. Your input is unsaved; reload the latest settings before editing.')
: `保存失败:${reason instanceof Error ? reason.message : '请重试'}` : `${t('保存失败:', 'Save failed: ')}${reason instanceof Error ? reason.message : t('请重试', 'please retry')}`
} finally { saving.value = false } } finally { saving.value = false }
} }
</script> </script>
@@ -40,7 +40,7 @@ async function previewRequest() {
try { try {
if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.')) if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.'))
const response = await apiClient.post<{body:Record<string,unknown>}>('/api/providers/request-preview', { const response = await apiClient.post<{body:Record<string,unknown>}>('/api/providers/request-preview', {
provider: {provider_type:form.provider_type,name:form.name || '预览',base_url:form.base_url || null, provider: {provider_type:form.provider_type,name:form.name || t('预览', 'Preview'),base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value, default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value,
}) })
if (active && generation === draftGeneration) requestPreview.value = JSON.stringify(response.body, null, 2) if (active && generation === draftGeneration) requestPreview.value = JSON.stringify(response.body, null, 2)
@@ -54,7 +54,7 @@ async function probeRequest() {
if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.')) if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.'))
if (apiKey.value.trim()) throw new Error(t('请先保存新的 API Key,再进行推理验证。', 'Save the new API key before testing inference.')) if (apiKey.value.trim()) throw new Error(t('请先保存新的 API Key,再进行推理验证。', 'Save the new API key before testing inference.'))
const result = await apiClient.post<{message:string}>('/api/providers/request-probe', { const result = await apiClient.post<{message:string}>('/api/providers/request-probe', {
provider: {provider_type:form.provider_type,name:form.name || '推理验证',base_url:form.base_url || null, provider: {provider_type:form.provider_type,name:form.name || t('推理验证', 'Inference test'),base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)), default_model:form.default_model || null,request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)),
credential_id:configured.value ? credentialId.value : null}, stream:previewStream.value, credential_id:configured.value ? credentialId.value : null}, stream:previewStream.value,
}) })
+2 -1
View File
@@ -1,4 +1,5 @@
import { apiClient, resolveApiUrl } from './apiClient' import { apiClient, resolveApiUrl } from './apiClient'
import { t } from '@/i18n'
export interface Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string } export interface Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string }
export interface MediaJob { export interface MediaJob {
@@ -26,7 +27,7 @@ export const mediaService = {
const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), { const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), {
method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file, method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file,
}) })
if (!response.ok) throw new Error((await response.json())?.error?.message || '附件上传失败') if (!response.ok) throw new Error((await response.json())?.error?.message || t('附件上传失败', 'Attachment upload failed'))
return await response.json() as {attachment_id: string} return await response.json() as {attachment_id: string}
}, },
} }
+3 -2
View File
@@ -7,6 +7,7 @@ import type {
OperationResponse, OperationResponse,
} from '@/contracts' } from '@/contracts'
import apiClient from './apiClient' import apiClient from './apiClient'
import { t } from '@/i18n'
import * as noteService from './noteService' import * as noteService from './noteService'
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */ /** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
@@ -72,7 +73,7 @@ async function requireNoteId(filePath: string): Promise<string> {
await refreshTree() await refreshTree()
noteId = noteIdByPath.get(path) noteId = noteIdByPath.get(path)
} }
if (!noteId) throw new Error(`笔记尚未建立后端索引:${path}`) if (!noteId) throw new Error(`${t('笔记尚未建立后端索引:', 'The note has not been indexed by the backend: ')}${path}`)
return noteId return noteId
} }
@@ -174,7 +175,7 @@ export async function deleteFile(pathValue: string): Promise<void> {
export async function moveFile(sourcePath: string, targetPath: string): Promise<void> { export async function moveFile(sourcePath: string, targetPath: string): Promise<void> {
const source = normalizePublicPath(sourcePath) const source = normalizePublicPath(sourcePath)
if (typeByPath.get(source) !== 'file') { if (typeByPath.get(source) !== 'file') {
throw new Error('当前阶段只支持移动笔记文件。') throw new Error(t('当前阶段只支持移动笔记文件。', 'Only note files can be moved at this stage.'))
} }
await noteService.moveNote(await requireNoteId(source), relativePath(targetPath)) await noteService.moveNote(await requireNoteId(source), relativePath(targetPath))
await refreshTree() await refreshTree()
+2 -1
View File
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts' import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
import * as agentService from '@/services/agentService' import * as agentService from '@/services/agentService'
import type { SseClient } from '@/services/sseClient' import type { SseClient } from '@/services/sseClient'
import { t } from '@/i18n'
export const useAgentStore = defineStore('agent', () => { export const useAgentStore = defineStore('agent', () => {
const runs = ref<AgentRun[]>([]) const runs = ref<AgentRun[]>([])
@@ -93,7 +94,7 @@ export const useAgentStore = defineStore('agent', () => {
tool_name: String(call.name ?? 'unknown'), tool_name: String(call.name ?? 'unknown'),
permission: String(data.permission ?? ''), permission: String(data.permission ?? ''),
parameters: (call.arguments ?? {}) as Record<string, unknown>, parameters: (call.arguments ?? {}) as Record<string, unknown>,
impact: '该工具需要获得权限后才能继续执行。', impact: t('该工具需要获得权限后才能继续执行。', 'This tool requires permission before it can continue.'),
} }
if (run) run.status = 'waiting_permission' if (run) run.status = 'waiting_permission'
} else if (['RunCompleted', 'RunFailed', 'RunCancelled'].includes(event.event)) { } else if (['RunCompleted', 'RunFailed', 'RunCancelled'].includes(event.event)) {
+4 -3
View File
@@ -3,6 +3,7 @@ import { ref, computed, reactive } from 'vue'
import type { ChatMessage, Conversation } from '@/contracts' import type { ChatMessage, Conversation } from '@/contracts'
import { streamChat } from '@/services/chatService' import { streamChat } from '@/services/chatService'
import type { SseClient } from '@/services/sseClient' import type { SseClient } from '@/services/sseClient'
import { t } from '@/i18n'
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () => {
const conversations = ref<Conversation[]>([]) const conversations = ref<Conversation[]>([])
@@ -128,11 +129,11 @@ export const useChatStore = defineStore('chat', () => {
content: String(event.data.content ?? event.data.snippet ?? ''), content: String(event.data.content ?? event.data.snippet ?? ''),
}) })
} }
if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}` if (event.event === 'Error') aiMsg.content += `\n\n${t('生成失败:', 'Generation failed: ')}${String(event.data.message ?? t('未知错误', 'Unknown error'))}`
}, },
onError(error) { onError(error) {
if (version !== streamVersion) return if (version !== streamVersion) return
aiMsg.content += `\n\n连接失败:${error.message}` aiMsg.content += `\n\n${t('连接失败:', 'Connection failed: ')}${error.message}`
isStreaming.value = false isStreaming.value = false
sseClient = null sseClient = null
}, },
@@ -162,7 +163,7 @@ export const useChatStore = defineStore('chat', () => {
stopGeneration() stopGeneration()
const newConv: Conversation = { const newConv: Conversation = {
conversation_id: crypto.randomUUID(), conversation_id: crypto.randomUUID(),
title: '新对话', title: t('新对话', 'New conversation'),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
message_count: 0, message_count: 0,
+3 -2
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { SaveStatus } from '@/contracts' import type { SaveStatus } from '@/contracts'
import * as workspaceService from '@/services/workspaceService' import * as workspaceService from '@/services/workspaceService'
import { t } from '@/i18n'
export const useEditorStore = defineStore('editor', () => { export const useEditorStore = defineStore('editor', () => {
const mode = ref<'wysiwyg' | 'source'>('wysiwyg') const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
@@ -76,12 +77,12 @@ export const useEditorStore = defineStore('editor', () => {
saveTimer = null saveTimer = null
} }
if (saveStatus.value === 'conflict') { if (saveStatus.value === 'conflict') {
throw new Error('当前文件存在编辑冲突,请处理后再切换文件。') throw new Error(t('当前文件存在编辑冲突,请处理后再切换文件。', 'The current file has an editing conflict. Resolve it before switching files.'))
} }
if (pendingSave) await pendingSave if (pendingSave) await pendingSave
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') await save() if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') await save()
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') { if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。') throw new Error(t('当前文件保存失败,已阻止切换以避免内容丢失。', 'The current file could not be saved. Switching was blocked to prevent data loss.'))
} }
// 版本号使较慢的旧读取不能覆盖用户后选择的新文件。 // 版本号使较慢的旧读取不能覆盖用户后选择的新文件。
const version = ++loadVersion const version = ++loadVersion
+2 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Plugin } from '@/contracts' import type { Plugin } from '@/contracts'
import * as pluginService from '@/services/pluginService' import * as pluginService from '@/services/pluginService'
import { t } from '@/i18n'
export const usePluginStore = defineStore('plugin', () => { export const usePluginStore = defineStore('plugin', () => {
const plugins = ref<Plugin[]>([]) const plugins = ref<Plugin[]>([])
@@ -23,7 +24,7 @@ export const usePluginStore = defineStore('plugin', () => {
plugins.value = await pluginService.listPlugins() plugins.value = await pluginService.listPlugins()
error.value = null error.value = null
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Plugin 加载失败' error.value = reason instanceof Error ? reason.message : t('Plugin 加载失败', 'Failed to load Plugins')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
+6 -5
View File
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts' import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts'
import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService' import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
import { ApiErrorClass } from '@/services/apiClient' import { ApiErrorClass } from '@/services/apiClient'
import { t } from '@/i18n'
export const useProviderStore = defineStore('provider', () => { export const useProviderStore = defineStore('provider', () => {
const providers = ref<ProviderConfig[]>([]) const providers = ref<ProviderConfig[]>([])
@@ -29,7 +30,7 @@ export const useProviderStore = defineStore('provider', () => {
} }
error.value = null error.value = null
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Provider 加载失败' error.value = reason instanceof Error ? reason.message : t('Provider 加载失败', 'Failed to load Providers')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -39,7 +40,7 @@ export const useProviderStore = defineStore('provider', () => {
try { try {
presets.value = await listProviderPresets() presets.value = await listProviderPresets()
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Provider 预设加载失败' error.value = reason instanceof Error ? reason.message : t('Provider 预设加载失败', 'Failed to load Provider presets')
} }
} }
@@ -55,11 +56,11 @@ export const useProviderStore = defineStore('provider', () => {
} catch (reason) { } catch (reason) {
const provider = providers.value.find((item) => item.provider_id === providerId) const provider = providers.value.find((item) => item.provider_id === providerId)
const credentialId = provider?.credential_id const credentialId = provider?.credential_id
let message = reason instanceof Error ? reason.message : '模型列表获取失败' let message = reason instanceof Error ? reason.message : t('模型列表获取失败', 'Failed to load the model list')
if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_CREDENTIAL_MISSING') { if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_CREDENTIAL_MISSING') {
message = '尚未配置 API Key,请编辑该 Provider 后填写并保存。' message = t('尚未配置 API Key,请编辑该 Provider 后填写并保存。', 'No API key is configured. Edit this Provider, enter a key, and save it.')
} else if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_AUTH_FAILED') { } else if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_AUTH_FAILED') {
message = `鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。` message = t(`鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。`, `Authentication failed. Check the API key for credential “${credentialId || 'not set'}”.`)
} }
modelErrorsByProvider.value[providerId] = message modelErrorsByProvider.value[providerId] = message
throw reason throw reason
+5 -4
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { SearchResult, SearchRequest } from '@/contracts' import type { SearchResult, SearchRequest } from '@/contracts'
import * as searchService from '@/services/searchService' import * as searchService from '@/services/searchService'
import { ApiErrorClass } from '@/services/apiClient' import { ApiErrorClass } from '@/services/apiClient'
import { t } from '@/i18n'
const VECTOR_ERROR_CODES = new Set([ const VECTOR_ERROR_CODES = new Set([
'SEMANTIC_INDEX_UNAVAILABLE', 'SEMANTIC_INDEX_UNAVAILABLE',
@@ -28,7 +29,7 @@ export const useSearchStore = defineStore('search', () => {
if (version !== historyVersion) return if (version !== historyVersion) return
recentQueries.value = response.queries recentQueries.value = response.queries
historyError.value = '' historyError.value = ''
} catch { if (version === historyVersion) historyError.value = '无法读取应用搜索记录,请检查后端连接。' } } catch { if (version === historyVersion) historyError.value = t('无法读取应用搜索记录,请检查后端连接。', 'Could not load search history. Check the backend connection.') }
} }
async function clearHistory() { async function clearHistory() {
const version = ++historyVersion const version = ++historyVersion
@@ -36,7 +37,7 @@ export const useSearchStore = defineStore('search', () => {
await searchService.clearHistory() await searchService.clearHistory()
if (version !== historyVersion) return if (version !== historyVersion) return
recentQueries.value = []; historyError.value = '' recentQueries.value = []; historyError.value = ''
} catch { if (version === historyVersion) historyError.value = '清空搜索记录失败,请重试。' } } catch { if (version === historyVersion) historyError.value = t('清空搜索记录失败,请重试。', 'Failed to clear search history. Please retry.') }
} }
const error = ref<string | null>(null) const error = ref<string | null>(null)
const vectorUnavailable = ref(false) const vectorUnavailable = ref(false)
@@ -71,12 +72,12 @@ export const useSearchStore = defineStore('search', () => {
selectedIndex.value = 0 selectedIndex.value = 0
} catch (fallbackError) { } catch (fallbackError) {
if (version !== searchVersion) return if (version !== searchVersion) return
error.value = fallbackError instanceof Error ? fallbackError.message : '全文检索降级失败' error.value = fallbackError instanceof Error ? fallbackError.message : t('全文检索降级失败', 'Full-text search fallback failed')
results.value = [] results.value = []
total.value = 0 total.value = 0
} }
} else { } else {
error.value = reason instanceof Error ? reason.message : '搜索失败' error.value = reason instanceof Error ? reason.message : t('搜索失败', 'Search failed')
results.value = [] results.value = []
total.value = 0 total.value = 0
} }
+5 -5
View File
@@ -5,7 +5,7 @@ import { resolveApiUrl } from '@/services/apiClient'
import packageInfo from '../../package.json' import packageInfo from '../../package.json'
import * as indexService from '@/services/indexService' import * as indexService from '@/services/indexService'
import * as systemService from '@/services/systemService' import * as systemService from '@/services/systemService'
import { appLocale } from '@/i18n' import { appLocale, t } from '@/i18n'
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
const saved = (() => { const saved = (() => {
@@ -17,7 +17,7 @@ export const useSettingsStore = defineStore('settings', () => {
const autoSaveInterval = ref(typeof saved.autoSaveInterval === 'number' ? saved.autoSaveInterval : 1500) const autoSaveInterval = ref(typeof saved.autoSaveInterval === 'number' ? saved.autoSaveInterval : 1500)
const language = appLocale const language = appLocale
const appVersion = ref(packageInfo.version) const appVersion = ref(packageInfo.version)
const aiCoreVersion = ref('未获取') const aiCoreVersion = ref('')
// Editor // Editor
const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg') const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg')
@@ -48,10 +48,10 @@ export const useSettingsStore = defineStore('settings', () => {
]) ])
const [health, status, index, policy] = results const [health, status, index, policy] = results
aiCoreStatus.value = health.status === 'fulfilled' && health.value.status === 'ok' ? 'running' : 'error' aiCoreStatus.value = health.status === 'fulfilled' && health.value.status === 'ok' ? 'running' : 'error'
aiCoreVersion.value = status.status === 'fulfilled' ? status.value.version : '未获取' aiCoreVersion.value = status.status === 'fulfilled' ? status.value.version : ''
indexStatus.value = index.status === 'fulfilled' ? index.value : emptyIndex() indexStatus.value = index.status === 'fulfilled' ? index.value : emptyIndex()
permissionPolicy.value = policy.status === 'fulfilled' ? policy.value : {} permissionPolicy.value = policy.status === 'fulfilled' ? policy.value : {}
diagnosticsError.value = results.filter(item => item.status === 'rejected').map(item => item.reason instanceof Error ? item.reason.message : '后端请求失败').join('') || null diagnosticsError.value = results.filter(item => item.status === 'rejected').map(item => item.reason instanceof Error ? item.reason.message : t('后端请求失败', 'Backend request failed')).join(t('', '; ')) || null
} }
function setAutoSaveInterval(ms: number) { function setAutoSaveInterval(ms: number) {
@@ -69,7 +69,7 @@ export const useSettingsStore = defineStore('settings', () => {
indexStatus.value = await indexService.getIndexStatus() indexStatus.value = await indexService.getIndexStatus()
} catch (reason) { } catch (reason) {
indexStatus.value.status = 'error' indexStatus.value.status = 'error'
indexStatus.value.error = reason instanceof Error ? reason.message : '索引重建失败' indexStatus.value.error = reason instanceof Error ? reason.message : t('索引重建失败', 'Index rebuild failed')
} }
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Skill } from '@/contracts' import type { Skill } from '@/contracts'
import * as skillService from '@/services/skillService' import * as skillService from '@/services/skillService'
import { t } from '@/i18n'
export const useSkillStore = defineStore('skill', () => { export const useSkillStore = defineStore('skill', () => {
const skills = ref<Skill[]>([]) const skills = ref<Skill[]>([])
@@ -23,7 +24,7 @@ export const useSkillStore = defineStore('skill', () => {
skills.value = await skillService.listSkills() skills.value = await skillService.listSkills()
error.value = null error.value = null
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Skill 加载失败' error.value = reason instanceof Error ? reason.message : t('Skill 加载失败', 'Failed to load Skills')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts' import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts'
import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, updateTask as updateTaskRequest } from '@/services/taskService' import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, updateTask as updateTaskRequest } from '@/services/taskService'
import { t } from '@/i18n'
export const useTaskStore = defineStore('task', () => { export const useTaskStore = defineStore('task', () => {
const tasks = ref<TaskItem[]>([]) const tasks = ref<TaskItem[]>([])
@@ -31,7 +32,7 @@ export const useTaskStore = defineStore('task', () => {
tasks.value = resp.items tasks.value = resp.items
error.value = null error.value = null
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : '任务加载失败' error.value = reason instanceof Error ? reason.message : t('任务加载失败', 'Failed to load tasks')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }