feat: 增加账号消息推送配置
This commit is contained in:
parent
4c41a2bdff
commit
d65be70048
@ -0,0 +1,250 @@
|
||||
/* @vitest-environment happy-dom */
|
||||
|
||||
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
|
||||
|
||||
import type { QqbotApi } from '#/api/qqbot';
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AccountConfigPanel from './AccountConfigPanel';
|
||||
import AccountMessagePushPanel from './AccountMessagePushPanel';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: {
|
||||
bindCommand: vi.fn(),
|
||||
bindEvent: vi.fn(),
|
||||
bindRule: vi.fn(),
|
||||
getCommands: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
getRules: vi.fn(),
|
||||
unbindCommand: vi.fn(),
|
||||
unbindEvent: vi.fn(),
|
||||
unbindRule: vi.fn(),
|
||||
},
|
||||
legacyTableProps: [] as Array<Record<string, any>>,
|
||||
}));
|
||||
|
||||
vi.mock('#/api/qqbot', () => ({
|
||||
bindQqbotAccountCommand: mocks.api.bindCommand,
|
||||
bindQqbotAccountRule: mocks.api.bindRule,
|
||||
getQqbotCommandList: mocks.api.getCommands,
|
||||
getQqbotRuleList: mocks.api.getRules,
|
||||
unbindQqbotAccountCommand: mocks.api.unbindCommand,
|
||||
unbindQqbotAccountRule: mocks.api.unbindRule,
|
||||
}));
|
||||
|
||||
vi.mock('#/api/qqbot/plugin', () => ({
|
||||
bindQqbotEventPlugin: mocks.api.bindEvent,
|
||||
getQqbotEventPluginList: mocks.api.getEvents,
|
||||
unbindQqbotEventPlugin: mocks.api.unbindEvent,
|
||||
}));
|
||||
|
||||
vi.mock('#/components/ktTable', () => ({
|
||||
KtTable: defineComponent({
|
||||
name: 'MockLegacyKtTable',
|
||||
inheritAttrs: false,
|
||||
/**
|
||||
* Renders the real parent-owned header callbacks and records legacy table props.
|
||||
*/
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => {
|
||||
mocks.legacyTableProps.push(attrs);
|
||||
return h('section', { 'data-testid': 'legacy-table' }, [
|
||||
slots.title?.(),
|
||||
slots.headerControls?.(),
|
||||
]);
|
||||
};
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('antdv-next', () => ({
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
Spin: defineComponent({
|
||||
name: 'MockSpin',
|
||||
/** Keeps the legacy table subtree mounted for regression assertions. */
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', slots.default?.());
|
||||
},
|
||||
}),
|
||||
Tabs: defineComponent({
|
||||
name: 'MockTabs',
|
||||
props: {
|
||||
activeKey: String,
|
||||
items: Array,
|
||||
},
|
||||
emits: ['update:activeKey'],
|
||||
/** Exposes every configured tab through a deterministic clickable button. */
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'nav',
|
||||
(
|
||||
props.items as Array<{ key: string; label: string }> | undefined
|
||||
)?.map((item) =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-tab-key': item.key,
|
||||
onClick: () => emit('update:activeKey', item.key),
|
||||
},
|
||||
item.label,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
Tag: defineComponent({
|
||||
name: 'MockTag',
|
||||
/** Renders tag text without changing the parent structure. */
|
||||
setup(_, { slots }) {
|
||||
return () => h('span', slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./AccountMessagePushPanel', () => ({
|
||||
default: defineComponent({
|
||||
name: 'AccountMessagePushPanel',
|
||||
props: {
|
||||
headerControls: Function,
|
||||
selfId: String,
|
||||
title: Function,
|
||||
},
|
||||
/** Renders both inherited header callbacks to prove the ownership boundary. */
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('section', { 'data-testid': 'message-push-panel' }, [
|
||||
(props.title as (() => unknown) | undefined)?.(),
|
||||
(props.headerControls as (() => unknown) | undefined)?.(),
|
||||
] as any);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Creates the current route account with an unsafe-integer string self ID. */
|
||||
function createAccount(): QqbotApi.Account {
|
||||
return {
|
||||
connectStatus: 'online',
|
||||
connectionMode: 'reverse-ws',
|
||||
enabled: true,
|
||||
id: '1',
|
||||
name: 'Bot A',
|
||||
selfId: '10000000000000001',
|
||||
};
|
||||
}
|
||||
|
||||
describe('qqbot account config panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.legacyTableProps.length = 0;
|
||||
mocks.api.getCommands.mockResolvedValue({ list: [], total: 0 });
|
||||
mocks.api.getEvents.mockResolvedValue([]);
|
||||
mocks.api.getRules.mockResolvedValue({ list: [], total: 0 });
|
||||
});
|
||||
|
||||
it('appends the exact fourth tab after all existing tabs', async () => {
|
||||
const wrapper = mount(AccountConfigPanel, {
|
||||
props: { account: createAccount() },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('[data-tab-key]')
|
||||
.map((tab) => [tab.attributes('data-tab-key'), tab.text()]),
|
||||
).toEqual([
|
||||
['command', '在线命令'],
|
||||
['event', '事件触发'],
|
||||
['rule', '自动回复规则'],
|
||||
['message-push', '消息推送'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the three legacy tabs on their original KtTable boundary', async () => {
|
||||
const wrapper = mount(AccountConfigPanel, {
|
||||
props: { account: createAccount() },
|
||||
});
|
||||
await flushPromises();
|
||||
const expectedColumns = {
|
||||
command: [
|
||||
'name',
|
||||
'code',
|
||||
'aliases',
|
||||
'pluginKey',
|
||||
'targetType',
|
||||
'enabled',
|
||||
'bound',
|
||||
],
|
||||
event: ['name', 'key', 'triggerType', 'description', 'bound'],
|
||||
rule: [
|
||||
'name',
|
||||
'keyword',
|
||||
'matchType',
|
||||
'targetType',
|
||||
'replyContent',
|
||||
'enabled',
|
||||
'bound',
|
||||
],
|
||||
};
|
||||
|
||||
for (const key of ['command', 'event', 'rule']) {
|
||||
await wrapper.get(`[data-tab-key="${key}"]`).trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll('[data-testid="legacy-table"]')).toHaveLength(1);
|
||||
expect(wrapper.find('[data-testid="message-push-panel"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
const latest = mocks.legacyTableProps.at(-1);
|
||||
expect(
|
||||
latest?.columns.map((column: { key: string }) => column.key),
|
||||
).toEqual(expectedColumns[key as keyof typeof expectedColumns]);
|
||||
expect(
|
||||
latest?.rowActions.map((action: { key: string }) => action.key),
|
||||
).toEqual(['bind', 'unbind']);
|
||||
expect(Array.isArray(latest?.dataSource)).toBe(true);
|
||||
expect(typeof latest?.rowKey).toBe(
|
||||
key === 'event' ? 'function' : 'string',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('passes the exact implicit publisher and existing header callbacks', async () => {
|
||||
const wrapper = mount(AccountConfigPanel, {
|
||||
props: { account: createAccount() },
|
||||
});
|
||||
await wrapper.get('[data-tab-key="message-push"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const panel = wrapper.getComponent(AccountMessagePushPanel);
|
||||
expect(panel.props('selfId')).toBe('10000000000000001');
|
||||
expect(panel.props('title')).toBeTypeOf('function');
|
||||
expect(panel.props('headerControls')).toBeTypeOf('function');
|
||||
expect(wrapper.text()).toContain('Self ID:10000000000000001');
|
||||
expect(wrapper.text()).toContain('Bot A');
|
||||
expect(wrapper.find('[data-testid="legacy-table"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves one stable outer element root across tab changes', async () => {
|
||||
const wrapper = mount(AccountConfigPanel, {
|
||||
props: { account: createAccount() },
|
||||
});
|
||||
const root = wrapper.element;
|
||||
|
||||
await wrapper.get('[data-tab-key="message-push"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.element).toBe(root);
|
||||
expect(wrapper.findAll('.qqbot-account-config-panel')).toHaveLength(1);
|
||||
|
||||
await wrapper.get('[data-tab-key="command"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.element).toBe(root);
|
||||
expect(wrapper.findAll('.qqbot-account-config-panel')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -30,6 +30,7 @@ import {
|
||||
qqbotRuleTargetOptions,
|
||||
} from '../../modules/options';
|
||||
import { getQqbotStatusColor, getQqbotStatusLabel } from '../../modules/status';
|
||||
import AccountMessagePushPanel from './AccountMessagePushPanel';
|
||||
|
||||
const AKtTable = KtTable as any;
|
||||
const ASpin = Spin as any;
|
||||
@ -39,6 +40,7 @@ const configTabItems = [
|
||||
{ key: 'command', label: '在线命令' },
|
||||
{ key: 'event', label: '事件触发' },
|
||||
{ key: 'rule', label: '自动回复规则' },
|
||||
{ key: 'message-push', label: '消息推送' },
|
||||
] as const;
|
||||
|
||||
type ConfigTabKey = (typeof configTabItems)[number]['key'];
|
||||
@ -431,28 +433,36 @@ export default defineComponent({
|
||||
|
||||
return () => (
|
||||
<div class="qqbot-account-config-panel">
|
||||
<div class="qqbot-account-config-panel__spin">
|
||||
<ASpin spinning={loading.value}>
|
||||
<AKtTable
|
||||
class="qqbot-account-config-panel__table"
|
||||
columns={activeColumns.value}
|
||||
dataSource={activeRows.value}
|
||||
rowActions={activeRowActions.value}
|
||||
rowKey={activeRowKey.value}
|
||||
showDefaultButtons={false}
|
||||
showFooter={false}
|
||||
showIndex={false}
|
||||
showPagination={false}
|
||||
showTableSetting={false}
|
||||
size="small"
|
||||
v-slots={{
|
||||
bodyCell: renderBodyCell,
|
||||
headerControls: renderHeaderControls,
|
||||
title: renderTableTitle,
|
||||
}}
|
||||
/>
|
||||
</ASpin>
|
||||
</div>
|
||||
{activeTab.value === 'message-push' ? (
|
||||
<AccountMessagePushPanel
|
||||
headerControls={renderHeaderControls}
|
||||
selfId={currentSelfId.value}
|
||||
title={renderTableTitle}
|
||||
/>
|
||||
) : (
|
||||
<div class="qqbot-account-config-panel__spin">
|
||||
<ASpin spinning={loading.value}>
|
||||
<AKtTable
|
||||
class="qqbot-account-config-panel__table"
|
||||
columns={activeColumns.value}
|
||||
dataSource={activeRows.value}
|
||||
rowActions={activeRowActions.value}
|
||||
rowKey={activeRowKey.value}
|
||||
showDefaultButtons={false}
|
||||
showFooter={false}
|
||||
showIndex={false}
|
||||
showPagination={false}
|
||||
showTableSetting={false}
|
||||
size="small"
|
||||
v-slots={{
|
||||
bodyCell: renderBodyCell,
|
||||
headerControls: renderHeaderControls,
|
||||
title: renderTableTitle,
|
||||
}}
|
||||
/>
|
||||
</ASpin>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@ -0,0 +1,564 @@
|
||||
/* @vitest-environment happy-dom */
|
||||
|
||||
/* eslint-disable no-template-curly-in-string, vue/one-component-per-file, vue/require-default-prop */
|
||||
|
||||
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AccountMessagePushModal from './AccountMessagePushModal';
|
||||
|
||||
interface FormValues {
|
||||
enabled: boolean;
|
||||
subscriptionId: string;
|
||||
targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[];
|
||||
templateId: string;
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const modalApi = {
|
||||
close: vi.fn(async () => {}),
|
||||
getData: vi.fn(),
|
||||
lock: vi.fn(),
|
||||
open: vi.fn(),
|
||||
setData: vi.fn(),
|
||||
unlock: vi.fn(),
|
||||
};
|
||||
const formApi = {
|
||||
getValues: vi.fn(),
|
||||
resetForm: vi.fn(async () => {}),
|
||||
resetValidate: vi.fn(async () => {}),
|
||||
setValues: vi.fn(),
|
||||
validate: vi.fn(async () => ({ valid: true })),
|
||||
};
|
||||
return {
|
||||
create: vi.fn(),
|
||||
currentValues: {
|
||||
enabled: true,
|
||||
subscriptionId: '',
|
||||
targets: [],
|
||||
templateId: '',
|
||||
} as FormValues,
|
||||
formApi,
|
||||
formOptions: undefined as any,
|
||||
modalApi,
|
||||
modalOptions: undefined as any,
|
||||
update: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
/** Creates one fluent no-op Zod rule for schema-focused tests. */
|
||||
function createRule(): any {
|
||||
const rule: any = {};
|
||||
for (const method of [
|
||||
'array',
|
||||
'max',
|
||||
'min',
|
||||
'object',
|
||||
'optional',
|
||||
'or',
|
||||
'regex',
|
||||
]) {
|
||||
rule[method] = vi.fn(() => rule);
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
vi.mock('#/adapter/form', () => ({
|
||||
useVbenForm: vi.fn((options) => {
|
||||
mocks.formOptions = options;
|
||||
const Form = defineComponent({
|
||||
name: 'MockBindingForm',
|
||||
/** Renders the schema's real local target-picker component and model wiring. */
|
||||
setup() {
|
||||
return () => {
|
||||
const targetField = mocks.formOptions.schema.find(
|
||||
(field: any) => field.fieldName === 'targets',
|
||||
);
|
||||
const TargetPicker = targetField?.component;
|
||||
return h('form', { 'data-testid': 'binding-form' }, [
|
||||
TargetPicker
|
||||
? h(TargetPicker, {
|
||||
...(typeof targetField.componentProps === 'function'
|
||||
? targetField.componentProps()
|
||||
: targetField.componentProps),
|
||||
'onUpdate:value': (
|
||||
value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||
) => {
|
||||
mocks.currentValues.targets = value;
|
||||
},
|
||||
value: mocks.currentValues.targets,
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
};
|
||||
},
|
||||
});
|
||||
return [Form, mocks.formApi];
|
||||
}),
|
||||
z: {
|
||||
array: vi.fn(createRule),
|
||||
enum: vi.fn(createRule),
|
||||
literal: vi.fn(createRule),
|
||||
object: vi.fn(createRule),
|
||||
string: vi.fn(createRule),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@vben/common-ui', () => ({
|
||||
useVbenModal: vi.fn((options) => {
|
||||
mocks.modalOptions = options;
|
||||
const Modal = defineComponent({
|
||||
name: 'MockBindingModal',
|
||||
/** Renders modal content while lifecycle remains API-controlled. */
|
||||
setup(_, { slots }) {
|
||||
return () => h('section', slots.default?.());
|
||||
},
|
||||
});
|
||||
return [Modal, mocks.modalApi];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./MessagePushTargetPicker', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MockMessagePushTargetPicker',
|
||||
props: {
|
||||
available: Boolean,
|
||||
disabled: Boolean,
|
||||
loading: Boolean,
|
||||
options: Array,
|
||||
reasonCode: String,
|
||||
value: Array,
|
||||
},
|
||||
emits: ['update:value'],
|
||||
/** Exposes one button that exercises the Vben `modelPropName:value` path. */
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'target-picker',
|
||||
onClick: () =>
|
||||
emit('update:value', [
|
||||
{ targetId: '12345', targetType: 'group' },
|
||||
]),
|
||||
},
|
||||
'target-picker',
|
||||
);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Mirrors the production helper so modal tests retain the exact validation boundary.
|
||||
*/
|
||||
isValidMessagePushTargetId: (targetId: string) =>
|
||||
/^[1-9]\d{4,19}$/.test(targetId),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/qqbot/message-push', () => ({
|
||||
createAccountMessagePushBinding: mocks.create,
|
||||
updateAccountMessagePushBinding: mocks.update,
|
||||
}));
|
||||
|
||||
/** Creates two source families to test compatible-template filtering. */
|
||||
function createSubscriptions(): QqbotMessagePushApi.MessageSubscriptionView[] {
|
||||
return [
|
||||
{
|
||||
createTime: '2026-07-24 10:00:00',
|
||||
enabled: true,
|
||||
id: '20000000000000001',
|
||||
invalidReasonCode: null,
|
||||
name: 'STUN A',
|
||||
remark: null,
|
||||
sourceConfig: {
|
||||
ddnsRecordId: '60000000000000001',
|
||||
portForwardId: '70000000000000001',
|
||||
},
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
sourceName: 'STUN 端口变更',
|
||||
sourceSummary: 'A',
|
||||
updateTime: '2026-07-24 10:00:00',
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
createTime: '2026-07-24 10:00:00',
|
||||
enabled: true,
|
||||
id: '20000000000000002',
|
||||
invalidReasonCode: null,
|
||||
name: 'STUN B',
|
||||
remark: null,
|
||||
sourceConfig: {
|
||||
ddnsRecordId: '60000000000000002',
|
||||
portForwardId: '70000000000000002',
|
||||
},
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
sourceName: 'STUN 端口变更',
|
||||
sourceSummary: 'B',
|
||||
updateTime: '2026-07-24 10:00:00',
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
createTime: '2026-07-24 10:00:00',
|
||||
enabled: true,
|
||||
id: '20000000000000003',
|
||||
invalidReasonCode: null,
|
||||
name: 'Other',
|
||||
remark: null,
|
||||
sourceConfig: {
|
||||
ddnsRecordId: '60000000000000003',
|
||||
portForwardId: '70000000000000003',
|
||||
},
|
||||
sourceKey: 'system.other',
|
||||
sourceName: 'Other',
|
||||
sourceSummary: 'Other',
|
||||
updateTime: '2026-07-24 10:00:00',
|
||||
valid: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Creates templates in both source families. */
|
||||
function createTemplates(): QqbotMessagePushApi.MessageTemplateView[] {
|
||||
return [
|
||||
{
|
||||
content: '${{endpoint}}',
|
||||
createTime: '2026-07-24 10:00:00',
|
||||
enabled: true,
|
||||
id: '50000000000000001',
|
||||
name: 'STUN 模板',
|
||||
referenceCount: 0,
|
||||
remark: null,
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
sourceName: 'STUN 端口变更',
|
||||
updateTime: '2026-07-24 10:00:00',
|
||||
},
|
||||
{
|
||||
content: 'other',
|
||||
createTime: '2026-07-24 10:00:00',
|
||||
enabled: true,
|
||||
id: '50000000000000002',
|
||||
name: 'Other 模板',
|
||||
referenceCount: 0,
|
||||
remark: null,
|
||||
sourceKey: 'system.other',
|
||||
sourceName: 'Other',
|
||||
updateTime: '2026-07-24 10:00:00',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Creates an edit row whose targets retain API-provided names as strings. */
|
||||
function createBinding(): QqbotMessagePushApi.QqbotMessagePublishBindingView {
|
||||
return {
|
||||
available: true,
|
||||
createTime: '2026-07-24 10:00:00',
|
||||
enabled: false,
|
||||
id: '10000000000000001',
|
||||
invalidReasonCode: null,
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
sourceName: 'STUN 端口变更',
|
||||
subscriptionId: '20000000000000001',
|
||||
subscriptionName: 'STUN A',
|
||||
targets: [
|
||||
{
|
||||
enabled: true,
|
||||
id: '30000000000000001',
|
||||
targetId: '40000000000000001',
|
||||
targetName: '保留群名',
|
||||
targetType: 'group',
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
id: '30000000000000002',
|
||||
targetId: '40000000000000002',
|
||||
targetName: null,
|
||||
targetType: 'private',
|
||||
},
|
||||
],
|
||||
templateId: '50000000000000001',
|
||||
templateName: 'STUN 模板',
|
||||
updateTime: '2026-07-24 10:00:00',
|
||||
};
|
||||
}
|
||||
|
||||
/** Mounts the modal with one implicit publisher and page-owned metadata. */
|
||||
function mountModal(selfId = '10000000000000001') {
|
||||
return mount(AccountMessagePushModal, {
|
||||
props: {
|
||||
selfId,
|
||||
subscriptions: createSubscriptions(),
|
||||
targetOptions: {
|
||||
available: false,
|
||||
options: [],
|
||||
reasonCode: 'onebot_unavailable',
|
||||
},
|
||||
targetOptionsLoading: false,
|
||||
templates: createTemplates(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('account message-push modal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.currentValues = {
|
||||
enabled: true,
|
||||
subscriptionId: '20000000000000001',
|
||||
targets: [{ targetId: '40000000000000001', targetType: 'group' }],
|
||||
templateId: '50000000000000001',
|
||||
};
|
||||
mocks.formApi.getValues.mockImplementation(async () => ({
|
||||
...mocks.currentValues,
|
||||
targets: mocks.currentValues.targets.map((target) => ({ ...target })),
|
||||
}));
|
||||
mocks.formApi.resetForm.mockImplementation(async () => {
|
||||
mocks.currentValues = {
|
||||
enabled: true,
|
||||
subscriptionId: '',
|
||||
targets: [],
|
||||
templateId: '',
|
||||
};
|
||||
});
|
||||
mocks.formApi.setValues.mockImplementation(
|
||||
async (patch: Partial<FormValues>) => {
|
||||
mocks.currentValues = { ...mocks.currentValues, ...patch };
|
||||
},
|
||||
);
|
||||
mocks.modalApi.getData.mockImplementation(
|
||||
() => mocks.modalApi.setData.mock.calls.at(-1)?.[0] || {},
|
||||
);
|
||||
mocks.modalApi.setData.mockImplementation(() => mocks.modalApi);
|
||||
mocks.modalApi.open.mockImplementation(() => {
|
||||
void mocks.modalOptions.onOpenChange?.(true);
|
||||
return mocks.modalApi;
|
||||
});
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.update.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('renders the exact four fields and local value-model target picker', async () => {
|
||||
const wrapper = mountModal();
|
||||
const fields = mocks.formOptions.schema.map(
|
||||
(field: any) => field.fieldName,
|
||||
);
|
||||
|
||||
expect(fields).toEqual([
|
||||
'subscriptionId',
|
||||
'templateId',
|
||||
'targets',
|
||||
'enabled',
|
||||
]);
|
||||
expect(JSON.stringify(fields)).not.toMatch(/account|selfId/i);
|
||||
const targetField = mocks.formOptions.schema.find(
|
||||
(field: any) => field.fieldName === 'targets',
|
||||
);
|
||||
expect(targetField.modelPropName).toBe('value');
|
||||
expect(
|
||||
wrapper
|
||||
.get('[data-testid="binding-form"]')
|
||||
.find('[data-testid="target-picker"]')
|
||||
.exists(),
|
||||
).toBe(true);
|
||||
|
||||
await wrapper.get('[data-testid="target-picker"]').trigger('click');
|
||||
expect(mocks.currentValues.targets).toEqual([
|
||||
{ targetId: '12345', targetType: 'group' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('isolates edit/create sessions and preserves only editable string data', async () => {
|
||||
const wrapper = mountModal();
|
||||
(wrapper.vm as any).openEdit(createBinding());
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({
|
||||
enabled: false,
|
||||
subscriptionId: '20000000000000001',
|
||||
targets: [
|
||||
{
|
||||
targetId: '40000000000000001',
|
||||
targetName: '保留群名',
|
||||
targetType: 'group',
|
||||
},
|
||||
{
|
||||
targetId: '40000000000000002',
|
||||
targetType: 'private',
|
||||
},
|
||||
],
|
||||
templateId: '50000000000000001',
|
||||
});
|
||||
|
||||
(wrapper.vm as any).openCreate();
|
||||
await flushPromises();
|
||||
expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({
|
||||
enabled: true,
|
||||
subscriptionId: '',
|
||||
targets: [],
|
||||
templateId: '',
|
||||
});
|
||||
expect(mocks.formApi.resetForm).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.formApi.resetValidate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('filters templates by source and clears only an incompatible choice', async () => {
|
||||
mountModal();
|
||||
const templateField = mocks.formOptions.schema.find(
|
||||
(field: any) => field.fieldName === 'templateId',
|
||||
);
|
||||
|
||||
await mocks.formOptions.handleValuesChange(
|
||||
{
|
||||
subscriptionId: '20000000000000001',
|
||||
templateId: '50000000000000001',
|
||||
},
|
||||
['subscriptionId'],
|
||||
);
|
||||
expect(templateField.componentProps().options).toEqual([
|
||||
expect.objectContaining({ value: '50000000000000001' }),
|
||||
]);
|
||||
expect(mocks.formApi.setValues).not.toHaveBeenCalledWith({
|
||||
templateId: '',
|
||||
});
|
||||
|
||||
await mocks.formOptions.handleValuesChange(
|
||||
{
|
||||
subscriptionId: '20000000000000002',
|
||||
templateId: '50000000000000001',
|
||||
},
|
||||
['subscriptionId'],
|
||||
);
|
||||
expect(mocks.formApi.setValues).not.toHaveBeenCalledWith({
|
||||
templateId: '',
|
||||
});
|
||||
|
||||
await mocks.formOptions.handleValuesChange(
|
||||
{
|
||||
subscriptionId: '20000000000000003',
|
||||
templateId: '50000000000000001',
|
||||
},
|
||||
['subscriptionId'],
|
||||
);
|
||||
expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({
|
||||
templateId: '',
|
||||
});
|
||||
expect(templateField.componentProps().options).toEqual([
|
||||
expect.objectContaining({ value: '50000000000000002' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates and updates exact four-member bodies with selfId only as path arg', async () => {
|
||||
const wrapper = mountModal();
|
||||
(wrapper.vm as any).openCreate();
|
||||
await flushPromises();
|
||||
mocks.currentValues = {
|
||||
enabled: true,
|
||||
subscriptionId: '20000000000000001',
|
||||
targets: [
|
||||
{
|
||||
targetId: '40000000000000001',
|
||||
targetName: '测试群',
|
||||
targetType: 'group',
|
||||
},
|
||||
],
|
||||
templateId: '50000000000000001',
|
||||
};
|
||||
await mocks.modalOptions.onConfirm();
|
||||
|
||||
const expectedBody: QqbotMessagePushApi.QqbotMessagePublishBindingInput = {
|
||||
enabled: true,
|
||||
subscriptionId: '20000000000000001',
|
||||
targets: [
|
||||
{
|
||||
targetId: '40000000000000001',
|
||||
targetName: '测试群',
|
||||
targetType: 'group',
|
||||
},
|
||||
],
|
||||
templateId: '50000000000000001',
|
||||
};
|
||||
expect(mocks.create).toHaveBeenCalledWith(
|
||||
'10000000000000001',
|
||||
expectedBody,
|
||||
);
|
||||
expect(
|
||||
Object.keys(mocks.create.mock.calls[0]?.[1] || {}).toSorted(),
|
||||
).toEqual(['enabled', 'subscriptionId', 'targets', 'templateId']);
|
||||
expect(JSON.stringify(mocks.create.mock.calls[0]?.[1])).not.toMatch(
|
||||
/selfId|account/i,
|
||||
);
|
||||
|
||||
(wrapper.vm as any).openEdit(createBinding());
|
||||
await flushPromises();
|
||||
mocks.currentValues = expectedBody;
|
||||
await mocks.modalOptions.onConfirm();
|
||||
expect(mocks.update).toHaveBeenCalledWith(
|
||||
'10000000000000001',
|
||||
'10000000000000001',
|
||||
expectedBody,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses empty, excessive, malformed, and non-string targets before API calls', async () => {
|
||||
const wrapper = mountModal();
|
||||
(wrapper.vm as any).openCreate();
|
||||
await flushPromises();
|
||||
|
||||
for (const targets of [
|
||||
[],
|
||||
Array.from({ length: 101 }, () => ({
|
||||
targetId: '12345',
|
||||
targetType: 'group' as const,
|
||||
})),
|
||||
[{ targetId: '01234', targetType: 'group' as const }],
|
||||
[{ targetId: 12_345 as unknown as string, targetType: 'group' as const }],
|
||||
[{ targetId: '12345', targetType: 'channel' as 'group' }],
|
||||
]) {
|
||||
mocks.currentValues.targets = targets;
|
||||
await mocks.modalOptions.onConfirm();
|
||||
}
|
||||
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes and invalidates an open session when the implicit selfId changes', async () => {
|
||||
const wrapper = mountModal();
|
||||
(wrapper.vm as any).openCreate();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.setProps({ selfId: '10000000000000002' });
|
||||
await flushPromises();
|
||||
await mocks.modalOptions.onConfirm();
|
||||
|
||||
expect(mocks.modalApi.close).toHaveBeenCalledOnce();
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a rejected session open and closes/emits exactly once on success', async () => {
|
||||
mocks.create.mockRejectedValueOnce(new Error('save failed'));
|
||||
const wrapper = mountModal();
|
||||
(wrapper.vm as any).openCreate();
|
||||
await flushPromises();
|
||||
mocks.currentValues = {
|
||||
enabled: true,
|
||||
subscriptionId: '20000000000000001',
|
||||
targets: [{ targetId: '12345', targetType: 'group' }],
|
||||
templateId: '50000000000000001',
|
||||
};
|
||||
|
||||
await expect(mocks.modalOptions.onConfirm()).rejects.toThrow('save failed');
|
||||
expect(mocks.modalApi.lock).toHaveBeenCalledOnce();
|
||||
expect(mocks.modalApi.close).not.toHaveBeenCalled();
|
||||
expect(mocks.modalApi.unlock).toHaveBeenCalledOnce();
|
||||
expect(wrapper.emitted('saved')).toBeUndefined();
|
||||
|
||||
mocks.create.mockResolvedValue({});
|
||||
await mocks.modalOptions.onConfirm();
|
||||
expect(mocks.modalApi.close).toHaveBeenCalledOnce();
|
||||
expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2);
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,422 @@
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
||||
|
||||
import { computed, defineComponent, markRaw, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { useVbenForm, z } from '#/adapter/form';
|
||||
import {
|
||||
createAccountMessagePushBinding,
|
||||
updateAccountMessagePushBinding,
|
||||
} from '#/api/qqbot/message-push';
|
||||
|
||||
import MessagePushTargetPicker, {
|
||||
isValidMessagePushTargetId,
|
||||
} from './MessagePushTargetPicker';
|
||||
|
||||
export interface AccountMessagePushModalExposed {
|
||||
openCreate: () => void;
|
||||
openEdit: (row: QqbotMessagePushApi.QqbotMessagePublishBindingView) => void;
|
||||
}
|
||||
|
||||
interface AccountMessagePushFormValues {
|
||||
enabled: boolean;
|
||||
subscriptionId: string;
|
||||
targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[];
|
||||
templateId: string;
|
||||
}
|
||||
|
||||
interface AccountMessagePushModalData {
|
||||
selfId: string;
|
||||
sessionRevision: number;
|
||||
values: AccountMessagePushFormValues;
|
||||
}
|
||||
|
||||
const DECIMAL_ID_PATTERN = /^[1-9]\d*$/;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AccountMessagePushModal',
|
||||
props: {
|
||||
selfId: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
subscriptions: {
|
||||
required: true,
|
||||
type: Array as PropType<QqbotMessagePushApi.MessageSubscriptionView[]>,
|
||||
},
|
||||
targetOptions: {
|
||||
default: undefined,
|
||||
type: Object as PropType<
|
||||
QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse | undefined
|
||||
>,
|
||||
},
|
||||
targetOptionsLoading: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
templates: {
|
||||
required: true,
|
||||
type: Array as PropType<QqbotMessagePushApi.MessageTemplateView[]>,
|
||||
},
|
||||
},
|
||||
emits: ['saved'],
|
||||
/** Owns one account-scoped create/edit session without selecting another account. */
|
||||
setup(props, { emit, expose }) {
|
||||
const editingId = ref<string>();
|
||||
const modalOpen = ref(false);
|
||||
const selectedSubscriptionId = ref('');
|
||||
let sessionRevision = 0;
|
||||
let sessionSelfId = '';
|
||||
const [BindingForm, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
labelClass: 'w-24',
|
||||
},
|
||||
/**
|
||||
* Keeps the selected template only while it remains source-compatible.
|
||||
* @param values - Current form values after the update.
|
||||
* @param fieldsChanged - Exact form fields changed by this event.
|
||||
*/
|
||||
async handleValuesChange(values, fieldsChanged) {
|
||||
if (!fieldsChanged.includes('subscriptionId')) return;
|
||||
selectedSubscriptionId.value =
|
||||
typeof values.subscriptionId === 'string'
|
||||
? values.subscriptionId
|
||||
: '';
|
||||
const templateId =
|
||||
typeof values.templateId === 'string' ? values.templateId : '';
|
||||
if (!templateId) return;
|
||||
if (
|
||||
!isTemplateCompatible(props, selectedSubscriptionId.value, templateId)
|
||||
) {
|
||||
await formApi.setValues({ templateId: '' });
|
||||
}
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: createFormSchema(props, selectedSubscriptionId),
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-1',
|
||||
});
|
||||
/** Derives the title from the current isolated binding identity. */
|
||||
const modalTitle = computed(() =>
|
||||
editingId.value ? '编辑消息推送' : '新增消息推送',
|
||||
);
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
class: 'w-[760px]',
|
||||
fullscreenButton: false,
|
||||
/** Validates and persists the active account-scoped session. */
|
||||
async onConfirm() {
|
||||
await submit();
|
||||
},
|
||||
/**
|
||||
* Restores only the latest session after destroy-on-close content mounts.
|
||||
* @param isOpen - Whether the modal content is currently mounted.
|
||||
*/
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
modalOpen.value = isOpen;
|
||||
if (!isOpen) return;
|
||||
const data = modalApi.getData<AccountMessagePushModalData>();
|
||||
if (
|
||||
data.sessionRevision !== sessionRevision ||
|
||||
data.selfId !== props.selfId
|
||||
) {
|
||||
await modalApi.close();
|
||||
return;
|
||||
}
|
||||
selectedSubscriptionId.value = data.values.subscriptionId;
|
||||
await resetForm(data.values);
|
||||
},
|
||||
});
|
||||
|
||||
/** Opens a fresh four-field binding session for the current account path. */
|
||||
function openCreate() {
|
||||
editingId.value = undefined;
|
||||
beginSession({
|
||||
enabled: true,
|
||||
subscriptionId: '',
|
||||
targets: [],
|
||||
templateId: '',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an edit session with only API-provided editable binding values.
|
||||
* @param row - Account binding selected from the panel-owned KtTable.
|
||||
*/
|
||||
function openEdit(row: QqbotMessagePushApi.QqbotMessagePublishBindingView) {
|
||||
editingId.value = row.id;
|
||||
beginSession({
|
||||
enabled: row.enabled,
|
||||
subscriptionId: row.subscriptionId,
|
||||
targets: row.targets.map((target) => ({
|
||||
targetId: target.targetId,
|
||||
...(target.targetName ? { targetName: target.targetName } : {}),
|
||||
targetType: target.targetType,
|
||||
})),
|
||||
templateId: row.templateId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores one revision-bound modal payload before opening its content.
|
||||
* @param values - Exact four editable values for the new modal session.
|
||||
*/
|
||||
function beginSession(values: AccountMessagePushFormValues) {
|
||||
sessionRevision += 1;
|
||||
sessionSelfId = props.selfId;
|
||||
selectedSubscriptionId.value = values.subscriptionId;
|
||||
modalApi
|
||||
.setData({
|
||||
selfId: sessionSelfId,
|
||||
sessionRevision,
|
||||
values,
|
||||
} satisfies AccountMessagePushModalData)
|
||||
.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the mounted form before installing one isolated session.
|
||||
* @param values - Exact values captured before opening the modal.
|
||||
*/
|
||||
async function resetForm(values: AccountMessagePushFormValues) {
|
||||
await formApi.resetForm();
|
||||
await formApi.setValues(values);
|
||||
await formApi.resetValidate();
|
||||
}
|
||||
|
||||
/** Validates and persists one session without putting selfId in the body. */
|
||||
async function submit() {
|
||||
const revision = sessionRevision;
|
||||
const selfId = sessionSelfId;
|
||||
if (!selfId || selfId !== props.selfId) return;
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues<AccountMessagePushFormValues>();
|
||||
const payload = normalizeBindingPayload(props, values);
|
||||
if (!payload) return;
|
||||
const currentEditingId = editingId.value;
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
await (currentEditingId
|
||||
? updateAccountMessagePushBinding(selfId, currentEditingId, payload)
|
||||
: createAccountMessagePushBinding(selfId, payload));
|
||||
if (revision !== sessionRevision || selfId !== props.selfId) return;
|
||||
await modalApi.close();
|
||||
emit('saved');
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates an open old-account session before its path identity changes.
|
||||
*/
|
||||
async function invalidateForSelfIdChange() {
|
||||
sessionRevision += 1;
|
||||
sessionSelfId = '';
|
||||
editingId.value = undefined;
|
||||
selectedSubscriptionId.value = '';
|
||||
if (modalOpen.value) await modalApi.close();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.selfId,
|
||||
(selfId, previousSelfId) => {
|
||||
if (selfId === previousSelfId) return;
|
||||
void invalidateForSelfIdChange();
|
||||
},
|
||||
);
|
||||
|
||||
expose({ openCreate, openEdit } satisfies AccountMessagePushModalExposed);
|
||||
|
||||
return () => (
|
||||
<Modal title={modalTitle.value}>
|
||||
<BindingForm class="mx-2" />
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the exact account-binding schema with the local controlled picker.
|
||||
* @param props - Page-owned subscriptions, templates, and target candidates.
|
||||
* @param selectedSubscriptionId - Current subscription driving template options.
|
||||
* @returns Four fields in subscription/template/targets/enabled order.
|
||||
*/
|
||||
function createFormSchema(
|
||||
props: Readonly<{
|
||||
selfId: string;
|
||||
subscriptions: QqbotMessagePushApi.MessageSubscriptionView[];
|
||||
targetOptions:
|
||||
| QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse
|
||||
| undefined;
|
||||
targetOptionsLoading: boolean;
|
||||
templates: QqbotMessagePushApi.MessageTemplateView[];
|
||||
}>,
|
||||
selectedSubscriptionId: Readonly<{ value: string }>,
|
||||
): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: () => ({
|
||||
options: props.subscriptions.map((subscription) => ({
|
||||
disabled: !subscription.enabled || !subscription.valid,
|
||||
label: formatSubscriptionLabel(subscription),
|
||||
value: subscription.id,
|
||||
})),
|
||||
}),
|
||||
fieldName: 'subscriptionId',
|
||||
label: '消息订阅',
|
||||
rules: z.string().regex(DECIMAL_ID_PATTERN),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: () => ({
|
||||
options: compatibleTemplates(props, selectedSubscriptionId.value).map(
|
||||
(template) => ({
|
||||
disabled: !template.enabled,
|
||||
label: `${template.name} · ${template.sourceName}`,
|
||||
value: template.id,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
fieldName: 'templateId',
|
||||
label: '消息模板',
|
||||
rules: z.string().regex(DECIMAL_ID_PATTERN),
|
||||
},
|
||||
{
|
||||
component: markRaw(MessagePushTargetPicker),
|
||||
componentProps: () => ({
|
||||
available: props.targetOptions?.available ?? true,
|
||||
loading: props.targetOptionsLoading,
|
||||
options: props.targetOptions?.options || [],
|
||||
reasonCode: props.targetOptions?.reasonCode ?? null,
|
||||
}),
|
||||
fieldName: 'targets',
|
||||
label: '推送目标',
|
||||
modelPropName: 'value',
|
||||
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']),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
},
|
||||
{
|
||||
component: 'Switch',
|
||||
defaultValue: true,
|
||||
fieldName: 'enabled',
|
||||
label: '启用',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats one subscription while retaining server validity information.
|
||||
* @param subscription - Global subscription available to the current account.
|
||||
* @returns Select label with a stable invalid reason when applicable.
|
||||
*/
|
||||
function formatSubscriptionLabel(
|
||||
subscription: QqbotMessagePushApi.MessageSubscriptionView,
|
||||
): string {
|
||||
const reason =
|
||||
subscription.valid && subscription.enabled
|
||||
? ''
|
||||
: ` · ${subscription.invalidReasonCode || 'disabled'}`;
|
||||
return `${subscription.name} · ${subscription.sourceName}${reason}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns templates whose source matches the selected global subscription.
|
||||
* @param props - Current page-owned subscription/template collections.
|
||||
* @param subscriptionId - Exact selected subscription string ID.
|
||||
* @returns Source-compatible template rows in server order.
|
||||
*/
|
||||
function compatibleTemplates(
|
||||
props: Readonly<{
|
||||
subscriptions: QqbotMessagePushApi.MessageSubscriptionView[];
|
||||
templates: QqbotMessagePushApi.MessageTemplateView[];
|
||||
}>,
|
||||
subscriptionId: string,
|
||||
): QqbotMessagePushApi.MessageTemplateView[] {
|
||||
const sourceKey = props.subscriptions.find(
|
||||
(subscription) => subscription.id === subscriptionId,
|
||||
)?.sourceKey;
|
||||
if (!sourceKey) return [];
|
||||
return props.templates.filter((template) => template.sourceKey === sourceKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks template/source compatibility without interpreting numeric IDs.
|
||||
* @param props - Current subscription/template collections.
|
||||
* @param subscriptionId - Selected subscription string ID.
|
||||
* @param templateId - Existing template string ID.
|
||||
* @returns Whether both rows exist and share the exact source key.
|
||||
*/
|
||||
function isTemplateCompatible(
|
||||
props: Readonly<{
|
||||
subscriptions: QqbotMessagePushApi.MessageSubscriptionView[];
|
||||
templates: QqbotMessagePushApi.MessageTemplateView[];
|
||||
}>,
|
||||
subscriptionId: string,
|
||||
templateId: string,
|
||||
): boolean {
|
||||
return compatibleTemplates(props, subscriptionId).some(
|
||||
(template) => template.id === templateId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the exact four-field payload before invoking the account API.
|
||||
* @param props - Current source-compatible metadata.
|
||||
* @param values - Form-owned binding values.
|
||||
* @returns A clean API payload or undefined when any ID/target is invalid.
|
||||
*/
|
||||
function normalizeBindingPayload(
|
||||
props: Readonly<{
|
||||
subscriptions: QqbotMessagePushApi.MessageSubscriptionView[];
|
||||
templates: QqbotMessagePushApi.MessageTemplateView[];
|
||||
}>,
|
||||
values: AccountMessagePushFormValues,
|
||||
): QqbotMessagePushApi.QqbotMessagePublishBindingInput | undefined {
|
||||
if (
|
||||
!DECIMAL_ID_PATTERN.test(values.subscriptionId) ||
|
||||
!DECIMAL_ID_PATTERN.test(values.templateId) ||
|
||||
!isTemplateCompatible(props, values.subscriptionId, values.templateId) ||
|
||||
!Array.isArray(values.targets) ||
|
||||
values.targets.length === 0 ||
|
||||
values.targets.length > 100
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[] = [];
|
||||
for (const target of values.targets) {
|
||||
if (
|
||||
!target ||
|
||||
typeof target.targetId !== 'string' ||
|
||||
!isValidMessagePushTargetId(target.targetId) ||
|
||||
(target.targetType !== 'group' && target.targetType !== 'private')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
targets.push({
|
||||
targetId: target.targetId,
|
||||
...(target.targetName?.trim() ? { targetName: target.targetName } : {}),
|
||||
targetType: target.targetType,
|
||||
});
|
||||
}
|
||||
return {
|
||||
enabled: !!values.enabled,
|
||||
subscriptionId: values.subscriptionId,
|
||||
targets,
|
||||
templateId: values.templateId,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,548 @@
|
||||
/* @vitest-environment happy-dom */
|
||||
|
||||
/* eslint-disable no-template-curly-in-string, vue/one-component-per-file, vue/require-default-prop */
|
||||
|
||||
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AccountMessagePushPanel from './AccountMessagePushPanel';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
accessCodes: new Set<string>(),
|
||||
api: {
|
||||
deleteBinding: vi.fn(),
|
||||
getBindings: vi.fn(),
|
||||
getSubscriptions: vi.fn(),
|
||||
getTargets: vi.fn(),
|
||||
getTemplates: vi.fn(),
|
||||
toggleBinding: vi.fn(),
|
||||
},
|
||||
modalOpenCreate: vi.fn(),
|
||||
modalOpenEdit: vi.fn(),
|
||||
registeredTableOptions: undefined as any,
|
||||
registerTable: vi.fn(),
|
||||
renderedPage: undefined as any,
|
||||
tableApi: {
|
||||
reload: vi.fn(),
|
||||
},
|
||||
tableOptions: undefined as any,
|
||||
};
|
||||
/** Installs the real list callback only after the rendered KtTable registers. */
|
||||
state.registerTable.mockImplementation(() => {
|
||||
state.registeredTableOptions = state.tableOptions;
|
||||
});
|
||||
/** Reloads through the registered list callback and records the applied page. */
|
||||
state.tableApi.reload.mockImplementation(async () => {
|
||||
if (!state.registeredTableOptions) {
|
||||
throw new Error('[MockKtTable]: table is not registered yet.');
|
||||
}
|
||||
const page = await state.registeredTableOptions.api.list({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
state.renderedPage = page;
|
||||
return page;
|
||||
});
|
||||
return state;
|
||||
});
|
||||
|
||||
vi.mock('@vben/access', () => ({
|
||||
useAccess: () => ({
|
||||
hasAccessByCodes: (codes: string[]) =>
|
||||
codes.every((code) => mocks.accessCodes.has(code)),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@vben/icons', () => ({
|
||||
Plus: defineComponent({
|
||||
name: 'MockPlusIcon',
|
||||
setup: () => () => h('i'),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('antdv-next', () => ({
|
||||
Space: defineComponent({
|
||||
name: 'MockSpace',
|
||||
setup:
|
||||
(_: unknown, { slots }: any) =>
|
||||
() =>
|
||||
h('span', slots.default?.()),
|
||||
}),
|
||||
Tag: defineComponent({
|
||||
name: 'MockTag',
|
||||
setup:
|
||||
(_: unknown, { slots }: any) =>
|
||||
() =>
|
||||
h('span', slots.default?.()),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('#/components/ktTable', () => ({
|
||||
KtTable: defineComponent({
|
||||
name: 'MockAccountMessagePushKtTable',
|
||||
emits: ['register'],
|
||||
/** Registers first, then renders inherited account header callbacks. */
|
||||
setup(_, { emit, slots }) {
|
||||
emit('register', {});
|
||||
return () =>
|
||||
h('section', { 'data-testid': 'message-push-table' }, [
|
||||
slots.title?.(),
|
||||
slots.headerControls?.(),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
useKtTable: vi.fn((options) => {
|
||||
mocks.tableOptions = options;
|
||||
return [mocks.registerTable, mocks.tableApi];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./AccountMessagePushModal', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MockAccountMessagePushModal',
|
||||
props: {
|
||||
selfId: String,
|
||||
subscriptions: Array,
|
||||
targetOptions: Object,
|
||||
targetOptionsLoading: Boolean,
|
||||
templates: Array,
|
||||
},
|
||||
emits: ['saved'],
|
||||
/** Exposes create/edit commands plus a deterministic save event. */
|
||||
setup(_, { emit, expose }) {
|
||||
expose({
|
||||
openCreate: mocks.modalOpenCreate,
|
||||
openEdit: mocks.modalOpenEdit,
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'modal-saved',
|
||||
onClick: () => emit('saved'),
|
||||
},
|
||||
'saved',
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/qqbot/message-push', () => ({
|
||||
deleteAccountMessagePushBinding: mocks.api.deleteBinding,
|
||||
getAccountMessagePushBindings: mocks.api.getBindings,
|
||||
getAccountMessagePushTargets: mocks.api.getTargets,
|
||||
getMessageSubscriptionList: mocks.api.getSubscriptions,
|
||||
getMessageTemplateList: mocks.api.getTemplates,
|
||||
setAccountMessagePushBindingEnabled: mocks.api.toggleBinding,
|
||||
}));
|
||||
|
||||
/** Creates one unsafe-integer string-ID binding row. */
|
||||
function createBinding(
|
||||
overrides: Partial<QqbotMessagePushApi.QqbotMessagePublishBindingView> = {},
|
||||
): QqbotMessagePushApi.QqbotMessagePublishBindingView {
|
||||
return {
|
||||
available: true,
|
||||
createTime: '2026-07-24 10:00:00',
|
||||
enabled: true,
|
||||
id: '10000000000000001',
|
||||
invalidReasonCode: null,
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
sourceName: 'STUN 映射端口变更',
|
||||
subscriptionId: '20000000000000001',
|
||||
subscriptionName: '帕鲁端口变更',
|
||||
targets: [
|
||||
{
|
||||
enabled: true,
|
||||
id: '30000000000000001',
|
||||
targetId: '40000000000000001',
|
||||
targetName: '测试群',
|
||||
targetType: 'group',
|
||||
},
|
||||
],
|
||||
templateId: '50000000000000001',
|
||||
templateName: '默认模板',
|
||||
updateTime: '2026-07-24 10:00:00',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates one global subscription fixture for the account modal. */
|
||||
function createSubscription(
|
||||
id = '20000000000000001',
|
||||
): QqbotMessagePushApi.MessageSubscriptionView {
|
||||
return {
|
||||
createTime: '2026-07-24 10:00:00',
|
||||
enabled: true,
|
||||
id,
|
||||
invalidReasonCode: null,
|
||||
name: `订阅 ${id}`,
|
||||
remark: null,
|
||||
sourceConfig: {
|
||||
ddnsRecordId: '60000000000000001',
|
||||
portForwardId: '70000000000000001',
|
||||
},
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
sourceName: 'STUN 映射端口变更',
|
||||
sourceSummary: '帕鲁 · pal.kwitsukasa.top',
|
||||
updateTime: '2026-07-24 10:00:00',
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates one source-compatible template fixture. */
|
||||
function createTemplate(
|
||||
id = '50000000000000001',
|
||||
): QqbotMessagePushApi.MessageTemplateView {
|
||||
return {
|
||||
content: '当前STUN的端口已变更为${{endpoint}}',
|
||||
createTime: '2026-07-24 10:00:00',
|
||||
enabled: true,
|
||||
id,
|
||||
name: `模板 ${id}`,
|
||||
referenceCount: 0,
|
||||
remark: null,
|
||||
sourceKey: 'network.stun.mapping-port-changed',
|
||||
sourceName: 'STUN 映射端口变更',
|
||||
updateTime: '2026-07-24 10:00:00',
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates a manually controlled promise for stale-response assertions. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** Mounts the panel with visible account header callbacks. */
|
||||
function mountPanel(selfId = '10000000000000001') {
|
||||
return mount(AccountMessagePushPanel, {
|
||||
props: {
|
||||
headerControls: () => h('div', { 'data-testid': 'account-tabs' }),
|
||||
selfId,
|
||||
title: () => h('div', { 'data-testid': 'account-title' }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('account message-push panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
mocks.accessCodes = new Set([
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Delete',
|
||||
'QqBot:Account:MessagePush:List',
|
||||
'QqBot:Account:MessagePush:Toggle',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
]);
|
||||
mocks.registeredTableOptions = undefined;
|
||||
mocks.renderedPage = undefined;
|
||||
mocks.tableOptions = undefined;
|
||||
mocks.api.getBindings.mockResolvedValue([createBinding()]);
|
||||
mocks.api.getSubscriptions.mockResolvedValue({
|
||||
items: [createSubscription()],
|
||||
total: 1,
|
||||
});
|
||||
mocks.api.getTemplates.mockResolvedValue({
|
||||
items: [createTemplate()],
|
||||
total: 1,
|
||||
});
|
||||
mocks.api.getTargets.mockResolvedValue({
|
||||
available: true,
|
||||
options: [
|
||||
{
|
||||
label: '测试群',
|
||||
targetId: '40000000000000001',
|
||||
targetType: 'group',
|
||||
},
|
||||
],
|
||||
reasonCode: null,
|
||||
});
|
||||
mocks.api.toggleBinding.mockResolvedValue(createBinding());
|
||||
mocks.api.deleteBinding.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('registers one native table and performs one full authorized load', async () => {
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll('.qqbot-account-message-push-panel')).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(wrapper.findAll('[data-testid="message-push-table"]')).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(wrapper.find('[data-testid="account-title"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="account-tabs"]').exists()).toBe(true);
|
||||
expect(mocks.registerTable).toHaveBeenCalledOnce();
|
||||
expect(mocks.tableOptions).toMatchObject({
|
||||
immediate: false,
|
||||
rowKey: 'id',
|
||||
showPagination: false,
|
||||
showTableSetting: false,
|
||||
});
|
||||
expect(mocks.api.getBindings).toHaveBeenCalledOnce();
|
||||
expect(mocks.api.getBindings).toHaveBeenCalledWith('10000000000000001');
|
||||
expect(mocks.api.getSubscriptions).toHaveBeenCalledWith({
|
||||
pageNo: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
expect(mocks.api.getTemplates).toHaveBeenCalledWith({
|
||||
pageNo: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
expect(mocks.api.getTargets).toHaveBeenCalledWith('10000000000000001');
|
||||
expect(mocks.renderedPage).toEqual({
|
||||
items: [createBinding()],
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('hard-gates registration and every request when List is missing', async () => {
|
||||
mocks.accessCodes = new Set([
|
||||
'QqBot:Account:MessagePush:Create',
|
||||
'QqBot:Account:MessagePush:Update',
|
||||
]);
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="message-push-table"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(wrapper.find('[data-testid="modal-saved"]').exists()).toBe(false);
|
||||
expect(mocks.registerTable).not.toHaveBeenCalled();
|
||||
expect(mocks.tableApi.reload).not.toHaveBeenCalled();
|
||||
expect(mocks.api.getBindings).not.toHaveBeenCalled();
|
||||
expect(mocks.api.getSubscriptions).not.toHaveBeenCalled();
|
||||
expect(mocks.api.getTemplates).not.toHaveBeenCalled();
|
||||
expect(mocks.api.getTargets).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets List-only users load metadata without target discovery', async () => {
|
||||
mocks.accessCodes = new Set(['QqBot:Account:MessagePush:List']);
|
||||
mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.api.getBindings).toHaveBeenCalledOnce();
|
||||
expect(mocks.api.getSubscriptions).toHaveBeenCalledOnce();
|
||||
expect(mocks.api.getTemplates).toHaveBeenCalledOnce();
|
||||
expect(mocks.api.getTargets).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads all global metadata pages up to the server total', async () => {
|
||||
mocks.api.getSubscriptions
|
||||
.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 100 }, (_, index) =>
|
||||
createSubscription(`2${String(index).padStart(16, '0')}`),
|
||||
),
|
||||
total: 101,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [createSubscription('29999999999999999')],
|
||||
total: 101,
|
||||
});
|
||||
mocks.api.getTemplates
|
||||
.mockResolvedValueOnce({
|
||||
items: Array.from({ length: 100 }, (_, index) =>
|
||||
createTemplate(`5${String(index).padStart(16, '0')}`),
|
||||
),
|
||||
total: 101,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [createTemplate('59999999999999999')],
|
||||
total: 101,
|
||||
});
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.api.getSubscriptions).toHaveBeenNthCalledWith(2, {
|
||||
pageNo: 2,
|
||||
pageSize: 100,
|
||||
});
|
||||
expect(mocks.api.getTemplates).toHaveBeenNthCalledWith(2, {
|
||||
pageNo: 2,
|
||||
pageSize: 100,
|
||||
});
|
||||
const modal = wrapper.getComponent({ name: 'MockAccountMessagePushModal' });
|
||||
expect(modal.props('subscriptions')).toHaveLength(101);
|
||||
expect(modal.props('templates')).toHaveLength(101);
|
||||
});
|
||||
|
||||
it('uses a revision guard for real selfId changes and ignores stale rows', async () => {
|
||||
const wrapper = mountPanel('10001');
|
||||
await flushPromises();
|
||||
vi.clearAllMocks();
|
||||
const oldRequest =
|
||||
deferred<QqbotMessagePushApi.QqbotMessagePublishBindingView[]>();
|
||||
mocks.api.getBindings
|
||||
.mockImplementationOnce(() => oldRequest.promise)
|
||||
.mockResolvedValueOnce([
|
||||
createBinding({ id: '90000000000000003', subscriptionName: 'new' }),
|
||||
]);
|
||||
|
||||
await wrapper.setProps({ selfId: '10002' });
|
||||
await wrapper.setProps({ selfId: '10003' });
|
||||
await flushPromises();
|
||||
oldRequest.resolve([
|
||||
createBinding({ id: '90000000000000002', subscriptionName: 'old' }),
|
||||
]);
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.api.getBindings).toHaveBeenNthCalledWith(1, '10002');
|
||||
expect(mocks.api.getBindings).toHaveBeenNthCalledWith(2, '10003');
|
||||
expect(mocks.renderedPage).toEqual({
|
||||
items: [
|
||||
createBinding({
|
||||
id: '90000000000000003',
|
||||
subscriptionName: 'new',
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const callsBeforeIdentical = mocks.api.getBindings.mock.calls.length;
|
||||
await wrapper.setProps({ selfId: '10003' });
|
||||
await flushPromises();
|
||||
expect(mocks.api.getBindings).toHaveBeenCalledTimes(callsBeforeIdentical);
|
||||
});
|
||||
|
||||
it('pins columns, exact permissions, and account-scoped actions', () => {
|
||||
mountPanel();
|
||||
|
||||
expect(mocks.tableOptions.columns.map((column: any) => column.key)).toEqual(
|
||||
[
|
||||
'subscription',
|
||||
'source',
|
||||
'template',
|
||||
'targets',
|
||||
'enabled',
|
||||
'updateTime',
|
||||
],
|
||||
);
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
mocks.tableOptions.buttons.map((button: any) => [
|
||||
button.key,
|
||||
button.permissionCodes,
|
||||
]),
|
||||
),
|
||||
).toEqual({
|
||||
create: ['QqBot:Account:MessagePush:Create'],
|
||||
refresh: ['QqBot:Account:MessagePush:List'],
|
||||
});
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
mocks.tableOptions.rowActions.map((action: any) => [
|
||||
action.key,
|
||||
action.permissionCodes,
|
||||
]),
|
||||
),
|
||||
).toEqual({
|
||||
delete: ['QqBot:Account:MessagePush:Delete'],
|
||||
edit: ['QqBot:Account:MessagePush:Update'],
|
||||
toggle: ['QqBot:Account:MessagePush:Toggle'],
|
||||
});
|
||||
expect(
|
||||
mocks.tableOptions.rowActions.find(
|
||||
(action: any) => action.key === 'delete',
|
||||
).confirm,
|
||||
).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('opens create/edit and reloads bindings once after modal saved', async () => {
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
mocks.tableApi.reload.mockClear();
|
||||
mocks.api.getBindings.mockClear();
|
||||
const row = createBinding();
|
||||
|
||||
await mocks.tableOptions.buttons
|
||||
.find((button: any) => button.key === 'create')
|
||||
.onClick({});
|
||||
await mocks.tableOptions.rowActions
|
||||
.find((action: any) => action.key === 'edit')
|
||||
.onClick(row, {});
|
||||
await wrapper.get('[data-testid="modal-saved"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.modalOpenCreate).toHaveBeenCalledOnce();
|
||||
expect(mocks.modalOpenEdit).toHaveBeenCalledWith(row);
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
||||
expect(mocks.api.getBindings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reloads once after successful toggle/delete and never after rejection', async () => {
|
||||
mountPanel();
|
||||
await flushPromises();
|
||||
const row = createBinding();
|
||||
const context = { reload: vi.fn(async () => {}) };
|
||||
const toggle = mocks.tableOptions.rowActions.find(
|
||||
(action: any) => action.key === 'toggle',
|
||||
);
|
||||
const remove = mocks.tableOptions.rowActions.find(
|
||||
(action: any) => action.key === 'delete',
|
||||
);
|
||||
|
||||
await toggle.onClick(row, context);
|
||||
await remove.onClick(row, context);
|
||||
expect(mocks.api.toggleBinding).toHaveBeenCalledWith(
|
||||
'10000000000000001',
|
||||
'10000000000000001',
|
||||
false,
|
||||
);
|
||||
expect(mocks.api.deleteBinding).toHaveBeenCalledWith(
|
||||
'10000000000000001',
|
||||
'10000000000000001',
|
||||
);
|
||||
expect(context.reload).toHaveBeenCalledTimes(2);
|
||||
|
||||
context.reload.mockClear();
|
||||
mocks.api.toggleBinding.mockRejectedValueOnce(new Error('toggle failed'));
|
||||
mocks.api.deleteBinding.mockRejectedValueOnce(new Error('delete failed'));
|
||||
await expect(toggle.onClick(row, context)).rejects.toThrow('toggle failed');
|
||||
await expect(remove.onClick(row, context)).rejects.toThrow('delete failed');
|
||||
expect(context.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('explicit refresh reloads only bindings and idle time adds no work', async () => {
|
||||
mountPanel();
|
||||
await flushPromises();
|
||||
mocks.tableApi.reload.mockClear();
|
||||
mocks.api.getBindings.mockClear();
|
||||
const subscriptionCalls = mocks.api.getSubscriptions.mock.calls.length;
|
||||
const templateCalls = mocks.api.getTemplates.mock.calls.length;
|
||||
const targetCalls = mocks.api.getTargets.mock.calls.length;
|
||||
|
||||
const refresh = mocks.tableOptions.buttons.find(
|
||||
(button: any) => button.key === 'refresh',
|
||||
);
|
||||
expect(refresh.operation).toBe('reload');
|
||||
await mocks.tableApi.reload();
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.api.getBindings).toHaveBeenCalledOnce();
|
||||
expect(mocks.api.getSubscriptions).toHaveBeenCalledTimes(subscriptionCalls);
|
||||
expect(mocks.api.getTemplates).toHaveBeenCalledTimes(templateCalls);
|
||||
expect(mocks.api.getTargets).toHaveBeenCalledTimes(targetCalls);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await flushPromises();
|
||||
expect(mocks.api.getBindings).toHaveBeenCalledOnce();
|
||||
expect(mocks.api.getSubscriptions).toHaveBeenCalledTimes(subscriptionCalls);
|
||||
expect(mocks.api.getTemplates).toHaveBeenCalledTimes(templateCalls);
|
||||
expect(mocks.api.getTargets).toHaveBeenCalledTimes(targetCalls);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,417 @@
|
||||
import type { TableColumnType } from 'antdv-next';
|
||||
|
||||
import type { PropType, VNodeChild } from 'vue';
|
||||
|
||||
import type { AccountMessagePushModalExposed } from './AccountMessagePushModal';
|
||||
|
||||
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
||||
import type {
|
||||
KtTableApi,
|
||||
KtTableButton,
|
||||
KtTableContext,
|
||||
KtTableRowAction,
|
||||
} from '#/components/ktTable';
|
||||
|
||||
import { defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { Space, Tag } from 'antdv-next';
|
||||
|
||||
import {
|
||||
deleteAccountMessagePushBinding,
|
||||
getAccountMessagePushBindings,
|
||||
getAccountMessagePushTargets,
|
||||
getMessageSubscriptionList,
|
||||
getMessageTemplateList,
|
||||
setAccountMessagePushBindingEnabled,
|
||||
} from '#/api/qqbot/message-push';
|
||||
import { KtTable, useKtTable } from '#/components/ktTable';
|
||||
|
||||
import AccountMessagePushModal from './AccountMessagePushModal';
|
||||
|
||||
const AKtTable = KtTable as any;
|
||||
|
||||
export interface AccountMessagePushPanelProps {
|
||||
headerControls: () => VNodeChild;
|
||||
selfId: string;
|
||||
title: () => VNodeChild;
|
||||
}
|
||||
|
||||
const PERMISSIONS = {
|
||||
create: 'QqBot:Account:MessagePush:Create',
|
||||
delete: 'QqBot:Account:MessagePush:Delete',
|
||||
list: 'QqBot:Account:MessagePush:List',
|
||||
toggle: 'QqBot:Account:MessagePush:Toggle',
|
||||
update: 'QqBot:Account:MessagePush:Update',
|
||||
} as const;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AccountMessagePushPanel',
|
||||
props: {
|
||||
headerControls: {
|
||||
required: true,
|
||||
type: Function as PropType<() => VNodeChild>,
|
||||
},
|
||||
selfId: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
title: {
|
||||
required: true,
|
||||
type: Function as PropType<() => VNodeChild>,
|
||||
},
|
||||
},
|
||||
/** Owns one permission-gated account binding table and per-account option cache. */
|
||||
setup(props) {
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canList = hasAccessByCodes([PERMISSIONS.list]);
|
||||
const canLoadTargets =
|
||||
hasAccessByCodes([PERMISSIONS.create]) ||
|
||||
hasAccessByCodes([PERMISSIONS.update]);
|
||||
const modalRef = ref<AccountMessagePushModalExposed>();
|
||||
const subscriptions = ref<QqbotMessagePushApi.MessageSubscriptionView[]>(
|
||||
[],
|
||||
);
|
||||
const templates = ref<QqbotMessagePushApi.MessageTemplateView[]>([]);
|
||||
const targetOptions =
|
||||
ref<QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse>();
|
||||
const targetOptionsLoading = ref(false);
|
||||
let loadRevision = 0;
|
||||
let latestBindingPage: {
|
||||
items: QqbotMessagePushApi.QqbotMessagePublishBindingView[];
|
||||
total: number;
|
||||
} = { items: [], total: 0 };
|
||||
|
||||
const columns: Array<
|
||||
TableColumnType<QqbotMessagePushApi.QqbotMessagePublishBindingView>
|
||||
> = [
|
||||
{
|
||||
dataIndex: 'subscriptionName',
|
||||
key: 'subscription',
|
||||
title: '消息订阅',
|
||||
width: 210,
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
title: '消息源',
|
||||
width: 260,
|
||||
},
|
||||
{
|
||||
dataIndex: 'templateName',
|
||||
key: 'template',
|
||||
title: '消息模板',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
key: 'targets',
|
||||
title: '推送目标',
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
title: '状态',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
dataIndex: 'updateTime',
|
||||
key: 'updateTime',
|
||||
title: '更新时间',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
const api: KtTableApi<QqbotMessagePushApi.QqbotMessagePublishBindingView> =
|
||||
{
|
||||
/**
|
||||
* Adapts the account binding array while returning the latest page for stale requests.
|
||||
* @returns Strict KtTable page retaining every string identifier.
|
||||
*/
|
||||
list: async () => {
|
||||
const revision = loadRevision;
|
||||
const selfId = props.selfId;
|
||||
const bindings = await getAccountMessagePushBindings(selfId);
|
||||
if (revision !== loadRevision || selfId !== props.selfId) {
|
||||
return latestBindingPage;
|
||||
}
|
||||
latestBindingPage = {
|
||||
items: bindings,
|
||||
total: bindings.length,
|
||||
};
|
||||
return latestBindingPage;
|
||||
},
|
||||
};
|
||||
const buttons: Array<
|
||||
KtTableButton<QqbotMessagePushApi.QqbotMessagePublishBindingView>
|
||||
> = [
|
||||
{
|
||||
icon: <Plus class="kt-table__button-icon" />,
|
||||
key: 'create',
|
||||
label: '新增推送',
|
||||
onClick: openCreate,
|
||||
permissionCodes: [PERMISSIONS.create],
|
||||
type: 'primary',
|
||||
},
|
||||
{
|
||||
key: 'refresh',
|
||||
label: '刷新',
|
||||
operation: 'reload',
|
||||
permissionCodes: [PERMISSIONS.list],
|
||||
},
|
||||
];
|
||||
const rowActions: Array<
|
||||
KtTableRowAction<QqbotMessagePushApi.QqbotMessagePublishBindingView>
|
||||
> = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
onClick: openEdit,
|
||||
permissionCodes: [PERMISSIONS.update],
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
label: '启停',
|
||||
onClick: handleToggle,
|
||||
permissionCodes: [PERMISSIONS.toggle],
|
||||
},
|
||||
{
|
||||
confirm: getDeleteConfirm,
|
||||
danger: true,
|
||||
key: 'delete',
|
||||
label: '解绑',
|
||||
onClick: handleDelete,
|
||||
permissionCodes: [PERMISSIONS.delete],
|
||||
},
|
||||
];
|
||||
const [registerTable, tableApi] =
|
||||
useKtTable<QqbotMessagePushApi.QqbotMessagePublishBindingView>({
|
||||
api,
|
||||
buttons,
|
||||
columns,
|
||||
immediate: false,
|
||||
rowActions,
|
||||
rowActionVisibleCount: 3,
|
||||
rowKey: 'id',
|
||||
showDefaultButtons: false,
|
||||
showFooter: false,
|
||||
showIndex: false,
|
||||
showPagination: false,
|
||||
showTableSetting: false,
|
||||
size: 'small',
|
||||
});
|
||||
|
||||
/** Opens a blank binding session for this panel's implicit account. */
|
||||
function openCreate() {
|
||||
modalRef.value?.openCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one existing binding without deriving another account identity.
|
||||
* @param row - Binding selected from this account's KtTable.
|
||||
*/
|
||||
function openEdit(row: QqbotMessagePushApi.QqbotMessagePublishBindingView) {
|
||||
modalRef.value?.openEdit(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds account-binding removal confirmation text.
|
||||
* @param row - Binding awaiting removal.
|
||||
* @returns Confirmation containing the global subscription name.
|
||||
*/
|
||||
function getDeleteConfirm(
|
||||
row: QqbotMessagePushApi.QqbotMessagePublishBindingView,
|
||||
): string {
|
||||
return `确认解绑消息订阅「${row.subscriptionName}」吗?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles one binding and reloads only the mutable binding list after success.
|
||||
* @param row - Binding whose enabled state is inverted.
|
||||
* @param context - Registered KtTable action context.
|
||||
*/
|
||||
async function handleToggle(
|
||||
row: QqbotMessagePushApi.QqbotMessagePublishBindingView,
|
||||
context: KtTableContext<QqbotMessagePushApi.QqbotMessagePublishBindingView>,
|
||||
) {
|
||||
await setAccountMessagePushBindingEnabled(
|
||||
props.selfId,
|
||||
row.id,
|
||||
!row.enabled,
|
||||
);
|
||||
await context.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes one binding and reloads only the mutable binding list after success.
|
||||
* @param row - Binding confirmed for account-scoped removal.
|
||||
* @param context - Registered KtTable action context.
|
||||
*/
|
||||
async function handleDelete(
|
||||
row: QqbotMessagePushApi.QqbotMessagePublishBindingView,
|
||||
context: KtTableContext<QqbotMessagePushApi.QqbotMessagePublishBindingView>,
|
||||
) {
|
||||
await deleteAccountMessagePushBinding(props.selfId, row.id);
|
||||
await context.reload();
|
||||
}
|
||||
|
||||
/** Reloads only bindings after one successful modal persistence. */
|
||||
async function handleModalSaved() {
|
||||
await tableApi.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the complete subscription/template collection in bounded 100-row pages.
|
||||
* @param loader - One strict page caller receiving pageNo/pageSize.
|
||||
* @returns Concatenated server rows until total is reached or progress stops.
|
||||
*/
|
||||
async function loadAllPages<Row>(
|
||||
loader: (params: {
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
}) => Promise<QqbotMessagePushApi.PageResult<Row>>,
|
||||
): Promise<Row[]> {
|
||||
const rows: Row[] = [];
|
||||
for (let pageNo = 1; pageNo <= 1000; pageNo += 1) {
|
||||
const page = await loader({ pageNo, pageSize: 100 });
|
||||
rows.push(...page.items);
|
||||
if (rows.length >= page.total || page.items.length === 0) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads per-account modal metadata once and applies only the latest revision.
|
||||
* @param selfId - Exact account identity captured for this load.
|
||||
* @param revision - Monotonic revision assigned by `loadAccount`.
|
||||
*/
|
||||
async function loadMetadata(selfId: string, revision: number) {
|
||||
targetOptionsLoading.value = canLoadTargets;
|
||||
const [subscriptionResult, templateResult, targetResult] =
|
||||
await Promise.allSettled([
|
||||
loadAllPages((params) => getMessageSubscriptionList(params)),
|
||||
loadAllPages((params) => getMessageTemplateList(params)),
|
||||
canLoadTargets
|
||||
? getAccountMessagePushTargets(selfId)
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
if (revision !== loadRevision || selfId !== props.selfId) return;
|
||||
if (subscriptionResult.status === 'fulfilled') {
|
||||
subscriptions.value = subscriptionResult.value;
|
||||
}
|
||||
if (templateResult.status === 'fulfilled') {
|
||||
templates.value = templateResult.value;
|
||||
}
|
||||
if (targetResult.status === 'fulfilled') {
|
||||
targetOptions.value = targetResult.value;
|
||||
}
|
||||
targetOptionsLoading.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts exactly one logical load for a new nonempty account identity.
|
||||
* @param selfId - Current implicit account identity.
|
||||
*/
|
||||
async function loadAccount(selfId: string) {
|
||||
const revision = ++loadRevision;
|
||||
latestBindingPage = { items: [], total: 0 };
|
||||
subscriptions.value = [];
|
||||
templates.value = [];
|
||||
targetOptions.value = undefined;
|
||||
if (!selfId) {
|
||||
targetOptionsLoading.value = false;
|
||||
return;
|
||||
}
|
||||
await Promise.allSettled([
|
||||
tableApi.reload(),
|
||||
loadMetadata(selfId, revision),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Starts the one authorized initial account load after table registration. */
|
||||
function activatePanel() {
|
||||
if (canList && props.selfId) void loadAccount(props.selfId);
|
||||
}
|
||||
|
||||
/** Invalidates any pending request when this tab's panel unmounts. */
|
||||
function invalidatePendingLoad() {
|
||||
loadRevision += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders source, targets, availability, and enabled state.
|
||||
* @param slot - KtTable body-cell slot payload.
|
||||
* @param slot.column - Current data column.
|
||||
* @param slot.record - Current account binding.
|
||||
* @returns Custom cell content or undefined for native field rendering.
|
||||
*/
|
||||
function renderBodyCell(slot: {
|
||||
column: TableColumnType<QqbotMessagePushApi.QqbotMessagePublishBindingView>;
|
||||
record: QqbotMessagePushApi.QqbotMessagePublishBindingView;
|
||||
}) {
|
||||
const { column, record } = slot;
|
||||
if (column.key === 'source') {
|
||||
return `${record.sourceName} · ${record.sourceKey}`;
|
||||
}
|
||||
if (column.key === 'targets') {
|
||||
return (
|
||||
<Space wrap>
|
||||
{record.targets.map((target) => (
|
||||
<Tag key={`${target.targetType}:${target.targetId}`}>
|
||||
{target.targetType === 'group' ? '群' : '私聊'} ·{' '}
|
||||
{target.targetName || target.targetId}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (column.key === 'enabled') {
|
||||
let color = 'warning';
|
||||
let label = record.invalidReasonCode || 'unavailable';
|
||||
if (record.available) {
|
||||
color = record.enabled ? 'success' : 'default';
|
||||
label = record.enabled ? '启用' : '停用';
|
||||
}
|
||||
return <Tag color={color}>{label}</Tag>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
onMounted(activatePanel);
|
||||
onBeforeUnmount(invalidatePendingLoad);
|
||||
watch(
|
||||
() => props.selfId,
|
||||
(selfId, previousSelfId) => {
|
||||
if (!canList || selfId === previousSelfId) return;
|
||||
void loadAccount(selfId);
|
||||
},
|
||||
);
|
||||
|
||||
return () => (
|
||||
<div class="qqbot-account-config-panel__spin qqbot-account-message-push-panel">
|
||||
{canList ? (
|
||||
<>
|
||||
<AKtTable
|
||||
class="qqbot-account-config-panel__table qqbot-account-message-push-panel__table"
|
||||
onRegister={registerTable}
|
||||
v-slots={{
|
||||
bodyCell: renderBodyCell,
|
||||
headerControls: props.headerControls,
|
||||
title: props.title,
|
||||
}}
|
||||
/>
|
||||
<AccountMessagePushModal
|
||||
onSaved={handleModalSaved}
|
||||
ref={modalRef}
|
||||
selfId={props.selfId}
|
||||
subscriptions={subscriptions.value}
|
||||
targetOptions={targetOptions.value}
|
||||
targetOptionsLoading={targetOptionsLoading.value}
|
||||
templates={templates.value}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,307 @@
|
||||
/* @vitest-environment happy-dom */
|
||||
|
||||
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import Select from 'antdv-next/dist/select/index';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import MessagePushTargetPicker, {
|
||||
isValidMessagePushTargetId,
|
||||
} from './MessagePushTargetPicker';
|
||||
|
||||
/** Creates type-scoped known group/private target candidates. */
|
||||
function createOptions(): QqbotMessagePushApi.QqbotMessagePushTargetOption[] {
|
||||
return [
|
||||
{
|
||||
label: '帕鲁测试群',
|
||||
targetId: '10000000000000001',
|
||||
targetType: 'group',
|
||||
},
|
||||
{
|
||||
label: '测试好友',
|
||||
targetId: '20000000000000001',
|
||||
targetType: 'private',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Mounts the controlled picker with optional contract overrides. */
|
||||
function mountPicker(
|
||||
overrides: Partial<{
|
||||
available: boolean;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[];
|
||||
reasonCode: null | string;
|
||||
value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[];
|
||||
}> = {},
|
||||
) {
|
||||
return mount(MessagePushTargetPicker, {
|
||||
props: {
|
||||
available: true,
|
||||
options: createOptions(),
|
||||
reasonCode: null,
|
||||
value: [],
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads the newest controlled value emitted by the picker. */
|
||||
function latestValue(
|
||||
wrapper: ReturnType<typeof mountPicker>,
|
||||
): QqbotMessagePushApi.QqbotMessagePublishTargetInput[] {
|
||||
const emitted = wrapper.emitted('update:value')?.at(-1)?.[0];
|
||||
if (!Array.isArray(emitted)) throw new Error('Expected one target update');
|
||||
return emitted as QqbotMessagePushApi.QqbotMessagePublishTargetInput[];
|
||||
}
|
||||
|
||||
describe('message-push target picker', () => {
|
||||
it.each([
|
||||
['', false],
|
||||
['01234', false],
|
||||
['1234', false],
|
||||
['12345', true],
|
||||
['12345678901234567890', true],
|
||||
['123456789012345678901', false],
|
||||
])('validates the exact manual target matrix for %s', (targetId, valid) => {
|
||||
expect(isValidMessagePushTargetId(targetId)).toBe(valid);
|
||||
});
|
||||
|
||||
it('renders exactly two controlled tags Selects with type-scoped options', () => {
|
||||
const wrapper = mountPicker();
|
||||
const selects = wrapper.findAllComponents(Select);
|
||||
|
||||
expect(selects).toHaveLength(2);
|
||||
expect(selects[0]?.props()).toMatchObject({
|
||||
mode: 'tags',
|
||||
value: [],
|
||||
});
|
||||
expect(selects[1]?.props()).toMatchObject({
|
||||
mode: 'tags',
|
||||
value: [],
|
||||
});
|
||||
expect(selects[0]?.props('labelInValue')).not.toBe(true);
|
||||
expect(selects[1]?.props('labelInValue')).not.toBe(true);
|
||||
expect(selects[0]?.props('options')).toEqual([
|
||||
expect.objectContaining({
|
||||
targetId: '10000000000000001',
|
||||
value: '10000000000000001',
|
||||
}),
|
||||
]);
|
||||
expect(selects[1]?.props('options')).toEqual([
|
||||
expect.objectContaining({
|
||||
targetId: '20000000000000001',
|
||||
value: '20000000000000001',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, '测试群'],
|
||||
[0, '100000000000000'],
|
||||
[1, '好友'],
|
||||
[1, '200000000000000'],
|
||||
])('searches selector %s by known label or string ID', (index, query) => {
|
||||
const wrapper = mountPicker();
|
||||
const select = wrapper.findAllComponents(Select)[index];
|
||||
const option = (select?.props('options') || [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const filter = select?.props('filterOption') as (
|
||||
input: string,
|
||||
option: Record<string, unknown>,
|
||||
) => boolean;
|
||||
|
||||
expect(filter(query as string, option)).toBe(true);
|
||||
expect(filter('unrelated', option)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses actual controlled Select updates for multiple known/manual string IDs', async () => {
|
||||
const wrapper = mountPicker();
|
||||
const [group, privateSelect] = wrapper.findAllComponents(Select);
|
||||
|
||||
group?.vm.$emit('update:value', ['10000000000000001', '30000000000000001']);
|
||||
await flushPromises();
|
||||
expect(latestValue(wrapper)).toEqual([
|
||||
{
|
||||
targetId: '10000000000000001',
|
||||
targetName: '帕鲁测试群',
|
||||
targetType: 'group',
|
||||
},
|
||||
{ targetId: '30000000000000001', targetType: 'group' },
|
||||
]);
|
||||
|
||||
await wrapper.setProps({ value: latestValue(wrapper) });
|
||||
privateSelect?.vm.$emit('update:value', [
|
||||
'20000000000000001',
|
||||
'40000000000000001',
|
||||
]);
|
||||
await flushPromises();
|
||||
const combined = latestValue(wrapper);
|
||||
expect(combined).toEqual([
|
||||
{
|
||||
targetId: '10000000000000001',
|
||||
targetName: '帕鲁测试群',
|
||||
targetType: 'group',
|
||||
},
|
||||
{ targetId: '30000000000000001', targetType: 'group' },
|
||||
{
|
||||
targetId: '20000000000000001',
|
||||
targetName: '测试好友',
|
||||
targetType: 'private',
|
||||
},
|
||||
{ targetId: '40000000000000001', targetType: 'private' },
|
||||
]);
|
||||
expect(combined.every(({ targetId }) => typeof targetId === 'string')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid/non-string values and remounts only that Select', async () => {
|
||||
const wrapper = mountPicker({
|
||||
value: [
|
||||
{
|
||||
targetId: '20000000000000001',
|
||||
targetName: '测试好友',
|
||||
targetType: 'private',
|
||||
},
|
||||
],
|
||||
});
|
||||
const initial = wrapper.findAllComponents(Select);
|
||||
const groupKey = initial[0]?.vm.$.vnode.key;
|
||||
const privateKey = initial[1]?.vm.$.vnode.key;
|
||||
|
||||
initial[0]?.vm.$emit('update:value', [
|
||||
' 12345 ',
|
||||
'01234',
|
||||
12_345,
|
||||
{ value: '99999' },
|
||||
'12345',
|
||||
]);
|
||||
await flushPromises();
|
||||
|
||||
expect(latestValue(wrapper)).toEqual([
|
||||
{ targetId: '12345', targetType: 'group' },
|
||||
{
|
||||
targetId: '20000000000000001',
|
||||
targetName: '测试好友',
|
||||
targetType: 'private',
|
||||
},
|
||||
]);
|
||||
const next = wrapper.findAllComponents(Select);
|
||||
expect(next[0]?.vm.$.vnode.key).not.toBe(groupKey);
|
||||
expect(next[1]?.vm.$.vnode.key).toBe(privateKey);
|
||||
expect(next[0]?.props('value')).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows the exact unavailable reason while manual input remains enabled', async () => {
|
||||
const wrapper = mountPicker({
|
||||
available: false,
|
||||
options: [],
|
||||
reasonCode: 'onebot_unavailable',
|
||||
});
|
||||
const [group, privateSelect] = wrapper.findAllComponents(Select);
|
||||
|
||||
expect(wrapper.text()).toContain('onebot_unavailable');
|
||||
expect(group?.props('disabled')).toBe(false);
|
||||
expect(privateSelect?.props('disabled')).toBe(false);
|
||||
group?.vm.$emit('update:value', ['12345']);
|
||||
await flushPromises();
|
||||
expect(latestValue(wrapper)).toEqual([
|
||||
{ targetId: '12345', targetType: 'group' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps names only for the same type and omits unknown manual names', async () => {
|
||||
const equalId = '10000000000000001';
|
||||
const wrapper = mountPicker({
|
||||
options: [
|
||||
{
|
||||
label: '群名称',
|
||||
targetId: equalId,
|
||||
targetType: 'group',
|
||||
},
|
||||
{
|
||||
label: '私聊名称',
|
||||
targetId: equalId,
|
||||
targetType: 'private',
|
||||
},
|
||||
],
|
||||
});
|
||||
const [group, privateSelect] = wrapper.findAllComponents(Select);
|
||||
group?.vm.$emit('update:value', [equalId, '30000000000000001']);
|
||||
await flushPromises();
|
||||
await wrapper.setProps({ value: latestValue(wrapper) });
|
||||
privateSelect?.vm.$emit('update:value', [equalId]);
|
||||
await flushPromises();
|
||||
|
||||
expect(latestValue(wrapper)).toEqual([
|
||||
{
|
||||
targetId: equalId,
|
||||
targetName: '群名称',
|
||||
targetType: 'group',
|
||||
},
|
||||
{ targetId: '30000000000000001', targetType: 'group' },
|
||||
{
|
||||
targetId: equalId,
|
||||
targetName: '私聊名称',
|
||||
targetType: 'private',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves an existing same-type API name when candidates are unavailable', async () => {
|
||||
const wrapper = mountPicker({
|
||||
available: false,
|
||||
options: [],
|
||||
reasonCode: 'onebot_unavailable',
|
||||
value: [
|
||||
{
|
||||
targetId: '10000000000000001',
|
||||
targetName: '历史群名',
|
||||
targetType: 'group',
|
||||
},
|
||||
],
|
||||
});
|
||||
const group = wrapper.findAllComponents(Select)[0];
|
||||
|
||||
group?.vm.$emit('update:value', ['10000000000000001', '30000000000000001']);
|
||||
await flushPromises();
|
||||
expect(latestValue(wrapper)).toEqual([
|
||||
{
|
||||
targetId: '10000000000000001',
|
||||
targetName: '历史群名',
|
||||
targetType: 'group',
|
||||
},
|
||||
{ targetId: '30000000000000001', targetType: 'group' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removing one type leaves the other controlled values untouched', async () => {
|
||||
const wrapper = mountPicker({
|
||||
value: [
|
||||
{ targetId: '10000000000000001', targetType: 'group' },
|
||||
{
|
||||
targetId: '20000000000000001',
|
||||
targetName: '测试好友',
|
||||
targetType: 'private',
|
||||
},
|
||||
],
|
||||
});
|
||||
const group = wrapper.findAllComponents(Select)[0];
|
||||
|
||||
group?.vm.$emit('update:value', []);
|
||||
await flushPromises();
|
||||
expect(latestValue(wrapper)).toEqual([
|
||||
{
|
||||
targetId: '20000000000000001',
|
||||
targetName: '测试好友',
|
||||
targetType: 'private',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,322 @@
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
|
||||
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
|
||||
import Alert from 'antdv-next/dist/alert/index';
|
||||
import Select from 'antdv-next/dist/select/index';
|
||||
|
||||
const AAlert = Alert as any;
|
||||
const ASelect = Select as any;
|
||||
|
||||
type TargetType = QqbotMessagePushApi.QqbotMessagePushTargetType;
|
||||
|
||||
interface TargetSelectOption {
|
||||
label: string;
|
||||
targetId: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface MessagePushTargetPickerProps {
|
||||
available: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[];
|
||||
reasonCode: null | string;
|
||||
value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates one QQ group/user target without numeric conversion.
|
||||
* @param targetId - Trimmed target identifier supplied by the Select runtime.
|
||||
* @returns Whether the identifier is 5–20 digits and has no leading zero.
|
||||
*/
|
||||
export function isValidMessagePushTargetId(targetId: string): boolean {
|
||||
return /^[1-9]\d{4,19}$/.test(targetId);
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MessagePushTargetPicker',
|
||||
props: {
|
||||
available: {
|
||||
required: true,
|
||||
type: Boolean,
|
||||
},
|
||||
disabled: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
loading: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
options: {
|
||||
required: true,
|
||||
type: Array as PropType<
|
||||
QqbotMessagePushApi.QqbotMessagePushTargetOption[]
|
||||
>,
|
||||
},
|
||||
reasonCode: {
|
||||
default: null,
|
||||
type: String as PropType<null | string>,
|
||||
},
|
||||
value: {
|
||||
default: () => [],
|
||||
type: Array as PropType<
|
||||
QqbotMessagePushApi.QqbotMessagePublishTargetInput[]
|
||||
>,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
/**
|
||||
* Emits one normalized controlled target collection.
|
||||
* @param value - Group-first then private targets with string IDs only.
|
||||
* @returns Whether the payload remains an array for Vue emit validation.
|
||||
*/
|
||||
'update:value': (
|
||||
value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||
) => Array.isArray(value),
|
||||
},
|
||||
/** Owns only selector remount revisions; all durable values stay controlled. */
|
||||
setup(props, { emit }) {
|
||||
const groupRevision = ref(0);
|
||||
const privateRevision = ref(0);
|
||||
|
||||
/** Derives the currently controlled group IDs without cloning other fields. */
|
||||
const groupIds = computed(() => targetIdsForType(props.value, 'group'));
|
||||
/** Derives the currently controlled private IDs independently. */
|
||||
const privateIds = computed(() => targetIdsForType(props.value, 'private'));
|
||||
/** Maps only server-returned group choices to raw Select values. */
|
||||
const groupOptions = computed(() =>
|
||||
createSelectOptions(props.options, 'group'),
|
||||
);
|
||||
/** Maps only server-returned private choices to raw Select values. */
|
||||
const privateOptions = computed(() =>
|
||||
createSelectOptions(props.options, 'private'),
|
||||
);
|
||||
|
||||
/**
|
||||
* Cleans one installed Select update and remounts only rejected input.
|
||||
* @param targetType - Selector whose controlled string values changed.
|
||||
* @param runtimeValue - Raw installed Select emission; may contain bad runtime values.
|
||||
*/
|
||||
function handleValueUpdate(targetType: TargetType, runtimeValue: unknown) {
|
||||
const targetIds = normalizeTargetIds(runtimeValue);
|
||||
if (containsRejectedTarget(runtimeValue, targetIds)) {
|
||||
if (targetType === 'group') groupRevision.value += 1;
|
||||
else privateRevision.value += 1;
|
||||
}
|
||||
emit(
|
||||
'update:value',
|
||||
mergeTargets(props.value, props.options, targetType, targetIds),
|
||||
);
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class="qqbot-message-push-target-picker">
|
||||
{props.available ? null : (
|
||||
<AAlert
|
||||
class="mb-3"
|
||||
message={props.reasonCode || ''}
|
||||
showIcon
|
||||
type="warning"
|
||||
/>
|
||||
)}
|
||||
<div class="mb-3">
|
||||
<div class="mb-1 text-sm">群聊目标</div>
|
||||
<ASelect
|
||||
allowClear
|
||||
class="w-full"
|
||||
disabled={props.disabled}
|
||||
filterOption={filterTargetOption}
|
||||
key={`group-${groupRevision.value}`}
|
||||
loading={props.loading}
|
||||
mode="tags"
|
||||
onUpdate:value={(value: unknown) =>
|
||||
handleValueUpdate('group', value)
|
||||
}
|
||||
options={groupOptions.value}
|
||||
placeholder="选择或输入群号"
|
||||
value={groupIds.value}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-sm">私聊目标</div>
|
||||
<ASelect
|
||||
allowClear
|
||||
class="w-full"
|
||||
disabled={props.disabled}
|
||||
filterOption={filterTargetOption}
|
||||
key={`private-${privateRevision.value}`}
|
||||
loading={props.loading}
|
||||
mode="tags"
|
||||
onUpdate:value={(value: unknown) =>
|
||||
handleValueUpdate('private', value)
|
||||
}
|
||||
options={privateOptions.value}
|
||||
placeholder="选择或输入 QQ 号"
|
||||
value={privateIds.value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns controlled target IDs for exactly one target type.
|
||||
* @param targets - Current API/form-controlled targets.
|
||||
* @param targetType - Group or private selector identity.
|
||||
* @returns Original string IDs in their controlled order.
|
||||
*/
|
||||
function targetIdsForType(
|
||||
targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||
targetType: TargetType,
|
||||
): string[] {
|
||||
return targets
|
||||
.filter((target) => target.targetType === targetType)
|
||||
.map((target) => target.targetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one type's API candidates to plain string-value Select options.
|
||||
* @param options - Current OneBot candidate snapshot.
|
||||
* @param targetType - Type allowed in the target selector.
|
||||
* @returns Type-scoped candidates retaining their server labels.
|
||||
*/
|
||||
function createSelectOptions(
|
||||
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
||||
targetType: TargetType,
|
||||
): TargetSelectOption[] {
|
||||
return options
|
||||
.filter((option) => option.targetType === targetType)
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
targetId: option.targetId,
|
||||
value: option.targetId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches one known candidate by case-insensitive label and string ID.
|
||||
* @param input - Search text entered into the installed Select.
|
||||
* @param option - Candidate option produced by `createSelectOptions`.
|
||||
* @returns Whether its label or target ID contains the query.
|
||||
*/
|
||||
function filterTargetOption(
|
||||
input: string,
|
||||
option: Record<string, unknown>,
|
||||
): boolean {
|
||||
const query = input.toLocaleLowerCase();
|
||||
return [option.label, option.targetId, option.value].some(
|
||||
(value) =>
|
||||
typeof value === 'string' && value.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the installed tags-mode payload without coercing non-string values.
|
||||
* @param runtimeValue - Raw `update:value` payload from Antdv Next.
|
||||
* @returns Trimmed, valid, deduplicated string IDs in input order.
|
||||
*/
|
||||
function normalizeTargetIds(runtimeValue: unknown): string[] {
|
||||
if (!Array.isArray(runtimeValue)) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of runtimeValue) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const targetId = value.trim();
|
||||
if (!isValidMessagePushTargetId(targetId) || seen.has(targetId)) continue;
|
||||
seen.add(targetId);
|
||||
result.push(targetId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects internal Select state that differs from the cleaned controlled value.
|
||||
* @param runtimeValue - Raw installed component emission.
|
||||
* @param normalized - Accepted target IDs.
|
||||
* @returns Whether the affected Select must remount to discard invalid state.
|
||||
*/
|
||||
function containsRejectedTarget(
|
||||
runtimeValue: unknown,
|
||||
normalized: string[],
|
||||
): boolean {
|
||||
if (!Array.isArray(runtimeValue)) return true;
|
||||
if (runtimeValue.length !== normalized.length) return true;
|
||||
return runtimeValue.some(
|
||||
(value, index) =>
|
||||
typeof value !== 'string' || value.trim() !== normalized[index],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds group-first/private-second targets after one selector update.
|
||||
* @param current - Controlled value carrying existing API name snapshots.
|
||||
* @param options - Current type-scoped candidate labels.
|
||||
* @param changedType - Selector whose IDs are replaced.
|
||||
* @param changedIds - Sanitized replacement IDs.
|
||||
* @returns Normalized targets with no cross-type name leakage.
|
||||
*/
|
||||
function mergeTargets(
|
||||
current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
||||
changedType: TargetType,
|
||||
changedIds: string[],
|
||||
): QqbotMessagePushApi.QqbotMessagePublishTargetInput[] {
|
||||
const idsByType: Record<TargetType, string[]> = {
|
||||
group:
|
||||
changedType === 'group' ? changedIds : targetIdsForType(current, 'group'),
|
||||
private:
|
||||
changedType === 'private'
|
||||
? changedIds
|
||||
: targetIdsForType(current, 'private'),
|
||||
};
|
||||
return (['group', 'private'] as const).flatMap((targetType) =>
|
||||
idsByType[targetType].map((targetId) => {
|
||||
const targetName = resolveTargetName(
|
||||
current,
|
||||
options,
|
||||
targetType,
|
||||
targetId,
|
||||
);
|
||||
return {
|
||||
targetId,
|
||||
...(targetName ? { targetName } : {}),
|
||||
targetType,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a display name from only the same target type and identifier.
|
||||
* @param current - Existing API/form values with optional name snapshots.
|
||||
* @param options - Current OneBot candidates.
|
||||
* @param targetType - Exact group/private namespace.
|
||||
* @param targetId - Exact string target identifier.
|
||||
* @returns Current candidate label, same-type existing name, or undefined.
|
||||
*/
|
||||
function resolveTargetName(
|
||||
current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[],
|
||||
options: QqbotMessagePushApi.QqbotMessagePushTargetOption[],
|
||||
targetType: TargetType,
|
||||
targetId: string,
|
||||
): string | undefined {
|
||||
const candidate = options.find(
|
||||
(option) =>
|
||||
option.targetType === targetType && option.targetId === targetId,
|
||||
);
|
||||
const candidateName = candidate?.label;
|
||||
const comparableCandidateName = candidateName?.trim();
|
||||
if (candidateName && comparableCandidateName !== targetId) {
|
||||
return candidateName;
|
||||
}
|
||||
const existingName = current.find(
|
||||
(target) =>
|
||||
target.targetType === targetType && target.targetId === targetId,
|
||||
)?.targetName;
|
||||
return existingName?.trim() ? existingName : undefined;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user