更新wz-app和wz-api项目,新增玩值C端支付相关API接口,包括微信支付的预支付、通知和订单状态查询,完善支付回调配置,提升支付功能的稳定性与用户体验。同时,优化了商城和装扮页面的加载逻辑,增强了用户界面的友好性与响应速度,确保文档与代码的一致性。
This commit is contained in:
@@ -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` 关键词,需在管理端规范命名以便归类。
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
426
new/wz-api/internal/handler/playhub_wz_app_payment.go
Normal file
426
new/wz-api/internal/handler/playhub_wz_app_payment.go
Normal file
@@ -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,
|
||||
}})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & "
|
||||
"sleep 3; "
|
||||
'for p in $(pgrep -f wz-api 2>/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
|
||||
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
4
new/wz-app/dist/index.html
vendored
4
new/wz-app/dist/index.html
vendored
@@ -11,8 +11,8 @@
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>玩值电竞 - WanZhi Esports</title>
|
||||
<script type="module" crossorigin src="/assets/index-CilfF9Zo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BfFz-CMK.css">
|
||||
<script type="module" crossorigin src="/assets/index-ChhlhjH6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D5DM94su.css">
|
||||
</head>
|
||||
<body class="min-h-screen antialiased">
|
||||
<div id="root"></div>
|
||||
|
||||
47
new/wz-app/src/api/payment-wechat.ts
Normal file
47
new/wz-app/src/api/payment-wechat.ts
Normal file
@@ -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<string, string>
|
||||
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<WechatPrepayData>("/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<WechatPaymentOrderStatus | null> {
|
||||
const r = await request<WechatPaymentOrderStatus>(
|
||||
`/api/app/payment/wechat/order-status?no=${encodeURIComponent(orderNo)}`,
|
||||
{ method: "GET" },
|
||||
)
|
||||
if (!r.success || !r.data) return null
|
||||
return r.data as WechatPaymentOrderStatus
|
||||
}
|
||||
37
new/wz-app/src/lib/mall-product-buckets.ts
Normal file
37
new/wz-app/src/lib/mall-product-buckets.ts
Normal file
@@ -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 }
|
||||
}
|
||||
78
new/wz-app/src/lib/weixin-bridge-pay.ts
Normal file
78
new/wz-app/src/lib/weixin-bridge-pay.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 微信内置浏览器拉起 JSAPI(getBrandWCPayRequest)。依赖后端 Pay v3 BridgeConfig。
|
||||
*/
|
||||
declare global {
|
||||
interface Window {
|
||||
WeixinJSBridge?: {
|
||||
invoke: (
|
||||
name: string,
|
||||
args: Record<string, string>,
|
||||
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<string, string>): 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 || "支付失败")))
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export function CoachListPage() {
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">暂无大神数据 · 请先导入 stars</p>
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">暂时没有可展示的大神,请稍后再来</p>
|
||||
) : (
|
||||
<Tabs defaultValue="hot" className="w-full">
|
||||
<TabsList className="w-full bg-white/5 p-1 rounded-xl">
|
||||
|
||||
@@ -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<AppProductDTO[]>([])
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen pb-24 bg-background">
|
||||
<header className="sticky top-0 z-40 glass px-4 py-3 flex items-center gap-3">
|
||||
<button onClick={() => void navigate(-1)} className="p-2 -ml-2 hover:bg-white/5 rounded-lg transition-colors">
|
||||
<button onClick={() => void navigate(-1)} className="p-2 -ml-2 hover:bg-white/5 rounded-lg transition-colors" type="button">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="font-bold text-lg">我的装扮</h1>
|
||||
<div className="ml-auto flex items-center gap-2 text-xs bg-white/5 px-3 py-1.5 rounded-full">
|
||||
<Sparkles size={14} className="text-yellow-400" />
|
||||
<span className="text-white/80">数字头像商城</span>
|
||||
<span className="text-white/80">上架数据与商城同步</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="glass-card p-6 rounded-2xl mb-6 text-center border border-primary/20">
|
||||
<div className="w-24 h-24 mx-auto mb-3 rounded-full border-4 border-primary p-1 relative">
|
||||
<FillImage src="/avatar-cyberpunk.jpg" alt="Current Avatar" className="h-24 w-24 rounded-full" />
|
||||
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 bg-primary text-[10px] px-2 py-0.5 rounded-full text-white font-bold">
|
||||
LV.8
|
||||
</div>
|
||||
<div className="w-24 h-24 mx-auto mb-3 rounded-full border-4 border-primary p-1 relative overflow-hidden">
|
||||
<FillImage src={currentPreview} alt="" className="h-24 w-24 rounded-full object-cover" />
|
||||
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 bg-primary text-[10px] px-2 py-0.5 rounded-full text-black font-bold">展示</div>
|
||||
</div>
|
||||
<h3 className="font-bold mb-1">当前形象</h3>
|
||||
<p className="text-xs text-white/50 mb-4">展示您的独特个性</p>
|
||||
<Button size="sm" variant="outline" className="border-white/20 hover:bg-white/5 bg-transparent">
|
||||
更换头像
|
||||
<p className="text-xs text-white/50 mb-4">与资料头像一致;可在资料页更换基础头像</p>
|
||||
<Button type="button" size="sm" variant="outline" className="border-white/20 hover:bg-white/5 bg-transparent" onClick={() => navigate("/profile/edit")}>
|
||||
去编辑资料
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="avatar" className="w-full">
|
||||
<TabsList className="w-full bg-white/5 p-1 rounded-xl mb-4">
|
||||
<TabsTrigger value="avatar" className="flex-1 text-xs">
|
||||
数字头像
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="decoration" className="flex-1 text-xs">
|
||||
装饰道具
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="my" className="flex-1 text-xs">
|
||||
我的藏品
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-9 w-9 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="avatar" className="w-full">
|
||||
<TabsList className="w-full bg-white/5 p-1 rounded-xl mb-4">
|
||||
<TabsTrigger value="avatar" className="flex-1 text-xs">
|
||||
数字头像
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="decoration" className="flex-1 text-xs">
|
||||
装饰道具
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="my" className="flex-1 text-xs">
|
||||
我的藏品
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="avatar" className="space-y-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold text-sm">热门头像</h3>
|
||||
<button type="button" className="text-xs text-primary">
|
||||
查看全部 <TrendingUp size={12} className="inline ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{avatars.map((avatar) => (
|
||||
<div
|
||||
key={avatar.id}
|
||||
className="glass-card p-3 rounded-xl hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
toast({
|
||||
title: "购买成功",
|
||||
description: `已获得 ${avatar.name},消费 ¥${avatar.price}`,
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="relative w-full aspect-square rounded-lg overflow-hidden mb-2 bg-zinc-800">
|
||||
<FillImage src={avatar.image || "/placeholder.svg"} alt={avatar.name} className="h-full" />
|
||||
<div className="absolute top-2 right-2 bg-yellow-500/90 text-black text-[9px] px-1.5 py-0.5 rounded font-bold">
|
||||
{avatar.rarity}
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-bold text-sm mb-1 truncate">{avatar.name}</h4>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-white/50">{avatar.sales}人购买</span>
|
||||
<span className="text-primary font-bold">¥{avatar.price}</span>
|
||||
</div>
|
||||
<Button size="sm" className="w-full mt-2 h-7 text-xs bg-primary hover:bg-primary/90">
|
||||
<ShoppingCart size={12} className="mr-1" /> 购买
|
||||
</Button>
|
||||
<TabsContent value="avatar" className="space-y-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold text-sm">头像类商品</h3>
|
||||
<button type="button" className="text-xs text-primary" onClick={() => navigate("/mall")}>
|
||||
去商城中心 <TrendingUp size={12} className="inline ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
{avatars.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">暂无头像类上架,可先逛商城装扮区</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{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 (
|
||||
<div
|
||||
key={avatar.id}
|
||||
className="glass-card p-3 rounded-xl hover:bg-white/10 transition-colors cursor-pointer"
|
||||
onClick={() => handleBuy(avatar)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleBuy(avatar)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="relative w-full aspect-square rounded-lg overflow-hidden mb-2 bg-zinc-800">
|
||||
<FillImage src={img} alt="" className="h-full w-full object-cover" />
|
||||
<div className="absolute top-2 right-2 bg-yellow-500/90 text-black text-[9px] px-1.5 py-0.5 rounded font-bold line-clamp-1 max-w-[5rem]">
|
||||
{cat}
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-bold text-sm mb-1 truncate">{avatar.name ?? "商品"}</h4>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-white/50 truncate">{cat}</span>
|
||||
<span className="text-primary font-bold shrink-0 ml-1">{price} 币</span>
|
||||
</div>
|
||||
<Button type="button" size="sm" className="w-full mt-2 h-7 text-xs bg-primary hover:bg-primary/90 text-black">
|
||||
<ShoppingCart size={12} className="mr-1" /> 兑换
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="decoration" className="space-y-3">
|
||||
<div className="grid gap-3">
|
||||
{decorations.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="glass-card p-3 rounded-xl flex items-center gap-3 hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-lg overflow-hidden bg-zinc-800 relative">
|
||||
<FillImage src={item.image || "/placeholder.svg"} alt={item.name} className="h-full" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-sm mb-1">{item.name}</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] bg-white/10 px-2 py-0.5 rounded text-white/60">{item.type}</span>
|
||||
<span className="text-primary font-bold text-sm">¥{item.price}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" className="h-8 text-xs">
|
||||
购买
|
||||
</Button>
|
||||
<TabsContent value="decoration" className="space-y-3">
|
||||
{decorations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">暂无装饰类上架</p>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{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 (
|
||||
<div key={item.id} className="glass-card p-3 rounded-xl flex items-center gap-3 hover:bg-white/10 transition-colors">
|
||||
<div className="w-16 h-16 rounded-lg overflow-hidden bg-zinc-800 relative shrink-0">
|
||||
<FillImage src={img} alt="" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-bold text-sm mb-1 truncate">{item.name ?? "商品"}</h4>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] bg-white/10 px-2 py-0.5 rounded text-white/60">{typ}</span>
|
||||
<span className="text-primary font-bold text-sm">{price} 币</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" size="sm" className="h-8 text-xs shrink-0" onClick={() => handleBuy(item)}>
|
||||
兑换
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="my" className="text-center py-12">
|
||||
<Sparkles className="w-12 h-12 text-white/20 mx-auto mb-3" />
|
||||
<p className="text-sm text-white/40">暂无藏品</p>
|
||||
<p className="text-xs text-white/30 mt-1">购买数字头像和装扮后会显示在这里</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="my" className="text-center py-12">
|
||||
<Sparkles className="w-12 h-12 text-white/20 mx-auto mb-3" />
|
||||
<p className="text-sm text-white/40">「我的藏品」待个人装扮资产接口就绪后对接</p>
|
||||
<p className="text-xs text-white/30 mt-1">兑换记录可在订单相关页查看(若有)</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<DressCategory>("热门推荐")
|
||||
const [dressCategory, setDressCategory] = useState<string>("热门推荐")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dress, setDress] = useState<AppProductDTO[]>([])
|
||||
const [gift, setGift] = useState<AppProductDTO[]>([])
|
||||
const [exchange, setExchange] = useState<AppProductDTO[]>([])
|
||||
|
||||
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 (
|
||||
<div className="col-span-2 flex justify-center py-16">
|
||||
<Loader2 className="h-9 w-9 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (list.length === 0) {
|
||||
return <div className="col-span-2 py-12 text-center text-white/50 text-sm px-4">{empty}</div>
|
||||
}
|
||||
return list.map((item) => {
|
||||
const img = productImg(item)
|
||||
const price = Math.max(0, Math.round(Number(item.price) || 0))
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="glass-card rounded-xl p-4 flex flex-col items-center gap-3 group relative overflow-hidden border border-white/5 hover:border-primary/30"
|
||||
>
|
||||
<div className="relative w-20 h-20 flex items-center justify-center rounded-lg overflow-hidden bg-zinc-800">
|
||||
<FillImage src={img} alt="" className="h-full w-full object-cover transition-transform group-hover:scale-110" />
|
||||
</div>
|
||||
<div className="text-center relative z-10 min-w-0 w-full">
|
||||
<h3 className="font-bold text-sm mb-1 text-white/90 line-clamp-2">{item.name ?? "商品"}</h3>
|
||||
<div className="flex items-center justify-center gap-1 text-primary">
|
||||
<Coins size={12} />
|
||||
<span className="text-xs font-bold">{price} 币</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full py-2 mt-2 rounded-lg bg-white/5 hover:bg-primary hover:text-black transition-all text-xs font-bold relative z-10"
|
||||
onClick={() => handleBuy(price, String(item.name ?? "商品"), "product", img)}
|
||||
>
|
||||
{btn}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-24 bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-40 glass px-4 py-3 flex items-center justify-between">
|
||||
<h1 className="font-bold text-lg">商城中心</h1>
|
||||
<div
|
||||
@@ -77,7 +138,6 @@ export function MallPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Top Categories - 8 Items */}
|
||||
<div className="px-4 mt-6">
|
||||
<h3 className="text-sm font-bold mb-3 flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-yellow-400" />
|
||||
@@ -85,44 +145,14 @@ export function MallPage() {
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<Link
|
||||
key={i}
|
||||
@@ -157,9 +187,10 @@ export function MallPage() {
|
||||
|
||||
<TabsContent value="dress" className="mt-4">
|
||||
<div className="flex items-center gap-3 px-4 overflow-x-auto no-scrollbar pb-1 mb-4">
|
||||
{DRESS_CATEGORIES.map((cat) => (
|
||||
{dressChipLabels.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => 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() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-4 grid grid-cols-2 gap-4">
|
||||
{filteredDressItems.length === 0 ? (
|
||||
<div className="col-span-2 py-12 text-center text-white/50 text-sm">
|
||||
该分类暂无商品,敬请期待
|
||||
</div>
|
||||
) : (
|
||||
filteredDressItems.map((item, i) => (
|
||||
<div
|
||||
key={`${item.category}-${item.name}-${i}`}
|
||||
className="glass-card rounded-xl p-4 flex flex-col items-center gap-3 group relative overflow-hidden border border-white/5 hover:border-primary/30"
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-br ${item.color} opacity-0 group-hover:opacity-100 transition-opacity duration-500`}
|
||||
/>
|
||||
|
||||
<div className="relative w-20 h-20 flex items-center justify-center rounded-lg overflow-hidden bg-zinc-800">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center relative z-10">
|
||||
<h3 className="font-bold text-sm mb-1 text-white/90">{item.name}</h3>
|
||||
<div className="flex items-center justify-center gap-1 text-primary">
|
||||
<Coins size={12} />
|
||||
<span className="text-xs font-bold">{item.price} 币</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full py-2 mt-2 rounded-lg bg-white/5 hover:bg-primary hover:text-black transition-all text-xs font-bold relative z-10"
|
||||
onClick={() => handleBuy(item.price, item.name, "product", item.image)}
|
||||
>
|
||||
购买
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 grid grid-cols-2 gap-4">{renderProductGrid(filteredDress, "该分类暂时没有上架装扮,可先看看课程或点卡馆", "购买")}</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gift" className="mt-4 px-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div
|
||||
key={i}
|
||||
className="glass-card rounded-xl p-3 flex flex-col items-center gap-2 hover:bg-white/10 cursor-pointer transition-colors border border-white/5"
|
||||
onClick={() => handleBuy(item.price, item.name, "product")}
|
||||
>
|
||||
<div className="text-3xl filter drop-shadow-lg">{item.icon}</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xs font-bold">{item.name}</h3>
|
||||
<div className="flex items-center justify-center gap-1 text-primary mt-1">
|
||||
<Coins size={10} />
|
||||
<span className="text-[10px]">{item.price} 币</span>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="col-span-3 flex justify-center py-16">
|
||||
<Loader2 className="h-9 w-9 animate-spin text-primary" />
|
||||
</div>
|
||||
))}
|
||||
) : gift.length === 0 ? (
|
||||
<div className="col-span-3 py-12 text-center text-sm text-white/50">暂无礼物道具上架,敬请期待</div>
|
||||
) : (
|
||||
gift.map((item) => {
|
||||
const img = productImg(item)
|
||||
const price = Math.max(0, Math.round(Number(item.price) || 0))
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="glass-card rounded-xl p-3 flex flex-col items-center gap-2 hover:bg-white/10 transition-colors border border-white/5"
|
||||
onClick={() => handleBuy(price, String(item.name ?? "礼物"), "product", img)}
|
||||
>
|
||||
<div className="relative h-14 w-14 rounded-lg overflow-hidden bg-zinc-800">
|
||||
<FillImage src={img} alt="" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
<div className="text-center min-w-0 w-full">
|
||||
<h3 className="text-xs font-bold line-clamp-2">{item.name ?? "礼物"}</h3>
|
||||
<div className="flex items-center justify-center gap-1 text-primary mt-1">
|
||||
<Coins size={10} />
|
||||
<span className="text-[10px]">{price} 币</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="exchange" className="mt-4 px-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ name: "10元话费券", price: 1000, icon: Gift },
|
||||
{ name: "随机皮肤宝箱", price: 5000, icon: ShoppingBag },
|
||||
{ name: "改名卡", price: 2000, icon: Crown },
|
||||
{ name: "双倍经验卡", price: 500, icon: Zap },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="glass-card rounded-xl p-4 flex flex-col items-center gap-3 border border-white/5">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<item.icon className="text-primary" size={24} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-bold text-sm mb-1">{item.name}</h3>
|
||||
<div className="flex items-center justify-center gap-1 text-primary">
|
||||
<Coins size={12} />
|
||||
<span className="text-xs font-bold">{item.price} 币</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full h-7 text-xs border-primary/20 text-primary hover:bg-primary/10 bg-transparent"
|
||||
onClick={() => handleBuy(item.price, item.name, "product")}
|
||||
>
|
||||
兑换
|
||||
</Button>
|
||||
{loading ? (
|
||||
<div className="col-span-2 flex justify-center py-16">
|
||||
<Loader2 className="h-9 w-9 animate-spin text-primary" />
|
||||
</div>
|
||||
))}
|
||||
) : exchange.length === 0 ? (
|
||||
<div className="col-span-2 py-12 text-center text-sm text-white/50">暂无福利兑换项,可关注活动公告</div>
|
||||
) : (
|
||||
exchange.map((item) => {
|
||||
const img = productImg(item)
|
||||
const price = Math.max(0, Math.round(Number(item.price) || 0))
|
||||
return (
|
||||
<div key={item.id} className="glass-card rounded-xl p-4 flex flex-col items-center gap-3 border border-white/5">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center overflow-hidden">
|
||||
<FillImage src={img} alt="" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
<div className="text-center min-w-0 w-full">
|
||||
<h3 className="font-bold text-sm mb-1 line-clamp-2">{item.name ?? "兑换项"}</h3>
|
||||
<div className="flex items-center justify-center gap-1 text-primary">
|
||||
<Coins size={12} />
|
||||
<span className="text-xs font-bold">{price} 币</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full h-7 text-xs border-primary/20 text-primary hover:bg-primary/10 bg-transparent"
|
||||
type="button"
|
||||
onClick={() => handleBuy(price, String(item.name ?? "兑换"), "product", img)}
|
||||
>
|
||||
兑换
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -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<string, MomentEntry> = {
|
||||
"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 (
|
||||
<div className="flex h-screen items-center justify-center bg-black text-white">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!streamer) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-black px-4 pb-28 text-white">
|
||||
<p className="text-center text-sm text-white/60">未找到该内容或暂无展示信息</p>
|
||||
<Button type="button" variant="secondary" onClick={() => void navigate(-1)}>
|
||||
返回
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="h-screen bg-black text-white relative flex flex-col">
|
||||
@@ -61,9 +64,9 @@ export function MomentsDetailPage() {
|
||||
</button>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full overflow-hidden border border-white/20 relative shrink-0">
|
||||
<FillImage src={momentData.avatar} alt={momentData.user} className="h-8 w-8" />
|
||||
<FillImage src={avatarSrc} alt="" className="h-8 w-8 object-cover" />
|
||||
</div>
|
||||
<span className="font-bold text-sm truncate">{momentData.user}</span>
|
||||
<span className="font-bold text-sm truncate">{user}</span>
|
||||
<button type="button" className="px-3 py-1 rounded-full bg-primary text-black text-xs font-bold shrink-0" onClick={() => toast({ title: "已关注" })}>
|
||||
关注
|
||||
</button>
|
||||
@@ -71,15 +74,15 @@ export function MomentsDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative bg-zinc-900 min-h-0">
|
||||
<FillImage src={momentData.video} alt="Video" className="h-full w-full" />
|
||||
<FillImage src={cover} alt="" className="h-full w-full object-cover" />
|
||||
<div className="absolute right-2 bottom-32 flex flex-col items-center gap-6 z-20">
|
||||
<button type="button" className="flex flex-col items-center gap-1 group" onClick={handleLike}>
|
||||
<Heart size={32} className="text-white drop-shadow-lg group-hover:text-red-500 transition-colors" />
|
||||
<span className="text-xs font-medium drop-shadow-md">{momentData.likes}</span>
|
||||
<span className="text-xs font-medium drop-shadow-md">{likesLabel}</span>
|
||||
</button>
|
||||
<div className="flex flex-col items-center gap-1 group">
|
||||
<MessageCircle size={32} className="text-white drop-shadow-lg group-hover:text-primary transition-colors" />
|
||||
<span className="text-xs font-medium drop-shadow-md">{momentData.comments}</span>
|
||||
<span className="text-xs font-medium drop-shadow-md">评论</span>
|
||||
</div>
|
||||
<button type="button" className="flex flex-col items-center gap-1 group" onClick={handleShare}>
|
||||
<Share2 size={32} className="text-white drop-shadow-lg group-hover:text-green-400 transition-colors" />
|
||||
@@ -94,26 +97,24 @@ export function MomentsDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="absolute left-0 bottom-0 w-full p-4 z-20 bg-gradient-to-t from-black via-black/60 to-transparent pt-20">
|
||||
{momentData.hasCourse && (
|
||||
<Link
|
||||
to={`/course/${momentData.courseId}`}
|
||||
className="flex items-center justify-between w-full p-3 rounded-xl bg-white/10 backdrop-blur-md border border-white/10 mb-4 hover:bg-white/20 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/20 flex items-center justify-center text-primary shrink-0">
|
||||
<BookOpen size={20} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-primary font-bold mb-0.5">同款课程</div>
|
||||
<div className="text-sm font-bold text-white group-hover:text-primary transition-colors truncate">{momentData.courseTitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 rounded-lg bg-primary text-black text-xs font-bold shrink-0">去学习</div>
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Button type="button" size="sm" className="bg-primary text-black font-bold shrink-0" onClick={() => void navigate(`/mall/courses`)}>
|
||||
<BookOpen size={14} className="mr-1" />
|
||||
课程区
|
||||
</Button>
|
||||
{isLive ? (
|
||||
<Button type="button" size="sm" variant="outline" className="border-white/30 text-white" onClick={() => void navigate(`/live/${momentId}`)}>
|
||||
看直播
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" variant="outline" className="border-white/30 text-white" onClick={() => void navigate(`/star/${momentId}`)}>
|
||||
Ta 的主页
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold text-lg mb-2">@{momentData.user}</h3>
|
||||
<p className="text-sm text-white/90 leading-relaxed mb-4">{momentData.desc}</p>
|
||||
<h3 className="font-bold text-lg mb-2">@{user}</h3>
|
||||
<p className="text-sm text-white/90 leading-relaxed mb-4">{intro}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-10 bg-white/10 rounded-full px-4 flex items-center text-white/50 text-sm">说点什么...</div>
|
||||
@@ -121,6 +122,9 @@ export function MomentsDetailPage() {
|
||||
<Send size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<Link to="/live" className="mt-3 block text-center text-xs text-white/40">
|
||||
逛逛直播广场
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<number[]>([])
|
||||
const [likeCounts, setLikeCounts] = useState<Record<number, string>>({})
|
||||
const [commentForId, setCommentForId] = useState<number | null>(null)
|
||||
const [likedItems, setLikedItems] = useState<string[]>([])
|
||||
const [likeCounts, setLikeCounts] = useState<Record<string, string>>({})
|
||||
const [commentForId, setCommentForId] = useState<string | null>(null)
|
||||
const [commentDraft, setCommentDraft] = useState("")
|
||||
const [localComments, setLocalComments] = useState<Record<number, { text: string; at: string }[]>>({})
|
||||
const [localComments, setLocalComments] = useState<Record<string, { text: string; at: string }[]>>({})
|
||||
|
||||
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 ? (
|
||||
<div className={`flex flex-col items-center justify-center gap-3 text-white/60 ${slideH}`}>
|
||||
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
||||
<span className="text-sm">加载推荐内容…</span>
|
||||
</div>
|
||||
) : feed.length === 0 ? (
|
||||
<div className={`flex flex-col items-center justify-center gap-3 px-6 text-center text-white/50 ${slideH}`}>
|
||||
<p className="text-sm font-medium text-white/70">暂时没有推荐动态</p>
|
||||
<p className="text-xs text-white/40">主播开播后会上线更多信息,可先逛逛直播广场</p>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => navigate("/live")}>
|
||||
直播广场
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
feed.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`group relative bg-zinc-900 ${slideH}`}
|
||||
>
|
||||
<div className="absolute inset-0 h-full w-full">
|
||||
<FillImage src={item.video || "/placeholder.svg"} alt="Content" className="h-full w-full object-cover" />
|
||||
<FillImage src={item.cover || "/placeholder.svg"} alt="" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-5"
|
||||
onClick={() => goMomentDetail(item.id)}
|
||||
aria-label={`查看${item.user}动态详情`}
|
||||
aria-label={`查看${item.user}的动态详情`}
|
||||
/>
|
||||
<div className="from-black/20 absolute inset-0 bg-linear-to-b via-transparent to-black/60" />
|
||||
{item.isLive && (
|
||||
@@ -224,14 +203,14 @@ export function MomentsPage() {
|
||||
直播中
|
||||
</div>
|
||||
)}
|
||||
{item.type === "live" && (
|
||||
{item.isLive && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-10 flex items-center justify-center"
|
||||
onClick={() => goLive(item.roomId)}
|
||||
onClick={() => openLive(item.id)}
|
||||
>
|
||||
<div className="bg-primary opacity-0 transition-opacity group-hover:opacity-100 px-6 py-3 text-white font-bold rounded-full shadow-lg transform hover:scale-105">
|
||||
点击进入直播间
|
||||
进入直播间
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -244,14 +223,14 @@ export function MomentsPage() {
|
||||
<span className="text-[10px] font-bold text-white">+</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="flex flex-col items-center gap-1" onClick={() => handleLike(item.id, item.likes)}>
|
||||
<button type="button" className="flex flex-col items-center gap-1" onClick={() => handleLike(item.id, item.likesLabel)}>
|
||||
<Heart
|
||||
size={32}
|
||||
className={`drop-shadow-md transition-all ${
|
||||
likedItems.includes(item.id) ? "text-red-500 fill-red-500 scale-110" : "text-white"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs font-bold drop-shadow-md">{likeCounts[item.id] || item.likes}</span>
|
||||
<span className="text-xs font-bold drop-shadow-md">{likeCounts[item.id] || item.likesLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -283,23 +262,14 @@ export function MomentsPage() {
|
||||
<div className="absolute right-20 bottom-28 left-4 z-20">
|
||||
<h3 className="drop-shadow-md mb-2 text-lg font-bold">@{item.user}</h3>
|
||||
<p className="drop-shadow-md line-clamp-2 text-sm leading-relaxed opacity-90">{item.desc}</p>
|
||||
{item.hasCourse && "courseId" in item && item.courseId && (
|
||||
<button
|
||||
type="button"
|
||||
className="border-white/10 bg-black/40 hover:bg-black/60 mt-3 inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 backdrop-blur-md transition-colors"
|
||||
onClick={() => navigate(`/course/${item.courseId}`)}
|
||||
>
|
||||
<Trophy size={14} className="text-yellow-400" />
|
||||
<span className="text-xs font-bold text-yellow-100">同款课程: {item.courseTitle}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Music size={12} className="text-white/60" />
|
||||
<div className="text-white/60 truncate text-xs">原声 - {item.user}</div>
|
||||
<div className="text-white/60 truncate text-xs">{item.user} · 推荐</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
))}
|
||||
{activeTab === "follow" && (
|
||||
<div className={`text-white/50 flex flex-col items-center justify-center gap-4 ${slideH}`}>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/10">
|
||||
|
||||
@@ -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<ChatMsg[]>([{ user: "系统", content: "加载房间信息…", type: "system" }])
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const [giftItems, setGiftItems] = useState<Array<{ id: string; name: string; price: number; image: string }>>([])
|
||||
|
||||
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() {
|
||||
<p className="text-white/60 text-[10px] flex items-center gap-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={10} />
|
||||
在线人数待实时通道
|
||||
房间互动升级中
|
||||
</span>
|
||||
<span>{partyInfo.game}</span>
|
||||
</p>
|
||||
@@ -235,18 +244,24 @@ export function PartyDetailPage() {
|
||||
<DialogTitle>送礼物给 {partyInfo.host}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-3 gap-3 py-4">
|
||||
{GIFTS.map((gift) => (
|
||||
<button
|
||||
key={gift.id}
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<span className="text-3xl">{gift.icon}</span>
|
||||
<span className="text-xs font-bold">{gift.name}</span>
|
||||
<span className="text-[10px] text-primary">{gift.price} 币</span>
|
||||
</button>
|
||||
))}
|
||||
{giftItems.length === 0 ? (
|
||||
<p className="col-span-3 text-center text-sm text-white/50 py-6">礼物列表由商城上架后同步,请稍后再试</p>
|
||||
) : (
|
||||
giftItems.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div className="h-11 w-11 rounded-lg overflow-hidden bg-zinc-800">
|
||||
{g.image ? <FillImage src={g.image} alt="" className="h-full w-full object-cover" /> : <span className="flex h-full items-center justify-center text-2xl">🎁</span>}
|
||||
</div>
|
||||
<span className="text-xs font-bold line-clamp-2 text-center">{g.name}</span>
|
||||
<span className="text-[10px] text-primary">{g.price} 币</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<number | null>(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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-white/30">所选档位将兑换为对应数量的玩值币;体验期可能为先到账、后接支付渠道的模式,请以实际为准</p>
|
||||
<p className="text-center text-xs text-white/30">
|
||||
微信支付为真实订单;档位与到账玩值币以后端校验为准。微信内置浏览器可把 openId 存入 localStorage 键{' '}
|
||||
<span className="font-mono">wz_wechat_openid</span> 以启用 JSAPI(需与商户配置的小程序绑定一致)。
|
||||
</p>
|
||||
<p className="text-center text-xs text-white/25">充值即代表同意《用户充值协议》</p>
|
||||
</div>
|
||||
|
||||
@@ -152,12 +247,12 @@ export function RechargePage() {
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleRecharge}
|
||||
disabled={selectedAmount === null}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<CreditCard size={18} className="mr-2" />
|
||||
立即充值
|
||||
{payBusy ? <Loader2 size={18} className="mr-2 animate-spin" /> : <CreditCard size={18} className="mr-2" />}
|
||||
{payBusy ? "处理中…" : "立即充值"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 ? "已取消关注" : "已关注" })
|
||||
}}
|
||||
>
|
||||
<Heart className="w-4 h-4 mr-1" />
|
||||
@@ -184,7 +184,7 @@ export function StarDetailPage() {
|
||||
|
||||
<h3 className="font-bold mb-3 flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-primary" />
|
||||
快捷下单(占位价 · 可走玩值币扣款)
|
||||
快捷下单 · 玩值币支付
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{services.map((svc) => (
|
||||
|
||||
@@ -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<CpVm | null>(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<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") handleSendMessage()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background pb-24">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!cp) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 pt-14 pb-24">
|
||||
<p className="text-center text-sm text-muted-foreground">未找到这位搭档或已下架</p>
|
||||
<Button type="button" variant="outline" className="mt-6 mx-auto flex" onClick={() => void navigate("/cp")}>
|
||||
返回列表
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background pb-24 relative">
|
||||
<div className="h-48 relative">
|
||||
<FillImage src={cp.cover || "/placeholder.svg"} alt="Cover" className="h-48" />
|
||||
<FillImage src={cp.cover || "/placeholder.svg"} alt="" className="h-48 object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-background" />
|
||||
<button onClick={() => 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">
|
||||
<ArrowLeft size={20} />
|
||||
@@ -138,7 +162,7 @@ export function CpDetailPage() {
|
||||
<div className="px-4 -mt-12 relative z-10">
|
||||
<div className="flex items-end justify-between mb-4">
|
||||
<div className="w-24 h-24 rounded-2xl border-4 border-background bg-muted relative overflow-hidden shadow-xl">
|
||||
<FillImage src={cp.avatar || "/placeholder.svg"} alt="Avatar" className="h-24 w-24 rounded-2xl" />
|
||||
<FillImage src={cp.avatar || "/placeholder.svg"} alt="" className="h-24 w-24 rounded-2xl object-cover" />
|
||||
{cp.online && <div className="absolute bottom-0 right-0 w-5 h-5 rounded-full bg-green-500 border-2 border-background z-10" />}
|
||||
</div>
|
||||
<div className="flex gap-2 mb-2">
|
||||
@@ -154,7 +178,7 @@ export function CpDetailPage() {
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 text-xs bg-pink-500 gap-1 hover:bg-pink-600" type="button" onClick={handleSendRequest}>
|
||||
<MessageCircle size={14} />
|
||||
发送请求
|
||||
聊一聊
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,25 +186,21 @@ export function CpDetailPage() {
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h1 className="text-2xl font-bold">{cp.name}</h1>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[10px] border font-bold ${cp.gender === "女" ? "bg-pink-500/20 text-pink-400 border-pink-500/30" : "bg-blue-500/20 text-blue-400 border-blue-500/30"}`}
|
||||
>
|
||||
{cp.gender} · {cp.age}岁
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] border font-bold bg-white/10 text-white/80 border-white/20">{cp.badge}</span>
|
||||
</div>
|
||||
<p className="text-sm text-white/60 mb-3">{cp.bio}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-white/40">
|
||||
<div className="flex items-center gap-4 text-xs text-white/40 flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star size={12} className="text-primary" />
|
||||
{cp.voice}
|
||||
{cp.voiceLabel}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Trophy size={12} className="text-accent" />
|
||||
{cp.matches} 场游戏
|
||||
热度 {cp.matchesStat}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={12} className="text-secondary" />
|
||||
{cp.friends} 位好友
|
||||
关注参考 {cp.friendsStat}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,27 +208,35 @@ export function CpDetailPage() {
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="font-bold text-sm flex items-center gap-2">
|
||||
<Gamepad2 size={16} className="text-primary" />
|
||||
擅长游戏
|
||||
擅长方向
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{cp.games.map((item) => (
|
||||
<div key={item.game} className="glass-card p-3 rounded-xl">
|
||||
<div className="font-bold text-sm mb-1">{item.game}</div>
|
||||
<div className="text-xs text-white/50">{item.rank}</div>
|
||||
<div className="text-xs text-primary mt-1">{item.role}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{cp.games.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">上架信息暂未填报具体对局类目</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{cp.games.map((item) => (
|
||||
<div key={item.game} className="glass-card p-3 rounded-xl">
|
||||
<div className="font-bold text-sm mb-1">{item.game}</div>
|
||||
<div className="text-xs text-white/50">{item.rank}</div>
|
||||
{item.role ? <div className="text-xs text-primary mt-1">{item.role}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="font-bold text-sm">个人标签</h3>
|
||||
<h3 className="font-bold text-sm">标签</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cp.tags.map((tag) => (
|
||||
<span key={tag} className="px-3 py-1.5 rounded-full bg-pink-500/10 border border-pink-500/30 text-xs text-pink-400">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{cp.tags.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">暂无标签</span>
|
||||
) : (
|
||||
cp.tags.map((tag) => (
|
||||
<span key={tag} className="px-3 py-1.5 rounded-full bg-pink-500/10 border border-pink-500/30 text-xs text-pink-400">
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -216,8 +244,8 @@ export function CpDetailPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield size={24} className="text-pink-500" />
|
||||
<div>
|
||||
<h4 className="font-bold text-sm">平台实名认证</h4>
|
||||
<p className="text-xs text-white/60">真实身份,安全交友</p>
|
||||
<h4 className="font-bold text-sm">安全提示</h4>
|
||||
<p className="text-xs text-white/60">避免线下私交易,服务保障以平台订单为准</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,19 +260,19 @@ export function CpDetailPage() {
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-muted relative overflow-hidden">
|
||||
<FillImage src={cp.avatar || "/placeholder.svg"} alt={cp.name} className="h-8 w-8 rounded-full" />
|
||||
<FillImage src={cp.avatar || "/placeholder.svg"} alt="" className="h-8 w-8 rounded-full object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm">{cp.name}</div>
|
||||
<div className="text-[10px] text-green-400">在线</div>
|
||||
<div className="text-[10px] text-green-400">{cp.online ? "在线" : "离线"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-2 hover:bg-white/5 rounded-full" type="button">
|
||||
<button className="p-2 hover:bg-white/5 rounded-full opacity-40" type="button" aria-label="电话">
|
||||
<Phone size={18} />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-white/5 rounded-full" type="button">
|
||||
<button className="p-2 hover:bg-white/5 rounded-full opacity-40" type="button" aria-label="视频">
|
||||
<Video size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="glass-card p-4 rounded-xl hover:bg-white/5 transition-colors">
|
||||
<div className="flex items-start gap-3 mb-3 cursor-pointer" onClick={onOpen} onKeyDown={(e) => e.key === "Enter" && onOpen()} role="button" tabIndex={0}>
|
||||
<div
|
||||
className="flex items-start gap-3 mb-3 cursor-pointer"
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => e.key === "Enter" && onOpen()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-lg bg-muted relative overflow-hidden">
|
||||
<FillImage src={cp.avatar || "/placeholder.svg"} alt={cp.name} className="h-16 w-16" />
|
||||
<FillImage src={cp.avatar || "/placeholder.svg"} alt={cp.name} className="h-16 w-16 object-cover" />
|
||||
</div>
|
||||
{cp.online && <div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-green-500 border-2 border-background" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-bold truncate">{cp.name}</h4>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] ${cp.gender === "女" ? "bg-pink-500/20 text-pink-400" : "bg-blue-500/20 text-blue-400"}`}>
|
||||
{cp.gender} · {cp.age}岁
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] bg-white/10 text-white/70">{cp.rankHint}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-white/50 mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star size={12} className="text-primary" />
|
||||
{cp.rank}
|
||||
</span>
|
||||
<span>{cp.voice}</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/60 mb-2 line-clamp-1">{cp.bio}</p>
|
||||
{cp.subtitle ? (
|
||||
<div className="text-xs text-white/50 mb-2 line-clamp-1">{cp.subtitle}</div>
|
||||
) : null}
|
||||
<p className="text-xs text-white/60 mb-2 line-clamp-2">{cp.bio}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cp.tags.map((tag) => (
|
||||
{cp.tags.slice(0, 6).map((tag) => (
|
||||
<span key={tag} className="px-2 py-0.5 rounded-full bg-white/5 text-[10px] text-white/60">
|
||||
{tag}
|
||||
</span>
|
||||
@@ -55,13 +77,19 @@ function CpCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button size="sm" variant="outline" className="flex-1 h-8 text-xs border-white/10 hover:bg-white/5 bg-transparent" type="button" onClick={(e) => (e.stopPropagation(), onContact())}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 h-8 text-xs border-white/10 hover:bg-white/5 bg-transparent"
|
||||
type="button"
|
||||
onClick={(e) => (e.stopPropagation(), onContact())}
|
||||
>
|
||||
<MessageCircle size={14} className="mr-1" />
|
||||
打个招呼
|
||||
打招呼
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1 h-8 text-xs bg-pink-500 hover:bg-pink-600" type="button" onClick={(e) => (e.stopPropagation(), onOpen())}>
|
||||
<Heart size={14} className="mr-1" />
|
||||
查看详情
|
||||
详情
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,14 +98,30 @@ function CpCard({
|
||||
|
||||
export function CpListPage() {
|
||||
const navigate = useNavigate()
|
||||
const [rows, setRows] = useState<CpRow[]>([])
|
||||
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() {
|
||||
<button onClick={() => void navigate(-1)} className="p-2 -ml-2 hover:bg-white/5 rounded-lg transition-colors" type="button">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="font-bold text-lg">电竞CP</h1>
|
||||
<h1 className="font-bold text-lg">电竞 CP</h1>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-6">
|
||||
@@ -94,33 +138,45 @@ export function CpListPage() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold text-sm flex items-center gap-2">
|
||||
<Heart className="text-pink-500" size={18} />
|
||||
找个游戏CP
|
||||
找个游戏搭档
|
||||
</h3>
|
||||
<Sparkles className="text-pink-500 animate-pulse" size={18} />
|
||||
</div>
|
||||
<p className="text-xs text-white/60 mb-3">一起开黑,一起上分,遇见你的电竞知己</p>
|
||||
<p className="text-xs text-white/60 mb-3">数据来源:大神池(上架信息),请以实际接单与客服说明为准。</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<TabsList className="w-full bg-white/5 p-1 rounded-xl">
|
||||
<TabsTrigger value="all" className="flex-1 text-xs">
|
||||
全部
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="girl" className="flex-1 text-xs">
|
||||
女生
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="boy" className="flex-1 text-xs">
|
||||
男生
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{(["all", "girl", "boy"] as const).map((tab) => (
|
||||
<TabsContent key={tab} value={tab} className="mt-4 space-y-3">
|
||||
{filterTab(tab).map((cp) => (
|
||||
<CpCard key={cp.id} cp={cp} onOpen={() => void navigate(`/cp/${cp.id}`)} onContact={() => handleContact(cp.name)} />
|
||||
))}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-9 w-9 animate-spin text-primary" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">暂时没有可展示的搭档人选,晚点再来看看</p>
|
||||
) : (
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<TabsList className="w-full bg-white/5 p-1 rounded-xl">
|
||||
<TabsTrigger value="all" className="flex-1 text-xs">
|
||||
全部
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="girl" className="flex-1 text-xs">
|
||||
偏女声向
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="boy" className="flex-1 text-xs">
|
||||
偏男声向
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{(["all", "girl", "boy"] as const).map((tab) => (
|
||||
<TabsContent key={tab} value={tab} className="mt-4 space-y-3">
|
||||
{filterTab(tab).length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">这个分类暂时没有匹配项</p>
|
||||
) : (
|
||||
filterTab(tab).map((cp) => (
|
||||
<CpCard key={cp.id} cp={cp} onOpen={() => void navigate(`/cp/${encodeURIComponent(cp.id)}`)} onContact={() => handleContact(cp.name)} />
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<MallPointCard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState<string | null>(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}
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
登录后下单;订单多为 pending 演示态,真实支付见《迁移-API对照表》。
|
||||
登录后可微信下单支付;完成后订单状态会通过支付回调更新。微信内可走 JSAPI(需配置 localStorage 键 wz_wechat_openid)。
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<Link to="/mall/points" className="text-xs text-primary hover:underline">
|
||||
|
||||
@@ -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 : "" })
|
||||
|
||||
@@ -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,未设为空串) |
|
||||
|
||||
Reference in New Issue
Block a user