mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-04 12:57:39 +08:00
✨ feat(i18n): 完善多模块多语言适配与发版验证
扩展前后端多语言文案与共享词典。增加多模块 i18n 回归测试与 guard。收口外部 SQL 菜单和弹窗多语言文案。
This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' }));
|
||||
};
|
||||
|
||||
@@ -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}`');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user