Files
pc-10584/app/pages/console/invoices.vue
赵忠林 775841eed3 feat(core): 初始化项目基础架构和CMS功能模块
- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore)
- 实现服务端API代理功能,支持文件、模块和服务器API转发
- 创建文章详情页、栏目文章列表页和单页内容展示页面
- 集成Ant Design Vue组件库并实现SSR样式提取功能
- 定义API响应数据结构类型和应用布局组件
- 开发开发者应用中心和文章管理页面
- 实现CMS导航菜单获取和多租户切换功能
2026-01-27 00:14:08 +08:00

426 lines
15 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.

<template>
<div class="space-y-4">
<a-page-header title="发票记录" sub-title="开票申请与发票下载">
<template #extra>
<a-space>
<a-button :loading="loadingPrefill" @click="prefill">自动填充</a-button>
<a-button @click="reloadRecords">刷新记录</a-button>
</a-space>
</template>
</a-page-header>
<a-card :bordered="false" class="card">
<a-alert
class="mb-4"
show-icon
type="info"
message="开票申请提交后会记录在本地(浏览器)用于演示;如需接入后端开票流程,可在 submitApply 中替换为真实接口。"
/>
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules">
<div class="grid gap-4 md:grid-cols-2">
<a-form-item label="发票类型" name="invoiceType">
<a-select
v-model:value="form.invoiceType"
placeholder="请选择发票类型"
:options="invoiceTypeOptions"
/>
</a-form-item>
<a-form-item label="发票获取方式" name="deliveryMethod">
<a-select v-model:value="form.deliveryMethod" :options="deliveryMethodOptions" disabled />
</a-form-item>
<a-form-item label="发票抬头" name="invoiceTitle">
<a-input v-model:value="form.invoiceTitle" placeholder="例如:某某科技有限公司" />
</a-form-item>
<a-form-item label="纳税人识别号" name="taxpayerId">
<a-input v-model:value="form.taxpayerId" placeholder="请输入纳税人识别号" />
</a-form-item>
<a-form-item label="邮箱地址" name="email">
<a-input v-model:value="form.email" placeholder="例如name@example.com" />
</a-form-item>
<div class="hidden md:block" />
<a-form-item label="开户银行" name="bankName">
<a-input v-model:value="form.bankName" placeholder="专票必填" />
</a-form-item>
<a-form-item label="开户账号" name="bankAccount">
<a-input v-model:value="form.bankAccount" placeholder="专票必填" />
</a-form-item>
<a-form-item label="注册地址" name="registeredAddress">
<a-input v-model:value="form.registeredAddress" placeholder="专票必填" />
</a-form-item>
<a-form-item label="注册电话" name="registeredPhone">
<a-input v-model:value="form.registeredPhone" placeholder="专票必填(座机/手机号)" />
</a-form-item>
</div>
<a-space class="mt-2">
<a-button type="primary" :loading="submitting" @click="submitApply">提交开票申请</a-button>
<a-button :disabled="submitting" @click="resetForm">重置</a-button>
</a-space>
</a-form>
</a-card>
<a-card :bordered="false" class="card">
<a-space class="mb-3" align="center">
<div class="text-base font-medium">申请记录</div>
<a-tag color="blue">{{ records.length }}</a-tag>
</a-space>
<a-empty v-if="!records.length" description="暂无开票申请记录" />
<a-table
v-else
:data-source="records"
:pagination="false"
size="middle"
:row-key="(r: InvoiceApplyRecord) => r.id"
>
<a-table-column title="提交时间" key="createdAt" width="180">
<template #default="{ record }">
<span>{{ formatTime(record.createdAt) }}</span>
</template>
</a-table-column>
<a-table-column title="发票类型" key="invoiceType" width="170">
<template #default="{ record }">
<span>{{ invoiceTypeText(record.invoiceType) }}</span>
</template>
</a-table-column>
<a-table-column title="发票抬头" key="invoiceTitle" ellipsis>
<template #default="{ record }">
<span>{{ record.invoiceTitle || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="邮箱" key="email" width="220" ellipsis>
<template #default="{ record }">
<span>{{ record.email || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="状态" key="status" width="120">
<template #default="{ record }">
<a-tag v-if="record.status === 'submitted'" color="default">已提交</a-tag>
<a-tag v-else-if="record.status === 'issued'" color="green">已开具</a-tag>
<a-tag v-else-if="record.status === 'rejected'" color="red">已驳回</a-tag>
<a-tag v-else color="default">-</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" key="actions" width="220" fixed="right">
<template #default="{ record }">
<a-space>
<a-button size="small" @click="openDetail(record)">查看</a-button>
<a-button size="small" :disabled="!record.fileUrl" @click="download(record)">下载</a-button>
<a-button danger size="small" @click="removeRecord(record)">删除</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
</a-card>
<a-modal v-model:open="detailOpen" title="开票申请详情" :width="720" ok-text="关闭" cancel-text="取消" :footer="null">
<a-descriptions bordered size="small" :column="2">
<a-descriptions-item label="发票类型">{{ invoiceTypeText(detail?.invoiceType) }}</a-descriptions-item>
<a-descriptions-item label="发票获取方式">数字电子发票</a-descriptions-item>
<a-descriptions-item label="发票抬头" :span="2">{{ detail?.invoiceTitle || '-' }}</a-descriptions-item>
<a-descriptions-item label="纳税人识别号" :span="2">{{ detail?.taxpayerId || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱" :span="2">{{ detail?.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="开户银行" :span="2">{{ detail?.bankName || '-' }}</a-descriptions-item>
<a-descriptions-item label="开户账号" :span="2">{{ detail?.bankAccount || '-' }}</a-descriptions-item>
<a-descriptions-item label="注册地址" :span="2">{{ detail?.registeredAddress || '-' }}</a-descriptions-item>
<a-descriptions-item label="注册电话" :span="2">{{ detail?.registeredPhone || '-' }}</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ formatTime(detail?.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="detail?.status === 'submitted'" color="default">已提交</a-tag>
<a-tag v-else-if="detail?.status === 'issued'" color="green">已开具</a-tag>
<a-tag v-else-if="detail?.status === 'rejected'" color="red">已驳回</a-tag>
<a-tag v-else color="default">-</a-tag>
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal, type FormInstance } from 'ant-design-vue'
import { getTenantInfo, getUserInfo } from '@/api/layout'
definePageMeta({ layout: 'console' })
type InvoiceType = 'normal' | 'special'
type InvoiceDeliveryMethod = 'digital'
type InvoiceApplyStatus = 'submitted' | 'issued' | 'rejected'
type InvoiceApplyRecord = {
id: string
createdAt: string
status: InvoiceApplyStatus
invoiceType: InvoiceType
invoiceTitle: string
taxpayerId: string
email: string
deliveryMethod: InvoiceDeliveryMethod
bankName: string
bankAccount: string
registeredAddress: string
registeredPhone: string
invoiceNo?: string
fileUrl?: string
}
const STORAGE_KEY = 'console.invoiceApplications.v1'
const invoiceTypeOptions = [
{ label: '增值税普通发票', value: 'normal' },
{ label: '增值税专用发票', value: 'special' }
]
const deliveryMethodOptions = [{ label: '数字电子发票', value: 'digital' }]
const loadingPrefill = ref(false)
const submitting = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<{
invoiceType: InvoiceType | undefined
invoiceTitle: string
taxpayerId: string
email: string
deliveryMethod: InvoiceDeliveryMethod
bankName: string
bankAccount: string
registeredAddress: string
registeredPhone: string
}>({
invoiceType: undefined,
invoiceTitle: '',
taxpayerId: '',
email: '',
deliveryMethod: 'digital',
bankName: '',
bankAccount: '',
registeredAddress: '',
registeredPhone: ''
})
const records = ref<InvoiceApplyRecord[]>([])
const detailOpen = ref(false)
const detail = ref<InvoiceApplyRecord | null>(null)
function invoiceTypeText(value?: InvoiceType | null) {
if (value === 'special') return '增值税专用发票'
if (value === 'normal') return '增值税普通发票'
return '-'
}
function formatTime(value?: string | null) {
if (!value) return '-'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function safeParseRecords(raw: string | null): InvoiceApplyRecord[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed as InvoiceApplyRecord[]
} catch {
return []
}
}
function persistRecords(next: InvoiceApplyRecord[]) {
try {
if (!import.meta.client) return
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// ignore
}
}
function reloadRecords() {
if (!import.meta.client) return
records.value = safeParseRecords(localStorage.getItem(STORAGE_KEY))
}
function generateId() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function isSpecialInvoice() {
return form.invoiceType === 'special'
}
function requiredWhenSpecial(label: string) {
return (_rule: unknown, value: unknown) => {
if (!isSpecialInvoice()) return Promise.resolve()
const normalized = typeof value === 'string' ? value.trim() : ''
if (normalized) return Promise.resolve()
return Promise.reject(new Error(`${label}不能为空(专票必填)`))
}
}
function phoneValidator(_rule: unknown, value: unknown) {
const normalized = typeof value === 'string' ? value.trim() : ''
if (!normalized) {
if (isSpecialInvoice()) return Promise.reject(new Error('注册电话不能为空(专票必填)'))
return Promise.resolve()
}
const mobileReg = /^1[3-9]\d{9}$/
const landlineReg = /^0\d{2,3}-?\d{7,8}$/
if (mobileReg.test(normalized) || landlineReg.test(normalized)) return Promise.resolve()
return Promise.reject(new Error('电话格式不正确座机0xx-xxxxxxx 或手机号)'))
}
const rules = computed(() => ({
invoiceType: [{ required: true, message: '请选择发票类型' }],
invoiceTitle: [{ required: true, message: '请输入发票抬头', type: 'string' }],
taxpayerId: [{ required: true, message: '请输入纳税人识别号', type: 'string' }],
email: [
{ required: true, message: '请输入邮箱地址', type: 'string' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
],
deliveryMethod: [{ required: true, message: '请选择发票获取方式' }],
bankName: [{ validator: requiredWhenSpecial('开户银行'), trigger: 'blur' }],
bankAccount: [{ validator: requiredWhenSpecial('开户账号'), trigger: 'blur' }],
registeredAddress: [{ validator: requiredWhenSpecial('注册地址'), trigger: 'blur' }],
registeredPhone: [{ validator: phoneValidator, trigger: 'blur' }]
}))
async function prefill(options: { silent?: boolean } = {}) {
loadingPrefill.value = true
try {
const [uRes, cRes] = await Promise.allSettled([getUserInfo(), getTenantInfo()])
if (uRes.status === 'fulfilled') {
if (!form.email.trim()) form.email = (uRes.value.email ?? '').trim()
}
if (cRes.status === 'fulfilled') {
const title = (cRes.value.invoiceHeader ?? cRes.value.companyName ?? cRes.value.tenantName ?? '').trim()
if (title && !form.invoiceTitle.trim()) form.invoiceTitle = title
}
if (!options.silent) message.success('已自动填充可用信息')
} catch (e: unknown) {
console.error(e)
if (!options.silent) message.error(e instanceof Error ? e.message : '自动填充失败')
} finally {
loadingPrefill.value = false
}
}
function resetForm() {
form.invoiceType = undefined
form.invoiceTitle = ''
form.taxpayerId = ''
form.bankName = ''
form.bankAccount = ''
form.registeredAddress = ''
form.registeredPhone = ''
form.deliveryMethod = 'digital'
}
async function submitApply() {
try {
await formRef.value?.validate()
} catch {
return
}
const payload: Omit<InvoiceApplyRecord, 'id' | 'createdAt' | 'status'> = {
invoiceType: form.invoiceType as InvoiceType,
invoiceTitle: form.invoiceTitle.trim(),
taxpayerId: form.taxpayerId.trim(),
email: form.email.trim(),
deliveryMethod: form.deliveryMethod,
bankName: form.bankName.trim(),
bankAccount: form.bankAccount.trim(),
registeredAddress: form.registeredAddress.trim(),
registeredPhone: form.registeredPhone.trim()
}
if (!payload.invoiceTitle) return message.error('请输入发票抬头')
if (!payload.taxpayerId) return message.error('请输入纳税人识别号')
if (!payload.email) return message.error('请输入邮箱地址')
submitting.value = true
try {
const next: InvoiceApplyRecord = {
id: generateId(),
createdAt: new Date().toISOString(),
status: 'submitted',
...payload
}
const updated = [next, ...records.value]
records.value = updated
persistRecords(updated)
message.success('已提交开票申请')
resetForm()
await prefill({ silent: true })
} catch (e: unknown) {
console.error(e)
message.error(e instanceof Error ? e.message : '提交失败')
} finally {
submitting.value = false
}
}
function openDetail(record: InvoiceApplyRecord) {
detail.value = record
detailOpen.value = true
}
function download(record: InvoiceApplyRecord) {
if (!record.fileUrl) return
if (!import.meta.client) return
window.open(record.fileUrl, '_blank', 'noopener,noreferrer')
}
function removeRecord(record: InvoiceApplyRecord) {
Modal.confirm({
title: '确认删除该开票申请?',
content: '删除后无法恢复(仅删除本地记录)。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
const updated = records.value.filter((r) => r.id !== record.id)
records.value = updated
persistRecords(updated)
if (detail.value?.id === record.id) {
detailOpen.value = false
detail.value = null
}
message.success('已删除')
}
})
}
onMounted(() => {
reloadRecords()
prefill({ silent: true })
})
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>