- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore) - 实现服务端API代理功能,支持文件、模块和服务器API转发 - 创建文章详情页、栏目文章列表页和单页内容展示页面 - 集成Ant Design Vue组件库并实现SSR样式提取功能 - 定义API响应数据结构类型和应用布局组件 - 开发开发者应用中心和文章管理页面 - 实现CMS导航菜单获取和多租户切换功能
426 lines
15 KiB
Vue
426 lines
15 KiB
Vue
<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>
|