摸大红支持购物车多选一起结账

下单支持多商品行快照与一次支付;支付后整单复制文案,前台提供购物车加购与结账。
This commit is contained in:
yml2213
2026-07-16 20:58:39 +08:00
parent e241607535
commit 183351a297
17 changed files with 1159 additions and 117 deletions
+1
View File
@@ -66,6 +66,7 @@ type MohongOrder struct {
AmountCent int64 `gorm:"column:amount_cent;not null;default:0" json:"-"`
Status string `gorm:"size:32;not null;default:'pending_payment';index" json:"status"`
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"`
CopyText string `gorm:"type:text;not null" json:"copy_text"`
PaidAt *time.Time `json:"paid_at"`
+1 -1
View File
@@ -14,7 +14,7 @@ const (
configKeyDefaultQrcodeURL = "mohong.default_qrcode_url"
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) {
+58 -25
View File
@@ -87,36 +87,56 @@ type UpdateProductRequest struct {
QrcodeImageURL *string `json:"qrcode_image_url"`
}
type CreateOrderRequest struct {
type CreateOrderItemRequest struct {
ProductID uint64 `json:"product_id" binding:"required"`
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 {
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
UserID uint64 `json:"user_id"`
ProductID uint64 `json:"product_id"`
Quantity int `json:"quantity"`
UnitPriceCent int64 `json:"unit_price_cent"`
UnitPrice string `json:"unit_price"`
AmountCent int64 `json:"amount_cent"`
Amount string `json:"amount"`
Status string `json:"status"`
ProductTitle string `json:"product_title"`
ProductCoverURL string `json:"product_cover_url"`
ProductUnit string `json:"product_unit"`
QrcodeURLSnapshot string `json:"qrcode_url_snapshot"`
CopyText string `json:"copy_text"`
BuyerNickname string `json:"buyer_nickname,omitempty"`
BuyerPhone string `json:"buyer_phone,omitempty"`
AdminRemark string `json:"admin_remark,omitempty"`
CancelReason string `json:"cancel_reason,omitempty"`
PaidAt *time.Time `json:"paid_at"`
CompletedAt *time.Time `json:"completed_at"`
CancelledAt *time.Time `json:"cancelled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
UserID uint64 `json:"user_id"`
ProductID uint64 `json:"product_id"`
Quantity int `json:"quantity"`
UnitPriceCent int64 `json:"unit_price_cent"`
UnitPrice string `json:"unit_price"`
AmountCent int64 `json:"amount_cent"`
Amount string `json:"amount"`
Status string `json:"status"`
ProductTitle string `json:"product_title"`
ProductCoverURL string `json:"product_cover_url"`
ProductUnit string `json:"product_unit"`
Items []OrderItemDTO `json:"items,omitempty"`
QrcodeURLSnapshot string `json:"qrcode_url_snapshot"`
CopyText string `json:"copy_text"`
BuyerNickname string `json:"buyer_nickname,omitempty"`
BuyerPhone string `json:"buyer_phone,omitempty"`
AdminRemark string `json:"admin_remark,omitempty"`
CancelReason string `json:"cancel_reason,omitempty"`
PaidAt *time.Time `json:"paid_at"`
CompletedAt *time.Time `json:"completed_at"`
CancelledAt *time.Time `json:"cancelled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type OrderListQuery struct {
@@ -162,3 +182,16 @@ type productSnapshot struct {
Unit string `json:"unit"`
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"`
}
+209 -68
View File
@@ -20,64 +20,88 @@ import (
)
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
}
var createdID uint64
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var product model.MohongProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, req.ProductID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return ErrProductNotFound
}
return err
}
if product.Status != model.MohongProductStatusOnSale {
return ErrProductUnavailable
}
if product.PriceCent <= 0 {
return ErrProductUnavailable
}
if product.Stock >= 0 && product.Stock < req.Quantity {
return ErrStockInsufficient
}
// 下单时预扣库存,取消未支付订单时归还。
if product.Stock >= 0 {
product.Stock -= req.Quantity
if err := tx.Save(&product).Error; err != nil {
itemSnaps := make([]orderItemSnapshot, 0, len(reqItems))
var totalQty int
var totalAmount int64
var primary productSnapshot
for _, line := range reqItems {
var product model.MohongProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, line.ProductID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return ErrProductNotFound
}
return err
}
if product.Status != model.MohongProductStatusOnSale || product.PriceCent <= 0 {
return ErrProductUnavailable
}
if product.Stock >= 0 && product.Stock < line.Quantity {
return ErrStockInsufficient
}
// 下单时预扣库存,取消未支付订单时归还。
if product.Stock >= 0 {
product.Stock -= line.Quantity
if err := tx.Save(&product).Error; err != nil {
return err
}
}
images := decodeStringList(product.ImageURLs)
item := orderItemSnapshot{
ID: product.ID,
Title: product.Title,
CoverURL: product.CoverURL,
ImageURLs: images,
Description: product.Description,
PriceCent: product.PriceCent,
Unit: product.Unit,
QrcodeImageURL: product.QrcodeImageURL,
Quantity: line.Quantity,
}
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 {
return err
}
images := decodeStringList(product.ImageURLs)
snap := productSnapshot{
ID: product.ID,
Title: product.Title,
CoverURL: product.CoverURL,
ImageURLs: images,
Description: product.Description,
PriceCent: product.PriceCent,
Unit: product.Unit,
QrcodeImageURL: product.QrcodeImageURL,
}
snapRaw, err := json.Marshal(snap)
primaryRaw, err := json.Marshal(primary)
if err != nil {
return err
}
amount := product.PriceCent * int64(req.Quantity)
itemsRaw, err := json.Marshal(itemSnaps)
if err != nil {
return err
}
// 展示字段:单商品保持原语义;多商品 quantity 为合计,unit_price 用首项单价。
order := model.MohongOrder{
OrderNo: orderNo,
UserID: userID,
ProductID: product.ID,
Quantity: req.Quantity,
UnitPriceCent: product.PriceCent,
AmountCent: amount,
ProductID: primary.ID,
Quantity: totalQty,
UnitPriceCent: primary.PriceCent,
AmountCent: totalAmount,
Status: model.MohongOrderStatusPendingPayment,
ProductSnapshot: datatypes.JSON(snapRaw),
ProductSnapshot: datatypes.JSON(primaryRaw),
ItemsSnapshot: datatypes.JSON(itemsRaw),
CopyText: "",
}
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)
}
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) {
var row model.MohongOrder
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 {
return 0, err
}
items := decodeOrderItems(order)
snap := decodeProductSnapshot(order.ProductSnapshot)
qrcodeURL := r.resolveQrcodeURL(tx, snap.QrcodeImageURL)
copyText := buildCopyText(r.loadCopyTemplate(tx), order, snap, user)
qrcodeURL := r.resolveQrcodeURL(tx, resolveOrderQrcode(items, snap))
copyText := buildCopyText(r.loadCopyTemplate(tx), order, items, snap, user)
now := time.Now()
order.Status = model.MohongOrderStatusPaid
@@ -303,15 +362,19 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint
func (r *Repository) NotifyNewConversation(conversationID uint64) {}
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
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, order.ProductID).Error; err == nil {
if product.Stock >= 0 {
product.Stock += order.Quantity
if err := tx.Save(&product).Error; err != nil {
return err
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, item.ID).Error; err != nil {
continue
}
if product.Stock >= 0 {
product.Stock += item.Quantity
if err := tx.Save(&product).Error; err != nil {
return err
}
}
}
@@ -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) {
snap := decodeProductSnapshot(row.ProductSnapshot)
items := decodeOrderItems(row)
var nickname, phone string
var user model.User
if err := r.db.WithContext(ctx).Select("id", "nickname", "phone").First(&user, row.UserID).Error; err == nil {
nickname = user.Nickname
phone = user.Phone
}
dto := toOrderDTO(row, snap, nickname, phone, includeAdmin)
dto := toOrderDTO(row, snap, items, nickname, phone, includeAdmin)
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) == "" {
template = defaultOrderCopyTemplate
}
@@ -342,33 +406,110 @@ func buildCopyText(template string, order model.MohongOrder, snap productSnapsho
if 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(
"{{order_no}}", order.OrderNo,
"{{created_at}}", timeutil.ShanghaiNow().Format("2006-01-02 15:04:05"),
"{{product_title}}", snap.Title,
"{{created_at}}", createdAt,
"{{product_title}}", productTitle,
"{{items}}", formatItemsForCopy(items, snap, order.Quantity),
"{{quantity}}", fmt.Sprintf("%d", order.Quantity),
"{{amount}}", money.FormatWithSymbol(order.AmountCent),
"{{buyer_name}}", buyerName,
"{{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)
}
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) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
+39 -4
View File
@@ -2,6 +2,7 @@ package mohong
import (
"encoding/json"
"fmt"
"strings"
"hfb_sys/backend/internal/model"
@@ -42,7 +43,40 @@ func toProductDTOWithCategory(row model.MohongProduct, categoryName string, incl
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{
ID: row.ID,
OrderNo: row.OrderNo,
@@ -54,9 +88,10 @@ func toOrderDTO(row model.MohongOrder, snap productSnapshot, buyerNickname, buye
AmountCent: row.AmountCent,
Amount: money.Format(row.AmountCent),
Status: row.Status,
ProductTitle: snap.Title,
ProductCoverURL: snap.CoverURL,
ProductUnit: snap.Unit,
ProductTitle: title,
ProductCoverURL: cover,
ProductUnit: unit,
Items: itemDTOs,
QrcodeURLSnapshot: row.QrcodeURLSnapshot,
CopyText: row.CopyText,
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" />
<div class="hint">
可用变量&#123;&#123;order_no&#125;&#125; &#123;&#123;created_at&#125;&#125;
&#123;&#123;product_title&#125;&#125; &#123;&#123;quantity&#125;&#125; &#123;&#123;amount&#125;&#125;
&#123;&#123;buyer_name&#125;&#125; &#123;&#123;buyer_phone&#125;&#125; &#123;&#123;unit&#125;&#125;
&#123;&#123;items&#125;&#125;多商品明细 &#123;&#123;product_title&#125;&#125;
&#123;&#123;quantity&#125;&#125; &#123;&#123;amount&#125;&#125; &#123;&#123;buyer_name&#125;&#125;
&#123;&#123;buyer_phone&#125;&#125; &#123;&#123;unit&#125;&#125;
</div>
</el-form-item>
</el-form>
@@ -175,6 +175,13 @@ async function copyText(text: string) {
{{ detail.buyer_nickname }} {{ detail.buyer_phone }}
</el-descriptions-item>
</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 class="copy-head">
<strong>复制文案</strong>
@@ -215,6 +222,23 @@ async function copyText(text: string) {
gap: 10px;
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,
.qr-box {
margin-top: 16px;
+27 -5
View File
@@ -34,6 +34,18 @@ export interface MohongProduct {
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 {
id: number
order_no: string
@@ -48,6 +60,7 @@ export interface MohongOrder {
product_title: string
product_cover_url: string
product_unit: string
items?: MohongOrderItem[]
qrcode_url_snapshot: string
copy_text: string
buyer_nickname?: string
@@ -61,6 +74,11 @@ export interface MohongOrder {
updated_at: string
}
export interface MohongCheckoutItem {
product_id: number
quantity: number
}
export interface MohongConfig {
default_qrcode_url: string
order_copy_template: string
@@ -95,11 +113,15 @@ export async function fetchMohongProduct(id: number | string) {
return data.data
}
export async function createMohongOrder(productId: number, quantity: number) {
const { data } = await apiClient.post<ApiResponse<MohongOrder>>('/mohong/orders', {
product_id: productId,
quantity,
})
/** 单商品或购物车多商品结账 */
export async function createMohongOrder(
productIdOrItems: number | MohongCheckoutItem[],
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
}
@@ -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,
type MohongProduct,
} 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 { formatCent } from '@/shared/utils/money'
import { readError } from '@/shared/utils/error'
@@ -20,6 +22,7 @@ import { readError } from '@/shared/utils/error'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const { addProduct, openCart, totalCount: cartCount } = useMohongCart()
const product = ref<MohongProduct | null>(null)
const loading = ref(true)
const quantity = ref(1)
@@ -91,6 +94,19 @@ async function ensureAuthReady() {
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() {
if (!product.value || submitting.value) return
if (!(await ensureAuthReady())) return
@@ -107,11 +123,11 @@ async function handleBuy() {
const payment = await startMohongPayment(order.id, payWay)
if (payment.paid || payment.status === 'paid') {
showToast({ message: '支付成功', icon: 'passed' })
await router.push(`/mohong/orders/${order.id}`)
await router.push(`/m/mohong/orders/${order.id}`)
return
}
await openMobilePaymentCashier(payment, async () => {
await router.push(`/mohong/orders/${order.id}`)
await router.push(`/m/mohong/orders/${order.id}`)
})
} catch (error) {
showToast({ message: readError(error, '下单失败'), icon: 'cross' })
@@ -164,9 +180,16 @@ async function handleBuy() {
</section>
<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">
合计 <strong>¥{{ totalPrice }}</strong>
</div>
<van-button plain round color="#ff6a00" :disabled="!canBuy" @click="handleAddCart">
加购
</van-button>
<van-button
type="primary"
color="#ff6a00"
@@ -175,12 +198,17 @@ async function handleBuy() {
:disabled="!canBuy"
@click="handleBuy"
>
{{ canBuy ? '立即购买' : '暂时缺货' }}
{{ canBuy ? '购买' : '缺货' }}
</van-button>
</div>
</template>
<van-empty v-else description="商品不存在" />
<MohongCartPanel
mobile
:select-pay-way="() => payWaySelect.select()"
:start-cashier="(payment, onPaid) => openMobilePaymentCashier(payment, onPaid)"
/>
<MobilePayWaySelectPopup
v-model:show="payWaySelect.visible.value"
@choose="payWaySelect.choose"
@@ -297,22 +325,51 @@ async function handleBuy() {
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
gap: 8px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: #fff;
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 {
flex: 1;
color: #6b7a90;
font-size: 13px;
min-width: 0;
}
.sum strong {
color: #ff6a00;
font-size: 20px;
font-size: 18px;
margin-left: 4px;
}
.buy-bar :deep(.van-button) {
min-width: 128px;
min-width: 72px;
flex-shrink: 0;
}
</style>
@@ -7,9 +7,16 @@ import { ensureSupportChat } from '@/features/chats/api/chats'
import {
fetchMohongCategories,
fetchMohongProducts,
queryMohongPayment,
type MohongCategory,
type MohongProduct,
} 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 { formatCent } from '@/shared/utils/money'
import { readError } from '@/shared/utils/error'
@@ -17,6 +24,21 @@ import { readError } from '@/shared/utils/error'
const router = useRouter()
const route = useRoute()
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 loading = ref(false)
const products = ref<MohongProduct[]>([])
@@ -191,6 +213,9 @@ async function handleSupportClick() {
@input="onSearchInput"
/>
</div>
<button type="button" class="orders-btn" @click="openCart">
购物车{{ cartCount > 0 ? `(${cartCount})` : '' }}
</button>
<button
type="button"
class="orders-btn"
@@ -264,6 +289,25 @@ async function handleSupportClick() {
<div v-else-if="!loading && products.length && !hasMore" class="load-tip">已经到底了</div>
</section>
</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 />
</main>
</template>
@@ -127,14 +127,25 @@ async function copyText() {
</div>
<div>
<h2>{{ order.product_title }}</h2>
<p>
<p v-if="!order.items?.length">
¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }}
{{ order.product_unit }}
</p>
<p v-else> {{ order.items.length }} · {{ order.quantity }} </p>
<strong>合计 ¥{{ order.amount || formatCent(order.amount_cent) }}</strong>
</div>
</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">
<div class="copy-head">
<h3>订单信息发给客服</h3>
@@ -272,6 +283,23 @@ async function copyText() {
.product-panel strong {
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 {
display: flex;
align-items: center;
@@ -9,6 +9,8 @@ import {
startMohongPayment,
type MohongProduct,
} 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 { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
import { useSessionStore } from '@/stores/session'
@@ -18,6 +20,7 @@ import { readError } from '@/shared/utils/error'
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const { addProduct, openCart, totalCount: cartCount } = useMohongCart()
const product = ref<MohongProduct | null>(null)
const loading = ref(true)
const quantity = ref(1)
@@ -112,6 +115,20 @@ async function ensureAuthReady() {
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() {
if (!product.value || submitting.value) return
if (!(await ensureAuthReady())) return
@@ -204,6 +221,9 @@ async function handleBuy() {
</div>
<div class="actions">
<el-button size="large" class="btn-outline" :disabled="!canBuy" @click="handleAddCart">
加入购物车
</el-button>
<el-button
type="primary"
size="large"
@@ -214,6 +234,9 @@ async function handleBuy() {
>
{{ canBuy ? '立即购买' : '暂时缺货' }}
</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>
@@ -223,8 +246,8 @@ async function handleBuy() {
<p>购买须知</p>
<ul>
<li>下单需登录并完成实名认证</li>
<li>可加入购物车多选一起结账支付后一键复制整单信息</li>
<li>支付成功后可在订单详情扫码联系客服</li>
<li>订单详情支持一键复制订单信息发给客服</li>
</ul>
</div>
</div>
@@ -232,6 +255,11 @@ async function handleBuy() {
</template>
<el-empty v-else-if="!loading" description="商品不存在" />
<MohongCartPanel
:select-pay-way="selectPayWay"
:start-cashier="(payment, onPaid) => cashier.openPaymentCashier(payment, onPaid)"
/>
<el-dialog
v-model="payWayDialogVisible"
title="选择支付方式"
@@ -2,9 +2,14 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 { useMohongCart } from '@/features/mohong/composables/useMohongCart'
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 {
fetchMohongCategories,
fetchMohongProducts,
@@ -17,8 +22,38 @@ import { readError } from '@/shared/utils/error'
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const { totalCount: cartCount, openCart } = useMohongCart()
const supportLoading = 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 products = ref<MohongProduct[]>([])
const categories = ref<MohongCategory[]>([])
@@ -188,6 +223,10 @@ async function handleSupportClick() {
@input="onKeywordInput"
/>
</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')">
<el-icon><Tickets /></el-icon>
<span>我的订单</span>
@@ -252,6 +291,29 @@ async function handleSupportClick() {
</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>
</template>
@@ -481,6 +543,32 @@ async function handleSupportClick() {
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) {
.shop-layout {
grid-template-columns: 1fr;
@@ -148,14 +148,27 @@ async function copyText() {
</div>
<div>
<h2>{{ order.product_title }}</h2>
<p>
<p v-if="!order.items?.length">
¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }}
{{ order.product_unit }}
</p>
<p v-else> {{ order.items.length }} · {{ order.quantity }} </p>
<strong>合计 ¥{{ order.amount || formatCent(order.amount_cent) }}</strong>
</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 class="copy-head">
<h3>订单信息发给客服</h3>
@@ -396,6 +409,36 @@ async function copyText() {
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,
.qr-box {
margin-top: 16px;