2026-08-11 18:06:35 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-08-13 11:57:44 +08:00
|
|
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
|
import { ArrowLeft, Download, Eye, Pencil, Save } from '@lucide/vue'
|
2026-08-11 18:06:35 +08:00
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
2026-08-14 12:50:42 +08:00
|
|
|
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
2026-08-11 18:06:35 +08:00
|
|
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
|
|
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
2026-08-17 12:31:27 +08:00
|
|
|
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
2026-08-11 18:06:35 +08:00
|
|
|
|
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'
|
2026-08-11 18:43:56 +08:00
|
|
|
|
import { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
|
2026-08-13 11:57:44 +08:00
|
|
|
|
import { financeBusinessTypeLabel } from '../status'
|
2026-08-11 18:43:56 +08:00
|
|
|
|
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
|
2026-08-11 18:06:35 +08:00
|
|
|
|
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
2026-08-13 11:57:44 +08:00
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
const router = useRouter()
|
2026-08-14 12:50:42 +08:00
|
|
|
|
const filters = ref<QueryModel>({ customerId: '', status: '' })
|
|
|
|
|
|
const customers = ref<PlatformTenantResource[]>([])
|
|
|
|
|
|
const customersLoading = ref(false)
|
|
|
|
|
|
const customersError = ref<ApiErrorDetails | null>(null)
|
2026-08-11 18:43:56 +08:00
|
|
|
|
const rows = ref<FinanceAccountSummary[]>([])
|
2026-08-11 18:06:35 +08:00
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const loadError = ref<ApiErrorDetails | null>(null)
|
2026-08-11 18:43:56 +08:00
|
|
|
|
const page = ref(1)
|
|
|
|
|
|
const pageSize = ref(20)
|
|
|
|
|
|
const total = ref(0)
|
2026-08-13 11:57:44 +08:00
|
|
|
|
const detailOpen = ref(false)
|
2026-08-11 18:06:35 +08:00
|
|
|
|
const editing = ref(false)
|
|
|
|
|
|
const saving = ref(false)
|
2026-08-11 18:43:56 +08:00
|
|
|
|
const selected = ref<FinanceAccountSummary | null>(null)
|
|
|
|
|
|
const ledger = ref<FinanceLedgerEntry[]>([])
|
|
|
|
|
|
const ledgerLoading = ref(false)
|
|
|
|
|
|
const ledgerError = ref<ApiErrorDetails | null>(null)
|
2026-08-11 18:06:35 +08:00
|
|
|
|
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
|
|
|
|
|
|
const creditDays = ref(30)
|
2026-08-13 11:57:44 +08:00
|
|
|
|
const ledgerFilters = ref<QueryModel>({ businessType: '', occurredRange: [] })
|
|
|
|
|
|
const ledgerPage = ref(1)
|
|
|
|
|
|
const ledgerPageSize = ref(20)
|
|
|
|
|
|
const ledgerTotal = ref(0)
|
2026-08-11 18:06:35 +08:00
|
|
|
|
|
2026-08-13 14:37:59 +08:00
|
|
|
|
const canView = computed(() => access.can('platform:finance:customer-account:list'))
|
2026-08-14 12:50:42 +08:00
|
|
|
|
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
|
2026-08-13 12:37:49 +08:00
|
|
|
|
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
|
2026-08-11 18:43:56 +08:00
|
|
|
|
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
|
2026-08-14 12:50:42 +08:00
|
|
|
|
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) })) },
|
2026-08-11 18:43:56 +08:00
|
|
|
|
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] },
|
2026-08-14 12:50:42 +08:00
|
|
|
|
])
|
2026-08-11 18:06:35 +08:00
|
|
|
|
const columns: DataTableColumn[] = [
|
|
|
|
|
|
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
2026-08-14 14:27:03 +08:00
|
|
|
|
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
|
2026-08-11 18:43:56 +08:00
|
|
|
|
{ key: 'outstandingReceivable', label: '待收(CNY)', width: 130 },
|
|
|
|
|
|
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
|
|
|
|
|
|
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
|
|
|
|
|
|
{ key: 'outstandingPayable', label: '待付(CNY)', width: 130 },
|
2026-08-11 18:06:35 +08:00
|
|
|
|
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
|
2026-08-15 10:03:03 +08:00
|
|
|
|
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
|
2026-08-11 18:06:35 +08:00
|
|
|
|
]
|
2026-08-13 11:57:44 +08:00
|
|
|
|
const ledgerQueryFields: QueryField[] = [
|
|
|
|
|
|
{ key: 'businessType', type: 'select', label: '流水类型', width: 180, placeholder: '全部类型', options: [
|
2026-08-17 16:45:46 +08:00
|
|
|
|
{ label: '纳入结算', value: 'FEE_INCLUDED' }, { label: '排除结算', value: 'FEE_EXCLUDED' },
|
|
|
|
|
|
{ label: '生成对账单', value: 'RECONCILIATION_CREATED' }, { label: '结算单生效', value: 'SETTLEMENT_ISSUED' },
|
|
|
|
|
|
{ label: '收款', value: 'RECEIPT' }, { label: '付款', value: 'PAYMENT' },
|
|
|
|
|
|
{ label: '账务调整', value: 'ADJUSTMENT' }, { label: '作废', value: 'VOID' },
|
2026-08-13 11:57:44 +08:00
|
|
|
|
] },
|
|
|
|
|
|
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
|
|
|
|
|
|
]
|
|
|
|
|
|
const ledgerColumns: DataTableColumn[] = [
|
2026-08-17 16:45:46 +08:00
|
|
|
|
{ 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: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
|
2026-08-14 14:27:03 +08:00
|
|
|
|
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
|
2026-08-13 11:57:44 +08:00
|
|
|
|
]
|
|
|
|
|
|
const summary = computed(() => rows.value.reduce((result, row) => ({
|
|
|
|
|
|
receivable: result.receivable + row.outstandingReceivable,
|
|
|
|
|
|
pending: result.pending + row.pendingBilling,
|
|
|
|
|
|
received: result.received + row.receivedInPeriod,
|
|
|
|
|
|
payable: result.payable + row.outstandingPayable,
|
|
|
|
|
|
}), { receivable: 0, pending: 0, received: 0, payable: 0 }))
|
2026-08-11 18:06:35 +08:00
|
|
|
|
|
2026-08-11 19:59:15 +08:00
|
|
|
|
async function load(model: QueryModel = filters.value) {
|
|
|
|
|
|
Object.assign(filters.value, model)
|
2026-08-11 18:43:56 +08:00
|
|
|
|
if (!canView.value) return
|
2026-08-11 18:06:35 +08:00
|
|
|
|
loading.value = true
|
2026-08-11 18:43:56 +08:00
|
|
|
|
loadError.value = null
|
2026-08-11 18:06:35 +08:00
|
|
|
|
try {
|
2026-08-14 12:50:42 +08:00
|
|
|
|
const result = await listFinanceAccounts({ page: page.value, size: pageSize.value, customerId: String(filters.value.customerId || '').trim() || undefined, status: String(filters.value.status || '') })
|
2026-08-11 18:43:56 +08:00
|
|
|
|
rows.value = result.records
|
|
|
|
|
|
total.value = result.total
|
2026-08-11 18:06:35 +08:00
|
|
|
|
} catch (error) { loadError.value = getApiErrorDetails(error, '客户账户加载失败,请重试') }
|
|
|
|
|
|
finally { loading.value = false }
|
|
|
|
|
|
}
|
2026-08-14 12:50:42 +08:00
|
|
|
|
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 }
|
|
|
|
|
|
}
|
2026-08-11 18:43:56 +08:00
|
|
|
|
function search(model: QueryModel) { page.value = 1; void load(model) }
|
2026-08-11 18:06:35 +08:00
|
|
|
|
async function openAccount(row: Record<string, unknown>, edit: boolean) {
|
2026-08-11 18:43:56 +08:00
|
|
|
|
selected.value = row as unknown as FinanceAccountSummary
|
|
|
|
|
|
editing.value = edit
|
2026-08-13 11:57:44 +08:00
|
|
|
|
if (!edit) {
|
|
|
|
|
|
await router.push({ name: 'finance-customer-account-detail', params: { customerId: selected.value.customerId } })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
detailOpen.value = true
|
2026-08-11 18:43:56 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-13 11:57:44 +08:00
|
|
|
|
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) : []
|
2026-08-11 18:43:56 +08:00
|
|
|
|
try {
|
2026-08-13 11:57:44 +08:00
|
|
|
|
const result = await listFinanceAccountLedger(selected.value.customerId, { page: ledgerPage.value, size: ledgerPageSize.value, businessType: String(ledgerFilters.value.businessType || ''), startDate: range[0], endDate: range[1] })
|
2026-08-11 18:43:56 +08:00
|
|
|
|
ledger.value = result.records
|
2026-08-13 11:57:44 +08:00
|
|
|
|
ledgerTotal.value = result.total
|
2026-08-11 18:43:56 +08:00
|
|
|
|
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户流水加载失败,请重试') }
|
|
|
|
|
|
finally { ledgerLoading.value = false }
|
2026-08-11 18:06:35 +08:00
|
|
|
|
}
|
2026-08-11 18:43:56 +08:00
|
|
|
|
async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, editing.value) }
|
2026-08-11 18:06:35 +08:00
|
|
|
|
async function saveTerms() {
|
|
|
|
|
|
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
|
|
|
|
|
|
saving.value = true
|
2026-08-11 18:43:56 +08:00
|
|
|
|
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) }
|
2026-08-11 18:06:35 +08:00
|
|
|
|
finally { saving.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
2026-08-14 14:27:03 +08:00
|
|
|
|
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()
|
2026-08-17 16:45:46 +08:00
|
|
|
|
const known: Record<string, string> = { REC: '收款', RECONCILIATION: '对账单', FEE_SOURCE: '费用来源' }
|
2026-08-14 14:27:03 +08:00
|
|
|
|
return known[code] || financeBusinessTypeLabel(code)
|
|
|
|
|
|
}
|
|
|
|
|
|
function displayOperator(value: unknown) {
|
|
|
|
|
|
const operator = String(value || '').trim()
|
2026-08-17 16:45:46 +08:00
|
|
|
|
if (!operator) return '名称数据缺失'
|
|
|
|
|
|
return /^(?:operator-)?\d+$/i.test(operator) ? '名称数据缺失' : operator
|
2026-08-14 14:27:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
function formatDateTime(value: unknown) {
|
|
|
|
|
|
const raw = String(value || '').trim()
|
2026-08-17 16:45:46 +08:00
|
|
|
|
if (!raw) return '时间数据缺失'
|
2026-08-14 14:27:03 +08:00
|
|
|
|
const date = new Date(raw)
|
2026-08-17 16:45:46 +08:00
|
|
|
|
return Number.isNaN(date.getTime()) ? '时间数据缺失' : date.toLocaleString('zh-CN', { hour12: false })
|
2026-08-14 14:27:03 +08:00
|
|
|
|
}
|
2026-08-17 16:45:46 +08:00
|
|
|
|
function formatLastChangedAt(value: unknown) { return String(value || '').trim() ? formatDateTime(value) : '暂无变动' }
|
|
|
|
|
|
function displayDocumentNo(value: unknown) { return String(value || '').trim() || '关联单据缺失' }
|
|
|
|
|
|
function displayLedgerId(value: unknown) { return String(value || '').trim() || '流水号缺失' }
|
2026-08-13 11:57:44 +08:00
|
|
|
|
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
|
|
|
|
|
|
function downloadCsv(fileName: string, headers: string[], data: unknown[][]) {
|
|
|
|
|
|
const content = `\uFEFF${[headers, ...data].map((line) => line.map(csvCell).join(',')).join('\r\n')}`
|
|
|
|
|
|
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' }))
|
|
|
|
|
|
const link = document.createElement('a'); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url)
|
|
|
|
|
|
}
|
2026-08-15 09:47:23 +08:00
|
|
|
|
function exportAccounts() { downloadCsv('客户账户.csv', columns.slice(1).map((item) => String(item.label)), rows.value.map((row) => [customerName(row.customerId, row.customerName), row.outstandingReceivable, row.pendingBilling, row.receivedInPeriod, row.outstandingPayable, row.status, row.lastChangedAt])) }
|
2026-08-14 14:27:03 +08:00
|
|
|
|
function exportLedger() { downloadCsv(`${selected.value ? customerName(selected.value.customerId, selected.value.customerName) : '客户'}-账户流水.csv`, ledgerColumns.map((item) => String(item.label)), ledger.value.map((row) => [row.ledgerId, formatDateTime(row.occurredAt), ledgerBusinessType(row.businessType), row.documentNo, row.receivableChange, row.payableChange, row.outstandingReceivableAfter, displayOperator(row.operatorName), row.remark])) }
|
2026-08-13 11:57:44 +08:00
|
|
|
|
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 {
|
|
|
|
|
|
let currentPage = 1; let pages = 1; selected.value = null
|
|
|
|
|
|
do {
|
|
|
|
|
|
const result = await listFinanceAccounts({ page: currentPage, size: 200 })
|
|
|
|
|
|
selected.value = result.records.find((row) => row.customerId === customerId) || null
|
|
|
|
|
|
pages = result.pages; currentPage += 1
|
|
|
|
|
|
} while (!selected.value && currentPage <= pages)
|
|
|
|
|
|
if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return }
|
|
|
|
|
|
await loadLedger()
|
|
|
|
|
|
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') }
|
|
|
|
|
|
}
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
const customerId = String(route.params.customerId || '')
|
2026-08-14 14:27:03 +08:00
|
|
|
|
if (route.name === 'finance-customer-account-detail' && customerId) { if (canListCustomers.value) void loadCustomers(); void restoreAccountDetail(customerId) }
|
2026-08-14 12:50:42 +08:00
|
|
|
|
else { if (canListCustomers.value) void loadCustomers(); void load() }
|
2026-08-13 11:57:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
watch(() => [route.name, String(route.params.customerId || '')], ([name, customerId], previous) => {
|
|
|
|
|
|
if (!previous) return
|
2026-08-13 12:22:41 +08:00
|
|
|
|
if (name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(String(customerId))
|
2026-08-13 11:57:44 +08:00
|
|
|
|
else { detailOpen.value = false; selected.value = null; ledger.value = []; void load() }
|
|
|
|
|
|
})
|
2026-08-11 18:06:35 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<AdminPage as="div" class="finance-account-page" spacing="normal">
|
2026-08-11 18:43:56 +08:00
|
|
|
|
<el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通客户账户查看权限" />
|
2026-08-13 11:57:44 +08:00
|
|
|
|
<ListPageLayout v-else-if="!detailOpen" aria-label="客户账户">
|
2026-08-11 18:43:56 +08:00
|
|
|
|
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
|
2026-08-14 12:50:42 +08:00
|
|
|
|
<template v-if="stateError" #state><LoadErrorState title="客户账户加载失败" :message="stateError.message" :loading="loading || customersLoading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
|
|
|
|
|
|
<section v-if="!stateError" class="surface-panel table-panel">
|
2026-08-11 18:43:56 +08:00
|
|
|
|
<DataTable 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()">
|
2026-08-13 11:57:44 +08:00
|
|
|
|
<template #toolbar-actions><el-button :disabled="loading || !rows.length" @click="exportAccounts"><Download :size="16" />导出</el-button></template>
|
2026-08-11 18:43:56 +08:00
|
|
|
|
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" link type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
|
2026-08-14 14:27:03 +08:00
|
|
|
|
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
|
2026-08-17 16:45:46 +08:00
|
|
|
|
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
|
2026-08-11 18:06:35 +08:00
|
|
|
|
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
|
2026-08-13 11:57:44 +08:00
|
|
|
|
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 应付 {{ money(summary.payable) }} CNY</span></template>
|
2026-08-11 18:06:35 +08:00
|
|
|
|
</DataTable>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</ListPageLayout>
|
2026-08-13 11:57:44 +08:00
|
|
|
|
<AdminPage v-else as="div" class="finance-account-detail" spacing="normal">
|
2026-08-17 12:31:27 +08:00
|
|
|
|
<section class="surface-panel finance-account-detail__surface">
|
|
|
|
|
|
<DetailPageLayout class="finance-account-detail__layout" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
|
|
|
|
|
|
<template #header><div class="detail-heading"><div class="detail-title"><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · 客户财务往来总览</p></div><el-button class="detail-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button></div></template>
|
|
|
|
|
|
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户账户汇总"><div class="account-metric is-receivable"><span>待收金额</span><strong>{{ money(selected.outstandingReceivable) }}</strong><small>CNY</small></div><div class="account-metric is-pending"><span>待开账金额</span><strong>{{ money(selected.pendingBilling) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>本期已收</span><strong>{{ money(selected.receivedInPeriod) }}</strong><small>CNY</small></div><div class="account-metric is-payable"><span>待付金额</span><strong>{{ money(selected.outstandingPayable) }}</strong><small>CNY</small></div></section></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">
|
2026-08-17 13:24:55 +08:00
|
|
|
|
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :bordered="false">
|
2026-08-17 12:31:27 +08:00
|
|
|
|
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" />
|
2026-08-17 16:45:46 +08:00
|
|
|
|
<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 :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
|
2026-08-17 12:31:27 +08:00
|
|
|
|
</FormSection>
|
2026-08-17 13:24:55 +08:00
|
|
|
|
<FormSection v-else class="term-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" /><span class="term-unit">天</span></div></div></FormSection>
|
2026-08-17 12:31:27 +08:00
|
|
|
|
</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>
|
2026-08-13 11:57:44 +08:00
|
|
|
|
</AdminPage>
|
2026-08-11 18:06:35 +08:00
|
|
|
|
</AdminPage>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-08-17 16:45:46 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.finance-account-detail__layout :deep(.detail-page-layout) {
|
|
|
|
|
|
gap: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.finance-account-detail__layout :deep(.detail-page-layout__header) {
|
|
|
|
|
|
min-height: 64px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
|
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.finance-account-detail__layout :deep(.detail-page-layout__summary) {
|
|
|
|
|
|
padding: 10px 16px 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.finance-account-detail__layout :deep(.detail-page-layout__content) {
|
|
|
|
|
|
padding: 0 16px 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.finance-account-detail__layout :deep(.detail-page-layout__footer) {
|
|
|
|
|
|
padding: 0 16px 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.row-actions,
|
|
|
|
|
|
.detail-footer,
|
|
|
|
|
|
.detail-heading,
|
|
|
|
|
|
.term-control {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.row-actions .el-button {
|
|
|
|
|
|
margin-left: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-heading {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-title {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-heading h2 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: var(--cc-text);
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-heading p {
|
|
|
|
|
|
margin: 3px 0 0;
|
|
|
|
|
|
color: var(--cc-muted);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-back {
|
|
|
|
|
|
margin-left: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.account-metrics {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
|
|
|
gap: 0;
|
|
|
|
|
|
padding: 0 0 12px;
|
|
|
|
|
|
border-bottom: 1px solid var(--cc-border, #e3e8ef);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.account-metric {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
padding: 0 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.account-metric:first-child {
|
|
|
|
|
|
padding-left: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.account-metric + .account-metric {
|
|
|
|
|
|
border-left: 1px solid var(--cc-border, #e3e8ef);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.account-metric::before {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.account-metric span {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
color: var(--cc-muted);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.account-metric strong {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
|
color: var(--cc-text);
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.account-metric small {
|
|
|
|
|
|
margin-left: 6px;
|
|
|
|
|
|
color: var(--cc-muted);
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ledger-section {
|
|
|
|
|
|
padding: 10px 0 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ledger-query {
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
: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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.ledger-table .data-table__toolbar.is-compact) {
|
|
|
|
|
|
min-height: 36px;
|
|
|
|
|
|
padding: 0 0 6px;
|
|
|
|
|
|
border-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.ledger-table .data-table__pagination) {
|
|
|
|
|
|
min-height: 40px;
|
|
|
|
|
|
padding: 6px 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.term-section {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 10px 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.term-editor {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 120px minmax(0, 220px);
|
|
|
|
|
|
justify-content: start;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 14px;
|
|
|
|
|
|
padding: 2px 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.term-label {
|
|
|
|
|
|
color: var(--cc-text);
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.term-control :deep(.el-input-number) {
|
|
|
|
|
|
width: 220px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.term-unit {
|
|
|
|
|
|
color: var(--cc-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-footer {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
margin-top: 0;
|
|
|
|
|
|
padding-top: 8px;
|
|
|
|
|
|
border-top: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1080px) {
|
|
|
|
|
|
.account-metrics {
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.account-metric:nth-child(3) {
|
|
|
|
|
|
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) {
|
|
|
|
|
|
padding-left: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.account-metric + .account-metric {
|
|
|
|
|
|
border-left: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 480px) {
|
|
|
|
|
|
.detail-footer .el-button {
|
|
|
|
|
|
flex: 1 1 120px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-11 18:06:35 +08:00
|
|
|
|
</style>
|