NewLife/NewLife.Cube.React

表格列设置:齿轮按钮自定义列显隐/顺序(后端持久化,用户级)

ColumnSetting 齿轮面板(勾选/上下移动/保存/恢复默认),保存到后端 Page-React 后清缓存重载;
单测 +4、E2E +2
大石头 authored at 2026-08-31 16:00:44
95beeb8
Tree
1 Parent(s) 682ac52
Summary: 5 changed files with 259 additions and 1 deletions.
Added +50 -0
Added +50 -0
Added +135 -0
Modified +4 -0
Modified +20 -1
Added +50 -0
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 });
+  });
+});
Added +50 -0
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: [] });
+  });
+});
Added +135 -0
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>
+  );
+}
Modified +4 -0
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"
Modified +20 -1
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}