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" />
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}
)}
+
+ {/* 操作按钮 */}
+
+
+
+
>)}
-
-
-
-
-
-
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}
)}
+
+ {/* 操作按钮 */}
+
+
+
+
>)}
-
-
-
-
-
-
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); } },
]}
/>
- {/* 停用确认 */}
+ {/* ======== 停用确认 ======== */}
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); }}>
+
+
+ );
+ })}
+
+ ) : 暂无餐段,点击右上角添加}
+ 餐段建议:早餐、午餐、晚餐、夜宵等
+
+ )}
+
- {/* ======== 删除确认弹窗 ======== */}
- {/* 分类 */}
-
+ {/* 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 ? "编辑餐段" : "添加餐段"}
- {/* 标签 */}
-
-
- {/* 餐段 */}
-
+ {/* Dialogs */}
+
+
+
+
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}}
+
+
+
+
+
+ >)}
- {/* 底部操作栏 */}
-
-
-
-
-
-
- {/* 停用确认 */}
-
-
+
);
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="例如:番茄、鸡蛋、葱"
- />
-
-
-
-
-
+
+
+
+
+ {form.imageFilePath ? () : form.imageFileKey ? () : (点击上传菜品图片)}
+
- {/* 难度 */}
- 难度
-
- {DIFFICULTY_OPTIONS.map((opt) => (
- setForm((prev) => ({ ...prev, difficulty: opt.value }))}
- >
-
- {opt.label}
-
+
+
+ setForm((p) => ({ ...p, name: v }))} placeholder="输入菜品名称" />
+
+
+
+
+
+ {enabledCats.length > 0 ? enabledCats.map((c) => {
+ const sel = form.categoryId === c.id;
+ return setForm((p) => ({ ...p, categoryId: sel ? null : c.id }))}>{c.name};
+ }) : 暂无分类}
+
+
+
+
+
+
+ {[{ v: MealDishDifficulty.Easy, label: "简单" }, { v: MealDishDifficulty.Normal, label: "普通" }, { v: MealDishDifficulty.Hard, label: "复杂" }].map((d) => {
+ const sel = form.difficulty === d.v;
+ return setForm((p) => ({ ...p, difficulty: d.v }))}>{d.label};
+ })}
+
+
+
+
+
+ setForm((p) => ({ ...p, cookingTime: v.replace(/[^0-9]/g, "") }))} placeholder="如:30" keyboardType="numeric" />
+
+
+
+
+
+
+
+
+
+
+
+ 添加步骤
- ))}
-
-
- {/* 分类 */}
-
- 分类
- { setQuickCategoryName(""); setShowQuickCategorySheet(true); }}
- >
-
-
-
-
- setForm((prev) => ({ ...prev, categoryId: null }))}
- >
- 不选择
-
- {categories.map(renderCategoryChip)}
-
-
- {/* 标签 */}
- {tags.length > 0 && (
- <>
- 标签
-
- {parentTags.map((parent) => {
- const children = childTagsByParent.get(parent.id) ?? [];
- return (
-
-
- {parent.name}
- {
- setQuickTagName("");
- setQuickTagColor("");
- setQuickTagParentId(parent.id);
- setShowQuickTagSheet(true);
- }}
- >
-
-
+
+ {steps.length > 0 ? (
+
+ {steps.map((s) => (
+
+
+
+ {s.stepNumber}
- {children.map(renderTagChip)}
+
+ updateStepDesc(s.key, v)}
+ placeholder={"第" + s.stepNumber + "步:描述做法..."}
+ multiline
+ textAlignVertical="top"
+ style={{ minHeight: 50 }}
+ />
+ {(s.imageFilePath || s.imageFileKey) ? (
+ pickStepImage(s.key)}>
+
+
+ ) : (
+ pickStepImage(s.key)}>
+
+ 添加步骤图
+
+ )}
+
+ removeStep(s.key, s._id)}>
+
+
- );
- })}
+
+ ))}
- >
- )}
-
- {/* 食材明细 */}
-
- 食材明细
-
-
-
+ ) : (
+ 暂无步骤,点击上方添加
+ )}
- {ingredients.length > 0 ? (
-
- {ingredients.map((ing, idx) => (
- handleEditIngredient(idx)}
- >
-
- {ing.name}
-
- {[ing.quantity ? `${ing.quantity}` : "", ing.unit, ing.spec].filter(Boolean).join(" ")}
- {ing.remark ? ` · ${ing.remark}` : ""}
-
-
- handleRemoveIngredient(idx)}
- >
-
-
-
- ))}
-
- ) : (
- 暂无食材,选填
- )}
- {/* 做法步骤 */}
-
- 做法步骤
-
-
-
-
- {steps.length > 0 ? (
-
- {steps.map((step, idx) => (
- handleEditStep(idx)}
- >
-
-
- {step.step_number}
-
-
- {step.description || "(无描述)"}
- {!!step.image_file_key && (
-
- )}
-
- handleRemoveStep(idx)}
- >
-
-
-
-
- ))}
-
- ) : (
- 暂无步骤,选填
- )}
-
- {/* 保存/取消 */}
-
-
-
+
+
-
- {/* 食材编辑 BottomSheet */}
-
-
-
-
-
- {editingIngredientIndex >= 0 ? "编辑食材" : "新增食材"}
-
-
-
-
-
- setIngredientForm((prev) => ({ ...prev, name: v }))}
- placeholder="例如:鸡蛋"
- />
-
-
-
-
-
- setIngredientForm((prev) => ({ ...prev, quantity: v ? parseFloat(v) : undefined }))
- }
- placeholder="2"
- keyboardType="decimal-pad"
- />
-
-
-
- setIngredientForm((prev) => ({ ...prev, unit: v }))}
- placeholder="个"
- />
-
-
-
-
- setIngredientForm((prev) => ({ ...prev, spec: v }))}
- placeholder="例如:去壳、切块"
- />
-
-
-
- setIngredientForm((prev) => ({ ...prev, remark: v }))}
- placeholder="可替换成鸭蛋"
- />
-
-
-
-
-
-
-
-
-
-
-
- {/* 步骤编辑 BottomSheet */}
-
-
-
-
-
- {editingStepIndex >= 0 ? "编辑步骤" : "新增步骤"}
-
-
-
-
- {stepForm.image_file_key ? (
-
- ) : (
-
-
- 添加步骤图片(选填)
-
- )}
-
-
-
-
-
-
- 描述和图片至少填写一项
-
-
-
-
-
-
-
-
- {/* 快速新建分类 BottomSheet */}
-
-
-
-
- 新增分类
-
-
-
-
-
-
-
-
-
-
-
-
- {/* 快速新建标签 BottomSheet */}
-
-
-
-
-
- {quickTagParentId ? "新增子标签" : "新增标签"}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
);
}
diff --git a/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx b/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx
new file mode 100644
index 00000000..4f957dd6
--- /dev/null
+++ b/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx
@@ -0,0 +1,806 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { ActivityIndicator, Text, TextInput, TouchableOpacity, View } from "react-native";
+import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
+import dayjs from "dayjs";
+import { Avatar, Button } from "heroui-native";
+import { BadgeCheckIcon, CheckCircleIcon, CircleIcon, PlusIcon, SendIcon, UserIcon } 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 EmptyState from "@/components/EmptyStatus";
+import { LoadingOverlay } from "@/components/LoadingOverlay";
+import { useLoading } from "@/hooks/useLoading";
+import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
+import { getUniqueIdApi, loadDataApi } from "@/request/api";
+import { useFamilyStore, useMealStore, useUserStore } from "@/store";
+import { MealPlanLogType, MealPlanStatus } from "@/types/meal";
+import { buildFileUrl } from "@/utils/file";
+
+// ============================================================
+// 类型定义
+// ============================================================
+
+interface FMember {
+ user_id: number;
+ nickname: string;
+ avatar?: string;
+}
+
+interface TimelineEntry {
+ id: number;
+ type: number;
+ content?: string;
+ dishName?: string;
+ refId?: number;
+ createTime: string;
+ createBy: number;
+ nickName: string;
+ avatar?: string;
+}
+
+// ============================================================
+// 时间格式化
+// ============================================================
+
+function timeAgo(t: string) {
+ const d = dayjs(t);
+ const diff = dayjs().diff(d, "second");
+ if (diff < 60) return "刚刚";
+ if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
+ return `${Math.floor(diff / 86400)}天前`;
+}
+
+function formatTime(t: string) {
+ return dayjs(t).format("HH:mm");
+}
+
+function formatPlanDate(d: string) {
+ const date = dayjs(d);
+ const today = dayjs().format("YYYY-MM-DD");
+ const tomorrow = dayjs().add(1, "day").format("YYYY-MM-DD");
+ const fmt = date.format("YYYY-MM-DD");
+ if (fmt === today) return "今天";
+ if (fmt === tomorrow) return "明天";
+ return fmt;
+}
+
+// ============================================================
+// 日志类型标签
+// ============================================================
+
+const LOG_LABELS: Record = {
+ [MealPlanLogType.Plan]: "创建了菜单",
+ [MealPlanLogType.AddDish]: "添加了",
+ [MealPlanLogType.RemoveDish]: "移除了",
+ [MealPlanLogType.SetCook]: "设置了做饭人",
+};
+
+// ============================================================
+// 组件
+// ============================================================
+
+export default function MealPlanDetail() {
+ const router = useRouter();
+ const { id } = useLocalSearchParams<{ id?: string }>();
+ const planId = id ? Number(id) : 0;
+
+ const familyId = useFamilyStore((s) => s.getFamilyId());
+ const userId = useUserStore((s) => s.getUserId());
+ const dishes = useMealStore((s) => s.dishes);
+ const plans = useMealStore((s) => s.plans);
+ const slots = useMealStore((s) => s.slots);
+ const fetchDishes = useMealStore((s) => s.fetchDishes);
+ const fetchCategories = useMealStore((s) => s.fetchCategories);
+ const fetchTags = useMealStore((s) => s.fetchTags);
+ const fetchDishTags = useMealStore((s) => s.fetchDishTags);
+ const fetchSlots = useMealStore((s) => s.fetchSlots);
+ const savePlanChanges = useMealStore((s) => s.savePlanChanges);
+ const savePlanLogChanges = useMealStore((s) => s.savePlanLogChanges);
+ const addDishToPlan = useMealStore((s) => s.addDishToPlan);
+ const removeDishFromPlan = useMealStore((s) => s.removeDishFromPlan);
+ const confirmPlan = useMealStore((s) => s.confirmPlan);
+ const completePlan = useMealStore((s) => s.completePlan);
+ const { isLoading, startLoading, stopLoading } = useLoading();
+ const notifyOp = useModuleChatNotifier();
+
+ // 页面状态
+ const [pageLoading, setPageLoading] = useState(true);
+ const [planItems, setPlanItems] = useState([]);
+ const [logs, setLogs] = useState([]);
+ const [members, setMembers] = useState([]);
+ const [comment, setComment] = useState("");
+ const [sending, setSending] = useState(false);
+ const [showAddDish, setShowAddDish] = useState(false);
+ const [addDishCandidates, setAddDishCandidates] = useState([]);
+ const [showCookSheet, setShowCookSheet] = useState(false);
+ const [completing, setCompleting] = useState(false);
+ const [confirming, setConfirming] = useState(false);
+ const scrollRef = useRef(null);
+
+ // 派生数据
+ const plan = useMemo(() => plans.find((p) => p.id === planId) ?? null, [planId, plans]);
+ const slot = useMemo(() => slots.find((s) => s.id === plan?.meal_slot_id) ?? null, [plan?.meal_slot_id, slots]);
+ const cookMember = useMemo(() => members.find((m) => m.user_id === plan?.cook_by) ?? null, [members, plan?.cook_by]);
+ const planItemsWithDish = useMemo(
+ () => planItems.map((pi) => ({ ...pi, dish: dishes.find((d) => d.id === pi.dish_id) })),
+ [planItems, dishes],
+ );
+ const existingDishIds = useMemo(() => new Set(planItems.map((pi: any) => pi.dish_id)), [planItems]);
+
+ // 菜单状态
+ const isPlanning = plan?.status === MealPlanStatus.Planning;
+ const isConfirmed = plan?.status === MealPlanStatus.Confirmed;
+ const isCompleted = plan?.status === MealPlanStatus.Completed;
+ const isLocked = false;
+
+ // ======== 数据加载 ========
+
+ const loadData = useCallback(async () => {
+ if (!familyId || !planId) return;
+ setPageLoading(true);
+ try {
+ // 如果 store 中没有该 plan,直接从 DB 拉取
+ const currentPlans = useMealStore.getState().plans;
+ if (!currentPlans.find((p) => p.id === planId)) {
+ const planRes = await loadDataApi({
+ view_name: "b_meal_plan",
+ search_condition: "family_id = ? and id = ?",
+ search_columns: [],
+ order_by: "id asc",
+ args: [familyId, planId],
+ });
+ if (planRes.isSuccess && planRes.data?.length > 0) {
+ useMealStore.setState((s) => ({
+ plans: [...s.plans, (planRes.data as any[])[0]],
+ }));
+ }
+ }
+
+ const [ir, lr, mr] = await Promise.all([
+ loadDataApi({
+ view_name: "b_meal_plan_item",
+ search_condition: "family_id = ? and plan_id = ?",
+ search_columns: [],
+ order_by: "sort_number asc, id asc",
+ args: [familyId, planId],
+ }),
+ loadDataApi({
+ view_name: "b_meal_plan_log",
+ search_condition: "family_id = ? and plan_id = ?",
+ search_columns: [],
+ order_by: "create_time asc, id asc",
+ args: [familyId, planId],
+ }),
+ loadDataApi({
+ view_name: "v_family_member",
+ search_condition: "family_id = ?",
+ search_columns: ["user_id", "nickname", "avatar"],
+ order_by: "id asc",
+ args: [familyId],
+ }),
+ ]);
+
+ const mData = (mr.isSuccess ? mr.data ?? [] : []) as FMember[];
+ setMembers(mData);
+
+ if (ir.isSuccess) setPlanItems(ir.data ?? []);
+
+ if (lr.isSuccess) {
+ const rawLogs = (lr.data ?? []) as any[];
+ setLogs(
+ rawLogs.map((log: any) => {
+ const m = mData.find((mm) => mm.user_id === log.create_by);
+ return {
+ id: log.id,
+ type: log.type,
+ content: log.content ?? "",
+ dishName: log.dish_name ?? "",
+ refId: log.ref_id,
+ createTime: log.create_time ?? "",
+ createBy: log.create_by ?? 0,
+ nickName: m?.nickname ?? `用户${log.create_by}`,
+ avatar: m?.avatar,
+ } as TimelineEntry;
+ }),
+ );
+ }
+ } finally {
+ setPageLoading(false);
+ }
+ }, [familyId, planId]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ useFocusEffect(
+ useCallback(() => {
+ if (familyId && planId) {
+ loadData();
+ fetchDishes(familyId);
+ fetchCategories(familyId);
+ fetchTags(familyId);
+ fetchDishTags(familyId);
+ fetchSlots(familyId);
+ }
+ }, [familyId, planId, loadData, fetchDishes, fetchCategories, fetchTags, fetchDishTags, fetchSlots]),
+ );
+
+ // 重新加载 plan 数据以获取最新 status
+ const refreshPlan = useCallback(async () => {
+ if (!familyId || !planId) return;
+ const res = await loadDataApi({
+ view_name: "b_meal_plan",
+ search_condition: "family_id = ? and id = ?",
+ search_columns: [],
+ order_by: "id asc",
+ args: [familyId, planId],
+ });
+ if (res.isSuccess && res.data?.length > 0) {
+ useMealStore.setState((s) => ({
+ plans: s.plans.map((p) => (p.id === planId ? (res.data as any[])[0] : p)),
+ }));
+ }
+ }, [familyId, planId]);
+
+ // ======== 操作 ========
+
+ // 确认菜单(进行中 → 已确认)
+ const handleConfirm = useCallback(async () => {
+ if (!familyId || !planId || !isPlanning) return;
+ setConfirming(true);
+ try {
+ const ok = await confirmPlan({ planId, familyId, userId });
+ if (ok) {
+ await refreshPlan();
+ toast.success("菜单已确认");
+ } else {
+ toast.error("操作失败");
+ }
+ } catch {
+ toast.error("操作失败");
+ } finally {
+ setConfirming(false);
+ }
+ }, [familyId, planId, isPlanning, userId, confirmPlan, refreshPlan]);
+
+ // 完成菜单(已确认 → 已完成)
+ const handleComplete = useCallback(async () => {
+ if (!familyId || !planId || !isConfirmed) return;
+ setCompleting(true);
+ try {
+ const ok = await completePlan({ planId, familyId, userId });
+ if (ok) {
+ await refreshPlan();
+ toast.success("菜单已完成");
+ } else {
+ toast.error("操作失败");
+ }
+ } catch {
+ toast.error("操作失败");
+ } finally {
+ setCompleting(false);
+ }
+ }, [familyId, planId, isConfirmed, userId, completePlan, refreshPlan]);
+
+ // 添加评论
+ const addComment = useCallback(async () => {
+ if (!familyId || !comment.trim() || sending || isLocked) return;
+ setSending(true);
+ try {
+ const lid = (await getUniqueIdApi(1)).data?.[0] ?? 0;
+ if (!lid) {
+ toast.error("发送失败");
+ return;
+ }
+ await savePlanLogChanges({
+ inserts: [{
+ id: lid,
+ family_id: familyId,
+ plan_id: planId,
+ type: MealPlanLogType.Remark,
+ content: comment.trim(),
+ create_by: userId,
+ }],
+ });
+ setComment("");
+ await loadData();
+ notifyOp({
+ module: "餐食",
+ action: "评论菜单",
+ target: planId.toString(),
+ sourceTable: "b_meal_plan_log",
+ sourceId: lid,
+ });
+ } catch {
+ toast.error("发送失败");
+ } finally {
+ setSending(false);
+ }
+ }, [comment, familyId, isLocked, loadData, notifyOp, planId, savePlanLogChanges, sending, userId]);
+
+ // 添加菜品
+ const addDish = useCallback(
+ async (dishId: number, dishName: string) => {
+ if (!familyId || isLocked) return;
+ startLoading("添加中...");
+ try {
+ const result = await addDishToPlan({
+ familyId,
+ planId,
+ dishId,
+ dishName,
+ userId,
+ sortNumber: planItems.length,
+ });
+ if (result.planItemId) {
+ setShowAddDish(false);
+ await loadData();
+ stopLoading({ type: "success", message: `已添加${dishName}` });
+ } else {
+ stopLoading({ type: "error", message: "添加失败" });
+ }
+ } catch {
+ stopLoading({ type: "error", message: "添加失败" });
+ }
+ },
+ [familyId, isLocked, planId, planItems.length, addDishToPlan, userId, loadData, startLoading, stopLoading],
+ );
+
+ // 移除菜品
+ const removeDish = useCallback(
+ async (itemId: number, dishName: string) => {
+ if (!familyId || isLocked) return;
+ startLoading("移除中...");
+ try {
+ const ok = await removeDishFromPlan({
+ familyId,
+ planId,
+ planItemId: itemId,
+ dishName,
+ userId,
+ });
+ if (ok) {
+ await loadData();
+ stopLoading({ type: "success", message: `已移除${dishName}` });
+ } else {
+ stopLoading({ type: "error", message: "移除失败" });
+ }
+ } catch {
+ stopLoading({ type: "error", message: "移除失败" });
+ }
+ },
+ [familyId, isLocked, planId, removeDishFromPlan, userId, loadData, startLoading, stopLoading],
+ );
+
+ // 设置做饭人
+ const setCook = useCallback(
+ async (uid: number) => {
+ if (!familyId || isLocked) return;
+ startLoading("设置中...");
+ try {
+ const newCookBy = plan?.cook_by === uid ? null : uid;
+ await savePlanChanges({
+ updates: [{ id: planId, cook_by: newCookBy }],
+ });
+ // 同步更新本地 store
+ useMealStore.setState((s) => ({
+ plans: s.plans.map((p) => p.id === planId ? { ...p, cook_by: newCookBy } : p),
+ }));
+ const lid = (await getUniqueIdApi(1)).data?.[0] ?? 0;
+ if (lid) {
+ await savePlanLogChanges({
+ inserts: [{
+ id: lid,
+ family_id: familyId,
+ plan_id: planId,
+ type: MealPlanLogType.SetCook,
+ create_by: userId,
+ }],
+ });
+ }
+ setShowCookSheet(false);
+ await loadData();
+ stopLoading({ type: "success", message: "已更新" });
+ } catch {
+ stopLoading({ type: "error", message: "设置失败" });
+ }
+ },
+ [
+ familyId,
+ isLocked,
+ planId,
+ plan?.cook_by,
+ savePlanChanges,
+ savePlanLogChanges,
+ userId,
+ loadData,
+ startLoading,
+ stopLoading,
+ ],
+ );
+
+ // 打开加菜面板
+ const openAddDish = useCallback(async () => {
+ if (!familyId || isLocked) return;
+ const ir = await loadDataApi({
+ view_name: "b_meal_dish",
+ search_condition: "family_id = ? and status = 1",
+ search_columns: ["id", "name", "image_file_key"],
+ order_by: "sort_number asc",
+ args: [familyId],
+ });
+ setAddDishCandidates(ir.isSuccess ? ir.data ?? [] : []);
+ setShowAddDish(true);
+ }, [familyId, isLocked]);
+
+ // ======== 渲染 ========
+
+ // 不存在且非加载中
+ if (!plan && !pageLoading) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {/* ======== 头部 ======== */}
+
+ {/* 日期 + 状态 */}
+
+ {plan?.plan_date ? formatPlanDate(plan.plan_date) : ""}
+ {/* 状态标签(三态) */}
+
+ {isCompleted ? (
+
+ ) : isConfirmed ? (
+
+ ) : (
+
+ )}
+
+ {isCompleted ? "已完成" : isConfirmed ? "已确认" : "进行中"}
+
+
+
+
+ {/* 做饭人 + 确认/完成按钮(同行) */}
+
+ {/* 做饭人:标签 + 选择 */}
+
+ 做饭人
+ {cookMember ? (
+ !isLocked && setShowCookSheet(true)}
+ disabled={isLocked}
+ >
+
+ {cookMember.avatar
+ ?
+ : null}
+
+ {cookMember.nickname?.charAt(0)}
+
+
+ {cookMember.nickname}
+
+ ) : (
+ !isLocked && setShowCookSheet(true)}
+ disabled={isLocked}
+ >
+
+ 未设置
+
+ )}
+
+
+ {/* 确认 / 完成 小按钮 */}
+ {isPlanning && (
+
+ {confirming ? : }
+ 确认菜单
+
+ )}
+ {isConfirmed && (
+
+ {completing ? : }
+ 完成菜单
+
+ )}
+
+
+
+ {/* ======== 当前菜品列表 ======== */}
+
+
+
+ 当前菜品 · {planItemsWithDish.length} 道
+
+ {isPlanning && (
+
+
+
+ )}
+
+ {planItemsWithDish.length > 0 ? (
+
+ {planItemsWithDish.map((pi) => (
+
+ {
+ if (pi.dish?.id) router.push(`/meal/dish/detail/${pi.dish.id}` as any);
+ }}
+ >
+
+
+
+
+ {pi.dish?.name ?? "未知"}
+
+
+ {/* 仅 planning 状态可移除 */}
+ {isPlanning && (
+ removeDish(pi.id, pi.dish?.name ?? "")}
+ >
+ ×
+
+ )}
+
+ ))}
+
+ ) : (
+
+ 暂无菜品
+ {isPlanning && (
+ 点击右上角 + 添加菜品
+ )}
+
+ )}
+
+
+ {/* ======== 时间轴 ======== */}
+
+
+ 时间轴 · {logs.length} 条记录
+
+ {logs.length > 0 ? (
+
+ {/* 时间轴线 */}
+
+
+ {logs.map((entry, idx) => (
+
+ {/* 左侧:头像 + 时间 */}
+
+
+ {entry.avatar
+ ?
+ : null}
+
+ {entry.nickName.charAt(0)}
+
+
+
+
+ {/* 右侧:内容 */}
+
+ {/* 用户名 + 时间 */}
+
+
+ {entry.nickName}
+
+
+ {timeAgo(entry.createTime)}
+
+
+ {formatTime(entry.createTime)}
+
+
+
+ {/* 操作内容 */}
+ {entry.type === MealPlanLogType.Remark ? (
+ // 评论:气泡样式
+
+
+ {entry.content}
+
+
+ ) : (
+ // 操作事件:文本描述
+
+
+ {LOG_LABELS[entry.type] ?? ""}
+
+ {entry.dishName ? (
+
+ {" "}「{entry.dishName}」
+
+ ) : null}
+
+ )}
+
+
+ ))}
+
+ ) : (
+ 暂无活动记录
+ )}
+
+
+ {/* 底部间距 */}
+
+
+
+ {/* ======== 底部评论输入(仅 planning 状态,已确认/完成不可评论) ======== */}
+ {isPlanning && (
+
+
+
+ {sending
+ ?
+ : }
+
+
+ )}
+
+ {/* ======== 添加菜品 Sheet ======== */}
+
+
+
+
+ 添加菜品
+
+ {addDishCandidates.length > 0
+ ? (
+
+ {addDishCandidates
+ .filter((d: any) => !existingDishIds.has(d.id))
+ .map((d: any) => (
+ addDish(d.id, d.name)}
+ >
+
+
+
+
+ {d.name}
+
+
+ ))}
+
+ )
+ : 没有可添加的菜品}
+
+
+
+
+
+
+ {/* ======== 做饭人选择 Sheet ======== */}
+
+
+
+
+ 选择做饭人
+ {members.length > 0
+ ? (
+
+ {members.map((m) => (
+ setCook(m.user_id)}
+ >
+
+ {m.avatar
+ ?
+ : null}
+ {m.nickname?.charAt(0)}
+
+
+ {m.nickname}
+
+ {plan?.cook_by === m.user_id && (
+ 当前
+ )}
+
+ ))}
+
+ )
+ : 暂无家庭成员}
+
+
+
+
+
+
+
+ );
+}
diff --git a/code/app/app-rn/src/app/meal/plan/edit/index.tsx b/code/app/app-rn/src/app/meal/plan/edit/index.tsx
index 20ea457f..4f55b38b 100644
--- a/code/app/app-rn/src/app/meal/plan/edit/index.tsx
+++ b/code/app/app-rn/src/app/meal/plan/edit/index.tsx
@@ -1,95 +1,68 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import dayjs from "dayjs";
-import { Button, Input, Label, TextArea, TextField } from "heroui-native";
-import { CheckIcon, MinusIcon, PlusIcon, SaveIcon, XIcon } from "lucide-react-native";
+import { Button } from "heroui-native";
+import { CheckIcon, PlusIcon } 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 { getUniqueIdApi, loadDataApi } from "@/request/api";
+import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
+import { loadDataApi } from "@/request/api";
import { useFamilyStore, useMealStore, useUserStore } from "@/store";
-import { MealDish, MealPlanLogType, MealSlot, MealStatus } from "@/types/meal";
+import { MealSlot } from "@/types/meal";
-// ------- 类型 -------
+// ============================================================
+// 类型
+// ============================================================
-interface SelectedDish {
- dish: MealDish;
- quantity: number;
-}
-
-interface FamilyMember {
+interface FMember {
user_id: number;
nickname: string;
}
-// ------- 页面 -------
+// ============================================================
+// 菜单创建页(仅创建空菜单,不选择菜品)
+// 菜品管理在菜单详情页进行
+// ============================================================
export default function MealPlanEdit() {
const router = useRouter();
const params = useLocalSearchParams<{ planDate?: string; mealSlotId?: string }>();
const familyId = useFamilyStore((s) => s.getFamilyId());
const userId = useUserStore((s) => s.getUserId());
- const dishes = useMealStore((s) => s.dishes);
const slots = useMealStore((s) => s.slots);
const fetchDishes = useMealStore((s) => s.fetchDishes);
const fetchCategories = useMealStore((s) => s.fetchCategories);
const fetchTags = useMealStore((s) => s.fetchTags);
const fetchDishTags = useMealStore((s) => s.fetchDishTags);
const fetchSlots = useMealStore((s) => s.fetchSlots);
- const fetchPlansByDate = useMealStore((s) => s.fetchPlansByDate);
- const fetchPlanItems = useMealStore((s) => s.fetchPlanItems);
- const plans = useMealStore((s) => s.plans);
- const savePlanBundle = useMealStore((s) => s.savePlanBundle);
- const recordFeed = useMealStore((s) => s.recordFeed);
+ const createEmptyPlan = useMealStore((s) => s.createEmptyPlan);
const { isLoading, startLoading, stopLoading } = useLoading();
+ const notifyOp = useModuleChatNotifier();
- // 日期
- const [planDate, setPlanDate] = useState(
- params.planDate ?? dayjs().format("YYYY-MM-DD"),
- );
- // 餐段
+ const today = dayjs().format("YYYY-MM-DD");
+
+ const [planDate, setPlanDate] = useState(params.planDate ?? today);
const [mealSlotId, setMealSlotId] = useState(
params.mealSlotId ? Number(params.mealSlotId) : null,
);
- // 选中的菜品
- const [selectedDishes, setSelectedDishes] = useState([]);
- // 做饭人
const [cookBy, setCookBy] = useState(null);
- // 备注
- const [remark, setRemark] = useState("");
- // 家庭成员
- const [members, setMembers] = useState([]);
- // 页面加载
- const [pageLoading, setPageLoading] = useState(true);
- // 日期选择器
- const [showDateSheet, setShowDateSheet] = useState(false);
- const [datePickerValue, setDatePickerValue] = useState(planDate);
+ const [members, setMembers] = useState([]);
+ const [pageReady, setPageReady] = useState(false);
+ const [showDate, setShowDate] = useState(false);
+ const [dateVal, setDateVal] = useState(planDate);
+ const initRef = useRef(false);
- const today = dayjs().format("YYYY-MM-DD");
- const tomorrow = dayjs().add(1, "day").format("YYYY-MM-DD");
-
- // 现有的 plan(如果已安排过)
- const existingPlan = useMemo(
- () => plans.find((p) => p.plan_date === planDate && p.meal_slot_id === mealSlotId) ?? null,
- [plans, planDate, mealSlotId],
- );
-
- // 已选中的 dish ID 集合
- const selectedDishIds = useMemo(
- () => new Set(selectedDishes.map((d) => d.dish.id)),
- [selectedDishes],
- );
-
- // ------- 数据加载 -------
+ // ======== 初始化加载 ========
const loadData = useCallback(async () => {
if (!familyId) return;
- setPageLoading(true);
+ const isInit = !initRef.current;
+ if (isInit) initRef.current = true;
try {
await Promise.all([
fetchDishes(familyId),
@@ -97,234 +70,100 @@ export default function MealPlanEdit() {
fetchTags(familyId),
fetchDishTags(familyId),
fetchSlots(familyId),
- fetchPlansByDate(familyId, planDate),
]);
-
- // 加载家庭成员
- const memberRes = await loadDataApi({
- view_name: "b_family_member",
+ 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 (memberRes.isSuccess) {
- setMembers((memberRes.data as FamilyMember[]) ?? []);
- }
-
- // 如果已有 plan,加载已选的菜品
- const state = useMealStore.getState();
- const plan = state.plans.find(
- (p) => p.plan_date === planDate && p.meal_slot_id === mealSlotId,
- );
- if (plan) {
- await fetchPlanItems(familyId, plan.id);
- const items = useMealStore.getState().planItems;
- const loaded: SelectedDish[] = items
- .map((item) => {
- const dish = state.dishes.find((d) => d.id === item.dish_id);
- return dish ? { dish, quantity: item.quantity } : null;
- })
- .filter(Boolean) as SelectedDish[];
- setSelectedDishes(loaded);
- }
- } finally {
- setPageLoading(false);
+ if (mr.isSuccess) setMembers((mr.data as FMember[]) ?? []);
+ if (isInit) setPageReady(true);
+ } catch {
+ if (isInit) setPageReady(true);
}
- }, [
- familyId, fetchCategories, fetchDishTags, fetchDishes, fetchPlanItems,
- fetchPlansByDate, fetchSlots, fetchTags, mealSlotId, planDate,
- ]);
+ }, [familyId, fetchDishes, fetchCategories, fetchTags, fetchDishTags, fetchSlots]);
useEffect(() => {
loadData();
}, [loadData]);
- // 切换日期时重新加载
- useEffect(() => {
- if (!familyId) return;
- fetchPlansByDate(familyId, planDate);
- setMealSlotId(null);
- setSelectedDishes([]);
- setCookBy(null);
- setRemark("");
- }, [planDate, familyId, fetchPlansByDate]);
+ // ======== 创建空菜单 ========
- // ------- 菜品选择 -------
-
- const handleToggleDish = useCallback((dish: MealDish) => {
- setSelectedDishes((prev) => {
- const exists = prev.find((d) => d.dish.id === dish.id);
- if (exists) {
- return prev.filter((d) => d.dish.id !== dish.id);
- }
- return [...prev, { dish, quantity: 1 }];
- });
- }, []);
-
- const handleUpdateQuantity = useCallback((dishId: number, delta: number) => {
- setSelectedDishes((prev) =>
- prev.map((d) =>
- d.dish.id === dishId
- ? { ...d, quantity: Math.max(1, d.quantity + delta) }
- : d,
- ),
- );
- }, []);
-
- const handleRemoveDish = useCallback((dishId: number) => {
- setSelectedDishes((prev) => prev.filter((d) => d.dish.id !== dishId));
- }, []);
-
- // ------- 保存 -------
-
- const handleSave = useCallback(async () => {
- if (!familyId) return;
+ const handleCreate = useCallback(async () => {
+ if (!familyId || !userId) return;
if (!mealSlotId) {
toast.error("请选择餐段");
return;
}
- if (selectedDishes.length === 0) {
- toast.error("请至少选择一道菜");
- return;
- }
if (isLoading) return;
- startLoading("保存中...");
+ startLoading("创建菜单...");
try {
- const logInserts: any[] = [];
- let planId: number;
+ const newPlanId = await createEmptyPlan({
+ familyId,
+ planDate,
+ mealSlotId,
+ userId,
+ });
- if (existingPlan) {
- planId = existingPlan.id;
- } else {
- // 创建新 plan
- planId = (await getUniqueIdApi(1)).data?.[0] ?? 0;
- if (!planId) {
- stopLoading({ type: "error", message: "创建餐食安排失败" });
- return;
- }
- // type=1 日志
- const logId = (await getUniqueIdApi(1)).data?.[0] ?? 0;
- if (logId) {
- logInserts.push({
- id: logId,
- family_id: familyId,
- plan_id: planId,
- type: MealPlanLogType.Plan,
- create_by: userId,
- });
- }
- }
-
- // 生成 plan_item ID
- const itemCount = selectedDishes.length;
- const itemIds = (await getUniqueIdApi(itemCount)).data ?? [];
- if (itemIds.length !== itemCount) {
- stopLoading({ type: "error", message: "生成菜品项 ID 失败" });
+ if (!newPlanId) {
+ stopLoading({ type: "error", message: "创建失败" });
return;
}
- // 生成日志 ID(每个菜一条 type=2)
- const logIds = (await getUniqueIdApi(itemCount)).data ?? [];
+ // 如果选了做饭人,更新 plan
+ if (cookBy) {
+ const savePlanChanges = useMealStore.getState().savePlanChanges;
+ await savePlanChanges({ updates: [{ id: newPlanId, cook_by: cookBy }] });
+ }
- const planItemInserts = selectedDishes.map((d, i) => ({
- id: itemIds[i],
- family_id: familyId,
- plan_id: planId,
- dish_id: d.dish.id,
- quantity: d.quantity,
- sort_number: i,
- }));
-
- // type=2 加菜日志
- selectedDishes.forEach((d, i) => {
- if (logIds[i]) {
- logInserts.push({
- id: logIds[i],
- family_id: familyId,
- plan_id: planId,
- type: MealPlanLogType.AddDish,
- ref_id: itemIds[i],
- create_by: userId,
- });
- }
+ notifyOp({
+ module: "餐食",
+ action: "创建菜单",
+ target: `${planDate} ${slots.find((s) => s.id === mealSlotId)?.name ?? ""}`,
+ sourceTable: "b_meal_plan",
+ sourceId: newPlanId,
});
- // type=4 备注日志
- if (remark.trim()) {
- const remarkLogId = (await getUniqueIdApi(1)).data?.[0] ?? 0;
- if (remarkLogId) {
- logInserts.push({
- id: remarkLogId,
- family_id: familyId,
- plan_id: planId,
- type: MealPlanLogType.Remark,
- content: remark.trim(),
- create_by: userId,
- });
- }
- }
+ stopLoading({ type: "success", message: "菜单已创建" });
- // type=5 做饭人日志
- if (cookBy !== existingPlan?.cook_by) {
- const cookLogId = (await getUniqueIdApi(1)).data?.[0] ?? 0;
- if (cookLogId) {
- logInserts.push({
- id: cookLogId,
- family_id: familyId,
- plan_id: planId,
- type: MealPlanLogType.SetCook,
- create_by: userId,
- });
- }
- }
-
- const ok = await savePlanBundle({
- planInserts: existingPlan
- ? []
- : [{ id: planId, family_id: familyId, plan_date: planDate, meal_slot_id: mealSlotId, remark: remark.trim(), cook_by: cookBy }],
- planUpdates: existingPlan
- ? [{ id: planId, remark: remark.trim(), cook_by: cookBy }]
- : [],
- planItemInserts,
- planItemUpdates: [],
- planItemDeletes: existingPlan
- ? useMealStore.getState().planItems.map((item) => ({ id: item.id }))
- : [],
- planLogInserts: logInserts,
- });
-
- if (!ok) {
- stopLoading({ type: "error", message: "保存失败" });
- return;
- }
-
- // 触发家庭动态
- recordFeed(familyId, JSON.stringify({ type: "meal_plan", plan_date: planDate }));
- stopLoading({ type: "success", message: "已保存" });
- router.back();
+ // 跳转到详情页
+ router.replace(`/meal/plan/detail/${newPlanId}` as any);
} catch {
- stopLoading({ type: "error", message: "保存失败" });
+ stopLoading({ type: "error", message: "创建失败" });
}
}, [
- cookBy, existingPlan, familyId, isLoading, mealSlotId, planDate, recordFeed,
- remark, router, savePlanBundle, selectedDishes, startLoading, stopLoading, userId,
+ familyId,
+ userId,
+ mealSlotId,
+ planDate,
+ cookBy,
+ isLoading,
+ createEmptyPlan,
+ notifyOp,
+ slots,
+ startLoading,
+ stopLoading,
+ router,
]);
- // ------- 渲染 -------
+ // ======== 渲染辅助 ========
- const renderSlotChip = useCallback(
- (slot: MealSlot) => {
- const selected = mealSlotId === slot.id;
+ const renderSlot = useCallback(
+ (s: MealSlot) => {
+ const sel = mealSlotId === s.id;
return (
setMealSlotId(slot.id)}
+ key={s.id}
+ className={`rounded-full border px-4 py-2.5 ${
+ sel ? "border-accent bg-accent" : "border-gray-200 bg-white"
+ }`}
+ onPress={() => setMealSlotId(s.id)}
>
-
- {slot.name}
+
+ {s.name}
);
@@ -332,251 +171,137 @@ export default function MealPlanEdit() {
[mealSlotId],
);
- const renderDishItem = useCallback(
- (item: MealDish) => {
- const selected = selectedDishIds.has(item.id);
- const selectedItem = selectedDishes.find((d) => d.dish.id === item.id);
-
+ const renderMember = useCallback(
+ (m: FMember) => {
+ const sel = cookBy === m.user_id;
return (
handleToggleDish(item)}
+ key={m.user_id}
+ className={`rounded-full border px-4 py-2.5 ${
+ sel ? "border-accent bg-accent" : "border-gray-200 bg-white"
+ }`}
+ onPress={() => setCookBy(sel ? null : m.user_id)}
>
-
-
- {selected && (
-
-
-
- )}
-
-
-
- {item.name}
-
- {selected && selectedItem && (
-
- {
- e.stopPropagation();
- handleUpdateQuantity(item.id, -1);
- }}
- >
-
-
- {selectedItem.quantity}
- {
- e.stopPropagation();
- handleUpdateQuantity(item.id, 1);
- }}
- >
-
-
-
- )}
-
-
- );
- },
- [handleToggleDish, handleUpdateQuantity, selectedDishIds, selectedDishes],
- );
-
- const renderMemberChip = useCallback(
- (member: FamilyMember) => {
- const selected = cookBy === member.user_id;
- return (
- setCookBy(selected ? null : member.user_id)}
- >
-
- {member.nickname || `用户${member.user_id}`}
-
+ {m.nickname}
);
},
[cookBy],
);
- // 日期快捷选择
- const isToday = planDate === today;
- const isTomorrow = planDate === tomorrow;
+ // ======== 页面 ========
return (
-
+
- {/* ======== 日期选择 ======== */}
- 日期
-
+ {/* 提示 */}
+
+
+ 创建一个空菜单,之后在菜单详情页中添加菜品。
+
+
+
+ {/* 日期 */}
+ 日期
+
setPlanDate(today)}
>
-
+
今天 {dayjs().format("M/D")}
setPlanDate(tomorrow)}
+ className={`rounded-full border px-5 py-2.5 ${
+ planDate === dayjs().add(1, "day").format("YYYY-MM-DD")
+ ? "border-accent bg-accent"
+ : "border-gray-200 bg-white"
+ }`}
+ onPress={() => setPlanDate(dayjs().add(1, "day").format("YYYY-MM-DD"))}
>
-
- 明天 {dayjs().add(1, "day").format("M/D")}
+
+ 明天
{
- setDatePickerValue(planDate);
- setShowDateSheet(true);
+ setDateVal(planDate);
+ setShowDate(true);
}}
>
-
- {planDate === today || planDate === tomorrow ? "其他" : planDate}
-
+ 其他
- {/* ======== 餐段选择 ======== */}
- 餐段
- {slots.length > 0 ? (
-
- {slots.map(renderSlotChip)}
-
- ) : (
- 暂无可用餐段,请先在管理页添加
- )}
-
- {/* ======== 已选菜品摘要 ======== */}
- {selectedDishes.length > 0 && (
-
-
- 已选 ({selectedDishes.length})
-
-
- {selectedDishes.map((d) => (
-
-
-
-
-
-
- {d.dish.name}
-
- x{d.quantity}
-
-
- handleUpdateQuantity(d.dish.id, -1)}
- >
-
-
- {d.quantity}
- handleUpdateQuantity(d.dish.id, 1)}
- >
-
-
-
- handleRemoveDish(d.dish.id)}
- >
-
-
-
- ))}
+ {/* 餐段 */}
+ 餐段(必选)
+ {slots.length > 0
+ ? (
+
+ {slots.map(renderSlot)}
-
- )}
+ )
+ : 暂无可用餐段}
- {/* ======== 菜品选择 ======== */}
-
- 选择菜品 {selectedDishes.length > 0 ? "" : "(至少选 1 道)"}
-
- {dishes.length > 0 ? (
-
- {dishes.map(renderDishItem)}
-
- ) : (
- 暂无菜品,请先在菜品页添加
- )}
+ {/* 做饭人 */}
+ 做饭人(可选)
+ {members.length > 0
+ ? (
+
+ {members.map(renderMember)}
+
+ )
+ : 暂无家庭成员}
- {/* ======== 做饭人 ======== */}
- 做饭人(可选)
- {members.length > 0 ? (
-
- {members.map(renderMemberChip)}
-
- ) : (
- 暂无家庭成员
- )}
-
- {/* ======== 备注 ======== */}
-
-
-
-
-
- {/* ======== 保存按钮 ======== */}
-
+ {/* 按钮 */}
+
-
- {/* ======== 日期选择器 BottomSheet ======== */}
-
+ {/* 日期选择 Sheet */}
+
- 选择日期
-
-
-
-
+ 选择日期
+
+ 日期
+
+
- setShowDateSheet(false)}>
+ setShowDate(false)}>
取消
{
- if (dayjs(datePickerValue).isValid()) {
- setPlanDate(datePickerValue);
- setShowDateSheet(false);
+ if (dayjs(dateVal).isValid()) {
+ setPlanDate(dateVal);
+ setShowDate(false);
} else {
toast.error("日期格式无效");
}
@@ -590,7 +315,7 @@ export default function MealPlanEdit() {
-
+ {!pageReady ? : }
);
}
diff --git a/code/app/app-rn/src/store/clothing.ts b/code/app/app-rn/src/store/clothing.ts
index 2b3bec17..73c9ee69 100644
--- a/code/app/app-rn/src/store/clothing.ts
+++ b/code/app/app-rn/src/store/clothing.ts
@@ -1,5 +1,5 @@
import { create } from "zustand";
-import { loadDataApi, saveDataApi, s3UploadFileApi } from "@/request/api";
+import { loadDataApi, loadDataPageApi, saveDataApi, s3UploadFileApi } from "@/request/api";
import {
ClothingCategory,
ClothingClothes,
@@ -36,6 +36,8 @@ interface ClothingState {
tags: ClothingTag[];
tagRelations: ClothingTagRelation[];
members: ClothingFamilyMember[];
+ clothesTotal: number;
+ outfitsTotal: number;
clear: () => void;
@@ -52,6 +54,8 @@ interface ClothingState {
) => Promise;
fetchMembers: (familyId: number) => Promise;
getOwnerName: (ownerId?: number | null) => string;
+ loadMoreClothes: (familyId: number) => Promise;
+ loadMoreOutfits: (familyId: number) => Promise;
saveClothesChanges: (changes: ClothingSaveChanges) => Promise;
saveOutfitChanges: (changes: ClothingSaveChanges) => Promise;
@@ -126,6 +130,8 @@ export const useClothingStore = create((set, get) => ({
tags: [],
tagRelations: [],
members: [],
+ clothesTotal: 0,
+ outfitsTotal: 0,
clear: () =>
set({
@@ -136,6 +142,8 @@ export const useClothingStore = create((set, get) => ({
tags: [],
tagRelations: [],
members: [],
+ clothesTotal: 0,
+ outfitsTotal: 0,
}),
fetchAll: async (familyId: number) => {
@@ -281,9 +289,9 @@ export const useClothingStore = create((set, get) => ({
if (!familyId) return;
const res = await loadDataApi({
- view_name: TABLES.familyMember,
+ view_name: "v_family_member",
search_condition: "family_id = ?",
- search_columns: ["user_id", "nickname"],
+ search_columns: ["user_id", "nickname", "avatar"],
order_by: "id asc",
args: [familyId],
});
@@ -299,6 +307,54 @@ export const useClothingStore = create((set, get) => ({
return m?.nickname || `用户${ownerId}`;
},
+ loadMoreClothes: async (familyId: number) => {
+ if (!familyId) return false;
+ const state = get();
+ const page = Math.floor(state.clothes.length / 20) + 1;
+ const res = await loadDataPageApi({
+ view_name: TABLES.clothes,
+ search_condition: "family_id = ? and status = ?",
+ search_columns: [],
+ order_by: "sort_number asc, id asc",
+ args: [familyId, ClothingStatus.Enabled],
+ page,
+ page_size: 20,
+ });
+ if (res.isSuccess) {
+ const { rows, total } = res.data;
+ set({
+ clothes: page === 1 ? rows : [...state.clothes, ...rows],
+ clothesTotal: total,
+ });
+ return state.clothes.length + rows.length < total;
+ }
+ return false;
+ },
+
+ loadMoreOutfits: async (familyId: number) => {
+ if (!familyId) return false;
+ const state = get();
+ const page = Math.floor(state.outfits.length / 20) + 1;
+ const res = await loadDataPageApi({
+ view_name: TABLES.outfit,
+ search_condition: "family_id = ? and status = ?",
+ search_columns: [],
+ order_by: "sort_number asc, id asc",
+ args: [familyId, ClothingStatus.Enabled],
+ page,
+ page_size: 20,
+ });
+ if (res.isSuccess) {
+ const { rows, total } = res.data;
+ set({
+ outfits: page === 1 ? rows : [...state.outfits, ...rows],
+ outfitsTotal: total,
+ });
+ return state.outfits.length + rows.length < total;
+ }
+ return false;
+ },
+
saveClothesChanges: async (changes: ClothingSaveChanges) => {
const res = await saveDataApi([
{ table_name: TABLES.clothes, key_field: "id", ...normalizeChanges(changes) },
diff --git a/code/app/app-rn/src/store/meal.ts b/code/app/app-rn/src/store/meal.ts
index f4d327e1..6dac81ff 100644
--- a/code/app/app-rn/src/store/meal.ts
+++ b/code/app/app-rn/src/store/meal.ts
@@ -1,5 +1,5 @@
import { create } from "zustand";
-import { loadDataApi, saveDataApi, s3UploadFileApi } from "@/request/api";
+import { loadDataApi, saveDataApi, s3UploadFileApi, getUniqueIdApi } from "@/request/api";
import {
MealCategory,
MealDish,
@@ -9,6 +9,8 @@ import {
MealPlan,
MealPlanItem,
MealPlanLog,
+ MealPlanLogType,
+ MealPlanStatus,
MealSaveChanges,
MealSlot,
MealStatus,
@@ -98,6 +100,13 @@ interface MealState {
// 家庭动态(占位,待 family feed API 就绪后接入)
recordFeed: (familyId: number, content: string) => void;
+
+ // 菜单事件驱动操作
+ createEmptyPlan: (params: { familyId: number; planDate: string; mealSlotId: number; userId: number }) => Promise;
+ confirmPlan: (params: { planId: number; familyId: number; userId: number }) => Promise;
+ completePlan: (params: { planId: number; familyId: number; userId: number }) => Promise;
+ addDishToPlan: (params: { familyId: number; planId: number; dishId: number; dishName: string; userId: number; sortNumber: number }) => Promise<{ planItemId?: number; logId?: number }>;
+ removeDishFromPlan: (params: { familyId: number; planId: number; planItemId: number; dishName: string; userId: number }) => Promise;
}
function normalizeChanges(changes: MealSaveChanges) {
@@ -474,4 +483,153 @@ export const useMealStore = create((set, get) => ({
// TODO: 接入 family feed API 后实现
// 加菜(type=2)和留下备注(type=4)两个动作触发
},
+
+ // ======== 菜单事件驱动操作 ========
+
+ // 创建空菜单(不需要预先选择菜品)
+ createEmptyPlan: async ({ familyId, planDate, mealSlotId, userId }) => {
+ if (!familyId) return null;
+ const idRes = await getUniqueIdApi(1);
+ const planId = idRes.data?.[0] ?? 0;
+ if (!planId) return null;
+
+ const logIdRes = await getUniqueIdApi(1);
+ const logId = logIdRes.data?.[0] ?? 0;
+
+ const now = new Date().toISOString();
+ const ok = await get().savePlanBundle({
+ planInserts: [{
+ id: planId,
+ family_id: familyId,
+ plan_date: planDate,
+ meal_slot_id: mealSlotId,
+ status: MealPlanStatus.Planning,
+ create_time: now,
+ update_time: now,
+ create_by: userId,
+ update_by: userId,
+ }],
+ planUpdates: [],
+ planItemInserts: [],
+ planItemUpdates: [],
+ planItemDeletes: [],
+ planLogInserts: logId ? [{
+ id: logId,
+ family_id: familyId,
+ plan_id: planId,
+ type: MealPlanLogType.Plan,
+ create_by: userId,
+ create_time: now,
+ }] : [],
+ });
+
+ if (ok) {
+ // 将新创建的 plan 添加到本地状态
+ const newPlan: MealPlan = {
+ id: planId,
+ family_id: familyId,
+ plan_date: planDate,
+ meal_slot_id: mealSlotId,
+ status: MealPlanStatus.Planning,
+ create_time: now,
+ update_time: now,
+ create_by: userId,
+ update_by: userId,
+ };
+ set((s) => ({ plans: [...s.plans, newPlan] }));
+ return planId;
+ }
+ return null;
+ },
+
+ // 确认菜单(菜单已定,但还没做)
+ confirmPlan: async ({ planId, familyId, userId }) => {
+ if (!familyId || !planId) return false;
+ const now = new Date().toISOString();
+ return get().savePlanChanges({
+ updates: [{
+ id: planId,
+ status: MealPlanStatus.Confirmed,
+ update_time: now,
+ update_by: userId,
+ }],
+ });
+ },
+
+ // 完成菜单(已做完,锁定不可修改)
+ completePlan: async ({ planId, familyId, userId }) => {
+ if (!familyId || !planId) return false;
+ const now = new Date().toISOString();
+ return get().savePlanChanges({
+ updates: [{
+ id: planId,
+ status: MealPlanStatus.Completed,
+ update_time: now,
+ update_by: userId,
+ }],
+ });
+ },
+
+ // 添加菜品到菜单(事件驱动:写入 plan_item + plan_log)
+ addDishToPlan: async ({ familyId, planId, dishId, dishName, userId, sortNumber }) => {
+ if (!familyId || !planId) return {};
+ const [idRes, logIdRes] = await Promise.all([getUniqueIdApi(1), getUniqueIdApi(1)]);
+ const itemId = idRes.data?.[0] ?? 0;
+ const logId = logIdRes.data?.[0] ?? 0;
+ if (!itemId) return {};
+
+ const now = new Date().toISOString();
+ const ops: any[] = [
+ {
+ table_name: TABLES.planItem,
+ key_field: "id",
+ inserts: [{ id: itemId, family_id: familyId, plan_id: planId, dish_id: dishId, quantity: 1, sort_number: sortNumber, create_time: now, create_by: userId }],
+ updates: [],
+ deletes: [],
+ },
+ ];
+ if (logId) {
+ ops.push({
+ table_name: TABLES.planLog,
+ key_field: "id",
+ inserts: [{ id: logId, family_id: familyId, plan_id: planId, type: MealPlanLogType.AddDish, ref_id: itemId, dish_name: dishName, create_by: userId, create_time: now }],
+ updates: [],
+ deletes: [],
+ });
+ }
+ const res = await saveDataApi(ops);
+ if (res.isSuccess) {
+ get().recordFeed(familyId, JSON.stringify({ type: "meal_add_dish", plan_id: planId, dish_name: dishName }));
+ }
+ return res.isSuccess ? { planItemId: itemId, logId: logId || undefined } : {};
+ },
+
+ // 移除菜品(事件驱动:删除 plan_item + 写入 plan_log)
+ removeDishFromPlan: async ({ familyId, planId, planItemId, dishName, userId }) => {
+ if (!familyId || !planId || !planItemId) return false;
+ const logIdRes = await getUniqueIdApi(1);
+ const logId = logIdRes.data?.[0] ?? 0;
+
+ const now = new Date().toISOString();
+ const ops: any[] = [
+ {
+ table_name: TABLES.planItem,
+ key_field: "id",
+ inserts: [],
+ updates: [],
+ deletes: [{ id: planItemId }],
+ },
+ ];
+ if (logId) {
+ ops.push({
+ table_name: TABLES.planLog,
+ key_field: "id",
+ inserts: [{ id: logId, family_id: familyId, plan_id: planId, type: MealPlanLogType.RemoveDish, ref_id: planItemId, dish_name: dishName, create_by: userId, create_time: now }],
+ updates: [],
+ deletes: [],
+ });
+ }
+ const res = await saveDataApi(ops);
+ return res.isSuccess;
+ },
}));
diff --git a/code/app/app-rn/src/types/meal.ts b/code/app/app-rn/src/types/meal.ts
index e12c5a9d..91103176 100644
--- a/code/app/app-rn/src/types/meal.ts
+++ b/code/app/app-rn/src/types/meal.ts
@@ -24,6 +24,12 @@ export enum MealPlanLogType {
SetCook = 5,
}
+export enum MealPlanStatus {
+ Planning = 0,
+ Confirmed = 1,
+ Completed = 2,
+}
+
// --- 接口 ---
export interface MealDish {
@@ -128,6 +134,7 @@ export interface MealPlan {
family_id: number;
plan_date: string;
meal_slot_id: number;
+ status: MealPlanStatus;
remark?: string;
cook_by?: number | null;
create_time?: string;
@@ -157,6 +164,7 @@ export interface MealPlanLog {
type: MealPlanLogType;
content?: string;
ref_id?: number | null;
+ dish_name?: string;
create_time?: string;
update_time?: string;
create_by?: number;