feat(points): 增加双边积分与虚拟积分商城

This commit is contained in:
yml2213
2026-11-04 19:21:02 +08:00
parent c00d691301
commit d5abe67f60
42 changed files with 2593 additions and 316 deletions
@@ -17,6 +17,7 @@ export interface AdminUserItem {
deposit_free_used_cent: number
deposit_free_remaining_cent: number
renter_growth_points: number
points_available: number
renter_growth_level: string
renter_growth_level_name: string
available_balance_cent: number
@@ -2,6 +2,7 @@ export interface RenterGrowthLevelRule {
code: 'normal' | 'platinum' | 'diamond' | 'peak'
name: string
min_points: number
points_multiplier_bps: number
discount_bps: number
deposit_free_quota_cent: number
}
@@ -20,6 +21,7 @@ export const defaultRenterGrowthConfig: RenterGrowthConfig = {
code: 'normal',
name: '普通',
min_points: 0,
points_multiplier_bps: 10000,
discount_bps: 10000,
deposit_free_quota_cent: 0,
},
@@ -27,6 +29,7 @@ export const defaultRenterGrowthConfig: RenterGrowthConfig = {
code: 'platinum',
name: '铂金',
min_points: 300,
points_multiplier_bps: 12000,
discount_bps: 9900,
deposit_free_quota_cent: 50000,
},
@@ -34,6 +37,7 @@ export const defaultRenterGrowthConfig: RenterGrowthConfig = {
code: 'diamond',
name: '钻石',
min_points: 1000,
points_multiplier_bps: 15000,
discount_bps: 9800,
deposit_free_quota_cent: 100000,
},
@@ -41,6 +45,7 @@ export const defaultRenterGrowthConfig: RenterGrowthConfig = {
code: 'peak',
name: '巅峰',
min_points: 5000,
points_multiplier_bps: 20000,
discount_bps: 9500,
deposit_free_quota_cent: 150000,
},
@@ -59,6 +64,10 @@ export function parseRenterGrowthConfig(raw: string): RenterGrowthConfig {
code: defaultLevel.code,
name: defaultLevel.name,
min_points: nonNegativeInteger(incoming?.min_points, defaultLevel.min_points),
points_multiplier_bps: positiveInteger(
incoming?.points_multiplier_bps,
defaultLevel.points_multiplier_bps
),
discount_bps: positiveInteger(incoming?.discount_bps, defaultLevel.discount_bps),
deposit_free_quota_cent: nonNegativeInteger(
incoming?.deposit_free_quota_cent,
@@ -49,6 +49,14 @@ function updateQuotaYuan(level: RenterGrowthLevelRule, value: number | undefined
level.deposit_free_quota_cent = Math.round(Number(value || 0) * 100)
}
function pointsMultiplier(level: RenterGrowthLevelRule) {
return level.points_multiplier_bps / 10000
}
function updatePointsMultiplier(level: RenterGrowthLevelRule, value: number | undefined) {
level.points_multiplier_bps = Math.round(Number(value || 0) * 10000)
}
function resetDefaults() {
draft.value = cloneRenterGrowthConfig(defaultRenterGrowthConfig)
}
@@ -64,6 +72,16 @@ function validateDraft() {
}
if (index === 0 && level.min_points !== 0) return '普通等级积分门槛必须为 0'
if (previous && level.min_points <= previous.min_points) return '等级积分门槛必须严格递增'
if (
!Number.isInteger(level.points_multiplier_bps) ||
level.points_multiplier_bps <= 0 ||
level.points_multiplier_bps > 100000
) {
return `${level.name}积分倍率不正确`
}
if (previous && level.points_multiplier_bps < previous.points_multiplier_bps) {
return '高等级积分倍率不能低于低等级'
}
if (level.discount_bps <= 0 || level.discount_bps > 10000) {
return `${level.name}支付比例必须在 0% 到 100% 之间`
}
@@ -89,7 +107,7 @@ async function handleSave() {
try {
await updateSystemConfig(props.config.key, {
value: JSON.stringify(draft.value),
description: '租客成长等级规则(积分门槛、租金折扣、等级免押额度)',
description: '积分等级规则(积分门槛、积分倍率、租金折扣、等级免押额度)',
})
ElMessage.success('成长等级配置已更新')
emit('saved')
@@ -105,14 +123,14 @@ async function handleSave() {
<template>
<el-dialog
:model-value="modelValue"
title="成长等级配置"
title="积分等级配置"
width="860px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div class="growth-config-form">
<div class="growth-switch-row">
<span>启用成长等级</span>
<span>启用积分等级</span>
<el-switch v-model="draft.enabled" />
</div>
<el-form label-position="top" @submit.prevent>
@@ -141,6 +159,19 @@ async function handleSave() {
/>
</template>
</el-table-column>
<el-table-column label="积分倍率" min-width="150">
<template #default="{ row }">
<el-input-number
:model-value="pointsMultiplier(row)"
:min="0.1"
:max="10"
:precision="2"
:step="0.1"
@update:model-value="updatePointsMultiplier(row, $event)"
/>
<span class="unit">倍</span>
</template>
</el-table-column>
<el-table-column label="租金支付比例" min-width="190">
<template #default="{ row, $index }">
<el-input-number
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import {
completePointsRedemption,
createPointsProduct,
fetchAdminPointsProducts,
fetchAdminPointsRedemptions,
updatePointsProduct,
type PointsMallProduct,
type PointsMallProductPayload,
type PointsMallRedemption,
} from '@/features/points/api/pointsMall'
import { useAdminSessionStore } from '@/stores/adminSession'
import { readError } from '@/shared/utils/error'
const adminSession = useAdminSessionStore()
const activeTab = ref<'products' | 'redemptions'>('products')
const loading = ref(false)
const submitting = ref(false)
const products = ref<PointsMallProduct[]>([])
const redemptions = ref<PointsMallRedemption[]>([])
const editorVisible = ref(false)
const editingID = ref(0)
const canManage = computed(() => adminSession.hasPermission('points_mall:manage'))
const form = reactive<PointsMallProductPayload>(defaultProduct())
onMounted(load)
async function load() {
loading.value = true
try {
const [nextProducts, nextRedemptions] = await Promise.all([
fetchAdminPointsProducts(),
fetchAdminPointsRedemptions(),
])
products.value = nextProducts
redemptions.value = nextRedemptions
} catch (error) {
ElMessage.error(readError(error, '积分商城数据加载失败'))
} finally {
loading.value = false
}
}
function openCreate() {
editingID.value = 0
Object.assign(form, defaultProduct())
editorVisible.value = true
}
function openEdit(item: PointsMallProduct) {
editingID.value = item.id
Object.assign(form, {
title: item.title,
subtitle: item.subtitle,
description: item.description,
cover_url: item.cover_url,
redemption_instructions: item.redemption_instructions,
points_price: item.points_price,
stock: item.stock,
per_user_limit: item.per_user_limit,
status: item.status === 'online' ? 'online' : 'offline',
sort_order: item.sort_order,
})
editorVisible.value = true
}
async function saveProduct() {
if (!form.title.trim() || !form.description.trim() || !form.redemption_instructions.trim()) {
ElMessage.warning('请填写商品名称、说明和兑换说明')
return
}
submitting.value = true
try {
const payload = { ...form }
if (editingID.value) {
await updatePointsProduct(editingID.value, payload)
} else {
await createPointsProduct(payload)
}
ElMessage.success('积分商品已保存')
editorVisible.value = false
await load()
} catch (error) {
ElMessage.error(readError(error, '积分商品保存失败'))
} finally {
submitting.value = false
}
}
async function completeRedemption(item: PointsMallRedemption) {
const remark = window.prompt('填写发放说明或处理备注(可留空)', item.admin_remark || '')
if (remark === null) return
try {
await completePointsRedemption(item.id, remark)
ElMessage.success('兑换已标记完成')
await load()
} catch (error) {
ElMessage.error(readError(error, '处理兑换失败'))
}
}
function defaultProduct(): PointsMallProductPayload {
return {
title: '',
subtitle: '',
description: '',
cover_url: '',
redemption_instructions: '',
points_price: 100,
stock: 10,
per_user_limit: 1,
status: 'offline',
sort_order: 0,
}
}
</script>
<template>
<main class="admin-page">
<header class="page-header">
<div>
<h1>积分商城</h1>
<p>管理虚拟商品、库存和用户兑换发放。</p>
</div>
<el-button v-if="canManage" type="primary" @click="openCreate">新增积分商品</el-button>
</header>
<el-tabs v-model="activeTab">
<el-tab-pane label="商品管理" name="products">
<el-table v-loading="loading" :data="products" border>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="商品" min-width="180" />
<el-table-column prop="points_price" label="积分价格" width="110" />
<el-table-column prop="stock" label="库存" width="90" />
<el-table-column prop="redeemed_count" label="已兑换" width="90" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'online' ? 'success' : 'info'">
{{ row.status === 'online' ? '上架' : '下架' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button v-if="canManage" link type="primary" @click="openEdit(row)">
编辑
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="兑换记录" name="redemptions">
<el-table v-loading="loading" :data="redemptions" border>
<el-table-column prop="redemption_no" label="兑换单号" min-width="190" />
<el-table-column label="用户" min-width="150">
<template #default="{ row }">
{{ row.user_nickname || '-' }}<br />
<small>{{ row.user_phone || row.user_id }}</small>
</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">
<template #default="{ row }">
<el-tag :type="row.status === 'completed' ? 'success' : 'warning'">
{{ row.status === 'completed' ? '已发放' : '待处理' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="兑换时间" min-width="170" />
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
v-if="canManage && row.status === 'pending'"
link
type="primary"
@click="completeRedemption(row)"
>
发放
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<el-dialog
v-model="editorVisible"
:title="editingID ? '编辑积分商品' : '新增积分商品'"
width="680px"
>
<el-form label-position="top">
<el-form-item label="商品名称">
<el-input v-model="form.title" maxlength="128" />
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="form.subtitle" maxlength="255" />
</el-form-item>
<el-form-item label="商品说明">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="封面图片 URL">
<el-input v-model="form.cover_url" />
</el-form-item>
<el-form-item label="兑换后发放说明">
<el-input v-model="form.redemption_instructions" type="textarea" :rows="3" />
</el-form-item>
<div class="form-grid">
<el-form-item label="积分价格">
<el-input-number v-model="form.points_price" :min="1" />
</el-form-item>
<el-form-item label="库存">
<el-input-number v-model="form.stock" :min="0" />
</el-form-item>
<el-form-item label="每人限兑">
<el-input-number v-model="form.per_user_limit" :min="1" :max="100" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" />
</el-form-item>
</div>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio-button value="online">上架</el-radio-button>
<el-radio-button value="offline">下架</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editorVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="saveProduct">保存</el-button>
</template>
</el-dialog>
</main>
</template>
<style scoped>
.admin-page {
padding: 22px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.page-header h1 {
margin: 0;
color: #1f2937;
font-size: 24px;
}
.page-header p {
margin: 6px 0 0;
color: #6b7280;
font-size: 13px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 760px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -695,10 +695,10 @@ function formatConfigValue(row: SystemConfig) {
<div class="publish-config-main">
<div>
<p class="eyebrow">Renter Growth</p>
<h2>租客成长等级</h2>
<span>管理完成订单后的积分换算、各等级租金支付比例与等级免押额度。</span>
<h2>积分等级规则</h2>
<span>管理订单积分倍率、等级门槛、租金支付比例与等级免押额度。</span>
</div>
<el-button type="primary" @click="openEdit(renterGrowthConfig)">编辑成长等级</el-button>
<el-button type="primary" @click="openEdit(renterGrowthConfig)">编辑积分等级</el-button>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
+1
View File
@@ -13,6 +13,7 @@ export interface AuthUser {
credit_score: number
renter_growth_points?: number
renter_growth_level?: string
points_available?: number
quality_owner_status?: string
status: UserStatus
}
@@ -684,6 +684,39 @@
background: linear-gradient(135deg, #faf8ff 0%, #ffffff 72%);
}
.mobile-points-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 10px 12px;
padding: 14px 16px;
border: 1px solid #ddd6fe;
border-radius: 14px;
background: #faf8ff;
}
.mobile-points-entry div {
display: grid;
gap: 3px;
}
.mobile-points-entry span {
color: #7c3aed;
font-size: 10px;
font-weight: 900;
}
.mobile-points-entry strong {
color: #312e81;
font-size: 15px;
}
.mobile-points-entry small {
color: #6b7280;
font-size: 10px;
}
.quality-owner-card.is-active {
border-color: #c4b5fd;
}
@@ -525,7 +525,7 @@ function resolveAvatarURL(url: string | undefined | null) {
<strong>{{ session.renterGrowthLevelName }}</strong>
</div>
<div>
<span>当前积分</span>
<span>累计积分</span>
<strong>{{ session.renterGrowthPoints }}</strong>
</div>
</section>
@@ -605,6 +605,15 @@ function resolveAvatarURL(url: string | undefined | null) {
</p>
</section>
<section class="mobile-points-entry" @click="router.push('/m/points-mall')">
<div>
<span>积分商城</span>
<strong>{{ session.pointsAvailable }} 可用积分</strong>
<small>{{ session.renterGrowthLevelName }}等级</small>
</div>
<van-icon name="arrow" />
</section>
<!-- 余额卡片 - 银行卡质感暗色卡片 -->
<div class="balance-card">
<div class="balance-row">
@@ -420,7 +420,7 @@ async function savePassword() {
<dd class="growth-level">{{ session.renterGrowthLevelName }}</dd>
</div>
<div>
<dt>成长积分</dt>
<dt>累计积分</dt>
<dd>{{ session.renterGrowthPoints }}</dd>
</div>
</dl>
@@ -514,6 +514,17 @@ async function savePassword() {
</div>
</section>
<section class="points-entry-card">
<div>
<span>POINTS MALL</span>
<strong>积分商城</strong>
<p>
可用 {{ session.pointsAvailable }} 积分 · 当前等级 {{ session.renterGrowthLevelName }}
</p>
</div>
<el-button type="primary" @click="router.push('/points-mall')">进入积分商城</el-button>
</section>
<PayWaySelectDialog v-model="qualityOwnerPayWayVisible" @choose="chooseQualityOwnerPayWay" />
<el-dialog
@@ -1023,6 +1034,45 @@ async function savePassword() {
box-shadow: 0 10px 28px rgba(76, 29, 149, 0.06);
}
.points-entry-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 18px;
padding: 20px 24px;
border: 1px solid #ddd6fe;
border-radius: 14px;
background: linear-gradient(135deg, #faf8ff 0%, #fff 72%);
}
.points-entry-card div {
display: grid;
gap: 4px;
}
.points-entry-card span {
color: #7c3aed;
font-size: 10px;
font-weight: 900;
}
.points-entry-card strong {
color: #312e81;
font-size: 19px;
}
.points-entry-card p {
margin: 0;
color: #6b7280;
font-size: 12px;
}
.points-entry-card :deep(.el-button--primary) {
border: none;
background: #7c3aed;
}
.quality-owner-card.is-active {
border-color: #c4b5fd;
}
@@ -45,8 +45,14 @@ export interface Order {
renter_discount_bps?: number
growth_points_basis_cent?: number
growth_points_per_yuan?: number
growth_points_multiplier_bps?: number
growth_points_awarded?: number
growth_points_awarded_at?: string
owner_growth_points_basis_cent?: number
owner_growth_points_per_yuan?: number
owner_growth_points_multiplier_bps?: number
owner_growth_points_awarded?: number
owner_growth_points_awarded_at?: string
account_snapshot?: Record<string, unknown>
account_source?: 'internal' | 'external' | string
source_channel?: string
@@ -20,6 +20,12 @@ const props = withDefaults(
)
const checkout = computed(() => props.order.checkout)
const pointsAwarded = computed(() =>
props.isOwner ? props.order.owner_growth_points_awarded : props.order.growth_points_awarded
)
const pointsBasisCent = computed(() =>
props.isOwner ? props.order.owner_growth_points_basis_cent : props.order.growth_points_basis_cent
)
// 结账内容含手填备注与系统结算信息,拆开展示并标注备注不参与结算
const checkoutContent = computed(() => splitCheckoutContent(props.order.checkout?.content))
</script>
@@ -105,10 +111,10 @@ const checkoutContent = computed(() => splitCheckoutContent(props.order.checkout
value-class="green-text"
/>
<van-cell
v-if="props.isRenter && props.order.status === 'completed'"
title="本单成长积分"
:label="`积分基数 ¥${money(amountYuan(props.order.growth_points_basis_cent))}`"
:value="`+${Number(props.order.growth_points_awarded || 0)}`"
v-if="(props.isRenter || props.isOwner) && props.order.status === 'completed'"
:title="props.isOwner ? '本单卖家积分' : '本单租客积分'"
:label="`积分基数 ¥${money(amountYuan(pointsBasisCent))}`"
:value="`+${Number(pointsAwarded || 0)}`"
value-class="green-text"
/>
<van-cell
@@ -297,9 +297,13 @@ async function copyListingCode() {
>
</div>
<div v-if="isRenter && order.status === 'completed' && !order.checkout" class="meta-item">
<span class="meta-label">本单成长积分</span>
<span class="meta-label">本单租客积分</span>
<strong class="meta-value income">+{{ order.growth_points_awarded || 0 }}</strong>
</div>
<div v-if="isOwner && order.status === 'completed' && !order.checkout" class="meta-item">
<span class="meta-label">本单卖家积分</span>
<strong class="meta-value income">+{{ order.owner_growth_points_awarded || 0 }}</strong>
</div>
</div>
</section>
@@ -457,9 +457,16 @@ watch([() => route.query.focus, order, loading], () => {
v-if="isRenter && order.status === 'completed' && !order.checkout"
class="metric-card income"
>
<span class="metric-label">本单成长积分</span>
<span class="metric-label">本单租客积分</span>
<strong class="metric-value">+{{ order.growth_points_awarded || 0 }}</strong>
</div>
<div
v-if="isOwner && order.status === 'completed' && !order.checkout"
class="metric-card income"
>
<span class="metric-label">本单卖家积分</span>
<strong class="metric-value">+{{ order.owner_growth_points_awarded || 0 }}</strong>
</div>
</div>
<div v-if="order" class="info-section">
@@ -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>
+6
View File
@@ -89,6 +89,12 @@ const allNavGroups: NavGroup[] = [
icon: Briefcase,
children: [
{ label: '用户管理', to: adminPath('users'), icon: User, permission: 'user:view' },
{
label: '积分商城',
to: adminPath('points-mall'),
icon: Present,
permission: 'points_mall:view',
},
{ label: '订单管理', to: adminPath('orders'), icon: Tickets, permission: 'order:view' },
{
label: '退款审核',
+1 -1
View File
@@ -272,7 +272,7 @@ async function handleSupportClick() {
<strong>{{ session.renterGrowthLevelName }}</strong>
</div>
<div>
<span>当前积分</span>
<span>累计积分</span>
<strong>{{ session.renterGrowthPoints }}</strong>
</div>
</div>
+6
View File
@@ -19,6 +19,12 @@ export const accountRoutes: RouteRecordRaw[] = [
component: () => import('@/features/auth/views/ProfileView.vue'),
meta: { requiresAuth: true },
},
{
path: '/points-mall',
name: 'points-mall',
component: () => import('@/features/points/views/PointsMallView.vue'),
meta: { requiresAuth: true },
},
{
path: '/orders',
name: 'orders',
+6
View File
@@ -24,6 +24,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/features/admin/views/AdminUsersView.vue'),
meta: adminMeta,
},
{
path: adminPath('points-mall'),
name: 'admin-points-mall',
component: () => import('@/features/admin/views/AdminPointsMallView.vue'),
meta: adminMeta,
},
{
path: adminPath('orders'),
name: 'admin-orders',
+6
View File
@@ -95,6 +95,12 @@ export const mobileRoutes: RouteRecordRaw[] = [
component: () => import('@/features/auth/views/MobileProfileView.vue'),
meta: { layout: 'blank', requiresAuth: true },
},
{
path: '/m/points-mall',
name: 'mobile-points-mall',
component: () => import('@/features/points/views/PointsMallView.vue'),
meta: { layout: 'blank', requiresAuth: true },
},
{
path: '/m/notifications',
name: 'mobile-notifications',
+1
View File
@@ -17,6 +17,7 @@ const userKeys = {
'avatar_url',
'realname_status',
'quality_owner_status',
'points_available',
],
}
+5
View File
@@ -35,6 +35,7 @@ export const useSessionStore = defineStore('session', {
avatarUrl: localStorage.getItem('avatar_url') || '',
realnameStatus: localStorage.getItem('realname_status') || 'unknown',
renterGrowthPoints: Number(localStorage.getItem('renter_growth_points') || 0),
pointsAvailable: Number(localStorage.getItem('points_available') || 0),
renterGrowthLevel: localStorage.getItem('renter_growth_level') || 'normal',
qualityOwnerStatus: localStorage.getItem('quality_owner_status') || 'none',
_loadingMe: false, // 防止重复调用
@@ -105,6 +106,7 @@ export const useSessionStore = defineStore('session', {
this.avatarUrl = ''
this.realnameStatus = 'unknown'
this.renterGrowthPoints = 0
this.pointsAvailable = 0
this.renterGrowthLevel = 'normal'
this.qualityOwnerStatus = 'none'
clearAuthStorage('user')
@@ -118,6 +120,7 @@ export const useSessionStore = defineStore('session', {
this.avatarUrl = localStorage.getItem('avatar_url') || ''
this.realnameStatus = localStorage.getItem('realname_status') || 'unknown'
this.renterGrowthPoints = Number(localStorage.getItem('renter_growth_points') || 0)
this.pointsAvailable = Number(localStorage.getItem('points_available') || 0)
this.renterGrowthLevel = localStorage.getItem('renter_growth_level') || 'normal'
this.qualityOwnerStatus = localStorage.getItem('quality_owner_status') || 'none'
},
@@ -145,6 +148,8 @@ export const useSessionStore = defineStore('session', {
localStorage.setItem('realname_status', user.realname_status)
this.renterGrowthPoints = Number(user.renter_growth_points || 0)
localStorage.setItem('renter_growth_points', String(this.renterGrowthPoints))
this.pointsAvailable = Number(user.points_available || 0)
localStorage.setItem('points_available', String(this.pointsAvailable))
this.renterGrowthLevel = user.renter_growth_level || 'normal'
localStorage.setItem('renter_growth_level', this.renterGrowthLevel)
this.qualityOwnerStatus = user.quality_owner_status || 'none'