完善租客等级免押与积分管理

This commit is contained in:
yml2213
2026-07-26 18:07:10 +08:00
parent 379536e5c5
commit c594b543d1
24 changed files with 1296 additions and 178 deletions
@@ -18,6 +18,7 @@ import {
} from '@/features/listings/api/homeConfig'
import type { OrderAgreements } from '@/features/orders/api/orders'
import { fetchSystemConfigs, type SystemConfig } from '@/features/admin/api/systemConfigs'
import { parseRenterGrowthConfig } from '@/features/admin/api/renterGrowth'
import { formatDateTime } from '@/shared/utils/time'
import { safeParseJSON } from '@/shared/utils/json'
import { formatSystemConfigSelectValue } from '@/shared/utils/systemConfigOptions'
@@ -28,14 +29,12 @@ import SalePriceDialog from '../components/SalePriceDialog.vue'
import HomeAnnouncementsDialog from '../components/HomeAnnouncementsDialog.vue'
import HomeBannersDialog from '../components/HomeBannersDialog.vue'
import AwRecycleDialog from '../components/AwRecycleDialog.vue'
import {
defaultAWRecycleConfig,
type AWRecycleConfig,
} from '@/features/listings/api/homeConfig'
import { defaultAWRecycleConfig, type AWRecycleConfig } from '@/features/listings/api/homeConfig'
import ListingPublishAgreementsDialog from '../components/ListingPublishAgreementsDialog.vue'
import OrderAgreementsDialog from '../components/OrderAgreementsDialog.vue'
import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue'
import GeneralConfigDialog from '../components/GeneralConfigDialog.vue'
import RenterGrowthConfigDialog from '../components/RenterGrowthConfigDialog.vue'
import AutoWelcomeConfig from '../components/AutoWelcomeConfig.vue'
import {
fetchAutoWelcomeMessage,
@@ -80,6 +79,7 @@ const listingPublishAgreementsVisible = ref(false)
const agreementsVisible = ref(false)
const postRentalNoticeVisible = ref(false)
const generalVisible = ref(false)
const renterGrowthVisible = ref(false)
const publishConfig = computed(
() => configs.value.find(item => item.key === 'listing.publish_options') || null
@@ -90,6 +90,9 @@ const salePriceConfig = computed(
const listingPublishCooldownConfig = computed(
() => configs.value.find(item => item.key === 'listing.publish_cooldown_minutes') || null
)
const renterGrowthConfig = computed(
() => configs.value.find(item => item.key === 'renter.growth_level_rules') || null
)
const homeAnnouncementsConfig = computed(
() => configs.value.find(item => item.key === 'mobile.home_announcements') || null
)
@@ -153,7 +156,8 @@ const regularConfigs = computed(() =>
item.key !== 'chat.renter_retention_days_after_order_end' &&
item.key !== 'integration.paddle_ocr_token' &&
item.key !== 'integration.paddle_ocr_job_url' &&
item.key !== 'integration.paddle_ocr_model'
item.key !== 'integration.paddle_ocr_model' &&
item.key !== 'renter.growth_level_rules'
)
)
@@ -197,6 +201,15 @@ const awRecycleStats = computed(() => {
}
})
const renterGrowthStats = computed(() => {
const config = parseRenterGrowthConfig(renterGrowthConfig.value?.value || '')
return {
enabled: config.enabled,
pointsPerYuan: config.points_per_yuan,
levels: config.levels.filter(level => level.code !== 'normal'),
}
})
const agreementStats = computed(() => {
const agreements = parseOrderAgreements(orderAgreementsConfig.value?.value || '')
return {
@@ -261,6 +274,8 @@ function openEdit(row: SystemConfig) {
agreementsVisible.value = true
} else if (row.key === 'profile.post_rental_notice') {
postRentalNoticeVisible.value = true
} else if (row.key === 'renter.growth_level_rules') {
renterGrowthVisible.value = true
} else if (row.key === 'chat.renter_retention_days_after_order_end') {
generalVisible.value = true
} else {
@@ -410,7 +425,7 @@ function formatConfigValue(row: SystemConfig) {
<div class="page-header">
<p class="eyebrow">Configs</p>
<h1>系统配置</h1>
<p>管理交接超时、归还超时、短信限流、最低押金和抽成比例。</p>
<p>管理发布、订单、成长等级、首页内容与外部集成参数。</p>
</div>
<section v-if="publishConfig" class="publish-config-panel">
@@ -514,6 +529,31 @@ function formatConfigValue(row: SystemConfig) {
</div>
</section>
<section v-if="renterGrowthConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Renter Growth</p>
<h2>租客成长等级</h2>
<span>管理完成订单后的积分换算、各等级租金支付比例与等级免押额度。</span>
</div>
<el-button type="primary" @click="openEdit(renterGrowthConfig)">编辑成长等级</el-button>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
<strong>{{ renterGrowthStats.enabled ? '已启用' : '已停用' }}</strong>
<span>成长权益</span>
</div>
<div class="publish-stat">
<strong>{{ renterGrowthStats.pointsPerYuan }}</strong>
<span>每元积分</span>
</div>
<div v-for="level in renterGrowthStats.levels" :key="level.code" class="publish-stat">
<strong>¥{{ level.deposit_free_quota_cent / 100 }}</strong>
<span>{{ level.name }}免押</span>
</div>
</div>
</section>
<section
v-if="homeAnnouncementsConfig || homeBannersConfig || awRecycleConfig"
class="publish-config-panel"
@@ -555,7 +595,9 @@ function formatConfigValue(row: SystemConfig) {
</div>
<div class="publish-stat">
<strong>更新</strong>
<span>{{ formatHomeConfigStatus(awRecycleConfig || homeBannersConfig, '未初始化') }}</span>
<span>{{
formatHomeConfigStatus(awRecycleConfig || homeBannersConfig, '未初始化')
}}</span>
</div>
</div>
</section>
@@ -836,6 +878,13 @@ function formatConfigValue(row: SystemConfig) {
@saved="loadConfigs"
/>
<RenterGrowthConfigDialog
v-if="renterGrowthConfig"
v-model="renterGrowthVisible"
:config="renterGrowthConfig"
@saved="loadConfigs"
/>
<GeneralConfigDialog
v-if="currentEditingConfig"
v-model="generalVisible"
@@ -5,6 +5,7 @@ import { Search } from '@element-plus/icons-vue'
import { computed, reactive, ref } from 'vue'
import {
adjustAdminUserGrowthPoints,
adjustAdminUserWallet,
fetchAdminUsers,
freezeAdminUser,
@@ -24,6 +25,7 @@ import AdminTablePagination from '../components/AdminTablePagination.vue'
const adminSession = useAdminSessionStore()
const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust'))
const canAdjustGrowthPoints = computed(() => adminSession.hasPermission('user:growth_points'))
const submitting = ref(false)
const activeUser = ref<AdminUserItem | null>(null)
@@ -31,9 +33,14 @@ const quotaUser = ref<AdminUserItem | null>(null)
const realnameUser = ref<AdminUserItem | null>(null)
const manualRealnameUser = ref<AdminUserItem | null>(null)
const walletUser = ref<AdminUserItem | null>(null)
const growthUser = ref<AdminUserItem | null>(null)
const freezeReason = ref('')
const revokeRealnameReason = ref('')
const quotaAmount = ref(0)
const growthForm = reactive({
targetPoints: 0,
reason: '',
})
const manualRealnameForm = reactive({
name: '',
idNo: '',
@@ -85,6 +92,12 @@ function openDepositQuota(row: AdminUserItem) {
quotaAmount.value = centToYuan(row.deposit_free_quota_cent)
}
function openGrowthPoints(row: AdminUserItem) {
growthUser.value = row
growthForm.targetPoints = Number(row.renter_growth_points || 0)
growthForm.reason = ''
}
function openWalletAdjust(row: AdminUserItem) {
walletUser.value = row
walletForm.direction = 'out'
@@ -98,7 +111,7 @@ async function handleSetDepositQuota() {
submitting.value = true
try {
await setAdminUserDepositFreeQuota(quotaUser.value.id, yuanToCent(quotaAmount.value))
ElMessage.success('免押额度已更新')
ElMessage.success('人工免押额度已更新')
quotaUser.value = null
await loadUsers()
} catch (error) {
@@ -108,6 +121,51 @@ async function handleSetDepositQuota() {
}
}
async function handleAdjustGrowthPoints() {
if (!growthUser.value) return
const targetPoints = Number(growthForm.targetPoints)
if (!Number.isInteger(targetPoints) || targetPoints < 0 || targetPoints > 1000000000) {
ElMessage.warning('目标积分必须是 0 到 10 亿之间的整数')
return
}
if (targetPoints === Number(growthUser.value.renter_growth_points || 0)) {
ElMessage.warning('目标积分与当前积分相同')
return
}
if (growthForm.reason.trim().length < 2) {
ElMessage.warning('请填写至少 2 个字的调整原因')
return
}
const direction = targetPoints > growthUser.value.renter_growth_points ? '提升' : '降低'
try {
await ElMessageBox.confirm(
`确认将用户 ${growthUser.value.phone || growthUser.value.id} 的成长积分从 ${growthUser.value.renter_growth_points} ${direction}至 ${targetPoints}?`,
'确认调整积分',
{
type: 'warning',
confirmButtonText: '确认调整',
cancelButtonText: '取消',
}
)
} catch {
return
}
submitting.value = true
try {
await adjustAdminUserGrowthPoints(growthUser.value.id, {
target_points: targetPoints,
reason: growthForm.reason.trim(),
})
ElMessage.success('成长积分已更新,等级已自动重新计算')
growthUser.value = null
await loadUsers()
} catch (error) {
ElMessage.error(readError(error, '调整成长积分失败'))
} finally {
submitting.value = false
}
}
async function handleFreeze() {
if (!activeUser.value) return
submitting.value = true
@@ -258,7 +316,7 @@ function moneyCent(value: number | string | undefined) {
<div class="page-header">
<p class="eyebrow">Users</p>
<h1>用户管理</h1>
<p>查看用户信息与钱包余额,处理冻结、实名与余额调账。</p>
<p>查看用户信息,处理成长积分、人工免押、实名与余额调账。</p>
</div>
<el-button @click="loadUsers">刷新</el-button>
</div>
@@ -314,7 +372,9 @@ function moneyCent(value: number | string | undefined) {
</el-table-column>
<el-table-column label="成长等级" width="100">
<template #default="{ row }">
<el-tag type="success" effect="light">{{ row.renter_growth_level_name || '普通' }}</el-tag>
<el-tag type="success" effect="light">{{
row.renter_growth_level_name || '普通'
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="成长积分" width="100" align="right">
@@ -335,8 +395,16 @@ function moneyCent(value: number | string | undefined) {
</span>
</template>
</el-table-column>
<el-table-column label="免押额度" width="100" align="right">
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_quota_cent) }}</template>
<el-table-column label="免押总额" width="150" align="right">
<template #default="{ row }">
<div class="quota-cell">
<strong>¥{{ moneyCent(row.deposit_free_effective_quota_cent) }}</strong>
<span>
等级 {{ moneyCent(row.deposit_free_level_quota_cent) }} + 人工
{{ moneyCent(row.deposit_free_manual_quota_cent) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="剩余免押" width="100" align="right">
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_remaining_cent) }}</template>
@@ -344,7 +412,7 @@ function moneyCent(value: number | string | undefined) {
<el-table-column label="注册时间" min-width="160">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right" align="right">
<el-table-column label="操作" width="360" fixed="right" align="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
@@ -356,8 +424,17 @@ function moneyCent(value: number | string | undefined) {
>
余额
</el-button>
<el-button
v-if="canAdjustGrowthPoints"
text
type="primary"
size="small"
@click="openGrowthPoints(row)"
>
积分
</el-button>
<el-button text type="primary" size="small" @click="openDepositQuota(row)">
免押额度
人工免押
</el-button>
<el-button
v-if="row.realname_status !== 'verified'"
@@ -513,7 +590,7 @@ function moneyCent(value: number | string | undefined) {
<el-dialog
:model-value="!!quotaUser"
title="设置免押额度"
title="设置人工免押额度"
width="520px"
@update:model-value="quotaUser = null"
>
@@ -522,10 +599,15 @@ function moneyCent(value: number | string | undefined) {
<strong>{{ quotaUser.phone }}</strong> · {{ quotaUser.nickname }}
</p>
<p>
已占用 ¥{{ moneyCent(quotaUser.deposit_free_used_cent) }},剩余 ¥{{
moneyCent(quotaUser.deposit_free_remaining_cent)
等级额度 ¥{{ moneyCent(quotaUser.deposit_free_level_quota_cent) }},人工额度 ¥{{
moneyCent(quotaUser.deposit_free_manual_quota_cent)
}}
</p>
<div class="quota-summary">
<span>有效总额 ¥{{ moneyCent(quotaUser.deposit_free_effective_quota_cent) }}</span>
<span>已占用 ¥{{ moneyCent(quotaUser.deposit_free_used_cent) }}</span>
<strong>剩余 ¥{{ moneyCent(quotaUser.deposit_free_remaining_cent) }}</strong>
</div>
<el-input-number
v-model="quotaAmount"
class="full-control"
@@ -542,6 +624,52 @@ function moneyCent(value: number | string | undefined) {
</template>
</el-dialog>
<el-dialog
:model-value="!!growthUser"
title="调整成长积分"
width="540px"
@update:model-value="growthUser = null"
>
<div v-if="growthUser" class="dialog-body growth-dialog">
<p>
<strong>{{ growthUser.phone }}</strong> · {{ growthUser.nickname || '-' }} · 当前
{{ growthUser.renter_growth_level_name }}
</p>
<div class="growth-current">
<span>当前积分</span>
<strong>{{ growthUser.renter_growth_points }}</strong>
</div>
<el-form label-position="top" @submit.prevent>
<el-form-item label="目标积分">
<el-input-number
v-model="growthForm.targetPoints"
class="full-control"
:min="0"
:max="1000000000"
:step="100"
step-strictly
/>
</el-form-item>
<el-form-item label="调整原因(必填)">
<el-input
v-model="growthForm.reason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="例如:运营活动赠送或修正误发积分"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button :disabled="submitting" @click="growthUser = null">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleAdjustGrowthPoints">
确认调整
</el-button>
</template>
</el-dialog>
<el-dialog
:model-value="!!walletUser"
title="余额管理"
@@ -655,6 +783,22 @@ function moneyCent(value: number | string | undefined) {
color: #c2410c;
}
.quota-cell {
display: grid;
gap: 2px;
}
.quota-cell strong {
color: #0f172a;
font-variant-numeric: tabular-nums;
}
.quota-cell span {
color: #64748b;
font-size: 11px;
line-height: 1.3;
}
.table-actions {
display: inline-flex;
flex-wrap: nowrap;
@@ -688,6 +832,43 @@ function moneyCent(value: number | string | undefined) {
gap: 14px;
}
.quota-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
color: #64748b;
font-size: 13px;
}
.quota-summary strong {
color: #0f172a;
}
.growth-dialog {
gap: 16px;
}
.growth-current {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
color: #64748b;
}
.growth-current strong {
color: #0f172a;
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.wallet-balance-cards {
display: grid;
grid-template-columns: 1fr 1fr;