feat(i18n): 完善多模块多语言适配与发版验证

扩展前后端多语言文案与共享词典。增加多模块 i18n 回归测试与 guard。收口外部 SQL 菜单和弹窗多语言文案。
This commit is contained in:
tianqijiuyun-latiao
2026-06-17 13:17:33 +08:00
parent 76b0163bd3
commit 9364c48ef0
135 changed files with 8401 additions and 1325 deletions

View File

@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { BUILTIN_AI_TOOL_INFO, buildAvailableAIChatTools } from './aiToolRegistry';
const source = readFileSync(new URL('./aiToolRegistry.ts', import.meta.url), 'utf8');
describe('aiToolRegistry', () => {
it('registers the ai-runtime inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_runtime');
@@ -307,4 +310,27 @@ describe('aiToolRegistry', () => {
expect(tools.some((item) => item.function.name === 'inspect_shortcuts')).toBe(true);
expect(tools.some((item) => item.function.name === 'custom_probe')).toBe(true);
});
it('localizes MCP fallback descriptions while preserving raw server and tool names', () => {
const tools = buildAvailableAIChatTools([{
alias: 'raw_alias',
originalName: 'raw_original_name',
serverId: 'server-raw',
serverName: 'raw-server.local',
title: 'raw_tool_title',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
}], (key, params) => `${key}: ${params?.toolName} @ ${params?.serverName}`);
const mcpTool = tools.find((item) => item.function.name === 'raw_alias');
expect(source).not.toContain('提供的 MCP 工具');
expect(mcpTool?.function.description).toBe(
'ai_chat.tools.mcp_fallback_description: raw_tool_title @ raw-server.local',
);
});
});

View File

@@ -17,8 +17,14 @@ export const BUILTIN_AI_TOOL_NAME_SET = new Set<string>(
BUILTIN_AI_TOOL_INFO.map((item) => item.name),
);
type AIChatToolTranslator = (
key: string,
params?: Record<string, string>,
) => string;
export const buildMCPAIChatTools = (
tools: AIMCPToolDescriptor[],
t?: AIChatToolTranslator,
): AIChatToolDefinition[] =>
(tools || []).map((tool) => ({
type: "function",
@@ -26,7 +32,12 @@ export const buildMCPAIChatTools = (
name: tool.alias,
description:
tool.description ||
`${tool.serverName} 提供的 MCP 工具 ${tool.title || tool.originalName}`,
(t
? t("ai_chat.tools.mcp_fallback_description", {
serverName: tool.serverName,
toolName: tool.title || tool.originalName,
})
: `MCP tool ${tool.title || tool.originalName} provided by ${tool.serverName}`),
parameters:
tool.inputSchema && Object.keys(tool.inputSchema).length > 0
? tool.inputSchema
@@ -36,4 +47,5 @@ export const buildMCPAIChatTools = (
export const buildAvailableAIChatTools = (
tools: AIMCPToolDescriptor[],
): AIChatToolDefinition[] => [...BUILTIN_AI_TOOLS, ...buildMCPAIChatTools(tools)];
t?: AIChatToolTranslator,
): AIChatToolDefinition[] => [...BUILTIN_AI_TOOLS, ...buildMCPAIChatTools(tools, t)];

View File

@@ -1,11 +1,228 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import {
buildMessagePublishCommand,
createDefaultMessagePublishDraft,
getMessagePublishPresentation,
} from './messagePublish';
const messagePublishSource = readFileSync(new URL('./messagePublish.ts', import.meta.url), 'utf8');
describe('messagePublish', () => {
it('localizes presentation copy through the supplied translator while keeping transport names raw', () => {
const t = (key: string) => key;
expect(getMessagePublishPresentation({ type: 'rabbitmq' }, t)).toMatchObject({
transportLabel: 'RabbitMQ Queue',
destinationLabel: 'Queue',
destinationPlaceholder: 'message_publish.presentation.rabbitmq.destination_placeholder',
destinationRequiredMessage: 'message_publish.presentation.rabbitmq.destination_required',
alertMessage: 'message_publish.presentation.rabbitmq.alert',
successHint: 'message_publish.presentation.rabbitmq.success_hint',
keyLabel: 'message_publish.presentation.key_label',
});
expect(getMessagePublishPresentation({ type: 'rocketmq' }, t)).toMatchObject({
transportLabel: 'RocketMQ Topic',
destinationLabel: 'Topic',
destinationPlaceholder: 'message_publish.presentation.rocketmq.destination_placeholder',
destinationRequiredMessage: 'message_publish.presentation.topic_required',
alertMessage: 'message_publish.presentation.rocketmq.alert',
successHint: 'message_publish.presentation.rocketmq.success_hint',
keyLabel: 'message_publish.presentation.keys_label',
keyPlaceholder: 'message_publish.presentation.rocketmq.key_placeholder',
tagPlaceholder: 'message_publish.presentation.rocketmq.tag_placeholder',
});
expect(getMessagePublishPresentation({ type: 'mqtt' }, t)).toMatchObject({
transportLabel: 'MQTT Topic',
destinationLabel: 'Topic',
destinationPlaceholder: 'message_publish.presentation.mqtt.destination_placeholder',
destinationRequiredMessage: 'message_publish.presentation.topic_required',
alertMessage: 'message_publish.presentation.mqtt.alert',
successHint: 'message_publish.presentation.mqtt.success_hint',
keyLabel: 'message_publish.presentation.key_label',
});
expect(getMessagePublishPresentation({ type: 'kafka' }, t)).toMatchObject({
transportLabel: 'Kafka Topic',
destinationLabel: 'Topic',
destinationPlaceholder: 'message_publish.presentation.kafka.destination_placeholder',
destinationRequiredMessage: 'message_publish.presentation.topic_required',
alertMessage: 'message_publish.presentation.kafka.alert',
successHint: 'message_publish.presentation.kafka.success_hint',
keyLabel: 'message_publish.presentation.key_label',
keyPlaceholder: 'message_publish.presentation.kafka.key_placeholder',
});
});
it('keeps presentation copy out of hardcoded Chinese literals', () => {
[
'例如orders.queue',
'请输入 Queue',
'当前表单会自动拼装 RabbitMQ publish JSON 命令',
'留空 Exchange 时会使用默认交换机',
'例如orders.events',
'请输入 Topic',
'当前表单会自动拼装 RocketMQ publish JSON 命令',
'Tag、Keys、Delay Level 与 Properties 会一并写入 RocketMQ 消息属性',
'消息 Keys可选',
'可输入多个 Key使用逗号分隔',
'例如TagA',
'例如devices/device-001/telemetry',
'当前表单会自动拼装 MQTT publish JSON 命令',
'QoS 与 retain 可单独指定',
'当前表单会自动拼装 Kafka publish JSON 命令',
'Headers 会作为 Kafka Record Headers 一并发送',
'消息 Key可选',
'可留空JSON 模式请输入一行合法 JSON',
].forEach((legacyText) => {
expect(messagePublishSource).not.toContain(legacyText);
});
});
it('keeps presentation keys in every locale catalog', () => {
(['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const).forEach((locale) => {
const catalog = JSON.parse(
readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'),
) as Record<string, string>;
[
'message_publish.presentation.rabbitmq.destination_placeholder',
'message_publish.presentation.rabbitmq.destination_required',
'message_publish.presentation.rabbitmq.alert',
'message_publish.presentation.rabbitmq.success_hint',
'message_publish.presentation.rocketmq.destination_placeholder',
'message_publish.presentation.topic_required',
'message_publish.presentation.rocketmq.alert',
'message_publish.presentation.rocketmq.success_hint',
'message_publish.presentation.keys_label',
'message_publish.presentation.rocketmq.key_placeholder',
'message_publish.presentation.rocketmq.tag_placeholder',
'message_publish.presentation.mqtt.destination_placeholder',
'message_publish.presentation.mqtt.alert',
'message_publish.presentation.mqtt.success_hint',
'message_publish.presentation.kafka.destination_placeholder',
'message_publish.presentation.kafka.alert',
'message_publish.presentation.kafka.success_hint',
'message_publish.presentation.key_label',
'message_publish.presentation.kafka.key_placeholder',
].forEach((key) => {
expect(catalog[key]).toEqual(expect.any(String));
expect(catalog[key].length).toBeGreaterThan(0);
});
[
['message_publish.presentation.rabbitmq.alert', 'RabbitMQ'],
['message_publish.presentation.rabbitmq.alert', 'Management API'],
['message_publish.presentation.rocketmq.alert', 'RocketMQ'],
['message_publish.presentation.rocketmq.alert', 'NameServer'],
['message_publish.presentation.mqtt.alert', 'MQTT'],
['message_publish.presentation.kafka.alert', 'Kafka'],
['message_publish.presentation.kafka.success_hint', 'Headers'],
['message_publish.presentation.key_label', 'Key'],
].forEach(([key, rawTerm]) => {
expect(catalog[key]).toContain(rawTerm);
});
});
});
it('localizes command validation errors while preserving raw protocol details', () => {
const t = (key: string, params?: Record<string, unknown>) => (
params ? `${key} ${JSON.stringify(params)}` : key
);
expect(() => buildMessagePublishCommand(
{ type: 'kafka' },
{
destination: '',
bodyMode: 'json',
body: '{"ok":true}',
},
t,
)).toThrow('message_publish.error.destination_required');
expect(() => buildMessagePublishCommand(
{ type: 'kafka' },
{
destination: 'orders.events',
bodyMode: 'json',
body: '{bad',
},
t,
)).toThrow(/message_publish\.error\.invalid_json_detail .*message_publish\.field\.body/);
expect(() => buildMessagePublishCommand(
{ type: 'kafka' },
{
destination: 'orders.events',
bodyMode: 'json',
body: '{"ok":true}',
headers: '["bad"]',
},
t,
)).toThrow(/message_publish\.error\.json_object_required .*"field":"Headers"/);
expect(() => buildMessagePublishCommand(
{ type: 'mqtt' },
{
destination: 'devices/+/telemetry',
bodyMode: 'json',
body: '{"ok":true}',
},
t,
)).toThrow('message_publish.error.mqtt_wildcard_topic');
expect(() => buildMessagePublishCommand(
{ type: 'not-a-message-bus' },
{
destination: 'orders.events',
bodyMode: 'json',
body: '{"ok":true}',
},
t,
)).toThrow(/message_publish\.error\.unsupported_type .*not-a-message-bus/);
});
it('keeps command validation errors in locale catalogs and out of hardcoded Chinese literals', () => {
[
'请输入目标 Topic / Queue',
'MQTT 发送 Topic 不能包含 + 或 # 通配符',
'请输入${fieldLabel}',
'${fieldLabel}不是合法 JSON',
'${fieldLabel} 必须是 JSON 对象',
'当前数据源暂不支持测试发送消息',
].forEach((legacyText) => {
expect(messagePublishSource).not.toContain(legacyText);
});
(['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const).forEach((locale) => {
const catalog = JSON.parse(
readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'),
) as Record<string, string>;
[
'message_publish.field.body',
'message_publish.field.message_key',
'message_publish.error.destination_required',
'message_publish.error.required_field',
'message_publish.error.invalid_json_detail',
'message_publish.error.json_object_required',
'message_publish.error.mqtt_wildcard_topic',
'message_publish.error.unsupported_type',
].forEach((key) => {
expect(catalog[key]).toEqual(expect.any(String));
expect(catalog[key].length).toBeGreaterThan(0);
});
expect(catalog['message_publish.error.destination_required']).toContain('Topic / Queue');
expect(catalog['message_publish.error.mqtt_wildcard_topic']).toContain('MQTT');
expect(catalog['message_publish.error.mqtt_wildcard_topic']).toContain('+');
expect(catalog['message_publish.error.mqtt_wildcard_topic']).toContain('#');
});
});
it('builds a Kafka publish JSON command from JSON payload inputs', () => {
const result = buildMessagePublishCommand(
{ type: 'kafka' },
@@ -53,7 +270,7 @@ describe('messagePublish', () => {
body: '{"ok":true}',
headers: '["bad"]',
},
)).toThrow('Headers 必须是 JSON 对象');
)).toThrow(/Headers.*JSON object/);
});
it('seeds Kafka default publish draft with a JSON body example', () => {

View File

@@ -1,4 +1,5 @@
import { resolveDataSourceType } from './dataSourceCapabilities';
import { t as defaultTranslate, type I18nParams } from '../i18n';
type ConnectionLike = {
type?: string;
@@ -55,6 +56,8 @@ export type MessagePublishPresentation = {
showRetain: boolean;
};
export type MessagePublishTranslate = (key: string, params?: I18nParams) => string;
const normalizeMode = (value: unknown, fallback: MessagePublishValueMode): MessagePublishValueMode => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'text') return 'text';
@@ -66,10 +69,11 @@ const parseRequiredPayload = (
rawValue: unknown,
mode: MessagePublishValueMode,
fieldLabel: string,
translate: MessagePublishTranslate,
): string | number | boolean | Record<string, any> | Array<any> => {
const text = String(rawValue ?? '');
if (!text.trim()) {
throw new Error(`请输入${fieldLabel}`);
throw new Error(translate('message_publish.error.required_field', { field: fieldLabel }));
}
if (mode === 'text') {
return text;
@@ -77,7 +81,10 @@ const parseRequiredPayload = (
try {
return JSON.parse(text);
} catch (error: any) {
throw new Error(`${fieldLabel}不是合法 JSON${error?.message || String(error)}`);
throw new Error(translate('message_publish.error.invalid_json_detail', {
field: fieldLabel,
detail: error?.message || String(error),
}));
}
};
@@ -85,17 +92,19 @@ const parseOptionalPayload = (
rawValue: unknown,
mode: MessagePublishValueMode,
fieldLabel: string,
translate: MessagePublishTranslate,
): string | number | boolean | Record<string, any> | Array<any> | undefined => {
const text = String(rawValue ?? '');
if (!text.trim()) {
return undefined;
}
return parseRequiredPayload(text, mode, fieldLabel);
return parseRequiredPayload(text, mode, fieldLabel, translate);
};
const parseOptionalJSONObject = (
rawValue: unknown,
fieldLabel: string,
translate: MessagePublishTranslate,
): Record<string, any> | undefined => {
const text = String(rawValue ?? '');
if (!text.trim()) {
@@ -105,10 +114,13 @@ const parseOptionalJSONObject = (
try {
parsed = JSON.parse(text);
} catch (error: any) {
throw new Error(`${fieldLabel}不是合法 JSON${error?.message || String(error)}`);
throw new Error(translate('message_publish.error.invalid_json_detail', {
field: fieldLabel,
detail: error?.message || String(error),
}));
}
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error(`${fieldLabel} 必须是 JSON 对象`);
throw new Error(translate('message_publish.error.json_object_required', { field: fieldLabel }));
}
return parsed as Record<string, any>;
};
@@ -165,20 +177,22 @@ const resolveDefaultDestination = (config: ConnectionLike, explicitDestination:
export const getMessagePublishPresentation = (
config: ConnectionLike,
translate: MessagePublishTranslate = defaultTranslate,
): MessagePublishPresentation => {
const resolvedType = resolveDataSourceType(config as any);
const tr = translate;
if (resolvedType === 'rabbitmq') {
return {
transportLabel: 'RabbitMQ Queue',
destinationLabel: 'Queue',
destinationPlaceholder: '例如orders.queue',
destinationRequiredMessage: '请输入 Queue',
alertMessage: '当前表单会自动拼装 RabbitMQ publish JSON 命令,并通过 Management API 执行测试发送。',
successHint: '留空 Exchange 时会使用默认交换机并按 Queue 名作为 routing key。',
destinationPlaceholder: tr('message_publish.presentation.rabbitmq.destination_placeholder'),
destinationRequiredMessage: tr('message_publish.presentation.rabbitmq.destination_required'),
alertMessage: tr('message_publish.presentation.rabbitmq.alert'),
successHint: tr('message_publish.presentation.rabbitmq.success_hint'),
showKey: false,
showKeyMode: false,
keyLabel: '消息 Key可选',
keyLabel: tr('message_publish.presentation.key_label'),
keyPlaceholder: '',
showExchange: true,
showRoutingKey: true,
@@ -196,20 +210,20 @@ export const getMessagePublishPresentation = (
return {
transportLabel: 'RocketMQ Topic',
destinationLabel: 'Topic',
destinationPlaceholder: '例如orders.events',
destinationRequiredMessage: '请输入 Topic',
alertMessage: '当前表单会自动拼装 RocketMQ publish JSON 命令,并通过 NameServer/Broker 执行测试发送。',
successHint: 'Tag、Keys、Delay Level 与 Properties 会一并写入 RocketMQ 消息属性。',
destinationPlaceholder: tr('message_publish.presentation.rocketmq.destination_placeholder'),
destinationRequiredMessage: tr('message_publish.presentation.topic_required'),
alertMessage: tr('message_publish.presentation.rocketmq.alert'),
successHint: tr('message_publish.presentation.rocketmq.success_hint'),
showKey: true,
showKeyMode: false,
keyLabel: '消息 Keys可选',
keyPlaceholder: '可输入多个 Key使用逗号分隔',
keyLabel: tr('message_publish.presentation.keys_label'),
keyPlaceholder: tr('message_publish.presentation.rocketmq.key_placeholder'),
showExchange: false,
showRoutingKey: false,
showHeaders: false,
showProperties: true,
showTag: true,
tagPlaceholder: '例如TagA',
tagPlaceholder: tr('message_publish.presentation.rocketmq.tag_placeholder'),
showDelayLevel: true,
showQos: false,
showRetain: false,
@@ -220,13 +234,13 @@ export const getMessagePublishPresentation = (
return {
transportLabel: 'MQTT Topic',
destinationLabel: 'Topic',
destinationPlaceholder: '例如devices/device-001/telemetry',
destinationRequiredMessage: '请输入 Topic',
alertMessage: '当前表单会自动拼装 MQTT publish JSON 命令,并直接通过 broker 执行测试发送。',
successHint: 'QoS 与 retain 可单独指定;未填写时沿用当前连接中的默认参数。',
destinationPlaceholder: tr('message_publish.presentation.mqtt.destination_placeholder'),
destinationRequiredMessage: tr('message_publish.presentation.topic_required'),
alertMessage: tr('message_publish.presentation.mqtt.alert'),
successHint: tr('message_publish.presentation.mqtt.success_hint'),
showKey: false,
showKeyMode: false,
keyLabel: '消息 Key可选',
keyLabel: tr('message_publish.presentation.key_label'),
keyPlaceholder: '',
showExchange: false,
showRoutingKey: false,
@@ -243,14 +257,14 @@ export const getMessagePublishPresentation = (
return {
transportLabel: 'Kafka Topic',
destinationLabel: 'Topic',
destinationPlaceholder: '例如orders.events',
destinationRequiredMessage: '请输入 Topic',
alertMessage: '当前表单会自动拼装 Kafka publish JSON 命令,并直接调用后端执行测试发送。',
successHint: 'Headers 会作为 Kafka Record Headers 一并发送。',
destinationPlaceholder: tr('message_publish.presentation.kafka.destination_placeholder'),
destinationRequiredMessage: tr('message_publish.presentation.topic_required'),
alertMessage: tr('message_publish.presentation.kafka.alert'),
successHint: tr('message_publish.presentation.kafka.success_hint'),
showKey: true,
showKeyMode: true,
keyLabel: '消息 Key可选',
keyPlaceholder: '可留空JSON 模式请输入一行合法 JSON',
keyLabel: tr('message_publish.presentation.key_label'),
keyPlaceholder: tr('message_publish.presentation.kafka.key_placeholder'),
showExchange: false,
showRoutingKey: false,
showHeaders: true,
@@ -322,23 +336,27 @@ export const createDefaultMessagePublishDraft = (
export const buildMessagePublishCommand = (
config: ConnectionLike,
draft: MessagePublishDraft,
translate: MessagePublishTranslate = defaultTranslate,
): MessagePublishCommand => {
const resolvedType = resolveDataSourceType(config as any);
const tr = translate;
const bodyFieldLabel = tr('message_publish.field.body');
const messageKeyFieldLabel = tr('message_publish.field.message_key');
const destination = String(draft.destination || '').trim();
if (!destination) {
throw new Error('请输入目标 Topic / Queue');
throw new Error(tr('message_publish.error.destination_required'));
}
if (resolvedType === 'mqtt') {
if (/[#+]/.test(destination)) {
throw new Error('MQTT 发送 Topic 不能包含 + 或 # 通配符');
throw new Error(tr('message_publish.error.mqtt_wildcard_topic'));
}
const bodyMode = normalizeMode(draft.bodyMode, 'json');
const qosValue = Number(draft.qos);
const qos = Number.isFinite(qosValue) ? Math.min(2, Math.max(0, Math.trunc(qosValue))) : 0;
const command: Record<string, unknown> = {
publish: destination,
payload: parseRequiredPayload(draft.body, bodyMode, '消息体'),
payload: parseRequiredPayload(draft.body, bodyMode, bodyFieldLabel, tr),
qos,
retain: !!draft.retain,
};
@@ -354,7 +372,7 @@ export const buildMessagePublishCommand = (
const bodyMode = normalizeMode(draft.bodyMode, 'json');
const command: Record<string, unknown> = {
publish: destination,
payload: parseRequiredPayload(draft.body, bodyMode, '消息体'),
payload: parseRequiredPayload(draft.body, bodyMode, bodyFieldLabel, tr),
};
const keys = String(draft.key || '')
@@ -375,7 +393,7 @@ export const buildMessagePublishCommand = (
command.delayLevel = Math.trunc(delayLevel);
}
const properties = parseOptionalJSONObject(draft.properties, 'Properties');
const properties = parseOptionalJSONObject(draft.properties, 'Properties', tr);
if (properties && Object.keys(properties).length > 0) {
command.properties = properties;
}
@@ -392,17 +410,17 @@ export const buildMessagePublishCommand = (
const bodyMode = normalizeMode(draft.bodyMode, 'json');
const command: Record<string, unknown> = {
publish: destination,
payload: parseRequiredPayload(draft.body, bodyMode, '消息体'),
payload: parseRequiredPayload(draft.body, bodyMode, bodyFieldLabel, tr),
exchange: normalizeRabbitMQExchange(draft.exchange || params.get('defaultExchange') || params.get('exchange') || ''),
routing_key: String(draft.routingKey || '').trim() || destination,
};
const headers = parseOptionalJSONObject(draft.headers, 'Headers');
const headers = parseOptionalJSONObject(draft.headers, 'Headers', tr);
if (headers && Object.keys(headers).length > 0) {
command.headers = headers;
}
const properties = parseOptionalJSONObject(draft.properties, 'Properties');
const properties = parseOptionalJSONObject(draft.properties, 'Properties', tr);
if (properties && Object.keys(properties).length > 0) {
command.properties = properties;
}
@@ -419,15 +437,15 @@ export const buildMessagePublishCommand = (
const bodyMode = normalizeMode(draft.bodyMode, 'json');
const command: Record<string, unknown> = {
publish: destination,
value: parseRequiredPayload(draft.body, bodyMode, '消息体'),
value: parseRequiredPayload(draft.body, bodyMode, bodyFieldLabel, tr),
};
const keyPayload = parseOptionalPayload(draft.key, keyMode, '消息 Key');
const keyPayload = parseOptionalPayload(draft.key, keyMode, messageKeyFieldLabel, tr);
if (keyPayload !== undefined) {
command.key = keyPayload;
}
const headers = parseOptionalJSONObject(draft.headers, 'Headers');
const headers = parseOptionalJSONObject(draft.headers, 'Headers', tr);
if (headers && Object.keys(headers).length > 0) {
command.headers = headers;
}
@@ -439,5 +457,5 @@ export const buildMessagePublishCommand = (
};
}
throw new Error(`当前数据源暂不支持测试发送消息:${resolvedType || 'unknown'}`);
throw new Error(tr('message_publish.error.unsupported_type', { type: resolvedType || 'unknown' }));
};

View File

@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { setCurrentLanguage } from '../i18n';
import type { SavedQuery } from '../types';
import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage';
import {
@@ -131,4 +133,33 @@ describe('saved query persistence', () => {
expect(stripped.state.savedQueries).toBeUndefined();
expect(stripped.state.sidebarWidth).toBe(320);
});
it('localizes generated legacy saved query names', () => {
setCurrentLanguage('en-US');
const payload = JSON.stringify({
state: {
savedQueries: [
{
id: 'saved-generated-name',
sql: 'select 1;',
connectionId: 'conn-1',
dbName: 'warehouse',
createdAt: 200,
},
],
},
});
expect(readLegacySavedQueriesFromPayload(payload)).toEqual([
expect.objectContaining({
id: 'saved-generated-name',
name: 'Query 1',
}),
]);
});
it('does not hardcode Chinese generated saved query names', () => {
const source = readFileSync(new URL('./savedQueryPersistence.ts', import.meta.url), 'utf8');
expect(source).not.toContain('`查询-${index + 1}`');
});
});

View File

@@ -1,4 +1,5 @@
import type { SavedConnection, SavedQuery } from '../types';
import { t as translate } from '../i18n';
import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage';
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
@@ -50,6 +51,10 @@ const unwrapPersistedAppState = (payload: unknown): Record<string, unknown> => {
return raw;
};
const resolveGeneratedSavedQueryName = (index: number): string => (
translate('saved_query.default_name', { index: index + 1 })
);
const sanitizeSavedQuery = (value: unknown, index: number): SavedQuery | null => {
if (!value || typeof value !== 'object') {
return null;
@@ -64,7 +69,7 @@ const sanitizeSavedQuery = (value: unknown, index: number): SavedQuery | null =>
}
const query: SavedQuery = {
id,
name: toTrimmedString(raw.name, `查询-${index + 1}`) || `查询-${index + 1}`,
name: toTrimmedString(raw.name, resolveGeneratedSavedQueryName(index)) || resolveGeneratedSavedQueryName(index),
sql,
connectionId,
dbName,

View File

@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { t as translate } from '../i18n';
import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage';
import {
bootstrapSecureConfig,
@@ -37,6 +39,8 @@ const legacyPayload = JSON.stringify({
},
});
const en = (key: string) => translate(key, undefined, 'en-US');
const createMemoryStorage = () => {
const data = new Map<string, string>();
return {
@@ -99,6 +103,46 @@ describe('secureConfigBootstrap', () => {
]));
});
it('uses catalog text for local legacy security update issues when a translator is provided', async () => {
const args = createBaseArgs();
const result = await bootstrapSecureConfig({
...args,
t: en,
backend: {
GetSecurityUpdateStatus: vi.fn().mockResolvedValue({
overallStatus: 'not_detected',
summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 },
issues: [],
}),
},
});
expect(result.status.issues).toEqual(expect.arrayContaining([
expect.objectContaining({
scope: 'connection',
title: 'Legacy',
message: "This connection is still saved in the current app's local configuration. After the security update completes, it will be moved to the new secure storage.",
}),
expect.objectContaining({
scope: 'global_proxy',
title: 'Global Proxy',
message: "Global proxy settings are still saved in the current app's local configuration. After the security update completes, they will be moved to the new secure storage.",
}),
]));
});
it('keeps local legacy security update text out of production source literals', () => {
const source = readFileSync(new URL('./secureConfigBootstrap.ts', import.meta.url), 'utf8');
expect(source).not.toContain('该连接仍保存在当前应用的本地配置中');
expect(source).not.toContain('全局代理仍保存在当前应用的本地配置中');
expect(source).not.toContain('安全更新能力不可用');
expect(source).toContain('security_update.bootstrap.legacy.connection.message');
expect(source).toContain('security_update.bootstrap.legacy.global_proxy.message');
expect(source).toContain('security_update.error.capability_unavailable');
});
it('shows intro when legacy sensitive items exist and backend status is pending', async () => {
const args = createBaseArgs();

View File

@@ -40,6 +40,7 @@ type SecureConfigBootstrapArgs = {
storage?: StorageLike;
replaceConnections: (connections: SavedConnection[]) => void;
replaceGlobalProxy: (proxy: GlobalProxyConfig) => void;
t?: SecureConfigBootstrapTranslator;
};
type SecureConfigBootstrapResult = {
@@ -57,8 +58,16 @@ type StartSecurityUpdateResult = {
type MergeSecurityUpdateStatusOptions = {
previousStatus?: Partial<SecurityUpdateStatus> | null;
t?: SecureConfigBootstrapTranslator;
};
type SecureConfigBootstrapTranslator = (key: string) => string;
const secureConfigBootstrapText = (
key: string,
t?: SecureConfigBootstrapTranslator,
): string => (t ? t(key) : key);
const defaultSummary = () => ({
total: 0,
updated: 0,
@@ -75,7 +84,10 @@ const hasMeaningfulSummary = (summary: SecurityUpdateSummary): boolean => (
|| summary.failed > 0
);
const buildLegacyPendingDetails = (rawPayload: string | null): {
const buildLegacyPendingDetails = (
rawPayload: string | null,
t?: SecureConfigBootstrapTranslator,
): {
hasLegacyItems: boolean;
summary: SecurityUpdateSummary;
issues: SecurityUpdateIssue[];
@@ -90,19 +102,19 @@ const buildLegacyPendingDetails = (rawPayload: string | null): {
status: 'pending',
reasonCode: 'migration_required',
action: 'open_connection',
message: '该连接仍保存在当前应用的本地配置中,完成安全更新后会迁入新的安全存储。',
message: secureConfigBootstrapText('security_update.bootstrap.legacy.connection.message', t),
}));
if (legacy.globalProxy) {
issues.push({
id: 'legacy-global-proxy-default',
scope: 'global_proxy',
title: '全局代理',
title: secureConfigBootstrapText('security_update.bootstrap.legacy.global_proxy.title', t),
severity: 'medium',
status: 'pending',
reasonCode: 'migration_required',
action: 'open_proxy_settings',
message: '全局代理仍保存在当前应用的本地配置中,完成安全更新后会迁入新的安全存储。',
message: secureConfigBootstrapText('security_update.bootstrap.legacy.global_proxy.message', t),
});
}
@@ -197,7 +209,7 @@ export const mergeSecurityUpdateStatusWithLegacySource = (
const hasActiveMigrationRound = String(base.migrationId || '').trim() !== '';
const baseNonLegacyIssues = base.issues.filter((issue) => !isLocalLegacyIssue(issue));
const legacy = buildLegacyPendingDetails(rawPayload);
const legacy = buildLegacyPendingDetails(rawPayload, options?.t);
const legacySummary = deriveLegacySummary(base, legacy.issues.length, options?.previousStatus);
if (!legacySummary.hasContribution) {
@@ -326,7 +338,7 @@ export async function finalizeSecurityUpdateStatus(
): Promise<SecurityUpdateStatus> {
const storage = resolveStorage(args.storage);
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
const status = mergeSecurityUpdateStatusWithLegacySource(rawStatus, rawPayload);
const status = mergeSecurityUpdateStatusWithLegacySource(rawStatus, rawPayload, { t: args.t });
if (status.overallStatus === 'completed') {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
@@ -346,7 +358,7 @@ export async function bootstrapSecureConfig(args: SecureConfigBootstrapArgs): Pr
const backendStatus = typeof args.backend?.GetSecurityUpdateStatus === 'function'
? await args.backend.GetSecurityUpdateStatus()
: undefined;
const status = mergeSecurityUpdateStatusWithLegacySource(backendStatus, rawPayload);
const status = mergeSecurityUpdateStatusWithLegacySource(backendStatus, rawPayload, { t: args.t });
if (!hasLegacySensitiveItems) {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);
@@ -374,7 +386,7 @@ export async function startSecurityUpdateFromBootstrap(args: SecureConfigBootstr
if (typeof args.backend?.StartSecurityUpdate !== 'function') {
return {
status: null,
error: new Error('安全更新能力不可用'),
error: new Error(secureConfigBootstrapText('security_update.error.capability_unavailable', args.t)),
};
}
@@ -387,7 +399,7 @@ export async function startSecurityUpdateFromBootstrap(args: SecureConfigBootstr
writeBackup: true,
},
});
const status = mergeSecurityUpdateStatusWithLegacySource(rawStatus, rawPayload);
const status = mergeSecurityUpdateStatusWithLegacySource(rawStatus, rawPayload, { t: args.t });
if (status.overallStatus === 'completed') {
await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true);

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { t as translate } from '../i18n';
import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
import {
getSecurityUpdateIssueSeverityMeta,
@@ -22,6 +23,9 @@ const createStatus = (overallStatus: SecurityUpdateStatus['overallStatus']): Sec
issues: [],
});
const zh = (key: string) => translate(key, undefined, 'zh-CN');
const en = (key: string) => translate(key, undefined, 'en-US');
describe('securityUpdatePresentation', () => {
it('sorts issues by severity from high to low', () => {
const issues: SecurityUpdateIssue[] = [
@@ -40,9 +44,9 @@ describe('securityUpdatePresentation', () => {
});
it('maps needs_attention, rolled_back and completed to stable display labels', () => {
expect(getSecurityUpdateStatusMeta(createStatus('needs_attention')).label).toBe('待处理');
expect(getSecurityUpdateStatusMeta(createStatus('rolled_back')).label).toBe('已回退');
expect(getSecurityUpdateStatusMeta(createStatus('completed')).label).toBe('已完成');
expect(getSecurityUpdateStatusMeta(createStatus('needs_attention'), zh).label).toBe('待处理');
expect(getSecurityUpdateStatusMeta(createStatus('rolled_back'), zh).label).toBe('已回退');
expect(getSecurityUpdateStatusMeta(createStatus('completed'), zh).label).toBe('已完成');
});
it('resolves intro, banner and detail entry visibility for key overall states', () => {
@@ -66,31 +70,41 @@ describe('securityUpdatePresentation', () => {
});
it('maps issue scope actions to existing repair entry labels', () => {
expect(getSecurityUpdateIssueActionMeta({ id: 'conn', scope: 'connection', action: 'open_connection' }).label).toBe('打开连接');
expect(getSecurityUpdateIssueActionMeta({ id: 'proxy', scope: 'global_proxy', action: 'open_proxy_settings' }).label).toBe('代理设置');
expect(getSecurityUpdateIssueActionMeta({ id: 'ai', scope: 'ai_provider', action: 'open_ai_settings' }).label).toBe('AI 设置');
expect(getSecurityUpdateIssueActionMeta({ id: 'system', scope: 'system', action: 'view_details' }).label).toBe('查看详情');
expect(getSecurityUpdateIssueActionMeta({ id: 'conn', scope: 'connection', action: 'open_connection' }, zh).label).toBe('打开连接');
expect(getSecurityUpdateIssueActionMeta({ id: 'proxy', scope: 'global_proxy', action: 'open_proxy_settings' }, zh).label).toBe('代理设置');
expect(getSecurityUpdateIssueActionMeta({ id: 'ai', scope: 'ai_provider', action: 'open_ai_settings' }, zh).label).toBe('AI 设置');
expect(getSecurityUpdateIssueActionMeta({ id: 'system', scope: 'system', action: 'view_details' }, zh).label).toBe('查看详情');
});
it('maps item status to explicit Chinese labels instead of reusing severity wording', () => {
expect(getSecurityUpdateItemStatusMeta('needs_attention')).toEqual({
expect(getSecurityUpdateItemStatusMeta('needs_attention', zh)).toEqual({
label: '待处理',
color: 'warning',
});
expect(getSecurityUpdateItemStatusMeta('updated')).toEqual({
expect(getSecurityUpdateItemStatusMeta('updated', zh)).toEqual({
label: '已更新',
color: 'success',
});
});
it('maps issue severity to dedicated risk labels', () => {
expect(getSecurityUpdateIssueSeverityMeta('medium')).toEqual({
expect(getSecurityUpdateIssueSeverityMeta('medium', zh)).toEqual({
label: '中风险',
color: 'warning',
});
expect(getSecurityUpdateIssueSeverityMeta('high')).toEqual({
expect(getSecurityUpdateIssueSeverityMeta('high', zh)).toEqual({
label: '高风险',
color: 'error',
});
});
it('uses catalog labels and descriptions when a translator is provided', () => {
expect(getSecurityUpdateStatusMeta(createStatus('postponed'), en)).toMatchObject({
label: 'Pending',
description: 'This security update has been postponed. The currently usable configuration is still kept.',
});
expect(getSecurityUpdateIssueActionMeta({ id: 'conn', action: 'open_connection' }, en).label).toBe('Open Connection');
expect(getSecurityUpdateItemStatusMeta('needs_attention', en).label).toBe('Needs Attention');
expect(getSecurityUpdateIssueSeverityMeta('high', en).label).toBe('High Risk');
});
});

View File

@@ -14,6 +14,8 @@ type SecurityUpdateStatusMeta = {
tone: SecurityUpdateTone;
};
type SecurityUpdateTranslator = (key: string) => string;
type SecurityUpdateEntryVisibility = {
showIntro: boolean;
showBanner: boolean;
@@ -36,63 +38,67 @@ const severityWeight: Record<SecurityUpdateIssueSeverity, number> = {
low: 2,
};
const localize = (t: SecurityUpdateTranslator | undefined, key: string): string => (
t ? t(key) : key
);
const actionMetaMap: Record<SecurityUpdateIssueAction, SecurityUpdateIssueActionMeta> = {
open_connection: {
label: '打开连接',
label: 'security_update.action.open_connection',
emphasis: 'primary',
},
open_proxy_settings: {
label: '代理设置',
label: 'security_update.action.open_proxy_settings',
emphasis: 'primary',
},
open_ai_settings: {
label: 'AI 设置',
label: 'security_update.action.open_ai_settings',
emphasis: 'primary',
},
retry_update: {
label: '重新检查',
label: 'security_update.action.retry_update',
emphasis: 'primary',
},
view_details: {
label: '查看详情',
label: 'security_update.action.view_details',
emphasis: 'default',
},
};
const itemStatusMetaMap: Record<SecurityUpdateItemStatus, SecurityUpdateBadgeMeta> = {
pending: {
label: '待更新',
label: 'security_update.item_status.pending',
color: 'processing',
},
updated: {
label: '已更新',
label: 'security_update.item_status.updated',
color: 'success',
},
needs_attention: {
label: '待处理',
label: 'security_update.item_status.needs_attention',
color: 'warning',
},
skipped: {
label: '已跳过',
label: 'security_update.item_status.skipped',
color: 'default',
},
failed: {
label: '失败',
label: 'security_update.item_status.failed',
color: 'error',
},
};
const issueSeverityMetaMap: Record<SecurityUpdateIssueSeverity, SecurityUpdateBadgeMeta> = {
high: {
label: '高风险',
label: 'security_update.severity.high',
color: 'error',
},
medium: {
label: '中风险',
label: 'security_update.severity.medium',
color: 'warning',
},
low: {
label: '低风险',
label: 'security_update.severity.low',
color: 'default',
},
};
@@ -108,49 +114,52 @@ export function sortSecurityUpdateIssues(issues: SecurityUpdateIssue[]): Securit
});
}
export function getSecurityUpdateStatusMeta(status: SecurityUpdateStatus): SecurityUpdateStatusMeta {
export function getSecurityUpdateStatusMeta(
status: SecurityUpdateStatus,
t?: SecurityUpdateTranslator,
): SecurityUpdateStatusMeta {
switch (status.overallStatus) {
case 'pending':
return {
label: '待更新',
description: '检测到可进行的安全更新,你可以现在开始或稍后继续。',
label: localize(t, 'security_update.status.pending.label'),
description: localize(t, 'security_update.status.pending.description'),
tone: 'warning',
};
case 'postponed':
return {
label: '待更新',
description: '本次安全更新已延后,当前可用配置会继续保留。',
label: localize(t, 'security_update.status.postponed.label'),
description: localize(t, 'security_update.status.postponed.description'),
tone: 'warning',
};
case 'in_progress':
return {
label: '更新中',
description: '正在检查并更新已保存配置的安全存储。',
label: localize(t, 'security_update.status.in_progress.label'),
description: localize(t, 'security_update.status.in_progress.description'),
tone: 'processing',
};
case 'needs_attention':
return {
label: '待处理',
description: '更新尚未完成,有少量配置需要你处理。',
label: localize(t, 'security_update.status.needs_attention.label'),
description: localize(t, 'security_update.status.needs_attention.description'),
tone: 'warning',
};
case 'completed':
return {
label: '已完成',
description: '已保存配置已完成安全更新。',
label: localize(t, 'security_update.status.completed.label'),
description: localize(t, 'security_update.status.completed.description'),
tone: 'success',
};
case 'rolled_back':
return {
label: '已回退',
description: '本次更新未完成,系统已保留当前可用配置。',
label: localize(t, 'security_update.status.rolled_back.label'),
description: localize(t, 'security_update.status.rolled_back.description'),
tone: 'error',
};
case 'not_detected':
default:
return {
label: '未检测到',
description: '当前没有需要处理的安全更新。',
label: localize(t, 'security_update.status.not_detected.label'),
description: localize(t, 'security_update.status.not_detected.description'),
tone: 'default',
};
}
@@ -189,16 +198,41 @@ export function resolveSecurityUpdateEntryVisibility(status: SecurityUpdateStatu
}
}
export function getSecurityUpdateIssueActionMeta(issue: Partial<SecurityUpdateIssue>): SecurityUpdateIssueActionMeta {
return actionMetaMap[issue.action ?? 'view_details'] ?? actionMetaMap.view_details;
export function getSecurityUpdateIssueActionMeta(
issue: Partial<SecurityUpdateIssue>,
t?: SecurityUpdateTranslator,
): SecurityUpdateIssueActionMeta {
const resolvedAction = issue.action && actionMetaMap[issue.action] ? issue.action : 'view_details';
const meta = actionMetaMap[resolvedAction];
const key = `security_update.action.${resolvedAction}`;
return {
...meta,
label: localize(t, key),
};
}
export function getSecurityUpdateItemStatusMeta(status?: SecurityUpdateItemStatus): SecurityUpdateBadgeMeta {
return itemStatusMetaMap[status ?? 'pending'] ?? itemStatusMetaMap.pending;
export function getSecurityUpdateItemStatusMeta(
status?: SecurityUpdateItemStatus,
t?: SecurityUpdateTranslator,
): SecurityUpdateBadgeMeta {
const resolvedStatus = status ?? 'pending';
const meta = itemStatusMetaMap[resolvedStatus] ?? itemStatusMetaMap.pending;
return {
...meta,
label: localize(t, `security_update.item_status.${resolvedStatus}`),
};
}
export function getSecurityUpdateIssueSeverityMeta(severity?: SecurityUpdateIssueSeverity): SecurityUpdateBadgeMeta {
return issueSeverityMetaMap[severity ?? 'low'] ?? issueSeverityMetaMap.low;
export function getSecurityUpdateIssueSeverityMeta(
severity?: SecurityUpdateIssueSeverity,
t?: SecurityUpdateTranslator,
): SecurityUpdateBadgeMeta {
const resolvedSeverity = severity ?? 'low';
const meta = issueSeverityMetaMap[resolvedSeverity] ?? issueSeverityMetaMap.low;
return {
...meta,
label: localize(t, `security_update.severity.${resolvedSeverity}`),
};
}
export type {
@@ -206,5 +240,6 @@ export type {
SecurityUpdateEntryVisibility,
SecurityUpdateIssueActionMeta,
SecurityUpdateStatusMeta,
SecurityUpdateTranslator,
SecurityUpdateTone,
};

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { t as translate } from '../i18n';
import type { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from '../types';
import {
hasSecurityUpdateRecentResult,
@@ -11,6 +13,8 @@ import {
shouldRetrySecurityUpdateAfterRepairSave,
} from './securityUpdateRepairFlow';
const en = (key: string) => translate(key, undefined, 'en-US');
const createConnection = (id: string): SavedConnection => ({
id,
name: `连接-${id}`,
@@ -52,7 +56,7 @@ describe('securityUpdateRepairFlow', () => {
});
});
it('returns a user-facing warning when the target connection no longer exists', () => {
it('returns a stable warning key when the target connection no longer exists without a translator', () => {
const issue: SecurityUpdateIssue = {
id: 'issue-1',
action: 'open_connection',
@@ -61,10 +65,37 @@ describe('securityUpdateRepairFlow', () => {
expect(resolveSecurityUpdateRepairEntry(issue, [createConnection('conn-1')])).toEqual({
type: 'warning',
message: '未找到对应连接,请先重新检查最新状态',
message: 'security_update.repair.warning.connection_not_found',
});
});
it('uses the catalog warning when a repair translator is provided', () => {
const issue: SecurityUpdateIssue = {
id: 'issue-1',
action: 'open_connection',
refId: 'missing-conn',
};
const resolveWithTranslator = resolveSecurityUpdateRepairEntry as unknown as (
issue: SecurityUpdateIssue,
connections: SavedConnection[],
status: SecurityUpdateStatus | null,
t: (key: string) => string,
) => ReturnType<typeof resolveSecurityUpdateRepairEntry>;
expect(resolveWithTranslator(issue, [createConnection('conn-1')], null, en)).toEqual({
type: 'warning',
message: 'The matching connection was not found. Check the latest status first.',
});
});
it('keeps the connection-not-found warning out of production source literals', () => {
const source = readFileSync(new URL('./securityUpdateRepairFlow.ts', import.meta.url), 'utf8');
expect(source).not.toContain('未找到对应连接,请先重新检查最新状态');
expect(source).toContain('security_update.repair.warning.connection_not_found');
});
it('maps proxy, ai and retry actions to the expected repair entry', () => {
expect(resolveSecurityUpdateRepairEntry({ id: 'proxy', action: 'open_proxy_settings' }, [])).toEqual({
type: 'proxy',

View File

@@ -2,11 +2,17 @@ import type { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from
export type SecurityUpdateRepairSource = 'connection' | 'proxy' | 'ai';
export type SecurityUpdateSettingsFocusTarget = 'recent_result' | 'status';
export type SecurityUpdateRepairTranslator = (key: string) => string;
export type SecurityUpdateFocusState = {
target: SecurityUpdateSettingsFocusTarget | null;
pulseKey: string | null;
};
const securityUpdateRepairText = (
key: string,
t?: SecurityUpdateRepairTranslator,
): string => (t ? t(key) : key);
export type SecurityUpdateRepairEntry =
| {
type: 'connection';
@@ -66,13 +72,14 @@ export const resolveSecurityUpdateRepairEntry = (
issue: SecurityUpdateIssue,
connections: SavedConnection[],
status?: Pick<SecurityUpdateStatus, 'backupPath' | 'lastError'> | null,
t?: SecurityUpdateRepairTranslator,
): SecurityUpdateRepairEntry => {
if (issue.action === 'open_connection') {
const target = connections.find((connection) => connection.id === issue.refId);
if (!target) {
return {
type: 'warning',
message: '未找到对应连接,请先重新检查最新状态',
message: securityUpdateRepairText('security_update.repair.warning.connection_not_found', t),
};
}
return {

View File

@@ -20,13 +20,31 @@ export interface TabDisplaySettings {
export const TAB_DISPLAY_SECONDARY_DEFAULT_KEYS: TabDisplayElementKey[] = ['connection', 'database', 'schema', 'host'];
export const TAB_DISPLAY_ELEMENT_META: Record<TabDisplayElementKey, { label: string; description: string }> = {
connection: { label: '连接名', description: '连接简称或环境名,例如 DEV' },
kind: { label: '对象类型', description: 'SQL / TABLE / VIEW 等类型标签' },
object: { label: '对象名', description: '表名、查询名、资源名等核心名称' },
database: { label: '数据库', description: '当前 DB / catalog 名称' },
schema: { label: 'Schema', description: 'schema / owner 前缀' },
host: { label: 'Host/IP', description: '连接目标地址摘要' },
export const TAB_DISPLAY_ELEMENT_META: Record<TabDisplayElementKey, { labelKey: string; descriptionKey: string }> = {
connection: {
labelKey: 'app.theme.tab_display.element.connection.label',
descriptionKey: 'app.theme.tab_display.element.connection.description',
},
kind: {
labelKey: 'app.theme.tab_display.element.kind.label',
descriptionKey: 'app.theme.tab_display.element.kind.description',
},
object: {
labelKey: 'app.theme.tab_display.element.object.label',
descriptionKey: 'app.theme.tab_display.element.object.description',
},
database: {
labelKey: 'app.theme.tab_display.element.database.label',
descriptionKey: 'app.theme.tab_display.element.database.description',
},
schema: {
labelKey: 'app.theme.tab_display.element.schema.label',
descriptionKey: 'app.theme.tab_display.element.schema.description',
},
host: {
labelKey: 'app.theme.tab_display.element.host.label',
descriptionKey: 'app.theme.tab_display.element.host.description',
},
};
export const DEFAULT_TAB_DISPLAY_SETTINGS: TabDisplaySettings = {