Files
admin-web/src/modules/finance/pages/FinanceCustomerAccountsView.vue
T
2026-10-21 13:22:42 +08:00

748 lines
48 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 '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } 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 InlineHelp from '@/shared/components/InlineHelp.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('付款凭证')
let voucherPreviewRequest = 0
watch(voucherPreviewOpen, value => { if (!value) clearVoucherPreview() })
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 (adjustmentSaving.value || !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 (creditLimitSaving.value || !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 (reviewSaving.value || !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() {
voucherPreviewRequest++
if (voucherPreviewUrl.value) URL.revokeObjectURL(voucherPreviewUrl.value)
voucherPreviewUrl.value = ''
}
async function previewVoucher(request: FinanceRechargeRequest) {
clearVoucherPreview()
const requestSequence = ++voucherPreviewRequest
voucherPreviewName.value = request.voucherFileName || '付款凭证'
voucherPreviewOpen.value = true
voucherPreviewLoading.value = true
try {
const blob = await downloadFinanceRechargeVoucher(request.requestId)
if (requestSequence !== voucherPreviewRequest || !voucherPreviewOpen.value) return
voucherPreviewUrl.value = URL.createObjectURL(blob)
} catch (error) {
if (requestSequence !== voucherPreviewRequest || !voucherPreviewOpen.value) return
voucherPreviewOpen.value = false
ElMessage.error(getApiErrorMessage(error, '付款凭证加载失败'))
} finally { if (requestSequence === voucherPreviewRequest) 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 admin-workbench" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通客户账户查看权限" />
<ListPageLayout v-else-if="!detailOpen" aria-label="客户账户">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" 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 workbench-panel is-flush is-page-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 workbench-table"><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 workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
<div class="detail-table-scroll workbench-table"><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 admin-workbench is-dialog" v-model="adjustmentOpen" :title="`${adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'} · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
<div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前账户资料">
<h3 class="density-dialog-section__title">当前账户(CNY) <InlineHelp v-if="adjustmentForm.type === 'RECHARGE'" title="充值说明">充值会先归还已用授信,剩余金额计入实际余额。</InlineHelp></h3>
<table class="density-table wallet-summary"><tbody>
<tr><th scope="row">客户名称</th><td colspan="3">{{ adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—' }}</td></tr>
<tr><th scope="row">当前实际余额</th><td :colspan="adjustmentForm.type === 'DEDUCTION' ? 1 : 3">{{ money(adjustmentAccount?.actualBalance) }}</td><template v-if="adjustmentForm.type === 'DEDUCTION'"><th scope="row">当前可用余额</th><td>{{ money(adjustmentAccount?.availableBalance) }}</td></template></tr>
</tbody></table>
</section>
<el-alert v-if="adjustmentForm.type === 'DEDUCTION'" title="扣款先使用未冻结的实际余额,不足部分再使用可用授信。" type="warning" :closable="false" />
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="账户变动登记">
<h3 class="density-dialog-section__title">{{ adjustmentForm.type === 'RECHARGE' ? '充值登记' : '扣款登记' }}</h3>
<el-form label-position="left" label-width="100px" class="density-form" :disabled="adjustmentSaving">
<table class="density-table density-table--editable wallet-adjustment-table"><tbody>
<tr><td><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值金额' : '扣款金额'" required><el-input v-model="adjustmentForm.amount" inputmode="decimal" placeholder="请输入大于 0 的金额" /></el-form-item></td><td><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></td></tr>
<tr><td><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></td><td><el-form-item label="关联单据"><el-input v-model="adjustmentForm.referenceNo" maxlength="128" placeholder="请输入关联业务单号(选填)" /></el-form-item></td></tr>
<tr><td colspan="2"><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></td></tr>
</tbody></table>
</el-form>
</section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="creditLimitOpen" :title="`设置授信额度 · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="保存授信额度" :confirm-loading="creditLimitSaving" @confirm="submitCreditLimit">
<div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前授信资料"><h3 class="density-dialog-section__title">当前授信(CNY)</h3><table class="density-table wallet-summary"><tbody><tr><th scope="row">客户名称</th><td>{{ adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—' }}</td></tr><tr><th scope="row">当前已用授信</th><td>{{ money(adjustmentAccount?.creditUsed) }}</td></tr></tbody></table></section>
<el-alert title="授信到期日当天仍有效,次日授信失效;届时负数实际余额将阻止客户提交新订单。" type="warning" :closable="false" />
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="授信额度设置"><h3 class="density-dialog-section__title">授信设置</h3>
<el-form label-position="left" label-width="100px" class="density-form" :disabled="creditLimitSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item></td></tr>
<tr><td><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></td></tr>
<tr><td><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></td></tr>
</tbody></table></el-form>
</section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="reviewOpen" :title="`审核充值申请 · ${reviewRequest?.requestNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<div class="density-dialog-sections">
<section v-if="reviewRequest" class="density-dialog-section workbench-evidence" data-tone="source" aria-label="充值申请资料"><h3 class="density-dialog-section__title">充值申请</h3><table class="density-table wallet-summary"><tbody><tr><th scope="row">申请单号</th><td>{{ reviewRequest.requestNo || '—' }}</td></tr><tr><th scope="row">充值金额(CNY)</th><td>{{ money(reviewRequest.amount) }}</td></tr><tr><th scope="row">付款凭证</th><td>{{ reviewRequest.voucherFileName || '—' }}</td></tr></tbody></table></section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="充值审核登记"><h3 class="density-dialog-section__title">审核登记</h3>
<el-form label-position="left" label-width="100px" class="density-form" :disabled="reviewSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><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></td></tr>
<tr><td><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></td></tr>
</tbody></table></el-form>
</section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" fullscreenable :loading="voucherPreviewLoading" :show-footer="false">
<section class="density-dialog-section" data-tone="source" aria-label="付款凭证"><div class="voucher-preview"><img v-if="voucherPreviewUrl" class="voucher-preview__image" :src="voucherPreviewUrl" :alt="voucherPreviewName" /></div></section>
</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(--workbench-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(--workbench-border);
}
.recharge-table {
min-width: 0;
}
.ledger-query {
margin-bottom: var(--admin-space-3);
}
:deep(.ledger-query.query-toolbar) {
padding: 0;
}
.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(--workbench-text);
font-size: var(--admin-font-section);
font-weight: 600;
}
.term-control :deep(.el-input-number) {
width: 220px;
}
.term-unit {
color: var(--workbench-muted);
}
.wallet-summary { table-layout: fixed; }
.wallet-summary th { width: var(--admin-label-width); }
.wallet-adjustment-table { table-layout: fixed; }
.wallet-adjustment-table td:not([colspan]) { width: 50%; }
.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) {
.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>