修改了首页

This commit is contained in:
2026-03-05 13:32:48 +08:00
commit 2c80df8b07
322 changed files with 48063 additions and 0 deletions

32
app/pages/[...slug].vue Normal file
View File

@@ -0,0 +1,32 @@
<template>
<div class="mx-auto max-w-screen-md px-4 py-16">
<a-result status="404" title="页面建设中" sub-title="该页面暂未开放建议返回首页或联系我们">
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/')">返回首页</a-button>
<a-button @click="navigateTo('/contact')">联系我们</a-button>
</a-space>
</template>
</a-result>
<a-card class="mt-6" size="small">
<div class="text-sm text-gray-500">
当前路径<span class="font-mono">{{ path }}</span>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { usePageSeo } from '@/composables/usePageSeo'
const route = useRoute()
const path = computed(() => route.fullPath || '/')
usePageSeo({
title: '页面未开放',
description: '该页面暂未开放,可返回首页或进入联系我们/经营范围。',
path: route.path
})
</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>

401
app/pages/article/[id].vue Normal file
View File

@@ -0,0 +1,401 @@
<template>
<main class="article-page">
<section class="article-hero" :style="heroStyle">
<div class="article-hero-mask">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<a-breadcrumb class="article-breadcrumb">
<a-breadcrumb-item>
<NuxtLink to="/">首页</NuxtLink>
</a-breadcrumb-item>
<a-breadcrumb-item v-if="parentName">{{ parentName }}</a-breadcrumb-item>
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
</a-breadcrumb>
<div class="article-hero-title">{{ pageTitle }}</div>
<div class="article-hero-meta">
<a-tag v-if="typeof total === 'number'" color="green"> {{ total }} </a-tag>
<a-tag v-if="keywords" color="blue">关键词{{ keywords }}</a-tag>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2">
<a-input
v-model:value="keywordInput"
allow-clear
class="w-full sm:w-80"
placeholder="搜索标题/关键词"
@press-enter="applySearch"
/>
<a-button type="primary" @click="applySearch">搜索</a-button>
<a-button v-if="keywords" @click="clearSearch">清除</a-button>
</div>
</div>
</div>
</section>
<section class="mx-auto max-w-screen-xl px-4 py-10">
<a-card class="article-card" :bordered="false">
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
<a-result
v-else-if="!isValidNavigationId"
status="404"
title="栏目不存在"
sub-title="navigationId 无效或缺失"
>
<template #extra>
<a-space>
<a-button type="primary" @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="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<template v-else>
<a-empty v-if="!articles.length" description="暂无文章" />
<a-list v-else item-layout="vertical" size="large" :data-source="articles">
<template #renderItem="{ item, index }">
<a-list-item
:key="String(item.articleId ?? item.code ?? `${String(route.params.id)}-${index}`)"
class="article-item"
>
<template #extra>
<img
v-if="resolveArticleImage(item)"
class="article-cover"
:src="resolveArticleImage(item)"
:alt="resolveArticleTitle(item)"
loading="lazy"
@error="onImgError"
/>
</template>
<a-list-item-meta :description="resolveArticleOverview(item)">
<template #title>
<NuxtLink class="article-title" :to="resolveArticleLink(item)">
{{ resolveArticleTitle(item) }}
</NuxtLink>
</template>
</a-list-item-meta>
<div class="article-meta">
<a-tag v-if="item.categoryName" color="default">{{ item.categoryName }}</a-tag>
<a-tag v-if="item.author" color="blue">{{ item.author }}</a-tag>
<a-tag v-if="item.createTime" color="green">{{ item.createTime }}</a-tag>
<a-tag v-if="typeof item.actualViews === 'number'" color="default">
阅读 {{ item.actualViews }}
</a-tag>
<a-tag v-if="typeof item.virtualViews === 'number'" color="default">
热度 {{ item.virtualViews }}
</a-tag>
</div>
</a-list-item>
</template>
</a-list>
<div v-if="total > 0" class="mt-6 flex items-center justify-end">
<a-pagination
:current="page"
:page-size="limit"
:total="total"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
@change="onPageChange"
@show-size-change="onPageSizeChange"
/>
</div>
</template>
</a-card>
</section>
</main>
</template>
<script setup lang="ts">
import type { LocationQueryRaw } from 'vue-router'
import { pageCmsArticle } from '@/api/cms/cmsArticle'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
const route = useRoute()
const router = useRouter()
function parseNumberParam(raw: unknown): number {
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
return Number.isFinite(n) ? n : NaN
}
function parsePositiveInt(raw: unknown, fallback: number) {
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
if (!Number.isFinite(n)) return fallback
const i = Math.floor(n)
return i > 0 ? i : fallback
}
function parseQueryString(raw: unknown) {
const text = Array.isArray(raw) ? raw[0] : raw
return typeof text === 'string' ? text.trim() : ''
}
const navigationId = computed(() => parseNumberParam(route.params.id))
const isValidNavigationId = computed(() => Number.isFinite(navigationId.value) && navigationId.value > 0)
const page = computed(() => parsePositiveInt(route.query.page, 1))
const limit = computed(() => parsePositiveInt(route.query.limit, 10))
const keywords = computed(() => parseQueryString(route.query.q))
const keywordInput = ref(keywords.value)
watch(
() => keywords.value,
(v) => {
keywordInput.value = v
}
)
function updateQuery(next: LocationQueryRaw) {
// Keep list state shareable via URL.
router.replace({
path: route.path,
query: {
...route.query,
...next
}
})
}
function applySearch() {
updateQuery({ q: keywordInput.value?.trim() || undefined, page: 1 })
}
function clearSearch() {
keywordInput.value = ''
updateQuery({ q: undefined, page: 1 })
}
function onPageChange(nextPage: number) {
updateQuery({ page: nextPage })
}
function onPageSizeChange(_current: number, nextSize: number) {
updateQuery({ limit: nextSize, page: 1 })
}
const { data: navigation } = await useAsyncData<CmsNavigation | null>(
() => `cms-navigation-${String(route.params.id)}`,
async () => {
if (!isValidNavigationId.value) return null
return await getCmsNavigation(navigationId.value).catch(() => null)
},
{ watch: [navigationId] }
)
const {
data: articlePage,
pending,
error: loadError,
refresh
} = await useAsyncData<{ list: CmsArticle[]; count: number } | null>(
() => `cms-article-${String(route.params.id)}-${page.value}-${limit.value}-${keywords.value}`,
async () => {
if (!isValidNavigationId.value) return null
return await pageCmsArticle({
categoryId: navigationId.value,
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined
})
},
{ watch: [navigationId, page, limit, keywords] }
)
const articles = computed(() => articlePage.value?.list ?? [])
const total = computed(() => articlePage.value?.count ?? 0)
function pickString(obj: unknown, key: string) {
if (!obj || typeof obj !== 'object') return ''
const record = obj as Record<string, unknown>
const value = record[key]
return typeof value === 'string' ? value.trim() : ''
}
const pageTitle = computed(() => {
const name = pickString(navigation.value, 'title') || pickString(navigation.value, 'label')
if (name) return name
if (isValidNavigationId.value) return `栏目 ${navigationId.value}`
return '文章列表'
})
const parentName = computed(() => pickString(navigation.value, 'parentName'))
const heroStyle = computed(() => {
const banner = pickString(navigation.value, 'banner')
if (banner) return { backgroundImage: `url(${banner})` }
return {}
})
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 || ''
}
function resolveArticleOverview(a: CmsArticle) {
const text = String(a.overview || '').trim()
if (text) return text
const fallback = String(a.detail || '').trim()
return fallback.length > 120 ? `${fallback.slice(0, 120)}...` : fallback
}
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(() => {
if (keywords.value) return `${pageTitle.value} - 关键词「${keywords.value}」的文章列表`
return `${pageTitle.value} - 文章列表`
})
useSeoMeta({
title: seoTitle,
description: seoDescription,
ogTitle: seoTitle,
ogDescription: seoDescription,
ogType: 'website'
})
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-page {
background: #f4f6f8;
}
.article-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-hero-mask {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.92));
}
.article-breadcrumb {
color: rgba(0, 0, 0, 0.6);
}
.article-hero-title {
margin-top: 10px;
font-size: 30px;
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
line-height: 1.2;
}
.article-hero-meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.article-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
}
.article-item :deep(.ant-list-item-extra) {
margin-inline-start: 24px;
}
.article-title {
font-size: 18px;
font-weight: 800;
color: rgba(0, 0, 0, 0.88);
text-decoration: none;
}
.article-meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.article-cover {
width: 220px;
height: 140px;
object-fit: cover;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
@media (max-width: 640px) {
.article-item :deep(.ant-list-item-extra) {
margin-inline-start: 0;
margin-top: 12px;
width: 100%;
}
.article-cover {
width: 100%;
height: 200px;
}
}
</style>

View File

@@ -0,0 +1,347 @@
<template>
<div class="space-y-4">
<a-page-header title="账号信息" sub-title="基本资料与企业信息" />
<a-spin :spinning="loading" tip="加载中...">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="基本资料">
<div class="flex items-center gap-4">
<a-avatar :size="56" :src="avatarUrl">
<template v-if="!avatarUrl" #icon>
<UserOutlined />
</template>
</a-avatar>
<div class="min-w-0">
<div class="text-base font-semibold text-gray-900">
{{ user?.nickname || user?.username || '未命名用户' }}
</div>
<div class="text-gray-500">
{{ user?.phone || (user as any)?.mobile || user?.email || '' }}
</div>
</div>
</div>
<a-divider />
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="用户ID">{{ user?.userId ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="账号">{{ user?.username ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="昵称">{{ user?.nickname ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="手机号">{{ user?.phone || (user as any)?.mobile || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ user?.email ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="租户ID">{{ user?.tenantId ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="租户名称">{{ user?.tenantName ?? '-' }}</a-descriptions-item>
</a-descriptions>
<div class="mt-6 flex justify-end gap-2">
<a-button @click="reload" :loading="loading">刷新</a-button>
<a-button type="primary" :disabled="!user" @click="openEditUser">编辑</a-button>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="企业信息">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="企业ID">{{ company?.companyId ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="企业简称">{{ company?.shortName ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="企业全称">{{ company?.companyName ?? company?.tenantName ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="绑定域名">{{ company?.domain ?? company?.freeDomain ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ company?.phone ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ company?.email ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="地址">
{{ companyAddress || '-' }}
</a-descriptions-item>
<a-descriptions-item label="实名认证">
<a-tag v-if="company?.authentication" color="green">已认证</a-tag>
<a-tag v-else color="default">未认证</a-tag>
</a-descriptions-item>
</a-descriptions>
<div class="mt-6 flex justify-end gap-2">
<a-button @click="reload" :loading="loading">刷新</a-button>
<a-button type="primary" :disabled="!company" @click="openEditCompany">编辑</a-button>
</div>
</a-card>
</a-col>
</a-row>
</a-spin>
<a-modal
v-model:open="editUserOpen"
title="编辑基本资料"
:confirm-loading="savingUser"
ok-text="保存"
cancel-text="取消"
@ok="submitUser"
>
<a-form ref="userFormRef" layout="vertical" :model="userForm" :rules="userRules">
<a-form-item label="昵称" name="nickname">
<a-input v-model:value="userForm.nickname" placeholder="请输入昵称" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="userForm.email" placeholder="例如name@example.com" />
</a-form-item>
<a-form-item label="头像 URL" name="avatarUrl">
<a-input v-model:value="userForm.avatarUrl" placeholder="https://..." />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="editCompanyOpen"
title="编辑企业信息"
:confirm-loading="savingCompany"
ok-text="保存"
cancel-text="取消"
@ok="submitCompany"
>
<a-form ref="companyFormRef" layout="vertical" :model="companyForm" :rules="companyRules">
<a-form-item label="企业简称" name="shortName">
<a-input v-model:value="companyForm.shortName" placeholder="例如:某某科技" />
</a-form-item>
<a-form-item label="企业全称" name="companyName">
<a-input v-model:value="companyForm.companyName" placeholder="例如:某某科技有限公司" />
</a-form-item>
<a-form-item label="绑定域名" name="domain">
<a-input v-model:value="companyForm.domain" placeholder="例如example.com" />
</a-form-item>
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="companyForm.phone" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="companyForm.email" placeholder="例如service@example.com" />
</a-form-item>
<a-form-item label="地址" name="address">
<a-textarea v-model:value="companyForm.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
<a-form-item label="发票抬头" name="invoiceHeader">
<a-input v-model:value="companyForm.invoiceHeader" placeholder="用于开票" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import { UserOutlined } from '@ant-design/icons-vue'
import { getTenantInfo, getUserInfo, updateLoginUser } from '@/api/layout'
import { updateCompany } from '@/api/system/company'
import type { Company } from '@/api/system/company/model'
import type { User } from '@/api/system/user/model'
definePageMeta({ layout: 'console' })
const loading = ref(false)
const savingUser = ref(false)
const savingCompany = ref(false)
const user = ref<User | null>(null)
const company = ref<Company | null>(null)
const avatarUrl = computed(() => {
const candidate =
user.value?.avatarUrl ||
user.value?.avatar ||
user.value?.merchantAvatar ||
user.value?.logo ||
''
if (typeof candidate !== 'string') return ''
const normalized = candidate.trim()
if (!normalized || normalized === 'null' || normalized === 'undefined') return ''
return normalized
})
const companyAddress = computed(() => {
const parts = [company.value?.province, company.value?.city, company.value?.region, company.value?.address]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean)
return parts.join(' ')
})
async function load() {
loading.value = true
try {
const [uRes, cRes] = await Promise.allSettled([getUserInfo(), getTenantInfo()])
if (uRes.status === 'fulfilled') {
user.value = uRes.value
} else {
console.error(uRes.reason)
message.error(uRes.reason instanceof Error ? uRes.reason.message : '获取用户信息失败')
}
if (cRes.status === 'fulfilled') {
company.value = cRes.value
} else {
console.error(cRes.reason)
message.error(cRes.reason instanceof Error ? cRes.reason.message : '获取企业信息失败')
}
} finally {
loading.value = false
}
}
async function reload() {
await load()
}
const editUserOpen = ref(false)
const userFormRef = ref<FormInstance>()
const userForm = reactive<{ nickname?: string; email?: string; avatarUrl?: string }>({
nickname: '',
email: '',
avatarUrl: ''
})
const userRules = reactive({
nickname: [{ required: true, message: '请输入昵称', type: 'string' }],
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }]
})
function openEditUser() {
if (!user.value) return
userForm.nickname = user.value.nickname ?? ''
userForm.email = user.value.email ?? ''
userForm.avatarUrl = user.value.avatarUrl ?? avatarUrl.value ?? ''
editUserOpen.value = true
}
async function submitUser() {
if (!user.value) return
try {
await userFormRef.value?.validate()
} catch {
return
}
const nickname = (userForm.nickname ?? '').trim()
if (!nickname) {
message.error('请输入昵称')
return
}
const email = (userForm.email ?? '').trim()
const avatar = (userForm.avatarUrl ?? '').trim()
savingUser.value = true
try {
await updateLoginUser({
userId: user.value.userId,
nickname,
email: email || undefined,
avatarUrl: avatar || undefined
} as User)
message.success('保存成功')
editUserOpen.value = false
await load()
} catch (e: unknown) {
console.error(e)
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingUser.value = false
}
}
const editCompanyOpen = ref(false)
const companyFormRef = ref<FormInstance>()
const companyForm = reactive<{
companyId?: number
shortName?: string
companyName?: string
domain?: string
phone?: string
email?: string
address?: string
invoiceHeader?: string
}>({
companyId: undefined,
shortName: '',
companyName: '',
domain: '',
phone: '',
email: '',
address: '',
invoiceHeader: ''
})
const companyRules = reactive({
companyName: [{ required: true, message: '请输入企业全称', type: 'string' }],
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
phone: [
{
validator: (_rule: unknown, value: unknown) => {
const normalized = typeof value === 'string' ? value.trim() : ''
if (!normalized) return Promise.resolve()
const mobileReg = /^1[3-9]\d{9}$/
if (mobileReg.test(normalized)) return Promise.resolve()
return Promise.reject(new Error('手机号格式不正确'))
},
trigger: 'blur'
}
]
})
function openEditCompany() {
if (!company.value) return
companyForm.companyId = company.value.companyId
companyForm.shortName = company.value.shortName ?? ''
companyForm.companyName = company.value.companyName ?? company.value.tenantName ?? ''
companyForm.domain = company.value.domain ?? ''
companyForm.phone = company.value.phone ?? ''
companyForm.email = company.value.email ?? ''
companyForm.address = company.value.address ?? ''
companyForm.invoiceHeader = company.value.invoiceHeader ?? ''
editCompanyOpen.value = true
}
async function submitCompany() {
if (!company.value) return
try {
await companyFormRef.value?.validate()
} catch {
return
}
if (!companyForm.companyId) {
message.error('企业ID缺失无法保存')
return
}
const companyName = (companyForm.companyName ?? '').trim()
if (!companyName) {
message.error('请输入企业全称')
return
}
savingCompany.value = true
try {
const domain = (companyForm.domain ?? '').trim()
const phone = (companyForm.phone ?? '').trim()
const email = (companyForm.email ?? '').trim()
const address = (companyForm.address ?? '').trim()
const invoiceHeader = (companyForm.invoiceHeader ?? '').trim()
await updateCompany({
companyId: companyForm.companyId,
shortName: (companyForm.shortName ?? '').trim() || undefined,
companyName,
domain: domain || undefined,
phone: phone || undefined,
email: email || undefined,
address: address || undefined,
invoiceHeader: invoiceHeader || undefined
} as Company)
message.success('保存成功')
editCompanyOpen.value = false
await load()
} catch (e: unknown) {
console.error(e)
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingCompany.value = false
}
}
onMounted(load)
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,456 @@
<template>
<div class="space-y-4">
<a-page-header title="实名认证" sub-title="企业/个人认证与资料提交">
<template #extra>
<a-space>
<a-tag v-if="current" :color="statusTagColor">{{ statusText }}</a-tag>
</a-space>
</template>
</a-page-header>
<a-card :bordered="false" class="card">
<a-alert show-icon :type="statusAlertType" :message="statusMessage" :description="statusDescription" />
<a-divider />
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules" :disabled="formDisabled">
<a-form-item label="认证类型" name="type">
<a-radio-group v-model:value="form.type">
<a-radio :value="0">个人</a-radio>
<a-radio :value="1">企业</a-radio>
</a-radio-group>
</a-form-item>
<template v-if="form.type === 0">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-form-item label="真实姓名" name="realName">
<a-input v-model:value="form.realName" placeholder="请输入真实姓名" />
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="证件号码" name="idCard">
<a-input v-model:value="form.idCard" placeholder="请输入身份证/证件号码" />
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="手机号" name="phone">
<a-input v-model:value="form.phone" placeholder="用于联系(选填)" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-form-item label="身份证正面" name="sfz1">
<a-upload
v-model:file-list="sfz1List"
:disabled="formDisabled"
:max-count="1"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="uploadSfz1"
@remove="() => (form.sfz1 = '')"
>
<div>上传</div>
</a-upload>
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="身份证反面" name="sfz2">
<a-upload
v-model:file-list="sfz2List"
:disabled="formDisabled"
:max-count="1"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="uploadSfz2"
@remove="() => (form.sfz2 = '')"
>
<div>上传</div>
</a-upload>
</a-form-item>
</a-col>
</a-row>
</template>
<template v-else>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-form-item label="主体名称" name="name">
<a-input v-model:value="form.name" placeholder="例如:某某科技有限公司" />
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="营业执照号码" name="zzCode">
<a-input v-model:value="form.zzCode" placeholder="请输入统一社会信用代码/执照号" />
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="联系人" name="realName">
<a-input v-model:value="form.realName" placeholder="请输入联系人姓名(选填)" />
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="form.phone" placeholder="用于联系(选填)" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="营业执照" name="zzImg">
<a-upload
v-model:file-list="zzImgList"
:disabled="formDisabled"
:max-count="1"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="uploadZzImg"
@remove="() => (form.zzImg = '')"
>
<div>上传</div>
</a-upload>
</a-form-item>
</template>
</a-form>
<div class="mt-4 flex justify-end gap-2">
<!-- <a-popconfirm-->
<!-- v-if="current?.id"-->
<!-- :title="withdrawConfirmTitle"-->
<!-- ok-text="撤回"-->
<!-- cancel-text="取消"-->
<!-- @confirm="withdraw"-->
<!-- >-->
<!-- <a-button danger :loading="submitting">撤回</a-button>-->
<!-- </a-popconfirm>-->
<a-button @click="resetForm" :disabled="submitting || formDisabled">重置</a-button>
<a-button
type="primary"
:loading="submitting"
:disabled="formDisabled"
@click="submit"
>
{{ current?.id ? '更新' : '提交' }}
</a-button>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import { getUserInfo } from '@/api/layout'
import { addUserVerify, listUserVerify, removeUserVerify, updateUserVerify } from '@/api/system/userVerify'
import { uploadFile } from '@/api/system/file'
import type { UploadFile } from 'ant-design-vue'
import type { UserVerify } from '@/api/system/userVerify/model'
definePageMeta({ layout: 'console' })
type UploadRequestOption = {
file?: File
onSuccess?: (body: unknown, file: File) => void
onError?: (err: unknown) => void
}
const loading = ref(false)
const submitting = ref(false)
const current = ref<UserVerify | null>(null)
const userId = ref<number | null>(null)
const status = computed(() => current.value?.status)
const isPending = computed(() => status.value === 0)
const isApproved = computed(() => status.value === 1)
const isRejected = computed(() => status.value === 2 || status.value === 30)
const formDisabled = computed(() => !!current.value && (isPending.value || isApproved.value))
const statusText = computed(() => {
if (isPending.value) return '待审核'
if (isApproved.value) return '审核通过'
if (isRejected.value) return '已驳回'
if (status.value === undefined || status.value === null) return '未知状态'
return `未知状态(${status.value}`
})
const statusTagColor = computed(() => {
if (isPending.value) return 'gold'
if (isApproved.value) return 'green'
if (isRejected.value) return 'red'
return 'default'
})
const statusAlertType = computed(() => {
if (!current.value) return 'info'
if (isPending.value) return 'warning'
if (isApproved.value) return 'success'
if (isRejected.value) return 'error'
return 'info'
})
const statusMessage = computed(() => {
if (!current.value) return '未提交认证资料'
const prefix = isApproved.value ? '已通过实名认证' : isRejected.value ? '实名认证已驳回' : '已提交认证资料'
return `${prefix}ID: ${current.value.id ?? '-'}`
})
const statusDescription = computed(() => {
if (!current.value) return '提交后将生成一条实名认证记录,你可随时更新或撤回。'
const time = current.value.createTime ?? current.value.updateTime ?? '-'
const reason = (current.value.comments || '').trim()
if (isApproved.value) return `审核通过时间:${time}(审核通过后不可编辑;如需变更请联系管理员)`
if (isPending.value) return `提交时间:${time}(审核中不可编辑;如需修改请先撤回后重新提交)`
if (isRejected.value) return `驳回时间:${time}${reason ? `(原因:${reason}` : ''}(请修改资料后重新提交)`
return `提交时间:${time}`
})
const withdrawConfirmTitle = computed(() => {
if (isApproved.value) return '当前已审核通过,确定撤回(删除)实名认证记录?'
if (isPending.value) return '当前正在审核中,撤回后可修改并重新提交,确定撤回?'
if (isRejected.value) return '当前已驳回,撤回后可重新提交,确定撤回?'
return '确定撤回(删除)当前实名认证记录?'
})
const formRef = ref<FormInstance>()
const form = reactive<UserVerify>({
type: 0,
name: '',
zzCode: '',
zzImg: '',
realName: '',
phone: '',
idCard: '',
sfz1: '',
sfz2: '',
status: 0,
comments: ''
})
const rules = computed(() => {
if (form.type === 1) {
return {
type: [{ required: true, type: 'number', message: '请选择认证类型' }],
name: [{ required: true, type: 'string', message: '请输入主体名称' }],
zzCode: [{ required: true, type: 'string', message: '请输入营业执照号码' }],
zzImg: [{ required: true, type: 'string', message: '请上传营业执照' }]
}
}
return {
type: [{ required: true, type: 'number', message: '请选择认证类型' }],
realName: [{ required: true, type: 'string', message: '请输入真实姓名' }],
idCard: [{ required: true, type: 'string', message: '请输入证件号码' }],
sfz1: [{ required: true, type: 'string', message: '请上传身份证正面' }],
sfz2: [{ required: true, type: 'string', message: '请上传身份证反面' }]
}
})
function applyCurrentToForm(next: UserVerify | null) {
current.value = next
form.id = next?.id
form.type = next?.type ?? 0
form.name = next?.name ?? ''
form.zzCode = next?.zzCode ?? ''
form.zzImg = next?.zzImg ?? ''
form.realName = next?.realName ?? ''
form.phone = next?.phone ?? ''
form.idCard = next?.idCard ?? ''
form.sfz1 = next?.sfz1 ?? ''
form.sfz2 = next?.sfz2 ?? ''
form.status = next?.status ?? 0
form.comments = next?.comments ?? ''
syncFileLists()
}
const sfz1List = ref<UploadFile[]>([])
const sfz2List = ref<UploadFile[]>([])
const zzImgList = ref<UploadFile[]>([])
function toFileList(url: string): UploadFile[] {
const normalized = typeof url === 'string' ? url.trim() : ''
if (!normalized) return []
return [
{
uid: normalized,
name: normalized.split('/').slice(-1)[0] || 'image',
status: 'done',
url: normalized
} as UploadFile
]
}
function syncFileLists() {
sfz1List.value = toFileList(form.sfz1 ?? '')
sfz2List.value = toFileList(form.sfz2 ?? '')
zzImgList.value = toFileList(form.zzImg ?? '')
}
function beforeUpload(file: File) {
const isImage = file.type.startsWith('image/')
if (!isImage) {
message.error('仅支持上传图片文件')
return false
}
const maxSizeMb = 5
if (file.size > maxSizeMb * 1024 * 1024) {
message.error(`图片大小不能超过 ${maxSizeMb}MB`)
return false
}
return true
}
async function doUpload(
option: UploadRequestOption,
setUrl: (url: string) => void,
setList: (list: UploadFile[]) => void
) {
const rawFile = option.file
if (!rawFile) return
try {
const record = await uploadFile(rawFile)
const url = (record?.url || record?.downloadUrl || '').trim()
if (!url) throw new Error('上传成功但未返回文件地址')
setUrl(url)
setList(
toFileList(url).map((f) => ({
...f,
uid: String(rawFile.name) + '-' + String(Date.now())
})) as UploadFile[]
)
option.onSuccess?.(record, rawFile)
} catch (e) {
option.onError?.(e)
message.error(e instanceof Error ? e.message : '上传失败')
}
}
function uploadSfz1(option: UploadRequestOption) {
return doUpload(
option,
(url) => (form.sfz1 = url),
(list) => (sfz1List.value = list)
)
}
function uploadSfz2(option: UploadRequestOption) {
return doUpload(
option,
(url) => (form.sfz2 = url),
(list) => (sfz2List.value = list)
)
}
function uploadZzImg(option: UploadRequestOption) {
return doUpload(
option,
(url) => (form.zzImg = url),
(list) => (zzImgList.value = list)
)
}
async function load() {
loading.value = true
try {
const user = await getUserInfo()
userId.value = user.userId ?? null
} catch {
userId.value = null
}
try {
if (!userId.value) {
applyCurrentToForm(null)
return
}
const list = await listUserVerify({ userId: userId.value })
const mine = Array.isArray(list)
? [...list].sort((a, b) => (Number(b.id ?? 0) - Number(a.id ?? 0)))[0]
: undefined
applyCurrentToForm(mine ?? null)
} catch (e) {
applyCurrentToForm(null)
message.error(e instanceof Error ? e.message : '加载实名认证信息失败')
} finally {
loading.value = false
}
}
async function reload() {
await load()
}
function resetForm() {
applyCurrentToForm(current.value)
formRef.value?.clearValidate()
}
async function submit() {
if (formDisabled.value) {
message.warning(isApproved.value ? '审核通过后不可编辑' : '审核中不可编辑')
return
}
try {
await formRef.value?.validate()
} catch {
return
}
submitting.value = true
try {
const payload: UserVerify = {
id: form.id,
userId: userId.value ?? form.userId,
type: form.type,
name: form.name,
zzCode: form.zzCode,
zzImg: form.zzImg,
realName: form.realName,
phone: form.phone,
idCard: form.idCard,
sfz1: form.sfz1,
sfz2: form.sfz2,
status: 0,
comments: form.comments
}
if (current.value?.id) {
await updateUserVerify(payload)
message.success('认证资料已更新')
} else {
await addUserVerify(payload)
message.success('认证资料已提交')
}
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '提交失败')
} finally {
submitting.value = false
}
}
async function withdraw() {
if (!current.value?.id) return
submitting.value = true
try {
await removeUserVerify(current.value.id)
message.success('已撤回')
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '撤回失败')
} finally {
submitting.value = false
}
}
onMounted(async () => {
await load()
})
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,442 @@
<template>
<div class="space-y-4">
<a-page-header title="成员管理" sub-title="成员邀请角色与权限">
<template #extra>
<a-space>
<a-input
v-model:value="keywords"
allow-clear
placeholder="搜索账号/昵称/手机号"
class="w-64"
@press-enter="doSearch"
/>
<a-button :loading="loading" @click="reload">刷新</a-button>
<a-button type="primary" @click="openInvite">邀请成员</a-button>
</a-space>
</template>
</a-page-header>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="成员配额">
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="成员上限">
{{ company?.members ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="当前人数">
{{ company?.users ?? '-' }}
</a-descriptions-item>
</a-descriptions>
<div class="mt-4 text-sm text-gray-500">
成员数据来自系统用户租户维度可进行邀请禁用重置密码与角色设置
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="快速操作">
<a-space wrap>
<a-button @click="openInvite">邀请成员</a-button>
<a-button @click="reload">刷新列表</a-button>
</a-space>
</a-card>
</a-col>
</a-row>
<a-card :bordered="false" class="card">
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
<a-table
:data-source="list"
:loading="loading"
:pagination="false"
size="middle"
:row-key="(r: any) => r.userId ?? r.username"
>
<a-table-column title="ID" data-index="userId" width="90" />
<a-table-column title="账号" data-index="username" width="180" />
<a-table-column title="昵称" data-index="nickname" width="160" />
<a-table-column title="手机号" data-index="phone" width="140" />
<a-table-column title="角色" key="roleName" width="160">
<template #default="{ record }">
<span>{{ resolveRoleName(record) }}</span>
</template>
</a-table-column>
<a-table-column title="状态" key="status" width="120">
<template #default="{ record }">
<a-tag v-if="record.status === 0 || record.status === undefined" color="green">正常</a-tag>
<a-tag v-else color="default">冻结</a-tag>
</template>
</a-table-column>
<a-table-column title="创建时间" data-index="createTime" width="180" />
<a-table-column title="操作" key="actions" width="260" fixed="right">
<template #default="{ record }">
<a-space>
<a-button size="small" @click="openRole(record)" :disabled="!record.userId">设置角色</a-button>
<a-button size="small" @click="openReset(record)" :disabled="!record.userId">重置密码</a-button>
<a-button
size="small"
:loading="busyUserId === record.userId"
@click="toggleStatus(record)"
:disabled="!record.userId"
>
{{ record.status === 1 ? '解冻' : '冻结' }}
</a-button>
<a-popconfirm
title="确定删除该成员"
ok-text="删除"
cancel-text="取消"
@confirm="remove(record)"
>
<a-button size="small" danger :disabled="!record.userId">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table-column>
</a-table>
<div class="mt-4 flex items-center justify-end">
<a-pagination
:current="page"
:page-size="limit"
:total="total"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
@change="onPageChange"
@show-size-change="onPageSizeChange"
/>
</div>
</a-card>
<a-modal
v-model:open="inviteOpen"
title="邀请成员"
ok-text="创建账号"
cancel-text="取消"
:confirm-loading="inviting"
@ok="submitInvite"
>
<a-form ref="inviteFormRef" layout="vertical" :model="inviteForm" :rules="inviteRules">
<a-form-item label="账号" name="username">
<a-input v-model:value="inviteForm.username" placeholder="例如tom / tom@example.com" />
</a-form-item>
<a-form-item label="昵称" name="nickname">
<a-input v-model:value="inviteForm.nickname" placeholder="例如Tom" />
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input v-model:value="inviteForm.phone" placeholder="例如13800000000" />
</a-form-item>
<a-form-item label="角色" name="roleId">
<a-select
v-model:value="inviteForm.roleId"
placeholder="请选择角色"
allow-clear
:options="roleOptions"
/>
</a-form-item>
<a-form-item label="初始密码" name="password">
<a-input-password v-model:value="inviteForm.password" placeholder="请输入初始密码" />
</a-form-item>
<a-form-item label="确认密码" name="password2">
<a-input-password v-model:value="inviteForm.password2" placeholder="再次输入密码" />
</a-form-item>
</a-form>
<a-alert
class="mt-2"
type="info"
show-icon
message="创建后可在本页进行冻结/解冻重置密码与角色设置"
/>
</a-modal>
<a-modal
v-model:open="roleOpen"
title="设置角色"
ok-text="保存"
cancel-text="取消"
:confirm-loading="savingRole"
@ok="submitRole"
>
<a-form layout="vertical">
<a-form-item label="成员">
<a-input :value="selectedUser?.nickname || selectedUser?.username || ''" disabled />
</a-form-item>
<a-form-item label="角色">
<a-select v-model:value="selectedRoleId" placeholder="请选择角色" :options="roleOptions" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="resetOpen"
title="重置密码"
ok-text="确认重置"
cancel-text="取消"
:confirm-loading="resetting"
@ok="submitReset"
>
<a-form layout="vertical">
<a-form-item label="成员">
<a-input :value="selectedUser?.nickname || selectedUser?.username || ''" disabled />
</a-form-item>
<a-form-item label="新密码">
<a-input-password v-model:value="resetPassword" placeholder="请输入新密码" />
</a-form-item>
</a-form>
<a-alert class="mt-2" type="warning" show-icon message="重置后请尽快通知成员修改密码" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import { getTenantInfo } from '@/api/layout'
import { listRoles } from '@/api/system/role'
import { addUser, pageUsers, removeUser, updateUserPassword, updateUserStatus } from '@/api/system/user'
import { addUserRole, listUserRole, updateUserRole } from '@/api/system/userRole'
import type { Company } from '@/api/system/company/model'
import type { Role } from '@/api/system/role/model'
import type { User } from '@/api/system/user/model'
definePageMeta({ layout: 'console' })
const loading = ref(false)
const error = ref<string>('')
const company = ref<Company | null>(null)
const roles = ref<Role[]>([])
const list = ref<User[]>([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
const keywords = ref('')
const roleOptions = computed(() =>
roles.value.map((r) => ({ label: r.roleName ?? String(r.roleId ?? ''), value: r.roleId }))
)
function resolveRoleName(user: User) {
const direct = typeof user.roleName === 'string' ? user.roleName.trim() : ''
if (direct) return direct
const hit = roles.value.find((r) => r.roleId === user.roleId)
return hit?.roleName ?? '-'
}
async function loadCompany() {
try {
company.value = await getTenantInfo()
} catch {
// ignore
}
}
async function loadRolesOnce() {
if (roles.value.length) return
try {
roles.value = await listRoles()
} catch {
roles.value = []
}
}
async function loadMembers() {
loading.value = true
error.value = ''
try {
const res = await pageUsers({
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined
})
list.value = res?.list ?? []
total.value = res?.count ?? 0
} catch (e) {
error.value = e instanceof Error ? e.message : '成员列表加载失败'
} finally {
loading.value = false
}
}
async function reload() {
await Promise.all([loadCompany(), loadRolesOnce(), loadMembers()])
}
function doSearch() {
page.value = 1
loadMembers()
}
function onPageChange(nextPage: number) {
page.value = nextPage
loadMembers()
}
function onPageSizeChange(_current: number, nextSize: number) {
limit.value = nextSize
page.value = 1
loadMembers()
}
onMounted(async () => {
await reload()
})
const busyUserId = ref<number | null>(null)
async function toggleStatus(user: User) {
if (!user.userId) return
const next = user.status === 1 ? 0 : 1
busyUserId.value = user.userId
try {
await updateUserStatus(user.userId, next)
message.success(next === 0 ? '已解冻' : '已冻结')
await loadMembers()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
busyUserId.value = null
}
}
async function remove(user: User) {
if (!user.userId) return
busyUserId.value = user.userId
try {
await removeUser(user.userId)
message.success('已删除')
await loadMembers()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
} finally {
busyUserId.value = null
}
}
const inviteOpen = ref(false)
const inviting = ref(false)
const inviteFormRef = ref<FormInstance>()
const inviteForm = reactive<{ username: string; nickname: string; phone: string; roleId?: number; password: string; password2: string }>({
username: '',
nickname: '',
phone: '',
roleId: undefined,
password: '',
password2: ''
})
const inviteRules = reactive({
username: [{ required: true, type: 'string', message: '请输入账号' }],
nickname: [{ required: true, type: 'string', message: '请输入昵称' }],
password: [{ required: true, type: 'string', message: '请输入初始密码' }],
password2: [{ required: true, type: 'string', message: '请再次输入密码' }]
})
function openInvite() {
inviteForm.username = ''
inviteForm.nickname = ''
inviteForm.phone = ''
inviteForm.roleId = undefined
inviteForm.password = ''
inviteForm.password2 = ''
inviteOpen.value = true
}
async function submitInvite() {
try {
await inviteFormRef.value?.validate()
} catch {
return
}
if (inviteForm.password !== inviteForm.password2) {
message.error('两次输入的密码不一致')
return
}
inviting.value = true
try {
await addUser({
username: inviteForm.username.trim(),
nickname: inviteForm.nickname.trim(),
phone: inviteForm.phone.trim() || undefined,
password: inviteForm.password,
password2: inviteForm.password2,
roleId: inviteForm.roleId
})
message.success('成员已创建')
inviteOpen.value = false
await loadMembers()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
inviting.value = false
}
}
const roleOpen = ref(false)
const savingRole = ref(false)
const selectedUser = ref<User | null>(null)
const selectedRoleId = ref<number | undefined>(undefined)
function openRole(user: User) {
selectedUser.value = user
selectedRoleId.value = user.roleId
roleOpen.value = true
}
async function submitRole() {
if (!selectedUser.value?.userId) return
if (!selectedRoleId.value) {
message.error('请选择角色')
return
}
savingRole.value = true
try {
const mappings = await listUserRole({ userId: selectedUser.value.userId })
const first = Array.isArray(mappings) ? mappings[0] : undefined
if (first?.id) {
await updateUserRole({ ...first, roleId: selectedRoleId.value })
} else {
await addUserRole({ userId: selectedUser.value.userId, roleId: selectedRoleId.value })
}
message.success('角色已更新')
roleOpen.value = false
await loadMembers()
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
} finally {
savingRole.value = false
}
}
const resetOpen = ref(false)
const resetting = ref(false)
const resetPassword = ref('')
function openReset(user: User) {
selectedUser.value = user
resetPassword.value = ''
resetOpen.value = true
}
async function submitReset() {
if (!selectedUser.value?.userId) return
const pwd = resetPassword.value.trim()
if (!pwd) {
message.error('请输入新密码')
return
}
resetting.value = true
try {
await updateUserPassword(selectedUser.value.userId, pwd)
message.success('密码已重置')
resetOpen.value = false
} catch (e) {
message.error(e instanceof Error ? e.message : '重置失败')
} finally {
resetting.value = false
}
}
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,134 @@
<template>
<div class="space-y-4">
<a-page-header title="账号安全" sub-title="密码登录设备与安全设置">
<template #extra>
<a-space>
<a-button danger @click="logout">退出登录</a-button>
</a-space>
</template>
</a-page-header>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="修改密码">
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules">
<a-form-item label="原密码" name="oldPassword">
<a-input-password v-model:value="form.oldPassword" placeholder="请输入原密码" />
</a-form-item>
<a-form-item label="新密码" name="password">
<a-input-password v-model:value="form.password" placeholder="请输入新密码(至少 6 位)" />
</a-form-item>
<a-form-item label="确认新密码" name="password2">
<a-input-password v-model:value="form.password2" placeholder="再次输入新密码" />
</a-form-item>
</a-form>
<div class="mt-2 flex justify-end gap-2">
<a-button @click="resetForm" :disabled="pending">重置</a-button>
<a-button type="primary" :loading="pending" @click="submit">保存</a-button>
</div>
<a-alert
class="mt-4"
show-icon
type="info"
message="修改密码后建议重新登录,以确保所有会话状态一致。"
/>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="安全建议">
<a-list size="small" bordered :data-source="tips">
<template #renderItem="{ item }">
<a-list-item>{{ item }}</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import { updatePassword } from '@/api/layout'
import { removeToken } from '@/utils/token-util'
import { clearAuthz } from '@/utils/permission'
definePageMeta({ layout: 'console' })
const pending = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<{ oldPassword: string; password: string; password2: string }>({
oldPassword: '',
password: '',
password2: ''
})
const rules = reactive({
oldPassword: [{ required: true, type: 'string', message: '请输入原密码' }],
password: [
{ required: true, type: 'string', message: '请输入新密码' },
{ min: 6, type: 'string', message: '新密码至少 6 位', trigger: 'blur' }
],
password2: [{ required: true, type: 'string', message: '请再次输入新密码' }]
})
const tips = [
'定期修改密码,避免与其他平台重复使用。',
'优先使用更长的随机密码。',
'不要将账号/密码分享给他人。',
'如怀疑账号被盗用,请立即修改密码并退出登录。'
]
function resetForm() {
form.oldPassword = ''
form.password = ''
form.password2 = ''
formRef.value?.clearValidate()
}
async function submit() {
try {
await formRef.value?.validate()
} catch {
return
}
if (form.password !== form.password2) {
message.error('两次输入的新密码不一致')
return
}
pending.value = true
try {
await updatePassword({ oldPassword: form.oldPassword, password: form.password })
message.success('密码修改成功')
resetForm()
} catch (e) {
message.error(e instanceof Error ? e.message : '密码修改失败')
} finally {
pending.value = false
}
}
function logout() {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch {
// ignore
}
clearAuthz()
navigateTo('/login')
}
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<div class="space-y-4">
<a-page-header title="优惠券" sub-title="可用优惠与使用记录" />
<a-card :bordered="false" class="card">
<a-empty description="待接入:优惠券列表" />
</a-card>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: 'console' })
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

362
app/pages/console/index.vue Normal file
View File

@@ -0,0 +1,362 @@
<template>
<div class="space-y-4">
<a-page-header title="租户管理" sub-title="租户创建查询与维护" :ghost="false" class="page-header">
<template #extra>
<a-space>
<a-input
v-model:value="keywords"
allow-clear
placeholder="搜索租户名称/租户ID"
class="w-64"
@press-enter="doSearch"
/>
<a-button :loading="loading" @click="reload">刷新</a-button>
<a-button type="primary" @click="openCreate">创建</a-button>
</a-space>
</template>
</a-page-header>
<a-card :bordered="false" class="card">
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
<a-table
:data-source="list"
:loading="loading"
:pagination="false"
size="middle"
:row-key="(r: any) => r.tenantId ?? r.websiteId ?? r.appId ?? r.websiteName ?? r.tenantName"
>
<a-table-column title="租户ID" data-index="tenantId" width="90" />
<a-table-column title="租户名称" key="tenantName">
<template #default="{ record }">
<div class="flex items-center gap-2 min-w-0">
<a-avatar :src="record.websiteLogo || record.websiteIcon || record.logo" :size="22" shape="square">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="truncate">{{ record.websiteName || record.tenantName || '-' }}</span>
</div>
</template>
</a-table-column>
<a-table-column title="状态" key="status">
<template #default="{ record }">
<a-tag :color="statusColor(record.status)">
{{ statusText(record.status, record.statusText) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="创建时间" data-index="createTime" />
<a-table-column title="操作" key="actions" width="260" fixed="right">
<template #default="{ record }">
<a-space>
<a-button size="small" @click="openEdit(record)">详情</a-button>
<!-- <a-button size="small" @click="openReset(record)" :disabled="!record.tenantId">重置密码</a-button>-->
<!-- <a-popconfirm-->
<!-- title="确定删除该租户"-->
<!-- ok-text="删除"-->
<!-- cancel-text="取消"-->
<!-- @confirm="remove(record)"-->
<!-- >-->
<!-- <a-button size="small" danger :loading="busyTenantId === record.tenantId" :disabled="!record.tenantId">删除</a-button>-->
<!-- </a-popconfirm>-->
</a-space>
</template>
</a-table-column>
</a-table>
<div class="mt-4 flex items-center justify-end">
<a-pagination
:current="page"
:page-size="limit"
:total="total"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
@change="onPageChange"
@show-size-change="onPageSizeChange"
/>
</div>
</a-card>
<a-modal
v-model:open="editOpen"
:title="editTitle"
ok-text="保存"
cancel-text="取消"
:confirm-loading="saving"
@ok="submitEdit"
>
<a-form ref="editFormRef" layout="vertical" :model="editForm" :rules="editRules">
<a-form-item v-if="editForm.tenantId" label="租户ID">
<a-input :value="String(editForm.tenantId ?? '')" disabled />
</a-form-item>
<a-form-item label="租户名称" name="tenantName">
<a-input v-model:value="editForm.tenantName" placeholder="例如某某科技有限公司" />
</a-form-item>
<a-form-item label="企业名称" name="companyName">
<a-input v-model:value="editForm.companyName" placeholder="例如某某科技有限公司" />
</a-form-item>
<a-form-item label="Logo" name="logo">
<a-input v-model:value="editForm.logo" placeholder="https://..." />
</a-form-item>
<a-form-item label="应用秘钥" name="appSecret">
<a-input-password v-model:value="editForm.appSecret" placeholder="appSecret可选" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-select
v-model:value="editForm.status"
placeholder="请选择"
:options="[
{ label: '正常', value: 0 },
{ label: '禁用', value: 1 }
]"
/>
</a-form-item>
<a-form-item label="备注" name="comments">
<a-textarea v-model:value="editForm.comments" :rows="3" placeholder="备注(可选)" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="resetOpen"
title="重置租户密码"
ok-text="确认重置"
cancel-text="取消"
:confirm-loading="resetting"
@ok="submitReset"
>
<a-form layout="vertical">
<a-form-item label="租户">
<a-input :value="selectedTenant?.tenantName || ''" disabled />
</a-form-item>
<a-form-item label="新密码">
<a-input-password v-model:value="resetPassword" placeholder="请输入新密码" />
</a-form-item>
</a-form>
<a-alert class="mt-2" type="warning" show-icon message="重置后请尽快通知租户管理员修改密码。" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { message, type FormInstance } from 'ant-design-vue'
import { UserOutlined } from '@ant-design/icons-vue'
import { pageCmsWebsiteAll } from '@/api/cms/cmsWebsite'
import type { CmsWebsite } from '@/api/cms/cmsWebsite/model'
import { addTenant, removeTenant, updateTenant, updateTenantPassword } from '@/api/system/tenant'
import type { Tenant } from '@/api/system/tenant/model'
definePageMeta({ layout: 'console' })
const loading = ref(false)
const error = ref<string>('')
const list = ref<CmsWebsite[]>([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
const keywords = ref('')
async function loadTenants() {
loading.value = true
error.value = ''
try {
const rawUserId = process.client ? localStorage.getItem('UserId') : null
const userId = rawUserId ? Number(rawUserId) : NaN
const res = await pageCmsWebsiteAll({
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined,
userId: Number.isFinite(userId) ? userId : undefined
})
list.value = res?.list ?? []
total.value = res?.count ?? 0
} catch (e) {
error.value = e instanceof Error ? e.message : '租户列表加载失败'
} finally {
loading.value = false
}
}
async function reload() {
await loadTenants()
}
function doSearch() {
page.value = 1
loadTenants()
}
function onPageChange(nextPage: number) {
page.value = nextPage
loadTenants()
}
function onPageSizeChange(_current: number, nextSize: number) {
limit.value = nextSize
page.value = 1
loadTenants()
}
onMounted(() => {
loadTenants()
})
const editOpen = ref(false)
const saving = ref(false)
const editFormRef = ref<FormInstance>()
const editForm = reactive<Tenant>({
tenantId: undefined,
tenantName: '',
companyName: '',
appId: '',
appSecret: '',
logo: '',
comments: '',
status: 0
})
const editTitle = computed(() => (editForm.tenantId ? '编辑' : '创建'))
const editRules = reactive({
tenantName: [{ required: true, type: 'string', message: '请输入租户名称' }]
})
function openCreate() {
editForm.tenantId = undefined
editForm.tenantName = ''
editForm.companyName = ''
editForm.appId = ''
editForm.appSecret = ''
editForm.logo = ''
editForm.comments = ''
editForm.status = 0
editOpen.value = true
}
function openEdit(row: CmsWebsite | Tenant) {
// pageCmsWebsiteAll 返回的是应用(网站)列表,这里映射到租户表单字段
const anyRow = row as unknown as Partial<CmsWebsite & Tenant>
editForm.tenantId = anyRow.tenantId
editForm.tenantName = anyRow.tenantName ?? anyRow.websiteName ?? ''
editForm.companyName = anyRow.companyName ?? ''
editForm.appId = anyRow.appId ?? anyRow.websiteCode ?? ''
editForm.appSecret = anyRow.appSecret ?? anyRow.websiteSecret ?? ''
editForm.logo = anyRow.logo ?? anyRow.websiteLogo ?? anyRow.websiteIcon ?? ''
editForm.comments = anyRow.comments ?? ''
// 租户状态只支持 0/1应用状态(0~5) 这里做一个兼容映射
editForm.status = typeof anyRow.status === 'number' ? (anyRow.status === 1 ? 0 : 1) : 0
editOpen.value = true
}
async function submitEdit() {
try {
await editFormRef.value?.validate()
} catch {
return
}
saving.value = true
try {
const payload: Tenant = {
...editForm,
tenantName: editForm.tenantName?.trim(),
companyName: editForm.companyName?.trim() || undefined,
appId: editForm.appId?.trim(),
appSecret: editForm.appSecret?.trim() || undefined,
logo: editForm.logo?.trim() || undefined,
comments: editForm.comments?.trim() || undefined
}
if (payload.tenantId) {
await updateTenant(payload)
message.success('租户已更新')
} else {
await addTenant(payload)
message.success('租户已创建')
}
editOpen.value = false
await loadTenants()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
const busyTenantId = ref<number | null>(null)
async function remove(row: CmsWebsite | Tenant) {
if (!row.tenantId) return
busyTenantId.value = row.tenantId
try {
await removeTenant(row.tenantId)
message.success('已删除')
if (list.value.length <= 1 && page.value > 1) page.value -= 1
await loadTenants()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
} finally {
busyTenantId.value = null
}
}
const resetOpen = ref(false)
const resetting = ref(false)
const resetPassword = ref('')
const selectedTenant = ref<Tenant | null>(null)
async function submitReset() {
if (!selectedTenant.value?.tenantId) return
const pwd = resetPassword.value.trim()
if (!pwd) {
message.error('请输入新密码')
return
}
resetting.value = true
try {
await updateTenantPassword(selectedTenant.value.tenantId, pwd)
message.success('密码已重置')
resetOpen.value = false
} catch (e) {
message.error(e instanceof Error ? e.message : '重置失败')
} finally {
resetting.value = false
}
}
function statusText(status?: number, fallback?: string) {
if (fallback) return fallback
const map: Record<number, string> = {
0: '未开通',
1: '运行中',
2: '维护中',
3: '已关闭',
4: '欠费停机',
5: '违规关停'
}
if (typeof status === 'number' && status in map) return map[status]
return '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = {
0: 'default',
1: 'green',
2: 'orange',
3: 'red',
4: 'volcano',
5: 'red'
}
if (typeof status === 'number' && status in map) return map[status]
return 'default'
}
</script>
<style scoped>
.page-header {
border-radius: 12px;
}
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,425 @@
<template>
<div class="space-y-4">
<a-page-header title="发票记录" sub-title="开票申请与发票下载">
<template #extra>
<a-space>
<a-button :loading="loadingPrefill" @click="prefill">自动填充</a-button>
<a-button @click="reloadRecords">刷新记录</a-button>
</a-space>
</template>
</a-page-header>
<a-card :bordered="false" class="card">
<a-alert
class="mb-4"
show-icon
type="info"
message="开票申请提交后会记录在本地(浏览器)用于演示;如需接入后端开票流程,可在 submitApply 中替换为真实接口。"
/>
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules">
<div class="grid gap-4 md:grid-cols-2">
<a-form-item label="发票类型" name="invoiceType">
<a-select
v-model:value="form.invoiceType"
placeholder="请选择发票类型"
:options="invoiceTypeOptions"
/>
</a-form-item>
<a-form-item label="发票获取方式" name="deliveryMethod">
<a-select v-model:value="form.deliveryMethod" :options="deliveryMethodOptions" disabled />
</a-form-item>
<a-form-item label="发票抬头" name="invoiceTitle">
<a-input v-model:value="form.invoiceTitle" placeholder="例如:某某科技有限公司" />
</a-form-item>
<a-form-item label="纳税人识别号" name="taxpayerId">
<a-input v-model:value="form.taxpayerId" placeholder="请输入纳税人识别号" />
</a-form-item>
<a-form-item label="邮箱地址" name="email">
<a-input v-model:value="form.email" placeholder="例如name@example.com" />
</a-form-item>
<div class="hidden md:block" />
<a-form-item label="开户银行" name="bankName">
<a-input v-model:value="form.bankName" placeholder="专票必填" />
</a-form-item>
<a-form-item label="开户账号" name="bankAccount">
<a-input v-model:value="form.bankAccount" placeholder="专票必填" />
</a-form-item>
<a-form-item label="注册地址" name="registeredAddress">
<a-input v-model:value="form.registeredAddress" placeholder="专票必填" />
</a-form-item>
<a-form-item label="注册电话" name="registeredPhone">
<a-input v-model:value="form.registeredPhone" placeholder="专票必填(座机/手机号)" />
</a-form-item>
</div>
<a-space class="mt-2">
<a-button type="primary" :loading="submitting" @click="submitApply">提交开票申请</a-button>
<a-button :disabled="submitting" @click="resetForm">重置</a-button>
</a-space>
</a-form>
</a-card>
<a-card :bordered="false" class="card">
<a-space class="mb-3" align="center">
<div class="text-base font-medium">申请记录</div>
<a-tag color="blue">{{ records.length }}</a-tag>
</a-space>
<a-empty v-if="!records.length" description="暂无开票申请记录" />
<a-table
v-else
:data-source="records"
:pagination="false"
size="middle"
:row-key="(r: InvoiceApplyRecord) => r.id"
>
<a-table-column title="提交时间" key="createdAt" width="180">
<template #default="{ record }">
<span>{{ formatTime(record.createdAt) }}</span>
</template>
</a-table-column>
<a-table-column title="发票类型" key="invoiceType" width="170">
<template #default="{ record }">
<span>{{ invoiceTypeText(record.invoiceType) }}</span>
</template>
</a-table-column>
<a-table-column title="发票抬头" key="invoiceTitle" ellipsis>
<template #default="{ record }">
<span>{{ record.invoiceTitle || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="邮箱" key="email" width="220" ellipsis>
<template #default="{ record }">
<span>{{ record.email || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="状态" key="status" width="120">
<template #default="{ record }">
<a-tag v-if="record.status === 'submitted'" color="default">已提交</a-tag>
<a-tag v-else-if="record.status === 'issued'" color="green">已开具</a-tag>
<a-tag v-else-if="record.status === 'rejected'" color="red">已驳回</a-tag>
<a-tag v-else color="default">-</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" key="actions" width="220" fixed="right">
<template #default="{ record }">
<a-space>
<a-button size="small" @click="openDetail(record)">查看</a-button>
<a-button size="small" :disabled="!record.fileUrl" @click="download(record)">下载</a-button>
<a-button danger size="small" @click="removeRecord(record)">删除</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
</a-card>
<a-modal v-model:open="detailOpen" title="开票申请详情" :width="720" ok-text="关闭" cancel-text="取消" :footer="null">
<a-descriptions bordered size="small" :column="2">
<a-descriptions-item label="发票类型">{{ invoiceTypeText(detail?.invoiceType) }}</a-descriptions-item>
<a-descriptions-item label="发票获取方式">数字电子发票</a-descriptions-item>
<a-descriptions-item label="发票抬头" :span="2">{{ detail?.invoiceTitle || '-' }}</a-descriptions-item>
<a-descriptions-item label="纳税人识别号" :span="2">{{ detail?.taxpayerId || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱" :span="2">{{ detail?.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="开户银行" :span="2">{{ detail?.bankName || '-' }}</a-descriptions-item>
<a-descriptions-item label="开户账号" :span="2">{{ detail?.bankAccount || '-' }}</a-descriptions-item>
<a-descriptions-item label="注册地址" :span="2">{{ detail?.registeredAddress || '-' }}</a-descriptions-item>
<a-descriptions-item label="注册电话" :span="2">{{ detail?.registeredPhone || '-' }}</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ formatTime(detail?.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="detail?.status === 'submitted'" color="default">已提交</a-tag>
<a-tag v-else-if="detail?.status === 'issued'" color="green">已开具</a-tag>
<a-tag v-else-if="detail?.status === 'rejected'" color="red">已驳回</a-tag>
<a-tag v-else color="default">-</a-tag>
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal, type FormInstance } from 'ant-design-vue'
import { getTenantInfo, getUserInfo } from '@/api/layout'
definePageMeta({ layout: 'console' })
type InvoiceType = 'normal' | 'special'
type InvoiceDeliveryMethod = 'digital'
type InvoiceApplyStatus = 'submitted' | 'issued' | 'rejected'
type InvoiceApplyRecord = {
id: string
createdAt: string
status: InvoiceApplyStatus
invoiceType: InvoiceType
invoiceTitle: string
taxpayerId: string
email: string
deliveryMethod: InvoiceDeliveryMethod
bankName: string
bankAccount: string
registeredAddress: string
registeredPhone: string
invoiceNo?: string
fileUrl?: string
}
const STORAGE_KEY = 'console.invoiceApplications.v1'
const invoiceTypeOptions = [
{ label: '增值税普通发票', value: 'normal' },
{ label: '增值税专用发票', value: 'special' }
]
const deliveryMethodOptions = [{ label: '数字电子发票', value: 'digital' }]
const loadingPrefill = ref(false)
const submitting = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<{
invoiceType: InvoiceType | undefined
invoiceTitle: string
taxpayerId: string
email: string
deliveryMethod: InvoiceDeliveryMethod
bankName: string
bankAccount: string
registeredAddress: string
registeredPhone: string
}>({
invoiceType: undefined,
invoiceTitle: '',
taxpayerId: '',
email: '',
deliveryMethod: 'digital',
bankName: '',
bankAccount: '',
registeredAddress: '',
registeredPhone: ''
})
const records = ref<InvoiceApplyRecord[]>([])
const detailOpen = ref(false)
const detail = ref<InvoiceApplyRecord | null>(null)
function invoiceTypeText(value?: InvoiceType | null) {
if (value === 'special') return '增值税专用发票'
if (value === 'normal') return '增值税普通发票'
return '-'
}
function formatTime(value?: string | null) {
if (!value) return '-'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function safeParseRecords(raw: string | null): InvoiceApplyRecord[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed as InvoiceApplyRecord[]
} catch {
return []
}
}
function persistRecords(next: InvoiceApplyRecord[]) {
try {
if (!import.meta.client) return
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// ignore
}
}
function reloadRecords() {
if (!import.meta.client) return
records.value = safeParseRecords(localStorage.getItem(STORAGE_KEY))
}
function generateId() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function isSpecialInvoice() {
return form.invoiceType === 'special'
}
function requiredWhenSpecial(label: string) {
return (_rule: unknown, value: unknown) => {
if (!isSpecialInvoice()) return Promise.resolve()
const normalized = typeof value === 'string' ? value.trim() : ''
if (normalized) return Promise.resolve()
return Promise.reject(new Error(`${label}不能为空(专票必填)`))
}
}
function phoneValidator(_rule: unknown, value: unknown) {
const normalized = typeof value === 'string' ? value.trim() : ''
if (!normalized) {
if (isSpecialInvoice()) return Promise.reject(new Error('注册电话不能为空(专票必填)'))
return Promise.resolve()
}
const mobileReg = /^1[3-9]\d{9}$/
const landlineReg = /^0\d{2,3}-?\d{7,8}$/
if (mobileReg.test(normalized) || landlineReg.test(normalized)) return Promise.resolve()
return Promise.reject(new Error('电话格式不正确座机0xx-xxxxxxx 或手机号)'))
}
const rules = computed(() => ({
invoiceType: [{ required: true, message: '请选择发票类型' }],
invoiceTitle: [{ required: true, message: '请输入发票抬头', type: 'string' }],
taxpayerId: [{ required: true, message: '请输入纳税人识别号', type: 'string' }],
email: [
{ required: true, message: '请输入邮箱地址', type: 'string' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
],
deliveryMethod: [{ required: true, message: '请选择发票获取方式' }],
bankName: [{ validator: requiredWhenSpecial('开户银行'), trigger: 'blur' }],
bankAccount: [{ validator: requiredWhenSpecial('开户账号'), trigger: 'blur' }],
registeredAddress: [{ validator: requiredWhenSpecial('注册地址'), trigger: 'blur' }],
registeredPhone: [{ validator: phoneValidator, trigger: 'blur' }]
}))
async function prefill(options: { silent?: boolean } = {}) {
loadingPrefill.value = true
try {
const [uRes, cRes] = await Promise.allSettled([getUserInfo(), getTenantInfo()])
if (uRes.status === 'fulfilled') {
if (!form.email.trim()) form.email = (uRes.value.email ?? '').trim()
}
if (cRes.status === 'fulfilled') {
const title = (cRes.value.invoiceHeader ?? cRes.value.companyName ?? cRes.value.tenantName ?? '').trim()
if (title && !form.invoiceTitle.trim()) form.invoiceTitle = title
}
if (!options.silent) message.success('已自动填充可用信息')
} catch (e: unknown) {
console.error(e)
if (!options.silent) message.error(e instanceof Error ? e.message : '自动填充失败')
} finally {
loadingPrefill.value = false
}
}
function resetForm() {
form.invoiceType = undefined
form.invoiceTitle = ''
form.taxpayerId = ''
form.bankName = ''
form.bankAccount = ''
form.registeredAddress = ''
form.registeredPhone = ''
form.deliveryMethod = 'digital'
}
async function submitApply() {
try {
await formRef.value?.validate()
} catch {
return
}
const payload: Omit<InvoiceApplyRecord, 'id' | 'createdAt' | 'status'> = {
invoiceType: form.invoiceType as InvoiceType,
invoiceTitle: form.invoiceTitle.trim(),
taxpayerId: form.taxpayerId.trim(),
email: form.email.trim(),
deliveryMethod: form.deliveryMethod,
bankName: form.bankName.trim(),
bankAccount: form.bankAccount.trim(),
registeredAddress: form.registeredAddress.trim(),
registeredPhone: form.registeredPhone.trim()
}
if (!payload.invoiceTitle) return message.error('请输入发票抬头')
if (!payload.taxpayerId) return message.error('请输入纳税人识别号')
if (!payload.email) return message.error('请输入邮箱地址')
submitting.value = true
try {
const next: InvoiceApplyRecord = {
id: generateId(),
createdAt: new Date().toISOString(),
status: 'submitted',
...payload
}
const updated = [next, ...records.value]
records.value = updated
persistRecords(updated)
message.success('已提交开票申请')
resetForm()
await prefill({ silent: true })
} catch (e: unknown) {
console.error(e)
message.error(e instanceof Error ? e.message : '提交失败')
} finally {
submitting.value = false
}
}
function openDetail(record: InvoiceApplyRecord) {
detail.value = record
detailOpen.value = true
}
function download(record: InvoiceApplyRecord) {
if (!record.fileUrl) return
if (!import.meta.client) return
window.open(record.fileUrl, '_blank', 'noopener,noreferrer')
}
function removeRecord(record: InvoiceApplyRecord) {
Modal.confirm({
title: '确认删除该开票申请?',
content: '删除后无法恢复(仅删除本地记录)。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
const updated = records.value.filter((r) => r.id !== record.id)
records.value = updated
persistRecords(updated)
if (detail.value?.id === record.id) {
detailOpen.value = false
detail.value = null
}
message.success('已删除')
}
})
}
onMounted(() => {
reloadRecords()
prefill({ silent: true })
})
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<a-spin size="large" tip="正在退出..." class="logout-spin" />
</template>
<script setup lang="ts">
import { removeToken } from '@/utils/token-util'
import { clearAuthz } from '@/utils/permission'
definePageMeta({ layout: 'console' })
onMounted(async () => {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch {
// ignore
}
clearAuthz()
await navigateTo('/')
})
</script>
<style scoped>
.logout-spin {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
}
</style>

View File

@@ -0,0 +1,394 @@
<template>
<div class="space-y-4">
<a-page-header title="订单管理" sub-title="购买续费与支付记录">
<template #extra>
<a-space>
<a-input
v-model:value="keywords"
allow-clear
placeholder="搜索订单号/产品"
class="w-64"
@press-enter="doSearch"
/>
<a-button :loading="loading" @click="reload">刷新</a-button>
</a-space>
</template>
</a-page-header>
<a-card :bordered="false" class="card">
<a-space class="mb-4">
<a-segmented
:value="payStatusSegment"
:options="payStatusOptions"
@update:value="onPayStatusChange"
/>
<a-select
v-model:value="orderStatus"
allow-clear
placeholder="订单状态"
:options="orderStatusOptions"
@change="reload"
style="min-width: 160px"
/>
</a-space>
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
<a-table
:data-source="list"
:loading="loading"
:pagination="false"
size="middle"
:row-key="(r: any) => r.orderId ?? r.orderNo"
>
<a-table-column title="订单号" key="orderNo" width="220">
<template #default="{ record }">
<a-typography-text :copyable="{ text: record.orderNo || '' }">
{{ record.orderNo || '-' }}
</a-typography-text>
</template>
</a-table-column>
<a-table-column title="产品" key="product" width="200">
<template #default="{ record }">
<div class="min-w-0">
<div class="truncate">{{ resolveProductName(record) }}</div>
<div class="text-xs text-gray-500 truncate" v-if="resolveProductSub(record)">
{{ resolveProductSub(record) }}
</div>
</div>
</template>
</a-table-column>
<a-table-column title="金额" key="amount" width="140">
<template #default="{ record }">
<span>{{ formatMoney(record.payPrice || record.totalPrice) }}</span>
</template>
</a-table-column>
<a-table-column title="支付" key="payStatus" width="110">
<template #default="{ record }">
<a-tag v-if="Number(record.payStatus) === 1" color="green">已支付</a-tag>
<a-tag v-else-if="Number(record.payStatus) === 0" color="default">未支付</a-tag>
<a-tag v-else color="default">-</a-tag>
</template>
</a-table-column>
<a-table-column title="状态" key="orderStatus" width="160">
<template #default="{ record }">
<a-tag :color="resolveOrderStatusColor(record.orderStatus)">{{ resolveOrderStatusText(record.orderStatus) }}</a-tag>
</template>
</a-table-column>
<a-table-column title="创建时间" data-index="createTime" width="180">
<template #default="{ record }">
<span>{{ record.createTime || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="到期时间" data-index="expirationTime" width="180">
<template #default="{ record }">
<span>{{ record.expirationTime || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="操作" key="actions" width="120" fixed="right">
<template #default="{ record }">
<a-button size="small" @click="openDetail(record)">查看</a-button>
</template>
</a-table-column>
</a-table>
<div class="mt-4 flex items-center justify-end">
<a-pagination
:current="page"
:page-size="limit"
:total="total"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
@change="onPageChange"
@show-size-change="onPageSizeChange"
/>
</div>
</a-card>
<a-modal v-model:open="detailOpen" title="订单详情" :width="720" ok-text="关闭" cancel-text="取消" :footer="null">
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="订单号">
<a-typography-text :copyable="{ text: selected?.orderNo || '' }">
{{ selected?.orderNo || '-' }}
</a-typography-text>
</a-descriptions-item>
<a-descriptions-item label="订单ID">{{ selected?.orderId ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="金额">{{ formatMoney(selected?.payPrice || selected?.totalPrice) }}</a-descriptions-item>
<a-descriptions-item label="支付方式">{{ resolvePayTypeText(selected?.payType) }}</a-descriptions-item>
<a-descriptions-item label="支付状态">
{{ Number(selected?.payStatus) === 1 ? '已支付' : Number(selected?.payStatus) === 0 ? '未支付' : '-' }}
</a-descriptions-item>
<a-descriptions-item label="订单状态">{{ resolveOrderStatusText(selected?.orderStatus) }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ selected?.createTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="支付时间">{{ selected?.payTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="到期时间">{{ selected?.expirationTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="产品">
{{ resolveProductName(selected) }}
</a-descriptions-item>
<a-descriptions-item label="备注">
<span class="break-all">{{ pickFirstRemark(selected) || '-' }}</span>
</a-descriptions-item>
</a-descriptions>
<a-divider />
<div class="text-sm text-gray-600 mb-2">解析到的扩展字段buyerRemarks/merchantRemarks/comments</div>
<a-typography-paragraph :copyable="{ text: prettyExtra(selected) }">
<pre class="m-0 whitespace-pre-wrap break-words">{{ prettyExtra(selected) }}</pre>
</a-typography-paragraph>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { getUserInfo } from '@/api/layout'
import { pageShopOrder } from '@/api/shop/shopOrder'
import type { ShopOrder } from '@/api/shop/shopOrder/model'
definePageMeta({ layout: 'console' })
const loading = ref(false)
const error = ref('')
const list = ref<ShopOrder[]>([])
const page = ref(1)
const limit = ref(10)
const total = ref(0)
const keywords = ref('')
const payStatus = ref<number | null>(null)
const orderStatus = ref<number | null>(null)
const currentUserId = ref<number | null>(null)
const payStatusOptions = [
{ label: '全部', value: 'all' },
{ label: '已支付', value: 1 },
{ label: '未支付', value: 0 }
]
const payStatusSegment = computed(() => (payStatus.value === null ? 'all' : payStatus.value))
const orderStatusOptions = [
{ label: '未使用', value: 0 },
{ label: '已完成', value: 1 },
{ label: '已取消', value: 2 },
{ label: '取消中', value: 3 },
{ label: '退款申请中', value: 4 },
{ label: '退款被拒绝', value: 5 },
{ label: '退款成功', value: 6 },
{ label: '客户申请退款', value: 7 }
]
const detailOpen = ref(false)
const selected = ref<ShopOrder | null>(null)
function safeJsonParse(value: string): unknown {
try {
return JSON.parse(value)
} catch {
return undefined
}
}
function pickFirstRemark(order?: ShopOrder | null) {
if (!order) return ''
const record = order as unknown as Record<string, unknown>
const keys = ['buyerRemarks', 'merchantRemarks', 'comments']
for (const key of keys) {
const v = record[key]
if (typeof v === 'string' && v.trim()) return v.trim()
}
return ''
}
function parseExtra(order?: ShopOrder | null): Record<string, unknown> | null {
const raw = pickFirstRemark(order)
if (!raw) return null
const parsed = safeJsonParse(raw)
if (!parsed || typeof parsed !== 'object') return null
return parsed as Record<string, unknown>
}
function prettyExtra(order?: ShopOrder | null) {
const extra = parseExtra(order)
if (!extra) return '-'
try {
return JSON.stringify(extra, null, 2)
} catch {
return '-'
}
}
const productCatalog: Record<string, { name: string }> = {
website: { name: '企业官网' },
shop: { name: '电商系统' },
mp: { name: '小程序/公众号' }
}
function resolveProductCode(order?: ShopOrder | null) {
const extra = parseExtra(order)
const code = typeof extra?.product === 'string' ? extra.product.trim() : ''
return code
}
function resolveProductSub(order?: ShopOrder | null) {
const extra = parseExtra(order)
const months = extra?.months
const tenantName = extra?.tenantName
const domain = extra?.domain
const parts: string[] = []
if (typeof months === 'number' || typeof months === 'string') {
const m = String(months).trim()
if (m) parts.push(`${m}个月`)
}
if (typeof tenantName === 'string' && tenantName.trim()) parts.push(tenantName.trim())
if (typeof domain === 'string' && domain.trim()) parts.push(domain.trim())
return parts.join(' · ')
}
function resolveProductName(order?: ShopOrder | null) {
const code = resolveProductCode(order)
if (code && productCatalog[code]) return productCatalog[code].name
if (code) return code
return '-'
}
function formatMoney(value?: string) {
const v = typeof value === 'string' ? value.trim() : ''
if (!v) return '-'
const n = Number(v)
if (!Number.isFinite(n)) return `¥${v}`
return `¥${n.toFixed(2)}`
}
function resolvePayTypeText(payType?: number) {
const v = Number(payType)
if (!Number.isFinite(v)) return '-'
const map: Record<number, string> = {
0: '余额',
1: '微信',
102: '微信 Native',
2: '会员卡',
3: '支付宝',
4: '现金',
5: 'POS',
12: '免费'
}
return map[v] || `方式${v}`
}
function resolveOrderStatusText(orderStatus?: number) {
const v = Number(orderStatus)
if (!Number.isFinite(v)) return '-'
const map: Record<number, string> = {
0: '未使用',
1: '已完成',
2: '已取消',
3: '取消中',
4: '退款申请中',
5: '退款被拒绝',
6: '退款成功',
7: '客户申请退款'
}
return map[v] || `状态${v}`
}
function resolveOrderStatusColor(orderStatus?: number) {
const v = Number(orderStatus)
if (v === 1) return 'green'
if (v === 2) return 'default'
if (v === 6) return 'default'
if (v === 4 || v === 3 || v === 7) return 'orange'
if (v === 5) return 'red'
return 'blue'
}
async function ensureUser() {
if (currentUserId.value) return
const user = await getUserInfo()
currentUserId.value = user.userId ?? null
}
async function load() {
loading.value = true
error.value = ''
try {
await ensureUser()
const userId = currentUserId.value
if (!userId) {
throw new Error('缺少用户信息,无法查询当前用户订单')
}
const data = await pageShopOrder({
page: page.value,
limit: limit.value,
userId,
keywords: keywords.value?.trim() || undefined,
payStatus: payStatus.value === null ? undefined : payStatus.value,
orderStatus: orderStatus.value === null ? undefined : orderStatus.value
})
list.value = data?.list || []
total.value = data?.count || 0
} catch (e: unknown) {
console.error(e)
list.value = []
total.value = 0
error.value = e instanceof Error ? e.message : '加载订单失败'
message.error(error.value)
} finally {
loading.value = false
}
}
async function reload() {
await load()
}
function doSearch() {
page.value = 1
load()
}
function onPayStatusChange(value: string | number) {
payStatus.value = value === 'all' ? null : Number(value)
page.value = 1
load()
}
function onPageChange(p: number) {
page.value = p
load()
}
function onPageSizeChange(_current: number, size: number) {
limit.value = size
page.value = 1
load()
}
function openDetail(order: ShopOrder) {
selected.value = order
detailOpen.value = true
}
onMounted(() => {
load()
})
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<div class="space-y-4">
<a-page-header title="已购产品" sub-title="订阅与授权信息" />
<a-card :bordered="false" class="card">
<a-empty description="待接入:已购产品列表" />
</a-card>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: 'console' })
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,336 @@
<template>
<div class="space-y-4">
<a-page-header title="租户管理" sub-title="租户创建查询与维护" :ghost="false" class="page-header">
<template #extra>
<a-space>
<a-input
v-model:value="keywords"
allow-clear
placeholder="搜索租户名称/租户ID"
class="w-64"
@press-enter="doSearch"
/>
<a-button :loading="loading" @click="reload">刷新</a-button>
<a-button type="primary" @click="openCreate">新增租户</a-button>
</a-space>
</template>
</a-page-header>
<a-card :bordered="false" class="card">
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
<a-table
:data-source="list"
:loading="loading"
:pagination="false"
size="middle"
:row-key="(r: any) => r.tenantId ?? r.appId ?? r.tenantName"
>
<a-table-column title="租户ID" data-index="tenantId" width="90" />
<a-table-column title="租户名称" key="tenantName" width="220">
<template #default="{ record }">
<div class="flex items-center gap-2 min-w-0">
<a-avatar :src="record.logo" :size="22" shape="square">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="truncate">{{ record.tenantName || '-' }}</span>
</div>
</template>
</a-table-column>
<a-table-column title="状态" key="status" width="120">
<template #default="{ record }">
<a-tag v-if="record.status === 0 || record.status === undefined" color="green">正常</a-tag>
<a-tag v-else color="default">禁用</a-tag>
</template>
</a-table-column>
<a-table-column title="创建时间" data-index="createTime" width="180" />
<a-table-column title="操作" key="actions" width="260" fixed="right">
<template #default="{ record }">
<a-space>
<a-button size="small" @click="openEdit(record)">编辑</a-button>
<a-button size="small" @click="openReset(record)" :disabled="!record.tenantId">重置密码</a-button>
<a-popconfirm
title="确定删除该租户"
ok-text="删除"
cancel-text="取消"
@confirm="remove(record)"
>
<a-button size="small" danger :loading="busyTenantId === record.tenantId" :disabled="!record.tenantId">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table-column>
</a-table>
<div class="mt-4 flex items-center justify-end">
<a-pagination
:current="page"
:page-size="limit"
:total="total"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
@change="onPageChange"
@show-size-change="onPageSizeChange"
/>
</div>
</a-card>
<a-modal
v-model:open="editOpen"
:title="editTitle"
ok-text="保存"
cancel-text="取消"
:confirm-loading="saving"
@ok="submitEdit"
>
<a-form ref="editFormRef" layout="vertical" :model="editForm" :rules="editRules">
<a-form-item v-if="editForm.tenantId" label="租户ID">
<a-input :value="String(editForm.tenantId ?? '')" disabled />
</a-form-item>
<a-form-item label="租户名称" name="tenantName">
<a-input v-model:value="editForm.tenantName" placeholder="例如某某科技有限公司" />
</a-form-item>
<a-form-item label="企业名称" name="companyName">
<a-input v-model:value="editForm.companyName" placeholder="例如某某科技有限公司" />
</a-form-item>
<a-form-item label="Logo" name="logo">
<a-input v-model:value="editForm.logo" placeholder="https://..." />
</a-form-item>
<a-form-item label="应用秘钥" name="appSecret">
<a-input-password v-model:value="editForm.appSecret" placeholder="appSecret可选" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-select
v-model:value="editForm.status"
placeholder="请选择"
:options="[
{ label: '正常', value: 0 },
{ label: '禁用', value: 1 }
]"
/>
</a-form-item>
<a-form-item label="备注" name="comments">
<a-textarea v-model:value="editForm.comments" :rows="3" placeholder="备注(可选)" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="resetOpen"
title="重置租户密码"
ok-text="确认重置"
cancel-text="取消"
:confirm-loading="resetting"
@ok="submitReset"
>
<a-form layout="vertical">
<a-form-item label="租户">
<a-input :value="selectedTenant?.tenantName || ''" disabled />
</a-form-item>
<a-form-item label="新密码">
<a-input-password v-model:value="resetPassword" placeholder="请输入新密码" />
</a-form-item>
</a-form>
<a-alert class="mt-2" type="warning" show-icon message="重置后请尽快通知租户管理员修改密码。" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { message, type FormInstance } from 'ant-design-vue'
import { UserOutlined } from '@ant-design/icons-vue'
import { addTenant, pageTenant, removeTenant, updateTenant, updateTenantPassword } from '@/api/system/tenant'
import type { Tenant } from '@/api/system/tenant/model'
import { TEMPLATE_ID } from '@/config/setting'
definePageMeta({ layout: 'console' })
const loading = ref(false)
const error = ref<string>('')
const list = ref<Tenant[]>([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
const keywords = ref('')
const tenantCode = ref('')
const adminHeaders = { TenantId: TEMPLATE_ID }
async function loadTenants() {
loading.value = true
error.value = ''
try {
const res = await pageTenant({
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined,
tenantCode: tenantCode.value || undefined
}, { headers: adminHeaders })
list.value = res?.list ?? []
total.value = res?.count ?? 0
} catch (e) {
error.value = e instanceof Error ? e.message : '租户列表加载失败'
} finally {
loading.value = false
}
}
async function reload() {
await loadTenants()
}
function doSearch() {
page.value = 1
loadTenants()
}
function onPageChange(nextPage: number) {
page.value = nextPage
loadTenants()
}
function onPageSizeChange(_current: number, nextSize: number) {
limit.value = nextSize
page.value = 1
loadTenants()
}
onMounted(() => {
loadTenants()
})
const editOpen = ref(false)
const saving = ref(false)
const editFormRef = ref<FormInstance>()
const editForm = reactive<Tenant>({
tenantId: undefined,
tenantName: '',
companyName: '',
appId: '',
appSecret: '',
logo: '',
comments: '',
status: 0
})
const editTitle = computed(() => (editForm.tenantId ? '编辑租户' : '新增租户'))
const editRules = reactive({
tenantName: [{ required: true, type: 'string', message: '请输入租户名称' }]
})
function openCreate() {
editForm.tenantId = undefined
editForm.tenantName = ''
editForm.companyName = ''
editForm.appId = ''
editForm.appSecret = ''
editForm.logo = ''
editForm.comments = ''
editForm.status = 0
editOpen.value = true
}
function openEdit(row: Tenant) {
editForm.tenantId = row.tenantId
editForm.tenantName = row.tenantName ?? ''
editForm.companyName = row.companyName ?? ''
editForm.appId = row.appId ?? ''
editForm.appSecret = row.appSecret ?? ''
editForm.logo = row.logo ?? ''
editForm.comments = row.comments ?? ''
editForm.status = row.status ?? 0
editOpen.value = true
}
async function submitEdit() {
try {
await editFormRef.value?.validate()
} catch {
return
}
saving.value = true
try {
const payload: Tenant = {
...editForm,
tenantName: editForm.tenantName?.trim(),
companyName: editForm.companyName?.trim() || undefined,
appId: editForm.appId?.trim(),
appSecret: editForm.appSecret?.trim() || undefined,
logo: editForm.logo?.trim() || undefined,
comments: editForm.comments?.trim() || undefined
}
if (payload.tenantId) {
await updateTenant(payload, { headers: adminHeaders })
message.success('租户已更新')
} else {
await addTenant(payload, { headers: adminHeaders })
message.success('租户已创建')
}
editOpen.value = false
await loadTenants()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
const busyTenantId = ref<number | null>(null)
async function remove(row: Tenant) {
if (!row.tenantId) return
busyTenantId.value = row.tenantId
try {
await removeTenant(row.tenantId, { headers: adminHeaders })
message.success('已删除')
if (list.value.length <= 1 && page.value > 1) page.value -= 1
await loadTenants()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
} finally {
busyTenantId.value = null
}
}
const resetOpen = ref(false)
const resetting = ref(false)
const resetPassword = ref('')
const selectedTenant = ref<Tenant | null>(null)
function openReset(row: Tenant) {
selectedTenant.value = row
resetPassword.value = ''
resetOpen.value = true
}
async function submitReset() {
if (!selectedTenant.value?.tenantId) return
const pwd = resetPassword.value.trim()
if (!pwd) {
message.error('请输入新密码')
return
}
resetting.value = true
try {
await updateTenantPassword(selectedTenant.value.tenantId, pwd, { headers: adminHeaders })
message.success('密码已重置')
resetOpen.value = false
} catch (e) {
message.error(e instanceof Error ? e.message : '重置失败')
} finally {
resetting.value = false
}
}
</script>
<style scoped>
.page-header {
border-radius: 12px;
}
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="space-y-4">
<a-page-header title="管理中心" sub-title="产品开通使用与续费" :ghost="false" class="page-header">
<template #extra>
<a-segmented
:value="active"
:options="[
{ label: '已开通', value: 'index' },
{ label: '未开通', value: 'unopened' }
]"
@update:value="onSwitch"
/>
</template>
</a-page-header>
<a-card :bordered="false" class="card">
<a-empty description="待接入:未开通产品列表" />
</a-card>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: 'console' })
const route = useRoute()
const active = computed(() => (route.path.includes('/console/tenant/unopened') ? 'unopened' : ''))
function onSwitch(value: string | number) {
navigateTo(`/console/tenant/${String(value)}`)
}
</script>
<style scoped>
.page-header {
border-radius: 12px;
}
.card {
border-radius: 12px;
}
</style>

167
app/pages/contact/index.vue Normal file
View File

@@ -0,0 +1,167 @@
<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-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="company">
<a-input v-model:value="form.company" placeholder="请填写公司或团队名称" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="咨询方向" name="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>
</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-col>
<a-col :xs="24" :lg="10">
<a-card title="咨询内容建议">
<a-list :data-source="tips" size="small">
<template #renderItem="{ item }">
<a-list-item>{{ item }}</a-list-item>
</template>
</a-list>
<div class="mt-6">
<a-alert
show-icon
type="info"
message="如需更快响应,请在需求描述中留下可联系时间段,以及微信/邮箱(选填)。"
/>
</div>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import { addCmsOrder } from '@/api/cms/cmsOrder'
import { usePageSeo } from '@/composables/usePageSeo'
usePageSeo({
title: '联系我们 - 合作咨询 / 供货与采购对接',
description: '合作咨询与采购对接:供货报价、资质资料、配送方案与长期合作建议。',
path: '/contact'
})
const form = reactive({
name: '',
phone: '',
company: '',
consultType: undefined as undefined | 'cooperation' | 'purchase' | 'dealer' | 'service' | 'other',
need: ''
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const rules = {
name: [{ required: true, message: '请填写姓名' }],
phone: [{ required: true, message: '请填写手机号' }],
company: [{ required: true, message: '请填写公司/团队' }],
need: [{ required: true, message: '请填写需求描述' }]
}
const tips = [
'合作方向:渠道经销/团购/企业采购/门店合作/技术服务?',
'所在城市与可覆盖区域(渠道/门店/客户类型)?',
'预计需求规模与周期(首批/月度/长期)?',
'资质与结算:是否需要资质文件、开票类型与账期?',
'交付与配送:自提/同城/快递/冷链,期望时效?'
]
async function onSubmit() {
if (submitting.value) return
submitting.value = true
try {
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 || '-'}`,
`咨询方向:${consultTypeLabel}`,
'',
'需求描述:',
form.need || '-'
].join('\n')
const resMessage = await addCmsOrder({
title: `合作咨询 - ${form.company || form.name || '未命名'}`,
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>

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

@@ -0,0 +1,421 @@
<template>
<main class="detail">
<section class="mx-auto max-w-screen-xl px-4 py-8">
<a-breadcrumb class="detail-breadcrumb">
<a-breadcrumb-item>
<NuxtLink to="/">首页</NuxtLink>
</a-breadcrumb-item>
<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>
</section>
<section class="mx-auto max-w-screen-xl px-4 pb-12">
<a-card class="detail-card" :bordered="false">
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
<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="!goods"
status="404"
title="商品不存在"
sub-title="未找到对应的商品信息"
>
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/')">返回首页</a-button>
<a-button @click="goBack">返回</a-button>
</a-space>
</template>
</a-result>
<template v-else>
<a-row :gutter="[24, 24]">
<a-col :xs="24" :lg="10">
<div class="detail-cover-wrap">
<img
class="detail-cover"
:src="coverUrl"
:alt="title"
loading="lazy"
@error="onImgError"
/>
</div>
<div v-if="galleryUrls.length" class="mt-4 grid grid-cols-4 gap-3">
<button
v-for="u in galleryUrls"
:key="u"
type="button"
class="thumb"
@click="coverUrl = u"
>
<img class="thumb-img" :src="u" :alt="title" loading="lazy" @error="onImgError" />
</button>
</div>
</a-col>
<a-col :xs="24" :lg="14">
<div class="detail-title-row">
<a-typography-title :level="2" class="!mb-2">{{ title }}</a-typography-title>
<div class="detail-tags">
<a-tag v-if="unitName" color="blue">{{ unitName }}</a-tag>
<a-tag v-if="typeof sales === 'number'" color="default">销量 {{ sales }}</a-tag>
<a-tag v-if="typeof stock === 'number'" color="default">库存 {{ stock }}</a-tag>
</div>
</div>
<div class="detail-price">
<span class="detail-price-main">{{ formatMoney(price) }}</span>
<span v-if="unitName" class="detail-price-unit">/ {{ unitName }}</span>
</div>
<div class="mt-4">
<a-descriptions bordered size="small" :column="2">
<a-descriptions-item label="商品ID">{{ goodsId }}</a-descriptions-item>
<a-descriptions-item label="分类ID">{{ categoryIdText }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ createTime }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ updateTime }}</a-descriptions-item>
</a-descriptions>
</div>
<div class="mt-4">
<a-space>
<a-button @click="goBack">返回</a-button>
<a-button type="primary" @click="navigateTo('/')">首页</a-button>
</a-space>
</div>
</a-col>
</a-row>
<a-divider class="!my-6" />
<a-typography-title :level="4" class="!mb-3">商品详情</a-typography-title>
<a-alert v-if="!content" type="info" show-icon message="暂无详情内容" class="mb-4" />
<RichText v-else :content="content" />
</template>
</a-card>
</section>
</main>
</template>
<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()
const id = computed(() => {
const raw = route.params.id
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
return Number.isFinite(n) ? n : NaN
})
const {
data: goods,
pending,
error: loadError,
refresh
} = await useAsyncData<ShopGoods | null>(
() => `shop-goods-${String(route.params.id)}`,
async () => {
if (!Number.isFinite(id.value)) return null
return await getShopGoods(id.value)
},
{ watch: [id] }
)
function pickString(obj: unknown, key: string) {
if (!obj || typeof obj !== 'object') return ''
const record = obj as Record<string, unknown>
const value = record[key]
return typeof value === 'string' ? value.trim() : ''
}
function pickNumber(obj: unknown, key: string): number | undefined {
if (!obj || typeof obj !== 'object') return undefined
const record = obj as Record<string, unknown>
const value = record[key]
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value)
return undefined
}
const title = computed(() => {
const g = goods.value as unknown as Record<string, unknown> | null
if (!g) return '商品详情'
return (
pickString(g, 'goodsName') ||
pickString(g, 'name') ||
pickString(g, 'code') ||
`商品 ${String(route.params.id)}`
)
})
const unitName = computed(() => {
const g = goods.value as unknown as Record<string, unknown> | null
return g ? pickString(g, 'unitName') : ''
})
const price = computed(() => {
const g = goods.value as unknown as Record<string, unknown> | null
if (!g) return undefined
return (
pickNumber(g, 'salePrice') ??
pickNumber(g, 'price') ??
pickNumber(g, 'chainStorePrice') ??
pickNumber(g, 'originPrice') ??
pickNumber(g, 'buyingPrice')
)
})
const sales = computed(() => {
const g = goods.value as unknown as Record<string, unknown> | null
return g ? pickNumber(g, 'sales') : undefined
})
const stock = computed(() => {
const g = goods.value as unknown as Record<string, unknown> | null
return g ? pickNumber(g, 'stock') : undefined
})
const goodsId = computed(() => {
const g = goods.value as unknown as Record<string, unknown> | null
return g ? pickNumber(g, 'goodsId') : undefined
})
const categoryIdText = computed(() => {
const g = goods.value as unknown as Record<string, unknown> | null
const n = g ? pickNumber(g, 'categoryId') : undefined
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') || '-' : '-'
})
const updateTime = computed(() => {
const g = goods.value as unknown as Record<string, unknown> | null
return g ? pickString(g, 'updateTime') || '-' : '-'
})
const content = computed(() => {
const g = goods.value as unknown as Record<string, unknown> | null
if (!g) return ''
return pickString(g, 'content')
})
function parseGalleryUrls(g: ShopGoods | null | undefined) {
if (!g) return []
const anyG = g as unknown as Record<string, unknown>
const files = typeof anyG.files === 'string' ? anyG.files.trim() : ''
const image = typeof anyG.image === 'string' ? anyG.image.trim() : ''
const urls: string[] = []
if (image) urls.push(image)
if (files) {
if (files.startsWith('[')) {
try {
const parsed = JSON.parse(files) as unknown
if (Array.isArray(parsed)) {
for (const it of parsed) {
const url = typeof (it as any)?.url === 'string' ? String((it as any).url).trim() : ''
if (url) urls.push(url)
}
}
} catch {
// ignore JSON parse errors
}
} else {
for (const part of files.split(',')) {
const u = part.trim()
if (u) urls.push(u)
}
}
}
return Array.from(new Set(urls)).filter(Boolean)
}
const galleryUrls = computed(() => parseGalleryUrls(goods.value))
const coverUrl = ref('')
watch(
() => galleryUrls.value,
(list) => {
coverUrl.value =
list[0] ||
'https://oss.wsdns.cn/20251226/675876f9f5a84732b22efc02b275440a.png'
},
{ immediate: true }
)
function onImgError(e: Event) {
const img = e.target as HTMLImageElement | null
if (!img) return
img.onerror = null
img.src = 'https://oss.wsdns.cn/20251226/675876f9f5a84732b22efc02b275440a.png'
}
function formatMoney(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) return `¥${value.toFixed(2)}`
const v = typeof value === 'string' ? value.trim() : ''
if (!v) return '-'
const n = Number(v)
if (!Number.isFinite(n)) return `¥${v}`
return `¥${n.toFixed(2)}`
}
function goBack() {
if (import.meta.client && window.history.length > 1) {
router.back()
return
}
navigateTo('/')
}
const seoTitle = computed(() => title.value)
const seoDescription = computed(() => `${title.value},价格 ${formatMoney(price.value)}${unitName.value ? '/' + unitName.value : ''}`)
useSeoMeta({
title: seoTitle,
description: seoDescription,
ogTitle: seoTitle,
ogDescription: seoDescription,
ogType: 'product'
})
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>
.detail {
background: #f4f6f8;
}
.detail-breadcrumb {
color: rgba(0, 0, 0, 0.6);
}
.detail-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
}
.detail-cover-wrap {
width: 100%;
border-radius: 12px;
overflow: hidden;
background: #f3f4f6;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.detail-cover {
width: 100%;
height: 360px;
object-fit: cover;
display: block;
}
.thumb {
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.thumb-img {
width: 100%;
height: 70px;
object-fit: cover;
display: block;
background: #f3f4f6;
}
.detail-title-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.detail-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.detail-price {
margin-top: 8px;
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.detail-price-main {
color: #16a34a;
font-size: 28px;
font-weight: 900;
}
.detail-price-unit {
color: rgba(0, 0, 0, 0.6);
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
import GoodsCategoryPage from '@/components/shop/GoodsCategoryPage.vue'
</script>
<template>
<GoodsCategoryPage />
</template>
<style scoped>
</style>

370
app/pages/index.vue Normal file
View File

@@ -0,0 +1,370 @@
<template>
<main class="portal-home">
<header class="site-header">
<div class="mx-auto max-w-screen-xl px-4">
<div class="site-brand-wrap">
<div class="site-badge">政务</div>
<div>
<h1 class="site-title">广西决策咨询网</h1>
<p class="site-subtitle">权威发布 · 决策支持 · 服务发展</p>
</div>
</div>
<nav class="top-nav" aria-label="主导航">
<NuxtLink
v-for="item in navItems"
:key="item.label"
:to="item.to"
class="nav-item"
>
{{ item.label }}
</NuxtLink>
</nav>
</div>
</header>
<section class="hero-panel">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<p class="hero-eyebrow">政府门户 · 官方导向</p>
<h2 class="hero-title">聚焦广西决策咨询服务建设高水平智库平台</h2>
<p class="hero-desc">
围绕政策解读课题研究战略合作与会员服务提供规范及时可追溯的政务信息发布与决策支持
</p>
</div>
</section>
<section class="mx-auto max-w-screen-xl px-4 py-8">
<div class="content-grid">
<article class="module-card">
<h3 class="module-title">政策文件</h3>
<ul class="module-list">
<li v-for="item in policyDocs" :key="item">{{ item }}</li>
</ul>
</article>
<article class="module-card">
<h3 class="module-title">重要通知</h3>
<ul class="module-list">
<li v-for="item in notices" :key="item">{{ item }}</li>
</ul>
</article>
<article class="module-card">
<h3 class="module-title">领导讲话</h3>
<ul class="module-list">
<li v-for="item in speeches" :key="item">{{ item }}</li>
</ul>
</article>
</div>
<div class="feature-block">
<h3 class="feature-title">重点工作</h3>
<div class="feature-grid">
<div v-for="item in focusWorks" :key="item.title" class="feature-card">
<h4>{{ item.title }}</h4>
<p>{{ item.desc }}</p>
</div>
</div>
</div>
</section>
<footer class="site-footer">
<div class="mx-auto max-w-screen-xl px-4 py-6">
<h3 class="friend-title">友情链接</h3>
<div class="friend-links">
<a v-for="item in friendLinks" :key="item.name" :href="item.url" target="_blank" rel="noreferrer">
{{ item.name }}
</a>
</div>
<p class="copyright">© 广西决策咨询网 | 政务信息仅供公开发布与查询使用</p>
</div>
</footer>
</main>
</template>
<script setup lang="ts">
import { usePageSeo } from "@/composables/usePageSeo"
usePageSeo({
title: "广西决策咨询网",
description: "广西决策咨询网门户首页,发布政策文件、重要通知、领导讲话与课题研究信息。",
path: "/"
})
const navItems = [
{ label: "网站首页", to: "/" },
{ label: "学会活动", to: "/article/activities" },
{ label: "决策资讯", to: "/article/news" },
{ label: "战略合作", to: "/article/coop" },
{ label: "会员服务", to: "/member" },
{ label: "课题研究", to: "/article/research" },
{ label: "学会党建", to: "/article/party" },
{ label: "关于我们", to: "/about" }
]
const policyDocs = [
"广西重点产业发展政策解读2026年",
"自治区决策咨询课题管理办法(修订)",
"关于加强高端智库成果转化的实施意见",
"政务信息公开与数据安全管理规范"
]
const notices = [
"关于申报2026年度重点课题的通知",
"学会专家库入库审核结果公示",
"战略合作单位联络员培训安排",
"清明节假期值班与应急工作通知"
]
const speeches = [
"坚持问题导向,提升咨政建言质量",
"服务地方治理现代化的智库路径",
"打造开放协同的决策咨询共同体",
"推动研究成果向政策实践高效转化"
]
const focusWorks = [
{ title: "课题研究", desc: "围绕重大现实问题开展专题研究,形成可落地的政策建议。" },
{ title: "战略合作", desc: "联动高校、研究机构与行业协会,构建协同创新平台。" },
{ title: "会员服务", desc: "提供成果交流、培训活动和资源对接等综合服务。" },
{ title: "学会党建", desc: "强化党建引领,推动业务发展与组织建设深度融合。" }
]
const friendLinks = [
{ name: "广西壮族自治区人民政府", url: "https://www.gxzf.gov.cn" },
{ name: "中国社会科学院", url: "https://www.cass.cn" },
{ name: "国务院发展研究中心", url: "https://www.drc.gov.cn" },
{ name: "广西社科联", url: "http://www.gxskl.gov.cn" }
]
</script>
<style scoped>
.portal-home {
min-height: 100vh;
background: #f8f8f8;
}
.site-header {
position: sticky;
top: 0;
z-index: 50;
background: linear-gradient(180deg, #9f1313, #c31818);
border-bottom: 3px solid #f3d27a;
box-shadow: 0 8px 20px rgba(110, 10, 10, 0.25);
}
.site-brand-wrap {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 0 14px;
}
.site-badge {
width: 54px;
height: 54px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: #8b1111;
background: linear-gradient(180deg, #f8df9f, #e7bd5b);
}
.site-title {
margin: 0;
color: #fff;
font-size: 30px;
letter-spacing: 0.06em;
font-weight: 800;
}
.site-subtitle {
margin: 6px 0 0;
color: rgba(255, 255, 255, 0.88);
font-size: 13px;
}
.top-nav {
display: flex;
flex-wrap: wrap;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.nav-item {
color: #fff3d7;
text-decoration: none;
font-size: 15px;
padding: 12px 18px;
border-bottom: 3px solid transparent;
transition: all 0.2s ease;
}
.nav-item:hover,
.nav-item.router-link-active {
color: #fff;
border-bottom-color: #f5d07a;
background: rgba(255, 255, 255, 0.08);
}
.hero-panel {
background:
linear-gradient(140deg, rgba(140, 12, 12, 0.9), rgba(173, 18, 18, 0.88)),
repeating-linear-gradient(45deg, transparent, transparent 18px, rgba(255, 255, 255, 0.03) 18px, rgba(255, 255, 255, 0.03) 36px);
color: #fff;
}
.hero-eyebrow {
margin: 0;
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 233, 190, 0.95);
}
.hero-title {
margin: 12px 0;
font-size: 34px;
line-height: 1.35;
font-weight: 900;
}
.hero-desc {
margin: 0;
max-width: 780px;
color: rgba(255, 255, 255, 0.9);
line-height: 1.8;
}
.content-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 18px;
}
.module-card {
grid-column: span 12;
border: 1px solid #eed7d7;
border-radius: 10px;
background: #fff;
overflow: hidden;
}
@media (min-width: 1024px) {
.module-card {
grid-column: span 4;
}
}
.module-title {
margin: 0;
padding: 14px 16px;
font-size: 18px;
color: #941515;
background: linear-gradient(180deg, #fff6f6, #ffeaea);
border-bottom: 1px solid #efd5d5;
}
.module-list {
list-style: none;
padding: 10px 16px 16px;
margin: 0;
}
.module-list li {
padding: 10px 0;
border-bottom: 1px dashed #ecd1d1;
color: #2d2d2d;
line-height: 1.55;
}
.module-list li:last-child {
border-bottom: 0;
}
.feature-block {
margin-top: 24px;
border: 1px solid #efd4d4;
border-radius: 10px;
background: #fff;
}
.feature-title {
margin: 0;
padding: 14px 16px;
color: #941515;
font-size: 20px;
border-bottom: 1px solid #efd4d4;
background: linear-gradient(180deg, #fff9f9, #fff0f0);
}
.feature-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 14px;
padding: 16px;
}
.feature-card {
grid-column: span 12;
background: #fffafa;
border: 1px solid #f2dede;
border-radius: 8px;
padding: 14px;
}
@media (min-width: 900px) {
.feature-card {
grid-column: span 6;
}
}
.feature-card h4 {
margin: 0;
color: #8f1313;
font-size: 16px;
}
.feature-card p {
margin: 8px 0 0;
line-height: 1.7;
color: #4b4b4b;
}
.site-footer {
margin-top: 20px;
background: #f0ecec;
border-top: 1px solid #dcc8c8;
}
.friend-title {
margin: 0;
font-size: 18px;
color: #6f6f6f;
}
.friend-links {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
margin-top: 10px;
}
.friend-links a {
color: #514a4a;
text-decoration: none;
}
.friend-links a:hover {
color: #931414;
text-decoration: underline;
}
.copyright {
margin: 14px 0 0;
color: #7f7f7f;
font-size: 12px;
}
</style>

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>

539
app/pages/login.vue Normal file
View File

@@ -0,0 +1,539 @@
<template>
<div class="login-shell">
<SiteHeader />
<div class="login-page" :style="bgStyle">
<div v-if="config?.siteName" class="brand">
<img :src="config.sysLogo || defaultLogo" class="brand-logo" alt="logo" />
<h1 class="brand-name">{{ config.siteName }}</h1>
</div>
<div v-if="config?.loginTitle" class="brand-title">{{ config.loginTitle }}</div>
<a-form ref="formRef" :model="form" :rules="rules" class="card">
<div class="card-header">
<template v-if="loginType === 'scan'">
<h2 class="card-title">扫码登录</h2>
</template>
<template v-else>
<h2 class="tab" :class="{ active: loginType === 'sms' }" @click="setLoginType('sms')">
手机号登录
</h2>
<a-divider type="vertical" style="height: 20px" />
<h2
class="tab"
:class="{ active: loginType === 'account' }"
@click="setLoginType('account')"
>
账号登录
</h2>
</template>
<a-button
class="switch"
type="text"
@click="toggleScan"
:title="loginType === 'scan' ? '切换到手机号登录' : '切换到扫码登录'"
>
<QrcodeOutlined v-if="loginType !== 'scan'" />
<MobileOutlined v-else />
</a-button>
</div>
<template v-if="loginType === 'account'">
<a-form-item name="username">
<a-input v-model:value="form.username" size="large" allow-clear placeholder="账号 / 用户ID">
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="form.password"
size="large"
placeholder="登录密码"
@press-enter="submitAccount"
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<a-form-item name="code">
<div class="input-group">
<a-input
v-model:value="form.code"
size="large"
allow-clear
:maxlength="5"
placeholder="验证码"
@press-enter="submitAccount"
>
<template #prefix><SafetyCertificateOutlined /></template>
</a-input>
<a-button class="captcha-btn" @click="changeCaptcha">
<img v-if="captcha" :src="captcha" alt="captcha" />
</a-button>
</div>
</a-form-item>
<a-form-item>
<div class="row">
<a-checkbox v-model:checked="form.remember">记住登录</a-checkbox>
</div>
</a-form-item>
<a-form-item>
<a-button block size="large" type="primary" :loading="loading" @click="submitAccount">
{{ loading ? '登录中' : '登录' }}
</a-button>
</a-form-item>
</template>
<template v-else-if="loginType === 'sms'">
<a-form-item name="phone">
<a-input
v-model:value="form.phone"
size="large"
allow-clear
:maxlength="11"
placeholder="请输入手机号码"
>
<template #addonBefore>+86</template>
</a-input>
</a-form-item>
<a-form-item name="smsCode">
<div class="input-group">
<a-input
v-model:value="form.smsCode"
size="large"
allow-clear
:maxlength="6"
placeholder="请输入验证码"
@press-enter="submitSms"
/>
<a-button class="captcha-btn" :disabled="countdown > 0" @click="openImgCodeModal">
<span v-if="countdown <= 0">发送验证码</span>
<span v-else>已发送 {{ countdown }} s</span>
</a-button>
</div>
</a-form-item>
<a-form-item>
<a-button block size="large" type="primary" :loading="loading" @click="submitSms">
{{ loading ? '登录中' : '登录' }}
</a-button>
</a-form-item>
</template>
<template v-else>
<QrLogin @login-success="onQrLoginSuccess" @login-error="onQrLoginError" />
</template>
</a-form>
<div class="copyright 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>
</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>
<SiteFooter />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import {
LockOutlined,
MobileOutlined,
QrcodeOutlined,
RightOutlined,
SafetyCertificateOutlined,
UserOutlined
} from '@ant-design/icons-vue'
import QrLogin from '@/components/QrLogin.vue'
import { configWebsiteField, type Config } from '@/api/cms/cmsWebsiteField'
import { getCaptcha, login, loginBySms, sendSmsCaptcha } from '@/api/passport/login'
import type { LoginParam } from '@/api/passport/login/model'
import { listAdminsByPhoneAll } from '@/api/system/user'
import type { User } from '@/api/system/user/model'
import { TEMPLATE_ID } from '@/config/setting'
import { setToken } from '@/utils/token-util'
import type { QrCodeStatusResponse } from '@/api/passport/qrLogin'
// Login page is a public page: keep a lightweight layout and render header/footer locally.
definePageMeta({ layout: 'blank' })
const route = useRoute()
const defaultLogo = 'https://oss.wsdns.cn/20240822/0252ad4ed46449cdafe12f8d3d96c2ea.svg'
const config = ref<Config>()
const loading = ref(false)
const loginType = ref<'scan' | 'sms' | 'account'>('scan')
const captcha = ref('')
const captchaText = ref('')
const imgCodeModalOpen = ref(false)
const imgCode = ref('')
const sendingSms = ref(false)
const countdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
const selectUserOpen = ref(false)
const admins = ref<User[]>([])
const formRef = ref<FormInstance>()
const form = reactive<LoginParam & { smsCode?: string }>({
username: '',
password: '',
phone: '',
code: '',
smsCode: '',
remember: true
})
const phoneReg = /^1[3-9]\d{9}$/
const rules = reactive({
username: [{ required: true, message: '请输入账号', type: 'string' }],
password: [{ required: true, message: '请输入密码', type: 'string' }],
code: [{ required: true, message: '请输入验证码', type: 'string' }],
phone: [
{ required: true, message: '请输入手机号码', type: 'string' },
{ pattern: phoneReg, message: '手机号格式不正确', trigger: 'blur' }
],
smsCode: [{ required: true, message: '请输入短信验证码', type: 'string' }]
})
const bgStyle = computed(() => {
const bg = config.value?.loginBgImg
if (!bg) return {}
return { backgroundImage: `url(${bg})` }
})
function setLoginType(type: 'scan' | 'sms' | 'account') {
loginType.value = type
}
function toggleScan() {
loginType.value = loginType.value === 'scan' ? 'sms' : 'scan'
}
function stopCountdown() {
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = null
countdown.value = 0
}
async function changeCaptcha() {
try {
const data = await getCaptcha()
captcha.value = data.base64
captchaText.value = data.text
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '获取验证码失败')
}
}
function openImgCodeModal() {
if (!form.phone) return message.error('请输入手机号码')
imgCode.value = ''
changeCaptcha()
imgCodeModalOpen.value = true
}
async function sendSmsCode() {
if (!imgCode.value) return message.error('请输入图形验证码')
if (captchaText.value && imgCode.value.toLowerCase() !== captchaText.value.toLowerCase()) {
return message.error('图形验证码不正确')
}
sendingSms.value = true
try {
await sendSmsCaptcha({ phone: form.phone })
message.success('短信验证码发送成功,请注意查收')
imgCodeModalOpen.value = false
countdown.value = 30
stopCountdown()
countdown.value = 30
countdownTimer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) stopCountdown()
}, 1000)
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '发送失败')
} finally {
sendingSms.value = false
}
}
async function goAfterLogin() {
const from = typeof route.query.from === 'string' ? route.query.from : ''
await navigateTo(from || '/')
}
async function submitAccount() {
if (!formRef.value) return
loading.value = true
try {
await formRef.value.validate()
const msg = await login({
username: form.username,
password: form.password,
code: String(form.code || '').toLowerCase(),
remember: !!form.remember
})
message.success(msg || '登录成功')
await goAfterLogin()
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '登录失败')
changeCaptcha()
} finally {
loading.value = false
}
}
async function submitSms() {
if (!formRef.value) return
loading.value = true
try {
await formRef.value.validate()
const msg = await loginBySms({
phone: form.phone,
code: String(form.smsCode || '').toLowerCase(),
tenantId: form.tenantId,
remember: !!form.remember
})
if (msg === '请选择登录用户') {
selectUserOpen.value = true
admins.value = await listAdminsByPhoneAll({
phone: form.phone,
templateId: Number(TEMPLATE_ID)
})
return
}
message.success(msg || '登录成功')
await goAfterLogin()
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '登录失败')
} finally {
loading.value = false
}
}
async function selectUser(user: User) {
form.tenantId = user.tenantId
selectUserOpen.value = false
await submitSms()
}
function onQrLoginSuccess(payload: QrCodeStatusResponse) {
const accessToken = payload.accessToken || payload.access_token
if (accessToken) setToken(String(accessToken), true)
if (payload.tenantId && import.meta.client) localStorage.setItem('TenantId', String(payload.tenantId))
if (import.meta.client && typeof payload.userInfo === 'object' && payload.userInfo && 'userId' in payload.userInfo) {
const userId = (payload.userInfo as { userId?: unknown }).userId
if (userId !== undefined && userId !== null) localStorage.setItem('UserId', String(userId))
}
message.success('扫码登录成功')
goAfterLogin()
}
function onQrLoginError(error: string) {
message.error(error || '扫码登录失败')
}
onMounted(async () => {
try {
config.value = await configWebsiteField({ lang: 'zh-CN' })
} catch {
// ignore config errors
}
changeCaptcha()
if (typeof route.query.loginPhone === 'string') {
form.phone = route.query.loginPhone
loginType.value = 'sms'
}
})
onUnmounted(() => {
stopCountdown()
})
</script>
<style scoped>
.login-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.login-page {
position: relative;
flex: 1;
min-height: 0;
background-size: cover;
background-position: center;
padding: 48px 16px;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.25);
}
.brand {
position: absolute;
top: 18px;
left: 18px;
display: flex;
align-items: center;
gap: 10px;
z-index: 2;
}
.brand-logo {
width: 28px;
height: 28px;
border-radius: 6px;
}
.brand-name {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.brand-title {
position: absolute;
top: 16%;
left: 50%;
transform: translateX(-50%);
z-index: 2;
color: #fff;
font-size: 24px;
font-weight: 600;
text-align: center;
padding: 0 12px;
}
.card {
width: 390px;
max-width: 100%;
margin: 0 auto;
background: #fff;
padding: 0 28px 22px;
border-radius: 10px;
box-shadow: 0 10px 35px rgba(0, 0, 0, 0.25);
position: relative;
z-index: 2;
}
.card-header {
display: flex;
align-items: center;
justify-content: center;
position: relative;
gap: 12px;
padding: 18px 0 6px;
}
.card-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.tab {
margin: 0;
font-size: 16px;
font-weight: 600;
cursor: pointer;
color: #374151;
}
.tab.active {
color: #1677ff;
}
.switch {
position: absolute;
right: 0;
top: 14px;
}
.input-group {
display: flex;
align-items: center;
}
.captcha-btn {
width: 140px;
height: 40px;
margin-left: 10px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.captcha-btn img {
width: 100%;
height: 100%;
object-fit: contain;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.copyright {
z-index: 2;
position: relative;
margin-top: 28px;
text-align: center;
color: rgba(255, 255, 255, 0.85);
font-size: 12px;
}
.sep {
margin: 0 8px;
opacity: 0.7;
}
.modal-row {
margin-bottom: 16px;
}
.list-item {
cursor: pointer;
}
</style>

282
app/pages/page/[id].vue Normal file
View File

@@ -0,0 +1,282 @@
<template>
<main class="page">
<section class="page-hero" :style="heroStyle">
<div class="page-hero-mask">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<a-breadcrumb class="page-breadcrumb">
<a-breadcrumb-item>
<NuxtLink to="/">首页</NuxtLink>
</a-breadcrumb-item>
<a-breadcrumb-item v-if="parentName">{{ parentName }}</a-breadcrumb-item>
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
</a-breadcrumb>
<div class="page-hero-title">{{ pageTitle }}</div>
<div class="page-hero-meta">
<a-tag v-if="modelName" color="green">{{ modelName }}</a-tag>
<a-tag v-if="createTime" color="blue">{{ createTime }}</a-tag>
<a-tag v-if="typeof readNum === 'number'" color="default">阅读 {{ readNum }}</a-tag>
</div>
</div>
</div>
</section>
<section class="mx-auto max-w-screen-xl px-4 py-10">
<a-card class="page-card" :bordered="false">
<div class="page-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="page-content">
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
<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="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<a-result
v-else-if="!navigation"
status="404"
title="页面不存在"
sub-title="未找到对应的页面内容"
>
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<template v-else>
<a-alert v-if="!pageContent" class="mb-6" type="info" show-icon message="暂无内容" />
<RichText v-else :content="pageContent" />
</template>
</div>
</a-card>
</section>
</main>
</template>
<script setup lang="ts">
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
const route = useRoute()
const router = useRouter()
const id = computed(() => {
const raw = route.params.id
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
return Number.isFinite(n) ? n : NaN
})
const {
data: navigation,
pending,
error: loadError,
refresh
} = await useAsyncData<CmsNavigation | null>(
() => `cms-navigation-${String(route.params.id)}`,
async () => {
if (!Number.isFinite(id.value)) return null
return await getCmsNavigation(id.value)
},
{ watch: [id] }
)
function pickString(obj: Record<string, unknown>, key: string) {
const v = obj[key]
return typeof v === 'string' ? v.trim() : ''
}
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 ''
}
const pageTitle = computed(() => {
const nav = navigation.value as unknown as Record<string, unknown> | null
if (!nav) return '页面'
return pickString(nav, 'title') || pickString(nav, 'label') || `页面 ${String(route.params.id)}`
})
const heroStyle = computed(() => {
const nav = navigation.value as unknown as Record<string, unknown> | null
const banner = nav ? pickString(nav, 'banner') : ''
if (banner) {
return {
backgroundImage: `url(${banner})`
}
}
return {}
})
const parentName = computed(() => {
const nav = navigation.value as unknown as Record<string, unknown> | null
if (!nav) return ''
return pickString(nav, 'parentName')
})
const modelName = computed(() => {
const nav = navigation.value as unknown as Record<string, unknown> | null
if (!nav) return ''
return pickString(nav, 'modelName')
})
const createTime = computed(() => {
const nav = navigation.value as unknown as Record<string, unknown> | null
if (!nav) return ''
return pickString(nav, 'createTime')
})
const readNum = computed(() => {
const nav = navigation.value as unknown as Record<string, unknown> | null
if (!nav) return undefined
const n = nav.readNum
if (typeof n === 'number' && Number.isFinite(n)) return n
if (typeof n === 'string' && n.trim() && Number.isFinite(Number(n))) return Number(n)
return undefined
})
const pageContent = computed(() => {
const nav = navigation.value as unknown as Record<string, unknown> | null
if (!nav) return ''
// Different CMS deployments may store content under different keys.
const candidates = [
'content',
'html',
'body',
'text',
'pageContent',
'articleContent',
'suffix',
'comments',
'meta',
'style'
]
for (const k of candidates) {
const val = nav[k]
const text = coerceContent(val).trim()
if (text) return text
}
return ''
})
const seoTitle = computed(() => pageTitle.value)
const seoDescription = computed(() =>
pageContent.value ? pageContent.value.slice(0, 120) : `${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 }] : []
}))
function goBack() {
if (import.meta.client && window.history.length > 1) {
router.back()
return
}
navigateTo('/')
}
</script>
<style scoped>
.page {
background: #f4f6f8;
}
.page-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);
}
.page-hero-mask {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.92));
}
.page-breadcrumb {
color: rgba(0, 0, 0, 0.6);
}
.page-hero-title {
margin-top: 10px;
font-size: 30px;
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
line-height: 1.2;
}
.page-hero-meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.page-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
}
.page-card-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.page-content {
max-width: 860px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,8 @@
<script setup lang="ts">
import GoodsCategoryPage from '@/components/shop/GoodsCategoryPage.vue'
</script>
<template>
<GoodsCategoryPage />
</template>

86
app/pages/profile.vue Normal file
View File

@@ -0,0 +1,86 @@
<template>
<div class="mx-auto max-w-screen-md px-4 py-8">
<a-card title="个人资料" :bordered="false">
<div class="flex items-center gap-4">
<a-avatar :size="64" :src="avatarUrl">
<template v-if="!avatarUrl" #icon>
<UserOutlined />
</template>
</a-avatar>
<div class="min-w-0">
<div class="text-lg font-semibold text-gray-900">
{{ user?.nickname || user?.username || '未命名用户' }}
</div>
<div class="text-gray-500">
{{ user?.phone || (user as any)?.mobile || '' }}
</div>
</div>
</div>
<a-divider />
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="用户ID">{{ user?.userId }}</a-descriptions-item>
<a-descriptions-item label="账号">{{ user?.username }}</a-descriptions-item>
<a-descriptions-item label="昵称">{{ user?.nickname }}</a-descriptions-item>
<a-descriptions-item label="手机号">{{ user?.phone || (user as any)?.mobile }}</a-descriptions-item>
<a-descriptions-item label="租户ID">{{ tenantId }}</a-descriptions-item>
</a-descriptions>
<div class="mt-6 flex justify-end gap-2">
<a-button @click="navigateTo('/')">返回首页</a-button>
<a-button danger type="primary" @click="logout">退出登录</a-button>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { UserOutlined } from '@ant-design/icons-vue'
import { getUserInfo } from '@/api/layout'
import type { User } from '@/api/system/user/model'
import { getTenantId } from '@/utils/domain'
import { getToken, removeToken } from '@/utils/token-util'
const user = ref<User | null>(null)
const tenantId = computed(() => getTenantId())
const avatarUrl = computed(() => {
const candidate =
user.value?.avatarUrl ||
user.value?.avatar ||
user.value?.merchantAvatar ||
user.value?.logo ||
''
if (typeof candidate !== 'string') return ''
const normalized = candidate.trim()
if (!normalized || normalized === 'null' || normalized === 'undefined') return ''
return normalized
})
function logout() {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch {
// ignore
}
navigateTo('/')
}
onMounted(async () => {
if (!getToken()) {
message.error('请先登录')
await navigateTo('/login?from=/profile')
return
}
try {
user.value = await getUserInfo()
} catch (e: unknown) {
console.error(e)
message.error('获取用户信息失败')
}
})
</script>

252
app/pages/qr-confirm.vue Normal file
View File

@@ -0,0 +1,252 @@
<template>
<div class="page">
<a-card :bordered="false" class="card">
<div class="header">
<div class="app">
<img :src="appLogo" class="logo" alt="logo" />
<h3 class="name">{{ appName }}</h3>
</div>
<p class="tip">确认登录到 Web </p>
</div>
<div v-if="userInfo" class="user">
<a-avatar :size="64" :src="userInfo.avatar">
<template v-if="!userInfo.avatar" #icon><UserOutlined /></template>
</a-avatar>
<div class="user-text">
<h4 class="username">{{ userInfo.nickname || userInfo.username }}</h4>
<p class="phone">{{ userInfo.phone || userInfo.mobile }}</p>
</div>
</div>
<div class="device">
<div class="row"><span class="label">登录设备</span><span class="value">{{ deviceInfo.browser }} {{ deviceInfo.version }}</span></div>
<div class="row"><span class="label">操作系统</span><span class="value">{{ deviceInfo.os }}</span></div>
<div class="row"><span class="label">IP 地址</span><span class="value">{{ deviceInfo.ip }}</span></div>
<div class="row"><span class="label">登录时间</span><span class="value">{{ formatTime(new Date()) }}</span></div>
</div>
<div class="actions">
<a-button size="large" class="cancel" :loading="cancelLoading" @click="handleCancel">取消登录</a-button>
<a-button type="primary" size="large" class="confirm" :loading="confirmLoading" @click="handleConfirm">确认登录</a-button>
</div>
<div class="security">
<ExclamationCircleOutlined class="warn" />
<span>请确认是您本人操作如非本人操作请点击取消登录</span>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { ExclamationCircleOutlined, UserOutlined } from '@ant-design/icons-vue'
import { confirmQrLogin, scanQrCode, type QrLoginConfirmRequest } from '@/api/passport/qrLogin'
import { getUserInfo } from '@/api/layout'
import { getToken } from '@/utils/token-util'
import type { User } from '@/api/system/user/model'
definePageMeta({ layout: 'blank' })
const route = useRoute()
const qrCodeKey = computed(() => String(route.query.qrCodeKey || ''))
const userInfo = ref<User | null>(null)
const confirmLoading = ref(false)
const cancelLoading = ref(false)
const appName = ref('桂乐淘')
const appLogo = ref('/favicon.ico')
const deviceInfo = ref({
browser: 'Mobile',
version: '',
os: 'Unknown',
ip: '-'
})
function formatTime(date: Date) {
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
async function fetchUser() {
const token = getToken()
if (!token) {
message.error('请先登录')
await navigateTo('/login')
return false
}
try {
userInfo.value = await getUserInfo()
return true
} catch (error: unknown) {
console.error(error)
message.error('获取用户信息失败')
return false
}
}
async function markScanned() {
if (!qrCodeKey.value) return
try {
await scanQrCode(qrCodeKey.value)
} catch {
// ignore
}
}
async function handleConfirm() {
if (!qrCodeKey.value) return message.error('二维码参数错误')
confirmLoading.value = true
try {
if (!userInfo.value?.userId) return message.error('用户信息获取失败')
const requestData: QrLoginConfirmRequest = {
token: qrCodeKey.value,
userId: Number(userInfo.value.userId),
platform: 'web'
}
await confirmQrLogin(requestData)
message.success('登录确认成功')
setTimeout(() => backOrHome(), 1200)
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '确认登录失败')
} finally {
confirmLoading.value = false
}
}
function backOrHome() {
if (import.meta.client && window.history.length > 1) {
window.history.back()
return
}
navigateTo('/')
}
function handleCancel() {
cancelLoading.value = true
try {
message.info('已取消登录')
setTimeout(() => backOrHome(), 800)
} finally {
cancelLoading.value = false
}
}
onMounted(async () => {
if (!qrCodeKey.value) {
message.error('二维码参数错误')
await navigateTo('/login')
return
}
const ok = await fetchUser()
if (!ok) return
await markScanned()
})
</script>
<style scoped>
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: #f5f5f5;
}
.card {
width: 480px;
max-width: 100%;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
}
.header {
text-align: center;
padding: 8px 0 12px;
}
.app {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.logo {
width: 40px;
height: 40px;
border-radius: 10px;
}
.name {
margin: 0;
font-weight: 700;
color: #111827;
}
.tip {
margin: 10px 0 0;
color: #6b7280;
}
.user {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 4px;
}
.user-text {
flex: 1;
}
.username {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.phone {
margin: 4px 0 0;
color: #6b7280;
}
.device {
padding: 12px 4px 4px;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
}
.row {
display: flex;
justify-content: space-between;
padding: 6px 0;
gap: 12px;
}
.label {
color: #6b7280;
}
.value {
color: #111827;
font-weight: 500;
}
.actions {
display: flex;
gap: 12px;
padding: 16px 4px 6px;
}
.cancel,
.confirm {
flex: 1;
}
.security {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 4px 2px;
color: #6b7280;
font-size: 12px;
}
.warn {
color: #faad14;
}
</style>