feat(points): 增加积分兑换联系信息
This commit is contained in:
@@ -31,6 +31,9 @@ type PointsMallRedemption struct {
|
|||||||
ProductTitle string `gorm:"size:128;not null" json:"product_title"`
|
ProductTitle string `gorm:"size:128;not null" json:"product_title"`
|
||||||
ProductCoverURL string `gorm:"size:512;not null;default:''" json:"product_cover_url"`
|
ProductCoverURL string `gorm:"size:512;not null;default:''" json:"product_cover_url"`
|
||||||
RedemptionInstructions string `gorm:"type:text;not null" json:"redemption_instructions"`
|
RedemptionInstructions string `gorm:"type:text;not null" json:"redemption_instructions"`
|
||||||
|
ContactName string `gorm:"size:32;not null;default:''" json:"contact_name"`
|
||||||
|
ContactPhone string `gorm:"size:32;not null;default:''" json:"contact_phone"`
|
||||||
|
CustomerRemark string `gorm:"size:500;not null;default:''" json:"customer_remark"`
|
||||||
PointsPrice int64 `gorm:"not null;default:0" json:"points_price"`
|
PointsPrice int64 `gorm:"not null;default:0" json:"points_price"`
|
||||||
Quantity int `gorm:"not null;default:1" json:"quantity"`
|
Quantity int `gorm:"not null;default:1" json:"quantity"`
|
||||||
TotalPoints int64 `gorm:"not null;default:0" json:"total_points"`
|
TotalPoints int64 `gorm:"not null;default:0" json:"total_points"`
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ type ProductRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RedeemRequest struct {
|
type RedeemRequest struct {
|
||||||
Quantity int `json:"quantity" binding:"required,min=1,max=10"`
|
Quantity int `json:"quantity" binding:"required,min=1,max=10"`
|
||||||
|
ContactName string `json:"contact_name" binding:"required,max=32"`
|
||||||
|
ContactPhone string `json:"contact_phone" binding:"required,len=11,numeric"`
|
||||||
|
CustomerRemark string `json:"customer_remark" binding:"max=500"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CompleteRedemptionRequest struct {
|
type CompleteRedemptionRequest struct {
|
||||||
@@ -73,6 +76,9 @@ type RedemptionDTO struct {
|
|||||||
ProductTitle string `json:"product_title"`
|
ProductTitle string `json:"product_title"`
|
||||||
ProductCoverURL string `json:"product_cover_url"`
|
ProductCoverURL string `json:"product_cover_url"`
|
||||||
RedemptionInstructions string `json:"redemption_instructions"`
|
RedemptionInstructions string `json:"redemption_instructions"`
|
||||||
|
ContactName string `json:"contact_name"`
|
||||||
|
ContactPhone string `json:"contact_phone"`
|
||||||
|
CustomerRemark string `json:"customer_remark"`
|
||||||
PointsPrice int64 `json:"points_price"`
|
PointsPrice int64 `json:"points_price"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
TotalPoints int64 `json:"total_points"`
|
TotalPoints int64 `json:"total_points"`
|
||||||
@@ -111,6 +117,9 @@ func toRedemptionDTO(item model.PointsMallRedemption) RedemptionDTO {
|
|||||||
ProductTitle: item.ProductTitle,
|
ProductTitle: item.ProductTitle,
|
||||||
ProductCoverURL: item.ProductCoverURL,
|
ProductCoverURL: item.ProductCoverURL,
|
||||||
RedemptionInstructions: item.RedemptionInstructions,
|
RedemptionInstructions: item.RedemptionInstructions,
|
||||||
|
ContactName: item.ContactName,
|
||||||
|
ContactPhone: item.ContactPhone,
|
||||||
|
CustomerRemark: item.CustomerRemark,
|
||||||
PointsPrice: item.PointsPrice,
|
PointsPrice: item.PointsPrice,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
TotalPoints: item.TotalPoints,
|
TotalPoints: item.TotalPoints,
|
||||||
|
|||||||
@@ -107,10 +107,10 @@ func (h *Handler) Redeem(c *gin.Context) {
|
|||||||
productID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
productID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
var req RedeemRequest
|
var req RedeemRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
response.BadRequest(c, "兑换数量不正确")
|
response.BadRequest(c, "请填写联系人、正确的手机号和兑换数量")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item, err := h.service.Redeem(c.Request.Context(), userID, productID, req.Quantity)
|
item, err := h.service.Redeem(c.Request.Context(), userID, productID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ type Repository struct {
|
|||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var mobilePhonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
||||||
|
|
||||||
func NewRepository(db *gorm.DB) *Repository {
|
func NewRepository(db *gorm.DB) *Repository {
|
||||||
return &Repository{db: db}
|
return &Repository{db: db}
|
||||||
}
|
}
|
||||||
@@ -115,8 +118,18 @@ func (r *Repository) findProduct(ctx context.Context, id uint64) (*ProductDTO, e
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, quantity int) (*RedemptionDTO, error) {
|
func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, req RedeemRequest) (*RedemptionDTO, error) {
|
||||||
if userID == 0 || productID == 0 || quantity <= 0 || quantity > 10 {
|
contactName := strings.TrimSpace(req.ContactName)
|
||||||
|
contactPhone := strings.TrimSpace(req.ContactPhone)
|
||||||
|
customerRemark := strings.TrimSpace(req.CustomerRemark)
|
||||||
|
if userID == 0 ||
|
||||||
|
productID == 0 ||
|
||||||
|
req.Quantity <= 0 ||
|
||||||
|
req.Quantity > 10 ||
|
||||||
|
contactName == "" ||
|
||||||
|
len([]rune(contactName)) > 32 ||
|
||||||
|
!mobilePhonePattern.MatchString(contactPhone) ||
|
||||||
|
len([]rune(customerRemark)) > 500 {
|
||||||
return nil, ErrInvalidInput
|
return nil, ErrInvalidInput
|
||||||
}
|
}
|
||||||
var result model.PointsMallRedemption
|
var result model.PointsMallRedemption
|
||||||
@@ -125,7 +138,7 @@ func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, quant
|
|||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, productID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, productID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if product.Status != "online" || product.Stock < int64(quantity) {
|
if product.Status != "online" || product.Stock < int64(req.Quantity) {
|
||||||
return ErrProductUnavailable
|
return ErrProductUnavailable
|
||||||
}
|
}
|
||||||
var redeemed int64
|
var redeemed int64
|
||||||
@@ -135,14 +148,14 @@ func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, quant
|
|||||||
Scan(&redeemed).Error; err != nil {
|
Scan(&redeemed).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if redeemed+int64(quantity) > int64(product.PerUserLimit) {
|
if redeemed+int64(req.Quantity) > int64(product.PerUserLimit) {
|
||||||
return ErrRedeemLimitExceeded
|
return ErrRedeemLimitExceeded
|
||||||
}
|
}
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
totalPoints := product.PointsPrice * int64(quantity)
|
totalPoints := product.PointsPrice * int64(req.Quantity)
|
||||||
if totalPoints <= 0 || user.PointsAvailable < totalPoints {
|
if totalPoints <= 0 || user.PointsAvailable < totalPoints {
|
||||||
return ErrInsufficientPoints
|
return ErrInsufficientPoints
|
||||||
}
|
}
|
||||||
@@ -154,8 +167,11 @@ func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, quant
|
|||||||
ProductTitle: product.Title,
|
ProductTitle: product.Title,
|
||||||
ProductCoverURL: product.CoverURL,
|
ProductCoverURL: product.CoverURL,
|
||||||
RedemptionInstructions: product.RedemptionInstructions,
|
RedemptionInstructions: product.RedemptionInstructions,
|
||||||
|
ContactName: contactName,
|
||||||
|
ContactPhone: contactPhone,
|
||||||
|
CustomerRemark: customerRemark,
|
||||||
PointsPrice: product.PointsPrice,
|
PointsPrice: product.PointsPrice,
|
||||||
Quantity: quantity,
|
Quantity: req.Quantity,
|
||||||
TotalPoints: totalPoints,
|
TotalPoints: totalPoints,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -170,8 +186,8 @@ func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, quant
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Model(&product).Updates(map[string]any{
|
if err := tx.Model(&product).Updates(map[string]any{
|
||||||
"stock": gorm.Expr("stock - ?", quantity),
|
"stock": gorm.Expr("stock - ?", req.Quantity),
|
||||||
"redeemed_count": gorm.Expr("redeemed_count + ?", quantity),
|
"redeemed_count": gorm.Expr("redeemed_count + ?", req.Quantity),
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,29 @@ func TestRedeemDeductsAvailablePointsAndStock(t *testing.T) {
|
|||||||
t.Fatalf("create product failed: %v", err)
|
t.Fatalf("create product failed: %v", err)
|
||||||
}
|
}
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
redemption, err := repo.Redeem(t.Context(), user.ID, product.ID, 2)
|
req := RedeemRequest{
|
||||||
|
Quantity: 2,
|
||||||
|
ContactName: "张三",
|
||||||
|
ContactPhone: "13800138000",
|
||||||
|
CustomerRemark: "请发送到邮箱",
|
||||||
|
}
|
||||||
|
invalidReq := req
|
||||||
|
invalidReq.ContactPhone = "123"
|
||||||
|
if _, err := repo.Redeem(t.Context(), user.ID, product.ID, invalidReq); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Fatalf("invalid contact phone error = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
redemption, err := repo.Redeem(t.Context(), user.ID, product.ID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("redeem failed: %v", err)
|
t.Fatalf("redeem failed: %v", err)
|
||||||
}
|
}
|
||||||
if redemption.TotalPoints != 200 {
|
if redemption.TotalPoints != 200 {
|
||||||
t.Fatalf("total points = %d, want 200", redemption.TotalPoints)
|
t.Fatalf("total points = %d, want 200", redemption.TotalPoints)
|
||||||
}
|
}
|
||||||
|
if redemption.ContactName != req.ContactName ||
|
||||||
|
redemption.ContactPhone != req.ContactPhone ||
|
||||||
|
redemption.CustomerRemark != req.CustomerRemark {
|
||||||
|
t.Fatalf("redemption contact = %+v, want %+v", redemption, req)
|
||||||
|
}
|
||||||
var savedUser model.User
|
var savedUser model.User
|
||||||
if err := db.First(&savedUser, user.ID).Error; err != nil {
|
if err := db.First(&savedUser, user.ID).Error; err != nil {
|
||||||
t.Fatalf("find user failed: %v", err)
|
t.Fatalf("find user failed: %v", err)
|
||||||
@@ -48,7 +64,8 @@ func TestRedeemDeductsAvailablePointsAndStock(t *testing.T) {
|
|||||||
if savedProduct.Stock != 0 || savedProduct.RedeemedCount != 2 {
|
if savedProduct.Stock != 0 || savedProduct.RedeemedCount != 2 {
|
||||||
t.Fatalf("product stock/redeemed = %d/%d", savedProduct.Stock, savedProduct.RedeemedCount)
|
t.Fatalf("product stock/redeemed = %d/%d", savedProduct.Stock, savedProduct.RedeemedCount)
|
||||||
}
|
}
|
||||||
if _, err := repo.Redeem(t.Context(), user.ID, product.ID, 1); !errors.Is(err, ErrRedeemLimitExceeded) && !errors.Is(err, ErrProductUnavailable) {
|
req.Quantity = 1
|
||||||
|
if _, err := repo.Redeem(t.Context(), user.ID, product.ID, req); !errors.Is(err, ErrRedeemLimitExceeded) && !errors.Is(err, ErrProductUnavailable) {
|
||||||
t.Fatalf("repeat redeem error = %v, want limit or unavailable", err)
|
t.Fatalf("repeat redeem error = %v, want limit or unavailable", err)
|
||||||
}
|
}
|
||||||
completed, err := repo.CompleteRedemption(t.Context(), 99, redemption.ID, "已发放")
|
completed, err := repo.CompleteRedemption(t.Context(), 99, redemption.ID, "已发放")
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ func (s *Service) UpdateProduct(ctx context.Context, id uint64, req ProductReque
|
|||||||
return s.repo.UpdateProduct(ctx, id, req)
|
return s.repo.UpdateProduct(ctx, id, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Redeem(ctx context.Context, userID, productID uint64, quantity int) (*RedemptionDTO, error) {
|
func (s *Service) Redeem(ctx context.Context, userID, productID uint64, req RedeemRequest) (*RedemptionDTO, error) {
|
||||||
return s.repo.Redeem(ctx, userID, productID, quantity)
|
return s.repo.Redeem(ctx, userID, productID, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListRedemptions(ctx context.Context, userID uint64, admin bool) ([]RedemptionDTO, error) {
|
func (s *Service) ListRedemptions(ctx context.Context, userID uint64, admin bool) ([]RedemptionDTO, error) {
|
||||||
|
|||||||
@@ -186,6 +186,16 @@ function defaultProduct(): PointsMallProductPayload {
|
|||||||
<small>{{ row.user_phone || row.user_id }}</small>
|
<small>{{ row.user_phone || row.user_id }}</small>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="兑换联系人" min-width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.contact_name || '-' }}</span
|
||||||
|
><br />
|
||||||
|
<small>{{ row.contact_phone || '-' }}</small>
|
||||||
|
<p v-if="row.customer_remark" class="customer-remark">
|
||||||
|
备注:{{ row.customer_remark }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="product_title" label="商品" min-width="160" />
|
<el-table-column prop="product_title" label="商品" min-width="160" />
|
||||||
<el-table-column prop="total_points" label="积分" width="100" />
|
<el-table-column prop="total_points" label="积分" width="100" />
|
||||||
<el-table-column label="状态" width="100">
|
<el-table-column label="状态" width="100">
|
||||||
@@ -315,6 +325,14 @@ function defaultProduct(): PointsMallProductPayload {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.customer-remark {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.cover-editor {
|
.cover-editor {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 180px minmax(0, 1fr);
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export interface PointsMallRedemption {
|
|||||||
product_title: string
|
product_title: string
|
||||||
product_cover_url: string
|
product_cover_url: string
|
||||||
redemption_instructions: string
|
redemption_instructions: string
|
||||||
|
contact_name: string
|
||||||
|
contact_phone: string
|
||||||
|
customer_remark: string
|
||||||
points_price: number
|
points_price: number
|
||||||
quantity: number
|
quantity: number
|
||||||
total_points: number
|
total_points: number
|
||||||
@@ -58,6 +61,13 @@ export interface PointsMallRedemption {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PointsMallRedeemPayload {
|
||||||
|
quantity: number
|
||||||
|
contact_name: string
|
||||||
|
contact_phone: string
|
||||||
|
customer_remark: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PointsMallProductPayload {
|
export interface PointsMallProductPayload {
|
||||||
title: string
|
title: string
|
||||||
subtitle: string
|
subtitle: string
|
||||||
@@ -86,10 +96,10 @@ export async function fetchPointsProducts() {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function redeemPointsProduct(id: number, quantity = 1) {
|
export async function redeemPointsProduct(id: number, payload: PointsMallRedeemPayload) {
|
||||||
const { data } = await apiClient.post<ApiResponse<PointsMallRedemption>>(
|
const { data } = await apiClient.post<ApiResponse<PointsMallRedemption>>(
|
||||||
`/points-mall/products/${id}/redeem`,
|
`/points-mall/products/${id}/redeem`,
|
||||||
{ quantity }
|
payload
|
||||||
)
|
)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,9 @@ const router = useRouter()
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const redeemingID = ref(0)
|
const redeemingID = ref(0)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
const redeemError = ref('')
|
||||||
|
const redeemDialogVisible = ref(false)
|
||||||
|
const selectedProduct = ref<PointsMallProduct | null>(null)
|
||||||
const summary = ref<PointsSummary | null>(null)
|
const summary = ref<PointsSummary | null>(null)
|
||||||
const products = ref<PointsMallProduct[]>([])
|
const products = ref<PointsMallProduct[]>([])
|
||||||
const redemptions = ref<PointsMallRedemption[]>([])
|
const redemptions = ref<PointsMallRedemption[]>([])
|
||||||
@@ -29,6 +32,15 @@ const isMobile = computed(() => props.mobile)
|
|||||||
const multiplierText = computed(() =>
|
const multiplierText = computed(() =>
|
||||||
summary.value ? (summary.value.points_multiplier_bps / 10000).toFixed(2) : '1.00'
|
summary.value ? (summary.value.points_multiplier_bps / 10000).toFixed(2) : '1.00'
|
||||||
)
|
)
|
||||||
|
const redeemTotalPoints = computed(() =>
|
||||||
|
selectedProduct.value ? selectedProduct.value.points_price * redeemForm.quantity : 0
|
||||||
|
)
|
||||||
|
const redeemForm = reactive({
|
||||||
|
quantity: 1,
|
||||||
|
contact_name: '',
|
||||||
|
contact_phone: '',
|
||||||
|
customer_remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
||||||
@@ -53,18 +65,51 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRedeem(product: PointsMallProduct) {
|
function openRedeem(product: PointsMallProduct) {
|
||||||
if (!summary.value || product.stock <= 0 || summary.value.available_points < product.points_price)
|
if (!summary.value || product.stock <= 0 || summary.value.available_points < product.points_price)
|
||||||
return
|
return
|
||||||
if (!window.confirm(`确认使用 ${product.points_price} 积分兑换「${product.title}」?`)) return
|
selectedProduct.value = product
|
||||||
redeemingID.value = product.id
|
Object.assign(redeemForm, {
|
||||||
errorMessage.value = ''
|
quantity: 1,
|
||||||
|
contact_name: '',
|
||||||
|
contact_phone: '',
|
||||||
|
customer_remark: '',
|
||||||
|
})
|
||||||
|
redeemError.value = ''
|
||||||
|
redeemDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRedeem() {
|
||||||
|
if (!selectedProduct.value) return
|
||||||
|
const contactName = redeemForm.contact_name.trim()
|
||||||
|
const contactPhone = redeemForm.contact_phone.trim()
|
||||||
|
const customerRemark = redeemForm.customer_remark.trim()
|
||||||
|
if (!contactName) {
|
||||||
|
redeemError.value = '请填写联系人'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(contactPhone)) {
|
||||||
|
redeemError.value = '请输入正确的11位手机号码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (customerRemark.length > 500) {
|
||||||
|
redeemError.value = '备注最多填写500个字'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redeemingID.value = selectedProduct.value.id
|
||||||
|
redeemError.value = ''
|
||||||
try {
|
try {
|
||||||
await redeemPointsProduct(product.id)
|
await redeemPointsProduct(selectedProduct.value.id, {
|
||||||
|
quantity: redeemForm.quantity,
|
||||||
|
contact_name: contactName,
|
||||||
|
contact_phone: contactPhone,
|
||||||
|
customer_remark: customerRemark,
|
||||||
|
})
|
||||||
|
redeemDialogVisible.value = false
|
||||||
await load()
|
await load()
|
||||||
activeTab.value = 'records'
|
activeTab.value = 'records'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = readError(error, '兑换失败')
|
redeemError.value = readError(error, '兑换失败')
|
||||||
} finally {
|
} finally {
|
||||||
redeemingID.value = 0
|
redeemingID.value = 0
|
||||||
}
|
}
|
||||||
@@ -156,7 +201,7 @@ function sourceLabel(source: string) {
|
|||||||
summary.available_points < product.points_price ||
|
summary.available_points < product.points_price ||
|
||||||
redeemingID === product.id
|
redeemingID === product.id
|
||||||
"
|
"
|
||||||
@click="handleRedeem(product)"
|
@click="openRedeem(product)"
|
||||||
>
|
>
|
||||||
{{ redeemingID === product.id ? '兑换中' : product.stock <= 0 ? '已兑完' : '立即兑换' }}
|
{{ redeemingID === product.id ? '兑换中' : product.stock <= 0 ? '已兑完' : '立即兑换' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -178,6 +223,10 @@ function sourceLabel(source: string) {
|
|||||||
{{ item.status === 'completed' ? '已发放' : '待处理' }}
|
{{ item.status === 'completed' ? '已发放' : '待处理' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="item.contact_name" class="record-contact">
|
||||||
|
兑换联系人:{{ item.contact_name }} · {{ item.contact_phone }}
|
||||||
|
<template v-if="item.customer_remark"> · 备注:{{ item.customer_remark }}</template>
|
||||||
|
</p>
|
||||||
<p v-if="item.status === 'completed'">{{ item.redemption_instructions }}</p>
|
<p v-if="item.status === 'completed'">{{ item.redemption_instructions }}</p>
|
||||||
<p v-if="item.admin_remark">处理备注:{{ item.admin_remark }}</p>
|
<p v-if="item.admin_remark">处理备注:{{ item.admin_remark }}</p>
|
||||||
</article>
|
</article>
|
||||||
@@ -195,6 +244,96 @@ function sourceLabel(source: string) {
|
|||||||
</em>
|
</em>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="redeemDialogVisible"
|
||||||
|
class="redeem-dialog"
|
||||||
|
width="min(520px, calc(100vw - 24px))"
|
||||||
|
align-center
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="!redeemingID"
|
||||||
|
:close-on-press-escape="!redeemingID"
|
||||||
|
:show-close="!redeemingID"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="redeem-dialog-title">
|
||||||
|
<span>积分兑换</span>
|
||||||
|
<strong>{{ selectedProduct?.title }}</strong>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="selectedProduct" class="redeem-dialog-body">
|
||||||
|
<div class="redeem-product">
|
||||||
|
<div class="redeem-product-cover">
|
||||||
|
<img
|
||||||
|
v-if="selectedProduct.cover_url"
|
||||||
|
:src="selectedProduct.cover_url"
|
||||||
|
:alt="selectedProduct.title"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ selectedProduct.title.slice(0, 1) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="redeem-product-info">
|
||||||
|
<span>{{ selectedProduct.subtitle || '积分商城商品' }}</span>
|
||||||
|
<strong>{{ selectedProduct.title }}</strong>
|
||||||
|
<small>{{ selectedProduct.description }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="redeem-summary">
|
||||||
|
<div>
|
||||||
|
<dt>兑换类型</dt>
|
||||||
|
<dd>虚拟权益</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>所需积分</dt>
|
||||||
|
<dd>{{ redeemTotalPoints }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>当前库存</dt>
|
||||||
|
<dd>{{ selectedProduct.stock }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<el-form label-position="top" class="redeem-form" @submit.prevent="submitRedeem">
|
||||||
|
<el-form-item label="联系人" required>
|
||||||
|
<el-input
|
||||||
|
v-model="redeemForm.contact_name"
|
||||||
|
maxlength="32"
|
||||||
|
placeholder="请输入联系人姓名"
|
||||||
|
:disabled="Boolean(redeemingID)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="联系手机" required>
|
||||||
|
<el-input
|
||||||
|
v-model="redeemForm.contact_phone"
|
||||||
|
type="tel"
|
||||||
|
maxlength="11"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
:disabled="Boolean(redeemingID)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input
|
||||||
|
v-model="redeemForm.customer_remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="选填,可填写发放或联系要求"
|
||||||
|
:disabled="Boolean(redeemingID)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<p v-if="redeemError" class="redeem-error">{{ redeemError }}</p>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="Boolean(redeemingID)" @click="redeemDialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="Boolean(redeemingID)" @click="submitRedeem">
|
||||||
|
提交兑换
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -442,6 +581,10 @@ function sourceLabel(source: string) {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.record-card .record-contact {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
.ledger-section {
|
.ledger-section {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -490,6 +633,164 @@ function sourceLabel(source: string) {
|
|||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.redeem-dialog) {
|
||||||
|
display: flex;
|
||||||
|
max-height: calc(100vh - 24px);
|
||||||
|
max-height: calc(100dvh - 24px);
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.redeem-dialog .el-dialog__header) {
|
||||||
|
flex: none;
|
||||||
|
margin-right: 0;
|
||||||
|
padding: 18px 20px 14px;
|
||||||
|
border-bottom: 1px solid #f0f1f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.redeem-dialog .el-dialog__body) {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 20px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.redeem-dialog .el-dialog__footer) {
|
||||||
|
flex: none;
|
||||||
|
padding: 14px 20px 18px;
|
||||||
|
border-top: 1px solid #f0f1f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.redeem-dialog .el-button--primary) {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
background: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-dialog-title {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-dialog-title span {
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-dialog-title strong {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-dialog-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-product {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 86px minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-product-cover {
|
||||||
|
display: grid;
|
||||||
|
width: 86px;
|
||||||
|
height: 76px;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f1ebff;
|
||||||
|
color: #6d28d9;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-product-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-product-info {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-product-info span {
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-product-info strong {
|
||||||
|
color: #20242a;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-product-info small {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #ede9fe;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #faf8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-summary > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 1px solid #ede9fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-summary > div:first-child {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-summary dt {
|
||||||
|
color: #8b95a5;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-summary dd {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #312e81;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-form :deep(.el-form-item__label) {
|
||||||
|
color: #4b5563;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-error {
|
||||||
|
margin: -6px 0 10px;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.points-mall.is-mobile {
|
.points-mall.is-mobile {
|
||||||
padding: 18px 12px 90px;
|
padding: 18px 12px 90px;
|
||||||
}
|
}
|
||||||
@@ -535,4 +836,30 @@ function sourceLabel(source: string) {
|
|||||||
.is-mobile .product-action {
|
.is-mobile .product-action {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
:global(.redeem-dialog .el-dialog__header),
|
||||||
|
:global(.redeem-dialog .el-dialog__body),
|
||||||
|
:global(.redeem-dialog .el-dialog__footer) {
|
||||||
|
padding-right: 16px;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-mobile .redeem-product {
|
||||||
|
grid-template-columns: 68px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-mobile .redeem-product-cover {
|
||||||
|
width: 68px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-mobile .redeem-summary > div {
|
||||||
|
padding: 9px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-mobile .redeem-summary dd {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user