更新wz-app和wz-api项目,新增玩值C端支付相关API接口,包括微信支付的预支付、通知和订单状态查询,完善支付回调配置,提升支付功能的稳定性与用户体验。同时,优化了商城和装扮页面的加载逻辑,增强了用户界面的友好性与响应速度,确保文档与代码的一致性。

This commit is contained in:
乘风
2026-04-30 18:49:04 +08:00
parent f50090132d
commit eeb7812688
26 changed files with 1767 additions and 698 deletions

View File

@@ -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` 关键词,需在管理端规范命名以便归类。

View File

@@ -2,6 +2,7 @@
| 日期 | 主题 |
|------|------|
| 2026-04-30 | [wz-app 去 mockstreamers/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) |

View File

@@ -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可忽略
# 本地 Dockerredis-local-nwal 映射 6379:6379无密码用 db 0

View File

@@ -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,

View 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 JWTchannel=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,
}})
}

View File

@@ -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)

View File

@@ -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%scode=%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 {

View File

@@ -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

View File

@@ -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` |

View File

@@ -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>

View 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
}

View 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 }
}

View File

@@ -0,0 +1,78 @@
/**
* 微信内置浏览器拉起 JSAPIgetBrandWCPayRequest。依赖后端 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 || "支付失败")))
}
},
)
})
})
}

View File

@@ -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">

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) => (

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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">

View File

@@ -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 : "" })

View File

@@ -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未设为空串 |