2026-08-11 10:46:01 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
2026-08-15 09:47:24 +08:00
|
|
|
|
import { CircleDollarSign, Eye, RefreshCcw, Search } from '@lucide/vue'
|
2026-08-11 10:46:01 +08:00
|
|
|
|
import { ApiError } from '@/api/http'
|
|
|
|
|
|
import { getMerchantFinanceBill, getMerchantFinanceBills, getMerchantFinanceReceipts } from '../api/finance'
|
|
|
|
|
|
import type { FinanceBillDetail, FinanceBillQuery, FinanceBillStatus, FinanceBillSummary } from '../contracts'
|
|
|
|
|
|
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const detailLoading = ref(false)
|
|
|
|
|
|
const errorMessage = ref('')
|
|
|
|
|
|
const detailError = ref('')
|
|
|
|
|
|
const bills = ref<FinanceBillSummary[]>([])
|
|
|
|
|
|
const detail = ref<FinanceBillDetail | null>(null)
|
|
|
|
|
|
const selectedBillId = ref('')
|
|
|
|
|
|
const detailVisible = ref(false)
|
|
|
|
|
|
const page = ref(1)
|
|
|
|
|
|
const pageSize = ref(20)
|
|
|
|
|
|
const total = ref(0)
|
|
|
|
|
|
const totals = reactive({ billed: '0', received: '0', outstanding: '0' })
|
|
|
|
|
|
const filters = reactive<FinanceBillQuery>({ keyword: '', status: undefined, page: 1, size: 20 })
|
|
|
|
|
|
|
|
|
|
|
|
const statusOptions: Array<{ label: string; value: FinanceBillStatus }> = [
|
|
|
|
|
|
{ label: '已出账', value: 'ISSUED' },
|
|
|
|
|
|
{ label: '部分收款', value: 'PARTIALLY_PAID' },
|
|
|
|
|
|
{ label: '已收清', value: 'PAID' },
|
|
|
|
|
|
{ label: '作废', value: 'VOIDED' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const hasData = computed(() => bills.value.length > 0)
|
|
|
|
|
|
|
|
|
|
|
|
function formatAmount(value: string | null | undefined) {
|
|
|
|
|
|
if (!value) return '0.00'
|
|
|
|
|
|
const numeric = Number(value)
|
|
|
|
|
|
return Number.isFinite(numeric) ? numeric.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDate(value: string | null | undefined) {
|
|
|
|
|
|
if (!value) return '-'
|
|
|
|
|
|
return value.slice(0, 10)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function statusLabel(status: FinanceBillStatus) {
|
|
|
|
|
|
return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废' })[status] ?? status
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function statusType(status: FinanceBillStatus) {
|
|
|
|
|
|
return ({ ISSUED: 'info', PARTIALLY_PAID: 'warning', PAID: 'success', VOIDED: 'danger', DRAFT: 'info' })[status] as 'info' | 'warning' | 'success' | 'danger'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function messageOf(error: unknown) {
|
|
|
|
|
|
return error instanceof ApiError ? error.message : '账单加载失败,请稍后重试'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadBills(nextPage = page.value) {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
errorMessage.value = ''
|
|
|
|
|
|
page.value = nextPage
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await getMerchantFinanceBills({ ...filters, keyword: filters.keyword?.trim() || undefined, page: nextPage, size: pageSize.value })
|
|
|
|
|
|
bills.value = response.records
|
|
|
|
|
|
total.value = response.total
|
|
|
|
|
|
totals.billed = response.totalBilledAmount
|
|
|
|
|
|
totals.received = response.totalReceivedAmount
|
|
|
|
|
|
totals.outstanding = response.totalOutstandingAmount
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
bills.value = []
|
|
|
|
|
|
total.value = 0
|
|
|
|
|
|
totals.billed = '0'
|
|
|
|
|
|
totals.received = '0'
|
|
|
|
|
|
totals.outstanding = '0'
|
|
|
|
|
|
errorMessage.value = messageOf(error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openDetail(row: FinanceBillSummary) {
|
|
|
|
|
|
selectedBillId.value = row.id
|
|
|
|
|
|
await loadDetail(row.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadDetail(billId: string) {
|
|
|
|
|
|
detailVisible.value = true
|
|
|
|
|
|
detailLoading.value = true
|
|
|
|
|
|
detailError.value = ''
|
|
|
|
|
|
detail.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [bill, receipts] = await Promise.all([getMerchantFinanceBill(billId), getMerchantFinanceReceipts(billId)])
|
|
|
|
|
|
detail.value = { ...bill, receipts }
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
detailError.value = messageOf(error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
detailLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetFilters() {
|
|
|
|
|
|
filters.keyword = ''
|
|
|
|
|
|
filters.status = undefined
|
|
|
|
|
|
void loadBills(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handlePageChange(nextPage: number) {
|
|
|
|
|
|
void loadBills(nextPage)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => { void loadBills(1) })
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="finance-bills-page">
|
2026-08-15 09:47:24 +08:00
|
|
|
|
<section class="finance-summary" aria-label="账单汇总">
|
|
|
|
|
|
<span>账单总额 <strong>¥ {{ formatAmount(totals.billed) }}</strong></span>
|
|
|
|
|
|
<span>已收 <strong>¥ {{ formatAmount(totals.received) }}</strong></span>
|
|
|
|
|
|
<span class="amount-warning">未收 <strong>¥ {{ formatAmount(totals.outstanding) }}</strong></span>
|
2026-08-11 10:46:01 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="content-card">
|
|
|
|
|
|
<form class="filter-bar" @submit.prevent="loadBills(1)">
|
|
|
|
|
|
<el-input v-model="filters.keyword" clearable placeholder="搜索账单编号" aria-label="搜索账单编号" class="keyword-input">
|
|
|
|
|
|
<template #prefix><Search :size="16" /></template>
|
|
|
|
|
|
</el-input>
|
|
|
|
|
|
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="账单状态" class="status-select">
|
|
|
|
|
|
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
|
|
|
|
|
|
</el-select>
|
|
|
|
|
|
<el-button type="primary" native-type="submit" :loading="loading">查询</el-button>
|
|
|
|
|
|
<el-button :disabled="loading" @click="resetFilters">重置</el-button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="errorMessage" class="state-panel state-panel--error" role="alert">
|
|
|
|
|
|
<p>{{ errorMessage }}</p>
|
|
|
|
|
|
<el-button @click="loadBills(1)"><RefreshCcw :size="15" />重试</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-table v-else v-loading="loading" :data="bills" row-key="billNo" class="finance-table" aria-label="客户账单列表">
|
|
|
|
|
|
<el-table-column label="操作" width="90" fixed="left">
|
|
|
|
|
|
<template #default="{ row }"><el-button link type="primary" @click="openDetail(row as FinanceBillSummary)"><Eye :size="15" />查看</el-button></template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="billNo" label="账单编号" min-width="160" />
|
|
|
|
|
|
<el-table-column prop="issueDate" label="出账日期" width="120"><template #default="{ row }">{{ formatDate(row.issueDate) }}</template></el-table-column>
|
|
|
|
|
|
<el-table-column prop="dueDate" label="到期日" width="120"><template #default="{ row }">{{ formatDate(row.dueDate) }}</template></el-table-column>
|
|
|
|
|
|
<el-table-column label="账单金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.totalAmount) }}</template></el-table-column>
|
|
|
|
|
|
<el-table-column label="已收" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.receivedAmount) }}</template></el-table-column>
|
|
|
|
|
|
<el-table-column label="未收余额" width="145" align="right"><template #default="{ row }"><strong :class="{ 'amount-warning': row.outstandingAmount !== '0' }">¥ {{ formatAmount(row.outstandingAmount) }}</strong></template></el-table-column>
|
|
|
|
|
|
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
|
|
|
|
|
<template #empty><div class="table-empty"><CircleDollarSign :size="32" /><strong>暂无账单</strong><span>平台财务出账后,这里会显示本企业账单。</span></div></template>
|
|
|
|
|
|
</el-table>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="!errorMessage && hasData" class="pagination-row"><span>共 {{ total }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" layout="prev, pager, next" :total="total" @current-change="handlePageChange" /></div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<el-drawer v-model="detailVisible" title="账单详情" size="min(680px, 100%)" destroy-on-close>
|
|
|
|
|
|
<div v-loading="detailLoading" class="detail-content">
|
|
|
|
|
|
<div v-if="detailError" class="state-panel state-panel--error" role="alert"><p>{{ detailError }}</p><el-button @click="loadDetail(selectedBillId)">重试</el-button></div>
|
|
|
|
|
|
<template v-else-if="detail">
|
2026-08-15 09:47:24 +08:00
|
|
|
|
<div class="detail-heading"><div><h2>{{ detail.billNo }}</h2><p>出账 {{ formatDate(detail.issueDate) }} · 到期 {{ formatDate(detail.dueDate) }} · 未收 <strong class="amount-warning">¥ {{ formatAmount(detail.outstandingAmount) }}</strong></p></div><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag></div>
|
|
|
|
|
|
<section class="detail-section"><h3>费用明细</h3><el-table :data="detail.lines" size="small"><el-table-column prop="feeName" label="费用项目" min-width="180" /><el-table-column label="金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column></el-table></section>
|
2026-08-11 10:46:01 +08:00
|
|
|
|
<section class="detail-section"><h3>收款记录</h3><el-table v-if="detail.receipts.length" :data="detail.receipts" size="small"><el-table-column prop="receivedAt" label="收款日期" width="110"><template #default="{ row }">{{ formatDate(row.receivedAt) }}</template></el-table-column><el-table-column prop="receiptNo" label="收款编号" min-width="130" /><el-table-column label="本次金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.allocatedAmount) }}</template></el-table-column><el-table-column prop="method" label="方式" min-width="100" /></el-table><div v-else class="sub-empty">暂无收款记录</div></section>
|
|
|
|
|
|
<p class="readonly-notice">账单金额和收款记录仅供查看。如有疑问,请线下联系平台财务。</p>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-drawer>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-08-15 09:47:24 +08:00
|
|
|
|
.finance-bills-page { display: flex; flex-direction: column; gap: 12px; }
|
|
|
|
|
|
.finance-summary { display: flex; flex-wrap: wrap; gap: 8px 26px; color: var(--muted); font-size: 13px; }.finance-summary strong { margin-left: 6px; color: var(--navy); font-size: 18px; }.amount-warning, .amount-warning strong { color: #b45309; }
|
2026-08-11 10:46:01 +08:00
|
|
|
|
.content-card { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface, #fff); }
|
|
|
|
|
|
.filter-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }.keyword-input { width: 260px; }.status-select { width: 140px; }
|
|
|
|
|
|
.finance-table { width: 100%; }.finance-table :deep(.el-button) { display: inline-flex; align-items: center; gap: 4px; }
|
|
|
|
|
|
.state-panel { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 12px; flex-direction: column; color: var(--muted); text-align: center; }.state-panel p { margin: 0; }.state-panel--error { color: #b42318; background: #fff8f7; border: 1px solid #f5c2bd; border-radius: 6px; }
|
|
|
|
|
|
.table-empty { display: flex; align-items: center; flex-direction: column; gap: 8px; padding: 42px 12px; color: var(--muted); }.table-empty strong { color: var(--navy); }.table-empty span { font-size: 12px; }
|
|
|
|
|
|
.pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; color: var(--muted); font-size: 12px; }
|
2026-08-15 09:47:24 +08:00
|
|
|
|
.detail-content { min-height: 300px; }.detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.detail-heading h2 { margin: 0 0 5px; color: var(--navy); font-size: 18px; }.detail-heading p { margin: 0; color: var(--muted); font-size: 13px; }.detail-section { margin-top: 22px; }.detail-section h3 { margin: 0 0 10px; color: var(--navy); font-size: 14px; }.sub-empty { padding: 22px; color: var(--muted); font-size: 12px; text-align: center; background: #f7fafb; }.readonly-notice { margin: 22px 0 0; padding: 11px 12px; color: #45615d; background: #edf6f4; font-size: 12px; }
|
|
|
|
|
|
@media (max-width: 720px) { .keyword-input, .status-select { width: 100%; }.filter-bar > .el-button { flex: 1; }.content-card { padding: 12px; }.pagination-row { align-items: flex-start; flex-direction: column; }.finance-summary { align-items: flex-start; flex-direction: column; gap: 6px; } }
|
2026-08-11 10:46:01 +08:00
|
|
|
|
</style>
|