feat(router): 更新路由结构并优化页面组件
- 移除经营范围按钮,精简导航栏 - 实现文章标题链接功能,提升用户体验 - 添加商品详情页面包屑导航,支持分类跳转 - 引入配送管理相关页面(区域、接单台、配送员、派单) - 替换控制台布局为站点头部和底部组件 - 重构商品分类页面,集成CMS导航功能 - 新增文章详情页面,支持多种访问方式 - 删除已迁移的创建应用和空应用页面 - 优化样式和组件导入,提升代码质量
This commit is contained in:
@@ -132,7 +132,7 @@ export async function getByCode(code: string) {
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
if (res.data.code === 0) {
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
<div class="topbar-right">
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<a-button size="small" @click="navigateTo('/products')">经营范围</a-button>
|
||||
<a-button size="small" type="primary" @click="navigateTo('/contact')">联系我们</a-button>
|
||||
<a-button size="small" @click="navigateTo('/join')">招商加盟</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>
|
||||
|
||||
<a-button class="md:hidden" size="small" @click="open = true">菜单</a-button>
|
||||
@@ -107,6 +108,7 @@
|
||||
<a-space direction="vertical" class="w-full">
|
||||
<a-button type="primary" block @click="onNav('/contact')">联系我们</a-button>
|
||||
<a-button block @click="onNav('/products')">经营范围</a-button>
|
||||
<a-button block @click="onNav('/join')">招商加盟</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-drawer>
|
||||
@@ -116,10 +118,16 @@
|
||||
import { mainNav } from '@/config/nav'
|
||||
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
|
||||
import { COMPANY } from '@/config/company'
|
||||
import { getToken } from '@/utils/token-util'
|
||||
|
||||
const route = useRoute()
|
||||
const open = 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 = {
|
||||
key: string
|
||||
@@ -286,6 +294,20 @@ const todayText = computed(() => {
|
||||
function onAffixChange(affixed: boolean) {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -126,8 +126,8 @@
|
||||
<script setup lang="ts">
|
||||
import { pageShopGoods } from '@/api/shop/shopGoods'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
import { getShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
|
||||
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
|
||||
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
|
||||
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
|
||||
import type { LocationQueryRaw } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -195,11 +195,11 @@ function onPageSizeChange(_current: number, nextSize: number) {
|
||||
updateQuery({ limit: nextSize, page: 1 })
|
||||
}
|
||||
|
||||
const { data: category } = await useAsyncData<ShopGoodsCategory | null>(
|
||||
() => `shop-goods-category-${String(route.params.navigationId)}`,
|
||||
const { data: navigation } = await useAsyncData<CmsNavigation | null>(
|
||||
() => `cms-navigation-${String(route.params.navigationId)}`,
|
||||
async () => {
|
||||
if (!isValidCategoryId.value) return null
|
||||
return await getShopGoodsCategory(categoryId.value).catch(() => null)
|
||||
return await getCmsNavigation(categoryId.value).catch(() => null)
|
||||
},
|
||||
{ watch: [categoryId] }
|
||||
)
|
||||
@@ -242,14 +242,16 @@ function pickString(obj: unknown, key: string) {
|
||||
}
|
||||
|
||||
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 (isValidCategoryId.value) return `分类 ${categoryId.value}`
|
||||
return '商品列表'
|
||||
})
|
||||
|
||||
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) {
|
||||
return {
|
||||
backgroundImage: `url(${banner})`
|
||||
@@ -294,8 +296,9 @@ function resolveGoodsImage(g: ShopGoods) {
|
||||
try {
|
||||
const parsed = JSON.parse(files) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
const first = parsed[0] as any
|
||||
const url = typeof first?.url === 'string' ? first.url.trim() : ''
|
||||
const first = parsed[0] as unknown
|
||||
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
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -7,5 +7,6 @@ export type NavItem = {
|
||||
export const mainNav: NavItem[] = [
|
||||
{key: 'home', label: '首页', to: '/'},
|
||||
{key: 'products', label: '经营范围', to: '/products'},
|
||||
{key: 'join', label: '招商加盟', to: '/join'},
|
||||
{key: 'contact', label: '联系我们', to: '/contact'}
|
||||
]
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<template>
|
||||
<a-layout class="min-h-screen layout-shell">
|
||||
<a-layout class="w-full px-4 py-4">
|
||||
<ConsoleHeader
|
||||
:user="user"
|
||||
:user-display-name="userDisplayName"
|
||||
@logout="logout"
|
||||
/>
|
||||
<a-layout class="w-full">
|
||||
<SiteHeader />
|
||||
|
||||
<a-layout class="body">
|
||||
<a-layout class="body max-w-screen-xl w-screen">
|
||||
<a-layout-sider
|
||||
class="sider"
|
||||
:width="240"
|
||||
@@ -29,7 +25,7 @@
|
||||
</template>
|
||||
</a-avatar>
|
||||
<div v-if="!collapsed" class="sider-title">
|
||||
控制台
|
||||
用户中心
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +113,7 @@
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
<SiteFooter />
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -135,6 +132,8 @@ import { TEMPLATE_ID } from '@/config/setting'
|
||||
import { getToken, removeToken } from '@/utils/token-util'
|
||||
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
|
||||
import { getTenantId } from '@/utils/domain'
|
||||
import SiteHeader from "~/components/SiteHeader.vue";
|
||||
import SiteFooter from "~/components/SiteFooter.vue";
|
||||
|
||||
const route = useRoute()
|
||||
const collapsed = ref(false)
|
||||
@@ -415,7 +414,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 16px 0;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.main {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="navigateTo('/')">返回首页</a-button>
|
||||
<a-button @click="navigateTo('/products')">经营范围</a-button>
|
||||
<a-button @click="navigateTo('/contact')">联系我们</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
374
app/pages/article-item/index.vue
Normal file
374
app/pages/article-item/index.vue
Normal 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>
|
||||
|
||||
@@ -86,7 +86,9 @@
|
||||
|
||||
<a-list-item-meta :description="resolveArticleOverview(item)">
|
||||
<template #title>
|
||||
<span class="article-title">{{ resolveArticleTitle(item) }}</span>
|
||||
<NuxtLink class="article-title" :to="resolveArticleLink(item)">
|
||||
{{ resolveArticleTitle(item) }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
|
||||
@@ -251,6 +253,22 @@ function resolveArticleTitle(a: CmsArticle) {
|
||||
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) {
|
||||
const img = String(a.image || '').trim()
|
||||
return img || ''
|
||||
@@ -350,6 +368,7 @@ useHead(() => ({
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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]">
|
||||
@@ -29,11 +29,13 @@
|
||||
</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">售前咨询</a-select-option>
|
||||
<a-select-option value="private">售后服务</a-select-option>
|
||||
<a-select-option value="hybrid">留意反馈</a-select-option>
|
||||
<a-form-item label="咨询方向" name="consultType">
|
||||
<a-select v-model:value="form.consultType" placeholder="请选择">
|
||||
<a-select-option value="cooperation">合作咨询</a-select-option>
|
||||
<a-select-option value="purchase">企业采购</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-form-item>
|
||||
</a-col>
|
||||
@@ -43,7 +45,7 @@
|
||||
<a-textarea
|
||||
v-model:value="form.need"
|
||||
:rows="5"
|
||||
placeholder="例如:需要企业官网/电商/小程序;是否需要模板/插件市场;是否需要支付即开通等"
|
||||
placeholder="例如:合作方向、所在城市与规模、对资质/发票/配送的要求等"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
@@ -66,7 +68,7 @@
|
||||
<a-alert
|
||||
show-icon
|
||||
type="info"
|
||||
message="如需更快响应,可在需求描述中留下可联系时间段。"
|
||||
message="如需更快响应,请在需求描述中留下可联系时间段,以及微信/邮箱(选填)。"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
@@ -82,8 +84,8 @@ import { addCmsOrder } from '@/api/cms/cmsOrder'
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
|
||||
usePageSeo({
|
||||
title: '联系我们 - 预约演示 / 私有化部署 / 产品开通',
|
||||
description: '预约演示与咨询:SaaS 平台、私有化部署、模板/插件市场与支付即开通业务链路。',
|
||||
title: '联系我们 - 合作咨询 / 供货与采购对接',
|
||||
description: '合作咨询与采购对接:供货报价、资质资料、配送方案与长期合作建议。',
|
||||
path: '/contact'
|
||||
})
|
||||
|
||||
@@ -91,7 +93,7 @@ const form = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
company: '',
|
||||
delivery: undefined as undefined | 'saas' | 'private' | 'hybrid',
|
||||
consultType: undefined as undefined | 'cooperation' | 'purchase' | 'dealer' | 'service' | 'other',
|
||||
need: ''
|
||||
})
|
||||
|
||||
@@ -106,30 +108,35 @@ const rules = {
|
||||
}
|
||||
|
||||
const tips = [
|
||||
'你希望售卖哪些产品(官网/电商/小程序/门户等)?',
|
||||
'是否需要模板/插件市场(购买、授权、更新)?',
|
||||
'是否需要“支付即开通”(自动创建租户/初始化模块与数据)?',
|
||||
'交付方式:SaaS 或私有化部署?是否有合规要求?'
|
||||
'合作方向:渠道经销/团购/企业采购/门店合作/技术服务?',
|
||||
'所在城市与可覆盖区域(渠道/门店/客户类型)?',
|
||||
'预计需求规模与周期(首批/月度/长期)?',
|
||||
'资质与结算:是否需要资质文件、开票类型与账期?',
|
||||
'交付与配送:自提/同城/快递/冷链,期望时效?'
|
||||
]
|
||||
|
||||
async function onSubmit() {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const deliveryLabel =
|
||||
form.delivery === 'saas'
|
||||
? 'SaaS(云端)'
|
||||
: form.delivery === 'private'
|
||||
? '私有化部署'
|
||||
: form.delivery === 'hybrid'
|
||||
? '混合部署'
|
||||
: '未选择'
|
||||
const consultTypeLabel =
|
||||
form.consultType === 'cooperation'
|
||||
? '合作咨询'
|
||||
: form.consultType === 'purchase'
|
||||
? '企业采购'
|
||||
: form.consultType === 'dealer'
|
||||
? '渠道经销'
|
||||
: form.consultType === 'service'
|
||||
? '技术服务'
|
||||
: form.consultType === 'other'
|
||||
? '其他'
|
||||
: '未选择'
|
||||
|
||||
const content = [
|
||||
`姓名:${form.name || '-'}`,
|
||||
`手机号:${form.phone || '-'}`,
|
||||
`公司/团队:${form.company || '-'}`,
|
||||
`交付方式:${deliveryLabel}`,
|
||||
`咨询方向:${consultTypeLabel}`,
|
||||
'',
|
||||
'需求描述:',
|
||||
form.need || '-'
|
||||
@@ -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>
|
||||
23
app/pages/delivery/areas.vue
Normal file
23
app/pages/delivery/areas.vue
Normal 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>
|
||||
|
||||
27
app/pages/delivery/board.vue
Normal file
27
app/pages/delivery/board.vue
Normal 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>
|
||||
|
||||
27
app/pages/delivery/couriers.vue
Normal file
27
app/pages/delivery/couriers.vue
Normal 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>
|
||||
|
||||
27
app/pages/delivery/dispatch.vue
Normal file
27
app/pages/delivery/dispatch.vue
Normal 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>
|
||||
|
||||
204
app/pages/delivery/index.vue
Normal file
204
app/pages/delivery/index.vue
Normal 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>
|
||||
27
app/pages/delivery/settings.vue
Normal file
27
app/pages/delivery/settings.vue
Normal 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>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<a-breadcrumb-item>
|
||||
<NuxtLink to="/">首页</NuxtLink>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>
|
||||
<span>商品</span>
|
||||
<a-breadcrumb-item v-if="Number.isFinite(categoryId)">
|
||||
<NuxtLink :to="`/product/${categoryId}`">{{ categoryTitle || `分类 ${categoryId}` }}</NuxtLink>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ title }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
@@ -117,6 +117,8 @@
|
||||
<script setup lang="ts">
|
||||
import { getShopGoods } from '@/api/shop/shopGoods'
|
||||
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 router = useRouter()
|
||||
@@ -207,6 +209,27 @@ const categoryIdText = computed(() => {
|
||||
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 g = goods.value as unknown as Record<string, unknown> | null
|
||||
return g ? pickString(g, 'createTime') || '-' : '-'
|
||||
@@ -396,4 +419,3 @@ useHead(() => ({
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -198,14 +198,14 @@ const columns = [
|
||||
|
||||
const compliance = [
|
||||
{
|
||||
title: '经营范围',
|
||||
title: '产品展示',
|
||||
desc: '一般项目/许可项目明细与说明',
|
||||
to: '/products'
|
||||
to: '/goods/4476'
|
||||
},
|
||||
{
|
||||
title: '注册地址',
|
||||
desc: '南宁市江南区国凯大道东13号神冠胶原智库项目加工厂房',
|
||||
to: '/products'
|
||||
to: '/contact'
|
||||
},
|
||||
{
|
||||
title: '合作咨询',
|
||||
|
||||
311
app/pages/join/index.vue
Normal file
311
app/pages/join/index.vue
Normal 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>
|
||||
@@ -1,164 +1,180 @@
|
||||
<template>
|
||||
<div class="login-page" :style="bgStyle">
|
||||
<div class="overlay" />
|
||||
<div class="login-shell">
|
||||
<SiteHeader />
|
||||
|
||||
<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 class="login-page" :style="bgStyle">
|
||||
|
||||
<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 v-if="config?.siteName" class="brand">
|
||||
<img :src="config.sysLogo || defaultLogo" class="brand-logo" alt="logo" />
|
||||
<h1 class="brand-name">{{ config.siteName }}</h1>
|
||||
</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>
|
||||
<div v-if="config?.loginTitle" class="brand-title">{{ config.loginTitle }}</div>
|
||||
|
||||
<a-form-item name="password">
|
||||
<a-input-password
|
||||
v-model:value="form.password"
|
||||
size="large"
|
||||
placeholder="登录密码"
|
||||
@press-enter="submitAccount"
|
||||
<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' ? '切换到手机号登录' : '切换到扫码登录'"
|
||||
>
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
<QrcodeOutlined v-if="loginType !== 'scan'" />
|
||||
<MobileOutlined v-else />
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-form-item name="code">
|
||||
<div class="input-group">
|
||||
<a-input
|
||||
v-model:value="form.code"
|
||||
<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"
|
||||
allow-clear
|
||||
:maxlength="5"
|
||||
placeholder="验证码"
|
||||
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" />
|
||||
<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>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<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">
|
||||
<template v-else-if="loginType === 'sms'">
|
||||
<a-form-item name="phone">
|
||||
<a-input
|
||||
v-model:value="form.smsCode"
|
||||
v-model:value="form.phone"
|
||||
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>
|
||||
: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>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item>
|
||||
<a-button block size="large" type="primary" :loading="loading" @click="submitSms">
|
||||
{{ loading ? '登录中…' : '登录' }}
|
||||
<template v-else>
|
||||
<QrLogin @login-success="onQrLoginSuccess" @login-error="onQrLoginError" />
|
||||
</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-form-item>
|
||||
</template>
|
||||
</div>
|
||||
<a-button block size="large" type="primary" :loading="sendingSms" @click="sendSmsCode">
|
||||
立即发送
|
||||
</a-button>
|
||||
</a-modal>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -183,6 +199,7 @@ import { TEMPLATE_ID } from '@/config/setting'
|
||||
import { setToken } from '@/utils/token-util'
|
||||
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' })
|
||||
|
||||
const route = useRoute()
|
||||
@@ -387,10 +404,16 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: #111827;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
padding: 48px 16px;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user