1205 lines
38 KiB
Vue
1205 lines
38 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
ArrowUpBold,
|
||
Delete,
|
||
Document,
|
||
EditPen,
|
||
FolderOpened,
|
||
House,
|
||
Monitor,
|
||
Plus,
|
||
Right,
|
||
School,
|
||
Trophy,
|
||
} from '@element-plus/icons-vue'
|
||
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
|
||
import { isRectVisible } from './lighting'
|
||
import { calculateRevealRadius, resolveInitialTheme, toggleTheme, type Theme } from './theme'
|
||
|
||
type SectionId = 'overview' | 'explorer' | 'games' | 'school'
|
||
type ExplorerItemKind = 'folder' | 'file'
|
||
type GameId = 'race' | 't_race'
|
||
|
||
interface ExplorerItem {
|
||
id: string
|
||
parentId: string | null
|
||
kind: ExplorerItemKind
|
||
name: string
|
||
}
|
||
|
||
interface GameOption {
|
||
id: GameId
|
||
label: string
|
||
subtitle: string
|
||
path: string
|
||
}
|
||
|
||
interface SectionNavItem {
|
||
id: SectionId
|
||
title: string
|
||
subtitle: string
|
||
icon: Component
|
||
}
|
||
|
||
interface ExplorerFolderTreeItem {
|
||
id: string
|
||
name: string
|
||
level: number
|
||
hasChildren: boolean
|
||
expanded: boolean
|
||
}
|
||
|
||
interface SectionMeta {
|
||
eyebrow: string
|
||
title: string
|
||
description: string
|
||
badge: string
|
||
}
|
||
|
||
interface LightingTarget {
|
||
element: HTMLElement
|
||
left: number
|
||
top: number
|
||
}
|
||
|
||
const text = {
|
||
skip: '跳到主要内容',
|
||
loginTitle: 'Workspace Login',
|
||
loginSubtitle: '输入账号后进入工作区视图。',
|
||
username: '用户名',
|
||
usernamePlaceholder: '输入用户名…',
|
||
password: '密码',
|
||
passwordPlaceholder: '输入密码…',
|
||
loginButton: '进入工作区',
|
||
welcome: '欢迎回来',
|
||
}
|
||
|
||
const THEME_STORAGE_KEY = 'workspace-theme'
|
||
const THEME_REVEAL_DURATION_MS = 520
|
||
const THEME_APPLY_OFFSET_MS = 320
|
||
const THEME_CANVAS_COLORS: Record<Theme, string> = {
|
||
light: '#f5f8fc',
|
||
dark: '#070d16',
|
||
}
|
||
|
||
const username = ref('')
|
||
const password = ref('')
|
||
const loginError = ref('')
|
||
const statusMessage = ref('')
|
||
const isLoggedIn = ref(false)
|
||
const activeSection = ref<SectionId>('overview')
|
||
const selectedGameId = ref<GameId | null>(null)
|
||
const isGameFullscreen = ref(false)
|
||
const explorerCurrentFolderId = ref('root')
|
||
const explorerSelectedItemId = ref<string | null>(null)
|
||
const expandedFolderIds = ref(new Set<string>(['root']))
|
||
const nextExplorerId = ref(1000)
|
||
const gamePlayerRef = ref<HTMLElement | null>(null)
|
||
const loginRef = ref<HTMLElement | null>(null)
|
||
const workspaceRef = ref<HTMLElement | null>(null)
|
||
const sidebarRef = ref<HTMLElement | null>(null)
|
||
const sidebarIndicatorStyle = ref({
|
||
transform: 'translateY(0px)',
|
||
height: '0px',
|
||
opacity: '0',
|
||
})
|
||
const sidebarIndicatorJelly = ref(false)
|
||
const theme = ref<Theme>('light')
|
||
const followsSystemTheme = ref(true)
|
||
const themeRevealActive = ref(false)
|
||
const themeRevealStyle = ref<Record<string, string>>({
|
||
'--reveal-x': '50vw',
|
||
'--reveal-y': '50vh',
|
||
'--reveal-radius': '0px',
|
||
'--reveal-color': THEME_CANVAS_COLORS.light,
|
||
})
|
||
let sidebarJellyTimer: ReturnType<typeof setTimeout> | null = null
|
||
let glowFrameId: number | null = null
|
||
let lightingTargetRefreshFrameId: number | null = null
|
||
let themeApplyTimer: ReturnType<typeof setTimeout> | null = null
|
||
let themeRevealCleanupTimer: ReturnType<typeof setTimeout> | null = null
|
||
let systemThemeMediaQuery: MediaQueryList | null = null
|
||
let systemThemeChangeHandler: ((event: MediaQueryListEvent) => void) | null = null
|
||
let latestPointer: { x: number; y: number } | null = null
|
||
let latestPointerTarget: 'workspace' | 'login' | null = null
|
||
let workspaceLightTargets: LightingTarget[] = []
|
||
let loginLightTargets: LightingTarget[] = []
|
||
let workspaceLightObserver: MutationObserver | null = null
|
||
let loginLightObserver: MutationObserver | null = null
|
||
const lightTargetSelector =
|
||
'.nav-item, .ghost-btn, .primary-btn, .icon-btn, .game-card, .file-main, .folder-item, .path-segment'
|
||
const loginLightTargetSelector = '.login-card, .login-form button, .login-input-shell, .login-card h1'
|
||
|
||
const navItems: SectionNavItem[] = [
|
||
{ id: 'overview', title: '总览', subtitle: '项目入口与状态', icon: House },
|
||
{ id: 'explorer', title: '文件', subtitle: '管理目录与文件', icon: FolderOpened },
|
||
{ id: 'games', title: '游戏', subtitle: '启动内置小游戏', icon: Trophy },
|
||
{ id: 'school', title: '学习', subtitle: '课程与路线图', icon: School },
|
||
]
|
||
|
||
const explorerItems = ref<ExplorerItem[]>([
|
||
{ id: 'root', parentId: null, kind: 'folder', name: 'Workspace' },
|
||
{ id: 'f-projects', parentId: 'root', kind: 'folder', name: 'Projects' },
|
||
{ id: 'f-docs', parentId: 'root', kind: 'folder', name: 'Docs' },
|
||
{ id: 'f-media', parentId: 'root', kind: 'folder', name: 'Assets' },
|
||
{ id: 'f-ui', parentId: 'f-projects', kind: 'folder', name: 'UI-Experiments' },
|
||
{ id: 'file-readme', parentId: 'root', kind: 'file', name: 'Readme.txt' },
|
||
{ id: 'file-plan', parentId: 'f-docs', kind: 'file', name: 'Roadmap.md' },
|
||
{ id: 'file-shot', parentId: 'f-media', kind: 'file', name: 'Preview.png' },
|
||
])
|
||
|
||
const gameOptions: GameOption[] = [
|
||
{ id: 'race', label: 'Race', subtitle: '经典 JS13K 版本', path: '/race/index.html' },
|
||
{ id: 't_race', label: 'HTML Race', subtitle: '新版 HTML 版本', path: '/t_race/index.html' },
|
||
]
|
||
|
||
const currentUser = computed(() => username.value.trim() || 'Guest')
|
||
const isDarkTheme = computed(() => theme.value === 'dark')
|
||
const themeToggleAriaLabel = computed(() => (isDarkTheme.value ? '切换为浅色主题' : '切换为深色主题'))
|
||
const sectionMeta = computed<SectionMeta>(() => {
|
||
switch (activeSection.value) {
|
||
case 'explorer':
|
||
return {
|
||
eyebrow: 'Explorer',
|
||
title: '文件编排',
|
||
description: '用更清晰的目录树和文件卡片处理工作区结构。',
|
||
badge: `${explorerCurrentFolder.value?.name ?? 'Workspace'} · ${explorerChildItems.value.length} 项`,
|
||
}
|
||
case 'games':
|
||
return {
|
||
eyebrow: 'Arcade',
|
||
title: '游戏中心',
|
||
description: '切换到内置小游戏,支持沉浸式全屏与快速返回。',
|
||
badge: activeGame.value ? `Now Playing · ${activeGame.value.label}` : `${gameOptions.length} 个可玩项目`,
|
||
}
|
||
case 'school':
|
||
return {
|
||
eyebrow: 'Learning',
|
||
title: '学习路径',
|
||
description: '把阶段任务、知识结构与部署能力集中到同一视图。',
|
||
badge: '3 条核心进阶路线',
|
||
}
|
||
default:
|
||
return {
|
||
eyebrow: 'Overview',
|
||
title: '总览驾驶舱',
|
||
description: '把常用入口、当前状态与近期重点放在同一块主画布里。',
|
||
badge: `${navItems.length} 个模块 · ${explorerFolders.value.length} 个目录`,
|
||
}
|
||
}
|
||
})
|
||
const overviewSignals = computed(() => [
|
||
{
|
||
label: '当前模块',
|
||
value: sectionMeta.value.title,
|
||
note: '主工作流状态',
|
||
},
|
||
{
|
||
label: '路径深度',
|
||
value: `${explorerPath.value.length} 层`,
|
||
note: '当前文件位置',
|
||
},
|
||
{
|
||
label: '主题模式',
|
||
value: isDarkTheme.value ? 'Dark' : 'Light',
|
||
note: followsSystemTheme.value ? '跟随系统' : '手动切换',
|
||
},
|
||
])
|
||
const studyTracks = [
|
||
{
|
||
title: 'Frontend Systems',
|
||
description: 'Vue + TypeScript 组件拆分、状态设计、可访问性。',
|
||
meta: '组件化 · 可访问性 · 工程化',
|
||
},
|
||
{
|
||
title: 'Graphics',
|
||
description: '游戏渲染循环、碰撞检测、输入系统与性能优化。',
|
||
meta: 'Canvas · 循环调度 · 性能',
|
||
},
|
||
{
|
||
title: 'Deployment',
|
||
description: '构建产物、缓存策略、静态资源托管与监控。',
|
||
meta: '构建 · 托管 · 监控',
|
||
},
|
||
]
|
||
|
||
const explorerCurrentFolder = computed(
|
||
() => explorerItems.value.find((item) => item.id === explorerCurrentFolderId.value) ?? explorerItems.value[0],
|
||
)
|
||
|
||
const explorerChildItems = computed(() =>
|
||
explorerItems.value
|
||
.filter((item) => item.parentId === explorerCurrentFolderId.value)
|
||
.sort((a, b) => {
|
||
if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1
|
||
return a.name.localeCompare(b.name)
|
||
}),
|
||
)
|
||
|
||
const explorerPath = computed(() => {
|
||
const result: ExplorerItem[] = []
|
||
const itemMap = new Map(explorerItems.value.map((item) => [item.id, item]))
|
||
let cursorId: string | null = explorerCurrentFolderId.value
|
||
|
||
while (cursorId) {
|
||
const current = itemMap.get(cursorId)
|
||
if (!current) break
|
||
result.unshift(current)
|
||
cursorId = current.parentId
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
const explorerFolders = computed(() =>
|
||
explorerItems.value
|
||
.filter((item) => item.kind === 'folder')
|
||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||
)
|
||
|
||
const explorerFolderChildrenMap = computed(() => {
|
||
const folderMap = new Map<string, ExplorerItem[]>()
|
||
for (const folder of explorerFolders.value) {
|
||
folderMap.set(folder.id, [])
|
||
}
|
||
for (const item of explorerFolders.value) {
|
||
if (!item.parentId) continue
|
||
if (!folderMap.has(item.parentId)) continue
|
||
folderMap.get(item.parentId)!.push(item)
|
||
}
|
||
for (const children of folderMap.values()) {
|
||
children.sort((a, b) => a.name.localeCompare(b.name))
|
||
}
|
||
return folderMap
|
||
})
|
||
|
||
const explorerFolderTreeItems = computed<ExplorerFolderTreeItem[]>(() => {
|
||
const root = explorerItems.value.find((item) => item.id === 'root' && item.kind === 'folder')
|
||
if (!root) return []
|
||
|
||
const result: ExplorerFolderTreeItem[] = []
|
||
const walk = (folder: ExplorerItem, level: number) => {
|
||
const children = explorerFolderChildrenMap.value.get(folder.id) ?? []
|
||
const expanded = expandedFolderIds.value.has(folder.id)
|
||
result.push({
|
||
id: folder.id,
|
||
name: folder.name,
|
||
level,
|
||
hasChildren: children.length > 0,
|
||
expanded,
|
||
})
|
||
if (!expanded) return
|
||
for (const child of children) {
|
||
walk(child, level + 1)
|
||
}
|
||
}
|
||
|
||
walk(root, 0)
|
||
return result
|
||
})
|
||
|
||
const activeGame = computed(() => gameOptions.find((option) => option.id === selectedGameId.value) ?? null)
|
||
|
||
function setSection(nextSection: SectionId) {
|
||
activeSection.value = nextSection
|
||
statusMessage.value = `已切换到${navItems.find((item) => item.id === nextSection)?.title ?? ''}视图。`
|
||
}
|
||
|
||
function readStoredTheme(): Theme | null {
|
||
try {
|
||
const raw = localStorage.getItem(THEME_STORAGE_KEY)
|
||
return raw === 'light' || raw === 'dark' ? raw : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function writeStoredTheme(nextTheme: Theme) {
|
||
try {
|
||
localStorage.setItem(THEME_STORAGE_KEY, nextTheme)
|
||
} catch {
|
||
// Ignore storage write errors (private mode, quota, etc.)
|
||
}
|
||
}
|
||
|
||
function updateThemeMeta(nextTheme: Theme) {
|
||
let meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
|
||
if (!meta) {
|
||
meta = document.createElement('meta')
|
||
meta.name = 'theme-color'
|
||
document.head.appendChild(meta)
|
||
}
|
||
meta.content = THEME_CANVAS_COLORS[nextTheme]
|
||
}
|
||
|
||
function applyTheme(nextTheme: Theme) {
|
||
theme.value = nextTheme
|
||
document.documentElement.setAttribute('data-theme', nextTheme)
|
||
updateThemeMeta(nextTheme)
|
||
scheduleLightingTargetsRefresh()
|
||
}
|
||
|
||
function clearThemeTimers() {
|
||
if (themeApplyTimer) {
|
||
clearTimeout(themeApplyTimer)
|
||
themeApplyTimer = null
|
||
}
|
||
if (themeRevealCleanupTimer) {
|
||
clearTimeout(themeRevealCleanupTimer)
|
||
themeRevealCleanupTimer = null
|
||
}
|
||
}
|
||
|
||
function handleThemeToggle(event: MouseEvent) {
|
||
const nextTheme = toggleTheme(theme.value)
|
||
followsSystemTheme.value = false
|
||
writeStoredTheme(nextTheme)
|
||
|
||
const toggleButton = event.currentTarget instanceof HTMLElement ? event.currentTarget : null
|
||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||
if (reduceMotion || !toggleButton) {
|
||
themeRevealActive.value = false
|
||
clearThemeTimers()
|
||
applyTheme(nextTheme)
|
||
return
|
||
}
|
||
|
||
clearThemeTimers()
|
||
|
||
const rect = toggleButton.getBoundingClientRect()
|
||
const originX = rect.left + rect.width / 2
|
||
const originY = rect.top + rect.height / 2
|
||
const radius = calculateRevealRadius(originX, originY, window.innerWidth, window.innerHeight)
|
||
|
||
themeRevealStyle.value = {
|
||
'--reveal-x': `${originX}px`,
|
||
'--reveal-y': `${originY}px`,
|
||
'--reveal-radius': `${radius}px`,
|
||
'--reveal-color': THEME_CANVAS_COLORS[nextTheme],
|
||
}
|
||
|
||
themeRevealActive.value = false
|
||
requestAnimationFrame(() => {
|
||
themeRevealActive.value = true
|
||
})
|
||
|
||
themeApplyTimer = setTimeout(() => {
|
||
applyTheme(nextTheme)
|
||
themeApplyTimer = null
|
||
}, THEME_APPLY_OFFSET_MS)
|
||
|
||
themeRevealCleanupTimer = setTimeout(() => {
|
||
themeRevealActive.value = false
|
||
themeRevealCleanupTimer = null
|
||
}, THEME_REVEAL_DURATION_MS)
|
||
}
|
||
|
||
function initializeTheme() {
|
||
const storedTheme = readStoredTheme()
|
||
followsSystemTheme.value = storedTheme === null
|
||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||
applyTheme(resolveInitialTheme(storedTheme, prefersDark))
|
||
}
|
||
|
||
function buildLightingTargets(root: HTMLElement, selector: string) {
|
||
const viewportWidth = window.innerWidth
|
||
const viewportHeight = window.innerHeight
|
||
const targets: LightingTarget[] = []
|
||
for (const element of root.querySelectorAll<HTMLElement>(selector)) {
|
||
const rect = element.getBoundingClientRect()
|
||
if (!isRectVisible(rect, viewportWidth, viewportHeight)) continue
|
||
const target: LightingTarget = {
|
||
element,
|
||
left: rect.left,
|
||
top: rect.top,
|
||
}
|
||
element.style.setProperty('--target-left', `${target.left}px`)
|
||
element.style.setProperty('--target-top', `${target.top}px`)
|
||
targets.push(target)
|
||
}
|
||
return targets
|
||
}
|
||
|
||
function refreshLightingTargets() {
|
||
lightingTargetRefreshFrameId = null
|
||
workspaceLightTargets = workspaceRef.value ? buildLightingTargets(workspaceRef.value, lightTargetSelector) : []
|
||
loginLightTargets = loginRef.value ? buildLightingTargets(loginRef.value, loginLightTargetSelector) : []
|
||
}
|
||
|
||
function scheduleLightingTargetsRefresh() {
|
||
if (lightingTargetRefreshFrameId !== null) return
|
||
lightingTargetRefreshFrameId = requestAnimationFrame(refreshLightingTargets)
|
||
}
|
||
|
||
function setContainerPointer(container: HTMLElement | null, clientX: number, clientY: number) {
|
||
if (!container) return
|
||
container.style.setProperty('--pointer-x', `${clientX}px`)
|
||
container.style.setProperty('--pointer-y', `${clientY}px`)
|
||
}
|
||
|
||
function clearContainerPointer(container: HTMLElement | null) {
|
||
if (!container) return
|
||
container.style.setProperty('--pointer-x', '-9999px')
|
||
container.style.setProperty('--pointer-y', '-9999px')
|
||
}
|
||
|
||
function reconnectLightingObservers() {
|
||
if (workspaceLightObserver) {
|
||
workspaceLightObserver.disconnect()
|
||
workspaceLightObserver = null
|
||
}
|
||
if (loginLightObserver) {
|
||
loginLightObserver.disconnect()
|
||
loginLightObserver = null
|
||
}
|
||
|
||
if (workspaceRef.value) {
|
||
workspaceLightObserver = new MutationObserver(scheduleLightingTargetsRefresh)
|
||
workspaceLightObserver.observe(workspaceRef.value, { childList: true, subtree: true })
|
||
}
|
||
if (loginRef.value) {
|
||
loginLightObserver = new MutationObserver(scheduleLightingTargetsRefresh)
|
||
loginLightObserver.observe(loginRef.value, { childList: true, subtree: true })
|
||
}
|
||
}
|
||
|
||
function applyMouseLighting(clientX: number, clientY: number) {
|
||
setContainerPointer(workspaceRef.value, clientX, clientY)
|
||
}
|
||
|
||
function applyLoginLighting(clientX: number, clientY: number) {
|
||
const login = loginRef.value
|
||
if (login) {
|
||
login.classList.add('lighting-active')
|
||
}
|
||
setContainerPointer(login, clientX, clientY)
|
||
}
|
||
|
||
function flushMouseLighting() {
|
||
glowFrameId = null
|
||
if (document.hidden) return
|
||
if (!latestPointer) return
|
||
|
||
if (latestPointerTarget === 'workspace') {
|
||
applyMouseLighting(latestPointer.x, latestPointer.y)
|
||
} else if (latestPointerTarget === 'login') {
|
||
applyLoginLighting(latestPointer.x, latestPointer.y)
|
||
}
|
||
}
|
||
|
||
function onWorkspacePointerMove(event: PointerEvent) {
|
||
if (!workspaceLightTargets.length) {
|
||
scheduleLightingTargetsRefresh()
|
||
}
|
||
latestPointerTarget = 'workspace'
|
||
latestPointer = { x: event.clientX, y: event.clientY }
|
||
if (glowFrameId !== null) return
|
||
glowFrameId = requestAnimationFrame(flushMouseLighting)
|
||
}
|
||
|
||
function onWorkspacePointerLeave() {
|
||
clearContainerPointer(workspaceRef.value)
|
||
latestPointerTarget = null
|
||
}
|
||
|
||
function onLoginPointerMove(event: PointerEvent) {
|
||
if (!loginLightTargets.length) {
|
||
scheduleLightingTargetsRefresh()
|
||
}
|
||
latestPointerTarget = 'login'
|
||
latestPointer = { x: event.clientX, y: event.clientY }
|
||
if (glowFrameId !== null) return
|
||
glowFrameId = requestAnimationFrame(flushMouseLighting)
|
||
}
|
||
|
||
function onLoginPointerLeave() {
|
||
const login = loginRef.value
|
||
if (!login) return
|
||
login.classList.remove('lighting-active')
|
||
clearContainerPointer(login)
|
||
latestPointerTarget = null
|
||
}
|
||
|
||
function updateSidebarIndicator(triggerJelly = false) {
|
||
const sidebar = sidebarRef.value
|
||
if (!sidebar) return
|
||
|
||
const activeButton = sidebar.querySelector<HTMLButtonElement>(`.nav-item[data-section-id="${activeSection.value}"]`)
|
||
if (!activeButton) return
|
||
|
||
sidebarIndicatorStyle.value = {
|
||
transform: `translateY(${activeButton.offsetTop}px)`,
|
||
height: `${activeButton.offsetHeight}px`,
|
||
opacity: '1',
|
||
}
|
||
|
||
if (!triggerJelly) return
|
||
|
||
sidebarIndicatorJelly.value = false
|
||
requestAnimationFrame(() => {
|
||
sidebarIndicatorJelly.value = true
|
||
})
|
||
|
||
if (sidebarJellyTimer) {
|
||
clearTimeout(sidebarJellyTimer)
|
||
}
|
||
sidebarJellyTimer = setTimeout(() => {
|
||
sidebarIndicatorJelly.value = false
|
||
}, 560)
|
||
}
|
||
|
||
function submitLogin() {
|
||
loginError.value = ''
|
||
if (!username.value.trim()) {
|
||
loginError.value = '请输入用户名。'
|
||
return
|
||
}
|
||
if (!password.value.trim()) {
|
||
loginError.value = '请输入密码。'
|
||
return
|
||
}
|
||
|
||
isLoggedIn.value = true
|
||
statusMessage.value = `${text.welcome},${username.value.trim()}。`
|
||
}
|
||
|
||
function logout() {
|
||
isLoggedIn.value = false
|
||
password.value = ''
|
||
selectedGameId.value = null
|
||
activeSection.value = 'overview'
|
||
statusMessage.value = '你已退出登录。'
|
||
}
|
||
|
||
function openFolder(folderId: string) {
|
||
explorerCurrentFolderId.value = folderId
|
||
explorerSelectedItemId.value = null
|
||
expandFolderPath(folderId)
|
||
}
|
||
|
||
function explorerGoUp() {
|
||
const current = explorerCurrentFolder.value
|
||
if (!current?.parentId) return
|
||
explorerCurrentFolderId.value = current.parentId
|
||
explorerSelectedItemId.value = null
|
||
}
|
||
|
||
function goToPathFolder(folderId: string) {
|
||
explorerCurrentFolderId.value = folderId
|
||
explorerSelectedItemId.value = null
|
||
expandFolderPath(folderId)
|
||
}
|
||
|
||
function toggleFolderExpand(folderId: string) {
|
||
const nextExpanded = new Set(expandedFolderIds.value)
|
||
if (nextExpanded.has(folderId)) {
|
||
if (folderId !== 'root') {
|
||
nextExpanded.delete(folderId)
|
||
}
|
||
} else {
|
||
nextExpanded.add(folderId)
|
||
}
|
||
expandedFolderIds.value = nextExpanded
|
||
}
|
||
|
||
function expandFolderPath(folderId: string) {
|
||
const parentMap = new Map(explorerFolders.value.map((folder) => [folder.id, folder.parentId]))
|
||
const nextExpanded = new Set(expandedFolderIds.value)
|
||
|
||
let cursor: string | null = folderId
|
||
while (cursor) {
|
||
nextExpanded.add(cursor)
|
||
cursor = parentMap.get(cursor) ?? null
|
||
}
|
||
|
||
nextExpanded.add('root')
|
||
expandedFolderIds.value = nextExpanded
|
||
}
|
||
|
||
function nextName(baseName: string, parentId: string, kind: ExplorerItemKind) {
|
||
const siblingNames = new Set(
|
||
explorerItems.value
|
||
.filter((item) => item.parentId === parentId && item.kind === kind)
|
||
.map((item) => item.name),
|
||
)
|
||
if (!siblingNames.has(baseName)) return baseName
|
||
|
||
let index = 2
|
||
while (siblingNames.has(`${baseName} (${index})`)) {
|
||
index += 1
|
||
}
|
||
return `${baseName} (${index})`
|
||
}
|
||
|
||
function createExplorerItem(kind: ExplorerItemKind) {
|
||
const parentId = explorerCurrentFolderId.value
|
||
const baseName = kind === 'folder' ? 'New Folder' : 'New File.txt'
|
||
const nextItem: ExplorerItem = {
|
||
id: `${kind}-${nextExplorerId.value++}`,
|
||
parentId,
|
||
kind,
|
||
name: nextName(baseName, parentId, kind),
|
||
}
|
||
explorerItems.value.push(nextItem)
|
||
explorerSelectedItemId.value = nextItem.id
|
||
if (kind === 'folder') {
|
||
expandFolderPath(parentId)
|
||
}
|
||
statusMessage.value = `已创建${kind === 'folder' ? '文件夹' : '文件'}:${nextItem.name}`
|
||
}
|
||
|
||
function renameExplorerItem(itemId: string) {
|
||
const item = explorerItems.value.find((entry) => entry.id === itemId)
|
||
if (!item) return
|
||
|
||
const input = window.prompt('输入新名称', item.name)
|
||
if (input === null) return
|
||
const next = input.trim()
|
||
if (!next) {
|
||
statusMessage.value = '名称不能为空。'
|
||
return
|
||
}
|
||
|
||
item.name = next
|
||
statusMessage.value = `已重命名为:${item.name}`
|
||
}
|
||
|
||
function collectDescendantIds(rootId: string) {
|
||
const removed = new Set<string>([rootId])
|
||
const stack = [rootId]
|
||
|
||
while (stack.length) {
|
||
const current = stack.pop()!
|
||
for (const item of explorerItems.value) {
|
||
if (item.parentId !== current) continue
|
||
if (removed.has(item.id)) continue
|
||
removed.add(item.id)
|
||
stack.push(item.id)
|
||
}
|
||
}
|
||
|
||
return removed
|
||
}
|
||
|
||
function deleteExplorerItem(itemId: string) {
|
||
const target = explorerItems.value.find((item) => item.id === itemId)
|
||
if (!target || target.id === 'root') return
|
||
|
||
const confirmed = window.confirm(
|
||
target.kind === 'folder'
|
||
? `确定删除文件夹“${target.name}”及其内容?`
|
||
: `确定删除文件“${target.name}”?`,
|
||
)
|
||
if (!confirmed) return
|
||
|
||
const removedIds = collectDescendantIds(itemId)
|
||
explorerItems.value = explorerItems.value.filter((item) => !removedIds.has(item.id))
|
||
|
||
if (explorerSelectedItemId.value && removedIds.has(explorerSelectedItemId.value)) {
|
||
explorerSelectedItemId.value = null
|
||
}
|
||
|
||
if (removedIds.has(explorerCurrentFolderId.value)) {
|
||
explorerCurrentFolderId.value = target.parentId ?? 'root'
|
||
}
|
||
|
||
const nextExpanded = new Set(expandedFolderIds.value)
|
||
for (const removedId of removedIds) {
|
||
nextExpanded.delete(removedId)
|
||
}
|
||
nextExpanded.add('root')
|
||
expandedFolderIds.value = nextExpanded
|
||
|
||
statusMessage.value = `已删除:${target.name}`
|
||
}
|
||
|
||
function openExplorerItem(item: ExplorerItem) {
|
||
explorerSelectedItemId.value = item.id
|
||
if (item.kind === 'folder') {
|
||
openFolder(item.id)
|
||
statusMessage.value = `已进入文件夹:${item.name}`
|
||
return
|
||
}
|
||
statusMessage.value = `已打开文件:${item.name}`
|
||
}
|
||
|
||
function selectGame(gameId: GameId) {
|
||
selectedGameId.value = gameId
|
||
statusMessage.value = `已启动游戏:${gameOptions.find((item) => item.id === gameId)?.label ?? ''}`
|
||
}
|
||
|
||
function backToGameChooser() {
|
||
selectedGameId.value = null
|
||
statusMessage.value = '已返回游戏列表。'
|
||
}
|
||
|
||
async function toggleGameFullscreen() {
|
||
if (!gamePlayerRef.value) return
|
||
try {
|
||
if (document.fullscreenElement === gamePlayerRef.value) {
|
||
await document.exitFullscreen()
|
||
return
|
||
}
|
||
await gamePlayerRef.value.requestFullscreen()
|
||
} catch {
|
||
statusMessage.value = '当前浏览器不支持全屏或全屏被阻止。'
|
||
}
|
||
}
|
||
|
||
function onFullscreenChange() {
|
||
isGameFullscreen.value = document.fullscreenElement === gamePlayerRef.value
|
||
}
|
||
|
||
function onWindowResize() {
|
||
updateSidebarIndicator(false)
|
||
scheduleLightingTargetsRefresh()
|
||
}
|
||
|
||
function onAnyScroll() {
|
||
scheduleLightingTargetsRefresh()
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
initializeTheme()
|
||
}
|
||
|
||
watch(
|
||
activeSection,
|
||
async () => {
|
||
await nextTick()
|
||
updateSidebarIndicator(true)
|
||
scheduleLightingTargetsRefresh()
|
||
},
|
||
{ flush: 'post' },
|
||
)
|
||
|
||
watch(isLoggedIn, async (loggedIn) => {
|
||
await nextTick()
|
||
if (loggedIn) {
|
||
updateSidebarIndicator(false)
|
||
}
|
||
refreshLightingTargets()
|
||
reconnectLightingObservers()
|
||
clearContainerPointer(workspaceRef.value)
|
||
clearContainerPointer(loginRef.value)
|
||
})
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||
window.addEventListener('resize', onWindowResize)
|
||
document.addEventListener('scroll', onAnyScroll, true)
|
||
systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||
systemThemeChangeHandler = (event) => {
|
||
if (!followsSystemTheme.value) return
|
||
applyTheme(event.matches ? 'dark' : 'light')
|
||
}
|
||
systemThemeMediaQuery.addEventListener('change', systemThemeChangeHandler)
|
||
nextTick(() => {
|
||
updateSidebarIndicator(false)
|
||
refreshLightingTargets()
|
||
reconnectLightingObservers()
|
||
clearContainerPointer(workspaceRef.value)
|
||
clearContainerPointer(loginRef.value)
|
||
})
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||
window.removeEventListener('resize', onWindowResize)
|
||
document.removeEventListener('scroll', onAnyScroll, true)
|
||
if (systemThemeMediaQuery && systemThemeChangeHandler) {
|
||
systemThemeMediaQuery.removeEventListener('change', systemThemeChangeHandler)
|
||
}
|
||
clearThemeTimers()
|
||
if (sidebarJellyTimer) {
|
||
clearTimeout(sidebarJellyTimer)
|
||
}
|
||
if (glowFrameId !== null) {
|
||
cancelAnimationFrame(glowFrameId)
|
||
}
|
||
if (lightingTargetRefreshFrameId !== null) {
|
||
cancelAnimationFrame(lightingTargetRefreshFrameId)
|
||
}
|
||
if (workspaceLightObserver) {
|
||
workspaceLightObserver.disconnect()
|
||
}
|
||
if (loginLightObserver) {
|
||
loginLightObserver.disconnect()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<a class="skip-link" href="#main-content">{{ text.skip }}</a>
|
||
<div
|
||
aria-hidden="true"
|
||
class="theme-reveal-layer"
|
||
:class="{ active: themeRevealActive }"
|
||
:style="themeRevealStyle"
|
||
></div>
|
||
|
||
<Transition name="view-swap" mode="out-in">
|
||
<main
|
||
v-if="!isLoggedIn"
|
||
id="main-content"
|
||
key="login"
|
||
ref="loginRef"
|
||
class="login-view"
|
||
@pointermove="onLoginPointerMove"
|
||
@pointerleave="onLoginPointerLeave"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="theme-toggle theme-toggle-floating"
|
||
:class="{ 'is-dark': isDarkTheme }"
|
||
:aria-label="themeToggleAriaLabel"
|
||
@click="handleThemeToggle"
|
||
>
|
||
<span class="theme-toggle-track" aria-hidden="true">
|
||
<span class="theme-toggle-sun">浅</span>
|
||
<span class="theme-toggle-moon">深</span>
|
||
<span class="theme-toggle-thumb"></span>
|
||
</span>
|
||
</button>
|
||
|
||
<section class="login-card" aria-labelledby="login-title">
|
||
<p class="eyebrow">Workspace Console</p>
|
||
<h1 id="login-title">{{ text.loginTitle }}</h1>
|
||
<p class="subtitle">{{ text.loginSubtitle }}</p>
|
||
|
||
<form class="login-form" @submit.prevent="submitLogin">
|
||
<label for="username">{{ text.username }}</label>
|
||
<div class="login-input-shell">
|
||
<input
|
||
id="username"
|
||
v-model="username"
|
||
name="username"
|
||
autocomplete="username"
|
||
type="text"
|
||
spellcheck="false"
|
||
:placeholder="text.usernamePlaceholder"
|
||
/>
|
||
</div>
|
||
|
||
<label for="password">{{ text.password }}</label>
|
||
<div class="login-input-shell">
|
||
<input
|
||
id="password"
|
||
v-model="password"
|
||
name="password"
|
||
autocomplete="current-password"
|
||
type="password"
|
||
:placeholder="text.passwordPlaceholder"
|
||
/>
|
||
</div>
|
||
|
||
<p v-if="loginError" class="form-error" aria-live="polite">{{ loginError }}</p>
|
||
<button type="submit">{{ text.loginButton }}</button>
|
||
</form>
|
||
</section>
|
||
</main>
|
||
|
||
<main
|
||
v-else
|
||
id="main-content"
|
||
key="workspace"
|
||
ref="workspaceRef"
|
||
class="workspace-view"
|
||
@pointermove="onWorkspacePointerMove"
|
||
@pointerleave="onWorkspacePointerLeave"
|
||
>
|
||
<header class="topbar">
|
||
<div>
|
||
<p class="eyebrow">Workspace</p>
|
||
<h1>Personal Command Center</h1>
|
||
</div>
|
||
<div class="topbar-actions">
|
||
<button
|
||
type="button"
|
||
class="theme-toggle"
|
||
:class="{ 'is-dark': isDarkTheme }"
|
||
:aria-label="themeToggleAriaLabel"
|
||
@click="handleThemeToggle"
|
||
>
|
||
<span class="theme-toggle-track" aria-hidden="true">
|
||
<span class="theme-toggle-sun">浅</span>
|
||
<span class="theme-toggle-moon">深</span>
|
||
<span class="theme-toggle-thumb"></span>
|
||
</span>
|
||
</button>
|
||
<p class="user-chip">{{ currentUser }}</p>
|
||
<button type="button" class="ghost-btn" @click="logout">退出</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="workspace-layout">
|
||
<aside ref="sidebarRef" class="sidebar" aria-label="section-navigation">
|
||
<span
|
||
aria-hidden="true"
|
||
class="nav-active-indicator"
|
||
:class="{ jelly: sidebarIndicatorJelly }"
|
||
:style="sidebarIndicatorStyle"
|
||
></span>
|
||
<button
|
||
v-for="item in navItems"
|
||
:key="item.id"
|
||
type="button"
|
||
class="nav-item"
|
||
:data-section-id="item.id"
|
||
:class="{ active: activeSection === item.id }"
|
||
@click="setSection(item.id)"
|
||
>
|
||
<span class="nav-icon" aria-hidden="true">
|
||
<component :is="item.icon" class="nav-icon-glyph" />
|
||
</span>
|
||
<span class="nav-copy">
|
||
<strong>{{ item.title }}</strong>
|
||
<small>{{ item.subtitle }}</small>
|
||
</span>
|
||
</button>
|
||
</aside>
|
||
|
||
<section class="panel" aria-live="polite">
|
||
<div class="panel-body">
|
||
<header class="panel-intro">
|
||
<div>
|
||
<p class="eyebrow">{{ sectionMeta.eyebrow }}</p>
|
||
<h2>{{ sectionMeta.title }}</h2>
|
||
<p>{{ sectionMeta.description }}</p>
|
||
</div>
|
||
<span class="panel-intro-badge">{{ sectionMeta.badge }}</span>
|
||
</header>
|
||
|
||
<div v-if="activeSection === 'overview'" class="panel-section overview-panel">
|
||
<div class="overview-hero-grid">
|
||
<article class="hero-card hero-card-featured">
|
||
<div class="hero-copy">
|
||
<span class="hero-kicker">Workspace Flow</span>
|
||
<h3>一眼进入高频任务</h3>
|
||
<p>
|
||
从这里切换到文件、游戏或学习模块。界面改为稳定导航 + 单主工作区,并用更强的层次、光感与状态反馈降低切换成本。
|
||
</p>
|
||
</div>
|
||
<div class="hero-actions">
|
||
<button type="button" class="primary-btn" @click="setSection('explorer')">打开文件管理</button>
|
||
<button type="button" class="ghost-btn" @click="setSection('games')">打开游戏中心</button>
|
||
</div>
|
||
<div class="hero-stat-strip" aria-label="总览状态">
|
||
<div v-for="signal in overviewSignals" :key="signal.label" class="hero-stat">
|
||
<small>{{ signal.label }}</small>
|
||
<strong>{{ signal.value }}</strong>
|
||
<span>{{ signal.note }}</span>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<aside class="insight-rail" aria-label="工作区重点">
|
||
<article class="insight-card ambient">
|
||
<span class="insight-pill">Live Pulse</span>
|
||
<strong>主工作区在线</strong>
|
||
<p>导航、主题切换与局部高光已统一到同一套交互语言。</p>
|
||
</article>
|
||
<article class="insight-card">
|
||
<span class="insight-pill neutral">Focus</span>
|
||
<strong>{{ explorerCurrentFolder?.name ?? 'Workspace' }}</strong>
|
||
<p>当前目录可直接新建、重命名与回退,减少跳转路径。</p>
|
||
</article>
|
||
</aside>
|
||
</div>
|
||
|
||
<div class="metric-grid">
|
||
<article class="metric-card">
|
||
<p>Folders</p>
|
||
<strong>{{ explorerFolders.length }}</strong>
|
||
<span>工作区目录总数</span>
|
||
</article>
|
||
<article class="metric-card">
|
||
<p>Items In Current Folder</p>
|
||
<strong>{{ explorerChildItems.length }}</strong>
|
||
<span>当前层级可操作项</span>
|
||
</article>
|
||
<article class="metric-card">
|
||
<p>Games</p>
|
||
<strong>{{ gameOptions.length }}</strong>
|
||
<span>支持快速进入沉浸模式</span>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="activeSection === 'explorer'" class="panel-section explorer-panel">
|
||
<header class="explorer-toolbar">
|
||
<button
|
||
type="button"
|
||
class="icon-btn"
|
||
:disabled="!explorerCurrentFolder?.parentId"
|
||
aria-label="返回上级目录"
|
||
@click="explorerGoUp"
|
||
>
|
||
<ArrowUpBold class="inline-icon" aria-hidden="true" />
|
||
</button>
|
||
|
||
<nav class="pathbar" aria-label="当前路径">
|
||
<button
|
||
v-for="(pathItem, index) in explorerPath"
|
||
:key="pathItem.id"
|
||
type="button"
|
||
class="path-segment"
|
||
@click="goToPathFolder(pathItem.id)"
|
||
>
|
||
<FolderOpened class="inline-icon" aria-hidden="true" />
|
||
<span>{{ pathItem.name }}</span>
|
||
<Right v-if="index < explorerPath.length - 1" class="path-arrow" aria-hidden="true" />
|
||
</button>
|
||
</nav>
|
||
|
||
<div class="toolbar-actions">
|
||
<button type="button" class="ghost-btn" @click="createExplorerItem('folder')">
|
||
<Plus class="inline-icon" aria-hidden="true" />新建文件夹
|
||
</button>
|
||
<button type="button" class="ghost-btn" @click="createExplorerItem('file')">
|
||
<Document class="inline-icon" aria-hidden="true" />新建文件
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="explorer-layout">
|
||
<aside class="folder-list" aria-label="所有文件夹">
|
||
<div
|
||
v-for="folder in explorerFolderTreeItems"
|
||
:key="folder.id"
|
||
class="tree-row"
|
||
:style="{ paddingLeft: `${8 + folder.level * 14}px` }"
|
||
>
|
||
<button
|
||
v-if="folder.hasChildren"
|
||
type="button"
|
||
class="tree-toggle"
|
||
:aria-label="folder.expanded ? '折叠文件夹' : '展开文件夹'"
|
||
@click="toggleFolderExpand(folder.id)"
|
||
>
|
||
<Right class="tree-chevron" :class="{ expanded: folder.expanded }" aria-hidden="true" />
|
||
</button>
|
||
<span v-else class="tree-placeholder" aria-hidden="true"></span>
|
||
|
||
<button
|
||
type="button"
|
||
class="folder-item"
|
||
:class="{ active: explorerCurrentFolderId === folder.id }"
|
||
@click="openFolder(folder.id)"
|
||
>
|
||
<FolderOpened class="inline-icon" aria-hidden="true" />
|
||
<span>{{ folder.name }}</span>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="file-list" role="list">
|
||
<article v-for="item in explorerChildItems" :key="item.id" class="file-card" role="listitem">
|
||
<button
|
||
type="button"
|
||
class="file-main"
|
||
:class="{ selected: explorerSelectedItemId === item.id }"
|
||
@click="openExplorerItem(item)"
|
||
>
|
||
<span class="file-icon" aria-hidden="true">
|
||
<FolderOpened v-if="item.kind === 'folder'" class="inline-icon" />
|
||
<Document v-else class="inline-icon" />
|
||
</span>
|
||
<span class="file-text">
|
||
<strong>{{ item.name }}</strong>
|
||
<small>{{ item.kind === 'folder' ? '文件夹' : '文件' }}</small>
|
||
</span>
|
||
</button>
|
||
<div class="file-actions">
|
||
<button
|
||
type="button"
|
||
class="icon-btn"
|
||
:aria-label="`重命名 ${item.name}`"
|
||
@click="renameExplorerItem(item.id)"
|
||
>
|
||
<EditPen class="inline-icon" aria-hidden="true" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="icon-btn danger"
|
||
:aria-label="`删除 ${item.name}`"
|
||
@click="deleteExplorerItem(item.id)"
|
||
>
|
||
<Delete class="inline-icon" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</article>
|
||
|
||
<p v-if="!explorerChildItems.length" class="empty">当前目录为空。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="activeSection === 'games'" class="panel-section games-panel">
|
||
<div v-if="!activeGame" class="game-grid">
|
||
<button
|
||
v-for="game in gameOptions"
|
||
:key="game.id"
|
||
type="button"
|
||
class="game-card"
|
||
@click="selectGame(game.id)"
|
||
>
|
||
<span class="game-icon" aria-hidden="true">
|
||
<Trophy class="inline-icon" />
|
||
</span>
|
||
<strong>{{ game.label }}</strong>
|
||
<small>{{ game.subtitle }}</small>
|
||
<span class="game-meta">Launch Experience</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-else ref="gamePlayerRef" class="game-player">
|
||
<header class="game-player-bar">
|
||
<div class="game-title-wrap">
|
||
<Monitor class="inline-icon" aria-hidden="true" />
|
||
<strong>{{ activeGame.label }}</strong>
|
||
</div>
|
||
<div class="game-actions">
|
||
<button
|
||
type="button"
|
||
class="ghost-btn"
|
||
:aria-label="isGameFullscreen ? '退出全屏' : '进入全屏'"
|
||
@click="toggleGameFullscreen"
|
||
>
|
||
{{ isGameFullscreen ? '退出全屏' : '全屏' }}
|
||
</button>
|
||
<button type="button" class="ghost-btn" @click="backToGameChooser">返回列表</button>
|
||
</div>
|
||
</header>
|
||
<iframe
|
||
class="game-frame"
|
||
:title="activeGame.label"
|
||
:src="activeGame.path"
|
||
loading="lazy"
|
||
allow="fullscreen"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="panel-section school-panel">
|
||
<article class="hero-card compact">
|
||
<h3>学习路径</h3>
|
||
<p>把课程链接、阶段任务和周计划集中到一个高可读性的学习面板里。</p>
|
||
</article>
|
||
<div class="study-grid">
|
||
<article v-for="track in studyTracks" :key="track.title" class="study-card">
|
||
<span class="study-meta">{{ track.meta }}</span>
|
||
<h3>{{ track.title }}</h3>
|
||
<p>{{ track.description }}</p>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<p class="status" aria-live="polite">{{ statusMessage }}</p>
|
||
</main>
|
||
</Transition>
|
||
</template>
|