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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`">
|
||||
|
||||
@@ -28,6 +28,10 @@ export type PublishForm = {
|
||||
ban_record: string
|
||||
common_regions: string[]
|
||||
depositAmountYuan: number | ''
|
||||
/** 是否发布到极品专区(仅优质号主可用) */
|
||||
is_premium: boolean
|
||||
/** 普通赔付或不额外购买无忧租号保险 */
|
||||
compensation_plan: 'normal' | 'insurance'
|
||||
remark: string
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: '邀请返利',
|
||||
// 分销
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user