feat: enrich motion and page transitions

This commit is contained in:
admin_gitea
2026-08-04 07:58:22 +08:00
parent eafa0549f7
commit 68021a9f99
14 changed files with 505 additions and 133 deletions
+9 -3
View File
@@ -1,7 +1,9 @@
<template>
<div v-if="isPublicPage" class="public-shell">
<router-view v-slot="{ Component, route: viewRoute }">
<Transition name="route-view" mode="out-in" appear>
<div v-if="viewRoute.name === 'home'" :key="viewRoute.name" class="public-shell">
<div class="background" aria-hidden="true"></div>
<router-view />
<component :is="Component" />
<footer class="site-footer">
<span>© {{ footerYearText }} Made by <a href="/">{{ userName }}</a></span>
<a v-if="icpNumber" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">
@@ -19,7 +21,11 @@
</a>
</footer>
</div>
<router-view v-else />
<div v-else :key="viewRoute.name" class="route-shell">
<component :is="Component" />
</div>
</Transition>
</router-view>
</template>
<script setup>
+65 -18
View File
@@ -1,7 +1,9 @@
<template>
<div class="admin-page">
<div class="admin-background" aria-hidden="true"></div>
<Transition name="drawer-backdrop">
<button v-if="sidebarOpen" type="button" class="drawer-backdrop" aria-label="关闭导航" @click="sidebarOpen = false"></button>
</Transition>
<aside class="admin-sidebar" :class="{ open: sidebarOpen }">
<router-link to="/" class="admin-brand" title="返回主页">
@@ -26,7 +28,7 @@
<div class="sidebar-footer">
<router-link to="/" class="sidebar-action" title="查看主页" aria-label="查看主页"><i class="fas fa-arrow-up-right-from-square"></i></router-link>
<button type="button" class="sidebar-action" :title="themeLabel" :aria-label="themeLabel" @click="toggleTheme"><i :class="themeIcon"></i></button>
<button type="button" class="sidebar-action" :title="themeLabel" :aria-label="themeLabel" @click="toggleTheme($event)"><i :class="themeIcon"></i></button>
<button type="button" class="sidebar-action" title="修改密码" aria-label="修改密码" @click="passwordModalOpen = true"><i class="fas fa-key"></i></button>
<button type="button" class="sidebar-action sidebar-action--danger" title="退出登录" aria-label="退出登录" @click="requestLogout"><i class="fas fa-right-from-bracket"></i></button>
</div>
@@ -36,23 +38,31 @@
<header class="admin-topbar">
<div class="topbar-title">
<button type="button" class="menu-button" title="打开导航" aria-label="打开导航" @click="sidebarOpen = true"><i class="fas fa-bars"></i></button>
<Transition name="topbar-current" mode="out-in">
<div :key="activeTab" class="topbar-current">
<span class="page-icon"><i :class="currentTab.icon"></i></span>
<div><h1>{{ activeTab }}</h1><p>{{ currentTab.description }}</p></div>
</div>
</Transition>
</div>
<div class="topbar-actions">
<button v-if="activeTab === '站点配置'" type="button" class="primary-button" :disabled="actionLoading" @click="saveSiteConfig">
<Transition name="topbar-action" mode="out-in">
<button v-if="activeTab === '站点配置'" key="save-config" type="button" class="primary-button" :disabled="actionLoading" @click="saveSiteConfig">
<i :class="actionLoading ? 'fas fa-spinner fa-spin' : 'fas fa-floppy-disk'"></i><span>保存配置</span>
</button>
<button v-else-if="activeTab === '站点管理'" type="button" class="primary-button" @click="openSiteForm()"><i class="fas fa-plus"></i><span>添加站点</span></button>
<button v-else-if="activeTab === '联系方式管理'" type="button" class="primary-button" @click="openContactForm()"><i class="fas fa-plus"></i><span>添加联系方式</span></button>
<button v-else-if="activeTab === '站点管理'" key="add-site" type="button" class="primary-button" @click="openSiteForm()"><i class="fas fa-plus"></i><span>添加站点</span></button>
<button v-else-if="activeTab === '联系方式管理'" key="add-contact" type="button" class="primary-button" @click="openContactForm()"><i class="fas fa-plus"></i><span>添加联系方式</span></button>
</Transition>
</div>
</header>
<main class="admin-content">
<div v-if="loading" class="admin-loading"><i class="fas fa-spinner fa-spin"></i><span>正在读取管理数据</span></div>
<Dashboard v-else-if="activeTab === '仪表盘'" />
<Transition name="admin-view" mode="out-in">
<div v-if="loading" key="loading" class="admin-loading"><i class="fas fa-spinner fa-spin"></i><span>正在读取管理数据</span></div>
<Dashboard v-else-if="activeTab === '仪表盘'" key="dashboard" />
<AdminConfig
v-else-if="activeTab === '站点配置'"
key="config"
:config="siteConfig"
:rotating-texts="rotatingTexts"
:active-section="activeConfigSection"
@@ -61,8 +71,9 @@
@remove-text="removeRotatingText"
@save-texts="saveRotatingTexts"
/>
<AdminCollection v-else-if="activeTab === '站点管理'" kind="sites" :items="sites" @edit="openSiteForm" @delete="requestDelete('site', $event)" />
<AdminCollection v-else kind="contacts" :items="contacts" @edit="openContactForm" @delete="requestDelete('contact', $event)" />
<AdminCollection v-else-if="activeTab === '站点管理'" key="sites" kind="sites" :items="sites" @edit="openSiteForm" @delete="requestDelete('site', $event)" />
<AdminCollection v-else key="contacts" kind="contacts" :items="contacts" @edit="openContactForm" @delete="requestDelete('contact', $event)" />
</Transition>
</main>
</div>
@@ -70,6 +81,7 @@
<div v-if="toast.visible" class="admin-toast" :class="`admin-toast--${toast.type}`" role="status">
<i :class="toastIcon" aria-hidden="true"></i><span>{{ toast.message }}</span>
<button type="button" aria-label="关闭通知" @click="toast.visible = false"><i class="fas fa-xmark"></i></button>
<span class="toast-progress" aria-hidden="true"></span>
</div>
</Transition>
@@ -349,25 +361,31 @@ onUnmounted(() => { window.clearTimeout(toastTimer); configChannel?.close() })
<style scoped>
.admin-page { min-height: 100vh; min-height: 100dvh; color: var(--text-color); }
.admin-background { position: fixed; inset: 0; z-index: -1; background: var(--background-color) var(--background-image) repeat; }
.admin-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 100; width: 232px; display: flex; flex-direction: column; border-right: 1px solid var(--border-color); background: var(--surface-color); backdrop-filter: blur(18px); }
.admin-brand { height: 74px; display: flex; align-items: center; gap: 11px; padding: 0 16px; border-bottom: 1px solid var(--border-color); }
.admin-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 100; width: 232px; display: flex; flex-direction: column; border-right: 1px solid var(--border-color); background: var(--surface-color); backdrop-filter: blur(18px); animation: admin-sidebar-in var(--motion-emphasis) var(--motion-ease) both; }
.admin-brand { height: 74px; display: flex; align-items: center; gap: 11px; padding: 0 16px; border-bottom: 1px solid var(--border-color); animation: admin-item-in var(--motion-base) var(--motion-ease) 80ms both; }
.brand-mark { width: 40px; height: 40px; display: grid; place-items: center; flex: 0 0 auto; overflow: hidden; border: 1px solid var(--border-color); border-radius: 50%; color: var(--hover-link-color); background: var(--surface-muted); }
.brand-mark img { width: 100%; height: 100%; object-fit: cover; }
.admin-brand > span:last-child { min-width: 0; display: flex; flex-direction: column; }
.admin-brand strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.admin-brand small { color: var(--text-muted); font-size: 11px; }
.admin-nav { flex: 1; display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; }
.admin-nav button { min-height: 43px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border: 1px solid transparent; border-radius: 6px; color: var(--text-muted); background: transparent; text-align: left; cursor: pointer; }
.admin-nav button i { width: 18px; text-align: center; }
.admin-nav button { min-height: 43px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border: 1px solid transparent; border-radius: 6px; color: var(--text-muted); background: transparent; text-align: left; cursor: pointer; animation: admin-item-in var(--motion-base) var(--motion-ease) both; transition: color var(--motion-fast) var(--motion-ease-standard), background-color var(--motion-fast) var(--motion-ease-standard), border-color var(--motion-fast) var(--motion-ease-standard), transform var(--motion-fast) var(--motion-ease); }
.admin-nav button:nth-child(1) { animation-delay: 120ms; }
.admin-nav button:nth-child(2) { animation-delay: 165ms; }
.admin-nav button:nth-child(3) { animation-delay: 210ms; }
.admin-nav button:nth-child(4) { animation-delay: 255ms; }
.admin-nav button i { width: 18px; text-align: center; transition: transform var(--motion-base) var(--motion-ease); }
.admin-nav button:hover { color: var(--text-color); background: var(--surface-muted); }
.admin-nav button.active { border-color: var(--border-color); color: var(--text-color); background: var(--surface-solid); box-shadow: inset 3px 0 var(--hover-link-color); font-weight: 600; }
.sidebar-footer { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; padding: 12px 10px; border-top: 1px solid var(--border-color); }
.admin-nav button.active i { transform: scale(1.12) rotate(-5deg); }
.sidebar-footer { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; padding: 12px 10px; border-top: 1px solid var(--border-color); animation: admin-item-in var(--motion-base) var(--motion-ease) 280ms both; }
.sidebar-action { width: 42px; height: 38px; display: grid; place-items: center; justify-self: center; border: 1px solid transparent; border-radius: 6px; color: var(--text-muted); background: transparent; cursor: pointer; }
.sidebar-action:hover { border-color: var(--border-color); color: var(--hover-link-color); background: var(--surface-solid); }
.sidebar-action--danger:hover { color: var(--danger-color); }
.admin-main { min-height: 100vh; margin-left: 232px; }
.admin-topbar { position: sticky; top: 0; z-index: 50; min-height: 74px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 10px 24px; border-bottom: 1px solid var(--border-color); background: var(--surface-color); backdrop-filter: blur(18px); }
.admin-topbar { position: sticky; top: 0; z-index: 50; min-height: 74px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 10px 24px; border-bottom: 1px solid var(--border-color); background: var(--surface-color); backdrop-filter: blur(18px); animation: admin-topbar-in var(--motion-emphasis) var(--motion-ease) 60ms both; }
.topbar-title { min-width: 0; display: flex; align-items: center; gap: 11px; }
.topbar-current { min-width: 0; display: flex; align-items: center; gap: 11px; }
.page-icon { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 7px; color: #2c2500; background: var(--hover-link-color); }
.topbar-title h1 { margin: 0 0 2px; font-size: 17px; letter-spacing: 0; }
.topbar-title p { margin: 0; color: var(--text-muted); font-size: 11px; }
@@ -379,23 +397,30 @@ onUnmounted(() => { window.clearTimeout(toastTimer); configChannel?.close() })
.primary-button,
.secondary-button,
.danger-button { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border-radius: 6px; font-weight: 600; cursor: pointer; }
.primary-button,
.secondary-button,
.danger-button { transition: transform var(--motion-fast) var(--motion-ease), box-shadow var(--motion-base) var(--motion-ease), filter var(--motion-fast) var(--motion-ease-standard); }
.primary-button:hover:not(:disabled),
.secondary-button:hover:not(:disabled),
.danger-button:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 14px var(--shadow-color); }
.primary-button { border: 1px solid #d1a700; color: #2b2400; background: var(--hover-link-color); }
.secondary-button { border: 1px solid var(--border-color); color: var(--text-color); background: var(--surface-muted); }
.danger-button { border: 1px solid var(--danger-color); color: white; background: var(--danger-color); }
.primary-button:disabled,
.secondary-button:disabled,
.danger-button:disabled { opacity: 0.55; cursor: not-allowed; }
.admin-toast { position: fixed; top: 86px; right: 18px; z-index: 3000; max-width: min(420px, calc(100vw - 36px)); display: grid; grid-template-columns: 20px minmax(0, 1fr) 28px; gap: 9px; align-items: center; padding: 11px 10px 11px 13px; border: 1px solid var(--border-color); border-left: 4px solid var(--success-color); border-radius: 7px; background: var(--surface-solid); box-shadow: 0 9px 24px rgba(0, 0, 0, 0.2); font-size: 13px; }
.admin-toast { position: fixed; top: 86px; right: 18px; z-index: 3000; max-width: min(420px, calc(100vw - 36px)); display: grid; grid-template-columns: 20px minmax(0, 1fr) 28px; gap: 9px; align-items: center; overflow: hidden; padding: 11px 10px 11px 13px; border: 1px solid var(--border-color); border-left: 4px solid var(--success-color); border-radius: 7px; background: var(--surface-solid); box-shadow: 0 9px 24px rgba(0, 0, 0, 0.2); font-size: 13px; }
.admin-toast--error { border-left-color: var(--danger-color); }
.admin-toast--warning { border-left-color: var(--warning-color); }
.admin-toast > i { color: var(--success-color); }
.admin-toast--error > i { color: var(--danger-color); }
.admin-toast--warning > i { color: var(--warning-color); }
.admin-toast button { width: 28px; height: 28px; border: 0; border-radius: 5px; color: var(--text-muted); background: transparent; cursor: pointer; }
.toast-progress { position: absolute; right: 0; bottom: 0; left: 0; height: 2px; background: currentColor; transform-origin: left; animation: toast-progress 3.2s linear both; }
.toast-enter-active,
.toast-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
.toast-leave-active { transition: opacity var(--motion-base) var(--motion-ease), transform var(--motion-base) var(--motion-ease); }
.toast-enter-from,
.toast-leave-to { opacity: 0; transform: translateY(-8px); }
.toast-leave-to { opacity: 0; transform: translateX(14px) translateY(-4px) scale(0.98); }
.modal-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; }
.modal-form label,
.selector-field,
@@ -433,10 +458,32 @@ onUnmounted(() => { window.clearTimeout(toastTimer); configChannel?.close() })
.confirm-copy { margin: 0; color: var(--text-muted); font-size: 13px; }
.drawer-backdrop { display: none; }
.admin-view-enter-active,
.admin-view-leave-active { transition: opacity var(--motion-base) var(--motion-ease), transform var(--motion-base) var(--motion-ease); }
.admin-view-enter-from { opacity: 0; transform: translateY(12px); }
.admin-view-leave-to { opacity: 0; transform: translateY(-7px); }
.topbar-current-enter-active,
.topbar-current-leave-active,
.topbar-action-enter-active,
.topbar-action-leave-active { transition: opacity var(--motion-fast) var(--motion-ease-standard), transform var(--motion-fast) var(--motion-ease); }
.topbar-current-enter-from { opacity: 0; transform: translateX(9px); }
.topbar-current-leave-to { opacity: 0; transform: translateX(-7px); }
.topbar-action-enter-from,
.topbar-action-leave-to { opacity: 0; transform: translateY(-6px); }
@keyframes admin-sidebar-in { from { opacity: 0; transform: translateX(-18px); } }
@keyframes admin-topbar-in { from { opacity: 0; transform: translateY(-12px); } }
@keyframes admin-item-in { from { opacity: 0; transform: translateY(8px); } }
@keyframes toast-progress { to { transform: scaleX(0); } }
@media (max-width: 900px) {
.admin-sidebar { width: 240px; transform: translateX(-100%); transition: transform 0.2s ease; }
.admin-sidebar { width: 240px; animation: none; transform: translateX(-100%); transition: transform var(--motion-base) var(--motion-ease), box-shadow var(--motion-base) var(--motion-ease); }
.admin-sidebar.open { transform: translateX(0); }
.drawer-backdrop { position: fixed; inset: 0; z-index: 90; display: block; border: 0; background: rgba(0, 0, 0, 0.48); }
.drawer-backdrop-enter-active,
.drawer-backdrop-leave-active { transition: opacity var(--motion-base) var(--motion-ease-standard); }
.drawer-backdrop-enter-from,
.drawer-backdrop-leave-to { opacity: 0; }
.admin-main { margin-left: 0; }
.menu-button { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-color); background: var(--surface-muted); cursor: pointer; }
.page-icon { display: none; }
+104 -23
View File
@@ -1,18 +1,20 @@
<template>
<div ref="dashboardElement" class="dashboard" :aria-busy="loading">
<Transition name="dashboard-alert">
<p v-if="errorMessage" class="dashboard-alert" role="status">
<i class="fas fa-circle-exclamation" aria-hidden="true"></i>{{ errorMessage }}
</p>
</Transition>
<section class="stats-grid" aria-label="访问概览">
<article v-for="stat in statCards" :key="stat.key" class="stat-card">
<article v-for="(stat, index) in statCards" :key="stat.key" class="stat-card dashboard-enter" :style="{ '--enter-delay': `${index * 50}ms` }">
<span class="stat-icon"><i :class="stat.icon" aria-hidden="true"></i></span>
<div><strong>{{ formatNumber(stats[stat.key]) }}</strong><span>{{ stat.label }}</span></div>
<div><strong>{{ formatNumber(displayedStats[stat.key]) }}</strong><span>{{ stat.label }}</span></div>
</article>
</section>
<section class="charts-grid">
<article class="dashboard-panel trend-panel">
<article class="dashboard-panel trend-panel dashboard-enter" style="--enter-delay: 190ms">
<header class="panel-header">
<div><h2>访问趋势</h2><p>按日期汇总的页面访问次数</p></div>
<select v-model="chartPeriod" aria-label="趋势统计周期" @change="loadCharts">
@@ -24,12 +26,12 @@
<div class="chart-canvas"><canvas ref="trendCanvas" aria-label="访问趋势图"></canvas></div>
</article>
<article class="dashboard-panel source-panel">
<article class="dashboard-panel source-panel dashboard-enter" style="--enter-delay: 220ms">
<header class="panel-header"><div><h2>访问来源</h2><p>访客进入站点的主要渠道</p></div></header>
<div class="source-content">
<div class="source-canvas"><canvas ref="sourceCanvas" aria-label="访问来源图"></canvas></div>
<ul class="source-legend">
<li v-for="(source, index) in sourceData" :key="source.label">
<li v-for="(source, index) in sourceData" :key="source.label" class="legend-enter" :style="{ '--enter-delay': `${240 + index * 20}ms` }">
<i :style="{ background: chartColors[index % chartColors.length] }"></i>
<span>{{ source.label }}</span><strong>{{ source.value }}%</strong>
</li>
@@ -39,24 +41,24 @@
</section>
<section class="activity-grid">
<article class="dashboard-panel login-panel">
<article class="dashboard-panel login-panel dashboard-enter" style="--enter-delay: 260ms">
<header class="panel-header">
<div><h2>最近登录</h2><p>最近五次后台登录记录</p></div>
<button type="button" class="icon-action" title="刷新登录记录" aria-label="刷新登录记录" @click="loadLoginHistory">
<i class="fas fa-rotate" :class="{ 'fa-spin': loginLoading }" aria-hidden="true"></i>
</button>
</header>
<div class="login-list">
<TransitionGroup tag="div" class="login-list" name="activity-row">
<div v-for="(history, index) in loginHistory" :key="`${history.loginTime}-${index}`" class="login-row">
<i :class="history.success ? 'fas fa-circle-check success' : 'fas fa-circle-xmark danger'" aria-hidden="true"></i>
<div><strong>{{ history.ip || '未知 IP' }}</strong><span>{{ history.location || history.username || '未知位置' }}</span></div>
<time>{{ history.loginTime }}</time>
</div>
<div v-if="!loginHistory.length" class="panel-empty"><i class="fas fa-clock-rotate-left"></i><span>暂无登录记录</span></div>
</div>
<div v-if="!loginHistory.length" key="empty" class="panel-empty"><i class="fas fa-clock-rotate-left"></i><span>暂无登录记录</span></div>
</TransitionGroup>
</article>
<article class="dashboard-panel logs-panel">
<article class="dashboard-panel logs-panel dashboard-enter" style="--enter-delay: 300ms">
<header class="panel-header">
<div><h2>后台日志</h2><p>服务端最近输出的运行记录</p></div>
<div class="panel-actions">
@@ -83,6 +85,7 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { adminAPI } from '../api'
import { useMotionPreference } from '../composables/useMotionPreference'
const statCards = [
{ key: 'totalViews', label: '总访问量', icon: 'fas fa-eye' },
@@ -92,6 +95,7 @@ const statCards = [
]
const chartColors = ['#ffcc00', '#4f8bc9', '#4c9a61', '#d07842', '#8b6cb1']
const stats = ref({ totalViews: 0, uniqueVisitors: 0, todayViews: 0, totalSites: 0 })
const displayedStats = ref({ totalViews: 0, uniqueVisitors: 0, todayViews: 0, totalSites: 0 })
const chartPeriod = ref('7')
const trendData = ref([])
const sourceData = ref([])
@@ -107,13 +111,38 @@ const dashboardElement = ref(null)
const trendCanvas = ref(null)
const sourceCanvas = ref(null)
const logsElement = ref(null)
const { prefersReducedMotion } = useMotionPreference()
let refreshTimer = null
let resizeObserver = null
let themeObserver = null
let statsAnimationFrame = null
let chartAnimationFrame = null
const formatNumber = (value) => new Intl.NumberFormat('zh-CN').format(Number(value) || 0)
const styles = computed(() => getComputedStyle(document.documentElement))
const cssColor = (name, fallback) => styles.value.getPropertyValue(name).trim() || fallback
const easeOut = (progress) => 1 - Math.pow(1 - progress, 3)
const animateStats = (nextStats) => {
window.cancelAnimationFrame(statsAnimationFrame)
const target = { ...displayedStats.value, ...nextStats }
if (prefersReducedMotion.value) {
displayedStats.value = target
return
}
const start = { ...displayedStats.value }
const startedAt = performance.now()
const tick = (time) => {
const progress = Math.min((time - startedAt) / 620, 1)
const eased = easeOut(progress)
displayedStats.value = Object.fromEntries(Object.keys(target).map((key) => [
key,
Math.round((Number(start[key]) || 0) + ((Number(target[key]) || 0) - (Number(start[key]) || 0)) * eased),
]))
if (progress < 1) statsAnimationFrame = window.requestAnimationFrame(tick)
}
statsAnimationFrame = window.requestAnimationFrame(tick)
}
const canvasContext = (canvas) => {
if (!canvas) return null
@@ -136,7 +165,7 @@ const drawEmpty = ({ context, width, height }) => {
context.fillText('暂无数据', width / 2, height / 2)
}
const drawTrend = () => {
const drawTrend = (progress = 1) => {
const surface = canvasContext(trendCanvas.value)
if (!surface) return
const { context, width, height } = surface
@@ -165,15 +194,32 @@ const drawTrend = () => {
y: padding.top + chartHeight - (Number(point.value) || 0) / max * chartHeight,
label: point.label,
}))
const segmentProgress = Math.max(0, Math.min(progress, 1)) * Math.max(points.length - 1, 1)
const completeSegments = Math.floor(segmentProgress)
const partialProgress = segmentProgress - completeSegments
const visiblePoints = points.slice(0, Math.min(completeSegments + 1, points.length))
if (points.length > 1 && completeSegments < points.length - 1) {
const from = points[completeSegments]
const to = points[completeSegments + 1]
visiblePoints.push({
x: from.x + (to.x - from.x) * partialProgress,
y: from.y + (to.y - from.y) * partialProgress,
label: to.label,
})
}
const fill = context.createLinearGradient(0, padding.top, 0, height - padding.bottom)
fill.addColorStop(0, 'rgba(255, 204, 0, 0.24)')
fill.addColorStop(1, 'rgba(255, 204, 0, 0.01)')
context.beginPath(); context.moveTo(points[0].x, height - padding.bottom)
points.forEach((point) => context.lineTo(point.x, point.y))
context.lineTo(points.at(-1).x, height - padding.bottom); context.closePath(); context.fillStyle = fill; context.fill()
context.beginPath(); points.forEach((point, index) => index ? context.lineTo(point.x, point.y) : context.moveTo(point.x, point.y))
context.beginPath(); context.moveTo(visiblePoints[0].x, height - padding.bottom)
visiblePoints.forEach((point) => context.lineTo(point.x, point.y))
context.lineTo(visiblePoints.at(-1).x, height - padding.bottom); context.closePath(); context.fillStyle = fill; context.fill()
context.beginPath(); visiblePoints.forEach((point, index) => index ? context.lineTo(point.x, point.y) : context.moveTo(point.x, point.y))
context.strokeStyle = accent; context.lineWidth = 2.5; context.lineJoin = 'round'; context.stroke()
visiblePoints.slice(0, -1).forEach((point) => {
context.beginPath(); context.arc(point.x, point.y, 2.8, 0, Math.PI * 2); context.fillStyle = accent; context.fill()
})
const labelStep = Math.max(1, Math.ceil(trendData.value.length / Math.max(Math.floor(chartWidth / 58), 1)))
context.fillStyle = textColor; context.textAlign = 'center'
points.forEach((point, index) => {
@@ -181,7 +227,7 @@ const drawTrend = () => {
})
}
const drawSources = () => {
const drawSources = (progress = 1) => {
const surface = canvasContext(sourceCanvas.value)
if (!surface) return
const { context, width, height } = surface
@@ -190,20 +236,37 @@ const drawSources = () => {
const centerY = height / 2
const radius = Math.max(Math.min(width, height) / 2 - 20, 40)
const total = sourceData.value.reduce((sum, item) => sum + (Number(item.value) || 0), 0) || 1
const animatedEnd = -Math.PI / 2 + Math.PI * 2 * Math.max(0, Math.min(progress, 1))
let angle = -Math.PI / 2
sourceData.value.forEach((item, index) => {
const nextAngle = angle + (Number(item.value) || 0) / total * Math.PI * 2
context.beginPath(); context.arc(centerX, centerY, radius, angle, nextAngle); context.arc(centerX, centerY, radius * 0.62, nextAngle, angle, true); context.closePath()
context.fillStyle = chartColors[index % chartColors.length]; context.fill(); angle = nextAngle
const visibleEnd = Math.min(nextAngle, animatedEnd)
if (visibleEnd > angle) {
context.beginPath(); context.arc(centerX, centerY, radius, angle, visibleEnd); context.arc(centerX, centerY, radius * 0.62, visibleEnd, angle, true); context.closePath()
context.fillStyle = chartColors[index % chartColors.length]; context.fill()
}
angle = nextAngle
})
context.fillStyle = cssColor('--text-color', '#333'); context.textAlign = 'center'; context.font = '700 20px sans-serif'; context.fillText(`${Math.round(total)}%`, centerX, centerY + 7)
context.fillStyle = cssColor('--text-color', '#333'); context.textAlign = 'center'; context.font = '700 20px sans-serif'; context.fillText(`${Math.round(total * Math.max(0, Math.min(progress, 1)))}%`, centerX, centerY + 7)
}
const drawCharts = () => { drawTrend(); drawSources() }
const drawCharts = (progress = 1) => { drawTrend(progress); drawSources(progress) }
const animateCharts = () => {
window.cancelAnimationFrame(chartAnimationFrame)
if (prefersReducedMotion.value) return drawCharts(1)
const startedAt = performance.now()
const tick = (time) => {
const progress = Math.min((time - startedAt) / 640, 1)
drawCharts(easeOut(progress))
if (progress < 1) chartAnimationFrame = window.requestAnimationFrame(tick)
}
chartAnimationFrame = window.requestAnimationFrame(tick)
}
const loadStats = async () => {
const { data } = await adminAPI.getStats()
stats.value = { ...stats.value, ...data }
animateStats(stats.value)
}
const loadCharts = async () => {
@@ -211,7 +274,7 @@ const loadCharts = async () => {
const { data } = await adminAPI.getChartData(chartPeriod.value)
trendData.value = data.trend || []
sourceData.value = data.sources || []
await nextTick(); drawCharts()
await nextTick(); animateCharts()
} catch {
trendData.value = []; sourceData.value = []; drawCharts()
}
@@ -266,6 +329,8 @@ onMounted(async () => {
onUnmounted(() => {
window.clearInterval(refreshTimer)
window.cancelAnimationFrame(statsAnimationFrame)
window.cancelAnimationFrame(chartAnimationFrame)
resizeObserver?.disconnect()
themeObserver?.disconnect()
})
@@ -275,14 +340,16 @@ onUnmounted(() => {
.dashboard { display: flex; flex-direction: column; gap: 20px; }
.dashboard-alert { display: flex; align-items: center; gap: 8px; margin: 0; padding: 10px 12px; border-left: 3px solid var(--warning-color); color: var(--warning-color); background: var(--surface-muted); font-size: 13px; }
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.stat-card { min-width: 0; display: flex; align-items: center; gap: 13px; padding: 17px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--surface-color); }
.stat-card { min-width: 0; display: flex; align-items: center; gap: 13px; padding: 17px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--surface-color); transition: transform var(--motion-base) var(--motion-ease), border-color var(--motion-fast) var(--motion-ease-standard), box-shadow var(--motion-base) var(--motion-ease); }
.stat-card:hover { transform: translateY(-3px); border-color: rgba(var(--hover-link-color-rgb), 0.7); box-shadow: 0 7px 18px var(--shadow-color); }
.stat-icon { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 7px; color: #302800; background: var(--hover-link-color); }
.stat-card div { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.stat-card strong { overflow: hidden; font-size: 24px; line-height: 1.1; text-overflow: ellipsis; }
.stat-card span { color: var(--text-muted); font-size: 12px; }
.charts-grid { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(300px, 1fr); gap: 16px; }
.activity-grid { display: grid; grid-template-columns: minmax(300px, 0.8fr) minmax(0, 1.4fr); gap: 16px; }
.dashboard-panel { min-width: 0; overflow: hidden; border: 1px solid var(--border-color); border-radius: 8px; background: var(--surface-color); }
.dashboard-panel { min-width: 0; overflow: hidden; border: 1px solid var(--border-color); border-radius: 8px; background: var(--surface-color); transition: border-color var(--motion-fast) var(--motion-ease-standard), box-shadow var(--motion-base) var(--motion-ease); }
.dashboard-panel:hover { border-color: rgba(var(--hover-link-color-rgb), 0.55); box-shadow: 0 5px 16px rgba(0, 0, 0, 0.08); }
.panel-header { min-height: 66px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 16px; border-bottom: 1px solid var(--border-color); }
.panel-header h2 { margin: 0 0 3px; font-size: 15px; letter-spacing: 0; }
.panel-header p { margin: 0; color: var(--text-muted); font-size: 11px; }
@@ -320,6 +387,20 @@ onUnmounted(() => {
.panel-empty i { color: var(--hover-link-color); font-size: 20px; }
.logs-output .panel-empty { color: #92928d; }
.dashboard-enter,
.legend-enter { animation: dashboard-item-in var(--motion-emphasis) var(--motion-ease) var(--enter-delay, 0ms) both; }
.dashboard-alert-enter-active,
.dashboard-alert-leave-active,
.activity-row-enter-active,
.activity-row-leave-active,
.activity-row-move { transition: opacity var(--motion-base) var(--motion-ease), transform var(--motion-base) var(--motion-ease); }
.dashboard-alert-enter-from,
.dashboard-alert-leave-to { opacity: 0; transform: translateY(-8px); }
.activity-row-enter-from,
.activity-row-leave-to { opacity: 0; transform: translateX(-10px); }
@keyframes dashboard-item-in { from { opacity: 0; transform: translateY(14px) scale(0.99); } }
@media (max-width: 1200px) {
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.charts-grid,
+52 -10
View File
@@ -1,19 +1,38 @@
<template>
<main class="home-view" :aria-busy="loading">
<section class="identity" aria-labelledby="home-title">
<button class="profile-button" type="button" aria-label="查看关于信息" @click="showAbout = true" v-motion-pop>
<button
class="profile-button"
type="button"
aria-label="查看关于信息"
@click="showAbout = true"
v-motion
:initial="{ opacity: 0, y: 18, scale: 0.72 }"
:enter="{ opacity: 1, y: 0, scale: 1, transition: { duration: prefersReducedMotion ? 0 : 360, delay: prefersReducedMotion ? 0 : 30 } }"
>
<img v-if="profileImage && !profileImageFailed" :src="profileImage" :alt="`${userName} 的头像`" @error="profileImageFailed = true" />
<span v-else class="profile-fallback"><i class="fas fa-user" aria-hidden="true"></i></span>
<span class="status-ball"><span>在线</span></span>
</button>
<div class="user-name" v-motion-slide-left>
<div
class="user-name"
v-motion
:initial="{ opacity: 0, x: 28 }"
:enter="{ opacity: 1, x: 0, transition: { duration: prefersReducedMotion ? 0 : 360, delay: prefersReducedMotion ? 0 : 90 } }"
>
<h1 id="home-title">Hi,</h1>
<h1>I'm <span class="name-style">{{ userName }}</span></h1>
</div>
</section>
<div class="description" aria-live="polite">
<div
class="description"
aria-live="polite"
v-motion
:initial="{ opacity: 0, y: 12 }"
:enter="{ opacity: 1, y: 0, transition: { duration: prefersReducedMotion ? 0 : 300, delay: prefersReducedMotion ? 0 : 150 } }"
>
<p v-if="loading">正在加载主页...</p>
<p v-else ref="descriptionElement"></p>
</div>
@@ -23,7 +42,13 @@
当前展示本地备用内容
</p>
<nav class="contact-section" aria-label="联系方式" v-motion-pop>
<nav
class="contact-section"
aria-label="联系方式"
v-motion
:initial="{ opacity: 0, y: 14, scale: 0.96 }"
:enter="{ opacity: 1, y: 0, scale: 1, transition: { duration: prefersReducedMotion ? 0 : 300, delay: prefersReducedMotion ? 0 : 210 } }"
>
<template v-for="contact in contacts" :key="contact.id || contact.type">
<a
v-if="contact.url"
@@ -49,7 +74,7 @@
<span class="tooltip">{{ contact.type }}</span>
</button>
</template>
<button type="button" class="contact-item" :aria-label="themeLabel" @click="toggleTheme">
<button type="button" class="contact-item" :aria-label="themeLabel" @click="toggleTheme($event)">
<i :class="themeIcon" aria-hidden="true"></i>
<span class="tooltip">{{ isDarkMode ? '浅色' : '深色' }}</span>
</button>
@@ -80,6 +105,7 @@ import Typed from 'typed.js'
import api, { getContacts, getSiteConfig } from '../api'
import fallbackContacts from '../config/links.json'
import { useTheme } from '../composables/useTheme'
import { useMotionPreference } from '../composables/useMotionPreference'
import AboutPage from './AboutPage.vue'
import VisitTimer from './VisitTimer.vue'
import Website from './Website.vue'
@@ -105,6 +131,7 @@ const showAbout = ref(false)
const showQR = ref(false)
const qrCodeSrc = ref('')
const { isDarkMode, themeIcon, themeLabel, toggleTheme } = useTheme()
const { prefersReducedMotion } = useMotionPreference()
let typedInstance = null
let configChannel = null
@@ -224,8 +251,12 @@ onUnmounted(() => {
background: var(--surface-muted);
box-shadow: 0 2px 8px var(--shadow-color);
cursor: pointer;
transition: border-color var(--motion-base) var(--motion-ease-standard), box-shadow var(--motion-base) var(--motion-ease), transform var(--motion-fast) var(--motion-ease);
}
.profile-button:hover { border-color: var(--hover-link-color); box-shadow: 0 8px 24px var(--shadow-color); transform: translateY(-3px); }
.profile-button:active { transform: translateY(-1px) scale(0.98); }
.profile-button img,
.profile-fallback {
width: 150px;
@@ -233,7 +264,8 @@ onUnmounted(() => {
border-radius: 50%;
}
.profile-button img { object-fit: cover; }
.profile-button img { object-fit: cover; animation: profile-image-in var(--motion-emphasis) var(--motion-ease) both; transition: transform var(--motion-emphasis) var(--motion-ease); }
.profile-button:hover img { transform: scale(1.025); }
.profile-fallback { display: grid; place-items: center; font-size: 52px; background: var(--surface-color); }
.status-ball {
@@ -253,7 +285,7 @@ onUnmounted(() => {
transition: width 0.2s ease;
}
.status-ball span { opacity: 0; font-size: 12px; white-space: nowrap; }
.status-ball span { opacity: 0; font-size: 12px; white-space: nowrap; transition: opacity var(--motion-fast) var(--motion-ease-standard); }
.profile-button:hover .status-ball { width: 56px; }
.profile-button:hover .status-ball span { opacity: 1; }
@@ -316,7 +348,8 @@ onUnmounted(() => {
cursor: pointer;
transition: color 0.2s ease, transform 0.2s ease;
}
.contact-item:hover { color: var(--hover-color, var(--hover-link-color)); transform: translateY(-3px); }
.contact-item:hover { color: var(--hover-color, var(--hover-link-color)); transform: translateY(-5px) rotate(8deg); }
.contact-item:active { transform: translateY(-2px) rotate(3deg) scale(0.94); }
.tooltip {
position: absolute;
left: 50%;
@@ -338,11 +371,20 @@ onUnmounted(() => {
.overlay { position: fixed; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(0, 0, 0, 0.62); z-index: 1000; }
.modal-content { max-width: 100%; }
.qr-image { width: min(320px, 82vw); aspect-ratio: 1; object-fit: contain; padding: 18px; border-radius: 8px; background: white; box-shadow: 0 12px 34px rgba(0, 0, 0, 0.28); }
.qr-image { width: min(320px, 82vw); aspect-ratio: 1; object-fit: contain; padding: 18px; border-radius: 8px; background: white; box-shadow: 0 12px 34px rgba(0, 0, 0, 0.28); transition: transform var(--motion-emphasis) var(--motion-ease), box-shadow var(--motion-emphasis) var(--motion-ease); }
.qr-image:hover { transform: translateY(-4px) scale(1.025); box-shadow: 0 18px 42px rgba(0, 0, 0, 0.3); }
.fade-enter-active,
.fade-leave-active { transition: opacity 0.2s ease; }
.fade-leave-active { transition: opacity var(--motion-base) var(--motion-ease); }
.fade-enter-active .modal-content,
.fade-leave-active .modal-content { transition: opacity var(--motion-base) var(--motion-ease), transform var(--motion-emphasis) var(--motion-ease); }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
.fade-enter-from .modal-content,
.fade-leave-to .modal-content { opacity: 0; transform: translateY(24px) scale(0.9); }
@keyframes profile-image-in {
from { opacity: 0; transform: scale(0.9); }
}
@media (max-width: 768px) {
.home-view { justify-content: flex-start; gap: 17px; padding-top: 28px; }
+18 -4
View File
@@ -25,8 +25,9 @@
</button>
</div>
<Transition name="icon-panel" mode="out-in">
<!-- 默认图标 -->
<div v-if="iconMode === 'default' && defaultIconPath" class="icon-content">
<div v-if="iconMode === 'default' && defaultIconPath" key="default" class="icon-content">
<div class="default-icon-preview">
<img :src="defaultIconPath" alt="默认图标" class="icon-preview" />
<p class="icon-hint">使用默认本地图标{{ defaultIconPath }}</p>
@@ -35,7 +36,7 @@
</div>
<!-- 上传图标 -->
<div v-if="iconMode === 'upload'" class="icon-content">
<div v-else-if="iconMode === 'upload'" key="upload" class="icon-content">
<div class="upload-area">
<input
type="file"
@@ -55,12 +56,12 @@
<button type="button" @click="$refs.fileInput.click()" class="upload-btn">
{{ uploadedIconUrl ? '重新选择' : '选择文件' }}
</button>
<div v-if="uploading" class="upload-status">上传中...</div>
<Transition name="upload-status"><div v-if="uploading" class="upload-status">上传中...</div></Transition>
</div>
</div>
<!-- URL图标 -->
<div v-if="iconMode === 'url'" class="icon-content">
<div v-else key="url" class="icon-content">
<div class="url-input-group">
<label>图标URL</label>
<input
@@ -88,6 +89,7 @@
{{ urlValidating ? '验证中...' : '使用URL图标' }}
</button>
</div>
</Transition>
<!-- 当前选择的图标预览 -->
<div v-if="currentIcon" class="current-icon">
@@ -378,6 +380,8 @@ onMounted(() => {
background: var(--accent-soft);
}
.tab-btn:hover:not(.active) { background: var(--surface-color); }
.icon-content {
min-height: 170px;
}
@@ -444,6 +448,7 @@ onMounted(() => {
.upload-btn:hover,
.select-btn:hover {
background: var(--accent-strong);
transform: translateY(-1px);
}
.select-btn:disabled {
@@ -522,4 +527,13 @@ onMounted(() => {
color: var(--text-color);
margin-bottom: 10px;
}
.icon-panel-enter-active,
.icon-panel-leave-active { transition: opacity var(--motion-base) var(--motion-ease), transform var(--motion-base) var(--motion-ease); }
.icon-panel-enter-from { opacity: 0; transform: translateX(9px); }
.icon-panel-leave-to { opacity: 0; transform: translateX(-7px); }
.upload-status-enter-active,
.upload-status-leave-active { transition: opacity var(--motion-fast) var(--motion-ease-standard), transform var(--motion-fast) var(--motion-ease); }
.upload-status-enter-from,
.upload-status-leave-to { opacity: 0; transform: translateY(-4px); }
</style>
+42 -7
View File
@@ -6,12 +6,19 @@
<router-link to="/" class="icon-button" title="返回主页" aria-label="返回主页">
<i class="fas fa-house" aria-hidden="true"></i>
</router-link>
<button type="button" class="icon-button" :title="themeLabel" :aria-label="themeLabel" @click="toggleTheme">
<button type="button" class="icon-button" :title="themeLabel" :aria-label="themeLabel" @click="toggleTheme($event)">
<i :class="themeIcon" aria-hidden="true"></i>
</button>
</div>
<section class="login-panel" aria-labelledby="login-title">
<section
class="login-panel"
:class="{ 'login-panel--success': loginSuccess }"
aria-labelledby="login-title"
v-motion
:initial="{ opacity: 0, y: 22, scale: 0.96 }"
:enter="{ opacity: 1, y: 0, scale: 1, transition: { duration: prefersReducedMotion ? 0 : 360, delay: prefersReducedMotion ? 0 : 80 } }"
>
<header class="login-header">
<span class="site-mark">
<img v-if="siteIcon && !iconFailed" :src="siteIcon" alt="" @error="iconFailed = true" />
@@ -52,10 +59,12 @@
</button>
</div>
<Transition name="login-error">
<p v-if="error" class="login-error" role="alert">
<i class="fas fa-circle-exclamation" aria-hidden="true"></i>
{{ error }}
</p>
</Transition>
<button type="submit" class="login-submit" :disabled="loading">
<i :class="loading ? 'fas fa-spinner fa-spin' : 'fas fa-arrow-right-to-bracket'" aria-hidden="true"></i>
@@ -73,6 +82,7 @@ import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getSiteConfig, login } from '../api'
import { useTheme } from '../composables/useTheme'
import { useMotionPreference } from '../composables/useMotionPreference'
const route = useRoute()
const router = useRouter()
@@ -81,10 +91,12 @@ const password = ref('')
const showPassword = ref(false)
const loading = ref(false)
const error = ref('')
const loginSuccess = ref(false)
const siteName = ref('Home-Vue')
const siteIcon = ref('/favicon.ico')
const iconFailed = ref(false)
const { themeIcon, themeLabel, toggleTheme } = useTheme()
const { prefersReducedMotion } = useMotionPreference()
const handleLogin = async () => {
if (loading.value) return
@@ -93,9 +105,11 @@ const handleLogin = async () => {
try {
const { data } = await login(username.value, password.value)
localStorage.setItem('token', data.token)
loginSuccess.value = true
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
? route.query.redirect
: '/admin'
if (!prefersReducedMotion.value) await new Promise((resolve) => window.setTimeout(resolve, 180))
await router.replace(redirect)
} catch (requestError) {
error.value = requestError.response?.data?.error || '登录失败,请检查用户名和密码'
@@ -133,6 +147,7 @@ onMounted(async () => {
display: flex;
gap: 8px;
z-index: 2;
animation: login-tools-in var(--motion-emphasis) var(--motion-ease) 180ms both;
}
.icon-button,
@@ -152,7 +167,8 @@ onMounted(async () => {
backdrop-filter: blur(12px);
}
.icon-button:hover { color: var(--hover-link-color); border-color: var(--hover-link-color); }
.icon-button { transition: color var(--motion-fast) var(--motion-ease-standard), border-color var(--motion-fast) var(--motion-ease-standard), transform var(--motion-fast) var(--motion-ease); }
.icon-button:hover { color: var(--hover-link-color); border-color: var(--hover-link-color); transform: translateY(-2px); }
.login-panel {
position: relative;
@@ -165,6 +181,8 @@ onMounted(async () => {
backdrop-filter: blur(18px);
}
.login-panel--success { border-color: var(--success-color); box-shadow: 0 18px 46px rgba(35, 134, 54, 0.18); }
.login-panel::before {
position: absolute;
top: 0;
@@ -175,14 +193,18 @@ onMounted(async () => {
content: '';
}
.login-header { display: flex; align-items: center; gap: 14px; margin-bottom: 26px; }
.login-header { display: flex; align-items: center; gap: 14px; margin-bottom: 26px; animation: login-item-in var(--motion-base) var(--motion-ease) 80ms both; }
.site-mark { width: 48px; height: 48px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--border-color); border-radius: 50%; background: var(--surface-muted); color: var(--hover-link-color); font-size: 20px; overflow: hidden; }
.site-mark img { width: 100%; height: 100%; object-fit: cover; }
.site-name { margin: 0 0 2px; color: var(--text-muted); font-size: 13px; }
.login-header h1 { margin: 0; font-size: 24px; letter-spacing: 0; }
.login-form { display: flex; flex-direction: column; }
.login-form label { margin: 0 0 7px; font-size: 13px; font-weight: 600; }
.login-form label { margin: 0 0 7px; font-size: 13px; font-weight: 600; animation: login-item-in var(--motion-base) var(--motion-ease) both; }
.login-form label:nth-of-type(1) { animation-delay: 120ms; }
.login-form label:nth-of-type(2) { animation-delay: 190ms; }
.input-control { position: relative; display: flex; align-items: center; margin-bottom: 18px; }
.input-control:nth-of-type(1) { animation: login-item-in var(--motion-base) var(--motion-ease) 150ms both; }
.input-control:nth-of-type(2) { animation: login-item-in var(--motion-base) var(--motion-ease) 220ms both; }
.input-control > i { position: absolute; left: 13px; color: var(--text-muted); pointer-events: none; }
.input-control input {
width: 100%;
@@ -197,10 +219,23 @@ onMounted(async () => {
.password-toggle { position: absolute; right: 5px; width: 34px; height: 34px; border: 0; border-radius: 5px; background: transparent; }
.password-toggle:hover { color: var(--hover-link-color); }
.login-error { display: flex; align-items: flex-start; gap: 8px; margin: -4px 0 16px; padding: 10px 12px; border-left: 3px solid var(--danger-color); color: var(--danger-color); background: rgba(201, 54, 43, 0.08); font-size: 13px; }
.login-submit { height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid #d1a700; border-radius: 7px; color: #272100; background: var(--hover-link-color); font-weight: 700; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease; }
.login-submit { height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid #d1a700; border-radius: 7px; color: #272100; background: var(--hover-link-color); font-weight: 700; cursor: pointer; animation: login-item-in var(--motion-base) var(--motion-ease) 260ms both; transition: transform var(--motion-fast) var(--motion-ease), box-shadow var(--motion-base) var(--motion-ease), background-color var(--motion-fast) var(--motion-ease-standard); }
.login-submit:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 5px 14px rgba(var(--hover-link-color-rgb), 0.28); }
.login-submit:disabled { opacity: 0.64; cursor: wait; }
.login-footnote { margin: 20px 0 0; color: var(--text-muted); font-size: 12px; text-align: center; }
.login-footnote { margin: 20px 0 0; color: var(--text-muted); font-size: 12px; text-align: center; animation: login-item-in var(--motion-base) var(--motion-ease) 300ms both; }
.login-error-enter-active { animation: login-error-in var(--motion-base) var(--motion-ease); }
.login-error-leave-active { transition: opacity var(--motion-fast) var(--motion-ease-standard), transform var(--motion-fast) var(--motion-ease); }
.login-error-leave-to { opacity: 0; transform: translateY(-5px); }
@keyframes login-tools-in { from { opacity: 0; transform: translateY(-10px); } }
@keyframes login-item-in { from { opacity: 0; transform: translateY(10px); } }
@keyframes login-error-in {
0% { opacity: 0; transform: translateX(-5px); }
40% { transform: translateX(4px); }
70% { transform: translateX(-2px); }
100% { opacity: 1; transform: translateX(0); }
}
@media (max-width: 480px) {
.login-page { padding-inline: 14px; }
+4 -2
View File
@@ -3,7 +3,7 @@
<div class="visit-timer"
v-motion
:initial="{ opacity: 0, y: 50, x: '-50%', scale: 0.5 }"
:enter="{ opacity: 1, y: 0, x: '-50%', scale: 1, transition: { duration: 300 } }"
:enter="{ opacity: 1, y: 0, x: '-50%', scale: 1, transition: { duration: prefersReducedMotion ? 0 : 360, delay: prefersReducedMotion ? 0 : 300 } }"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="toggleCalendar">
@@ -46,6 +46,7 @@
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useMotionPreference } from '../composables/useMotionPreference';
// === ===
const useTimeManager = () => {
@@ -95,6 +96,7 @@ const useTimeManager = () => {
// === ===
const showCalendar = ref(false);
const isCalendarPinned = ref(false);
const { prefersReducedMotion } = useMotionPreference();
const { timeUnits, dateTime } = useTimeManager();
@@ -136,7 +138,7 @@ const toggleCalendar = () => {
bottom: 50px;
padding: 8px 15px;
border-radius: 20px;
transition: all 0.3s ease;
transition: transform var(--motion-base) var(--motion-ease), box-shadow var(--motion-base) var(--motion-ease), border-color var(--motion-fast) var(--motion-ease-standard);
cursor: pointer;
&:hover {
+8 -4
View File
@@ -8,12 +8,13 @@
<div v-for="(siteChunk, index) in chunkedSites" :key="index" class="swiper-slide">
<div class="site-grid">
<a
v-for="site in siteChunk"
v-for="(site, siteIndex) in siteChunk"
:key="site.id || site.url"
:href="site.url"
target="_blank"
rel="noopener noreferrer"
class="site-box"
:style="{ '--site-index': siteIndex }"
>
<i :class="site.icon" aria-hidden="true"></i>
<span>{{ site.name }}</span>
@@ -85,15 +86,18 @@ onUnmounted(() => swiperInstance?.destroy(true, true))
border-radius: 8px;
background: var(--surface-muted);
}
.site-box { display: flex; align-items: center; justify-content: center; gap: 9px; padding: 18px; font-weight: 600; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; }
.site-box:hover { transform: translateY(-2px); border-color: var(--hover-link-color); box-shadow: 0 3px 10px var(--shadow-color); }
.site-box { display: flex; align-items: center; justify-content: center; gap: 9px; padding: 18px; font-weight: 600; animation: site-card-in var(--motion-emphasis) var(--motion-ease) both; animation-delay: calc(var(--site-index) * 50ms); transition: transform var(--motion-base) var(--motion-ease), box-shadow var(--motion-base) var(--motion-ease), border-color var(--motion-fast) var(--motion-ease-standard); }
.site-box:hover { transform: translateY(-4px); border-color: var(--hover-link-color); box-shadow: 0 7px 18px var(--shadow-color); }
.site-box:active { transform: translateY(-1px) scale(0.98); }
.site-box i { flex: 0 0 auto; font-size: var(--icon-size); }
.site-box span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.site-grid--loading { padding: 10px; }
.site-skeleton { animation: pulse 1.2s ease-in-out infinite alternate; }
@keyframes pulse { to { opacity: 0.45; } }
@keyframes site-card-in { from { opacity: 0; transform: translateY(14px) scale(0.97); } }
:deep(.swiper-pagination-bullet) { background: var(--text-muted); }
:deep(.swiper-pagination-bullet-active) { width: 20px; border-radius: 5px; background: var(--hover-link-color); }
:deep(.swiper-pagination-bullet) { transition: width var(--motion-base) var(--motion-ease), transform var(--motion-fast) var(--motion-ease); }
:deep(.swiper-pagination-bullet-active) { width: 20px; border-radius: 5px; background: var(--hover-link-color); transform: scaleY(1.08); }
@media (max-width: 600px) {
.sites { min-height: 160px; }
+19 -7
View File
@@ -8,7 +8,7 @@
<div v-if="items.length" class="desktop-table">
<table v-if="kind === 'sites'">
<thead><tr><th>名称</th><th>地址</th><th>图标</th><th>排序</th><th><span class="sr-only">操作</span></th></tr></thead>
<tbody>
<TransitionGroup tag="tbody" name="table-row">
<tr v-for="item in items" :key="item.id">
<td><span class="name-cell"><i :class="item.icon" aria-hidden="true"></i>{{ item.name }}</span></td>
<td><a :href="item.url" target="_blank" rel="noopener noreferrer" class="url-cell">{{ item.url }}<i class="fas fa-arrow-up-right-from-square"></i></a></td>
@@ -16,11 +16,11 @@
<td>{{ item.sortOrder }}</td>
<td class="row-actions"><ActionButtons :item="item" @edit="emit('edit', item)" @delete="emit('delete', item)" /></td>
</tr>
</tbody>
</TransitionGroup>
</table>
<table v-else>
<thead><tr><th>类型</th><th>图标</th><th>链接或二维码</th><th>悬停颜色</th><th>排序</th><th><span class="sr-only">操作</span></th></tr></thead>
<tbody>
<TransitionGroup tag="tbody" name="table-row">
<tr v-for="item in items" :key="item.id">
<td><strong>{{ item.type }}</strong></td>
<td><i :class="item.icon" :style="{ color: item.hoverColor }" aria-hidden="true"></i></td>
@@ -29,11 +29,11 @@
<td>{{ item.sortOrder }}</td>
<td class="row-actions"><ActionButtons :item="item" @edit="emit('edit', item)" @delete="emit('delete', item)" /></td>
</tr>
</tbody>
</TransitionGroup>
</table>
</div>
<div v-if="items.length" class="mobile-list">
<TransitionGroup v-if="items.length" tag="div" class="mobile-list" name="mobile-row">
<article v-for="item in items" :key="item.id" class="mobile-item">
<div class="mobile-icon"><i :class="item.icon" :style="kind === 'contacts' ? { color: item.hoverColor } : {}"></i></div>
<div class="mobile-content">
@@ -43,7 +43,7 @@
</div>
<ActionButtons :item="item" @edit="emit('edit', item)" @delete="emit('delete', item)" />
</article>
</div>
</TransitionGroup>
<div v-if="!items.length" class="empty-state">
<i :class="kind === 'sites' ? 'fas fa-link' : 'fas fa-address-book'" aria-hidden="true"></i>
@@ -83,6 +83,7 @@ th,
td { padding: 13px 15px; border-bottom: 1px solid var(--border-color); text-align: left; vertical-align: middle; font-size: 13px; }
th { color: var(--text-muted); background: var(--surface-muted); font-size: 12px; font-weight: 600; white-space: nowrap; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr { transition: background-color var(--motion-fast) var(--motion-ease-standard); }
tbody tr:hover { background: var(--surface-muted); }
.name-cell,
.color-cell,
@@ -102,6 +103,17 @@ code { padding: 3px 6px; border-radius: 4px; color: var(--text-muted); backgroun
:deep(.action-buttons button) { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 6px; color: var(--text-muted); background: transparent; cursor: pointer; }
:deep(.action-buttons button:hover) { border-color: var(--border-color); color: var(--text-color); background: var(--surface-solid); }
:deep(.action-buttons button.delete:hover) { border-color: var(--danger-color); color: var(--danger-color); }
.table-row-enter-active,
.table-row-leave-active,
.table-row-move,
.mobile-row-enter-active,
.mobile-row-leave-active,
.mobile-row-move { transition: opacity var(--motion-base) var(--motion-ease), transform var(--motion-base) var(--motion-ease); }
.table-row-enter-from,
.table-row-leave-to { opacity: 0; transform: translateX(10px); }
.mobile-row-enter-from,
.mobile-row-leave-to { opacity: 0; transform: translateY(10px) scale(0.98); }
.mobile-row-leave-active { position: absolute; width: 100%; }
.mobile-list { display: none; }
.empty-state { min-height: 280px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 30px; color: var(--text-muted); text-align: center; }
.empty-state > i { font-size: 28px; color: var(--hover-link-color); }
@@ -111,7 +123,7 @@ code { padding: 3px 6px; border-radius: 4px; color: var(--text-muted); backgroun
@media (max-width: 720px) {
.desktop-table { display: none; }
.mobile-list { display: block; }
.mobile-list { position: relative; display: block; }
.mobile-item { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 13px; border-bottom: 1px solid var(--border-color); }
.mobile-item:last-child { border-bottom: 0; }
.mobile-icon { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 7px; background: var(--surface-muted); }
+28 -3
View File
@@ -23,6 +23,8 @@
</div>
</header>
<Transition name="settings-panel" mode="out-in">
<div :key="activeSection" class="settings-section-content">
<div v-if="activeSection === '基础信息'" class="form-grid">
<label class="field"><span>站点名称</span><input v-model="config.siteName" type="text" placeholder="Home-Vue" /></label>
<label class="field"><span>站点 URL</span><input v-model="config.siteURL" type="url" placeholder="https://example.com" /></label>
@@ -58,6 +60,7 @@
</div>
<div v-else-if="activeSection === '轮换文本'" class="rotating-editor">
<TransitionGroup tag="div" class="rotating-list" name="config-row">
<div v-for="(text, index) in rotatingTexts" :key="index" class="rotating-row">
<span>{{ String(index + 1).padStart(2, '0') }}</span>
<input v-model="rotatingTexts[index]" type="text" :placeholder="`第 ${index + 1} 条文本`" />
@@ -65,6 +68,7 @@
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
</TransitionGroup>
<div class="inline-actions">
<button type="button" class="secondary-button" :disabled="rotatingTexts.length >= 8" @click="emit('addText')">
<i class="fas fa-plus" aria-hidden="true"></i>添加文本
@@ -79,6 +83,8 @@
<label class="field field--full"><span>Umami 统计脚本地址</span><input v-model="config.umamiScript" type="url" placeholder="https://analytics.example.com/script.js" /><small>留空时不加载统计脚本</small></label>
<label class="field field--full"><span>Umami 网站 ID</span><input v-model="config.umamiWebsiteId" type="text" placeholder="网站 UUID" /></label>
</div>
</div>
</Transition>
</section>
<aside class="live-preview" aria-label="主页即时预览">
@@ -86,8 +92,10 @@
<div class="preview-stage">
<div class="preview-identity">
<span class="preview-avatar">
<img v-if="config.profileImageURL && !previewImageFailed" :src="config.profileImageURL" alt="头像预览" @error="previewImageFailed = true" />
<i v-else class="fas fa-user" aria-hidden="true"></i>
<Transition name="preview-image" mode="out-in">
<img v-if="config.profileImageURL && !previewImageFailed" :key="config.profileImageURL" :src="config.profileImageURL" alt="头像预览" @error="previewImageFailed = true" />
<i v-else key="fallback" class="fas fa-user" aria-hidden="true"></i>
</Transition>
</span>
<div><strong>Hi,</strong><strong>I'm <mark>{{ config.userName || '用户' }}</mark></strong></div>
</div>
@@ -137,10 +145,11 @@ watch(() => props.config.profileImageURL, () => { previewImageFailed.value = fal
<style scoped>
.settings-shell { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 24px; }
.settings-nav { display: flex; flex-direction: column; gap: 4px; }
.settings-nav button { min-height: 42px; display: flex; align-items: center; gap: 10px; padding: 0 12px; border: 1px solid transparent; border-radius: 6px; color: var(--text-muted); background: transparent; text-align: left; cursor: pointer; }
.settings-nav button { min-height: 42px; display: flex; align-items: center; gap: 10px; padding: 0 12px; border: 1px solid transparent; border-radius: 6px; color: var(--text-muted); background: transparent; text-align: left; cursor: pointer; transition: color var(--motion-fast) var(--motion-ease-standard), background-color var(--motion-fast) var(--motion-ease-standard), border-color var(--motion-fast) var(--motion-ease-standard), transform var(--motion-fast) var(--motion-ease); }
.settings-nav button i { width: 18px; text-align: center; }
.settings-nav button:hover { color: var(--text-color); background: var(--surface-muted); }
.settings-nav button.active { border-color: var(--border-color); color: var(--text-color); background: var(--surface-color); box-shadow: inset 3px 0 var(--hover-link-color); }
.settings-nav button.active i { transform: scale(1.12) rotate(-5deg); }
.settings-workspace { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 24px; align-items: start; }
.settings-form { min-width: 0; }
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 24px; padding-bottom: 18px; border-bottom: 1px solid var(--border-color); }
@@ -166,6 +175,7 @@ watch(() => props.config.profileImageURL, () => { previewImageFailed.value = fal
.switch-field > span { display: flex; flex-direction: column; gap: 3px; }
.switch-field input { width: 42px; height: 22px; accent-color: var(--hover-link-color); }
.rotating-editor { display: flex; flex-direction: column; gap: 10px; }
.rotating-list { position: relative; display: flex; flex-direction: column; gap: 10px; }
.rotating-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 36px; gap: 8px; align-items: center; }
.rotating-row > span { color: var(--text-muted); font: 12px Consolas, monospace; }
.rotating-row input { height: 40px; padding: 0 11px; }
@@ -193,6 +203,21 @@ watch(() => props.config.profileImageURL, () => { previewImageFailed.value = fal
.preview-timer { padding: 7px 10px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--surface-muted); font-size: 11px; }
.live-preview > footer { padding: 10px 12px; color: var(--text-muted); font-size: 10px; text-align: center; }
.settings-panel-enter-active,
.settings-panel-leave-active { transition: opacity var(--motion-base) var(--motion-ease), transform var(--motion-base) var(--motion-ease); }
.settings-panel-enter-from { opacity: 0; transform: translateX(10px); }
.settings-panel-leave-to { opacity: 0; transform: translateX(-7px); }
.config-row-enter-active,
.config-row-leave-active,
.config-row-move { transition: opacity var(--motion-base) var(--motion-ease), transform var(--motion-base) var(--motion-ease); }
.config-row-enter-from,
.config-row-leave-to { opacity: 0; transform: translateY(-8px) scale(0.98); }
.config-row-leave-active { position: absolute; width: 100%; }
.preview-image-enter-active,
.preview-image-leave-active { transition: opacity var(--motion-base) var(--motion-ease), transform var(--motion-base) var(--motion-ease); }
.preview-image-enter-from { opacity: 0; transform: scale(0.85); }
.preview-image-leave-to { opacity: 0; transform: scale(1.08); }
@media (max-width: 1280px) {
.settings-workspace { grid-template-columns: minmax(0, 1fr); }
.live-preview { position: static; }
+5 -3
View File
@@ -76,15 +76,17 @@ onUnmounted(() => {
.modal-body { min-height: 0; padding: 20px; overflow-y: auto; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--border-color); background: var(--surface-muted); }
.modal-fade-enter-active,
.modal-fade-leave-active { transition: opacity 0.18s ease; }
.modal-fade-leave-active { transition: opacity var(--motion-base) var(--motion-ease-standard); }
.modal-fade-enter-active .admin-modal,
.modal-fade-leave-active .admin-modal { transition: transform 0.18s ease; }
.modal-fade-leave-active .admin-modal { transition: opacity var(--motion-base) var(--motion-ease), transform var(--motion-emphasis) var(--motion-ease); }
.modal-fade-enter-from,
.modal-fade-leave-to { opacity: 0; }
.modal-fade-enter-from .admin-modal,
.modal-fade-leave-to .admin-modal { transform: translateY(10px); }
.modal-fade-leave-to .admin-modal { opacity: 0; transform: translateY(18px) scale(0.96); }
@media (max-width: 640px) {
.modal-backdrop { place-items: end center; padding: 0; }
.admin-modal { width: 100%; max-height: 92vh; border-radius: 8px 8px 0 0; }
.modal-fade-enter-from .admin-modal,
.modal-fade-leave-to .admin-modal { transform: translateY(48px); }
}
</style>
+18
View File
@@ -0,0 +1,18 @@
import { readonly, ref } from 'vue'
const prefersReducedMotion = ref(false)
let initialized = false
const initializeMotionPreference = () => {
if (initialized || typeof window === 'undefined') return
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
const update = () => { prefersReducedMotion.value = media.matches }
update()
media.addEventListener?.('change', update)
initialized = true
}
export const useMotionPreference = () => {
initializeMotionPreference()
return { prefersReducedMotion: readonly(prefersReducedMotion) }
}
+14 -1
View File
@@ -22,10 +22,23 @@ export const initializeTheme = () => {
export const useTheme = () => {
initializeTheme()
const toggleTheme = () => {
const toggleTheme = (event) => {
const updateTheme = () => {
applyTheme(!isDarkMode.value)
localStorage.setItem('darkMode', String(isDarkMode.value))
}
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
if (!document.startViewTransition || reducedMotion) {
updateTheme()
return
}
const originX = Number.isFinite(event?.clientX) && event.clientX > 0 ? event.clientX : window.innerWidth / 2
const originY = Number.isFinite(event?.clientY) && event.clientY > 0 ? event.clientY : window.innerHeight / 2
document.documentElement.style.setProperty('--theme-origin-x', `${originX}px`)
document.documentElement.style.setProperty('--theme-origin-y', `${originY}px`)
document.startViewTransition(updateTheme)
}
return {
isDarkMode,
+73 -2
View File
File diff suppressed because one or more lines are too long