feat: 增加账号消息推送配置

This commit is contained in:
sunlei 2026-07-24 17:58:49 +08:00
parent 4c41a2bdff
commit d65be70048
8 changed files with 2862 additions and 22 deletions

View File

@ -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 ID10000000000000001');
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);
});
});

View File

@ -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>
);
},

View File

@ -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);
});
});

View File

@ -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,
};
}

View File

@ -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);
});
});

View File

@ -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>
);
},
});

View File

@ -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',
},
]);
});
});

View File

@ -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 520 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;
}