feat: 优化后台表格并接入 QQBot 插件能力

This commit is contained in:
sunlei 2026-06-02 14:29:07 +08:00
parent 6221487991
commit 77270c51eb
53 changed files with 2122 additions and 4031 deletions

View File

@ -137,7 +137,22 @@ const withDefaultPlaceholder = <T extends Component>(
return () =>
h(
component,
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
{
...componentProps,
placeholder,
...props,
...attrs,
ref: innerRef,
style:
type === 'select'
? [
{ width: '100%' },
componentProps.style,
props?.style,
attrs?.style,
]
: (attrs?.style ?? props?.style ?? componentProps.style),
},
slots,
);
},

View File

@ -1,298 +0,0 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { Recordable } from '@vben/types';
import type { ComponentType } from './component';
import { h } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $te } from '@vben/locales';
import {
setupVbenVxeTable,
useVbenVxeGrid as useGrid,
} from '@vben/plugins/vxe-table';
import { get, isFunction, isString } from '@vben/utils';
import { objectOmit } from '@vueuse/core';
import { Button, Image, Popconfirm, Switch, Tag } from 'antdv-next';
import { $t } from '#/locales';
import { useVbenForm } from './form';
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
border: false,
columnConfig: {
resizable: true,
},
formConfig: {
// 全局禁用vxe-table的表单配置使用formOptions
enabled: false,
},
minHeight: 180,
proxyConfig: {
autoLoad: true,
response: {
result: 'items',
total: 'total',
list: '',
},
showActiveMsg: true,
showResponseMsg: false,
},
round: true,
showOverflow: true,
size: 'small',
} as VxeTableGridOptions,
});
/**
* vxeTable在热更新时可能会出错的问题
*/
vxeUI.renderer.forEach((_item, key) => {
if (key.startsWith('Cell')) {
vxeUI.renderer.delete(key);
}
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', {
renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params;
return h(Image, { src: row[column.field], ...props });
},
});
// 表格配置项可以用 cellRender: { name: 'CellLink' },
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(
Button,
{ size: 'small', type: 'link' },
{ default: () => props?.text },
);
},
});
// 单元格渲染: Tag
vxeUI.renderer.add('CellTag', {
renderTableDefault({ options, props }, { column, row }) {
const value = get(row, column.field);
const tagOptions = options ?? [
{ color: 'success', label: $t('common.enabled'), value: 1 },
{ color: 'error', label: $t('common.disabled'), value: 0 },
];
const tagItem = tagOptions.find((item) => item.value === value);
return h(
Tag,
{
...props,
...objectOmit(tagItem ?? {}, ['label']),
},
{ default: () => tagItem?.label ?? value },
);
},
});
vxeUI.renderer.add('CellSwitch', {
renderTableDefault({ attrs, props }, { column, row }) {
const loadingKey = `__loading_${column.field}`;
const finallyProps = {
checkedChildren: $t('common.enabled'),
checkedValue: 1,
unCheckedChildren: $t('common.disabled'),
unCheckedValue: 0,
...props,
checked: row[column.field],
loading: row[loadingKey] ?? false,
'onUpdate:checked': onChange,
};
async function onChange(newVal: any) {
row[loadingKey] = true;
try {
const result = await attrs?.beforeChange?.(newVal, row);
if (result !== false) {
row[column.field] = newVal;
}
} finally {
row[loadingKey] = false;
}
}
return h(Switch, finallyProps);
},
});
/**
*
*/
vxeUI.renderer.add('CellOperation', {
renderTableDefault({ attrs, options, props }, { column, row }) {
const defaultProps = { size: 'small', type: 'link', ...props };
let align = 'end';
switch (column.align) {
case 'center': {
align = 'center';
break;
}
case 'left': {
align = 'start';
break;
}
default: {
align = 'end';
break;
}
}
const presets: Recordable<Recordable<any>> = {
delete: {
danger: true,
text: $t('common.delete'),
},
edit: {
text: $t('common.edit'),
},
};
const operations: Array<Recordable<any>> = (
options || ['edit', 'delete']
)
.map((opt) => {
if (isString(opt)) {
return presets[opt]
? { code: opt, ...presets[opt], ...defaultProps }
: {
code: opt,
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
...defaultProps,
};
} else {
return { ...defaultProps, ...presets[opt.code], ...opt };
}
})
.map((opt) => {
const optBtn: Recordable<any> = {};
Object.keys(opt).forEach((key) => {
optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
});
return optBtn;
})
.filter((opt) => opt.show !== false);
function renderBtn(opt: Recordable<any>, listen = true) {
return h(
Button,
{
...props,
...opt,
icon: undefined,
onClick: listen
? () =>
attrs?.onClick?.({
code: opt.code,
row,
})
: undefined,
},
{
default: () => {
const content = [];
if (opt.icon) {
content.push(
h(IconifyIcon, { class: 'size-5', icon: opt.icon }),
);
}
content.push(opt.text);
return content;
},
},
);
}
function renderConfirm(opt: Recordable<any>) {
let viewportWrapper: HTMLElement | null = null;
return h(
Popconfirm,
{
/**
* popconfirm用在固定列中时
*
* body或者表格视口区域作为弹窗容器时又会导致弹窗无法跟随表格滚动
*
*
*/
getPopupContainer(el) {
viewportWrapper = el.closest('.vxe-table--viewport-wrapper');
return document.body;
},
placement: 'topLeft',
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
...props,
...opt,
icon: undefined,
onOpenChange: (open: boolean) => {
// 当弹窗打开时,禁止表格的滚动
if (open) {
viewportWrapper?.style.setProperty('pointer-events', 'none');
} else {
viewportWrapper?.style.removeProperty('pointer-events');
}
},
onConfirm: () => {
attrs?.onClick?.({
code: opt.code,
row,
});
},
},
{
default: () => renderBtn({ ...opt }, false),
description: () =>
h(
'div',
{ class: 'truncate' },
$t('ui.actionMessage.deleteConfirm', [
row[attrs?.nameField || 'name'],
]),
),
},
);
}
const btns = operations.map((opt) =>
opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt),
);
return h(
'div',
{
class: 'flex table-operations',
style: { justifyContent: align },
},
btns,
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
},
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType>>
) => useGrid<T, ComponentType>(...rest);
export type OnActionClickParams<T = Recordable<any>> = {
code: string;
row: T;
};
export type OnActionClickFn<T = Recordable<any>> = (
params: OnActionClickParams<T>,
) => void;
export type * from '@vben/plugins/vxe-table';

View File

@ -22,6 +22,12 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'QqBotAccountDelete',
'QqBotAccountEdit',
'QqBotAccountKick',
'QqBotCommand',
'QqBotCommandCreate',
'QqBotCommandDelete',
'QqBotCommandEdit',
'QqBotCommandTest',
'QqBotCommandToggle',
'QqBotConversation',
'QqBotDashboard',
'QqBotMessage',
@ -29,6 +35,7 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'QqBotPermissionCreate',
'QqBotPermissionDelete',
'QqBotPermissionEdit',
'QqBotPlugin',
'QqBotRule',
'QqBotRuleCreate',
'QqBotRuleDelete',

View File

@ -159,6 +159,78 @@ export namespace QqbotApi {
userId?: string;
}
export interface Command {
aliases: string[];
code: string;
cooldownMs: number;
defaultParams?: Recordable<any>;
enabled: boolean;
errorTemplate?: string;
id: string;
lastHitAt?: string;
name: string;
operationKey: string;
parserKey: 'ff14Price' | 'plain';
pluginKey: string;
prefixes: string[];
priority: number;
remark?: string;
replyTemplate?: string;
targetType: 'all' | 'channel' | 'group' | 'private';
}
export interface CommandBody {
aliases?: string | string[];
code: string;
cooldownMs?: number;
defaultParams?: Recordable<any> | string;
enabled?: boolean;
errorTemplate?: string;
id?: string;
name: string;
operationKey: string;
parserKey?: 'ff14Price' | 'plain';
pluginKey: string;
prefixes?: string | string[];
priority?: number;
remark?: string;
replyTemplate?: string;
targetType?: 'all' | 'channel' | 'group' | 'private';
}
export interface CommandTestResult {
command?: Command;
input?: Recordable<any>;
matched: boolean;
message?: string;
output?: Recordable<any>;
replyText?: string;
}
export interface Plugin {
description?: string;
key: string;
name: string;
operationCount: number;
version: string;
}
export interface PluginOperation {
cacheTtlMs?: number;
description?: string;
inputSchema?: Recordable<any>;
key: string;
name: string;
outputSchema?: Recordable<any>;
pluginKey: string;
}
export interface PluginHealth {
checkedAt: string;
message?: string;
status: 'degraded' | 'healthy' | 'offline';
}
export type Query = Recordable<any>;
}
@ -339,3 +411,59 @@ export function deleteQqbotPermission(
`/qqbot/permission/${kind}/delete?id=${id}`,
);
}
export function getQqbotCommandList(params: QqbotApi.Query) {
return requestClient.get<QqbotApi.PageResult<QqbotApi.Command>>(
'/qqbot/command/list',
{ params },
);
}
export function createQqbotCommand(data: QqbotApi.CommandBody) {
return requestClient.post<string>('/qqbot/command/save', data);
}
export function updateQqbotCommand(data: QqbotApi.CommandBody) {
return requestClient.post<boolean>('/qqbot/command/update', data);
}
export function deleteQqbotCommand(id: string) {
return requestClient.post<boolean>(`/qqbot/command/delete?id=${id}`);
}
export function toggleQqbotCommand(id: string, enabled: boolean) {
return requestClient.post<boolean>(
`/qqbot/command/toggle?id=${id}&enabled=${enabled}`,
);
}
export function testQqbotCommand(data: {
commandId?: string;
selfId?: string;
targetId?: string;
targetType?: 'channel' | 'group' | 'private';
text: string;
userId?: string;
}) {
return requestClient.post<QqbotApi.CommandTestResult>(
'/qqbot/command/test',
data,
);
}
export function getQqbotPluginList() {
return requestClient.get<QqbotApi.Plugin[]>('/qqbot/plugin/list');
}
export function getQqbotPluginOperationList(pluginKey?: string) {
return requestClient.get<QqbotApi.PluginOperation[]>(
'/qqbot/plugin/operation/list',
{ params: { pluginKey } },
);
}
export function getQqbotPluginHealth(pluginKey?: string) {
return requestClient.get<QqbotApi.PluginHealth[]>('/qqbot/plugin/health', {
params: { pluginKey },
});
}

View File

@ -870,6 +870,7 @@ export default defineComponent({
{props.showHeader ? (
<KtTableHeader title={props.tableTitle}>
{{
controls: () => slots.headerControls?.(context),
settings: renderHeaderSettings,
title: () => slots.title?.(),
toolbar: renderHeaderButtons,

View File

@ -5,19 +5,28 @@ import { Divider } from 'antdv-next';
const ADivider = Divider as any;
/**
*
*
*
* @param title
* @param content
*/
function isEmptyTitleSlot(title: unknown) {
function isEmptySlot(content: unknown) {
return (
Array.isArray(title) &&
title.every(
Array.isArray(content) &&
content.every(
(item) => item === null || (isVNode(item) && item.type === Comment),
)
);
}
/**
*
*
* @param content
*/
function resolveSlotContent(content: unknown) {
return !content || isEmptySlot(content) ? null : content;
}
export default defineComponent({
name: 'KtTableHeader',
props: {
@ -31,30 +40,50 @@ export default defineComponent({
*
* @param props props
* @param slots Vue setup context
* @param slots.slots
* @param slots.slots
*/
setup(props, { slots }) {
return () => {
const slotTitle = slots.title?.();
const title =
!slotTitle || isEmptyTitleSlot(slotTitle) ? props.title : slotTitle;
const toolbar = slots.toolbar?.();
const settings = slots.settings?.();
const slotTitle = resolveSlotContent(slots.title?.());
const title = slotTitle || props.title;
const controls = resolveSlotContent(slots.controls?.());
const toolbar = resolveSlotContent(slots.toolbar?.());
const settings = resolveSlotContent(slots.settings?.());
if (!title && !toolbar && !settings) return null;
if (!title && !controls && !toolbar && !settings) return null;
return (
<div class="kt-table__header">
<div class="kt-table__header-align">
<div class="kt-table__header-title">{title}</div>
<div class="kt-table__header-button">{toolbar}</div>
</div>
{settings ? (
<div class="kt-table__header-toolbar">
<ADivider class="kt-table__header-divider" type="vertical" />
{settings}
<div class="kt-table__header-layout">
<div class="kt-table__header-content">
{title ? (
<div class="kt-table__header-title-row">
<div class="kt-table__header-title">{title}</div>
</div>
) : null}
{controls ? (
<div class="kt-table__header-controls">{controls}</div>
) : null}
</div>
) : null}
{toolbar || settings ? (
<div class="kt-table__header-actions">
{toolbar ? (
<div class="kt-table__header-button">{toolbar}</div>
) : null}
{settings ? (
<div class="kt-table__header-toolbar">
{toolbar ? (
<ADivider
class="kt-table__header-divider"
type="vertical"
/>
) : null}
{settings}
</div>
) : null}
</div>
) : null}
</div>
</div>
);
};

View File

@ -1,6 +1,6 @@
import type { PropType } from 'vue';
import { defineComponent, h, onBeforeUnmount, ref } from 'vue';
import { defineComponent, onBeforeUnmount, ref } from 'vue';
export interface KtTableResizeInfo {
size: {
@ -13,6 +13,10 @@ type KtTableResizableTitleProps = {
width?: number;
};
type KtTableClickHandler =
| ((event: MouseEvent) => void)
| Array<(event: MouseEvent) => void>;
export default defineComponent({
name: 'KtTableResizableTitle',
inheritAttrs: false,
@ -164,12 +168,32 @@ export default defineComponent({
}
/**
*
*
*
* @param event
* @param event
*/
function handleClickCapture(event: MouseEvent) {
if (!stopNextClick.value) return;
function runOriginalClick(event: MouseEvent) {
const handler = attrs.onClick as KtTableClickHandler | undefined;
if (!handler) return;
if (Array.isArray(handler)) {
handler.forEach((item) => item(event));
return;
}
handler(event);
}
/**
*
*
* @param event
*/
function handleHeaderClick(event: MouseEvent) {
if (!stopNextClick.value) {
runOriginalClick(event);
return;
}
event.stopPropagation();
event.preventDefault();
@ -183,41 +207,39 @@ export default defineComponent({
return () => {
if (!props.width) {
return h('th', attrs, slots.default?.());
return <th {...attrs}>{slots.default?.()}</th>;
}
if (!props.onResize) {
return h(
'th',
{
...attrs,
style: {
return (
<th
{...attrs}
style={{
...(attrs.style as Record<string, unknown> | undefined),
width: `${props.width}px`,
},
},
slots.default?.(),
}}
>
{slots.default?.()}
</th>
);
}
return h(
'th',
{
...attrs,
class: ['kt-table__resizable-title', attrs.class],
onClickCapture: handleClickCapture,
style: {
return (
<th
{...attrs}
class={['kt-table__resizable-title', attrs.class]}
onClick={handleHeaderClick}
style={{
...(attrs.style as Record<string, unknown> | undefined),
width: `${props.width}px`,
},
},
[
slots.default?.(),
h('span', {
class: 'kt-table__resizable-handle',
onMousedown: handleMouseDown,
}),
],
}}
>
{slots.default?.()}
<span
class="kt-table__resizable-handle"
onMousedown={handleMouseDown}
/>
</th>
);
};
},

View File

@ -12,7 +12,7 @@ export const KT_TABLE_DEFAULT_ROW_RESIZE_MAX_HEIGHT = 140;
export const KT_TABLE_DEFAULT_ROW_RESIZE_MIN_HEIGHT = 40;
export const KT_TABLE_ROW_ACTION_VISIBLE_COUNT = 2;
export const KT_TABLE_ROW_ACTION_VISIBLE_COUNT = 1;
export const KT_TABLE_DEFAULT_PAGE_SIZE = 10;

View File

@ -2,20 +2,31 @@
@include kt.block {
&__header {
display: flex;
display: block;
flex-shrink: 0;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
&__header-align {
&__header-layout {
display: flex;
flex: 1 1 0;
gap: 12px;
align-items: flex-end;
justify-content: space-between;
min-width: 0;
}
&__header-content {
display: grid;
flex: 1 1 auto;
gap: 8px;
min-width: 0;
}
&__header-title-row {
display: flex;
gap: 12px;
align-items: center;
justify-content: flex-start;
min-width: 0;
}
@ -25,6 +36,56 @@
font-weight: 500;
}
&__header-controls {
display: grid;
gap: 6px;
align-items: start;
min-width: 0;
}
&__header-control-group {
display: flex;
flex: 0 1 auto;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
min-width: 0;
}
&__header-control-group--grow {
flex: 0 1 auto;
}
&__header-control-label {
flex: 0 0 auto;
font-weight: 500;
line-height: 32px;
}
&__header-control-muted {
flex: 0 0 auto;
line-height: 32px;
color: hsl(var(--muted-foreground));
}
&__header-tabs {
min-width: 0;
.ant-tabs-nav {
margin: 0;
}
}
&__header-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
min-height: 32px;
}
&__header-button {
display: flex;
flex-shrink: 0;

View File

@ -26,18 +26,6 @@
"crop-image": "Crop image",
"upload-image": "Click to upload image"
},
"vxeTable": {
"title": "Vxe Table",
"basic": "Basic Table",
"remote": "Remote Load",
"tree": "Tree Table",
"fixed": "Fixed Header/Column",
"virtual": "Virtual Scroll",
"editCell": "Edit Cell",
"editRow": "Edit Row",
"custom-cell": "Custom Cell",
"form": "Form Table"
},
"captcha": {
"title": "Captcha",
"pointSelection": "Point Selection Captcha",

View File

@ -29,18 +29,6 @@
"crop-image": "裁剪图片",
"upload-image": "点击上传图片"
},
"vxeTable": {
"title": "Vxe 表格",
"basic": "基础表格",
"remote": "远程加载",
"tree": "树形表格",
"fixed": "固定表头/列",
"virtual": "虚拟滚动",
"editCell": "单元格编辑",
"editRow": "行编辑",
"custom-cell": "自定义单元格",
"form": "搜索表单"
},
"captcha": {
"title": "验证码",
"pointSelection": "点选验证",

View File

@ -96,89 +96,6 @@ const routes: RouteRecordRaw[] = [
},
],
},
{
name: 'VxeTableExample',
path: '/examples/vxe-table',
meta: {
icon: 'lucide:table',
title: $t('examples.vxeTable.title'),
},
children: [
{
name: 'VxeTableBasicExample',
path: '/examples/vxe-table/basic',
component: () => import('#/views/examples/vxe-table/basic.vue'),
meta: {
title: $t('examples.vxeTable.basic'),
},
},
{
name: 'VxeTableRemoteExample',
path: '/examples/vxe-table/remote',
component: () => import('#/views/examples/vxe-table/remote.vue'),
meta: {
title: $t('examples.vxeTable.remote'),
},
},
{
name: 'VxeTableTreeExample',
path: '/examples/vxe-table/tree',
component: () => import('#/views/examples/vxe-table/tree.vue'),
meta: {
title: $t('examples.vxeTable.tree'),
},
},
{
name: 'VxeTableFixedExample',
path: '/examples/vxe-table/fixed',
component: () => import('#/views/examples/vxe-table/fixed.vue'),
meta: {
title: $t('examples.vxeTable.fixed'),
},
},
{
name: 'VxeTableCustomCellExample',
path: '/examples/vxe-table/custom-cell',
component: () =>
import('#/views/examples/vxe-table/custom-cell.vue'),
meta: {
title: $t('examples.vxeTable.custom-cell'),
},
},
{
name: 'VxeTableFormExample',
path: '/examples/vxe-table/form',
component: () => import('#/views/examples/vxe-table/form.vue'),
meta: {
title: $t('examples.vxeTable.form'),
},
},
{
name: 'VxeTableEditCellExample',
path: '/examples/vxe-table/edit-cell',
component: () => import('#/views/examples/vxe-table/edit-cell.vue'),
meta: {
title: $t('examples.vxeTable.editCell'),
},
},
{
name: 'VxeTableEditRowExample',
path: '/examples/vxe-table/edit-row',
component: () => import('#/views/examples/vxe-table/edit-row.vue'),
meta: {
title: $t('examples.vxeTable.editRow'),
},
},
{
name: 'VxeTableVirtualExample',
path: '/examples/vxe-table/virtual',
component: () => import('#/views/examples/vxe-table/virtual.vue'),
meta: {
title: $t('examples.vxeTable.virtual'),
},
},
],
},
{
name: 'CaptchaExample',
path: '/examples/captcha',

View File

@ -38,6 +38,24 @@ const routes: RouteRecordRaw[] = [
name: 'QqBotRule',
path: '/qqbot/rule',
},
{
component: () => import('#/views/qqbot/command/list'),
meta: {
icon: 'lucide:square-terminal',
title: '在线命令',
},
name: 'QqBotCommand',
path: '/qqbot/command',
},
{
component: () => import('#/views/qqbot/plugin/list'),
meta: {
icon: 'lucide:plug',
title: '插件能力',
},
name: 'QqBotPlugin',
path: '/qqbot/plugin',
},
{
component: () => import('#/views/qqbot/conversation/list'),
meta: {

View File

@ -8,30 +8,14 @@ import type {
KtTableRowAction,
} from '#/components/ktTable';
import {
computed,
defineComponent,
onActivated,
onMounted,
reactive,
ref,
} from 'vue';
import { computed, defineComponent, onActivated, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import {
Form,
FormItem,
Input,
message,
Modal,
Select,
Switch,
Tag,
TextArea,
} from 'antdv-next';
import { message, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
createArticle,
deleteArticle,
@ -57,11 +41,6 @@ type ArticleSearchValues = {
};
const AKtTable = KtTable as any;
const AInput = Input as any;
const AModal = Modal as any;
const ASelect = Select as any;
const ASwitch = Switch as any;
const ATextArea = TextArea as any;
const articleStatusOptions = [
{ color: 'success', label: '已发布', value: 'publish' },
@ -73,26 +52,106 @@ const articleStatusOptions = [
export default defineComponent({
name: 'BlogArticleList',
setup() {
const saving = ref(false);
const modalOpen = ref(false);
const editingId = ref<number>();
const categoryOptions = ref<TermOption[]>([]);
const tagOptions = ref<TermOption[]>([]);
const form = reactive<WordpressBlogApi.ArticleBody>({
categories: [],
content: '',
excerpt: '',
slug: '',
status: 'draft',
sticky: false,
tags: [],
title: '',
const [ArticleForm, articleFormApi] = useVbenForm({
commonConfig: {
labelClass: 'w-20',
},
layout: 'horizontal',
schema: [
{
component: 'Input',
componentProps: {
placeholder: '请输入文章标题',
},
fieldName: 'title',
label: '标题',
rules: 'required',
},
{
component: 'Select',
componentProps: {
options: articleStatusOptions,
},
fieldName: 'status',
label: '状态',
},
{
component: 'Input',
componentProps: {
placeholder: '可选WordPress slug',
},
fieldName: 'slug',
label: '别名',
},
{
component: 'Select',
componentProps: () => ({
mode: 'multiple',
options: categoryOptions.value,
placeholder: '选择分类',
}),
fieldName: 'categories',
label: '分类',
},
{
component: 'Select',
componentProps: () => ({
mode: 'multiple',
options: tagOptions.value,
placeholder: '选择标签',
}),
fieldName: 'tags',
label: '标签',
},
{
component: 'Textarea',
componentProps: {
autoSize: { maxRows: 4, minRows: 2 },
placeholder: '可选,文章摘要',
},
fieldName: 'excerpt',
label: '摘要',
},
{
component: 'Textarea',
componentProps: {
autoSize: { maxRows: 12, minRows: 6 },
placeholder: '支持 HTML 内容',
},
fieldName: 'content',
label: '内容',
},
{
component: 'Switch',
fieldName: 'sticky',
label: '置顶',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const modalTitle = computed(() =>
editingId.value ? '编辑文章' : '新建文章',
);
const [ArticleModal, articleModalApi] = useVbenModal({
class: 'w-[760px]',
fullscreenButton: false,
async onConfirm() {
await submitArticle();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = articleModalApi.getData<{
values?: WordpressBlogApi.ArticleBody;
}>();
void resetArticleForm(values || getArticleFormDefaults());
},
});
const columns: Array<TableColumnType<WordpressBlogApi.Article>> = [
{ dataIndex: 'title', key: 'title', title: '标题', width: 280 },
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
@ -310,15 +369,10 @@ export default defineComponent({
await tableApi.search();
}
async function openCreate(
context?: KtTableContext<WordpressBlogApi.Article, ArticleSearchValues>,
) {
const searchValues = context
? await context.getSearchValues()
: await tableApi.getSearchValues();
editingId.value = undefined;
Object.assign(form, {
function getArticleFormDefaults(
searchValues: ArticleSearchValues = {},
): WordpressBlogApi.ArticleBody {
return {
categories: [...(searchValues.categories || [])],
content: '',
excerpt: '',
@ -327,47 +381,74 @@ export default defineComponent({
sticky: false,
tags: [...(searchValues.tags || [])],
title: '',
});
modalOpen.value = true;
};
}
async function resetArticleForm(values: WordpressBlogApi.ArticleBody) {
await articleFormApi.resetForm();
await articleFormApi.setValues(values);
await articleFormApi.resetValidate();
}
async function openCreate(
context?: KtTableContext<WordpressBlogApi.Article, ArticleSearchValues>,
) {
const searchValues = context
? await context.getSearchValues()
: await tableApi.getSearchValues();
editingId.value = undefined;
articleModalApi
.setData({ values: getArticleFormDefaults(searchValues) })
.open();
}
function openEdit(row: WordpressBlogApi.Article) {
editingId.value = row.id;
Object.assign(form, {
categories: row.categories || [],
content: getRenderedText(row.content),
excerpt: getRenderedText(row.excerpt),
id: row.id,
slug: row.slug || '',
status: row.status || 'draft',
sticky: !!row.sticky,
tags: row.tags || [],
title: getRenderedText(row.title),
});
modalOpen.value = true;
articleModalApi
.setData({
values: {
categories: row.categories || [],
content: getRenderedText(row.content),
excerpt: getRenderedText(row.excerpt),
id: row.id,
slug: row.slug || '',
status: row.status || 'draft',
sticky: !!row.sticky,
tags: row.tags || [],
title: getRenderedText(row.title),
},
})
.open();
}
async function submitArticle() {
if (!form.title.trim()) {
const { valid } = await articleFormApi.validate();
if (!valid) return;
const values =
await articleFormApi.getValues<WordpressBlogApi.ArticleBody>();
const title = values.title?.trim();
if (!title) {
message.warning('请填写文章标题');
return;
}
saving.value = true;
articleModalApi.lock();
try {
const payload = {
...form,
...values,
id: editingId.value,
title: form.title.trim(),
title,
};
await (editingId.value
? updateArticle(payload)
: createArticle(payload));
message.success('文章保存成功');
modalOpen.value = false;
await articleModalApi.close();
await tableApi.reload();
} finally {
saving.value = false;
articleModalApi.unlock();
}
}
@ -457,96 +538,9 @@ export default defineComponent({
}}
/>
<AModal
confirmLoading={saving.value}
onOk={submitArticle}
onUpdate:open={(value: boolean) => {
modalOpen.value = value;
}}
open={modalOpen.value}
title={modalTitle.value}
width="760px"
>
<Form labelCol={{ span: 4 }} model={form} wrapperCol={{ span: 19 }}>
<FormItem label="标题" required>
<AInput
onUpdate:value={(value: string) => {
form.title = value;
}}
placeholder="请输入文章标题"
value={form.title}
/>
</FormItem>
<FormItem label="状态">
<ASelect
onUpdate:value={(value: string | undefined) => {
form.status = value;
}}
options={articleStatusOptions}
value={form.status}
/>
</FormItem>
<FormItem label="别名">
<AInput
onUpdate:value={(value: string | undefined) => {
form.slug = value;
}}
placeholder="可选WordPress slug"
value={form.slug}
/>
</FormItem>
<FormItem label="分类">
<ASelect
mode="multiple"
onUpdate:value={(value: number[] | undefined) => {
form.categories = value;
}}
options={categoryOptions.value}
placeholder="选择分类"
value={form.categories}
/>
</FormItem>
<FormItem label="标签">
<ASelect
mode="multiple"
onUpdate:value={(value: number[] | undefined) => {
form.tags = value;
}}
options={tagOptions.value}
placeholder="选择标签"
value={form.tags}
/>
</FormItem>
<FormItem label="摘要">
<ATextArea
autoSize={{ maxRows: 4, minRows: 2 }}
onUpdate:value={(value: string | undefined) => {
form.excerpt = value;
}}
placeholder="可选,文章摘要"
value={form.excerpt}
/>
</FormItem>
<FormItem label="内容">
<ATextArea
autoSize={{ maxRows: 12, minRows: 6 }}
onUpdate:value={(value: string | undefined) => {
form.content = value;
}}
placeholder="支持 HTML 内容"
value={form.content}
/>
</FormItem>
<FormItem label="置顶">
<ASwitch
checked={form.sticky}
onUpdate:checked={(value: boolean) => {
form.sticky = value;
}}
/>
</FormItem>
</Form>
</AModal>
<ArticleModal title={modalTitle.value}>
<ArticleForm class="mx-2" />
</ArticleModal>
</Page>
);
},

View File

@ -9,29 +9,15 @@ import type {
KtTableRowAction,
} from '#/components/ktTable';
import {
computed,
defineComponent,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { computed, defineComponent, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import {
Form,
FormItem,
Input,
message,
Modal,
Select,
TextArea,
} from 'antdv-next';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
createCategory,
createTag,
@ -51,10 +37,6 @@ type TermSearchValues = {
};
const AKtTable = KtTable as any;
const AInput = Input as any;
const AModal = Modal as any;
const ASelect = Select as any;
const ATextArea = TextArea as any;
export default defineComponent({
name: 'BlogTermManagement',
@ -72,21 +54,82 @@ export default defineComponent({
const route = useRoute();
const router = useRouter();
const saving = ref(false);
const modalOpen = ref(false);
const editingId = ref<number>();
const tableRows = ref<WordpressBlogApi.Term[]>([]);
const parentOptions = computed(() =>
tableRows.value
.filter((item) => item.id !== editingId.value)
.map((item) => ({ label: item.name, value: item.id })),
);
const form = reactive<WordpressBlogApi.TermBody>({
description: '',
name: '',
parent: undefined,
slug: '',
const [TermForm, termFormApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
layout: 'horizontal',
schema: [
{
component: 'Input',
componentProps: () => ({
placeholder: `请输入${props.title}名称`,
}),
fieldName: 'name',
label: '名称',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '可选WordPress slug',
},
fieldName: 'slug',
label: '别名',
},
{
component: 'Select',
componentProps: () => ({
allowClear: true,
options: parentOptions.value,
placeholder: '选择父级分类',
}),
dependencies: {
if: () => props.kind === 'category',
triggerFields: ['name'],
},
fieldName: 'parent',
label: '父级分类',
},
{
component: 'Textarea',
componentProps: {
autoSize: { maxRows: 6, minRows: 3 },
placeholder: '可选',
},
fieldName: 'description',
label: '描述',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const modalTitle = computed(() =>
editingId.value ? `编辑${props.title}` : `新建${props.title}`,
);
const [TermModal, termModalApi] = useVbenModal({
class: 'w-[620px]',
fullscreenButton: false,
async onConfirm() {
await submitTerm();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = termModalApi.getData<{
values?: WordpressBlogApi.TermBody;
}>();
void resetTermForm(values || getTermFormDefaults());
},
});
const permissionModule = computed(() =>
props.kind === 'category' ? 'Blog:Category' : 'Blog:Tag',
);
@ -103,12 +146,6 @@ export default defineComponent({
},
],
);
const parentOptions = computed(() =>
tableRows.value
.filter((item) => item.id !== editingId.value)
.map((item) => ({ label: item.name, value: item.id })),
);
const api: KtTableApi<WordpressBlogApi.Term, TermSearchValues> = {
list: async (params) => {
const requestParams = {
@ -225,41 +262,58 @@ export default defineComponent({
});
}
function openCreate() {
editingId.value = undefined;
Object.assign(form, {
function getTermFormDefaults(): WordpressBlogApi.TermBody {
return {
description: '',
name: '',
parent: undefined,
slug: '',
});
modalOpen.value = true;
};
}
async function resetTermForm(values: WordpressBlogApi.TermBody) {
await termFormApi.resetForm();
await termFormApi.setValues(values);
await termFormApi.resetValidate();
}
function openCreate() {
editingId.value = undefined;
termModalApi.setData({ values: getTermFormDefaults() }).open();
}
function openEdit(row: WordpressBlogApi.Term) {
editingId.value = row.id;
Object.assign(form, {
description: row.description || '',
id: row.id,
name: row.name,
parent: row.parent || undefined,
slug: row.slug || '',
});
modalOpen.value = true;
termModalApi
.setData({
values: {
description: row.description || '',
id: row.id,
name: row.name,
parent: row.parent || undefined,
slug: row.slug || '',
},
})
.open();
}
async function submitTerm() {
if (!form.name.trim()) {
const { valid } = await termFormApi.validate();
if (!valid) return;
const values = await termFormApi.getValues<WordpressBlogApi.TermBody>();
const name = values.name?.trim();
if (!name) {
message.warning(`请填写${props.title}名称`);
return;
}
saving.value = true;
termModalApi.lock();
try {
const payload = {
...form,
...values,
id: editingId.value,
name: form.name.trim(),
name,
};
if (props.kind === 'category') {
await (editingId.value
@ -269,10 +323,10 @@ export default defineComponent({
await (editingId.value ? updateTag(payload) : createTag(payload));
}
message.success(`${props.title}保存成功`);
modalOpen.value = false;
await termModalApi.close();
await tableApi.reload();
} finally {
saving.value = false;
termModalApi.unlock();
}
}
@ -323,60 +377,9 @@ export default defineComponent({
}}
/>
<AModal
confirmLoading={saving.value}
onOk={submitTerm}
onUpdate:open={(value: boolean) => {
modalOpen.value = value;
}}
open={modalOpen.value}
title={modalTitle.value}
width="620px"
>
<Form labelCol={{ span: 5 }} model={form} wrapperCol={{ span: 18 }}>
<FormItem label="名称" required>
<AInput
onUpdate:value={(value: string) => {
form.name = value;
}}
placeholder={`请输入${props.title}名称`}
value={form.name}
/>
</FormItem>
<FormItem label="别名">
<AInput
onUpdate:value={(value: string | undefined) => {
form.slug = value;
}}
placeholder="可选WordPress slug"
value={form.slug}
/>
</FormItem>
{props.kind === 'category' ? (
<FormItem label="父级分类">
<ASelect
allowClear
onUpdate:value={(value: number | undefined) => {
form.parent = value;
}}
options={parentOptions.value}
placeholder="选择父级分类"
value={form.parent}
/>
</FormItem>
) : null}
<FormItem label="描述">
<ATextArea
autoSize={{ maxRows: 6, minRows: 3 }}
onUpdate:value={(value: string | undefined) => {
form.description = value;
}}
placeholder="可选"
value={form.description}
/>
</FormItem>
</Form>
</AModal>
<TermModal title={modalTitle.value}>
<TermForm class="mx-2" />
</TermModal>
</Page>
);
},

View File

@ -1,112 +0,0 @@
<script lang="ts" setup>
import type { VxeGridListeners, VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button, message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import DocButton from '../doc-button.vue';
import { MOCK_TABLE_DATA } from './table-data';
interface RowType {
address: string;
age: number;
id: number;
name: string;
nickname: string;
role: string;
}
const gridOptions: VxeGridProps<RowType> = {
columns: [
{ title: '序号', type: 'seq', width: 50 },
{ field: 'name', title: 'Name' },
{ field: 'age', sortable: true, title: 'Age' },
{ field: 'nickname', title: 'Nickname' },
{ field: 'role', title: 'Role' },
{ field: 'address', showOverflow: true, title: 'Address' },
],
data: MOCK_TABLE_DATA,
pagerConfig: {
enabled: false,
},
sortConfig: {
multiple: true,
},
};
const gridEvents: VxeGridListeners<RowType> = {
cellClick: ({ row }) => {
message.info(`cell-click: ${row.name}`);
},
};
// @ts-ignore
const [Grid, gridApi] = useVbenVxeGrid<RowType>({
//
// formOptions: {
// schema: [
// {
// component: 'Switch',
// fieldName: 'name',
// },
// ],
// },
gridEvents,
gridOptions,
});
//
// gridApi.grid
const showBorder = gridApi.useStore((state) => state.gridOptions?.border);
const showStripe = gridApi.useStore((state) => state.gridOptions?.stripe);
function changeBorder() {
gridApi.setGridOptions({
border: !showBorder.value,
});
}
function changeStripe() {
gridApi.setGridOptions({
stripe: !showStripe.value,
});
}
function changeLoading() {
gridApi.setLoading(true);
setTimeout(() => {
gridApi.setLoading(false);
}, 2000);
}
</script>
<template>
<Page
description="表格组件常用于快速开发数据展示与交互界面示例数据为静态数据。该组件是对vxe-table进行简单的二次封装大部分属性与方法与vxe-table保持一致。"
title="表格基础示例"
>
<template #extra>
<DocButton path="/components/common-ui/vben-vxe-table" />
</template>
<Grid table-title="基础列表" table-title-help="提示">
<!-- <template #toolbar-actions>
<Button class="mr-2" type="primary">左侧插槽</Button>
</template> -->
<template #toolbar-tools>
<Button class="mr-2" type="primary" @click="changeBorder">
{{ showBorder ? '隐藏' : '显示' }}边框
</Button>
<Button class="mr-2" type="primary" @click="changeLoading">
显示loading
</Button>
<Button type="primary" @click="changeStripe">
{{ showStripe ? '隐藏' : '显示' }}斑马纹
</Button>
</template>
</Grid>
</Page>
</template>

View File

@ -1,108 +0,0 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button, Image, Switch, Tag } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';
interface RowType {
category: string;
color: string;
id: string;
imageUrl: string;
open: boolean;
price: string;
productName: string;
releaseDate: string;
status: 'error' | 'success' | 'warning';
}
const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: 'name',
},
columns: [
{ title: '序号', type: 'seq', width: 50 },
{ field: 'category', title: 'Category', width: 100 },
{
field: 'imageUrl',
slots: { default: 'image-url' },
title: 'Image',
width: 100,
},
{
cellRender: { name: 'CellImage' },
field: 'imageUrl2',
title: 'Render Image',
width: 130,
},
{
field: 'open',
slots: { default: 'open' },
title: 'Open',
width: 100,
},
{
field: 'status',
slots: { default: 'status' },
title: 'Status',
width: 100,
},
{ field: 'color', title: 'Color', width: 100 },
{ field: 'productName', title: 'Product Name', width: 200 },
{ field: 'price', title: 'Price', width: 100 },
{
field: 'releaseDate',
formatter: 'formatDateTime',
title: 'Date',
width: 200,
},
{
cellRender: { name: 'CellLink', props: { text: '编辑' } },
field: 'action',
fixed: 'right',
title: '操作',
width: 120,
},
],
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }) => {
return await getExampleTableApi({
page: page.currentPage,
pageSize: page.pageSize,
});
},
},
},
showOverflow: false,
};
const [Grid] = useVbenVxeGrid({ gridOptions });
</script>
<template>
<Page auto-content-height>
<Grid>
<template #image-url="{ row }">
<Image :src="row.imageUrl" height="30" width="30" />
</template>
<template #open="{ row }">
<Switch v-model="row.open" />
</template>
<template #status="{ row }">
<Tag :color="row.color">{{ row.status }}</Tag>
</template>
<template #action>
<Button type="link">编辑</Button>
</template>
</Grid>
</Page>
</template>

View File

@ -1,57 +0,0 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';
interface RowType {
category: string;
color: string;
id: string;
price: string;
productName: string;
releaseDate: string;
}
const gridOptions: VxeGridProps<RowType> = {
columns: [
{ title: '序号', type: 'seq', width: 50 },
{ editRender: { name: 'input' }, field: 'category', title: 'Category' },
{ editRender: { name: 'input' }, field: 'color', title: 'Color' },
{
editRender: { name: 'input' },
field: 'productName',
title: 'Product Name',
},
{ field: 'price', title: 'Price' },
{ field: 'releaseDate', formatter: 'formatDateTime', title: 'Date' },
],
editConfig: {
mode: 'cell',
trigger: 'click',
},
height: 'auto',
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }) => {
return await getExampleTableApi({
page: page.currentPage,
pageSize: page.pageSize,
});
},
},
},
showOverflow: true,
};
const [Grid] = useVbenVxeGrid({ gridOptions });
</script>
<template>
<Page auto-content-height>
<Grid />
</Page>
</template>

View File

@ -1,94 +0,0 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button, message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';
interface RowType {
category: string;
color: string;
id: string;
price: string;
productName: string;
releaseDate: string;
}
const gridOptions: VxeGridProps<RowType> = {
columns: [
{ title: '序号', type: 'seq', width: 50 },
{ editRender: { name: 'input' }, field: 'category', title: 'Category' },
{ editRender: { name: 'input' }, field: 'color', title: 'Color' },
{
editRender: { name: 'input' },
field: 'productName',
title: 'Product Name',
},
{ field: 'price', title: 'Price' },
{ field: 'releaseDate', formatter: 'formatDateTime', title: 'Date' },
{ slots: { default: 'action' }, title: '操作' },
],
editConfig: {
mode: 'row',
trigger: 'click',
},
height: 'auto',
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }) => {
return await getExampleTableApi({
page: page.currentPage,
pageSize: page.pageSize,
});
},
},
},
showOverflow: true,
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
function hasEditStatus(row: RowType) {
return gridApi.grid?.isEditByRow(row);
}
function editRowEvent(row: RowType) {
gridApi.grid?.setEditRow(row);
}
async function saveRowEvent(row: RowType) {
await gridApi.grid?.clearEdit();
gridApi.setLoading(true);
setTimeout(() => {
gridApi.setLoading(false);
message.success({
content: `保存成功category=${row.category}`,
});
}, 600);
}
const cancelRowEvent = (_row: RowType) => {
gridApi.grid?.clearEdit();
};
</script>
<template>
<Page auto-content-height>
<Grid>
<template #action="{ row }">
<template v-if="hasEditStatus(row)">
<Button type="link" @click="saveRowEvent(row)">保存</Button>
<Button type="link" @click="cancelRowEvent(row)">取消</Button>
</template>
<template v-else>
<Button type="link" @click="editRowEvent(row)">编辑</Button>
</template>
</template>
</Grid>
</Page>
</template>

View File

@ -1,69 +0,0 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';
interface RowType {
category: string;
color: string;
id: string;
price: string;
productName: string;
releaseDate: string;
}
const gridOptions: VxeGridProps<RowType> = {
columns: [
{ fixed: 'left', title: '序号', type: 'seq', width: 50 },
{ field: 'category', title: 'Category', width: 300 },
{ field: 'color', title: 'Color', width: 300 },
{ field: 'productName', title: 'Product Name', width: 300 },
{ field: 'price', title: 'Price', width: 300 },
{
field: 'releaseDate',
formatter: 'formatDateTime',
title: 'DateTime',
width: 500,
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 120,
},
],
height: 'auto',
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }) => {
return await getExampleTableApi({
page: page.currentPage,
pageSize: page.pageSize,
});
},
},
},
rowConfig: {
isHover: true,
},
};
const [Grid] = useVbenVxeGrid({ gridOptions });
</script>
<template>
<Page auto-content-height>
<Grid>
<template #action>
<Button type="link">编辑</Button>
</template>
</Grid>
</Page>
</template>

View File

@ -1,127 +0,0 @@
<script lang="ts" setup>
import type { VbenFormProps } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { message } from 'antdv-next';
import dayjs from 'dayjs';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';
interface RowType {
category: string;
color: string;
id: string;
price: string;
productName: string;
releaseDate: string;
}
const formOptions: VbenFormProps = {
//
collapsed: false,
fieldMappingTime: [['date', ['start', 'end']]],
schema: [
{
component: 'Input',
defaultValue: '1',
fieldName: 'category',
label: 'Category',
},
{
component: 'Input',
fieldName: 'productName',
label: 'ProductName',
},
{
component: 'Input',
fieldName: 'price',
label: 'Price',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{
label: 'Color1',
value: '1',
},
{
label: 'Color2',
value: '2',
},
],
placeholder: '请选择',
},
fieldName: 'color',
label: 'Color',
},
{
component: 'RangePicker',
defaultValue: [dayjs().subtract(7, 'days'), dayjs()],
fieldName: 'date',
label: 'Date',
},
],
//
showCollapseButton: true,
//
submitOnChange: true,
//
submitOnEnter: false,
};
const gridOptions: VxeTableGridOptions<RowType> = {
checkboxConfig: {
highlight: true,
labelField: 'name',
},
columns: [
{ title: '序号', type: 'seq', width: 50 },
{ align: 'left', title: 'Name', type: 'checkbox', width: 100 },
{ field: 'category', title: 'Category' },
{ field: 'color', title: 'Color' },
{ field: 'productName', title: 'Product Name' },
{ field: 'price', title: 'Price' },
{ field: 'releaseDate', formatter: 'formatDateTime', title: 'Date' },
],
exportConfig: {},
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
message.success(`Query params: ${JSON.stringify(formValues)}`);
return await getExampleTableApi({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
toolbarConfig: {
custom: true,
export: true,
refresh: true,
resizable: true,
search: true,
zoom: true,
},
};
const [Grid] = useVbenVxeGrid({
formOptions,
gridOptions,
});
</script>
<template>
<Page auto-content-height>
<Grid />
</Page>
</template>

View File

@ -1,81 +0,0 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getExampleTableApi } from '#/api';
interface RowType {
category: string;
color: string;
id: string;
price: string;
productName: string;
releaseDate: string;
}
const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: 'name',
},
columns: [
{ title: '序号', type: 'seq', width: 50 },
{ align: 'left', title: 'Name', type: 'checkbox', width: 100 },
{ field: 'category', sortable: true, title: 'Category' },
{ field: 'color', sortable: true, title: 'Color' },
{ field: 'productName', sortable: true, title: 'Product Name' },
{ field: 'price', sortable: true, title: 'Price' },
{ field: 'releaseDate', formatter: 'formatDateTime', title: 'DateTime' },
],
exportConfig: {},
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page, sort }) => {
return await getExampleTableApi({
page: page.currentPage,
pageSize: page.pageSize,
sortBy: sort.field,
sortOrder: sort.order,
});
},
},
sort: true,
},
sortConfig: {
defaultSort: { field: 'category', order: 'desc' },
remote: true,
},
toolbarConfig: {
custom: true,
export: true,
// import: true,
refresh: true,
zoom: true,
},
};
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions,
});
</script>
<template>
<Page auto-content-height>
<Grid table-title="数据列表" table-title-help="提示">
<template #toolbar-tools>
<Button class="mr-2" type="primary" @click="() => gridApi.query()">
刷新当前页面
</Button>
<Button type="primary" @click="() => gridApi.reload()">
刷新并返回第一页
</Button>
</template>
</Grid>
</Page>
</template>

View File

@ -1,172 +0,0 @@
interface TableRowData {
address: string;
age: number;
id: number;
name: string;
nickname: string;
role: string;
}
const roles = ['User', 'Admin', 'Manager', 'Guest'];
export const MOCK_TABLE_DATA: TableRowData[] = (() => {
const data: TableRowData[] = [];
for (let i = 0; i < 40; i++) {
data.push({
address: `New York${i}`,
age: i + 1,
id: i,
name: `Test${i}`,
nickname: `Test${i}`,
role: roles[Math.floor(Math.random() * roles.length)] as string,
});
}
return data;
})();
export const MOCK_TREE_TABLE_DATA = [
{
date: '2020-08-01',
id: 10_000,
name: 'Test1',
parentId: null,
size: 1024,
type: 'mp3',
},
{
date: '2021-04-01',
id: 10_050,
name: 'Test2',
parentId: null,
size: 0,
type: 'mp4',
},
{
date: '2020-03-01',
id: 24_300,
name: 'Test3',
parentId: 10_050,
size: 1024,
type: 'avi',
},
{
date: '2021-04-01',
id: 20_045,
name: 'Test4',
parentId: 24_300,
size: 600,
type: 'html',
},
{
date: '2021-04-01',
id: 10_053,
name: 'Test5',
parentId: 24_300,
size: 0,
type: 'avi',
},
{
date: '2021-10-01',
id: 24_330,
name: 'Test6',
parentId: 10_053,
size: 25,
type: 'txt',
},
{
date: '2020-01-01',
id: 21_011,
name: 'Test7',
parentId: 10_053,
size: 512,
type: 'pdf',
},
{
date: '2021-06-01',
id: 22_200,
name: 'Test8',
parentId: 10_053,
size: 1024,
type: 'js',
},
{
date: '2020-11-01',
id: 23_666,
name: 'Test9',
parentId: null,
size: 2048,
type: 'xlsx',
},
{
date: '2021-06-01',
id: 23_677,
name: 'Test10',
parentId: 23_666,
size: 1024,
type: 'js',
},
{
date: '2021-06-01',
id: 23_671,
name: 'Test11',
parentId: 23_677,
size: 1024,
type: 'js',
},
{
date: '2021-06-01',
id: 23_672,
name: 'Test12',
parentId: 23_677,
size: 1024,
type: 'js',
},
{
date: '2021-06-01',
id: 23_688,
name: 'Test13',
parentId: 23_666,
size: 1024,
type: 'js',
},
{
date: '2021-06-01',
id: 23_681,
name: 'Test14',
parentId: 23_688,
size: 1024,
type: 'js',
},
{
date: '2021-06-01',
id: 23_682,
name: 'Test15',
parentId: 23_688,
size: 1024,
type: 'js',
},
{
date: '2020-10-01',
id: 24_555,
name: 'Test16',
parentId: null,
size: 224,
type: 'avi',
},
{
date: '2021-06-01',
id: 24_566,
name: 'Test17',
parentId: 24_555,
size: 1024,
type: 'js',
},
{
date: '2021-06-01',
id: 24_577,
name: 'Test18',
parentId: 24_555,
size: 1024,
type: 'js',
},
];

View File

@ -1,62 +0,0 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { MOCK_TREE_TABLE_DATA } from './table-data';
interface RowType {
date: string;
id: number;
name: string;
parentId: null | number;
size: number;
type: string;
}
const gridOptions: VxeGridProps<RowType> = {
columns: [
{ type: 'seq', width: 70 },
{ field: 'name', minWidth: 300, title: 'Name', treeNode: true },
{ field: 'size', title: 'Size' },
{ field: 'type', title: 'Type' },
{ field: 'date', title: 'Date' },
],
data: MOCK_TREE_TABLE_DATA,
pagerConfig: {
enabled: false,
},
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true,
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
const expandAll = () => {
gridApi.grid?.setAllTreeExpand(true);
};
const collapseAll = () => {
gridApi.grid?.setAllTreeExpand(false);
};
</script>
<template>
<Page>
<Grid table-title="数据列表" table-title-help="提示">
<template #toolbar-tools>
<Button class="mr-2" type="primary" @click="expandAll">
展开全部
</Button>
<Button type="primary" @click="collapseAll"> 折叠全部 </Button>
</template>
</Grid>
</Page>
</template>

View File

@ -1,66 +0,0 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { onMounted } from 'vue';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
interface RowType {
id: number;
name: string;
role: string;
sex: string;
}
const gridOptions: VxeGridProps<RowType> = {
columns: [
{ type: 'seq', width: 70 },
{ field: 'name', title: 'Name' },
{ field: 'role', title: 'Role' },
{ field: 'sex', title: 'Sex' },
],
data: [],
height: 'auto',
pagerConfig: {
enabled: false,
},
scrollY: {
enabled: true,
gt: 0,
},
showOverflow: true,
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
//
const loadList = (size = 200) => {
try {
const dataList: RowType[] = [];
for (let i = 0; i < size; i++) {
dataList.push({
id: 10_000 + i,
name: `Test${i}`,
role: 'Developer',
sex: '男',
});
}
gridApi.setGridOptions({ data: dataList });
} catch (error) {
console.error('Failed to load data:', error);
// Implement user-friendly error handling
}
};
onMounted(() => {
loadList(1000);
});
</script>
<template>
<Page auto-content-height>
<Grid />
</Page>
</template>

View File

@ -9,24 +9,13 @@ import type {
import { computed, defineComponent, onBeforeUnmount, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import {
Alert,
Button,
Form,
FormItem,
Input,
message,
Modal,
Space,
Switch,
Tag,
Typography,
} from 'antdv-next';
import { Alert, Button, message, Space, Tag, Typography } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
cancelQqbotAccountScan,
createQqbotAccount,
@ -43,19 +32,13 @@ import { KtTable, useKtTable } from '#/components/ktTable';
const AKtTable = KtTable as any;
const AButton = Button as any;
const AInput = Input as any;
const AModal = Modal as any;
const ASwitch = Switch as any;
const ATypographyLink = Typography.Link as any;
export default defineComponent({
name: 'QqBotAccountList',
setup() {
const saving = ref(false);
const modalOpen = ref(false);
const editingId = ref<string>();
const scanLoading = ref(false);
const scanModalOpen = ref(false);
const scanQrcodeText = ref('');
const scanState = reactive<{
containerId?: string;
@ -77,13 +60,53 @@ export default defineComponent({
scale: 8,
});
let scanTimer: number | undefined;
const form = reactive<QqbotApi.AccountBody>({
accessToken: '',
connectionMode: 'reverse-ws',
enabled: true,
name: '',
remark: '',
selfId: '',
const [AccountForm, accountFormApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
layout: 'horizontal',
schema: [
{
component: 'Input',
componentProps: {
placeholder: 'NapCat 当前登录 QQ',
},
fieldName: 'selfId',
label: 'Self ID',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '便于后台识别',
},
fieldName: 'name',
label: '账号名称',
},
{
component: 'InputPassword',
componentProps: () => ({
placeholder: editingId.value
? '留空表示不修改'
: 'OneBot 反向 WS token',
}),
fieldName: 'accessToken',
label: 'Token',
},
{
component: 'Switch',
fieldName: 'enabled',
label: '启用',
},
{
component: 'Input',
fieldName: 'remark',
label: '备注',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const columns: Array<TableColumnType<QqbotApi.Account>> = [
@ -218,6 +241,33 @@ export default defineComponent({
scanState.mode === 'refresh' ? '更新账号登录' : '扫码新增账号',
);
const [ScanModal, scanModalApi] = useVbenModal({
class: 'w-[520px]',
fullscreenButton: false,
onBeforeClose() {
cleanupScanSession();
return true;
},
onCancel() {
closeScanModal();
},
});
const [AccountModal, accountModalApi] = useVbenModal({
class: 'w-[620px]',
fullscreenButton: false,
async onConfirm() {
await submitAccount();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = accountModalApi.getData<{
values?: QqbotApi.AccountBody;
}>();
void resetAccountForm(values || getAccountFormDefaults());
},
});
onBeforeUnmount(() => {
stopScanPolling();
});
@ -235,7 +285,7 @@ export default defineComponent({
row?: QqbotApi.Account,
) {
resetScanState(mode);
scanModalOpen.value = true;
scanModalApi.setState({ title: scanTitle.value }).open();
scanLoading.value = true;
try {
if (mode === 'create') {
@ -277,7 +327,7 @@ export default defineComponent({
message.success(
result.selfId ? `账号 ${result.selfId} 登录态已更新` : '账号已更新',
);
scanModalOpen.value = false;
await scanModalApi.close();
await tableApi.reload();
}
}
@ -335,15 +385,18 @@ export default defineComponent({
scanQrcodeText.value = '';
}
function closeScanModal() {
function cleanupScanSession() {
const sessionId = scanState.sessionId;
stopScanPolling();
scanModalOpen.value = false;
if (sessionId && scanState.status === 'pending') {
void cancelQqbotAccountScan(sessionId);
}
}
function closeScanModal() {
void scanModalApi.close();
}
function getScanAlertType() {
if (scanState.status === 'success') return 'success';
if (scanState.status === 'error') return 'error';
@ -371,55 +424,72 @@ export default defineComponent({
return '扫码登录请求失败';
}
function openCreate() {
editingId.value = undefined;
Object.assign(form, {
function getAccountFormDefaults(): QqbotApi.AccountBody {
return {
accessToken: '',
connectionMode: 'reverse-ws',
enabled: true,
name: '',
remark: '',
selfId: '',
});
modalOpen.value = true;
};
}
async function resetAccountForm(values: QqbotApi.AccountBody) {
await accountFormApi.resetForm();
await accountFormApi.setValues(values);
await accountFormApi.resetValidate();
}
function openCreate() {
editingId.value = undefined;
accountModalApi.setData({ values: getAccountFormDefaults() }).open();
}
function openEdit(row: QqbotApi.Account) {
editingId.value = row.id;
Object.assign(form, {
accessToken: '',
connectionMode: row.connectionMode,
enabled: row.enabled,
id: row.id,
name: row.name,
remark: row.remark || '',
selfId: row.selfId,
});
modalOpen.value = true;
accountModalApi
.setData({
values: {
accessToken: '',
connectionMode: row.connectionMode,
enabled: row.enabled,
id: row.id,
name: row.name,
remark: row.remark || '',
selfId: row.selfId,
},
})
.open();
}
async function submitAccount() {
if (!form.selfId.trim()) {
const { valid } = await accountFormApi.validate();
if (!valid) return;
const values = await accountFormApi.getValues<QqbotApi.AccountBody>();
const selfId = values.selfId?.trim();
if (!selfId) {
message.warning('请填写 Self ID');
return;
}
saving.value = true;
accountModalApi.lock();
try {
const payload = {
...form,
...values,
id: editingId.value,
selfId: form.selfId.trim(),
selfId,
};
if (!payload.accessToken) delete payload.accessToken;
await (editingId.value
? updateQqbotAccount(payload)
: createQqbotAccount(payload));
message.success('账号保存成功');
modalOpen.value = false;
await accountModalApi.close();
await tableApi.reload();
} finally {
saving.value = false;
accountModalApi.unlock();
}
}
@ -445,43 +515,32 @@ export default defineComponent({
},
}}
/>
<AModal
destroyOnClose
footer={[
<AButton key="close" onClick={closeScanModal}>
</AButton>,
<AButton
disabled={!scanState.sessionId}
key="refresh"
loading={scanLoading.value}
onClick={refreshScanQrcode}
>
</AButton>,
<AButton
disabled={!scanState.sessionId}
key="check"
loading={scanLoading.value}
onClick={pollScanStatus}
type="primary"
>
</AButton>,
]}
onCancel={closeScanModal}
{...{
'onUpdate:open': (value: boolean) => {
if (value) {
scanModalOpen.value = value;
return;
}
closeScanModal();
},
}}
open={scanModalOpen.value}
<ScanModal
title={scanTitle.value}
width="520px"
v-slots={{
footer: () => [
<AButton key="close" onClick={closeScanModal}>
</AButton>,
<AButton
disabled={!scanState.sessionId}
key="refresh"
loading={scanLoading.value}
onClick={refreshScanQrcode}
>
</AButton>,
<AButton
disabled={!scanState.sessionId}
key="check"
loading={scanLoading.value}
onClick={pollScanStatus}
type="primary"
>
</AButton>,
],
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
@ -535,77 +594,10 @@ export default defineComponent({
</ATypographyLink>
) : null}
</Space>
</AModal>
<AModal
confirmLoading={saving.value}
onOk={submitAccount}
{...{
'onUpdate:open': (value: boolean) => {
modalOpen.value = value;
},
}}
open={modalOpen.value}
title={modalTitle.value}
width="620px"
>
<Form labelCol={{ span: 5 }} model={form} wrapperCol={{ span: 18 }}>
<FormItem label="Self ID" required>
<AInput
{...{
'onUpdate:value': (value: string) => {
form.selfId = value;
},
}}
placeholder="NapCat 当前登录 QQ"
value={form.selfId}
/>
</FormItem>
<FormItem label="账号名称">
<AInput
{...{
'onUpdate:value': (value: string) => {
form.name = value;
},
}}
placeholder="便于后台识别"
value={form.name}
/>
</FormItem>
<FormItem label="Token">
<AInput.Password
{...{
'onUpdate:value': (value: string) => {
form.accessToken = value;
},
}}
placeholder={
editingId.value ? '留空表示不修改' : 'OneBot 反向 WS token'
}
value={form.accessToken}
/>
</FormItem>
<FormItem label="启用">
<ASwitch
checked={form.enabled}
{...{
'onUpdate:checked': (value: boolean) => {
form.enabled = value;
},
}}
/>
</FormItem>
<FormItem label="备注">
<AInput
{...{
'onUpdate:value': (value: string) => {
form.remark = value;
},
}}
value={form.remark}
/>
</FormItem>
</Form>
</AModal>
</ScanModal>
<AccountModal title={modalTitle.value}>
<AccountForm class="mx-2" />
</AccountModal>
</Page>
);
},

View File

@ -0,0 +1,612 @@
import type { TableColumnType } from 'antdv-next';
import type { QqbotApi } from '#/api/qqbot';
import type {
KtTableApi,
KtTableButton,
KtTableRowAction,
} from '#/components/ktTable';
import { computed, defineComponent, onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { message, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
createQqbotCommand,
deleteQqbotCommand,
getQqbotCommandList,
getQqbotPluginList,
getQqbotPluginOperationList,
testQqbotCommand,
toggleQqbotCommand,
updateQqbotCommand,
} from '#/api/qqbot';
import { KtTable, useKtTable } from '#/components/ktTable';
import {
getOptionLabel,
qqbotCommandParserOptions,
qqbotRuleTargetOptions,
} from '../modules/options';
const AKtTable = KtTable as any;
export default defineComponent({
name: 'QqBotCommandList',
setup() {
const editingId = ref<string>();
const pluginOptions = ref<Array<{ label: string; value: string }>>([]);
const pluginOperations = ref<QqbotApi.PluginOperation[]>([]);
const pluginMetadataLoaded = ref(false);
const selectedPluginKey = ref('');
const testResult = ref<QqbotApi.CommandTestResult>();
let pluginMetadataPromise: Promise<void> | undefined;
let isRestoringCommandForm = false;
const operationOptions = computed(() =>
pluginOperations.value
.filter((item) => item.pluginKey === selectedPluginKey.value)
.map((item) => ({
label: `${item.name} (${item.key})`,
value: item.key,
})),
);
const modalTitle = computed(() =>
editingId.value ? '编辑命令' : '新建命令',
);
const [CommandForm, commandFormApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
handleValuesChange(values, fieldsChanged) {
if (fieldsChanged.includes('pluginKey')) {
selectedPluginKey.value = values.pluginKey || '';
if (!isRestoringCommandForm) {
void commandFormApi.setFieldValue('operationKey', undefined);
}
}
},
layout: 'horizontal',
schema: [
{
component: 'Input',
componentProps: { placeholder: '如 ff14_price' },
fieldName: 'code',
label: '命令编码',
rules: 'required',
},
{
component: 'Input',
fieldName: 'name',
label: '命令名称',
rules: 'required',
},
{
component: 'Textarea',
componentProps: {
autoSize: { maxRows: 3, minRows: 2 },
placeholder: '逗号分隔,如 查价,price,ff14price',
},
fieldName: 'aliases',
label: '命令别名',
},
{
component: 'Input',
componentProps: {
placeholder: '逗号分隔,如 /,!,',
},
fieldName: 'prefixes',
label: '命令前缀',
},
{
component: 'Select',
componentProps: () => ({
options: pluginOptions.value,
}),
fieldName: 'pluginKey',
label: '插件',
rules: 'selectRequired',
},
{
component: 'Select',
componentProps: () => ({
options: operationOptions.value,
}),
fieldName: 'operationKey',
label: '插件能力',
rules: 'selectRequired',
},
{
component: 'Select',
componentProps: {
options: qqbotCommandParserOptions,
},
fieldName: 'parserKey',
label: '解析器',
},
{
component: 'Select',
componentProps: {
options: qqbotRuleTargetOptions,
},
fieldName: 'targetType',
label: '目标范围',
},
{
component: 'Textarea',
componentProps: {
autoSize: { maxRows: 8, minRows: 4 },
placeholder: '{\n "world": "中国",\n "language": "zh"\n}',
},
fieldName: 'defaultParams',
label: '默认参数',
},
{
component: 'Textarea',
componentProps: {
autoSize: { maxRows: 5, minRows: 3 },
placeholder:
'留空时使用插件返回的 replyText可用 {{output.xxx}} / {{input.xxx}}',
},
fieldName: 'replyTemplate',
label: '回复模板',
},
{
component: 'Textarea',
componentProps: {
autoSize: { maxRows: 4, minRows: 2 },
placeholder: '如 FF14 查价失败:{{error}}',
},
fieldName: 'errorTemplate',
label: '错误模板',
},
{
component: 'InputNumber',
fieldName: 'priority',
label: '优先级',
},
{
component: 'InputNumber',
componentProps: { min: 0 },
fieldName: 'cooldownMs',
label: '冷却时间',
suffix: () => 'ms',
},
{
component: 'Switch',
fieldName: 'enabled',
label: '启用',
},
{
component: 'Input',
fieldName: 'remark',
label: '备注',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const [TestForm, testFormApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
layout: 'horizontal',
schema: [
{
component: 'Input',
componentProps: {
placeholder: '如 /查价 魔匠药酒 莫古力 hq',
},
fieldName: 'text',
label: '测试消息',
rules: 'required',
},
{
component: 'Select',
componentProps: {
options: [
{ label: '私聊', value: 'private' },
{ label: '群聊', value: 'group' },
{ label: '频道', value: 'channel' },
],
},
fieldName: 'targetType',
label: '消息类型',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const columns: Array<TableColumnType<QqbotApi.Command>> = [
{ dataIndex: 'code', key: 'code', title: '命令编码', width: 150 },
{ dataIndex: 'name', key: 'name', title: '命令名称', width: 150 },
{ dataIndex: 'aliases', key: 'aliases', title: '别名', width: 220 },
{ dataIndex: 'pluginKey', key: 'pluginKey', title: '插件', width: 140 },
{
dataIndex: 'operationKey',
key: 'operationKey',
title: '能力',
width: 180,
},
{
dataIndex: 'parserKey',
key: 'parserKey',
title: '解析器',
width: 120,
},
{
dataIndex: 'targetType',
key: 'targetType',
title: '目标范围',
width: 120,
},
{ dataIndex: 'enabled', key: 'enabled', title: '状态', width: 100 },
{ dataIndex: 'priority', key: 'priority', title: '优先级', width: 100 },
{
dataIndex: 'lastHitAt',
key: 'lastHitAt',
title: '最后命中',
width: 190,
},
];
const api: KtTableApi<QqbotApi.Command> = {
list: async (params) => await getQqbotCommandList(params),
};
const buttons: Array<KtTableButton<QqbotApi.Command>> = [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: '新建命令',
onClick: openCreate,
permissionCodes: ['QqBot:Command:Create'],
type: 'primary',
},
];
const rowActions: Array<KtTableRowAction<QqbotApi.Command>> = [
{
key: 'toggle',
label: '启停',
onClick: async (row, context) => {
await toggleQqbotCommand(row.id, !row.enabled);
message.success(row.enabled ? '命令已停用' : '命令已启用');
await context.reload();
},
permissionCodes: ['QqBot:Command:Toggle'],
},
{
key: 'test',
label: '测试',
onClick: openTest,
permissionCodes: ['QqBot:Command:Test'],
},
{
key: 'edit',
label: '编辑',
onClick: openEdit,
permissionCodes: ['QqBot:Command:Edit'],
},
{
confirm: (row) => `确认删除命令「${row.name || row.code}」吗?`,
danger: true,
key: 'delete',
label: '删除',
onClick: async (row, context) => {
await deleteQqbotCommand(row.id);
message.success('命令删除成功');
await context.reload();
},
permissionCodes: ['QqBot:Command:Delete'],
},
];
const [registerTable, tableApi] = useKtTable<QqbotApi.Command>({
api,
buttons,
columns,
formOptions: {
schema: [
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '命令编码/名称/别名',
},
fieldName: 'keyword',
label: '关键词',
},
{
component: 'Select',
componentProps: () => ({
allowClear: true,
options: pluginOptions.value,
}),
fieldName: 'pluginKey',
label: '插件',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: qqbotRuleTargetOptions,
},
fieldName: 'targetType',
label: '目标范围',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: '启用', value: true },
{ label: '停用', value: false },
],
},
fieldName: 'enabled',
label: '状态',
},
],
},
rowActions,
tableTitle: '在线命令',
});
const [CommandModal, commandModalApi] = useVbenModal({
class: 'w-[820px]',
fullscreenButton: false,
async onConfirm() {
await submitCommand();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = commandModalApi.getData<{
values?: QqbotApi.CommandBody;
}>();
void resetCommandForm(values || getCommandFormDefaults());
},
});
const [TestModal, testModalApi] = useVbenModal({
class: 'w-[680px]',
fullscreenButton: false,
async onConfirm() {
await submitTest();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
testResult.value = undefined;
const { row } = testModalApi.getData<{ row?: QqbotApi.Command }>();
void resetTestForm(row);
},
});
onMounted(() => {
void ensurePluginMetadata();
});
async function loadPlugins() {
const [plugins, operations] = await Promise.all([
getQqbotPluginList(),
getQqbotPluginOperationList(),
]);
pluginOptions.value = plugins.map((item) => ({
label: `${item.name} (${item.key})`,
value: item.key,
}));
pluginOperations.value = operations;
pluginMetadataLoaded.value = true;
}
async function ensurePluginMetadata() {
if (pluginMetadataLoaded.value) {
return;
}
pluginMetadataPromise ||= loadPlugins().finally(() => {
pluginMetadataPromise = undefined;
});
await pluginMetadataPromise;
}
function getCommandFormDefaults(): QqbotApi.CommandBody {
return {
aliases: '',
code: '',
cooldownMs: 1500,
defaultParams: '{\n "language": "zh",\n "world": "中国"\n}',
enabled: true,
errorTemplate: '命令执行失败:{{error}}',
name: '',
operationKey: '',
parserKey: 'plain',
pluginKey: '',
prefixes: '/,!,',
priority: 0,
replyTemplate: '',
targetType: 'all',
};
}
async function resetCommandForm(values: QqbotApi.CommandBody) {
await ensurePluginMetadata();
isRestoringCommandForm = true;
selectedPluginKey.value = values.pluginKey || '';
try {
await commandFormApi.resetForm();
await commandFormApi.setValues({
...values,
aliases: normalizeListText(values.aliases),
defaultParams: normalizeJsonText(values.defaultParams),
prefixes: normalizeListText(values.prefixes),
});
await commandFormApi.resetValidate();
} finally {
isRestoringCommandForm = false;
}
}
async function resetTestForm(row?: QqbotApi.Command) {
await testFormApi.resetForm();
await testFormApi.setValues({
targetType: 'private',
text: row?.aliases?.[0] ? `/${row.aliases[0]} ` : '',
});
await testFormApi.resetValidate();
}
function openCreate() {
editingId.value = undefined;
commandModalApi.setData({ values: getCommandFormDefaults() }).open();
}
function openEdit(row: QqbotApi.Command) {
editingId.value = row.id;
commandModalApi.setData({ values: { ...row } }).open();
}
function openTest(row: QqbotApi.Command) {
testModalApi.setData({ row }).open();
}
async function submitCommand() {
const { valid } = await commandFormApi.validate();
if (!valid) return;
const values = await commandFormApi.getValues<QqbotApi.CommandBody>();
const payload = normalizeCommandPayload(values);
commandModalApi.lock();
try {
await (editingId.value
? updateQqbotCommand({ ...payload, id: editingId.value })
: createQqbotCommand(payload));
message.success('命令保存成功');
await commandModalApi.close();
await tableApi.reload();
} finally {
commandModalApi.unlock();
}
}
async function submitTest() {
const { valid } = await testFormApi.validate();
if (!valid) return;
const values = await testFormApi.getValues<{
targetType: 'channel' | 'group' | 'private';
text: string;
}>();
const { row } = testModalApi.getData<{ row?: QqbotApi.Command }>();
testModalApi.lock();
try {
testResult.value = await testQqbotCommand({
commandId: row?.id,
targetType: values.targetType || 'private',
text: values.text,
});
} finally {
testModalApi.unlock();
}
}
function normalizeCommandPayload(
values: QqbotApi.CommandBody,
): QqbotApi.CommandBody {
return {
...values,
aliases: normalizeList(values.aliases),
code: values.code.trim(),
cooldownMs: values.cooldownMs || 0,
defaultParams: parseJsonText(values.defaultParams),
name: values.name.trim(),
prefixes: normalizeList(values.prefixes),
priority: values.priority || 0,
};
}
function normalizeList(value?: string | string[]) {
if (Array.isArray(value)) return value;
return `${value || ''}`
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function normalizeListText(value?: string | string[]) {
return Array.isArray(value) ? value.join(',') : value || '';
}
function normalizeJsonText(value?: Record<string, any> | string) {
if (!value) return '';
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
function parseJsonText(value?: Record<string, any> | string) {
if (!value || typeof value !== 'string') return value || {};
const source = value.trim();
if (!source) return {};
try {
return JSON.parse(source);
} catch {
message.warning('默认参数必须是合法 JSON');
throw new Error('默认参数必须是合法 JSON');
}
}
return () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Command;
if (column.key === 'enabled') {
return (
<Tag color={row.enabled ? 'success' : 'default'}>
{row.enabled ? '启用' : '停用'}
</Tag>
);
}
if (column.key === 'aliases') {
return row.aliases?.join(' / ') || '-';
}
if (column.key === 'parserKey') {
return getOptionLabel(qqbotCommandParserOptions, row.parserKey);
}
if (column.key === 'targetType') {
return getOptionLabel(qqbotRuleTargetOptions, row.targetType);
}
return undefined;
},
}}
/>
<CommandModal title={modalTitle.value}>
<CommandForm class="mx-2" />
</CommandModal>
<TestModal title="测试命令">
<div class="mx-2">
<TestForm />
{testResult.value ? (
<div class="mt-4 rounded border border-border p-3 text-sm">
<div>
{testResult.value.matched ? '已匹配' : '未匹配'}
</div>
{testResult.value.replyText ? (
<pre class="mt-2 whitespace-pre-wrap">
{testResult.value.replyText}
</pre>
) : null}
{testResult.value.message ? (
<div class="mt-2 text-warning">
{testResult.value.message}
</div>
) : null}
</div>
) : null}
</div>
</TestModal>
</Page>
);
},
});

View File

@ -19,6 +19,11 @@ export const qqbotRuleMatchOptions = [
export const qqbotRuleTargetOptions = qqbotTargetTypeOptions;
export const qqbotCommandParserOptions = [
{ label: '普通文本', value: 'plain' },
{ label: 'FF14 查价', value: 'ff14Price' },
];
export const qqbotPermissionTargetOptions = [
{ label: 'QQ号', value: 'qq' },
{ label: '群聊', value: 'group' },

View File

@ -7,31 +7,14 @@ import type {
KtTableRowAction,
} from '#/components/ktTable';
import {
computed,
defineComponent,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { computed, defineComponent, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import {
Button,
Form,
FormItem,
Input,
message,
Modal,
Select,
Switch,
Tabs,
Tag,
} from 'antdv-next';
import { message, Switch, Tabs, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
createQqbotPermission,
deleteQqbotPermission,
@ -48,10 +31,6 @@ import {
} from '../modules/options';
const AKtTable = KtTable as any;
const AButton = Button as any;
const AInput = Input as any;
const AModal = Modal as any;
const ASelect = Select as any;
const ASwitch = Switch as any;
const ATabs = Tabs as any;
@ -68,21 +47,85 @@ export default defineComponent({
const activeKind = ref<PermissionKind>('allowlist');
const activeTargetType = ref<PermissionTargetType>('qq');
const configSaving = ref(false);
const saving = ref(false);
const modalOpen = ref(false);
const editingId = ref<string>();
const permissionConfig = reactive<QqbotApi.PermissionConfig>({
const permissionConfig = ref<QqbotApi.PermissionConfig>({
allowlistEnabled: false,
blocklistEnabled: true,
});
const form = reactive<QqbotApi.PermissionBody>({
enabled: true,
preciseUser: false,
remark: '',
selfId: '',
targetId: '',
targetType: 'qq',
userId: '',
const [PermissionForm, permissionFormApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
handleValuesChange(values, fieldsChanged) {
if (fieldsChanged.includes('preciseUser') && !values.preciseUser) {
void permissionFormApi.setFieldValue('userId', '');
}
},
layout: 'horizontal',
schema: [
{
component: 'Input',
componentProps: {
placeholder: '留空代表全部账号',
},
fieldName: 'selfId',
label: 'Self ID',
},
{
component: 'Select',
componentProps: {
disabled: true,
options: qqbotPermissionTargetOptions,
},
fieldName: 'targetType',
label: '目标类型',
},
{
component: 'Input',
componentProps: () => ({
placeholder: `请填写${targetIdLabel.value}`,
}),
fieldName: 'targetId',
label: () => targetIdLabel.value,
rules: 'required',
},
{
component: 'Switch',
dependencies: {
if: () => isPreciseAvailable(),
triggerFields: ['targetType'],
},
fieldName: 'preciseUser',
label: '精确 QQ',
},
{
component: 'Input',
componentProps: {
placeholder: '请填写需要精确匹配的 QQ 号',
},
dependencies: {
if(values) {
return isPreciseAvailable() && !!values.preciseUser;
},
triggerFields: ['preciseUser', 'targetType'],
},
fieldName: 'userId',
label: 'QQ 号',
rules: 'required',
},
{
component: 'Switch',
fieldName: 'enabled',
label: '启用',
},
{
component: 'Input',
fieldName: 'remark',
label: '备注',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const columns: Array<TableColumnType<QqbotApi.Permission>> = [
{ dataIndex: 'selfId', key: 'selfId', title: 'Self ID', width: 150 },
@ -110,16 +153,6 @@ export default defineComponent({
targetType: activeTargetType.value,
}),
};
const buttons: Array<KtTableButton<QqbotApi.Permission>> = [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: '新增名单',
onClick: openCreate,
permissionCodes: ['QqBot:Permission:Create'],
type: 'primary',
},
];
const rowActions: Array<KtTableRowAction<QqbotApi.Permission>> = [
{
key: 'edit',
@ -141,6 +174,16 @@ export default defineComponent({
permissionCodes: ['QqBot:Permission:Delete'],
},
];
const buttons: Array<KtTableButton<QqbotApi.Permission>> = [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: '新增名单',
onClick: openCreate,
permissionCodes: ['QqBot:Permission:Create'],
type: 'primary',
},
];
const [registerTable, tableApi] = useKtTable<QqbotApi.Permission>({
api,
buttons,
@ -168,9 +211,14 @@ export default defineComponent({
],
},
rowActions,
tableTitle: '权限名单',
});
const activeTargetLabel = computed(() => getPermissionTargetLabel());
const permissionModeChecked = computed({
get: () => permissionConfig.value.allowlistEnabled,
set: (checked: boolean) => {
void handlePermissionModeChange(checked);
},
});
const modalTitle = computed(
() =>
`${editingId.value ? '编辑' : '新增'}${activeTargetLabel.value}${activeKind.value === 'allowlist' ? '白名单' : '黑名单'}`,
@ -181,6 +229,21 @@ export default defineComponent({
return 'QQ 号';
});
const [PermissionModal, permissionModalApi] = useVbenModal({
class: 'w-[620px]',
fullscreenButton: false,
async onConfirm() {
await submitPermission();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = permissionModalApi.getData<{
values?: QqbotApi.PermissionBody;
}>();
void resetPermissionForm(values || getPermissionFormDefaults());
},
});
onMounted(() => {
void loadConfig();
});
@ -190,25 +253,12 @@ export default defineComponent({
});
async function loadConfig() {
Object.assign(permissionConfig, await getQqbotPermissionConfig());
const config = await getQqbotPermissionConfig();
permissionConfig.value = normalizePermissionConfig(config);
}
async function saveConfig() {
configSaving.value = true;
try {
Object.assign(
permissionConfig,
await updateQqbotPermissionConfig(permissionConfig),
);
message.success('权限配置保存成功');
} finally {
configSaving.value = false;
}
}
function openCreate() {
editingId.value = undefined;
Object.assign(form, {
function getPermissionFormDefaults(): QqbotApi.PermissionBody {
return {
enabled: true,
preciseUser: false,
remark: '',
@ -216,50 +266,85 @@ export default defineComponent({
targetId: '',
targetType: activeTargetType.value,
userId: '',
});
modalOpen.value = true;
};
}
async function resetPermissionForm(values: QqbotApi.PermissionBody) {
await permissionFormApi.resetForm();
await permissionFormApi.setValues(values);
await permissionFormApi.resetValidate();
}
function openCreate() {
editingId.value = undefined;
permissionModalApi
.setData({ values: getPermissionFormDefaults() })
.open();
}
function openEdit(row: QqbotApi.Permission) {
editingId.value = row.id;
activeTargetType.value = normalizePermissionTargetType(row.targetType);
Object.assign(form, {
...row,
preciseUser: !!row.preciseUser,
targetType: activeTargetType.value,
userId: row.userId || '',
});
modalOpen.value = true;
permissionModalApi
.setData({
values: {
...row,
preciseUser: !!row.preciseUser,
targetType: activeTargetType.value,
userId: row.userId || '',
},
})
.open();
}
async function submitPermission() {
form.targetType = activeTargetType.value;
if (!form.targetId.trim()) {
const { valid } = await permissionFormApi.validate();
if (!valid) return;
const values =
await permissionFormApi.getValues<QqbotApi.PermissionBody>();
const targetId = values.targetId?.trim();
if (!targetId) {
message.warning(`请填写${targetIdLabel.value}`);
return;
}
if (isPreciseAvailable() && form.preciseUser && !form.userId?.trim()) {
if (
isPreciseAvailable() &&
values.preciseUser &&
!values.userId?.trim()
) {
message.warning('开启精确到 QQ 号后必须填写 QQ 号');
return;
}
const payload: QqbotApi.PermissionBody = {
...values,
preciseUser: isPreciseAvailable() ? !!values.preciseUser : false,
targetId,
targetType: activeTargetType.value,
userId:
isPreciseAvailable() && values.preciseUser
? values.userId?.trim()
: '',
};
if (!isPreciseAvailable()) {
form.preciseUser = false;
form.userId = '';
payload.preciseUser = false;
payload.userId = '';
}
saving.value = true;
permissionModalApi.lock();
try {
await (editingId.value
? updateQqbotPermission(activeKind.value, {
...form,
...payload,
id: editingId.value,
})
: createQqbotPermission(activeKind.value, form));
: createQqbotPermission(activeKind.value, payload));
message.success('名单保存成功');
modalOpen.value = false;
await permissionModalApi.close();
await tableApi.reload();
} finally {
saving.value = false;
permissionModalApi.unlock();
}
}
@ -283,187 +368,128 @@ export default defineComponent({
return 'qq';
}
return () => (
<Page autoContentHeight>
<div style={{ display: 'grid', gap: '12px' }}>
<div
style={{
alignItems: 'center',
display: 'flex',
gap: '20px',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', gap: '20px' }}>
<span>
<ASwitch
checked={permissionConfig.allowlistEnabled}
{...{
'onUpdate:checked': (value: boolean) => {
permissionConfig.allowlistEnabled = value;
},
}}
/>
</span>
<span>
<ASwitch
checked={permissionConfig.blocklistEnabled}
{...{
'onUpdate:checked': (value: boolean) => {
permissionConfig.blocklistEnabled = value;
},
}}
/>
</span>
</div>
<AButton loading={configSaving.value} onClick={saveConfig}>
</AButton>
/**
*
*
* @param checked switch true false
*/
async function handlePermissionModeChange(checked: boolean) {
const nextKind: PermissionKind = checked ? 'allowlist' : 'blocklist';
const nextConfig = {
allowlistEnabled: nextKind === 'allowlist',
blocklistEnabled: nextKind === 'blocklist',
};
configSaving.value = true;
try {
Object.assign(
permissionConfig.value,
normalizePermissionConfig(
await updateQqbotPermissionConfig(nextConfig),
),
);
activeKind.value = nextKind;
message.success('权限配置已更新');
} finally {
configSaving.value = false;
}
}
/**
*
*
* @param config
*/
function normalizePermissionConfig(
config: QqbotApi.PermissionConfig,
): QqbotApi.PermissionConfig {
const allowlistEnabled = !!config.allowlistEnabled;
return {
allowlistEnabled,
blocklistEnabled: !allowlistEnabled,
};
}
/**
* KtTable
*/
function renderHeaderControls() {
return (
<>
<div class="kt-table__header-control-group">
<ATabs
class="kt-table__header-tabs"
items={[
{ key: 'allowlist', label: '白名单' },
{ key: 'blocklist', label: '黑名单' },
]}
v-model:activeKey={activeKind.value}
/>
</div>
<ATabs
activeKey={activeKind.value}
items={[
{ key: 'allowlist', label: '白名单' },
{ key: 'blocklist', label: '黑名单' },
]}
{...{
'onUpdate:activeKey': (value: PermissionKind) => {
activeKind.value = value;
},
}}
/>
<ATabs
activeKey={activeTargetType.value}
items={permissionTargetTabItems}
{...{
'onUpdate:activeKey': (value: PermissionTargetType) => {
activeTargetType.value = value;
},
}}
/>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Permission;
if (column.key === 'enabled') {
return (
<Tag color={row.enabled ? 'success' : 'default'}>
{row.enabled ? '启用' : '停用'}
</Tag>
);
}
if (column.key === 'targetType') {
return getPermissionTargetLabel(row.targetType);
}
if (column.key === 'preciseUser') {
if (row.targetType === 'qq' || row.targetType === 'private') {
return '-';
}
return row.preciseUser ? '是' : '否';
}
if (column.key === 'userId') {
return row.preciseUser ? row.userId || '-' : '-';
}
return undefined;
},
}}
<div class="kt-table__header-control-group kt-table__header-control-group--grow">
<ATabs
class="kt-table__header-tabs"
items={permissionTargetTabItems}
v-model:activeKey={activeTargetType.value}
/>
</div>
</>
);
}
/**
* KtTable
*/
function renderPermissionModeToolbar() {
return (
<div class="kt-table__header-control-group">
<span class="kt-table__header-control-muted"></span>
<ASwitch
checkedChildren="白名单"
loading={configSaving.value}
unCheckedChildren="黑名单"
v-model:checked={permissionModeChecked.value}
/>
</div>
<AModal
confirmLoading={saving.value}
onOk={submitPermission}
{...{
'onUpdate:open': (value: boolean) => {
modalOpen.value = value;
);
}
return () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Permission;
if (column.key === 'enabled') {
return (
<Tag color={row.enabled ? 'success' : 'default'}>
{row.enabled ? '启用' : '停用'}
</Tag>
);
}
if (column.key === 'targetType') {
return getPermissionTargetLabel(row.targetType);
}
if (column.key === 'preciseUser') {
if (row.targetType === 'qq' || row.targetType === 'private') {
return '-';
}
return row.preciseUser ? '是' : '否';
}
if (column.key === 'userId') {
return row.preciseUser ? row.userId || '-' : '-';
}
return undefined;
},
headerControls: renderHeaderControls,
toolbar: renderPermissionModeToolbar,
}}
open={modalOpen.value}
title={modalTitle.value}
width="620px"
>
<Form labelCol={{ span: 5 }} model={form} wrapperCol={{ span: 18 }}>
<FormItem label="Self ID">
<AInput
{...{
'onUpdate:value': (value: string) => {
form.selfId = value;
},
}}
placeholder="留空代表全部账号"
value={form.selfId}
/>
</FormItem>
<FormItem label="目标类型">
<ASelect
disabled
options={qqbotPermissionTargetOptions}
value={activeTargetType.value}
/>
</FormItem>
<FormItem label={targetIdLabel.value}>
<AInput
{...{
'onUpdate:value': (value: string) => {
form.targetId = value;
},
}}
placeholder={`请填写${targetIdLabel.value}`}
value={form.targetId}
/>
</FormItem>
{isPreciseAvailable() && (
<>
<FormItem label="精确 QQ">
<ASwitch
checked={form.preciseUser}
{...{
'onUpdate:checked': (value: boolean) => {
form.preciseUser = value;
if (!value) form.userId = '';
},
}}
/>
</FormItem>
{form.preciseUser && (
<FormItem label="QQ 号">
<AInput
{...{
'onUpdate:value': (value: string) => {
form.userId = value;
},
}}
placeholder="请填写需要精确匹配的 QQ 号"
value={form.userId}
/>
</FormItem>
)}
</>
)}
<FormItem label="启用">
<ASwitch
checked={form.enabled}
{...{
'onUpdate:checked': (value: boolean) => {
form.enabled = value;
},
}}
/>
</FormItem>
<FormItem label="备注">
<AInput
{...{
'onUpdate:value': (value: string) => {
form.remark = value;
},
}}
value={form.remark}
/>
</FormItem>
</Form>
</AModal>
/>
<PermissionModal title={modalTitle.value}>
<PermissionForm class="mx-2" />
</PermissionModal>
</Page>
);
},

View File

@ -0,0 +1,126 @@
import type { TableColumnType } from 'antdv-next';
import type { QqbotApi } from '#/api/qqbot';
import type { KtTableApi, KtTableButton } from '#/components/ktTable';
import { defineComponent, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { message, Tag } from 'antdv-next';
import {
getQqbotPluginHealth,
getQqbotPluginList,
getQqbotPluginOperationList,
} from '#/api/qqbot';
import { KtTable, useKtTable } from '#/components/ktTable';
const AKtTable = KtTable as any;
export default defineComponent({
name: 'QqBotPluginList',
setup() {
const pluginOptions = ref<Array<{ label: string; value: string }>>([]);
const pluginMap = ref<Record<string, QqbotApi.Plugin>>({});
const columns: Array<TableColumnType<QqbotApi.PluginOperation>> = [
{ dataIndex: 'pluginKey', key: 'pluginKey', title: '插件', width: 160 },
{ dataIndex: 'key', key: 'key', title: '能力 Key', width: 220 },
{ dataIndex: 'name', key: 'name', title: '能力名称', width: 160 },
{
dataIndex: 'description',
key: 'description',
title: '说明',
width: 360,
},
{
dataIndex: 'cacheTtlMs',
key: 'cacheTtlMs',
title: '建议缓存',
width: 120,
},
];
const api: KtTableApi<QqbotApi.PluginOperation> = {
list: async (params) =>
await getQqbotPluginOperationList(params.pluginKey),
};
const buttons: Array<KtTableButton<QqbotApi.PluginOperation>> = [
{
key: 'health',
label: '健康检查',
onClick: async () => {
const health = await getQqbotPluginHealth();
const content = health
.map((item) => `${item.status}: ${item.message || 'OK'}`)
.join('');
message.success(content || '插件健康检查完成');
},
},
];
const [registerTable] = useKtTable<QqbotApi.PluginOperation>({
api,
buttons,
columns,
formOptions: {
schema: [
{
component: 'Select',
componentProps: () => ({
allowClear: true,
options: pluginOptions.value,
}),
fieldName: 'pluginKey',
label: '插件',
},
],
},
showSelection: false,
tableTitle: '插件能力',
});
onMounted(() => {
void loadPlugins();
});
async function loadPlugins() {
const plugins = await getQqbotPluginList();
const nextPluginMap: Record<string, QqbotApi.Plugin> = {};
for (const item of plugins) {
nextPluginMap[item.key] = item;
}
pluginMap.value = nextPluginMap;
pluginOptions.value = plugins.map((item) => ({
label: `${item.name} (${item.key})`,
value: item.key,
}));
}
return () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.PluginOperation;
if (column.key === 'pluginKey') {
const plugin = pluginMap.value[row.pluginKey];
return plugin ? (
<Tag color="processing">
{plugin.name} v{plugin.version}
</Tag>
) : (
row.pluginKey
);
}
if (column.key === 'cacheTtlMs') {
return row.cacheTtlMs ? `${row.cacheTtlMs} ms` : '-';
}
return undefined;
},
}}
/>
</Page>
);
},
});

View File

@ -7,24 +7,14 @@ import type {
KtTableRowAction,
} from '#/components/ktTable';
import { computed, defineComponent, reactive, ref } from 'vue';
import { computed, defineComponent, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import {
Form,
FormItem,
Input,
InputNumber,
message,
Modal,
Select,
Switch,
Tag,
TextArea,
} from 'antdv-next';
import { message, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
createQqbotRule,
deleteQqbotRule,
@ -41,28 +31,76 @@ import {
} from '../modules/options';
const AKtTable = KtTable as any;
const AInput = Input as any;
const AInputNumber = InputNumber as any;
const AModal = Modal as any;
const ASelect = Select as any;
const ASwitch = Switch as any;
const ATextArea = TextArea as any;
export default defineComponent({
name: 'QqBotRuleList',
setup() {
const saving = ref(false);
const modalOpen = ref(false);
const editingId = ref<string>();
const form = reactive<QqbotApi.RuleBody>({
cooldownMs: 1500,
enabled: true,
keyword: '',
matchType: 'keyword',
name: '',
priority: 0,
replyContent: '',
targetType: 'all',
const [RuleForm, ruleFormApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
layout: 'horizontal',
schema: [
{
component: 'Input',
fieldName: 'name',
label: '规则名称',
},
{
component: 'Select',
componentProps: {
options: qqbotRuleMatchOptions,
},
fieldName: 'matchType',
label: '匹配方式',
rules: 'selectRequired',
},
{
component: 'Input',
fieldName: 'keyword',
label: '关键词',
rules: 'required',
},
{
component: 'Select',
componentProps: {
options: qqbotRuleTargetOptions,
},
fieldName: 'targetType',
label: '目标范围',
},
{
component: 'Textarea',
componentProps: {
autoSize: { maxRows: 6, minRows: 3 },
},
fieldName: 'replyContent',
label: '回复内容',
rules: 'required',
},
{
component: 'InputNumber',
fieldName: 'priority',
label: '优先级',
},
{
component: 'InputNumber',
componentProps: {
min: 0,
},
fieldName: 'cooldownMs',
label: '冷却时间',
suffix: () => 'ms',
},
{
component: 'Switch',
fieldName: 'enabled',
label: '启用',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const columns: Array<TableColumnType<QqbotApi.Rule>> = [
@ -177,9 +215,23 @@ export default defineComponent({
editingId.value ? '编辑规则' : '新建规则',
);
function openCreate() {
editingId.value = undefined;
Object.assign(form, {
const [RuleModal, ruleModalApi] = useVbenModal({
class: 'w-[720px]',
fullscreenButton: false,
async onConfirm() {
await submitRule();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = ruleModalApi.getData<{
values?: QqbotApi.RuleBody;
}>();
void resetRuleForm(values || getRuleFormDefaults());
},
});
function getRuleFormDefaults(): QqbotApi.RuleBody {
return {
cooldownMs: 1500,
enabled: true,
keyword: '',
@ -188,32 +240,54 @@ export default defineComponent({
priority: 0,
replyContent: '',
targetType: 'all',
});
modalOpen.value = true;
};
}
async function resetRuleForm(values: QqbotApi.RuleBody) {
await ruleFormApi.resetForm();
await ruleFormApi.setValues(values);
await ruleFormApi.resetValidate();
}
function openCreate() {
editingId.value = undefined;
ruleModalApi.setData({ values: getRuleFormDefaults() }).open();
}
function openEdit(row: QqbotApi.Rule) {
editingId.value = row.id;
Object.assign(form, { ...row });
modalOpen.value = true;
ruleModalApi.setData({ values: { ...row } }).open();
}
async function submitRule() {
if (!form.keyword.trim() || !form.replyContent.trim()) {
const { valid } = await ruleFormApi.validate();
if (!valid) return;
const values = await ruleFormApi.getValues<QqbotApi.RuleBody>();
const keyword = values.keyword?.trim();
const replyContent = values.replyContent?.trim();
if (!keyword || !replyContent) {
message.warning('请填写关键词和回复内容');
return;
}
saving.value = true;
ruleModalApi.lock();
try {
const payload: QqbotApi.RuleBody = {
...values,
cooldownMs: values.cooldownMs || 0,
keyword,
priority: values.priority || 0,
replyContent,
};
await (editingId.value
? updateQqbotRule({ ...form, id: editingId.value })
: createQqbotRule(form));
? updateQqbotRule({ ...payload, id: editingId.value })
: createQqbotRule(payload));
message.success('规则保存成功');
modalOpen.value = false;
await ruleModalApi.close();
await tableApi.reload();
} finally {
saving.value = false;
ruleModalApi.unlock();
}
}
@ -241,108 +315,9 @@ export default defineComponent({
},
}}
/>
<AModal
confirmLoading={saving.value}
onOk={submitRule}
{...{
'onUpdate:open': (value: boolean) => {
modalOpen.value = value;
},
}}
open={modalOpen.value}
title={modalTitle.value}
width="720px"
>
<Form labelCol={{ span: 5 }} model={form} wrapperCol={{ span: 18 }}>
<FormItem label="规则名称">
<AInput
{...{
'onUpdate:value': (value: string) => {
form.name = value;
},
}}
value={form.name}
/>
</FormItem>
<FormItem label="匹配方式" required>
<ASelect
{...{
'onUpdate:value': (value: QqbotApi.RuleBody['matchType']) => {
form.matchType = value;
},
}}
options={qqbotRuleMatchOptions}
value={form.matchType}
/>
</FormItem>
<FormItem label="关键词" required>
<AInput
{...{
'onUpdate:value': (value: string) => {
form.keyword = value;
},
}}
value={form.keyword}
/>
</FormItem>
<FormItem label="目标范围">
<ASelect
{...{
'onUpdate:value': (
value: QqbotApi.RuleBody['targetType'],
) => {
form.targetType = value;
},
}}
options={qqbotRuleTargetOptions}
value={form.targetType}
/>
</FormItem>
<FormItem label="回复内容" required>
<ATextArea
autoSize={{ maxRows: 6, minRows: 3 }}
{...{
'onUpdate:value': (value: string) => {
form.replyContent = value;
},
}}
value={form.replyContent}
/>
</FormItem>
<FormItem label="优先级">
<AInputNumber
{...{
'onUpdate:value': (value: number) => {
form.priority = value || 0;
},
}}
value={form.priority}
/>
</FormItem>
<FormItem label="冷却时间">
<AInputNumber
addonAfter="ms"
min={0}
{...{
'onUpdate:value': (value: number) => {
form.cooldownMs = value || 0;
},
}}
value={form.cooldownMs}
/>
</FormItem>
<FormItem label="启用">
<ASwitch
checked={form.enabled}
{...{
'onUpdate:checked': (value: boolean) => {
form.enabled = value;
},
}}
/>
</FormItem>
</Form>
</AModal>
<RuleModal title={modalTitle.value}>
<RuleForm class="mx-2" />
</RuleModal>
</Page>
);
},

View File

@ -3,12 +3,13 @@ import type { TableColumnType } from 'antdv-next';
import type { QqbotApi } from '#/api/qqbot';
import type { KtTableApi, KtTableButton } from '#/components/ktTable';
import { computed, defineComponent, reactive, ref } from 'vue';
import { computed, defineComponent, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { Form, FormItem, Input, message, Modal, Select, Tag } from 'antdv-next';
import { message, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
getQqbotSendLogList,
sendQqbotGroup,
@ -24,20 +25,57 @@ import {
} from '../modules/options';
const AKtTable = KtTable as any;
const AInput = Input as any;
const AModal = Modal as any;
const ASelect = Select as any;
export default defineComponent({
name: 'QqBotSendLogList',
setup() {
const saving = ref(false);
const modalOpen = ref(false);
const sendForm = reactive({
message: '',
selfId: '',
targetId: '',
targetType: 'private' as 'group' | 'private',
const sendTargetType = ref<'group' | 'private'>('private');
const [SendForm, sendFormApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
handleValuesChange(values, fieldsChanged) {
if (fieldsChanged.includes('targetType')) {
sendTargetType.value =
values.targetType === 'group' ? 'group' : 'private';
}
},
layout: 'horizontal',
schema: [
{
component: 'Input',
componentProps: {
placeholder: '留空使用默认启用账号',
},
fieldName: 'selfId',
label: 'Self ID',
},
{
component: 'Select',
componentProps: {
options: qqbotMessageTypeOptions,
},
fieldName: 'targetType',
label: '目标类型',
},
{
component: 'Input',
fieldName: 'targetId',
label: () => targetLabel.value,
rules: 'required',
},
{
component: 'Textarea',
componentProps: {
autoSize: { maxRows: 6, minRows: 3 },
},
fieldName: 'message',
label: '消息内容',
rules: 'required',
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const columns: Array<TableColumnType<QqbotApi.SendLog>> = [
{ dataIndex: 'selfId', key: 'selfId', title: 'Self ID', width: 150 },
@ -122,43 +160,73 @@ export default defineComponent({
tableTitle: '发送日志',
});
const targetLabel = computed(() =>
sendForm.targetType === 'group' ? '群号' : 'QQ 号',
sendTargetType.value === 'group' ? '群号' : 'QQ 号',
);
function openSend() {
Object.assign(sendForm, {
const [SendModal, sendModalApi] = useVbenModal({
class: 'w-[620px]',
fullscreenButton: false,
async onConfirm() {
await submitSend();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
void resetSendForm();
},
});
async function resetSendForm() {
const values = {
message: '',
selfId: '',
targetId: '',
targetType: 'private',
});
modalOpen.value = true;
};
sendTargetType.value = values.targetType as 'private';
await sendFormApi.resetForm();
await sendFormApi.setValues(values);
await sendFormApi.resetValidate();
}
function openSend() {
sendModalApi.open();
}
async function submitSend() {
if (!sendForm.targetId.trim() || !sendForm.message.trim()) {
const { valid } = await sendFormApi.validate();
if (!valid) return;
const values = await sendFormApi.getValues<{
message: string;
selfId: string;
targetId: string;
targetType: 'group' | 'private';
}>();
const targetId = values.targetId?.trim();
const messageText = values.message?.trim();
if (!targetId || !messageText) {
message.warning('请填写目标和消息内容');
return;
}
saving.value = true;
sendModalApi.lock();
try {
await (sendForm.targetType === 'group'
await (values.targetType === 'group'
? sendQqbotGroup({
groupId: sendForm.targetId,
message: sendForm.message,
selfId: sendForm.selfId || undefined,
groupId: targetId,
message: messageText,
selfId: values.selfId || undefined,
})
: sendQqbotPrivate({
message: sendForm.message,
selfId: sendForm.selfId || undefined,
userId: sendForm.targetId,
message: messageText,
selfId: values.selfId || undefined,
userId: targetId,
}));
message.success('消息已发送');
modalOpen.value = false;
await sendModalApi.close();
await tableApi.reload();
} finally {
saving.value = false;
sendModalApi.unlock();
}
}
@ -180,68 +248,9 @@ export default defineComponent({
},
}}
/>
<AModal
confirmLoading={saving.value}
onOk={submitSend}
{...{
'onUpdate:open': (value: boolean) => {
modalOpen.value = value;
},
}}
open={modalOpen.value}
title="手动发送"
width="620px"
>
<Form
labelCol={{ span: 5 }}
model={sendForm}
wrapperCol={{ span: 18 }}
>
<FormItem label="Self ID">
<AInput
{...{
'onUpdate:value': (value: string) => {
sendForm.selfId = value;
},
}}
placeholder="留空使用默认启用账号"
value={sendForm.selfId}
/>
</FormItem>
<FormItem label="目标类型">
<ASelect
{...{
'onUpdate:value': (value: 'group' | 'private') => {
sendForm.targetType = value;
},
}}
options={qqbotMessageTypeOptions}
value={sendForm.targetType}
/>
</FormItem>
<FormItem label={targetLabel.value} required>
<AInput
{...{
'onUpdate:value': (value: string) => {
sendForm.targetId = value;
},
}}
value={sendForm.targetId}
/>
</FormItem>
<FormItem label="消息内容" required>
<AInput.TextArea
autoSize={{ maxRows: 6, minRows: 3 }}
{...{
'onUpdate:value': (value: string) => {
sendForm.message = value;
},
}}
value={sendForm.message}
/>
</FormItem>
</Form>
</AModal>
<SendModal title="手动发送">
<SendForm class="mx-2" />
</SendModal>
</Page>
);
},

View File

@ -1,17 +1,9 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn } from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept';
import { z } from '#/adapter/form';
import { getDeptList } from '#/api/system/dept';
import { $t } from '#/locales';
type PermissionOptions = {
canAccess?: (code: string) => boolean;
};
/**
* 使export一个数组常量
*/
@ -72,76 +64,3 @@ export function useSchema(): VbenFormSchema[] {
},
];
}
/**
*
* @description 使export一个Array常量
* @param onActionClick
*/
export function useColumns(
onActionClick?: OnActionClickFn<SystemDeptApi.SystemDept>,
options: PermissionOptions = {},
): VxeTableGridOptions<SystemDeptApi.SystemDept>['columns'] {
const canAccess = options.canAccess || (() => true);
return [
{
align: 'left',
field: 'name',
fixed: 'left',
title: $t('system.dept.deptName'),
treeNode: true,
width: 150,
},
{
cellRender: { name: 'CellTag' },
field: 'status',
title: $t('system.dept.status'),
width: 100,
},
{
field: 'createTime',
title: $t('system.dept.createTime'),
width: 180,
},
{
field: 'remark',
title: $t('system.dept.remark'),
},
{
align: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: $t('system.dept.name'),
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'append',
show: () => canAccess('System:Dept:Create'),
text: '新增下级',
},
{
code: 'edit',
show: () => canAccess('System:Dept:Edit'),
},
{
code: 'delete', // 默认的删除按钮
disabled: (row: SystemDeptApi.SystemDept) => {
return !!(row.children && row.children.length > 0);
},
show: () => canAccess('System:Dept:Delete'),
},
],
},
field: 'operation',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
title: $t('system.dept.operation'),
width: 200,
},
];
}

View File

@ -1,12 +1,5 @@
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMenuApi } from '#/api/system/menu';
import { $t } from '#/locales';
type PermissionOptions = {
canAccess?: (code: string) => boolean;
};
export function getMenuTypeOptions() {
return [
{
@ -24,100 +17,3 @@ export function getMenuTypeOptions() {
{ color: 'warning', label: $t('system.menu.typeLink'), value: 'link' },
];
}
export function useColumns(
onActionClick: OnActionClickFn<SystemMenuApi.SystemMenu>,
options: PermissionOptions = {},
): VxeTableGridOptions<SystemMenuApi.SystemMenu>['columns'] {
const canAccess = options.canAccess || (() => true);
return [
{
align: 'left',
field: 'meta.title',
fixed: 'left',
slots: { default: 'title' },
title: $t('system.menu.menuTitle'),
treeNode: true,
width: 250,
},
{
align: 'center',
cellRender: { name: 'CellTag', options: getMenuTypeOptions() },
field: 'type',
title: $t('system.menu.type'),
width: 100,
},
{
field: 'authCode',
title: $t('system.menu.authCode'),
width: 200,
},
{
align: 'left',
field: 'path',
title: $t('system.menu.path'),
width: 200,
},
{
align: 'left',
field: 'component',
formatter: ({ row }) => {
switch (row.type) {
case 'catalog':
case 'menu': {
return row.component ?? '';
}
case 'embedded': {
return row.meta?.iframeSrc ?? '';
}
case 'link': {
return row.meta?.link ?? '';
}
}
return '';
},
minWidth: 200,
title: $t('system.menu.component'),
},
{
cellRender: { name: 'CellTag' },
field: 'status',
title: $t('system.menu.status'),
width: 100,
},
{
align: 'right',
cellRender: {
attrs: {
nameField: 'name',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'append',
show: () => canAccess('System:Menu:Create'),
text: '新增下级',
},
{
code: 'edit',
show: () => canAccess('System:Menu:Edit'),
},
{
code: 'delete',
show: () => canAccess('System:Menu:Delete'),
},
],
},
field: 'operation',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
title: $t('system.menu.operation'),
width: 200,
},
];
}

View File

@ -1,13 +1,7 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemRoleApi } from '#/api';
import { $t } from '#/locales';
type PermissionOptions = {
canAccess?: (code: string) => boolean;
};
export function useFormSchema(): VbenFormSchema[] {
return [
{
@ -77,68 +71,3 @@ export function useGridFormSchema(): VbenFormSchema[] {
},
];
}
export function useColumns<T = SystemRoleApi.SystemRole>(
onActionClick: OnActionClickFn<T>,
onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
options: PermissionOptions = {},
): VxeTableGridOptions['columns'] {
const canAccess = options.canAccess || (() => true);
return [
{
field: 'name',
title: $t('system.role.roleName'),
width: 200,
},
{
field: 'id',
title: $t('system.role.id'),
width: 200,
},
{
cellRender: {
attrs: { beforeChange: onStatusChange },
name: onStatusChange ? 'CellSwitch' : 'CellTag',
},
field: 'status',
title: $t('system.role.status'),
width: 100,
},
{
field: 'remark',
minWidth: 100,
title: $t('system.role.remark'),
},
{
field: 'createTime',
title: $t('system.role.createTime'),
width: 200,
},
{
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: $t('system.role.name'),
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: () => canAccess('System:Role:Edit'),
},
{
code: 'delete',
show: () => canAccess('System:Role:Delete'),
},
],
},
field: 'operation',
fixed: 'right',
title: $t('system.role.operation'),
width: 130,
},
];
}

View File

@ -53,7 +53,6 @@
"vite": "catalog:",
"vite-plugin-compression": "catalog:",
"vite-plugin-dts": "catalog:",
"vite-plugin-html": "catalog:",
"vite-plugin-lazy-import": "catalog:"
"vite-plugin-html": "catalog:"
}
}

View File

@ -48,7 +48,6 @@ function defineApplicationConfig(userConfigPromise?: DefineApplicationOptions) {
},
pwa: true,
pwaOptions: getDefaultPwaOptions(appTitle),
vxeTableLazyImport: true,
...envConfig,
...application,
});

View File

@ -25,7 +25,6 @@ import { viteMetadataPlugin } from './inject-metadata';
import { viteLicensePlugin } from './license';
import { viteNitroMockPlugin } from './nitro-mock';
import { vitePrintPlugin } from './print';
import { viteVxeTableImportsPlugin } from './vxe-table';
/**
* vite
@ -112,7 +111,6 @@ async function loadApplicationPlugins(
printInfoMap,
pwa,
pwaOptions,
vxeTableLazyImport,
...commonOptions
} = options;
@ -138,12 +136,6 @@ async function loadApplicationPlugins(
return [await vitePrintPlugin({ infoMap: printInfoMap })];
},
},
{
condition: vxeTableLazyImport,
plugins: async () => {
return [await viteVxeTableImportsPlugin()];
},
},
{
condition: nitroMock,
plugins: async () => {
@ -259,5 +251,4 @@ export {
viteDtsPlugin,
viteHtmlPlugin,
viteVisualizerPlugin,
viteVxeTableImportsPlugin,
};

View File

@ -1,20 +0,0 @@
import type { PluginOption } from 'vite';
import { lazyImport, VxeResolver } from 'vite-plugin-lazy-import';
async function viteVxeTableImportsPlugin(): Promise<PluginOption> {
return [
lazyImport({
resolvers: [
VxeResolver({
libraryName: 'vxe-table',
}),
VxeResolver({
libraryName: 'vxe-pc-ui',
}),
],
}),
] as unknown as PluginOption;
}
export { viteVxeTableImportsPlugin };

View File

@ -278,11 +278,6 @@ interface ApplicationPluginOptions extends CommonPluginOptions {
* PWA
*/
pwaOptions?: Partial<PwaPluginOptions>;
/**
* VXE Table
* @default false
*/
vxeTableLazyImport?: boolean;
}
/**

View File

@ -18,10 +18,6 @@
"types": "./src/echarts/index.ts",
"default": "./src/echarts/index.ts"
},
"./vxe-table": {
"types": "./src/vxe-table/index.ts",
"default": "./src/vxe-table/index.ts"
},
"./motion": {
"types": "./src/motion/index.ts",
"default": "./src/motion/index.ts"
@ -40,8 +36,6 @@
"@vueuse/core": "catalog:",
"@vueuse/motion": "catalog:",
"echarts": "catalog:",
"vue": "catalog:",
"vxe-pc-ui": "catalog:",
"vxe-table": "catalog:"
"vue": "catalog:"
}
}

View File

@ -1,128 +0,0 @@
import type { VxeGridInstance } from 'vxe-table';
import type { ExtendedFormApi } from '@vben-core/form-ui';
import type { VxeGridProps } from './types';
import { toRaw } from 'vue';
import { Store } from '@vben-core/shared/store';
import {
bindMethods,
isBoolean,
isFunction,
mergeWithArrayOverride,
StateHandler,
} from '@vben-core/shared/utils';
function getDefaultState(): VxeGridProps {
return {
class: '',
gridClass: '',
gridOptions: {},
gridEvents: {},
formOptions: undefined,
showSearchForm: true,
};
}
export class VxeGridApi<T extends Record<string, any> = any> {
public formApi = {} as ExtendedFormApi;
// private prevState: null | VxeGridProps = null;
public grid = {} as VxeGridInstance<T>;
public state: null | VxeGridProps<T> = null;
public store: Store<VxeGridProps<T>>;
private isMounted = false;
private stateHandler: StateHandler;
constructor(options: VxeGridProps = {}) {
const storeState = { ...options };
const defaultState = getDefaultState();
this.store = new Store<VxeGridProps>(
mergeWithArrayOverride(storeState, defaultState),
{
onUpdate: () => {
// this.prevState = this.state;
this.state = this.store.state;
},
},
);
this.state = this.store.state;
this.stateHandler = new StateHandler();
bindMethods(this);
}
mount(instance: null | VxeGridInstance, formApi: ExtendedFormApi) {
if (!this.isMounted && instance) {
this.grid = instance;
this.formApi = formApi;
this.stateHandler.setConditionTrue();
this.isMounted = true;
}
}
async query(params: Record<string, any> = {}) {
try {
await this.grid.commitProxy('query', toRaw(params));
} catch (error) {
console.error('Error occurred while querying:', error);
}
}
async reload(params: Record<string, any> = {}) {
try {
await this.grid.commitProxy('reload', toRaw(params));
} catch (error) {
console.error('Error occurred while reloading:', error);
}
}
setGridOptions(options: Partial<VxeGridProps['gridOptions']>) {
this.setState({
gridOptions: options,
});
}
setLoading(isLoading: boolean) {
this.setState({
gridOptions: {
loading: isLoading,
},
});
}
setState(
stateOrFn:
| ((prev: VxeGridProps<T>) => Partial<VxeGridProps<T>>)
| Partial<VxeGridProps<T>>,
) {
if (isFunction(stateOrFn)) {
this.store.setState((prev) => {
return mergeWithArrayOverride(stateOrFn(prev), prev);
});
} else {
this.store.setState((prev) => mergeWithArrayOverride(stateOrFn, prev));
}
}
toggleSearchForm(show?: boolean) {
this.setState({
showSearchForm: isBoolean(show) ? show : !this.state?.showSearchForm,
});
// nextTick(() => {
// this.grid.recalculate();
// });
return this.state?.showSearchForm;
}
unmount() {
this.isMounted = false;
this.stateHandler.reset();
}
}

View File

@ -1,81 +0,0 @@
import type { VxeGridProps, VxeUIExport } from 'vxe-table';
import type { Recordable } from '@vben/types';
import type { VxeGridApi } from './api';
import { formatDate, formatDateTime, isFunction } from '@vben/utils';
export function extendProxyOptions(
api: VxeGridApi,
options: VxeGridProps,
getFormValues: () => Recordable<any>,
) {
[
'query',
'querySuccess',
'queryError',
'queryAll',
'queryAllSuccess',
'queryAllError',
].forEach((key) => {
extendProxyOption(key, api, options, getFormValues);
});
}
function extendProxyOption(
key: string,
api: VxeGridApi,
options: VxeGridProps,
getFormValues: () => Recordable<any>,
) {
const { proxyConfig } = options;
const configFn = (proxyConfig?.ajax as Recordable<any>)?.[key];
if (!isFunction(configFn)) {
return options;
}
const wrapperFn = async (
params: Recordable<any>,
customValues: Recordable<any>,
...args: Recordable<any>[]
) => {
const formValues = getFormValues();
const data = await configFn(
params,
{
/**
* toolbarConfig.refresh功能
* PointerEvent
*/
...(customValues instanceof PointerEvent ? {} : customValues),
...formValues,
},
...args,
);
return data;
};
api.setState({
gridOptions: {
proxyConfig: {
ajax: {
[key]: wrapperFn,
},
},
},
});
}
export function extendsDefaultFormatter(vxeUI: VxeUIExport) {
vxeUI.formats.add('formatDate', {
tableCellFormatMethod({ cellValue }) {
return formatDate(cellValue);
},
});
vxeUI.formats.add('formatDateTime', {
tableCellFormatMethod({ cellValue }) {
return formatDateTime(cellValue);
},
});
}

View File

@ -1,10 +0,0 @@
export { setupVbenVxeTable } from './init';
export type { VxeTableGridOptions } from './types';
export * from './use-vxe-grid';
export { default as VbenVxeGrid } from './use-vxe-grid.vue';
export type {
VxeGridListeners,
VxeGridProps,
VxeGridPropTypes,
} from 'vxe-table';

View File

@ -1,131 +0,0 @@
import type { SetupVxeTable } from './types';
import { defineComponent, watch } from 'vue';
import { usePreferences } from '@vben/preferences';
import { useVbenForm } from '@vben-core/form-ui';
import {
VxeButton,
VxeCheckbox,
// VxeFormGather,
// VxeForm,
// VxeFormItem,
VxeIcon,
VxeInput,
VxeLoading,
VxeModal,
VxeNumberInput,
VxePager,
// VxeList,
// VxeModal,
// VxeOptgroup,
// VxeOption,
// VxePulldown,
// VxeRadio,
// VxeRadioButton,
VxeRadioGroup,
VxeSelect,
VxeTooltip,
VxeUI,
VxeUpload,
// VxeSwitch,
// VxeTextarea,
} from 'vxe-pc-ui';
import enUS from 'vxe-pc-ui/lib/language/en-US';
// 导入默认的语言
import zhCN from 'vxe-pc-ui/lib/language/zh-CN';
import {
VxeColgroup,
VxeColumn,
VxeGrid,
VxeTable,
VxeToolbar,
} from 'vxe-table';
import { extendsDefaultFormatter } from './extends';
// 是否加载过
let isInit = false;
// eslint-disable-next-line import/no-mutable-exports
export let useTableForm: typeof useVbenForm;
// 部分组件如果没注册vxe-table 会报错,这里实际没用组件,只是为了不报错,同时可以减少打包体积
const createVirtualComponent = (name = '') => {
return defineComponent({
name,
});
};
export function initVxeTable() {
if (isInit) {
return;
}
VxeUI.component(VxeTable);
VxeUI.component(VxeColumn);
VxeUI.component(VxeColgroup);
VxeUI.component(VxeGrid);
VxeUI.component(VxeToolbar);
VxeUI.component(VxeButton);
// VxeUI.component(VxeButtonGroup);
VxeUI.component(VxeCheckbox);
// VxeUI.component(VxeCheckboxGroup);
VxeUI.component(createVirtualComponent('VxeForm'));
// VxeUI.component(VxeFormGather);
// VxeUI.component(VxeFormItem);
VxeUI.component(VxeIcon);
VxeUI.component(VxeInput);
// VxeUI.component(VxeList);
VxeUI.component(VxeLoading);
VxeUI.component(VxeModal);
VxeUI.component(VxeNumberInput);
// VxeUI.component(VxeOptgroup);
// VxeUI.component(VxeOption);
VxeUI.component(VxePager);
// VxeUI.component(VxePulldown);
// VxeUI.component(VxeRadio);
// VxeUI.component(VxeRadioButton);
VxeUI.component(VxeRadioGroup);
VxeUI.component(VxeSelect);
// VxeUI.component(VxeSwitch);
// VxeUI.component(VxeTextarea);
VxeUI.component(VxeTooltip);
VxeUI.component(VxeUpload);
isInit = true;
}
export function setupVbenVxeTable(setupOptions: SetupVxeTable) {
const { configVxeTable, useVbenForm } = setupOptions;
initVxeTable();
useTableForm = useVbenForm;
const { isDark, locale } = usePreferences();
const localMap = {
'zh-CN': zhCN,
'en-US': enUS,
};
watch(
[() => isDark.value, () => locale.value],
([isDarkValue, localeValue]) => {
VxeUI.setTheme(isDarkValue ? 'dark' : 'light');
VxeUI.setI18n(localeValue, localMap[localeValue]);
VxeUI.setLanguage(localeValue);
},
{
immediate: true,
},
);
extendsDefaultFormatter(VxeUI);
configVxeTable(VxeUI);
}

View File

@ -1,117 +0,0 @@
:root .vxe-grid {
--vxe-ui-font-color: hsl(var(--foreground));
--vxe-ui-font-primary-color: hsl(var(--primary));
/* --vxe-ui-font-lighten-color: #babdc0;
--vxe-ui-font-darken-color: #86898e; */
--vxe-ui-font-disabled-color: hsl(var(--foreground) / 50%);
/* base */
--vxe-ui-base-popup-border-color: hsl(var(--border));
--vxe-ui-input-disabled-color: hsl(var(--border) / 60%);
/* --vxe-ui-base-popup-box-shadow: 0px 12px 30px 8px rgb(0 0 0 / 50%); */
/* layout */
--vxe-ui-layout-background-color: hsl(var(--background));
--vxe-ui-table-resizable-line-color: hsl(var(--heavy));
/* --vxe-ui-table-fixed-left-scrolling-box-shadow: 8px 0px 10px -5px hsl(var(--accent));
--vxe-ui-table-fixed-right-scrolling-box-shadow: -8px 0px 10px -5px hsl(var(--accent)); */
/* input */
--vxe-ui-input-border-color: hsl(var(--border));
/* --vxe-ui-input-placeholder-color: #8d9095; */
/* --vxe-ui-input-disabled-background-color: #262727; */
/* loading */
--vxe-ui-loading-background-color: hsl(var(--overlay-content));
/* table */
--vxe-ui-table-header-background-color: hsl(var(--accent));
--vxe-ui-table-border-color: hsl(var(--border));
--vxe-ui-table-row-hover-background-color: hsl(var(--accent-hover));
--vxe-ui-table-row-striped-background-color: hsl(var(--accent) / 60%);
--vxe-ui-table-row-hover-striped-background-color: hsl(var(--accent));
--vxe-ui-table-row-radio-checked-background-color: hsl(var(--accent));
--vxe-ui-table-row-hover-radio-checked-background-color: hsl(
var(--accent-hover)
);
--vxe-ui-table-row-checkbox-checked-background-color: hsl(var(--accent));
--vxe-ui-table-row-hover-checkbox-checked-background-color: hsl(
var(--accent-hover)
);
--vxe-ui-table-row-current-background-color: hsl(var(--accent));
--vxe-ui-table-row-hover-current-background-color: hsl(var(--accent-hover));
--vxe-ui-font-primary-tinge-color: hsl(var(--primary));
--vxe-ui-font-primary-lighten-color: hsl(var(--primary) / 60%);
--vxe-ui-font-primary-darken-color: hsl(var(--primary));
height: auto !important;
/* --vxe-ui-table-fixed-scrolling-box-shadow-color: rgb(0 0 0 / 80%); */
}
.vxe-pager {
.vxe-pager--prev-btn:not(.is--disabled):active,
.vxe-pager--next-btn:not(.is--disabled):active,
.vxe-pager--num-btn:not(.is--disabled):active,
.vxe-pager--jump-prev:not(.is--disabled):active,
.vxe-pager--jump-next:not(.is--disabled):active,
.vxe-pager--prev-btn:not(.is--disabled):focus,
.vxe-pager--next-btn:not(.is--disabled):focus,
.vxe-pager--num-btn:not(.is--disabled):focus,
.vxe-pager--jump-prev:not(.is--disabled):focus,
.vxe-pager--jump-next:not(.is--disabled):focus {
color: hsl(var(--accent-foreground));
background-color: hsl(var(--accent));
border: 1px solid hsl(var(--border));
box-shadow: 0 0 0 1px hsl(var(--border));
}
.vxe-pager--wrapper {
display: flex;
align-items: center;
}
.vxe-pager--sizes {
margin-right: auto;
}
}
.vxe-pager--wrapper {
@apply justify-center md:justify-end;
}
.vxe-tools--operate {
margin-right: 0.25rem;
margin-left: 0.75rem;
}
.vxe-table-custom--checkbox-option:hover {
background: none !important;
}
.vxe-toolbar {
padding: 0;
}
.vxe-buttons--wrapper:not(:empty),
.vxe-tools--operate:not(:empty),
.vxe-tools--wrapper:not(:empty) {
padding: 0.6em 0;
}
.vxe-tools--operate:not(:has(button)) {
margin-left: 0;
}
.vxe-grid--layout-header-wrapper {
overflow: visible;
}
.vxe-grid--layout-body-content-wrapper {
overflow: hidden;
}

View File

@ -1,93 +0,0 @@
import type {
VxeGridListeners,
VxeGridPropTypes,
VxeGridProps as VxeTableGridProps,
VxeUIExport,
} from 'vxe-table';
import type { Ref } from 'vue';
import type { ClassType, DeepPartial } from '@vben/types';
import type { BaseFormComponentType, VbenFormProps } from '@vben-core/form-ui';
import type { VxeGridApi } from './api';
import { useVbenForm } from '@vben-core/form-ui';
export interface VxePaginationInfo {
currentPage: number;
pageSize: number;
total: number;
}
interface ToolbarConfigOptions extends VxeGridPropTypes.ToolbarConfig {
/** 是否显示切换搜索表单的按钮 */
search?: boolean;
}
export interface VxeTableGridOptions<T = any> extends VxeTableGridProps<T> {
/** 工具栏配置 */
toolbarConfig?: ToolbarConfigOptions;
}
export interface SeparatorOptions {
show?: boolean;
backgroundColor?: string;
}
export interface VxeGridProps<
T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType,
> {
/**
*
*/
tableTitle?: string;
/**
*
*/
tableTitleHelp?: string;
/**
* class
*/
class?: ClassType;
/**
* vxe-grid class
*/
gridClass?: ClassType;
/**
* vxe-grid
*/
gridOptions?: DeepPartial<VxeTableGridOptions<T>>;
/**
* vxe-grid
*/
gridEvents?: DeepPartial<VxeGridListeners<T>>;
/**
*
*/
formOptions?: VbenFormProps<D>;
/**
*
*/
showSearchForm?: boolean;
/**
*
*/
separator?: boolean | SeparatorOptions;
}
export type ExtendedVxeGridApi<
D extends Record<string, any> = any,
F extends BaseFormComponentType = BaseFormComponentType,
> = VxeGridApi<D> & {
useStore: <T = NoInfer<VxeGridProps<D, F>>>(
selector?: (state: NoInfer<VxeGridProps<any, any>>) => T,
) => Readonly<Ref<T>>;
};
export interface SetupVxeTable {
configVxeTable: (ui: VxeUIExport) => void;
useVbenForm: typeof useVbenForm;
}

View File

@ -1,70 +0,0 @@
import type { VxeGridSlots, VxeGridSlotTypes } from 'vxe-table';
import type { SlotsType } from 'vue';
import type { BaseFormComponentType } from '@vben-core/form-ui';
import type { ExtendedVxeGridApi, VxeGridProps } from './types';
import { defineComponent, h, onBeforeUnmount } from 'vue';
import { useStore } from '@vben-core/shared/store';
import { VxeGridApi } from './api';
import VxeGrid from './use-vxe-grid.vue';
type FilteredSlots<T> = {
[K in keyof VxeGridSlots<T> as K extends 'form'
? never
: K]: VxeGridSlots<T>[K];
};
export function useVbenVxeGrid<
T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType,
>(options: VxeGridProps<T, D>) {
// const IS_REACTIVE = isReactive(options);
const api = new VxeGridApi(options);
const extendedApi: ExtendedVxeGridApi<T, D> = api as ExtendedVxeGridApi<T, D>;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
};
const Grid = defineComponent(
(props: VxeGridProps<T>, { attrs, slots }) => {
onBeforeUnmount(() => {
api.unmount();
});
api.setState({ ...props, ...attrs });
return () => h(VxeGrid, { ...props, ...attrs, api: extendedApi }, slots);
},
{
name: 'VbenVxeGrid',
inheritAttrs: false,
slots: Object as SlotsType<
{
// 表格标题
'table-title': undefined;
// 工具栏左侧部分
'toolbar-actions': VxeGridSlotTypes.DefaultSlotParams<T>;
// 工具栏右侧部分
'toolbar-tools': VxeGridSlotTypes.DefaultSlotParams<T>;
} & FilteredSlots<T>
>,
},
);
// Add reactivity support
// if (IS_REACTIVE) {
// watch(
// () => options,
// () => {
// api.setState(options);
// },
// { immediate: true },
// );
// }
return [Grid, extendedApi] as const;
}
export type UseVbenVxeGrid = typeof useVbenVxeGrid;

View File

@ -1,481 +0,0 @@
<script lang="ts" setup>
import type {
VxeGridDefines,
VxeGridInstance,
VxeGridListeners,
VxeGridPropTypes,
VxeGridProps as VxeTableGridProps,
VxeToolbarPropTypes,
} from 'vxe-table';
import type { SetupContext } from 'vue';
import type { VbenFormProps } from '@vben-core/form-ui';
import type { ExtendedVxeGridApi, VxeGridProps } from './types';
import {
computed,
nextTick,
onMounted,
onUnmounted,
toRaw,
useSlots,
useTemplateRef,
watch,
} from 'vue';
import { usePriorityValues } from '@vben/hooks';
import { EmptyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { usePreferences } from '@vben/preferences';
import {
cloneDeep,
cn,
isBoolean,
isEqual,
mergeWithArrayOverride,
} from '@vben/utils';
import { VbenHelpTooltip, VbenLoading } from '@vben-core/shadcn-ui';
import { VxeButton } from 'vxe-pc-ui';
import { VxeGrid, VxeUI } from 'vxe-table';
import { extendProxyOptions } from './extends';
import { useTableForm } from './init';
import 'vxe-table/styles/cssvar.scss';
import 'vxe-pc-ui/styles/cssvar.scss';
import './style.css';
interface Props extends VxeGridProps {
api: ExtendedVxeGridApi;
}
const props = withDefaults(defineProps<Props>(), {});
const FORM_SLOT_PREFIX = 'form-';
const TOOLBAR_ACTIONS = 'toolbar-actions';
const TOOLBAR_TOOLS = 'toolbar-tools';
const TABLE_TITLE = 'table-title';
const gridRef = useTemplateRef<VxeGridInstance>('gridRef');
const state = props.api?.useStore?.();
const {
gridOptions,
class: className,
gridClass,
gridEvents,
formOptions,
tableTitle,
tableTitleHelp,
showSearchForm,
separator,
} = usePriorityValues(props, state);
const { isMobile } = usePreferences();
const isSeparator = computed(() => {
if (
!formOptions.value ||
showSearchForm.value === false ||
separator.value === false
) {
return false;
}
if (separator.value === true || separator.value === undefined) {
return true;
}
return separator.value.show !== false;
});
const separatorBg = computed(() => {
return !separator.value ||
isBoolean(separator.value) ||
!separator.value.backgroundColor
? undefined
: separator.value.backgroundColor;
});
const slots: SetupContext['slots'] = useSlots();
const [Form, formApi] = useTableForm({
compact: true,
handleSubmit: async () => {
const formValues = await formApi.getValues();
formApi.setLatestSubmissionValues(toRaw(formValues));
props.api.reload(formValues);
},
handleReset: async () => {
const prevValues = await formApi.getValues();
await formApi.resetForm();
const formValues = await formApi.getValues();
formApi.setLatestSubmissionValues(formValues);
// submitOnChangesubmitOnChangefalse
if (isEqual(prevValues, formValues) || !formOptions.value?.submitOnChange) {
props.api.reload(formValues);
}
},
commonConfig: {
componentProps: {
class: 'w-full',
},
},
showCollapseButton: true,
submitButtonOptions: {
content: computed(() => $t('common.search')),
},
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
});
const showTableTitle = computed(() => {
return !!slots[TABLE_TITLE]?.() || tableTitle.value;
});
const showToolbar = computed(() => {
return (
!!slots[TOOLBAR_ACTIONS]?.() ||
!!slots[TOOLBAR_TOOLS]?.() ||
showTableTitle.value
);
});
const toolbarOptions = computed(() => {
const slotActions = slots[TOOLBAR_ACTIONS]?.();
const slotTools = slots[TOOLBAR_TOOLS]?.();
const searchBtn: VxeToolbarPropTypes.ToolConfig = {
code: 'search',
icon: 'vxe-icon-search',
circle: true,
status: showSearchForm.value ? 'primary' : undefined,
title: showSearchForm.value
? $t('common.hideSearchPanel')
: $t('common.showSearchPanel'),
};
// toolbarConfig.tools
const toolbarConfig: VxeGridPropTypes.ToolbarConfig = {
tools: (gridOptions.value?.toolbarConfig?.tools ??
[]) as VxeToolbarPropTypes.ToolConfig[],
};
if (gridOptions.value?.toolbarConfig?.search && !!formOptions.value) {
toolbarConfig.tools = Array.isArray(toolbarConfig.tools)
? [...toolbarConfig.tools, searchBtn]
: [searchBtn];
}
if (!showToolbar.value) {
toolbarConfig.enabled = false;
return { toolbarConfig };
}
// 使toolbar
//
toolbarConfig.slots = {
...(slotActions || showTableTitle.value
? { buttons: TOOLBAR_ACTIONS }
: {}),
...(slotTools ? { tools: TOOLBAR_TOOLS } : {}),
};
return { toolbarConfig };
});
const options = computed(() => {
const globalGridConfig = VxeUI?.getConfig()?.grid ?? {};
const mergedOptions: VxeTableGridProps = cloneDeep(
mergeWithArrayOverride(
{},
toRaw(toolbarOptions.value),
toRaw(gridOptions.value),
globalGridConfig,
),
);
if (mergedOptions.proxyConfig) {
const { ajax } = mergedOptions.proxyConfig;
mergedOptions.proxyConfig.enabled = !!ajax;
// ,
mergedOptions.proxyConfig.autoLoad = false;
}
if (mergedOptions.pagerConfig) {
const mobileLayouts = [
'PrevJump',
'PrevPage',
'Number',
'NextPage',
'NextJump',
] as any;
const layouts = [
'Total',
'Sizes',
'Home',
...mobileLayouts,
'End',
] as readonly string[];
mergedOptions.pagerConfig = mergeWithArrayOverride(
{},
mergedOptions.pagerConfig,
{
pageSize: 20,
background: true,
pageSizes: [10, 20, 30, 50, 100, 200],
className: 'mt-2 w-full',
layouts: isMobile.value ? mobileLayouts : layouts,
size: 'mini' as const,
},
);
}
if (mergedOptions.formConfig) {
mergedOptions.formConfig.enabled = false;
}
return mergedOptions;
});
function onToolbarToolClick(event: VxeGridDefines.ToolbarToolClickEventParams) {
if (event.code === 'search') {
onSearchBtnClick();
}
(
gridEvents.value?.toolbarToolClick as VxeGridListeners['toolbarToolClick']
)?.(event);
}
function onSearchBtnClick() {
props.api?.toggleSearchForm?.();
}
const events = computed(() => {
return {
...gridEvents.value,
toolbarToolClick: onToolbarToolClick,
};
});
const delegatedSlots = computed(() => {
const resultSlots: string[] = [];
for (const key of Object.keys(slots)) {
if (
!['empty', 'form', 'loading', TOOLBAR_ACTIONS, TOOLBAR_TOOLS].includes(
key,
)
) {
resultSlots.push(key);
}
}
return resultSlots;
});
const delegatedFormSlots = computed(() => {
const resultSlots: string[] = [];
for (const key of Object.keys(slots)) {
if (key.startsWith(FORM_SLOT_PREFIX)) {
resultSlots.push(key);
}
}
return resultSlots.map((key) => key.replace(FORM_SLOT_PREFIX, ''));
});
const showDefaultEmpty = computed(() => {
// VXE Table
const hasEmptyText = options.value.emptyText !== undefined;
const hasEmptyRender = options.value.emptyRender !== undefined;
//
return !hasEmptyText && !hasEmptyRender;
});
async function init() {
await nextTick();
const globalGridConfig = VxeUI?.getConfig()?.grid ?? {};
const defaultGridOptions: VxeTableGridProps = mergeWithArrayOverride(
{},
toRaw(gridOptions.value),
toRaw(globalGridConfig),
);
// form
const autoLoad = defaultGridOptions.proxyConfig?.autoLoad;
const enableProxyConfig = options.value.proxyConfig?.enabled;
if (enableProxyConfig && autoLoad) {
props.api.grid.commitProxy?.(
'query',
formOptions.value ? ((await formApi.getValues()) ?? {}) : {},
);
// props.api.reload(formApi.form?.values ?? {});
}
// form vben-formformConfig
const formConfig = gridOptions.value?.formConfig;
// Table2Table
// defaultGridOptionsgridOptionsState
if (formConfig && formConfig.enabled) {
console.warn(
'[Vben Vxe Table]: The formConfig in the grid is not supported, please use the `formOptions` props',
);
}
// @ts-ignore
props.api?.setState?.({ gridOptions: defaultGridOptions });
// form vben-form query
extendProxyOptions(props.api, defaultGridOptions, () =>
formApi.getLatestSubmissionValues(),
);
}
// formOptions
watch(
formOptions,
() => {
formApi.setState((prev) => {
const finalFormOptions: VbenFormProps = mergeWithArrayOverride(
{},
formOptions.value,
prev,
);
return {
...finalFormOptions,
collapseTriggerResize: !!finalFormOptions.showCollapseButton,
};
});
},
{
immediate: true,
},
);
const isCompactForm = computed(() => {
return formApi.getState()?.compact;
});
onMounted(() => {
props.api?.mount?.(gridRef.value, formApi);
init();
});
onUnmounted(() => {
formApi?.unmount?.();
props.api?.unmount?.();
});
</script>
<template>
<div :class="cn('bg-card h-full rounded-md', className)">
<VxeGrid
ref="gridRef"
:class="
cn(
'p-2',
{
'pt-0': showToolbar && !formOptions,
},
gridClass,
)
"
v-bind="options"
v-on="events"
>
<!-- 左侧操作区域或者title -->
<template v-if="showToolbar" #toolbar-actions="slotProps">
<slot v-if="showTableTitle" name="table-title">
<div class="mr-1 pl-1 text-[1rem]">
{{ tableTitle }}
<VbenHelpTooltip v-if="tableTitleHelp" trigger-class="pb-1">
{{ tableTitleHelp }}
</VbenHelpTooltip>
</div>
</slot>
<slot name="toolbar-actions" v-bind="slotProps"> </slot>
</template>
<!-- 继承默认的slot -->
<template
v-for="slotName in delegatedSlots"
:key="slotName"
#[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps"></slot>
</template>
<template #toolbar-tools="slotProps">
<slot name="toolbar-tools" v-bind="slotProps"></slot>
<VxeButton
icon="vxe-icon-search"
circle
class="ml-2"
v-if="gridOptions?.toolbarConfig?.search && !!formOptions"
:status="showSearchForm ? 'primary' : undefined"
:title="$t('common.search')"
@click="onSearchBtnClick"
/>
</template>
<!-- form表单 -->
<template #form>
<div
v-if="formOptions"
v-show="showSearchForm !== false"
:class="
cn(
'relative rounded py-3',
isCompactForm
? isSeparator
? 'pb-8'
: 'pb-4'
: isSeparator
? 'pb-4'
: 'pb-0',
)
"
>
<slot name="form">
<Form>
<template
v-for="slotName in delegatedFormSlots"
:key="slotName"
#[slotName]="slotProps"
>
<slot
:name="`${FORM_SLOT_PREFIX}${slotName}`"
v-bind="slotProps"
></slot>
</template>
<template #reset-before="slotProps">
<slot name="reset-before" v-bind="slotProps"></slot>
</template>
<template #submit-before="slotProps">
<slot name="submit-before" v-bind="slotProps"></slot>
</template>
<template #expand-before="slotProps">
<slot name="expand-before" v-bind="slotProps"></slot>
</template>
<template #expand-after="slotProps">
<slot name="expand-after" v-bind="slotProps"></slot>
</template>
</Form>
</slot>
<div
v-if="isSeparator"
:style="{
...(separatorBg ? { backgroundColor: separatorBg } : undefined),
}"
class="bg-background-deep z-100 absolute -left-2 bottom-1 h-2 w-[calc(100%+1rem)] overflow-hidden md:bottom-2 md:h-3"
></div>
</div>
</template>
<!-- loading -->
<template #loading>
<slot name="loading">
<VbenLoading :spinning="true" />
</slot>
</template>
<!-- 统一控状态 -->
<template v-if="showDefaultEmpty" #empty>
<slot name="empty">
<EmptyIcon class="mx-auto" />
<div class="mt-2">{{ $t('common.noData') }}</div>
</slot>
</template>
</VxeGrid>
</div>
</template>

View File

@ -390,9 +390,6 @@ catalogs:
vite-plugin-html:
specifier: ^3.2.2
version: 3.2.2
vite-plugin-lazy-import:
specifier: ^1.0.7
version: 1.0.7
vite-plugin-pwa:
specifier: ^1.2.0
version: 1.2.0
@ -420,12 +417,6 @@ catalogs:
vue-tsc:
specifier: ^3.2.4
version: 3.2.4
vxe-pc-ui:
specifier: ^4.12.16
version: 4.12.35
vxe-table:
specifier: ^4.17.46
version: 4.17.48
watermark-js-plus:
specifier: ^1.6.3
version: 1.6.3
@ -922,9 +913,6 @@ importers:
vite-plugin-html:
specifier: 'catalog:'
version: 3.2.2(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(less@4.5.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))
vite-plugin-lazy-import:
specifier: 'catalog:'
version: 1.0.7
packages/@core/base/design: {}
@ -1410,12 +1398,6 @@ importers:
vue:
specifier: ^3.5.27
version: 3.5.27(typescript@5.9.3)
vxe-pc-ui:
specifier: 'catalog:'
version: 4.12.35(vue@3.5.27(typescript@5.9.3))
vxe-table:
specifier: 'catalog:'
version: 4.17.48(vue@3.5.27(typescript@5.9.3))
packages/effects/request:
dependencies:
@ -4091,11 +4073,6 @@ packages:
peerDependencies:
vue: ^3.5.27
'@vxe-ui/core@4.3.1':
resolution: {integrity: sha512-sr2WdFDWM3IKID02HbSaDxxRDvj1LZ5ZkOnH2POvGkkCfCWItkx3avkizfRUk8RtjNU+wXozaPbYTNha5kjSdg==}
peerDependencies:
vue: ^3.5.27
abbrev@2.0.0:
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@ -4890,9 +4867,6 @@ packages:
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
dom-zindex@1.0.6:
resolution: {integrity: sha512-FKWIhiU96bi3xpP9ewRMgANsoVmMUBnMnmpCT6dPMZOunVYJQmJhSRruoI0XSPoHeIif3kyEuiHbFrOJwEJaEA==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
@ -8449,9 +8423,6 @@ packages:
'@nuxt/kit':
optional: true
vite-plugin-lazy-import@1.0.7:
resolution: {integrity: sha512-mE6oAObOb4wqso4AoUGi9cLjdR+4vay1RCaKJvziBuFPlziZl7J0aw2hsqRTokLVRx3bli0a0VyjMOwsNDv58A==}
vite-plugin-pwa@1.2.0:
resolution: {integrity: sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==}
engines: {node: '>=16.0.0'}
@ -8601,12 +8572,6 @@ packages:
typescript:
optional: true
vxe-pc-ui@4.12.35:
resolution: {integrity: sha512-Hzmz8fhi3osQbxRAZ4mxdX+BgZjaGczl2O3Xhqp14+VUKuU4/S6UXU7uIvZqTO4nWl2w6DVdZzXE+UpXDs2zEg==}
vxe-table@4.17.48:
resolution: {integrity: sha512-hd2j3FMA5vu3Qc3wyCavwMdsaT5uEq1GCux3eV5VKPkd/MoSdh9DIyMzmqDKh/0QnpVPsoQuIZPJi/govnw2Iw==}
watermark-js-plus@1.6.3:
resolution: {integrity: sha512-iCLOGf70KacIwjGF9MDViYxQcRiVwOH7l42qDHLeE2HeUsQD1EQuUC9cKRG/4SErTUmdqV3yf5WnKk2dRARHPQ==}
@ -8751,9 +8716,6 @@ packages:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
xe-utils@3.9.1:
resolution: {integrity: sha512-Ujk5UmoH6Iaqhgz3oGwfCXVcMdUJKlXnfvLABdnMyseMG0eHsX2mcCvLd/8sGlIXtfwsprI9bW7vgcVognLmqQ==}
xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
@ -11703,12 +11665,6 @@ snapshots:
dependencies:
vue: 3.5.27(typescript@5.9.3)
'@vxe-ui/core@4.3.1(vue@3.5.27(typescript@5.9.3))':
dependencies:
dom-zindex: 1.0.6
vue: 3.5.27(typescript@5.9.3)
xe-utils: 3.9.1
abbrev@2.0.0: {}
abbrev@3.0.1: {}
@ -12551,8 +12507,6 @@ snapshots:
domhandler: 5.0.3
entities: 4.5.0
dom-zindex@1.0.6: {}
domelementtype@2.3.0: {}
domhandler@4.3.1:
@ -16470,13 +16424,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-lazy-import@1.0.7:
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.57.1)
es-module-lexer: 1.7.0
rollup: 4.57.1
xe-utils: 3.9.1
vite-plugin-pwa@1.2.0(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(less@4.5.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0):
dependencies:
debug: 4.4.3
@ -16694,18 +16641,6 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
vxe-pc-ui@4.12.35(vue@3.5.27(typescript@5.9.3)):
dependencies:
'@vxe-ui/core': 4.3.1(vue@3.5.27(typescript@5.9.3))
transitivePeerDependencies:
- vue
vxe-table@4.17.48(vue@3.5.27(typescript@5.9.3)):
dependencies:
vxe-pc-ui: 4.12.35(vue@3.5.27(typescript@5.9.3))
transitivePeerDependencies:
- vue
watermark-js-plus@1.6.3: {}
web-streams-polyfill@3.3.3: {}
@ -16935,8 +16870,6 @@ snapshots:
dependencies:
is-wsl: 3.1.0
xe-utils@3.9.1: {}
xml-name-validator@4.0.0: {}
y18n@4.0.3: {}

View File

@ -158,7 +158,6 @@ catalog:
vite-plugin-compression: ^0.5.1
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-lazy-import: ^1.0.7
vite-plugin-pwa: ^1.2.0
vite-plugin-vue-devtools: ^8.0.5
vitest: ^3.2.4
@ -169,8 +168,6 @@ catalog:
vue-router: ^4.6.4
vue-tippy: ^6.7.1
vue-tsc: ^3.2.4
vxe-pc-ui: ^4.12.16
vxe-table: ^4.17.46
watermark-js-plus: ^1.6.3
yaml-eslint-parser: ^1.3.2
zod: ^3.25.76

View File

@ -279,7 +279,7 @@ import { useAuthStore } from '#/store';
### 业务组件 (references/components/business/)
- **Page页面**: `references/components/business/page.md` - 页面布局容器、标题区、内容区
- **表单组件**: `references/components/business/form.md` - Vben Form表单配置、校验、联动
- **表格组件**: `references/components/business/table.md` - Vben Vxe Table表格配置、搜索、远程加载
- **表格组件**: `references/components/business/table.md` - KtTable 表格配置、搜索、远程加载、按钮权限
- **模态框**: `references/components/business/modal.md` - Vben Modal配置、拖拽、全屏
- **抽屉**: `references/components/business/drawer.md` - Vben Drawer配置、组件抽离
- **轻量提示框**: `references/components/business/alert.md` - alert、confirm、prompt调用

View File

@ -1,266 +1,166 @@
# Vben Vxe Table 表格
# KtTable 表格
基于 [vxe-table](https://vxetable.cn/v4/#/grid/api?apiKey=grid) 和 `Vben Form` 做了二次封装,用于构建带搜索表单的列表页面
当前项目表格统一使用 `KtTable + Antdv Next Table + Vben Form`。不要再引入旧表格适配器或额外表格依赖
## 基础用法
```vue
<script setup lang="ts">
import { useVbenVxeGrid } from '#/adapter/vxe-table';
```tsx
import type { TableColumnType } from 'antdv-next';
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ type: 'seq', width: 50 },
{ field: 'name', title: '名称' },
{ field: 'age', title: '年龄' },
],
data: [
{ name: '张三', age: 18 },
{ name: '李四', age: 20 },
],
import type { KtTableApi, KtTableButton } from '#/components/ktTable';
import { h } from 'vue';
import { Plus } from '@vben/icons';
import { KtTable, useKtTable } from '#/components/ktTable';
interface Row {
id: string;
name: string;
status: number;
}
const columns: Array<TableColumnType<Row>> = [
{ dataIndex: 'name', key: 'name', title: '名称', width: 200 },
{ dataIndex: 'status', key: 'status', title: '状态', width: 100 },
];
const api: KtTableApi<Row> = {
list: async (params) => {
return await getListApi({
page: params.pageNo,
pageSize: params.pageSize,
...params,
});
},
});
</script>
};
<template>
<Grid />
</template>
const buttons: Array<KtTableButton<Row>> = [
{
icon: () => h(Plus, { class: 'kt-table__button-icon' }),
key: 'create',
label: '新增',
onClick: onCreate,
type: 'primary',
},
];
const [registerTable, tableApi] = useKtTable<Row>({
api,
buttons,
columns,
rowActions,
});
```
## 远程加载
```vue
<script setup lang="ts">
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getUserListApi } from '#/api';
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ type: 'seq', width: 50 },
{ field: 'name', title: '名称' },
{ field: 'age', title: '年龄' },
],
proxyConfig: {
ajax: {
query: async ({ page }) => {
const res = await getUserListApi({
page: page.currentPage,
pageSize: page.pageSize,
});
return {
items: res.data.list,
total: res.data.total,
};
},
},
```tsx
<KtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }) => {
if (column.key === 'status') {
return record.status === 1 ? '启用' : '停用';
}
return undefined;
},
},
});
</script>
}}
/>
```
## 搜索表单
```vue
<script setup lang="ts">
import { useVbenVxeGrid } from '#/adapter/vxe-table';
const [Grid, gridApi] = useVbenVxeGrid({
```ts
const [registerTable] = useKtTable<Row>({
api,
columns,
formOptions: {
fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
labelInInput: true,
schema: [
{
component: 'Input',
fieldName: 'name',
label: '名称',
},
{ component: 'Input', fieldName: 'keyword', label: '关键词' },
{
component: 'Select',
fieldName: 'status',
label: '状态',
componentProps: {
allowClear: true,
options: [
{ label: '启用', value: 1 },
{ label: '用', value: 0 },
{ label: '用', value: 0 },
],
},
},
],
},
gridOptions: {
toolbarConfig: {
search: true, // 显示搜索面板开关按钮
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const res = await getUserListApi({
...formValues,
page: page.currentPage,
pageSize: page.pageSize,
});
return res;
},
},
},
columns: [...],
},
});
</script>
```
## 树形表格
```ts
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: [...],
treeConfig: {
transform: true,
parentField: 'parentId',
rowField: 'id',
},
},
});
```
## 固定列
## 操作按钮
按钮完全由业务页面注册,组件里不写死新增、编辑、删除等业务逻辑。
```ts
const columns = [
{ field: 'name', title: '名称', fixed: 'left', width: 100 },
{ field: 'age', title: '年龄' },
{ field: 'address', title: '地址' },
{ field: 'action', title: '操作', fixed: 'right', width: 100 },
];
```
## 单元格编辑
```ts
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
editConfig: {
mode: 'cell', // 或 'row'
trigger: 'click',
},
columns: [
{
field: 'name',
title: '名称',
editRender: { name: 'input' },
},
],
},
});
```
## 自定义渲染器
```ts
// 适配器配置
import { h } from 'vue';
import { Image, Button } from 'ant-design-vue';
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
const { column, row } = params;
return h(Image, { src: row[column.field] });
},
});
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(Button, { size: 'small', type: 'link' }, {
default: () => props?.text,
});
},
});
// 使用
const columns = [
const rowActions = [
{
field: 'avatar',
title: '头像',
cellRender: { name: 'CellImage' },
key: 'edit',
label: '编辑',
onClick: onEdit,
permissionCodes: ['System:Role:Edit'],
},
{
field: 'link',
title: '链接',
cellRender: { name: 'CellLink', props: { text: '查看' } },
confirm: (row) => `确认删除「${row.name}」吗?`,
danger: true,
key: 'delete',
label: '删除',
onClick: onDelete,
permissionCodes: ['System:Role:Delete'],
},
];
```
## GridApi 方法
| 方法名 | 描述 | 类型 |
|--------|------|------|
| setLoading | 设置loading状态 | `(loading: boolean) => void` |
| setGridOptions | 更新gridOptions | `(options) => void` |
| reload | 重新加载,重置分页 | `(params?) => void` |
| query | 重新查询,保留分页 | `(params?) => void` |
| grid | vxe-grid实例 | `VxeGridInstance` |
| formApi | 搜索表单API | `FormApi` |
| toggleSearchForm | 切换搜索表单状态 | `(show?: boolean) => boolean` |
## Props 属性
| 属性名 | 描述 | 类型 |
|--------|------|------|
| tableTitle | 表格标题 | `string` |
| tableTitleHelp | 表格标题帮助信息 | `string` |
| class | 外层容器的class | `string` |
| gridClass | vxe-grid的class | `string` |
| gridOptions | vxe-grid配置 | `VxeTableGridOptions` |
| gridEvents | vxe-grid事件 | `VxeGridListeners` |
| formOptions | 搜索表单配置 | `VbenFormProps` |
| showSearchForm | 是否显示搜索表单 | `boolean` |
| separator | 搜索表单与表格的分隔条 | `boolean \| SeparatorOptions` |
## 插槽
| 插槽名 | 描述 |
|--------|------|
| toolbar-actions | 工具栏左侧区域 |
| toolbar-tools | 工具栏右侧区域 |
| table-title | 自定义表格标题 |
| form-* | 搜索表单插槽转发 |
## 适配器配置
## 可插拔模块
```ts
// src/adapter/vxe-table.ts
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { useVbenForm } from './form';
import { defineKtTableHook, defineKtTableModule } from '#/components/ktTable';
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
border: false,
columnConfig: {
resizable: true,
},
minHeight: 180,
proxyConfig: {
autoLoad: true,
response: {
result: 'items',
total: 'total',
list: 'items',
},
},
showOverflow: true,
size: 'small',
},
});
const requestLogger = defineKtTableHook<Row>({
name: 'requestLogger',
onBeforeFetch(params) {
console.log(params);
},
useVbenForm,
});
export { useVbenVxeGrid };
const statusModule = defineKtTableModule<Row>({
columns: [{ dataIndex: 'status', key: 'status', title: '状态', width: 100 }],
hooks: [requestLogger],
name: 'statusModule',
});
const [registerTable] = useKtTable<Row>({
columns,
modules: [statusModule],
});
```
## 常用配置
| 属性 | 说明 |
| --- | --- |
| `api.list` | 远程数据接口,组件自动带分页和搜索参数 |
| `columns` | Antdv Next `TableColumnType[]` |
| `formOptions` | Vben Form 搜索表单配置 |
| `buttons` | 表格头部按钮 |
| `rowActions` | 行操作按钮,超过可见数量自动折叠 |
| `statistics` | 行列级统计,固定在表格底部 |
| `showIndex` | 是否显示序号列,默认显示 |
| `showSelection` | 是否显示选择列 |
| `showPagination` | 是否显示分页 |
| `rowResizable` | 是否允许调整单行行高 |
## 约束
- 表格列使用 Antdv Next 原生 `TableColumnType`
- 自定义单元格使用 `bodyCell` 插槽或页面内 TSX 渲染。
- 搜索表单必须使用 Vben Form不在外部维护独立 `searchValue`
- 业务按钮、权限码和请求逻辑都由页面通过 `useKtTable` 注册。