完善租客等级免押与积分管理
This commit is contained in:
@@ -11,6 +11,9 @@ export interface AdminUserItem {
|
||||
risk_status: RiskStatus
|
||||
credit_score: number
|
||||
deposit_free_quota_cent: number
|
||||
deposit_free_manual_quota_cent: number
|
||||
deposit_free_level_quota_cent: number
|
||||
deposit_free_effective_quota_cent: number
|
||||
deposit_free_used_cent: number
|
||||
deposit_free_remaining_cent: number
|
||||
renter_growth_points: number
|
||||
@@ -34,6 +37,11 @@ export interface AdminWalletAdjustPayload {
|
||||
reference_no?: string
|
||||
}
|
||||
|
||||
export interface AdminGrowthPointsAdjustPayload {
|
||||
target_points: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface AdminUserQuery {
|
||||
keyword?: string
|
||||
status?: '' | UserStatus
|
||||
@@ -74,6 +82,17 @@ export async function setAdminUserDepositFreeQuota(id: number, amountCent: numbe
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function adjustAdminUserGrowthPoints(
|
||||
id: number,
|
||||
payload: AdminGrowthPointsAdjustPayload
|
||||
) {
|
||||
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
|
||||
`/admin/users/${id}/growth-points`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function revokeAdminUserRealname(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
|
||||
`/admin/users/${id}/revoke-realname`,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
export interface RenterGrowthLevelRule {
|
||||
code: 'normal' | 'platinum' | 'diamond' | 'peak'
|
||||
name: string
|
||||
min_points: number
|
||||
discount_bps: number
|
||||
deposit_free_quota_cent: number
|
||||
}
|
||||
|
||||
export interface RenterGrowthConfig {
|
||||
enabled: boolean
|
||||
points_per_yuan: number
|
||||
levels: RenterGrowthLevelRule[]
|
||||
}
|
||||
|
||||
export const defaultRenterGrowthConfig: RenterGrowthConfig = {
|
||||
enabled: true,
|
||||
points_per_yuan: 1,
|
||||
levels: [
|
||||
{
|
||||
code: 'normal',
|
||||
name: '普通',
|
||||
min_points: 0,
|
||||
discount_bps: 10000,
|
||||
deposit_free_quota_cent: 0,
|
||||
},
|
||||
{
|
||||
code: 'platinum',
|
||||
name: '铂金',
|
||||
min_points: 300,
|
||||
discount_bps: 9900,
|
||||
deposit_free_quota_cent: 50000,
|
||||
},
|
||||
{
|
||||
code: 'diamond',
|
||||
name: '钻石',
|
||||
min_points: 1000,
|
||||
discount_bps: 9800,
|
||||
deposit_free_quota_cent: 100000,
|
||||
},
|
||||
{
|
||||
code: 'peak',
|
||||
name: '巅峰',
|
||||
min_points: 5000,
|
||||
discount_bps: 9500,
|
||||
deposit_free_quota_cent: 150000,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function parseRenterGrowthConfig(raw: string): RenterGrowthConfig {
|
||||
const parsed = parseRawConfig(raw)
|
||||
const incomingLevels = Array.isArray(parsed.levels) ? parsed.levels : []
|
||||
return {
|
||||
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : true,
|
||||
points_per_yuan: positiveInteger(parsed.points_per_yuan, 1),
|
||||
levels: defaultRenterGrowthConfig.levels.map(defaultLevel => {
|
||||
const incoming = incomingLevels.find(level => level?.code === defaultLevel.code)
|
||||
return {
|
||||
code: defaultLevel.code,
|
||||
name: defaultLevel.name,
|
||||
min_points: nonNegativeInteger(incoming?.min_points, defaultLevel.min_points),
|
||||
discount_bps: positiveInteger(incoming?.discount_bps, defaultLevel.discount_bps),
|
||||
deposit_free_quota_cent: nonNegativeInteger(
|
||||
incoming?.deposit_free_quota_cent,
|
||||
defaultLevel.deposit_free_quota_cent
|
||||
),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function parseRawConfig(raw: string): Partial<RenterGrowthConfig> {
|
||||
try {
|
||||
return JSON.parse(raw) as Partial<RenterGrowthConfig>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneRenterGrowthConfig(config: RenterGrowthConfig): RenterGrowthConfig {
|
||||
return JSON.parse(JSON.stringify(config)) as RenterGrowthConfig
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, fallback: number) {
|
||||
const number = Number(value)
|
||||
return Number.isInteger(number) && number > 0 ? number : fallback
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, fallback: number) {
|
||||
const number = Number(value)
|
||||
return Number.isInteger(number) && number >= 0 ? number : fallback
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { updateSystemConfig, type SystemConfig } from '@/features/admin/api/systemConfigs'
|
||||
import {
|
||||
cloneRenterGrowthConfig,
|
||||
defaultRenterGrowthConfig,
|
||||
parseRenterGrowthConfig,
|
||||
type RenterGrowthConfig,
|
||||
type RenterGrowthLevelRule,
|
||||
} from '@/features/admin/api/renterGrowth'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
config: SystemConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'saved'): void
|
||||
}>()
|
||||
|
||||
const submitting = ref(false)
|
||||
const draft = ref<RenterGrowthConfig>(cloneRenterGrowthConfig(defaultRenterGrowthConfig))
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.config.value] as const,
|
||||
([visible]) => {
|
||||
if (visible) draft.value = parseRenterGrowthConfig(props.config.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function discountPercent(level: RenterGrowthLevelRule) {
|
||||
return level.discount_bps / 100
|
||||
}
|
||||
|
||||
function updateDiscountPercent(level: RenterGrowthLevelRule, value: number | undefined) {
|
||||
level.discount_bps = Math.round(Number(value || 0) * 100)
|
||||
}
|
||||
|
||||
function quotaYuan(level: RenterGrowthLevelRule) {
|
||||
return level.deposit_free_quota_cent / 100
|
||||
}
|
||||
|
||||
function updateQuotaYuan(level: RenterGrowthLevelRule, value: number | undefined) {
|
||||
level.deposit_free_quota_cent = Math.round(Number(value || 0) * 100)
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
draft.value = cloneRenterGrowthConfig(defaultRenterGrowthConfig)
|
||||
}
|
||||
|
||||
function validateDraft() {
|
||||
if (!Number.isInteger(draft.value.points_per_yuan) || draft.value.points_per_yuan <= 0) {
|
||||
return '每元积分必须是大于 0 的整数'
|
||||
}
|
||||
for (const [index, level] of draft.value.levels.entries()) {
|
||||
const previous = draft.value.levels[index - 1]
|
||||
if (!Number.isInteger(level.min_points) || level.min_points < 0) {
|
||||
return `${level.name}积分门槛不正确`
|
||||
}
|
||||
if (index === 0 && level.min_points !== 0) return '普通等级积分门槛必须为 0'
|
||||
if (previous && level.min_points <= previous.min_points) return '等级积分门槛必须严格递增'
|
||||
if (level.discount_bps <= 0 || level.discount_bps > 10000) {
|
||||
return `${level.name}支付比例必须在 0% 到 100% 之间`
|
||||
}
|
||||
if (previous && level.discount_bps > previous.discount_bps)
|
||||
return '高等级支付比例不能高于低等级'
|
||||
if (!Number.isInteger(level.deposit_free_quota_cent) || level.deposit_free_quota_cent < 0) {
|
||||
return `${level.name}免押额度不正确`
|
||||
}
|
||||
if (previous && level.deposit_free_quota_cent < previous.deposit_free_quota_cent) {
|
||||
return '高等级免押额度不能低于低等级'
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const validationMessage = validateDraft()
|
||||
if (validationMessage) {
|
||||
ElMessage.warning(validationMessage)
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateSystemConfig(props.config.key, {
|
||||
value: JSON.stringify(draft.value),
|
||||
description: '租客成长等级规则(积分门槛、租金折扣、等级免押额度)',
|
||||
})
|
||||
ElMessage.success('成长等级配置已更新')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '成长等级配置保存失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
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>
|
||||
<el-switch v-model="draft.enabled" />
|
||||
</div>
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="每完成 1 元实付租金获得积分">
|
||||
<el-input-number
|
||||
v-model="draft.points_per_yuan"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
step-strictly
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="draft.levels" border>
|
||||
<el-table-column prop="name" label="等级" width="100" />
|
||||
<el-table-column label="积分门槛" min-width="170">
|
||||
<template #default="{ row, $index }">
|
||||
<el-input-number
|
||||
v-model="row.min_points"
|
||||
:disabled="$index === 0"
|
||||
:min="0"
|
||||
:max="1000000000"
|
||||
:step="100"
|
||||
step-strictly
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="租金支付比例" min-width="190">
|
||||
<template #default="{ row, $index }">
|
||||
<el-input-number
|
||||
:model-value="discountPercent(row)"
|
||||
:disabled="$index === 0"
|
||||
:min="0.01"
|
||||
:max="100"
|
||||
:precision="2"
|
||||
:step="0.5"
|
||||
@update:model-value="updateDiscountPercent(row, $event)"
|
||||
/>
|
||||
<span class="unit">%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级免押额度" min-width="190">
|
||||
<template #default="{ row, $index }">
|
||||
<el-input-number
|
||||
:model-value="quotaYuan(row)"
|
||||
:disabled="$index === 0"
|
||||
:min="0"
|
||||
:max="1000000"
|
||||
:precision="0"
|
||||
:step="100"
|
||||
@update:model-value="updateQuotaYuan(row, $event)"
|
||||
/>
|
||||
<span class="unit">元</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button :disabled="submitting" @click="resetDefaults">恢复默认</el-button>
|
||||
<el-button :disabled="submitting" @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存配置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.growth-config-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.growth-switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 40px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.growth-config-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.growth-config-form :deep(.el-input-number) {
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.unit {
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
margin-left: 4px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.growth-config-form :deep(.el-table) {
|
||||
min-width: 760px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user