feat(app): 初始化项目配置和页面结构
- 添加 .dockerignore 和 .env.example 配置文件 - 添加 .gitignore 忽略规则配置 - 创建服务端代理API路由(_file、_modules、_server) - 集成 Ant Design Vue 组件库并配置SSR样式提取 - 定义API响应类型封装 - 创建基础布局组件(blank、console) - 实现应用中心页面和组件(AppsCenter) - 添加文章列表测试页面 - 配置控制台导航菜单结构 - 实现控制台头部组件 - 创建联系页面表单
This commit is contained in:
220
app/components/QrLogin.vue
Normal file
220
app/components/QrLogin.vue
Normal 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>
|
||||
56
app/components/SiteFooter.vue
Normal file
56
app/components/SiteFooter.vue
Normal 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>
|
||||
|
||||
199
app/components/SiteHeader.vue
Normal file
199
app/components/SiteHeader.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<a-affix :offset-top="0">
|
||||
<a-layout-header class="header">
|
||||
<div class="mx-auto flex max-w-screen-xl items-center justify-between px-4">
|
||||
<NuxtLink to="/" class="flex items-center gap-2 text-white">
|
||||
<a-image src="https://oss.wsdns.cn/20251226/675876f9f5a84732b22efc02b275440a.png" :width="120" :preview="false" />
|
||||
</NuxtLink>
|
||||
<a-menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
:selected-keys="selectedKeys"
|
||||
class="menu"
|
||||
>
|
||||
<a-menu-item v-for="item in nav" :key="item.to">
|
||||
<NuxtLink :to="item.to">{{ item.label }}</NuxtLink>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
|
||||
<template v-if="!isAuthed">
|
||||
<a-button type="primary" @click="navigateTo('/login')">登录</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-dropdown :trigger="['hover']" placement="bottomRight">
|
||||
<a-space>
|
||||
<a-avatar :src="userAvatar" :size="32">
|
||||
<template v-if="!userAvatar" #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<span class="text-gray-100">{{ userName }}</span>
|
||||
</a-space>
|
||||
<template #overlay>
|
||||
<a-menu @click="onUserMenuClick">
|
||||
<a-menu-item key="console">管理中心</a-menu-item>
|
||||
<a-menu-item key="developer">开发者中心</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>
|
||||
|
||||
<div class="md:hidden">
|
||||
<a-button type="primary" @click="open = true">菜单</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
</a-affix>
|
||||
|
||||
<a-drawer v-model:open="open" title="导航" placement="right">
|
||||
<a-menu mode="inline" :selected-keys="selectedKeys">
|
||||
<a-menu-item v-for="item in nav" :key="item.to" @click="onNav(item.to)">
|
||||
{{ item.label }}
|
||||
</a-menu-item>
|
||||
</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 { 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 nav = computed(() => mainNav)
|
||||
const route = useRoute()
|
||||
const open = ref(false)
|
||||
|
||||
const selectedKeys = computed(() => {
|
||||
const hit = nav.value.find((n) => n.to === route.path)
|
||||
if (hit) return [hit.to]
|
||||
if (route.path.startsWith('/products')) return ['/products']
|
||||
return ['/']
|
||||
})
|
||||
|
||||
const { data: siteInfo } = useSiteInfo()
|
||||
const siteName = computed(() => {
|
||||
const data = siteInfo.value?.data
|
||||
if (data && typeof data === 'object' && 'websiteName' in data) {
|
||||
const websiteName = (data as { websiteName?: unknown }).websiteName
|
||||
if (typeof websiteName === 'string' && websiteName.trim()) return websiteName.trim()
|
||||
}
|
||||
return '网宿软件'
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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>
|
||||
.header {
|
||||
background: #111827;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 0;
|
||||
}
|
||||
.menu {
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
min-width: 520px;
|
||||
}
|
||||
</style>
|
||||
169
app/components/console/ConsoleHeader.vue
Normal file
169
app/components/console/ConsoleHeader.vue
Normal 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>
|
||||
156
app/components/developer/AppsCenter.vue
Normal file
156
app/components/developer/AppsCenter.vue
Normal 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>
|
||||
Reference in New Issue
Block a user