feat: 保存KtTable当前稳定版本

This commit is contained in:
sunlei 2026-05-19 21:39:08 +08:00
parent 961098bddc
commit eb77dff6a6
43 changed files with 6247 additions and 841 deletions

View File

@ -26,6 +26,7 @@
"#/*": "./src/*"
},
"dependencies": {
"@antdv-next/icons": "catalog:",
"@tanstack/vue-query": "catalog:",
"@vben-core/menu-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",

View File

@ -40,6 +40,15 @@ export namespace WordpressBlogApi {
title: string;
}
export interface ArticleQuery extends Recordable<any> {
categories?: number[] | string;
pageNo?: number;
pageSize?: number;
search?: string;
status?: string;
tags?: number[] | string;
}
export interface Term {
count?: number;
description?: string;
@ -66,7 +75,7 @@ export namespace WordpressBlogApi {
}
}
export function getArticleList(params: Recordable<any>) {
export function getArticleList(params: WordpressBlogApi.ArticleQuery) {
return requestClient.get<
WordpressBlogApi.PageResult<WordpressBlogApi.Article>
>('/wordpress/article/list', { params });

View File

@ -21,6 +21,10 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'SystemDeptCreate',
'SystemDeptDelete',
'SystemDeptEdit',
'SystemKtTableDemo',
'SystemKtTableDemoCreate',
'SystemKtTableDemoDelete',
'SystemKtTableDemoEdit',
'SystemMenu',
'SystemMenuCreate',
'SystemMenuDelete',
@ -61,6 +65,6 @@ export async function getAllMenusApi() {
const menus =
await requestClient.get<RouteRecordStringComponent[]>('/menu/all');
// 只暴露当前后端真实接口已经支撑的后台菜单,模板演示入口等后续补接口后再放开
// 只暴露当前前端页面和后端接口已经支撑的后台菜单。
return filterSupportedAdminMenus(menus);
}

View File

@ -12,7 +12,7 @@ export default defineComponent({
name: 'App',
setup() {
const { isDark } = usePreferences();
const { tokens } = useAntdDesignTokens();
const { components, tokens } = useAntdDesignTokens();
const tokenTheme = computed(() => {
const algorithm = isDark.value
@ -25,6 +25,7 @@ export default defineComponent({
return {
algorithm,
components,
token: tokens,
};
});

View File

@ -0,0 +1,672 @@
import type { VNodeChild } from 'vue';
import type {
KtTableContext,
KtTableProps,
KtTableRecord,
KtTableRegisterApi,
KtTableRowAction,
KtTableSize,
} from './types';
import {
computed,
defineComponent,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { ChevronDown } from '@vben/icons';
import { EllipsisOutlined } from '@antdv-next/icons';
import { Button, Popover, Space, Table } from 'antdv-next';
import KtTableFooter from './components/KtTableFooter';
import KtTableHeader from './components/KtTableHeader';
import KtTableResizableTitle from './components/KtTableResizableTitle';
import KtTableSearch from './components/KtTableSearch';
import KtTableSettings from './components/KtTableSettings';
import { renderKtTableSummary } from './components/KtTableSummary';
import {
KT_TABLE_ACTION_COLUMN_KEY,
KT_TABLE_INDEX_COLUMN_KEY,
KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT,
KT_TABLE_ROW_ACTION_VISIBLE_COUNT,
} from './config/constants';
import { DEFAULT_TABLE_SETTING, ktTableProps } from './config/ktTableProps';
import { useKtTableActions } from './hooks/useKtTableActions';
import { useKtTableColumns } from './hooks/useKtTableColumns';
import { useKtTableForm } from './hooks/useKtTableForm';
import { useKtTableRuntimeHooks } from './hooks/useKtTableHooks';
import { useKtTableLayout } from './hooks/useKtTableLayout';
import { useKtTablePermission } from './hooks/useKtTablePermission';
import { useKtTableResolvedProps } from './hooks/useKtTableResolvedProps';
import { useKtTableSelection } from './hooks/useKtTableSelection';
import { normalizePageResult } from './utils/index';
import './style.scss';
const AButton = Button as any;
const APopover = Popover as any;
const ASpace = Space as any;
const ATable = Table as any;
const tableComponents = {
header: {
cell: KtTableResizableTitle,
},
};
type SortState = {
field?: string;
order?: string;
};
type LoadOptions = {
validateForm?: boolean;
};
export default defineComponent({
name: 'KtTable',
props: ktTableProps,
emits: ['register'],
/**
* KtTable API
*
* @param rawProps props register
* @param emit Vue setup context
* @param emit.emit Vue register API
* @param emit.expose Vue ref 访 API
* @param emit.slots titletoolbarbodyCellsummaryfooter
*/
setup(rawProps, { emit, expose, slots }) {
const { props, setProps } = useKtTableResolvedProps(
rawProps as KtTableProps,
);
const loading = ref(false);
const rows = ref<KtTableRecord[]>([]);
const sortState = reactive<SortState>({});
const pagination = reactive({
current: 1,
pageSize: props.pageSize,
total: 0,
});
const fullscreen = ref(false);
const searchCollapsed = ref(false);
const searchVisible = ref(true);
const tableSize = ref<KtTableSize>(props.size);
const mounted = ref(false);
const autoLoaded = ref(false);
const {
formApi,
formGrid,
formOptions,
getSearchValues,
resetForm,
SearchForm,
setSearchValues,
} = useKtTableForm(props);
const { registerHook, runHook, unregisterHook } =
useKtTableRuntimeHooks(props);
const { clearSelection, rowSelection, selectedRowKeys, selectedRows } =
useKtTableSelection(props);
const api = computed(
() =>
props.api || props.modules.find((module) => !!module.api)?.api || null,
);
const tableSetting = computed(() => ({
...DEFAULT_TABLE_SETTING,
...props.tableSettings,
}));
const statistics = computed(() => [
...props.statistics,
...props.modules.flatMap((module) => module.statistics || []),
]);
const hasSummary = computed(
() => statistics.value.length > 0 || !!slots.summary,
);
const {
handleSearchTransitionEnd,
handleSearchTransitionStart,
scheduleTableLayout,
tableBodyRef,
tableScrollY,
} = useKtTableLayout({ hasSummary });
const context: KtTableContext = {
formApi,
getRows: () => rows.value,
getSearchValues,
registerHook,
reload,
reset,
search,
selectedRowKeys: () => selectedRowKeys.value,
selectedRows: () => selectedRows.value,
setSearchValues,
unregisterHook,
};
const registerApi: KtTableRegisterApi = {
...context,
getProps: () => ({ ...props }),
setProps,
};
emit('register', registerApi);
const permissions = useKtTablePermission(context);
const {
formButtons,
headerButtons,
renderButton,
renderRowAction,
rowActions,
} = useKtTableActions({
context,
permissions,
props,
reload,
reset,
runHook,
search,
});
const {
columnOrderKeys,
columns,
reorderColumns,
resetColumns,
sourceColumns,
tableScrollX,
visibleColumnKeys,
} = useKtTableColumns({
props,
rowActions,
scheduleTableLayout,
});
watch(
() => props.size,
(size) => {
tableSize.value = size;
},
{
immediate: true,
},
);
watch(
() => props.pageSize,
(pageSize) => {
pagination.pageSize = pageSize;
},
{
immediate: true,
},
);
watch(
() => props.dataSource,
(dataSource) => {
if (!api.value?.list && Array.isArray(dataSource)) {
rows.value = dataSource;
pagination.total = dataSource.length;
}
},
{
immediate: true,
},
);
watch(
[searchCollapsed, formOptions],
([collapsed]) => {
formApi.setState({ collapsed, showCollapseButton: true });
},
{
immediate: true,
},
);
/**
*
*
* @param index Antdv Table 0
*/
function resolveRowIndex(index: number) {
if (!props.showPagination) return index + 1;
return (pagination.current - 1) * pagination.pageSize + index + 1;
}
/**
* Antdv bodyCell slot index
*
* @param record
* @param index Antdv Table
*/
function resolveRecordIndex(record: KtTableRecord, index?: number) {
const rowIndex =
typeof index === 'number' ? index : rows.value.indexOf(record);
return resolveRowIndex(Math.max(rowIndex, 0));
}
/**
*
*
* @param options
*/
async function getFetchParams(options: LoadOptions = {}) {
if (options.validateForm) {
const { valid } = await formApi.validate();
if (!valid) return null;
}
return {
...(await getSearchValues()),
pageNo: props.showPagination ? pagination.current : undefined,
pageSize: props.showPagination ? pagination.pageSize : undefined,
sortField: sortState.field,
sortOrder: sortState.order,
};
}
/**
* dataSource
*
* @param options
*/
async function loadData(options: LoadOptions = {}) {
if (!api.value?.list) {
const list = props.dataSource || [];
rows.value = list;
pagination.total = list.length;
return;
}
const rawParams = await getFetchParams(options);
if (!rawParams) return;
const params =
((await props.beforeFetch?.(rawParams, context)) as KtTableRecord) ||
rawParams;
loading.value = true;
try {
await runHook('onBeforeFetch', params, context);
const result = await api.value.list(params, context);
const afterResult =
(await props.afterFetch?.(result, context)) || result;
const normalized = normalizePageResult(afterResult);
rows.value = normalized.list;
pagination.total = normalized.total;
clearSelection();
await runHook('onAfterFetch', afterResult, context);
} catch (error) {
await runHook('onFetchError', error, context);
throw error;
} finally {
loading.value = false;
}
}
/**
* register api
*/
async function autoLoadData() {
if (!props.immediate || autoLoaded.value || !api.value?.list) return;
// register 模式下 api 可能晚于 mounted 合并,首次自动加载要等 api 真正可用。
autoLoaded.value = true;
await loadData();
}
/**
*
*/
async function search() {
pagination.current = 1;
await loadData({ validateForm: true });
}
/**
*
*/
async function reset() {
await resetForm();
pagination.current = 1;
await loadData();
}
/**
*
*/
async function reload() {
await loadData();
}
/**
* Antdv Table
*
* @param sorter Antdv Table
*/
function readSorter(sorter: KtTableRecord | KtTableRecord[]) {
const currentSorter = Array.isArray(sorter) ? sorter[0] : sorter;
sortState.field = currentSorter?.field || currentSorter?.columnKey;
sortState.order = currentSorter?.order;
}
/**
* Antdv Table //
*
* @param _tablePagination Antdv Table KtTable 使
* @param _filters Antdv Table
* @param sorter Antdv Table KtTable
*/
function handleTableChange(
_tablePagination: KtTableRecord,
_filters: KtTableRecord,
sorter: KtTableRecord | KtTableRecord[],
) {
pagination.current = 1;
readSorter(sorter);
loadData();
}
/**
*
*
* @param pageInfo
*/
function handlePageChange(pageInfo: KtTableRecord) {
pagination.current = pageInfo.current || 1;
pagination.pageSize = pageInfo.pageSize || props.pageSize;
loadData();
}
/**
*
*/
function renderSearchArea() {
const hasSearch = (formOptions.value.schema?.length || 0) > 0;
const hasFormButtons = formButtons.value.length > 0;
const hasCollapse =
hasSearch && (formOptions.value.schema?.length || 0) > 4;
const visible = hasSearch && searchVisible.value;
if (!visible) return null;
return (
<KtTableSearch
collapsed={searchCollapsed.value}
formGrid={formGrid.value}
onTransitionEnd={handleSearchTransitionEnd}
onTransitionStart={handleSearchTransitionStart}
visible
>
{{
actions: () =>
hasFormButtons || hasCollapse ? (
<div class="kt-table__search-action-stack">
{formButtons.value.map((button) => renderButton(button))}
{hasCollapse ? (
<AButton
class="kt-table__search-toggle"
onClick={() => {
searchCollapsed.value = !searchCollapsed.value;
}}
type="link"
>
<ChevronDown
class={[
'kt-table__search-toggle-icon',
searchCollapsed.value
? ''
: 'kt-table__search-toggle-icon--expanded',
]}
/>
{searchCollapsed.value ? '展开' : '收起'}
</AButton>
) : null}
</div>
) : null,
form: () => <SearchForm />,
}}
</KtTableSearch>
);
}
/**
*
*
* @param record
*/
function renderActionCell(record: KtTableRecord) {
const { inlineActions, overflowActions } = splitRowActions(
rowActions.value,
);
return (
<ASpace class="kt-table__row-actions" size={1}>
{inlineActions.map((action) => renderRowAction(action, record))}
{overflowActions.length > 0 ? (
<APopover
overlayClassName="kt-table__row-action-popover"
placement="bottomRight"
trigger="click"
>
{{
content: () => (
<div class="kt-table__row-action-popover-content">
{overflowActions.map((action) =>
renderRowAction(action, record),
)}
</div>
),
default: () => (
<AButton
aria-label="更多操作"
class="kt-table__row-action-more"
type="link"
>
<EllipsisOutlined class="kt-table__row-action-more-icon" />
</AButton>
),
}}
</APopover>
) : null}
</ASpace>
);
}
/**
*
*
* @param actions
*/
function splitRowActions(actions: KtTableRowAction[]) {
if (actions.length <= KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT) {
return {
inlineActions: actions,
overflowActions: [],
};
}
return {
inlineActions: actions.slice(0, KT_TABLE_ROW_ACTION_VISIBLE_COUNT),
overflowActions: actions.slice(KT_TABLE_ROW_ACTION_VISIBLE_COUNT),
};
}
/**
* toolbar
*/
function renderHeaderButtons() {
const toolbar = slots.toolbar?.(context);
const buttons = headerButtons.value.map((button) => renderButton(button));
if (!toolbar && buttons.length === 0) return null;
return (
<ASpace wrap>
{buttons}
{toolbar}
</ASpace>
);
}
/**
*
*/
function renderHeaderSettings() {
if (!props.showTableSetting) return null;
return (
<KtTableSettings
columnOrderKeys={columnOrderKeys.value}
columns={sourceColumns.value}
fullscreen={fullscreen.value}
onColumnOrderKeysChange={(keys: string[]) => {
reorderColumns(keys);
}}
onFullscreenChange={(value: boolean) => {
fullscreen.value = value;
}}
onReload={reload}
onResetColumns={resetColumns}
onSearchVisibleChange={(value: boolean) => {
searchVisible.value = value;
}}
onSizeChange={(value: KtTableSize) => {
tableSize.value = value;
}}
onVisibleColumnKeysChange={(keys: string[]) => {
visibleColumnKeys.value = keys;
}}
searchVisible={searchVisible.value}
setting={tableSetting.value}
size={tableSize.value}
visibleColumnKeys={visibleColumnKeys.value}
/>
);
}
expose(registerApi);
onMounted(() => {
mounted.value = true;
autoLoadData();
});
watch(api, () => {
if (mounted.value) {
autoLoadData();
}
});
watch(
[columns, rows, searchVisible, fullscreen, tableSize],
() => {
scheduleTableLayout();
},
{
deep: true,
},
);
return () => (
<div class={['kt-table', fullscreen.value ? 'kt-table--fullscreen' : '']}>
<div class="kt-table__main">
{renderSearchArea()}
<div class="kt-table__main-content">
{props.showHeader ? (
<KtTableHeader title={props.tableTitle}>
{{
settings: renderHeaderSettings,
title: () => slots.title?.(),
toolbar: renderHeaderButtons,
}}
</KtTableHeader>
) : null}
<div
class="kt-table__body"
ref={tableBodyRef}
style={{
'--kt-table-scroll-y': `${tableScrollY.value}px`,
}}
>
<ATable
class="kt-table__ant"
columns={columns.value}
components={tableComponents}
dataSource={rows.value}
loading={loading.value}
onChange={handleTableChange}
pagination={false}
rowKey={props.rowKey}
rowSelection={rowSelection.value}
scroll={{ x: tableScrollX.value, y: tableScrollY.value }}
size={tableSize.value}
v-slots={{
bodyCell: ({ column, index, record }: any): VNodeChild => {
if (column.key === KT_TABLE_INDEX_COLUMN_KEY) {
return resolveRecordIndex(record, index);
}
if (column.key === KT_TABLE_ACTION_COLUMN_KEY) {
return renderActionCell(record);
}
return slots.bodyCell?.({ column, record });
},
summary: (): VNodeChild =>
hasSummary.value
? renderKtTableSummary({
columns: columns.value,
context,
customSummary: slots.summary?.({
columns: columns.value,
context,
rows: rows.value,
}),
showSelection: props.showSelection,
statistics: statistics.value,
})
: null,
}}
/>
</div>
{props.showFooter ? (
<KtTableFooter
current={pagination.current}
onPageChange={handlePageChange}
pageSize={pagination.pageSize}
pageSizeOptions={props.pageSizeOptions}
selectedCount={selectedRowKeys.value.length}
showPagination={props.showPagination}
showSelection={props.showSelection}
total={pagination.total}
>
{{
default: () =>
slots.footer?.({
context,
selectedRowKeys: selectedRowKeys.value,
selectedRows: selectedRows.value,
}),
}}
</KtTableFooter>
) : null}
</div>
</div>
</div>
);
},
});

View File

@ -0,0 +1,95 @@
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { Pagination } from 'antdv-next';
const APagination = Pagination as any;
export default defineComponent({
name: 'KtTableFooter',
props: {
current: {
default: 1,
type: Number,
},
pageSize: {
default: 10,
type: Number,
},
pageSizeOptions: {
default: () => ['10', '20', '50', '100'],
type: Array as PropType<string[]>,
},
selectedCount: {
default: 0,
type: Number,
},
showPagination: {
default: true,
type: Boolean,
},
showSelection: {
default: false,
type: Boolean,
},
total: {
default: 0,
type: Number,
},
},
emits: ['pageChange'],
/**
*
*
* @param props
* @param emit Vue setup context
* @param emit.emit
* @param emit.slots
*/
setup(props, { emit, slots }) {
/**
* Antdv Pagination KtTable
*
* @param current
* @param pageSize
*/
function handlePageChange(current: number, pageSize: number) {
emit('pageChange', { current, pageSize });
}
return () => (
<div class="kt-table__footer">
<div class="kt-table__footer-settings">
{props.showSelection ? (
<span class="kt-table__footer-selection">
{props.selectedCount > 0 ? (
<>
<span class="kt-table__footer-selection-count">
{props.selectedCount}
</span>
</>
) : (
'选中激活'
)}
</span>
) : null}
{slots.default?.()}
</div>
{props.showPagination ? (
<APagination
current={props.current}
onChange={handlePageChange}
pageSize={props.pageSize}
pageSizeOptions={props.pageSizeOptions}
showSizeChanger
showTotal={(total: number) => `${total}`}
total={props.total}
/>
) : null}
</div>
);
},
});

View File

@ -0,0 +1,62 @@
import { Comment, defineComponent, isVNode } from 'vue';
import { Divider } from 'antdv-next';
const ADivider = Divider as any;
/**
*
*
* @param title
*/
function isEmptyTitleSlot(title: unknown) {
return (
Array.isArray(title) &&
title.every(
(item) => item === null || (isVNode(item) && item.type === Comment),
)
);
}
export default defineComponent({
name: 'KtTableHeader',
props: {
title: {
default: undefined,
type: String,
},
},
/**
*
*
* @param props props
* @param slots Vue setup context
* @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?.();
if (!title && !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>
) : null}
</div>
);
};
},
});

View File

@ -0,0 +1,139 @@
import type { PropType } from 'vue';
import { defineComponent, h, onBeforeUnmount, ref } from 'vue';
export interface KtTableResizeInfo {
size: {
width: number;
};
}
type KtTableResizableTitleProps = {
onResize?: (event: MouseEvent, info: KtTableResizeInfo) => void;
width?: number;
};
export default defineComponent({
name: 'KtTableResizableTitle',
inheritAttrs: false,
props: {
onResize: {
default: undefined,
type: Function as PropType<KtTableResizableTitleProps['onResize']>,
},
width: {
default: undefined,
type: Number,
},
},
/**
*
*
* @param props
* @param attrs Vue setup context
* @param attrs.attrs Antdv Table th
* @param attrs.slots
*/
setup(props, { attrs, slots }) {
const dragging = ref(false);
const stopNextClick = ref(false);
let startWidth = 0;
let startX = 0;
/**
*
*/
function stopDragging() {
dragging.value = false;
document.body.classList.remove('kt-table--column-resizing');
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
/**
*
*
* @param event
*/
function handleMouseMove(event: MouseEvent) {
if (!dragging.value) return;
stopNextClick.value = true;
const nextWidth = Math.max(startWidth + event.clientX - startX, 40);
props.onResize?.(event, { size: { width: nextWidth } });
}
/**
*
*/
function handleMouseUp() {
stopDragging();
window.setTimeout(() => {
stopNextClick.value = false;
}, 0);
}
/**
*
*
* @param event
*/
function handleMouseDown(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
const target = event.currentTarget as HTMLElement;
const headerCell = target.parentElement;
dragging.value = true;
stopNextClick.value = false;
startX = event.clientX;
startWidth = props.width || headerCell?.offsetWidth || 0;
document.body.classList.add('kt-table--column-resizing');
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
/**
*
*
* @param event
*/
function handleClickCapture(event: MouseEvent) {
if (!stopNextClick.value) return;
event.stopPropagation();
event.preventDefault();
stopNextClick.value = false;
}
onBeforeUnmount(stopDragging);
return () => {
if (!props.width) {
return h('th', attrs, slots.default?.());
}
return h(
'th',
{
...attrs,
class: ['kt-table__resizable-title', attrs.class],
onClickCapture: handleClickCapture,
style: {
...(attrs.style as Record<string, unknown> | undefined),
width: `${props.width}px`,
},
},
[
slots.default?.(),
h('span', {
class: 'kt-table__resizable-handle',
onMousedown: handleMouseDown,
}),
],
);
};
},
});

View File

@ -0,0 +1,243 @@
import type { PropType } from 'vue';
import type { KtTableFormGridOptions } from '../types';
import {
computed,
defineComponent,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import { KT_TABLE_DEFAULT_FORM_GRID } from '../config/constants';
const SEARCH_TRANSITION_DURATION = 220;
export default defineComponent({
name: 'KtTableSearch',
props: {
collapsed: {
default: false,
type: Boolean,
},
visible: {
default: true,
type: Boolean,
},
formGrid: {
default: () => KT_TABLE_DEFAULT_FORM_GRID,
type: Object as PropType<KtTableFormGridOptions>,
},
},
emits: ['transitionEnd', 'transitionStart'],
/**
* /
*
* @param props
* @param emit Vue setup context
* @param emit.emit
* @param emit.slots
*/
setup(props, { emit, slots }) {
const shellRef = ref<HTMLElement | null>(null);
const contentRef = ref<HTMLElement | null>(null);
const shellHeight = ref<number>();
const transitioning = ref(false);
let initialized = false;
let resizeObserver: ResizeObserver | undefined;
let transitionTimer: number | undefined;
let animationFrame: number | undefined;
const gridStyle = computed(() => {
const grid = props.formGrid;
return {
'--kt-table-form-action-fr': `${grid.actionSpan}fr`,
'--kt-table-form-action-min-width': `${grid.actionMinWidth}px`,
'--kt-table-form-action-span': String(grid.actionSpan),
'--kt-table-form-content-fr': `${grid.contentSpan}fr`,
'--kt-table-form-content-span': String(grid.contentSpan),
'--kt-table-form-tablet-columns': String(grid.tabletColumns),
'--kt-table-form-total-span': String(grid.totalSpan),
};
});
/**
*
*/
function readContentHeight() {
const content = contentRef.value;
return content ? Math.ceil(content.getBoundingClientRect().height) : 0;
}
/**
*
*/
function readShellHeight() {
const shell = shellRef.value;
return shell ? Math.ceil(shell.getBoundingClientRect().height) : 0;
}
/**
*
*/
function clearTransitionTimer() {
if (transitionTimer) {
window.clearTimeout(transitionTimer);
transitionTimer = undefined;
}
}
/**
* requestAnimationFrame
*/
function clearAnimationFrame() {
if (animationFrame) {
window.cancelAnimationFrame(animationFrame);
animationFrame = undefined;
}
}
/**
*
*/
function endTransition() {
if (!transitioning.value && shellHeight.value === undefined) return;
clearTransitionTimer();
clearAnimationFrame();
transitioning.value = false;
shellHeight.value = undefined;
emit('transitionEnd');
}
/**
*
*/
function prepareTransition() {
const shell = shellRef.value;
if (!shell) return false;
const currentHeight = readShellHeight();
if (!initialized) {
initialized = true;
return false;
}
clearAnimationFrame();
clearTransitionTimer();
transitioning.value = false;
shellHeight.value = currentHeight;
emit('transitionStart');
return true;
}
/**
*
*/
async function animateToNextHeight() {
if (!prepareTransition()) return;
await nextTick();
await nextTick();
const shell = shellRef.value;
const currentHeight = shellHeight.value ?? readShellHeight();
const targetHeight = readContentHeight();
if (!shell || Math.abs(currentHeight - targetHeight) <= 1) {
endTransition();
return;
}
transitioning.value = true;
await nextTick();
animationFrame = window.requestAnimationFrame(() => {
animationFrame = undefined;
shellHeight.value = targetHeight;
transitionTimer = window.setTimeout(
endTransition,
SEARCH_TRANSITION_DURATION + 80,
);
});
}
/**
*
*/
function syncContentHeight() {
if (
!initialized ||
transitioning.value ||
shellHeight.value !== undefined
) {
return;
}
shellHeight.value = undefined;
}
onMounted(() => {
if (contentRef.value) {
resizeObserver = new ResizeObserver(syncContentHeight);
resizeObserver.observe(contentRef.value);
}
initialized = true;
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
clearTransitionTimer();
clearAnimationFrame();
});
watch(
() => props.collapsed,
() => {
void animateToNextHeight();
},
{
flush: 'pre',
},
);
return () =>
props.visible ? (
<div class="kt-table__search" style={gridStyle.value}>
<div
class={[
'kt-table__search-content-shell',
transitioning.value
? 'kt-table__search-content-shell--transitioning'
: '',
]}
onTransitionend={(event: TransitionEvent) => {
if (
event.currentTarget === event.target &&
event.propertyName === 'height'
) {
endTransition();
}
}}
ref={shellRef}
style={{
height:
shellHeight.value === undefined
? undefined
: `${shellHeight.value}px`,
}}
>
<div class="kt-table__search-content" ref={contentRef}>
<div class="kt-table__search-form">{slots.form?.()}</div>
<div class="kt-table__search-actions">{slots.actions?.()}</div>
</div>
</div>
<div class="kt-table__search-split" />
</div>
) : null;
},
});

View File

@ -0,0 +1,404 @@
import type { TableColumnType } from 'antdv-next';
import type { PropType, VNodeChild } from 'vue';
import type { KtTableRecord, KtTableSetting, KtTableSize } from '../types';
import { computed, defineComponent, ref } from 'vue';
import {
Eye,
EyeOff,
Fullscreen,
IconifyIcon,
Menu,
Minimize2,
RotateCw,
Settings,
} from '@vben/icons';
import { Button, Checkbox, Popover, Space, Tooltip } from 'antdv-next';
import { getColumnKey } from '../utils/index';
const AButton = Button as any;
const ACheckbox = Checkbox as any;
const APopover = Popover as any;
const ASpace = Space as any;
const ATooltip = Tooltip as any;
const SIZE_LABEL: Record<KtTableSize, string> = {
large: '宽松',
middle: '默认',
small: '紧凑',
};
const SIZE_LIST: KtTableSize[] = ['small', 'middle', 'large'];
export default defineComponent({
name: 'KtTableSettings',
props: {
columns: {
default: () => [],
type: Array as PropType<Array<TableColumnType<KtTableRecord>>>,
},
columnOrderKeys: {
default: () => [],
type: Array as PropType<string[]>,
},
fullscreen: {
default: false,
type: Boolean,
},
searchVisible: {
default: true,
type: Boolean,
},
setting: {
default: () => ({}),
type: Object as PropType<KtTableSetting>,
},
size: {
default: 'middle',
type: String as PropType<KtTableSize>,
},
visibleColumnKeys: {
default: () => [],
type: Array as PropType<string[]>,
},
},
emits: [
'columnOrderKeysChange',
'fullscreenChange',
'reload',
'resetColumns',
'searchVisibleChange',
'sizeChange',
'visibleColumnKeysChange',
],
/**
* KtTable
*
* @param props
* @param emit Vue setup context
* @param emit.emit
*/
setup(props, { emit }) {
const draggingColumnKey = ref('');
const dragOverColumnKey = ref('');
const dragInsertPosition = ref<'after' | 'before'>('before');
const sourceColumnOptions = computed(() =>
props.columns
.map((column) => ({
key: getColumnKey(column),
title: String(column.title || getColumnKey(column)),
}))
.filter((item) => !!item.key),
);
const columnOptions = computed(() => {
const optionMap = new Map(
sourceColumnOptions.value.map((item) => [item.key, item]),
);
const orderedKeys = props.columnOrderKeys.filter((key) =>
optionMap.has(key),
);
const restKeys = sourceColumnOptions.value
.map((item) => item.key)
.filter((key) => !orderedKeys.includes(key));
return [...orderedKeys, ...restKeys]
.map((key) => optionMap.get(key))
.filter((item) => !!item);
});
/**
*
*
* @param key
* @param checked true false
*/
function toggleColumn(key: string, checked: boolean) {
if (!checked) {
emit(
'visibleColumnKeysChange',
props.visibleColumnKeys.filter((item) => item !== key),
);
return;
}
emit(
'visibleColumnKeysChange',
props.visibleColumnKeys.includes(key)
? [...props.visibleColumnKeys]
: [...props.visibleColumnKeys, key],
);
}
/**
*
*/
function clearColumnDragState() {
draggingColumnKey.value = '';
dragOverColumnKey.value = '';
dragInsertPosition.value = 'before';
}
/**
*
*
* @param event
*/
function readDropPosition(event: DragEvent) {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
return event.clientY > rect.top + rect.height / 2 ? 'after' : 'before';
}
/**
*
*
* @param sourceKey
* @param targetKey
*/
function moveColumn(sourceKey: string, targetKey: string) {
if (!sourceKey || !targetKey || sourceKey === targetKey) return;
const nextKeys = columnOptions.value.map((item) => item.key);
const sourceIndex = nextKeys.indexOf(sourceKey);
const targetIndex = nextKeys.indexOf(targetKey);
if (sourceIndex === -1 || targetIndex === -1) return;
const [movedKey] = nextKeys.splice(sourceIndex, 1);
if (!movedKey) return;
const nextTargetIndex = nextKeys.indexOf(targetKey);
nextKeys.splice(
dragInsertPosition.value === 'after'
? nextTargetIndex + 1
: nextTargetIndex,
0,
movedKey,
);
emit('columnOrderKeysChange', nextKeys);
}
/**
*
*
* @param key
* @param event
*/
function handleColumnDragStart(key: string, event: DragEvent) {
draggingColumnKey.value = key;
dragOverColumnKey.value = key;
event.dataTransfer?.setData('text/plain', key);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
}
/**
*
*
* @param key
* @param event
*/
function handleColumnDragOver(key: string, event: DragEvent) {
if (!draggingColumnKey.value || draggingColumnKey.value === key) return;
event.preventDefault();
dragOverColumnKey.value = key;
dragInsertPosition.value = readDropPosition(event);
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
/**
*
*
* @param key
* @param event
*/
function handleColumnDrop(key: string, event: DragEvent) {
event.preventDefault();
const sourceKey =
draggingColumnKey.value ||
event.dataTransfer?.getData('text/plain') ||
'';
moveColumn(sourceKey, key);
clearColumnDragState();
}
/**
*
*/
function cycleSize() {
const currentIndex = SIZE_LIST.indexOf(props.size);
const next = SIZE_LIST[(currentIndex + 1) % SIZE_LIST.length] || 'middle';
emit('sizeChange', next);
}
/**
*
*
* @param key key Vue
* @param title
* @param icon
* @param onClick
*/
function renderIconButton(
key: string,
title: string,
icon: VNodeChild,
onClick: () => void,
) {
return (
<ATooltip title={title}>
<AButton
aria-label={title}
class="kt-table__toolbar-button"
key={key}
onClick={onClick}
shape="circle"
type="text"
>
{icon}
</AButton>
</ATooltip>
);
}
/**
*
*/
function renderColumnSetting() {
if (!props.setting.column) return null;
return (
<APopover placement="bottomRight" trigger="click">
{{
content: () => (
<div class="kt-table__settings-popover">
<div class="kt-table__settings-popover-header">
<span class="kt-table__settings-popover-title"></span>
<AButton
onClick={() => emit('resetColumns')}
size="small"
type="link"
>
</AButton>
</div>
<div class="kt-table__settings-popover-list">
{columnOptions.value.map((item) => (
<div
class={[
'kt-table__settings-column-item',
draggingColumnKey.value === item.key
? 'kt-table__settings-column-item--dragging'
: '',
dragOverColumnKey.value === item.key &&
draggingColumnKey.value !== item.key
? `kt-table__settings-column-item--drop-${dragInsertPosition.value}`
: '',
]}
key={item.key}
onDragend={clearColumnDragState}
onDragover={(event: DragEvent) =>
handleColumnDragOver(item.key, event)
}
onDrop={(event: DragEvent) =>
handleColumnDrop(item.key, event)
}
>
<button
aria-label={`拖拽排序:${item.title}`}
class="kt-table__settings-column-drag"
draggable
onClick={(event) => event.preventDefault()}
onDragstart={(event: DragEvent) =>
handleColumnDragStart(item.key, event)
}
type="button"
>
<IconifyIcon
class="kt-table__settings-column-drag-icon"
icon="lucide:grip-vertical"
/>
</button>
<ACheckbox
checked={props.visibleColumnKeys.includes(item.key)}
class="kt-table__settings-column-checkbox"
onChange={(event: any) =>
toggleColumn(item.key, event.target.checked)
}
>
{item.title}
</ACheckbox>
</div>
))}
</div>
</div>
),
default: () =>
renderIconButton(
'column',
'列设置',
<Settings class="kt-table__toolbar-icon" />,
() => {},
),
}}
</APopover>
);
}
return () => (
<ASpace size={4}>
{props.setting.reload
? renderIconButton(
'reload',
'刷新',
<RotateCw class="kt-table__toolbar-icon" />,
() => emit('reload'),
)
: null}
{props.setting.showSearch
? renderIconButton(
'showSearch',
props.searchVisible ? '隐藏搜索' : '显示搜索',
props.searchVisible ? (
<EyeOff class="kt-table__toolbar-icon" />
) : (
<Eye class="kt-table__toolbar-icon" />
),
() => emit('searchVisibleChange', !props.searchVisible),
)
: null}
{props.setting.density
? renderIconButton(
'density',
`密度:${SIZE_LABEL[props.size]}`,
<Menu class="kt-table__toolbar-icon" />,
cycleSize,
)
: null}
{renderColumnSetting()}
{props.setting.fullscreen
? renderIconButton(
'fullscreen',
props.fullscreen ? '退出全屏' : '全屏',
props.fullscreen ? (
<Minimize2 class="kt-table__toolbar-icon" />
) : (
<Fullscreen class="kt-table__toolbar-icon" />
),
() => emit('fullscreenChange', !props.fullscreen),
)
: null}
</ASpace>
);
},
});

View File

@ -0,0 +1,121 @@
import type { TableColumnType } from 'antdv-next';
import type { VNodeChild } from 'vue';
import type { KtTableContext, KtTableRecord, KtTableStatistic } from '../types';
import { TableSummary, TableSummaryCell, TableSummaryRow } from 'antdv-next';
import {
KT_TABLE_ACTION_COLUMN_KEY,
KT_TABLE_INDEX_COLUMN_KEY,
} from '../config/constants';
import { getColumnKey } from '../utils/index';
const ATableSummary = TableSummary as any;
const ATableSummaryCell = TableSummaryCell as any;
const ATableSummaryRow = TableSummaryRow as any;
type RenderKtTableSummaryOptions = {
columns: Array<TableColumnType<KtTableRecord>>;
context: KtTableContext;
customSummary?: VNodeChild;
showSelection: boolean;
statistics: KtTableStatistic[];
};
/**
*
*
* @param item
* @param context KtTable
*/
function renderStatisticValue(item: KtTableStatistic, context: KtTableContext) {
const value =
item.render?.(context) ??
(typeof item.value === 'function' ? item.value(context) : item.value);
return (
<span class="kt-table__summary-value">
{item.label ? (
<span class="kt-table__summary-label">{item.label}:</span>
) : null}
<span>{value}</span>
</span>
);
}
/**
* summary
*
* @param context KtTable
* @param statistic
* @param showDefaultLabel
*/
function renderCellContent(
context: KtTableContext,
statistic: KtTableStatistic | undefined,
showDefaultLabel: boolean,
): VNodeChild {
if (statistic) return renderStatisticValue(statistic, context);
if (showDefaultLabel) {
return <span class="kt-table__summary-title"></span>;
}
return null;
}
/**
* KtTable summary
*
* @param options summary
* @param options.columns
* @param options.context KtTable
* @param options.customSummary summary
* @param options.showSelection
* @param options.statistics
*/
export function renderKtTableSummary(options: RenderKtTableSummaryOptions) {
const { columns, context, customSummary, showSelection, statistics } =
options;
if (statistics.length === 0) return customSummary;
// summary slot 必须直接返回 TableSummaryAntdv 才会启用 fixed="bottom" 固定层。
const statisticMap = new Map(
statistics
.filter((item) => !!item.columnKey)
.map((item) => [item.columnKey, item]),
);
const selectionOffset = showSelection ? 1 : 0;
const defaultLabelColumnKey = columns
.map((column) => getColumnKey(column))
.find(
(key) =>
key &&
key !== KT_TABLE_INDEX_COLUMN_KEY &&
key !== KT_TABLE_ACTION_COLUMN_KEY,
);
return (
<ATableSummary fixed="bottom">
<ATableSummaryRow>
{showSelection ? <ATableSummaryCell index={0} /> : null}
{columns.map((column, index) => {
const columnKey = getColumnKey(column);
const statistic = statisticMap.get(columnKey);
const showDefaultLabel =
columnKey === defaultLabelColumnKey && !statistic;
return (
<ATableSummaryCell
index={index + selectionOffset}
key={columnKey || index}
>
{renderCellContent(context, statistic, showDefaultLabel)}
</ATableSummaryCell>
);
})}
</ATableSummaryRow>
</ATableSummary>
);
}

View File

@ -0,0 +1,23 @@
import type { KtTableFormGridOptions } from '../types';
export const KT_TABLE_ACTION_COLUMN_KEY = '__kt_table_actions__';
export const KT_TABLE_INDEX_COLUMN_KEY = '__kt_table_index__';
export const KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT = 3;
export const KT_TABLE_ROW_ACTION_VISIBLE_COUNT = 2;
export const KT_TABLE_DEFAULT_PAGE_SIZE = 10;
export const KT_TABLE_DEFAULT_PAGE_SIZE_OPTIONS = ['10', '20', '50', '100'];
export const KT_TABLE_DEFAULT_FORM_GRID: KtTableFormGridOptions = {
actionMinWidth: 180,
actionSpan: 6,
contentSpan: 18,
fieldSpan: 4,
rangeSpan: 6,
tabletColumns: 2,
totalSpan: 24,
};

View File

@ -0,0 +1,199 @@
import type { TableColumnType } from 'antdv-next';
import type { PropType } from 'vue';
import type {
KtTableButton,
KtTableFormOptions,
KtTableHook,
KtTableModule,
KtTableProps,
KtTableRecord,
KtTableResolvedProps,
KtTableRowAction,
KtTableSetting,
KtTableSize,
KtTableStatistic,
} from '../types';
import {
KT_TABLE_DEFAULT_PAGE_SIZE,
KT_TABLE_DEFAULT_PAGE_SIZE_OPTIONS,
} from './constants';
export const DEFAULT_TABLE_SETTING: Required<KtTableSetting> = {
column: true,
density: true,
fullscreen: true,
reload: true,
showSearch: true,
};
export const KT_TABLE_PROP_KEYS = [
'afterFetch',
'api',
'beforeFetch',
'buttons',
'columns',
'dataSource',
'formOptions',
'hooks',
'immediate',
'modules',
'pageSize',
'pageSizeOptions',
'rowActions',
'rowKey',
'showDefaultButtons',
'showFooter',
'showHeader',
'showIndex',
'showPagination',
'showSelection',
'showTableSetting',
'size',
'statistics',
'tableSettings',
'tableTitle',
] as const satisfies ReadonlyArray<keyof KtTableProps>;
/**
* KtTable props register
*/
export function createDefaultTableProps(): KtTableResolvedProps<
KtTableRecord,
KtTableRecord
> {
return {
afterFetch: undefined,
api: undefined,
beforeFetch: undefined,
buttons: [],
columns: [],
dataSource: undefined,
formOptions: undefined,
hooks: [],
immediate: true,
modules: [],
pageSize: KT_TABLE_DEFAULT_PAGE_SIZE,
pageSizeOptions: KT_TABLE_DEFAULT_PAGE_SIZE_OPTIONS,
rowActions: [],
rowKey: 'id',
showDefaultButtons: true,
showFooter: true,
showHeader: true,
showIndex: true,
showPagination: true,
showSelection: false,
showTableSetting: true,
size: 'small',
statistics: [],
tableSettings: {},
tableTitle: undefined,
};
}
export const ktTableProps = {
afterFetch: {
default: undefined,
type: Function as PropType<KtTableProps['afterFetch']>,
},
api: {
default: undefined,
type: Object as PropType<KtTableModule['api']>,
},
beforeFetch: {
default: undefined,
type: Function as PropType<KtTableProps['beforeFetch']>,
},
buttons: {
default: () => [],
type: Array as PropType<KtTableButton[]>,
},
columns: {
default: () => [],
type: Array as PropType<Array<TableColumnType<KtTableRecord>>>,
},
dataSource: {
default: undefined,
type: Array as PropType<KtTableRecord[]>,
},
formOptions: {
default: undefined,
type: Object as PropType<KtTableFormOptions>,
},
hooks: {
default: () => [],
type: Array as PropType<KtTableHook[]>,
},
immediate: {
default: true,
type: Boolean,
},
modules: {
default: () => [],
type: Array as PropType<KtTableModule[]>,
},
pageSize: {
default: KT_TABLE_DEFAULT_PAGE_SIZE,
type: Number,
},
pageSizeOptions: {
default: () => KT_TABLE_DEFAULT_PAGE_SIZE_OPTIONS,
type: Array as PropType<string[]>,
},
rowActions: {
default: () => [],
type: Array as PropType<KtTableRowAction[]>,
},
rowKey: {
default: 'id',
type: [String, Function] as PropType<
((row: KtTableRecord) => string) | string
>,
},
showDefaultButtons: {
default: true,
type: Boolean,
},
showFooter: {
default: true,
type: Boolean,
},
showHeader: {
default: true,
type: Boolean,
},
showIndex: {
default: true,
type: Boolean,
},
showPagination: {
default: true,
type: Boolean,
},
showSelection: {
default: false,
type: Boolean,
},
showTableSetting: {
default: true,
type: Boolean,
},
size: {
default: 'small',
type: String as PropType<KtTableSize>,
},
statistics: {
default: () => [],
type: Array as PropType<KtTableStatistic[]>,
},
tableSettings: {
default: () => ({}),
type: Object as PropType<KtTableSetting>,
},
tableTitle: {
default: undefined,
type: String,
},
};

View File

@ -0,0 +1,25 @@
import type { KtTableHook, KtTableModule, KtTableRecord } from '../types';
/**
* KtTable hook
*
* @param hook KtTable hook
*/
export function defineKtTableHook<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
>(hook: KtTableHook<Row, SearchValues>) {
return hook;
}
/**
* KtTable
*
* @param module 业务侧提供的 KtTable
*/
export function defineKtTableModule<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
>(module: KtTableModule<Row, SearchValues>) {
return module;
}

View File

@ -0,0 +1,89 @@
import type {
KtTableProps,
KtTableRecord,
KtTableRegisterApi,
KtTableRegisterFn,
KtTableSetProps,
} from '../types';
/**
*
*/
function createUnregisteredError() {
return new Error('[KtTable]: table is not registered yet.');
}
/**
* KtTable register API
*
* @param options
*/
export function useKtTable<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
>(options: Partial<KtTableProps<Row, SearchValues>> = {}) {
let tableApi: KtTableRegisterApi<Row, SearchValues> | null = null;
let pendingProps: Partial<KtTableProps<Row, SearchValues>> = { ...options };
/**
* API
*/
function getTableApi() {
if (!tableApi) {
throw createUnregisteredError();
}
return tableApi;
}
/**
* props
*
* @param nextProps props props
*/
const setProps: KtTableSetProps<Row, SearchValues> = (nextProps) => {
if (tableApi) {
tableApi.setProps(nextProps);
return;
}
const patch =
typeof nextProps === 'function'
? nextProps(pendingProps as never)
: nextProps;
pendingProps = {
...pendingProps,
...patch,
};
};
/**
* KtTable API props
*
* @param api KtTable API
*/
const register: KtTableRegisterFn<Row, SearchValues> = (api) => {
tableApi = api;
api.setProps(pendingProps);
};
const api = {
get formApi() {
return getTableApi().formApi;
},
getProps: () => getTableApi().getProps(),
getRows: () => getTableApi().getRows(),
getSearchValues: () => getTableApi().getSearchValues(),
registerHook: (...args) => getTableApi().registerHook(...args),
reload: () => getTableApi().reload(),
reset: () => getTableApi().reset(),
search: () => getTableApi().search(),
selectedRowKeys: () => getTableApi().selectedRowKeys(),
selectedRows: () => getTableApi().selectedRows(),
setProps,
setSearchValues: (...args) => getTableApi().setSearchValues(...args),
unregisterHook: (...args) => getTableApi().unregisterHook(...args),
} as KtTableRegisterApi<Row, SearchValues>;
return [register, api] as const;
}

View File

@ -0,0 +1,250 @@
import type { VNodeChild } from 'vue';
import type {
KtTableButton,
KtTableContext,
KtTableRecord,
KtTableResolvedProps,
KtTableRowAction,
} from '../types';
import type { useKtTableRuntimeHooks } from './useKtTableHooks';
import type { useKtTablePermission } from './useKtTablePermission';
import { computed } from 'vue';
import { Search, SearchX } from '@vben/icons';
import { Button, Modal } from 'antdv-next';
const AButton = Button as any;
type PermissionHelpers = Pick<
ReturnType<typeof useKtTablePermission>,
'filterVisibleActions' | 'filterVisibleButtons' | 'resolveBoolean'
>;
type RunHook = ReturnType<typeof useKtTableRuntimeHooks>['runHook'];
interface UseKtTableActionsOptions {
context: KtTableContext;
permissions: PermissionHelpers;
props: KtTableResolvedProps;
reload: () => Promise<void>;
reset: () => Promise<void>;
runHook: RunHook;
search: () => Promise<void>;
}
/**
* KtTable
*
* @param options
* @param options.context KtTable
* @param options.permissions
* @param options.props
* @param options.reload
* @param options.reset
* @param options.runHook hook
* @param options.search
*/
export function useKtTableActions(options: UseKtTableActionsOptions) {
const { context, permissions, props, reload, reset, runHook, search } =
options;
// 按钮 hook 只负责权限过滤、确认弹窗和回调触发,业务行为仍完全由调用方自定义。
const { filterVisibleActions, filterVisibleButtons, resolveBoolean } =
permissions;
const rowActions = computed(() =>
filterVisibleActions([
...props.rowActions,
...props.modules.flatMap((module) => module.rowActions || []),
]),
);
const defaultFormButtons = computed(() =>
filterVisibleButtons(getDefaultButtons()),
);
const customButtons = computed(() =>
filterVisibleButtons([
...props.buttons,
...props.modules.flatMap((module) => module.buttons || []),
]),
);
const formButtons = computed(() => [
...defaultFormButtons.value,
...customButtons.value.filter((button) => button.placement === 'form'),
]);
const headerButtons = computed(() =>
customButtons.value.filter((button) => button.placement !== 'form'),
);
/**
* KtTable
*/
function getDefaultButtons(): KtTableButton[] {
if (!props.showDefaultButtons) return [];
return [
{
icon: <Search class="kt-table__button-icon" />,
key: 'search',
label: '查询',
operation: 'search',
placement: 'form',
type: 'primary',
},
{
icon: <SearchX class="kt-table__button-icon" />,
key: 'reset',
label: '重置',
operation: 'reset',
placement: 'form',
},
];
}
/**
*
*
* @param icon
* @param targetContext 使
*/
function renderIcon(
icon: KtTableButton['icon'],
targetContext: KtTableContext = context,
) {
if (!icon) return null;
return typeof icon === 'function' ? icon(targetContext) : icon;
}
/**
*
*
* @param button
*/
async function runButtonAction(button: KtTableButton) {
await runHook('onBeforeAction', button, context);
let result: unknown;
if (button.onClick) {
result = await button.onClick(context);
} else {
switch (button.operation) {
case 'reload': {
result = await reload();
break;
}
case 'reset': {
result = await reset();
break;
}
case 'search': {
result = await search();
break;
}
default:
}
}
await runHook('onAfterAction', button, result, context);
}
/**
*
*
* @param action
* @param row
*/
async function runRowAction(action: KtTableRowAction, row: KtTableRecord) {
await runHook('onBeforeAction', action, context);
let result: unknown;
if (action.onClick) {
result = await action.onClick(row, context);
}
await runHook('onAfterAction', action, result, context);
}
/**
*
*
* @param action
* @param row
*/
function confirmRowAction(action: KtTableRowAction, row: KtTableRecord) {
if (!action.confirm) {
return runRowAction(action, row);
}
Modal.confirm({
content:
typeof action.confirm === 'function'
? action.confirm(row)
: `确认${action.label}该数据吗?`,
onOk: async () => {
await runRowAction(action, row);
},
title: action.label,
});
}
/**
*
*
* @param button
*/
function renderButton(button: KtTableButton) {
return (
<AButton
danger={button.danger}
disabled={resolveBoolean(button.disabled, false)}
key={button.key}
loading={button.loading}
onClick={() => runButtonAction(button)}
type={button.type}
>
{renderIcon(button.icon)}
{button.label}
</AButton>
);
}
/**
*
*
* @param action
* @param row
*/
function renderRowAction(action: KtTableRowAction, row: KtTableRecord) {
const disabled =
typeof action.disabled === 'function'
? action.disabled(row, context)
: resolveBoolean(action.disabled, false);
return (
<AButton
danger={action.danger}
disabled={disabled}
key={action.key}
onClick={() => confirmRowAction(action, row)}
type={action.type || 'link'}
>
{renderIcon(action.icon)}
{action.label}
</AButton>
);
}
return {
formButtons,
headerButtons,
renderButton,
renderRowAction: renderRowAction as (
action: KtTableRowAction,
row: KtTableRecord,
) => VNodeChild,
rowActions,
};
}

View File

@ -0,0 +1,326 @@
import type { TableColumnType } from 'antdv-next';
import type { ComputedRef } from 'vue';
import type {
KtTableRecord,
KtTableResolvedProps,
KtTableRowAction,
} from '../types';
import { computed, reactive, ref, watch } from 'vue';
import {
KT_TABLE_ACTION_COLUMN_KEY,
KT_TABLE_INDEX_COLUMN_KEY,
KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT,
} from '../config/constants';
import { getColumnKey } from '../utils/index';
interface UseKtTableColumnsOptions {
props: KtTableResolvedProps;
rowActions: ComputedRef<KtTableRowAction[]>;
scheduleTableLayout: () => void;
}
/**
* KtTable
*
* @param options KtTable
* @param options.props
* @param options.rowActions
* @param options.scheduleTableLayout
*/
export function useKtTableColumns(options: UseKtTableColumnsOptions) {
const { props, rowActions, scheduleTableLayout } = options;
// 列系统集中处理可见列、拖拽宽度和横向滚动宽度,避免主组件继续堆列计算细节。
const columnWidths = reactive<Record<string, number>>({});
const columnOrderKeys = ref<string[]>([]);
const visibleColumnKeys = ref<string[]>([]);
const moduleColumns = computed(() =>
props.modules.flatMap((module) => module.columns || []),
);
const sourceColumns = computed(() => [
...props.columns,
...moduleColumns.value,
]);
const orderedSourceColumns = computed(() => {
const columnMap = new Map(
sourceColumns.value.map((column) => [getColumnKey(column), column]),
);
const orderedKeys = columnOrderKeys.value.filter((key) =>
columnMap.has(key),
);
const restKeys = sourceColumns.value
.map((column) => getColumnKey(column))
.filter((key) => key && !orderedKeys.includes(key));
return [...orderedKeys, ...restKeys]
.map((key) => columnMap.get(key))
.filter(Boolean) as Array<TableColumnType<KtTableRecord>>;
});
const visibleColumns = computed(() =>
orderedSourceColumns.value
.filter((column) =>
visibleColumnKeys.value.includes(getColumnKey(column)),
)
.map((column) => normalizeColumnWidth(column)),
);
const indexColumn = computed<null | TableColumnType<KtTableRecord>>(() => {
if (!props.showIndex) return null;
return normalizeColumnWidth({
className: 'kt-table__index-column',
align: 'center',
fixed: 'left',
key: KT_TABLE_INDEX_COLUMN_KEY,
minWidth: 40,
title: '序号',
width: 48,
} as TableColumnType<KtTableRecord>);
});
const actionColumn = computed<null | TableColumnType<KtTableRecord>>(() => {
if (rowActions.value.length === 0) return null;
const actionColumnWidth = resolveActionColumnWidth(rowActions.value.length);
return normalizeColumnWidth({
className: 'kt-table__action-column',
fixed: 'right',
key: KT_TABLE_ACTION_COLUMN_KEY,
minWidth: actionColumnWidth,
title: '操作',
width: actionColumnWidth,
} as TableColumnType<KtTableRecord>);
});
const columns = computed(
() =>
[indexColumn.value, ...visibleColumns.value, actionColumn.value].filter(
Boolean,
) as Array<TableColumnType<KtTableRecord>>,
);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(total, column) => total + readColumnWidth(column.width, 140),
props.showSelection ? 48 : 0,
),
720,
),
);
watch(
sourceColumns,
(nextColumns) => {
const nextKeys = nextColumns
.map((column) => getColumnKey(column))
.filter(Boolean);
const current = visibleColumnKeys.value.filter((key) =>
nextKeys.includes(key),
);
const merged = [...new Set([...current, ...nextKeys])];
visibleColumnKeys.value = merged;
columnOrderKeys.value = mergeColumnOrderKeys(nextKeys);
},
{
immediate: true,
},
);
/**
*
*/
function resetColumns() {
const sourceKeys = sourceColumns.value
.map((column) => getColumnKey(column))
.filter(Boolean);
columnOrderKeys.value = [...sourceKeys];
visibleColumnKeys.value = [...sourceKeys];
}
/**
* key
*
* @param sourceKeys key
*/
function mergeColumnOrderKeys(sourceKeys: string[]) {
const current = columnOrderKeys.value.filter((key) =>
sourceKeys.includes(key),
);
return [...current, ...sourceKeys.filter((key) => !current.includes(key))];
}
/**
* key
*
* @param keys key
*/
function reorderColumns(keys: string[]) {
const sourceKeys = sourceColumns.value
.map((column) => getColumnKey(column))
.filter(Boolean);
const nextKeys = keys.filter((key) => sourceKeys.includes(key));
columnOrderKeys.value = [
...nextKeys,
...sourceKeys.filter((key) => !nextKeys.includes(key)),
];
}
/**
*
*
* @param width Antdv width
* @param fallback width 使
*/
function readColumnWidth(
width: TableColumnType<KtTableRecord>['width'],
fallback = 160,
) {
if (typeof width === 'number') return width;
if (typeof width === 'string') {
const parsed = Number.parseInt(width, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
/**
*
*
* @param column
*/
function getColumnMinWidth(column: TableColumnType<KtTableRecord>) {
const minWidth = Number((column as any).minWidth || 96);
if (getColumnKey(column) === KT_TABLE_INDEX_COLUMN_KEY) {
return Number.isFinite(minWidth) ? Math.max(minWidth, 40) : 40;
}
if (getColumnKey(column) === KT_TABLE_ACTION_COLUMN_KEY) {
return Number.isFinite(minWidth) ? Math.max(minWidth, 96) : 112;
}
return Number.isFinite(minWidth) ? Math.max(minWidth, 80) : 96;
}
/**
*
*
* @param actionCount
*/
function resolveActionColumnWidth(actionCount: number) {
if (actionCount > KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT) return 112;
if (actionCount === KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT) return 112;
if (actionCount === 2) return 96;
return 80;
}
/**
* ellipsis
*
* @param column
*/
function normalizeColumnWidth(column: TableColumnType<KtTableRecord>) {
const key = getColumnKey(column);
const width =
key && columnWidths[key]
? columnWidths[key]
: readColumnWidth(column.width, 160);
const originalHeaderCell = column.onHeaderCell;
const originalCell = column.onCell;
const minWidth = getColumnMinWidth(column);
const nextWidth = Math.max(width, minWidth);
return {
...column,
ellipsis: column.ellipsis ?? true,
/**
* header cell KtTable
*
* @param targetColumn Antdv
*/
onHeaderCell: (targetColumn: TableColumnType<KtTableRecord>) => {
const originalProps = (originalHeaderCell?.(targetColumn) ||
{}) as Record<string, any>;
return {
...originalProps,
/**
*
*
* @param event
* @param info
* @param info.size
* @param info.size.width
*/
onResize: (event: MouseEvent, info: { size: { width: number } }) => {
originalProps.onResize?.(event, info);
resizeColumnWidth(column, info.size.width);
},
style: {
...(originalProps.style as Record<string, unknown>),
minWidth: `${minWidth}px`,
},
width: nextWidth,
};
},
/**
* body cell KtTable
*
* @param record
* @param index
*/
onCell: (record: KtTableRecord, index?: number) => {
const originalProps = (originalCell?.(record, index) || {}) as Record<
string,
any
>;
return {
...originalProps,
style: {
...(originalProps.style as Record<string, unknown>),
minWidth: `${minWidth}px`,
},
};
},
width: nextWidth,
} as TableColumnType<KtTableRecord>;
}
/**
*
*
* @param column
* @param width
*/
function resizeColumnWidth(
column: TableColumnType<KtTableRecord>,
width: number,
) {
const key = getColumnKey(column);
if (
!key ||
key === KT_TABLE_ACTION_COLUMN_KEY ||
key === KT_TABLE_INDEX_COLUMN_KEY
) {
return;
}
const minWidth = getColumnMinWidth(column);
columnWidths[key] = Math.max(minWidth, Math.round(width));
scheduleTableLayout();
}
return {
columnOrderKeys,
columns,
reorderColumns,
resetColumns,
sourceColumns,
tableScrollX,
visibleColumnKeys,
};
}

View File

@ -0,0 +1,80 @@
import type { KtTableModule, KtTableProps, KtTableRecord } from '../types';
import { computed, watch } from 'vue';
import { useVbenForm } from '#/adapter/form';
import { mergeFormOptions, resolveFormGridOptions } from '../utils/index';
type KtTableFormProps = Readonly<
Pick<KtTableProps<KtTableRecord>, 'formOptions' | 'modules'>
>;
/**
* KtTable
*
* @param props
*/
export function useKtTableForm(props: KtTableFormProps) {
const sourceOptions = computed(() => [
props.formOptions,
...(props.modules || []).map((module: KtTableModule) => module.formOptions),
]);
const formGrid = computed(() => resolveFormGridOptions(sourceOptions.value));
const formOptions = computed(() => mergeFormOptions(sourceOptions.value));
const [SearchForm, formApi] = useVbenForm(formOptions.value);
const hasSearchForm = computed(
() => (formOptions.value.schema?.length || 0) > 0,
);
watch(
formOptions,
(options) => {
formApi.setState(options);
},
{
deep: true,
immediate: true,
},
);
/**
*
*/
async function getSearchValues() {
if (!hasSearchForm.value) return {};
return await formApi.getValues();
}
/**
*
*
* @param values
*/
async function setSearchValues(values: KtTableRecord) {
if (!hasSearchForm.value) return;
await formApi.setValues(values);
}
/**
*
*/
async function resetForm() {
if (!hasSearchForm.value) return;
await formApi.resetForm();
}
return {
formApi,
formGrid,
formOptions,
getSearchValues,
resetForm,
SearchForm,
setSearchValues,
};
}

View File

@ -0,0 +1,81 @@
import type {
KtTableHook,
KtTableModule,
KtTableProps,
KtTableRecord,
} from '../types';
import { computed, shallowRef } from 'vue';
type KtTableHookHandler = Exclude<keyof KtTableHook, 'name'>;
type KtTableHookProps = Readonly<
Pick<KtTableProps<KtTableRecord>, 'hooks' | 'modules'>
>;
/**
* KtTable hook hook hook
*
* @param props hook hook
*/
export function useKtTableRuntimeHooks(props: KtTableHookProps) {
const runtimeHooks = shallowRef<KtTableHook[]>([]);
const hooks = computed(() => [
...(props.hooks || []),
...(props.modules || []).flatMap(
(module: KtTableModule) => module.hooks || [],
),
...runtimeHooks.value,
]);
/**
* hook hook
*
* @param hook hook
*/
function registerHook(hook: KtTableHook) {
runtimeHooks.value = [
...runtimeHooks.value.filter((item) => item.name !== hook.name),
hook,
];
return () => unregisterHook(hook.name);
}
/**
* hook hook
*
* @param name hook
*/
function unregisterHook(name: string) {
runtimeHooks.value = runtimeHooks.value.filter(
(item) => item.name !== name,
);
}
/**
* hook
*
* @param handler hook
* @param params hook
*/
async function runHook(handler: KtTableHookHandler, ...params: unknown[]) {
for (const hook of hooks.value) {
const callback = hook[handler] as
| ((...args: unknown[]) => Promise<void> | void)
| undefined;
if (callback) {
await callback(...params);
}
}
}
return {
hooks,
registerHook,
runHook,
unregisterHook,
};
}

View File

@ -0,0 +1,152 @@
import type { ComputedRef } from 'vue';
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
interface UseKtTableLayoutOptions {
hasSummary: ComputedRef<boolean>;
}
/**
* KtTable
*
* @param options
* @param options.hasSummary summary
*/
export function useKtTableLayout(options: UseKtTableLayoutOptions) {
const { hasSummary } = options;
// 搜索区动画期间冻结表格高度重算,等过渡结束后再同步一次,避免频繁重算导致动画卡顿。
const tableBodyRef = ref<HTMLElement | null>(null);
const tableScrollY = ref(260);
const searchTransitioning = ref(false);
let layoutFrame: number | undefined;
let resizeObserver: ResizeObserver | undefined;
let observingTableBody = false;
/**
*
*/
function handleSearchTransitionEnd() {
searchTransitioning.value = false;
observeTableBody();
scheduleTableLayout(true);
}
/**
*
*/
function handleSearchTransitionStart() {
searchTransitioning.value = true;
pauseTableBodyObserver();
cancelLayoutFrame();
}
/**
* summary body
*
* @param force
*/
function updateTableScrollY(force = false) {
if (searchTransitioning.value && !force) return;
const wrapper = tableBodyRef.value;
if (!wrapper) return;
const header = wrapper.querySelector(
'.ant-table-header',
) as HTMLElement | null;
const fallbackSummaryHeight = hasSummary.value ? 44 : 0;
const summary = wrapper.querySelector(
'.ant-table-summary',
) as HTMLElement | null;
const headerHeight =
header?.getBoundingClientRect().height ||
(
wrapper.querySelector('.ant-table-thead') as HTMLElement | null
)?.getBoundingClientRect().height ||
48;
const summaryHeight =
summary?.getBoundingClientRect().height || fallbackSummaryHeight;
const nextHeight = Math.max(
160,
Math.floor(wrapper.clientHeight - headerHeight - summaryHeight - 2),
);
if (Number.isFinite(nextHeight)) {
tableScrollY.value = nextHeight;
}
}
/**
*
*/
function cancelLayoutFrame() {
if (!layoutFrame) return;
window.cancelAnimationFrame(layoutFrame);
layoutFrame = undefined;
}
/**
* DOM
*
* @param force
*/
function scheduleTableLayout(force = false) {
if (searchTransitioning.value && !force) return;
nextTick(() => {
cancelLayoutFrame();
layoutFrame = window.requestAnimationFrame(() => {
layoutFrame = undefined;
updateTableScrollY(force);
});
});
}
/**
* Antdv Table scroll.y
*/
function observeTableBody() {
const wrapper = tableBodyRef.value;
if (!resizeObserver || !wrapper || observingTableBody) return;
resizeObserver.observe(wrapper);
observingTableBody = true;
}
/**
*
*/
function pauseTableBodyObserver() {
const wrapper = tableBodyRef.value;
if (!resizeObserver || !wrapper || !observingTableBody) return;
resizeObserver.unobserve(wrapper);
observingTableBody = false;
}
onMounted(() => {
resizeObserver = new ResizeObserver(() => {
scheduleTableLayout();
});
observeTableBody();
scheduleTableLayout();
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
if (layoutFrame) {
window.cancelAnimationFrame(layoutFrame);
}
document.body.classList.remove('kt-table--column-resizing');
});
return {
handleSearchTransitionEnd,
handleSearchTransitionStart,
scheduleTableLayout,
tableBodyRef,
tableScrollY,
};
}

View File

@ -0,0 +1,83 @@
import type {
KtTableButton,
KtTableContext,
KtTableRecord,
KtTableRowAction,
} from '../types';
import { useAccess } from '@vben/access';
/**
* KtTable
*
* @param context KtTable visible/disabled
*/
export function useKtTablePermission(context: KtTableContext) {
const { hasAccessByCodes } = useAccess();
type ContextBoolean =
| ((context: KtTableContext) => boolean)
| boolean
| undefined;
/**
*
*
* @param permissionCodes
*/
function canAccess(permissionCodes?: string[]) {
return !permissionCodes || hasAccessByCodes(permissionCodes);
}
/**
* boolean boolean
*
* @param value
* @param fallback value 使
*/
function resolveBoolean(value: ContextBoolean, fallback: boolean) {
if (typeof value === 'function') return value(context);
if (typeof value === 'boolean') return value;
return fallback;
}
/**
*
*
* @param items
*/
function filterVisibleButtons(items: KtTableButton[]) {
return items.filter(
(item) =>
canAccess(item.permissionCodes) && resolveBoolean(item.visible, true),
);
}
/**
*
*
* @param items
*/
function filterVisibleActions(items: KtTableRowAction[]) {
return items.filter(
(item) =>
canAccess(item.permissionCodes) && resolveBoolean(item.visible, true),
);
}
/**
*
*
* @param rows
*/
function getEnabledRows(rows: KtTableRecord[]) {
return rows.filter(Boolean);
}
return {
filterVisibleActions,
filterVisibleButtons,
getEnabledRows,
resolveBoolean,
};
}

View File

@ -0,0 +1,103 @@
import type {
KtTableProps,
KtTableResolvedProps,
KtTableSetProps,
} from '../types';
import {
getCurrentInstance,
shallowReactive,
shallowRef,
watchEffect,
} from 'vue';
import {
createDefaultTableProps,
KT_TABLE_PROP_KEYS,
} from '../config/ktTableProps';
/**
* camelCase prop kebab-case
*
* @param value camelCase prop
*/
function toKebabCase(value: string) {
return value.replaceAll(/([A-Z])/g, '-$1').toLowerCase();
}
/**
* KtTable register props
*
* @param rawProps props
*/
export function useKtTableResolvedProps(rawProps: KtTableProps) {
const instance = getCurrentInstance();
const registeredProps = shallowRef<Partial<KtTableProps>>({});
const props = shallowReactive({}) as KtTableResolvedProps;
Object.assign(props, createDefaultTableProps());
/**
* prop
*
* @param key KtTable prop
*/
function hasExplicitProp(key: keyof KtTableProps) {
const vnodeProps = instance?.vnode.props || {};
const propName = String(key);
return (
Object.hasOwn(vnodeProps, propName) ||
Object.hasOwn(vnodeProps, toKebabCase(propName))
);
}
/**
* props
*/
function getExplicitProps() {
const result: Partial<KtTableProps> = {};
for (const key of KT_TABLE_PROP_KEYS) {
const value = rawProps[key];
if (hasExplicitProp(key)) {
(result as Record<string, unknown>)[key] = value;
}
}
return result;
}
/**
* KtTable props
*/
function syncResolvedProps() {
Object.assign(
props,
createDefaultTableProps(),
registeredProps.value,
getExplicitProps(),
);
}
/**
* register props props
*
* @param nextProps props props
*/
const setProps: KtTableSetProps = (nextProps) => {
const patch =
typeof nextProps === 'function' ? nextProps({ ...props }) : nextProps;
registeredProps.value = {
...registeredProps.value,
...patch,
};
syncResolvedProps();
};
watchEffect(syncResolvedProps);
return {
props,
setProps,
};
}

View File

@ -0,0 +1,47 @@
import type { KtTableProps, KtTableRecord } from '../types';
import { computed, ref } from 'vue';
type KtTableSelectionProps = Readonly<
Pick<KtTableProps<KtTableRecord>, 'showSelection'>
>;
/**
* KtTable
*
* @param props
*/
export function useKtTableSelection(props: KtTableSelectionProps) {
const selectedRowKeys = ref<Array<number | string>>([]);
const selectedRows = ref<KtTableRecord[]>([]);
const rowSelection = computed(() =>
props.showSelection
? {
onChange: (
keys: Array<number | string>,
tableRows: KtTableRecord[],
) => {
selectedRowKeys.value = keys;
selectedRows.value = tableRows;
},
selectedRowKeys: selectedRowKeys.value,
}
: undefined,
);
/**
* key
*/
function clearSelection() {
selectedRowKeys.value = [];
selectedRows.value = [];
}
return {
clearSelection,
rowSelection,
selectedRowKeys,
selectedRows,
};
}

View File

@ -0,0 +1,7 @@
export {
defineKtTableHook,
defineKtTableModule,
} from './helpers/defineKtTable';
export { useKtTable } from './hooks/useKtTable';
export { default as KtTable } from './KtTable';
export type * from './types';

View File

@ -0,0 +1,540 @@
.kt-table {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
&--fullscreen {
position: fixed;
inset: 0;
z-index: 1000;
padding: 16px;
background: hsl(var(--background));
}
&__main {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: hsl(var(--card));
}
&__main-content {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
padding: 16px;
}
&__search {
--kt-table-form-action-fr: 6fr;
--kt-table-form-action-min-width: 180px;
--kt-table-form-content-fr: 18fr;
--kt-table-form-content-span: 18;
--kt-table-form-item-span: 4;
--kt-table-form-tablet-columns: 2;
flex-shrink: 0;
padding: 12px 16px 0;
background: hsl(var(--card));
}
&__search-content-shell {
overflow: hidden;
}
&__search-content-shell--transitioning {
transition: height 0.22s cubic-bezier(0.22, 1, 0.36, 1);
will-change: height;
}
&__search-content {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
&__search-form {
min-width: 0;
}
&__search-actions {
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
&__search-action-stack {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
width: 100%;
}
&__search-toggle {
padding-inline: 8px;
}
&__search-toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
&__search-toggle-icon--expanded {
transform: rotate(180deg);
}
&__search-split {
height: 1px;
margin-top: 12px;
background: hsl(var(--border));
}
&__form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
&__form-item {
min-width: 0;
padding-bottom: 0;
}
&__form-control {
width: 100%;
}
&__header {
display: flex;
flex-shrink: 0;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
&__header-align {
display: flex;
flex: 1 1 0;
gap: 12px;
align-items: center;
justify-content: flex-start;
min-width: 0;
}
&__header-title {
min-width: 0;
font-size: 16px;
font-weight: 500;
}
&__header-button {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
}
&__header-toolbar {
display: flex;
flex-shrink: 0;
align-items: center;
}
&__header-divider {
margin-inline: 8px;
}
&__body {
--kt-table-scroll-y: 260px;
position: relative;
flex: 1 1 0;
min-height: 0;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 6px;
}
&__ant,
&__ant .ant-spin-nested-loading,
&__ant .ant-spin-container {
height: 100%;
min-height: 0;
}
&__ant {
.ant-spin-container,
.ant-table {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
}
.ant-table-container {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
}
.ant-table-header {
position: relative;
z-index: 5;
flex: 0 0 auto;
background: hsl(var(--card));
}
.ant-table-body {
position: relative;
z-index: 1;
min-height: 0;
max-height: var(--kt-table-scroll-y) !important;
overflow: auto !important;
}
.ant-table-summary {
flex-shrink: 0;
margin-top: auto;
}
.ant-table-cell {
border-bottom-color: hsl(var(--border)) !important;
}
.ant-table-cell-fix-left,
.ant-table-cell-fix-right,
.ant-table-cell-fix-start,
.ant-table-cell-fix-end {
background: hsl(var(--card)) !important;
}
.ant-table-thead > tr > th.ant-table-cell-fix-left,
.ant-table-thead > tr > th.ant-table-cell-fix-right,
.ant-table-thead > tr > th.ant-table-cell-fix-start,
.ant-table-thead > tr > th.ant-table-cell-fix-end {
z-index: 6;
background: hsl(var(--card)) !important;
}
.ant-table-tbody > tr:hover > td.ant-table-cell-fix-left,
.ant-table-tbody > tr:hover > td.ant-table-cell-fix-right,
.ant-table-tbody > tr:hover > td.ant-table-cell-fix-start,
.ant-table-tbody > tr:hover > 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-right,
.ant-table-summary .ant-table-cell-fix-start,
.ant-table-summary .ant-table-cell-fix-end {
background: hsl(var(--card)) !important;
}
.ant-table-thead > tr > th {
position: relative;
top: 0;
z-index: 5;
background: hsl(var(--card)) !important;
}
tfoot.ant-table-summary .ant-table-cell,
tfoot.ant-table-summary > tr > td {
height: 44px;
padding: 0 16px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
line-height: 20px;
color: hsl(var(--foreground));
white-space: nowrap;
border-bottom: 0 !important;
}
}
&__body + &__footer {
margin-top: 10px;
}
&__footer {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 12px;
align-items: center;
min-height: 32px;
}
&__footer-settings {
display: flex;
flex: 1 1 0;
gap: 12px;
align-items: center;
font-size: 14px;
}
&__footer-selection {
color: hsl(var(--muted-foreground));
}
&__footer-selection-count {
margin-inline: 4px;
color: hsl(var(--foreground));
}
&__summary-value {
display: inline-flex;
gap: 4px;
align-items: center;
}
&__summary-label {
color: hsl(var(--muted-foreground));
}
&__summary-title {
font-weight: 500;
}
&__row-actions {
white-space: nowrap;
.ant-btn {
min-width: auto;
padding-inline: 0;
}
}
&__action-column {
min-width: 112px;
}
&__row-action-more {
min-width: 18px;
padding-inline: 2px;
}
&__row-actions &__row-action-more {
min-width: 18px;
padding-inline: 2px;
}
&__row-action-more-icon {
font-size: 14px;
}
&__button-icon,
&__toolbar-icon {
width: 16px;
height: 16px;
}
&__toolbar-button {
width: 32px;
height: 32px;
}
&__settings-popover {
width: 240px;
}
&__settings-popover-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
&__settings-popover-title {
font-weight: 500;
}
&__settings-popover-list {
display: flex;
flex-direction: column;
max-height: 260px;
overflow-y: auto;
}
&__settings-column-item {
position: relative;
display: flex;
gap: 6px;
align-items: center;
min-height: 32px;
padding: 4px 6px;
border-radius: 4px;
transition:
background 0.16s ease,
opacity 0.16s ease;
}
&__settings-column-item:hover {
background: hsl(var(--accent));
}
&__settings-column-item::before,
&__settings-column-item::after {
position: absolute;
right: 6px;
left: 6px;
height: 2px;
content: '';
background: transparent;
border-radius: 999px;
}
&__settings-column-item::before {
top: 0;
}
&__settings-column-item::after {
bottom: 0;
}
&__settings-column-item--dragging {
opacity: 0.45;
}
&__settings-column-item--drop-before::before,
&__settings-column-item--drop-after::after {
background: hsl(var(--primary));
}
&__settings-column-drag {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 20px;
height: 24px;
padding: 0;
color: hsl(var(--muted-foreground));
cursor: grab;
background: transparent;
border: 0;
}
&__settings-column-drag:active {
cursor: grabbing;
}
&__settings-column-drag-icon {
width: 16px;
height: 16px;
}
&__settings-column-checkbox {
flex: 1 1 0;
min-width: 0;
}
&__resizable-title {
position: relative;
}
&__resizable-handle {
position: absolute;
top: 0;
right: -4px;
bottom: 0;
z-index: 4;
width: 8px;
cursor: col-resize;
user-select: none;
}
&__resizable-handle:hover {
background: hsl(var(--primary) / 18%);
}
}
@media (min-width: 1280px) {
.kt-table {
&__search-content {
grid-template-columns:
minmax(0, var(--kt-table-form-content-fr))
minmax(
var(--kt-table-form-action-min-width),
var(--kt-table-form-action-fr)
);
}
&__search-actions,
&__search-action-stack {
justify-content: flex-end;
}
&__form-grid {
grid-template-columns: repeat(
var(--kt-table-form-content-span),
minmax(0, 1fr)
);
}
&__form-item {
grid-column: span var(--kt-table-form-item-span) / span
var(--kt-table-form-item-span);
}
&__form-item--collapsed-visible {
display: flex !important;
}
}
}
@media (min-width: 768px) and (max-width: 1279px) {
.kt-table {
&__form-grid {
grid-template-columns: repeat(
var(--kt-table-form-tablet-columns),
minmax(0, 1fr)
);
}
&__form-item {
grid-column: span 1 / span 1;
}
&__form-item--range {
grid-column: 1 / -1;
}
}
}
.kt-table--column-resizing,
.kt-table--column-resizing * {
cursor: col-resize !important;
user-select: none !important;
}
.kt-table__row-action-popover-content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 72px;
.ant-btn {
justify-content: flex-start;
padding-inline: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kt-table {
&__search-content-shell--transitioning {
transition: none;
}
}
}

View File

@ -0,0 +1,274 @@
import type { TableColumnType } from 'antdv-next';
import type { VNodeChild } from 'vue';
import type {
ExtendedFormApi,
VbenFormProps,
VbenFormSchema,
} from '@vben/common-ui';
export type KtTableRecord = Record<string, any>;
export type KtTableSize = 'large' | 'middle' | 'small';
export interface KtTableSetting {
column?: boolean;
density?: boolean;
fullscreen?: boolean;
reload?: boolean;
showSearch?: boolean;
}
export type KtTableOperation = 'custom' | 'reload' | 'reset' | 'search';
export type KtTablePermissionChecker = (codes: string[]) => boolean;
export interface KtTableFormOptions extends Omit<
VbenFormProps,
'schema' | 'showDefaultActions'
> {
formGrid?: Partial<KtTableFormGridOptions>;
labelInInput?: boolean;
schema?: KtTableFormSchema[];
}
export interface KtTableFormGridOptions {
actionMinWidth: number;
actionSpan: number;
contentSpan: number;
fieldSpan: number;
rangeSpan: number;
tabletColumns: number;
totalSpan: number;
}
export type KtTableFormSchema = VbenFormSchema & {
colProps?: {
span?: number;
};
formGridSpan?: number;
};
export interface KtTablePageResult<Row extends KtTableRecord = KtTableRecord> {
items?: Row[];
list?: Row[];
records?: Row[];
total?: number;
}
export interface KtTableApi<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> {
list: (
params: KtTableRecord & SearchValues,
context: KtTableContext<Row, SearchValues>,
) => Promise<KtTablePageResult<Row> | Row[]>;
}
export interface KtTableButton<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> {
danger?: boolean;
disabled?:
| ((context: KtTableContext<Row, SearchValues>) => boolean)
| boolean;
icon?:
| ((context: KtTableContext<Row, SearchValues>) => VNodeChild)
| VNodeChild;
key: string;
label: string;
loading?: boolean;
onClick?: (
context: KtTableContext<Row, SearchValues>,
) => Promise<void> | void;
operation?: KtTableOperation;
permissionCodes?: string[];
placement?: 'form' | 'header';
type?: 'dashed' | 'default' | 'link' | 'primary' | 'text';
visible?: ((context: KtTableContext<Row, SearchValues>) => boolean) | boolean;
}
export interface KtTableRowAction<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> extends Omit<KtTableButton<Row, SearchValues>, 'disabled' | 'onClick'> {
confirm?: ((row: Row) => string) | boolean;
disabled?:
| ((row: Row, context: KtTableContext<Row, SearchValues>) => boolean)
| boolean;
onClick?: (
row: Row,
context: KtTableContext<Row, SearchValues>,
) => Promise<void> | void;
}
export interface KtTableStatistic<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> {
columnKey?: string;
key: string;
label?: string;
render?: (context: KtTableContext<Row, SearchValues>) => VNodeChild;
value?:
| ((
context: KtTableContext<Row, SearchValues>,
) => number | string | VNodeChild)
| number
| string;
}
export interface KtTableHook<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> {
name: string;
onAfterAction?: (
action:
| KtTableButton<Row, SearchValues>
| KtTableRowAction<Row, SearchValues>,
result: unknown,
context: KtTableContext<Row, SearchValues>,
) => Promise<void> | void;
onAfterFetch?: (
result: KtTablePageResult<Row> | Row[],
context: KtTableContext<Row, SearchValues>,
) => Promise<void> | void;
onBeforeAction?: (
action:
| KtTableButton<Row, SearchValues>
| KtTableRowAction<Row, SearchValues>,
context: KtTableContext<Row, SearchValues>,
) => Promise<void> | void;
onBeforeFetch?: (
params: KtTableRecord,
context: KtTableContext<Row, SearchValues>,
) => Promise<void> | void;
onFetchError?: (
error: unknown,
context: KtTableContext<Row, SearchValues>,
) => Promise<void> | void;
}
export interface KtTableModule<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> {
api?: KtTableApi<Row, SearchValues>;
buttons?: Array<KtTableButton<Row, SearchValues>>;
columns?: Array<TableColumnType<Row>>;
formOptions?: KtTableFormOptions;
hooks?: Array<KtTableHook<Row, SearchValues>>;
name: string;
rowActions?: Array<KtTableRowAction<Row, SearchValues>>;
statistics?: Array<KtTableStatistic<Row, SearchValues>>;
}
export interface KtTableContext<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> {
formApi: ExtendedFormApi;
getRows: () => Row[];
getSearchValues: () => Promise<SearchValues>;
registerHook: (hook: KtTableHook<Row, SearchValues>) => () => void;
reload: () => Promise<void>;
reset: () => Promise<void>;
search: () => Promise<void>;
selectedRowKeys: () => Array<number | string>;
selectedRows: () => Row[];
setSearchValues: (values: Partial<SearchValues>) => Promise<void>;
unregisterHook: (name: string) => void;
}
export type KtTableSetProps<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> = (
props:
| ((
currentProps: KtTableResolvedProps<Row, SearchValues>,
) => Partial<KtTableProps<Row, SearchValues>>)
| Partial<KtTableProps<Row, SearchValues>>,
) => void;
export interface KtTableRegisterApi<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> extends KtTableContext<Row, SearchValues> {
getProps: () => KtTableResolvedProps<Row, SearchValues>;
setProps: KtTableSetProps<Row, SearchValues>;
}
export type KtTableRegisterFn<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> = (api: KtTableRegisterApi<Row, SearchValues>) => void;
export interface KtTableProps<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> {
api?: KtTableApi<Row, SearchValues>;
afterFetch?: (
result: KtTablePageResult<Row> | Row[],
context: KtTableContext<Row, SearchValues>,
) => KtTablePageResult<Row> | Promise<KtTablePageResult<Row> | Row[]> | Row[];
beforeFetch?: (
params: KtTableRecord & SearchValues,
context: KtTableContext<Row, SearchValues>,
) =>
| (KtTableRecord & SearchValues)
| Promise<KtTableRecord & SearchValues>
| undefined;
buttons?: Array<KtTableButton<Row, SearchValues>>;
columns?: Array<TableColumnType<Row>>;
dataSource?: Row[];
formOptions?: KtTableFormOptions;
hooks?: Array<KtTableHook<Row, SearchValues>>;
immediate?: boolean;
modules?: Array<KtTableModule<Row, SearchValues>>;
pageSize?: number;
pageSizeOptions?: string[];
rowActions?: Array<KtTableRowAction<Row, SearchValues>>;
rowKey?: ((row: Row) => string) | keyof Row | string;
showDefaultButtons?: boolean;
showFooter?: boolean;
showHeader?: boolean;
showIndex?: boolean;
showPagination?: boolean;
showSelection?: boolean;
showTableSetting?: boolean;
size?: KtTableSize;
statistics?: Array<KtTableStatistic<Row, SearchValues>>;
tableSettings?: KtTableSetting;
tableTitle?: string;
}
export type KtTableResolvedProps<
Row extends KtTableRecord = KtTableRecord,
SearchValues extends KtTableRecord = KtTableRecord,
> = KtTableProps<Row, SearchValues> & {
buttons: Array<KtTableButton<Row, SearchValues>>;
columns: Array<TableColumnType<Row>>;
hooks: Array<KtTableHook<Row, SearchValues>>;
immediate: boolean;
modules: Array<KtTableModule<Row, SearchValues>>;
pageSize: number;
pageSizeOptions: string[];
rowActions: Array<KtTableRowAction<Row, SearchValues>>;
rowKey: ((row: Row) => string) | keyof Row | string;
showDefaultButtons: boolean;
showFooter: boolean;
showHeader: boolean;
showIndex: boolean;
showPagination: boolean;
showSelection: boolean;
showTableSetting: boolean;
size: KtTableSize;
statistics: Array<KtTableStatistic<Row, SearchValues>>;
tableSettings: KtTableSetting;
};

View File

@ -0,0 +1,447 @@
import type { TableColumnType } from 'antdv-next';
import type {
KtTableFormGridOptions,
KtTableFormOptions,
KtTableFormSchema,
KtTablePageResult,
KtTableRecord,
} from '../types';
import type { VbenFormProps } from '#/adapter/form';
import { KT_TABLE_DEFAULT_FORM_GRID } from '../config/constants';
/**
*
*
* @param result
*/
export function normalizePageResult<Row extends KtTableRecord>(
result: KtTablePageResult<Row> | Row[],
) {
if (Array.isArray(result)) {
return {
list: result,
total: result.length,
};
}
const list = result.list || result.records || result.items || [];
return {
list,
total: typeof result.total === 'number' ? result.total : list.length,
};
}
/**
* Vben
*
* @param options undefined
*/
export function mergeFormOptions(
options: Array<KtTableFormOptions | undefined>,
) {
let labelInInput = true;
const formGrid = resolveFormGridOptions(options);
let mergedOptions: KtTableFormOptions = {
commonConfig: {
componentProps: {
class: 'kt-table__form-control',
},
controlClass: 'kt-table__form-control',
formItemClass: 'kt-table__form-item',
hideLabel: true,
labelWidth: 72,
},
compact: true,
collapsed: false,
collapsedRows: 1,
collapseReserveAction: false,
layout: 'horizontal',
schema: [],
showCollapseButton: true,
submitOnEnter: true,
// 对齐 ShyTable 的 24 栅格分栏18 份表单区域 + 6 份操作区域。
wrapperClass: 'kt-table__form-grid',
};
for (const item of options) {
if (!item) continue;
labelInInput = item.labelInInput ?? labelInInput;
mergedOptions = {
...mergedOptions,
...item,
arrayToStringFields: [
...(mergedOptions.arrayToStringFields || []),
...(item.arrayToStringFields || []),
],
commonConfig: {
...mergedOptions.commonConfig,
...item.commonConfig,
},
fieldMappingTime: [
...(mergedOptions.fieldMappingTime || []),
...(item.fieldMappingTime || []),
],
schema: [...(mergedOptions.schema || []), ...(item.schema || [])],
};
}
mergedOptions = {
...mergedOptions,
commonConfig: labelInInput
? {
...mergedOptions.commonConfig,
hideLabel: true,
}
: mergedOptions.commonConfig,
schema: withKtTableFormLayout(
mergedOptions.schema || [],
mergedOptions.collapsedRows || 1,
formGrid,
labelInInput,
),
};
const {
formGrid: _formGrid,
labelInInput: _labelInInput,
...formProps
} = mergedOptions;
// 表格统一接管查询、重置按钮,表单只负责字段渲染和值管理。
return {
...formProps,
showDefaultActions: false,
} as VbenFormProps;
}
/**
* KtTable 24
*
* @param options
*/
export function resolveFormGridOptions(
options: Array<KtTableFormOptions | undefined>,
): KtTableFormGridOptions {
const formGrid: KtTableFormGridOptions = { ...KT_TABLE_DEFAULT_FORM_GRID };
for (const item of options) {
if (item?.formGrid) {
Object.assign(formGrid, item.formGrid);
}
}
const totalSpan = Math.max(
toPositiveInteger(formGrid.totalSpan, KT_TABLE_DEFAULT_FORM_GRID.totalSpan),
2,
);
const actionSpan = Math.min(
toPositiveInteger(
formGrid.actionSpan,
KT_TABLE_DEFAULT_FORM_GRID.actionSpan,
),
totalSpan - 1,
);
const contentSpan = Math.min(
toPositiveInteger(
formGrid.contentSpan,
Math.max(totalSpan - actionSpan, 1),
),
totalSpan,
);
return {
actionMinWidth: toPositiveInteger(
formGrid.actionMinWidth,
KT_TABLE_DEFAULT_FORM_GRID.actionMinWidth,
),
actionSpan,
contentSpan,
fieldSpan: Math.min(
toPositiveInteger(
formGrid.fieldSpan,
KT_TABLE_DEFAULT_FORM_GRID.fieldSpan,
),
contentSpan,
),
rangeSpan: Math.min(
toPositiveInteger(
formGrid.rangeSpan,
KT_TABLE_DEFAULT_FORM_GRID.rangeSpan,
),
contentSpan,
),
tabletColumns: toPositiveInteger(
formGrid.tabletColumns,
KT_TABLE_DEFAULT_FORM_GRID.tabletColumns,
),
totalSpan,
};
}
/**
*
*
* @param value
* @param fallback 使
*/
function toPositiveInteger(value: number | undefined, fallback: number) {
const normalized = Number(value);
return Number.isFinite(normalized) && normalized > 0
? Math.round(normalized)
: fallback;
}
/**
* label placeholder
*
* @param label label label placeholder
*/
function getTextLabel(label: unknown) {
return typeof label === 'string' ? label : '';
}
/**
* placeholder
*
* @param component
* @param label placeholder
*/
function createPlaceholder(component: unknown, label: string) {
if (!label || typeof component !== 'string') return undefined;
if (component.includes('Picker')) {
return component === 'RangePicker'
? [`开始${label}`, `结束${label}`]
: `请选择${label}`;
}
if (component.includes('Select') || component.includes('Cascader')) {
return `请选择${label}`;
}
if (component.includes('Input')) {
return `请输入${label}`;
}
return label;
}
/**
* componentProps placeholder
*
* @param schema label
* @param componentProps placeholder
*/
function mergePlaceholder(
schema: NonNullable<KtTableFormOptions['schema']>[number],
componentProps: Record<string, any>,
) {
const label = getTextLabel(schema.label);
const placeholder = createPlaceholder(schema.component, label);
if (!placeholder || componentProps.placeholder) return componentProps;
return {
...componentProps,
placeholder,
};
}
/**
* KtTable class label placeholder
*
* @param schema schema
* @param collapsedRows
* @param formGrid
* @param labelInInput label placeholder
*/
function withKtTableFormLayout(
schema: NonNullable<KtTableFormOptions['schema']>,
collapsedRows: number,
formGrid: KtTableFormGridOptions,
labelInInput: boolean,
) {
const collapsedVisibleFields = getCollapsedVisibleFields(
schema,
collapsedRows,
formGrid,
);
return schema.map((item) => {
const span = getTableFormSpan(item, formGrid);
const componentProps = item.componentProps;
const formItemClass = mergeFormItemClass(
getTableFormItemClass(
item.component,
collapsedVisibleFields.has(item.fieldName),
),
item.formItemClass,
);
if (typeof componentProps === 'function') {
const resolveComponentProps = componentProps as (
...args: any[]
) => Record<string, any>;
return {
...item,
componentProps: (...args: any[]) =>
labelInInput
? mergePlaceholder(item, resolveComponentProps(...args) || {})
: resolveComponentProps(...args),
formItemClass,
style: mergeFormItemStyle(item, span),
};
}
return {
...item,
componentProps: labelInInput
? mergePlaceholder(item, item.componentProps || {})
: item.componentProps,
formItemClass,
style: mergeFormItemStyle(item, span),
};
});
}
/**
*
*
* @param schema schema
* @param collapsedRows
* @param formGrid
*/
function getCollapsedVisibleFields(
schema: NonNullable<KtTableFormOptions['schema']>,
collapsedRows: number,
formGrid: KtTableFormGridOptions,
) {
const visibleFields = new Set<string>();
let currentRowSpan = 0;
let row = 1;
for (const item of schema) {
const span = getTableFormSpan(item, formGrid);
if (currentRowSpan + span > formGrid.contentSpan) {
row += 1;
currentRowSpan = 0;
}
if (row > collapsedRows) break;
visibleFields.add(item.fieldName);
currentRowSpan += span;
}
return visibleFields;
}
/**
* class
*
* @param component
* @param keepCollapsedVisible
*/
function getTableFormItemClass(
component: unknown,
keepCollapsedVisible: boolean,
) {
const baseClass =
component === 'RangePicker'
? 'kt-table__form-item--range'
: 'kt-table__form-item--field';
// KtTable 的操作按钮在表单外部,桌面端收起时需要保留完整左侧 18 栅格行。
return keepCollapsedVisible
? `${baseClass} kt-table__form-item--collapsed-visible`
: baseClass;
}
/**
*
*
* @param schema formGridSpan colProps.span
* @param formGrid
*/
function getTableFormSpan(
schema: KtTableFormSchema,
formGrid: KtTableFormGridOptions,
) {
const customSpan = schema.formGridSpan ?? schema.colProps?.span;
const defaultSpan =
schema.component === 'RangePicker'
? formGrid.rangeSpan
: formGrid.fieldSpan;
const span = typeof customSpan === 'number' ? customSpan : defaultSpan;
return Math.min(
toPositiveInteger(span, formGrid.fieldSpan),
formGrid.contentSpan,
);
}
/**
* style KtTable span
*
* @param schema style
* @param span
*/
function mergeFormItemStyle(schema: KtTableFormSchema, span: number) {
const gridStyle = {
'--kt-table-form-item-span': String(span),
};
const style = (schema as any).style;
if (Array.isArray(style)) return [...style, gridStyle];
if (style && typeof style === 'object') return { ...style, ...gridStyle };
if (typeof style === 'string') return [style, gridStyle];
return gridStyle;
}
/**
* class
*
* @param classes class
*/
function mergeClass(...classes: Array<string | undefined>) {
return classes.filter(Boolean).join(' ');
}
/**
* KtTable class class
*
* @param baseClass KtTable class
* @param customClass class class
*/
function mergeFormItemClass(
baseClass: string,
customClass: (() => string) | string | undefined,
) {
if (typeof customClass === 'function') {
return () => mergeClass(baseClass, customClass());
}
return mergeClass(baseClass, customClass);
}
/**
* Antdv
*
* @param column 使 key使 dataIndex
*/
export function getColumnKey(column: TableColumnType<KtTableRecord>) {
const dataIndex = Array.isArray(column.dataIndex)
? column.dataIndex.join('.')
: column.dataIndex;
return String(column.key || dataIndex || '');
}

View File

@ -49,6 +49,9 @@
},
"badgeVariants": "Badge Style"
},
"ktTableDemo": {
"title": "Table Demo"
},
"role": {
"title": "Role Management",
"list": "Role List",

View File

@ -51,6 +51,9 @@
"typeLink": "外链",
"typeMenu": "菜单"
},
"ktTableDemo": {
"title": "表格演示"
},
"role": {
"title": "角色管理",
"list": "角色列表",

View File

@ -39,6 +39,15 @@ const routes: RouteRecordRaw[] = [
},
component: () => import('#/views/system/dept/list.vue'),
},
{
path: '/system/ktTableDemo',
name: 'SystemKtTableDemo',
meta: {
icon: 'lucide:table-2',
title: $t('system.ktTableDemo.title'),
},
component: () => import('#/views/system/ktTableDemo/list'),
},
],
},
];

View File

@ -1,23 +1,33 @@
import type { TableColumnType } from 'antdv-next';
import type { WordpressBlogApi } from '#/api/blog';
import { computed, defineComponent, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { Plus, RotateCw } from '@vben/icons';
import type {
KtTableApi,
KtTableButton,
KtTableContext,
KtTableRowAction,
} from '#/components/ktTable';
import {
computed,
defineComponent,
onActivated,
onMounted,
reactive,
ref,
} from 'vue';
import { Page } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import {
Button,
Form,
FormItem,
Input,
message,
Modal,
Select,
Space,
Switch,
Table,
Tag,
TextArea,
} from 'antdv-next';
@ -30,27 +40,29 @@ import {
getTagList,
updateArticle,
} from '#/api/blog';
import { KtTable, useKtTable } from '#/components/ktTable';
import { consumeBlogArticleFilters } from '../modules/use-article-filters';
type TermOption = {
label: string;
value: number;
};
const AButton = Button as any;
type ArticleSearchValues = {
categories?: number[];
search?: string;
status?: string;
tags?: number[];
};
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 ATable = Table as any;
const ATextArea = TextArea as any;
export default defineComponent({
name: 'BlogArticleList',
setup() {
const route = useRoute();
const router = useRouter();
const { hasAccessByCodes } = useAccess();
const articleStatusOptions = [
{ color: 'success', label: '已发布', value: 'publish' },
{ color: 'default', label: '草稿', value: 'draft' },
@ -58,24 +70,15 @@ export default defineComponent({
{ color: 'processing', label: '私有', value: 'private' },
];
const loading = ref(false);
export default defineComponent({
name: 'BlogArticleList',
setup() {
const saving = ref(false);
const modalOpen = ref(false);
const rows = ref<WordpressBlogApi.Article[]>([]);
const total = ref(0);
const editingId = ref<number>();
const categoryOptions = ref<TermOption[]>([]);
const tagOptions = ref<TermOption[]>([]);
const query = reactive({
categoryId: undefined as number | undefined,
pageNo: 1,
pageSize: 10,
search: '',
status: undefined as string | undefined,
tagId: undefined as number | undefined,
});
const form = reactive<WordpressBlogApi.ArticleBody>({
categories: [],
content: '',
@ -90,32 +93,116 @@ export default defineComponent({
const modalTitle = computed(() =>
editingId.value ? '编辑文章' : '新建文章',
);
const canCreate = computed(() => hasAccessByCodes(['Blog:Article:Create']));
const canEdit = computed(() => hasAccessByCodes(['Blog:Article:Edit']));
const canDelete = computed(() => hasAccessByCodes(['Blog:Article:Delete']));
const canOperate = computed(() => canEdit.value || canDelete.value);
const columns = computed(() => {
const baseColumns = [
{ dataIndex: 'title', key: 'title', title: '标题' },
const columns: Array<TableColumnType<WordpressBlogApi.Article>> = [
{ dataIndex: 'title', key: 'title', title: '标题', width: 280 },
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
{
dataIndex: 'categories',
key: 'categories',
title: '分类',
width: 180,
},
{ dataIndex: 'categories', key: 'categories', title: '分类', width: 180 },
{ dataIndex: 'tags', key: 'tags', title: '标签', width: 180 },
{ dataIndex: 'modified', key: 'modified', title: '更新时间', width: 180 },
];
const api: KtTableApi<WordpressBlogApi.Article, ArticleSearchValues> = {
list: async (params) => {
return await getArticleList({
categories: Array.isArray(params.categories)
? params.categories.join(',')
: undefined,
pageNo: params.pageNo,
pageSize: params.pageSize,
search: params.search,
status: params.status || undefined,
tags: Array.isArray(params.tags) ? params.tags.join(',') : undefined,
});
},
};
const buttons: Array<
KtTableButton<WordpressBlogApi.Article, ArticleSearchValues>
> = [
{
dataIndex: 'modified',
key: 'modified',
title: '更新时间',
width: 180,
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: '新建文章',
onClick: openCreate,
permissionCodes: ['Blog:Article:Create'],
type: 'primary',
},
];
const rowActions: Array<
KtTableRowAction<WordpressBlogApi.Article, ArticleSearchValues>
> = [
{
key: 'edit',
label: '编辑',
onClick: openEdit,
permissionCodes: ['Blog:Article:Edit'],
},
{
confirm: (row) =>
`确认删除文章「${getRenderedText(row.title) || row.id}」吗?`,
danger: true,
key: 'delete',
label: '删除',
onClick: async (row, context) => {
await deleteArticle(row.id);
message.success('文章删除成功');
await context.reload();
},
permissionCodes: ['Blog:Article:Delete'],
},
];
return canOperate.value
? [...baseColumns, { key: 'action', title: '操作', width: 150 }]
: baseColumns;
const [registerTable, tableApi] = useKtTable<
WordpressBlogApi.Article,
ArticleSearchValues
>({
api,
buttons,
columns,
formOptions: {
schema: [
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '搜索标题或内容',
},
fieldName: 'search',
label: '关键词',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: articleStatusOptions,
},
fieldName: 'status',
label: '文章状态',
},
{
component: 'Select',
componentProps: {
allowClear: true,
mode: 'multiple',
options: categoryOptions.value,
},
fieldName: 'categories',
label: '文章分类',
},
{
component: 'Select',
componentProps: {
allowClear: true,
mode: 'multiple',
options: tagOptions.value,
},
fieldName: 'tags',
label: '文章标签',
},
],
},
immediate: false,
rowActions,
tableTitle: '文章管理',
});
function getRenderedText(value?: string | WordpressBlogApi.RenderedField) {
@ -142,38 +229,16 @@ export default defineComponent({
return options.find((item) => item.value === value)?.label || `${value}`;
}
function getRouteNumber(name: 'category' | 'tag') {
const value = route.query[name];
const rawValue = Array.isArray(value) ? value[0] : value;
const id = Number(rawValue);
async function applyPendingFilters() {
const filters = consumeBlogArticleFilters();
if (!filters) return false;
return Number.isFinite(id) && id > 0 ? id : undefined;
}
function readRouteFilters() {
query.categoryId = getRouteNumber('category');
query.tagId = getRouteNumber('tag');
}
function syncRouteFilters() {
const nextQuery = { ...route.query };
if (query.categoryId) {
nextQuery.category = `${query.categoryId}`;
} else {
delete nextQuery.category;
}
if (query.tagId) {
nextQuery.tag = `${query.tagId}`;
} else {
delete nextQuery.tag;
}
return router.replace({
name: 'BlogArticle',
query: nextQuery,
await tableApi.setSearchValues({
categories: filters.categories || [],
tags: filters.tags || [],
});
return true;
}
async function loadTermOptions() {
@ -189,84 +254,95 @@ export default defineComponent({
label: item.name,
value: item.id,
}));
}
async function loadArticles() {
loading.value = true;
try {
const result = await getArticleList({
categories: query.categoryId ? `${query.categoryId}` : undefined,
pageNo: query.pageNo,
pageSize: query.pageSize,
search: query.search,
status: query.status || undefined,
tags: query.tagId ? `${query.tagId}` : undefined,
tableApi.setProps({
formOptions: {
schema: [
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '搜索标题或内容',
},
fieldName: 'search',
label: '关键词',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: articleStatusOptions,
},
fieldName: 'status',
label: '文章状态',
},
{
component: 'Select',
componentProps: {
allowClear: true,
mode: 'multiple',
options: categoryOptions.value,
},
fieldName: 'categories',
label: '文章分类',
},
{
component: 'Select',
componentProps: {
allowClear: true,
mode: 'multiple',
options: tagOptions.value,
},
fieldName: 'tags',
label: '文章标签',
},
],
},
});
rows.value = result.list;
total.value = result.total;
} finally {
loading.value = false;
}
}
async function searchArticles() {
query.pageNo = 1;
await syncRouteFilters();
await loadArticles();
}
function resetSearch() {
query.categoryId = undefined;
query.search = '';
query.status = undefined;
query.tagId = undefined;
query.pageNo = 1;
syncRouteFilters();
loadArticles();
}
async function filterByCategory(id: number) {
query.categoryId = id;
query.pageNo = 1;
await syncRouteFilters();
await loadArticles();
await tableApi.setSearchValues({ categories: [id] });
await tableApi.search();
}
async function filterByTag(id: number) {
query.tagId = id;
query.pageNo = 1;
await syncRouteFilters();
await loadArticles();
await tableApi.setSearchValues({ tags: [id] });
await tableApi.search();
}
function openCreate() {
async function openCreate(
context?: KtTableContext<WordpressBlogApi.Article, ArticleSearchValues>,
) {
const searchValues = context
? await context.getSearchValues()
: await tableApi.getSearchValues();
editingId.value = undefined;
Object.assign(form, {
categories: [],
categories: [...(searchValues.categories || [])],
content: '',
excerpt: '',
slug: '',
status: 'draft',
sticky: false,
tags: [],
tags: [...(searchValues.tags || [])],
title: '',
});
modalOpen.value = true;
}
function openEdit(row: Record<string, any> | WordpressBlogApi.Article) {
const article = row as WordpressBlogApi.Article;
editingId.value = article.id;
function openEdit(row: WordpressBlogApi.Article) {
editingId.value = row.id;
Object.assign(form, {
categories: article.categories || [],
content: getRenderedText(article.content),
excerpt: getRenderedText(article.excerpt),
id: article.id,
slug: article.slug || '',
status: article.status || 'draft',
sticky: !!article.sticky,
tags: article.tags || [],
title: getRenderedText(article.title),
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;
}
@ -276,6 +352,7 @@ export default defineComponent({
message.warning('请填写文章标题');
return;
}
saving.value = true;
try {
const payload = {
@ -288,124 +365,42 @@ export default defineComponent({
: createArticle(payload));
message.success('文章保存成功');
modalOpen.value = false;
loadArticles();
await tableApi.reload();
} finally {
saving.value = false;
}
}
function confirmDelete(
row: Record<string, any> | WordpressBlogApi.Article,
) {
const article = row as WordpressBlogApi.Article;
Modal.confirm({
content: `确认删除文章「${getRenderedText(article.title) || article.id}」吗?`,
onOk: async () => {
await deleteArticle(article.id);
message.success('文章删除成功');
loadArticles();
},
title: '删除文章',
});
}
function handleTableChange(pagination: any) {
query.pageNo = pagination.current || 1;
query.pageSize = pagination.pageSize || 10;
loadArticles();
}
onMounted(async () => {
readRouteFilters();
await loadTermOptions();
await loadArticles();
await applyPendingFilters();
await tableApi.reload();
});
onActivated(async () => {
if (await applyPendingFilters()) {
await tableApi.search();
}
});
return () => (
<Page autoContentHeight>
<div class="flex h-full min-h-0 flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3 bg-card px-4 py-3">
<Space wrap>
<AInput
allowClear
class="w-[260px]"
onPressEnter={searchArticles}
onUpdate:value={(value: string) => {
query.search = value;
}}
placeholder="搜索标题或内容"
value={query.search}
/>
<ASelect
allowClear
class="w-[150px]"
onUpdate:value={(value: string | undefined) => {
query.status = value;
}}
options={articleStatusOptions}
placeholder="文章状态"
value={query.status}
/>
<ASelect
allowClear
class="w-[150px]"
onUpdate:value={(value: number | undefined) => {
query.categoryId = value;
}}
options={categoryOptions.value}
placeholder="文章分类"
value={query.categoryId}
/>
<ASelect
allowClear
class="w-[150px]"
onUpdate:value={(value: number | undefined) => {
query.tagId = value;
}}
options={tagOptions.value}
placeholder="文章标签"
value={query.tagId}
/>
<AButton onClick={searchArticles}></AButton>
<AButton onClick={resetSearch}>
<RotateCw class="size-4" />
</AButton>
</Space>
{canCreate.value ? (
<AButton onClick={openCreate} type="primary">
<Plus class="size-4" />
</AButton>
) : null}
</div>
<div class="min-h-0 flex-1 bg-card p-4">
<ATable
columns={columns.value}
dataSource={rows.value}
loading={loading.value}
onChange={handleTableChange}
pagination={{
current: query.pageNo,
pageSize: query.pageSize,
showSizeChanger: true,
total: total.value,
}}
rowKey="id"
scroll={{ x: 980 }}
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const article = record as WordpressBlogApi.Article;
if (column.key === 'title') {
return (
<div class="max-w-[420px]">
<div class="truncate font-medium">
{getRenderedText(record.title) || '-'}
{getRenderedText(article.title) || '-'}
</div>
{record.link ? (
{article.link ? (
<a
class="text-xs text-primary"
href={record.link}
href={article.link}
target="_blank"
>
@ -416,14 +411,14 @@ export default defineComponent({
}
if (column.key === 'status') {
const status = getStatusOption(record.status);
const status = getStatusOption(article.status);
return <Tag color={status?.color}>{status?.label}</Tag>;
}
if (column.key === 'categories') {
return record.categories?.length ? (
<Space size={[4, 4]} wrap>
{record.categories.map((item: number) => (
return article.categories?.length ? (
<div class="flex flex-wrap gap-1">
{article.categories.map((item) => (
<Tag
class="cursor-pointer"
color="blue"
@ -433,16 +428,16 @@ export default defineComponent({
{getTermLabel(categoryOptions.value, item)}
</Tag>
))}
</Space>
</div>
) : (
<span>-</span>
);
}
if (column.key === 'tags') {
return record.tags?.length ? (
<Space size={[4, 4]} wrap>
{record.tags.map((item: number) => (
return article.tags?.length ? (
<div class="flex flex-wrap gap-1">
{article.tags.map((item) => (
<Tag
class="cursor-pointer"
key={item}
@ -451,39 +446,16 @@ export default defineComponent({
{getTermLabel(tagOptions.value, item)}
</Tag>
))}
</Space>
</div>
) : (
<span>-</span>
);
}
if (column.key === 'action') {
return (
<Space>
{canEdit.value ? (
<AButton onClick={() => openEdit(record)} type="link">
</AButton>
) : null}
{canDelete.value ? (
<AButton
danger
onClick={() => confirmDelete(record)}
type="link"
>
</AButton>
) : null}
</Space>
);
}
return undefined;
},
}}
/>
</div>
</div>
<AModal
confirmLoading={saving.value}

View File

@ -1,6 +1,13 @@
import type { TableColumnType } from 'antdv-next';
import type { PropType } from 'vue';
import type { WordpressBlogApi } from '#/api/blog';
import type {
KtTableApi,
KtTableButton,
KtTableRowAction,
} from '#/components/ktTable';
import {
computed,
@ -12,20 +19,16 @@ import {
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { Plus, RotateCw } from '@vben/icons';
import { Plus } from '@vben/icons';
import {
Button,
Form,
FormItem,
Input,
message,
Modal,
Select,
Space,
Table,
TextArea,
} from 'antdv-next';
@ -39,12 +42,18 @@ import {
updateCategory,
updateTag,
} from '#/api/blog';
import { KtTable, useKtTable } from '#/components/ktTable';
const AButton = Button as any;
import { setBlogArticleFilters } from './use-article-filters';
type TermSearchValues = {
search?: string;
};
const AKtTable = KtTable as any;
const AInput = Input as any;
const AModal = Modal as any;
const ASelect = Select as any;
const ATable = Table as any;
const ATextArea = TextArea as any;
export default defineComponent({
@ -62,20 +71,11 @@ export default defineComponent({
setup(props) {
const route = useRoute();
const router = useRouter();
const { hasAccessByCodes } = useAccess();
const loading = ref(false);
const saving = ref(false);
const modalOpen = ref(false);
const rows = ref<WordpressBlogApi.Term[]>([]);
const total = ref(0);
const editingId = ref<number>();
const query = reactive({
pageNo: 1,
pageSize: 10,
search: '',
});
const tableRows = ref<WordpressBlogApi.Term[]>([]);
const form = reactive<WordpressBlogApi.TermBody>({
description: '',
@ -90,72 +90,139 @@ export default defineComponent({
const permissionModule = computed(() =>
props.kind === 'category' ? 'Blog:Category' : 'Blog:Tag',
);
const canCreate = computed(() =>
hasAccessByCodes([`${permissionModule.value}:Create`]),
);
const canEdit = computed(() =>
hasAccessByCodes([`${permissionModule.value}:Edit`]),
);
const canDelete = computed(() =>
hasAccessByCodes([`${permissionModule.value}:Delete`]),
);
const canViewArticles = computed(() =>
hasAccessByCodes(['Blog:Article:List']),
);
const canOperate = computed(
() => canViewArticles.value || canEdit.value || canDelete.value,
);
const columns = computed(() => {
const baseColumns = [
{ dataIndex: 'name', key: 'name', title: '名称' },
const columns = computed<Array<TableColumnType<WordpressBlogApi.Term>>>(
() => [
{ dataIndex: 'name', key: 'name', title: '名称', width: 220 },
{ dataIndex: 'slug', key: 'slug', title: '别名', width: 180 },
{ dataIndex: 'count', key: 'count', title: '文章数', width: 100 },
{ dataIndex: 'description', key: 'description', title: '描述' },
];
return canOperate.value
? [...baseColumns, { key: 'action', title: '操作', width: 220 }]
: baseColumns;
});
{
dataIndex: 'description',
key: 'description',
title: '描述',
width: 300,
},
],
);
const parentOptions = computed(() =>
rows.value
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 = {
hide_empty: false,
pageNo: params.pageNo,
pageSize: params.pageSize,
search: params.search,
};
return props.kind === 'category'
? await getCategoryList(requestParams)
: await getTagList(requestParams);
},
};
const buttons = computed<
Array<KtTableButton<WordpressBlogApi.Term, TermSearchValues>>
>(() => [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: `新建${props.title}`,
onClick: openCreate,
permissionCodes: [`${permissionModule.value}:Create`],
type: 'primary',
},
]);
const rowActions = computed<
Array<KtTableRowAction<WordpressBlogApi.Term, TermSearchValues>>
>(() => [
{
key: 'articles',
label: '查看文章',
onClick: openRelatedArticles,
permissionCodes: ['Blog:Article:List'],
},
{
key: 'edit',
label: '编辑',
onClick: openEdit,
permissionCodes: [`${permissionModule.value}:Edit`],
},
{
confirm: (row) =>
`确认删除${props.title}${row.name}」吗WordPress 分类和标签不支持回收站,本操作会强制删除该条目,但不会删除已关联文章。`,
danger: true,
key: 'delete',
label: '删除',
onClick: async (row, context) => {
await (props.kind === 'category'
? deleteCategory(row.id)
: deleteTag(row.id));
message.success(`${props.title}删除成功`);
await context.reload();
},
permissionCodes: [`${permissionModule.value}:Delete`],
},
]);
const [registerTable, tableApi] = useKtTable<
WordpressBlogApi.Term,
TermSearchValues
>({
afterFetch: (result) => {
tableRows.value = Array.isArray(result)
? result
: result.list || result.records || result.items || [];
return result;
},
api,
buttons: buttons.value,
columns: columns.value,
formOptions: {
schema: [
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: `搜索${props.title}名称`,
},
fieldName: 'search',
label: '关键词',
},
],
},
immediate: false,
rowActions: rowActions.value,
tableTitle: props.title,
});
function getRouteSearch() {
const value = route.query.search;
return Array.isArray(value) ? value[0] || '' : value || '';
}
async function requestList() {
const params = {
hide_empty: false,
pageNo: query.pageNo,
pageSize: query.pageSize,
search: query.search,
};
return props.kind === 'category'
? await getCategoryList(params)
: await getTagList(params);
}
async function loadTerms() {
loading.value = true;
try {
const result = await requestList();
rows.value = result.list;
total.value = result.total;
} finally {
loading.value = false;
}
}
function resetSearch() {
query.search = '';
query.pageNo = 1;
loadTerms();
function syncTableProps() {
tableApi.setProps({
buttons: buttons.value,
columns: columns.value,
formOptions: {
schema: [
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: `搜索${props.title}名称`,
},
fieldName: 'search',
label: '关键词',
},
],
},
rowActions: rowActions.value,
tableTitle: props.title,
});
}
function openCreate() {
@ -169,15 +236,14 @@ export default defineComponent({
modalOpen.value = true;
}
function openEdit(row: Record<string, any> | WordpressBlogApi.Term) {
const term = row as WordpressBlogApi.Term;
editingId.value = term.id;
function openEdit(row: WordpressBlogApi.Term) {
editingId.value = row.id;
Object.assign(form, {
description: term.description || '',
id: term.id,
name: term.name,
parent: term.parent || undefined,
slug: term.slug || '',
description: row.description || '',
id: row.id,
name: row.name,
parent: row.parent || undefined,
slug: row.slug || '',
});
modalOpen.value = true;
}
@ -187,6 +253,7 @@ export default defineComponent({
message.warning(`请填写${props.title}名称`);
return;
}
saving.value = true;
try {
const payload = {
@ -203,139 +270,51 @@ export default defineComponent({
}
message.success(`${props.title}保存成功`);
modalOpen.value = false;
loadTerms();
await tableApi.reload();
} finally {
saving.value = false;
}
}
function confirmDelete(row: Record<string, any> | WordpressBlogApi.Term) {
const term = row as WordpressBlogApi.Term;
Modal.confirm({
content: `确认删除${props.title}${term.name}」吗?`,
onOk: async () => {
await (props.kind === 'category'
? deleteCategory(term.id)
: deleteTag(term.id));
message.success(`${props.title}删除成功`);
loadTerms();
},
title: `删除${props.title}`,
});
}
function openRelatedArticles(
row: Record<string, any> | WordpressBlogApi.Term,
) {
const term = row as WordpressBlogApi.Term;
function openRelatedArticles(row: WordpressBlogApi.Term) {
setBlogArticleFilters(
props.kind === 'category'
? { categories: [row.id] }
: { tags: [row.id] },
);
router.push({
name: 'BlogArticle',
query:
props.kind === 'category'
? { category: `${term.id}` }
: { tag: `${term.id}` },
});
}
function handleTableChange(pagination: any) {
query.pageNo = pagination.current || 1;
query.pageSize = pagination.pageSize || 10;
loadTerms();
async function reloadWithRouteSearch() {
syncTableProps();
await tableApi.setSearchValues({ search: getRouteSearch() });
await tableApi.reload();
}
watch(
() => props.kind,
() => {
query.search = getRouteSearch();
query.pageNo = 1;
loadTerms();
reloadWithRouteSearch();
},
);
onMounted(() => {
query.search = getRouteSearch();
loadTerms();
reloadWithRouteSearch();
});
return () => (
<Page autoContentHeight>
<div class="flex h-full min-h-0 flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3 bg-card px-4 py-3">
<Space wrap>
<AInput
allowClear
class="w-[260px]"
onPressEnter={loadTerms}
onUpdate:value={(value: string) => {
query.search = value;
}}
placeholder={`搜索${props.title}名称`}
value={query.search}
/>
<AButton onClick={loadTerms}></AButton>
<AButton onClick={resetSearch}>
<RotateCw class="size-4" />
</AButton>
</Space>
{canCreate.value ? (
<AButton onClick={openCreate} type="primary">
<Plus class="size-4" />
{props.title}
</AButton>
) : null}
</div>
<div class="min-h-0 flex-1 bg-card p-4">
<ATable
columns={columns.value}
dataSource={rows.value}
loading={loading.value}
onChange={handleTableChange}
pagination={{
current: query.pageNo,
pageSize: query.pageSize,
showSizeChanger: true,
total: total.value,
}}
rowKey="id"
scroll={{ x: 820 }}
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const term = record as WordpressBlogApi.Term;
if (column.key === 'description') {
return (
<span class="line-clamp-2">
{record.description || '-'}
</span>
);
}
if (column.key === 'action') {
return (
<Space>
{canViewArticles.value ? (
<AButton
onClick={() => openRelatedArticles(record)}
type="link"
>
</AButton>
) : null}
{canEdit.value ? (
<AButton onClick={() => openEdit(record)} type="link">
</AButton>
) : null}
{canDelete.value ? (
<AButton
danger
onClick={() => confirmDelete(record)}
type="link"
>
</AButton>
) : null}
</Space>
<span class="line-clamp-2">{term.description || '-'}</span>
);
}
@ -343,8 +322,6 @@ export default defineComponent({
},
}}
/>
</div>
</div>
<AModal
confirmLoading={saving.value}

View File

@ -0,0 +1,20 @@
export interface BlogArticleFilters {
categories?: number[];
tags?: number[];
}
let pendingFilters: BlogArticleFilters | null = null;
export function setBlogArticleFilters(filters: BlogArticleFilters) {
pendingFilters = {
categories: filters.categories ? [...filters.categories] : undefined,
tags: filters.tags ? [...filters.tags] : undefined,
};
}
export function consumeBlogArticleFilters() {
const filters = pendingFilters;
pendingFilters = null;
return filters;
}

View File

@ -1,21 +1,25 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept';
import type { TableColumnType } from 'antdv-next';
import type { SystemDeptApi } from '#/api/system/dept';
import type {
KtTableApi,
KtTableButton,
KtTableContext,
KtTableRowAction,
} from '#/components/ktTable';
import { h } from 'vue';
import { useAccess } from '@vben/access';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message } from 'antdv-next';
import { message, Tag } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteDept, getDeptList } from '#/api/system/dept';
import { KtTable, useKtTable } from '#/components/ktTable';
import { $t } from '#/locales';
import { useColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
@ -23,132 +27,139 @@ const [FormModal, formModalApi] = useVbenModal({
destroyOnClose: true,
});
const { hasAccessByCodes } = useAccess();
const columns: Array<TableColumnType<SystemDeptApi.SystemDept>> = [
{
dataIndex: 'name',
fixed: 'left',
key: 'name',
title: $t('system.dept.deptName'),
width: 180,
},
{
align: 'center',
dataIndex: 'status',
key: 'status',
title: $t('system.dept.status'),
width: 100,
},
{
dataIndex: 'createTime',
key: 'createTime',
title: $t('system.dept.createTime'),
width: 180,
},
{
dataIndex: 'remark',
key: 'remark',
title: $t('system.dept.remark'),
width: 260,
},
];
function hasPermission(code: string) {
return hasAccessByCodes([code]);
}
const api: KtTableApi<SystemDeptApi.SystemDept> = {
list: async () => {
return await getDeptList();
},
};
const buttons: Array<KtTableButton<SystemDeptApi.SystemDept>> = [
{
icon: () => h(Plus, { class: 'kt-table__button-icon' }),
key: 'create',
label: $t('ui.actionTitle.create', [$t('system.dept.name')]),
onClick: onCreate,
permissionCodes: ['System:Dept:Create'],
type: 'primary',
},
];
const rowActions: Array<KtTableRowAction<SystemDeptApi.SystemDept>> = [
{
key: 'append',
label: '新增下级',
onClick: onAppend,
permissionCodes: ['System:Dept:Create'],
},
{
key: 'edit',
label: $t('common.edit'),
onClick: onEdit,
permissionCodes: ['System:Dept:Edit'],
},
{
confirm: (row) => `确认删除「${row.name}」吗?`,
danger: true,
disabled: (row) => !!row.children?.length,
key: 'delete',
label: $t('common.delete'),
onClick: onDelete,
permissionCodes: ['System:Dept:Delete'],
},
];
const [registerTable, tableApi] = useKtTable<SystemDeptApi.SystemDept>({
api,
buttons,
columns,
rowActions,
showDefaultButtons: false,
showFooter: false,
showPagination: false,
tableTitle: '部门列表',
});
/**
* 编辑部门
* @param row
*/
function onEdit(row: SystemDeptApi.SystemDept) {
formModalApi.setData(row).open();
}
/**
* 添加下级部门
* @param row
*/
function onAppend(row: SystemDeptApi.SystemDept) {
formModalApi.setData({ pid: row.id }).open();
}
/**
* 创建新部门
*/
function onCreate() {
formModalApi.setData(null).open();
}
/**
* 删除部门
* @param row
*/
function onDelete(row: SystemDeptApi.SystemDept) {
async function onDelete(
row: SystemDeptApi.SystemDept,
context?: KtTableContext<SystemDeptApi.SystemDept>,
) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
deleteDept(row.id)
.then(() => {
try {
await deleteDept(row.id);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_process_msg',
});
refreshGrid();
})
.catch(() => {
await (context || tableApi).reload();
} catch {
hideLoading();
});
}
/**
* 表格操作按钮的回调函数
*/
function onActionClick({
code,
row,
}: OnActionClickParams<SystemDeptApi.SystemDept>) {
switch (code) {
case 'append': {
onAppend(row);
break;
}
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridEvents: {},
gridOptions: {
columns: useColumns(onActionClick, { canAccess: hasPermission }),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_params) => {
return await getDeptList();
},
},
},
toolbarConfig: {
custom: true,
export: false,
refresh: true,
zoom: true,
},
treeConfig: {
parentField: 'pid',
rowField: 'id',
transform: false,
},
} as VxeTableGridOptions,
});
/**
* 刷新表格
*/
function refreshGrid() {
gridApi.query();
function refreshTable() {
tableApi.reload();
}
</script>
<template>
<Page auto-content-height>
<FormModal @success="refreshGrid" />
<Grid table-title="部门列表">
<template #toolbar-tools>
<Button
v-if="hasPermission('System:Dept:Create')"
type="primary"
@click="onCreate"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.dept.name')]) }}
</Button>
<FormModal @success="refreshTable" />
<KtTable @register="registerTable">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<Tag :color="record.status === 1 ? 'success' : 'default'">
{{
record.status === 1 ? $t('common.enabled') : $t('common.disabled')
}}
</Tag>
</template>
</Grid>
</template>
</KtTable>
</Page>
</template>

View File

@ -0,0 +1,646 @@
import type { TableColumnType } from 'antdv-next';
import type {
KtTableApi,
KtTableButton,
KtTableHook,
KtTableRowAction,
KtTableStatistic,
} from '#/components/ktTable';
import { computed, defineComponent, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon, Plus, SvgDownloadIcon } from '@vben/icons';
import { message, Progress, Space, Tag } from 'antdv-next';
import {
defineKtTableHook,
defineKtTableModule,
KtTable,
useKtTable,
} from '#/components/ktTable';
import './style.scss';
const AKtTable = KtTable as any;
const AProgress = Progress as any;
const ASpace = Space as any;
type DemoLevel = 'business' | 'core' | 'template';
type DemoStatus = 'disabled' | 'draft' | 'released' | 'testing';
type DemoChannel = 'admin' | 'playground' | 'web';
type DemoRow = {
channel: DemoChannel;
code: string;
coverage: number;
description: string;
groupName: string;
id: number;
level: DemoLevel;
name: string;
owner: string;
status: DemoStatus;
updatedAt: string;
usageCount: number;
version: string;
};
type DemoSearchValues = {
channel?: DemoChannel;
endDate?: string;
keyword?: string;
level?: DemoLevel;
owner?: string;
startDate?: string;
status?: DemoStatus;
updatedAt?: unknown;
};
const levelOptions: Array<{
color: string;
label: string;
value: DemoLevel;
}> = [
{ color: 'blue', label: '核心组件', value: 'core' },
{ color: 'green', label: '业务组件', value: 'business' },
{ color: 'purple', label: '模板组件', value: 'template' },
];
const statusOptions: Array<{
color: string;
label: string;
value: DemoStatus;
}> = [
{ color: 'default', label: '草稿', value: 'draft' },
{ color: 'processing', label: '测试中', value: 'testing' },
{ color: 'success', label: '已发布', value: 'released' },
{ color: 'error', label: '已停用', value: 'disabled' },
];
const channelOptions: Array<{
color: string;
label: string;
value: DemoChannel;
}> = [
{ color: 'blue', label: 'Admin', value: 'admin' },
{ color: 'cyan', label: 'Web', value: 'web' },
{ color: 'gold', label: 'Playground', value: 'playground' },
];
function wait(milliseconds = 240) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function pickOption<T extends string>(
options: Array<{ color: string; label: string; value: T }>,
value: T,
) {
return options.find((item) => item.value === value);
}
function readDate(value: unknown) {
if (!value) return '';
if (typeof value === 'string') return value.slice(0, 10);
if (value instanceof Date) return value.toISOString().slice(0, 10);
if (
typeof value === 'object' &&
value !== null &&
'format' in value &&
typeof value.format === 'function'
) {
return value.format('YYYY-MM-DD');
}
return String(value).slice(0, 10);
}
function createRows() {
const names = [
'权限按钮',
'可编辑表格',
'统计汇总',
'远程搜索',
'发布抽屉',
'字典标签',
'图表卡片',
'文件上传',
'权限树',
'审计日志',
];
const groups = ['系统基础', '博客运营', '组件中心', '数据看板'];
const owners = ['Admin', '产品组', '研发组', '运营组'];
const levels: DemoLevel[] = ['core', 'business', 'template'];
const statuses: DemoStatus[] = ['released', 'testing', 'draft', 'disabled'];
const channels: DemoChannel[] = ['admin', 'web', 'playground'];
return Array.from({ length: 72 }, (_, index) => {
const id = index + 1;
const name = names[index % names.length] || names[0];
return {
channel: channels[index % channels.length] || 'admin',
code: `KT-CMP-${String(id).padStart(4, '0')}`,
coverage: 58 + ((index * 7) % 42),
description: `用于${groups[index % groups.length]}${name}能力验证`,
groupName: groups[index % groups.length] || '系统基础',
id,
level: levels[index % levels.length] || 'business',
name: `${name} ${id}`,
owner: owners[index % owners.length] || 'Admin',
status: statuses[index % statuses.length] || 'draft',
updatedAt: `2026-05-${String((index % 19) + 1).padStart(2, '0')}`,
usageCount: 20 + ((index * 13) % 260),
version: `v${1 + (index % 3)}.${index % 10}.${index % 6}`,
} satisfies DemoRow;
});
}
export default defineComponent({
name: 'SystemKtTableDemo',
setup() {
const rows = ref<DemoRow[]>(createRows());
const requestTimes = ref(0);
const actionTimes = ref(0);
const columns: Array<TableColumnType<DemoRow>> = [
{
dataIndex: 'name',
fixed: 'left',
key: 'name',
sorter: true,
title: '组件名称',
width: 260,
},
{
dataIndex: 'status',
key: 'status',
sorter: true,
title: '状态',
width: 120,
},
{
dataIndex: 'level',
key: 'level',
sorter: true,
title: '等级',
width: 120,
},
{
dataIndex: 'owner',
key: 'owner',
sorter: true,
title: '负责人',
width: 130,
},
{
dataIndex: 'coverage',
key: 'coverage',
sorter: true,
title: '覆盖率',
width: 160,
},
{
dataIndex: 'updatedAt',
key: 'updatedAt',
sorter: true,
title: '更新时间',
width: 150,
},
];
const api: KtTableApi<DemoRow, DemoSearchValues> = {
list: async (params) => {
await wait();
let list = [...rows.value];
const keyword = params.keyword?.trim();
const startDate = readDate(params.startDate);
const endDate = readDate(params.endDate);
if (keyword) {
list = list.filter((item) =>
[item.code, item.name, item.description, item.groupName]
.join(' ')
.includes(keyword),
);
}
if (params.owner) {
list = list.filter((item) => item.owner.includes(params.owner || ''));
}
if (params.status) {
list = list.filter((item) => item.status === params.status);
}
if (params.level) {
list = list.filter((item) => item.level === params.level);
}
if (params.channel) {
list = list.filter((item) => item.channel === params.channel);
}
if (startDate) {
list = list.filter((item) => item.updatedAt >= startDate);
}
if (endDate) {
list = list.filter((item) => item.updatedAt <= endDate);
}
if (params.sortField && params.sortOrder) {
const field = String(params.sortField) as keyof DemoRow;
list.sort((first, second) => {
const firstValue = first[field];
const secondValue = second[field];
if (firstValue === secondValue) return 0;
const result = firstValue > secondValue ? 1 : -1;
return params.sortOrder === 'descend' ? -result : result;
});
}
const pageNo = Number(params.pageNo || 1);
const pageSize = Number(params.pageSize || 20);
const start = (pageNo - 1) * pageSize;
return {
list: list.slice(start, start + pageSize),
total: list.length,
};
},
};
const demoHook: KtTableHook<DemoRow, DemoSearchValues> = defineKtTableHook({
name: 'ktTableDemoCounter',
onAfterAction: () => {
actionTimes.value += 1;
},
onAfterFetch: () => {
requestTimes.value += 1;
},
});
const channelModule = defineKtTableModule<DemoRow, DemoSearchValues>({
buttons: [
{
icon: <IconifyIcon class="kt-table-demo__icon" icon="lucide:plug" />,
key: 'moduleSync',
label: '同步渠道',
onClick: () => {
message.info('可插拔模块按钮已触发');
},
},
],
columns: [
{
dataIndex: 'channel',
key: 'channel',
sorter: true,
title: '渠道',
width: 130,
},
{
dataIndex: 'usageCount',
key: 'usageCount',
sorter: true,
title: '使用次数',
width: 130,
},
],
formOptions: {
schema: [
{
component: 'Select',
componentProps: {
allowClear: true,
options: channelOptions,
},
fieldName: 'channel',
label: '渠道',
},
],
},
name: 'channelModule',
statistics: [
{
columnKey: 'channel',
key: 'requestTimes',
label: '请求',
value: () => `${requestTimes.value}`,
},
],
});
const buttons: Array<KtTableButton<DemoRow, DemoSearchValues>> = [
{
icon: <Plus class="kt-table-demo__icon" />,
key: 'create',
label: '新增组件',
onClick: async (context) => {
createDemoRow();
await context.reload();
},
permissionCodes: ['System:KtTableDemo:Create'],
type: 'primary',
},
{
icon: (
<IconifyIcon class="kt-table-demo__icon" icon="lucide:badge-check" />
),
key: 'batchRelease',
label: '批量发布',
onClick: async (context) => {
const selectedRows = context.selectedRows();
if (selectedRows.length === 0) {
message.warning('请先选择要发布的组件');
return;
}
updateRows(selectedRows, { status: 'released' });
message.success(`已发布 ${selectedRows.length} 个组件`);
await context.reload();
},
permissionCodes: ['System:KtTableDemo:Edit'],
},
{
icon: <SvgDownloadIcon class="kt-table-demo__icon" />,
key: 'export',
label: '导出当前页',
onClick: (context) => {
message.info(`当前页 ${context.getRows().length} 条数据`);
},
},
];
const rowActions: Array<KtTableRowAction<DemoRow, DemoSearchValues>> = [
{
key: 'preview',
label: '预览',
onClick: (row) => {
message.info(`预览组件:${row.code}`);
},
},
{
disabled: (row) => row.status === 'released',
key: 'release',
label: '发布',
onClick: async (row, context) => {
updateRows([row], { status: 'released' });
message.success(`已发布:${row.name}`);
await context.reload();
},
permissionCodes: ['System:KtTableDemo:Edit'],
},
{
key: 'copy',
label: '复制',
onClick: (row) => {
message.info(`已复制组件编号:${row.code}`);
},
},
{
key: 'log',
label: '日志',
onClick: (row) => {
message.info(`查看操作日志:${row.code}`);
},
},
{
key: 'detail',
label: '详情',
onClick: (row) => {
message.info(`查看组件详情:${row.name}`);
},
},
{
confirm: (row) => `确认删除「${row.name}」吗?`,
danger: true,
key: 'delete',
label: '删除',
onClick: async (row, context) => {
removeRows([row]);
message.success('组件已删除');
await context.reload();
},
permissionCodes: ['System:KtTableDemo:Delete'],
},
];
const statistics: Array<KtTableStatistic<DemoRow, DemoSearchValues>> = [
{
columnKey: 'name',
key: 'pageCount',
label: '本页',
value: (context) => `${context.getRows().length}`,
},
{
columnKey: 'status',
key: 'releaseCount',
label: '已发布',
value: (context) =>
context.getRows().filter((item) => item.status === 'released').length,
},
{
columnKey: 'coverage',
key: 'averageCoverage',
label: '平均覆盖率',
value: (context) => {
const currentRows = context.getRows();
if (currentRows.length === 0) return '0%';
const total = currentRows.reduce(
(sum, item) => sum + item.coverage,
0,
);
return `${Math.round(total / currentRows.length)}%`;
},
},
{
columnKey: 'usageCount',
key: 'usageCount',
label: '调用',
value: (context) =>
context.getRows().reduce((sum, item) => sum + item.usageCount, 0),
},
];
function createDemoRow() {
const nextId = Math.max(0, ...rows.value.map((item) => item.id)) + 1;
rows.value = [
{
channel: 'admin',
code: `KT-CMP-${String(nextId).padStart(4, '0')}`,
coverage: 86,
description: '用于演示新增后的刷新和统计联动',
groupName: '组件中心',
id: nextId,
level: 'business',
name: `新建组件 ${nextId}`,
owner: 'Admin',
status: 'draft',
updatedAt: '2026-05-19',
usageCount: 0,
version: 'v1.0.0',
},
...rows.value,
];
message.success('已新增一条组件数据');
}
function updateRows(targets: DemoRow[], patch: Partial<DemoRow>) {
const ids = new Set(targets.map((item) => item.id));
rows.value = rows.value.map((item) =>
ids.has(item.id) ? { ...item, ...patch } : item,
);
}
function removeRows(targets: DemoRow[]) {
const ids = new Set(targets.map((item) => item.id));
rows.value = rows.value.filter((item) => !ids.has(item.id));
}
const [registerTable] = useKtTable<DemoRow, DemoSearchValues>({
api,
buttons,
columns,
formOptions: {
fieldMappingTime: [['updatedAt', ['startDate', 'endDate']]],
schema: [
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '组件名称 / 编码 / 描述',
},
fieldName: 'keyword',
label: '关键词',
},
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '负责人',
},
fieldName: 'owner',
label: '负责人',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: statusOptions,
},
fieldName: 'status',
label: '状态',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: levelOptions,
},
fieldName: 'level',
label: '等级',
},
{
component: 'RangePicker',
fieldName: 'updatedAt',
label: '更新时间',
},
],
},
hooks: [demoHook],
modules: [channelModule],
pageSize: 20,
rowActions,
showSelection: true,
statistics,
tableTitle: 'KT 组件发布清单 Demo',
});
const summaryText = computed(
() => `请求 ${requestTimes.value} 次 / 操作 ${actionTimes.value}`,
);
function renderStatus(status: DemoStatus) {
const option = pickOption(statusOptions, status);
return <Tag color={option?.color}>{option?.label || status}</Tag>;
}
function renderLevel(level: DemoLevel) {
const option = pickOption(levelOptions, level);
return <Tag color={option?.color}>{option?.label || level}</Tag>;
}
function renderChannel(channel: DemoChannel) {
const option = pickOption(channelOptions, channel);
return <Tag color={option?.color}>{option?.label || channel}</Tag>;
}
return () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
title: () => <span>KT Demo</span>,
footer: () => (
<span class="kt-table-demo__footer-note">
{summaryText.value}
</span>
),
bodyCell: ({ column, record }: any) => {
const row = record as DemoRow;
if (column.key === 'name') {
return (
<div class="kt-table-demo__component">
<div class="kt-table-demo__component-title">
<span class="kt-table-demo__component-name">
{row.name}
</span>
<span class="kt-table-demo__component-version">
{row.version}
</span>
</div>
<div class="kt-table-demo__component-meta">
{row.code} / {row.groupName}
</div>
<div class="kt-table-demo__component-desc">
{row.description}
</div>
</div>
);
}
if (column.key === 'status') {
return renderStatus(row.status);
}
if (column.key === 'level') {
return renderLevel(row.level);
}
if (column.key === 'channel') {
return renderChannel(row.channel);
}
if (column.key === 'coverage') {
return (
<ASpace class="kt-table-demo__coverage" size={8}>
<AProgress
percent={row.coverage}
showInfo={false}
size="small"
status={row.coverage >= 80 ? 'success' : 'normal'}
/>
<span>{row.coverage}%</span>
</ASpace>
);
}
return undefined;
},
}}
/>
</Page>
);
},
});

View File

@ -0,0 +1,57 @@
.kt-table-demo {
&__icon {
width: 16px;
height: 16px;
}
&__component {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
&__component-title {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
&__component-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
white-space: nowrap;
}
&__component-version {
flex-shrink: 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
&__component-meta,
&__component-desc {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
line-height: 18px;
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
&__coverage {
width: 100%;
.ant-progress {
min-width: 86px;
max-width: 120px;
}
}
&__footer-note {
color: hsl(var(--muted-foreground));
}
}

View File

@ -1,22 +1,28 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { TableColumnType } from 'antdv-next';
import type { SystemMenuApi } from '#/api/system/menu';
import type {
KtTableApi,
KtTableButton,
KtTableContext,
KtTableRowAction,
} from '#/components/ktTable';
import { h } from 'vue';
import { useAccess } from '@vben/access';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon, Plus } from '@vben/icons';
import { $t } from '@vben/locales';
import { MenuBadge } from '@vben-core/menu-ui';
import { Button, message } from 'antdv-next';
import { message, Tag } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteMenu, getMenuList, SystemMenuApi } from '#/api/system/menu';
import { deleteMenu, getMenuList } from '#/api/system/menu';
import { KtTable, useKtTable } from '#/components/ktTable';
import { useColumns } from './data';
import { getMenuTypeOptions } from './data';
import Form from './modules/form.vue';
const [FormDrawer, formDrawerApi] = useVbenDrawer({
@ -24,142 +30,237 @@ const [FormDrawer, formDrawerApi] = useVbenDrawer({
destroyOnClose: true,
});
const { hasAccessByCodes } = useAccess();
const menuTypeOptions = getMenuTypeOptions();
function hasPermission(code: string) {
return hasAccessByCodes([code]);
}
const columns: Array<TableColumnType<SystemMenuApi.SystemMenu>> = [
{
dataIndex: ['meta', 'title'],
fixed: 'left',
key: 'title',
title: $t('system.menu.menuTitle'),
width: 250,
},
{
align: 'center',
dataIndex: 'type',
key: 'type',
title: $t('system.menu.type'),
width: 100,
},
{
dataIndex: 'authCode',
key: 'authCode',
title: $t('system.menu.authCode'),
width: 200,
},
{
dataIndex: 'path',
key: 'path',
title: $t('system.menu.path'),
width: 200,
},
{
dataIndex: 'component',
key: 'component',
title: $t('system.menu.component'),
width: 220,
},
{
align: 'center',
dataIndex: 'status',
key: 'status',
title: $t('system.menu.status'),
width: 100,
},
];
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useColumns(onActionClick, { canAccess: hasPermission }),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
const api: KtTableApi<SystemMenuApi.SystemMenu> = {
list: getMenuList,
};
const buttons: Array<KtTableButton<SystemMenuApi.SystemMenu>> = [
{
icon: () => h(Plus, { class: 'kt-table__button-icon' }),
key: 'create',
label: $t('ui.actionTitle.create', [$t('system.menu.name')]),
onClick: onCreate,
permissionCodes: ['System:Menu:Create'],
type: 'primary',
},
proxyConfig: {
ajax: {
query: async (_params) => {
return await getMenuList();
];
const rowActions: Array<KtTableRowAction<SystemMenuApi.SystemMenu>> = [
{
key: 'append',
label: '新增下级',
onClick: onAppend,
permissionCodes: ['System:Menu:Create'],
},
{
key: 'edit',
label: $t('common.edit'),
onClick: onEdit,
permissionCodes: ['System:Menu:Edit'],
},
{
confirm: (row) => `确认删除「${row.name}」吗?`,
danger: true,
key: 'delete',
label: $t('common.delete'),
onClick: onDelete,
permissionCodes: ['System:Menu:Delete'],
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: true,
zoom: true,
},
treeConfig: {
parentField: 'pid',
rowField: 'id',
transform: false,
},
} as VxeTableGridOptions,
];
const [registerTable, tableApi] = useKtTable<SystemMenuApi.SystemMenu>({
api,
buttons,
columns,
rowActions,
showDefaultButtons: false,
showFooter: false,
showPagination: false,
});
function onActionClick({
code,
row,
}: OnActionClickParams<SystemMenuApi.SystemMenu>) {
switch (code) {
case 'append': {
onAppend(row);
break;
function getMenuTypeOption(type: SystemMenuApi.SystemMenu['type']) {
return menuTypeOptions.find((item) => item.value === type);
}
case 'delete': {
onDelete(row);
break;
function readComponent(row: SystemMenuApi.SystemMenu) {
switch (row.type) {
case 'catalog':
case 'menu': {
return row.component ?? '';
}
case 'edit': {
onEdit(row);
break;
case 'embedded': {
return row.meta?.iframeSrc ?? '';
}
case 'link': {
return row.meta?.link ?? '';
}
default: {
break;
return '';
}
}
}
function onRefresh() {
gridApi.query();
tableApi.reload();
}
function onEdit(row: SystemMenuApi.SystemMenu) {
formDrawerApi.setData(row).open();
}
function onCreate() {
formDrawerApi.setData({}).open();
}
function onAppend(row: SystemMenuApi.SystemMenu) {
formDrawerApi.setData({ pid: row.id }).open();
}
function onDelete(row: SystemMenuApi.SystemMenu) {
async function onDelete(
row: SystemMenuApi.SystemMenu,
context?: KtTableContext<SystemMenuApi.SystemMenu>,
) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
deleteMenu(row.id)
.then(() => {
try {
await deleteMenu(row.id);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_process_msg',
});
onRefresh();
})
.catch(() => {
await (context || tableApi).reload();
} catch {
hideLoading();
});
}
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<Grid>
<template #toolbar-tools>
<Button
v-if="hasPermission('System:Menu:Create')"
type="primary"
@click="onCreate"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.menu.name')]) }}
</Button>
</template>
<template #title="{ row }">
<div class="flex w-full items-center gap-1">
<div class="size-5 flex-shrink-0">
<KtTable @register="registerTable">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="menu-title">
<div class="menu-title__icon">
<IconifyIcon
v-if="row.type === 'button'"
v-if="record.type === 'button'"
class="menu-title__icon-svg"
icon="carbon:security"
class="size-full"
/>
<IconifyIcon
v-else-if="row.meta?.icon"
:icon="row.meta?.icon || 'carbon:circle-dash'"
class="size-full"
v-else-if="record.meta?.icon"
class="menu-title__icon-svg"
:icon="record.meta?.icon || 'carbon:circle-dash'"
/>
</div>
<span class="flex-auto">{{ $t(row.meta?.title) }}</span>
<div class="items-center justify-end"></div>
</div>
<span class="menu-title__text">{{ $t(record.meta?.title) }}</span>
<MenuBadge
v-if="row.meta?.badgeType"
v-if="record.meta?.badgeType"
class="menu-badge"
:badge="row.meta.badge"
:badge-type="row.meta.badgeType"
:badge-variants="row.meta.badgeVariants"
:badge="record.meta.badge"
:badge-type="record.meta.badgeType"
:badge-variants="record.meta.badgeVariants"
/>
</div>
</template>
</Grid>
<template v-else-if="column.key === 'type'">
<Tag :color="getMenuTypeOption(record.type)?.color">
{{ getMenuTypeOption(record.type)?.label || record.type }}
</Tag>
</template>
<template v-else-if="column.key === 'component'">
{{ readComponent(record) || '-' }}
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="record.status === 1 ? 'success' : 'default'">
{{
record.status === 1 ? $t('common.enabled') : $t('common.disabled')
}}
</Tag>
</template>
</template>
</KtTable>
</Page>
</template>
<style lang="scss" scoped>
.menu-title {
position: relative;
display: flex;
gap: 4px;
align-items: center;
width: 100%;
min-width: 0;
&__icon {
flex-shrink: 0;
width: 20px;
height: 20px;
}
&__icon-svg {
width: 100%;
height: 100%;
}
&__text {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.menu-badge {
top: 50%;
right: 0;

View File

@ -1,23 +1,29 @@
<script lang="ts" setup>
import type { TableColumnType } from 'antdv-next';
import type { Recordable } from '@vben/types';
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemRoleApi } from '#/api';
import type {
KtTableApi,
KtTableButton,
KtTableContext,
KtTableRowAction,
} from '#/components/ktTable';
import { h } from 'vue';
import { useAccess } from '@vben/access';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message, Modal } from 'antdv-next';
import { message, Modal, Switch, Tag } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteRole, getRoleList, updateRole } from '#/api';
import { KtTable, useKtTable } from '#/components/ktTable';
import { $t } from '#/locales';
import { useColumns, useGridFormSchema } from './data';
import { useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormDrawer, formDrawerApi] = useVbenDrawer({
@ -31,58 +37,91 @@ function hasPermission(code: string) {
return hasAccessByCodes([code]);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
schema: useGridFormSchema(),
submitOnChange: true,
const columns: Array<TableColumnType<SystemRoleApi.SystemRole>> = [
{
dataIndex: 'name',
key: 'name',
title: $t('system.role.roleName'),
width: 200,
},
gridOptions: {
columns: useColumns(
onActionClick,
hasPermission('System:Role:Edit') ? onStatusChange : undefined,
{ canAccess: hasPermission },
),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
{
dataIndex: 'id',
key: 'id',
title: $t('system.role.id'),
width: 200,
},
{
dataIndex: 'status',
key: 'status',
title: $t('system.role.status'),
width: 100,
},
{
dataIndex: 'remark',
key: 'remark',
title: $t('system.role.remark'),
width: 180,
},
{
dataIndex: 'createTime',
key: 'createTime',
title: $t('system.role.createTime'),
width: 200,
},
];
const api: KtTableApi<SystemRoleApi.SystemRole> = {
list: async (params) => {
const { pageNo, pageSize, ...formValues } = params;
return await getRoleList({
page: page.currentPage,
pageSize: page.pageSize,
page: pageNo,
pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
};
toolbarConfig: {
custom: true,
export: false,
refresh: true,
search: true,
zoom: true,
const buttons: Array<KtTableButton<SystemRoleApi.SystemRole>> = [
{
icon: () => h(Plus, { class: 'kt-table__button-icon' }),
key: 'create',
label: $t('ui.actionTitle.create', [$t('system.role.name')]),
onClick: onCreate,
permissionCodes: ['System:Role:Create'],
type: 'primary',
},
} as VxeTableGridOptions<SystemRoleApi.SystemRole>,
];
const rowActions: Array<KtTableRowAction<SystemRoleApi.SystemRole>> = [
{
key: 'edit',
label: $t('common.edit'),
onClick: onEdit,
permissionCodes: ['System:Role:Edit'],
},
{
confirm: (row) => `确认删除「${row.name}」吗?`,
danger: true,
key: 'delete',
label: $t('common.delete'),
onClick: onDelete,
permissionCodes: ['System:Role:Delete'],
},
];
const [registerTable, tableApi] = useKtTable<SystemRoleApi.SystemRole>({
api,
buttons,
columns,
formOptions: {
fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
schema: useGridFormSchema(),
},
rowActions,
tableTitle: $t('system.role.list'),
});
function onActionClick(e: OnActionClickParams<SystemRoleApi.SystemRole>) {
switch (e.code) {
case 'delete': {
onDelete(e.row);
break;
}
case 'edit': {
onEdit(e.row);
break;
}
}
}
/**
* 将Antd的Modal.confirm封装为promise方便在异步函数中调用
* @param content 提示内容
@ -123,57 +162,78 @@ async function onStatusChange(
`切换状态`,
);
await updateRole(row.id, { status: newStatus });
await tableApi.reload();
return true;
} catch {
return false;
}
}
async function onStatusSwitchChange(
checked: boolean | number | string,
row: SystemRoleApi.SystemRole,
) {
const nextStatus = Number(checked) as SystemRoleApi.SystemRole['status'];
if (nextStatus === row.status) return;
await onStatusChange(nextStatus, row);
}
function onEdit(row: SystemRoleApi.SystemRole) {
formDrawerApi.setData(row).open();
}
function onDelete(row: SystemRoleApi.SystemRole) {
async function onDelete(
row: SystemRoleApi.SystemRole,
context?: KtTableContext<SystemRoleApi.SystemRole>,
) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
deleteRole(row.id)
.then(() => {
try {
await deleteRole(row.id);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_process_msg',
});
onRefresh();
})
.catch(() => {
await (context || tableApi).reload();
} catch {
hideLoading();
});
}
}
function onRefresh() {
gridApi.query();
tableApi.reload();
}
function onCreate() {
formDrawerApi.setData({}).open();
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<Grid :table-title="$t('system.role.list')">
<template #toolbar-tools>
<Button
v-if="hasPermission('System:Role:Create')"
type="primary"
@click="onCreate"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.role.name')]) }}
</Button>
<KtTable @register="registerTable">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<Switch
v-if="record && hasPermission('System:Role:Edit')"
:checked="record.status"
:checked-value="1"
:un-checked-value="0"
@change="(checked) => onStatusSwitchChange(checked, record)"
/>
<Tag v-else :color="record.status === 1 ? 'success' : 'default'">
{{
record.status === 1 ? $t('common.enabled') : $t('common.disabled')
}}
</Tag>
</template>
</Grid>
</template>
</KtTable>
</Page>
</template>

View File

@ -27,8 +27,9 @@ export function useExpandable(props: FormRenderProps) {
for (let index = 1; index <= rows; index++) {
maxItem += mapping?.[index] ?? 0;
}
// 保持一行
return maxItem - 1 || 1;
const reservedActionCount = props.collapseReserveAction === false ? 0 : 1;
// 默认给内置 FormActions 预留一格;外部自定义操作区可关闭预留。
return Math.max(maxItem - reservedActionCount, 1);
});
watch(

View File

@ -293,6 +293,11 @@ export interface FormRenderProps<
* @default false
*/
collapseTriggerResize?: boolean;
/**
*
* @default true
*/
collapseReserveAction?: boolean;
/**
* 使
*/

View File

@ -8,8 +8,6 @@ import { convertToRgb, updateCSSVariables } from '@vben/utils';
*/
export function useAntdDesignTokens() {
const rootStyles = getComputedStyle(document.documentElement);
const tokens = reactive({
borderRadius: '' as any,
colorBgBase: '',
@ -27,15 +25,54 @@ export function useAntdDesignTokens() {
colorWarning: '',
zIndexPopupBase: 2000, // 调整基础弹层层级,避免下拉等组件被弹窗或者最大化状态下的表格遮挡
});
const formControlTokens = reactive({
activeBorderColor: '',
colorBgContainer: '',
colorBorder: '',
hoverBorderColor: '',
lineWidth: 1,
});
const components = reactive({
Cascader: formControlTokens,
DatePicker: formControlTokens,
Input: formControlTokens,
InputNumber: formControlTokens,
Select: formControlTokens,
TreeSelect: formControlTokens,
});
/**
* CSS 使
*
* @param variable CSS
* @param isColor HSL
*/
const getCssVariableValue = (variable: string, isColor: boolean = true) => {
const rootStyles = getComputedStyle(document.documentElement);
const value = rootStyles.getPropertyValue(variable);
return isColor ? `hsl(${value})` : value;
};
watch(
() => preferences.theme,
() => {
/**
* CSS
*
* @param variable CSS
* @param fallback 使
* @param isColor HSL
*/
const getCssVariableValueWithFallback = (
variable: string,
fallback: string,
isColor: boolean = true,
) => {
const value = getCssVariableValue(variable, isColor).trim();
return value === (isColor ? 'hsl()' : '') ? fallback : value;
};
/**
* Vben CSS Antdv token token
*/
const syncTokens = () => {
tokens.colorPrimary = getCssVariableValue('--primary');
tokens.colorInfo = getCssVariableValue('--primary');
@ -61,15 +98,35 @@ export function useAntdDesignTokens() {
const radius = Number.parseFloat(getCssVariableValue('--radius', false));
// 1rem = 16px
tokens.borderRadius = radius * 16;
tokens.borderRadius = Number.isFinite(radius) ? radius * 16 : 8;
tokens.colorBgLayout = getCssVariableValue('--background-deep');
tokens.colorBgMask = getCssVariableValue('--overlay');
},
{ immediate: true },
// 表单类组件单独走输入框变量,避免深色模式下输入框与卡片背景粘在一起。
formControlTokens.colorBgContainer = getCssVariableValueWithFallback(
'--input-background',
tokens.colorBgContainer,
);
formControlTokens.colorBorder = getCssVariableValueWithFallback(
'--input',
tokens.colorBorder,
);
formControlTokens.activeBorderColor = tokens.colorPrimary;
formControlTokens.hoverBorderColor = getCssVariableValueWithFallback(
'--accent-hover',
tokens.colorPrimary,
);
};
watch(() => preferences.theme, syncTokens, {
deep: true,
flush: 'post',
immediate: true,
});
return {
components,
tokens,
};
}

View File

@ -6,6 +6,9 @@ settings:
catalogs:
default:
'@antdv-next/icons':
specifier: 1.0.0
version: 1.0.0
'@eslint/js':
specifier: ^9.39.2
version: 9.39.2
@ -527,6 +530,9 @@ importers:
apps/web-antdv-next:
dependencies:
'@antdv-next/icons':
specifier: 'catalog:'
version: 1.0.0(vue@3.5.27(typescript@5.9.3))
'@tanstack/vue-query':
specifier: 'catalog:'
version: 5.92.9(vue@3.5.27(typescript@5.9.3))

View File

@ -19,6 +19,7 @@ overrides:
pinia: 'catalog:'
vue: 'catalog:'
catalog:
'@antdv-next/icons': 1.0.0
'@ast-grep/napi': ^0.39.9
'@ctrl/tinycolor': ^4.2.0
'@eslint/js': ^9.39.2