feat(app): 初始化项目配置和页面结构
- 添加 .dockerignore 和 .env.example 配置文件 - 添加 .gitignore 忽略规则配置 - 创建服务端代理API路由(_file、_modules、_server) - 集成 Ant Design Vue 组件库并配置SSR样式提取 - 定义API响应类型封装 - 创建基础布局组件(blank、console) - 实现应用中心页面和组件(AppsCenter) - 添加文章列表测试页面 - 配置控制台导航菜单结构 - 实现控制台头部组件 - 创建联系页面表单
This commit is contained in:
425
app/pages/console/invoices.vue
Normal file
425
app/pages/console/invoices.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user