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,
|
qqbotRuleTargetOptions,
|
||||||
} from '../../modules/options';
|
} from '../../modules/options';
|
||||||
import { getQqbotStatusColor, getQqbotStatusLabel } from '../../modules/status';
|
import { getQqbotStatusColor, getQqbotStatusLabel } from '../../modules/status';
|
||||||
|
import AccountMessagePushPanel from './AccountMessagePushPanel';
|
||||||
|
|
||||||
const AKtTable = KtTable as any;
|
const AKtTable = KtTable as any;
|
||||||
const ASpin = Spin as any;
|
const ASpin = Spin as any;
|
||||||
@ -39,6 +40,7 @@ const configTabItems = [
|
|||||||
{ key: 'command', label: '在线命令' },
|
{ key: 'command', label: '在线命令' },
|
||||||
{ key: 'event', label: '事件触发' },
|
{ key: 'event', label: '事件触发' },
|
||||||
{ key: 'rule', label: '自动回复规则' },
|
{ key: 'rule', label: '自动回复规则' },
|
||||||
|
{ key: 'message-push', label: '消息推送' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type ConfigTabKey = (typeof configTabItems)[number]['key'];
|
type ConfigTabKey = (typeof configTabItems)[number]['key'];
|
||||||
@ -431,28 +433,36 @@ export default defineComponent({
|
|||||||
|
|
||||||
return () => (
|
return () => (
|
||||||
<div class="qqbot-account-config-panel">
|
<div class="qqbot-account-config-panel">
|
||||||
<div class="qqbot-account-config-panel__spin">
|
{activeTab.value === 'message-push' ? (
|
||||||
<ASpin spinning={loading.value}>
|
<AccountMessagePushPanel
|
||||||
<AKtTable
|
headerControls={renderHeaderControls}
|
||||||
class="qqbot-account-config-panel__table"
|
selfId={currentSelfId.value}
|
||||||
columns={activeColumns.value}
|
title={renderTableTitle}
|
||||||
dataSource={activeRows.value}
|
/>
|
||||||
rowActions={activeRowActions.value}
|
) : (
|
||||||
rowKey={activeRowKey.value}
|
<div class="qqbot-account-config-panel__spin">
|
||||||
showDefaultButtons={false}
|
<ASpin spinning={loading.value}>
|
||||||
showFooter={false}
|
<AKtTable
|
||||||
showIndex={false}
|
class="qqbot-account-config-panel__table"
|
||||||
showPagination={false}
|
columns={activeColumns.value}
|
||||||
showTableSetting={false}
|
dataSource={activeRows.value}
|
||||||
size="small"
|
rowActions={activeRowActions.value}
|
||||||
v-slots={{
|
rowKey={activeRowKey.value}
|
||||||
bodyCell: renderBodyCell,
|
showDefaultButtons={false}
|
||||||
headerControls: renderHeaderControls,
|
showFooter={false}
|
||||||
title: renderTableTitle,
|
showIndex={false}
|
||||||
}}
|
showPagination={false}
|
||||||
/>
|
showTableSetting={false}
|
||||||
</ASpin>
|
size="small"
|
||||||
</div>
|
v-slots={{
|
||||||
|
bodyCell: renderBodyCell,
|
||||||
|
headerControls: renderHeaderControls,
|
||||||
|
title: renderTableTitle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ASpin>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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