feat 搭建架构骨架

This commit is contained in:
Ironsp
2026-09-24 09:09:47 +08:00
parent dd9f6b77a4
commit 7a2efa3fea
180 changed files with 18967 additions and 9 deletions

View File

@@ -0,0 +1,364 @@
<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);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
&__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);
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>