增加票通票样功能

This commit is contained in:
BBIT-Kai
2026-05-19 17:43:39 +08:00
parent 74fdddd113
commit cdcfaa192c
14 changed files with 376 additions and 466 deletions
+22
View File
@@ -705,6 +705,28 @@ export function invoiceDetailApi(invoiceReqSerialNo: string): Promise<InvoiceDet
return http.get('/pt/invoiceDetail', { params: { invoiceReqSerialNo } })
}
export function invoiceDownloadUrlApi(invoiceReqSerialNo: string): Promise<{ downloadUrl?: string }> {
return http.get('/pt/invoiceDownloadUrl', { params: { invoiceReqSerialNo } })
}
export function invoicePreviewBlobApi(invoiceReqSerialNo: string): Promise<Blob> {
return http.get('/pt/invoicePreview', {
params: { invoiceReqSerialNo },
responseType: 'blob'
})
}
export function redInvoiceDownloadUrlApi(invoiceReqSerialNo: string): Promise<{ downloadUrl?: string }> {
return http.get('/pt/redInvoiceDownloadUrl', { params: { invoiceReqSerialNo } })
}
export function redInvoicePreviewBlobApi(invoiceReqSerialNo: string): Promise<Blob> {
return http.get('/pt/redInvoicePreview', {
params: { invoiceReqSerialNo },
responseType: 'blob'
})
}
/**
* 查询并刷新发票状态
*/
@@ -361,6 +361,53 @@
</template>
</n-modal>
<n-modal
v-model:show="showSamplePreview"
preset="card"
title="查看票样"
:style="{ width: '980px', maxWidth: '94vw' }"
content-style="padding: 0"
>
<div class="sample-preview">
<div class="sample-toolbar">
<div class="sample-title">{{ sampleSerialNo }}</div>
<div class="sample-actions">
<n-button size="small" secondary @click="zoomOutSample">
<template #icon><ZoomOut :size="14" /></template>
缩小
</n-button>
<span class="sample-zoom">{{ sampleZoom }}%</span>
<n-button size="small" secondary @click="zoomInSample">
<template #icon><ZoomIn :size="14" /></template>
放大
</n-button>
<n-button
size="small"
type="primary"
tag="a"
:href="sampleBlobUrl || undefined"
:download="`${sampleSerialNo || 'invoice'}.pdf`"
:disabled="!sampleBlobUrl"
>
<template #icon><Download :size="14" /></template>
下载
</n-button>
</div>
</div>
<n-spin :show="sampleLoading">
<div class="sample-frame-wrap">
<iframe
v-if="sampleBlobUrl"
class="sample-frame"
:src="sampleBlobUrl"
:style="{ transform: `scale(${sampleZoom / 100})` }"
/>
<n-empty v-else description="暂无票样地址" />
</div>
</n-spin>
</div>
</n-modal>
<n-modal
v-model:show="showRedForm"
preset="card"
@@ -410,6 +457,7 @@ import type { Component } from 'vue'
import {
NButton,
NDataTable,
NEmpty,
NForm,
NFormItem,
NInput,
@@ -426,16 +474,24 @@ import {
Clock,
XCircle,
FileSpreadsheet,
FileSearch,
ZoomIn,
ZoomOut,
Download,
RotateCcw
} from 'lucide-vue-next'
import {
invoiceDownloadUrlApi,
invoicePreviewBlobApi,
invoiceDetailApi,
invoiceHistoryApi,
invoiceKindMap,
invoiceStatusMap,
queryInvoiceApi,
redInvoiceCreateApi,
redInvoiceDownloadUrlApi,
redInvoiceInfoApi,
redInvoicePreviewBlobApi,
redReasonMap
} from '@/api/piaotong'
import type {
@@ -446,7 +502,7 @@ import type {
RedCreateRequest,
RedInvoiceInfo
} from '@/api/piaotong'
import type { DataTableColumn } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
const invoiceTypeMap: Record<string, string> = {
BLUE: '蓝票',
@@ -583,7 +639,7 @@ const pagination = reactive({
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
pageSlot: 7,
prefix: ({ itemCount }: { itemCount: number }) => `${itemCount}`
prefix: ({ itemCount }: { itemCount?: number }) => `${itemCount ?? 0}`
})
async function fetchData() {
@@ -613,12 +669,22 @@ function handlePageSizeChange(pageSize: number) {
}
const refreshingSet = reactive(new Set<string>())
const showSamplePreview = ref(false)
const sampleLoading = ref(false)
const sampleUrl = ref('')
const sampleBlobUrl = ref('')
const sampleSerialNo = ref('')
const sampleZoom = ref(100)
function getRowActions(row: InvoiceHistoryItem) {
const actions: Array<{ label: string; icon?: Component; onClick: () => void }> = []
actions.push({ label: '详情', icon: Eye, onClick: () => showDetailInfo(row) })
actions.push({ label: '刷新', icon: RefreshCw, onClick: () => refreshStatus(row) })
if ((activeTab.value === 'BLUE' || activeTab.value === 'RED') && row.status === 'SUCCESS') {
actions.push({ label: '查看票样', icon: FileSearch, onClick: () => openSamplePreview(row) })
}
if (
activeTab.value === 'BLUE' &&
row.status === 'SUCCESS' &&
@@ -630,7 +696,47 @@ function getRowActions(row: InvoiceHistoryItem) {
return actions
}
const columns = computed<DataTableColumn[]>(() => [
async function openSamplePreview(item: InvoiceHistoryItem) {
showSamplePreview.value = true
sampleLoading.value = true
if (sampleBlobUrl.value) {
URL.revokeObjectURL(sampleBlobUrl.value)
}
sampleUrl.value = ''
sampleBlobUrl.value = ''
sampleSerialNo.value = item.invoiceReqSerialNo
sampleZoom.value = 100
try {
const isRedInvoice = activeTab.value === 'RED' || item.invoiceType === 'RED'
const res = isRedInvoice
? await redInvoiceDownloadUrlApi(item.invoiceReqSerialNo)
: await invoiceDownloadUrlApi(item.invoiceReqSerialNo)
sampleUrl.value = res.downloadUrl || ''
if (!sampleUrl.value) {
message.warning('暂无票样地址')
return
}
const blob = isRedInvoice
? await redInvoicePreviewBlobApi(item.invoiceReqSerialNo)
: await invoicePreviewBlobApi(item.invoiceReqSerialNo)
sampleBlobUrl.value = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' }))
} catch {
message.error('预览票样失败')
} finally {
sampleLoading.value = false
}
}
function zoomInSample() {
sampleZoom.value = Math.min(200, sampleZoom.value + 10)
}
function zoomOutSample() {
sampleZoom.value = Math.max(50, sampleZoom.value - 10)
}
const columns = computed<DataTableColumns<InvoiceHistoryItem>>(() => {
const tableColumns: DataTableColumns<InvoiceHistoryItem> = [
{
title: '流水号',
key: 'invoiceReqSerialNo',
@@ -659,7 +765,17 @@ const columns = computed<DataTableColumn[]>(() => [
key: 'redFlag',
width: 110,
render: (row: InvoiceHistoryItem) => {
if (!row.redFlag || row.redFlag === 'NOT_RED') {
const redFlag =
row.redFlag ||
(row.invoiceType === 'RED'
? {
SUCCESS: 'ALREADY_RED',
PROCESSING: 'REDING',
PENDING: 'REDING',
FAILED: 'RED_FAIL'
}[row.status]
: undefined)
if (!redFlag || redFlag === 'NOT_RED') {
return h(NTag, { size: 'small', round: true }, () => '未冲红')
}
const typeMap: Record<string, 'error' | 'warning' | 'default'> = {
@@ -670,8 +786,8 @@ const columns = computed<DataTableColumn[]>(() => [
}
return h(
NTag,
{ size: 'small', round: true, type: typeMap[row.redFlag] || 'default' },
() => redFlagMap[row.redFlag] || row.redFlag
{ size: 'small', round: true, type: typeMap[redFlag] || 'default' },
() => redFlagMap[redFlag] || redFlag
)
}
},
@@ -713,6 +829,7 @@ const columns = computed<DataTableColumn[]>(() => [
{ style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap' },
actions.map((btn) => {
const isLoading = btn.label === '刷新' && refreshingSet.has(row.invoiceReqSerialNo)
const Icon = btn.icon
return h(
NButton,
{
@@ -723,14 +840,19 @@ const columns = computed<DataTableColumn[]>(() => [
},
{
default: () => btn.label,
...(btn.icon && !isLoading ? { icon: () => h(btn.icon, { size: 13 }) } : {})
...(Icon && !isLoading ? { icon: () => h(Icon, { size: 13 }) } : {})
}
)
})
)
}
}
])
]
return activeTab.value === 'RED'
? tableColumns.filter((column) => !('key' in column) || column.key !== 'redFlag')
: tableColumns
})
const showDetail = ref(false)
const detailLoading = ref(false)
@@ -830,7 +952,7 @@ async function handleRedSubmit() {
}
}
const goodsColumns: DataTableColumn[] = [
const goodsColumns: DataTableColumns<InvoiceDetailGoods> = [
{ title: '行号', key: 'lineNo', width: 60, align: 'center' },
{ title: '商品名称', key: 'goodsName', width: 140, ellipsis: { tooltip: true } },
{ title: '税收分类编码', key: 'taxClassificationCode', width: 120, ellipsis: { tooltip: true } },
@@ -931,7 +1053,7 @@ function goodsSummary() {
]
}
const voucherColumns: DataTableColumn[] = [
const voucherColumns: DataTableColumns<InvoiceDetailVoucher> = [
{
title: '凭证类型',
key: 'proofType',
@@ -1045,6 +1167,56 @@ onMounted(() => {
background: #fafafa !important;
}
.sample-preview {
padding: 16px;
}
.sample-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.sample-title {
color: #111827;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
font-weight: 600;
}
.sample-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sample-zoom {
min-width: 46px;
color: #4b5563;
font-size: 13px;
text-align: center;
}
.sample-frame-wrap {
height: 70vh;
min-height: 420px;
overflow: auto;
border: 1px solid #eef1f5;
border-radius: 8px;
background: #f8fafc;
}
.sample-frame {
width: 100%;
height: 100%;
min-height: 680px;
border: 0;
transform-origin: 0 0;
}
.detail-shell {
padding: 0 20px 20px;
}