feat(quality-owner): 增加优质号主、极品专区与无忧租号保险
- 支持保证金余额支付、在线支付开通及后台手动设置审计 - 新增极品专区发布权限、赔付方案与保险结算费率快照 - 优化首页、商品卡和个人中心的优质号主身份展示
This commit is contained in:
@@ -7,6 +7,7 @@ export interface AdminPayment {
|
||||
payment_no: string
|
||||
order_id: number
|
||||
order_no: string
|
||||
biz_id?: number
|
||||
user_id: number
|
||||
user_phone: string
|
||||
provider: string
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface AdminUserItem {
|
||||
renter_growth_level_name: string
|
||||
available_balance_cent: number
|
||||
frozen_balance_cent: number
|
||||
quality_owner_status: 'none' | 'active' | string
|
||||
quality_owner_deposit_cent: number
|
||||
quality_owner_deposit_source: 'paid' | 'manual' | '' | string
|
||||
quality_owner_activated_at?: string
|
||||
status: UserStatus
|
||||
order_count: number
|
||||
listing_count: number
|
||||
@@ -46,6 +50,7 @@ export interface AdminUserQuery {
|
||||
keyword?: string
|
||||
status?: '' | UserStatus
|
||||
has_deposit_free_quota?: boolean
|
||||
quality_owner_only?: boolean
|
||||
}
|
||||
|
||||
export async function fetchAdminUsers(page = 1, pageSize = 20, query: AdminUserQuery = {}) {
|
||||
@@ -53,6 +58,7 @@ export async function fetchAdminUsers(page = 1, pageSize = 20, query: AdminUserQ
|
||||
if (query.keyword) params.keyword = query.keyword
|
||||
if (query.status) params.status = query.status
|
||||
if (query.has_deposit_free_quota) params.has_deposit_free_quota = true
|
||||
if (query.quality_owner_only) params.quality_owner_only = true
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>(
|
||||
'/admin/users',
|
||||
{
|
||||
@@ -128,3 +134,14 @@ export async function adjustAdminUserWallet(id: number, payload: AdminWalletAdju
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function setAdminUserQualityOwner(
|
||||
id: number,
|
||||
payload: { enabled: boolean; reason: string }
|
||||
) {
|
||||
const { data } = await apiClient.post<ApiResponse<unknown>>(
|
||||
`/admin/users/${id}/quality-owner`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -408,6 +408,7 @@ function paymentBizTypeLabel(type: string) {
|
||||
rent_refund: '租金退款',
|
||||
arbitration_refund: '仲裁退款',
|
||||
mohong_pay: '撞车支付',
|
||||
quality_owner_pay: '优质号主保证金',
|
||||
mohong_refund: '撞车退款',
|
||||
}
|
||||
return labels[type] || type || '-'
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
import { updateSystemConfig, type SystemConfig } from '@/features/admin/api/systemConfigs'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
config: SystemConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
depositYuan: 200,
|
||||
insurancePercent: 8,
|
||||
premiumInsurancePercent: 4,
|
||||
description: '',
|
||||
saving: false,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
visible => {
|
||||
if (!visible) return
|
||||
try {
|
||||
const parsed = JSON.parse(props.config.value || '{}') as Record<string, unknown>
|
||||
form.depositYuan = Number(parsed.deposit_amount_cent || 20000) / 100
|
||||
form.insurancePercent = Number(parsed.insurance_fee_bps || 800) / 100
|
||||
form.premiumInsurancePercent = Number(parsed.premium_insurance_fee_bps || 400) / 100
|
||||
} catch {
|
||||
form.depositYuan = 200
|
||||
form.insurancePercent = 8
|
||||
form.premiumInsurancePercent = 4
|
||||
}
|
||||
form.description = props.config.description || ''
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function save() {
|
||||
if (!(form.depositYuan > 0)) {
|
||||
ElMessage.warning('保证金必须大于 0')
|
||||
return
|
||||
}
|
||||
if (
|
||||
form.insurancePercent < 0 ||
|
||||
form.premiumInsurancePercent < 0 ||
|
||||
form.premiumInsurancePercent > form.insurancePercent
|
||||
) {
|
||||
ElMessage.warning('极品专区费率不能高于普通保险费率')
|
||||
return
|
||||
}
|
||||
form.saving = true
|
||||
try {
|
||||
await updateSystemConfig(props.config.key, {
|
||||
value: JSON.stringify({
|
||||
deposit_amount_cent: Math.round(form.depositYuan * 100),
|
||||
insurance_fee_bps: Math.round(form.insurancePercent * 100),
|
||||
premium_insurance_fee_bps: Math.round(form.premiumInsurancePercent * 100),
|
||||
}),
|
||||
description: form.description,
|
||||
})
|
||||
ElMessage.success('优质号主与保险费率配置已更新')
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '保存配置失败'))
|
||||
} finally {
|
||||
form.saving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="优质号主与无忧租号保险"
|
||||
width="560px"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="优质号主保证金(元)">
|
||||
<el-input-number v-model="form.depositYuan" :min="0.01" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="普通无忧租号保险服务费(%)">
|
||||
<el-input-number
|
||||
v-model="form.insurancePercent"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:precision="2"
|
||||
:step="0.5"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="优质号主 + 极品专区服务费(%)">
|
||||
<el-input-number
|
||||
v-model="form.premiumInsurancePercent"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:precision="2"
|
||||
:step="0.5"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="配置说明">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="费率按号主实际到手租金计算,并在订单结算时扣减;历史订单保留下单时费率快照。"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="form.saving" @click="save">保存配置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -42,6 +42,7 @@ export function paymentBizTypeLabel(type: string) {
|
||||
admin_close_refund: '客服关闭退款',
|
||||
admin_seal_refund: '封存退款',
|
||||
deposit_refund: '暂扣押金归还',
|
||||
quality_owner_pay: '优质号主保证金',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
@@ -648,6 +648,7 @@ function paymentBizTypeLabel(type: string) {
|
||||
admin_refund: '人工退款',
|
||||
cancel_refund: '取消退款',
|
||||
admin_close_refund: '客服关闭退款',
|
||||
quality_owner_pay: '优质号主保证金',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
@@ -990,6 +990,20 @@ function formatQuantity(value: number) {
|
||||
<strong class="listing-code">{{ formatListingCode(row) }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="专区" width="72">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_premium" type="warning" size="small">极品</el-tag>
|
||||
<span v-else>普通</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="赔付方案" width="102">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.compensation_plan === 'insurance'">
|
||||
无忧保险 {{ (Number(row.insurance_fee_bps || 0) / 100).toFixed(1) }}%
|
||||
</span>
|
||||
<span v-else>普通赔付</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="地区" width="86">
|
||||
<template #default="{ row }">{{ regionText(row) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -37,7 +37,12 @@ const filters = reactive({
|
||||
|
||||
const payAmount = computed(() =>
|
||||
payments.value
|
||||
.filter(item => item.biz_type === 'order_pay' || item.biz_type === 'mohong_pay')
|
||||
.filter(
|
||||
item =>
|
||||
item.biz_type === 'order_pay' ||
|
||||
item.biz_type === 'mohong_pay' ||
|
||||
item.biz_type === 'quality_owner_pay'
|
||||
)
|
||||
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
|
||||
)
|
||||
const refundAmount = computed(() =>
|
||||
@@ -123,6 +128,7 @@ function bizTypeLabel(type: string) {
|
||||
rent_refund: '租金退款',
|
||||
arbitration_refund: '仲裁退款',
|
||||
mohong_pay: '撞车支付',
|
||||
quality_owner_pay: '优质号主保证金',
|
||||
mohong_refund: '撞车退款',
|
||||
}
|
||||
return map[type] || type
|
||||
@@ -225,6 +231,7 @@ function jsonText(value: unknown) {
|
||||
>
|
||||
<el-option label="订单支付" value="order_pay" />
|
||||
<el-option label="撞车支付" value="mohong_pay" />
|
||||
<el-option label="优质号主保证金" value="quality_owner_pay" />
|
||||
<el-option label="结账退款" value="checkout_refund" />
|
||||
<el-option label="仲裁退款" value="arbitration_refund" />
|
||||
<el-option label="人工退款" value="admin_refund" />
|
||||
|
||||
@@ -51,6 +51,7 @@ import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue'
|
||||
import GeneralConfigDialog from '../components/GeneralConfigDialog.vue'
|
||||
import RenterGrowthConfigDialog from '../components/RenterGrowthConfigDialog.vue'
|
||||
import SmsNodeTemplatesDialog from '../components/SmsNodeTemplatesDialog.vue'
|
||||
import QualityOwnerConfigDialog from '../components/QualityOwnerConfigDialog.vue'
|
||||
import AutoWelcomeConfig from '../components/AutoWelcomeConfig.vue'
|
||||
import {
|
||||
fetchAutoWelcomeMessage,
|
||||
@@ -88,6 +89,7 @@ const currentEditingConfig = ref<SystemConfig | null>(null)
|
||||
// 各种子 Dialog 的可见性控制
|
||||
const publishVisible = ref(false)
|
||||
const salePriceVisible = ref(false)
|
||||
const qualityOwnerVisible = ref(false)
|
||||
const announcementsVisible = ref(false)
|
||||
const bannersVisible = ref(false)
|
||||
const awRecycleVisible = ref(false)
|
||||
@@ -105,6 +107,9 @@ const publishConfig = computed(
|
||||
const salePriceConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'listing.sale_price_config') || null
|
||||
)
|
||||
const qualityOwnerConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'quality_owner.rules') || null
|
||||
)
|
||||
const listingPublishCooldownConfig = computed(
|
||||
() => configs.value.find(item => item.key === 'listing.publish_cooldown_minutes') || null
|
||||
)
|
||||
@@ -176,6 +181,7 @@ const regularConfigs = computed(() =>
|
||||
item =>
|
||||
item.key !== 'listing.publish_options' &&
|
||||
item.key !== 'listing.sale_price_config' &&
|
||||
item.key !== 'quality_owner.rules' &&
|
||||
item.key !== 'listing.publish_cooldown_minutes' &&
|
||||
item.key !== 'mobile.home_announcements' &&
|
||||
item.key !== 'mobile.home_banners' &&
|
||||
@@ -220,6 +226,19 @@ const salePriceStats = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const qualityOwnerStats = computed(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(qualityOwnerConfig.value?.value || '{}')
|
||||
return {
|
||||
deposit: Number(parsed.deposit_amount_cent || 20000) / 100,
|
||||
insurance: Number(parsed.insurance_fee_bps || 800) / 100,
|
||||
premium: Number(parsed.premium_insurance_fee_bps || 400) / 100,
|
||||
}
|
||||
} catch {
|
||||
return { deposit: 200, insurance: 8, premium: 4 }
|
||||
}
|
||||
})
|
||||
|
||||
const homeStats = computed(() => {
|
||||
const announcements = parseHomeAnnouncements(homeAnnouncementsConfig.value?.value || '')
|
||||
const banners = parseHomeBanners(homeBannersConfig.value?.value || '')
|
||||
@@ -343,6 +362,8 @@ function openEdit(row: SystemConfig) {
|
||||
publishVisible.value = true
|
||||
} else if (row.key === 'listing.sale_price_config') {
|
||||
salePriceVisible.value = true
|
||||
} else if (row.key === 'quality_owner.rules') {
|
||||
qualityOwnerVisible.value = true
|
||||
} else if (row.key === 'mobile.home_announcements') {
|
||||
announcementsVisible.value = true
|
||||
} else if (row.key === 'mobile.home_banners') {
|
||||
@@ -633,6 +654,43 @@ function formatConfigValue(row: SystemConfig) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="qualityOwnerConfig" class="publish-config-panel">
|
||||
<div class="publish-config-main">
|
||||
<div>
|
||||
<p class="eyebrow">Premium Owner</p>
|
||||
<h2>优质号主与无忧租号保险</h2>
|
||||
<span>
|
||||
配置优质号主保证金,以及普通发布和「优质号主 + 极品专区」的无忧租号保险服务费率。
|
||||
</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openEdit(qualityOwnerConfig)">
|
||||
编辑保证金与费率
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="publish-stat-grid home-stat-grid">
|
||||
<div class="publish-stat">
|
||||
<strong>¥{{ qualityOwnerStats.deposit }}</strong>
|
||||
<span>优质号主保证金</span>
|
||||
</div>
|
||||
<div class="publish-stat">
|
||||
<strong>{{ qualityOwnerStats.insurance }}%</strong>
|
||||
<span>普通保险费率</span>
|
||||
</div>
|
||||
<div class="publish-stat">
|
||||
<strong>{{ qualityOwnerStats.premium }}%</strong>
|
||||
<span>极品专区专享费率</span>
|
||||
</div>
|
||||
<div class="publish-stat">
|
||||
<strong>订单快照</strong>
|
||||
<span>历史订单不受调价影响</span>
|
||||
</div>
|
||||
<div class="publish-stat">
|
||||
<strong>更新</strong>
|
||||
<span>{{ formatHomeConfigStatus(qualityOwnerConfig, '未初始化') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="renterGrowthConfig" class="publish-config-panel">
|
||||
<div class="publish-config-main">
|
||||
<div>
|
||||
@@ -1009,6 +1067,13 @@ function formatConfigValue(row: SystemConfig) {
|
||||
@saved="loadConfigs"
|
||||
/>
|
||||
|
||||
<QualityOwnerConfigDialog
|
||||
v-if="qualityOwnerConfig"
|
||||
v-model="qualityOwnerVisible"
|
||||
:config="qualityOwnerConfig"
|
||||
@saved="loadConfigs"
|
||||
/>
|
||||
|
||||
<HomeAnnouncementsDialog
|
||||
v-if="homeAnnouncementsConfig"
|
||||
v-model="announcementsVisible"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
manualAdminUserRealname,
|
||||
revokeAdminUserRealname,
|
||||
setAdminUserDepositFreeQuota,
|
||||
setAdminUserQualityOwner,
|
||||
unfreezeAdminUser,
|
||||
type AdminUserItem,
|
||||
type AdminUserQuery,
|
||||
@@ -27,6 +28,7 @@ import AdminUserDetailDrawer from '../components/AdminUserDetailDrawer.vue'
|
||||
const adminSession = useAdminSessionStore()
|
||||
const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust'))
|
||||
const canAdjustGrowthPoints = computed(() => adminSession.hasPermission('user:growth_points'))
|
||||
const canSetQualityOwner = computed(() => adminSession.hasPermission('user:quality_owner'))
|
||||
const canViewUserDetail = computed(
|
||||
() =>
|
||||
adminSession.hasPermission('wallet:view') ||
|
||||
@@ -65,6 +67,7 @@ const filters = reactive<AdminUserQuery>({
|
||||
keyword: '',
|
||||
status: '',
|
||||
has_deposit_free_quota: false,
|
||||
quality_owner_only: false,
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -87,6 +90,7 @@ function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.status = ''
|
||||
filters.has_deposit_free_quota = false
|
||||
filters.quality_owner_only = false
|
||||
queryUsers()
|
||||
}
|
||||
|
||||
@@ -317,6 +321,40 @@ async function handleWalletAdjust() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleQualityOwner(row: AdminUserItem) {
|
||||
const enabling = row.quality_owner_status !== 'active'
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
enabling
|
||||
? '后台手动开通将直接发放优质号主资格并跳过保证金缴纳,请填写原因。'
|
||||
: '取消后用户将无法继续发布到极品专区,已发布的极品商品会下架;用户自缴保证金将退回可用余额。',
|
||||
enabling ? '设置优质号主' : '取消优质号主',
|
||||
{
|
||||
inputPlaceholder: '请填写 2-200 字操作原因',
|
||||
inputValidator: value => {
|
||||
const length = value.trim().length
|
||||
return (length >= 2 && length <= 200) || '请填写 2-200 字操作原因'
|
||||
},
|
||||
confirmButtonText: enabling ? '确认开通' : '确认取消',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
submitting.value = true
|
||||
await setAdminUserQualityOwner(row.id, {
|
||||
enabled: enabling,
|
||||
reason: value.trim(),
|
||||
})
|
||||
ElMessage.success(enabling ? '已设置为优质号主' : '已取消优质号主')
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(readError(error, '设置优质号主失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function moneyCent(value: number | string | undefined) {
|
||||
return formatCent(Number(value || 0))
|
||||
}
|
||||
@@ -363,6 +401,11 @@ function moneyCent(value: number | string | undefined) {
|
||||
仅看有额度
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="号主类型">
|
||||
<el-checkbox v-model="filters.quality_owner_only" @change="queryUsers">
|
||||
仅看优质号主
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="queryUsers"
|
||||
>查询</el-button
|
||||
@@ -382,6 +425,14 @@ function moneyCent(value: number | string | undefined) {
|
||||
<el-table-column label="实名状态" width="100">
|
||||
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="号主类型" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.quality_owner_status === 'active'" type="warning" effect="light">
|
||||
优质号主
|
||||
</el-tag>
|
||||
<span v-else class="muted-text">普通</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成长等级" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" effect="light">{{
|
||||
@@ -424,7 +475,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="400" fixed="right" align="right">
|
||||
<el-table-column label="操作" width="470" fixed="right" align="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button
|
||||
@@ -457,6 +508,16 @@ function moneyCent(value: number | string | undefined) {
|
||||
<el-button text type="primary" size="small" @click="openDepositQuota(row)">
|
||||
人工免押
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canSetQualityOwner"
|
||||
text
|
||||
type="warning"
|
||||
size="small"
|
||||
:loading="submitting"
|
||||
@click="handleToggleQualityOwner(row)"
|
||||
>
|
||||
{{ row.quality_owner_status === 'active' ? '取消优质' : '设为优质' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.realname_status !== 'verified'"
|
||||
text
|
||||
|
||||
Reference in New Issue
Block a user