实名姓名与收款账户不一致会导致收款码登记失败,新增后台撤销实名能力: - 新增 user:revoke_realname 权限,授予超级管理员与运营角色 - 撤销时重置用户实名状态为未认证并删除实名记录,使其可重新认证,写入审计日志 - 用户管理页新增实名状态列与"撤销实名"操作(含原因弹窗) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
342 lines
10 KiB
Vue
342 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import { readError } from '@/shared/utils/error'
|
||
import { ElMessage } from 'element-plus'
|
||
import { Search } from '@element-plus/icons-vue'
|
||
import { reactive, ref } from 'vue'
|
||
|
||
import {
|
||
fetchAdminUsers,
|
||
freezeAdminUser,
|
||
revokeAdminUserRealname,
|
||
setAdminUserDepositFreeQuota,
|
||
unfreezeAdminUser,
|
||
type AdminUserItem,
|
||
type AdminUserQuery,
|
||
} from '@/features/admin/api/adminUsers'
|
||
import { centToYuan, formatCent, yuanToCent } from '@/shared/utils/money'
|
||
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
|
||
import { realnameStatusLabel, userStatusLabel } from '@/shared/utils/statusLabels'
|
||
import { formatDateTime } from '@/shared/utils/time'
|
||
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
||
|
||
const submitting = ref(false)
|
||
const activeUser = ref<AdminUserItem | null>(null)
|
||
const quotaUser = ref<AdminUserItem | null>(null)
|
||
const realnameUser = ref<AdminUserItem | null>(null)
|
||
const freezeReason = ref('')
|
||
const revokeRealnameReason = ref('')
|
||
const quotaAmount = ref(0)
|
||
|
||
const filters = reactive<AdminUserQuery>({
|
||
keyword: '',
|
||
status: '',
|
||
})
|
||
|
||
const {
|
||
loading,
|
||
data: users,
|
||
total,
|
||
currentPage,
|
||
currentPageSize,
|
||
load: loadUsers,
|
||
} = useAdminPaginatedTable<AdminUserItem>({
|
||
fetchFn: (page, pageSize) => fetchAdminUsers(page, pageSize, filters),
|
||
})
|
||
|
||
function queryUsers() {
|
||
currentPage.value = 1
|
||
void loadUsers()
|
||
}
|
||
|
||
function resetFilters() {
|
||
filters.keyword = ''
|
||
filters.status = ''
|
||
queryUsers()
|
||
}
|
||
|
||
function openFreeze(row: AdminUserItem) {
|
||
activeUser.value = row
|
||
freezeReason.value = ''
|
||
}
|
||
|
||
function openDepositQuota(row: AdminUserItem) {
|
||
quotaUser.value = row
|
||
quotaAmount.value = centToYuan(row.deposit_free_quota_cent)
|
||
}
|
||
|
||
async function handleSetDepositQuota() {
|
||
if (!quotaUser.value) return
|
||
submitting.value = true
|
||
try {
|
||
await setAdminUserDepositFreeQuota(quotaUser.value.id, yuanToCent(quotaAmount.value))
|
||
ElMessage.success('免押额度已更新')
|
||
quotaUser.value = null
|
||
await loadUsers()
|
||
} catch (error) {
|
||
ElMessage.error(readError(error, '设置免押额度失败'))
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
async function handleFreeze() {
|
||
if (!activeUser.value) return
|
||
submitting.value = true
|
||
try {
|
||
await freezeAdminUser(activeUser.value.id, freezeReason.value)
|
||
ElMessage.success('用户已冻结')
|
||
activeUser.value = null
|
||
await loadUsers()
|
||
} catch (error) {
|
||
ElMessage.error(readError(error, '冻结失败'))
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
async function handleUnfreeze(row: AdminUserItem) {
|
||
submitting.value = true
|
||
try {
|
||
await unfreezeAdminUser(row.id)
|
||
ElMessage.success('用户已解冻')
|
||
await loadUsers()
|
||
} catch (error) {
|
||
ElMessage.error(readError(error, '解冻失败'))
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
function openRevokeRealname(row: AdminUserItem) {
|
||
realnameUser.value = row
|
||
revokeRealnameReason.value = ''
|
||
}
|
||
|
||
async function handleRevokeRealname() {
|
||
if (!realnameUser.value) return
|
||
submitting.value = true
|
||
try {
|
||
await revokeAdminUserRealname(realnameUser.value.id, revokeRealnameReason.value)
|
||
ElMessage.success('已撤销实名,用户可重新认证')
|
||
realnameUser.value = null
|
||
await loadUsers()
|
||
} catch (error) {
|
||
ElMessage.error(readError(error, '撤销实名失败'))
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
function moneyCent(value: number | string | undefined) {
|
||
return formatCent(Number(value || 0))
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="page">
|
||
<div class="page-header-row">
|
||
<div class="page-header">
|
||
<p class="eyebrow">Users</p>
|
||
<h1>用户管理</h1>
|
||
<p>查看用户信息和状态,处理冻结与解冻。</p>
|
||
</div>
|
||
<el-button @click="loadUsers">刷新</el-button>
|
||
</div>
|
||
|
||
<div class="user-filter-bar">
|
||
<el-form inline @submit.prevent>
|
||
<el-form-item label="关键词">
|
||
<el-input
|
||
v-model="filters.keyword"
|
||
clearable
|
||
placeholder="手机号 / 昵称 / 用户ID"
|
||
style="width: 220px"
|
||
@keyup.enter="queryUsers"
|
||
@clear="queryUsers"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="状态">
|
||
<el-select
|
||
v-model="filters.status"
|
||
clearable
|
||
placeholder="全部状态"
|
||
style="width: 140px"
|
||
@change="queryUsers"
|
||
>
|
||
<el-option label="正常" value="active" />
|
||
<el-option label="已冻结" value="frozen" />
|
||
<el-option label="已禁用" value="disabled" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" :icon="Search" :loading="loading" @click="queryUsers"
|
||
>查询</el-button
|
||
>
|
||
<el-button @click="resetFilters">重置</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
|
||
<el-table v-loading="loading" class="table-panel" :data="users" empty-text="未找到匹配的用户">
|
||
<el-table-column prop="id" label="用户ID" width="80" />
|
||
<el-table-column prop="nickname" label="昵称" min-width="130" />
|
||
<el-table-column prop="phone" label="手机号" min-width="130" />
|
||
<el-table-column label="状态" width="110">
|
||
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="实名状态" width="110">
|
||
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="免押额度" width="130">
|
||
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_quota_cent) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="已占用" width="120">
|
||
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_used_cent) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="剩余免押" width="120">
|
||
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_remaining_cent) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="注册时间" min-width="180">
|
||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="320">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="openDepositQuota(row)">免押</el-button>
|
||
<el-button
|
||
v-if="row.realname_status !== 'unverified'"
|
||
size="small"
|
||
type="warning"
|
||
:loading="submitting"
|
||
@click="openRevokeRealname(row)"
|
||
>
|
||
撤销实名
|
||
</el-button>
|
||
<el-button
|
||
v-if="row.status === 'active'"
|
||
size="small"
|
||
type="danger"
|
||
:loading="submitting"
|
||
@click="openFreeze(row)"
|
||
>
|
||
冻结
|
||
</el-button>
|
||
<el-button
|
||
v-else
|
||
size="small"
|
||
type="primary"
|
||
:loading="submitting"
|
||
@click="handleUnfreeze(row)"
|
||
>解冻</el-button
|
||
>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<AdminTablePagination
|
||
v-if="total > 0"
|
||
v-model:current-page="currentPage"
|
||
v-model:page-size="currentPageSize"
|
||
:total="total"
|
||
:loading="loading"
|
||
@page-change="loadUsers"
|
||
/>
|
||
|
||
<el-dialog
|
||
:model-value="!!activeUser"
|
||
title="冻结用户"
|
||
width="560px"
|
||
@update:model-value="activeUser = null"
|
||
>
|
||
<div v-if="activeUser" class="dialog-body">
|
||
<p>
|
||
<strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}
|
||
</p>
|
||
<el-input
|
||
v-model="freezeReason"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder="填写冻结原因,便于审计追踪"
|
||
/>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="activeUser = null">取消</el-button>
|
||
<el-button type="danger" :loading="submitting" @click="handleFreeze">确认冻结</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
:model-value="!!realnameUser"
|
||
title="撤销实名认证"
|
||
width="560px"
|
||
@update:model-value="realnameUser = null"
|
||
>
|
||
<div v-if="realnameUser" class="dialog-body">
|
||
<p>
|
||
<strong>{{ realnameUser.phone }}</strong> · {{ realnameUser.nickname }}
|
||
</p>
|
||
<el-alert
|
||
type="warning"
|
||
:closable="false"
|
||
title="撤销后该用户实名信息将被清除,需重新完成实名认证后才能下单与登记收款码。"
|
||
/>
|
||
<el-input
|
||
v-model="revokeRealnameReason"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder="填写撤销原因(如实名姓名与收款账户不一致),便于审计追踪"
|
||
/>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="realnameUser = null">取消</el-button>
|
||
<el-button type="warning" :loading="submitting" @click="handleRevokeRealname"
|
||
>确认撤销</el-button
|
||
>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
:model-value="!!quotaUser"
|
||
title="设置免押额度"
|
||
width="520px"
|
||
@update:model-value="quotaUser = null"
|
||
>
|
||
<div v-if="quotaUser" class="dialog-body">
|
||
<p>
|
||
<strong>{{ quotaUser.phone }}</strong> · {{ quotaUser.nickname }}
|
||
</p>
|
||
<p>
|
||
已占用 ¥{{ moneyCent(quotaUser.deposit_free_used_cent) }},剩余 ¥{{
|
||
moneyCent(quotaUser.deposit_free_remaining_cent)
|
||
}}
|
||
</p>
|
||
<el-input-number
|
||
v-model="quotaAmount"
|
||
class="full-control"
|
||
:min="0"
|
||
:precision="0"
|
||
:step="100"
|
||
/>
|
||
</div>
|
||
<template #footer>
|
||
<el-button :disabled="submitting" @click="quotaUser = null">取消</el-button>
|
||
<el-button type="primary" :loading="submitting" @click="handleSetDepositQuota"
|
||
>保存额度</el-button
|
||
>
|
||
</template>
|
||
</el-dialog>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.user-filter-bar {
|
||
margin-bottom: 16px;
|
||
padding: 16px 16px 0;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
}
|
||
|
||
.user-filter-bar :deep(.el-form-item) {
|
||
margin-bottom: 16px;
|
||
}
|
||
</style>
|