Files
jczxw-pc/app/layouts/developer.vue
2026-04-23 16:30:57 +08:00

667 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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, setCurrentUser } = 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)
// 保存到共享状态,避免其他组件重复请求
setCurrentUser(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>