feat(app): 初始化项目配置和页面结构
- 添加 .dockerignore 和 .env.example 配置文件 - 添加 .gitignore 忽略规则配置 - 创建服务端代理API路由(_file、_modules、_server) - 集成 Ant Design Vue 组件库并配置SSR样式提取 - 定义API响应类型封装 - 创建基础布局组件(blank、console) - 实现应用中心页面和组件(AppsCenter) - 添加文章列表测试页面 - 配置控制台导航菜单结构 - 实现控制台头部组件 - 创建联系页面表单
This commit is contained in:
11
app/pages/apps.vue
Normal file
11
app/pages/apps.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<a-spin class="w-full" tip="跳转中..." />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'blank' })
|
||||
|
||||
onMounted(() => {
|
||||
navigateTo('/developer/apps', { replace: true })
|
||||
})
|
||||
</script>
|
||||
121
app/pages/articles.vue
Normal file
121
app/pages/articles.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<main class="min-h-screen bg-gray-50 p-8">
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
<a-card title="文章列表 (pageCmsArticle)" class="shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a-input-password
|
||||
v-model:value="token"
|
||||
placeholder="Authorization (AccessToken)"
|
||||
class="w-96"
|
||||
/>
|
||||
<a-button :disabled="pending" @click="applyToken">设置Token</a-button>
|
||||
<a-button :disabled="pending" danger @click="clearToken">清除Token</a-button>
|
||||
<a-input
|
||||
v-model:value="keywords"
|
||||
placeholder="关键词 keywords"
|
||||
class="w-72"
|
||||
@pressEnter="doSearch"
|
||||
/>
|
||||
<a-button type="primary" :loading="pending" @click="doSearch">查询</a-button>
|
||||
<a-button :disabled="pending" @click="refresh">刷新</a-button>
|
||||
<div class="text-sm text-gray-500">
|
||||
TenantId: {{ tenantId }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="error"
|
||||
class="mt-4"
|
||||
show-icon
|
||||
type="error"
|
||||
:message="String(error)"
|
||||
/>
|
||||
|
||||
<a-table
|
||||
class="mt-4"
|
||||
:data-source="list"
|
||||
:loading="pending"
|
||||
:pagination="false"
|
||||
row-key="articleId"
|
||||
size="middle"
|
||||
>
|
||||
<a-table-column title="ID" data-index="articleId" width="90" />
|
||||
<a-table-column title="标题" data-index="title" />
|
||||
<a-table-column title="编号" data-index="code" width="220" />
|
||||
<a-table-column title="栏目" data-index="categoryName" width="160" />
|
||||
<a-table-column title="创建时间" data-index="createTime" width="180" />
|
||||
</a-table>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:page-size="limit"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
@change="onPageChange"
|
||||
@showSizeChange="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { pageCmsArticle } from '@/api/cms/cmsArticle/index'
|
||||
import { getToken, removeToken, setToken } from '@/utils/token-util'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const tenantId = computed(() => String(config.public.tenantId))
|
||||
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const keywords = ref('')
|
||||
const token = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
token.value = getToken()
|
||||
})
|
||||
|
||||
const { data, pending, error, refresh } = useAsyncData(
|
||||
'cms-article-page',
|
||||
() =>
|
||||
pageCmsArticle({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
keywords: keywords.value || undefined
|
||||
}),
|
||||
{ server: false }
|
||||
)
|
||||
|
||||
const list = computed(() => data.value?.list ?? [])
|
||||
const total = computed(() => data.value?.count ?? 0)
|
||||
|
||||
function applyToken() {
|
||||
setToken(token.value, true)
|
||||
refresh()
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
removeToken()
|
||||
token.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
page.value = 1
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onPageChange(nextPage: number) {
|
||||
page.value = nextPage
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onPageSizeChange(_current: number, nextSize: number) {
|
||||
limit.value = nextSize
|
||||
page.value = 1
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
347
app/pages/console/account/index.vue
Normal file
347
app/pages/console/account/index.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="账号信息" sub-title="基本资料与企业信息" />
|
||||
|
||||
<a-spin :spinning="loading" tip="加载中...">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" class="card" title="基本资料">
|
||||
<div class="flex items-center gap-4">
|
||||
<a-avatar :size="56" :src="avatarUrl">
|
||||
<template v-if="!avatarUrl" #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold text-gray-900">
|
||||
{{ user?.nickname || user?.username || '未命名用户' }}
|
||||
</div>
|
||||
<div class="text-gray-500">
|
||||
{{ user?.phone || (user as any)?.mobile || user?.email || '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="用户ID">{{ user?.userId ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="账号">{{ user?.username ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="昵称">{{ user?.nickname ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="手机号">{{ user?.phone || (user as any)?.mobile || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ user?.email ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户ID">{{ user?.tenantId ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户名称">{{ user?.tenantName ?? '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<a-button @click="reload" :loading="loading">刷新</a-button>
|
||||
<a-button type="primary" :disabled="!user" @click="openEditUser">编辑</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" class="card" title="企业信息">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="企业ID">{{ company?.companyId ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="企业简称">{{ company?.shortName ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="企业全称">{{ company?.companyName ?? company?.tenantName ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="绑定域名">{{ company?.domain ?? company?.freeDomain ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="联系电话">{{ company?.phone ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ company?.email ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="地址">
|
||||
{{ companyAddress || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="实名认证">
|
||||
<a-tag v-if="company?.authentication" color="green">已认证</a-tag>
|
||||
<a-tag v-else color="default">未认证</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<a-button @click="reload" :loading="loading">刷新</a-button>
|
||||
<a-button type="primary" :disabled="!company" @click="openEditCompany">编辑</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editUserOpen"
|
||||
title="编辑基本资料"
|
||||
:confirm-loading="savingUser"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitUser"
|
||||
>
|
||||
<a-form ref="userFormRef" layout="vertical" :model="userForm" :rules="userRules">
|
||||
<a-form-item label="昵称" name="nickname">
|
||||
<a-input v-model:value="userForm.nickname" placeholder="请输入昵称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input v-model:value="userForm.email" placeholder="例如:name@example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="头像 URL" name="avatarUrl">
|
||||
<a-input v-model:value="userForm.avatarUrl" placeholder="https://..." />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editCompanyOpen"
|
||||
title="编辑企业信息"
|
||||
:confirm-loading="savingCompany"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitCompany"
|
||||
>
|
||||
<a-form ref="companyFormRef" layout="vertical" :model="companyForm" :rules="companyRules">
|
||||
<a-form-item label="企业简称" name="shortName">
|
||||
<a-input v-model:value="companyForm.shortName" placeholder="例如:某某科技" />
|
||||
</a-form-item>
|
||||
<a-form-item label="企业全称" name="companyName">
|
||||
<a-input v-model:value="companyForm.companyName" placeholder="例如:某某科技有限公司" />
|
||||
</a-form-item>
|
||||
<a-form-item label="绑定域名" name="domain">
|
||||
<a-input v-model:value="companyForm.domain" placeholder="例如:example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="联系电话" name="phone">
|
||||
<a-input v-model:value="companyForm.phone" placeholder="请输入联系电话" />
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input v-model:value="companyForm.email" placeholder="例如:service@example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="地址" name="address">
|
||||
<a-textarea v-model:value="companyForm.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</a-form-item>
|
||||
<a-form-item label="发票抬头" name="invoiceHeader">
|
||||
<a-input v-model:value="companyForm.invoiceHeader" placeholder="用于开票" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import { UserOutlined } from '@ant-design/icons-vue'
|
||||
import { getTenantInfo, getUserInfo, updateLoginUser } from '@/api/layout'
|
||||
import { updateCompany } from '@/api/system/company'
|
||||
import type { Company } from '@/api/system/company/model'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
const loading = ref(false)
|
||||
const savingUser = ref(false)
|
||||
const savingCompany = ref(false)
|
||||
|
||||
const user = ref<User | null>(null)
|
||||
const company = ref<Company | null>(null)
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
const candidate =
|
||||
user.value?.avatarUrl ||
|
||||
user.value?.avatar ||
|
||||
user.value?.merchantAvatar ||
|
||||
user.value?.logo ||
|
||||
''
|
||||
if (typeof candidate !== 'string') return ''
|
||||
const normalized = candidate.trim()
|
||||
if (!normalized || normalized === 'null' || normalized === 'undefined') return ''
|
||||
return normalized
|
||||
})
|
||||
|
||||
const companyAddress = computed(() => {
|
||||
const parts = [company.value?.province, company.value?.city, company.value?.region, company.value?.address]
|
||||
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
||||
.filter(Boolean)
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [uRes, cRes] = await Promise.allSettled([getUserInfo(), getTenantInfo()])
|
||||
if (uRes.status === 'fulfilled') {
|
||||
user.value = uRes.value
|
||||
} else {
|
||||
console.error(uRes.reason)
|
||||
message.error(uRes.reason instanceof Error ? uRes.reason.message : '获取用户信息失败')
|
||||
}
|
||||
|
||||
if (cRes.status === 'fulfilled') {
|
||||
company.value = cRes.value
|
||||
} else {
|
||||
console.error(cRes.reason)
|
||||
message.error(cRes.reason instanceof Error ? cRes.reason.message : '获取企业信息失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await load()
|
||||
}
|
||||
|
||||
const editUserOpen = ref(false)
|
||||
const userFormRef = ref<FormInstance>()
|
||||
const userForm = reactive<{ nickname?: string; email?: string; avatarUrl?: string }>({
|
||||
nickname: '',
|
||||
email: '',
|
||||
avatarUrl: ''
|
||||
})
|
||||
const userRules = reactive({
|
||||
nickname: [{ required: true, message: '请输入昵称', type: 'string' }],
|
||||
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
function openEditUser() {
|
||||
if (!user.value) return
|
||||
userForm.nickname = user.value.nickname ?? ''
|
||||
userForm.email = user.value.email ?? ''
|
||||
userForm.avatarUrl = user.value.avatarUrl ?? avatarUrl.value ?? ''
|
||||
editUserOpen.value = true
|
||||
}
|
||||
|
||||
async function submitUser() {
|
||||
if (!user.value) return
|
||||
try {
|
||||
await userFormRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const nickname = (userForm.nickname ?? '').trim()
|
||||
if (!nickname) {
|
||||
message.error('请输入昵称')
|
||||
return
|
||||
}
|
||||
const email = (userForm.email ?? '').trim()
|
||||
const avatar = (userForm.avatarUrl ?? '').trim()
|
||||
savingUser.value = true
|
||||
try {
|
||||
await updateLoginUser({
|
||||
userId: user.value.userId,
|
||||
nickname,
|
||||
email: email || undefined,
|
||||
avatarUrl: avatar || undefined
|
||||
} as User)
|
||||
message.success('保存成功')
|
||||
editUserOpen.value = false
|
||||
await load()
|
||||
} catch (e: unknown) {
|
||||
console.error(e)
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
savingUser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editCompanyOpen = ref(false)
|
||||
const companyFormRef = ref<FormInstance>()
|
||||
const companyForm = reactive<{
|
||||
companyId?: number
|
||||
shortName?: string
|
||||
companyName?: string
|
||||
domain?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
address?: string
|
||||
invoiceHeader?: string
|
||||
}>({
|
||||
companyId: undefined,
|
||||
shortName: '',
|
||||
companyName: '',
|
||||
domain: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
invoiceHeader: ''
|
||||
})
|
||||
|
||||
const companyRules = reactive({
|
||||
companyName: [{ required: true, message: '请输入企业全称', type: 'string' }],
|
||||
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
|
||||
phone: [
|
||||
{
|
||||
validator: (_rule: unknown, value: unknown) => {
|
||||
const normalized = typeof value === 'string' ? value.trim() : ''
|
||||
if (!normalized) return Promise.resolve()
|
||||
const mobileReg = /^1[3-9]\d{9}$/
|
||||
if (mobileReg.test(normalized)) return Promise.resolve()
|
||||
return Promise.reject(new Error('手机号格式不正确'))
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
function openEditCompany() {
|
||||
if (!company.value) return
|
||||
companyForm.companyId = company.value.companyId
|
||||
companyForm.shortName = company.value.shortName ?? ''
|
||||
companyForm.companyName = company.value.companyName ?? company.value.tenantName ?? ''
|
||||
companyForm.domain = company.value.domain ?? ''
|
||||
companyForm.phone = company.value.phone ?? ''
|
||||
companyForm.email = company.value.email ?? ''
|
||||
companyForm.address = company.value.address ?? ''
|
||||
companyForm.invoiceHeader = company.value.invoiceHeader ?? ''
|
||||
editCompanyOpen.value = true
|
||||
}
|
||||
|
||||
async function submitCompany() {
|
||||
if (!company.value) return
|
||||
try {
|
||||
await companyFormRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!companyForm.companyId) {
|
||||
message.error('企业ID缺失,无法保存')
|
||||
return
|
||||
}
|
||||
const companyName = (companyForm.companyName ?? '').trim()
|
||||
if (!companyName) {
|
||||
message.error('请输入企业全称')
|
||||
return
|
||||
}
|
||||
savingCompany.value = true
|
||||
try {
|
||||
const domain = (companyForm.domain ?? '').trim()
|
||||
const phone = (companyForm.phone ?? '').trim()
|
||||
const email = (companyForm.email ?? '').trim()
|
||||
const address = (companyForm.address ?? '').trim()
|
||||
const invoiceHeader = (companyForm.invoiceHeader ?? '').trim()
|
||||
await updateCompany({
|
||||
companyId: companyForm.companyId,
|
||||
shortName: (companyForm.shortName ?? '').trim() || undefined,
|
||||
companyName,
|
||||
domain: domain || undefined,
|
||||
phone: phone || undefined,
|
||||
email: email || undefined,
|
||||
address: address || undefined,
|
||||
invoiceHeader: invoiceHeader || undefined
|
||||
} as Company)
|
||||
message.success('保存成功')
|
||||
editCompanyOpen.value = false
|
||||
await load()
|
||||
} catch (e: unknown) {
|
||||
console.error(e)
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
savingCompany.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
456
app/pages/console/account/kyc.vue
Normal file
456
app/pages/console/account/kyc.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="实名认证" sub-title="企业/个人认证与资料提交">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-tag v-if="current" :color="statusTagColor">{{ statusText }}</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-alert show-icon :type="statusAlertType" :message="statusMessage" :description="statusDescription" />
|
||||
|
||||
<a-divider />
|
||||
|
||||
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules" :disabled="formDisabled">
|
||||
<a-form-item label="认证类型" name="type">
|
||||
<a-radio-group v-model:value="form.type">
|
||||
<a-radio :value="0">个人</a-radio>
|
||||
<a-radio :value="1">企业</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="form.type === 0">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-form-item label="真实姓名" name="realName">
|
||||
<a-input v-model:value="form.realName" placeholder="请输入真实姓名" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-form-item label="证件号码" name="idCard">
|
||||
<a-input v-model:value="form.idCard" placeholder="请输入身份证/证件号码" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="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, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-form-item label="身份证正面" name="sfz1">
|
||||
<a-upload
|
||||
v-model:file-list="sfz1List"
|
||||
:disabled="formDisabled"
|
||||
:max-count="1"
|
||||
list-type="picture-card"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="uploadSfz1"
|
||||
@remove="() => (form.sfz1 = '')"
|
||||
>
|
||||
<div>上传</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-form-item label="身份证反面" name="sfz2">
|
||||
<a-upload
|
||||
v-model:file-list="sfz2List"
|
||||
:disabled="formDisabled"
|
||||
:max-count="1"
|
||||
list-type="picture-card"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="uploadSfz2"
|
||||
@remove="() => (form.sfz2 = '')"
|
||||
>
|
||||
<div>上传</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-form-item label="主体名称" name="name">
|
||||
<a-input v-model:value="form.name" placeholder="例如:某某科技有限公司" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-form-item label="营业执照号码" name="zzCode">
|
||||
<a-input v-model:value="form.zzCode" placeholder="请输入统一社会信用代码/执照号" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-form-item label="联系人" name="realName">
|
||||
<a-input v-model:value="form.realName" placeholder="请输入联系人姓名(选填)" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-form-item label="联系电话" name="phone">
|
||||
<a-input v-model:value="form.phone" placeholder="用于联系(选填)" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="营业执照" name="zzImg">
|
||||
<a-upload
|
||||
v-model:file-list="zzImgList"
|
||||
:disabled="formDisabled"
|
||||
:max-count="1"
|
||||
list-type="picture-card"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="uploadZzImg"
|
||||
@remove="() => (form.zzImg = '')"
|
||||
>
|
||||
<div>上传</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
</a-form>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<!-- <a-popconfirm-->
|
||||
<!-- v-if="current?.id"-->
|
||||
<!-- :title="withdrawConfirmTitle"-->
|
||||
<!-- ok-text="撤回"-->
|
||||
<!-- cancel-text="取消"-->
|
||||
<!-- @confirm="withdraw"-->
|
||||
<!-- >-->
|
||||
<!-- <a-button danger :loading="submitting">撤回</a-button>-->
|
||||
<!-- </a-popconfirm>-->
|
||||
<a-button @click="resetForm" :disabled="submitting || formDisabled">重置</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="formDisabled"
|
||||
@click="submit"
|
||||
>
|
||||
{{ current?.id ? '更新' : '提交' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import { getUserInfo } from '@/api/layout'
|
||||
import { addUserVerify, listUserVerify, removeUserVerify, updateUserVerify } from '@/api/system/userVerify'
|
||||
import { uploadFile } from '@/api/system/file'
|
||||
import type { UploadFile } from 'ant-design-vue'
|
||||
import type { UserVerify } from '@/api/system/userVerify/model'
|
||||
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
type UploadRequestOption = {
|
||||
file?: File
|
||||
onSuccess?: (body: unknown, file: File) => void
|
||||
onError?: (err: unknown) => void
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const current = ref<UserVerify | null>(null)
|
||||
const userId = ref<number | null>(null)
|
||||
|
||||
const status = computed(() => current.value?.status)
|
||||
const isPending = computed(() => status.value === 0)
|
||||
const isApproved = computed(() => status.value === 1)
|
||||
const isRejected = computed(() => status.value === 2 || status.value === 30)
|
||||
const formDisabled = computed(() => !!current.value && (isPending.value || isApproved.value))
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isPending.value) return '待审核'
|
||||
if (isApproved.value) return '审核通过'
|
||||
if (isRejected.value) return '已驳回'
|
||||
if (status.value === undefined || status.value === null) return '未知状态'
|
||||
return `未知状态(${status.value})`
|
||||
})
|
||||
|
||||
const statusTagColor = computed(() => {
|
||||
if (isPending.value) return 'gold'
|
||||
if (isApproved.value) return 'green'
|
||||
if (isRejected.value) return 'red'
|
||||
return 'default'
|
||||
})
|
||||
|
||||
const statusAlertType = computed(() => {
|
||||
if (!current.value) return 'info'
|
||||
if (isPending.value) return 'warning'
|
||||
if (isApproved.value) return 'success'
|
||||
if (isRejected.value) return 'error'
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const statusMessage = computed(() => {
|
||||
if (!current.value) return '未提交认证资料'
|
||||
const prefix = isApproved.value ? '已通过实名认证' : isRejected.value ? '实名认证已驳回' : '已提交认证资料'
|
||||
return `${prefix}(ID: ${current.value.id ?? '-'})`
|
||||
})
|
||||
|
||||
const statusDescription = computed(() => {
|
||||
if (!current.value) return '提交后将生成一条实名认证记录,你可随时更新或撤回。'
|
||||
const time = current.value.createTime ?? current.value.updateTime ?? '-'
|
||||
const reason = (current.value.comments || '').trim()
|
||||
if (isApproved.value) return `审核通过时间:${time}(审核通过后不可编辑;如需变更请联系管理员)`
|
||||
if (isPending.value) return `提交时间:${time}(审核中不可编辑;如需修改请先撤回后重新提交)`
|
||||
if (isRejected.value) return `驳回时间:${time}${reason ? `(原因:${reason})` : ''}(请修改资料后重新提交)`
|
||||
return `提交时间:${time}`
|
||||
})
|
||||
|
||||
const withdrawConfirmTitle = computed(() => {
|
||||
if (isApproved.value) return '当前已审核通过,确定撤回(删除)实名认证记录?'
|
||||
if (isPending.value) return '当前正在审核中,撤回后可修改并重新提交,确定撤回?'
|
||||
if (isRejected.value) return '当前已驳回,撤回后可重新提交,确定撤回?'
|
||||
return '确定撤回(删除)当前实名认证记录?'
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<UserVerify>({
|
||||
type: 0,
|
||||
name: '',
|
||||
zzCode: '',
|
||||
zzImg: '',
|
||||
realName: '',
|
||||
phone: '',
|
||||
idCard: '',
|
||||
sfz1: '',
|
||||
sfz2: '',
|
||||
status: 0,
|
||||
comments: ''
|
||||
})
|
||||
|
||||
const rules = computed(() => {
|
||||
if (form.type === 1) {
|
||||
return {
|
||||
type: [{ required: true, type: 'number', message: '请选择认证类型' }],
|
||||
name: [{ required: true, type: 'string', message: '请输入主体名称' }],
|
||||
zzCode: [{ required: true, type: 'string', message: '请输入营业执照号码' }],
|
||||
zzImg: [{ required: true, type: 'string', message: '请上传营业执照' }]
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: [{ required: true, type: 'number', message: '请选择认证类型' }],
|
||||
realName: [{ required: true, type: 'string', message: '请输入真实姓名' }],
|
||||
idCard: [{ required: true, type: 'string', message: '请输入证件号码' }],
|
||||
sfz1: [{ required: true, type: 'string', message: '请上传身份证正面' }],
|
||||
sfz2: [{ required: true, type: 'string', message: '请上传身份证反面' }]
|
||||
}
|
||||
})
|
||||
|
||||
function applyCurrentToForm(next: UserVerify | null) {
|
||||
current.value = next
|
||||
form.id = next?.id
|
||||
form.type = next?.type ?? 0
|
||||
form.name = next?.name ?? ''
|
||||
form.zzCode = next?.zzCode ?? ''
|
||||
form.zzImg = next?.zzImg ?? ''
|
||||
form.realName = next?.realName ?? ''
|
||||
form.phone = next?.phone ?? ''
|
||||
form.idCard = next?.idCard ?? ''
|
||||
form.sfz1 = next?.sfz1 ?? ''
|
||||
form.sfz2 = next?.sfz2 ?? ''
|
||||
form.status = next?.status ?? 0
|
||||
form.comments = next?.comments ?? ''
|
||||
syncFileLists()
|
||||
}
|
||||
|
||||
const sfz1List = ref<UploadFile[]>([])
|
||||
const sfz2List = ref<UploadFile[]>([])
|
||||
const zzImgList = ref<UploadFile[]>([])
|
||||
|
||||
function toFileList(url: string): UploadFile[] {
|
||||
const normalized = typeof url === 'string' ? url.trim() : ''
|
||||
if (!normalized) return []
|
||||
return [
|
||||
{
|
||||
uid: normalized,
|
||||
name: normalized.split('/').slice(-1)[0] || 'image',
|
||||
status: 'done',
|
||||
url: normalized
|
||||
} as UploadFile
|
||||
]
|
||||
}
|
||||
|
||||
function syncFileLists() {
|
||||
sfz1List.value = toFileList(form.sfz1 ?? '')
|
||||
sfz2List.value = toFileList(form.sfz2 ?? '')
|
||||
zzImgList.value = toFileList(form.zzImg ?? '')
|
||||
}
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!isImage) {
|
||||
message.error('仅支持上传图片文件')
|
||||
return false
|
||||
}
|
||||
const maxSizeMb = 5
|
||||
if (file.size > maxSizeMb * 1024 * 1024) {
|
||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function doUpload(
|
||||
option: UploadRequestOption,
|
||||
setUrl: (url: string) => void,
|
||||
setList: (list: UploadFile[]) => void
|
||||
) {
|
||||
const rawFile = option.file
|
||||
if (!rawFile) return
|
||||
try {
|
||||
const record = await uploadFile(rawFile)
|
||||
const url = (record?.url || record?.downloadUrl || '').trim()
|
||||
if (!url) throw new Error('上传成功但未返回文件地址')
|
||||
setUrl(url)
|
||||
setList(
|
||||
toFileList(url).map((f) => ({
|
||||
...f,
|
||||
uid: String(rawFile.name) + '-' + String(Date.now())
|
||||
})) as UploadFile[]
|
||||
)
|
||||
option.onSuccess?.(record, rawFile)
|
||||
} catch (e) {
|
||||
option.onError?.(e)
|
||||
message.error(e instanceof Error ? e.message : '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
function uploadSfz1(option: UploadRequestOption) {
|
||||
return doUpload(
|
||||
option,
|
||||
(url) => (form.sfz1 = url),
|
||||
(list) => (sfz1List.value = list)
|
||||
)
|
||||
}
|
||||
|
||||
function uploadSfz2(option: UploadRequestOption) {
|
||||
return doUpload(
|
||||
option,
|
||||
(url) => (form.sfz2 = url),
|
||||
(list) => (sfz2List.value = list)
|
||||
)
|
||||
}
|
||||
|
||||
function uploadZzImg(option: UploadRequestOption) {
|
||||
return doUpload(
|
||||
option,
|
||||
(url) => (form.zzImg = url),
|
||||
(list) => (zzImgList.value = list)
|
||||
)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const user = await getUserInfo()
|
||||
userId.value = user.userId ?? null
|
||||
} catch {
|
||||
userId.value = null
|
||||
}
|
||||
|
||||
try {
|
||||
if (!userId.value) {
|
||||
applyCurrentToForm(null)
|
||||
return
|
||||
}
|
||||
|
||||
const list = await listUserVerify({ userId: userId.value })
|
||||
const mine = Array.isArray(list)
|
||||
? [...list].sort((a, b) => (Number(b.id ?? 0) - Number(a.id ?? 0)))[0]
|
||||
: undefined
|
||||
applyCurrentToForm(mine ?? null)
|
||||
} catch (e) {
|
||||
applyCurrentToForm(null)
|
||||
message.error(e instanceof Error ? e.message : '加载实名认证信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await load()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
applyCurrentToForm(current.value)
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (formDisabled.value) {
|
||||
message.warning(isApproved.value ? '审核通过后不可编辑' : '审核中不可编辑')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload: UserVerify = {
|
||||
id: form.id,
|
||||
userId: userId.value ?? form.userId,
|
||||
type: form.type,
|
||||
name: form.name,
|
||||
zzCode: form.zzCode,
|
||||
zzImg: form.zzImg,
|
||||
realName: form.realName,
|
||||
phone: form.phone,
|
||||
idCard: form.idCard,
|
||||
sfz1: form.sfz1,
|
||||
sfz2: form.sfz2,
|
||||
status: 0,
|
||||
comments: form.comments
|
||||
}
|
||||
if (current.value?.id) {
|
||||
await updateUserVerify(payload)
|
||||
message.success('认证资料已更新')
|
||||
} else {
|
||||
await addUserVerify(payload)
|
||||
message.success('认证资料已提交')
|
||||
}
|
||||
await load()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function withdraw() {
|
||||
if (!current.value?.id) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await removeUserVerify(current.value.id)
|
||||
message.success('已撤回')
|
||||
await load()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '撤回失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
442
app/pages/console/account/members.vue
Normal file
442
app/pages/console/account/members.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="成员管理" sub-title="成员邀请、角色与权限">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-input
|
||||
v-model:value="keywords"
|
||||
allow-clear
|
||||
placeholder="搜索账号/昵称/手机号"
|
||||
class="w-64"
|
||||
@press-enter="doSearch"
|
||||
/>
|
||||
<a-button :loading="loading" @click="reload">刷新</a-button>
|
||||
<a-button type="primary" @click="openInvite">邀请成员</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" class="card" title="成员配额">
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item label="成员上限">
|
||||
{{ company?.members ?? '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="当前人数">
|
||||
{{ company?.users ?? '-' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
成员数据来自系统用户(租户维度),可进行邀请、禁用、重置密码与角色设置。
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" class="card" title="快速操作">
|
||||
<a-space wrap>
|
||||
<a-button @click="openInvite">邀请成员</a-button>
|
||||
<a-button @click="reload">刷新列表</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
|
||||
|
||||
<a-table
|
||||
:data-source="list"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:row-key="(r: any) => r.userId ?? r.username"
|
||||
>
|
||||
<a-table-column title="ID" data-index="userId" width="90" />
|
||||
<a-table-column title="账号" data-index="username" width="180" />
|
||||
<a-table-column title="昵称" data-index="nickname" width="160" />
|
||||
<a-table-column title="手机号" data-index="phone" width="140" />
|
||||
<a-table-column title="角色" key="roleName" width="160">
|
||||
<template #default="{ record }">
|
||||
<span>{{ resolveRoleName(record) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" key="status" width="120">
|
||||
<template #default="{ record }">
|
||||
<a-tag v-if="record.status === 0 || record.status === undefined" color="green">正常</a-tag>
|
||||
<a-tag v-else color="default">冻结</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="创建时间" data-index="createTime" width="180" />
|
||||
<a-table-column title="操作" key="actions" width="260" fixed="right">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-button size="small" @click="openRole(record)" :disabled="!record.userId">设置角色</a-button>
|
||||
<a-button size="small" @click="openReset(record)" :disabled="!record.userId">重置密码</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
:loading="busyUserId === record.userId"
|
||||
@click="toggleStatus(record)"
|
||||
:disabled="!record.userId"
|
||||
>
|
||||
{{ record.status === 1 ? '解冻' : '冻结' }}
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定删除该成员?"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="remove(record)"
|
||||
>
|
||||
<a-button size="small" danger :disabled="!record.userId">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:page-size="limit"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
@change="onPageChange"
|
||||
@show-size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="inviteOpen"
|
||||
title="邀请成员"
|
||||
ok-text="创建账号"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="inviting"
|
||||
@ok="submitInvite"
|
||||
>
|
||||
<a-form ref="inviteFormRef" layout="vertical" :model="inviteForm" :rules="inviteRules">
|
||||
<a-form-item label="账号" name="username">
|
||||
<a-input v-model:value="inviteForm.username" placeholder="例如:tom / tom@example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="昵称" name="nickname">
|
||||
<a-input v-model:value="inviteForm.nickname" placeholder="例如:Tom" />
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号" name="phone">
|
||||
<a-input v-model:value="inviteForm.phone" placeholder="例如:13800000000" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色" name="roleId">
|
||||
<a-select
|
||||
v-model:value="inviteForm.roleId"
|
||||
placeholder="请选择角色"
|
||||
allow-clear
|
||||
:options="roleOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="初始密码" name="password">
|
||||
<a-input-password v-model:value="inviteForm.password" placeholder="请输入初始密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="确认密码" name="password2">
|
||||
<a-input-password v-model:value="inviteForm.password2" placeholder="再次输入密码" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-alert
|
||||
class="mt-2"
|
||||
type="info"
|
||||
show-icon
|
||||
message="创建后可在本页进行冻结/解冻、重置密码与角色设置。"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="roleOpen"
|
||||
title="设置角色"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="savingRole"
|
||||
@ok="submitRole"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="成员">
|
||||
<a-input :value="selectedUser?.nickname || selectedUser?.username || ''" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-select v-model:value="selectedRoleId" placeholder="请选择角色" :options="roleOptions" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="resetOpen"
|
||||
title="重置密码"
|
||||
ok-text="确认重置"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="resetting"
|
||||
@ok="submitReset"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="成员">
|
||||
<a-input :value="selectedUser?.nickname || selectedUser?.username || ''" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码">
|
||||
<a-input-password v-model:value="resetPassword" placeholder="请输入新密码" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-alert class="mt-2" type="warning" show-icon message="重置后请尽快通知成员修改密码。" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import { getTenantInfo } from '@/api/layout'
|
||||
import { listRoles } from '@/api/system/role'
|
||||
import { addUser, pageUsers, removeUser, updateUserPassword, updateUserStatus } from '@/api/system/user'
|
||||
import { addUserRole, listUserRole, updateUserRole } from '@/api/system/userRole'
|
||||
import type { Company } from '@/api/system/company/model'
|
||||
import type { Role } from '@/api/system/role/model'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string>('')
|
||||
|
||||
const company = ref<Company | null>(null)
|
||||
const roles = ref<Role[]>([])
|
||||
|
||||
const list = ref<User[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const keywords = ref('')
|
||||
|
||||
const roleOptions = computed(() =>
|
||||
roles.value.map((r) => ({ label: r.roleName ?? String(r.roleId ?? ''), value: r.roleId }))
|
||||
)
|
||||
|
||||
function resolveRoleName(user: User) {
|
||||
const direct = typeof user.roleName === 'string' ? user.roleName.trim() : ''
|
||||
if (direct) return direct
|
||||
const hit = roles.value.find((r) => r.roleId === user.roleId)
|
||||
return hit?.roleName ?? '-'
|
||||
}
|
||||
|
||||
async function loadCompany() {
|
||||
try {
|
||||
company.value = await getTenantInfo()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRolesOnce() {
|
||||
if (roles.value.length) return
|
||||
try {
|
||||
roles.value = await listRoles()
|
||||
} catch {
|
||||
roles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await pageUsers({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
keywords: keywords.value || undefined
|
||||
})
|
||||
list.value = res?.list ?? []
|
||||
total.value = res?.count ?? 0
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '成员列表加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await Promise.all([loadCompany(), loadRolesOnce(), loadMembers()])
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
page.value = 1
|
||||
loadMembers()
|
||||
}
|
||||
|
||||
function onPageChange(nextPage: number) {
|
||||
page.value = nextPage
|
||||
loadMembers()
|
||||
}
|
||||
|
||||
function onPageSizeChange(_current: number, nextSize: number) {
|
||||
limit.value = nextSize
|
||||
page.value = 1
|
||||
loadMembers()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await reload()
|
||||
})
|
||||
|
||||
const busyUserId = ref<number | null>(null)
|
||||
async function toggleStatus(user: User) {
|
||||
if (!user.userId) return
|
||||
const next = user.status === 1 ? 0 : 1
|
||||
busyUserId.value = user.userId
|
||||
try {
|
||||
await updateUserStatus(user.userId, next)
|
||||
message.success(next === 0 ? '已解冻' : '已冻结')
|
||||
await loadMembers()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
busyUserId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(user: User) {
|
||||
if (!user.userId) return
|
||||
busyUserId.value = user.userId
|
||||
try {
|
||||
await removeUser(user.userId)
|
||||
message.success('已删除')
|
||||
await loadMembers()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
} finally {
|
||||
busyUserId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const inviteOpen = ref(false)
|
||||
const inviting = ref(false)
|
||||
const inviteFormRef = ref<FormInstance>()
|
||||
const inviteForm = reactive<{ username: string; nickname: string; phone: string; roleId?: number; password: string; password2: string }>({
|
||||
username: '',
|
||||
nickname: '',
|
||||
phone: '',
|
||||
roleId: undefined,
|
||||
password: '',
|
||||
password2: ''
|
||||
})
|
||||
const inviteRules = reactive({
|
||||
username: [{ required: true, type: 'string', message: '请输入账号' }],
|
||||
nickname: [{ required: true, type: 'string', message: '请输入昵称' }],
|
||||
password: [{ required: true, type: 'string', message: '请输入初始密码' }],
|
||||
password2: [{ required: true, type: 'string', message: '请再次输入密码' }]
|
||||
})
|
||||
|
||||
function openInvite() {
|
||||
inviteForm.username = ''
|
||||
inviteForm.nickname = ''
|
||||
inviteForm.phone = ''
|
||||
inviteForm.roleId = undefined
|
||||
inviteForm.password = ''
|
||||
inviteForm.password2 = ''
|
||||
inviteOpen.value = true
|
||||
}
|
||||
|
||||
async function submitInvite() {
|
||||
try {
|
||||
await inviteFormRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (inviteForm.password !== inviteForm.password2) {
|
||||
message.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
inviting.value = true
|
||||
try {
|
||||
await addUser({
|
||||
username: inviteForm.username.trim(),
|
||||
nickname: inviteForm.nickname.trim(),
|
||||
phone: inviteForm.phone.trim() || undefined,
|
||||
password: inviteForm.password,
|
||||
password2: inviteForm.password2,
|
||||
roleId: inviteForm.roleId
|
||||
})
|
||||
message.success('成员已创建')
|
||||
inviteOpen.value = false
|
||||
await loadMembers()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
inviting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const roleOpen = ref(false)
|
||||
const savingRole = ref(false)
|
||||
const selectedUser = ref<User | null>(null)
|
||||
const selectedRoleId = ref<number | undefined>(undefined)
|
||||
function openRole(user: User) {
|
||||
selectedUser.value = user
|
||||
selectedRoleId.value = user.roleId
|
||||
roleOpen.value = true
|
||||
}
|
||||
|
||||
async function submitRole() {
|
||||
if (!selectedUser.value?.userId) return
|
||||
if (!selectedRoleId.value) {
|
||||
message.error('请选择角色')
|
||||
return
|
||||
}
|
||||
savingRole.value = true
|
||||
try {
|
||||
const mappings = await listUserRole({ userId: selectedUser.value.userId })
|
||||
const first = Array.isArray(mappings) ? mappings[0] : undefined
|
||||
if (first?.id) {
|
||||
await updateUserRole({ ...first, roleId: selectedRoleId.value })
|
||||
} else {
|
||||
await addUserRole({ userId: selectedUser.value.userId, roleId: selectedRoleId.value })
|
||||
}
|
||||
message.success('角色已更新')
|
||||
roleOpen.value = false
|
||||
await loadMembers()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '更新失败')
|
||||
} finally {
|
||||
savingRole.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetOpen = ref(false)
|
||||
const resetting = ref(false)
|
||||
const resetPassword = ref('')
|
||||
function openReset(user: User) {
|
||||
selectedUser.value = user
|
||||
resetPassword.value = ''
|
||||
resetOpen.value = true
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (!selectedUser.value?.userId) return
|
||||
const pwd = resetPassword.value.trim()
|
||||
if (!pwd) {
|
||||
message.error('请输入新密码')
|
||||
return
|
||||
}
|
||||
resetting.value = true
|
||||
try {
|
||||
await updateUserPassword(selectedUser.value.userId, pwd)
|
||||
message.success('密码已重置')
|
||||
resetOpen.value = false
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '重置失败')
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
134
app/pages/console/account/security.vue
Normal file
134
app/pages/console/account/security.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="账号安全" sub-title="密码、登录设备与安全设置">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button danger @click="logout">退出登录</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" class="card" title="修改密码">
|
||||
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules">
|
||||
<a-form-item label="原密码" name="oldPassword">
|
||||
<a-input-password v-model:value="form.oldPassword" placeholder="请输入原密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码" name="password">
|
||||
<a-input-password v-model:value="form.password" placeholder="请输入新密码(至少 6 位)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="确认新密码" name="password2">
|
||||
<a-input-password v-model:value="form.password2" placeholder="再次输入新密码" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<a-button @click="resetForm" :disabled="pending">重置</a-button>
|
||||
<a-button type="primary" :loading="pending" @click="submit">保存</a-button>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
class="mt-4"
|
||||
show-icon
|
||||
type="info"
|
||||
message="修改密码后建议重新登录,以确保所有会话状态一致。"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" class="card" title="安全建议">
|
||||
<a-list size="small" bordered :data-source="tips">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>{{ item }}</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import { updatePassword } from '@/api/layout'
|
||||
import { removeToken } from '@/utils/token-util'
|
||||
import { clearAuthz } from '@/utils/permission'
|
||||
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
const pending = ref(false)
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<{ oldPassword: string; password: string; password2: string }>({
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
password2: ''
|
||||
})
|
||||
|
||||
const rules = reactive({
|
||||
oldPassword: [{ required: true, type: 'string', message: '请输入原密码' }],
|
||||
password: [
|
||||
{ required: true, type: 'string', message: '请输入新密码' },
|
||||
{ min: 6, type: 'string', message: '新密码至少 6 位', trigger: 'blur' }
|
||||
],
|
||||
password2: [{ required: true, type: 'string', message: '请再次输入新密码' }]
|
||||
})
|
||||
|
||||
const tips = [
|
||||
'定期修改密码,避免与其他平台重复使用。',
|
||||
'优先使用更长的随机密码。',
|
||||
'不要将账号/密码分享给他人。',
|
||||
'如怀疑账号被盗用,请立即修改密码并退出登录。'
|
||||
]
|
||||
|
||||
function resetForm() {
|
||||
form.oldPassword = ''
|
||||
form.password = ''
|
||||
form.password2 = ''
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (form.password !== form.password2) {
|
||||
message.error('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
pending.value = true
|
||||
try {
|
||||
await updatePassword({ oldPassword: form.oldPassword, password: form.password })
|
||||
message.success('密码修改成功')
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '密码修改失败')
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
removeToken()
|
||||
try {
|
||||
localStorage.removeItem('TenantId')
|
||||
localStorage.removeItem('UserId')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
clearAuthz()
|
||||
navigateTo('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
20
app/pages/console/coupons.vue
Normal file
20
app/pages/console/coupons.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="优惠券" sub-title="可用优惠与使用记录" />
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-empty description="待接入:优惠券列表" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'console' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
362
app/pages/console/index.vue
Normal file
362
app/pages/console/index.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="租户管理" sub-title="租户创建、查询与维护" :ghost="false" class="page-header">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-input
|
||||
v-model:value="keywords"
|
||||
allow-clear
|
||||
placeholder="搜索租户名称/租户ID"
|
||||
class="w-64"
|
||||
@press-enter="doSearch"
|
||||
/>
|
||||
<a-button :loading="loading" @click="reload">刷新</a-button>
|
||||
<a-button type="primary" @click="openCreate">创建</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
|
||||
|
||||
<a-table
|
||||
:data-source="list"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:row-key="(r: any) => r.tenantId ?? r.websiteId ?? r.appId ?? r.websiteName ?? r.tenantName"
|
||||
>
|
||||
<a-table-column title="租户ID" data-index="tenantId" width="90" />
|
||||
<a-table-column title="租户名称" key="tenantName">
|
||||
<template #default="{ record }">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<a-avatar :src="record.websiteLogo || record.websiteIcon || record.logo" :size="22" shape="square">
|
||||
<template #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<span class="truncate">{{ record.websiteName || record.tenantName || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" key="status">
|
||||
<template #default="{ record }">
|
||||
<a-tag :color="statusColor(record.status)">
|
||||
{{ statusText(record.status, record.statusText) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="创建时间" data-index="createTime" />
|
||||
<a-table-column title="操作" key="actions" width="260" fixed="right">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-button size="small" @click="openEdit(record)">详情</a-button>
|
||||
<!-- <a-button size="small" @click="openReset(record)" :disabled="!record.tenantId">重置密码</a-button>-->
|
||||
<!-- <a-popconfirm-->
|
||||
<!-- title="确定删除该租户?"-->
|
||||
<!-- ok-text="删除"-->
|
||||
<!-- cancel-text="取消"-->
|
||||
<!-- @confirm="remove(record)"-->
|
||||
<!-- >-->
|
||||
<!-- <a-button size="small" danger :loading="busyTenantId === record.tenantId" :disabled="!record.tenantId">删除</a-button>-->
|
||||
<!-- </a-popconfirm>-->
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:page-size="limit"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
@change="onPageChange"
|
||||
@show-size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editOpen"
|
||||
:title="editTitle"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="saving"
|
||||
@ok="submitEdit"
|
||||
>
|
||||
<a-form ref="editFormRef" layout="vertical" :model="editForm" :rules="editRules">
|
||||
<a-form-item v-if="editForm.tenantId" label="租户ID">
|
||||
<a-input :value="String(editForm.tenantId ?? '')" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="租户名称" name="tenantName">
|
||||
<a-input v-model:value="editForm.tenantName" placeholder="例如:某某科技有限公司" />
|
||||
</a-form-item>
|
||||
<a-form-item label="企业名称" name="companyName">
|
||||
<a-input v-model:value="editForm.companyName" placeholder="例如:某某科技有限公司" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Logo" name="logo">
|
||||
<a-input v-model:value="editForm.logo" placeholder="https://..." />
|
||||
</a-form-item>
|
||||
<a-form-item label="应用秘钥" name="appSecret">
|
||||
<a-input-password v-model:value="editForm.appSecret" placeholder="appSecret(可选)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select
|
||||
v-model:value="editForm.status"
|
||||
placeholder="请选择"
|
||||
:options="[
|
||||
{ label: '正常', value: 0 },
|
||||
{ label: '禁用', value: 1 }
|
||||
]"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="comments">
|
||||
<a-textarea v-model:value="editForm.comments" :rows="3" placeholder="备注(可选)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="resetOpen"
|
||||
title="重置租户密码"
|
||||
ok-text="确认重置"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="resetting"
|
||||
@ok="submitReset"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="租户">
|
||||
<a-input :value="selectedTenant?.tenantName || ''" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码">
|
||||
<a-input-password v-model:value="resetPassword" placeholder="请输入新密码" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-alert class="mt-2" type="warning" show-icon message="重置后请尽快通知租户管理员修改密码。" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import { UserOutlined } from '@ant-design/icons-vue'
|
||||
import { pageCmsWebsiteAll } from '@/api/cms/cmsWebsite'
|
||||
import type { CmsWebsite } from '@/api/cms/cmsWebsite/model'
|
||||
import { addTenant, removeTenant, updateTenant, updateTenantPassword } from '@/api/system/tenant'
|
||||
import type { Tenant } from '@/api/system/tenant/model'
|
||||
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string>('')
|
||||
|
||||
const list = ref<CmsWebsite[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
|
||||
const keywords = ref('')
|
||||
|
||||
async function loadTenants() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const rawUserId = process.client ? localStorage.getItem('UserId') : null
|
||||
const userId = rawUserId ? Number(rawUserId) : NaN
|
||||
const res = await pageCmsWebsiteAll({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
keywords: keywords.value || undefined,
|
||||
userId: Number.isFinite(userId) ? userId : undefined
|
||||
})
|
||||
list.value = res?.list ?? []
|
||||
total.value = res?.count ?? 0
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '租户列表加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await loadTenants()
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
page.value = 1
|
||||
loadTenants()
|
||||
}
|
||||
|
||||
function onPageChange(nextPage: number) {
|
||||
page.value = nextPage
|
||||
loadTenants()
|
||||
}
|
||||
|
||||
function onPageSizeChange(_current: number, nextSize: number) {
|
||||
limit.value = nextSize
|
||||
page.value = 1
|
||||
loadTenants()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTenants()
|
||||
})
|
||||
|
||||
const editOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const editFormRef = ref<FormInstance>()
|
||||
const editForm = reactive<Tenant>({
|
||||
tenantId: undefined,
|
||||
tenantName: '',
|
||||
companyName: '',
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
logo: '',
|
||||
comments: '',
|
||||
status: 0
|
||||
})
|
||||
const editTitle = computed(() => (editForm.tenantId ? '编辑' : '创建'))
|
||||
const editRules = reactive({
|
||||
tenantName: [{ required: true, type: 'string', message: '请输入租户名称' }]
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
editForm.tenantId = undefined
|
||||
editForm.tenantName = ''
|
||||
editForm.companyName = ''
|
||||
editForm.appId = ''
|
||||
editForm.appSecret = ''
|
||||
editForm.logo = ''
|
||||
editForm.comments = ''
|
||||
editForm.status = 0
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: CmsWebsite | Tenant) {
|
||||
// pageCmsWebsiteAll 返回的是应用(网站)列表,这里映射到租户表单字段
|
||||
const anyRow = row as unknown as Partial<CmsWebsite & Tenant>
|
||||
editForm.tenantId = anyRow.tenantId
|
||||
editForm.tenantName = anyRow.tenantName ?? anyRow.websiteName ?? ''
|
||||
editForm.companyName = anyRow.companyName ?? ''
|
||||
editForm.appId = anyRow.appId ?? anyRow.websiteCode ?? ''
|
||||
editForm.appSecret = anyRow.appSecret ?? anyRow.websiteSecret ?? ''
|
||||
editForm.logo = anyRow.logo ?? anyRow.websiteLogo ?? anyRow.websiteIcon ?? ''
|
||||
editForm.comments = anyRow.comments ?? ''
|
||||
// 租户状态只支持 0/1,应用状态(0~5) 这里做一个兼容映射
|
||||
editForm.status = typeof anyRow.status === 'number' ? (anyRow.status === 1 ? 0 : 1) : 0
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
try {
|
||||
await editFormRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: Tenant = {
|
||||
...editForm,
|
||||
tenantName: editForm.tenantName?.trim(),
|
||||
companyName: editForm.companyName?.trim() || undefined,
|
||||
appId: editForm.appId?.trim(),
|
||||
appSecret: editForm.appSecret?.trim() || undefined,
|
||||
logo: editForm.logo?.trim() || undefined,
|
||||
comments: editForm.comments?.trim() || undefined
|
||||
}
|
||||
if (payload.tenantId) {
|
||||
await updateTenant(payload)
|
||||
message.success('租户已更新')
|
||||
} else {
|
||||
await addTenant(payload)
|
||||
message.success('租户已创建')
|
||||
}
|
||||
editOpen.value = false
|
||||
await loadTenants()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const busyTenantId = ref<number | null>(null)
|
||||
async function remove(row: CmsWebsite | Tenant) {
|
||||
if (!row.tenantId) return
|
||||
busyTenantId.value = row.tenantId
|
||||
try {
|
||||
await removeTenant(row.tenantId)
|
||||
message.success('已删除')
|
||||
if (list.value.length <= 1 && page.value > 1) page.value -= 1
|
||||
await loadTenants()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
} finally {
|
||||
busyTenantId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const resetOpen = ref(false)
|
||||
const resetting = ref(false)
|
||||
const resetPassword = ref('')
|
||||
const selectedTenant = ref<Tenant | null>(null)
|
||||
|
||||
async function submitReset() {
|
||||
if (!selectedTenant.value?.tenantId) return
|
||||
const pwd = resetPassword.value.trim()
|
||||
if (!pwd) {
|
||||
message.error('请输入新密码')
|
||||
return
|
||||
}
|
||||
resetting.value = true
|
||||
try {
|
||||
await updateTenantPassword(selectedTenant.value.tenantId, pwd)
|
||||
message.success('密码已重置')
|
||||
resetOpen.value = false
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '重置失败')
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(status?: number, fallback?: string) {
|
||||
if (fallback) return fallback
|
||||
const map: Record<number, string> = {
|
||||
0: '未开通',
|
||||
1: '运行中',
|
||||
2: '维护中',
|
||||
3: '已关闭',
|
||||
4: '欠费停机',
|
||||
5: '违规关停'
|
||||
}
|
||||
if (typeof status === 'number' && status in map) return map[status]
|
||||
return '-'
|
||||
}
|
||||
|
||||
function statusColor(status?: number) {
|
||||
const map: Record<number, string> = {
|
||||
0: 'default',
|
||||
1: 'green',
|
||||
2: 'orange',
|
||||
3: 'red',
|
||||
4: 'volcano',
|
||||
5: 'red'
|
||||
}
|
||||
if (typeof status === 'number' && status in map) return map[status]
|
||||
return 'default'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
32
app/pages/console/logout.vue
Normal file
32
app/pages/console/logout.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<a-spin size="large" tip="正在退出..." class="logout-spin" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { removeToken } from '@/utils/token-util'
|
||||
import { clearAuthz } from '@/utils/permission'
|
||||
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
onMounted(async () => {
|
||||
removeToken()
|
||||
try {
|
||||
localStorage.removeItem('TenantId')
|
||||
localStorage.removeItem('UserId')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
clearAuthz()
|
||||
await navigateTo('/')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logout-spin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 240px;
|
||||
}
|
||||
</style>
|
||||
|
||||
394
app/pages/console/orders.vue
Normal file
394
app/pages/console/orders.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="订单管理" sub-title="购买、续费与支付记录">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-input
|
||||
v-model:value="keywords"
|
||||
allow-clear
|
||||
placeholder="搜索订单号/产品"
|
||||
class="w-64"
|
||||
@press-enter="doSearch"
|
||||
/>
|
||||
<a-button :loading="loading" @click="reload">刷新</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-space class="mb-4">
|
||||
<a-segmented
|
||||
:value="payStatusSegment"
|
||||
:options="payStatusOptions"
|
||||
@update:value="onPayStatusChange"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="orderStatus"
|
||||
allow-clear
|
||||
placeholder="订单状态"
|
||||
:options="orderStatusOptions"
|
||||
@change="reload"
|
||||
style="min-width: 160px"
|
||||
/>
|
||||
</a-space>
|
||||
|
||||
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
|
||||
|
||||
<a-table
|
||||
:data-source="list"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:row-key="(r: any) => r.orderId ?? r.orderNo"
|
||||
>
|
||||
<a-table-column title="订单号" key="orderNo" width="220">
|
||||
<template #default="{ record }">
|
||||
<a-typography-text :copyable="{ text: record.orderNo || '' }">
|
||||
{{ record.orderNo || '-' }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="产品" key="product" width="200">
|
||||
<template #default="{ record }">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate">{{ resolveProductName(record) }}</div>
|
||||
<div class="text-xs text-gray-500 truncate" v-if="resolveProductSub(record)">
|
||||
{{ resolveProductSub(record) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="金额" key="amount" width="140">
|
||||
<template #default="{ record }">
|
||||
<span>{{ formatMoney(record.payPrice || record.totalPrice) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="支付" key="payStatus" width="110">
|
||||
<template #default="{ record }">
|
||||
<a-tag v-if="Number(record.payStatus) === 1" color="green">已支付</a-tag>
|
||||
<a-tag v-else-if="Number(record.payStatus) === 0" color="default">未支付</a-tag>
|
||||
<a-tag v-else color="default">-</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="状态" key="orderStatus" width="160">
|
||||
<template #default="{ record }">
|
||||
<a-tag :color="resolveOrderStatusColor(record.orderStatus)">{{ resolveOrderStatusText(record.orderStatus) }}</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="创建时间" data-index="createTime" width="180">
|
||||
<template #default="{ record }">
|
||||
<span>{{ record.createTime || '-' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="到期时间" data-index="expirationTime" width="180">
|
||||
<template #default="{ record }">
|
||||
<span>{{ record.expirationTime || '-' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="操作" key="actions" width="120" fixed="right">
|
||||
<template #default="{ record }">
|
||||
<a-button size="small" @click="openDetail(record)">查看</a-button>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:page-size="limit"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
@change="onPageChange"
|
||||
@show-size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:open="detailOpen" title="订单详情" :width="720" ok-text="关闭" cancel-text="取消" :footer="null">
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item label="订单号">
|
||||
<a-typography-text :copyable="{ text: selected?.orderNo || '' }">
|
||||
{{ selected?.orderNo || '-' }}
|
||||
</a-typography-text>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="订单ID">{{ selected?.orderId ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="金额">{{ formatMoney(selected?.payPrice || selected?.totalPrice) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="支付方式">{{ resolvePayTypeText(selected?.payType) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="支付状态">
|
||||
{{ Number(selected?.payStatus) === 1 ? '已支付' : Number(selected?.payStatus) === 0 ? '未支付' : '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="订单状态">{{ resolveOrderStatusText(selected?.orderStatus) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ selected?.createTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="支付时间">{{ selected?.payTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="到期时间">{{ selected?.expirationTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="产品">
|
||||
{{ resolveProductName(selected) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="备注">
|
||||
<span class="break-all">{{ pickFirstRemark(selected) || '-' }}</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="text-sm text-gray-600 mb-2">解析到的扩展字段(buyerRemarks/merchantRemarks/comments)</div>
|
||||
<a-typography-paragraph :copyable="{ text: prettyExtra(selected) }">
|
||||
<pre class="m-0 whitespace-pre-wrap break-words">{{ prettyExtra(selected) }}</pre>
|
||||
</a-typography-paragraph>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getUserInfo } from '@/api/layout'
|
||||
import { pageShopOrder } from '@/api/shop/shopOrder'
|
||||
import type { ShopOrder } from '@/api/shop/shopOrder/model'
|
||||
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const list = ref<ShopOrder[]>([])
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
const keywords = ref('')
|
||||
const payStatus = ref<number | null>(null)
|
||||
const orderStatus = ref<number | null>(null)
|
||||
|
||||
const currentUserId = ref<number | null>(null)
|
||||
|
||||
const payStatusOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '已支付', value: 1 },
|
||||
{ label: '未支付', value: 0 }
|
||||
]
|
||||
|
||||
const payStatusSegment = computed(() => (payStatus.value === null ? 'all' : payStatus.value))
|
||||
|
||||
const orderStatusOptions = [
|
||||
{ label: '未使用', value: 0 },
|
||||
{ label: '已完成', value: 1 },
|
||||
{ label: '已取消', value: 2 },
|
||||
{ label: '取消中', value: 3 },
|
||||
{ label: '退款申请中', value: 4 },
|
||||
{ label: '退款被拒绝', value: 5 },
|
||||
{ label: '退款成功', value: 6 },
|
||||
{ label: '客户申请退款', value: 7 }
|
||||
]
|
||||
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref<ShopOrder | null>(null)
|
||||
|
||||
function safeJsonParse(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function pickFirstRemark(order?: ShopOrder | null) {
|
||||
if (!order) return ''
|
||||
const record = order as unknown as Record<string, unknown>
|
||||
const keys = ['buyerRemarks', 'merchantRemarks', 'comments']
|
||||
for (const key of keys) {
|
||||
const v = record[key]
|
||||
if (typeof v === 'string' && v.trim()) return v.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function parseExtra(order?: ShopOrder | null): Record<string, unknown> | null {
|
||||
const raw = pickFirstRemark(order)
|
||||
if (!raw) return null
|
||||
const parsed = safeJsonParse(raw)
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
function prettyExtra(order?: ShopOrder | null) {
|
||||
const extra = parseExtra(order)
|
||||
if (!extra) return '-'
|
||||
try {
|
||||
return JSON.stringify(extra, null, 2)
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
const productCatalog: Record<string, { name: string }> = {
|
||||
website: { name: '企业官网' },
|
||||
shop: { name: '电商系统' },
|
||||
mp: { name: '小程序/公众号' }
|
||||
}
|
||||
|
||||
function resolveProductCode(order?: ShopOrder | null) {
|
||||
const extra = parseExtra(order)
|
||||
const code = typeof extra?.product === 'string' ? extra.product.trim() : ''
|
||||
return code
|
||||
}
|
||||
|
||||
function resolveProductSub(order?: ShopOrder | null) {
|
||||
const extra = parseExtra(order)
|
||||
const months = extra?.months
|
||||
const tenantName = extra?.tenantName
|
||||
const domain = extra?.domain
|
||||
|
||||
const parts: string[] = []
|
||||
if (typeof months === 'number' || typeof months === 'string') {
|
||||
const m = String(months).trim()
|
||||
if (m) parts.push(`${m}个月`)
|
||||
}
|
||||
if (typeof tenantName === 'string' && tenantName.trim()) parts.push(tenantName.trim())
|
||||
if (typeof domain === 'string' && domain.trim()) parts.push(domain.trim())
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function resolveProductName(order?: ShopOrder | null) {
|
||||
const code = resolveProductCode(order)
|
||||
if (code && productCatalog[code]) return productCatalog[code].name
|
||||
if (code) return code
|
||||
return '-'
|
||||
}
|
||||
|
||||
function formatMoney(value?: string) {
|
||||
const v = typeof value === 'string' ? value.trim() : ''
|
||||
if (!v) return '-'
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n)) return `¥${v}`
|
||||
return `¥${n.toFixed(2)}`
|
||||
}
|
||||
|
||||
function resolvePayTypeText(payType?: number) {
|
||||
const v = Number(payType)
|
||||
if (!Number.isFinite(v)) return '-'
|
||||
const map: Record<number, string> = {
|
||||
0: '余额',
|
||||
1: '微信',
|
||||
102: '微信 Native',
|
||||
2: '会员卡',
|
||||
3: '支付宝',
|
||||
4: '现金',
|
||||
5: 'POS',
|
||||
12: '免费'
|
||||
}
|
||||
return map[v] || `方式${v}`
|
||||
}
|
||||
|
||||
function resolveOrderStatusText(orderStatus?: number) {
|
||||
const v = Number(orderStatus)
|
||||
if (!Number.isFinite(v)) return '-'
|
||||
const map: Record<number, string> = {
|
||||
0: '未使用',
|
||||
1: '已完成',
|
||||
2: '已取消',
|
||||
3: '取消中',
|
||||
4: '退款申请中',
|
||||
5: '退款被拒绝',
|
||||
6: '退款成功',
|
||||
7: '客户申请退款'
|
||||
}
|
||||
return map[v] || `状态${v}`
|
||||
}
|
||||
|
||||
function resolveOrderStatusColor(orderStatus?: number) {
|
||||
const v = Number(orderStatus)
|
||||
if (v === 1) return 'green'
|
||||
if (v === 2) return 'default'
|
||||
if (v === 6) return 'default'
|
||||
if (v === 4 || v === 3 || v === 7) return 'orange'
|
||||
if (v === 5) return 'red'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
async function ensureUser() {
|
||||
if (currentUserId.value) return
|
||||
const user = await getUserInfo()
|
||||
currentUserId.value = user.userId ?? null
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await ensureUser()
|
||||
const userId = currentUserId.value
|
||||
if (!userId) {
|
||||
throw new Error('缺少用户信息,无法查询当前用户订单')
|
||||
}
|
||||
|
||||
const data = await pageShopOrder({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
userId,
|
||||
keywords: keywords.value?.trim() || undefined,
|
||||
payStatus: payStatus.value === null ? undefined : payStatus.value,
|
||||
orderStatus: orderStatus.value === null ? undefined : orderStatus.value
|
||||
})
|
||||
|
||||
list.value = data?.list || []
|
||||
total.value = data?.count || 0
|
||||
} catch (e: unknown) {
|
||||
console.error(e)
|
||||
list.value = []
|
||||
total.value = 0
|
||||
error.value = e instanceof Error ? e.message : '加载订单失败'
|
||||
message.error(error.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await load()
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onPayStatusChange(value: string | number) {
|
||||
payStatus.value = value === 'all' ? null : Number(value)
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p
|
||||
load()
|
||||
}
|
||||
|
||||
function onPageSizeChange(_current: number, size: number) {
|
||||
limit.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function openDetail(order: ShopOrder) {
|
||||
selected.value = order
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
20
app/pages/console/products.vue
Normal file
20
app/pages/console/products.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="已购产品" sub-title="订阅与授权信息" />
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-empty description="待接入:已购产品列表" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'console' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
336
app/pages/console/tenant/index.vue
Normal file
336
app/pages/console/tenant/index.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="租户管理" sub-title="租户创建、查询与维护" :ghost="false" class="page-header">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-input
|
||||
v-model:value="keywords"
|
||||
allow-clear
|
||||
placeholder="搜索租户名称/租户ID"
|
||||
class="w-64"
|
||||
@press-enter="doSearch"
|
||||
/>
|
||||
<a-button :loading="loading" @click="reload">刷新</a-button>
|
||||
<a-button type="primary" @click="openCreate">新增租户</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
|
||||
|
||||
<a-table
|
||||
:data-source="list"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
:row-key="(r: any) => r.tenantId ?? r.appId ?? r.tenantName"
|
||||
>
|
||||
<a-table-column title="租户ID" data-index="tenantId" width="90" />
|
||||
<a-table-column title="租户名称" key="tenantName" width="220">
|
||||
<template #default="{ record }">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<a-avatar :src="record.logo" :size="22" shape="square">
|
||||
<template #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<span class="truncate">{{ record.tenantName || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" key="status" width="120">
|
||||
<template #default="{ record }">
|
||||
<a-tag v-if="record.status === 0 || record.status === undefined" color="green">正常</a-tag>
|
||||
<a-tag v-else color="default">禁用</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="创建时间" data-index="createTime" width="180" />
|
||||
<a-table-column title="操作" key="actions" width="260" fixed="right">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-button size="small" @click="openEdit(record)">编辑</a-button>
|
||||
<a-button size="small" @click="openReset(record)" :disabled="!record.tenantId">重置密码</a-button>
|
||||
<a-popconfirm
|
||||
title="确定删除该租户?"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="remove(record)"
|
||||
>
|
||||
<a-button size="small" danger :loading="busyTenantId === record.tenantId" :disabled="!record.tenantId">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:page-size="limit"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
@change="onPageChange"
|
||||
@show-size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editOpen"
|
||||
:title="editTitle"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="saving"
|
||||
@ok="submitEdit"
|
||||
>
|
||||
<a-form ref="editFormRef" layout="vertical" :model="editForm" :rules="editRules">
|
||||
<a-form-item v-if="editForm.tenantId" label="租户ID">
|
||||
<a-input :value="String(editForm.tenantId ?? '')" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="租户名称" name="tenantName">
|
||||
<a-input v-model:value="editForm.tenantName" placeholder="例如:某某科技有限公司" />
|
||||
</a-form-item>
|
||||
<a-form-item label="企业名称" name="companyName">
|
||||
<a-input v-model:value="editForm.companyName" placeholder="例如:某某科技有限公司" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Logo" name="logo">
|
||||
<a-input v-model:value="editForm.logo" placeholder="https://..." />
|
||||
</a-form-item>
|
||||
<a-form-item label="应用秘钥" name="appSecret">
|
||||
<a-input-password v-model:value="editForm.appSecret" placeholder="appSecret(可选)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select
|
||||
v-model:value="editForm.status"
|
||||
placeholder="请选择"
|
||||
:options="[
|
||||
{ label: '正常', value: 0 },
|
||||
{ label: '禁用', value: 1 }
|
||||
]"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="comments">
|
||||
<a-textarea v-model:value="editForm.comments" :rows="3" placeholder="备注(可选)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="resetOpen"
|
||||
title="重置租户密码"
|
||||
ok-text="确认重置"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="resetting"
|
||||
@ok="submitReset"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="租户">
|
||||
<a-input :value="selectedTenant?.tenantName || ''" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码">
|
||||
<a-input-password v-model:value="resetPassword" placeholder="请输入新密码" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-alert class="mt-2" type="warning" show-icon message="重置后请尽快通知租户管理员修改密码。" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import { UserOutlined } from '@ant-design/icons-vue'
|
||||
import { addTenant, pageTenant, removeTenant, updateTenant, updateTenantPassword } from '@/api/system/tenant'
|
||||
import type { Tenant } from '@/api/system/tenant/model'
|
||||
import { TEMPLATE_ID } from '@/config/setting'
|
||||
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string>('')
|
||||
|
||||
const list = ref<Tenant[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
|
||||
const keywords = ref('')
|
||||
const tenantCode = ref('')
|
||||
|
||||
const adminHeaders = { TenantId: TEMPLATE_ID }
|
||||
|
||||
async function loadTenants() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await pageTenant({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
keywords: keywords.value || undefined,
|
||||
tenantCode: tenantCode.value || undefined
|
||||
}, { headers: adminHeaders })
|
||||
list.value = res?.list ?? []
|
||||
total.value = res?.count ?? 0
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '租户列表加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await loadTenants()
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
page.value = 1
|
||||
loadTenants()
|
||||
}
|
||||
|
||||
function onPageChange(nextPage: number) {
|
||||
page.value = nextPage
|
||||
loadTenants()
|
||||
}
|
||||
|
||||
function onPageSizeChange(_current: number, nextSize: number) {
|
||||
limit.value = nextSize
|
||||
page.value = 1
|
||||
loadTenants()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTenants()
|
||||
})
|
||||
|
||||
const editOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const editFormRef = ref<FormInstance>()
|
||||
const editForm = reactive<Tenant>({
|
||||
tenantId: undefined,
|
||||
tenantName: '',
|
||||
companyName: '',
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
logo: '',
|
||||
comments: '',
|
||||
status: 0
|
||||
})
|
||||
const editTitle = computed(() => (editForm.tenantId ? '编辑租户' : '新增租户'))
|
||||
const editRules = reactive({
|
||||
tenantName: [{ required: true, type: 'string', message: '请输入租户名称' }]
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
editForm.tenantId = undefined
|
||||
editForm.tenantName = ''
|
||||
editForm.companyName = ''
|
||||
editForm.appId = ''
|
||||
editForm.appSecret = ''
|
||||
editForm.logo = ''
|
||||
editForm.comments = ''
|
||||
editForm.status = 0
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: Tenant) {
|
||||
editForm.tenantId = row.tenantId
|
||||
editForm.tenantName = row.tenantName ?? ''
|
||||
editForm.companyName = row.companyName ?? ''
|
||||
editForm.appId = row.appId ?? ''
|
||||
editForm.appSecret = row.appSecret ?? ''
|
||||
editForm.logo = row.logo ?? ''
|
||||
editForm.comments = row.comments ?? ''
|
||||
editForm.status = row.status ?? 0
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
try {
|
||||
await editFormRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: Tenant = {
|
||||
...editForm,
|
||||
tenantName: editForm.tenantName?.trim(),
|
||||
companyName: editForm.companyName?.trim() || undefined,
|
||||
appId: editForm.appId?.trim(),
|
||||
appSecret: editForm.appSecret?.trim() || undefined,
|
||||
logo: editForm.logo?.trim() || undefined,
|
||||
comments: editForm.comments?.trim() || undefined
|
||||
}
|
||||
if (payload.tenantId) {
|
||||
await updateTenant(payload, { headers: adminHeaders })
|
||||
message.success('租户已更新')
|
||||
} else {
|
||||
await addTenant(payload, { headers: adminHeaders })
|
||||
message.success('租户已创建')
|
||||
}
|
||||
editOpen.value = false
|
||||
await loadTenants()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const busyTenantId = ref<number | null>(null)
|
||||
async function remove(row: Tenant) {
|
||||
if (!row.tenantId) return
|
||||
busyTenantId.value = row.tenantId
|
||||
try {
|
||||
await removeTenant(row.tenantId, { headers: adminHeaders })
|
||||
message.success('已删除')
|
||||
if (list.value.length <= 1 && page.value > 1) page.value -= 1
|
||||
await loadTenants()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
} finally {
|
||||
busyTenantId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const resetOpen = ref(false)
|
||||
const resetting = ref(false)
|
||||
const resetPassword = ref('')
|
||||
const selectedTenant = ref<Tenant | null>(null)
|
||||
function openReset(row: Tenant) {
|
||||
selectedTenant.value = row
|
||||
resetPassword.value = ''
|
||||
resetOpen.value = true
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (!selectedTenant.value?.tenantId) return
|
||||
const pwd = resetPassword.value.trim()
|
||||
if (!pwd) {
|
||||
message.error('请输入新密码')
|
||||
return
|
||||
}
|
||||
resetting.value = true
|
||||
try {
|
||||
await updateTenantPassword(selectedTenant.value.tenantId, pwd, { headers: adminHeaders })
|
||||
message.success('密码已重置')
|
||||
resetOpen.value = false
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '重置失败')
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
40
app/pages/console/tenant/unopened.vue
Normal file
40
app/pages/console/tenant/unopened.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="管理中心" sub-title="产品开通、使用与续费" :ghost="false" class="page-header">
|
||||
<template #extra>
|
||||
<a-segmented
|
||||
:value="active"
|
||||
:options="[
|
||||
{ label: '已开通', value: 'index' },
|
||||
{ label: '未开通', value: 'unopened' }
|
||||
]"
|
||||
@update:value="onSwitch"
|
||||
/>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-empty description="待接入:未开通产品列表" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'console' })
|
||||
|
||||
const route = useRoute()
|
||||
const active = computed(() => (route.path.includes('/console/tenant/unopened') ? 'unopened' : ''))
|
||||
function onSwitch(value: string | number) {
|
||||
navigateTo(`/console/tenant/${String(value)}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
123
app/pages/contact.vue
Normal file
123
app/pages/contact.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<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">
|
||||
填写需求后我们将尽快联系你,为你规划产品套餐、交付开通链路与部署方案(SaaS/私有化)。
|
||||
</a-typography-paragraph>
|
||||
|
||||
<a-row :gutter="[24, 24]">
|
||||
<a-col :xs="24" :lg="14">
|
||||
<a-card title="需求表单">
|
||||
<a-form layout="vertical" :model="form" :rules="rules" @finish="onSubmit">
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="姓名" name="name">
|
||||
<a-input v-model:value="form.name" 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">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="公司/团队" name="company">
|
||||
<a-input v-model:value="form.company" placeholder="请填写公司或团队名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="交付方式" name="delivery">
|
||||
<a-select v-model:value="form.delivery" placeholder="请选择">
|
||||
<a-select-option value="saas">SaaS(云端)</a-select-option>
|
||||
<a-select-option value="private">私有化部署</a-select-option>
|
||||
<a-select-option value="hybrid">混合部署</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="需求描述" name="need">
|
||||
<a-textarea
|
||||
v-model:value="form.need"
|
||||
:rows="5"
|
||||
placeholder="例如:需要企业官网/电商/小程序;是否需要模板/插件市场;是否需要支付即开通等"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit">提交</a-button>
|
||||
<a-button @click="reset">重置</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="10">
|
||||
<a-card title="咨询内容建议">
|
||||
<a-list :data-source="tips" size="small">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>{{ item }}</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
<div class="mt-6">
|
||||
<a-alert
|
||||
show-icon
|
||||
type="info"
|
||||
message="如需更快响应,可在需求描述中留下可联系时间段。"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { message } from 'ant-design-vue'
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
|
||||
usePageSeo({
|
||||
title: '联系我们 - 预约演示 / 私有化部署 / 产品开通',
|
||||
description: '预约演示与咨询:SaaS 平台、私有化部署、模板/插件市场与支付即开通业务链路。',
|
||||
path: '/contact'
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
company: '',
|
||||
delivery: undefined as undefined | 'saas' | 'private' | 'hybrid',
|
||||
need: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请填写姓名' }],
|
||||
phone: [{ required: true, message: '请填写手机号' }],
|
||||
company: [{ required: true, message: '请填写公司/团队' }],
|
||||
need: [{ required: true, message: '请填写需求描述' }]
|
||||
}
|
||||
|
||||
const tips = [
|
||||
'你希望售卖哪些产品(官网/电商/小程序/门户等)?',
|
||||
'是否需要模板/插件市场(购买、授权、更新)?',
|
||||
'是否需要“支付即开通”(自动创建租户/初始化模块与数据)?',
|
||||
'交付方式:SaaS 或私有化部署?是否有合规要求?'
|
||||
]
|
||||
|
||||
function onSubmit() {
|
||||
message.success('已提交,我们会尽快联系你(当前为演示表单,可接入你的工单/线索接口)。')
|
||||
reset()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
form.name = ''
|
||||
form.phone = ''
|
||||
form.company = ''
|
||||
form.delivery = undefined
|
||||
form.need = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
595
app/pages/create-app.vue
Normal file
595
app/pages/create-app.vue
Normal 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>
|
||||
91
app/pages/deploy.vue
Normal file
91
app/pages/deploy.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<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">
|
||||
支持 SaaS、私有化与混合部署。针对安全合规/数据隔离/运维可控等需求,提供交付物清单与验收流程。
|
||||
</a-typography-paragraph>
|
||||
|
||||
<a-alert
|
||||
class="mb-6"
|
||||
type="info"
|
||||
show-icon
|
||||
message="支持私有化部署:提供部署文档、初始化脚本、升级/回滚建议与验收清单。"
|
||||
/>
|
||||
|
||||
<a-table :columns="columns" :data-source="rows" :pagination="false" row-key="key" />
|
||||
|
||||
<a-row class="mt-10" :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="私有化交付清单(示例)">
|
||||
<a-list :data-source="deliverables" size="small" bordered>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>{{ item }}</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="上线与升级策略">
|
||||
<a-collapse>
|
||||
<a-collapse-panel key="1" header="升级方式">
|
||||
<div class="text-gray-600">
|
||||
支持版本升级与兼容性说明;建议灰度升级并保留回滚方案。
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="2" header="数据安全">
|
||||
<div class="text-gray-600">
|
||||
提供租户隔离、权限审计、数据备份/恢复建议;可对接客户既有安全体系。
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="3" header="高可用建议">
|
||||
<div class="text-gray-600">
|
||||
提供多实例部署、负载均衡与健康检查建议;根据业务量规划资源与扩容策略。
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="mt-10">
|
||||
<a-space>
|
||||
<a-button @click="navigateTo('/flow')">查看开通流程</a-button>
|
||||
<a-button type="primary" @click="navigateTo('/contact')">获取部署方案</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
|
||||
usePageSeo({
|
||||
title: '部署方案 - SaaS / 私有化 / 混合部署',
|
||||
description: '支持 SaaS、私有化与混合部署,提供交付物清单与验收流程,满足安全合规与运维可控需求。',
|
||||
path: '/deploy'
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '对比项', dataIndex: 'name' },
|
||||
{ title: 'SaaS', dataIndex: 'saas' },
|
||||
{ title: '私有化', dataIndex: 'private' },
|
||||
{ title: '混合', dataIndex: 'hybrid' }
|
||||
]
|
||||
|
||||
const rows = [
|
||||
{ key: 'k1', name: '交付速度', saas: '最快', private: '中', hybrid: '中' },
|
||||
{ key: 'k2', name: '数据与合规', saas: '标准', private: '最高可控', hybrid: '可定制' },
|
||||
{ key: 'k3', name: '运维成本', saas: '最低', private: '客户自运维', hybrid: '可分担' },
|
||||
{ key: 'k4', name: '扩展能力', saas: '强', private: '强', hybrid: '强' },
|
||||
{ key: 'k5', name: '适用场景', saas: '快速试用/中小团队', private: '政企/强合规', hybrid: '集团/多系统' }
|
||||
]
|
||||
|
||||
const deliverables = [
|
||||
'部署包/镜像(示例)',
|
||||
'部署与运维文档(示例)',
|
||||
'初始化脚本与默认配置(示例)',
|
||||
'验收清单与检查项(示例)',
|
||||
'升级/回滚建议(示例)'
|
||||
]
|
||||
</script>
|
||||
|
||||
13
app/pages/developer/apps.vue
Normal file
13
app/pages/developer/apps.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<a-page-header title="应用中心" sub-title="订阅应用与交付入口" />
|
||||
<AppsCenter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppsCenter from '@/components/developer/AppsCenter.vue'
|
||||
|
||||
definePageMeta({ layout: 'developer' })
|
||||
</script>
|
||||
|
||||
34
app/pages/developer/git.vue
Normal file
34
app/pages/developer/git.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="Git 账号绑定" sub-title="用于开通 Gitea 仓库访问权限" />
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
message="待接入接口:保存你的 Gitea 用户名(或邮箱),用于运营加组。"
|
||||
/>
|
||||
|
||||
<a-form class="mt-4" layout="vertical">
|
||||
<a-form-item label="Gitea 用户名">
|
||||
<a-input placeholder="例如:lily" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注(可选)">
|
||||
<a-textarea :rows="3" placeholder="例如:公司/项目/联系方式" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" disabled>保存</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'developer' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
49
app/pages/developer/index.vue
Normal file
49
app/pages/developer/index.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="概览" sub-title="开发者能力与交付入口" />
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12" :lg="8">
|
||||
<a-card :bordered="false" class="card">
|
||||
<div class="text-gray-500">可开发应用</div>
|
||||
<div class="text-2xl font-semibold mt-1">-</div>
|
||||
<div class="text-gray-400 mt-2">订阅有效且已购买源码权益</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12" :lg="8">
|
||||
<a-card :bordered="false" class="card">
|
||||
<div class="text-gray-500">待处理申请</div>
|
||||
<div class="text-2xl font-semibold mt-1">-</div>
|
||||
<div class="text-gray-400 mt-2">Git 加组申请处理状态</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12" :lg="8">
|
||||
<a-card :bordered="false" class="card">
|
||||
<div class="text-gray-500">教程更新</div>
|
||||
<div class="text-2xl font-semibold mt-1">-</div>
|
||||
<div class="text-gray-400 mt-2">后续接入教程内容源</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card title="快捷入口" :bordered="false" class="card">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a-button type="primary" @click="navigateTo('/developer/apps')">应用中心</a-button>
|
||||
<a-button @click="navigateTo('/developer/source')">源码与仓库</a-button>
|
||||
<a-button @click="navigateTo('/developer/tutorial')">开发教程</a-button>
|
||||
<a-button @click="navigateTo('/developer/git')">Git 账号绑定</a-button>
|
||||
<a-button @click="navigateTo('/developer/requests')">权限申请记录</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'developer' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
20
app/pages/developer/requests.vue
Normal file
20
app/pages/developer/requests.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="权限申请记录" sub-title="Git 加组申请与处理状态" />
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-empty description="待接入:/developer/git-access-requests" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'developer' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
40
app/pages/developer/source.vue
Normal file
40
app/pages/developer/source.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="源码与仓库" sub-title="Gitea 仓库访问与源码交付" />
|
||||
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
message="当前版本为页面骨架:后续将接入“订阅有效 + 已购买源码权益”的可见性与申请流程。"
|
||||
/>
|
||||
|
||||
<a-card title="仓库访问" :bordered="false" class="card">
|
||||
<a-steps :current="1" size="small">
|
||||
<a-step title="绑定 Git 账号" />
|
||||
<a-step title="提交加组申请" />
|
||||
<a-step title="运营处理加组" />
|
||||
<a-step title="获得仓库访问权限" />
|
||||
</a-steps>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<a-button type="primary" @click="navigateTo('/developer/git')">去绑定 Git 账号</a-button>
|
||||
<a-button @click="navigateTo('/developer/requests')">查看申请记录</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="我的源码列表" :bordered="false" class="card">
|
||||
<a-empty description="待接入:按 templateId 映射仓库 + 权益校验" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'developer' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
24
app/pages/developer/support.vue
Normal file
24
app/pages/developer/support.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="支持与反馈" sub-title="开发相关问题、工单与联系渠道" />
|
||||
|
||||
<a-card :bordered="false" class="card">
|
||||
<a-descriptions bordered :column="1">
|
||||
<a-descriptions-item label="常见问题">待补充</a-descriptions-item>
|
||||
<a-descriptions-item label="工单/反馈">待接入</a-descriptions-item>
|
||||
<a-descriptions-item label="联系方式">待补充</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'developer' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
43
app/pages/developer/tutorial.vue
Normal file
43
app/pages/developer/tutorial.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="开发教程" sub-title="快速开始、二次开发与部署" />
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12" :lg="8">
|
||||
<a-card title="快速开始" :bordered="false" class="card">
|
||||
<p class="text-gray-600">拉代码、安装依赖、配置环境变量、本地运行。</p>
|
||||
<a-button type="link" class="px-0" disabled>查看</a-button>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12" :lg="8">
|
||||
<a-card title="二次开发" :bordered="false" class="card">
|
||||
<p class="text-gray-600">扩展点、模块/插件机制、权限/菜单接入规范。</p>
|
||||
<a-button type="link" class="px-0" disabled>查看</a-button>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12" :lg="8">
|
||||
<a-card title="部署上线" :bordered="false" class="card">
|
||||
<p class="text-gray-600">Docker/PM2、域名 HTTPS、升级回滚。</p>
|
||||
<a-button type="link" class="px-0" disabled>查看</a-button>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="教程内容源待接入:可先用 Markdown/远端 CMS/静态页面三选一实现。"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'developer' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
71
app/pages/flow.vue
Normal file
71
app/pages/flow.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<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-row :gutter="[24, 24]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="对外流程(客户视角)">
|
||||
<a-steps direction="vertical" :current="-1">
|
||||
<a-step title="选择产品/套餐" description="选择产品矩阵中的产品与套餐,按需加购模板/插件" />
|
||||
<a-step title="支付下单" description="支付成功后触发开通编排任务" />
|
||||
<a-step title="收到交付入口" description="获取管理员账号、访问地址与基础指引" />
|
||||
<a-step title="开始配置与运营" description="基于模板与默认配置快速上线,按需启用插件" />
|
||||
</a-steps>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="平台内部自动化(系统视角)">
|
||||
<a-timeline>
|
||||
<a-timeline-item>订单校验:产品/套餐/加购项与授权生成</a-timeline-item>
|
||||
<a-timeline-item>创建租户:租户信息、域名/应用信息、管理员生成</a-timeline-item>
|
||||
<a-timeline-item>模块装配:按所购产品组合模块并初始化菜单/权限</a-timeline-item>
|
||||
<a-timeline-item>数据初始化:基础字典、默认配置、可选演示数据</a-timeline-item>
|
||||
<a-timeline-item>交付通知:发送入口、账号、初始化结果与下一步指引</a-timeline-item>
|
||||
</a-timeline>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card class="mt-10" title="常见问题">
|
||||
<a-collapse>
|
||||
<a-collapse-panel key="1" header="如何支持私有化部署?">
|
||||
<div class="text-gray-600">
|
||||
私有化交付可提供部署包/镜像、部署文档、初始化脚本与验收清单;按客户环境对接域名、存储、日志与监控体系。
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="2" header="模板/插件购买后如何生效?">
|
||||
<div class="text-gray-600">
|
||||
支付成功后生成授权,并在租户侧自动装配:模板应用到站点与配置;插件完成安装/启用与默认配置写入。
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="3" header="初始化哪些数据?">
|
||||
<div class="text-gray-600">
|
||||
可按产品套餐选择:基础字典、默认配置、菜单与权限、可选演示数据/示例内容;便于“开通即验收”。
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-card>
|
||||
|
||||
<div class="mt-10">
|
||||
<a-space>
|
||||
<a-button @click="navigateTo('/products')">选择产品</a-button>
|
||||
<a-button type="primary" @click="navigateTo('/contact')">咨询开通与交付</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
|
||||
usePageSeo({
|
||||
title: '开通流程 - 选品支付 / 创建租户 / 初始化模块与数据',
|
||||
description:
|
||||
'客户选择产品并支付后,平台自动创建租户、装配模块、初始化菜单权限与基础数据,并完成交付上线;支持 SaaS 与私有化交付。',
|
||||
path: '/flow'
|
||||
})
|
||||
</script>
|
||||
|
||||
189
app/pages/index.vue
Normal file
189
app/pages/index.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="relative overflow-hidden bg-gradient-to-b from-gray-950 to-gray-900 text-white">
|
||||
<div class="pointer-events-none absolute left-1/2 top-[-140px] h-[420px] w-[900px] -translate-x-1/2 rounded-full bg-green-500/20 blur-3xl" />
|
||||
<div class="mx-auto max-w-screen-xl px-4 py-16 sm:py-24 relative">
|
||||
<a-tag color="orange" class="mb-6">v3.0 版本发布</a-tag>
|
||||
|
||||
<a-typography-title :level="1" class="!text-white !mb-4">
|
||||
软件开发平台:SaaS + 私有化 + 模板/插件生态
|
||||
</a-typography-title>
|
||||
<a-typography-paragraph class="!text-gray-300 !text-lg !mb-8">
|
||||
面向企业官网、电商、小程序等业务场景,提供多租户架构与模块化能力;客户下单后自动创建租户、初始化模块与基础数据,实现“支付即开通”。
|
||||
</a-typography-paragraph>
|
||||
|
||||
<a-space size="middle" wrap>
|
||||
<a-button type="primary" size="large" @click="navigateTo('/contact')">预约演示</a-button>
|
||||
<a-button size="large" @click="navigateTo('/flow')">查看开通流程</a-button>
|
||||
<a-button size="large" @click="navigateTo('/deploy')">私有化部署</a-button>
|
||||
</a-space>
|
||||
|
||||
<a-row class="mt-10" :gutter="[16, 16]">
|
||||
<a-col :xs="12" :md="6">
|
||||
<a-statistic title="平台定位" value="SaaS" />
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="6">
|
||||
<a-statistic title="交付模式" value="私有化" />
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="6">
|
||||
<a-statistic title="能力" value="模板/插件" />
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="6">
|
||||
<a-statistic title="开通" value="自动化" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-screen-xl px-4 py-14">
|
||||
<a-typography-title :level="2" class="!mb-2">核心能力</a-typography-title>
|
||||
<a-typography-paragraph class="!text-gray-500 !mb-8">
|
||||
用一套平台能力,覆盖产品售卖、交付开通、运营升级与生态变现。
|
||||
</a-typography-paragraph>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col v-for="item in capabilities" :key="item.title" :xs="24" :md="12" :lg="8">
|
||||
<a-card :title="item.title">
|
||||
<template #extra>
|
||||
<a-tag color="green">{{ item.badge }}</a-tag>
|
||||
</template>
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
{{ item.desc }}
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</section>
|
||||
|
||||
<section class="bg-gray-50">
|
||||
<div class="mx-auto max-w-screen-xl px-4 py-14">
|
||||
<a-row :gutter="[24, 24]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-typography-title :level="2" class="!mb-2">支付即开通</a-typography-title>
|
||||
<a-typography-paragraph class="!text-gray-600 !mb-6">
|
||||
客户选择产品并支付后,平台自动完成:创建租户、初始化模块、写入默认配置与基础数据、生成管理员账号并交付访问入口。
|
||||
</a-typography-paragraph>
|
||||
<a-button type="primary" @click="navigateTo('/flow')">查看全流程</a-button>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card>
|
||||
<a-steps direction="vertical" :current="-1" size="small">
|
||||
<a-step title="选择产品/套餐" description="支持产品矩阵、模板/插件加购、增值项" />
|
||||
<a-step title="下单支付" description="支付成功触发开通任务编排" />
|
||||
<a-step title="创建租户" description="租户隔离、域名/应用信息绑定、管理员生成" />
|
||||
<a-step title="模块初始化" description="按所购产品加载模块与菜单权限,写入基础数据/示例数据" />
|
||||
<a-step title="交付上线" description="SaaS 直接可用;私有化交付镜像/部署文档/验收清单" />
|
||||
</a-steps>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-screen-xl px-4 py-14">
|
||||
<a-typography-title :level="2" class="!mb-2">模板与插件生态</a-typography-title>
|
||||
<a-typography-paragraph class="!text-gray-500 !mb-8">
|
||||
通过模板加速交付,通过插件扩展能力;支持购买、授权、更新与版本管理。
|
||||
</a-typography-paragraph>
|
||||
|
||||
<a-tabs>
|
||||
<a-tab-pane key="template" tab="模板">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="行业模板">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
按行业/场景提供成套页面与配置,支持一键套用、二次编辑与多版本管理。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="交付标准化">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
模板与初始化脚本配套,让“开通后的默认站点”可直接验收。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="plugin" tab="插件">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="能力扩展">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
支付、会员、营销、工单、数据统计等能力按需加购,随买随用。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="升级与授权">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
支持版本升级、授权校验、到期续费与灰度发布。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<div class="mt-6">
|
||||
<a-button @click="navigateTo('/market')">了解模板/插件市场</a-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-gray-950 text-white">
|
||||
<div class="mx-auto max-w-screen-xl px-4 py-12 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-xl font-semibold">想快速搭建并交付一个可运营的产品?</div>
|
||||
<div class="mt-1 text-gray-300">预约演示,我们将按你的业务场景给出方案与报价。</div>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button size="large" @click="navigateTo('/products')">看产品矩阵</a-button>
|
||||
<a-button type="primary" size="large" @click="navigateTo('/contact')">马上联系</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
|
||||
usePageSeo({
|
||||
title: '软件开发平台 - SaaS / 私有化部署 / 模板&插件生态',
|
||||
description:
|
||||
'面向企业官网、电商、小程序等业务场景的 SaaS 开发平台,支持私有化部署与模板/插件购买;客户下单支付后自动创建租户并完成模块与数据初始化。'
|
||||
})
|
||||
|
||||
const capabilities = [
|
||||
{
|
||||
title: 'SaaS 多租户平台',
|
||||
badge: '核心',
|
||||
desc: '租户隔离、组织与权限体系、配置中心与审计能力,为多业务线统一底座。'
|
||||
},
|
||||
{
|
||||
title: '私有化部署',
|
||||
badge: '可选',
|
||||
desc: '支持本地/专有云部署,提供部署文档、验收清单与升级策略,满足安全合规。'
|
||||
},
|
||||
{
|
||||
title: '模板市场',
|
||||
badge: '生态',
|
||||
desc: '行业模板一键套用,默认配置与初始化脚本配套,交付更标准、上线更快。'
|
||||
},
|
||||
{
|
||||
title: '插件市场',
|
||||
badge: '扩展',
|
||||
desc: '支付、会员、营销、工单等能力按需加购;支持授权、更新与版本管理。'
|
||||
},
|
||||
{
|
||||
title: '自动开通链路',
|
||||
badge: '交付',
|
||||
desc: '选品支付后自动创建租户、初始化模块/菜单/基础数据,并交付访问入口。'
|
||||
},
|
||||
{
|
||||
title: '模块化与可扩展',
|
||||
badge: '开发',
|
||||
desc: '支持按模块组合产品能力,插件化扩展点让二开与生态合作更高效。'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
516
app/pages/login.vue
Normal file
516
app/pages/login.vue
Normal file
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<div class="login-page" :style="bgStyle">
|
||||
<div class="overlay" />
|
||||
|
||||
<div v-if="config?.siteName" class="brand">
|
||||
<img :src="config.sysLogo || defaultLogo" class="brand-logo" alt="logo" />
|
||||
<h1 class="brand-name">{{ config.siteName }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="config?.loginTitle" class="brand-title">{{ config.loginTitle }}</div>
|
||||
|
||||
<a-form ref="formRef" :model="form" :rules="rules" class="card">
|
||||
<div class="card-header">
|
||||
<template v-if="loginType === 'scan'">
|
||||
<h2 class="card-title">扫码登录</h2>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2 class="tab" :class="{ active: loginType === 'sms' }" @click="setLoginType('sms')">
|
||||
手机号登录
|
||||
</h2>
|
||||
<a-divider type="vertical" style="height: 20px" />
|
||||
<h2
|
||||
class="tab"
|
||||
:class="{ active: loginType === 'account' }"
|
||||
@click="setLoginType('account')"
|
||||
>
|
||||
账号登录
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<a-button class="switch" type="text" @click="toggleScan" :title="loginType === 'scan' ? '切换到手机号登录' : '切换到扫码登录'">
|
||||
<QrcodeOutlined v-if="loginType !== 'scan'" />
|
||||
<MobileOutlined v-else />
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<template v-if="loginType === 'account'">
|
||||
<a-form-item name="username">
|
||||
<a-input v-model:value="form.username" size="large" allow-clear placeholder="账号 / 用户ID">
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password">
|
||||
<a-input-password
|
||||
v-model:value="form.password"
|
||||
size="large"
|
||||
placeholder="登录密码"
|
||||
@press-enter="submitAccount"
|
||||
>
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="code">
|
||||
<div class="input-group">
|
||||
<a-input
|
||||
v-model:value="form.code"
|
||||
size="large"
|
||||
allow-clear
|
||||
:maxlength="5"
|
||||
placeholder="验证码"
|
||||
@press-enter="submitAccount"
|
||||
>
|
||||
<template #prefix><SafetyCertificateOutlined /></template>
|
||||
</a-input>
|
||||
<a-button class="captcha-btn" @click="changeCaptcha">
|
||||
<img v-if="captcha" :src="captcha" alt="captcha" />
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div class="row">
|
||||
<a-checkbox v-model:checked="form.remember">记住登录</a-checkbox>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button block size="large" type="primary" :loading="loading" @click="submitAccount">
|
||||
{{ loading ? '登录中…' : '登录' }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="loginType === 'sms'">
|
||||
<a-form-item name="phone">
|
||||
<a-input v-model:value="form.phone" size="large" allow-clear :maxlength="11" placeholder="请输入手机号码">
|
||||
<template #addonBefore>+86</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="smsCode">
|
||||
<div class="input-group">
|
||||
<a-input
|
||||
v-model:value="form.smsCode"
|
||||
size="large"
|
||||
allow-clear
|
||||
:maxlength="6"
|
||||
placeholder="请输入验证码"
|
||||
@press-enter="submitSms"
|
||||
/>
|
||||
<a-button class="captcha-btn" :disabled="countdown > 0" @click="openImgCodeModal">
|
||||
<span v-if="countdown <= 0">发送验证码</span>
|
||||
<span v-else>已发送 {{ countdown }} s</span>
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button block size="large" type="primary" :loading="loading" @click="submitSms">
|
||||
{{ loading ? '登录中…' : '登录' }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<QrLogin @login-success="onQrLoginSuccess" @login-error="onQrLoginError" />
|
||||
</template>
|
||||
</a-form>
|
||||
|
||||
<div class="copyright">
|
||||
<span>© {{ new Date().getFullYear() }}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{{ config?.copyright || 'websoft.top Inc.' }}</span>
|
||||
</div>
|
||||
|
||||
<a-modal v-model:open="imgCodeModalOpen" :width="340" :footer="null" title="发送验证码">
|
||||
<div class="input-group modal-row">
|
||||
<a-input
|
||||
v-model:value="imgCode"
|
||||
size="large"
|
||||
allow-clear
|
||||
:maxlength="5"
|
||||
placeholder="请输入图形验证码"
|
||||
@press-enter="sendSmsCode"
|
||||
/>
|
||||
<a-button class="captcha-btn">
|
||||
<img alt="captcha" :src="captcha" @click="changeCaptcha" />
|
||||
</a-button>
|
||||
</div>
|
||||
<a-button block size="large" type="primary" :loading="sendingSms" @click="sendSmsCode">
|
||||
立即发送
|
||||
</a-button>
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:open="selectUserOpen" :width="520" :footer="null" title="选择账号登录">
|
||||
<a-list item-layout="horizontal" :data-source="admins">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item class="list-item" @click="selectUser(item)">
|
||||
<a-list-item-meta :description="`租户ID: ${item.tenantId}`">
|
||||
<template #title>{{ item.tenantName || item.username }}</template>
|
||||
<template #avatar>
|
||||
<a-avatar :src="item.avatar" />
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions><RightOutlined /></template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import {
|
||||
LockOutlined,
|
||||
MobileOutlined,
|
||||
QrcodeOutlined,
|
||||
RightOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
UserOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import QrLogin from '@/components/QrLogin.vue'
|
||||
import { configWebsiteField, type Config } from '@/api/cms/cmsWebsiteField'
|
||||
import { getCaptcha, login, loginBySms, sendSmsCaptcha } from '@/api/passport/login'
|
||||
import type { LoginParam } from '@/api/passport/login/model'
|
||||
import { listAdminsByPhoneAll } from '@/api/system/user'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
import { TEMPLATE_ID } from '@/config/setting'
|
||||
import { setToken } from '@/utils/token-util'
|
||||
import type { QrCodeStatusResponse } from '@/api/passport/qrLogin'
|
||||
|
||||
definePageMeta({ layout: 'blank' })
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const defaultLogo = 'https://oss.wsdns.cn/20240822/0252ad4ed46449cdafe12f8d3d96c2ea.svg'
|
||||
const config = ref<Config>()
|
||||
const loading = ref(false)
|
||||
const loginType = ref<'scan' | 'sms' | 'account'>('scan')
|
||||
|
||||
const captcha = ref('')
|
||||
const captchaText = ref('')
|
||||
|
||||
const imgCodeModalOpen = ref(false)
|
||||
const imgCode = ref('')
|
||||
const sendingSms = ref(false)
|
||||
|
||||
const countdown = ref(0)
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const selectUserOpen = ref(false)
|
||||
const admins = ref<User[]>([])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<LoginParam & { smsCode?: string }>({
|
||||
username: '',
|
||||
password: '',
|
||||
phone: '',
|
||||
code: '',
|
||||
smsCode: '',
|
||||
remember: true
|
||||
})
|
||||
|
||||
const phoneReg = /^1[3-9]\d{9}$/
|
||||
const rules = reactive({
|
||||
username: [{ required: true, message: '请输入账号', type: 'string' }],
|
||||
password: [{ required: true, message: '请输入密码', type: 'string' }],
|
||||
code: [{ required: true, message: '请输入验证码', type: 'string' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号码', type: 'string' },
|
||||
{ pattern: phoneReg, message: '手机号格式不正确', trigger: 'blur' }
|
||||
],
|
||||
smsCode: [{ required: true, message: '请输入短信验证码', type: 'string' }]
|
||||
})
|
||||
|
||||
const bgStyle = computed(() => {
|
||||
const bg = config.value?.loginBgImg
|
||||
if (!bg) return {}
|
||||
return { backgroundImage: `url(${bg})` }
|
||||
})
|
||||
|
||||
function setLoginType(type: 'scan' | 'sms' | 'account') {
|
||||
loginType.value = type
|
||||
}
|
||||
|
||||
function toggleScan() {
|
||||
loginType.value = loginType.value === 'scan' ? 'sms' : 'scan'
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
countdown.value = 0
|
||||
}
|
||||
|
||||
async function changeCaptcha() {
|
||||
try {
|
||||
const data = await getCaptcha()
|
||||
captcha.value = data.base64
|
||||
captchaText.value = data.text
|
||||
} catch (e: unknown) {
|
||||
message.error(e instanceof Error ? e.message : '获取验证码失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openImgCodeModal() {
|
||||
if (!form.phone) return message.error('请输入手机号码')
|
||||
imgCode.value = ''
|
||||
changeCaptcha()
|
||||
imgCodeModalOpen.value = true
|
||||
}
|
||||
|
||||
async function sendSmsCode() {
|
||||
if (!imgCode.value) return message.error('请输入图形验证码')
|
||||
if (captchaText.value && imgCode.value.toLowerCase() !== captchaText.value.toLowerCase()) {
|
||||
return message.error('图形验证码不正确')
|
||||
}
|
||||
|
||||
sendingSms.value = true
|
||||
try {
|
||||
await sendSmsCaptcha({ phone: form.phone })
|
||||
message.success('短信验证码发送成功,请注意查收')
|
||||
imgCodeModalOpen.value = false
|
||||
countdown.value = 30
|
||||
stopCountdown()
|
||||
countdown.value = 30
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value -= 1
|
||||
if (countdown.value <= 0) stopCountdown()
|
||||
}, 1000)
|
||||
} catch (e: unknown) {
|
||||
message.error(e instanceof Error ? e.message : '发送失败')
|
||||
} finally {
|
||||
sendingSms.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function goAfterLogin() {
|
||||
const from = typeof route.query.from === 'string' ? route.query.from : ''
|
||||
await navigateTo(from || '/')
|
||||
}
|
||||
|
||||
async function submitAccount() {
|
||||
if (!formRef.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
const msg = await login({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
code: String(form.code || '').toLowerCase(),
|
||||
remember: !!form.remember
|
||||
})
|
||||
message.success(msg || '登录成功')
|
||||
await goAfterLogin()
|
||||
} catch (e: unknown) {
|
||||
message.error(e instanceof Error ? e.message : '登录失败')
|
||||
changeCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSms() {
|
||||
if (!formRef.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
const msg = await loginBySms({
|
||||
phone: form.phone,
|
||||
code: String(form.smsCode || '').toLowerCase(),
|
||||
tenantId: form.tenantId,
|
||||
remember: !!form.remember
|
||||
})
|
||||
|
||||
if (msg === '请选择登录用户') {
|
||||
selectUserOpen.value = true
|
||||
admins.value = await listAdminsByPhoneAll({
|
||||
phone: form.phone,
|
||||
templateId: Number(TEMPLATE_ID)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
message.success(msg || '登录成功')
|
||||
await goAfterLogin()
|
||||
} catch (e: unknown) {
|
||||
message.error(e instanceof Error ? e.message : '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectUser(user: User) {
|
||||
form.tenantId = user.tenantId
|
||||
selectUserOpen.value = false
|
||||
await submitSms()
|
||||
}
|
||||
|
||||
function onQrLoginSuccess(payload: QrCodeStatusResponse) {
|
||||
const accessToken = payload.accessToken || payload.access_token
|
||||
if (accessToken) setToken(String(accessToken), true)
|
||||
if (payload.tenantId && import.meta.client) localStorage.setItem('TenantId', String(payload.tenantId))
|
||||
if (import.meta.client && typeof payload.userInfo === 'object' && payload.userInfo && 'userId' in payload.userInfo) {
|
||||
const userId = (payload.userInfo as { userId?: unknown }).userId
|
||||
if (userId !== undefined && userId !== null) localStorage.setItem('UserId', String(userId))
|
||||
}
|
||||
message.success('扫码登录成功')
|
||||
goAfterLogin()
|
||||
}
|
||||
|
||||
function onQrLoginError(error: string) {
|
||||
message.error(error || '扫码登录失败')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
config.value = await configWebsiteField({ lang: 'zh-CN' })
|
||||
} catch {
|
||||
// ignore config errors
|
||||
}
|
||||
changeCaptcha()
|
||||
|
||||
if (typeof route.query.loginPhone === 'string') {
|
||||
form.phone = route.query.loginPhone
|
||||
loginType.value = 'sms'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopCountdown()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: #111827;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.brand {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 2;
|
||||
}
|
||||
.brand-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.brand-name {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.brand-title {
|
||||
position: absolute;
|
||||
top: 16%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.card {
|
||||
width: 390px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
padding: 0 28px 22px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 35px rgba(0, 0, 0, 0.25);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
gap: 12px;
|
||||
padding: 18px 0 6px;
|
||||
}
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: #374151;
|
||||
}
|
||||
.tab.active {
|
||||
color: #1677ff;
|
||||
}
|
||||
.switch {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 14px;
|
||||
}
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.captcha-btn {
|
||||
width: 140px;
|
||||
height: 40px;
|
||||
margin-left: 10px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.captcha-btn img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.copyright {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
margin-top: 28px;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 12px;
|
||||
}
|
||||
.sep {
|
||||
margin: 0 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.modal-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.list-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
87
app/pages/market.vue
Normal file
87
app/pages/market.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<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-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="模板:标准化交付">
|
||||
<a-list :data-source="templates" size="small">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>{{ item }}</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="插件:按需扩展与变现">
|
||||
<a-list :data-source="plugins" size="small">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>{{ item }}</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row class="mt-10" :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="购买与授权流程">
|
||||
<a-steps direction="vertical" :current="-1" size="small">
|
||||
<a-step title="选择模板/插件" description="支持套餐内置 + 加购" />
|
||||
<a-step title="下单支付" description="支付成功生成授权与交付记录" />
|
||||
<a-step title="自动启用" description="为租户装配模板/插件与默认配置" />
|
||||
<a-step title="更新升级" description="支持版本更新、到期续费与兼容性提示" />
|
||||
</a-steps>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="生态合作(示例)">
|
||||
<a-collapse>
|
||||
<a-collapse-panel key="1" header="插件开发">
|
||||
<div class="text-gray-600">提供扩展点与规范,支持第三方开发并上架。</div>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="2" header="模板共建">
|
||||
<div class="text-gray-600">按行业/场景沉淀模板资产,形成标准化交付能力。</div>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="3" header="授权与结算">
|
||||
<div class="text-gray-600">支持授权校验、版本管理与结算规则扩展。</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="mt-10">
|
||||
<a-space>
|
||||
<a-button @click="navigateTo('/products')">看产品矩阵</a-button>
|
||||
<a-button type="primary" @click="navigateTo('/contact')">咨询合作/上架</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
|
||||
usePageSeo({
|
||||
title: '模板/插件市场 - 购买/授权/更新/生态合作',
|
||||
description: '模板加速交付,插件按需扩展;支持购买、授权、更新与版本管理,构建可持续生态与增值体系。',
|
||||
path: '/market'
|
||||
})
|
||||
|
||||
const templates = [
|
||||
'行业模板:官网/电商/门户/活动页等',
|
||||
'一键套用:默认页面 + 配置 + 初始化脚本',
|
||||
'多版本管理:预览、回滚、迁移与二次编辑'
|
||||
]
|
||||
|
||||
const plugins = [
|
||||
'能力插件:支付/会员/营销/工单/统计等',
|
||||
'授权与更新:版本升级、到期续费、兼容性提示',
|
||||
'生态变现:第三方能力沉淀为可售卖插件'
|
||||
]
|
||||
</script>
|
||||
|
||||
13
app/pages/mp/index.vue
Normal file
13
app/pages/mp/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="小程序开发" :ghost="false" class="page-header" />
|
||||
<a-card :bordered="false">
|
||||
<a-result status="info" title="模块已创建" sub-title="mp 模块页面占位,后续在此接入功能。" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'mp' })
|
||||
</script>
|
||||
|
||||
13
app/pages/oa/index.vue
Normal file
13
app/pages/oa/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="办公协同OA" :ghost="false" class="page-header" />
|
||||
<a-card :bordered="false">
|
||||
<a-result status="info" title="模块已创建" sub-title="oa 模块页面占位,后续在此接入功能。" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'oa' })
|
||||
</script>
|
||||
|
||||
115
app/pages/platform.vue
Normal file
115
app/pages/platform.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<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">
|
||||
平台定位为 SaaS 软件开发平台:支持私有化部署,提供模板/插件生态,并将“下单支付→自动开通→初始化交付”标准化。
|
||||
</a-typography-paragraph>
|
||||
|
||||
<a-tabs>
|
||||
<a-tab-pane key="tenant" tab="多租户与权限">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="租户隔离">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
租户数据隔离、域名/应用信息绑定、独立配置与配额策略,适配多业务线与多客户运营。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="权限体系">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
角色/菜单/按钮权限,审计与操作日志;支持模块级别的权限装配与初始化。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="modules" tab="模块化与扩展">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="模块组合产品">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
将能力拆分为可组合的模块:内容、商品、订单、会员、营销、表单等,可按产品套餐选择并初始化。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="扩展点与二开">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
支持插件化扩展点,便于第三方能力集成与二次开发;可沉淀为生态插件进行复用与变现。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="market" tab="模板/插件生态">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="模板市场">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
行业模板 + 初始化脚本配套,让交付标准化;支持多版本、预览、回滚与迁移。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="插件市场">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
插件按需购买启用,支持授权校验、更新与兼容性管理;可用于增值服务与生态合作。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div class="mt-6">
|
||||
<a-button @click="navigateTo('/market')">了解模板/插件市场</a-button>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="ops" tab="运维与安全">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="可观测与运维">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
支持日志/告警/指标等可观测能力接入;提供升级策略、灰度与回滚建议。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="私有化与合规">
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
支持专有云/本地部署与数据安全策略;可按客户要求提供交付物清单与验收流程。
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<a-card class="mt-10" :bordered="false" style="background: #f6ffed">
|
||||
<a-row :gutter="[16, 16]" align="middle">
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-typography-title :level="3" class="!mb-1">想把你的业务做成可售卖的产品?</a-typography-title>
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
我们可以基于平台能力帮你规划产品套餐、开通链路与交付标准。
|
||||
</a-typography-paragraph>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8" class="flex md:justify-end">
|
||||
<a-button type="primary" size="large" @click="navigateTo('/contact')">马上联系</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
|
||||
usePageSeo({
|
||||
title: '平台能力 - SaaS 多租户 / 模块化 / 模板&插件生态',
|
||||
description:
|
||||
'平台定位为 SaaS 软件开发平台,支持私有化部署与模板/插件生态,标准化“下单支付→自动开通→初始化交付”全链路。',
|
||||
path: '/platform'
|
||||
})
|
||||
</script>
|
||||
|
||||
91
app/pages/products/index.vue
Normal file
91
app/pages/products/index.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<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-row :gutter="[16, 16]">
|
||||
<a-col v-for="p in products" :key="p.title" :xs="24" :md="12" :lg="8">
|
||||
<a-card :title="p.title">
|
||||
<template #extra>
|
||||
<a-tag v-if="p.recommend" color="green">推荐</a-tag>
|
||||
</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">
|
||||
<a-button type="link" @click="navigateTo('/contact')">咨询开通</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card class="mt-10" :bordered="false" style="background: #f0f5ff">
|
||||
<a-row :gutter="[16, 16]" align="middle">
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-typography-title :level="3" class="!mb-1">需要上架你的专属产品?</a-typography-title>
|
||||
<a-typography-paragraph class="!mb-0 !text-gray-600">
|
||||
我们可基于平台能力为你定制产品组合、套餐定价、开通初始化与交付流程。
|
||||
</a-typography-paragraph>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8" class="flex md:justify-end">
|
||||
<a-button type="primary" size="large" @click="navigateTo('/contact')">马上联系</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
|
||||
usePageSeo({
|
||||
title: '产品矩阵 - 套餐售卖 / 支付即开通 / 模板&插件加购',
|
||||
description: '企业官网、电商、小程序等产品矩阵,支持套餐化售卖、支付即开通、模板/插件加购与私有化交付。',
|
||||
path: '/products'
|
||||
})
|
||||
|
||||
const products = [
|
||||
{
|
||||
title: '企业官网',
|
||||
recommend: true,
|
||||
desc: '品牌展示与获客转化,支持多模板、多语言、SEO 与可视化配置。',
|
||||
tags: ['模板', 'SEO', '多语言', '私有化']
|
||||
},
|
||||
{
|
||||
title: '电商系统',
|
||||
recommend: true,
|
||||
desc: '商品/订单/支付/营销基础能力,插件化扩展,支持多端触达。',
|
||||
tags: ['支付', '插件', '营销', '多租户']
|
||||
},
|
||||
{
|
||||
title: '小程序/公众号',
|
||||
recommend: false,
|
||||
desc: '多端渠道接入与统一管理,适配常见内容与电商场景。',
|
||||
tags: ['多端', '渠道', '可扩展']
|
||||
},
|
||||
{
|
||||
title: '行业门户',
|
||||
recommend: false,
|
||||
desc: '内容栏目体系、推荐与搜索能力,适配资讯/政企/协会等门户需求。',
|
||||
tags: ['内容管理', '栏目', '审计']
|
||||
},
|
||||
{
|
||||
title: '管理后台',
|
||||
recommend: false,
|
||||
desc: '多租户管理、角色权限、组织架构与可扩展菜单体系。',
|
||||
tags: ['权限', '多租户', '审计']
|
||||
},
|
||||
{
|
||||
title: '模板/插件市场',
|
||||
recommend: false,
|
||||
desc: '支持模板与插件购买、授权与更新,形成生态与增值体系。',
|
||||
tags: ['市场', '授权', '更新', '变现']
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
86
app/pages/profile.vue
Normal file
86
app/pages/profile.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-screen-md px-4 py-8">
|
||||
<a-card title="个人资料" :bordered="false">
|
||||
<div class="flex items-center gap-4">
|
||||
<a-avatar :size="64" :src="avatarUrl">
|
||||
<template v-if="!avatarUrl" #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<div class="min-w-0">
|
||||
<div class="text-lg font-semibold text-gray-900">
|
||||
{{ user?.nickname || user?.username || '未命名用户' }}
|
||||
</div>
|
||||
<div class="text-gray-500">
|
||||
{{ user?.phone || (user as any)?.mobile || '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="用户ID">{{ user?.userId }}</a-descriptions-item>
|
||||
<a-descriptions-item label="账号">{{ user?.username }}</a-descriptions-item>
|
||||
<a-descriptions-item label="昵称">{{ user?.nickname }}</a-descriptions-item>
|
||||
<a-descriptions-item label="手机号">{{ user?.phone || (user as any)?.mobile }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户ID">{{ tenantId }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<a-button @click="navigateTo('/')">返回首页</a-button>
|
||||
<a-button danger type="primary" @click="logout">退出登录</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined } from '@ant-design/icons-vue'
|
||||
import { getUserInfo } from '@/api/layout'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
import { getTenantId } from '@/utils/domain'
|
||||
import { getToken, removeToken } from '@/utils/token-util'
|
||||
|
||||
const user = ref<User | null>(null)
|
||||
const tenantId = computed(() => getTenantId())
|
||||
const avatarUrl = computed(() => {
|
||||
const candidate =
|
||||
user.value?.avatarUrl ||
|
||||
user.value?.avatar ||
|
||||
user.value?.merchantAvatar ||
|
||||
user.value?.logo ||
|
||||
''
|
||||
if (typeof candidate !== 'string') return ''
|
||||
const normalized = candidate.trim()
|
||||
if (!normalized || normalized === 'null' || normalized === 'undefined') return ''
|
||||
return normalized
|
||||
})
|
||||
|
||||
function logout() {
|
||||
removeToken()
|
||||
try {
|
||||
localStorage.removeItem('TenantId')
|
||||
localStorage.removeItem('UserId')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!getToken()) {
|
||||
message.error('请先登录')
|
||||
await navigateTo('/login?from=/profile')
|
||||
return
|
||||
}
|
||||
try {
|
||||
user.value = await getUserInfo()
|
||||
} catch (e: unknown) {
|
||||
console.error(e)
|
||||
message.error('获取用户信息失败')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
252
app/pages/qr-confirm.vue
Normal file
252
app/pages/qr-confirm.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<a-card :bordered="false" class="card">
|
||||
<div class="header">
|
||||
<div class="app">
|
||||
<img :src="appLogo" class="logo" alt="logo" />
|
||||
<h3 class="name">{{ appName }}</h3>
|
||||
</div>
|
||||
<p class="tip">确认登录到 Web 端?</p>
|
||||
</div>
|
||||
|
||||
<div v-if="userInfo" class="user">
|
||||
<a-avatar :size="64" :src="userInfo.avatar">
|
||||
<template v-if="!userInfo.avatar" #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<div class="user-text">
|
||||
<h4 class="username">{{ userInfo.nickname || userInfo.username }}</h4>
|
||||
<p class="phone">{{ userInfo.phone || userInfo.mobile }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="device">
|
||||
<div class="row"><span class="label">登录设备:</span><span class="value">{{ deviceInfo.browser }} {{ deviceInfo.version }}</span></div>
|
||||
<div class="row"><span class="label">操作系统:</span><span class="value">{{ deviceInfo.os }}</span></div>
|
||||
<div class="row"><span class="label">IP 地址:</span><span class="value">{{ deviceInfo.ip }}</span></div>
|
||||
<div class="row"><span class="label">登录时间:</span><span class="value">{{ formatTime(new Date()) }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a-button size="large" class="cancel" :loading="cancelLoading" @click="handleCancel">取消登录</a-button>
|
||||
<a-button type="primary" size="large" class="confirm" :loading="confirmLoading" @click="handleConfirm">确认登录</a-button>
|
||||
</div>
|
||||
|
||||
<div class="security">
|
||||
<ExclamationCircleOutlined class="warn" />
|
||||
<span>请确认是您本人操作,如非本人操作请点击“取消登录”</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ExclamationCircleOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||
import { confirmQrLogin, scanQrCode, type QrLoginConfirmRequest } from '@/api/passport/qrLogin'
|
||||
import { getUserInfo } from '@/api/layout'
|
||||
import { getToken } from '@/utils/token-util'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
|
||||
definePageMeta({ layout: 'blank' })
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const qrCodeKey = computed(() => String(route.query.qrCodeKey || ''))
|
||||
const userInfo = ref<User | null>(null)
|
||||
const confirmLoading = ref(false)
|
||||
const cancelLoading = ref(false)
|
||||
|
||||
const appName = ref('网宿软件')
|
||||
const appLogo = ref('/favicon.ico')
|
||||
|
||||
const deviceInfo = ref({
|
||||
browser: 'Mobile',
|
||||
version: '',
|
||||
os: 'Unknown',
|
||||
ip: '-'
|
||||
})
|
||||
|
||||
function formatTime(date: Date) {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchUser() {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
message.error('请先登录')
|
||||
await navigateTo('/login')
|
||||
return false
|
||||
}
|
||||
try {
|
||||
userInfo.value = await getUserInfo()
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
console.error(error)
|
||||
message.error('获取用户信息失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function markScanned() {
|
||||
if (!qrCodeKey.value) return
|
||||
try {
|
||||
await scanQrCode(qrCodeKey.value)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!qrCodeKey.value) return message.error('二维码参数错误')
|
||||
confirmLoading.value = true
|
||||
try {
|
||||
if (!userInfo.value?.userId) return message.error('用户信息获取失败')
|
||||
const requestData: QrLoginConfirmRequest = {
|
||||
token: qrCodeKey.value,
|
||||
userId: Number(userInfo.value.userId),
|
||||
platform: 'web'
|
||||
}
|
||||
await confirmQrLogin(requestData)
|
||||
message.success('登录确认成功')
|
||||
setTimeout(() => backOrHome(), 1200)
|
||||
} catch (e: unknown) {
|
||||
message.error(e instanceof Error ? e.message : '确认登录失败')
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backOrHome() {
|
||||
if (import.meta.client && window.history.length > 1) {
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
cancelLoading.value = true
|
||||
try {
|
||||
message.info('已取消登录')
|
||||
setTimeout(() => backOrHome(), 800)
|
||||
} finally {
|
||||
cancelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!qrCodeKey.value) {
|
||||
message.error('二维码参数错误')
|
||||
await navigateTo('/login')
|
||||
return
|
||||
}
|
||||
const ok = await fetchUser()
|
||||
if (!ok) return
|
||||
await markScanned()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.card {
|
||||
width: 480px;
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
.app {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.name {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
.tip {
|
||||
margin: 10px 0 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 4px;
|
||||
}
|
||||
.user-text {
|
||||
flex: 1;
|
||||
}
|
||||
.username {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.phone {
|
||||
margin: 4px 0 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
.device {
|
||||
padding: 12px 4px 4px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
.label {
|
||||
color: #6b7280;
|
||||
}
|
||||
.value {
|
||||
color: #111827;
|
||||
font-weight: 500;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 4px 6px;
|
||||
}
|
||||
.cancel,
|
||||
.confirm {
|
||||
flex: 1;
|
||||
}
|
||||
.security {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 4px 2px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
.warn {
|
||||
color: #faad14;
|
||||
}
|
||||
</style>
|
||||
13
app/pages/site/index.vue
Normal file
13
app/pages/site/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<a-page-header title="云·企业官网" :ghost="false" class="page-header" />
|
||||
<a-card :bordered="false">
|
||||
<a-result status="info" title="模块已创建" sub-title="site 模块页面占位,后续在此接入功能。" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'site' })
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user