20260706213434
This commit is contained in:
1 parent
4d1e6c8b8f
commit
f8e16efa83
23 files changed
+1157
-690
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Generated
+18
-52
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}));
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user