feat(points): 增加积分兑换联系信息

This commit is contained in:
yml2213
2026-11-04 19:51:17 +08:00
parent 95aeee7eb3
commit f439e23eb8
9 changed files with 425 additions and 25 deletions
@@ -186,6 +186,16 @@ function defaultProduct(): PointsMallProductPayload {
<small>{{ row.user_phone || row.user_id }}</small>
</template>
</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="total_points" label="积分" width="100" />
<el-table-column label="状态" width="100">
@@ -315,6 +325,14 @@ function defaultProduct(): PointsMallProductPayload {
gap: 12px;
}
.customer-remark {
margin: 5px 0 0;
color: #6b7280;
font-size: 12px;
line-height: 1.4;
white-space: normal;
}
.cover-editor {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
+12 -2
View File
@@ -49,6 +49,9 @@ export interface PointsMallRedemption {
product_title: string
product_cover_url: string
redemption_instructions: string
contact_name: string
contact_phone: string
customer_remark: string
points_price: number
quantity: number
total_points: number
@@ -58,6 +61,13 @@ export interface PointsMallRedemption {
created_at: string
}
export interface PointsMallRedeemPayload {
quantity: number
contact_name: string
contact_phone: string
customer_remark: string
}
export interface PointsMallProductPayload {
title: string
subtitle: string
@@ -86,10 +96,10 @@ export async function fetchPointsProducts() {
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>>(
`/points-mall/products/${id}/redeem`,
{ quantity }
payload
)
return data.data
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
@@ -20,6 +20,9 @@ const router = useRouter()
const loading = ref(true)
const redeemingID = ref(0)
const errorMessage = ref('')
const redeemError = ref('')
const redeemDialogVisible = ref(false)
const selectedProduct = ref<PointsMallProduct | null>(null)
const summary = ref<PointsSummary | null>(null)
const products = ref<PointsMallProduct[]>([])
const redemptions = ref<PointsMallRedemption[]>([])
@@ -29,6 +32,15 @@ const isMobile = computed(() => props.mobile)
const multiplierText = computed(() =>
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)
@@ -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)
return
if (!window.confirm(`确认使用 ${product.points_price} 积分兑换「${product.title}」?`)) return
redeemingID.value = product.id
errorMessage.value = ''
selectedProduct.value = product
Object.assign(redeemForm, {
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 {
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()
activeTab.value = 'records'
} catch (error) {
errorMessage.value = readError(error, '兑换失败')
redeemError.value = readError(error, '兑换失败')
} finally {
redeemingID.value = 0
}
@@ -156,7 +201,7 @@ function sourceLabel(source: string) {
summary.available_points < product.points_price ||
redeemingID === product.id
"
@click="handleRedeem(product)"
@click="openRedeem(product)"
>
{{ redeemingID === product.id ? '兑换中' : product.stock <= 0 ? '已兑完' : '立即兑换' }}
</button>
@@ -178,6 +223,10 @@ function sourceLabel(source: string) {
{{ item.status === 'completed' ? '已发放' : '待处理' }}
</span>
</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.admin_remark">处理备注:{{ item.admin_remark }}</p>
</article>
@@ -195,6 +244,96 @@ function sourceLabel(source: string) {
</em>
</div>
</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>
</template>
@@ -442,6 +581,10 @@ function sourceLabel(source: string) {
font-size: 12px;
}
.record-card .record-contact {
color: #4b5563;
}
.ledger-section {
margin-top: 18px;
padding: 16px;
@@ -490,6 +633,164 @@ function sourceLabel(source: string) {
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 {
padding: 18px 12px 90px;
}
@@ -535,4 +836,30 @@ function sourceLabel(source: string) {
.is-mobile .product-action {
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>