feat(core): 初始化项目基础架构和CMS功能模块
- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore) - 实现服务端API代理功能,支持文件、模块和服务器API转发 - 创建文章详情页、栏目文章列表页和单页内容展示页面 - 集成Ant Design Vue组件库并实现SSR样式提取功能 - 定义API响应数据结构类型和应用布局组件 - 开发开发者应用中心和文章管理页面 - 实现CMS导航菜单获取和多租户切换功能
This commit is contained in:
439
app/layouts/console.vue
Normal file
439
app/layouts/console.vue
Normal file
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<a-layout class="min-h-screen layout-shell">
|
||||
<a-layout class="w-full px-4 py-4">
|
||||
<ConsoleHeader
|
||||
:user="user"
|
||||
:user-display-name="userDisplayName"
|
||||
@logout="logout"
|
||||
/>
|
||||
|
||||
<a-layout class="body">
|
||||
<a-layout-sider
|
||||
class="sider"
|
||||
:width="240"
|
||||
breakpoint="lg"
|
||||
theme="light"
|
||||
:trigger="null"
|
||||
collapsible
|
||||
v-model:collapsed="collapsed"
|
||||
>
|
||||
<a-dropdown
|
||||
placement="bottomLeft"
|
||||
:trigger="['hover']"
|
||||
:disabled="tenantOptions.length <= 1"
|
||||
>
|
||||
<div class="sider-brand" :class="{ collapsed }">
|
||||
<a-avatar :size="28">
|
||||
<template #icon>
|
||||
<AppstoreOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<div v-if="!collapsed" class="sider-title">
|
||||
控制台
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #overlay>
|
||||
<a-menu
|
||||
:selected-keys="[String(currentTenantId)]"
|
||||
@click="onTenantMenuClick"
|
||||
>
|
||||
<a-menu-item
|
||||
v-for="t in tenantOptions"
|
||||
:key="String(t.tenantId ?? '')"
|
||||
:disabled="switchingTenant || !t.tenantId"
|
||||
>
|
||||
<a-space>
|
||||
<a-avatar
|
||||
:size="20"
|
||||
shape="square"
|
||||
:src="t.logo || t.avatar || t.avatarUrl"
|
||||
>
|
||||
<template v-if="!(t.logo || t.avatar || t.avatarUrl)" #icon>
|
||||
<AppstoreOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<span class="tenant-menu-title">
|
||||
{{ t.tenantName || t.companyName || t.username || `租户 ${t.tenantId}` }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<a-menu
|
||||
mode="inline"
|
||||
theme="light"
|
||||
:selected-keys="selectedKeys"
|
||||
:open-keys="collapsed ? [] : openKeys"
|
||||
:inline-collapsed="collapsed"
|
||||
@open-change="onOpenChange"
|
||||
@click="onMenuClick"
|
||||
>
|
||||
<template v-for="entry in menu" :key="isGroup(entry) ? entry.key : entry.to">
|
||||
<a-sub-menu v-if="isGroup(entry)" :key="entry.key">
|
||||
<template v-if="entry.icon" #icon>
|
||||
<component :is="entry.icon" />
|
||||
</template>
|
||||
<template #title>{{ entry.label }}</template>
|
||||
<a-menu-item v-for="item in entry.children" :key="item.to">
|
||||
<template v-if="item.icon" #icon>
|
||||
<component :is="item.icon" />
|
||||
</template>
|
||||
{{ item.label }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
<a-menu-item v-else :key="entry.to">
|
||||
<template v-if="entry.icon" #icon>
|
||||
<component :is="entry.icon" />
|
||||
</template>
|
||||
{{ entry.label }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
</a-menu>
|
||||
|
||||
<div
|
||||
class="sider-collapse-trigger"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="collapsed ? '展开菜单' : '收起菜单'"
|
||||
@click="toggleCollapsed"
|
||||
@keydown.enter.prevent="toggleCollapsed"
|
||||
@keydown.space.prevent="toggleCollapsed"
|
||||
>
|
||||
<RightOutlined :style="{fontSize: '10px', paddingRight: '4px'}" v-if="collapsed" />
|
||||
<LeftOutlined :style="{fontSize: '10px', paddingRight: '4px'}" v-else />
|
||||
</div>
|
||||
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout class="main">
|
||||
<a-layout-content class="content">
|
||||
<a-spin v-if="!ready" size="large" tip="加载中..." class="spin" />
|
||||
<template v-else>
|
||||
<slot />
|
||||
</template>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { message } from 'ant-design-vue'
|
||||
import { AppstoreOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import { consoleNav, type ConsoleNavEntry, type ConsoleNavGroup, type ConsoleNavItem } from '@/config/console-nav'
|
||||
import { getTenantInfo, getUserInfo } from '@/api/layout'
|
||||
import { getSiteInfo } from '@/api/cms/cmsWebsite'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
import { listAdminsByPhoneAll } from '@/api/system/user'
|
||||
import type { Company } from '@/api/system/company/model'
|
||||
import type { CmsWebsite } from '@/api/cms/cmsWebsite/model'
|
||||
import { TEMPLATE_ID } from '@/config/setting'
|
||||
import { getToken, removeToken } from '@/utils/token-util'
|
||||
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
|
||||
import { getTenantId } from '@/utils/domain'
|
||||
|
||||
const route = useRoute()
|
||||
const collapsed = ref(false)
|
||||
const menu = computed(() => consoleNav)
|
||||
const user = ref<User | null>(null)
|
||||
const tenant = ref<Company | null>(null)
|
||||
const siteInfo = ref<CmsWebsite | null>(null)
|
||||
const tenantOptions = ref<User[]>([])
|
||||
const switchingTenant = ref(false)
|
||||
|
||||
const currentTenantId = computed(() => {
|
||||
const tid = tenant.value?.tenantId ?? user.value?.tenantId
|
||||
if (typeof tid === 'number' && Number.isFinite(tid)) return tid
|
||||
return Number(getTenantId(String(TEMPLATE_ID)))
|
||||
})
|
||||
|
||||
const currentTenantName = computed(() => {
|
||||
// const websiteName = siteInfo.value?.websiteName?.trim()
|
||||
// if (websiteName) return websiteName
|
||||
return (
|
||||
// tenant.value?.shortName?.trim() ||
|
||||
// tenant.value?.companyName?.trim() ||
|
||||
// tenant.value?.tenantName?.trim() ||
|
||||
'小程序'
|
||||
)
|
||||
})
|
||||
|
||||
const currentTenantLogo = computed(() => {
|
||||
const candidate =
|
||||
tenant.value?.companyLogo ||
|
||||
'https://mp.websoft.top/logo.png'
|
||||
if (typeof candidate !== 'string') return ''
|
||||
const normalized = candidate.trim()
|
||||
if (!normalized || normalized === 'null' || normalized === 'undefined') return ''
|
||||
return normalized
|
||||
})
|
||||
|
||||
const userDisplayName = computed(() => {
|
||||
const u = user.value
|
||||
const nickname = u?.nickname?.trim()
|
||||
const username = u?.username?.trim()
|
||||
const phone = u?.phone?.trim()
|
||||
const mobile = u?.mobile?.trim()
|
||||
if (nickname) return nickname
|
||||
if (username) return username
|
||||
if (phone) return phone
|
||||
if (mobile) return mobile
|
||||
return '当前用户'
|
||||
})
|
||||
|
||||
function isGroup(entry: ConsoleNavEntry): entry is ConsoleNavGroup {
|
||||
return 'children' in entry
|
||||
}
|
||||
|
||||
function entriesToItems(entries: ConsoleNavEntry[]): ConsoleNavItem[] {
|
||||
return entries.flatMap((entry) => {
|
||||
if (isGroup(entry)) return entry.children
|
||||
return [{ key: entry.key, label: entry.label, to: entry.to }]
|
||||
})
|
||||
}
|
||||
|
||||
const selectedKeys = computed(() => {
|
||||
const all = entriesToItems(menu.value)
|
||||
if (route.path === '/console') return ['/console/tenant/index']
|
||||
if (route.path.startsWith('/console/tenant/')) return ['/console/tenant/index']
|
||||
const match = all.find((item) => route.path === item.to)
|
||||
if (match) return [match.to]
|
||||
return []
|
||||
})
|
||||
|
||||
const openKeys = ref<string[]>([])
|
||||
function syncOpenKeys() {
|
||||
const hit = menu.value.find(
|
||||
(entry) => isGroup(entry) && entry.children.some((c) => route.path === c.to || route.path.startsWith(c.to + '/'))
|
||||
)
|
||||
openKeys.value = hit ? [hit.key] : []
|
||||
}
|
||||
|
||||
function toggleCollapsed() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
function onOpenChange(keys: string[]) {
|
||||
openKeys.value = Array.isArray(keys) ? keys.slice(-1) : []
|
||||
}
|
||||
|
||||
function onMenuClick(info: { key: string }) {
|
||||
navigateTo(String(info.key))
|
||||
}
|
||||
|
||||
function logout() {
|
||||
removeToken()
|
||||
try {
|
||||
localStorage.removeItem('TenantId')
|
||||
localStorage.removeItem('UserId')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
clearAuthz()
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
const ready = ref(false)
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => syncOpenKeys(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function normalizeTenantOptions(list: User[]) {
|
||||
const byTenantId = new Map<number, User>()
|
||||
for (const item of list) {
|
||||
const tid = item.tenantId
|
||||
if (typeof tid !== 'number' || !Number.isFinite(tid)) continue
|
||||
if (!byTenantId.has(tid)) byTenantId.set(tid, item)
|
||||
}
|
||||
const options = Array.from(byTenantId.values())
|
||||
options.sort((a, b) => (a.tenantId ?? 0) - (b.tenantId ?? 0))
|
||||
return options
|
||||
}
|
||||
|
||||
async function loadTenantOptions(me: User) {
|
||||
const phone = me.phone || me.mobile
|
||||
if (!phone) {
|
||||
tenantOptions.value = me.tenantId ? [{ ...me }] : []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const list = await listAdminsByPhoneAll({ phone, templateId: Number(TEMPLATE_ID) })
|
||||
const options = normalizeTenantOptions(Array.isArray(list) ? list : [])
|
||||
tenantOptions.value = options.length ? options : (me.tenantId ? [{ ...me }] : [])
|
||||
} catch {
|
||||
tenantOptions.value = me.tenantId ? [{ ...me }] : []
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSessionContext() {
|
||||
const me = await getUserInfo()
|
||||
user.value = me
|
||||
setAuthzFromUser(me)
|
||||
await loadTenantOptions(me)
|
||||
try {
|
||||
siteInfo.value = await getSiteInfo()
|
||||
} catch {
|
||||
siteInfo.value = null
|
||||
}
|
||||
try {
|
||||
tenant.value = await getTenantInfo()
|
||||
} catch {
|
||||
tenant.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function switchTenant(tenantId: number) {
|
||||
if (!import.meta.client) return
|
||||
if (!Number.isFinite(tenantId)) return
|
||||
if (tenantId === currentTenantId.value) return
|
||||
|
||||
switchingTenant.value = true
|
||||
try {
|
||||
const prev = localStorage.getItem('TenantId')
|
||||
localStorage.setItem('TenantId', String(tenantId))
|
||||
try {
|
||||
await refreshSessionContext()
|
||||
} catch (e) {
|
||||
if (prev) localStorage.setItem('TenantId', prev)
|
||||
else localStorage.removeItem('TenantId')
|
||||
throw e
|
||||
}
|
||||
message.success('已切换租户')
|
||||
} catch (e: unknown) {
|
||||
message.error(e instanceof Error ? e.message : '切换租户失败')
|
||||
} finally {
|
||||
switchingTenant.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTenantMenuClick(info: { key: string }) {
|
||||
const tid = Number(info.key)
|
||||
if (!Number.isFinite(tid)) return
|
||||
switchTenant(tid)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
clearAuthz()
|
||||
message.error('请先登录')
|
||||
await navigateTo('/login')
|
||||
ready.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshSessionContext()
|
||||
} catch {
|
||||
clearAuthz()
|
||||
}
|
||||
|
||||
syncOpenKeys()
|
||||
ready.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-shell {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.sider {
|
||||
border-radius: 12px;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 16px 10px;
|
||||
}
|
||||
|
||||
.sider-brand.collapsed {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sider-brand.switchable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sider-brand.switchable:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.sider-title {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sider-actions {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-inline) {
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
.sider-collapse-trigger {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -16px;
|
||||
width: 16px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-left: 0;
|
||||
border-radius: 0 9999px 9999px 0;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sider-collapse-trigger:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.sider-collapse-trigger:active {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-left: 16px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 520px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 380px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user