feat(admin): 优化字典管理与QQBot账号页

This commit is contained in:
sunlei 2026-06-04 03:13:39 +08:00
parent e4a2bace24
commit 3c8455e8ac
11 changed files with 550 additions and 76 deletions

View File

@ -43,10 +43,23 @@ export namespace QqbotApi {
lastError?: string; lastError?: string;
lastHeartbeatAt?: string; lastHeartbeatAt?: string;
name: string; name: string;
napcat?: AccountNapcatRuntime | null;
remark?: string; remark?: string;
selfId: string; selfId: string;
} }
export interface AccountNapcatRuntime {
bindStatus?: 'bound' | 'disabled' | 'pending';
containerId?: string;
containerName?: string;
containerStatus?: 'creating' | 'error' | 'running' | 'stopped';
lastCheckedAt?: string;
lastError?: string;
lastLoginAt?: string;
lastStartedAt?: string;
webuiPort?: null | number;
}
export interface AccountBody { export interface AccountBody {
accessToken?: string; accessToken?: string;
connectionMode?: 'reverse-ws'; connectionMode?: 'reverse-ws';

View File

@ -21,6 +21,14 @@ export namespace SystemDictApi {
treeKey: string; treeKey: string;
} }
export interface DictGroup {
dictCode: string;
id: string;
itemCount: number;
label: string;
value: string;
}
export type DictInput = Omit<DictItem, 'createTime' | 'id' | 'updateTime'>; export type DictInput = Omit<DictItem, 'createTime' | 'id' | 'updateTime'>;
export interface DictCodeOption { export interface DictCodeOption {
@ -47,6 +55,13 @@ async function getDictTree(params: Recordable<any>) {
}); });
} }
async function getDictGroups(params: Recordable<any>) {
return requestClient.get<SystemDictApi.PageResult<SystemDictApi.DictGroup>>(
'/dict/groups',
{ params },
);
}
async function getDictCodeOptions() { async function getDictCodeOptions() {
return requestClient.get<SystemDictApi.DictCodeOption[]>('/dict/codes'); return requestClient.get<SystemDictApi.DictCodeOption[]>('/dict/codes');
} }
@ -82,6 +97,7 @@ export {
createDict, createDict,
deleteDict, deleteDict,
getDictCodeOptions, getDictCodeOptions,
getDictGroups,
getDictList, getDictList,
getDictTree, getDictTree,
toggleDictStatus, toggleDictStatus,

View File

@ -469,29 +469,63 @@ export default defineComponent({
} }
/** /**
* class CSS *
* *
* @param record * @param record
*/ */
function resolveRowProps(record: KtTableRecord) { function resolveRowProps(record: KtTableRecord) {
if (!props.rowResizable) return {}; const recordKey = String(resolveRecordKey(record));
const classNames = [
props.rowResizable ? 'kt-table__row--resizable' : '',
props.onRowClick ? 'kt-table__row--clickable' : '',
props.activeRowKey !== undefined &&
String(props.activeRowKey) === recordKey
? 'kt-table__row--active'
: '',
resolveCustomRowClassName(record),
].filter(Boolean);
const height = rowHeights[recordKey];
const rowProps: KtTableRecord = {};
const height = rowHeights[String(resolveRecordKey(record))]; if (classNames.length > 0) {
rowProps.class = classNames.join(' ');
}
return { if (props.rowResizable) {
class: 'kt-table__row--resizable', rowProps.onMousedown = (event: MouseEvent) => {
onMousedown: (event: MouseEvent) => {
handleRowResizeMouseDown(event, record); handleRowResizeMouseDown(event, record);
}, };
style: height rowProps.style = height
? { ? {
'--kt-table-row-height': `${height}px`, '--kt-table-row-height': `${height}px`,
height: `${height}px`, height: `${height}px`,
} }
: undefined, : undefined;
}
if (props.onRowClick) {
rowProps.onClick = () => {
props.onRowClick?.(record, context);
}; };
} }
return rowProps;
}
/**
* class
*
* @param record
*/
function resolveCustomRowClassName(record: KtTableRecord) {
if (!props.rowClassName) return '';
if (typeof props.rowClassName === 'function') {
return props.rowClassName(record, context) || '';
}
return props.rowClassName;
}
/** /**
* *
* *

View File

@ -35,6 +35,7 @@ export const DEFAULT_TABLE_SETTING: Required<KtTableSetting> = {
export const KT_TABLE_PROP_KEYS = [ export const KT_TABLE_PROP_KEYS = [
'afterFetch', 'afterFetch',
'api', 'api',
'activeRowKey',
'beforeFetch', 'beforeFetch',
'buttons', 'buttons',
'columns', 'columns',
@ -43,10 +44,12 @@ export const KT_TABLE_PROP_KEYS = [
'hooks', 'hooks',
'immediate', 'immediate',
'modules', 'modules',
'onRowClick',
'pageSize', 'pageSize',
'pageSizeOptions', 'pageSizeOptions',
'rowActions', 'rowActions',
'rowActionVisibleCount', 'rowActionVisibleCount',
'rowClassName',
'rowResizeMaxHeight', 'rowResizeMaxHeight',
'rowResizeMinHeight', 'rowResizeMinHeight',
'rowResizable', 'rowResizable',
@ -74,6 +77,7 @@ export function createDefaultTableProps(): KtTableResolvedProps<
return { return {
afterFetch: undefined, afterFetch: undefined,
api: undefined, api: undefined,
activeRowKey: undefined,
beforeFetch: undefined, beforeFetch: undefined,
buttons: [], buttons: [],
columns: [], columns: [],
@ -82,10 +86,12 @@ export function createDefaultTableProps(): KtTableResolvedProps<
hooks: [], hooks: [],
immediate: true, immediate: true,
modules: [], modules: [],
onRowClick: undefined,
pageSize: KT_TABLE_DEFAULT_PAGE_SIZE, pageSize: KT_TABLE_DEFAULT_PAGE_SIZE,
pageSizeOptions: KT_TABLE_DEFAULT_PAGE_SIZE_OPTIONS, pageSizeOptions: KT_TABLE_DEFAULT_PAGE_SIZE_OPTIONS,
rowActions: [], rowActions: [],
rowActionVisibleCount: KT_TABLE_ROW_ACTION_VISIBLE_COUNT, rowActionVisibleCount: KT_TABLE_ROW_ACTION_VISIBLE_COUNT,
rowClassName: undefined,
rowResizeMaxHeight: KT_TABLE_DEFAULT_ROW_RESIZE_MAX_HEIGHT, rowResizeMaxHeight: KT_TABLE_DEFAULT_ROW_RESIZE_MAX_HEIGHT,
rowResizeMinHeight: KT_TABLE_DEFAULT_ROW_RESIZE_MIN_HEIGHT, rowResizeMinHeight: KT_TABLE_DEFAULT_ROW_RESIZE_MIN_HEIGHT,
rowResizable: false, rowResizable: false,
@ -109,6 +115,10 @@ export const ktTableProps = {
default: undefined, default: undefined,
type: Function as PropType<KtTableProps['afterFetch']>, type: Function as PropType<KtTableProps['afterFetch']>,
}, },
activeRowKey: {
default: undefined,
type: [Number, String] as PropType<KtTableProps['activeRowKey']>,
},
api: { api: {
default: undefined, default: undefined,
type: Object as PropType<KtTableModule['api']>, type: Object as PropType<KtTableModule['api']>,
@ -145,6 +155,10 @@ export const ktTableProps = {
default: () => [], default: () => [],
type: Array as PropType<KtTableModule[]>, type: Array as PropType<KtTableModule[]>,
}, },
onRowClick: {
default: undefined,
type: Function as PropType<KtTableProps['onRowClick']>,
},
pageSize: { pageSize: {
default: KT_TABLE_DEFAULT_PAGE_SIZE, default: KT_TABLE_DEFAULT_PAGE_SIZE,
type: Number, type: Number,
@ -161,6 +175,10 @@ export const ktTableProps = {
default: KT_TABLE_ROW_ACTION_VISIBLE_COUNT, default: KT_TABLE_ROW_ACTION_VISIBLE_COUNT,
type: Number, type: Number,
}, },
rowClassName: {
default: undefined,
type: [Function, String] as PropType<KtTableProps['rowClassName']>,
},
rowResizeMaxHeight: { rowResizeMaxHeight: {
default: KT_TABLE_DEFAULT_ROW_RESIZE_MAX_HEIGHT, default: KT_TABLE_DEFAULT_ROW_RESIZE_MAX_HEIGHT,
type: Number, type: Number,

View File

@ -97,6 +97,22 @@
background: hsl(var(--accent)) !important; background: hsl(var(--accent)) !important;
} }
.ant-table-tbody > tr#{kt.$block}__row--clickable {
cursor: pointer;
}
.ant-table-tbody > tr#{kt.$block}__row--active > td,
.ant-table-tbody > tr#{kt.$block}__row--active > td.ant-table-cell-fix-left,
.ant-table-tbody
> tr#{kt.$block}__row--active
> td.ant-table-cell-fix-right,
.ant-table-tbody
> tr#{kt.$block}__row--active
> td.ant-table-cell-fix-start,
.ant-table-tbody > tr#{kt.$block}__row--active > td.ant-table-cell-fix-end {
background: hsl(var(--accent)) !important;
}
.ant-table-summary .ant-table-cell-fix-left, .ant-table-summary .ant-table-cell-fix-left,
.ant-table-summary .ant-table-cell-fix-right, .ant-table-summary .ant-table-cell-fix-right,
.ant-table-summary .ant-table-cell-fix-start, .ant-table-summary .ant-table-cell-fix-start,

View File

@ -9,6 +9,7 @@ import type {
} from '@vben/common-ui'; } from '@vben/common-ui';
export type KtTableRecord = Record<string, any>; export type KtTableRecord = Record<string, any>;
export type KtTableRowKey = number | string;
export type KtTableSize = 'large' | 'middle' | 'small'; export type KtTableSize = 'large' | 'middle' | 'small';
@ -217,6 +218,7 @@ export interface KtTableProps<
result: KtTablePageResult<Row> | Row[], result: KtTablePageResult<Row> | Row[],
context: KtTableContext<Row, SearchValues>, context: KtTableContext<Row, SearchValues>,
) => KtTablePageResult<Row> | Promise<KtTablePageResult<Row> | Row[]> | Row[]; ) => KtTablePageResult<Row> | Promise<KtTablePageResult<Row> | Row[]> | Row[];
activeRowKey?: KtTableRowKey;
beforeFetch?: ( beforeFetch?: (
params: KtTableRecord & SearchValues, params: KtTableRecord & SearchValues,
context: KtTableContext<Row, SearchValues>, context: KtTableContext<Row, SearchValues>,
@ -231,10 +233,17 @@ export interface KtTableProps<
hooks?: Array<KtTableHook<Row, SearchValues>>; hooks?: Array<KtTableHook<Row, SearchValues>>;
immediate?: boolean; immediate?: boolean;
modules?: Array<KtTableModule<Row, SearchValues>>; modules?: Array<KtTableModule<Row, SearchValues>>;
onRowClick?: (
row: Row,
context: KtTableContext<Row, SearchValues>,
) => Promise<void> | void;
pageSize?: number; pageSize?: number;
pageSizeOptions?: string[]; pageSizeOptions?: string[];
rowActions?: Array<KtTableRowAction<Row, SearchValues>>; rowActions?: Array<KtTableRowAction<Row, SearchValues>>;
rowActionVisibleCount?: number; rowActionVisibleCount?: number;
rowClassName?:
| ((row: Row, context: KtTableContext<Row, SearchValues>) => string)
| string;
rowResizeMaxHeight?: number; rowResizeMaxHeight?: number;
rowResizeMinHeight?: number; rowResizeMinHeight?: number;
rowResizable?: boolean; rowResizable?: boolean;
@ -256,15 +265,23 @@ export type KtTableResolvedProps<
Row extends KtTableRecord = KtTableRecord, Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord, SearchValues extends KtTableRecord = KtTableRecord,
> = KtTableProps<Row, SearchValues> & { > = KtTableProps<Row, SearchValues> & {
activeRowKey?: KtTableRowKey;
buttons: Array<KtTableButton<Row, SearchValues>>; buttons: Array<KtTableButton<Row, SearchValues>>;
columns: Array<TableColumnType<Row>>; columns: Array<TableColumnType<Row>>;
hooks: Array<KtTableHook<Row, SearchValues>>; hooks: Array<KtTableHook<Row, SearchValues>>;
immediate: boolean; immediate: boolean;
modules: Array<KtTableModule<Row, SearchValues>>; modules: Array<KtTableModule<Row, SearchValues>>;
onRowClick?: (
row: Row,
context: KtTableContext<Row, SearchValues>,
) => Promise<void> | void;
pageSize: number; pageSize: number;
pageSizeOptions: string[]; pageSizeOptions: string[];
rowActions: Array<KtTableRowAction<Row, SearchValues>>; rowActions: Array<KtTableRowAction<Row, SearchValues>>;
rowActionVisibleCount: number; rowActionVisibleCount: number;
rowClassName?:
| ((row: Row, context: KtTableContext<Row, SearchValues>) => string)
| string;
rowKey: ((row: Row) => string) | keyof Row | string; rowKey: ((row: Row) => string) | keyof Row | string;
rowResizable: boolean; rowResizable: boolean;
rowResizeMaxHeight: number; rowResizeMaxHeight: number;

View File

@ -104,7 +104,9 @@ export default defineComponent({
: 'default' : 'default'
} }
> >
{account.value.connectStatus === 'online' ? '在线' : '离线'} {account.value.connectStatus === 'online'
? 'OneBot 在线'
: 'OneBot 离线'}
</Tag> </Tag>
) : null} ) : null}
</div> </div>

View File

@ -34,6 +34,7 @@ import { KtTable, useKtTable } from '#/components/ktTable';
const AKtTable = KtTable as any; const AKtTable = KtTable as any;
const AButton = Button as any; const AButton = Button as any;
const ATypographyLink = Typography.Link as any; const ATypographyLink = Typography.Link as any;
const ATypographyText = Typography.Text as any;
export default defineComponent({ export default defineComponent({
name: 'QqBotAccountList', name: 'QqBotAccountList',
@ -41,6 +42,7 @@ export default defineComponent({
const editingId = ref<string>(); const editingId = ref<string>();
const router = useRouter(); const router = useRouter();
const scanLoading = ref(false); const scanLoading = ref(false);
const scanQrcodeImageFailed = ref(false);
const scanQrcodeText = ref(''); const scanQrcodeText = ref('');
const scanState = reactive<{ const scanState = reactive<{
containerId?: string; containerId?: string;
@ -61,6 +63,14 @@ export default defineComponent({
margin: 2, margin: 2,
scale: 8, scale: 8,
}); });
const scanQrcodeImageSrc = computed(() => {
const qrcode = scanQrcodeText.value.trim();
if (!qrcode) return '';
if (!scanQrcodeImageFailed.value && isQrcodeImageCandidate(qrcode)) {
return normalizeQrcodeImageSrc(qrcode);
}
return scanQrcode.value;
});
let scanTimer: number | undefined; let scanTimer: number | undefined;
const [AccountForm, accountFormApi] = useVbenForm({ const [AccountForm, accountFormApi] = useVbenForm({
@ -112,31 +122,31 @@ export default defineComponent({
}); });
const columns: Array<TableColumnType<QqbotApi.Account>> = [ const columns: Array<TableColumnType<QqbotApi.Account>> = [
{ dataIndex: 'selfId', key: 'selfId', title: 'Self ID', width: 160 }, { dataIndex: 'selfId', key: 'selfId', title: 'Self ID', width: 140 },
{ dataIndex: 'name', key: 'name', title: '账号名称', width: 180 }, { dataIndex: 'name', key: 'name', title: '账号名称', width: 150 },
{ {
dataIndex: 'connectStatus', dataIndex: 'connectStatus',
key: 'connectStatus', key: 'accountOnlineStatus',
title: '连接状态', title: '账号在线',
width: 120, width: 130,
}, },
{ {
dataIndex: 'clientRole', dataIndex: 'napcat',
key: 'clientRole', key: 'napcatRuntime',
title: '连接角色', title: 'NapCat 容器',
width: 120, width: 220,
}, },
{ {
dataIndex: 'lastHeartbeatAt', dataIndex: 'lastHeartbeatAt',
key: 'lastHeartbeatAt', key: 'lastHeartbeatAt',
title: '最后心跳', title: '最近活动',
width: 190, width: 190,
}, },
{ {
dataIndex: 'lastError', dataIndex: 'lastError',
key: 'lastError', key: 'runtimeSummary',
title: '错误信息', title: '运行说明',
width: 260, width: 220,
}, },
]; ];
@ -230,12 +240,12 @@ export default defineComponent({
componentProps: { componentProps: {
allowClear: true, allowClear: true,
options: [ options: [
{ label: '在线', value: 'online' }, { label: 'OneBot 在线', value: 'online' },
{ label: '离线', value: 'offline' }, { label: 'OneBot 离线', value: 'offline' },
], ],
}, },
fieldName: 'connectStatus', fieldName: 'connectStatus',
label: '连接状态', label: '账号在线',
}, },
], ],
}, },
@ -323,7 +333,11 @@ export default defineComponent({
scanState.sessionId = result.sessionId; scanState.sessionId = result.sessionId;
scanState.status = result.status; scanState.status = result.status;
scanState.webuiPort = result.webuiPort; scanState.webuiPort = result.webuiPort;
scanQrcodeText.value = result.qrcode || ''; const nextQrcode = result.qrcode || '';
if (nextQrcode !== scanQrcodeText.value) {
scanQrcodeImageFailed.value = false;
}
scanQrcodeText.value = nextQrcode;
if (result.status === 'pending') { if (result.status === 'pending') {
startScanPolling(); startScanPolling();
@ -389,6 +403,7 @@ export default defineComponent({
status: 'idle', status: 'idle',
webuiPort: undefined, webuiPort: undefined,
}); });
scanQrcodeImageFailed.value = false;
scanQrcodeText.value = ''; scanQrcodeText.value = '';
} }
@ -431,6 +446,157 @@ export default defineComponent({
return '扫码登录请求失败'; return '扫码登录请求失败';
} }
function isQrcodeImageCandidate(value: string) {
return (
/^data:image\//i.test(value) ||
/^https?:\/\//i.test(value) ||
isRawBase64Image(value)
);
}
function normalizeQrcodeImageSrc(value: string) {
if (isRawBase64Image(value)) {
return `data:image/png;base64,${value}`;
}
return value;
}
function isRawBase64Image(value: string) {
const normalized = value.trim();
return (
normalized.startsWith('iVBORw0KGgo') ||
normalized.startsWith('/9j/') ||
normalized.startsWith('R0lGOD')
);
}
const renderAccountOnlineStatus = (row: QqbotApi.Account) => {
if (!row.enabled) {
return <Tag color="default"></Tag>;
}
return (
<Tag color={row.connectStatus === 'online' ? 'success' : 'default'}>
{row.connectStatus === 'online' ? 'OneBot 在线' : 'OneBot 离线'}
</Tag>
);
};
const renderNapcatRuntime = (row: QqbotApi.Account) => {
const napcat = row.napcat;
const meta = getNapcatStatusMeta(napcat);
return (
<Space direction="vertical" size={2}>
<Tag color={meta.color}>{meta.label}</Tag>
{napcat?.containerName ? (
<ATypographyText type="secondary">
{napcat.containerName}
{napcat.webuiPort ? `:${napcat.webuiPort}` : ''}
</ATypographyText>
) : null}
</Space>
);
};
const renderRecentActivity = (row: QqbotApi.Account) => {
const active = getRecentActivity(row);
return (
<Space direction="vertical" size={2}>
<span>{active.label}</span>
<ATypographyText type="secondary">
{active.time || '暂无记录'}
</ATypographyText>
</Space>
);
};
const renderRuntimeSummary = (row: QqbotApi.Account) => {
const summary = getRuntimeSummary(row);
return (
<ATypographyText
title={summary.text}
type={summary.level === 'warning' ? 'warning' : undefined}
>
{summary.text}
</ATypographyText>
);
};
function getNapcatStatusMeta(
napcat?: null | QqbotApi.AccountNapcatRuntime,
) {
if (!napcat) {
return { color: 'default', label: '未绑定专属容器' };
}
if (!napcat.containerStatus) {
return { color: 'warning', label: '容器记录缺失' };
}
const statusMap: Record<
NonNullable<QqbotApi.AccountNapcatRuntime['containerStatus']>,
{ color: string; label: string }
> = {
creating: { color: 'processing', label: '容器创建中' },
error: { color: 'error', label: '容器异常' },
running: { color: 'success', label: '容器运行中' },
stopped: { color: 'default', label: '容器已停止' },
};
return statusMap[napcat.containerStatus];
}
function getRecentActivity(row: QqbotApi.Account) {
const candidates = [
{ label: '最近心跳', value: row.lastHeartbeatAt },
{ label: '最近连接', value: row.lastConnectedAt },
{ label: '最近扫码登录', value: row.napcat?.lastLoginAt },
{ label: '容器启动', value: row.napcat?.lastStartedAt },
].filter((item) => item.value);
const latest = candidates.toSorted(
(left, right) =>
new Date(right.value || '').getTime() -
new Date(left.value || '').getTime(),
)[0];
return {
label: latest?.label || '暂无活动',
time: latest?.value ? formatDisplayTime(latest.value) : '',
};
}
function getRuntimeSummary(row: QqbotApi.Account) {
if (!row.enabled) {
return { level: 'warning', text: '账号已停用' };
}
if (row.lastError) {
return { level: 'warning', text: `账号异常:${row.lastError}` };
}
if (row.napcat?.lastError) {
return { level: 'warning', text: `容器异常:${row.napcat.lastError}` };
}
if (row.connectStatus === 'online') {
return { level: 'normal', text: '消息链路可用' };
}
if (row.napcat?.containerStatus === 'running') {
return { level: 'warning', text: '等待反向 WS' };
}
if (row.napcat?.containerStatus === 'creating') {
return { level: 'warning', text: '容器创建中' };
}
if (row.napcat?.containerStatus === 'stopped') {
return { level: 'warning', text: '容器已停止' };
}
if (!row.napcat) {
return {
level: 'warning',
text: '可更新登录绑定容器',
};
}
return { level: 'normal', text: '暂无异常记录' };
}
function formatDisplayTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', { hour12: false });
}
function getAccountFormDefaults(): QqbotApi.AccountBody { function getAccountFormDefaults(): QqbotApi.AccountBody {
return { return {
accessToken: '', accessToken: '',
@ -516,16 +682,17 @@ export default defineComponent({
v-slots={{ v-slots={{
bodyCell: ({ column, record }: any) => { bodyCell: ({ column, record }: any) => {
const row = record as QqbotApi.Account; const row = record as QqbotApi.Account;
if (column.key === 'connectStatus') { if (column.key === 'accountOnlineStatus') {
return ( return renderAccountOnlineStatus(row);
<Tag
color={
row.connectStatus === 'online' ? 'success' : 'default'
} }
> if (column.key === 'napcatRuntime') {
{row.connectStatus === 'online' ? '在线' : '离线'} return renderNapcatRuntime(row);
</Tag> }
); if (column.key === 'lastHeartbeatAt') {
return renderRecentActivity(row);
}
if (column.key === 'runtimeSummary') {
return renderRuntimeSummary(row);
} }
return undefined; return undefined;
}, },
@ -579,7 +746,12 @@ export default defineComponent({
{scanQrcodeText.value ? ( {scanQrcodeText.value ? (
<img <img
alt="qqbot-login-qrcode" alt="qqbot-login-qrcode"
src={scanQrcode.value} onError={() => {
if (isQrcodeImageCandidate(scanQrcodeText.value)) {
scanQrcodeImageFailed.value = true;
}
}}
src={scanQrcodeImageSrc.value}
style={{ style={{
background: '#fff', background: '#fff',
borderRadius: '8px', borderRadius: '8px',

View File

@ -125,3 +125,17 @@ export function useGridFormSchema(): VbenFormSchema[] {
}, },
]; ];
} }
export function useGroupFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '如 COMPONENT_TYPE',
},
fieldName: 'keyword',
label: $t('system.dict.dictCode'),
},
];
}

View File

@ -6,22 +6,33 @@ import type {
KtTableApi, KtTableApi,
KtTableButton, KtTableButton,
KtTableContext, KtTableContext,
KtTablePageResult,
KtTableRegisterApi,
KtTableRowAction, KtTableRowAction,
} from '#/components/ktTable'; } from '#/components/ktTable';
import { h } from 'vue'; import { h, nextTick, ref, watch } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui'; import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons'; import { Plus } from '@vben/icons';
import { message, Tag } from 'antdv-next'; import { message, Tag } from 'antdv-next';
import { deleteDict, getDictTree, toggleDictStatus } from '#/api/system/dict'; import {
deleteDict,
getDictGroups,
getDictList,
toggleDictStatus,
} from '#/api/system/dict';
import { KtTable, useKtTable } from '#/components/ktTable'; import { KtTable, useKtTable } from '#/components/ktTable';
import { clearDictCache } from '#/hooks/useDict'; import { clearDictCache } from '#/hooks/useDict';
import { $t } from '#/locales'; import { $t } from '#/locales';
import { getStatusOptions, useGridFormSchema } from './data'; import {
getStatusOptions,
useGridFormSchema,
useGroupFormSchema,
} from './data';
import Form from './modules/form.vue'; import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({ const [FormModal, formModalApi] = useVbenModal({
@ -30,8 +41,26 @@ const [FormModal, formModalApi] = useVbenModal({
}); });
const statusOptions = getStatusOptions(); const statusOptions = getStatusOptions();
const selectedDictCode = ref('');
const itemTableRegistered = ref(false);
const columns: Array<TableColumnType<SystemDictApi.DictTreeItem>> = [ const groupColumns: Array<TableColumnType<SystemDictApi.DictGroup>> = [
{
dataIndex: 'dictCode',
key: 'dictCode',
title: $t('system.dict.dictCode'),
width: 220,
},
{
align: 'right',
dataIndex: 'itemCount',
key: 'itemCount',
title: '项数',
width: 88,
},
];
const columns: Array<TableColumnType<SystemDictApi.DictItem>> = [
{ {
dataIndex: 'dictCode', dataIndex: 'dictCode',
fixed: 'left', fixed: 'left',
@ -79,11 +108,27 @@ const columns: Array<TableColumnType<SystemDictApi.DictTreeItem>> = [
}, },
]; ];
const api: KtTableApi<SystemDictApi.DictTreeItem> = { const groupApi: KtTableApi<SystemDictApi.DictGroup> = {
list: async (params) => await getDictTree(params), list: async (params) => await getDictGroups(params),
}; };
const buttons: Array<KtTableButton<SystemDictApi.DictTreeItem>> = [ const api: KtTableApi<SystemDictApi.DictItem> = {
list: async (params) => {
if (!selectedDictCode.value) {
return {
items: [],
total: 0,
};
}
return await getDictList({
...params,
dictCode: selectedDictCode.value,
});
},
};
const buttons: Array<KtTableButton<SystemDictApi.DictItem>> = [
{ {
icon: () => h(Plus, { class: 'kt-table__button-icon' }), icon: () => h(Plus, { class: 'kt-table__button-icon' }),
key: 'create', key: 'create',
@ -94,7 +139,7 @@ const buttons: Array<KtTableButton<SystemDictApi.DictTreeItem>> = [
}, },
]; ];
const rowActions: Array<KtTableRowAction<SystemDictApi.DictTreeItem>> = [ const rowActions: Array<KtTableRowAction<SystemDictApi.DictItem>> = [
{ {
key: 'toggle', key: 'toggle',
label: $t('system.dict.toggle'), label: $t('system.dict.toggle'),
@ -118,34 +163,130 @@ const rowActions: Array<KtTableRowAction<SystemDictApi.DictTreeItem>> = [
}, },
]; ];
const [registerTable, tableApi] = useKtTable<SystemDictApi.DictTreeItem>({ const [registerGroupTable, groupTableApi] = useKtTable<SystemDictApi.DictGroup>(
{
activeRowKey: selectedDictCode.value,
afterFetch: onGroupAfterFetch,
api: groupApi,
columns: groupColumns,
formOptions: {
formGrid: {
actionMinWidth: 180,
actionSpan: 8,
contentSpan: 16,
fieldSpan: 16,
},
schema: useGroupFormSchema(),
},
onRowClick: onGroupRowClick,
pageSize: 20,
rowKey: 'dictCode',
showIndex: false,
showSelection: false,
showTableSetting: false,
tableTitle: '字典编码',
},
);
const [registerItemTable, tableApi] = useKtTable<SystemDictApi.DictItem>({
api, api,
buttons, buttons,
columns, columns,
formOptions: { formOptions: {
schema: useGridFormSchema(), schema: useGridFormSchema().filter((item) => item.fieldName !== 'dictCode'),
}, },
immediate: false,
rowActions, rowActions,
rowKey: 'treeKey', rowKey: 'id',
showPagination: false, showPagination: true,
tableTitle: $t('system.dict.list'), tableTitle: getItemTableTitle(),
}); });
function getStatusOption(status: SystemDictApi.DictTreeItem['status']) { watch(selectedDictCode, (dictCode) => {
groupTableApi.setProps({
activeRowKey: dictCode,
});
tableApi.setProps({
tableTitle: getItemTableTitle(),
});
});
function getItemTableTitle() {
return selectedDictCode.value
? `字典项:${selectedDictCode.value}`
: '字典项';
}
function getStatusOption(status: SystemDictApi.DictItem['status']) {
return statusOptions.find((item) => item.value === status); return statusOptions.find((item) => item.value === status);
} }
function onCreate() { function normalizeGroupRows(
formModalApi.setData(undefined).open(); result:
| KtTablePageResult<SystemDictApi.DictGroup>
| SystemDictApi.DictGroup[],
) {
if (Array.isArray(result)) return result;
return result.items || result.list || result.records || [];
} }
function onEdit(row: SystemDictApi.DictTreeItem) { async function onGroupAfterFetch(
result:
| KtTablePageResult<SystemDictApi.DictGroup>
| SystemDictApi.DictGroup[],
) {
const rows = normalizeGroupRows(result);
const selectedExists = rows.some(
(item) => item.dictCode === selectedDictCode.value,
);
if (!selectedExists) {
selectedDictCode.value = rows[0]?.dictCode || '';
}
await reloadItemTable();
return result;
}
async function onGroupRowClick(row: SystemDictApi.DictGroup) {
if (selectedDictCode.value === row.dictCode) return;
selectedDictCode.value = row.dictCode;
await reloadItemTable();
}
function onItemTableRegister(api: KtTableRegisterApi<SystemDictApi.DictItem>) {
registerItemTable(api);
itemTableRegistered.value = true;
reloadItemTable();
}
async function reloadItemTable() {
if (!itemTableRegistered.value) return;
await nextTick();
await tableApi.search();
}
function onCreate() {
formModalApi
.setData(
selectedDictCode.value
? {
dictCode: selectedDictCode.value,
}
: undefined,
)
.open();
}
function onEdit(row: SystemDictApi.DictItem) {
formModalApi.setData(row).open(); formModalApi.setData(row).open();
} }
async function onToggle( async function onToggle(
row: SystemDictApi.DictTreeItem, row: SystemDictApi.DictItem,
context: KtTableContext<SystemDictApi.DictTreeItem>, context: KtTableContext<SystemDictApi.DictItem>,
) { ) {
const nextStatus = row.status === 1 ? 0 : 1; const nextStatus = row.status === 1 ? 0 : 1;
await toggleDictStatus(row.id, nextStatus); await toggleDictStatus(row.id, nextStatus);
@ -159,8 +300,8 @@ async function onToggle(
} }
async function onDelete( async function onDelete(
row: SystemDictApi.DictTreeItem, row: SystemDictApi.DictItem,
context?: KtTableContext<SystemDictApi.DictTreeItem>, context?: KtTableContext<SystemDictApi.DictItem>,
) { ) {
const hideLoading = message.loading({ const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.label]), content: $t('ui.actionMessage.deleting', [row.label]),
@ -176,20 +317,27 @@ async function onDelete(
key: 'action_process_msg', key: 'action_process_msg',
}); });
await (context || tableApi).reload(); await (context || tableApi).reload();
await groupTableApi.reload();
} catch { } catch {
hideLoading(); hideLoading();
} }
} }
function onRefresh() { async function onRefresh() {
tableApi.reload(); await groupTableApi.reload();
await tableApi.reload();
} }
</script> </script>
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<FormModal @success="onRefresh" /> <FormModal @success="onRefresh" />
<KtTable @register="registerTable"> <div class="dict-page">
<section class="dict-page__groups">
<KtTable @register="registerGroupTable" />
</section>
<section class="dict-page__items">
<KtTable @register="onItemTableRegister">
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'childrenCode'"> <template v-if="column.key === 'childrenCode'">
{{ record.childrenCode || '-' }} {{ record.childrenCode || '-' }}
@ -201,5 +349,29 @@ function onRefresh() {
</template> </template>
</template> </template>
</KtTable> </KtTable>
</section>
</div>
</Page> </Page>
</template> </template>
<style scoped>
.dict-page {
display: grid;
grid-template-columns: minmax(460px, 520px) minmax(0, 1fr);
gap: 12px;
height: 100%;
min-height: 0;
}
.dict-page__groups,
.dict-page__items {
min-width: 0;
min-height: 0;
}
@media (max-width: 1024px) {
.dict-page {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -18,7 +18,7 @@ const emit = defineEmits<{
success: []; success: [];
}>(); }>();
const formData = ref<SystemDictApi.DictItem>(); const formData = ref<Partial<SystemDictApi.DictItem>>();
const getTitle = computed(() => { const getTitle = computed(() => {
return formData.value?.id return formData.value?.id
? $t('ui.actionTitle.edit', [$t('system.dict.name')]) ? $t('ui.actionTitle.edit', [$t('system.dict.name')])
@ -51,7 +51,7 @@ const [Modal, modalApi] = useVbenModal({
}, },
onOpenChange(isOpen) { onOpenChange(isOpen) {
if (!isOpen) return; if (!isOpen) return;
const data = modalApi.getData<SystemDictApi.DictItem>(); const data = modalApi.getData<Partial<SystemDictApi.DictItem>>();
formData.value = data || undefined; formData.value = data || undefined;
resetForm(); resetForm();
}, },