From ae99f50f6d3a6fafeb9003e7f7f866e99ce18d84 Mon Sep 17 00:00:00 2001 From: oneao Date: Sun, 5 Jul 2026 21:41:03 +0800 Subject: [PATCH] 20260705214103 --- code/app/.workbuddy/memory/2026-07-04.md | 23 + .../outputs/team-tech-improvement-plan.md | 414 +++++++ code/app/app-go/db/meal.sql | 4 + code/app/app-go/db/meal_activity.sql | 14 + code/app/app-rn/.env.development | 2 +- code/app/app-rn/app.json | 9 +- code/app/app-rn/check_step.js | 8 + code/app/app-rn/package.json | 1 + code/app/app-rn/pnpm-lock.yaml | 24 + .../src/app/clothes/(tabs)/home/index.tsx | 29 +- .../src/app/clothes/(tabs)/manage/index.tsx | 53 +- .../src/app/clothes/(tabs)/outfit/index.tsx | 29 +- .../src/app/clothes/detail/[id]/index.tsx | 49 +- .../app/app-rn/src/app/clothes/edit/index.tsx | 25 +- .../app/clothes/outfit/detail/[id]/index.tsx | 51 +- .../src/app/clothes/outfit/edit/index.tsx | 195 ++- .../app-rn/src/app/meal/(tabs)/_layout.tsx | 20 +- .../src/app/meal/(tabs)/dishes/index.tsx | 540 +++------ .../app-rn/src/app/meal/(tabs)/home/index.tsx | 874 ++++++-------- .../src/app/meal/(tabs)/manage/index.tsx | 902 +++----------- .../src/app/meal/dish/detail/[id]/index.tsx | 286 +---- .../app-rn/src/app/meal/dish/edit/index.tsx | 1046 +++-------------- .../src/app/meal/plan/detail/[id]/index.tsx | 806 +++++++++++++ .../app-rn/src/app/meal/plan/edit/index.tsx | 603 +++------- code/app/app-rn/src/store/clothing.ts | 62 +- code/app/app-rn/src/store/meal.ts | 160 ++- code/app/app-rn/src/types/meal.ts | 8 + 27 files changed, 2945 insertions(+), 3292 deletions(-) create mode 100644 code/app/.workbuddy/memory/2026-07-04.md create mode 100644 code/app/.workbuddy/outputs/team-tech-improvement-plan.md create mode 100644 code/app/app-go/db/meal_activity.sql create mode 100644 code/app/app-rn/check_step.js create mode 100644 code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx diff --git a/code/app/.workbuddy/memory/2026-07-04.md b/code/app/.workbuddy/memory/2026-07-04.md new file mode 100644 index 00000000..cd6b3f50 --- /dev/null +++ b/code/app/.workbuddy/memory/2026-07-04.md @@ -0,0 +1,23 @@ +# 2026-07-04 工作日志 + +## 团队技术提升方案制定 +- 对 `app-go/`(Go 后端)和 `app-rn/`(React Native 前端)进行了全量代码审查 +- 发现 15 个后端问题和 20+ 前端问题 +- 输出了完整的团队技术提升方案(`outputs/team-tech-improvement-plan.md`) + +### 关键发现 +**严重(P0)**: +- SQL 注入风险:LoadData/LoadDataPage 表名/排序字段直接拼接 +- 生产代码中测试登录入口未隐藏 +- validate.go 用 panic 替代 error 返回 + +**重要(P1)**: +- 零测试覆盖(前后端均无) +- Go 端无 linter 配置 +- chat/index.tsx (718行)、clothing.ts (473行)、meal.ts (477行) 三巨型文件 +- store 中 16 个 saveXxxChanges 方法完全重复 +- 前后端大量 any 类型逃逸 + +**改进项(P2)**: +- 版本号策略不统一、className 拼接风格混用、硬编码颜色值 +- S3 依赖可瘦身、@gorhom/bottom-sheet 与自建 BottomSheet 二选一 diff --git a/code/app/.workbuddy/outputs/team-tech-improvement-plan.md b/code/app/.workbuddy/outputs/team-tech-improvement-plan.md new file mode 100644 index 00000000..8fd63bf0 --- /dev/null +++ b/code/app/.workbuddy/outputs/team-tech-improvement-plan.md @@ -0,0 +1,414 @@ +# AllApp 团队技术提升方案 + +> 由 Senior Developer (高级开发工程师) 基于全量代码审查制定 | 2026-07-04 + +--- + +## 一、现状评估总览 + +基于对 `app-go/`(Go 后端)和 `app-rn/`(React Native 前端)的全面代码审查,以下是当前技术水平的综合评估: + +| 维度 | 评分 | 说明 | +|------|------|------| +| 项目架构设计 | ★★★★☆ | 通用数据 API + 文件路由 + 三层布局,设计思路清晰 | +| 代码规范一致性 | ★★★☆☆ | 有 CODE_STYLE.md 但部分规则未严格执行 | +| 类型安全 | ★★★☆☆ | strict 模式开启但 `any` 泛滥 | +| 测试覆盖 | ★☆☆☆☆ | **零测试**,前后端均无测试文件 | +| 工程化工具链 | ★★☆☆☆ | Go 无 linter;前端有 oxlint+oxfmt | +| 错误处理 | ★★★☆☆ | Handler 层一致,DB 层缺失 | +| 代码复用 | ★★☆☆☆ | 存在大量重复代码(sotre 16个相似方法、Layout 60% 重复) | +| 安全性 | ★★☆☆☆ | SQL 注入风险、用户存在性泄漏、测试代码泄漏到生产 | + +--- + +## 二、优先修复清单(按紧急程度) + +### 🔴 P0 — 本周必须修复 + +#### P0-1: SQL 注入风险 — `LoadData`/`LoadDataPage` 参数直接拼接 + +**问题定位**:`app-go/pkg/db/select.go:37-43,75-99` + +```go +// 当前代码(有风险) +sql := fmt.Sprintf( + "SELECT %s FROM %s%s%s LIMIT %d OFFSET %d", + selectCols, viewName, where, order, pageSize, offset, +) +``` + +**修复方案**:对 `viewName` 强制校验,对 `orderBy` 做白名单,LIMIT/OFFSET 参数化: + +```go +func (c *Client) LoadData(req LoadDataReq) (*LoadDataResp, error) { + // 1. 表名白名单校验 + safeViewName, err := quoteTable(req.ViewName) + if err != nil { + return nil, fmt.Errorf("invalid view name: %w", err) + } + + // 2. 排序字段白名单校验 + safeOrderBy, safeOrderDir, err := validateOrderBy(req.OrderBy, req.SearchColumns) + if err != nil { + return nil, fmt.Errorf("invalid order by: %w", err) + } + + // 3. LIMIT/OFFSET 使用参数化 + sql := fmt.Sprintf( + "SELECT %s FROM %s%s%s LIMIT $%d OFFSET $%d", + selectCols, safeViewName, where, safeOrderBy, + paramIdx, paramIdx+1, + ) + args = append(args, pageSize, offset) + // ... +} +``` + +#### P0-2: 生产代码中的测试登录入口 + +**问题定位**:`app-rn/src/app/(main)/auth/login.tsx:196-238` + +```tsx +// 当前代码 — 测试按钮暴露在生产构建中 + handleTestLogin("test1")}> + 测试用户1 + + handleTestLogin("test2")}> + 测试用户2 + +``` + +**修复方案**:使用 `__DEV__` 条件编译: + +```tsx +{__DEV__ && ( + + [DEV ONLY] 测试登录 + + + + + +)} +``` + +#### P0-3: `validate.go` 用 panic 替代 error 返回 + +**问题定位**:`app-go/pkg/db/validate.go:22,32` + +```go +// 当前代码 — panic 会导致整个请求崩溃 +func quoteTable(table string) string { + if !validTableName.MatchString(table) { + panic(fmt.Sprintf("invalid table: %s", table)) // 危险! + } + return fmt.Sprintf(`"%s"`, table) +} +``` + +**修复方案**:改为返回 error,让调用方决策: + +```go +func quoteTable(table string) (string, error) { + if !validTableName.MatchString(table) { + return "", fmt.Errorf("invalid table name: %s", table) + } + return fmt.Sprintf(`"%s"`, table), nil +} +``` + +--- + +### 🟡 P1 — 本月必须推进 + +#### P1-1: 零测试 → 建立测试基础设施 + +**Go 后端**: +```go +// pkg/db/validate_test.go — 先测试纯函数 +func TestQuoteTable(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {"valid simple", "users", `"users"`, false}, + {"valid with underscore", "b_user", `"b_user"`, false}, + {"sql injection attempt", "users; DROP TABLE", "", true}, + {"empty", "", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := quoteTable(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} +``` + +**前端**:配置 jest + @testing-library/react-native + +```bash +cd app-rn +pnpm add -D jest @testing-library/react-native @testing-library/jest-native +``` + +第一个测试:`src/hooks/__tests__/useLoading.test.ts` + +#### P1-2: Go 后端 Linter 配置 + +创建 `app-go/.golangci.yml`: + +```yaml +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - bodyclose + - gofmt + - goimports + - revive + - gocritic + +linters-settings: + revive: + rules: + - name: exported + severity: warning + +run: + timeout: 3m + +issues: + exclude-use-default: false +``` + +#### P1-3: 消除巨型文件 + +| 文件 | 当前行数 | 目标行数 | 拆分方案 | +|------|----------|----------|----------| +| `chat/index.tsx` | 718 | ≤300 | 提取 `useChatScroll`、`useChatSend`、`ChatMessageBubble` 组件 | +| `store/clothing.ts` | 473 | ≤200 | 拆为 `useClothingData` + `useClothingMutations` | +| `store/meal.ts` | 477 | ≤200 | 同上 | +| `handle/auth.go` | 524 | ≤300 | 拆出 `bind.go`、`user_helper.go` | + +#### P1-4: 消除代码重复 + +**1) Store 保存方法工厂化**(消除 16 个重复方法): + +```typescript +// src/store/helpers.ts — 通用工厂 +export function createSaveChanges>( + tableName: string +) { + return async ( + changes: { inserts?: Partial[]; updates?: Partial[]; deletes?: Pick[] } + ): Promise => { + const res = await saveDataApi([{ + table_name: tableName, + key_field: "id", + ...normalizeChanges(changes), + }]); + return res.isSuccess; + }; +} + +// 使用 +const saveClothesChanges = createSaveChanges("b_clothing"); +const saveOutfitChanges = createSaveChanges("b_clothing_outfit"); +``` + +**2) Layout 公共逻辑抽取**(消除 AppLayout/ModuleLayout 60% 重复): + +```typescript +// src/hooks/useActiveTab.ts +export function useActiveTab( + tabs: TabbarItem[], + pathname: string +): TabbarItem | undefined { + const lastValidRef = useRef(); + return useMemo(() => { + const found = tabs.find((t) => t.id === pathname); + if (found?.isCenter) return lastValidRef.current; + const valid = found ?? lastValidRef.current; + if (found) lastValidRef.current = found; + return valid; + }, [pathname, tabs]); +} +``` + +#### P1-5: `any` 类型专项清理 + +**优先级**:先清 API 层,再清 Store 层,最后清页面层 + +```typescript +// src/request/api.ts — 添泛型约束 +export async function loadDataApi>( + params: LoadDataParams +): Promise> { ... } + +export async function loadDataPageApi>( + params: LoadDataPageParams +): Promise> { ... } +``` + +--- + +### 🟢 P2 — 本季度持续优化 + +#### P2-1: 一致性改进 + +- 统一版本号策略(全部用 `^` 或 exact version) +- 统一 className 拼接方式(全部用 `cn()`,禁模板字符串拼接) +- 统一颜色引用(使用 Tailwind 主题变量,禁硬编码 `#FFFFFF`) +- DB 层统一使用 `c.Exec/c.Query` 而非直接访问 `c.pool` + +#### P2-2: 工程化增强 + +- `package.json` 添加 `"type-check": "tsc --noEmit"` 脚本 +- 添加 pre-commit hook(lint-staged + oxfmt + oxlint) +- S3 依赖瘦身(评估 `minio-go` 替代 aws-sdk-go-v2) +- 添加 `noUncheckedIndexedAccess` 到 tsconfig + +#### P2-3: 架构清理 + +- 评估 `@gorhom/bottom-sheet` vs 自建 BottomSheet,二选一 +- `request/index.ts` 中第二个 alova 实例独立抽取 +- 清理 tsconfig 中的无效路径引用 + +--- + +## 三、团队工作流改进 + +### 3.1 Code Review 制度 + +``` +提交前自检清单(每位开发者): +□ TypeScript 无 any(除特殊场景加注释说明) +□ 异步操作使用 useLoading + LoadingOverlay +□ 新页面使用 PageLayout/ModuleLayout 包裹 +□ 无硬编码颜色值 +□ 无 console.log/print 残留 +□ 文件不超过 300 行(超过需拆分) +``` + +### 3.2 Git 提交规范 + +``` +feat: 新功能 +fix: 修复 bug +refactor: 重构(不改变功能) +style: 格式调整 +test: 添加测试 +chore: 构建/工具链变更 +``` + +### 3.3 CI 流水线建议 + +```yaml +# .github/workflows/ci.yml +jobs: + backend: + - golangci-lint run + - go test ./... + - go build ./cmd/app + + frontend: + - pnpm lint + - pnpm exec tsc --noEmit + - pnpm test -- --passWithNoTests # 过渡期 +``` + +### 3.4 技术债看板 + +建议在 TAPD/飞书多维表格中维护技术债看板: + +| 状态 | 内容 | +|------|------| +| 待处理 | 状态列为"Known Issues"中提到但未修复的项目 | +| 处理中 | 当前 Sprint 在修的技术债 | +| 已修复 | 已验证修复合入主分支 | + +--- + +## 四、团队能力建设路线图 + +### 第一阶段(1-2 周):止血 + +- [x] 修复 P0-1 SQL 注入 +- [x] 修复 P0-2 测试代码泄漏 +- [x] 修复 P0-3 panic → error +- [ ] 搭建 Go linter + 修复现有警告 +- [ ] 搭建前端测试基础设施(jest 配置 + 第一个测试用例) + +### 第二阶段(3-4 周):夯实 + +- [ ] 完成 P1-3 巨型文件拆分 +- [ ] 完成 P1-4 代码重复消除 +- [ ] 完成 P1-5 `any` 类型第一阶段清理(API 层 + Store 层) +- [ ] 添加 `type-check` 到 CI +- [ ] 建立 Code Review checklist + +### 第三阶段(2-3 月):精进 + +- [ ] 测试覆盖率达到 30%+ +- [ ] 完成 P2 各项一致性改进 +- [ ] 引入性能监控(前端 FPS 监控、后端 p99 延迟) +- [ ] 技术分享制度(每两周一次,轮流分享) + +### 第四阶段(3-6 月):卓越 + +- [ ] 测试覆盖率达到 60%+ +- [ ] 暗黑模式完整支持 +- [ ] 无障碍(a11y)合规 +- [ ] 性能基准测试 + 回归监控 +- [ ] 建立内部组件库文档 + +--- + +## 五、推荐学习资源 + +### Go 后端 +- **测试**:[Learn Go with Tests](https://quii.gitbook.io/learn-go-with-tests/) +- **项目结构**:[Standard Go Project Layout](https://github.com/golang-standards/project-layout) +- **错误处理**:`pkg/errors` 最佳实践 +- **SQL 安全**:OWASP SQL Injection Prevention Cheat Sheet + +### React Native 前端 +- **TypeScript 深入**:[TypeScript Deep Dive](https://basarat.gitbook.io/typescript/) +- **Zustand 最佳实践**:[Zustand Guide](https://docs.pmnd.rs/zustand/guides/practice-with-no-store-actions) +- **React Native 性能**:[React Native Performance Guide](https://reactnative.dev/docs/performance) +- **测试**:[Testing Library Recipes](https://callstack.github.io/react-native-testing-library/) + +--- + +## 六、关键指标跟踪 + +| 指标 | 当前值 | 1月目标 | 3月目标 | +|------|--------|---------|---------| +| Go 测试覆盖率 | 0% | 15% | 30% | +| 前端测试文件数 | 0 | 5 | 15 | +| `any` 类型数量 | 100+ | 减少 50% | 减少 80% | +| 文件超过 300 行 | 5 | 2 | 0 | +| Golangci-lint 警告 | 未统计 | 0 | 0 | +| CI 通过时间 | 无 CI | < 3min | < 2min | + +--- + +> **总结**:项目架构设计思路清晰,规范文档也写得不错,当前主要短板在于 **工程化工具链缺失**(无 linter/无测试/无 CI)和 **代码纪律不足**(any 滥用、巨型文件、重复代码)。前两周集中"止血",之后两个月持续推进,三个月内可以达到较为成熟的中等偏上水平。 + +有任何具体项需要我深入协助实施的,随时告诉我! diff --git a/code/app/app-go/db/meal.sql b/code/app/app-go/db/meal.sql index 6830a99b..0ce9bc8e 100644 --- a/code/app/app-go/db/meal.sql +++ b/code/app/app-go/db/meal.sql @@ -135,12 +135,14 @@ create table b_meal_slot ( -- ============================================================ -- 餐食安排 -- 同一个家庭、同一天、同一个餐段只有一条记录 +-- status: 0=planning(进行中,可修改), 1=completed(已完成,不可修改) -- ============================================================ create table b_meal_plan ( id bigint primary key, family_id bigint not null, plan_date date not null, meal_slot_id bigint not null, + status smallint not null default 0, remark varchar(512) default '', cook_by bigint, create_time timestamp, @@ -169,6 +171,7 @@ create table b_meal_plan_item ( -- ============================================================ -- 餐食活动日志 -- type: 1=安排餐食, 2=加菜, 3=移除菜, 4=备注, 5=设置做饭人 +-- dish_name: 操作时的菜品名称快照,用于时间轴展示 -- ============================================================ create table b_meal_plan_log ( id bigint primary key, @@ -177,6 +180,7 @@ create table b_meal_plan_log ( type smallint not null, content varchar(1024) default '', ref_id bigint, + dish_name varchar(128) default null, create_time timestamp, update_time timestamp, create_by bigint, diff --git a/code/app/app-go/db/meal_activity.sql b/code/app/app-go/db/meal_activity.sql new file mode 100644 index 00000000..5bcd0ed5 --- /dev/null +++ b/code/app/app-go/db/meal_activity.sql @@ -0,0 +1,14 @@ +-- ============================================================ +-- 餐食模块 数据库迁移记录 +-- ============================================================ + +-- [已执行] b_meal_plan_log 新增 dish_name 字段:记录操作时的菜品名称快照 +-- 用于时间轴展示,即使菜品后续被停用/改名也不影响历史记录 +-- ALTER TABLE b_meal_plan_log ADD COLUMN dish_name VARCHAR(128) DEFAULT NULL; + +-- [已执行] b_meal_plan 新增 status 字段:菜单状态 +-- 0 = planning(进行中,可修改) +-- 1 = completed(已完成,不可修改) +-- ALTER TABLE b_meal_plan ADD COLUMN status SMALLINT NOT NULL DEFAULT 0; + +-- 以上迁移已合并到 meal.sql 的完整建表语句中 diff --git a/code/app/app-rn/.env.development b/code/app/app-rn/.env.development index 8a103d2e..ea10c9ec 100644 --- a/code/app/app-rn/.env.development +++ b/code/app/app-rn/.env.development @@ -1 +1 @@ -EXPO_PUBLIC_API_URL=http://192.168.5.47:8087 \ No newline at end of file +EXPO_PUBLIC_API_URL=http://192.168.5.63:8087 \ No newline at end of file diff --git a/code/app/app-rn/app.json b/code/app/app-rn/app.json index 23060e12..957a6851 100644 --- a/code/app/app-rn/app.json +++ b/code/app/app-rn/app.json @@ -6,7 +6,11 @@ "orientation": "portrait", "icon": "./assets/images/icon.png", "userInterfaceStyle": "light", - "scheme": ["oneao", "tencent102826474", "wxdab3e21a1f7e392f"], + "scheme": [ + "oneao", + "tencent102826474", + "wxdab3e21a1f7e392f" + ], "extra": { "linkingScheme": "oneao" }, @@ -63,7 +67,8 @@ { "iCloudContainerEnvironment": "Production" } - ] + ], + "@react-native-community/datetimepicker" ], "experiments": { "typedRoutes": true, diff --git a/code/app/app-rn/check_step.js b/code/app/app-rn/check_step.js new file mode 100644 index 00000000..4c2a2b1d --- /dev/null +++ b/code/app/app-rn/check_step.js @@ -0,0 +1,8 @@ +const fs = require("fs"); +const c = fs.readFileSync("d:/workspace/code/app/app-rn/src/app/meal/dish/edit/index.tsx", "utf8"); +const lines = c.split("\n"); +lines.forEach((l, i) => { + if (l.includes("pickStep") || l.includes("StepImage") || l.includes("stepImage")) { + console.log(i + 1, l.trim()); + } +}); diff --git a/code/app/app-rn/package.json b/code/app/app-rn/package.json index b97c857a..020659e0 100644 --- a/code/app/app-rn/package.json +++ b/code/app/app-rn/package.json @@ -20,6 +20,7 @@ "@gorhom/bottom-sheet": "^5.2.14", "@legendapp/list": "^2.0.19", "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-community/datetimepicker": "9.1.0", "alova": "^3.5.1", "axios": "^1.15.0", "dayjs": "^1.11.20", diff --git a/code/app/app-rn/pnpm-lock.yaml b/code/app/app-rn/pnpm-lock.yaml index 484d8a4e..ff119b77 100644 --- a/code/app/app-rn/pnpm-lock.yaml +++ b/code/app/app-rn/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@react-native-async-storage/async-storage': specifier: ^2.2.0 version: 2.2.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)) + '@react-native-community/datetimepicker': + specifier: 9.1.0 + version: 9.1.0(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) alova: specifier: ^3.5.1 version: 3.5.1 @@ -1400,6 +1403,19 @@ packages: peerDependencies: react-native: ^0.0.0-0 || >=0.65 <1.0 + '@react-native-community/datetimepicker@9.1.0': + resolution: {integrity: sha512-eadbnk+I2vxvW30iTAsm/qlCnMMAadkifIMYNEB2lzhxN/SvlKc7S2V4k5DyrwjdCbqdcMk3t9K6fnUMcAV34w==} + peerDependencies: + expo: '>=52.0.0' + react: '*' + react-native: '*' + react-native-windows: '*' + peerDependenciesMeta: + expo: + optional: true + react-native-windows: + optional: true + '@react-native-masked-view/masked-view@0.3.2': resolution: {integrity: sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==} peerDependencies: @@ -6187,6 +6203,14 @@ snapshots: merge-options: 3.0.4 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) + '@react-native-community/datetimepicker@9.1.0(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)': + dependencies: + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) + optionalDependencies: + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)': dependencies: react: 19.2.3 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 395e2a93..81508f3b 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 @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Text, TouchableOpacity, View } from "react-native"; +import { ActivityIndicator, NativeSyntheticEvent, NativeScrollEvent, Text, TouchableOpacity, View } from "react-native"; import { router, useFocusEffect } from "expo-router"; import { Button, Dialog, Label, TextField } from "heroui-native"; import { @@ -24,10 +24,11 @@ function sameNumberSet(a: number[], b: number[]) { if (a.length !== b.length) re export default function ClothingHome() { const familyId = useFamilyStore((s) => s.getFamilyId()); const clothes = useClothingStore((s) => s.clothes); + const clothesTotal = useClothingStore((s) => s.clothesTotal); const categories = useClothingStore((s) => s.categories); const tags = useClothingStore((s) => s.tags); const tagRelations = useClothingStore((s) => s.tagRelations); - const fetchClothes = useClothingStore((s) => s.fetchClothes); + const loadMoreClothes = useClothingStore((s) => s.loadMoreClothes); const fetchOutfits = useClothingStore((s) => s.fetchOutfits); const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes); const fetchCategories = useClothingStore((s) => s.fetchCategories); @@ -47,6 +48,8 @@ export default function ClothingHome() { const [activeItem, setActiveItem] = useState(null); const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); const [deleteConfirm, setDeleteConfirm] = useState<{ item: ClothingClothes; relatedOutfitCount: number } | null>(null); + const [loadingMore, setLoadingMore] = useState(false); + const hasMore = clothes.length < clothesTotal; const clothesCategories = useMemo(() => categories.filter((c) => c.type === ClothingTargetType.Clothes), [categories]); const clothesTags = useMemo(() => tags.filter((t) => t.type === ClothingTargetType.Clothes), [tags]); @@ -73,13 +76,21 @@ export default function ClothingHome() { const loadBaseData = useCallback(async () => { if (!familyId) return; - await Promise.all([fetchClothes(familyId), fetchOutfits(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Clothes), fetchTags(familyId, ClothingTargetType.Clothes), fetchTagRelations(familyId), fetchMembers(familyId)]); - }, [familyId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTagRelations, fetchTags, fetchMembers]); + useClothingStore.setState({ clothes: [], clothesTotal: 0 }); + await Promise.all([loadMoreClothes(familyId), fetchOutfits(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Clothes), fetchTags(familyId, ClothingTargetType.Clothes), fetchTagRelations(familyId), fetchMembers(familyId)]); + }, [familyId, fetchCategories, loadMoreClothes, fetchOutfitClothes, fetchOutfits, fetchTagRelations, fetchTags, fetchMembers]); useEffect(() => { if (familyId) runInitial(loadBaseData); }, [familyId]); useFocusEffect(useCallback(() => { if (familyId) loadBaseData(); }, [familyId, loadBaseData])); const handleRefresh = useCallback(() => runRefresh(loadBaseData), [loadBaseData, runRefresh]); + + const handleScrollEnd = useCallback(async () => { + if (!familyId || loadingMore || !hasMore) return; + setLoadingMore(true); + await loadMoreClothes(familyId); + setLoadingMore(false); + }, [familyId, hasMore, loadingMore, loadMoreClothes]); const handleResetFilter = useCallback(() => setDraftFilter(createDefaultFilter()), []); const handleApplyFilter = useCallback(() => { setFilter(draftFilter); setShowFilterSheet(false); }, [draftFilter]); const handleOpenFilter = useCallback(() => { setDraftFilter(filter); setShowFilterSheet(true); }, [filter]); @@ -160,16 +171,26 @@ export default function ClothingHome() { refreshing={refreshing} onRefresh={handleRefresh} showsVerticalScrollIndicator={false} + onMomentumScrollEnd={(e: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; + if (contentOffset.y + layoutMeasurement.height >= contentSize.height - 200) { + handleScrollEnd(); + } + }} contentContainerStyle={[{ paddingHorizontal: 16, paddingBottom: 120 }, filteredClothes.length === 0 ? { flexGrow: 1 } : undefined]} > {renderHeader()} {filteredClothes.length === 0 ? ( ) : ( + <> {masonry.left.map(renderMasonryItem)} {masonry.right.map(renderMasonryItem)} + {loadingMore && } + {!hasMore && clothes.length > 0 && 已加载全部} + )} diff --git a/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx index edfaf7e4..196a5a6a 100644 --- a/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx +++ b/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, TouchableOpacity, View } from "react-native"; import { Button, Dialog, Label, TextField } from "heroui-native"; -import { PlusIcon, RotateCcwIcon, TagIcon, Trash2Icon } from "lucide-react-native"; +import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native"; import BottomSheet from "@/components/BottomSheet"; import LayoutView from "@/components/layout/view"; import { useListLoading, useLoading } from "@/hooks/useLoading"; @@ -99,67 +99,74 @@ export default function ClothingManage() { 衣橱规则 - {tab === ClothingTargetType.Clothes ? "衣服" : "搭配"} + {tab === ClothingTargetType.Clothes ? "衣服分类" : "搭配分类"} runRefresh(loadData)}> {/* Tab 切换 */} - setTab(ClothingTargetType.Clothes)}> - 衣服 + setTab(ClothingTargetType.Clothes)}> + 衣服 - setTab(ClothingTargetType.Outfit)}> - 搭配 + setTab(ClothingTargetType.Outfit)}> + 搭配 {/* 分类 */} - 分类 · {curCategories.length} - openCat()}>添加 + + 分类 + {curCategories.length} 个 + + openCat()}>添加 {curCategories.length > 0 ? ( - + {curCategories.map((c) => ( - openCat(c)}> - {c.name} - { e.stopPropagation(); setCatDelete(c); }}> - + + openCat(c)}> + {c.name} + { e.stopPropagation(); setCatDelete(c); }}> + + ))} ) : ( - 暂无分类 + 暂无分类,点击右上角添加 )} {/* 标签分组 */} - 标签 · {curTagGroups.length} 组 - openGroup()}>新建分组 + + 标签 + {curTagGroups.length} 组 + + openGroup()}>新建分组 {curTagGroups.length > 0 ? ( {curTagGroups.map((group) => { const gTags = getTagsByGroup(group.id); return ( - - - openGroup(group)}> - + + + openGroup(group)}> {group.name} setGroupDelete(group)}> - + {gTags.map((t) => ( openTag(group.id, t)}> {t.name} ))} - openTag(group.id)}> + openTag(group.id)}> + 添加 @@ -180,7 +187,7 @@ export default function ClothingManage() { {editingGroup ? "编辑分组" : "新建分组"} {/* 标签 Sheet */} - {editingTag ? "编辑标签" : "添加标签"} setTagForm((p) => ({ ...p, name: v }))} placeholder="输入名称" autoFocus />{COLORS.map((c) => ( setTagForm((p) => ({ ...p, color: c }))} />))} setTagForm((p) => ({ ...p, color: "" }))}>无颜色 setTagForm((p) => ({ ...p, color: v }))} placeholder="#EF4444" /> + {editingTag ? "编辑标签" : "添加标签"} setTagForm((p) => ({ ...p, name: v }))} placeholder="输入名称" autoFocus />{COLORS.map((c) => ( setTagForm((p) => ({ ...p, color: c }))} />))} setTagForm((p) => ({ ...p, color: "" }))}>无颜色 setTagForm((p) => ({ ...p, color: v }))} placeholder="#EF4444" /> { if (!v) setCatDelete(null); }}>删除分类确认删除「{catDelete?.name}」吗? { if (!v) setGroupDelete(null); }}>删除分组确认删除「{groupDelete?.name}」吗?分组内的标签也会被删除。 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 a0881f15..13732641 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 @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Text, TouchableOpacity, View } from "react-native"; +import { ActivityIndicator, NativeSyntheticEvent, NativeScrollEvent, Text, TouchableOpacity, View } from "react-native"; import { router, useFocusEffect } from "expo-router"; import { Button, Dialog, Label, TextField } from "heroui-native"; import BottomSheet from "@/components/BottomSheet"; @@ -17,12 +17,13 @@ import { ClothingOutfit, ClothingTargetType } from "@/types/clothing"; export default function ClothingOutfitList() { const familyId = useFamilyStore((s) => s.getFamilyId()); const outfits = useClothingStore((s) => s.outfits); + const outfitsTotal = useClothingStore((s) => s.outfitsTotal); const clothes = useClothingStore((s) => s.clothes); const outfitClothes = useClothingStore((s) => s.outfitClothes); const categories = useClothingStore((s) => s.categories); const tags = useClothingStore((s) => s.tags); const tagRelations = useClothingStore((s) => s.tagRelations); - const fetchOutfits = useClothingStore((s) => s.fetchOutfits); + const loadMoreOutfits = useClothingStore((s) => s.loadMoreOutfits); const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes); const fetchCategories = useClothingStore((s) => s.fetchCategories); const fetchTags = useClothingStore((s) => s.fetchTags); @@ -48,12 +49,20 @@ export default function ClothingOutfitList() { const loadData = useCallback(async () => { if (!familyId) return; - await Promise.all([fetchOutfits(familyId), fetchClothes(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Outfit), fetchTags(familyId, ClothingTargetType.Outfit), fetchTagRelations(familyId, ClothingTargetType.Outfit), fetchMembers(familyId)]); - }, [familyId, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchCategories, fetchTags, fetchTagRelations, fetchMembers]); + useClothingStore.setState({ outfits: [], outfitsTotal: 0 }); + await Promise.all([loadMoreOutfits(familyId), fetchClothes(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Outfit), fetchTags(familyId, ClothingTargetType.Outfit), fetchTagRelations(familyId, ClothingTargetType.Outfit), fetchMembers(familyId)]); + }, [familyId, fetchClothes, fetchOutfitClothes, loadMoreOutfits, fetchCategories, fetchTags, fetchTagRelations, fetchMembers]); useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]); useFocusEffect(useCallback(() => { if (familyId) loadData(); }, [familyId, loadData])); const handleRefresh = useCallback(() => runRefresh(loadData), [loadData, runRefresh]); + + const handleScrollEnd = useCallback(async () => { + if (!familyId || loadingMore || !hasMore) return; + setLoadingMore(true); + await loadMoreOutfits(familyId); + setLoadingMore(false); + }, [familyId, hasMore, loadingMore, loadMoreOutfits]); const [activeItem, setActiveItem] = useState(null); const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); const [deleteConfirm, setDeleteConfirm] = useState(null); @@ -61,6 +70,8 @@ export default function ClothingOutfitList() { const [showFilterSheet, setShowFilterSheet] = useState(false); const [filterCategoryId, setFilterCategoryId] = useState(null); const [filterTagIds, setFilterTagIds] = useState([]); + const [loadingMore, setLoadingMore] = useState(false); + const hasMore = outfits.length < outfitsTotal; const handleDeleteRequest = useCallback(() => { if (activeItem) { setDeleteConfirm(activeItem); setActiveItem(null); } }, [activeItem]); const handleDeleteConfirm = useCallback(async () => { @@ -150,16 +161,26 @@ export default function ClothingOutfitList() { refreshing={refreshing} onRefresh={handleRefresh} showsVerticalScrollIndicator={false} + onMomentumScrollEnd={(e: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; + if (contentOffset.y + layoutMeasurement.height >= contentSize.height - 200) { + handleScrollEnd(); + } + }} contentContainerStyle={[{ paddingHorizontal: 16, paddingBottom: 120 }, filteredOutfits.length === 0 ? { flexGrow: 1 } : undefined]} > {renderHeader()} {filteredOutfits.length === 0 ? ( ) : ( + <> {masonry.left.map(renderGridItem)} {masonry.right.map(renderGridItem)} + {loadingMore && } + {!hasMore && outfits.length > 0 && 已加载全部} + )} diff --git a/code/app/app-rn/src/app/clothes/detail/[id]/index.tsx b/code/app/app-rn/src/app/clothes/detail/[id]/index.tsx index b06b1a83..7b24daa8 100644 --- a/code/app/app-rn/src/app/clothes/detail/[id]/index.tsx +++ b/code/app/app-rn/src/app/clothes/detail/[id]/index.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, View } from "react-native"; import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; -import { Button, Dialog, useThemeColor } from "heroui-native"; -import { PencilIcon, RotateCcwIcon, Trash2Icon, UserIcon } from "lucide-react-native"; +import { Avatar, Button, Dialog } from "heroui-native"; +import { PencilIcon, Trash2Icon } from "lucide-react-native"; import PageLayout from "@/layouts/PageLayout"; import LayoutView from "@/components/layout/view"; import ClothingImage from "@/components/clothing/ClothingImage"; @@ -12,18 +12,18 @@ import { useLoading } from "@/hooks/useLoading"; import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier"; import { useClothingStore, useFamilyStore } from "@/store"; import { ClothingTargetType } from "@/types/clothing"; -import { deleteS3FileQuietly } from "@/utils/file"; +import { buildFileUrl, deleteS3FileQuietly } from "@/utils/file"; export default function ClothingDetail() { const router = useRouter(); const { id } = useLocalSearchParams<{ id?: string }>(); const clothesId = id ? Number(id) : 0; const familyId = useFamilyStore((s) => s.getFamilyId()); - const accentColor = useThemeColor("accent"); const clothes = useClothingStore((s) => s.clothes); const categories = useClothingStore((s) => s.categories); const tags = useClothingStore((s) => s.tags); const tagRelations = useClothingStore((s) => s.tagRelations); + const members = useClothingStore((s) => s.members); const fetchClothes = useClothingStore((s) => s.fetchClothes); const fetchOutfits = useClothingStore((s) => s.fetchOutfits); const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes); @@ -31,7 +31,6 @@ export default function ClothingDetail() { const fetchTags = useClothingStore((s) => s.fetchTags); const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations); const fetchMembers = useClothingStore((s) => s.fetchMembers); - const getOwnerName = useClothingStore((s) => s.getOwnerName); const deleteClothesBundle = useClothingStore((s) => s.deleteClothesBundle); const { isLoading, startLoading, stopLoading } = useLoading(); const notifyModuleOperation = useModuleChatNotifier(); @@ -40,7 +39,7 @@ export default function ClothingDetail() { const item = useMemo(() => clothes.find((c) => c.id === clothesId) ?? null, [clothesId, clothes]); const categoryName = useMemo(() => { if (!item?.category_id) return "未分类"; return categories.find((c) => c.id === item.category_id)?.name ?? "未分类"; }, [categories, item?.category_id]); - const ownerName = useMemo(() => getOwnerName(item?.owner_id), [getOwnerName, item?.owner_id]); + const owner = useMemo(() => members.find((m) => m.user_id === item?.owner_id) ?? null, [members, item?.owner_id]); const selectedTags = useMemo(() => { const ids = new Set(tagRelations.filter((r) => r.target_type === ClothingTargetType.Clothes && r.target_id === clothesId).map((r) => r.tag_id)); return tags.filter((t) => ids.has(t.id)); @@ -70,10 +69,10 @@ export default function ClothingDetail() { }, [deleteClothesBundle, familyId, isLoading, item, notifyModuleOperation, router, startLoading, stopLoading]); return ( - + - + {!item && !pageLoading ? ( ) : item && (<> @@ -84,18 +83,26 @@ export default function ClothingDetail() { {/* 信息区 */} - 衣服详情 {item.name} {!!item.brand && {item.brand}} - {/* 分类 + 标签数 - 横向排列,不套卡片 */} + {/* 归属者 + 分类 + 标签 */} - {categoryName} - {!!ownerName && ( - - - {ownerName} + {owner ? ( + + + {owner.avatar ? : null} + {owner.nickname?.charAt(0)} + + {owner.nickname} + ) : ( + 未指定归属者 + )} + {categoryName === "未分类" ? ( + 未分类 + ) : ( + {categoryName} )} {selectedTags.length > 0 && ( @@ -116,16 +123,16 @@ export default function ClothingDetail() { {item.remark} )} + + {/* 操作按钮 */} + + + + )} - - - - - - 删除衣服确认删除「{item?.name}」吗? diff --git a/code/app/app-rn/src/app/clothes/edit/index.tsx b/code/app/app-rn/src/app/clothes/edit/index.tsx index 7a90eaef..12acba5c 100644 --- a/code/app/app-rn/src/app/clothes/edit/index.tsx +++ b/code/app/app-rn/src/app/clothes/edit/index.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, TouchableOpacity, View } from "react-native"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import ImagePicker from "react-native-image-crop-picker"; -import { Button, Input, Label, TextArea, TextField } from "heroui-native"; +import { Avatar, Button, Input, Label, TextArea, TextField } from "heroui-native"; import { CheckIcon, ImageIcon, @@ -24,7 +24,7 @@ import { ClothingTag, ClothingTargetType, } from "@/types/clothing"; -import { deleteS3FileQuietly } from "@/utils/file"; +import { buildFileUrl, deleteS3FileQuietly } from "@/utils/file"; interface ClothesFormState { name: string; @@ -326,7 +326,7 @@ export default function ClothingEdit() { setForm((prev) => ({ @@ -348,11 +348,13 @@ export default function ClothingEdit() { (member: ClothingFamilyMember) => { const selected = form.ownerId === member.user_id; + console.log(member) + return ( setForm((prev) => ({ @@ -361,6 +363,10 @@ export default function ClothingEdit() { })) } > + + {member.avatar ? : null} + {(member.nickname || "?").charAt(0)} + {member.nickname || `用户${member.user_id}`} @@ -468,7 +474,7 @@ export default function ClothingEdit() { setForm((prev) => ({ ...prev, categoryId: null }))} @@ -485,15 +491,12 @@ export default function ClothingEdit() { {/* 归属者 */} {members.length > 0 && ( <> - 归属者 - - 衣服仍为家庭共享,仅用于让家人知道这件衣服是谁的 - + 归属者 setForm((prev) => ({ ...prev, ownerId: null }))} diff --git a/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx b/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx index feb31a39..2ee926f4 100644 --- a/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx +++ b/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, TouchableOpacity, View } from "react-native"; import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; -import { Button, Dialog, useThemeColor } from "heroui-native"; -import { PencilIcon, RotateCcwIcon, Trash2Icon, UserIcon } from "lucide-react-native"; +import { Avatar, Button, Dialog } from "heroui-native"; +import { PencilIcon, Trash2Icon } from "lucide-react-native"; import PageLayout from "@/layouts/PageLayout"; import LayoutView from "@/components/layout/view"; import ClothingImage from "@/components/clothing/ClothingImage"; @@ -12,19 +12,20 @@ import { useLoading } from "@/hooks/useLoading"; import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier"; import { useClothingStore, useFamilyStore } from "@/store"; import { ClothingTargetType } from "@/types/clothing"; +import { buildFileUrl } from "@/utils/file"; export default function OutfitDetail() { const router = useRouter(); const { id } = useLocalSearchParams<{ id?: string }>(); const outfitId = id ? Number(id) : 0; const familyId = useFamilyStore((s) => s.getFamilyId()); - const accentColor = useThemeColor("accent"); const outfits = useClothingStore((s) => s.outfits); const clothes = useClothingStore((s) => s.clothes); const outfitClothes = useClothingStore((s) => s.outfitClothes); const categories = useClothingStore((s) => s.categories); const tags = useClothingStore((s) => s.tags); const tagRelations = useClothingStore((s) => s.tagRelations); + const members = useClothingStore((s) => s.members); const fetchOutfits = useClothingStore((s) => s.fetchOutfits); const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes); const fetchCategories = useClothingStore((s) => s.fetchCategories); @@ -32,7 +33,6 @@ export default function OutfitDetail() { const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations); const fetchClothes = useClothingStore((s) => s.fetchClothes); const fetchMembers = useClothingStore((s) => s.fetchMembers); - const getOwnerName = useClothingStore((s) => s.getOwnerName); const saveOutfitBundle = useClothingStore((s) => s.saveOutfitBundle); const { isLoading, startLoading, stopLoading } = useLoading(); const notifyModuleOperation = useModuleChatNotifier(); @@ -41,7 +41,7 @@ export default function OutfitDetail() { const item = useMemo(() => outfits.find((o) => o.id === outfitId) ?? null, [outfitId, outfits]); const categoryName = useMemo(() => { if (!item?.category_id) return "未分类"; return categories.find((c) => c.id === item.category_id)?.name ?? "未分类"; }, [categories, item?.category_id]); - const ownerName = useMemo(() => getOwnerName(item?.owner_id), [getOwnerName, item?.owner_id]); + const owner = useMemo(() => members.find((m) => m.user_id === item?.owner_id) ?? null, [members, item?.owner_id]); const details = useMemo(() => outfitClothes.filter((r) => r.outfit_id === outfitId).sort((a, b) => a.z_index - b.z_index || a.sort_number - b.sort_number).map((r) => ({ ...r, clothes: clothes.find((c) => c.id === r.clothes_id) })).filter((r) => r.clothes), [clothes, outfitClothes, outfitId]); const selectedTags = useMemo(() => { const ids = new Set(tagRelations.filter((r) => r.target_type === ClothingTargetType.Outfit && r.target_id === outfitId).map((r) => r.tag_id)); return tags.filter((t) => ids.has(t.id)); }, [outfitId, tagRelations, tags]); const getCoverKey = useCallback(() => { if (!item) return ""; if (item.cover_file_key) return item.cover_file_key; return details[0]?.clothes?.image_file_key ?? ""; }, [details, item]); @@ -67,10 +67,10 @@ export default function OutfitDetail() { }, [familyId, isLoading, item, notifyModuleOperation, router, saveOutfitBundle, startLoading, stopLoading]); return ( - + - + {!item && !pageLoading ? ( ) : item && (<> @@ -81,18 +81,27 @@ export default function OutfitDetail() { {/* 信息区 */} - 搭配详情 {item.name} {/* 统计 + 标签 - 横向排列 */} + {/* 归属者 + 件数 + 分类 + 标签 */} - {categoryName} - {details.length} 件衣服 - {!!ownerName && ( - - - {ownerName} + {owner ? ( + + + {owner.avatar ? : null} + {owner.nickname?.charAt(0)} + + {owner.nickname} + ) : ( + 未指定归属者 + )} + {details.length} 件衣服 + {categoryName === "未分类" ? ( + 未分类 + ) : ( + {categoryName} )} {selectedTags.map((tag) => ( @@ -139,21 +148,21 @@ export default function OutfitDetail() { {/* 备注 */} {!!item.remark && ( - + 备注 {item.remark} )} + + {/* 操作按钮 */} + + + + )} - - - - - - 删除搭配确认删除「{item?.name}」吗?衣服本身不会被删除。 diff --git a/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx b/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx index d1d5f387..5587b551 100644 --- a/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx +++ b/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx @@ -1,16 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { PanResponder, ScrollView, Text, TouchableOpacity, View } from "react-native"; +import { ActivityIndicator, NativeSyntheticEvent, NativeScrollEvent, PanResponder, ScrollView, Text, TouchableOpacity, View } from "react-native"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { captureRef } from "react-native-view-shot"; -import { Button, Card, Input, Label, TextArea, TextField, useThemeColor } from "heroui-native"; +import { Avatar, Button, Card, Input, Label, TextArea, TextField, useThemeColor } from "heroui-native"; import { BringToFrontIcon, + FilterIcon, ImageIcon, MinusIcon, PlusIcon, RotateCcwIcon, SaveIcon, - SearchIcon, SendToBackIcon, Trash2Icon, } from "lucide-react-native"; @@ -31,7 +31,7 @@ import { ClothingTag, ClothingTargetType, } from "@/types/clothing"; -import { deleteS3FileQuietly } from "@/utils/file"; +import { buildFileUrl, deleteS3FileQuietly } from "@/utils/file"; interface CanvasItem { uid: string; @@ -148,11 +148,12 @@ export default function ClothingOutfitEdit() { const currentUserId = useUserStore((s) => s.getUserId()); const accentColor = useThemeColor("accent"); const clothes = useClothingStore((s) => s.clothes); + const clothesTotal = useClothingStore((s) => s.clothesTotal); const categories = useClothingStore((s) => s.categories); const tags = useClothingStore((s) => s.tags); const tagRelations = useClothingStore((s) => s.tagRelations); const members = useClothingStore((s) => s.members); - const fetchClothes = useClothingStore((s) => s.fetchClothes); + const loadMoreClothes = useClothingStore((s) => s.loadMoreClothes); const fetchOutfits = useClothingStore((s) => s.fetchOutfits); const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes); const fetchCategories = useClothingStore((s) => s.fetchCategories); @@ -176,9 +177,12 @@ export default function ClothingOutfitEdit() { const [pageLoading, setPageLoading] = useState(true); const [capturingCover, setCapturingCover] = useState(false); const [showClothesSheet, setShowClothesSheet] = useState(false); + const [showClothesFilterSheet, setShowClothesFilterSheet] = useState(false); const [clothesKeyword, setClothesKeyword] = useState(""); const [clothesFilterCatId, setClothesFilterCatId] = useState(null); const [clothesFilterTagIds, setClothesFilterTagIds] = useState([]); + const [clothesFilterOwnerId, setClothesFilterOwnerId] = useState(null); + const [loadingMoreClothes, setLoadingMoreClothes] = useState(false); const clothesCategories = useMemo(() => categories.filter((c) => c.type === ClothingTargetType.Clothes), [categories]); const clothesTags = useMemo(() => tags.filter((t) => t.type === ClothingTargetType.Clothes), [tags]); @@ -199,12 +203,13 @@ export default function ClothingOutfitEdit() { const ids = clothesTagMap.get(item.id); if (!ids || !clothesFilterTagIds.every((tid) => ids.has(tid))) return false; } + if (clothesFilterOwnerId && item.owner_id !== clothesFilterOwnerId) return false; if (!kw) return true; return `${item.name} ${item.brand ?? ""}`.toLowerCase().includes(kw); }); - }, [clothes, clothesKeyword, clothesFilterCatId, clothesFilterTagIds, clothesTagMap]); + }, [clothes, clothesKeyword, clothesFilterCatId, clothesFilterTagIds, clothesFilterOwnerId, clothesTagMap]); - const clothesFilterCount = useMemo(() => { let c = 0; if (clothesFilterCatId) c++; if (clothesFilterTagIds.length > 0) c++; return c; }, [clothesFilterCatId, clothesFilterTagIds]); + const clothesFilterCount = useMemo(() => { let c = 0; if (clothesFilterCatId) c++; if (clothesFilterTagIds.length > 0) c++; if (clothesFilterOwnerId) c++; return c; }, [clothesFilterCatId, clothesFilterTagIds, clothesFilterOwnerId]); const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]); const outfitCategories = useMemo( @@ -238,13 +243,17 @@ export default function ClothingOutfitEdit() { setPageLoading(true); try { + useClothingStore.setState({ clothes: [], clothesTotal: 0 }); await Promise.all([ - fetchClothes(familyId), + loadMoreClothes(familyId), fetchOutfits(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Outfit), + fetchCategories(familyId, ClothingTargetType.Clothes), fetchTags(familyId, ClothingTargetType.Outfit), + fetchTags(familyId, ClothingTargetType.Clothes), fetchTagRelations(familyId, ClothingTargetType.Outfit, isEdit ? editId : undefined), + fetchTagRelations(familyId, ClothingTargetType.Clothes), fetchMembers(familyId), ]); @@ -298,7 +307,7 @@ export default function ClothingOutfitEdit() { editId, familyId, fetchCategories, - fetchClothes, + loadMoreClothes, fetchMembers, fetchOutfitClothes, fetchOutfits, @@ -410,7 +419,7 @@ export default function ClothingOutfitEdit() { setCategoryId((prev) => (prev === item.id ? null : item.id))} > @@ -430,11 +439,15 @@ export default function ClothingOutfitEdit() { return ( setOwnerId((prev) => (prev === member.user_id ? null : member.user_id))} > + + {member.avatar ? : null} + {(member.nickname || "?").charAt(0)} + {member.nickname || `用户${member.user_id}`} @@ -728,7 +741,7 @@ export default function ClothingOutfitEdit() { setCategoryId(null)} > @@ -751,7 +764,7 @@ export default function ClothingOutfitEdit() { setOwnerId(null)} > @@ -823,56 +836,46 @@ export default function ClothingOutfitEdit() { + {/* 从衣橱选择衣服 */} - - {/* 搜索 + 分类一行 */} - - - - + + {/* 搜索 + 筛选按钮 */} + + + - {(clothesFilterCount > 0 || clothesKeyword) && ( - { setClothesKeyword(""); setClothesFilterCatId(null); setClothesFilterTagIds([]); }}> - - - )} + setShowClothesFilterSheet(true)} + > + + 筛选{clothesFilterCount > 0 ? ` ${clothesFilterCount}` : ""} + - {/* 分类 */} - {clothesCategories.length > 0 && ( - - setClothesFilterCatId(null)}> - 全部 - - {clothesCategories.map((c) => setClothesFilterCatId(clothesFilterCatId === c.id ? null : c.id)}>{c.name})} - - )} - - {/* 标签按分组 */} - {tags.filter((t) => t.type === ClothingTargetType.Clothes && t.parent_id !== 0).length > 0 && (() => { - const ctGroups = tags.filter((g) => g.type === ClothingTargetType.Clothes && g.parent_id === 0); - const ctTags = tags.filter((t) => t.type === ClothingTargetType.Clothes && t.parent_id !== 0); - return ctGroups.map((g) => { - const gTags = ctTags.filter((t) => t.parent_id === g.id); - if (gTags.length === 0) return null; - return ( - - - {gTags.map((t) => { - const s = clothesFilterTagIds.includes(t.id); - return setClothesFilterTagIds((p) => s ? p.filter((id) => id !== t.id) : [...p, t.id])}>{t.name}; - })} - - - ); - }); - })()} - - {/* 2列网格 */} - + {/* 衣服列表 */} + ) => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; + if (contentOffset.y + layoutMeasurement.height >= contentSize.height - 100) { + if (!familyId || loadingMoreClothes || clothes.length >= clothesTotal) return; + setLoadingMoreClothes(true); + await loadMoreClothes(familyId); + setLoadingMoreClothes(false); + } + }} + > {filteredClothes.length > 0 ? ( + <> {(() => { const left = filteredClothes.filter((_, i) => i % 2 === 0); @@ -893,6 +896,9 @@ export default function ClothingOutfitEdit() { ; })()} + {loadingMoreClothes && } + {clothes.length >= clothesTotal && clothesTotal > 0 && 已加载全部衣服} + ) : ( 没有匹配的衣服 )} @@ -901,6 +907,81 @@ export default function ClothingOutfitEdit() { + {/* 筛选条件 */} + + + + + + + 筛选条件 + + { setClothesFilterCatId(null); setClothesFilterTagIds([]); setClothesFilterOwnerId(null); }}> + + 重置 + + + + + {/* 分类 */} + + + + setClothesFilterCatId(null)}> + 全部 + + {clothesCategories.map((c) => ( + setClothesFilterCatId((p) => p === c.id ? null : c.id)}> + {c.name} + + ))} + + + + {/* 标签 */} + + + + setClothesFilterTagIds([])}> + 全部 + + {clothesTags.map((t) => { + const s = clothesFilterTagIds.includes(t.id); + return ( + setClothesFilterTagIds((p) => s ? p.filter((id) => id !== t.id) : [...p, t.id])}> + {t.name} + + ); + })} + + + + {/* 归属者 */} + + + + setClothesFilterOwnerId(null)}> + 全部 + + {members.map((m) => ( + setClothesFilterOwnerId((p) => p === m.user_id ? null : m.user_id)}> + + {m.avatar ? : null} + {m.nickname?.charAt(0)} + + {m.nickname} + + ))} + + + + + + + + ); diff --git a/code/app/app-rn/src/app/meal/(tabs)/_layout.tsx b/code/app/app-rn/src/app/meal/(tabs)/_layout.tsx index 2cc5e2f7..53e727a3 100644 --- a/code/app/app-rn/src/app/meal/(tabs)/_layout.tsx +++ b/code/app/app-rn/src/app/meal/(tabs)/_layout.tsx @@ -1,24 +1,12 @@ import { Stack } from "expo-router"; -import { CalendarDaysIcon, UtensilsCrossedIcon, SlidersHorizontalIcon } from "lucide-react-native"; +import { CalendarDaysIcon, ChefHatIcon, TagIcon } from "lucide-react-native"; import ModuleLayout from "@/layouts/ModuleLayout"; import { TabbarItem } from "@/components/layout/tabbar"; const tabs: TabbarItem[] = [ - { - id: "/meal/home", - icon: CalendarDaysIcon, - title: "今日", - }, - { - id: "/meal/dishes", - icon: UtensilsCrossedIcon, - title: "菜品", - }, - { - id: "/meal/manage", - icon: SlidersHorizontalIcon, - title: "管理", - }, + { id: "/meal/home", icon: CalendarDaysIcon, title: "今日" }, + { id: "/meal/dishes", icon: ChefHatIcon, title: "菜品" }, + { id: "/meal/manage", icon: TagIcon, title: "管理" }, ]; export default function MealLayout() { diff --git a/code/app/app-rn/src/app/meal/(tabs)/dishes/index.tsx b/code/app/app-rn/src/app/meal/(tabs)/dishes/index.tsx index b65ed35f..3a47cfb5 100644 --- a/code/app/app-rn/src/app/meal/(tabs)/dishes/index.tsx +++ b/code/app/app-rn/src/app/meal/(tabs)/dishes/index.tsx @@ -1,41 +1,38 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Text, TouchableOpacity, View } from "react-native"; +import { Dimensions, FlatList, ScrollView, Text, TouchableOpacity, View } from "react-native"; import { router, useFocusEffect } from "expo-router"; -import { Button, Dialog, Input, Label, TextField } from "heroui-native"; -import { - CheckIcon, - FilterIcon, - PencilIcon, - PlusIcon, - RotateCcwIcon, - SearchIcon, - Trash2Icon, -} from "lucide-react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button, Dialog, Label, TextField } from "heroui-native"; +import { CheckIcon, FilterIcon, PencilIcon, PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native"; import BottomSheet from "@/components/BottomSheet"; import ContextMenu from "@/components/ContextMenu"; import EmptyState from "@/components/EmptyStatus"; import LayoutView from "@/components/layout/view"; import MealDishImage from "@/components/meal/MealDishImage"; import { LoadingOverlay } from "@/components/LoadingOverlay"; -import { useLoading } from "@/hooks/useLoading"; +import { useListLoading, useLoading } from "@/hooks/useLoading"; import { useFamilyStore, useMealStore } from "@/store"; -import { MealCategory, MealDish, MealStatus, MealTag } from "@/types/meal"; +import { MealDish, MealStatus, MealTag } from "@/types/meal"; -interface DishFilter { - keyword: string; - categoryId: number | null; - tagIds: number[]; -} +// ============================================================ +// 筛选类型 +// ============================================================ -function createDefaultFilter(): DishFilter { - return { keyword: "", categoryId: null, tagIds: [] }; -} +interface DishFilter { categoryId: number | null; tagIds: number[]; } +function createDefaultFilter(): DishFilter { return { categoryId: null, tagIds: [] }; } +function sameNumberSet(a: number[], b: number[]) { if (a.length !== b.length) return false; const set = new Set(a); return b.every((i) => set.has(i)); } -function sameNumberSet(a: number[], b: number[]) { - if (a.length !== b.length) return false; - const set = new Set(a); - return b.every((item) => set.has(item)); -} +// ============================================================ +// 常量 +// ============================================================ + +const HEADER_H = 52; // 筛选栏高度 +const TAB_BAR_H = 56; // 底部 tabbar 高度 +const MODULE_HEADER_H = 50; // 模块顶部 header 高度(对齐 LayoutView 默认值) + +// ============================================================ +// 菜品库页面 +// ============================================================ export default function MealDishesPage() { const familyId = useFamilyStore((s) => s.getFamilyId()); @@ -49,385 +46,224 @@ export default function MealDishesPage() { const fetchDishTags = useMealStore((s) => s.fetchDishTags); const saveDishChanges = useMealStore((s) => s.saveDishChanges); const { isLoading, startLoading, stopLoading } = useLoading(); + const { refreshing, runInitial, runRefresh } = useListLoading(); - const [filter, setFilter] = useState(() => createDefaultFilter()); - const [draftFilter, setDraftFilter] = useState(() => createDefaultFilter()); + const insets = useSafeAreaInsets(); + const screenH = Dimensions.get("window").height; + + // 筛选状态 + const [keyword, setKeyword] = useState(""); + const [filter, setFilter] = useState(createDefaultFilter); + const [draftFilter, setDraftFilter] = useState(createDefaultFilter); const [showFilterSheet, setShowFilterSheet] = useState(false); + const [selCatId, setSelCatId] = useState(null); + + // 长按菜单 const [activeItem, setActiveItem] = useState(null); const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); const [deleteConfirm, setDeleteConfirm] = useState(null); - const [pageLoading, setPageLoading] = useState(true); + + // 左右两栏可用高度 + const bodyH = screenH - insets.top - MODULE_HEADER_H - HEADER_H - TAB_BAR_H - insets.bottom; + + // ======== 派生数据 ======== + + const enabledCats = useMemo(() => categories.filter((c) => c.status !== 0), [categories]); const activeFilterCount = useMemo(() => { - let count = 0; - if (filter.categoryId) count += 1; - if (filter.tagIds.length > 0) count += 1; - return count; + let c = 0; if (filter.categoryId) c++; if (filter.tagIds.length > 0) c++; return c; }, [filter]); - const tagIdsByDishId = useMemo(() => { - const map = new Map>(); - dishTags.forEach((item) => { - const set = map.get(item.dish_id) ?? new Set(); - set.add(item.tag_id); - map.set(item.dish_id, set); - }); - return map; + const tagIdsByDish = useMemo(() => { + const m = new Map>(); + dishTags.forEach((r) => { const s = m.get(r.dish_id) ?? new Set(); s.add(r.tag_id); m.set(r.dish_id, s); }); + return m; }, [dishTags]); - const filteredDishes = useMemo(() => { - const trimmedKeyword = filter.keyword.trim().toLowerCase(); + const activeCatId = selCatId !== null ? selCatId : filter.categoryId; - return dishes.filter((item) => { - if (filter.categoryId && item.category_id !== filter.categoryId) return false; - - if (filter.tagIds.length > 0) { - const itemTagIds = tagIdsByDishId.get(item.id); - if (!itemTagIds) return false; - if (!filter.tagIds.every((tagId) => itemTagIds.has(tagId))) return false; - } - - if (!trimmedKeyword) return true; - return item.name.toLowerCase().includes(trimmedKeyword); + const filtered = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + return dishes.filter((d) => { + const effectiveCatId = selCatId !== null ? selCatId : filter.categoryId; + if (effectiveCatId && d.category_id !== effectiveCatId) return false; + if (filter.tagIds.length > 0) { const ids = tagIdsByDish.get(d.id); if (!ids || !filter.tagIds.every((t) => ids.has(t))) return false; } + if (!kw) return true; + return d.name.toLowerCase().includes(kw); }); - }, [dishes, filter, tagIdsByDishId]); + }, [dishes, keyword, filter, selCatId, tagIdsByDish]); - const loadBaseData = useCallback(async () => { + const filterChanged = filter.categoryId !== draftFilter.categoryId || !sameNumberSet(filter.tagIds, draftFilter.tagIds); + + // ======== 数据加载 ======== + + const loadData = useCallback(async () => { if (!familyId) return; - setPageLoading(true); - try { - await Promise.all([ - fetchDishes(familyId), - fetchCategories(familyId), - fetchTags(familyId), - fetchDishTags(familyId), - ]); - } finally { - setPageLoading(false); - } + await Promise.all([fetchDishes(familyId), fetchCategories(familyId), fetchTags(familyId), fetchDishTags(familyId)]); }, [familyId, fetchCategories, fetchDishTags, fetchDishes, fetchTags]); - useEffect(() => { - if (!familyId) return; - loadBaseData(); - }, [familyId]); + useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]); + useFocusEffect(useCallback(() => { if (familyId) runRefresh(loadData); }, [familyId, loadData, runRefresh])); - useFocusEffect( - useCallback(() => { - if (!familyId) return; - loadBaseData(); - }, [familyId, loadBaseData]), - ); + // ======== 操作 ======== - const handleOpenFilter = useCallback(() => { - setDraftFilter(filter); - setShowFilterSheet(true); - }, [filter]); - - const handleResetFilter = useCallback(() => { - setDraftFilter(createDefaultFilter()); - }, []); + const handleDelete = useCallback(async (item: MealDish) => { + if (!familyId || isLoading) return; startLoading("删除中..."); + try { await saveDishChanges({ deletes: [{ id: item.id }] }); await loadData(); stopLoading({ type: "success", message: "已删除" }); } + catch { stopLoading({ type: "error", message: "删除失败" }); } + }, [familyId, isLoading, loadData, saveDishChanges, startLoading, stopLoading]); + const handleOpenFilter = useCallback(() => { setDraftFilter(filter); setShowFilterSheet(true); }, [filter]); + const handleResetFilter = useCallback(() => setDraftFilter(createDefaultFilter()), []); const handleApplyFilter = useCallback(() => { setFilter(draftFilter); + if (draftFilter.categoryId !== null) setSelCatId(draftFilter.categoryId); setShowFilterSheet(false); }, [draftFilter]); - const handleEdit = useCallback((item: MealDish) => { - setActiveItem(null); - router.push(`/meal/dish/edit?id=${item.id}` as any); - }, []); + const handleCatPress = useCallback((catId: number | null) => { + const newId = selCatId === catId ? null : catId; + setSelCatId(newId); + setFilter((p) => ({ ...p, categoryId: newId })); + }, [selCatId]); - const handleDelete = useCallback( - async (item: MealDish) => { - if (!familyId || isLoading) return; - startLoading("删除中..."); - try { - const ok = await saveDishChanges({ - updates: [{ id: item.id, status: MealStatus.Disabled }], - }); - if (!ok) { - stopLoading({ type: "error", message: "删除失败" }); - return; - } - await loadBaseData(); - stopLoading({ type: "success", message: "已停用" }); - } catch { - stopLoading({ type: "error", message: "删除失败" }); - } - }, - [familyId, isLoading, loadBaseData, saveDishChanges, startLoading, stopLoading], - ); + const renderDraftTag = useCallback((t: MealTag) => { + const s = draftFilter.tagIds.includes(t.id); + return setDraftFilter((p) => ({ ...p, tagIds: p.tagIds.includes(t.id) ? p.tagIds.filter((id) => id !== t.id) : [...p.tagIds, t.id] }))}>{t.name}; + }, [draftFilter.tagIds]); - const renderCategoryName = useCallback( - (categoryId?: number | null) => - categories.find((c) => c.id === categoryId)?.name ?? "", - [categories], - ); + // ======== 渲染 ======== - const renderFilterCategory = useCallback( - (item: MealCategory) => { - const selected = draftFilter.categoryId === item.id; - return ( - - setDraftFilter((prev) => ({ - ...prev, - categoryId: prev.categoryId === item.id ? null : item.id, - })) - } - > - {item.name} - - ); - }, - [draftFilter.categoryId], - ); - - const renderFilterTag = useCallback( - (item: MealTag) => { - const selected = draftFilter.tagIds.includes(item.id); - return ( - - setDraftFilter((prev) => ({ - ...prev, - tagIds: selected - ? prev.tagIds.filter((id) => id !== item.id) - : [...prev.tagIds, item.id], - })) - } - > - {item.name} - - ); - }, - [draftFilter.tagIds], - ); - - const filterChanged = - filter.keyword !== draftFilter.keyword || - filter.categoryId !== draftFilter.categoryId || - !sameNumberSet(filter.tagIds, draftFilter.tagIds); - - const renderHeader = useCallback( - () => ( - - 共 {dishes.length} 道菜 - - + return ( + + {/* 筛选栏 — 固定顶部 */} + + 菜品库 + + - - 筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""} - + 筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""} - + runRefresh(loadData)}> - ), - [activeFilterCount, dishes.length, handleOpenFilter, loadBaseData], - ); - return ( - - String(item.id)} - renderItem={({ item }) => ( - { - setActiveItem(null); - router.push(`/meal/dish/detail/${item.id}` as any); - }} - onLongPress={(e) => { - setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); - setActiveItem(item); - }} + {/* 左右两栏:各自独立滚动 */} + + {/* 左侧分类栏 */} + + - - - - {!!renderCategoryName(item.category_id) && ( - - - {renderCategoryName(item.category_id)} - - - )} - - - - {item.name} - - {!!item.difficulty && item.difficulty > 0 && ( - - {"★".repeat(item.difficulty)} {"☆".repeat(Math.max(0, 3 - item.difficulty))} - - )} - - - - )} - numColumns={2} - columnWrapperStyle={{ gap: 12 }} - header={renderHeader()} - onRefresh={loadBaseData} - refreshing={pageLoading} - showsVerticalScrollIndicator={false} - contentContainerStyle={[ - { paddingHorizontal: 16, paddingBottom: 120 }, - filteredDishes.length === 0 ? { flexGrow: 1 } : undefined, - ]} - ListEmptyComponent={() => ( - - )} - /> + handleCatPress(null)} + > + + 全部 + + - {/* 筛选 BottomSheet */} - - - - - - - 筛选菜品 - + {enabledCats.map((c) => { + const sel = activeCatId === c.id; + return ( handleCatPress(c.id)} > - - 重置 - - - - - - - setDraftFilter((prev) => ({ ...prev, keyword: v }))} - placeholder="搜索菜名" - returnKeyType="search" - /> - - - - - - setDraftFilter((prev) => ({ ...prev, categoryId: null }))} - > - - 全部 + + {c.name} - {categories.map(renderFilterCategory)} - - + ); + })} + + - - - {tags.length > 0 ? ( - - {tags.map(renderFilterTag)} + {/* 右侧菜品列表 */} + + String(i.id)} + refreshing={refreshing} + onRefresh={() => runRefresh(loadData)} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ paddingBottom: 80 }} + ListEmptyComponent={() => ( + + + + )} + renderItem={({ item }) => ( + { setActiveItem(null); router.push(`/meal/dish/detail/${item.id}` as any); }} + onLongPress={(e) => { setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); setActiveItem(item); }}> + + - ) : ( - 暂无标签 - )} - + + {item.name} + + {!!item.difficulty && item.difficulty > 0 && {"★".repeat(item.difficulty)}} + {!!item.cooking_time_minutes && 约{item.cooking_time_minutes}分钟} + + + {">"} + + )} + /> + + - - - - - - - - - {/* FAB 新增 */} - router.push("/meal/dish/edit" as any)} - > + {/* FAB */} + router.push("/meal/dish/edit" as any)}> - {/* 长按菜单 */} + {/* ======== 筛选 BottomSheet ======== */} + + + + 筛选重置 + + {tags.length > 0 ? {tags.map(renderDraftTag)} : 暂无标签} + + + + + {/* ======== 长按菜单 ======== */} setActiveItem(null)} items={[ - { icon: , label: "编辑", onPress: () => { if (activeItem) handleEdit(activeItem); } }, - { icon: , label: "停用", onPress: () => { const item = activeItem; setActiveItem(null); if (item) setDeleteConfirm(item); } }, + { icon: , label: "编辑", onPress: () => { if (activeItem) { const a = activeItem; setActiveItem(null); router.push(`/meal/dish/edit?id=${a.id}` as any); } } }, + { icon: , label: "删除", onPress: () => { const a = activeItem; setActiveItem(null); if (a) setDeleteConfirm(a); } }, ]} /> - {/* 停用确认 */} + {/* ======== 停用确认 ======== */} { if (!v) setDeleteConfirm(null); }}> - - - - 停用菜品 - - 确认停用「{deleteConfirm?.name}」吗?停用后菜品将从列表隐藏,历史餐食安排不受影响。 - - - - - - - + + 删除菜品确认删除「{deleteConfirm?.name}」吗?此操作不可撤销。 + + diff --git a/code/app/app-rn/src/app/meal/(tabs)/home/index.tsx b/code/app/app-rn/src/app/meal/(tabs)/home/index.tsx index 257d8815..f70dc3ed 100644 --- a/code/app/app-rn/src/app/meal/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/meal/(tabs)/home/index.tsx @@ -1,16 +1,17 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Text, TouchableOpacity, View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Platform, Text, TouchableOpacity, View } from "react-native"; import { router } from "expo-router"; import dayjs from "dayjs"; +import DateTimePicker, { type DateTimePickerEvent } from "@react-native-community/datetimepicker"; import { Button } from "heroui-native"; import { + BadgeCheckIcon, CalendarDaysIcon, - CheckIcon, - MessageSquareIcon, + CheckCircleIcon, + ChevronRightIcon, + CircleIcon, PlusIcon, - Trash2Icon, UserIcon, - XIcon, } from "lucide-react-native"; import { toast } from "sonner-native"; import BottomSheet from "@/components/BottomSheet"; @@ -18,55 +19,23 @@ import EmptyState from "@/components/EmptyStatus"; import LayoutView from "@/components/layout/view"; import MealDishImage from "@/components/meal/MealDishImage"; import { LoadingOverlay } from "@/components/LoadingOverlay"; -import { useLoading } from "@/hooks/useLoading"; -import { getUniqueIdApi, loadDataApi } from "@/request/api"; +import { useListLoading, useLoading } from "@/hooks/useLoading"; +import { loadDataApi } from "@/request/api"; import { useFamilyStore, useMealStore, useUserStore } from "@/store"; -import { MealPlanLogType } from "@/types/meal"; +import { MealPlanStatus } from "@/types/meal"; -// ------- 类型 ------- +// ============================================================ +// 类型 +// ============================================================ -interface FamilyMember { +interface FMember { user_id: number; nickname: string; } -interface TimelineEntry { - id: number; - type: MealPlanLogType; - content: string; - ref_id?: number | null; - create_time: string; - create_by: number; - userNickname: string; - dishName?: string; -} - -interface SlotPlanData { - plan: any; - items: any[]; - timeline: TimelineEntry[]; -} - -// ------- 辅助函数 ------- - -function getLogLabel(type: MealPlanLogType): string { - switch (type) { - case MealPlanLogType.Plan: - return "安排了这顿饭"; - case MealPlanLogType.AddDish: - return "加了"; - case MealPlanLogType.RemoveDish: - return "移除了"; - case MealPlanLogType.Remark: - return "留了备注"; - case MealPlanLogType.SetCook: - return "设置了做饭人"; - default: - return ""; - } -} - -// ------- 页面 ------- +// ============================================================ +// 今日餐食首页 +// ============================================================ export default function MealHomePage() { const familyId = useFamilyStore((s) => s.getFamilyId()); @@ -80,372 +49,233 @@ export default function MealHomePage() { const fetchDishTags = useMealStore((s) => s.fetchDishTags); const fetchSlots = useMealStore((s) => s.fetchSlots); const fetchPlansByDate = useMealStore((s) => s.fetchPlansByDate); - const savePlanItemChanges = useMealStore((s) => s.savePlanItemChanges); - const savePlanLogChanges = useMealStore((s) => s.savePlanLogChanges); - const savePlanChanges = useMealStore((s) => s.savePlanChanges); + const createEmptyPlan = useMealStore((s) => s.createEmptyPlan); + const removeDishFromPlan = useMealStore((s) => s.removeDishFromPlan); const savePlanBundle = useMealStore((s) => s.savePlanBundle); - const recordFeed = useMealStore((s) => s.recordFeed); const { isLoading, startLoading, stopLoading } = useLoading(); + const { refreshing, runInitial, runRefresh } = useListLoading(); const today = dayjs().format("YYYY-MM-DD"); const tomorrow = dayjs().add(1, "day").format("YYYY-MM-DD"); - const [planDate, setPlanDate] = useState(today); - const [showDateSheet, setShowDateSheet] = useState(false); - const [datePickerValue, setDatePickerValue] = useState(today); - const [members, setMembers] = useState([]); - const [allPlanItems, setAllPlanItems] = useState([]); - const [allPlanLogs, setAllPlanLogs] = useState([]); - const [pageLoading, setPageLoading] = useState(true); - const [expandedTimelines, setExpandedTimelines] = useState>(new Set()); + const [showDate, setShowDate] = useState(false); + const [members, setMembers] = useState([]); + const [allItems, setAllItems] = useState([]); + const isFirstLoad = useRef(true); + const [creatingSlotId, setCreatingSlotId] = useState(null); const isToday = planDate === today; - const isTomorrow = planDate === tomorrow; - // 按 slot 分组 plan 数据 - const slotPlanMap = useMemo(() => { - const map = new Map(); - - // 先按计划聚合 items 和 logs - const itemsByPlan = new Map(); - allPlanItems.forEach((item) => { - const list = itemsByPlan.get(item.plan_id) ?? []; - list.push(item); - itemsByPlan.set(item.plan_id, list); + const itemsByPlan = useMemo(() => { + const m = new Map(); + allItems.forEach((i) => { + const l = m.get(i.plan_id) ?? []; + l.push(i); + m.set(i.plan_id, l); }); + return m; + }, [allItems]); - const logsByPlan = new Map(); - allPlanLogs.forEach((log) => { - const list = logsByPlan.get(log.plan_id) ?? []; - const member = members.find((m) => m.user_id === log.create_by); - const entry: TimelineEntry = { - id: log.id, - type: log.type, - content: log.content ?? "", - ref_id: log.ref_id, - create_time: log.create_time ?? "", - create_by: log.create_by ?? 0, - userNickname: member?.nickname ?? `用户${log.create_by}`, - }; - - // type=2/3 补 dishName - if ((log.type === MealPlanLogType.AddDish || log.type === MealPlanLogType.RemoveDish) && log.ref_id) { - const planItem = allPlanItems.find((pi) => pi.id === log.ref_id); - if (planItem) { - const dish = dishes.find((d) => d.id === planItem.dish_id); - entry.dishName = dish?.name ?? "未知菜品"; - } - } - - list.push(entry); - logsByPlan.set(log.plan_id, list); - }); - - // 构建 slot -> plan data - slots.forEach((slot) => { - const plan = plans.find((p) => p.meal_slot_id === slot.id); - if (plan) { - map.set(slot.id, { - plan, - items: (itemsByPlan.get(plan.id) ?? []).map((item) => { - const dish = dishes.find((d) => d.id === item.dish_id); - return { ...item, dish }; - }), - timeline: (logsByPlan.get(plan.id) ?? []).sort( - (a, b) => new Date(a.create_time).getTime() - new Date(b.create_time).getTime(), - ), - }); - } - }); - - return map; - }, [slots, plans, allPlanItems, allPlanLogs, dishes, members]); - - // ------- 数据加载 ------- + // ======== 数据加载 ======== const loadData = useCallback(async () => { if (!familyId) return; - setPageLoading(true); - try { - await Promise.all([ - fetchDishes(familyId), - fetchCategories(familyId), - fetchTags(familyId), - fetchDishTags(familyId), - fetchSlots(familyId), - fetchPlansByDate(familyId, planDate), - ]); - - // 加载所有 plan 的 items 和 logs(使用直接 API 避免 store 覆盖问题) - const state = useMealStore.getState(); - const currentPlans = state.plans; - const planIds = currentPlans.map((p) => p.id); - - if (planIds.length > 0) { - const [itemsRes, logsRes, memberRes] = await Promise.all([ - loadDataApi({ - view_name: "b_meal_plan_item", - search_condition: `family_id = ? and plan_id in (${planIds.map(() => "?").join(",")})`, - search_columns: [], - order_by: "sort_number asc, id asc", - args: [familyId, ...planIds], - }), - loadDataApi({ - view_name: "b_meal_plan_log", - search_condition: `family_id = ? and plan_id in (${planIds.map(() => "?").join(",")})`, - search_columns: [], - order_by: "create_time asc, id asc", - args: [familyId, ...planIds], - }), - loadDataApi({ - view_name: "b_family_member", - search_condition: "family_id = ?", - search_columns: ["user_id", "nickname"], - order_by: "id asc", - args: [familyId], - }), - ]); - - if (itemsRes.isSuccess) setAllPlanItems(itemsRes.data ?? []); - if (logsRes.isSuccess) setAllPlanLogs(logsRes.data ?? []); - if (memberRes.isSuccess) setMembers((memberRes.data as FamilyMember[]) ?? []); - } else { - setAllPlanItems([]); - setAllPlanLogs([]); - // 依然加载家庭成员 - const memberRes = await loadDataApi({ - view_name: "b_family_member", + await Promise.all([ + fetchDishes(familyId), + fetchCategories(familyId), + fetchTags(familyId), + fetchDishTags(familyId), + fetchSlots(familyId), + fetchPlansByDate(familyId, planDate), + ]); + const st = useMealStore.getState(); + const pids = st.plans.map((p) => p.id); + if (pids.length > 0) { + const [ir, mr] = await Promise.all([ + loadDataApi({ + view_name: "b_meal_plan_item", + search_condition: `family_id = ? and plan_id in (${pids.map(() => "?").join(",")})`, + search_columns: [], + order_by: "sort_number asc, id asc", + args: [familyId, ...pids], + }), + loadDataApi({ + view_name: "v_family_member", search_condition: "family_id = ?", search_columns: ["user_id", "nickname"], order_by: "id asc", args: [familyId], - }); - if (memberRes.isSuccess) setMembers((memberRes.data as FamilyMember[]) ?? []); - } - } finally { - setPageLoading(false); + }), + ]); + if (ir.isSuccess) setAllItems(ir.data ?? []); + if (mr.isSuccess) setMembers((mr.data as FMember[]) ?? []); + } else { + setAllItems([]); + const mr = await loadDataApi({ + view_name: "v_family_member", + search_condition: "family_id = ?", + search_columns: ["user_id", "nickname"], + order_by: "id asc", + args: [familyId], + }); + if (mr.isSuccess) setMembers((mr.data as FMember[]) ?? []); } }, [ - familyId, planDate, fetchDishes, fetchCategories, fetchTags, fetchDishTags, - fetchSlots, fetchPlansByDate, + familyId, + planDate, + fetchDishes, + fetchCategories, + fetchTags, + fetchDishTags, + fetchSlots, + fetchPlansByDate, ]); useEffect(() => { - loadData(); - }, [loadData]); + if (familyId) { + runInitial(loadData); + isFirstLoad.current = false; + } + }, [familyId]); - // ------- 交互 ------- - - const handleOpenDateSheet = useCallback(() => { - setDatePickerValue(planDate); - setShowDateSheet(true); + useEffect(() => { + if (familyId && !isFirstLoad.current) runRefresh(loadData); }, [planDate]); - const handleConfirmDate = useCallback(() => { - if (dayjs(datePickerValue).isValid()) { - setPlanDate(datePickerValue); - setShowDateSheet(false); - } else { - toast.error("日期格式无效"); - } - }, [datePickerValue]); + // ======== 辅助 ======== + const getCookName = useCallback( + (c?: number | null) => { + if (!c) return null; + return members.find((m) => m.user_id === c)?.nickname ?? `用户${c}`; + }, + [members], + ); + + // ======== 操作 ======== + + // 快速创建空菜单 + const handleCreatePlan = useCallback( + async (slotId: number) => { + if (!familyId || !userId) return; + setCreatingSlotId(slotId); + try { + const planId = await createEmptyPlan({ + familyId, + planDate, + mealSlotId: slotId, + userId, + }); + if (planId) { + router.push(`/meal/plan/detail/${planId}` as any); + } else { + toast.error("创建失败"); + } + } catch { + toast.error("创建失败"); + } finally { + setCreatingSlotId(null); + } + }, + [familyId, userId, planDate, createEmptyPlan], + ); + + // 快速移除菜品(事件驱动) const handleRemoveDish = useCallback( async (planId: number, itemId: number, dishName: string) => { if (!familyId) return; startLoading("移除中..."); try { - const logId = (await getUniqueIdApi(1)).data?.[0] ?? 0; - - const res = await savePlanItemChanges({ - deletes: [{ id: itemId }], + const ok = await removeDishFromPlan({ + familyId, + planId, + planItemId: itemId, + dishName, + userId, }); - - if (!res) { + if (ok) { + await loadData(); + stopLoading({ type: "success", message: `已移除${dishName}` }); + } else { stopLoading({ type: "error", message: "移除失败" }); - return; } - - // 写入 type=3 日志 - if (logId) { - await savePlanLogChanges({ - inserts: [{ - id: logId, - family_id: familyId, - plan_id: planId, - type: MealPlanLogType.RemoveDish, - ref_id: itemId, - create_by: userId, - }], - }); - } - - await loadData(); - stopLoading({ type: "success", message: `已移除${dishName}` }); } catch { stopLoading({ type: "error", message: "移除失败" }); } }, - [familyId, loadData, savePlanItemChanges, savePlanLogChanges, startLoading, stopLoading, userId], + [familyId, userId, removeDishFromPlan, loadData, startLoading, stopLoading], ); - const handleDeleteRemark = useCallback( - async (planId: number, log: TimelineEntry) => { - if (log.create_by !== userId) { - toast.error("只能删除自己的备注"); - return; - } - if (!familyId) return; - startLoading("删除中..."); - try { - await savePlanLogChanges({ deletes: [{ id: log.id }] }); - await loadData(); - stopLoading({ type: "success", message: "备注已删除" }); - } catch { - stopLoading({ type: "error", message: "删除失败" }); - } - }, - [familyId, loadData, savePlanLogChanges, startLoading, stopLoading, userId], - ); - - const handleRemoveRemarkPlan = useCallback( - async (planId: number) => { - if (!familyId) return; - startLoading("操作中..."); - try { - await savePlanChanges({ updates: [{ id: planId, remark: "" }] }); - await loadData(); - stopLoading({ type: "success", message: "备注已清除" }); - } catch { - stopLoading({ type: "error", message: "操作失败" }); - } - }, - [familyId, loadData, savePlanChanges, startLoading, stopLoading], - ); - - // ------- 复制历史餐食到今日 ------- - + // 复制历史餐食 const handleCopyPlan = useCallback( async (planId: number, mealSlotId: number) => { if (!familyId) return; startLoading("复制中..."); try { - // 查看今日是否已有同一餐段的 plan - const todayPlans = await loadDataApi({ + const tp = await loadDataApi({ view_name: "b_meal_plan", search_condition: "family_id = ? and plan_date = ? and meal_slot_id = ?", search_columns: ["id"], order_by: "id asc", args: [familyId, today, mealSlotId], }); - - const sourceItems = allPlanItems.filter((item) => item.plan_id === planId); - if (sourceItems.length === 0) { + const si = allItems.filter((i) => i.plan_id === planId); + if (si.length === 0) { stopLoading({ type: "error", message: "无菜品可复制" }); return; } - - const newPlanId = todayPlans.data?.[0]?.id ?? (await getUniqueIdApi(1)).data?.[0] ?? 0; - if (!newPlanId) { - stopLoading({ type: "error", message: "生成计划 ID 失败" }); + const { getUniqueIdApi } = await import("@/request/api"); + const idRes = tp.data?.[0] + ? { data: [tp.data[0].id] } + : await getUniqueIdApi(1); + const npId = idRes.data?.[0] ?? 0; + if (!npId) { + stopLoading({ type: "error", message: "复制失败" }); return; } - - const itemIds = (await getUniqueIdApi(sourceItems.length)).data ?? []; - const logId = (await getUniqueIdApi(1)).data?.[0] ?? 0; - - const newItems = sourceItems.map((item, i) => ({ - id: itemIds[i], + const iidRes = await getUniqueIdApi(si.length); + const newIds = iidRes.data ?? []; + const ni = si.map((item, i) => ({ + id: newIds[i] ?? 0, + plan_id: tp.data?.[0] ? npId : npId, family_id: familyId, - plan_id: newPlanId, dish_id: item.dish_id, quantity: item.quantity ?? 1, sort_number: i, })); - - // 创建 plan + items + log - const isNewPlan = !todayPlans.data?.[0]; + const planHas = tp.data?.[0] + ? [] + : [{ + id: npId, + family_id: familyId, + plan_date: today, + meal_slot_id: mealSlotId, + status: MealPlanStatus.Planning, + }]; await savePlanBundle({ - planInserts: isNewPlan - ? [{ id: newPlanId, family_id: familyId, plan_date: today, meal_slot_id: mealSlotId }] - : [], + planInserts: planHas, planUpdates: [], - planItemInserts: newItems, + planItemInserts: ni, planItemUpdates: [], planItemDeletes: [], - planLogInserts: [ - ...(isNewPlan && logId - ? [{ id: logId, family_id: familyId, plan_id: newPlanId, type: MealPlanLogType.Plan, create_by: userId }] - : []), - ], + planLogInserts: [], }); - await loadData(); stopLoading({ type: "success", message: "已复制到今天" }); } catch { stopLoading({ type: "error", message: "复制失败" }); } }, - [familyId, allPlanItems, today, startLoading, stopLoading, userId], + [familyId, allItems, today, savePlanBundle, loadData, startLoading, stopLoading], ); - const toggleTimeline = useCallback((planId: number) => { - setExpandedTimelines((prev) => { - const next = new Set(prev); - if (next.has(planId)) next.delete(planId); - else next.add(planId); - return next; - }); - }, []); + // ======== 空状态 ======== - // ------- 渲染 ------- - - const renderTimelineEntry = useCallback( - (entry: TimelineEntry, planId: number) => { - const isRemark = entry.type === MealPlanLogType.Remark; - const isOwnRemark = isRemark && entry.create_by === userId; - - let label = `${entry.userNickname} ${getLogLabel(entry.type)}`; - if (entry.type === MealPlanLogType.AddDish || entry.type === MealPlanLogType.RemoveDish) { - label += ` ${entry.dishName ?? "一道菜"}`; - } - - return ( - - - - - {label} - {isRemark && entry.content ? `:「${entry.content}」` : ""} - - {isOwnRemark && ( - handleDeleteRemark(planId, entry)} - > - - - )} - - - ); - }, - [handleDeleteRemark, userId], - ); - - const getCookName = useCallback( - (cookById?: number | null) => { - if (!cookById) return null; - return members.find((m) => m.user_id === cookById)?.nickname ?? `用户${cookById}`; - }, - [members], - ); - - if (slots.length === 0 && !pageLoading) { + if (slots.length === 0 && !refreshing) { return ( - + @@ -454,227 +284,279 @@ export default function MealHomePage() { ); } + // ======== 主界面 ======== + return ( runRefresh(loadData)} + refreshing={refreshing} showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }} > - {/* ======== 日期切换栏 ======== */} + {/* 日期选择器 */} setPlanDate(today)} > - + 今天 setPlanDate(tomorrow)} > - + 明天 setShowDate(true)} > - - {isToday || isTomorrow ? planDate : planDate} - + {planDate} - {/* ======== 餐段卡片 ======== */} + {/* 各餐段菜单 */} {slots.map((slot) => { - const data = slotPlanMap.get(slot.id); - const hasPlan = !!data; - const timeline = data?.timeline ?? []; - const isTimelineExpanded = expandedTimelines.has(data?.plan.id ?? 0); - const cookName = getCookName(data?.plan.cook_by); + const plan = plans.find((p) => p.meal_slot_id === slot.id); + const pitems = plan + ? (itemsByPlan.get(plan.id) ?? []).map((item) => ({ + ...item, + dish: dishes.find((d) => d.id === item.dish_id), + })) + : []; + const hasPlan = !!plan; + const cookName = getCookName(plan?.cook_by); + const isPlanning = plan?.status === MealPlanStatus.Planning; + const isConfirmed = plan?.status === MealPlanStatus.Confirmed; + const isCompleted = plan?.status === MealPlanStatus.Completed; + const isLocked = false; return ( - - {/* 头部 */} - - - - {slot.name} + + {/* 卡片头部 */} + + + + + {slot.name} + + + {/* 做饭人 */} {cookName && ( - + - {cookName} + + {cookName} + + + )} + + {/* 状态标签(三态) */} + {hasPlan && ( + + {isCompleted + ? + : isConfirmed + ? + : } + + {isCompleted ? "已完成" : isConfirmed ? "已确认" : "进行中"} + )} - - router.push( - `/meal/plan/edit?planDate=${planDate}&mealSlotId=${slot.id}` as any, - ) - } - > - - + + {/* 创建 / 添加按钮 */} + {hasPlan + ? ( + router.push(`/meal/plan/detail/${plan.id}` as any)} + > + + + ) + : ( + handleCreatePlan(slot.id)} + disabled={creatingSlotId === slot.id} + > + + + )} - {/* 菜品列表 */} - {hasPlan && data.items.length > 0 ? ( - - {data.items.map((item: any) => ( - { - if (item.dish?.id) { - router.push(`/meal/dish/detail/${item.dish.id}` as any); - } - }} - > - - {item.dish && ( - - )} - - - - {item.dish ? item.dish.name : "未知菜品"} - - {item.quantity > 1 && ( - x{item.quantity} - )} - + {/* 菜品列表 / 空状态 */} + {hasPlan && pitems.length > 0 + ? ( + + {pitems.map((item: any) => ( { - e.stopPropagation(); - const dishName = item.dish?.name ?? ""; - handleRemoveDish(data.plan.id, item.id, dishName); + key={item.id} + className="flex-row items-center gap-3 mb-3" + activeOpacity={0.7} + onPress={() => { + if (item.dish?.id) { + router.push(`/meal/dish/detail/${item.dish.id}` as any); + } }} > - + + {item.dish && ( + + )} + + + + {item.dish?.name ?? "未知"} + + {item.quantity > 1 && ( + x{item.quantity} + )} + + {/* 仅 planning 状态可以快速移除 */} + {isPlanning && ( + { + e.stopPropagation(); + handleRemoveDish(plan.id, item.id, item.dish?.name ?? ""); + }} + > + × + + )} + ))} + + {/* 底部操作栏 */} + router.push(`/meal/plan/detail/${plan.id}` as any)} + > + 查看菜单详情 + - ))} - - {/* 备注 */} - {data.plan.remark ? ( - - - {data.plan.remark} + + ) + : hasPlan && pitems.length === 0 + ? ( + + 暂无菜品 + {isPlanning && ( handleRemoveRemarkPlan(data.plan.id)} + className="mt-2 flex-row items-center gap-1 px-4 py-2 rounded-full bg-accent active:opacity-80" + onPress={() => router.push(`/meal/plan/detail/${plan.id}` as any)} > - + + 添加菜品 - - ) : null} - - ) : ( - - 暂未安排 - - router.push( - `/meal/plan/edit?planDate=${planDate}&mealSlotId=${slot.id}` as any, - ) - } - > - - 添加菜品 - - - )} + )} + {isLocked && ( + router.push(`/meal/plan/detail/${plan.id}` as any)} + > + 查看菜单详情 + + )} + + ) + : ( + /* 无菜单 */ + + 暂未安排 + handleCreatePlan(slot.id)} + disabled={creatingSlotId === slot.id} + > + + 创建空菜单 + + + )} - {/* 时间轴 */} - {timeline.length > 0 && hasPlan && ( - <> - toggleTimeline(data.plan.id)} - > - - {isTimelineExpanded ? "收起" : `${timeline.length} 条记录`} - - - {isTimelineExpanded && ( - - {timeline.map((entry) => renderTimelineEntry(entry, data.plan.id))} - - )} - - )} - - {/* 历史日期:复制到今天 */} + {/* 复制到「今天」(非今天日期且有菜单时) */} {!isToday && hasPlan && ( handleCopyPlan(data.plan.id, slot.id)} + onPress={() => handleCopyPlan(plan.id, slot.id)} > - 复制到「今天」 + 复制到「今天」 )} ); })} - {slots.length > 0 && - !pageLoading && - slots.every((slot) => !slotPlanMap.has(slot.id)) && ( - - - - )} - {/* ======== 日期选择 BottomSheet ======== */} - - - - - 选择日期 - - 日期 (YYYY-MM-DD) - - - - - - - - - + {/* 日期选择 DateTimePicker */} + {showDate && ( + { + setShowDate(false); + if (date) setPlanDate(dayjs(date).format("YYYY-MM-DD")); + }} + /> + )} - + ); } 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 0990530b..c6fa675d 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,45 +1,19 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Text, TouchableOpacity, View } from "react-native"; -import { Button, Dialog, Input, Label, TextField } from "heroui-native"; -import { - ChevronDownIcon, - ChevronRightIcon, - ChevronUpIcon, - ClockIcon, - FoldersIcon, - GripVerticalIcon, - PlusIcon, - RotateCcwIcon, - TagIcon, - Trash2Icon, -} from "lucide-react-native"; +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 BottomSheet from "@/components/BottomSheet"; -import EmptyState from "@/components/EmptyStatus"; import LayoutView from "@/components/layout/view"; import { LoadingOverlay } from "@/components/LoadingOverlay"; -import { useLoading } from "@/hooks/useLoading"; +import { useListLoading, useLoading } from "@/hooks/useLoading"; import { getUniqueIdApi } from "@/request/api"; import { useFamilyStore, useMealStore } from "@/store"; import { MealCategory, MealSlot, MealStatus, MealTag } from "@/types/meal"; -// ------- 表单类型 ------- +type ManageTab = "category" | "tag" | "slot"; -interface NameForm { - name: string; -} - -interface TagForm { - name: string; - color: string; -} - -const COLORS = [ - "#EF4444", "#F97316", "#F59E0B", "#84CC16", "#22C55E", - "#14B8A6", "#06B6D4", "#3B82F6", "#6366F1", "#8B5CF6", - "#A855F7", "#D946EF", "#EC4899", "#F43F5E", "#78716C", -]; - -// ------- 页面 ------- +const COLORS = ["#EF4444", "#F97316", "#F59E0B", "#84CC16", "#22C55E", "#14B8A6", "#06B6D4", "#3B82F6", "#6366F1", "#8B5CF6", "#A855F7", "#D946EF", "#EC4899", "#F43F5E", "#78716C"]; +const TAB_DATA: { key: ManageTab; label: string }[] = [{ key: "category", label: "分类" }, { key: "tag", label: "标签" }, { key: "slot", label: "餐段" }]; export default function MealManagePage() { const familyId = useFamilyStore((s) => s.getFamilyId()); @@ -52,722 +26,226 @@ export default function MealManagePage() { const saveCategoryChanges = useMealStore((s) => s.saveCategoryChanges); const saveTagChanges = useMealStore((s) => s.saveTagChanges); const saveSlotChanges = useMealStore((s) => s.saveSlotChanges); + const { refreshing, runInitial, runRefresh } = useListLoading(); const { isLoading, startLoading, stopLoading } = useLoading(); - // ---- 分类 ---- - const [showCategorySheet, setShowCategorySheet] = useState(false); - const [categoryForm, setCategoryForm] = useState({ name: "" }); - const [editingCategory, setEditingCategory] = useState(null); - const [categoryDeleteConfirm, setCategoryDeleteConfirm] = useState(null); + const [tab, setTab] = useState("category"); - // ---- 标签 ---- - const [showTagSheet, setShowTagSheet] = useState(false); - const [tagForm, setTagForm] = useState({ name: "", color: "" }); + // 分类 + const [catForm, setCatForm] = useState(""); + const [editingCat, setEditingCat] = useState(null); + const [showCatSheet, setShowCatSheet] = useState(false); + const [catDelete, setCatDelete] = useState(null); + // 标签分组 + const [groupForm, setGroupForm] = useState(""); + const [editingGroup, setEditingGroup] = useState(null); + const [showGroupSheet, setShowGroupSheet] = useState(false); + const [groupDelete, setGroupDelete] = useState(null); + // 子标签 + const [tagForm, setTagForm] = useState({ name: "", color: "" }); const [editingTag, setEditingTag] = useState(null); - const [tagDeleteConfirm, setTagDeleteConfirm] = useState(null); - const [editingParentId, setEditingParentId] = useState(null); - const [expandedParents, setExpandedParents] = useState>(new Set()); - - // ---- 餐段 ---- - const [showSlotSheet, setShowSlotSheet] = useState(false); - const [slotForm, setSlotForm] = useState({ name: "" }); + const [showTagSheet, setShowTagSheet] = useState(false); + const [tagDelete, setTagDelete] = useState(null); + const [activeGroupId, setActiveGroupId] = useState(null); + // 餐段 + const [slotForm, setSlotForm] = useState(""); const [editingSlot, setEditingSlot] = useState(null); - const [slotDeleteConfirm, setSlotDeleteConfirm] = useState(null); + const [showSlotSheet, setShowSlotSheet] = useState(false); + const [slotDelete, setSlotDelete] = useState(null); + + const catInputRef = useRef(null); + const groupInputRef = useRef(null); + const tagInputRef = useRef(null); + const slotInputRef = useRef(null); + + useEffect(() => { if (showCatSheet) setTimeout(() => catInputRef.current?.focus(), 250); }, [showCatSheet]); + useEffect(() => { if (showGroupSheet) setTimeout(() => groupInputRef.current?.focus(), 250); }, [showGroupSheet]); + useEffect(() => { if (showTagSheet) setTimeout(() => tagInputRef.current?.focus(), 250); }, [showTagSheet]); + useEffect(() => { if (showSlotSheet) setTimeout(() => slotInputRef.current?.focus(), 250); }, [showSlotSheet]); - // 数据分组 const parentTags = useMemo(() => tags.filter((t) => !t.parent_id), [tags]); - - const childTagsByParent = useMemo(() => { - const map = new Map(); - tags - .filter((t) => !!t.parent_id) - .forEach((tag) => { - const group = map.get(tag.parent_id!) ?? []; - group.push(tag); - map.set(tag.parent_id!, group); - }); - return map; - }, [tags]); - - // ------- 数据加载 ------- + const tabCount = useMemo(() => ({ category: categories.length, tag: parentTags.length, slot: slots.length }), [categories.length, parentTags.length, slots.length]); const loadData = useCallback(async () => { if (!familyId) return; await Promise.all([fetchCategories(familyId), fetchTags(familyId), fetchSlots(familyId)]); - }, [familyId, fetchCategories, fetchSlots, fetchTags]); - - useEffect(() => { - if (!familyId) return; - loadData(); - }, [familyId]); - - // ------- 分类操作 ------- - - const handleOpenCategoryForm = useCallback((category?: MealCategory) => { - if (category) { - setCategoryForm({ name: category.name }); - setEditingCategory(category); - } else { - setCategoryForm({ name: "" }); - setEditingCategory(null); - } - setShowCategorySheet(true); - }, []); - - const handleSaveCategory = useCallback(async () => { - if (!familyId || !categoryForm.name.trim()) return; - startLoading("保存中..."); + }, [familyId, fetchCategories, fetchTags, fetchSlots]); + useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]); + // 分类 + const openCat = useCallback((c?: MealCategory) => { setCatForm(c?.name ?? ""); setEditingCat(c ?? null); setShowCatSheet(true); }, []); + const saveCat = useCallback(async () => { + if (!familyId || !catForm.trim()) return; startLoading("保存中..."); try { - if (editingCategory) { - await saveCategoryChanges({ - updates: [{ id: editingCategory.id, name: categoryForm.name.trim() }], - }); - } else { - const id = (await getUniqueIdApi(1)).data?.[0]; - if (!id) { - stopLoading({ type: "error", message: "获取 ID 失败" }); - return; - } - await saveCategoryChanges({ - inserts: [{ id, family_id: familyId, name: categoryForm.name.trim(), status: MealStatus.Enabled }], - }); - } - setShowCategorySheet(false); - await loadData(); - stopLoading({ type: "success", message: editingCategory ? "已更新" : "已添加" }); - } catch { - stopLoading({ type: "error", message: "保存失败" }); - } - }, [categoryForm.name, editingCategory, familyId, loadData, saveCategoryChanges, startLoading, stopLoading]); + let ok = false; + if (editingCat) ok = await saveCategoryChanges({ updates: [{ id: editingCat.id, name: catForm.trim() }] }); + else { const id = (await getUniqueIdApi(1)).data?.[0]; if (!id) { stopLoading({ type: "error", message: "ID 失败" }); return; } ok = await saveCategoryChanges({ inserts: [{ id, family_id: familyId, name: catForm.trim(), sort_number: categories.length, status: MealStatus.Enabled }] }); } + if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } + setShowCatSheet(false); await loadData(); stopLoading({ type: "success", message: editingCat ? "已更新" : "已添加" }); + } catch { stopLoading({ type: "error", message: "保存失败" }); } + }, [catForm, editingCat, familyId, loadData, saveCategoryChanges, startLoading, stopLoading, categories.length]); + const delCat = useCallback(async () => { if (!catDelete) return; startLoading("删除中..."); try { const ok = await saveCategoryChanges({ deletes: [{ id: catDelete.id }] }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } setCatDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); } }, [catDelete, loadData, saveCategoryChanges, startLoading, stopLoading]); - const handleDeleteCategory = useCallback(async () => { - if (!categoryDeleteConfirm) return; - startLoading("删除中..."); + // 分组 + const openGroup = useCallback((g?: MealTag) => { setGroupForm(g?.name ?? ""); setEditingGroup(g ?? null); setShowGroupSheet(true); }, []); + const saveGroup = useCallback(async () => { + if (!familyId || !groupForm.trim()) return; startLoading("保存中..."); try { - await saveCategoryChanges({ deletes: [{ id: categoryDeleteConfirm.id }] }); - setCategoryDeleteConfirm(null); - await loadData(); - stopLoading({ type: "success", message: "已删除" }); - } catch { - stopLoading({ type: "error", message: "删除失败" }); - } - }, [categoryDeleteConfirm, loadData, saveCategoryChanges, startLoading, stopLoading]); - - // ------- 标签操作 ------- - - const handleOpenTagForm = useCallback((parentId: number | null, tag?: MealTag) => { - setEditingParentId(parentId); - if (tag) { - setTagForm({ name: tag.name, color: tag.color ?? "" }); - setEditingTag(tag); - } else { - setTagForm({ name: "", color: "" }); - setEditingTag(null); - } - setShowTagSheet(true); - }, []); - - const handleSaveTag = useCallback(async () => { - if (!familyId || !tagForm.name.trim()) return; - startLoading("保存中..."); + let ok = false; + if (editingGroup) ok = await saveTagChanges({ updates: [{ id: editingGroup.id, name: groupForm.trim() }] }); + else { const id = (await getUniqueIdApi(1)).data?.[0]; if (!id) { stopLoading({ type: "error", message: "ID 失败" }); return; } ok = await saveTagChanges({ inserts: [{ id, family_id: familyId, name: groupForm.trim(), parent_id: null, color: "", sort_number: parentTags.length, status: MealStatus.Enabled }] }); } + if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } + setShowGroupSheet(false); await loadData(); stopLoading({ type: "success", message: editingGroup ? "已更新" : "已添加" }); + } catch { stopLoading({ type: "error", message: "保存失败" }); } + }, [editingGroup, familyId, groupForm, loadData, saveTagChanges, startLoading, stopLoading, parentTags.length]); + const delGroup = useCallback(async () => { if (!groupDelete) return; startLoading("删除中..."); try { const c = tags.filter((t) => t.parent_id === groupDelete.id).map((t) => ({ id: t.id })); const ok = await saveTagChanges({ deletes: [{ id: groupDelete.id }, ...c] }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } setGroupDelete(null); await loadData(); stopLoading({ type: "success", message: "父标签及子标签已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); } }, [groupDelete, loadData, saveTagChanges, startLoading, stopLoading, tags]); + // 子标签 + const openTag = useCallback((gId: number, t?: MealTag) => { setActiveGroupId(gId); setTagForm({ name: t?.name ?? "", color: t?.color ?? "" }); setEditingTag(t ?? null); setShowTagSheet(true); }, []); + const saveTag = useCallback(async () => { + if (!familyId || !activeGroupId || !tagForm.name.trim()) return; startLoading("保存中..."); try { - if (editingTag) { - await saveTagChanges({ - updates: [{ id: editingTag.id, name: tagForm.name.trim(), color: tagForm.color }], - }); - } else { - const id = (await getUniqueIdApi(1)).data?.[0]; - if (!id) { - stopLoading({ type: "error", message: "获取 ID 失败" }); - return; - } - await saveTagChanges({ - inserts: [{ - id, - family_id: familyId, - name: tagForm.name.trim(), - color: tagForm.color, - parent_id: editingParentId, - status: MealStatus.Enabled, - }], - }); - } - setShowTagSheet(false); - await loadData(); - stopLoading({ type: "success", message: editingTag ? "已更新" : "已添加" }); - } catch { - stopLoading({ type: "error", message: "保存失败" }); - } - }, [editingParentId, editingTag, familyId, loadData, saveTagChanges, startLoading, stopLoading, tagForm]); + let ok = false; + if (editingTag) ok = await saveTagChanges({ updates: [{ id: editingTag.id, name: tagForm.name.trim(), color: tagForm.color }] }); + else { const id = (await getUniqueIdApi(1)).data?.[0]; if (!id) { stopLoading({ type: "error", message: "ID 失败" }); return; } const gtags = tags.filter((t) => t.parent_id === activeGroupId); ok = await saveTagChanges({ inserts: [{ id, family_id: familyId, name: tagForm.name.trim(), color: tagForm.color, sort_number: gtags.length, parent_id: activeGroupId, status: MealStatus.Enabled }] }); } + if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } + setShowTagSheet(false); await loadData(); stopLoading({ type: "success", message: editingTag ? "已更新" : "已添加" }); + } catch { stopLoading({ type: "error", message: "保存失败" }); } + }, [activeGroupId, editingTag, familyId, loadData, saveTagChanges, startLoading, stopLoading, tagForm, tags]); + const delTag = useCallback(async () => { if (!tagDelete) return; startLoading("删除中..."); try { const ok = await saveTagChanges({ deletes: [{ id: tagDelete.id }] }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } setTagDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); } }, [loadData, saveTagChanges, startLoading, stopLoading, tagDelete]); - const handleDeleteTag = useCallback(async () => { - if (!tagDeleteConfirm) return; - startLoading("删除中..."); + // 餐段 + const openSlot = useCallback((s?: MealSlot) => { setSlotForm(s?.name ?? ""); setEditingSlot(s ?? null); setShowSlotSheet(true); }, []); + const saveSlot = useCallback(async () => { + if (!familyId || !slotForm.trim()) return; startLoading("保存中..."); try { - // 如果是父标签,同时删除子标签 - const isParent = !tagDeleteConfirm.parent_id; - const childTags = isParent ? childTagsByParent.get(tagDeleteConfirm.id) ?? [] : []; - const deletes = [{ id: tagDeleteConfirm.id }, ...childTags.map((t) => ({ id: t.id }))]; - await saveTagChanges({ deletes }); - setTagDeleteConfirm(null); - await loadData(); - stopLoading({ type: "success", message: isParent ? "父标签及子标签已删除" : "已删除" }); - } catch { - stopLoading({ type: "error", message: "删除失败" }); - } - }, [childTagsByParent, loadData, saveTagChanges, startLoading, stopLoading, tagDeleteConfirm]); + let ok = false; + if (editingSlot) ok = await saveSlotChanges({ updates: [{ id: editingSlot.id, name: slotForm.trim() }] }); + else { const id = (await getUniqueIdApi(1)).data?.[0]; if (!id) { stopLoading({ type: "error", message: "ID 失败" }); return; } ok = await saveSlotChanges({ inserts: [{ id, family_id: familyId, name: slotForm.trim(), sort_number: slots.length, status: MealStatus.Enabled }] }); } + if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } + setShowSlotSheet(false); await loadData(); stopLoading({ type: "success", message: editingSlot ? "已更新" : "已添加" }); + } catch { stopLoading({ type: "error", message: "保存失败" }); } + }, [editingSlot, familyId, loadData, saveSlotChanges, slotForm, slots.length, startLoading, stopLoading]); + const delSlot = useCallback(async () => { if (!slotDelete) return; startLoading("删除中..."); try { const ok = await saveSlotChanges({ deletes: [{ id: slotDelete.id }] }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } setSlotDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); } }, [loadData, saveSlotChanges, slotDelete, startLoading, stopLoading]); - const toggleParentExpanded = useCallback((parentId: number) => { - setExpandedParents((prev) => { - const next = new Set(prev); - if (next.has(parentId)) next.delete(parentId); - else next.add(parentId); - return next; - }); - }, []); - // ------- 餐段操作 ------- - const handleOpenSlotForm = useCallback((slot?: MealSlot) => { - if (slot) { - setSlotForm({ name: slot.name }); - setEditingSlot(slot); - } else { - setSlotForm({ name: "" }); - setEditingSlot(null); - } - setShowSlotSheet(true); - }, []); - - const handleSaveSlot = useCallback(async () => { - if (!familyId || !slotForm.name.trim()) return; - startLoading("保存中..."); - - try { - if (editingSlot) { - await saveSlotChanges({ - updates: [{ id: editingSlot.id, name: slotForm.name.trim() }], - }); - } else { - const id = (await getUniqueIdApi(1)).data?.[0]; - if (!id) { - stopLoading({ type: "error", message: "获取 ID 失败" }); - return; - } - await saveSlotChanges({ - inserts: [{ id, family_id: familyId, name: slotForm.name.trim(), sort_number: slots.length, status: MealStatus.Enabled }], - }); - } - setShowSlotSheet(false); - await loadData(); - stopLoading({ type: "success", message: editingSlot ? "已更新" : "已添加" }); - } catch { - stopLoading({ type: "error", message: "保存失败" }); - } - }, [editingSlot, familyId, loadData, saveSlotChanges, slotForm.name, slots.length, startLoading, stopLoading]); - - const handleToggleSlotStatus = useCallback(async (slot: MealSlot) => { - startLoading("操作中..."); - try { - const newStatus = slot.status === MealStatus.Enabled ? MealStatus.Disabled : MealStatus.Enabled; - await saveSlotChanges({ updates: [{ id: slot.id, status: newStatus }] }); - await loadData(); - stopLoading({ type: "success", message: newStatus === MealStatus.Enabled ? "已启用" : "已停用" }); - } catch { - stopLoading({ type: "error", message: "操作失败" }); - } - }, [loadData, saveSlotChanges, startLoading, stopLoading]); - - const handleMoveSlot = useCallback(async (index: number, direction: "up" | "down") => { - const targetIndex = direction === "up" ? index - 1 : index + 1; - if (targetIndex < 0 || targetIndex >= slots.length) return; - - const current = slots[index]; - const target = slots[targetIndex]; - - await saveSlotChanges({ - updates: [ - { id: current.id, sort_number: target.sort_number }, - { id: target.id, sort_number: current.sort_number }, - ], - }); - await loadData(); - }, [loadData, saveSlotChanges, slots]); - - const handleDeleteSlot = useCallback(async () => { - if (!slotDeleteConfirm) return; - startLoading("删除中..."); - try { - await saveSlotChanges({ deletes: [{ id: slotDeleteConfirm.id }] }); - setSlotDeleteConfirm(null); - await loadData(); - stopLoading({ type: "success", message: "已删除" }); - } catch { - stopLoading({ type: "error", message: "删除失败" }); - } - }, [loadData, saveSlotChanges, slotDeleteConfirm, startLoading, stopLoading]); - - // ------- 渲染 ------- - - const renderCategoryItem = useCallback( - (item: MealCategory) => ( - handleOpenCategoryForm(item)} - > - - - - - {item.name} - - - { - e.stopPropagation(); - setCategoryDeleteConfirm(item); - }} - > - - - - - - ), - [handleOpenCategoryForm], - ); - - const renderChildTagItem = useCallback( - (item: MealTag) => ( - handleOpenTagForm(item.parent_id!, item)} - > - - - {item.name} - - - { - e.stopPropagation(); - setTagDeleteConfirm(item); - }} - > - - - - - - ), - [handleOpenTagForm], - ); - - const renderParentTag = useCallback( - (parent: MealTag) => { - const children = childTagsByParent.get(parent.id) ?? []; - const isExpanded = expandedParents.has(parent.id); - - return ( - - toggleParentExpanded(parent.id)} - > - - - - - {parent.name} - - - {children.length} 个子标签 - { - e.stopPropagation(); - handleOpenTagForm(parent.id); - }} - > - - - - - - - {isExpanded && ( - <> - {children.map(renderChildTagItem)} - handleOpenTagForm(parent.id)} - > - - 添加子标签 - - - )} - - ); - }, - [childTagsByParent, expandedParents, handleOpenTagForm, renderChildTagItem, toggleParentExpanded], - ); - - const renderSlotItem = useCallback( - (item: MealSlot, index: number) => { - const isActive = item.status === MealStatus.Enabled; - const isFirst = index === 0; - const isLast = index === slots.length - 1; - - return ( - handleOpenSlotForm(item)} - > - - {/* 排序按钮 */} - - { - e.stopPropagation(); - handleMoveSlot(index, "up"); - }} - > - - - { - e.stopPropagation(); - handleMoveSlot(index, "down"); - }} - > - - - - - {isActive ? ( - - ) : ( - - )} - - - - {item.name} - - - {isActive ? "已启用" : "已停用"} - - - - - { - e.stopPropagation(); - handleToggleSlotStatus(item); - }} - > - {isActive ? "停用" : "启用"} - - { - e.stopPropagation(); - setSlotDeleteConfirm(item); - }} - > - - - - - - ); - }, - [handleMoveSlot, handleOpenSlotForm, handleToggleSlotStatus, slots.length], - ); + const getTagsByGroup = useCallback((gId: number) => tags.filter((t) => t.parent_id === gId), [tags]); return ( - - {/* ======== 分类管理 ======== */} - - 分类管理 - - - + runRefresh(loadData)} showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }}> + {/* 头部 */} + + 菜品规则分类与标签 + runRefresh(loadData)}> - - {categories.length > 0 ? ( - categories.map(renderCategoryItem) - ) : ( - - - - )} - handleOpenCategoryForm()} - > - - 添加分类 - - - - {/* ======== 标签管理 ======== */} - 标签管理 - - - - 父标签 - handleOpenTagForm(null)} - > - - 添加父标签 + {/* Tab 切换 */} + + {TAB_DATA.map(({ key, label }) => ( + setTab(key)}> + {label} ({tabCount[key]}) - - - {parentTags.length > 0 ? ( - parentTags.map(renderParentTag) - ) : ( - - - - )} + ))} - {/* 无父标签的子标签(待归类标签) */} - {parentTags.length === 0 && ( - - )} - - {/* ======== 餐段管理 ======== */} - 餐段管理 - - - {slots.length > 0 ? ( - slots.map((slot, idx) => renderSlotItem(slot, idx)) - ) : ( - - + {/* 分类 */} + {tab === "category" && ( + + + 分类{categories.length} 个 + openCat()}>添加 - )} - handleOpenSlotForm()} - > - - 添加餐段 - - - - - 餐段建议:早餐、午餐、晚餐、夜宵等 - - - - {/* ======== 分类 BottomSheet ======== */} - - - - - - {editingCategory ? "编辑分类" : "新增分类"} - - - - setCategoryForm({ name: v })} - placeholder="例如:家常菜" - /> - - - - - - - - - - {/* ======== 标签 BottomSheet ======== */} - - - - - - {editingTag ? "编辑标签" : editingParentId ? "新增子标签" : "新增父标签"} - - - - setTagForm((prev) => ({ ...prev, name: v }))} - placeholder="例如:微辣" - /> - - - - - {COLORS.map((c) => ( - setTagForm((prev) => ({ ...prev, color: c }))} - /> + {categories.length > 0 ? ( + + {categories.map((c) => ( + + openCat(c)}> + {c.name} + { e.stopPropagation(); setCatDelete(c); }}> + + ))} - - setTagForm((prev) => ({ ...prev, color: "" }))} - > - 无颜色 - - setTagForm((prev) => ({ ...prev, color: v }))} - placeholder="#EF4444" - /> + ) : 暂无分类,点击右上角添加} + + )} + + {/* 标签 */} + {tab === "tag" && ( + + + 标签{parentTags.length} 组 + openGroup()}>新建分组 + + {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)}> + + 添加 + + + + ); + })} - - - - - - - - + ) : 暂无标签分组,先新建分组再添加标签} + + )} - {/* ======== 餐段 BottomSheet ======== */} - - - - - - {editingSlot ? "编辑餐段" : "新增餐段"} - - - - setSlotForm({ name: v })} - placeholder="例如:早餐" - /> - - - - + {/* 餐段 */} + {tab === "slot" && ( + + + 餐段{slots.length} 个 + openSlot()}>添加 - - - + {slots.length > 0 ? ( + + {slots.map((s, i) => { + return ( + openSlot(s)}> + + + {s.name} + + { e.stopPropagation(); setSlotDelete(s); }}> + + + ); + })} + + ) : 暂无餐段,点击右上角添加} + 餐段建议:早餐、午餐、晚餐、夜宵等 + + )} + - {/* ======== 删除确认弹窗 ======== */} - {/* 分类 */} - { if (!v) setCategoryDeleteConfirm(null); }}> - - - - 删除分类 - - 确认删除分类「{categoryDeleteConfirm?.name}」吗?已归类的菜品不会受影响。 - - - - - - - - + {/* Sheets */} + {editingCat ? "编辑分类" : "添加分类"} + {editingGroup ? "编辑分组" : "新建分组"} + {editingTag ? "编辑标签" : "添加标签"} setTagForm((p) => ({ ...p, name: v }))} placeholder="例如:微辣" ref={tagInputRef} />{COLORS.map((c) => setTagForm((p) => ({ ...p, color: c }))} />)} setTagForm((p) => ({ ...p, color: "" }))}>无颜色 setTagForm((p) => ({ ...p, color: v }))} placeholder="#EF4444" /> + {editingSlot ? "编辑餐段" : "添加餐段"} - {/* 标签 */} - { if (!v) setTagDeleteConfirm(null); }}> - - - - 删除标签 - - {tagDeleteConfirm && !tagDeleteConfirm.parent_id - ? `确认删除父标签「${tagDeleteConfirm.name}」吗?其下的所有子标签也会被删除。` - : `确认删除标签「${tagDeleteConfirm?.name}」吗?`} - - - - - - - - - - {/* 餐段 */} - { if (!v) setSlotDeleteConfirm(null); }}> - - - - 删除餐段 - - 确认删除餐段「{slotDeleteConfirm?.name}」吗?历史餐食安排不受影响。 - - - - - - - - + {/* Dialogs */} + { if (!v) setCatDelete(null); }}>删除分类确认删除「{catDelete?.name}」吗?菜品不受影响。 + { if (!v) setGroupDelete(null); }}>删除分组确认删除「{groupDelete?.name}」吗?分组内的标签也会被删除。 + { if (!v) setTagDelete(null); }}>删除标签确认删除「{tagDelete?.name}」吗? + { if (!v) setSlotDelete(null); }}>删除餐段确认删除「{slotDelete?.name}」吗?历史餐食不受影响。 diff --git a/code/app/app-rn/src/app/meal/dish/detail/[id]/index.tsx b/code/app/app-rn/src/app/meal/dish/detail/[id]/index.tsx index 21eae6dc..1723bcdc 100644 --- a/code/app/app-rn/src/app/meal/dish/detail/[id]/index.tsx +++ b/code/app/app-rn/src/app/meal/dish/detail/[id]/index.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, View } from "react-native"; import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; -import { Button, Dialog, useThemeColor } from "heroui-native"; -import { PencilIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native"; +import { Button, Dialog } from "heroui-native"; +import { PencilIcon, Trash2Icon } from "lucide-react-native"; import PageLayout from "@/layouts/PageLayout"; import LayoutView from "@/components/layout/view"; import MealDishImage from "@/components/meal/MealDishImage"; @@ -12,19 +12,13 @@ import { useLoading } from "@/hooks/useLoading"; import { useFamilyStore, useMealStore } from "@/store"; import { MealDishDifficulty, MealStatus } from "@/types/meal"; -const DIFFICULTY_LABELS: Record = { - [MealDishDifficulty.None]: "", - [MealDishDifficulty.Easy]: "简单", - [MealDishDifficulty.Normal]: "普通", - [MealDishDifficulty.Hard]: "复杂", -}; +const DIFF_LABELS: Record = { [MealDishDifficulty.None]: "", [MealDishDifficulty.Easy]: "简单", [MealDishDifficulty.Normal]: "普通", [MealDishDifficulty.Hard]: "复杂" }; export default function MealDishDetail() { - const router = useRouter(); + const r = useRouter(); const { id } = useLocalSearchParams<{ id?: string }>(); const dishId = id ? Number(id) : 0; const familyId = useFamilyStore((s) => s.getFamilyId()); - const accentColor = useThemeColor("accent"); const dishes = useMealStore((s) => s.dishes); const categories = useMealStore((s) => s.categories); const tags = useMealStore((s) => s.tags); @@ -40,257 +34,63 @@ export default function MealDishDetail() { const saveDishChanges = useMealStore((s) => s.saveDishChanges); const { isLoading, startLoading, stopLoading } = useLoading(); const [pageLoading, setPageLoading] = useState(true); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showDel, setShowDel] = useState(false); const item = useMemo(() => dishes.find((d) => d.id === dishId) ?? null, [dishId, dishes]); - - const categoryName = useMemo(() => { - if (!item?.category_id) return "未分类"; - return categories.find((c) => c.id === item.category_id)?.name ?? "未分类"; - }, [categories, item?.category_id]); - - const selectedTags = useMemo(() => { - const relationTagIds = new Set( - dishTags.filter((r) => r.dish_id === dishId).map((r) => r.tag_id), - ); - return tags.filter((t) => relationTagIds.has(t.id)); - }, [dishId, dishTags, tags]); - - const sortedSteps = useMemo( - () => [...dishSteps].sort((a, b) => a.step_number - b.step_number), - [dishSteps], - ); + const catName = useMemo(() => { if (!item?.category_id) return "未分类"; return categories.find((c) => c.id === item.category_id)?.name ?? "未分类"; }, [categories, item?.category_id]); + const selTags = useMemo(() => { const ids = new Set(dishTags.filter((r) => r.dish_id === dishId).map((r) => r.tag_id)); return tags.filter((t) => ids.has(t.id)); }, [dishId, dishTags, tags]); + const sortedSteps = useMemo(() => [...dishSteps].sort((a, b) => a.step_number - b.step_number), [dishSteps]); const loadData = useCallback(async () => { - if (!familyId || !dishId) return; - setPageLoading(true); - try { - await Promise.all([ - fetchDishes(familyId), - fetchCategories(familyId), - fetchTags(familyId), - fetchDishTags(familyId, dishId), - fetchDishIngredients(familyId, dishId), - fetchDishSteps(familyId, dishId), - ]); - } finally { - setPageLoading(false); - } + if (!familyId || !dishId) return; setPageLoading(true); + try { await Promise.all([fetchDishes(familyId), fetchCategories(familyId), fetchTags(familyId), fetchDishTags(familyId, dishId), fetchDishIngredients(familyId, dishId), fetchDishSteps(familyId, dishId)]); } + finally { setPageLoading(false); } }, [familyId, dishId, fetchCategories, fetchDishIngredients, fetchDishSteps, fetchDishTags, fetchDishes, fetchTags]); + useEffect(() => { loadData(); }, [loadData]); + useFocusEffect(useCallback(() => { if (familyId && dishId) loadData(); }, [familyId, loadData, dishId])); - useEffect(() => { - loadData(); - }, [loadData]); - - useFocusEffect( - useCallback(() => { - if (!familyId || !dishId) return; - loadData(); - }, [familyId, loadData, dishId]), - ); - - const handleDeleteConfirm = useCallback(async () => { - if (!familyId || !item || isLoading) return; - setShowDeleteDialog(false); - startLoading("停用中..."); - try { - const ok = await saveDishChanges({ - updates: [{ id: item.id, status: MealStatus.Disabled }], - }); - if (!ok) { - stopLoading({ type: "error", message: "停用失败" }); - return; - } - stopLoading({ type: "success", message: "已停用" }); - router.replace("/meal/dishes" as any); - } catch { - stopLoading({ type: "error", message: "停用失败" }); - } - }, [familyId, isLoading, item, router, saveDishChanges, startLoading, stopLoading]); + const handleDelete = useCallback(async () => { + if (!familyId || !item || isLoading) return; setShowDel(false); startLoading("删除中..."); + try { const ok = await saveDishChanges({ deletes: [{ id: item.id }] }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } stopLoading({ type: "success", message: "已删除" }); r.replace("/meal/dishes" as any); } + catch { stopLoading({ type: "error", message: "删除失败" }); } + }, [familyId, isLoading, item, r, saveDishChanges, startLoading, stopLoading]); return ( - + - - {!item && !pageLoading ? ( - - ) : ( - item && ( - <> - - - - - - {item.name} - - - - {categoryName} - - {!!item.difficulty && item.difficulty > 0 && ( - - - {DIFFICULTY_LABELS[item.difficulty]} - - - )} - {!!item.cooking_time_minutes && item.cooking_time_minutes > 0 && ( - - 约{item.cooking_time_minutes}分钟 - - )} - - - + + {!item && !pageLoading ? : item && (<> + - - {/* 标签 */} - {selectedTags.length > 0 && ( - - 标签 - - {selectedTags.map((tag) => ( - - - {tag.name} - - - ))} - - - )} + + {item.name} - {/* 食材摘要 */} - {!!item.ingredient_summary && ( - - 主要食材 - {item.ingredient_summary} - - )} + + {catName === "未分类" ? 未分类 : {catName}} + {!!item.difficulty && item.difficulty > 0 && {DIFF_LABELS[item.difficulty]}} + {!!item.cooking_time_minutes && item.cooking_time_minutes > 0 && 约{item.cooking_time_minutes}分钟} + {selTags.map((t) => {t.name})} + + - {/* 食材明细 */} - {dishIngredients.length > 0 && ( - - 食材明细 - - {dishIngredients.map((ing) => ( - - - {ing.name} - - {[ing.quantity ? `${ing.quantity}` : "", ing.unit, ing.spec] - .filter(Boolean) - .join(" ")} - - - ))} - - - )} + {!!item.ingredient_summary && 主要食材{item.ingredient_summary}} - {/* 做法步骤 */} - {sortedSteps.length > 0 && ( - - 做法步骤 - - {sortedSteps.map((step) => ( - - - {step.step_number} - - - {!!step.image_file_key && ( - - )} - {!!step.description && ( - {step.description} - )} - - - ))} - - - )} + {dishIngredients.length > 0 && 食材明细{dishIngredients.map((ing) => {ing.name}{[ing.quantity ? `${ing.quantity}` : "", ing.unit, ing.spec].filter(Boolean).join(" ")})}} - {/* 备注 */} - {!!item.remark && ( - - 备注 - {item.remark} - - )} - - - ) - )} + {sortedSteps.length > 0 && 做法步骤{sortedSteps.map((step) => {step.step_number}{!!step.image_file_key && }{!!step.description && {step.description}})}} + + {!!item.remark && 备注{item.remark}} + + + + + + )} - {/* 底部操作栏 */} - - - - - - - {/* 停用确认 */} - - - - - 停用菜品 - - 确认停用「{item?.name}」吗?停用后菜品将从列表隐藏,历史餐食安排不受影响。 - - - - - - - - - + 删除菜品确认删除「{item?.name}」吗?此操作不可撤销。 ); 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 4d74a241..b3f93b07 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 @@ -1,957 +1,231 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { ScrollView, Text, TouchableOpacity, View } from "react-native"; +import { Image, Text, TextInput, TouchableOpacity, View } from "react-native"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import ImagePicker from "react-native-image-crop-picker"; import { Button, Input, Label, TextArea, TextField } from "heroui-native"; -import { CheckIcon, ImageIcon, PlusIcon, SaveIcon, Trash2Icon, XIcon } from "lucide-react-native"; +import { ImageIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react-native"; import { toast } from "sonner-native"; import PageLayout from "@/layouts/PageLayout"; import LayoutView from "@/components/layout/view"; -import BottomSheet from "@/components/BottomSheet"; import MealDishImage from "@/components/meal/MealDishImage"; import { LoadingOverlay } from "@/components/LoadingOverlay"; import { useLoading } from "@/hooks/useLoading"; +import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier"; import { getUniqueIdApi } from "@/request/api"; -import { useFamilyStore, useMealStore } from "@/store"; -import { - MealCategory, - MealDish, - MealDishDifficulty, - MealDishIngredient, - MealDishStep, - MealDishTag, - MealStatus, - MealTag, -} from "@/types/meal"; +import { useFamilyStore, useMealStore, useUserStore } from "@/store"; +import { MealDishDifficulty, MealStatus } from "@/types/meal"; -// ------- 表单状态 ------- - -interface DishFormState { +interface DishForm { name: string; imageFileKey: string; + imageFilePath: string; categoryId: number | null; difficulty: MealDishDifficulty; - cookingTimeMinutes: number; - ingredientSummary: string; + cookingTime: string; remark: string; } -const emptyForm: DishFormState = { - name: "", - imageFileKey: "", - categoryId: null, - difficulty: MealDishDifficulty.None, - cookingTimeMinutes: 0, - ingredientSummary: "", - remark: "", -}; - -function createEditForm(item: MealDish): DishFormState { - return { - name: item.name, - imageFileKey: item.image_file_key, - categoryId: item.category_id ?? null, - difficulty: item.difficulty ?? MealDishDifficulty.None, - cookingTimeMinutes: item.cooking_time_minutes ?? 0, - ingredientSummary: item.ingredient_summary ?? "", - remark: item.remark ?? "", - }; +function emptyForm(): DishForm { + return { name: "", imageFileKey: "", imageFilePath: "", categoryId: null, difficulty: MealDishDifficulty.Easy, cookingTime: "", remark: "" }; } -const DIFFICULTY_OPTIONS = [ - { value: MealDishDifficulty.None, label: "不限" }, - { value: MealDishDifficulty.Easy, label: "简单" }, - { value: MealDishDifficulty.Normal, label: "普通" }, - { value: MealDishDifficulty.Hard, label: "复杂" }, -]; - -function toggleId(list: number[], id: number) { - return list.includes(id) ? list.filter((item) => item !== id) : [...list, id]; -} - -// ------- 页面 ------- - export default function MealDishEdit() { const router = useRouter(); const { id } = useLocalSearchParams<{ id?: string }>(); const editId = id ? Number(id) : 0; const isEdit = editId > 0; + const familyId = useFamilyStore((s) => s.getFamilyId()); - const dishes = useMealStore((s) => s.dishes); + const userId = useUserStore((s) => s.getUserId()); const categories = useMealStore((s) => s.categories); - const tags = useMealStore((s) => s.tags); - const dishTags = useMealStore((s) => s.dishTags); - const dishIngredients = useMealStore((s) => s.dishIngredients); - const dishSteps = useMealStore((s) => s.dishSteps); const fetchDishes = useMealStore((s) => s.fetchDishes); const fetchCategories = useMealStore((s) => s.fetchCategories); - const saveCategoryChanges = useMealStore((s) => s.saveCategoryChanges); - const saveTagChanges = useMealStore((s) => s.saveTagChanges); - const fetchTags = useMealStore((s) => s.fetchTags); - const fetchDishTags = useMealStore((s) => s.fetchDishTags); - const fetchDishIngredients = useMealStore((s) => s.fetchDishIngredients); - const fetchDishSteps = useMealStore((s) => s.fetchDishSteps); const saveDishBundle = useMealStore((s) => s.saveDishBundle); + const saveDishChanges = useMealStore((s) => s.saveDishChanges); + const saveDishStepChanges = useMealStore((s) => s.saveDishStepChanges); + const fetchDishSteps = useMealStore((s) => s.fetchDishSteps); const uploadDishImage = useMealStore((s) => s.uploadDishImage); - const { isLoading, startLoading, stopLoading } = useLoading(); - - const [form, setForm] = useState(emptyForm); - const [selectedTagIds, setSelectedTagIds] = useState([]); - const [ingredients, setIngredients] = useState([]); - const [steps, setSteps] = useState([]); - const [uploading, setUploading] = useState(false); - const [pageLoading, setPageLoading] = useState(true); - - // 食材 BottomSheet - const [showIngredientSheet, setShowIngredientSheet] = useState(false); - const [ingredientForm, setIngredientForm] = useState>({}); - const [editingIngredientIndex, setEditingIngredientIndex] = useState(-1); - - // 步骤 BottomSheet - const [showStepSheet, setShowStepSheet] = useState(false); - const [stepForm, setStepForm] = useState>({}); - const [editingStepIndex, setEditingStepIndex] = useState(-1); - const [stepUploading, setStepUploading] = useState(false); - const uploadStepImage = useMealStore((s) => s.uploadStepImage); + const { isLoading, startLoading, stopLoading } = useLoading(); + const notifyOp = useModuleChatNotifier(); - // 快速新建分类 - const [showQuickCategorySheet, setShowQuickCategorySheet] = useState(false); - const [quickCategoryName, setQuickCategoryName] = useState(""); + const [form, setForm] = useState(emptyForm); + const [steps, setSteps] = useState<{ key: string; stepNumber: number; description: string; imageFileKey: string; imageFilePath: string; _id?: number }[]>([]); + const [stepsIdsToDelete, setStepsIdsToDelete] = useState([]); + const [uploading, setUploading] = useState(false); + const [pageLoading, setPageLoading] = useState(false); + const nextStepKey = useCallback(() => "s" + Date.now() + "_" + Math.random().toString(36).substr(2, 5), []); + const addStep = useCallback(() => { setSteps((p) => [...p, { key: nextStepKey(), stepNumber: p.length + 1, description: "" }]); }, [nextStepKey]); + const removeStep = useCallback((key: string, stepId?: number) => { setSteps((p) => { const f = p.filter((s) => s.key !== key); return f.map((s, i) => ({ ...s, stepNumber: i + 1 })); }); if (stepId) setStepsIdsToDelete((p) => [...p, stepId]); }, []); + const updateStepDesc = useCallback((key: string, desc: string) => { setSteps((p) => p.map((s) => s.key === key ? { ...s, description: desc } : s)); }, []); - // 快速新建标签 - const [showQuickTagSheet, setShowQuickTagSheet] = useState(false); - const [quickTagName, setQuickTagName] = useState(""); - const [quickTagColor, setQuickTagColor] = useState(""); - const [quickTagParentId, setQuickTagParentId] = useState(null); + const enabledCats = useMemo(() => categories.filter((c) => c.status !== 0), [categories]); - // 标签按父标签分组 - const parentTags = useMemo(() => tags.filter((t) => !t.parent_id), [tags]); - const childTagsByParent = useMemo(() => { - const map = new Map(); - tags - .filter((t) => !!t.parent_id) - .forEach((tag) => { - const group = map.get(tag.parent_id!) ?? []; - group.push(tag); - map.set(tag.parent_id!, group); - }); - return map; - }, [tags]); - - const currentItem = useMemo( - () => dishes.find((item) => item.id === editId) ?? null, - [dishes, editId], - ); - - // ------- 数据加载 ------- - - const loadBaseData = useCallback(async () => { + const loadData = useCallback(async () => { if (!familyId) return; setPageLoading(true); try { - await Promise.all([ - fetchDishes(familyId), - fetchCategories(familyId), - fetchTags(familyId), - fetchDishTags(familyId, isEdit ? editId : undefined), - ]); - + await Promise.all([fetchDishes(familyId), fetchCategories(familyId)]); if (isEdit) { - const state = useMealStore.getState(); - const item = state.dishes.find((row) => row.id === editId); - if (!item) { - toast.error("菜品不存在"); - router.back(); - return; - } - setForm(createEditForm(item)); - setSelectedTagIds( - state.dishTags.filter((row) => row.dish_id === editId).map((row) => row.tag_id), - ); - - await Promise.all([ - fetchDishIngredients(familyId, editId), - fetchDishSteps(familyId, editId), - ]); - const s = useMealStore.getState(); - setIngredients(s.dishIngredients); - setSteps(s.dishSteps); + const st = useMealStore.getState(); + const item = st.dishes.find((d) => d.id === editId); + if (!item) { toast.error("菜品不存在"); router.back(); return; } + setForm({ + name: item.name, imageFileKey: item.image_file_key, imageFilePath: "", + categoryId: item.category_id ?? null, + difficulty: (item.difficulty ?? MealDishDifficulty.Easy) as MealDishDifficulty, + cookingTime: item.cooking_time_minutes ? String(item.cooking_time_minutes) : "", + remark: item.remark ?? "", + }); + await fetchDishSteps(familyId, editId); const sd = useMealStore.getState().dishSteps; setSteps(sd.map((s2) => ({ key: "es" + s2.id, stepNumber: s2.step_number, description: s2.description ?? "", _id: s2.id }))); setStepsIdsToDelete([]); } - } finally { - setPageLoading(false); - } - }, [editId, familyId, fetchCategories, fetchDishes, fetchDishIngredients, fetchDishSteps, fetchDishTags, fetchTags, isEdit, router]); - - useEffect(() => { - loadBaseData(); - }, [loadBaseData]); - - // ------- 图片选择 ------- + } finally { setPageLoading(false); } + }, [familyId, isEdit, editId, fetchDishes, fetchCategories, fetchDishSteps, router]); + useEffect(() => { loadData(); }, [loadData]); const handlePickImage = useCallback(async () => { - if (!familyId || uploading) return; - let toastId: string | number | undefined; try { - setUploading(true); - toastId = toast.loading("图片上传中"); - const img = await ImagePicker.openPicker({ - cropping: true, - width: 800, - height: 600, - compressImageQuality: 0.86, - mediaType: "photo", - }); - const file = { - uri: img.path, - name: img.filename || `dish_${Date.now()}.jpg`, - type: img.mime || "image/jpeg", - }; - const key = await uploadDishImage(familyId, file); - if (!key) { - toast.error("图片上传失败", { id: toastId }); - return; - } - setForm((prev) => ({ ...prev, imageFileKey: key })); - toast.success("图片已上传", { id: toastId }); - } catch (error: any) { - if (error?.code === "E_PICKER_CANCELLED") { - if (toastId) toast.dismiss(toastId); - } else if (toastId) { - toast.error("图片选择失败", { id: toastId }); - } - } finally { - setUploading(false); - } - }, [familyId, uploadDishImage, uploading]); - - const handlePickStepImage = useCallback(async () => { - if (!familyId || stepUploading) return; - try { - setStepUploading(true); - const img = await ImagePicker.openPicker({ - cropping: true, - width: 800, - height: 600, - compressImageQuality: 0.86, - mediaType: "photo", - }); - const file = { - uri: img.path, - name: img.filename || `step_${Date.now()}.jpg`, - type: img.mime || "image/jpeg", - }; - const key = await uploadStepImage(familyId, file); - if (key) { - setStepForm((prev) => ({ ...prev, image_file_key: key })); - } - } catch { - // cancelled - } finally { - setStepUploading(false); - } - }, [familyId, uploadStepImage, stepUploading]); - - // ------- 食材管理 ------- - - const handleAddIngredient = useCallback(() => { - setEditingIngredientIndex(-1); - setIngredientForm({ name: "", quantity: undefined, unit: "", spec: "", remark: "" }); - setShowIngredientSheet(true); + const img = await ImagePicker.openPicker({ width: 800, height: 800, cropping: true, mediaType: "photo" }); + setForm((p) => ({ ...p, imageFilePath: img.path })); + } catch { /* cancelled */ } }, []); - const handleEditIngredient = useCallback((index: number) => { - setEditingIngredientIndex(index); - setIngredientForm(ingredients[index]); - setShowIngredientSheet(true); - }, [ingredients]); - - const handleSaveIngredient = useCallback(() => { - const name = (ingredientForm.name ?? "").trim(); - if (!name) { - toast.error("请输入食材名称"); - return; - } - const item: MealDishIngredient = { - id: ingredientForm.id ?? 0, - family_id: familyId, - dish_id: editId, - name, - quantity: ingredientForm.quantity, - unit: ingredientForm.unit ?? "", - spec: ingredientForm.spec ?? "", - remark: ingredientForm.remark ?? "", - sort_number: editingIngredientIndex >= 0 ? ingredients[editingIngredientIndex].sort_number : ingredients.length, - }; - - if (editingIngredientIndex >= 0) { - const updated = [...ingredients]; - updated[editingIngredientIndex] = item; - setIngredients(updated); - } else { - setIngredients([...ingredients, item]); - } - setShowIngredientSheet(false); - }, [editId, editingIngredientIndex, familyId, ingredientForm, ingredients]); - - const handleRemoveIngredient = useCallback((index: number) => { - setIngredients(ingredients.filter((_, i) => i !== index)); - }, [ingredients]); - - // ------- 步骤管理 ------- - - const handleAddStep = useCallback(() => { - setEditingStepIndex(-1); - setStepForm({ step_number: steps.length + 1, description: "", image_file_key: "" }); - setShowStepSheet(true); - }, [steps.length]); - - const handleEditStep = useCallback((index: number) => { - setEditingStepIndex(index); - setStepForm(steps[index]); - setShowStepSheet(true); - }, [steps]); - - const handleSaveStep = useCallback(() => { - const desc = (stepForm.description ?? "").trim(); - const imgKey = stepForm.image_file_key ?? ""; - if (!desc && !imgKey) { - toast.error("步骤描述或图片至少填写一项"); - return; - } - const item: MealDishStep = { - id: stepForm.id ?? 0, - family_id: familyId, - dish_id: editId, - step_number: editingStepIndex >= 0 ? steps[editingStepIndex].step_number : steps.length + 1, - description: desc, - image_file_key: imgKey, - sort_number: editingStepIndex >= 0 ? steps[editingStepIndex].sort_number : steps.length, - }; - - if (editingStepIndex >= 0) { - const updated = [...steps]; - updated[editingStepIndex] = item; - setSteps(updated); - } else { - setSteps([...steps, item]); - } - setShowStepSheet(false); - }, [editId, editingStepIndex, familyId, stepForm, steps]); - - const handleRemoveStep = useCallback((index: number) => { - setSteps(steps.filter((_, i) => i !== index)); - }, [steps]); - - // ------- 快速新建分类 ------- - - const handleQuickCreateCategory = useCallback(async () => { - if (!familyId || !quickCategoryName.trim()) return; - const id = (await getUniqueIdApi(1)).data?.[0]; - if (!id) { - toast.error("创建分类失败"); - return; - } - const ok = await saveCategoryChanges({ - inserts: [{ id, family_id: familyId, name: quickCategoryName.trim(), status: MealStatus.Enabled }], - }); - if (ok) { - await fetchCategories(familyId); - const state = useMealStore.getState(); - const created = state.categories.find((c) => c.name === quickCategoryName.trim()); - if (created) { - setForm((prev) => ({ ...prev, categoryId: created.id })); - } - toast.success("分类已创建"); - } - setQuickCategoryName(""); - setShowQuickCategorySheet(false); - }, [familyId, fetchCategories, quickCategoryName, saveCategoryChanges]); - - // ------- 快速新建标签 ------- - - const handleQuickCreateTag = useCallback(async () => { - if (!familyId || !quickTagName.trim()) return; - const id = (await getUniqueIdApi(1)).data?.[0]; - if (!id) { - toast.error("创建标签失败"); - return; - } - const ok = await saveTagChanges({ - inserts: [{ id, family_id: familyId, name: quickTagName.trim(), color: quickTagColor, parent_id: quickTagParentId, status: MealStatus.Enabled }], - }); - if (ok) { - await fetchTags(familyId); - const state = useMealStore.getState(); - const created = state.tags.find((t) => t.name === quickTagName.trim() && t.parent_id === quickTagParentId); - if (created) { - setSelectedTagIds((prev) => [...prev, created.id]); - } - toast.success("标签已创建"); - } - setQuickTagName(""); - setQuickTagColor(""); - setQuickTagParentId(null); - setShowQuickTagSheet(false); - }, [familyId, fetchTags, quickTagColor, quickTagName, quickTagParentId, saveTagChanges]); - - // ------- 保存 ------- + const pickStepImage = useCallback(async (stepKey: string) => { try { const img = await ImagePicker.openPicker({ width: 600, height: 400, cropping: true, mediaType: "photo" }); setSteps((p) => p.map((s) => s.key === stepKey ? { ...s, imageFilePath: img.path } : s)); } catch { /* cancelled */ } }, []); const handleSave = useCallback(async () => { - const name = form.name.trim(); - if (!name) { - toast.error("请输入菜名"); - return; - } - if (!form.imageFileKey) { - toast.error("请上传菜品图片"); - return; - } - if (!familyId || isLoading) return; - + if (!familyId || !userId) return; + if (!form.name.trim()) { toast.error("请输入菜品名称"); return; } startLoading("保存中..."); try { - const targetId = isEdit ? editId : (await getUniqueIdApi(1)).data?.[0]; - if (!targetId) { - stopLoading({ type: "error", message: "获取菜品 ID 失败" }); - return; + let imageFileKey = form.imageFileKey; + if (form.imageFilePath) { + setUploading(true); + const file = { uri: form.imageFilePath, type: "image/jpeg", name: Date.now() + ".jpg" }; + const uploaded = await uploadDishImage(familyId, file); + setUploading(false); + if (!uploaded) { stopLoading({ type: "error", message: "图片上传失败" }); return; } + imageFileKey = uploaded; + } else if (!isEdit) { stopLoading({ type: "error", message: "请选择菜品图片" }); return; } + + if (isEdit) { + const ok = await saveDishChanges({ updates: [{ id: editId, name: form.name.trim(), image_file_key: imageFileKey, category_id: form.categoryId, difficulty: form.difficulty, cooking_time_minutes: form.cookingTime ? Number(form.cookingTime) : 0, remark: form.remark.trim() }] }); + if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } + notifyOp({ module: "餐食", action: "编辑菜品", target: form.name.trim(), sourceTable: "b_meal_dish", sourceId: editId }); + } else { + const sc = steps.length + 1; const idsRes = await getUniqueIdApi(sc); const dishId = idsRes.data?.[0] ?? 0; + if (!dishId) { stopLoading({ type: "error", message: "创建失败" }); return; } + const sids2 = idsRes.data?.slice(1) ?? []; const sis2 = steps.map((s, i) => ({ id: sids2[i] ?? 0, family_id: familyId, dish_id: dishId, step_number: s.stepNumber, description: s.description, image_file_key: s.imageFileKey, sort_number: i })); + const ok = await saveDishBundle({ dishInserts: [{ id: dishId, family_id: familyId, name: form.name.trim(), image_file_key: imageFileKey, category_id: form.categoryId, difficulty: form.difficulty, cooking_time_minutes: form.cookingTime ? Number(form.cookingTime) : 0, remark: form.remark.trim(), status: MealStatus.Enabled, sort_number: 0 }], dishStepInserts: sis2 }); + if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } + notifyOp({ module: "餐食", action: "新增菜品", target: form.name.trim(), sourceTable: "b_meal_dish", sourceId: dishId }); } - const currentTagIds = new Set(dishTags.filter((r) => r.dish_id === targetId).map((r) => r.tag_id)); - const tagDeletes = dishTags - .filter((r) => r.dish_id === targetId && !selectedTagIds.includes(r.tag_id)) - .map((r) => ({ id: r.id })); - const newTagIds = selectedTagIds.filter((tid) => !currentTagIds.has(tid)); - const tagIdList = newTagIds.length ? ((await getUniqueIdApi(newTagIds.length)).data ?? []) : []; - if (tagIdList.length !== newTagIds.length) { - stopLoading({ type: "error", message: "获取标签关联 ID 失败" }); - return; - } - - // 生成食材 ID - const newIngredients = ingredients.filter((ing) => !ing.id); - const ingIdList = newIngredients.length ? ((await getUniqueIdApi(newIngredients.length)).data ?? []) : []; - - // 生成步骤 ID - const newSteps = steps.filter((s) => !s.id); - const stepIdList = newSteps.length ? ((await getUniqueIdApi(newSteps.length)).data ?? []) : []; - - const row = { - id: targetId, - family_id: familyId, - name, - image_file_key: form.imageFileKey, - category_id: form.categoryId, - ingredient_summary: form.ingredientSummary.trim(), - cooking_time_minutes: form.cookingTimeMinutes, - difficulty: form.difficulty, - remark: form.remark.trim(), - sort_number: dishes.length, - status: MealStatus.Enabled, - }; - - const ok = await saveDishBundle({ - dishInserts: isEdit ? [] : [row], - dishUpdates: isEdit ? [row] : [], - dishTagInserts: newTagIds.map((tagId, i) => ({ - id: tagIdList[i], - family_id: familyId, - dish_id: targetId, - tag_id: tagId, - })), - dishTagDeletes: tagDeletes, - dishIngredientInserts: newIngredients.map((ing, i) => ({ - ...ing, - id: ingIdList[i], - dish_id: targetId, - family_id: familyId, - })), - dishIngredientUpdates: ingredients.filter((ing) => !!ing.id).map((ing) => ({ - ...ing, - dish_id: targetId, - family_id: familyId, - })), - dishIngredientDeletes: [], // 全量替换模型 - dishStepInserts: newSteps.map((s, i) => ({ - ...s, - id: stepIdList[i], - dish_id: targetId, - family_id: familyId, - })), - dishStepUpdates: steps.filter((s) => !!s.id).map((s) => ({ - ...s, - dish_id: targetId, - family_id: familyId, - })), - dishStepDeletes: [], - }); - - if (!ok) { - stopLoading({ type: "error", message: "保存失败" }); - return; - } - - await Promise.all([fetchDishes(familyId), fetchDishTags(familyId)]); - stopLoading({ type: "success", message: isEdit ? "菜品已保存" : "菜品已新增" }); - router.back(); - } catch { - stopLoading({ type: "error", message: "保存失败" }); - } - }, [ - dishes.length, dishTags, editId, familyId, fetchDishes, fetchDishTags, form, - ingredients, isEdit, isLoading, router, saveDishBundle, selectedTagIds, - startLoading, steps, stopLoading, - ]); - - // ------- 渲染辅助 ------- - - const renderCategoryChip = useCallback( - (item: MealCategory) => { - const selected = form.categoryId === item.id; - return ( - - setForm((prev) => ({ ...prev, categoryId: prev.categoryId === item.id ? null : item.id })) - } - > - {item.name} - - ); - }, - [form.categoryId], - ); - - const renderTagChip = useCallback( - (item: MealTag) => { - const selected = selectedTagIds.includes(item.id); - return ( - setSelectedTagIds((prev) => toggleId(prev, item.id))} - > - {item.name} - - ); - }, - [selectedTagIds], - ); + stopLoading({ type: "success", message: "已保存" }); router.back(); + } catch { stopLoading({ type: "error", message: "保存失败" }); } + }, [familyId, userId, form, isEdit, editId, startLoading, stopLoading, uploadDishImage, saveDishBundle, saveDishChanges, notifyOp, router]); return ( - - {/* 图片 */} - - {form.imageFileKey ? ( - - ) : ( - - - 上传菜品图片 - - )} - - - {/* 基本信息 */} - - - - setForm((prev) => ({ ...prev, name: v }))} - placeholder="例如:番茄炒蛋" - /> - - - - - - setForm((prev) => ({ ...prev, cookingTimeMinutes: parseInt(v) || 0 })) - } - placeholder="例如:15" - keyboardType="numeric" - /> - - - - - setForm((prev) => ({ ...prev, ingredientSummary: v }))} - placeholder="例如:番茄、鸡蛋、葱" - /> - - - - -