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

1679 lines
52 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, watch } 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 { createPoller } from '@/composables/usePolling'
import {
IconDesktop,
IconDashboard,
IconUserGroup,
IconApps,
IconBranch,
IconFile,
IconCommand,
IconIdcard,
IconStorage,
IconGift,
IconTag,
IconCodeBlock,
IconClockCircle,
IconSafe,
IconSettings,
IconMenuFold,
IconMenuUnfold,
IconMessage,
IconNotification,
IconSend,
IconRight,
IconCalendar,
IconLayers,
IconHome,
IconUser,
IconBook,
IconCheckCircleFill,
} from '@arco-design/web-vue/es/icon'
import { useAuthStore } from '@/stores/auth'
import { ADMIN_ROLES } from '@/utils/permission'
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 updatePoller = createPoller(fetchUpdateStatus, 2000)
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() {
// 在线更新是超管专属能力(后端 superAdminOnlyRules 拦截),非超管不打开弹窗也不发请求
if (!isSuperAdminUser.value) return
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()
updatePoller.start()
}
function stopPolling() {
updatePoller.stop()
}
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.value.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.value.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 = ''
}
// 菜单的完整定义。每项声明它需要的权限点,实际显隐由下方 menuGroups 派生 ——
// 菜单是权限的投影,不存在独立的「菜单权限」配置。
const ALL_MENU_GROUPS = [
{
key: 'dashboard',
label: '数据看板',
icon: IconDashboard,
items: [
{ name: 'AdminWorkbench', label: '工作台', icon: IconDesktop, perm: 'dashboard:view' },
{ name: 'AdminDashboard', label: '仪表盘', icon: IconDashboard, perm: 'dashboard:view' },
{ name: 'AdminRealtimeMonitor', label: '实时监控', icon: IconCommand, perm: 'monitor:view' },
],
},
{
key: 'ai',
label: '大模型',
icon: IconApps,
items: [
{ name: 'AdminModels', label: '模型列表', icon: IconApps, perm: 'model:view' },
{ name: 'AdminModelGroups', label: '模型分组', icon: IconStorage, perm: 'model:view' },
{ name: 'AdminChannels', label: '渠道管理', icon: IconBranch, perm: 'channel:view' },
{ name: 'AdminTaskLogs', label: '任务日志', icon: IconCalendar, perm: 'task:view' },
{ name: 'AdminUsageLogs', label: '用量日志', icon: IconFile, perm: 'billing:view' },
{ name: 'AdminRequestAuditLogs', label: '请求审计日志', icon: IconCommand, perm: 'audit:view' },
],
},
{
key: 'tenants',
label: '租户管理',
icon: IconUserGroup,
items: [
{ name: 'AdminTenants', label: '租户列表', icon: IconHome, perm: 'tenant:view' },
{ name: 'AdminTenantLevels', label: '租户级别', icon: IconLayers, perm: 'tenant:view' },
{ name: 'AdminMembers', label: '成员列表', icon: IconUser, perm: 'member:view' },
],
},
{
key: 'finance',
label: '财务中心',
icon: IconIdcard,
items: [
{ name: 'AdminOrders', label: '订单管理', icon: IconCodeBlock, perm: 'order:view' },
{ name: 'AdminTransactions', label: '交易流水', icon: IconCommand, perm: 'billing:view' },
{ name: 'AdminRedemptions', label: '兑换码管理', icon: IconGift, perm: 'redemption:view' },
{ name: 'AdminPromoCodes', label: '优惠码管理', icon: IconTag, perm: 'promo:view' },
],
},
{
key: 'security',
label: '安全审计',
icon: IconSafe,
items: [
{ name: 'AdminLoginHistory', label: '登录历史', icon: IconClockCircle, perm: 'audit:view' },
{ name: 'AdminTenantLoginHistory', label: '租户登录历史', icon: IconClockCircle, perm: 'audit:view' },
{ name: 'AdminSessions', label: '会话管理', icon: IconClockCircle, perm: 'user:edit' },
{ name: 'AdminAudit', label: '操作日志', icon: IconFile, perm: 'audit:view' },
],
},
{
key: 'operations',
label: '运营管理',
icon: IconNotification,
items: [
{ name: 'AdminNotificationTemplates', label: '通知模板', icon: IconNotification, perm: 'operation:view' },
{ name: 'AdminMessages', label: '消息管理', icon: IconSend, perm: 'operation:view' },
{ name: 'AdminEmailLogs', label: '邮件发送记录', icon: IconFile, perm: 'operation:view' },
{ name: 'AdminAnnouncements', label: '公告管理', icon: IconMessage, perm: 'operation:view' },
{ name: 'AdminTickets', label: '工单管理', icon: IconCommand, perm: 'support:view' },
{ name: 'AdminFeedback', label: '反馈管理', icon: IconMessage, perm: 'support:view' },
{ name: 'AdminAgreements', label: '用户协议', icon: IconFile, perm: 'system:view' },
{ name: 'AdminHelpCategories', label: '帮助分类', icon: IconLayers, perm: 'support:view' },
{ name: 'AdminHelpArticles', label: '帮助文章', icon: IconFile, perm: 'support:view' },
],
},
{
key: 'monitoring',
label: '运维监控',
icon: IconCommand,
items: [
{ name: 'AdminMonitor', label: '系统监控', icon: IconCommand, perm: 'monitor:view' },
{ name: 'AdminTrafficFlow', label: '流量流向', icon: IconCommand, perm: 'monitor:view' },
{ name: 'AdminModelPerformance', label: '模型性能', icon: IconCommand, perm: 'monitor:view' },
{ name: 'AdminAlertRules', label: '告警规则', icon: IconNotification, perm: 'monitor:view' },
{ name: 'AdminAlertEvents', label: '告警记录', icon: IconFile, perm: 'monitor:view' },
{ name: 'AdminErrorLogs', label: '错误日志', icon: IconFile, perm: 'monitor:view' },
{ name: 'AdminChannelErrors', label: '渠道错误监控', icon: IconFile, perm: 'monitor:view' },
{ name: 'AdminContentFilterLogs', label: '拦截日志', icon: IconFile, perm: 'audit:view' },
{ name: 'AdminCronJobs', label: '定时任务', icon: IconFile, perm: 'system:view' },
],
},
{
key: 'system',
label: '系统',
icon: IconSettings,
items: [
{ name: 'AdminUsers', label: '用户管理', icon: IconUserGroup, perm: 'user:view' },
{ name: 'AdminPermissions', label: '角色权限', icon: IconSafe, superOnly: true },
{ name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock, perm: 'system:plugin' },
{ name: 'AdminSettings', label: '系统设置', icon: IconSettings, perm: 'system:view' },
{ name: 'AdminPaymentChannels', label: '支付渠道', icon: IconIdcard, perm: 'payment:view' },
{ name: 'AdminFiles', label: '文件管理', icon: IconStorage, perm: 'file:view' },
],
},
]
// 权限判定读 store 而不是 utils/permission 里的 localStorage 版本:
// localStorage 不是响应式数据源,computed 求值一次后就再也不会失效,
// fetchMe() 拉到新权限、或切换账号后菜单不会跟着变。
// utils 版本仍用于按钮级 v-if(每次渲染都会重新调用,不依赖响应式追踪)。
const isSuperAdminUser = computed(() => authStore.user?.role === ADMIN_ROLES.SUPER_ADMIN)
const permissionSet = computed(() => new Set(authStore.permissions))
// 按当前用户权限过滤菜单:整组条目都被过滤掉时,分组本身也不再渲染,
// 避免出现点开是空的分组。super_admin 拥有全部权限,直接放行。
const menuGroups = computed(() =>
ALL_MENU_GROUPS
.map(group => ({
...group,
items: group.items.filter(item => {
// superOnly 的条目不参与权限点判定:它们是权限体系本身的元操作,
// 无法通过授予权限点下放(后端 superAdminOnlyRules 同样硬性拦截)
if ((item as any).superOnly) return isSuperAdminUser.value
if (isSuperAdminUser.value) return true
return !item.perm || permissionSet.value.has(item.perm)
}),
}))
.filter(group => group.items.length > 0)
)
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
}
if (!target.closest('.admin-header__bell-wrapper')) {
bellVisible.value = false
}
}
// --- 顶栏铃铛(客服支持待办) ---
// 与工作台红点分工:工作台只报异常项(超 SLA / 未分配),铃铛报全量待处理
// (pending + reopened 工单、pending 反馈)—— 有客户在等回复就挂着数字,
// 处理一条少一条,归零即安静。仅对有 support:view 权限的管理员显示和轮询。
const canSeeBell = computed(() =>
isSuperAdminUser.value || permissionSet.value.has('support:view'),
)
const bellVisible = ref(false)
const bellData = ref<{
tickets: number
tickets_overdue: number
feedbacks: number
feedbacks_wait: string
total: number
} | null>(null)
// 待办铃铛轮询:页面隐藏时暂停,恢复可见时按剩余时间续排
const bellPoller = createPoller(fetchBellSummary, 60000)
async function fetchBellSummary() {
try {
const res: any = await request.get('/admin/support/pending-summary', {
_suppressErrorMsg: true,
} as any)
bellData.value = res.data?.data || null
} catch {
// 静默:铃铛拉取失败不打扰用户,保留上一次数值
}
}
function startBellPolling() {
fetchBellSummary()
bellPoller.start()
}
function stopBellPolling() {
bellPoller.stop()
}
function toggleBell() {
bellVisible.value = !bellVisible.value
// 打开面板时立即刷新,避免展示 60s 轮询间隙里的旧数字
if (bellVisible.value) fetchBellSummary()
}
function goFromBell(name: string) {
bellVisible.value = false
router.push({ name })
}
// 权限可能晚于挂载就绪(fetchMe 异步),用 watch 而不是 onMounted 一次性判断
watch(canSeeBell, (visible) => {
if (visible) startBellPolling()
}, { immediate: true })
// --- 工作台菜单红点 ---
// 工作台是聚合视图,红点才是常驻发现入口:管理员不会每天主动点开工作台,
// 但会看见菜单上的角标。后端 badges 与 summary 共用同一份 30s Redis 缓存,
// 60s 轮询不会额外压库。
const workbenchUrgent = ref(0)
const workbenchTotal = ref(0)
// 工作台红点轮询:页面隐藏时暂停,恢复可见时按剩余时间续排
const badgePoller = createPoller(fetchWorkbenchBadges, 60000)
// 角标只挂工作台菜单:待办的排查线索只在工作台的描述文案里,
// 业务菜单里没有对应的定位入口,挂数字只会带来「进去了却找不到问题」的困惑
function badgeOf(name: string): number {
if (name === 'AdminWorkbench') return workbenchTotal.value
return 0
}
async function fetchWorkbenchBadges() {
try {
const res = await request.get('/admin/workbench/badges')
const data = res.data?.data
if (!data) return
workbenchUrgent.value = data.urgent || 0
workbenchTotal.value = data.total || 0
} catch {
// 静默:红点拉取失败不该打扰用户,也不该阻塞布局
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
updateMobile()
window.addEventListener('resize', updateMobile)
// 在线更新是超管专属能力(后端 superAdminOnlyRules 拦截),
// 非超管不发请求,避免每次刷新页面都弹「缺少权限」错误提示
if (isSuperAdminUser.value) {
checkUpdate()
fetchUpdateStatus() // 取后端实时版本,用于准确的"是否可更新"判断
}
fetchWorkbenchBadges()
badgePoller.start()
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!)
badgePoller.stop()
stopBellPolling()
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">
<!-- 待办铃铛:客服支持待办(工单 + 反馈),有 support:view 权限才显示 -->
<div v-if="canSeeBell" class="admin-header__bell-wrapper" style="position: relative;">
<button
type="button"
class="admin-header__bell-btn"
:class="{ 'admin-header__bell-btn--active': bellVisible }"
@click.stop="toggleBell"
title="待办提醒"
>
<IconNotification class="admin-header__bell-icon" />
<span
v-if="bellData && bellData.total > 0"
class="admin-header__bell-badge"
:class="{ 'admin-header__bell-badge--urgent': bellData.tickets_overdue > 0 }"
>{{ bellData.total > 99 ? '99+' : bellData.total }}</span>
</button>
<Transition name="fade">
<div v-if="bellVisible" class="admin-header__bell-panel">
<div class="admin-header__bell-panel-head">待办提醒</div>
<template v-if="bellData && bellData.total > 0">
<div class="admin-header__bell-item" @click="goFromBell('AdminTickets')">
<div class="admin-header__bell-item-main">
<span>工单待处理</span>
<span class="admin-header__bell-item-count">{{ bellData.tickets }}</span>
</div>
<div v-if="bellData.tickets_overdue > 0" class="admin-header__bell-item-sub admin-header__bell-item-sub--danger">
{{ bellData.tickets_overdue }} 条已超 SLA 未响应
</div>
</div>
<div class="admin-header__bell-item" @click="goFromBell('AdminFeedback')">
<div class="admin-header__bell-item-main">
<span>反馈待处理</span>
<span class="admin-header__bell-item-count">{{ bellData.feedbacks }}</span>
</div>
<div v-if="bellData.feedbacks_wait" class="admin-header__bell-item-sub">
最早 1 条已等待 {{ bellData.feedbacks_wait }}
</div>
</div>
</template>
<div v-else class="admin-header__bell-empty">暂无待办,一切安静</div>
</div>
</Transition>
</div>
<!-- 帮助文档:新窗口打开文档站 -->
<a
class="admin-header__docs-btn"
href="https://docs.team-api.net"
target="_blank"
rel="noopener noreferrer"
title="帮助文档"
>
<IconBook class="admin-header__docs-icon" />
<span class="admin-header__docs-text">帮助文档</span>
</a>
<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__bell-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
margin-right: 12px;
border: none;
border-radius: 8px;
color: var(--ta-text-secondary);
cursor: pointer;
transition: all var(--ta-duration-fast) var(--ta-ease);
background: none;
}
.admin-header__bell-btn:hover,
.admin-header__bell-btn--active {
background: var(--ta-bg-secondary);
color: var(--ta-text-primary);
}
.admin-header__bell-icon {
font-size: 17px;
}
.admin-header__bell-badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
line-height: 16px;
text-align: center;
border-radius: 8px;
font-size: 10px;
font-weight: 600;
background: var(--color-fill-3);
color: var(--color-text-2);
}
/* 红色只留给「现在就得看」:有超 SLA 工单才转红,与工作台角标策略一致 */
.admin-header__bell-badge--urgent {
background: #f53f3f;
color: #fff;
}
.admin-header__bell-panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 280px;
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__bell-panel-head {
padding: 8px 12px 6px;
font-size: 12px;
font-weight: 600;
color: var(--ta-text-tertiary);
}
.admin-header__bell-item {
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: all var(--ta-duration-fast) var(--ta-ease);
}
.admin-header__bell-item:hover {
background: var(--ta-bg-secondary);
}
.admin-header__bell-item-main {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: var(--ta-text-secondary);
}
.admin-header__bell-item-count {
font-size: 15px;
font-weight: 600;
color: var(--ta-text-primary);
}
.admin-header__bell-item-sub {
margin-top: 2px;
font-size: 12px;
color: var(--ta-text-tertiary);
}
.admin-header__bell-item-sub--danger {
color: #f53f3f;
}
.admin-header__bell-empty {
padding: 20px 0;
text-align: center;
font-size: 13px;
color: var(--ta-text-tertiary);
}
/* ===== 帮助文档胶囊入口 ===== */
.admin-header__docs-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
margin-right: 12px;
padding: 0 14px;
border: 1px solid var(--ta-border);
border-radius: 999px;
color: var(--ta-text-secondary);
font-size: 13px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition: all var(--ta-duration-fast) var(--ta-ease);
}
.admin-header__docs-btn:hover {
color: var(--ta-primary);
border-color: var(--ta-primary);
background: rgba(13, 148, 136, 0.06);
}
.admin-header__docs-icon {
flex-shrink: 0;
font-size: 15px;
}
/* 窄屏收起文字,退化为纯图标小胶囊(与右侧用户名在移动端隐藏的处理一致) */
@media (max-width: 480px) {
.admin-header__bell-btn {
margin-right: 8px;
}
.admin-header__docs-btn {
width: 34px;
padding: 0;
justify-content: center;
margin-right: 8px;
}
.admin-header__docs-text {
display: none;
}
}
.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>