fix: 统一消息推送中文校验
This commit is contained in:
parent
b96003edcc
commit
4c78b10c0c
@ -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`。
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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('')),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user