diff --git a/code/app/app-go/cmd/apply_sql/main.go b/code/app/app-go/cmd/apply_sql/main.go deleted file mode 100644 index b7c45cb9..00000000 --- a/code/app/app-go/cmd/apply_sql/main.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "path/filepath" - "time" - - "allapp-go/internal/bootstrap/boots" - "allapp-go/internal/config" -) - -func main() { - if len(os.Args) != 2 { - fmt.Fprintln(os.Stderr, "usage: go run ./cmd/apply_sql ") - os.Exit(1) - } - - sqlPath, err := filepath.Abs(os.Args[1]) - if err != nil { - exitErr("resolve sql path", err) - } - - sqlBytes, err := os.ReadFile(sqlPath) - if err != nil { - exitErr("read sql file", err) - } - - cfg, err := config.Load() - if err != nil { - exitErr("load config", err) - } - - pool, err := boots.InitPgsql(cfg) - if err != nil { - exitErr("connect postgres", err) - } - defer pool.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil { - exitErr("execute sql", err) - } - - fmt.Printf("applied sql: %s\n", sqlPath) -} - -func exitErr(step string, err error) { - fmt.Fprintf(os.Stderr, "%s failed: %v\n", step, err) - os.Exit(1) -} diff --git a/code/app/app-go/cmd/check_clothing_tables/main.go b/code/app/app-go/cmd/check_clothing_tables/main.go deleted file mode 100644 index 8fba6b47..00000000 --- a/code/app/app-go/cmd/check_clothing_tables/main.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "time" - - "allapp-go/internal/bootstrap/boots" - "allapp-go/internal/config" -) - -func main() { - cfg, err := config.Load() - if err != nil { - exitErr("load config", err) - } - - pool, err := boots.InitPgsql(cfg) - if err != nil { - exitErr("connect postgres", err) - } - defer pool.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - rows, err := pool.Query(ctx, ` - select table_name - from information_schema.tables - where table_schema = 'public' - and table_name like 'b_clothing_%' - order by table_name - `) - if err != nil { - exitErr("query tables", err) - } - defer rows.Close() - - count := 0 - for rows.Next() { - var tableName string - if err := rows.Scan(&tableName); err != nil { - exitErr("scan table", err) - } - count++ - fmt.Println(tableName) - } - if err := rows.Err(); err != nil { - exitErr("read rows", err) - } - - fmt.Printf("total=%d\n", count) -} - -func exitErr(step string, err error) { - fmt.Fprintf(os.Stderr, "%s failed: %v\n", step, err) - os.Exit(1) -} diff --git a/code/app/app-go/cmd/check_removed_mime_columns/main.go b/code/app/app-go/cmd/check_removed_mime_columns/main.go deleted file mode 100644 index 80521e96..00000000 --- a/code/app/app-go/cmd/check_removed_mime_columns/main.go +++ /dev/null @@ -1,66 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "time" - - "allapp-go/internal/bootstrap/boots" - "allapp-go/internal/config" -) - -func main() { - cfg, err := config.Load() - if err != nil { - exitErr("load config", err) - } - - pool, err := boots.InitPgsql(cfg) - if err != nil { - exitErr("connect postgres", err) - } - defer pool.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - rows, err := pool.Query(ctx, ` - select table_name, column_name - from information_schema.columns - where table_schema = 'public' - and table_name in ( - 'b_finance_category_default', - 'b_finance_category', - 'b_clothing_clothes', - 'b_clothing_outfit' - ) - and column_name in ('icon_mime', 'image_mime', 'cover_mime') - order by table_name, column_name - `) - if err != nil { - exitErr("query columns", err) - } - defer rows.Close() - - count := 0 - for rows.Next() { - var tableName string - var columnName string - if err := rows.Scan(&tableName, &columnName); err != nil { - exitErr("scan column", err) - } - count++ - fmt.Printf("%s.%s\n", tableName, columnName) - } - if err := rows.Err(); err != nil { - exitErr("read rows", err) - } - - fmt.Printf("removed_mime_columns_remaining=%d\n", count) -} - -func exitErr(step string, err error) { - fmt.Fprintf(os.Stderr, "%s failed: %v\n", step, err) - os.Exit(1) -} diff --git a/code/app/app-go/cmd/import_finance_category_icons/main.go b/code/app/app-go/cmd/import_finance_category_icons/main.go deleted file mode 100644 index a40f23eb..00000000 --- a/code/app/app-go/cmd/import_finance_category_icons/main.go +++ /dev/null @@ -1,233 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "time" - - "allapp-go/internal/bootstrap/boots" - "allapp-go/internal/config" - "allapp-go/pkg/s3store" - "allapp-go/pkg/uniqueid" - - "github.com/jackc/pgx/v5" -) - -type iconManifestItem struct { - Name string `json:"name"` - Type int16 `json:"type"` - TypeName string `json:"typeName"` - Source string `json:"source"` - File string `json:"file"` - Key string `json:"key"` - Mime string `json:"mime"` - SortNumber int `json:"sortNumber"` - Width int `json:"width"` - Height int `json:"height"` -} - -func main() { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - cfg, err := config.Load() - if err != nil { - exitErr("load config", err) - } - - boots.InitUniqueId(cfg) - s3store.NewS3Client(cfg) - - pool, err := boots.InitPgsql(cfg) - if err != nil { - exitErr("connect postgres", err) - } - defer pool.Close() - - workspaceRoot, err := filepath.Abs("..") - if err != nil { - exitErr("resolve workspace", err) - } - - manifestPath := filepath.Join(workspaceRoot, "tools", "finance-category-icons", "dist", "manifest.json") - manifestBytes, err := os.ReadFile(manifestPath) - if err != nil { - exitErr("read manifest", err) - } - - var items []iconManifestItem - if err := json.Unmarshal(manifestBytes, &items); err != nil { - exitErr("parse manifest", err) - } - if len(items) == 0 { - exitErr("validate manifest", fmt.Errorf("manifest is empty")) - } - - tableName, err := resolveDefaultCategoryTable(ctx, pool) - if err != nil { - exitErr("resolve table", err) - } - - tx, err := pool.Begin(ctx) - if err != nil { - exitErr("begin transaction", err) - } - defer func() { - _ = tx.Rollback(ctx) - }() - - uploaded := 0 - inserted := 0 - updated := 0 - - for _, item := range items { - if err := validateItem(item); err != nil { - exitErr("validate item", err) - } - - filePath := filepath.Join(workspaceRoot, filepath.FromSlash(item.File)) - data, err := os.ReadFile(filePath) - if err != nil { - exitErr("read converted icon", err) - } - - if err := s3store.UploadToRustFS(ctx, bytes.NewReader(data), item.Key, int64(len(data)), item.Mime); err != nil { - exitErr("upload "+item.Key, err) - } - uploaded++ - - existingID, found, err := findExistingCategory(ctx, tx, tableName, item.Name, item.Type) - if err != nil { - exitErr("query existing category", err) - } - - now := time.Now() - if found { - if err := updateCategory(ctx, tx, tableName, existingID, item, now); err != nil { - exitErr("update category", err) - } - updated++ - continue - } - - if err := insertCategory(ctx, tx, tableName, uniqueid.NextId(), item, now); err != nil { - exitErr("insert category", err) - } - inserted++ - } - - if err := tx.Commit(ctx); err != nil { - exitErr("commit transaction", err) - } - - expenseCount, incomeCount, err := countImportedCategories(ctx, pool, tableName) - if err != nil { - exitErr("verify imported categories", err) - } - - fmt.Printf("Imported finance category icons successfully.\n") - fmt.Printf("table=%s uploaded=%d inserted=%d updated=%d total=%d\n", tableName, uploaded, inserted, updated, len(items)) - fmt.Printf("verified prefix=finance-category-default/ expense=%d income=%d total=%d\n", expenseCount, incomeCount, expenseCount+incomeCount) - for _, item := range items { - fmt.Printf("%s type=%d sort=%d key=%s\n", item.Name, item.Type, item.SortNumber, item.Key) - } -} - -func validateItem(item iconManifestItem) error { - if item.Name == "" || item.Key == "" || item.File == "" || item.Mime != "image/webp" { - return fmt.Errorf("invalid manifest item: %+v", item) - } - if item.Type != 0 && item.Type != 1 { - return fmt.Errorf("invalid type for %s: %d", item.Name, item.Type) - } - if item.Width != 256 || item.Height != 256 { - return fmt.Errorf("invalid dimensions for %s: %dx%d", item.Name, item.Width, item.Height) - } - return nil -} - -func resolveDefaultCategoryTable(ctx context.Context, q pgxQueryer) (string, error) { - candidates := []string{"finance_category_default", "b_finance_category_default"} - for _, table := range candidates { - var exists bool - if err := q.QueryRow(ctx, "select to_regclass($1) is not null", table).Scan(&exists); err != nil { - return "", err - } - if exists { - return table, nil - } - } - return "", fmt.Errorf("neither finance_category_default nor b_finance_category_default exists") -} - -func findExistingCategory(ctx context.Context, tx pgx.Tx, tableName string, name string, categoryType int16) (int64, bool, error) { - query := fmt.Sprintf("select id from %s where name=$1 and type=$2 order by id limit 1", safeTableName(tableName)) - var id int64 - err := tx.QueryRow(ctx, query, name, categoryType).Scan(&id) - if err == nil { - return id, true, nil - } - if err == pgx.ErrNoRows { - return 0, false, nil - } - return 0, false, err -} - -func updateCategory(ctx context.Context, tx pgx.Tx, tableName string, id int64, item iconManifestItem, now time.Time) error { - query := fmt.Sprintf(` - update %s - set icon_file_key=$1, sort_number=$2, status=1, update_time=$3 - where id=$4 - `, safeTableName(tableName)) - _, err := tx.Exec(ctx, query, item.Key, item.SortNumber, now, id) - return err -} - -func insertCategory(ctx context.Context, tx pgx.Tx, tableName string, id int64, item iconManifestItem, now time.Time) error { - query := fmt.Sprintf(` - insert into %s (id, name, type, icon_file_key, sort_number, status, create_time, update_time) - values ($1, $2, $3, $4, $5, 1, $6, $6) - `, safeTableName(tableName)) - _, err := tx.Exec(ctx, query, id, item.Name, item.Type, item.Key, item.SortNumber, now) - return err -} - -func countImportedCategories(ctx context.Context, q pgxQueryer, tableName string) (int, int, error) { - query := fmt.Sprintf(` - select - count(*) filter (where type = 0), - count(*) filter (where type = 1) - from %s - where icon_file_key like 'finance-category-default/%%' - and status = 1 - `, safeTableName(tableName)) - - var expenseCount int - var incomeCount int - if err := q.QueryRow(ctx, query).Scan(&expenseCount, &incomeCount); err != nil { - return 0, 0, err - } - return expenseCount, incomeCount, nil -} - -func safeTableName(tableName string) string { - switch tableName { - case "finance_category_default", "b_finance_category_default": - return tableName - default: - panic("unsafe table name: " + tableName) - } -} - -type pgxQueryer interface { - QueryRow(ctx context.Context, sql string, args ...any) pgx.Row -} - -func exitErr(step string, err error) { - fmt.Fprintf(os.Stderr, "%s failed: %v\n", step, err) - os.Exit(1) -} diff --git a/code/app/app-go/db/meal.sql b/code/app/app-go/db/meal.sql index 4857566f..868b8eb5 100644 --- a/code/app/app-go/db/meal.sql +++ b/code/app/app-go/db/meal.sql @@ -2,6 +2,7 @@ -- 餐食模块 数据库表结构 -- ============================================================ +drop table if exists b_meal_plan_rating; drop table if exists b_meal_dish_tag; drop table if exists b_meal_dish_ingredient; drop table if exists b_meal_dish_step; @@ -187,6 +188,24 @@ create table b_meal_plan_log ( update_by bigint ); +-- ============================================================ +-- 餐食安排评分 +-- rating: 1-5 星 +-- 同一家庭、同一安排、同一评分人只有一条记录(通过索引保证唯一) +-- ============================================================ +create table b_meal_plan_rating ( + id bigint primary key, + family_id bigint not null, + plan_id bigint not null, + rater_id bigint not null, + rating smallint not null, + content varchar(256) default '', + create_time timestamptz, + update_time timestamptz, + create_by bigint, + update_by bigint +); + -- ============================================================ -- 索引 -- ============================================================ @@ -245,3 +264,11 @@ create index idx_b_meal_plan_log_plan -- 活动日志:按安排和类型筛选 create index idx_b_meal_plan_log_plan_type on b_meal_plan_log (family_id, plan_id, type); + +-- 评分:按安排查询 +create index idx_b_meal_plan_rating_plan + on b_meal_plan_rating (family_id, plan_id, id); + +-- 评分:同一家庭、同一安排、同一评分人唯一 +create unique index idx_b_meal_plan_rating_plan_rater + on b_meal_plan_rating (family_id, plan_id, rater_id); diff --git a/code/app/app-rn/.env.development b/code/app/app-rn/.env.development index ea10c9ec..9d252886 100644 --- a/code/app/app-rn/.env.development +++ b/code/app/app-rn/.env.development @@ -1 +1,3 @@ -EXPO_PUBLIC_API_URL=http://192.168.5.63:8087 \ No newline at end of file +EXPO_PUBLIC_API_URL=http://192.168.5.72:8087 +EXPO_PUBLIC_G3_URL=http://118.89.70.199:9001 +EXPO_PUBLIC_G3_BUCKET=test \ No newline at end of file diff --git a/code/app/app-rn/package.json b/code/app/app-rn/package.json index 7efe92b7..d2d9897c 100644 --- a/code/app/app-rn/package.json +++ b/code/app/app-rn/package.json @@ -36,7 +36,7 @@ "expo-image": "~56.0.11", "expo-linear-gradient": "~56.0.4", "expo-linking": "~56.0.14", - "expo-notifications": "^57.0.3", + "expo-notifications": "^56.0.19", "expo-qq": "^0.1.8", "expo-router": "~56.2.11", "expo-splash-screen": "~56.0.10", diff --git a/code/app/app-rn/pnpm-lock.yaml b/code/app/app-rn/pnpm-lock.yaml index 60ff5438..b02f30f3 100644 --- a/code/app/app-rn/pnpm-lock.yaml +++ b/code/app/app-rn/pnpm-lock.yaml @@ -72,8 +72,8 @@ importers: specifier: ~56.0.14 version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) expo-notifications: - specifier: ^57.0.3 - version: 57.0.3(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + specifier: ^56.0.19 + version: 56.0.19(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-qq: specifier: ^0.1.8 version: 0.1.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) @@ -705,8 +705,8 @@ packages: resolution: {integrity: sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==} engines: {node: '>=20.12.0'} - '@expo/env@2.4.1': - resolution: {integrity: sha512-3c9Mg9x0HmGPEsVrGAGyEDJsNUOZ55cZvZ47/HLmXh7MHV9Zv7My73wThklKrObaBBoMfE4YqpKjYKDRzojpjQ==} + '@expo/env@2.3.1': + resolution: {integrity: sha512-JvBdZa5OkTd+dGEtt3fLW9OF6RlIp9SNu9VyMSiWPV5szMDTSAYbX8Uj3cRvZcU1zzaRbqnTSsHk2AE8WXHfxQ==} engines: {node: '>=20.12.0'} '@expo/expo-modules-macros-plugin@0.2.2': @@ -719,9 +719,6 @@ packages: '@expo/image-utils@0.10.1': resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==} - '@expo/image-utils@0.11.1': - resolution: {integrity: sha512-0JueH4vdgAZQmhlSYFvTQzt4b4NO5cnByDuApw7bMUIjhwLRnT46Ki3ritMrzJMQaO2lLK2flInZbsZbOuy8nw==} - '@expo/inline-modules@0.0.12': resolution: {integrity: sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==} @@ -786,14 +783,6 @@ packages: typescript: optional: true - '@expo/require-utils@57.0.1': - resolution: {integrity: sha512-uXen5/4x7j60I5slShgZr5QEtJDBK8homFiNLDnDrNrxZhrRHXASo0H6JArs3/1PDzw1wahzhGWg2WKuYyZd0A==} - peerDependencies: - typescript: ^5.0.0 || ^5.0.0-0 || ^6.0.0 - peerDependenciesMeta: - typescript: - optional: true - '@expo/router-server@56.0.14': resolution: {integrity: sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==} peerDependencies: @@ -2594,8 +2583,8 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - expo-application@57.0.0: - resolution: {integrity: sha512-vNrnYlqtRS5pFUm1tjut4dogZxW/uhmMOD5GeLQ8Z9Ji3KMYKLOy5dG0BlUsMrcSgyPJvJqP0TFopkEZ02ec6g==} + expo-application@56.0.3: + resolution: {integrity: sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==} peerDependencies: expo: '*' @@ -2631,8 +2620,8 @@ packages: expo: '*' react-native: '*' - expo-constants@57.0.3: - resolution: {integrity: sha512-BghbZlzwFnA22BG0CWv6W29zx8w19FozYPfSeZ3HjMitoy4aAmF1FnFbbjYSmZz6HHXXTWOfqwobOEIaglfzxg==} + expo-constants@56.0.20: + resolution: {integrity: sha512-4HgoVvUiMvcqujr/CUj7L1tumLWj8WX0PLM77OriDPoaJrMEwUHd841m4o4IoUyMPVT8XTiTiPFwJtGali8+9Q==} peerDependencies: expo: '*' react-native: '*' @@ -2716,8 +2705,8 @@ packages: peerDependencies: react-native: '*' - expo-notifications@57.0.3: - resolution: {integrity: sha512-BTfrlnWF4EQZ0Oufsj00OesdxxlXCaC3OexnJTWKdQJ2a26w/bIAM3kXIxHsizmBWaHt5EJc5LvuDzVeUt7JJA==} + expo-notifications@56.0.19: + resolution: {integrity: sha512-3sTegn8Wwu84TLIAxAqC725kqQ412U2NG9qUH4k3gQzBU49vROhvsslj7gARgvOJY8KohuJz82csuZfZnHDDHw==} peerDependencies: expo: '*' react: '*' @@ -5612,7 +5601,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/env@2.4.1': + '@expo/env@2.3.1': dependencies: chalk: 4.1.2 debug: 4.4.3 @@ -5651,19 +5640,6 @@ snapshots: - supports-color - typescript - '@expo/image-utils@0.11.1(typescript@6.0.3)': - dependencies: - '@expo/require-utils': 57.0.1(typescript@6.0.3) - '@expo/spawn-async': 1.8.0 - chalk: 4.1.2 - getenv: 2.0.0 - jimp-compact: 0.16.1 - parse-png: 2.1.0 - semver: 7.8.5 - transitivePeerDependencies: - - supports-color - - typescript - '@expo/inline-modules@0.0.12(typescript@6.0.3)': dependencies: '@expo/config-plugins': 56.0.9(typescript@6.0.3) @@ -5816,16 +5792,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/require-utils@57.0.1(typescript@6.0.3)': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - optionalDependencies: - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@expo/router-server@56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 @@ -7667,7 +7633,7 @@ snapshots: event-target-shim@5.0.1: {} - expo-application@57.0.0(expo@56.0.12): + expo-application@56.0.3(expo@56.0.12): dependencies: expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) @@ -7709,9 +7675,9 @@ snapshots: transitivePeerDependencies: - supports-color - expo-constants@57.0.3(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)): + expo-constants@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)): dependencies: - '@expo/env': 2.4.1 + '@expo/env': 2.3.1 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) transitivePeerDependencies: @@ -7798,14 +7764,14 @@ snapshots: dependencies: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) - expo-notifications@57.0.3(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3): + expo-notifications@56.0.19(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3): dependencies: - '@expo/image-utils': 0.11.1(typescript@6.0.3) + '@expo/image-utils': 0.10.1(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) - expo-application: 57.0.0(expo@56.0.12) - expo-constants: 57.0.3(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)) + expo-application: 56.0.3(expo@56.0.12) + expo-constants: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) transitivePeerDependencies: 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 697fab3b..b0cf552e 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 @@ -324,6 +324,7 @@ export default function ChatPage() { const [inputText, setInputText] = useState(""); const [adminRole, setAdminRole] = useState(0); const listRef = useRef(null); + const nearBottomRef = useRef(true); // ---- optimistic send ---- type PendingMsg = { @@ -446,22 +447,28 @@ export default function ChatPage() { }); }, []); + // 延迟多次滚底,确保 FlatList 布局稳定后能滚到位 + const scrollToBottomMulti = useCallback((animated = true, times = 3, delay = 80) => { + for (let i = 0; i < times; i++) { + setTimeout(() => scrollToBottom(animated), i * delay); + } + }, [scrollToBottom]); + // Keyboard appears → 等键盘动画完成再滚到底 useEffect(() => { const sub = Keyboard.addListener("keyboardDidShow", () => { - setTimeout(() => scrollToBottom(true), 150); - setTimeout(() => scrollToBottom(true), 350); + scrollToBottomMulti(true, 3, 120); }); return () => sub.remove(); - }, [scrollToBottom]); + }, [scrollToBottomMulti]); // 首次加载完成后滚到底部 const initialScrollDone = useRef(false); useEffect(() => { if (loading || initialScrollDone.current || allMessages.length === 0) return; initialScrollDone.current = true; - scrollToBottom(true); - }, [loading, allMessages.length, scrollToBottom]); + scrollToBottomMulti(false, 4, 100); + }, [loading, allMessages.length, scrollToBottomMulti]); // 成员映射 useEffect(() => { @@ -590,15 +597,14 @@ export default function ChatPage() { const msgCountRef = useRef(0); useEffect(() => { if (msgCountRef.current === 0 && allMessages.length > 0) { - // 首次有数据,只记录数量不滚动(由 initialScrollDone effect 处理) msgCountRef.current = allMessages.length; return; } if (allMessages.length > msgCountRef.current) { - scrollToBottom(true); + scrollToBottomMulti(true, 3, 80); } msgCountRef.current = allMessages.length; - }, [allMessages.length, scrollToBottom]); + }, [allMessages.length, scrollToBottomMulti]); if (loading) { return ( @@ -629,7 +635,7 @@ export default function ChatPage() { placeholderTextColor="#86A19C" value={inputText} onChangeText={setInputText} - onFocus={() => setTimeout(() => scrollToBottom(true), 250)} + onFocus={() => scrollToBottomMulti(true, 3, 100)} onSubmitEditing={handleSend} returnKeyType="send" multiline @@ -652,15 +658,19 @@ export default function ChatPage() { } data={allMessages} keyExtractor={(item) => String(item.id)} - contentContainerStyle={{ paddingBottom: 12 }} + contentContainerStyle={{ paddingBottom: 40 }} onScroll={({ nativeEvent }) => { - if (nativeEvent.contentOffset.y < 30 && hasMore && !loadingMore) { + const { contentOffset, contentSize, layoutMeasurement } = nativeEvent; + nearBottomRef.current = contentSize.height - contentOffset.y - layoutMeasurement.height < 100; + if (contentOffset.y < 30 && hasMore && !loadingMore) { handleLoadMore(); } }} scrollEventThrottle={200} onContentSizeChange={() => { - if (!loadingMore) scrollToBottom(false); + if (!loadingMore && nearBottomRef.current) { + scrollToBottomMulti(false, 2, 50); + } }} ListHeaderComponent={ loadingMore ? ( 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 71224551..d30ad7c8 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 @@ -1,7 +1,6 @@ import React from "react"; import LayoutView from "@/components/layout/view"; -import { Text, View } from "react-native"; -import { Avatar } from "heroui-native"; +import { Image, Text, View } from "react-native"; import { ChevronRightIcon } from "lucide-react-native"; import { ListGroup, PressableFeedback } from "heroui-native"; import { Ionicons } from "@expo/vector-icons"; @@ -19,10 +18,13 @@ export default function My() { - - - {userStore.userInfo.nickname} - + {userStore.userInfo.avatar ? ( + + ) : ( + + {userStore.userInfo.nickname?.charAt(0) || "?"} + + )} 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 b5490692..c8ecf3e4 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 @@ -26,6 +26,7 @@ import { Ionicons } from "@expo/vector-icons"; import { withUniwind } from "uniwind"; import { FadeIn } from "react-native-reanimated"; import { toast } from "sonner-native"; +import { buildFileUrl } from "@/utils/file"; import { useRouter } from "expo-router"; const StyledIonicons = withUniwind(Ionicons); @@ -164,8 +165,8 @@ export default function FamilyMember() { - - {memberInfo.nickname} + {memberInfo.avatar ? : null} + {memberInfo.nickname?.charAt(0) || "?"} {memberInfo.nickname} 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 1b1129b9..c223722d 100644 --- a/code/app/app-rn/src/app/(main)/family/user.tsx +++ b/code/app/app-rn/src/app/(main)/family/user.tsx @@ -1,9 +1,8 @@ import { useEffect, useRef, useState } from "react"; -import { InteractionManager, Keyboard, View, Text, Pressable } from "react-native"; +import { Image, InteractionManager, Keyboard, View, Text, Pressable } from "react-native"; import PageLayout from "@/layouts/PageLayout"; import LayoutView from "@/components/layout/view"; import { - Avatar, PressableFeedback, ListGroup, Separator, @@ -523,10 +522,13 @@ export default function UserSetting() { }} className="w-full h-full rounded-full overflow-hidden" > - - - {userInfo.name} - + {userStore.userInfo.avatar ? ( + + ) : ( + + {(userStore.userInfo.nickname || "?").charAt(0)} + + )} 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 f70dc3ed..58f64114 100644 --- a/code/app/app-rn/src/app/meal/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/meal/(tabs)/home/index.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Platform, Text, TouchableOpacity, View } from "react-native"; -import { router } from "expo-router"; +import { Text, TouchableOpacity, View } from "react-native"; +import { router, useFocusEffect } from "expo-router"; import dayjs from "dayjs"; -import DateTimePicker, { type DateTimePickerEvent } from "@react-native-community/datetimepicker"; -import { Button } from "heroui-native"; +import DateTime from "@/components/DateTime"; +import { Avatar, Button } from "heroui-native"; import { BadgeCheckIcon, CalendarDaysIcon, @@ -11,7 +11,6 @@ import { ChevronRightIcon, CircleIcon, PlusIcon, - UserIcon, } from "lucide-react-native"; import { toast } from "sonner-native"; import BottomSheet from "@/components/BottomSheet"; @@ -23,6 +22,7 @@ import { useListLoading, useLoading } from "@/hooks/useLoading"; import { loadDataApi } from "@/request/api"; import { useFamilyStore, useMealStore, useUserStore } from "@/store"; import { MealPlanStatus } from "@/types/meal"; +import { buildFileUrl } from "@/utils/file"; // ============================================================ // 类型 @@ -31,6 +31,7 @@ import { MealPlanStatus } from "@/types/meal"; interface FMember { user_id: number; nickname: string; + avatar?: string; } // ============================================================ @@ -58,7 +59,7 @@ export default function MealHomePage() { const today = dayjs().format("YYYY-MM-DD"); const tomorrow = dayjs().add(1, "day").format("YYYY-MM-DD"); const [planDate, setPlanDate] = useState(today); - const [showDate, setShowDate] = useState(false); + const [showDatePicker, setShowDatePicker] = useState(false); const [members, setMembers] = useState([]); const [allItems, setAllItems] = useState([]); const isFirstLoad = useRef(true); @@ -102,7 +103,7 @@ export default function MealHomePage() { loadDataApi({ view_name: "v_family_member", search_condition: "family_id = ?", - search_columns: ["user_id", "nickname"], + search_columns: ["user_id", "nickname", "avatar"], order_by: "id asc", args: [familyId], }), @@ -114,7 +115,7 @@ export default function MealHomePage() { const mr = await loadDataApi({ view_name: "v_family_member", search_condition: "family_id = ?", - search_columns: ["user_id", "nickname"], + search_columns: ["user_id", "nickname", "avatar"], order_by: "id asc", args: [familyId], }); @@ -142,12 +143,19 @@ export default function MealHomePage() { if (familyId && !isFirstLoad.current) runRefresh(loadData); }, [planDate]); + // 从详情页返回时自动刷新 + useFocusEffect( + useCallback(() => { + if (familyId && !isFirstLoad.current) runRefresh(loadData); + }, [familyId, loadData]), + ); + // ======== 辅助 ======== - const getCookName = useCallback( + const getCookMember = useCallback( (c?: number | null) => { if (!c) return null; - return members.find((m) => m.user_id === c)?.nickname ?? `用户${c}`; + return members.find((m) => m.user_id === c) ?? null; }, [members], ); @@ -326,7 +334,7 @@ export default function MealHomePage() { setShowDate(true)} + onPress={() => setShowDatePicker(true)} > {planDate} @@ -344,7 +352,7 @@ export default function MealHomePage() { })) : []; const hasPlan = !!plan; - const cookName = getCookName(plan?.cook_by); + const cookMember = getCookMember(plan?.cook_by); const isPlanning = plan?.status === MealPlanStatus.Planning; const isConfirmed = plan?.status === MealPlanStatus.Confirmed; const isCompleted = plan?.status === MealPlanStatus.Completed; @@ -365,21 +373,23 @@ export default function MealHomePage() { {/* 卡片头部 */} - {slot.name} {/* 做饭人 */} - {cookName && ( + {cookMember && ( - + + {cookMember.avatar + ? + : null} + + {cookMember.nickname?.charAt(0)} + + - {cookName} + {cookMember.nickname} )} @@ -406,29 +416,7 @@ export default function MealHomePage() { )} - - {/* 创建 / 添加按钮 */} - {hasPlan - ? ( - router.push(`/meal/plan/detail/${plan.id}` as any)} - > - - - ) - : ( - handleCreatePlan(slot.id)} - disabled={creatingSlotId === slot.id} - > - - - )} - + {/* 菜品列表 / 空状态 */} {hasPlan && pitems.length > 0 @@ -543,18 +531,14 @@ export default function MealHomePage() { - {/* 日期选择 DateTimePicker */} - {showDate && ( - { - setShowDate(false); - if (date) setPlanDate(dayjs(date).format("YYYY-MM-DD")); - }} - /> - )} + {/* 日期选择器 */} + setShowDatePicker(false)} + onConfirm={(value) => setPlanDate(value)} + mode="date" + /> diff --git a/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx b/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx index 1bf8f7f9..5f496c4f 100644 --- a/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx +++ b/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Text, TextInput, TouchableOpacity, View } from "react-native"; +import { ActivityIndicator, NativeScrollEvent, NativeSyntheticEvent, ScrollView, Text, TextInput, TouchableOpacity, View } from "react-native"; import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; import dayjs from "dayjs"; -import { Avatar, Button, Input, Label } from "heroui-native"; -import { BadgeCheckIcon, CheckCircleIcon, CircleIcon, FilterIcon, PlusIcon, RotateCcwIcon, SendIcon, UserIcon } from "lucide-react-native"; +import { Avatar, Button, Label } from "heroui-native"; +import { BadgeCheckIcon, CheckCircleIcon, CircleIcon, FilterIcon, PlusIcon, RotateCcwIcon, SendIcon, StarIcon, UserIcon } from "lucide-react-native"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; import { toast } from "sonner-native"; import PageLayout from "@/layouts/PageLayout"; import LayoutView from "@/components/layout/view"; @@ -13,7 +14,7 @@ import EmptyState from "@/components/EmptyStatus"; import { LoadingOverlay } from "@/components/LoadingOverlay"; import { useLoading } from "@/hooks/useLoading"; import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier"; -import { getUniqueIdApi, loadDataApi } from "@/request/api"; +import { getUniqueIdApi, loadDataApi, loadDataPageApi } from "@/request/api"; import { useFamilyStore, useMealStore, useUserStore } from "@/store"; import { MealPlanLogType, MealPlanStatus } from "@/types/meal"; import { buildFileUrl } from "@/utils/file"; @@ -103,6 +104,9 @@ export default function MealPlanDetail() { const removeDishFromPlan = useMealStore((s) => s.removeDishFromPlan); const confirmPlan = useMealStore((s) => s.confirmPlan); const completePlan = useMealStore((s) => s.completePlan); + const planRatings = useMealStore((s) => s.planRatings); + const fetchPlanRatings = useMealStore((s) => s.fetchPlanRatings); + const ratePlan = useMealStore((s) => s.ratePlan); const { isLoading, startLoading, stopLoading } = useLoading(); const notifyOp = useModuleChatNotifier(); @@ -115,9 +119,15 @@ export default function MealPlanDetail() { const [sending, setSending] = useState(false); const [showAddDish, setShowAddDish] = useState(false); const [addDishCandidates, setAddDishCandidates] = useState([]); + const [dishTotal, setDishTotal] = useState(0); + const [loadingMoreDishes, setLoadingMoreDishes] = useState(false); const [showCookSheet, setShowCookSheet] = useState(false); const [completing, setCompleting] = useState(false); const [confirming, setConfirming] = useState(false); + const [showRateSheet, setShowRateSheet] = useState(false); + const [rateValue, setRateValue] = useState(5); + const [rateContent, setRateContent] = useState(""); + const [rating, setRating] = useState(false); const scrollRef = useRef(null); // 加菜筛选 @@ -177,6 +187,14 @@ export default function MealPlanDetail() { const isCompleted = plan?.status === MealPlanStatus.Completed; const isLocked = false; + // 评分 + const avgRating = useMemo(() => { + if (planRatings.length === 0) return 0; + const sum = planRatings.reduce((acc, r) => acc + (r.rating ?? 0), 0); + return parseFloat((sum / planRatings.length).toFixed(1)); + }, [planRatings]); + const hasRated = useMemo(() => planRatings.some((r) => r.rater_id === userId), [planRatings, userId]); + // ======== 数据加载 ======== const loadData = useCallback(async () => { @@ -266,8 +284,9 @@ export default function MealPlanDetail() { fetchTags(familyId); fetchDishTags(familyId); fetchSlots(familyId); + fetchPlanRatings(familyId, planId); } - }, [familyId, planId, loadData, fetchDishes, fetchCategories, fetchTags, fetchDishTags, fetchSlots]), + }, [familyId, planId, loadData, fetchDishes, fetchCategories, fetchTags, fetchDishTags, fetchSlots, fetchPlanRatings]), ); // 重新加载 plan 数据以获取最新 status @@ -464,20 +483,76 @@ export default function MealPlanDetail() { ], ); - // 打开加菜面板 + // 打开加菜面板(加载首页) const openAddDish = useCallback(async () => { if (!familyId || isLocked) return; - const ir = await loadDataApi({ + setShowAddDish(true); + const res = await loadDataPageApi({ view_name: "b_meal_dish", search_condition: "family_id = ? and status = 1", search_columns: ["id", "name", "image_file_key", "category_id"], - order_by: "sort_number asc", + order_by: "sort_number asc, id asc", args: [familyId], + page: 1, + page_size: 20, }); - setAddDishCandidates(ir.isSuccess ? ir.data ?? [] : []); - setShowAddDish(true); + if (res.isSuccess) { + const { rows, total } = res.data; + setAddDishCandidates(rows); + setDishTotal(total); + } else { + setAddDishCandidates([]); + setDishTotal(0); + } }, [familyId, isLocked]); + // 滚动加载更多菜品 + const loadMoreDishes = useCallback(async () => { + if (!familyId || loadingMoreDishes || addDishCandidates.length >= dishTotal) return; + setLoadingMoreDishes(true); + try { + const page = Math.floor(addDishCandidates.length / 20) + 1; + const res = await loadDataPageApi({ + view_name: "b_meal_dish", + search_condition: "family_id = ? and status = 1", + search_columns: ["id", "name", "image_file_key", "category_id"], + order_by: "sort_number asc, id asc", + args: [familyId], + page, + page_size: 20, + }); + if (res.isSuccess) { + const { rows, total } = res.data; + setAddDishCandidates((prev) => [...prev, ...rows]); + setDishTotal(total); + } + } finally { + setLoadingMoreDishes(false); + } + }, [familyId, loadingMoreDishes, addDishCandidates.length, dishTotal]); + + // 提交评分 + const handleRate = useCallback(async () => { + if (!familyId || !planId || rating) return; + setRating(true); + try { + const ok = await ratePlan({ + familyId, planId, rating: rateValue, content: rateContent.trim(), userId, + }); + if (ok) { + setShowRateSheet(false); + await fetchPlanRatings(familyId, planId); + toast.success("评价成功"); + } else { + toast.error("评价失败"); + } + } catch { + toast.error("评价失败"); + } finally { + setRating(false); + } + }, [familyId, planId, rating, rateValue, rateContent, userId, ratePlan, fetchPlanRatings]); + // ======== 渲染 ======== // 不存在且非加载中 @@ -497,14 +572,15 @@ export default function MealPlanDetail() { return ( - - + + + {/* ======== 头部 ======== */} {/* 日期 + 状态 */} @@ -533,9 +609,17 @@ export default function MealPlanDetail() { - {/* 做饭人 + 确认/完成按钮(同行) */} + {/* 评分星星展示 */} + {avgRating > 0 && ( + + + {avgRating} + ({planRatings.length}) + + )} + + {/* 做饭人 */} - {/* 做饭人:标签 + 选择 */} 做饭人 {cookMember ? ( @@ -569,28 +653,6 @@ export default function MealPlanDetail() { )} - - {/* 确认 / 完成 小按钮 */} - {isPlanning && ( - - {confirming ? : } - 确认菜单 - - )} - {isConfirmed && ( - - {completing ? : } - 完成菜单 - - )} @@ -650,6 +712,40 @@ export default function MealPlanDetail() { )} )} + {/* 确认 / 完成 / 评价按钮 — 菜品列表右下角 */} + {(planItemsWithDish.length > 0 || (isCompleted && !hasRated)) && ( + + {isPlanning && ( + + {confirming ? : } + 确认菜单 + + )} + {isConfirmed && ( + + {completing ? : } + 完成菜单 + + )} + {isCompleted && !hasRated && ( + { setRateValue(5); setRateContent(""); setShowRateSheet(true); }} + > + + 评价 + + )} + + )} {/* ======== 时间轴 ======== */} @@ -724,6 +820,50 @@ export default function MealPlanDetail() { )} + {/* ======== 评分列表 ======== */} + {planRatings.length > 0 && ( + + + 评价 · {planRatings.length} 条 + + {planRatings.map((r) => { + const m = members.find((m2) => m2.user_id === r.rater_id); + return ( + + + {m?.avatar + ? + : null} + + {m?.nickname?.charAt(0) ?? "?"} + + + + + + {m?.nickname ?? `用户${r.rater_id}`} + + + {[1,2,3,4,5].map((s) => ( + + ))} + + + {r.content ? ( + {r.content} + ) : null} + + + ); + })} + + )} + {/* 底部间距 */} @@ -752,75 +892,86 @@ export default function MealPlanDetail() { )} + + - {/* ======== 添加菜品 Sheet ======== */} - { setShowAddDish(v); if (!v) { setDishKeyword(""); setDishFilterCatId(null); setDishFilterTagIds([]); } }}> - - - - 添加菜品 - {/* 搜索 + 筛选 */} - - - - - setShowDishFilterSheet(true)} - > - - 筛选{dishFilterCount > 0 ? ` ${dishFilterCount}` : ""} - + {/* ======== 添加菜品 Sheet ======== */} + { setShowAddDish(v); if (!v) { setDishKeyword(""); setDishFilterCatId(null); setDishFilterTagIds([]); setDishTotal(0); setAddDishCandidates([]); } }}> + + + + {/* 搜索 + 筛选按钮 */} + + + - - {filteredDishCandidates.length > 0 - ? ( + setShowDishFilterSheet(true)} + > + + 筛选{dishFilterCount > 0 ? ` ${dishFilterCount}` : ""} + + + + {/* 菜品列表 */} + ) => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; + if (contentOffset.y + layoutMeasurement.height >= contentSize.height - 100) { + await loadMoreDishes(); + } + }} + > + {filteredDishCandidates.length > 0 + ? ( + <> {filteredDishCandidates .filter((d: any) => !existingDishIds.has(d.id)) .map((d: any) => ( addDish(d.id, d.name)} > - - - - + + {d.name} ))} - ) - : 没有匹配的菜品} - - - - - + {loadingMoreDishes && } + {addDishCandidates.length >= dishTotal && dishTotal > 0 && ( + 已加载全部菜品 + )} + + ) + : 没有匹配的菜品} + + + + - {/* ======== 菜品筛选 Sheet ======== */} - - - - + {/* ======== 菜品筛选 Sheet ======== */} + + + + 筛选菜品 @@ -882,15 +1033,15 @@ export default function MealPlanDetail() { - - - + + + - {/* ======== 做饭人选择 Sheet ======== */} - - - - + {/* ======== 做饭人选择 Sheet ======== */} + + + + 选择做饭人 {members.length > 0 ? ( @@ -925,10 +1076,49 @@ export default function MealPlanDetail() { - - - - + + + + {/* ======== 评价弹窗 ======== */} + + + + + 评价菜单 + + {/* 星级 */} + + {[1, 2, 3, 4, 5].map((s) => ( + setRateValue(s)}> + + + ))} + + + {/* 评价文字 */} + + + + + + + + + + ); diff --git a/code/app/app-rn/src/app/todo/(tabs)/_layout.tsx b/code/app/app-rn/src/app/todo/(tabs)/_layout.tsx index 32869bdf..6d85fab4 100644 --- a/code/app/app-rn/src/app/todo/(tabs)/_layout.tsx +++ b/code/app/app-rn/src/app/todo/(tabs)/_layout.tsx @@ -1,11 +1,24 @@ import { Stack } from "expo-router"; -import { ListTodoIcon, PlusCircleIcon } from "lucide-react-native"; +import { CalendarIcon, ChartNoAxesColumnIcon, ListTodoIcon } from "lucide-react-native"; import ModuleLayout from "@/layouts/ModuleLayout"; import { TabbarItem } from "@/components/layout/tabbar"; const tabs: TabbarItem[] = [ - { id: "/todo/home", icon: ListTodoIcon, title: "待办" }, - { id: "/todo/add", icon: PlusCircleIcon, title: "新建" }, + { + id: "/todo/home", + icon: ListTodoIcon, + title: "待办", + }, + { + id: "/todo/calendar", + icon: CalendarIcon, + title: "日历", + }, + { + id: "/todo/stats", + icon: ChartNoAxesColumnIcon, + title: "统计", + }, ]; export default function TodoLayout() { diff --git a/code/app/app-rn/src/app/todo/(tabs)/calendar/index.tsx b/code/app/app-rn/src/app/todo/(tabs)/calendar/index.tsx new file mode 100644 index 00000000..7c3ef44d --- /dev/null +++ b/code/app/app-rn/src/app/todo/(tabs)/calendar/index.tsx @@ -0,0 +1,244 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { useFocusEffect, useRouter } from "expo-router"; +import dayjs from "dayjs"; +import { Avatar } from "heroui-native"; +import { CalendarIcon, CircleIcon, RotateCcwIcon, StarIcon } from "lucide-react-native"; +import LayoutView from "@/components/layout/view"; +import { useListLoading } from "@/hooks/useLoading"; +import { useTodoStore, useFamilyStore } from "@/store"; +import { Todo, TodoPriority, TodoStatus } from "@/types/todo"; +import { buildFileUrl } from "@/utils/file"; + +const PRIORITY_COLORS: Record = { + [TodoPriority.Normal]: "", + [TodoPriority.Important]: "border-l-amber-500", + [TodoPriority.Urgent]: "border-l-red-500", +}; + +const STATUS_COLORS: Record = { + [TodoStatus.Unclaimed]: "bg-gray-400", + [TodoStatus.InProgress]: "bg-blue-500", + [TodoStatus.Completed]: "bg-green-500", + [TodoStatus.Overdue]: "bg-red-500", +}; + +export default function TodoCalendar() { + const router = useRouter(); + const familyId = useFamilyStore((s) => s.getFamilyId()); + const todos = useTodoStore((s) => s.todos); + const members = useTodoStore((s) => s.members); + const todoRatings = useTodoStore((s) => s.todoRatings); + const fetchAll = useTodoStore((s) => s.fetchAll); + const { refreshing, runInitial, runRefresh } = useListLoading(); + + const load = useCallback(async () => { + if (!familyId) return; + await fetchAll(familyId); + }, [familyId, fetchAll]); + + useEffect(() => { if (familyId) runInitial(load); }, [familyId]); + useFocusEffect(useCallback(() => { load(); }, [load])); + + const memberMap = useMemo(() => new Map(members.map((m) => [m.user_id, m])), [members]); + const ratingMap = useMemo(() => { + const m = new Map(); + const groups = new Map(); + todoRatings.forEach((r) => { + const arr = groups.get(r.todo_id) ?? []; + arr.push(r.rating); + groups.set(r.todo_id, arr); + }); + groups.forEach((arr, todoId) => { + m.set(todoId, { count: arr.length, avg: Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 10) / 10 }); + }); + return m; + }, [todoRatings]); + + const getDisplayStatus = useCallback((todo: Todo) => { + if (todo.status === TodoStatus.Completed || todo.status === TodoStatus.Overdue) return todo.status; + if (todo.status === TodoStatus.InProgress && todo.deadline && dayjs().isAfter(dayjs(todo.deadline))) { + return TodoStatus.Overdue; + } + return todo.status; + }, []); + + // 按日期分组 + const dateGroups = useMemo(() => { + const today = dayjs().startOf("day"); + const groups = new Map(); + + // 有截止时间的任务按日期分组 + todos.forEach((t) => { + if (!t.deadline) return; + const dateKey = dayjs(t.deadline).format("YYYY-MM-DD"); + const arr = groups.get(dateKey) ?? []; + arr.push(t); + groups.set(dateKey, arr); + }); + + // 无截止时间的单独分组 + const noDeadline = todos.filter((t) => !t.deadline); + + // 排序日期 + const sortedDates = Array.from(groups.keys()).sort((a, b) => a.localeCompare(b)); + + // 分组:过期、今天、未来、无截止时间 + const overdue: { date: string; items: Todo[] }[] = []; + const upcoming: { date: string; items: Todo[] }[] = []; + + sortedDates.forEach((dateKey) => { + const items = groups.get(dateKey) ?? []; + const date = dayjs(dateKey); + if (date.isBefore(today, "day")) { + overdue.push({ date: dateKey, items }); + } else { + upcoming.push({ date: dateKey, items }); + } + }); + + return { overdue, upcoming, noDeadline, totalWithDeadline: todos.filter((t) => !!t.deadline).length }; + }, [todos]); + + const formatDateLabel = (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 "明天"; + if (diff === -1) return "昨天"; + return d.format("M月D日 ddd"); + }; + + const renderTodoCard = (todo: Todo) => { + const status = getDisplayStatus(todo); + const assignee = todo.assignee_id ? memberMap.get(todo.assignee_id) : null; + const rating = ratingMap.get(todo.id); + const isOverdue = status === TodoStatus.Overdue; + const isCompleted = status === TodoStatus.Completed; + + return ( + router.push(`/todo/detail/${todo.id}` as any)} + > + + {/* 状态圆点 */} + + + + {todo.name} + + {todo.description ? ( + {todo.description} + ) : null} + + {todo.deadline && ( + + {dayjs(todo.deadline).format("HH:mm")} + + )} + {assignee && ( + + + {assignee.avatar ? : null} + {assignee.nickname?.charAt(0)} + + {assignee.nickname} + + )} + {rating && rating.count > 0 && ( + + + {rating.avg} + + )} + + + + + ); + }; + + const renderHeader = () => ( + + 任务日历 + + {dateGroups.totalWithDeadline} 项 + runRefresh(load)}> + + + + + ); + + return ( + + runRefresh(load)} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }} + > + {renderHeader()} + + {dateGroups.totalWithDeadline === 0 && dateGroups.noDeadline.length === 0 ? ( + + + 暂无任务 + + ) : ( + <> + {/* 过期任务 */} + {dateGroups.overdue.length > 0 && ( + + + + 已过期 + {dateGroups.overdue.reduce((s, g) => s + g.items.length, 0)} 项 + + {dateGroups.overdue.map((group) => ( + + {formatDateLabel(group.date)} + {group.items.map(renderTodoCard)} + + ))} + + )} + + {/* 即将到来 */} + {dateGroups.upcoming.length > 0 && ( + + + + 即将到来 + {dateGroups.upcoming.reduce((s, g) => s + g.items.length, 0)} 项 + + {dateGroups.upcoming.map((group) => ( + + {formatDateLabel(group.date)} + {group.items.map(renderTodoCard)} + + ))} + + )} + + {/* 无截止时间 */} + {dateGroups.noDeadline.length > 0 && ( + + + + 未设截止 + {dateGroups.noDeadline.length} 项 + + {dateGroups.noDeadline.map(renderTodoCard)} + + )} + + )} + + + ); +} diff --git a/code/app/app-rn/src/app/todo/(tabs)/home/index.tsx b/code/app/app-rn/src/app/todo/(tabs)/home/index.tsx index 5860d0c0..304599b1 100644 --- a/code/app/app-rn/src/app/todo/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/todo/(tabs)/home/index.tsx @@ -3,9 +3,10 @@ import { Text, TouchableOpacity, View } from "react-native"; import { useFocusEffect, useRouter } from "expo-router"; import dayjs from "dayjs"; import { Avatar } from "heroui-native"; -import { CalendarIcon, CircleIcon, StarIcon } from "lucide-react-native"; -import PageLayout from "@/layouts/PageLayout"; +import { CalendarIcon, CircleIcon, FilterIcon, PlusIcon, RotateCcwIcon, StarIcon } from "lucide-react-native"; import LayoutView from "@/components/layout/view"; +import BottomSheet from "@/components/BottomSheet"; +import { Label, TextField } from "heroui-native"; import { useListLoading } from "@/hooks/useLoading"; import { useTodoStore, useFamilyStore } from "@/store"; import { Todo, TodoPriority, TodoStatus } from "@/types/todo"; @@ -40,6 +41,11 @@ export default function TodoHome() { const fetchAll = useTodoStore((s) => s.fetchAll); const { refreshing, runInitial, runRefresh } = useListLoading(); + const [keyword, setKeyword] = useState(""); + const [showFilterSheet, setShowFilterSheet] = useState(false); + const [filterStatus, setFilterStatus] = useState(null); + const [filterPriority, setFilterPriority] = useState(null); + const load = useCallback(async () => { if (!familyId) return; await fetchAll(familyId); @@ -71,18 +77,53 @@ export default function TodoHome() { return todo.status; }, []); + const activeFilterCount = useMemo(() => { + let c = 0; + if (filterStatus !== null) c++; + if (filterPriority !== null) c++; + return c; + }, [filterStatus, filterPriority]); + + const filteredTodos = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + return todos.filter((t) => { + if (filterStatus !== null && getDisplayStatus(t) !== filterStatus) return false; + if (filterPriority !== null && t.priority !== filterPriority) return false; + if (!kw) return true; + return t.name.toLowerCase().includes(kw) || (t.description ?? "").toLowerCase().includes(kw); + }); + }, [todos, filterStatus, filterPriority, keyword, getDisplayStatus]); + const grouped = useMemo(() => { const inProgress: Todo[] = []; const unclaimed: Todo[] = []; const completed: Todo[] = []; - todos.forEach((t) => { + filteredTodos.forEach((t) => { const s = getDisplayStatus(t); if (s === TodoStatus.InProgress) inProgress.push(t); else if (s === TodoStatus.Unclaimed) unclaimed.push(t); else completed.push(t); }); return { inProgress, unclaimed, completed }; - }, [todos, getDisplayStatus]); + }, [filteredTodos, getDisplayStatus]); + + const renderHeader = useCallback(() => ( + + 家庭待办 + + {filteredTodos.length} 项 + + setShowFilterSheet(true)}> + + 筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""} + + runRefresh(load)}> + + + + + + ), [activeFilterCount, filteredTodos.length, load, runRefresh]); const renderTodo = (todo: Todo) => { const status = getDisplayStatus(todo); @@ -104,6 +145,9 @@ export default function TodoHome() { {STATUS_LABELS[status]} + {todo.description ? ( + {todo.description} + ) : null} {PRIORITY_LABELS[todo.priority].text ? ( @@ -139,35 +183,102 @@ export default function TodoHome() { }; return ( - - - runRefresh(load)} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}> - {grouped.inProgress.length > 0 && ( - <> - 进行中 · {grouped.inProgress.length} - {grouped.inProgress.map(renderTodo)} - - )} - {grouped.unclaimed.length > 0 && ( - <> - 待认领 · {grouped.unclaimed.length} - {grouped.unclaimed.map(renderTodo)} - - )} - {grouped.completed.length > 0 && ( - <> - 已完成 · {grouped.completed.length} - {grouped.completed.map(renderTodo)} - - )} - {todos.length === 0 && ( - - - 暂无待办 + + runRefresh(load)} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }} + > + {renderHeader()} + + {filteredTodos.length === 0 ? ( + + + 暂无待办 + + ) : ( + <> + {grouped.inProgress.length > 0 && ( + <> + 进行中 · {grouped.inProgress.length} + {grouped.inProgress.map(renderTodo)} + + )} + {grouped.unclaimed.length > 0 && ( + <> + 待认领 · {grouped.unclaimed.length} + {grouped.unclaimed.map(renderTodo)} + + )} + {grouped.completed.length > 0 && ( + <> + 已完成 · {grouped.completed.length} + {grouped.completed.map(renderTodo)} + + )} + + )} + + + {/* 筛选面板 */} + + + + + + 筛选 + + { setFilterStatus(null); setFilterPriority(null); setKeyword(""); }}> + 重置 + + - )} - - - + + + + + + + + setFilterStatus(null)}> + 全部 + + {[TodoStatus.Unclaimed, TodoStatus.InProgress, TodoStatus.Completed, TodoStatus.Overdue].map((s) => ( + setFilterStatus(filterStatus === s ? null : s)}> + {STATUS_LABELS[s]} + + ))} + + + + + + setFilterPriority(null)}> + 全部 + + setFilterPriority(filterPriority === TodoPriority.Normal ? null : TodoPriority.Normal)}> + 普通 + + setFilterPriority(filterPriority === TodoPriority.Important ? null : TodoPriority.Important)}> + 重要 + + setFilterPriority(filterPriority === TodoPriority.Urgent ? null : TodoPriority.Urgent)}> + 紧急 + + + + setShowFilterSheet(false)}> + 确定 + + + + + + {/* 浮动添加按钮 */} + router.push("/todo/add" as any)}> + + + ); } diff --git a/code/app/app-rn/src/app/todo/(tabs)/stats/index.tsx b/code/app/app-rn/src/app/todo/(tabs)/stats/index.tsx new file mode 100644 index 00000000..b435bc5a --- /dev/null +++ b/code/app/app-rn/src/app/todo/(tabs)/stats/index.tsx @@ -0,0 +1,247 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { useFocusEffect } from "expo-router"; +import dayjs from "dayjs"; +import { Avatar } from "heroui-native"; +import { ChartNoAxesColumnIcon, RotateCcwIcon, StarIcon, TrendingUpIcon } from "lucide-react-native"; +import LayoutView from "@/components/layout/view"; +import { useListLoading } from "@/hooks/useLoading"; +import { useTodoStore, useFamilyStore } from "@/store"; +import { TodoPriority, TodoStatus } from "@/types/todo"; +import { buildFileUrl } from "@/utils/file"; + +const PRIORITY_CONFIG: Record = { + [TodoPriority.Normal]: { label: "普通", color: "text-gray-600", bg: "bg-gray-100" }, + [TodoPriority.Important]: { label: "重要", color: "text-amber-600", bg: "bg-amber-100" }, + [TodoPriority.Urgent]: { label: "紧急", color: "text-red-600", bg: "bg-red-100" }, +}; + +const STATUS_LABELS: Record = { + [TodoStatus.Unclaimed]: "待认领", + [TodoStatus.InProgress]: "进行中", + [TodoStatus.Completed]: "已完成", + [TodoStatus.Overdue]: "已超时", +}; + +export default function TodoStats() { + const familyId = useFamilyStore((s) => s.getFamilyId()); + const todos = useTodoStore((s) => s.todos); + const todoRatings = useTodoStore((s) => s.todoRatings); + const members = useTodoStore((s) => s.members); + const fetchAll = useTodoStore((s) => s.fetchAll); + const { refreshing, runInitial, runRefresh } = useListLoading(); + + const load = useCallback(async () => { + if (!familyId) return; + await fetchAll(familyId); + }, [familyId, fetchAll]); + + useEffect(() => { if (familyId) runInitial(load); }, [familyId]); + useFocusEffect(useCallback(() => { load(); }, [load])); + + const memberMap = useMemo(() => new Map(members.map((m) => [m.user_id, m])), [members]); + + // 总体统计 + const overview = useMemo(() => { + const total = todos.length; + const completed = todos.filter((t) => t.status === TodoStatus.Completed).length; + const inProgress = todos.filter((t) => t.status === TodoStatus.InProgress).length; + const unclaimed = todos.filter((t) => t.status === TodoStatus.Unclaimed).length; + const overdue = todos.filter((t) => { + if (t.status === TodoStatus.Overdue) return true; + if (t.status === TodoStatus.InProgress && t.deadline && dayjs().isAfter(dayjs(t.deadline))) return true; + return false; + }).length; + const rate = total > 0 ? Math.round((completed / total) * 100) : 0; + return { total, completed, inProgress, unclaimed, overdue, rate }; + }, [todos]); + + // 成员统计 + const memberStats = useMemo(() => { + const stats = new Map(); + todos.forEach((t) => { + if (t.assignee_id) { + const s = stats.get(t.assignee_id) ?? { assigned: 0, completed: 0, ratingAvg: 0, ratingCount: 0 }; + s.assigned++; + if (t.status === TodoStatus.Completed) s.completed++; + stats.set(t.assignee_id, s); + } + }); + todoRatings.forEach((r) => { + const todo = todos.find((t) => t.id === r.todo_id); + if (todo?.assignee_id) { + const s = stats.get(todo.assignee_id) ?? { assigned: 0, completed: 0, ratingAvg: 0, ratingCount: 0 }; + const totalRating = s.ratingAvg * s.ratingCount + r.rating; + s.ratingCount++; + s.ratingAvg = Math.round((totalRating / s.ratingCount) * 10) / 10; + if (!s.assigned) s.assigned = 0; + stats.set(todo.assignee_id, s); + } + }); + return Array.from(stats.entries()) + .map(([userId, data]) => ({ userId, ...data })) + .sort((a, b) => b.assigned - a.assigned); + }, [todos, todoRatings]); + + // 优先级分布 + const priorityStats = useMemo(() => { + const total = todos.length || 1; + return [TodoPriority.Normal, TodoPriority.Important, TodoPriority.Urgent].map((p) => { + const count = todos.filter((t) => t.priority === p).length; + return { priority: p, count, pct: Math.round((count / total) * 100) }; + }); + }, [todos]); + + // 本周完成数 + const weekStats = useMemo(() => { + const weekStart = dayjs().startOf("week"); + return todos.filter((t) => t.status === TodoStatus.Completed && t.completed_at && dayjs(t.completed_at).isAfter(weekStart)).length; + }, [todos]); + + const renderHeader = () => ( + + 数据统计 + + 总览 + runRefresh(load)}> + + + + + ); + + if (todos.length === 0) { + return ( + + runRefresh(load)} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120, flexGrow: 1 }}> + {renderHeader()} + + + 暂无数据 + 创建任务后开始统计 + + + + ); + } + + return ( + + runRefresh(load)} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }} + > + {renderHeader()} + + {/* 完成率 */} + + 完成率 + + {overview.rate}% + {overview.completed}/{overview.total} + + + + + + + {/* 状态卡片 */} + + + + + 进行中 + + {overview.inProgress} + + + + + 待认领 + + {overview.unclaimed} + + + + + 已完成 + + {overview.completed} + + + + + 已超时 + + {overview.overdue} + + + + {/* 本周完成 */} + + + + + + + 本周完成 + {weekStats} 项 + + + + + {/* 优先级分布 */} + + 优先级分布 + {priorityStats.map((ps) => ( + + + {PRIORITY_CONFIG[ps.priority].label} + + + + + {ps.count} + + ))} + + + {/* 成员贡献 */} + {memberStats.length > 0 && ( + + 成员贡献 + {memberStats.map((ms) => { + const member = memberMap.get(ms.userId); + const completeRate = ms.assigned > 0 ? Math.round((ms.completed / ms.assigned) * 100) : 0; + return ( + + + {member?.avatar ? : null} + {member?.nickname?.charAt(0) ?? "?"} + + + + {member?.nickname ?? `用户${ms.userId}`} + {ms.completed}/{ms.assigned} 完成 + + + + + + {ms.ratingCount > 0 && ( + + + {ms.ratingAvg} + + )} + + ); + })} + + )} + + + ); +} diff --git a/code/app/app-rn/src/app/todo/add/index.tsx b/code/app/app-rn/src/app/todo/add/index.tsx index 52f4a0bf..f92e8ba7 100644 --- a/code/app/app-rn/src/app/todo/add/index.tsx +++ b/code/app/app-rn/src/app/todo/add/index.tsx @@ -3,11 +3,11 @@ import { Text, TouchableOpacity, View } from "react-native"; import { Stack, useRouter } from "expo-router"; import dayjs from "dayjs"; import { Avatar, Button, Input, Label, TextArea, TextField } from "heroui-native"; -import { BellIcon, CheckIcon, RepeatIcon } from "lucide-react-native"; +import { CalendarIcon, CheckIcon, RepeatIcon } from "lucide-react-native"; import { toast } from "sonner-native"; import PageLayout from "@/layouts/PageLayout"; import LayoutView from "@/components/layout/view"; -import BottomSheet from "@/components/BottomSheet"; +import DateTime from "@/components/DateTime"; import { LoadingOverlay } from "@/components/LoadingOverlay"; import { useLoading } from "@/hooks/useLoading"; import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier"; @@ -42,13 +42,9 @@ export default function TodoAdd() { const [priority, setPriority] = useState(TodoPriority.Normal); const [assigneeId, setAssigneeId] = useState(null); const [repeatType, setRepeatType] = useState(TodoRepeatType.None); - const [deadline, setDeadline] = useState(""); - const [remindAt, setRemindAt] = useState(""); - const [pageLoading, setPageLoading] = useState(true); + const [deadline, setDeadline] = useState(() => dayjs().add(1, "hour").format("YYYY-MM-DD HH:mm")); const [showDeadline, setShowDeadline] = useState(false); - const [deadlineInput, setDeadlineInput] = useState(""); - const [showRemind, setShowRemind] = useState(false); - const [remindInput, setRemindInput] = useState(""); + const [pageLoading, setPageLoading] = useState(true); useEffect(() => { if (familyId) fetchMembers(familyId).finally(() => setPageLoading(false)); @@ -56,21 +52,22 @@ export default function TodoAdd() { const handleSave = useCallback(async () => { if (!name.trim()) { toast.error("请输入任务名称"); return; } + if (!deadline) { toast.error("请选择截止时间"); return; } if (!familyId || isLoading) return; startLoading("创建中..."); try { const todoId = await createTodo({ familyId, userId: 0, name: name.trim(), description: description.trim(), priority, assigneeId, repeatType, - deadline: deadline || null, - remindAt: remindAt || null, + deadline, + remindAt: null, }); if (!todoId) { stopLoading({ type: "error", message: "创建失败" }); return; } notifyOp({ module: "待办", action: "创建任务", target: name.trim(), sourceTable: "b_todo", sourceId: todoId }); stopLoading({ type: "success", message: "任务已创建" }); router.back(); } catch { stopLoading({ type: "error", message: "创建失败" }); } - }, [name, description, priority, assigneeId, repeatType, deadline, remindAt, familyId, isLoading, createTodo, notifyOp, router, startLoading, stopLoading]); + }, [name, deadline, description, priority, assigneeId, repeatType, familyId, isLoading, createTodo, notifyOp, router, startLoading, stopLoading]); const renderAssignee = (m: TodoFamilyMember) => { const selected = assigneeId === m.user_id; @@ -91,6 +88,8 @@ export default function TodoAdd() {