feat(points): 增加双边积分与虚拟积分商城
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { ApiResponse } from '@/shared/types/types'
|
||||
|
||||
export interface PointsSummary {
|
||||
available_points: number
|
||||
lifetime_points: number
|
||||
level_code: string
|
||||
level_name: string
|
||||
points_per_yuan: number
|
||||
points_multiplier_bps: number
|
||||
}
|
||||
|
||||
export interface PointsLedgerItem {
|
||||
id: number
|
||||
role: string
|
||||
points: number
|
||||
basis_amount_cent: number
|
||||
before_points: number
|
||||
after_points: number
|
||||
available_before: number
|
||||
available_after: number
|
||||
source: string
|
||||
remark: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PointsMallProduct {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string
|
||||
description: string
|
||||
cover_url: string
|
||||
redemption_instructions: string
|
||||
points_price: number
|
||||
stock: number
|
||||
redeemed_count: number
|
||||
per_user_limit: number
|
||||
status: 'online' | 'offline' | string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface PointsMallRedemption {
|
||||
id: number
|
||||
redemption_no: string
|
||||
user_id: number
|
||||
user_phone?: string
|
||||
user_nickname?: string
|
||||
product_id: number
|
||||
product_title: string
|
||||
product_cover_url: string
|
||||
redemption_instructions: string
|
||||
points_price: number
|
||||
quantity: number
|
||||
total_points: number
|
||||
status: 'pending' | 'completed' | string
|
||||
admin_remark: string
|
||||
completed_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PointsMallProductPayload {
|
||||
title: string
|
||||
subtitle: string
|
||||
description: string
|
||||
cover_url: string
|
||||
redemption_instructions: string
|
||||
points_price: number
|
||||
stock: number
|
||||
per_user_limit: number
|
||||
status: 'online' | 'offline'
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export async function fetchPointsSummary() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsSummary>>('/points/summary')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchPointsLedger() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsLedgerItem[]>>('/points/ledger')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchPointsProducts() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsMallProduct[]>>('/points-mall/products')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function redeemPointsProduct(id: number, quantity = 1) {
|
||||
const { data } = await apiClient.post<ApiResponse<PointsMallRedemption>>(
|
||||
`/points-mall/products/${id}/redeem`,
|
||||
{ quantity }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchMyPointsRedemptions() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsMallRedemption[]>>(
|
||||
'/points-mall/redemptions'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminPointsProducts() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsMallProduct[]>>(
|
||||
'/admin/points-mall/products'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createPointsProduct(payload: PointsMallProductPayload) {
|
||||
const { data } = await apiClient.post<ApiResponse<PointsMallProduct>>(
|
||||
'/admin/points-mall/products',
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updatePointsProduct(id: number, payload: PointsMallProductPayload) {
|
||||
const { data } = await apiClient.put<ApiResponse<PointsMallProduct>>(
|
||||
`/admin/points-mall/products/${id}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminPointsRedemptions() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsMallRedemption[]>>(
|
||||
'/admin/points-mall/redemptions'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function completePointsRedemption(id: number, remark: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<PointsMallRedemption>>(
|
||||
`/admin/points-mall/redemptions/${id}/complete`,
|
||||
{ remark }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
fetchMyPointsRedemptions,
|
||||
fetchPointsLedger,
|
||||
fetchPointsProducts,
|
||||
fetchPointsSummary,
|
||||
redeemPointsProduct,
|
||||
type PointsLedgerItem,
|
||||
type PointsMallProduct,
|
||||
type PointsMallRedemption,
|
||||
type PointsSummary,
|
||||
} from '@/features/points/api/pointsMall'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const redeemingID = ref(0)
|
||||
const errorMessage = ref('')
|
||||
const summary = ref<PointsSummary | null>(null)
|
||||
const products = ref<PointsMallProduct[]>([])
|
||||
const redemptions = ref<PointsMallRedemption[]>([])
|
||||
const ledger = ref<PointsLedgerItem[]>([])
|
||||
const activeTab = ref<'mall' | 'records'>('mall')
|
||||
const isMobile = computed(() => route.path.startsWith('/m/'))
|
||||
const multiplierText = computed(() =>
|
||||
summary.value ? (summary.value.points_multiplier_bps / 10000).toFixed(2) : '1.00'
|
||||
)
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const [nextSummary, nextProducts, nextRedemptions, nextLedger] = await Promise.all([
|
||||
fetchPointsSummary(),
|
||||
fetchPointsProducts(),
|
||||
fetchMyPointsRedemptions(),
|
||||
fetchPointsLedger(),
|
||||
])
|
||||
summary.value = nextSummary
|
||||
products.value = nextProducts
|
||||
redemptions.value = nextRedemptions
|
||||
ledger.value = nextLedger
|
||||
} catch (error) {
|
||||
errorMessage.value = readError(error, '积分商城加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRedeem(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 = ''
|
||||
try {
|
||||
await redeemPointsProduct(product.id)
|
||||
await load()
|
||||
activeTab.value = 'records'
|
||||
} catch (error) {
|
||||
errorMessage.value = readError(error, '兑换失败')
|
||||
} finally {
|
||||
redeemingID.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function back() {
|
||||
router.push(isMobile.value ? '/m/profile' : '/profile')
|
||||
}
|
||||
|
||||
function sourceLabel(source: string) {
|
||||
const labels: Record<string, string> = {
|
||||
order_completed: '订单完成',
|
||||
order_refunded: '退款冲正',
|
||||
admin_adjustment: '后台调整',
|
||||
mall_redeem: '商城兑换',
|
||||
}
|
||||
return labels[source] || source
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="points-mall" :class="{ 'is-mobile': isMobile }">
|
||||
<header class="points-header">
|
||||
<button type="button" class="back-button" @click="back">返回</button>
|
||||
<div>
|
||||
<span>POINTS CENTER</span>
|
||||
<h1>积分商城</h1>
|
||||
<p>租客按实际租金支出、卖家按实际租金收入获得积分。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="notice is-error">{{ errorMessage }}</p>
|
||||
|
||||
<section v-if="summary" class="points-overview">
|
||||
<div class="points-balance">
|
||||
<span>可用积分</span>
|
||||
<strong>{{ summary.available_points }}</strong>
|
||||
<small>累计 {{ summary.lifetime_points }} 积分,不随兑换降级</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>当前等级</span>
|
||||
<strong>{{ summary.level_name }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>积分倍率</span>
|
||||
<strong>{{ multiplierText }}x</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>基础规则</span>
|
||||
<strong>¥1 = {{ summary.points_per_yuan }} 积分</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="points-tabs">
|
||||
<button type="button" :class="{ active: activeTab === 'mall' }" @click="activeTab = 'mall'">
|
||||
积分商品
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'records' }"
|
||||
@click="activeTab = 'records'"
|
||||
>
|
||||
我的兑换
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p v-if="loading" class="notice">加载中...</p>
|
||||
|
||||
<section v-else-if="activeTab === 'mall'" class="product-section">
|
||||
<div v-if="!products.length" class="empty-state">暂无可兑换商品</div>
|
||||
<article v-for="product in products" :key="product.id" class="product-card">
|
||||
<div class="product-cover">
|
||||
<img v-if="product.cover_url" :src="product.cover_url" :alt="product.title" />
|
||||
<span v-else>{{ product.title.slice(0, 1) }}</span>
|
||||
</div>
|
||||
<div class="product-copy">
|
||||
<span v-if="product.subtitle">{{ product.subtitle }}</span>
|
||||
<h2>{{ product.title }}</h2>
|
||||
<p>{{ product.description }}</p>
|
||||
<small>库存 {{ product.stock }} · 每人限兑 {{ product.per_user_limit }} 件</small>
|
||||
</div>
|
||||
<div class="product-action">
|
||||
<strong>{{ product.points_price }} 积分</strong>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="
|
||||
product.stock <= 0 ||
|
||||
!summary ||
|
||||
summary.available_points < product.points_price ||
|
||||
redeemingID === product.id
|
||||
"
|
||||
@click="handleRedeem(product)"
|
||||
>
|
||||
{{ redeemingID === product.id ? '兑换中' : product.stock <= 0 ? '已兑完' : '立即兑换' }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-else class="record-section">
|
||||
<div v-if="!redemptions.length" class="empty-state">还没有兑换记录</div>
|
||||
<article v-for="item in redemptions" :key="item.id" class="record-card">
|
||||
<div>
|
||||
<span>{{ item.redemption_no }}</span>
|
||||
<strong>{{ item.product_title }}</strong>
|
||||
<small>{{ new Date(item.created_at).toLocaleString() }}</small>
|
||||
</div>
|
||||
<div class="record-status">
|
||||
<strong>-{{ item.total_points }}</strong>
|
||||
<span :class="{ completed: item.status === 'completed' }">
|
||||
{{ item.status === 'completed' ? '已发放' : '待处理' }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="item.status === 'completed'">{{ item.redemption_instructions }}</p>
|
||||
<p v-if="item.admin_remark">处理备注:{{ item.admin_remark }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="ledger.length" class="ledger-section">
|
||||
<h2>最近积分明细</h2>
|
||||
<div v-for="item in ledger.slice(0, 8)" :key="item.id" class="ledger-row">
|
||||
<div>
|
||||
<strong>{{ item.remark || sourceLabel(item.source) }}</strong>
|
||||
<span>{{ new Date(item.created_at).toLocaleString() }}</span>
|
||||
</div>
|
||||
<em :class="{ income: item.points > 0 }">
|
||||
{{ item.points > 0 ? '+' : '' }}{{ item.points }}
|
||||
</em>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.points-mall {
|
||||
min-height: 100vh;
|
||||
padding: 28px max(20px, calc((100vw - 1240px) / 2));
|
||||
background: #f5f7fb;
|
||||
color: #20242a;
|
||||
}
|
||||
|
||||
.points-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.points-header span,
|
||||
.product-copy > span {
|
||||
color: #7c3aed;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.points-header h1 {
|
||||
margin: 4px 0;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.points-header p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
min-height: 34px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 17px;
|
||||
background: #fff;
|
||||
color: #6d28d9;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.points-overview {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.points-overview > div,
|
||||
.product-card,
|
||||
.record-card,
|
||||
.ledger-section {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.points-overview > div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.points-overview span {
|
||||
color: #7b8492;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.points-overview strong {
|
||||
color: #312e81;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.points-overview small {
|
||||
color: #8b95a5;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.points-balance {
|
||||
border-color: #c4b5fd !important;
|
||||
background: #faf8ff !important;
|
||||
}
|
||||
|
||||
.points-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 22px 0 14px;
|
||||
}
|
||||
|
||||
.points-tabs button {
|
||||
min-height: 36px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
color: #6b7280;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.points-tabs button.active {
|
||||
border-color: #7c3aed;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.product-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
display: grid;
|
||||
grid-template-rows: 150px 1fr auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #f1ebff;
|
||||
color: #6d28d9;
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.product-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.product-copy {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 7px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.product-copy h2,
|
||||
.ledger-section h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.product-copy p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.product-copy small {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.product-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 15px;
|
||||
border-top: 1px solid #f0f1f4;
|
||||
}
|
||||
|
||||
.product-action strong {
|
||||
color: #7c3aed;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.product-action button {
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 17px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.product-action button:disabled {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.record-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.record-card > div:first-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.record-card span,
|
||||
.record-card small {
|
||||
color: #8b95a5;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.record-status {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.record-status strong {
|
||||
display: block;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.record-status span {
|
||||
display: inline-block;
|
||||
margin-top: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.record-status span.completed {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.record-card p {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ledger-section {
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ledger-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 0;
|
||||
border-top: 1px solid #f1f2f5;
|
||||
}
|
||||
|
||||
.ledger-row div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ledger-row span {
|
||||
color: #9ca3af;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ledger-row em {
|
||||
color: #dc2626;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.ledger-row em.income {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.notice,
|
||||
.empty-state {
|
||||
margin: 12px 0;
|
||||
padding: 18px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notice.is-error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.points-mall.is-mobile {
|
||||
padding: 18px 12px 90px;
|
||||
}
|
||||
|
||||
.is-mobile .points-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.is-mobile .points-overview {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.is-mobile .points-balance {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.is-mobile .points-overview > div {
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.is-mobile .points-overview strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.is-mobile .product-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.is-mobile .product-card {
|
||||
grid-template-columns: 104px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
.is-mobile .product-cover {
|
||||
grid-row: 1 / 3;
|
||||
min-height: 144px;
|
||||
}
|
||||
|
||||
.is-mobile .product-copy {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.is-mobile .product-action {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user