feat: 保存KtTable当前稳定版本
This commit is contained in:
parent
961098bddc
commit
eb77dff6a6
@ -26,6 +26,7 @@
|
||||
"#/*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@antdv-next/icons": "catalog:",
|
||||
"@tanstack/vue-query": "catalog:",
|
||||
"@vben-core/menu-ui": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
672
apps/web-antdv-next/src/components/ktTable/KtTable.tsx
Normal file
672
apps/web-antdv-next/src/components/ktTable/KtTable.tsx
Normal 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 业务侧传入的 title、toolbar、bodyCell、summary、footer 等插槽。
|
||||
*/
|
||||
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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
}),
|
||||
],
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -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 必须直接返回 TableSummary,Antdv 才会启用 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
7
apps/web-antdv-next/src/components/ktTable/index.ts
Normal file
7
apps/web-antdv-next/src/components/ktTable/index.ts
Normal 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';
|
||||
540
apps/web-antdv-next/src/components/ktTable/style.scss
Normal file
540
apps/web-antdv-next/src/components/ktTable/style.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
274
apps/web-antdv-next/src/components/ktTable/types.ts
Normal file
274
apps/web-antdv-next/src/components/ktTable/types.ts
Normal 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;
|
||||
};
|
||||
447
apps/web-antdv-next/src/components/ktTable/utils/index.ts
Normal file
447
apps/web-antdv-next/src/components/ktTable/utils/index.ts
Normal 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 || '');
|
||||
}
|
||||
@ -49,6 +49,9 @@
|
||||
},
|
||||
"badgeVariants": "Badge Style"
|
||||
},
|
||||
"ktTableDemo": {
|
||||
"title": "Table Demo"
|
||||
},
|
||||
"role": {
|
||||
"title": "Role Management",
|
||||
"list": "Role List",
|
||||
|
||||
@ -51,6 +51,9 @@
|
||||
"typeLink": "外链",
|
||||
"typeMenu": "菜单"
|
||||
},
|
||||
"ktTableDemo": {
|
||||
"title": "表格演示"
|
||||
},
|
||||
"role": {
|
||||
"title": "角色管理",
|
||||
"list": "角色列表",
|
||||
|
||||
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
646
apps/web-antdv-next/src/views/system/ktTableDemo/list.tsx
Normal file
646
apps/web-antdv-next/src/views/system/ktTableDemo/list.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
57
apps/web-antdv-next/src/views/system/ktTableDemo/style.scss
Normal file
57
apps/web-antdv-next/src/views/system/ktTableDemo/style.scss
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -293,6 +293,11 @@ export interface FormRenderProps<
|
||||
* @default false
|
||||
*/
|
||||
collapseTriggerResize?: boolean;
|
||||
/**
|
||||
* 折叠计算时是否为表单内置操作按钮预留一个格子
|
||||
* @default true
|
||||
*/
|
||||
collapseReserveAction?: boolean;
|
||||
/**
|
||||
* 表单项通用后备配置,当子项目没配置时使用这里的配置,子项目配置优先级高于此配置
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user