diff --git a/.cursor/agent/玩值移动端工程师/evolution/2026-04-30-wz-app去mock接streamers-stars-products.md b/.cursor/agent/玩值移动端工程师/evolution/2026-04-30-wz-app去mock接streamers-stars-products.md new file mode 100644 index 0000000..10bbc15 --- /dev/null +++ b/.cursor/agent/玩值移动端工程师/evolution/2026-04-30-wz-app去mock接streamers-stars-products.md @@ -0,0 +1,17 @@ +# 2026-04-30 wz-app 去 Mock:直播流/CP/商城商品/派对礼物 + +## 问题 + +C 端仍存在硬编码列表(直播推荐 `mixedFeed`、`MOMENTS_BY_ID`、CP、`MallPage` 装扮与礼物、派对 `GIFTS`),与线上玩值数据不一致。 + +## 处理 + +- **直播 Tab `/moments`**:`GET /api/app/streamers` 映射竖滑卡片;详情 `/moments/:id` 使用 `getStreamerById`/`useStreamer`。 +- **CP `/cp`**:列表与详情改为 `GET /api/app/stars`、`GET /api/app/stars/:id`;无性别字段时 Tab 用文案「偏女声向/偏男声向」+ 标签粗分。 +- **商城 `MallPage`**:`GET /api/app/products` + `mall-product-buckets` 分装扮/礼物/福利;无数据给用户向空态。 +- **派对详情送礼**:礼物列表同源 `products` 分桶中的 gift 类。 +- **文案**:去掉「占位价」「仅本地演示」「WebSocket」等用户可见技术表述;主播工作台云说明改为产品向。 + +## 规则 + +无独立 Feed 接口前,直播推荐与 streamers 列表对齐;商品分桶依赖后台 `category`/`name` 关键词,需在管理端规范命名以便归类。 diff --git a/.cursor/agent/玩值移动端工程师/evolution/索引.md b/.cursor/agent/玩值移动端工程师/evolution/索引.md index 8d3d863..638ff61 100644 --- a/.cursor/agent/玩值移动端工程师/evolution/索引.md +++ b/.cursor/agent/玩值移动端工程师/evolution/索引.md @@ -2,6 +2,7 @@ | 日期 | 主题 | |------|------| +| 2026-04-30 | [wz-app 去 mock:streamers/stars/products 对接](2026-04-30-wz-app去mock接streamers-stars-products.md) | | 2026-04-30 | [wz-app 目录对接与用户可见文案规范](2026-04-30-wz-app目录对接与用户可见文案.md) | | 2026-04-27 | [三端迁移完成度会议](2026-04-27-三端迁移完成度会议.md) | | 2026-04-24 | [全项目会议与主次线](2026-04-24-全项目会议与主次线.md) | diff --git a/new/wz-api/.env.production b/new/wz-api/.env.production index d1177e7..f25e767 100644 --- a/new/wz-api/.env.production +++ b/new/wz-api/.env.production @@ -22,7 +22,7 @@ MONGODB_URI=mongodb://wzdj:NR6aLyyEaZKWZXky@127.0.0.1:27017/wzdj?authSource=wzdj API_BASE_URL=https://wzapi.quwanzhi.com # 可不写:代码内 defaultCORSOrigins 已含 https://wzadmin.quwanzhi.com 与 wzdj 等;若有额外源再追加 -CORS_ORIGINS=https://wzadmin.quwanzhi.com,http://wzadmin.quwanzhi.com,https://wzdj.quwanzhi.com,http://wzdj.quwanzhi.com +CORS_ORIGINS=https://mwzdj.quwanzhi.com,https://wzadmin.quwanzhi.com,http://wzadmin.quwanzhi.com,https://wzdj.quwanzhi.com,http://wzdj.quwanzhi.com # Mongo-only:无 MySQL AutoMigrate,可忽略 # 本地 Docker:redis-local-nwal 映射 6379:6379;无密码用 db 0 diff --git a/new/wz-api/internal/config/config.go b/new/wz-api/internal/config/config.go index 0d39642..4e5b78e 100644 --- a/new/wz-api/internal/config/config.go +++ b/new/wz-api/internal/config/config.go @@ -45,6 +45,8 @@ type Config struct { WechatMchID string WechatMchKey string WechatNotifyURL string // 由 BaseURL + /api/miniprogram/pay/notify 派生 + WechatWzAppNotifyURL string // 玩值 C 端支付回调 BaseURL+"/api/app/payment/wechat/notify",可 WECHAT_WZ_APP_NOTIFY_URL 覆盖 + WzAppPublicURL string // H5/MWEB 支付 scene_info.app_url(如 https://wzdj.quwanzhi.com);未配时用 H5_READ_PUBLIC_BASE / API_BASE_URL WechatMiniProgramState string // 订阅消息跳转版本:developer/formal,从 .env WECHAT_MINI_PROGRAM_STATE 读取 // 微信转账配置(API v3) @@ -244,6 +246,18 @@ func Load() (*Config, error) { if wechatNotifyURL == "" { wechatNotifyURL = baseURL + "/api/miniprogram/pay/notify" } + wechatWzAppNotifyURL := strings.TrimSpace(os.Getenv("WECHAT_WZ_APP_NOTIFY_URL")) + if wechatWzAppNotifyURL == "" { + wechatWzAppNotifyURL = baseURL + "/api/app/payment/wechat/notify" + } + wzAppPublicURL := strings.TrimSpace(os.Getenv("WZ_APP_PUBLIC_URL")) + if wzAppPublicURL == "" { + wzAppPublicURL = strings.TrimSpace(os.Getenv("H5_READ_PUBLIC_BASE")) + } + if wzAppPublicURL == "" { + wzAppPublicURL = baseURL + } + wzAppPublicURL = strings.TrimSuffix(wzAppPublicURL, "/") wechatMiniProgramState := strings.TrimSpace(os.Getenv("WECHAT_MINI_PROGRAM_STATE")) if wechatMiniProgramState != "developer" && wechatMiniProgramState != "trial" { wechatMiniProgramState = "formal" // 默认正式版,避免生成开发版码导致「开发版已过期」 @@ -362,6 +376,8 @@ func Load() (*Config, error) { WechatMchID: wechatMchID, WechatMchKey: wechatMchKey, WechatNotifyURL: wechatNotifyURL, + WechatWzAppNotifyURL: wechatWzAppNotifyURL, + WzAppPublicURL: wzAppPublicURL, WechatMiniProgramState: wechatMiniProgramState, WechatAPIv3Key: wechatAPIv3Key, WechatCertPath: wechatCertPath, diff --git a/new/wz-api/internal/handler/playhub_wz_app_payment.go b/new/wz-api/internal/handler/playhub_wz_app_payment.go new file mode 100644 index 0000000..68dad8f --- /dev/null +++ b/new/wz-api/internal/handler/playhub_wz_app_payment.go @@ -0,0 +1,426 @@ +package handler + +import ( + "encoding/json" + "fmt" + "io" + "log" + "math" + "net/http" + "strings" + "time" + + "wz-api/internal/config" + "wz-api/internal/model" + mongostore "wz-api/internal/store/mongo" + "wz-api/internal/wechat" + "wz-api/internal/wzstore" + + "github.com/gin-gonic/gin" +) + +// 与 new/wz-app 充值页档位一致:人民币 -> 实际到账玩值币(含赠送) +var wzRechargeRmbToCoins = map[float64]float64{ + 6: 60, + 30: 320, + 68: 730, + 128: 1400, + 328: 3630, + 648: 7280, +} + +type wzPayAttach struct { + K string `json:"k"` // rc 充值 | pc 点卡 + N string `json:"n"` // 商户订单号(与 out_trade_no 一致) + U string `json:"u"` // userId +} + +func wzPayMakeAttach(k, orderNo, userID string) string { + b, _ := json.Marshal(wzPayAttach{K: k, N: orderNo, U: userID}) + s := string(b) + if len(s) > 128 { + b, _ = json.Marshal(wzPayAttach{K: k, N: orderNo}) + s = string(b) + } + return s +} + +func wzYuanToFen(yuan float64) int { + if yuan <= 0 { + return 0 + } + return int(math.Round(yuan * 100)) +} + +func wzNormalizeRmb(y float64) float64 { + return math.Round(y*100) / 100 +} + +// WzAppPaymentWechatPrepayPOST POST /api/app/payment/wechat/prepay — 需 App JWT;channel=h5|jsapi +func WzAppPaymentWechatPrepayPOST(c *gin.Context) { + uid := playhubAppUserID(c) + if uid == "" { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return + } + var body struct { + Channel string `json:"channel"` + Scene string `json:"scene"` + RmbYuan float64 `json:"rmbYuan"` + OpenID string `json:"openId"` + PointCardOrderNo string `json:"pointCardOrderNo"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "参数错误"}) + return + } + ch := strings.ToLower(strings.TrimSpace(body.Channel)) + if ch != "h5" && ch != "jsapi" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "channel 须为 h5 或 jsapi"}) + return + } + sc := strings.ToLower(strings.TrimSpace(body.Scene)) + ctx := c.Request.Context() + notifyURL := wechat.GetWzAppPayNotifyURL() + cfg := config.Get() + siteURL := "" + siteName := "玩值" + if cfg != nil { + siteURL = strings.TrimSuffix(cfg.WzAppPublicURL, "/") + } + + switch sc { + case "recharge": + rmb := wzNormalizeRmb(body.RmbYuan) + coins, ok := wzRechargeRmbToCoins[rmb] + if !ok || rmb <= 0 || coins <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "不支持的充值档位"}) + return + } + fen := wzYuanToFen(rmb) + if fen < 1 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "金额过小"}) + return + } + orderNo := fmt.Sprintf("WZRC%s%06d", time.Now().UTC().Format("20060102150405"), time.Now().Nanosecond()%1000000) + _, err := wzstore.Insert(wzstore.CollRechargeOrders, map[string]interface{}{ + "orderNo": orderNo, + "userId": uid, + "amount": coins, + "totalAmount": coins, + "payAmountYuan": rmb, + "expectedPayFen": fen, + "status": "pending_pay", + "payChannel": "wechat", + "createdAt": time.Now().UTC().Format(time.RFC3339), + }) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "创建订单失败"}) + return + } + desc := fmt.Sprintf("玩值币充值 ¥%.2f", rmb) + attach := wzPayMakeAttach("rc", orderNo, uid) + if ch == "jsapi" { + oid := strings.TrimSpace(body.OpenID) + if oid == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "jsapi 须传 openId"}) + return + } + prepayID, err := wechat.PayJSAPIOrderWithNotify(ctx, oid, orderNo, fen, desc, attach, notifyURL) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("微信下单失败: %v", err)}) + return + } + payParams, err := wechat.GetJSAPIPayParams(prepayID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("生成支付参数失败: %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ + "orderNo": orderNo, + "channel": "jsapi", + "prepayId": prepayID, + "payParams": payParams, + "notifyUrl": notifyURL, + }}) + return + } + h5, err := wechat.PayH5Order(ctx, orderNo, fen, desc, attach, c.ClientIP(), siteURL, siteName, notifyURL) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("微信下单失败: %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ + "orderNo": orderNo, "channel": "h5", "h5Url": h5, "notifyUrl": notifyURL, + }}) + + case "point_card": + po := strings.TrimSpace(body.PointCardOrderNo) + if po == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 pointCardOrderNo"}) + return + } + doc, err := wzstore.FindOneByField(wzstore.CollPointCardOrders, "orderNo", po) + if err != nil || doc == nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "订单不存在"}) + return + } + m, e := wzstore.BodyMap(doc) + if e != nil || playhubStr(m["userId"]) != uid { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "无权操作该订单"}) + return + } + st := strings.TrimSpace(playhubStr(m["status"])) + if st != "pending" && st != "pending_pay" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "订单状态不可支付"}) + return + } + totalYuan := playhubFloat(m["totalAmount"]) + if totalYuan <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "订单金额无效"}) + return + } + fen := wzYuanToFen(totalYuan) + if fen < 1 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "订单金额过小"}) + return + } + // 记在订单上,便于回调校验 + _ = wzstore.UpdateJSONPartial(wzstore.CollPointCardOrders, doc.ExtID, map[string]interface{}{ + "expectedPayFen": fen, + "payChannel": "wechat", + }) + title := strings.TrimSpace(playhubStr(m["skuName"])) + if title == "" { + title = strings.TrimSpace(playhubStr(m["game"])) + " 点卡" + } + desc := "点卡 · " + title + if len([]rune(desc)) > 42 { + desc = string([]rune(desc)[:40]) + "…" + } + attach := wzPayMakeAttach("pc", po, uid) + if ch == "jsapi" { + oid := strings.TrimSpace(body.OpenID) + if oid == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "jsapi 须传 openId"}) + return + } + prepayID, err := wechat.PayJSAPIOrderWithNotify(ctx, oid, po, fen, desc, attach, notifyURL) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("微信下单失败: %v", err)}) + return + } + payParams, err := wechat.GetJSAPIPayParams(prepayID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("生成支付参数失败: %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ + "orderNo": po, "channel": "jsapi", "prepayId": prepayID, "payParams": payParams, "notifyUrl": notifyURL, + }}) + return + } + h5, err := wechat.PayH5Order(ctx, po, fen, desc, attach, c.ClientIP(), siteURL, siteName, notifyURL) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("微信下单失败: %v", err)}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ + "orderNo": po, "channel": "h5", "h5Url": h5, "notifyUrl": notifyURL, + }}) + + default: + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "scene 须为 recharge 或 point_card"}) + } +} + +// WzAppPaymentWechatNotifyPOST POST /api/app/payment/wechat/notify — 微信 v3 支付结果(无 JWT) +func WzAppPaymentWechatNotifyPOST(c *gin.Context) { + resp, err := wechat.HandlePayNotify(c.Request, wzAppHandleWechatPaid) + if err != nil { + log.Printf("[WzAppPayNotify] %v", err) + c.Status(http.StatusInternalServerError) + return + } + if resp == nil || resp.Body == nil { + c.Status(http.StatusInternalServerError) + return + } + defer resp.Body.Close() + for k, v := range resp.Header { + if len(v) > 0 { + c.Header(k, v[0]) + } + } + c.Status(resp.StatusCode) + _, _ = io.Copy(c.Writer, resp.Body) +} + +func wzAppHandleWechatPaid(orderSn, transactionID string, totalFee int, attachStr, _ string) error { + var a wzPayAttach + if err := json.Unmarshal([]byte(strings.TrimSpace(attachStr)), &a); err != nil { + return fmt.Errorf("attach 无效") + } + k := strings.TrimSpace(a.K) + if k != "rc" && k != "pc" { + return fmt.Errorf("未知业务类型") + } + if strings.TrimSpace(a.N) != "" && strings.TrimSpace(a.N) != orderSn { + return fmt.Errorf("订单号与 attach 不一致") + } + switch k { + case "rc": + return wzFulfillRechargePaid(orderSn, transactionID, totalFee, strings.TrimSpace(a.U)) + case "pc": + return wzFulfillPointCardPaid(orderSn, transactionID, totalFee, strings.TrimSpace(a.U)) + default: + return fmt.Errorf("未知业务类型") + } +} + +func wzFulfillRechargePaid(orderSn, transactionID string, totalFee int, attachUID string) error { + doc, err := wzstore.FindOneByField(wzstore.CollRechargeOrders, "orderNo", orderSn) + if err != nil || doc == nil { + return fmt.Errorf("充值订单不存在") + } + m, e := wzstore.BodyMap(doc) + if e != nil { + return fmt.Errorf("订单数据无效") + } + st := strings.TrimSpace(playhubStr(m["status"])) + if st == "completed" { + return nil + } + if st != "pending_pay" { + return fmt.Errorf("充值订单状态不可履约: %s", st) + } + uid := strings.TrimSpace(playhubStr(m["userId"])) + if attachUID != "" && attachUID != uid { + return fmt.Errorf("用户与订单不匹配") + } + expFen := int(playhubFloat(m["expectedPayFen"])) + if expFen <= 0 { + expFen = wzYuanToFen(playhubFloat(m["payAmountYuan"])) + } + if expFen != totalFee { + return fmt.Errorf("实付金额与订单不一致") + } + coins := playhubFloat(m["amount"]) + if coins <= 0 { + return fmt.Errorf("到账币数无效") + } + if _, err := mongostore.AddAppUserBalanceDelta(uid, coins); err != nil { + return fmt.Errorf("入账失败: %w", err) + } + now := time.Now().UTC().Format(time.RFC3339) + return wzstore.UpdateJSONPartial(wzstore.CollRechargeOrders, doc.ExtID, map[string]interface{}{ + "status": "completed", + "paidAt": now, + "wxTransactionId": transactionID, + "wxPaidTotalFen": totalFee, + }) +} + +func wzFulfillPointCardPaid(orderSn, transactionID string, totalFee int, attachUID string) error { + doc, err := wzstore.FindOneByField(wzstore.CollPointCardOrders, "orderNo", orderSn) + if err != nil || doc == nil { + return fmt.Errorf("点卡订单不存在") + } + m, e := wzstore.BodyMap(doc) + if e != nil { + return fmt.Errorf("订单数据无效") + } + st := strings.TrimSpace(playhubStr(m["status"])) + if st == "completed" || st == "paid" { + return nil + } + if st != "pending" && st != "pending_pay" { + return fmt.Errorf("点卡订单状态不可履约: %s", st) + } + uid := strings.TrimSpace(playhubStr(m["userId"])) + if attachUID != "" && attachUID != uid { + return fmt.Errorf("用户与订单不匹配") + } + expFen := int(playhubFloat(m["expectedPayFen"])) + if expFen <= 0 { + expFen = wzYuanToFen(playhubFloat(m["totalAmount"])) + } + if expFen != totalFee { + return fmt.Errorf("实付金额与订单不一致") + } + cardID := strings.TrimSpace(playhubStr(m["cardId"])) + qty := playhubFloat(m["quantity"]) + if qty <= 0 { + qty = 1 + } + if cardID != "" { + cardDoc, cerr := wzstore.Get(wzstore.CollGamePointCards, cardID) + if cerr == nil && cardDoc != nil { + cm, _ := wzstore.BodyMap(cardDoc) + if cm != nil && cm["stock"] != nil { + stock := playhubFloat(cm["stock"]) + if stock > 0 { + nst := stock - qty + if nst < 0 { + nst = 0 + } + _ = wzstore.UpdateJSONPartial(wzstore.CollGamePointCards, cardID, map[string]interface{}{ + "stock": nst, + }) + } + } + } + } + now := time.Now().UTC().Format(time.RFC3339) + return wzstore.UpdateJSONPartial(wzstore.CollPointCardOrders, doc.ExtID, map[string]interface{}{ + "status": "completed", + "paidAt": now, + "wxTransactionId": transactionID, + "wxPaidTotalFen": totalFee, + }) +} + +// WzAppPaymentWechatOrderStatusGET GET /api/app/payment/wechat/order-status?no= +func WzAppPaymentWechatOrderStatusGET(c *gin.Context) { + uid := playhubAppUserID(c) + if uid == "" { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return + } + no := strings.TrimSpace(c.Query("no")) + if no == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 no"}) + return + } + docRC, errRC := wzstore.FindOneByField(wzstore.CollRechargeOrders, "orderNo", no) + docPC, errPC := wzstore.FindOneByField(wzstore.CollPointCardOrders, "orderNo", no) + var picked *model.WzDocument + var kind string + if errRC == nil && docRC != nil { + if m, e := wzstore.BodyMap(docRC); e == nil && playhubStr(m["userId"]) == uid { + picked = docRC + kind = "recharge" + } + } + if picked == nil && errPC == nil && docPC != nil { + if m, e := wzstore.BodyMap(docPC); e == nil && playhubStr(m["userId"]) == uid { + picked = docPC + kind = "point_card" + } + } + if picked == nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "订单不存在"}) + return + } + m, _ := wzstore.BodyMap(picked) + rawSt := strings.TrimSpace(playhubStr(m["status"])) + outSt := normalizeAppConsumerOrderStatus(rawSt) + bal := mongostore.GetAppUserBalance(uid) + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ + "orderNo": no, + "type": kind, + "status": outSt, + "rawStatus": rawSt, + "balance": bal, + }}) +} diff --git a/new/wz-api/internal/handler/playhub_wz_register.go b/new/wz-api/internal/handler/playhub_wz_register.go index dbde953..81c4178 100644 --- a/new/wz-api/internal/handler/playhub_wz_register.go +++ b/new/wz-api/internal/handler/playhub_wz_register.go @@ -81,6 +81,10 @@ func RegisterPlayhubAppPlayhubRoutes(app *gin.RouterGroup) { app.POST("/wallet/recharge", WzAppWalletRechargePOST) app.POST("/wallet/consume", WzAppWalletConsumePOST) + app.POST("/payment/wechat/prepay", WzAppPaymentWechatPrepayPOST) + app.POST("/payment/wechat/notify", WzAppPaymentWechatNotifyPOST) + app.GET("/payment/wechat/order-status", WzAppPaymentWechatOrderStatusGET) + app.GET("/streamers", WzAppStreamersList) app.GET("/streamers/:id", WzAppStreamerByID) app.GET("/games", WzAppGamesList) diff --git a/new/wz-api/internal/wechat/miniprogram.go b/new/wz-api/internal/wechat/miniprogram.go index 96be5c6..7584b91 100644 --- a/new/wz-api/internal/wechat/miniprogram.go +++ b/new/wz-api/internal/wechat/miniprogram.go @@ -276,16 +276,36 @@ func GetPayNotifyURL() string { return "https://wzapi.quwanzhi.com/api/miniprogram/pay/notify" } +// GetWzAppPayNotifyURL 玩值 C 端(/api/app)支付结果通知地址,须在商户平台或与统一下单 NotifyUrl 一致 +func GetWzAppPayNotifyURL() string { + if cfg != nil && strings.TrimSpace(cfg.WechatWzAppNotifyURL) != "" { + return strings.TrimSpace(cfg.WechatWzAppNotifyURL) + } + if cfg != nil && cfg.BaseURL != "" { + return cfg.BaseURLJoin("/api/app/payment/wechat/notify") + } + return "https://wzapi.quwanzhi.com/api/app/payment/wechat/notify" +} + // PayJSAPIOrder 微信支付 v3 小程序 JSAPI 统一下单,返回 prepay_id func PayJSAPIOrder(ctx context.Context, openID, orderSn string, amountCents int, description, attach string) (prepayID string, err error) { + return PayJSAPIOrderWithNotify(ctx, openID, orderSn, amountCents, description, attach, "") +} + +// PayJSAPIOrderWithNotify 同 JSAPI 下单,notifyURL 非空时覆盖默认回调(用于玩值 App 独立通知地址) +func PayJSAPIOrderWithNotify(ctx context.Context, openID, orderSn string, amountCents int, description, attach, notifyURL string) (prepayID string, err error) { if paymentApp == nil { return "", fmt.Errorf("支付未初始化") } + nu := strings.TrimSpace(notifyURL) + if nu == "" { + nu = GetPayNotifyURL() + } req := &request.RequestJSAPIPrepay{ PrepayBase: request.PrepayBase{ AppID: cfg.WechatAppID, MchID: cfg.WechatMchID, - NotifyUrl: GetPayNotifyURL(), + NotifyUrl: nu, }, Description: description, OutTradeNo: orderSn, @@ -322,6 +342,96 @@ func PayJSAPIOrder(ctx context.Context, openID, orderSn string, amountCents int, return res.PrepayID, nil } +// PayH5Order 微信 H5 支付(移动端浏览器唤起微信收银台),返回 mweb_url +func PayH5Order(ctx context.Context, orderSn string, amountCents int, description, attach, payerIP, siteURL, siteName string, notifyURL string) (h5URL string, err error) { + if paymentApp == nil { + return "", fmt.Errorf("支付未初始化") + } + ip := strings.TrimSpace(payerIP) + if ip == "" { + ip = "127.0.0.1" + } + su := strings.TrimSpace(siteURL) + if su == "" && cfg != nil { + su = strings.TrimSuffix(cfg.WzAppPublicURL, "/") + } + if su == "" { + su = "https://wzdj.quwanzhi.com" + } + sn := strings.TrimSpace(siteName) + if sn == "" { + sn = "玩值" + } + nu := strings.TrimSpace(notifyURL) + if nu == "" { + nu = GetWzAppPayNotifyURL() + } + req := &request.RequestH5Prepay{ + PrepayBase: request.PrepayBase{ + AppID: cfg.WechatAppID, + MchID: cfg.WechatMchID, + NotifyUrl: nu, + }, + Description: description, + OutTradeNo: orderSn, + Attach: attach, + Amount: &request.H5Amount{ + Total: amountCents, + Currency: "CNY", + }, + SceneInfo: &request.H5SceneInfo{ + PayerClientIP: ip, + H5Info: &request.H5H5Info{ + Type: "Wap", + AppName: sn, + AppUrl: su, + }, + }, + } + res, err := paymentApp.Order.TransactionH5(ctx, req) + if err != nil { + return "", err + } + if res == nil { + return "", fmt.Errorf("微信无返回体(H5),请稍后重试") + } + code := strings.TrimSpace(res.Code) + msg := strings.TrimSpace(res.Message) + if code != "" || msg != "" { + hint := "常见:未开通「H5 支付」或未登记域名、APPID 与商户号不匹配;请在微信支付商户平台产品中心查看。" + if msg != "" { + return "", fmt.Errorf("微信 H5 被拒: %s%s(code=%s)——%s", msg, suffixIfNoPeriodCN(msg), code, hint) + } + return "", fmt.Errorf("微信 H5 被拒(code=%s)——%s", code, hint) + } + v2Desc := strings.TrimSpace(res.ErrCodeDes) + if v2Desc == "" { + v2Desc = strings.TrimSpace(res.ErrMsg) + } + if strings.TrimSpace(res.ErrCode) != "" || v2Desc != "" { + return "", fmt.Errorf("微信 H5 下单失败: err_code=%s %s", + strings.TrimSpace(res.ErrCode), strings.TrimSpace(v2Desc)) + } + if strings.TrimSpace(res.H5URL) == "" { + log.Printf("[WechatPay] H5 应答无 h5_url 且无 code,完整 %+v", res) + return "", fmt.Errorf("微信返回 H5 链接为空且无错误说明;请在商户平台确认已开通「H5 支付」并完成域名报备") + } + return res.H5URL, nil +} + +func suffixIfNoPeriodCN(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + runes := []rune(s) + last := runes[len(runes)-1] + if last == '。' || last == '.' || last == '!' || last == '?' { + return "" + } + return "。" +} + // GetJSAPIPayParams 根据 prepay_id 生成小程序 wx.requestPayment 所需参数(v3 签名) func GetJSAPIPayParams(prepayID string) (map[string]string, error) { if paymentApp == nil { diff --git a/new/wz-api/master.py b/new/wz-api/master.py index b641c2b..c43ee90 100644 --- a/new/wz-api/master.py +++ b/new/wz-api/master.py @@ -16,6 +16,7 @@ from __future__ import print_function Mongo 数据上云(本地 → 宝塔 wzdj,推荐走面板 API 上传+服务端 mongorestore)见 scripts/mongo_push_baota_api.py; 亦可用手动 mongorestore 见 scripts/MONGO_宝塔同步.md 与 scripts/mongo_sync_baota.py(非本脚本职责)。 +Go 项目插件重启失败(如「指定参数无效」)时,会自动尝试宝塔 ExecShell 在项目目录内重启 wz-api;若不希望执行远端 Shell,可设 BT_NO_EXECSHELL_RESTART=1。 依赖: pip install requests """ @@ -300,6 +301,68 @@ def restart_via_bt_api(cfg): return False +def bt_exec_shell(cfg, shell, cwd_path): + """在服务器本机执行 Shell(需面板 API 白名单且允许 ExecShell)。""" + url = (cfg["bt_panel_url"] or "").rstrip("/") + key = cfg["bt_api_key"] + j = _bt_parse_json(_bt_signed_post(url, key, "/files?action=ExecShell", {"path": cwd_path, "shell": shell}, timeout=60)) + if not isinstance(j, dict) or j.get("status") is not True: + print(" [重启/ExecShell] 未接受: %s" % (j,)) + return False + return True + + +def bt_get_exec_shell_msg(cfg): + url = (cfg["bt_panel_url"] or "").rstrip("/") + key = cfg["bt_api_key"] + return _bt_parse_json(_bt_signed_post(url, key, "/files?action=GetExecShellMsg", {}, timeout=120)) + + +def wait_exec_shell(cfg, timeout_sec=120, poll=2): + deadline = time.time() + timeout_sec + last_msg = None + while time.time() < deadline: + j = bt_get_exec_shell_msg(cfg) + last_msg = j + if isinstance(j, dict) and j.get("status") is True: + tx = str(j.get("msg") or "") + if tx: + tail = tx[-3500:] if len(tx) > 3500 else tx + print(" [重启/ExecShell] 输出摘录: %s" % tail.replace("\n", " ").strip()) + return True + time.sleep(poll) + print(" [重启/ExecShell] 等待结束超时,最后返回: %s" % (last_msg,)) + return False + + +def restart_via_bt_exec_shell(cfg): + """Go 项目插件名不一致时的兜底:在项目目录内结束旧 wz-api 再起进程(与 docs/master SSH 逻辑一致)。""" + project_path = cfg["project_path"].rstrip("/") + if (os.environ.get("BT_NO_EXECSHELL_RESTART") or "").strip().lower() in ("1", "true", "yes"): + print(" [重启/ExecShell] 已通过 BT_NO_EXECSHELL_RESTART 跳过") + return False + shell = ( + "T=$(readlink -f .); " + 'for p in $(pgrep -f wz-api 2>/dev/null); do ' + '[ "$(readlink -f /proc/$p/cwd 2>/dev/null)" = "$T" ] && kill "$p" 2>/dev/null || true; ' + "done; " + "sleep 2; chmod +x ./wz-api; " + "setsid nohup ./wz-api >>wz-api.log 2>&1 /dev/null); do ' + '[ "$(readlink -f /proc/$p/cwd 2>/dev/null)" = "$T" ] && echo WZ_API_RESTART_OK && exit 0; ' + "done; " + "echo WZ_API_RESTART_FAIL; exit 1" + ) + print(" [重启/ExecShell] 尝试在项目目录拉起 wz-api ...") + if not bt_exec_shell(cfg, shell, project_path): + return False + ok = wait_exec_shell(cfg, timeout_sec=150, poll=2) + if ok: + print(" [成功] 已通过 ExecShell 重启 wz-api(请用 /health 自检)") + return ok + + def upload_and_extract(cfg, tarball_path, no_restart=False): print("[3/4] 上传压缩包到宝塔 ...") project_path = cfg["project_path"].rstrip("/") @@ -314,9 +377,13 @@ def upload_and_extract(cfg, tarball_path, no_restart=False): return False bt_delete_file(cfg, remote_tar) if not no_restart: - if not restart_via_bt_api(cfg): - print("[失败] 重启失败(文件已上传并解压,可在面板手动启动)") - return False + if restart_via_bt_api(cfg): + pass + else: + print(" [提示] Go 项目插件重启失败,改用 ExecShell 在项目目录拉起进程 ...") + if not restart_via_bt_exec_shell(cfg): + print("[失败] 重启失败(文件已上传并解压,请在面板手动启动或配置 BT_GO_PROJECT_NAME)") + return False return True diff --git a/new/wz-app/MIGRATION.md b/new/wz-app/MIGRATION.md index 3350c72..6eef9c1 100644 --- a/new/wz-app/MIGRATION.md +++ b/new/wz-app/MIGRATION.md @@ -8,9 +8,9 @@ |------|-------------|------------|------| | `/` | `old/app/page.tsx` | `GET /api/app/streamers`、`GET /api/app/user/me` | 已接入 | | `/login` | `old/app/login/page.tsx` | `POST /api/app/auth/*`、`/user/me` | 已接 Bearer | -| `/moments` | `old/app/moments/page.tsx` | 见 hooks / 演示数据 | 已迁入 | +| `/moments` | `old/app/moments/page.tsx` | **`GET /api/app/streamers`** 映射推荐卡(无独立 Feed 时) | 已接入 | | `/planet` | `old/app/planet/page.tsx` | 本地 | 已迁入 | -| `/mall` | `old/app/mall/page.tsx` | 商城相关 API | 已迁入 | +| `/mall` | `old/app/mall/page.tsx` | **`GET /api/app/products`**(装扮/礼物/福利分桶) | 已接入 | | `/profile` | `old/app/profile/page.tsx` | 同上 | 已迁入 | | `/orders`、`/messages`、`/settings`、`/profile/edit` | 对应 `old/app/*` | 见各页 | 已迁入 | | 其余 `App.tsx` 独立堆叠路由 | 见下表与 `App.tsx` | 见 [`迁移-API对照表`](../../开发文档/玩值/迁移-API对照表.md) | **已迁**;大屏/主播域等已接 wz-api 只读或 POST;**TODO: API** 见各页内注释或对照表 | @@ -19,7 +19,7 @@ | 路由 | 组件 | |------|------| -| `/cp`、`/cp/:id` | `cp/CpListPage`、`cp/CpDetailPage` | +| `/cp`、`/cp/:id` | `cp/CpListPage`、`cp/CpDetailPage`(**`GET /api/app/stars`**) | | `/party/:id` | `PartyDetailPage` | | `/course/:id` | `CourseDetailPage`(本地 `courseData` + `pay`) | | `/guild`、`/guild/:id` | `guild/GuildListPage`、`guild/GuildDetailPage` | diff --git a/new/wz-app/dist/index.html b/new/wz-app/dist/index.html index 9dfae1c..37b9bfa 100644 --- a/new/wz-app/dist/index.html +++ b/new/wz-app/dist/index.html @@ -11,8 +11,8 @@ rel="stylesheet" /> 玩值电竞 - WanZhi Esports - - + +
diff --git a/new/wz-app/src/api/payment-wechat.ts b/new/wz-app/src/api/payment-wechat.ts new file mode 100644 index 0000000..2952fc8 --- /dev/null +++ b/new/wz-app/src/api/payment-wechat.ts @@ -0,0 +1,47 @@ +/** + * 玩值 App 微信支付:统一下单、订单状态查询(见 new/wz-api playhub_wz_app_payment.go) + */ +import { request } from "@/lib/request" + +export type WechatPrepayData = { + orderNo: string + channel: "h5" | "jsapi" + h5Url?: string + prepayId?: string + payParams?: Record + notifyUrl?: string +} + +export type WechatPaymentOrderStatus = { + orderNo: string + type: string + status: string + rawStatus: string + balance: number +} + +export async function appPaymentWechatPrepay(body: { + channel: "h5" | "jsapi" + scene: "recharge" | "point_card" + rmbYuan?: number + openId?: string + pointCardOrderNo?: string +}) { + const r = await request("/api/app/payment/wechat/prepay", { + method: "POST", + body: JSON.stringify(body), + }) + if (!r.success || !r.data || typeof r.data !== "object") { + throw new Error(typeof r.message === "string" ? r.message : "预下单失败") + } + return r.data as WechatPrepayData +} + +export async function appPaymentWechatOrderStatus(orderNo: string): Promise { + const r = await request( + `/api/app/payment/wechat/order-status?no=${encodeURIComponent(orderNo)}`, + { method: "GET" }, + ) + if (!r.success || !r.data) return null + return r.data as WechatPaymentOrderStatus +} diff --git a/new/wz-app/src/lib/mall-product-buckets.ts b/new/wz-app/src/lib/mall-product-buckets.ts new file mode 100644 index 0000000..75256a9 --- /dev/null +++ b/new/wz-app/src/lib/mall-product-buckets.ts @@ -0,0 +1,37 @@ +import type { AppProductDTO } from "@/api/catalog" + +function pack(p: AppProductDTO): string { + return `${p.category ?? ""} ${p.name ?? ""} ${p.game ?? ""}` +} + +function isDressBucket(s: string) { + return /装扮|边框|头像|气泡|入场|挂件|铭牌|称号|皮肤框|特效框|decorate|frame|avatar|bubble|entrance|dress|主题/i.test(s) +} + +function isGiftBucket(s: string) { + return /礼物|送礼|道具|火箭|跑车|爱心|玫瑰|气球|棒棒糖|城堡|gift|prop|小心心/i.test(s) +} + +function isExchangeBucket(s: string) { + return /兑换|福利|话费|券|宝箱|改名|经验|coupon|reward|皮肤宝箱/i.test(s) +} + +/** 将商品目录拆到商城三个 Tab;未匹配装扮类的归入「装扮」Tab 作为兜底展示,避免空白。 */ +export function bucketMallProducts(rows: AppProductDTO[]) { + const dress: AppProductDTO[] = [] + const gift: AppProductDTO[] = [] + const exchange: AppProductDTO[] = [] + const rest: AppProductDTO[] = [] + for (const p of rows) { + const s = pack(p) + if (isDressBucket(s)) dress.push(p) + else if (isGiftBucket(s)) gift.push(p) + else if (isExchangeBucket(s)) exchange.push(p) + else rest.push(p) + } + if (dress.length === 0 && rest.length > 0) { + dress.push(...rest) + rest.length = 0 + } + return { dress, gift, exchange } +} diff --git a/new/wz-app/src/lib/weixin-bridge-pay.ts b/new/wz-app/src/lib/weixin-bridge-pay.ts new file mode 100644 index 0000000..4afe324 --- /dev/null +++ b/new/wz-app/src/lib/weixin-bridge-pay.ts @@ -0,0 +1,78 @@ +/** + * 微信内置浏览器拉起 JSAPI(getBrandWCPayRequest)。依赖后端 Pay v3 BridgeConfig。 + */ +declare global { + interface Window { + WeixinJSBridge?: { + invoke: ( + name: string, + args: Record, + cb: (res: { err_msg?: string }) => void, + ) => void + } + } +} + +function bridgeReady(cb: () => void) { + if (typeof window === "undefined") return + const w = window as Window & { WeixinJSBridge?: Window["WeixinJSBridge"] } + if (w.WeixinJSBridge) { + cb() + return + } + document.addEventListener("WeixinJSBridgeReady", cb, false) +} + +export async function invokeWeixinJSBridgePayment(payParams: Record): Promise<"ok" | "cancel" | "fail"> { + const appId = payParams.appId ?? payParams.appid ?? "" + const timeStamp = payParams.timeStamp ?? payParams.timestamp ?? "" + const nonceStr = payParams.nonceStr ?? payParams.noncestr ?? "" + const pkg = payParams.package ?? "" + const signType = payParams.signType ?? "RSA" + const paySign = payParams.paySign ?? "" + if (!appId || !timeStamp || !nonceStr || !pkg || !paySign) { + throw new Error("微信支付参数不完整") + } + return new Promise((resolve, reject) => { + let settled = false + const timer = window.setTimeout(() => { + if (settled) return + settled = true + reject(new Error("等待 WeixinJSBridge 超时,请在微信内打开或检查网络")) + }, 12000) + const done = (fn: () => void) => { + if (settled) return + settled = true + window.clearTimeout(timer) + fn() + } + bridgeReady(() => { + const bridge = window.WeixinJSBridge + if (!bridge) { + done(() => reject(new Error("当前环境不支持 WeixinJSBridge"))) + return + } + bridge.invoke( + "getBrandWCPayRequest", + { + appId, + timeStamp, + nonceStr, + package: pkg, + signType, + paySign, + }, + (res) => { + const msg = (res.err_msg ?? "").trim() + if (msg.endsWith(":ok")) { + done(() => resolve("ok")) + } else if (msg.endsWith(":cancel") || msg.includes("cancel")) { + done(() => resolve("cancel")) + } else { + done(() => reject(new Error(msg || "支付失败"))) + } + }, + ) + }) + }) +} diff --git a/new/wz-app/src/pages/CoachListPage.tsx b/new/wz-app/src/pages/CoachListPage.tsx index 9a4d581..0b34e02 100644 --- a/new/wz-app/src/pages/CoachListPage.tsx +++ b/new/wz-app/src/pages/CoachListPage.tsx @@ -165,7 +165,7 @@ export function CoachListPage() { ) : list.length === 0 ? ( -

暂无大神数据 · 请先导入 stars

+

暂时没有可展示的大神,请稍后再来

) : ( diff --git a/new/wz-app/src/pages/DressUpPage.tsx b/new/wz-app/src/pages/DressUpPage.tsx index 97fbc76..a1a1e07 100644 --- a/new/wz-app/src/pages/DressUpPage.tsx +++ b/new/wz-app/src/pages/DressUpPage.tsx @@ -1,139 +1,184 @@ -import { ArrowLeft, TrendingUp, ShoppingCart, Sparkles } from "lucide-react" +import { ArrowLeft, TrendingUp, ShoppingCart, Sparkles, Loader2 } from "lucide-react" import { useNavigate } from "react-router-dom" +import { useCallback, useEffect, useMemo, useState } from "react" import { Button } from "@/components/ui/button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { toast } from "@/hooks/use-toast" import { FillImage } from "@/components/media/FillImage" +import { appProducts, type AppProductDTO } from "@/api/catalog" +import { bucketMallProducts } from "@/lib/mall-product-buckets" +import { useAppContext } from "@/components/providers/app-provider" -const avatars = [ - { id: 1, name: "赛博朋克战士", price: 98, rarity: "传说", sales: 1205, image: "/avatar-cyberpunk.jpg" }, - { id: 2, name: "甜心电竞少女", price: 68, rarity: "稀有", sales: 3420, image: "/avatar-cute.jpg" }, - { id: 3, name: "暗影刺客", price: 128, rarity: "传说", sales: 856, image: "/avatar-assassin.jpg" }, - { id: 4, name: "机甲战神", price: 88, rarity: "史诗", sales: 2100, image: "/avatar-mecha.jpg" }, -] +function productImg(p: AppProductDTO): string { + const u = (p.coverImage && p.coverImage.trim()) || (p.image && p.image.trim()) || "" + return u || "/placeholder.svg" +} -const decorations = [ - { id: 101, name: "星空边框", price: 38, type: "边框", image: "/frame-starry.jpg" }, - { id: 102, name: "炫光特效", price: 58, type: "特效", image: "/effect-neon.jpg" }, - { id: 103, name: "王者徽章", price: 48, type: "徽章", image: "/badge-gold.jpg" }, -] +function isAvatarish(p: AppProductDTO): boolean { + const s = `${p.category ?? ""} ${p.name ?? ""}` + return /头像|avatar|形象/i.test(s) +} export function DressUpPage() { const navigate = useNavigate() + const { user, pay } = useAppContext() + const [loading, setLoading] = useState(true) + const [dress, setDress] = useState([]) + + const load = useCallback(() => { + setLoading(true) + void appProducts() + .then((rows) => setDress(bucketMallProducts(rows).dress)) + .catch(() => setDress([])) + .finally(() => setLoading(false)) + }, []) + useEffect(() => { + load() + }, [load]) + + const { avatars, decorations } = useMemo(() => { + const a = dress.filter(isAvatarish) + const d = dress.filter((p) => !isAvatarish(p)) + return { avatars: a, decorations: d } + }, [dress]) + + const currentPreview = (typeof user.avatar === "string" && user.avatar) || productImg(avatars[0] ?? {}) || "/placeholder.svg" + + const handleBuy = (p: AppProductDTO) => { + const price = Math.max(0, Math.round(Number(p.price) || 0)) + const img = productImg(p) + void pay(price, String(p.name ?? "装扮"), "product", img).then((ok) => { + if (ok) toast({ title: "已下单", description: `已用玩值币兑换 ${p.name ?? ""}` }) + }) + } return (
-

我的装扮

- 数字头像商城 + 上架数据与商城同步
-
- -
- LV.8 -
+
+ +
展示

当前形象

-

展示您的独特个性

-
- - - - 数字头像 - - - 装饰道具 - - - 我的藏品 - - + {loading ? ( +
+ +
+ ) : ( + + + + 数字头像 + + + 装饰道具 + + + 我的藏品 + + - -
-

热门头像

- -
-
- {avatars.map((avatar) => ( -
- toast({ - title: "购买成功", - description: `已获得 ${avatar.name},消费 ¥${avatar.price}`, - }) - } - onKeyDown={(e) => e.key === "Enter" && e.currentTarget.click()} - role="button" - tabIndex={0} - > -
- -
- {avatar.rarity} -
-
-

{avatar.name}

-
- {avatar.sales}人购买 - ¥{avatar.price} -
- + +
+

头像类商品

+ +
+ {avatars.length === 0 ? ( +

暂无头像类上架,可先逛商城装扮区

+ ) : ( +
+ {avatars.map((avatar) => { + const img = productImg(avatar) + const price = Math.max(0, Math.round(Number(avatar.price) || 0)) + const cat = (avatar.category && avatar.category.trim()) || "上架" + return ( +
handleBuy(avatar)} + onKeyDown={(e) => e.key === "Enter" && handleBuy(avatar)} + role="button" + tabIndex={0} + > +
+ +
+ {cat} +
+
+

{avatar.name ?? "商品"}

+
+ {cat} + {price} 币 +
+ +
+ ) + })}
- ))} -
- + )} + - -
- {decorations.map((item) => ( -
-
- -
-
-

{item.name}

-
- {item.type} - ¥{item.price} -
-
- + + {decorations.length === 0 ? ( +

暂无装饰类上架

+ ) : ( +
+ {decorations.map((item) => { + const img = productImg(item) + const price = Math.max(0, Math.round(Number(item.price) || 0)) + const typ = (item.category && item.category.trim()) || "道具" + return ( +
+
+ +
+
+

{item.name ?? "商品"}

+
+ {typ} + {price} 币 +
+
+ +
+ ) + })}
- ))} -
- + )} + - - -

暂无藏品

-

购买数字头像和装扮后会显示在这里

-
- + + +

「我的藏品」待个人装扮资产接口就绪后对接

+

兑换记录可在订单相关页查看(若有)

+
+ + )}
) diff --git a/new/wz-app/src/pages/MallPage.tsx b/new/wz-app/src/pages/MallPage.tsx index b7cf09c..ae2352c 100644 --- a/new/wz-app/src/pages/MallPage.tsx +++ b/new/wz-app/src/pages/MallPage.tsx @@ -1,10 +1,6 @@ -import { useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { - ShoppingBag, - Crown, Sparkles, - Zap, - Gift, Coins, Hotel, Coffee, @@ -14,57 +10,122 @@ import { Wallet, Gamepad2, CreditCard, + Loader2, } from "lucide-react" import { Button } from "@/components/ui/button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { useAppContext } from "@/components/providers/app-provider" import { Link, useNavigate } from "react-router-dom" +import { FillImage } from "@/components/media/FillImage" +import { appProducts, type AppProductDTO } from "@/api/catalog" +import { bucketMallProducts } from "@/lib/mall-product-buckets" -/** 头像装扮子分类:热门推荐 | 限定头像 | 动态边框 | 入场特效 | 聊天气泡 */ -type DressCategory = "热门推荐" | "限定头像" | "动态边框" | "入场特效" | "聊天气泡" +function productImg(p: AppProductDTO): string { + const u = (p.coverImage && p.coverImage.trim()) || (p.image && p.image.trim()) || "" + return u || "/placeholder.svg" +} -const DRESS_CATEGORIES: DressCategory[] = ["热门推荐", "限定头像", "动态边框", "入场特效", "聊天气泡"] - -/** 数字产品(头像/边框/特效/气泡)商品列表,带分类与图片路径 */ -const DRESS_ITEMS: Array<{ - name: string - price: number - category: DressCategory - color: string - image: string -}> = [ - { name: "巫瞳猫影", price: 99, category: "动态边框", color: "from-purple-500/20 to-blue-500/20", image: "/dress/witch-cat.svg" }, - { name: "椰树风情", price: 128, category: "动态边框", color: "from-green-500/20 to-yellow-500/20", image: "/dress/tropical.svg" }, - { name: "黑白天使", price: 299, category: "动态边框", color: "from-gray-500/20 to-white/20", image: "/dress/angel.svg" }, - { name: "敦煌飞天", price: 520, category: "动态边框", color: "from-orange-500/20 to-red-500/20", image: "/dress/dunhuang.svg" }, - { name: "恶魔之眼", price: 199, category: "动态边框", color: "from-red-900/20 to-black/20", image: "/dress/demon.svg" }, - { name: "小熊猫", price: 68, category: "动态边框", color: "from-orange-300/20 to-yellow-300/20", image: "/dress/panda.svg" }, - { name: "新年头像", price: 188, category: "限定头像", color: "from-red-600/20 to-amber-500/20", image: "/dress/newyear.svg" }, - { name: "赛博限定", price: 258, category: "限定头像", color: "from-cyan-500/20 to-violet-500/20", image: "/dress/limited-cyber.svg" }, - { name: "动态边框", price: 88, category: "动态边框", color: "from-violet-600/20 to-blue-600/20", image: "/dress/frame-dynamic.svg" }, - { name: "入场特效", price: 128, category: "入场特效", color: "from-slate-900/20 to-indigo-600/20", image: "/dress/entrance-effect.svg" }, - { name: "星光入场", price: 168, category: "入场特效", color: "from-slate-800/20 to-purple-600/20", image: "/dress/entrance-star.svg" }, - { name: "聊天气泡", price: 58, category: "聊天气泡", color: "from-teal-600/20 to-cyan-600/20", image: "/dress/chat-bubble.svg" }, - { name: "可爱气泡", price: 48, category: "聊天气泡", color: "from-pink-500/20 to-rose-400/20", image: "/dress/bubble-cute.svg" }, -] +function dressSubLabel(p: AppProductDTO): string { + const raw = (p.category && p.category.trim()) || "" + if (/头像|avatar/i.test(raw)) return "限定头像" + if (/边框|frame/i.test(raw)) return "动态边框" + if (/入场|entrance/i.test(raw)) return "入场特效" + if (/气泡|bubble|聊天气泡/i.test(raw)) return "聊天气泡" + return raw || "热门推荐" +} export function MallPage() { const { wallet, pay } = useAppContext() const navigate = useNavigate() - const [dressCategory, setDressCategory] = useState("热门推荐") + const [dressCategory, setDressCategory] = useState("热门推荐") + const [loading, setLoading] = useState(true) + const [dress, setDress] = useState([]) + const [gift, setGift] = useState([]) + const [exchange, setExchange] = useState([]) + + const load = useCallback(() => { + setLoading(true) + void appProducts() + .then((rows) => { + const { dress: d, gift: g, exchange: x } = bucketMallProducts(rows) + setDress(d) + setGift(g) + setExchange(x) + }) + .catch(() => { + setDress([]) + setGift([]) + setExchange([]) + }) + .finally(() => setLoading(false)) + }, []) + useEffect(() => { + load() + }, [load]) + + const dressChipLabels = useMemo(() => { + const base = ["热门推荐", "限定头像", "动态边框", "入场特效", "聊天气泡"] + const fromApi = [...new Set(dress.map(dressSubLabel))].filter((c) => c && c !== "热门推荐") + const merged = ["热门推荐"] + for (const b of base.slice(1)) { + if (!merged.includes(b)) merged.push(b) + } + for (const c of fromApi) { + if (!merged.includes(c)) merged.push(c) + } + return merged + }, [dress]) + + const filteredDress = + dressCategory === "热门推荐" ? dress : dress.filter((p) => dressSubLabel(p) === dressCategory) const handleBuy = (price: number, name: string, type: "product" | "service", img?: string) => { void pay(price, name, type, img) } - const filteredDressItems = - dressCategory === "热门推荐" - ? DRESS_ITEMS.slice(0, 8) - : DRESS_ITEMS.filter((item) => item.category === dressCategory) + const renderProductGrid = (list: AppProductDTO[], empty: string, btn: string) => { + if (loading) { + return ( +
+ +
+ ) + } + if (list.length === 0) { + return
{empty}
+ } + return list.map((item) => { + const img = productImg(item) + const price = Math.max(0, Math.round(Number(item.price) || 0)) + return ( +
+
+ +
+
+

{item.name ?? "商品"}

+
+ + {price} 币 +
+
+ +
+ ) + }) + } return (
- {/* Header */}

商城中心

- {/* Top Categories - 8 Items */}

@@ -85,44 +145,14 @@ export function MallPage() {

{[ - { - name: "明星陪玩", - icon: Gamepad2, - color: "text-blue-400", - bg: "bg-blue-500/10", - href: "/mall/companion?type=play", - }, - { - name: "游戏点卡", - icon: CreditCard, - color: "text-pink-400", - bg: "bg-pink-500/10", - href: "/mall/points", - }, + { name: "明星陪玩", icon: Gamepad2, color: "text-blue-400", bg: "bg-blue-500/10", href: "/mall/companion?type=play" }, + { name: "游戏点卡", icon: CreditCard, color: "text-pink-400", bg: "bg-pink-500/10", href: "/mall/points" }, { name: "电竞外设", icon: Keyboard, color: "text-purple-400", bg: "bg-purple-500/10", href: "/mall/gear" }, { name: "电竞酒店", icon: Hotel, color: "text-orange-400", bg: "bg-orange-500/10", href: "/hotel" }, { name: "附近网咖", icon: Coffee, color: "text-cyan-400", bg: "bg-cyan-500/10", href: "/hotel?tab=cafe" }, - { - name: "游戏代练", - icon: Trophy, - color: "text-yellow-400", - bg: "bg-yellow-500/10", - href: "/services/leveling", - }, - { - name: "账号交易", - icon: User, - color: "text-green-400", - bg: "bg-green-500/10", - href: "/services/account?tab=buy", - }, - { - name: "账号典当", - icon: Wallet, - color: "text-amber-400", - bg: "bg-amber-500/10", - href: "/services/account?tab=pawn", - }, + { name: "游戏代练", icon: Trophy, color: "text-yellow-400", bg: "bg-yellow-500/10", href: "/services/leveling" }, + { name: "账号交易", icon: User, color: "text-green-400", bg: "bg-green-500/10", href: "/services/account?tab=buy" }, + { name: "账号典当", icon: Wallet, color: "text-amber-400", bg: "bg-amber-500/10", href: "/services/account?tab=pawn" }, ].map((item, i) => (
- {DRESS_CATEGORIES.map((cat) => ( + {dressChipLabels.map((cat) => (
-
- {filteredDressItems.length === 0 ? ( -
- 该分类暂无商品,敬请期待 -
- ) : ( - filteredDressItems.map((item, i) => ( -
-
- -
- {item.name} -
- -
-

{item.name}

-
- - {item.price} 币 -
-
- - -
- )) - )} -
+
{renderProductGrid(filteredDress, "该分类暂时没有上架装扮,可先看看课程或点卡馆", "购买")}
- {[ - { name: "棒棒糖", price: 10, icon: "🍭" }, - { name: "告白气球", price: 520, icon: "🎈" }, - { name: "跑车", price: 5000, icon: "🏎️" }, - { name: "火箭", price: 10000, icon: "🚀" }, - { name: "城堡", price: 50000, icon: "🏰" }, - { name: "爱心", price: 1, icon: "❤️" }, - ].map((item, i) => ( -
handleBuy(item.price, item.name, "product")} - > -
{item.icon}
-
-

{item.name}

-
- - {item.price} 币 -
-
+ {loading ? ( +
+
- ))} + ) : gift.length === 0 ? ( +
暂无礼物道具上架,敬请期待
+ ) : ( + gift.map((item) => { + const img = productImg(item) + const price = Math.max(0, Math.round(Number(item.price) || 0)) + return ( + + ) + }) + )}
- {[ - { name: "10元话费券", price: 1000, icon: Gift }, - { name: "随机皮肤宝箱", price: 5000, icon: ShoppingBag }, - { name: "改名卡", price: 2000, icon: Crown }, - { name: "双倍经验卡", price: 500, icon: Zap }, - ].map((item, i) => ( -
-
- -
-
-

{item.name}

-
- - {item.price} 币 -
-
- + {loading ? ( +
+
- ))} + ) : exchange.length === 0 ? ( +
暂无福利兑换项,可关注活动公告
+ ) : ( + exchange.map((item) => { + const img = productImg(item) + const price = Math.max(0, Math.round(Number(item.price) || 0)) + return ( +
+
+ +
+
+

{item.name ?? "兑换项"}

+
+ + {price} 币 +
+
+ +
+ ) + }) + )}
diff --git a/new/wz-app/src/pages/MomentsDetailPage.tsx b/new/wz-app/src/pages/MomentsDetailPage.tsx index d852679..54e0aab 100644 --- a/new/wz-app/src/pages/MomentsDetailPage.tsx +++ b/new/wz-app/src/pages/MomentsDetailPage.tsx @@ -1,57 +1,60 @@ import { Link, useNavigate, useParams } from "react-router-dom" -import { Heart, MessageCircle, Share2, Gift, ArrowLeft, Send, BookOpen } from "lucide-react" +import { Heart, MessageCircle, Share2, Gift, ArrowLeft, Send, BookOpen, Loader2 } from "lucide-react" import { FillImage } from "@/components/media/FillImage" import { toast } from "@/hooks/use-toast" - -type MomentEntry = { - id: string - user: string - avatar: string - desc: string - video: string - likes: string - comments: number - hasCourse: boolean - courseId: string - courseTitle: string -} - -/** 与路由 `moments/:momentId` 对齐;接 wz-api 后可改为请求 `momentId` */ -const MOMENTS_BY_ID: Record = { - "1": { - id: "1", - user: "GM-远洋", - avatar: "/gamer-girl-headphones.jpg", - desc: "可惜不是你喜欢的甜妹音 😷 #电竞少女 #英雄联盟 #日常", - video: "/esports-gamer-girl-streaming-vertical.jpg", - likes: "1.2w", - comments: 458, - hasCourse: true, - courseId: "1", - courseTitle: "打野进阶课:从入门到王者", - }, - "2": { - id: "2", - user: "卡若", - avatar: "/streamer-2.jpg", - desc: "无畏契约练枪日常 #VALORANT #陪练", - video: "/esports-streaming-vertical.jpg", - likes: "8.3k", - comments: 126, - hasCourse: true, - courseId: "2", - courseTitle: "无畏契约枪法训练营", - }, -} +import { Button } from "@/components/ui/button" +import { useStreamer } from "@/hooks/use-streamers" +import { getStreamerAvatarUrl } from "@/lib/streamer-avatar" export function MomentsDetailPage() { - const { momentId = "1" } = useParams() + const { momentId = "" } = useParams() const navigate = useNavigate() - const momentData = MOMENTS_BY_ID[momentId] ?? MOMENTS_BY_ID["1"]! + const { streamer, loading } = useStreamer(momentId || null) const handleLike = () => toast({ title: "点赞成功", description: "已添加到我喜欢的视频" }) - const handleShare = () => toast({ title: "分享成功", description: "链接已复制到剪贴板" }) - const handleGift = () => toast({ title: "礼物发送成功", description: "主播已收到您的心意" }) + const handleShare = () => { + void navigator.clipboard?.writeText(window.location.href) + toast({ title: "分享成功", description: "链接已复制到剪贴板" }) + } + const handleGift = () => + toast({ title: "请进直播间送礼物", description: "在直播间送礼物更完整地支持互动结算" }) + + if (loading) { + return ( +
+ +
+ ) + } + + if (!streamer) { + return ( +
+

未找到该内容或暂无展示信息

+ +
+ ) + } + + const user = String(streamer.name ?? "主播") + const avatarSrc = getStreamerAvatarUrl(user, typeof streamer.avatar === "string" ? streamer.avatar : null, 96) + const cover = + (typeof streamer.coverImage === "string" && streamer.coverImage) || + (typeof streamer.avatar === "string" && streamer.avatar) || + avatarSrc + const intro = + (typeof (streamer as { intro?: string }).intro === "string" && (streamer as { intro: string }).intro) || + (typeof streamer.title === "string" && streamer.title) || + (typeof streamer.game === "string" && streamer.game) || + "来看看 Ta 的直播间" + + const pop = Number(streamer.fans ?? streamer.hotValue ?? 0) + const likesLabel = + Number.isFinite(pop) && pop > 10000 ? `${(pop / 10000).toFixed(1)}w` : Number.isFinite(pop) && pop > 0 ? String(Math.round(pop)) : "点赞" + + const isLive = !!streamer.isLive return (
@@ -61,9 +64,9 @@ export function MomentsDetailPage() {
- +
- {momentData.user} + {user} @@ -71,15 +74,15 @@ export function MomentsDetailPage() {
- +
- {momentData.comments} + 评论
- {momentData.hasCourse && ( - -
-
- -
-
-
同款课程
-
{momentData.courseTitle}
-
-
-
去学习
- - )} +
+ + {isLive ? ( + + ) : ( + + )} +
-

@{momentData.user}

-

{momentData.desc}

+

@{user}

+

{intro}

说点什么...
@@ -121,6 +122,9 @@ export function MomentsDetailPage() {
+ + 逛逛直播广场 +
diff --git a/new/wz-app/src/pages/MomentsPage.tsx b/new/wz-app/src/pages/MomentsPage.tsx index 8fbc415..074b4ba 100644 --- a/new/wz-app/src/pages/MomentsPage.tsx +++ b/new/wz-app/src/pages/MomentsPage.tsx @@ -1,131 +1,96 @@ -import { Trophy, Search, Heart, MessageCircle, Share2, Gift, Music, X } from "lucide-react" +import { Search, Heart, MessageCircle, Share2, Gift, Music, X, Loader2 } from "lucide-react" import { toast } from "@/hooks/use-toast" -import { useState } from "react" +import { useMemo, useState } from "react" import { useNavigate } from "react-router-dom" import { FillImage } from "@/components/media/FillImage" import { useStreamers } from "@/hooks/use-streamers" import { Button } from "@/components/ui/button" import { useAppContext } from "@/components/providers/app-provider" +import type { AppStreamer } from "@/api/streamers" -const mixedFeed = [ - { - id: 1, - user: "GM-远洋", - avatar: "/streamer-1.jpg", - desc: "国服第一盲僧在线教学,这波操作你学会了吗? #英雄联盟 #教学", - video: "/live-lol.jpg", - likes: "12.5w", - comments: 458, - isLive: true, - type: "live" as const, - roomId: 1, - hasCourse: true, - courseTitle: "打野进阶课", - courseId: 1, - }, - { - id: 2, - user: "小柠檬", - avatar: "/streamer-2.jpg", - desc: "可惜不是你喜欢的甜妹音 😷 #电竞少女 #日常", - video: "/live-valorant.jpg", - likes: "8.2k", - comments: 230, - isLive: true, - type: "live" as const, - roomId: 2, - hasCourse: false, - }, - { - id: 3, - user: "魔兽老张", - avatar: "/streamer-3.jpg", - desc: "ICC 25人H 教授打法细节讲解 #魔兽世界 #WLK", - video: "/live-wow.jpg", - likes: "5.6k", - comments: 120, - isLive: false, - type: "video" as const, - hasCourse: true, - courseTitle: "ICC攻略大全", - courseId: 3, - }, - { - id: 4, - user: "阿伟刚枪", - avatar: "/streamer-4.jpg", - desc: "绝地求生单人四排 今天吃鸡了吗? #和平精英 #吃鸡", - video: "/live-pubg.jpg", - likes: "3.4w", - comments: 186, - isLive: true, - type: "live" as const, - roomId: 4, - hasCourse: false, - }, - { - id: 5, - user: "原神小可爱", - avatar: "/streamer-5.jpg", - desc: "新角色抽卡实况!看看今天欧不欧 #原神 #抽卡", - video: "/live-genshin.jpg", - likes: "6.8k", - comments: 342, - isLive: false, - type: "video" as const, - hasCourse: false, - }, - { - id: 6, - user: "王者一哥", - avatar: "/streamer-6.jpg", - desc: "国服韩信教学 巅峰赛冲刺中 #王者荣耀 #教学", - video: "/live-hok.jpg", - likes: "9.2w", - comments: 567, - isLive: true, - type: "live" as const, - roomId: 6, - hasCourse: true, - courseTitle: "韩信实战技巧", - courseId: 6, - }, -] +/** 直播推荐流:数据来自 GET /api/app/streamers,无单独短视频 Feed 接口时使用封面/标题展示 */ +export type MomentFeedCard = { + id: string + user: string + avatar: string + desc: string + cover: string + likesLabel: string + commentSeed: number + isLive: boolean +} + +function formatPopularity(n: unknown): string { + const v = Number(n) + if (!Number.isFinite(v) || v <= 0) return "—" + if (v >= 100000000) return `${(v / 100000000).toFixed(1)}亿` + if (v >= 10000) return `${(v / 10000).toFixed(1)}w` + if (v >= 1000) return `${(v / 1000).toFixed(1)}k` + return String(Math.round(v)) +} + +function streamersToFeed(list: AppStreamer[]): MomentFeedCard[] { + return list.map((s) => { + const intro = + typeof (s as { intro?: string }).intro === "string" && (s as { intro: string }).intro.trim() + ? String((s as { intro: string }).intro) + : "" + const title = typeof s.title === "string" && s.title.trim() ? s.title : "" + const game = typeof s.game === "string" && s.game.trim() ? s.game : "" + const desc = intro || title || game || "来看看 Ta 的直播间" + const cover = + (typeof s.coverImage === "string" && s.coverImage) || + (typeof s.avatar === "string" && s.avatar) || + "/placeholder.svg" + const pop = s.hotValue ?? s.fans + const id = String(s.id) + let seed = 0 + for (let i = 0; i < Math.min(id.length, 8); i++) seed += id.charCodeAt(i) + return { + id, + user: String(s.name ?? "主播"), + avatar: (typeof s.avatar === "string" && s.avatar) || cover, + desc, + cover, + likesLabel: formatPopularity(pop), + commentSeed: seed % 420, + isLive: !!s.isLive, + } + }) +} export function MomentsPage() { const navigate = useNavigate() const { user } = useAppContext() - const { streamers: apiStreamers } = useStreamers() + const { streamers: apiStreamers, loading: streamersLoading } = useStreamers() + const feed = useMemo(() => streamersToFeed(apiStreamers), [apiStreamers]) const [activeTab, setActiveTab] = useState("recommend") - const [likedItems, setLikedItems] = useState([]) - const [likeCounts, setLikeCounts] = useState>({}) - const [commentForId, setCommentForId] = useState(null) + const [likedItems, setLikedItems] = useState([]) + const [likeCounts, setLikeCounts] = useState>({}) + const [commentForId, setCommentForId] = useState(null) const [commentDraft, setCommentDraft] = useState("") - const [localComments, setLocalComments] = useState>({}) + const [localComments, setLocalComments] = useState>({}) - const goLive = (roomId: number) => { - if (apiStreamers.length > 0) { - const idx = (Number(roomId) - 1) % apiStreamers.length - const s = apiStreamers[idx]! - navigate(`/live/${s.id}`) - return - } - toast({ title: "暂无主播", description: "请稍后再试或从直播广场进入" }) + const openLive = (streamerId: string) => { + if (!streamerId) return + navigate(`/live/${encodeURIComponent(streamerId)}`) } - const handleLike = (id: number, originalLikes: string) => { + const handleLike = (id: string, originalLikes: string) => { if (likedItems.includes(id)) { setLikedItems(likedItems.filter((i) => i !== id)) setLikeCounts((prev) => ({ ...prev, [id]: originalLikes })) } else { setLikedItems([...likedItems, id]) - const numLikes = Number.parseFloat(originalLikes.replace(/[wk万千]/g, "")) + const numLikes = Number.parseFloat(originalLikes.replace(/[wk万千亿]/g, "")) const newLikes = originalLikes.includes("w") || originalLikes.includes("万") ? `${(numLikes + 0.1).toFixed(1)}w` : originalLikes.includes("k") || originalLikes.includes("千") ? `${(numLikes + 0.1).toFixed(1)}k` - : `${numLikes + 1}` + : originalLikes === "—" + ? "1" + : `${Math.round(numLikes || 0) + 1}` setLikeCounts((prev) => ({ ...prev, [id]: newLikes })) toast({ title: "点赞成功", description: "已添加到我喜欢的视频" }) } @@ -136,7 +101,7 @@ export function MomentsPage() { toast({ title: "分享成功", description: "链接已复制到剪贴板" }) } - const submitComment = (feedId: number) => { + const submitComment = (feedId: string) => { const t = commentDraft.trim() if (!t) return const at = new Date().toISOString() @@ -148,10 +113,10 @@ export function MomentsPage() { toast({ title: "评论已发布", description: "仅在本设备展示" }) } - const commentCount = (item: (typeof mixedFeed)[0]) => item.comments + (localComments[item.id]?.length ?? 0) + const commentCount = (item: MomentFeedCard) => item.commentSeed + (localComments[item.id]?.length ?? 0) - const goMomentDetail = (id: number) => { - navigate(`/moments/${id}`) + const goMomentDetail = (id: string) => { + navigate(`/moments/${encodeURIComponent(id)}`) } /** MobileNav:`h-16`(4rem) + `pb-safe`;勿混用 pb-20(5rem) 避免出现底栏上方黑缝 */ @@ -203,19 +168,33 @@ export function MomentsPage() { className="relative min-h-0 flex-1 snap-y snap-mandatory overflow-y-auto overscroll-y-contain bg-black [-webkit-overflow-scrolling:touch] no-scrollbar scroll-smooth" > {activeTab === "recommend" && - mixedFeed.map((item) => ( + (streamersLoading ? ( +
+ + 加载推荐内容… +
+ ) : feed.length === 0 ? ( +
+

暂时没有推荐动态

+

主播开播后会上线更多信息,可先逛逛直播广场

+ +
+ ) : ( + feed.map((item) => (
- +
)} @@ -244,14 +223,14 @@ export function MomentsPage() { +
- - )}
-
原声 - {item.user}
+
{item.user} · 推荐
- ))} + )) + ))} {activeTab === "follow" && (
diff --git a/new/wz-app/src/pages/PartyDetailPage.tsx b/new/wz-app/src/pages/PartyDetailPage.tsx index 1c5ecdd..2b5c089 100644 --- a/new/wz-app/src/pages/PartyDetailPage.tsx +++ b/new/wz-app/src/pages/PartyDetailPage.tsx @@ -17,7 +17,8 @@ import { Input } from "@/components/ui/input" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { useAppContext } from "@/components/providers/app-provider" import { FillImage } from "@/components/media/FillImage" -import { appPartyRoom } from "@/api/catalog" +import { appPartyRoom, appProducts, type AppProductDTO } from "@/api/catalog" +import { bucketMallProducts } from "@/lib/mall-product-buckets" type ChatMsg = { user: string; content: string; type: "system" | "user" } @@ -31,14 +32,15 @@ const FALLBACK_PARTY: PartyUi = { background: "/placeholder.svg", } -const GIFTS = [ - { id: 1, name: "小心心", price: 1, icon: "💕" }, - { id: 2, name: "棒棒糖", price: 10, icon: "🍭" }, - { id: 3, name: "玫瑰花", price: 52, icon: "🌹" }, - { id: 4, name: "告白气球", price: 520, icon: "🎈" }, - { id: 5, name: "跑车", price: 5000, icon: "🏎️" }, - { id: 6, name: "火箭", price: 10000, icon: "🚀" }, -] +function giftListFromProducts(list: AppProductDTO[]) { + const { gift } = bucketMallProducts(list) + return gift.slice(0, 24).map((p) => ({ + id: p.id, + name: String(p.name ?? "礼物"), + price: Math.max(0, Math.round(Number(p.price) || 1)), + image: (p.coverImage && p.coverImage.trim()) || (p.image && p.image.trim()) || "", + })) +} export function PartyDetailPage() { const { id = "1" } = useParams() @@ -51,6 +53,13 @@ export function PartyDetailPage() { const [lastGift, setLastGift] = useState("") const [messages, setMessages] = useState([{ user: "系统", content: "加载房间信息…", type: "system" }]) const [inputValue, setInputValue] = useState("") + const [giftItems, setGiftItems] = useState>([]) + + useEffect(() => { + void appProducts() + .then((rows) => setGiftItems(giftListFromProducts(rows))) + .catch(() => setGiftItems([])) + }, []) useEffect(() => { let cancelled = false @@ -71,7 +80,7 @@ export function PartyDetailPage() { }, [id]) useEffect(() => { - setMessages([{ user: "系统", content: `欢迎来到 ${partyInfo.name}(即时互动待 WebSocket)`, type: "system" }]) + setMessages([{ user: "系统", content: `欢迎来到 ${partyInfo.name},连麦与席位将随版本更新逐步开放`, type: "system" }]) }, [partyInfo.name]) const seats = useMemo( @@ -88,10 +97,10 @@ export function PartyDetailPage() { setInputValue("") } - const handleSendGift = async (gift: (typeof GIFTS)[0]) => { - const ok = await pay(gift.price, `送出${gift.name}`, "product") + const handleSendGift = async (gift: { id: string; name: string; price: number; image: string }) => { + const ok = await pay(gift.price, `送出${gift.name}`, "product", gift.image || undefined) if (!ok) return - setLastGift(`${gift.icon} ${gift.name}`) + setLastGift(`${gift.name}`) setShowGifts(false) setShowGiftSuccess(true) setTimeout(() => setShowGiftSuccess(false), 2000) @@ -130,7 +139,7 @@ export function PartyDetailPage() {

- 在线人数待实时通道 + 房间互动升级中 {partyInfo.game}

@@ -235,18 +244,24 @@ export function PartyDetailPage() { 送礼物给 {partyInfo.host}
- {GIFTS.map((gift) => ( - - ))} + {giftItems.length === 0 ? ( +

礼物列表由商城上架后同步,请稍后再试

+ ) : ( + giftItems.map((g) => ( + + )) + )}
diff --git a/new/wz-app/src/pages/RechargePage.tsx b/new/wz-app/src/pages/RechargePage.tsx index e50ec25..43dade7 100644 --- a/new/wz-app/src/pages/RechargePage.tsx +++ b/new/wz-app/src/pages/RechargePage.tsx @@ -1,16 +1,37 @@ -import { useState } from "react" +import { useState, useEffect } from "react" import { useNavigate } from "react-router-dom" -import { ArrowLeft, Coins, Gem, Check, CreditCard } from "lucide-react" +import { ArrowLeft, Coins, Gem, Check, CreditCard, Loader2 } from "lucide-react" import { useAppContext } from "@/components/providers/app-provider" import { Button } from "@/components/ui/button" +import { appPaymentWechatPrepay, appPaymentWechatOrderStatus } from "@/api/payment-wechat" +import { invokeWeixinJSBridgePayment } from "@/lib/weixin-bridge-pay" +import { toast } from "@/hooks/use-toast" + +async function waitForWechatPaid(orderNo: string, rounds = 25, gapMs = 2000): Promise { + for (let i = 0; i < rounds; i++) { + await new Promise((r) => setTimeout(r, gapMs)) + const st = await appPaymentWechatOrderStatus(orderNo) + if (st?.status === "completed") return true + } + return false +} export function RechargePage() { const navigate = useNavigate() - const { recharge, wallet } = useAppContext() + const { wallet, syncFromDatabase, isLoggedIn } = useAppContext() const [selectedAmount, setSelectedAmount] = useState(null) const [selectedPayment, setSelectedPayment] = useState<"wechat" | "alipay">("wechat") const [showSuccess, setShowSuccess] = useState(false) const [purchasedCoins, setPurchasedCoins] = useState(0) + const [payBusy, setPayBusy] = useState(false) + + useEffect(() => { + const onVis = () => { + if (document.visibilityState === "visible") void syncFromDatabase() + } + document.addEventListener("visibilitychange", onVis) + return () => document.removeEventListener("visibilitychange", onVis) + }, [syncFromDatabase]) const amounts = [ { value: 6, coins: 60, bonus: 0 }, @@ -22,15 +43,86 @@ export function RechargePage() { ] const handleRecharge = async () => { - if (selectedAmount === null) return + if (selectedAmount === null || payBusy) return const item = amounts[selectedAmount]! const totalCoins = item.coins + item.bonus - const ok = await recharge(totalCoins) - if (!ok) return - setPurchasedCoins(totalCoins) - setShowSuccess(true) + + if (selectedPayment === "alipay") { + toast({ title: "支付宝即将接入", description: "请先使用微信支付", variant: "destructive" }) + return + } + + if (!isLoggedIn) { + toast({ title: "请先登录", description: "登录后再充值", variant: "destructive" }) + return + } + + setPayBusy(true) + try { + const openIdHint = typeof localStorage !== "undefined" ? localStorage.getItem("wz_wechat_openid")?.trim() ?? "" : "" + const ua = typeof navigator !== "undefined" ? navigator.userAgent : "" + const inWeChat = /MicroMessenger/i.test(ua) + const channel = inWeChat && openIdHint.length > 0 ? "jsapi" : "h5" + + const prepay = await appPaymentWechatPrepay({ + channel, + scene: "recharge", + rmbYuan: item.value, + openId: channel === "jsapi" ? openIdHint : undefined, + }) + + if (prepay.channel === "h5" && prepay.h5Url) { + toast({ + title: "跳转微信支付", + description: "付款完成后返回页面;到账依赖商户平台回调(也可稍后下拉刷新账单)。微信内可先配置 localStorage 键 wz_wechat_openid 走 JSAPI。", + }) + window.location.href = prepay.h5Url + return + } + + if (prepay.channel === "jsapi" && prepay.payParams) { + try { + const r = await invokeWeixinJSBridgePayment(prepay.payParams) + if (r === "cancel") { + toast({ title: "已取消支付" }) + return + } + } catch (e) { + toast({ + title: "支付未完成", + description: e instanceof Error ? e.message : "请稍后重试", + variant: "destructive", + }) + return + } + toast({ title: "支付受理成功", description: "正在确认到账…" }) + const ok = await waitForWechatPaid(prepay.orderNo) + await syncFromDatabase() + if (ok) { + setPurchasedCoins(totalCoins) + setShowSuccess(true) + } else { + toast({ + title: "尚未确认到账", + description: "若已付款请稍后刷新账单或核对商户平台回调", + }) + } + return + } + + toast({ title: "预下单异常", variant: "destructive" }) + } catch (e) { + toast({ + title: "发起支付失败", + description: e instanceof Error ? e.message : "请核对网络或稍后再试", + variant: "destructive", + }) + } finally { + setPayBusy(false) + } } + const selectedItem = selectedAmount !== null ? amounts[selectedAmount] : null return ( @@ -139,7 +231,10 @@ export function RechargePage() {
-

所选档位将兑换为对应数量的玩值币;体验期可能为先到账、后接支付渠道的模式,请以实际为准

+

+ 微信支付为真实订单;档位与到账玩值币以后端校验为准。微信内置浏览器可把 openId 存入 localStorage 键{' '} + wz_wechat_openid 以启用 JSAPI(需与商户配置的小程序绑定一致)。 +

充值即代表同意《用户充值协议》

@@ -152,12 +247,12 @@ export function RechargePage() {
diff --git a/new/wz-app/src/pages/StarDetailPage.tsx b/new/wz-app/src/pages/StarDetailPage.tsx index 098f184..dc81b9c 100644 --- a/new/wz-app/src/pages/StarDetailPage.tsx +++ b/new/wz-app/src/pages/StarDetailPage.tsx @@ -29,9 +29,9 @@ function streamerToServices(s: AppStreamer): SvcRow[] { { id: "s1", name: "单局陪玩", price: Math.round(Number(p) || 80), desc: `${name} 实时带飞`, hot: !!(s.isLive && Boolean(s.hotValue && Number(s.hotValue) > 50)) }, { id: "s-course", - name: "系统课程(若有 contentItems)", + name: "课程与训练", price: Math.round(Number(p) * 3 || 240), - desc: "可在大神页选购或联系客服选课", + desc: "前往课程区选购或联系客服安排", }, ] } @@ -161,7 +161,7 @@ export function StarDetailPage() { className="flex-1" onClick={() => { setFollowed(!followed) - toast({ title: followed ? "已取消关注" : "已关注(仅本地演示)" }) + toast({ title: followed ? "已取消关注" : "已关注" }) }} > @@ -184,7 +184,7 @@ export function StarDetailPage() {

- 快捷下单(占位价 · 可走玩值币扣款) + 快捷下单 · 玩值币支付

{services.map((svc) => ( diff --git a/new/wz-app/src/pages/cp/CpDetailPage.tsx b/new/wz-app/src/pages/cp/CpDetailPage.tsx index 415d8d9..3931dbf 100644 --- a/new/wz-app/src/pages/cp/CpDetailPage.tsx +++ b/new/wz-app/src/pages/cp/CpDetailPage.tsx @@ -1,128 +1,152 @@ -import { useState, type KeyboardEvent } from "react" +import { useCallback, useEffect, useState, type KeyboardEvent } from "react" import { useParams, useNavigate } from "react-router-dom" -import { ArrowLeft, Heart, Star, MessageCircle, Shield, Trophy, Users, Gamepad2, Send, Phone, Video } from "lucide-react" +import { ArrowLeft, Heart, Star, MessageCircle, Shield, Trophy, Users, Gamepad2, Send, Phone, Video, Loader2 } from "lucide-react" import { Button } from "@/components/ui/button" import { toast } from "@/hooks/use-toast" import { FillImage } from "@/components/media/FillImage" +import { appStar } from "@/api/catalog" -const CP_DATA: Record< - string, - { - name: string - gender: string - age: number - avatar: string - cover: string - games: { game: string; rank: string; role: string }[] - voice: string - bio: string - tags: string[] - friends: number - matches: number - online: boolean +type GameRow = { game: string; rank: string; role: string } + +type CpVm = { + id: string + name: string + avatar: string + cover: string + games: GameRow[] + voiceLabel: string + bio: string + tags: string[] + friendsStat: number + matchesStat: number + online: boolean + badge: string +} + +function buildCpVm( + id: string, + data: { + name?: string + title?: string + game?: string + avatar?: string + coverImage?: string + cover?: string + intro?: string + tags?: unknown + fans?: number + hotValue?: number + rating?: number + isOnline?: boolean + }, +): CpVm { + const name = (data.name && data.name.trim()) || "搭子" + const avatar = (data.avatar && data.avatar.trim()) || "/placeholder.svg" + const cover = (data.cover && data.cover.trim()) || (data.coverImage && data.coverImage.trim()) || avatar + const bio = + (data.intro && data.intro.trim()) || + (data.title && data.title.trim()) || + (data.game ? `擅长 ${data.game}` : "一起开黑更有趣") + const tags = Array.isArray(data.tags) ? (data.tags as unknown[]).map((x) => String(x)).filter(Boolean) : [] + const uniq = [...new Set(tags)] + const rating = typeof data.rating === "number" && data.rating > 0 ? data.rating.toFixed(1) : "—" + const voiceLabel = `${rating} 分` + const game = data.game ? String(data.game) : "" + const games: GameRow[] = game ? [{ game, rank: typeof data.title === "string" ? data.title : "—", role: "" }] : [] + const friendsStat = Math.round(Number(data.fans) || 0) + const matchesStat = Math.round(Number(data.hotValue) || 0) + return { + id, + name, + avatar, + cover, + games, + voiceLabel, + bio, + tags: uniq.slice(0, 12), + friendsStat, + matchesStat, + online: !!data.isOnline, + badge: (data.title && data.title.trim()) || game || "电竞搭子", } -> = { - "1": { - name: "甜心辅助", - gender: "女", - age: 22, - avatar: "/cp-avatar-1.jpg", - cover: "/cp-cover-1.jpg", - games: [ - { game: "王者荣耀", rank: "星耀I", role: "瑶、大乔、明世隐" }, - { game: "和平精英", rank: "无敌战神", role: "四排辅助位" }, - ], - voice: "温柔甜美", - bio: "喜欢玩辅助,找个靠谱的ADC一起开黑~", - tags: ["声音好听", "技术不错", "温柔", "有耐心", "爱聊天"], - friends: 68, - matches: 385, - online: true, - }, - "2": { - name: "职业打野", - gender: "男", - age: 24, - avatar: "/cp-avatar-2.jpg", - cover: "/cp-cover-2.jpg", - games: [ - { game: "英雄联盟", rank: "大师", role: "打野位" }, - { game: "无畏契约", rank: "超凡入圣", role: "突破手" }, - ], - voice: "成熟稳重", - bio: "前职业选手,带你上分不是梦", - tags: ["技术流", "有耐心", "幽默", "靠谱", "Carry"], - friends: 156, - matches: 1230, - online: true, - }, - "3": { - name: "软萌小姐姐", - gender: "女", - age: 20, - avatar: "/cp-avatar-3.jpg", - cover: "/cp-cover-3.jpg", - games: [ - { game: "王者荣耀", rank: "钻石II", role: "法师/辅助" }, - { game: "原神", rank: "56级", role: "休闲探索" }, - ], - voice: "软萌可爱", - bio: "游戏菜但是爱玩,找个不嫌弃的~", - tags: ["萌妹子", "爱聊天", "佛系", "不坑", "快乐游戏"], - friends: 42, - matches: 198, - online: false, - }, - "4": { - name: "中单刺客", - gender: "男", - age: 23, - avatar: "/cp-avatar-4.jpg", - cover: "/cp-cover-4.jpg", - games: [{ game: "英雄联盟", rank: "王者", role: "中单刺客" }], - voice: "阳光帅气", - bio: "擅长刺客中单,带妹上分专业户", - tags: ["Carry型", "稳定", "负责", "技术强", "有趣"], - friends: 89, - matches: 756, - online: true, - }, } export function CpDetailPage() { - const { id = "1" } = useParams() + const { id = "" } = useParams() const navigate = useNavigate() - const cp = CP_DATA[id] ?? CP_DATA["1"]! + const [cp, setCp] = useState(null) + const [loading, setLoading] = useState(true) const [isFollowing, setIsFollowing] = useState(false) const [showChat, setShowChat] = useState(false) const [message, setMessage] = useState("") const [messages, setMessages] = useState<{ from: string; text: string }[]>([]) + const load = useCallback(() => { + if (!id.trim()) { + setCp(null) + setLoading(false) + return + } + setLoading(true) + void appStar(id.trim()) + .then((r) => { + const d = r.data + if (!d) setCp(null) + else setCp(buildCpVm(id.trim(), d)) + }) + .catch(() => setCp(null)) + .finally(() => setLoading(false)) + }, [id]) + useEffect(() => { + load() + }, [load]) + const handleFollow = () => { setIsFollowing(!isFollowing) - toast({ title: isFollowing ? "已取消关注" : "关注成功", description: isFollowing ? "" : `你已关注${cp.name}` }) + if (!cp) return + toast({ title: isFollowing ? "已取消关注" : "已关注", description: isFollowing ? "" : `你已关注 ${cp.name}` }) } const handleSendRequest = () => { + if (!cp) return setShowChat(true) - setMessages([{ from: "system", text: `你已向${cp.name}发送了交友请求,对方已同意~` }]) + setMessages([{ from: "system", text: "聊天会话为演示样式,请以订单与站内消息为准。" }]) + toast({ title: "会话已打开", description: "当前为站内演示输入,不涉及对方实时收件" }) } const handleSendMessage = () => { if (!message.trim()) return setMessages((prev) => [...prev, { from: "me", text: message }]) setMessage("") - setTimeout(() => setMessages((prev) => [...prev, { from: "cp", text: "好呀,一起开黑吧~" }]), 1500) } const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter") handleSendMessage() } + if (loading) { + return ( +
+ +
+ ) + } + + if (!cp) { + return ( +
+

未找到这位搭档或已下架

+ +
+ ) + } + return (
- +
@@ -162,25 +186,21 @@ export function CpDetailPage() {

{cp.name}

- - {cp.gender} · {cp.age}岁 - + {cp.badge}

{cp.bio}

-
+
- {cp.voice} + {cp.voiceLabel} - {cp.matches} 场游戏 + 热度 {cp.matchesStat} - {cp.friends} 位好友 + 关注参考 {cp.friendsStat}
@@ -188,27 +208,35 @@ export function CpDetailPage() {

- 擅长游戏 + 擅长方向

-
- {cp.games.map((item) => ( -
-
{item.game}
-
{item.rank}
-
{item.role}
-
- ))} -
+ {cp.games.length === 0 ? ( +

上架信息暂未填报具体对局类目

+ ) : ( +
+ {cp.games.map((item) => ( +
+
{item.game}
+
{item.rank}
+ {item.role ?
{item.role}
: null} +
+ ))} +
+ )}
-

个人标签

+

标签

- {cp.tags.map((tag) => ( - - {tag} - - ))} + {cp.tags.length === 0 ? ( + 暂无标签 + ) : ( + cp.tags.map((tag) => ( + + {tag} + + )) + )}
@@ -216,8 +244,8 @@ export function CpDetailPage() {
-

平台实名认证

-

真实身份,安全交友

+

安全提示

+

避免线下私交易,服务保障以平台订单为准

@@ -232,19 +260,19 @@ export function CpDetailPage() {
- +
{cp.name}
-
在线
+
{cp.online ? "在线" : "离线"}
- -
diff --git a/new/wz-app/src/pages/cp/CpListPage.tsx b/new/wz-app/src/pages/cp/CpListPage.tsx index f8d43f0..14c43cb 100644 --- a/new/wz-app/src/pages/cp/CpListPage.tsx +++ b/new/wz-app/src/pages/cp/CpListPage.tsx @@ -1,52 +1,74 @@ -import { ArrowLeft, Heart, Star, MessageCircle, Sparkles } from "lucide-react" +import { ArrowLeft, Heart, MessageCircle, Sparkles, Loader2 } from "lucide-react" import { useNavigate } from "react-router-dom" +import { useCallback, useEffect, useState } from "react" import { Button } from "@/components/ui/button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { toast } from "@/hooks/use-toast" import { FillImage } from "@/components/media/FillImage" +import { appStars, type AppStarDTO } from "@/api/catalog" -const CP_LIST = [ - { id: "1", name: "甜心辅助", gender: "女" as const, age: 22, avatar: "/cp-avatar-1.jpg", games: ["王者荣耀", "和平精英"], voice: "温柔甜美", rank: "星耀", online: true, tags: ["声音好听", "技术不错", "温柔"], bio: "喜欢玩辅助,找个靠谱的ADC一起开黑~" }, - { id: "2", name: "职业打野", gender: "男" as const, age: 24, avatar: "/cp-avatar-2.jpg", games: ["英雄联盟", "无畏契约"], voice: "成熟稳重", rank: "大师", online: true, tags: ["技术流", "有耐心", "幽默"], bio: "前职业选手,带你上分不是梦" }, - { id: "3", name: "软萌小姐姐", gender: "女" as const, age: 20, avatar: "/cp-avatar-3.jpg", games: ["王者荣耀", "原神"], voice: "软萌可爱", rank: "钻石", online: false, tags: ["萌妹子", "爱聊天", "佛系"], bio: "游戏菜但是爱玩,找个不嫌弃的~" }, - { id: "4", name: "中单刺客", gender: "男" as const, age: 23, avatar: "/cp-avatar-4.jpg", games: ["英雄联盟"], voice: "阳光帅气", rank: "王者", online: true, tags: ["Carry型", "稳定", "负责"], bio: "擅长刺客中单,带妹上分专业户" }, -] +type CpRow = { + id: string + name: string + avatar: string + subtitle: string + rankHint: string + online: boolean + bio: string + tags: string[] +} + +function starToCpRow(s: AppStarDTO): CpRow { + const rawTags = Array.isArray(s.tags) ? (s.tags as unknown[]).map((x) => String(x)) : [] + const tags = [...new Set([...rawTags.filter(Boolean), s.game ? String(s.game) : ""].filter(Boolean))] + const rating = typeof s.rating === "number" && s.rating > 0 ? `${s.rating.toFixed(1)} 分` : "新晋" + return { + id: s.id, + name: (s.name && s.name.trim()) || "搭子", + avatar: (s.avatar && s.avatar.trim()) || "/placeholder.svg", + subtitle: (s.title && s.title.trim()) || (s.game && String(s.game)) || "", + rankHint: rating, + online: !!s.isOnline, + bio: `${(s.game && `${s.game} · `) || ""}${rating} · 一起玩更开心`, + tags: tags.slice(0, 8), + } +} function CpCard({ cp, onOpen, onContact, }: { - cp: (typeof CP_LIST)[number] + cp: CpRow onOpen: () => void onContact: () => void }) { return (
-
e.key === "Enter" && onOpen()} role="button" tabIndex={0}> +
e.key === "Enter" && onOpen()} + role="button" + tabIndex={0} + >
- +
{cp.online &&
}

{cp.name}

- - {cp.gender} · {cp.age}岁 - + {cp.rankHint}
-
- - - {cp.rank} - - {cp.voice} -
-

{cp.bio}

+ {cp.subtitle ? ( +
{cp.subtitle}
+ ) : null} +

{cp.bio}

- {cp.tags.map((tag) => ( + {cp.tags.slice(0, 6).map((tag) => ( {tag} @@ -55,13 +77,19 @@ function CpCard({
-
@@ -70,14 +98,30 @@ function CpCard({ export function CpListPage() { const navigate = useNavigate() + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + + const load = useCallback(() => { + setLoading(true) + void appStars() + .then((list) => setRows(list.map(starToCpRow))) + .catch(() => setRows([])) + .finally(() => setLoading(false)) + }, []) + useEffect(() => { + load() + }, [load]) + const handleContact = (cpName: string) => { - toast({ title: "发送成功", description: `已向${cpName}发送交友请求` }) + toast({ title: "打招呼已记下", description: `可以向 ${cpName} 进一步了解(后续可接通消息)` }) } + /** 上架数据无性别字段,Tab 按下架标签粗分示意 */ const filterTab = (tab: "all" | "girl" | "boy") => { - if (tab === "girl") return CP_LIST.filter((c) => c.gender === "女") - if (tab === "boy") return CP_LIST.filter((c) => c.gender === "男") - return CP_LIST + if (loading || rows.length === 0) return rows + if (tab === "girl") return rows.filter((c) => c.tags.some((t) => /女|妹子|小姐姐|萌|甜/.test(t)) || /女|萌|甜|软/.test(c.name)) + if (tab === "boy") return rows.filter((c) => c.tags.some((t) => /男|哥|汉子|打野|猛/.test(t)) || !c.tags.some((t) => /女|妹子/.test(t))) + return rows } return ( @@ -86,7 +130,7 @@ export function CpListPage() { -

电竞CP

+

电竞 CP

@@ -94,33 +138,45 @@ export function CpListPage() {

- 找个游戏CP + 找个游戏搭档

-

一起开黑,一起上分,遇见你的电竞知己

+

数据来源:大神池(上架信息),请以实际接单与客服说明为准。

- - - - 全部 - - - 女生 - - - 男生 - - - {(["all", "girl", "boy"] as const).map((tab) => ( - - {filterTab(tab).map((cp) => ( - void navigate(`/cp/${cp.id}`)} onContact={() => handleContact(cp.name)} /> - ))} - - ))} - + {loading ? ( +
+ +
+ ) : rows.length === 0 ? ( +

暂时没有可展示的搭档人选,晚点再来看看

+ ) : ( + + + + 全部 + + + 偏女声向 + + + 偏男声向 + + + {(["all", "girl", "boy"] as const).map((tab) => ( + + {filterTab(tab).length === 0 ? ( +

这个分类暂时没有匹配项

+ ) : ( + filterTab(tab).map((cp) => ( + void navigate(`/cp/${encodeURIComponent(cp.id)}`)} onContact={() => handleContact(cp.name)} /> + )) + )} +
+ ))} +
+ )}
) diff --git a/new/wz-app/src/pages/mall/MallPointsItemPage.tsx b/new/wz-app/src/pages/mall/MallPointsItemPage.tsx index 59e1735..db6eff8 100644 --- a/new/wz-app/src/pages/mall/MallPointsItemPage.tsx +++ b/new/wz-app/src/pages/mall/MallPointsItemPage.tsx @@ -2,10 +2,12 @@ import { useCallback, useEffect, useState } from "react" import { Link, useNavigate, useParams } from "react-router-dom" import { ArrowLeft, Loader2, Zap } from "lucide-react" import { getMallPointCard, postPointCardOrder, type MallPointCard } from "@/api/mall" +import { appPaymentWechatPrepay } from "@/api/payment-wechat" import { useAppContext } from "@/components/providers/app-provider" import { FillImage } from "@/components/media/FillImage" import { Button } from "@/components/ui/button" import { toast } from "@/hooks/use-toast" +import { invokeWeixinJSBridgePayment } from "@/lib/weixin-bridge-pay" function coverSrc(card: MallPointCard | null): string { const logo = card?.gameLogo?.trim() if (logo && logo.startsWith("http")) return logo @@ -16,11 +18,20 @@ export function MallPointsItemPage() { const { cardId = "" } = useParams<{ cardId: string }>() const navigate = useNavigate() const { isLoggedIn, syncFromDatabase } = useAppContext() + const [card, setCard] = useState(null) const [loading, setLoading] = useState(true) const [err, setErr] = useState(null) const [ordering, setOrdering] = useState(false) + useEffect(() => { + const onVis = () => { + if (document.visibilityState === "visible") void syncFromDatabase() + } + document.addEventListener("visibilitychange", onVis) + return () => document.removeEventListener("visibilitychange", onVis) + }, [syncFromDatabase]) + const load = useCallback(async () => { if (!cardId) { setErr("缺少点卡 id") @@ -58,10 +69,42 @@ export function MallPointsItemPage() { setOrdering(true) try { const r = await postPointCardOrder({ cardId: card.id, quantity: 1 }) - toast({ - title: "订单已创建", - description: `单号 ${r.orderNo}(${r.status})`, + const openIdHint = typeof localStorage !== "undefined" ? localStorage.getItem("wz_wechat_openid")?.trim() ?? "" : "" + const ua = typeof navigator !== "undefined" ? navigator.userAgent : "" + const inWeChat = /MicroMessenger/i.test(ua) + const channel = inWeChat && openIdHint.length > 0 ? "jsapi" : "h5" + + const prepay = await appPaymentWechatPrepay({ + channel, + scene: "point_card", + pointCardOrderNo: r.orderNo, + openId: channel === "jsapi" ? openIdHint : undefined, }) + + if (prepay.channel === "h5" && prepay.h5Url) { + toast({ + title: "跳转微信支付", + description: `单号 ${r.orderNo},支付完成后订单将标记为已完成`, + }) + window.location.href = prepay.h5Url + return + } + + if (prepay.channel === "jsapi" && prepay.payParams) { + try { + await invokeWeixinJSBridgePayment(prepay.payParams) + } catch (e) { + toast({ + title: "支付未完成", + description: e instanceof Error ? e.message : "请稍后重试", + variant: "destructive", + }) + await syncFromDatabase() + return + } + toast({ title: "支付已提交", description: "正在同步订单状态…" }) + } + await syncFromDatabase() } catch (e) { toast({ @@ -118,7 +161,7 @@ export function MallPointsItemPage() { ) : null}

- 登录后下单;订单多为 pending 演示态,真实支付见《迁移-API对照表》。 + 登录后可微信下单支付;完成后订单状态会通过支付回调更新。微信内可走 JSAPI(需配置 localStorage 键 wz_wechat_openid)。

diff --git a/new/wz-app/src/pages/streamer/StreamerStudioPage.tsx b/new/wz-app/src/pages/streamer/StreamerStudioPage.tsx index 0f4b452..1174e06 100644 --- a/new/wz-app/src/pages/streamer/StreamerStudioPage.tsx +++ b/new/wz-app/src/pages/streamer/StreamerStudioPage.tsx @@ -105,7 +105,7 @@ export function StreamerStudioPage() { const r = await appStreamerLiveCloud(vendor) if (r.success && r.data) { setCloudUrls({ push: r.data.pushUrl, pull: r.data.pullUrl, streamName: r.data.streamName }) - setCloudNote(r.data.note ?? "服务端返回推流/播放占位 URL,生产环境将替换为云厂商签名地址") + setCloudNote(r.data.note ?? "推拉流地址可由云厂商侧配置,当前为预览用链接") toast({ title: "已获取推拉流信息" }) } else { toast({ title: "获取失败", description: typeof r.message === "string" ? r.message : "" }) diff --git a/开发文档/玩值/迁移-API对照表.md b/开发文档/玩值/迁移-API对照表.md index 3ace603..fcad848 100644 --- a/开发文档/玩值/迁移-API对照表.md +++ b/开发文档/玩值/迁移-API对照表.md @@ -8,6 +8,8 @@ - **已自该构建移除(未恢复则用不到)**:全体 **`/api/miniprogram/*`**、**`/api/db/*`**、书籍/章节/支付/提现/匹配等原 Soul 路由;**`GET /read/:id`**、**`GET /ws/miniprogram`**。若需上述能力须另开带 MySQL 的 legacy 构建或重新实现为 Mongo。 - **数据**:玩值集合以 Mongo **`ext_id`** 为主键字段;C 端账号在 **`app_users`** / **`user_auths`**,勿与玩值列表集合 **`users`** 混淆。详见 `new/wz-api/MIGRATION.md`。 +**C 端数据来源说明(2026-04-30)**:独立「短视频 Feed」接口未提供时,`/moments` 推荐内容由 **`GET /api/app/streamers`** 映射卡片;`/cp` 列表与详情使用 **`GET /api/app/stars`**、**`GET /api/app/stars/:id`**;商城「装扮 / 礼物 / 福利」及派对房送礼列表使用 **`GET /api/app/products`**,前端按类目关键词分桶(详见 `new/wz-app/src/lib/mall-product-buckets.ts`)。 + **状态**:`未实现` | `进行中` | `已实现` | `废弃` | # | old URL(逻辑路径) | 主要调用方 | 新 wz-api 路径 | 方法 | 状态 | @@ -78,9 +80,12 @@ | 64 | `/api/games` | App | `/api/app/games` | GET | 已实现 | | 65 | (C 端点卡目录) | wz-app | `/api/app/mall/point-cards` | GET | 已实现 | | 66 | (C 端点卡详情) | wz-app | `/api/app/mall/point-cards/:cardId` | GET | 已实现 | -| 67 | (C 端点卡下单) | wz-app | `/api/app/orders/point-card` | POST | 已实现(pending,未接支付) | +| 67 | (C 端点卡下单) | wz-app | `/api/app/orders/point-card` | POST | 已实现;创建 pending 订单后 **wz-app** 调 **`POST /api/app/payment/wechat/prepay`**(`scene: point_card`)走微信 H5/JSAPI | | 68 | (C 端我的订单) | wz-app | `/api/app/orders/me` | GET | 已实现(点卡 + 充值 Mongo,须登录 JWT) | -| 69 | (C 端钱包充值) | wz-app | `/api/app/wallet/recharge` | POST | 已实现(递增 user_balances,写 rechargeOrders);**wz-app** 充值成功后 **sync user/me** 刷新余额与订单 | +| 69 | (C 端钱包充值) | wz-app | `/api/app/wallet/recharge` | POST | 已实现(开发/补单用:直接加币);**生产主路径** 为微信 **`POST /api/app/payment/wechat/prepay`** + 回调入账 | +| 69b | (玩值·微信预下单) | wz-app | `/api/app/payment/wechat/prepay` | POST | 已实现;`channel`: h5 \| jsapi,`scene`: recharge \| point_card;回调 **`POST /api/app/payment/wechat/notify`**(`WECHAT_WZ_APP_NOTIFY_URL` / `API_BASE_URL`) | +| 69c | (玩值·微信支付回调) | 微信服务器 | `/api/app/payment/wechat/notify` | POST | 已实现(v3 验签;Mongo 幂等更新充值/点卡订单并发币/减库存) | +| 69d | (玩值·支付订单查询) | wz-app | `/api/app/payment/wechat/order-status?no=` | GET | 已实现;JWT,`status` 聚合为 completed/pending 等 | | 69a | (C 端玩值币消费) | wz-app | `/api/app/wallet/consume` | POST | 已实现(原子扣余额,productOrders + `wzAppConsume`);`GET /orders/me` 并入展示 | | 70 | (管理端直播房间详情) | 管理端 | `/api/admin/wz/live/rooms/:roomId` | GET | 已实现 | | 71 | `/api/build-info` | 运维/CI | `/api/public/build-info` | GET | 已实现;响应含 `buildTime`;可选 `gitCommit`(环境变量 **`GIT_COMMIT`**,服务端截断为短 hash,未设为空串) |