20260706213434

This commit is contained in:
oneao committed 2026-07-06 21:34:34 +08:00
1 parent 4d1e6c8b8f
commit f8e16efa83
23 files changed
+1157 -690

No files matched your search

-54
View File
@@ -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 <sql-file>")
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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
+27
View File
@@ -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);
+3 -1
View File
@@ -1 +1,3 @@
EXPO_PUBLIC_API_URL=http://192.168.5.63:8087
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
+1 -1
View File
@@ -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",
+18 -52
View File
@@ -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:
@@ -324,6 +324,7 @@ export default function ChatPage() {
const [inputText, setInputText] = useState("");
const [adminRole, setAdminRole] = useState(0);
const listRef = useRef<any>(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 ? (
@@ -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() {
<LayoutView>
<LayoutView.ScrollView>
<View className="flex-row items-center">
<Avatar alt="">
<Avatar.Image source={{ uri: buildFileUrl(userStore.userInfo.avatar) }} />
<Avatar.Fallback className="text-sm">{userStore.userInfo.nickname}</Avatar.Fallback>
</Avatar>
{userStore.userInfo.avatar ? (
<Image source={{ uri: buildFileUrl(userStore.userInfo.avatar) }} className="w-16 h-16 rounded-full bg-gray-200" />
) : (
<View className="w-16 h-16 rounded-full bg-accent/20 items-center justify-center">
<Text className="text-lg font-semibold text-accent">{userStore.userInfo.nickname?.charAt(0) || "?"}</Text>
</View>
)}
<View className="ml-3 mr-1">
<Text className="text-xl font-semibold text-foreground">
@@ -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() {
<LayoutView.ScrollView>
<View className="items-center justify-center">
<Avatar alt="" className="w-28 h-28">
<Avatar.Image source={{ uri: memberInfo.avatar }} />
<Avatar.Fallback className="text-sm">{memberInfo.nickname}</Avatar.Fallback>
{memberInfo.avatar ? <Avatar.Image source={{ uri: buildFileUrl(memberInfo.avatar) }} /> : null}
<Avatar.Fallback className="text-sm">{memberInfo.nickname?.charAt(0) || "?"}</Avatar.Fallback>
</Avatar>
<Text className="text-2xl font-medium mt-4">{memberInfo.nickname}</Text>
@@ -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"
>
<Avatar alt="" className="w-full h-full">
<Avatar.Image source={{ uri: buildFileUrl(userStore.userInfo.avatar) }} />
<Avatar.Fallback>{userInfo.name}</Avatar.Fallback>
</Avatar>
{userStore.userInfo.avatar ? (
<Image source={{ uri: buildFileUrl(userStore.userInfo.avatar) }} className="w-20 h-20 rounded-full bg-gray-200" />
) : (
<View className="w-20 h-20 rounded-full bg-accent/20 items-center justify-center">
<Text className="text-xl font-semibold text-accent">{(userStore.userInfo.nickname || "?").charAt(0)}</Text>
</View>
)}
<PressableFeedback.Ripple />
</PressableFeedback>
@@ -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<FMember[]>([]);
const [allItems, setAllItems] = useState<any[]>([]);
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() {
</TouchableOpacity>
<TouchableOpacity
className="rounded-full border border-gray-200 bg-white px-4 py-2.5 flex-row items-center gap-1"
onPress={() => setShowDate(true)}
onPress={() => setShowDatePicker(true)}
>
<CalendarDaysIcon size={14} color="#6B7280" />
<Text className="text-sm text-gray-700">{planDate}</Text>
@@ -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() {
{/* 卡片头部 */}
<View className="flex-row items-center justify-between px-5 py-4 border-b border-gray-50">
<View className="flex-row items-center gap-2 flex-1 min-w-0">
<View
className={`w-2 h-2 rounded-full ${
isCompleted ? "bg-green-500" : isConfirmed ? "bg-blue-500" : "bg-accent"
}`}
/>
<Text className="text-base font-semibold text-gray-950" numberOfLines={1}>
{slot.name}
</Text>
{/* 做饭人 */}
{cookName && (
{cookMember && (
<View className="flex-row items-center gap-1 ml-1">
<UserIcon size={12} color="#9CA3AF" />
<Avatar alt="" className="w-4 h-4">
{cookMember.avatar
? <Avatar.Image source={{ uri: buildFileUrl(cookMember.avatar) }} />
: null}
<Avatar.Fallback className="text-[8px]">
{cookMember.nickname?.charAt(0)}
</Avatar.Fallback>
</Avatar>
<Text className="text-xs text-gray-500" numberOfLines={1}>
{cookName}
{cookMember.nickname}
</Text>
</View>
)}
@@ -406,28 +416,6 @@ export default function MealHomePage() {
</View>
)}
</View>
{/* 创建 / 添加按钮 */}
{hasPlan
? (
<TouchableOpacity
className="h-8 w-8 rounded-full bg-accent items-center justify-center active:opacity-80"
onPress={() => router.push(`/meal/plan/detail/${plan.id}` as any)}
>
<ChevronRightIcon size={16} color="#fff" />
</TouchableOpacity>
)
: (
<TouchableOpacity
className={`h-8 w-8 rounded-full items-center justify-center active:opacity-80 ${
creatingSlotId === slot.id ? "bg-gray-300" : "bg-accent"
}`}
onPress={() => handleCreatePlan(slot.id)}
disabled={creatingSlotId === slot.id}
>
<PlusIcon size={16} color="#fff" />
</TouchableOpacity>
)}
</View>
{/* 菜品列表 / 空状态 */}
@@ -543,18 +531,14 @@ export default function MealHomePage() {
</View>
</LayoutView.ScrollView>
{/* 日期选择 DateTimePicker */}
{showDate && (
<DateTimePicker
value={dayjs(planDate).toDate()}
{/* 日期选择器 */}
<DateTime
value={planDate}
show={showDatePicker}
onClose={() => setShowDatePicker(false)}
onConfirm={(value) => setPlanDate(value)}
mode="date"
display={Platform.OS === "ios" ? "spinner" : "default"}
onChange={(event: DateTimePickerEvent, date?: Date) => {
setShowDate(false);
if (date) setPlanDate(dayjs(date).format("YYYY-MM-DD"));
}}
/>
)}
<LoadingOverlay visible={isLoading} />
</LayoutView>
@@ -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<any[]>([]);
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<any>(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,6 +572,7 @@ export default function MealPlanDetail() {
return (
<PageLayout title={slot?.name ?? "菜单"}>
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<LayoutView>
<LayoutView.ScrollView
ref={scrollRef}
@@ -533,9 +609,17 @@ export default function MealPlanDetail() {
</View>
</View>
{/* 做饭人 + 确认/完成按钮(同行) */}
{/* 评分星星展示 */}
{avgRating > 0 && (
<View className="flex-row items-center gap-1 mt-3">
<StarIcon size={14} color="#F59E0B" fill="#F59E0B" />
<Text className="text-xs text-gray-700 font-medium">{avgRating}</Text>
<Text className="text-[10px] text-gray-400">({planRatings.length})</Text>
</View>
)}
{/* 做饭人 */}
<View className="flex-row items-center justify-between mt-3">
{/* 做饭人:标签 + 选择 */}
<View className="flex-row items-center gap-1.5">
<Text className="text-xs text-gray-400">做饭人</Text>
{cookMember ? (
@@ -569,28 +653,6 @@ export default function MealPlanDetail() {
</TouchableOpacity>
)}
</View>
{/* 确认 / 完成 小按钮 */}
{isPlanning && (
<TouchableOpacity
className="flex-row items-center gap-1 rounded-full bg-blue-500 px-4 py-1.5 active:bg-blue-600"
onPress={handleConfirm}
disabled={confirming}
>
{confirming ? <ActivityIndicator size="small" color="#fff" /> : <BadgeCheckIcon size={14} color="#fff" />}
<Text className="text-xs font-semibold text-white">确认菜单</Text>
</TouchableOpacity>
)}
{isConfirmed && (
<TouchableOpacity
className="flex-row items-center gap-1 rounded-full bg-green-500 px-4 py-1.5 active:bg-green-600"
onPress={handleComplete}
disabled={completing}
>
{completing ? <ActivityIndicator size="small" color="#fff" /> : <CheckCircleIcon size={14} color="#fff" />}
<Text className="text-xs font-semibold text-white">完成菜单</Text>
</TouchableOpacity>
)}
</View>
</View>
@@ -650,6 +712,40 @@ export default function MealPlanDetail() {
)}
</View>
)}
{/* 确认 / 完成 / 评价按钮 — 菜品列表右下角 */}
{(planItemsWithDish.length > 0 || (isCompleted && !hasRated)) && (
<View className="flex-row justify-end mt-3 gap-2">
{isPlanning && (
<TouchableOpacity
className="flex-row items-center gap-1 rounded-full bg-blue-500 px-4 py-1.5 active:bg-blue-600"
onPress={handleConfirm}
disabled={confirming}
>
{confirming ? <ActivityIndicator size="small" color="#fff" /> : <BadgeCheckIcon size={14} color="#fff" />}
<Text className="text-xs font-semibold text-white">确认菜单</Text>
</TouchableOpacity>
)}
{isConfirmed && (
<TouchableOpacity
className="flex-row items-center gap-1 rounded-full bg-green-500 px-4 py-1.5 active:bg-green-600"
onPress={handleComplete}
disabled={completing}
>
{completing ? <ActivityIndicator size="small" color="#fff" /> : <CheckCircleIcon size={14} color="#fff" />}
<Text className="text-xs font-semibold text-white">完成菜单</Text>
</TouchableOpacity>
)}
{isCompleted && !hasRated && (
<TouchableOpacity
className="flex-row items-center gap-1 rounded-full bg-amber-500 px-4 py-1.5 active:bg-amber-600"
onPress={() => { setRateValue(5); setRateContent(""); setShowRateSheet(true); }}
>
<StarIcon size={14} color="#fff" />
<Text className="text-xs font-semibold text-white">评价</Text>
</TouchableOpacity>
)}
</View>
)}
</View>
{/* ======== 时间轴 ======== */}
@@ -724,6 +820,50 @@ export default function MealPlanDetail() {
)}
</View>
{/* ======== 评分列表 ======== */}
{planRatings.length > 0 && (
<View className="px-5 pt-4">
<Text className="text-xs text-gray-500 font-medium mb-3">
评价 · {planRatings.length} 条
</Text>
{planRatings.map((r) => {
const m = members.find((m2) => m2.user_id === r.rater_id);
return (
<View key={r.id} className="flex-row gap-3 mb-3">
<Avatar alt="" className="w-7 h-7">
{m?.avatar
? <Avatar.Image source={{ uri: buildFileUrl(m.avatar) }} />
: null}
<Avatar.Fallback className="text-xs" style={{ backgroundColor: "#E5E7EB" }}>
{m?.nickname?.charAt(0) ?? "?"}
</Avatar.Fallback>
</Avatar>
<View className="flex-1">
<View className="flex-row items-center gap-2 mb-0.5">
<Text className="text-sm font-semibold text-gray-950">
{m?.nickname ?? `用户${r.rater_id}`}
</Text>
<View className="flex-row gap-0.5">
{[1,2,3,4,5].map((s) => (
<StarIcon
key={s}
size={10}
color={s <= r.rating ? "#F59E0B" : "#E5E7EB"}
fill={s <= r.rating ? "#F59E0B" : "none"}
/>
))}
</View>
</View>
{r.content ? (
<Text className="text-sm text-gray-600">{r.content}</Text>
) : null}
</View>
</View>
);
})}
</View>
)}
{/* 底部间距 */}
<View style={{ height: 20 }} />
</LayoutView.ScrollView>
@@ -752,17 +892,18 @@ export default function MealPlanDetail() {
</TouchableOpacity>
</View>
)}
</LayoutView>
</KeyboardAvoidingView>
{/* ======== 添加菜品 Sheet ======== */}
<BottomSheet isOpen={showAddDish} onOpenChange={(v) => { setShowAddDish(v); if (!v) { setDishKeyword(""); setDishFilterCatId(null); setDishFilterTagIds([]); } }}>
<BottomSheet isOpen={showAddDish} onOpenChange={(v) => { setShowAddDish(v); if (!v) { setDishKeyword(""); setDishFilterCatId(null); setDishFilterTagIds([]); setDishTotal(0); setAddDishCandidates([]); } }}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content className="gap-3" style={{ paddingHorizontal: 16, maxHeight: 520 }}>
<Text className="text-center text-lg font-semibold text-gray-950">添加菜品</Text>
{/* 搜索 + 筛选 */}
<BottomSheet.Content className="gap-3" style={{ paddingHorizontal: 16 }}>
{/* 搜索 + 筛选按钮 */}
<View className="flex-row items-center gap-2">
<View className="flex-1">
<Input
<BottomSheet.Input
value={dishKeyword}
onChangeText={setDishKeyword}
placeholder="搜索菜品"
@@ -777,41 +918,51 @@ export default function MealPlanDetail() {
<Text className="text-xs font-medium text-white">筛选{dishFilterCount > 0 ? ` ${dishFilterCount}` : ""}</Text>
</TouchableOpacity>
</View>
<LayoutView.ScrollView className="max-h-60" showsVerticalScrollIndicator>
{/* 菜品列表 */}
<ScrollView
className="max-h-80"
showsVerticalScrollIndicator
onMomentumScrollEnd={async (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
if (contentOffset.y + layoutMeasurement.height >= contentSize.height - 100) {
await loadMoreDishes();
}
}}
>
{filteredDishCandidates.length > 0
? (
<>
<View className="flex-row flex-wrap gap-3">
{filteredDishCandidates
.filter((d: any) => !existingDishIds.has(d.id))
.map((d: any) => (
<TouchableOpacity
key={d.id}
className="w-[48%] bg-gray-50 rounded-xl overflow-hidden"
className="w-[48%] rounded-xl overflow-hidden bg-gray-100"
activeOpacity={0.8}
onPress={() => addDish(d.id, d.name)}
>
<View className="bg-gray-100" style={{ aspectRatio: 4 / 3 }}>
<MealDishImage
fileKey={d.image_file_key}
label={d.name}
className="w-full h-full"
className="w-full"
style={{ aspectRatio: 4 / 3 }}
/>
</View>
<Text
className="text-xs font-medium text-gray-900 px-2 py-2"
numberOfLines={1}
>
<Text className="text-xs font-medium text-gray-900 px-2 py-1.5" numberOfLines={1}>
{d.name}
</Text>
</TouchableOpacity>
))}
</View>
{loadingMoreDishes && <ActivityIndicator className="py-3" color="#9CA3AF" />}
{addDishCandidates.length >= dishTotal && dishTotal > 0 && (
<Text className="text-xs text-gray-400 text-center py-3">已加载全部菜品</Text>
)}
</>
)
: <Text className="text-sm text-gray-400 text-center py-8">没有匹配的菜品</Text>}
</LayoutView.ScrollView>
<Button variant="secondary" className="w-full" onPress={() => setShowAddDish(false)}>
<Button.Label>关闭</Button.Label>
</Button>
</ScrollView>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
@@ -820,7 +971,7 @@ export default function MealPlanDetail() {
<BottomSheet isOpen={showDishFilterSheet} onOpenChange={setShowDishFilterSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16, maxHeight: 460 }}>
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }}>
<View className="flex-row items-center">
<View className="flex-1" />
<Text className="text-lg font-semibold text-gray-900">筛选菜品</Text>
@@ -890,7 +1041,7 @@ export default function MealPlanDetail() {
<BottomSheet isOpen={showCookSheet} onOpenChange={setShowCookSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content className="gap-3" style={{ paddingHorizontal: 16, maxHeight: 420 }}>
<BottomSheet.Content className="gap-3" style={{ paddingHorizontal: 16 }}>
<Text className="text-center text-lg font-semibold text-gray-950">选择做饭人</Text>
{members.length > 0
? (
@@ -928,7 +1079,46 @@ export default function MealPlanDetail() {
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
</LayoutView>
{/* ======== 评价弹窗 ======== */}
<BottomSheet isOpen={showRateSheet} onOpenChange={setShowRateSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }}>
<Text className="text-center text-lg font-semibold text-gray-950">评价菜单</Text>
{/* 星级 */}
<View className="flex-row justify-center gap-1">
{[1, 2, 3, 4, 5].map((s) => (
<TouchableOpacity key={s} onPress={() => setRateValue(s)}>
<StarIcon
size={32}
color={s <= rateValue ? "#F59E0B" : "#E5E7EB"}
fill={s <= rateValue ? "#F59E0B" : "none"}
/>
</TouchableOpacity>
))}
</View>
{/* 评价文字 */}
<BottomSheet.TextArea
className="bg-gray-50 rounded-xl px-4 py-3 text-sm min-h-[80px]"
value={rateContent}
onChangeText={setRateContent}
placeholder="写点评价..."
/>
<View className="flex-row gap-3">
<Button variant="secondary" className="flex-1" onPress={() => setShowRateSheet(false)}>
<Button.Label>取消</Button.Label>
</Button>
<Button className="flex-1" onPress={handleRate} isDisabled={rating}>
{rating ? <ActivityIndicator size="small" color="#fff" /> : <Button.Label>提交</Button.Label>}
</Button>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<LoadingOverlay visible={isLoading || pageLoading} />
</PageLayout>
);
@@ -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() {
@@ -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<number, string> = {
[TodoPriority.Normal]: "",
[TodoPriority.Important]: "border-l-amber-500",
[TodoPriority.Urgent]: "border-l-red-500",
};
const STATUS_COLORS: Record<number, string> = {
[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<number, { count: number; avg: number }>();
const groups = new Map<number, number[]>();
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<string, Todo[]>();
// 有截止时间的任务按日期分组
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 (
<TouchableOpacity
key={todo.id}
className={`bg-white rounded-xl px-4 py-3 mb-2 border border-gray-100 border-l-4 ${PRIORITY_COLORS[todo.priority] || "border-l-gray-200"}`}
activeOpacity={0.7}
onPress={() => router.push(`/todo/detail/${todo.id}` as any)}
>
<View className="flex-row items-start gap-2">
{/* 状态圆点 */}
<View className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${STATUS_COLORS[status]}`} style={{ opacity: isCompleted ? 0.5 : 1 }} />
<View className="flex-1 min-w-0">
<Text className={`text-sm font-semibold mb-0.5 ${isCompleted ? "text-gray-400 line-through" : "text-gray-900"}`} numberOfLines={1}>
{todo.name}
</Text>
{todo.description ? (
<Text className="text-xs text-gray-400 mb-1" numberOfLines={1}>{todo.description}</Text>
) : null}
<View className="flex-row items-center gap-2">
{todo.deadline && (
<Text className={`text-[10px] ${isOverdue ? "text-red-500 font-medium" : "text-gray-400"}`}>
{dayjs(todo.deadline).format("HH:mm")}
</Text>
)}
{assignee && (
<View className="flex-row items-center gap-1">
<Avatar alt="" className="w-4 h-4">
{assignee.avatar ? <Avatar.Image source={{ uri: buildFileUrl(assignee.avatar) }} /> : null}
<Avatar.Fallback className="text-[7px]">{assignee.nickname?.charAt(0)}</Avatar.Fallback>
</Avatar>
<Text className="text-[10px] text-gray-400">{assignee.nickname}</Text>
</View>
)}
{rating && rating.count > 0 && (
<View className="flex-row items-center gap-0.5">
<StarIcon size={10} color="#F59E0B" fill="#F59E0B" />
<Text className="text-[10px] text-amber-500">{rating.avg}</Text>
</View>
)}
</View>
</View>
</View>
</TouchableOpacity>
);
};
const renderHeader = () => (
<View className="pb-5">
<Text className="text-xs font-medium text-gray-500">任务日历</Text>
<View className="flex-row items-center justify-between mt-1.5">
<Text className="text-2xl font-bold text-gray-950">{dateGroups.totalWithDeadline} 项</Text>
<TouchableOpacity className="h-10 w-10 items-center justify-center rounded-full bg-gray-100 active:bg-gray-200" onPress={() => runRefresh(load)}>
<RotateCcwIcon size={18} color="#6B7280" />
</TouchableOpacity>
</View>
</View>
);
return (
<LayoutView>
<LayoutView.ScrollView
refreshing={refreshing}
onRefresh={() => runRefresh(load)}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }}
>
{renderHeader()}
{dateGroups.totalWithDeadline === 0 && dateGroups.noDeadline.length === 0 ? (
<View className="py-20 items-center">
<CalendarIcon size={40} color="#D1D5DB" />
<Text className="text-sm text-gray-400 mt-3">暂无任务</Text>
</View>
) : (
<>
{/* 过期任务 */}
{dateGroups.overdue.length > 0 && (
<View className="mb-6">
<View className="flex-row items-center gap-2 mb-3">
<View className="w-2.5 h-2.5 rounded-full bg-red-500" />
<Text className="text-sm font-semibold text-gray-900">已过期</Text>
<Text className="text-xs text-gray-400">{dateGroups.overdue.reduce((s, g) => s + g.items.length, 0)} 项</Text>
</View>
{dateGroups.overdue.map((group) => (
<View key={group.date} className="mb-3">
<Text className="text-xs font-medium text-gray-400 mb-2">{formatDateLabel(group.date)}</Text>
{group.items.map(renderTodoCard)}
</View>
))}
</View>
)}
{/* 即将到来 */}
{dateGroups.upcoming.length > 0 && (
<View className="mb-6">
<View className="flex-row items-center gap-2 mb-3">
<View className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<Text className="text-sm font-semibold text-gray-900">即将到来</Text>
<Text className="text-xs text-gray-400">{dateGroups.upcoming.reduce((s, g) => s + g.items.length, 0)} 项</Text>
</View>
{dateGroups.upcoming.map((group) => (
<View key={group.date} className="mb-3">
<Text className="text-xs font-medium text-gray-400 mb-2">{formatDateLabel(group.date)}</Text>
{group.items.map(renderTodoCard)}
</View>
))}
</View>
)}
{/* 无截止时间 */}
{dateGroups.noDeadline.length > 0 && (
<View className="mb-6">
<View className="flex-row items-center gap-2 mb-3">
<View className="w-2.5 h-2.5 rounded-full bg-gray-400" />
<Text className="text-sm font-semibold text-gray-900">未设截止</Text>
<Text className="text-xs text-gray-400">{dateGroups.noDeadline.length} 项</Text>
</View>
{dateGroups.noDeadline.map(renderTodoCard)}
</View>
)}
</>
)}
</LayoutView.ScrollView>
</LayoutView>
);
}
@@ -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<number | null>(null);
const [filterPriority, setFilterPriority] = useState<number | null>(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(() => (
<View className="pb-5">
<Text className="text-xs font-medium text-gray-500">家庭待办</Text>
<View className="flex-row items-center justify-between mt-1.5">
<Text className="text-2xl font-bold text-gray-950">{filteredTodos.length} 项</Text>
<View className="flex-row items-center gap-2">
<TouchableOpacity className="h-10 flex-row items-center gap-1.5 rounded-full bg-accent px-4 active:scale-95" activeOpacity={0.85} onPress={() => setShowFilterSheet(true)}>
<FilterIcon size={16} color="#fff" />
<Text className="text-sm font-medium text-white">筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""}</Text>
</TouchableOpacity>
<TouchableOpacity className="h-10 w-10 items-center justify-center rounded-full bg-gray-100 active:bg-gray-200" onPress={() => runRefresh(load)}>
<RotateCcwIcon size={18} color="#6B7280" />
</TouchableOpacity>
</View>
</View>
</View>
), [activeFilterCount, filteredTodos.length, load, runRefresh]);
const renderTodo = (todo: Todo) => {
const status = getDisplayStatus(todo);
@@ -104,6 +145,9 @@ export default function TodoHome() {
<Text className="text-[10px] font-medium">{STATUS_LABELS[status]}</Text>
</View>
</View>
{todo.description ? (
<Text className="text-xs text-gray-400 mb-1.5" numberOfLines={1}>{todo.description}</Text>
) : null}
<View className="flex-row items-center gap-2">
{PRIORITY_LABELS[todo.priority].text ? (
<Text className={`text-[10px] font-medium ${PRIORITY_LABELS[todo.priority].color}`}>
@@ -139,9 +183,22 @@ export default function TodoHome() {
};
return (
<PageLayout title="家庭待办">
<LayoutView>
<LayoutView.ScrollView refreshing={refreshing} onRefresh={() => runRefresh(load)} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}>
<LayoutView.ScrollView
refreshing={refreshing}
onRefresh={() => runRefresh(load)}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }}
>
{renderHeader()}
{filteredTodos.length === 0 ? (
<View className="py-20 items-center">
<CircleIcon size={40} color="#D1D5DB" />
<Text className="text-sm text-gray-400 mt-3">暂无待办</Text>
</View>
) : (
<>
{grouped.inProgress.length > 0 && (
<>
<Text className="text-xs font-medium text-gray-500 mb-2 mt-2">进行中 · {grouped.inProgress.length}</Text>
@@ -160,14 +217,68 @@ export default function TodoHome() {
{grouped.completed.map(renderTodo)}
</>
)}
{todos.length === 0 && (
<View className="py-20 items-center">
<CircleIcon size={40} color="#D1D5DB" />
<Text className="text-sm text-gray-400 mt-3">暂无待办</Text>
</View>
</>
)}
</LayoutView.ScrollView>
{/* 筛选面板 */}
<BottomSheet isOpen={showFilterSheet} onOpenChange={setShowFilterSheet}>
<BottomSheet.Portal><BottomSheet.Overlay />
<BottomSheet.Content animateOnMount={false} className="gap-4" style={{ paddingHorizontal: 16 }}>
<View className="flex-row items-center">
<View className="flex-1" />
<Text className="text-center text-lg font-semibold text-gray-900">筛选</Text>
<View className="flex-1 items-end">
<TouchableOpacity className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1" onPress={() => { setFilterStatus(null); setFilterPriority(null); setKeyword(""); }}>
<RotateCcwIcon size={14} color="#4B5563" /><Text className="text-xs text-gray-600">重置</Text>
</TouchableOpacity>
</View>
</View>
<TextField>
<Label className="mb-2">关键词</Label>
<BottomSheet.Input value={keyword} onChangeText={setKeyword} placeholder="搜索任务" returnKeyType="search" />
</TextField>
<View>
<Label className="mb-2">状态</Label>
<View className="flex-row flex-wrap gap-2">
<TouchableOpacity className={`rounded-full border px-3 py-2 ${filterStatus == null ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"}`} onPress={() => setFilterStatus(null)}>
<Text className={`text-xs ${filterStatus == null ? "text-white" : "text-gray-700"}`}>全部</Text>
</TouchableOpacity>
{[TodoStatus.Unclaimed, TodoStatus.InProgress, TodoStatus.Completed, TodoStatus.Overdue].map((s) => (
<TouchableOpacity key={s} className={`rounded-full border px-3 py-2 ${filterStatus === s ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"}`} onPress={() => setFilterStatus(filterStatus === s ? null : s)}>
<Text className={`text-xs ${filterStatus === s ? "text-white" : "text-gray-700"}`}>{STATUS_LABELS[s]}</Text>
</TouchableOpacity>
))}
</View>
</View>
<View>
<Label className="mb-2">优先级</Label>
<View className="flex-row flex-wrap gap-2">
<TouchableOpacity className={`rounded-full border px-3 py-2 ${filterPriority == null ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"}`} onPress={() => setFilterPriority(null)}>
<Text className={`text-xs ${filterPriority == null ? "text-white" : "text-gray-700"}`}>全部</Text>
</TouchableOpacity>
<TouchableOpacity className={`rounded-full border px-3 py-2 ${filterPriority === TodoPriority.Normal ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"}`} onPress={() => setFilterPriority(filterPriority === TodoPriority.Normal ? null : TodoPriority.Normal)}>
<Text className={`text-xs ${filterPriority === TodoPriority.Normal ? "text-white" : "text-gray-700"}`}>普通</Text>
</TouchableOpacity>
<TouchableOpacity className={`rounded-full border px-3 py-2 ${filterPriority === TodoPriority.Important ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"}`} onPress={() => setFilterPriority(filterPriority === TodoPriority.Important ? null : TodoPriority.Important)}>
<Text className={`text-xs ${filterPriority === TodoPriority.Important ? "text-white" : "text-gray-700"}`}>重要</Text>
</TouchableOpacity>
<TouchableOpacity className={`rounded-full border px-3 py-2 ${filterPriority === TodoPriority.Urgent ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"}`} onPress={() => setFilterPriority(filterPriority === TodoPriority.Urgent ? null : TodoPriority.Urgent)}>
<Text className={`text-xs ${filterPriority === TodoPriority.Urgent ? "text-white" : "text-gray-700"}`}>紧急</Text>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity className="w-full h-11 rounded-full bg-accent items-center justify-center" onPress={() => setShowFilterSheet(false)}>
<Text className="text-sm font-semibold text-white">确定</Text>
</TouchableOpacity>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
{/* 浮动添加按钮 */}
<TouchableOpacity className="absolute bottom-20 right-5 w-14 h-14 rounded-full bg-accent items-center justify-center shadow-xl z-50 active:scale-95" activeOpacity={0.85} onPress={() => router.push("/todo/add" as any)}>
<PlusIcon size={24} color="#fff" />
</TouchableOpacity>
</LayoutView>
</PageLayout>
);
}
@@ -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<number, { label: string; color: string; bg: string }> = {
[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<number, string> = {
[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<number, { assigned: number; completed: number; ratingAvg: number; ratingCount: number }>();
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 = () => (
<View className="pb-5">
<Text className="text-xs font-medium text-gray-500">数据统计</Text>
<View className="flex-row items-center justify-between mt-1.5">
<Text className="text-2xl font-bold text-gray-950">总览</Text>
<TouchableOpacity className="h-10 w-10 items-center justify-center rounded-full bg-gray-100 active:bg-gray-200" onPress={() => runRefresh(load)}>
<RotateCcwIcon size={18} color="#6B7280" />
</TouchableOpacity>
</View>
</View>
);
if (todos.length === 0) {
return (
<LayoutView>
<LayoutView.ScrollView refreshing={refreshing} onRefresh={() => runRefresh(load)} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120, flexGrow: 1 }}>
{renderHeader()}
<View className="flex-1 items-center justify-center py-20">
<ChartNoAxesColumnIcon size={40} color="#D1D5DB" />
<Text className="text-sm text-gray-400 mt-3">暂无数据</Text>
<Text className="text-xs text-gray-300 mt-1">创建任务后开始统计</Text>
</View>
</LayoutView.ScrollView>
</LayoutView>
);
}
return (
<LayoutView>
<LayoutView.ScrollView
refreshing={refreshing}
onRefresh={() => runRefresh(load)}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 120 }}
>
{renderHeader()}
{/* 完成率 */}
<View className="bg-white rounded-2xl px-5 py-4 mb-4 border border-gray-100">
<Text className="text-xs font-medium text-gray-500 mb-3">完成率</Text>
<View className="flex-row items-end gap-2 mb-2">
<Text className="text-4xl font-bold text-gray-950">{overview.rate}%</Text>
<Text className="text-sm text-gray-400 mb-1">{overview.completed}/{overview.total}</Text>
</View>
<View className="h-2 bg-gray-100 rounded-full overflow-hidden">
<View className="h-full bg-green-500 rounded-full" style={{ width: `${overview.rate}%` }} />
</View>
</View>
{/* 状态卡片 */}
<View className="flex-row flex-wrap mb-4" style={{ gap: 8 }}>
<View className="flex-1 min-w-[45%] bg-white rounded-2xl px-4 py-3.5 border border-gray-100">
<View className="flex-row items-center gap-1.5 mb-1">
<View className="w-2 h-2 rounded-full bg-blue-500" />
<Text className="text-xs text-gray-400">进行中</Text>
</View>
<Text className="text-2xl font-bold text-gray-950">{overview.inProgress}</Text>
</View>
<View className="flex-1 min-w-[45%] bg-white rounded-2xl px-4 py-3.5 border border-gray-100">
<View className="flex-row items-center gap-1.5 mb-1">
<View className="w-2 h-2 rounded-full bg-gray-400" />
<Text className="text-xs text-gray-400">待认领</Text>
</View>
<Text className="text-2xl font-bold text-gray-950">{overview.unclaimed}</Text>
</View>
<View className="flex-1 min-w-[45%] bg-white rounded-2xl px-4 py-3.5 border border-gray-100">
<View className="flex-row items-center gap-1.5 mb-1">
<View className="w-2 h-2 rounded-full bg-green-500" />
<Text className="text-xs text-gray-400">已完成</Text>
</View>
<Text className="text-2xl font-bold text-gray-950">{overview.completed}</Text>
</View>
<View className="flex-1 min-w-[45%] bg-white rounded-2xl px-4 py-3.5 border border-gray-100">
<View className="flex-row items-center gap-1.5 mb-1">
<View className="w-2 h-2 rounded-full bg-red-500" />
<Text className="text-xs text-gray-400">已超时</Text>
</View>
<Text className="text-2xl font-bold text-gray-950">{overview.overdue}</Text>
</View>
</View>
{/* 本周完成 */}
<View className="flex-row items-center justify-between bg-white rounded-2xl px-5 py-4 mb-4 border border-gray-100">
<View className="flex-row items-center gap-2">
<View className="w-10 h-10 rounded-full bg-green-50 items-center justify-center">
<TrendingUpIcon size={20} color="#22C55E" />
</View>
<View>
<Text className="text-xs text-gray-400">本周完成</Text>
<Text className="text-xl font-bold text-gray-950">{weekStats} 项</Text>
</View>
</View>
</View>
{/* 优先级分布 */}
<View className="bg-white rounded-2xl px-5 py-4 mb-4 border border-gray-100">
<Text className="text-xs font-medium text-gray-500 mb-3">优先级分布</Text>
{priorityStats.map((ps) => (
<View key={ps.priority} className="flex-row items-center gap-3 mb-2.5 last:mb-0">
<View className={`w-14 items-center rounded-full px-2 py-0.5 ${PRIORITY_CONFIG[ps.priority].bg}`}>
<Text className={`text-[10px] font-medium ${PRIORITY_CONFIG[ps.priority].color}`}>{PRIORITY_CONFIG[ps.priority].label}</Text>
</View>
<View className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<View className="h-full rounded-full" style={{ width: `${ps.pct}%`, backgroundColor: ps.priority === TodoPriority.Normal ? "#6B7280" : ps.priority === TodoPriority.Important ? "#F59E0B" : "#EF4444" }} />
</View>
<Text className="text-xs text-gray-500 w-10 text-right">{ps.count}</Text>
</View>
))}
</View>
{/* 成员贡献 */}
{memberStats.length > 0 && (
<View className="bg-white rounded-2xl px-5 py-4 mb-4 border border-gray-100">
<Text className="text-xs font-medium text-gray-500 mb-3">成员贡献</Text>
{memberStats.map((ms) => {
const member = memberMap.get(ms.userId);
const completeRate = ms.assigned > 0 ? Math.round((ms.completed / ms.assigned) * 100) : 0;
return (
<View key={ms.userId} className="flex-row items-center gap-3 mb-3 last:mb-0">
<Avatar alt="" className="w-9 h-9">
{member?.avatar ? <Avatar.Image source={{ uri: buildFileUrl(member.avatar) }} /> : null}
<Avatar.Fallback className="text-xs" style={{ backgroundColor: "#E5E7EB" }}>{member?.nickname?.charAt(0) ?? "?"}</Avatar.Fallback>
</Avatar>
<View className="flex-1">
<View className="flex-row items-baseline justify-between mb-1">
<Text className="text-sm font-semibold text-gray-900">{member?.nickname ?? `用户${ms.userId}`}</Text>
<Text className="text-xs text-gray-400">{ms.completed}/{ms.assigned} 完成</Text>
</View>
<View className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<View className="h-full bg-green-500 rounded-full" style={{ width: `${completeRate}%` }} />
</View>
</View>
{ms.ratingCount > 0 && (
<View className="flex-row items-center gap-0.5">
<StarIcon size={12} color="#F59E0B" fill="#F59E0B" />
<Text className="text-xs font-medium text-amber-600">{ms.ratingAvg}</Text>
</View>
)}
</View>
);
})}
</View>
)}
</LayoutView.ScrollView>
</LayoutView>
);
}
+39 -60
View File
@@ -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<number | null>(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() {
<TextField isRequired><Label className="mb-2">任务名称</Label><Input value={name} onChangeText={setName} placeholder="例如:倒垃圾" autoFocus /></TextField>
<TextField><Label className="mb-2">描述</Label><TextArea value={description} onChangeText={setDescription} placeholder="可选" /></TextField>
</View>
{/* 优先级 */}
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">优先级</Text>
<View className="flex-row gap-2 mb-4">
{PRIORITIES.map((p) => (
@@ -99,6 +98,26 @@ export default function TodoAdd() {
</TouchableOpacity>
))}
</View>
{/* 截止时间(必选) */}
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">截止时间</Text>
<TouchableOpacity className="rounded-full border border-gray-200 bg-white px-4 py-2.5 flex-row items-center gap-2 mb-4" onPress={() => setShowDeadline(true)}>
<CalendarIcon size={16} color="#6B7280" />
<Text className="text-sm text-gray-700">{deadline ? dayjs(deadline).format("MM/DD HH:mm") : "请选择"}</Text>
</TouchableOpacity>
{/* 重复 */}
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">重复</Text>
<View className="flex-row gap-2 mb-4">
{REPEATS.map((r) => (
<TouchableOpacity key={r.value} className={`rounded-full border px-4 py-2 flex-row items-center gap-1 ${repeatType === r.value ? "bg-accent border-accent" : "bg-white border-gray-200"}`} onPress={() => setRepeatType(r.value)}>
{r.value !== TodoRepeatType.None && <RepeatIcon size={14} color={repeatType === r.value ? "#fff" : "#6B7280"} />}
<Text className={`text-sm font-medium ${repeatType === r.value ? "text-white" : "text-gray-700"}`}>{r.label}</Text>
</TouchableOpacity>
))}
</View>
{/* 指派 */}
{members.length > 0 && (
<>
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">指派给(可选)</Text>
@@ -110,62 +129,22 @@ export default function TodoAdd() {
</View>
</>
)}
{/* 重复 */}
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">重复</Text>
<View className="flex-row gap-2 mb-4">
{REPEATS.map((r) => (
<TouchableOpacity key={r.value} className={`rounded-full border px-4 py-2 flex-row items-center gap-1 ${repeatType === r.value ? "bg-accent border-accent" : "bg-white border-gray-200"}`} onPress={() => setRepeatType(r.value)}>
{r.value !== TodoRepeatType.None && <RepeatIcon size={14} color={repeatType === r.value ? "#fff" : "#6B7280"} />}
<Text className={`text-sm font-medium ${repeatType === r.value ? "text-white" : "text-gray-700"}`}>{r.label}</Text>
</TouchableOpacity>
))}
</View>
{/* 截止时间 */}
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">截止时间(可选)</Text>
<TouchableOpacity className={`rounded-full border px-4 py-2.5 mb-3 ${deadline ? "border-accent bg-accent" : "border-gray-200 bg-white"}`} onPress={() => { setDeadlineInput(deadline || dayjs().add(1, "hour").format("YYYY-MM-DDTHH:mm")); setShowDeadline(true); }}>
<Text className={`text-sm ${deadline ? "text-white" : "text-gray-700"}`}>{deadline ? dayjs(deadline).format("MM/DD HH:mm") : "不设置"}</Text>
</TouchableOpacity>
{/* 提醒 */}
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">提醒(可选)</Text>
<TouchableOpacity className={`rounded-full border px-4 py-2.5 flex-row items-center gap-1.5 ${remindAt ? "border-accent bg-accent" : "border-gray-200 bg-white"}`} onPress={() => { setRemindInput(remindAt || dayjs().subtract(15, "minute").format("YYYY-MM-DDTHH:mm")); setShowRemind(true); }}>
<BellIcon size={14} color={remindAt ? "#fff" : "#6B7280"} />
<Text className={`text-sm ${remindAt ? "text-white" : "text-gray-700"}`}>{remindAt ? dayjs(remindAt).format("MM/DD HH:mm") : "不提醒"}</Text>
</TouchableOpacity>
<View className="flex-row gap-3 mt-8">
<Button variant="secondary" className="flex-1" onPress={() => router.back()}><Button.Label>取消</Button.Label></Button>
<Button className="flex-1" onPress={handleSave} isDisabled={isLoading || !name.trim()}><CheckIcon size={18} color="#fff" /><Button.Label>创建</Button.Label></Button>
<Button className="flex-1" onPress={handleSave} isDisabled={isLoading || !name.trim() || !deadline}><CheckIcon size={18} color="#fff" /><Button.Label>创建</Button.Label></Button>
</View>
</LayoutView.ScrollView>
</LayoutView>
{/* 截止时间 Sheet */}
<BottomSheet isOpen={showDeadline} onOpenChange={setShowDeadline}>
<BottomSheet.Portal><BottomSheet.Overlay />
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16, maxHeight: 300 }}>
<Text className="text-center text-lg font-semibold text-gray-950">选择截止时间</Text>
<BottomSheet.Input value={deadlineInput} onChangeText={setDeadlineInput} placeholder={dayjs().format("YYYY-MM-DDTHH:mm")} />
<View className="flex-row gap-3">
<Button variant="secondary" className="flex-1" onPress={() => { setDeadline(""); setShowDeadline(false); }}><Button.Label>清除</Button.Label></Button>
<Button className="flex-1" onPress={() => { if (dayjs(deadlineInput).isValid()) { setDeadline(deadlineInput); setShowDeadline(false); } else toast.error("时间格式无效"); }}><CheckIcon size={18} color="#fff" /><Button.Label>确定</Button.Label></Button>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
{/* 提醒时间 Sheet */}
<BottomSheet isOpen={showRemind} onOpenChange={setShowRemind}>
<BottomSheet.Portal><BottomSheet.Overlay />
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16, maxHeight: 300 }}>
<Text className="text-center text-lg font-semibold text-gray-950">选择提醒时间</Text>
<BottomSheet.Input value={remindInput} onChangeText={setRemindInput} placeholder={dayjs().format("YYYY-MM-DDTHH:mm")} />
<View className="flex-row gap-3">
<Button variant="secondary" className="flex-1" onPress={() => { setRemindAt(""); setShowRemind(false); }}><Button.Label>清除</Button.Label></Button>
<Button className="flex-1" onPress={() => { if (dayjs(remindInput).isValid()) { setRemindAt(remindInput); setShowRemind(false); } else toast.error("时间格式无效"); }}><CheckIcon size={18} color="#fff" /><Button.Label>确定</Button.Label></Button>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
{/* 截止时间选择器 */}
<DateTime
value={deadline}
show={showDeadline}
mode="datetime"
onClose={() => setShowDeadline(false)}
onConfirm={(v) => { setDeadline(v); setShowDeadline(false); }}
/>
{pageLoading ? <LoadingOverlay visible /> : <LoadingOverlay visible={isLoading} />}
</PageLayout>
+31 -2
View File
@@ -23,7 +23,7 @@ import {
type TextProps,
type ViewStyle,
} from "react-native";
import { Input, useBottomSheetAwareHandlers } from "heroui-native";
import { Input, TextArea, useBottomSheetAwareHandlers } from "heroui-native";
import { Toaster } from "sonner-native";
import { resetToaster } from "@/utils/toaster";
@@ -67,7 +67,7 @@ function setInputRef(ref: Ref<TextInput>, node: TextInput | null) {
function isSheetInput(node: ReactNode) {
if (!isValidElement(node)) return false;
const t = node.type as { displayName?: string };
return typeof t === "object" && t?.displayName === "AppBottomSheetInput";
return typeof t === "object" && (t?.displayName === "AppBottomSheetInput" || t?.displayName === "AppBottomSheetTextArea");
}
function countInputs(children: ReactNode): number {
let c = 0;
@@ -284,6 +284,34 @@ const AppBottomSheetInput = forwardRef<TextInput, ComponentProps<typeof Input>>(
);
AppBottomSheetInput.displayName = "AppBottomSheetInput";
// ── TextArea ───────────────────────────────────────────
const AppBottomSheetTextArea = forwardRef<TextInput, ComponentProps<typeof TextArea>>(
({ onFocus, onBlur, ...props }, ref) => {
const handlers = useBottomSheetAwareHandlers();
const inputRef = useRef<TextInput | null>(null);
const { registerInput } = useContext(AppBottomSheetContentContext);
const handleRef = useCallback((node: TextInput | null) => { inputRef.current = node; setInputRef(ref, node); }, [ref]);
return (
<TextArea
ref={handleRef}
onFocus={e => {
handlers.onFocus(e);
registerInput(inputRef.current);
onFocus?.(e);
}}
onBlur={e => {
handlers.onBlur(e);
onBlur?.(e);
}}
{...props}
/>
);
},
);
AppBottomSheetTextArea.displayName = "AppBottomSheetTextArea";
function AppBottomSheetTitle(props: TextProps) { return <Text className="text-center text-base font-semibold" {...props} />; }
function AppBottomSheetDescription(props: TextProps) { return <Text {...props} />; }
@@ -292,6 +320,7 @@ const BottomSheet = Object.assign(AppBottomSheetRoot, {
Overlay: AppBottomSheetOverlay,
Content: AppBottomSheetContent,
Input: AppBottomSheetInput,
TextArea: AppBottomSheetTextArea,
Title: AppBottomSheetTitle,
Description: AppBottomSheetDescription,
});
+16 -14
View File
@@ -43,20 +43,22 @@ export const SUB_APPS: SubAppItem[] = [
group: "J",
description: "家庭菜品库与餐食安排",
},
{
id: "/todo/home",
title: "家庭待办",
icon: IconAppTodo,
group: "J",
description: "家庭任务分配与跟踪",
},
{
id: "/anniversary/home",
title: "纪念日",
icon: IconAppAnniversary,
group: "J",
description: "记录家人的生日与重要纪念日",
},
// TODO: 待办模块暂时隐藏
// {
// id: "/todo/home",
// title: "家庭待办",
// icon: IconAppTodo,
// group: "J",
// description: "家庭任务分配与跟踪",
// },
// TODO: 纪念日模块暂时隐藏
// {
// id: "/anniversary/home",
// title: "纪念日",
// icon: IconAppAnniversary,
// group: "J",
// description: "记录家人的生日与重要纪念日",
// },
];
export function getGroupedApps(keyword?: string): AppListItem[] {
+57
View File
@@ -10,6 +10,7 @@ import {
MealPlanItem,
MealPlanLog,
MealPlanLogType,
MealPlanRating,
MealPlanStatus,
MealSaveChanges,
MealSlot,
@@ -28,6 +29,7 @@ const TABLES = {
plan: "b_meal_plan",
planItem: "b_meal_plan_item",
planLog: "b_meal_plan_log",
planRating: "b_meal_plan_rating",
} as const;
interface MealState {
@@ -41,6 +43,7 @@ interface MealState {
plans: MealPlan[];
planItems: MealPlanItem[];
planLogs: MealPlanLog[];
planRatings: MealPlanRating[];
clear: () => void;
@@ -107,6 +110,10 @@ interface MealState {
completePlan: (params: { planId: number; familyId: number; userId: number }) => Promise<boolean>;
addDishToPlan: (params: { familyId: number; planId: number; dishId: number; dishName: string; userId: number; sortNumber: number }) => Promise<{ planItemId?: number; logId?: number }>;
removeDishFromPlan: (params: { familyId: number; planId: number; planItemId: number; dishName: string; userId: number }) => Promise<boolean>;
// 评分
fetchPlanRatings: (familyId: number, planId: number) => Promise<void>;
ratePlan: (params: { familyId: number; planId: number; rating: number; content?: string; userId: number }) => Promise<boolean>;
}
function normalizeChanges<T>(changes: MealSaveChanges<T>) {
@@ -129,6 +136,7 @@ export const useMealStore = create<MealState>((set, get) => ({
plans: [],
planItems: [],
planLogs: [],
planRatings: [],
clear: () =>
set({
@@ -632,4 +640,53 @@ export const useMealStore = create<MealState>((set, get) => ({
const res = await saveDataApi(ops);
return res.isSuccess;
},
// ======== 评分 ========
fetchPlanRatings: async (familyId, planId) => {
if (!familyId || !planId) return;
const res = await loadDataApi({
view_name: TABLES.planRating,
search_condition: "family_id = ? and plan_id = ?",
search_columns: [],
order_by: "create_time asc, id asc",
args: [familyId, planId],
});
if (res.isSuccess) {
set({ planRatings: (res.data ?? []) as MealPlanRating[] });
}
},
ratePlan: async ({ familyId, planId, rating, content, userId }) => {
if (!familyId || !planId || !userId) return false;
const idRes = await getUniqueIdApi(1);
const id = idRes.data?.[0] ?? 0;
if (!id) return false;
const now = new Date().toISOString();
const inserts = [{
id,
family_id: familyId,
plan_id: planId,
rater_id: userId,
rating,
content: content ?? "",
create_time: now,
create_by: userId,
}];
const res = await saveDataApi([{
table_name: TABLES.planRating,
key_field: "id",
inserts,
updates: [],
deletes: [],
}]);
if (res.isSuccess) {
// 乐观更新本地 state
set((s) => ({ planRatings: [...s.planRatings, inserts[0] as any] }));
}
return res.isSuccess;
},
}));
+13
View File
@@ -171,6 +171,19 @@ export interface MealPlanLog {
update_by?: number;
}
export interface MealPlanRating {
id: number;
family_id: number;
plan_id: number;
rater_id: number;
rating: number;
content?: string;
create_time?: string;
update_time?: string;
create_by?: number;
update_by?: number;
}
// --- 保存变更工具类型 ---
export interface MealSaveChanges<T> {