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
-
+
当前形象
-
展示您的独特个性
-
- 更换头像
+ 与资料头像一致;可在资料页更换基础头像
+ navigate("/profile/edit")}>
+ 去编辑资料
-
-
-
- 数字头像
-
-
- 装饰道具
-
-
- 我的藏品
-
-
+ {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}
-
-
- 购买
-
+
+
+
头像类商品
+ navigate("/mall")}>
+ 去商城中心
+
+
+ {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}
+ >
+
+
{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} 币
+
+
+
handleBuy(item)}>
+ 兑换
+
+
+ )
+ })}
- ))}
-
-
+ )}
+
-
-
- 暂无藏品
- 购买数字头像和装扮后会显示在这里
-
-
+
+
+ 「我的藏品」待个人装扮资产接口就绪后对接
+ 兑换记录可在订单相关页查看(若有)
+
+
+ )}
)
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} 币
+
+
+
handleBuy(price, String(item.name ?? "商品"), "product", img)}
+ >
+ {btn}
+
+
+ )
+ })
+ }
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) => (
setDressCategory(cat)}
className={`whitespace-nowrap text-xs font-bold px-3 py-1.5 rounded-full transition-colors ${
dressCategory === cat ? "bg-primary text-black" : "bg-white/5 text-white/50 hover:bg-white/10"
@@ -170,108 +201,82 @@ export function MallPage() {
))}
-
- {filteredDressItems.length === 0 ? (
-
- 该分类暂无商品,敬请期待
-
- ) : (
- filteredDressItems.map((item, i) => (
-
-
-
-
-

-
-
-
-
{item.name}
-
-
- {item.price} 币
-
-
-
-
handleBuy(item.price, item.name, "product", item.image)}
- >
- 购买
-
-
- ))
- )}
-
+
{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 (
+
handleBuy(price, String(item.name ?? "礼物"), "product", img)}
+ >
+
+
+
+
+
{item.name ?? "礼物"}
+
+
+ {price} 币
+
+
+
+ )
+ })
+ )}
- {[
- { 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} 币
-
-
-
handleBuy(item.price, item.name, "product")}
- >
- 兑换
-
+ {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} 币
+
+
+
handleBuy(price, String(item.name ?? "兑换"), "product", img)}
+ >
+ 兑换
+
+
+ )
+ })
+ )}
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 (
+
+
未找到该内容或暂无展示信息
+
void navigate(-1)}>
+ 返回
+
+
+ )
+ }
+
+ 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}
toast({ title: "已关注" })}>
关注
@@ -71,15 +74,15 @@ export function MomentsDetailPage() {
-
+
- {momentData.likes}
+ {likesLabel}
- {momentData.comments}
+ 评论
@@ -94,26 +97,24 @@ export function MomentsDetailPage() {
- {momentData.hasCourse && (
-
-
-
-
-
-
-
同款课程
-
{momentData.courseTitle}
-
-
-
去学习
-
- )}
+
+ void navigate(`/mall/courses`)}>
+
+ 课程区
+
+ {isLive ? (
+ void navigate(`/live/${momentId}`)}>
+ 看直播
+
+ ) : (
+ void navigate(`/star/${momentId}`)}>
+ Ta 的主页
+
+ )}
+
-
@{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 ? (
+
+
暂时没有推荐动态
+
主播开播后会上线更多信息,可先逛逛直播广场
+
navigate("/live")}>
+ 直播广场
+
+
+ ) : (
+ feed.map((item) => (
-
+
goMomentDetail(item.id)}
- aria-label={`查看${item.user}动态详情`}
+ aria-label={`查看${item.user}的动态详情`}
/>
{item.isLive && (
@@ -224,14 +203,14 @@ export function MomentsPage() {
直播中
)}
- {item.type === "live" && (
+ {item.isLive && (
goLive(item.roomId)}
+ onClick={() => openLive(item.id)}
>
- 点击进入直播间
+ 进入直播间
)}
@@ -244,14 +223,14 @@ export function MomentsPage() {
+
- handleLike(item.id, item.likes)}>
+ handleLike(item.id, item.likesLabel)}>
- {likeCounts[item.id] || item.likes}
+ {likeCounts[item.id] || item.likesLabel}
@{item.user}
{item.desc}
- {item.hasCourse && "courseId" in item && item.courseId && (
- navigate(`/course/${item.courseId}`)}
- >
-
- 同款课程: {item.courseTitle}
-
- )}
-
原声 - {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) => (
-
handleSendGift(gift)}
- className="flex flex-col items-center gap-2 p-3 bg-white/5 rounded-xl hover:bg-white/10 transition-colors border border-white/5 hover:border-pink-500/30 active:scale-95"
- >
- {gift.icon}
- {gift.name}
- {gift.price} 币
-
- ))}
+ {giftItems.length === 0 ? (
+
礼物列表由商城上架后同步,请稍后再试
+ ) : (
+ giftItems.map((g) => (
+
void handleSendGift(g)}
+ className="flex flex-col items-center gap-2 p-3 bg-white/5 rounded-xl hover:bg-white/10 transition-colors border border-white/5 hover:border-pink-500/30 active:scale-95"
+ >
+
+ {g.image ? : 🎁}
+
+ {g.name}
+ {g.price} 币
+
+ ))
+ )}
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() {
void handleRecharge()}
+ disabled={selectedAmount === null || payBusy}
className="w-full h-12 bg-gradient-to-r from-primary to-cyan-500 hover:from-primary/90 hover:to-cyan-500/90 text-white font-bold text-base rounded-xl disabled:opacity-50 disabled:cursor-not-allowed"
>
-
- 立即充值
+ {payBusy ? : }
+ {payBusy ? "处理中…" : "立即充值"}
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 (
+
+
未找到这位搭档或已下架
+
void navigate("/cp")}>
+ 返回列表
+
+
+ )
+ }
+
return (
-
+
void navigate(-1)} className="absolute top-4 left-4 p-2 rounded-full bg-black/20 backdrop-blur-md text-white z-10" type="button">
@@ -138,7 +162,7 @@ export function CpDetailPage() {
@@ -154,7 +178,7 @@ export function CpDetailPage() {
- 发送请求
+ 聊一聊
@@ -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.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({
- (e.stopPropagation(), onContact())}>
+ (e.stopPropagation(), onContact())}
+ >
- 打个招呼
+ 打招呼
(e.stopPropagation(), onOpen())}>
- 查看详情
+ 详情
@@ -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() {
void navigate(-1)} className="p-2 -ml-2 hover:bg-white/5 rounded-lg transition-colors" type="button">
- 电竞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,未设为空串) |