Files
admin-web/src/modules/finance/pages/FinanceCustomerAccountsView.vue
T
2026-09-30 14:01:58 +08:00

725 lines
45 KiB
Vue

<script setup lang="ts">
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
const filters = ref<QueryModel>({ customerId: '', status: '' })
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null)
const rows = ref<FinanceAccountSummary[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const detailOpen = ref(false)
const editing = ref(false)
const saving = ref(false)
const selected = ref<FinanceAccountSummary | null>(null)
const ledger = ref<FinanceLedgerEntry[]>([])
const ledgerLoading = ref(false)
const ledgerError = ref<ApiErrorDetails | null>(null)
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
const creditDays = ref(30)
const ledgerFilters = ref<QueryModel>({ businessType: '', occurredRange: [] })
const ledgerPage = ref(1)
const ledgerPageSize = ref(20)
const ledgerTotal = ref(0)
const rechargeRequests = ref<FinanceRechargeRequest[]>([])
const rechargeLoading = ref(false)
const rechargeError = ref<ApiErrorDetails | null>(null)
const accountExportBusy = ref(false)
const ledgerExportBusy = ref(false)
const adjustmentOpen = ref(false)
const adjustmentSaving = ref(false)
const adjustmentAccount = ref<FinanceAccountSummary | null>(null)
const adjustmentForm = reactive({ type: 'RECHARGE' as 'RECHARGE' | 'DEDUCTION', amount: '', occurredDate: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', remark: '' })
const creditLimitOpen = ref(false)
const creditLimitSaving = ref(false)
const creditLimitForm = reactive({ amount: '', expiresOn: '', remark: '' })
const reviewOpen = ref(false)
const reviewSaving = ref(false)
const reviewRequest = ref<FinanceRechargeRequest | null>(null)
const reviewForm = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', remark: '' })
const voucherPreviewOpen = ref(false)
const voucherPreviewLoading = ref(false)
const voucherPreviewUrl = ref('')
const voucherPreviewName = ref('付款凭证')
const canView = computed(() => access.can('platform:finance:customer-account:list'))
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
const canExport = computed(() => access.can('platform:finance:customer-account:export') && access.can('platform:download:list'))
const canAdjust = computed(() => access.can('platform:finance:customer-account:adjust'))
const canReviewRecharge = computed(() => access.can('platform:finance:recharge:review'))
const stateError = computed(() => customersError.value || loadError.value)
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '全部客户', props: { 'aria-label': '客户名称', filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
{ key: 'actualBalance', label: '实际余额(CNY)', width: 155, slotName: 'actualBalance' },
{ key: 'frozenBalance', label: '冻结金额(CNY)', width: 150, slotName: 'frozenBalance' },
{ key: 'availableBalance', label: '总可用余额(CNY)', width: 155, slotName: 'availableBalance' },
{ key: 'creditLimit', label: '授信额度(CNY)', width: 155, slotName: 'creditLimit' },
{ key: 'creditExpiresOn', label: '授信有效期', width: 140, slotName: 'creditExpiresOn' },
{ key: 'creditUsed', label: '已用授信(CNY)', width: 150, slotName: 'creditUsed' },
{ key: 'pendingRechargeCount', label: '待审核充值', width: 120, slotName: 'pendingRechargeCount' },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
]
const ledgerQueryFields: QueryField[] = [
{ key: 'businessType', type: 'select', label: '流水类型', width: 180, placeholder: '全部类型', options: [
{ label: '生成对账单', value: 'RECONCILIATION_CREATED' }, { label: '结算单生效', value: 'SETTLEMENT_ISSUED' },
{ label: '收款', value: 'RECEIPT' },
{ label: '供应商线下直付', value: 'SUPPLIER_DIRECT_PAID' },
{ label: '供应商账期付款', value: 'SUPPLIER_ON_ACCOUNT_PAYMENT' },
{ label: '账务调整', value: 'ADJUSTMENT' }, { label: '作废', value: 'VOID' },
] },
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
]
const ledgerColumns: DataTableColumn[] = [
{ key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' },
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, slotName: 'amount' },
{ key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' },
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, slotName: 'actualBalanceAfter' },
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, slotName: 'frozenBalanceAfter' },
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, slotName: 'availableBalanceAfter' },
{ key: 'creditUsedAfter', label: '变动后已用授信', width: 160, slotName: 'creditUsedAfter' },
{ key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
]
const rechargeColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' },
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 },
{ key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' },
]
const sortedRechargeRequests = computed(() => [...rechargeRequests.value].sort((left, right) => Number(right.status === 'PENDING') - Number(left.status === 'PENDING')))
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
if (!canView.value) return
loading.value = true
loadError.value = null
try {
const result = await listFinanceAccounts({ page: page.value, size: pageSize.value, customerId: String(filters.value.customerId || '').trim() || undefined, status: String(filters.value.status || '') })
rows.value = result.records
total.value = result.total
} catch (error) { loadError.value = getApiErrorDetails(error, '客户账户加载失败,请重试') }
finally { loading.value = false }
}
async function loadCustomers() {
customersLoading.value = true; customersError.value = null
try {
const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1
do { const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages)
customers.value = records
} catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
finally { customersLoading.value = false }
}
function search(model: QueryModel) { page.value = 1; void load(model) }
async function openAccount(row: Record<string, unknown>, edit: boolean) {
selected.value = row as unknown as FinanceAccountSummary
editing.value = edit
if (!edit) {
await router.push({ name: 'finance-customer-account-detail', params: { customerId: selected.value.customerId } })
return
}
detailOpen.value = true
ledger.value = []
ledgerError.value = null
selectedTerms.value = null
if (edit) {
if (!canViewTerms.value) return
try { selectedTerms.value = await getFinanceCustomerTerms(selected.value.customerId); creditDays.value = selectedTerms.value.creditDays }
catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期加载失败,请重试') }
return
}
await loadLedger()
}
async function loadLedger(model: QueryModel = ledgerFilters.value) {
if (!selected.value) return
Object.assign(ledgerFilters.value, model)
ledgerLoading.value = true; ledgerError.value = null
const range = Array.isArray(ledgerFilters.value.occurredRange) ? ledgerFilters.value.occurredRange.map(String) : []
try {
const result = await listFinanceAccountLedger(selected.value.customerId, { page: ledgerPage.value, size: ledgerPageSize.value, businessType: String(ledgerFilters.value.businessType || ''), startDate: range[0], endDate: range[1] })
ledger.value = result.records
ledgerTotal.value = result.total
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户流水加载失败,请重试') }
finally { ledgerLoading.value = false }
}
async function loadRechargeRequests() {
if (!selected.value) return
rechargeLoading.value = true; rechargeError.value = null
try { rechargeRequests.value = (await listFinanceRechargeRequests(selected.value.customerId, { page: 1, size: 200 })).records }
catch (error) { rechargeRequests.value = []; rechargeError.value = getApiErrorDetails(error, '充值申请加载失败,请重试') }
finally { rechargeLoading.value = false }
}
async function refreshSelectedAccount() {
if (!selected.value) return
const customerId = selected.value.customerId
const result = await listFinanceAccounts({ page: 1, size: 1, customerId })
selected.value = result.records.find(row => row.customerId === customerId) || null
}
async function refreshWalletDetail() {
await refreshSelectedAccount()
if (!selected.value) return
await Promise.all([loadRechargeRequests(), loadLedger()])
}
async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, editing.value) }
async function saveTerms() {
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
saving.value = true
ledgerError.value = null
try { selectedTerms.value = await updateFinanceCustomerTerms(selected.value.customerId, { customerName: selected.value.customerName, creditDays: creditDays.value }); ElMessage.success('客户账期已保存') }
catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期保存失败,请重试'); ElMessage.error(ledgerError.value.message) }
finally { saving.value = false }
}
function money(value: unknown) { const amount = String(value ?? '').trim(); return amount ? `¥${amount}` : '—' }
function amountInput(value: string) {
const amount = Number(value)
return Number.isFinite(amount) && amount > 0 ? amount.toFixed(2) : null
}
function today() {
const date = new Date()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
function disableCreditExpiry(date: Date) {
const boundary = new Date()
boundary.setHours(0, 0, 0, 0)
return date.getTime() <= boundary.getTime()
}
function creditExpiryLabel(account: FinanceAccountSummary) {
if (!account.creditExpiresOn) return '未设置'
return account.creditActive ? `${account.creditExpiresOn} 到期` : `${account.creditExpiresOn} 已到期`
}
function paymentMethodLabel(value: unknown) {
return ({ BANK_TRANSFER: '银行转账', WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', OTHER: '其他' } as Record<string, string>)[String(value)] || '其他'
}
function walletBusinessType(value: unknown) {
return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[String(value)] || ledgerBusinessType(value)
}
function openAdjustment(account: FinanceAccountSummary, type: 'RECHARGE' | 'DEDUCTION') {
adjustmentAccount.value = account
Object.assign(adjustmentForm, { type, amount: '', occurredDate: today(), paymentMethod: 'BANK_TRANSFER', referenceNo: '', remark: '' })
adjustmentOpen.value = true
}
async function submitAdjustment() {
if (!adjustmentAccount.value) return
const amount = amountInput(adjustmentForm.amount)
if (!amount || !adjustmentForm.occurredDate || !adjustmentForm.paymentMethod) { ElMessage.warning('请填写有效金额、发生日期和付款方式'); return }
adjustmentSaving.value = true
try {
await adjustFinanceAccount(adjustmentAccount.value.customerId, {
type: adjustmentForm.type, amount, occurredDate: adjustmentForm.occurredDate, paymentMethod: adjustmentForm.paymentMethod,
referenceNo: adjustmentForm.referenceNo.trim() || undefined, remark: adjustmentForm.remark.trim() || undefined,
expectedAccountVersion: adjustmentAccount.value.accountVersion,
})
adjustmentOpen.value = false
ElMessage.success(adjustmentForm.type === 'RECHARGE' ? '手工充值已入账' : '手工扣款已入账')
if (selected.value?.customerId === adjustmentAccount.value.customerId) await refreshWalletDetail()
else await load()
} catch (error) { ElMessage.error(getApiErrorMessage(error, adjustmentForm.type === 'RECHARGE' ? '手工充值失败' : '手工扣款失败')) }
finally { adjustmentSaving.value = false }
}
function openCreditLimit(account: FinanceAccountSummary) {
adjustmentAccount.value = account
Object.assign(creditLimitForm, {
amount: Number(account.creditLimit || 0).toFixed(2),
expiresOn: account.creditActive ? account.creditExpiresOn || '' : '',
remark: '',
})
creditLimitOpen.value = true
}
function handleAccountMore(command: string, account: FinanceAccountSummary) {
if (command === 'credit') openCreditLimit(account)
if (command === 'terms') void openAccount(account as unknown as Record<string, unknown>, true)
}
function accountActions(account: FinanceAccountSummary): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 300 },
{ key: 'recharge', label: '充值', priority: 250, visible: canAdjust.value },
{ key: 'deduction', label: '扣款', priority: 200, tone: 'danger', visible: canAdjust.value },
{ key: 'credit', label: '授信', priority: 100, visible: canAdjust.value },
{ key: 'terms', label: '账期', visible: canUpdateTerms.value, divided: canAdjust.value },
]
}
function handleAccountAction(command: string, account: FinanceAccountSummary) {
if (command === 'detail') void openAccount(account as unknown as Record<string, unknown>, false)
else if (command === 'recharge') openAdjustment(account, 'RECHARGE')
else if (command === 'deduction') openAdjustment(account, 'DEDUCTION')
else handleAccountMore(command, account)
}
async function submitCreditLimit() {
if (!adjustmentAccount.value) return
const raw = creditLimitForm.amount.trim()
const amount = Number(raw)
if (!Number.isFinite(amount) || amount < 0 || !/^\d+(?:\.\d{1,2})?$/.test(raw)) {
ElMessage.warning('授信额度必须是大于或等于 0 且最多两位小数的金额')
return
}
if (!creditLimitForm.expiresOn || creditLimitForm.expiresOn <= today()) {
ElMessage.warning('授信到期日必须晚于今天')
return
}
creditLimitSaving.value = true
try {
await updateFinanceCreditLimit(adjustmentAccount.value.customerId, {
creditLimit: amount.toFixed(2), creditExpiresOn: creditLimitForm.expiresOn,
remark: creditLimitForm.remark.trim() || undefined,
expectedAccountVersion: adjustmentAccount.value.accountVersion,
})
creditLimitOpen.value = false
ElMessage.success('授信额度已更新')
if (selected.value?.customerId === adjustmentAccount.value.customerId) await refreshWalletDetail()
else await load()
} catch (error) { ElMessage.error(getApiErrorMessage(error, '授信额度更新失败')) }
finally { creditLimitSaving.value = false }
}
function openReview(request: FinanceRechargeRequest) {
reviewRequest.value = request
Object.assign(reviewForm, { decision: 'APPROVE', remark: '' })
reviewOpen.value = true
}
async function submitReview() {
if (!reviewRequest.value) return
reviewSaving.value = true
try {
await reviewFinanceRechargeRequest(reviewRequest.value.requestId, {
decision: reviewForm.decision, remark: reviewForm.remark.trim() || undefined, expectedVersion: reviewRequest.value.version,
})
reviewOpen.value = false
ElMessage.success(reviewForm.decision === 'APPROVE' ? '充值申请已通过并入账' : '充值申请已驳回')
await refreshWalletDetail()
} catch (error) { ElMessage.error(getApiErrorMessage(error, '充值申请审核失败')) }
finally { reviewSaving.value = false }
}
function clearVoucherPreview() {
if (voucherPreviewUrl.value) URL.revokeObjectURL(voucherPreviewUrl.value)
voucherPreviewUrl.value = ''
}
async function previewVoucher(request: FinanceRechargeRequest) {
clearVoucherPreview()
voucherPreviewName.value = request.voucherFileName || '付款凭证'
voucherPreviewOpen.value = true
voucherPreviewLoading.value = true
try {
const blob = await downloadFinanceRechargeVoucher(request.requestId)
voucherPreviewUrl.value = URL.createObjectURL(blob)
} catch (error) {
voucherPreviewOpen.value = false
ElMessage.error(getApiErrorMessage(error, '付款凭证加载失败'))
} finally { voucherPreviewLoading.value = false }
}
function customerName(customerId: string, snapshot?: string) {
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
const candidate = String(snapshot || '').trim()
const placeholder = [String(customerId), `客户 ${customerId}`, `客户${customerId}`].includes(candidate)
return masterName || (candidate && !placeholder ? candidate : '客户名称暂不可用')
}
function ledgerBusinessType(value: unknown) {
const code = String(value || '').trim().toUpperCase()
const known: Record<string, string> = { REC: '收款', RECONCILIATION: '对账单', FEE_SOURCE: '费用来源' }
return known[code] || financeBusinessTypeLabel(code)
}
function displayOperator(value: unknown) {
const operator = String(value || '').trim()
if (!operator) return '名称数据缺失'
return /^(?:operator-)?\d+$/i.test(operator) ? '名称数据缺失' : operator
}
function formatDateTime(value: unknown) {
const raw = String(value || '').trim()
if (!raw) return '时间数据缺失'
const date = new Date(raw)
return Number.isNaN(date.getTime()) ? '时间数据缺失' : date.toLocaleString('zh-CN', { hour12: false })
}
function formatLastChangedAt(value: unknown) { return String(value || '').trim() ? formatDateTime(value) : '暂无变动' }
function customerField(value: unknown) { return String(value || '').trim() || '未维护' }
function displayDocumentNo(value: unknown) { return String(value || '').trim() || '关联单据缺失' }
function displayLedgerId(value: unknown) { return String(value || '').trim() || '流水号缺失' }
async function finishExport(task: Awaited<ReturnType<typeof createPlatformExportTask>>) {
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo, dense: true })
if (openDownloadCenter) await router.push({ name: 'download-center' })
}
async function exportAccounts() {
const customerId = String(filters.value.customerId || '').trim()
const status = String(filters.value.status || '').trim()
accountExportBusy.value = true
try {
const task = await createPlatformExportTask({
exportType: 'FINANCE_CUSTOMER_ACCOUNT_LIST', format: 'XLSX', taskName: '客户账户',
parameters: {
...(customerId ? { customerId } : {}),
...(status ? { status } : {}),
},
})
await finishExport(task)
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户账户导出任务创建失败')) }
finally { accountExportBusy.value = false }
}
async function exportLedger() {
if (!selected.value) return
const range = Array.isArray(ledgerFilters.value.occurredRange) ? ledgerFilters.value.occurredRange.map(String) : []
const businessType = String(ledgerFilters.value.businessType || '').trim()
ledgerExportBusy.value = true
try {
const task = await createPlatformExportTask({
exportType: 'FINANCE_CUSTOMER_ACCOUNT_LEDGER', format: 'XLSX', taskName: '客户账户流水',
parameters: {
customerId: selected.value.customerId,
...(businessType ? { businessType } : {}),
...(range[0] ? { startDate: range[0] } : {}),
...(range[1] ? { endDate: range[1] } : {}),
},
})
await finishExport(task)
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户账户流水导出任务创建失败')) }
finally { ledgerExportBusy.value = false }
}
async function closeDetail() {
detailOpen.value = false; selected.value = null; editing.value = false; ledger.value = []
if (route.name === 'finance-customer-account-detail') await router.push({ name: 'finance-customer-accounts' })
}
async function restoreAccountDetail(customerId: string) {
detailOpen.value = true; editing.value = false; ledgerError.value = null
try {
selected.value = (await listFinanceAccounts({ page: 1, size: 1, customerId })).records.find(row => row.customerId === customerId) || null
if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return }
await Promise.all([loadLedger(), loadRechargeRequests()])
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') }
}
onMounted(() => {
const customerId = String(route.params.customerId || '')
if (route.name === 'finance-customer-account-detail' && customerId) { if (canListCustomers.value) void loadCustomers(); void restoreAccountDetail(customerId) }
else { if (canListCustomers.value) void loadCustomers(); void load() }
})
onBeforeUnmount(clearVoucherPreview)
</script>
<template>
<AdminPage as="div" class="finance-account-page admin-density" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通客户账户查看权限" />
<ListPageLayout v-else-if="!detailOpen" aria-label="客户账户">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!stateError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>客户账户</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
<template #notices><template v-if="stateError"><LoadErrorState title="客户账户加载失败" :message="stateError.message" :loading="loading || customersLoading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template>
<template #actions="{ row }"><RowActions text-actions :actions="accountActions(row as FinanceAccountSummary)" :more-label="`更多客户账户操作 · ${(row as FinanceAccountSummary).customerName || '当前客户'}`" @action="handleAccountAction($event, row as FinanceAccountSummary)" /></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
<template #actualBalance="{ row }"><strong class="balance-value is-actual" :class="{ 'is-negative': Number(row.actualBalance) < 0 }">{{ money(row.actualBalance) }}</strong></template><template #frozenBalance="{ row }"><span class="balance-value is-frozen">{{ money(row.frozenBalance) }}</span></template><template #availableBalance="{ row }"><strong class="balance-value">{{ money(row.availableBalance) }}</strong></template><template #creditLimit="{ row }">{{ money(row.creditLimit) }}</template><template #creditExpiresOn="{ row }"><span :class="{ 'credit-expired': row.creditExpiresOn && !row.creditActive }">{{ creditExpiryLabel(row as FinanceAccountSummary) }}</span></template><template #creditUsed="{ row }"><span class="balance-value is-frozen">{{ money(row.creditUsed) }}</span></template>
<template #pendingRechargeCount="{ row }"><el-tag v-if="row.pendingRechargeCount" type="warning">{{ row.pendingRechargeCount }} 笔</el-tag><span v-else>—</span></template>
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left><span>共 {{ total }} 个客户账户</span></template>
</DataTable></section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-account-detail admin-density" spacing="normal">
<section class="surface-panel finance-account-detail__surface">
<DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div></div></template>
<template v-if="selected && !ledgerError" #summary><dl class="density-detail-overview account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><dt>实际余额</dt><dd :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }} <small>CNY</small></dd></div><div class="account-metric is-frozen"><dt>冻结金额</dt><dd>{{ money(selected.frozenBalance) }} <small>CNY</small></dd></div><div class="account-metric is-available"><dt>总可用余额</dt><dd>{{ money(selected.availableBalance) }} <small>CNY</small></dd></div><div class="account-metric"><dt>授信额度</dt><dd>{{ money(selected.creditLimit) }} <small>{{ creditExpiryLabel(selected) }}</small></dd></div><div class="account-metric is-frozen"><dt>已用授信</dt><dd>{{ money(selected.creditUsed) }} <small>CNY</small></dd></div><div class="account-metric"><dt>可用授信</dt><dd>{{ money(selected.availableCredit) }} <small>CNY</small></dd></div></dl></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions" :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
<LoadErrorState v-if="rechargeError" title="充值申请加载失败" :message="rechargeError.message" :loading="rechargeLoading" @retry="loadRechargeRequests" />
<div v-else class="detail-table-scroll"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
<template #actions="{ row }"><div class="density-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template>
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template>
<template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template>
<template #status="{ row }"><el-tag :type="row.status === 'PENDING' ? 'warning' : row.status === 'APPROVED' ? 'success' : 'danger'">{{ row.status === 'PENDING' ? '待审核' : row.status === 'APPROVED' ? '已通过' : '已驳回' }}</el-tag></template>
</DataTable></div>
</FormSection>
<FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection>
<FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
</template>
<template v-if="editing && selected && !ledgerError" #footer><div class="detail-footer"><el-button @click="closeDetail">关闭</el-button><el-button v-if="canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog class="admin-density-dialog" v-model="adjustmentOpen" :title="adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'" width="min(720px, calc(100vw - 28px))" :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
<el-alert :title="adjustmentForm.type === 'RECHARGE' ? '充值会先归还已用授信,剩余金额计入实际余额。' : '扣款先使用未冻结的实际余额,不足部分再使用可用授信。'" :type="adjustmentForm.type === 'RECHARGE' ? 'info' : 'warning'" :closable="false" />
<el-form label-position="top" class="wallet-form density-form"><div class="wallet-form__grid"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前实际余额"><el-input :model-value="money(adjustmentAccount?.actualBalance)" readonly /></el-form-item><el-form-item v-if="adjustmentForm.type === 'DEDUCTION'" label="当前可用余额"><el-input :model-value="money(adjustmentAccount?.availableBalance)" readonly /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值金额' : '扣款金额'" required><el-input v-model="adjustmentForm.amount" inputmode="decimal" placeholder="请输入大于 0 的金额" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '到账日期' : '扣款日期'" required><el-date-picker v-model="adjustmentForm.occurredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值方式' : '扣款方式'" required><el-select v-model="adjustmentForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="关联单据"><el-input v-model="adjustmentForm.referenceNo" maxlength="128" placeholder="请输入关联业务单号(选填)" /></el-form-item></div><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值备注' : '扣款备注'"><el-input v-model="adjustmentForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="creditLimitOpen" title="设置授信额度" width="min(560px, calc(100vw - 28px))" confirm-text="保存授信额度" :confirm-loading="creditLimitSaving" @confirm="submitCreditLimit">
<el-alert title="授信到期日当天仍有效,次日授信失效;届时负数实际余额将阻止客户提交新订单。" type="warning" :closable="false" />
<el-form label-position="top" class="wallet-form density-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="授信到期日" required><el-date-picker v-model="creditLimitForm.expiresOn" type="date" value-format="YYYY-MM-DD" :disabled-date="disableCreditExpiry" placeholder="请选择今天以后的日期" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="reviewOpen" title="审核充值申请" width="min(620px, calc(100vw - 28px))" confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-descriptions class="density-descriptions" v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
<el-form label-position="top" class="review-form density-form"><el-form-item label="审核结果" required><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" :show-footer="false" @closed="clearVoucherPreview">
<div v-loading="voucherPreviewLoading" class="voucher-preview"><img v-if="voucherPreviewUrl" class="voucher-preview__image" :src="voucherPreviewUrl" :alt="voucherPreviewName" /></div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.finance-account-page {
min-width: 0;
}
.finance-account-detail {
width: 100%;
min-width: 0;
}
.finance-account-detail__surface {
width: min(100%, 1920px);
margin: 0 auto;
overflow: hidden;
}
.finance-account-detail__layout {
width: 100%;
margin: 0 auto;
}
.detail-footer,
.term-control {
display: flex;
align-items: center;
gap: var(--admin-space-3);
}
.balance-value {
color: var(--cc-text);
font-variant-numeric: tabular-nums;
}
.balance-value.is-actual {
color: var(--el-color-success);
}
.balance-value.is-frozen {
color: var(--el-color-warning);
}
.balance-value.is-negative,
.credit-expired {
color: var(--el-color-danger);
}
.voucher-preview {
display: grid;
min-height: 320px;
place-items: center;
}
.voucher-preview__image {
display: block;
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.ledger-section {
padding: var(--admin-space-4) 0 0;
}
.recharge-section {
padding: var(--admin-space-4) 0 0;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
.recharge-table {
min-width: 0;
}
.ledger-query {
margin-bottom: var(--admin-space-3);
}
:deep(.ledger-query.query-toolbar) {
padding: 0;
border: 0;
background: transparent;
}
.detail-table-scroll {
overflow-x: auto;
}
.ledger-table {
min-width: 0;
}
.ledger-identifier,
.ledger-document {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.term-section {
width: 100%;
padding: var(--admin-space-4) 0;
}
.term-editor {
display: grid;
grid-template-columns: 120px minmax(0, 220px);
justify-content: start;
align-items: center;
gap: var(--admin-space-5);
padding: 2px 0;
}
.term-label {
color: var(--cc-text);
font-size: var(--admin-font-section);
font-weight: 600;
}
.term-control :deep(.el-input-number) {
width: 220px;
}
.term-unit {
color: var(--cc-muted);
}
.wallet-form,
.review-form {
margin-top: var(--admin-space-5);
}
.wallet-form__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 var(--admin-space-5);
}
.wallet-form :deep(.el-date-editor),
.wallet-form :deep(.el-select) {
width: 100%;
}
.detail-footer {
width: 100%;
justify-content: flex-end;
margin-top: 0;
padding-top: var(--admin-space-3);
border-top: 0;
}
@media (min-width: 1920px) {
.detail-footer {
padding-top: 0;
}
}
@media (max-width: 1080px) {
.account-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.account-metric:nth-child(3),
.account-metric:nth-child(5) {
padding-left: 0;
border-left: 0;
}
.finance-account-detail__surface {
width: 100%;
}
}
@media (max-width: 720px) {
.wallet-form__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.detail-heading {
align-items: stretch;
flex-direction: column;
}
.detail-back {
margin-left: 0;
}
.detail-footer .el-button {
min-height: 44px;
}
.term-editor {
grid-template-columns: 1fr;
}
.account-metrics {
grid-template-columns: 1fr;
gap: 12px;
}
.account-metric,
.account-metric:first-child,
.account-metric:nth-child(3),
.account-metric:nth-child(5) {
padding-left: 0;
}
.account-metric + .account-metric {
border-left: 0;
}
}
@media (max-width: 480px) {
.detail-footer .el-button {
flex: 1 1 120px;
}
}
</style>