摸大红支持购物车多选一起结账
下单支持多商品行快照与一次支付;支付后整单复制文案,前台提供购物车加购与结账。
This commit is contained in:
@@ -66,6 +66,7 @@ type MohongOrder struct {
|
|||||||
AmountCent int64 `gorm:"column:amount_cent;not null;default:0" json:"-"`
|
AmountCent int64 `gorm:"column:amount_cent;not null;default:0" json:"-"`
|
||||||
Status string `gorm:"size:32;not null;default:'pending_payment';index" json:"status"`
|
Status string `gorm:"size:32;not null;default:'pending_payment';index" json:"status"`
|
||||||
ProductSnapshot datatypes.JSON `gorm:"column:product_snapshot" json:"product_snapshot"`
|
ProductSnapshot datatypes.JSON `gorm:"column:product_snapshot" json:"product_snapshot"`
|
||||||
|
ItemsSnapshot datatypes.JSON `gorm:"column:items_snapshot" json:"items_snapshot"`
|
||||||
QrcodeURLSnapshot string `gorm:"column:qrcode_url_snapshot;size:512;not null;default:''" json:"qrcode_url_snapshot"`
|
QrcodeURLSnapshot string `gorm:"column:qrcode_url_snapshot;size:512;not null;default:''" json:"qrcode_url_snapshot"`
|
||||||
CopyText string `gorm:"type:text;not null" json:"copy_text"`
|
CopyText string `gorm:"type:text;not null" json:"copy_text"`
|
||||||
PaidAt *time.Time `json:"paid_at"`
|
PaidAt *time.Time `json:"paid_at"`
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const (
|
|||||||
configKeyDefaultQrcodeURL = "mohong.default_qrcode_url"
|
configKeyDefaultQrcodeURL = "mohong.default_qrcode_url"
|
||||||
configKeyOrderCopyTemplate = "mohong.order_copy_template"
|
configKeyOrderCopyTemplate = "mohong.order_copy_template"
|
||||||
|
|
||||||
defaultOrderCopyTemplate = "【摸大红订单】\n订单号:{{order_no}}\n下单时间:{{created_at}}\n商品:{{product_title}}\n数量:{{quantity}}\n金额:{{amount}}\n下单人:{{buyer_name}}({{buyer_phone}})"
|
defaultOrderCopyTemplate = "【摸大红订单】\n订单号:{{order_no}}\n下单时间:{{created_at}}\n商品明细:\n{{items}}\n数量合计:{{quantity}}\n金额:{{amount}}\n下单人:{{buyer_name}}({{buyer_phone}})"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Repository) GetConfig(ctx context.Context) (*ConfigDTO, error) {
|
func (r *Repository) GetConfig(ctx context.Context) (*ConfigDTO, error) {
|
||||||
|
|||||||
@@ -87,11 +87,30 @@ type UpdateProductRequest struct {
|
|||||||
QrcodeImageURL *string `json:"qrcode_image_url"`
|
QrcodeImageURL *string `json:"qrcode_image_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateOrderRequest struct {
|
type CreateOrderItemRequest struct {
|
||||||
ProductID uint64 `json:"product_id" binding:"required"`
|
ProductID uint64 `json:"product_id" binding:"required"`
|
||||||
Quantity int `json:"quantity" binding:"required,min=1,max=99"`
|
Quantity int `json:"quantity" binding:"required,min=1,max=99"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateOrderRequest 支持单商品(product_id+quantity)或多商品(items)。
|
||||||
|
type CreateOrderRequest struct {
|
||||||
|
ProductID uint64 `json:"product_id"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
Items []CreateOrderItemRequest `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderItemDTO struct {
|
||||||
|
ProductID uint64 `json:"product_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
UnitPriceCent int64 `json:"unit_price_cent"`
|
||||||
|
UnitPrice string `json:"unit_price"`
|
||||||
|
AmountCent int64 `json:"amount_cent"`
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
type OrderDTO struct {
|
type OrderDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
@@ -106,6 +125,7 @@ type OrderDTO struct {
|
|||||||
ProductTitle string `json:"product_title"`
|
ProductTitle string `json:"product_title"`
|
||||||
ProductCoverURL string `json:"product_cover_url"`
|
ProductCoverURL string `json:"product_cover_url"`
|
||||||
ProductUnit string `json:"product_unit"`
|
ProductUnit string `json:"product_unit"`
|
||||||
|
Items []OrderItemDTO `json:"items,omitempty"`
|
||||||
QrcodeURLSnapshot string `json:"qrcode_url_snapshot"`
|
QrcodeURLSnapshot string `json:"qrcode_url_snapshot"`
|
||||||
CopyText string `json:"copy_text"`
|
CopyText string `json:"copy_text"`
|
||||||
BuyerNickname string `json:"buyer_nickname,omitempty"`
|
BuyerNickname string `json:"buyer_nickname,omitempty"`
|
||||||
@@ -162,3 +182,16 @@ type productSnapshot struct {
|
|||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
QrcodeImageURL string `json:"qrcode_image_url"`
|
QrcodeImageURL string `json:"qrcode_image_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// orderItemSnapshot 订单商品行快照(多商品结账)。
|
||||||
|
type orderItemSnapshot struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
ImageURLs []string `json:"image_urls"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
PriceCent int64 `json:"price_cent"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
QrcodeImageURL string `json:"qrcode_image_url"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,41 +20,43 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (r *Repository) CreateOrder(ctx context.Context, userID uint64, req CreateOrderRequest) (*OrderDTO, error) {
|
func (r *Repository) CreateOrder(ctx context.Context, userID uint64, req CreateOrderRequest) (*OrderDTO, error) {
|
||||||
if userID == 0 || req.ProductID == 0 || req.Quantity < 1 || req.Quantity > 99 {
|
if userID == 0 {
|
||||||
|
return nil, ErrInvalidRequest
|
||||||
|
}
|
||||||
|
reqItems := normalizeCreateOrderItems(req)
|
||||||
|
if len(reqItems) == 0 {
|
||||||
return nil, ErrInvalidRequest
|
return nil, ErrInvalidRequest
|
||||||
}
|
}
|
||||||
var createdID uint64
|
var createdID uint64
|
||||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
itemSnaps := make([]orderItemSnapshot, 0, len(reqItems))
|
||||||
|
var totalQty int
|
||||||
|
var totalAmount int64
|
||||||
|
var primary productSnapshot
|
||||||
|
|
||||||
|
for _, line := range reqItems {
|
||||||
var product model.MohongProduct
|
var product model.MohongProduct
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, req.ProductID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, line.ProductID).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return ErrProductNotFound
|
return ErrProductNotFound
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if product.Status != model.MohongProductStatusOnSale {
|
if product.Status != model.MohongProductStatusOnSale || product.PriceCent <= 0 {
|
||||||
return ErrProductUnavailable
|
return ErrProductUnavailable
|
||||||
}
|
}
|
||||||
if product.PriceCent <= 0 {
|
if product.Stock >= 0 && product.Stock < line.Quantity {
|
||||||
return ErrProductUnavailable
|
|
||||||
}
|
|
||||||
if product.Stock >= 0 && product.Stock < req.Quantity {
|
|
||||||
return ErrStockInsufficient
|
return ErrStockInsufficient
|
||||||
}
|
}
|
||||||
// 下单时预扣库存,取消未支付订单时归还。
|
// 下单时预扣库存,取消未支付订单时归还。
|
||||||
if product.Stock >= 0 {
|
if product.Stock >= 0 {
|
||||||
product.Stock -= req.Quantity
|
product.Stock -= line.Quantity
|
||||||
if err := tx.Save(&product).Error; err != nil {
|
if err := tx.Save(&product).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
orderNo, err := newMohongOrderNo()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
images := decodeStringList(product.ImageURLs)
|
images := decodeStringList(product.ImageURLs)
|
||||||
snap := productSnapshot{
|
item := orderItemSnapshot{
|
||||||
ID: product.ID,
|
ID: product.ID,
|
||||||
Title: product.Title,
|
Title: product.Title,
|
||||||
CoverURL: product.CoverURL,
|
CoverURL: product.CoverURL,
|
||||||
@@ -63,21 +65,43 @@ func (r *Repository) CreateOrder(ctx context.Context, userID uint64, req CreateO
|
|||||||
PriceCent: product.PriceCent,
|
PriceCent: product.PriceCent,
|
||||||
Unit: product.Unit,
|
Unit: product.Unit,
|
||||||
QrcodeImageURL: product.QrcodeImageURL,
|
QrcodeImageURL: product.QrcodeImageURL,
|
||||||
|
Quantity: line.Quantity,
|
||||||
}
|
}
|
||||||
snapRaw, err := json.Marshal(snap)
|
itemSnaps = append(itemSnaps, item)
|
||||||
|
totalQty += line.Quantity
|
||||||
|
totalAmount += product.PriceCent * int64(line.Quantity)
|
||||||
|
if primary.ID == 0 {
|
||||||
|
primary = productSnapshot{
|
||||||
|
ID: product.ID, Title: product.Title, CoverURL: product.CoverURL,
|
||||||
|
ImageURLs: images, Description: product.Description, PriceCent: product.PriceCent,
|
||||||
|
Unit: product.Unit, QrcodeImageURL: product.QrcodeImageURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orderNo, err := newMohongOrderNo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
amount := product.PriceCent * int64(req.Quantity)
|
primaryRaw, err := json.Marshal(primary)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
itemsRaw, err := json.Marshal(itemSnaps)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 展示字段:单商品保持原语义;多商品 quantity 为合计,unit_price 用首项单价。
|
||||||
order := model.MohongOrder{
|
order := model.MohongOrder{
|
||||||
OrderNo: orderNo,
|
OrderNo: orderNo,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ProductID: product.ID,
|
ProductID: primary.ID,
|
||||||
Quantity: req.Quantity,
|
Quantity: totalQty,
|
||||||
UnitPriceCent: product.PriceCent,
|
UnitPriceCent: primary.PriceCent,
|
||||||
AmountCent: amount,
|
AmountCent: totalAmount,
|
||||||
Status: model.MohongOrderStatusPendingPayment,
|
Status: model.MohongOrderStatusPendingPayment,
|
||||||
ProductSnapshot: datatypes.JSON(snapRaw),
|
ProductSnapshot: datatypes.JSON(primaryRaw),
|
||||||
|
ItemsSnapshot: datatypes.JSON(itemsRaw),
|
||||||
CopyText: "",
|
CopyText: "",
|
||||||
}
|
}
|
||||||
if err := tx.Create(&order).Error; err != nil {
|
if err := tx.Create(&order).Error; err != nil {
|
||||||
@@ -103,6 +127,40 @@ func (r *Repository) CreateOrder(ctx context.Context, userID uint64, req CreateO
|
|||||||
return r.FindOrderForUser(ctx, userID, createdID)
|
return r.FindOrderForUser(ctx, userID, createdID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeCreateOrderItems(req CreateOrderRequest) []CreateOrderItemRequest {
|
||||||
|
// 优先 items;否则兼容旧单商品参数。
|
||||||
|
if len(req.Items) > 0 {
|
||||||
|
merged := map[uint64]int{}
|
||||||
|
orderIDs := make([]uint64, 0, len(req.Items))
|
||||||
|
for _, item := range req.Items {
|
||||||
|
if item.ProductID == 0 || item.Quantity < 1 || item.Quantity > 99 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := merged[item.ProductID]; !ok {
|
||||||
|
orderIDs = append(orderIDs, item.ProductID)
|
||||||
|
}
|
||||||
|
merged[item.ProductID] += item.Quantity
|
||||||
|
if merged[item.ProductID] > 99 {
|
||||||
|
merged[item.ProductID] = 99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]CreateOrderItemRequest, 0, len(orderIDs))
|
||||||
|
for _, id := range orderIDs {
|
||||||
|
if qty := merged[id]; qty > 0 {
|
||||||
|
out = append(out, CreateOrderItemRequest{ProductID: id, Quantity: qty})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) > 50 {
|
||||||
|
return out[:50]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if req.ProductID > 0 && req.Quantity >= 1 && req.Quantity <= 99 {
|
||||||
|
return []CreateOrderItemRequest{{ProductID: req.ProductID, Quantity: req.Quantity}}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) FindOrderForUser(ctx context.Context, userID, orderID uint64) (*OrderDTO, error) {
|
func (r *Repository) FindOrderForUser(ctx context.Context, userID, orderID uint64) (*OrderDTO, error) {
|
||||||
var row model.MohongOrder
|
var row model.MohongOrder
|
||||||
if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ?", orderID, userID).First(&row).Error; err != nil {
|
if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ?", orderID, userID).First(&row).Error; err != nil {
|
||||||
@@ -272,9 +330,10 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint
|
|||||||
if err := tx.First(&user, order.UserID).Error; err != nil {
|
if err := tx.First(&user, order.UserID).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
items := decodeOrderItems(order)
|
||||||
snap := decodeProductSnapshot(order.ProductSnapshot)
|
snap := decodeProductSnapshot(order.ProductSnapshot)
|
||||||
qrcodeURL := r.resolveQrcodeURL(tx, snap.QrcodeImageURL)
|
qrcodeURL := r.resolveQrcodeURL(tx, resolveOrderQrcode(items, snap))
|
||||||
copyText := buildCopyText(r.loadCopyTemplate(tx), order, snap, user)
|
copyText := buildCopyText(r.loadCopyTemplate(tx), order, items, snap, user)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
order.Status = model.MohongOrderStatusPaid
|
order.Status = model.MohongOrderStatusPaid
|
||||||
@@ -303,18 +362,22 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint
|
|||||||
func (r *Repository) NotifyNewConversation(conversationID uint64) {}
|
func (r *Repository) NotifyNewConversation(conversationID uint64) {}
|
||||||
|
|
||||||
func (r *Repository) cancelPendingOrderTx(tx *gorm.DB, order *model.MohongOrder, reason string) error {
|
func (r *Repository) cancelPendingOrderTx(tx *gorm.DB, order *model.MohongOrder, reason string) error {
|
||||||
// 归还预扣库存
|
// 归还预扣库存(支持多商品行)
|
||||||
if order.Quantity > 0 {
|
for _, item := range decodeOrderItems(*order) {
|
||||||
|
if item.Quantity <= 0 || item.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var product model.MohongProduct
|
var product model.MohongProduct
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, order.ProductID).Error; err == nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, item.ID).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if product.Stock >= 0 {
|
if product.Stock >= 0 {
|
||||||
product.Stock += order.Quantity
|
product.Stock += item.Quantity
|
||||||
if err := tx.Save(&product).Error; err != nil {
|
if err := tx.Save(&product).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
order.Status = model.MohongOrderStatusCancelled
|
order.Status = model.MohongOrderStatusCancelled
|
||||||
order.CancelledAt = &now
|
order.CancelledAt = &now
|
||||||
@@ -324,17 +387,18 @@ func (r *Repository) cancelPendingOrderTx(tx *gorm.DB, order *model.MohongOrder,
|
|||||||
|
|
||||||
func (r *Repository) orderDTO(ctx context.Context, row model.MohongOrder, includeAdmin bool) (*OrderDTO, error) {
|
func (r *Repository) orderDTO(ctx context.Context, row model.MohongOrder, includeAdmin bool) (*OrderDTO, error) {
|
||||||
snap := decodeProductSnapshot(row.ProductSnapshot)
|
snap := decodeProductSnapshot(row.ProductSnapshot)
|
||||||
|
items := decodeOrderItems(row)
|
||||||
var nickname, phone string
|
var nickname, phone string
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := r.db.WithContext(ctx).Select("id", "nickname", "phone").First(&user, row.UserID).Error; err == nil {
|
if err := r.db.WithContext(ctx).Select("id", "nickname", "phone").First(&user, row.UserID).Error; err == nil {
|
||||||
nickname = user.Nickname
|
nickname = user.Nickname
|
||||||
phone = user.Phone
|
phone = user.Phone
|
||||||
}
|
}
|
||||||
dto := toOrderDTO(row, snap, nickname, phone, includeAdmin)
|
dto := toOrderDTO(row, snap, items, nickname, phone, includeAdmin)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCopyText(template string, order model.MohongOrder, snap productSnapshot, user model.User) string {
|
func buildCopyText(template string, order model.MohongOrder, items []orderItemSnapshot, snap productSnapshot, user model.User) string {
|
||||||
if strings.TrimSpace(template) == "" {
|
if strings.TrimSpace(template) == "" {
|
||||||
template = defaultOrderCopyTemplate
|
template = defaultOrderCopyTemplate
|
||||||
}
|
}
|
||||||
@@ -342,33 +406,110 @@ func buildCopyText(template string, order model.MohongOrder, snap productSnapsho
|
|||||||
if buyerName == "" {
|
if buyerName == "" {
|
||||||
buyerName = "用户"
|
buyerName = "用户"
|
||||||
}
|
}
|
||||||
|
createdAt := timeutil.ShanghaiNow().Format("2006-01-02 15:04:05")
|
||||||
|
if !order.CreatedAt.IsZero() {
|
||||||
|
createdAt = order.CreatedAt.In(timeutil.ShanghaiLocation()).Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
productTitle := snap.Title
|
||||||
|
if productTitle == "" && len(items) > 0 {
|
||||||
|
productTitle = items[0].Title
|
||||||
|
}
|
||||||
|
if len(items) > 1 {
|
||||||
|
productTitle = fmt.Sprintf("%s 等%d种", productTitle, len(items))
|
||||||
|
}
|
||||||
|
unit := snap.Unit
|
||||||
|
if unit == "" && len(items) > 0 {
|
||||||
|
unit = items[0].Unit
|
||||||
|
}
|
||||||
replacer := strings.NewReplacer(
|
replacer := strings.NewReplacer(
|
||||||
"{{order_no}}", order.OrderNo,
|
"{{order_no}}", order.OrderNo,
|
||||||
"{{created_at}}", timeutil.ShanghaiNow().Format("2006-01-02 15:04:05"),
|
"{{created_at}}", createdAt,
|
||||||
"{{product_title}}", snap.Title,
|
"{{product_title}}", productTitle,
|
||||||
|
"{{items}}", formatItemsForCopy(items, snap, order.Quantity),
|
||||||
"{{quantity}}", fmt.Sprintf("%d", order.Quantity),
|
"{{quantity}}", fmt.Sprintf("%d", order.Quantity),
|
||||||
"{{amount}}", money.FormatWithSymbol(order.AmountCent),
|
"{{amount}}", money.FormatWithSymbol(order.AmountCent),
|
||||||
"{{buyer_name}}", buyerName,
|
"{{buyer_name}}", buyerName,
|
||||||
"{{buyer_phone}}", maskPhone(user.Phone),
|
"{{buyer_phone}}", maskPhone(user.Phone),
|
||||||
"{{unit}}", snap.Unit,
|
"{{unit}}", unit,
|
||||||
)
|
)
|
||||||
// 下单时间用订单创建时间更准确
|
|
||||||
created := order.CreatedAt
|
|
||||||
if !created.IsZero() {
|
|
||||||
replacer = strings.NewReplacer(
|
|
||||||
"{{order_no}}", order.OrderNo,
|
|
||||||
"{{created_at}}", created.In(timeutil.ShanghaiLocation()).Format("2006-01-02 15:04:05"),
|
|
||||||
"{{product_title}}", snap.Title,
|
|
||||||
"{{quantity}}", fmt.Sprintf("%d", order.Quantity),
|
|
||||||
"{{amount}}", money.FormatWithSymbol(order.AmountCent),
|
|
||||||
"{{buyer_name}}", buyerName,
|
|
||||||
"{{buyer_phone}}", maskPhone(user.Phone),
|
|
||||||
"{{unit}}", snap.Unit,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return replacer.Replace(template)
|
return replacer.Replace(template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatItemsForCopy(items []orderItemSnapshot, snap productSnapshot, fallbackQty int) string {
|
||||||
|
if len(items) == 0 && snap.ID > 0 {
|
||||||
|
items = []orderItemSnapshot{{
|
||||||
|
ID: snap.ID, Title: snap.Title, PriceCent: snap.PriceCent,
|
||||||
|
Unit: snap.Unit, Quantity: fallbackQty,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
lines := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
lineAmount := item.PriceCent * int64(item.Quantity)
|
||||||
|
unit := strings.TrimSpace(item.Unit)
|
||||||
|
if unit == "" {
|
||||||
|
unit = "件"
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf(
|
||||||
|
"- %s ×%d%s 单价%s 小计%s",
|
||||||
|
item.Title,
|
||||||
|
item.Quantity,
|
||||||
|
unit,
|
||||||
|
money.FormatWithSymbol(item.PriceCent),
|
||||||
|
money.FormatWithSymbol(lineAmount),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeOrderItems(order model.MohongOrder) []orderItemSnapshot {
|
||||||
|
if len(order.ItemsSnapshot) > 0 {
|
||||||
|
var items []orderItemSnapshot
|
||||||
|
if err := json.Unmarshal(order.ItemsSnapshot, &items); err == nil && len(items) > 0 {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兼容旧订单:从 product_snapshot 还原单行
|
||||||
|
snap := decodeProductSnapshot(order.ProductSnapshot)
|
||||||
|
if snap.ID == 0 && order.ProductID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
qty := order.Quantity
|
||||||
|
if qty < 1 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
id := snap.ID
|
||||||
|
if id == 0 {
|
||||||
|
id = order.ProductID
|
||||||
|
}
|
||||||
|
price := snap.PriceCent
|
||||||
|
if price <= 0 {
|
||||||
|
price = order.UnitPriceCent
|
||||||
|
}
|
||||||
|
return []orderItemSnapshot{{
|
||||||
|
ID: id,
|
||||||
|
Title: firstNonEmpty(snap.Title, "商品"),
|
||||||
|
CoverURL: snap.CoverURL,
|
||||||
|
ImageURLs: snap.ImageURLs,
|
||||||
|
Description: snap.Description,
|
||||||
|
PriceCent: price,
|
||||||
|
Unit: snap.Unit,
|
||||||
|
QrcodeImageURL: snap.QrcodeImageURL,
|
||||||
|
Quantity: qty,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveOrderQrcode(items []orderItemSnapshot, snap productSnapshot) string {
|
||||||
|
for _, item := range items {
|
||||||
|
if strings.TrimSpace(item.QrcodeImageURL) != "" {
|
||||||
|
return item.QrcodeImageURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snap.QrcodeImageURL
|
||||||
|
}
|
||||||
|
|
||||||
func newMohongOrderNo() (string, error) {
|
func newMohongOrderNo() (string, error) {
|
||||||
var b [4]byte
|
var b [4]byte
|
||||||
if _, err := rand.Read(b[:]); err != nil {
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package mohong
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
@@ -42,7 +43,40 @@ func toProductDTOWithCategory(row model.MohongProduct, categoryName string, incl
|
|||||||
return dto
|
return dto
|
||||||
}
|
}
|
||||||
|
|
||||||
func toOrderDTO(row model.MohongOrder, snap productSnapshot, buyerNickname, buyerPhone string, includeAdmin bool) OrderDTO {
|
func toOrderDTO(row model.MohongOrder, snap productSnapshot, items []orderItemSnapshot, buyerNickname, buyerPhone string, includeAdmin bool) OrderDTO {
|
||||||
|
if len(items) == 0 {
|
||||||
|
items = decodeOrderItems(row)
|
||||||
|
}
|
||||||
|
title := snap.Title
|
||||||
|
cover := snap.CoverURL
|
||||||
|
unit := snap.Unit
|
||||||
|
if title == "" && len(items) > 0 {
|
||||||
|
title = items[0].Title
|
||||||
|
}
|
||||||
|
if cover == "" && len(items) > 0 {
|
||||||
|
cover = items[0].CoverURL
|
||||||
|
}
|
||||||
|
if unit == "" && len(items) > 0 {
|
||||||
|
unit = items[0].Unit
|
||||||
|
}
|
||||||
|
if len(items) > 1 && title != "" {
|
||||||
|
title = fmt.Sprintf("%s 等%d种", title, len(items))
|
||||||
|
}
|
||||||
|
itemDTOs := make([]OrderItemDTO, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
lineAmount := item.PriceCent * int64(item.Quantity)
|
||||||
|
itemDTOs = append(itemDTOs, OrderItemDTO{
|
||||||
|
ProductID: item.ID,
|
||||||
|
Title: item.Title,
|
||||||
|
CoverURL: item.CoverURL,
|
||||||
|
Unit: item.Unit,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPriceCent: item.PriceCent,
|
||||||
|
UnitPrice: money.Format(item.PriceCent),
|
||||||
|
AmountCent: lineAmount,
|
||||||
|
Amount: money.Format(lineAmount),
|
||||||
|
})
|
||||||
|
}
|
||||||
dto := OrderDTO{
|
dto := OrderDTO{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
OrderNo: row.OrderNo,
|
OrderNo: row.OrderNo,
|
||||||
@@ -54,9 +88,10 @@ func toOrderDTO(row model.MohongOrder, snap productSnapshot, buyerNickname, buye
|
|||||||
AmountCent: row.AmountCent,
|
AmountCent: row.AmountCent,
|
||||||
Amount: money.Format(row.AmountCent),
|
Amount: money.Format(row.AmountCent),
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
ProductTitle: snap.Title,
|
ProductTitle: title,
|
||||||
ProductCoverURL: snap.CoverURL,
|
ProductCoverURL: cover,
|
||||||
ProductUnit: snap.Unit,
|
ProductUnit: unit,
|
||||||
|
Items: itemDTOs,
|
||||||
QrcodeURLSnapshot: row.QrcodeURLSnapshot,
|
QrcodeURLSnapshot: row.QrcodeURLSnapshot,
|
||||||
CopyText: row.CopyText,
|
CopyText: row.CopyText,
|
||||||
CancelReason: row.CancelReason,
|
CancelReason: row.CancelReason,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- 摸大红订单支持多商品:商品行快照(兼容旧单:无 items 时仍读 product_snapshot)
|
||||||
|
ALTER TABLE mohong_orders
|
||||||
|
ADD COLUMN items_snapshot JSON NULL COMMENT '订单商品行快照[{product_id,title,quantity,unit_price_cent,...}]' AFTER product_snapshot;
|
||||||
|
|
||||||
|
UPDATE system_configs
|
||||||
|
SET `value` = '【摸大红订单】\n订单号:{{order_no}}\n下单时间:{{created_at}}\n商品明细:\n{{items}}\n数量合计:{{quantity}}\n金额:{{amount}}\n下单人:{{buyer_name}}({{buyer_phone}})',
|
||||||
|
description = '摸大红订单一键复制文案模板(支持多商品 {{items}})'
|
||||||
|
WHERE `key` = 'mohong.order_copy_template'
|
||||||
|
AND (`value` LIKE '%商品:{{product_title}}%' OR `value` = '');
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE mohong_orders DROP COLUMN items_snapshot;
|
||||||
@@ -90,8 +90,9 @@ async function uploadQrcode(file: File) {
|
|||||||
<el-input v-model="form.order_copy_template" type="textarea" :rows="8" />
|
<el-input v-model="form.order_copy_template" type="textarea" :rows="8" />
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
可用变量:{{order_no}} {{created_at}}
|
可用变量:{{order_no}} {{created_at}}
|
||||||
{{product_title}} {{quantity}} {{amount}}
|
{{items}}(多商品明细) {{product_title}}
|
||||||
{{buyer_name}} {{buyer_phone}} {{unit}}
|
{{quantity}} {{amount}} {{buyer_name}}
|
||||||
|
{{buyer_phone}} {{unit}}
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|||||||
@@ -175,6 +175,13 @@ async function copyText(text: string) {
|
|||||||
{{ detail.buyer_nickname }} {{ detail.buyer_phone }}
|
{{ detail.buyer_nickname }} {{ detail.buyer_phone }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
<div v-if="detail.items?.length" class="items-box">
|
||||||
|
<strong>商品明细</strong>
|
||||||
|
<div v-for="item in detail.items" :key="item.product_id" class="item-line">
|
||||||
|
<span>{{ item.title }} × {{ item.quantity }}</span>
|
||||||
|
<em>¥{{ item.amount || formatCent(item.amount_cent) }}</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="detail.copy_text" class="copy-box">
|
<div v-if="detail.copy_text" class="copy-box">
|
||||||
<div class="copy-head">
|
<div class="copy-head">
|
||||||
<strong>复制文案</strong>
|
<strong>复制文案</strong>
|
||||||
@@ -215,6 +222,23 @@ async function copyText(text: string) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.items-box {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.item-line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
.item-line em {
|
||||||
|
font-style: normal;
|
||||||
|
color: #ff6a00;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.copy-box,
|
.copy-box,
|
||||||
.qr-box {
|
.qr-box {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ export interface MohongProduct {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MohongOrderItem {
|
||||||
|
product_id: number
|
||||||
|
title: string
|
||||||
|
cover_url: string
|
||||||
|
unit: string
|
||||||
|
quantity: number
|
||||||
|
unit_price_cent: number
|
||||||
|
unit_price: string
|
||||||
|
amount_cent: number
|
||||||
|
amount: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface MohongOrder {
|
export interface MohongOrder {
|
||||||
id: number
|
id: number
|
||||||
order_no: string
|
order_no: string
|
||||||
@@ -48,6 +60,7 @@ export interface MohongOrder {
|
|||||||
product_title: string
|
product_title: string
|
||||||
product_cover_url: string
|
product_cover_url: string
|
||||||
product_unit: string
|
product_unit: string
|
||||||
|
items?: MohongOrderItem[]
|
||||||
qrcode_url_snapshot: string
|
qrcode_url_snapshot: string
|
||||||
copy_text: string
|
copy_text: string
|
||||||
buyer_nickname?: string
|
buyer_nickname?: string
|
||||||
@@ -61,6 +74,11 @@ export interface MohongOrder {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MohongCheckoutItem {
|
||||||
|
product_id: number
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface MohongConfig {
|
export interface MohongConfig {
|
||||||
default_qrcode_url: string
|
default_qrcode_url: string
|
||||||
order_copy_template: string
|
order_copy_template: string
|
||||||
@@ -95,11 +113,15 @@ export async function fetchMohongProduct(id: number | string) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMohongOrder(productId: number, quantity: number) {
|
/** 单商品或购物车多商品结账 */
|
||||||
const { data } = await apiClient.post<ApiResponse<MohongOrder>>('/mohong/orders', {
|
export async function createMohongOrder(
|
||||||
product_id: productId,
|
productIdOrItems: number | MohongCheckoutItem[],
|
||||||
quantity,
|
quantity = 1
|
||||||
})
|
) {
|
||||||
|
const payload = Array.isArray(productIdOrItems)
|
||||||
|
? { items: productIdOrItems }
|
||||||
|
: { product_id: productIdOrItems, quantity }
|
||||||
|
const { data } = await apiClient.post<ApiResponse<MohongOrder>>('/mohong/orders', payload)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Delete, ShoppingCart } from '@element-plus/icons-vue'
|
||||||
|
import { createMohongOrder, startMohongPayment } from '@/features/mohong/api/mohong'
|
||||||
|
import { useMohongCart } from '@/features/mohong/composables/useMohongCart'
|
||||||
|
import type { PaymentPayWay } from '@/features/orders/api/orders'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { formatCent } from '@/shared/utils/money'
|
||||||
|
import { readError } from '@/shared/utils/error'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mobile?: boolean
|
||||||
|
selectPayWay: () => Promise<PaymentPayWay | null>
|
||||||
|
startCashier: (
|
||||||
|
payment: Awaited<ReturnType<typeof startMohongPayment>>,
|
||||||
|
onPaid: () => void | Promise<void>
|
||||||
|
) => Promise<void>
|
||||||
|
}>(),
|
||||||
|
{ mobile: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const session = useSessionStore()
|
||||||
|
const {
|
||||||
|
lines,
|
||||||
|
drawerOpen,
|
||||||
|
totalCount,
|
||||||
|
totalAmountCent,
|
||||||
|
isEmpty,
|
||||||
|
closeCart,
|
||||||
|
setQuantity,
|
||||||
|
removeLine,
|
||||||
|
clearCart,
|
||||||
|
checkoutItems,
|
||||||
|
} = useMohongCart()
|
||||||
|
const checkingOut = ref(false)
|
||||||
|
|
||||||
|
async function ensureAuthReady() {
|
||||||
|
const loginPath = props.mobile ? '/m/login' : '/login'
|
||||||
|
const realnamePath = props.mobile ? '/m/realname' : '/realname'
|
||||||
|
if (!session.isLoggedIn) {
|
||||||
|
router.push({ path: loginPath, query: { redirect: router.currentRoute.value.fullPath } })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await session.loadMe()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (session.realnameStatus !== 'verified') {
|
||||||
|
router.push({ path: realnamePath, query: { redirect: router.currentRoute.value.fullPath } })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCheckout() {
|
||||||
|
if (isEmpty.value || checkingOut.value) return
|
||||||
|
if (!(await ensureAuthReady())) return
|
||||||
|
const payWay = await props.selectPayWay()
|
||||||
|
if (!payWay) return
|
||||||
|
|
||||||
|
checkingOut.value = true
|
||||||
|
try {
|
||||||
|
const order = await createMohongOrder(checkoutItems())
|
||||||
|
clearCart()
|
||||||
|
closeCart()
|
||||||
|
const payment = await startMohongPayment(order.id, payWay)
|
||||||
|
const orderPath = props.mobile ? `/m/mohong/orders/${order.id}` : `/mohong/orders/${order.id}`
|
||||||
|
if (payment.paid || payment.status === 'paid') {
|
||||||
|
ElMessage.success('支付成功')
|
||||||
|
await router.push(orderPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await props.startCashier(payment, async () => {
|
||||||
|
await router.push(orderPath)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '结账失败'))
|
||||||
|
} finally {
|
||||||
|
checkingOut.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
v-if="!mobile"
|
||||||
|
v-model="drawerOpen"
|
||||||
|
title="购物车"
|
||||||
|
size="420px"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<div v-if="isEmpty" class="empty">
|
||||||
|
<el-icon :size="36"><ShoppingCart /></el-icon>
|
||||||
|
<p>还没有选购商品</p>
|
||||||
|
<el-button type="primary" color="#ff6a00" @click="closeCart">去选购</el-button>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="cart-list">
|
||||||
|
<div v-for="line in lines" :key="line.product_id" class="cart-line">
|
||||||
|
<div class="cover">
|
||||||
|
<img v-if="line.cover_url" :src="line.cover_url" :alt="line.title" />
|
||||||
|
<span v-else>{{ line.title.slice(0, 1) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<strong>{{ line.title }}</strong>
|
||||||
|
<p>¥{{ line.price || formatCent(line.price_cent) }} / {{ line.unit }}</p>
|
||||||
|
<div class="line-actions">
|
||||||
|
<el-input-number
|
||||||
|
:model-value="line.quantity"
|
||||||
|
:min="1"
|
||||||
|
:max="line.stock > 0 ? line.stock : 99"
|
||||||
|
size="small"
|
||||||
|
@update:model-value="(v: number | undefined) => setQuantity(line.product_id, Number(v || 1))"
|
||||||
|
/>
|
||||||
|
<el-button text type="danger" :icon="Delete" @click="removeLine(line.product_id)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="line-amount">¥{{ formatCent(line.price_cent * line.quantity) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cart-footer">
|
||||||
|
<div>
|
||||||
|
共 {{ totalCount }} 件
|
||||||
|
<strong>¥{{ formatCent(totalAmountCent) }}</strong>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" color="#ff6a00" :loading="checkingOut" @click="handleCheckout">
|
||||||
|
一起结账
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<van-popup
|
||||||
|
v-else
|
||||||
|
v-model:show="drawerOpen"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
:style="{ maxHeight: '78vh' }"
|
||||||
|
>
|
||||||
|
<div class="m-cart">
|
||||||
|
<header class="m-head">
|
||||||
|
<h3>购物车</h3>
|
||||||
|
<button type="button" @click="closeCart">关闭</button>
|
||||||
|
</header>
|
||||||
|
<div v-if="isEmpty" class="empty">
|
||||||
|
<p>还没有选购商品</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="m-list">
|
||||||
|
<div v-for="line in lines" :key="line.product_id" class="m-line">
|
||||||
|
<div class="cover">
|
||||||
|
<img v-if="line.cover_url" :src="line.cover_url" :alt="line.title" />
|
||||||
|
<span v-else>{{ line.title.slice(0, 1) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<strong>{{ line.title }}</strong>
|
||||||
|
<p>¥{{ line.price || formatCent(line.price_cent) }}</p>
|
||||||
|
<div class="line-actions">
|
||||||
|
<van-stepper
|
||||||
|
:model-value="line.quantity"
|
||||||
|
:min="1"
|
||||||
|
:max="line.stock > 0 ? line.stock : 99"
|
||||||
|
@change="(v: number) => setQuantity(line.product_id, Number(v || 1))"
|
||||||
|
/>
|
||||||
|
<button type="button" class="rm" @click="removeLine(line.product_id)">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer v-if="!isEmpty" class="m-footer">
|
||||||
|
<div>
|
||||||
|
共{{ totalCount }}件
|
||||||
|
<strong>¥{{ formatCent(totalAmountCent) }}</strong>
|
||||||
|
</div>
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
color="#ff6a00"
|
||||||
|
round
|
||||||
|
:loading="checkingOut"
|
||||||
|
@click="handleCheckout"
|
||||||
|
>
|
||||||
|
一起结账
|
||||||
|
</van-button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.cart-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
.cart-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.cover {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f1f5f9;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.info strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.info p {
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.line-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.line-amount {
|
||||||
|
color: #ff6a00;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cart-footer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.cart-footer strong {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #ff6a00;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.m-cart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 78vh;
|
||||||
|
}
|
||||||
|
.m-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid #eef1f5;
|
||||||
|
}
|
||||||
|
.m-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.m-head button {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.m-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.m-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
.rm {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.m-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
}
|
||||||
|
.m-footer strong {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: #ff6a00;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { MohongProduct } from '@/features/mohong/api/mohong'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'mohong_cart_v1'
|
||||||
|
|
||||||
|
export interface MohongCartLine {
|
||||||
|
product_id: number
|
||||||
|
title: string
|
||||||
|
cover_url: string
|
||||||
|
unit: string
|
||||||
|
price_cent: number
|
||||||
|
price: string
|
||||||
|
stock: number
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromStorage(): MohongCartLine[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
return parsed
|
||||||
|
.filter(
|
||||||
|
(item): item is MohongCartLine =>
|
||||||
|
item &&
|
||||||
|
typeof item.product_id === 'number' &&
|
||||||
|
item.product_id > 0 &&
|
||||||
|
typeof item.quantity === 'number' &&
|
||||||
|
item.quantity > 0
|
||||||
|
)
|
||||||
|
.map(item => ({
|
||||||
|
product_id: item.product_id,
|
||||||
|
title: String(item.title || '商品'),
|
||||||
|
cover_url: String(item.cover_url || ''),
|
||||||
|
unit: String(item.unit || '份'),
|
||||||
|
price_cent: Number(item.price_cent) || 0,
|
||||||
|
price: String(item.price || ''),
|
||||||
|
stock: typeof item.stock === 'number' ? item.stock : -1,
|
||||||
|
quantity: Math.min(99, Math.max(1, Math.floor(item.quantity))),
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = ref<MohongCartLine[]>(loadFromStorage())
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
lines,
|
||||||
|
value => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
// ignore quota
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function maxQtyFor(stock: number) {
|
||||||
|
if (stock < 0) return 99
|
||||||
|
return Math.max(0, Math.min(99, stock))
|
||||||
|
}
|
||||||
|
|
||||||
|
function productToLine(product: MohongProduct, quantity = 1): MohongCartLine | null {
|
||||||
|
const max = maxQtyFor(product.stock)
|
||||||
|
if (max <= 0) return null
|
||||||
|
return {
|
||||||
|
product_id: product.id,
|
||||||
|
title: product.title,
|
||||||
|
cover_url: product.cover_url || product.image_urls?.[0] || '',
|
||||||
|
unit: product.unit || '份',
|
||||||
|
price_cent: product.price_cent,
|
||||||
|
price: product.price || '',
|
||||||
|
stock: product.stock,
|
||||||
|
quantity: Math.min(max, Math.max(1, quantity)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMohongCart() {
|
||||||
|
const totalCount = computed(() => lines.value.reduce((sum, item) => sum + item.quantity, 0))
|
||||||
|
const totalAmountCent = computed(() =>
|
||||||
|
lines.value.reduce((sum, item) => sum + item.price_cent * item.quantity, 0)
|
||||||
|
)
|
||||||
|
const isEmpty = computed(() => lines.value.length === 0)
|
||||||
|
|
||||||
|
function openCart() {
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
function closeCart() {
|
||||||
|
drawerOpen.value = false
|
||||||
|
}
|
||||||
|
function toggleCart() {
|
||||||
|
drawerOpen.value = !drawerOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProduct(product: MohongProduct, quantity = 1): { ok: boolean; message: string } {
|
||||||
|
const next = productToLine(product, quantity)
|
||||||
|
if (!next) {
|
||||||
|
return { ok: false, message: '暂时缺货' }
|
||||||
|
}
|
||||||
|
const existing = lines.value.find(item => item.product_id === product.id)
|
||||||
|
if (existing) {
|
||||||
|
const max = maxQtyFor(product.stock)
|
||||||
|
const qty = Math.min(max, existing.quantity + quantity)
|
||||||
|
if (qty <= existing.quantity) {
|
||||||
|
return { ok: false, message: '已达库存上限' }
|
||||||
|
}
|
||||||
|
existing.quantity = qty
|
||||||
|
existing.title = next.title
|
||||||
|
existing.cover_url = next.cover_url
|
||||||
|
existing.price_cent = next.price_cent
|
||||||
|
existing.price = next.price
|
||||||
|
existing.stock = next.stock
|
||||||
|
existing.unit = next.unit
|
||||||
|
return { ok: true, message: '已更新购物车数量' }
|
||||||
|
}
|
||||||
|
lines.value = [...lines.value, next]
|
||||||
|
return { ok: true, message: '已加入购物车' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuantity(productId: number, quantity: number) {
|
||||||
|
const line = lines.value.find(item => item.product_id === productId)
|
||||||
|
if (!line) return
|
||||||
|
const max = maxQtyFor(line.stock)
|
||||||
|
if (quantity < 1 || max <= 0) {
|
||||||
|
removeLine(productId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line.quantity = Math.min(max, Math.floor(quantity))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(productId: number) {
|
||||||
|
lines.value = lines.value.filter(item => item.product_id !== productId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCart() {
|
||||||
|
lines.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkoutItems() {
|
||||||
|
return lines.value.map(item => ({
|
||||||
|
product_id: item.product_id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lines,
|
||||||
|
drawerOpen,
|
||||||
|
totalCount,
|
||||||
|
totalAmountCent,
|
||||||
|
isEmpty,
|
||||||
|
openCart,
|
||||||
|
closeCart,
|
||||||
|
toggleCart,
|
||||||
|
addProduct,
|
||||||
|
setQuantity,
|
||||||
|
removeLine,
|
||||||
|
clearCart,
|
||||||
|
checkoutItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
startMohongPayment,
|
startMohongPayment,
|
||||||
type MohongProduct,
|
type MohongProduct,
|
||||||
} from '@/features/mohong/api/mohong'
|
} from '@/features/mohong/api/mohong'
|
||||||
|
import MohongCartPanel from '@/features/mohong/components/MohongCartPanel.vue'
|
||||||
|
import { useMohongCart } from '@/features/mohong/composables/useMohongCart'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { formatCent } from '@/shared/utils/money'
|
import { formatCent } from '@/shared/utils/money'
|
||||||
import { readError } from '@/shared/utils/error'
|
import { readError } from '@/shared/utils/error'
|
||||||
@@ -20,6 +22,7 @@ import { readError } from '@/shared/utils/error'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
|
const { addProduct, openCart, totalCount: cartCount } = useMohongCart()
|
||||||
const product = ref<MohongProduct | null>(null)
|
const product = ref<MohongProduct | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const quantity = ref(1)
|
const quantity = ref(1)
|
||||||
@@ -91,6 +94,19 @@ async function ensureAuthReady() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAddCart() {
|
||||||
|
if (!product.value) return
|
||||||
|
if (!canBuy.value) {
|
||||||
|
showToast({ message: '库存不足', icon: 'cross' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = addProduct(product.value, quantity.value)
|
||||||
|
showToast({
|
||||||
|
message: result.message,
|
||||||
|
icon: result.ok ? 'passed' : 'cross',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBuy() {
|
async function handleBuy() {
|
||||||
if (!product.value || submitting.value) return
|
if (!product.value || submitting.value) return
|
||||||
if (!(await ensureAuthReady())) return
|
if (!(await ensureAuthReady())) return
|
||||||
@@ -107,11 +123,11 @@ async function handleBuy() {
|
|||||||
const payment = await startMohongPayment(order.id, payWay)
|
const payment = await startMohongPayment(order.id, payWay)
|
||||||
if (payment.paid || payment.status === 'paid') {
|
if (payment.paid || payment.status === 'paid') {
|
||||||
showToast({ message: '支付成功', icon: 'passed' })
|
showToast({ message: '支付成功', icon: 'passed' })
|
||||||
await router.push(`/mohong/orders/${order.id}`)
|
await router.push(`/m/mohong/orders/${order.id}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await openMobilePaymentCashier(payment, async () => {
|
await openMobilePaymentCashier(payment, async () => {
|
||||||
await router.push(`/mohong/orders/${order.id}`)
|
await router.push(`/m/mohong/orders/${order.id}`)
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast({ message: readError(error, '下单失败'), icon: 'cross' })
|
showToast({ message: readError(error, '下单失败'), icon: 'cross' })
|
||||||
@@ -164,9 +180,16 @@ async function handleBuy() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="buy-bar">
|
<div class="buy-bar">
|
||||||
|
<button type="button" class="cart-entry" @click="openCart">
|
||||||
|
<van-icon name="shopping-cart-o" :size="20" />
|
||||||
|
<em v-if="cartCount > 0">{{ cartCount }}</em>
|
||||||
|
</button>
|
||||||
<div class="sum">
|
<div class="sum">
|
||||||
合计 <strong>¥{{ totalPrice }}</strong>
|
合计 <strong>¥{{ totalPrice }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<van-button plain round color="#ff6a00" :disabled="!canBuy" @click="handleAddCart">
|
||||||
|
加购
|
||||||
|
</van-button>
|
||||||
<van-button
|
<van-button
|
||||||
type="primary"
|
type="primary"
|
||||||
color="#ff6a00"
|
color="#ff6a00"
|
||||||
@@ -175,12 +198,17 @@ async function handleBuy() {
|
|||||||
:disabled="!canBuy"
|
:disabled="!canBuy"
|
||||||
@click="handleBuy"
|
@click="handleBuy"
|
||||||
>
|
>
|
||||||
{{ canBuy ? '立即购买' : '暂时缺货' }}
|
{{ canBuy ? '购买' : '缺货' }}
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<van-empty v-else description="商品不存在" />
|
<van-empty v-else description="商品不存在" />
|
||||||
|
|
||||||
|
<MohongCartPanel
|
||||||
|
mobile
|
||||||
|
:select-pay-way="() => payWaySelect.select()"
|
||||||
|
:start-cashier="(payment, onPaid) => openMobilePaymentCashier(payment, onPaid)"
|
||||||
|
/>
|
||||||
<MobilePayWaySelectPopup
|
<MobilePayWaySelectPopup
|
||||||
v-model:show="payWaySelect.visible.value"
|
v-model:show="payWaySelect.visible.value"
|
||||||
@choose="payWaySelect.choose"
|
@choose="payWaySelect.choose"
|
||||||
@@ -297,22 +325,51 @@ async function handleBuy() {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
gap: 12px;
|
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-top: 1px solid #eef1f5;
|
border-top: 1px solid #eef1f5;
|
||||||
}
|
}
|
||||||
|
.cart-entry {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #ff6a00;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cart-entry em {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.sum {
|
.sum {
|
||||||
|
flex: 1;
|
||||||
color: #6b7a90;
|
color: #6b7a90;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.sum strong {
|
.sum strong {
|
||||||
color: #ff6a00;
|
color: #ff6a00;
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
.buy-bar :deep(.van-button) {
|
.buy-bar :deep(.van-button) {
|
||||||
min-width: 128px;
|
min-width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,9 +7,16 @@ import { ensureSupportChat } from '@/features/chats/api/chats'
|
|||||||
import {
|
import {
|
||||||
fetchMohongCategories,
|
fetchMohongCategories,
|
||||||
fetchMohongProducts,
|
fetchMohongProducts,
|
||||||
|
queryMohongPayment,
|
||||||
type MohongCategory,
|
type MohongCategory,
|
||||||
type MohongProduct,
|
type MohongProduct,
|
||||||
} from '@/features/mohong/api/mohong'
|
} from '@/features/mohong/api/mohong'
|
||||||
|
import MohongCartPanel from '@/features/mohong/components/MohongCartPanel.vue'
|
||||||
|
import { useMohongCart } from '@/features/mohong/composables/useMohongCart'
|
||||||
|
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
|
||||||
|
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
|
||||||
|
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
|
||||||
|
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { formatCent } from '@/shared/utils/money'
|
import { formatCent } from '@/shared/utils/money'
|
||||||
import { readError } from '@/shared/utils/error'
|
import { readError } from '@/shared/utils/error'
|
||||||
@@ -17,6 +24,21 @@ import { readError } from '@/shared/utils/error'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
|
const { totalCount: cartCount, openCart } = useMohongCart()
|
||||||
|
const payWaySelect = useMobilePayWaySelect()
|
||||||
|
const {
|
||||||
|
paymentPopupVisible,
|
||||||
|
activePayment,
|
||||||
|
payURL,
|
||||||
|
paymentQRCodeURL,
|
||||||
|
qrGenerating,
|
||||||
|
checkingPayment,
|
||||||
|
openMobilePaymentCashier,
|
||||||
|
refreshPaymentStatus,
|
||||||
|
} = useMobilePaymentCashier({
|
||||||
|
queryPayment: queryMohongPayment,
|
||||||
|
paidMessage: '支付成功',
|
||||||
|
})
|
||||||
const supportLoading = ref(false)
|
const supportLoading = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const products = ref<MohongProduct[]>([])
|
const products = ref<MohongProduct[]>([])
|
||||||
@@ -191,6 +213,9 @@ async function handleSupportClick() {
|
|||||||
@input="onSearchInput"
|
@input="onSearchInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="orders-btn" @click="openCart">
|
||||||
|
购物车{{ cartCount > 0 ? `(${cartCount})` : '' }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="orders-btn"
|
class="orders-btn"
|
||||||
@@ -264,6 +289,25 @@ async function handleSupportClick() {
|
|||||||
<div v-else-if="!loading && products.length && !hasMore" class="load-tip">已经到底了</div>
|
<div v-else-if="!loading && products.length && !hasMore" class="load-tip">已经到底了</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<MohongCartPanel
|
||||||
|
mobile
|
||||||
|
:select-pay-way="() => payWaySelect.select()"
|
||||||
|
:start-cashier="(payment, onPaid) => openMobilePaymentCashier(payment, onPaid)"
|
||||||
|
/>
|
||||||
|
<MobilePayWaySelectPopup
|
||||||
|
v-model:show="payWaySelect.visible.value"
|
||||||
|
@choose="payWaySelect.choose"
|
||||||
|
@closed="payWaySelect.handleClosed"
|
||||||
|
/>
|
||||||
|
<MobilePaymentCashierPopup
|
||||||
|
v-model:show="paymentPopupVisible"
|
||||||
|
:payment="activePayment"
|
||||||
|
:pay-url="payURL"
|
||||||
|
:qr-code-url="paymentQRCodeURL"
|
||||||
|
:qr-generating="qrGenerating"
|
||||||
|
:checking="checkingPayment"
|
||||||
|
@refresh="refreshPaymentStatus(false)"
|
||||||
|
/>
|
||||||
<MobileBottomNav />
|
<MobileBottomNav />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -127,14 +127,25 @@ async function copyText() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ order.product_title }}</h2>
|
<h2>{{ order.product_title }}</h2>
|
||||||
<p>
|
<p v-if="!order.items?.length">
|
||||||
¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }}
|
¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }}
|
||||||
{{ order.product_unit }}
|
{{ order.product_unit }}
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else>共 {{ order.items.length }} 种 · {{ order.quantity }} 件</p>
|
||||||
<strong>合计 ¥{{ order.amount || formatCent(order.amount_cent) }}</strong>
|
<strong>合计 ¥{{ order.amount || formatCent(order.amount_cent) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="order.items?.length" class="panel">
|
||||||
|
<h3>商品明细</h3>
|
||||||
|
<div v-for="item in order.items" :key="item.product_id" class="item-row">
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<em
|
||||||
|
>×{{ item.quantity }} · ¥{{ item.amount || formatCent(item.amount_cent) }}</em
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="order.copy_text" class="panel">
|
<section v-if="order.copy_text" class="panel">
|
||||||
<div class="copy-head">
|
<div class="copy-head">
|
||||||
<h3>订单信息(发给客服)</h3>
|
<h3>订单信息(发给客服)</h3>
|
||||||
@@ -272,6 +283,23 @@ async function copyText() {
|
|||||||
.product-panel strong {
|
.product-panel strong {
|
||||||
color: #ff6a00;
|
color: #ff6a00;
|
||||||
}
|
}
|
||||||
|
.item-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.item-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.item-row em {
|
||||||
|
font-style: normal;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.copy-head {
|
.copy-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
startMohongPayment,
|
startMohongPayment,
|
||||||
type MohongProduct,
|
type MohongProduct,
|
||||||
} from '@/features/mohong/api/mohong'
|
} from '@/features/mohong/api/mohong'
|
||||||
|
import MohongCartPanel from '@/features/mohong/components/MohongCartPanel.vue'
|
||||||
|
import { useMohongCart } from '@/features/mohong/composables/useMohongCart'
|
||||||
import type { PaymentPayWay } from '@/features/orders/api/orders'
|
import type { PaymentPayWay } from '@/features/orders/api/orders'
|
||||||
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
|
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
@@ -18,6 +20,7 @@ import { readError } from '@/shared/utils/error'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
|
const { addProduct, openCart, totalCount: cartCount } = useMohongCart()
|
||||||
const product = ref<MohongProduct | null>(null)
|
const product = ref<MohongProduct | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const quantity = ref(1)
|
const quantity = ref(1)
|
||||||
@@ -112,6 +115,20 @@ async function ensureAuthReady() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAddCart() {
|
||||||
|
if (!product.value) return
|
||||||
|
if (!canBuy.value) {
|
||||||
|
ElMessage.warning('库存不足')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = addProduct(product.value, quantity.value)
|
||||||
|
if (result.ok) {
|
||||||
|
ElMessage.success(result.message)
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(result.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBuy() {
|
async function handleBuy() {
|
||||||
if (!product.value || submitting.value) return
|
if (!product.value || submitting.value) return
|
||||||
if (!(await ensureAuthReady())) return
|
if (!(await ensureAuthReady())) return
|
||||||
@@ -204,6 +221,9 @@ async function handleBuy() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<el-button size="large" class="btn-outline" :disabled="!canBuy" @click="handleAddCart">
|
||||||
|
加入购物车
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -214,6 +234,9 @@ async function handleBuy() {
|
|||||||
>
|
>
|
||||||
{{ canBuy ? '立即购买' : '暂时缺货' }}
|
{{ canBuy ? '立即购买' : '暂时缺货' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button size="large" class="btn-outline" @click="openCart">
|
||||||
|
购物车{{ cartCount > 0 ? `(${cartCount})` : '' }}
|
||||||
|
</el-button>
|
||||||
<el-button size="large" class="btn-outline" @click="router.push('/mohong')">
|
<el-button size="large" class="btn-outline" @click="router.push('/mohong')">
|
||||||
返回列表
|
返回列表
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -223,8 +246,8 @@ async function handleBuy() {
|
|||||||
<p>购买须知</p>
|
<p>购买须知</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>下单需登录并完成实名认证</li>
|
<li>下单需登录并完成实名认证</li>
|
||||||
|
<li>可加入购物车多选,一起结账;支付后一键复制整单信息</li>
|
||||||
<li>支付成功后可在订单详情扫码联系客服</li>
|
<li>支付成功后可在订单详情扫码联系客服</li>
|
||||||
<li>订单详情支持一键复制订单信息发给客服</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,6 +255,11 @@ async function handleBuy() {
|
|||||||
</template>
|
</template>
|
||||||
<el-empty v-else-if="!loading" description="商品不存在" />
|
<el-empty v-else-if="!loading" description="商品不存在" />
|
||||||
|
|
||||||
|
<MohongCartPanel
|
||||||
|
:select-pay-way="selectPayWay"
|
||||||
|
:start-cashier="(payment, onPaid) => cashier.openPaymentCashier(payment, onPaid)"
|
||||||
|
/>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="payWayDialogVisible"
|
v-model="payWayDialogVisible"
|
||||||
title="选择支付方式"
|
title="选择支付方式"
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Search, Service, Tickets } from '@element-plus/icons-vue'
|
import { Search, Service, ShoppingCart, Tickets } from '@element-plus/icons-vue'
|
||||||
|
import MohongCartPanel from '@/features/mohong/components/MohongCartPanel.vue'
|
||||||
import MohongProductCard from '@/features/mohong/components/MohongProductCard.vue'
|
import MohongProductCard from '@/features/mohong/components/MohongProductCard.vue'
|
||||||
|
import { useMohongCart } from '@/features/mohong/composables/useMohongCart'
|
||||||
import { ensureSupportChat } from '@/features/chats/api/chats'
|
import { ensureSupportChat } from '@/features/chats/api/chats'
|
||||||
|
import type { PaymentPayWay } from '@/features/orders/api/orders'
|
||||||
|
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
|
||||||
|
import { queryMohongPayment, startMohongPayment } from '@/features/mohong/api/mohong'
|
||||||
import {
|
import {
|
||||||
fetchMohongCategories,
|
fetchMohongCategories,
|
||||||
fetchMohongProducts,
|
fetchMohongProducts,
|
||||||
@@ -17,8 +22,38 @@ import { readError } from '@/shared/utils/error'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
|
const { totalCount: cartCount, openCart } = useMohongCart()
|
||||||
const supportLoading = ref(false)
|
const supportLoading = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const payWayDialogVisible = ref(false)
|
||||||
|
let payWayResolver: ((value: PaymentPayWay | null) => void) | null = null
|
||||||
|
|
||||||
|
const cashier = useOrderPaymentCashier({
|
||||||
|
notifySuccess: msg => ElMessage.success(msg),
|
||||||
|
notifyError: msg => ElMessage.error(msg),
|
||||||
|
notifyInfo: msg => ElMessage.info(msg),
|
||||||
|
notifyFallback: msg => ElMessage.warning(msg),
|
||||||
|
paidMessage: '支付成功',
|
||||||
|
pollIntervalMs: 3000,
|
||||||
|
qrWidth: 240,
|
||||||
|
queryPayment: queryMohongPayment,
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectPayWay(): Promise<PaymentPayWay | null> {
|
||||||
|
payWayDialogVisible.value = true
|
||||||
|
return new Promise(resolve => {
|
||||||
|
payWayResolver = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function choosePayWay(payWay: PaymentPayWay) {
|
||||||
|
payWayResolver?.(payWay)
|
||||||
|
payWayResolver = null
|
||||||
|
payWayDialogVisible.value = false
|
||||||
|
}
|
||||||
|
function handlePayWayDialogClosed() {
|
||||||
|
payWayResolver?.(null)
|
||||||
|
payWayResolver = null
|
||||||
|
}
|
||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
const products = ref<MohongProduct[]>([])
|
const products = ref<MohongProduct[]>([])
|
||||||
const categories = ref<MohongCategory[]>([])
|
const categories = ref<MohongCategory[]>([])
|
||||||
@@ -188,6 +223,10 @@ async function handleSupportClick() {
|
|||||||
@input="onKeywordInput"
|
@input="onKeywordInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="toolbar-btn outline" @click="openCart">
|
||||||
|
<el-icon><ShoppingCart /></el-icon>
|
||||||
|
<span>购物车{{ cartCount > 0 ? `(${cartCount})` : '' }}</span>
|
||||||
|
</button>
|
||||||
<button type="button" class="toolbar-btn outline" @click="router.push('/mohong/orders')">
|
<button type="button" class="toolbar-btn outline" @click="router.push('/mohong/orders')">
|
||||||
<el-icon><Tickets /></el-icon>
|
<el-icon><Tickets /></el-icon>
|
||||||
<span>我的订单</span>
|
<span>我的订单</span>
|
||||||
@@ -252,6 +291,29 @@ async function handleSupportClick() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MohongCartPanel
|
||||||
|
:select-pay-way="selectPayWay"
|
||||||
|
:start-cashier="(payment, onPaid) => cashier.openPaymentCashier(payment, onPaid)"
|
||||||
|
/>
|
||||||
|
<el-dialog
|
||||||
|
v-model="payWayDialogVisible"
|
||||||
|
title="选择支付方式"
|
||||||
|
width="420px"
|
||||||
|
append-to-body
|
||||||
|
@closed="handlePayWayDialogClosed"
|
||||||
|
>
|
||||||
|
<div class="pay-way-options">
|
||||||
|
<button type="button" class="pay-way-option" @click="choosePayWay('WXZF')">
|
||||||
|
<strong>微信支付</strong>
|
||||||
|
<small>使用微信扫码完成支付</small>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pay-way-option" @click="choosePayWay('ZFBZF')">
|
||||||
|
<strong>支付宝支付</strong>
|
||||||
|
<small>使用支付宝扫码完成支付</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -481,6 +543,32 @@ async function handleSupportClick() {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pay-way-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.pay-way-option {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pay-way-option:hover {
|
||||||
|
border-color: #ff6a00;
|
||||||
|
}
|
||||||
|
.pay-way-option strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.pay-way-option small {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.shop-layout {
|
.shop-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -148,14 +148,27 @@ async function copyText() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ order.product_title }}</h2>
|
<h2>{{ order.product_title }}</h2>
|
||||||
<p>
|
<p v-if="!order.items?.length">
|
||||||
¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }}
|
¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }}
|
||||||
{{ order.product_unit }}
|
{{ order.product_unit }}
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else>共 {{ order.items.length }} 种 · {{ order.quantity }} 件</p>
|
||||||
<strong>合计 ¥{{ order.amount || formatCent(order.amount_cent) }}</strong>
|
<strong>合计 ¥{{ order.amount || formatCent(order.amount_cent) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="order.items?.length" class="items-box">
|
||||||
|
<h3>商品明细</h3>
|
||||||
|
<div v-for="item in order.items" :key="item.product_id" class="item-row">
|
||||||
|
<span class="item-title">{{ item.title }}</span>
|
||||||
|
<span
|
||||||
|
>¥{{ item.unit_price || formatCent(item.unit_price_cent) }} × {{ item.quantity }}
|
||||||
|
{{ item.unit }}</span
|
||||||
|
>
|
||||||
|
<strong>¥{{ item.amount || formatCent(item.amount_cent) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="order.copy_text" class="copy-box">
|
<div v-if="order.copy_text" class="copy-box">
|
||||||
<div class="copy-head">
|
<div class="copy-head">
|
||||||
<h3>订单信息(发给客服)</h3>
|
<h3>订单信息(发给客服)</h3>
|
||||||
@@ -396,6 +409,36 @@ async function copyText() {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.items-box {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 12px 0 4px;
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
}
|
||||||
|
.items-box h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #17233d;
|
||||||
|
}
|
||||||
|
.item-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.item-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.item-title {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.item-row strong {
|
||||||
|
color: #ff6a00;
|
||||||
|
}
|
||||||
|
|
||||||
.copy-box,
|
.copy-box,
|
||||||
.qr-box {
|
.qr-box {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
|||||||
Reference in New Issue
Block a user