feat(quality-owner): 增加优质号主、极品专区与无忧租号保险

- 支持保证金余额支付、在线支付开通及后台手动设置审计
- 新增极品专区发布权限、赔付方案与保险结算费率快照
- 优化首页、商品卡和个人中心的优质号主身份展示
This commit is contained in:
yml2213
2026-11-04 15:04:35 +08:00
parent 812e95e7d1
commit 98174c0e33
90 changed files with 3478 additions and 64 deletions
@@ -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
+1
View File
@@ -13,6 +13,7 @@ export interface AuthUser {
credit_score: number
renter_growth_points?: number
renter_growth_level?: string
quality_owner_status?: string
status: UserStatus
}
@@ -96,6 +96,16 @@
border: none;
}
.owner-quality-tag {
height: 18px;
padding: 0 7px;
border: 1px solid #d8b4fe;
background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%) !important;
color: #fff !important;
font-size: 10px !important;
font-weight: 800;
}
.user-meta {
margin: 4px 0 0;
color: #6b7280;
@@ -664,6 +674,76 @@
font-weight: 800;
}
.quality-owner-card {
display: grid;
gap: 12px;
margin: 10px 12px;
padding: 16px;
border: 1px solid #e9e2fb;
border-radius: 14px;
background: linear-gradient(135deg, #faf8ff 0%, #ffffff 72%);
}
.quality-owner-card.is-active {
border-color: #c4b5fd;
}
.quality-owner-heading,
.quality-owner-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.quality-owner-heading > div,
.quality-owner-info > div {
display: grid;
gap: 3px;
}
.quality-owner-heading span,
.quality-owner-info span {
color: #7b8492;
font-size: 11px;
font-weight: 700;
}
.quality-owner-heading strong {
color: #312e81;
font-size: 15px;
font-weight: 900;
}
.quality-owner-info {
gap: 8px;
}
.quality-owner-info > div {
flex: 1;
padding: 10px;
border-radius: 8px;
background: rgba(245, 243, 255, 0.85);
}
.quality-owner-info strong {
color: #6d28d9;
font-size: 14px;
font-weight: 900;
}
.quality-owner-actions {
display: grid;
gap: 8px;
}
.quality-owner-card p {
margin: 0;
color: #7b8492;
font-size: 11px;
line-height: 1.5;
}
/* ========== 响应式适配 ========== */
@media (min-width: 520px) {
.mobile-profile-shell {
@@ -18,6 +18,18 @@ import { uploadFile } from '@/shared/api/files'
import { formatCent } from '@/shared/utils/money'
import { setPassword } from '@/features/auth/api/auth'
import { fetchUserInvitationSummary } from '@/features/invitation/api/invitation'
import {
activateQualityOwner,
fetchQualityOwnerStatus,
queryQualityOwnerPayment,
startQualityOwnerPayment,
type QualityOwnerStatus,
} from '@/features/quality-owner/api/qualityOwner'
import type { PaymentPayWay } from '@/features/orders/api/orders'
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
import { useDistributionSiteStore } from '@/stores/distributionSite'
@@ -33,6 +45,22 @@ const { unreadCount: notificationUnreadCount, unreadLabel: notificationUnreadLab
/** 数据项 */
const availableBalanceCent = ref(0)
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
const qualityOwnerLoading = ref(false)
const qualityOwnerOnlineLoading = ref(false)
const qualityOwnerPayWaySelect = useMobilePayWaySelect()
const qualityOwnerCashier = useMobilePaymentCashier({
queryPayment: () => queryQualityOwnerPayment(),
paidMessage: '保证金支付成功,优质号主已开通',
})
const {
paymentPopupVisible: qualityOwnerPaymentPopupVisible,
activePayment: activeQualityOwnerPayment,
payURL: qualityOwnerPaymentPayURL,
paymentQRCodeURL: qualityOwnerPaymentQRCodeURL,
qrGenerating: qualityOwnerPaymentQRGenerating,
checkingPayment: qualityOwnerPaymentChecking,
} = qualityOwnerCashier
const showSettings = ref(false)
const showProfileEditor = ref(false)
const savingProfile = ref(false)
@@ -121,10 +149,72 @@ onMounted(async () => {
// 保留本地会话展示,避免个人中心因网络短暂异常整页不可用。
}
void loadBalance()
void loadQualityOwnerStatus()
void loadInviteRate()
}
})
async function loadQualityOwnerStatus() {
try {
qualityOwnerStatus.value = await fetchQualityOwnerStatus()
} catch {
qualityOwnerStatus.value = null
}
}
async function handleActivateQualityOwner() {
const status = qualityOwnerStatus.value
if (!status || status.active) return
try {
await showDialog({
title: '开通优质号主',
message: `确认从可用余额中扣除 ¥${formatCent(
status.required_deposit_cent
)} 作为保证金?取消资格时会退回余额。`,
showCancelButton: true,
confirmButtonText: '确认缴纳',
cancelButtonText: '取消',
})
} catch {
return
}
qualityOwnerLoading.value = true
try {
qualityOwnerStatus.value = await activateQualityOwner()
await session.loadMe()
await loadBalance()
showToast({ message: '优质号主已开通', icon: 'passed' })
} catch (error) {
showToast({ message: readError(error, '开通失败,请确认余额充足'), icon: 'cross' })
} finally {
qualityOwnerLoading.value = false
}
}
async function handleOnlineQualityOwnerPayment() {
const payWay = await qualityOwnerPayWaySelect.select()
if (!payWay) return
qualityOwnerOnlineLoading.value = true
try {
const payment = await startQualityOwnerPayment(payWay as PaymentPayWay)
if (payment.paid) {
await handleQualityOwnerPaid()
return
}
await qualityOwnerCashier.openMobilePaymentCashier(payment, handleQualityOwnerPaid)
} catch (error) {
showToast({ message: readError(error, '发起在线支付失败'), icon: 'cross' })
} finally {
qualityOwnerOnlineLoading.value = false
}
}
async function handleQualityOwnerPaid() {
qualityOwnerStatus.value = await fetchQualityOwnerStatus()
await session.loadMe()
await loadBalance()
}
async function loadBalance() {
try {
const wallet = await fetchWalletBalance()
@@ -366,6 +456,9 @@ function resolveAvatarURL(url: string | undefined | null) {
>
未实名
</van-tag>
<van-tag v-if="qualityOwnerStatus?.active" round class="owner-quality-tag">
<van-icon name="award-o" /> 优质号主
</van-tag>
</div>
<p class="user-meta">ID: {{ displayId }} · {{ maskedPhone }}</p>
</div>
@@ -393,6 +486,65 @@ function resolveAvatarURL(url: string | undefined | null) {
</div>
</section>
<section class="quality-owner-card" :class="{ 'is-active': qualityOwnerStatus?.active }">
<div class="quality-owner-heading">
<div>
<span>优质号主</span>
<strong>{{ qualityOwnerStatus?.active ? '已开通' : '解锁极品专区发布权限' }}</strong>
</div>
<van-tag :type="qualityOwnerStatus?.active ? 'success' : 'default'" round>
{{ qualityOwnerStatus?.active ? '优质号主' : '未开通' }}
</van-tag>
</div>
<div class="quality-owner-info">
<div>
<span>保证金</span>
<strong>¥{{ formatCent(qualityOwnerStatus?.required_deposit_cent || 20000) }}</strong>
</div>
<div>
<span>极品专区保险费率</span>
<strong
>{{
((qualityOwnerStatus?.premium_insurance_fee_bps || 400) / 100).toFixed(1)
}}%</strong
>
</div>
</div>
<div v-if="!qualityOwnerStatus?.active" class="quality-owner-actions">
<van-button
block
round
type="primary"
color="#7c3aed"
:loading="qualityOwnerLoading"
:disabled="
(qualityOwnerStatus?.available_balance_cent || 0) <
(qualityOwnerStatus?.required_deposit_cent || 20000)
"
@click="handleActivateQualityOwner"
>
余额支付开通
</van-button>
<van-button
block
round
plain
type="primary"
:loading="qualityOwnerOnlineLoading"
@click="handleOnlineQualityOwnerPayment"
>
微信 / 支付宝支付开通
</van-button>
</div>
<p v-else>
{{
qualityOwnerStatus?.deposit_source === 'manual'
? '已由后台开通优质号主。'
: '保证金已冻结,取消优质号主资格时退回可用余额。'
}}
</p>
</section>
<!-- 余额卡片 - 银行卡质感暗色卡片 -->
<div class="balance-card">
<div class="balance-row">
@@ -496,6 +648,22 @@ function resolveAvatarURL(url: string | undefined | null) {
</div>
</template>
<MobilePayWaySelectPopup
v-model:show="qualityOwnerPayWaySelect.visible.value"
@choose="qualityOwnerPayWaySelect.choose"
@closed="qualityOwnerPayWaySelect.handleClosed"
/>
<MobilePaymentCashierPopup
v-model:show="qualityOwnerPaymentPopupVisible"
:payment="activeQualityOwnerPayment"
:pay-url="qualityOwnerPaymentPayURL"
:qr-code-url="qualityOwnerPaymentQRCodeURL"
:qr-generating="qualityOwnerPaymentQRGenerating"
:checking="qualityOwnerPaymentChecking"
@refresh="qualityOwnerCashier.refreshPaymentStatus(false)"
/>
<!-- 账单明细 Popup -->
<van-popup
v-model:show="showLedgers"
@@ -4,13 +4,16 @@ import {
Camera,
CircleCheckFilled,
EditPen,
Loading,
Lock,
Picture,
Postcard,
RefreshRight,
Trophy,
User,
WarningFilled,
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -18,10 +21,25 @@ import { uploadFile } from '@/shared/api/files'
import { useSessionStore } from '@/stores/session'
import { realnameStatusLabel } from '@/shared/utils/statusLabels'
import { setPassword } from '@/features/auth/api/auth'
import {
activateQualityOwner,
fetchQualityOwnerStatus,
queryQualityOwnerPayment,
startQualityOwnerPayment,
type QualityOwnerStatus,
} from '@/features/quality-owner/api/qualityOwner'
import { formatCent } from '@/shared/utils/money'
import type { PaymentPayWay } from '@/features/orders/api/orders'
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
import PayWaySelectDialog from '@/features/orders/components/PayWaySelectDialog.vue'
const session = useSessionStore()
const router = useRouter()
const saving = ref(false)
const qualityOwnerLoading = ref(false)
const qualityOwnerOnlineLoading = ref(false)
const qualityOwnerPayWayVisible = ref(false)
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
const uploadingAvatar = ref(false)
const avatarFileInput = ref<HTMLInputElement | null>(null)
const form = reactive({
@@ -36,6 +54,20 @@ const passwordForm = reactive({
confirmPassword: '',
})
const qualityOwnerCashier = useOrderPaymentCashier({
notifySuccess: message => ElMessage.success(message),
notifyError: message => ElMessage.error(message),
notifyInfo: message => ElMessage.info(message),
notifyFallback: message => ElMessage.warning(message),
paidMessage: '保证金支付成功,优质号主已开通',
queryPayment: () => queryQualityOwnerPayment(),
})
const qualityOwnerPaymentVisible = qualityOwnerCashier.paymentPopupVisible
const activeQualityOwnerPayment = qualityOwnerCashier.activePayment
const qualityOwnerPaymentQRCodeURL = qualityOwnerCashier.paymentQRCodeURL
const qualityOwnerQrGenerating = qualityOwnerCashier.qrGenerating
const qualityOwnerCheckingPayment = qualityOwnerCashier.checkingPayment
const displayName = computed(() => session.displayName)
const maskedPhone = computed(() => {
if (!session.phone) return '未绑定手机号'
@@ -56,8 +88,72 @@ onMounted(async () => {
// 保留本地会话展示,避免资料页因网络短暂异常整页不可用。
}
resetForm()
void loadQualityOwnerStatus()
})
async function loadQualityOwnerStatus() {
try {
qualityOwnerStatus.value = await fetchQualityOwnerStatus()
} catch {
qualityOwnerStatus.value = null
}
}
async function handleActivateQualityOwner() {
const status = qualityOwnerStatus.value
if (!status || status.active) return
const deposit = formatCent(status.required_deposit_cent)
try {
await ElMessageBox.confirm(
`确认从可用余额中扣除 ¥${deposit} 作为优质号主保证金?保证金会冻结,取消优质号主资格时退回。`,
'开通优质号主',
{
type: 'warning',
confirmButtonText: '确认缴纳',
cancelButtonText: '取消',
}
)
} catch {
return
}
qualityOwnerLoading.value = true
try {
qualityOwnerStatus.value = await activateQualityOwner()
await session.loadMe()
ElMessage.success('优质号主已开通,可在发布时选择极品专区')
} catch (error) {
ElMessage.error(readError(error, '开通失败,请确认余额充足'))
} finally {
qualityOwnerLoading.value = false
}
}
function openQualityOwnerOnlinePayment() {
qualityOwnerPayWayVisible.value = true
}
async function chooseQualityOwnerPayWay(payWay: PaymentPayWay) {
qualityOwnerPayWayVisible.value = false
qualityOwnerOnlineLoading.value = true
try {
const payment = await startQualityOwnerPayment(payWay)
if (payment.paid) {
await handleQualityOwnerPaymentSucceeded()
return
}
await qualityOwnerCashier.openPaymentCashier(payment, handleQualityOwnerPaymentSucceeded)
} catch (error) {
ElMessage.error(readError(error, '发起在线支付失败'))
} finally {
qualityOwnerOnlineLoading.value = false
}
}
async function handleQualityOwnerPaymentSucceeded() {
qualityOwnerStatus.value = await fetchQualityOwnerStatus()
await session.loadMe()
}
function resetForm() {
form.nickname = session.nickname || session.displayName
form.avatar_url = session.avatarUrl || ''
@@ -153,14 +249,20 @@ async function savePassword() {
<template>
<section class="profile-page">
<div class="profile-hero">
<div class="profile-hero" :class="{ 'is-quality-owner': qualityOwnerStatus?.active }">
<div class="hero-avatar">
<img v-if="session.avatarUrl" :src="resolveAvatarURL(session.avatarUrl)" alt="" />
<span v-else>{{ session.avatarText }}</span>
</div>
<div class="hero-copy">
<span class="eyebrow">Profile</span>
<h1>个人资料</h1>
<div class="hero-title-row">
<h1>个人资料</h1>
<span v-if="qualityOwnerStatus?.active" class="hero-quality-chip">
<el-icon><Trophy /></el-icon>
优质号主
</span>
</div>
<p>{{ displayName }} · {{ maskedPhone }}</p>
</div>
<button
@@ -176,6 +278,24 @@ async function savePassword() {
</button>
</div>
<section v-if="qualityOwnerStatus?.active" class="quality-owner-spotlight">
<div class="quality-owner-spotlight-mark">
<el-icon :size="25"><Trophy /></el-icon>
</div>
<div class="quality-owner-spotlight-copy">
<span>PREMIUM OWNER</span>
<strong>优质号主身份已点亮</strong>
<p>
你的账号将优先展示优质号主标识,并可发布至首页极品专区,享受更高曝光与 4% 保险专享费率。
</p>
</div>
<div class="quality-owner-spotlight-benefits">
<span>极致曝光</span>
<span>身份认证</span>
<span>专享费率</span>
</div>
</section>
<div class="profile-layout">
<section class="edit-panel">
<div class="panel-head">
@@ -280,6 +400,104 @@ async function savePassword() {
</aside>
</div>
<section class="quality-owner-card" :class="{ 'is-active': qualityOwnerStatus?.active }">
<div class="quality-owner-heading">
<div>
<span class="card-kicker">Premium Owner</span>
<h2>优质号主</h2>
<p>
开通后可发布到极品专区;在极品专区购买无忧租号保险可享
{{ ((qualityOwnerStatus?.premium_insurance_fee_bps || 400) / 100).toFixed(1) }}%
专享费率。
</p>
</div>
<span class="quality-owner-status">
{{ qualityOwnerStatus?.active ? '已开通' : '未开通' }}
</span>
</div>
<div class="quality-owner-metrics">
<div>
<span>保证金</span>
<strong>¥{{ formatCent(qualityOwnerStatus?.required_deposit_cent || 20000) }}</strong>
</div>
<div>
<span>普通保险服务费</span>
<strong>{{ ((qualityOwnerStatus?.insurance_fee_bps || 800) / 100).toFixed(1) }}%</strong>
</div>
<div>
<span>极品专区保险服务费</span>
<strong
>{{
((qualityOwnerStatus?.premium_insurance_fee_bps || 400) / 100).toFixed(1)
}}%</strong
>
</div>
<div>
<span>当前可用余额</span>
<strong>¥{{ formatCent(qualityOwnerStatus?.available_balance_cent || 0) }}</strong>
</div>
</div>
<div class="quality-owner-actions">
<span v-if="qualityOwnerStatus?.active">
<template v-if="qualityOwnerStatus.deposit_source === 'manual'">已由后台开通</template>
<template v-else>保证金已冻结</template>
</span>
<span v-else>保证金取消资格时原路退回余额。</span>
<div v-if="!qualityOwnerStatus?.active" class="quality-owner-action-buttons">
<el-button
type="primary"
:loading="qualityOwnerLoading"
:disabled="
(qualityOwnerStatus?.available_balance_cent || 0) <
(qualityOwnerStatus?.required_deposit_cent || 20000)
"
@click="handleActivateQualityOwner"
>
余额支付开通
</el-button>
<el-button :loading="qualityOwnerOnlineLoading" @click="openQualityOwnerOnlinePayment">
微信 / 支付宝支付开通
</el-button>
</div>
</div>
</section>
<PayWaySelectDialog v-model="qualityOwnerPayWayVisible" @choose="chooseQualityOwnerPayWay" />
<el-dialog
v-model="qualityOwnerPaymentVisible"
title="优质号主保证金支付"
width="460px"
append-to-body
class="quality-owner-pay-dialog"
>
<div v-if="activeQualityOwnerPayment" class="quality-owner-pay-dialog-body">
<div class="quality-owner-pay-amount">
<span>支付金额</span>
<strong>¥{{ formatCent(activeQualityOwnerPayment.amount_cent) }}</strong>
</div>
<div class="quality-owner-pay-qr">
<el-icon v-if="qualityOwnerQrGenerating" class="is-loading" :size="30">
<Loading />
</el-icon>
<img
v-else-if="qualityOwnerPaymentQRCodeURL"
:src="qualityOwnerPaymentQRCodeURL"
alt="保证金支付二维码"
/>
<el-icon v-else :size="42"><Picture /></el-icon>
</div>
<p>请使用{{ activeQualityOwnerPayment.pay_way === 'WXZF' ? '微信' : '支付宝' }}扫码支付</p>
<el-button
type="primary"
:loading="qualityOwnerCheckingPayment"
@click="qualityOwnerCashier.refreshPaymentStatus(false)"
>
我已支付,刷新状态
</el-button>
</div>
</el-dialog>
<el-dialog
v-model="showPasswordDialog"
title="修改密码"
@@ -345,6 +563,19 @@ async function savePassword() {
box-shadow: 0 16px 36px rgba(23, 35, 61, 0.08);
}
.profile-hero.is-quality-owner {
border-color: #d8c9fb;
background:
radial-gradient(circle at 86% 0%, rgba(245, 158, 11, 0.15), transparent 34%),
linear-gradient(135deg, #f3edff 0%, #ffffff 58%, #fff8e8 100%);
box-shadow: 0 16px 42px rgba(109, 40, 217, 0.12);
}
.profile-hero.is-quality-owner .hero-avatar {
background: linear-gradient(135deg, #5b21b6 0%, #9333ea 62%, #f59e0b 100%);
box-shadow: 0 8px 20px rgba(109, 40, 217, 0.22);
}
.hero-avatar,
.avatar-preview {
display: grid;
@@ -389,6 +620,27 @@ async function savePassword() {
line-height: 1.2;
}
.hero-title-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.hero-quality-chip {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 26px;
padding: 0 10px;
border: 1px solid #d8b4fe;
border-radius: 999px;
background: #f5f3ff;
color: #6d28d9;
font-size: 12px;
font-weight: 900;
}
.hero-copy p {
margin: 10px 0 0;
color: #64748b;
@@ -433,6 +685,69 @@ async function savePassword() {
color: #2563eb;
}
.quality-owner-spotlight {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
margin-top: 18px;
padding: 17px 20px;
border: 1px solid #e1d7fa;
border-radius: 14px;
background: linear-gradient(110deg, #faf7ff 0%, #ffffff 62%, #fffaf0 100%);
}
.quality-owner-spotlight-mark {
display: grid;
width: 46px;
height: 46px;
place-items: center;
border-radius: 12px;
background: linear-gradient(135deg, #5b21b6 0%, #9333ea 65%, #f59e0b 100%);
color: #fff;
}
.quality-owner-spotlight-copy {
display: grid;
gap: 3px;
}
.quality-owner-spotlight-copy > span {
color: #7c3aed;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.08em;
}
.quality-owner-spotlight-copy strong {
color: #2e1065;
font-size: 17px;
font-weight: 900;
}
.quality-owner-spotlight-copy p {
margin: 0;
color: #70677f;
font-size: 12px;
line-height: 1.55;
}
.quality-owner-spotlight-benefits {
display: flex;
gap: 7px;
flex-wrap: wrap;
justify-content: flex-end;
}
.quality-owner-spotlight-benefits span {
padding: 5px 9px;
border-radius: 999px;
background: #f5f3ff;
color: #6d28d9;
font-size: 11px;
font-weight: 800;
}
.profile-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
@@ -647,9 +962,178 @@ async function savePassword() {
line-height: 1.6;
}
.quality-owner-card {
margin-top: 22px;
padding: 26px 28px;
border: 1px solid #e8e4f4;
border-radius: 16px;
background: linear-gradient(135deg, #faf8ff 0%, #ffffff 68%);
box-shadow: 0 10px 28px rgba(76, 29, 149, 0.06);
}
.quality-owner-card.is-active {
border-color: #c4b5fd;
}
.quality-owner-heading,
.quality-owner-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.quality-owner-heading h2 {
margin: 6px 0 0;
color: #312e81;
font-size: 24px;
}
.quality-owner-heading p {
max-width: 680px;
margin: 8px 0 0;
color: #6b7280;
font-size: 13px;
font-weight: 700;
line-height: 1.7;
}
.quality-owner-status {
flex-shrink: 0;
padding: 6px 12px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
font-size: 12px;
font-weight: 900;
}
.quality-owner-card.is-active .quality-owner-status {
background: #ede9fe;
color: #6d28d9;
}
.quality-owner-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 20px;
}
.quality-owner-metrics div {
display: grid;
gap: 6px;
padding: 14px;
border: 1px solid #ede9fe;
border-radius: 10px;
background: rgba(255, 255, 255, 0.82);
}
.quality-owner-metrics span,
.quality-owner-actions span {
color: #7b8492;
font-size: 12px;
font-weight: 700;
}
.quality-owner-metrics strong {
color: #312e81;
font-size: 18px;
font-weight: 900;
}
.quality-owner-actions {
margin-top: 16px;
}
.quality-owner-actions :deep(.el-button--primary) {
border: none;
background: #7c3aed;
}
.quality-owner-action-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.quality-owner-action-buttons :deep(.el-button) {
margin-left: 0;
}
.quality-owner-pay-dialog-body {
display: grid;
gap: 14px;
justify-items: center;
}
.quality-owner-pay-amount {
display: flex;
align-items: baseline;
gap: 10px;
color: #64748b;
font-size: 14px;
}
.quality-owner-pay-amount strong {
color: #f97316;
font-size: 26px;
}
.quality-owner-pay-qr {
display: grid;
width: 228px;
height: 228px;
place-items: center;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
}
.quality-owner-pay-qr img {
width: 214px;
height: 214px;
}
.quality-owner-pay-dialog-body p {
margin: 0;
color: #475569;
font-size: 13px;
}
@media (max-width: 1100px) {
.profile-layout {
grid-template-columns: 1fr;
}
.quality-owner-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.quality-owner-heading,
.quality-owner-actions {
align-items: flex-start;
flex-direction: column;
}
.quality-owner-action-buttons {
justify-content: flex-start;
}
.quality-owner-metrics {
grid-template-columns: 1fr;
}
.quality-owner-spotlight {
grid-template-columns: auto minmax(0, 1fr);
}
.quality-owner-spotlight-benefits {
grid-column: 1 / -1;
justify-content: flex-start;
}
}
</style>
@@ -10,6 +10,7 @@ export interface Listing {
owner_id: number
owner_phone?: string
owner_nickname?: string
owner_is_quality_owner?: boolean
source_channel?: string
is_external_upload?: boolean
title: string
@@ -33,6 +34,9 @@ export interface Listing {
distribution_site_id?: number
distribution_site_code?: string
distribution_site_name?: string
is_premium: boolean
compensation_plan: 'normal' | 'insurance' | string
insurance_fee_bps: number
listing_group_conversation_id?: number
created_at: string
updated_at: string
@@ -52,6 +56,8 @@ export interface ListingPayload {
screenshot_urls: string[]
price_cent: number
deposit_amount_cent: number
is_premium: boolean
compensation_plan: 'normal' | 'insurance'
agreed_virtual_asset_sale: boolean
agreed_seller_agreement: boolean
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Coin, Discount, Grid, Key, Moon, Present } from '@element-plus/icons-vue'
import { Coin, Discount, Grid, Key, Moon, Present, Trophy } from '@element-plus/icons-vue'
interface ZoneOption {
key: string
@@ -27,6 +27,7 @@ const zoneIcons: Record<string, Component> = {
night: Moon,
password: Key,
highCoin: Coin,
premium: Trophy,
}
function selectZone(key: string) {
@@ -41,7 +42,7 @@ function selectZone(key: string) {
:key="zone.key"
type="button"
class="zone-pill-item"
:class="{ active: activeZone === zone.key }"
:class="{ active: activeZone === zone.key, 'is-premium': zone.key === 'premium' }"
:title="zone.hint"
@click="selectZone(zone.key)"
>
@@ -89,6 +90,18 @@ function selectZone(key: string) {
box-shadow: 0 2px 8px rgba(255, 106, 0, 0.25);
}
.zone-pill-item.is-premium:not(.active) {
border-color: #ddd6fe;
background: #faf7ff;
color: #6d28d9;
}
.zone-pill-item.is-premium.active {
border-color: #7c3aed;
background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%);
box-shadow: 0 4px 14px rgba(109, 40, 217, 0.24);
}
.zone-pill-icon {
font-size: 14px;
}
@@ -99,7 +99,14 @@ function formatStatNumber(value: number) {
</script>
<template>
<RouterLink class="hfb-listing-card" :to="`/listings/${listing.id}`">
<RouterLink
class="hfb-listing-card"
:class="{
'is-quality-owner': listing.owner_is_quality_owner,
'is-premium': listing.is_premium,
}"
:to="`/listings/${listing.id}`"
>
<!-- 左侧:4:3 黄金比例单张封面(后端公开缩略图,秒级加载) -->
<div class="card-media">
<img
@@ -114,6 +121,9 @@ function formatStatNumber(value: number) {
<!-- 状态浮标 -->
<div class="media-badges">
<span v-if="listing.owner_is_quality_owner" class="badge owner">优质号主</span>
<span v-if="listing.is_premium" class="badge premium">极品</span>
<span v-if="listing.compensation_plan === 'insurance'" class="badge insurance">保险</span>
<span v-if="hasAcceleratedSaleRatio(listing)" class="badge sale">特惠</span>
<span v-if="hasGiftResources(listing)" class="badge gift">有赠送</span>
</div>
@@ -128,6 +138,10 @@ function formatStatNumber(value: number) {
<div class="content-primary">
<div class="header-row">
<span class="code-badge">{{ listingCode }}</span>
<span v-if="listing.owner_is_quality_owner" class="owner-quality-badge">
<van-icon name="award-o" :size="13" />
<span>优质号主</span>
</span>
<span v-for="tag in accessTags" :key="tag.label" class="platform-badge" :class="tag.tone">
<van-icon :name="tag.icon" :size="13" />
<span>{{ tag.label }}</span>
@@ -261,6 +275,7 @@ function formatStatNumber(value: number) {
<style scoped>
.hfb-listing-card {
position: relative;
display: grid;
grid-template-columns: 210px minmax(0, 1fr) 146px;
gap: 18px;
@@ -279,6 +294,22 @@ function formatStatNumber(value: number) {
transform 0.22s ease;
}
.hfb-listing-card.is-quality-owner {
border-color: #d8c9fb;
box-shadow: 0 8px 24px rgba(109, 40, 217, 0.08);
}
.hfb-listing-card.is-quality-owner::before,
.hfb-listing-card.is-premium::before {
content: '';
position: absolute;
top: 0;
right: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, #7c3aed 0%, #a855f7 50%, #f59e0b 100%);
}
.hfb-listing-card:hover {
border-color: #ff8533;
box-shadow: 0 12px 30px rgba(255, 106, 0, 0.12);
@@ -346,6 +377,18 @@ function formatStatNumber(value: number) {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.badge.owner {
background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%);
}
.badge.premium {
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
}
.badge.insurance {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.media-ratio {
position: absolute;
bottom: 0;
@@ -465,6 +508,19 @@ function formatStatNumber(value: number) {
color: #4f46e5;
}
.owner-quality-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid #ddd6fe;
border-radius: 6px;
background: #f5f3ff;
color: #6d28d9;
font-size: 12px;
font-weight: 800;
}
.aux-badge {
display: inline-flex;
align-items: center;
@@ -15,6 +15,7 @@ import {
type HomeBannerSlide,
} from '@/features/listings/api/homeConfig'
import { ElMessage } from 'element-plus'
import { ArrowRight, Trophy } from '@element-plus/icons-vue'
import AnnouncementPopup from '@/features/announcement/components/AnnouncementPopup.vue'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import {
@@ -206,6 +207,11 @@ watch(
)
const statCards = computed(() => [
{
label: '优质号主精选',
value: `${zoneCount('premium')}`,
hint: '认证优质号主 · 极品专区',
},
{ label: '可租账号', value: `${totalListings.value}`, hint: '当前筛选结果' },
{
label: '高哈夫币',
@@ -226,6 +232,12 @@ const zoneOptions = computed(() => [
hint: '当前可租账号',
count: zoneCount('all'),
},
{
key: 'premium',
label: '极品专区',
hint: '优质号主精选',
count: zoneCount('premium'),
},
{
key: 'sale',
label: '特惠专区',
@@ -640,6 +652,25 @@ if (!restored) {
<el-empty v-else description="暂无展示图片" />
</el-dialog>
<section class="premium-spotlight" aria-label="优质号主精选">
<div class="premium-spotlight-mark">
<el-icon :size="24"><Trophy /></el-icon>
</div>
<div class="premium-spotlight-copy">
<span>QUALITY OWNER SELECTION</span>
<strong>优质号主严选 · 极品专区</strong>
<p>仅认证优质号主可发布,尊享更严格的服务标准与无忧租号保险 4% 专享费率。</p>
</div>
<div class="premium-spotlight-count">
<strong>{{ zoneCount('premium') }}</strong>
<span>个精选账号</span>
</div>
<button type="button" @click="activeZone = 'premium'">
进入极品专区
<el-icon><ArrowRight /></el-icon>
</button>
</section>
<HomeFilters
:filters="filters"
:total-listings="totalListings"
@@ -714,6 +745,95 @@ if (!restored) {
min-width: 0;
}
.premium-spotlight {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 18px;
min-height: 92px;
padding: 16px 20px;
border: 1px solid #ded3fb;
border-radius: 14px;
background:
radial-gradient(circle at 82% 0%, rgba(245, 158, 11, 0.13), transparent 32%),
linear-gradient(115deg, #f7f3ff 0%, #ffffff 62%, #fffaf0 100%);
box-shadow: 0 8px 24px rgba(109, 40, 217, 0.07);
}
.premium-spotlight-mark {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 13px;
background: linear-gradient(135deg, #5b21b6 0%, #9333ea 62%, #f59e0b 100%);
color: #fff;
box-shadow: 0 8px 18px rgba(109, 40, 217, 0.24);
}
.premium-spotlight-copy {
display: grid;
gap: 3px;
min-width: 0;
}
.premium-spotlight-copy > span {
color: #7c3aed;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.08em;
}
.premium-spotlight-copy strong {
color: #2e1065;
font-size: 18px;
font-weight: 900;
}
.premium-spotlight-copy p {
margin: 0;
color: #70677f;
font-size: 12px;
line-height: 1.5;
}
.premium-spotlight-count {
display: grid;
justify-items: end;
padding-right: 6px;
}
.premium-spotlight-count strong {
color: #6d28d9;
font-size: 24px;
line-height: 1;
}
.premium-spotlight-count span {
margin-top: 4px;
color: #8b8299;
font-size: 11px;
}
.premium-spotlight > button {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 36px;
padding: 0 15px;
border: none;
border-radius: 9px;
background: #6d28d9;
color: #fff;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.premium-spotlight > button:hover {
background: #5b21b6;
}
.hero-section {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
@@ -888,12 +1008,35 @@ if (!restored) {
min-height: 76px;
padding: 14px 40px 14px 16px;
}
.premium-spotlight {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.premium-spotlight > button {
grid-column: 2 / -1;
justify-self: start;
}
}
@media (max-width: 680px) {
.biz-entry-rail {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.premium-spotlight {
grid-template-columns: auto minmax(0, 1fr);
}
.premium-spotlight-count {
display: none;
}
.premium-spotlight > button {
grid-column: 1 / -1;
width: 100%;
justify-content: center;
}
}
.infinite-load-state {
@@ -207,6 +207,16 @@
color: #7c3aed;
}
.platform-pill.premium {
background: #f5f3ff;
color: #6d28d9;
}
.platform-pill.insurance {
background: #eff6ff;
color: #1d4ed8;
}
.status-pill {
display: inline-flex;
align-items: center;
@@ -462,6 +462,19 @@ function goBack() {
<span>{{ listing.rank_level }}</span>
</span>
<span v-if="listing.is_premium" class="platform-pill premium">
<van-icon name="award-o" :size="13" />
<span>极品专区</span>
</span>
<span
v-if="listing.compensation_plan === 'insurance'"
class="platform-pill insurance"
>
<van-icon name="shield-o" :size="13" />
<span>无忧租号保险</span>
</span>
<span class="status-pill" :class="{ 'is-busy': listing.in_transaction }">
<i class="status-dot" />
<span>{{ listing.in_transaction ? '交易中' : '现号可租' }}</span>
@@ -564,6 +564,78 @@
scrollbar-width: none;
}
.mobile-premium-spotlight {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 9px;
width: 100%;
min-height: 68px;
margin: 2px 0 9px;
padding: 10px 12px;
border: 1px solid #ddd0fb;
border-radius: 12px;
background:
radial-gradient(circle at 88% 0%, rgba(245, 158, 11, 0.16), transparent 34%),
linear-gradient(115deg, #f7f2ff 0%, #fff 68%, #fffaf0 100%);
color: #6d28d9;
text-align: left;
}
.premium-spotlight-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 10px;
background: linear-gradient(135deg, #5b21b6 0%, #9333ea 65%, #f59e0b 100%);
color: #fff;
}
.premium-spotlight-copy {
display: grid;
gap: 1px;
min-width: 0;
}
.premium-spotlight-copy small {
color: #8b5cf6;
font-size: 8px;
font-weight: 900;
letter-spacing: 0.08em;
}
.premium-spotlight-copy strong {
overflow: hidden;
color: #2e1065;
font-size: 13px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.premium-spotlight-copy em {
overflow: hidden;
color: #7b7188;
font-size: 9px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.premium-spotlight-count {
display: grid;
min-width: 27px;
height: 27px;
place-items: center;
padding: 0 7px;
border-radius: 999px;
background: #6d28d9;
color: #fff;
font-size: 11px;
font-weight: 900;
}
.zone-strip::-webkit-scrollbar {
display: none;
}
@@ -618,6 +690,23 @@
color: #fff;
}
.zone-pill.is-premium:not(.active) {
border-color: #ddd6fe;
background: #faf7ff;
color: #6d28d9;
}
.zone-pill.is-premium:not(.active) span {
background: #ede9fe;
color: #6d28d9;
}
.zone-pill.is-premium.active {
border-color: #7c3aed;
background: #6d28d9;
color: #fff;
}
.zone-pill:active {
transform: translateY(1px);
}
@@ -910,6 +999,18 @@
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.media-badges .badge.owner {
background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%);
}
.media-badges .badge.premium {
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
}
.media-badges .badge.insurance {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.media-ratio {
position: absolute;
bottom: 0;
@@ -169,6 +169,7 @@ const sortOptions: MobileSortOption[] = [
const zoneOptions = [
{ key: 'all', label: '全部', hint: '当前可租' },
{ key: 'premium', label: '极品', hint: '优质号主精选' },
{ key: 'sale', label: '特惠', hint: '价格更划算' },
{ key: 'gift', label: '赠送', hint: '含赠送物品' },
{ key: 'night', label: '夜间', hint: '0-2点可响应上号' },
@@ -1006,13 +1007,31 @@ if (!restoreHomeConfigFromCache()) {
</button>
</div>
<button
type="button"
class="mobile-premium-spotlight"
aria-label="进入优质号主极品专区"
@click="selectZone('premium')"
>
<span class="premium-spotlight-icon">
<van-icon name="award-o" :size="22" />
</span>
<span class="premium-spotlight-copy">
<small>QUALITY OWNER</small>
<strong>优质号主严选 · 极品专区</strong>
<em>认证发布 · 4% 保险专享费率</em>
</span>
<span class="premium-spotlight-count">{{ zoneCount('premium') }}</span>
<van-icon name="arrow" :size="13" />
</button>
<div class="zone-strip" aria-label="账号专区">
<button
v-for="zone in visibleZoneOptions"
:key="zone.key"
type="button"
class="zone-pill"
:class="{ active: activeZone === zone.key }"
:class="{ active: activeZone === zone.key, 'is-premium': zone.key === 'premium' }"
@click="selectZone(zone.key)"
>
<strong>{{ zone.label }}</strong>
@@ -1122,9 +1141,20 @@ if (!restoreHomeConfigFromCache()) {
<div v-else class="media-empty">HFB</div>
<div
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
v-if="
hasGiftResources(item) ||
hasAcceleratedSaleRatio(item) ||
item.owner_is_quality_owner ||
item.is_premium ||
item.compensation_plan === 'insurance'
"
class="media-badges"
>
<span v-if="item.owner_is_quality_owner" class="badge owner">优质号主</span>
<span v-if="item.is_premium" class="badge premium">极品</span>
<span v-if="item.compensation_plan === 'insurance'" class="badge insurance"
>保险</span
>
<span v-if="hasAcceleratedSaleRatio(item)" class="badge sale">特惠</span>
<span v-if="hasGiftResources(item)" class="badge gift">有赠送</span>
</div>
@@ -195,6 +195,16 @@
color: #7c3aed;
}
.platform-pill.premium {
background: #f5f3ff;
color: #6d28d9;
}
.platform-pill.insurance {
background: #eff6ff;
color: #1d4ed8;
}
.status-pill {
display: inline-flex;
align-items: center;
@@ -444,6 +444,14 @@ function goBack() {
<van-icon name="diamond-o" :size="12" />
<span>{{ listing.rank_level }}</span>
</span>
<span v-if="listing.is_premium" class="platform-pill premium">
<van-icon name="award-o" :size="12" />
<span>极品专区</span>
</span>
<span v-if="listing.compensation_plan === 'insurance'" class="platform-pill insurance">
<van-icon name="shield-o" :size="12" />
<span>无忧保险</span>
</span>
<span class="status-pill" :class="{ 'is-busy': listing.in_transaction }">
<i class="status-dot" />
<span>{{ listing.in_transaction ? '交易中' : '现号可租' }}</span>
@@ -50,6 +50,10 @@ export interface Order {
account_snapshot?: Record<string, unknown>
account_source?: 'internal' | 'external' | string
source_channel?: string
is_premium?: boolean
compensation_plan?: 'normal' | 'insurance' | string
insurance_fee_bps?: number
insurance_fee_cent?: number
listing_snapshot?: string
checkout_info?: string
counter_info?: string
@@ -174,6 +178,8 @@ export interface Checkout {
rent_amount_cent?: number
owner_rent_amount_cent?: number
platform_fee_cent?: number
insurance_fee_bps?: number
insurance_fee_cent?: number
deposit_amount_cent: number
pure_coin_amount_cent: number
pure_coin_discount_cent: number
@@ -234,12 +240,14 @@ export interface PaymentOrder {
payment_no: string
order_id: number
order_no: string
biz_id?: number
provider: string
third_order_id: string
provider_order_id: string
pay_way: string
jspay_flag: string
amount_cent: number
biz_type?: string
status: string
td_code?: string
jspay_url?: string
@@ -91,6 +91,13 @@ const checkoutContent = computed(() => splitCheckoutContent(props.order.checkout
:value="`¥${money(amountYuan(checkout.renter_refund_amount_cent))}`"
value-class="green-text"
/>
<van-cell
v-if="props.isOwner && Number(checkout.insurance_fee_cent || 0) > 0"
title="无忧租号保险服务费"
:label="`费率 ${(Number(checkout.insurance_fee_bps || 0) / 100).toFixed(1)}%`"
:value="`-¥${money(amountYuan(checkout.insurance_fee_cent))}`"
value-class="red-text"
/>
<van-cell
v-if="props.isOwner"
title="号主最终收入"
@@ -186,6 +193,15 @@ const checkoutContent = computed(() => splitCheckoutContent(props.order.checkout
<span>退还租客(未使用租金 + 剩余押金)</span>
<strong class="amount">¥{{ money(amountYuan(checkout.renter_refund_amount_cent)) }}</strong>
</div>
<div
v-if="props.isOwner && Number(checkout.insurance_fee_cent || 0) > 0"
class="summary-row highlight"
>
<span>
无忧租号保险服务费({{ (Number(checkout.insurance_fee_bps || 0) / 100).toFixed(1) }}%)
</span>
<strong class="warning">-¥{{ money(amountYuan(checkout.insurance_fee_cent)) }}</strong>
</div>
<div v-if="props.isOwner" class="summary-row highlight">
<span>号主最终收入(租金 + 押金赔付)</span>
<strong class="amount">¥{{ money(amountYuan(checkout.owner_income_amount_cent)) }}</strong>
@@ -7,6 +7,7 @@ export interface OwnerSimulation {
role: 'owner'
totalIncomeYuan: number
rentIncomeYuan: number
insuranceFeeYuan: number
damageCompensationYuan: number
lossCompensationYuan: number
shortfallYuan: number
@@ -99,7 +100,14 @@ export function useCheckoutSimulator(options: {
ownerConsumableYuan = consumableYuan
}
const ownerRentIncomeYuan = roundMoney(ownerCoinYuan + ownerConsumableYuan)
const grossOwnerRentIncomeYuan = roundMoney(ownerCoinYuan + ownerConsumableYuan)
const insuranceFeeYuan = roundMoney(
(grossOwnerRentIncomeYuan * Number(o.insurance_fee_bps || 0)) / 10000
)
const ownerRentIncomeYuan = Math.max(
roundMoney(grossOwnerRentIncomeYuan - insuranceFeeYuan),
0
)
// 押金赔付覆盖与差额
const damageCoveredYuan = Math.min(depositDeductYuan, depositYuan)
@@ -110,6 +118,7 @@ export function useCheckoutSimulator(options: {
role: 'owner',
totalIncomeYuan,
rentIncomeYuan: ownerRentIncomeYuan,
insuranceFeeYuan,
damageCompensationYuan: damageCoveredYuan,
lossCompensationYuan: ownerConsumableYuan,
shortfallYuan,
@@ -0,0 +1,38 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types'
import type { PaymentOrder, PaymentPayWay } from '@/features/orders/api/orders'
export interface QualityOwnerStatus {
user_id: number
active: boolean
status: 'none' | 'active' | string
deposit_amount_cent: number
deposit_source: 'paid' | 'manual' | '' | string
activated_at?: string
available_balance_cent: number
required_deposit_cent: number
insurance_fee_bps: number
premium_insurance_fee_bps: number
}
export async function fetchQualityOwnerStatus() {
const { data } = await apiClient.get<ApiResponse<QualityOwnerStatus>>('/quality-owner/status')
return data.data
}
export async function activateQualityOwner() {
const { data } = await apiClient.post<ApiResponse<QualityOwnerStatus>>('/quality-owner/activate')
return data.data
}
export async function startQualityOwnerPayment(payWay: PaymentPayWay) {
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>('/quality-owner/payment', {
pay_way: payWay,
})
return data.data
}
export async function queryQualityOwnerPayment() {
const { data } = await apiClient.get<ApiResponse<PaymentOrder>>('/quality-owner/payment')
return data.data
}
@@ -25,6 +25,8 @@ export function defaultPublishForm(): PublishForm {
ban_record: '',
common_regions: [],
depositAmountYuan: '',
is_premium: false,
compensation_plan: 'normal',
remark: '',
}
}
@@ -101,6 +103,8 @@ function normalizeDraftForm(value: unknown): PublishForm {
if (next.haf_coin_amount === 0) next.haf_coin_amount = ''
if (String(next.secret_kd).trim() === '0') next.secret_kd = ''
if (next.fire_level === 0) next.fire_level = ''
next.is_premium = next.is_premium === true
if (next.compensation_plan !== 'insurance') next.compensation_plan = 'normal'
return next
}
@@ -22,6 +22,10 @@ import {
updateListing,
type Listing,
} from '@/features/listings/api/listings'
import {
fetchQualityOwnerStatus,
type QualityOwnerStatus,
} from '@/features/quality-owner/api/qualityOwner'
import { usePricingCalculator } from '@/shared/composables/usePricingCalculator'
import {
buildPublishDraft,
@@ -66,6 +70,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig)
const publishAgreements = ref<ListingPublishAgreements>(emptyListingPublishAgreements)
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
const virtualAssetSaleAgreementChecked = ref(false)
const sellerAgreementChecked = ref(false)
const passwordAndDeviceConfirmed = ref(false)
@@ -86,6 +91,13 @@ export function usePublishForm(options: UsePublishFormOptions) {
const showBanRecordRiskHint = false
const unlockDemulanEnabled = computed(() => publishOptions.value.unlock_demulan_enabled)
const nextSeasonPassEnabled = computed(() => publishOptions.value.next_season_pass_enabled)
const isQualityOwner = computed(() => qualityOwnerStatus.value?.active === true)
const insuranceFeeBps = computed(() =>
form.is_premium && isQualityOwner.value
? Number(qualityOwnerStatus.value?.premium_insurance_fee_bps || 400)
: Number(qualityOwnerStatus.value?.insurance_fee_bps || 800)
)
const insuranceFeePercent = computed(() => insuranceFeeBps.value / 100)
const pricing = usePricingCalculator({
form,
@@ -184,14 +196,17 @@ export function usePublishForm(options: UsePublishFormOptions) {
async function loadPublishOptions() {
try {
const [nextPublishOptions, nextSalePriceConfig, nextPublishAgreements] = await Promise.all([
fetchListingPublishOptions(),
fetchListingSalePriceConfig(),
fetchListingPublishAgreements(),
])
const [nextPublishOptions, nextSalePriceConfig, nextPublishAgreements, nextQualityOwner] =
await Promise.all([
fetchListingPublishOptions(),
fetchListingSalePriceConfig(),
fetchListingPublishAgreements(),
fetchQualityOwnerStatus().catch(() => null),
])
publishOptions.value = nextPublishOptions
salePriceConfig.value = nextSalePriceConfig
publishAgreements.value = nextPublishAgreements
qualityOwnerStatus.value = nextQualityOwner
// 默认数字都是0
for (const item of nextPublishOptions.quantity_items) {
@@ -203,6 +218,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
publishOptions.value = emptyListingPublishOptions
salePriceConfig.value = emptyListingSalePriceConfig
publishAgreements.value = emptyListingPublishAgreements
qualityOwnerStatus.value = null
}
}
@@ -326,6 +342,8 @@ export function usePublishForm(options: UsePublishFormOptions) {
ban_record: stringValue(summary.ban_record),
common_regions: stringArray(summary.common_regions),
depositAmountYuan: listing.deposit_amount_cent > 0 ? listing.deposit_amount_cent / 100 : '',
is_premium: listing.is_premium === true,
compensation_plan: listing.compensation_plan === 'insurance' ? 'insurance' : 'normal',
remark: listing.description || stringValue(summary.remark),
})
@@ -662,6 +680,8 @@ export function usePublishForm(options: UsePublishFormOptions) {
screenshot_urls: pricing.screenshotUrls.value,
price_cent: yuanToCent(pricing.calculatedFinalPrice.value),
deposit_amount_cent: yuanToCent(Number(form.depositAmountYuan)),
is_premium: form.is_premium,
compensation_plan: form.compensation_plan,
agreed_virtual_asset_sale: virtualAssetSaleAgreementChecked.value,
agreed_seller_agreement: sellerAgreementChecked.value,
}
@@ -758,6 +778,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
if (!pricing.calculatedFinalPrice.value) return '请完善币数、保险、体力和负重后再发布'
if (!Number.isFinite(pricing.calculatedFinalPrice.value))
return '发布价格计算异常,请检查填写内容'
if (form.is_premium && !isQualityOwner.value) return '仅优质号主可以发布到极品专区'
if (!virtualAssetSaleAgreementChecked.value || !sellerAgreementChecked.value) {
return '请先阅读并勾选两份发布协议'
}
@@ -932,6 +953,10 @@ export function usePublishForm(options: UsePublishFormOptions) {
loading,
uploading,
publishAgreements,
qualityOwnerStatus,
isQualityOwner,
insuranceFeeBps,
insuranceFeePercent,
virtualAssetSaleAgreementChecked,
sellerAgreementChecked,
passwordAndDeviceConfirmed,
@@ -870,6 +870,100 @@
line-height: 1.5;
}
.premium-coverage-card {
display: grid;
gap: 12px;
margin: 4px 0 12px;
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
}
.premium-zone-row,
.coverage-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.premium-zone-row strong,
.coverage-heading strong {
display: block;
color: #20242a;
font-size: 14px;
font-weight: 800;
}
.premium-zone-row span,
.coverage-heading span {
display: block;
margin-top: 3px;
color: #858c96;
font-size: 11px;
line-height: 1.45;
}
.quality-owner-link {
justify-self: start;
padding: 0;
border: none;
background: transparent;
color: #7c3aed;
font-size: 12px;
font-weight: 800;
}
.coverage-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.coverage-option {
display: grid;
align-content: start;
gap: 5px;
min-height: 82px;
padding: 11px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #20242a;
text-align: left;
}
.coverage-option strong {
font-size: 13px;
font-weight: 800;
}
.coverage-option span {
color: #858c96;
font-size: 10px;
line-height: 1.45;
}
.coverage-option em {
color: #7c3aed;
font-size: 10px;
font-style: normal;
font-weight: 800;
}
.coverage-option.active {
border-color: #7c3aed;
background: #faf5ff;
}
.coverage-note {
margin: 0;
color: #858c96;
font-size: 10px;
line-height: 1.55;
}
.publish-agreement-card {
display: grid;
gap: 10px;
@@ -26,6 +26,9 @@ const {
sellerAgreementChecked,
passwordAndDeviceConfirmed,
canPublishAfterAgreements,
isQualityOwner,
qualityOwnerStatus,
insuranceFeePercent,
showBanRecordRiskHint,
unlockDemulanEnabled,
nextSeasonPassEnabled,
@@ -821,6 +824,61 @@ const screenshotGuides: Record<string, string> = {
</div>
<p class="price-breakdown-hint">卖家价格 = 纯币基础价 + 额外消耗品。</p>
<div class="premium-coverage-card">
<div class="premium-zone-row">
<div>
<strong>极品专区</strong>
<span>
{{
isQualityOwner
? '优质号主专享曝光专区'
: `保证金 ¥${formatMoney(
(qualityOwnerStatus?.required_deposit_cent || 20000) / 100
)}`
}}
</span>
</div>
<van-switch v-model="form.is_premium" :disabled="!isQualityOwner" size="22px" />
</div>
<button
v-if="!isQualityOwner"
type="button"
class="quality-owner-link"
@click="router.push('/m/profile')"
>
前往个人中心开通优质号主
</button>
<div class="coverage-heading">
<strong>选择赔付方案</strong>
<span>服务费按实际到手租金结算扣除</span>
</div>
<div class="coverage-option-grid">
<button
type="button"
class="coverage-option"
:class="{ active: form.compensation_plan === 'normal' }"
@click="form.compensation_plan = 'normal'"
>
<strong>普通赔付</strong>
<span>租金 + 押金规则赔付</span>
</button>
<button
type="button"
class="coverage-option"
:class="{ active: form.compensation_plan === 'insurance' }"
@click="form.compensation_plan = 'insurance'"
>
<strong>无忧租号保险</strong>
<span>服务费 {{ insuranceFeePercent.toFixed(1) }}%</span>
<em v-if="isQualityOwner && form.is_premium">极品专享费率</em>
</button>
</div>
<p class="coverage-note">
无忧租号保险在普通赔付基础上额外保障账号市场价值;选择后按号主实际到手租金扣除服务费。
</p>
</div>
<van-field
v-model="form.remark"
label="备注"
@@ -1012,6 +1012,108 @@
background-color: var(--color-orange-dark);
}
.premium-coverage-panel {
display: grid;
gap: 14px;
margin-top: 18px;
padding: 18px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #f8fafc;
}
.premium-zone-row,
.coverage-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.premium-zone-row > div,
.coverage-heading {
min-width: 0;
}
.premium-zone-row strong,
.coverage-heading strong {
display: block;
color: #1f2937;
font-size: 15px;
font-weight: 900;
}
.premium-zone-row span,
.coverage-heading span {
color: #7b8492;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
}
.quality-owner-link {
justify-self: start;
padding: 0;
border: none;
background: transparent;
color: #7c3aed;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.coverage-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.coverage-option {
display: grid;
gap: 6px;
min-height: 92px;
padding: 16px 18px;
border: 1px solid #e5e7eb;
border-radius: 9px;
background: #fff;
color: #1f2937;
text-align: left;
cursor: pointer;
}
.coverage-option strong {
font-size: 16px;
font-weight: 900;
}
.coverage-option span {
color: #6b7280;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
}
.coverage-option em {
color: #7c3aed;
font-size: 11px;
font-style: normal;
font-weight: 800;
}
.coverage-option.active {
border-color: #7c3aed;
background: #faf5ff;
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.08);
}
.coverage-note {
margin: 0;
color: #7b8492;
font-size: 12px;
font-weight: 600;
line-height: 1.7;
}
@media (max-width: 1120px) {
.publish-layout {
grid-template-columns: 1fr;
@@ -1094,4 +1196,8 @@
.region-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.coverage-option-grid {
grid-template-columns: 1fr;
}
}
@@ -22,7 +22,11 @@ const {
formatNumber,
loading,
uploading,
router,
publishAgreements,
isQualityOwner,
qualityOwnerStatus,
insuranceFeePercent,
virtualAssetSaleAgreementChecked,
sellerAgreementChecked,
passwordAndDeviceConfirmed,
@@ -653,6 +657,65 @@ function selectDailyLoss(value: string | number) {
</div>
</div>
<div class="premium-coverage-panel">
<div class="premium-zone-row">
<div>
<strong>极品专区</strong>
<span>
{{
isQualityOwner
? '优质号主专享曝光专区'
: `缴纳 ¥${formatMoney(
(qualityOwnerStatus?.required_deposit_cent || 20000) / 100
)} 保证金后可开通优质号主`
}}
</span>
</div>
<el-switch
v-model="form.is_premium"
:disabled="!isQualityOwner"
active-text="发布到极品专区"
/>
</div>
<button
v-if="!isQualityOwner"
type="button"
class="quality-owner-link"
@click="router.push('/profile')"
>
前往个人中心开通优质号主
</button>
<div class="coverage-heading">
<strong>选择赔付方案</strong>
<span>服务费按号主实际到手租金计算,结算时自动扣除</span>
</div>
<div class="coverage-option-grid">
<button
type="button"
class="coverage-option"
:class="{ active: form.compensation_plan === 'normal' }"
@click="form.compensation_plan = 'normal'"
>
<strong>普通赔付</strong>
<span>按平台规则赔付租金与押金</span>
</button>
<button
type="button"
class="coverage-option"
:class="{ active: form.compensation_plan === 'insurance' }"
@click="form.compensation_plan = 'insurance'"
>
<strong>无忧租号保险</strong>
<span> 服务费 {{ insuranceFeePercent.toFixed(1) }}%,额外保障账号市场价值 </span>
<em v-if="isQualityOwner && form.is_premium">极品专区专享费率</em>
</button>
</div>
<p class="coverage-note">
普通赔付:账号封禁、追回、洗号等情况最高赔付全额租金与押金。无忧租号保险:在普通赔付基础上,额外按账号市场价值赔付;选择后按实际到手租金扣除服务费。
</p>
</div>
<label class="input-block remark">
<span>备注</span>
<el-input
@@ -50,7 +50,7 @@ const walletMetrics = computed(() => {
{
label: '冻结余额',
value: formatCentWithSymbol(account.value.frozen_balance_cent),
hint: '当前暂无冻结资金使用',
hint: '提现冻结及优质号主保证金',
icon: Lock,
tone: 'frozen',
},
+16
View File
@@ -356,6 +356,22 @@
white-space: nowrap;
}
.pc-quality-owner-badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
padding: 0 7px;
border: 1px solid #d8b4fe;
border-radius: 999px;
background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%);
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
}
.pc-realname-badge {
display: inline-flex;
align-items: center;
+6 -1
View File
@@ -231,7 +231,12 @@ async function handleSupportClick() {
<span v-else class="pc-avatar-text">{{ session.avatarText }}</span>
</span>
<span class="pc-user-name">{{ session.displayName }}</span>
<span v-if="siteStore.isDistribution" class="pc-growth-badge is-distribution">
<span
v-if="session.isQualityOwner && !siteStore.isDistribution"
class="pc-quality-owner-badge"
>优质号主</span
>
<span v-else-if="siteStore.isDistribution" class="pc-growth-badge is-distribution">
会员专享
</span>
<span v-else class="pc-growth-badge" :class="`is-${growthBadgeTone}`">
+4
View File
@@ -28,6 +28,10 @@ export type PublishForm = {
ban_record: string
common_regions: string[]
depositAmountYuan: number | ''
/** 是否发布到极品专区(仅优质号主可用) */
is_premium: boolean
/** 普通赔付或不额外购买无忧租号保险 */
compensation_plan: 'normal' | 'insurance'
remark: string
}
+2
View File
@@ -28,6 +28,8 @@ export const AUDIT_ACTION_OPTIONS: AuditOption[] = [
{ value: 'admin_user.manual_realname', label: '人工实名', group: '用户', highRisk: true },
{ value: 'admin_user.revoke_realname', label: '撤销实名', group: '用户', highRisk: true },
{ value: 'admin_user.wallet_adjust', label: '调整用户余额', group: '用户', highRisk: true },
{ value: 'admin_user.set_quality_owner', label: '设置优质号主', group: '用户', highRisk: true },
{ value: 'quality_owner.activate', label: '缴纳优质号主保证金', group: '用户', highRisk: true },
// 商品
{ value: 'listing.admin_offline', label: '后台下架商品', group: '商品', highRisk: true },
{ value: 'listing.mark_abnormal', label: '商品标记异常', group: '商品', highRisk: true },
+8 -1
View File
@@ -10,7 +10,14 @@ export interface AuthTokenPair {
const userKeys = {
accessToken: 'access_token',
refreshToken: 'refresh_token',
profile: ['user_id', 'phone', 'nickname', 'avatar_url', 'realname_status'],
profile: [
'user_id',
'phone',
'nickname',
'avatar_url',
'realname_status',
'quality_owner_status',
],
}
const adminKeys = {
@@ -157,6 +157,8 @@ const walletBizTypeMap: Record<string, string> = {
admin_debit: '管理员扣款',
admin_pickup_settle: '提号结算',
admin_pickup_adjustment: '提号结算调整',
quality_owner_deposit: '优质号主保证金',
quality_owner_deposit_refund: '优质号主保证金退回',
// 营销
invite_rebate: '邀请返利',
// 分销
+6
View File
@@ -36,6 +36,7 @@ export const useSessionStore = defineStore('session', {
realnameStatus: localStorage.getItem('realname_status') || 'unknown',
renterGrowthPoints: Number(localStorage.getItem('renter_growth_points') || 0),
renterGrowthLevel: localStorage.getItem('renter_growth_level') || 'normal',
qualityOwnerStatus: localStorage.getItem('quality_owner_status') || 'none',
_loadingMe: false, // 防止重复调用
}),
getters: {
@@ -44,6 +45,7 @@ export const useSessionStore = defineStore('session', {
state.nickname || (state.phone ? `用户${state.phone.slice(-6)}` : `用户${state.userId || 0}`),
avatarText: state => (state.nickname || state.phone || 'U').slice(0, 1),
renterGrowthLevelName: state => renterGrowthLevelName(state.renterGrowthLevel),
isQualityOwner: state => state.qualityOwnerStatus === 'active',
},
actions: {
async login(phone: string, code: string) {
@@ -104,6 +106,7 @@ export const useSessionStore = defineStore('session', {
this.realnameStatus = 'unknown'
this.renterGrowthPoints = 0
this.renterGrowthLevel = 'normal'
this.qualityOwnerStatus = 'none'
clearAuthStorage('user')
},
syncFromStorage() {
@@ -116,6 +119,7 @@ export const useSessionStore = defineStore('session', {
this.realnameStatus = localStorage.getItem('realname_status') || 'unknown'
this.renterGrowthPoints = Number(localStorage.getItem('renter_growth_points') || 0)
this.renterGrowthLevel = localStorage.getItem('renter_growth_level') || 'normal'
this.qualityOwnerStatus = localStorage.getItem('quality_owner_status') || 'none'
},
applySession(user: AuthUser, accessToken: string, refreshToken: string) {
this.token = accessToken
@@ -143,6 +147,8 @@ export const useSessionStore = defineStore('session', {
localStorage.setItem('renter_growth_points', String(this.renterGrowthPoints))
this.renterGrowthLevel = user.renter_growth_level || 'normal'
localStorage.setItem('renter_growth_level', this.renterGrowthLevel)
this.qualityOwnerStatus = user.quality_owner_status || 'none'
localStorage.setItem('quality_owner_status', this.qualityOwnerStatus)
},
},
})