Files
hfb_sys/frontend/src/features/admin/views/AdminUsersView.vue
T
yml2213andClaude Opus 4.8 4d54f1b03a 后台增加撤销用户实名功能
实名姓名与收款账户不一致会导致收款码登记失败,新增后台撤销实名能力:
- 新增 user:revoke_realname 权限,授予超级管理员与运营角色
- 撤销时重置用户实名状态为未认证并删除实名记录,使其可重新认证,写入审计日志
- 用户管理页新增实名状态列与"撤销实名"操作(含原因弹窗)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:03:52 +08:00

342 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>