From bb596f324535672eb14296b88dda805521bb2eff Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Mon, 29 Jun 2026 15:48:39 +0800 Subject: [PATCH] 20260629154839 --- code/app/.gitignore | 2 + code/app/CLOTHING_OUTFIT_DESIGN.md | 609 +++++++++++++ code/app/app-go/cmd/apply_sql/main.go | 54 ++ .../app-go/cmd/check_clothing_tables/main.go | 59 ++ .../cmd/check_removed_mime_columns/main.go | 66 ++ .../cmd/import_finance_category_icons/main.go | 233 +++++ code/app/app-go/db/clothing.sql | 136 +++ code/app/app-go/db/finance_category.sql | 2 - code/app/app-go/internal/handle/auth.go | 4 +- code/app/app-go/internal/handle/s3.go | 80 +- code/app/app-go/internal/router/router.go | 1 + code/app/app-go/internal/types/s3.go | 5 + code/app/app-go/pkg/s3store/index.go | 13 + code/app/app-rn/package.json | 1 + code/app/app-rn/pnpm-lock.yaml | 52 ++ .../src/app/(main)/(tabs)/chat/index.tsx | 11 +- .../app-rn/src/app/(main)/chat/history.tsx | 2 +- .../app-rn/src/app/(main)/family/entry.tsx | 27 +- .../app-rn/src/app/(main)/family/settings.tsx | 10 +- .../app/app-rn/src/app/(main)/family/user.tsx | 2 +- .../app-rn/src/app/clothes/(tabs)/_layout.tsx | 31 + .../src/app/clothes/(tabs)/home/index.tsx | 579 ++++++++++++ .../src/app/clothes/(tabs)/manage/index.tsx | 755 ++++++++++++++++ .../src/app/clothes/(tabs)/outfit/index.tsx | 149 ++++ .../app/app-rn/src/app/clothes/edit/index.tsx | 756 ++++++++++++++++ .../app/clothes/outfit/detail/[id]/index.tsx | 345 ++++++++ .../src/app/clothes/outfit/edit/index.tsx | 829 ++++++++++++++++++ .../src/app/finance/(tabs)/home/index.tsx | 49 +- code/app/app-rn/src/app/finance/add/index.tsx | 22 +- .../app-rn/src/app/finance/category/index.tsx | 35 +- .../app/app-rn/src/components/BottomSheet.tsx | 160 +++- .../src/components/clothing/ClothingImage.tsx | 46 + .../app/app-rn/src/hooks/useChatWebSocket.tsx | 24 +- .../app-rn/src/hooks/useModuleChatNotifier.ts | 35 + code/app/app-rn/src/request/api.ts | 23 +- code/app/app-rn/src/store/clothing.ts | 480 ++++++++++ code/app/app-rn/src/store/index.ts | 1 + code/app/app-rn/src/types/clothing.ts | 123 +++ code/app/app-rn/src/types/finance.ts | 1 - code/app/app-rn/src/types/index.ts | 5 - code/app/app-rn/src/utils/file.ts | 4 +- 41 files changed, 5735 insertions(+), 86 deletions(-) create mode 100644 code/app/.gitignore create mode 100644 code/app/CLOTHING_OUTFIT_DESIGN.md create mode 100644 code/app/app-go/cmd/apply_sql/main.go create mode 100644 code/app/app-go/cmd/check_clothing_tables/main.go create mode 100644 code/app/app-go/cmd/check_removed_mime_columns/main.go create mode 100644 code/app/app-go/cmd/import_finance_category_icons/main.go create mode 100644 code/app/app-go/db/clothing.sql create mode 100644 code/app/app-rn/src/app/clothes/(tabs)/_layout.tsx create mode 100644 code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx create mode 100644 code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx create mode 100644 code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx create mode 100644 code/app/app-rn/src/app/clothes/edit/index.tsx create mode 100644 code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx create mode 100644 code/app/app-rn/src/app/clothes/outfit/edit/index.tsx create mode 100644 code/app/app-rn/src/components/clothing/ClothingImage.tsx create mode 100644 code/app/app-rn/src/hooks/useModuleChatNotifier.ts create mode 100644 code/app/app-rn/src/store/clothing.ts create mode 100644 code/app/app-rn/src/types/clothing.ts delete mode 100644 code/app/app-rn/src/types/index.ts diff --git a/code/app/.gitignore b/code/app/.gitignore new file mode 100644 index 00000000..af97cc5a --- /dev/null +++ b/code/app/.gitignore @@ -0,0 +1,2 @@ +.tmp/ +tools/finance-category-icons/node_modules/ diff --git a/code/app/CLOTHING_OUTFIT_DESIGN.md b/code/app/CLOTHING_OUTFIT_DESIGN.md new file mode 100644 index 00000000..a07f126d --- /dev/null +++ b/code/app/CLOTHING_OUTFIT_DESIGN.md @@ -0,0 +1,609 @@ +# 衣服搭配子模块设计文档 + +## 1. 背景 + +在现有家庭 App 中新增「衣服搭配」子模块,用于管理家庭共享的衣物资产,并基于衣物设计、保存、复用搭配方案。 + +本模块先按 App 内工具设计,不做营销页。界面重点应是图片可浏览、筛选顺手、保存动作明确。 + +## 2. 目标 + +- 录入衣服,形成可管理的衣橱。 +- 给衣服维护标签/分类,分类支持自定义。 +- 设计搭配,选择多件衣服组合成一个搭配方案。 +- 保存搭配,搭配也支持标签/分类。 +- 后续可基于分类、标签等维度检索衣服和搭配。 + +## 3. 非目标 + +第一期暂不做: + +- AI 自动搭配。 +- 社区分享、公开穿搭广场。 +- 电商购买链接。 +- 复杂身材数据、尺码推荐。 +- 日历穿搭计划。 +- 洗护/库存/折旧管理。 +- 多成员私有衣橱隔离。 + +这些能力可以作为后续扩展,不进入第一版主流程。 + +## 4. 核心用户故事 + +### 衣服管理 + +- 作为用户,我可以新增一件衣服,上传图片,填写名称、分类、标签等信息。 +- 作为用户,我可以按分类、标签筛选衣服。 +- 作为用户,我可以编辑或删除已录入的衣服。 +- 作为用户,我可以停用/隐藏不常穿的衣服,而不是必须删除。 +- 作为用户,删除被搭配引用的衣服时,我可以选择是否同时删除相关搭配。 + +### 搭配管理 + +- 作为用户,我可以从衣橱中选择多件衣服组成一套搭配。 +- 作为用户,我可以保存搭配,填写搭配名称、分类、标签等信息。 +- 作为用户,我可以查看搭配详情,看到包含的所有衣服。 +- 作为用户,我可以编辑搭配中的衣服列表和基础信息。 +- 作为用户,我可以删除搭配,删除搭配不会删除搭配中引用的衣服。 + +### 分类/标签管理 + +- 作为用户,我可以维护衣服分类,例如上衣、裤子、外套、鞋、包、配饰。 +- 作为用户,我可以维护搭配分类,例如通勤、约会、运动、旅行、居家。 +- 作为用户,我可以维护标签组,例如尺寸、季节、风格、材质、颜色。 +- 作为用户,我可以给衣服和搭配添加多个标签,例如春秋、黑色、正式、宽松、显瘦。 +- 衣服标签和搭配标签需要区分,避免两个场景下的标签互相干扰。 + +## 5. 信息架构 + +建议一级入口叫「穿搭」或「衣橱」。 + +模块内建议 3 个主视图: + +- 衣橱:衣服列表、筛选、录入入口。 +- 搭配:搭配列表、筛选、创建入口。 +- 分类:衣服分类、搭配分类、标签维护。 + +第一期保留独立「分类」页,放在衣服搭配模块的底部 Tab 中;新增/编辑流程里也可以保留快速新建入口,减少录入中断。 + +## 5.1 已确认规则 + +- 子模块永远是家庭共享,不做个人私有衣橱。 +- 衣服图片必须上传,不允许无图衣服。 +- 分类和标签不做默认初始化,由用户自行创建。 +- 分类保持单层,不做父子结构。 +- 尺寸、季节、风格、颜色等父子结构使用“标签组 -> 标签”表达。 +- 衣服标签和搭配标签区分管理。 +- 不做颜色结构化字段,颜色这类信息由用户通过标签处理。 +- 标签使用表情做视觉识别,不单独配置标签颜色。 +- 不做季节、场景固定枚举,季节/场景由用户通过标签或分类自行处理。 +- 数据保存优先使用现有公共接口,例如 `data/loadData`、`data/saveData`。 +- 尽量都走通用接口;只有通用接口明显无法保证体验或一致性时,再补特殊接口。 +- 能在前端完成的能力优先放前端,减少后端特殊接口。 +- 删除搭配时,只删除搭配和搭配明细,不删除衣服。 +- 删除衣服时,如果衣服没有被搭配引用,直接删除或软删除该衣服。 +- 删除衣服时,如果衣服已被搭配引用,需要提示用户是否同时删除相关搭配: + - 用户确认:删除该衣服,同时删除所有引用该衣服的搭配及其明细。 + - 用户取消:衣服和搭配都不删除。 + +## 5.2 搭配编辑模式 + +第一版采用「自由拖拽画布」模式。 + +推荐模式: + +- 搭配编辑页顶部或主体是固定比例画布,建议 1:1。 +- 下方或底部抽屉是衣服选择区,可按分类、标签筛选衣服。 +- 用户点击衣服加入画布。 +- 画布内衣服支持拖拽移动、缩放、删除。 +- 第一版可以先不做旋转,降低手势复杂度。 +- 画布内衣服支持置顶/置底,或通过点击选中后调整层级。 +- 保存搭配时保存画布布局数据,并生成搭配缩略图。 + +原因: + +- 穿搭本质上是视觉组合,固定槽位会限制用户表达。 +- 用户需要自己控制衣服摆放位置,否则操作体验会显得僵硬。 +- 自由画布更接近“搭配板”,更符合用户对穿搭设计的预期。 + +第一版边界: + +- 画布比例固定为 1:1,不支持任意画布尺寸。 +- 每件衣服记录位置、尺寸、层级;第一版暂不支持旋转。 +- 不做撤销/重做。 +- 不做自动吸附、对齐辅助线。 +- 不做多选。 +- 不做复杂贴纸、文字、背景。 +- 画布背景固定为浅色或透明。 + +后续扩展: + +- 支持旋转。 +- 支持撤销/重做。 +- 支持吸附、对齐线、居中线。 +- 支持背景模板。 +- 支持文字说明或贴纸。 + +## 6. 数据对象草案 + +### 衣服 b_clothing_clothes + +字段草案: + +- id +- family_id +- name +- image_file_key +- category_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 停用。 +- 颜色、季节这类检索信息不做结构化字段,由标签承载。 + +### 搭配 b_clothing_outfit + +字段草案: + +- id +- family_id +- name +- cover_file_key +- layout_json +- category_id +- remark +- status +- sort_number +- create_time +- update_time +- create_by +- update_by + +说明: + +- `cover_file_key` 建议保存一张搭配缩略图,用于搭配列表快速展示。 +- 缩略图只保存生成后的结果,列表不实时拼图。 +- 缩略图由前端生成后通过 S3 上传,保存时写入 `cover_file_key`。 +- 如果缩略图生成失败,可以降级使用搭配内第一件衣服图片作为封面。 +- `layout_json` 保存画布级配置,例如画布宽高比例、背景、版本号等。 + +### 搭配明细 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/app-go/cmd/apply_sql/main.go b/code/app/app-go/cmd/apply_sql/main.go new file mode 100644 index 00000000..b7c45cb9 --- /dev/null +++ b/code/app/app-go/cmd/apply_sql/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "allapp-go/internal/bootstrap/boots" + "allapp-go/internal/config" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: go run ./cmd/apply_sql ") + os.Exit(1) + } + + sqlPath, err := filepath.Abs(os.Args[1]) + if err != nil { + exitErr("resolve sql path", err) + } + + sqlBytes, err := os.ReadFile(sqlPath) + if err != nil { + exitErr("read sql file", err) + } + + cfg, err := config.Load() + if err != nil { + exitErr("load config", err) + } + + pool, err := boots.InitPgsql(cfg) + if err != nil { + exitErr("connect postgres", err) + } + defer pool.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil { + exitErr("execute sql", err) + } + + fmt.Printf("applied sql: %s\n", sqlPath) +} + +func exitErr(step string, err error) { + fmt.Fprintf(os.Stderr, "%s failed: %v\n", step, err) + os.Exit(1) +} diff --git a/code/app/app-go/cmd/check_clothing_tables/main.go b/code/app/app-go/cmd/check_clothing_tables/main.go new file mode 100644 index 00000000..8fba6b47 --- /dev/null +++ b/code/app/app-go/cmd/check_clothing_tables/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "allapp-go/internal/bootstrap/boots" + "allapp-go/internal/config" +) + +func main() { + cfg, err := config.Load() + if err != nil { + exitErr("load config", err) + } + + pool, err := boots.InitPgsql(cfg) + if err != nil { + exitErr("connect postgres", err) + } + defer pool.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rows, err := pool.Query(ctx, ` + select table_name + from information_schema.tables + where table_schema = 'public' + and table_name like 'b_clothing_%' + order by table_name + `) + if err != nil { + exitErr("query tables", err) + } + defer rows.Close() + + count := 0 + for rows.Next() { + var tableName string + if err := rows.Scan(&tableName); err != nil { + exitErr("scan table", err) + } + count++ + fmt.Println(tableName) + } + if err := rows.Err(); err != nil { + exitErr("read rows", err) + } + + fmt.Printf("total=%d\n", count) +} + +func exitErr(step string, err error) { + fmt.Fprintf(os.Stderr, "%s failed: %v\n", step, err) + os.Exit(1) +} diff --git a/code/app/app-go/cmd/check_removed_mime_columns/main.go b/code/app/app-go/cmd/check_removed_mime_columns/main.go new file mode 100644 index 00000000..80521e96 --- /dev/null +++ b/code/app/app-go/cmd/check_removed_mime_columns/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "allapp-go/internal/bootstrap/boots" + "allapp-go/internal/config" +) + +func main() { + cfg, err := config.Load() + if err != nil { + exitErr("load config", err) + } + + pool, err := boots.InitPgsql(cfg) + if err != nil { + exitErr("connect postgres", err) + } + defer pool.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rows, err := pool.Query(ctx, ` + select table_name, column_name + from information_schema.columns + where table_schema = 'public' + and table_name in ( + 'b_finance_category_default', + 'b_finance_category', + 'b_clothing_clothes', + 'b_clothing_outfit' + ) + and column_name in ('icon_mime', 'image_mime', 'cover_mime') + order by table_name, column_name + `) + if err != nil { + exitErr("query columns", err) + } + defer rows.Close() + + count := 0 + for rows.Next() { + var tableName string + var columnName string + if err := rows.Scan(&tableName, &columnName); err != nil { + exitErr("scan column", err) + } + count++ + fmt.Printf("%s.%s\n", tableName, columnName) + } + if err := rows.Err(); err != nil { + exitErr("read rows", err) + } + + fmt.Printf("removed_mime_columns_remaining=%d\n", count) +} + +func exitErr(step string, err error) { + fmt.Fprintf(os.Stderr, "%s failed: %v\n", step, err) + os.Exit(1) +} diff --git a/code/app/app-go/cmd/import_finance_category_icons/main.go b/code/app/app-go/cmd/import_finance_category_icons/main.go new file mode 100644 index 00000000..a40f23eb --- /dev/null +++ b/code/app/app-go/cmd/import_finance_category_icons/main.go @@ -0,0 +1,233 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "allapp-go/internal/bootstrap/boots" + "allapp-go/internal/config" + "allapp-go/pkg/s3store" + "allapp-go/pkg/uniqueid" + + "github.com/jackc/pgx/v5" +) + +type iconManifestItem struct { + Name string `json:"name"` + Type int16 `json:"type"` + TypeName string `json:"typeName"` + Source string `json:"source"` + File string `json:"file"` + Key string `json:"key"` + Mime string `json:"mime"` + SortNumber int `json:"sortNumber"` + Width int `json:"width"` + Height int `json:"height"` +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + cfg, err := config.Load() + if err != nil { + exitErr("load config", err) + } + + boots.InitUniqueId(cfg) + s3store.NewS3Client(cfg) + + pool, err := boots.InitPgsql(cfg) + if err != nil { + exitErr("connect postgres", err) + } + defer pool.Close() + + workspaceRoot, err := filepath.Abs("..") + if err != nil { + exitErr("resolve workspace", err) + } + + manifestPath := filepath.Join(workspaceRoot, "tools", "finance-category-icons", "dist", "manifest.json") + manifestBytes, err := os.ReadFile(manifestPath) + if err != nil { + exitErr("read manifest", err) + } + + var items []iconManifestItem + if err := json.Unmarshal(manifestBytes, &items); err != nil { + exitErr("parse manifest", err) + } + if len(items) == 0 { + exitErr("validate manifest", fmt.Errorf("manifest is empty")) + } + + tableName, err := resolveDefaultCategoryTable(ctx, pool) + if err != nil { + exitErr("resolve table", err) + } + + tx, err := pool.Begin(ctx) + if err != nil { + exitErr("begin transaction", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + uploaded := 0 + inserted := 0 + updated := 0 + + for _, item := range items { + if err := validateItem(item); err != nil { + exitErr("validate item", err) + } + + filePath := filepath.Join(workspaceRoot, filepath.FromSlash(item.File)) + data, err := os.ReadFile(filePath) + if err != nil { + exitErr("read converted icon", err) + } + + if err := s3store.UploadToRustFS(ctx, bytes.NewReader(data), item.Key, int64(len(data)), item.Mime); err != nil { + exitErr("upload "+item.Key, err) + } + uploaded++ + + existingID, found, err := findExistingCategory(ctx, tx, tableName, item.Name, item.Type) + if err != nil { + exitErr("query existing category", err) + } + + now := time.Now() + if found { + if err := updateCategory(ctx, tx, tableName, existingID, item, now); err != nil { + exitErr("update category", err) + } + updated++ + continue + } + + if err := insertCategory(ctx, tx, tableName, uniqueid.NextId(), item, now); err != nil { + exitErr("insert category", err) + } + inserted++ + } + + if err := tx.Commit(ctx); err != nil { + exitErr("commit transaction", err) + } + + expenseCount, incomeCount, err := countImportedCategories(ctx, pool, tableName) + if err != nil { + exitErr("verify imported categories", err) + } + + fmt.Printf("Imported finance category icons successfully.\n") + fmt.Printf("table=%s uploaded=%d inserted=%d updated=%d total=%d\n", tableName, uploaded, inserted, updated, len(items)) + fmt.Printf("verified prefix=finance-category-default/ expense=%d income=%d total=%d\n", expenseCount, incomeCount, expenseCount+incomeCount) + for _, item := range items { + fmt.Printf("%s type=%d sort=%d key=%s\n", item.Name, item.Type, item.SortNumber, item.Key) + } +} + +func validateItem(item iconManifestItem) error { + if item.Name == "" || item.Key == "" || item.File == "" || item.Mime != "image/webp" { + return fmt.Errorf("invalid manifest item: %+v", item) + } + if item.Type != 0 && item.Type != 1 { + return fmt.Errorf("invalid type for %s: %d", item.Name, item.Type) + } + if item.Width != 256 || item.Height != 256 { + return fmt.Errorf("invalid dimensions for %s: %dx%d", item.Name, item.Width, item.Height) + } + return nil +} + +func resolveDefaultCategoryTable(ctx context.Context, q pgxQueryer) (string, error) { + candidates := []string{"finance_category_default", "b_finance_category_default"} + for _, table := range candidates { + var exists bool + if err := q.QueryRow(ctx, "select to_regclass($1) is not null", table).Scan(&exists); err != nil { + return "", err + } + if exists { + return table, nil + } + } + return "", fmt.Errorf("neither finance_category_default nor b_finance_category_default exists") +} + +func findExistingCategory(ctx context.Context, tx pgx.Tx, tableName string, name string, categoryType int16) (int64, bool, error) { + query := fmt.Sprintf("select id from %s where name=$1 and type=$2 order by id limit 1", safeTableName(tableName)) + var id int64 + err := tx.QueryRow(ctx, query, name, categoryType).Scan(&id) + if err == nil { + return id, true, nil + } + if err == pgx.ErrNoRows { + return 0, false, nil + } + return 0, false, err +} + +func updateCategory(ctx context.Context, tx pgx.Tx, tableName string, id int64, item iconManifestItem, now time.Time) error { + query := fmt.Sprintf(` + update %s + set icon_file_key=$1, sort_number=$2, status=1, update_time=$3 + where id=$4 + `, safeTableName(tableName)) + _, err := tx.Exec(ctx, query, item.Key, item.SortNumber, now, id) + return err +} + +func insertCategory(ctx context.Context, tx pgx.Tx, tableName string, id int64, item iconManifestItem, now time.Time) error { + query := fmt.Sprintf(` + insert into %s (id, name, type, icon_file_key, sort_number, status, create_time, update_time) + values ($1, $2, $3, $4, $5, 1, $6, $6) + `, safeTableName(tableName)) + _, err := tx.Exec(ctx, query, id, item.Name, item.Type, item.Key, item.SortNumber, now) + return err +} + +func countImportedCategories(ctx context.Context, q pgxQueryer, tableName string) (int, int, error) { + query := fmt.Sprintf(` + select + count(*) filter (where type = 0), + count(*) filter (where type = 1) + from %s + where icon_file_key like 'finance-category-default/%%' + and status = 1 + `, safeTableName(tableName)) + + var expenseCount int + var incomeCount int + if err := q.QueryRow(ctx, query).Scan(&expenseCount, &incomeCount); err != nil { + return 0, 0, err + } + return expenseCount, incomeCount, nil +} + +func safeTableName(tableName string) string { + switch tableName { + case "finance_category_default", "b_finance_category_default": + return tableName + default: + panic("unsafe table name: " + tableName) + } +} + +type pgxQueryer interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +func exitErr(step string, err error) { + fmt.Fprintf(os.Stderr, "%s failed: %v\n", step, err) + os.Exit(1) +} diff --git a/code/app/app-go/db/clothing.sql b/code/app/app-go/db/clothing.sql new file mode 100644 index 00000000..0f7ad163 --- /dev/null +++ b/code/app/app-go/db/clothing.sql @@ -0,0 +1,136 @@ +drop table if exists b_clothing_tag_relation; +drop table if exists b_clothing_outfit_clothes; +drop table if exists b_clothing_tag; +drop table if exists b_clothing_tag_group; +drop table if exists b_clothing_category; +drop table if exists b_clothing_outfit; +drop table if exists b_clothing_clothes; + +create table b_clothing_clothes ( + id bigint primary key, + family_id bigint not null, + name varchar(64) not null, + image_file_key varchar(512) not null, + category_id bigint, + brand varchar(64) default '', + remark varchar(512) default '', + sort_number int not null default 0, + status smallint not null default 1, + create_time timestamp, + update_time timestamp, + create_by bigint, + update_by bigint +); + +create table b_clothing_outfit ( + id bigint primary key, + family_id bigint not null, + name varchar(64) not null, + cover_file_key varchar(512) default '', + layout_json jsonb, + category_id bigint, + remark varchar(512) default '', + sort_number int not null default 0, + status smallint not null default 1, + create_time timestamp, + update_time timestamp, + create_by bigint, + update_by bigint +); + +create table b_clothing_outfit_clothes ( + id bigint primary key, + family_id bigint not null, + outfit_id bigint not null, + clothes_id bigint not null, + x numeric(8, 6) not null default 0, + y numeric(8, 6) not null default 0, + width numeric(8, 6) not null default 0, + 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_by bigint, + update_by bigint +); + +create table b_clothing_category ( + id bigint primary key, + family_id bigint not null, + name varchar(32) not null, + type smallint not null, + sort_number int not null default 0, + status smallint not null default 1, + create_time timestamp, + update_time timestamp, + create_by bigint, + update_by bigint +); + +create table b_clothing_tag_group ( + id bigint primary key, + family_id bigint not null, + name varchar(32) not null, + type smallint not null, + sort_number int not null default 0, + status smallint not null default 1, + create_time timestamp, + update_time timestamp, + create_by bigint, + update_by bigint +); + +create table b_clothing_tag ( + id bigint primary key, + family_id bigint not null, + tag_group_id bigint not null, + name varchar(32) not null, + type smallint not null, + emoji varchar(16) default '', + sort_number int not null default 0, + status smallint not null default 1, + create_time timestamp, + update_time timestamp, + create_by bigint, + update_by bigint +); + +create table b_clothing_tag_relation ( + id bigint primary key, + family_id bigint not null, + target_type smallint not null, + target_id bigint not null, + tag_id bigint not null, + create_time timestamp, + update_time timestamp, + create_by bigint, + update_by bigint +); + +create index idx_b_clothing_clothes_family_status_category + on b_clothing_clothes (family_id, status, category_id, sort_number, id); + +create index idx_b_clothing_outfit_family_status_category + on b_clothing_outfit (family_id, status, category_id, sort_number, id); + +create index idx_b_clothing_outfit_clothes_outfit + on b_clothing_outfit_clothes (outfit_id, z_index, sort_number, id); + +create index idx_b_clothing_outfit_clothes_clothes + on b_clothing_outfit_clothes (family_id, clothes_id, outfit_id); + +create index idx_b_clothing_category_family_type + on b_clothing_category (family_id, status, type, sort_number, id); + +create index idx_b_clothing_tag_group_family_type + on b_clothing_tag_group (family_id, status, type, sort_number, id); + +create index idx_b_clothing_tag_family_group + on b_clothing_tag (family_id, status, type, tag_group_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_tag_relation_tag + on b_clothing_tag_relation (family_id, tag_id, target_type, target_id); diff --git a/code/app/app-go/db/finance_category.sql b/code/app/app-go/db/finance_category.sql index 99ba6656..fdbc58e9 100644 --- a/code/app/app-go/db/finance_category.sql +++ b/code/app/app-go/db/finance_category.sql @@ -6,7 +6,6 @@ create table b_finance_category_default ( name varchar(32) not null, type smallint not null, icon_file_key varchar(512) not null, - icon_mime varchar(64) default '', sort_number int not null default 0, status smallint not null default 1, create_time timestamp, @@ -22,7 +21,6 @@ create table b_finance_category ( type smallint not null, icon_file_key varchar(512) not null, icon_source smallint not null, - icon_mime varchar(64) default '', sort_number int not null default 0, status smallint not null default 1, create_time timestamp, diff --git a/code/app/app-go/internal/handle/auth.go b/code/app/app-go/internal/handle/auth.go index 03164493..5cf09028 100644 --- a/code/app/app-go/internal/handle/auth.go +++ b/code/app/app-go/internal/handle/auth.go @@ -257,9 +257,9 @@ func registerUser( ext = ".jpg" // 兜底 } - // 年月日 + uuid + // 注册头像没有家庭上下文,单独放到 avatar 前缀下。 now := time.Now() - key := fmt.Sprintf("%d/%02d/%02d/%s%s", + key := fmt.Sprintf("avatar/%d/%02d/%02d/%s%s", now.Year(), now.Month(), now.Day(), diff --git a/code/app/app-go/internal/handle/s3.go b/code/app/app-go/internal/handle/s3.go index c3966699..55ee2b65 100644 --- a/code/app/app-go/internal/handle/s3.go +++ b/code/app/app-go/internal/handle/s3.go @@ -8,6 +8,7 @@ import ( "allapp-go/pkg/s3store" "fmt" "path/filepath" + "strconv" "time" "github.com/gofiber/fiber/v3" @@ -28,11 +29,10 @@ func S3UploadFile(c fiber.Ctx) error { } now := time.Now() - folderPath := fmt.Sprintf("%d/%02d/%02d", - now.Year(), - now.Month(), - now.Day(), - ) + folderPath, err := uploadFolderPath(c.FormValue("upload_type"), c.FormValue("family_id"), now) + if err != nil { + return httpx.Fail(c, err.Error()) + } var result []string @@ -68,6 +68,37 @@ func S3UploadFile(c fiber.Ctx) error { return httpx.OK(c, result) } +func S3CopyFile(c fiber.Ctx) error { + var req types.S3CopyReq + + if err := httpx.BindAndValidate(c, &req); err != nil { + return errors.WithStack(err) + } + + if len(req.Keys) == 0 { + return httpx.OK(c, []string{}) + } + + folderPath := familyDateFolder(req.FamilyID, time.Now()) + result := make([]string, 0, len(req.Keys)) + + for _, sourceKey := range req.Keys { + ext := filepath.Ext(sourceKey) + if ext == "" { + ext = ".bin" + } + + targetKey := fmt.Sprintf("%s/%s%s", folderPath, uuid.NewString(), ext) + if err := s3store.CopyObject(c.Context(), sourceKey, targetKey); err != nil { + return errors.WithStack(err) + } + + result = append(result, targetKey) + } + + return httpx.OK(c, result) +} + func S3DeleteFile(c fiber.Ctx) error { var req types.S3DeleteReq @@ -87,3 +118,42 @@ func S3DeleteFile(c fiber.Ctx) error { return httpx.OK(c, nil) } + +func parseFamilyID(value string) (int64, error) { + familyID, err := strconv.ParseInt(value, 10, 64) + if err != nil || familyID <= 0 { + return 0, fmt.Errorf("invalid family_id") + } + + return familyID, nil +} + +func familyDateFolder(familyID int64, now time.Time) string { + return fmt.Sprintf("%d/%d/%02d/%02d", + familyID, + now.Year(), + now.Month(), + now.Day(), + ) +} + +func avatarDateFolder(now time.Time) string { + return fmt.Sprintf("avatar/%d/%02d/%02d", + now.Year(), + now.Month(), + now.Day(), + ) +} + +func uploadFolderPath(uploadType string, familyIDValue string, now time.Time) (string, error) { + if uploadType == "avatar" { + return avatarDateFolder(now), nil + } + + familyID, err := parseFamilyID(familyIDValue) + if err != nil { + return "", fmt.Errorf("family_id不能为空") + } + + return familyDateFolder(familyID, now), nil +} diff --git a/code/app/app-go/internal/router/router.go b/code/app/app-go/internal/router/router.go index 12c3f3ad..fcdddd43 100644 --- a/code/app/app-go/internal/router/router.go +++ b/code/app/app-go/internal/router/router.go @@ -24,6 +24,7 @@ func SetupRouter(app *fiber.App, cfg *config.Config) { s3 := api.Group("/s3", middleware.Auth()) s3.Post("/upload", handle.S3UploadFile) + s3.Post("/copy", handle.S3CopyFile) s3.Post("/delete", handle.S3DeleteFile) // ==================== auth ==================== diff --git a/code/app/app-go/internal/types/s3.go b/code/app/app-go/internal/types/s3.go index 7435eb53..73cc00e1 100644 --- a/code/app/app-go/internal/types/s3.go +++ b/code/app/app-go/internal/types/s3.go @@ -3,3 +3,8 @@ package types type S3DeleteReq struct { Keys []string `json:"keys" validate:"required"` } + +type S3CopyReq struct { + FamilyID int64 `json:"family_id" validate:"required,gt=0"` + Keys []string `json:"keys" validate:"required"` +} diff --git a/code/app/app-go/pkg/s3store/index.go b/code/app/app-go/pkg/s3store/index.go index daa50f5b..69085000 100644 --- a/code/app/app-go/pkg/s3store/index.go +++ b/code/app/app-go/pkg/s3store/index.go @@ -5,6 +5,8 @@ import ( "context" "fmt" "io" + "net/url" + "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials" @@ -52,6 +54,17 @@ func UploadToRustFS(ctx context.Context, file io.Reader, key string, size int64, return err } +func CopyObject(ctx context.Context, sourceKey string, targetKey string) error { + source := strings.ReplaceAll(url.PathEscape(fmt.Sprintf("%s/%s", bucket, sourceKey)), "%2F", "/") + + _, err := S3Client.CopyObject(ctx, &s3.CopyObjectInput{ + Bucket: aws.String(bucket), + CopySource: aws.String(source), + Key: aws.String(targetKey), + }) + return err +} + // DeleteFromRustFS 删除 func DeleteFromRustFS(ctx context.Context, key string) error { _, err := S3Client.DeleteObject(ctx, &s3.DeleteObjectInput{ diff --git a/code/app/app-rn/package.json b/code/app/app-rn/package.json index f2afd93f..d19d1681 100644 --- a/code/app/app-rn/package.json +++ b/code/app/app-rn/package.json @@ -60,6 +60,7 @@ "react-native-sortables": "^1.9.4", "react-native-svg": "15.15.4", "react-native-ui-datepicker": "^3.1.3", + "react-native-view-shot": "^5.1.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.8.3", "sonner-native": "^0.24.0", diff --git a/code/app/app-rn/pnpm-lock.yaml b/code/app/app-rn/pnpm-lock.yaml index 1fcfbf6a..19cc5391 100644 --- a/code/app/app-rn/pnpm-lock.yaml +++ b/code/app/app-rn/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: react-native-ui-datepicker: specifier: ^3.1.3 version: 3.3.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) + react-native-view-shot: + specifier: ^5.1.0 + version: 5.1.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) react-native-web: specifier: ~0.21.0 version: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1681,6 +1684,10 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1863,6 +1870,9 @@ packages: css-in-js-utils@3.1.0: resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -2452,6 +2462,10 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3259,6 +3273,13 @@ packages: react: '*' react-native: '*' + react-native-view-shot@5.1.0: + resolution: {integrity: sha512-JZgElCD82aO+hejIF/leUzI7JufL9mgJ6ChzGWIcdZ2ajpaEvvSnvIcw0qD32XWkrbId8wfSbyz/4u/ulTQzQA==} + engines: {node: '>=20', npm: '>=10'} + peerDependencies: + react: '>=18.0.0' + react-native: '>=0.76.0' + react-native-web@0.21.2: resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} peerDependencies: @@ -3575,6 +3596,9 @@ packages: engines: {node: '>=10'} hasBin: true + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} @@ -3710,6 +3734,9 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -5552,6 +5579,8 @@ snapshots: balanced-match@4.0.4: {} + base64-arraybuffer@1.0.2: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.38: {} @@ -5747,6 +5776,10 @@ snapshots: dependencies: hyphenate-style-name: 1.1.0 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -6327,6 +6360,11 @@ snapshots: dependencies: lru-cache: 10.4.3 + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -7182,6 +7220,12 @@ snapshots: 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) tailwind-merge: 3.6.0 + react-native-view-shot@5.1.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3): + dependencies: + html2canvas: 1.4.1 + 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-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@babel/runtime': 7.29.7 @@ -7518,6 +7562,10 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + throat@5.0.0: {} tinyglobby@0.2.17: @@ -7614,6 +7662,10 @@ snapshots: utils-merge@1.0.1: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + uuid@7.0.3: {} validate-npm-package-name@5.0.1: {} 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 16d64f72..a176699e 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 @@ -18,7 +18,7 @@ import { useUserStore, useFamilyStore } from "@/store"; import { useChatWebSocket, ChatMessage, OnlineUser } from "@/hooks/useChatWebSocket"; import { buildFileUrl } from "@/utils/file"; import { cn } from "@/utils/cn"; -import { ChatMsgType, normalizeChatMessage } from "@/types"; +import { ChatMsgType, normalizeChatMessage } from "@/types/chat"; import { useListLoading } from "@/hooks/useLoading"; function formatTime(ts: string) { @@ -98,6 +98,7 @@ const ChatMessageItem = memo(function ChatMessageItem({ const msgTime = new Date(item.create_time).getTime(); const within2Min = !isNaN(msgTime) && Date.now() - msgTime < 2 * 60 * 1000; const canRecall = isSelf && within2Min; + const canShowActions = canRecall || isAdmin; if (item.recalled === 1) { return ( @@ -119,7 +120,10 @@ const ChatMessageItem = memo(function ChatMessageItem({ return ( <> - (canRecall || isAdmin) && setShowActions(true)}> + canShowActions && setShowActions(true)} + > {!isSelf && ( {showActions && ( - + @@ -285,6 +289,7 @@ const ChatMessageItem = memo(function ChatMessageItem({ )} {isAdmin && ( { onDelete(item.id); setShowActions(false); diff --git a/code/app/app-rn/src/app/(main)/chat/history.tsx b/code/app/app-rn/src/app/(main)/chat/history.tsx index f6b76bd2..f74da944 100644 --- a/code/app/app-rn/src/app/(main)/chat/history.tsx +++ b/code/app/app-rn/src/app/(main)/chat/history.tsx @@ -10,7 +10,7 @@ import { Button, Label, SearchField, Spinner, PressableFeedback } from "heroui-n import BottomSheet from "@/components/BottomSheet"; import { CalendarDaysIcon, CheckIcon, FilterIcon, RotateCcwIcon } from "lucide-react-native"; import dayjs from "dayjs"; -import { ChatMsgType, normalizeChatMessage } from "@/types"; +import { ChatMsgType, normalizeChatMessage } from "@/types/chat"; import { useListLoading } from "@/hooks/useLoading"; import { useFamilyStore } from "@/store"; import DateTimeRange from "@/components/DateTimeRange"; diff --git a/code/app/app-rn/src/app/(main)/family/entry.tsx b/code/app/app-rn/src/app/(main)/family/entry.tsx index 139c31d9..f0ec1723 100644 --- a/code/app/app-rn/src/app/(main)/family/entry.tsx +++ b/code/app/app-rn/src/app/(main)/family/entry.tsx @@ -4,7 +4,7 @@ import { Stack } from "expo-router"; import EmptyState from "@/components/EmptyStatus"; import { Button, Input, InputOTP, type InputOTPRef } from "heroui-native"; import { PlusIcon, HousePlusIcon } from "lucide-react-native"; -import { getUniqueIdApi, loadDataApi, saveDataApi } from "@/request/api"; +import { getUniqueIdApi, loadDataApi, saveDataApi, s3CopyFilesApi } from "@/request/api"; import { useUserStore, useFamilyStore } from "@/store"; import { toast } from "sonner-native"; import { generateCode } from "@/utils/common"; @@ -12,6 +12,7 @@ import { useRouter } from "expo-router"; import { useLoading } from "@/hooks/useLoading"; import { LoadingOverlay } from "@/components/LoadingOverlay"; import { KeyboardAvoidingView } from "react-native-keyboard-controller"; +import { deleteS3FileQuietly } from "@/utils/file"; export default function EntryFamily() { const router = useRouter(); @@ -131,6 +132,7 @@ export default function EntryFamily() { } startLoading("创建中..."); + let copiedDefaultIconKeys: string[] = []; try { const existFamilyId = await getUserFamily(); @@ -190,7 +192,7 @@ export default function EntryFamily() { view_name: "b_finance_category_default", search_condition: "", order_by: "", - search_columns: ["name", "type", "icon_file_key", "icon_mime", "sort_number"], + search_columns: ["name", "type", "icon_file_key", "sort_number"], args: [], }); @@ -199,9 +201,24 @@ export default function EntryFamily() { return; } - defaultFinanceCategoryRes.data.forEach((item) => { + const defaultFinanceCategories = defaultFinanceCategoryRes.data; + const defaultIconKeys = defaultFinanceCategories + .map((item) => item.icon_file_key) + .filter(Boolean); + const copyIconRes = await s3CopyFilesApi(familyId, defaultIconKeys); + const copiedIconKeys = copyIconRes.data ?? []; + copiedDefaultIconKeys = copiedIconKeys; + + if (!copyIconRes.isSuccess || copiedIconKeys.length !== defaultIconKeys.length) { + stopLoading({ type: "error", message: copyIconRes.message || "复制默认分类图标失败" }); + return; + } + + let iconIndex = 0; + defaultFinanceCategories.forEach((item) => { item.family_id = familyId; item.icon_source = 0; + item.icon_file_key = item.icon_file_key ? copiedIconKeys[iconIndex++] : ""; item.status = 1; }); @@ -209,7 +226,7 @@ export default function EntryFamily() { saveReq.push({ table_name: "b_finance_category", key_field: "id", - inserts: defaultFinanceCategoryRes.data, + inserts: defaultFinanceCategories, updates: [], deletes: [], }); @@ -221,10 +238,12 @@ export default function EntryFamily() { setActiveForm(null); enterFamily(familyId); } else { + copiedDefaultIconKeys.forEach(deleteS3FileQuietly); stopLoading({ type: "error", message: saveRes.message || "创建失败" }); } } catch (e) { console.error(e); + copiedDefaultIconKeys.forEach(deleteS3FileQuietly); stopLoading({ type: "error", message: "创建失败" }); } } diff --git a/code/app/app-rn/src/app/(main)/family/settings.tsx b/code/app/app-rn/src/app/(main)/family/settings.tsx index 32f625a5..95d50082 100644 --- a/code/app/app-rn/src/app/(main)/family/settings.tsx +++ b/code/app/app-rn/src/app/(main)/family/settings.tsx @@ -104,9 +104,10 @@ export default function HomeSettings() { useEffect(() => { if (showFamilyNameSheet) { - requestAnimationFrame(() => { + const timer = setTimeout(() => { familyNameInputRef.current?.focus(); - }); + }, 300); + return () => clearTimeout(timer); } else { Keyboard.dismiss(); } @@ -114,9 +115,10 @@ export default function HomeSettings() { useEffect(() => { if (showNicknameSheet) { - requestAnimationFrame(() => { + const timer = setTimeout(() => { nicknameInputRef.current?.focus(); - }); + }, 300); + return () => clearTimeout(timer); } else { Keyboard.dismiss(); } diff --git a/code/app/app-rn/src/app/(main)/family/user.tsx b/code/app/app-rn/src/app/(main)/family/user.tsx index befc0f00..6e3a53cb 100644 --- a/code/app/app-rn/src/app/(main)/family/user.tsx +++ b/code/app/app-rn/src/app/(main)/family/user.tsx @@ -283,7 +283,7 @@ export default function UserSetting() { type: img.mime || "image/jpeg", }; - const uploadRes = await s3UploadFileApi([file]); + const uploadRes = await s3UploadFileApi([file], { uploadType: "avatar" }); if (!uploadRes.isSuccess) return; diff --git a/code/app/app-rn/src/app/clothes/(tabs)/_layout.tsx b/code/app/app-rn/src/app/clothes/(tabs)/_layout.tsx new file mode 100644 index 00000000..f09dfa15 --- /dev/null +++ b/code/app/app-rn/src/app/clothes/(tabs)/_layout.tsx @@ -0,0 +1,31 @@ +import { Stack } from "expo-router"; +import { ShirtIcon, SparklesIcon, TagIcon } from "lucide-react-native"; +import ModuleLayout from "@/layouts/ModuleLayout"; +import { TabbarItem } from "@/components/layout/tabbar"; + +const tabs: TabbarItem[] = [ + { + id: "/clothes/home", + icon: ShirtIcon, + title: "衣橱", + }, + { + id: "/clothes/outfit", + icon: SparklesIcon, + title: "搭配", + }, + { + id: "/clothes/manage", + icon: TagIcon, + title: "分类", + }, +]; + +export default function ClothesLayout() { + return ( + <> + + + + ); +} 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 new file mode 100644 index 00000000..b2ee6b54 --- /dev/null +++ b/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx @@ -0,0 +1,579 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, Text, TouchableOpacity, View } from "react-native"; +import { router, useFocusEffect } from "expo-router"; +import { Button, Card, Label, Menu, SearchField } from "heroui-native"; +import { + CheckIcon, + FilterIcon, + PencilIcon, + PlusIcon, + RotateCcwIcon, + Trash2Icon, +} from "lucide-react-native"; +import BottomSheet from "@/components/BottomSheet"; +import EmptyState from "@/components/EmptyStatus"; +import LayoutView from "@/components/layout/view"; +import ClothingImage from "@/components/clothing/ClothingImage"; +import { LoadingOverlay } from "@/components/LoadingOverlay"; +import { useListLoading, useLoading } from "@/hooks/useLoading"; +import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier"; +import { useClothingStore, useFamilyStore } from "@/store"; +import { + ClothingCategory, + ClothingClothes, + ClothingTag, + ClothingTargetType, +} from "@/types/clothing"; +import { deleteS3FileQuietly } from "@/utils/file"; + +interface ClothesFilter { + categoryId: number | null; + tagIds: number[]; +} + +function createDefaultFilter(): ClothesFilter { + return { + categoryId: null, + tagIds: [], + }; +} + +function sameNumberSet(a: number[], b: number[]) { + if (a.length !== b.length) return false; + const set = new Set(a); + return b.every((item) => set.has(item)); +} + +export default function ClothingHome() { + const familyId = useFamilyStore((s) => s.getFamilyId()); + const clothes = useClothingStore((s) => s.clothes); + const categories = useClothingStore((s) => s.categories); + const tags = useClothingStore((s) => s.tags); + const tagRelations = useClothingStore((s) => s.tagRelations); + const fetchClothes = useClothingStore((s) => s.fetchClothes); + const fetchOutfits = useClothingStore((s) => s.fetchOutfits); + const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes); + const fetchCategories = useClothingStore((s) => s.fetchCategories); + const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups); + const fetchTags = useClothingStore((s) => s.fetchTags); + const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations); + const deleteClothesBundle = useClothingStore((s) => s.deleteClothesBundle); + const { loading, refreshing, runInitial, runRefresh } = useListLoading(); + const { isLoading, startLoading, stopLoading } = useLoading(); + const notifyModuleOperation = useModuleChatNotifier(); + + const [keyword, setKeyword] = useState(""); + const [filter, setFilter] = useState(() => createDefaultFilter()); + const [draftFilter, setDraftFilter] = useState(() => createDefaultFilter()); + const [showFilterSheet, setShowFilterSheet] = useState(false); + const [activeItem, setActiveItem] = useState(null); + + const clothesCategories = useMemo( + () => categories.filter((item) => item.type === ClothingTargetType.Clothes), + [categories], + ); + + const clothesTags = useMemo( + () => tags.filter((item) => item.type === ClothingTargetType.Clothes), + [tags], + ); + + const activeFilterCount = useMemo(() => { + let count = 0; + if (filter.categoryId) count += 1; + if (filter.tagIds.length > 0) count += 1; + return count; + }, [filter]); + + const tagIdsByClothesId = useMemo(() => { + const map = new Map>(); + tagRelations + .filter((item) => item.target_type === ClothingTargetType.Clothes) + .forEach((item) => { + const set = map.get(item.target_id) ?? new Set(); + set.add(item.tag_id); + map.set(item.target_id, set); + }); + return map; + }, [tagRelations]); + + const filteredClothes = useMemo(() => { + const trimmedKeyword = keyword.trim().toLowerCase(); + + return clothes.filter((item) => { + if (filter.categoryId && item.category_id !== filter.categoryId) return false; + + if (filter.tagIds.length > 0) { + const itemTagIds = tagIdsByClothesId.get(item.id); + if (!itemTagIds) return false; + if (!filter.tagIds.every((tagId) => itemTagIds.has(tagId))) return false; + } + + if (!trimmedKeyword) return true; + const text = `${item.name} ${item.brand ?? ""} ${item.remark ?? ""}`.toLowerCase(); + return text.includes(trimmedKeyword); + }); + }, [clothes, filter, keyword, tagIdsByClothesId]); + + const loadBaseData = useCallback(async () => { + if (!familyId) return; + + await Promise.all([ + fetchClothes(familyId), + fetchOutfits(familyId), + fetchOutfitClothes(familyId), + fetchCategories(familyId, ClothingTargetType.Clothes), + fetchTagGroups(familyId, ClothingTargetType.Clothes), + fetchTags(familyId, ClothingTargetType.Clothes), + fetchTagRelations(familyId), + ]); + }, [ + familyId, + fetchCategories, + fetchClothes, + fetchOutfitClothes, + fetchOutfits, + fetchTagGroups, + fetchTagRelations, + fetchTags, + ]); + + useEffect(() => { + if (!familyId) return; + runInitial(loadBaseData); + }, [familyId]); + + useFocusEffect( + useCallback(() => { + if (!familyId) return; + loadBaseData(); + }, [familyId, loadBaseData]), + ); + + const handleRefresh = useCallback(() => { + runRefresh(loadBaseData); + }, [loadBaseData, runRefresh]); + + const handleResetFilter = useCallback(() => { + setDraftFilter(createDefaultFilter()); + }, []); + + const handleApplyFilter = useCallback(() => { + setFilter(draftFilter); + setShowFilterSheet(false); + }, [draftFilter]); + + const handleOpenFilter = useCallback(() => { + setDraftFilter(filter); + setShowFilterSheet(true); + }, [filter]); + + const handleEdit = useCallback((item: ClothingClothes) => { + setActiveItem(null); + router.push(`/clothes/edit?id=${item.id}` as any); + }, []); + + const handleDelete = useCallback( + async (item: ClothingClothes, deleteRelatedOutfits: boolean) => { + if (!familyId || isLoading) return; + + startLoading("删除中..."); + try { + const state = useClothingStore.getState(); + const references = state.outfitClothes.filter((row) => row.clothes_id === item.id); + const relatedOutfitIds = Array.from(new Set(references.map((row) => row.outfit_id))); + const outfitClothesDeletes = deleteRelatedOutfits + ? state.outfitClothes + .filter((row) => relatedOutfitIds.includes(row.outfit_id)) + .map((row) => ({ id: row.id })) + : []; + const tagRelationDeletes = state.tagRelations + .filter( + (row) => + (row.target_type === ClothingTargetType.Clothes && row.target_id === item.id) || + (deleteRelatedOutfits && + row.target_type === ClothingTargetType.Outfit && + relatedOutfitIds.includes(row.target_id)), + ) + .map((row) => ({ id: row.id })); + + const ok = await deleteClothesBundle({ + clothesDeletes: [{ id: item.id }], + outfitDeletes: deleteRelatedOutfits + ? relatedOutfitIds.map((id) => ({ + id, + })) + : [], + outfitClothesDeletes, + tagRelationDeletes, + }); + + if (!ok) { + stopLoading({ type: "error", message: "删除失败" }); + return; + } + + deleteS3FileQuietly(item.image_file_key); + if (deleteRelatedOutfits) { + state.outfits + .filter((outfit) => relatedOutfitIds.includes(outfit.id)) + .forEach((outfit) => deleteS3FileQuietly(outfit.cover_file_key)); + } + await loadBaseData(); + notifyModuleOperation({ + module: "衣服搭配", + action: "删除衣服", + target: item.name, + detail: deleteRelatedOutfits + ? `同时删除 ${relatedOutfitIds.length} 个关联搭配` + : undefined, + sourceTable: "b_clothing_clothes", + sourceId: item.id, + }); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, + [ + deleteClothesBundle, + familyId, + isLoading, + loadBaseData, + notifyModuleOperation, + startLoading, + stopLoading, + ], + ); + + const handleRequestDelete = useCallback( + async (item: ClothingClothes) => { + setActiveItem(null); + if (!familyId) return; + + await Promise.all([ + fetchOutfits(familyId), + fetchOutfitClothes(familyId), + fetchTagRelations(familyId), + ]); + + const state = useClothingStore.getState(); + const relatedOutfitIds = Array.from( + new Set( + state.outfitClothes + .filter((row) => row.clothes_id === item.id) + .map((row) => row.outfit_id), + ), + ); + + if (relatedOutfitIds.length === 0) { + Alert.alert("删除衣服", `确认删除「${item.name}」吗?`, [ + { text: "取消", style: "cancel" }, + { text: "删除", style: "destructive", onPress: () => handleDelete(item, false) }, + ]); + return; + } + + Alert.alert( + "衣服已被搭配使用", + `「${item.name}」已被 ${relatedOutfitIds.length} 个搭配使用,是否同时删除这些搭配?`, + [ + { text: "不删除", style: "cancel" }, + { + text: "全部删除", + style: "destructive", + onPress: () => handleDelete(item, true), + }, + ], + ); + }, + [familyId, fetchOutfitClothes, fetchOutfits, fetchTagRelations, handleDelete], + ); + + const renderCategoryName = useCallback( + (categoryId?: number | null) => + clothesCategories.find((category) => category.id === categoryId)?.name ?? "未分类", + [clothesCategories], + ); + + const renderItemTags = useCallback( + (item: ClothingClothes) => { + const itemTagIds = tagIdsByClothesId.get(item.id); + if (!itemTagIds?.size) return null; + + return clothesTags + .filter((tag) => itemTagIds.has(tag.id)) + .slice(0, 3) + .map((tag) => ( + + + {tag.emoji ? `${tag.emoji} ` : ""} + {tag.name} + + + )); + }, + [clothesTags, tagIdsByClothesId], + ); + + const renderClothesCard = useCallback( + ({ item }: { item: ClothingClothes }) => ( + handleEdit(item)} + onLongPress={() => setActiveItem(item)} + > + + + + + + + {item.name} + + + {item.brand?.trim() || renderCategoryName(item.category_id)} + + + setActiveItem(item)} + > + + + + + + + + {renderCategoryName(item.category_id)} + + + {renderItemTags(item)} + + + + + ), + [handleEdit, renderCategoryName, renderItemTags], + ); + + const renderFilterCategory = useCallback( + (item: ClothingCategory) => { + const selected = draftFilter.categoryId === item.id; + + return ( + + setDraftFilter((prev) => ({ + ...prev, + categoryId: prev.categoryId === item.id ? null : item.id, + })) + } + > + + {item.name} + + + ); + }, + [draftFilter.categoryId], + ); + + const renderFilterTag = useCallback( + (item: ClothingTag) => { + const selected = draftFilter.tagIds.includes(item.id); + + return ( + + setDraftFilter((prev) => ({ + ...prev, + tagIds: selected + ? prev.tagIds.filter((tagId) => tagId !== item.id) + : [...prev.tagIds, item.id], + })) + } + > + + {item.emoji ? `${item.emoji} ` : ""} + {item.name} + + + ); + }, + [draftFilter.tagIds], + ); + + const renderHeader = useCallback( + () => ( + + + + + + + + + + + + + + + + ), + [activeFilterCount, handleOpenFilter, handleRefresh, keyword], + ); + + const filterChanged = + filter.categoryId !== draftFilter.categoryId || + !sameNumberSet(filter.tagIds, draftFilter.tagIds); + + return ( + + String(item.id)} + renderItem={renderClothesCard} + numColumns={2} + columnWrapperStyle={{ gap: 12 }} + header={renderHeader()} + refreshing={refreshing} + onRefresh={handleRefresh} + showsVerticalScrollIndicator={false} + contentContainerStyle={[ + { paddingHorizontal: 16, paddingBottom: 120 }, + filteredClothes.length === 0 ? { flexGrow: 1 } : undefined, + ]} + ListEmptyComponent={() => ( + router.push("/clothes/edit" as any)}> + + 新增衣服 + + ) : undefined + } + /> + )} + /> + + + + + + + + 筛选衣服 + + + 重置 + + + + + + + setDraftFilter((prev) => ({ ...prev, categoryId: null }))} + > + + 全部 + + + {clothesCategories.map(renderFilterCategory)} + + + + + + {clothesTags.length > 0 ? ( + + {clothesTags.map(renderFilterTag)} + + ) : ( + 暂无标签 + )} + + + + + + + + + + + + { + if (!open) setActiveItem(null); + }} + > + + + + + activeItem && handleEdit(activeItem)}> + + 编辑 + + activeItem && handleRequestDelete(activeItem)}> + + 删除 + + + + + + + + + ); +} 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 new file mode 100644 index 00000000..14faab7a --- /dev/null +++ b/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx @@ -0,0 +1,755 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, Text, TouchableOpacity, View } from "react-native"; +import { Button, Card, Label, Tabs, TextField } from "heroui-native"; +import { + CheckIcon, + FolderIcon, + PencilIcon, + PlusIcon, + RotateCcwIcon, + SaveIcon, + TagIcon, + Trash2Icon, +} from "lucide-react-native"; +import { toast } from "sonner-native"; +import BottomSheet from "@/components/BottomSheet"; +import EmptyState from "@/components/EmptyStatus"; +import LayoutView from "@/components/layout/view"; +import { LoadingOverlay } from "@/components/LoadingOverlay"; +import { useListLoading, useLoading } from "@/hooks/useLoading"; +import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier"; +import { getUniqueIdApi } from "@/request/api"; +import { useClothingStore, useFamilyStore } from "@/store"; +import { + ClothingCategory, + ClothingStatus, + ClothingTag, + ClothingTagGroup, + ClothingTargetType, +} from "@/types/clothing"; + +type ManageMode = "category" | "tag"; +type MetadataForm = + | { + kind: "category"; + id?: number; + name: string; + type: ClothingTargetType; + sortNumber?: number; + } + | { + kind: "tagGroup"; + id?: number; + name: string; + type: ClothingTargetType; + sortNumber?: number; + } + | { + kind: "tag"; + id?: number; + name: string; + emoji: string; + tagGroupId: number | null; + type: ClothingTargetType; + sortNumber?: number; + }; + +function targetTypeLabel(type: ClothingTargetType) { + return type === ClothingTargetType.Clothes ? "衣服" : "搭配"; +} + +export default function ClothingMetadataManage() { + const familyId = useFamilyStore((s) => s.getFamilyId()); + const categories = useClothingStore((s) => s.categories); + const tagGroups = useClothingStore((s) => s.tagGroups); + const tags = useClothingStore((s) => s.tags); + const tagRelations = useClothingStore((s) => s.tagRelations); + const fetchCategories = useClothingStore((s) => s.fetchCategories); + const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups); + const fetchTags = useClothingStore((s) => s.fetchTags); + const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations); + const saveMetadataBundle = useClothingStore((s) => s.saveMetadataBundle); + const { loading, refreshing, runInitial, runRefresh } = useListLoading(); + const { isLoading, startLoading, stopLoading } = useLoading(); + const notifyModuleOperation = useModuleChatNotifier(); + + const [activeType, setActiveType] = useState(String(ClothingTargetType.Clothes)); + const [mode, setMode] = useState("category"); + const [form, setForm] = useState(null); + + const targetType = Number(activeType) as ClothingTargetType; + + const currentCategories = useMemo( + () => + categories + .filter((item) => item.type === targetType) + .sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), + [categories, targetType], + ); + + const currentTagGroups = useMemo( + () => + tagGroups + .filter((item) => item.type === targetType) + .sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), + [tagGroups, targetType], + ); + + const currentTags = useMemo( + () => + tags + .filter((item) => item.type === targetType) + .sort( + (a, b) => a.tag_group_id - b.tag_group_id || a.sort_number - b.sort_number || a.id - b.id, + ), + [tags, targetType], + ); + + const tagsByGroupId = useMemo(() => { + const map = new Map(); + currentTags.forEach((tag) => { + const groupTags = map.get(tag.tag_group_id) ?? []; + groupTags.push(tag); + map.set(tag.tag_group_id, groupTags); + }); + return map; + }, [currentTags]); + + const loadData = useCallback(async () => { + if (!familyId) return; + await Promise.all([ + fetchCategories(familyId), + fetchTagGroups(familyId), + fetchTags(familyId), + fetchTagRelations(familyId), + ]); + }, [familyId, fetchCategories, fetchTagGroups, fetchTagRelations, fetchTags]); + + useEffect(() => { + if (!familyId) return; + runInitial(loadData); + }, [familyId]); + + const handleRefresh = useCallback(() => { + runRefresh(loadData); + }, [loadData, runRefresh]); + + const closeForm = useCallback(() => { + setForm(null); + }, []); + + const openCreateCategory = useCallback(() => { + setForm({ + kind: "category", + name: "", + type: targetType, + }); + }, [targetType]); + + const openEditCategory = useCallback((item: ClothingCategory) => { + setForm({ + kind: "category", + id: item.id, + name: item.name, + type: item.type, + sortNumber: item.sort_number, + }); + }, []); + + const openCreateTagGroup = useCallback(() => { + setForm({ + kind: "tagGroup", + name: "", + type: targetType, + }); + }, [targetType]); + + const openEditTagGroup = useCallback((item: ClothingTagGroup) => { + setForm({ + kind: "tagGroup", + id: item.id, + name: item.name, + type: item.type, + sortNumber: item.sort_number, + }); + }, []); + + const openCreateTag = useCallback( + (tagGroupId?: number) => { + setForm({ + kind: "tag", + name: "", + emoji: "", + tagGroupId: tagGroupId ?? currentTagGroups[0]?.id ?? null, + type: targetType, + }); + }, + [currentTagGroups, targetType], + ); + + const openEditTag = useCallback((item: ClothingTag) => { + setForm({ + kind: "tag", + id: item.id, + name: item.name, + emoji: item.emoji ?? "", + tagGroupId: item.tag_group_id, + type: item.type, + sortNumber: item.sort_number, + }); + }, []); + + const handleSaveForm = useCallback(async () => { + if (!form || !familyId || isLoading) return; + + const name = form.name.trim(); + if (!name) { + toast.error("请输入名称"); + return; + } + if (form.kind === "tag" && !form.tagGroupId) { + toast.error("请选择标签组"); + return; + } + + startLoading("保存中..."); + try { + const id = form.id ?? (await getUniqueIdApi(1)).data?.[0]; + if (!id) { + stopLoading({ type: "error", message: "获取 ID 失败" }); + return; + } + + let ok = false; + if (form.kind === "category") { + const row = { + id, + family_id: familyId, + name, + type: form.type, + sort_number: + form.sortNumber ?? categories.filter((item) => item.type === form.type).length, + status: ClothingStatus.Enabled, + }; + ok = await saveMetadataBundle({ + categoryInserts: form.id ? [] : [row], + categoryUpdates: form.id ? [row] : [], + }); + } else if (form.kind === "tagGroup") { + const row = { + id, + family_id: familyId, + name, + type: form.type, + sort_number: + form.sortNumber ?? tagGroups.filter((item) => item.type === form.type).length, + status: ClothingStatus.Enabled, + }; + ok = await saveMetadataBundle({ + tagGroupInserts: form.id ? [] : [row], + tagGroupUpdates: form.id ? [row] : [], + }); + } else { + const row = { + id, + family_id: familyId, + tag_group_id: form.tagGroupId!, + name, + type: form.type, + emoji: form.emoji.trim(), + sort_number: + form.sortNumber ?? + tags.filter((item) => item.type === form.type && item.tag_group_id === form.tagGroupId) + .length, + status: ClothingStatus.Enabled, + }; + ok = await saveMetadataBundle({ + tagInserts: form.id ? [] : [row], + tagUpdates: form.id ? [row] : [], + }); + } + + if (!ok) { + stopLoading({ type: "error", message: "保存失败" }); + return; + } + + await loadData(); + closeForm(); + const typeLabel = targetTypeLabel(form.type); + const targetLabel = + form.kind === "category" + ? `${typeLabel}分类` + : form.kind === "tagGroup" + ? `${typeLabel}标签组` + : `${typeLabel}标签`; + notifyModuleOperation({ + module: "衣服搭配", + action: `${form.id ? "更新" : "新增"}${targetLabel}`, + target: name, + sourceTable: + form.kind === "category" + ? "b_clothing_category" + : form.kind === "tagGroup" + ? "b_clothing_tag_group" + : "b_clothing_tag", + sourceId: id, + }); + stopLoading({ type: "success", message: "已保存" }); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + categories, + closeForm, + familyId, + form, + isLoading, + loadData, + notifyModuleOperation, + saveMetadataBundle, + startLoading, + stopLoading, + tagGroups, + tags, + ]); + + const handleDeleteCategory = useCallback( + (item: ClothingCategory) => { + Alert.alert("删除分类", `确认删除「${item.name}」吗?已使用该分类的数据会显示为未分类。`, [ + { text: "取消", style: "cancel" }, + { + text: "删除", + style: "destructive", + onPress: async () => { + startLoading("删除中..."); + try { + const ok = await saveMetadataBundle({ + categoryUpdates: [{ id: item.id, status: ClothingStatus.Disabled }], + }); + if (!ok) { + stopLoading({ type: "error", message: "删除失败" }); + return; + } + await loadData(); + notifyModuleOperation({ + module: "衣服搭配", + action: `删除${targetTypeLabel(item.type)}分类`, + target: item.name, + sourceTable: "b_clothing_category", + sourceId: item.id, + }); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, + }, + ]); + }, + [loadData, notifyModuleOperation, saveMetadataBundle, startLoading, stopLoading], + ); + + const handleDeleteTag = useCallback( + (item: ClothingTag) => { + const relationDeletes = tagRelations + .filter((row) => row.tag_id === item.id) + .map((row) => ({ id: row.id })); + + Alert.alert("删除标签", `确认删除「${item.name}」吗?对应引用关系也会移除。`, [ + { text: "取消", style: "cancel" }, + { + text: "删除", + style: "destructive", + onPress: async () => { + startLoading("删除中..."); + try { + const ok = await saveMetadataBundle({ + tagUpdates: [{ id: item.id, status: ClothingStatus.Disabled }], + tagRelationDeletes: relationDeletes, + }); + if (!ok) { + stopLoading({ type: "error", message: "删除失败" }); + return; + } + await loadData(); + notifyModuleOperation({ + module: "衣服搭配", + action: `删除${targetTypeLabel(item.type)}标签`, + target: item.name, + sourceTable: "b_clothing_tag", + sourceId: item.id, + }); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, + }, + ]); + }, + [loadData, notifyModuleOperation, saveMetadataBundle, startLoading, stopLoading, tagRelations], + ); + + const handleDeleteTagGroup = useCallback( + (item: ClothingTagGroup) => { + const groupTags = tags.filter((tag) => tag.tag_group_id === item.id); + const groupTagIds = new Set(groupTags.map((tag) => tag.id)); + const relationDeletes = tagRelations + .filter((row) => groupTagIds.has(row.tag_id)) + .map((row) => ({ id: row.id })); + + Alert.alert("删除标签组", `确认删除「${item.name}」及其下 ${groupTags.length} 个标签吗?`, [ + { text: "取消", style: "cancel" }, + { + text: "删除", + style: "destructive", + onPress: async () => { + startLoading("删除中..."); + try { + const ok = await saveMetadataBundle({ + tagGroupUpdates: [{ id: item.id, status: ClothingStatus.Disabled }], + tagUpdates: groupTags.map((tag) => ({ + id: tag.id, + status: ClothingStatus.Disabled, + })), + tagRelationDeletes: relationDeletes, + }); + if (!ok) { + stopLoading({ type: "error", message: "删除失败" }); + return; + } + await loadData(); + notifyModuleOperation({ + module: "衣服搭配", + action: `删除${targetTypeLabel(item.type)}标签组`, + target: item.name, + detail: `${groupTags.length} 个标签`, + sourceTable: "b_clothing_tag_group", + sourceId: item.id, + }); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, + }, + ]); + }, + [ + loadData, + notifyModuleOperation, + saveMetadataBundle, + startLoading, + stopLoading, + tagRelations, + tags, + ], + ); + + const renderCategory = useCallback( + (item: ClothingCategory) => ( + + + + + + + + + {item.name} + + {targetTypeLabel(item.type)}分类 + + + + + + + ), + [handleDeleteCategory, openEditCategory], + ); + + const renderTagGroup = useCallback( + (group: ClothingTagGroup) => { + const groupTags = tagsByGroupId.get(group.id) ?? []; + + return ( + + + + + + + + + {group.name} + + + {targetTypeLabel(group.type)}标签组 · {groupTags.length} 个标签 + + + + + + + + {groupTags.length > 0 ? ( + + {groupTags.map((tag) => ( + openEditTag(tag)} + > + + {tag.emoji ? `${tag.emoji} ` : ""} + {tag.name} + + handleDeleteTag(tag)}> + + + + ))} + + ) : ( + 暂无标签 + )} + + + + ); + }, + [ + handleDeleteTag, + handleDeleteTagGroup, + openCreateTag, + openEditTag, + openEditTagGroup, + tagsByGroupId, + ], + ); + + const renderFormTitle = () => { + if (!form) return ""; + if (form.kind === "category") return form.id ? "编辑分类" : "新增分类"; + if (form.kind === "tagGroup") return form.id ? "编辑标签组" : "新增标签组"; + return form.id ? "编辑标签" : "新增标签"; + }; + + return ( + + + + + + + + 衣服 + + + 搭配 + + + + + setMode(value as ManageMode)} + variant="secondary" + > + + + + 分类 + + + 标签 + + + + + + {mode === "category" ? ( + + ) : ( + <> + + + + )} + + + + + {mode === "category" ? ( + currentCategories.length > 0 ? ( + currentCategories.map(renderCategory) + ) : ( + + ) + ) : currentTagGroups.length > 0 ? ( + currentTagGroups.map(renderTagGroup) + ) : ( + + )} + + + { + if (!open) closeForm(); + }} + > + + + + {form && ( + + {renderFormTitle()} + + + + setForm((prev) => (prev ? { ...prev, name } : prev))} + placeholder={form.kind === "tag" ? "例如:春秋" : "例如:季节"} + /> + + + {form.kind === "tag" && ( + <> + + + + setForm((prev) => + prev && prev.kind === "tag" ? { ...prev, emoji } : prev, + ) + } + placeholder="👕" + /> + + + + + + {currentTagGroups.map((group) => { + const selected = form.tagGroupId === group.id; + return ( + + setForm((prev) => + prev && prev.kind === "tag" + ? { ...prev, tagGroupId: group.id } + : prev, + ) + } + > + + {group.name} + + + ); + })} + + + + )} + + + + + {targetTypeLabel(form.type)} + + + + + + + + + )} + + + + + + + ); +} 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 new file mode 100644 index 00000000..ea940ce6 --- /dev/null +++ b/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { TouchableOpacity, View } from "react-native"; +import { router, useFocusEffect } from "expo-router"; +import { Button, Card } from "heroui-native"; +import { PencilIcon, PlusIcon, RotateCcwIcon, SparklesIcon } from "lucide-react-native"; +import LayoutView from "@/components/layout/view"; +import EmptyState from "@/components/EmptyStatus"; +import ClothingImage from "@/components/clothing/ClothingImage"; +import { useListLoading } from "@/hooks/useLoading"; +import { useClothingStore, useFamilyStore } from "@/store"; +import { ClothingOutfit } from "@/types/clothing"; + +export default function ClothingOutfitList() { + const familyId = useFamilyStore((s) => s.getFamilyId()); + const outfits = useClothingStore((s) => s.outfits); + const clothes = useClothingStore((s) => s.clothes); + const outfitClothes = useClothingStore((s) => s.outfitClothes); + const fetchOutfits = useClothingStore((s) => s.fetchOutfits); + const fetchClothes = useClothingStore((s) => s.fetchClothes); + const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes); + const { loading, refreshing, runInitial, runRefresh } = useListLoading(); + + const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]); + + const loadData = useCallback(async () => { + if (!familyId) return; + await Promise.all([ + fetchOutfits(familyId), + fetchClothes(familyId), + fetchOutfitClothes(familyId), + ]); + }, [familyId, fetchClothes, fetchOutfitClothes, fetchOutfits]); + + useEffect(() => { + if (!familyId) return; + runInitial(loadData); + }, [familyId]); + + useFocusEffect( + useCallback(() => { + if (!familyId) return; + loadData(); + }, [familyId, loadData]), + ); + + const handleRefresh = useCallback(() => { + runRefresh(loadData); + }, [loadData, runRefresh]); + + const getCoverKey = useCallback( + (item: ClothingOutfit) => { + if (item.cover_file_key) return item.cover_file_key; + const first = outfitClothes + .filter((row) => row.outfit_id === item.id) + .sort((a, b) => a.z_index - b.z_index || a.sort_number - b.sort_number)[0]; + return first ? clothesById.get(first.clothes_id)?.image_file_key : ""; + }, + [clothesById, outfitClothes], + ); + + const getClothesCount = useCallback( + (item: ClothingOutfit) => outfitClothes.filter((row) => row.outfit_id === item.id).length, + [outfitClothes], + ); + + const renderHeader = useCallback( + () => ( + + + + + + + ), + [handleRefresh], + ); + + const renderItem = useCallback( + ({ item }: { item: ClothingOutfit }) => ( + router.push(`/clothes/outfit/detail/${item.id}` as any)} + > + + + + + + + {item.name} + + {getClothesCount(item)} 件衣服 + + router.push(`/clothes/outfit/edit?id=${item.id}` as any)} + > + + + + + + + ), + [getClothesCount, getCoverKey], + ); + + return ( + + String(item.id)} + renderItem={renderItem} + numColumns={2} + columnWrapperStyle={{ gap: 12 }} + header={renderHeader()} + refreshing={refreshing} + onRefresh={handleRefresh} + showsVerticalScrollIndicator={false} + contentContainerStyle={[ + { paddingHorizontal: 16, paddingBottom: 120 }, + outfits.length === 0 ? { flexGrow: 1 } : undefined, + ]} + ListEmptyComponent={() => ( + router.push("/clothes/outfit/edit" as any)}> + + 新增搭配 + + ) : undefined + } + /> + )} + /> + + ); +} diff --git a/code/app/app-rn/src/app/clothes/edit/index.tsx b/code/app/app-rn/src/app/clothes/edit/index.tsx new file mode 100644 index 00000000..2f13e915 --- /dev/null +++ b/code/app/app-rn/src/app/clothes/edit/index.tsx @@ -0,0 +1,756 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import ImagePicker from "react-native-image-crop-picker"; +import { Button, Card, Input, Label, TextArea, TextField } from "heroui-native"; +import { + CheckIcon, + ImageIcon, + PlusIcon, + SaveIcon, + ShirtIcon, + TagIcon, + XIcon, +} from "lucide-react-native"; +import { toast } from "sonner-native"; +import PageLayout from "@/layouts/PageLayout"; +import LayoutView from "@/components/layout/view"; +import ClothingImage from "@/components/clothing/ClothingImage"; +import { LoadingOverlay } from "@/components/LoadingOverlay"; +import { useLoading } from "@/hooks/useLoading"; +import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier"; +import { getUniqueIdApi } from "@/request/api"; +import { useClothingStore, useFamilyStore } from "@/store"; +import { + ClothingCategory, + ClothingClothes, + ClothingStatus, + ClothingTag, + ClothingTagGroup, + ClothingTargetType, +} from "@/types/clothing"; +import { deleteS3FileQuietly } from "@/utils/file"; + +interface ClothesFormState { + name: string; + imageFileKey: string; + categoryId: number | null; + brand: string; + remark: string; + sortNumber?: number; +} + +const emptyForm: ClothesFormState = { + name: "", + imageFileKey: "", + categoryId: null, + brand: "", + remark: "", +}; + +function createEditForm(item: ClothingClothes): ClothesFormState { + return { + name: item.name, + imageFileKey: item.image_file_key, + categoryId: item.category_id ?? null, + brand: item.brand ?? "", + remark: item.remark ?? "", + sortNumber: item.sort_number, + }; +} + +function toggleId(list: number[], id: number) { + return list.includes(id) ? list.filter((item) => item !== id) : [...list, id]; +} + +export default function ClothingEdit() { + const router = useRouter(); + const { id } = useLocalSearchParams<{ id?: string }>(); + const editId = id ? Number(id) : 0; + const isEdit = editId > 0; + const familyId = useFamilyStore((s) => s.getFamilyId()); + const clothes = useClothingStore((s) => s.clothes); + const categories = useClothingStore((s) => s.categories); + const tagGroups = useClothingStore((s) => s.tagGroups); + const tags = useClothingStore((s) => s.tags); + const tagRelations = useClothingStore((s) => s.tagRelations); + const fetchClothes = useClothingStore((s) => s.fetchClothes); + const fetchCategories = useClothingStore((s) => s.fetchCategories); + const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups); + const fetchTags = useClothingStore((s) => s.fetchTags); + const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations); + const saveCategoryChanges = useClothingStore((s) => s.saveCategoryChanges); + const saveTagGroupChanges = useClothingStore((s) => s.saveTagGroupChanges); + const saveTagChanges = useClothingStore((s) => s.saveTagChanges); + const saveClothesBundle = useClothingStore((s) => s.saveClothesBundle); + const uploadClothesImage = useClothingStore((s) => s.uploadClothesImage); + const { isLoading, startLoading, stopLoading } = useLoading(); + const notifyModuleOperation = useModuleChatNotifier(); + + const [form, setForm] = useState(emptyForm); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const [newCategoryName, setNewCategoryName] = useState(""); + const [newTagGroupName, setNewTagGroupName] = useState(""); + const [newTagName, setNewTagName] = useState(""); + const [newTagEmoji, setNewTagEmoji] = useState(""); + const [uploading, setUploading] = useState(false); + const [pageLoading, setPageLoading] = useState(true); + + const clothesCategories = useMemo( + () => categories.filter((item) => item.type === ClothingTargetType.Clothes), + [categories], + ); + + const clothesTagGroups = useMemo( + () => tagGroups.filter((item) => item.type === ClothingTargetType.Clothes), + [tagGroups], + ); + + const clothesTags = useMemo( + () => tags.filter((item) => item.type === ClothingTargetType.Clothes), + [tags], + ); + + const tagsByGroupId = useMemo(() => { + const map = new Map(); + clothesTags.forEach((tag) => { + const groupTags = map.get(tag.tag_group_id) ?? []; + groupTags.push(tag); + map.set(tag.tag_group_id, groupTags); + }); + return map; + }, [clothesTags]); + + const currentItem = useMemo( + () => clothes.find((item) => item.id === editId) ?? null, + [clothes, editId], + ); + + const loadBaseData = useCallback(async () => { + if (!familyId) return; + + setPageLoading(true); + try { + await Promise.all([ + fetchClothes(familyId), + fetchCategories(familyId, ClothingTargetType.Clothes), + fetchTagGroups(familyId, ClothingTargetType.Clothes), + fetchTags(familyId, ClothingTargetType.Clothes), + fetchTagRelations(familyId, ClothingTargetType.Clothes, isEdit ? editId : undefined), + ]); + + if (isEdit) { + const state = useClothingStore.getState(); + const item = state.clothes.find((row) => row.id === editId); + if (!item) { + toast.error("衣服不存在"); + router.back(); + return; + } + setForm(createEditForm(item)); + setSelectedTagIds( + state.tagRelations + .filter( + (row) => row.target_type === ClothingTargetType.Clothes && row.target_id === editId, + ) + .map((row) => row.tag_id), + ); + } + } finally { + setPageLoading(false); + } + }, [ + editId, + familyId, + fetchCategories, + fetchClothes, + fetchTagGroups, + fetchTagRelations, + fetchTags, + isEdit, + router, + ]); + + useEffect(() => { + loadBaseData(); + }, [loadBaseData]); + + const handlePickImage = useCallback(async () => { + if (!familyId || uploading) return; + + let toastId: string | number | undefined; + try { + setUploading(true); + toastId = toast.loading("图片上传中"); + const img = await ImagePicker.openPicker({ + cropping: true, + width: 800, + height: 800, + compressImageQuality: 0.86, + mediaType: "photo", + }); + + const file = { + uri: img.path, + name: img.filename || `clothes_${Date.now()}.jpg`, + type: img.mime || "image/jpeg", + }; + const key = await uploadClothesImage(familyId, file); + + if (!key) { + toast.error("图片上传失败", { id: toastId }); + return; + } + + setForm((prev) => ({ ...prev, imageFileKey: key })); + toast.success("图片已上传", { id: toastId }); + } catch (error) { + const code = typeof error === "object" && error && "code" in error ? error.code : ""; + if (code === "E_PICKER_CANCELLED") { + if (toastId) toast.dismiss(toastId); + } else if (toastId) { + toast.error("图片选择失败", { id: toastId }); + } + } finally { + setUploading(false); + } + }, [familyId, uploadClothesImage, uploading]); + + const handleCreateCategory = useCallback(async () => { + const name = newCategoryName.trim(); + if (!familyId || !name) { + toast.error("请输入分类名称"); + return; + } + + const existing = clothesCategories.find((item) => item.name === name); + if (existing) { + setForm((prev) => ({ ...prev, categoryId: existing.id })); + setNewCategoryName(""); + return; + } + + const id = (await getUniqueIdApi(1)).data?.[0]; + if (!id) { + toast.error("获取分类 ID 失败"); + return; + } + + const ok = await saveCategoryChanges({ + inserts: [ + { + id, + family_id: familyId, + name, + type: ClothingTargetType.Clothes, + sort_number: clothesCategories.length, + status: ClothingStatus.Enabled, + }, + ], + }); + + if (!ok) { + toast.error("分类创建失败"); + return; + } + + await fetchCategories(familyId, ClothingTargetType.Clothes); + setForm((prev) => ({ ...prev, categoryId: id })); + setNewCategoryName(""); + notifyModuleOperation({ + module: "衣服搭配", + action: "新增衣服分类", + target: name, + sourceTable: "b_clothing_category", + sourceId: id, + }); + toast.success("分类已创建"); + }, [ + clothesCategories, + familyId, + fetchCategories, + newCategoryName, + notifyModuleOperation, + saveCategoryChanges, + ]); + + const handleCreateTag = useCallback(async () => { + const groupName = newTagGroupName.trim(); + const tagName = newTagName.trim(); + const emoji = newTagEmoji.trim(); + + if (!familyId || !groupName || !tagName) { + toast.error("请输入标签组和标签名称"); + return; + } + + let group = clothesTagGroups.find((item) => item.name === groupName); + if (!group) { + const groupId = (await getUniqueIdApi(1)).data?.[0]; + if (!groupId) { + toast.error("获取标签组 ID 失败"); + return; + } + + const ok = await saveTagGroupChanges({ + inserts: [ + { + id: groupId, + family_id: familyId, + name: groupName, + type: ClothingTargetType.Clothes, + sort_number: clothesTagGroups.length, + status: ClothingStatus.Enabled, + }, + ], + }); + + if (!ok) { + toast.error("标签组创建失败"); + return; + } + + group = { + id: groupId, + family_id: familyId, + name: groupName, + type: ClothingTargetType.Clothes, + sort_number: clothesTagGroups.length, + status: ClothingStatus.Enabled, + }; + } + + const existingTag = clothesTags.find( + (item) => item.tag_group_id === group?.id && item.name === tagName, + ); + if (existingTag) { + setSelectedTagIds((prev) => + prev.includes(existingTag.id) ? prev : [...prev, existingTag.id], + ); + setNewTagName(""); + setNewTagEmoji(""); + await Promise.all([ + fetchTagGroups(familyId, ClothingTargetType.Clothes), + fetchTags(familyId, ClothingTargetType.Clothes), + ]); + return; + } + + const tagId = (await getUniqueIdApi(1)).data?.[0]; + if (!tagId) { + toast.error("获取标签 ID 失败"); + return; + } + + const groupTagCount = clothesTags.filter((item) => item.tag_group_id === group?.id).length; + const ok = await saveTagChanges({ + inserts: [ + { + id: tagId, + family_id: familyId, + tag_group_id: group.id, + name: tagName, + type: ClothingTargetType.Clothes, + emoji, + sort_number: groupTagCount, + status: ClothingStatus.Enabled, + }, + ], + }); + + if (!ok) { + toast.error("标签创建失败"); + return; + } + + await Promise.all([ + fetchTagGroups(familyId, ClothingTargetType.Clothes), + fetchTags(familyId, ClothingTargetType.Clothes), + ]); + setSelectedTagIds((prev) => [...prev, tagId]); + setNewTagName(""); + setNewTagEmoji(""); + notifyModuleOperation({ + module: "衣服搭配", + action: "新增衣服标签", + target: tagName, + detail: group.name, + sourceTable: "b_clothing_tag", + sourceId: tagId, + }); + toast.success("标签已创建"); + }, [ + clothesTagGroups, + clothesTags, + familyId, + fetchTagGroups, + fetchTags, + newTagEmoji, + newTagGroupName, + newTagName, + notifyModuleOperation, + saveTagChanges, + saveTagGroupChanges, + ]); + + const handleSave = useCallback(async () => { + const name = form.name.trim(); + if (!name) { + toast.error("请输入衣服名称"); + return; + } + if (!form.imageFileKey) { + toast.error("请上传衣服图片"); + return; + } + if (!familyId || isLoading) return; + + startLoading("保存中..."); + try { + const targetId = isEdit ? editId : (await getUniqueIdApi(1)).data?.[0]; + if (!targetId) { + stopLoading({ type: "error", message: "获取衣服 ID 失败" }); + return; + } + + const currentRelations = tagRelations.filter( + (row) => row.target_type === ClothingTargetType.Clothes && row.target_id === targetId, + ); + const currentTagIds = new Set(currentRelations.map((row) => row.tag_id)); + const relationDeletes = currentRelations + .filter((row) => !selectedTagIds.includes(row.tag_id)) + .map((row) => ({ id: row.id })); + const newRelationTagIds = selectedTagIds.filter((tagId) => !currentTagIds.has(tagId)); + const relationIds = newRelationTagIds.length + ? ((await getUniqueIdApi(newRelationTagIds.length)).data ?? []) + : []; + + if (relationIds.length !== newRelationTagIds.length) { + stopLoading({ type: "error", message: "获取标签关联 ID 失败" }); + return; + } + + const row = { + id: targetId, + family_id: familyId, + name, + image_file_key: form.imageFileKey, + category_id: form.categoryId, + brand: form.brand.trim(), + remark: form.remark.trim(), + sort_number: form.sortNumber ?? clothes.length, + status: ClothingStatus.Enabled, + }; + const ok = await saveClothesBundle({ + clothesInserts: isEdit ? [] : [row], + clothesUpdates: isEdit ? [row] : [], + tagRelationInserts: newRelationTagIds.map((tagId, index) => ({ + id: relationIds[index], + family_id: familyId, + target_type: ClothingTargetType.Clothes, + target_id: targetId, + tag_id: tagId, + })), + tagRelationDeletes: relationDeletes, + }); + + if (!ok) { + stopLoading({ type: "error", message: "保存失败" }); + return; + } + + if ( + isEdit && + currentItem?.image_file_key && + currentItem.image_file_key !== form.imageFileKey + ) { + deleteS3FileQuietly(currentItem.image_file_key); + } + await Promise.all([fetchClothes(familyId), fetchTagRelations(familyId)]); + notifyModuleOperation({ + module: "衣服搭配", + action: isEdit ? "更新衣服" : "新增衣服", + target: name, + sourceTable: "b_clothing_clothes", + sourceId: targetId, + }); + stopLoading({ type: "success", message: isEdit ? "衣服已保存" : "衣服已新增" }); + router.back(); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + clothes.length, + currentItem, + editId, + familyId, + fetchClothes, + fetchTagRelations, + form, + isEdit, + isLoading, + notifyModuleOperation, + router, + saveClothesBundle, + selectedTagIds, + startLoading, + stopLoading, + tagRelations, + ]); + + const renderCategoryChip = useCallback( + (item: ClothingCategory) => { + const selected = form.categoryId === item.id; + + return ( + + setForm((prev) => ({ + ...prev, + categoryId: prev.categoryId === item.id ? null : item.id, + })) + } + > + + {item.name} + + + ); + }, + [form.categoryId], + ); + + const renderTagChip = useCallback( + (item: ClothingTag) => { + const selected = selectedTagIds.includes(item.id); + + return ( + setSelectedTagIds((prev) => toggleId(prev, item.id))} + > + + {item.emoji ? `${item.emoji} ` : ""} + {item.name} + + + ); + }, + [selectedTagIds], + ); + + const renderTagGroup = useCallback( + (group: ClothingTagGroup) => { + const groupTags = tagsByGroupId.get(group.id) ?? []; + if (groupTags.length === 0) return null; + + return ( + + {group.name} + {groupTags.map(renderTagChip)} + + ); + }, + [renderTagChip, tagsByGroupId], + ); + + return ( + + + + + + + + {form.imageFileKey ? ( + + ) : ( + + + 上传衣服图片 + + )} + + 图片必填,上传时会裁剪为统一方图。 + + + + + + + + 基础信息 + + + + + + setForm((prev) => ({ ...prev, name }))} + placeholder="例如:白色衬衫" + /> + + + + + setForm((prev) => ({ ...prev, brand }))} + placeholder="可选" + /> + + + + +