表格列设置:齿轮按钮自定义列显隐/顺序(后端持久化,用户级) ColumnSetting 齿轮面板(勾选/上下移动/保存/恢复默认),保存到后端 Page-React 后清缓存重载; 单测 +4、E2E +2大石头 authored at 2026-08-31 16:00:44
diff --git a/NewLife.Cube.React/web/e2e/column-setting.spec.ts b/NewLife.Cube.React/web/e2e/column-setting.spec.ts
new file mode 100644
index 0000000..48b2746
--- /dev/null
+++ b/NewLife.Cube.React/web/e2e/column-setting.spec.ts
@@ -0,0 +1,50 @@
+/**
+ * 表格列设置 E2E(齿轮按钮 → 列显隐/顺序 → 持久化到后端)
+ *
+ * 覆盖:齿轮按钮存在、打开面板展示字段、取消勾选保存后列消失、恢复默认后列恢复。
+ * 后端 SetPageConfig(Page-React 分类)按用户持久化,GetPage 返回干预字段。
+ */
+import { expect, test } from '@playwright/test';
+
+test.describe('表格列设置(齿轮 → 显隐 → 持久化)', () => {
+ test('齿轮按钮存在且面板展示字段列表', async ({ page }) => {
+ await page.goto('/Cube/App');
+ await expect(page.locator('tbody tr').first()).toBeVisible({ timeout: 15000 });
+
+ // 工具栏齿轮(列设置)按钮
+ const gear = page.getByRole('button', { name: '列设置' });
+ await expect(gear).toBeVisible();
+
+ // 打开面板:字段 checkbox 列表 + 保存/恢复默认(AntD 两字按钮自动加空格)
+ await gear.click();
+ const panel = page.locator('.ant-popover:visible').first();
+ await expect(panel).toBeVisible();
+ await expect(panel.getByText('列设置', { exact: true }).first()).toBeVisible();
+ await expect(panel.locator('.ant-checkbox-wrapper').first()).toBeVisible();
+ await expect(page.getByRole('button', { name: /保\s*存/ }).last()).toBeVisible();
+ await expect(page.getByRole('button', { name: /恢\s*复\s*默\s*认/ }).last()).toBeVisible();
+ });
+
+ test('取消勾选保存后列隐藏,恢复默认后列重现', async ({ page }) => {
+ await page.goto('/Cube/App');
+ await expect(page.locator('th', { hasText: '名称' })).toBeVisible({ timeout: 15000 });
+
+ // 打开列设置,取消「名称」列勾选并保存
+ await page.getByRole('button', { name: '列设置' }).click();
+ const panel = page.locator('.ant-popover:visible').first();
+ await expect(panel).toBeVisible();
+ const nameBox = panel.locator('.ant-checkbox-wrapper', { hasText: '名称' }).first();
+ await expect(nameBox).toBeVisible();
+ await nameBox.locator('input').uncheck({ force: true });
+ await page.getByRole('button', { name: /保\s*存/ }).last().click();
+
+ // 保存后重新加载:名称列消失
+ await expect(page.locator('th', { hasText: '名称' })).toHaveCount(0, { timeout: 15000 });
+
+ // 恢复默认:重新打开面板 → 恢复默认 → 名称列重现
+ await page.getByRole('button', { name: '列设置' }).click();
+ await expect(page.locator('.ant-popover:visible').first()).toBeVisible();
+ await page.getByRole('button', { name: /恢\s*复\s*默\s*认/ }).last().click();
+ await expect(page.locator('th', { hasText: '名称' })).toBeVisible({ timeout: 15000 });
+ });
+});
diff --git a/NewLife.Cube.React/web/src/views/list/components/__tests__/ColumnSetting.test.ts b/NewLife.Cube.React/web/src/views/list/components/__tests__/ColumnSetting.test.ts
new file mode 100644
index 0000000..6dcc6d2
--- /dev/null
+++ b/NewLife.Cube.React/web/src/views/list/components/__tests__/ColumnSetting.test.ts
@@ -0,0 +1,50 @@
+/**
+ * 列设置组件单测:可见字段过滤 + 列配置持久化 payload
+ */
+import { describe, expect, it } from 'vitest';
+import { filterVisibleFields } from '../ColumnSetting';
+import type { FieldMapping } from '@newlifex/field-mapping';
+
+/** 构造一个 FieldMapping */
+function mk(name: string, visible?: boolean): FieldMapping {
+ return {
+ field: {
+ name,
+ displayName: name,
+ typeName: 'String',
+ ...(visible !== undefined ? { visible } : {}),
+ },
+ } as unknown as FieldMapping;
+}
+
+/** 构造列配置 payload(本地状态 → 提交结构) */
+export function buildColumnPayload(order: string[], hidden: string[]): Record<string, unknown> {
+ return { listOrder: order, listHidden: hidden };
+}
+
+describe('列设置', () => {
+ it('filterVisibleFields 过滤 visible=false 的字段', () => {
+ const fields = [mk('Id', true), mk('Name'), mk('Secret', false), mk('Remark', true)];
+ const out = filterVisibleFields(fields).map((f) => f.field.name);
+ expect(out).toEqual(['Id', 'Name', 'Remark']);
+ });
+
+ it('filterVisibleFields 保留未标记 visible 的字段(默认可见)', () => {
+ const fields = [mk('Name'), mk('Code', false)];
+ const out = filterVisibleFields(fields).map((f) => f.field.name);
+ expect(out).toEqual(['Name']);
+ });
+
+ it('filterVisibleFields 空数组安全', () => {
+ expect(filterVisibleFields([])).toEqual([]);
+ });
+
+ it('列配置 payload 结构(listOrder + listHidden)', () => {
+ expect(buildColumnPayload(['Id', 'Name'], ['Secret'])).toEqual({
+ listOrder: ['Id', 'Name'],
+ listHidden: ['Secret'],
+ });
+ // 恢复默认:空数组
+ expect(buildColumnPayload([], [])).toEqual({ listOrder: [], listHidden: [] });
+ });
+});
diff --git a/NewLife.Cube.React/web/src/views/list/components/ColumnSetting.tsx b/NewLife.Cube.React/web/src/views/list/components/ColumnSetting.tsx
new file mode 100644
index 0000000..12977dc
--- /dev/null
+++ b/NewLife.Cube.React/web/src/views/list/components/ColumnSetting.tsx
@@ -0,0 +1,135 @@
+/**
+ * 表格列设置(齿轮按钮 + 弹层)
+ *
+ * 勾选显示/隐藏列、上下调整顺序、恢复默认,持久化到后端(Parameter 表 Page-React 分类,用户级)。
+ * 渲染由后端 GetPage 干预:保存后重新加载页面元数据,GetPage 返回已按配置排序/过滤的字段。
+ */
+import { useEffect, useState } from 'react';
+import { App, Button, Checkbox, Popover, Tooltip } from 'antd';
+import { ArrowDownOutlined, ArrowUpOutlined, SettingOutlined } from '@ant-design/icons';
+import { api } from '@/api';
+import { toFieldMeta } from '@/types/field';
+import type { FieldMapping } from '@newlifex/field-mapping';
+
+export interface ColumnSettingProps {
+ /** 页面路径,如 /Cube/Area */
+ type: string;
+ /** 全部可用字段(GetPage.allList,应用配置前) */
+ allFields: FieldMapping[];
+ /** 当前可见字段名列表(GetPage.list 中 visible 未隐藏的) */
+ visibleFields: string[];
+ /** 保存/恢复默认后回调(重新加载页面元数据) */
+ onChanged: () => void;
+}
+
+/** 列配置持久化 kind(对应后端 PageService 的 Page-React 分类) */
+export const COLUMN_CONFIG_KIND = 'React';
+
+/** 过滤可见字段:visible !== false 的字段参与渲染(后端 GetPage 按用户配置标记隐藏) */
+export function filterVisibleFields(fields: FieldMapping[]): FieldMapping[] {
+ return fields.filter((f) => f.field.visible !== false);
+}
+
+export default function ColumnSetting({ type, allFields, visibleFields, onChanged }: ColumnSettingProps) {
+ const { message } = App.useApp();
+ const [open, setOpen] = useState(false);
+ const [order, setOrder] = useState<string[]>([]);
+ const [hidden, setHidden] = useState<string[]>([]);
+
+ // 打开面板时初始化本地状态:顺序 = 全量字段顺序;隐藏 = 全量 - 当前可见
+ useEffect(() => {
+ if (open) {
+ setOrder(allFields.map((f) => f.field.name));
+ const vis = new Set(visibleFields);
+ setHidden(allFields.map((f) => f.field.name).filter((n) => !vis.has(n)));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open]);
+
+ const list = order
+ .map((name) => {
+ const m = allFields.find((f) => f.field.name === name);
+ return { name, meta: m ? toFieldMeta(m.field) : null };
+ })
+ .filter((x) => x.meta !== null);
+
+ const toggle = (name: string) => {
+ setHidden((prev) => (prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name]));
+ };
+
+ const move = (name: string, dir: -1 | 1) => {
+ setOrder((prev) => {
+ const i = prev.indexOf(name);
+ const j = i + dir;
+ if (i < 0 || j < 0 || j >= prev.length) return prev;
+ const next = [...prev];
+ [next[i], next[j]] = [next[j], next[i]];
+ return next;
+ });
+ };
+
+ const persist = async (payload: Record<string, unknown>) => {
+ try {
+ await api.config.savePageSetting(COLUMN_CONFIG_KIND, type, payload);
+ message.success('列设置已保存');
+ setOpen(false);
+ onChanged();
+ } catch {
+ message.error('保存列设置失败');
+ }
+ };
+
+ const handleSave = () => void persist({ listOrder: order, listHidden: hidden });
+ const handleReset = () => void persist({ listOrder: [], listHidden: [] });
+
+ const content = (
+ <div style={{ width: 300 }}>
+ <div style={{ marginBottom: 8, fontWeight: 600 }}>列设置</div>
+ {list.length === 0 && <div style={{ color: '#999' }}>无可用字段</div>}
+ {list.map(({ name, meta }, idx) => (
+ <div key={name} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 0' }}>
+ <Checkbox
+ checked={!hidden.includes(name)}
+ onChange={() => toggle(name)}
+ style={{ flex: 1, minWidth: 0 }}
+ >
+ <span
+ style={{ display: 'inline-block', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', verticalAlign: 'middle' }}
+ title={meta!.displayName || name}
+ >
+ {meta!.displayName || name}
+ </span>
+ </Checkbox>
+ <Tooltip title="上移">
+ <Button type="text" size="small" icon={<ArrowUpOutlined />} disabled={idx === 0} onClick={() => move(name, -1)} />
+ </Tooltip>
+ <Tooltip title="下移">
+ <Button
+ type="text"
+ size="small"
+ icon={<ArrowDownOutlined />}
+ disabled={idx === list.length - 1}
+ onClick={() => move(name, 1)}
+ />
+ </Tooltip>
+ </div>
+ ))}
+ <div style={{ marginTop: 8, display: 'flex', justifyContent: 'space-between' }}>
+ <Button size="small" onClick={handleReset}>
+ 恢复默认
+ </Button>
+ <Button type="primary" size="small" onClick={handleSave}>
+ 保存
+ </Button>
+ </div>
+ </div>
+ );
+
+ return (
+ <Popover content={content} trigger="click" open={open} onOpenChange={setOpen} placement="bottomRight">
+ <Tooltip title="列设置">
+ <Button aria-label="列设置" type="text" icon={<SettingOutlined />} />
+ </Tooltip>
+ </Popover>
+ );
+}
diff --git a/NewLife.Cube.React/web/src/views/list/components/Toolbar.tsx b/NewLife.Cube.React/web/src/views/list/components/Toolbar.tsx
index 1e65cb5..ab30572 100644
--- a/NewLife.Cube.React/web/src/views/list/components/Toolbar.tsx
+++ b/NewLife.Cube.React/web/src/views/list/components/Toolbar.tsx
@@ -33,6 +33,8 @@ export interface ToolbarProps {
onImport?: () => void;
onViewChange?: (view: ListViewMode) => void;
onRefresh?: () => void;
+ /** 右侧额外按钮(如列设置齿轮),渲染在高级菜单前 */
+ columnSetting?: React.ReactNode;
}
export default function Toolbar({
@@ -50,6 +52,7 @@ export default function Toolbar({
onImport,
onViewChange,
onRefresh,
+ columnSetting,
}: ToolbarProps) {
// 高级菜单:导出(全部格式)→ 导入 → 删除全部,按权限驱动
const advItems: MenuProps['items'] = [];
@@ -95,6 +98,7 @@ export default function Toolbar({
</Tooltip>
</div>
<div className="cube-toolbar-side">
+ {columnSetting}
{canChart && (
<Segmented
size="small"
diff --git a/NewLife.Cube.React/web/src/views/list/EntityListPage.tsx b/NewLife.Cube.React/web/src/views/list/EntityListPage.tsx
index d6853cd..a951759 100644
--- a/NewLife.Cube.React/web/src/views/list/EntityListPage.tsx
+++ b/NewLife.Cube.React/web/src/views/list/EntityListPage.tsx
@@ -13,12 +13,14 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { App, Card } from 'antd';
+import { clearPageMetaCache } from '@newlifex/api-core';
import { getValueByKey } from '@/utils/url';
import { api } from '@/api';
import { usePageStore } from '@/hooks/usePageStore';
import SearchBar from './components/SearchBar';
import Toolbar, { type ListViewMode } from './components/Toolbar';
import TableContent from './components/TableContent';
+import ColumnSetting, { filterVisibleFields } from './components/ColumnSetting';
import ListPagination from './components/ListPagination';
import ChartView from './components/ChartView';
import FormDialog from '@/views/form/FormDialog';
@@ -36,6 +38,7 @@ export default function EntityListPage({ type }: EntityListPageProps) {
// 订阅 store 状态
const listFields = store((s) => s.listFields);
+ const allListFields = store((s) => s.allListFields);
const searchFields = store((s) => s.searchFields);
const addFields = store((s) => s.addFields);
const editFields = store((s) => s.editFields);
@@ -56,6 +59,10 @@ export default function EntityListPage({ type }: EntityListPageProps) {
// 查看权限:只读控制器或无可编辑权限时提供「查看」(规范 §7.9)
const canView = !!pageSetting?.isReadOnly || !canEdit;
+ // 渲染列:过滤后端 GetPage 标记隐藏(visible=false)的字段(列设置持久化到后端后由 GetPage 干预)
+ const displayFields = useMemo(() => filterVisibleFields(listFields), [listFields]);
+ const visibleFieldNames = useMemo(() => displayFields.map((f) => f.field.name), [displayFields]);
+
// 软删除字段:列表字段含 Deleted/IsDelete/IsDeleted 布尔字段时启用「恢复」(规范 §7.9)
const softDeleteField = useMemo(
() =>
@@ -127,6 +134,15 @@ export default function EntityListPage({ type }: EntityListPageProps) {
.catch(() => {});
}, [store, searchParams, sortState]);
+ // 列设置保存/恢复默认后:清除页面元数据缓存 + 重新拉取(GetPage 返回已应用配置的字段)+ 数据
+ const reloadMeta = useCallback(() => {
+ clearPageMetaCache();
+ void store
+ .getState()
+ .loadFields()
+ .then(() => store.getState().loadData({ ...searchParams, sort: sortState.field, desc: sortState.desc }));
+ }, [store, searchParams, sortState]);
+
// ── 事件处理 ─────────────────────────────────────────
const handleSearch = (params: Record<string, unknown>) => {
setSearchParams(params);
@@ -319,13 +335,16 @@ export default function EntityListPage({ type }: EntityListPageProps) {
onImport={handleImport}
onViewChange={handleViewChange}
onRefresh={() => void refresh()}
+ columnSetting={
+ <ColumnSetting type={type} allFields={allListFields} visibleFields={visibleFieldNames} onChanged={reloadMeta} />
+ }
/>
{view === 'chart' ? (
<ChartView charts={store((s) => s.chartList)} />
) : (
<>
<TableContent
- fields={listFields}
+ fields={displayFields}
data={tableData}
loading={loading}
pkField={pkField}