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