diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index 7e4d7651..cecd2a09 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -13,12 +13,12 @@ "state": { "type": "markdown", "state": { - "file": "code/fms/REQUIREMENTS.md", + "file": "code/fms/重构实施计划.md", "mode": "source", "source": false }, "icon": "lucide-file", - "title": "REQUIREMENTS" + "title": "重构实施计划" } } ] @@ -156,13 +156,13 @@ "state": { "type": "outline", "state": { - "file": "code/fms/REQUIREMENTS.md", + "file": "code/fms/重构实施计划.md", "followCursor": false, "showSearch": false, "searchQuery": "" }, "icon": "lucide-list", - "title": "REQUIREMENTS 的大纲" + "title": "重构实施计划 的大纲" } } ], @@ -185,8 +185,30 @@ }, "active": "8ff629886042c2ed", "lastOpenFiles": [ - "code/fms/REQUIREMENTS.md", - "code/fms/fms-vue/package.json.4230026273", - "code/fms/fms-vue/pnpm-lock.yaml.1103361394" + "code/app/app-rn/android/build/reports/problems/problems-report.html", + "code/app/app-rn/android/build/reports/problems", + "code/app/app-rn/android/build/reports", + "code/app/app-rn/android/app/build/intermediates/apk_ide_redirect_file/release/createReleaseApkListingFileRedirect/redirect.txt", + "code/app/app-rn/android/app/build/intermediates/apk_ide_redirect_file/release/createReleaseApkListingFileRedirect", + "code/app/app-rn/android/app/build/intermediates/apk_ide_redirect_file/release", + "code/app/app-rn/android/app/build/intermediates/apk_ide_redirect_file", + "code/app/app-rn/android/app/build/outputs/apk/release/output-metadata.json", + "code/app/app-rn/android/app/build/outputs/apk/release/baselineProfiles/1/app-release.dm", + "code/app/app-rn/android/app/build/outputs/apk/release/baselineProfiles/0/app-release.dm", + "code/app/app-rn/android/app/build/outputs/apk/release/baselineProfiles/1", + "code/app/app-rn/android/app/build/intermediates/packaged_res/release/packageReleaseResources/mipmap-xxxhdpi-v4/ic_launcher_round.webp", + "code/app/app-rn/android/app/build/intermediates/packaged_res/release/packageReleaseResources/mipmap-xxxhdpi-v4/ic_launcher_monochrome.webp", + "code/app/app-rn/android/app/build/intermediates/packaged_res/release/packageReleaseResources/mipmap-xxxhdpi-v4/ic_launcher_foreground.webp", + "code/app/app-rn/android/app/build/intermediates/packaged_res/release/packageReleaseResources/mipmap-xxxhdpi-v4/ic_launcher_background.webp", + "code/app/app-rn/android/app/build/intermediates/packaged_res/release/packageReleaseResources/mipmap-xxxhdpi-v4/ic_launcher.webp", + "code/app/app-rn/android/app/build/intermediates/packaged_res/release/packageReleaseResources/mipmap-xxhdpi-v4/ic_launcher_round.webp", + "code/app/app-rn/android/app/build/intermediates/packaged_res/release/packageReleaseResources/mipmap-xxhdpi-v4/ic_launcher_monochrome.webp", + "code/app/app-rn/android/app/build/intermediates/packaged_res/release/packageReleaseResources/mipmap-xxhdpi-v4/ic_launcher_foreground.webp", + "code/app/app-rn/android/app/build/intermediates/packaged_res/release/packageReleaseResources/mipmap-xxhdpi-v4/ic_launcher_background.webp", + "code/app/app-rn/android/app/build/intermediates/packaged_res/release/packageReleaseResources/mipmap-xxhdpi-v4/ic_launcher.webp", + "code/fms/docs/refactor/功能树与模块配置重构计划.md", + "code/fms/docs/refactor/阶段一模块管理.md", + "code/fms/软件需求.md", + "code/fms/REQUIREMENTS.md" ] } \ No newline at end of file diff --git a/code/app/app-go/app-go b/code/app/app-go/app-go new file mode 100644 index 00000000..33e859d9 Binary files /dev/null and b/code/app/app-go/app-go differ diff --git a/code/app/app-go/build-linux.bat b/code/app/app-go/build-linux.bat new file mode 100644 index 00000000..f2050c0b --- /dev/null +++ b/code/app/app-go/build-linux.bat @@ -0,0 +1,5 @@ +@echo off +set GOOS=linux +set GOARCH=amd64 +go build -o app-go .\cmd\app +echo Build done: app-go diff --git a/code/app/app-go/configs/config.dev.yaml b/code/app/app-go/configs/config.dev.yaml index 80e82aab..71a86ee6 100644 --- a/code/app/app-go/configs/config.dev.yaml +++ b/code/app/app-go/configs/config.dev.yaml @@ -2,11 +2,11 @@ app: port: 8087 base_url: "" postgres: - host: 117.72.182.135 + host: 118.89.70.199 port: 5432 user: postgres password: zhang520.. - dbname: allapp + dbname: app-dev timezone: Asia/Shanghai max_open_conns: 10 max_idle_conns: 5 @@ -14,7 +14,7 @@ postgres: ssl_mode: false aws_s3: region: us-east-1 - access_key: 2c0sZEpPQyCVhoLGNzHS - secret_key: fRbcQPEhU8B0C3On4qIoJ19w5vXxTFD26LlAiae7 + access_key: fUkOMAzaBW7HV5wq4BSQ + secret_key: sCIWI5e4c5gHNGqrNCsTjvIkcWK1W8EXD7feUdJR endpoint: http://118.89.70.199:9000 - bucket: test \ No newline at end of file + bucket: app-dev \ No newline at end of file diff --git a/code/app/app-go/configs/config.prod.yaml b/code/app/app-go/configs/config.prod.yaml index e69de29b..973f381a 100644 --- a/code/app/app-go/configs/config.prod.yaml +++ b/code/app/app-go/configs/config.prod.yaml @@ -0,0 +1,20 @@ +app: + port: 8080 + base_url: /app +postgres: + host: 118.89.70.199 + port: 5432 + user: postgres + password: zhang520.. + dbname: app-prod + timezone: Asia/Shanghai + max_open_conns: 20 + max_idle_conns: 10 + conn_max_lifetime: 30m + ssl_mode: false +aws_s3: + region: us-east-1 + access_key: fUkOMAzaBW7HV5wq4BSQ + secret_key: sCIWI5e4c5gHNGqrNCsTjvIkcWK1W8EXD7feUdJR + endpoint: http://118.89.70.199:9000 + bucket: app-prod diff --git a/code/app/app-go/configs/config.yaml b/code/app/app-go/configs/config.yaml index a1f67426..c8227b7b 100644 --- a/code/app/app-go/configs/config.yaml +++ b/code/app/app-go/configs/config.yaml @@ -1,6 +1,5 @@ app: - name: allapp - port: 8080 + name: 窝织 base_url: /app wechat: app_id: wxdab3e21a1f7e392f diff --git a/code/app/app-go/internal/config/loader.go b/code/app/app-go/internal/config/loader.go index d29d79bd..2e1b6ff7 100644 --- a/code/app/app-go/internal/config/loader.go +++ b/code/app/app-go/internal/config/loader.go @@ -14,10 +14,8 @@ func Load() (*Config, error) { v := viper.New() v.SetConfigType("yaml") - // ✅ 默认值 setDefaults(v) - // ✅ 支持环境变量(如 APP_PORT → app.port) v.AutomaticEnv() v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) @@ -34,7 +32,6 @@ func Load() (*Config, error) { v.SetConfigName(envConfigName) if err := v.MergeInConfig(); err != nil { - // dev 可以没有,prod 必须有 if env == "prod" { return nil, fmt.Errorf("读取环境配置失败(%s): %w", env, err) } @@ -48,13 +45,10 @@ func Load() (*Config, error) { return &cfg, nil } -// ✅ 默认值统一在这里管理 func setDefaults(v *viper.Viper) { - v.SetDefault("app.name", "allapp-go") + v.SetDefault("app.name", "app") v.SetDefault("app.port", 8080) v.SetDefault("app.base_url", "app") - - // 可以继续扩展 v.SetDefault("unique_id.datacenter_id", 1) v.SetDefault("unique_id.worker_id", 1) } diff --git a/code/app/app-go/internal/handle/s3.go b/code/app/app-go/internal/handle/s3.go index 55ee2b65..98d1e137 100644 --- a/code/app/app-go/internal/handle/s3.go +++ b/code/app/app-go/internal/handle/s3.go @@ -29,7 +29,7 @@ func S3UploadFile(c fiber.Ctx) error { } now := time.Now() - folderPath, err := uploadFolderPath(c.FormValue("upload_type"), c.FormValue("family_id"), now) + folderPath, err := uploadFolderPath(c.FormValue("upload_type"), c.FormValue("family_id"), c.FormValue("upload_module"), now) if err != nil { return httpx.Fail(c, err.Error()) } @@ -79,7 +79,7 @@ func S3CopyFile(c fiber.Ctx) error { return httpx.OK(c, []string{}) } - folderPath := familyDateFolder(req.FamilyID, time.Now()) + folderPath := familyDateFolder(req.Module, req.FamilyID, time.Now()) result := make([]string, 0, len(req.Keys)) for _, sourceKey := range req.Keys { @@ -128,9 +128,10 @@ func parseFamilyID(value string) (int64, error) { return familyID, nil } -func familyDateFolder(familyID int64, now time.Time) string { - return fmt.Sprintf("%d/%d/%02d/%02d", +func familyDateFolder(module string, familyID int64, now time.Time) string { + return fmt.Sprintf("business/%d/%s/%d/%02d/%02d", familyID, + module, now.Year(), now.Month(), now.Day(), @@ -145,7 +146,7 @@ func avatarDateFolder(now time.Time) string { ) } -func uploadFolderPath(uploadType string, familyIDValue string, now time.Time) (string, error) { +func uploadFolderPath(uploadType string, familyIDValue string, module string, now time.Time) (string, error) { if uploadType == "avatar" { return avatarDateFolder(now), nil } @@ -155,5 +156,9 @@ func uploadFolderPath(uploadType string, familyIDValue string, now time.Time) (s return "", fmt.Errorf("family_id不能为空") } - return familyDateFolder(familyID, now), nil + if module == "" { + return "", fmt.Errorf("module不能为空") + } + + return familyDateFolder(module, familyID, now), nil } diff --git a/code/app/app-go/internal/types/s3.go b/code/app/app-go/internal/types/s3.go index 73cc00e1..09e66d8d 100644 --- a/code/app/app-go/internal/types/s3.go +++ b/code/app/app-go/internal/types/s3.go @@ -5,6 +5,7 @@ type S3DeleteReq struct { } type S3CopyReq struct { + Module string `json:"module" validate:"required"` FamilyID int64 `json:"family_id" validate:"required,gt=0"` Keys []string `json:"keys" validate:"required"` } diff --git a/code/app/app-rn/.env b/code/app/app-rn/.env index 7c8acd2f..cd53a476 100644 --- a/code/app/app-rn/.env +++ b/code/app/app-rn/.env @@ -1,3 +1,4 @@ EXPO_PUBLIC_API_URL=https://dev.api.com +EXPO_PUBLIC_WS_URL=wss://dev.api.com EXPO_PUBLIC_G3_URL=http://118.89.70.199:9001 -EXPO_PUBLIC_G3_BUCKET=test +EXPO_PUBLIC_G3_BUCKET=app-prod diff --git a/code/app/app-rn/.env.development b/code/app/app-rn/.env.development index d2bcf72b..d26d0f76 100644 --- a/code/app/app-rn/.env.development +++ b/code/app/app-rn/.env.development @@ -1,3 +1,5 @@ EXPO_PUBLIC_API_URL=http://192.168.5.80:8087 +# EXPO_PUBLIC_API_URL=https://www.oneao.cn/app +EXPO_PUBLIC_WS_URL=ws://192.168.5.80:8087 EXPO_PUBLIC_G3_URL=http://118.89.70.199:9001 -EXPO_PUBLIC_G3_BUCKET=test \ No newline at end of file +EXPO_PUBLIC_G3_BUCKET=app-dev \ No newline at end of file diff --git a/code/app/app-rn/.env.production b/code/app/app-rn/.env.production index b8c3c1e9..ee9e9f0d 100644 --- a/code/app/app-rn/.env.production +++ b/code/app/app-rn/.env.production @@ -1 +1,4 @@ -EXPO_PUBLIC_API_URL=http://192.168.5.23:8087 \ No newline at end of file +EXPO_PUBLIC_API_URL=https://www.oneao.cn/app +EXPO_PUBLIC_WS_URL=wss://www.oneao.cn/app +EXPO_PUBLIC_G3_URL=http://118.89.70.199:9001 +EXPO_PUBLIC_G3_BUCKET=app-prod \ No newline at end of file diff --git a/code/app/app-rn/app.json b/code/app/app-rn/app.json index 957a6851..6d9c615c 100644 --- a/code/app/app-rn/app.json +++ b/code/app/app-rn/app.json @@ -1,21 +1,17 @@ { "expo": { - "name": "app-rn", - "slug": "app-rn", + "name": "窝织", + "slug": "wozhi", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", "userInterfaceStyle": "light", - "scheme": [ - "oneao", - "tencent102826474", - "wxdab3e21a1f7e392f" - ], + "scheme": ["oneao", "tencent102826474", "wxdab3e21a1f7e392f"], "extra": { "linkingScheme": "oneao" }, "ios": { - "icon": "./assets/expo.icon" + "icon": "./assets/images/icon.png" }, "android": { "adaptiveIcon": { @@ -38,10 +34,10 @@ [ "expo-splash-screen", { - "backgroundColor": "#15803D", + "backgroundColor": "#FFFFFF", "android": { "image": "./assets/images/splash-icon.png", - "imageWidth": 76 + "imageWidth": 120 } } ], diff --git a/code/app/app-rn/assets/images/android-icon-background.png b/code/app/app-rn/assets/images/android-icon-background.png index 5ffefc5b..d57a8b66 100644 Binary files a/code/app/app-rn/assets/images/android-icon-background.png and b/code/app/app-rn/assets/images/android-icon-background.png differ diff --git a/code/app/app-rn/assets/images/android-icon-background_hdpi.png b/code/app/app-rn/assets/images/android-icon-background_hdpi.png new file mode 100644 index 00000000..a3f8f6cc Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-background_hdpi.png differ diff --git a/code/app/app-rn/assets/images/android-icon-background_mdpi.png b/code/app/app-rn/assets/images/android-icon-background_mdpi.png new file mode 100644 index 00000000..d847c437 Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-background_mdpi.png differ diff --git a/code/app/app-rn/assets/images/android-icon-background_xhdpi.png b/code/app/app-rn/assets/images/android-icon-background_xhdpi.png new file mode 100644 index 00000000..d1abd6b5 Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-background_xhdpi.png differ diff --git a/code/app/app-rn/assets/images/android-icon-background_xxhdpi.png b/code/app/app-rn/assets/images/android-icon-background_xxhdpi.png new file mode 100644 index 00000000..b9845b90 Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-background_xxhdpi.png differ diff --git a/code/app/app-rn/assets/images/android-icon-foreground.png b/code/app/app-rn/assets/images/android-icon-foreground.png index 3a9e5016..d050cd94 100644 Binary files a/code/app/app-rn/assets/images/android-icon-foreground.png and b/code/app/app-rn/assets/images/android-icon-foreground.png differ diff --git a/code/app/app-rn/assets/images/android-icon-foreground_hdpi.png b/code/app/app-rn/assets/images/android-icon-foreground_hdpi.png new file mode 100644 index 00000000..c2c6ea02 Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-foreground_hdpi.png differ diff --git a/code/app/app-rn/assets/images/android-icon-foreground_mdpi.png b/code/app/app-rn/assets/images/android-icon-foreground_mdpi.png new file mode 100644 index 00000000..9d8059de Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-foreground_mdpi.png differ diff --git a/code/app/app-rn/assets/images/android-icon-foreground_xhdpi.png b/code/app/app-rn/assets/images/android-icon-foreground_xhdpi.png new file mode 100644 index 00000000..1a37749f Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-foreground_xhdpi.png differ diff --git a/code/app/app-rn/assets/images/android-icon-foreground_xxhdpi.png b/code/app/app-rn/assets/images/android-icon-foreground_xxhdpi.png new file mode 100644 index 00000000..71556948 Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-foreground_xxhdpi.png differ diff --git a/code/app/app-rn/assets/images/android-icon-monochrome.png b/code/app/app-rn/assets/images/android-icon-monochrome.png index 77484ebd..e001238b 100644 Binary files a/code/app/app-rn/assets/images/android-icon-monochrome.png and b/code/app/app-rn/assets/images/android-icon-monochrome.png differ diff --git a/code/app/app-rn/assets/images/android-icon-monochrome_hdpi.png b/code/app/app-rn/assets/images/android-icon-monochrome_hdpi.png new file mode 100644 index 00000000..515edfe4 Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-monochrome_hdpi.png differ diff --git a/code/app/app-rn/assets/images/android-icon-monochrome_mdpi.png b/code/app/app-rn/assets/images/android-icon-monochrome_mdpi.png new file mode 100644 index 00000000..573ef497 Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-monochrome_mdpi.png differ diff --git a/code/app/app-rn/assets/images/android-icon-monochrome_xhdpi.png b/code/app/app-rn/assets/images/android-icon-monochrome_xhdpi.png new file mode 100644 index 00000000..8d855fd2 Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-monochrome_xhdpi.png differ diff --git a/code/app/app-rn/assets/images/android-icon-monochrome_xxhdpi.png b/code/app/app-rn/assets/images/android-icon-monochrome_xxhdpi.png new file mode 100644 index 00000000..a29f3676 Binary files /dev/null and b/code/app/app-rn/assets/images/android-icon-monochrome_xxhdpi.png differ diff --git a/code/app/app-rn/assets/images/favicon.png b/code/app/app-rn/assets/images/favicon.png index 408bd746..eaf5dbd1 100644 Binary files a/code/app/app-rn/assets/images/favicon.png and b/code/app/app-rn/assets/images/favicon.png differ diff --git a/code/app/app-rn/assets/images/icon.png b/code/app/app-rn/assets/images/icon.png index 67c777a4..2dc5c8e9 100644 Binary files a/code/app/app-rn/assets/images/icon.png and b/code/app/app-rn/assets/images/icon.png differ diff --git a/code/app/app-rn/assets/images/login-logo.png b/code/app/app-rn/assets/images/login-logo.png new file mode 100644 index 00000000..8ab72f69 Binary files /dev/null and b/code/app/app-rn/assets/images/login-logo.png differ diff --git a/code/app/app-rn/assets/images/splash-icon.png b/code/app/app-rn/assets/images/splash-icon.png index 6b1642a0..173e7710 100644 Binary files a/code/app/app-rn/assets/images/splash-icon.png and b/code/app/app-rn/assets/images/splash-icon.png differ diff --git a/code/app/app-rn/eslint.config.js b/code/app/app-rn/eslint.config.js index ba708ed9..ece97001 100644 --- a/code/app/app-rn/eslint.config.js +++ b/code/app/app-rn/eslint.config.js @@ -1,10 +1,10 @@ // https://docs.expo.dev/guides/using-eslint/ -const { defineConfig } = require('eslint/config'); +const { defineConfig } = require("eslint/config"); const expoConfig = require("eslint-config-expo/flat"); module.exports = defineConfig([ expoConfig, { ignores: ["dist/*"], - } + }, ]); 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 f75b4a52..2e6bae40 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 @@ -38,17 +38,36 @@ function formatTime(ts: string) { const SUBAPP_ICONS: Record = { finance: JizhangIcon, - meal: CaidanIcon, + meal: CaidanIcon, clothes: YifudapeiIcon, }; -const SOURCE_CARD_CONFIG: Record string }> = { - b_finance: { label: "记账", icon: SUBAPP_ICONS.finance, route: (id) => `/finance/detail/${id}` }, - b_todo: { label: "待办", route: (id) => `/todo/detail/${id}` }, - b_meal_dish: { label: "家庭菜单", icon: SUBAPP_ICONS.meal, route: (id) => `/meal/dish/detail/${id}` }, - b_meal_plan: { label: "家庭菜单", icon: SUBAPP_ICONS.meal, route: (id) => `/meal/plan/detail/${id}` }, - b_clothing_outfit: { label: "衣服穿搭", icon: SUBAPP_ICONS.clothes, route: (id) => `/clothes/outfit/detail/${id}` }, - b_clothing_clothes: { label: "衣服穿搭", icon: SUBAPP_ICONS.clothes, route: (id) => `/clothes/detail/${id}` }, +const SOURCE_CARD_CONFIG: Record< + string, + { label: string; icon?: ImageSourcePropType; route: (id: number) => string } +> = { + b_finance: { label: "记账", icon: SUBAPP_ICONS.finance, route: (id) => `/finance/detail/${id}` }, + b_todo: { label: "待办", route: (id) => `/todo/detail/${id}` }, + b_meal_dish: { + label: "家庭菜单", + icon: SUBAPP_ICONS.meal, + route: (id) => `/meal/dish/detail/${id}`, + }, + b_meal_plan: { + label: "家庭菜单", + icon: SUBAPP_ICONS.meal, + route: (id) => `/meal/plan/detail/${id}`, + }, + b_clothing_outfit: { + label: "衣服穿搭", + icon: SUBAPP_ICONS.clothes, + route: (id) => `/clothes/outfit/detail/${id}`, + }, + b_clothing_clothes: { + label: "衣服穿搭", + icon: SUBAPP_ICONS.clothes, + route: (id) => `/clothes/detail/${id}`, + }, }; /** 从 content 中解析模块名和正文,格式:『【模块】正文』 */ @@ -160,10 +179,13 @@ const ChatMessageItem = memo(function ChatMessageItem({ const canShowActions = canRecall || isAdmin; // 是否为模块卡片消息(Forward 或带 source 的 System) - const isCardMsg = item.msg_type === ChatMsgType.Forward || (item.msg_type === ChatMsgType.System && !!item.source_table); + const isCardMsg = + item.msg_type === ChatMsgType.Forward || + (item.msg_type === ChatMsgType.System && !!item.source_table); const cardConfig = isCardMsg && item.source_table ? SOURCE_CARD_CONFIG[item.source_table] : null; const { module: moduleName, body: moduleBody } = parseModuleContent(item.content); - const sourceDetail = isCardMsg && item.source_table ? formatSourceDetail(item.source_table, item.source_data) : null; + const sourceDetail = + isCardMsg && item.source_table ? formatSourceDetail(item.source_table, item.source_data) : null; const handleCardPress = useCallback(() => { if (cardConfig && item.source_id) { @@ -183,7 +205,9 @@ const ChatMessageItem = memo(function ChatMessageItem({ return ( - {nickname} {item.content} + + {nickname} {item.content} + ); @@ -325,21 +349,41 @@ const ChatMessageItem = memo(function ChatMessageItem({ {cardConfig?.icon ? ( ) : ( - + )} - + {cardConfig?.label ?? moduleName ?? "动态"} {/* 操作描述 */} - + {moduleBody || item.content} {/* 来源补充信息 */} {sourceDetail && ( - + {sourceDetail} )} @@ -470,13 +514,18 @@ export default function ChatPage() { // ---- toast: new message from others ---- const seenMsgIds = useRef>(new Set()); - // Sync history IDs into seen set so we don't toast on initial load + const seededIds = useRef(false); + // Seed seenMsgIds from history once loaded, to avoid toasting on initial render useEffect(() => { - for (const m of history) seenMsgIds.current.add(m.id); - }, [history]); + if (!loading && history.length > 0 && !seededIds.current) { + for (const m of history) seenMsgIds.current.add(m.id); + for (const m of ws.messages) seenMsgIds.current.add(m.id); + seededIds.current = true; + } + }, [history, ws.messages, loading]); // Watch ws.messages for new arrivals from others useEffect(() => { - if (loading) return; // skip during initial load + if (loading || !seededIds.current) return; for (const m of ws.messages) { if (seenMsgIds.current.has(m.id)) continue; seenMsgIds.current.add(m.id); @@ -488,16 +537,12 @@ export default function ChatPage() { } }, [ws.messages, userId, members, loading]); - // Merge pending messages into the display list + // Merge pending messages into the display list (ws.messages 覆盖 history,保证撤回等实时更新生效) const allMessages = useMemo(() => { - const ids = new Set(history.map((m) => m.id)); - const merged: (ChatMessage & { _pending?: PendingMsg })[] = [...history]; - for (const m of ws.messages) { - if (!ids.has(m.id)) { - merged.push(m); - ids.add(m.id); - } - } + const map = new Map(); + for (const m of history) map.set(m.id, m); + for (const m of ws.messages) map.set(m.id, { ...map.get(m.id), ...m }); + const merged: (ChatMessage & { _pending?: PendingMsg })[] = Array.from(map.values()); // Append pending (sending/failed) at the end for (const p of pending) { merged.push({ @@ -608,7 +653,7 @@ export default function ChatPage() { // Send the current message draft (optimistic). function handleSend() { const text = inputText.trim(); - if (!text) return; + if (!text || !ws.isConnected) return; setInputText(""); const localId = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; @@ -631,19 +676,22 @@ export default function ChatPage() { } // Retry a failed message - const handleRetry = useCallback((p: PendingMsg) => { - setPending((prev) => - prev.map((x) => (x.localId === p.localId ? { ...x, status: "sending" } : x)), - ); - toast.loading("正在发送", { id: p.localId }); - ws.send({ type: "send", content: p.content }); - pendingTimers.current[p.localId] = setTimeout(() => { + const handleRetry = useCallback( + (p: PendingMsg) => { setPending((prev) => - prev.map((x) => (x.localId === p.localId ? { ...x, status: "failed" } : x)), + prev.map((x) => (x.localId === p.localId ? { ...x, status: "sending" } : x)), ); - toast.error("发送失败", { id: p.localId }); - }, 10_000); - }, [ws]); + toast.loading("正在发送", { id: p.localId }); + ws.send({ type: "send", content: p.content }); + pendingTimers.current[p.localId] = setTimeout(() => { + setPending((prev) => + prev.map((x) => (x.localId === p.localId ? { ...x, status: "failed" } : x)), + ); + toast.error("发送失败", { id: p.localId }); + }, 10_000); + }, + [ws], + ); // memo 稳定引用,避免 inverted FlatList 反复 renderItem const keyExtractor = useCallback((item: any) => { @@ -652,52 +700,59 @@ export default function ChatPage() { }, []); // 删除 & 撤回时同步更新本地 history,确保页面即时移除 - const handleDeleteLocal = useCallback((id: number) => { - ws.deleteMessage(id); - setHistory((prev) => prev.filter((m) => m.id !== id)); - }, [ws]); + const handleDeleteLocal = useCallback( + (id: number) => { + ws.deleteMessage(id); + setHistory((prev) => prev.filter((m) => m.id !== id)); + }, + [ws], + ); - const handleRecallLocal = useCallback((id: number) => { - ws.recall(id); - setHistory((prev) => prev.map((m) => (m.id === id ? { ...m, recalled: 1 } : m))); - }, [ws]); + const handleRecallLocal = useCallback( + (id: number) => { + ws.recall(id); + setHistory((prev) => prev.map((m) => (m.id === id ? { ...m, recalled: 1 } : m))); + }, + [ws], + ); - const renderItem = useCallback(({ item }: { item: any }) => { - const pending = item._pending as PendingMsg | undefined; - if (pending) { - return ( - - {pending.status === "sending" ? ( - - - - ) : ( - handleRetry(pending)}> - - + const renderItem = useCallback( + ({ item }: { item: any }) => { + const pending = item._pending as PendingMsg | undefined; + if (pending) { + return ( + + {pending.status === "sending" ? ( + + - - - )} - - - {pending.content} - + ) : ( + handleRetry(pending)}> + + + + + + )} + + {pending.content} + - + ); + } + return ( + ); - } - return ( - - ); - }, [userId, members, adminRole, handleRecallLocal, handleDeleteLocal, handleRetry]); + }, + [userId, members, adminRole, handleRecallLocal, handleDeleteLocal, handleRetry], + ); if (loading) { return ( @@ -720,32 +775,26 @@ export default function ChatPage() { /> } footer={ - - - - - - - - - - + + + + + } data={invertedMessages} @@ -769,9 +818,7 @@ export default function ChatPage() { ) : null } ListEmptyComponent={ - - 暂无消息,开始聊天吧 - + } renderItem={renderItem} /> diff --git a/code/app/app-rn/src/app/(main)/(tabs)/home/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/home/index.tsx index f6d87ebd..1852b13c 100644 --- a/code/app/app-rn/src/app/(main)/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/(main)/(tabs)/home/index.tsx @@ -105,7 +105,11 @@ export default function Home() { onPress={() => router.navigate(app.id as any)} /> ))} - } title="更多工具" onPress={() => router.navigate("/tools")} /> + } + title="更多工具" + onPress={() => router.navigate("/tools")} + /> {/* 填充空位保证对齐 */} {Array.from({ length: Math.max(0, 2 - SUB_APPS.length), diff --git a/code/app/app-rn/src/app/(main)/(tabs)/my/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/my/index.tsx index d30ad7c8..0c981d94 100644 --- a/code/app/app-rn/src/app/(main)/(tabs)/my/index.tsx +++ b/code/app/app-rn/src/app/(main)/(tabs)/my/index.tsx @@ -19,10 +19,15 @@ export default function My() { {userStore.userInfo.avatar ? ( - + ) : ( - {userStore.userInfo.nickname?.charAt(0) || "?"} + + {userStore.userInfo.nickname?.charAt(0) || "?"} + )} diff --git a/code/app/app-rn/src/app/(main)/auth/login.tsx b/code/app/app-rn/src/app/(main)/auth/login.tsx index c9e2ce06..d1294322 100644 --- a/code/app/app-rn/src/app/(main)/auth/login.tsx +++ b/code/app/app-rn/src/app/(main)/auth/login.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { Stack } from "expo-router"; -import { Image, Text, View } from "react-native"; +import { Image, Text, TouchableOpacity, View } from "react-native"; import { Button, LinkButton, ControlField, Checkbox } from "heroui-native"; import { IconQq, IconWeixin } from "@/components/iconfont"; import { useState } from "react"; @@ -193,43 +193,13 @@ export default function Login() { handleLogin(); }, [authResult]); - async function test1() { + async function handleTestLogin() { if (isLoading) return; - startLoading("正在登录..."); try { - const loginRes = await loginTestApi({ - user_id: 801787924787269, - }); - - if (!loginRes.isSuccess) { - stopLoading(); - return; - } - - await handlePostLogin(loginRes.data); - stopLoading(); - } catch (err) { - console.error("测试登录失败", err); - stopLoading({ type: "error", message: "登录失败" }); - } - } - - async function test2() { - if (isLoading) return; - - startLoading("正在登录..."); - try { - const loginRes = await loginTestApi({ - user_id: 801787924787270, - }); - - if (!loginRes.isSuccess) { - stopLoading(); - return; - } - - await handlePostLogin(loginRes.data); + const res = await loginTestApi({ user_id: 828607613968453 }); + if (!res.isSuccess) { stopLoading(); return; } + await handlePostLogin(res.data); stopLoading(); } catch (err) { console.error("测试登录失败", err); @@ -247,11 +217,11 @@ export default function Login() { - Welcome to Allapp + 欢迎来到窝织 @@ -265,15 +235,11 @@ export default function Login() { QQ - - - + {!process.env.EXPO_PUBLIC_API_URL?.startsWith("https://") && ( + + 测试登录 + + )} - - 筛选聊天记录 - - - 重置 - - + + 筛选聊天记录 + + + 重置 + + - - - setShowDateRange(true)} - > - - {draftStartDate - ? `${draftStartDate} 至 ${draftEndDate || draftStartDate}` - : "全部时间"} - - - - + + + setShowDateRange(true)} + > + + {draftStartDate + ? `${draftStartDate} 至 ${draftEndDate || draftStartDate}` + : "全部时间"} + + + + - - - - {MSG_TYPES.map((t) => ( - setDraftTypeFilter(t.value)}> - + + + {MSG_TYPES.map((t) => ( + setDraftTypeFilter(t.value)}> + + - - {t.label} - - - - - ))} - + {t.label} + + + + + ))} + - - - - + + + + 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 f0ec1723..0155cedf 100644 --- a/code/app/app-rn/src/app/(main)/family/entry.tsx +++ b/code/app/app-rn/src/app/(main)/family/entry.tsx @@ -205,7 +205,7 @@ export default function EntryFamily() { const defaultIconKeys = defaultFinanceCategories .map((item) => item.icon_file_key) .filter(Boolean); - const copyIconRes = await s3CopyFilesApi(familyId, defaultIconKeys); + const copyIconRes = await s3CopyFilesApi(familyId, defaultIconKeys, "finance"); const copiedIconKeys = copyIconRes.data ?? []; copiedDefaultIconKeys = copiedIconKeys; diff --git a/code/app/app-rn/src/app/(main)/family/member/[id].tsx b/code/app/app-rn/src/app/(main)/family/member/[id].tsx index c8ecf3e4..397a784a 100644 --- a/code/app/app-rn/src/app/(main)/family/member/[id].tsx +++ b/code/app/app-rn/src/app/(main)/family/member/[id].tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import LayoutView from "@/components/layout/view"; import { Text, View } from "react-native"; import PageLayout from "@/layouts/PageLayout"; @@ -28,6 +28,7 @@ import { FadeIn } from "react-native-reanimated"; import { toast } from "sonner-native"; import { buildFileUrl } from "@/utils/file"; import { useRouter } from "expo-router"; +import dayjs from "dayjs"; const StyledIonicons = withUniwind(Ionicons); @@ -80,6 +81,11 @@ export default function FamilyMember() { } } + const age = useMemo(() => { + if (!memberInfo.birth_date) return null; + return dayjs().diff(dayjs(memberInfo.birth_date), "year"); + }, [memberInfo.birth_date]); + useEffect(() => { if (!id) return; @@ -165,28 +171,15 @@ export default function FamilyMember() { - {memberInfo.avatar ? : null} - {memberInfo.nickname?.charAt(0) || "?"} + {memberInfo.avatar ? ( + + ) : null} + + {memberInfo.nickname?.charAt(0) || "?"} + {memberInfo.nickname} - - - - - - - - 家庭成员信息 @@ -201,7 +194,7 @@ export default function FamilyMember() { 姓名 - 张三 + {memberInfo.name || "未设置"} @@ -216,7 +209,7 @@ export default function FamilyMember() { 手机号 - 18233334444 + {memberInfo.phone || "未设置"} @@ -231,7 +224,9 @@ export default function FamilyMember() { 生日 - 18233334444 + + {memberInfo.birth_date ? dayjs(memberInfo.birth_date).format("YYYY-MM-DD") : "未设置"} + @@ -246,7 +241,7 @@ export default function FamilyMember() { 年龄 - 26 + {age != null ? age : "未设置"} 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 91f0b849..e48f4bad 100644 --- a/code/app/app-rn/src/app/(main)/family/settings.tsx +++ b/code/app/app-rn/src/app/(main)/family/settings.tsx @@ -552,20 +552,20 @@ export default function HomeSettings() { - - - setRenameForm({ name })} - placeholder="请输入家庭名称" - onSubmitEditing={handleRename} - returnKeyType="done" - /> - - + + + setRenameForm({ name })} + placeholder="请输入家庭名称" + onSubmitEditing={handleRename} + returnKeyType="done" + /> + + 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 c223722d..e1fe22b8 100644 --- a/code/app/app-rn/src/app/(main)/family/user.tsx +++ b/code/app/app-rn/src/app/(main)/family/user.tsx @@ -523,10 +523,15 @@ export default function UserSetting() { className="w-full h-full rounded-full overflow-hidden" > {userStore.userInfo.avatar ? ( - + ) : ( - {(userStore.userInfo.nickname || "?").charAt(0)} + + {(userStore.userInfo.nickname || "?").charAt(0)} + )} @@ -726,20 +731,20 @@ export default function UserSetting() { - - - setRenameForm({ name })} - placeholder="请输入姓名" - onSubmitEditing={handleRename} - returnKeyType="done" - /> - - + + + setRenameForm({ name })} + placeholder="请输入姓名" + onSubmitEditing={handleRename} + returnKeyType="done" + /> + + @@ -748,20 +753,20 @@ export default function UserSetting() { - - - setPhoneForm({ phone })} - placeholder="请输入手机号" - onSubmitEditing={handlePhone} - returnKeyType="done" - /> - - + + + setPhoneForm({ phone })} + placeholder="请输入手机号" + onSubmitEditing={handlePhone} + returnKeyType="done" + /> + + 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 index d3c4262a..0ee6d6e1 100644 --- a/code/app/app-rn/src/app/anniversary/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/anniversary/(tabs)/home/index.tsx @@ -22,7 +22,9 @@ const TYPE_LABELS: Record = { 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")}`); + 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"); } @@ -39,8 +41,14 @@ export default function AnniversaryHome() { await fetchAll(familyId); }, [familyId, fetchAll]); - useEffect(() => { if (familyId) runInitial(load); }, [familyId]); - useFocusEffect(useCallback(() => { load(); }, [load])); + useEffect(() => { + if (familyId) runInitial(load); + }, [familyId]); + useFocusEffect( + useCallback(() => { + load(); + }, [load]), + ); const sorted = useMemo(() => { return [...items] @@ -51,28 +59,46 @@ export default function AnniversaryHome() { return ( - runRefresh(load)} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}> + 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.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 index e7e48c02..e152b9ad 100644 --- a/code/app/app-rn/src/app/anniversary/add/index.tsx +++ b/code/app/app-rn/src/app/anniversary/add/index.tsx @@ -35,19 +35,58 @@ export default function AnniversaryAdd() { 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 (!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 }); + 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]); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + name, + month, + day, + type, + remindBefore, + familyId, + isLoading, + create, + notifyOp, + router, + startLoading, + stopLoading, + ]); return ( @@ -55,7 +94,10 @@ export default function AnniversaryAdd() { - + + + + 类型 @@ -64,9 +106,15 @@ export default function AnniversaryAdd() { const Icon = t.icon; const sel = type === t.value; return ( - setType(t.value)}> + setType(t.value)} + > - {t.label} + + {t.label} + ); })} @@ -74,60 +122,125 @@ export default function AnniversaryAdd() { 日期 - setShowMonth(true)}> - {month ? `${month}月` : "选择月份"} + setShowMonth(true)} + > + + {month ? `${month}月` : "选择月份"} + - setShowDay(true)}> - {day ? `${day}日` : "选择日期"} + 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} + {[ + { 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}月 + { + setMonth(String(m)); + setShowMonth(false); + }} + > + + {m}月 + ))} - + {/* 日期选择 */} - + + 选择日期 {Array.from({ length: 31 }, (_, i) => i + 1).map((d) => ( - { setDay(String(d)); setShowDay(false); }}> - {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 dafa387b..fe64a3c6 100644 --- a/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx @@ -1,9 +1,22 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, NativeSyntheticEvent, NativeScrollEvent, Text, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + ActivityIndicator, + NativeSyntheticEvent, + NativeScrollEvent, + Text, + TouchableOpacity, + View, +} from "react-native"; import { router, useFocusEffect } from "expo-router"; import { Button, Dialog, Label, TextField } from "heroui-native"; import { - CheckIcon, FilterIcon, PencilIcon, PlusIcon, RotateCcwIcon, Trash2Icon, + CheckIcon, + FilterIcon, + PencilIcon, + PlusIcon, + RotateCcwIcon, + Trash2Icon, } from "lucide-react-native"; import BottomSheet from "@/components/BottomSheet"; import ContextMenu from "@/components/ContextMenu"; @@ -17,11 +30,21 @@ import { useClothingStore, useFamilyStore } from "@/store"; import { ClothingClothes, 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)); } +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 insets = useSafeAreaInsets(); const familyId = useFamilyStore((s) => s.getFamilyId()); const clothes = useClothingStore((s) => s.clothes); const clothesTotal = useClothingStore((s) => s.clothesTotal); @@ -47,20 +70,38 @@ export default function ClothingHome() { const [showFilterSheet, setShowFilterSheet] = useState(false); const [activeItem, setActiveItem] = useState(null); const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); - const [deleteConfirm, setDeleteConfirm] = useState<{ item: ClothingClothes; relatedOutfitCount: number } | null>(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ + item: ClothingClothes; + relatedOutfitCount: number; + } | null>(null); const [loadingMore, setLoadingMore] = useState(false); const hasMore = clothes.length < clothesTotal; - const clothesCategories = useMemo(() => categories.filter((c) => c.type === ClothingTargetType.Clothes), [categories]); - const clothesTags = useMemo(() => tags.filter((t) => t.type === ClothingTargetType.Clothes), [tags]); + const clothesCategories = useMemo( + () => categories.filter((c) => c.type === ClothingTargetType.Clothes), + [categories], + ); + const clothesTags = useMemo( + () => tags.filter((t) => t.type === ClothingTargetType.Clothes), + [tags], + ); - const activeFilterCount = useMemo(() => { let c = 0; if (filter.categoryId) c++; if (filter.tagIds.length > 0) c++; return c; }, [filter]); + const activeFilterCount = useMemo(() => { + let c = 0; + if (filter.categoryId) c++; + if (filter.tagIds.length > 0) c++; + return c; + }, [filter]); const tagIdsByClothesId = useMemo(() => { const map = new Map>(); - 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); - }); + 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]); @@ -68,7 +109,10 @@ export default function ClothingHome() { const kw = keyword.trim().toLowerCase(); return clothes.filter((item) => { if (filter.categoryId && item.category_id !== filter.categoryId) return false; - if (filter.tagIds.length > 0) { const ids = tagIdsByClothesId.get(item.id); if (!ids || !filter.tagIds.every((tid) => ids.has(tid))) return false; } + if (filter.tagIds.length > 0) { + const ids = tagIdsByClothesId.get(item.id); + if (!ids || !filter.tagIds.every((tid) => ids.has(tid))) return false; + } if (!kw) return true; return `${item.name} ${item.brand ?? ""}`.toLowerCase().includes(kw); }); @@ -77,11 +121,34 @@ export default function ClothingHome() { const loadBaseData = useCallback(async () => { if (!familyId) return; useClothingStore.setState({ clothes: [], clothesTotal: 0 }); - await Promise.all([loadMoreClothes(familyId), fetchOutfits(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Clothes), fetchTags(familyId, ClothingTargetType.Clothes), fetchTagRelations(familyId), fetchMembers(familyId)]); - }, [familyId, fetchCategories, loadMoreClothes, fetchOutfitClothes, fetchOutfits, fetchTagRelations, fetchTags, fetchMembers]); + await Promise.all([ + loadMoreClothes(familyId), + fetchOutfits(familyId), + fetchOutfitClothes(familyId), + fetchCategories(familyId, ClothingTargetType.Clothes), + fetchTags(familyId, ClothingTargetType.Clothes), + fetchTagRelations(familyId), + fetchMembers(familyId), + ]); + }, [ + familyId, + fetchCategories, + loadMoreClothes, + fetchOutfitClothes, + fetchOutfits, + fetchTagRelations, + fetchTags, + fetchMembers, + ]); - useEffect(() => { if (familyId) runInitial(loadBaseData); }, [familyId]); - useFocusEffect(useCallback(() => { if (familyId) loadBaseData(); }, [familyId, loadBaseData])); + useEffect(() => { + if (familyId) runInitial(loadBaseData); + }, [familyId]); + useFocusEffect( + useCallback(() => { + if (familyId) loadBaseData(); + }, [familyId, loadBaseData]), + ); const handleRefresh = useCallback(() => runRefresh(loadBaseData), [loadBaseData, runRefresh]); @@ -92,60 +159,145 @@ export default function ClothingHome() { setLoadingMore(false); }, [familyId, hasMore, loadingMore, loadMoreClothes]); const handleResetFilter = useCallback(() => setDraftFilter(createDefaultFilter()), []); - const handleApplyFilter = useCallback(() => { setFilter(draftFilter); setShowFilterSheet(false); }, [draftFilter]); - const handleOpenFilter = useCallback(() => { setDraftFilter(filter); setShowFilterSheet(true); }, [filter]); + 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 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 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: 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.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); + if (deleteRelatedOutfits) + state.outfits + .filter((o) => relatedIds.includes(o.id)) + .forEach((o) => deleteS3FileQuietly(o.cover_file_key)); + await loadBaseData(); + notifyModuleOperation({ + module: "衣服穿搭", + action: "删除衣服", + target: item.name, + detail: deleteRelatedOutfits ? `同时删除 ${relatedIds.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 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: 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.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); - if (deleteRelatedOutfits) state.outfits.filter((o) => relatedIds.includes(o.id)).forEach((o) => deleteS3FileQuietly(o.cover_file_key)); - await loadBaseData(); - notifyModuleOperation({ module: "衣服穿搭", action: "删除衣服", target: item.name, detail: deleteRelatedOutfits ? `同时删除 ${relatedIds.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 relatedIds = [ + ...new Set( + state.outfitClothes.filter((r) => r.clothes_id === item.id).map((r) => r.outfit_id), + ), + ]; + setDeleteConfirm({ item, relatedOutfitCount: relatedIds.length }); + }, + [familyId, fetchOutfitClothes, fetchOutfits, fetchTagRelations], + ); - 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 relatedIds = [...new Set(state.outfitClothes.filter((r) => r.clothes_id === item.id).map((r) => r.outfit_id))]; - setDeleteConfirm({ item, relatedOutfitCount: relatedIds.length }); - }, [familyId, fetchOutfitClothes, fetchOutfits, fetchTagRelations]); + const handleConfirmDelete = useCallback( + (withOutfits: boolean) => { + const d = deleteConfirm; + if (!d) return; + setDeleteConfirm(null); + handleDelete(d.item, withOutfits); + }, + [deleteConfirm, handleDelete], + ); + const renderCategoryName = useCallback( + (cid?: number | null) => clothesCategories.find((c) => c.id === cid)?.name ?? "", + [clothesCategories], + ); + const filterChanged = + filter.categoryId !== draftFilter.categoryId || + !sameNumberSet(filter.tagIds, draftFilter.tagIds); - const handleConfirmDelete = useCallback((withOutfits: boolean) => { const d = deleteConfirm; if (!d) return; setDeleteConfirm(null); handleDelete(d.item, withOutfits); }, [deleteConfirm, handleDelete]); - const renderCategoryName = useCallback((cid?: number | null) => clothesCategories.find((c) => c.id === cid)?.name ?? "", [clothesCategories]); - const filterChanged = filter.categoryId !== draftFilter.categoryId || !sameNumberSet(filter.tagIds, draftFilter.tagIds); - - const renderHeader = useCallback(() => ( - - 家庭衣橱 - - {filteredClothes.length} 件 - - - - 筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""} - - - - + const renderHeader = useCallback( + () => ( + + 家庭衣橱 + + {filteredClothes.length} 件 + + + + + 筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""} + + + + + + - - ), [activeFilterCount, filteredClothes.length, handleOpenFilter, handleRefresh]); + ), + [activeFilterCount, filteredClothes.length, handleOpenFilter, handleRefresh], + ); const masonry = useMemo(() => { const left: (ClothingClothes & { r: number })[] = []; @@ -157,13 +309,31 @@ export default function ClothingHome() { return { left, right }; }, [filteredClothes]); - const renderMasonryItem = useCallback((item: any) => ( - { setActiveItem(null); router.push(`/clothes/detail/${item.id}` as any); }} - onLongPress={(e) => { setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); setActiveItem(item); }}> - - - ), [getOwnerName, renderCategoryName]); + const renderMasonryItem = useCallback( + (item: any) => ( + { + setActiveItem(null); + router.push(`/clothes/detail/${item.id}` as any); + }} + onLongPress={(e) => { + setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); + setActiveItem(item); + }} + > + + + ), + [getOwnerName, renderCategoryName], + ); return ( @@ -177,51 +347,206 @@ export default function ClothingHome() { handleScrollEnd(); } }} - contentContainerStyle={[{ paddingHorizontal: 16, paddingBottom: 120 }, filteredClothes.length === 0 ? { flexGrow: 1 } : undefined]} + contentContainerStyle={[ + { paddingHorizontal: 16, paddingBottom: 120 }, + filteredClothes.length === 0 ? { flexGrow: 1 } : undefined, + ]} > {renderHeader()} {filteredClothes.length === 0 ? ( - + ) : ( - <> - - {masonry.left.map(renderMasonryItem)} - {masonry.right.map(renderMasonryItem)} - - {loadingMore && } - {!hasMore && clothes.length > 0 && 已加载全部} - + <> + + + {masonry.left.map(renderMasonryItem)} + + + {masonry.right.map(renderMasonryItem)} + + + {loadingMore && } + {!hasMore && clothes.length > 0 && ( + 已加载全部 + )} + )} - - - 筛选重置 - - - setDraftFilter((p) => ({ ...p, categoryId: null }))}>全部 - {clothesCategories.map((c) => ( setDraftFilter((p) => ({ ...p, categoryId: p.categoryId === c.id ? null : c.id }))}>{c.name}))} - - - {clothesTags.map((t) => ( setDraftFilter((p) => ({ ...p, tagIds: p.tagIds.includes(t.id) ? p.tagIds.filter((id) => id !== t.id) : [...p.tagIds, t.id] }))}>{t.name}))} - - - + + + + + + + 筛选 + + + + 重置 + + + + + + + + + + + setDraftFilter((p) => ({ ...p, categoryId: null }))} + > + + 全部 + + + {clothesCategories.map((c) => ( + + setDraftFilter((p) => ({ + ...p, + categoryId: p.categoryId === c.id ? null : c.id, + })) + } + > + + {c.name} + + + ))} + + + + + + {clothesTags.map((t) => ( + + setDraftFilter((p) => ({ + ...p, + tagIds: p.tagIds.includes(t.id) + ? p.tagIds.filter((id) => id !== t.id) + : [...p.tagIds, t.id], + })) + } + > + + {t.name} + + + ))} + + + + + + + + + - router.push("/clothes/edit" as any)}> + router.push("/clothes/edit" as any)} + > + + - setActiveItem(null)} + setActiveItem(null)} items={[ - { icon: , label: "编辑", onPress: () => { if (activeItem) handleEdit(activeItem); } }, - { icon: , label: "删除", onPress: () => { if (activeItem) handleRequestDelete(activeItem); } }, + { + icon: , + label: "编辑", + onPress: () => { + if (activeItem) handleEdit(activeItem); + }, + }, + { + icon: , + label: "删除", + onPress: () => { + if (activeItem) handleRequestDelete(activeItem); + }, + }, ]} /> - { if (!v) setDeleteConfirm(null); }}> - {deleteConfirm?.relatedOutfitCount ? "衣服已被搭配使用" : "删除衣服"} - {deleteConfirm?.relatedOutfitCount ? `「${deleteConfirm.item.name}」已被 ${deleteConfirm.relatedOutfitCount} 个搭配使用,是否同时删除这些搭配?` : `确认删除「${deleteConfirm?.item.name}」吗?`} - - + { + if (!v) setDeleteConfirm(null); + }} + > + + + + + {deleteConfirm?.relatedOutfitCount ? "衣服已被搭配使用" : "删除衣服"} + + + {deleteConfirm?.relatedOutfitCount + ? `「${deleteConfirm.item.name}」已被 ${deleteConfirm.relatedOutfitCount} 个搭配使用,是否同时删除这些搭配?` + : `确认删除「${deleteConfirm?.item.name}」吗?`} + + + + + + + + ); 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 ce2fc82c..9231768a 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 @@ -9,7 +9,22 @@ import { LoadingOverlay } from "@/components/LoadingOverlay"; import { useClothingStore, useFamilyStore } from "@/store"; import { ClothingCategory, ClothingTag, ClothingTargetType } from "@/types/clothing"; -const COLORS = ["#EF4444","#F97316","#F59E0B","#84CC16","#22C55E","#14B8A6","#06B6D4","#3B82F6","#6366F1","#8B5CF6","#A855F7","#D946EF","#EC4899","#78716C"]; +const COLORS = [ + "#EF4444", + "#F97316", + "#F59E0B", + "#84CC16", + "#22C55E", + "#14B8A6", + "#06B6D4", + "#3B82F6", + "#6366F1", + "#8B5CF6", + "#A855F7", + "#D946EF", + "#EC4899", + "#78716C", +]; export default function ClothingManage() { const familyId = useFamilyStore((s) => s.getFamilyId()); @@ -24,7 +39,10 @@ export default function ClothingManage() { const [tab, setTab] = useState(ClothingTargetType.Clothes); const curCategories = useMemo(() => categories.filter((c) => c.type === tab), [categories, tab]); - const curTagGroups = useMemo(() => tags.filter((t) => t.parent_id === 0 && t.type === tab), [tags, tab]); + const curTagGroups = useMemo( + () => tags.filter((t) => t.parent_id === 0 && t.type === tab), + [tags, tab], + ); // 分类 const [catForm, setCatForm] = useState(""); @@ -49,68 +67,206 @@ export default function ClothingManage() { if (!familyId) return; await Promise.all([fetchCategories(familyId), fetchTags(familyId)]); }, [familyId, fetchCategories, fetchTags]); - useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]); + useEffect(() => { + if (familyId) runInitial(loadData); + }, [familyId]); // 分类 - const openCat = useCallback((c?: ClothingCategory) => { setCatForm(c?.name ?? ""); setEditingCat(c ?? null); setShowCatSheet(true); }, []); + const openCat = useCallback((c?: ClothingCategory) => { + setCatForm(c?.name ?? ""); + setEditingCat(c ?? null); + setShowCatSheet(true); + }, []); const saveCat = useCallback(async () => { - if (!familyId || !catForm.trim()) return; startLoading("保存中..."); + if (!familyId || !catForm.trim()) return; + startLoading("保存中..."); try { - editingCat ? await saveCategoryChanges({ updates: [{ id: editingCat.id, name: catForm.trim() }] }) : await saveCategoryChanges({ inserts: [{ name: catForm.trim(), type: tab, family_id: familyId }] }); - setShowCatSheet(false); await loadData(); stopLoading({ type: "success", message: "已保存" }); - } catch { stopLoading({ type: "error", message: "保存失败" }); } - }, [catForm, editingCat, familyId, loadData, saveCategoryChanges, startLoading, stopLoading, tab]); - const delCat = useCallback(async () => { if (!catDelete) return; startLoading("删除中..."); - try { await saveCategoryChanges({ deletes: [{ id: catDelete.id }] }); setCatDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); } + editingCat + ? await saveCategoryChanges({ updates: [{ id: editingCat.id, name: catForm.trim() }] }) + : await saveCategoryChanges({ + inserts: [{ name: catForm.trim(), type: tab, family_id: familyId }], + }); + setShowCatSheet(false); + await loadData(); + stopLoading({ type: "success", message: "已保存" }); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + catForm, + editingCat, + familyId, + loadData, + saveCategoryChanges, + startLoading, + stopLoading, + tab, + ]); + const delCat = useCallback(async () => { + if (!catDelete) return; + startLoading("删除中..."); + try { + await saveCategoryChanges({ deletes: [{ id: catDelete.id }] }); + setCatDelete(null); + await loadData(); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } }, [catDelete, loadData, saveCategoryChanges, startLoading, stopLoading]); // 标签分组 - const openGroup = useCallback((g?: ClothingTag) => { setGroupForm(g?.name ?? ""); setEditingGroup(g ?? null); setShowGroupSheet(true); }, []); + const openGroup = useCallback((g?: ClothingTag) => { + setGroupForm(g?.name ?? ""); + setEditingGroup(g ?? null); + setShowGroupSheet(true); + }, []); const saveGroup = useCallback(async () => { - if (!familyId || !groupForm.trim()) return; startLoading("保存中..."); + 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: null, color: "" }] }); - setShowGroupSheet(false); await loadData(); stopLoading({ type: "success", message: "已保存" }); - } catch { stopLoading({ type: "error", message: "保存失败" }); } + 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]); - const delGroup = useCallback(async () => { if (!groupDelete) return; startLoading("删除中..."); - try { const childIds = tags.filter((t) => t.parent_id === groupDelete.id).map((t) => ({ id: t.id })); await saveTagChanges({ deletes: [{ id: groupDelete.id }, ...childIds] }); setGroupDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); } + const delGroup = useCallback(async () => { + if (!groupDelete) return; + startLoading("删除中..."); + try { + const childIds = tags + .filter((t) => t.parent_id === groupDelete.id) + .map((t) => ({ id: t.id })); + await saveTagChanges({ deletes: [{ id: groupDelete.id }, ...childIds] }); + setGroupDelete(null); + await loadData(); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } }, [groupDelete, loadData, saveTagChanges, startLoading, stopLoading, tags]); // 标签 - const openTag = useCallback((groupId: number, t?: ClothingTag) => { setActiveGroupId(groupId); setTagForm({ name: t?.name ?? "", color: t?.color ?? "" }); setEditingTag(t ?? null); setShowTagSheet(true); }, []); + const openTag = useCallback((groupId: number, t?: ClothingTag) => { + setActiveGroupId(groupId); + setTagForm({ name: t?.name ?? "", color: t?.color ?? "" }); + setEditingTag(t ?? null); + setShowTagSheet(true); + }, []); const saveTag = useCallback(async () => { - if (!familyId || !activeGroupId || !tagForm.name.trim()) return; startLoading("保存中..."); + if (!familyId || !activeGroupId || !tagForm.name.trim()) return; + startLoading("保存中..."); try { - editingTag ? await saveTagChanges({ updates: [{ id: editingTag.id, name: tagForm.name.trim(), color: tagForm.color }] }) : await saveTagChanges({ inserts: [{ name: tagForm.name.trim(), color: tagForm.color, parent_id: activeGroupId, type: tab, family_id: familyId }] }); - setShowTagSheet(false); await loadData(); stopLoading({ type: "success", message: "已保存" }); - } catch { stopLoading({ type: "error", message: "保存失败" }); } - }, [activeGroupId, editingTag, familyId, loadData, saveTagChanges, startLoading, stopLoading, tab, tagForm]); - const delTag = useCallback(async () => { if (!tagDelete) return; startLoading("删除中..."); - try { await saveTagChanges({ deletes: [{ id: tagDelete.id }] }); setTagDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); } + editingTag + ? await saveTagChanges({ + updates: [{ id: editingTag.id, name: tagForm.name.trim(), color: tagForm.color }], + }) + : await saveTagChanges({ + inserts: [ + { + name: tagForm.name.trim(), + color: tagForm.color, + parent_id: activeGroupId, + type: tab, + family_id: familyId, + }, + ], + }); + setShowTagSheet(false); + await loadData(); + stopLoading({ type: "success", message: "已保存" }); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + activeGroupId, + editingTag, + familyId, + loadData, + saveTagChanges, + startLoading, + stopLoading, + tab, + tagForm, + ]); + const delTag = useCallback(async () => { + if (!tagDelete) return; + startLoading("删除中..."); + try { + await saveTagChanges({ deletes: [{ id: tagDelete.id }] }); + setTagDelete(null); + await loadData(); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } }, [loadData, saveTagChanges, startLoading, stopLoading, tagDelete]); - const getTagsByGroup = useCallback((groupId: number) => tags.filter((t) => t.parent_id === groupId), [tags]); + const getTagsByGroup = useCallback( + (groupId: number) => tags.filter((t) => t.parent_id === groupId), + [tags], + ); return ( - runRefresh(loadData)} showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }}> + runRefresh(loadData)} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }} + > {/* 头部 */} 衣橱规则 - {tab === ClothingTargetType.Clothes ? "衣服分类" : "搭配分类"} + + {tab === ClothingTargetType.Clothes ? "衣服分类" : "搭配分类"} + - runRefresh(loadData)}> + runRefresh(loadData)} + > + + {/* Tab 切换 */} - setTab(ClothingTargetType.Clothes)}> - 衣服 + setTab(ClothingTargetType.Clothes)} + > + + 衣服 + - setTab(ClothingTargetType.Outfit)}> - 搭配 + setTab(ClothingTargetType.Outfit)} + > + + 搭配 + @@ -121,15 +277,42 @@ export default function ClothingManage() { 分类 {curCategories.length} 个 - openCat()}>添加 + openCat()} + > + + 添加 + {curCategories.length > 0 ? ( {curCategories.map((c) => ( - - openCat(c)}> + + openCat(c)} + > {c.name} - { e.stopPropagation(); setCatDelete(c); }}> + { + e.stopPropagation(); + setCatDelete(c); + }} + > + + ))} @@ -146,27 +329,61 @@ export default function ClothingManage() { 标签 {curTagGroups.length} 组 - openGroup()}>新建分组 + openGroup()} + > + + 新建分组 + {curTagGroups.length > 0 ? ( {curTagGroups.map((group) => { const gTags = getTagsByGroup(group.id); return ( - + openGroup(group)}> {group.name} - setGroupDelete(group)}> + setGroupDelete(group)} + > + + {gTags.map((t) => ( - openTag(group.id, t)}> - {t.name} + openTag(group.id, t)} + > + + {t.name} + ))} - openTag(group.id)}> + openTag(group.id)} + > + 添加 @@ -181,17 +398,195 @@ export default function ClothingManage() { {/* 分类 Sheet */} - {editingCat ? "编辑分类" : "添加分类"} + + + + + + {editingCat ? "编辑分类" : "添加分类"} + + + + + + + + + + + + {/* 标签分组 Sheet */} - {editingGroup ? "编辑分组" : "新建分组"} + + + + + + {editingGroup ? "编辑分组" : "新建分组"} + + + + + + + + + + + + {/* 标签 Sheet */} - {editingTag ? "编辑标签" : "添加标签"} setTagForm((p) => ({ ...p, name: v }))} placeholder="输入名称" autoFocus />{COLORS.map((c) => ( setTagForm((p) => ({ ...p, color: c }))} />))} setTagForm((p) => ({ ...p, color: "" }))}>无颜色 setTagForm((p) => ({ ...p, color: v }))} placeholder="#EF4444" /> + + + + + + {editingTag ? "编辑标签" : "添加标签"} + + + + setTagForm((p) => ({ ...p, name: v }))} + placeholder="输入名称" + autoFocus + /> + + + + + {COLORS.map((c) => ( + setTagForm((p) => ({ ...p, color: c }))} + /> + ))} + + + setTagForm((p) => ({ ...p, color: "" }))} + > + 无颜色 + + setTagForm((p) => ({ ...p, color: v }))} + placeholder="#EF4444" + /> + + + + + + + + + - { if (!v) setCatDelete(null); }}>删除分类确认删除「{catDelete?.name}」吗? - { if (!v) setGroupDelete(null); }}>删除分组确认删除「{groupDelete?.name}」吗?分组内的标签也会被删除。 - { if (!v) setTagDelete(null); }}>删除标签确认删除「{tagDelete?.name}」吗? + { + if (!v) setCatDelete(null); + }} + > + + + + 删除分类 + 确认删除「{catDelete?.name}」吗? + + + + + + + + { + if (!v) setGroupDelete(null); + }} + > + + + + 删除分组 + + 确认删除「{groupDelete?.name}」吗?分组内的标签也会被删除。 + + + + + + + + + { + if (!v) setTagDelete(null); + }} + > + + + + 删除标签 + 确认删除「{tagDelete?.name}」吗? + + + + + + + ); diff --git a/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx index 4e55ff78..19735852 100644 --- a/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx +++ b/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx @@ -1,10 +1,25 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, NativeSyntheticEvent, NativeScrollEvent, Text, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + ActivityIndicator, + NativeSyntheticEvent, + NativeScrollEvent, + Text, + TouchableOpacity, + View, +} from "react-native"; import { router, useFocusEffect } from "expo-router"; import { Button, Dialog, Label, TextField } from "heroui-native"; import BottomSheet from "@/components/BottomSheet"; import ContextMenu from "@/components/ContextMenu"; -import { FilterIcon, PencilIcon, PlusIcon, RotateCcwIcon, Trash2Icon, UserIcon } from "lucide-react-native"; +import { + FilterIcon, + PencilIcon, + PlusIcon, + RotateCcwIcon, + Trash2Icon, + UserIcon, +} from "lucide-react-native"; import LayoutView from "@/components/layout/view"; import EmptyState from "@/components/EmptyStatus"; import ClothingImage from "@/components/clothing/ClothingImage"; @@ -15,6 +30,7 @@ import { useClothingStore, useFamilyStore } from "@/store"; import { ClothingOutfit, ClothingTargetType } from "@/types/clothing"; export default function ClothingOutfitList() { + const insets = useSafeAreaInsets(); const familyId = useFamilyStore((s) => s.getFamilyId()); const outfits = useClothingStore((s) => s.outfits); const outfitsTotal = useClothingStore((s) => s.outfitsTotal); @@ -41,19 +57,46 @@ 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.outfit_id !== undefined).forEach((r) => { - const ids = map.get(r.outfit_id!) ?? []; ids.push(r.tag_id); map.set(r.outfit_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]); const loadData = useCallback(async () => { if (!familyId) return; useClothingStore.setState({ outfits: [], outfitsTotal: 0 }); - await Promise.all([loadMoreOutfits(familyId), fetchClothes(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Outfit), fetchTags(familyId, ClothingTargetType.Outfit), fetchTagRelations(familyId, ClothingTargetType.Outfit), fetchMembers(familyId)]); - }, [familyId, fetchClothes, fetchOutfitClothes, loadMoreOutfits, fetchCategories, fetchTags, fetchTagRelations, fetchMembers]); - useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]); - useFocusEffect(useCallback(() => { if (familyId) loadData(); }, [familyId, loadData])); + await Promise.all([ + loadMoreOutfits(familyId), + fetchClothes(familyId), + fetchOutfitClothes(familyId), + fetchCategories(familyId, ClothingTargetType.Outfit), + fetchTags(familyId, ClothingTargetType.Outfit), + fetchTagRelations(familyId, ClothingTargetType.Outfit), + fetchMembers(familyId), + ]); + }, [ + familyId, + fetchClothes, + fetchOutfitClothes, + loadMoreOutfits, + fetchCategories, + fetchTags, + fetchTagRelations, + fetchMembers, + ]); + useEffect(() => { + if (familyId) runInitial(loadData); + }, [familyId]); + useFocusEffect( + useCallback(() => { + if (familyId) loadData(); + }, [familyId, loadData]), + ); const handleRefresh = useCallback(() => runRefresh(loadData), [loadData, runRefresh]); @@ -73,50 +116,119 @@ export default function ClothingOutfitList() { const [loadingMore, setLoadingMore] = useState(false); const hasMore = outfits.length < outfitsTotal; - const handleDeleteRequest = useCallback(() => { if (activeItem) { setDeleteConfirm(activeItem); setActiveItem(null); } }, [activeItem]); + const handleDeleteRequest = useCallback(() => { + if (activeItem) { + setDeleteConfirm(activeItem); + setActiveItem(null); + } + }, [activeItem]); const handleDeleteConfirm = useCallback(async () => { - if (!deleteConfirm || !familyId || isLoading) return; const outfit = deleteConfirm; setDeleteConfirm(null); startLoading("删除中..."); + 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.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 }); + 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.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: "已删除" }); - } catch { stopLoading({ type: "error", message: "删除失败" }); } - }, [deleteConfirm, familyId, isLoading, loadData, notifyModuleOperation, saveOutfitBundle, startLoading, stopLoading]); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, [ + deleteConfirm, + familyId, + isLoading, + loadData, + notifyModuleOperation, + saveOutfitBundle, + startLoading, + stopLoading, + ]); - const getCoverKey = useCallback((item: ClothingOutfit) => { - if (item.cover_file_key) return item.cover_file_key; - const first = outfitClothes.filter((r) => r.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((r) => r.outfit_id === item.id).length, [outfitClothes]); + const getCoverKey = useCallback( + (item: ClothingOutfit) => { + if (item.cover_file_key) return item.cover_file_key; + const first = outfitClothes + .filter((r) => r.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((r) => r.outfit_id === item.id).length, + [outfitClothes], + ); const filteredOutfits = useMemo(() => { const kw = keyword.trim().toLowerCase(); return outfits.filter((item) => { if (filterCategoryId && item.category_id !== filterCategoryId) return false; - if (filterTagIds.length > 0) { const ids = outfitTagIds.get(item.id); if (!ids || !filterTagIds.every((tid) => ids.includes(tid))) return false; } - if (!kw) return true; return item.name.toLowerCase().includes(kw); + if (filterTagIds.length > 0) { + const ids = outfitTagIds.get(item.id); + if (!ids || !filterTagIds.every((tid) => ids.includes(tid))) return false; + } + if (!kw) return true; + return item.name.toLowerCase().includes(kw); }); }, [outfits, keyword, filterCategoryId, filterTagIds, outfitTagIds]); - const activeFilterCount = useMemo(() => { let c = 0; if (filterCategoryId) c++; if (filterTagIds.length > 0) c++; return c; }, [filterCategoryId, filterTagIds]); + const activeFilterCount = useMemo(() => { + let c = 0; + if (filterCategoryId) c++; + if (filterTagIds.length > 0) c++; + return c; + }, [filterCategoryId, filterTagIds]); - const renderHeader = useCallback(() => ( - - 搭配方案 - - {filteredOutfits.length} 套 - - setShowFilterSheet(true)}> - 筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""} - - + const renderHeader = useCallback( + () => ( + + 搭配方案 + + {filteredOutfits.length} 套 + + setShowFilterSheet(true)} + > + + + 筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""} + + + + + + - - ), [activeFilterCount, filteredOutfits.length, handleRefresh]); + ), + [activeFilterCount, filteredOutfits.length, handleRefresh], + ); const masonry = useMemo(() => { const left: any[] = []; @@ -128,32 +240,56 @@ export default function ClothingOutfitList() { return { left, right }; }, [filteredOutfits]); - const renderGridItem = useCallback((item: any) => { - const catName = item.category_id ? categoriesById.get(item.category_id)?.name : null; - const ownerName = getOwnerName(item.owner_id); - return ( - router.push(`/clothes/outfit/detail/${item.id}` as any)} - onLongPress={(e) => { setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); setActiveItem(item); }}> - - - - - {getClothesCount(item)} 件 - - {!!ownerName && ( - - - {ownerName} + const renderGridItem = useCallback( + (item: any) => { + const catName = item.category_id ? categoriesById.get(item.category_id)?.name : null; + const ownerName = getOwnerName(item.owner_id); + return ( + router.push(`/clothes/outfit/detail/${item.id}` as any)} + onLongPress={(e) => { + setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); + setActiveItem(item); + }} + > + + + + + + {getClothesCount(item)} 件 + - )} + {!!ownerName && ( + + + + {ownerName} + + + )} + + + {item.name} + + {catName && {catName}} - {item.name} - {catName && {catName}} - - - ); - }, [categoriesById, getClothesCount, getCoverKey, getOwnerName]); + + ); + }, + [categoriesById, getClothesCount, getCoverKey, getOwnerName], + ); return ( @@ -167,56 +303,186 @@ export default function ClothingOutfitList() { handleScrollEnd(); } }} - contentContainerStyle={[{ paddingHorizontal: 16, paddingBottom: 120 }, filteredOutfits.length === 0 ? { flexGrow: 1 } : undefined]} + contentContainerStyle={[ + { paddingHorizontal: 16, paddingBottom: 120 }, + filteredOutfits.length === 0 ? { flexGrow: 1 } : undefined, + ]} > {renderHeader()} {filteredOutfits.length === 0 ? ( - + ) : ( - <> - - {masonry.left.map(renderGridItem)} - {masonry.right.map(renderGridItem)} - - {loadingMore && } - {!hasMore && outfits.length > 0 && 已加载全部} - + <> + + + {masonry.left.map(renderGridItem)} + + + {masonry.right.map(renderGridItem)} + + + {loadingMore && } + {!hasMore && outfits.length > 0 && ( + 已加载全部 + )} + )} - - - 筛选 { setFilterCategoryId(null); setFilterTagIds([]); setKeyword(""); }}>重置 - - - setFilterCategoryId(null)}>全部 - {categories.filter((c) => c.type === ClothingTargetType.Outfit).map((c) => { - const s = filterCategoryId === c.id; - return ( setFilterCategoryId(s ? null : c.id)}>{c.name}); - })} - - - {tags.filter((t) => t.type === ClothingTargetType.Outfit).map((t) => { - const s = filterTagIds.includes(t.id); - return ( setFilterTagIds((p) => s ? p.filter((id) => id !== t.id) : [...p, t.id])}>{t.name}); - })} - - - + + + + + + + 筛选 + + { + setFilterCategoryId(null); + setFilterTagIds([]); + setKeyword(""); + }} + > + + 重置 + + + + + + + + + + + setFilterCategoryId(null)} + > + + 全部 + + + {categories + .filter((c) => c.type === ClothingTargetType.Outfit) + .map((c) => { + const s = filterCategoryId === c.id; + return ( + setFilterCategoryId(s ? null : c.id)} + > + + {c.name} + + + ); + })} + + + + + + {tags + .filter((t) => t.type === ClothingTargetType.Outfit) + .map((t) => { + const s = filterTagIds.includes(t.id); + return ( + + setFilterTagIds((p) => (s ? p.filter((id) => id !== t.id) : [...p, t.id])) + } + > + + {t.name} + + + ); + })} + + + + + + - router.push("/clothes/outfit/edit" as any)}> + router.push("/clothes/outfit/edit" as any)} + > + + - setActiveItem(null)} + setActiveItem(null)} items={[ - { icon: , label: "编辑", onPress: () => { const a = activeItem; setActiveItem(null); if (a) router.push(`/clothes/outfit/edit?id=${a.id}` as any); } }, - { icon: , label: "删除", onPress: handleDeleteRequest }, + { + icon: , + label: "编辑", + onPress: () => { + const a = activeItem; + setActiveItem(null); + if (a) router.push(`/clothes/outfit/edit?id=${a.id}` as any); + }, + }, + { + icon: , + label: "删除", + onPress: handleDeleteRequest, + }, ]} /> - { if (!v) setDeleteConfirm(null); }}> - 删除搭配确认删除「{deleteConfirm?.name}」吗?衣服本身不会被删除。 - - + { + if (!v) setDeleteConfirm(null); + }} + > + + + + 删除搭配 + + 确认删除「{deleteConfirm?.name}」吗?衣服本身不会被删除。 + + + + + + + + ); 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 f416c097..d7707a5a 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 @@ -38,102 +38,247 @@ export default function ClothingDetail() { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const item = useMemo(() => clothes.find((c) => c.id === clothesId) ?? null, [clothesId, clothes]); - const categoryName = useMemo(() => { if (!item?.category_id) return "未分类"; return categories.find((c) => c.id === item.category_id)?.name ?? "未分类"; }, [categories, item?.category_id]); - const owner = useMemo(() => members.find((m) => m.user_id === item?.owner_id) ?? null, [members, item?.owner_id]); + 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.clothes_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]); const loadData = useCallback(async () => { - if (!familyId || !clothesId) return; setPageLoading(true); - try { await Promise.all([fetchClothes(familyId), fetchOutfits(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Clothes), fetchTags(familyId, ClothingTargetType.Clothes), fetchTagRelations(familyId, ClothingTargetType.Clothes, clothesId), fetchMembers(familyId)]); } - finally { setPageLoading(false); } - }, [familyId, clothesId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTagRelations, fetchTags, fetchMembers]); - useEffect(() => { loadData(); }, [loadData]); - useFocusEffect(useCallback(() => { if (familyId && clothesId) loadData(); }, [familyId, loadData, clothesId])); + if (!familyId || !clothesId) return; + setPageLoading(true); + try { + await Promise.all([ + fetchClothes(familyId), + fetchOutfits(familyId), + fetchOutfitClothes(familyId), + fetchCategories(familyId, ClothingTargetType.Clothes), + fetchTags(familyId, ClothingTargetType.Clothes), + fetchTagRelations(familyId, ClothingTargetType.Clothes, clothesId), + fetchMembers(familyId), + ]); + } finally { + setPageLoading(false); + } + }, [ + familyId, + clothesId, + fetchCategories, + fetchClothes, + fetchOutfitClothes, + fetchOutfits, + fetchTagRelations, + fetchTags, + fetchMembers, + ]); + useEffect(() => { + loadData(); + }, [loadData]); + useFocusEffect( + useCallback(() => { + if (familyId && clothesId) loadData(); + }, [familyId, loadData, clothesId]), + ); const handleDeleteConfirm = useCallback(async () => { - if (!familyId || !item || isLoading) return; setShowDeleteDialog(false); startLoading("删除中..."); + if (!familyId || !item || isLoading) return; + setShowDeleteDialog(false); + startLoading("删除中..."); try { 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.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; } + 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)); - notifyModuleOperation({ module: "衣服穿搭", action: "删除衣服", target: item.name, detail: relatedIds.length > 0 ? `同时删除 ${relatedIds.length} 个关联搭配` : undefined, sourceTable: "b_clothing_clothes", sourceId: item.id }); - stopLoading({ type: "success", message: "已删除" }); router.replace("/clothes/home" as any); - } catch { stopLoading({ type: "error", message: "删除失败" }); } - }, [deleteClothesBundle, familyId, isLoading, item, notifyModuleOperation, router, startLoading, stopLoading]); + state.outfits + .filter((o) => relatedIds.includes(o.id)) + .forEach((o) => deleteS3FileQuietly(o.cover_file_key)); + notifyModuleOperation({ + module: "衣服穿搭", + action: "删除衣服", + target: item.name, + detail: relatedIds.length > 0 ? `同时删除 ${relatedIds.length} 个关联搭配` : undefined, + sourceTable: "b_clothing_clothes", + sourceId: item.id, + }); + stopLoading({ type: "success", message: "已删除" }); + router.replace("/clothes/home" as any); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, [ + deleteClothesBundle, + familyId, + isLoading, + item, + notifyModuleOperation, + router, + startLoading, + stopLoading, + ]); return ( - + {!item && !pageLoading ? ( - - ) : item && (<> - {/* 图片 - 无边距全宽 */} - - - + + ) : ( + item && ( + <> + {/* 图片 - 无边距全宽 */} + + + - {/* 信息区 */} - - {item.name} - {!!item.brand && {item.brand}} + {/* 信息区 */} + + + {item.name} + + {!!item.brand && {item.brand}} - {/* 所属 + 分类 + 标签 */} - - {owner ? ( - - - {owner.avatar ? : null} - {owner.nickname?.charAt(0)} - - {owner.nickname} - - ) : ( - 未指定所属 - )} - {categoryName === "未分类" ? ( - 未分类 - ) : ( - {categoryName} - )} - {selectedTags.length > 0 && ( - - {selectedTags.map((tag) => ( - - {tag.name} + {/* 所属 + 分类 + 标签 */} + + {owner ? ( + + + {owner.avatar ? ( + + ) : null} + + {owner.nickname?.charAt(0)} + + + {owner.nickname} - ))} + ) : ( + 未指定所属 + )} + {categoryName === "未分类" ? ( + 未分类 + ) : ( + + {categoryName} + + )} + {selectedTags.length > 0 && ( + + {selectedTags.map((tag) => ( + + + {tag.name} + + + ))} + + )} + + + + {/* 备注 */} + {!!item.remark && ( + + 备注 + {item.remark} )} - - - {/* 备注 */} - {!!item.remark && ( - - 备注 - {item.remark} - - )} - - {/* 操作按钮 */} - - - - - )} + {/* 操作按钮 */} + + + + + + ) + )} - 删除衣服确认删除「{item?.name}」吗? + + + + + 删除衣服 + 确认删除「{item?.name}」吗? + + + + + + + ); diff --git a/code/app/app-rn/src/app/clothes/edit/index.tsx b/code/app/app-rn/src/app/clothes/edit/index.tsx index 4d32d306..d49d0df0 100644 --- a/code/app/app-rn/src/app/clothes/edit/index.tsx +++ b/code/app/app-rn/src/app/clothes/edit/index.tsx @@ -3,11 +3,7 @@ import { Text, TouchableOpacity, View } from "react-native"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import ImagePicker from "react-native-image-crop-picker"; import { Avatar, Button, Input, Label, TextArea, TextField } from "heroui-native"; -import { - CheckIcon, - ImageIcon, - SaveIcon, -} from "lucide-react-native"; +import { CheckIcon, ImageIcon, SaveIcon } from "lucide-react-native"; import { toast } from "sonner-native"; import PageLayout from "@/layouts/PageLayout"; import LayoutView from "@/components/layout/view"; @@ -99,7 +95,8 @@ export default function ClothingEdit() { ); const clothesTags = useMemo( - () => tags.filter((item) => item.type === ClothingTargetType.Clothes && item.parent_id !== null), + () => + tags.filter((item) => item.type === ClothingTargetType.Clothes && item.parent_id !== null), [tags], ); @@ -141,11 +138,7 @@ export default function ClothingEdit() { } setForm(createEditForm(item)); setSelectedTagIds( - state.tagRelations - .filter( - (row) => row.clothes_id === editId, - ) - .map((row) => row.tag_id), + state.tagRelations.filter((row) => row.clothes_id === editId).map((row) => row.tag_id), ); } else { // 新增时默认所属为当前用户 @@ -232,9 +225,7 @@ export default function ClothingEdit() { return; } - const currentRelations = tagRelations.filter( - (row) => row.clothes_id === targetId, - ); + const currentRelations = tagRelations.filter((row) => row.clothes_id === targetId); const currentTagIds = new Set(currentRelations.map((row) => row.tag_id)); const relationDeletes = currentRelations .filter((row) => !selectedTagIds.includes(row.tag_id)) @@ -347,7 +338,7 @@ export default function ClothingEdit() { (member: ClothingFamilyMember) => { const selected = form.ownerId === member.user_id; - console.log(member) + console.log(member); return ( {member.avatar ? : null} - {(member.nickname || "?").charAt(0)} + + {(member.nickname || "?").charAt(0)} + {member.nickname || `用户${member.user_id}`} @@ -472,9 +465,7 @@ export default function ClothingEdit() { setForm((prev) => ({ ...prev, categoryId: null }))} > @@ -494,9 +485,7 @@ export default function ClothingEdit() { setForm((prev) => ({ ...prev, ownerId: null }))} > @@ -515,9 +504,7 @@ export default function ClothingEdit() { {clothesTagGroups.length > 0 && ( <> 标签 - - {clothesTagGroups.map(renderTagGroup)} - + {clothesTagGroups.map(renderTagGroup)} )} diff --git a/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx b/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx index 484d27cf..0954016e 100644 --- a/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx +++ b/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx @@ -40,130 +40,339 @@ export default function OutfitDetail() { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const item = useMemo(() => outfits.find((o) => o.id === outfitId) ?? null, [outfitId, outfits]); - const categoryName = useMemo(() => { if (!item?.category_id) return "未分类"; return categories.find((c) => c.id === item.category_id)?.name ?? "未分类"; }, [categories, item?.category_id]); - const owner = useMemo(() => members.find((m) => m.user_id === item?.owner_id) ?? null, [members, item?.owner_id]); - const details = useMemo(() => outfitClothes.filter((r) => r.outfit_id === outfitId).sort((a, b) => a.z_index - b.z_index || a.sort_number - b.sort_number).map((r) => ({ ...r, clothes: clothes.find((c) => c.id === r.clothes_id) })).filter((r) => r.clothes), [clothes, outfitClothes, outfitId]); - const selectedTags = useMemo(() => { const ids = new Set(tagRelations.filter((r) => r.target_type === ClothingTargetType.Outfit && r.target_id === outfitId).map((r) => r.tag_id)); return tags.filter((t) => ids.has(t.id)); }, [outfitId, tagRelations, tags]); - const getCoverKey = useCallback(() => { if (!item) return ""; if (item.cover_file_key) return item.cover_file_key; return details[0]?.clothes?.image_file_key ?? ""; }, [details, item]); + 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 details = useMemo( + () => + outfitClothes + .filter((r) => r.outfit_id === outfitId) + .sort((a, b) => a.z_index - b.z_index || a.sort_number - b.sort_number) + .map((r) => ({ ...r, clothes: clothes.find((c) => c.id === r.clothes_id) })) + .filter((r) => r.clothes), + [clothes, outfitClothes, outfitId], + ); + const selectedTags = useMemo(() => { + const ids = new Set( + tagRelations + .filter((r) => r.target_type === ClothingTargetType.Outfit && r.target_id === outfitId) + .map((r) => r.tag_id), + ); + return tags.filter((t) => ids.has(t.id)); + }, [outfitId, tagRelations, tags]); + const getCoverKey = useCallback(() => { + if (!item) return ""; + if (item.cover_file_key) return item.cover_file_key; + return details[0]?.clothes?.image_file_key ?? ""; + }, [details, item]); const loadData = useCallback(async () => { - if (!familyId || !outfitId) return; setPageLoading(true); - try { await Promise.all([fetchOutfits(familyId), fetchClothes(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Outfit), fetchTags(familyId, ClothingTargetType.Outfit), fetchTagRelations(familyId, ClothingTargetType.Outfit, outfitId), fetchMembers(familyId)]); } - finally { setPageLoading(false); } - }, [familyId, outfitId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTags, fetchTagRelations, fetchMembers]); - useEffect(() => { loadData(); }, [loadData]); - useFocusEffect(useCallback(() => { if (familyId && outfitId) loadData(); }, [familyId, loadData, outfitId])); + if (!familyId || !outfitId) return; + setPageLoading(true); + try { + await Promise.all([ + fetchOutfits(familyId), + fetchClothes(familyId), + fetchOutfitClothes(familyId), + fetchCategories(familyId, ClothingTargetType.Outfit), + fetchTags(familyId, ClothingTargetType.Outfit), + fetchTagRelations(familyId, ClothingTargetType.Outfit, outfitId), + fetchMembers(familyId), + ]); + } finally { + setPageLoading(false); + } + }, [ + familyId, + outfitId, + fetchCategories, + fetchClothes, + fetchOutfitClothes, + fetchOutfits, + fetchTags, + fetchTagRelations, + fetchMembers, + ]); + useEffect(() => { + loadData(); + }, [loadData]); + useFocusEffect( + useCallback(() => { + if (familyId && outfitId) loadData(); + }, [familyId, loadData, outfitId]), + ); const handleDeleteConfirm = useCallback(async () => { - if (!familyId || !item || isLoading) return; setShowDeleteDialog(false); startLoading("删除中..."); + if (!familyId || !item || isLoading) return; + setShowDeleteDialog(false); + startLoading("删除中..."); try { const state = useClothingStore.getState(); const d = state.outfitClothes.filter((r) => 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.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); - } catch { stopLoading({ type: "error", message: "删除失败" }); } - }, [familyId, isLoading, item, notifyModuleOperation, router, saveOutfitBundle, startLoading, stopLoading]); + 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); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, [ + familyId, + isLoading, + item, + notifyModuleOperation, + router, + saveOutfitBundle, + startLoading, + stopLoading, + ]); return ( - + {!item && !pageLoading ? ( - - ) : item && (<> - {/* 封面图 */} - - - - - {/* 信息区 */} - - {item.name} - - {/* 统计 + 标签 - 横向排列 */} - {/* 所属 + 件数 + 分类 + 标签 */} - - {owner ? ( - - - {owner.avatar ? : null} - {owner.nickname?.charAt(0)} - - {owner.nickname} - - ) : ( - 未指定所属 - )} - {details.length} 件衣服 - {categoryName === "未分类" ? ( - 未分类 - ) : ( - {categoryName} - )} - {selectedTags.map((tag) => ( - - {tag.name} - - ))} - - - - {/* 包含衣服 - 2列网格 */} - - 包含衣服 · {details.length} 件 - {details.length > 0 ? ( - - {(() => { - const left = details.filter((_, i) => i % 2 === 0); - const right = details.filter((_, i) => i % 2 === 1); - return ( - <> - {left.map((d) => ( - router.push(`/clothes/detail/${d.clothes_id}` as any)}> - - - {d.z_index} - - {d.clothes?.name} - - ))} - {right.map((d) => ( - router.push(`/clothes/detail/${d.clothes_id}` as any)}> - - - {d.z_index} - - {d.clothes?.name} - - ))} - - ); - })()} + + ) : ( + item && ( + <> + {/* 封面图 */} + + - ) : (暂无)} - - {/* 备注 */} - {!!item.remark && ( - - 备注 - {item.remark} - - )} + {/* 信息区 */} + + + {item.name} + - {/* 操作按钮 */} - - - - - )} + {/* 统计 + 标签 - 横向排列 */} + {/* 所属 + 件数 + 分类 + 标签 */} + + {owner ? ( + + + {owner.avatar ? ( + + ) : null} + + {owner.nickname?.charAt(0)} + + + {owner.nickname} + + ) : ( + 未指定所属 + )} + {details.length} 件衣服 + {categoryName === "未分类" ? ( + 未分类 + ) : ( + + {categoryName} + + )} + {selectedTags.map((tag) => ( + + + {tag.name} + + + ))} + + + + {/* 包含衣服 - 2列网格 */} + + + 包含衣服 · {details.length} 件 + + {details.length > 0 ? ( + + {(() => { + const left = details.filter((_, i) => i % 2 === 0); + const right = details.filter((_, i) => i % 2 === 1); + return ( + <> + + {left.map((d) => ( + + router.push(`/clothes/detail/${d.clothes_id}` as any) + } + > + + + + + {d.z_index} + + + + + {d.clothes?.name} + + + ))} + + + {right.map((d) => ( + + router.push(`/clothes/detail/${d.clothes_id}` as any) + } + > + + + + + {d.z_index} + + + + + {d.clothes?.name} + + + ))} + + + ); + })()} + + ) : ( + 暂无 + )} + + + {/* 备注 */} + {!!item.remark && ( + + 备注 + {item.remark} + + )} + + {/* 操作按钮 */} + + + + + + ) + )} - 删除搭配确认删除「{item?.name}」吗?衣服本身不会被删除。 + + + + + 删除搭配 + + 确认删除「{item?.name}」吗?衣服本身不会被删除。 + + + + + + + + ); diff --git a/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx b/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx index 323096a2..6ef32f58 100644 --- a/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx +++ b/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx @@ -1,8 +1,26 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, NativeSyntheticEvent, NativeScrollEvent, PanResponder, ScrollView, Text, TouchableOpacity, View } from "react-native"; +import { + ActivityIndicator, + NativeSyntheticEvent, + NativeScrollEvent, + PanResponder, + ScrollView, + Text, + TouchableOpacity, + View, +} from "react-native"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { captureRef } from "react-native-view-shot"; -import { Avatar, Button, Card, Input, Label, TextArea, TextField, useThemeColor } from "heroui-native"; +import { + Avatar, + Button, + Card, + Input, + Label, + TextArea, + TextField, + useThemeColor, +} from "heroui-native"; import { BringToFrontIcon, FilterIcon, @@ -184,14 +202,24 @@ export default function ClothingOutfitEdit() { const [clothesFilterOwnerId, setClothesFilterOwnerId] = useState(null); const [loadingMoreClothes, setLoadingMoreClothes] = useState(false); - const clothesCategories = useMemo(() => categories.filter((c) => c.type === ClothingTargetType.Clothes), [categories]); - const clothesTags = useMemo(() => tags.filter((t) => t.type === ClothingTargetType.Clothes), [tags]); + const clothesCategories = useMemo( + () => categories.filter((c) => c.type === ClothingTargetType.Clothes), + [categories], + ); + const clothesTags = useMemo( + () => tags.filter((t) => t.type === ClothingTargetType.Clothes), + [tags], + ); const clothesTagMap = useMemo(() => { const map = new Map>(); - 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); - }); + 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]); @@ -207,9 +235,22 @@ export default function ClothingOutfitEdit() { if (!kw) return true; return `${item.name} ${item.brand ?? ""}`.toLowerCase().includes(kw); }); - }, [clothes, clothesKeyword, clothesFilterCatId, clothesFilterTagIds, clothesFilterOwnerId, clothesTagMap]); + }, [ + clothes, + clothesKeyword, + clothesFilterCatId, + clothesFilterTagIds, + clothesFilterOwnerId, + clothesTagMap, + ]); - const clothesFilterCount = useMemo(() => { let c = 0; if (clothesFilterCatId) c++; if (clothesFilterTagIds.length > 0) c++; if (clothesFilterOwnerId) c++; return c; }, [clothesFilterCatId, clothesFilterTagIds, clothesFilterOwnerId]); + const clothesFilterCount = useMemo(() => { + let c = 0; + if (clothesFilterCatId) c++; + if (clothesFilterTagIds.length > 0) c++; + if (clothesFilterOwnerId) c++; + return c; + }, [clothesFilterCatId, clothesFilterTagIds, clothesFilterOwnerId]); const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]); const outfitCategories = useMemo( @@ -275,11 +316,7 @@ export default function ClothingOutfitEdit() { setOwnerId(outfit.owner_id ?? null); setRemark(outfit.remark ?? ""); setSelectedTagIds( - state.tagRelations - .filter( - (row) => row.outfit_id === editId, - ) - .map((row) => row.tag_id), + state.tagRelations.filter((row) => row.outfit_id === editId).map((row) => row.tag_id), ); setItems( details.map((row, index) => ({ @@ -446,7 +483,9 @@ export default function ClothingOutfitEdit() { > {member.avatar ? : null} - {(member.nickname || "?").charAt(0)} + + {(member.nickname || "?").charAt(0)} + {member.nickname || `用户${member.user_id}`} @@ -540,9 +579,7 @@ export default function ClothingOutfitEdit() { return; } - const currentRelations = state.tagRelations.filter( - (row) => row.outfit_id === targetId, - ); + const currentRelations = state.tagRelations.filter((row) => row.outfit_id === targetId); const currentTagIds = new Set(currentRelations.map((row) => row.tag_id)); const relationDeletes = currentRelations .filter((row) => !selectedTagIds.includes(row.tag_id)) @@ -744,9 +781,7 @@ export default function ClothingOutfitEdit() { }`} onPress={() => setCategoryId(null)} > - + 不选择 @@ -767,9 +802,7 @@ export default function ClothingOutfitEdit() { }`} onPress={() => setOwnerId(null)} > - + 不指定 @@ -782,24 +815,42 @@ export default function ClothingOutfitEdit() { {outfitTagGroups.length > 0 && ( <> 标签 - - {outfitTagGroups.map(renderTagGroup)} - + {outfitTagGroups.map(renderTagGroup)} )} {/* 画布操作栏 */} - - - - @@ -808,15 +859,18 @@ export default function ClothingOutfitEdit() { {clothesById.get(selectedItem.clothesId)?.name} )} - {/* 画布 */} - - {canvasContent} - + {canvasContent} - + + 重置 + + + + + + + setShowDateRange(true)} + > + + {draftFilter.startDate} 至 {draftFilter.endDate || draftFilter.startDate} + + + + + + + + + {[ + { key: "all" as const, label: "全部" }, + { key: FinanceType.Expense, label: "支出" }, + { key: FinanceType.Income, label: "收入" }, + ].map((option) => { + const selected = draftFilter.type === option.key; + return ( + + setDraftFilter((prev) => ({ + ...prev, + type: option.key as FilterState["type"], + })) + } + > + + {option.label} + + + ); + })} + + + + + + + setDraftFilter((prev) => ({ ...prev, memberId: null }))} + > + + 全部 + + + {members.map((member) => { + const selected = draftFilter.memberId === member.id; + return ( + + setDraftFilter((prev) => ({ + ...prev, + memberId: prev.memberId === member.id ? null : member.id, + })) + } + > + + {member.nickname} + + + ); + })} + + + + + + {/* 全部 */} + + setDraftFilter((prev) => ({ ...prev, categoryId: null }))} + > + + 全部 + + + + {/* 支出 */} + 支出 + + {categories + .filter((c) => c.type === FinanceType.Expense) + .map((cat) => { + const selected = draftFilter.categoryId === cat.id; + return ( + + setDraftFilter((prev) => ({ + ...prev, + categoryId: prev.categoryId === cat.id ? null : cat.id, + })) + } + > + + + {cat.name} + + + ); + })} + + {/* 收入 */} + 收入 + + {categories + .filter((c) => c.type === FinanceType.Income) + .map((cat) => { + const selected = draftFilter.categoryId === cat.id; + return ( + + setDraftFilter((prev) => ({ + ...prev, + categoryId: prev.categoryId === cat.id ? null : cat.id, + })) + } + > + + + {cat.name} + + + ); + })} + + + + + + setDraftFilter((prev) => ({ ...prev, keyword }))} + placeholder="按备注模糊搜索" + returnKeyType="search" + /> + + + + + + + @@ -926,54 +887,60 @@ function Section({ ); } -function LegendDot({ color, label }: { color: string; label: string }) { - return ( - - - {label} - - ); -} - -/* ============== 横向柱状图(纯 View 实现)============== */ +/* ============== 收支条(紧凑版)============== */ interface BarItem { label: string; - stacks: { value: number; color: string }[]; + expense: number; + income: number; } -function DailyBarChart({ data, maxVal: rawMax }: { data: BarItem[]; maxVal: number }) { - const maxVal = rawMax || 1; +function formatCompact(v: number) { + if (v >= 10000) return (v / 10000).toFixed(1) + "w"; + return v >= 100 ? Math.round(v).toString() : v.toFixed(v % 1 ? 1 : 0); +} + +function DailyBarChart({ data, maxVal }: { data: BarItem[]; maxVal: number }) { + if (data.length === 0) return null; + const m = maxVal || 1; + const maxH = 56; return ( - - {data.map((item, i) => ( - - {item.label} - {item.stacks - .filter((s) => s.value > 0) - .map((stack, j) => { - const pct = Math.min((stack.value / maxVal) * 100, 100); - return ( - - {/* 背景轨道 */} - - - - - {formatMoney(stack.value)} - - - ); - })} - - ))} - + + + {data.map((item, i) => { + const eh = Math.max(2, (item.expense / m) * maxH); + const ih = Math.max(2, (item.income / m) * maxH); + const showExpense = item.expense > 0; + const showIncome = item.income > 0; + return ( + + {/* 数值 */} + {showExpense && ( + + {formatCompact(item.expense)} + + )} + {showIncome && ( + + {formatCompact(item.income)} + + )} + {/* 柱子 */} + + {showExpense && ( + + )} + {showIncome && ( + + )} + + {/* 日期 */} + {item.label} + + ); + })} + + ); } diff --git a/code/app/app-rn/src/app/finance/(tabs)/auto-rules/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/auto-rules/index.tsx index c1333771..0ad874e9 100644 --- a/code/app/app-rn/src/app/finance/(tabs)/auto-rules/index.tsx +++ b/code/app/app-rn/src/app/finance/(tabs)/auto-rules/index.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Alert, ScrollView, Text, TouchableOpacity, View } from "react-native"; import { Button, Label, TextField } from "heroui-native"; import BottomSheet from "@/components/BottomSheet"; @@ -12,7 +13,10 @@ import { REPEAT_LABELS, WEEK_DAYS } from "@/helpers/financeAuto"; import { useListLoading, useLoading } from "@/hooks/useLoading"; import { LoadingOverlay } from "@/components/LoadingOverlay"; -const moneyFormatter = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const moneyFormatter = new Intl.NumberFormat("zh-CN", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); function formatMoney(value: number) { return moneyFormatter.format(Math.abs(value)); @@ -39,7 +43,10 @@ function formatRepeatDate(repeatType: number, repeatDate: string): string { } case FinanceAutoRepeatType.Monthly: { if (!raw) return "未设置"; - const dates = raw.split(",").map(Number).sort((a, b) => a - b); + const dates = raw + .split(",") + .map(Number) + .sort((a, b) => a - b); return exclude ? `排除 ${dates.join("、")}号` : `${dates.join("、")}号`; } default: @@ -78,6 +85,7 @@ const FILTER_TABS: { key: FilterTab; label: string }[] = [ ]; export default function AutoRulesPage() { + const insets = useSafeAreaInsets(); const familyId = useFamilyStore((s) => s.getFamilyId()); const userId = useUserStore((s) => s.getUserId()); const categories = useFinanceStore((s) => s.categories); @@ -102,11 +110,15 @@ export default function AutoRulesPage() { useEffect(() => { if (!familyId) return; - runInitial(async () => { loadData(); }); + runInitial(async () => { + loadData(); + }); }, [familyId]); const handleRefresh = useCallback(() => { - runRefresh(async () => { loadData(); }); + runRefresh(async () => { + loadData(); + }); }, [loadData, runRefresh]); // 筛选后的规则 @@ -117,7 +129,9 @@ export default function AutoRulesPage() { weekly: FinanceAutoRepeatType.Weekly, monthly: FinanceAutoRepeatType.Monthly, }; - return autoRules.filter((r) => r.repeat_type === typeMap[filterTab as Exclude]); + return autoRules.filter( + (r) => r.repeat_type === typeMap[filterTab as Exclude], + ); }, [autoRules, filterTab]); // ===== 编辑/新建 ===== @@ -162,9 +176,10 @@ export default function AutoRulesPage() { startLoading("保存中..."); try { - const finalRepeatDate = editForm.excludeMode && editForm.repeatDate - ? `!${editForm.repeatDate}` - : editForm.repeatDate; + const finalRepeatDate = + editForm.excludeMode && editForm.repeatDate + ? `!${editForm.repeatDate}` + : editForm.repeatDate; const base: Partial = { id: editingRuleId ?? undefined, @@ -192,85 +207,140 @@ export default function AutoRulesPage() { } else { stopLoading({ type: "error", message: "保存失败" }); } - } catch { stopLoading({ type: "error", message: "保存失败" }); } - }, [familyId, userId, editForm, editingRuleId, categories, saveAutoRule, startLoading, stopLoading]); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + familyId, + userId, + editForm, + editingRuleId, + categories, + saveAutoRule, + startLoading, + stopLoading, + ]); - const handleDelete = useCallback((rule: FinanceAuto) => { - Alert.alert("确认删除", `确定要删除「${formatRepeatDate(rule.repeat_type, rule.repeat_date ?? "")}」的规则吗?`, [ - { text: "取消", style: "cancel" }, - { - text: "删除", style: "destructive", - onPress: async () => { - startLoading("删除中..."); - const ok = await saveAutoRule(rule, true); - if (ok) await fetchAutoRules(familyId); - stopLoading(ok ? { type: "success", message: "已删除" } : { type: "error", message: "删除失败" }); - }, - }, - ]); - }, [saveAutoRule, startLoading, stopLoading]); + const handleDelete = useCallback( + (rule: FinanceAuto) => { + Alert.alert( + "确认删除", + `确定要删除「${formatRepeatDate(rule.repeat_type, rule.repeat_date ?? "")}」的规则吗?`, + [ + { text: "取消", style: "cancel" }, + { + text: "删除", + style: "destructive", + onPress: async () => { + startLoading("删除中..."); + const ok = await saveAutoRule(rule, true); + if (ok) await fetchAutoRules(familyId); + stopLoading( + ok + ? { type: "success", message: "已删除" } + : { type: "error", message: "删除失败" }, + ); + }, + }, + ], + ); + }, + [saveAutoRule, startLoading, stopLoading], + ); - const getCategory = useCallback((id: number): FinanceCategory | undefined => { - return categories.find((c) => c.id === id); - }, [categories]); + const getCategory = useCallback( + (id: number): FinanceCategory | undefined => { + return categories.find((c) => c.id === id); + }, + [categories], + ); const selectedCategory = useMemo(() => { if (!editForm.categoryId) return null; return categories.find((c) => c.id === editForm.categoryId) ?? null; }, [editForm.categoryId, categories]); - const renderHeader = useCallback(() => ( - - - 自动记账规则 - 管理周期性自动记账 - - - - {FILTER_TABS.map((tab) => { - const active = filterTab === tab.key; - return ( - setFilterTab(tab.key)} - > - {tab.label} - - ); - })} - - - ), [filterTab, openNewEditor]); - - const sections = useMemo(() => [{ title: "", expenseTotal: 0, incomeTotal: 0, data: filteredRules as any }], [filteredRules]); - - const renderItem = useCallback(({ item: rule }: { item: FinanceAuto }) => { - const cat = getCategory(rule.category_id); - const isIncome = rule.type === FinanceType.Income; - const displayTitle = rule.title?.trim() || cat?.name || "未命名"; - return ( - - - + const renderHeader = useCallback( + () => ( + + + 自动记账规则 + 管理周期性自动记账 - - {displayTitle} - - {REPEAT_LABELS[rule.repeat_type]} + + + {FILTER_TABS.map((tab) => { + const active = filterTab === tab.key; + return ( + setFilterTab(tab.key)} + > + + {tab.label} + + + ); + })} + + + ), + [filterTab, openNewEditor], + ); + + const sections = useMemo( + () => [{ title: "", expenseTotal: 0, incomeTotal: 0, data: filteredRules as any }], + [filteredRules], + ); + + const renderItem = useCallback( + ({ item: rule }: { item: FinanceAuto }) => { + const cat = getCategory(rule.category_id); + const isIncome = rule.type === FinanceType.Income; + const displayTitle = rule.title?.trim() || cat?.name || "未命名"; + return ( + + + + + + {displayTitle} + + + + {REPEAT_LABELS[rule.repeat_type]} + + + + + {isIncome ? "+" : "-"} + {formatMoney(Number(rule.amount))} + + openEditEditor(rule)} + > + + + handleDelete(rule)} + > + + - - {isIncome ? "+" : "-"}{formatMoney(Number(rule.amount))} - - openEditEditor(rule)}> - - - handleDelete(rule)}> - - - - ); - }, [getCategory, openEditEditor, handleDelete]); + ); + }, + [getCategory, openEditEditor, handleDelete], + ); return ( @@ -287,7 +357,13 @@ export default function AutoRulesPage() { ListEmptyComponent={() => ( t.key === filterTab)?.label}规则`)} + title={ + loading + ? "正在加载" + : filterTab === "all" + ? "暂无自动记账规则" + : `暂无${FILTER_TABS.find((t) => t.key === filterTab)?.label}规则` + } description={loading ? "请稍候" : "点击右下角 + 添加一条规则"} center="screen" /> @@ -296,7 +372,8 @@ export default function AutoRulesPage() { {/* 悬浮新增按钮 */} @@ -305,13 +382,16 @@ export default function AutoRulesPage() { {/* ===== 编辑/新建 BottomSheet ===== */} - + + {editingRuleId ? "编辑规则" : "新增规则"} - {/* 标题 */} @@ -335,7 +415,11 @@ export default function AutoRulesPage() { > {selectedCategory ? ( - + {selectedCategory.name} ) : ( @@ -350,7 +434,9 @@ export default function AutoRulesPage() { setEditForm((p) => ({ ...p, amount: v.replace(/[^0-9.]/g, "") }))} + onChangeText={(v) => + setEditForm((p) => ({ ...p, amount: v.replace(/[^0-9.]/g, "") })) + } placeholder="0.00" keyboardType="decimal-pad" /> @@ -373,11 +459,18 @@ export default function AutoRulesPage() { {Object.entries(REPEAT_LABELS).map(([k, v]) => { const sel = editForm.repeatType === Number(k); return ( - setEditForm((p) => ({ ...p, repeatType: Number(k), repeatDate: "" }))} + onPress={() => + setEditForm((p) => ({ ...p, repeatType: Number(k), repeatDate: "" })) + } > - {v} + + {v} + ); })} @@ -392,13 +485,21 @@ export default function AutoRulesPage() { className={`flex-1 py-2 rounded-md items-center ${!editForm.excludeMode ? "bg-white shadow-sm" : ""}`} onPress={() => setEditForm((p) => ({ ...p, excludeMode: false }))} > - 指定 + + 指定 + setEditForm((p) => ({ ...p, excludeMode: true }))} > - 排除 + + 排除 + @@ -406,22 +507,35 @@ export default function AutoRulesPage() { {/* 每天 */} {editForm.repeatType === FinanceAutoRepeatType.Daily && ( - + {editForm.excludeMode && ( {WEEK_DAYS.map((d) => { - const sel = (editForm.repeatDate || "").split(",").includes(String(d.value)); + const sel = (editForm.repeatDate || "") + .split(",") + .includes(String(d.value)); return ( - { const arr = (editForm.repeatDate || "").split(",").filter(Boolean); const idx = arr.indexOf(String(d.value)); - if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d.value)); - setEditForm((p) => ({ ...p, repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(",") })); + if (idx >= 0) arr.splice(idx, 1); + else arr.push(String(d.value)); + setEditForm((p) => ({ + ...p, + repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(","), + })); }} > - {d.label} + + {d.label} + ); })} @@ -433,21 +547,32 @@ export default function AutoRulesPage() { {/* 每周 */} {editForm.repeatType === FinanceAutoRepeatType.Weekly && ( - + {WEEK_DAYS.map((d) => { const sel = (editForm.repeatDate || "").split(",").includes(String(d.value)); return ( - { const arr = (editForm.repeatDate || "").split(",").filter(Boolean); const idx = arr.indexOf(String(d.value)); - if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d.value)); - setEditForm((p) => ({ ...p, repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(",") })); + if (idx >= 0) arr.splice(idx, 1); + else arr.push(String(d.value)); + setEditForm((p) => ({ + ...p, + repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(","), + })); }} > - {d.label} + + {d.label} + ); })} @@ -458,21 +583,32 @@ export default function AutoRulesPage() { {/* 每月 */} {editForm.repeatType === FinanceAutoRepeatType.Monthly && ( - + {Array.from({ length: 31 }, (_, i) => i + 1).map((d) => { const sel = (editForm.repeatDate || "").split(",").includes(String(d)); return ( - { const arr = (editForm.repeatDate || "").split(",").filter(Boolean); const idx = arr.indexOf(String(d)); - if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d)); - setEditForm((p) => ({ ...p, repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(",") })); + if (idx >= 0) arr.splice(idx, 1); + else arr.push(String(d)); + setEditForm((p) => ({ + ...p, + repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(","), + })); }} > - {d} + + {d} + ); })} @@ -496,42 +632,75 @@ export default function AutoRulesPage() { {/* ===== 分类选择 ===== */} - + + 选择分类 支出 - {categories.filter((c) => c.type === FinanceType.Expense).map((cat) => { - const sel = editForm.categoryId === cat.id; - return ( - { setEditForm((p) => ({ ...p, categoryId: cat.id })); setShowCategoryPicker(false); }} - > - - {cat.name} - - ); - })} + {categories + .filter((c) => c.type === FinanceType.Expense) + .map((cat) => { + const sel = editForm.categoryId === cat.id; + return ( + { + setEditForm((p) => ({ ...p, categoryId: cat.id })); + setShowCategoryPicker(false); + }} + > + + + {cat.name} + + + ); + })} 收入 - {categories.filter((c) => c.type === FinanceType.Income).map((cat) => { - const sel = editForm.categoryId === cat.id; - return ( - { setEditForm((p) => ({ ...p, categoryId: cat.id })); setShowCategoryPicker(false); }} - > - - {cat.name} - - ); - })} + {categories + .filter((c) => c.type === FinanceType.Income) + .map((cat) => { + const sel = editForm.categoryId === cat.id; + return ( + { + setEditForm((p) => ({ ...p, categoryId: cat.id })); + setShowCategoryPicker(false); + }} + > + + + {cat.name} + + + ); + })} - 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 9b84f72e..fdc3bbec 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,5 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Dimensions, Image, Modal, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + Alert, + Dimensions, + Image, + Modal, + Pressable, + ScrollView, + Text, + TouchableOpacity, + View, +} from "react-native"; import { Button, Label, TextField } from "heroui-native"; import BottomSheet from "@/components/BottomSheet"; import { router, useFocusEffect } from "expo-router"; @@ -52,6 +63,22 @@ function formatMoney(value: number) { return moneyFormatter.format(Math.abs(value)); } +const WEEK_NAMES = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; + +function formatSectionDate(dateStr: string) { + const d = dayjs(dateStr); + const today = dayjs().startOf("day"); + const diff = d.diff(today, "day"); + + if (diff === 0) return "今天"; + if (diff === -1) return "昨天"; + // 本周:周一为周首,同一周且在7天内 + const monday = today.subtract((today.day() + 6) % 7, "day"); + if (d.isAfter(monday.subtract(1, "day")) && d.isBefore(today)) return WEEK_NAMES[d.day()]; + if (d.isSame(today, "year")) return d.format("M/D"); + return d.format("YYYY/M/D"); +} + function createDefaultFilter(): FilterState { return { startDate: getStartOfMonth(), @@ -62,6 +89,7 @@ function createDefaultFilter(): FilterState { } export default function Home() { + const insets = useSafeAreaInsets(); const familyId = useFamilyStore((s) => s.getFamilyId()); const userId = useUserStore((s) => s.getUserId()); const categories = useFinanceStore((s) => s.categories); @@ -78,7 +106,11 @@ export default function Home() { const loadAutoBillIds = useFinanceStore((s) => s.loadAutoBillIds); const autoRunRef = useRef(false); const [showAutoSheet, setShowAutoSheet] = useState(false); - const [autoForm, setAutoForm] = useState({ repeatType: FinanceAutoRepeatType.Daily, repeatDate: "", title: "" }); + const [autoForm, setAutoForm] = useState({ + repeatType: FinanceAutoRepeatType.Daily, + repeatDate: "", + title: "", + }); const [autoBaseBill, setAutoBaseBill] = useState(null); const [autoExcludeMode, setAutoExcludeMode] = useState(false); @@ -274,7 +306,11 @@ export default function Home() { if (!activeBill) return; setAutoBaseBill(activeBill); // 先保存账单引用,再关闭菜单 closeActionMenu(); - setAutoForm({ repeatType: FinanceAutoRepeatType.Daily, repeatDate: "", title: activeBill?.category_name ?? "" }); + setAutoForm({ + repeatType: FinanceAutoRepeatType.Daily, + repeatDate: "", + title: activeBill?.category_name ?? "", + }); setAutoExcludeMode(false); setShowAutoSheet(true); }, [activeBill, closeActionMenu]); @@ -287,9 +323,8 @@ export default function Home() { } startLoading("保存中..."); try { - const finalRepeatDate = autoExcludeMode && autoForm.repeatDate - ? `!${autoForm.repeatDate}` - : autoForm.repeatDate; + const finalRepeatDate = + autoExcludeMode && autoForm.repeatDate ? `!${autoForm.repeatDate}` : autoForm.repeatDate; const ok = await saveAutoRule({ family_id: familyId, type: autoBaseBill.category_type, @@ -307,8 +342,19 @@ export default function Home() { } else { stopLoading({ type: "error", message: "保存失败" }); } - } catch { stopLoading({ type: "error", message: "保存失败" }); } - }, [familyId, autoBaseBill, userId, autoForm, autoExcludeMode, saveAutoRule, startLoading, stopLoading]); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + familyId, + autoBaseBill, + userId, + autoForm, + autoExcludeMode, + saveAutoRule, + startLoading, + stopLoading, + ]); const handleDeleteBill = useCallback(async () => { if (!activeBill || isLoading) return; @@ -387,23 +433,26 @@ export default function Home() { - - - 支出 - + + + 支出 + -{formatMoney(totals.expense)} - - 收入 - + + 收入 + {formatMoney(totals.income)} - - 结余 - = 0 ? "text-emerald-600" : "text-rose-500"}`}> - {totals.income - totals.expense >= 0 ? "+" : "-"}{formatMoney(totals.income - totals.expense)} + + 结余 + = 0 ? "text-emerald-600" : "text-rose-500"}`} + > + {totals.income - totals.expense >= 0 ? "+" : "-"} + {formatMoney(totals.income - totals.expense)} @@ -419,7 +468,7 @@ export default function Home() { - {dayjs(billSection.title).format("YYYY年MM月DD日")} + {formatSectionDate(billSection.title)} -{formatMoney(billSection.expenseTotal)} @@ -431,23 +480,25 @@ export default function Home() { }, []); const renderItem = useCallback( - ({ item, index, section }: { item: FinanceBill; index: number; section: { data: FinanceBill[] } }) => { + ({ + item, + index, + section, + }: { + item: FinanceBill; + index: number; + section: { data: FinanceBill[] }; + }) => { const isIncome = item.category_type === FinanceType.Income; const isOwnRecord = String(item.create_by ?? "") === String(userId ?? ""); const remark = item.remark?.trim(); const isFirst = index === 0; const isLast = index === section.data.length - 1; - const radiusClass = isFirst && isLast - ? "rounded-xl" - : isFirst - ? "rounded-t-xl" - : isLast - ? "rounded-b-xl" - : ""; + const radiusClass = + isFirst && isLast ? "rounded-xl" : isFirst ? "rounded-t-xl" : isLast ? "rounded-b-xl" : ""; - const memberAvatarUri = !isOwnRecord && item.member_avatar - ? buildFileUrl(item.member_avatar) || null - : null; + const memberAvatarUri = + !isOwnRecord && item.member_avatar ? buildFileUrl(item.member_avatar) || null : null; return ( - {item.category_name || "未分类"} + {remark || item.category_name || "未分类"} {isAutoBill(item.id) && ( @@ -483,18 +534,14 @@ export default function Home() { )} - {!!remark && ( - - {remark} - - )} - {isIncome ? "+" : "-"}{formatMoney(Number(item.amount))} + {isIncome ? "+" : "-"} + {formatMoney(Number(item.amount))} @@ -503,7 +550,6 @@ export default function Home() { [userId, isAutoBill], ); - return ( @@ -532,7 +578,8 @@ export default function Home() { {/* 添加记账 FAB */} router.push("/finance/add" as any)} > @@ -543,155 +590,155 @@ export default function Home() { - - - 筛选记账 - - - - 重置 - - - - - - - - setShowDateRange(true)} - > - - {draftFilter.startDate} 至 {draftFilter.endDate || draftFilter.startDate} - - - - - - - - setDraftFilter((prev) => ({ ...prev, keyword }))} - placeholder="按备注模糊搜索" - returnKeyType="search" - /> - - - - - {/* 全部 */} - - setDraftFilter((prev) => ({ ...prev, categoryId: null }))} - > - - 全部 - - - - {/* 支出 */} - 支出 - - {categories - .filter((c) => c.type === FinanceType.Expense) - .map((cat) => { - const selected = draftFilter.categoryId === cat.id; - return ( - - setDraftFilter((prev) => ({ - ...prev, - categoryId: prev.categoryId === cat.id ? null : cat.id, - })) - } - > - - - {cat.name} - - - ); - })} - - {/* 收入 */} - 收入 - - {categories - .filter((c) => c.type === FinanceType.Income) - .map((cat) => { - const selected = draftFilter.categoryId === cat.id; - return ( - - setDraftFilter((prev) => ({ - ...prev, - categoryId: prev.categoryId === cat.id ? null : cat.id, - })) - } - > - - - {cat.name} - - - ); - })} - - - - - - - + + 重置 + + + + + + + setShowDateRange(true)} + > + + {draftFilter.startDate} 至 {draftFilter.endDate || draftFilter.startDate} + + + + + + + + setDraftFilter((prev) => ({ ...prev, keyword }))} + placeholder="按备注模糊搜索" + returnKeyType="search" + /> + + + + + {/* 全部 */} + + setDraftFilter((prev) => ({ ...prev, categoryId: null }))} + > + + 全部 + + + + {/* 支出 */} + 支出 + + {categories + .filter((c) => c.type === FinanceType.Expense) + .map((cat) => { + const selected = draftFilter.categoryId === cat.id; + return ( + + setDraftFilter((prev) => ({ + ...prev, + categoryId: prev.categoryId === cat.id ? null : cat.id, + })) + } + > + + + {cat.name} + + + ); + })} + + {/* 收入 */} + 收入 + + {categories + .filter((c) => c.type === FinanceType.Income) + .map((cat) => { + const selected = draftFilter.categoryId === cat.id; + return ( + + setDraftFilter((prev) => ({ + ...prev, + categoryId: prev.categoryId === cat.id ? null : cat.id, + })) + } + > + + + {cat.name} + + + ); + })} + + + + + + + + @@ -768,7 +815,8 @@ export default function Home() { {/* 自动记账 Sheet */} - + + 自动记账 @@ -825,11 +873,19 @@ export default function Home() { {Object.entries(REPEAT_LABELS).map(([k, v]) => { const sel = autoForm.repeatType === Number(k); return ( - { setAutoForm({ repeatType: Number(k), repeatDate: "" }); setAutoExcludeMode(false); }} + onPress={() => { + setAutoForm({ repeatType: Number(k), repeatDate: "" }); + setAutoExcludeMode(false); + }} > - {v} + + {v} + ); })} @@ -844,13 +900,21 @@ export default function Home() { className={`flex-1 py-2 rounded-md items-center ${!autoExcludeMode ? "bg-white shadow-sm" : ""}`} onPress={() => setAutoExcludeMode(false)} > - 指定 + + 指定 + setAutoExcludeMode(true)} > - 排除 + + 排除 + @@ -864,18 +928,29 @@ export default function Home() { {autoExcludeMode && ( {WEEK_DAYS.map((d) => { - const sel = (autoForm.repeatDate || "").split(",").includes(String(d.value)); + 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((a, b) => Number(a) - Number(b)).join(",") })); + if (idx >= 0) arr.splice(idx, 1); + else arr.push(String(d.value)); + setAutoForm((p) => ({ + ...p, + repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(","), + })); }} > - {d.label} + + {d.label} + ); })} @@ -894,16 +969,25 @@ export default function Home() { {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((a, b) => Number(a) - Number(b)).join(",") })); + if (idx >= 0) arr.splice(idx, 1); + else arr.push(String(d.value)); + setAutoForm((p) => ({ + ...p, + repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(","), + })); }} > - {d.label} + + {d.label} + ); })} @@ -921,16 +1005,25 @@ export default function Home() { {Array.from({ length: 31 }, (_, i) => i + 1).map((d) => { const sel = (autoForm.repeatDate || "").split(",").includes(String(d)); return ( - { const arr = (autoForm.repeatDate || "").split(",").filter(Boolean); const idx = arr.indexOf(String(d)); - if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d)); - setAutoForm((p) => ({ ...p, repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(",") })); + if (idx >= 0) arr.splice(idx, 1); + else arr.push(String(d)); + setAutoForm((p) => ({ + ...p, + repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(","), + })); }} > - {d} + + {d} + ); })} @@ -940,8 +1033,17 @@ export default function Home() { - - + + diff --git a/code/app/app-rn/src/app/finance/add/index.tsx b/code/app/app-rn/src/app/finance/add/index.tsx index 03e7005a..dd4b7109 100644 --- a/code/app/app-rn/src/app/finance/add/index.tsx +++ b/code/app/app-rn/src/app/finance/add/index.tsx @@ -296,71 +296,73 @@ export default function Add() { {/* 记账 BottomSheet */} - amountInputRef.current?.focus()}> + amountInputRef.current?.focus()} + > - - {/* 第一行:分类 icon + 名称 | 日期 */} - {selectedCategory && ( - - - - - - - {selectedCategory.name} - + + {/* 第一行:分类 icon + 名称 | 日期 */} + {selectedCategory && ( + + + + - setShowDatePicker(true)}> - - {date} - - - - + + {selectedCategory.name} + - )} - - {/* 金额(居中、放大、无 label) */} - - + setShowDatePicker(true)}> + + {date} + + + + + )} - {/* 备注 */} - - - - + {/* 金额(居中、放大、无 label) */} + + + - {/* 按钮 */} - - + {!isEdit && ( + - {!isEdit && ( - - )} - + )} + diff --git a/code/app/app-rn/src/app/finance/category/index.tsx b/code/app/app-rn/src/app/finance/category/index.tsx index 63357661..e1f45171 100644 --- a/code/app/app-rn/src/app/finance/category/index.tsx +++ b/code/app/app-rn/src/app/finance/category/index.tsx @@ -106,7 +106,7 @@ export default function FinanceCategoryList() { name: img.filename || `finance_category_${Date.now()}.jpg`, type: img.mime || "image/jpeg", }; - const uploadRes = await s3UploadFileApi([file], familyId); + const uploadRes = await s3UploadFileApi([file], { familyId, module: "finance" }); if (!uploadRes.isSuccess) { toast.error(uploadRes.message || "图标上传失败", { id: toastId }); diff --git a/code/app/app-rn/src/app/finance/detail/[id].tsx b/code/app/app-rn/src/app/finance/detail/[id].tsx index 1c64205f..7ef7c3cd 100644 --- a/code/app/app-rn/src/app/finance/detail/[id].tsx +++ b/code/app/app-rn/src/app/finance/detail/[id].tsx @@ -118,7 +118,8 @@ export default function FinanceDetail() { - {isIncome ? "+" : "-"}{formatMoney(bill.amount)} + {isIncome ? "+" : "-"} + {formatMoney(bill.amount)} diff --git a/code/app/app-rn/src/app/meal/(tabs)/dishes/index.tsx b/code/app/app-rn/src/app/meal/(tabs)/dishes/index.tsx index 2080656b..18afd0e4 100644 --- a/code/app/app-rn/src/app/meal/(tabs)/dishes/index.tsx +++ b/code/app/app-rn/src/app/meal/(tabs)/dishes/index.tsx @@ -1,10 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Dimensions, FlatList, ScrollView, Text, TouchableOpacity, View } from "react-native"; import { router, useFocusEffect } from "expo-router"; import dayjs from "dayjs"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button, Dialog, Label, TextField } from "heroui-native"; -import { CheckIcon, FilterIcon, PencilIcon, PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native"; +import { + CheckIcon, + FilterIcon, + PencilIcon, + PlusIcon, + RotateCcwIcon, + Trash2Icon, +} from "lucide-react-native"; import { toast } from "sonner-native"; import BottomSheet from "@/components/BottomSheet"; import ContextMenu from "@/components/ContextMenu"; @@ -20,15 +27,24 @@ import { MealDish, MealStatus, MealTag } from "@/types/meal"; // 筛选类型 // ============================================================ -interface DishFilter { categoryId: number | null; tagIds: number[]; } -function createDefaultFilter(): DishFilter { return { categoryId: null, tagIds: [] }; } -function sameNumberSet(a: number[], b: number[]) { if (a.length !== b.length) return false; const set = new Set(a); return b.every((i) => set.has(i)); } +interface DishFilter { + categoryId: number | null; + tagIds: number[]; +} +function createDefaultFilter(): DishFilter { + return { categoryId: null, tagIds: [] }; +} +function sameNumberSet(a: number[], b: number[]) { + if (a.length !== b.length) return false; + const set = new Set(a); + return b.every((i) => set.has(i)); +} // ============================================================ // 常量 // ============================================================ -const HEADER_H = 52; // 筛选栏高度 +const HEADER_H = 52; // 筛选栏高度 const TAB_BAR_H = 56; // 底部 tabbar 高度 const MODULE_HEADER_H = 50; // 模块顶部 header 高度(对齐 LayoutView 默认值) @@ -37,6 +53,7 @@ const MODULE_HEADER_H = 50; // 模块顶部 header 高度(对齐 LayoutView // ============================================================ export default function MealDishesPage() { + const insets = useSafeAreaInsets(); const familyId = useFamilyStore((s) => s.getFamilyId()); const userId = useUserStore((s) => s.getUserId()); const dishes = useMealStore((s) => s.dishes); @@ -56,7 +73,6 @@ export default function MealDishesPage() { const { isLoading, startLoading, stopLoading } = useLoading(); const { refreshing, runInitial, runRefresh } = useListLoading(); - const insets = useSafeAreaInsets(); const screenH = Dimensions.get("window").height; // 日期常量 @@ -85,15 +101,25 @@ export default function MealDishesPage() { // ======== 派生数据 ======== const enabledCats = useMemo(() => categories.filter((c) => c.status !== 0), [categories]); - const enabledSlots = useMemo(() => slots.filter((s) => s.status !== 0).sort((a, b) => a.sort_number - b.sort_number), [slots]); + const enabledSlots = useMemo( + () => slots.filter((s) => s.status !== 0).sort((a, b) => a.sort_number - b.sort_number), + [slots], + ); const activeFilterCount = useMemo(() => { - let c = 0; if (filter.categoryId) c++; if (filter.tagIds.length > 0) c++; return c; + let c = 0; + if (filter.categoryId) c++; + if (filter.tagIds.length > 0) c++; + return c; }, [filter]); const tagIdsByDish = useMemo(() => { const m = new Map>(); - dishTags.forEach((r) => { const s = m.get(r.dish_id) ?? new Set(); s.add(r.tag_id); m.set(r.dish_id, s); }); + dishTags.forEach((r) => { + const s = m.get(r.dish_id) ?? new Set(); + s.add(r.tag_id); + m.set(r.dish_id, s); + }); return m; }, [dishTags]); @@ -104,33 +130,62 @@ export default function MealDishesPage() { return dishes.filter((d) => { const effectiveCatId = selCatId !== null ? selCatId : filter.categoryId; if (effectiveCatId && d.category_id !== effectiveCatId) return false; - if (filter.tagIds.length > 0) { const ids = tagIdsByDish.get(d.id); if (!ids || !filter.tagIds.every((t) => ids.has(t))) return false; } + if (filter.tagIds.length > 0) { + const ids = tagIdsByDish.get(d.id); + if (!ids || !filter.tagIds.every((t) => ids.has(t))) return false; + } if (!kw) return true; return d.name.toLowerCase().includes(kw); }); }, [dishes, keyword, filter, selCatId, tagIdsByDish]); - const filterChanged = filter.categoryId !== draftFilter.categoryId || !sameNumberSet(filter.tagIds, draftFilter.tagIds); + const filterChanged = + filter.categoryId !== draftFilter.categoryId || + !sameNumberSet(filter.tagIds, draftFilter.tagIds); // ======== 数据加载 ======== const loadData = useCallback(async () => { if (!familyId) return; - await Promise.all([fetchDishes(familyId), fetchCategories(familyId), fetchTags(familyId), fetchDishTags(familyId), fetchSlots(familyId)]); + await Promise.all([ + fetchDishes(familyId), + fetchCategories(familyId), + fetchTags(familyId), + fetchDishTags(familyId), + fetchSlots(familyId), + ]); }, [familyId, fetchCategories, fetchDishTags, fetchDishes, fetchTags, fetchSlots]); - useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]); - useFocusEffect(useCallback(() => { if (familyId) runRefresh(loadData); }, [familyId, loadData, runRefresh])); + useEffect(() => { + if (familyId) runInitial(loadData); + }, [familyId]); + useFocusEffect( + useCallback(() => { + if (familyId) runRefresh(loadData); + }, [familyId, loadData, runRefresh]), + ); // ======== 操作 ======== - const handleDelete = useCallback(async (item: MealDish) => { - if (!familyId || isLoading) return; startLoading("删除中..."); - try { await saveDishChanges({ deletes: [{ id: item.id }] }); await loadData(); stopLoading({ type: "success", message: "已删除" }); } - catch { stopLoading({ type: "error", message: "删除失败" }); } - }, [familyId, isLoading, loadData, saveDishChanges, startLoading, stopLoading]); + const handleDelete = useCallback( + async (item: MealDish) => { + if (!familyId || isLoading) return; + startLoading("删除中..."); + try { + await saveDishChanges({ deletes: [{ id: item.id }] }); + await loadData(); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, + [familyId, isLoading, loadData, saveDishChanges, startLoading, stopLoading], + ); - const handleOpenFilter = useCallback(() => { setDraftFilter(filter); setShowFilterSheet(true); }, [filter]); + const handleOpenFilter = useCallback(() => { + setDraftFilter(filter); + setShowFilterSheet(true); + }, [filter]); const handleResetFilter = useCallback(() => setDraftFilter(createDefaultFilter()), []); const handleApplyFilter = useCallback(() => { setFilter(draftFilter); @@ -138,60 +193,96 @@ export default function MealDishesPage() { setShowFilterSheet(false); }, [draftFilter]); - const handleCatPress = useCallback((catId: number | null) => { - const newId = selCatId === catId ? null : catId; - setSelCatId(newId); - setFilter((p) => ({ ...p, categoryId: newId })); - }, [selCatId]); + const handleCatPress = useCallback( + (catId: number | null) => { + const newId = selCatId === catId ? null : catId; + setSelCatId(newId); + setFilter((p) => ({ ...p, categoryId: newId })); + }, + [selCatId], + ); - const renderDraftTag = useCallback((t: MealTag) => { - const s = draftFilter.tagIds.includes(t.id); - return setDraftFilter((p) => ({ ...p, tagIds: p.tagIds.includes(t.id) ? p.tagIds.filter((id) => id !== t.id) : [...p.tagIds, t.id] }))}>{t.name}; - }, [draftFilter.tagIds]); + const renderDraftTag = useCallback( + (t: MealTag) => { + const s = draftFilter.tagIds.includes(t.id); + return ( + + setDraftFilter((p) => ({ + ...p, + tagIds: p.tagIds.includes(t.id) + ? p.tagIds.filter((id) => id !== t.id) + : [...p.tagIds, t.id], + })) + } + > + {t.name} + + ); + }, + [draftFilter.tagIds], + ); // ======== 加入安排 ======== - const handleAddToSlot = useCallback(async (slotId: number, slotName: string) => { - if (!familyId || !userId || !addDishTarget) return; - const dish = addDishTarget; - const date = addDishDate; - setAddDishTarget(null); - startLoading("添加中..."); - try { - // 获取该日期的安排 - await fetchPlansByDate(familyId, date); - const plans = useMealStore.getState().plans; - let planId = plans.find((p) => p.meal_slot_id === slotId)?.id; + const handleAddToSlot = useCallback( + async (slotId: number, slotName: string) => { + if (!familyId || !userId || !addDishTarget) return; + const dish = addDishTarget; + const date = addDishDate; + setAddDishTarget(null); + startLoading("添加中..."); + try { + // 获取该日期的安排 + await fetchPlansByDate(familyId, date); + const plans = useMealStore.getState().plans; + let planId = plans.find((p) => p.meal_slot_id === slotId)?.id; - // 没有安排则创建 - if (!planId) { - planId = await createEmptyPlan({ familyId, planDate: date, mealSlotId: slotId, userId }); - } - if (!planId) { - stopLoading({ type: "error", message: "创建安排失败" }); - return; - } + // 没有安排则创建 + if (!planId) { + planId = await createEmptyPlan({ familyId, planDate: date, mealSlotId: slotId, userId }); + } + if (!planId) { + stopLoading({ type: "error", message: "创建安排失败" }); + return; + } - // 添加菜品 - const result = await addDishToPlan({ - familyId, - planId, - dishId: dish.id, - dishName: dish.name, - userId, - sortNumber: 0, - }); - if (result.planItemId) { - const dateLabel = date === today ? "今天" : date === tomorrow ? "明天" : date; - toast.success(`已加入${dateLabel}${slotName}`); - stopLoading(); - } else { + // 添加菜品 + const result = await addDishToPlan({ + familyId, + planId, + dishId: dish.id, + dishName: dish.name, + userId, + sortNumber: 0, + }); + if (result.planItemId) { + const dateLabel = date === today ? "今天" : date === tomorrow ? "明天" : date; + toast.success(`已加入${dateLabel}${slotName}`); + stopLoading(); + } else { + stopLoading({ type: "error", message: "添加失败" }); + } + } catch { stopLoading({ type: "error", message: "添加失败" }); } - } catch { - stopLoading({ type: "error", message: "添加失败" }); - } - }, [familyId, userId, addDishTarget, addDishDate, today, tomorrow, fetchPlansByDate, createEmptyPlan, addDishToPlan, startLoading, stopLoading]); + }, + [ + familyId, + userId, + addDishTarget, + addDishDate, + today, + tomorrow, + fetchPlansByDate, + createEmptyPlan, + addDishToPlan, + startLoading, + stopLoading, + ], + ); // ======== 渲染 ======== @@ -205,11 +296,20 @@ export default function MealDishesPage() { 菜品库 - + - 筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""} + + 筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""} + - runRefresh(loadData)}> + runRefresh(loadData)} + > @@ -271,28 +371,60 @@ export default function MealDishesPage() { contentContainerStyle={{ paddingBottom: 80 }} ListEmptyComponent={() => ( - + )} renderItem={({ item }) => ( - { setActiveItem(null); router.push(`/meal/dish/detail/${item.id}` as any); }} - onLongPress={(e) => { setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); setActiveItem(item); }}> + { + setActiveItem(null); + router.push(`/meal/dish/detail/${item.id}` as any); + }} + onLongPress={(e) => { + setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); + setActiveItem(item); + }} + > - + - {item.name} + + {item.name} + - {!!item.difficulty && item.difficulty > 0 && {"★".repeat(item.difficulty)}} - {!!item.cooking_time_minutes && 约{item.cooking_time_minutes}分钟} + {!!item.difficulty && item.difficulty > 0 && ( + + {"★".repeat(item.difficulty)} + + )} + {!!item.cooking_time_minutes && ( + + 约{item.cooking_time_minutes}分钟 + + )} { setAddDishDate(today); setAddDishTarget(item); }} + onPress={() => { + setAddDishDate(today); + setAddDishTarget(item); + }} > @@ -304,25 +436,85 @@ export default function MealDishesPage() { {/* FAB */} - router.push("/meal/dish/edit" as any)}> + router.push("/meal/dish/edit" as any)} + > {/* ======== 筛选 BottomSheet ======== */} - - - 筛选重置 - - {tags.length > 0 ? {tags.map(renderDraftTag)} : 暂无标签} - - + + + + + + 筛选 + + + + 重置 + + + + + + + + + + {tags.length > 0 ? ( + {tags.map(renderDraftTag)} + ) : ( + 暂无标签 + )} + + + + + + + {/* ======== 加入安排 BottomSheet ======== */} - { if (!v) setAddDishTarget(null); }}> - - + { + if (!v) setAddDishTarget(null); + }} + > + + + 加入安排{addDishTarget ? ` · ${addDishTarget.name}` : ""} @@ -333,13 +525,21 @@ export default function MealDishesPage() { className={`flex-1 py-3 rounded-xl items-center ${addDishDate === today ? "bg-accent" : "bg-gray-100"}`} onPress={() => setAddDishDate(today)} > - 今天 + + 今天 + setAddDishDate(tomorrow)} > - 明天 + + 明天 + @@ -362,23 +562,71 @@ export default function MealDishesPage() { 暂无餐次,请先在设置中添加 )} - + + {/* ======== 长按菜单 ======== */} - setActiveItem(null)} + setActiveItem(null)} items={[ - { icon: , label: "编辑", onPress: () => { if (activeItem) { const a = activeItem; setActiveItem(null); router.push(`/meal/dish/edit?id=${a.id}` as any); } } }, - { icon: , label: "删除", onPress: () => { const a = activeItem; setActiveItem(null); if (a) setDeleteConfirm(a); } }, + { + icon: , + label: "编辑", + onPress: () => { + if (activeItem) { + const a = activeItem; + setActiveItem(null); + router.push(`/meal/dish/edit?id=${a.id}` as any); + } + }, + }, + { + icon: , + label: "删除", + onPress: () => { + const a = activeItem; + setActiveItem(null); + if (a) setDeleteConfirm(a); + }, + }, ]} /> {/* ======== 停用确认 ======== */} - { if (!v) setDeleteConfirm(null); }}> - - 删除菜品确认删除「{deleteConfirm?.name}」吗?此操作不可撤销。 - - + { + if (!v) setDeleteConfirm(null); + }} + > + + + + 删除菜品 + + 确认删除「{deleteConfirm?.name}」吗?此操作不可撤销。 + + + + + + + diff --git a/code/app/app-rn/src/app/meal/(tabs)/home/index.tsx b/code/app/app-rn/src/app/meal/(tabs)/home/index.tsx index 58f64114..3d14e65c 100644 --- a/code/app/app-rn/src/app/meal/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/meal/(tabs)/home/index.tsx @@ -233,9 +233,7 @@ export default function MealHomePage() { return; } const { getUniqueIdApi } = await import("@/request/api"); - const idRes = tp.data?.[0] - ? { data: [tp.data[0].id] } - : await getUniqueIdApi(1); + const idRes = tp.data?.[0] ? { data: [tp.data[0].id] } : await getUniqueIdApi(1); const npId = idRes.data?.[0] ?? 0; if (!npId) { stopLoading({ type: "error", message: "复制失败" }); @@ -253,13 +251,15 @@ export default function MealHomePage() { })); const planHas = tp.data?.[0] ? [] - : [{ - id: npId, - family_id: familyId, - plan_date: today, - meal_slot_id: mealSlotId, - status: MealPlanStatus.Planning, - }]; + : [ + { + id: npId, + family_id: familyId, + plan_date: today, + meal_slot_id: mealSlotId, + status: MealPlanStatus.Planning, + }, + ]; await savePlanBundle({ planInserts: planHas, planUpdates: [], @@ -310,9 +310,7 @@ export default function MealHomePage() { }`} onPress={() => setPlanDate(today)} > - + 今天 @@ -347,9 +345,9 @@ export default function MealHomePage() { const plan = plans.find((p) => p.meal_slot_id === slot.id); const pitems = plan ? (itemsByPlan.get(plan.id) ?? []).map((item) => ({ - ...item, - dish: dishes.find((d) => d.id === item.dish_id), - })) + ...item, + dish: dishes.find((d) => d.id === item.dish_id), + })) : []; const hasPlan = !!plan; const cookMember = getCookMember(plan?.cook_by); @@ -381,9 +379,9 @@ export default function MealHomePage() { {cookMember && ( - {cookMember.avatar - ? - : null} + {cookMember.avatar ? ( + + ) : null} {cookMember.nickname?.charAt(0)} @@ -401,14 +399,20 @@ export default function MealHomePage() { isCompleted ? "bg-green-50" : isConfirmed ? "bg-blue-50" : "bg-amber-50" }`} > - {isCompleted - ? - : isConfirmed - ? - : } + {isCompleted ? ( + + ) : isConfirmed ? ( + + ) : ( + + )} {isCompleted ? "已完成" : isConfirmed ? "已确认" : "进行中"} @@ -416,104 +420,100 @@ export default function MealHomePage() { )} - + {/* 菜品列表 / 空状态 */} - {hasPlan && pitems.length > 0 - ? ( - - {pitems.map((item: any) => ( - { - if (item.dish?.id) { - router.push(`/meal/dish/detail/${item.dish.id}` as any); - } - }} - > - - {item.dish && ( - - )} - - - - {item.dish?.name ?? "未知"} - - {item.quantity > 1 && ( - x{item.quantity} - )} - - {/* 仅 planning 状态可以快速移除 */} - {isPlanning && ( - { - e.stopPropagation(); - handleRemoveDish(plan.id, item.id, item.dish?.name ?? ""); - }} - > - × - - )} - - ))} - - {/* 底部操作栏 */} + {hasPlan && pitems.length > 0 ? ( + + {pitems.map((item: any) => ( { + if (item.dish?.id) { + router.push(`/meal/dish/detail/${item.dish.id}` as any); + } + }} + > + + {item.dish && ( + + )} + + + + {item.dish?.name ?? "未知"} + + {item.quantity > 1 && ( + x{item.quantity} + )} + + {/* 仅 planning 状态可以快速移除 */} + {isPlanning && ( + { + e.stopPropagation(); + handleRemoveDish(plan.id, item.id, item.dish?.name ?? ""); + }} + > + × + + )} + + ))} + + {/* 底部操作栏 */} + router.push(`/meal/plan/detail/${plan.id}` as any)} + > + 查看菜单详情 + + + + ) : hasPlan && pitems.length === 0 ? ( + + 暂无菜品 + {isPlanning && ( + router.push(`/meal/plan/detail/${plan.id}` as any)} - > - 查看菜单详情 - - - - ) - : hasPlan && pitems.length === 0 - ? ( - - 暂无菜品 - {isPlanning && ( - router.push(`/meal/plan/detail/${plan.id}` as any)} - > - - 添加菜品 - - )} - {isLocked && ( - router.push(`/meal/plan/detail/${plan.id}` as any)} - > - 查看菜单详情 - - )} - - ) - : ( - /* 无菜单 */ - - 暂未安排 - handleCreatePlan(slot.id)} - disabled={creatingSlotId === slot.id} > - 创建空菜单 + 添加菜品 - - )} + )} + {isLocked && ( + router.push(`/meal/plan/detail/${plan.id}` as any)} + > + 查看菜单详情 + + )} + + ) : ( + /* 无菜单 */ + + 暂未安排 + handleCreatePlan(slot.id)} + disabled={creatingSlotId === slot.id} + > + + 创建空菜单 + + + )} {/* 复制到「今天」(非今天日期且有菜单时) */} {!isToday && hasPlan && ( @@ -527,7 +527,6 @@ export default function MealHomePage() { ); })} - diff --git a/code/app/app-rn/src/app/meal/(tabs)/manage/index.tsx b/code/app/app-rn/src/app/meal/(tabs)/manage/index.tsx index c6fa675d..2bac672b 100644 --- a/code/app/app-rn/src/app/meal/(tabs)/manage/index.tsx +++ b/code/app/app-rn/src/app/meal/(tabs)/manage/index.tsx @@ -12,8 +12,28 @@ import { MealCategory, MealSlot, MealStatus, MealTag } from "@/types/meal"; type ManageTab = "category" | "tag" | "slot"; -const COLORS = ["#EF4444", "#F97316", "#F59E0B", "#84CC16", "#22C55E", "#14B8A6", "#06B6D4", "#3B82F6", "#6366F1", "#8B5CF6", "#A855F7", "#D946EF", "#EC4899", "#F43F5E", "#78716C"]; -const TAB_DATA: { key: ManageTab; label: string }[] = [{ key: "category", label: "分类" }, { key: "tag", label: "标签" }, { key: "slot", label: "餐段" }]; +const COLORS = [ + "#EF4444", + "#F97316", + "#F59E0B", + "#84CC16", + "#22C55E", + "#14B8A6", + "#06B6D4", + "#3B82F6", + "#6366F1", + "#8B5CF6", + "#A855F7", + "#D946EF", + "#EC4899", + "#F43F5E", + "#78716C", +]; +const TAB_DATA: { key: ManageTab; label: string }[] = [ + { key: "category", label: "分类" }, + { key: "tag", label: "标签" }, + { key: "slot", label: "餐段" }, +]; export default function MealManagePage() { const familyId = useFamilyStore((s) => s.getFamilyId()); @@ -58,94 +78,355 @@ export default function MealManagePage() { const tagInputRef = useRef(null); const slotInputRef = useRef(null); - useEffect(() => { if (showCatSheet) setTimeout(() => catInputRef.current?.focus(), 250); }, [showCatSheet]); - useEffect(() => { if (showGroupSheet) setTimeout(() => groupInputRef.current?.focus(), 250); }, [showGroupSheet]); - useEffect(() => { if (showTagSheet) setTimeout(() => tagInputRef.current?.focus(), 250); }, [showTagSheet]); - useEffect(() => { if (showSlotSheet) setTimeout(() => slotInputRef.current?.focus(), 250); }, [showSlotSheet]); + useEffect(() => { + if (showCatSheet) setTimeout(() => catInputRef.current?.focus(), 250); + }, [showCatSheet]); + useEffect(() => { + if (showGroupSheet) setTimeout(() => groupInputRef.current?.focus(), 250); + }, [showGroupSheet]); + useEffect(() => { + if (showTagSheet) setTimeout(() => tagInputRef.current?.focus(), 250); + }, [showTagSheet]); + useEffect(() => { + if (showSlotSheet) setTimeout(() => slotInputRef.current?.focus(), 250); + }, [showSlotSheet]); const parentTags = useMemo(() => tags.filter((t) => !t.parent_id), [tags]); - const tabCount = useMemo(() => ({ category: categories.length, tag: parentTags.length, slot: slots.length }), [categories.length, parentTags.length, slots.length]); + const tabCount = useMemo( + () => ({ category: categories.length, tag: parentTags.length, slot: slots.length }), + [categories.length, parentTags.length, slots.length], + ); const loadData = useCallback(async () => { if (!familyId) return; await Promise.all([fetchCategories(familyId), fetchTags(familyId), fetchSlots(familyId)]); }, [familyId, fetchCategories, fetchTags, fetchSlots]); - useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]); + useEffect(() => { + if (familyId) runInitial(loadData); + }, [familyId]); // 分类 - const openCat = useCallback((c?: MealCategory) => { setCatForm(c?.name ?? ""); setEditingCat(c ?? null); setShowCatSheet(true); }, []); + const openCat = useCallback((c?: MealCategory) => { + setCatForm(c?.name ?? ""); + setEditingCat(c ?? null); + setShowCatSheet(true); + }, []); const saveCat = useCallback(async () => { - if (!familyId || !catForm.trim()) return; startLoading("保存中..."); + if (!familyId || !catForm.trim()) return; + startLoading("保存中..."); try { let ok = false; - if (editingCat) ok = await saveCategoryChanges({ updates: [{ id: editingCat.id, name: catForm.trim() }] }); - else { const id = (await getUniqueIdApi(1)).data?.[0]; if (!id) { stopLoading({ type: "error", message: "ID 失败" }); return; } ok = await saveCategoryChanges({ inserts: [{ id, family_id: familyId, name: catForm.trim(), sort_number: categories.length, status: MealStatus.Enabled }] }); } - if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } - setShowCatSheet(false); await loadData(); stopLoading({ type: "success", message: editingCat ? "已更新" : "已添加" }); - } catch { stopLoading({ type: "error", message: "保存失败" }); } - }, [catForm, editingCat, familyId, loadData, saveCategoryChanges, startLoading, stopLoading, categories.length]); - const delCat = useCallback(async () => { if (!catDelete) return; startLoading("删除中..."); try { const ok = await saveCategoryChanges({ deletes: [{ id: catDelete.id }] }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } setCatDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); } }, [catDelete, loadData, saveCategoryChanges, startLoading, stopLoading]); + if (editingCat) + ok = await saveCategoryChanges({ updates: [{ id: editingCat.id, name: catForm.trim() }] }); + else { + const id = (await getUniqueIdApi(1)).data?.[0]; + if (!id) { + stopLoading({ type: "error", message: "ID 失败" }); + return; + } + ok = await saveCategoryChanges({ + inserts: [ + { + id, + family_id: familyId, + name: catForm.trim(), + sort_number: categories.length, + status: MealStatus.Enabled, + }, + ], + }); + } + if (!ok) { + stopLoading({ type: "error", message: "保存失败" }); + return; + } + setShowCatSheet(false); + await loadData(); + stopLoading({ type: "success", message: editingCat ? "已更新" : "已添加" }); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + catForm, + editingCat, + familyId, + loadData, + saveCategoryChanges, + startLoading, + stopLoading, + categories.length, + ]); + const delCat = useCallback(async () => { + if (!catDelete) return; + startLoading("删除中..."); + try { + const ok = await saveCategoryChanges({ deletes: [{ id: catDelete.id }] }); + if (!ok) { + stopLoading({ type: "error", message: "删除失败" }); + return; + } + setCatDelete(null); + await loadData(); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, [catDelete, loadData, saveCategoryChanges, startLoading, stopLoading]); // 分组 - const openGroup = useCallback((g?: MealTag) => { setGroupForm(g?.name ?? ""); setEditingGroup(g ?? null); setShowGroupSheet(true); }, []); + const openGroup = useCallback((g?: MealTag) => { + setGroupForm(g?.name ?? ""); + setEditingGroup(g ?? null); + setShowGroupSheet(true); + }, []); const saveGroup = useCallback(async () => { - if (!familyId || !groupForm.trim()) return; startLoading("保存中..."); + if (!familyId || !groupForm.trim()) return; + startLoading("保存中..."); try { let ok = false; - if (editingGroup) ok = await saveTagChanges({ updates: [{ id: editingGroup.id, name: groupForm.trim() }] }); - else { const id = (await getUniqueIdApi(1)).data?.[0]; if (!id) { stopLoading({ type: "error", message: "ID 失败" }); return; } ok = await saveTagChanges({ inserts: [{ id, family_id: familyId, name: groupForm.trim(), parent_id: null, color: "", sort_number: parentTags.length, status: MealStatus.Enabled }] }); } - if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } - setShowGroupSheet(false); await loadData(); stopLoading({ type: "success", message: editingGroup ? "已更新" : "已添加" }); - } catch { stopLoading({ type: "error", message: "保存失败" }); } - }, [editingGroup, familyId, groupForm, loadData, saveTagChanges, startLoading, stopLoading, parentTags.length]); - const delGroup = useCallback(async () => { if (!groupDelete) return; startLoading("删除中..."); try { const c = tags.filter((t) => t.parent_id === groupDelete.id).map((t) => ({ id: t.id })); const ok = await saveTagChanges({ deletes: [{ id: groupDelete.id }, ...c] }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } setGroupDelete(null); await loadData(); stopLoading({ type: "success", message: "父标签及子标签已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); } }, [groupDelete, loadData, saveTagChanges, startLoading, stopLoading, tags]); + if (editingGroup) + ok = await saveTagChanges({ updates: [{ id: editingGroup.id, name: groupForm.trim() }] }); + else { + const id = (await getUniqueIdApi(1)).data?.[0]; + if (!id) { + stopLoading({ type: "error", message: "ID 失败" }); + return; + } + ok = await saveTagChanges({ + inserts: [ + { + id, + family_id: familyId, + name: groupForm.trim(), + parent_id: null, + color: "", + sort_number: parentTags.length, + status: MealStatus.Enabled, + }, + ], + }); + } + if (!ok) { + stopLoading({ type: "error", message: "保存失败" }); + return; + } + setShowGroupSheet(false); + await loadData(); + stopLoading({ type: "success", message: editingGroup ? "已更新" : "已添加" }); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + editingGroup, + familyId, + groupForm, + loadData, + saveTagChanges, + startLoading, + stopLoading, + parentTags.length, + ]); + const delGroup = useCallback(async () => { + if (!groupDelete) return; + startLoading("删除中..."); + try { + const c = tags.filter((t) => t.parent_id === groupDelete.id).map((t) => ({ id: t.id })); + const ok = await saveTagChanges({ deletes: [{ id: groupDelete.id }, ...c] }); + if (!ok) { + stopLoading({ type: "error", message: "删除失败" }); + return; + } + setGroupDelete(null); + await loadData(); + stopLoading({ type: "success", message: "父标签及子标签已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, [groupDelete, loadData, saveTagChanges, startLoading, stopLoading, tags]); // 子标签 - const openTag = useCallback((gId: number, t?: MealTag) => { setActiveGroupId(gId); setTagForm({ name: t?.name ?? "", color: t?.color ?? "" }); setEditingTag(t ?? null); setShowTagSheet(true); }, []); + const openTag = useCallback((gId: number, t?: MealTag) => { + setActiveGroupId(gId); + setTagForm({ name: t?.name ?? "", color: t?.color ?? "" }); + setEditingTag(t ?? null); + setShowTagSheet(true); + }, []); const saveTag = useCallback(async () => { - if (!familyId || !activeGroupId || !tagForm.name.trim()) return; startLoading("保存中..."); + if (!familyId || !activeGroupId || !tagForm.name.trim()) return; + startLoading("保存中..."); try { let ok = false; - if (editingTag) ok = await saveTagChanges({ updates: [{ id: editingTag.id, name: tagForm.name.trim(), color: tagForm.color }] }); - else { const id = (await getUniqueIdApi(1)).data?.[0]; if (!id) { stopLoading({ type: "error", message: "ID 失败" }); return; } const gtags = tags.filter((t) => t.parent_id === activeGroupId); ok = await saveTagChanges({ inserts: [{ id, family_id: familyId, name: tagForm.name.trim(), color: tagForm.color, sort_number: gtags.length, parent_id: activeGroupId, status: MealStatus.Enabled }] }); } - if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } - setShowTagSheet(false); await loadData(); stopLoading({ type: "success", message: editingTag ? "已更新" : "已添加" }); - } catch { stopLoading({ type: "error", message: "保存失败" }); } - }, [activeGroupId, editingTag, familyId, loadData, saveTagChanges, startLoading, stopLoading, tagForm, tags]); - const delTag = useCallback(async () => { if (!tagDelete) return; startLoading("删除中..."); try { const ok = await saveTagChanges({ deletes: [{ id: tagDelete.id }] }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } setTagDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); } }, [loadData, saveTagChanges, startLoading, stopLoading, tagDelete]); + if (editingTag) + ok = await saveTagChanges({ + updates: [{ id: editingTag.id, name: tagForm.name.trim(), color: tagForm.color }], + }); + else { + const id = (await getUniqueIdApi(1)).data?.[0]; + if (!id) { + stopLoading({ type: "error", message: "ID 失败" }); + return; + } + const gtags = tags.filter((t) => t.parent_id === activeGroupId); + ok = await saveTagChanges({ + inserts: [ + { + id, + family_id: familyId, + name: tagForm.name.trim(), + color: tagForm.color, + sort_number: gtags.length, + parent_id: activeGroupId, + status: MealStatus.Enabled, + }, + ], + }); + } + if (!ok) { + stopLoading({ type: "error", message: "保存失败" }); + return; + } + setShowTagSheet(false); + await loadData(); + stopLoading({ type: "success", message: editingTag ? "已更新" : "已添加" }); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + activeGroupId, + editingTag, + familyId, + loadData, + saveTagChanges, + startLoading, + stopLoading, + tagForm, + tags, + ]); + const delTag = useCallback(async () => { + if (!tagDelete) return; + startLoading("删除中..."); + try { + const ok = await saveTagChanges({ deletes: [{ id: tagDelete.id }] }); + if (!ok) { + stopLoading({ type: "error", message: "删除失败" }); + return; + } + setTagDelete(null); + await loadData(); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, [loadData, saveTagChanges, startLoading, stopLoading, tagDelete]); // 餐段 - const openSlot = useCallback((s?: MealSlot) => { setSlotForm(s?.name ?? ""); setEditingSlot(s ?? null); setShowSlotSheet(true); }, []); + const openSlot = useCallback((s?: MealSlot) => { + setSlotForm(s?.name ?? ""); + setEditingSlot(s ?? null); + setShowSlotSheet(true); + }, []); const saveSlot = useCallback(async () => { - if (!familyId || !slotForm.trim()) return; startLoading("保存中..."); + if (!familyId || !slotForm.trim()) return; + startLoading("保存中..."); try { let ok = false; - if (editingSlot) ok = await saveSlotChanges({ updates: [{ id: editingSlot.id, name: slotForm.trim() }] }); - else { const id = (await getUniqueIdApi(1)).data?.[0]; if (!id) { stopLoading({ type: "error", message: "ID 失败" }); return; } ok = await saveSlotChanges({ inserts: [{ id, family_id: familyId, name: slotForm.trim(), sort_number: slots.length, status: MealStatus.Enabled }] }); } - if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } - setShowSlotSheet(false); await loadData(); stopLoading({ type: "success", message: editingSlot ? "已更新" : "已添加" }); - } catch { stopLoading({ type: "error", message: "保存失败" }); } - }, [editingSlot, familyId, loadData, saveSlotChanges, slotForm, slots.length, startLoading, stopLoading]); - const delSlot = useCallback(async () => { if (!slotDelete) return; startLoading("删除中..."); try { const ok = await saveSlotChanges({ deletes: [{ id: slotDelete.id }] }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } setSlotDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); } }, [loadData, saveSlotChanges, slotDelete, startLoading, stopLoading]); + if (editingSlot) + ok = await saveSlotChanges({ updates: [{ id: editingSlot.id, name: slotForm.trim() }] }); + else { + const id = (await getUniqueIdApi(1)).data?.[0]; + if (!id) { + stopLoading({ type: "error", message: "ID 失败" }); + return; + } + ok = await saveSlotChanges({ + inserts: [ + { + id, + family_id: familyId, + name: slotForm.trim(), + sort_number: slots.length, + status: MealStatus.Enabled, + }, + ], + }); + } + if (!ok) { + stopLoading({ type: "error", message: "保存失败" }); + return; + } + setShowSlotSheet(false); + await loadData(); + stopLoading({ type: "success", message: editingSlot ? "已更新" : "已添加" }); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + editingSlot, + familyId, + loadData, + saveSlotChanges, + slotForm, + slots.length, + startLoading, + stopLoading, + ]); + const delSlot = useCallback(async () => { + if (!slotDelete) return; + startLoading("删除中..."); + try { + const ok = await saveSlotChanges({ deletes: [{ id: slotDelete.id }] }); + if (!ok) { + stopLoading({ type: "error", message: "删除失败" }); + return; + } + setSlotDelete(null); + await loadData(); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, [loadData, saveSlotChanges, slotDelete, startLoading, stopLoading]); - - - const getTagsByGroup = useCallback((gId: number) => tags.filter((t) => t.parent_id === gId), [tags]); + const getTagsByGroup = useCallback( + (gId: number) => tags.filter((t) => t.parent_id === gId), + [tags], + ); return ( - runRefresh(loadData)} showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }}> + runRefresh(loadData)} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }} + > {/* 头部 */} - 菜品规则分类与标签 - runRefresh(loadData)}> + + 菜品规则 + 分类与标签 + + runRefresh(loadData)} + > + + {/* Tab 切换 */} {TAB_DATA.map(({ key, label }) => ( - setTab(key)}> - {label} ({tabCount[key]}) + setTab(key)} + > + + {label} ({tabCount[key]}) + ))} @@ -154,21 +435,53 @@ export default function MealManagePage() { {tab === "category" && ( - 分类{categories.length} 个 - openCat()}>添加 + + 分类 + {categories.length} 个 + + openCat()} + > + + 添加 + {categories.length > 0 ? ( {categories.map((c) => ( - - openCat(c)}> + + openCat(c)} + > {c.name} - { e.stopPropagation(); setCatDelete(c); }}> + { + e.stopPropagation(); + setCatDelete(c); + }} + > + + ))} - ) : 暂无分类,点击右上角添加} + ) : ( + 暂无分类,点击右上角添加 + )} )} @@ -176,26 +489,65 @@ export default function MealManagePage() { {tab === "tag" && ( - 标签{parentTags.length} 组 - openGroup()}>新建分组 + + 标签 + {parentTags.length} 组 + + openGroup()} + > + + 新建分组 + {parentTags.length > 0 ? ( {parentTags.map((g) => { const gt = getTagsByGroup(g.id); return ( - + - openGroup(g)}>{g.name} - setGroupDelete(g)}> + openGroup(g)}> + {g.name} + + setGroupDelete(g)} + > + + {gt.map((t) => ( - openTag(g.id, t)}> - {t.name} + openTag(g.id, t)} + > + + {t.name} + ))} - openTag(g.id)}> + openTag(g.id)} + > + 添加 @@ -203,7 +555,9 @@ export default function MealManagePage() { ); })} - ) : 暂无标签分组,先新建分组再添加标签} + ) : ( + 暂无标签分组,先新建分组再添加标签 + )} )} @@ -211,41 +565,304 @@ export default function MealManagePage() { {tab === "slot" && ( - 餐段{slots.length} 个 - openSlot()}>添加 + + 餐段 + {slots.length} 个 + + openSlot()} + > + + 添加 + {slots.length > 0 ? ( {slots.map((s, i) => { return ( - openSlot(s)}> + openSlot(s)} + > {s.name} - { e.stopPropagation(); setSlotDelete(s); }}> + { + e.stopPropagation(); + setSlotDelete(s); + }} + > + + ); })} - ) : 暂无餐段,点击右上角添加} - 餐段建议:早餐、午餐、晚餐、夜宵等 + ) : ( + 暂无餐段,点击右上角添加 + )} + + 餐段建议:早餐、午餐、晚餐、夜宵等 + )} {/* Sheets */} - {editingCat ? "编辑分类" : "添加分类"} - {editingGroup ? "编辑分组" : "新建分组"} - {editingTag ? "编辑标签" : "添加标签"} setTagForm((p) => ({ ...p, name: v }))} placeholder="例如:微辣" ref={tagInputRef} />{COLORS.map((c) => setTagForm((p) => ({ ...p, color: c }))} />)} setTagForm((p) => ({ ...p, color: "" }))}>无颜色 setTagForm((p) => ({ ...p, color: v }))} placeholder="#EF4444" /> - {editingSlot ? "编辑餐段" : "添加餐段"} + + + + + + {editingCat ? "编辑分类" : "添加分类"} + + + + + + + + + + + + + + + + + + {editingGroup ? "编辑分组" : "新建分组"} + + + + + + + + + + + + + + + + + + {editingTag ? "编辑标签" : "添加标签"} + + + + setTagForm((p) => ({ ...p, name: v }))} + placeholder="例如:微辣" + ref={tagInputRef} + /> + + + + + {COLORS.map((c) => ( + setTagForm((p) => ({ ...p, color: c }))} + /> + ))} + + + setTagForm((p) => ({ ...p, color: "" }))} + > + 无颜色 + + setTagForm((p) => ({ ...p, color: v }))} + placeholder="#EF4444" + /> + + + + + + + + + + + + + + + {editingSlot ? "编辑餐段" : "添加餐段"} + + + + + + + + + + + + {/* Dialogs */} - { if (!v) setCatDelete(null); }}>删除分类确认删除「{catDelete?.name}」吗?菜品不受影响。 - { if (!v) setGroupDelete(null); }}>删除分组确认删除「{groupDelete?.name}」吗?分组内的标签也会被删除。 - { if (!v) setTagDelete(null); }}>删除标签确认删除「{tagDelete?.name}」吗? - { if (!v) setSlotDelete(null); }}>删除餐段确认删除「{slotDelete?.name}」吗?历史餐食不受影响。 + { + if (!v) setCatDelete(null); + }} + > + + + + 删除分类 + 确认删除「{catDelete?.name}」吗?菜品不受影响。 + + + + + + + + { + if (!v) setGroupDelete(null); + }} + > + + + + 删除分组 + + 确认删除「{groupDelete?.name}」吗?分组内的标签也会被删除。 + + + + + + + + + { + if (!v) setTagDelete(null); + }} + > + + + + 删除标签 + 确认删除「{tagDelete?.name}」吗? + + + + + + + + { + if (!v) setSlotDelete(null); + }} + > + + + + 删除餐段 + + 确认删除「{slotDelete?.name}」吗?历史餐食不受影响。 + + + + + + + + diff --git a/code/app/app-rn/src/app/meal/dish/detail/[id]/index.tsx b/code/app/app-rn/src/app/meal/dish/detail/[id]/index.tsx index 1723bcdc..5195d18b 100644 --- a/code/app/app-rn/src/app/meal/dish/detail/[id]/index.tsx +++ b/code/app/app-rn/src/app/meal/dish/detail/[id]/index.tsx @@ -12,7 +12,12 @@ import { useLoading } from "@/hooks/useLoading"; import { useFamilyStore, useMealStore } from "@/store"; import { MealDishDifficulty, MealStatus } from "@/types/meal"; -const DIFF_LABELS: Record = { [MealDishDifficulty.None]: "", [MealDishDifficulty.Easy]: "简单", [MealDishDifficulty.Normal]: "普通", [MealDishDifficulty.Hard]: "复杂" }; +const DIFF_LABELS: Record = { + [MealDishDifficulty.None]: "", + [MealDishDifficulty.Easy]: "简单", + [MealDishDifficulty.Normal]: "普通", + [MealDishDifficulty.Hard]: "复杂", +}; export default function MealDishDetail() { const r = useRouter(); @@ -37,60 +42,251 @@ export default function MealDishDetail() { const [showDel, setShowDel] = useState(false); const item = useMemo(() => dishes.find((d) => d.id === dishId) ?? null, [dishId, dishes]); - const catName = useMemo(() => { if (!item?.category_id) return "未分类"; return categories.find((c) => c.id === item.category_id)?.name ?? "未分类"; }, [categories, item?.category_id]); - const selTags = useMemo(() => { const ids = new Set(dishTags.filter((r) => r.dish_id === dishId).map((r) => r.tag_id)); return tags.filter((t) => ids.has(t.id)); }, [dishId, dishTags, tags]); - const sortedSteps = useMemo(() => [...dishSteps].sort((a, b) => a.step_number - b.step_number), [dishSteps]); + const catName = useMemo(() => { + if (!item?.category_id) return "未分类"; + return categories.find((c) => c.id === item.category_id)?.name ?? "未分类"; + }, [categories, item?.category_id]); + const selTags = useMemo(() => { + const ids = new Set(dishTags.filter((r) => r.dish_id === dishId).map((r) => r.tag_id)); + return tags.filter((t) => ids.has(t.id)); + }, [dishId, dishTags, tags]); + const sortedSteps = useMemo( + () => [...dishSteps].sort((a, b) => a.step_number - b.step_number), + [dishSteps], + ); const loadData = useCallback(async () => { - if (!familyId || !dishId) return; setPageLoading(true); - try { await Promise.all([fetchDishes(familyId), fetchCategories(familyId), fetchTags(familyId), fetchDishTags(familyId, dishId), fetchDishIngredients(familyId, dishId), fetchDishSteps(familyId, dishId)]); } - finally { setPageLoading(false); } - }, [familyId, dishId, fetchCategories, fetchDishIngredients, fetchDishSteps, fetchDishTags, fetchDishes, fetchTags]); - useEffect(() => { loadData(); }, [loadData]); - useFocusEffect(useCallback(() => { if (familyId && dishId) loadData(); }, [familyId, loadData, dishId])); + if (!familyId || !dishId) return; + setPageLoading(true); + try { + await Promise.all([ + fetchDishes(familyId), + fetchCategories(familyId), + fetchTags(familyId), + fetchDishTags(familyId, dishId), + fetchDishIngredients(familyId, dishId), + fetchDishSteps(familyId, dishId), + ]); + } finally { + setPageLoading(false); + } + }, [ + familyId, + dishId, + fetchCategories, + fetchDishIngredients, + fetchDishSteps, + fetchDishTags, + fetchDishes, + fetchTags, + ]); + useEffect(() => { + loadData(); + }, [loadData]); + useFocusEffect( + useCallback(() => { + if (familyId && dishId) loadData(); + }, [familyId, loadData, dishId]), + ); const handleDelete = useCallback(async () => { - if (!familyId || !item || isLoading) return; setShowDel(false); startLoading("删除中..."); - try { const ok = await saveDishChanges({ deletes: [{ id: item.id }] }); if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } stopLoading({ type: "success", message: "已删除" }); r.replace("/meal/dishes" as any); } - catch { stopLoading({ type: "error", message: "删除失败" }); } + if (!familyId || !item || isLoading) return; + setShowDel(false); + startLoading("删除中..."); + try { + const ok = await saveDishChanges({ deletes: [{ id: item.id }] }); + if (!ok) { + stopLoading({ type: "error", message: "删除失败" }); + return; + } + stopLoading({ type: "success", message: "已删除" }); + r.replace("/meal/dishes" as any); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } }, [familyId, isLoading, item, r, saveDishChanges, startLoading, stopLoading]); return ( - - {!item && !pageLoading ? : item && (<> - + + {!item && !pageLoading ? ( + + ) : ( + item && ( + <> + + + - - {item.name} + + + {item.name} + - - {catName === "未分类" ? 未分类 : {catName}} - {!!item.difficulty && item.difficulty > 0 && {DIFF_LABELS[item.difficulty]}} - {!!item.cooking_time_minutes && item.cooking_time_minutes > 0 && 约{item.cooking_time_minutes}分钟} - {selTags.map((t) => {t.name})} - - + + {catName === "未分类" ? ( + 未分类 + ) : ( + + {catName} + + )} + {!!item.difficulty && item.difficulty > 0 && ( + + + {DIFF_LABELS[item.difficulty]} + + + )} + {!!item.cooking_time_minutes && item.cooking_time_minutes > 0 && ( + + + 约{item.cooking_time_minutes}分钟 + + + )} + {selTags.map((t) => ( + + + {t.name} + + + ))} + + - {!!item.ingredient_summary && 主要食材{item.ingredient_summary}} + {!!item.ingredient_summary && ( + + 主要食材 + + {item.ingredient_summary} + + + )} - {dishIngredients.length > 0 && 食材明细{dishIngredients.map((ing) => {ing.name}{[ing.quantity ? `${ing.quantity}` : "", ing.unit, ing.spec].filter(Boolean).join(" ")})}} + {dishIngredients.length > 0 && ( + + 食材明细 + + {dishIngredients.map((ing) => ( + + + {ing.name} + + {[ing.quantity ? `${ing.quantity}` : "", ing.unit, ing.spec] + .filter(Boolean) + .join(" ")} + + + ))} + + + )} - {sortedSteps.length > 0 && 做法步骤{sortedSteps.map((step) => {step.step_number}{!!step.image_file_key && }{!!step.description && {step.description}})}} + {sortedSteps.length > 0 && ( + + 做法步骤 + + {sortedSteps.map((step) => ( + + + {step.step_number} + + + {!!step.image_file_key && ( + + )} + {!!step.description && ( + + {step.description} + + )} + + + ))} + + + )} - {!!item.remark && 备注{item.remark}} + {!!item.remark && ( + + 备注 + {item.remark} + + )} - - - - - )} + + + + + + ) + )} - 删除菜品确认删除「{item?.name}」吗?此操作不可撤销。 + + + + + 删除菜品 + 确认删除「{item?.name}」吗?此操作不可撤销。 + + + + + + + ); diff --git a/code/app/app-rn/src/app/meal/dish/edit/index.tsx b/code/app/app-rn/src/app/meal/dish/edit/index.tsx index 96ba825e..a0a6da04 100644 --- a/code/app/app-rn/src/app/meal/dish/edit/index.tsx +++ b/code/app/app-rn/src/app/meal/dish/edit/index.tsx @@ -26,7 +26,15 @@ interface DishForm { } function emptyForm(): DishForm { - return { name: "", imageFileKey: "", imageFilePath: "", categoryId: null, difficulty: MealDishDifficulty.Easy, cookingTime: "", remark: "" }; + return { + name: "", + imageFileKey: "", + imageFilePath: "", + categoryId: null, + difficulty: MealDishDifficulty.Easy, + cookingTime: "", + remark: "", + }; } export default function MealDishEdit() { @@ -50,14 +58,36 @@ export default function MealDishEdit() { const notifyOp = useModuleChatNotifier(); const [form, setForm] = useState(emptyForm); - const [steps, setSteps] = useState<{ key: string; stepNumber: number; description: string; imageFileKey: string; imageFilePath: string; _id?: number }[]>([]); + const [steps, setSteps] = useState< + { + key: string; + stepNumber: number; + description: string; + imageFileKey: string; + imageFilePath: string; + _id?: number; + }[] + >([]); const [stepsIdsToDelete, setStepsIdsToDelete] = useState([]); const [uploading, setUploading] = useState(false); const [pageLoading, setPageLoading] = useState(false); - const nextStepKey = useCallback(() => "s" + Date.now() + "_" + Math.random().toString(36).substr(2, 5), []); - const addStep = useCallback(() => { setSteps((p) => [...p, { key: nextStepKey(), stepNumber: p.length + 1, description: "" }]); }, [nextStepKey]); - const removeStep = useCallback((key: string, stepId?: number) => { setSteps((p) => { const f = p.filter((s) => s.key !== key); return f.map((s, i) => ({ ...s, stepNumber: i + 1 })); }); if (stepId) setStepsIdsToDelete((p) => [...p, stepId]); }, []); - const updateStepDesc = useCallback((key: string, desc: string) => { setSteps((p) => p.map((s) => s.key === key ? { ...s, description: desc } : s)); }, []); + const nextStepKey = useCallback( + () => "s" + Date.now() + "_" + Math.random().toString(36).substr(2, 5), + [], + ); + const addStep = useCallback(() => { + setSteps((p) => [...p, { key: nextStepKey(), stepNumber: p.length + 1, description: "" }]); + }, [nextStepKey]); + const removeStep = useCallback((key: string, stepId?: number) => { + setSteps((p) => { + const f = p.filter((s) => s.key !== key); + return f.map((s, i) => ({ ...s, stepNumber: i + 1 })); + }); + if (stepId) setStepsIdsToDelete((p) => [...p, stepId]); + }, []); + const updateStepDesc = useCallback((key: string, desc: string) => { + setSteps((p) => p.map((s) => (s.key === key ? { ...s, description: desc } : s))); + }, []); const enabledCats = useMemo(() => categories.filter((c) => c.status !== 0), [categories]); @@ -69,32 +99,74 @@ export default function MealDishEdit() { if (isEdit) { const st = useMealStore.getState(); const item = st.dishes.find((d) => d.id === editId); - if (!item) { toast.error("菜品不存在"); router.back(); return; } + if (!item) { + toast.error("菜品不存在"); + router.back(); + return; + } setForm({ - name: item.name, imageFileKey: item.image_file_key, imageFilePath: "", + name: item.name, + imageFileKey: item.image_file_key, + imageFilePath: "", categoryId: item.category_id ?? null, difficulty: (item.difficulty ?? MealDishDifficulty.Easy) as MealDishDifficulty, cookingTime: item.cooking_time_minutes ? String(item.cooking_time_minutes) : "", remark: item.remark ?? "", }); - await fetchDishSteps(familyId, editId); const sd = useMealStore.getState().dishSteps; setSteps(sd.map((s2) => ({ key: "es" + s2.id, stepNumber: s2.step_number, description: s2.description ?? "", _id: s2.id }))); setStepsIdsToDelete([]); + await fetchDishSteps(familyId, editId); + const sd = useMealStore.getState().dishSteps; + setSteps( + sd.map((s2) => ({ + key: "es" + s2.id, + stepNumber: s2.step_number, + description: s2.description ?? "", + _id: s2.id, + })), + ); + setStepsIdsToDelete([]); } - } finally { setPageLoading(false); } + } finally { + setPageLoading(false); + } }, [familyId, isEdit, editId, fetchDishes, fetchCategories, fetchDishSteps, router]); - useEffect(() => { loadData(); }, [loadData]); + useEffect(() => { + loadData(); + }, [loadData]); const handlePickImage = useCallback(async () => { try { - const img = await ImagePicker.openPicker({ width: 800, height: 800, cropping: true, mediaType: "photo" }); + const img = await ImagePicker.openPicker({ + width: 800, + height: 800, + cropping: true, + mediaType: "photo", + }); setForm((p) => ({ ...p, imageFilePath: img.path })); - } catch { /* cancelled */ } + } catch { + /* cancelled */ + } }, []); - const pickStepImage = useCallback(async (stepKey: string) => { try { const img = await ImagePicker.openPicker({ width: 600, height: 400, cropping: true, mediaType: "photo" }); setSteps((p) => p.map((s) => s.key === stepKey ? { ...s, imageFilePath: img.path } : s)); } catch { /* cancelled */ } }, []); + const pickStepImage = useCallback(async (stepKey: string) => { + try { + const img = await ImagePicker.openPicker({ + width: 600, + height: 400, + cropping: true, + mediaType: "photo", + }); + setSteps((p) => p.map((s) => (s.key === stepKey ? { ...s, imageFilePath: img.path } : s))); + } catch { + /* cancelled */ + } + }, []); const handleSave = useCallback(async () => { if (!familyId || !userId) return; - if (!form.name.trim()) { toast.error("请输入菜品名称"); return; } + if (!form.name.trim()) { + toast.error("请输入菜品名称"); + return; + } startLoading("保存中..."); try { let imageFileKey = form.imageFileKey; @@ -103,26 +175,108 @@ export default function MealDishEdit() { const file = { uri: form.imageFilePath, type: "image/jpeg", name: Date.now() + ".jpg" }; const uploaded = await uploadDishImage(familyId, file); setUploading(false); - if (!uploaded) { stopLoading({ type: "error", message: "图片上传失败" }); return; } + if (!uploaded) { + stopLoading({ type: "error", message: "图片上传失败" }); + return; + } imageFileKey = uploaded; - } else if (!isEdit) { stopLoading({ type: "error", message: "请选择菜品图片" }); return; } - - if (isEdit) { - const ok = await saveDishChanges({ updates: [{ id: editId, name: form.name.trim(), image_file_key: imageFileKey, category_id: form.categoryId, difficulty: form.difficulty, cooking_time_minutes: form.cookingTime ? Number(form.cookingTime) : 0, remark: form.remark.trim() }] }); - if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } - notifyOp({ module: "家庭菜单", action: "编辑菜品", target: form.name.trim(), sourceTable: "b_meal_dish", sourceId: editId }); - } else { - const sc = steps.length + 1; const idsRes = await getUniqueIdApi(sc); const dishId = idsRes.data?.[0] ?? 0; - if (!dishId) { stopLoading({ type: "error", message: "创建失败" }); return; } - const sids2 = idsRes.data?.slice(1) ?? []; const sis2 = steps.map((s, i) => ({ id: sids2[i] ?? 0, family_id: familyId, dish_id: dishId, step_number: s.stepNumber, description: s.description, image_file_key: s.imageFileKey, sort_number: i })); - const ok = await saveDishBundle({ dishInserts: [{ id: dishId, family_id: familyId, name: form.name.trim(), image_file_key: imageFileKey, category_id: form.categoryId, difficulty: form.difficulty, cooking_time_minutes: form.cookingTime ? Number(form.cookingTime) : 0, remark: form.remark.trim(), status: MealStatus.Enabled, sort_number: 0 }], dishStepInserts: sis2 }); - if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; } - notifyOp({ module: "家庭菜单", action: "新增菜品", target: form.name.trim(), sourceTable: "b_meal_dish", sourceId: dishId }); + } else if (!isEdit) { + stopLoading({ type: "error", message: "请选择菜品图片" }); + return; } - stopLoading({ type: "success", message: "已保存" }); router.back(); - } catch { stopLoading({ type: "error", message: "保存失败" }); } - }, [familyId, userId, form, isEdit, editId, startLoading, stopLoading, uploadDishImage, saveDishBundle, saveDishChanges, notifyOp, router]); + if (isEdit) { + const ok = await saveDishChanges({ + updates: [ + { + id: editId, + name: form.name.trim(), + image_file_key: imageFileKey, + category_id: form.categoryId, + difficulty: form.difficulty, + cooking_time_minutes: form.cookingTime ? Number(form.cookingTime) : 0, + remark: form.remark.trim(), + }, + ], + }); + if (!ok) { + stopLoading({ type: "error", message: "保存失败" }); + return; + } + notifyOp({ + module: "家庭菜单", + action: "编辑菜品", + target: form.name.trim(), + sourceTable: "b_meal_dish", + sourceId: editId, + }); + } else { + const sc = steps.length + 1; + const idsRes = await getUniqueIdApi(sc); + const dishId = idsRes.data?.[0] ?? 0; + if (!dishId) { + stopLoading({ type: "error", message: "创建失败" }); + return; + } + const sids2 = idsRes.data?.slice(1) ?? []; + const sis2 = steps.map((s, i) => ({ + id: sids2[i] ?? 0, + family_id: familyId, + dish_id: dishId, + step_number: s.stepNumber, + description: s.description, + image_file_key: s.imageFileKey, + sort_number: i, + })); + const ok = await saveDishBundle({ + dishInserts: [ + { + id: dishId, + family_id: familyId, + name: form.name.trim(), + image_file_key: imageFileKey, + category_id: form.categoryId, + difficulty: form.difficulty, + cooking_time_minutes: form.cookingTime ? Number(form.cookingTime) : 0, + remark: form.remark.trim(), + status: MealStatus.Enabled, + sort_number: 0, + }, + ], + dishStepInserts: sis2, + }); + if (!ok) { + stopLoading({ type: "error", message: "保存失败" }); + return; + } + notifyOp({ + module: "家庭菜单", + action: "新增菜品", + target: form.name.trim(), + sourceTable: "b_meal_dish", + sourceId: dishId, + }); + } + + stopLoading({ type: "success", message: "已保存" }); + router.back(); + } catch { + stopLoading({ type: "error", message: "保存失败" }); + } + }, [ + familyId, + userId, + form, + isEdit, + editId, + startLoading, + stopLoading, + uploadDishImage, + saveDishBundle, + saveDishChanges, + notifyOp, + router, + ]); return ( @@ -131,44 +285,115 @@ export default function MealDishEdit() { - - {form.imageFilePath ? () : form.imageFileKey ? () : (点击上传菜品图片)} + + {form.imageFilePath ? ( + + ) : form.imageFileKey ? ( + + ) : ( + + + 点击上传菜品图片 + + )} - setForm((p) => ({ ...p, name: v }))} placeholder="输入菜品名称" /> + setForm((p) => ({ ...p, name: v }))} + placeholder="输入菜品名称" + /> - {enabledCats.length > 0 ? enabledCats.map((c) => { - const sel = form.categoryId === c.id; - return setForm((p) => ({ ...p, categoryId: sel ? null : c.id }))}>{c.name}; - }) : 暂无分类} + {enabledCats.length > 0 ? ( + enabledCats.map((c) => { + const sel = form.categoryId === c.id; + return ( + setForm((p) => ({ ...p, categoryId: sel ? null : c.id }))} + > + + {c.name} + + + ); + }) + ) : ( + 暂无分类 + )} - {[{ v: MealDishDifficulty.Easy, label: "简单" }, { v: MealDishDifficulty.Normal, label: "普通" }, { v: MealDishDifficulty.Hard, label: "复杂" }].map((d) => { + {[ + { v: MealDishDifficulty.Easy, label: "简单" }, + { v: MealDishDifficulty.Normal, label: "普通" }, + { v: MealDishDifficulty.Hard, label: "复杂" }, + ].map((d) => { const sel = form.difficulty === d.v; - return setForm((p) => ({ ...p, difficulty: d.v }))}>{d.label}; + return ( + setForm((p) => ({ ...p, difficulty: d.v }))} + > + + {d.label} + + + ); })} - setForm((p) => ({ ...p, cookingTime: v.replace(/[^0-9]/g, "") }))} placeholder="如:30" keyboardType="numeric" /> + + setForm((p) => ({ ...p, cookingTime: v.replace(/[^0-9]/g, "") })) + } + placeholder="如:30" + keyboardType="numeric" + /> -