用户管理增加余额调账功能

支持后台加减可用余额并写入资金流水与审计,优化操作列按钮样式。
This commit is contained in:
yml2213
2026-07-13 21:30:38 +08:00
parent a2a9ade68e
commit b96d6141f5
10 changed files with 642 additions and 57 deletions
@@ -13,6 +13,8 @@ export interface AdminUserItem {
deposit_free_quota_cent: number
deposit_free_used_cent: number
deposit_free_remaining_cent: number
available_balance_cent: number
frozen_balance_cent: number
status: UserStatus
order_count: number
listing_count: number
@@ -22,6 +24,13 @@ export interface AdminUserItem {
updated_at: string
}
export interface AdminWalletAdjustPayload {
direction: 'in' | 'out'
amount_cent: number
reason: string
reference_no?: string
}
export interface AdminUserQuery {
keyword?: string
status?: '' | UserStatus
@@ -84,3 +93,16 @@ export async function manualAdminUserRealname(
)
return data.data
}
export async function adjustAdminUserWallet(id: number, payload: AdminWalletAdjustPayload) {
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(
`/admin/users/${id}/wallet/adjust`,
{
direction: payload.direction,
amount_cent: payload.amount_cent,
reason: payload.reason,
reference_no: payload.reference_no || undefined,
}
)
return data.data
}
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { readError } from '@/shared/utils/error'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import {
adjustAdminUserWallet,
fetchAdminUsers,
freezeAdminUser,
manualAdminUserRealname,
@@ -18,13 +19,18 @@ 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 { useAdminSessionStore } from '@/stores/adminSession'
import AdminTablePagination from '../components/AdminTablePagination.vue'
const adminSession = useAdminSessionStore()
const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust'))
const submitting = ref(false)
const activeUser = ref<AdminUserItem | null>(null)
const quotaUser = ref<AdminUserItem | null>(null)
const realnameUser = ref<AdminUserItem | null>(null)
const manualRealnameUser = ref<AdminUserItem | null>(null)
const walletUser = ref<AdminUserItem | null>(null)
const freezeReason = ref('')
const revokeRealnameReason = ref('')
const quotaAmount = ref(0)
@@ -33,6 +39,12 @@ const manualRealnameForm = reactive({
idNo: '',
reason: '',
})
const walletForm = reactive({
direction: 'out' as 'in' | 'out',
amountYuan: 0,
reason: '',
referenceNo: '',
})
const filters = reactive<AdminUserQuery>({
keyword: '',
@@ -73,6 +85,14 @@ function openDepositQuota(row: AdminUserItem) {
quotaAmount.value = centToYuan(row.deposit_free_quota_cent)
}
function openWalletAdjust(row: AdminUserItem) {
walletUser.value = row
walletForm.direction = 'out'
walletForm.amountYuan = 0
walletForm.reason = ''
walletForm.referenceNo = ''
}
async function handleSetDepositQuota() {
if (!quotaUser.value) return
submitting.value = true
@@ -170,6 +190,63 @@ async function handleRevokeRealname() {
}
}
async function handleWalletAdjust() {
if (!walletUser.value) return
const amountYuan = Number(walletForm.amountYuan || 0)
if (!(amountYuan > 0)) {
ElMessage.warning('请输入大于 0 的金额')
return
}
if (amountYuan > 100000) {
ElMessage.warning('单笔调账不能超过 10 万元')
return
}
if (walletForm.reason.trim().length < 2) {
ElMessage.warning('请填写至少 2 个字的调账原因')
return
}
if (
walletForm.direction === 'out' &&
yuanToCent(amountYuan) > Number(walletUser.value.available_balance_cent || 0)
) {
ElMessage.warning('扣款金额不能超过可用余额')
return
}
const actionLabel = walletForm.direction === 'in' ? '加款' : '扣款'
const amountText = `¥${amountYuan.toFixed(1)}`
try {
await ElMessageBox.confirm(
`确认为用户 ${walletUser.value.phone || walletUser.value.id} ${actionLabel} ${amountText}\n操作将写入资金流水与审计日志。`,
`确认${actionLabel}`,
{
type: 'warning',
confirmButtonText: `确认${actionLabel}`,
cancelButtonText: '取消',
}
)
} catch {
return
}
submitting.value = true
try {
await adjustAdminUserWallet(walletUser.value.id, {
direction: walletForm.direction,
amount_cent: yuanToCent(amountYuan),
reason: walletForm.reason.trim(),
reference_no: walletForm.referenceNo.trim() || undefined,
})
ElMessage.success(`${actionLabel}成功`)
walletUser.value = null
await loadUsers()
} catch (error) {
ElMessage.error(readError(error, `${actionLabel}失败`))
} finally {
submitting.value = false
}
}
function moneyCent(value: number | string | undefined) {
return formatCent(Number(value || 0))
}
@@ -181,7 +258,7 @@ function moneyCent(value: number | string | undefined) {
<div class="page-header">
<p class="eyebrow">Users</p>
<h1>用户管理</h1>
<p>查看用户信息和状态处理冻结与解冻</p>
<p>查看用户信息与钱包余额处理冻结实名与余额调账</p>
</div>
<el-button @click="loadUsers">刷新</el-button>
</div>
@@ -227,64 +304,94 @@ function moneyCent(value: number | string | undefined) {
<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">
<el-table-column prop="nickname" label="昵称" min-width="120" />
<el-table-column prop="phone" label="手机号" min-width="120" />
<el-table-column label="状态" width="90">
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column label="实名状态" width="110">
<el-table-column label="实名状态" width="100">
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
</el-table-column>
<el-table-column label="免押额度" width="130">
<el-table-column label="可用余额" width="108" align="right">
<template #default="{ row }">
<span class="balance-available">¥{{ moneyCent(row.available_balance_cent) }}</span>
</template>
</el-table-column>
<el-table-column label="冻结余额" width="108" align="right">
<template #default="{ row }">
<span
class="balance-frozen"
:class="{ 'is-positive': Number(row.frozen_balance_cent || 0) > 0 }"
>
¥{{ moneyCent(row.frozen_balance_cent) }}
</span>
</template>
</el-table-column>
<el-table-column label="免押额度" width="100" align="right">
<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">
<el-table-column label="剩余免押" width="100" align="right">
<template #default="{ row }">¥{{ moneyCent(row.deposit_free_remaining_cent) }}</template>
</el-table-column>
<el-table-column label="注册时间" min-width="180">
<el-table-column label="注册时间" min-width="160">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="400">
<el-table-column label="操作" width="320" fixed="right" align="right">
<template #default="{ row }">
<el-button size="small" @click="openDepositQuota(row)">免押</el-button>
<el-button
v-if="row.realname_status !== 'verified'"
size="small"
type="success"
:loading="submitting"
@click="openManualRealname(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
>
<div class="table-actions">
<el-button
v-if="canAdjustWallet"
text
type="primary"
size="small"
@click="openWalletAdjust(row)"
>
余额
</el-button>
<el-button text type="primary" size="small" @click="openDepositQuota(row)">
免押额度
</el-button>
<el-button
v-if="row.realname_status !== 'verified'"
text
type="success"
size="small"
:loading="submitting"
@click="openManualRealname(row)"
>
实名
</el-button>
<el-button
v-if="row.realname_status !== 'unverified'"
text
type="warning"
size="small"
:loading="submitting"
@click="openRevokeRealname(row)"
>
撤销实名
</el-button>
<el-button
v-if="row.status === 'active'"
text
type="danger"
size="small"
:loading="submitting"
@click="openFreeze(row)"
>
冻结
</el-button>
<el-button
v-else
text
type="primary"
size="small"
:loading="submitting"
@click="handleUnfreeze(row)"
>
解冻
</el-button>
</div>
</template>
</el-table-column>
</el-table>
@@ -426,6 +533,89 @@ function moneyCent(value: number | string | undefined) {
>
</template>
</el-dialog>
<el-dialog
:model-value="!!walletUser"
title="余额管理"
width="560px"
@update:model-value="walletUser = null"
>
<div v-if="walletUser" class="dialog-body wallet-dialog">
<p>
<strong>{{ walletUser.phone }}</strong> · {{ walletUser.nickname || '-' }} · ID
{{ walletUser.id }}
</p>
<div class="wallet-balance-cards">
<div>
<span>可用余额</span>
<strong>¥{{ moneyCent(walletUser.available_balance_cent) }}</strong>
</div>
<div>
<span>冻结余额</span>
<strong>¥{{ moneyCent(walletUser.frozen_balance_cent) }}</strong>
</div>
</div>
<el-alert
v-if="Number(walletUser.frozen_balance_cent || 0) > 0"
type="warning"
:closable="false"
show-icon
title="该用户有冻结余额,通常对应进行中的提现单。冻结金额请到「提现审核」处理;此处仅调整可用余额。"
/>
<el-alert
type="info"
:closable="false"
show-icon
title="扣款用于线下已打款核销;加款用于客服补偿。操作会写入资金流水与审计日志。"
/>
<el-form label-position="top" @submit.prevent>
<el-form-item label="调账类型">
<el-radio-group v-model="walletForm.direction">
<el-radio-button value="out">扣款核销</el-radio-button>
<el-radio-button value="in">加款补偿</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="金额(元)">
<el-input-number
v-model="walletForm.amountYuan"
class="full-control"
:min="0.1"
:max="100000"
:precision="1"
:step="10"
/>
</el-form-item>
<el-form-item label="原因(必填,计入审计)">
<el-input
v-model="walletForm.reason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="例如:用户无法提现,已线下转账核销可用余额"
/>
</el-form-item>
<el-form-item label="外部单号(可选)">
<el-input
v-model="walletForm.referenceNo"
clearable
maxlength="64"
placeholder="线下转账单号 / 客服工单号"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button :disabled="submitting" @click="walletUser = null">取消</el-button>
<el-button
:type="walletForm.direction === 'out' ? 'danger' : 'success'"
:loading="submitting"
@click="handleWalletAdjust"
>
确认{{ walletForm.direction === 'out' ? '扣款' : '加款' }}
</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -440,4 +630,90 @@ function moneyCent(value: number | string | undefined) {
.user-filter-bar :deep(.el-form-item) {
margin-bottom: 16px;
}
.balance-available {
color: #0f172a;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.balance-frozen {
color: #94a3b8;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.balance-frozen.is-positive {
color: #c2410c;
}
.table-actions {
display: inline-flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
gap: 2px;
white-space: nowrap;
}
.table-actions :deep(.el-button) {
margin: 0;
height: 28px;
padding: 0 8px;
font-weight: 600;
}
.table-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.table-actions :deep(.el-button.is-text) {
border-radius: 6px;
}
.table-actions :deep(.el-button.is-text:hover) {
background: #f1f5f9;
}
.wallet-dialog {
display: grid;
gap: 14px;
}
.wallet-balance-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.wallet-balance-cards > div {
display: grid;
gap: 6px;
padding: 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
}
.wallet-balance-cards span {
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.wallet-balance-cards strong {
color: #0f172a;
font-size: 22px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.full-control {
width: 100%;
}
.dialog-body {
display: grid;
gap: 12px;
}
</style>