diff --git a/code/allapp/allapp-go-v3/internal/bootstrap/boots/pgsql.go b/code/allapp/allapp-go-v3/internal/bootstrap/boots/pgsql.go index 3b6a11ce..065cb87e 100644 --- a/code/allapp/allapp-go-v3/internal/bootstrap/boots/pgsql.go +++ b/code/allapp/allapp-go-v3/internal/bootstrap/boots/pgsql.go @@ -43,24 +43,40 @@ func InitPgsql(cfg *config.Config) (*pgxpool.Pool, error) { } // ====================== - // 连接池配置 + // 连接池配置(带默认值) // ====================== - // 最大连接数 + // 默认最大连接数 + maxConns := int32(20) if pg.MaxOpenConns > 0 { - conf.MaxConns = int32(pg.MaxOpenConns) + maxConns = int32(pg.MaxOpenConns) } - // 最小连接数(近似 idle) + // 默认最小连接数 + minConns := int32(2) if pg.MaxIdleConns > 0 { - conf.MinConns = int32(pg.MaxIdleConns) + minConns = int32(pg.MaxIdleConns) } - // 连接最大生命周期 + // 防止配置错误:min > max + if minConns > maxConns { + minConns = maxConns + } + + // 应用配置 + conf.MaxConns = maxConns + conf.MinConns = minConns + + // 默认连接最大生命周期 if pg.ConnMaxLifetime > 0 { conf.MaxConnLifetime = pg.ConnMaxLifetime + } else { + conf.MaxConnLifetime = time.Hour } + // (推荐补充)空闲连接最大存活时间 + conf.MaxConnIdleTime = 30 * time.Minute + // ====================== // 时区设置(正确方式 ⭐) // ====================== diff --git a/code/allapp/allapp-go-v3/internal/handle/auth.go b/code/allapp/allapp-go-v3/internal/handle/auth.go index d0d20746..f8f0f72a 100644 --- a/code/allapp/allapp-go-v3/internal/handle/auth.go +++ b/code/allapp/allapp-go-v3/internal/handle/auth.go @@ -3,6 +3,7 @@ package handle import ( "allapp-go/internal/errors" "allapp-go/internal/httpx" + "allapp-go/internal/middleware" "allapp-go/internal/types" "allapp-go/pkg/db" "allapp-go/pkg/jwtx" @@ -10,7 +11,9 @@ import ( "allapp-go/pkg/s3store" "allapp-go/pkg/uniqueid" "allapp-go/pkg/wechat" + "bytes" "fmt" + "io" "net/url" "path/filepath" "time" @@ -161,6 +164,14 @@ func registerUser( _ = stream.Close() }() + // ✅ 只在这里转成可 seek + data, err := io.ReadAll(stream) + if err != nil { + return err + } + + reader := bytes.NewReader(data) + // ✅ 从 avatar URL 获取扩展名(简化版) u, _ := url.Parse(avatar) ext := filepath.Ext(u.Path) @@ -181,12 +192,12 @@ func registerUser( // 上传到 S3 if err := s3store.UploadToRustFS( c.Context(), - stream, + reader, key, size, contentType, ); err != nil { - avatar = defaultAvatar + return errors.WithStack(err) } else { avatar = key } @@ -240,9 +251,160 @@ func registerUser( return httpx.OK(c, vo) } +// BindQq 绑定QQ +func BindQq(c fiber.Ctx) error { + var req types.LoginQqReq + + if err := httpx.BindAndValidate(c, &req); err != nil { + return errors.WithStack(err) + } + + dbClient := db.New() + + // 检查 QQ 是否已经被其他账号绑定 + var exist []map[string]any + _, err := dbClient.LoadDataBySQL( + c.Context(), + "SELECT id FROM b_user_oauth WHERE openid = ? AND type = 1", + []any{req.Openid}, + ) + if err != nil { + return errors.WithStack(err) + } + + if len(exist) > 0 { + return httpx.Fail(c, "该 QQ 已被其他账号绑定") + } + + userID, ok := middleware.GetUserID(c.Context()) + if !ok { + return httpx.Unauthorized(c, "账号异常") + } + + var userBind []map[string]any + _, err = dbClient.LoadDataBySQL( + c.Context(), + "SELECT id FROM b_user_oauth WHERE user_id = ? AND type = 1", + []any{userID}, + ) + if err != nil { + return errors.WithStack(err) + } + + if len(userBind) > 0 { + return httpx.Fail(c, "该账号已绑定 QQ") + } + + err = dbClient.WithTx(c.Context(), func(tx *db.Client) error { + err2 := tx.Insert( + c.Context(), + "b_user_oauth", + "id", + map[string]any{ + "id": uniqueid.NextId(), + "user_id": userID, + "type": 1, // QQ + "openid": req.Openid, + "create_time": time.Now(), + "update_time": time.Now(), + }, + ) + + if err2 != nil { + return err2 + } + + return nil + }) + + if err != nil { + return errors.WithStack(err) + } + + return httpx.OK(c, "绑定成功") +} + +// BindWechat 绑定微信 +func BindWechat(c fiber.Ctx) error { + var req types.LoginWechatReq + + if err := httpx.BindAndValidate(c, &req); err != nil { + return errors.WithStack(err) + } + + dbClient := db.New() + + // 2️⃣ 检查 微信 是否已经被其他账号绑定 + var exist []map[string]any + _, err := dbClient.LoadDataBySQL( + c.Context(), + "SELECT id FROM b_user_oauth WHERE openid = ? AND type = 0", + []any{req.Code}, + ) + if err != nil { + return errors.WithStack(err) + } + + if len(exist) > 0 { + return httpx.Fail(c, "该 微信 已被其他账号绑定") + } + + userID, ok := middleware.GetUserID(c.Context()) + if !ok { + return httpx.Unauthorized(c, "账号异常") + } + + var userBind []map[string]any + _, err = dbClient.LoadDataBySQL( + c.Context(), + "SELECT id FROM b_user_oauth WHERE user_id = ? AND type = 0", + []any{userID}, + ) + if err != nil { + return errors.WithStack(err) + } + + if len(userBind) > 0 { + return httpx.Fail(c, "该账号已绑定 微信") + } + + openid, _, err := wechat.GetWechatAccess(req.Code) + if err != nil { + log.Errorw("微信获取Token失败", "code", req.Code, "error", err) + return httpx.Fail(c, "微信登录失败,请重试") + } + + err = dbClient.WithTx(c.Context(), func(tx *db.Client) error { + err2 := tx.Insert( + c.Context(), + "b_user_oauth", + "id", + map[string]any{ + "id": uniqueid.NextId(), + "user_id": userID, + "type": 0, // 微信 + "openid": openid, + "create_time": time.Now(), + "update_time": time.Now(), + }, + ) + + if err2 != nil { + return err2 + } + + return nil + }) + + if err != nil { + return errors.WithStack(err) + } + + return httpx.OK(c, "绑定成功") +} + // ======================== DB 查询封装(去重复 SQL) ======================== func getUserByOpenID(dbClient *db.Client, c fiber.Ctx, openid string, loginType int16) (map[string]any, error) { - users, err := dbClient.LoadDataBySQL( c.Context(), `SELECT u.* diff --git a/code/allapp/allapp-go-v3/internal/router/router.go b/code/allapp/allapp-go-v3/internal/router/router.go index c8f84b43..260f67cc 100644 --- a/code/allapp/allapp-go-v3/internal/router/router.go +++ b/code/allapp/allapp-go-v3/internal/router/router.go @@ -26,4 +26,8 @@ func SetupRouter(app *fiber.App, cfg *config.Config) { // ==================== auth ==================== api.Post("/auth/login/qq", handle.LoginQq) api.Post("/auth/login/wechat", handle.LoginWechat) + + bind := api.Group("/bind", middleware.Auth()) + bind.Post("/qq", handle.BindQq) + bind.Post("/wechat", handle.BindWechat) } diff --git a/code/allapp/allapp-go-v3/pkg/db/db.go b/code/allapp/allapp-go-v3/pkg/db/db.go index d0e70395..c8b0781f 100644 --- a/code/allapp/allapp-go-v3/pkg/db/db.go +++ b/code/allapp/allapp-go-v3/pkg/db/db.go @@ -10,33 +10,41 @@ import ( var defaultClient *Client -type Executor interface { - Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) - Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) - QueryRow(ctx context.Context, sql string, args ...any) pgx.Row -} - type Client struct { pool *pgxpool.Pool tx pgx.Tx } -// Init 初始化 +// Init 初始化(只调用一次) func Init(pool *pgxpool.Pool) { defaultClient = &Client{pool: pool} } -// New 创建会话 +// New 获取全局 client func New() *Client { - return &Client{ - pool: defaultClient.pool, + if defaultClient == nil { + panic("db not initialized, call db.Init(pool) first") } + return defaultClient } -// 内部获取执行器(关键) -func (c *Client) exec() Executor { +func (c *Client) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { if c.tx != nil { - return c.tx + return c.tx.Exec(ctx, sql, args...) } - return c.pool + return c.pool.Exec(ctx, sql, args...) +} + +func (c *Client) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) { + if c.tx != nil { + return c.tx.Query(ctx, sql, args...) + } + return c.pool.Query(ctx, sql, args...) +} + +func (c *Client) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row { + if c.tx != nil { + return c.tx.QueryRow(ctx, sql, args...) + } + return c.pool.QueryRow(ctx, sql, args...) } diff --git a/code/allapp/allapp-go-v3/pkg/db/delete.go b/code/allapp/allapp-go-v3/pkg/db/delete.go index 1b30073e..25e2a397 100644 --- a/code/allapp/allapp-go-v3/pkg/db/delete.go +++ b/code/allapp/allapp-go-v3/pkg/db/delete.go @@ -7,32 +7,24 @@ import ( ) func (c *Client) Delete(ctx context.Context, table, keyField string, data map[string]any) error { - _, err := validateData(table, keyField, data) + + keyVal, err := validateData(table, keyField, data) if err != nil { return err } - where := make([]string, 0, len(data)) - args := make([]any, 0, len(data)) - i := 1 - - for k, v := range data { - where = append(where, fmt.Sprintf("%s = $%d", quoteCol(k), i)) - args = append(args, v) - i++ - } - sql := fmt.Sprintf( - "DELETE FROM %s WHERE %s", + "DELETE FROM %s WHERE %s = $1", quoteTable(table), - strings.Join(where, " AND "), + quoteCol(keyField), ) - _, err = c.exec().Exec(ctx, sql, args...) + _, err = c.pool.Exec(ctx, sql, keyVal) // ⭐修复 exec return err } func (c *Client) BatchDelete(ctx context.Context, table, keyField string, list []map[string]any) error { + if len(list) == 0 { return fmt.Errorf("empty data") } @@ -44,6 +36,7 @@ func (c *Client) BatchDelete(ctx context.Context, table, keyField string, list [ ) for _, row := range list { + v, err := validateData(table, keyField, row) if err != nil { return err @@ -61,6 +54,6 @@ func (c *Client) BatchDelete(ctx context.Context, table, keyField string, list [ strings.Join(in, ", "), ) - _, err := c.exec().Exec(ctx, sql, args...) + _, err := c.pool.Exec(ctx, sql, args...) // ⭐修复 exec return err } diff --git a/code/allapp/allapp-go-v3/pkg/db/insert.go b/code/allapp/allapp-go-v3/pkg/db/insert.go index 1da6229b..b59ab098 100644 --- a/code/allapp/allapp-go-v3/pkg/db/insert.go +++ b/code/allapp/allapp-go-v3/pkg/db/insert.go @@ -31,6 +31,7 @@ func buildInsertSQL(table string, data map[string]any) (string, []any) { } func (c *Client) Insert(ctx context.Context, table, keyField string, data map[string]any) error { + if _, err := validateData(table, keyField, data); err != nil { return err } @@ -38,11 +39,13 @@ func (c *Client) Insert(ctx context.Context, table, keyField string, data map[st data = applyMetaFields(ctx, table, data, true) sql, args := buildInsertSQL(table, data) - _, err := c.exec().Exec(ctx, sql, args...) + + _, err := c.pool.Exec(ctx, sql, args...) return err } func (c *Client) BatchInsert(ctx context.Context, table, keyField string, list []map[string]any) error { + if len(list) == 0 { return fmt.Errorf("empty data") } @@ -54,6 +57,7 @@ func (c *Client) BatchInsert(ctx context.Context, table, keyField string, list [ tableSQL := quoteTable(table) + // 固定字段顺序(稳定性关键) var cols []string for k := range first { cols = append(cols, k) @@ -67,9 +71,11 @@ func (c *Client) BatchInsert(ctx context.Context, table, keyField string, list [ ) for _, row := range list { + row = applyMetaFields(ctx, table, row, true) var place []string + for _, col := range cols { v, ok := row[col] if !ok { @@ -85,8 +91,8 @@ func (c *Client) BatchInsert(ctx context.Context, table, keyField string, list [ } var quotedCols []string - for _, c := range cols { - quotedCols = append(quotedCols, quoteCol(c)) + for _, col := range cols { + quotedCols = append(quotedCols, quoteCol(col)) } sql := fmt.Sprintf( @@ -96,6 +102,6 @@ func (c *Client) BatchInsert(ctx context.Context, table, keyField string, list [ strings.Join(values, ", "), ) - _, err := c.exec().Exec(ctx, sql, args...) + _, err := c.pool.Exec(ctx, sql, args...) // ⭐关键修复 return err } diff --git a/code/allapp/allapp-go-v3/pkg/db/select.go b/code/allapp/allapp-go-v3/pkg/db/select.go index 7ca38716..5dcaa358 100644 --- a/code/allapp/allapp-go-v3/pkg/db/select.go +++ b/code/allapp/allapp-go-v3/pkg/db/select.go @@ -34,7 +34,8 @@ func (c *Client) LoadData( order = " ORDER BY " + orderBy } - sql := fmt.Sprintf("SELECT %s FROM %s%s%s", + sql := fmt.Sprintf( + "SELECT %s FROM %s%s%s", selectCols, viewName, where, @@ -54,26 +55,25 @@ func (c *Client) LoadDataBySQL( sql string, args []any, ) ([]map[string]any, error) { + logger.FromCtx(ctx).Info("LoadDataBySQL", zap.String("sql", sql), zap.Any("args", args), ) + return c.query(ctx, sql, args...) } -// ========================== -// 核心查询方法(已升级) -// ========================== func (c *Client) query(ctx context.Context, sql string, args ...any) ([]map[string]any, error) { + sql = convertPlaceholder(sql) - rows, err := c.exec().Query(ctx, sql, args...) + rows, err := c.pool.Query(ctx, sql, args...) if err != nil { return nil, err } defer rows.Close() - // 获取字段名 fields := rows.FieldDescriptions() result := make([]map[string]any, 0, 16) @@ -86,19 +86,22 @@ func (c *Client) query(ctx context.Context, sql string, args ...any) ([]map[stri row := make(map[string]any, len(values)) - for i, f := range fields { + for i := range fields { if i < len(values) { - row[string(f.Name)] = values[i] + row[string(fields[i].Name)] = values[i] } } result = append(result, row) } - return result, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil } -// 高效 placeholder 转换 func convertPlaceholder(sql string) string { var sb strings.Builder sb.Grow(len(sql)) diff --git a/code/allapp/allapp-go-v3/pkg/db/tx.go b/code/allapp/allapp-go-v3/pkg/db/tx.go index 974fcd30..b076386b 100644 --- a/code/allapp/allapp-go-v3/pkg/db/tx.go +++ b/code/allapp/allapp-go-v3/pkg/db/tx.go @@ -2,12 +2,10 @@ package db import ( "context" - - "github.com/jackc/pgx/v5" ) -// WithTx 开启事务(在当前 client 上) func (c *Client) WithTx(ctx context.Context, fn func(tx *Client) error) error { + tx, err := c.pool.Begin(ctx) if err != nil { return err @@ -19,16 +17,22 @@ func (c *Client) WithTx(ctx context.Context, fn func(tx *Client) error) error { tx: tx, } - defer func(tx pgx.Tx, ctx context.Context) { - err := tx.Rollback(ctx) - if err != nil { - - } - }(tx, ctx) + // rollback 兜底(防 panic / 提前 return) + defer func() { + _ = tx.Rollback(ctx) + }() + // 执行业务 if err := fn(txClient); err != nil { + _ = tx.Rollback(ctx) return err } - return tx.Commit(ctx) + // commit + if err := tx.Commit(ctx); err != nil { + _ = tx.Rollback(ctx) + return err + } + + return nil } diff --git a/code/allapp/allapp-go-v3/pkg/db/update.go b/code/allapp/allapp-go-v3/pkg/db/update.go index 522f73b6..83fad658 100644 --- a/code/allapp/allapp-go-v3/pkg/db/update.go +++ b/code/allapp/allapp-go-v3/pkg/db/update.go @@ -8,6 +8,7 @@ import ( ) func (c *Client) Update(ctx context.Context, table, keyField string, data map[string]any) error { + keyVal, err := validateData(table, keyField, data) if err != nil { return err @@ -15,9 +16,6 @@ func (c *Client) Update(ctx context.Context, table, keyField string, data map[st data = applyMetaFields(ctx, table, data, false) - tableSQL := quoteTable(table) - keySQL := quoteCol(keyField) - var ( set []string args []any @@ -38,17 +36,18 @@ func (c *Client) Update(ctx context.Context, table, keyField string, data map[st sql := fmt.Sprintf( "UPDATE %s SET %s WHERE %s=$%d", - tableSQL, + quoteTable(table), strings.Join(set, ", "), - keySQL, + quoteCol(keyField), i, ) - _, err = c.exec().Exec(ctx, sql, args...) + _, err = c.pool.Exec(ctx, sql, args...) return err } func (c *Client) BatchUpdate(ctx context.Context, table, keyField string, list []map[string]any) error { + if len(list) == 0 { return fmt.Errorf("empty data") } @@ -75,6 +74,7 @@ func (c *Client) BatchUpdate(ctx context.Context, table, keyField string, list [ sets []string ) + // CASE 构建 for _, col := range cols { colSQL := quoteCol(col) @@ -84,7 +84,7 @@ func (c *Client) BatchUpdate(ctx context.Context, table, keyField string, list [ for _, row := range list { row = applyMetaFields(ctx, table, row, false) - keyVal, _ := row[keyField] + keyVal := row[keyField] val := row[col] caseSQL.WriteString(fmt.Sprintf( @@ -101,9 +101,10 @@ func (c *Client) BatchUpdate(ctx context.Context, table, keyField string, list [ sets = append(sets, caseSQL.String()) } - var where []string + // ⭐修复 IN 写法(关键) + var inPlaceholders []string for _, row := range list { - where = append(where, fmt.Sprintf("$%d", argIndex)) + inPlaceholders = append(inPlaceholders, fmt.Sprintf("$%d", argIndex)) args = append(args, row[keyField]) argIndex++ } @@ -113,9 +114,9 @@ func (c *Client) BatchUpdate(ctx context.Context, table, keyField string, list [ tableSQL, strings.Join(sets, ", "), keySQL, - strings.Join(where, ", "), + strings.Join(inPlaceholders, ", "), ) - _, err := c.exec().Exec(ctx, sql, args...) + _, err := c.pool.Exec(ctx, sql, args...) // ⭐修复点 return err } diff --git a/code/app/app-rn4/src/app/auth/login.tsx b/code/app/app-rn4/src/app/auth/login.tsx index fcb3772e..79038069 100644 --- a/code/app/app-rn4/src/app/auth/login.tsx +++ b/code/app/app-rn4/src/app/auth/login.tsx @@ -111,46 +111,6 @@ export default function Login() { }, [onLoginFinished]); const handlePostLogin = async (user: any) => { - // let finalAvatar = user.avatar || ""; - - // // 统一头像迁移逻辑 - // const isExternalUrl = finalAvatar && !isS3Key(finalAvatar); - - // if (isExternalUrl) { - // userStore.updateToken(user.token); - - // try { - // const file = { - // uri: finalAvatar, - // name: `avatar_${Date.now()}.jpg`, - // type: "image/jpeg", - // }; - - // const uploadRes = await s3UploadFileApi([file]); - - // if (uploadRes.isSuccess) { - // finalAvatar = uploadRes.data?.[0] || finalAvatar; - - // saveDataApi([ - // { - // table_name: "b_user", - // key_field: "id", - // inserts: [], - // deletes: [], - // updates: [ - // { - // id: user.userId, - // avatar: finalAvatar, - // }, - // ], - // }, - // ]).catch(console.log); - // } - // } catch (e) { - // console.log("头像迁移失败", e); - // } - // } - // 统一写入 store userStore.setUserInfo({ token: user.token, @@ -254,14 +214,22 @@ export default function Login() { {loading ? ( ) : ( - + <> + + WeChat + )} - WeChat - diff --git a/code/app/app-rn4/src/app/family/settings.tsx b/code/app/app-rn4/src/app/family/settings.tsx index 06f952cd..5718277a 100644 --- a/code/app/app-rn4/src/app/family/settings.tsx +++ b/code/app/app-rn4/src/app/family/settings.tsx @@ -28,6 +28,7 @@ import { CopyIcon } from "lucide-react-native"; import * as Clipboard from "expo-clipboard"; import { formatRelativeTime } from "@/utils/time"; import { UserRoundIcon } from "lucide-react-native"; +import { buildFileUrl } from "@/utils/file"; const StyledIonicons = withUniwind(Ionicons); @@ -168,6 +169,8 @@ export default function HomeSettings() { return; } + setRenameLoading(true); + try { const requestData = []; @@ -223,6 +226,8 @@ export default function HomeSettings() { return; } + setNicknameLoading(true); + try { const requestData = []; @@ -264,6 +269,8 @@ export default function HomeSettings() { async function handleExitFamily() { if (exitLoading) return; + setExitLoading(true); + try { let isExit = true; @@ -357,20 +364,25 @@ export default function HomeSettings() { } async function loadMembers() { - let searchCondition = `family_id = ?`; - let args = [familyStore.currentFamily.family_id]; + const familyId = familyStore.currentFamily?.family_id; + if (!familyId) return; const loadRes = await loadDataApi({ - view_name: `v_family_member`, - search_condition: searchCondition, + view_name: "v_family_member", + search_condition: "family_id = ?", search_columns: [], order_by: "role ASC, create_time ASC", - args, + args: [familyId], }); - if (loadRes.isSuccess) { - setFamilyMembers(loadRes.data); - } + if (!loadRes?.isSuccess || !Array.isArray(loadRes.data)) return; + + const list = loadRes.data.map((item) => ({ + ...item, + avatar: item.avatar ? buildFileUrl(item.avatar) : "", + })); + + setFamilyMembers(list); } return ( @@ -537,9 +549,12 @@ export default function HomeSettings() { /> - @@ -574,9 +589,12 @@ export default function HomeSettings() { /> - @@ -612,9 +630,12 @@ export default function HomeSettings() { 取消 - diff --git a/code/app/app-rn4/src/app/family/user.tsx b/code/app/app-rn4/src/app/family/user.tsx index de84ee18..ac3ec4fd 100644 --- a/code/app/app-rn4/src/app/family/user.tsx +++ b/code/app/app-rn4/src/app/family/user.tsx @@ -1,18 +1,61 @@ import LayoutView from "@/components/layout/view"; -import { View, Text, Pressable } from "react-native"; +import { View, Text, Pressable, Keyboard } from "react-native"; import PageLayout from "@/layouts/PageLayout"; -import { Avatar, PressableFeedback, ListGroup, Separator, Button, Dialog } from "heroui-native"; +import { + Avatar, + PressableFeedback, + BottomSheet, + ListGroup, + Separator, + Button, + Dialog, + TextField, + Label, + Input, + useBottomSheetAwareHandlers, + Spinner, +} from "heroui-native"; import { loadDataApi, saveDataApi } from "@/request/api"; import { useUserStore, useFamilyStore } from "@/store"; import { useEffect, useState } from "react"; import { CameraIcon } from "lucide-react-native"; import ImagePicker from "react-native-image-crop-picker"; -import { s3UploadFileApi } from "@/request/api"; +import { s3UploadFileApi, bindQqApi, bindWxApi } from "@/request/api"; import { buildFileUrl } from "@/utils/file"; import { PhoneIcon, CakeIcon, ContactRoundIcon } from "lucide-react-native"; import { IconQq, IconWeixin } from "@/components/iconfont"; import { useRouter } from "expo-router"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { withUniwind } from "uniwind"; +import ExpoQQ from "expo-qq"; +import ExpoWeChat from "expo-wechat"; +import { useEvent } from "expo"; +import { toast } from "sonner-native"; +import { forwardRef, useRef } from "react"; +import { FadeIn } from "react-native-reanimated"; import DateTime from "@/components/DateTime"; +import { toDateString } from "@/utils/time"; + +const StyledIonicons = withUniwind(Ionicons); + +const BottomSheetTextInput = forwardRef>((props, ref) => { + const { onFocus, onBlur } = useBottomSheetAwareHandlers(); + + return ( + { + onFocus(e); + props.onFocus?.(e); + }} + onBlur={(e) => { + onBlur(e); + props.onBlur?.(e); + }} + /> + ); +}); export default function UserSetting() { const router = useRouter(); @@ -25,10 +68,75 @@ export default function UserSetting() { const [openLogout, setOpenLogout] = useState(false); + // 绑定 + const [bindLoading, setBindLoading] = useState(false); + const onLoginFinished = useEvent(ExpoQQ, "onLoginFinished"); + const authResult = useEvent(ExpoWeChat, "onAuthResult"); + + // 名称 + const [showRename, setShowRename] = useState(false); + const [renameLoading, setRenameLoading] = useState(false); + + const [renameForm, setRenameForm] = useState({ + name: "", + }); + + const renameInputRef = useRef(null); + + // 手机号 + const [showPhone, setShowPhone] = useState(false); + const [phoneLoading, setPhoneLoading] = useState(false); + + const [phoneForm, setPhoneForm] = useState({ + phone: "", + }); + + const phoneInputRef = useRef(null); + + // 年龄 + const [showBirth, setShowBirth] = useState(false); + + const [birthForm, setBirthForm] = useState({ + birth: "", + }); + useEffect(() => { loadUserInfo(); }, []); + useEffect(() => { + ExpoQQ.init("102826474", ""); + ExpoWeChat.registerApp("wxdab3e21a1f7e392f", ""); + }, []); + + useEffect(() => { + if (showRename) { + requestAnimationFrame(() => { + renameInputRef.current?.focus(); + }); + } else { + setRenameForm({ + name: "", + }); + setRenameLoading(false); + Keyboard.dismiss(); + } + }, [showRename]); + + useEffect(() => { + if (showPhone) { + requestAnimationFrame(() => { + phoneInputRef.current?.focus(); + }); + } else { + setPhoneForm({ + phone: "", + }); + setPhoneLoading(false); + Keyboard.dismiss(); + } + }, [showPhone]); + async function loadUserInfo() { try { const userId = userStore.userInfo.id; @@ -71,6 +179,118 @@ export default function UserSetting() { } } + // 微信登录 + async function handleBindWechat() { + if (bindWechat) { + toast.success("微信已绑定"); + setBindLoading(false); + return; + } + + if (bindLoading) { + return; + } + setBindLoading(true); + + // 检查微信是否安装 + const isInstalled = await ExpoWeChat.isWXAppInstalled(); + if (!isInstalled) { + toast.warning(onLoginFinished?.errorMessage || "请先安装微信"); + setBindLoading(false); + return; + } + + ExpoWeChat.sendAuthRequest("snsapi_userinfo", "state123"); + } + + // QQ 登录 + async function handleBindQq() { + if (bindQq) { + toast.success("QQ已绑定"); + setBindLoading(false); + return; + } + + if (bindLoading) { + return; + } + + setBindLoading(true); + + await ExpoQQ.login(["get_user_info"]); + } + + useEffect(() => { + if (!onLoginFinished) { + return; + } + + if (!onLoginFinished.success) { + toast.warning(onLoginFinished?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权"); + setBindLoading(false); + return; + } + + const fetchUserInfo = async () => { + try { + const tokenInfo = await ExpoQQ.getLoginTokenInfo(); + if (!tokenInfo.openId) { + toast.warning(tokenInfo?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权"); + setBindLoading(false); + return; + } + + const bindRes = await bindQqApi({ + openid: String(tokenInfo.openId), + avatar: "", + nickname: "", + }); + + if (bindRes.isSuccess) { + toast.success("绑定 QQ 成功"); + setBindQq(true); + } + } catch (e) { + console.log(e); + } finally { + setBindLoading(false); + } + }; + + fetchUserInfo(); + }, [onLoginFinished]); + + // 微信登录 + useEffect(() => { + if (!authResult) return; + + const handleLogin = async () => { + if (authResult.errorCode !== 0) { + console.log("授权失败:", authResult.errorMessage); + setBindLoading(false); + return; + } + + try { + // 登录 + const bindRes = await bindWxApi({ + code: authResult.code, + }); + + if (bindRes.isSuccess) { + toast.success("绑定 微信 成功"); + setBindWechat(true); + } + } catch (err) { + console.error("微信登录失败", err); + } finally { + setBindLoading(false); + } + }; + + handleLogin(); + }, [authResult]); + // 选择头像 const pickImage = async () => { try { @@ -128,6 +348,164 @@ export default function UserSetting() { }); } + // 姓名 + async function handleRename() { + if (renameLoading) return; + + if (renameForm.name === "") { + toast.warning("名称不能为空"); + return; + } + + if (renameForm.name === userInfo.name) { + setShowRename(false); + setRenameForm({ + name: "", + }); + toast.success("名称修改成功"); + return; + } + + setRenameLoading(true); + + try { + const requestData = []; + + requestData.push({ + table_name: "b_user", + key_field: "id", + inserts: [], + updates: [ + { + id: userInfo.id, + name: renameForm.name, + }, + ], + deletes: [], + }); + + const saveRes = await saveDataApi(requestData); + + if (saveRes.isSuccess) { + setUserInfo((prev: any) => ({ + ...prev, + name: renameForm.name, + })); + + setShowRename(false); + setRenameForm({ + name: "", + }); + + toast.success("名称修改成功"); + } + } catch (e) { + console.log(e); + } finally { + setRenameLoading(false); + } + } + + // 手机号 + async function handlePhone() { + if (phoneLoading) return; + + if (phoneForm.phone === "") { + toast.warning("手机号不能为空"); + return; + } + + if (phoneForm.phone === userInfo.phone) { + setShowPhone(false); + setPhoneForm({ + phone: "", + }); + toast.success("手机号修改成功"); + return; + } + + setPhoneLoading(true); + + try { + const requestData = []; + + requestData.push({ + table_name: "b_user", + key_field: "id", + inserts: [], + updates: [ + { + id: userInfo.id, + phone: phoneForm.phone, + }, + ], + deletes: [], + }); + + const saveRes = await saveDataApi(requestData); + + if (saveRes.isSuccess) { + setUserInfo((prev: any) => ({ + ...prev, + phone: phoneForm.phone, + })); + + setShowPhone(false); + setPhoneForm({ + phone: "", + }); + + toast.success("手机号修改成功"); + } + } catch (e) { + console.log(e); + } finally { + setPhoneLoading(false); + } + } + + // 生日期 + async function handleBirth(value: string) { + if (value === userInfo.birth_date) { + setShowBirth(false); + return; + } + + try { + const requestData = [ + { + table_name: "b_user", + key_field: "id", + inserts: [], + updates: [ + { + id: userInfo.id, + birth_date: value, + }, + ], + deletes: [], + }, + ]; + + const saveRes = await saveDataApi(requestData); + + if (saveRes.isSuccess) { + setUserInfo((prev: any) => ({ + ...prev, + birth_date: value, + })); + + setShowBirth(false); + + toast.success("生日修改成功"); + } else { + toast.error(saveRes.message || "修改失败"); + } + } catch (e) { + console.log(e); + } + } + return ( @@ -165,7 +543,14 @@ export default function UserSetting() { 基础信息 - + { + setShowRename(true); + setRenameForm({ + name: userInfo.name, + }); + }} + > @@ -181,7 +566,14 @@ export default function UserSetting() { - + { + setShowPhone(true); + setPhoneForm({ + phone: userInfo.phone, + }); + }} + > @@ -196,7 +588,14 @@ export default function UserSetting() { - + { + setShowBirth(true); + setBirthForm({ + birth: userInfo.birth_date, + }); + }} + > @@ -205,7 +604,9 @@ export default function UserSetting() { 生日 - {userInfo.birth_date || "-"} + + {toDateString(userInfo.birth_date) || "-"} + @@ -214,7 +615,11 @@ export default function UserSetting() { 账号信息 - + { + handleBindQq(); + }} + > @@ -223,15 +628,28 @@ export default function UserSetting() { QQ - - {bindQq ? "已绑定" : "未绑定"} - + + + {bindQq ? "已绑定" : "未绑定"} + + {bindQq && ( + + )} + - + { + handleBindWechat(); + }} + > @@ -239,28 +657,17 @@ export default function UserSetting() { 微信 - + {bindWechat ? "已绑定" : "未绑定"} - - - - - - - 其它 - - - - - - - - QQ - - - 注销账号 + {bindWechat && ( + + )} @@ -269,7 +676,7 @@ export default function UserSetting() { + + + + + + { + setShowPhone(val); + }} + > + + + + + + + + setPhoneForm({ + phone: val, + }) + } + /> + + + + + + + + + { + setShowBirth(false); + }} + onConfirm={handleBirth} + /> ); } diff --git a/code/app/app-rn4/src/components/DateTime.tsx b/code/app/app-rn4/src/components/DateTime.tsx index 8881b59a..b0e7aa5d 100644 --- a/code/app/app-rn4/src/components/DateTime.tsx +++ b/code/app/app-rn4/src/components/DateTime.tsx @@ -1,10 +1,11 @@ import { DatePicker as YzDatePicker } from "@yz1311/react-native-wheel-picker"; import dayjs from "dayjs"; -import { BottomSheet, Button } from "heroui-native"; +import { BottomSheet, Button, Spinner } from "heroui-native"; import { useState, useEffect } from "react"; import { toDate } from "@/utils/time"; import { View } from "react-native"; import { CheckIcon } from "lucide-react-native"; +import { FadeIn } from "react-native-reanimated"; export type DatePickerMode = "year" | "month" | "date" | "time" | "datetime"; @@ -14,7 +15,7 @@ interface DatePickerProps { value: Date | string; show?: boolean; onClose?: () => void; - onConfirm?: (value: string) => void; + onConfirm?: (value: string) => void | Promise; mode?: DatePickerMode; format?: DateFormatType; } @@ -35,9 +36,10 @@ export default function DateTime({ mode = "date", format, }: DatePickerProps) { - const finalFormat = format ?? defaultFormatMap[mode] ?? "YYYY-MM-DD"; + const finalFormat = format ?? defaultFormatMap[mode]; const [tempDate, setTempDate] = useState(toDate(value) ?? new Date()); + const [loading, setLoading] = useState(false); useEffect(() => { if (show) { @@ -46,14 +48,26 @@ export default function DateTime({ }, [show, value]); const handleClose = () => { + if (loading) return; onClose?.(); }; - const handleConfirm = () => { - if (!tempDate) return; + const handleConfirm = async () => { + if (!tempDate || loading) return; - onConfirm?.(dayjs(tempDate).format(finalFormat)); - handleClose(); + try { + setLoading(true); + + const result = dayjs(tempDate).format(finalFormat); + + await onConfirm?.(result); + + handleClose(); + } catch (e) { + console.log(e); + } finally { + setLoading(false); + } }; return ( @@ -71,9 +85,15 @@ export default function DateTime({ onDateChange={(d: Date) => setTempDate(d)} /> - diff --git a/code/app/app-rn4/src/request/api.ts b/code/app/app-rn4/src/request/api.ts index f4e895bd..1f9cd84c 100644 --- a/code/app/app-rn4/src/request/api.ts +++ b/code/app/app-rn4/src/request/api.ts @@ -9,6 +9,14 @@ export function loginWxApi(params: LoginWxParams) { return alovaInstance.Post>(`/auth/login/wechat`, params); } +export function bindQqApi(params: LoginQqParams) { + return alovaInstance.Post>(`/bind/qq`, params); +} + +export function bindWxApi(params: LoginWxParams) { + return alovaInstance.Post>(`/bind/wechat`, params); +} + export function getUniqueIdApi(count = 1) { return alovaInstance.Post>(`/data/getUniqueId`, { count: count, @@ -48,10 +56,6 @@ export function saveDataApi(params: SaveDataParams[]) { return alovaInstance.Post>(`/data/saveData`, params); } -export function s3ListBucketsApi() { - return alovaInstance.Post>(`/s3/listBuckets`); -} - export function s3UploadFileApi(files: any[]) { const form = new FormData(); diff --git a/code/app/app-rn4/src/utils/time.ts b/code/app/app-rn4/src/utils/time.ts index b2da2b93..0d48e970 100644 --- a/code/app/app-rn4/src/utils/time.ts +++ b/code/app/app-rn4/src/utils/time.ts @@ -34,35 +34,44 @@ export function getCurrentYear(): string { /** 字符串 / 数字 / Date → Date(无效返回 null) */ export function toDate(input: string | number | Date | undefined | null): Date | undefined { - if (!input) return undefined; + if (input === null || input === undefined || input === "") return undefined; if (input instanceof Date) return input; const d = dayjs(input); return d.isValid() ? d.toDate() : undefined; } + /** Date → YYYY-MM-DD */ export function toDateString(date: string | number | Date): string { - const d = dayjs(toDate(date)); - return d.isValid() ? d.format("YYYY-MM-DD") : ""; + const parsed = toDate(date); + if (!parsed) return ""; + + return dayjs(parsed).format("YYYY-MM-DD"); } /** Date → YYYY-MM */ export function toYearMonthString(date: string | number | Date): string { - const d = dayjs(toDate(date)); - return d.isValid() ? d.format("YYYY-MM") : ""; + const parsed = toDate(date); + if (!parsed) return ""; + + return dayjs(parsed).format("YYYY-MM"); } /** Date → HH:mm:ss */ export function toTimeString(date: string | number | Date): string { - const d = dayjs(toDate(date)); - return d.isValid() ? d.format("HH:mm:ss") : ""; + const parsed = toDate(date); + if (!parsed) return ""; + + return dayjs(parsed).format("HH:mm:ss"); } /** Date → YYYY-MM-DD HH:mm:ss */ export function toDateTimeString(date: string | number | Date): string { - const d = dayjs(toDate(date)); - return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : ""; + const parsed = toDate(date); + if (!parsed) return ""; + + return dayjs(parsed).format("YYYY-MM-DD HH:mm:ss"); } export function isInMonth(date: string, month: string): boolean {