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