feat(user): 支持后台调整等级并限制极品专区可见性

This commit is contained in:
yml2213
2026-11-05 12:02:48 +08:00
parent 09f244a93d
commit 0d558bdce0
35 changed files with 934 additions and 131 deletions
@@ -20,6 +20,8 @@ export interface AdminUserItem {
points_available: number
renter_growth_level: string
renter_growth_level_name: string
renter_growth_level_override: string
renter_growth_level_manual: boolean
available_balance_cent: number
frozen_balance_cent: number
quality_owner_status: 'none' | 'active' | string
@@ -47,6 +49,11 @@ export interface AdminGrowthPointsAdjustPayload {
reason: string
}
export interface AdminGrowthLevelAdjustPayload {
target_level: string
reason: string
}
export interface AdminUserQuery {
keyword?: string
status?: '' | UserStatus
@@ -100,6 +107,17 @@ export async function adjustAdminUserGrowthPoints(
return data.data
}
export async function adjustAdminUserGrowthLevel(
id: number,
payload: AdminGrowthLevelAdjustPayload
) {
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
`/admin/users/${id}/growth-level`,
payload
)
return data.data
}
export async function revokeAdminUserRealname(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
`/admin/users/${id}/revoke-realname`,
@@ -59,6 +59,18 @@
font-variant-numeric: tabular-nums;
}
.user-detail__metrics strong em {
display: inline-flex;
margin-left: 5px;
padding: 1px 6px;
border-radius: 8px;
background: #fff7ed;
color: #c2410c;
font-size: 10px;
font-style: normal;
vertical-align: middle;
}
.tab-toolbar {
display: flex;
align-items: center;
@@ -464,7 +464,10 @@ function goPayments() {
</div>
<div>
<span>成长等级</span>
<strong>{{ user.renter_growth_level_name || '普通' }}</strong>
<strong>
{{ user.renter_growth_level_name || '普通' }}
<em v-if="user.renter_growth_level_manual">手动</em>
</strong>
</div>
<div>
<span>注册时间</span>
@@ -111,6 +111,17 @@
font-variant-numeric: tabular-nums;
}
.growth-level-cell {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
}
.level-reason-item {
margin-top: 16px;
}
.wallet-balance-cards {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -5,6 +5,7 @@ import { Search } from '@element-plus/icons-vue'
import { computed, reactive, ref } from 'vue'
import {
adjustAdminUserGrowthLevel,
adjustAdminUserGrowthPoints,
adjustAdminUserWallet,
fetchAdminUsers,
@@ -17,6 +18,7 @@ import {
type AdminUserItem,
type AdminUserQuery,
} from '@/features/admin/api/adminUsers'
import { defaultRenterGrowthConfig } from '@/features/admin/api/renterGrowth'
import { centToYuan, formatCent, yuanToCent } from '@/shared/utils/money'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { realnameStatusLabel, userStatusLabel } from '@/shared/utils/statusLabels'
@@ -28,6 +30,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 canAdjustGrowthLevel = computed(() => adminSession.hasPermission('user:growth_level'))
const canSetQualityOwner = computed(() => adminSession.hasPermission('user:quality_owner'))
const canViewUserDetail = computed(
() =>
@@ -44,6 +47,7 @@ const realnameUser = ref<AdminUserItem | null>(null)
const manualRealnameUser = ref<AdminUserItem | null>(null)
const walletUser = ref<AdminUserItem | null>(null)
const growthUser = ref<AdminUserItem | null>(null)
const growthLevelUser = ref<AdminUserItem | null>(null)
const freezeReason = ref('')
const revokeRealnameReason = ref('')
const quotaAmount = ref(0)
@@ -51,6 +55,11 @@ const growthForm = reactive({
targetPoints: 0,
reason: '',
})
const growthLevelForm = reactive({
targetLevel: '',
reason: '',
})
const growthLevelOptions = defaultRenterGrowthConfig.levels
const manualRealnameForm = reactive({
name: '',
idNo: '',
@@ -114,6 +123,12 @@ function openGrowthPoints(row: AdminUserItem) {
growthForm.reason = ''
}
function openGrowthLevel(row: AdminUserItem) {
growthLevelUser.value = row
growthLevelForm.targetLevel = row.renter_growth_level_override || ''
growthLevelForm.reason = ''
}
function openWalletAdjust(row: AdminUserItem) {
walletUser.value = row
walletForm.direction = 'out'
@@ -182,6 +197,49 @@ async function handleAdjustGrowthPoints() {
}
}
async function handleAdjustGrowthLevel() {
if (!growthLevelUser.value) return
const targetLevel = growthLevelForm.targetLevel.trim()
if (targetLevel === (growthLevelUser.value.renter_growth_level_override || '')) {
ElMessage.warning('目标等级与当前手动等级相同')
return
}
if (growthLevelForm.reason.trim().length < 2) {
ElMessage.warning('请填写至少 2 个字的调整原因')
return
}
const targetName = targetLevel
? growthLevelOptions.find(level => level.code === targetLevel)?.name || targetLevel
: '按积分自动计算'
try {
await ElMessageBox.confirm(
`确认将用户 ${growthLevelUser.value.phone || growthLevelUser.value.id} 的成长等级调整为「${targetName}」?\n手动设置后会锁定等级,后续积分变动不会自动覆盖。`,
'确认调整等级',
{
type: 'warning',
confirmButtonText: '确认调整',
cancelButtonText: '取消',
}
)
} catch {
return
}
submitting.value = true
try {
await adjustAdminUserGrowthLevel(growthLevelUser.value.id, {
target_level: targetLevel,
reason: growthLevelForm.reason.trim(),
})
ElMessage.success('成长等级已更新')
growthLevelUser.value = null
await loadUsers()
} catch (error) {
ElMessage.error(readError(error, '调整成长等级失败'))
} finally {
submitting.value = false
}
}
async function handleFreeze() {
if (!activeUser.value) return
submitting.value = true
@@ -435,9 +493,19 @@ function moneyCent(value: number | string | undefined) {
</el-table-column>
<el-table-column label="成长等级" width="100">
<template #default="{ row }">
<el-tag type="success" effect="light">{{
row.renter_growth_level_name || '普通'
}}</el-tag>
<div class="growth-level-cell">
<el-tag type="success" effect="light">{{
row.renter_growth_level_name || '普通'
}}</el-tag>
<el-tag
v-if="row.renter_growth_level_manual"
type="warning"
effect="plain"
size="small"
>
手动
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="成长积分" width="100" align="right">
@@ -475,7 +543,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="470" fixed="right" align="right">
<el-table-column label="操作" width="520" fixed="right" align="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
@@ -505,6 +573,15 @@ function moneyCent(value: number | string | undefined) {
>
积分
</el-button>
<el-button
v-if="canAdjustGrowthLevel"
text
type="primary"
size="small"
@click="openGrowthLevel(row)"
>
等级
</el-button>
<el-button text type="primary" size="small" @click="openDepositQuota(row)">
人工免押
</el-button>
@@ -752,6 +829,60 @@ function moneyCent(value: number | string | undefined) {
</template>
</el-dialog>
<el-dialog
:model-value="!!growthLevelUser"
title="调整成长等级"
width="540px"
@update:model-value="growthLevelUser = null"
>
<div v-if="growthLevelUser" class="dialog-body growth-dialog">
<p>
<strong>{{ growthLevelUser.phone }}</strong> · {{ growthLevelUser.nickname || '-' }} ·
当前
{{ growthLevelUser.renter_growth_level_name }}
{{ growthLevelUser.renter_growth_level_manual ? '(手动锁定)' : '(自动计算)' }}
</p>
<div class="growth-current">
<span>当前成长积分</span>
<strong>{{ growthLevelUser.renter_growth_points }}</strong>
</div>
<el-form label-position="top" @submit.prevent>
<el-form-item label="目标等级">
<el-select v-model="growthLevelForm.targetLevel" class="full-control">
<el-option label="恢复按积分自动计算" value="" />
<el-option
v-for="level in growthLevelOptions"
:key="level.code"
:label="level.name"
:value="level.code"
/>
</el-select>
</el-form-item>
<el-alert
type="warning"
:closable="false"
title="手动等级会锁定,后续积分变动不会自动覆盖;选择“恢复按积分自动计算”可解除锁定。操作会写入审计日志。"
/>
<el-form-item label="调整原因(必填)" class="level-reason-item">
<el-input
v-model="growthLevelForm.reason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="例如:特殊用户授权、客服补偿或修正异常等级"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button :disabled="submitting" @click="growthLevelUser = null">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleAdjustGrowthLevel">
确认调整
</el-button>
</template>
</el-dialog>
<el-dialog
:model-value="!!walletUser"
title="余额管理"
@@ -45,8 +45,12 @@ import HomeFilters from '../components/HomeFilters.vue'
import HomeZones from '../components/HomeZones.vue'
import ListingCard from '../components/ListingCard.vue'
import { mapDistributionHomeBanners, useDistributionSiteStore } from '@/stores/distributionSite'
import { useSessionStore } from '@/stores/session'
import { canViewPremiumZone } from '@/shared/utils/growthLevel'
const siteStore = useDistributionSiteStore()
const session = useSessionStore()
const canViewPremium = computed(() => canViewPremiumZone(session.renterGrowthLevel))
const announcements = ref<string[]>(defaultHomeAnnouncements)
const displayAnnouncements = computed(() => {
@@ -164,6 +168,14 @@ const activeZone = ref('all')
const route = useRoute()
const router = useRouter()
watch(canViewPremium, allowed => {
if (!allowed && activeZone.value === 'premium') {
activeZone.value = 'all'
}
pcHomeCache.clearList()
void loadListingsPage(true)
})
const {
filters,
regionOptions,
@@ -191,6 +203,9 @@ const {
restoreSnapshot,
getSnapshot,
} = useListingQuery(filters, sortBy, activeZone)
const visibleListings = computed(() =>
canViewPremium.value ? listings.value : listings.value.filter(item => !item.is_premium)
)
watch(
() => route.query,
@@ -206,69 +221,78 @@ watch(
}
)
const statCards = computed(() => [
{
label: '优质号主精选',
value: `${zoneCount('premium')}`,
hint: '认证优质号主 · 极品专区',
},
{ label: '可租账号', value: `${totalListings.value}`, hint: '当前筛选结果' },
{
label: '高哈夫币',
value: `${zoneCount('highCoin')}`,
hint: '100M 以上',
},
{
label: '账密登录',
value: `${zoneCount('password')}`,
hint: '交接更快',
},
])
const statCards = computed(() => {
const cards = [
{
key: 'premium',
label: '优质号主精选',
value: `${zoneCount('premium')}`,
hint: '认证优质号主 · 极品专区',
},
{ key: 'all', label: '可租账号', value: `${totalListings.value}`, hint: '当前筛选结果' },
{
key: 'highCoin',
label: '高哈夫币',
value: `${zoneCount('highCoin')}`,
hint: '100M 以上',
},
{
key: 'password',
label: '账密登录',
value: `${zoneCount('password')}`,
hint: '交接更快',
},
]
return canViewPremium.value ? cards : cards.filter(card => card.key !== 'premium')
})
const zoneOptions = computed(() => [
{
key: 'all',
label: '全部专区',
hint: '当前可租账号',
count: zoneCount('all'),
},
{
key: 'premium',
label: '极品专区',
hint: '优质号主精选',
count: zoneCount('premium'),
},
{
key: 'sale',
label: '特惠专区',
hint: '价格更划算',
count: zoneCount('sale'),
},
{
key: 'gift',
label: '赠送专区',
hint: '含赠送物品',
count: zoneCount('gift'),
},
{
key: 'night',
label: '夜间专区',
hint: '0-2点可响应上号',
count: zoneCount('night'),
},
{
key: 'password',
label: '账密专区',
hint: '交接更快',
count: zoneCount('password'),
},
{
key: 'highCoin',
label: '高币专区',
hint: '100M 以上',
count: zoneCount('highCoin'),
},
])
const zoneOptions = computed(() => {
const zones = [
{
key: 'all',
label: '全部专区',
hint: '当前可租账号',
count: zoneCount('all'),
},
{
key: 'premium',
label: '极品专区',
hint: '优质号主精选',
count: zoneCount('premium'),
},
{
key: 'sale',
label: '特惠专区',
hint: '价格更划算',
count: zoneCount('sale'),
},
{
key: 'gift',
label: '赠送专区',
hint: '含赠送物品',
count: zoneCount('gift'),
},
{
key: 'night',
label: '夜间专区',
hint: '0-2点可响应上号',
count: zoneCount('night'),
},
{
key: 'password',
label: '账密专区',
hint: '交接更快',
count: zoneCount('password'),
},
{
key: 'highCoin',
label: '高币专区',
hint: '100M 以上',
count: zoneCount('highCoin'),
},
]
return canViewPremium.value ? zones : zones.filter(zone => zone.key !== 'premium')
})
async function loadHome() {
loading.value = true
@@ -360,7 +384,10 @@ function applyRouteQueryToHomeState() {
}
function desktopHomeStateSignature() {
return JSON.stringify(buildDesktopHomeQueryValues())
return JSON.stringify({
filters: buildDesktopHomeQueryValues(),
premiumAccess: canViewPremium.value,
})
}
function syncHomeQuery() {
@@ -652,7 +679,7 @@ if (!restored) {
<el-empty v-else description="暂无展示图片" />
</el-dialog>
<section class="premium-spotlight" aria-label="优质号主精选">
<section v-if="canViewPremium" class="premium-spotlight" aria-label="优质号主精选">
<div class="premium-spotlight-mark">
<el-icon :size="24"><Trophy /></el-icon>
</div>
@@ -711,16 +738,16 @@ if (!restored) {
</div>
</div>
<div v-if="!loading && listings.length === 0" class="home-empty-state">
<div v-if="!loading && visibleListings.length === 0" class="home-empty-state">
<strong>没有符合条件的账号</strong>
<span>可以放宽筛选条件或切回全部专区。</span>
<button type="button" @click="handleResetFilters">重置条件</button>
</div>
<div v-else v-loading="loading" class="enhanced-desktop-list">
<ListingCard v-for="item in listings" :key="item.id" :listing="item" />
<ListingCard v-for="item in visibleListings" :key="item.id" :listing="item" />
</div>
<div v-if="!loading && listings.length" class="infinite-load-state">
<div v-if="!loading && visibleListings.length" class="infinite-load-state">
<span v-if="loadingMore">正在加载更多账号...</span>
<span v-else-if="!hasMoreListings">已经到底了</span>
</div>
@@ -72,10 +72,12 @@ import {
import { buildMobileHomeListSignature, useMobileHomeCacheStore } from '@/stores/mobileHomeCache'
import { mapDistributionHomeBanners, useDistributionSiteStore } from '@/stores/distributionSite'
import { useSessionStore } from '@/stores/session'
import { canViewPremiumZone } from '@/shared/utils/growthLevel'
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const canViewPremium = computed(() => canViewPremiumZone(session.renterGrowthLevel))
const siteStore = useDistributionSiteStore()
const homeCache = useMobileHomeCacheStore()
const { run: runBannerAction } = useBannerAction()
@@ -167,7 +169,7 @@ const sortOptions: MobileSortOption[] = [
{ key: 'published', label: '最新发布', shortLabel: '最新发布' },
]
const zoneOptions = [
const allZoneOptions = [
{ key: 'all', label: '全部', hint: '当前可租' },
{ key: 'premium', label: '极品', hint: '优质号主精选' },
{ key: 'sale', label: '特惠', hint: '价格更划算' },
@@ -176,6 +178,9 @@ const zoneOptions = [
{ key: 'password', label: '账密', hint: '交接更快' },
{ key: 'highCoin', label: '高币', hint: '100M以上' },
]
const zoneOptions = computed(() =>
canViewPremium.value ? allZoneOptions : allZoneOptions.filter(zone => zone.key !== 'premium')
)
/** 统一使用领域层导出的预设区间,保证与桌面端完全一致 */
const rangePresets = defaultRangePresets
@@ -321,18 +326,32 @@ const activeFilterCount = computed(() => {
})
const displayListings = computed(() => {
return listings.value
return canViewPremium.value ? listings.value : listings.value.filter(item => !item.is_premium)
})
const visibleZoneOptions = computed(() =>
zoneOptions.map(zone => ({
...zone,
count: zoneCount(zone.key),
}))
zoneOptions.value.map(zone => {
const count =
!canViewPremium.value && zone.key === 'all'
? Math.max(Number(zoneCounts.value.all || 0) - Number(zoneCounts.value.premium || 0), 0)
: zoneCount(zone.key)
return {
...zone,
count,
}
})
)
applyRouteQueryToMobileState()
watch(canViewPremium, allowed => {
homeCache.clearList()
if (!allowed && activeZone.value === 'premium') {
activeZone.value = 'all'
}
void loadListings(true)
})
function currentListSignature() {
return buildMobileHomeListSignature({
searchValue: searchValue.value,
@@ -340,6 +359,7 @@ function currentListSignature() {
activeZone: activeZone.value,
selectedFilters: selectedFilters.value,
rangeFilters: rangeFilters.value,
premiumAccess: canViewPremium.value,
})
}
@@ -675,7 +695,8 @@ function applyRouteQueryToMobileState() {
: storedQuery
const nextSearch = readQueryString(query, 'keyword')
const nextSort = readQueryString(query, 'sort') || 'recommended'
const nextZone = readQueryString(query, 'zone') || 'all'
const requestedZone = readQueryString(query, 'zone') || 'all'
const nextZone = requestedZone === 'premium' && !canViewPremium.value ? 'all' : requestedZone
const nextFilters = normalizeSelectedFilters(
decodeHomeQueryJson<Record<string, unknown>>(query, 'filters', {})
)
@@ -1008,6 +1029,7 @@ if (!restoreHomeConfigFromCache()) {
</div>
<button
v-if="canViewPremium"
type="button"
class="mobile-premium-spotlight"
aria-label="进入优质号主极品专区"
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { canViewPremiumZone } from '../growthLevel'
describe('canViewPremiumZone', () => {
it('仅铂金及以上等级可以查看极品专区', () => {
expect(canViewPremiumZone('normal')).toBe(false)
expect(canViewPremiumZone('')).toBe(false)
expect(canViewPremiumZone('platinum')).toBe(true)
expect(canViewPremiumZone('diamond')).toBe(true)
expect(canViewPremiumZone('peak')).toBe(true)
})
})
+1
View File
@@ -29,6 +29,7 @@ export const AUDIT_ACTION_OPTIONS: AuditOption[] = [
{ 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: 'admin_user.adjust_growth_level', label: '调整成长等级', group: '用户', highRisk: true },
{ value: 'quality_owner.activate', label: '缴纳优质号主保证金', group: '用户', highRisk: true },
{ value: 'quality_owner.cancel', label: '取消优质号主', group: '用户', highRisk: true },
// 商品
+5
View File
@@ -0,0 +1,5 @@
const premiumZoneLevels = new Set(['platinum', 'diamond', 'peak'])
export function canViewPremiumZone(levelCode: string | undefined | null) {
return premiumZoneLevels.has(String(levelCode || '').trim())
}
+2
View File
@@ -24,6 +24,7 @@ export function buildMobileHomeListSignature(input: {
activeZone: string
selectedFilters: Record<string, string[]>
rangeFilters: MobileHomeRangeFilters
premiumAccess: boolean
}) {
return JSON.stringify({
keyword: input.searchValue.trim(),
@@ -31,6 +32,7 @@ export function buildMobileHomeListSignature(input: {
zone: input.activeZone,
selected: input.selectedFilters,
ranges: input.rangeFilters,
premiumAccess: input.premiumAccess,
})
}