diff --git a/apps/web-antdv-next/package.json b/apps/web-antdv-next/package.json index 3848873..22cc3bb 100644 --- a/apps/web-antdv-next/package.json +++ b/apps/web-antdv-next/package.json @@ -26,6 +26,7 @@ "#/*": "./src/*" }, "dependencies": { + "@antdv-next/icons": "catalog:", "@tanstack/vue-query": "catalog:", "@vben-core/menu-ui": "workspace:*", "@vben-core/shadcn-ui": "workspace:*", diff --git a/apps/web-antdv-next/src/api/blog/wordpress.ts b/apps/web-antdv-next/src/api/blog/wordpress.ts index bb6a4d2..36c3433 100644 --- a/apps/web-antdv-next/src/api/blog/wordpress.ts +++ b/apps/web-antdv-next/src/api/blog/wordpress.ts @@ -40,6 +40,15 @@ export namespace WordpressBlogApi { title: string; } + export interface ArticleQuery extends Recordable { + 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) { +export function getArticleList(params: WordpressBlogApi.ArticleQuery) { return requestClient.get< WordpressBlogApi.PageResult >('/wordpress/article/list', { params }); diff --git a/apps/web-antdv-next/src/api/core/menu.ts b/apps/web-antdv-next/src/api/core/menu.ts index 814b53b..5a68c88 100644 --- a/apps/web-antdv-next/src/api/core/menu.ts +++ b/apps/web-antdv-next/src/api/core/menu.ts @@ -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('/menu/all'); - // 只暴露当前后端真实接口已经支撑的后台菜单,模板演示入口等后续补接口后再放开。 + // 只暴露当前前端页面和后端接口已经支撑的后台菜单。 return filterSupportedAdminMenus(menus); } diff --git a/apps/web-antdv-next/src/app.tsx b/apps/web-antdv-next/src/app.tsx index 2541395..6f284f7 100644 --- a/apps/web-antdv-next/src/app.tsx +++ b/apps/web-antdv-next/src/app.tsx @@ -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, }; }); diff --git a/apps/web-antdv-next/src/components/ktTable/KtTable.tsx b/apps/web-antdv-next/src/components/ktTable/KtTable.tsx new file mode 100644 index 0000000..146dcca --- /dev/null +++ b/apps/web-antdv-next/src/components/ktTable/KtTable.tsx @@ -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([]); + const sortState = reactive({}); + 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(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 ( + + {{ + actions: () => + hasFormButtons || hasCollapse ? ( +
+ {formButtons.value.map((button) => renderButton(button))} + {hasCollapse ? ( + { + searchCollapsed.value = !searchCollapsed.value; + }} + type="link" + > + + {searchCollapsed.value ? '展开' : '收起'} + + ) : null} +
+ ) : null, + form: () => , + }} +
+ ); + } + + /** + * 渲染操作列里的行操作按钮。 + * + * @param record 当前行数据。 + */ + function renderActionCell(record: KtTableRecord) { + const { inlineActions, overflowActions } = splitRowActions( + rowActions.value, + ); + + return ( + + {inlineActions.map((action) => renderRowAction(action, record))} + {overflowActions.length > 0 ? ( + + {{ + content: () => ( +
+ {overflowActions.map((action) => + renderRowAction(action, record), + )} +
+ ), + default: () => ( + + + + ), + }} +
+ ) : null} +
+ ); + } + + /** + * 将行操作按内联展示和弹层展示拆分。 + * + * @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 ( + + {buttons} + {toolbar} + + ); + } + + /** + * 渲染表格头部右侧设置按钮组。 + */ + function renderHeaderSettings() { + if (!props.showTableSetting) return null; + + return ( + { + 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 () => ( +
+
+ {renderSearchArea()} + +
+ {props.showHeader ? ( + + {{ + settings: renderHeaderSettings, + title: () => slots.title?.(), + toolbar: renderHeaderButtons, + }} + + ) : null} + +
+ { + 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, + }} + /> +
+ + {props.showFooter ? ( + + {{ + default: () => + slots.footer?.({ + context, + selectedRowKeys: selectedRowKeys.value, + selectedRows: selectedRows.value, + }), + }} + + ) : null} +
+
+
+ ); + }, +}); diff --git a/apps/web-antdv-next/src/components/ktTable/components/KtTableFooter.tsx b/apps/web-antdv-next/src/components/ktTable/components/KtTableFooter.tsx new file mode 100644 index 0000000..846119a --- /dev/null +++ b/apps/web-antdv-next/src/components/ktTable/components/KtTableFooter.tsx @@ -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, + }, + 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 () => ( + + ); + }, +}); diff --git a/apps/web-antdv-next/src/components/ktTable/components/KtTableHeader.tsx b/apps/web-antdv-next/src/components/ktTable/components/KtTableHeader.tsx new file mode 100644 index 0000000..5284431 --- /dev/null +++ b/apps/web-antdv-next/src/components/ktTable/components/KtTableHeader.tsx @@ -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 ( +
+
+
{title}
+
{toolbar}
+
+ {settings ? ( +
+ + {settings} +
+ ) : null} +
+ ); + }; + }, +}); diff --git a/apps/web-antdv-next/src/components/ktTable/components/KtTableResizableTitle.tsx b/apps/web-antdv-next/src/components/ktTable/components/KtTableResizableTitle.tsx new file mode 100644 index 0000000..179a185 --- /dev/null +++ b/apps/web-antdv-next/src/components/ktTable/components/KtTableResizableTitle.tsx @@ -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, + }, + 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 | undefined), + width: `${props.width}px`, + }, + }, + [ + slots.default?.(), + h('span', { + class: 'kt-table__resizable-handle', + onMousedown: handleMouseDown, + }), + ], + ); + }; + }, +}); diff --git a/apps/web-antdv-next/src/components/ktTable/components/KtTableSearch.tsx b/apps/web-antdv-next/src/components/ktTable/components/KtTableSearch.tsx new file mode 100644 index 0000000..fe529d2 --- /dev/null +++ b/apps/web-antdv-next/src/components/ktTable/components/KtTableSearch.tsx @@ -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, + }, + }, + emits: ['transitionEnd', 'transitionStart'], + /** + * 初始化搜索区域的展开/收起动画状态。 + * + * @param props 搜索区显示状态和收起状态。 + * @param emit Vue setup context。 + * @param emit.emit 搜索动画开始和结束事件发送器。 + * @param emit.slots 搜索表单和操作按钮插槽。 + */ + setup(props, { emit, slots }) { + const shellRef = ref(null); + const contentRef = ref(null); + const shellHeight = ref(); + 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 ? ( +