实现租客成长等级并优化展示
This commit is contained in:
@@ -13,6 +13,9 @@ export interface AdminUserItem {
|
||||
deposit_free_quota_cent: number
|
||||
deposit_free_used_cent: number
|
||||
deposit_free_remaining_cent: number
|
||||
renter_growth_points: number
|
||||
renter_growth_level: string
|
||||
renter_growth_level_name: string
|
||||
available_balance_cent: number
|
||||
frozen_balance_cent: number
|
||||
status: UserStatus
|
||||
|
||||
@@ -266,7 +266,17 @@ const ownerLossPriceCent = computed(() => {
|
||||
const hasOwnerRentBreakdown = computed(() => ownerCoinBasePriceCent.value !== null)
|
||||
const fundSplitRows = computed(() => {
|
||||
if (!order.value) return []
|
||||
const rows: FundSplitRow[] = [{ label: '租客租金', amountCent: order.value.rent_amount_cent }]
|
||||
const rows: FundSplitRow[] = []
|
||||
if (Number(order.value.rent_discount_amount_cent || 0) > 0) {
|
||||
rows.push(
|
||||
{ label: '原始租金', amountCent: order.value.rent_original_amount_cent },
|
||||
{
|
||||
label: `${order.value.renter_growth_level_name || '成长等级'}优惠`,
|
||||
amountCent: -Number(order.value.rent_discount_amount_cent || 0),
|
||||
}
|
||||
)
|
||||
}
|
||||
rows.push({ label: '租客租金', amountCent: order.value.rent_amount_cent })
|
||||
if (hasOwnerRentBreakdown.value) {
|
||||
rows.push(
|
||||
{ label: '号主纯币价格', amountCent: ownerCoinBasePriceCent.value },
|
||||
@@ -843,7 +853,12 @@ function paymentPaidAt(record: AdminPayment) {
|
||||
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')">
|
||||
{{ resetActionLabel }}
|
||||
</el-button>
|
||||
<el-button v-if="canPlatformHandoff" type="primary" plain @click="openPlatformHandoff">
|
||||
<el-button
|
||||
v-if="canPlatformHandoff"
|
||||
class="platform-handoff-button"
|
||||
type="primary"
|
||||
@click="openPlatformHandoff"
|
||||
>
|
||||
{{ platformHandoffAction?.label || '客服代交接' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
@@ -1551,6 +1566,27 @@ function paymentPaidAt(record: AdminPayment) {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.platform-handoff-button {
|
||||
min-width: 96px;
|
||||
border-color: #2563eb;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 6px 14px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.platform-handoff-button:hover,
|
||||
.platform-handoff-button:focus {
|
||||
border-color: #1d4ed8;
|
||||
background: #1d4ed8;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.platform-handoff-button:active {
|
||||
border-color: #1e40af;
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
.order-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr);
|
||||
|
||||
@@ -312,6 +312,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="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" effect="light">{{ row.renter_growth_level_name || '普通' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成长积分" width="100" align="right">
|
||||
<template #default="{ row }">{{ Number(row.renter_growth_points || 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可用余额" width="108" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="balance-available">¥{{ moneyCent(row.available_balance_cent) }}</span>
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface AuthUser {
|
||||
realname_status: RealnameStatusValue
|
||||
risk_status: RiskStatus
|
||||
credit_score: number
|
||||
renter_growth_points?: number
|
||||
renter_growth_level?: string
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
|
||||
@@ -104,11 +104,16 @@ const settingsGroups = [
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
if (!isLoggedIn.value) {
|
||||
router.replace({ path: '/m/login', query: { redirect: route.fullPath } })
|
||||
} else {
|
||||
loadBalance()
|
||||
try {
|
||||
await session.loadMe()
|
||||
} catch {
|
||||
// 保留本地会话展示,避免个人中心因网络短暂异常整页不可用。
|
||||
}
|
||||
void loadBalance()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -348,6 +353,17 @@ function resolveAvatarURL(url: string | undefined | null) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="growth-card">
|
||||
<div>
|
||||
<span>租客成长等级</span>
|
||||
<strong>{{ session.renterGrowthLevelName }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>当前积分</span>
|
||||
<strong>{{ session.renterGrowthPoints }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 余额卡片 - 银行卡质感暗色卡片 -->
|
||||
<div class="balance-card">
|
||||
<div class="balance-row">
|
||||
@@ -780,6 +796,39 @@ function resolveAvatarURL(url: string | undefined | null) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.growth-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin: 12px 16px 0;
|
||||
}
|
||||
|
||||
.growth-card > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(243, 244, 246, 0.9);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.02),
|
||||
0 1px 4px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.growth-card span {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.growth-card strong {
|
||||
color: #7c3aed;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* ========== Balance Card ========== */
|
||||
.balance-card {
|
||||
margin: 12px 16px 14px;
|
||||
|
||||
@@ -50,8 +50,10 @@ const realnameTone = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!session.phone) {
|
||||
try {
|
||||
await session.loadMe()
|
||||
} catch {
|
||||
// 保留本地会话展示,避免资料页因网络短暂异常整页不可用。
|
||||
}
|
||||
resetForm()
|
||||
})
|
||||
@@ -258,6 +260,14 @@ async function savePassword() {
|
||||
<dt>实名状态</dt>
|
||||
<dd :class="`is-${realnameTone}`">{{ realnameStatusLabel(session.realnameStatus) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>租客等级</dt>
|
||||
<dd class="growth-level">{{ session.renterGrowthLevelName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>成长积分</dt>
|
||||
<dd>{{ session.renterGrowthPoints }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button class="realname-shortcut" type="button" @click="router.push('/realname')">
|
||||
<span>查看实名认证</span>
|
||||
@@ -604,6 +614,10 @@ async function savePassword() {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.account-card dd.growth-level {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.realname-shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -27,6 +27,13 @@ export interface Order {
|
||||
deposit_original_amount_cent: number
|
||||
deposit_waived_amount_cent: number
|
||||
platform_fee_cent?: number
|
||||
rent_original_amount_cent?: number
|
||||
rent_discount_amount_cent?: number
|
||||
renter_growth_level?: string
|
||||
renter_growth_level_name?: string
|
||||
renter_discount_bps?: number
|
||||
growth_points_awarded?: number
|
||||
growth_points_awarded_at?: string
|
||||
account_snapshot?: Record<string, unknown>
|
||||
listing_snapshot?: string
|
||||
checkout_info?: string
|
||||
|
||||
@@ -188,6 +188,14 @@ async function copyListingCode() {
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">{{ orderAmountLabel }}</span>
|
||||
<strong class="meta-value accent">¥{{ money(orderRentDisplayAmount) }}</strong>
|
||||
<span
|
||||
v-if="isRenter && Number(order.rent_discount_amount_cent || 0) > 0"
|
||||
class="meta-note"
|
||||
>
|
||||
{{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{
|
||||
money(amountYuan(order.rent_discount_amount_cent))
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isOwner && ownerIncomeDisplayAmount !== null" class="meta-item">
|
||||
<span class="meta-label">{{ ownerIncomeLabel }}</span>
|
||||
|
||||
@@ -388,6 +388,14 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
<div class="metric-card highlight">
|
||||
<span class="metric-label">{{ orderAmountLabel }}</span>
|
||||
<strong class="metric-value amount">¥{{ money(orderRentDisplayAmount) }}</strong>
|
||||
<span
|
||||
v-if="isRenter && Number(order.rent_discount_amount_cent || 0) > 0"
|
||||
class="metric-note"
|
||||
>
|
||||
{{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{
|
||||
money(amountYuan(order.rent_discount_amount_cent))
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isOwner && ownerIncomeDisplayAmount !== null" class="metric-card income">
|
||||
<span class="metric-label">{{ ownerIncomeLabel }}</span>
|
||||
|
||||
@@ -70,6 +70,19 @@ const realnameBadge = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const growthBadgeTone = computed(() => {
|
||||
switch (session.renterGrowthLevel) {
|
||||
case 'peak':
|
||||
return 'peak'
|
||||
case 'diamond':
|
||||
return 'diamond'
|
||||
case 'platinum':
|
||||
return 'platinum'
|
||||
default:
|
||||
return 'normal'
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [route.path, route.query.keyword],
|
||||
() => {
|
||||
@@ -198,6 +211,9 @@ async function handleSupportClick() {
|
||||
<span v-else class="pc-avatar-text">{{ session.avatarText }}</span>
|
||||
</span>
|
||||
<span class="pc-user-name">{{ session.displayName }}</span>
|
||||
<span class="pc-growth-badge" :class="`is-${growthBadgeTone}`">
|
||||
{{ session.renterGrowthLevelName }}
|
||||
</span>
|
||||
<span class="pc-realname-badge" :class="`is-${realnameBadge.tone}`">
|
||||
{{ realnameBadge.text }}
|
||||
</span>
|
||||
@@ -209,6 +225,17 @@ async function handleSupportClick() {
|
||||
<Transition name="dropdown">
|
||||
<div v-if="showUserDropdown" class="pc-user-dropdown-container">
|
||||
<div class="pc-user-dropdown">
|
||||
<div class="dropdown-growth-panel">
|
||||
<div>
|
||||
<span>租客成长等级</span>
|
||||
<strong>{{ session.renterGrowthLevelName }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>当前积分</span>
|
||||
<strong>{{ session.renterGrowthPoints }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-basic-grid">
|
||||
<RouterLink
|
||||
class="dropdown-basic-item"
|
||||
@@ -664,6 +691,40 @@ async function handleSupportClick() {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pc-growth-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 42px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pc-growth-badge.is-normal {
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.pc-growth-badge.is-platinum {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.pc-growth-badge.is-diamond {
|
||||
background: #ecfeff;
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.pc-growth-badge.is-peak {
|
||||
background: #fff7ed;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.pc-realname-badge.is-verified,
|
||||
.dropdown-status.is-verified {
|
||||
background: #ecfdf3;
|
||||
@@ -733,6 +794,40 @@ async function handleSupportClick() {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-growth-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dropdown-growth-panel > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
min-height: 62px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.dropdown-growth-panel span {
|
||||
color: #8b9cb5;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dropdown-growth-panel strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #7c3aed;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -989,6 +1084,10 @@ async function handleSupportClick() {
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.pc-growth-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pc-realname-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,16 @@ import {
|
||||
} from '@/shared/utils/authStorage'
|
||||
import { loginWithPassword, registerWithPassword } from '@/features/auth/api/auth'
|
||||
|
||||
function renterGrowthLevelName(level: string) {
|
||||
const map: Record<string, string> = {
|
||||
normal: '普通',
|
||||
platinum: '铂金',
|
||||
diamond: '钻石',
|
||||
peak: '巅峰',
|
||||
}
|
||||
return map[level] || '普通'
|
||||
}
|
||||
|
||||
export const useSessionStore = defineStore('session', {
|
||||
state: () => ({
|
||||
token: getAccessToken('user'),
|
||||
@@ -18,6 +28,8 @@ export const useSessionStore = defineStore('session', {
|
||||
nickname: localStorage.getItem('nickname') || '',
|
||||
avatarUrl: localStorage.getItem('avatar_url') || '',
|
||||
realnameStatus: localStorage.getItem('realname_status') || 'unknown',
|
||||
renterGrowthPoints: Number(localStorage.getItem('renter_growth_points') || 0),
|
||||
renterGrowthLevel: localStorage.getItem('renter_growth_level') || 'normal',
|
||||
_loadingMe: false, // 防止重复调用
|
||||
}),
|
||||
getters: {
|
||||
@@ -25,6 +37,7 @@ export const useSessionStore = defineStore('session', {
|
||||
displayName: state =>
|
||||
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),
|
||||
},
|
||||
actions: {
|
||||
async login(phone: string, code: string) {
|
||||
@@ -78,6 +91,8 @@ export const useSessionStore = defineStore('session', {
|
||||
this.nickname = ''
|
||||
this.avatarUrl = ''
|
||||
this.realnameStatus = 'unknown'
|
||||
this.renterGrowthPoints = 0
|
||||
this.renterGrowthLevel = 'normal'
|
||||
clearAuthStorage('user')
|
||||
},
|
||||
syncFromStorage() {
|
||||
@@ -88,6 +103,8 @@ export const useSessionStore = defineStore('session', {
|
||||
this.nickname = localStorage.getItem('nickname') || ''
|
||||
this.avatarUrl = localStorage.getItem('avatar_url') || ''
|
||||
this.realnameStatus = localStorage.getItem('realname_status') || 'unknown'
|
||||
this.renterGrowthPoints = Number(localStorage.getItem('renter_growth_points') || 0)
|
||||
this.renterGrowthLevel = localStorage.getItem('renter_growth_level') || 'normal'
|
||||
},
|
||||
applySession(user: AuthUser, accessToken: string, refreshToken: string) {
|
||||
this.token = accessToken
|
||||
@@ -111,6 +128,10 @@ export const useSessionStore = defineStore('session', {
|
||||
localStorage.setItem('avatar_url', user.avatar_url)
|
||||
this.realnameStatus = user.realname_status
|
||||
localStorage.setItem('realname_status', user.realname_status)
|
||||
this.renterGrowthPoints = Number(user.renter_growth_points || 0)
|
||||
localStorage.setItem('renter_growth_points', String(this.renterGrowthPoints))
|
||||
this.renterGrowthLevel = user.renter_growth_level || 'normal'
|
||||
localStorage.setItem('renter_growth_level', this.renterGrowthLevel)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user