Files
team-api/web/admin/src/views/AdminLayout.vue
T

1375 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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.
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, provide } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import axios from 'axios'
import { marked } from 'marked'
import request from '@/utils/request'
import RouteErrorBoundary from '@/components/RouteErrorBoundary.vue'
import { useWatermark } from '@/composables/useWatermark'
import { useSiteName } from '@/composables/useSiteName'
import {
IconThunderbolt,
IconDashboard,
IconUserGroup,
IconApps,
IconBranch,
IconFile,
IconCommand,
IconIdcard,
IconStorage,
IconGift,
IconTag,
IconCodeBlock,
IconClockCircle,
IconSafe,
IconSettings,
IconMenuFold,
IconMenuUnfold,
IconMessage,
IconNotification,
IconSend,
IconRight,
IconCalendar,
IconLayers,
IconHome,
IconUser,
IconCheckCircleFill,
} from '@arco-design/web-vue/es/icon'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const { siteName } = useSiteName()
const collapsed = ref(false)
const mobileMenuOpen = ref(false)
const collapsedGroups = reactive<Record<string, boolean>>({})
const isMobile = ref(false)
const demoMode = ref(false)
const demoMessage = ref('')
// --- Version & Update ---
const appVersion = ref(__APP_VERSION__ || 'dev')
const currentVersion = ref('') // 后端实时版本(权威来源:/admin/update/status 的 current_version)
const hasUpdate = ref(false)
const latestVersion = ref('')
const updateModalVisible = ref(false)
const updateLoading = ref(false)
const executing = ref(false)
const rollingBack = ref(false)
const checking = ref(false)
const updateStatus = ref<any>(null)
const releaseNotes = ref('')
const releaseUrl = ref('')
const updatePollTimer = ref<ReturnType<typeof setInterval> | null>(null)
const releaseNotesHtml = computed(() => {
if (!releaseNotes.value) return ''
return marked(releaseNotes.value) as string
})
const isUpdating = computed(() => updateStatus.value?.updating === true)
const isFailed = computed(() => updateStatus.value?.update_progress?.phase === 'failed')
const isComplete = computed(() => updateStatus.value?.update_progress?.phase === 'complete')
const isDocker = computed(() => updateStatus.value?.deployment_mode === 'docker')
const progress = computed(() => updateStatus.value?.update_progress)
const rollbackAvailable = computed(() => updateStatus.value?.rollback_available === true)
const backupVersion = computed(() => updateStatus.value?.backup_version || '')
// 取版本号的 base 部分:去掉 v 前缀和 -N-gXXXX / +N / _N 等构建后缀,
// 仅保留主版本号用于判断"是否同一发行版",避免 git describe 后缀干扰比较
function baseVersion(v: string): string {
if (!v) return ''
const s = String(v).replace(/^v/, '')
const i = s.search(/[-+_]/)
return i > 0 ? s.slice(0, i) : s
}
// 是否真的有新版本可更新:除后端 has_update 外,再用展示的当前版本与最新版本做一次校验,
// 二者一致(同一发行版)时不展示更新入口,避免后端 has_update 滞后/缓存误报导致
// "相同版本"仍显示【立即更新】按钮
const canUpdate = computed(() => {
if (!latestVersion.value || !hasUpdate.value) return false
const current = currentVersion.value || appVersion.value
return baseVersion(current) !== baseVersion(latestVersion.value)
})
async function checkUpdate(force = false) {
try {
const res = await request.get('/admin/update/check', { params: { force } })
const data = res.data?.data
if (data) {
hasUpdate.value = data.has_update
latestVersion.value = data.latest_version || ''
releaseNotes.value = data.release_notes || ''
releaseUrl.value = data.release_url || ''
}
} catch {
// silent
}
}
async function fetchUpdateStatus() {
try {
const res = await request.get('/admin/update/status')
updateStatus.value = res.data?.data
// 以 /status 的 current_version 为权威当前版本:它直接读后端 consts.Version,
// 是二进制实时版本,不受 /check 的检查缓存影响,保证更新判断始终基于真实版本
if (res.data?.data?.current_version) {
currentVersion.value = res.data.data.current_version
}
} catch {
// silent
}
}
function openUpdateModal() {
updateModalVisible.value = true
fetchUpdateStatus()
checkUpdate() // 打开弹窗时刷新版本检查,确保当前版本/最新版本为最新
}
// 立即检测:force=true 跳过后端检查缓存,强制向上游拉取最新版本信息
async function manualCheck() {
checking.value = true
try {
await Promise.all([fetchUpdateStatus(), checkUpdate(true)])
} finally {
checking.value = false
}
}
function closeUpdateModal() {
updateModalVisible.value = false
stopPolling()
}
function startPolling() {
stopPolling()
updatePollTimer.value = setInterval(fetchUpdateStatus, 2000)
}
function stopPolling() {
if (updatePollTimer.value) {
clearInterval(updatePollTimer.value)
updatePollTimer.value = null
}
}
async function executeUpdate() {
if (!latestVersion.value) return
executing.value = true
try {
await request.post('/admin/update/execute', { version: latestVersion.value })
Message.success('系统更新已启动')
startPolling()
} catch (e: any) {
Message.error(e.response?.data?.message || '启动更新失败')
} finally {
executing.value = false
}
}
async function doRollback() {
rollingBack.value = true
try {
await request.post('/admin/update/rollback')
Message.success('回滚已启动,系统将重启...')
startPolling()
} catch (e: any) {
Message.error(e.response?.data?.message || '回滚失败')
} finally {
rollingBack.value = false
}
}
function getPhaseLabel(phase: string): string {
const map: Record<string, string> = {
downloading: '下载中', verifying: '校验中', backing_up: '备份中',
replacing: '替换中', restarting: '重启中', complete: '完成', failed: '失败',
}
return map[phase] || phase
}
function getStepStatus(phase: string): string {
if (!progress.value) return 'wait'
const phases = ['downloading', 'verifying', 'backing_up', 'replacing', 'restarting']
const currentIdx = phases.indexOf(progress.value.phase)
const stepIdx = phases.indexOf(phase)
if (isFailed.value) return stepIdx < currentIdx ? 'finish' : stepIdx === currentIdx ? 'error' : 'wait'
if (isComplete.value) return 'finish'
if (stepIdx < currentIdx) return 'finish'
if (stepIdx === currentIdx) return 'process'
return 'wait'
}
// --- End Version & Update ---
provide('demoMode', demoMode)
const { mount: mountWatermark, unmount: unmountWatermark } = useWatermark(demoMessage)
function updateMobile() {
isMobile.value = window.innerWidth <= 768
}
const hoveredGroup = ref<string | null>(null)
const popupTop = ref(0)
let popupHideTimer: ReturnType<typeof setTimeout> | null = null
const activeKey = computed(() => route.name as string)
function toggleGroup(group: string) {
if (collapsed.value) {
collapsed.value = false
collapsedGroups[group] = false
return
}
collapsedGroups[group] = !isGroupCollapsed(group)
}
function isGroupCollapsed(group: string): boolean {
if (group in collapsedGroups) return collapsedGroups[group]
return group !== 'dashboard' && group !== 'ai' && group !== 'tenants'
}
function onGroupMouseEnter(groupKey: string, event: MouseEvent) {
if (!collapsed.value) return
clearTimeout(popupHideTimer!)
hoveredGroup.value = groupKey
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const groupData = menuGroups.find(g => g.key === groupKey)
const estimatedHeight = 32 + (groupData?.items.length || 0) * 36
popupTop.value = Math.max(8, Math.min(rect.top, window.innerHeight - estimatedHeight - 8))
}
function onGroupMouseLeave() {
popupHideTimer = setTimeout(() => {
hoveredGroup.value = null
}, 120)
}
function onPopupMouseEnter() {
clearTimeout(popupHideTimer!)
}
function onPopupMouseLeave() {
popupHideTimer = setTimeout(() => {
hoveredGroup.value = null
}, 120)
}
const hoveredGroupData = computed(() => {
return menuGroups.find(g => g.key === hoveredGroup.value)
})
function handlePopupItemClick(key: string) {
hoveredGroup.value = null
router.push({ name: key })
}
function onCollapseEnter(el: Element) {
const e = el as HTMLElement
e.style.height = '0'
e.style.overflow = 'hidden'
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
e.offsetHeight // force reflow
e.style.height = e.scrollHeight + 'px'
}
function onCollapseAfterEnter(el: Element) {
const e = el as HTMLElement
e.style.height = 'auto'
e.style.overflow = ''
}
function onCollapseLeave(el: Element) {
const e = el as HTMLElement
e.style.height = e.scrollHeight + 'px'
e.style.overflow = 'hidden'
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
e.offsetHeight // force reflow
e.style.height = '0'
}
function onCollapseAfterLeave(el: Element) {
const e = el as HTMLElement
e.style.height = ''
e.style.overflow = ''
}
const menuGroups = [
{
key: 'dashboard',
label: '数据看板',
icon: IconDashboard,
items: [
{ name: 'AdminWorkbench', label: '工作台', icon: IconThunderbolt },
{ name: 'AdminDashboard', label: '仪表盘', icon: IconDashboard },
{ name: 'AdminRealtimeMonitor', label: '实时监控', icon: IconCommand },
],
},
{
key: 'ai',
label: '大模型',
icon: IconApps,
items: [
{ name: 'AdminModels', label: '模型列表', icon: IconApps },
{ name: 'AdminModelGroups', label: '模型分组', icon: IconStorage },
{ name: 'AdminChannels', label: '渠道管理', icon: IconBranch },
{ name: 'AdminTaskLogs', label: '任务日志', icon: IconCalendar },
{ name: 'AdminUsageLogs', label: '用量日志', icon: IconFile },
{ name: 'AdminRequestAuditLogs', label: '请求审计日志', icon: IconCommand },
],
},
{
key: 'tenants',
label: '租户管理',
icon: IconUserGroup,
items: [
{ name: 'AdminTenants', label: '租户列表', icon: IconHome },
{ name: 'AdminTenantLevels', label: '租户级别', icon: IconLayers },
{ name: 'AdminMembers', label: '成员列表', icon: IconUser },
],
},
{
key: 'finance',
label: '财务中心',
icon: IconIdcard,
items: [
{ name: 'AdminOrders', label: '订单管理', icon: IconCodeBlock },
{ name: 'AdminTransactions', label: '交易流水', icon: IconCommand },
{ name: 'AdminRedemptions', label: '兑换码管理', icon: IconGift },
{ name: 'AdminPromoCodes', label: '优惠码管理', icon: IconTag },
],
},
{
key: 'security',
label: '安全审计',
icon: IconSafe,
items: [
{ name: 'AdminLoginHistory', label: '登录历史', icon: IconClockCircle },
{ name: 'AdminTenantLoginHistory', label: '租户登录历史', icon: IconClockCircle },
{ name: 'AdminSessions', label: '会话管理', icon: IconClockCircle },
{ name: 'AdminPermissions', label: '权限管理', icon: IconSafe },
{ name: 'AdminAudit', label: '操作日志', icon: IconFile },
],
},
{
key: 'operations',
label: '运营管理',
icon: IconNotification,
items: [
{ name: 'AdminNotificationTemplates', label: '通知模板', icon: IconNotification },
{ name: 'AdminMessages', label: '消息管理', icon: IconSend },
{ name: 'AdminEmailLogs', label: '邮件发送记录', icon: IconFile },
{ name: 'AdminAnnouncements', label: '公告管理', icon: IconMessage },
{ name: 'AdminTickets', label: '工单管理', icon: IconCommand },
{ name: 'AdminFeedback', label: '反馈管理', icon: IconMessage },
{ name: 'AdminAgreements', label: '用户协议', icon: IconFile },
{ name: 'AdminHelpCategories', label: '帮助分类', icon: IconLayers },
{ name: 'AdminHelpArticles', label: '帮助文章', icon: IconFile },
],
},
{
key: 'monitoring',
label: '运维监控',
icon: IconCommand,
items: [
{ name: 'AdminMonitor', label: '系统监控', icon: IconCommand },
{ name: 'AdminTrafficFlow', label: '流量流向', icon: IconCommand },
{ name: 'AdminModelPerformance', label: '模型性能', icon: IconCommand },
{ name: 'AdminAlertRules', label: '告警规则', icon: IconNotification },
{ name: 'AdminAlertEvents', label: '告警记录', icon: IconFile },
{ name: 'AdminErrorLogs', label: '错误日志', icon: IconFile },
{ name: 'AdminChannelErrors', label: '渠道错误监控', icon: IconFile },
{ name: 'AdminContentFilterLogs', label: '拦截日志', icon: IconFile },
{ name: 'AdminCronJobs', label: '定时任务', icon: IconFile },
],
},
{
key: 'system',
label: '系统',
icon: IconSettings,
items: [
{ name: 'AdminUsers', label: '用户管理', icon: IconUserGroup },
{ name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock },
{ name: 'AdminSettings', label: '系统设置', icon: IconSettings },
{ name: 'AdminFiles', label: '文件管理', icon: IconStorage },
],
},
]
const breadcrumbItems = computed(() => {
return route.matched
.filter(item => item.meta?.title)
.map(item => item.meta.title as string)
})
function handleMenuUpdate(key: string) {
if (collapsed.value) {
collapsed.value = false
return
}
router.push({ name: key })
mobileMenuOpen.value = false
}
async function handleLogout() {
try {
await authStore.logout()
} catch {
// best-effort
}
Message.success('已退出登录')
router.push({ name: 'AdminLogin' })
}
const displayLabel = computed(() => authStore.user?.display_name || authStore.user?.username || '管理员')
const userMenuVisible = ref(false)
function toggleUserMenu() {
userMenuVisible.value = !userMenuVisible.value
}
function closeUserMenu() {
userMenuVisible.value = false
}
function handleUserDropdown(key: string) {
userMenuVisible.value = false
if (key === 'profile') {
router.push({ name: 'AdminProfile' })
} else if (key === 'logout') {
handleLogout()
}
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.admin-header__user-wrapper')) {
userMenuVisible.value = false
}
}
// --- 工作台菜单红点 ---
// 工作台是聚合视图,红点才是常驻发现入口:管理员不会每天主动点开工作台,
// 但会看见菜单上的角标。后端 badges 与 summary 共用同一份 30s Redis 缓存,
// 60s 轮询不会额外压库。
const workbenchBadges = ref<Record<string, number>>({})
const workbenchUrgent = ref(0)
const workbenchTotal = ref(0)
let badgeTimer: ReturnType<typeof setInterval> | null = null
// 菜单项角标:工作台本身显示待办总数,其余菜单显示后端映射到该路由的待办数
function badgeOf(name: string): number {
if (name === 'AdminWorkbench') return workbenchTotal.value
return workbenchBadges.value[name] || 0
}
async function fetchWorkbenchBadges() {
try {
const res = await request.get('/admin/workbench/badges')
const data = res.data?.data
if (!data) return
workbenchBadges.value = data.menus || {}
workbenchUrgent.value = data.urgent || 0
workbenchTotal.value = data.total || 0
} catch {
// 静默:红点拉取失败不该打扰用户,也不该阻塞布局
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
updateMobile()
window.addEventListener('resize', updateMobile)
// Check for updates on mount
checkUpdate()
fetchUpdateStatus() // 取后端实时版本,用于准确的"是否可更新"判断
fetchWorkbenchBadges()
badgeTimer = setInterval(fetchWorkbenchBadges, 60000)
axios.get('/api/settings/public').then((res) => {
const settings = res.data?.data?.settings
if (settings) {
demoMode.value = !!settings.demo_mode
demoMessage.value = settings.demo_message || '演示环境,数据不可修改'
if (demoMode.value) {
mountWatermark(document.body)
}
}
}).catch(() => {})
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', updateMobile)
clearTimeout(popupHideTimer!)
if (badgeTimer) clearInterval(badgeTimer)
stopPolling()
unmountWatermark()
})
</script>
<template>
<div class="admin-layout">
<!-- Desktop Sidebar -->
<div class="admin-sidebar" :class="{ 'admin-sidebar--collapsed': collapsed }">
<div class="admin-sidebar__logo" @click="openUpdateModal" style="cursor: pointer;">
<img src="/favicon.png" alt="Logo" class="admin-sidebar__logo-icon" />
<Transition name="fade">
<span v-if="!collapsed" class="admin-sidebar__logo-text">
{{ siteName || 'Team-API' }}
<span class="admin-sidebar__version">
{{ appVersion === 'dev' ? 'dev' : 'v' + appVersion }}
<span v-if="canUpdate" class="admin-sidebar__update-dot" title="有新版本可用"></span>
</span>
</span>
</Transition>
</div>
<div class="admin-sidebar__menu">
<div v-for="group in menuGroups" :key="group.key">
<div
class="admin-sidebar__group-header"
:class="{ 'admin-sidebar__group-header--hovered': hoveredGroup === group.key }"
@click="toggleGroup(group.key)"
@mouseenter="onGroupMouseEnter(group.key, $event)"
@mouseleave="onGroupMouseLeave"
>
<component :is="group.icon" class="admin-sidebar__group-icon" />
<span class="admin-sidebar__group-label">{{ group.label }}</span>
<IconRight class="admin-sidebar__group-chevron" :class="{ 'admin-sidebar__group-chevron--collapsed': isGroupCollapsed(group.key) }" />
</div>
<Transition name="collapse" @enter="onCollapseEnter" @after-enter="onCollapseAfterEnter" @leave="onCollapseLeave" @after-leave="onCollapseAfterLeave">
<div v-show="!isGroupCollapsed(group.key)" class="admin-sidebar__group-items">
<div
v-for="item in group.items"
:key="item.name"
class="admin-sidebar__item"
:class="{ 'admin-sidebar__item--active': activeKey === item.name }"
@click="handleMenuUpdate(item.name)"
>
<component :is="item.icon" class="admin-sidebar__icon" />
<span class="admin-sidebar__text">{{ item.label }}</span>
<span
v-if="badgeOf(item.name) > 0"
class="admin-sidebar__badge"
:class="{ 'admin-sidebar__badge--urgent': item.name === 'AdminWorkbench' && workbenchUrgent > 0 }"
>{{ badgeOf(item.name) > 99 ? '99+' : badgeOf(item.name) }}</span>
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- Collapsed Popup Menu -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="hoveredGroup && collapsed && hoveredGroupData"
class="sidebar-popup"
:style="{ top: popupTop + 'px' }"
@mouseenter="onPopupMouseEnter"
@mouseleave="onPopupMouseLeave"
>
<div class="sidebar-popup__title">{{ hoveredGroupData.label }}</div>
<div
v-for="item in hoveredGroupData.items"
:key="item.name"
class="sidebar-popup__item"
:class="{ 'sidebar-popup__item--active': activeKey === item.name }"
@click="handlePopupItemClick(item.name)"
>
<component :is="item.icon" class="sidebar-popup__item-icon" />
<span>{{ item.label }}</span>
</div>
</div>
</Transition>
</Teleport>
<!-- Mobile Menu Overlay -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="mobileMenuOpen"
class="mobile-overlay"
@click="mobileMenuOpen = false"
/>
</Transition>
<Transition name="slide">
<div v-if="mobileMenuOpen" class="admin-sidebar admin-sidebar--mobile">
<div class="admin-sidebar__logo">
<img src="/favicon.png" alt="Logo" class="admin-sidebar__logo-icon" />
<span class="admin-sidebar__logo-text">{{ siteName || 'Team-API' }}</span>
</div>
<div class="admin-sidebar__menu">
<div v-for="group in menuGroups" :key="group.key">
<div class="admin-sidebar__group-header" @click="toggleGroup(group.key)">
<component :is="group.icon" class="admin-sidebar__group-icon" />
<span class="admin-sidebar__group-label">{{ group.label }}</span>
<IconRight class="admin-sidebar__group-chevron" :class="{ 'admin-sidebar__group-chevron--collapsed': isGroupCollapsed(group.key) }" />
</div>
<Transition name="collapse" @enter="onCollapseEnter" @after-enter="onCollapseAfterEnter" @leave="onCollapseLeave" @after-leave="onCollapseAfterLeave">
<div v-show="!isGroupCollapsed(group.key)" class="admin-sidebar__group-items">
<div
v-for="item in group.items"
:key="item.name"
class="admin-sidebar__item"
:class="{ 'admin-sidebar__item--active': activeKey === item.name }"
@click="handleMenuUpdate(item.name)"
>
<component :is="item.icon" class="admin-sidebar__icon" />
<span class="admin-sidebar__text">{{ item.label }}</span>
<span
v-if="badgeOf(item.name) > 0"
class="admin-sidebar__badge"
:class="{ 'admin-sidebar__badge--urgent': item.name === 'AdminWorkbench' && workbenchUrgent > 0 }"
>{{ badgeOf(item.name) > 99 ? '99+' : badgeOf(item.name) }}</span>
</div>
</div>
</Transition>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Main Area -->
<div class="admin-main">
<!-- Header -->
<header class="admin-header">
<div class="admin-header__left">
<button class="admin-header__menu-btn" @click="isMobile ? (mobileMenuOpen = !mobileMenuOpen) : (collapsed = !collapsed)">
<IconMenuFold v-if="!isMobile && !collapsed" class="admin-header__menu-icon" />
<IconMenuUnfold v-else-if="!isMobile && collapsed" class="admin-header__menu-icon" />
<svg v-else class="admin-header__menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div v-if="breadcrumbItems.length" class="admin-header__breadcrumbs">
<template v-for="(item, index) in breadcrumbItems" :key="index">
<span class="admin-header__breadcrumb">{{ item }}</span>
<span v-if="index < breadcrumbItems.length - 1" class="admin-header__breadcrumb-sep">/</span>
</template>
</div>
</div>
<div class="admin-header__right">
<div class="admin-header__user-wrapper" style="position: relative;">
<div class="admin-header__user" @click.stop="toggleUserMenu">
<div class="admin-header__avatar">{{ displayLabel.charAt(0) }}</div>
<span class="hidden sm:inline">{{ displayLabel }}</span>
<svg class="hidden sm:inline h-4 w-4" :style="{ transform: userMenuVisible ? 'rotate(180deg)' : '', transition: 'transform 0.2s', color: 'var(--ta-text-tertiary)' }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<Transition name="fade">
<div v-if="userMenuVisible" class="admin-header__user-menu">
<div class="admin-header__user-menu-item" @click="handleUserDropdown('profile')">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
<span>个人信息</span>
</div>
<div class="admin-header__user-menu-divider" />
<div class="admin-header__user-menu-item admin-header__user-menu-item--danger" @click="handleUserDropdown('logout')">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
<span>退出登录</span>
</div>
</div>
</Transition>
</div>
</div>
</header>
<!-- Content -->
<main class="admin-content page-bg">
<router-view v-slot="{ Component, route: currentRoute }">
<RouteErrorBoundary>
<Transition name="page-fade" mode="out-in">
<component :is="Component" :key="currentRoute.fullPath" />
</Transition>
</RouteErrorBoundary>
</router-view>
</main>
</div>
<!-- Update Modal -->
<a-modal
v-model:visible="updateModalVisible"
:footer="false"
:closable="true"
:mask-closable="true"
title-align="start"
width="520px"
@cancel="closeUpdateModal"
>
<template #title>
<div style="display: flex; align-items: center; gap: 8px;">
<span>系统更新</span>
<a-tag v-if="isDocker" color="blue" size="small">Docker</a-tag>
<a-tag v-else color="green" size="small">二进制</a-tag>
</div>
</template>
<!-- Version info -->
<div style="display: flex; gap: 16px; margin-bottom: 20px;">
<a-card :bordered="false" class="update-modal-stat" size="small">
<div class="update-modal-stat__title">当前版本</div>
<div class="update-modal-stat__value">v{{ currentVersion || appVersion }}</div>
</a-card>
<a-card :bordered="false" class="update-modal-stat" size="small">
<div class="update-modal-stat__title">最新版本</div>
<div class="update-modal-stat__value" :style="canUpdate ? { color: '#00b42a' } : {}">
{{ latestVersion ? 'v' + latestVersion : '--' }}
</div>
</a-card>
</div>
<!-- Docker mode hint -->
<template v-if="isDocker">
<a-alert type="info" style="margin-bottom: 16px;">
Docker 部署模式不支持在线自动更新,请手动执行:
</a-alert>
<a-input
model-value="docker compose pull && docker compose up -d"
read-only
style="font-family: monospace; margin-bottom: 16px;"
>
<template #append>
<a-button type="text" size="mini" @click="() => { navigator.clipboard.writeText('docker compose pull && docker compose up -d'); Message.success('已复制') }">复制</a-button>
</template>
</a-input>
</template>
<!-- Update available -->
<!-- 注意不能被 isFailed/isComplete 挡住:后端的 complete/failed 进度快照在进程内
不会过期(上次升级留下的状态会一直挂着),否则新版本出现后更新入口被吞掉、
失败后也无法重试。更新进行中(isUpdating)才是真正的互斥条件 -->
<template v-if="canUpdate && !isUpdating && !isDocker">
<a-divider style="margin: 16px 0;" />
<div style="margin-bottom: 12px;">
<a-typography-text bold>更新说明</a-typography-text>
</div>
<div class="update-modal-notes" v-html="releaseNotesHtml"></div>
<a-divider style="margin: 16px 0;" />
<div style="display: flex; justify-content: space-between; align-items: center;">
<a-typography-text type="secondary" style="font-size: 12px;">⚠️ 更新过程中服务将短暂中断</a-typography-text>
<a-button type="primary" :loading="executing" @click="executeUpdate">
{{ executing ? '启动中...' : '立即更新' }}
</a-button>
</div>
</template>
<!-- No update -->
<template v-if="!canUpdate && !isUpdating">
<a-result>
<template #icon><icon-check-circle-fill style="color: #00b42a; font-size: 32px;" /></template>
<template #title><span style="color: #00b42a; font-size: 14px;">当前已是最新版本</span></template>
<template #extra>
<a-button size="small" :loading="checking" @click="manualCheck">立即检测</a-button>
</template>
</a-result>
</template>
<!-- Update progress -->
<template v-if="isUpdating">
<a-divider style="margin: 16px 0;" />
<a-steps :current="1" size="small" style="margin-bottom: 16px;">
<a-step title="下载" :status="getStepStatus('downloading')" />
<a-step title="校验" :status="getStepStatus('verifying')" />
<a-step title="备份" :status="getStepStatus('backing_up')" />
<a-step title="替换" :status="getStepStatus('replacing')" />
<a-step title="重启" :status="getStepStatus('restarting')" />
</a-steps>
<a-progress
:percent="(progress?.percentage || 0) / 100"
:status="isFailed ? 'danger' : isComplete ? 'success' : 'normal'"
/>
<a-typography-text :type="isFailed ? 'danger' : 'secondary'" style="font-size: 13px;">
{{ progress?.message }}
</a-typography-text>
</template>
<!-- Failed with rollback -->
<template v-if="isFailed">
<a-divider style="margin: 16px 0;" />
<a-alert type="error" style="margin-bottom: 12px;">更新失败:{{ progress?.error || '未知错误' }}</a-alert>
<div v-if="rollbackAvailable" style="text-align: right;">
<a-button status="danger" :loading="rollingBack" @click="doRollback">回滚到 v{{ backupVersion }}</a-button>
</div>
</template>
<!-- Complete with rollback option -->
<!-- 有新版本可更新时隐藏:这个 complete 是历史升级留下的常驻状态,
不应与新版本入口同时出现 -->
<template v-if="isComplete && rollbackAvailable && !canUpdate">
<a-divider style="margin: 16px 0;" />
<div style="display: flex; justify-content: space-between; align-items: center;">
<a-typography-text type="secondary" style="font-size: 12px;">如遇问题可回滚到 v{{ backupVersion }}</a-typography-text>
<a-button status="warning" :loading="rollingBack" @click="doRollback">回滚</a-button>
</div>
</template>
<template v-if="releaseUrl && canUpdate">
<a-divider style="margin: 8px 0 0;" />
<div style="text-align: center; padding-top: 8px;">
<a-link :href="releaseUrl" target="_blank" style="font-size: 12px;">在 GitHub 上查看完整发布说明</a-link>
</div>
</template>
</a-modal>
</div>
</template>
<style scoped>
/* ===== Layout ===== */
.admin-layout {
display: flex;
height: 100vh;
background: var(--ta-bg-page);
}
/* ===== Sidebar ===== */
.admin-sidebar {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--ta-sidebar-bg);
transition: width var(--ta-duration-slow) var(--ta-ease);
overflow: hidden;
}
.admin-sidebar--collapsed {
width: 64px;
}
.admin-sidebar--mobile {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 50;
width: 264px;
}
.admin-sidebar__logo {
display: flex;
align-items: center;
gap: 10px;
height: 56px;
padding: 0 16px;
border-bottom: 1px solid var(--ta-sidebar-divider);
flex-shrink: 0;
}
.admin-sidebar__logo-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 8px;
object-fit: contain;
}
.admin-sidebar__logo-text {
color: #f1f5f9;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
}
.admin-sidebar--collapsed .admin-sidebar__logo {
justify-content: center;
padding: 0;
}
.admin-sidebar__menu {
flex: 1;
overflow-y: auto;
padding: 8px;
overflow-x: hidden;
}
/* ===== Collapsible Group Header ===== */
.admin-sidebar__group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 20px 16px 6px;
cursor: pointer;
user-select: none;
}
.admin-sidebar__group-header:hover .admin-sidebar__group-label {
color: rgba(255, 255, 255, 0.55);
}
.admin-sidebar__group-header:hover .admin-sidebar__group-icon {
color: rgba(255, 255, 255, 0.55);
}
.admin-sidebar__group-icon {
flex-shrink: 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.35);
transition: color var(--ta-duration-fast) var(--ta-ease);
}
.admin-sidebar__group-label {
flex: 1;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.35);
white-space: nowrap;
overflow: hidden;
transition: color 0.2s;
}
.admin-sidebar__group-chevron {
font-size: 10px;
color: rgba(255, 255, 255, 0.25);
transition: transform 0.25s ease;
flex-shrink: 0;
}
.admin-sidebar__group-chevron--collapsed {
transform: rotate(-90deg);
}
/* ===== Collapsible Group Items ===== */
.admin-sidebar__group-items {
overflow: hidden;
}
.collapse-enter-active,
.collapse-leave-active {
transition: height 0.25s ease;
}
.admin-sidebar__item {
display: flex;
align-items: center;
gap: 10px;
height: 32px;
margin: 1px 0;
padding: 0 12px 0 24px;
border-radius: 8px;
color: var(--ta-sidebar-text);
cursor: pointer;
transition: all var(--ta-duration-fast) var(--ta-ease);
white-space: nowrap;
overflow: hidden;
}
.admin-sidebar__item:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.06);
}
.admin-sidebar__item--active {
color: #5eead4 !important;
background: var(--ta-sidebar-active) !important;
}
.admin-sidebar__icon {
flex-shrink: 0;
font-size: 18px;
}
.admin-sidebar__text {
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
}
/* 待办角标:默认中性灰,仅在工作台有 P0 时转红。
全部标红会让红点迅速失去意义 —— 红色必须留给「现在就得看」。 */
.admin-sidebar__badge {
margin-left: auto;
flex-shrink: 0;
min-width: 18px;
height: 18px;
padding: 0 5px;
line-height: 18px;
text-align: center;
border-radius: 9px;
font-size: 11px;
font-weight: 600;
background: var(--color-fill-3);
color: var(--color-text-2);
}
.admin-sidebar__badge--urgent {
background: #f53f3f;
color: #fff;
}
/* ===== Collapsed Sidebar Overrides ===== */
.admin-sidebar--collapsed .admin-sidebar__group-header {
justify-content: center;
padding: 12px 0;
cursor: pointer;
}
.admin-sidebar--collapsed .admin-sidebar__group-header--hovered {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.06);
border-radius: 8px;
}
.admin-sidebar--collapsed .admin-sidebar__group-icon {
font-size: 18px;
color: var(--ta-sidebar-text);
}
.admin-sidebar--collapsed .admin-sidebar__group-label,
.admin-sidebar--collapsed .admin-sidebar__group-chevron {
display: none;
}
.admin-sidebar--collapsed .admin-sidebar__group-items {
display: none;
}
.admin-sidebar--collapsed .admin-sidebar__text {
display: none;
}
/* ===== Header ===== */
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 24px;
background: var(--ta-bg-card);
border-bottom: 1px solid var(--ta-border-light);
box-shadow: var(--ta-shadow-card);
z-index: 10;
flex-shrink: 0;
}
.admin-header__left {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.admin-header__menu-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
color: var(--ta-text-secondary);
cursor: pointer;
transition: all var(--ta-duration-fast) var(--ta-ease);
background: none;
border: none;
}
.admin-header__menu-btn:hover {
background: var(--ta-bg-secondary);
color: var(--ta-text-primary);
}
.admin-header__menu-icon {
width: 20px;
height: 20px;
}
.admin-header__breadcrumbs {
display: flex;
align-items: center;
gap: 0;
font-size: 13px;
color: var(--ta-text-tertiary);
overflow: hidden;
white-space: nowrap;
min-width: 0;
}
.admin-header__breadcrumb {
color: var(--ta-text-secondary);
}
.admin-header__breadcrumb-sep {
margin: 0 6px;
color: var(--ta-border);
}
.admin-header__right {
display: flex;
align-items: center;
flex-shrink: 0;
}
.admin-header__user {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
color: var(--ta-text-secondary);
font-size: 14px;
transition: all var(--ta-duration-fast) var(--ta-ease);
}
.admin-header__user:hover {
background: var(--ta-bg-secondary);
}
.admin-header__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--ta-primary-gradient);
color: #fff;
font-weight: 600;
font-size: 13px;
}
.admin-header__user-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 160px;
background: var(--ta-bg-card);
border: 1px solid var(--ta-border-light);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
padding: 4px;
z-index: 100;
}
.admin-header__user-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--ta-text-secondary);
transition: all var(--ta-duration-fast) var(--ta-ease);
}
.admin-header__user-menu-item:hover {
background: var(--ta-bg-secondary);
color: var(--ta-text-primary);
}
.admin-header__user-menu-item--danger:hover {
background: #fef2f2;
color: #ef4444;
}
.admin-header__user-menu-divider {
height: 1px;
margin: 4px 8px;
background: var(--ta-border-light);
}
/* ===== Main Area ===== */
.admin-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ===== Content ===== */
.admin-content {
background: var(--ta-bg-page);
padding: 24px;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* ===== Transitions ===== */
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--ta-duration-normal) var(--ta-ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: transform var(--ta-duration-slow) var(--ta-ease);
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(-100%);
}
.mobile-overlay {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
/* ===== Collapsed Popup Menu ===== */
.sidebar-popup {
position: fixed;
left: 72px;
min-width: 160px;
background: var(--ta-bg-card, #fff);
border: 1px solid var(--ta-border-light, #e5e7eb);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
padding: 8px 4px;
z-index: 100;
}
.sidebar-popup__title {
padding: 4px 12px 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ta-text-tertiary, #94a3b8);
}
.sidebar-popup__item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--ta-text-secondary, #64748b);
transition: all 0.15s;
}
.sidebar-popup__item:hover {
background: var(--ta-bg-secondary, #f1f5f9);
color: var(--ta-text-primary, #1e293b);
}
.sidebar-popup__item--active {
color: var(--ta-primary, #0d9488);
background: rgba(13, 148, 136, 0.08);
}
.sidebar-popup__item-icon {
flex-shrink: 0;
font-size: 16px;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.admin-sidebar:not(.admin-sidebar--mobile) {
display: none;
}
.admin-sidebar--mobile {
display: flex;
}
}
/* ===== Version Badge & Update Dot ===== */
.admin-sidebar__version {
display: inline-flex;
align-items: center;
font-size: 10px;
font-weight: 500;
color: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 1px 7px;
margin-left: 6px;
line-height: 18px;
letter-spacing: 0.02em;
}
.admin-sidebar__update-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #00b42a;
margin-left: 4px;
vertical-align: middle;
position: relative;
top: -1px;
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 180, 42, 0.4); }
50% { box-shadow: 0 0 0 4px rgba(0, 180, 42, 0); }
}
/* ===== Update Modal ===== */
.update-modal-stat {
flex: 1;
background: var(--ta-bg-secondary);
}
.update-modal-stat__title {
font-size: 12px;
color: var(--ta-text-tertiary);
margin-bottom: 4px;
}
.update-modal-stat__value {
font-size: 18px;
font-weight: 600;
color: var(--ta-text-primary);
}
.update-modal-notes {
max-height: 260px;
overflow-y: auto;
line-height: 1.8;
font-size: 13px;
color: var(--ta-text-secondary);
}
.update-modal-notes :deep(h1),
.update-modal-notes :deep(h2),
.update-modal-notes :deep(h3) {
margin-top: 12px;
margin-bottom: 6px;
font-size: 15px;
}
.update-modal-notes :deep(ul),
.update-modal-notes :deep(ol) {
padding-left: 20px;
}
.update-modal-notes :deep(code) {
background: var(--ta-fill-2);
padding: 1px 4px;
border-radius: 3px;
font-size: 12px;
}
</style>