From 183351a2978cb38defe86498416a1fb8916492a1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 16 Jul 2026 20:58:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=91=B8=E5=A4=A7=E7=BA=A2=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=B4=AD=E7=89=A9=E8=BD=A6=E5=A4=9A=E9=80=89=E4=B8=80=E8=B5=B7?= =?UTF-8?q?=E7=BB=93=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下单支持多商品行快照与一次支付;支付后整单复制文案,前台提供购物车加购与结账。 --- backend/internal/model/mohong.go | 1 + backend/internal/modules/mohong/config.go | 2 +- backend/internal/modules/mohong/dto.go | 83 +++-- backend/internal/modules/mohong/order_repo.go | 277 +++++++++++---- backend/internal/modules/mohong/presenter.go | 43 ++- .../migrations/000031_mohong_order_items.sql | 15 + .../admin/views/AdminMohongConfigView.vue | 5 +- .../admin/views/AdminMohongOrdersView.vue | 24 ++ frontend/src/features/mohong/api/mohong.ts | 32 +- .../mohong/components/MohongCartPanel.vue | 317 ++++++++++++++++++ .../mohong/composables/useMohongCart.ts | 165 +++++++++ .../mohong/views/MobileMohongDetailView.vue | 73 +++- .../mohong/views/MobileMohongListView.vue | 44 +++ .../views/MobileMohongOrderDetailView.vue | 30 +- .../mohong/views/MohongDetailView.vue | 30 +- .../features/mohong/views/MohongListView.vue | 90 ++++- .../mohong/views/MohongOrderDetailView.vue | 45 ++- 17 files changed, 1159 insertions(+), 117 deletions(-) create mode 100644 backend/migrations/000031_mohong_order_items.sql create mode 100644 frontend/src/features/mohong/components/MohongCartPanel.vue create mode 100644 frontend/src/features/mohong/composables/useMohongCart.ts diff --git a/backend/internal/model/mohong.go b/backend/internal/model/mohong.go index d14762f..5ecc3f0 100644 --- a/backend/internal/model/mohong.go +++ b/backend/internal/model/mohong.go @@ -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"` diff --git a/backend/internal/modules/mohong/config.go b/backend/internal/modules/mohong/config.go index 436939b..e484fc6 100644 --- a/backend/internal/modules/mohong/config.go +++ b/backend/internal/modules/mohong/config.go @@ -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) { diff --git a/backend/internal/modules/mohong/dto.go b/backend/internal/modules/mohong/dto.go index e05e9ae..fe3a93e 100644 --- a/backend/internal/modules/mohong/dto.go +++ b/backend/internal/modules/mohong/dto.go @@ -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"` +} diff --git a/backend/internal/modules/mohong/order_repo.go b/backend/internal/modules/mohong/order_repo.go index 4666d9e..5f71524 100644 --- a/backend/internal/modules/mohong/order_repo.go +++ b/backend/internal/modules/mohong/order_repo.go @@ -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 { diff --git a/backend/internal/modules/mohong/presenter.go b/backend/internal/modules/mohong/presenter.go index 52c716c..2af8996 100644 --- a/backend/internal/modules/mohong/presenter.go +++ b/backend/internal/modules/mohong/presenter.go @@ -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, diff --git a/backend/migrations/000031_mohong_order_items.sql b/backend/migrations/000031_mohong_order_items.sql new file mode 100644 index 0000000..5b7eb61 --- /dev/null +++ b/backend/migrations/000031_mohong_order_items.sql @@ -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; diff --git a/frontend/src/features/admin/views/AdminMohongConfigView.vue b/frontend/src/features/admin/views/AdminMohongConfigView.vue index c3adcbc..9add081 100644 --- a/frontend/src/features/admin/views/AdminMohongConfigView.vue +++ b/frontend/src/features/admin/views/AdminMohongConfigView.vue @@ -90,8 +90,9 @@ async function uploadQrcode(file: File) {
可用变量:{{order_no}} {{created_at}} - {{product_title}} {{quantity}} {{amount}} - {{buyer_name}} {{buyer_phone}} {{unit}} + {{items}}(多商品明细) {{product_title}} + {{quantity}} {{amount}} {{buyer_name}} + {{buyer_phone}} {{unit}}
diff --git a/frontend/src/features/admin/views/AdminMohongOrdersView.vue b/frontend/src/features/admin/views/AdminMohongOrdersView.vue index 42e1042..5d4c5cc 100644 --- a/frontend/src/features/admin/views/AdminMohongOrdersView.vue +++ b/frontend/src/features/admin/views/AdminMohongOrdersView.vue @@ -175,6 +175,13 @@ async function copyText(text: string) { {{ detail.buyer_nickname }} {{ detail.buyer_phone }} +
+ 商品明细 +
+ {{ item.title }} × {{ item.quantity }} + ¥{{ item.amount || formatCent(item.amount_cent) }} +
+
复制文案 @@ -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; diff --git a/frontend/src/features/mohong/api/mohong.ts b/frontend/src/features/mohong/api/mohong.ts index f2e13f0..2a23530 100644 --- a/frontend/src/features/mohong/api/mohong.ts +++ b/frontend/src/features/mohong/api/mohong.ts @@ -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>('/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>('/mohong/orders', payload) return data.data } diff --git a/frontend/src/features/mohong/components/MohongCartPanel.vue b/frontend/src/features/mohong/components/MohongCartPanel.vue new file mode 100644 index 0000000..1ad6dd5 --- /dev/null +++ b/frontend/src/features/mohong/components/MohongCartPanel.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/frontend/src/features/mohong/composables/useMohongCart.ts b/frontend/src/features/mohong/composables/useMohongCart.ts new file mode 100644 index 0000000..f728ad2 --- /dev/null +++ b/frontend/src/features/mohong/composables/useMohongCart.ts @@ -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(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, + } +} diff --git a/frontend/src/features/mohong/views/MobileMohongDetailView.vue b/frontend/src/features/mohong/views/MobileMohongDetailView.vue index ecea8c1..63c94ba 100644 --- a/frontend/src/features/mohong/views/MobileMohongDetailView.vue +++ b/frontend/src/features/mohong/views/MobileMohongDetailView.vue @@ -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(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() {
+
合计 ¥{{ totalPrice }}
+ + 加购 + - {{ canBuy ? '立即购买' : '暂时缺货' }} + {{ canBuy ? '购买' : '缺货' }}
+ diff --git a/frontend/src/features/mohong/views/MobileMohongListView.vue b/frontend/src/features/mohong/views/MobileMohongListView.vue index 4569a8f..8abae7e 100644 --- a/frontend/src/features/mohong/views/MobileMohongListView.vue +++ b/frontend/src/features/mohong/views/MobileMohongListView.vue @@ -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([]) @@ -191,6 +213,9 @@ async function handleSupportClick() { @input="onSearchInput" />
+
+ + + diff --git a/frontend/src/features/mohong/views/MobileMohongOrderDetailView.vue b/frontend/src/features/mohong/views/MobileMohongOrderDetailView.vue index 1850e96..9a43a7a 100644 --- a/frontend/src/features/mohong/views/MobileMohongOrderDetailView.vue +++ b/frontend/src/features/mohong/views/MobileMohongOrderDetailView.vue @@ -127,14 +127,25 @@ async function copyText() {

{{ order.product_title }}

-

+

¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }} {{ order.product_unit }}

+

共 {{ order.items.length }} 种 · {{ order.quantity }} 件

合计 ¥{{ order.amount || formatCent(order.amount_cent) }}
+
+

商品明细

+
+ {{ item.title }} + ×{{ item.quantity }} · ¥{{ item.amount || formatCent(item.amount_cent) }} +
+
+

订单信息(发给客服)

@@ -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; diff --git a/frontend/src/features/mohong/views/MohongDetailView.vue b/frontend/src/features/mohong/views/MohongDetailView.vue index 4cd3310..24d1eb4 100644 --- a/frontend/src/features/mohong/views/MohongDetailView.vue +++ b/frontend/src/features/mohong/views/MohongDetailView.vue @@ -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(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() {
+ + 加入购物车 + {{ canBuy ? '立即购买' : '暂时缺货' }} + + 购物车{{ cartCount > 0 ? `(${cartCount})` : '' }} + 返回列表 @@ -223,8 +246,8 @@ async function handleBuy() {

购买须知

  • 下单需登录并完成实名认证
  • +
  • 可加入购物车多选,一起结账;支付后一键复制整单信息
  • 支付成功后可在订单详情扫码联系客服
  • -
  • 订单详情支持一键复制订单信息发给客服
@@ -232,6 +255,11 @@ async function handleBuy() { + + 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 { + 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([]) const categories = ref([]) @@ -188,6 +223,10 @@ async function handleSupportClick() { @input="onKeywordInput" /> + + + +
@@ -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; diff --git a/frontend/src/features/mohong/views/MohongOrderDetailView.vue b/frontend/src/features/mohong/views/MohongOrderDetailView.vue index 9d2d426..39b5145 100644 --- a/frontend/src/features/mohong/views/MohongOrderDetailView.vue +++ b/frontend/src/features/mohong/views/MohongOrderDetailView.vue @@ -148,14 +148,27 @@ async function copyText() {

{{ order.product_title }}

-

+

¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }} {{ order.product_unit }}

+

共 {{ order.items.length }} 种 · {{ order.quantity }} 件

合计 ¥{{ order.amount || formatCent(order.amount_cent) }}
+
+

商品明细

+
+ {{ item.title }} + ¥{{ item.unit_price || formatCent(item.unit_price_cent) }} × {{ item.quantity }} + {{ item.unit }} + ¥{{ item.amount || formatCent(item.amount_cent) }} +
+
+

订单信息(发给客服)

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