feat(points): 增加双边积分与虚拟积分商城
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user