fix: 统一消息推送中文校验

This commit is contained in:
sunlei 2026-07-25 15:26:57 +08:00
parent b96003edcc
commit 4c78b10c0c
7 changed files with 187 additions and 19 deletions

View File

@ -56,7 +56,7 @@ pnpm run build:antdv-next
- 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态,并通过独立页签管理腾讯云云解析 DNS 的 A/AAAA 自动更新绑定。页面展示 Agent、路由同步、UDP Keeper、公网端点租约和 DDNS 同步状态,支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史A 只使用合格 UDP Keeper 的公网 IPv4AAAA 只使用 Agent 全局 IPv6DNS 值不包含端口。首屏读取一次 HTTP 快照,后续只按 API SSE 的资源来源刷新当前活动页签;心跳和其他页签事件不刷新,不使用定时轮询。`Page autoContentHeight` 中的 Tabs 与 KtTable 必须由满高纵向 flex 外壳承接,活动面板保持 `flex-1 min-h-0`,否则 KtTable 的百分比高度链会塌陷。Admin 不接触路由器、MQTT 或腾讯云凭据;当前已验证切片只执行 UDP 路由器写入TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败STUN 操作保持可见且禁用。 - 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态,并通过独立页签管理腾讯云云解析 DNS 的 A/AAAA 自动更新绑定。页面展示 Agent、路由同步、UDP Keeper、公网端点租约和 DDNS 同步状态,支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史A 只使用合格 UDP Keeper 的公网 IPv4AAAA 只使用 Agent 全局 IPv6DNS 值不包含端口。首屏读取一次 HTTP 快照,后续只按 API SSE 的资源来源刷新当前活动页签;心跳和其他页签事件不刷新,不使用定时轮询。`Page autoContentHeight` 中的 Tabs 与 KtTable 必须由满高纵向 flex 外壳承接,活动面板保持 `flex-1 min-h-0`,否则 KtTable 的百分比高度链会塌陷。Admin 不接触路由器、MQTT 或腾讯云凭据;当前已验证切片只执行 UDP 路由器写入TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败STUN 操作保持可见且禁用。
- Vue i18n 文案中的普通 `@` 必须写成字面量插值 `{'@'}`,否则生产消息编译器会把它识别为 linked message 语法。网络管理语言包由 `network-locale.spec.ts` 逐条通过实际 i18n runtime 校验,不能只依赖 JSON 解析或组件测试里的 `$t` mock。 - Vue i18n 文案中的普通 `@` 必须写成字面量插值 `{'@'}`,否则生产消息编译器会把它识别为 linked message 语法。网络管理语言包由 `network-locale.spec.ts` 逐条通过实际 i18n runtime 校验,不能只依赖 JSON 解析或组件测试里的 `$t` mock。
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API新设备验证二维码和腾讯验证码分开展示行操作“运行态”打开只读抽屉展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。 - QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API新设备验证二维码和腾讯验证码分开展示行操作“运行态”打开只读抽屉展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
- QQBot / 消息订阅与消息模板是两个平级菜单;新建订阅和新建模板均不默认选择消息源。订阅选择来源后才按该来源的 `subscriptionFields` 动态生成字段并加载候选项,不把通用订阅表单绑定到 STUN模板选择来源后才加载变量详情输入 `$` 后通过 Mentions 候选精确插入 `${{变量}}`。账号配置第四页签用于为当前 QQBot 选择订阅、模板以及群聊/私聊目标,不提供跨账号选择,两个目标选择框固定填满横向表单宽度。三个入口只在首次进入、显式刷新或成功写操作后更新列表,不使用后台轮询;菜单、按钮与账号页签分别受 `QqBot:MessageSubscription:*`、`QqBot:MessageTemplate:*` 和 `QqBot:Account:MessagePush:*` 权限控制。 - QQBot / 消息订阅与消息模板是两个平级菜单;新建订阅和新建模板均不默认选择消息源。订阅选择来源后才按该来源的 `subscriptionFields` 动态生成字段并加载候选项,不把通用订阅表单绑定到 STUN模板选择来源后才加载变量详情输入 `$` 后通过 Mentions 候选精确插入 `${{变量}}`。账号配置第四页签用于为当前 QQBot 选择订阅、模板以及群聊/私聊目标,不提供跨账号选择,两个目标选择框固定填满横向表单宽度;上述消息推送表单的必填、格式和长度校验提示统一使用中文。三个入口只在首次进入、显式刷新或成功写操作后更新列表,不使用后台轮询;菜单、按钮与账号页签分别受 `QqBot:MessageSubscription:*`、`QqBot:MessageTemplate:*` 和 `QqBot:Account:MessagePush:*` 权限控制。
源码目录禁止同级存放单元测试或 `__tests__`;全部单元测试统一放在根目录单数 `test/`,应用测试直接使用 `test/api`、`test/components`、`test/router`、`test/store`、`test/views`,共享包与内部工具分别使用 `test/packages`、`test/internal`,结构门禁位于 `test/governance` 源码目录禁止同级存放单元测试或 `__tests__`;全部单元测试统一放在根目录单数 `test/`,应用测试直接使用 `test/api`、`test/components`、`test/router`、`test/store`、`test/views`,共享包与内部工具分别使用 `test/packages`、`test/internal`,结构门禁位于 `test/governance`

View File

@ -243,7 +243,12 @@ function createFormSchema(
}), }),
fieldName: 'subscriptionId', fieldName: 'subscriptionId',
label: '消息订阅', label: '消息订阅',
rules: z.string().regex(DECIMAL_ID_PATTERN), rules: z
.string({
invalid_type_error: '消息订阅格式不正确',
required_error: '请选择消息订阅',
})
.regex(DECIMAL_ID_PATTERN, '请选择消息订阅'),
}, },
{ {
component: 'Select', component: 'Select',
@ -258,7 +263,12 @@ function createFormSchema(
}), }),
fieldName: 'templateId', fieldName: 'templateId',
label: '消息模板', label: '消息模板',
rules: z.string().regex(DECIMAL_ID_PATTERN), rules: z
.string({
invalid_type_error: '消息模板格式不正确',
required_error: '请选择消息模板',
})
.regex(DECIMAL_ID_PATTERN, '请选择消息模板'),
}, },
{ {
component: markRaw(MessagePushTargetPicker), component: markRaw(MessagePushTargetPicker),
@ -274,13 +284,27 @@ function createFormSchema(
rules: z rules: z
.array( .array(
z.object({ z.object({
targetId: z.string().regex(/^[1-9]\d{4,19}$/), targetId: z
targetName: z.string().max(120).optional(), .string({
targetType: z.enum(['group', 'private']), invalid_type_error: '推送目标格式不正确',
required_error: '请输入推送目标',
})
.regex(/^[1-9]\d{4,19}$/, '推送目标必须是有效的 QQ 号或群号'),
targetName: z
.string({ invalid_type_error: '推送目标名称格式不正确' })
.max(120, '推送目标名称不能超过 120 个字符')
.optional(),
targetType: z.enum(['group', 'private'], {
message: '推送目标类型无效',
}),
}), }),
{
invalid_type_error: '推送目标格式不正确',
required_error: '请至少选择一个推送目标',
},
) )
.min(1) .min(1, '请至少选择一个推送目标')
.max(100), .max(100, '推送目标不能超过 100 个'),
}, },
{ {
component: 'Switch', component: 'Switch',

View File

@ -348,7 +348,14 @@ function createFormSchema(
componentProps: { allowClear: true, maxlength: 100 }, componentProps: { allowClear: true, maxlength: 100 },
fieldName: 'name', fieldName: 'name',
label: '订阅名称', label: '订阅名称',
rules: z.string().trim().min(1).max(100), rules: z
.string({
invalid_type_error: '订阅名称格式不正确',
required_error: '请输入订阅名称',
})
.trim()
.min(1, '请输入订阅名称')
.max(100, '订阅名称不能超过 100 个字符'),
}, },
{ {
component: 'Select', component: 'Select',
@ -361,7 +368,12 @@ function createFormSchema(
}), }),
fieldName: 'sourceKey', fieldName: 'sourceKey',
label: '消息源', label: '消息源',
rules: z.string().min(1), rules: z
.string({
invalid_type_error: '消息源格式不正确',
required_error: '请选择消息源',
})
.min(1, '请选择消息源'),
}, },
...dynamicFields, ...dynamicFields,
{ {
@ -375,7 +387,11 @@ function createFormSchema(
componentProps: { allowClear: true, maxlength: 500, rows: 3 }, componentProps: { allowClear: true, maxlength: 500, rows: 3 },
fieldName: 'remark', fieldName: 'remark',
label: '备注', label: '备注',
rules: z.string().max(500).optional().or(z.literal('')), rules: z
.string({ invalid_type_error: '备注格式不正确' })
.max(500, '备注不能超过 500 个字符')
.optional()
.or(z.literal('')),
}, },
]; ];
} }
@ -391,7 +407,12 @@ function createSourceFieldSchema(
}>, }>,
sourceOptionsLoading: Readonly<{ value: boolean }>, sourceOptionsLoading: Readonly<{ value: boolean }>,
): VbenFormSchema { ): VbenFormSchema {
const optionalRule = z.string().optional().or(z.literal('')); const requiredMessage = `请选择${field.label}`;
const invalidTypeMessage = `${field.label}格式不正确`;
const optionalRule = z
.string({ invalid_type_error: invalidTypeMessage })
.optional()
.or(z.literal(''));
return { return {
component: 'Select', component: 'Select',
componentProps: () => ({ componentProps: () => ({
@ -405,7 +426,14 @@ function createSourceFieldSchema(
}), }),
fieldName: field.key, fieldName: field.key,
label: field.label, label: field.label,
rules: field.required ? z.string().min(1) : optionalRule, rules: field.required
? z
.string({
invalid_type_error: invalidTypeMessage,
required_error: requiredMessage,
})
.min(1, requiredMessage)
: optionalRule,
}; };
} }

View File

@ -322,14 +322,26 @@ function createFormSchema(
}), }),
fieldName: 'sourceKey', fieldName: 'sourceKey',
label: '消息源', label: '消息源',
rules: z.string().min(1), rules: z
.string({
invalid_type_error: '消息源格式不正确',
required_error: '请选择消息源',
})
.min(1, '请选择消息源'),
}, },
{ {
component: 'Input', component: 'Input',
componentProps: { allowClear: true, maxlength: 100 }, componentProps: { allowClear: true, maxlength: 100 },
fieldName: 'name', fieldName: 'name',
label: '模板名称', label: '模板名称',
rules: z.string().trim().min(1).max(100), rules: z
.string({
invalid_type_error: '模板名称格式不正确',
required_error: '请输入模板名称',
})
.trim()
.min(1, '请输入模板名称')
.max(100, '模板名称不能超过 100 个字符'),
}, },
{ {
component: markRaw(MessageTemplateMentions), component: markRaw(MessageTemplateMentions),
@ -341,7 +353,13 @@ function createFormSchema(
fieldName: 'content', fieldName: 'content',
label: '模板内容', label: '模板内容',
modelPropName: 'value', modelPropName: 'value',
rules: z.string().min(1).max(2000), rules: z
.string({
invalid_type_error: '模板内容格式不正确',
required_error: '请输入模板内容',
})
.min(1, '请输入模板内容')
.max(2000, '模板内容不能超过 2000 个字符'),
}, },
{ {
component: 'Switch', component: 'Switch',
@ -354,7 +372,11 @@ function createFormSchema(
componentProps: { allowClear: true, maxlength: 500, rows: 3 }, componentProps: { allowClear: true, maxlength: 500, rows: 3 },
fieldName: 'remark', fieldName: 'remark',
label: '备注', label: '备注',
rules: z.string().max(500).optional().or(z.literal('')), rules: z
.string({ invalid_type_error: '备注格式不正确' })
.max(500, '备注不能超过 500 个字符')
.optional()
.or(z.literal('')),
}, },
]; ];
} }

View File

@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => {
validate: vi.fn(async () => ({ valid: true })), validate: vi.fn(async () => ({ valid: true })),
}; };
return { return {
arrayRule: vi.fn(),
create: vi.fn(), create: vi.fn(),
currentValues: { currentValues: {
enabled: true, enabled: true,
@ -45,6 +46,7 @@ const mocks = vi.hoisted(() => {
formOptions: undefined as any, formOptions: undefined as any,
modalApi, modalApi,
modalOptions: undefined as any, modalOptions: undefined as any,
stringRule: vi.fn(),
update: vi.fn(), update: vi.fn(),
}; };
}); });
@ -97,11 +99,11 @@ vi.mock('#/adapter/form', () => ({
return [Form, mocks.formApi]; return [Form, mocks.formApi];
}), }),
z: { z: {
array: vi.fn(createRule), array: mocks.arrayRule,
enum: vi.fn(createRule), enum: vi.fn(createRule),
literal: vi.fn(createRule), literal: vi.fn(createRule),
object: vi.fn(createRule), object: vi.fn(createRule),
string: vi.fn(createRule), string: mocks.stringRule,
}, },
})); }));
@ -302,6 +304,8 @@ function mountModal(selfId = '10000000000000001') {
describe('account message-push modal', () => { describe('account message-push modal', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.arrayRule.mockImplementation(createRule);
mocks.stringRule.mockImplementation(createRule);
mocks.currentValues = { mocks.currentValues = {
enabled: true, enabled: true,
subscriptionId: '20000000000000001', subscriptionId: '20000000000000001',
@ -367,6 +371,40 @@ describe('account message-push modal', () => {
]); ]);
}); });
it('uses Chinese validation messages for subscription, template, and targets', () => {
mountModal();
const fieldRule = (fieldName: string) =>
mocks.formOptions.schema.find(
(field: any) => field.fieldName === fieldName,
).rules;
expect(fieldRule('subscriptionId').regex).toHaveBeenCalledWith(
expect.any(RegExp),
'请选择消息订阅',
);
expect(fieldRule('templateId').regex).toHaveBeenCalledWith(
expect.any(RegExp),
'请选择消息模板',
);
expect(fieldRule('targets').min).toHaveBeenCalledWith(
1,
'请至少选择一个推送目标',
);
expect(fieldRule('targets').max).toHaveBeenCalledWith(
100,
'推送目标不能超过 100 个',
);
for (const result of mocks.stringRule.mock.results) {
const rule = result.value;
for (const method of ['max', 'regex']) {
for (const call of rule[method].mock.calls) {
expect(call[1]).toMatch(/[\u4E00-\u9FFF]/u);
}
}
}
});
it('isolates edit/create sessions and preserves only editable string data', async () => { it('isolates edit/create sessions and preserves only editable string data', async () => {
const wrapper = mountModal(); const wrapper = mountModal();
(wrapper.vm as any).openEdit(createBinding()); (wrapper.vm as any).openEdit(createBinding());

View File

@ -306,6 +306,38 @@ describe('message subscription modal', () => {
expect(mocks.getOptions).not.toHaveBeenCalled(); expect(mocks.getOptions).not.toHaveBeenCalled();
}); });
it('订阅固定字段和消息源动态必填字段均使用中文校验文案', async () => {
mountModal();
const fieldRule = (fieldName: string) =>
mocks.formOptions.schema.find(
(field: any) => field.fieldName === fieldName,
).rules;
expect(fieldRule('name').min).toHaveBeenCalledWith(1, '请输入订阅名称');
expect(fieldRule('name').max).toHaveBeenCalledWith(
100,
'订阅名称不能超过 100 个字符',
);
expect(fieldRule('sourceKey').min).toHaveBeenCalledWith(1, '请选择消息源');
expect(fieldRule('remark').max).toHaveBeenCalledWith(
500,
'备注不能超过 500 个字符',
);
await mocks.formOptions.handleValuesChange({ sourceKey: STUN_SOURCE_KEY }, [
'sourceKey',
]);
expect(fieldRule('portForwardId').min).toHaveBeenCalledWith(
1,
'请选择STUN 端口转发',
);
expect(fieldRule('ddnsRecordId').min).toHaveBeenCalledWith(
1,
'请选择IPv4 DDNS 记录',
);
});
it('选择消息源后按元数据生成字段并过滤依赖候选项', async () => { it('选择消息源后按元数据生成字段并过滤依赖候选项', async () => {
mountModal(); mountModal();

View File

@ -233,6 +233,30 @@ describe('message template modal', () => {
).toBe(500); ).toBe(500);
}); });
it('uses Chinese validation messages for every template field', () => {
mountModal();
const fieldRule = (fieldName: string) =>
mocks.formOptions.schema.find(
(field: any) => field.fieldName === fieldName,
).rules;
expect(fieldRule('sourceKey').min).toHaveBeenCalledWith(1, '请选择消息源');
expect(fieldRule('name').min).toHaveBeenCalledWith(1, '请输入模板名称');
expect(fieldRule('name').max).toHaveBeenCalledWith(
100,
'模板名称不能超过 100 个字符',
);
expect(fieldRule('content').min).toHaveBeenCalledWith(1, '请输入模板内容');
expect(fieldRule('content').max).toHaveBeenCalledWith(
2000,
'模板内容不能超过 2000 个字符',
);
expect(fieldRule('remark').max).toHaveBeenCalledWith(
500,
'备注不能超过 500 个字符',
);
});
it('opens create with an empty source and does not preload source detail', async () => { it('opens create with an empty source and does not preload source detail', async () => {
const wrapper = mountModal(); const wrapper = mountModal();
mocks.formApi.resetForm.mockImplementation(async () => { mocks.formApi.resetForm.mockImplementation(async () => {