ZKit管理端

This commit is contained in:
zpooi
2025-12-03 23:40:20 +08:00
parent 4f0413503f
commit 00e8c1258c
31 changed files with 6954 additions and 0 deletions

24
Zkit-admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
Zkit-admin/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
Zkit-admin/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

15
Zkit-admin/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zkit Admin</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1917
Zkit-admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
Zkit-admin/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "zkit-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^3.0.4",
"sass": "^1.94.2",
"vue": "^3.5.24",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

9
Zkit-admin/src/App.vue Normal file
View File

@@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
</style>

View File

@@ -0,0 +1,234 @@
:root {
/* Colors */
--primary: #07C160;
--primary-hover: #06ad56;
--primary-light: #e6f9f0;
--text-dark: #181c32;
--text-gray: #5e6278;
--text-light: #b5b5c3;
--text-white: #ffffff;
--bg-body: #f5f8fa;
--bg-card: #ffffff;
--bg-hover: #f5f8fa;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* Border Radius */
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 0 20px 0 rgba(76, 87, 125, 0.02);
--shadow-lg: 0 10px 30px 0 rgba(76, 87, 125, 0.05);
/* Fonts */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
}
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#app {
width: 100%;
height: 100%;
}
body {
font-family: var(--font-family);
background-color: var(--bg-body);
color: var(--text-gray);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 14px;
}
a {
text-decoration: none;
color: inherit;
transition: color 0.2s;
}
ul {
list-style: none;
}
/* Utility Classes */
.card {
background: var(--bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
border: 1px solid transparent;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: var(--shadow-lg);
}
.btn-primary {
background-color: var(--primary);
color: var(--text-white);
border: none;
padding: 10px 20px;
border-radius: var(--radius-sm);
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary:hover {
background-color: var(--primary-hover);
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.3);
transform: translateY(-1px);
}
.btn-danger:hover {
background-color: #d9364a;
box-shadow: 0 4px 12px rgba(246, 78, 96, 0.3);
}
/* Pastel Colors for Avatars/Badges */
.bg-light-success {
background-color: #e6f9f0;
color: #07C160;
}
.bg-light-primary {
background-color: #e1f0ff;
color: #3699ff;
}
.bg-light-warning {
background-color: #fff4de;
color: #ffa800;
}
.bg-light-danger {
background-color: #ffe2e5;
color: #f64e60;
}
.bg-light-info {
background-color: #c9f7f5;
color: #1bc5bd;
}
.bg-light-dark {
background-color: #f5f8fa;
color: #5e6278;
}
/* Table Styles */
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
th {
background-color: transparent;
color: #b5b5c3;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 16px 24px;
text-align: left;
border-bottom: 1px dashed #eff2f5;
white-space: nowrap;
/* Fix for mobile text wrapping */
}
td {
padding: 20px 24px;
color: var(--text-gray);
font-size: 14px;
font-weight: 500;
border-bottom: 1px dashed #eff2f5;
transition: background-color 0.2s;
vertical-align: middle;
white-space: nowrap;
/* Fix for mobile text wrapping */
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background-color: transparent;
}
/* Badge Styles */
.badge {
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
}
/* Button Text Style (Ghost) */
.btn-text {
background: transparent;
color: var(--text-gray);
border: none;
font-weight: 600;
cursor: pointer;
padding: 8px 16px;
border-radius: 6px;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-text:hover {
background-color: #f5f8fa;
color: var(--primary);
}
.btn-text.primary {
color: var(--primary);
background-color: var(--primary-light);
}
.btn-text.primary:hover {
background-color: var(--primary);
color: #fff;
}
/* Scrollbar - Hidden but scrollable */
::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: transparent;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,173 @@
<template>
<transition name="modal-fade">
<div v-if="visible" class="modal-overlay" @click.self="handleCancel">
<div class="modal-container confirm-modal">
<div class="modal-header">
<div class="icon-wrapper">
<i class="fa-solid fa-triangle-exclamation"></i>
</div>
<h3>{{ title }}</h3>
</div>
<div class="modal-body">
<p>{{ message }}</p>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="handleCancel">取消</button>
<button class="btn-danger" @click="handleConfirm">确定</button>
</div>
</div>
</div>
</transition>
</template>
<script setup>
defineProps({
visible: Boolean,
title: {
type: String,
default: '确认删除'
},
message: {
type: String,
default: '确定要删除吗?此操作无法撤销。'
}
})
const emit = defineEmits(['update:visible', 'confirm'])
const handleCancel = () => {
emit('update:visible', false)
}
const handleConfirm = () => {
emit('confirm')
emit('update:visible', false)
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-container.confirm-modal {
background-color: #fff;
border-radius: 12px;
width: 420px;
max-width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
animation: slide-up 0.3s ease-out;
}
.modal-header {
padding: 24px 24px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.icon-wrapper {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: #fff4de;
display: flex;
align-items: center;
justify-content: center;
color: #ffa800;
font-size: 24px;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #1e1e2d;
text-align: center;
}
.modal-body {
padding: 0 24px 24px;
text-align: center;
}
.modal-body p {
margin: 0;
color: #5e6278;
font-size: 14px;
line-height: 1.6;
}
.modal-footer {
padding: 20px 24px;
border-top: 1px solid #f0f2f5;
display: flex;
justify-content: center;
gap: 12px;
}
.btn-cancel {
padding: 10px 24px;
border-radius: 6px;
border: 1px solid #e4e6ef;
background-color: #fff;
color: #5e6278;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-cancel:hover {
background-color: #f5f8fa;
color: #1e1e2d;
}
.btn-danger {
padding: 10px 24px;
border-radius: 6px;
border: none;
background-color: #f64e60;
color: #fff;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-danger:hover {
background-color: #d9364a;
}
/* Animations */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.3s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
@keyframes slide-up {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,387 @@
<template>
<header class="header">
<div class="header-left">
<button class="hamburger-btn" @click="appStore.toggleMobileSidebar">
<i class="fa-solid fa-bars"></i>
</button>
<h2 class="page-title">{{ routeName }}</h2>
</div>
<div class="header-right">
<div class="search-bar">
<span class="search-icon"><i class="fa-solid fa-magnifying-glass"></i></span>
<input type="text" placeholder="搜索..." />
</div>
<div class="action-icons">
<div class="icon-wrapper-relative">
<button class="icon-btn" @click.stop="toggleNotification" :class="{ 'active': notificationOpen }">
<i class="fa-regular fa-bell"></i>
<span class="badge-dot" v-if="hasUnread"></span>
</button>
<NotificationDropdown v-if="notificationOpen" @update-unread="updateUnreadStatus" />
</div>
</div>
<div class="user-profile" @click="toggleDropdown">
<div class="avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=E6F9F0&color=07C160" alt="Admin" />
</div>
<div class="user-info">
<span class="username">管理员</span>
</div>
<!-- Dropdown Menu -->
<transition name="dropdown-fade">
<div v-if="dropdownOpen" class="dropdown-menu" @click.stop>
<div class="user-summary-mobile">
<div class="info">
<div class="name">管理员</div>
<div class="email">admin@zkit.com</div>
</div>
</div>
<div class="dropdown-item">
<i class="fa-regular fa-user"></i>
<span>个人资料</span>
</div>
<div class="dropdown-item">
<i class="fa-solid fa-sliders"></i>
<span>账号设置</span>
</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item">
<i class="fa-regular fa-circle-question"></i>
<span>帮助中心</span>
</div>
<router-link to="/login" class="dropdown-item logout">
<i class="fa-solid fa-right-from-bracket"></i>
<span>退出登录</span>
</router-link>
</div>
</transition>
</div>
</div>
</header>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '../stores/app'
import NotificationDropdown from './NotificationDropdown.vue'
const route = useRoute()
const appStore = useAppStore()
const dropdownOpen = ref(false)
const notificationOpen = ref(false)
const hasUnread = ref(true)
const routeName = computed(() => {
const nameMap = {
'dashboard': '仪表盘',
'users': '用户管理',
'tools': '工具管理',
'announcements': '公告管理',
'faqs': 'FAQ 管理',
'ads': '广告管理',
'footer': '页脚管理',
'login': '登录'
}
return nameMap[route.name] || route.name
})
const toggleDropdown = () => {
dropdownOpen.value = !dropdownOpen.value
notificationOpen.value = false
}
const toggleNotification = () => {
notificationOpen.value = !notificationOpen.value
dropdownOpen.value = false
}
const updateUnreadStatus = (status) => {
hasUnread.value = status
}
const closeDropdown = (e) => {
if (!e.target.closest('.user-profile') && !e.target.closest('.action-icons')) {
dropdownOpen.value = false
notificationOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeDropdown)
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdown)
})
</script>
<style scoped>
.header {
height: 80px;
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
box-shadow: 0 0 20px rgba(0,0,0,0.03);
z-index: 90;
position: sticky;
top: 0;
}
.header-left {
display: flex;
align-items: center;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #181c32;
margin: 0;
}
.header-right {
display: flex;
align-items: center;
gap: 24px;
}
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
font-size: 14px;
color: #a1a5b7;
}
.search-bar input {
padding: 10px 16px 10px 38px;
border-radius: 8px;
border: 1px solid transparent;
background-color: #f5f8fa;
font-size: 13px;
width: 240px;
transition: all 0.3s;
outline: none;
color: #5e6278;
font-weight: 500;
}
.search-bar input:focus {
background-color: #fff;
border-color: #e4e6ef;
color: #181c32;
}
.action-icons {
display: flex;
align-items: center;
gap: 8px;
}
.icon-wrapper-relative {
position: relative;
}
.icon-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: #a1a5b7;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
position: relative;
font-size: 16px;
}
.icon-btn:hover,
.icon-btn.active {
background-color: #f5f8fa;
color: #07C160;
}
.badge-dot {
position: absolute;
top: 8px;
right: 8px;
width: 6px;
height: 6px;
background-color: #f64e60;
border-radius: 50%;
}
.user-profile {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 6px 12px;
border-radius: 10px;
transition: all 0.3s;
position: relative;
}
.user-info {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
}
.username {
font-size: 14px;
font-weight: 600;
color: #181c32;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 2px 8px rgba(7, 193, 96, 0.2);
border: 2px solid #e6f9f0;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 12px);
right: 0;
left: auto;
width: 200px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
z-index: 1000;
border: 1px solid #e4e6ef;
padding: 8px 0;
}
.user-summary-mobile {
padding: 12px 16px;
border-bottom: 1px solid #f5f8fa;
margin-bottom: 8px;
background-color: #fcfcfc;
}
.user-summary-mobile .name {
font-weight: 600;
color: #181c32;
font-size: 14px;
}
.user-summary-mobile .email {
font-size: 12px;
color: #a1a5b7;
}
.dropdown-divider {
height: 1px;
background: #e4e6ef;
margin: 8px 0;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
color: #5e6278;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.dropdown-item:hover {
background-color: #f5f8fa;
color: #181c32;
}
.dropdown-item.logout {
color: #f64e60;
}
.dropdown-item.logout:hover {
background-color: #ffe2e5;
}
.dropdown-item i {
font-size: 16px;
width: 20px;
text-align: center;
}
/* Dropdown Animation */
.dropdown-fade-enter-active {
transition: opacity 0.3s, transform 0.3s;
}
.dropdown-fade-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.dropdown-fade-enter-from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
.dropdown-fade-leave-to {
opacity: 0;
transform: translateY(-5px) scale(0.98);
}
.hamburger-btn {
display: none;
background: transparent;
border: none;
font-size: 20px;
color: #5e6278;
cursor: pointer;
margin-right: 16px;
padding: 4px;
}
@media (max-width: 768px) {
.header {
padding: 0 16px;
height: 64px;
}
.hamburger-btn {
display: block;
}
.search-bar {
display: none; /* Hide search bar on mobile for simplicity, or make it an icon */
}
.action-icons {
display: none; /* Hide action icons on mobile to save space */
}
.page-title {
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,164 @@
<template>
<transition name="modal-fade">
<div v-if="visible" class="modal-overlay" @click.self="handleClose">
<div class="modal-container">
<div class="modal-header">
<h3>{{ title }}</h3>
<button class="close-btn" @click="handleClose">×</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="handleClose">取消</button>
<button class="btn-confirm" @click="handleConfirm">确定</button>
</div>
</div>
</div>
</transition>
</template>
<script setup>
defineProps({
visible: Boolean,
title: String
})
const emit = defineEmits(['update:visible', 'confirm'])
const handleClose = () => {
emit('update:visible', false)
}
const handleConfirm = () => {
emit('confirm')
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-container {
background-color: #fff;
border-radius: 12px;
width: auto;
min-width: 600px;
max-width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
animation: slide-up 0.3s ease-out;
}
@media (max-width: 768px) {
.modal-container {
min-width: 90%;
}
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #f0f2f5;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #1e1e2d;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
color: #a1a5b7;
cursor: pointer;
transition: color 0.2s;
}
.close-btn:hover {
color: #5e6278;
}
.modal-body {
padding: 24px;
max-height: 70vh;
overflow-y: auto;
}
.modal-footer {
padding: 20px 24px;
border-top: 1px solid #f0f2f5;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn-cancel {
padding: 10px 20px;
border-radius: 6px;
border: 1px solid #e4e6ef;
background-color: #fff;
color: #5e6278;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-cancel:hover {
background-color: #f5f8fa;
color: #1e1e2d;
}
.btn-confirm {
padding: 10px 20px;
border-radius: 6px;
border: none;
background-color: #3699ff;
color: #fff;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-confirm:hover {
background-color: #0073e9;
}
/* Animations */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.3s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
@keyframes slide-up {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<div class="notification-dropdown">
<div class="dropdown-header">
<h3>通知</h3>
<button class="mark-read-btn" @click="markAllAsRead" v-if="hasUnread">全部已读</button>
</div>
<div class="notification-list">
<div
v-for="item in notifications"
:key="item.id"
class="notification-item"
:class="{ 'unread': !item.read }"
@click="markAsRead(item)"
>
<div class="icon-wrapper" :class="item.type">
<i class="fa-solid" :class="item.icon"></i>
</div>
<div class="content">
<p class="title">{{ item.title }}</p>
<span class="time">{{ item.time }}</span>
</div>
</div>
</div>
<div class="dropdown-footer">
<button class="view-all-btn">查看全部通知</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const emit = defineEmits(['update-unread'])
const notifications = ref([
{ id: 1, title: '系统更新成功', time: '5分钟前', type: 'success', icon: 'fa-check', read: false },
{ id: 2, title: '存储空间不足警告', time: '1小时前', type: 'warning', icon: 'fa-triangle-exclamation', read: false },
{ id: 3, title: '新用户注册: John Doe', time: '2小时前', type: 'info', icon: 'fa-user-plus', read: true },
{ id: 4, title: '收到新的反馈消息', time: '1天前', type: 'primary', icon: 'fa-envelope', read: true }
])
const hasUnread = computed(() => {
return notifications.value.some(n => !n.read)
})
const markAllAsRead = () => {
notifications.value.forEach(n => n.read = true)
emit('update-unread', false)
}
const markAsRead = (item) => {
if (!item.read) {
item.read = true
emit('update-unread', hasUnread.value)
}
}
</script>
<style scoped>
.notification-dropdown {
position: absolute;
top: calc(100% + 12px);
right: -80px; /* Adjust based on parent position */
width: 320px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
border: 1px solid #e4e6ef;
overflow: hidden;
z-index: 1000;
animation: dropdown-fade-in 0.2s ease-out;
}
@keyframes dropdown-fade-in {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.dropdown-header {
padding: 16px 20px;
border-bottom: 1px solid #f5f8fa;
display: flex;
justify-content: space-between;
align-items: center;
}
.dropdown-header h3 {
margin: 0;
font-size: 16px;
color: #181c32;
}
.mark-read-btn {
background: none;
border: none;
color: #07C160;
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.mark-read-btn:hover {
background-color: #e6f9f0;
}
.notification-list {
max-height: 300px;
overflow-y: auto;
}
.notification-item {
display: flex;
align-items: flex-start;
padding: 16px 20px;
border-bottom: 1px solid #f5f8fa;
cursor: pointer;
transition: background-color 0.2s;
}
.notification-item:hover {
background-color: #f9f9f9;
}
.notification-item.unread {
background-color: #fcfcfc;
}
.notification-item.unread .title {
font-weight: 600;
color: #181c32;
}
.icon-wrapper {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
}
.icon-wrapper.success { background-color: #e6f9f0; color: #07C160; }
.icon-wrapper.warning { background-color: #fff4de; color: #ffa800; }
.icon-wrapper.info { background-color: #e1f0ff; color: #3699ff; }
.icon-wrapper.primary { background-color: #f5f8fa; color: #5e6278; }
.content {
flex: 1;
}
.title {
margin: 0 0 4px;
font-size: 13px;
color: #5e6278;
line-height: 1.4;
}
.time {
font-size: 12px;
color: #a1a5b7;
}
.dropdown-footer {
padding: 12px;
text-align: center;
border-top: 1px solid #f5f8fa;
}
.view-all-btn {
background: none;
border: none;
color: #07C160;
font-size: 13px;
font-weight: 500;
cursor: pointer;
width: 100%;
padding: 8px;
border-radius: 6px;
}
.view-all-btn:hover {
background-color: #f5f8fa;
}
</style>

View File

@@ -0,0 +1,230 @@
<template>
<div class="pagination">
<div class="pagination-right">
<div class="total-count">
{{ total }}
</div>
<div class="page-size-selector">
<select :value="pageSize" @change="changePageSize($event.target.value)">
<option :value="7">7/</option>
<option :value="10">10/</option>
<option :value="20">20/</option>
<option :value="50">50/</option>
</select>
</div>
<div class="pagination-controls" v-if="totalPages > 0">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="changePage(currentPage - 1)"
>
<i class="fa-solid fa-chevron-left"></i>
</button>
<div class="page-numbers">
<button
v-for="page in pages"
:key="page"
class="page-num"
:class="{ active: currentPage === page }"
@click="changePage(page)"
>
{{ page }}
</button>
</div>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="changePage(currentPage + 1)"
>
<i class="fa-solid fa-chevron-right"></i>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
total: {
type: Number,
required: true
},
pageSize: {
type: Number,
default: 7
},
currentPage: {
type: Number,
required: true
}
})
const emit = defineEmits(['update:currentPage', 'update:pageSize'])
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
const pages = computed(() => {
const p = []
for (let i = 1; i <= totalPages.value; i++) {
p.push(i)
}
return p
})
const changePage = (page) => {
if (page >= 1 && page <= totalPages.value) {
emit('update:currentPage', page)
}
}
const changePageSize = (size) => {
const newSize = parseInt(size)
emit('update:pageSize', newSize)
emit('update:currentPage', 1)
}
</script>
<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 0;
padding: 24px;
border-top: 1px dashed #eff2f5;
background: #ffffff;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.pagination-right {
display: flex;
align-items: center;
gap: 20px;
}
.total-count {
color: #a1a5b7;
font-size: 14px;
font-weight: 500;
padding: 8px 16px;
background: #f5f8fa;
border-radius: 8px;
}
.page-size-selector {
display: flex;
align-items: center;
gap: 8px;
color: #7e8299;
font-size: 14px;
}
.page-size-selector select {
padding: 8px 12px;
border: 2px solid #e4e6ef;
border-radius: 8px;
color: #5e6278;
outline: none;
cursor: pointer;
background-color: #fff;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
}
.page-size-selector select:hover {
border-color: #07C160;
}
.page-size-selector select:focus {
border-color: #07C160;
box-shadow: 0 0 0 3px rgba(7, 193, 96, 0.1);
}
.pagination-controls {
display: flex;
align-items: center;
gap: 6px;
}
.page-btn,
.page-num {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: #7e8299;
cursor: pointer;
border-radius: 8px;
transition: all 0.3s;
font-weight: 600;
font-size: 14px;
}
.page-btn {
width: 36px;
height: 36px;
background: #f5f8fa;
border: 2px solid transparent;
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-btn:not(:disabled):hover {
background: #e6f9f0;
color: #07C160;
border-color: #07C160;
transform: translateY(-1px);
}
.page-btn:not(:disabled):active {
transform: translateY(0);
}
.page-numbers {
display: flex;
gap: 4px;
}
.page-num {
min-width: 36px;
height: 36px;
padding: 0 8px;
background: #f5f8fa;
border: 2px solid transparent;
}
.page-num:hover {
color: #07C160;
background: #e6f9f0;
border-color: #07C160;
transform: translateY(-1px);
}
.page-num:active {
transform: translateY(0);
}
.page-num.active {
background: linear-gradient(135deg, #07C160 0%, #05a050 100%);
color: #fff;
border-color: #07C160;
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.3);
}
.page-num.active:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(7, 193, 96, 0.4);
}
</style>

View File

@@ -0,0 +1,139 @@
<template>
<div class="settings-dropdown">
<div class="user-summary">
<div class="avatar">
<img src="https://ui-avatars.com/api/?name=Admin&background=E6F9F0&color=07C160" alt="Admin" />
</div>
<div class="info">
<div class="name">管理员</div>
<div class="email">admin@zkit.com</div>
</div>
</div>
<div class="menu-list">
<div class="menu-item">
<i class="fa-regular fa-user"></i>
<span>个人资料</span>
</div>
<div class="menu-item">
<i class="fa-solid fa-sliders"></i>
<span>账号设置</span>
</div>
<div class="menu-item">
<i class="fa-solid fa-palette"></i>
<span>主题设置</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item">
<i class="fa-regular fa-circle-question"></i>
<span>帮助中心</span>
</div>
<div class="menu-item logout">
<i class="fa-solid fa-right-from-bracket"></i>
<span>退出登录</span>
</div>
</div>
</div>
</template>
<script setup>
// Logic can be added here
</script>
<style scoped>
.settings-dropdown {
position: absolute;
top: calc(100% + 12px);
right: 0;
width: 240px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
border: 1px solid #e4e6ef;
overflow: hidden;
z-index: 1000;
animation: dropdown-fade-in 0.2s ease-out;
}
@keyframes dropdown-fade-in {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.user-summary {
padding: 20px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid #f5f8fa;
background-color: #fcfcfc;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.info {
display: flex;
flex-direction: column;
}
.name {
font-weight: 600;
color: #181c32;
font-size: 14px;
}
.email {
font-size: 12px;
color: #a1a5b7;
}
.menu-list {
padding: 8px 0;
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
color: #5e6278;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.menu-item:hover {
background-color: #f5f8fa;
color: #07C160;
}
.menu-item i {
width: 20px;
text-align: center;
font-size: 16px;
}
.menu-divider {
height: 1px;
background-color: #f5f8fa;
margin: 8px 0;
}
.menu-item.logout {
color: #f64e60;
}
.menu-item.logout:hover {
background-color: #ffe2e5;
}
</style>

View File

@@ -0,0 +1,350 @@
<template>
<aside class="sidebar" :class="{ 'collapsed': appStore.sidebarCollapsed, 'mobile-open': appStore.mobileSidebarOpen }">
<div class="logo">
<div class="logo-icon">
<i class="fa-solid fa-layer-group"></i>
</div>
<span class="logo-text" v-show="!appStore.sidebarCollapsed">Zkit管理系统</span>
<button class="collapse-btn-header" @click="appStore.toggleSidebar" title="收起菜单">
<i class="fa-solid" :class="appStore.sidebarCollapsed ? 'fa-angles-right' : 'fa-angles-left'"></i>
</button>
</div>
<!-- Mobile Tools (Search & Actions) -->
<div class="mobile-tools" v-show="appStore.mobileSidebarOpen">
<div class="mobile-search">
<i class="fa-solid fa-magnifying-glass search-icon"></i>
<input type="text" placeholder="搜索..." />
</div>
<div class="mobile-actions">
<button class="icon-btn">
<i class="fa-regular fa-bell"></i>
<span class="badge-dot"></span>
</button>
<button class="icon-btn">
<i class="fa-solid fa-gear"></i>
</button>
</div>
</div>
<nav class="nav-menu">
<router-link to="/" class="nav-item" title="仪表盘">
<span class="icon"><i class="fa-solid fa-chart-line"></i></span>
<span class="label" v-show="!appStore.sidebarCollapsed">仪表盘</span>
</router-link>
<div class="nav-section" v-show="!appStore.sidebarCollapsed">管理模块</div>
<div class="nav-section-divider" v-show="appStore.sidebarCollapsed"></div>
<router-link to="/users" class="nav-item" title="用户管理">
<span class="icon"><i class="fa-solid fa-users"></i></span>
<span class="label" v-show="!appStore.sidebarCollapsed">用户管理</span>
</router-link>
<router-link to="/tools" class="nav-item" title="工具管理">
<span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span>
<span class="label" v-show="!appStore.sidebarCollapsed">工具管理</span>
</router-link>
<router-link to="/announcements" class="nav-item" title="公告管理">
<span class="icon"><i class="fa-solid fa-bullhorn"></i></span>
<span class="label" v-show="!appStore.sidebarCollapsed">公告管理</span>
</router-link>
<router-link to="/faqs" class="nav-item" title="FAQ 管理">
<span class="icon"><i class="fa-solid fa-circle-question"></i></span>
<span class="label" v-show="!appStore.sidebarCollapsed">FAQ 管理</span>
</router-link>
<router-link to="/ads" class="nav-item" title="广告管理">
<span class="icon"><i class="fa-solid fa-rectangle-ad"></i></span>
<span class="label" v-show="!appStore.sidebarCollapsed">广告管理</span>
</router-link>
<router-link to="/footer" class="nav-item" title="页脚管理">
<span class="icon"><i class="fa-solid fa-shoe-prints"></i></span>
<span class="label" v-show="!appStore.sidebarCollapsed">页脚管理</span>
</router-link>
</nav>
</aside>
</template>
<script setup>
import { useAppStore } from '../stores/app'
const appStore = useAppStore()
</script>
<style scoped>
.sidebar {
width: 280px;
background: #ffffff;
color: #5e6278;
display: flex;
flex-direction: column;
transition: width 0.3s ease;
z-index: 100;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 0 20px rgba(0,0,0,0.05);
}
.sidebar.collapsed {
width: 80px;
}
.logo {
height: 80px;
display: flex;
align-items: center;
padding: 0 28px;
background-color: #ffffff;
white-space: nowrap;
overflow: hidden;
border-bottom: 1px solid #f5f8fa;
}
.sidebar.collapsed .logo {
padding: 0;
justify-content: center;
}
.logo-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #07C160 0%, #06ad56 100%);
color: #fff;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-right: 12px;
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.3);
flex-shrink: 0;
}
.sidebar.collapsed .logo-icon {
margin-right: 0;
}
.logo-text {
font-size: 20px;
font-weight: 700;
color: #181c32;
letter-spacing: 0.5px;
flex: 1;
}
.collapse-btn-header {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #a1a5b7;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
flex-shrink: 0;
}
.collapse-btn-header:hover {
background: #f5f8fa;
color: #07C160;
}
.sidebar.collapsed .collapse-btn-header {
margin-left: 0;
}
.nav-menu {
padding: 24px 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
overflow-x: hidden;
}
.nav-section {
padding: 16px 28px 8px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: #a1a5b7;
font-weight: 600;
white-space: nowrap;
}
.nav-section-divider {
height: 1px;
background-color: #f5f8fa;
margin: 16px 20px;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 28px;
transition: all 0.3s ease;
font-size: 14px;
font-weight: 500;
border-right: 3px solid transparent;
color: #5e6278;
white-space: nowrap;
cursor: pointer;
margin-right: 20px;
border-top-right-radius: 40px;
border-bottom-right-radius: 40px;
}
.sidebar.collapsed .nav-item {
padding: 12px 0;
justify-content: center;
margin-right: 0;
border-radius: 0;
border-right: none;
}
.nav-item .icon {
width: 24px;
margin-right: 12px;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.3s;
color: #a1a5b7;
}
.sidebar.collapsed .nav-item .icon {
margin-right: 0;
font-size: 20px;
}
.nav-item:hover {
background-color: #f5f8fa;
color: #07C160;
}
.nav-item:hover .icon {
color: #07C160;
}
.nav-item.router-link-exact-active {
background-color: #e6f9f0;
color: #07C160;
}
.sidebar.collapsed .nav-item.router-link-exact-active {
background-color: #e6f9f0;
}
.nav-item.router-link-exact-active .icon {
color: #07C160;
}
/* Scrollbar for sidebar */
.nav-menu::-webkit-scrollbar {
width: 4px;
}
.nav-menu::-webkit-scrollbar-thumb {
background: #e4e6ef;
border-radius: 2px;
}
.mobile-tools {
display: none;
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 280px !important; /* Always full width on mobile when open */
transform: translateX(-100%);
box-shadow: 0 0 40px rgba(0,0,0,0.1);
}
.sidebar.mobile-open {
transform: translateX(0);
}
.collapse-btn-header {
display: none; /* Hide collapse button on mobile, use backdrop to close */
}
.mobile-tools {
padding: 16px 24px;
border-bottom: 1px solid #f5f8fa;
display: flex;
flex-direction: column;
gap: 16px;
}
.mobile-search {
position: relative;
display: flex;
align-items: center;
}
.mobile-search .search-icon {
position: absolute;
left: 12px;
color: #a1a5b7;
font-size: 14px;
}
.mobile-search input {
width: 100%;
padding: 10px 16px 10px 38px;
border-radius: 8px;
border: 1px solid #e4e6ef;
background-color: #f5f8fa;
font-size: 13px;
outline: none;
color: #5e6278;
transition: all 0.3s;
}
.mobile-search input:focus {
background-color: #fff;
border-color: #07C160;
}
.mobile-actions {
display: flex;
gap: 12px;
}
.mobile-actions .icon-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e4e6ef;
background: transparent;
color: #a1a5b7;
border-radius: 8px;
cursor: pointer;
position: relative;
}
.mobile-actions .icon-btn:hover {
color: #07C160;
background-color: #f5f8fa;
border-color: #07C160;
}
.badge-dot {
position: absolute;
top: 8px;
right: 8px;
width: 6px;
height: 6px;
background-color: #f64e60;
border-radius: 50%;
}
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div class="main-layout">
<Sidebar />
<transition name="fade">
<div v-if="appStore.mobileSidebarOpen" class="mobile-backdrop" @click="appStore.closeMobileSidebar"></div>
</transition>
<div class="content-wrapper">
<Header />
<main class="main-content">
<router-view v-slot="{ Component, route }">
<transition name="fade" mode="out-in">
<component :is="Component" :key="route.fullPath" />
</transition>
</router-view>
</main>
</div>
</div>
</template>
<script setup>
import Sidebar from '../components/Sidebar.vue'
import Header from '../components/Header.vue'
import { useAppStore } from '../stores/app'
const appStore = useAppStore()
</script>
<style scoped>
.main-layout {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
background-color: #f5f8fa;
}
.content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0; /* Prevent flex overflow */
}
.main-content {
flex: 1;
padding: 32px;
overflow-y: auto;
}
/* Transition effects */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.mobile-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
backdrop-filter: blur(2px);
}
@media (max-width: 768px) {
.main-content {
padding: 16px;
}
}
</style>

13
Zkit-admin/src/main.js Normal file
View File

@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/base.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,57 @@
import { createRouter, createWebHistory } from 'vue-router'
import MainLayout from '../layout/MainLayout.vue'
import Dashboard from '../views/Dashboard.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
component: MainLayout,
children: [
{
path: '',
name: 'dashboard',
component: Dashboard
},
{
path: 'users',
name: 'users',
component: () => import('../views/Users.vue')
},
{
path: 'tools',
name: 'tools',
component: () => import('../views/Tools.vue')
},
{
path: 'announcements',
name: 'announcements',
component: () => import('../views/Announcements.vue')
},
{
path: 'faqs',
name: 'faqs',
component: () => import('../views/Faqs.vue')
},
{
path: 'ads',
name: 'ads',
component: () => import('../views/Ads.vue')
},
{
path: 'footer',
name: 'footer',
component: () => import('../views/Footer.vue')
}
]
},
{
path: '/login',
name: 'login',
component: () => import('../views/Login.vue')
}
]
})
export default router

View File

@@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref(false)
const mobileSidebarOpen = ref(false)
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
const toggleMobileSidebar = () => {
mobileSidebarOpen.value = !mobileSidebarOpen.value
}
const closeMobileSidebar = () => {
mobileSidebarOpen.value = false
}
return {
sidebarCollapsed,
mobileSidebarOpen,
toggleSidebar,
toggleMobileSidebar,
closeMobileSidebar
}
})

79
Zkit-admin/src/style.css Normal file
View File

@@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,433 @@
<template>
<div class="ads-page">
<div class="card">
<div class="card-header">
<h3>广告管理</h3>
<button class="btn-primary" @click="openAddModal">
<i class="fa-solid fa-plus"></i> 新增广告
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>描述</th>
<th>标签</th>
<th>显示位置</th>
<th>按钮文字</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(ad, index) in paginatedAds" :key="index">
<td>{{ (currentPage - 1) * pageSize + index + 1 }}</td>
<td>
<div class="ad-title-cell">
<div class="ad-icon-wrapper">
<i :class="ad.icon"></i>
</div>
<span class="ad-title">{{ ad.title }}</span>
</div>
</td>
<td class="desc-cell">{{ ad.desc }}</td>
<td><span class="badge tag">{{ ad.tag }}</span></td>
<td>
<div class="location-tags">
<span v-for="loc in ad.locations" :key="loc" class="badge location">
{{ getLocationName(loc) }}
</span>
</div>
</td>
<td>{{ ad.btnText }}</td>
<td>
<span class="badge status" :class="ad.status === 'active' ? 'bg-light-success' : 'bg-light-danger'">
{{ ad.status === 'active' ? '启用' : '停用' }}
</span>
</td>
<td>
<div class="actions">
<button class="action-btn edit" @click="openEditModal(ad, index)" title="编辑">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button class="action-btn delete" @click="deleteAd(index)" title="删除">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:total="ads.length"
v-model:page-size="pageSize"
v-model:current-page="currentPage"
/>
</div>
<!-- Ad Modal -->
<Modal
v-model:visible="modalVisible"
:title="isEdit ? '编辑广告' : '新增广告'"
@confirm="handleSave"
>
<form class="modal-form">
<div class="form-group">
<label>标题</label>
<input type="text" v-model="form.title" placeholder="请输入广告标题" />
</div>
<div class="form-group">
<label>描述</label>
<input type="text" v-model="form.desc" placeholder="请输入广告描述" />
</div>
<div class="form-group">
<label>标签</label>
<input type="text" v-model="form.tag" placeholder="例如: 广告, 推荐" />
</div>
<div class="form-group">
<label>图标 (FontAwesome Class)</label>
<div class="input-with-icon">
<input type="text" v-model="form.icon" placeholder="例如: fa-solid fa-server" />
<div class="icon-preview" v-if="form.icon">
<i :class="form.icon"></i>
</div>
</div>
</div>
<div class="form-group">
<label>显示位置</label>
<div class="checkbox-list">
<label class="checkbox-item">
<input type="checkbox" value="home" v-model="form.locations" />
首页
</label>
<label class="checkbox-item">
<input type="checkbox" value="all-tools" v-model="form.locations" />
全部工具
</label>
<label class="checkbox-item">
<input type="checkbox" value="faq" v-model="form.locations" />
常见问题
</label>
<label class="checkbox-item">
<input type="checkbox" value="about" v-model="form.locations" />
关于我们
</label>
</div>
</div>
<div class="form-group">
<label>链接</label>
<input type="text" v-model="form.link" placeholder="请输入跳转链接" />
</div>
<div class="form-group">
<label>按钮文字</label>
<input type="text" v-model="form.btnText" placeholder="例如: 抢购, 查看" />
</div>
<div class="form-group">
<label>状态</label>
<select v-model="form.status">
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
</div>
</form>
</Modal>
<!-- Confirm Delete Modal -->
<ConfirmModal
v-model:visible="confirmVisible"
title="确认删除"
message="确定要删除该广告吗?此操作无法撤销。"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import Modal from '../components/Modal.vue'
import ConfirmModal from '../components/ConfirmModal.vue'
import Pagination from '../components/Pagination.vue'
const ads = ref([
{
title: '云服务器',
desc: '新用户首年99元',
tag: '广告',
icon: 'fa-solid fa-server',
btnText: '抢购',
link: 'https://example.com/cloud',
locations: ['home', 'all-tools'],
status: 'active'
},
{
title: 'AI 绘画',
desc: '一键生成艺术画作',
tag: '推荐',
icon: 'fa-solid fa-palette',
btnText: '体验',
link: 'https://example.com/ai-art',
locations: ['all-tools', 'faq'],
status: 'active'
},
{
title: 'VIP 会员',
desc: '解锁全部高级功能',
tag: '推广',
icon: 'fa-solid fa-crown',
btnText: '立即开通',
link: '/vip',
locations: ['home', 'all-tools', 'faq', 'about'],
status: 'active'
},
])
const locationMap = {
'home': '首页',
'all-tools': '全部工具',
'faq': '常见问题',
'about': '关于我们'
}
const getLocationName = (id) => locationMap[id] || id
const currentPage = ref(1)
const pageSize = ref(7)
const paginatedAds = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return ads.value.slice(start, end)
})
const modalVisible = ref(false)
const isEdit = ref(false)
const editIndex = ref(-1)
const confirmVisible = ref(false)
const deleteIndex = ref(-1)
const form = reactive({
title: '',
desc: '',
tag: '',
icon: '',
btnText: '',
link: '',
locations: [],
status: 'active'
})
const openAddModal = () => {
isEdit.value = false
editIndex.value = -1
Object.assign(form, { title: '', desc: '', tag: '', icon: 'fa-solid fa-ad', btnText: '', link: '', locations: [], status: 'active' })
modalVisible.value = true
}
const openEditModal = (ad, index) => {
isEdit.value = true
// Calculate actual index based on pagination
const actualIndex = (currentPage.value - 1) * pageSize.value + index
editIndex.value = actualIndex
Object.assign(form, JSON.parse(JSON.stringify(ad)))
modalVisible.value = true
}
const handleSave = () => {
if (isEdit.value && editIndex.value !== -1) {
ads.value[editIndex.value] = { ...form }
} else {
ads.value.push({ ...form })
// Jump to the last page to see the new ad
const totalPages = Math.ceil(ads.value.length / pageSize.value)
currentPage.value = totalPages
}
modalVisible.value = false
}
const deleteAd = (index) => {
// Calculate actual index based on pagination
const actualIndex = (currentPage.value - 1) * pageSize.value + index
deleteIndex.value = actualIndex
confirmVisible.value = true
}
const confirmDelete = () => {
ads.value.splice(deleteIndex.value, 1)
deleteIndex.value = -1
// Adjust current page if empty
const totalPages = Math.ceil(ads.value.length / pageSize.value)
if (currentPage.value > totalPages && totalPages > 0) {
currentPage.value = totalPages
}
}
</script>
<style scoped>
.card-header {
padding: 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px dashed #eff2f5;
}
.card-header h3 {
font-size: 18px;
color: #181c32;
font-weight: 600;
margin: 0;
}
.table-container {
overflow-x: auto;
}
.ad-title-cell {
display: flex;
align-items: center;
gap: 12px;
}
.ad-icon-wrapper {
width: 40px;
height: 40px;
background-color: #e6f9f0;
color: #07C160;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.ad-title {
font-weight: 600;
color: #181c32;
}
.desc-cell {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #a1a5b7;
}
.badge.tag {
background-color: #f5f8fa;
color: #5e6278;
}
.location-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 200px;
}
.badge.location {
background-color: #f5f8fa;
color: #a1a5b7;
font-weight: 500;
}
.actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #b5b5c3;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.action-btn:hover {
background-color: #f5f8fa;
color: var(--primary);
}
.action-btn.delete:hover {
background-color: #ffe2e5;
color: #f64e60;
}
/* Form Styles */
.modal-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: #3f4254;
}
.form-group input,
.form-group select {
padding: 12px;
border: 1px solid #e4e6ef;
border-radius: 8px;
font-size: 14px;
color: #5e6278;
outline: none;
transition: border-color 0.2s;
background-color: #fff;
}
.form-group input:focus,
.form-group select:focus {
border-color: #07C160;
}
.input-with-icon {
position: relative;
}
.icon-preview {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #5e6278;
}
.checkbox-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #5e6278;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,462 @@
<template>
<div class="announcements-page">
<div class="card">
<div class="card-header">
<h3>公告管理</h3>
<button class="btn-primary" @click="openAddModal">
<i class="fa-solid fa-plus"></i> 发布公告
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>版本</th>
<th>特性数量</th>
<th>发布时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in paginatedAnnouncements" :key="item.id">
<td>#{{ item.id }}</td>
<td>
<span class="title-text">{{ item.title }}</span>
</td>
<td>
<span class="version-badge">{{ item.version }}</span>
</td>
<td>
<span class="feature-count">{{ item.features?.length || 0 }} </span>
</td>
<td>
<div class="date-cell">
<i class="fa-regular fa-calendar"></i>
<span>{{ item.date }}</span>
</div>
</td>
<td><span class="badge status" :class="getStatusClass(item.status)">{{ getStatusText(item.status) }}</span></td>
<td>
<div class="actions">
<button class="action-btn edit" @click="openEditModal(item)" title="编辑">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button class="action-btn delete" @click="deleteAnnouncement(item.id)" title="撤回/删除">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:total="announcements.length"
v-model:page-size="pageSize"
v-model:current-page="currentPage"
/>
</div>
<!-- Announcement Modal -->
<Modal
v-model:visible="modalVisible"
:title="isEdit ? '编辑公告' : '发布公告'"
@confirm="handleSave"
>
<form class="modal-form">
<div class="form-group">
<label>标题</label>
<input type="text" v-model="form.title" placeholder="例如:全新版本 V2.0 已发布" />
</div>
<div class="form-group">
<label>版本号</label>
<input type="text" v-model="form.version" placeholder="例如V2.0" />
</div>
<div class="form-group">
<label>存储键名 (Storage Key)</label>
<input type="text" v-model="form.storageKey" placeholder="例如has_seen_announcement_v2" />
<small class="form-hint">用于控制公告是否再次弹出修改此值可让已关闭的用户重新看到公告</small>
</div>
<div class="form-group">
<label>简介文本</label>
<textarea v-model="form.intro" placeholder="请输入公告简介" rows="2"></textarea>
</div>
<div class="form-group">
<label>特性列表</label>
<div class="features-editor">
<div v-for="(feature, index) in form.features" :key="index" class="feature-item">
<input type="text" v-model="feature.title" placeholder="特性标题" />
<input type="text" v-model="feature.desc" placeholder="特性描述" />
<select v-model="feature.color">
<option value="green">绿色</option>
<option value="blue">蓝色</option>
<option value="purple">紫色</option>
<option value="orange">橙色</option>
</select>
<button type="button" class="btn-remove" @click="removeFeature(index)">×</button>
</div>
<button type="button" class="btn-add-feature" @click="addFeature">+ 添加特性</button>
</div>
</div>
<div class="form-group">
<label>发布时间</label>
<input type="date" v-model="form.date" />
</div>
<div class="form-group">
<label>状态</label>
<select v-model="form.status">
<option value="published">已发布</option>
<option value="draft">草稿</option>
<option value="expired">已过期</option>
</select>
</div>
</form>
</Modal>
<!-- Confirm Delete Modal -->
<ConfirmModal
v-model:visible="confirmVisible"
title="确认删除"
message="确定要撤回/删除该公告吗?此操作无法撤销。"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import Modal from '../components/Modal.vue'
import ConfirmModal from '../components/ConfirmModal.vue'
import Pagination from '../components/Pagination.vue'
const announcements = ref([
{
id: 101,
title: '全新版本 V2.0 已发布',
version: 'V2.0',
storageKey: 'has_seen_announcement_v2',
intro: '为了提升您的工作效率,我们带来了一些令人兴奋的改进:',
features: [
{ title: 'PDF 工具箱', desc: '支持一键合并、拆分与压缩', color: 'green' },
{ title: '安全升级', desc: '全新的 MD5 加密算法,速度提升 50%', color: 'blue' },
{ title: '体验优化', desc: '修复移动端显示 bug更加丝滑', color: 'purple' }
],
date: '2025-11-20',
status: 'published'
},
{
id: 102,
title: '系统维护通知',
version: 'V1.5',
storageKey: 'has_seen_announcement_v1_5',
intro: '我们将于本周六凌晨进行服务器维护,预计耗时 2 小时。',
features: [],
date: '2025-11-15',
status: 'expired'
},
{
id: 103,
title: '新功能预告AI 写作',
version: 'V3.0',
storageKey: 'has_seen_announcement_v3',
intro: '即将上线 AI 写作助手,敬请期待!',
features: [
{ title: 'AI 写作', desc: '智能生成文章内容', color: 'purple' }
],
date: '2025-11-25',
status: 'draft'
},
])
const currentPage = ref(1)
const pageSize = ref(7)
const paginatedAnnouncements = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return announcements.value.slice(start, end)
})
const modalVisible = ref(false)
const isEdit = ref(false)
const confirmVisible = ref(false)
const deleteId = ref(null)
const form = reactive({
id: null,
title: '',
version: '',
storageKey: '',
intro: '',
features: [],
date: '',
status: 'draft'
})
const getStatusText = (status) => {
const map = {
published: '已发布',
expired: '已过期',
draft: '草稿'
}
return map[status] || status
}
const getStatusClass = (status) => {
const map = {
published: 'bg-light-success',
expired: 'bg-light-dark',
draft: 'bg-light-warning'
}
return map[status] || 'bg-light-dark'
}
const addFeature = () => {
form.features.push({ title: '', desc: '', color: 'green' })
}
const removeFeature = (index) => {
form.features.splice(index, 1)
}
const openAddModal = () => {
isEdit.value = false
Object.assign(form, {
id: null,
title: '',
version: '',
storageKey: '',
intro: '',
features: [],
date: new Date().toISOString().split('T')[0],
status: 'published'
})
modalVisible.value = true
}
const openEditModal = (item) => {
isEdit.value = true
Object.assign(form, item)
modalVisible.value = true
}
const handleSave = () => {
if (isEdit.value) {
const index = announcements.value.findIndex(a => a.id === form.id)
if (index !== -1) {
announcements.value[index] = { ...form }
}
} else {
const newId = Math.max(...announcements.value.map(a => a.id)) + 1
announcements.value.unshift({ ...form, id: newId })
// Jump to the first page to see the new announcement (since we unshift)
currentPage.value = 1
}
modalVisible.value = false
}
const deleteAnnouncement = (id) => {
deleteId.value = id
confirmVisible.value = true
}
const confirmDelete = () => {
announcements.value = announcements.value.filter(a => a.id !== deleteId.value)
deleteId.value = null
// Adjust current page if empty
const totalPages = Math.ceil(announcements.value.length / pageSize.value)
if (currentPage.value > totalPages && totalPages > 0) {
currentPage.value = totalPages
}
}
</script>
<style scoped>
.card-header {
padding: 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px dashed #eff2f5;
}
.card-header h3 {
font-size: 18px;
color: #181c32;
font-weight: 600;
margin: 0;
}
.table-container {
overflow-x: auto;
}
.title-text {
font-weight: 600;
color: #181c32;
}
.content-cell {
max-width: 400px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #a1a5b7;
}
.date-cell {
display: flex;
align-items: center;
gap: 6px;
color: #7e8299;
font-size: 13px;
}
.actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #b5b5c3;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.action-btn:hover {
background-color: #f5f8fa;
color: var(--primary);
}
.action-btn.delete:hover {
background-color: #ffe2e5;
color: #f64e60;
}
/* Form Styles */
.modal-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: #3f4254;
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 12px;
border: 1px solid #e4e6ef;
border-radius: 8px;
font-size: 14px;
color: #5e6278;
outline: none;
transition: border-color 0.2s;
background-color: #fff;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: #07C160;
}
.version-badge {
display: inline-block;
padding: 4px 10px;
background: linear-gradient(135deg, #07C160 0%, #05a050 100%);
color: #fff;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
}
.feature-count {
color: #a1a5b7;
font-size: 13px;
}
.form-hint {
font-size: 12px;
color: #a1a5b7;
margin-top: 4px;
}
.features-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.feature-item {
display: grid;
grid-template-columns: 1fr 1.5fr auto 32px;
gap: 8px;
align-items: center;
}
.feature-item input,
.feature-item select {
padding: 8px;
border: 1px solid #e4e6ef;
border-radius: 6px;
font-size: 13px;
}
.btn-remove {
width: 32px;
height: 32px;
border: none;
background: #ffe2e5;
color: #f64e60;
border-radius: 6px;
cursor: pointer;
font-size: 18px;
font-weight: bold;
transition: all 0.3s;
}
.btn-remove:hover {
background: #f64e60;
color: #fff;
}
.btn-add-feature {
padding: 10px;
border: 2px dashed #e4e6ef;
background: transparent;
color: #07C160;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
}
.btn-add-feature:hover {
border-color: #07C160;
background: #e6f9f0;
}
</style>

View File

@@ -0,0 +1,252 @@
<template>
<div class="dashboard">
<div class="stats-grid">
<div class="stat-card" v-for="(stat, index) in stats" :key="index">
<div class="stat-icon" :class="stat.bgClass">
<i :class="stat.icon"></i>
</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</div>
<div class="content-grid">
<div class="card chart-card">
<div class="card-header">
<h3>营收概览</h3>
<div class="card-actions">
<select class="select-sm">
<option>本周</option>
<option>本月</option>
<option>全年</option>
</select>
</div>
</div>
<div class="chart-placeholder">
<!-- Placeholder for a chart -->
<div class="bar" style="height: 60%"></div>
<div class="bar" style="height: 80%"></div>
<div class="bar" style="height: 40%"></div>
<div class="bar" style="height: 90%"></div>
<div class="bar" style="height: 70%"></div>
<div class="bar" style="height: 50%"></div>
<div class="bar" style="height: 75%"></div>
</div>
</div>
<div class="card activity-card">
<div class="card-header">
<h3>近期活动</h3>
</div>
<ul class="activity-list">
<li v-for="i in 5" :key="i">
<div class="timeline-badge"></div>
<div class="timeline-content">
<span class="activity-text">用户 #{{ 1000 + i }} 注册成功</span>
<span class="time">{{ i }}小时前</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const stats = ref([
{ label: '总用户数', value: '12,345', icon: 'fa-solid fa-users', bgClass: 'bg-light-primary text-primary' },
{ label: '总销售额', value: '¥45,678', icon: 'fa-solid fa-wallet', bgClass: 'bg-light-success text-success' },
{ label: '新订单', value: '890', icon: 'fa-solid fa-box-open', bgClass: 'bg-light-info text-info' },
{ label: '待处理事项', value: '12', icon: 'fa-solid fa-clipboard-list', bgClass: 'bg-light-warning text-warning' },
])
</script>
<style scoped>
.dashboard {
display: flex;
flex-direction: column;
gap: 24px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 24px;
}
.stat-card {
background: #fff;
padding: 24px;
border-radius: 12px;
display: flex;
align-items: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.03);
transition: all 0.3s ease;
border: 1px solid transparent;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0,0,0,0.08);
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-right: 20px;
}
.bg-light-primary { background-color: #e1f0ff; }
.text-primary { color: #3699ff; }
.bg-light-success { background-color: #c9f7f5; }
.text-success { color: #1bc5bd; }
.bg-light-info { background-color: #eee5ff; }
.text-info { color: #8950fc; }
.bg-light-warning { background-color: #fff4de; }
.text-warning { color: #ffa800; }
.stat-value {
font-size: 24px;
font-weight: 700;
color: #181c32;
margin-bottom: 4px;
line-height: 1.2;
}
.stat-label {
color: #b5b5c3;
font-size: 14px;
font-weight: 500;
}
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
}
.card {
background: #fff;
padding: 0;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.03);
overflow: hidden;
}
.card-header {
padding: 24px;
border-bottom: 1px solid #eff2f5;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
color: #181c32;
font-size: 18px;
font-weight: 600;
}
.select-sm {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #e4e6ef;
background-color: #f5f8fa;
color: #5e6278;
font-size: 12px;
outline: none;
}
.chart-placeholder {
height: 240px;
display: flex;
align-items: flex-end;
justify-content: space-around;
padding: 30px;
}
.bar {
width: 40px;
background: linear-gradient(180deg, #3699ff 0%, #0073e9 100%);
border-radius: 6px 6px 0 0;
opacity: 0.85;
transition: height 1s ease;
position: relative;
}
.bar:hover {
opacity: 1;
transform: scaleY(1.05);
transform-origin: bottom;
}
.activity-list {
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.activity-list li {
display: flex;
gap: 16px;
position: relative;
}
.activity-list li:not(:last-child)::after {
content: '';
position: absolute;
left: 5px;
top: 12px;
bottom: -24px;
width: 2px;
background-color: #eff2f5;
}
.timeline-badge {
width: 12px;
height: 12px;
border-radius: 50%;
border: 3px solid #fff;
background-color: #1bc5bd;
box-shadow: 0 0 0 1px #1bc5bd;
flex-shrink: 0;
margin-top: 4px;
z-index: 1;
}
.timeline-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.activity-text {
font-size: 14px;
color: #5e6278;
font-weight: 500;
}
.time {
color: #b5b5c3;
font-size: 12px;
}
@media (max-width: 1024px) {
.content-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,284 @@
<template>
<div class="faq-page">
<div class="card">
<div class="card-header">
<h3>FAQ 管理</h3>
<button class="btn-primary" @click="openAddModal">
<i class="fa-solid fa-plus"></i> 新增问题
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>问题</th>
<th>回答</th>
<th>排序</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in paginatedFaqs" :key="index">
<td>{{ (currentPage - 1) * pageSize + index + 1 }}</td>
<td class="question-cell">
<span class="question-text">{{ item.q }}</span>
</td>
<td class="answer-cell">{{ item.a }}</td>
<td>
<span class="sort-badge">{{ (currentPage - 1) * pageSize + index + 1 }}</span>
</td>
<td>
<div class="actions">
<button class="action-btn edit" @click="openEditModal(item, index)" title="编辑">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button class="action-btn delete" @click="deleteFaq(index)" title="删除">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:total="faqs.length"
v-model:page-size="pageSize"
v-model:current-page="currentPage"
/>
</div>
<!-- FAQ Modal -->
<Modal
v-model:visible="modalVisible"
:title="isEdit ? '编辑 FAQ' : '新增 FAQ'"
@confirm="handleSave"
>
<form class="modal-form">
<div class="form-group">
<label>问题</label>
<input type="text" v-model="form.q" placeholder="请输入问题" />
</div>
<div class="form-group">
<label>回答</label>
<textarea v-model="form.a" placeholder="请输入回答" rows="5"></textarea>
</div>
</form>
</Modal>
<!-- Confirm Delete Modal -->
<ConfirmModal
v-model:visible="confirmVisible"
title="确认删除"
message="确定要删除该 FAQ 吗?此操作无法撤销。"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import Modal from '../components/Modal.vue'
import ConfirmModal from '../components/ConfirmModal.vue'
import Pagination from '../components/Pagination.vue'
const faqs = ref([
{ q: 'OmniKit 需要下载安装吗?', a: '不需要。OmniKit 是完全基于浏览器的云端工具箱,您只需打开网页即可在 Windows、Mac 或手机上使用所有工具。' },
{ q: '我的文件安全吗?会泄露吗?', a: '非常安全。对于图片压缩、MD5 计算等工具,我们采用 WebAssembly 技术在您本地浏览器处理,文件从未上传至服务器。' },
{ q: '如何开通 VIP 会员?', a: '点击右上角的“登录”按钮,注册或登录账号后,在个人中心选择“升级 VIP”。支持微信和支付宝支付开通后即时生效。' },
{ q: 'VIP 有什么特权?', a: '1. 解锁 PDF 转 Word、视频处理等高算力工具2. 移除全站所有广告3. 优先获得极速服务器资源4. 专属客服支持。' },
{ q: '如果转换失败怎么办?', a: '如果遇到文件转换失败,通常是因为文件加密或损坏。您可以尝试重新上传,或者通过底部的“联系我们”发送邮件反馈。' },
{ q: '支持哪些文件格式?', a: '我们支持多种常见的文件格式,包括 PDF, Word, Excel, JPG, PNG 等。具体支持的格式请查看每个工具的说明。' },
{ q: '如何联系客服?', a: '您可以通过页面底部的“联系我们”链接发送邮件,或者在工作时间通过在线客服系统与我们联系。' },
{ q: '退款政策是怎样的?', a: '如果您对我们的服务不满意,可以在购买后 7 天内申请全额退款。请联系客服处理。' },
{ q: '可以开发票吗?', a: '支持开具电子发票。购买成功后,您可以在“我的订单”页面申请开票。' },
{ q: '账号可以多人使用吗?', a: 'VIP 账号仅限个人使用,不支持多人共享。系统检测到异常登录可能会冻结账号。' },
{ q: '有手机 App 吗?', a: '目前暂时没有原生 App但我们的网站是响应式设计完美适配手机浏览器您可以直接添加到主屏幕使用。' },
{ q: '如何修改密码?', a: '登录后,点击右上角头像进入“个人中心”,在“账号安全”选项卡中可以修改密码。' },
])
const currentPage = ref(1)
const pageSize = ref(7)
const paginatedFaqs = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return faqs.value.slice(start, end)
})
const modalVisible = ref(false)
const isEdit = ref(false)
const editIndex = ref(-1)
const confirmVisible = ref(false)
const deleteIndex = ref(-1)
const form = reactive({
q: '',
a: ''
})
const openAddModal = () => {
isEdit.value = false
editIndex.value = -1
Object.assign(form, { q: '', a: '' })
modalVisible.value = true
}
const openEditModal = (item, index) => {
isEdit.value = true
// Calculate actual index based on pagination
const actualIndex = (currentPage.value - 1) * pageSize.value + index
editIndex.value = actualIndex
Object.assign(form, item)
modalVisible.value = true
}
const handleSave = () => {
if (isEdit.value && editIndex.value !== -1) {
faqs.value[editIndex.value] = { ...form }
} else {
faqs.value.push({ ...form })
// Jump to the last page to see the new faq
const totalPages = Math.ceil(faqs.value.length / pageSize.value)
currentPage.value = totalPages
}
modalVisible.value = false
}
const deleteFaq = (index) => {
// Calculate actual index based on pagination
const actualIndex = (currentPage.value - 1) * pageSize.value + index
deleteIndex.value = actualIndex
confirmVisible.value = true
}
const confirmDelete = () => {
faqs.value.splice(deleteIndex.value, 1)
deleteIndex.value = -1
// Adjust current page if empty
const totalPages = Math.ceil(faqs.value.length / pageSize.value)
if (currentPage.value > totalPages && totalPages > 0) {
currentPage.value = totalPages
}
}
</script>
<style scoped>
.card-header {
padding: 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px dashed #eff2f5;
}
.card-header h3 {
font-size: 18px;
color: #181c32;
font-weight: 600;
margin: 0;
}
.table-container {
overflow-x: auto;
}
.question-text {
font-weight: 600;
color: #181c32;
}
.question-cell {
max-width: 250px;
}
.answer-cell {
max-width: 400px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #a1a5b7;
}
.sort-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background-color: #f5f8fa;
color: #5e6278;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #b5b5c3;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.action-btn:hover {
background-color: #f5f8fa;
color: var(--primary);
}
.action-btn.delete:hover {
background-color: #ffe2e5;
color: #f64e60;
}
/* Form Styles */
.modal-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: #3f4254;
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 12px;
border: 1px solid #e4e6ef;
border-radius: 8px;
font-size: 14px;
color: #5e6278;
outline: none;
transition: border-color 0.2s;
background-color: #fff;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: #07C160;
}
</style>

View File

@@ -0,0 +1,279 @@
<template>
<div class="footer-page">
<div class="card">
<div class="card-header">
<h3>页脚管理</h3>
<button class="btn-primary" @click="openAddModal">
<i class="fa-solid fa-plus"></i> 新增链接
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>链接</th>
<th>分类</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in paginatedLinks" :key="index">
<td>{{ (currentPage - 1) * pageSize + index + 1 }}</td>
<td>
<span class="title-text">{{ item.title }}</span>
</td>
<td class="link-cell">
<a :href="item.link" target="_blank">
<i class="fa-solid fa-arrow-up-right-from-square"></i>
{{ item.link }}
</a>
</td>
<td><span class="badge category">{{ item.category }}</span></td>
<td>
<div class="actions">
<button class="action-btn edit" @click="openEditModal(item, (currentPage - 1) * pageSize + index)" title="编辑">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button class="action-btn delete" @click="deleteLink((currentPage - 1) * pageSize + index)" title="删除">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:total="footerLinks.length"
v-model:page-size="pageSize"
v-model:current-page="currentPage"
/>
</div>
<!-- Footer Link Modal -->
<Modal
v-model:visible="modalVisible"
:title="isEdit ? '编辑链接' : '新增链接'"
@confirm="handleSave"
>
<form class="modal-form">
<div class="form-group">
<label>标题</label>
<input type="text" v-model="form.title" placeholder="请输入链接标题" />
</div>
<div class="form-group">
<label>链接</label>
<input type="text" v-model="form.link" placeholder="请输入跳转链接" />
</div>
<div class="form-group">
<label>分类</label>
<select v-model="form.category">
<option value="产品">产品</option>
<option value="资源">资源</option>
<option value="关于">关于</option>
<option value="法律">法律</option>
</select>
</div>
</form>
</Modal>
<!-- Confirm Delete Modal -->
<ConfirmModal
v-model:visible="confirmVisible"
title="确认删除"
message="确定要删除该链接吗?此操作无法撤销。"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import Modal from '../components/Modal.vue'
import ConfirmModal from '../components/ConfirmModal.vue'
import Pagination from '../components/Pagination.vue'
const footerLinks = ref([
{ title: '所有工具', link: '/tools', category: '产品' },
{ title: '更新日志', link: '/changelog', category: '产品' },
{ title: '使用文档', link: '/docs', category: '资源' },
{ title: '关于我们', link: '/about', category: '关于' },
{ title: '隐私政策', link: '/privacy', category: '法律' },
])
const currentPage = ref(1)
const pageSize = ref(7)
const paginatedLinks = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return footerLinks.value.slice(start, end)
})
const modalVisible = ref(false)
const isEdit = ref(false)
const editIndex = ref(-1)
const confirmVisible = ref(false)
const deleteIndex = ref(-1)
const form = reactive({
title: '',
link: '',
category: '产品'
})
const openAddModal = () => {
isEdit.value = false
editIndex.value = -1
Object.assign(form, { title: '', link: '', category: '产品' })
modalVisible.value = true
}
const openEditModal = (item, index) => {
isEdit.value = true
editIndex.value = index
Object.assign(form, item)
modalVisible.value = true
}
const handleSave = () => {
if (isEdit.value && editIndex.value !== -1) {
footerLinks.value[editIndex.value] = { ...form }
} else {
footerLinks.value.push({ ...form })
// Jump to the last page to see the new link
const totalPages = Math.ceil(footerLinks.value.length / pageSize.value)
currentPage.value = totalPages
}
modalVisible.value = false
}
const deleteLink = (index) => {
deleteIndex.value = index
confirmVisible.value = true
}
const confirmDelete = () => {
footerLinks.value.splice(deleteIndex.value, 1)
deleteIndex.value = -1
// Adjust current page if empty
const totalPages = Math.ceil(footerLinks.value.length / pageSize.value)
if (currentPage.value > totalPages && totalPages > 0) {
currentPage.value = totalPages
}
}
</script>
<style scoped>
.card-header {
padding: 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eff2f5;
}
.card-header h3 {
font-size: 18px;
color: #181c32;
font-weight: 600;
margin: 0;
}
.table-container {
overflow-x: auto;
}
.title-text {
font-weight: 600;
color: #181c32;
}
.link-cell a {
color: #3699ff;
text-decoration: none;
display: flex;
align-items: center;
gap: 6px;
}
.link-cell a:hover {
text-decoration: underline;
}
.badge.category {
background-color: #f5f8fa;
color: #5e6278;
font-weight: 600;
}
.actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
border: none;
background: #f5f8fa;
color: #a1a5b7;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.action-btn:hover {
background-color: #3699ff;
color: #fff;
}
.action-btn.delete:hover {
background-color: #f64e60;
color: #fff;
}
/* Form Styles */
.modal-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: #3f4254;
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 12px;
border: 1px solid #e4e6ef;
border-radius: 8px;
font-size: 14px;
color: #5e6278;
outline: none;
transition: border-color 0.2s;
background-color: #fff;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: #3699ff;
}
</style>

View File

@@ -0,0 +1,435 @@
<template>
<div class="login-page">
<div class="login-container">
<div class="login-left">
<div class="brand animate-fade-up">
<div class="logo-icon">
<i class="fa-solid fa-layer-group"></i>
</div>
<h1>Zkit Admin</h1>
</div>
<div class="welcome-text animate-fade-up delay-1">
<h2>欢迎回来</h2>
<p>请输入您的账号密码登录管理后台</p>
</div>
<form @submit.prevent="handleLogin" class="login-form animate-fade-up delay-2">
<div class="form-group">
<label>用户名</label>
<div class="input-wrapper">
<i class="fa-solid fa-user input-icon"></i>
<input type="text" v-model="username" placeholder="请输入用户名" />
</div>
</div>
<div class="form-group">
<label>密码</label>
<div class="input-wrapper">
<i class="fa-solid fa-lock input-icon"></i>
<input type="password" v-model="password" placeholder="请输入密码" />
</div>
</div>
<div class="form-options">
<label class="checkbox-label">
<input type="checkbox" />
<span>记住我</span>
</label>
<a href="#" class="forgot-password">忘记密码?</a>
</div>
<button type="submit" class="btn-login">
登录 <i class="fa-solid fa-arrow-right"></i>
</button>
</form>
</div>
<div class="login-right">
<div class="decoration">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
<div class="glass-card animate-float">
<i class="fa-solid fa-chart-pie icon-lg"></i>
<h3>数据可视化</h3>
<p>高效直观的数据分析体验</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const username = ref('')
const password = ref('')
const router = useRouter()
const handleLogin = () => {
// Mock login
router.push('/')
}
</script>
<style scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f0fdf4;
font-family: 'Inter', sans-serif;
background-image: radial-gradient(#07C160 0.5px, transparent 0.5px), radial-gradient(#07C160 0.5px, #f0fdf4 0.5px);
background-size: 20px 20px;
background-position: 0 0, 10px 10px;
background-attachment: fixed;
}
.login-container {
display: flex;
width: 1000px;
height: 600px;
background: #ffffff;
border-radius: 24px;
box-shadow: 0 20px 60px rgba(7, 193, 96, 0.15);
overflow: hidden;
position: relative;
z-index: 1;
}
.login-left {
flex: 1;
padding: 60px;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 40px;
}
.logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #07C160 0%, #05a050 100%);
color: #fff;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 8px 16px rgba(7, 193, 96, 0.2);
}
.brand h1 {
font-size: 24px;
font-weight: 800;
color: #181c32;
margin: 0;
letter-spacing: -0.5px;
}
.welcome-text {
margin-bottom: 32px;
}
.welcome-text h2 {
font-size: 32px;
font-weight: 800;
color: #181c32;
margin: 0 0 12px 0;
letter-spacing: -1px;
}
.welcome-text p {
color: #a1a5b7;
margin: 0;
font-size: 15px;
}
.login-form {
width: 100%;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #3f4254;
font-weight: 600;
font-size: 14px;
}
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: #a1a5b7;
font-size: 18px;
transition: color 0.3s;
}
input {
width: 100%;
padding: 16px 16px 16px 48px;
border: 2px solid #f1f1f4;
border-radius: 16px;
font-size: 15px;
color: #5e6278;
outline: none;
transition: all 0.3s ease;
background-color: #f9f9f9;
font-weight: 500;
box-sizing: border-box;
}
input:hover {
border-color: #e4e6ef;
}
input:focus {
border-color: #07C160;
background-color: #fff;
box-shadow: 0 0 0 4px rgba(7, 193, 96, 0.1);
}
input:focus + .input-icon {
color: #07C160;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: #7e8299;
font-weight: 500;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
border-radius: 6px;
border: 2px solid #d8d8e5;
appearance: none;
background-color: #fff;
cursor: pointer;
position: relative;
padding: 0;
}
.checkbox-label input[type="checkbox"]:checked {
background-color: #07C160;
border-color: #07C160;
}
.checkbox-label input[type="checkbox"]:checked::after {
content: '✔';
position: absolute;
color: white;
font-size: 12px;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.forgot-password {
font-size: 14px;
color: #07C160;
font-weight: 600;
transition: color 0.2s;
}
.forgot-password:hover {
color: #05a050;
text-decoration: underline;
}
.btn-login {
width: 100%;
padding: 18px;
background: linear-gradient(135deg, #07C160 0%, #05a050 100%);
color: #fff;
border: none;
border-radius: 16px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
box-shadow: 0 10px 20px rgba(7, 193, 96, 0.2);
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 15px 30px rgba(7, 193, 96, 0.3);
}
.btn-login:active {
transform: translateY(0);
}
/* Right Side Decoration */
.login-right {
flex: 1;
background: linear-gradient(135deg, #07C160 0%, #047857 100%);
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.decoration {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.circle {
position: absolute;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05));
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.circle-1 {
width: 400px;
height: 400px;
top: -100px;
right: -100px;
animation: pulse 10s infinite alternate;
}
.circle-2 {
width: 600px;
height: 600px;
bottom: -150px;
left: -150px;
animation: pulse 15s infinite alternate-reverse;
}
.glass-card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
padding: 48px;
border-radius: 32px;
border: 1px solid rgba(255, 255, 255, 0.2);
text-align: center;
color: #fff;
max-width: 340px;
z-index: 10;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
}
.icon-lg {
font-size: 56px;
margin-bottom: 24px;
display: block;
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.glass-card h3 {
font-size: 24px;
font-weight: 800;
margin: 0 0 12px 0;
letter-spacing: -0.5px;
}
.glass-card p {
font-size: 16px;
opacity: 0.9;
margin: 0;
line-height: 1.6;
font-weight: 500;
}
/* Animations */
.animate-fade-up {
animation: fadeInUp 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
opacity: 0;
transform: translateY(20px);
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.delay-1 {
animation-delay: 0.1s;
}
.delay-2 {
animation-delay: 0.2s;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.5;
}
100% {
transform: scale(1.1);
opacity: 0.8;
}
}
@media (max-width: 900px) {
.login-container {
width: 100%;
height: 100vh;
border-radius: 0;
}
.login-right {
display: none;
}
.login-left {
padding: 40px;
}
}
</style>

View File

@@ -0,0 +1,394 @@
<template>
<div class="tools-page">
<div class="card">
<div class="card-header">
<h3>工具管理</h3>
<button class="btn-primary" @click="openAddModal">
<i class="fa-solid fa-plus"></i> 新增工具
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>图标</th>
<th>标题</th>
<th>描述</th>
<th>分类</th>
<th>VIP</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(tool, index) in paginatedTools" :key="index">
<td>
<div class="icon-wrapper" :class="`icon-bg-${tool.color}`">
<i :class="tool.icon"></i>
</div>
</td>
<td>
<span class="tool-title">{{ tool.title }}</span>
</td>
<td class="desc-cell">{{ tool.desc }}</td>
<td>
<span class="badge category">{{ getCategoryName(tool.category) }}</span>
</td>
<td>
<span v-if="tool.vip" class="badge vip">
<i class="fa-solid fa-crown"></i> VIP
</span>
<span v-else class="badge free">免费</span>
</td>
<td>
<div class="actions">
<button class="action-btn edit" @click="openEditModal(tool, index)" title="编辑">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button class="action-btn delete" @click="deleteTool(index)" title="删除">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:total="tools.length"
v-model:page-size="pageSize"
v-model:current-page="currentPage"
/>
</div>
<!-- Tool Modal -->
<Modal
v-model:visible="modalVisible"
:title="isEdit ? '编辑工具' : '新增工具'"
@confirm="handleSave"
>
<form class="modal-form">
<div class="form-group">
<label>标题</label>
<input type="text" v-model="form.title" placeholder="请输入工具标题" />
</div>
<div class="form-group">
<label>描述</label>
<textarea v-model="form.desc" placeholder="请输入工具描述" rows="3"></textarea>
</div>
<div class="form-group">
<label>图标 (FontAwesome Class)</label>
<div class="input-with-icon">
<input type="text" v-model="form.icon" placeholder="例如: fa-solid fa-image" />
<div class="icon-preview" v-if="form.icon">
<i :class="form.icon"></i>
</div>
</div>
</div>
<div class="form-group">
<label>颜色</label>
<select v-model="form.color">
<option value="blue">蓝色</option>
<option value="rose">玫瑰红</option>
<option value="fuchsia">紫红色</option>
<option value="orange">橙色</option>
<option value="cyan">青色</option>
<option value="emerald">翠绿色</option>
<option value="violet">紫罗兰</option>
<option value="indigo">靛青色</option>
</select>
</div>
<div class="form-group">
<label>分类</label>
<select v-model="form.category">
<option value="media">媒体</option>
<option value="dev">开发</option>
<option value="crypto">加密</option>
<option value="life">生活</option>
</select>
</div>
<div class="form-group checkbox-group">
<label class="checkbox-label">
<input type="checkbox" v-model="form.vip" />
<span class="checkbox-text">设为 VIP 工具</span>
</label>
</div>
</form>
</Modal>
<!-- Confirm Delete Modal -->
<ConfirmModal
v-model:visible="confirmVisible"
title="确认删除"
message="确定要删除该工具吗?此操作无法撤销。"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import Modal from '../components/Modal.vue'
import ConfirmModal from '../components/ConfirmModal.vue'
import Pagination from '../components/Pagination.vue'
const tools = ref([
{ title: 'PDF 转 Word', desc: '精准保持排版,一键转换', icon: 'fa-solid fa-file-word', color: 'blue', vip: true, category: 'media' },
{ title: '图片压缩', desc: '智能压缩,保持清晰度', icon: 'fa-solid fa-image', color: 'rose', vip: false, category: 'media' },
{ title: '图片去底', desc: 'AI 自动识别主体抠图', icon: 'fa-solid fa-wand-magic-sparkles', color: 'fuchsia', vip: false, category: 'media' },
{ title: '视频转 GIF', desc: '截取片段生成表情包', icon: 'fa-solid fa-film', color: 'orange', vip: true, category: 'media' },
{ title: '二维码生成', desc: '自定义颜色、Logo嵌入', icon: 'fa-solid fa-qrcode', color: 'gray', vip: false, category: 'media' },
{ title: 'SQL 转 ER', desc: '一键生成数据库关系图', icon: 'fa-solid fa-database', color: 'cyan', vip: true, category: 'dev' },
{ title: 'JSON 格式化', desc: '校验、高亮、折叠代码', icon: 'fa-solid fa-code', color: 'emerald', vip: false, category: 'dev' },
{ title: 'MD5 加密', desc: '不可逆加密,支持多种盐值', icon: 'fa-solid fa-lock', color: 'indigo', vip: false, category: 'crypto' },
])
const categoryMap = {
'all': '全部',
'dev': '开发',
'media': '媒体',
'crypto': '加密',
'life': '生活'
}
const getCategoryName = (id) => categoryMap[id] || id
const currentPage = ref(1)
const pageSize = ref(7)
const paginatedTools = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return tools.value.slice(start, end)
})
const modalVisible = ref(false)
const isEdit = ref(false)
const editIndex = ref(-1)
const confirmVisible = ref(false)
const deleteIndex = ref(-1)
const form = reactive({
title: '',
desc: '',
icon: '',
color: 'blue',
category: 'dev',
vip: false
})
const openAddModal = () => {
isEdit.value = false
editIndex.value = -1
Object.assign(form, { title: '', desc: '', icon: 'fa-solid fa-tools', color: 'blue', category: 'dev', vip: false })
modalVisible.value = true
}
const openEditModal = (tool, index) => {
isEdit.value = true
// Calculate actual index based on pagination
const actualIndex = (currentPage.value - 1) * pageSize.value + index
editIndex.value = actualIndex
Object.assign(form, tool)
modalVisible.value = true
}
const handleSave = () => {
if (isEdit.value && editIndex.value !== -1) {
tools.value[editIndex.value] = { ...form }
} else {
tools.value.push({ ...form })
// Jump to the last page to see the new tool
const totalPages = Math.ceil(tools.value.length / pageSize.value)
currentPage.value = totalPages
}
modalVisible.value = false
}
const deleteTool = (index) => {
// Calculate actual index based on pagination
const actualIndex = (currentPage.value - 1) * pageSize.value + index
deleteIndex.value = actualIndex
confirmVisible.value = true
}
const confirmDelete = () => {
tools.value.splice(deleteIndex.value, 1)
deleteIndex.value = -1
// Adjust current page if empty
const totalPages = Math.ceil(tools.value.length / pageSize.value)
if (currentPage.value > totalPages && totalPages > 0) {
currentPage.value = totalPages
}
}
</script>
<style scoped>
.card-header {
padding: 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px dashed #eff2f5;
}
.card-header h3 {
font-size: 18px;
color: #181c32;
font-weight: 600;
margin: 0;
}
.table-container {
overflow-x: auto;
}
.icon-wrapper {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.icon-bg-blue { background: #e1f0ff; color: #3699ff; }
.icon-bg-rose { background: #ffe2e5; color: #f64e60; }
.icon-bg-fuchsia { background: #f8f5ff; color: #7239ea; }
.icon-bg-orange { background: #fff4de; color: #ffa800; }
.icon-bg-cyan { background: #c9f7f5; color: #1bc5bd; }
.icon-bg-emerald { background: #e6f9f0; color: #07C160; }
.icon-bg-violet { background: #eee5ff; color: #8950fc; }
.icon-bg-indigo { background: #e0e5ff; color: #50cd89; }
.icon-bg-gray { background: #f5f8fa; color: #a1a5b7; }
.tool-title {
font-weight: 600;
color: #181c32;
}
.desc-cell {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #a1a5b7;
}
.badge.category {
background-color: #f5f8fa;
color: #5e6278;
}
.badge.vip {
background-color: #fff4de;
color: #ffa800;
}
.badge.free {
background-color: #e6f9f0;
color: #07C160;
}
.actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #b5b5c3;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.action-btn:hover {
background-color: #f5f8fa;
color: var(--primary);
}
.action-btn.delete:hover {
background-color: #ffe2e5;
color: #f64e60;
}
/* Form Styles */
.modal-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: #3f4254;
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 12px;
border: 1px solid #e4e6ef;
border-radius: 8px;
font-size: 14px;
color: #5e6278;
outline: none;
transition: border-color 0.2s;
background-color: #fff;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: #07C160;
}
.input-with-icon {
position: relative;
}
.icon-preview {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #5e6278;
}
.checkbox-group {
flex-direction: row;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-text {
font-size: 14px;
color: #3f4254;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,295 @@
<template>
<div class="users">
<div class="card">
<div class="card-header">
<h3>所有用户</h3>
<button class="btn-primary" @click="openAddModal">
<i class="fa-solid fa-plus"></i> 新增用户
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>邮箱</th>
<th>角色</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in paginatedUsers" :key="user.id">
<td>#{{ user.id }}</td>
<td>
<span class="username-text">{{ user.name }}</span>
</td>
<td>{{ user.email }}</td>
<td><span class="badge role">{{ user.role }}</span></td>
<td><span class="badge status" :class="getStatusClass(user.status)">{{ getStatusText(user.status) }}</span></td>
<td>
<div class="actions">
<button class="action-btn edit" @click="openEditModal(user)" title="编辑">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button class="action-btn delete" @click="deleteUser(user.id)" title="删除">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:total="users.length"
v-model:page-size="pageSize"
v-model:current-page="currentPage"
/>
</div>
<!-- User Modal -->
<Modal
v-model:visible="modalVisible"
:title="isEdit ? '编辑用户' : '新增用户'"
@confirm="handleSave"
>
<form class="modal-form">
<div class="form-group">
<label>姓名</label>
<input type="text" v-model="form.name" placeholder="请输入姓名" />
</div>
<div class="form-group">
<label>邮箱</label>
<input type="email" v-model="form.email" placeholder="请输入邮箱" />
</div>
<div class="form-group">
<label>角色</label>
<select v-model="form.role">
<option value="管理员">管理员</option>
<option value="编辑">编辑</option>
<option value="访客">访客</option>
</select>
</div>
<div class="form-group">
<label>状态</label>
<select v-model="form.status">
<option value="active">正常</option>
<option value="inactive">停用</option>
<option value="pending">待审核</option>
</select>
</div>
</form>
</Modal>
<!-- Confirm Delete Modal -->
<ConfirmModal
v-model:visible="confirmVisible"
title="确认删除"
message="确定要删除该用户吗?此操作无法撤销。"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import Modal from '../components/Modal.vue'
import ConfirmModal from '../components/ConfirmModal.vue'
import Pagination from '../components/Pagination.vue'
const users = ref([
{ id: 1, name: 'John Doe', email: 'john@example.com', role: '管理员', status: 'active' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', role: '编辑', status: 'active' },
{ id: 3, name: 'Bob Johnson', email: 'bob@example.com', role: '访客', status: 'inactive' },
{ id: 4, name: 'Alice Williams', email: 'alice@example.com', role: '编辑', status: 'active' },
{ id: 5, name: 'Charlie Brown', email: 'charlie@example.com', role: '访客', status: 'pending' },
{ id: 6, name: 'David Lee', email: 'david@example.com', role: '访客', status: 'active' },
{ id: 7, name: 'Eva Green', email: 'eva@example.com', role: '编辑', status: 'inactive' },
{ id: 8, name: 'Frank White', email: 'frank@example.com', role: '管理员', status: 'active' },
{ id: 9, name: 'Grace Black', email: 'grace@example.com', role: '访客', status: 'pending' },
{ id: 10, name: 'Henry Ford', email: 'henry@example.com', role: '编辑', status: 'active' },
{ id: 11, name: 'Ivy Wilson', email: 'ivy@example.com', role: '访客', status: 'inactive' },
{ id: 12, name: 'Jack Brown', email: 'jack@example.com', role: '管理员', status: 'active' },
])
const currentPage = ref(1)
const pageSize = ref(7)
const paginatedUsers = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return users.value.slice(start, end)
})
const modalVisible = ref(false)
const isEdit = ref(false)
const confirmVisible = ref(false)
const deleteId = ref(null)
const form = reactive({
id: null,
name: '',
email: '',
role: '访客',
status: 'active'
})
const getStatusText = (status) => {
const map = {
active: '正常',
inactive: '停用',
pending: '待审核'
}
return map[status] || status
}
const getStatusClass = (status) => {
const map = {
active: 'bg-light-success',
inactive: 'bg-light-danger',
pending: 'bg-light-warning'
}
return map[status] || 'bg-light-dark'
}
const openAddModal = () => {
isEdit.value = false
Object.assign(form, { id: null, name: '', email: '', role: '访客', status: 'active' })
modalVisible.value = true
}
const openEditModal = (user) => {
isEdit.value = true
Object.assign(form, user)
modalVisible.value = true
}
const handleSave = () => {
if (isEdit.value) {
const index = users.value.findIndex(u => u.id === form.id)
if (index !== -1) {
users.value[index] = { ...form }
}
} else {
const newId = Math.max(...users.value.map(u => u.id)) + 1
users.value.push({ ...form, id: newId })
const totalPages = Math.ceil(users.value.length / pageSize.value)
currentPage.value = totalPages
}
modalVisible.value = false
}
const deleteUser = (id) => {
deleteId.value = id
confirmVisible.value = true
}
const confirmDelete = () => {
users.value = users.value.filter(u => u.id !== deleteId.value)
deleteId.value = null
const totalPages = Math.ceil(users.value.length / pageSize.value)
if (currentPage.value > totalPages && totalPages > 0) {
currentPage.value = totalPages
}
}
</script>
<style scoped>
.card-header {
padding: 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px dashed #eff2f5;
}
.card-header h3 {
font-size: 18px;
color: #181c32;
font-weight: 600;
margin: 0;
}
.table-container {
overflow-x: auto;
}
.username-text {
font-weight: 600;
color: #181c32;
font-size: 14px;
}
.badge.role {
background-color: #f5f8fa;
color: #5e6278;
font-weight: 600;
}
.actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #b5b5c3;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.action-btn:hover {
background-color: #f5f8fa;
color: var(--primary);
}
.action-btn.delete:hover {
background-color: #ffe2e5;
color: #f64e60;
}
/* Form Styles */
.modal-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: #3f4254;
}
.form-group input,
.form-group select {
padding: 12px;
border: 1px solid #e4e6ef;
border-radius: 8px;
font-size: 14px;
color: #5e6278;
outline: none;
transition: border-color 0.2s;
background-color: #fff;
}
.form-group input:focus,
.form-group select:focus {
border-color: #07C160;
}
</style>

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})