From 4d1e6c8b8f9d1d68259582df629ce648663add48 Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Mon, 6 Jul 2026 17:03:39 +0800 Subject: [PATCH] 20260706170339 --- code/app/CLAUDE.md | 149 ---- code/app/CLOTHING_OUTFIT_DESIGN.md | 614 -------------- code/app/CODEX.md | 150 ---- code/app/CODE_STYLE.md | 344 -------- code/app/DESIGN_FOLLOWUPS.md | 36 - code/app/MEAL_ORDER_DESIGN.md | 800 ------------------ code/app/app-go/db/anniversary.sql | 18 + code/app/app-go/db/clothing.sql | 59 +- code/app/app-go/db/finance_auto.sql | 20 + code/app/app-go/db/finance_category.sql | 8 +- code/app/app-go/db/meal.sql | 40 +- code/app/app-go/db/todo.sql | 84 ++ code/app/app-go/internal/ws/handler.go | 4 +- code/app/app-rn/package.json | 1 + code/app/app-rn/pnpm-lock.yaml | 98 +++ .../src/app/(main)/(tabs)/chat/index.tsx | 2 +- .../src/app/anniversary/(tabs)/_layout.tsx | 18 + .../src/app/anniversary/(tabs)/home/index.tsx | 96 +++ .../app-rn/src/app/anniversary/add/index.tsx | 138 +++ .../src/app/clothes/(tabs)/home/index.tsx | 6 +- .../src/app/clothes/(tabs)/manage/index.tsx | 2 +- .../src/app/clothes/(tabs)/outfit/index.tsx | 6 +- .../src/app/clothes/detail/[id]/index.tsx | 8 +- .../app/app-rn/src/app/clothes/edit/index.tsx | 15 +- .../app/clothes/outfit/detail/[id]/index.tsx | 6 +- .../src/app/clothes/outfit/edit/index.tsx | 25 +- .../src/app/finance/(tabs)/home/index.tsx | 165 +++- .../src/app/meal/plan/detail/[id]/index.tsx | 149 +++- .../app-rn/src/app/meal/plan/edit/index.tsx | 8 +- .../app-rn/src/app/todo/(tabs)/_layout.tsx | 18 + .../app-rn/src/app/todo/(tabs)/home/index.tsx | 173 ++++ code/app/app-rn/src/app/todo/add/index.tsx | 173 ++++ .../app-rn/src/app/todo/detail/[id]/index.tsx | 386 +++++++++ .../iconfont/IconAppAnniversary.tsx | 13 + .../src/components/iconfont/IconAppTodo.tsx | 64 ++ .../app-rn/src/components/iconfont/index.tsx | 13 +- code/app/app-rn/src/configs/pages.ts | 16 +- code/app/app-rn/src/helpers/financeAuto.ts | 111 +++ code/app/app-rn/src/helpers/notification.ts | 52 ++ code/app/app-rn/src/store/anniversary.ts | 49 ++ code/app/app-rn/src/store/clothing.ts | 127 ++- code/app/app-rn/src/store/finance.ts | 122 ++- code/app/app-rn/src/store/index.ts | 2 + code/app/app-rn/src/store/todo.ts | 277 ++++++ code/app/app-rn/src/types/anniversary.ts | 26 + code/app/app-rn/src/types/clothing.ts | 6 +- code/app/app-rn/src/types/finance.ts | 25 + code/app/app-rn/src/types/todo.ts | 108 +++ code/app/模块功能文档.md | 200 +++++ code/app/项目开发规范.md | 264 ++++++ 50 files changed, 3045 insertions(+), 2249 deletions(-) delete mode 100644 code/app/CLAUDE.md delete mode 100644 code/app/CLOTHING_OUTFIT_DESIGN.md delete mode 100644 code/app/CODEX.md delete mode 100644 code/app/CODE_STYLE.md delete mode 100644 code/app/DESIGN_FOLLOWUPS.md delete mode 100644 code/app/MEAL_ORDER_DESIGN.md create mode 100644 code/app/app-go/db/anniversary.sql create mode 100644 code/app/app-go/db/finance_auto.sql create mode 100644 code/app/app-go/db/todo.sql create mode 100644 code/app/app-rn/src/app/anniversary/(tabs)/_layout.tsx create mode 100644 code/app/app-rn/src/app/anniversary/(tabs)/home/index.tsx create mode 100644 code/app/app-rn/src/app/anniversary/add/index.tsx create mode 100644 code/app/app-rn/src/app/todo/(tabs)/_layout.tsx create mode 100644 code/app/app-rn/src/app/todo/(tabs)/home/index.tsx create mode 100644 code/app/app-rn/src/app/todo/add/index.tsx create mode 100644 code/app/app-rn/src/app/todo/detail/[id]/index.tsx create mode 100644 code/app/app-rn/src/components/iconfont/IconAppAnniversary.tsx create mode 100644 code/app/app-rn/src/components/iconfont/IconAppTodo.tsx create mode 100644 code/app/app-rn/src/helpers/financeAuto.ts create mode 100644 code/app/app-rn/src/helpers/notification.ts create mode 100644 code/app/app-rn/src/store/anniversary.ts create mode 100644 code/app/app-rn/src/store/todo.ts create mode 100644 code/app/app-rn/src/types/anniversary.ts create mode 100644 code/app/app-rn/src/types/todo.ts create mode 100644 code/app/模块功能文档.md create mode 100644 code/app/项目开发规范.md diff --git a/code/app/CLAUDE.md b/code/app/CLAUDE.md deleted file mode 100644 index 421b8ff1..00000000 --- a/code/app/CLAUDE.md +++ /dev/null @@ -1,149 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Allapp is a family/space mini-program platform — a main shell with sub-apps inside it. The frontend owns most business logic; the backend is deliberately thin and generic (auth, file storage, ID generation, database access). - -- **Backend**: `app-go/` — Go 1.25, Fiber v3.1, pgx (PostgreSQL), JWT, Viper, Zap -- **Frontend**: `app-rn/` — React Native 0.85, Expo 56, Expo Router, Zustand, Alova, HeroUI Native, Uniwind (Tailwind CSS) -- **Database**: PostgreSQL 18.1, database `allapp`, schema `public` - -Read `PROJECT_CONTEXT.md` for deeper context; this file focuses on what you need day-to-day. - -## Commands - -### Backend (`app-go/`) - -```bash -cd app-go -go build ./cmd/app # Build -go run ./cmd/app # Run server -go test ./... # Run all tests -go mod tidy # Tidy dependencies -``` - -### Frontend (`app-rn/`) - -```bash -cd app-rn -pnpm start # Expo dev server (port 8083) -pnpm android # Android build -pnpm ios # iOS build -pnpm lint # oxlint -pnpm exec tsc --noEmit # TypeScript type check -pnpm exec oxfmt # Format code -``` - -WSL-specific: if `node`/`pnpm` are not found, run: -```bash -export PATH="$HOME/.local/share/fnm:$PATH" -eval "$(fnm env --shell bash)" -``` - -## Architecture - -### Bootstrap Flow - -``` -Backend: config → logger → S3 → Postgres → db.Init → unique ID → JWT → WeChat → Fiber server -Frontend: /index → token check → auth/login or family/entry or /home -``` - -### Backend Design - -**All CRUD goes through 5 generic data APIs.** Never create dedicated REST endpoints for feature-specific operations: - -| Endpoint | Purpose | -|---|---| -| `POST /app/data/loadData` | Query by table/view name, conditions, columns, order, args | -| `POST /app/data/loadDataPage` | Paginated query — same as loadData + `page`/`page_size`, returns `{rows, total, page, page_size}` | -| `POST /app/data/loadDataBySql` | Raw SQL query | -| `POST /app/data/saveData` | Batch insert/update/delete by table name and key field | -| `POST /app/data/getUniqueId` | Get snowflake IDs (1–100) | - -Auth endpoints: `/app/auth/login/{qq,wechat,test}`, `/app/bind/{qq,wechat}`. - -**File endpoints:** - -| Endpoint | Purpose | -|---|---| -| `POST /app/s3/upload` | Upload files (multipart form, field name `files`), stored under `YYYY/MM/DD/{uuid}.ext`, returns `string[]` keys | -| `POST /app/s3/delete` | Delete files by keys, body `{keys: string[]}`, failure is logged but still returns success | - -On the frontend: `s3UploadFileApi(files[])` wraps files into a `FormData` with key `"files"` and `multipart/form-data` content type. For deleting uploaded files, use `deleteS3FileQuietly()` from `app-rn/src/utils/file.ts`; do not reimplement S3 key checks or silent delete/catch logic in pages or components. - -Auth token is passed in a custom `Token` header. JWT middleware stores `user_id` in context. Audit fields (`create_time`, `update_time`, `create_by`, `update_by`) are auto-injected by `pkg/db/meta_field.go` for all tables except `b_user` and `b_user_oauth`. - -### Frontend Design - -Expo Router file-based routing under `src/app/`: -- `(main)/(tabs)/` — main shell tabs (home, chat, my) -- `(main)/auth/`, `(main)/family/`, `(main)/tools/`, `(main)/activities/` -- `finance/(tabs)/` — finance sub-app (independent from main) -- Layouts: `AppLayout` for main shell, `ModuleLayout` for sub-app shells, `PageLayout` for simple pages - -State: Zustand with `persist` middleware for `user` and `family` (AsyncStorage). Finance store is in-memory only. - -API layer: single Alova instance (`src/request/index.ts`), API wrappers in `src/request/api.ts`. Key wrappers: `loadDataApi`, `loadDataPageApi` (paginated, returns `PageData` with `rows`/`total`/`page`/`page_size`), `loadDataBySqlApi`, `saveDataApi`, `getUniqueIdApi`. Response codes: 1000 (success), 1001 (unauthorized → redirect to login), 2000 (business error), 3000 (system error). - -Startup flow in `src/app/index.tsx`: -``` -no token → /auth/login -token invalid → /auth/login -no family → /family/entry -has family → /home -``` - -## Critical Conventions - -### 1. No New Dedicated API Endpoints -Always reuse the 5 generic data APIs. If you think a new endpoint is needed, **ask the user first**. Even then, new endpoints must be generic helpers, not feature-specific. - -### 2. Database Tables Must Include Audit Columns -All new business tables must include: `id BIGINT PRIMARY KEY` (snowflake), `create_time TIMESTAMPTZ`, `update_time TIMESTAMPTZ`, `create_by BIGINT`, `update_by BIGINT`. Do not add new tables to `auditExcludeTables` — only `b_user` and `b_user_oauth` are exempt. - -### 3. Family/Space-Centric Data Model -All business data (finance, favorites, etc.) associates with `family_id`. Users link to families via `b_family_member`. Always filter queries by the current user's `family_id`. - -### 4. Frontend Family-Scoped Data Access -On the frontend, keep using the generic data APIs in `app-rn/src/request/api.ts`. Pages are responsible for passing the right family/user scope in `search_condition`, `args`, and inserted rows. Use `useFamilyStore((s) => s.getFamilyId())` for the current family id and `useUserStore((s) => s.getUserId())` for the current user id. - -### 5. Family Feed Recording -Certain actions must call `recordFeed()` from `@/helpers/feed`. Use `{nickname}` placeholder in content — do NOT hardcode nicknames. The `loadFeeds()` helper replaces it dynamically. Scenarios: creating/joining family (`join`), renaming (`rename`), recording bills (`bill`), favoriting apps (`fav`). - -### 6. Frontend: Use Existing Libraries Only -Before writing any page/component, check `app-rn/package.json` for available dependencies. Use HeroUI Native for UI, `@legendapp/list` for long lists, `lucide-react-native` for icons, `sonner-native` for toasts. Use NativeWind `className` for styling, not inline `style={}`. - -### 7. Loading State: Always Use useLoading + LoadingOverlay -Any async save/update/delete operation must use the `useLoading` hook + `` pattern — never roll per-field loading booleans. The hook provides `startLoading(message)` (shows toast.loading + page mask), `stopLoading(options?)` (dismisses toast, optionally shows success/error), and `isLoading` (boolean for disabling buttons). Place `` near the bottom of the page JSX (before closing ``). Reference: `src/hooks/useLoading.ts`, `src/components/LoadingOverlay.tsx`. - -### 8. Finance Convention -`type = 0` means expense, `type = 1` means income. Category icon names match iconfont names (e.g., `finance_jiaotong`, `finance_canyin`). - -## Key Files - -| File | Role | -|---|---| -| `PROJECT_CONTEXT.md` | Full project documentation, risks, feature status | -| `app-go/internal/bootstrap/app.go` | Backend initialization flow | -| `app-go/internal/router/router.go` | Route definitions | -| `app-go/pkg/db/meta_field.go` | Audit field auto-injection | -| `app-rn/src/app/index.tsx` | Startup/auth routing logic | -| `app-rn/src/request/index.ts` | Alova instance, auth interceptor | -| `app-rn/src/request/api.ts` | API wrapper functions | -| `app-rn/src/store/` | Zustand stores (user, family, finance, app, favorite) | -| `app-rn/src/layouts/` | Shell layouts (AppLayout, ModuleLayout, PageLayout) | -| `app-rn/src/configs/pages.ts` | Sub-app definitions | -| `app-rn/src/hooks/useLoading.ts` | Loading state hook (toast + overlay) | -| `app-rn/src/components/LoadingOverlay.tsx` | Full-page loading mask component | -| `app-rn/src/helpers/feed.ts` | Family feed recording | - -## Known Risks - -- Generic data APIs accept table names and raw SQL from the frontend — needs server-side whitelisting before production. -- `db.local.yml` contains database credentials and should never be committed. -- Finance category queries currently don't filter by `family_id`. -- Main tab `/space` route is missing its page file. -- `GetUniqueIdReq.Count` uses `binding` tag instead of `validate` tag — min/max rules may not run. diff --git a/code/app/CLOTHING_OUTFIT_DESIGN.md b/code/app/CLOTHING_OUTFIT_DESIGN.md deleted file mode 100644 index 7942ae13..00000000 --- a/code/app/CLOTHING_OUTFIT_DESIGN.md +++ /dev/null @@ -1,614 +0,0 @@ -# 衣服搭配子模块设计文档 - -## 1. 背景 - -在现有家庭 App 中新增「衣服搭配」子模块,用于管理家庭共享的衣物资产,并基于衣物设计、保存、复用搭配方案。 - -本模块先按 App 内工具设计,不做营销页。界面重点应是图片可浏览、筛选顺手、保存动作明确。 - -## 2. 目标 - -- 录入衣服,形成可管理的衣橱。 -- 给衣服维护标签/分类,分类支持自定义。 -- 设计搭配,选择多件衣服组合成一个搭配方案。 -- 保存搭配,搭配也支持标签/分类。 -- 后续可基于分类、标签等维度检索衣服和搭配。 - -## 3. 非目标 - -第一期暂不做: - -- AI 自动搭配。 -- 社区分享、公开穿搭广场。 -- 电商购买链接。 -- 复杂身材数据、尺码推荐。 -- 日历穿搭计划。 -- 洗护/库存/折旧管理。 -- 多成员私有衣橱隔离。 - -这些能力可以作为后续扩展,不进入第一版主流程。 - -## 4. 核心用户故事 - -### 衣服管理 - -- 作为用户,我可以新增一件衣服,上传图片,填写名称、分类、标签等信息。 -- 作为用户,我可以按分类、标签筛选衣服。 -- 作为用户,我可以编辑或删除已录入的衣服。 -- 作为用户,我可以停用/隐藏不常穿的衣服,而不是必须删除。 -- 作为用户,删除被搭配引用的衣服时,我可以选择是否同时删除相关搭配。 - -### 搭配管理 - -- 作为用户,我可以从衣橱中选择多件衣服组成一套搭配。 -- 作为用户,我可以保存搭配,填写搭配名称、分类、标签等信息。 -- 作为用户,我可以查看搭配详情,看到包含的所有衣服。 -- 作为用户,我可以编辑搭配中的衣服列表和基础信息。 -- 作为用户,我可以删除搭配,删除搭配不会删除搭配中引用的衣服。 - -### 分类/标签管理 - -- 作为用户,我可以维护衣服分类,例如上衣、裤子、外套、鞋、包、配饰。 -- 作为用户,我可以维护搭配分类,例如通勤、约会、运动、旅行、居家。 -- 作为用户,我可以维护标签组,例如尺寸、季节、风格、材质、颜色。 -- 作为用户,我可以给衣服和搭配添加多个标签,例如春秋、黑色、正式、宽松、显瘦。 -- 衣服标签和搭配标签需要区分,避免两个场景下的标签互相干扰。 - -## 5. 信息架构 - -建议一级入口叫「穿搭」或「衣橱」。 - -模块内建议 3 个主视图: - -- 衣橱:衣服列表、筛选、录入入口。 -- 搭配:搭配列表、筛选、创建入口。 -- 分类:衣服分类、搭配分类、标签维护。 - -第一期保留独立「分类」页,放在衣服搭配模块的底部 Tab 中;新增/编辑流程里也可以保留快速新建入口,减少录入中断。 - -## 5.1 已确认规则 - -- 子模块永远是家庭共享,不做个人私有衣橱。 -- 衣服图片必须上传,不允许无图衣服。 -- 分类和标签不做默认初始化,由用户自行创建。 -- 分类保持单层,不做父子结构。 -- 尺寸、季节、风格、颜色等父子结构使用“标签组 -> 标签”表达。 -- 衣服标签和搭配标签区分管理。 -- 不做颜色结构化字段,颜色这类信息由用户通过标签处理。 -- 标签使用表情做视觉识别,不单独配置标签颜色。 -- 不做季节、场景固定枚举,季节/场景由用户通过标签或分类自行处理。 -- 数据保存优先使用现有公共接口,例如 `data/loadData`、`data/saveData`。 -- 尽量都走通用接口;只有通用接口明显无法保证体验或一致性时,再补特殊接口。 -- 能在前端完成的能力优先放前端,减少后端特殊接口。 -- 衣服和搭配支持「归属者」字段(`owner_id`,引用 `b_family_member.user_id`)。归属者仅作为标记,让家人知道这件衣服/搭配是谁的;数据仍为家庭共享,不做个人隔离,也不按归属者过滤可见性。 -- 删除搭配时,只删除搭配和搭配明细,不删除衣服。 -- 删除衣服时,如果衣服没有被搭配引用,直接删除或软删除该衣服。 -- 删除衣服时,如果衣服已被搭配引用,需要提示用户是否同时删除相关搭配: - - 用户确认:删除该衣服,同时删除所有引用该衣服的搭配及其明细。 - - 用户取消:衣服和搭配都不删除。 - -## 5.2 搭配编辑模式 - -第一版采用「自由拖拽画布」模式。 - -推荐模式: - -- 搭配编辑页顶部或主体是固定比例画布,建议 1:1。 -- 下方或底部抽屉是衣服选择区,可按分类、标签筛选衣服。 -- 用户点击衣服加入画布。 -- 画布内衣服支持拖拽移动、缩放、删除。 -- 第一版可以先不做旋转,降低手势复杂度。 -- 画布内衣服支持置顶/置底,或通过点击选中后调整层级。 -- 保存搭配时保存画布布局数据,并生成搭配缩略图。 - -原因: - -- 穿搭本质上是视觉组合,固定槽位会限制用户表达。 -- 用户需要自己控制衣服摆放位置,否则操作体验会显得僵硬。 -- 自由画布更接近“搭配板”,更符合用户对穿搭设计的预期。 - -第一版边界: - -- 画布比例固定为 1:1,不支持任意画布尺寸。 -- 每件衣服记录位置、尺寸、层级;第一版暂不支持旋转。 -- 不做撤销/重做。 -- 不做自动吸附、对齐辅助线。 -- 不做多选。 -- 不做复杂贴纸、文字、背景。 -- 画布背景固定为浅色或透明。 - -后续扩展: - -- 支持旋转。 -- 支持撤销/重做。 -- 支持吸附、对齐线、居中线。 -- 支持背景模板。 -- 支持文字说明或贴纸。 - -## 6. 数据对象草案 - -### 衣服 b_clothing_clothes - -字段草案: - -- id -- family_id -- name -- image_file_key -- category_id -- owner_id -- brand -- remark -- status -- sort_number -- create_time -- update_time -- create_by -- update_by - -说明: - -- `family_id`:衣服属于家庭空间。 -- `image_file_key`:走现有 S3 规则,上传到 `family_id/年/月/日/...`。 -- `image_file_key` 必填。 -- `status`:建议 1 启用,0 停用。 -- `owner_id`:归属者,引用 `b_family_member.user_id`,可空。仅作为标记,让家人知道这件衣服是谁的,**不改变家庭共享语义,不做个人衣橱隔离**。 -- 颜色、季节这类检索信息不做结构化字段,由标签承载。 - -### 搭配 b_clothing_outfit - -字段草案: - -- id -- family_id -- name -- cover_file_key -- layout_json -- category_id -- owner_id -- remark -- status -- sort_number -- create_time -- update_time -- create_by -- update_by - -说明: - -- `cover_file_key` 建议保存一张搭配缩略图,用于搭配列表快速展示。 -- 缩略图只保存生成后的结果,列表不实时拼图。 -- 缩略图由前端生成后通过 S3 上传,保存时写入 `cover_file_key`。 -- 如果缩略图生成失败,可以降级使用搭配内第一件衣服图片作为封面。 -- `layout_json` 保存画布级配置,例如画布宽高比例、背景、版本号等。 -- `owner_id`:归属者,引用 `b_family_member.user_id`,可空。仅作为标记,让家人知道这套搭配是谁的,**不改变家庭共享语义,不做个人衣橱隔离**。 - -### 搭配明细 b_clothing_outfit_clothes - -字段草案: - -- id -- outfit_id -- clothes_id -- x -- y -- width -- height -- z_index -- sort_number -- create_time - -说明: - -- 一套搭配包含多件衣服。 -- `x/y/width/height` 建议使用归一化比例值,范围 0 到 1,便于不同屏幕尺寸还原。 -- `z_index` 用于控制叠放层级。 -- `sort_number` 可作为列表排序或备用顺序。 -- 第一版不保存旋转角度,后续如果支持旋转再增加 `rotate` 字段。 - -### 分类 b_clothing_category - -字段草案: - -- id -- family_id -- name -- type -- sort_number -- status - -说明: - -- `type = 0` 衣服分类。 -- `type = 1` 搭配分类。 -- 衣服分类和搭配分类可以共用一张表,减少表数量。 -- 分类保持单层,不设置 `parent_id`。 - -### 标签组 b_clothing_tag_group - -字段草案: - -- id -- family_id -- name -- type -- sort_number -- status - -说明: - -- `type = 0` 衣服标签组。 -- `type = 1` 搭配标签组。 -- 示例:尺寸、季节、风格、材质、颜色。 -- 标签组由用户自定义,不做默认初始化。 - -### 标签 b_clothing_tag - -字段草案: - -- id -- family_id -- tag_group_id -- name -- type -- emoji -- sort_number -- status - -说明: - -- `type = 0` 衣服标签。 -- `type = 1` 搭配标签。 -- `tag_group_id` 指向所属标签组,例如“XL”属于“尺寸”。 -- `emoji` 用于标签视觉识别。颜色不做结构化配置。 - -### 标签关联 b_clothing_tag_relation - -字段草案: - -- id -- family_id -- target_type -- target_id -- tag_id - -说明: - -- `target_type = 0` 衣服。 -- `target_type = 1` 搭配。 -- 一个衣服/搭配可以有多个标签。 - -## 7. 第一版流程 - -### 新增衣服 - -1. 进入「衣橱」。 -2. 点击新增。 -3. 上传或拍摄衣服图片。 -4. 填写名称、分类、标签。 -5. 保存。 -6. 返回衣橱列表并刷新。 - -校验规则: - -- 图片必填。 -- 分类可以为空,但如果用户输入新分类,应自动创建分类后再保存衣服。 -- 标签可以为空,支持选择已有标签或新建标签。 - -### 新增搭配 - -1. 进入「搭配」。 -2. 点击新增。 -3. 进入自由画布编辑页。 -4. 从底部衣服选择区添加衣服到画布。 -5. 在画布中拖拽、缩放衣服,调整层级。 -6. 填写搭配名称、分类、标签。 -7. 生成搭配缩略图并上传。 -8. 保存搭配、搭配明细、画布布局和缩略图 key。 -9. 返回搭配列表并刷新。 - -校验规则: - -- 至少选择 1 件衣服。 -- 分类可以为空。 -- 标签可以为空。 -- 画布内每件衣服必须有有效的位置和尺寸。 - -### 自定义分类/标签组/标签 - -第一版采用组合方案: - -- 在底部 Tab 中提供独立「分类」页,统一维护衣服/搭配分类、标签组、标签。 -- 在新增/编辑表单里保留快速新建分类、标签组、标签能力,减少录入中断。 - -### 删除衣服 - -1. 用户点击删除衣服。 -2. 后端检查是否存在搭配引用。 -3. 如果没有引用,删除衣服。 -4. 如果有引用,前端提示“该衣服已被 X 个搭配使用,是否同时删除这些搭配?” -5. 用户确认后,后端在同一事务中删除衣服、相关搭配、相关搭配明细、相关标签关联。 -6. 用户取消后,不执行任何删除。 - -### 删除搭配 - -1. 用户点击删除搭配。 -2. 后端删除搭配、搭配明细、搭配标签关联。 -3. 不删除任何衣服。 - -### 衣服快速记账 - -这是后置功能,可以最后再做。 - -1. 用户在编辑衣服页点击记账按钮。 -2. 页面弹出新增记账 BottomSheet。 -3. BottomSheet 复用现有记账新增能力。 -4. 可选地把衣服名称带入备注,例如“衣服:白色衬衫”。 -5. 保存成功后关闭 BottomSheet,不影响衣服编辑状态。 - -## 8. MVP 范围建议 - -第一期建议做: - -- 衣服 CRUD。 -- 搭配 CRUD。 -- 衣服分类自定义。 -- 搭配分类自定义。 -- 衣服/搭配标签组自定义。 -- 衣服/搭配标签多选和新建。 -- 图片上传。 -- 自由拖拽画布。 -- 画布内衣服拖拽、缩放、层级调整、删除。 -- 搭配缩略图生成。 -- 基础筛选:分类、标签。 - -第一期暂缓: - -- AI 搭配推荐。 -- 穿搭日历。 -- 多成员衣橱隔离。 -- 画布旋转、撤销/重做、吸附对齐、多选。 -- 衣服编辑页快速记账。 -- 统计分析。 - -## 9. 剩余待确认问题 - -暂无。 - -## 10. 初步技术建议 - -- 数据库建议新增独立表,不混入现有记账分类。 -- 图片上传复用现有 S3 上传接口,路径遵循 `family_id/年/月/日`。 -- 衣服、搭配、分类、标签组、标签保存优先复用当前公共接口 `data/saveData`。 -- 删除衣服前先用公共查询接口检查搭配引用;用户确认后,用 `data/saveData` 批量删除衣服、相关搭配、明细和标签关联。 -- 分类/标签组/标签创建也优先走公共接口。 -- 自由画布数据优先直接存入搭配明细字段;如果后续字段膨胀,再考虑使用 `layout_json` 承载更复杂的画布数据。 -- 搭配缩略图由前端生成,尺寸可先定为 512x512,S3 路径同样遵循 `family_id/年/月/日`。 -- 当前 `react-native-view-shot` 支持 `jpg/png`,首版使用 512x512 JPG;后续如果补图片转换链路,再升级为 WebP。 -- 前端缩略图生成可考虑使用 `react-native-view-shot` 截取画布视图,再走现有 S3 上传。 -- 衣服快速记账复用现有新增记账 BottomSheet,作为后置功能实现。 -- 前端状态可以新增 `clothing` store,负责衣服、搭配、分类、标签组、标签缓存。 - -## 11. 下一步 - -已确认核心业务规则。下一步可以进入: - -1. 数据库表结构设计。 -2. 页面与交互流程设计。 -3. 公共接口调用方案设计。 -4. 第一版任务拆分。 - -缩略图样式可以在页面流程设计阶段继续细化。 - -## 12. 实施计划 - -说明: - -- 每一步完成后,把 `[ ]` 改成 `[x]`。 -- 严格按阶段推进,前一阶段没有确认前,不进入下一阶段。 -- 默认优先使用通用接口;遇到通用接口明显无法覆盖的场景,再单独讨论是否增加特殊接口。 - -### 阶段 1:数据库表结构 - -- [x] 设计 `b_clothing_clothes` 表结构。 -- [x] 设计 `b_clothing_outfit` 表结构。 -- [x] 设计 `b_clothing_outfit_clothes` 表结构,包含画布坐标、尺寸、层级字段。 -- [x] 设计 `b_clothing_category` 表结构,分类保持单层。 -- [x] 设计 `b_clothing_tag_group` 表结构。 -- [x] 设计 `b_clothing_tag` 表结构,关联 `tag_group_id`。 -- [x] 设计 `b_clothing_tag_relation` 表结构。 -- [x] 补充索引设计,例如 `family_id/status/type/sort_number`。 -- [x] 生成 SQL 文件并确认命名、字段类型、默认值。 -- [x] 执行 SQL 到 dev 数据库并验证 7 张表已创建。 - -产出文件: - -- `app-go/db/clothing.sql` - -验收标准: - -- 表名全部使用 `b_clothing_` 前缀。 -- 不出现 `ref` 表名。 -- 分类无父子结构。 -- 标签组和标签可以表达尺寸、季节、风格等自定义属性。 - -### 阶段 2:前端基础模型与公共接口封装 - -- [x] 新增衣服、搭配、分类、标签组、标签相关 TypeScript 类型。 -- [x] 新增 `clothing` store,管理衣服、搭配、分类、标签组、标签缓存。 -- [x] 在 `clothing` store 中封装基于公共接口的查询方法。 -- [x] 在 `clothing` store 中封装基于公共接口的保存方法。 -- [x] 在 `clothing` store 中封装图片上传复用逻辑,路径继续走 `family_id/年/月/日`。 -- [x] 不新增 services 层,避免重复封装。 - -产出文件: - -- `app-rn/src/types/clothing.ts` -- `app-rn/src/store/clothing.ts` -- `app-rn/src/store/index.ts` - -验收标准: - -- 前端类型和数据库字段一致。 -- 查询和保存优先使用现有 `loadData`、`saveData`。 -- 不引入不必要的后端特殊接口。 - -### 阶段 3:衣服管理 - -- [x] 新增衣橱列表页。 -- [x] 支持按分类、标签筛选衣服。 -- [x] 新增衣服编辑页或 BottomSheet。 -- [x] 衣服图片必填,支持上传图片。 -- [x] 支持创建或选择分类。 -- [x] 支持创建或选择标签组/标签。 -- [x] 支持编辑衣服。 -- [x] 支持删除衣服前检查搭配引用。 -- [x] 如果存在搭配引用,提示是否同时删除相关搭配。 -- [x] 用户确认后批量删除衣服、相关搭配、搭配明细、标签关联。 -- [x] 用户取消后不删除任何数据。 - -产出文件: - -- `app-rn/src/app/clothes/(tabs)/_layout.tsx` -- `app-rn/src/app/clothes/(tabs)/home/index.tsx` -- `app-rn/src/app/clothes/(tabs)/outfit/index.tsx` -- `app-rn/src/app/clothes/edit/index.tsx` -- `app-rn/src/components/clothing/ClothingImage.tsx` - -验收标准: - -- 无图衣服不能保存。 -- 删除衣服的引用提示逻辑正确。 -- 删除搭配时不会误删衣服。 - -### 阶段 4:分类、标签组、标签管理 - -- [x] 在衣服编辑流程中支持快速新建分类。 -- [x] 在衣服编辑流程中支持快速新建标签组。 -- [x] 在衣服编辑流程中支持在标签组下新建标签。 -- [x] 标签支持 `emoji`。 -- [x] 衣服标签和搭配标签按 `type` 区分。 -- [x] 独立分类/标签管理页放到底部 Tab。 -- [x] 独立管理页支持维护衣服分类、搭配分类。 -- [x] 独立管理页支持维护衣服标签组/标签、搭配标签组/标签。 - -产出文件: - -- `app-rn/src/app/clothes/(tabs)/_layout.tsx` -- `app-rn/src/app/clothes/(tabs)/manage/index.tsx` - -验收标准: - -- 分类单层。 -- 标签必须归属标签组。 -- 衣服标签和搭配标签互不混用。 - -### 阶段 5:自由拖拽搭配画布 - -- [x] 新增搭配列表页。 -- [x] 新增搭配编辑页。 -- [x] 搭配编辑页提供 1:1 固定比例画布。 -- [x] 从衣服选择区添加衣服到画布。 -- [x] 支持画布内拖拽移动。 -- [x] 支持画布内缩放。 -- [x] 支持删除画布中的衣服。 -- [x] 支持调整层级,例如置顶、置底。 -- [x] 保存每件衣服的 `x/y/width/height/z_index`。 -- [x] 坐标和尺寸使用 0 到 1 的归一化比例。 -- [x] 重新打开搭配时准确还原画布。 - -产出文件: - -- `app-rn/src/app/clothes/(tabs)/outfit/index.tsx` -- `app-rn/src/app/clothes/outfit/edit/index.tsx` - -第一版不做: - -- 旋转。 -- 撤销/重做。 -- 多选。 -- 吸附线和对齐线。 -- 文字、贴纸、背景模板。 - -验收标准: - -- 不同屏幕尺寸下画布能稳定还原。 -- 画布操作不卡顿。 -- 保存后再次进入,布局不丢失。 - -### 阶段 6:搭配保存与缩略图 - -- [x] 引入或确认 `react-native-view-shot`。 -- [x] 保存搭配前截取画布生成缩略图。 -- [x] 缩略图上传到 S3。 -- [x] 保存搭配主表 `cover_file_key`。 -- [x] 保存搭配明细和画布布局。 -- [x] 搭配列表使用 `cover_file_key` 展示。 -- [x] 缩略图生成失败时,降级使用第一件衣服图片作为封面。 - -产出文件: - -- `app-rn/src/app/clothes/outfit/edit/index.tsx` -- `app-rn/src/store/clothing.ts` - -验收标准: - -- 搭配列表不实时拼图,只加载缩略图。 -- 缩略图路径遵循 `family_id/年/月/日`。 -- 保存失败时不产生错误的半成品数据。 - -### 阶段 7:搭配管理 - -- [x] 支持搭配详情查看。 -- [x] 支持编辑搭配基础信息。 -- [x] 支持编辑搭配画布。 -- [x] 支持删除搭配。 -- [x] 删除搭配时删除搭配明细和搭配标签关联。 -- [x] 删除搭配时不删除衣服。 - -产出文件: - -- `app-rn/src/app/clothes/(tabs)/outfit/index.tsx` -- `app-rn/src/app/clothes/outfit/detail/[id]/index.tsx` -- `app-rn/src/app/clothes/outfit/edit/index.tsx` - -验收标准: - -- 搭配 CRUD 完整。 -- 删除搭配不影响衣橱。 - -### 阶段 8:收尾与体验优化 - -- [x] 补充空状态。 -- [x] 补充加载状态。 -- [x] 补充保存中和上传中状态。 -- [x] 补充错误提示。 -- [x] 检查移动端小屏布局。 -- [x] 检查图片加载失败兜底。 -- [x] 跑 lint/format/type check。 -- [x] 根据实际结果更新本文档完成状态。 - -验证结果: - -- `oxfmt --check` 通过。 -- `oxlint` 通过。 -- `tsc --noEmit` 已执行,仅剩 `src/components/iconfont/*` SVG 类型问题,本阶段不处理。 - -验收标准: - -- 核心流程从新增衣服到保存搭配可完整走通。 -- 异常状态有明确反馈。 -- 文档计划状态和代码实现保持一致。 - -### 阶段 9:后置功能:衣服快速记账 - -- [ ] 在衣服编辑页增加记账入口。 -- [ ] 点击后弹出新增记账 BottomSheet。 -- [ ] 复用现有记账新增能力。 -- [ ] 可选地把衣服名称带入备注。 -- [ ] 保存成功后关闭 BottomSheet。 - -验收标准: - -- 快速记账不影响衣服编辑状态。 -- 记账保存逻辑与现有记账模块一致。 diff --git a/code/app/CODEX.md b/code/app/CODEX.md deleted file mode 100644 index 6e22b712..00000000 --- a/code/app/CODEX.md +++ /dev/null @@ -1,150 +0,0 @@ -# CODEX.md - -This file provides guidance to Codex when working with code in this repository. - -## Project Overview - -Allapp is a family/space mini-program platform — a main shell with sub-apps inside it. The frontend owns most business logic; the backend is deliberately thin and generic (auth, file storage, ID generation, database access). - -- **Backend**: `app-go/` — Go 1.25, Fiber v3.1, pgx (PostgreSQL), JWT, Viper, Zap -- **Frontend**: `app-rn/` — React Native 0.85, Expo 56, Expo Router, Zustand, Alova, HeroUI Native, Uniwind (Tailwind CSS) -- **Database**: PostgreSQL 18.1, database `allapp`, schema `public` - -Read `PROJECT_CONTEXT.md` for deeper context; this file focuses on what you need day-to-day. - -## Commands - -### Backend (`app-go/`) - -```bash -cd app-go -go build ./cmd/app # Build -go run ./cmd/app # Run server -go test ./... # Run all tests -go mod tidy # Tidy dependencies -``` - -### Frontend (`app-rn/`) - -```bash -cd app-rn -pnpm start # Expo dev server (port 8083) -pnpm android # Android build -pnpm ios # iOS build -pnpm lint # oxlint -pnpm exec tsc --noEmit # TypeScript type check -pnpm exec oxfmt # Format code -``` - -WSL-specific: if `node`/`pnpm` are not found, run: -```bash -export PATH="$HOME/.local/share/fnm:$PATH" -eval "$(fnm env --shell bash)" -``` - -## Architecture - -### Bootstrap Flow - -``` -Backend: config → logger → S3 → Postgres → db.Init → unique ID → JWT → WeChat → Fiber server -Frontend: /index → token check → auth/login or family/entry or /home -``` - -### Backend Design - -**All CRUD goes through 5 generic data APIs.** Never create dedicated REST endpoints for feature-specific operations: - -| Endpoint | Purpose | -|---|---| -| `POST /app/data/loadData` | Query by table/view name, conditions, columns, order, args | -| `POST /app/data/loadDataPage` | Paginated query — same as loadData + `page`/`page_size`, returns `{rows, total, page, page_size}` | -| `POST /app/data/loadDataBySql` | Raw SQL query | -| `POST /app/data/saveData` | Batch insert/update/delete by table name and key field | -| `POST /app/data/getUniqueId` | Get snowflake IDs (1–100) | - -Auth endpoints: `/app/auth/login/{qq,wechat,test}`, `/app/bind/{qq,wechat}`. - -**File endpoints:** - -| Endpoint | Purpose | -|---|---| -| `POST /app/s3/upload` | Upload files (multipart form, field name `files`), stored under `YYYY/MM/DD/{uuid}.ext`, returns `string[]` keys | -| `POST /app/s3/delete` | Delete files by keys, body `{keys: string[]}`, failure is logged but still returns success | - -On the frontend: `s3UploadFileApi(files[])` wraps files into a `FormData` with key `"files"` and `multipart/form-data` content type. For deleting uploaded files, use `deleteS3FileQuietly()` from `app-rn/src/utils/file.ts`; do not reimplement S3 key checks or silent delete/catch logic in pages or components. - -Auth token is passed in a custom `Token` header. JWT middleware stores `user_id` in context. Audit fields (`create_time`, `update_time`, `create_by`, `update_by`) are auto-injected by `pkg/db/meta_field.go` for all tables except `b_user` and `b_user_oauth`. - -### Frontend Design - -Expo Router file-based routing under `src/app/`: -- `(main)/(tabs)/` — main shell tabs (home, chat, my) -- `(main)/auth/`, `(main)/family/`, `(main)/tools/`, `(main)/activities/` -- `finance/(tabs)/` — finance sub-app (independent from main) -- Layouts: `AppLayout` for main shell, `ModuleLayout` for sub-app shells, `PageLayout` for simple pages - -State: Zustand with `persist` middleware for `user` and `family` (AsyncStorage). Finance store is in-memory only. - -API layer: single Alova instance (`src/request/index.ts`), API wrappers in `src/request/api.ts`. Key wrappers: `loadDataApi`, `loadDataPageApi` (paginated, returns `PageData` with `rows`/`total`/`page`/`page_size`), `loadDataBySqlApi`, `saveDataApi`, `getUniqueIdApi`. Response codes: 1000 (success), 1001 (unauthorized → redirect to login), 2000 (business error), 3000 (system error). - -Startup flow in `src/app/index.tsx`: -``` -no token → /auth/login -token invalid → /auth/login -no family → /family/entry -has family → /home -``` - -## Critical Conventions - -### 1. No New Dedicated API Endpoints -Always reuse the 5 generic data APIs. If you think a new endpoint is needed, **ask the user first**. Even then, new endpoints must be generic helpers, not feature-specific. - -### 2. Database Tables Must Include Audit Columns -All new business tables must include: `id BIGINT PRIMARY KEY` (snowflake), `create_time TIMESTAMPTZ`, `update_time TIMESTAMPTZ`, `create_by BIGINT`, `update_by BIGINT`. Do not add new tables to `auditExcludeTables` — only `b_user` and `b_user_oauth` are exempt. - -### 3. Family/Space-Centric Data Model -All business data (finance, favorites, etc.) associates with `family_id`. Users link to families via `b_family_member`. Always filter queries by the current user's `family_id`. - -### 4. Frontend Family-Scoped Data Access -On the frontend, keep using the generic data APIs in `app-rn/src/request/api.ts`. Pages are responsible for passing the right family/user scope in `search_condition`, `args`, and inserted rows. Use `useFamilyStore((s) => s.getFamilyId())` for the current family id and `useUserStore((s) => s.getUserId())` for the current user id. - -### 5. Family Feed Recording -Certain actions must call `recordFeed()` from `@/helpers/feed`. Use `{nickname}` placeholder in content — do NOT hardcode nicknames. The `loadFeeds()` helper replaces it dynamically. Scenarios: creating/joining family (`join`), renaming (`rename`), recording bills (`bill`), favoriting apps (`fav`). - -### 6. Frontend: Use Existing Libraries Only -Before writing any page/component, check `app-rn/package.json` for available dependencies. Use HeroUI Native for UI, `@legendapp/list` for long lists, `lucide-react-native` for icons, `sonner-native` for toasts. Use NativeWind `className` for styling, not inline `style={}`. - -### 7. Loading State: Always Use useLoading + LoadingOverlay -Any async save/update/delete operation must use the `useLoading` hook + `` pattern — never roll per-field loading booleans. The hook provides `startLoading(message)` (shows toast.loading + page mask), `stopLoading(options?)` (dismisses toast, optionally shows success/error), and `isLoading` (boolean for disabling buttons). Place `` near the bottom of the page JSX (before closing ``). Reference: `src/hooks/useLoading.ts`, `src/components/LoadingOverlay.tsx`. - -### 8. Finance Convention -`type = 0` means expense, `type = 1` means income. Category icon names match iconfont names (e.g., `finance_jiaotong`, `finance_canyin`). - -## Key Files - -| File | Role | -|---|---| -| `PROJECT_CONTEXT.md` | Full project documentation, risks, feature status | -| `app-go/internal/bootstrap/app.go` | Backend initialization flow | -| `app-go/internal/router/router.go` | Route definitions | -| `app-go/pkg/db/meta_field.go` | Audit field auto-injection | -| `app-rn/src/app/index.tsx` | Startup/auth routing logic | -| `app-rn/src/request/index.ts` | Alova instance, auth interceptor | -| `app-rn/src/request/api.ts` | API wrapper functions | -| `app-rn/src/store/` | Zustand stores (user, family, finance, app, favorite) | -| `app-rn/src/layouts/` | Shell layouts (AppLayout, ModuleLayout, PageLayout) | -| `app-rn/src/configs/pages.ts` | Sub-app definitions | -| `app-rn/src/hooks/useLoading.ts` | Loading state hook (toast + overlay) | -| `app-rn/src/components/LoadingOverlay.tsx` | Full-page loading mask component | -| `app-rn/src/helpers/feed.ts` | Family feed recording | - -## Known Risks - -- Generic data APIs accept table names and raw SQL from the frontend — needs server-side whitelisting before production. -- `db.local.yml` contains database credentials and should never be committed. -- Finance category queries currently don't filter by `family_id`. -- Main tab `/space` route is missing its page file. -- `GetUniqueIdReq.Count` uses `binding` tag instead of `validate` tag — min/max rules may not run. - diff --git a/code/app/CODE_STYLE.md b/code/app/CODE_STYLE.md deleted file mode 100644 index ac054d2f..00000000 --- a/code/app/CODE_STYLE.md +++ /dev/null @@ -1,344 +0,0 @@ -# 代码开发规范 - -本文档供 AI 开发工具(如 Claude Code)在本项目中开发时参考。记录后端(Go)和前端(React Native / TypeScript)的编码约定、模式及约束。 - ---- - -## 1. 通用原则 - -- 编辑器使用 2 空格缩进(Go 使用 tab)。 -- 避免过早抽象。三次重复的代码优于一个过早的通用抽象。 -- 不写多余的注释。只有当"为什么这样做"不显而易见时才加注释——隐藏约束、微妙的不变量、特定 bug 的变通方案。 -- 不加只为了"安全"的错误处理和校验。信任内部代码和框架的保证,只在系统边界(用户输入、外部 API)做校验。 -- 所有引用路径使用项目别名(前端 `@/`,后端 `allapp-go/`),不使用相对路径 `../../`。 - ---- - -## 2. Go 后端(`app-go/`) - -### 2.1 项目结构 - -``` -app-go/ - internal/ - config/ 配置加载 - errors/ 错误包装 - handle/ HTTP handler(按领域分文件) - httpx/ HTTP 请求/响应工具 - middleware/ Fiber 中间件 - router/ 路由注册 - types/ DTO(按领域分文件) - ws/ WebSocket - pkg/ - common/ 通用工具 - db/ PostgreSQL 客户端(insert/select/update/delete/tx/validate) - jwtx/ JWT 工具 - logger/ 日志 - requestx/ HTTP 请求工具 - s3store/ S3 存储 - uniqueid/ 雪花 ID 生成 - wechat/ 微信登录 -``` - -### 2.2 命名规范 - -| 成员 | 规范 | 示例 | -|---|---|---| -| 导出函数/类型 | `PascalCase` | `LoadData`, `LoginQqReq` | -| 非导出函数 | `camelCase` | `handleThirdLogin`, `toInt64` | -| 局部变量 | `camelCase` | `dbClient`, `token`, `vo` | -| 包名 | 单个小写单词 | `package handle`, `package db` | -| 文件名 | `snake_case.go` | `meta_field.go`, `data_save.go` | -| JSON 标签 | `snake_case` | `json:"family_id"`, `json:"search_condition"` | -| 验证标签 | `validate:"required"` | 使用 `go-playground/validator` 库 | -| 枚举/常量 | `PascalCase` | `CtxUserIDKey` | - -### 2.3 文件组织 - -- **每个 handler 文件**按领域拆分:`auth.go`、`data_list.go`、`data_save.go`、`s3.go`。 -- **每个 types 文件**按对应 handler 拆分:`auth.go`、`data_list.go`、`data_save.go`、`s3.go`。 -- 使用注释块分隔函数区域: - ```go - // LoginQq ======================== QQ 登录 ======================== - // ==================== data ==================== - ``` - -### 2.4 导入规范 - -内部包排第一组,第三方包排第二组,两组之间空一行: - -```go -import ( - "allapp-go/internal/httpx" - "allapp-go/internal/types" - - "github.com/gofiber/fiber/v3" -) -``` - -### 2.5 错误处理 - -- 来自数据库、JWT、S3 等外部调用的错误,用 `errors.WithStack(err)` 包装后向上返回。 -- 业务逻辑错误直接调用 `httpx.Fail(c, "用户可读消息")` 或 `httpx.Unauthorized(c, "消息")` 返回 JSON 响应。 -- 数据库事务使用闭包模式:`dbClient.WithTx(c.Context(), func(tx *db.Client) error { ... })`。 - -### 2.6 CRUD 约束 - -**绝不创建专用 REST 端点。** 所有 CRUD 必须复用 5 个通用数据 API: - -| 端点 | 用途 | -|---|---| -| `POST /app/data/loadData` | 按条件查询 | -| `POST /app/data/loadDataPage` | 分页查询 | -| `POST /app/data/loadDataBySql` | 原生 SQL 查询 | -| `POST /app/data/saveData` | 批量增/改/删 | -| `POST /app/data/getUniqueId` | 获取雪花 ID | - -必要时可以添加新接口,但**必须先询问用户并获得确认后**才可添加。即使新增,端点也必须是通用型的。 - -### 2.7 数据库表要求 - -所有业务表必须包含以下审计字段: - -```sql -id BIGINT PRIMARY KEY, -- 雪花 ID -create_time TIMESTAMPTZ, -update_time TIMESTAMPTZ, -create_by BIGINT, -update_by BIGINT -``` - -审计字段由 `pkg/db/meta_field.go` 自动注入。不要将表加入 `auditExcludeTables`(只有 `b_user` 和 `b_user_oauth` 豁免)。 - -### 2.8 数据模型约束 - -- 所有业务数据与 `family_id` 关联。 -- 查询时必须按当前用户的 `family_id` 过滤。 -- 用户通过 `b_family_member` 与家庭关联。 - ---- - -## 3. React Native / TypeScript 前端(`app-rn/`) - -### 3.1 项目结构 - -``` -app-rn/src/ - app/ Expo Router 页面(文件即路由) - components/ 可复用组件 - configs/ 配置定义 - helpers/ 业务辅助函数 - hooks/ 自定义 Hooks - layouts/ 布局组件 - request/ API 实例 + 封装 - store/ Zustand 状态 - types/ TypeScript 类型 - utils/ 通用工具函数 -``` - -### 3.2 命名规范 - -| 成员 | 规范 | 示例 | -|---|---|---| -| 组件函数 | `PascalCase` | `Home`, `PageLayout`, `LoadingOverlay` | -| 普通函数 | `camelCase` | `formatMoney`, `loadBills`, `createDefaultFilter` | -| hooks | `camelCase` 以 `use` 开头 | `useLoading`, `useFamilyStore` | -| API 函数 | `camelCase` 以 `Api` 结尾 | `loginQqApi`, `loadDataApi`, `saveDataApi` | -| 接口/类型 | `PascalCase` | `FinanceBill`, `UserState`, `FilterState` | -| 枚举 | `PascalCase`,成员 `PascalCase` | `enum FinanceType { Expense = 0, Income = 1 }` | -| 文件名 | `camelCase.ts` / `camelCase.tsx` | `useLoading.ts`, `finance.ts` | -| 目录名 | 小写单数 | `hooks/`, `store/`, `types/` | - -### 3.3 TypeScript 类型 - -- 优先使用 `interface` 而不是 `type`。 -- 全局类型定义在 `src/types/global.d.ts` 中,使用 `declare global {}` 扩充。 -- 领域类型按文件拆分:`auth.ts`、`finance.ts`、`chat.ts` 等。 -- 每个文件保持单一责任原则。 - -### 3.4 API 调用模式 - -**所有数据操作经过 5 个通用 API 封装函数**(定义在 `src/request/api.ts`),在页面中调用: - -```typescript -// 查询 -const res = await loadDataApi({ - view_name: "b_finance", - search_condition: "family_id = ? and create_by = ?", - order_by: "create_time desc", - search_columns: ["id", "amount", "remark"], - args: [familyId, userId], -}); - -// 分页查询 -const res = await loadDataPageApi({ - view_name: "b_finance", - search_condition: "family_id = ?", - order_by: "create_time desc", - search_columns: ["id", "amount", "remark"], - args: [familyId], - page: 1, - page_size: 20, -}); - -// 原生 SQL 查询 -const res = await loadDataBySqlApi({ - sql: "select * from b_finance where family_id = ?", - args: [familyId], -}); - -// 增/改/删 -const res = await saveDataApi([ - { - table_name: "b_finance", - key_field: "id", - inserts: [{ id, family_id, category_id, amount, record_time }], - updates: [{ id, amount }], - deletes: [{ id }], - }, -]); -``` - -响应包含 `res.isSuccess` 布尔值判断结果。 - -数据访问必须传递正确的 `family_id` 和 `user_id`: - -```typescript -const familyId = useFamilyStore((s) => s.getFamilyId()); -const userId = useUserStore((s) => s.getUserId()); -``` - -### 3.5 状态管理 - -- 使用 Zustand 创建 store,持久化存储使用 `persist` 中间件 + `AsyncStorage`。 -- Store 接口中同时包含状态和方法定义。 -- 持久化 store 使用 `partialize` 选择要持久化的字段。 -- 子应用(如 finance)内部使用内存 store,不持久化。 - -Store 模式: - -```typescript -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; - -interface MyState { - items: Item[]; - setItems: (items: Item[]) => void; - loadItems: () => Promise; -} - -export const useMyStore = create()( - persist( - (set) => ({ - items: [], - setItems: (items) => set({ items }), - loadItems: async () => { - const res = await loadDataApi({ ... }); - if (res.isSuccess) set({ items: res.data as Item[] }); - }, - }), - { - name: "my-storage", - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ items: state.items }), - }, - ), -); -``` - -### 3.6 组件模式 - -- 使用 NativeWind `className` 进行样式,不写内联 `style={}`。 -- 页面组件使用 `PageLayout` / `ModuleLayout` / `AppLayout` 包裹。 -- 长列表使用 `@legendapp/list` 优化性能。 -- 图标使用 `lucide-react-native` 或自定义 IconFont 组件。 -- UI 组件优先使用 HeroUI Native(`heroui-native`)。 -- 使用 `expo-router` 进行导航,路由基于 `src/app/` 文件结构。 -- **图标颜色使用主题色**:通过 `useThemeColor("accent")` 获取主题色后用 `color={accentColor}` 设置,不使用 `className="text-accent"`(对图标不生效)。 -- **详情页布局**:避免一个页面使用多个卡片(`Card`),改为平铺布局——大图 + 信息区块(标签式 label-value)+ 操作按钮。参考 `clothes/detail/[id]/index.tsx`。 - -### 3.6a 搜索条件 BottomSheet - -搜索/筛选条件弹窗统一使用项目封装的 `BottomSheet` 组件(`@/components/BottomSheet`),遵循以下规范: - -- **标题居中**:`BottomSheet.Content` 内的标题文案(如"筛选记账")使用 `text-center` + `font-semibold` 居中显示。 -- **重置按钮在右侧**:标题栏右侧放置重置按钮,使用 `flex-row items-center justify-between` 布局:左侧空 `flex-1`,中间标题,右侧重置按钮。 -- **限制高度可滚动**:筛选选项放在 `ScrollView` 中,设置 `maxHeight: Dimensions.get("window").height * 0.55`,`showsVerticalScrollIndicator={false}`,底部按钮固定在 ScrollView 外部。 -- **布局参考**:参照 `finance/(tabs)/home/index.tsx` 中的筛选 BottomSheet 实现: - - 日期范围用 `TouchableOpacity` 模拟输入框 - - 分类/成员选择使用标签按钮(`TouchableOpacity` + `border` + `rounded-full`)布局,`flex-row flex-wrap gap-2` - - 筛选标签分区域展示,用 `Text` 小标题分隔(如"支出"/"收入"分组) -- 使用 `useCallback` 包裹打开/关闭/重置/应用回调。 - -### 3.6b 表单分组标题 - -表单页如果只有一个分组/层级,不需要添加分组标题(如「基础信息」)。只有当表单字段明显分组为多个区块时,才使用分组标题分隔。 - -### 3.6c 表单页面(键盘遮挡处理) - - - -### 3.7 异步操作与加载状态 - -**所有异步保存/更新/删除操作必须使用 `useLoading` + `LoadingOverlay` 模式**,不得手动管理多个 loading 布尔值。 - -```typescript -const { isLoading, startLoading, stopLoading } = useLoading(); - -// 操作开始 -startLoading("删除中..."); -try { - const res = await saveDataApi([...]); - if (res.isSuccess) { - stopLoading({ type: "success", message: "已删除" }); - } else { - stopLoading({ type: "error", message: res.message || "删除失败" }); - } -} catch { - stopLoading({ type: "error", message: "删除失败" }); -} - -// 在 JSX 底部添加 LoadingOverlay -return ( - - {/* 页面内容 */} - - -); -``` - -列表加载使用 `useListLoading`: - -```typescript -const { loading, refreshing, runInitial, runRefresh, runLoadMore } = useListLoading(); -``` - -### 3.8 家庭成员动态记录 - -某些操作(创建/加入家庭、记账、收藏等)必须调用 `recordFeed()`,使用 `{nickname}` 占位符(不硬编码昵称): - -```typescript -import { recordFeed } from "@/helpers/feed"; - -recordFeed({ - family_id: familyId, - type: "bill", - content: "{nickname}记录了账单", -}); -``` - ---- - -## 4. 数据库 - -- 数据库:PostgreSQL 18.1,数据库名 `allapp`,schema `public`。 -- 所有业务表使用雪花 ID(通过 `getUniqueIdApi` 获取)。 -- SQL 查询中谨慎使用参数占位符 `?`(后端支持 `$1` 风格)。 - ---- - -## 5. 文件上传 - -上传使用 `s3UploadFileApi(files[])`(自动构建 `FormData`)。删除使用 `deleteS3FileQuietly()`,不在页面中重复实现 S3 key 校验逻辑。 - -文件键格式:`YYYY/MM/DD/{uuid}.ext`。 diff --git a/code/app/DESIGN_FOLLOWUPS.md b/code/app/DESIGN_FOLLOWUPS.md deleted file mode 100644 index 5948de28..00000000 --- a/code/app/DESIGN_FOLLOWUPS.md +++ /dev/null @@ -1,36 +0,0 @@ -# Design Follow-ups - -通用数据接口的安全性暂不纳入本清单。 - -## 已处理 - -- 聊天 DTO 已收口到 `app-rn/src/types/chat.ts`,统一通过 `normalizeChatMessage()` 兼容 `type` / `msg_type`。 -- WebSocket 在线用户、实时消息、未读数、连接状态已收口到 `app-rn/src/store/chat.ts`。 -- `app-rn/src/hooks/useLoading.ts` 已提供 `runWithLoading` 和 `useListLoading`,用于统一保存加载、列表初载、刷新和加载更多。 - -## API 与领域边界 - -- `src/request/api.ts` 仍主要返回 `any`,页面直接感知表名、字段名和查询条件。保持通用接口优先,只有明显跨页面复用的领域能力才考虑抽象。 - -## 状态与缓存 - -- `finance` store 只有分类缓存,账单列表仍由页面自行加载,后续可以形成 finance domain store。 -- `favorite` store 管理远端读写、`loadingIds` 和本地 `Set`,职责偏重。可以下沉远端操作到 service,store 只保留状态。 - -## UI 与交互一致性 - -- 后续新增保存类操作应优先使用 `useLoading().runWithLoading()`,页面底部保留 ``。 -- 列表初载、刷新、加载更多应优先使用 `useListLoading()`。 -- 部分页面中文显示/注释存在编码异常,应统一文件编码并恢复可读文案。 -- `AppLayout` 和 `ModuleLayout` 仍有大量重复,可继续抽一个共享 `ShellLayout`。 - -## 类型与工程质量 - -- 项目当前 `tsc --noEmit` 有既有错误,包括 `finance/analysis` variant、iconfont SvgProps、theme hook 缺失等。 -- iconfont 生成代码与当前 React Native SVG 类型不匹配,建议集中修生成模板或 wrapper 类型。 -- `PROJECT_CONTEXT.md` 当前缺失,应确认是否恢复或从文档引用中移除。 - -## 数据一致性 - -- `family_id` 作用域已经通过 `loadDataApi` / `loadDataPageApi` 的 `scope_family` 选项收口,仍有页面直接手写条件,需要逐步替换。 -- 创建/加入家庭、收藏 app、记账等 feed 记录规则需要逐项核对,确保所有触发场景一致执行。 diff --git a/code/app/MEAL_ORDER_DESIGN.md b/code/app/MEAL_ORDER_DESIGN.md deleted file mode 100644 index 67f07702..00000000 --- a/code/app/MEAL_ORDER_DESIGN.md +++ /dev/null @@ -1,800 +0,0 @@ -# 家庭菜单与餐食安排子模块设计文档 - -## 1. 背景 - -在现有家庭 App 中新增「餐食」子模块,用于维护家庭共享的菜品库,并支持家庭成员快速设置今天、明天或指定日期每个餐段吃什么。 - -本模块先按 App 内工具设计,不做营销页。界面重点应是菜品查找顺手、餐食安排直观、家人能快速看懂今天和明天吃什么。 - -## 2. 目标 - -- 录入菜品,形成家庭共享的菜品库。 -- 给菜品维护分类、标签、图片、食材、做法等信息。 -- 家庭成员可以从菜品库中选择一道或多道菜,直接安排到某天某餐。 -- 可以快速设置今天、明天或指定日期的餐食安排,餐段由家庭自定义,方便家庭备餐。 -- 后续可扩展到一周餐食计划、采购清单、菜品偏好统计。 - -## 3. 非目标 - -第一期暂不做: - -- 外卖/餐厅下单。 -- 在线支付。 -- 商家、桌台、排队叫号。 -- 多家庭公开餐食分享。 -- AI 自动生成菜谱。 -- 自动营养分析和热量计算。 -- 库存扣减和复杂采购管理。 -- 严格厨师/管理员权限模型。 -- 审批、通过、驳回、处理流。 -- 想吃列表、投票、不想吃。 -- 手动标记完成。 -- 菜品复杂 SKU,例如大小份、辣度、加料价格。 - -这些能力可以作为后续扩展,不进入第一版主流程。 - -## 4. 核心用户故事 - -### 菜品管理 - -- 作为用户,我可以新增一道菜,上传图片,填写名称、分类、标签、食材、做法和备注。 -- 作为用户,我可以按分类、标签、关键词筛选菜品。 -- 作为用户,我可以编辑或删除已录入的菜品。 -- 作为用户,我可以停用不常做的菜,而不是必须删除。 -- 作为用户,我可以收藏或置顶常吃菜,方便快速安排餐食。 - -### 餐食安排 - -- 作为用户,我可以选择今天、明天或指定日期。 -- 作为用户,我可以按家庭餐段查看每顿饭安排了哪些菜。 -- 作为用户,我可以从菜品库选择一道或多道菜加入某顿饭。 -- 作为用户,我可以从某顿饭移除不打算做的菜。 -- 作为用户,我可以给某顿饭填写备注或做饭人。 - -### 餐食安排视图 - -- 作为用户,我可以查看今天安排了哪些菜。 -- 作为用户,我可以直接设置今天每个餐段吃什么。 -- 作为用户,我可以直接切到明天,设置明天各餐段的餐食安排。 -- 作为用户,我可以按日期查看历史餐食安排。 -- 作为用户,我可以从历史餐食安排中再次安排同样的菜。 - -### 分类/标签管理 - -- 作为用户,我可以维护菜品分类,例如家常菜、汤、主食、早餐、甜品、饮品。 -- 作为用户,我可以维护父子标签,例如父标签“口味”下有“微辣、清淡”,父标签“食材”下有“牛肉、鸡蛋”。 -- 作为用户,我可以给菜品添加多个标签,例如微辣、牛肉、蒸、儿童友好、快手菜。 -- 分类保持单层,标签支持两级父子结构。 - -## 5. 信息架构 - -建议一级入口叫「餐食」。如果产品文案想保留“点菜”,它只作为按钮动作出现,例如“点菜/加菜”,不作为复杂流程。技术命名使用 `meal`,避免使用 `menu` 造成概念和路由冲突。 - -模块内建议 3 个主视图: - -- 今日:按今天/明天和家庭自定义餐段快速设置餐食安排。 -- 菜品:菜品列表、筛选、录入入口。 -- 管理:菜品分类、父子标签、餐段维护。 - -第一期建议把「今日」作为默认首页,优先展示家庭当天餐食安排。用户进入模块后先看到当天启用的餐段卡片,可以一键加菜或调整。 - -## 5.1 已确认/暂定规则 - -- 子模块默认是家庭共享,不做个人私有餐食安排。 -- 数据保存优先使用现有公共接口,例如 `data/loadData`、`data/saveData`。 -- 不新增餐食专用后端接口;只有通用接口明显无法覆盖一致性或事务要求时,再单独讨论。 -- 所有业务数据必须关联 `family_id`。 -- 菜品图片必须上传,不允许无图菜品。 -- 菜品食材建议保存为结构化明细,支持食材名、数量、单位、规格和备注。 -- 食材明细第一版选填,不阻塞菜品保存。 -- 做法保存为步骤明细,每一步可以上传图片、填写描述,图片和描述至少填写一个。 -- 分类由用户自行创建,不做强制默认初始化。 -- 分类保持单层,不做父子结构。 -- 标签由用户自行创建,支持两级父子结构。 -- 父标签用于分组,例如口味、食材、烹饪方式、适合人群;子标签用于实际标记菜品。 -- 菜品可以没有分类和标签。 -- 餐食安排必须关联日期和餐段。 -- 一顿饭可以安排多道菜,也可以先发起餐食安排不选菜,待其他家庭成员后续添加。 -- 餐段由家庭自定义,存储在 `b_meal_slot` 表中。系统可以在首次进入时提供建议餐段(早餐、午餐、晚餐),当创建家庭的时候默认创建几个,后续可以修改、排序、停用或新增。 -- 餐食日期入口优先提供今天、明天两个快捷选项,再提供日期选择。 -- 从菜品库加菜时,直接加入某天某餐,不进入待处理或想吃状态。 -- 一次可以选择多道菜加入同一顿饭。 -- 餐食安排支持设置做饭人 `cook_by`。 -- 不做预计用餐人数字段。 -- 支持从餐食安排进入记账模块,快速记录食材/餐饮支出。 -- 第一版不做状态流转。 -- 第一版不区分操作权限;家庭成员都可以添加、移除和调整餐食安排。 -- 第一版不处理“某个人不想吃”的场景。 -- 第一版不做投票。 -- 第一版不做审批。 -- 删除菜品时,如果存在历史餐食安排,默认软删除或停用菜品,不物理删除历史引用。 -- 每次安排餐食、加菜、移除菜、设置做饭人、留下备注时,向 `b_meal_plan_log` 写入一条对应类型的记录。 -- 加菜(type=2)和留下备注(type=4)两个动作需要触发家庭动态,调用 `recordFeed()`;其余操作不触发。 -- 备注(type=4)仅允许 `create_by` 本人删除,不可编辑;操作日志(type≠4)不暴露删除入口。 -- 活动日志按 `create_time` 升序展示,形成每顿饭的互动时间轴。 -- 餐食安排统一视为在家做饭,`dish_id` 可以为空,即发起一顿饭但不指定菜品,等待其他家庭成员添加。 - -## 5.2 简化操作原则 - -餐食模块以家庭日常使用为中心,第一版优先保证操作少、入口直观: - -- 首页默认展示「今天」,顶部提供「今天 / 明天」快捷切换。 -- 每天按家庭启用的餐段展示餐食区块。 -- 每个区块直接提供“加菜”入口,不要求用户先进入复杂表单。 -- 选择菜品后默认数量为 1,可以直接保存。 -- 餐食备注、数量等字段只在需要时展开。 -- 菜品录入必须填写菜名并上传图片,食材、做法、标签可以后续补充。 -- 分类和标签管理放到管理页,不打断日常加菜和排餐。 - -## 6. 数据对象草案 - -### 菜品 b_meal_dish - -字段草案: - -- id -- family_id -- name -- image_file_key -- category_id -- ingredient_summary -- cooking_time_minutes -- difficulty -- remark -- status -- sort_number -- create_time -- update_time -- create_by -- update_by - -说明: - -- `family_id`:菜品属于家庭空间。 -- `image_file_key`:菜品图片,走现有 S3 规则。 -- `image_file_key` 必填。 -- `ingredient_summary`:食材摘要,选填,用于列表或详情快速展示;真正的食材和规格保存到 `b_meal_dish_ingredient`。 -- `cooking_time_minutes`:预计用时,单位分钟,选填。 -- `difficulty`:难度,建议 1 简单、2 普通、3 复杂。 -- `status`:建议 1 启用,0 停用。 - -### 菜品分类 b_meal_category - -字段草案: - -- id -- family_id -- name -- sort_number -- status -- create_time -- update_time -- create_by -- update_by - -说明: - -- 分类只用于菜品。 -- 分类保持单层,不设置 `parent_id`。 -- 示例:家常菜、汤、主食、早餐、甜品、饮品。 - -### 菜品食材 b_meal_dish_ingredient - -字段草案: - -- id -- family_id -- dish_id -- name -- quantity -- unit -- spec -- remark -- sort_number -- create_time -- update_time -- create_by -- update_by - -说明: - -- 一道菜可以维护多条食材。 -- `name`:食材名称,例如土豆、牛肉、鸡蛋。 -- `quantity`:数量,选填,建议用 `NUMERIC`,例如 2、300、0.5。 -- `unit`:单位,选填,例如 个、克、斤、勺、适量。 -- `spec`:规格,选填,例如 去皮、切块、五花肉、带骨、约 200g/个。 -- `remark`:补充说明,选填,例如 可替换成鸡腿肉。 -- 第一版不做独立食材库,不要求把“土豆”维护成主数据,减少录入成本。 -- 第一版不做库存扣减。 -- 后续生成采购清单时,可以按 `name + unit + spec` 聚合,再由用户手动确认合并。 - -### 菜品步骤 b_meal_dish_step - -字段草案: - -- id -- family_id -- dish_id -- step_number -- description -- image_file_key -- sort_number -- create_time -- update_time -- create_by -- update_by - -说明: - -- 一道菜可以维护多条做菜步骤。 -- `step_number`:步骤序号,从 1 开始,用于展示“第几步”。 -- `description`:步骤描述,选填。 -- `image_file_key`:步骤图片,选填,走现有 S3 规则。 -- `description` 和 `image_file_key` 至少填写一个;第一版在应用层校验。 -- `sort_number`:用于拖拽排序或手动调整顺序,默认可以和 `step_number` 保持一致。 - -### 标签 b_meal_tag - -字段草案: - -- id -- family_id -- parent_id -- name -- color -- sort_number -- status -- create_time -- update_time -- create_by -- update_by - -说明: - -- `parent_id` 为空表示父标签,例如口味、食材、烹饪方式、适合人群。 -- `parent_id` 指向父标签时表示子标签,例如微辣、牛肉、蒸、儿童友好。 -- 第一版只支持两级结构,不做无限层级。 -- 菜品一般关联子标签;如果用户只建了父标签,也允许直接关联父标签,降低操作门槛。 -- `color` 第一版可选,建议保存十六进制颜色值,例如 `#EF4444`,用于标签背景、文字或边框展示。 - -### 菜品标签关联 b_meal_dish_tag - -字段草案: - -- id -- family_id -- dish_id -- tag_id -- create_time -- update_time -- create_by -- update_by - -说明: - -- 一道菜可以有多个标签。 -- 查询菜品列表时可以通过该表做标签筛选。 - -### 餐食安排 b_meal_plan - -字段草案: - -- id -- family_id -- plan_date -- meal_slot_id -- remark -- cook_by -- create_time -- update_time -- create_by -- update_by - -说明: - -- `plan_date`:餐食日期,例如今天、明天或用户选择的日期。 -- `meal_slot_id`:餐段 ID,指向 `b_meal_slot`。 -- 同一个家庭、同一天、同一个餐段建议只有一条餐食安排。 -- `remark`:这顿饭的备注,选填。 -- `cook_by`:做饭人,选填,指向家庭成员用户。 - -### 餐段 b_meal_slot - -字段草案: - -- id -- family_id -- name -- sort_number -- status -- create_time -- update_time -- create_by -- update_by - -说明: - -- 餐段属于家庭空间,可自定义。 -- 示例:早餐、午餐、晚餐、夜宵、加餐、周末早午餐。 -- `status`:建议 1 启用,0 停用。 -- 第一版不要求设置开始/结束时间,只按排序展示。 -- 不强制初始化默认数据;没有餐段时,前端提供“创建推荐餐段”快捷操作。 -- 如果后续需要按时间自动推荐餐段,可以再增加 `start_time`、`end_time`。 - -### 餐食安排明细 b_meal_plan_item - -字段草案: - -- id -- family_id -- plan_id -- dish_id -- quantity -- remark -- sort_number -- create_time -- update_time -- create_by -- update_by - -说明: - -- 统一保存一顿饭的菜品明细。 -- `dish_id` 必填,指向 `b_meal_dish`,菜名和图片从菜品库取。 -- `quantity` 第一版表示份数或数量,默认 1。 -- 明细独立保存,方便一顿饭安排多道菜,也方便从餐段区块移除单道菜。 - -### 餐食活动日志 b_meal_plan_log - -字段草案: - -- id -- family_id -- plan_id -- type -- content -- ref_id -- create_time -- update_time -- create_by -- update_by - -说明: - -- 统一记录每顿餐食安排上的操作和互动,用于时间轴展示。 -- `type` 枚举值: - - `1` 安排了餐食(创建 b_meal_plan 时写入) - - `2` 加了菜(新增 b_meal_plan_item 时写入) - - `3` 移除了菜(删除 b_meal_plan_item 时写入) - - `4` 留下了备注(用户主动填写文字时写入) - - `5` 设置了做饭人(更新 cook_by 时写入) -- `content`:type=4 时为备注正文;其余类型可为空,前端根据 type 拼接展示文案。 -- `ref_id`:type=2/3 时指向对应的 `b_meal_plan_item.id`,便于展示菜名;type=4 时为空。 -- type=4(备注)允许 `create_by` 本人删除,不可编辑。 -- type≠4 的操作记录由系统自动写入,不暴露用户删除入口。 -- 删除 plan_item(移除菜)时写入 type=3 记录,不删除历史 type=2 记录,保留操作轨迹。 -- 前端展示时按 `create_time` 升序排列,形成从上到下的时间轴。 - -## 7. 第一版流程 - -### 新增菜品 - -1. 进入「餐食」的「菜品」页。 -2. 点击新增。 -3. 填写菜名并上传图片。 -4. 可选填写分类、标签、食材明细、做法、预计用时、备注。 -5. 保存菜品。 -6. 返回菜品列表并刷新。 - -校验规则: - -- 菜名必填。 -- 图片必填。 -- 分类可以为空。 -- 标签可以为空。 -- 食材明细可以为空。 -- 预计用时如果填写,必须大于 0。 - -### 设置餐食安排 - -1. 进入「今日」页。 -2. 默认选中今天。 -3. 页面按家庭启用餐段展示餐食区块,每个区块底部展示该餐的活动时间轴(操作记录 + 备注)。 -4. 点击某个区块的加菜。 -5. 从菜品库选择一道或多道菜。 -6. 直接保存到该日期和餐段。 -7. 保存成功后:向 `b_meal_plan_log` 写入 type=2 记录(每道菜一条);调用 `recordFeed()` 触发家庭动态。 -8. 可选设置做饭人;设置后写入 type=5 记录。 -9. 切换到明天,可以用同样方式设置明天餐食。 - -校验规则: - -- 至少选择 1 道菜。 -- 日期默认今天,也可以快捷切换明天。 -- 餐段必填,来自家庭自定义餐段。 -- 数量默认 1,不强制用户填写。 -- 保存时生成或更新 `b_meal_plan` 和 `b_meal_plan_item`;同步写入 `b_meal_plan_log`。 - -### 新增菜品并加入餐食安排 - -1. 在某个日期/餐段区块点击加菜。 -2. 如果菜品库里没有目标菜,可以点击新增菜品。 -3. 填写菜名并上传图片。 -4. 保存菜品。 -5. 新菜品自动加入当前日期/餐段餐食安排。 - -校验规则: - -- 菜名必填。 -- 图片必填。 -- 分类可以为空。 -- 标签可以为空。 - -### 今日/明日餐食安排 - -1. 进入「今日」页。 -2. 默认按家庭启用餐段展示今天餐食安排。 -3. 顶部切换到明天,按同样餐段展示明天餐食安排。 -4. 支持继续打开日期选择器查看其他日期。 -5. 每个餐段区块支持加菜、移除菜、复制历史餐食。 -6. 可从历史记录中再次安排同样的菜。 - -### 快速记账 - -1. 在某顿餐食安排上点击记账按钮。 -2. 弹出或跳转到现有记账新增能力。 -3. 默认带入菜品名称、餐段和备注,例如“餐食:番茄炒蛋、排骨汤”。 -4. 保存成功后返回今日页。 - -校验规则: - -- 记账入口只是快捷入口,不强制每顿饭都记账。 -- 记账保存逻辑复用现有记账模块。 - -## 8. MVP 范围建议 - -第一期建议做: - -- 菜品 CRUD。 -- 菜品分类自定义。 -- 菜品父子标签自定义。 -- 菜品图片上传,图片必填。 -- 菜品食材明细维护,支持数量、单位、规格。 -- 菜品列表关键词、分类、标签筛选。 -- 今天/明天餐食快捷设置。 -- 家庭自定义餐段。 -- 餐食安排支持一次选择多道菜。 -- 餐食安排支持设置做饭人。 -- 餐食安排支持设置做饭人。 -- 今日/明日餐食视图。 -- 餐食安排提供快速记账按钮。 - -第一期暂缓: - -- AI 菜谱生成。 -- 一周餐食自动规划。 -- 采购清单生成和食材合并。 -- 营养、热量、过敏原分析。 -- 严格权限控制。 -- 菜品评分和偏好统计。 -- 菜品复杂 SKU。 -- 餐食消息提醒。 -- 餐段开始/结束时间和自动提醒。 -- 想吃列表、审批状态流转和对应操作日志。 -- 餐厅档案、常去餐厅统计、地图定位。 - -## 9. 剩余待确认问题 - -- 是否需要后续扩展一周餐食计划? - -## 10. 初步技术建议 - -- 数据库建议新增独立表,全部使用 `b_meal_` 前缀。 -- 所有新业务表都包含审计字段,符合项目现有规则。 -- 菜品、食材明细、分类、父子标签、餐段、餐食安排、餐食安排明细保存优先复用 `data/saveData`。 -- 查询优先使用 `data/loadData` 和 `data/loadDataPage`。 -- 今日/明日餐食可以用 `loadDataBySql` 做菜品、餐食安排、明细的聚合查询;第一版也可以前端分别查询后组合。 -- 删除菜品时优先软删除,避免破坏历史餐食安排。 -- 保存餐食安排建议在前端用一次 `saveData` 批量保存 `b_meal_plan` 和 `b_meal_plan_item`。 -- 餐食安排统一视为在家做饭。 -- 如果后续要求状态流转、想吃列表或权限校验,再单独设计,不放入第一版。 -- 图片上传复用现有 S3 上传接口。 -- 前端状态可以新增 `meal` store,负责菜品、分类、标签、餐食安排缓存。 -- 快速记账按钮复用现有记账模块新增能力,不在餐食模块内重复实现记账逻辑。 -- UI 使用 HeroUI Native、lucide-react-native、@legendapp/list、sonner-native;样式使用 Uniwind `className`。 -- 保存、加菜、移除菜等异步操作统一使用 `useLoading + LoadingOverlay`。 - -## 11. 页面建议 - -建议路由: - -- `app-rn/src/app/meal/(tabs)/_layout.tsx` -- `app-rn/src/app/meal/(tabs)/home/index.tsx` -- `app-rn/src/app/meal/(tabs)/dishes/index.tsx` -- `app-rn/src/app/meal/(tabs)/manage/index.tsx` -- `app-rn/src/app/meal/dish/edit/index.tsx` -- `app-rn/src/app/meal/dish/detail/[id]/index.tsx` -- `app-rn/src/app/meal/plan/edit/index.tsx` - -说明: - -- `home`:今天/明天餐食安排。 -- `dishes`:菜品库。 -- `manage`:分类和标签维护。 -- `dish/edit`:新增/编辑菜品。 -- `plan/edit`:编辑某天某餐的菜品。 - -## 12. 下一步 - -下一步可以进入: - -1. 数据库表结构设计。 -2. 页面与交互流程设计。 -3. 公共接口调用方案设计。 -4. 第一版任务拆分。 - -## 13. 实施计划 - -说明: - -- 每一步完成后,把 `[ ]` 改成 `[x]`。 -- 严格按阶段推进,前一阶段没有确认前,不进入下一阶段。 -- 默认优先使用通用接口;遇到通用接口明显无法覆盖的场景,再单独讨论是否增加通用 helper。 - -### 阶段 1:数据库表结构 ✅ - -- [x] 设计 `b_meal_dish` 表结构。 -- [x] 设计 `b_meal_dish_ingredient` 表结构,保存食材名称、数量、单位、规格。 -- [x] 设计 `b_meal_dish_step` 表结构,保存步骤描述和步骤图片。 -- [x] 设计 `b_meal_category` 表结构。 -- [x] 设计 `b_meal_tag` 表结构,包含 `parent_id` 支持父子标签。 -- [x] 设计 `b_meal_dish_tag` 表结构。 -- [x] 设计 `b_meal_slot` 表结构,支持家庭自定义餐段。 -- [x] 设计 `b_meal_plan` 表结构,包含 `plan_date`、`meal_slot_id`、`cook_by`。 -- [x] 设计 `b_meal_plan_item` 表结构,`dish_id` 指向菜品库。 -- [x] 设计 `b_meal_plan_log` 表结构,包含 `plan_id`、`type`、`content`、`ref_id`,统一记录操作和备注。 -- [x] 补充索引设计,例如 `family_id/plan_date/meal_slot_id/sort_number`。 -- [x] 生成 SQL 文件并确认命名、字段类型、默认值。 -- [x] 执行 SQL 到 dev 数据库并验证表已创建。 - -产出文件: - -- `app-go/db/meal.sql` - -验收标准: - -- 表名全部使用 `b_meal_` 前缀。 -- 所有业务表包含审计字段。 -- 历史餐食安排不会因为菜品删除而丢失。 -- 食材明细可以支持后续采购清单生成。 -- 餐段可以按家庭自定义、排序和停用。 - -### 阶段 2:前端基础模型与公共接口封装 ✅ - -- [x] 新增菜品、食材明细、分类、父子标签、餐段、餐食安排、统一餐食安排明细、活动日志 TypeScript 类型。 -- [x] 新增 `meal` store,管理餐食模块缓存。 -- [x] 在 `meal` store 中封装基于公共接口的查询方法。 -- [x] 在 `meal` store 中封装基于公共接口的保存方法。 -- [x] 在 `meal` store 中封装图片上传复用逻辑。 -- [x] 在 `app-rn/src/store/index.ts` 导出 `meal` store。 -- [x] 不新增 services 层,避免重复封装。 - -产出文件: - -- `app-rn/src/types/meal.ts` -- `app-rn/src/store/meal.ts` -- `app-rn/src/store/index.ts` - -验收标准: - -- 前端类型和数据库字段一致。 -- 查询和保存优先使用现有 `loadData`、`loadDataPage`、`saveData`。 -- 所有查询都按 `family_id` 过滤。 - -### 阶段 3:子应用入口与基础导航 ✅ - -- [x] 新增餐食应用图标。 -- [x] 在 `app-rn/src/configs/pages.ts` 注册餐食子应用。 -- [x] 新增 `meal/(tabs)/_layout.tsx`。 -- [x] 新增今日、菜品、管理底部 Tab,今日页默认展示今天/明天餐食安排。 -- [x] 确认子模块使用 `ModuleLayout`。 - -产出文件: - -- `app-rn/src/components/iconfont/IconAppMeal.tsx` -- `app-rn/src/components/iconfont/index.tsx` -- `app-rn/src/configs/pages.ts` -- `app-rn/src/app/meal/(tabs)/_layout.tsx` - -验收标准: - -- 首页工具列表可以进入餐食模块。 -- 餐食模块底部 Tab 能正常切换。 - -### 阶段 4:菜品管理 ✅ - -- [x] 新增菜品列表页。 -- [x] 支持关键词搜索。 -- [x] 支持按分类筛选。 -- [x] 支持按标签筛选。 -- [x] 新增菜品编辑页。 -- [x] 支持菜品图片上传,图片必填。 -- [x] 支持维护菜品食材明细。 -- [x] 食材明细支持名称、数量、单位、规格、备注。 -- [x] 支持创建或选择分类。 -- [x] 支持创建或选择父子标签。 -- [x] 支持编辑菜品。 -- [x] 支持停用或删除菜品。 -- [x] 删除已有餐食安排历史的菜品时使用软删除。 - -产出文件: - -- `app-rn/src/app/meal/(tabs)/home/index.tsx` -- `app-rn/src/app/meal/dish/edit/index.tsx` -- `app-rn/src/app/meal/dish/detail/[id]/index.tsx` -- `app-rn/src/components/meal/MealDishImage.tsx` - -验收标准: - -- 菜名必填。 -- 无图菜品不能保存。 -- 食材明细选填,但如果填写则食材名称必填。 -- 菜品列表筛选结果正确。 -- 删除菜品不会破坏历史餐食安排。 - -### 阶段 5:分类、父子标签管理 ✅ - -- [x] 在菜品编辑流程中支持快速新建分类。 -- [x] 在菜品编辑流程中支持快速新建父标签。 -- [x] 在菜品编辑流程中支持在父标签下新建子标签。 -- [x] 独立管理页支持维护分类。 -- [x] 独立管理页支持维护父标签。 -- [x] 独立管理页支持维护子标签。 -- [x] 支持父标签无子标签时直接用于菜品标记。 - -产出文件: - -- `app-rn/src/app/meal/(tabs)/manage/index.tsx` - -验收标准: - -- 分类单层。 -- 标签支持两级父子结构。 -- 停用分类或标签后,不影响历史菜品展示。 - -### 阶段 5.5:餐段管理 ✅ - -- [x] 独立管理页支持维护餐段。 -- [x] 支持新增餐段。 -- [x] 支持编辑餐段名称。 -- [x] 支持调整餐段排序。 -- [x] 支持停用餐段。 -- [x] 今日页按启用餐段排序展示(fetchSlots 已按 sort_number 排序并过滤已启用)。 - -产出文件: - -- `app-rn/src/app/meal/(tabs)/manage/index.tsx` - -验收标准: - -- 餐段由家庭自定义,不写死。 -- 停用餐段后,不影响历史餐食安排展示。 -- 没有餐段时,引导用户先创建餐段。 - -### 阶段 6:餐食安排编辑 ✅ - -- [x] 支持从菜品列表选择单道菜加入餐食。 -- [x] 支持多选菜品后批量加入餐食。 -- [x] 新增餐食安排编辑页或 BottomSheet。 -- [x] 支持今天/明天快捷选择。 -- [x] 支持选择家庭自定义餐段。 -- [x] 支持填写每道菜数量。 -- [x] 支持选择做饭人。 -- [x] 支持填写餐食备注。 -- [x] 提交后创建或更新餐食安排和餐食安排明细。 -- [x] 安排餐食时写入 type=1 日志记录。 -- [x] 加菜时写入 type=2 日志记录,并调用 `recordFeed()` 触发家庭动态。 -- [x] 移除菜时写入 type=3 日志记录。 -- [x] 设置做饭人时写入 type=5 日志记录。 -- [x] 支持在餐食安排上留下备注(type=4),写入日志并调用 `recordFeed()`。 -- [x] 备注仅允许 `create_by` 本人删除,不可编辑;操作日志不暴露删除入口。 - -产出文件: - -- `app-rn/src/app/meal/plan/edit/index.tsx` - -验收标准: - -- 至少选择一道菜才能提交。 -- 数量必须大于 0。 -- 保存中有 LoadingOverlay。 - -### 阶段 7:今日/明日餐食安排 ✅ - -- [x] 新增今日/明日餐食区。 -- [x] 默认展示今天餐食安排。 -- [x] 支持今天/明天快捷切换。 -- [x] 支持打开日期选择器查看其他日期。 -- [x] 支持按家庭自定义餐段分组展示。 -- [x] 支持在每个餐段区块直接加菜。 -- [x] 支持在每个餐段区块移除菜。 -- [x] 支持从历史餐食安排再次安排同样的菜。(阶段 8 已补完) -- [x] 每个餐段区块下方展示活动时间轴,按 `create_time` 升序列出操作记录和备注。 -- [x] 时间轴条目根据 `type` 拼接展示文案。 -- [x] 备注条目(type=4)在当前用户为作者时显示删除按钮;其余条目不显示。 -- [x] 支持空状态。 - -产出文件: - -- `app-rn/src/app/meal/(tabs)/home/index.tsx` - -验收标准: - -- 今天和明天餐食安排能按家庭自定义餐段清晰展示。 -- 切换日期后数据正确刷新。 -- 再次安排能复用原菜品生成新的餐食安排明细。 - -### 阶段 8:历史餐食与复用 ✅ - -- [x] 支持按日期查看历史餐食安排。 -- [x] 支持复制某顿历史餐食到今天或明天。 -- [x] 支持从历史餐食进入相关菜品详情。 - -产出文件: - -- `app-rn/src/app/meal/(tabs)/home/index.tsx` - -验收标准: - -- 历史餐食不会因为菜品停用而无法展示。 -- 复制历史餐食后能正确生成新的餐食安排。 -- 复制外出就餐时复用临时菜名和地点,不要求创建餐厅档案。 - -### 阶段 8.5:快速记账入口 - -- [ ] 在餐食安排上增加记账按钮。(暂缓,后续迭代) -- [ ] 点击后复用现有记账新增能力。 -- [ ] 默认带入菜品名称、餐段和备注。 -- [ ] 保存成功后返回今日页。 - -产出文件: - -- `app-rn/src/app/meal/(tabs)/home/index.tsx` - -验收标准: - -- 快速记账不影响餐食安排。 -- 记账保存逻辑与现有记账模块一致。 - -### 阶段 9:收尾与体验优化 ✅ - -- [x] 补充空状态。 -- [x] 补充加载状态。 -- [x] 补充保存中状态。 -- [x] 补充错误提示。 -- [x] 检查移动端小屏布局。 -- [x] 检查图片加载失败兜底。 -- [x] 跑 `pnpm exec oxfmt --check`。(通过) -- [ ] 跑 `pnpm lint`。(环境中 ESLint 未安装,需 `npx expo lint` 初始化配置) -- [x] 跑 `pnpm exec tsc --noEmit`。(通过,0 错误) -- [x] 根据实际结果更新本文档完成状态。 - -验收标准: - -- 核心流程从新增菜品到设置今天/明天餐食可完整走通。 -- 异常状态有明确反馈。 -- 文档计划状态和代码实现保持一致。 - -### 阶段 10:后置功能 - -- [ ] 一周餐食计划。 -- [ ] 采购清单生成。 -- [ ] 菜品评分和家庭偏好统计。 -- [ ] 家庭角色权限控制。 -- [ ] 餐食消息提醒。 -- [ ] AI 菜谱或餐食推荐。 - -验收标准: - -- 后置功能不影响第一版餐食安排主流程。 -- 每个后置功能开始前先单独确认范围。 diff --git a/code/app/app-go/db/anniversary.sql b/code/app/app-go/db/anniversary.sql new file mode 100644 index 00000000..a3ddfaba --- /dev/null +++ b/code/app/app-go/db/anniversary.sql @@ -0,0 +1,18 @@ +drop table if exists b_anniversary; + +create table b_anniversary ( + id bigint primary key, + family_id bigint not null, + name varchar(64) not null, + month smallint not null, + day smallint not null, + type smallint not null default 0, + remind_before smallint default 0, + sort_number int not null default 0, + create_time timestamptz, + update_time timestamptz, + create_by bigint, + update_by bigint +); + +create index idx_b_anniversary_family on b_anniversary (family_id); diff --git a/code/app/app-go/db/clothing.sql b/code/app/app-go/db/clothing.sql index e90fe771..df54c93b 100644 --- a/code/app/app-go/db/clothing.sql +++ b/code/app/app-go/db/clothing.sql @@ -1,4 +1,5 @@ -drop table if exists b_clothing_tag_relation; +drop table if exists b_clothing_clothes_tag; +drop table if exists b_clothing_outfit_tag; drop table if exists b_clothing_outfit_clothes; drop table if exists b_clothing_tag; drop table if exists b_clothing_category; @@ -16,8 +17,8 @@ create table b_clothing_clothes ( remark varchar(512) default '', sort_number int not null default 0, status smallint not null default 1, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -33,8 +34,8 @@ create table b_clothing_outfit ( remark varchar(512) default '', sort_number int not null default 0, status smallint not null default 1, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -50,8 +51,8 @@ create table b_clothing_outfit_clothes ( height numeric(8, 6) not null default 0, z_index int not null default 0, sort_number int not null default 0, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -63,8 +64,8 @@ create table b_clothing_category ( type smallint not null, sort_number int not null default 0, status smallint not null default 1, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -72,26 +73,36 @@ create table b_clothing_category ( create table b_clothing_tag ( id bigint primary key, family_id bigint not null, - parent_id bigint not null default 0, + parent_id bigint, name varchar(32) not null, type smallint not null, color varchar(7) default '', sort_number int not null default 0, status smallint not null default 1, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); -create table b_clothing_tag_relation ( +create table b_clothing_clothes_tag ( id bigint primary key, family_id bigint not null, - target_type smallint not null, - target_id bigint not null, + clothes_id bigint not null, tag_id bigint not null, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, + create_by bigint, + update_by bigint +); + +create table b_clothing_outfit_tag ( + id bigint primary key, + family_id bigint not null, + outfit_id bigint not null, + tag_id bigint not null, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -120,8 +131,14 @@ create index idx_b_clothing_category_family_type create index idx_b_clothing_tag_family_parent on b_clothing_tag (family_id, status, type, parent_id, sort_number, id); -create index idx_b_clothing_tag_relation_target - on b_clothing_tag_relation (family_id, target_type, target_id, tag_id); +create index idx_b_clothing_clothes_tag_clothes + on b_clothing_clothes_tag (family_id, clothes_id, tag_id); -create index idx_b_clothing_tag_relation_tag - on b_clothing_tag_relation (family_id, tag_id, target_type, target_id); +create index idx_b_clothing_clothes_tag_tag + on b_clothing_clothes_tag (family_id, tag_id, clothes_id); + +create index idx_b_clothing_outfit_tag_outfit + on b_clothing_outfit_tag (family_id, outfit_id, tag_id); + +create index idx_b_clothing_outfit_tag_tag + on b_clothing_outfit_tag (family_id, tag_id, outfit_id); diff --git a/code/app/app-go/db/finance_auto.sql b/code/app/app-go/db/finance_auto.sql new file mode 100644 index 00000000..d02806c8 --- /dev/null +++ b/code/app/app-go/db/finance_auto.sql @@ -0,0 +1,20 @@ +drop table if exists b_finance_auto; + +create table b_finance_auto ( + id bigint primary key, + family_id bigint not null, + type smallint not null, + category_id bigint not null, + amount numeric(12,2) not null, + remark varchar(255) default '', + repeat_type smallint not null, + repeat_date varchar(64) default '', + last_run date, + status smallint not null default 1, + create_time timestamptz, + update_time timestamptz, + create_by bigint, + update_by bigint +); + +create index idx_b_finance_auto_family on b_finance_auto (family_id, status); diff --git a/code/app/app-go/db/finance_category.sql b/code/app/app-go/db/finance_category.sql index fdbc58e9..53ef2c0f 100644 --- a/code/app/app-go/db/finance_category.sql +++ b/code/app/app-go/db/finance_category.sql @@ -8,8 +8,8 @@ create table b_finance_category_default ( icon_file_key varchar(512) not null, sort_number int not null default 0, status smallint not null default 1, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -23,8 +23,8 @@ create table b_finance_category ( icon_source smallint not null, sort_number int not null default 0, status smallint not null default 1, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); diff --git a/code/app/app-go/db/meal.sql b/code/app/app-go/db/meal.sql index 0ce9bc8e..4857566f 100644 --- a/code/app/app-go/db/meal.sql +++ b/code/app/app-go/db/meal.sql @@ -28,8 +28,8 @@ create table b_meal_dish ( remark varchar(512) default '', status smallint not null default 1, sort_number int not null default 0, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -43,8 +43,8 @@ create table b_meal_category ( name varchar(32) not null, sort_number int not null default 0, status smallint not null default 1, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -61,8 +61,8 @@ create table b_meal_tag ( color varchar(7) default '', sort_number int not null default 0, status smallint not null default 1, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -80,8 +80,8 @@ create table b_meal_dish_ingredient ( spec varchar(64) default '', remark varchar(128) default '', sort_number int not null default 0, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -97,8 +97,8 @@ create table b_meal_dish_step ( description varchar(1024) default '', image_file_key varchar(512) default '', sort_number int not null default 0, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -111,8 +111,8 @@ create table b_meal_dish_tag ( family_id bigint not null, dish_id bigint not null, tag_id bigint not null, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -126,8 +126,8 @@ create table b_meal_slot ( name varchar(32) not null, sort_number int not null default 0, status smallint not null default 1, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -145,8 +145,8 @@ create table b_meal_plan ( status smallint not null default 0, remark varchar(512) default '', cook_by bigint, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -162,8 +162,8 @@ create table b_meal_plan_item ( quantity int not null default 1, remark varchar(256) default '', sort_number int not null default 0, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); @@ -181,8 +181,8 @@ create table b_meal_plan_log ( content varchar(1024) default '', ref_id bigint, dish_name varchar(128) default null, - create_time timestamp, - update_time timestamp, + create_time timestamptz, + update_time timestamptz, create_by bigint, update_by bigint ); diff --git a/code/app/app-go/db/todo.sql b/code/app/app-go/db/todo.sql new file mode 100644 index 00000000..396dda9e --- /dev/null +++ b/code/app/app-go/db/todo.sql @@ -0,0 +1,84 @@ +drop table if exists b_todo_file; +drop table if exists b_todo_rating; +drop table if exists b_todo_log; +drop table if exists b_todo; + +-- 待办 +create table b_todo ( + id bigint primary key, + family_id bigint not null, + name varchar(64) not null, + description varchar(512) default '', + status smallint not null default 0, + priority smallint not null default 0, + assignee_id bigint, + deadline timestamptz, + completed_at timestamptz, + repeat_type smallint not null default 0, + remind_at timestamptz, + complete_note varchar(512) default '', + sort_number int not null default 0, + create_time timestamptz, + update_time timestamptz, + create_by bigint, + update_by bigint +); + +-- 日志 +create table b_todo_log ( + id bigint primary key, + family_id bigint not null, + todo_id bigint not null, + type smallint not null, + content varchar(1024) default '', + ref_id bigint, + create_time timestamptz, + update_time timestamptz, + create_by bigint, + update_by bigint +); + +-- 评分 +create table b_todo_rating ( + id bigint primary key, + family_id bigint not null, + todo_id bigint not null, + rater_id bigint not null, + rating smallint not null, + content varchar(256) default '', + create_time timestamptz, + update_time timestamptz, + create_by bigint, + update_by bigint +); + +-- 附件(完成时上传的图片/视频等) +create table b_todo_file ( + id bigint primary key, + family_id bigint not null, + todo_log_id bigint not null, + todo_id bigint not null, + file_key varchar(256) not null, + file_name varchar(256), + file_type smallint default 1, + mime_type varchar(64), + file_size bigint, + width int, + height int, + duration int, + sort_order int not null default 0, + create_time timestamptz, + update_time timestamptz, + create_by bigint, + update_by bigint +); + +-- 索引 +create index idx_b_todo_family_status on b_todo (family_id, status, sort_number, id); +create index idx_b_todo_family_assignee on b_todo (family_id, assignee_id, status); +create index idx_b_todo_log_todo on b_todo_log (family_id, todo_id, create_time, id); +create unique index idx_b_todo_log_todo_type_ref on b_todo_log (family_id, todo_id, type, ref_id); +create index idx_b_todo_rating_todo on b_todo_rating (family_id, todo_id, id); +create unique index idx_b_todo_rating_todo_rater on b_todo_rating (family_id, todo_id, rater_id); +create index idx_b_todo_file_todo_log on b_todo_file (family_id, todo_log_id, sort_order); +create index idx_b_todo_file_todo on b_todo_file (family_id, todo_id, id); diff --git a/code/app/app-go/internal/ws/handler.go b/code/app/app-go/internal/ws/handler.go index 0ce24f8e..0f52a77a 100644 --- a/code/app/app-go/internal/ws/handler.go +++ b/code/app/app-go/internal/ws/handler.go @@ -247,8 +247,8 @@ func (c *Client) handleSend(msg clientSendMsg) { "sort_order": i, }) } - if err := dbClient.BatchInsert(ctx, "b_file", "id", fileInserts); err != nil { - log.Printf("[chat] insert b_file error: %v", err) + if err := dbClient.BatchInsert(ctx, "b_chat_file", "id", fileInserts); err != nil { + log.Printf("[chat] insert b_chat_file error: %v", err) } } diff --git a/code/app/app-rn/package.json b/code/app/app-rn/package.json index 020659e0..7efe92b7 100644 --- a/code/app/app-rn/package.json +++ b/code/app/app-rn/package.json @@ -36,6 +36,7 @@ "expo-image": "~56.0.11", "expo-linear-gradient": "~56.0.4", "expo-linking": "~56.0.14", + "expo-notifications": "^57.0.3", "expo-qq": "^0.1.8", "expo-router": "~56.2.11", "expo-splash-screen": "~56.0.10", diff --git a/code/app/app-rn/pnpm-lock.yaml b/code/app/app-rn/pnpm-lock.yaml index ff119b77..60ff5438 100644 --- a/code/app/app-rn/pnpm-lock.yaml +++ b/code/app/app-rn/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: expo-linking: specifier: ~56.0.14 version: 56.0.14(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) + expo-notifications: + specifier: ^57.0.3 + version: 57.0.3(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)(typescript@6.0.3) expo-qq: specifier: ^0.1.8 version: 0.1.8(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) @@ -702,6 +705,10 @@ packages: resolution: {integrity: sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==} engines: {node: '>=20.12.0'} + '@expo/env@2.4.1': + resolution: {integrity: sha512-3c9Mg9x0HmGPEsVrGAGyEDJsNUOZ55cZvZ47/HLmXh7MHV9Zv7My73wThklKrObaBBoMfE4YqpKjYKDRzojpjQ==} + engines: {node: '>=20.12.0'} + '@expo/expo-modules-macros-plugin@0.2.2': resolution: {integrity: sha512-4IMzPDIo/VOXREQjsJtliSfqYVZvfzU2SLFS/9sKMWF848S8CHx+e/E+Vf0TcMvpWCCKX5umyqxb13KJJ+YUzg==} @@ -712,6 +719,9 @@ packages: '@expo/image-utils@0.10.1': resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==} + '@expo/image-utils@0.11.1': + resolution: {integrity: sha512-0JueH4vdgAZQmhlSYFvTQzt4b4NO5cnByDuApw7bMUIjhwLRnT46Ki3ritMrzJMQaO2lLK2flInZbsZbOuy8nw==} + '@expo/inline-modules@0.0.12': resolution: {integrity: sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==} @@ -776,6 +786,14 @@ packages: typescript: optional: true + '@expo/require-utils@57.0.1': + resolution: {integrity: sha512-uXen5/4x7j60I5slShgZr5QEtJDBK8homFiNLDnDrNrxZhrRHXASo0H6JArs3/1PDzw1wahzhGWg2WKuYyZd0A==} + peerDependencies: + typescript: ^5.0.0 || ^5.0.0-0 || ^6.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@expo/router-server@56.0.14': resolution: {integrity: sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==} peerDependencies: @@ -2018,6 +2036,9 @@ packages: expo-widgets: optional: true + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2573,6 +2594,11 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + expo-application@57.0.0: + resolution: {integrity: sha512-vNrnYlqtRS5pFUm1tjut4dogZxW/uhmMOD5GeLQ8Z9Ji3KMYKLOy5dG0BlUsMrcSgyPJvJqP0TFopkEZ02ec6g==} + peerDependencies: + expo: '*' + expo-asset@56.0.17: resolution: {integrity: sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg==} peerDependencies: @@ -2605,6 +2631,12 @@ packages: expo: '*' react-native: '*' + expo-constants@57.0.3: + resolution: {integrity: sha512-BghbZlzwFnA22BG0CWv6W29zx8w19FozYPfSeZ3HjMitoy4aAmF1FnFbbjYSmZz6HHXXTWOfqwobOEIaglfzxg==} + peerDependencies: + expo: '*' + react-native: '*' + expo-device@56.0.4: resolution: {integrity: sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg==} peerDependencies: @@ -2684,6 +2716,13 @@ packages: peerDependencies: react-native: '*' + expo-notifications@57.0.3: + resolution: {integrity: sha512-BTfrlnWF4EQZ0Oufsj00OesdxxlXCaC3OexnJTWKdQJ2a26w/bIAM3kXIxHsizmBWaHt5EJc5LvuDzVeUt7JJA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-qq@0.1.8: resolution: {integrity: sha512-Khuu4LWOjpYGGBamtUFBxaA5q8oIcFiVgXkyRVqfNdSEP6Z0Fy3eBj3HuXP8mxonT/hJjG3GCX7ypJD7M9nU/g==} peerDependencies: @@ -5573,6 +5612,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@expo/env@2.4.1': + dependencies: + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + '@expo/expo-modules-macros-plugin@0.2.2': {} '@expo/fingerprint@0.19.4': @@ -5604,6 +5651,19 @@ snapshots: - supports-color - typescript + '@expo/image-utils@0.11.1(typescript@6.0.3)': + dependencies: + '@expo/require-utils': 57.0.1(typescript@6.0.3) + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + '@expo/inline-modules@0.0.12(typescript@6.0.3)': dependencies: '@expo/config-plugins': 56.0.9(typescript@6.0.3) @@ -5756,6 +5816,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@expo/require-utils@57.0.1(typescript@6.0.3)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@expo/router-server@56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 @@ -6915,6 +6985,8 @@ snapshots: - '@babel/core' - supports-color + badgin@1.2.3: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -7595,6 +7667,10 @@ snapshots: event-target-shim@5.0.1: {} + expo-application@57.0.0(expo@56.0.12): + dependencies: + 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) + expo-asset@56.0.17(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)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) @@ -7633,6 +7709,14 @@ snapshots: transitivePeerDependencies: - supports-color + expo-constants@57.0.3(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)): + dependencies: + '@expo/env': 2.4.1 + 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: 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) + transitivePeerDependencies: + - supports-color + expo-device@56.0.4(expo@56.0.12): dependencies: 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) @@ -7714,6 +7798,20 @@ snapshots: dependencies: 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) + expo-notifications@57.0.3(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)(typescript@6.0.3): + dependencies: + '@expo/image-utils': 0.11.1(typescript@6.0.3) + abort-controller: 3.0.0 + badgin: 1.2.3 + 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) + expo-application: 57.0.0(expo@56.0.12) + expo-constants: 57.0.3(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 + 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) + transitivePeerDependencies: + - supports-color + - typescript + expo-qq@0.1.8(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: 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) diff --git a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx index d074f4cf..697fab3b 100644 --- a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx +++ b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx @@ -504,7 +504,7 @@ export default function ChatPage() { if (messageIds.length) { const fileRes = await loadDataApi({ - view_name: "b_file", + view_name: "b_chat_file", search_condition: `target_id IN (${messageIds.join(",")})`, order_by: "sort_order ASC", args: [], diff --git a/code/app/app-rn/src/app/anniversary/(tabs)/_layout.tsx b/code/app/app-rn/src/app/anniversary/(tabs)/_layout.tsx new file mode 100644 index 00000000..2a6971ff --- /dev/null +++ b/code/app/app-rn/src/app/anniversary/(tabs)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router"; +import { HeartIcon, PlusCircleIcon } from "lucide-react-native"; +import ModuleLayout from "@/layouts/ModuleLayout"; +import { TabbarItem } from "@/components/layout/tabbar"; + +const tabs: TabbarItem[] = [ + { id: "/anniversary/home", icon: HeartIcon, title: "纪念日" }, + { id: "/anniversary/add", icon: PlusCircleIcon, title: "添加" }, +]; + +export default function AnniversaryLayout() { + return ( + <> + + + + ); +} diff --git a/code/app/app-rn/src/app/anniversary/(tabs)/home/index.tsx b/code/app/app-rn/src/app/anniversary/(tabs)/home/index.tsx new file mode 100644 index 00000000..d3c4262a --- /dev/null +++ b/code/app/app-rn/src/app/anniversary/(tabs)/home/index.tsx @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { useFocusEffect, useRouter } from "expo-router"; +import dayjs from "dayjs"; +import { CakeIcon, HeartIcon, StarIcon } from "lucide-react-native"; +import PageLayout from "@/layouts/PageLayout"; +import LayoutView from "@/components/layout/view"; +import { useListLoading } from "@/hooks/useLoading"; +import { useAnniversaryStore, useFamilyStore } from "@/store"; +import { Anniversary, AnniversaryType } from "@/types/anniversary"; + +const TYPE_ICONS: Record = { + [AnniversaryType.Birthday]: CakeIcon, + [AnniversaryType.Anniversary]: HeartIcon, + [AnniversaryType.Custom]: StarIcon, +}; +const TYPE_LABELS: Record = { + [AnniversaryType.Birthday]: "生日", + [AnniversaryType.Anniversary]: "纪念日", + [AnniversaryType.Custom]: "自定义", +}; + +function daysUntil(month: number, day: number): number { + const now = dayjs(); + let target = dayjs(`${now.year()}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`); + if (target.isBefore(now, "day")) target = target.add(1, "year"); + return target.diff(now, "day"); +} + +export default function AnniversaryHome() { + const router = useRouter(); + const familyId = useFamilyStore((s) => s.getFamilyId()); + const items = useAnniversaryStore((s) => s.items); + const fetchAll = useAnniversaryStore((s) => s.fetchAll); + const { refreshing, runInitial, runRefresh } = useListLoading(); + + const load = useCallback(async () => { + if (!familyId) return; + await fetchAll(familyId); + }, [familyId, fetchAll]); + + useEffect(() => { if (familyId) runInitial(load); }, [familyId]); + useFocusEffect(useCallback(() => { load(); }, [load])); + + const sorted = useMemo(() => { + return [...items] + .map((item) => ({ ...item, days: daysUntil(item.month, item.day) })) + .sort((a, b) => a.days - b.days); + }, [items]); + + return ( + + + runRefresh(load)} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}> + {sorted.length > 0 ? ( + + {sorted.map((item) => { + const Icon = TYPE_ICONS[item.type] ?? StarIcon; + const isToday = item.days === 0; + return ( + + + + + + {TYPE_LABELS[item.type]} + + {item.name} + + {item.month}月{item.day}日 + + + + + {isToday ? "今天" : item.days} + + {!isToday && 天} + + + + ); + })} + + ) : ( + + + 暂无纪念日 + 点击「添加」记录家人的重要日子 + + )} + + + + ); +} diff --git a/code/app/app-rn/src/app/anniversary/add/index.tsx b/code/app/app-rn/src/app/anniversary/add/index.tsx new file mode 100644 index 00000000..e7e48c02 --- /dev/null +++ b/code/app/app-rn/src/app/anniversary/add/index.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useState } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { Stack, useRouter } from "expo-router"; +import { Button, Input, Label, TextField } from "heroui-native"; +import { CakeIcon, CheckIcon, HeartIcon, StarIcon } 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 { LoadingOverlay } from "@/components/LoadingOverlay"; +import { useLoading } from "@/hooks/useLoading"; +import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier"; +import { useAnniversaryStore, useFamilyStore } from "@/store"; +import { AnniversaryType } from "@/types/anniversary"; + +const TYPES = [ + { value: AnniversaryType.Birthday, label: "生日", icon: CakeIcon }, + { value: AnniversaryType.Anniversary, label: "纪念日", icon: HeartIcon }, + { value: AnniversaryType.Custom, label: "自定义", icon: StarIcon }, +]; + +export default function AnniversaryAdd() { + const router = useRouter(); + const familyId = useFamilyStore((s) => s.getFamilyId()); + const create = useAnniversaryStore((s) => s.create); + const { isLoading, startLoading, stopLoading } = useLoading(); + const notifyOp = useModuleChatNotifier(); + + const [name, setName] = useState(""); + const [type, setType] = useState(AnniversaryType.Birthday); + const [month, setMonth] = useState(""); + const [day, setDay] = useState(""); + const [remindBefore, setRemindBefore] = useState(0); + const [showMonth, setShowMonth] = useState(false); + const [showDay, setShowDay] = useState(false); + + const handleSave = useCallback(async () => { + if (!name.trim()) { toast.error("请输入名称"); return; } + const m = Number(month), d = Number(day); + if (!m || m < 1 || m > 12 || !d || d < 1 || d > 31) { toast.error("请输入有效日期"); return; } + if (!familyId || isLoading) return; + startLoading("保存中..."); + try { + const id = await create({ familyId, name: name.trim(), month: m, day: d, type, remindBefore, userId: 0 }); + if (!id) { stopLoading({ type: "error", message: "保存失败" }); return; } + notifyOp({ module: "纪念日", action: "添加纪念日", target: name.trim(), sourceTable: "b_anniversary", sourceId: id }); + stopLoading({ type: "success", message: "已添加" }); + router.back(); + } catch { stopLoading({ type: "error", message: "保存失败" }); } + }, [name, month, day, type, remindBefore, familyId, isLoading, create, notifyOp, router, startLoading, stopLoading]); + + return ( + + + + + + + + + 类型 + + {TYPES.map((t) => { + const Icon = t.icon; + const sel = type === t.value; + return ( + setType(t.value)}> + + {t.label} + + ); + })} + + + 日期 + + setShowMonth(true)}> + {month ? `${month}月` : "选择月份"} + + setShowDay(true)}> + {day ? `${day}日` : "选择日期"} + + + + 提前提醒 + + {[{ v: 0, l: "不提醒" }, { v: 1, l: "当天" }, { v: 3, l: "3天前" }, { v: 7, l: "7天前" }, { v: 15, l: "15天前" }].map((o) => ( + setRemindBefore(o.v)}> + {o.l} + + ))} + + + + + + + + + + {/* 月份选择 */} + + + + 选择月份 + + {Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ( + { setMonth(String(m)); setShowMonth(false); }}> + {m}月 + + ))} + + + + + + + {/* 日期选择 */} + + + + 选择日期 + + {Array.from({ length: 31 }, (_, i) => i + 1).map((d) => ( + { setDay(String(d)); setShowDay(false); }}> + {d}日 + + ))} + + + + + + + + + ); +} 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 81508f3b..e915db66 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 @@ -58,8 +58,8 @@ export default function ClothingHome() { const tagIdsByClothesId = useMemo(() => { const map = new Map>(); - tagRelations.filter((r) => r.target_type === ClothingTargetType.Clothes).forEach((r) => { - const s = map.get(r.target_id) ?? new Set(); s.add(r.tag_id); map.set(r.target_id, s); + tagRelations.filter((r) => r.clothes_id !== undefined).forEach((r) => { + const s = map.get(r.clothes_id!) ?? new Set(); s.add(r.tag_id); map.set(r.clothes_id!, s); }); return map; }, [tagRelations]); @@ -106,7 +106,7 @@ export default function ClothingHome() { clothesDeletes: [{ id: item.id }], outfitDeletes: deleteRelatedOutfits ? relatedIds.map((id) => ({ id })) : [], outfitClothesDeletes: deleteRelatedOutfits ? state.outfitClothes.filter((r) => relatedIds.includes(r.outfit_id)).map((r) => ({ id: r.id })) : [], - tagRelationDeletes: state.tagRelations.filter((r) => (r.target_type === ClothingTargetType.Clothes && r.target_id === item.id) || (deleteRelatedOutfits && r.target_type === ClothingTargetType.Outfit && relatedIds.includes(r.target_id))).map((r) => ({ id: r.id })), + tagRelationDeletes: state.tagRelations.filter((r) => (r.clothes_id === item.id) || (deleteRelatedOutfits && r.outfit_id !== undefined && relatedIds.includes(r.outfit_id!))).map((r) => ({ id: r.id })), }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } deleteS3FileQuietly(item.image_file_key); 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 196a5a6a..ce2fc82c 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 @@ -69,7 +69,7 @@ export default function ClothingManage() { const saveGroup = useCallback(async () => { if (!familyId || !groupForm.trim()) return; startLoading("保存中..."); try { - editingGroup ? await saveTagChanges({ updates: [{ id: editingGroup.id, name: groupForm.trim() }] }) : await saveTagChanges({ inserts: [{ name: groupForm.trim(), type: tab, family_id: familyId, parent_id: 0, color: "" }] }); + editingGroup ? await saveTagChanges({ updates: [{ id: editingGroup.id, name: groupForm.trim() }] }) : await saveTagChanges({ inserts: [{ name: groupForm.trim(), type: tab, family_id: familyId, parent_id: null, color: "" }] }); setShowGroupSheet(false); await loadData(); stopLoading({ type: "success", message: "已保存" }); } catch { stopLoading({ type: "error", message: "保存失败" }); } }, [editingGroup, familyId, groupForm, loadData, saveTagChanges, startLoading, stopLoading, tab]); 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 13732641..78a6e27e 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 @@ -41,8 +41,8 @@ export default function ClothingOutfitList() { const tagsById = useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]); const outfitTagIds = useMemo(() => { const map = new Map(); - tagRelations.filter((r) => r.target_type === ClothingTargetType.Outfit).forEach((r) => { - const ids = map.get(r.target_id) ?? []; ids.push(r.tag_id); map.set(r.target_id, ids); + tagRelations.filter((r) => r.outfit_id !== undefined).forEach((r) => { + const ids = map.get(r.outfit_id!) ?? []; ids.push(r.tag_id); map.set(r.outfit_id!, ids); }); return map; }, [tagRelations]); @@ -78,7 +78,7 @@ export default function ClothingOutfitList() { if (!deleteConfirm || !familyId || isLoading) return; const outfit = deleteConfirm; setDeleteConfirm(null); startLoading("删除中..."); try { const state = useClothingStore.getState(); const details = state.outfitClothes.filter((r) => r.outfit_id === outfit.id); - const ok = await saveOutfitBundle({ outfitDeletes: [{ id: outfit.id }], clothesDeletes: details.map((r) => ({ id: r.id })), tagRelationDeletes: state.tagRelations.filter((r) => r.target_type === ClothingTargetType.Outfit && r.target_id === outfit.id).map((r) => ({ id: r.id })) }); + const ok = await saveOutfitBundle({ outfitDeletes: [{ id: outfit.id }], clothesDeletes: details.map((r) => ({ id: r.id })), tagRelationDeletes: state.tagRelations.filter((r) => r.outfit_id === outfit.id).map((r) => ({ id: r.id })) }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } await loadData(); notifyModuleOperation({ module: "衣服搭配", action: "删除搭配", target: outfit.name, detail: `${details.length} 件衣服`, sourceTable: "b_clothing_outfit", sourceId: outfit.id }); stopLoading({ type: "success", message: "已删除" }); 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 7b24daa8..55b5242a 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 @@ -41,7 +41,7 @@ export default function ClothingDetail() { const categoryName = useMemo(() => { if (!item?.category_id) return "未分类"; return categories.find((c) => c.id === item.category_id)?.name ?? "未分类"; }, [categories, item?.category_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)); + const ids = new Set(tagRelations.filter((r) => r.clothes_id === clothesId).map((r) => r.tag_id)); return tags.filter((t) => ids.has(t.id)); }, [clothesId, tagRelations, tags]); @@ -59,7 +59,7 @@ export default function ClothingDetail() { const state = useClothingStore.getState(); const refs = state.outfitClothes.filter((r) => r.clothes_id === item.id); const relatedIds = [...new Set(refs.map((r) => r.outfit_id))]; - const ok = await deleteClothesBundle({ clothesDeletes: [{ id: item.id }], outfitDeletes: relatedIds.map((oid) => ({ id: oid })), outfitClothesDeletes: state.outfitClothes.filter((r) => relatedIds.includes(r.outfit_id)).map((r) => ({ id: r.id })), tagRelationDeletes: state.tagRelations.filter((r) => (r.target_type === ClothingTargetType.Clothes && r.target_id === item.id) || (r.target_type === ClothingTargetType.Outfit && relatedIds.includes(r.target_id))).map((r) => ({ id: r.id })) }); + const ok = await deleteClothesBundle({ clothesDeletes: [{ id: item.id }], outfitDeletes: relatedIds.map((oid) => ({ id: oid })), outfitClothesDeletes: state.outfitClothes.filter((r) => relatedIds.includes(r.outfit_id)).map((r) => ({ id: r.id })), tagRelationDeletes: state.tagRelations.filter((r) => (r.clothes_id === item.id) || (r.outfit_id !== undefined && relatedIds.includes(r.outfit_id!))).map((r) => ({ id: r.id })) }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } deleteS3FileQuietly(item.image_file_key); state.outfits.filter((o) => relatedIds.includes(o.id)).forEach((o) => deleteS3FileQuietly(o.cover_file_key)); @@ -86,7 +86,7 @@ export default function ClothingDetail() { {item.name} {!!item.brand && {item.brand}} - {/* 归属者 + 分类 + 标签 */} + {/* 所属 + 分类 + 标签 */} {owner ? ( @@ -97,7 +97,7 @@ export default function ClothingDetail() { {owner.nickname} ) : ( - 未指定归属者 + 未指定所属 )} {categoryName === "未分类" ? ( 未分类 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 12acba5c..56237291 100644 --- a/code/app/app-rn/src/app/clothes/edit/index.tsx +++ b/code/app/app-rn/src/app/clothes/edit/index.tsx @@ -99,7 +99,7 @@ export default function ClothingEdit() { ); const clothesTags = useMemo( - () => tags.filter((item) => item.type === ClothingTargetType.Clothes && item.parent_id !== 0), + () => tags.filter((item) => item.type === ClothingTargetType.Clothes && item.parent_id !== null), [tags], ); @@ -143,12 +143,12 @@ export default function ClothingEdit() { setSelectedTagIds( state.tagRelations .filter( - (row) => row.target_type === ClothingTargetType.Clothes && row.target_id === editId, + (row) => row.clothes_id === editId, ) .map((row) => row.tag_id), ); } else { - // 新增时默认归属者为当前登录用户 + // 新增时默认所属为当前用户 setForm({ ...emptyForm, ownerId: currentUserId || null }); } } finally { @@ -233,7 +233,7 @@ export default function ClothingEdit() { } const currentRelations = tagRelations.filter( - (row) => row.target_type === ClothingTargetType.Clothes && row.target_id === targetId, + (row) => row.clothes_id === targetId, ); const currentTagIds = new Set(currentRelations.map((row) => row.tag_id)); const relationDeletes = currentRelations @@ -267,8 +267,7 @@ export default function ClothingEdit() { tagRelationInserts: newRelationTagIds.map((tagId, index) => ({ id: relationIds[index], family_id: familyId, - target_type: ClothingTargetType.Clothes, - target_id: targetId, + clothes_id: targetId, tag_id: tagId, })), tagRelationDeletes: relationDeletes, @@ -488,10 +487,10 @@ export default function ClothingEdit() { {clothesCategories.map(renderCategoryChip)} - {/* 归属者 */} + {/* 所属 */} {members.length > 0 && ( <> - 归属者 + 所属 r.outfit_id === item.id); - const ok = await saveOutfitBundle({ outfitDeletes: [{ id: item.id }], clothesDeletes: d.map((r) => ({ id: r.id })), tagRelationDeletes: state.tagRelations.filter((r) => r.target_type === ClothingTargetType.Outfit && r.target_id === item.id).map((r) => ({ id: r.id })) }); + const ok = await saveOutfitBundle({ outfitDeletes: [{ id: item.id }], clothesDeletes: d.map((r) => ({ id: r.id })), tagRelationDeletes: state.tagRelations.filter((r) => r.outfit_id === item.id).map((r) => ({ id: r.id })) }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } notifyModuleOperation({ module: "衣服搭配", action: "删除搭配", target: item.name, detail: `${d.length} 件衣服`, sourceTable: "b_clothing_outfit", sourceId: item.id }); stopLoading({ type: "success", message: "已删除" }); router.replace("/clothes/outfit" as any); @@ -84,7 +84,7 @@ export default function OutfitDetail() { {item.name} {/* 统计 + 标签 - 横向排列 */} - {/* 归属者 + 件数 + 分类 + 标签 */} + {/* 所属 + 件数 + 分类 + 标签 */} {owner ? ( @@ -95,7 +95,7 @@ export default function OutfitDetail() { {owner.nickname} ) : ( - 未指定归属者 + 未指定所属 )} {details.length} 件衣服 {categoryName === "未分类" ? ( 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 5587b551..e9e1535b 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 @@ -189,8 +189,8 @@ export default function ClothingOutfitEdit() { const clothesTagMap = useMemo(() => { const map = new Map>(); - tagRelations.filter((r) => r.target_type === ClothingTargetType.Clothes).forEach((r) => { - const s = map.get(r.target_id) ?? new Set(); s.add(r.tag_id); map.set(r.target_id, s); + tagRelations.filter((r) => r.clothes_id !== undefined).forEach((r) => { + const s = map.get(r.clothes_id!) ?? new Set(); s.add(r.tag_id); map.set(r.clothes_id!, s); }); return map; }, [tagRelations]); @@ -221,7 +221,7 @@ export default function ClothingOutfitEdit() { [tags], ); const outfitTags = useMemo( - () => tags.filter((item) => item.type === ClothingTargetType.Outfit && item.parent_id !== 0), + () => tags.filter((item) => item.type === ClothingTargetType.Outfit && item.parent_id !== null), [tags], ); const tagsByGroupId = useMemo(() => { @@ -277,7 +277,7 @@ export default function ClothingOutfitEdit() { setSelectedTagIds( state.tagRelations .filter( - (row) => row.target_type === ClothingTargetType.Outfit && row.target_id === editId, + (row) => row.outfit_id === editId, ) .map((row) => row.tag_id), ); @@ -296,7 +296,7 @@ export default function ClothingOutfitEdit() { ); setSelectedUid(details[0] ? `detail_${details[0].id}` : null); } else { - // 新增时默认归属者为当前登录用户 + // 新增时默认所属为当前用户 setOwnerId(currentUserId || null); } } finally { @@ -541,7 +541,7 @@ export default function ClothingOutfitEdit() { } const currentRelations = state.tagRelations.filter( - (row) => row.target_type === ClothingTargetType.Outfit && row.target_id === targetId, + (row) => row.outfit_id === targetId, ); const currentTagIds = new Set(currentRelations.map((row) => row.tag_id)); const relationDeletes = currentRelations @@ -617,8 +617,7 @@ export default function ClothingOutfitEdit() { tagRelationInserts: newRelationTagIds.map((tagId, index) => ({ id: relationIds[index], family_id: familyId, - target_type: ClothingTargetType.Outfit, - target_id: targetId, + outfit_id: targetId, tag_id: tagId, })), tagRelationDeletes: relationDeletes, @@ -754,12 +753,12 @@ export default function ClothingOutfitEdit() { {outfitCategories.map(renderCategoryChip)} - {/* 归属者 */} + {/* 所属 */} {members.length > 0 && ( <> - 归属者 + 所属 - 搭配仍为家庭共享,仅用于让家人知道这套搭配是谁的 + 搭配仍为家庭共享,仅用于标记所属成员 - {/* 归属者 */} + {/* 所属 */} - + setClothesFilterOwnerId(null)}> 全部 diff --git a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx index a139d6dc..4caf4e20 100644 --- a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Dimensions, Image, Modal, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native"; import { Button, Label, TextField } from "heroui-native"; import BottomSheet from "@/components/BottomSheet"; @@ -9,6 +9,7 @@ import { FileTextIcon, FilterIcon, PencilIcon, + RepeatIcon, RotateCcwIcon, Trash2Icon, } from "lucide-react-native"; @@ -20,7 +21,8 @@ import DateTimeRange from "@/components/DateTimeRange"; import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon"; import { loadDataBySqlApi, saveDataApi } from "@/request/api"; import { useFamilyStore, useFinanceStore, useUserStore } from "@/store"; -import { FinanceBill, FinanceCategory, FinanceType } from "@/types/finance"; +import { FinanceAutoRepeatType, FinanceBill, FinanceCategory, FinanceType } from "@/types/finance"; +import { REPEAT_LABELS, WEEK_DAYS } from "@/helpers/financeAuto"; import { getEndOfMonth, getStartOfMonth } from "@/utils/time"; import { useListLoading, useLoading } from "@/hooks/useLoading"; import { LoadingOverlay } from "@/components/LoadingOverlay"; @@ -67,6 +69,14 @@ export default function Home() { const { isLoading, startLoading, stopLoading } = useLoading(); const notifyModuleOperation = useModuleChatNotifier(); + // 自动记账 + const fetchAutoRules = useFinanceStore((s) => s.fetchAutoRules); + const runAutoRules = useFinanceStore((s) => s.runAutoRules); + const saveAutoRule = useFinanceStore((s) => s.saveAutoRule); + const autoRunRef = useRef(false); + const [showAutoSheet, setShowAutoSheet] = useState(false); + const [autoForm, setAutoForm] = useState({ repeatType: FinanceAutoRepeatType.Daily, repeatDate: "" }); + const [filter, setFilter] = useState(() => createDefaultFilter()); const [draftFilter, setDraftFilter] = useState(() => createDefaultFilter()); const [showFilterSheet, setShowFilterSheet] = useState(false); @@ -200,7 +210,16 @@ export default function Home() { useCallback(() => { if (!familyId) return; loadBills(filter); - }, [familyId, filter, loadBills]), + // 自动记账:加载规则并执行 + if (!autoRunRef.current && userId) { + autoRunRef.current = true; + fetchAutoRules(familyId).then(() => { + runAutoRules(familyId, userId).then((count) => { + if (count > 0) loadBills(filter); + }); + }); + } + }, [familyId, filter, loadBills, fetchAutoRules, runAutoRules, userId]), ); const handleRefresh = useCallback(() => { @@ -240,6 +259,36 @@ export default function Home() { router.push(`/finance/detail/${id}` as any); }, [activeBill, closeActionMenu]); + const openAutoSheet = useCallback(() => { + if (!activeBill) return; + closeActionMenu(); + setAutoForm({ repeatType: FinanceAutoRepeatType.Daily, repeatDate: "" }); + setShowAutoSheet(true); + }, [activeBill, closeActionMenu]); + + const handleSaveAuto = useCallback(async () => { + if (!familyId || !activeBill || !userId) return; + startLoading("保存中..."); + try { + const ok = await saveAutoRule({ + family_id: familyId, + type: activeBill.category_type, + category_id: activeBill.category_id, + amount: activeBill.amount, + remark: activeBill.remark ?? "", + repeat_type: autoForm.repeatType, + repeat_date: autoForm.repeatDate, + create_by: userId, + }); + if (ok) { + setShowAutoSheet(false); + stopLoading({ type: "success", message: "自动记账已开启" }); + } else { + stopLoading({ type: "error", message: "保存失败" }); + } + } catch { stopLoading({ type: "error", message: "保存失败" }); } + }, [familyId, activeBill, userId, autoForm, saveAutoRule, startLoading, stopLoading]); + const handleDeleteBill = useCallback(async () => { if (!activeBill || isLoading) return; @@ -649,6 +698,15 @@ export default function Home() { 编辑 + {/* 自动记账 */} + + + 自动记账 + {/* 删除 */} + {/* 自动记账 Sheet */} + + + + 自动记账 + + 基于「{activeBill?.category_name} {Number(activeBill?.amount || 0)}」创建规则 + + + {/* 频率 */} + + + + {Object.entries(REPEAT_LABELS).map(([k, v]) => { + const sel = autoForm.repeatType === Number(k); + return ( + setAutoForm({ repeatType: Number(k), repeatDate: "" })} + > + {v} + + ); + })} + + + + {/* 每周:选周几 */} + {autoForm.repeatType === FinanceAutoRepeatType.Weekly && ( + + + + {WEEK_DAYS.map((d) => { + const sel = (autoForm.repeatDate || "").split(",").includes(String(d.value)); + return ( + { + const arr = (autoForm.repeatDate || "").split(",").filter(Boolean); + const idx = arr.indexOf(String(d.value)); + if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d.value)); + setAutoForm((p) => ({ ...p, repeatDate: arr.sort().join(",") })); + }} + > + {d.label} + + ); + })} + + + )} + + {/* 每月/每季度:选日期 */} + {(autoForm.repeatType === FinanceAutoRepeatType.Monthly || autoForm.repeatType === FinanceAutoRepeatType.Quarterly) && ( + + + + {[1,5,10,15,20,25,28].map((d) => { + const sel = (autoForm.repeatDate || "").split(",").includes(String(d)); + return ( + setAutoForm((p) => ({ ...p, repeatDate: String(d) }))} + > + {d} + + ); + })} + + + )} + + {/* 每年:选月日 */} + {autoForm.repeatType === FinanceAutoRepeatType.Yearly && ( + + + + {["1,1","3,1","6,1","9,1","12,1","1,15","3,15","6,15","9,15","12,15"].map((md) => { + const [m, d] = md.split(","); + const sel = autoForm.repeatDate === md; + return ( + setAutoForm((p) => ({ ...p, repeatDate: md }))} + > + {m}月{d}日 + + ); + })} + + + )} + + + + + + + + + ); diff --git a/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx b/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx index 4f957dd6..1bf8f7f9 100644 --- a/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx +++ b/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx @@ -2,8 +2,8 @@ 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 { Avatar, Button, Input, Label } from "heroui-native"; +import { BadgeCheckIcon, CheckCircleIcon, CircleIcon, FilterIcon, PlusIcon, RotateCcwIcon, SendIcon, UserIcon } from "lucide-react-native"; import { toast } from "sonner-native"; import PageLayout from "@/layouts/PageLayout"; import LayoutView from "@/components/layout/view"; @@ -120,6 +120,16 @@ export default function MealPlanDetail() { const [confirming, setConfirming] = useState(false); const scrollRef = useRef(null); + // 加菜筛选 + const [showDishFilterSheet, setShowDishFilterSheet] = useState(false); + const [dishKeyword, setDishKeyword] = useState(""); + const [dishFilterCatId, setDishFilterCatId] = useState(null); + const [dishFilterTagIds, setDishFilterTagIds] = useState([]); + + const categories = useMealStore((s) => s.categories); + const tags = useMealStore((s) => s.tags); + const dishTags = useMealStore((s) => s.dishTags); + // 派生数据 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]); @@ -130,6 +140,37 @@ export default function MealPlanDetail() { ); const existingDishIds = useMemo(() => new Set(planItems.map((pi: any) => pi.dish_id)), [planItems]); + // 加菜筛选 + const dishTagMap = useMemo(() => { + const map = new Map>(); + dishTags.forEach((r: any) => { + const s = map.get(r.dish_id) ?? new Set(); + s.add(r.tag_id); + map.set(r.dish_id, s); + }); + return map; + }, [dishTags]); + + const filteredDishCandidates = useMemo(() => { + const kw = dishKeyword.trim().toLowerCase(); + return addDishCandidates.filter((d: any) => { + if (dishFilterCatId && d.category_id !== dishFilterCatId) return false; + if (dishFilterTagIds.length > 0) { + const ids = dishTagMap.get(d.id); + if (!ids || !dishFilterTagIds.every((tid) => ids.has(tid))) return false; + } + if (!kw) return true; + return (d.name ?? "").toLowerCase().includes(kw); + }); + }, [addDishCandidates, dishKeyword, dishFilterCatId, dishFilterTagIds, dishTagMap]); + + const dishFilterCount = useMemo(() => { + let c = 0; + if (dishFilterCatId) c++; + if (dishFilterTagIds.length > 0) c++; + return c; + }, [dishFilterCatId, dishFilterTagIds]); + // 菜单状态 const isPlanning = plan?.status === MealPlanStatus.Planning; const isConfirmed = plan?.status === MealPlanStatus.Confirmed; @@ -429,7 +470,7 @@ export default function MealPlanDetail() { const ir = await loadDataApi({ view_name: "b_meal_dish", search_condition: "family_id = ? and status = 1", - search_columns: ["id", "name", "image_file_key"], + search_columns: ["id", "name", "image_file_key", "category_id"], order_by: "sort_number asc", args: [familyId], }); @@ -713,16 +754,34 @@ export default function MealPlanDetail() { )} {/* ======== 添加菜品 Sheet ======== */} - + { setShowAddDish(v); if (!v) { setDishKeyword(""); setDishFilterCatId(null); setDishFilterTagIds([]); } }}> - + 添加菜品 - - {addDishCandidates.length > 0 + {/* 搜索 + 筛选 */} + + + + + setShowDishFilterSheet(true)} + > + + 筛选{dishFilterCount > 0 ? ` ${dishFilterCount}` : ""} + + + + {filteredDishCandidates.length > 0 ? ( - {addDishCandidates + {filteredDishCandidates .filter((d: any) => !existingDishIds.has(d.id)) .map((d: any) => ( ) - : 没有可添加的菜品} + : 没有匹配的菜品} + + + + {/* ======== 做饭人选择 Sheet ======== */} - + 选择做饭人 {members.length > 0 ? ( 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 4f55b38b..b075819d 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 @@ -286,13 +286,15 @@ export default function MealPlanEdit() { - + 选择日期 - 日期 - + diff --git a/code/app/app-rn/src/app/todo/(tabs)/_layout.tsx b/code/app/app-rn/src/app/todo/(tabs)/_layout.tsx new file mode 100644 index 00000000..32869bdf --- /dev/null +++ b/code/app/app-rn/src/app/todo/(tabs)/_layout.tsx @@ -0,0 +1,18 @@ +import { Stack } from "expo-router"; +import { ListTodoIcon, PlusCircleIcon } from "lucide-react-native"; +import ModuleLayout from "@/layouts/ModuleLayout"; +import { TabbarItem } from "@/components/layout/tabbar"; + +const tabs: TabbarItem[] = [ + { id: "/todo/home", icon: ListTodoIcon, title: "待办" }, + { id: "/todo/add", icon: PlusCircleIcon, title: "新建" }, +]; + +export default function TodoLayout() { + return ( + <> + + + + ); +} diff --git a/code/app/app-rn/src/app/todo/(tabs)/home/index.tsx b/code/app/app-rn/src/app/todo/(tabs)/home/index.tsx new file mode 100644 index 00000000..5860d0c0 --- /dev/null +++ b/code/app/app-rn/src/app/todo/(tabs)/home/index.tsx @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { useFocusEffect, useRouter } from "expo-router"; +import dayjs from "dayjs"; +import { Avatar } from "heroui-native"; +import { CalendarIcon, CircleIcon, StarIcon } from "lucide-react-native"; +import PageLayout from "@/layouts/PageLayout"; +import LayoutView from "@/components/layout/view"; +import { useListLoading } from "@/hooks/useLoading"; +import { useTodoStore, useFamilyStore } from "@/store"; +import { Todo, TodoPriority, TodoStatus } from "@/types/todo"; +import { buildFileUrl } from "@/utils/file"; + +const PRIORITY_LABELS: Record = { + [TodoPriority.Normal]: { text: "", color: "" }, + [TodoPriority.Important]: { text: "重要", color: "text-amber-600" }, + [TodoPriority.Urgent]: { text: "紧急", color: "text-red-600" }, +}; + +const STATUS_STYLES: Record = { + [TodoStatus.Unclaimed]: "bg-gray-100 text-gray-600", + [TodoStatus.InProgress]: "bg-blue-100 text-blue-700", + [TodoStatus.Completed]: "bg-green-100 text-green-700", + [TodoStatus.Overdue]: "bg-red-100 text-red-700", +}; + +const STATUS_LABELS: Record = { + [TodoStatus.Unclaimed]: "待认领", + [TodoStatus.InProgress]: "进行中", + [TodoStatus.Completed]: "已完成", + [TodoStatus.Overdue]: "已超时", +}; + +export default function TodoHome() { + const router = useRouter(); + const familyId = useFamilyStore((s) => s.getFamilyId()); + const todos = useTodoStore((s) => s.todos); + const members = useTodoStore((s) => s.members); + const todoRatings = useTodoStore((s) => s.todoRatings); + const fetchAll = useTodoStore((s) => s.fetchAll); + const { refreshing, runInitial, runRefresh } = useListLoading(); + + const load = useCallback(async () => { + if (!familyId) return; + await fetchAll(familyId); + }, [familyId, fetchAll]); + + useEffect(() => { if (familyId) runInitial(load); }, [familyId]); + useFocusEffect(useCallback(() => { load(); }, [load])); + + const memberMap = useMemo(() => new Map(members.map((m) => [m.user_id, m])), [members]); + const ratingMap = useMemo(() => { + const m = new Map(); + const groups = new Map(); + todoRatings.forEach((r) => { + const arr = groups.get(r.todo_id) ?? []; + arr.push(r.rating); + groups.set(r.todo_id, arr); + }); + groups.forEach((arr, todoId) => { + m.set(todoId, { count: arr.length, avg: Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 10) / 10 }); + }); + return m; + }, [todoRatings]); + + const getDisplayStatus = useCallback((todo: Todo) => { + if (todo.status === TodoStatus.Completed || todo.status === TodoStatus.Overdue) return todo.status; + if (todo.status === TodoStatus.InProgress && todo.deadline && dayjs().isAfter(dayjs(todo.deadline))) { + return TodoStatus.Overdue; + } + return todo.status; + }, []); + + const grouped = useMemo(() => { + const inProgress: Todo[] = []; + const unclaimed: Todo[] = []; + const completed: Todo[] = []; + todos.forEach((t) => { + const s = getDisplayStatus(t); + if (s === TodoStatus.InProgress) inProgress.push(t); + else if (s === TodoStatus.Unclaimed) unclaimed.push(t); + else completed.push(t); + }); + return { inProgress, unclaimed, completed }; + }, [todos, getDisplayStatus]); + + const renderTodo = (todo: Todo) => { + const status = getDisplayStatus(todo); + const assignee = todo.assignee_id ? memberMap.get(todo.assignee_id) : null; + const rating = ratingMap.get(todo.id); + + return ( + router.push(`/todo/detail/${todo.id}` as any)} + > + + + {todo.name} + + + {STATUS_LABELS[status]} + + + + {PRIORITY_LABELS[todo.priority].text ? ( + + {PRIORITY_LABELS[todo.priority].text} + + ) : null} + {todo.deadline && ( + + + + {dayjs(todo.deadline).format("M/D HH:mm")} + + + )} + {assignee && ( + + + {assignee.avatar ? : null} + {assignee.nickname?.charAt(0)} + + {assignee.nickname} + + )} + {rating && rating.count > 0 && ( + + + {rating.avg} ({rating.count}) + + )} + + + ); + }; + + return ( + + + runRefresh(load)} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}> + {grouped.inProgress.length > 0 && ( + <> + 进行中 · {grouped.inProgress.length} + {grouped.inProgress.map(renderTodo)} + + )} + {grouped.unclaimed.length > 0 && ( + <> + 待认领 · {grouped.unclaimed.length} + {grouped.unclaimed.map(renderTodo)} + + )} + {grouped.completed.length > 0 && ( + <> + 已完成 · {grouped.completed.length} + {grouped.completed.map(renderTodo)} + + )} + {todos.length === 0 && ( + + + 暂无待办 + + )} + + + + ); +} diff --git a/code/app/app-rn/src/app/todo/add/index.tsx b/code/app/app-rn/src/app/todo/add/index.tsx new file mode 100644 index 00000000..52f4a0bf --- /dev/null +++ b/code/app/app-rn/src/app/todo/add/index.tsx @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useState } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { Stack, useRouter } from "expo-router"; +import dayjs from "dayjs"; +import { Avatar, Button, Input, Label, TextArea, TextField } from "heroui-native"; +import { BellIcon, CheckIcon, RepeatIcon } 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 { LoadingOverlay } from "@/components/LoadingOverlay"; +import { useLoading } from "@/hooks/useLoading"; +import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier"; +import { useTodoStore, useFamilyStore } from "@/store"; +import { TodoPriority, TodoRepeatType, TodoFamilyMember } from "@/types/todo"; +import { buildFileUrl } from "@/utils/file"; + +const PRIORITIES = [ + { value: TodoPriority.Normal, label: "普通" }, + { value: TodoPriority.Important, label: "重要" }, + { value: TodoPriority.Urgent, label: "紧急" }, +]; + +const REPEATS = [ + { value: TodoRepeatType.None, label: "不重复" }, + { value: TodoRepeatType.Daily, label: "每天" }, + { value: TodoRepeatType.Weekly, label: "每周" }, + { value: TodoRepeatType.Monthly, label: "每月" }, +]; + +export default function TodoAdd() { + const router = useRouter(); + const familyId = useFamilyStore((s) => s.getFamilyId()); + const members = useTodoStore((s) => s.members); + const fetchMembers = useTodoStore((s) => s.fetchMembers); + const createTodo = useTodoStore((s) => s.createTodo); + const { isLoading, startLoading, stopLoading } = useLoading(); + const notifyOp = useModuleChatNotifier(); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [priority, setPriority] = useState(TodoPriority.Normal); + const [assigneeId, setAssigneeId] = useState(null); + const [repeatType, setRepeatType] = useState(TodoRepeatType.None); + const [deadline, setDeadline] = useState(""); + const [remindAt, setRemindAt] = useState(""); + const [pageLoading, setPageLoading] = useState(true); + const [showDeadline, setShowDeadline] = useState(false); + const [deadlineInput, setDeadlineInput] = useState(""); + const [showRemind, setShowRemind] = useState(false); + const [remindInput, setRemindInput] = useState(""); + + useEffect(() => { + if (familyId) fetchMembers(familyId).finally(() => setPageLoading(false)); + }, [familyId, fetchMembers]); + + const handleSave = useCallback(async () => { + if (!name.trim()) { toast.error("请输入任务名称"); return; } + if (!familyId || isLoading) return; + startLoading("创建中..."); + try { + const todoId = await createTodo({ + familyId, userId: 0, name: name.trim(), description: description.trim(), + priority, assigneeId, repeatType, + deadline: deadline || null, + remindAt: remindAt || null, + }); + if (!todoId) { stopLoading({ type: "error", message: "创建失败" }); return; } + notifyOp({ module: "待办", action: "创建任务", target: name.trim(), sourceTable: "b_todo", sourceId: todoId }); + stopLoading({ type: "success", message: "任务已创建" }); + router.back(); + } catch { stopLoading({ type: "error", message: "创建失败" }); } + }, [name, description, priority, assigneeId, repeatType, deadline, remindAt, familyId, isLoading, createTodo, notifyOp, router, startLoading, stopLoading]); + + const renderAssignee = (m: TodoFamilyMember) => { + const selected = assigneeId === m.user_id; + return ( + setAssigneeId(selected ? null : m.user_id)}> + {m.avatar ? : null}{m.nickname?.charAt(0)} + {m.nickname} + + ); + }; + + return ( + + + + + + +