feat(core): 初始化项目基础架构和CMS功能模块

- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore)
- 实现服务端API代理功能,支持文件、模块和服务器API转发
- 创建文章详情页、栏目文章列表页和单页内容展示页面
- 集成Ant Design Vue组件库并实现SSR样式提取功能
- 定义API响应数据结构类型和应用布局组件
- 开发开发者应用中心和文章管理页面
- 实现CMS导航菜单获取和多租户切换功能
This commit is contained in:
2026-01-27 00:14:08 +08:00
commit 775841eed3
315 changed files with 47072 additions and 0 deletions

220
app/components/QrLogin.vue Normal file
View File

@@ -0,0 +1,220 @@
<template>
<div class="qr-login">
<div class="qr-box">
<div v-if="status === 'loading'" class="qr-state">
<a-spin size="large" />
<p class="muted">正在生成二维码</p>
</div>
<div v-else-if="status === 'active'" class="qr-state">
<a-qrcode :value="qrCodeUrl" :size="200" @click="refresh" />
<p class="tip">请使用手机 APP 或小程序扫码登录</p>
<p class="muted">有效期{{ formatCountdown(expireSeconds) }}</p>
</div>
<div v-else-if="status === 'scanned'" class="qr-state">
<CheckCircleOutlined class="icon ok" />
<p class="ok-text">扫码成功请在手机上确认登录</p>
</div>
<div v-else-if="status === 'expired'" class="qr-state">
<ClockCircleOutlined class="icon bad" />
<p class="bad-text">二维码已过期</p>
<a-button type="primary" @click="refresh">刷新二维码</a-button>
</div>
<div v-else-if="status === 'error'" class="qr-state">
<ExclamationCircleOutlined class="icon bad" />
<p class="bad-text">{{ errorMessage || '生成二维码失败' }}</p>
<a-button type="primary" @click="refresh">重新生成</a-button>
</div>
</div>
<div v-if="status === 'active'" class="actions">
<a-button type="link" :loading="refreshing" @click="refresh">
<ReloadOutlined />
刷新二维码
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import {
CheckCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
ReloadOutlined
} from '@ant-design/icons-vue'
import {
checkQrCodeStatus,
generateQrCode,
type QrCodeResponse,
type QrCodeStatusResponse
} from '@/api/passport/qrLogin'
const emit = defineEmits<{
(e: 'loginSuccess', data: QrCodeStatusResponse): void
(e: 'loginError', error: string): void
}>()
const qrCodeUrl = ref('')
const token = ref('')
const status = ref<'loading' | 'active' | 'scanned' | 'expired' | 'error'>('loading')
const expireSeconds = ref(0)
const errorMessage = ref('')
const refreshing = ref(false)
let statusTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
function stopTimers() {
if (statusTimer) clearInterval(statusTimer)
if (countdownTimer) clearInterval(countdownTimer)
statusTimer = null
countdownTimer = null
}
function formatCountdown(seconds: number) {
const m = Math.floor(seconds / 60)
const s = Math.max(0, seconds % 60)
return `${m}:${String(s).padStart(2, '0')}`
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
if (typeof error === 'object' && error && 'message' in error) {
const msg = (error as { message?: unknown }).message
if (typeof msg === 'string') return msg
}
try {
return String(error)
} catch {
return ''
}
}
async function init() {
if (!import.meta.client) return
stopTimers()
status.value = 'loading'
errorMessage.value = ''
try {
const response: QrCodeResponse = await generateQrCode()
token.value = response.token
expireSeconds.value = response.expiresIn || 300
qrCodeUrl.value = `${window.location.origin}/qr-confirm?qrCodeKey=${encodeURIComponent(
response.token
)}`
status.value = 'active'
statusTimer = setInterval(async () => {
try {
const current = await checkQrCodeStatus(token.value)
if (current.expiresIn !== undefined) expireSeconds.value = current.expiresIn
if (current.status === 'scanned') {
status.value = 'scanned'
return
}
if (current.status === 'expired') {
status.value = 'expired'
stopTimers()
return
}
if (current.status === 'confirmed') {
stopTimers()
emit('loginSuccess', current)
}
} catch {
// ignore polling errors, keep polling
}
}, 2000)
countdownTimer = setInterval(() => {
expireSeconds.value = Math.max(0, expireSeconds.value - 1)
if (expireSeconds.value <= 0) {
status.value = 'expired'
stopTimers()
}
}, 1000)
} catch (error: unknown) {
status.value = 'error'
errorMessage.value = getErrorMessage(error) || '生成二维码失败'
message.error(errorMessage.value)
emit('loginError', errorMessage.value)
}
}
async function refresh() {
refreshing.value = true
try {
await init()
} finally {
refreshing.value = false
}
}
onMounted(() => {
init()
})
onUnmounted(() => {
stopTimers()
})
</script>
<style scoped>
.qr-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0 12px;
}
.qr-box {
width: 100%;
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
}
.qr-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.tip {
color: #111827;
margin: 0;
font-size: 14px;
}
.muted {
color: #6b7280;
margin: 0;
font-size: 12px;
}
.actions {
margin-top: 10px;
}
.icon {
font-size: 48px;
}
.ok {
color: #16a34a;
}
.bad {
color: #ef4444;
}
.ok-text {
margin: 0;
color: #16a34a;
}
.bad-text {
margin: 0;
color: #ef4444;
}
</style>

134
app/components/RichText.vue Normal file
View File

@@ -0,0 +1,134 @@
<template>
<div v-if="mode === 'html'" class="rich-text" v-html="normalizedHtml" />
<div v-else class="rich-text whitespace-pre-wrap break-words">
{{ text }}
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
content?: string | null
}>()
const text = computed(() => (typeof props.content === 'string' ? props.content : ''))
// Heuristic: treat as HTML only when it looks like it contains tags.
const mode = computed(() => (/<[a-z][\s\S]*>/i.test(text.value) ? 'html' : 'text'))
function escapeHtml(input: string) {
return input
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
const normalizedHtml = computed(() => {
const raw = text.value.trim()
if (!raw) return ''
// Some CMS fields store JSON; try to pull out a common HTML field.
if (
(raw.startsWith('{') && raw.endsWith('}')) ||
(raw.startsWith('[') && raw.endsWith(']'))
) {
try {
const parsed: unknown = JSON.parse(raw)
let candidate = ''
if (typeof parsed === 'string') candidate = parsed
else if (isRecord(parsed)) {
const html = parsed.html
const content = parsed.content
const body = parsed.body
if (typeof html === 'string') candidate = html
else if (typeof content === 'string') candidate = content
else if (typeof body === 'string') candidate = body
}
if (candidate && /<[a-z][\s\S]*>/i.test(candidate)) return candidate
// Fallback: render JSON as <pre>.
return `<pre class="rich-pre">${escapeHtml(JSON.stringify(parsed, null, 2))}</pre>`
} catch {
// ignore JSON parse errors
}
}
return raw
})
</script>
<style scoped>
.rich-text {
color: rgba(0, 0, 0, 0.88);
line-height: 1.75;
font-size: 16px;
}
.rich-text :deep(h1) {
font-size: 28px;
font-weight: 700;
margin: 18px 0 12px;
}
.rich-text :deep(h2) {
font-size: 22px;
font-weight: 700;
margin: 16px 0 10px;
}
.rich-text :deep(h3) {
font-size: 18px;
font-weight: 700;
margin: 14px 0 8px;
}
.rich-text :deep(p) {
margin: 10px 0;
}
.rich-text :deep(a) {
color: #1677ff;
text-decoration: underline;
}
.rich-text :deep(ul),
.rich-text :deep(ol) {
padding-left: 20px;
margin: 10px 0;
}
.rich-text :deep(li) {
margin: 6px 0;
}
.rich-text :deep(img) {
max-width: 100%;
height: auto;
display: block;
margin: 12px auto;
}
.rich-text :deep(blockquote) {
margin: 12px 0;
padding: 8px 12px;
border-left: 4px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.02);
}
.rich-pre {
white-space: pre-wrap;
word-break: break-word;
padding: 12px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.04);
font-size: 13px;
line-height: 1.6;
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<a-layout-footer class="footer">
<div class="mx-auto max-w-screen-xl px-4 py-10">
<a-row :gutter="[24, 24]">
<a-col :xs="24" :md="8">
<div class="text-base font-semibold text-white">关注我们</div>
<div class="mt-4 flex items-center gap-4">
<a-avatar shape="square" :size="96" />
<div class="text-sm leading-6 text-gray-400">
公众号/小程序
<br />
获取最新动态
</div>
</div>
</a-col>
<a-col :xs="24" :md="8">
<div class="text-base font-semibold text-white">快速入口</div>
<div class="mt-4 grid gap-2 text-sm text-gray-400">
<NuxtLink class="hover:text-white" to="/platform">平台能力</NuxtLink>
<NuxtLink class="hover:text-white" to="/products">产品矩阵</NuxtLink>
<NuxtLink class="hover:text-white" to="/market">模板/插件市场</NuxtLink>
<NuxtLink class="hover:text-white" to="/deploy">部署方案</NuxtLink>
</div>
</a-col>
<a-col :xs="24" :md="8">
<div class="text-base font-semibold text-white">备案信息</div>
<div class="mt-4 text-sm text-gray-400">{{ icpText }}</div>
</a-col>
</a-row>
<div class="mt-10 border-t border-white/10 pt-6 text-xs text-gray-500">
© {{ year }} {{ siteName }}. All rights reserved.
</div>
</div>
</a-layout-footer>
</template>
<script setup lang="ts">
const { data: siteInfo } = useSiteInfo()
const siteName = computed(() => String((siteInfo.value as any)?.data?.websiteName || '桂乐淘'))
const icpText = computed(() => {
const icp = (siteInfo.value as any)?.data?.icpNo
return icp ? `备案号:${icp}` : '备案号:'
})
const year = new Date().getFullYear()
</script>
<style scoped>
.footer {
background: #000;
padding: 0;
}
</style>

View File

@@ -0,0 +1,557 @@
<template>
<header class="site-header">
<div class="topbar">
<div class="mx-auto flex max-w-screen-xl items-center justify-between gap-4 px-4">
<div class="topbar-left">
<span class="topbar-date">{{ todayText }}</span>
</div>
<div class="topbar-right">
<a-input-search
v-model:value="keywords"
class="topbar-search"
placeholder="请输入关键字"
:allow-clear="true"
@search="onSearch"
/>
<div class="hidden md:flex items-center gap-3">
<template v-if="!isAuthed">
<a-button size="small" type="primary" @click="navigateTo('/login')">登录</a-button>
</template>
<template v-else>
<a-dropdown :trigger="['hover']" placement="bottomRight">
<a-space>
<a-avatar :src="userAvatar" :size="28">
<template v-if="!userAvatar" #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="topbar-user">{{ userName }}</span>
</a-space>
<template #overlay>
<a-menu @click="onUserMenuClick">
<a-menu-item key="console">管理中心</a-menu-item>
<a-menu-item key="profile">个人资料</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">退出登录</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</div>
<a-button class="md:hidden" size="small" @click="open = true">菜单</a-button>
</div>
</div>
</div>
<div v-if="showBrandbar" class="brandbar">
<div class="mx-auto grid max-w-screen-xl grid-cols-12 items-center gap-6 px-4 py-8">
<NuxtLink to="/" class="col-span-12 flex items-center gap-4 md:col-span-6">
<img class="brand-logo" :src="logoUrl" :alt="siteName" />
<div class="brand-title">
<div class="brand-name">{{ siteName }}</div>
<div class="brand-sub">{{ siteSlogan }}</div>
</div>
</NuxtLink>
<div class="col-span-12 text-right md:col-span-6">
<div class="brand-mission">{{ missionText }}</div>
<div class="brand-values">{{ valuesText }}</div>
</div>
</div>
</div>
<a-affix :offset-top="0">
<div class="navbar">
<div class="mx-auto flex max-w-screen-xl items-center justify-between gap-3 px-4">
<NuxtLink to="/" class="navbar-brand">
<img class="navbar-logo" :src="logoUrl" :alt="siteName" />
<span class="navbar-brand-name">
{{ siteName }}
</span>
</NuxtLink>
<nav class="nav hidden md:flex">
<template v-for="item in navItems" :key="item.key">
<a-dropdown v-if="item.children?.length" :trigger="['hover']">
<a class="nav-link" :class="{ active: isActive(item) }" @click.prevent>
{{ item.label }}
</a>
<template #overlay>
<a-menu>
<a-menu-item
v-for="child in item.children"
:key="child.key"
@click="onNavClick(child)"
>
{{ child.label }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<NuxtLink
v-else-if="item.to"
class="nav-link"
:class="{ active: isActive(item) }"
:to="item.to"
>
{{ item.label }}
</NuxtLink>
<a
v-else
class="nav-link"
:class="{ active: isActive(item) }"
:href="item.href"
:target="item.target || undefined"
rel="noopener noreferrer"
>
{{ item.label }}
</a>
</template>
</nav>
<div class="nav-spacer md:hidden" />
</div>
</div>
</a-affix>
</header>
<a-drawer v-model:open="open" title="导航" placement="right">
<a-menu mode="inline" :selected-keys="selectedKeys">
<template v-for="item in navItems" :key="item.key">
<a-menu-item v-if="!item.children?.length" :key="item.key" @click="onNavClick(item)">
{{ item.label }}
</a-menu-item>
<a-sub-menu v-else :key="item.key" :title="item.label">
<a-menu-item
v-for="child in item.children"
:key="child.key"
@click="onNavClick(child)"
>
{{ child.label }}
</a-menu-item>
</a-sub-menu>
</template>
</a-menu>
<div class="mt-4">
<a-button v-if="!isAuthed" block type="primary" @click="onNav('/login')">登录</a-button>
<template v-else>
<a-button block @click="goConsoleCenter">管理中心</a-button>
<a-button block @click="goDeveloperCenter">开发者中心</a-button>
<a-button block @click="onNav('/profile')">个人资料</a-button>
<a-button block danger class="mt-2" @click="logout">退出登录</a-button>
</template>
</div>
</a-drawer>
</template>
<script setup lang="ts">
import { mainNav } from '@/config/nav'
import { getUserInfo } from '@/api/layout'
import type { User } from '@/api/system/user/model'
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
import { getToken, removeToken } from '@/utils/token-util'
import { clearAuthz, hasRole, setAuthzFromUser } from '@/utils/permission'
import { UserOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
const route = useRoute()
const open = ref(false)
const keywords = ref('')
type HeaderNavItem = {
key: string
label: string
to?: string
href?: string
target?: string
children?: HeaderNavItem[]
}
const { data: siteInfo } = useSiteInfo()
const selectedKeys = computed(() => {
const flatten = (items: HeaderNavItem[]) =>
items.flatMap((i) => [i, ...(i.children?.length ? flatten(i.children) : [])])
const all = flatten(navItems.value)
const exactHit = all.find((n) => n.to === route.path)
if (exactHit) return [exactHit.key]
const prefixHit = all
.filter((n) => n.to && n.to !== '/' && route.path.startsWith(n.to))
.sort((a, b) => (b.to?.length ?? 0) - (a.to?.length ?? 0))[0]
if (prefixHit) return [prefixHit.key]
const home = all.find((n) => n.to === '/')
return home ? [home.key] : []
})
type SiteInfoData = {
websiteName?: unknown
websiteLogo?: unknown
websiteIcon?: unknown
topNavs?: unknown
setting?: unknown
config?: unknown
} & Record<string, unknown>
const siteData = computed<SiteInfoData | null>(() => {
const data = siteInfo.value?.data
if (data && typeof data === 'object') return data as SiteInfoData
return null
})
function pickString(source: unknown, key: string) {
if (!source || typeof source !== 'object') return ''
const record = source as Record<string, unknown>
const value = record[key]
return typeof value === 'string' ? value.trim() : ''
}
const siteName = computed(() => {
const websiteName = siteData.value?.websiteName
return typeof websiteName === 'string' && websiteName.trim() ? websiteName.trim() : '桂乐淘'
})
const showBrandbar = computed(() => {
const p = route.path || '/'
if (p === '/') return true
// 文章列表、单页详情、文章详情都显示 brandbar
return p === '/articles' || p.startsWith('/article/') || p.startsWith('/page/') || p.startsWith('/item/')
})
const logoUrl = computed(() => {
const data = siteData.value
const logo = typeof data?.websiteLogo === 'string' ? data.websiteLogo.trim() : ''
const icon = typeof data?.websiteIcon === 'string' ? data.websiteIcon.trim() : ''
return (
logo ||
icon ||
'https://oss.wsdns.cn/20251226/675876f9f5a84732b22efc02b275440a.png'
)
})
const siteSlogan = computed(() => {
const data = siteData.value
const slogan =
pickString(data?.setting, 'slogan') ||
pickString(data?.setting, 'subtitle') ||
pickString(data?.config, 'slogan')
return slogan || 'XINGYUSI BANKRUPTCY TRANSACTION SERVICE PLATFORM'
})
const missionText = computed(() => '致力于企业纾困和破产事务服务')
const valuesText = computed(() => '真诚 · 奉献 · 规范 · 聚力')
function normalizePath(path: unknown) {
if (typeof path !== 'string') return ''
const p = path.trim()
if (!p) return ''
if (/^https?:\/\//i.test(p)) return p
if (p.startsWith('/')) return p
return `/${p}`
}
function normalizeNavTree(list: CmsNavigation[]): HeaderNavItem[] {
const normalizeOne = (n: CmsNavigation): HeaderNavItem => {
const label = String(n.title || n.label || '').trim() || '未命名'
const rawPath = normalizePath(n.path)
const isExternal = /^https?:\/\//i.test(rawPath)
const children =
Array.isArray(n.children) && n.children.length
? n.children
.slice()
.filter((c) => (c.hide ?? 0) !== 1)
.sort((a, b) => (a.sortNumber ?? 0) - (b.sortNumber ?? 0))
.map(normalizeOne)
: undefined
const key = String(n.code || n.navigationId || rawPath || label)
const target = n.target ? String(n.target) : (isExternal ? '_blank' : undefined)
return {
key,
label,
...(isExternal ? { href: rawPath } : { to: rawPath || '/' }),
target,
...(children?.length ? { children } : {})
}
}
return list
.filter((n) => (n.hide ?? 0) !== 1)
.slice()
.sort((a, b) => (a.sortNumber ?? 0) - (b.sortNumber ?? 0))
.map(normalizeOne)
}
const navItems = computed<HeaderNavItem[]>(() => {
const apiNavs = siteData.value?.topNavs
if (Array.isArray(apiNavs) && apiNavs.length) {
return normalizeNavTree(apiNavs as CmsNavigation[])
}
// Fallback when CMS has not configured topNavs.
return mainNav.map((n) => ({ key: n.key || n.to, label: n.label, to: n.to }))
})
function isActive(item: HeaderNavItem) {
const isHit = (candidate: HeaderNavItem): boolean => {
if (candidate.to && candidate.to === route.path) return true
return !!candidate.children?.some(isHit)
}
return isHit(item)
}
function onNavClick(item: HeaderNavItem) {
open.value = false
if (item.href) {
window.open(item.href, item.target || '_blank')
return
}
if (item.to) navigateTo(item.to)
}
function onSearch() {
if (!keywords.value.trim()) return
navigateTo({ path: '/articles', query: { keywords: keywords.value.trim() } })
}
const token = ref('')
const user = ref<User | null>(null)
const isAuthed = computed(() => !!token.value)
const userName = computed(() => String(user.value?.nickname || user.value?.username || '已登录'))
const userAvatar = 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 onNav(to: string) {
open.value = false
navigateTo(to)
}
const todayText = computed(() => {
const d = new Date()
const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()] || ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}日 星期${week}`
})
async function refreshAuth() {
token.value = getToken()
if (!token.value) {
user.value = null
clearAuthz()
return
}
try {
user.value = await getUserInfo()
setAuthzFromUser(user.value)
} catch {
// token may be expired; keep authed UI but without profile info
clearAuthz()
}
}
function goConsoleCenter() {
if (!isAuthed.value) return navigateTo('/login')
open.value = false
navigateTo('/console')
}
function goDeveloperCenter() {
if (!isAuthed.value) return navigateTo('/login')
open.value = false
if (!hasRole('developer')) return message.error('您还不是开发者')
navigateTo('/developer')
}
function logout() {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch {
// ignore
}
clearAuthz()
user.value = null
token.value = ''
open.value = false
navigateTo('/')
}
function onUserMenuClick(info: { key: string }) {
if (info.key === 'console') return goConsoleCenter()
if (info.key === 'developer') return goDeveloperCenter()
if (info.key === 'profile') return navigateTo('/profile')
if (info.key === 'logout') return logout()
}
onMounted(() => {
refreshAuth()
window.addEventListener('auth-token-changed', refreshAuth)
window.addEventListener('storage', refreshAuth)
})
onUnmounted(() => {
window.removeEventListener('auth-token-changed', refreshAuth)
window.removeEventListener('storage', refreshAuth)
})
</script>
<style scoped>
.site-header {
background: #fff;
}
.topbar {
background: #f7f7f7;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
font-size: 12px;
}
.topbar-left {
color: rgba(0, 0, 0, 0.7);
padding: 6px 0;
}
.topbar-right {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
}
.topbar-search {
width: 240px;
}
.topbar-user {
color: rgba(0, 0, 0, 0.85);
}
.brandbar {
background:
linear-gradient(0deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.88)),
radial-gradient(circle at 25% 20%, rgba(220, 38, 38, 0.12), transparent 60%),
radial-gradient(circle at 80% 20%, rgba(59, 130, 246, 0.12), transparent 55%),
linear-gradient(180deg, #f2f4f7, #ffffff);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.brand-logo {
width: 62px;
height: 62px;
object-fit: contain;
display: block;
}
.brand-title {
display: flex;
flex-direction: column;
gap: 4px;
}
.brand-name {
font-size: 28px;
line-height: 1.1;
font-weight: 800;
color: #b91c1c;
}
.brand-sub {
font-size: 12px;
letter-spacing: 0.08em;
color: rgba(0, 0, 0, 0.55);
}
.brand-mission {
font-size: 18px;
font-weight: 700;
color: #1d4ed8;
}
.brand-values {
margin-top: 6px;
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.65);
}
.navbar {
background: #c30000;
border-top: 1px solid rgba(255, 255, 255, 0.15);
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
}
.navbar-brand {
display: inline-flex;
align-items: center;
gap: 10px;
height: 48px;
text-decoration: none;
color: rgba(255, 255, 255, 0.95);
font-weight: 700;
}
.navbar-logo {
width: 28px;
height: 28px;
object-fit: contain;
display: block;
}
.navbar-brand-name {
font-size: 15px;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.navbar-brand:hover {
color: #fff;
}
.nav {
display: flex;
gap: 2px;
}
.nav-link {
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 18px;
color: rgba(255, 255, 255, 0.92);
font-weight: 600;
text-decoration: none;
}
.nav-link:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
.nav-link.active {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.nav-spacer {
height: 48px;
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<a-layout-header class="top-header !bg-white !p-0">
<div class="h-full px-4 flex items-center justify-between">
<div class="logo">
<a-space size="large">
<a-image
src="https://oss.wsdns.cn/20250204/a21e034cdb2441b797f5027db5305be5.png"
class="mb-1"
:width="90"
:preview="false"
@click="navigateTo('/console')"
/>
<!-- <a-dropdown-button @click="handleButtonClick">-->
<!-- {{ productLabel }}-->
<!-- <template #overlay>-->
<!-- <a-menu @click="handleProductMenuClick">-->
<!-- <a-menu-item key="site">-->
<!-- <a-avatar shape="square" :size="22" src="https://oss.wsdns.cn/20250215/2016c6f2da074b09b11a0e3603f5be23.png" />-->
<!-- ·企业官网-->
<!-- </a-menu-item>-->
<!-- <a-menu-item key="mp">-->
<!-- <a-avatar shape="square" :size="22" src="https://oss.wsdns.cn/20250304/e65ea719564e47a1b8da93d6eea8287a.png" />-->
<!-- 小程序开发-->
<!-- </a-menu-item>-->
<!-- <a-menu-item key="oa">-->
<!-- <a-avatar shape="square" :size="22" src="https://oss.wsdns.cn/20250215/457a343dba204d019281d8a23556c4b1.png" />-->
<!-- 办公协同OA-->
<!-- </a-menu-item>-->
<!-- <a-menu-item key="developer">-->
<!-- <a-avatar shape="square" :size="22" src="https://oss.wsdns.cn/20250214/d455a32a7a2043d899e079b4eb9b27b8.png" />-->
<!-- 开发者中心-->
<!-- </a-menu-item>-->
<!-- </a-menu>-->
<!-- </template>-->
<!-- </a-dropdown-button>-->
</a-space>
</div>
<a-dropdown placement="bottomRight" :trigger="['click']">
<div class="user-trigger">
<a-space>
<a-avatar :size="28" :src="user?.avatar || user?.avatarUrl">
<template #icon>
<AppstoreOutlined />
</template>
</a-avatar>
<span class="user-name">
{{ userDisplayName }}
</span>
</a-space>
</div>
<template #overlay>
<a-menu @click="onUserMenuClick">
<a-menu-item v-for="item in mergedUserMenuItems" :key="item.key">
{{ item.label }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
</template>
<script setup lang="ts">
import { AppstoreOutlined } from '@ant-design/icons-vue'
import type { MenuProps } from 'ant-design-vue'
import type { User } from '@/api/system/user/model'
type ConsoleHeaderMenuItem = {
key: string
label: string
}
const props = withDefaults(
defineProps<{
productLabel?: string
defaultJumpKey?: 'site' | 'mp' | 'oa' | 'developer'
user: User | null
userDisplayName: string
userMenuItems?: ConsoleHeaderMenuItem[]
}>(),
{
productLabel: '云·企业官网',
defaultJumpKey: 'site',
userMenuItems: () => [{ key: 'logout', label: '退出登录' }],
}
)
const emit = defineEmits<{
(e: 'logout'): void
(e: 'userMenuClick', key: string): void
}>()
const mergedUserMenuItems = computed<ConsoleHeaderMenuItem[]>(() => {
const items = Array.isArray(props.userMenuItems) ? props.userMenuItems.slice() : []
if (!items.some((i) => i.key === 'account')) {
const accountItem: ConsoleHeaderMenuItem = { key: 'account', label: '账号管理' }
const logoutIndex = items.findIndex((i) => i.key === 'logout')
if (logoutIndex >= 0) items.splice(logoutIndex, 0, accountItem)
else items.push(accountItem)
}
return items
})
const consoleJumpTargets = {
site: '/site',
mp: '/mp',
oa: '/oa',
developer: '/developer',
} as const
function openExternal(url: string) {
if (!import.meta.client) return
if (url.startsWith('http://') || url.startsWith('https://')) {
window.location.href = url
return
}
void navigateTo(url)
}
const handleButtonClick = () => {
openExternal(consoleJumpTargets[props.defaultJumpKey])
}
const handleProductMenuClick: MenuProps['onClick'] = (e) => {
const key = String(e.key) as keyof typeof consoleJumpTargets
const url = consoleJumpTargets[key]
if (!url) return
openExternal(url)
}
function onUserMenuClick(info: { key: string }) {
const key = String(info.key)
emit('userMenuClick', key)
if (key === 'account') {
void navigateTo('/console/account')
return
}
if (key === 'logout') emit('logout')
}
</script>
<style scoped>
.top-header {
height: 56px;
line-height: 56px;
border-radius: 12px;
}
.user-trigger {
height: 36px;
display: flex;
align-items: center;
padding: 0 10px;
border-radius: 9999px;
cursor: pointer;
}
.user-trigger:hover {
background: rgba(0, 0, 0, 0.04);
}
.user-name {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,156 @@
<template>
<div class="space-y-6">
<a-card title="应用列表" class="shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<a-input
v-model:value="keywords"
placeholder="关键词 keywords"
class="w-72"
@press-enter="doSearch"
/>
<a-button type="primary" :loading="pending" @click="doSearch">查询</a-button>
</div>
<a-alert
v-if="error"
class="mt-4"
show-icon
type="error"
:message="String(error)"
/>
<a-table
class="mt-4"
:data-source="list"
:loading="pending"
:pagination="false"
row-key="websiteId"
size="middle"
>
<a-table-column title="ID" data-index="tenantId" width="90" />
<a-table-column title="应用名称" data-index="websiteName" />
<a-table-column title="标识" data-index="websiteCode" width="200" />
<a-table-column title="域名" data-index="domain" width="220" />
<a-table-column title="类型" width="120">
<template #default="{ record }">
<a-tag :color="record.plugin ? 'geekblue' : 'green'">
{{ record.plugin ? '插件' : '应用' }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="状态" width="140">
<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="expirationTime" width="180" />
<a-table-column title="创建时间" data-index="createTime" width="180" />
<a-table-column title="后台" width="220">
<template #default="{ record }">
<a
v-if="record.adminUrl"
class="text-blue-600 hover:underline"
:href="record.adminUrl"
target="_blank"
rel="noreferrer"
>
{{ record.adminUrl }}
</a>
<span v-else class="text-gray-400">-</span>
</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>
</div>
</template>
<script setup lang="ts">
import { pageCmsWebsiteAll } from '@/api/cms/cmsWebsite/index'
import type { CmsWebsite } from '@/api/cms/cmsWebsite/model'
import { usePageSeo } from '@/composables/usePageSeo'
usePageSeo({
title: '应用中心',
description: '使用 pageCmsWebsite 分页读取应用并展示。'
})
const page = ref(1)
const limit = ref(10)
const keywords = ref('')
const { data, pending, error, refresh } = useAsyncData(
'developer-cms-website-page',
() =>
pageCmsWebsiteAll({
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined
}),
{ server: false }
)
const list = computed<CmsWebsite[]>(() => data.value?.list ?? [])
const total = computed(() => data.value?.count ?? 0)
function doSearch() {
page.value = 1
refresh()
}
function onPageChange(nextPage: number) {
page.value = nextPage
refresh()
}
function onPageSizeChange(_current: number, nextSize: number) {
limit.value = nextSize
page.value = 1
refresh()
}
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>