feat(router): 更新路由结构并优化页面组件

- 移除经营范围按钮,精简导航栏
- 实现文章标题链接功能,提升用户体验
- 添加商品详情页面包屑导航,支持分类跳转
- 引入配送管理相关页面(区域、接单台、配送员、派单)
- 替换控制台布局为站点头部和底部组件
- 重构商品分类页面,集成CMS导航功能
- 新增文章详情页面,支持多种访问方式
- 删除已迁移的创建应用和空应用页面
- 优化样式和组件导入,提升代码质量
This commit is contained in:
2026-01-29 16:21:22 +08:00
parent 26c236041f
commit 682e264a6f
22 changed files with 1309 additions and 881 deletions

View File

@@ -132,7 +132,7 @@ export async function getByCode(code: string) {
} }
export async function getCount(params: CmsArticleParam) { export async function getCount(params: CmsArticleParam) {
const res = await request.get('/cms/cms-article/data', { const res = await request.get<ApiResult<unknown>>('/cms/cms-article/data', {
params params
}); });
if (res.data.code === 0) { if (res.data.code === 0) {

View File

@@ -8,8 +8,9 @@
<div class="topbar-right"> <div class="topbar-right">
<div class="hidden md:flex items-center gap-2"> <div class="hidden md:flex items-center gap-2">
<a-button size="small" @click="navigateTo('/products')">经营范围</a-button> <a-button size="small" @click="navigateTo('/join')">招商加盟</a-button>
<a-button size="small" type="primary" @click="navigateTo('/contact')">联系我们</a-button> <a-button v-if="isLoggedIn" size="small" type="primary" @click="navigateTo('/console')">用户中心</a-button>
<a-button v-else size="small" type="primary" @click="navigateTo('/login')">会员登录</a-button>
</div> </div>
<a-button class="md:hidden" size="small" @click="open = true">菜单</a-button> <a-button class="md:hidden" size="small" @click="open = true">菜单</a-button>
@@ -107,6 +108,7 @@
<a-space direction="vertical" class="w-full"> <a-space direction="vertical" class="w-full">
<a-button type="primary" block @click="onNav('/contact')">联系我们</a-button> <a-button type="primary" block @click="onNav('/contact')">联系我们</a-button>
<a-button block @click="onNav('/products')">经营范围</a-button> <a-button block @click="onNav('/products')">经营范围</a-button>
<a-button block @click="onNav('/join')">招商加盟</a-button>
</a-space> </a-space>
</div> </div>
</a-drawer> </a-drawer>
@@ -116,10 +118,16 @@
import { mainNav } from '@/config/nav' import { mainNav } from '@/config/nav'
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model' import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
import { COMPANY } from '@/config/company' import { COMPANY } from '@/config/company'
import { getToken } from '@/utils/token-util'
const route = useRoute() const route = useRoute()
const open = ref(false) const open = ref(false)
const isAffixed = ref(false) const isAffixed = ref(false)
const isHydrated = ref(false)
const token = ref('')
const TOKEN_EVENT = 'auth-token-changed'
const isLoggedIn = computed(() => isHydrated.value && !!token.value)
type HeaderNavItem = { type HeaderNavItem = {
key: string key: string
@@ -286,6 +294,20 @@ const todayText = computed(() => {
function onAffixChange(affixed: boolean) { function onAffixChange(affixed: boolean) {
isAffixed.value = affixed isAffixed.value = affixed
} }
function syncToken() {
token.value = getToken()
}
onMounted(() => {
isHydrated.value = true
syncToken()
window.addEventListener(TOKEN_EVENT, syncToken)
})
onBeforeUnmount(() => {
window.removeEventListener(TOKEN_EVENT, syncToken)
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -126,8 +126,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { pageShopGoods } from '@/api/shop/shopGoods' import { pageShopGoods } from '@/api/shop/shopGoods'
import type { ShopGoods } from '@/api/shop/shopGoods/model' import type { ShopGoods } from '@/api/shop/shopGoods/model'
import { getShopGoodsCategory } from '@/api/shop/shopGoodsCategory' import { getCmsNavigation } from '@/api/cms/cmsNavigation'
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model' import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
import type { LocationQueryRaw } from 'vue-router' import type { LocationQueryRaw } from 'vue-router'
const route = useRoute() const route = useRoute()
@@ -195,11 +195,11 @@ function onPageSizeChange(_current: number, nextSize: number) {
updateQuery({ limit: nextSize, page: 1 }) updateQuery({ limit: nextSize, page: 1 })
} }
const { data: category } = await useAsyncData<ShopGoodsCategory | null>( const { data: navigation } = await useAsyncData<CmsNavigation | null>(
() => `shop-goods-category-${String(route.params.navigationId)}`, () => `cms-navigation-${String(route.params.navigationId)}`,
async () => { async () => {
if (!isValidCategoryId.value) return null if (!isValidCategoryId.value) return null
return await getShopGoodsCategory(categoryId.value).catch(() => null) return await getCmsNavigation(categoryId.value).catch(() => null)
}, },
{ watch: [categoryId] } { watch: [categoryId] }
) )
@@ -242,14 +242,16 @@ function pickString(obj: unknown, key: string) {
} }
const pageTitle = computed(() => { const pageTitle = computed(() => {
const name = category.value?.title?.trim() const nav = navigation.value as unknown as Record<string, unknown> | null
const name = nav ? pickString(nav, 'title') || pickString(nav, 'label') : ''
if (name) return name if (name) return name
if (isValidCategoryId.value) return `分类 ${categoryId.value}` if (isValidCategoryId.value) return `分类 ${categoryId.value}`
return '商品列表' return '商品列表'
}) })
const heroStyle = computed(() => { const heroStyle = computed(() => {
const banner = pickString(category.value, 'image') const nav = navigation.value as unknown as Record<string, unknown> | null
const banner = nav ? (pickString(nav, 'banner') || pickString(nav, 'image')) : ''
if (banner) { if (banner) {
return { return {
backgroundImage: `url(${banner})` backgroundImage: `url(${banner})`
@@ -294,8 +296,9 @@ function resolveGoodsImage(g: ShopGoods) {
try { try {
const parsed = JSON.parse(files) as unknown const parsed = JSON.parse(files) as unknown
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
const first = parsed[0] as any const first = parsed[0] as unknown
const url = typeof first?.url === 'string' ? first.url.trim() : '' const rec = first && typeof first === 'object' ? (first as Record<string, unknown>) : null
const url = rec && typeof rec.url === 'string' ? rec.url.trim() : ''
if (url) return url if (url) return url
} }
} catch { } catch {

View File

@@ -7,5 +7,6 @@ export type NavItem = {
export const mainNav: NavItem[] = [ export const mainNav: NavItem[] = [
{key: 'home', label: '首页', to: '/'}, {key: 'home', label: '首页', to: '/'},
{key: 'products', label: '经营范围', to: '/products'}, {key: 'products', label: '经营范围', to: '/products'},
{key: 'join', label: '招商加盟', to: '/join'},
{key: 'contact', label: '联系我们', to: '/contact'} {key: 'contact', label: '联系我们', to: '/contact'}
] ]

View File

@@ -1,13 +1,9 @@
<template> <template>
<a-layout class="min-h-screen layout-shell"> <a-layout class="min-h-screen layout-shell">
<a-layout class="w-full px-4 py-4"> <a-layout class="w-full">
<ConsoleHeader <SiteHeader />
:user="user"
:user-display-name="userDisplayName"
@logout="logout"
/>
<a-layout class="body"> <a-layout class="body max-w-screen-xl w-screen">
<a-layout-sider <a-layout-sider
class="sider" class="sider"
:width="240" :width="240"
@@ -29,7 +25,7 @@
</template> </template>
</a-avatar> </a-avatar>
<div v-if="!collapsed" class="sider-title"> <div v-if="!collapsed" class="sider-title">
控制台 用户中心
</div> </div>
</div> </div>
@@ -117,6 +113,7 @@
</a-layout-content> </a-layout-content>
</a-layout> </a-layout>
</a-layout> </a-layout>
<SiteFooter />
</a-layout> </a-layout>
</a-layout> </a-layout>
</template> </template>
@@ -135,6 +132,8 @@ import { TEMPLATE_ID } from '@/config/setting'
import { getToken, removeToken } from '@/utils/token-util' import { getToken, removeToken } from '@/utils/token-util'
import { clearAuthz, setAuthzFromUser } from '@/utils/permission' import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
import { getTenantId } from '@/utils/domain' import { getTenantId } from '@/utils/domain'
import SiteHeader from "~/components/SiteHeader.vue";
import SiteFooter from "~/components/SiteFooter.vue";
const route = useRoute() const route = useRoute()
const collapsed = ref(false) const collapsed = ref(false)
@@ -415,7 +414,7 @@ onMounted(async () => {
} }
.body { .body {
margin: 16px 0; margin: 16px auto;
} }
.main { .main {

View File

@@ -4,7 +4,6 @@
<template #extra> <template #extra>
<a-space> <a-space>
<a-button type="primary" @click="navigateTo('/')">返回首页</a-button> <a-button type="primary" @click="navigateTo('/')">返回首页</a-button>
<a-button @click="navigateTo('/products')">经营范围</a-button>
<a-button @click="navigateTo('/contact')">联系我们</a-button> <a-button @click="navigateTo('/contact')">联系我们</a-button>
</a-space> </a-space>
</template> </template>

View File

@@ -1,11 +0,0 @@
<template>
<a-spin class="w-full" tip="跳转中..." />
</template>
<script setup lang="ts">
definePageMeta({ layout: 'blank' })
onMounted(() => {
navigateTo('/developer/apps', { replace: true })
})
</script>

View File

@@ -0,0 +1,374 @@
<template>
<main class="article-item-page">
<section class="article-item-hero" :style="heroStyle">
<div class="article-item-hero-mask">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<a-breadcrumb class="article-item-breadcrumb">
<a-breadcrumb-item>
<NuxtLink to="/">首页</NuxtLink>
</a-breadcrumb-item>
<a-breadcrumb-item v-if="backToList">
<NuxtLink :to="backToList">文章列表</NuxtLink>
</a-breadcrumb-item>
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
</a-breadcrumb>
<div class="article-item-hero-title">{{ pageTitle }}</div>
<div class="article-item-hero-meta">
<a-tag v-if="article?.categoryName" color="default">{{ article.categoryName }}</a-tag>
<a-tag v-if="article?.author" color="blue">{{ article.author }}</a-tag>
<a-tag v-if="article?.createTime" color="green">{{ article.createTime }}</a-tag>
<a-tag v-if="typeof article?.actualViews === 'number'" color="default">
阅读 {{ article.actualViews }}
</a-tag>
<a-tag v-if="typeof article?.virtualViews === 'number'" color="default">
热度 {{ article.virtualViews }}
</a-tag>
</div>
</div>
</div>
</section>
<section class="mx-auto max-w-screen-xl px-4 py-10">
<a-card class="article-item-card" :bordered="false">
<div class="article-item-card-head">
<a-space>
<a-button @click="goBack">返回</a-button>
<a-button type="primary" @click="navigateTo('/')">首页</a-button>
</a-space>
</div>
<a-divider class="!my-4" />
<div class="article-item-content">
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
<a-result
v-else-if="!hasIdentifier"
status="404"
title="文章不存在"
sub-title="缺少文章参数id/code"
>
<template #extra>
<a-space>
<a-button type="primary" @click="goBack">返回列表</a-button>
<a-button @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<a-result
v-else-if="loadError"
status="error"
title="文章加载失败"
:sub-title="loadError.message"
>
<template #extra>
<a-space>
<a-button type="primary" @click="refresh()">重试</a-button>
<a-button @click="goBack">返回列表</a-button>
</a-space>
</template>
</a-result>
<a-result
v-else-if="!article"
status="404"
title="文章不存在"
sub-title="未找到对应的文章内容"
>
<template #extra>
<a-space>
<a-button type="primary" @click="goBack">返回列表</a-button>
<a-button @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<template v-else>
<img
v-if="coverUrl"
class="article-item-cover"
:src="coverUrl"
:alt="pageTitle"
loading="lazy"
@error="onImgError"
/>
<a-alert v-if="!articleBody" class="mb-6" type="info" show-icon message="暂无内容" />
<RichText v-else :content="articleBody" />
<a-divider class="!my-6" />
<a-descriptions bordered size="small" :column="1">
<a-descriptions-item label="标题">{{ pageTitle }}</a-descriptions-item>
<a-descriptions-item label="栏目" v-if="article.categoryName">
{{ article.categoryName }}
</a-descriptions-item>
<a-descriptions-item label="作者" v-if="article.author">
{{ article.author }}
</a-descriptions-item>
<a-descriptions-item label="来源" v-if="article.source">
{{ article.source }}
</a-descriptions-item>
<a-descriptions-item label="发布时间" v-if="article.createTime">
{{ article.createTime }}
</a-descriptions-item>
<a-descriptions-item label="阅读" v-if="typeof article.actualViews === 'number'">
{{ article.actualViews }}
</a-descriptions-item>
<a-descriptions-item label="热度" v-if="typeof article.virtualViews === 'number'">
{{ article.virtualViews }}
</a-descriptions-item>
</a-descriptions>
</template>
</div>
</a-card>
</section>
</main>
</template>
<script setup lang="ts">
import type { LocationQueryRaw } from 'vue-router'
import { getByCode, getCmsArticle } from '@/api/cms/cmsArticle'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
import { listCmsArticleContent } from '@/api/cms/cmsArticleContent'
const route = useRoute()
const router = useRouter()
function parseQueryString(raw: unknown) {
const text = Array.isArray(raw) ? raw[0] : raw
return typeof text === 'string' ? text.trim() : ''
}
function parseNumber(raw: unknown) {
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
return Number.isFinite(n) ? n : NaN
}
const rawId = computed(() => parseQueryString(route.query.id))
const rawCode = computed(() => parseQueryString(route.query.code))
const hasIdentifier = computed(() => !!(rawId.value || rawCode.value))
const numericId = computed(() => {
const n = parseNumber(rawId.value)
return Number.isFinite(n) && n > 0 ? n : NaN
})
const code = computed(() => {
const c = rawCode.value
if (c) return c
if (Number.isFinite(numericId.value)) return ''
return rawId.value
})
type ArticleBundle = {
article: CmsArticle | null
body: string
}
function coerceContent(value: unknown): string {
if (typeof value === 'string') return value
if (value && typeof value === 'object') {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
return ''
}
function pickFirstNonEmpty(article: CmsArticle | null, keys: Array<keyof CmsArticle>): string {
if (!article) return ''
for (const k of keys) {
const text = coerceContent(article[k]).trim()
if (text) return text
}
return ''
}
const {
data,
pending,
error: loadError,
refresh
} = await useAsyncData<ArticleBundle | null>(
() => `cms-article-item-${rawId.value || rawCode.value || 'missing'}`,
async () => {
if (!hasIdentifier.value) return null
let article: CmsArticle | null = null
if (Number.isFinite(numericId.value)) {
article = await getCmsArticle(numericId.value).catch(() => null)
} else if (code.value) {
article = await getByCode(code.value).catch(() => null)
}
if (!article) return { article: null, body: '' }
// Prefer content embedded on the article record; some deployments store it in a separate table.
let body =
pickFirstNonEmpty(article, ['content', 'detail']) ||
String(article.overview || '').trim()
if (!body && typeof article.articleId === 'number') {
const contentList = await listCmsArticleContent({ articleId: article.articleId }).catch(
() => []
)
const first = contentList?.[0]
body = typeof first?.content === 'string' ? first.content.trim() : ''
}
return { article, body }
},
{ watch: [rawId, rawCode] }
)
const article = computed(() => data.value?.article ?? null)
const articleBody = computed(() => data.value?.body ?? '')
const pageTitle = computed(() => {
const a = article.value
return String(a?.title || a?.code || '文章详情').trim()
})
const coverUrl = computed(() => {
const a = article.value
const img = String(a?.image || '').trim()
return img || ''
})
const heroStyle = computed(() => {
const cover = coverUrl.value
if (cover) return { backgroundImage: `url(${cover})` }
return {}
})
function buildListQuery(): LocationQueryRaw {
return {
page: route.query.page ? String(route.query.page) : undefined,
limit: route.query.limit ? String(route.query.limit) : undefined,
q: route.query.q ? String(route.query.q) : undefined
}
}
const backToList = computed(() => {
const navId = parseNumber(route.query.navId)
if (!Number.isFinite(navId) || navId <= 0) return null
return { path: `/article/${navId}`, query: buildListQuery() }
})
function goBack() {
if (backToList.value) {
navigateTo(backToList.value)
return
}
if (import.meta.client && window.history.length > 1) {
router.back()
return
}
navigateTo('/')
}
function onImgError(e: Event) {
const el = e.target as HTMLImageElement | null
if (!el) return
el.style.display = 'none'
}
const seoTitle = computed(() => pageTitle.value)
const seoDescription = computed(() => {
const a = article.value
const overview = String(a?.overview || '').trim()
if (overview) return overview.length > 120 ? overview.slice(0, 120) : overview
const text = articleBody.value.replace(/<[^>]*>/g, '').trim()
if (text) return text.length > 120 ? text.slice(0, 120) : text
return `${pageTitle.value} - 文章详情`
})
useSeoMeta({
title: seoTitle,
description: seoDescription,
ogTitle: seoTitle,
ogDescription: seoDescription,
ogType: 'article'
})
const canonicalUrl = computed(() => {
if (import.meta.client) return window.location.href
try {
return useRequestURL().href
} catch {
return ''
}
})
useHead(() => ({
link: canonicalUrl.value ? [{ rel: 'canonical', href: canonicalUrl.value }] : []
}))
</script>
<style scoped>
.article-item-page {
background: #f4f6f8;
}
.article-item-hero {
background:
radial-gradient(circle at 15% 20%, rgba(22, 163, 74, 0.22), transparent 60%),
radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 55%),
linear-gradient(180deg, #ffffff, #f8fafc);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.article-item-hero-mask {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.92));
}
.article-item-breadcrumb {
color: rgba(0, 0, 0, 0.6);
}
.article-item-hero-title {
margin-top: 10px;
font-size: 30px;
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
line-height: 1.2;
}
.article-item-hero-meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.article-item-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
}
.article-item-card-head {
display: flex;
justify-content: flex-end;
}
.article-item-cover {
width: 100%;
max-height: 420px;
object-fit: cover;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
margin-bottom: 18px;
}
</style>

View File

@@ -86,7 +86,9 @@
<a-list-item-meta :description="resolveArticleOverview(item)"> <a-list-item-meta :description="resolveArticleOverview(item)">
<template #title> <template #title>
<span class="article-title">{{ resolveArticleTitle(item) }}</span> <NuxtLink class="article-title" :to="resolveArticleLink(item)">
{{ resolveArticleTitle(item) }}
</NuxtLink>
</template> </template>
</a-list-item-meta> </a-list-item-meta>
@@ -251,6 +253,22 @@ function resolveArticleTitle(a: CmsArticle) {
return String(a.title || a.code || '未命名文章').trim() return String(a.title || a.code || '未命名文章').trim()
} }
function resolveArticleLink(a: CmsArticle) {
const articleId = typeof a.articleId === 'number' && Number.isFinite(a.articleId) ? a.articleId : NaN
const code = String(a.code || '').trim()
return {
path: '/article-item',
query: {
id: Number.isFinite(articleId) ? String(articleId) : undefined,
code: !Number.isFinite(articleId) && code ? code : undefined,
navId: Number.isFinite(navigationId.value) ? String(navigationId.value) : undefined,
page: String(page.value),
limit: String(limit.value),
q: keywords.value || undefined
}
}
}
function resolveArticleImage(a: CmsArticle) { function resolveArticleImage(a: CmsArticle) {
const img = String(a.image || '').trim() const img = String(a.image || '').trim()
return img || '' return img || ''
@@ -350,6 +368,7 @@ useHead(() => ({
font-size: 18px; font-size: 18px;
font-weight: 800; font-weight: 800;
color: rgba(0, 0, 0, 0.88); color: rgba(0, 0, 0, 0.88);
text-decoration: none;
} }
.article-meta { .article-meta {

View File

@@ -2,7 +2,7 @@
<div class="mx-auto max-w-screen-xl px-4 py-12"> <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-title :level="1" class="!mb-2">联系我们</a-typography-title>
<a-typography-paragraph class="!text-gray-600 !mb-8"> <a-typography-paragraph class="!text-gray-600 !mb-8">
填写需求后我们将尽快联系你为你规划产品套餐交付开通链路与部署方案SaaS/私有化 填写需求后我们将尽快联系你为你对接供货报价资质资料与合作方案渠道/团购/企业采购/门店合作等
</a-typography-paragraph> </a-typography-paragraph>
<a-row :gutter="[24, 24]"> <a-row :gutter="[24, 24]">
@@ -29,11 +29,13 @@
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<a-form-item label="业务类型" name="delivery"> <a-form-item label="咨询方向" name="consultType">
<a-select v-model:value="form.delivery" placeholder="请选择"> <a-select v-model:value="form.consultType" placeholder="请选择">
<a-select-option value="saas">售前咨询</a-select-option> <a-select-option value="cooperation">合作咨询</a-select-option>
<a-select-option value="private">售后服务</a-select-option> <a-select-option value="purchase">企业采购</a-select-option>
<a-select-option value="hybrid">留意反馈</a-select-option> <a-select-option value="dealer">渠道经销</a-select-option>
<a-select-option value="service">技术服务</a-select-option>
<a-select-option value="other">其他</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
@@ -43,7 +45,7 @@
<a-textarea <a-textarea
v-model:value="form.need" v-model:value="form.need"
:rows="5" :rows="5"
placeholder="例如:需要企业官网/电商/小程序;是否需要模板/插件市场;是否需要支付即开通等" placeholder="例如:合作方向、所在城市与规模、对资质/发票/配送的要求等"
/> />
</a-form-item> </a-form-item>
@@ -66,7 +68,7 @@
<a-alert <a-alert
show-icon show-icon
type="info" type="info"
message="如需更快响应,在需求描述中留下可联系时间段。" message="如需更快响应,在需求描述中留下可联系时间段,以及微信/邮箱(选填)。"
/> />
</div> </div>
</a-card> </a-card>
@@ -82,8 +84,8 @@ import { addCmsOrder } from '@/api/cms/cmsOrder'
import { usePageSeo } from '@/composables/usePageSeo' import { usePageSeo } from '@/composables/usePageSeo'
usePageSeo({ usePageSeo({
title: '联系我们 - 预约演示 / 私有化部署 / 产品开通', title: '联系我们 - 合作咨询 / 供货与采购对接',
description: '预约演示与咨询SaaS 平台、私有化部署、模板/插件市场与支付即开通业务链路。', description: '合作咨询与采购对接:供货报价、资质资料、配送方案与长期合作建议。',
path: '/contact' path: '/contact'
}) })
@@ -91,7 +93,7 @@ const form = reactive({
name: '', name: '',
phone: '', phone: '',
company: '', company: '',
delivery: undefined as undefined | 'saas' | 'private' | 'hybrid', consultType: undefined as undefined | 'cooperation' | 'purchase' | 'dealer' | 'service' | 'other',
need: '' need: ''
}) })
@@ -106,30 +108,35 @@ const rules = {
} }
const tips = [ const tips = [
'你希望售卖哪些产品(官网/电商/小程序/门户等)', '合作方向:渠道经销/团购/企业采购/门店合作/技术服务',
'是否需要模板/插件市场(购买、授权、更新', '所在城市与可覆盖区域(渠道/门店/客户类型',
'是否需要“支付即开通”(自动创建租户/初始化模块与数据', '预计需求规模与周期(首批/月度/长期',
'交付方式SaaS 或私有化部署?是否有合规要求?' '资质与结算:是否需要资质文件、开票类型与账期?',
'交付与配送:自提/同城/快递/冷链,期望时效?'
] ]
async function onSubmit() { async function onSubmit() {
if (submitting.value) return if (submitting.value) return
submitting.value = true submitting.value = true
try { try {
const deliveryLabel = const consultTypeLabel =
form.delivery === 'saas' form.consultType === 'cooperation'
? 'SaaS云端' ? '合作咨询'
: form.delivery === 'private' : form.consultType === 'purchase'
? '私有化部署' ? '企业采购'
: form.delivery === 'hybrid' : form.consultType === 'dealer'
? '混合部署' ? '渠道经销'
: '未选择' : form.consultType === 'service'
? '技术服务'
: form.consultType === 'other'
? '其他'
: '未选择'
const content = [ const content = [
`姓名:${form.name || '-'}`, `姓名:${form.name || '-'}`,
`手机号:${form.phone || '-'}`, `手机号:${form.phone || '-'}`,
`公司/团队:${form.company || '-'}`, `公司/团队:${form.company || '-'}`,
`交付方式${deliveryLabel}`, `咨询方向${consultTypeLabel}`,
'', '',
'需求描述:', '需求描述:',
form.need || '-' form.need || '-'

View File

@@ -1,595 +0,0 @@
<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>

View File

@@ -0,0 +1,23 @@
<template>
<div class="mx-auto max-w-screen-md px-4 py-16">
<a-result status="info" title="配送范围(地图划区)" sub-title="该模块正在建设中配送点范围绘制/编辑/保存与地址落点校验">
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/delivery')">返回配送管理</a-button>
<a-button @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { usePageSeo } from '@/composables/usePageSeo'
usePageSeo({
title: '配送范围(地图划区)',
description: '配送点范围绘制/编辑/保存与地址落点校验(建设中)。',
path: '/delivery/areas'
})
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="mx-auto max-w-screen-md px-4 py-16">
<a-result
status="info"
title="接单台"
sub-title="该模块正在建设中可接订单列表超时标签按日期筛选与接单操作"
>
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/delivery')">返回配送管理</a-button>
<a-button @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { usePageSeo } from '@/composables/usePageSeo'
usePageSeo({
title: '接单台',
description: '可接订单列表、超时标签、按日期筛选与接单操作(建设中)。',
path: '/delivery/board'
})
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="mx-auto max-w-screen-md px-4 py-16">
<a-result
status="info"
title="配送员"
sub-title="该模块正在建设中配送员管理负责小区配置自动派单/人工派单与工资统计"
>
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/delivery')">返回配送管理</a-button>
<a-button @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { usePageSeo } from '@/composables/usePageSeo'
usePageSeo({
title: '配送员',
description: '配送员管理、负责小区配置、自动派单/人工派单与工资统计(建设中)。',
path: '/delivery/couriers'
})
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="mx-auto max-w-screen-md px-4 py-16">
<a-result
status="info"
title="人工派单"
sub-title="该模块正在建设中配送点管理员查看待派单订单选择配送员并通知配送员端"
>
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/delivery')">返回配送管理</a-button>
<a-button @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { usePageSeo } from '@/composables/usePageSeo'
usePageSeo({
title: '人工派单',
description: '配送点管理员派单与通知配送员端(建设中)。',
path: '/delivery/dispatch'
})
</script>

View File

@@ -0,0 +1,204 @@
<template>
<main class="delivery">
<section class="delivery-hero">
<div class="delivery-hero-mask">
<div class="mx-auto max-w-screen-xl px-4 py-10">
<a-breadcrumb class="delivery-breadcrumb">
<a-breadcrumb-item>
<NuxtLink to="/">首页</NuxtLink>
</a-breadcrumb-item>
<a-breadcrumb-item>配送</a-breadcrumb-item>
</a-breadcrumb>
<div class="delivery-hero-title">配送管理</div>
<div class="delivery-hero-sub">
配送区域划分配送员自动派单/人工派单工资统计与接单台原型/规划页
</div>
<div class="mt-4 flex flex-wrap items-center gap-2">
<a-button type="primary" @click="navigateTo('/delivery/areas')">配送范围地图划区</a-button>
<a-button @click="navigateTo('/delivery/couriers')">配送员</a-button>
<a-button @click="navigateTo('/delivery/board')">接单台</a-button>
<a-button @click="navigateTo('/delivery/settings')">设置</a-button>
</div>
</div>
</div>
</section>
<section class="mx-auto max-w-screen-xl px-4 py-10">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :lg="6">
<a-card :bordered="false" class="kpi-card">
<a-statistic title="配送点" :value="kpi.points" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card :bordered="false" class="kpi-card">
<a-statistic title="配送员" :value="kpi.couriers" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card :bordered="false" class="kpi-card">
<a-statistic title="待派单" :value="kpi.pendingDispatch" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card :bordered="false" class="kpi-card">
<a-statistic title="今日配送单" :value="kpi.todayOrders" />
</a-card>
</a-col>
</a-row>
<a-alert
class="mt-4"
show-icon
type="info"
message="说明"
description="当前页面用于梳理配送相关需求与页面入口,后续再逐步落地:地图划区、自动派单、工资统计、确认收货与接单台。"
/>
<a-row class="mt-6" :gutter="[24, 24]">
<a-col :xs="24" :lg="12">
<a-card title="配送范围(配送点维度)" class="module-card" :bordered="false">
<a-list size="small" :data-source="deliveryAreaItems">
<template #renderItem="{ item }">
<a-list-item>{{ item }}</a-list-item>
</template>
</a-list>
<div class="mt-4 flex flex-wrap gap-2">
<a-button type="primary" @click="navigateTo('/delivery/areas')">进入地图划区</a-button>
<a-button @click="navigateTo('/delivery/settings')">配置配送点</a-button>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="配送员(站点维度)" class="module-card" :bordered="false">
<a-list size="small" :data-source="courierItems">
<template #renderItem="{ item }">
<a-list-item>{{ item }}</a-list-item>
</template>
</a-list>
<div class="mt-4 flex flex-wrap gap-2">
<a-button type="primary" @click="navigateTo('/delivery/couriers')">配送员管理</a-button>
<a-button @click="navigateTo('/delivery/settings')">工资/自动确认设置</a-button>
</div>
</a-card>
</a-col>
<a-col :xs="24">
<a-card title="接单台(配送员端)" class="module-card" :bordered="false">
<a-row :gutter="[24, 24]">
<a-col :xs="24" :lg="12">
<a-list size="small" :data-source="boardItems">
<template #renderItem="{ item }">
<a-list-item>{{ item }}</a-list-item>
</template>
</a-list>
<div class="mt-4 flex flex-wrap gap-2">
<a-button type="primary" @click="navigateTo('/delivery/board')">进入接单台</a-button>
<a-button @click="navigateTo('/delivery/dispatch')">人工派单</a-button>
</div>
</a-col>
<a-col :xs="24" :lg="12">
<a-typography-title :level="5" class="!mb-3">配送完成链路建议流程</a-typography-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-item>送达 可选拍照 配送员确认送达</a-timeline-item>
<a-timeline-item>客户确认收货若未确认按配置自动确认默认 24h</a-timeline-item>
</a-timeline>
</a-col>
</a-row>
</a-card>
</a-col>
</a-row>
</section>
</main>
</template>
<script setup lang="ts">
import { usePageSeo } from '@/composables/usePageSeo'
usePageSeo({
title: '配送管理',
description: '配送范围划区、配送员派单规则、工资统计与接单台需求梳理。',
path: '/delivery'
})
const kpi = reactive({
points: 0,
couriers: 0,
pendingDispatch: 0,
todayOrders: 0
})
const deliveryAreaItems = [
'每个配送点的配送范围可独立设置',
'在地图上划分区域(多边形/多个区域/禁配送区等)',
'订单地址落点后判断是否在范围内(点-in-多边形)'
]
const courierItems = [
'每个配送点可配置 N 个配送员',
'可设置配送员负责的小区;属于该小区的订单自动派给对应配送员',
'收货地址不属于任何小区:配送点管理员人工派单(需同步到配送员端)',
'工资统计:水按桶计提成;其它商品按金额/规则计提成;默认当月,可选时间段查询;线下结算',
'第三方配送点:不计算配送员工资',
'送达后:配送员可选拍照并确认送达;客户确认收货;未确认则按配置自动确认(默认 24h'
]
const boardItems = [
'配送员可查看所属站点的配送订单列表',
'按配送时间降序排序',
'按配送日期筛选查询可接订单',
'超时订单也可接单,并显示“超时”标签提醒'
]
</script>
<style scoped>
.delivery {
background: #f4f6f8;
}
.delivery-hero {
background:
radial-gradient(circle at 15% 20%, rgba(22, 163, 74, 0.22), transparent 60%),
radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 55%),
linear-gradient(180deg, #ffffff, #f8fafc);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.delivery-hero-mask {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.92));
}
.delivery-breadcrumb {
color: rgba(0, 0, 0, 0.6);
}
.delivery-hero-title {
margin-top: 10px;
font-size: 30px;
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
line-height: 1.2;
}
.delivery-hero-sub {
margin-top: 10px;
color: rgba(0, 0, 0, 0.6);
line-height: 1.6;
}
.kpi-card,
.module-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<div class="mx-auto max-w-screen-md px-4 py-16">
<a-result
status="info"
title="配送设置"
sub-title="该模块正在建设中自动确认收货时长工资规则按桶/按金额第三方配送点开关等"
>
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/delivery')">返回配送管理</a-button>
<a-button @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { usePageSeo } from '@/composables/usePageSeo'
usePageSeo({
title: '配送设置',
description: '自动确认收货时长、工资规则、第三方配送点开关(建设中)。',
path: '/delivery/settings'
})
</script>

View File

@@ -5,8 +5,8 @@
<a-breadcrumb-item> <a-breadcrumb-item>
<NuxtLink to="/">首页</NuxtLink> <NuxtLink to="/">首页</NuxtLink>
</a-breadcrumb-item> </a-breadcrumb-item>
<a-breadcrumb-item> <a-breadcrumb-item v-if="Number.isFinite(categoryId)">
<span>商品</span> <NuxtLink :to="`/product/${categoryId}`">{{ categoryTitle || `分类 ${categoryId}` }}</NuxtLink>
</a-breadcrumb-item> </a-breadcrumb-item>
<a-breadcrumb-item>{{ title }}</a-breadcrumb-item> <a-breadcrumb-item>{{ title }}</a-breadcrumb-item>
</a-breadcrumb> </a-breadcrumb>
@@ -117,6 +117,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { getShopGoods } from '@/api/shop/shopGoods' import { getShopGoods } from '@/api/shop/shopGoods'
import type { ShopGoods } from '@/api/shop/shopGoods/model' import type { ShopGoods } from '@/api/shop/shopGoods/model'
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -207,6 +209,27 @@ const categoryIdText = computed(() => {
return typeof n === 'number' ? String(n) : '-' return typeof n === 'number' ? String(n) : '-'
}) })
const categoryId = computed(() => {
const g = goods.value as unknown as Record<string, unknown> | null
const n = g ? pickNumber(g, 'categoryId') : undefined
return typeof n === 'number' && Number.isFinite(n) ? n : NaN
})
const { data: categoryNav } = await useAsyncData<CmsNavigation | null>(
() => `cms-navigation-${String(categoryId.value)}`,
async () => {
if (!Number.isFinite(categoryId.value)) return null
return await getCmsNavigation(categoryId.value).catch(() => null)
},
{ watch: [categoryId] }
)
const categoryTitle = computed(() => {
const nav = categoryNav.value as unknown as Record<string, unknown> | null
if (!nav) return ''
return pickString(nav, 'title') || pickString(nav, 'label')
})
const createTime = computed(() => { const createTime = computed(() => {
const g = goods.value as unknown as Record<string, unknown> | null const g = goods.value as unknown as Record<string, unknown> | null
return g ? pickString(g, 'createTime') || '-' : '-' return g ? pickString(g, 'createTime') || '-' : '-'
@@ -396,4 +419,3 @@ useHead(() => ({
font-weight: 700; font-weight: 700;
} }
</style> </style>

View File

@@ -198,14 +198,14 @@ const columns = [
const compliance = [ const compliance = [
{ {
title: '经营范围', title: '产品展示',
desc: '一般项目/许可项目明细与说明', desc: '一般项目/许可项目明细与说明',
to: '/products' to: '/goods/4476'
}, },
{ {
title: '注册地址', title: '注册地址',
desc: '南宁市江南区国凯大道东13号神冠胶原智库项目加工厂房', desc: '南宁市江南区国凯大道东13号神冠胶原智库项目加工厂房',
to: '/products' to: '/contact'
}, },
{ {
title: '合作咨询', title: '合作咨询',

311
app/pages/join/index.vue Normal file
View File

@@ -0,0 +1,311 @@
<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="14">
<a-card title="合作优势">
<a-list size="small" :data-source="advantages">
<template #renderItem="{ item }">
<a-list-item class="list-item">{{ item }}</a-list-item>
</template>
</a-list>
</a-card>
<a-card class="mt-6" title="合作模式">
<a-row :gutter="[16, 16]">
<a-col v-for="mode in modes" :key="mode.title" :xs="24" :md="8">
<div class="mode-card">
<div class="mode-title">{{ mode.title }}</div>
<div class="mode-desc">{{ mode.desc }}</div>
</div>
</a-col>
</a-row>
</a-card>
<a-card class="mt-6" title="扶持政策(示例)">
<a-row :gutter="[16, 16]">
<a-col v-for="s in supports" :key="s.title" :xs="24" :md="12">
<a-statistic :title="s.title" :value="s.value" />
<div class="support-desc">{{ s.desc }}</div>
</a-col>
</a-row>
<a-alert
class="mt-4"
show-icon
type="info"
message="实际政策以对接沟通为准,我们会根据城市、渠道资源与团队情况给出更匹配的方案。"
/>
</a-card>
<a-card class="mt-6" title="加盟流程">
<a-steps :current="0" size="small" direction="vertical">
<a-step title="提交意向" description="填写基本信息、合作类型与所在区域" />
<a-step title="电话沟通" description="了解资源与目标,确认合作方向" />
<a-step title="资质审核" description="身份证明/营业执照/场地等(按合作类型)" />
<a-step title="签约与培训" description="合同签署、产品与运营培训" />
<a-step title="开业/启动" description="物料支持、营销支持与持续复盘" />
</a-steps>
</a-card>
<a-card class="mt-6" title="常见问题">
<a-collapse accordion>
<a-collapse-panel key="q1" header="需要门店吗?">
<a-typography-paragraph class="!mb-0">
不强制可按你的资源选择渠道分销团购/企业客户或门店零售等模式我们会给出落地建议
</a-typography-paragraph>
</a-collapse-panel>
<a-collapse-panel key="q2" header="是否有区域保护?">
<a-typography-paragraph class="!mb-0">
可根据区域与合作层级协商设置原则上以可持续经营避免恶性竞争为目标
</a-typography-paragraph>
</a-collapse-panel>
<a-collapse-panel key="q3" header="多久可以启动?">
<a-typography-paragraph class="!mb-0">
资料齐全且沟通确认后一般 3-7 个工作日可完成签约与基础培训具体以项目复杂度为准
</a-typography-paragraph>
</a-collapse-panel>
</a-collapse>
</a-card>
</a-col>
<a-col :xs="24" :lg="10">
<a-card title="加盟申请">
<a-form ref="formRef" 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="city">
<a-input v-model:value="form.city" placeholder="例如:南宁/柳州/桂林" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="合作类型" name="cooperationType">
<a-select v-model:value="form.cooperationType" placeholder="请选择">
<a-select-option value="area">区域合伙人</a-select-option>
<a-select-option value="dealer">渠道经销</a-select-option>
<a-select-option value="group">团购/企业客户</a-select-option>
<a-select-option value="store">门店加盟</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="从业经验" name="experience">
<a-select v-model:value="form.experience" placeholder="请选择">
<a-select-option value="0"></a-select-option>
<a-select-option value="1-3">1-3</a-select-option>
<a-select-option value="3-5">3-5</a-select-option>
<a-select-option value="5+">5年以上</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="预算范围" name="budget">
<a-select v-model:value="form.budget" placeholder="请选择(可选)" allow-clear>
<a-select-option value="lt3">3万以内</a-select-option>
<a-select-option value="3-10">3-10</a-select-option>
<a-select-option value="10-30">10-30</a-select-option>
<a-select-option value="gt30">30万以上</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" :loading="submitting">提交申请</a-button>
<a-button :disabled="submitting" @click="reset">重置</a-button>
</a-space>
</a-form>
</a-card>
<a-card class="mt-6" title="填写建议">
<a-list size="small" :data-source="tips">
<template #renderItem="{ item }">
<a-list-item>{{ item }}</a-list-item>
</template>
</a-list>
<a-alert class="mt-4" show-icon type="success" message="我们将尽快与你联系沟通合作细节。" />
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import { addCmsOrder } from '@/api/cms/cmsOrder'
import { usePageSeo } from '@/composables/usePageSeo'
import { COMPANY } from '@/config/company'
usePageSeo({
title: '招商加盟',
description: `${COMPANY.projectName} 招商加盟:区域合伙人、渠道经销、团购/企业客户与门店合作。`,
path: '/join'
})
const advantages = [
'合规资质与经营范围清晰,合作沟通更高效',
'稳定供货与品控管理,支持标准化交付',
'市场物料与活动打法沉淀,降低试错成本',
'培训与运营辅导,帮助门店/渠道快速起量',
'数字化工具支持(选品、下单、对账等)',
'持续复盘与长期支持,陪跑成长'
]
const modes = [
{ title: '区域合伙人', desc: '负责区域招商与渠道拓展,获取更高合作权益与支持。' },
{ title: '渠道经销', desc: '适合批发商/渠道商/社区团购,走量稳定、周转快。' },
{ title: '门店加盟', desc: '适合零售门店,提供选品建议、陈列物料与运营培训。' }
]
const supports = [
{ title: '培训赋能', value: '1v1', desc: '产品知识/动销话术/活动策略与执行清单' },
{ title: '选品建议', value: '定制', desc: '按城市与客群定制类目结构与爆品组合' },
{ title: '运营陪跑', value: '持续', desc: '启动期跟进复盘,关键节点协助优化' },
{ title: '物料支持', value: '可配', desc: '海报、价签、活动页等物料统一输出' }
]
const tips = [
'请尽量写清楚:所在城市/可覆盖区域。',
'说明你的资源:门店数量、渠道类型、团购社群、企业客户等。',
'如果方便,可补充预估销量/团队人数/启动时间。'
]
const form = reactive({
name: '',
phone: '',
city: '',
cooperationType: undefined as undefined | 'area' | 'dealer' | 'group' | 'store',
experience: undefined as undefined | '0' | '1-3' | '3-5' | '5+',
budget: undefined as undefined | 'lt3' | '3-10' | '10-30' | 'gt30',
need: ''
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const rules = {
name: [{ required: true, message: '请填写姓名' }],
phone: [{ required: true, message: '请填写手机号' }],
city: [{ required: true, message: '请填写意向城市' }],
cooperationType: [{ required: true, message: '请选择合作类型' }],
experience: [{ required: true, message: '请选择从业经验' }],
need: [{ required: true, message: '请填写资源/诉求说明' }]
}
function labelCooperationType(value?: typeof form.cooperationType) {
if (value === 'area') return '区域合伙人'
if (value === 'dealer') return '渠道经销'
if (value === 'group') return '团购/企业客户'
if (value === 'store') return '门店加盟'
return '未选择'
}
function labelBudget(value?: typeof form.budget) {
if (value === 'lt3') return '3万以内'
if (value === '3-10') return '3-10万'
if (value === '10-30') return '10-30万'
if (value === 'gt30') return '30万以上'
return '未填写'
}
async function onSubmit() {
if (submitting.value) return
submitting.value = true
try {
const content = [
`姓名:${form.name || '-'}`,
`手机号:${form.phone || '-'}`,
`意向城市:${form.city || '-'}`,
`合作类型:${labelCooperationType(form.cooperationType)}`,
`从业经验:${form.experience || '-'}`,
`预算范围:${labelBudget(form.budget)}`,
'',
'资源/诉求说明:',
form.need || '-'
].join('\n')
const resMessage = await addCmsOrder({
title: `招商加盟 - ${form.city || ''}${form.name ? `-${form.name}` : ''}`.trim(),
type: 2, // 2 留言
channel: 0, // 0 网站
realName: form.name,
phone: form.phone,
content
})
message.success(resMessage || '已提交,我们会尽快与你联系。')
reset()
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : '提交失败,请稍后重试。'
message.error(errMsg)
} finally {
submitting.value = false
}
}
function reset() {
formRef.value?.resetFields()
}
</script>
<style scoped>
.list-item {
padding: 6px 0;
color: rgba(0, 0, 0, 0.75);
line-height: 1.6;
}
.mode-card {
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 10px;
padding: 14px;
height: 100%;
background: rgba(0, 0, 0, 0.01);
}
.mode-title {
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
}
.mode-desc {
margin-top: 6px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.6;
}
.support-desc {
margin-top: 6px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.6;
}
</style>

View File

@@ -1,164 +1,180 @@
<template> <template>
<div class="login-page" :style="bgStyle"> <div class="login-shell">
<div class="overlay" /> <SiteHeader />
<div v-if="config?.siteName" class="brand"> <div class="login-page" :style="bgStyle">
<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> <div v-if="config?.siteName" class="brand">
<img :src="config.sysLogo || defaultLogo" class="brand-logo" alt="logo" />
<a-form ref="formRef" :model="form" :rules="rules" class="card"> <h1 class="brand-name">{{ config.siteName }}</h1>
<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> </div>
<template v-if="loginType === 'account'"> <div v-if="config?.loginTitle" class="brand-title">{{ config.loginTitle }}</div>
<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-form ref="formRef" :model="form" :rules="rules" class="card">
<a-input-password <div class="card-header">
v-model:value="form.password" <template v-if="loginType === 'scan'">
size="large" <h2 class="card-title">扫码登录</h2>
placeholder="登录密码" </template>
@press-enter="submitAccount" <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' ? '切换到手机号登录' : '切换到扫码登录'"
> >
<template #prefix><LockOutlined /></template> <QrcodeOutlined v-if="loginType !== 'scan'" />
</a-input-password> <MobileOutlined v-else />
</a-form-item> </a-button>
</div>
<a-form-item name="code"> <template v-if="loginType === 'account'">
<div class="input-group"> <a-form-item name="username">
<a-input <a-input v-model:value="form.username" size="large" allow-clear placeholder="账号 / 用户ID">
v-model:value="form.code" <template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="form.password"
size="large" size="large"
allow-clear placeholder="登录密码"
:maxlength="5"
placeholder="验证码"
@press-enter="submitAccount" @press-enter="submitAccount"
> >
<template #prefix><SafetyCertificateOutlined /></template> <template #prefix><LockOutlined /></template>
</a-input> </a-input-password>
<a-button class="captcha-btn" @click="changeCaptcha"> </a-form-item>
<img v-if="captcha" :src="captcha" alt="captcha" />
<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-button>
</div> </a-form-item>
</a-form-item> </template>
<a-form-item> <template v-else-if="loginType === 'sms'">
<div class="row"> <a-form-item name="phone">
<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 <a-input
v-model:value="form.smsCode" v-model:value="form.phone"
size="large" size="large"
allow-clear allow-clear
:maxlength="6" :maxlength="11"
placeholder="请输入验证码" placeholder="请输入手机号码"
@press-enter="submitSms" >
/> <template #addonBefore>+86</template>
<a-button class="captcha-btn" :disabled="countdown > 0" @click="openImgCodeModal"> </a-input>
<span v-if="countdown <= 0">发送验证码</span> </a-form-item>
<span v-else>已发送 {{ countdown }} s</span>
<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-button>
</div> </a-form-item>
</a-form-item> </template>
<a-form-item> <template v-else>
<a-button block size="large" type="primary" :loading="loading" @click="submitSms"> <QrLogin @login-success="onQrLoginSuccess" @login-error="onQrLoginError" />
{{ loading ? '登录中' : '登录' }} </template>
</a-form>
<div class="copyright hidden">
<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> </a-button>
</a-form-item> </div>
</template> <a-button block size="large" type="primary" :loading="sendingSms" @click="sendSmsCode">
立即发送
</a-button>
</a-modal>
<template v-else> <a-modal v-model:open="selectUserOpen" :width="520" :footer="null" title="选择账号登录">
<QrLogin @login-success="onQrLoginSuccess" @login-error="onQrLoginError" /> <a-list item-layout="horizontal" :data-source="admins">
</template> <template #renderItem="{ item }">
</a-form> <a-list-item class="list-item" @click="selectUser(item)">
<a-list-item-meta :description="`租户ID: ${item.tenantId}`">
<div class="copyright"> <template #title>{{ item.tenantName || item.username }}</template>
<span>© {{ new Date().getFullYear() }}</span> <template #avatar>
<span class="sep">·</span> <a-avatar :src="item.avatar" />
<span>{{ config?.copyright || 'websoft.top Inc.' }}</span> </template>
</a-list-item-meta>
<template #actions><RightOutlined /></template>
</a-list-item>
</template>
</a-list>
</a-modal>
</div> </div>
<a-modal v-model:open="imgCodeModalOpen" :width="340" :footer="null" title="发送验证码"> <SiteFooter />
<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> </div>
</template> </template>
@@ -183,6 +199,7 @@ import { TEMPLATE_ID } from '@/config/setting'
import { setToken } from '@/utils/token-util' import { setToken } from '@/utils/token-util'
import type { QrCodeStatusResponse } from '@/api/passport/qrLogin' import type { QrCodeStatusResponse } from '@/api/passport/qrLogin'
// Login page is a public page: keep a lightweight layout and render header/footer locally.
definePageMeta({ layout: 'blank' }) definePageMeta({ layout: 'blank' })
const route = useRoute() const route = useRoute()
@@ -387,10 +404,16 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.login-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.login-page { .login-page {
position: relative; position: relative;
min-height: 100vh; flex: 1;
background: #111827; min-height: 0;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
padding: 48px 16px; padding: 48px 16px;

View File

@@ -1,81 +0,0 @@
<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="14">
<a-card title="一般项目(经营范围)">
<a-list size="small" :data-source="generalItems">
<template #renderItem="{ item }">
<a-list-item class="scope-item">{{ item }}</a-list-item>
</template>
</a-list>
</a-card>
<a-card class="mt-6" title="许可项目">
<a-list size="small" :data-source="licensedItems">
<template #renderItem="{ item }">
<a-list-item class="scope-item">{{ item }}</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col :xs="24" :lg="10">
<a-card title="基本信息">
<a-descriptions bordered :column="1" size="small">
<a-descriptions-item label="项目名称">{{ COMPANY.projectName }}</a-descriptions-item>
<a-descriptions-item label="注册地址">{{ COMPANY.address }}</a-descriptions-item>
</a-descriptions>
<a-alert
class="mt-4"
show-icon
type="warning"
message="涉及许可项目的经营活动,请以主管部门批准文件/许可证件为准。"
/>
</a-card>
<a-card class="mt-6" title="快速入口">
<a-space direction="vertical" class="w-full">
<a-button type="primary" block @click="navigateTo('/contact')">合作咨询</a-button>
<a-button block @click="navigateTo('/')">返回首页</a-button>
</a-space>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { usePageSeo } from '@/composables/usePageSeo'
import { COMPANY } from '@/config/company'
usePageSeo({
title: '经营范围',
description: `${COMPANY.projectName} 经营范围公示:一般项目、许可项目及注册地址信息。`,
path: '/products'
})
function splitScope(text: string) {
return text
.split(/[;]+/g)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => s.replace(/[。.]$/, '').trim())
}
const generalItems = computed(() => splitScope(COMPANY.scope.general))
const licensedItems = computed(() => splitScope(COMPANY.scope.licensed))
</script>
<style scoped>
.scope-item {
padding: 6px 0;
color: rgba(0, 0, 0, 0.75);
line-height: 1.6;
}
</style>