From 4c78b10c0cc59ea1f2986f249c8fbc479a24ea33 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 25 Jul 2026 15:26:57 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E4=B8=AD=E6=96=87=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../components/AccountMessagePushModal.tsx | 38 +++++++++++++---- .../components/MessageSubscriptionModal.tsx | 38 ++++++++++++++--- .../components/MessageTemplateModal.tsx | 30 +++++++++++-- .../AccountMessagePushModal.spec.tsx | 42 ++++++++++++++++++- .../MessageSubscriptionModal.spec.tsx | 32 ++++++++++++++ .../components/MessageTemplateModal.spec.tsx | 24 +++++++++++ 7 files changed, 187 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6f967ab..f9f372b 100644 --- a/README.md +++ b/README.md @@ -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 的公网 IPv4,AAAA 只使用 Agent 全局 IPv6,DNS 值不包含端口。首屏读取一次 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。 - 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`。 diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx index 6594698..9597fac 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx @@ -243,7 +243,12 @@ function createFormSchema( }), fieldName: 'subscriptionId', label: '消息订阅', - rules: z.string().regex(DECIMAL_ID_PATTERN), + rules: z + .string({ + invalid_type_error: '消息订阅格式不正确', + required_error: '请选择消息订阅', + }) + .regex(DECIMAL_ID_PATTERN, '请选择消息订阅'), }, { component: 'Select', @@ -258,7 +263,12 @@ function createFormSchema( }), fieldName: 'templateId', label: '消息模板', - rules: z.string().regex(DECIMAL_ID_PATTERN), + rules: z + .string({ + invalid_type_error: '消息模板格式不正确', + required_error: '请选择消息模板', + }) + .regex(DECIMAL_ID_PATTERN, '请选择消息模板'), }, { component: markRaw(MessagePushTargetPicker), @@ -274,13 +284,27 @@ function createFormSchema( rules: z .array( z.object({ - targetId: z.string().regex(/^[1-9]\d{4,19}$/), - targetName: z.string().max(120).optional(), - targetType: z.enum(['group', 'private']), + targetId: z + .string({ + 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) - .max(100), + .min(1, '请至少选择一个推送目标') + .max(100, '推送目标不能超过 100 个'), }, { component: 'Switch', diff --git a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx index 80945e8..4212a82 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx +++ b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx @@ -348,7 +348,14 @@ function createFormSchema( componentProps: { allowClear: true, maxlength: 100 }, fieldName: 'name', 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', @@ -361,7 +368,12 @@ function createFormSchema( }), fieldName: 'sourceKey', label: '消息源', - rules: z.string().min(1), + rules: z + .string({ + invalid_type_error: '消息源格式不正确', + required_error: '请选择消息源', + }) + .min(1, '请选择消息源'), }, ...dynamicFields, { @@ -375,7 +387,11 @@ function createFormSchema( componentProps: { allowClear: true, maxlength: 500, rows: 3 }, fieldName: 'remark', 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 }>, ): 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 { component: 'Select', componentProps: () => ({ @@ -405,7 +426,14 @@ function createSourceFieldSchema( }), fieldName: field.key, 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, }; } diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx index a1db3d1..3e66643 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx +++ b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx @@ -322,14 +322,26 @@ function createFormSchema( }), fieldName: 'sourceKey', label: '消息源', - rules: z.string().min(1), + rules: z + .string({ + invalid_type_error: '消息源格式不正确', + required_error: '请选择消息源', + }) + .min(1, '请选择消息源'), }, { component: 'Input', componentProps: { allowClear: true, maxlength: 100 }, fieldName: 'name', 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), @@ -341,7 +353,13 @@ function createFormSchema( fieldName: 'content', label: '模板内容', modelPropName: 'value', - rules: z.string().min(1).max(2000), + rules: z + .string({ + invalid_type_error: '模板内容格式不正确', + required_error: '请输入模板内容', + }) + .min(1, '请输入模板内容') + .max(2000, '模板内容不能超过 2000 个字符'), }, { component: 'Switch', @@ -354,7 +372,11 @@ function createFormSchema( componentProps: { allowClear: true, maxlength: 500, rows: 3 }, fieldName: 'remark', label: '备注', - rules: z.string().max(500).optional().or(z.literal('')), + rules: z + .string({ invalid_type_error: '备注格式不正确' }) + .max(500, '备注不能超过 500 个字符') + .optional() + .or(z.literal('')), }, ]; } diff --git a/test/views/qqbot/account/components/AccountMessagePushModal.spec.tsx b/test/views/qqbot/account/components/AccountMessagePushModal.spec.tsx index a83b574..cd9b56a 100644 --- a/test/views/qqbot/account/components/AccountMessagePushModal.spec.tsx +++ b/test/views/qqbot/account/components/AccountMessagePushModal.spec.tsx @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => { validate: vi.fn(async () => ({ valid: true })), }; return { + arrayRule: vi.fn(), create: vi.fn(), currentValues: { enabled: true, @@ -45,6 +46,7 @@ const mocks = vi.hoisted(() => { formOptions: undefined as any, modalApi, modalOptions: undefined as any, + stringRule: vi.fn(), update: vi.fn(), }; }); @@ -97,11 +99,11 @@ vi.mock('#/adapter/form', () => ({ return [Form, mocks.formApi]; }), z: { - array: vi.fn(createRule), + array: mocks.arrayRule, enum: vi.fn(createRule), literal: 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', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.arrayRule.mockImplementation(createRule); + mocks.stringRule.mockImplementation(createRule); mocks.currentValues = { enabled: true, 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 () => { const wrapper = mountModal(); (wrapper.vm as any).openEdit(createBinding()); diff --git a/test/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx b/test/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx index 7157868..ae1d359 100644 --- a/test/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx +++ b/test/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx @@ -306,6 +306,38 @@ describe('message subscription modal', () => { 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 () => { mountModal(); diff --git a/test/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx b/test/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx index 6d11f2c..3d4afa8 100644 --- a/test/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx +++ b/test/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx @@ -233,6 +233,30 @@ describe('message template modal', () => { ).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 () => { const wrapper = mountModal(); mocks.formApi.resetForm.mockImplementation(async () => {