Files
client-web/src/modules/finance/views/FinanceBillsView.vue
T
2026-09-18 16:48:35 +08:00

191 lines
12 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 { CircleDollarSign, Eye, RefreshCcw, Search } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { ApiError } from '@/api/http'
import { useAuthStore } from '@/modules/auth/public'
import { confirmMerchantFinanceStatement, getMerchantFinanceBills } from '../api/finance'
import type { FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCustomerConfirmationStatus } from '../contracts'
const loading = ref(false)
const auth = useAuthStore()
const router = useRouter()
const errorMessage = ref('')
const bills = ref<FinanceBillSummary[]>([])
const confirmingBillId = ref('')
const confirmationKeys = new Map<string, string>()
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)
const canConfirm = computed(() => auth.hasPermission('finance:bill:confirm'))
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 confirmationLabel(status: FinanceCustomerConfirmationStatus) {
return ({ NOT_REQUIRED: '无需确认', PENDING_CONFIRMATION: '待确认', CONFIRMED: '已确认' })[status]
}
function confirmationType(status: FinanceCustomerConfirmationStatus) {
return ({ NOT_REQUIRED: 'info', PENDING_CONFIRMATION: 'warning', CONFIRMED: 'success' })[status] as 'info' | 'warning' | 'success'
}
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
}
}
function openDetail(row: FinanceBillSummary) { void router.push({ name: 'finance-bill-detail', params: { billNo: row.billNo } }) }
async function confirmStatement(statement: FinanceBillSummary) {
if (!canConfirm.value || statement.customerConfirmationStatus !== 'PENDING_CONFIRMATION' || confirmingBillId.value) return
try {
await ElMessageBox.confirm(
`确认对账单 ${statement.billNo}(¥ ${formatAmount(statement.totalAmount)})的金额与费用明细无误?确认后平台将记录当前登录成员和确认时间。`,
'确认客户对账单',
{ type: 'warning', confirmButtonText: '确认无误', cancelButtonText: '取消' },
)
} catch {
return
}
confirmingBillId.value = statement.id
const statementId = statement.id
let key = confirmationKeys.get(statementId)
if (!key) {
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
key = `statement-confirm-${statementId}-${randomPart}`
confirmationKeys.set(statementId, key)
}
try {
await confirmMerchantFinanceStatement(statementId, statement.version, key)
confirmationKeys.delete(statementId)
ElMessage.success('对账单已确认')
await loadBills(page.value)
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '对账单确认失败,请重试')
} finally {
confirmingBillId.value = ''
}
}
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">
<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>
</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="190" fixed="left">
<template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as FinanceBillSummary)"><Eye :size="15" />查看</el-button><el-button v-if="row.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm" size="small" type="success" plain :loading="confirmingBillId === row.id" :disabled="Boolean(confirmingBillId)" @click="confirmStatement(row as FinanceBillSummary)">确认账单</el-button></div></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>
<el-table-column label="客户确认" width="110"><template #default="{ row }"><el-tag :type="confirmationType(row.customerConfirmationStatus)">{{ confirmationLabel(row.customerConfirmationStatus) }}</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>
</div>
</template>
<style scoped>
.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; }
.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; }.table-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.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; }
.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-statuses { display: flex; align-items: center; gap: 8px; }.confirmation-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding: 14px 16px; border: 1px solid #d7e9e4; border-radius: 8px; background: #f3f9f7; }.confirmation-panel strong { color: var(--navy); }.confirmation-panel p { margin: 4px 0 0; color: var(--muted); font-size: 12px; }.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; } }
</style>