news 2026/9/15 11:18:13

Frappe UI 列设置与表格拖拽调宽同步方案:共享 columns 引用与 frappe-ui ListView 复用(ADR-0006 全解)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Frappe UI 列设置与表格拖拽调宽同步方案:共享 columns 引用与 frappe-ui ListView 复用(ADR-0006 全解)

Frappe UI 列设置与表格拖拽调宽同步方案:共享 columns 引用与 frappe-ui ListView 复用(ADR-0006 全解)

【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe

本文解读 Frappe 前端 UI 库(ui/目录,下称@framework/ui)中的架构决策记录ADR-0006:Column Settings(列设置)弹层与列表表格的拖拽调宽(drag-resize)如何通过一个共享的columns引用实现双向自动同步,以及表格外壳为何直接复用 frappe-ui 的ListView/ListHeader/ListHeaderItem组件。读完你将掌握这套"共享状态、零事件总线"模式的完整实现链路:从useListView组合式函数到useColumns、再到serializeColumns/applyColumnWidth等纯函数与对应测试,可直接用于你自己实现可拖拽、可持久化的列配置功能。

一、决策背景:两个控件要改同一个状态

在 CRM 列表页中,"列"这个概念有两个不同的操作入口:

  • ColumnSettings 弹层:用户在这里添加/移除列、调整列顺序、重命名列标题;
  • 表格表头:用户直接拖拽表头分隔线调整某列的宽度。

两者编辑的是同一列同一份数据(是否显示、顺序、宽度),如果各自维护一份状态,就必须设计一套事件同步机制,这既容易产生时序问题,也难以维护。

ADR-0006 给出的答案是:让它们共享同一个Column[]引用(ref)。这与 ADR-0005 中 Filter 与 QuickFilter 共享同一份FilterCondition[]的做法一脉相承——这也是useListView组合式函数被"推迟"到两个控件真正需要共享状态时才落地的原因(见 useListView.ts 的注释)。

二、核心方案:一个columnsref,两个消费者

方案可以概括为三句话:

  1. 状态只存一份useListView拥有的columnsRef<Column[]>)是唯一数据源(single source of truth,SoT);
  2. 两端都绑这一个 ref:ColumnSettings 通过v-model读写它,拖拽调宽处理器把新宽度按fieldname直接写回它;
  3. 同步是自动的:因为是同一个响应式引用,一端写入,另一端立即感知,无需任何跨控件事件。
┌────────────────────────────────────────┐ │ useListView / useColumns │ │ columns: Ref<Column[]> ←── SoT ──┐ │ └────────────────────────────────────────┘ ▲ v-model │ setWidth() 写入 │ ▼ ┌──────────────────┐ ┌──────────────────────┐ │ ColumnSettings │ │ ListView / ListHeader│ │ (受控弹层) │ │ (frappe-ui 表格拖拽) │ └──────────────────┘ └──────────────────────┘

代码层面的证据非常直接。useListView内部把列状态组合进来:

// ui/src/components/ListView/useListView.ts const columns = useColumns(doctype, { synthetic: options.synthetic });

useColumns返回的shown是一个可写计算属性,注释明确写着它就是 ColumnSettings 与表格拖拽调宽共同绑定的那一个 ref

// ui/src/components/ColumnSettings/useColumns.ts /** 列表当前展示的列(顺序 = 展示顺序,width = 调宽写入的切片)。 * 默认取 doctype 中 in_list_view 的字段(来自 Meta),直到被用户自定义。 * ColumnSettings 通过 v-model 绑定它,表格的拖拽调宽也写回它—— * 两端绑定同一个 ref,无需跨控件事件即可保持同步(ADR-0006)。 */ shown: WritableComputedRef<Column[]>;

shown的状态本身保存在一个customColumns = ref<Column[] | null>(null)中:null表示"使用 Meta 派生默认值",一旦有任何写入(无论是 ColumnSettings 编辑还是拖拽调宽),值就"固化"下来,不再跟随 Meta 变化——这正是"首个写入即生效"的设计。

三、调宽处理器放在 ListView 组合层,而不是 ColumnSettings

ADR-0006 明确记录了一个关键决策:拖拽调宽的处理器(resize handler)放在ListView组合层,而不是 ColumnSettings 内部。理由有两点:

  • 调宽是表格行为(table behaviour),属于渲染层;
  • 共享的columns状态本来就归组合层所有,处理器放在这里可以就近访问。

这样 ColumnSettings 保持为一个专注的、受控的弹层(focused, controlled popover):它只负责展示和编辑Column[],不关心表格如何渲染、如何拖拽。

实际代码中,组合层的处理器就是useColumns返回的setWidth

// ui/src/components/ColumnSettings/useColumns.ts /** frappe-ui 表头拖拽会发出 { key, width };把它写回 `shown`, * 正是让新宽度在 ColumnSettings 中呈现出来的关键。 */ const setWidth = (fieldname: string, width: string) => { shown.value = applyColumnWidth(shown.value, fieldname, width); };

而表格侧的事件接线发生在 Shell/story 集成层。需要注意一个实现细节:frappe-ui 的ListView不会向上转发columnWidthUpdated,所以要在ListHeader上自己捕获(见 ListViewToolbar.vue 的注释):

<!-- ui/src/components/ListView/stories/ListViewToolbar.vue --> <ListHeader @columnWidthUpdated="onColumnWidthUpdated" />
// frappe-ui 的 ListHeaderItem 在拖拽过程中发出 { key, width, save } // 组合层持有处理器(ADR-0006);这里忽略 save 防抖标志—— // 持久化是宿主(host)的职责——只把宽度写进共享 ref。 function onColumnWidthUpdated(event: { key: string; width: string }) { view.columns.setWidth(event.key, event.width); }

四、复用 frappe-ui 的 ListView 外壳:拖拽数学与像素级对齐免费到手

第二个关键决策是:表格外壳直接复用 frappe-ui 的ListView/ListHeader/ListHeaderItem——也就是 CRM 渲染所用的同一批组件——而不是手写一个可调整宽度的自定义表头。这样可以得到三样东西:

  1. 拖拽数学:50px 最小宽度、px 字符串宽度等规则直接来自 frappe-ui;
  2. 网格布局(grid layout):列宽通过 CSS grid 的grid-template-columns生效;
  3. 像素级对齐:与 CRM 完全一致的渲染效果(pixel-parity)。

ListViewToolbar的完整集成中可以看到这套复用:

<template #table> <ListView :columns="view.columns.wire.value" :rows="data.rows.value" row-key="name" :options="{ selectable: true, resizeColumn: true }" > <ListHeader @columnWidthUpdated="(e) => view.columns.setWidth(e.key, e.width)" /> <ListRows /> </ListView> </template>

resizeColumn: true打开 frappe-ui 表头的拖拽调宽能力;而表格渲染所用的列(view.columns.wire.value)正是从共享的Column[]序列化而来(详见下文)。

ADR 还记录了两个被否决的备选方案,可以帮助理解设计取舍:

被否决的方案否决理由
把 CRM 的拖拽数学移植进自定义表头重复实现 frappe-ui 已经拥有的定位逻辑,且存在与 CRM 产生像素漂移(pixel drift)的风险;ui/CLAUDE.md明确要求优先寻找 frappe-ui 等价物
把调宽处理器放进 ColumnSettings会把弹层控件与表格渲染耦合;组合层本就持有共享columns和表格,处理器理应归组合层
在控件与表格之间用columnWidthUpdated事件同步共享 ref 模式(ADR-0005)已经让事件管道成为多余

五、数据形状:Column只存三样东西,其余全部派生自 Meta

Column的状态形状被刻意保持精简(types.ts):

/** 列表中展示的一列:fieldname、可被用户覆盖的 label,以及可选的固定 CSS width * ("11rem"、"120px")。ColumnSettings 控件的状态就是一个有序的 Column 列表: * 存在即表示显示,数组顺序即显示顺序,width 是列调宽共同写入的切片。 * 没有 width 的列是 auto:它会弹性填满可用空间(serializeColumns 输出数值 fr); * 只有被调过宽的列才携带固定 width,去掉它(双击调宽手柄)即恢复 auto。 * 列的 align/type/options 不存这里,而是在渲染/序列化时从 Meta 派生。 * 参见 CONTEXT.md("Column")。 */ export interface Column { fieldname: string; label: string; width?: string; }

关键设计要点:

  • width是 CSS 字符串(如"11rem""120px"),不是数字;
  • width= auto:列弹性填满剩余空间,序列化时输出一个数值fr(frappe-ui 的getGridTemplateColumns把数字渲染为Nfr);
  • align/type/options从不存进Column:它们在渲染/序列化时从 doctype 的Meta派生。例如右对齐完全由字段类型决定(IntFloatCurrencyPercentDuration右对齐,其余左对齐,见 columns.ts 的RIGHT_ALIGNED_FIELDTYPES)。

六、双向同步的两个纯函数:applyColumnWidthclearColumnWidth

写入共享 ref 的具体逻辑被实现为不可变更新的纯函数。拖拽 → 设置方向使用applyColumnWidth

// ui/src/components/ColumnSettings/columns.ts /** * 把调宽后的新 width 按 fieldname 写回匹配的 Column,返回新列表 * (其余列不动;没有匹配项则原样返回)。这是 ADR-0006 同步中 * "调宽 → 设置" 的一半:ListView 组合层的 columnWidthUpdated * 处理器在共享的 columns ref 上调用它——该 ref 同样被 ColumnSettings * v-model——于是弹层里的宽度跟着拖拽走,无需任何事件管道。 */ export function applyColumnWidth( columns: Column[], fieldname: string, width: string ): Column[] { return columns.map((c) => (c.fieldname === fieldname ? { ...c, width } : c)); }

**调宽的反向(恢复 auto)**使用clearColumnWidth——对应"双击调宽手柄恢复自动宽度"的手势:

/** * 按 fieldname 去掉列的固定 width,使其恢复 auto(弹性)。 * 没有 width 后,serializeColumns 会让它重新回到弹性 fr。 * 返回新列表(其余列不动;无匹配或原本就是 auto 则原样返回)。 */ export function clearColumnWidth( columns: Column[], fieldname: string ): Column[] { return columns.map((c) => { if (c.fieldname !== fieldname) return c; const { width: _omit, ...rest } = c; return rest; }); }

宿主如何把"双击恢复 auto"接上?frappe-ui 的ListHeaderItem把拖拽调宽绑定在调宽手柄(resizer)的mousedown上,既不暴露双击事件也不暴露startResizing,所以 ListViewToolbar.vue 采用委托方案:在表头网格上捕获双击,找到.cursor-col-resize手柄,映射其位置到对应列,再调用resetWidth

function onResizerDoubleClick(event: MouseEvent) { const resizer = (event.target as HTMLElement).closest(".cursor-col-resize"); const header = resizer?.closest(".grid"); if (!resizer || !header) return; const index = Array.from(header.querySelectorAll(".cursor-col-resize")).indexOf(resizer); const column = view.columns.wire.value[index]; if (column) view.columns.resetWidth(column.key); }

resetWidthuseColumns中对clearColumnWidth的封装:

const resetWidth = (fieldname: string) => { shown.value = clearColumnWidth(shown.value, fieldname); };

七、渲染形状与序列化:serializeColumns/parseColumns

共享的Column[]是"富状态"(rich),而 frappe-ui 表格需要的是"渲染形状"(wire shape)。两者之间的双向转换由一对纯函数负责(columns.ts):

  • serializeColumns(columns, fields, synthetic)WireColumn[]:为表格生成渲染列。每个 Column 保留存储的labelwidth,同时从匹配的 Meta 字段派生type/options/align;无width的列输出数值fr(首列更大,AUTO_LEADING_FR = 2,让标题优先可读);不在meta.fields里的标准字段(如name)回退为左对齐的Data列。
  • parseColumns(wire)Column[]:逆向转换,丢弃 Meta 派生的type/options/align,只保留fieldname(来自key)与label;数值fr(auto 列)映射回"无 width",字符串width原样保留。

WireColumn的形状对应 frappe-uiListView的渲染契约,其中key相当于 CRM 中的列标识:

// ui/src/components/ColumnSettings/types.ts export interface WireColumn { label: string; key: string; width: string | number; // 固定 CSS 字符串("150px")或 auto 列的数值 fr align: "left" | "right"; type: string; options?: string; }

useColumnswire是响应式派生的,因此共享 ref 一变、表格立刻重排:

const wire = computed(() => serializeColumns(shown.value, meta.value?.fields ?? [], synthetic.value) );

八、测试验证:纯函数层面锁死同步契约

同步契约有专门的单测覆盖,且刻意不挂载任何组件、只在纯函数层面验证(columnSync.test.ts)。三个用例正好对应双向同步的三个关键路径:

describe("resize ↔ ColumnSettings sync over the shared columns", () => { it("resize → settings: 一个 columnWidthUpdated 宽度落到匹配的 Column 上", () => { // 模拟组合层处理器对 { key, width } 调宽事件做的事 const next = applyColumnWidth(COLUMNS, "amount", "260px"); expect(next.find((c) => c.fieldname === "amount")?.width).toBe("260px"); // ……并且表格会以新宽度重渲染该列 expect(serializeColumns(next, FIELDS)[1].width).toBe("260px"); }); it("settings → resize: 弹层里编辑的宽度驱动渲染轨道", () => { // ColumnSettings 直接把宽度写到其 v-model 的 Column 上 const edited = COLUMNS.map((c) => c.fieldname === "status" ? { ...c, width: "6rem" } : c ); expect(serializeColumns(edited, FIELDS)[0].width).toBe("6rem"); }); it("调宽后的宽度经 parseColumns 往返后不变", () => { const resized = applyColumnWidth(COLUMNS, "status", "320px"); expect(parseColumns(serializeColumns(resized, FIELDS))).toEqual(resized); }); });

测试数据还体现了另一条规则:状态里的width使用 CSS 字符串("10rem"),而测试断言里拖拽写入的"260px"/"320px"也是字符串——印证了"宽度是 CSS 字符串"的约定。

九、两个明确的边界(Consequences)

ADR-0006 最后划清了两条职责边界:

1. 持久化是宿主的职责(呼应 ADR-0001)

控件无视frappe-uisave: false/true的防抖标志,只做实时状态更新——也就是调宽事件发出后立即写进共享 ref。需要防抖保存的宿主自己监听columns(更准确地说是view.snapshot)。

useListViewsnapshot把整视图状态打包成单个可序列化对象,宿主只需一个 watcher 就能覆盖所有改动(USAGE.md):

const view = useListView(props.doctype); // 任何改动——过滤、排序、列增删、拖拽调宽、快捷筛选——都会触发它 watch(view.snapshot, useDebounceFn((snap) => saveView(snap), 500));

注意:snapshot只在某个控件状态真正变化时才产生新对象,因此 watcher 每次真实编辑只触发一次,不需要deep: true。拖拽调宽与增删列都不是特例——它们都编辑view.columns.shown,而它就在 snapshot 里(见 USAGE.md 的完整表格)。

如果只想把列单独存到localStorage,甚至不需要序列化辅助函数——snapshot 是纯 JSON:

localStorage.setItem(key, JSON.stringify(view.snapshot.value)); // 读取时: view.restore(JSON.parse(localStorage.getItem(key)));

2. CRM 的rows与"重置/恢复默认"不属于控件

  • rows(要拉取的字段)fetchFields会从 wire 列集合推导出get_list需要的字段列表(name加每个列的 key,并剔除合成列;见 columns.ts),但拉取本身、以及最终返回的行数据,都是宿主/故事层的职责;
  • reset / reset-to-defaults:受控组件不持有"已保存/默认"状态,所以默认列集(来自 Meta 的in_list_view字段)由useColumns提供(getDefaultColumns),ColumnSettings 只发出reset意图,由宿主调用view.columns.reset()恢复:
<ColumnSettings v-model="view.columns.shown.value" :doctype="doctype" :can-reset="view.columns.isCustomized.value" @reset="view.columns.reset()" />

reset的实现就是把customColumns置回null,让shown的计算回落到 Meta 默认值:

const reset = () => { customColumns.value = null; };

isCustomizedcustomColumns.value !== null)则驱动canReset:只有存在可撤销的自定义时才显示 Reset 按钮。

十、实践速查:如何在你的页面里接上这套同步

把 ADR-0006 的模式落到实际页面,核心只需四步(完整示例见 USAGE.md 与 ListViewToolbar.vue):

  1. 创建共享状态const view = useListView(doctype),其中view.columns.shown就是共享 ref;
  2. 绑定弹层<ColumnSettings v-model="view.columns.shown.value" :doctype="doctype" />
  3. 绑定表格<ListView :columns="view.columns.wire.value" :options="{ resizeColumn: true }">,并在<ListHeader>上捕获columnWidthUpdatedview.columns.setWidth(key, width)
  4. (可选)接上双击恢复 auto:在表头网格上委托双击事件,映射到手柄对应列后调view.columns.resetWidth(key)

由于 ColumnSettings 与表格绑的是同一个 ref,第 2、3 步之间不需要任何事件线——这正是 ADR-0006 与 ADR-0005 一以贯之的"共享状态优先于事件同步"的架构主张。

延伸阅读

  • ADR-0001:列表控件是受控的、Meta 驱动的
  • ADR-0005:QuickFilter 与 Filter 共享同一份条件列表
  • ADR-0007:持久化推迟给宿主,库只产出视图快照
  • ListView 使用指南(含完整可复制集成示例)
  • 列状态组合式函数 useColumns 实现
  • 列序列化与宽度写入纯函数
  • 同步契约单测
  • 集成示例 ListViewToolbar(含双击恢复 auto 的实现)

【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 11:17:17

windows-metadata 深度指南:用 Rust 读写 ECMA-335 元数据的底层库解析

windows-metadata 深度指南&#xff1a;用 Rust 读写 ECMA-335 元数据的底层库解析 【免费下载链接】windows-rs Rust for Windows 项目地址: https://gitcode.com/GitHub_Trending/wi/windows-rs 本篇技术指南围绕 windows-rs 仓库中的 windows-metadata 底层元数据库展…

作者头像 李华
网站建设 2026/9/15 11:15:10

5阶段、33步、14个Agent:一文看懂AI-DLC的核心数字

5阶段、33步、14个Agent&#xff1a;一文看懂AI-DLC的核心数字 【免费下载链接】aidlc-workflows AI-Driven Life Cycle (AI-DLC) adaptive workflow steering rules for AI coding agents 项目地址: https://gitcode.com/GitHub_Trending/ai/aidlc-workflows AI-DLC&am…

作者头像 李华
网站建设 2026/9/15 11:14:43

Claude Code技术栈解析:LLM驱动的智能编程助手

1. 项目概述&#xff1a;Claude Code技术栈解析Claude Code是基于大型语言模型(LLM)的智能编码代理框架&#xff0c;它通过将Claude模型的自然语言理解能力与代码生成功能相结合&#xff0c;为开发者提供智能化的编程辅助工具。这个框架本质上构建了一个"思考-行动"循…

作者头像 李华
网站建设 2026/9/15 11:12:29

Unity角色对话口型同步:SALSA With RandomEyes插件实战指南

最近在弄Unity角色对话系统&#xff0c;被一个叫SALSA With RandomEyes的插件圈粉了。这东西说白了就是干一件事&#xff1a;让人物的嘴巴跟着语音动起来&#xff0c;配合随机眼球转动&#xff0c;整得跟真人说话似的。很多做独立游戏、虚拟主播、甚至数字人项目的朋友都在用这…

作者头像 李华
网站建设 2026/9/15 11:11:22

C语言实现俄罗斯方块:从数据结构到游戏循环的完整实践

如果你学过C语言&#xff0c;一定在某个时刻冒出过“写个俄罗斯方块试试”的念头。这个看似简单的益智游戏&#xff0c;其实是C语言里最经典的综合性项目之一&#xff1a;数组、指针、结构体、函数、循环、随机数、键盘输入、文件读写&#xff0c;全都用得上。更重要的是&#…

作者头像 李华