feat(app): 初始化项目配置和页面结构

- 添加 .dockerignore 和 .env.example 配置文件
- 添加 .gitignore 忽略规则配置
- 创建服务端代理API路由(_file、_modules、_server)
- 集成 Ant Design Vue 组件库并配置SSR样式提取
- 定义API响应类型封装
- 创建基础布局组件(blank、console)
- 实现应用中心页面和组件(AppsCenter)
- 添加文章列表测试页面
- 配置控制台导航菜单结构
- 实现控制台头部组件
- 创建联系页面表单
This commit is contained in:
2026-01-17 18:23:37 +08:00
commit 5e26fdc7fb
439 changed files with 56219 additions and 0 deletions

595
app/pages/create-app.vue Normal file
View File

@@ -0,0 +1,595 @@
<template>
<div class="mx-auto max-w-screen-xl px-4 py-12">
<a-typography-title :level="1" class="!mb-2">创建应用</a-typography-title>
<a-typography-paragraph class="!text-gray-600 !mb-8">
选择产品与时长填写租户与绑定信息生成订单并支付后自动开通产品并分配管理账号
</a-typography-paragraph>
<a-alert
class="mb-6"
type="info"
show-icon
message="当前页面已打通前端流程与接口对接点;如你的后端返回字段不同(订单号/二维码/账号信息),我可以按实际接口再调整。"
/>
<a-steps :current="step" class="mb-8">
<a-step title="选择产品" />
<a-step title="选择时长" />
<a-step title="填写信息" />
<a-step title="生成订单" />
<a-step title="支付订单" />
<a-step title="开通交付" />
</a-steps>
<a-card v-if="step === 0" title="选择产品">
<a-row :gutter="[16, 16]">
<a-col v-for="p in products" :key="p.code" :xs="24" :md="12" :lg="8">
<a-card
hoverable
:class="selectedProduct?.code === p.code ? 'card-active' : ''"
@click="selectProduct(p)"
>
<template #title>
<div class="flex items-center justify-between gap-2">
<span>{{ p.name }}</span>
<a-tag v-if="p.recommend" color="green">推荐</a-tag>
</div>
</template>
<a-typography-paragraph class="!text-gray-600">
{{ p.desc }}
</a-typography-paragraph>
<div class="flex flex-wrap gap-2">
<a-tag v-for="t in p.tags" :key="t">{{ t }}</a-tag>
</div>
<div class="mt-4 text-sm text-gray-500">
起步价¥{{ p.pricePerMonth }}/
</div>
</a-card>
</a-col>
</a-row>
<div class="mt-6 flex justify-end">
<a-button type="primary" :disabled="!selectedProduct" @click="next()">下一步</a-button>
</div>
</a-card>
<a-card v-else-if="step === 1" title="选择时长">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="14">
<a-segmented v-model:value="durationMonths" :options="durationOptions" block />
<div class="mt-6">
<a-descriptions bordered size="small" :column="1">
<a-descriptions-item label="产品">{{ selectedProduct?.name }}</a-descriptions-item>
<a-descriptions-item label="购买时长">{{ durationMonths }} 个月</a-descriptions-item>
<a-descriptions-item label="单价">¥{{ selectedProduct?.pricePerMonth }}/</a-descriptions-item>
<a-descriptions-item label="应付金额">
<span class="text-lg font-semibold text-green-600">¥{{ priceTotal }}</span>
</a-descriptions-item>
</a-descriptions>
</div>
</a-col>
<a-col :xs="24" :lg="10">
<a-card title="支持加购" size="small">
<a-list size="small" :data-source="addons">
<template #renderItem="{ item }">
<a-list-item>{{ item }}</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
</a-row>
<div class="mt-6 flex justify-between">
<a-button @click="prev()">上一步</a-button>
<a-button type="primary" @click="next()">下一步</a-button>
</div>
</a-card>
<a-card v-else-if="step === 2" title="填写租户与绑定信息">
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules" @finish="next">
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="租户名称" name="tenantName">
<a-input v-model:value="form.tenantName" placeholder="例如:某某科技有限公司" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="绑定域名" name="domain">
<a-input v-model:value="form.domain" placeholder="例如example.com" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="邮箱" name="email">
<a-input v-model:value="form.email" placeholder="用于接收交付信息" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="手机号" name="phone">
<a-input v-model:value="form.phone" placeholder="用于短信验证与管理员账号" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16" align="middle">
<a-col :xs="24" :md="12">
<a-form-item label="短信验证码" name="smsCode">
<a-input v-model:value="form.smsCode" placeholder="请输入验证码" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12" class="flex items-center">
<a-button :disabled="smsCountdown > 0" :loading="smsSending" @click="onSendSms">
{{ smsCountdown > 0 ? `${smsCountdown}s 后重试` : '发送验证码' }}
</a-button>
</a-col>
</a-row>
<a-alert
class="mb-4"
type="warning"
show-icon
message="短信验证码接口复用登录短信验证码sendSmsCaptcha。如你有专用的开通验证码接口可替换。"
/>
<div class="flex justify-between">
<a-button @click="prev()">上一步</a-button>
<a-button type="primary" html-type="submit">下一步</a-button>
</div>
</a-form>
</a-card>
<a-card v-else-if="step === 3" title="生成订单">
<a-descriptions bordered :column="1">
<a-descriptions-item label="产品">{{ selectedProduct?.name }}</a-descriptions-item>
<a-descriptions-item label="购买时长">{{ durationMonths }} 个月</a-descriptions-item>
<a-descriptions-item label="租户名称">{{ form.tenantName }}</a-descriptions-item>
<a-descriptions-item label="绑定域名">{{ form.domain }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ form.email }}</a-descriptions-item>
<a-descriptions-item label="手机号">{{ form.phone }}</a-descriptions-item>
<a-descriptions-item label="应付金额">
<span class="text-lg font-semibold text-green-600">¥{{ priceTotal }}</span>
</a-descriptions-item>
</a-descriptions>
<a-alert
class="mt-4"
type="info"
show-icon
message="点击“生成订单”后将创建订单并请求微信 Native 支付二维码。"
/>
<div class="mt-6 flex justify-between">
<a-button @click="prev()">上一步</a-button>
<a-button type="primary" :loading="creatingOrder" @click="createOrderAndPay">生成订单</a-button>
</div>
</a-card>
<a-card v-else-if="step === 4" title="支付订单">
<a-row :gutter="[24, 24]">
<a-col :xs="24" :lg="12">
<a-descriptions bordered size="small" :column="1">
<a-descriptions-item label="订单号">{{ order?.orderNo || '-' }}</a-descriptions-item>
<a-descriptions-item label="金额">¥{{ order?.payPrice || priceTotal }}</a-descriptions-item>
<a-descriptions-item label="支付方式">微信 Native扫码支付</a-descriptions-item>
</a-descriptions>
<div class="mt-6 flex flex-wrap gap-2">
<a-button :loading="checkingPay" @click="checkPayStatus">查询支付状态</a-button>
<a-button @click="rebuildPayCode" :disabled="!order">重新获取二维码</a-button>
<a-button danger @click="resetAll">取消并重来</a-button>
</div>
<a-alert
class="mt-6"
type="warning"
show-icon
message="支付成功后点击“查询支付状态”,确认到账后自动进入开通交付。"
/>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="扫码支付" size="small">
<div class="flex items-center justify-center py-6">
<a-qrcode v-if="payCodeUrl" :value="payCodeUrl" :size="220" />
<a-empty v-else description="暂无二维码" />
</div>
</a-card>
</a-col>
</a-row>
</a-card>
<a-card v-else-if="step === 5" title="开通交付">
<a-result
v-if="provisioned"
status="success"
title="开通成功"
sub-title="租户已创建并完成初始化可使用管理员账号登录后台"
>
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/contact')">获取后台地址</a-button>
<a-button @click="resetAll">再创建一个</a-button>
</a-space>
</template>
</a-result>
<a-alert
v-else
type="info"
show-icon
message="正在开通中..."
/>
<a-divider />
<a-descriptions bordered size="small" :column="1">
<a-descriptions-item label="TenantId">
{{ provisionInfo?.user?.tenantId ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="租户名称">{{ form.tenantName }}</a-descriptions-item>
<a-descriptions-item label="绑定域名">{{ form.domain }}</a-descriptions-item>
<a-descriptions-item label="管理员账号">
{{ provisionInfo?.user?.username || form.phone }}
</a-descriptions-item>
<a-descriptions-item label="初始密码">
{{ adminPasswordHint }}
</a-descriptions-item>
<a-descriptions-item label="Access Token">
<a-typography-text
v-if="provisionInfo?.access_token"
:copyable="{ text: provisionInfo.access_token }"
>
点击复制
</a-typography-text>
<span v-else>-</span>
</a-descriptions-item>
</a-descriptions>
</a-card>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { usePageSeo } from '@/composables/usePageSeo'
import { sendSmsCaptcha } from '@/api/passport/login'
import { createWithOrder, getNativeCode, type PaymentCreateResult } from '@/api/system/payment'
import { getOrder } from '@/api/system/order'
import request from '@/utils/request'
import { SERVER_API_URL } from '@/config/setting'
import type { ApiResult } from '@/api'
import type { Order } from '@/api/system/order/model'
type Product = {
code: string
name: string
desc: string
tags: string[]
pricePerMonth: number
recommend?: boolean
}
usePageSeo({
title: '创建应用 - 选品/支付/自动开通',
description:
'选择产品与时长,填写租户信息并短信验证,生成订单并支付后自动创建租户、初始化模块与数据并交付管理账号。',
path: '/create-app'
})
const step = ref(0)
const products: Product[] = [
{
code: 'website',
name: '企业官网',
desc: '品牌展示与获客转化支持模板、SEO 与可视化配置。',
tags: ['模板', 'SEO', '多语言'],
pricePerMonth: 199,
recommend: true
},
{
code: 'shop',
name: '电商系统',
desc: '商品/订单/支付/营销基础能力,插件化扩展。',
tags: ['支付', '插件', '营销'],
pricePerMonth: 399,
recommend: true
},
{
code: 'mp',
name: '小程序/公众号',
desc: '多端渠道接入与统一管理,适配内容与电商场景。',
tags: ['多端', '渠道'],
pricePerMonth: 299
}
]
const selectedProduct = ref<Product | null>(null)
const durationMonths = ref(12)
const durationOptions = [
{ label: '1个月', value: 1 },
{ label: '3个月', value: 3 },
{ label: '12个月', value: 12 },
{ label: '24个月', value: 24 }
]
const addons = ['模板加购(示例)', '插件加购(示例)', '私有化交付(示例)']
const priceTotal = computed(() => {
const base = selectedProduct.value?.pricePerMonth || 0
return base * Number(durationMonths.value || 0)
})
const formRef = ref()
const form = reactive({
tenantName: '',
domain: '',
email: '',
phone: '',
smsCode: ''
})
function isDomainLike(v: string) {
const value = v.trim().toLowerCase()
return /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/.test(value)
}
function isPhoneLike(v: string) {
const value = v.trim()
return /^1\d{10}$/.test(value)
}
function isSmsCodeLike(v: string) {
const value = v.trim()
return /^\d{4,8}$/.test(value)
}
const rules = {
tenantName: [{ required: true, message: '请填写租户名称' }],
domain: [
{ required: true, message: '请填写绑定域名' },
{ validator: (_: unknown, v: string) => (isDomainLike(v) ? Promise.resolve() : Promise.reject(new Error('域名格式不正确'))) }
],
email: [{ required: true, type: 'email', message: '请填写正确邮箱' }],
phone: [
{ required: true, message: '请填写手机号' },
{ validator: (_: unknown, v: string) => (isPhoneLike(v) ? Promise.resolve() : Promise.reject(new Error('手机号格式不正确'))) }
],
smsCode: [
{ required: true, message: '请填写短信验证码' },
{ validator: (_: unknown, v: string) => (isSmsCodeLike(v) ? Promise.resolve() : Promise.reject(new Error('验证码格式不正确'))) }
]
}
const smsSending = ref(false)
const smsCountdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | undefined
async function onSendSms() {
if (!form.phone) {
message.warning('请先填写手机号')
return
}
smsSending.value = true
try {
await sendSmsCaptcha({ phone: form.phone })
message.success('验证码已发送')
smsCountdown.value = 60
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(() => {
smsCountdown.value -= 1
if (smsCountdown.value <= 0) {
smsCountdown.value = 0
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = undefined
}
}, 1000)
} catch (e) {
message.error(String(e))
} finally {
smsSending.value = false
}
}
function selectProduct(p: Product) {
selectedProduct.value = p
}
function next() {
if (step.value < 5) step.value += 1
}
function prev() {
if (step.value > 0) step.value -= 1
}
const creatingOrder = ref(false)
const order = ref<Order | null>(null)
const payCodeUrl = ref<string>('')
const payment = ref<PaymentCreateResult | null>(null)
function pickFirstString(obj: unknown, keys: string[]) {
if (!obj || typeof obj !== 'object') return ''
const record = obj as Record<string, unknown>
for (const key of keys) {
const value = record[key]
if (typeof value === 'string' && value) return value
}
return ''
}
async function createOrderAndPay() {
if (!selectedProduct.value) return
creatingOrder.value = true
try {
const orderInfo: Partial<Order> & Record<string, unknown> = {
type: 0,
channel: 0,
realName: form.tenantName,
phone: form.phone,
totalNum: durationMonths.value,
totalPrice: String(priceTotal.value),
payPrice: String(priceTotal.value),
comments: JSON.stringify({
product: selectedProduct.value.code,
months: durationMonths.value,
tenantName: form.tenantName,
domain: form.domain,
email: form.email
})
}
const unifiedPayload = {
paymentChannel: 'WECHAT_NATIVE',
paymentType: 1,
amount: priceTotal.value,
subject: `${selectedProduct.value.name}${durationMonths.value}个月)`,
description: `租户:${form.tenantName};域名:${form.domain}`,
goodsId: selectedProduct.value.code,
quantity: 1,
orderType: 0,
buyerRemarks: orderInfo.comments,
extraParams: {
product: selectedProduct.value.code,
months: durationMonths.value,
tenantName: form.tenantName,
domain: form.domain,
email: form.email,
phone: form.phone
},
order: orderInfo
}
const data = await createWithOrder(unifiedPayload)
payment.value = data || null
const orderFromApi = (data as any)?.order || (data as any)?.orderInfo || (data as any)?.orderDTO
order.value = {
...(orderFromApi || {}),
orderId: (data as any)?.orderId ?? orderFromApi?.orderId,
orderNo: (data as any)?.orderNo ?? orderFromApi?.orderNo,
payPrice: (orderFromApi || {})?.payPrice ?? String(priceTotal.value)
} as Order
payCodeUrl.value =
pickFirstString(data, ['codeUrl', 'url', 'payUrl', 'paymentUrl', 'qrcode']) ||
pickFirstString(order.value, ['qrcode'])
if (!payCodeUrl.value && order.value?.orderId) {
await rebuildPayCode()
}
if (!payCodeUrl.value) {
message.warning('后端未返回二维码地址codeUrl/url/payUrl请确认统一下单接口返回格式或提供支付二维码接口')
}
step.value = 4
} catch (e) {
message.error(String(e))
} finally {
creatingOrder.value = false
}
}
async function rebuildPayCode() {
if (!order.value) return
try {
const data = await getNativeCode(order.value)
payCodeUrl.value = pickFirstString(data, ['codeUrl', 'url', 'payUrl', 'paymentUrl', 'qrcode']) || String(data || '')
if (!payCodeUrl.value) {
message.warning('后端未返回二维码地址codeUrl/url请确认接口返回格式')
}
} catch (e) {
message.error(String(e))
}
}
const checkingPay = ref(false)
const provisioned = ref(false)
const adminPasswordHint = '初始密码将通过短信/邮件发送(或由客服提供)'
async function checkPayStatus() {
if (!order.value?.orderId) {
message.warning('缺少订单ID暂无法查询支付状态请确认统一下单接口是否返回 orderId或提供按 orderNo/paymentNo 查询的接口)')
return
}
checkingPay.value = true
try {
const latest = await getOrder(order.value.orderId)
order.value = latest
if (Number(latest.payStatus) === 1) {
message.success('已支付,开始开通...')
step.value = 5
await provision()
} else {
message.info('订单未支付或未到账,请稍后重试')
}
} catch (e) {
message.error(String(e))
} finally {
checkingPay.value = false
}
}
async function provision() {
try {
const payload = {
websiteName: form.tenantName,
domain: form.domain,
email: form.email,
phone: form.phone,
username: form.phone,
smsCode: form.smsCode,
code: form.smsCode,
comments: JSON.stringify({
product: selectedProduct.value?.code,
months: durationMonths.value,
orderNo: order.value?.orderNo
})
}
const res = await request.post<ApiResult<unknown>>(SERVER_API_URL + '/superAdminRegister', payload)
if (res.data.code !== 0) throw new Error(res.data.message || '开通失败')
provisionInfo.value = (res.data.data || null) as any
provisioned.value = true
} catch (e) {
provisioned.value = false
message.error(String(e))
}
}
type ProvisionUser = {
tenantId?: number
tenantName?: string | null
username?: string
phone?: string
email?: string | null
} & Record<string, unknown>
type ProvisionInfo = {
access_token?: string
user?: ProvisionUser
} & Record<string, unknown>
const provisionInfo = ref<ProvisionInfo | null>(null)
function resetAll() {
step.value = 0
selectedProduct.value = null
durationMonths.value = 12
form.tenantName = ''
form.domain = ''
form.email = ''
form.phone = ''
form.smsCode = ''
order.value = null
payCodeUrl.value = ''
payment.value = null
provisioned.value = false
provisionInfo.value = null
}
</script>
<style scoped>
.card-active {
border-color: #16a34a;
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.15);
}
</style>