diff --git a/ERP费用核销并发控制方案.md b/ERP费用核销并发控制方案.md new file mode 100644 index 00000000..1751315d --- /dev/null +++ b/ERP费用核销并发控制方案.md @@ -0,0 +1,571 @@ +# 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. 财务业务边界 + +采用部分成功前,需要确认批量核销的业务含义。 + +如果多笔费用只是一次操作选择形成的处理批次,可以允许部分成功,成功项分别形成核销流水。 + +如果多笔费用共同对应一张不可拆分的付款单、结算单或会计凭证,并且必须满足固定总金额,则部分成功可能造成单据金额不平。这种场景应采用全成全败,或者先完成逐笔预校验和额度预占,再统一确认入账。 + +因此,建议将允许部分成功的批次定义为“操作批次”,而不是一笔不可拆分的会计交易。最终会计单据应根据成功明细生成,并确保借贷和业务金额平衡。 diff --git a/code/app/app-rn/.env.development b/code/app/app-rn/.env.development index d26d0f76..e40d5588 100644 --- a/code/app/app-rn/.env.development +++ b/code/app/app-rn/.env.development @@ -1,5 +1,5 @@ -EXPO_PUBLIC_API_URL=http://192.168.5.80:8087 +EXPO_PUBLIC_API_URL=http://192.168.5.115:8087 # EXPO_PUBLIC_API_URL=https://www.oneao.cn/app -EXPO_PUBLIC_WS_URL=ws://192.168.5.80:8087 +EXPO_PUBLIC_WS_URL=ws://192.168.5.115:8087 EXPO_PUBLIC_G3_URL=http://118.89.70.199:9001 EXPO_PUBLIC_G3_BUCKET=app-dev \ No newline at end of file diff --git a/code/app/app-rn/scripts/alias-loader.mjs b/code/app/app-rn/scripts/alias-loader.mjs new file mode 100644 index 00000000..4e4eaa05 --- /dev/null +++ b/code/app/app-rn/scripts/alias-loader.mjs @@ -0,0 +1,21 @@ +// 开发期测试用的路径别名解析器:把 @/ 映射到 src/,并为 .ts/.tsx 探测扩展名 +import { pathToFileURL } from "node:url"; +import { resolve as pathResolve } from "node:path"; + +const SRC = pathResolve(process.cwd(), "src"); + +const EXTS = [".ts", ".tsx", ".js", ".mjs", "/index.ts", "/index.tsx"]; + +export async function resolve(specifier, context, nextResolve) { + if (specifier.startsWith("@/")) { + const base = pathResolve(SRC, specifier.slice(2)); + for (const ext of EXTS) { + try { + return await nextResolve(pathToFileURL(base + ext).href, context); + } catch { + // 尝试下一个扩展名 + } + } + } + return nextResolve(specifier, context); +} diff --git a/code/app/app-rn/scripts/test-auto-rules.mts b/code/app/app-rn/scripts/test-auto-rules.mts new file mode 100644 index 00000000..f9fa6d21 --- /dev/null +++ b/code/app/app-rn/scripts/test-auto-rules.mts @@ -0,0 +1,80 @@ +import dayjs from "dayjs"; +import { getMissedDates, buildAutoBillInserts } from "@/helpers/financeAuto"; + +// 对应 src/types/finance.ts 中 FinanceAutoRepeatType 的枚举值(从 1 开始) +const Daily = 1; +const Weekly = 2; +const Monthly = 3; +const Quarterly = 4; +const Yearly = 5; + +// b_finance 表合法列(由前端查询推断,确认不含 type) +const B_FINANCE_COLUMNS = new Set([ + "id", "family_id", "category_id", "amount", "remark", + "record_time", "create_by", "update_by", "create_time", "update_time", +]); + +// 统一使用本地时间,与 financeAuto.ts 内部 dayjs() 保持一致 +const todayStr = () => dayjs().format("YYYY-MM-DD"); +const yesterday = () => dayjs().subtract(1, "day").format("YYYY-MM-DD"); +const day = () => dayjs().day(); +const dateOfMonth = () => String(dayjs().date()); +const monthDay = () => `${dayjs().month() + 1},${dayjs().date()}`; + +let passed = 0; +function check(name, cond) { + if (!cond) { + console.error(" ✗ " + name); + throw new Error("FAILED: " + name); + } + passed++; + console.log(" ✓ " + name); +} + +console.log("== 1. 触发日期计算 (getMissedDates) =="); +{ + const dates = getMissedDates(Daily, "", yesterday()); + check("Daily 上次昨天→今天1条", dates.length === 1); + check("Daily 日期=今天", dates[0] === todayStr()); +} +{ + const dates = getMissedDates(Monthly, dateOfMonth(), yesterday()); + check("Monthly 今天日期→1条", dates.length === 1 && dates[0] === todayStr()); +} +{ + const dates = getMissedDates(Yearly, monthDay(), yesterday()); + check("Yearly 今天月日→1条", dates.length === 1 && dates[0] === todayStr()); +} +{ + const dates = getMissedDates(Daily, "!" + day(), yesterday()); // 排除今天星期 + check("Daily 排除今天→0条", dates.length === 0); +} +{ + const dates = getMissedDates(Daily, "", todayStr()); // lastRun=今天 + check("lastRun=今天→0条", dates.length === 0); +} + +console.log("== 2. 写入 b_finance 的记录构造 (buildAutoBillInserts) =="); +{ + const rule = { category_id: 10, amount: 99.5, remark: "房租" }; + const missed = [todayStr(), yesterday()]; + const ids = [1001, 1002]; + const inserts = buildAutoBillInserts(rule, missed, ids, 7, 3); + check("生成2条记录", inserts.length === 2); + let ok = true; + for (const rec of inserts) { + for (const k of Object.keys(rec)) { + if (!B_FINANCE_COLUMNS.has(k)) ok = false; + } + if ("type" in rec) ok = false; + } + check("记录字段均在 b_finance 合法列内且不含 type", ok); + check("remark 正确透传", inserts[0].remark === "房租"); + check("record_time 使用补记账日期", inserts[1].record_time === yesterday()); +} +{ + const inserts = buildAutoBillInserts({ category_id: 1, amount: 1 }, [todayStr()], [], 7, 3); + check("ids 为空→0条", inserts.length === 0); +} + +console.log("\n全部通过:" + passed + " 项 ✅"); diff --git a/code/app/app-rn/src/app/(main)/family/settings.tsx b/code/app/app-rn/src/app/(main)/family/settings.tsx index e48f4bad..7670a677 100644 --- a/code/app/app-rn/src/app/(main)/family/settings.tsx +++ b/code/app/app-rn/src/app/(main)/family/settings.tsx @@ -552,6 +552,12 @@ export default function HomeSettings() { + setShowFamilyNameSheet(false)} + onConfirm={handleRename} + confirmDisabled={isLoading || !renameForm.name.trim()} + /> - @@ -575,6 +578,12 @@ export default function HomeSettings() { + setShowNicknameSheet(false)} + onConfirm={handleNickname} + confirmDisabled={isLoading || !nicknameForm.nickname.trim()} + /> - diff --git a/code/app/app-rn/src/app/(main)/family/user.tsx b/code/app/app-rn/src/app/(main)/family/user.tsx index e1fe22b8..bc052e59 100644 --- a/code/app/app-rn/src/app/(main)/family/user.tsx +++ b/code/app/app-rn/src/app/(main)/family/user.tsx @@ -731,6 +731,12 @@ export default function UserSetting() { + setShowNameSheet(false)} + onConfirm={handleRename} + confirmDisabled={isLoading || !renameForm.name.trim()} + /> - @@ -753,6 +756,12 @@ export default function UserSetting() { + setShowPhoneSheet(false)} + onConfirm={handlePhone} + confirmDisabled={isLoading || !phoneForm.phone.trim()} + /> - diff --git a/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx index fe64a3c6..b8078b16 100644 --- a/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx @@ -19,6 +19,7 @@ import { Trash2Icon, } from "lucide-react-native"; import BottomSheet from "@/components/BottomSheet"; +import CategoryChipBar from "@/components/CategoryChipBar"; import ContextMenu from "@/components/ContextMenu"; import EmptyState from "@/components/EmptyStatus"; import LayoutView from "@/components/layout/view"; @@ -78,9 +79,17 @@ export default function ClothingHome() { const hasMore = clothes.length < clothesTotal; const clothesCategories = useMemo( - () => categories.filter((c) => c.type === ClothingTargetType.Clothes), + () => + categories + .filter((c) => c.type === ClothingTargetType.Clothes) + .sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), [categories], ); + + // 顶部分类快捷筛选(与筛选弹层的分类状态共用 filter.categoryId) + const handleQuickCategory = useCallback((id: number | null) => { + setFilter((p) => ({ ...p, categoryId: id })); + }, []); const clothesTags = useMemo( () => tags.filter((t) => t.type === ClothingTargetType.Clothes), [tags], @@ -353,6 +362,12 @@ export default function ClothingHome() { ]} > {renderHeader()} + {filteredClothes.length === 0 ? ( tags.filter((t) => t.parent_id === groupId), + // ── 拖拽排序 ────────────────────────────────── + const scrollRef = useAnimatedRef(); + + const sortedCurCategories = useMemo( + () => [...curCategories].sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), + [curCategories], + ); + const sortedCurTagGroups = useMemo( + () => [...curTagGroups].sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), + [curTagGroups], + ); + const getSortedTagsByGroup = useCallback( + (groupId: number) => + tags + .filter((t) => t.parent_id === groupId) + .sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), [tags], ); + const handleCatDragEnd = useCallback( + async ({ data }: { data: ClothingCategory[] }) => { + const updates = data.map((item, index) => ({ id: item.id, sort_number: index })); + const ok = await saveCategoryChanges({ updates }); + if (!ok) toast.error("排序保存失败"); + else await loadData(); + }, + [loadData, saveCategoryChanges], + ); + + const handleTagGroupDragEnd = useCallback( + async ({ data }: { data: ClothingTag[] }) => { + const updates = data.map((item, index) => ({ id: item.id, sort_number: index })); + const ok = await saveTagChanges({ updates }); + if (!ok) toast.error("排序保存失败"); + else await loadData(); + }, + [loadData, saveTagChanges], + ); + + const handleChildTagDragEnd = useCallback( + async (data: ClothingTag[]) => { + const updates = data.map((item, index) => ({ id: item.id, sort_number: index })); + const ok = await saveTagChanges({ updates }); + if (!ok) toast.error("排序保存失败"); + else await loadData(); + }, + [loadData, saveTagChanges], + ); + + const renderChildTagItem = useCallback>( + ({ item }) => ( + + + + + openTag(item.parent_id as number, item)} + > + + {item.name} + + + + ), + [openTag], + ); + + const renderGroupItem = useCallback>( + ({ item }) => { + const gt = getSortedTagsByGroup(item.id); + return ( + + + + + + + openGroup(item)}> + {item.name} + + + setGroupDelete(item)} + > + + + + {gt.length > 0 && ( + String(t.id)} + renderItem={renderChildTagItem} + rowGap={8} + onDragEnd={({ data }) => handleChildTagDragEnd(data)} + /> + )} + openTag(item.id)} + > + + 添加 + + + ); + }, + [getSortedTagsByGroup, handleChildTagDragEnd, openGroup, openTag, renderChildTagItem], + ); + + const renderCatItem = useCallback>( + ({ item }) => ( + openCat(item)} + > + + + + + {item.name} + + { + e.stopPropagation(); + setCatDelete(item); + }} + > + + + + ), + [openCat], + ); + return ( runRefresh(loadData)} showsVerticalScrollIndicator={false} @@ -285,38 +425,17 @@ export default function ClothingManage() { 添加 - {curCategories.length > 0 ? ( - - {curCategories.map((c) => ( - - openCat(c)} - > - {c.name} - { - e.stopPropagation(); - setCatDelete(c); - }} - > - - - - - ))} - + {sortedCurCategories.length > 0 ? ( + String(c.id)} + renderItem={renderCatItem} + rowGap={12} + onDragEnd={handleCatDragEnd} + /> ) : ( 暂无分类,点击右上角添加 )} @@ -337,60 +456,17 @@ export default function ClothingManage() { 新建分组 - {curTagGroups.length > 0 ? ( - - {curTagGroups.map((group) => { - const gTags = getTagsByGroup(group.id); - return ( - - - openGroup(group)}> - {group.name} - - setGroupDelete(group)} - > - - - - - {gTags.map((t) => ( - openTag(group.id, t)} - > - - {t.name} - - - ))} - openTag(group.id)} - > - + 添加 - - - - ); - })} - + {sortedCurTagGroups.length > 0 ? ( + String(g.id)} + renderItem={renderGroupItem} + rowGap={12} + onDragEnd={handleTagGroupDragEnd} + /> ) : ( 暂无标签分组,先新建分组再添加标签 )} @@ -401,10 +477,13 @@ export default function ClothingManage() { - - - {editingCat ? "编辑分类" : "添加分类"} - + + setShowCatSheet(false)} + onConfirm={saveCat} + confirmDisabled={!catForm.trim()} + /> - - - - @@ -430,10 +501,13 @@ export default function ClothingManage() { - - - {editingGroup ? "编辑分组" : "新建分组"} - + + setShowGroupSheet(false)} + onConfirm={saveGroup} + confirmDisabled={!groupForm.trim()} + /> - - - - @@ -464,9 +526,12 @@ export default function ClothingManage() { - - {editingTag ? "编辑标签" : "添加标签"} - + setShowTagSheet(false)} + onConfirm={saveTag} + confirmDisabled={!tagForm.name.trim()} + /> - - - - diff --git a/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx index 19735852..01e58d7d 100644 --- a/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx +++ b/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx @@ -11,6 +11,7 @@ import { import { router, useFocusEffect } from "expo-router"; import { Button, Dialog, Label, TextField } from "heroui-native"; import BottomSheet from "@/components/BottomSheet"; +import CategoryChipBar from "@/components/CategoryChipBar"; import ContextMenu from "@/components/ContextMenu"; import { FilterIcon, @@ -180,6 +181,14 @@ export default function ClothingOutfitList() { [outfitClothes], ); + const outfitCategories = useMemo( + () => + categories + .filter((c) => c.type === ClothingTargetType.Outfit) + .sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), + [categories], + ); + const filteredOutfits = useMemo(() => { const kw = keyword.trim().toLowerCase(); return outfits.filter((item) => { @@ -309,6 +318,12 @@ export default function ClothingOutfitList() { ]} > {renderHeader()} + {filteredOutfits.length === 0 ? ( - {categories - .filter((c) => c.type === ClothingTargetType.Outfit) - .map((c) => { + {outfitCategories.map((c) => { const s = filterCategoryId === c.id; return ( void; @@ -108,9 +111,11 @@ function DraggableCanvasItem({ }) { const startRef = useRef({ x: item.x, y: item.y }); const itemRef = useRef(item); - const sizeRef = useRef(canvasSize); + const widthRef = useRef(canvasWidth); + const heightRef = useRef(canvasHeight); itemRef.current = item; - sizeRef.current = canvasSize; + widthRef.current = canvasWidth; + heightRef.current = canvasHeight; const panResponder = useRef( PanResponder.create({ @@ -121,17 +126,18 @@ function DraggableCanvasItem({ onSelect(itemRef.current.uid); }, onPanResponderMove: (_, gestureState) => { - const sz = sizeRef.current; + const w = widthRef.current; + const h = heightRef.current; const it = itemRef.current; - if (!sz) return; - const nextX = clamp(startRef.current.x + gestureState.dx / sz, 0, 1 - it.width); - const nextY = clamp(startRef.current.y + gestureState.dy / sz, 0, 1 - it.height); + if (!w || !h) return; + const nextX = clamp(startRef.current.x + gestureState.dx / w, 0, 1 - it.width); + const nextY = clamp(startRef.current.y + gestureState.dy / h, 0, 1 - it.height); onMove(it.uid, normalizeNumber(nextX), normalizeNumber(nextY)); }, }), ).current; - if (!canvasSize) return null; + if (!canvasWidth || !canvasHeight) return null; return ( @@ -190,6 +196,7 @@ export default function ClothingOutfitEdit() { const [remark, setRemark] = useState(""); const [selectedTagIds, setSelectedTagIds] = useState([]); const [canvasSize, setCanvasSize] = useState(0); + const [canvasHeight, setCanvasHeight] = useState(0); const [items, setItems] = useState([]); const [selectedUid, setSelectedUid] = useState(null); const [pageLoading, setPageLoading] = useState(true); @@ -246,11 +253,13 @@ export default function ClothingOutfitEdit() { const clothesFilterCount = useMemo(() => { let c = 0; - if (clothesFilterCatId) c++; if (clothesFilterTagIds.length > 0) c++; if (clothesFilterOwnerId) c++; return c; - }, [clothesFilterCatId, clothesFilterTagIds, clothesFilterOwnerId]); + }, [clothesFilterTagIds, clothesFilterOwnerId]); + + // 从衣橱选择衣服弹窗默认高度:固定接近顶部,约为屏幕高度减去外壳估算 + const clothesSheetHeight = Math.max(Dimensions.get("window").height - 200, 360); const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]); const outfitCategories = useMemo( @@ -729,7 +738,11 @@ export default function ClothingOutfitEdit() { collapsable={false} className="w-full flex-1" style={{ aspectRatio: 3 / 4 }} - onLayout={(event) => setCanvasSize(event.nativeEvent.layout.width)} + onLayout={(event) => { + const { width, height } = event.nativeEvent.layout; + setCanvasSize(width); + setCanvasHeight(height); + }} > {items.length === 0 && ( @@ -741,7 +754,8 @@ export default function ClothingOutfitEdit() { {/* 从衣橱选择衣服 */} - + @@ -915,9 +933,40 @@ export default function ClothingOutfitEdit() { + {/* 分类快捷选择(直接放在外部,不进筛选) */} + + setClothesFilterCatId(null)} + > + + 全部 + + + {clothesCategories.map((c) => ( + setClothesFilterCatId((p) => (p === c.id ? null : c.id))} + > + + {c.name} + + + ))} + + {/* 衣服列表 */} ) => { const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; @@ -1029,36 +1078,6 @@ export default function ClothingOutfitEdit() { - {/* 分类 */} - - - - setClothesFilterCatId(null)} - > - - 全部 - - - {clothesCategories.map((c) => ( - setClothesFilterCatId((p) => (p === c.id ? null : c.id))} - > - - {c.name} - - - ))} - - - {/* 标签 */} diff --git a/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx index 27b94b7f..d8741214 100644 --- a/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx +++ b/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx @@ -592,20 +592,26 @@ export default function FinanceAnalysis() { - - - - 筛选分析 - + + setShowFilterSheet(false)} + onConfirm={handleApplyFilter} + rightExtra={ 重置 - - + } + /> - - - - - diff --git a/code/app/app-rn/src/app/finance/(tabs)/auto-rules/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/auto-rules/index.tsx index 0ad874e9..0678ea0c 100644 --- a/code/app/app-rn/src/app/finance/(tabs)/auto-rules/index.tsx +++ b/code/app/app-rn/src/app/finance/(tabs)/auto-rules/index.tsx @@ -385,9 +385,12 @@ export default function AutoRulesPage() { - - {editingRuleId ? "编辑规则" : "新增规则"} - + setShowEditor(false)} + onConfirm={handleSave} + /> + )} - - - - - diff --git a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx index fdc3bbec..6f3b7880 100644 --- a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx @@ -256,11 +256,15 @@ export default function Home() { // 自动记账:加载规则并执行 if (!autoRunRef.current && userId) { autoRunRef.current = true; - fetchAutoRules(familyId).then(() => { - runAutoRules(familyId, userId).then((count) => { + fetchAutoRules(familyId) + .then(() => runAutoRules(familyId, userId)) + .then((count) => { if (count > 0) loadBills(filter); + }) + .catch((err) => { + console.error("自动记账执行失败", err); + Alert.alert("自动记账失败", err?.message || "请稍后重试"); }); - }); } }, [familyId, filter, loadBills, fetchAutoRules, runAutoRules, userId]), ); @@ -589,20 +593,26 @@ export default function Home() { - - - - 筛选记账 - + + setShowFilterSheet(false)} + onConfirm={handleApplyFilter} + rightExtra={ 重置 - - + } + /> - - - - - @@ -817,8 +813,13 @@ export default function Home() { - - 自动记账 + + setShowAutoSheet(false)} + onConfirm={handleSaveAuto} + /> + )} - - - - - diff --git a/code/app/app-rn/src/app/finance/add/index.tsx b/code/app/app-rn/src/app/finance/add/index.tsx index dd4b7109..d48d9ff2 100644 --- a/code/app/app-rn/src/app/finance/add/index.tsx +++ b/code/app/app-rn/src/app/finance/add/index.tsx @@ -62,6 +62,9 @@ export default function Add() { const { isLoading, startLoading, stopLoading } = useLoading(); const notifyModuleOperation = useModuleChatNotifier(); const amountInputRef = useRef(null); + const remarkInputRef = useRef(null); + const enteredRemarkRef = useRef(false); + const amountProgrammaticFocusRef = useRef(false); const [activeTab, setActiveTab] = useState("0"); // 0=支出 1=收入 const [keyword, setKeyword] = useState(""); @@ -335,6 +338,28 @@ export default function Add() { ref={amountInputRef} placeholder="0.00" inputMode="decimal" + returnKeyType="next" + blurOnSubmit={false} + onSubmitEditing={() => { + enteredRemarkRef.current = true; + Keyboard.dismiss(); + // 收起后重新聚焦,强制触发键盘弹出事件,让面板重新适配 + setTimeout(() => remarkInputRef.current?.focus(), 100); + }} + onFocus={() => { + // 从备注框点回金额框时,键盘已打开不会重新弹出, + // 需强制收起再聚焦以触发适配,避免备注内容被遮挡。 + // 程序化 focus 触发的 onFocus 直接跳过,避免循环 + if (!enteredRemarkRef.current) return; + if (amountProgrammaticFocusRef.current) { + amountProgrammaticFocusRef.current = false; + return; + } + enteredRemarkRef.current = false; + Keyboard.dismiss(); + amountProgrammaticFocusRef.current = true; + setTimeout(() => amountInputRef.current?.focus(), 100); + }} value={amount} onChangeText={setAmount} className="text-4xl font-bold text-center w-full" @@ -346,6 +371,7 @@ export default function Add() { - + {form && ( <> + - )} diff --git a/code/app/app-rn/src/app/meal/(tabs)/manage/index.tsx b/code/app/app-rn/src/app/meal/(tabs)/manage/index.tsx index 2bac672b..35c8b753 100644 --- a/code/app/app-rn/src/app/meal/(tabs)/manage/index.tsx +++ b/code/app/app-rn/src/app/meal/(tabs)/manage/index.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Text, TextInput, TouchableOpacity, View } from "react-native"; import { Button, Dialog, Label, TextField } from "heroui-native"; -import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native"; +import { GripVerticalIcon, PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native"; +import Sortable, { SortableGridRenderItem } from "react-native-sortables"; +import { useAnimatedRef } from "react-native-reanimated"; import BottomSheet from "@/components/BottomSheet"; import LayoutView from "@/components/layout/view"; import { LoadingOverlay } from "@/components/LoadingOverlay"; @@ -9,6 +11,7 @@ import { useListLoading, useLoading } from "@/hooks/useLoading"; import { getUniqueIdApi } from "@/request/api"; import { useFamilyStore, useMealStore } from "@/store"; import { MealCategory, MealSlot, MealStatus, MealTag } from "@/types/meal"; +import { toast } from "sonner-native"; type ManageTab = "category" | "tag" | "slot"; @@ -387,14 +390,195 @@ export default function MealManagePage() { } }, [loadData, saveSlotChanges, slotDelete, startLoading, stopLoading]); - const getTagsByGroup = useCallback( - (gId: number) => tags.filter((t) => t.parent_id === gId), + // ── 拖拽排序 ────────────────────────────────── + const scrollRef = useAnimatedRef(); + + const sortedCategories = useMemo( + () => [...categories].sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), + [categories], + ); + const sortedSlots = useMemo( + () => [...slots].sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), + [slots], + ); + const sortedParentTags = useMemo( + () => [...parentTags].sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), + [parentTags], + ); + const getSortedTagsByGroup = useCallback( + (gId: number) => + tags + .filter((t) => t.parent_id === gId) + .sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), [tags], ); + const handleCatDragEnd = useCallback( + async ({ data }: { data: MealCategory[] }) => { + const updates = data.map((item, index) => ({ id: item.id, sort_number: index })); + const ok = await saveCategoryChanges({ updates }); + if (!ok) toast.error("排序保存失败"); + else await loadData(); + }, + [loadData, saveCategoryChanges], + ); + + const handleSlotDragEnd = useCallback( + async ({ data }: { data: MealSlot[] }) => { + const updates = data.map((item, index) => ({ id: item.id, sort_number: index })); + const ok = await saveSlotChanges({ updates }); + if (!ok) toast.error("排序保存失败"); + else await loadData(); + }, + [loadData, saveSlotChanges], + ); + + const handleParentTagDragEnd = useCallback( + async ({ data }: { data: MealTag[] }) => { + const updates = data.map((item, index) => ({ id: item.id, sort_number: index })); + const ok = await saveTagChanges({ updates }); + if (!ok) toast.error("排序保存失败"); + else await loadData(); + }, + [loadData, saveTagChanges], + ); + + const handleChildTagDragEnd = useCallback( + async (data: MealTag[]) => { + const updates = data.map((item, index) => ({ id: item.id, sort_number: index })); + const ok = await saveTagChanges({ updates }); + if (!ok) toast.error("排序保存失败"); + else await loadData(); + }, + [loadData, saveTagChanges], + ); + + const renderChildTagItem = useCallback>( + ({ item }) => ( + + + + + openTag(item.parent_id as number, item)} + > + + {item.name} + + + + ), + [openTag], + ); + + const renderGroupItem = useCallback>( + ({ item }) => { + const gt = getSortedTagsByGroup(item.id); + return ( + + + + + + + openGroup(item)}> + {item.name} + + + setGroupDelete(item)} + > + + + + {gt.length > 0 && ( + String(t.id)} + renderItem={renderChildTagItem} + rowGap={8} + onDragEnd={({ data }) => handleChildTagDragEnd(data)} + /> + )} + openTag(item.id)} + > + + 添加 + + + ); + }, + [getSortedTagsByGroup, handleChildTagDragEnd, openGroup, openTag, renderChildTagItem], + ); + + const renderCatItem = useCallback>( + ({ item }) => ( + openCat(item)} + > + + + + + {item.name} + + { + e.stopPropagation(); + setCatDelete(item); + }} + > + + + + ), + [openCat], + ); + + const renderSlotItem = useCallback>( + ({ item }) => ( + openSlot(item)} + > + + + + + {item.name} + + { + e.stopPropagation(); + setSlotDelete(item); + }} + > + + + + ), + [openSlot], + ); + return ( runRefresh(loadData)} showsVerticalScrollIndicator={false} @@ -447,38 +631,17 @@ export default function MealManagePage() { 添加 - {categories.length > 0 ? ( - - {categories.map((c) => ( - - openCat(c)} - > - {c.name} - { - e.stopPropagation(); - setCatDelete(c); - }} - > - - - - - ))} - + {sortedCategories.length > 0 ? ( + String(c.id)} + renderItem={renderCatItem} + rowGap={12} + onDragEnd={handleCatDragEnd} + /> ) : ( 暂无分类,点击右上角添加 )} @@ -501,60 +664,17 @@ export default function MealManagePage() { 新建分组 - {parentTags.length > 0 ? ( - - {parentTags.map((g) => { - const gt = getTagsByGroup(g.id); - return ( - - - openGroup(g)}> - {g.name} - - setGroupDelete(g)} - > - - - - - {gt.map((t) => ( - openTag(g.id, t)} - > - - {t.name} - - - ))} - openTag(g.id)} - > - + 添加 - - - - ); - })} - + {sortedParentTags.length > 0 ? ( + String(g.id)} + renderItem={renderGroupItem} + rowGap={12} + onDragEnd={handleParentTagDragEnd} + /> ) : ( 暂无标签分组,先新建分组再添加标签 )} @@ -577,40 +697,17 @@ export default function MealManagePage() { 添加 - {slots.length > 0 ? ( - - {slots.map((s, i) => { - return ( - openSlot(s)} - > - - - {s.name} - - { - e.stopPropagation(); - setSlotDelete(s); - }} - > - - - - - ); - })} - + {sortedSlots.length > 0 ? ( + String(s.id)} + renderItem={renderSlotItem} + rowGap={12} + onDragEnd={handleSlotDragEnd} + /> ) : ( 暂无餐段,点击右上角添加 )} @@ -625,10 +722,13 @@ export default function MealManagePage() { - - - {editingCat ? "编辑分类" : "添加分类"} - + + setShowCatSheet(false)} + onConfirm={saveCat} + confirmDisabled={!catForm.trim()} + /> - - - - - - - {editingGroup ? "编辑分组" : "新建分组"} - + + setShowGroupSheet(false)} + onConfirm={saveGroup} + confirmDisabled={!groupForm.trim()} + /> - - - - @@ -684,9 +767,12 @@ export default function MealManagePage() { - - {editingTag ? "编辑标签" : "添加标签"} - + setShowTagSheet(false)} + onConfirm={saveTag} + confirmDisabled={!tagForm.name.trim()} + /> - - - - - - - {editingSlot ? "编辑餐段" : "添加餐段"} - + + setShowSlotSheet(false)} + onConfirm={saveSlot} + confirmDisabled={!slotForm.trim()} + /> - - - - diff --git a/code/app/app-rn/src/app/meal/dish/edit/index.tsx b/code/app/app-rn/src/app/meal/dish/edit/index.tsx index a0a6da04..ae115585 100644 --- a/code/app/app-rn/src/app/meal/dish/edit/index.tsx +++ b/code/app/app-rn/src/app/meal/dish/edit/index.tsx @@ -13,7 +13,7 @@ import { useLoading } from "@/hooks/useLoading"; import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier"; import { getUniqueIdApi } from "@/request/api"; import { useFamilyStore, useMealStore, useUserStore } from "@/store"; -import { MealDishDifficulty, MealStatus } from "@/types/meal"; +import { MealDishDifficulty, MealDishTag, MealStatus } from "@/types/meal"; interface DishForm { name: string; @@ -54,10 +54,16 @@ export default function MealDishEdit() { const fetchDishSteps = useMealStore((s) => s.fetchDishSteps); const uploadDishImage = useMealStore((s) => s.uploadDishImage); const uploadStepImage = useMealStore((s) => s.uploadStepImage); + const tags = useMealStore((s) => s.tags); + const fetchTags = useMealStore((s) => s.fetchTags); + const fetchDishTags = useMealStore((s) => s.fetchDishTags); + const dishTags = useMealStore((s) => s.dishTags); + const saveDishTagChanges = useMealStore((s) => s.saveDishTagChanges); const { isLoading, startLoading, stopLoading } = useLoading(); const notifyOp = useModuleChatNotifier(); const [form, setForm] = useState(emptyForm); + const [selectedTagIds, setSelectedTagIds] = useState([]); const [steps, setSteps] = useState< { key: string; @@ -89,13 +95,53 @@ export default function MealDishEdit() { setSteps((p) => p.map((s) => (s.key === key ? { ...s, description: desc } : s))); }, []); + const saveDishTagsForDish = useCallback( + async (dishId: number) => { + const existing = (useMealStore.getState().dishTags ?? []).filter( + (r) => r.dish_id === dishId, + ); + const existingIds = new Set(existing.map((r) => r.tag_id)); + const toAdd = selectedTagIds.filter((id) => !existingIds.has(id)); + const toRemove = existing.filter((r) => !selectedTagIds.includes(r.tag_id)); + const inserts: Partial[] = []; + const deletes: Partial[] = []; + if (toAdd.length > 0) { + const res = await getUniqueIdApi(toAdd.length); + const ids = res.data ?? []; + toAdd.forEach((tid, i) => { + inserts.push({ + id: ids[i] ?? 0, + family_id: familyId, + dish_id: dishId, + tag_id: tid, + create_by: userId, + update_by: userId, + }); + }); + } + toRemove.forEach((r) => deletes.push({ id: r.id })); + if (inserts.length > 0 || deletes.length > 0) { + const ok = await saveDishTagChanges({ inserts, deletes }); + if (ok) await fetchDishTags(familyId, dishId); + return ok; + } + return true; + }, + [familyId, userId, selectedTagIds, saveDishTagChanges, fetchDishTags], + ); + const enabledCats = useMemo(() => categories.filter((c) => c.status !== 0), [categories]); + const enabledTags = useMemo(() => tags.filter((t) => t.status !== 0), [tags]); + const parentTags = useMemo( + () => enabledTags.filter((t) => !t.parent_id), + [enabledTags], + ); const loadData = useCallback(async () => { if (!familyId) return; setPageLoading(true); try { - await Promise.all([fetchDishes(familyId), fetchCategories(familyId)]); + await Promise.all([fetchDishes(familyId), fetchCategories(familyId), fetchTags(familyId)]); if (isEdit) { const st = useMealStore.getState(); const item = st.dishes.find((d) => d.id === editId); @@ -124,6 +170,9 @@ export default function MealDishEdit() { })), ); setStepsIdsToDelete([]); + await fetchDishTags(familyId, editId); + const dt = useMealStore.getState().dishTags; + setSelectedTagIds(dt.filter((r) => r.dish_id === editId).map((r) => r.tag_id)); } } finally { setPageLoading(false); @@ -210,6 +259,11 @@ export default function MealDishEdit() { sourceTable: "b_meal_dish", sourceId: editId, }); + try { + await saveDishTagsForDish(editId); + } catch { + /* 标签保存失败不影响主流程 */ + } } else { const sc = steps.length + 1; const idsRes = await getUniqueIdApi(sc); @@ -256,6 +310,11 @@ export default function MealDishEdit() { sourceTable: "b_meal_dish", sourceId: dishId, }); + try { + await saveDishTagsForDish(dishId); + } catch { + /* 标签保存失败不影响主流程 */ + } } stopLoading({ type: "success", message: "已保存" }); @@ -274,6 +333,7 @@ export default function MealDishEdit() { uploadDishImage, saveDishBundle, saveDishChanges, + saveDishTagsForDish, notifyOp, router, ]); @@ -348,6 +408,59 @@ export default function MealDishEdit() { + + + {enabledTags.length > 0 ? ( + + {parentTags.map((p) => { + const children = enabledTags.filter((c) => c.parent_id === p.id); + const pSel = children.some((c) => selectedTagIds.includes(c.id)); + return ( + + + + {p.name} + + + {children.length > 0 && ( + + {children.map((c) => { + const cSel = selectedTagIds.includes(c.id); + return ( + + setSelectedTagIds((prev) => + cSel ? prev.filter((id) => id !== c.id) : [...prev, c.id], + ) + } + > + + {c.name} + + + ); + })} + + )} + + ); + })} + + ) : ( + 暂无标签 + )} + + diff --git a/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx b/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx index dcbe475c..23fd8211 100644 --- a/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx +++ b/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx @@ -6,6 +6,7 @@ import { ScrollView, Text, TextInput, + Dimensions, TouchableOpacity, View, } from "react-native"; @@ -111,6 +112,8 @@ export default function MealPlanDetail() { const removeDishFromPlan = useMealStore((s) => s.removeDishFromPlan); const confirmPlan = useMealStore((s) => s.confirmPlan); const completePlan = useMealStore((s) => s.completePlan); + const revertPlanToPlanning = useMealStore((s) => s.revertPlanToPlanning); + const revertPlanToConfirmed = useMealStore((s) => s.revertPlanToConfirmed); const planRatings = useMealStore((s) => s.planRatings); const fetchPlanRatings = useMealStore((s) => s.fetchPlanRatings); const ratePlan = useMealStore((s) => s.ratePlan); @@ -131,12 +134,16 @@ export default function MealPlanDetail() { const [showCookSheet, setShowCookSheet] = useState(false); const [completing, setCompleting] = useState(false); const [confirming, setConfirming] = useState(false); + const [reverting, setReverting] = useState(false); const [showRateSheet, setShowRateSheet] = useState(false); const [rateValue, setRateValue] = useState(5); const [rateContent, setRateContent] = useState(""); const [rating, setRating] = useState(false); const scrollRef = useRef(null); + // 加菜弹窗默认高度:固定接近顶部,约为屏幕高度减去外壳估算 + const dishSheetMaxHeight = Math.max(Dimensions.get("window").height - 200, 360); + // 加菜筛选 const [showDishFilterSheet, setShowDishFilterSheet] = useState(false); const [dishKeyword, setDishKeyword] = useState(""); @@ -192,10 +199,9 @@ export default function MealPlanDetail() { const dishFilterCount = useMemo(() => { let c = 0; - if (dishFilterCatId) c++; if (dishFilterTagIds.length > 0) c++; return c; - }, [dishFilterCatId, dishFilterTagIds]); + }, [dishFilterTagIds]); // 菜单状态 const isPlanning = plan?.status === MealPlanStatus.Planning; @@ -375,6 +381,44 @@ export default function MealPlanDetail() { } }, [familyId, planId, isConfirmed, userId, completePlan, refreshPlan]); + // 撤回确认(已确认 → 进行中) + const handleRevertConfirm = useCallback(async () => { + if (!familyId || !planId || !isConfirmed) return; + setReverting(true); + try { + const ok = await revertPlanToPlanning({ planId, familyId, userId }); + if (ok) { + await refreshPlan(); + toast.success("已撤回确认,回到进行中"); + } else { + toast.error("操作失败"); + } + } catch { + toast.error("操作失败"); + } finally { + setReverting(false); + } + }, [familyId, planId, isConfirmed, userId, revertPlanToPlanning, refreshPlan]); + + // 撤回完成(已完成 → 已确认) + const handleRevertComplete = useCallback(async () => { + if (!familyId || !planId || !isCompleted) return; + setReverting(true); + try { + const ok = await revertPlanToConfirmed({ planId, familyId, userId }); + if (ok) { + await refreshPlan(); + toast.success("已撤回完成,回到已确认"); + } else { + toast.error("操作失败"); + } + } catch { + toast.error("操作失败"); + } finally { + setReverting(false); + } + }, [familyId, planId, isCompleted, userId, revertPlanToConfirmed, refreshPlan]); + // 添加评论 const addComment = useCallback(async () => { if (!familyId || !comment.trim() || sending || isLocked) return; @@ -796,17 +840,37 @@ export default function MealPlanDetail() { )} {isConfirmed && ( + <> + + + 撤回 + + + {completing ? ( + + ) : ( + + )} + 完成菜单 + + + )} + {isCompleted && ( - {completing ? ( - - ) : ( - - )} - 完成菜单 + + 撤回 )} {isCompleted && !hasRated && ( @@ -973,6 +1037,7 @@ export default function MealPlanDetail() { {/* ======== 添加菜品 Sheet ======== */} { setShowAddDish(v); if (!v) { @@ -1008,9 +1073,40 @@ export default function MealPlanDetail() { + {/* 分类快捷选择(直接放在外部,不进筛选) */} + + setDishFilterCatId(null)} + > + + 全部 + + + {categories.map((c) => ( + setDishFilterCatId((p) => (p === c.id ? null : c.id))} + > + + {c.name} + + + ))} + + {/* 菜品列表 */} ) => { const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; @@ -1080,35 +1176,6 @@ export default function MealPlanDetail() { - {/* 分类 */} - - - - setDishFilterCatId(null)} - > - - 全部 - - - {categories.map((c) => ( - setDishFilterCatId((p) => (p === c.id ? null : c.id))} - > - - {c.name} - - - ))} - - {/* 标签 */} @@ -1195,7 +1262,13 @@ export default function MealPlanDetail() { - 评价菜单 + setShowRateSheet(false)} + onConfirm={handleRate} + confirmDisabled={rating} + /> {/* 星级 */} @@ -1217,23 +1290,6 @@ export default function MealPlanDetail() { onChangeText={setRateContent} placeholder="写点评价..." /> - - - - - diff --git a/code/app/app-rn/src/app/meal/plan/edit/index.tsx b/code/app/app-rn/src/app/meal/plan/edit/index.tsx index 0df5f3cd..f5ddab12 100644 --- a/code/app/app-rn/src/app/meal/plan/edit/index.tsx +++ b/code/app/app-rn/src/app/meal/plan/edit/index.tsx @@ -282,30 +282,34 @@ export default function MealPlanEdit() { - - 选择日期 + { + if (dayjs(dateVal).isValid()) { + setPlanDate(dateVal); + setShowDate(false); + } else { + toast.error("日期格式无效"); + } + }} + > + setShowDate(false)} + onConfirm={() => { + if (dayjs(dateVal).isValid()) { + setPlanDate(dateVal); + setShowDate(false); + } else { + toast.error("日期格式无效"); + } + }} + /> - - - - diff --git a/code/app/app-rn/src/app/todo/detail/[id]/index.tsx b/code/app/app-rn/src/app/todo/detail/[id]/index.tsx index d722ef2d..f62f3fcc 100644 --- a/code/app/app-rn/src/app/todo/detail/[id]/index.tsx +++ b/code/app/app-rn/src/app/todo/detail/[id]/index.tsx @@ -638,7 +638,15 @@ export default function TodoDetail() { - 完成任务 + { + setShowCompleteSheet(false); + setCompleteFiles([]); + }} + onConfirm={handleComplete} + />