diff --git a/ERP费用核销并发控制方案.md b/ERP费用核销并发控制方案.md index 1751315d..a7d6d7a6 100644 --- a/ERP费用核销并发控制方案.md +++ b/ERP费用核销并发控制方案.md @@ -1,571 +1,11 @@ -# ERP 费用核销并发控制方案 - -## 1. 文档目的 - -本文整理 ERP 费用核销场景下的并发控制方案,重点解决以下问题: - -- 一笔费用允许被多次核销; -- 多个用户可能同时核销同一笔费用; -- 任何情况下都不能超额核销; -- 必须保留完整核销流水; -- 必须支持核销撤销和财务审计; -- 批量核销多笔费用时,允许部分成功,一笔失败不能影响其他费用; -- 接口重试、重复点击等情况不能导致重复核销。 - -本文对版本锁、数据库悲观锁和数据库原子更新三种方案进行分析,并给出推荐实现。 - -## 2. 基本业务场景 - -假设费用原始金额为 100,当前状态如下: - -```text -原始金额:100 -已核销金额:0 -剩余金额:100 -``` - -用户 A 和用户 B 同时打开该费用: - -- 用户 A 核销 50; -- 用户 B 核销 30; -- 正确结果应为剩余金额 20; -- 系统不能因为两个用户读取到相同旧余额而产生覆盖或超额核销。 - -并发控制需要解决的核心问题不是让所有操作真正并行修改同一余额,而是确保对同一余额的修改在数据库中正确串行化,同时尽量减少不必要的用户重试。 - -## 3. 方案一:版本锁 - -### 3.1 实现方式 - -费用表增加版本字段: - -```text -remain_amount = 100 -version = 1 -``` - -用户 A 提交核销 50: - -```sql -UPDATE dbo.fee -SET remain_amount = remain_amount - 50, - version = version + 1 -WHERE id = @fee_id - AND version = 1 - AND remain_amount >= 50; -``` - -更新成功后: - -```text -remain_amount = 50 -version = 2 -``` - -用户 B 仍然携带页面读取到的 `version = 1`,因此更新影响行数为 0,需要提示用户刷新后重新操作。 - -### 3.2 优点 - -- 可以防止旧数据覆盖新数据; -- 不需要在用户查看页面期间持有数据库锁; -- 实现方式简单,适合普通资料编辑; -- 可以明确告诉用户数据已经发生变化。 - -### 3.3 局限 - -- 会拒绝原本可以成功的合法并发核销; -- 批量核销时容易出现多笔版本冲突; -- 财务人员需要刷新、重新定位并重新操作,使用体验较差; -- 如果版本冲突后自动读取最新余额重试,实际行为已经接近原子更新方案; -- 版本锁只防止旧数据覆盖,不等同于完整的财务一致性设计。 - -版本锁能够保证金额正确的前提是:所有余额修改入口都严格执行版本校验,并且余额更新与流水写入处于同一事务。任何绕过版本条件的接口、脚本或后台任务都可能破坏该保证。 - -### 3.4 适用范围 - -版本字段适合用于: - -- 普通资料编辑冲突检测; -- 页面数据变化提示; -- 缓存失效; -- 判断记录在用户查看后是否发生过变化。 - -如果业务允许多用户对同一费用连续核销,不建议将页面版本作为核销成功的必要条件。 - -## 4. 方案二:数据库悲观锁 - -### 4.1 实现方式 - -在事务中读取并锁定费用记录,然后校验和更新: - -```sql -BEGIN TRANSACTION; - -SELECT remain_amount, status -FROM dbo.fee WITH (UPDLOCK, ROWLOCK) -WHERE id = @fee_id; - --- 校验余额和状态 --- 更新余额 --- 写入核销流水 - -COMMIT; -``` - -用户 A 先锁定记录并核销 50,用户 B 等待。A 提交后,B 读取到最新余额 50,再核销 30,最终剩余金额为 20。 - -### 4.2 优点 - -- 读取、判断、更新之间的数据状态稳定; -- 可以处理多表、多条件和复杂额度判断; -- 后来的用户通常只需等待,不必因为页面版本过期而立即失败; -- 逻辑直观,便于表达复杂业务规则。 - -### 4.3 局限 - -- 显式锁从查询开始持有,临界区通常比原子 UPDATE 更长; -- 热点费用会产生锁等待; -- 同时锁定多条费用且顺序不一致时容易产生死锁; -- `ROWLOCK` 只是 SQL Server 锁提示,不保证数据库一定采用行锁; -- 大批量处理时,如果使用一个总事务,前面记录的锁可能一直持有到整个批次结束。 - -### 4.4 使用要求 - -- 锁定查询必须在数据库事务内执行; -- 获取锁后不能等待用户确认,也不能执行耗时的远程调用; -- 事务应尽可能短; -- 同时操作多条费用时,应按费用主键的固定顺序加锁; -- 对死锁错误和锁等待超时设置有限次数的重试机制。 - -### 4.5 适用范围 - -悲观锁适合以下场景: - -- 一次核销需要同时读取和修改多条费用; -- 需要跨表判断预算、额度、期间、审批或占用状态; -- 核销金额需要根据数据库当前状态动态分配; -- 业务规则无法表达为单条带条件的 UPDATE。 - -对于单条费用的简单余额扣减,没有必要先执行显式锁定查询。 - -## 5. 方案三:数据库原子更新 - -### 5.1 实现方式 - -直接通过带余额条件的 UPDATE 完成扣减: - -```sql -UPDATE dbo.fee -SET remain_amount = remain_amount - @amount, - version = version + 1 -WHERE id = @fee_id - AND status = 'OPEN' - AND remain_amount >= @amount; -``` - -根据影响行数判断结果: - -```sql -IF @@ROWCOUNT = 0 -BEGIN - THROW 50001, N'费用不存在、状态不可核销或剩余金额不足', 1; -END; -``` - -### 5.2 并发结果 - -用户 A 和用户 B 同时更新同一条费用时: - -1. 一个 UPDATE 先获得记录的更新锁和排他锁; -2. 另一个 UPDATE 等待; -3. 第一个事务提交后,第二个 UPDATE 基于数据库当前余额继续判断; -4. 当前余额足够则更新成功,不足则影响行数为 0; -5. 余额不会被旧页面数据覆盖,也不会出现负数。 - -原子更新并不是数据库完全不加锁,而是不需要应用程序预先显式加锁。SQL Server 会在执行 UPDATE 时取得必要的锁。 - -### 5.3 SQL Server 锁行为 - -在常见的 `READ COMMITTED` 或启用 RCSI 的环境中: - -- UPDATE 通常先获得更新锁,修改时转换为排他锁; -- 排他锁一般持有到事务结束; -- 同一记录上的其他写操作会等待; -- RCSI 可以减少读写阻塞,但写操作之间仍然需要锁; -- 使用完整 `SNAPSHOT` 事务隔离时可能出现更新冲突,需要捕获异常并重试。 - -对于同一个费用余额,任何能够保证强一致性的方案最终都需要串行化写操作。原子 UPDATE 的优势在于锁持有时间通常最短。 - -### 5.4 适用范围 - -原子 UPDATE 非常适合: - -- 单条费用余额扣减; -- 库存扣减; -- 额度占用; -- 次数扣减; -- 其他可以使用条件 UPDATE 表达的数值变更。 - -## 6. 三种方案对比 - -| 方案 | 防止超额核销 | 合法并发体验 | 锁持有时间 | 实现复杂度 | 推荐用途 | -| --- | --- | --- | --- | --- | --- | -| 版本锁 | 可以,但所有入口必须使用版本条件 | 较差,版本变化即失败 | 短 | 低 | 普通资料编辑、变化提示 | -| 显式悲观锁 | 可以 | 会等待,通常不要求刷新 | 相对较长 | 中高 | 多行、多表、复杂规则 | -| 原子 UPDATE | 可以 | 合法操作依次成功 | 较短 | 低 | 单费用余额扣减,首选 | - -如果在原子 UPDATE 中同时增加页面版本条件: - -```sql -WHERE id = @fee_id - AND version = @page_version - AND remain_amount >= @amount -``` - -虽然仍然安全,但会因为版本变化拒绝本来可以成功的核销,因此不建议在正常核销时使用页面版本作为必要条件。 - -## 7. 核销流水与事务一致性 - -### 7.1 建议数据结构 - -费用主表: - -```text -fee -- id -- original_amount -- remain_amount -- status -- version -``` - -核销流水表: - -```text -fee_writeoff -- id -- request_id -- batch_id -- batch_item_id -- fee_id -- amount -- operation_type -- reversal_of_id -- operator_id -- operation_time -- reason -- business_document_no -``` - -### 7.2 单笔核销事务 - -余额扣减与核销流水必须在同一个数据库事务内: - -```sql -SET XACT_ABORT ON; - -BEGIN TRY - BEGIN TRANSACTION; - - UPDATE dbo.fee - SET remain_amount = remain_amount - @amount, - version = version + 1 - WHERE id = @fee_id - AND status = 'OPEN' - AND remain_amount >= @amount; - - IF @@ROWCOUNT = 0 - THROW 50001, N'剩余金额不足或费用当前不可核销', 1; - - INSERT INTO dbo.fee_writeoff - ( - request_id, - fee_id, - amount, - operation_type, - operator_id, - operation_time, - business_document_no - ) - VALUES - ( - @request_id, - @fee_id, - @amount, - 'WRITEOFF', - @operator_id, - SYSDATETIME(), - @business_document_no - ); - - COMMIT; -END TRY -BEGIN CATCH - IF @@TRANCOUNT > 0 - ROLLBACK; - - THROW; -END CATCH; -``` - -这样可以保证: - -- 不会出现余额已扣减但没有流水; -- 不会出现流水已生成但余额没有扣减; -- 任意一步失败,当前费用的余额和流水一起回滚。 - -## 8. 批量核销与部分成功 - -### 8.1 事务边界 - -如果业务要求一笔费用失败不能影响其他费用,推荐采用: - -> 批次负责组织和汇总,每个批次明细使用独立事务处理。 - -处理流程: - -```text -创建批次和批次明细 - | -逐笔处理,每笔一个独立事务 - | -记录每笔成功或失败结果 - | -汇总批次最终状态 -``` - -不能简单地在整个批量方法外层开启一个总事务,否则中间一笔抛出异常时,之前成功的费用也可能被回滚。 - -### 8.2 业务失败与技术失败 - -需要区分: - -- 业务失败:余额不足、版本冲突、费用已关闭、期间不允许核销; -- 技术失败:数据库死锁、锁等待超时、程序异常、唯一键冲突。 - -如果多笔费用放在一个数据库事务中,可以通过检查 `@@ROWCOUNT` 跳过余额不足等业务失败,但一旦发生导致事务不可提交的技术异常,整个事务仍可能回滚。 - -如果要求任何一笔发生任何类型的失败都不影响已经处理成功的费用,则每笔费用必须使用独立事务。 - -### 8.3 版本锁下的批量处理 - -每笔费用使用自己的页面版本和独立事务: - -```text -费用1:成功 -费用2:版本冲突 -费用3:成功 -``` - -费用2的失败不会回滚费用1和费用3。但批量选择的费用越多,页面数据过期和版本冲突的概率越高,因此用户体验通常较差。 - -### 8.4 悲观锁下的批量处理 - -每笔费用分别执行以下过程: - -1. 开启当前费用的事务; -2. 使用 `UPDLOCK` 读取当前余额; -3. 校验、更新并写入流水; -4. 提交当前事务; -5. 继续处理下一笔。 - -不要在一个总事务中先后锁定全部费用,否则锁会长期持有,并增加等待和死锁概率。 - -### 8.5 原子 UPDATE 下的批量处理 - -每笔费用分别执行原子 UPDATE: - -```text -费用A:剩余100,核销50,成功 -费用B:剩余20,核销30,失败 -费用C:剩余80,核销40,成功 -``` - -最终批次结果: - -```text -成功:2笔,金额90 -失败:1笔,金额30 -批次状态:PARTIAL_SUCCESS -``` - -费用 A 和费用 C 的余额及流水正常提交,不受费用 B 影响。 - -### 8.6 推荐批次状态 - -批次主表状态: - -```text -PROCESSING -SUCCESS -PARTIAL_SUCCESS -FAILED -``` - -批次明细状态: - -```text -PENDING -PROCESSING -SUCCESS -FAILED -``` - -失败原因建议使用明确的业务代码: - -```text -INSUFFICIENT_BALANCE -VERSION_CONFLICT -FEE_CLOSED -PERIOD_CLOSED -DUPLICATE_REQUEST -LOCK_TIMEOUT -SYSTEM_ERROR -``` - -财务流水表只记录实际成功的财务变动;失败尝试记录在批次明细或操作日志中。 - -### 8.7 批量处理伪代码 - -```text -创建批次及所有批次明细 - -for each batchItem: - 开启独立事务 - 尝试原子扣减当前费用余额 - - 如果余额扣减成功: - 写入核销流水 - 将批次明细标记为 SUCCESS - 提交当前事务 - - 如果业务校验失败: - 将批次明细标记为 FAILED - 保存失败原因 - 提交当前事务 - - 如果发生技术异常: - 回滚当前事务 - 使用新的短事务记录技术失败 - -汇总批次状态和金额 -``` - -如果应用框架使用外层事务注解,需要确保逐笔处理真正开启独立事务。例如 Java/Spring 中应避免因为同类内部调用导致 `REQUIRES_NEW` 代理失效,也不应让外层批量方法持有一个覆盖全部明细的事务。 - -## 9. 幂等控制 - -前端重复点击、HTTP 超时重试、消息重复消费或批次恢复都可能导致重复核销,因此必须为每个批次明细生成唯一请求号。 - -建议增加唯一约束: - -```sql -CREATE UNIQUE INDEX UX_fee_writeoff_request -ON dbo.fee_writeoff(request_id); -``` - -批次明细还可以增加: - -```sql -CREATE UNIQUE INDEX UX_writeoff_batch_item -ON dbo.writeoff_batch_item(batch_id, line_no); -``` - -重复请求的处理规则: - -- 已成功明细直接返回第一次成功结果,不再次扣款; -- 业务失败明细可按业务规则重新校验; -- 技术失败明细可以有限次数重试; -- 重试整个批次时,只处理未成功的明细。 - -如果一个批次中允许同一费用出现多条明细,应提前明确规则: - -- 可以先按费用汇总核销金额后执行一次原子更新;或者 -- 按固定明细顺序逐条处理并接受后续明细可能余额不足。 - -否则,同一费用多条明细的成功结果可能依赖执行顺序。 - -## 10. 核销撤销 - -财务审计场景不建议删除或覆盖原核销流水。撤销应通过新增反向流水完成: - -```text -原流水:operation_type = WRITEOFF,amount = 50 -撤销流水:operation_type = REVERSAL,amount = 50 -reversal_of_id = 原流水ID -``` - -撤销时原子增加余额: - -```sql -UPDATE dbo.fee -SET remain_amount = remain_amount + @amount, - version = version + 1 -WHERE id = @fee_id - AND remain_amount + @amount <= original_amount; -``` - -同时要求: - -- 撤销余额更新和反向流水写入处于同一事务; -- `reversal_of_id` 建立唯一约束,防止同一笔核销被撤销两次; -- 保存撤销人、撤销时间、原因和审批信息; -- 已结账期间通过冲销记录处理,不物理修改或删除历史流水; -- 批量撤销同样按每笔独立事务处理,以支持部分成功。 - -## 11. 数据库防御性约束 - -建议增加余额非负约束: - -```sql -ALTER TABLE dbo.fee -ADD CONSTRAINT CK_fee_remain_nonnegative -CHECK (remain_amount >= 0); -``` - -如果业务始终要求剩余金额不能超过原始金额,可以增加: - -```sql -ALTER TABLE dbo.fee -ADD CONSTRAINT CK_fee_remain_range -CHECK ( - remain_amount >= 0 - AND remain_amount <= original_amount -); -``` - -其他建议: - -- 金额字段使用 `decimal`,不要使用 `float`; -- 精度根据币种和业务确定,例如 `decimal(19, 4)`; -- 原子更新必须通过费用主键或高选择性索引定位记录; -- 限制业务账号直接修改余额字段的权限; -- 所有余额变化统一通过服务或存储过程执行; -- 定期使用流水汇总结果与费用余额进行一致性对账。 - -## 12. 最终推荐架构 - -推荐采用: - -> 数据库原子 UPDATE + 每笔独立事务 + 核销流水 + 批次明细 + 幂等控制 + 数据库约束 - -各机制职责如下: - -- 原子 UPDATE:保证余额不会被超扣; -- 每笔独立事务:保证批量核销可以部分成功; -- 核销流水:满足审计、追溯、对账和撤销要求; -- 唯一请求号:防止重复提交和故障重试造成重复扣款; -- CHECK 约束:作为余额合法性的数据库防线; -- version/rowversion:用于页面变化提示、缓存失效和普通资料编辑,不作为正常核销的必要条件; -- 显式悲观锁:仅在多行、多表或复杂规则无法使用条件 UPDATE 表达时采用; -- 批次主表和明细表:记录部分成功结果、失败原因和重试状态。 - -## 13. 财务业务边界 - -采用部分成功前,需要确认批量核销的业务含义。 - -如果多笔费用只是一次操作选择形成的处理批次,可以允许部分成功,成功项分别形成核销流水。 - -如果多笔费用共同对应一张不可拆分的付款单、结算单或会计凭证,并且必须满足固定总金额,则部分成功可能造成单据金额不平。这种场景应采用全成全败,或者先完成逐笔预校验和额度预占,再统一确认入账。 - -因此,建议将允许部分成功的批次定义为“操作批次”,而不是一笔不可拆分的会计交易。最终会计单据应根据成功明细生成,并确保借贷和业务金额平衡。 +| 方案 | 存在问题 | +| ------------------------------- | ------------------------------------------------------------ | +| 存储过程核销和反核销 + 保存分开 | A和B打开了同一个收款单。A修改了核销金额点击了保存,B却点击了核销 | +| | | +| | | + +| 问题 | 发生位置 | 类型 | +| -------------------------- | -------------------------- | -------------- | +| A改20,B改30 | cw_sfdsub | 丢失更新 | +| 修改后核销金额和费用不一致 | cw_sfdsub + bs_business_cw | 数据一致性问题 | +| 多人同时核销同一费用 | bs_business_cw | 余额并发问题 | diff --git a/code/fms/.playwright-cli/console-2026-07-24T14-53-54-721Z.log b/code/fms/.playwright-cli/console-2026-07-24T14-53-54-721Z.log deleted file mode 100644 index 2672d16c..00000000 --- a/code/fms/.playwright-cli/console-2026-07-24T14-53-54-721Z.log +++ /dev/null @@ -1,383 +0,0 @@ -[ 256286ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at ... > - at - at ref=Ref< undefined > key="/dashboard:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< null > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 400526ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 400883ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 400885ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 412143ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 508727ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 509065ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 509068ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 510951ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 665122ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 665466ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 665468ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 784394ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 diff --git a/code/fms/.playwright-cli/console-2026-07-24T15-10-27-585Z.log b/code/fms/.playwright-cli/console-2026-07-24T15-10-27-585Z.log deleted file mode 100644 index 84d103b0..00000000 --- a/code/fms/.playwright-cli/console-2026-07-24T15-10-27-585Z.log +++ /dev/null @@ -1,263 +0,0 @@ -[ 23608ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at ... > - at - at ref=Ref< undefined > key="/dashboard:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< null > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 31848ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 32185ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 32188ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 34366ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 369884ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 370225ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 370227ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 -[ 372408ms] [WARNING] [Vue warn]: Invalid prop: type check failed for prop "areaSelection". Expected Boolean, got Object - at - at - at - at > - at - at - at - at direction=undefined getPopupContainer=undefined ... > - at - at ref=Ref< Proxy(Object) > key="/system/module-management:0" > - at - at - at - at - at - at - at - at - at - at - at ref=Ref< Proxy(Object) > > - at - at - at - at - at - at - at - at @ http://localhost:5180/node_modules/.vite/deps/vue.runtime.esm-bundler-Bqo3P_GY.js?v=b7166b48:1751 diff --git a/code/fms/.playwright-cli/page-2026-07-24T14-53-55-495Z.yml b/code/fms/.playwright-cli/page-2026-07-24T14-53-55-495Z.yml deleted file mode 100644 index e69de29b..00000000 diff --git a/code/fms/.playwright-cli/page-2026-07-24T15-10-28-219Z.yml b/code/fms/.playwright-cli/page-2026-07-24T15-10-28-219Z.yml deleted file mode 100644 index e69de29b..00000000 diff --git a/code/fms/CODEBUDDY.md b/code/fms/CODEBUDDY.md index 3a789419..0f97be14 100644 --- a/code/fms/CODEBUDDY.md +++ b/code/fms/CODEBUDDY.md @@ -87,8 +87,8 @@ Vue 3 ` + + + + diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue index 0b03e0a3..5b55ba35 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue @@ -7,7 +7,7 @@ import FmsQueryToolbar from "./FmsQueryToolbar.vue"; import { useModuleList } from "./useModuleList"; const props = defineProps({ - moduleCode: { type: String, default: "" }, + dataCode: { type: String, default: "" }, columnExtensions: { type: Object, default: () => ({}) }, rowActions: { type: Array, default: () => [] }, rowClassName: { type: Function, default: undefined }, @@ -20,7 +20,7 @@ const emit = defineEmits(["row-click", "cell-click", "row-action", "expand", "lo const slots = useSlots(); const tableRef = ref(null); const moduleList = useModuleList({ - moduleCode: toRef(props, "moduleCode"), + dataCode: toRef(props, "dataCode"), pageSize: props.pageSize, }); diff --git a/code/fms/fms-vue/src/components/fms-module-list/useModuleList.js b/code/fms/fms-vue/src/components/fms-module-list/useModuleList.js index de32b148..cacd793c 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/useModuleList.js +++ b/code/fms/fms-vue/src/components/fms-module-list/useModuleList.js @@ -240,17 +240,29 @@ export function useModuleList(options = {}) { appliedFilters.value = filters.filter(Boolean); } - async function resolveConfiguredModule() { - const moduleCode = String(unref(options.moduleCode) || "").trim(); - if (moduleCode) { + async function resolvePageModule(runtimeData) { + const pageId = requireModuleId(runtimeData.b_parent_id); + const response = await loadDataApi( + "s_module", + `b_id = ${pageId} AND b_module_type = 'page' AND b_canuse = 1`, + "b_id ASC", + ); + const module = response.data?.[0]; + if (!module) throw new Error(`数据编码 ${runtimeData.b_code} 没有对应的启用页面`); + return module; + } + + async function resolveConfiguredModules() { + const dataCode = String(unref(options.dataCode) || route.meta.dataCode || "").trim(); + if (dataCode) { const response = await loadDataApi( "s_module", - `b_code = N'${escapeSqlText(moduleCode)}' AND b_canuse = 1`, + `b_code = N'${escapeSqlText(dataCode)}' AND b_module_type = 'data' AND b_canuse = 1`, "b_id ASC", ); - const module = response.data?.[0]; - if (!module) throw new Error(`未找到编码为 ${moduleCode} 的启用模块`); - return module; + const runtimeData = response.data?.[0]; + if (!runtimeData) throw new Error(`未找到编码为 ${dataCode} 的启用数据模块`); + return { runtimePage: await resolvePageModule(runtimeData), runtimeData }; } const routePath = escapeSqlText(route.path); @@ -259,9 +271,9 @@ export function useModuleList(options = {}) { `b_route = N'${routePath}' AND b_canuse = 1`, "b_id ASC", ); - const module = response.data?.[0]; - if (!module) throw new Error(`未找到路由 ${route.path} 对应的启用模块`); - return module; + const runtimePage = response.data?.[0]; + if (!runtimePage) throw new Error(`未找到路由 ${route.path} 对应的启用模块`); + return { runtimePage, runtimeData: await resolveDataModule(runtimePage) }; } async function resolveDataModule(runtimePage) { @@ -281,8 +293,7 @@ export function useModuleList(options = {}) { configLoading.value = true; configError.value = ""; try { - const runtimePage = await resolveConfiguredModule(); - const runtimeData = await resolveDataModule(runtimePage); + const { runtimePage, runtimeData } = await resolveConfiguredModules(); const dataId = requireModuleId(runtimeData.b_id); const [fieldResponse, listResponse, queryResponse] = await Promise.all([ loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"), @@ -426,9 +437,13 @@ export function useModuleList(options = {}) { return keyField ? row?.[keyField] : JSON.stringify(row); } - watch(() => [String(unref(options.moduleCode) || ""), route.path], initialize, { - immediate: true, - }); + watch( + () => [String(unref(options.dataCode) || route.meta.dataCode || ""), route.path], + initialize, + { + immediate: true, + }, + ); onBeforeUnmount(() => { disposed = true; diff --git a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue index 510d85d3..60be07d4 100644 --- a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue +++ b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue @@ -51,12 +51,9 @@ useResizeObserver(rootRef, ([entry]) => { }); const NAVIGATION_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Tab"]); -const NAVIGATION_IDLE_MS = 80; const rowEditingStates = new WeakMap(); let activeEditingRow = null; let pendingEditingTarget = null; -let navigationEvent = null; -let navigationTimer = null; const pressedNavigationKeys = new Set(); function getRowKey(row) { @@ -232,41 +229,80 @@ function initVirtualScroll() { return tableRef.value?.initVirtualScroll?.(); } -function clearNavigationTimer() { - if (navigationTimer) clearTimeout(navigationTimer); - navigationTimer = null; +let editActivateTimer = null; +let lastEditActivateAt = 0; +let isRepeatNavigation = false; + +function clearEditActivateTimer() { + if (editActivateTimer) { + clearTimeout(editActivateTimer); + editActivateTimer = null; + } +} + +// 键盘导航停止(松开按键或停顿)后再激活编辑器,合并连续/按住按键, +// 避免滚动过程中每帧创建/销毁编辑器组件导致掉帧。 +function scheduleEditActivation() { + clearEditActivateTimer(); + editActivateTimer = setTimeout(() => { + editActivateTimer = null; + commitPendingEditing(); + }, 100); } function commitPendingEditing() { - clearNavigationTimer(); const target = pendingEditingTarget; pendingEditingTarget = null; if (!target) return; const row = props.dataSource.find( (item) => item === target.row || String(getRowKey(item)) === String(getRowKey(target.row)), ); - if (row) activateEditing(row, target.focusCol); + if (row) { + activateEditing(row, target.focusCol); + lastEditActivateAt = performance.now(); + } } -function schedulePendingEditing() { - clearNavigationTimer(); - navigationTimer = setTimeout(commitPendingEditing, NAVIGATION_IDLE_MS); +function removeNavigationListeners() { + window.removeEventListener("keyup", handleKeyupCapture, true); + window.removeEventListener("blur", handleWindowBlur); } function handleKeydownCapture(event) { if (!NAVIGATION_KEYS.has(event.key)) return; + if (!pressedNavigationKeys.size) { + window.addEventListener("keyup", handleKeyupCapture, true); + window.addEventListener("blur", handleWindowBlur); + } pressedNavigationKeys.add(event.key); - const currentEvent = { repeat: event.repeat }; - navigationEvent = currentEvent; - queueMicrotask(() => { - if (navigationEvent === currentEvent) navigationEvent = null; - }); + // event.repeat=true 表示系统自动重复(按住方向键不松),用于区分“按住滚动”与“离散点按” + if (event.repeat) isRepeatNavigation = true; } function handleKeyupCapture(event) { if (!NAVIGATION_KEYS.has(event.key)) return; - pressedNavigationKeys.delete(event.key); - if (!pressedNavigationKeys.size) commitPendingEditing(); + if (!pressedNavigationKeys.delete(event.key) || pressedNavigationKeys.size) return; + + removeNavigationListeners(); + + if (isRepeatNavigation) { + // 长按已经结束,立即恢复最终目标行,不再额外等待防抖时间。 + isRepeatNavigation = false; + clearEditActivateTimer(); + commitPendingEditing(); + return; + } + + // 快速离散连按仍使用短防抖,避免连续挂载/卸载整行编辑器。 + if (pendingEditingTarget) scheduleEditActivation(); +} + +function handleWindowBlur() { + removeNavigationListeners(); + pressedNavigationKeys.clear(); + isRepeatNavigation = false; + clearEditActivateTimer(); + commitPendingEditing(); } // 选区变化后,目标行进入编辑,活动单元格获得焦点。 @@ -278,22 +314,41 @@ function handleAreaSelectionChange(ranges) { const col = target ? displayColumns.value[target.col] : null; if (!row) { pendingEditingTarget = null; - clearNavigationTimer(); clearActiveEditing(); + clearEditActivateTimer(); return; } const focusCol = col?.editor ? col.dataIndex : null; - if (navigationEvent?.repeat) { - pendingEditingTarget = { row, focusCol }; - clearActiveEditing(); - schedulePendingEditing(); + // 键盘导航期间(按下方向键等): + // - 按住方向键滚动(event.repeat=true):全程保持 0 编辑器,避免长按下反复创建组件导致卡顿/闪烁; + // 松手后再激活到最终行。 + // - 慢速离散点按(非 repeat):立即激活,编辑器逐行跟随、跟手; + // - 快速连按(间隔 <150ms):防抖合并,避免连按时每键挂载掉帧。 + if (pressedNavigationKeys.size > 0) { + if (isRepeatNavigation) { + pendingEditingTarget = { row, focusCol }; + clearActiveEditing(); + } else { + const now = performance.now(); + if (now - lastEditActivateAt >= 150) { + pendingEditingTarget = null; + clearEditActivateTimer(); + activateEditing(row, focusCol); + lastEditActivateAt = now; + } else { + pendingEditingTarget = { row, focusCol }; + scheduleEditActivation(); + } + } return; } + // 非键盘导航(鼠标点击等):立即激活 pendingEditingTarget = null; - clearNavigationTimer(); + clearEditActivateTimer(); activateEditing(row, focusCol); + lastEditActivateAt = performance.now(); } watch( @@ -316,15 +371,19 @@ watch( (editable) => { if (editable) return; pendingEditingTarget = null; + removeNavigationListeners(); pressedNavigationKeys.clear(); - clearNavigationTimer(); + isRepeatNavigation = false; + clearEditActivateTimer(); clearActiveEditing(); + tableRef.value?.clearSelectedArea?.(); }, ); onBeforeUnmount(() => { + removeNavigationListeners(); + clearEditActivateTimer(); pressedNavigationKeys.clear(); - clearNavigationTimer(); }); defineExpose({ @@ -338,12 +397,7 @@ defineExpose({ diff --git a/code/fms/fms-vue/src/views/module-management/components/ModuleFieldsPanel.vue b/code/fms/fms-vue/src/views/module-management/components/ModuleFieldsPanel.vue index 9e156b34..0219faa2 100644 --- a/code/fms/fms-vue/src/views/module-management/components/ModuleFieldsPanel.vue +++ b/code/fms/fms-vue/src/views/module-management/components/ModuleFieldsPanel.vue @@ -1,7 +1,7 @@ diff --git a/code/fms/fms-vue/src/views/module-management/components/ModulePowersPanel.vue b/code/fms/fms-vue/src/views/module-management/components/ModulePowersPanel.vue index 11edbf6a..fcf60d7b 100644 --- a/code/fms/fms-vue/src/views/module-management/components/ModulePowersPanel.vue +++ b/code/fms/fms-vue/src/views/module-management/components/ModulePowersPanel.vue @@ -3,6 +3,7 @@ import { computed, h, nextTick, ref, watch } from "vue"; import { message, Modal } from "antdv-next"; import { Languages } from "@lucide/vue"; import FmsTable from "@/components/fms-table/FmsTable.vue"; +import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from "@/utils/i18n"; import { createTempId } from "@/utils/tempId"; import I18nQuickSetModal from "./I18nQuickSetModal.vue"; @@ -13,7 +14,7 @@ const props = defineProps({ loading: { type: Boolean, default: false }, }); -const emit = defineEmits(["update"]); +const emit = defineEmits(["update", "i18nSaved"]); const localPowers = ref([]); const activeKey = ref(null); @@ -24,11 +25,10 @@ watch( () => [props.powers, props.moduleCode], ([val, moduleCode], previous = []) => { if (val === localPowers.value && moduleCode === previous[1]) return; - localPowers.value = (val || []).map((r) => ({ - ...r, - b_i18n: - r.b_code && props.moduleCode ? `power.${props.moduleCode}.${r.b_code}` : (r.b_i18n ?? null), - })); + const rows = (val || []).map((row) => ({ ...row })); + const changed = syncModuleItemI18nKeys(rows, "power", moduleCode, "b_code"); + localPowers.value = rows; + if (changed) emit("update", rows); }, { immediate: true }, ); @@ -46,7 +46,7 @@ async function addRow() { b_code: "", b_name: "", b_i18n: null, - b_xh: localPowers.value.length * 10, + b_xh: (localPowers.value.length + 1) * 10, b_canuse: 1, }); emitUpdate(); @@ -89,7 +89,7 @@ function batchDelete() { cancelText: "取消", onOk: () => { const delSet = new Set(keys); - localPowers.value = localPowers.value.filter((r) => !delSet.has(r.b_id)); + localPowers.value = localPowers.value.filter((r) => !delSet.has(String(r.b_id))); selectedRowKeys.value = []; if (activeKey.value && delSet.has(activeKey.value)) activeKey.value = ""; emitUpdate(); @@ -99,6 +99,7 @@ function batchDelete() { // 编辑器已直接更新当前行,这里只向父级同步草稿。 function onCellChange() { + syncModuleItemI18nKeys(localPowers.value, "power", props.moduleCode, "b_code"); emitUpdate(); } @@ -106,8 +107,7 @@ function onCellChange() { const i18nModalRef = ref(null); function openI18n(row) { - const key = - row.b_code && props.moduleCode ? `power.${props.moduleCode}.${row.b_code}` : row.b_i18n; + const key = buildModuleItemI18nKey("power", props.moduleCode, row.b_code); if (!key) { message.warning("请先填写操作编码"); return; @@ -208,7 +208,7 @@ const columns = ref([ @change="onCellChange" /> - + diff --git a/code/fms/fms-vue/src/views/module-management/components/ModuleQueryConfigPanel.vue b/code/fms/fms-vue/src/views/module-management/components/ModuleQueryConfigPanel.vue index 8ab89d0b..1cde1bc0 100644 --- a/code/fms/fms-vue/src/views/module-management/components/ModuleQueryConfigPanel.vue +++ b/code/fms/fms-vue/src/views/module-management/components/ModuleQueryConfigPanel.vue @@ -65,7 +65,7 @@ function addRow() { b_component: "input", b_operator: "=", b_default_value: null, - b_xh: localQuery.value.length * 10, + b_xh: (localQuery.value.length + 1) * 10, b_canuse: 1, }); emitUpdate(); @@ -85,7 +85,6 @@ const columns = shallowRef([ editor: { type: "select", options: () => availableFields.value, - onChange: () => emitUpdate(), }, }, { @@ -95,7 +94,6 @@ const columns = shallowRef([ editor: { type: "select", options: COMPONENT_OPTIONS, - onChange: () => emitUpdate(), }, }, { @@ -105,7 +103,6 @@ const columns = shallowRef([ editor: { type: "select", options: OPERATOR_OPTIONS, - onChange: () => emitUpdate(), }, }, { dataIndex: "b_default_value", title: "默认查询值", width: 140 }, @@ -114,7 +111,7 @@ const columns = shallowRef([ title: "启用", width: 60, align: "center", - editor: { type: "checkbox", onChange: () => emitUpdate() }, + editor: { type: "checkbox" }, }, { dataIndex: "b_xh", title: "排序号", width: 80 }, { @@ -149,7 +146,8 @@ const columns = shallowRef([ :row-draggable="!loading" row-key="b_id" :active-row-key="activeKey" - @row-click="(ev, row) => (activeKey = row.b_id)" + @row-click="(_, row) => (activeKey = String(row.b_id))" + @change="emitUpdate" @row-order-change="onRowOrderChange" /> diff --git a/code/fms/fms-vue/src/views/module-management/index.vue b/code/fms/fms-vue/src/views/module-management/index.vue index 0ab3b45a..b71a80ea 100644 --- a/code/fms/fms-vue/src/views/module-management/index.vue +++ b/code/fms/fms-vue/src/views/module-management/index.vue @@ -1,16 +1,19 @@ diff --git a/code/fms/模块管理实施计划.md b/code/fms/模块管理实施计划.md index 8648c2c4..4a312551 100644 --- a/code/fms/模块管理实施计划.md +++ b/code/fms/模块管理实施计划.md @@ -209,7 +209,7 @@ services/moduleManagement.js ### 前端权限 -- 路由通过 `meta.moduleCode` 声明页面对应的模块代码。 +- 数据型路由通过 `meta.dataCode` 声明核心数据编码,权限仓库根据数据节点的 `b_parent_id` 解析页面权限;尚未配置数据节点的系统页面暂时兼容 `meta.moduleCode`。 - 用户没有 `b_user_module` 记录时禁止进入对应页面。 - 页面操作根据 `b_user_power` 中的 `create`、`update`、`delete` 权限禁用。 - 本阶段不改通用后端接口,也不做后端权限拦截;后端校验后续单独实施。 diff --git a/新建 文本文档.txt b/新建 文本文档.txt deleted file mode 100644 index e69de29b..00000000