初始化2

This commit is contained in:
2026-04-08 17:10:58 +08:00
commit 4986d90eb9
532 changed files with 112617 additions and 0 deletions

513
app/layouts/admin.vue Normal file
View File

@@ -0,0 +1,513 @@
<template>
<a-layout class="layout-shell">
<!-- 左侧固定侧边栏 -->
<a-layout-sider
class="sider"
:width="220"
:collapsed-width="64"
breakpoint="lg"
theme="dark"
:trigger="null"
collapsible
v-model:collapsed="collapsed"
>
<!-- Logo 区域 -->
<div class="sider-logo" @click="navigateTo('/admin')">
<img src="/logo.png" alt="logo" class="logo-img" />
<transition name="logo-text">
<span v-if="!collapsed" class="logo-name">平台管理</span>
</transition>
</div>
<!-- 菜单 -->
<a-menu
mode="inline"
theme="dark"
: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-tag
v-if="!collapsed && (entry as AdminNavLink).badge"
:color="(entry as AdminNavLink).badge === 'NEW' ? 'green' : 'orange'"
class="nav-badge"
>{{ (entry as AdminNavLink).badge }}</a-tag>
</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 v-if="collapsed" style="font-size: 10px" />
<LeftOutlined v-else style="font-size: 10px" />
</div>
</a-layout-sider>
<!-- 右侧主区域 -->
<a-layout class="main-layout" :class="{ 'main-layout--collapsed': collapsed }">
<!-- 顶部 Header -->
<a-layout-header class="main-header">
<div class="header-inner">
<div class="header-left">
<a-tag color="red" class="admin-badge">管理员</a-tag>
<span class="page-title">{{ currentPageTitle }}</span>
</div>
<div class="header-right">
<a-tooltip title="返回控制台">
<a-button type="text" size="small" @click="navigateTo('/console')">
<template #icon><DesktopOutlined /></template>
控制台
</a-button>
</a-tooltip>
<a-dropdown placement="bottomRight" :trigger="['click']">
<div class="user-trigger">
<a-avatar :size="28" :src="user?.avatar || user?.avatarUrl">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="user-name">{{ userDisplayName }}</span>
<DownOutlined style="font-size: 10px; opacity: 0.6; margin-left: 2px" />
</div>
<template #overlay>
<a-menu @click="onUserMenuClick" class="user-dropdown-menu">
<a-menu-item key="account-info">
<UserOutlined style="margin-right: 8px" />
账户信息
</a-menu-item>
<a-menu-item key="orders">
<ShoppingOutlined style="margin-right: 8px" />
我的订单
</a-menu-item>
<a-menu-item key="account-kyc">
<IdcardOutlined style="margin-right: 8px" />
实名认证
</a-menu-item>
<a-menu-divider />
<a-menu-item key="developer">
<CodeOutlined style="margin-right: 8px" />
开发者中心
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" class="logout-item">
<LogoutOutlined style="margin-right: 8px" />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</a-layout-header>
<!-- 内容区 -->
<a-layout-content class="main-content">
<a-spin v-if="!ready" size="large" tip="加载中..." class="spin" />
<template v-else>
<slot />
</template>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import {
CodeOutlined,
DesktopOutlined,
DownOutlined,
IdcardOutlined,
LeftOutlined,
LogoutOutlined,
RightOutlined,
ShoppingOutlined,
UserOutlined,
} from '@ant-design/icons-vue'
import { adminNav, type AdminNavEntry, type AdminNavGroup, type AdminNavLink } from '@/config/admin-nav'
import { getUserInfo } from '@/api/layout'
import { getToken, removeToken } from '@/utils/token-util'
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
import type { User } from '@/api/system/user/model'
const route = useRoute()
const collapsed = ref(false)
const menu = computed(() => adminNav)
const user = ref<User | null>(null)
const userDisplayName = computed(() => {
const u = user.value
return u?.nickname?.trim() || u?.username?.trim() || u?.phone?.trim() || u?.mobile?.trim() || '管理员'
})
function isGroup(entry: AdminNavEntry): entry is AdminNavGroup {
return 'children' in entry
}
// 当前页面标题
const currentPageTitle = computed(() => {
const path = route.path
for (const entry of menu.value) {
if (isGroup(entry)) {
const hit = entry.children.find((c) => path === c.to || path.startsWith(c.to + '/'))
if (hit) return hit.label
} else {
if (path === entry.to || path.startsWith(entry.to + '/')) return (entry as AdminNavLink).label
}
}
return '平台管理'
})
// 选中 key
const selectedKeys = computed(() => {
const path = route.path
for (const entry of menu.value) {
if (isGroup(entry)) {
const hit = entry.children.find((c) => path === c.to || path.startsWith(c.to + '/'))
if (hit) return [hit.to]
} else {
if (path === (entry as AdminNavLink).to) return [(entry as AdminNavLink).to]
}
}
return []
})
// 展开 key
const openKeys = ref<string[]>([])
function syncOpenKeys() {
const path = route.path
const hit = menu.value.find(
(entry) => isGroup(entry) && entry.children.some((c) => path === c.to || path.startsWith(c.to + '/'))
)
openKeys.value = hit ? [hit.key] : []
}
function onOpenChange(keys: string[]) {
openKeys.value = Array.isArray(keys) ? keys.slice(-1) : []
}
function onMenuClick(info: { key: string }) {
navigateTo(String(info.key))
}
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
function logout() {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch { /* ignore */ }
clearAuthz()
navigateTo('/')
}
function onUserMenuClick(info: { key: string }) {
const key = String(info.key)
if (key === 'developer') navigateTo('/developer')
if (key === 'account-info') navigateTo('/console/account')
if (key === 'orders') navigateTo('/console/orders')
if (key === 'account-kyc') navigateTo('/console/account/kyc')
if (key === 'logout') logout()
}
watch(() => route.path, syncOpenKeys, { immediate: true })
const ready = ref(false)
onMounted(async () => {
const token = getToken()
if (!token) {
clearAuthz()
message.error('请先登录')
await navigateTo('/login')
ready.value = true
return
}
try {
const me = await getUserInfo()
user.value = me
setAuthzFromUser(me)
// 权限校验isAdmin=true 方可访问管理后台
if (!(me as any).isAdmin) {
message.error('您无权访问平台管理中心,该区域仅限管理员使用')
clearAuthz()
await navigateTo('/console')
ready.value = true
return
}
} catch {
user.value = null
clearAuthz()
await navigateTo('/login')
ready.value = true
return
}
syncOpenKeys()
ready.value = true
})
</script>
<style scoped>
/* ===== 整体 shell ===== */
.layout-shell {
min-height: 100vh;
background: #f5f7fa;
}
/* ===== 左侧边栏(深红黑调,区别于 console 的深蓝黑)===== */
.sider {
position: fixed !important;
top: 0;
left: 0;
height: 100vh;
z-index: 100;
overflow: hidden;
background: #1a0f0f !important;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.2);
}
:deep(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
:deep(.ant-menu-dark) {
background: transparent;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
:deep(.ant-menu-dark .ant-menu-item-selected) {
background-color: rgba(239, 68, 68, 0.25) !important;
}
:deep(.ant-menu-dark .ant-menu-item:hover),
:deep(.ant-menu-dark .ant-menu-submenu-title:hover) {
background-color: rgba(255, 255, 255, 0.06) !important;
}
/* Logo 区域 */
.sider-logo {
height: 56px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 20px;
cursor: pointer;
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
overflow: hidden;
white-space: nowrap;
}
.logo-img {
height: 16px;
width: auto;
flex-shrink: 0;
display: block;
}
.logo-name {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
background: linear-gradient(135deg, #ffffff 0%, #fca5a5 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.logo-text-enter-active,
.logo-text-leave-active {
transition: opacity 0.2s, width 0.2s;
}
.logo-text-enter-from,
.logo-text-leave-to {
opacity: 0;
width: 0;
}
/* 菜单中的 badge */
.nav-badge {
margin-left: auto;
font-size: 10px;
padding: 0 4px;
line-height: 16px;
height: 16px;
}
/* 折叠按钮 */
.sider-collapse-trigger {
position: absolute;
right: -14px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #2d1515;
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: 0;
border-radius: 0 9999px 9999px 0;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
z-index: 101;
}
.sider-collapse-trigger:hover {
color: #fff;
background: #3d1f1f;
}
/* ===== 右侧主区域 ===== */
.main-layout {
margin-left: 220px;
transition: margin-left 0.2s ease;
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f5f7fa;
}
.main-layout--collapsed {
margin-left: 64px;
}
/* 顶部 Header */
.main-header {
position: sticky;
top: 0;
z-index: 99;
height: 56px;
line-height: 56px;
padding: 0;
background: #fff !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.header-inner {
height: 100%;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.admin-badge {
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.page-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.user-trigger {
height: 36px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 9999px;
cursor: pointer;
color: #374151;
transition: background 0.2s;
}
.user-trigger:hover {
background: rgba(0, 0, 0, 0.04);
}
.user-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: #374151;
}
.user-dropdown-menu {
min-width: 180px;
}
.logout-item {
color: #ff4d4f;
}
.logout-item:hover {
background-color: #fff1f0;
}
/* 内容区 */
.main-content {
flex: 1;
padding: 20px 24px;
min-height: calc(100vh - 56px);
}
.spin {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
</style>

8
app/layouts/blank.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<a-layout class="min-h-screen">
<a-layout-content>
<NuxtPage />
</a-layout-content>
</a-layout>
</template>

515
app/layouts/console.vue Normal file
View File

@@ -0,0 +1,515 @@
<template>
<a-layout class="layout-shell">
<!-- 左侧固定侧边栏 -->
<a-layout-sider
class="sider"
:width="220"
:collapsed-width="64"
breakpoint="lg"
theme="dark"
:trigger="null"
collapsible
v-model:collapsed="collapsed"
>
<!-- Logo 区域 -->
<div class="sider-logo" @click="navigateTo('/console')">
<img src="/logo.png" alt="logo" class="logo-img" />
<transition name="logo-text">
<span v-if="!collapsed" class="logo-name">控制台</span>
</transition>
</div>
<!-- 菜单 -->
<a-menu
mode="inline"
theme="dark"
: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 v-if="collapsed" style="font-size: 10px" />
<LeftOutlined v-else style="font-size: 10px" />
</div>
</a-layout-sider>
<!-- 右侧主区域 -->
<a-layout class="main-layout" :class="{ 'main-layout--collapsed': collapsed }">
<!-- 顶部 Header -->
<a-layout-header class="main-header">
<div class="header-inner">
<!-- 左侧面包屑或页面标题可扩展 -->
<div class="header-left">
<span class="page-title">{{ currentPageTitle }}</span>
</div>
<!-- 右侧用户区 -->
<div class="header-right">
<!-- 消息通知铃铛 -->
<NotificationBell />
<a-dropdown placement="bottomRight" :trigger="['click']">
<div class="user-trigger">
<a-avatar :size="28" :src="user?.avatar || user?.avatarUrl">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="user-name">{{ userDisplayName }}</span>
<DownOutlined style="font-size: 10px; opacity: 0.6; margin-left: 2px" />
</div>
<template #overlay>
<a-menu @click="onUserMenuClick" class="user-dropdown-menu">
<a-menu-item key="account-info">
<UserOutlined style="margin-right: 8px" />
账户信息
</a-menu-item>
<a-menu-item key="orders">
<ShoppingOutlined style="margin-right: 8px" />
我的订单
</a-menu-item>
<a-menu-item key="account-kyc">
<IdcardOutlined style="margin-right: 8px" />
实名认证
</a-menu-item>
<template v-if="isDeveloper">
<a-menu-divider />
<a-menu-item key="developer">
<CodeOutlined style="margin-right: 8px" />
开发者中心
</a-menu-item>
</template>
<template v-if="isAdmin">
<a-menu-divider />
<a-menu-item key="admin">
<SettingOutlined style="margin-right: 8px" />
平台管理
</a-menu-item>
</template>
<a-menu-divider />
<a-menu-item key="logout" class="logout-item">
<LogoutOutlined style="margin-right: 8px" />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</a-layout-header>
<!-- 内容区 -->
<a-layout-content class="main-content">
<a-spin v-if="!ready" size="large" tip="加载中..." class="spin" />
<template v-else>
<slot />
</template>
</a-layout-content>
</a-layout>
<!-- 邀请通知组件 -->
<ClientOnly>
<InviteNotification ref="inviteNotificationRef" />
</ClientOnly>
</a-layout>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import {
CodeOutlined,
DownOutlined,
IdcardOutlined,
LeftOutlined,
LogoutOutlined,
RightOutlined,
SettingOutlined,
ShoppingOutlined,
UserOutlined,
} from '@ant-design/icons-vue'
import { consoleNav, type ConsoleNavEntry, type ConsoleNavGroup } from '@/config/console-nav'
import { getUserInfo } from '@/api/layout'
import { getToken, removeToken } from '@/utils/token-util'
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
import type { User } from '@/api/system/user/model'
const route = useRoute()
const collapsed = ref(false)
const menu = computed(() => consoleNav)
const user = ref<User | null>(null)
const userDisplayName = computed(() => {
const u = user.value
return u?.nickname?.trim() || u?.username?.trim() || u?.phone?.trim() || u?.mobile?.trim() || '当前用户'
})
const isAdmin = computed(() => !!(user.value as any)?.isAdmin)
const isDeveloper = computed(() => (user.value as any)?.type === 2)
function isGroup(entry: ConsoleNavEntry): entry is ConsoleNavGroup {
return 'children' in entry
}
// 当前页面标题
const currentPageTitle = computed(() => {
const path = route.path
for (const entry of menu.value) {
if (isGroup(entry)) {
const hit = entry.children.find((c) => path === c.to || path.startsWith(c.to + '/'))
if (hit) return hit.label
} else {
if (path === entry.to || path.startsWith(entry.to + '/')) return entry.label
}
}
return '控制台'
})
// 选中 key
const selectedKeys = computed(() => {
const path = route.path
for (const entry of menu.value) {
if (isGroup(entry)) {
const hit = entry.children.find((c) => path === c.to || path.startsWith(c.to + '/'))
if (hit) return [hit.to]
} else {
if (path === entry.to) return [entry.to]
}
}
return []
})
// 展开 key自动展开当前路由所在的分组
const openKeys = ref<string[]>([])
function syncOpenKeys() {
const path = route.path
const hit = menu.value.find(
(entry) => isGroup(entry) && entry.children.some((c) => path === c.to || path.startsWith(c.to + '/'))
)
openKeys.value = hit ? [hit.key] : []
}
function onOpenChange(keys: string[]) {
openKeys.value = Array.isArray(keys) ? keys.slice(-1) : []
}
function onMenuClick(info: { key: string }) {
navigateTo(String(info.key))
}
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
function logout() {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch { /* ignore */ }
clearAuthz()
navigateTo('/')
}
function onUserMenuClick(info: { key: string }) {
const key = String(info.key)
if (key === 'home') navigateTo('/')
if (key === 'account-info') navigateTo('/console/account')
if (key === 'orders') navigateTo('/console/orders')
if (key === 'account-members') navigateTo('/console/account/members')
if (key === 'account-security') navigateTo('/console/account/security')
if (key === 'account-kyc') navigateTo('/console/account/kyc')
if (key === 'developer') navigateTo('/developer')
if (key === 'admin') navigateTo('/admin')
if (key === 'logout') logout()
}
watch(() => route.path, syncOpenKeys, { immediate: true })
const ready = ref(false)
const inviteNotificationRef = ref()
onMounted(async () => {
const token = getToken()
if (!token) {
clearAuthz()
message.error('请先登录')
await navigateTo('/login')
ready.value = true
return
}
try {
const me = await getUserInfo()
user.value = me
setAuthzFromUser(me)
} catch {
user.value = null
clearAuthz()
}
syncOpenKeys()
ready.value = true
})
</script>
<style scoped>
/* ===== 整体 shell ===== */
.layout-shell {
min-height: 100vh;
background: #f5f7fa;
}
/* ===== 左侧边栏 ===== */
.sider {
position: fixed !important;
top: 0;
left: 0;
height: 100vh;
z-index: 100;
overflow: hidden;
background: #111827 !important;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
/* 覆盖 Ant Design sider 背景 */
}
:deep(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
:deep(.ant-menu-dark) {
background: transparent;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
:deep(.ant-menu-dark .ant-menu-item-selected) {
background-color: rgba(59, 130, 246, 0.25) !important;
}
:deep(.ant-menu-dark .ant-menu-item:hover),
:deep(.ant-menu-dark .ant-menu-submenu-title:hover) {
background-color: rgba(255, 255, 255, 0.06) !important;
}
/* Logo 区域 */
.sider-logo {
height: 56px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 20px;
cursor: pointer;
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
overflow: hidden;
white-space: nowrap;
}
.sider-logo:hover .logo-img,
.sider-logo:hover .logo-name {
opacity: 0.8;
}
.logo-img {
height: 16px;
width: auto;
flex-shrink: 0;
display: block;
}
.logo-name {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
background: linear-gradient(135deg, #ffffff 0%, #a5c8ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.logo-text-enter-active,
.logo-text-leave-active {
transition: opacity 0.2s, width 0.2s;
}
.logo-text-enter-from,
.logo-text-leave-to {
opacity: 0;
width: 0;
}
/* 折叠按钮 */
.sider-collapse-trigger {
position: absolute;
right: -14px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #1f2937;
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: 0;
border-radius: 0 9999px 9999px 0;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
z-index: 101;
}
.sider-collapse-trigger:hover {
color: #fff;
background: #374151;
}
/* ===== 右侧主区域 ===== */
.main-layout {
margin-left: 220px;
transition: margin-left 0.2s ease;
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f5f7fa;
}
/* 当 sider 折叠时,右侧内容区跟着收缩 */
.main-layout--collapsed {
margin-left: 64px;
}
/* 顶部 Header */
.main-header {
position: sticky;
top: 0;
z-index: 99;
height: 56px;
line-height: 56px;
padding: 0;
background: #fff !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.header-inner {
height: 100%;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.user-trigger {
height: 36px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 9999px;
cursor: pointer;
color: #374151;
transition: background 0.2s;
}
.user-trigger:hover {
background: rgba(0, 0, 0, 0.04);
}
.user-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: #374151;
}
/* 头像下拉菜单样式 */
.user-dropdown-menu {
min-width: 180px;
}
.user-dropdown-menu .ant-menu-item,
.user-dropdown-menu .ant-menu-submenu-title {
padding: 8px 16px;
}
.logout-item {
color: #ff4d4f;
}
.logout-item:hover {
background-color: #fff1f0;
}
/* 内容区 */
.main-content {
flex: 1;
padding: 20px 24px;
min-height: calc(100vh - 56px);
}
.spin {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
</style>

66
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,66 @@
<template>
<a-spin :spinning="spinning" size="large" tip="加载中..." class="layout-spin">
<a-layout class="min-h-screen layout-shell" :class="{ 'layout-shell--pending': spinning }">
<SiteHeader />
<a-layout-content class="content">
<slot />
</a-layout-content>
<SiteFooter />
</a-layout>
</a-spin>
</template>
<script setup lang="ts">
import SiteFooter from '@/components/SiteFooter.vue'
import SiteHeader from '@/components/SiteHeader.vue'
import { nextTick } from 'vue'
const nuxtApp = useNuxtApp()
const bootstrapping = ref(true)
const navigating = ref(false)
const spinning = computed(() => bootstrapping.value || navigating.value)
if (import.meta.client) {
nuxtApp.hooks.hook('page:start', () => {
navigating.value = true
})
nuxtApp.hooks.hook('page:finish', () => {
navigating.value = false
})
onMounted(async () => {
// Wait for hydration + one paint to reduce CSS flicker (antdv cssinjs / tailwind).
await nextTick()
requestAnimationFrame(() => {
bootstrapping.value = false
})
})
}
</script>
<style scoped>
.content {
background: #fff;
}
.layout-shell {
transition: opacity 0.12s ease;
}
.layout-shell--pending {
opacity: 0;
pointer-events: none;
}
.layout-spin {
display: block;
min-height: 100vh;
}
.layout-spin :deep(.ant-spin-nested-loading),
.layout-spin :deep(.ant-spin-container) {
min-height: 100vh;
}
</style>

664
app/layouts/developer.vue Normal file
View File

@@ -0,0 +1,664 @@
<template>
<a-layout class="min-h-screen layout-shell">
<a-layout class="w-full">
<SiteHeader />
<a-layout class="body w-full mx-auto max-w-screen-xl">
<!-- 未授权提示页面 -->
<div v-if="!accessible && ready" class="w-full">
<!-- 场景 1已登录但既不是平台开发者也没有被邀请到任何应用 -->
<div v-if="accessChecked && !hasJoinedApps" class="apply-developer-page">
<div class="apply-card">
<div class="icon-wrapper">
<span class="icon">🛠</span>
</div>
<h1 class="title">加入开发者中心</h1>
<p class="subtitle">
你还没有加入任何应用请联系应用管理员发送邀请链接或申请平台开发者资质以创建自己的应用
</p>
<div class="features">
<div class="feature-item">
<span class="feature-icon">🔑</span>
<span>获取 API Key调用 200+ REST 接口</span>
</div>
<div class="feature-item">
<span class="feature-icon">📦</span>
<span>创建和管理您的应用</span>
</div>
<div class="feature-item">
<span class="feature-icon">💻</span>
<span>申请源码访问权限</span>
</div>
<div class="feature-item">
<span class="feature-icon">🤖</span>
<span>接入 AI 智能体 API</span>
</div>
</div>
<div class="actions">
<a-button
type="primary"
size="large"
class="apply-btn"
:loading="applying"
@click="handleApply"
>
申请平台开发者资质
</a-button>
<a-button
size="large"
class="back-btn"
@click="navigateTo('/console')"
>
返回用户中心
</a-button>
</div>
<div class="tips">
<p>💡 收到应用邀请后刷新此页面即可进入开发者中心</p>
<p>如有疑问请联系客服或发送邮件至 support@websopy.com</p>
</div>
</div>
</div>
</div>
<template v-else>
<!-- 侧边栏 -->
<a-layout-sider
class="sider"
:width="240"
breakpoint="lg"
theme="light"
:trigger="null"
collapsible
v-model:collapsed="collapsed"
>
<!-- 品牌区 -->
<div class="sider-brand" :class="{ collapsed }">
<div class="sider-brand-icon">
<span class="text-lg">🛠</span>
</div>
<div v-if="!collapsed" class="sider-brand-text">
<div class="sider-title">开发者中心</div>
<div class="sider-subtitle">Developer Console</div>
</div>
</div>
<!-- 用户信息非折叠状态 -->
<div v-if="!collapsed && user" class="sider-user">
<a-avatar :size="32" class="sider-user-avatar">
{{ userDisplayName.charAt(0).toUpperCase() }}
</a-avatar>
<div class="sider-user-info">
<div class="sider-user-name">{{ userDisplayName }}</div>
<a-tag :color="isPlatformDev ? 'green' : 'blue'" class="sider-user-role">
{{ isPlatformDev ? '平台开发者' : '协作成员' }}
</a-tag>
</div>
</div>
<!-- 分组导航菜单 -->
<div class="sider-nav">
<template v-for="(group, gIdx) in navGroups" :key="gIdx">
<!-- 分组标题 -->
<div v-if="group.name" class="nav-group-label">{{ group.name }}</div>
<!-- 菜单项 -->
<div
v-for="item in group.items"
:key="item.key"
class="nav-item"
:class="{ active: isActive(item.to), collapsed }"
@click="navigateTo(item.to)"
>
<span class="nav-item-icon">{{ item.icon }}</span>
<span v-if="!collapsed" class="nav-item-label">{{ item.label }}</span>
<a-tag
v-if="!collapsed && item.badge"
:color="item.badge === 'NEW' ? 'green' : 'orange'"
class="nav-item-badge"
>{{ item.badge }}</a-tag>
</div>
</template>
</div>
<!-- 底部返回入口 -->
<div v-if="!collapsed" class="sider-footer">
<div class="sider-footer-link" @click="navigateTo('/developer-center')">
<span>📄</span> 开发文档
</div>
<div class="sider-footer-link" @click="navigateTo('/')">
<span>🏠</span> 返回首页
</div>
</div>
<!-- 折叠触发器 -->
<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' }" v-if="collapsed" />
<LeftOutlined :style="{ fontSize: '10px' }" v-else />
</div>
</a-layout-sider>
<!-- 主内容区 -->
<a-layout class="main">
<a-layout-content class="content">
<a-spin v-if="!ready && isClient" size="large" tip="加载中..." class="spin" />
<NuxtPage />
</a-layout-content>
</a-layout>
</template>
</a-layout>
<SiteFooter />
</a-layout>
<!-- 邀请通知组件 -->
<ClientOnly>
<InviteNotification />
</ClientOnly>
</a-layout>
</template>
<script setup lang="ts">
import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { developerNav } from '@/config/developer-nav'
import { getUserInfo } from '@/api/layout'
import { getToken } from '@/utils/token-util'
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
import type { User } from '@/api/system/user/model'
import { useAppPermission } from '@/composables/useAppPermission'
import SiteHeader from '~/components/SiteHeader.vue'
import SiteFooter from '~/components/SiteFooter.vue'
import InviteNotification from '~/components/invite/InviteNotification.vue'
const route = useRoute()
const collapsed = ref(false)
const user = ref<User | null>(null)
const isClient = ref(false)
onMounted(() => {
isClient.value = true
})
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 '开发者'
})
// 按分组整理导航
const navGroups = computed(() => {
const groups: { name: string; items: typeof developerNav }[] = []
const groupMap = new Map<string, typeof developerNav[0][]>()
for (const item of developerNav) {
const g = item.group ?? ''
if (!groupMap.has(g)) groupMap.set(g, [])
groupMap.get(g)!.push(item)
}
for (const [name, items] of groupMap) {
groups.push({ name, items })
}
return groups
})
function isActive(to: string) {
if (to === '/developer') return route.path === '/developer'
return route.path === to || route.path.startsWith(to + '/')
}
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
// ============ 权限控制 ============
const ready = ref(false)
const accessible = ref(false)
const accessChecked = ref(false)
const hasJoinedApps = ref(false)
const isPlatformDev = ref(false)
const applying = ref(false)
const { checkDeveloperAccess, loadAppPermissions, isPlatformDeveloper } = useAppPermission()
onMounted(async () => {
const token = getToken()
if (!token) {
clearAuthz()
message.warning('请先登录后访问开发者中心')
await navigateTo(`/login?from=${encodeURIComponent(route.path)}`)
ready.value = true
return
}
try {
const me = await getUserInfo()
user.value = me
setAuthzFromUser(me)
// 检查开发者访问权限type===2 或有参与的应用都可以进入
const result = await checkDeveloperAccess()
accessChecked.value = true
accessible.value = result.accessible
hasJoinedApps.value = result.hasJoinedApps
isPlatformDev.value = result.isPlatformDeveloper
// 如果有权限,加载完整的应用权限列表
if (result.accessible) {
await loadAppPermissions()
}
}
catch {
// check-access 接口失败时降级type=2 仍然放行
const userType = localStorage.getItem('UserType')
if (userType === '2') {
accessible.value = true
accessChecked.value = true
isPlatformDev.value = true
}
else {
accessible.value = false
accessChecked.value = true
}
}
ready.value = true
})
// 申请开发者资质
async function handleApply() {
applying.value = true
try {
message.info('开发者资质申请功能即将上线,敬请期待!')
}
finally {
applying.value = false
}
}
</script>
<style scoped>
.layout-shell {
background: #f0f2f5;
}
/* 侧边栏 */
.sider {
border-radius: 14px;
overflow: visible;
position: relative;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.sider-brand {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 16px 12px;
border-bottom: 1px solid #f0f0f0;
}
.sider-brand.collapsed {
justify-content: center;
padding: 18px 8px 12px;
}
.sider-brand-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
font-size: 18px;
}
.sider-brand-text {
flex: 1;
min-width: 0;
}
.sider-title {
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
font-weight: 600;
line-height: 1.3;
}
.sider-subtitle {
color: rgba(0, 0, 0, 0.35);
font-size: 11px;
line-height: 1.3;
margin-top: 1px;
}
/* 用户信息 */
.sider-user {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: linear-gradient(135deg, #f5f0ff 0%, #eff6ff 100%);
margin: 10px 10px 0;
border-radius: 10px;
}
.sider-user-avatar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
}
.sider-user-info {
flex: 1;
min-width: 0;
}
.sider-user-name {
font-size: 13px;
font-weight: 500;
color: rgba(0, 0, 0, 0.88);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sider-user-role {
font-size: 10px;
margin: 0;
line-height: 1.5;
}
/* 导航 */
.sider-nav {
padding: 8px 8px 0;
flex: 1;
}
.nav-group-label {
font-size: 11px;
font-weight: 600;
color: rgba(0, 0, 0, 0.35);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 14px 10px 5px;
user-select: none;
}
.nav-item {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 10px;
border-radius: 9px;
cursor: pointer;
transition: all 0.15s ease;
color: rgba(0, 0, 0, 0.72);
font-size: 14px;
margin-bottom: 2px;
user-select: none;
}
.nav-item:hover {
background: rgba(99, 102, 241, 0.07);
color: #5145cd;
}
.nav-item.active {
background: rgba(99, 102, 241, 0.12);
color: #4f46e5;
font-weight: 500;
}
.nav-item.collapsed {
justify-content: center;
padding: 9px;
}
.nav-item-icon {
font-size: 16px;
flex-shrink: 0;
width: 20px;
text-align: center;
line-height: 1;
}
.nav-item-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item-badge {
font-size: 10px;
padding: 0 5px;
line-height: 16px;
height: 16px;
flex-shrink: 0;
}
/* 底部快捷入口 */
.sider-footer {
padding: 12px 10px 14px;
border-top: 1px solid #f0f0f0;
margin-top: 8px;
}
.sider-footer-link {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 8px;
cursor: pointer;
color: rgba(0, 0, 0, 0.45);
font-size: 13px;
transition: all 0.15s;
margin-bottom: 2px;
}
.sider-footer-link:hover {
background: #f5f5f5;
color: rgba(0, 0, 0, 0.72);
}
/* 折叠触发器 */
.sider-collapse-trigger {
position: absolute;
top: 50%;
right: -16px;
transform: translateY(-50%);
width: 16px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.1);
border-left: 0;
border-radius: 0 9999px 9999px 0;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.06);
color: rgba(0, 0, 0, 0.45);
cursor: pointer;
z-index: 2;
transition: all 0.15s;
}
.sider-collapse-trigger:hover {
background: #f5f5f5;
color: #4f46e5;
}
/* 内容区 */
.body {
margin: 16px auto;
padding: 0 16px;
}
.main {
margin-left: 16px;
background: transparent;
}
.content {
min-height: 520px;
background: #fff;
border-radius: 14px;
padding: 0;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.05);
}
.forbidden-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 480px;
}
.spin {
display: flex;
align-items: center;
justify-content: center;
min-height: 480px;
}
/* 申请开发者页面 */
.apply-developer-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 600px;
padding: 40px 20px;
}
.apply-card {
max-width: 560px;
width: 100%;
background: #fff;
border-radius: 16px;
padding: 48px 40px;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.icon-wrapper {
width: 80px;
height: 80px;
margin: 0 auto 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.icon {
font-size: 40px;
}
.title {
font-size: 24px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 12px;
}
.subtitle {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 32px;
line-height: 1.6;
}
.features {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 32px;
text-align: left;
}
.feature-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 10px;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.feature-icon {
font-size: 18px;
}
.actions {
display: flex;
gap: 12px;
justify-content: center;
margin-bottom: 24px;
}
.apply-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
font-weight: 500;
}
.apply-btn:hover {
opacity: 0.9;
}
.back-btn {
color: rgba(0, 0, 0, 0.65);
}
.tips {
font-size: 12px;
color: rgba(0, 0, 0, 0.35);
line-height: 1.8;
}
@media (max-width: 640px) {
.apply-card {
padding: 32px 24px;
}
.features {
grid-template-columns: 1fr;
}
.actions {
flex-direction: column;
}
}
</style>

246
app/layouts/oa.vue Normal file
View File

@@ -0,0 +1,246 @@
<template>
<a-layout class="min-h-screen layout-shell">
<a-layout class="w-full px-4 py-4">
<ConsoleHeader
product-label="办公协同OA"
default-jump-key="oa"
:user="user"
:user-display-name="userDisplayName"
:user-menu-items="userMenuItems"
@user-menu-click="onUserMenuClick"
/>
<a-layout class="body">
<a-layout-sider
class="sider"
:width="240"
breakpoint="lg"
theme="light"
:trigger="null"
collapsible
v-model:collapsed="collapsed"
>
<div class="sider-brand" :class="{ collapsed }">
<a-avatar shape="square" :size="28" src="https://oss.wsdns.cn/20250215/457a343dba204d019281d8a23556c4b1.png">
<template #icon>
<AppstoreOutlined />
</template>
</a-avatar>
<div v-if="!collapsed" class="sider-title">
办公协同OA
</div>
</div>
<a-menu
mode="inline"
theme="light"
:selected-keys="selectedKeys"
:inline-collapsed="collapsed"
@click="onMenuClick"
>
<a-menu-item v-for="item in menu" :key="item.to">
{{ item.label }}
</a-menu-item>
</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 { oaNav } from '@/config/oa-nav'
import { getUserInfo } from '@/api/layout'
import { getToken, removeToken } from '@/utils/token-util'
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
import type { User } from '@/api/system/user/model'
const route = useRoute()
const collapsed = ref(false)
const menu = computed(() => oaNav)
const user = ref<User | null>(null)
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 '当前用户'
})
const userMenuItems = [
{ key: 'home', label: '返回首页' },
{ key: 'logout', label: '退出登录' },
]
const selectedKeys = computed(() => {
const match = menu.value.find((item) => route.path === item.to)
if (match) return [match.to]
if (route.path.startsWith('/oa')) return ['/oa']
return []
})
function onMenuClick(info: { key: string }) {
navigateTo(String(info.key))
}
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
function logout() {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch {
// ignore
}
clearAuthz()
navigateTo('/')
}
function onUserMenuClick(key: string) {
if (key === 'home') navigateTo('/')
if (key === 'logout') logout()
}
const ready = ref(false)
onMounted(async () => {
const token = getToken()
if (!token) {
clearAuthz()
message.error('请先登录')
await navigateTo('/login')
ready.value = true
return
}
try {
const me = await getUserInfo()
user.value = me
setAuthzFromUser(me)
} catch {
user.value = null
clearAuthz()
}
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-title {
color: rgba(0, 0, 0, 0.88);
font-size: 16px;
}
: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>