aa3743b42d
完成 FileCodeBox 2026 全站桌面端、平板和移动端的视觉交互复核,补齐文件选择后的批量操作闭环,并降低公共首屏的字体与低频依赖成本。 - 为移动文件列表增加全选、已选数量、批量编辑、策略与删除固定栏,并补充详情删除和空筛选重置 - 优化设置保存栏、分区导航、发送过期控件、仪表盘日期与图表操作、分页和记录抽屉 - 为后台侧栏增加遮罩、滚动锁定、Escape 关闭和焦点返回,完善登录密码可见性与国际化 - 移除全站强制 DingTalk 字体,按需加载 JSZip、Marked 和 DOMPurify,并停止空闲提示定时器 - 已通过架构检查、Vue 类型检查、ESLint、生产构建、48 组多视口明暗模式矩阵及核心流程回归
345 lines
8.9 KiB
Vue
345 lines
8.9 KiB
Vue
<template>
|
|
<div
|
|
class="flex h-screen overflow-hidden flex-col transition-colors duration-300 lg:flex-row"
|
|
:class="[isDarkMode ? 'bg-[#10191e]' : 'bg-[#dfe9eb]']"
|
|
>
|
|
<button
|
|
v-if="isSidebarOpen"
|
|
type="button"
|
|
class="fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px] lg:hidden"
|
|
:aria-label="t('common.closeMenu')"
|
|
@click="closeSidebar"
|
|
/>
|
|
|
|
<!-- Sidebar -->
|
|
<aside
|
|
ref="sidebarRef"
|
|
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 shrink-0 transform flex-col border-r lg:relative lg:h-screen lg:translate-x-0"
|
|
:class="[
|
|
isDarkMode
|
|
? 'border-[#31454d] bg-[#1a2930]/92 backdrop-filter backdrop-blur-xl'
|
|
: 'border-[#c8d7da] bg-[#f2f6f5]/92 backdrop-blur-xl',
|
|
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
|
'transition-transform duration-300 ease-in-out lg:transition-none'
|
|
]"
|
|
>
|
|
<!-- Logo区域 -->
|
|
<div
|
|
class="flex items-center justify-between h-16 px-4 border-b"
|
|
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"
|
|
>
|
|
<div class="flex items-center">
|
|
<div
|
|
class="rounded-full bg-gradient-to-r from-[#6f98a6] via-[#8eaaa8] to-[#b7b2a1] p-1 animate-spin-slow"
|
|
>
|
|
<div class="rounded-full p-1" :class="[isDarkMode ? 'bg-[#1a2930]' : 'bg-[#f2f6f5]']">
|
|
<BoxIcon
|
|
class="w-6 h-6"
|
|
:class="[isDarkMode ? 'text-[#a5c8d0]' : 'text-[#5f8796]']"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<h1
|
|
@click="router.push('/')"
|
|
class="ml-2 text-xl font-semibold cursor-pointer"
|
|
:class="[isDarkMode ? 'text-[#edf4f4]' : 'text-[#2d4650]']"
|
|
>
|
|
{{ t('common.appName') }}
|
|
</h1>
|
|
</div>
|
|
<button type="button" :aria-label="t('common.closeMenu')" :title="t('common.closeMenu')" @click="closeSidebar" class="flex h-11 w-11 items-center justify-center rounded-lg lg:hidden">
|
|
<XIcon class="w-6 h-6" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 导航菜单 -->
|
|
<nav class="flex-1 overflow-y-auto custom-scrollbar">
|
|
<ul class="p-4 space-y-2">
|
|
<li v-for="item in menuItems" :key="item.id">
|
|
<RouterLink
|
|
:to="item.redirect"
|
|
class="flex min-h-11 w-full items-center rounded-lg border-l-4 px-3 text-sm font-medium"
|
|
:class="[
|
|
route.name === item.id
|
|
? isDarkMode
|
|
? 'border-[#a5c8d0] bg-[#294047] text-[#dceff1]'
|
|
: 'border-[#6f98a6] bg-[#d5e4e6] text-[#315c69]'
|
|
: isDarkMode
|
|
? 'border-transparent text-[#aebfc3] hover:bg-[#22353c]'
|
|
: 'border-transparent text-[#586f75] hover:bg-[#e5eeee]'
|
|
]"
|
|
@click="closeSidebarForMobile"
|
|
>
|
|
<component :is="item.icon" class="mr-3 h-5 w-5 shrink-0" />
|
|
{{ item.name }}
|
|
</RouterLink>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
|
|
<!-- 退出登录按钮 -->
|
|
<div class="p-4 border-t" :class="[isDarkMode ? 'border-[#31454d]' : 'border-[#c8d7da]']">
|
|
<button
|
|
@click="handleLogout"
|
|
class="flex min-h-11 w-full items-center rounded-lg p-2 transition-colors duration-200"
|
|
:class="[
|
|
isDarkMode
|
|
? 'text-[#aebfc3] hover:bg-[#22353c] hover:text-[#edf4f4]'
|
|
: 'text-[#586f75] hover:bg-[#e5eeee] hover:text-[#2d4650]'
|
|
]"
|
|
>
|
|
<LogOutIcon class="w-5 h-5 mr-3" />
|
|
{{ t('admin.logout') }}
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main Content -->
|
|
<div class="flex h-full min-h-0 min-w-0 flex-1 flex-col">
|
|
<!-- Header -->
|
|
<header
|
|
class="h-14 border-b shadow-md transition-colors duration-300 lg:hidden"
|
|
:class="[isDarkMode ? 'bg-[#1a2930] border-[#31454d]' : 'bg-[#f2f6f5] border-[#c8d7da]']"
|
|
>
|
|
<div class="flex h-14 items-center justify-between px-4">
|
|
<button ref="menuButtonRef" type="button" :aria-label="t('common.openMenu')" :title="t('common.openMenu')" :aria-expanded="isSidebarOpen" @click="openSidebar" class="flex h-11 w-11 items-center justify-center rounded-lg lg:hidden">
|
|
<MenuIcon class="w-6 h-6" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Content -->
|
|
<main
|
|
class="min-h-0 flex-1 overflow-y-auto transition-colors duration-300 custom-scrollbar"
|
|
:class="[isDarkMode ? 'bg-[#10191e]' : 'bg-[#dfe9eb]']"
|
|
>
|
|
<router-view />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, inject, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
|
import {
|
|
BoxIcon,
|
|
MenuIcon,
|
|
XIcon,
|
|
FolderIcon,
|
|
CogIcon,
|
|
LayoutDashboardIcon,
|
|
LogOutIcon
|
|
} from 'lucide-vue-next'
|
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { ROUTE_NAMES, ROUTES } from '@/constants'
|
|
import { useAdminSession } from '@/composables'
|
|
|
|
interface MenuItem {
|
|
id: string
|
|
name: string
|
|
icon: typeof LayoutDashboardIcon
|
|
redirect: string
|
|
}
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const { t } = useI18n()
|
|
const isDarkMode = inject('isDarkMode')
|
|
const { verifySession, logout } = useAdminSession()
|
|
const menuItems: MenuItem[] = [
|
|
{
|
|
id: ROUTE_NAMES.DASHBOARD,
|
|
name: t('admin.dashboard.title'),
|
|
icon: LayoutDashboardIcon,
|
|
redirect: ROUTES.DASHBOARD
|
|
},
|
|
{
|
|
id: ROUTE_NAMES.FILE_MANAGE,
|
|
name: t('admin.fileManage.title'),
|
|
icon: FolderIcon,
|
|
redirect: ROUTES.FILE_MANAGE
|
|
},
|
|
{
|
|
id: ROUTE_NAMES.SETTINGS,
|
|
name: t('admin.settings.title'),
|
|
icon: CogIcon,
|
|
redirect: ROUTES.SETTINGS
|
|
}
|
|
]
|
|
|
|
const isSidebarOpen = ref(true)
|
|
const sidebarRef = ref<HTMLElement | null>(null)
|
|
const menuButtonRef = ref<HTMLButtonElement | null>(null)
|
|
|
|
const openSidebar = async () => {
|
|
isSidebarOpen.value = true
|
|
await nextTick()
|
|
sidebarRef.value?.querySelector<HTMLElement>('button, a[href]')?.focus()
|
|
}
|
|
const closeSidebar = async () => {
|
|
isSidebarOpen.value = false
|
|
await nextTick()
|
|
menuButtonRef.value?.focus()
|
|
}
|
|
|
|
const closeSidebarForMobile = () => {
|
|
if (window.innerWidth < 1024) {
|
|
void closeSidebar()
|
|
}
|
|
}
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape' && window.innerWidth < 1024 && isSidebarOpen.value) {
|
|
void closeSidebar()
|
|
}
|
|
}
|
|
|
|
watch(isSidebarOpen, (open) => {
|
|
if (window.innerWidth < 1024) document.body.style.overflow = open ? 'hidden' : ''
|
|
})
|
|
|
|
// 响应式处理
|
|
const handleResize = () => {
|
|
if (window.innerWidth >= 1024) {
|
|
isSidebarOpen.value = true
|
|
} else {
|
|
isSidebarOpen.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
handleResize()
|
|
window.addEventListener('resize', handleResize)
|
|
window.addEventListener('keydown', handleEscape)
|
|
void verifySession().then((isValid) => {
|
|
if (!isValid) {
|
|
void router.push(ROUTES.LOGIN)
|
|
}
|
|
})
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', handleResize)
|
|
window.removeEventListener('keydown', handleEscape)
|
|
document.body.style.overflow = ''
|
|
})
|
|
|
|
// 登出处理
|
|
const handleLogout = async () => {
|
|
await logout()
|
|
await router.push(ROUTES.LOGIN)
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.switch {
|
|
position: relative;
|
|
display: inline-block;
|
|
width: 60px;
|
|
height: 34px;
|
|
}
|
|
|
|
.switch input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: #e5e7eb;
|
|
transition: 0.4s;
|
|
}
|
|
|
|
.dark .slider {
|
|
background-color: #4b5563;
|
|
}
|
|
|
|
input:checked + .slider {
|
|
background-color: #6f9688;
|
|
}
|
|
|
|
.dark input:checked + .slider {
|
|
background-color: #6f9688;
|
|
}
|
|
|
|
.slider:before {
|
|
position: absolute;
|
|
content: '';
|
|
height: 26px;
|
|
width: 26px;
|
|
left: 4px;
|
|
bottom: 4px;
|
|
background-color: white;
|
|
transition: 0.4s;
|
|
}
|
|
|
|
.dark .slider:before {
|
|
background-color: #e5e7eb;
|
|
}
|
|
|
|
.slider.round {
|
|
border-radius: 34px;
|
|
}
|
|
|
|
.slider.round:before {
|
|
border-radius: 50%;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.animate-spin-slow {
|
|
animation: spin 8s linear infinite;
|
|
}
|
|
|
|
.transition-colors {
|
|
transition-property: background-color, border-color, color, fill, stroke;
|
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
transition-duration: 300ms;
|
|
}
|
|
|
|
.custom-scrollbar {
|
|
&::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
&::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
&::-webkit-scrollbar-thumb {
|
|
background-color: #cbd5e0;
|
|
border-radius: 3px;
|
|
|
|
&:hover {
|
|
background-color: #a0aec0;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* 暗黑模式下的滚动条样式 */
|
|
:global(.dark) .custom-scrollbar {
|
|
&::-webkit-scrollbar-thumb {
|
|
background-color: #4a5568;
|
|
|
|
&:hover {
|
|
background-color: #2d3748;
|
|
}
|
|
}
|
|
}
|
|
</style>
|