
1. 为什么 RepositoryItemLookUpEdit 输入新值总是被吞掉RepositoryItemLookUpEdit 是 DevExpress 网格里最常用的编辑列类型之一它把下拉列表和文本输入合在一起用户既能从列表里挑也能直接敲。但很多人第一次用它都会撞上同一个坑在单元格里输入一个下拉列表里没有的值焦点一离开值就没了或者干脆弹回原来的旧值。这不是控件坏了而是它的默认行为——LookUpEdit 只认数据源里已经存在的行你输入的新值不在 DataSource 里它就没有对应的 ValueMember 可以回写于是被丢弃。这个问题的核心检索词就是 RepositoryItemLookUpEdit 输入新值能做什么它能让你的编辑列从「只能选」变成「可选可填」适合谁适合所有用 DevExpress GridControl 做单据录入、物料编码、客户名称这类场景的 WinForms 开发者。典型场景是这样的你有一个物料表PARTNO 是编码DESCRIPTION 是名称下拉列表里预置了几百条常用物料。某天来了个新物料操作员不想先去基础资料里建档直接在网格里敲名字希望它自动进列表、下次还能选到。默认情况下这一步是失败的因为控件不知道要把这个新值写到哪里。要打通这条链路靠的就是 ProcessNewValue 事件。它在用户输入了一个数据源里不存在的 DisplayValue、并且准备离开编辑状态时触发。你在这个事件里手动往 DataTable 里 Add 一行把 DisplayValue 写进对应的显示列再把 e.Handled 设为 True控件就会认为「这个值我已经处理过了」从而正常回写单元格。听起来简单但实际配置里有几个细节特别容易翻车DataSource 的类型转换、DisplayMember 和 ValueMember 的对应关系、以及新增行之后下拉列表要不要刷新。我试过在一个采购入库单的项目里踩这个坑当时网格绑的是 DataTableRepositoryItemLookUpEdit 的 DataSource 也是同一个 DataTable 的一个副本。输入新值后单元格显示正常但一保存就发现数据库里没有这条记录因为新增的行只进了内存里的副本没同步回主表。后来才理清楚ProcessNewValue 里操作的那个 DataTable必须是你真正要回写的那个数据源或者你得在事件里同时更新主表。这个区别决定了你是「只是显示对了」还是「数据真的进去了」。所以这篇文章不会只给你一段事件代码就完事。我会把前置条件、可复制的配置、DataTable 新增行与列值同步、以及输入新值后焦点离开和下拉重开两步验证动作全部串起来。你照着做能确认新值确实进了数据源并且再次打开下拉时能被检索选中。中间还会讲几个真实报错比如类型转换失败、DisplayMember 写错列名导致新值显示为空、以及 e.Handled 忘了设导致值被回滚。这些都是我在项目里实际遇到过的不是从文档里抄的。2. 前置条件DataSource 必须是 DataTable 且列名要对齐在写 ProcessNewValue 之前有几个前置条件必须先确认否则事件写了也是白写。这一节围绕 RepositoryItemLookUpEdit 绑定 DataTable 数据源 这个长尾检索词展开把绑定关系和列名对齐讲清楚。第一个条件是 DataSource 的类型。ProcessNewValue 事件里我们要把 DataSource 强转成 DataTable 然后调 NewRow()所以它必须是 DataTable不能是 List、BindingList 或者 DataView。如果你绑的是 BindingSource那要取 BindingSource.DataSource 再转 DataTable。很多人在这里直接 CType 报「无法将类型转换为 DataTable」就是因为绑的是 List。DevExpress 的 LookUpEdit 支持多种数据源但只有 DataTable 才有 NewRow 这种行构造方式List 得用 Add 新对象写法完全不同。所以第一步确认你的数据源是 DataTable。第二个条件是 DisplayMember 和 ValueMember 的对应。DisplayMember 是下拉里显示给用户看的列比如 DESCRIPTIONValueMember 是实际存进单元格的列比如 PARTNO。ProcessNewValue 的 e.DisplayValue 拿到的是用户输入的那个显示文本你要把它写进 DataTable 的 DisplayMember 对应的列。如果你 DisplayMember 设的是 DESCRIPTION但事件里往 PARTNO 列写那新值进是进去了但下拉重开时显示不出来因为显示列是空的。这个错我见过好几次表现就是「输入后单元格有值但下拉列表里那条是空白」。第三个条件是 DataSource 和网格主数据源的关系。如果你的网格绑的是主 DataTable而 RepositoryItemLookUpEdit 的 DataSource 是另一个独立的 DataTable比如从数据库单独查出来的物料表那 ProcessNewValue 里新增的行只进了物料表主表里存的还是 ValueMember 的值。这本身没问题但你要确保物料表在新增后能持久化否则程序一关新值就没了。如果你希望新值同时进主表那得在事件里额外处理。大多数场景下LookUpEdit 的 DataSource 是一个「字典表」新增行进字典表就够了主表只存编码。下面用一个具体的表结构来说明。假设物料字典表叫 dtMaterial有两列PARTNO编码和 DESCRIPTION名称。网格绑的主表叫 dtOrder里面有个 MATERIAL 列绑定的 RepositoryItemLookUpEdit 的 DisplayMember 是 DESCRIPTIONValueMember 是 PARTNO。用户在主表 MATERIAL 列输入一个新名称「不锈钢螺栓M8」下拉里没有触发 ProcessNewValue。我们要做的就是把「不锈钢螺栓M8」写进 dtMaterial 的 DESCRIPTION 列PARTNO 可以先留空或者自动生成一个临时编码。这里有个设计选择PARTNO 留空还是自动生成如果留空那 ValueMember 回写到主表的就是空字符串主表的 MATERIAL 列就没值了这通常不是我们想要的。所以更稳妥的做法是在事件里给 PARTNO 生成一个临时值比如用时间戳或者 GUID 前缀保证主表能存到一个非空编码。等后续基础资料建档时再替换成正式编码。这个细节很多教程不讲但实际项目里必须考虑否则你会发现单元格显示对了但保存到数据库的编码是空的。确认完这三个条件你就可以开始写事件了。下一节给出完整的可复制配置包括设计器里的设置和事件代码。注意事件代码里的 DataSource 转换和列名必须和你实际的表结构一致不能照抄列名。3. 可复制配置ProcessNewValue 事件与 DataTable 回写代码这一节是核心操作部分围绕 ProcessNewValue 事件配置与 DataTable 新增行 这个检索词给出可以直接复制的代码。分两步设计器里挂事件代码里写回写逻辑。先看设计器设置。在 GridControl 的 Run Designer 里找到 Columns选中你要编辑的那一列在 ColumnEdit 里选择或新建一个 RepositoryItemLookUpEdit。然后展开这个 RepositoryItemLookUpEdit 的 Properties设置三个关键属性DataSource 指向你的 DataTableDisplayMember 设为 DESCRIPTIONValueMember 设为 PARTNO。接着在事件列表里找到 ProcessNewValue双击生成事件处理程序。如果你用的是代码动态创建 RepositoryItemLookUpEdit那就手动挂事件写法一样。下面是事件处理代码用 C# 写VB.NET 的写法在注释里说明。假设你的 RepositoryItemLookUpEdit 实例叫 repoLookUpMaterialDataSource 是 dtMaterial。private void repoLookUpMaterial_ProcessNewValue(object sender, DevExpress.XtraEditors.Controls.ProcessNewValueEventArgs e) { // 取出绑定的 DataTable注意这里必须和 Properties.DataSource 是同一个引用 DataTable dt repoLookUpMaterial.DataSource as DataTable; if (dt null) return; // 去掉首尾空格避免用户误输入空格导致匹配失败 string newDisplay e.DisplayValue.ToString().Trim(); if (string.IsNullOrEmpty(newDisplay)) return; // 先检查是否已存在避免重复添加用户可能输入了大小写不同的同一个值 DataRow[] exists dt.Select(DESCRIPTION newDisplay.Replace(, ) ); if (exists.Length 0) { // 已存在直接把 DisplayValue 设为已存在行的显示值让控件自己匹配 e.DisplayValue exists[0][DESCRIPTION]; e.Handled true; return; } // 新增一行 DataRow row dt.NewRow(); row[DESCRIPTION] newDisplay; // PARTNO 生成一个临时编码保证 ValueMember 非空 row[PARTNO] TMP DateTime.Now.ToString(yyyyMMddHHmmssfff); dt.Rows.Add(row); // 关键告诉控件这个新值已经处理让它正常回写 e.Handled true; }VB.NET 的写法对应如下和 excerpt 里的结构一致但补了去重和临时编码Private Sub repoLookUpMaterial_ProcessNewValue(ByVal sender As Object, ByVal e As DevExpress.XtraEditors.Controls.ProcessNewValueEventArgs) Handles repoLookUpMaterial.ProcessNewValue Dim dt As DataTable TryCast(repoLookUpMaterial.DataSource, DataTable) If dt Is Nothing Then Return Dim newDisplay As String e.DisplayValue.ToString().Trim() If String.IsNullOrEmpty(newDisplay) Then Return Dim exists As DataRow() dt.Select(DESCRIPTION newDisplay.Replace(, ) ) If exists.Length 0 Then e.DisplayValue exists(0)(DESCRIPTION) e.Handled True Return End If Dim row As DataRow dt.NewRow() row(DESCRIPTION) newDisplay row(PARTNO) TMP DateTime.Now.ToString(yyyyMMddHHmmssfff) dt.Rows.Add(row) e.Handled True End Sub这段代码有几个关键点。第一dt.Select 里的单引号转义如果用户输入的名字里带单引号不转义会抛异常。第二去重逻辑很重要因为 ProcessNewValue 在某些情况下可能被触发多次或者用户输入了已存在但大小写不同的值不去重会往 DataTable 里塞重复行。第三临时编码用时间戳保证唯一避免多用户同时输入时冲突。第四e.Handled True 必须设否则控件会认为你没处理把值回滚。如果你希望新值不仅进字典表还要同步到主表那可以在事件里额外操作主表的当前行。但大多数场景下不需要因为主表存的是 ValueMember字典表新增后 ValueMember 已经有临时编码了主表自然能存进去。这里要注意一个坑如果你在事件里直接改主表当前行的值可能会和控件的回写冲突导致值被覆盖。稳妥的做法是让控件自己回写你只负责字典表的新增。还有一个配置项值得提RepositoryItemLookUpEdit 的 TextEditStyle 属性。默认是 Standard用户可以直接输入。如果你设成 DisableTextEditor那就完全不能输入新值了ProcessNewValue 也不会触发。所以确认这个属性没被改成禁用。另外ImmediatePopup 属性如果设为 True用户一输入就弹下拉可能影响输入体验一般保持默认 False。配置完成后编译运行在网格里输入一个新值试试。如果单元格能显示你输入的值说明事件生效了。但显示对了不代表数据真的进去了下一节讲怎么验证。4. 验证请求焦点离开与下拉重开两步确认新值入库写完事件代码很多人看到单元格显示新值就以为成功了其实不一定。这一节围绕 验证 ProcessNewValue 新值是否写入 DataTable 这个检索词给出两步验证动作确保新值真的进了数据源并且能被再次检索。第一步验证输入新值后让焦点离开单元格然后重新点回这个单元格看显示的值是否还是你输入的新值。这一步验证的是控件的回写有没有成功。如果焦点离开后值变回旧值或者变空说明 e.Handled 没设或者 DataSource 转换失败事件里的新增行没生效。如果值保持住了说明控件已经接受了这个新值并且从 DataTable 里找到了对应的行来回写 ValueMember。这一步只能证明「控件层面」成功了还不能证明 DataTable 里真的有这行。第二步验证重新打开这个单元格的下拉列表在列表里找有没有你刚才输入的新值。这一步验证的是 DataTable 新增行是否真的进了数据源。如果下拉里能看到新值说明 dt.Rows.Add(row) 生效了并且 DisplayMember 列写对了。如果下拉里看不到但单元格显示正常那大概率是 DisplayMember 设错了列或者你新增行加到了另一个 DataTable 实例上。这个错很隐蔽因为单元格显示用的是 e.DisplayValue不依赖 DataTable所以显示正常但下拉没有。为了更严谨你可以在事件里加一行日志或者断点确认 dt.Rows.Count 在新增后加了 1。也可以在验证时用一个临时按钮点击后遍历 dtMaterial 打印所有 DESCRIPTION看新值在不在。下面给一个简单的验证代码片段放在一个测试按钮里private void btnCheckMaterial_Click(object sender, EventArgs e) { DataTable dt repoLookUpMaterial.DataSource as DataTable; if (dt null) { MessageBox.Show(DataSource 不是 DataTable); return; } StringBuilder sb new StringBuilder(); foreach (DataRow row in dt.Rows) { sb.AppendLine(row[PARTNO].ToString() | row[DESCRIPTION].ToString()); } MessageBox.Show(共 dt.Rows.Count 行\n sb.ToString()); }运行后输入一个新值点这个按钮看新值在不在列表里。如果在说明回写链路完全打通。如果不在回去检查 DataSource 的引用是不是同一个。这里有个常见的坑如果你在窗体加载时用 dtMaterial.Copy() 给 LookUpEdit 赋了 DataSource那事件里新增的行进的是副本主表 dtMaterial 没有。验证按钮里如果查的是主表就会看不到新值。所以要么直接用主表作为 DataSource要么在事件里同时更新主表和副本。还有一个验证细节下拉重开时如果你发现新值在列表里但排在最后而且没有排序这是正常的因为 DataTable 的 Rows.Add 就是追加到末尾。如果你希望新值按字母序插入那得用 dt.Rows.InsertAt 或者重新排序 DataView。但大多数场景下追加就够了用户能用搜索找到就行。LookUpEdit 的下拉支持输入过滤用户敲几个字就能定位到新值。两步验证都通过后你还可以做一个持久化验证如果 dtMaterial 是从数据库查出来的新增行后要调 Update 或者重新查询才能存库。这一步取决于你的数据访问层不在 ProcessNewValue 的职责范围内。但你要知道ProcessNewValue 只负责内存里的 DataTable 新增不负责写数据库。如果你需要新值永久保存得在保存按钮里把 dtMaterial 的变更提交回数据库。验证过程中如果发现新值进了 DataTable 但下拉里显示为空白检查 DisplayMember 的列名大小写是否和 DataTable 列名完全一致。DataTable 的列名是大小写敏感的DisplayMember 写 Description 而列名是 DESCRIPTION 就会匹配不上。这个错很常见因为设计器里选列的时候可能自动改了大小写。5. 常见报错排查401、类型转换失败与 OAuth 无关但别混淆这一节围绕 RepositoryItemLookUpEdit ProcessNewValue 报错排查 这个检索词列出几个真实会遇到的错误和解决办法。注意ProcessNewValue 是本地控件事件不涉及网络请求所以不会出现 401、local proxy failed 这类 HTTP 错误。如果你在排查时看到这些说明你混淆了不同的技术栈那些是 API 调用或代理配置的问题和 DevExpress 网格无关。这里只讲控件层面的真实报错。第一个报错「无法将类型为 System.Collections.Generic.List 的对象转换为类型 System.Data.DataTable」。这个错发生在事件里 CType 或 as DataTable 的时候原因是你的 RepositoryItemLookUpEdit.DataSource 绑的是 List 而不是 DataTable。解决办法有两个要么把数据源改成 DataTable要么改事件逻辑用 List 的 Add 方法。如果你坚持用 List那事件里不能调 NewRow得 new 一个实体对象然后 Add 到 List 里。但 List 没有列名概念你得知道 DisplayMember 对应哪个属性。大多数 DevExpress 教程推荐用 DataTable因为列名操作更直观。第二个报错输入新值后单元格显示为空或者旧值。这个不是异常是行为不符合预期。原因通常是 e.Handled 没设成 True。ProcessNewValue 的默认行为是「不处理」控件会把 DisplayValue 丢弃。你必须在事件末尾设 e.Handled True哪怕你什么都没做也要设否则值一定丢。另一个原因是 DataSource 转换失败后直接 return 了没设 Handled值也会丢。所以事件里任何提前 return 的分支都要先设 e.Handled True 再 return或者干脆不 return让流程走到最后。第三个报错下拉列表里出现重复的新值。原因是没做去重用户多次输入同一个值或者 ProcessNewValue 被触发多次。解决办法就是在新增前用 dt.Select 查一下已存在就不新增直接把 e.DisplayValue 设为已存在行的显示值。注意 Select 的过滤字符串里单引号要转义否则用户输入带单引号的名字会抛「语法错误」异常。转义方法是用两个单引号替换一个单引号代码里就是 Replace(, )。第四个报错新值进了 DataTable 但保存到数据库时报主键冲突或编码重复。这是因为你生成的临时编码可能和已有编码撞了或者多用户同时输入时时间戳相同。解决办法是把临时编码生成得更唯一比如加上用户 ID 或者用 Guid.NewGuid().ToString(N).Substring(0, 8)。但更好的做法是让数据库的编码字段允许空或者有默认值等正式建档时再生成。如果你的业务要求编码必须非空且唯一那就在事件里查一下最大编码然后加一但这样并发会有问题得配合锁。第五个报错设计器里找不到 ProcessNewValue 事件。这个通常是因为你选中的是 GridColumn 而不是 RepositoryItemLookUpEdit。事件挂在 RepositoryItem 上不在 Column 上。你要在 Run Designer 里展开 ColumnEdit选中那个 RepositoryItemLookUpEdit然后在它的属性窗口的事件列表里找。如果你用的是代码动态创建那就用 repoLookUp.ProcessNewValue 的方式挂不要挂到 Column 上。还有一个容易混淆的点有些人会把 ProcessNewValue 和 Validating 搞混。Validating 是单元格级别的验证事件在值提交前触发可以用来做格式校验。ProcessNewValue 是编辑器级别的专门处理下拉列表外的新值。两者可以同时用但职责不同。如果你在 Validating 里把值改掉ProcessNewValue 可能就不会触发了因为值已经变成列表内的了。所以顺序是用户输入 - ProcessNewValue 判断是否新值 - 如果新值则新增行 - 值提交 - Validating 校验。理解这个顺序能帮你定位很多「事件没触发」的问题。排查的时候最有效的手段是在事件第一行加断点或者 Debug.WriteLine确认事件到底有没有进。如果没进检查 TextEditStyle 是不是被设成了 DisableTextEditor或者 ReadOnly 是不是 True。如果进了但值没变检查 e.Handled 和 DataSource 转换。如果 DataTable 行数加了但下拉没有检查 DisplayMember 列名。按这个顺序排查基本能覆盖 90% 的问题。6. 从网格录入到接口联调把新值链路接到 TaoTokenProcessNewValue 解决的是本地网格录入的问题新值进了 DataTable用户能选能存。但如果你的系统还要把这些新物料同步到后端或者用大模型做物料名称的智能补全、编码推荐那就涉及到接口调用。这一节把本地链路和接口链路串起来讲怎么在新增行之后触发一次 API 请求把新值送到后端或者让模型帮你生成正式编码。假设你的场景是操作员在网格里输入一个新物料名称ProcessNewValue 把它加进 dtMaterial同时你希望调用一个大模型接口根据名称自动推荐一个规范的物料编码回填到 PARTNO 列。这个需求在智能录入场景里很常见。要调接口你需要一个 API Key 和 Base URL。TaoToken 提供统一的模型接入地址Base URL 是 https://taotoken.net/api你可以在 API Keys 页面生成 Key具体接入方式看接入文档。模型对话入口可以用来先测试模型能不能正常返回确认通了再写进代码。在 ProcessNewValue 里调接口要注意事件是同步的网络请求会阻塞 UI。所以不要直接在事件里同步调 HTTP应该用 async/await 或者后台线程。下面给一个简化的异步示例在新增行之后触发编码推荐private async void repoLookUpMaterial_ProcessNewValue(object sender, ProcessNewValueEventArgs e) { DataTable dt repoLookUpMaterial.DataSource as DataTable; if (dt null) return; string newDisplay e.DisplayValue.ToString().Trim(); if (string.IsNullOrEmpty(newDisplay)) return; DataRow row dt.NewRow(); row[DESCRIPTION] newDisplay; row[PARTNO] TMP DateTime.Now.ToString(yyyyMMddHHmmssfff); dt.Rows.Add(row); e.Handled true; // 异步请求模型推荐编码不阻塞 UI string suggestedCode await SuggestPartNoAsync(newDisplay); if (!string.IsNullOrEmpty(suggestedCode)) { row[PARTNO] suggestedCode; } } private async Taskstring SuggestPartNoAsync(string materialName) { using (HttpClient client new HttpClient()) { client.DefaultRequestHeaders.Add(Authorization, Bearer 你的TaoTokenKey); var payload new { model 你的模型ID, messages new[] { new { role user, content 根据物料名称生成一个8位大写字母数字编码只返回编码 materialName } } }; string json JsonConvert.SerializeObject(payload); var content new StringContent(json, Encoding.UTF8, application/json); var resp await client.PostAsync(https://taotoken.net/api/v1/chat/completions, content); string result await resp.Content.ReadAsStringAsync(); // 解析 result 取出编码这里省略解析细节 return ParseCode(result); } }这段代码的关键是 async void 事件和 await 调用保证 UI 不卡。注意 API 地址用的是 https://taotoken.net/api不要加多余的路径具体端点看接入文档。Key 不要硬编码在代码里放配置文件或者环境变量。如果你只是本地测试可以先用模型对话页面验证请求格式和返回结构确认没问题再写进代码。如果你的场景是长期做编码推荐、物料分类这种重复性任务可以考虑用 Coding Plan 来管理调用配额和模型切换。但这不是必须的单次调用用普通 API Key 就够了。重点是先把 ProcessNewValue 的本地链路跑通再叠加接口调用。不要一上来就调接口否则本地值都没进去接口返回了也没地方存。还有一个实用技巧如果你不希望每次输入新值都调接口可以加一个开关比如按住 Ctrl 输入才触发推荐普通输入只新增不调接口。这样避免误触发和浪费配额。实现方式是在事件里检查 Control.ModifierKeys或者在单元格的 KeyDown 里设标志位。这个细节能让你的功能更可控。最后提醒一点ProcessNewValue 里新增的行如果后续要同步到后端记得在保存时把 dtMaterial 的变更一起提交。不要只提交主表否则新物料在后端不存在下次查询下拉列表时又没了。本地内存和远端数据库的一致性是这类功能最容易忽略的地方。把新增行标记一个状态列保存时只提交新增和修改的行能减少不必要的网络请求。