登录页面
效果:
增加登录页面:
.vitepress\theme\views\admin\Login.vue
<template>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h2>管理员登录</h2>
</div>
<div class="login-form">
<div class="form-item">
<span class="iconfont icon-user"></span>
<input
type="text"
v-model="username"
placeholder="用户名"
@keyup.enter="handleLogin"
>
</div>
<div class="form-item">
<span class="iconfont icon-password"></span>
<input
:type="showPassword ? 'text' : 'password'"
v-model="password"
placeholder="密码"
@keyup.enter="handleLogin"
>
<span
class="iconfont"
:class="showPassword ? 'icon-eye-open' : 'icon-eye-close'"
@click="showPassword = !showPassword"
></span>
</div>
<button class="login-btn" @click="handleLogin">登录</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vitepress'
const router = useRouter()
const username = ref('')
const password = ref('')
const showPassword = ref(false)
const handleLogin = () => {
if (!username.value || !password.value) {
window.$message.warning('请输入用户名和密码')
return
}
window.$message.success('登录成功')
// router.go('/admin/dashboard')
}
</script>
<style lang="scss" scoped>
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - var(--header-height));
padding: 20px;
.login-box {
width: 100%;
max-width: 400px;
padding: 30px;
border-radius: 16px;
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.login-header {
text-align: center;
margin-bottom: 30px;
h2 {
font-size: 24px;
color: var(--main-font-color);
}
}
.login-form {
.form-item {
position: relative;
margin-bottom: 20px;
.iconfont {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
font-size: 18px;
color: var(--main-font-second-color);
}
input {
width: 100%;
height: 45px;
padding: 0 45px;
border: 1px solid var(--main-card-border);
border-radius: 8px;
background-color: var(--main-card-second-background);
color: var(--main-font-color);
transition: all 0.3s;
&:focus {
border-color: var(--main-color);
box-shadow: 0 0 0 2px var(--main-color-bg);
}
}
.icon-eye-open,
.icon-eye-close {
left: auto;
right: 15px;
cursor: pointer;
&:hover {
color: var(--main-color);
}
}
}
.login-btn {
width: 100%;
height: 45px;
border: none;
border-radius: 8px;
background-color: var(--main-color);
color: #fff;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
}
}
</style>
pages\admin[login.md](http://login.md/)
---
title: 管理员登录
aside: false
---
<script setup>
import Login from "@/views/admin/Login.vue"
</script>
<Login />
.vitepress\components.d.ts 这个配置在调试的时候会自动引入相应的组件
配置菜单导航栏
// 导航栏菜单
.vitepress\theme\assets\themeConfig.mjs
{
text: "管理员",
items: [
{ text: "管理员登录", link: "/pages/admin/login", icon: "admin" },
],
},
后台首页
效果图:
变动文件:
新增Dashboard.vue页面:
.vitepress\theme\views\admin\Dashboard.vue
<template>
<AdminLayout>
<div class="dashboard-container">
<div class="dashboard-header">
<h2>仪表盘</h2>
<div class="header-right">
<span class="welcome">欢迎回来,{{ username }}</span>
<button class="logout-btn" @click="handleLogout">
<span class="iconfont icon-logout"></span>
退出登录
</button>
</div>
</div>
<div class="dashboard-content">
<!-- 数据概览卡片 -->
<div class="stats-cards">
<div class="stat-card" v-for="stat in statistics" :key="stat.title">
<div class="stat-icon">
<span class="iconfont" :class="stat.icon"></span>
</div>
<div class="stat-info">
<div class="stat-title">{{ stat.title }}</div>
<div class="stat-value">{{ stat.value }}</div>
</div>
</div>
</div>
<!-- 最近活动 -->
<div class="recent-activities">
<h3>最近活动</h3>
<div class="activity-list">
<div class="activity-item" v-for="activity in activities" :key="activity.id">
<span class="activity-time">{{ activity.time }}</span>
<span class="activity-content">{{ activity.content }}</span>
</div>
</div>
</div>
</div>
</div>
</AdminLayout>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vitepress'
import AdminLayout from '@/components/admin/Layout.vue'
const router = useRouter()
// 统计数据
const statistics = ref([
{ title: '文章总数', value: '125', icon: 'icon-article' },
{ title: '用户数量', value: '1,234', icon: 'icon-user' },
{ title: '评论数量', value: '3,456', icon: 'icon-comment' },
{ title: '访问量', value: '45,678', icon: 'icon-view' }
])
// 最近活动
const activities = ref([
{ id: 1, time: '2024-01-20 12:30', content: '新用户注册: user123' },
{ id: 2, time: '2024-01-20 11:45', content: '发布新文章: VitePress主题开发指南' },
{ id: 3, time: '2024-01-20 10:15', content: '更新了网站配置' },
{ id: 4, time: '2024-01-20 09:30', content: '回复了用户评论' }
])
// 退出登录
const handleLogout = () => {
adminStore.logout()
router.go('/pages/admin/login')
}
</script>
<style lang="scss" scoped>
.dashboard-container {
padding: 20px;
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
border-radius: 12px;
h2 {
font-size: 24px;
color: var(--main-font-color);
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
.welcome {
color: var(--main-font-second-color);
}
.logout-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 8px;
background-color: var(--main-error-color);
color: #fff;
cursor: pointer;
transition: opacity 0.3s;
&:hover {
opacity: 0.9;
}
}
}
}
.dashboard-content {
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 30px;
.stat-card {
display: flex;
align-items: center;
padding: 20px;
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
border-radius: 12px;
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
margin-right: 15px;
border-radius: 10px;
background-color: var(--main-color-bg);
.iconfont {
font-size: 24px;
color: var(--main-color);
}
}
.stat-info {
.stat-title {
font-size: 14px;
color: var(--main-font-second-color);
margin-bottom: 5px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: var(--main-font-color);
}
}
}
}
.recent-activities {
padding: 20px;
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
border-radius: 12px;
h3 {
margin-bottom: 20px;
font-size: 18px;
color: var(--main-font-color);
}
.activity-list {
.activity-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--main-card-border);
&:last-child {
border-bottom: none;
}
.activity-time {
min-width: 150px;
color: var(--main-font-second-color);
font-size: 14px;
}
.activity-content {
color: var(--main-font-color);
}
}
}
}
}
}
@media (max-width: 768px) {
.dashboard-container {
.dashboard-header {
flex-direction: column;
gap: 15px;
text-align: center;
.header-right {
flex-direction: column;
}
}
}
}
</style>
增加组件:
.vitepress\theme\components\admin\Layout.vue
<template> <div class="admin-layout"> <AdminSidebar /> <div class="admin-main" :class="{ 'menu-collapsed': collapsed }"> <AdminHeader /> <div class="admin-content"> <slot></slot> </div> </div> </div> </template> <script setup> import { computed } from 'vue' import AdminHeader from './Header.vue' import AdminSidebar from './Sidebar.vue' import { useSidebarCollapse } from '@/composables/useSidebarCollapse' const { collapsed } = useSidebarCollapse() </script> <style lang="scss" scoped> .admin-layout { position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; background-color: var(--main-background); z-index: 999; .admin-main { flex: 1; display: flex; flex-direction: column; width: calc(100% - 200px); transition: width 0.3s; &.menu-collapsed { width: calc(100% - 64px); } .admin-content { flex: 1; padding: 20px; overflow-y: auto; :deep(.VPDoc) { padding: 0 !important; .container { max-width: none !important; margin: 0 !important; padding: 0 !important; } } } } // 响应式布局 @media (max-width: 768px) { .admin-main { width: 100% !important; } } } </style>
.vitepress\theme\components\admin\Header.vue
<template> <div class="admin-header"> <div class="header-left"> <button class="collapse-btn" @click="toggleSidebar"> <span class="iconfont" :class="isMobile ? (collapsed ? 'icon-menu-unfold' : 'icon-menu-fold') : (collapsed ? 'icon-menu-fold' : 'icon-menu-unfold')" ></span> </button> <div class="breadcrumb"> <span v-for="(item, index) in breadcrumbs" :key="index" class="breadcrumb-item" > {{ item }} <span v-if="index < breadcrumbs.length - 1" class="separator">/</span> </span> </div> </div> <div class="header-right"> <div class="user-info"> <span class="username hide-on-mobile">{{ username }}</span> <div class="avatar"> <img :src="avatar" alt="avatar"> </div> </div> </div> </div> </template> <script setup> import { ref, computed, onMounted, onUnmounted } from 'vue' import { useRoute } from 'vitepress' import { useSidebarCollapse } from '@/composables/useSidebarCollapse' const route = useRoute() const username = ref('Admin') const avatar = ref('/avatar.jpg') const { collapsed, toggleCollapsed } = useSidebarCollapse() const isMobile = ref(window.innerWidth <= 768) // 监听窗口大小变化 onMounted(() => { const handleResize = () => { isMobile.value = window.innerWidth <= 768 } window.addEventListener('resize', handleResize) onUnmounted(() => { window.removeEventListener('resize', handleResize) }) }) // 面包屑导航映射 const breadcrumbMap = { dashboard: '仪表盘', users: '用户管理', posts: '文章管理', comments: '评论管理', settings: '系统设置' } // 面包屑导航 const breadcrumbs = computed(() => { const path = route.path const parts = path.split('/').filter(Boolean) const lastPart = parts[parts.length - 1] return ['管理后台', breadcrumbMap[lastPart] || lastPart] }) // 切换侧边栏 const toggleSidebar = () => { toggleCollapsed() } </script> <style lang="scss" scoped> .admin-header { display: flex; justify-content: space-between; align-items: center; height: 60px; padding: 0 20px; background-color: var(--main-card-background); border-bottom: 1px solid var(--main-card-border); .header-left { display: flex; align-items: center; gap: 20px; .collapse-btn { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border: none; border-radius: 8px; background-color: transparent; cursor: pointer; transition: background-color 0.3s; &:hover { background-color: var(--main-card-second-background); } .iconfont { font-size: 20px; color: var(--main-font-color); } } .breadcrumb { display: flex; align-items: center; .breadcrumb-item { color: var(--main-font-color); .separator { margin: 0 8px; color: var(--main-font-second-color); } &:last-child { color: var(--main-color); } } } } .header-right { .user-info { display: flex; align-items: center; gap: 10px; cursor: pointer; .username { color: var(--main-font-color); } .avatar { width: 40px; height: 40px; border-radius: 50%; overflow: hidden; img { width: 100%; height: 100%; object-fit: cover; } } } } @media (max-width: 768px) { padding: 0 15px; .header-left { gap: 10px; .breadcrumb { font-size: 14px; } } .header-right { .hide-on-mobile { display: none; } } } } </style>
.vitepress\theme\components\admin\Sidebar.vue
<template> <div class="sidebar-wrapper"> <!-- 遮罩层 --> <div v-show="!collapsed && isMobile" class="sidebar-mask" @click="toggleCollapsed" ></div> <div class="admin-sidebar" :class="{ collapsed, mobile: isMobile }"> <div class="sidebar-header"> <span class="iconfont icon-dashboard logo"></span> <h1 class="title" v-if="!collapsed">管理后台</h1> </div> <div class="sidebar-menu"> <div v-for="menu in menus" :key="menu.path" class="menu-item" :class="{ active: isActive(menu.path) }" @click="handleMenuClick(menu.path)" > <span class="iconfont" :class="menu.icon"></span> <span class="label" v-if="!collapsed">{{ menu.label }}</span> </div> </div> <!-- 收缩/展开按钮 --> <button class="collapse-trigger" @click="toggleCollapsed" :title="collapsed ? '展开菜单' : '收起菜单'" > <span class="iconfont" :class="collapsed ? 'icon-menu-unfold' : 'icon-menu-fold'"></span> </button> </div> </div> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue' import { useRoute, useRouter } from 'vitepress' import { useSidebarCollapse } from '@/composables/useSidebarCollapse' const route = useRoute() const router = useRouter() const { collapsed, toggleCollapsed } = useSidebarCollapse() // 是否为移动端 const isMobile = ref(false) // 检查是否为移动端 const checkMobile = () => { isMobile.value = window.innerWidth <= 768 } // 监听窗口大小变化 onMounted(() => { checkMobile() window.addEventListener('resize', checkMobile) }) onUnmounted(() => { window.removeEventListener('resize', checkMobile) }) // 处理菜单点击 const handleMenuClick = (path) => { router.go(path) } // 菜单配置 const menus = [ { label: '仪表盘', path: '/pages/admin/dashboard', icon: 'icon-dashboard' }, { label: '用户管理', path: '/pages/admin/users', icon: 'icon-users' }, { label: '文章管理', path: '/pages/admin/posts', icon: 'icon-article' }, { label: '评论管理', path: '/pages/admin/comments', icon: 'icon-comment' }, { label: '系统设置', path: '/pages/admin/settings', icon: 'icon-setting' } ] // 判断当前路由是否激活 const isActive = (path) => { return route.path === path || route.path === path.replace('/pages', '') } </script> <style lang="scss" scoped> .sidebar-wrapper { position: relative; z-index: 1000; .sidebar-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); z-index: 999; } .admin-sidebar { position: relative; width: 200px; height: 100vh; display: flex; flex-direction: column; background-color: var(--main-card-background); border-right: 1px solid var(--main-card-border); transition: all 0.3s; z-index: 1000; // 收缩/展开触发器 .collapse-trigger { position: absolute; right: -16px; top: 50%; transform: translateY(-50%); width: 32px; height: 32px; border-radius: 50%; background-color: var(--main-card-background); border: 1px solid var(--main-card-border); color: var(--main-font-color); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.3s; z-index: 1001; &:hover { color: var(--main-color); background-color: var(--main-color-bg); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .iconfont { font-size: 14px; } } &.collapsed { width: 64px; .sidebar-header { padding: 16px 0; justify-content: center; .logo { margin: 0; } } .menu-item { padding: 10px 0; justify-content: center; .iconfont { margin: 0; font-size: 18px; } } .sidebar-footer { padding: 12px 0; .collapse-btn { justify-content: center; padding: 8px 0; .iconfont { margin: 0; } } } } &.mobile { position: fixed; transform: translateX(0); &.collapsed { transform: translateX(-100%); } // 移动端按钮样式 .collapse-trigger { right: -40px; width: 40px; height: 40px; border-radius: 0 4px 4px 0; border-left: none; &:hover { box-shadow: none; } } } .sidebar-header { display: flex; align-items: center; padding: 16px; border-bottom: 1px solid var(--main-card-border); .logo { font-size: 20px; margin-right: 8px; color: var(--main-color); } .title { font-size: 16px; color: var(--main-font-color); white-space: nowrap; } } .sidebar-menu { flex: 1; padding: 12px 0; overflow-y: auto; .menu-item { cursor: pointer; display: flex; align-items: center; padding: 10px 16px; color: var(--main-font-color); text-decoration: none; transition: all 0.3s; .iconfont { font-size: 16px; margin-right: 8px; } .label { font-size: 14px; white-space: nowrap; } &:hover { color: var(--main-color); background-color: var(--main-color-bg); } &.active { color: var(--main-color); background-color: var(--main-color-bg); } } } .sidebar-footer { padding: 12px 16px; border-top: 1px solid var(--main-card-border); .collapse-btn { width: 100%; display: flex; align-items: center; padding: 8px 12px; border: none; border-radius: 4px; background-color: transparent; color: var(--main-font-color); cursor: pointer; transition: all 0.3s; &:hover { background-color: var(--main-color-bg); color: var(--main-color); } .iconfont { font-size: 16px; margin-right: 8px; } .label { font-size: 14px; white-space: nowrap; } } } } // 移动端样式调整 @media (max-width: 768px) { .admin-sidebar { &.mobile { width: 200px !important; &.collapsed { width: 200px !important; } } } } } </style>
增加收缩状态
.vitepress\theme\composables\useSidebarCollapse.ts
import { ref } from 'vue' const collapsed = ref(false) export function useSidebarCollapse() { const toggleCollapsed = () => { collapsed.value = !collapsed.value } return { collapsed, toggleCollapsed } }
增加
pages\admin[dashboard.md](http://dashboard.md/)渲染
---
title: 仪表盘
layout: admin
aside: false
---
<script setup>
import Dashboard from "@/views/admin/Dashboard.vue"
</script>
<Dashboard />
首页改进
效果:
改动代码:
新增后台布局,与首页区分开
.vitepress\theme\layouts\AdminLayout.vue
<template>
<AdminLayout>
<Content />
</AdminLayout>
</template>
<script setup>
import AdminLayout from '@/components/admin/Layout.vue'
</script>
<style lang="scss" scoped>
:deep(.VPDoc) {
padding: 0 !important;
.container {
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
}
}
</style>
在App.vue中修改布局方式:
.vitepress\theme\App.vue
在原来导航布局上套一层通过if else区分使用哪一套布局 isAdminRoute是判断哪些路由使用AdminLayout布局 .vitepress\theme\App.vue 判断是否是管理后台路由 改动文件: .vitepress\theme\store\modules .vitepress\theme\store\modules\admin.ts 创建状态管理ts 增加中间件 .vitepress\theme\middleware\auth.ts .vitepress\theme\index.mjs 登录修改: .vitepress\theme\views\admin\Login.vue 后台首页修改: .vitepress\theme\views\admin\Dashboard.vue 变动文件 自动检查 .vitepress\theme\composables\useAuthCheck.ts .vitepress\theme\components\admin\Layout.vue 这里只需引入自动检查文件即可,主要作用是,当没有登录的情况下,后台页面时不能正常访问的,直接调整到登录页面。 变动文件: 本主题提供了一个 改动文件: 后端代码参考:https://github.com/hhfb8848/halcyon-springboot 后端UI参考:https://github.com/hhfb8848/halcyon-admin-ui ruoyiplus:https://gitee.com/dromara/RuoYi-Vue-Plus ruoyiplus-github:https://github.com/dromara/RuoYi-Vue-Plus 后端改造推荐,使用Go或Node,java也可以,但不推荐。<template>
<!-- 管理后台路由不显示默认布局 -->
<template v-if="!isAdminRoute">
<!-- 背景图片 -->
<Background />
<!-- 加载提示 -->
<Loading />
<!-- 中控台 -->
<Control />
<!-- 导航栏 -->
<Nav />
<!-- 主内容 -->
<main :class="['mian-layout', { loading: loadingStatus, 'is-post': isPostPage }]">
<!-- 404 -->
<NotFound v-if="page.isNotFound" />
<!-- 首页 -->
<Home v-if="frontmatter.layout === 'home'" showHeader />
<!-- 页面 -->
<template v-else>
<!-- 文章页面 -->
<Post v-if="isPostPage" />
<!-- 普通页面 -->
<Page v-else-if="!page.isNotFound" />
</template>
</main>
<!-- 页脚 -->
<FooterLink v-show="!loadingStatus" :showBar="isPostPage && !page.isNotFound" />
<Footer v-show="!loadingStatus" />
<!-- 悬浮菜单 -->
<Teleport to="body">
<!-- 左侧菜单 -->
<div :class="['left-menu', { hidden: footerIsShow }]">
<!-- 全局设置 -->
<Settings />
<!-- 全局播放器 -->
<Player />
</div>
</Teleport>
<!-- 右键菜单 -->
<RightMenu ref="rightMenuRef" />
<!-- 全局消息 -->
<Message />
</template>
<!-- 管理后台路由 -->
<template v-else>
<AdminLayout>
<Content />
</AdminLayout>
</template>
</template>
//判断是否是管理后台路由
const isAdminRoute= computed(()=>{
const isAdminPath = route.path.startsWith('/admin') || route.path.startsWith('/pages/admin')
const isLoginPath= route.path.startsWith('/login')
return isAdminPath && !isLoginPath
})
认证
import { defineStore } from 'pinia'
// 用户信息
interface UserInfo {
username: string
}
// 管理员登录状态
interface AdminState {
isLoggedIn: boolean
userInfo: UserInfo | null
token: string
tokenExpireTime: number
}
// 管理员登录状态
export const useAdminStore = defineStore('admin', {
// 初始化管理员登录状态
state: (): AdminState => ({
isLoggedIn: false,
userInfo: null,
token: '',
tokenExpireTime: 0
}),
// 获取管理员登录状态
getters: {
isAuthenticated(): boolean {
const now = Date.now()
const isTokenValid = this.token && this.tokenExpireTime > now
return !!(this.isLoggedIn && isTokenValid && this.userInfo)
}
},
// 管理员登录状态
actions: {
login(data: { username: string, token: string }) {
this.isLoggedIn = true
this.userInfo = { username: data.username }
this.token = data.token
this.tokenExpireTime = Date.now() + 2 * 60 * 60 * 1000
},
// 管理员退出登录
logout() {
this.isLoggedIn = false
this.userInfo = null
this.token = ''
this.tokenExpireTime = 0
},
// 检查管理员登录状态
checkLoginStatus(): boolean {
return this.isAuthenticated
}
},
// 持久化管理员登录状态
persist: {
key: 'admin-store',
storage: window.localStorage,
paths: ['isLoggedIn', 'userInfo', 'token', 'tokenExpireTime']
}
})
import { useAdminStore } from '../store/modules/admin'
export function useAuthGuard() {
const adminStore = useAdminStore()
const checkAuth = (path: string) => {
// 检查是否是管理后台路由
const isAdminPath = path.startsWith('/admin') || path.startsWith('/pages/admin')
const isLoginPath = path.endsWith('/login')
// 登录页面不需要验证
if (isLoginPath) return true
// 管理后台页面需要验证登录状态
if (isAdminPath) {
return adminStore.checkLoginStatus()
}
// 其他页面不需要验证
return true
}
return {
checkAuth
}
}
<template>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h2>管理员登录</h2>
</div>
<div class="login-form">
<div class="form-item">
<span class="iconfont icon-user"></span>
<input
type="text"
v-model="username"
placeholder="用户名"
@keyup.enter="handleLogin"
>
</div>
<div class="form-item">
<span class="iconfont icon-password"></span>
<input
:type="showPassword ? 'text' : 'password'"
v-model="password"
placeholder="密码"
@keyup.enter="handleLogin"
>
<span
class="iconfont"
:class="showPassword ? 'icon-eye-open' : 'icon-eye-close'"
@click="showPassword = !showPassword"
></span>
</div>
<button class="login-btn" @click="handleLogin">登录</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vitepress'
import { useAdminStore } from '@/store/modules/admin'
const router = useRouter()
const adminStore = useAdminStore()
const username = ref('')
const password = ref('')
const showPassword = ref(false)
//如果已经登录,直接跳转到仪表盘
onMounted(() => {
if (adminStore.checkLoginStatus()) {
router.go('/pages/admin/dashboard')
}
})
const handleLogin = () => {
if (!username.value || !password.value) {
window.$message.warning('请输入用户名和密码')
return
}
// TODO:后续调用登录接口,模拟登录成功
adminStore.login({
username: username.value,
token: 'mock-token'
})
window.$message.success('登录成功')
router.go('/pages/admin/dashboard')
}
</script>
<style lang="scss" scoped>
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - var(--header-height));
padding: 20px;
.login-box {
width: 100%;
max-width: 400px;
padding: 30px;
border-radius: 16px;
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.login-header {
text-align: center;
margin-bottom: 30px;
h2 {
font-size: 24px;
color: var(--main-font-color);
}
}
.login-form {
.form-item {
position: relative;
margin-bottom: 20px;
.iconfont {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
font-size: 18px;
color: var(--main-font-second-color);
}
input {
width: 100%;
height: 45px;
padding: 0 45px;
border: 1px solid var(--main-card-border);
border-radius: 8px;
background-color: var(--main-card-second-background);
color: var(--main-font-color);
transition: all 0.3s;
&:focus {
border-color: var(--main-color);
box-shadow: 0 0 0 2px var(--main-color-bg);
}
}
.icon-eye-open,
.icon-eye-close {
left: auto;
right: 15px;
cursor: pointer;
&:hover {
color: var(--main-color);
}
}
}
.login-btn {
width: 100%;
height: 45px;
border: none;
border-radius: 8px;
background-color: var(--main-color);
color: #fff;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
}
}
</style>
<template>
<AdminLayout>
<div class="dashboard-container">
<div class="dashboard-header">
<h2>仪表盘</h2>
<div class="header-right">
<span class="welcome">欢迎回来,{{ username }}</span>
<button class="logout-btn" @click="handleLogout">
<span class="iconfont icon-logout"></span>
退出登录
</button>
</div>
</div>
<div class="dashboard-content">
<!-- 数据概览卡片 -->
<div class="stats-cards">
<div class="stat-card" v-for="stat in statistics" :key="stat.title">
<div class="stat-icon">
<span class="iconfont" :class="stat.icon"></span>
</div>
<div class="stat-info">
<div class="stat-title">{{ stat.title }}</div>
<div class="stat-value">{{ stat.value }}</div>
</div>
</div>
</div>
<!-- 最近活动 -->
<div class="recent-activities">
<h3>最近活动</h3>
<div class="activity-list">
<div class="activity-item" v-for="activity in activities" :key="activity.id">
<span class="activity-time">{{ activity.time }}</span>
<span class="activity-content">{{ activity.content }}</span>
</div>
</div>
</div>
</div>
</div>
</AdminLayout>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vitepress'
import { useAdminStore } from '@/store/modules/admin'
import AdminLayout from '@/components/admin/Layout.vue'
const router = useRouter()
const adminStore = useAdminStore()
const username = computed(() => adminStore.userInfo?.username || 'Admin')
// 统计数据
const statistics = ref([
{ title: '文章总数', value: '125', icon: 'icon-article' },
{ title: '用户数量', value: '1,234', icon: 'icon-user' },
{ title: '评论数量', value: '3,456', icon: 'icon-comment' },
{ title: '访问量', value: '45,678', icon: 'icon-view' }
])
// 最近活动
const activities = ref([
{ id: 1, time: '2024-01-20 12:30', content: '新用户注册: user123' },
{ id: 2, time: '2024-01-20 11:45', content: '发布新文章: VitePress主题开发指南' },
{ id: 3, time: '2024-01-20 10:15', content: '更新了网站配置' },
{ id: 4, time: '2024-01-20 09:30', content: '回复了用户评论' }
])
// 退出登录
const handleLogout = () => {
adminStore.logout()
router.go('/pages/admin/login')
}
</script>
<style lang="scss" scoped>
.dashboard-container {
padding: 20px;
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
border-radius: 12px;
h2 {
font-size: 24px;
color: var(--main-font-color);
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
.welcome {
color: var(--main-font-second-color);
}
.logout-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 8px;
background-color: var(--main-error-color);
color: #fff;
cursor: pointer;
transition: opacity 0.3s;
&:hover {
opacity: 0.9;
}
}
}
}
.dashboard-content {
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 30px;
.stat-card {
display: flex;
align-items: center;
padding: 20px;
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
border-radius: 12px;
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
margin-right: 15px;
border-radius: 10px;
background-color: var(--main-color-bg);
.iconfont {
font-size: 24px;
color: var(--main-color);
}
}
.stat-info {
.stat-title {
font-size: 14px;
color: var(--main-font-second-color);
margin-bottom: 5px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: var(--main-font-color);
}
}
}
}
.recent-activities {
padding: 20px;
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
border-radius: 12px;
h3 {
margin-bottom: 20px;
font-size: 18px;
color: var(--main-font-color);
}
.activity-list {
.activity-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--main-card-border);
&:last-child {
border-bottom: none;
}
.activity-time {
min-width: 150px;
color: var(--main-font-second-color);
font-size: 14px;
}
.activity-content {
color: var(--main-font-color);
}
}
}
}
}
}
@media (max-width: 768px) {
.dashboard-container {
.dashboard-header {
flex-direction: column;
gap: 15px;
text-align: center;
.header-right {
flex-direction: column;
}
}
}
}
</style>
增加自动检查
优化改动
themeConfig.mjs
文件用来配置,它位于 .vitepress\theme\assets\themeConfig.mjs
,你可以将它复制一份并移动至根目录中,在这里里面的修改将会覆盖初始配置,请注意,请不要更改文件名或者删除原配置文件,否则它将会不起作用!扩展后台页面
.vitepress\theme\views\admin\PostManage.vue<template>
<AdminLayout>
<div class="user-manage-container">
<div class="manage-header">
<h2>用户管理</h2>
<div class="header-actions">
<div class="search-box">
<span class="iconfont icon-search"></span>
<input
type="text"
v-model="searchQuery"
placeholder="搜索用户..."
@input="handleSearch"
>
</div>
<button class="add-btn" @click="handleAddUser">
<span class="iconfont icon-add"></span>
添加用户
</button>
</div>
</div>
<div class="user-table">
<table>
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>邮箱</th>
<th>角色</th>
<th>状态</th>
<th>注册时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>
<span class="role-tag" :class="user.role">{{ user.role }}</span>
</td>
<td>
<span class="status-tag" :class="user.status">
{{ user.status === 'active' ? '正常' : '禁用' }}
</span>
</td>
<td>{{ user.registerTime }}</td>
<td class="actions">
<button class="edit-btn" @click="handleEdit(user)">
<span class="iconfont icon-edit"></span>
</button>
<button class="delete-btn" @click="handleDelete(user)">
<span class="iconfont icon-delete"></span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination">
<button
:disabled="currentPage === 1"
@click="currentPage--"
>上一页</button>
<span>{{ currentPage }} / {{ totalPages }}</span>
<button
:disabled="currentPage === totalPages"
@click="currentPage++"
>下一页</button>
</div>
</div>
</AdminLayout>
</template>
<script setup>
import { ref } from 'vue'
import AdminLayout from '@/components/admin/Layout.vue'
const searchQuery = ref('')
const currentPage = ref(1)
const totalPages = ref(10)
// 模拟用户数据
const users = ref([
{
id: 1,
username: 'admin',
email: '[email protected]',
role: 'admin',
status: 'active',
registerTime: '2024-01-01 12:00'
},
{
id: 2,
username: 'user1',
email: '[email protected]',
role: 'user',
status: 'active',
registerTime: '2024-01-02 15:30'
},
{
id: 3,
username: 'user2',
email: '[email protected]',
role: 'user',
status: 'inactive',
registerTime: '2024-01-03 09:15'
}
])
const handleSearch = () => {
// 实现搜索逻辑
console.log('搜索:', searchQuery.value)
}
const handleAddUser = () => {
// 实现添加用户逻辑
console.log('添加用户')
}
const handleEdit = (user) => {
// 实现编辑用户逻辑
console.log('编辑用户:', user)
}
const handleDelete = (user) => {
// 实现删除用户逻辑
console.log('删除用户:', user)
}
</script>
<style lang="scss" scoped>
.user-manage-container {
padding: 20px;
.manage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
border-radius: 12px;
h2 {
font-size: 24px;
color: var(--main-font-color);
}
.header-actions {
display: flex;
gap: 15px;
.search-box {
position: relative;
.iconfont {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--main-font-second-color);
}
input {
width: 200px;
height: 36px;
padding: 0 12px 0 35px;
border: 1px solid var(--main-card-border);
border-radius: 8px;
background-color: var(--main-card-second-background);
color: var(--main-font-color);
&:focus {
border-color: var(--main-color);
box-shadow: 0 0 0 2px var(--main-color-bg);
}
}
}
.add-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 8px;
background-color: var(--main-color);
color: #fff;
cursor: pointer;
transition: opacity 0.3s;
&:hover {
opacity: 0.9;
}
}
}
}
.user-table {
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
border-radius: 12px;
overflow-x: auto;
table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 15px;
text-align: left;
border-bottom: 1px solid var(--main-card-border);
}
th {
background-color: var(--main-card-second-background);
color: var(--main-font-color);
font-weight: bold;
}
td {
color: var(--main-font-color);
}
.role-tag, .status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
}
.role-tag {
&.admin {
background-color: var(--main-color-bg);
color: var(--main-color);
}
&.user {
background-color: var(--main-success-color-bg);
color: var(--main-success-color);
}
}
.status-tag {
&.active {
background-color: var(--main-success-color-bg);
color: var(--main-success-color);
}
&.inactive {
background-color: var(--main-error-color-bg);
color: var(--main-error-color);
}
}
.actions {
display: flex;
gap: 8px;
button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
cursor: pointer;
transition: opacity 0.3s;
&:hover {
opacity: 0.8;
}
&.edit-btn {
background-color: var(--main-warning-color-bg);
color: var(--main-warning-color);
}
&.delete-btn {
background-color: var(--main-error-color-bg);
color: var(--main-error-color);
}
}
}
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
margin-top: 20px;
button {
padding: 8px 16px;
border: 1px solid var(--main-card-border);
border-radius: 8px;
background-color: var(--main-card-background);
color: var(--main-font-color);
cursor: pointer;
transition: all 0.3s;
&:hover:not(:disabled) {
border-color: var(--main-color);
color: var(--main-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
}
@media (max-width: 768px) {
.user-manage-container {
.manage-header {
flex-direction: column;
gap: 15px;
.header-actions {
flex-direction: column;
width: 100%;
.search-box {
width: 100%;
input {
width: 100%;
}
}
.add-btn {
width: 100%;
justify-content: center;
}
}
}
}
}
</style>
.vitepress\theme\views\admin\CommentManage.vue<template>
<div class="post-manage-container">
<div class="manage-header">
<h2>文章管理</h2>
<div class="header-actions">
<div class="search-box">
<span class="iconfont icon-search"></span>
<input
type="text"
v-model="searchQuery"
placeholder="搜索文章..."
@input="handleSearch"
>
</div>
<button class="add-btn" @click="handleAddPost">
<span class="iconfont icon-add"></span>
新建文章
</button>
</div>
</div>
<div class="post-table">
<table>
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>作者</th>
<th>分类</th>
<th>状态</th>
<th>发布时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id">
<td>{{ post.id }}</td>
<td>{{ post.title }}</td>
<td>{{ post.author }}</td>
<td>
<span class="category-tag">{{ post.category }}</span>
</td>
<td>
<span class="status-tag" :class="post.status">
{{ post.status === 'published' ? '已发布' : '草稿' }}
</span>
</td>
<td>{{ post.publishTime }}</td>
<td class="actions">
<button class="edit-btn" @click="handleEdit(post)">编辑</button>
<button class="delete-btn" @click="handleDelete(post)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const searchQuery = ref('')
const posts = ref([
{
id: 1,
title: 'VitePress主题开发指南',
author: 'Admin',
category: '技术',
status: 'published',
publishTime: '2024-01-20 15:30'
},
{
id: 2,
title: '如何使用Vue3',
author: 'Admin',
category: '教程',
status: 'draft',
publishTime: '2024-01-19 10:20'
}
])
const handleSearch = () => {
// 实现搜索逻辑
}
const handleAddPost = () => {
// 实现新建文章逻辑
}
const handleEdit = (post) => {
// 实现编辑逻辑
}
const handleDelete = (post) => {
// 实现删除逻辑
}
</script>
<style lang="scss" scoped>
.post-manage-container {
padding: 20px;
.manage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
font-size: 24px;
color: var(--main-font-color);
}
.header-actions {
display: flex;
gap: 15px;
.search-box {
position: relative;
.iconfont {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--main-font-second-color);
}
input {
width: 200px;
height: 36px;
padding: 0 35px;
border: 1px solid var(--main-card-border);
border-radius: 8px;
background-color: var(--main-card-second-background);
color: var(--main-font-color);
&:focus {
border-color: var(--main-color);
outline: none;
}
}
}
.add-btn {
display: flex;
align-items: center;
gap: 5px;
height: 36px;
padding: 0 15px;
border: none;
border-radius: 8px;
background-color: var(--main-color);
color: #fff;
cursor: pointer;
transition: opacity 0.3s;
&:hover {
opacity: 0.9;
}
}
}
}
.post-table {
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
border-radius: 12px;
overflow: hidden;
table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 15px;
text-align: left;
border-bottom: 1px solid var(--main-card-border);
}
th {
background-color: var(--main-card-second-background);
color: var(--main-font-second-color);
font-weight: normal;
}
td {
color: var(--main-font-color);
}
.category-tag {
padding: 4px 8px;
border-radius: 4px;
background-color: var(--main-color-bg);
color: var(--main-color);
}
.status-tag {
padding: 4px 8px;
border-radius: 4px;
&.published {
background-color: var(--success-color-bg);
color: var(--success-color);
}
&.draft {
background-color: var(--warning-color-bg);
color: var(--warning-color);
}
}
.actions {
display: flex;
gap: 10px;
button {
padding: 4px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: opacity 0.3s;
&:hover {
opacity: 0.9;
}
}
.edit-btn {
background-color: var(--main-color-bg);
color: var(--main-color);
}
.delete-btn {
background-color: var(--error-color-bg);
color: var(--error-color);
}
}
}
@media (max-width: 768px) {
overflow-x: auto;
table {
min-width: 800px;
}
.manage-header {
flex-direction: column;
gap: 15px;
.header-actions {
width: 100%;
justify-content: space-between;
.search-box {
flex: 1;
margin-right: 10px;
input {
width: 100%;
}
}
}
}
}
}
}
</style>
.vitepress\theme\views\admin\SystemSettings.vue<template>
<div class="comment-manage-container">
<div class="manage-header">
<h2>评论管理</h2>
<div class="header-actions">
<div class="search-box">
<span class="iconfont icon-search"></span>
<input
type="text"
v-model="searchQuery"
placeholder="搜索评论..."
@input="handleSearch"
>
</div>
</div>
</div>
<div class="comment-table">
<table>
<thead>
<tr>
<th>ID</th>
<th>评论内容</th>
<th>评论者</th>
<th>文章</th>
<th>状态</th>
<th>评论时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="comment in comments" :key="comment.id">
<td>{{ comment.id }}</td>
<td>{{ comment.content }}</td>
<td>{{ comment.author }}</td>
<td>{{ comment.postTitle }}</td>
<td>
<span class="status-tag" :class="comment.status">
{{ comment.status === 'approved' ? '已通过' : '待审核' }}
</span>
</td>
<td>{{ comment.createTime }}</td>
<td class="actions">
<button
v-if="comment.status === 'pending'"
class="approve-btn"
@click="handleApprove(comment)"
>
通过
</button>
<button class="delete-btn" @click="handleDelete(comment)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const searchQuery = ref('')
const comments = ref([
{
id: 1,
content: '这篇文章写得很好!',
author: 'user1',
postTitle: 'VitePress主题开发指南',
status: 'approved',
createTime: '2024-01-20 15:30'
},
{
id: 2,
content: '期待更新!',
author: 'user2',
postTitle: '如何使用Vue3',
status: 'pending',
createTime: '2024-01-19 10:20'
}
])
const handleSearch = () => {
// 实现搜索逻辑
}
const handleApprove = (comment) => {
// 实现审核通过逻辑
}
const handleDelete = (comment) => {
// 实现删除逻辑
}
</script>
<style lang="scss" scoped>
.comment-manage-container {
padding: 20px;
.manage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
font-size: 24px;
color: var(--main-font-color);
}
.header-actions {
.search-box {
position: relative;
.iconfont {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--main-font-second-color);
}
input {
width: 200px;
height: 36px;
padding: 0 35px;
border: 1px solid var(--main-card-border);
border-radius: 8px;
background-color: var(--main-card-second-background);
color: var(--main-font-color);
&:focus {
border-color: var(--main-color);
outline: none;
}
}
}
}
}
.comment-table {
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
border-radius: 12px;
overflow: hidden;
table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 15px;
text-align: left;
border-bottom: 1px solid var(--main-card-border);
}
th {
background-color: var(--main-card-second-background);
color: var(--main-font-second-color);
font-weight: normal;
}
td {
color: var(--main-font-color);
}
.status-tag {
padding: 4px 8px;
border-radius: 4px;
&.approved {
background-color: var(--success-color-bg);
color: var(--success-color);
}
&.pending {
background-color: var(--warning-color-bg);
color: var(--warning-color);
}
}
.actions {
display: flex;
gap: 10px;
button {
padding: 4px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: opacity 0.3s;
&:hover {
opacity: 0.9;
}
}
.approve-btn {
background-color: var(--success-color-bg);
color: var(--success-color);
}
.delete-btn {
background-color: var(--error-color-bg);
color: var(--error-color);
}
}
}
}
}
</style>
<template>
<div class="settings-container">
<div class="settings-header">
<h2>系统设置</h2>
</div>
<div class="settings-content">
<div class="settings-section">
<h3>基本设置</h3>
<div class="setting-item">
<label>网站标题</label>
<input type="text" v-model="settings.siteTitle">
</div>
<div class="setting-item">
<label>网站描述</label>
<textarea v-model="settings.siteDescription"></textarea>
</div>
</div>
<div class="settings-section">
<h3>主题设置</h3>
<div class="setting-item">
<label>主题模式</label>
<select v-model="settings.themeMode">
<option value="light">浅色</option>
<option value="dark">深色</option>
<option value="auto">跟随系统</option>
</select>
</div>
<div class="setting-item">
<label>主题色</label>
<input type="color" v-model="settings.themeColor">
</div>
</div>
<div class="settings-section">
<h3>其他设置</h3>
<div class="setting-item">
<label>开启评论</label>
<div class="switch">
<input
type="checkbox"
v-model="settings.enableComments"
id="commentSwitch"
>
<label for="commentSwitch"></label>
</div>
</div>
</div>
<div class="settings-actions">
<button class="save-btn" @click="handleSave">保存设置</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const settings = ref({
siteTitle: 'My Blog',
siteDescription: '这是一个基于 VitePress 的博客',
themeMode: 'auto',
themeColor: '#3eaf7c',
enableComments: true
})
const handleSave = () => {
// 实现保存设置逻辑
window.$message.success('设置保存成功')
}
</script>
<style lang="scss" scoped>
.settings-container {
padding: 20px;
.settings-header {
margin-bottom: 30px;
h2 {
font-size: 24px;
color: var(--main-font-color);
}
}
.settings-content {
max-width: 800px;
.settings-section {
margin-bottom: 40px;
padding: 20px;
background-color: var(--main-card-background);
border: 1px solid var(--main-card-border);
border-radius: 12px;
h3 {
margin-bottom: 20px;
font-size: 18px;
color: var(--main-font-color);
}
.setting-item {
display: flex;
align-items: center;
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
label {
width: 120px;
color: var(--main-font-second-color);
}
input[type="text"],
textarea,
select {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--main-card-border);
border-radius: 8px;
background-color: var(--main-card-second-background);
color: var(--main-font-color);
&:focus {
border-color: var(--main-color);
outline: none;
}
}
textarea {
height: 100px;
resize: vertical;
}
.switch {
position: relative;
width: 50px;
height: 24px;
input {
display: none;
&:checked + label {
background-color: var(--main-color);
&:after {
transform: translateX(26px);
}
}
}
label {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--main-font-second-color);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
&:after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: #fff;
border-radius: 50%;
transition: all 0.3s;
}
}
}
}
}
.settings-actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
.save-btn {
padding: 10px 24px;
border: none;
border-radius: 8px;
background-color: var(--main-color);
color: #fff;
font-size: 16px;
cursor: pointer;
transition: opacity 0.3s;
&:hover {
opacity: 0.9;
}
}
}
}
}
</style>
📎 参考文章