Files
cz-web/src/views/finance/invoices.vue
2026-09-30 15:55:36 +08:00

374 lines
9.4 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, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import { ElMessage } from 'element-plus'
import { FilterPanel, PaginationBar, StatusTag } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { Invoice, InvoiceStatus } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
const financeStore = useFinanceStore()
const { titles, invoices, invoiceQuery, invoiceTotal } = storeToRefs(financeStore)
const keyword = ref('')
const selectedIds = ref<string[]>([])
const defaultTitle = computed(() => titles.value.find((item) => item.isDefault) ?? titles.value[0] ?? null)
const STATUS_TABS: Array<{ label: string; value: InvoiceStatus | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '已开票', value: '已开票' },
{ label: '开票中', value: '开票中' },
{ label: '未开票', value: '未开票' },
]
const STATUS_VARIANTS: Record<InvoiceStatus, 'success' | 'warning' | 'info'> = {
已开票: 'success',
开票中: 'warning',
未开票: 'info',
}
function onTab(value: InvoiceStatus | 'all'): void {
if (invoiceQuery.value.status === value) return
financeStore.setInvoiceQuery({ ...invoiceQuery.value, status: value, keyword: keyword.value })
selectedIds.value = []
void financeStore.searchInvoices()
}
function onSearch(): void {
financeStore.setInvoiceQuery({ ...invoiceQuery.value, keyword: keyword.value })
void financeStore.searchInvoices()
}
function onReset(): void {
keyword.value = ''
selectedIds.value = []
financeStore.setInvoiceQuery({ keyword: '', status: 'all', page: 1, pageSize: invoiceQuery.value.pageSize })
void financeStore.fetchInvoices()
}
function onPageChange(page: number): void {
void financeStore.changeInvoicePage(page)
}
async function onApply(invoice: Invoice): Promise<void> {
await financeStore.applyInvoice(invoice.id)
ElMessage.success('开票申请已提交')
}
function onPreview(): void {
ElMessage.info('附件待接口接入')
}
function onDownload(): void {
ElMessage.info('附件待接口接入')
}
function onSendEmail(): void {
ElMessage.success('发票已发送至绑定邮箱(演示)')
}
function onToggleSelect(id: string): void {
if (selectedIds.value.includes(id)) {
selectedIds.value = selectedIds.value.filter((item) => item !== id)
}
else {
selectedIds.value = [...selectedIds.value, id]
}
}
onMounted(() => {
keyword.value = invoiceQuery.value.keyword
void Promise.all([financeStore.fetchTitles(), financeStore.fetchInvoices()])
})
</script>
<template>
<div class="invoices">
<nav class="invoices__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<span>发票申请</span>
</nav>
<section v-if="defaultTitle" class="invoices__title-card card">
<div class="invoices__title-main">
<div class="invoices__title-head">
<span class="invoices__title-label text-secondary">默认发票抬头</span>
<StatusTag :label="defaultTitle.type" variant="primary" />
<StatusTag label="默认" variant="success" />
</div>
<p class="invoices__title-name">
{{ defaultTitle.title }}
</p>
<p class="invoices__title-tax text-secondary">
税号:{{ defaultTitle.taxNo }}
</p>
</div>
<RouterLink to="/finance/invoice-titles" class="invoices__title-manage">
管理抬头 &gt;
</RouterLink>
</section>
<div class="invoices__tabs">
<button
v-for="tab in STATUS_TABS"
:key="tab.value"
class="invoices__tab"
:class="{ 'is-active': invoiceQuery.status === tab.value }"
@click="onTab(tab.value)"
>
{{ tab.label }}
</button>
</div>
<FilterPanel
v-model:keyword="keyword"
keyword-placeholder="搜索发票抬头 / 账单号 / 项目名称"
@search="onSearch"
@reset="onReset"
/>
<div class="invoices__table card">
<table class="invoices__table-el">
<thead>
<tr>
<th class="invoices__check">
多选
</th>
<th>序号</th>
<th>账单名称</th>
<th>账单号</th>
<th>交易流水号</th>
<th>项目名称</th>
<th>订单金额</th>
<th>状态</th>
<th>申请时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(invoice, index) in invoices" :key="invoice.id">
<td class="invoices__check">
<input
type="checkbox"
:checked="selectedIds.includes(invoice.id)"
@change="onToggleSelect(invoice.id)"
>
</td>
<td>{{ index + 1 }}</td>
<td>{{ invoice.planName }}</td>
<td>{{ invoice.billNo }}</td>
<td>{{ invoice.tradeNo ?? '—' }}</td>
<td>{{ invoice.projectName }}</td>
<td class="invoices__amount">¥{{ formatMoney(invoice.amount) }}</td>
<td>
<StatusTag :label="invoice.status" :variant="STATUS_VARIANTS[invoice.status]" />
</td>
<td>{{ invoice.applyTime }}</td>
<td class="invoices__ops">
<template v-if="invoice.status === '已开票'">
<button class="invoices__link" @click="onPreview">
预览
</button>
<button class="invoices__link" @click="onDownload">
下载
</button>
<button class="invoices__link" @click="onSendEmail">
发送邮箱
</button>
</template>
<span v-else-if="invoice.status === '开票中'" class="invoices__dash">—</span>
<button v-else class="invoices__link" @click="onApply(invoice)">
申请开票
</button>
</td>
</tr>
<tr v-if="invoices.length === 0">
<td colspan="10" class="invoices__empty text-secondary">
暂无发票记录
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="invoiceQuery.page"
:page-size="invoiceQuery.pageSize"
:total="invoiceTotal"
@page-change="onPageChange"
/>
<p class="invoices__tip text-secondary">
开票将使用默认抬头,如需调整请前往 <RouterLink to="/finance/invoice-titles" class="link">管理抬头</RouterLink>。
</p>
</div>
</template>
<style lang="less" scoped>
.invoices {
.flex(column, flex-start);
align-items: stretch;
gap: @spacing-base;
width: 100%;
max-width: @container-width;
margin: 0 auto;
padding: @spacing-lg @spacing-base @spacing-xxl;
// 与财务中心其他页一致:面包屑 11px、前缀灰当前页深灰
&__breadcrumb {
font-size: 11px;
color: @text-color-disabled;
> span:last-child {
color: @text-color;
}
}
&__title-card {
.flex(row, space-between, center);
padding: @spacing-base @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title-main {
.flex(column, flex-start, stretch);
gap: @spacing-xs;
}
&__title-head {
.flex(row, flex-start, center);
gap: @spacing-sm;
}
&__title-label {
font-size: @font-size-sm;
}
&__title-name {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__title-tax {
font-size: @font-size-base;
}
&__title-manage {
color: @primary-color;
font-size: @font-size-base;
white-space: nowrap;
&:hover {
color: @primary-hover;
}
}
&__tabs {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__tab {
height: 34px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-color;
color: @white;
}
}
&__table {
padding: @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
overflow-x: auto;
}
&__table-el {
width: 100%;
border-collapse: collapse;
font-size: @font-size-base;
color: @text-color;
th,
td {
padding: @spacing-sm;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid @border-color;
}
th {
background: @bg-color-light;
color: @text-color-secondary;
font-weight: 500;
}
tbody tr:hover {
background: @bg-color-light;
}
}
&__check {
width: 48px;
text-align: center;
}
&__amount {
color: @heading-color;
font-weight: 600;
}
&__ops {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__link {
color: @primary-color;
font-size: @font-size-base;
transition: color 0.2s;
&:hover {
color: @primary-hover;
}
}
&__dash {
color: @text-color-secondary;
}
&__empty {
text-align: center;
padding: @spacing-xl 0;
}
&__tip {
font-size: @font-size-sm;
}
}
</style>