feat(core): 初始化项目基础架构和CMS功能模块

- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore)
- 实现服务端API代理功能,支持文件、模块和服务器API转发
- 创建文章详情页、栏目文章列表页和单页内容展示页面
- 集成Ant Design Vue组件库并实现SSR样式提取功能
- 定义API响应数据结构类型和应用布局组件
- 开发开发者应用中心和文章管理页面
- 实现CMS导航菜单获取和多租户切换功能
This commit is contained in:
2026-01-27 00:14:08 +08:00
commit 775841eed3
315 changed files with 47072 additions and 0 deletions

View 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>