Files
admin-web/src/modules/finance/pages/FinanceCustomerAccountsView.vue
T
2026-08-11 18:43:56 +08:00

132 lines
11 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 { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, Save } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.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 { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
const access = useAccessPreviewStore()
const filters = reactive<QueryModel>({ keyword: '', status: '' })
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 drawerOpen = 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 canView = computed(() => access.can('platform:finance:account:view'))
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', width: 260, placeholder: '客户编号/客户名称/联系人/手机号', props: { 'aria-label': '关键词' } },
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'customerName', label: '客户', minWidth: 180 },
{ key: 'customerCode', label: '客户编号', width: 150 },
{ key: 'contactName', label: '联系人', width: 120 },
{ key: 'contactPhone', label: '联系电话', width: 140 },
{ key: 'outstandingReceivable', label: '待收(CNY)', width: 130 },
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
{ key: 'outstandingPayable', label: '待付(CNY)', width: 130 },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170 },
]
async function load(model: QueryModel = filters) {
Object.assign(filters, model)
if (!canView.value) return
loading.value = true
loadError.value = null
try {
const result = await listFinanceAccounts({ page: page.value, size: pageSize.value, keyword: String(filters.keyword || '').trim(), status: String(filters.status || '') })
rows.value = result.records
total.value = result.total
} catch (error) { loadError.value = getApiErrorDetails(error, '客户账户加载失败,请重试') }
finally { loading.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
drawerOpen.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
}
ledgerLoading.value = true
try {
const result = await listFinanceAccountLedger(selected.value.customerId, { page: 1, size: 200 })
ledger.value = result.records
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户流水加载失败,请重试') }
finally { ledgerLoading.value = false }
}
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) { return Number(value || 0).toFixed(2) }
onMounted(() => void load())
</script>
<template>
<AdminPage as="div" class="finance-account-page" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通客户账户查看权限" />
<ListPageLayout v-else aria-label="客户账户">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
<template v-if="loadError" #state><LoadErrorState title="客户账户加载失败" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel">
<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()">
<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>
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left>币种:CNY · 账户数据由对账单、收付款和费用来源派生</template>
</DataTable>
</section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" :title="editing ? '维护客户账期' : '客户账户详情'" size="min(760px, 94vw)" :loading="ledgerLoading" :confirm-loading="saving">
<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">
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
<FormSection v-if="!editing" title="账户流水" description="按后端正式流水 DTO 展示应收、应付及变动后未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ entry.businessType }} · {{ entry.documentType }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection>
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection>
</template>
<template #footer><div class="drawer-footer"><el-button @click="drawerOpen = false">关闭</el-button><el-button v-if="editing && canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
</AppDrawer>
</AdminPage>
</template>
<style scoped>
.finance-account-page{min-width:0}.row-actions,.drawer-footer{display:flex;align-items:center;gap:8px}.drawer-footer{justify-content:flex-end}.row-actions .el-button{margin-left:0}.account-detail{margin-bottom:16px}.term-unit{margin-left:8px;color:var(--cc-muted)}.ledger-entry{display:grid;grid-template-columns:minmax(160px,1fr) auto minmax(210px,auto);gap:8px 16px;padding:12px 0;border-bottom:1px solid var(--el-border-color-lighter)}.ledger-entry>div:first-child,.ledger-money{display:grid;gap:3px}.ledger-entry span,.ledger-entry small{color:var(--cc-muted)}.ledger-entry small{grid-column:1/-1}.ledger-money{text-align:right}.ledger-money b{white-space:nowrap}@media(max-width:720px){.drawer-footer .el-button{min-height:44px}.ledger-entry{grid-template-columns:1fr 1fr}.ledger-money{grid-column:1/-1;text-align:left}:deep(.account-detail .el-descriptions__body .el-descriptions__table){table-layout:fixed}}@media(max-width:480px){.drawer-footer .el-button{flex:1 1 120px}.ledger-entry{grid-template-columns:1fr}:deep(.account-detail .el-descriptions__cell){display:block;width:100%}}
</style>