feat: enrich motion and page transitions
This commit is contained in:
+27
-21
@@ -1,25 +1,31 @@
|
||||
<template>
|
||||
<div v-if="isPublicPage" class="public-shell">
|
||||
<div class="background" aria-hidden="true"></div>
|
||||
<router-view />
|
||||
<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">
|
||||
{{ icpNumber }}
|
||||
</a>
|
||||
<a
|
||||
v-if="policeNumber"
|
||||
:href="`https://beian.mps.gov.cn/#/query/webSearch?police=${policeNumber}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="police-link"
|
||||
>
|
||||
<i class="fas fa-shield-alt" aria-hidden="true"></i>
|
||||
{{ policeNumber }}
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<router-view v-else />
|
||||
<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>
|
||||
<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">
|
||||
{{ icpNumber }}
|
||||
</a>
|
||||
<a
|
||||
v-if="policeNumber"
|
||||
:href="`https://beian.mps.gov.cn/#/query/webSearch?police=${policeNumber}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="police-link"
|
||||
>
|
||||
<i class="fas fa-shield-alt" aria-hidden="true"></i>
|
||||
{{ policeNumber }}
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<div v-else :key="viewRoute.name" class="route-shell">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</Transition>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
+80
-33
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<div class="admin-background" aria-hidden="true"></div>
|
||||
<button v-if="sidebarOpen" type="button" class="drawer-backdrop" aria-label="关闭导航" @click="sidebarOpen = false"></button>
|
||||
<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,33 +38,42 @@
|
||||
<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>
|
||||
<span class="page-icon"><i :class="currentTab.icon"></i></span>
|
||||
<div><h1>{{ activeTab }}</h1><p>{{ currentTab.description }}</p></div>
|
||||
<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">
|
||||
<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>
|
||||
<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 === '站点管理'" 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 === '仪表盘'" />
|
||||
<AdminConfig
|
||||
v-else-if="activeTab === '站点配置'"
|
||||
:config="siteConfig"
|
||||
:rotating-texts="rotatingTexts"
|
||||
:active-section="activeConfigSection"
|
||||
@update:active-section="activeConfigSection = $event"
|
||||
@add-text="addRotatingText"
|
||||
@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)" />
|
||||
<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"
|
||||
@update:active-section="activeConfigSection = $event"
|
||||
@add-text="addRotatingText"
|
||||
@remove-text="removeRotatingText"
|
||||
@save-texts="saveRotatingTexts"
|
||||
/>
|
||||
<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; }
|
||||
|
||||
+107
-26
@@ -1,18 +1,20 @@
|
||||
<template>
|
||||
<div ref="dashboardElement" class="dashboard" :aria-busy="loading">
|
||||
<p v-if="errorMessage" class="dashboard-alert" role="status">
|
||||
<i class="fas fa-circle-exclamation" aria-hidden="true"></i>{{ errorMessage }}
|
||||
</p>
|
||||
<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
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
+46
-11
@@ -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>
|
||||
|
||||
<p v-if="error" class="login-error" role="alert">
|
||||
<i class="fas fa-circle-exclamation" aria-hidden="true"></i>
|
||||
{{ error }}
|
||||
</p>
|
||||
<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; }
|
||||
|
||||
@@ -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">
|
||||
@@ -45,7 +45,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
import { useMotionPreference } from '../composables/useMotionPreference';
|
||||
|
||||
// === 合并计时和日期时间逻辑 ===
|
||||
const useTimeManager = () => {
|
||||
@@ -93,8 +94,9 @@ const useTimeManager = () => {
|
||||
};
|
||||
|
||||
// === 简化组件状态管理 ===
|
||||
const showCalendar = ref(false);
|
||||
const isCalendarPinned = ref(false);
|
||||
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 {
|
||||
@@ -267,4 +269,4 @@ const toggleCalendar = () => {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -22,9 +22,22 @@ export const initializeTheme = () => {
|
||||
export const useTheme = () => {
|
||||
initializeTheme()
|
||||
|
||||
const toggleTheme = () => {
|
||||
applyTheme(!isDarkMode.value)
|
||||
localStorage.setItem('darkMode', String(isDarkMode.value))
|
||||
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 {
|
||||
|
||||
+73
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user