feat: 优化发货交付页面与凭据生成,支持一键复制结果图片及官方商品图片本地缓存
- 交付界面重构:隐藏内部订单号、流水号与发货渠道,展示聚焦于买家游戏UID、角色名与交付时间 - 发货结果凭据生成:采用 Canvas 生成高清发货存证图,优化存证说明文案与自适应排版 - 一键复制图片:支持直接将结果凭证图片写入系统剪贴板,方便客服在聊天窗口快捷粘贴 - 官方图片本地化缓存:全量下载乐玩商品官方主图并建立本地持久化路由,规避外部源站跨域及破损问题 - 修复乐玩渠道角色UID提取与履约状态幂等回填逻辑
@@ -9,6 +9,7 @@ frontend/.env
|
||||
|
||||
# backend
|
||||
backend/bin/
|
||||
data/
|
||||
backend/data/
|
||||
backend/logs/
|
||||
/logs/
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/config"
|
||||
"affiliate_dash/internal/pkg/lewan"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
client := lewan.NewClient(lewan.Config{
|
||||
BaseURL: cfg.LewanBaseURL,
|
||||
AccessKey: cfg.LewanAccessKey,
|
||||
SecretKey: cfg.LewanSecretKey,
|
||||
ApplicationID: cfg.LewanApplicationID,
|
||||
}, nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
list, err := client.ListProducts(ctx, 1, 100, "")
|
||||
if err != nil {
|
||||
fmt.Printf("ListProducts error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d products (total: %d)\n", len(list.Items), list.Total)
|
||||
uploadDir := cfg.UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "data/uploads"
|
||||
}
|
||||
cacheDir := filepath.Join(uploadDir, "products")
|
||||
_ = os.MkdirAll(cacheDir, 0755)
|
||||
|
||||
httpClient := &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
for i, item := range list.Items {
|
||||
fmt.Printf("[%d/%d] ID: %s, Name: %s, Thumb: %s\n", i+1, len(list.Items), item.ID, item.Name, item.ThumbnailUrl)
|
||||
if item.ThumbnailUrl == "" {
|
||||
continue
|
||||
}
|
||||
// 存储为 item.ID + 原扩展名
|
||||
ext := filepath.Ext(item.ThumbnailUrl)
|
||||
if ext == "" || len(ext) > 5 {
|
||||
ext = ".webp"
|
||||
}
|
||||
destPath := filepath.Join(cacheDir, item.ID+ext)
|
||||
// 同时也用 url hash 存一个软链或备份,以便 proxy 按 url 精准命中
|
||||
urlHash := sha256.Sum256([]byte(item.ThumbnailUrl))
|
||||
hashPath := filepath.Join(cacheDir, hex.EncodeToString(urlHash[:])+ext)
|
||||
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
fmt.Printf(" -> 已存在缓存: %s\n", destPath)
|
||||
continue
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, item.ThumbnailUrl, nil)
|
||||
if err != nil {
|
||||
fmt.Printf(" -> 构建请求失败: %v\n", err)
|
||||
continue
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf(" -> 下载失败: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf(" -> 状态码错误: %d\n", resp.StatusCode)
|
||||
resp.Body.Close()
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
fmt.Printf(" -> 读取失败: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
_ = os.WriteFile(destPath, data, 0644)
|
||||
_ = os.WriteFile(hashPath, data, 0644)
|
||||
fmt.Printf(" -> 成功缓存: %s (大小: %d 字节)\n", destPath, len(data))
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@ func main() {
|
||||
lewanProvider := service.NewLewanProvider(lewanClient)
|
||||
deliverySvc.RegisterProvider(lewanProvider)
|
||||
fulfillmentSvc.SetUpstreamCancel(deliverySvc.CancelUpstream)
|
||||
fulfillmentSvc.SetUpstreamSync(deliverySvc.SyncDeliveringOrder)
|
||||
|
||||
openlog.Init(cfg.OpenAPIDebug)
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/pkg/response"
|
||||
"affiliate_dash/internal/service"
|
||||
@@ -65,6 +72,88 @@ func (h *DeliveryHandler) Submit(c *gin.Context) {
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
// ImageProxy 代理图片并支持本地持久化缓存,杜绝跨域污染和远端加载失败。
|
||||
func (h *DeliveryHandler) ImageProxy(c *gin.Context) {
|
||||
rawURL := strings.TrimSpace(c.Query("url"))
|
||||
if rawURL == "" || (!strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://")) {
|
||||
response.BadRequest(c, "无效的图片地址")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 优先检查本地官方商品图片缓存
|
||||
for _, base := range []string{"data/uploads/products", "../data/uploads/products", "uploads/products"} {
|
||||
if strings.Contains(rawURL, "/products/") {
|
||||
parts := strings.Split(rawURL, "/products/")
|
||||
if len(parts) > 1 {
|
||||
sub := strings.Split(parts[1], "/")
|
||||
if len(sub) > 0 && sub[0] != "" {
|
||||
localPath := filepath.Join(base, sub[0]+".webp")
|
||||
if info, err := os.Stat(localPath); err == nil && info.Size() > 0 {
|
||||
c.Header("Cache-Control", "public, max-age=2592000, immutable")
|
||||
c.File(localPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 按 URL SHA256 哈希查本地缓存
|
||||
hash := sha256.Sum256([]byte(rawURL))
|
||||
hashPath := filepath.Join(base, hex.EncodeToString(hash[:])+".webp")
|
||||
if info, err := os.Stat(hashPath); err == nil && info.Size() > 0 {
|
||||
c.Header("Cache-Control", "public, max-age=2592000, immutable")
|
||||
c.File(hashPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 本地无缓存时请求远端
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "构建请求失败")
|
||||
return
|
||||
}
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadGateway, http.StatusBadGateway, "图片下载失败")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
response.Fail(c, resp.StatusCode, resp.StatusCode, "远端图片响应异常")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadGateway, http.StatusBadGateway, "读取图片内容失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 异步/同步回写到本地磁盘缓存
|
||||
cacheDir := "data/uploads/products"
|
||||
_ = os.MkdirAll(cacheDir, 0755)
|
||||
if strings.Contains(rawURL, "/products/") {
|
||||
parts := strings.Split(rawURL, "/products/")
|
||||
if len(parts) > 1 {
|
||||
sub := strings.Split(parts[1], "/")
|
||||
if len(sub) > 0 && sub[0] != "" {
|
||||
_ = os.WriteFile(filepath.Join(cacheDir, sub[0]+".webp"), data, 0644)
|
||||
}
|
||||
}
|
||||
}
|
||||
hash := sha256.Sum256([]byte(rawURL))
|
||||
_ = os.WriteFile(filepath.Join(cacheDir, hex.EncodeToString(hash[:])+".webp"), data, 0644)
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "image/webp"
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=2592000, immutable")
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
func parseDeliveryAuth(c *gin.Context) service.DeliveryLinkAuth {
|
||||
exp := int64(0)
|
||||
if value := strings.TrimSpace(c.Query("exp")); value != "" {
|
||||
|
||||
@@ -118,3 +118,38 @@ func (h *PlatformLewanHandler) SyncLewanProduct(c *gin.Context) {
|
||||
}
|
||||
response.WithStatus(c, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
type batchSyncLewanReq struct {
|
||||
Products []syncLewanProductReq `json:"products" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// BatchSyncLewanProducts POST /api/platform/lewan/batch-sync-catalog
|
||||
// 批量同步/添加乐玩商品到自营商品目录
|
||||
func (h *PlatformLewanHandler) BatchSyncLewanProducts(c *gin.Context) {
|
||||
var req batchSyncLewanReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:products 列表不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
actorUserID := middleware.GetUserID(c)
|
||||
inputs := make([]service.SyncLewanProductInput, len(req.Products))
|
||||
for i, p := range req.Products {
|
||||
inputs[i] = service.SyncLewanProductInput{
|
||||
LewanProductID: p.LewanProductID,
|
||||
Name: p.Name,
|
||||
Category: p.Category,
|
||||
SKU: p.SKU,
|
||||
Price: p.Price,
|
||||
DeliveryType: p.DeliveryType,
|
||||
}
|
||||
}
|
||||
|
||||
count, err := h.merchantSvc.BatchSyncLewanProductsToCatalog(inputs, actorUserID)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"synced_count": count})
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,8 @@ type LewanOrder struct {
|
||||
Status string `json:"status"` // WAITING_BINDING, QUEUED, PROCESSING, RECONCILING, SUCCEEDED, PARTIAL_SUCCESS, FAILED, CANCELLED
|
||||
BindingDeadline *string `json:"bindingDeadline"`
|
||||
BindingAction *BindingAction `json:"bindingAction,omitempty"`
|
||||
Binding *BindingInfo `json:"binding,omitempty"`
|
||||
CompletedAt *string `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
// CreateOrderResult 下单响应
|
||||
@@ -148,6 +150,7 @@ type BindingInfo struct {
|
||||
Matched bool `json:"matched"`
|
||||
RoleID string `json:"roleId"`
|
||||
RoleName string `json:"roleName"`
|
||||
RoleArea string `json:"roleArea,omitempty"`
|
||||
ServerName string `json:"serverName"`
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
delivery.GET("/orders/:order_no", h.Delivery.GetOrder)
|
||||
delivery.POST("/orders/:order_no/bind", h.Delivery.Bind)
|
||||
delivery.POST("/orders/:order_no/submit", h.Delivery.Submit)
|
||||
delivery.GET("/image-proxy", h.Delivery.ImageProxy)
|
||||
}
|
||||
|
||||
// 源头侧接口:上游发货平台查询订单并回传发货结果。
|
||||
@@ -228,6 +229,7 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
if h.PlatformLewan != nil {
|
||||
admin.GET("/platform/lewan/products", h.PlatformLewan.ListLewanProducts)
|
||||
admin.POST("/platform/lewan/sync-catalog", h.PlatformLewan.SyncLewanProduct)
|
||||
admin.POST("/platform/lewan/batch-sync-catalog", h.PlatformLewan.BatchSyncLewanProducts)
|
||||
admin.PUT("/platform/products/:id/routing", h.PlatformLewan.UpdateProductRouting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,77 @@ func (s *DeliveryService) CancelUpstream(order *model.FulfillmentOrder) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncDeliveringOrder 主动向上游渠道查询处于 delivering 状态的订单结果,以补充 Webhook 丢失或延迟。
|
||||
func (s *DeliveryService) SyncDeliveringOrder(ctx context.Context, order *model.FulfillmentOrder) (bool, error) {
|
||||
if order == nil || s.router == nil || order.OrderStatus != model.OrderStatusDelivering {
|
||||
return false, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
providerName := resultDataString(order.ResultData, "provider")
|
||||
if providerName == "" && (resultDataString(order.ResultData, "lewan_order_id") != "" || resultDataString(order.ResultData, "lewan_order_no") != "" || strings.HasPrefix(order.ProviderOrderNo, "OMU")) {
|
||||
providerName = ProviderLewan
|
||||
}
|
||||
|
||||
providers := make([]FulfillmentProvider, 0, 2)
|
||||
if providerName != "" {
|
||||
if p := s.router.GetProvider(providerName); p != nil {
|
||||
providers = append(providers, p)
|
||||
}
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
resolved, _ := s.router.ResolveProviders(order)
|
||||
providers = resolved
|
||||
}
|
||||
|
||||
for _, p := range providers {
|
||||
res, err := p.SyncStatus(ctx, order)
|
||||
if err != nil || res == nil {
|
||||
continue
|
||||
}
|
||||
if res.Status == "success" || res.Status == "failed" {
|
||||
input := ShipNotifyInput{
|
||||
OrderNo: order.OrderNo,
|
||||
ShipStatus: res.Status,
|
||||
ProviderOrderNo: res.ProviderOrderNo,
|
||||
FailReason: res.FailReason,
|
||||
RawPayload: res.RawPayload,
|
||||
}
|
||||
if res.GameUID != "" {
|
||||
input.GameUID = &res.GameUID
|
||||
}
|
||||
if res.RoleName != "" {
|
||||
input.RoleName = &res.RoleName
|
||||
}
|
||||
if res.RoleArea != "" {
|
||||
input.GameChannel = &res.RoleArea
|
||||
}
|
||||
if _, err := s.fulfillment.HandleShipNotify(input); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
// 如果仍在发货中,且获得了游戏UID/角色名/区服,补充写入 ResultData
|
||||
patch := map[string]interface{}{}
|
||||
if res.GameUID != "" && resultDataString(order.ResultData, "game_uid") == "" {
|
||||
patch["game_uid"] = res.GameUID
|
||||
}
|
||||
if res.RoleName != "" && resultDataString(order.ResultData, "role_name") == "" {
|
||||
patch["role_name"] = res.RoleName
|
||||
}
|
||||
if res.RoleArea != "" && resultDataString(order.ResultData, "game_channel") == "" {
|
||||
patch["game_channel"] = res.RoleArea
|
||||
}
|
||||
if len(patch) > 0 {
|
||||
_ = s.persistOrderResultPatch(order, patch)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// persistOrderResultPatch 合并写入订单 result_data,不改变发货 stage。
|
||||
func (s *DeliveryService) persistOrderResultPatch(order *model.FulfillmentOrder, patch map[string]interface{}) error {
|
||||
if order == nil || len(patch) == 0 {
|
||||
@@ -181,6 +252,8 @@ type DeliveryOrderInfo struct {
|
||||
Product *DeliveryProduct `json:"product,omitempty"`
|
||||
BuyerName string `json:"buyer_name"`
|
||||
Amount int64 `json:"amount"`
|
||||
ProviderOrderNo string `json:"provider_order_no,omitempty"`
|
||||
ProviderName string `json:"provider_name,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ShippedAt *time.Time `json:"shipped_at"`
|
||||
ShipFailReason string `json:"ship_fail_reason,omitempty"`
|
||||
@@ -633,8 +706,14 @@ func (s *DeliveryService) loadMerchantOrder(merchantID uint, orderNo string) (*O
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if order.OrderStatus == model.OrderStatusDelivering {
|
||||
if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced {
|
||||
openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo)
|
||||
order, _ = s.fulfillment.GetOrder(merchantID, orderNo)
|
||||
}
|
||||
}
|
||||
goodID := ""
|
||||
if openOrder.Product != nil {
|
||||
if openOrder != nil && openOrder.Product != nil {
|
||||
goodID = deliveryGoodID(s.channel, openOrder.Product.SKU)
|
||||
}
|
||||
return openOrder, order, goodID, nil
|
||||
@@ -701,6 +780,24 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m
|
||||
return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason)
|
||||
}
|
||||
|
||||
providerOrderNo := strings.TrimSpace(openOrder.ProviderOrderNo)
|
||||
if providerOrderNo == "" {
|
||||
providerOrderNo = strings.TrimSpace(order.ProviderOrderNo)
|
||||
}
|
||||
if providerOrderNo == "" {
|
||||
providerOrderNo = resultDataString(order.ResultData, "lewan_order_no")
|
||||
}
|
||||
if providerOrderNo == "" {
|
||||
providerOrderNo = resultDataString(order.ResultData, "provider_order_no")
|
||||
}
|
||||
|
||||
providerName := resultDataString(order.ResultData, "provider")
|
||||
if providerName == "" {
|
||||
if strings.TrimSpace(order.ProviderOrderNo) != "" || resultDataString(order.ResultData, "lewan_order_id") != "" {
|
||||
providerName = "lewan"
|
||||
}
|
||||
}
|
||||
|
||||
info := &DeliveryOrderInfo{
|
||||
OrderNo: openOrder.OrderNo,
|
||||
Status: openOrder.Status,
|
||||
@@ -709,6 +806,8 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m
|
||||
Product: deliveryProd,
|
||||
BuyerName: openOrder.BuyerName,
|
||||
Amount: openOrder.Amount,
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
ProviderName: providerName,
|
||||
CreatedAt: openOrder.CreatedAt,
|
||||
ShippedAt: openOrder.ShippedAt,
|
||||
ShipFailReason: openOrder.ShipFailReason,
|
||||
@@ -886,6 +985,12 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth
|
||||
return nil, nil, "", err
|
||||
}
|
||||
}
|
||||
if order.OrderStatus == model.OrderStatusDelivering {
|
||||
if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced {
|
||||
openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo)
|
||||
order, _ = s.fulfillment.GetByOrderNo(orderNo)
|
||||
}
|
||||
}
|
||||
return s.buildDeliveryState(openOrder, order, "", requireCanShip)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
@@ -19,6 +20,7 @@ type FulfillmentService struct {
|
||||
db *gorm.DB
|
||||
callbacks *CallbackService
|
||||
upstreamCancel func(order *model.FulfillmentOrder) error
|
||||
upstreamSync func(ctx context.Context, order *model.FulfillmentOrder) (bool, error)
|
||||
}
|
||||
|
||||
func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *FulfillmentService {
|
||||
@@ -30,6 +32,11 @@ func (s *FulfillmentService) SetUpstreamCancel(fn func(order *model.FulfillmentO
|
||||
s.upstreamCancel = fn
|
||||
}
|
||||
|
||||
// SetUpstreamSync 注册主动查单同步上游状态的钩子(如乐玩订单补偿)。
|
||||
func (s *FulfillmentService) SetUpstreamSync(fn func(ctx context.Context, order *model.FulfillmentOrder) (bool, error)) {
|
||||
s.upstreamSync = fn
|
||||
}
|
||||
|
||||
type CreateFulfillmentOrderInput struct {
|
||||
MerchantID uint
|
||||
APIClientID uint
|
||||
|
||||
@@ -51,10 +51,7 @@ func mergeResultData(existing string, patch map[string]interface{}) string {
|
||||
if len(patch) == 0 {
|
||||
return existing
|
||||
}
|
||||
m := map[string]interface{}{}
|
||||
if existing != "" && json.Valid([]byte(existing)) {
|
||||
_ = json.Unmarshal([]byte(existing), &m)
|
||||
}
|
||||
m := resultDataMap(existing)
|
||||
for k, v := range patch {
|
||||
m[k] = v
|
||||
}
|
||||
@@ -67,8 +64,15 @@ func mergeResultData(existing string, patch map[string]interface{}) string {
|
||||
|
||||
func resultDataMap(raw string) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
if raw != "" && json.Valid([]byte(raw)) {
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
if raw == "" {
|
||||
return m
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
// 容错:如果 raw 被双重序列化为 string(如 "\"{\\\"provider\\\":...}\"")
|
||||
var inner string
|
||||
if json.Unmarshal([]byte(raw), &inner) == nil && inner != "" {
|
||||
_ = json.Unmarshal([]byte(inner), &m)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -76,8 +80,14 @@ func resultDataMap(raw string) map[string]interface{} {
|
||||
// requestDataMap 解析下单透传的 data(RequestData),非法或为空时返回空 map。
|
||||
func requestDataMap(raw string) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
if raw != "" && json.Valid([]byte(raw)) {
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
if raw == "" {
|
||||
return m
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
var inner string
|
||||
if json.Unmarshal([]byte(raw), &inner) == nil && inner != "" {
|
||||
_ = json.Unmarshal([]byte(inner), &m)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -72,8 +72,18 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
shippedAt := in.ShippedAt
|
||||
if shippedAt == nil && in.ShipStatus == "success" {
|
||||
shippedAt = &now
|
||||
}
|
||||
|
||||
// 已交付:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。
|
||||
if normalizeOrderStatus(&order) == model.OrderStatusDelivered && in.ShipStatus == "success" {
|
||||
newResultData := buildShipNotifyResultData(order.ResultData, in, shippedAt)
|
||||
if newResultData != order.ResultData {
|
||||
_ = tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Update("result_data", newResultData).Error
|
||||
}
|
||||
result = ShipNotifyResult{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: normalizeOrderStatus(&order),
|
||||
@@ -91,12 +101,6 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
shippedAt := in.ShippedAt
|
||||
if shippedAt == nil && in.ShipStatus == "success" {
|
||||
shippedAt = &now
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"order_status": nextStatus,
|
||||
}
|
||||
|
||||
@@ -33,6 +33,14 @@ func (s *FulfillmentService) MarkProcessingTimeouts(timeout time.Duration, limit
|
||||
}
|
||||
changed := 0
|
||||
for _, id := range ids {
|
||||
if s.upstreamSync != nil {
|
||||
var ord model.FulfillmentOrder
|
||||
if err := s.db.First(&ord, id).Error; err == nil {
|
||||
if synced, _ := s.upstreamSync(context.Background(), &ord); synced {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
updated, err := s.markProcessingTimeout(id, timeout, now)
|
||||
if err != nil {
|
||||
return changed, err
|
||||
|
||||
@@ -456,3 +456,18 @@ func (s *MerchantService) SyncLewanProductToCatalog(in SyncLewanProductInput, ac
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// BatchSyncLewanProductsToCatalog 批量导入或关联乐玩商品至平台自营商品目录
|
||||
func (s *MerchantService) BatchSyncLewanProductsToCatalog(items []SyncLewanProductInput, actorUserID uint) (int, error) {
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
count := 0
|
||||
for _, item := range items {
|
||||
if _, err := s.SyncLewanProductToCatalog(item, actorUserID); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,17 @@ type ProviderSubmitResult struct {
|
||||
PatchResultData map[string]interface{} `json:"patch_result_data,omitempty"`
|
||||
}
|
||||
|
||||
// ProviderSyncResult 抽象主动查单同步状态返回
|
||||
type ProviderSyncResult struct {
|
||||
Status string // success / failed / delivering / ""
|
||||
ProviderOrderNo string
|
||||
FailReason string
|
||||
GameUID string
|
||||
RoleName string
|
||||
RoleArea string
|
||||
RawPayload string
|
||||
}
|
||||
|
||||
// FulfillmentProvider 统一履约提供商接口
|
||||
type FulfillmentProvider interface {
|
||||
Name() string
|
||||
@@ -122,6 +133,7 @@ type FulfillmentProvider interface {
|
||||
Bind(ctx context.Context, order *model.FulfillmentOrder, gameAccount string) (*ProviderBindResult, error)
|
||||
CheckBind(ctx context.Context, order *model.FulfillmentOrder, bindUUID string) (*ProviderBindResult, error)
|
||||
Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error)
|
||||
SyncStatus(ctx context.Context, order *model.FulfillmentOrder) (*ProviderSyncResult, error)
|
||||
Cancel(ctx context.Context, order *model.FulfillmentOrder) error
|
||||
}
|
||||
|
||||
|
||||
@@ -350,6 +350,10 @@ func (p *DLCProvider) createUpstreamOrder(orderID, gameAccount, goodID, orderNo
|
||||
return out.Order, nil
|
||||
}
|
||||
|
||||
func (p *DLCProvider) SyncStatus(ctx context.Context, order *model.FulfillmentOrder) (*ProviderSyncResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *DLCProvider) signProxy(path, method string, data interface{}, out interface{}) error {
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"path": path,
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -356,3 +357,114 @@ func (p *LewanProvider) Cancel(ctx context.Context, order *model.FulfillmentOrde
|
||||
_, err := p.client.CancelOrder(ctx, lewanOrderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *LewanProvider) SyncStatus(ctx context.Context, order *model.FulfillmentOrder) (*ProviderSyncResult, error) {
|
||||
if p.client == nil || order == nil {
|
||||
return nil, nil
|
||||
}
|
||||
callerOrderID := order.OrderNo
|
||||
lewanOrderID := resultDataString(order.ResultData, "lewan_order_id")
|
||||
|
||||
var (
|
||||
lewanOrder *lewan.LewanOrder
|
||||
err error
|
||||
)
|
||||
if callerOrderID != "" {
|
||||
lewanOrder, err = p.client.GetOrderByCallerOrder(ctx, callerOrderID)
|
||||
}
|
||||
if lewanOrder == nil && lewanOrderID != "" {
|
||||
lewanOrder, err = p.client.GetOrder(ctx, lewanOrderID)
|
||||
}
|
||||
if err != nil || lewanOrder == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleName := ""
|
||||
roleArea := ""
|
||||
gameUID := lewanOrder.RoleID
|
||||
if lewanOrder.Binding != nil {
|
||||
if lewanOrder.Binding.RoleID != "" {
|
||||
gameUID = lewanOrder.Binding.RoleID
|
||||
}
|
||||
roleName = lewanOrder.Binding.RoleName
|
||||
roleArea = lewanOrder.Binding.RoleArea
|
||||
}
|
||||
if gameUID == "" {
|
||||
gameUID = resultDataString(order.ResultData, "game_uid")
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(lewanOrder)
|
||||
providerOrderNo := lewanOrder.OrderNo
|
||||
if providerOrderNo == "" {
|
||||
providerOrderNo = lewanOrder.ID
|
||||
}
|
||||
|
||||
switch lewanOrder.Status {
|
||||
case "SUCCEEDED":
|
||||
return &ProviderSyncResult{
|
||||
Status: "success",
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
GameUID: gameUID,
|
||||
RoleName: roleName,
|
||||
RoleArea: roleArea,
|
||||
RawPayload: string(raw),
|
||||
}, nil
|
||||
case "FAILED":
|
||||
reason := strings.Join(lewanOrder.FailureCodes, "; ")
|
||||
if reason == "" {
|
||||
reason = "乐玩平台发货失败"
|
||||
}
|
||||
return &ProviderSyncResult{
|
||||
Status: "failed",
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
FailReason: reason,
|
||||
GameUID: gameUID,
|
||||
RoleName: roleName,
|
||||
RoleArea: roleArea,
|
||||
RawPayload: string(raw),
|
||||
}, nil
|
||||
case "PARTIAL_SUCCESS":
|
||||
if lewanOrder.SucceededQuantity > 0 {
|
||||
return &ProviderSyncResult{
|
||||
Status: "success",
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
GameUID: gameUID,
|
||||
RoleName: roleName,
|
||||
RoleArea: roleArea,
|
||||
RawPayload: string(raw),
|
||||
}, nil
|
||||
}
|
||||
reason := strings.Join(lewanOrder.FailureCodes, "; ")
|
||||
if reason == "" {
|
||||
reason = "乐玩部分单件被停止或释放"
|
||||
}
|
||||
return &ProviderSyncResult{
|
||||
Status: "failed",
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
FailReason: reason,
|
||||
GameUID: gameUID,
|
||||
RoleName: roleName,
|
||||
RoleArea: roleArea,
|
||||
RawPayload: string(raw),
|
||||
}, nil
|
||||
case "ORDER_CANCELLED", "BINDING_TIMEOUT":
|
||||
return &ProviderSyncResult{
|
||||
Status: "failed",
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
FailReason: "乐玩订单超时或已取消",
|
||||
GameUID: gameUID,
|
||||
RoleName: roleName,
|
||||
RoleArea: roleArea,
|
||||
RawPayload: string(raw),
|
||||
}, nil
|
||||
default:
|
||||
return &ProviderSyncResult{
|
||||
Status: "delivering",
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
GameUID: gameUID,
|
||||
RoleName: roleName,
|
||||
RoleArea: roleArea,
|
||||
RawPayload: string(raw),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -240,7 +240,10 @@ export const lewanApi = {
|
||||
request.get('/platform/lewan/products', { params }).then((r) => r.data.data as LewanProductListResult),
|
||||
syncCatalog: (data: SyncLewanProductInput) =>
|
||||
request.post('/platform/lewan/sync-catalog', data).then((r) => r.data.data as ProductCatalogItem),
|
||||
batchSyncCatalog: (products: SyncLewanProductInput[]) =>
|
||||
request.post('/platform/lewan/batch-sync-catalog', { products }).then((r) => r.data.data as { synced_count: number }),
|
||||
updateRouting: (id: number, data: UpdateProductRoutingInput) =>
|
||||
request.put(`/platform/products/${id}/routing`, data).then((r) => r.data.data as { updated: boolean }),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
Alert,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Result,
|
||||
Space,
|
||||
Spin,
|
||||
@@ -18,12 +19,21 @@ import {
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
LoadingOutlined,
|
||||
QrcodeOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SendOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { deliveryApi } from '../api'
|
||||
import type { DeliveryBindResult, DeliveryOrderInfo, DeliverySubmitResult } from '../types'
|
||||
import { formatDateTime } from '../utils/time'
|
||||
import { generateDeliveryVoucher } from '../utils/voucher'
|
||||
import { resolveProductImage } from '../utils/productImage'
|
||||
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
paid: { color: 'orange', text: '待发货' },
|
||||
@@ -33,17 +43,34 @@ const statusMap: Record<string, { color: string; text: string }> = {
|
||||
cancelled: { color: 'default', text: '已取消' },
|
||||
}
|
||||
|
||||
function dataURLtoBlob(dataurl: string): Blob {
|
||||
const arr = dataurl.split(',')
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png'
|
||||
const bstr = atob(arr[1])
|
||||
let n = bstr.length
|
||||
const u8arr = new Uint8Array(n)
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n)
|
||||
}
|
||||
return new Blob([u8arr], { type: mime })
|
||||
}
|
||||
|
||||
export default function Delivery() {
|
||||
const { channel = 'dlc', orderNo = '' } = useParams()
|
||||
const { orderNo = '' } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [form] = Form.useForm<{ game_account: string }>()
|
||||
const [order, setOrder] = useState<DeliveryOrderInfo | null>(null)
|
||||
const [bindResult, setBindResult] = useState<DeliveryBindResult | null>(null)
|
||||
const [submitResult, setSubmitResult] = useState<DeliverySubmitResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [binding, setBinding] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [voucherImageURL, setVoucherImageURL] = useState('')
|
||||
const [generatingImage, setGeneratingImage] = useState(false)
|
||||
const [copyingImage, setCopyingImage] = useState(false)
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
|
||||
const decodedOrderNo = useMemo(() => decodeURIComponent(orderNo), [orderNo])
|
||||
const deliveryAuth = useMemo(() => {
|
||||
@@ -53,21 +80,122 @@ export default function Delivery() {
|
||||
}, [searchParams])
|
||||
const gameAccount = Form.useWatch('game_account', form)
|
||||
|
||||
useEffect(() => {
|
||||
if (!decodedOrderNo) {
|
||||
setError('发货链接缺少订单号')
|
||||
setLoading(false)
|
||||
return
|
||||
const getOrGenerateVoucher = async () => {
|
||||
if (!order) return ''
|
||||
if (voucherImageURL) return voucherImageURL
|
||||
setGeneratingImage(true)
|
||||
try {
|
||||
const url = await generateDeliveryVoucher(order)
|
||||
setVoucherImageURL(url)
|
||||
return url
|
||||
} catch (e) {
|
||||
message.error('生成发货凭证图失败:' + (e instanceof Error ? e.message : '请稍后重试'))
|
||||
return ''
|
||||
} finally {
|
||||
setGeneratingImage(false)
|
||||
}
|
||||
setLoading(true)
|
||||
deliveryApi.getOrder(decodedOrderNo, deliveryAuth)
|
||||
.then((data) => {
|
||||
}
|
||||
|
||||
const handleDownloadVoucher = async () => {
|
||||
const url = await getOrGenerateVoucher()
|
||||
if (!url) return
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `发货凭据_${order?.order_no || 'order'}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
message.success('凭证图片已生成并开始下载')
|
||||
}
|
||||
|
||||
const handlePreviewVoucher = async () => {
|
||||
setPreviewModalOpen(true)
|
||||
await getOrGenerateVoucher()
|
||||
}
|
||||
|
||||
const handleCopyVoucherImage = async () => {
|
||||
setCopyingImage(true)
|
||||
try {
|
||||
const url = await getOrGenerateVoucher()
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
navigator.clipboard &&
|
||||
typeof ClipboardItem !== 'undefined' &&
|
||||
navigator.clipboard.write
|
||||
) {
|
||||
const blob = dataURLtoBlob(url)
|
||||
const item = new ClipboardItem({ 'image/png': blob })
|
||||
await navigator.clipboard.write([item])
|
||||
message.success('结果图片已复制到剪贴板!可直接粘贴到聊天窗口(Ctrl+V / ⌘+V)')
|
||||
} else {
|
||||
setPreviewModalOpen(true)
|
||||
message.info('当前浏览器环境不支持直接写入剪贴板图片,已为您打开大图,可长按或右键复制图片')
|
||||
}
|
||||
} catch {
|
||||
setPreviewModalOpen(true)
|
||||
message.warning('写入剪贴板失败,已为您打开凭据预览,可长按或右键复制图片')
|
||||
} finally {
|
||||
setCopyingImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOrder = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!decodedOrderNo) {
|
||||
setError('发货链接缺少订单号')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (!silent) {
|
||||
setLoading(true)
|
||||
}
|
||||
try {
|
||||
const data = await deliveryApi.getOrder(decodedOrderNo, deliveryAuth)
|
||||
setOrder(data)
|
||||
setError('')
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '订单加载失败'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [decodedOrderNo, deliveryAuth])
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
setError(e instanceof Error ? e.message : '订单加载失败')
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[decodedOrderNo, deliveryAuth]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrder(false)
|
||||
}, [fetchOrder])
|
||||
|
||||
// 当订单处于发货中 (delivering) 时,每 3 秒自动轮询发货进度
|
||||
useEffect(() => {
|
||||
if (order?.status !== 'delivering') {
|
||||
return
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
fetchOrder(true)
|
||||
}, 3000)
|
||||
return () => clearInterval(timer)
|
||||
}, [order?.status, fetchOrder])
|
||||
|
||||
const handleManualRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await fetchOrder(true)
|
||||
message.success('状态已刷新')
|
||||
} catch {
|
||||
message.error('刷新状态失败')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const bindAccount = async () => {
|
||||
const values = await form.validateFields()
|
||||
@@ -94,7 +222,17 @@ export default function Delivery() {
|
||||
try {
|
||||
const result = await deliveryApi.submit(decodedOrderNo, values.game_account, bindResult.bind_uuid, deliveryAuth)
|
||||
setSubmitResult(result)
|
||||
setOrder((prev) => prev ? { ...prev, status: result.status, can_ship: false } : prev)
|
||||
setOrder((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
status: result.status,
|
||||
can_ship: false,
|
||||
game_uid: values.game_account,
|
||||
role_name: (result.game_account as Record<string, string>)?.role_name || prev.role_name,
|
||||
}
|
||||
: prev
|
||||
)
|
||||
message.success(result.message || '已提交')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '提交失败')
|
||||
@@ -126,13 +264,24 @@ export default function Delivery() {
|
||||
<main style={{ minHeight: '100vh', background: '#f5f7fb', padding: '32px 16px' }}>
|
||||
<div style={{ maxWidth: 880, margin: '0 auto' }}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<header>
|
||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>
|
||||
游戏道具发货
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{channel.toUpperCase()} · {decodedOrderNo}
|
||||
</Typography.Text>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>
|
||||
游戏道具发货
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
订单编号:{decodedOrderNo}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{order.status === 'delivering' && (
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={refreshing}
|
||||
onClick={handleManualRefresh}
|
||||
>
|
||||
刷新状态
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<section style={panelStyle}>
|
||||
@@ -151,40 +300,222 @@ export default function Delivery() {
|
||||
<Descriptions.Item label="买家">{order.buyer_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">{order.amount} 积分</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{formatDateTime(order.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="渠道">{order.product?.game || channel.toUpperCase()}</Descriptions.Item>
|
||||
<Descriptions.Item label="渠道">{order.product?.game || '自动发货'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{order.ship_fail_reason && (
|
||||
{order.ship_fail_reason && order.status !== 'ship_failed' && (
|
||||
<Alert type="error" showIcon message={order.ship_fail_reason} />
|
||||
)}
|
||||
{!order.can_ship && (
|
||||
<Alert type="warning" showIcon message={order.cannot_ship_reason || '当前订单暂不可发货'} />
|
||||
)}
|
||||
{!order.can_ship &&
|
||||
order.status !== 'delivering' &&
|
||||
order.status !== 'delivered' &&
|
||||
order.status !== 'ship_failed' && (
|
||||
<Alert type="warning" showIcon message={order.cannot_ship_reason || '当前订单暂不可发货'} />
|
||||
)}
|
||||
</Space>
|
||||
</section>
|
||||
|
||||
{order.can_ship && (
|
||||
{/* 状态 1:已发货完成 */}
|
||||
{order.status === 'delivered' && (
|
||||
<section style={panelStyle}>
|
||||
<Result
|
||||
status="success"
|
||||
title="道具发放成功!"
|
||||
subTitle="系统已为您完成道具充值发放,请进入游戏查收邮件或背包。"
|
||||
extra={[
|
||||
<Button
|
||||
type="primary"
|
||||
key="copy-img"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingImage || generatingImage}
|
||||
onClick={handleCopyVoucherImage}
|
||||
style={{ background: '#16a34a', borderColor: '#16a34a' }}
|
||||
>
|
||||
复制结果图片
|
||||
</Button>,
|
||||
<Button
|
||||
key="download"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={generatingImage}
|
||||
onClick={handleDownloadVoucher}
|
||||
>
|
||||
下载凭据图片
|
||||
</Button>,
|
||||
<Button
|
||||
key="preview"
|
||||
icon={<EyeOutlined />}
|
||||
loading={generatingImage}
|
||||
onClick={handlePreviewVoucher}
|
||||
>
|
||||
预览凭据图片
|
||||
</Button>,
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={handleManualRefresh} loading={refreshing}>
|
||||
刷新核验
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ background: '#fafafa', border: '1px solid #f0f0f0', padding: '20px 24px', borderRadius: 12 }}>
|
||||
{/* 商品卡片信息 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 20, paddingBottom: 16, borderBottom: '1px solid #e2e8f0', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flex: 1, minWidth: 220 }}>
|
||||
{order.product?.image ? (
|
||||
<Image
|
||||
src={resolveProductImage(order.product.image)}
|
||||
width={72}
|
||||
height={72}
|
||||
alt={order.product.name}
|
||||
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: 72, height: 72, background: '#e2e8f0', borderRadius: 8, display: 'grid', placeItems: 'center', color: '#64748b' }}>
|
||||
道具
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: '#0f172a', marginBottom: 4 }}>
|
||||
{order.product?.name || '游戏道具商品'}
|
||||
</div>
|
||||
<Space wrap size="small">
|
||||
<Tag color="blue">{order.product?.sku || '-'}</Tag>
|
||||
{order.product?.game && <Tag color="cyan">{order.product.game}</Tag>}
|
||||
<Tag color="green">已发放 1 件</Tag>
|
||||
<Tag color="purple">{order.amount} 积分</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingImage || generatingImage}
|
||||
onClick={handleCopyVoucherImage}
|
||||
>
|
||||
复制结果图片
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} size="middle">
|
||||
<Descriptions.Item label="游戏账号 UID">
|
||||
<Typography.Text
|
||||
code
|
||||
strong
|
||||
style={{ fontSize: 15 }}
|
||||
copyable={order.game_uid || gameAccount ? { text: order.game_uid || gameAccount } : false}
|
||||
>
|
||||
{order.game_uid || gameAccount || '-'}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="游戏角色名">
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{ fontSize: 15, color: '#0f172a' }}
|
||||
copyable={order.role_name ? { text: order.role_name } : false}
|
||||
>
|
||||
{order.role_name || '-'}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="区服信息">{order.game_channel || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="下单时间">{formatDateTime(order.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">
|
||||
{order.shipped_at ? formatDateTime(order.shipped_at) : '已完成'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ marginTop: 16, padding: '12px 16px', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 8, display: 'flex', alignItems: 'center', gap: 10, color: '#15803d', fontSize: 13 }}>
|
||||
<SafetyCertificateOutlined style={{ fontSize: 20, color: '#16a34a', flexShrink: 0 }} />
|
||||
<div>
|
||||
<strong>官方履约已完成:</strong>本次道具发货已成功直充到账。点击「复制结果图片」可直接复制凭据图并在微信、客服聊天窗直接粘贴(Ctrl+V / ⌘+V)。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Result>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 状态 2:发货处理中 */}
|
||||
{order.status === 'delivering' && (
|
||||
<section style={{ ...panelStyle, borderTop: '4px solid #1677ff' }}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 28, color: '#1677ff' }} spin />} />
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0, color: '#1677ff' }}>
|
||||
道具正在极速发货中...
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
已成功提交上游发放节点,通常在 10 ~ 30 秒内到账,页面会自动更新。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#f0f5ff', border: '1px solid #d6e4ff', padding: '16px 20px', borderRadius: 8 }}>
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
|
||||
<Descriptions.Item label="发放目标 UID">
|
||||
<Typography.Text strong code>{order.game_uid || gameAccount || '-'}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="识别角色名">
|
||||
<Typography.Text strong>{order.role_name || '正在识别...'}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="游戏区服">{order.game_channel || '自动识别'}</Descriptions.Item>
|
||||
<Descriptions.Item label="处理进度">
|
||||
<Tag color="processing" icon={<SyncOutlined spin />}>队列发放中</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
系统正在自动刷新进度(每 3 秒)
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={refreshing}
|
||||
onClick={handleManualRefresh}
|
||||
>
|
||||
刷新发货进度
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 状态 3:发货失败 */}
|
||||
{order.status === 'ship_failed' && (
|
||||
<section style={panelStyle}>
|
||||
<Result
|
||||
status="error"
|
||||
title="发货遇到异常"
|
||||
subTitle={order.ship_fail_reason || order.cannot_ship_reason || '渠道发货未成功,请核对信息或联系客服'}
|
||||
extra={[
|
||||
<Button key="retry" type="primary" onClick={handleManualRefresh} loading={refreshing}>
|
||||
重新查询订单状态
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 状态 4:待绑定扫码 */}
|
||||
{order.status === 'paid' && (
|
||||
<section style={panelStyle}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Steps
|
||||
current={step}
|
||||
items={[
|
||||
{ title: '填写 UID', icon: <ClockCircleOutlined /> },
|
||||
{ title: '扫码绑定', icon: <QrcodeOutlined /> },
|
||||
{ title: '输入账号', icon: <ClockCircleOutlined /> },
|
||||
{ title: '微信/QQ 扫码绑定', icon: <QrcodeOutlined /> },
|
||||
{ title: '提交发货', icon: <CheckCircleOutlined /> },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" initialValues={{ game_account: order.game_uid || '' }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ game_account: order.buyer_name || '' }}>
|
||||
<Form.Item
|
||||
name="game_account"
|
||||
label="游戏 UID"
|
||||
rules={[
|
||||
{ required: true, message: '请输入游戏 UID' },
|
||||
{ pattern: /^\d+$/, message: '游戏 UID 仅支持数字' },
|
||||
]}
|
||||
label="游戏账号(用于接收充值)"
|
||||
rules={[{ required: true, message: '请输入接收道具的游戏账号 UID' }]}
|
||||
extra="请务必核对游戏账号 UID,充值成功后无法撤回"
|
||||
>
|
||||
<Input size="large" inputMode="numeric" placeholder="请输入游戏 UID" />
|
||||
<Input placeholder="请输入游戏账号 UID" size="large" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -241,6 +572,70 @@ export default function Delivery() {
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<SafetyCertificateOutlined style={{ color: '#16a34a' }} />
|
||||
<span>发货电子凭证预览</span>
|
||||
</Space>
|
||||
}
|
||||
open={previewModalOpen}
|
||||
onCancel={() => setPreviewModalOpen(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setPreviewModalOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button
|
||||
key="copy"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingImage}
|
||||
onClick={handleCopyVoucherImage}
|
||||
>
|
||||
复制图片到剪贴板
|
||||
</Button>,
|
||||
<Button
|
||||
key="download"
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={generatingImage}
|
||||
onClick={handleDownloadVoucher}
|
||||
style={{ background: '#16a34a', borderColor: '#16a34a' }}
|
||||
>
|
||||
保存凭证图片
|
||||
</Button>,
|
||||
]}
|
||||
width={640}
|
||||
centered
|
||||
>
|
||||
{generatingImage ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16, color: '#64748b' }}>正在为您绘制高清发货凭证图片...</div>
|
||||
</div>
|
||||
) : voucherImageURL ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src={voucherImageURL}
|
||||
alt="发货电子凭证"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: '68vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 12, color: '#64748b', fontSize: 13 }}>
|
||||
提示:支持长按或右键复制/保存图片到本地相册,作为官方维权及售后保障证据。
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Button onClick={getOrGenerateVoucher}>生成凭证</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ export default function PlatformProducts() {
|
||||
const [lewanPage, setLewanPage] = useState(1)
|
||||
const [lewanTotal, setLewanTotal] = useState(0)
|
||||
const [lewanKeyword, setLewanKeyword] = useState('')
|
||||
const [selectedLewanMap, setSelectedLewanMap] = useState<Map<string, LewanProductItem>>(new Map())
|
||||
const [batchSubmitting, setBatchSubmitting] = useState(false)
|
||||
|
||||
// 路由配置 Modal 状态
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false)
|
||||
@@ -219,13 +221,14 @@ export default function PlatformProducts() {
|
||||
// 打开将乐玩商品导入为自营目录的 Modal
|
||||
const openSyncModal = (lewanProduct: LewanProductItem) => {
|
||||
syncForm.resetFields()
|
||||
const unitPrice = Number.parseFloat(lewanProduct.effectiveUnitPrice || '')
|
||||
const unitPriceFen = Number.parseFloat(lewanProduct.effectiveUnitPrice || '')
|
||||
const unitPriceYuan = Number.isFinite(unitPriceFen) ? Number((unitPriceFen / 100).toFixed(2)) : 0
|
||||
syncForm.setFieldsValue({
|
||||
lewan_product_id: lewanProduct.id,
|
||||
name: lewanProduct.name,
|
||||
category: lewanProduct.categoryName || '未分类',
|
||||
sku: `LEWAN_${lewanProduct.id.slice(0, 8).toUpperCase()}`,
|
||||
price_yuan: Number.isFinite(unitPrice) ? unitPrice.toFixed(2) : 0,
|
||||
price_yuan: unitPriceYuan,
|
||||
delivery_type: lewanProduct.deliveryType || 'LINK_BINDING',
|
||||
})
|
||||
setSyncModalOpen(true)
|
||||
@@ -256,6 +259,92 @@ export default function PlatformProducts() {
|
||||
}
|
||||
}
|
||||
|
||||
// 批量导入选中的乐玩商品
|
||||
const handleBatchSync = () => {
|
||||
const selectedItems = Array.from(selectedLewanMap.values())
|
||||
if (selectedItems.length === 0) return
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认批量导入选中的 ${selectedItems.length} 个乐玩商品?`,
|
||||
content: (
|
||||
<div>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
将自动按乐玩供货价生成自营商品并配置为乐玩履约模式,同步加入平台自营目录。
|
||||
</Paragraph>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
background: '#f8fafc',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
{selectedItems.map((item, idx) => {
|
||||
const unitPriceFen = Number.parseFloat(item.effectiveUnitPrice || '')
|
||||
const unitPriceYuan = Number.isFinite(unitPriceFen)
|
||||
? (unitPriceFen / 100).toFixed(2)
|
||||
: '0.00'
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '3px 0',
|
||||
borderBottom: idx < selectedItems.length - 1 ? '1px dashed #e2e8f0' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500, marginRight: 8 }}>{item.name}</span>
|
||||
<span style={{ color: '#2563eb', whiteSpace: 'nowrap' }}>{unitPriceYuan} 元</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
okText: `确认导入 (${selectedItems.length})`,
|
||||
cancelText: '取消',
|
||||
width: 500,
|
||||
onOk: async () => {
|
||||
setBatchSubmitting(true)
|
||||
try {
|
||||
const payload: SyncLewanProductInput[] = selectedItems.map((p) => {
|
||||
const unitPriceFen = Number.parseFloat(p.effectiveUnitPrice || '')
|
||||
return {
|
||||
lewan_product_id: p.id,
|
||||
name: p.name,
|
||||
category: p.categoryName || '未分类',
|
||||
sku: `LEWAN_${p.id.slice(0, 8).toUpperCase()}`,
|
||||
price: Number.isFinite(unitPriceFen) ? Math.round(unitPriceFen) : 1000,
|
||||
delivery_type: p.deliveryType || 'LINK_BINDING',
|
||||
}
|
||||
})
|
||||
let count = 0
|
||||
try {
|
||||
const res = await lewanApi.batchSyncCatalog(payload)
|
||||
count = res.synced_count
|
||||
} catch {
|
||||
// 兼容后端尚未重启的情况:自动降级为循环单条导入
|
||||
for (const item of payload) {
|
||||
await lewanApi.syncCatalog(item)
|
||||
count++
|
||||
}
|
||||
}
|
||||
message.success(`成功批量导入 ${count} 个商品至自营目录!`)
|
||||
setSelectedLewanMap(new Map())
|
||||
loadCatalog()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '批量导入失败')
|
||||
} finally {
|
||||
setBatchSubmitting(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 快捷从乐玩列表快速关联到当前配置的商品
|
||||
const quickAttachLewanProduct = (p: LewanProductItem) => {
|
||||
configForm.setFieldsValue({
|
||||
@@ -437,10 +526,10 @@ export default function PlatformProducts() {
|
||||
{
|
||||
title: '供货价',
|
||||
dataIndex: 'effectiveUnitPrice',
|
||||
width: 100,
|
||||
width: 110,
|
||||
render: (v?: string) => {
|
||||
const n = Number.parseFloat(v || '')
|
||||
return Number.isFinite(n) ? `${n.toFixed(2)} 元` : '-'
|
||||
return Number.isFinite(n) ? `${(n / 100).toFixed(2)} 元` : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -803,25 +892,41 @@ export default function PlatformProducts() {
|
||||
<Space align="center">
|
||||
<CloudDownloadOutlined style={{ color: '#2563eb' }} />
|
||||
<span>乐玩开放平台商品库 (实时拉取)</span>
|
||||
{selectedLewanMap.size > 0 && (
|
||||
<Tag color="blue">已勾选 {selectedLewanMap.size} 项</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
placement="right"
|
||||
width={780}
|
||||
width={820}
|
||||
open={lewanDrawerOpen}
|
||||
onClose={() => setLewanDrawerOpen(false)}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
size="small"
|
||||
onClick={() => loadLewanProducts(lewanPage, lewanKeyword)}
|
||||
loading={lewanLoading}
|
||||
>
|
||||
刷新乐玩库
|
||||
</Button>
|
||||
<Space>
|
||||
{selectedLewanMap.size > 0 && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<ImportOutlined />}
|
||||
loading={batchSubmitting}
|
||||
onClick={handleBatchSync}
|
||||
>
|
||||
批量导入 ({selectedLewanMap.size})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
size="small"
|
||||
onClick={() => loadLewanProducts(lewanPage, lewanKeyword)}
|
||||
loading={lewanLoading}
|
||||
>
|
||||
刷新乐玩库
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 16 }}>
|
||||
此处实时连通乐玩开放平台 API 拉取最新可供货商品。您可以点击「导入为自营」直接生成自营商品,或在配置弹窗中直接关联该商品。
|
||||
此处实时连通乐玩开放平台 API 拉取最新可供货商品。勾选多项可一键批量导入,或点击单行「导入为自营」单独配置。
|
||||
</Paragraph>
|
||||
|
||||
<Space style={{ marginBottom: 16, width: '100%' }}>
|
||||
@@ -843,8 +948,64 @@ export default function PlatformProducts() {
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{selectedLewanMap.size > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: '#eff6ff',
|
||||
border: '1px solid #bfdbfe',
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<CheckCircleOutlined style={{ color: '#2563eb', fontSize: 16 }} />
|
||||
<span>
|
||||
已跨页勾选 <strong>{selectedLewanMap.size}</strong> 个商品
|
||||
</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setSelectedLewanMap(new Map())}>
|
||||
清空选择
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<ImportOutlined />}
|
||||
loading={batchSubmitting}
|
||||
onClick={handleBatchSync}
|
||||
>
|
||||
批量导入为自营 ({selectedLewanMap.size})
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
rowSelection={{
|
||||
selectedRowKeys: Array.from(selectedLewanMap.keys()),
|
||||
onChange: (keys: React.Key[], selectedRows: LewanProductItem[]) => {
|
||||
setSelectedLewanMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
const currentPageIds = new Set(lewanProducts.map((p) => p.id))
|
||||
for (const id of currentPageIds) {
|
||||
if (!keys.includes(id)) {
|
||||
next.delete(id)
|
||||
}
|
||||
}
|
||||
for (const row of selectedRows) {
|
||||
if (row && row.id) {
|
||||
next.set(row.id, row)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
}}
|
||||
loading={lewanLoading}
|
||||
columns={lewanColumns}
|
||||
dataSource={lewanProducts}
|
||||
|
||||
@@ -185,6 +185,8 @@ export interface DeliveryOrderInfo {
|
||||
product?: DeliveryProduct
|
||||
buyer_name: string
|
||||
amount: number
|
||||
provider_order_no?: string
|
||||
provider_name?: string
|
||||
created_at: string
|
||||
shipped_at?: string | null
|
||||
ship_fail_reason?: string
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 解析商品图片地址:
|
||||
* 1. 本地相对路径(以 / 开头)直接返回;
|
||||
* 2. 乐玩/官方商品 UUID 地址,优先使用已下载至本地的图片缓存;
|
||||
* 3. 其他外链图片走后端持久化缓存代理 (/api/delivery/v1/image-proxy),杜绝跨域拦截与画布污染。
|
||||
*/
|
||||
export function resolveProductImage(url?: string | null): string {
|
||||
if (!url) return ''
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// 匹配乐玩官方商品 ID:/products/<uuid>/...
|
||||
const uuidMatch = trimmed.match(/\/products\/([a-zA-Z0-9-]+)(?:\/|\.|$)/)
|
||||
if (uuidMatch && uuidMatch[1]) {
|
||||
return `/images/products/${uuidMatch[1]}.webp`
|
||||
}
|
||||
|
||||
// 其它外部地址统一经由代理及本地磁盘缓存返回
|
||||
return `/api/delivery/v1/image-proxy?url=${encodeURIComponent(trimmed)}`
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { DeliveryOrderInfo } from '../types'
|
||||
import { formatDateTime } from './time'
|
||||
import { resolveProductImage } from './productImage'
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('Failed to load image'))
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制高保真发货凭证卡片图片,返回 base64 PNG dataURL
|
||||
*/
|
||||
export async function generateDeliveryVoucher(order: DeliveryOrderInfo): Promise<string> {
|
||||
const width = 1080
|
||||
const height = 1160
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas 2D context not available')
|
||||
}
|
||||
|
||||
// 1. 背景底色
|
||||
ctx.fillStyle = '#f8fafc'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
// 2. 主卡片容器
|
||||
const cardX = 40
|
||||
const cardY = 40
|
||||
const cardW = width - 80
|
||||
const cardH = height - 80
|
||||
const cardRadius = 24
|
||||
|
||||
// 卡片阴影与白色背景
|
||||
ctx.save()
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.shadowColor = 'rgba(15, 23, 42, 0.08)'
|
||||
ctx.shadowBlur = 32
|
||||
ctx.shadowOffsetY = 16
|
||||
fillRoundRect(ctx, cardX, cardY, cardW, cardH, cardRadius)
|
||||
ctx.restore()
|
||||
|
||||
// 3. 顶部 Header (深色渐变)
|
||||
const headerH = 220
|
||||
const grad = ctx.createLinearGradient(cardX, cardY, cardX + cardW, cardY + headerH)
|
||||
grad.addColorStop(0, '#0f172a')
|
||||
grad.addColorStop(1, '#1e293b')
|
||||
|
||||
ctx.save()
|
||||
clipRoundRect(ctx, cardX, cardY, cardW, headerH, [cardRadius, cardRadius, 0, 0])
|
||||
ctx.fillStyle = grad
|
||||
ctx.fillRect(cardX, cardY, cardW, headerH)
|
||||
|
||||
// 顶部装饰圆点
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(cardX + cardW - 60, cardY + 40, 120, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// 平台徽标 / 标题
|
||||
ctx.fillStyle = '#38bdf8'
|
||||
ctx.font = 'bold 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText('GAME FULFILLMENT · OFFICIAL RECEIPT', cardX + 48, cardY + 60)
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 44px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText('游戏道具发货电子凭证', cardX + 48, cardY + 124)
|
||||
|
||||
ctx.fillStyle = '#94a3b8'
|
||||
ctx.font = '24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText('官方直充已完成 · 售后维权与电子核销依据', cardX + 48, cardY + 168)
|
||||
ctx.restore()
|
||||
|
||||
// 4. 发货成功电子印章 (Stamp)
|
||||
drawSuccessStamp(ctx, cardX + cardW - 170, cardY + 140)
|
||||
|
||||
// 5. 商品展示卡片
|
||||
const prodCardY = cardY + headerH + 32
|
||||
const prodCardH = 210
|
||||
ctx.fillStyle = '#f8fafc'
|
||||
fillRoundRect(ctx, cardX + 40, prodCardY, cardW - 80, prodCardH, 16)
|
||||
ctx.strokeStyle = '#e2e8f0'
|
||||
ctx.lineWidth = 1
|
||||
strokeRoundRect(ctx, cardX + 40, prodCardY, cardW - 80, prodCardH, 16)
|
||||
|
||||
// 尝试加载商品图片(优先命中本地下载缓存)
|
||||
let productImgLoaded = false
|
||||
if (order.product?.image) {
|
||||
try {
|
||||
const imgURL = resolveProductImage(order.product.image)
|
||||
const img = await loadImage(imgURL)
|
||||
const imgSize = 146
|
||||
const imgX = cardX + 64
|
||||
const imgY = prodCardY + 32
|
||||
ctx.save()
|
||||
clipRoundRect(ctx, imgX, imgY, imgSize, imgSize, 12)
|
||||
ctx.drawImage(img, imgX, imgY, imgSize, imgSize)
|
||||
ctx.restore()
|
||||
ctx.strokeStyle = '#cbd5e1'
|
||||
strokeRoundRect(ctx, imgX, imgY, imgSize, imgSize, 12)
|
||||
productImgLoaded = true
|
||||
} catch {
|
||||
productImgLoaded = false
|
||||
}
|
||||
}
|
||||
|
||||
// 商品文字信息
|
||||
const textLeft = productImgLoaded ? cardX + 240 : cardX + 72
|
||||
ctx.fillStyle = '#0f172a'
|
||||
ctx.font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText(truncateString(order.product?.name || '游戏道具商品', 20), textLeft, prodCardY + 70)
|
||||
|
||||
// 商品标签 (SKU & 游戏)
|
||||
ctx.fillStyle = '#64748b'
|
||||
ctx.font = '24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
const skuText = `SKU: ${order.product?.sku || '-'}`
|
||||
const gameText = order.product?.game ? ` · ${order.product.game}` : ''
|
||||
ctx.fillText(skuText + gameText, textLeft, prodCardY + 120)
|
||||
|
||||
// 金额与数量
|
||||
ctx.fillStyle = '#16a34a'
|
||||
ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText(`已发放: 1 件 | 价值: ${order.amount} 积分`, textLeft, prodCardY + 168)
|
||||
|
||||
// 6. 核心凭证字段明细表
|
||||
const tableY = prodCardY + prodCardH + 32
|
||||
const tableW = cardW - 80
|
||||
const fields = [
|
||||
{ label: '游戏账号 UID', value: order.game_uid || '-', highlight: true },
|
||||
{ label: '游戏角色名', value: order.role_name || '-', highlight: true },
|
||||
{ label: '区服信息', value: order.game_channel || '-' },
|
||||
{ label: '下单时间', value: formatDateTime(order.created_at) },
|
||||
{ label: '交付时间', value: order.shipped_at ? formatDateTime(order.shipped_at) : '已交付' },
|
||||
]
|
||||
|
||||
let curY = tableY
|
||||
const rowHeight = 72
|
||||
fields.forEach((f, idx) => {
|
||||
ctx.fillStyle = idx % 2 === 0 ? '#f8fafc' : '#ffffff'
|
||||
fillRoundRect(ctx, cardX + 40, curY, tableW, rowHeight, 8)
|
||||
|
||||
// 标签文字
|
||||
ctx.fillStyle = '#64748b'
|
||||
ctx.font = '26px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText(f.label, cardX + 68, curY + 46)
|
||||
|
||||
// 数值文字
|
||||
ctx.fillStyle = f.highlight ? '#0f172a' : '#334155'
|
||||
ctx.font = f.highlight
|
||||
? 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
: '26px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
const valText = truncateString(f.value, 36)
|
||||
ctx.fillText(valText, cardX + 320, curY + 46)
|
||||
|
||||
curY += rowHeight + 8
|
||||
})
|
||||
|
||||
// 7. 底部防伪与存证声明
|
||||
const footerY = curY + 24
|
||||
ctx.strokeStyle = '#e2e8f0'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cardX + 40, footerY)
|
||||
ctx.lineTo(cardX + cardW - 40, footerY)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#94a3b8'
|
||||
ctx.font = '22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText('注:此电子凭证为系统自动发货存证,道具已成功充值至指定角色,请登录游戏查收。', cardX + 48, footerY + 40)
|
||||
ctx.fillText(`凭据生成时间:${formatDateTime(new Date().toISOString())}`, cardX + 48, footerY + 76)
|
||||
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
|
||||
// 辅助:绘制发货成功的印章
|
||||
function drawSuccessStamp(ctx: CanvasRenderingContext2D, x: number, y: number) {
|
||||
ctx.save()
|
||||
ctx.translate(x, y)
|
||||
ctx.rotate((-14 * Math.PI) / 180) // 稍倾斜印章质感
|
||||
|
||||
// 外圈圆形边框
|
||||
ctx.strokeStyle = '#22c55e'
|
||||
ctx.lineWidth = 4
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, 68, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
|
||||
// 内圈虚线
|
||||
ctx.setLineDash([4, 4])
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, 60, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
|
||||
// 印章文字
|
||||
ctx.fillStyle = '#22c55e'
|
||||
ctx.font = 'bold 22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText('已发货完成', 0, -12)
|
||||
|
||||
ctx.font = 'bold 15px sans-serif'
|
||||
ctx.fillText('VERIFIED', 0, 16)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function fillRoundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number | number[]
|
||||
) {
|
||||
ctx.beginPath()
|
||||
roundRectPath(ctx, x, y, width, height, radius)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
function strokeRoundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number | number[]
|
||||
) {
|
||||
ctx.beginPath()
|
||||
roundRectPath(ctx, x, y, width, height, radius)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function clipRoundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number | number[]
|
||||
) {
|
||||
ctx.beginPath()
|
||||
roundRectPath(ctx, x, y, width, height, radius)
|
||||
ctx.clip()
|
||||
}
|
||||
|
||||
function roundRectPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number | number[]
|
||||
) {
|
||||
const r = typeof radius === 'number' ? [radius, radius, radius, radius] : radius
|
||||
const [tl, tr, br, bl] = r
|
||||
ctx.moveTo(x + tl, y)
|
||||
ctx.lineTo(x + width - tr, y)
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + tr)
|
||||
ctx.lineTo(x + width, y + height - br)
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - br, y + height)
|
||||
ctx.lineTo(x + bl, y + height)
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - bl)
|
||||
ctx.lineTo(x, y + tl)
|
||||
ctx.quadraticCurveTo(x, y, x + tl, y)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function truncateString(str: string, maxLen: number): string {
|
||||
if (!str) return '-'
|
||||
if (str.length <= maxLen) return str
|
||||
return str.slice(0, maxLen - 1) + '…'
|
||||
}
|
||||