1st_version

This commit is contained in:
yoyuzh
2026-03-12 15:06:47 +08:00
parent d669738967
commit d993d3f943
8 changed files with 1914 additions and 305 deletions

View File

@@ -13,6 +13,8 @@ import {
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'
@@ -47,6 +49,19 @@ interface ExplorerFolderTreeItem {
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',
@@ -59,6 +74,14 @@ const text = {
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('')
@@ -81,12 +104,30 @@ const sidebarIndicatorStyle = ref({
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, .topbar, .sidebar, .panel, .hero-card, .metric-card, .explorer-toolbar, .folder-list, .file-list, .file-card, .game-player, .study-card, .path-segment, .status'
'.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[] = [
@@ -113,6 +154,74 @@ const gameOptions: GameOption[] = [
]
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],
@@ -196,38 +305,181 @@ function setSection(nextSection: SectionId) {
statusMessage.value = `已切换到${navItems.find((item) => item.id === nextSection)?.title ?? ''}视图。`
}
function applyMouseLighting(clientX: number, clientY: number) {
const workspace = workspaceRef.value
if (!workspace) return
const targets = workspace.querySelectorAll<HTMLElement>(lightTargetSelector)
for (const target of targets) {
const rect = target.getBoundingClientRect()
target.style.setProperty('--lx', `${clientX - rect.left}px`)
target.style.setProperty('--ly', `${clientY - rect.top}px`)
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) return
login.classList.add('lighting-active')
const targets = login.querySelectorAll<HTMLElement>(loginLightTargetSelector)
for (const target of targets) {
const rect = target.getBoundingClientRect()
const x = `${clientX - rect.left}px`
const y = `${clientY - rect.top}px`
target.style.setProperty('--lx', x)
target.style.setProperty('--ly', y)
target.style.setProperty('--mx', x)
target.style.setProperty('--my', y)
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') {
@@ -236,6 +488,9 @@ function flushMouseLighting() {
}
function onWorkspacePointerMove(event: PointerEvent) {
if (!workspaceLightTargets.length) {
scheduleLightingTargetsRefresh()
}
latestPointerTarget = 'workspace'
latestPointer = { x: event.clientX, y: event.clientY }
if (glowFrameId !== null) return
@@ -243,17 +498,14 @@ function onWorkspacePointerMove(event: PointerEvent) {
}
function onWorkspacePointerLeave() {
const workspace = workspaceRef.value
if (!workspace) return
const targets = workspace.querySelectorAll<HTMLElement>(lightTargetSelector)
for (const target of targets) {
target.style.setProperty('--lx', '-9999px')
target.style.setProperty('--ly', '-9999px')
}
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
@@ -264,13 +516,7 @@ function onLoginPointerLeave() {
const login = loginRef.value
if (!login) return
login.classList.remove('lighting-active')
const targets = login.querySelectorAll<HTMLElement>(loginLightTargetSelector)
for (const target of targets) {
target.style.setProperty('--lx', '-9999px')
target.style.setProperty('--ly', '-9999px')
target.style.setProperty('--mx', '-9999px')
target.style.setProperty('--my', '-9999px')
}
clearContainerPointer(login)
latestPointerTarget = null
}
@@ -506,6 +752,15 @@ function onFullscreenChange() {
function onWindowResize() {
updateSidebarIndicator(false)
scheduleLightingTargetsRefresh()
}
function onAnyScroll() {
scheduleLightingTargetsRefresh()
}
if (typeof window !== 'undefined') {
initializeTheme()
}
watch(
@@ -513,98 +768,165 @@ watch(
async () => {
await nextTick()
updateSidebarIndicator(true)
scheduleLightingTargetsRefresh()
},
{ flush: 'post' },
)
watch(isLoggedIn, async (loggedIn) => {
if (!loggedIn) return
await nextTick()
updateSidebarIndicator(false)
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>
<main
v-if="!isLoggedIn"
id="main-content"
ref="loginRef"
class="login-view"
@pointermove="onLoginPointerMove"
@pointerleave="onLoginPointerLeave"
>
<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>
<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>
<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>
<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>
<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>
<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>
<p v-if="loginError" class="form-error" aria-live="polite">{{ loginError }}</p>
<button type="submit">{{ text.loginButton }}</button>
</form>
</section>
</main>
<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>
<main
v-else
id="main-content"
ref="workspaceRef"
class="workspace-view"
@pointermove="onWorkspacePointerMove"
@pointerleave="onWorkspacePointerLeave"
>
<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>
@@ -638,33 +960,73 @@ onUnmounted(() => {
</aside>
<section class="panel" aria-live="polite">
<div v-if="activeSection === 'overview'" class="panel-body overview-panel">
<article class="hero-card">
<h2>一眼进入高频任务</h2>
<p>从这里切换到文件游戏或学习模块相比原先桌面拖拽窗口模式这里改为稳定导航 + 单主工作区减少操作成本</p>
<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 class="panel-body">
<header class="panel-intro">
<div>
<p class="eyebrow">{{ sectionMeta.eyebrow }}</p>
<h2>{{ sectionMeta.title }}</h2>
<p>{{ sectionMeta.description }}</p>
</div>
</article>
<span class="panel-intro-badge">{{ sectionMeta.badge }}</span>
</header>
<div class="metric-grid">
<article class="metric-card">
<p>Folders</p>
<strong>{{ explorerFolders.length }}</strong>
</article>
<article class="metric-card">
<p>Items In Current Folder</p>
<strong>{{ explorerChildItems.length }}</strong>
</article>
<article class="metric-card">
<p>Games</p>
<strong>{{ gameOptions.length }}</strong>
</article>
<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>
<div v-else-if="activeSection === 'explorer'" class="panel-body explorer-panel">
<div v-else-if="activeSection === 'explorer'" class="panel-section explorer-panel">
<header class="explorer-toolbar">
<button
type="button"
@@ -773,7 +1135,7 @@ onUnmounted(() => {
</div>
</div>
<div v-else-if="activeSection === 'games'" class="panel-body games-panel">
<div v-else-if="activeSection === 'games'" class="panel-section games-panel">
<div v-if="!activeGame" class="game-grid">
<button
v-for="game in gameOptions"
@@ -787,6 +1149,7 @@ onUnmounted(() => {
</span>
<strong>{{ game.label }}</strong>
<small>{{ game.subtitle }}</small>
<span class="game-meta">Launch Experience</span>
</button>
</div>
@@ -816,31 +1179,26 @@ onUnmounted(() => {
allow="fullscreen"
/>
</div>
</div>
</div>
<div v-else class="panel-body school-panel">
<div v-else class="panel-section school-panel">
<article class="hero-card compact">
<h2>学习路径</h2>
<p>你可以把课程链接阶段任务周计划集中放在这</p>
<h3>学习路径</h3>
<p>把课程链接阶段任务和周计划集中到一个高可读性的学习面板</p>
</article>
<div class="study-grid">
<article class="study-card">
<h3>Frontend</h3>
<p>Vue + TypeScript 组件拆分状态设计可访问性</p>
</article>
<article class="study-card">
<h3>Graphics</h3>
<p>游戏渲染循环碰撞检测输入系统与性能优化</p>
</article>
<article class="study-card">
<h3>Deployment</h3>
<p>构建产物缓存策略静态资源托管与监控</p>
<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>
</main>
</Transition>
</template>