初始化项目
This commit is contained in:
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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).
|
||||
@@ -0,0 +1,247 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VueAdmin - 管理后台</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
z-index: 9999;
|
||||
transition: opacity 0.5s ease-in-out, visibility 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.loading-container.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
margin-bottom: 40px;
|
||||
animation: logoFloat 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-logo img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@keyframes logoFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #ffffff;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
}
|
||||
|
||||
.loading-subtitle {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 16px;
|
||||
margin-bottom: 50px;
|
||||
animation: fadeInUp 0.8s ease-out 0.2s backwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
position: relative;
|
||||
animation: spinnerRotate 2s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-circle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 4px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: #ffffff;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.spinner-circle:nth-child(2) {
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
top: 15%;
|
||||
left: 15%;
|
||||
border-top-color: rgba(255, 255, 255, 0.8);
|
||||
animation: spinnerRotate 1.5s linear infinite reverse;
|
||||
}
|
||||
|
||||
.spinner-circle:nth-child(3) {
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
top: 30%;
|
||||
left: 30%;
|
||||
border-top-color: #ffffff;
|
||||
animation: spinnerRotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spinnerRotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-tips {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 200px;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
margin-top: 30px;
|
||||
overflow: hidden;
|
||||
animation: fadeInUp 0.8s ease-out 0.4s backwards;
|
||||
}
|
||||
|
||||
.progress-bar-inner {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 3px;
|
||||
animation: progressLoading 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progressLoading {
|
||||
0% {
|
||||
width: 0%;
|
||||
margin-left: 0;
|
||||
}
|
||||
50% {
|
||||
width: 70%;
|
||||
margin-left: 15%;
|
||||
}
|
||||
100% {
|
||||
width: 0%;
|
||||
margin-left: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 加载页面 -->
|
||||
<div id="loading" class="loading-container">
|
||||
<div class="loading-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 50px; height: 50px;">
|
||||
<rect width="24" height="24" rx="4" fill="#667eea"/>
|
||||
<path d="M7 8H17V10H7V8Z" fill="white"/>
|
||||
<path d="M7 11H17V13H7V11Z" fill="white"/>
|
||||
<path d="M7 14H14V16H7V14Z" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="loading-text">VueAdmin</div>
|
||||
<div class="loading-subtitle">正在加载管理后台...</div>
|
||||
<div class="spinner">
|
||||
<div class="spinner-circle"></div>
|
||||
<div class="spinner-circle"></div>
|
||||
<div class="spinner-circle"></div>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-inner"></div>
|
||||
</div>
|
||||
<div class="loading-tips">首次加载可能需要几秒钟,请耐心等待</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue 应用挂载点 -->
|
||||
<div id="app"></div>
|
||||
|
||||
<!-- 隐藏加载页面的脚本 -->
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(function() {
|
||||
var loading = document.getElementById('loading');
|
||||
if (loading) {
|
||||
loading.classList.add('hidden');
|
||||
setTimeout(function() {
|
||||
if (loading.parentNode) {
|
||||
loading.parentNode.removeChild(loading);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@ckeditor/ckeditor5-vue": "^7.3.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.13.4",
|
||||
"ckeditor5": "^47.4.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"echarts": "^6.0.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^5.0.2",
|
||||
"vuedraggable": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-plugin-vue": "^10.7.0",
|
||||
"globals": "^17.3.0",
|
||||
"prettier": "^3.8.1",
|
||||
"sass-embedded": "^1.97.3",
|
||||
"vite": "^7.2.4",
|
||||
"vite-plugin-vue-devtools": "^8.0.6",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import { onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18nStore } from './stores/modules/i18n'
|
||||
import { useLayoutStore } from './stores/modules/layout'
|
||||
import { theme } from 'ant-design-vue'
|
||||
import i18n from './i18n'
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
import enUS from 'ant-design-vue/es/locale/en_US'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/en'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'App'
|
||||
})
|
||||
|
||||
// i18n store
|
||||
const i18nStore = useI18nStore()
|
||||
|
||||
// layout store
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
// 解构 themeColor 以确保响应式
|
||||
const { themeColor } = storeToRefs(layoutStore)
|
||||
|
||||
// Ant Design Vue 语言配置
|
||||
const antLocale = computed(() => {
|
||||
return i18nStore.currentLocale === 'zh-CN' ? zhCN : enUS
|
||||
})
|
||||
|
||||
// 获取弹出容器
|
||||
const getPopupContainer = () => {
|
||||
return document.body
|
||||
}
|
||||
|
||||
// Ant Design Vue 主题配置
|
||||
const antdTheme = computed(() => {
|
||||
return {
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: themeColor.value || '#1890ff',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
headerBg: '#fff',
|
||||
siderBg: '#001529',
|
||||
},
|
||||
Menu: {
|
||||
darkItemBg: '#001529',
|
||||
darkItemSelectedBg: themeColor.value || '#1890ff',
|
||||
darkItemHoverBg: '#002140',
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// 监听主题颜色变化,更新 CSS 变量
|
||||
watch(
|
||||
themeColor,
|
||||
(newColor) => {
|
||||
if (newColor) {
|
||||
document.documentElement.style.setProperty('--primary-color', newColor)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
|
||||
// 从持久化的 store 中读取语言设置并同步到 i18n
|
||||
i18n.global.locale.value = i18nStore.currentLocale
|
||||
|
||||
// 同步 dayjs 语言
|
||||
dayjs.locale(i18nStore.currentLocale === 'zh-CN' ? 'zh-cn' : 'en')
|
||||
|
||||
// 初始化主题颜色到 CSS 变量
|
||||
if (layoutStore.themeColor) {
|
||||
document.documentElement.style.setProperty('--primary-color', layoutStore.themeColor)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :locale="antLocale" :theme="antdTheme" :getPopupContainer="getPopupContainer">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,302 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export default {
|
||||
// 认证相关
|
||||
login: {
|
||||
post: async function (params) {
|
||||
return await request.post('auth/login', params)
|
||||
},
|
||||
},
|
||||
logout: {
|
||||
post: async function () {
|
||||
return await request.post('auth/logout')
|
||||
},
|
||||
},
|
||||
refresh: {
|
||||
post: async function () {
|
||||
return await request.post('auth/refresh')
|
||||
},
|
||||
},
|
||||
me: {
|
||||
get: async function () {
|
||||
return await request.get('auth/me')
|
||||
},
|
||||
},
|
||||
changePassword: {
|
||||
post: async function (params) {
|
||||
return await request.post('auth/change-password', params)
|
||||
},
|
||||
},
|
||||
|
||||
// 用户管理
|
||||
users: {
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('users', { params })
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`users/${id}`)
|
||||
},
|
||||
},
|
||||
add: {
|
||||
post: async function (params) {
|
||||
return await request.post('users', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
put: async function (id, params) {
|
||||
return await request.put(`users/${id}`, params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`users/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('users/batch-delete', params)
|
||||
},
|
||||
},
|
||||
batchStatus: {
|
||||
post: async function (params) {
|
||||
return await request.post('users/batch-status', params)
|
||||
},
|
||||
},
|
||||
batchDepartment: {
|
||||
post: async function (params) {
|
||||
return await request.post('users/batch-department', params)
|
||||
},
|
||||
},
|
||||
batchRoles: {
|
||||
post: async function (params) {
|
||||
return await request.post('users/batch-roles', params)
|
||||
},
|
||||
},
|
||||
export: {
|
||||
post: async function (params) {
|
||||
return await request.post('users/export', params, { responseType: 'blob' })
|
||||
},
|
||||
},
|
||||
import: {
|
||||
post: async function (formData) {
|
||||
return await request.post('users/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
},
|
||||
downloadTemplate: {
|
||||
get: async function () {
|
||||
return await request.get('users/download-template', { responseType: 'blob' })
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 在线用户管理
|
||||
onlineUsers: {
|
||||
count: {
|
||||
get: async function () {
|
||||
return await request.get('online-users/count')
|
||||
},
|
||||
},
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('online-users', { params })
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
get: async function (userId) {
|
||||
return await request.get(`online-users/${userId}/sessions`)
|
||||
},
|
||||
},
|
||||
offline: {
|
||||
post: async function (userId, params) {
|
||||
return await request.post(`online-users/${userId}/offline`, params)
|
||||
},
|
||||
},
|
||||
offlineAll: {
|
||||
post: async function (userId) {
|
||||
return await request.post(`online-users/${userId}/offline-all`)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 角色管理
|
||||
roles: {
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('roles', { params })
|
||||
},
|
||||
},
|
||||
all: {
|
||||
get: async function () {
|
||||
return await request.get('roles/all')
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`roles/${id}`)
|
||||
},
|
||||
},
|
||||
add: {
|
||||
post: async function (params) {
|
||||
return await request.post('roles', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
put: async function (id, params) {
|
||||
return await request.put(`roles/${id}`, params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`roles/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('roles/batch-delete', params)
|
||||
},
|
||||
},
|
||||
batchStatus: {
|
||||
post: async function (params) {
|
||||
return await request.post('roles/batch-status', params)
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
get: async function (id) {
|
||||
return await request.get(`roles/${id}/permissions`)
|
||||
},
|
||||
post: async function (id, params) {
|
||||
return await request.post(`roles/${id}/permissions`, params)
|
||||
},
|
||||
},
|
||||
copy: {
|
||||
post: async function (id, params) {
|
||||
return await request.post(`roles/${id}/copy`, params)
|
||||
},
|
||||
},
|
||||
batchCopy: {
|
||||
post: async function (params) {
|
||||
return await request.post('roles/batch-copy', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 权限管理
|
||||
permissions: {
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('permissions', { params })
|
||||
},
|
||||
},
|
||||
tree: {
|
||||
get: async function () {
|
||||
return await request.get('permissions/tree')
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
get: async function () {
|
||||
return await request.get('permissions/menu')
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`permissions/${id}`)
|
||||
},
|
||||
},
|
||||
add: {
|
||||
post: async function (params) {
|
||||
return await request.post('permissions', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
put: async function (id, params) {
|
||||
return await request.put(`permissions/${id}`, params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`permissions/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('permissions/batch-delete', params)
|
||||
},
|
||||
},
|
||||
batchStatus: {
|
||||
post: async function (params) {
|
||||
return await request.post('permissions/batch-status', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 部门管理
|
||||
departments: {
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('departments', { params })
|
||||
},
|
||||
},
|
||||
tree: {
|
||||
get: async function () {
|
||||
return await request.get('departments/tree')
|
||||
},
|
||||
},
|
||||
all: {
|
||||
get: async function () {
|
||||
return await request.get('departments/all')
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`departments/${id}`)
|
||||
},
|
||||
},
|
||||
add: {
|
||||
post: async function (params) {
|
||||
return await request.post('departments', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
put: async function (id, params) {
|
||||
return await request.put(`departments/${id}`, params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`departments/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('departments/batch-delete', params)
|
||||
},
|
||||
},
|
||||
batchStatus: {
|
||||
post: async function (params) {
|
||||
return await request.post('departments/batch-status', params)
|
||||
},
|
||||
},
|
||||
export: {
|
||||
post: async function (params) {
|
||||
return await request.post('departments/export', params, { responseType: 'blob' })
|
||||
},
|
||||
},
|
||||
import: {
|
||||
post: async function (formData) {
|
||||
return await request.post('departments/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
},
|
||||
downloadTemplate: {
|
||||
get: async function () {
|
||||
return await request.get('departments/download-template', { responseType: 'blob' })
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export default {
|
||||
// 系统配置管理
|
||||
configs: {
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('configs', { params })
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
get: async function () {
|
||||
return await request.get('configs/groups')
|
||||
},
|
||||
},
|
||||
all: {
|
||||
get: async function (params) {
|
||||
return await request.get('configs/all', { params })
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`configs/${id}`)
|
||||
},
|
||||
},
|
||||
add: {
|
||||
post: async function (params) {
|
||||
return await request.post('configs', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
put: async function (id, params) {
|
||||
return await request.put(`configs/${id}`, params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`configs/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('configs/batch-delete', params)
|
||||
},
|
||||
},
|
||||
batchStatus: {
|
||||
post: async function (params) {
|
||||
return await request.post('configs/batch-status', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 操作日志管理
|
||||
logs: {
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('logs', { params })
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`logs/${id}`)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`logs/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('logs/batch-delete', params)
|
||||
},
|
||||
},
|
||||
clear: {
|
||||
post: async function (params) {
|
||||
return await request.post('logs/clear', params)
|
||||
},
|
||||
},
|
||||
statistics: {
|
||||
get: async function (params) {
|
||||
return await request.get('logs/statistics', { params })
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 数据字典管理
|
||||
dictionaries: {
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('dictionaries', { params })
|
||||
},
|
||||
},
|
||||
all: {
|
||||
get: async function () {
|
||||
return await request.get('dictionaries/all')
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`dictionaries/${id}`)
|
||||
},
|
||||
},
|
||||
add: {
|
||||
post: async function (params) {
|
||||
return await request.post('dictionaries', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
put: async function (id, params) {
|
||||
return await request.put(`dictionaries/${id}`, params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`dictionaries/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('dictionaries/batch-delete', params)
|
||||
},
|
||||
},
|
||||
batchStatus: {
|
||||
post: async function (params) {
|
||||
return await request.post('dictionaries/batch-status', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 数据字典项管理
|
||||
dictionaryItems: {
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('dictionary-items', { params })
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`dictionary-items/${id}`)
|
||||
},
|
||||
},
|
||||
add: {
|
||||
post: async function (params) {
|
||||
return await request.post('dictionary-items', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
put: async function (id, params) {
|
||||
return await request.put(`dictionary-items/${id}`, params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`dictionary-items/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('dictionary-items/batch-delete', params)
|
||||
},
|
||||
},
|
||||
batchStatus: {
|
||||
post: async function (params) {
|
||||
return await request.post('dictionary-items/batch-status', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 任务管理
|
||||
tasks: {
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('tasks', { params })
|
||||
},
|
||||
},
|
||||
all: {
|
||||
get: async function () {
|
||||
return await request.get('tasks/all')
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`tasks/${id}`)
|
||||
},
|
||||
},
|
||||
add: {
|
||||
post: async function (params) {
|
||||
return await request.post('tasks', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
put: async function (id, params) {
|
||||
return await request.put(`tasks/${id}`, params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`tasks/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('tasks/batch-delete', params)
|
||||
},
|
||||
},
|
||||
batchStatus: {
|
||||
post: async function (params) {
|
||||
return await request.post('tasks/batch-status', params)
|
||||
},
|
||||
},
|
||||
run: {
|
||||
post: async function (id) {
|
||||
return await request.post(`tasks/${id}/run`)
|
||||
},
|
||||
},
|
||||
statistics: {
|
||||
get: async function () {
|
||||
return await request.get('tasks/statistics')
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 城市数据管理
|
||||
cities: {
|
||||
list: {
|
||||
get: async function (params) {
|
||||
return await request.get('cities', { params })
|
||||
},
|
||||
},
|
||||
tree: {
|
||||
get: async function () {
|
||||
return await request.get('cities/tree')
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`cities/${id}`)
|
||||
},
|
||||
},
|
||||
children: {
|
||||
get: async function (id) {
|
||||
return await request.get(`cities/${id}/children`)
|
||||
},
|
||||
},
|
||||
provinces: {
|
||||
get: async function () {
|
||||
return await request.get('cities/provinces')
|
||||
},
|
||||
},
|
||||
cities: {
|
||||
get: async function (provinceId) {
|
||||
return await request.get(`cities/${provinceId}/cities`)
|
||||
},
|
||||
},
|
||||
districts: {
|
||||
get: async function (cityId) {
|
||||
return await request.get(`cities/${cityId}/districts`)
|
||||
},
|
||||
},
|
||||
add: {
|
||||
post: async function (params) {
|
||||
return await request.post('cities', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
put: async function (id, params) {
|
||||
return await request.put(`cities/${id}`, params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
delete: async function (id) {
|
||||
return await request.delete(`cities/${id}`)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('cities/batch-delete', params)
|
||||
},
|
||||
},
|
||||
batchStatus: {
|
||||
post: async function (params) {
|
||||
return await request.post('cities/batch-status', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 文件上传管理
|
||||
upload: {
|
||||
single: {
|
||||
post: async function (formData) {
|
||||
return await request.post('upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
},
|
||||
multiple: {
|
||||
post: async function (formData) {
|
||||
return await request.post('upload/multiple', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
},
|
||||
base64: {
|
||||
post: async function (params) {
|
||||
return await request.post('upload/base64', params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
post: async function (params) {
|
||||
return await request.post('upload/delete', params)
|
||||
},
|
||||
},
|
||||
batchDelete: {
|
||||
post: async function (params) {
|
||||
return await request.post('upload/batch-delete', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 公共接口 (无需认证)
|
||||
public: {
|
||||
configs: {
|
||||
all: {
|
||||
get: async function () {
|
||||
return await request.get('system/configs')
|
||||
},
|
||||
},
|
||||
group: {
|
||||
get: async function (params) {
|
||||
return await request.get('system/configs/group', { params })
|
||||
},
|
||||
},
|
||||
key: {
|
||||
get: async function (params) {
|
||||
return await request.get('system/configs/key', { params })
|
||||
},
|
||||
},
|
||||
},
|
||||
dictionaries: {
|
||||
all: {
|
||||
get: async function () {
|
||||
return await request.get('system/dictionaries')
|
||||
},
|
||||
},
|
||||
code: {
|
||||
get: async function (params) {
|
||||
return await request.get('system/dictionaries/code', { params })
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`system/dictionaries/${id}`)
|
||||
},
|
||||
},
|
||||
},
|
||||
cities: {
|
||||
tree: {
|
||||
get: async function () {
|
||||
return await request.get('system/cities/tree')
|
||||
},
|
||||
},
|
||||
provinces: {
|
||||
get: async function () {
|
||||
return await request.get('system/cities/provinces')
|
||||
},
|
||||
},
|
||||
cities: {
|
||||
get: async function (provinceId) {
|
||||
return await request.get(`system/cities/${provinceId}/cities`)
|
||||
},
|
||||
},
|
||||
districts: {
|
||||
get: async function (cityId) {
|
||||
return await request.get(`system/cities/${cityId}/districts`)
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
get: async function (id) {
|
||||
return await request.get(`system/cities/${id}`)
|
||||
},
|
||||
},
|
||||
},
|
||||
upload: {
|
||||
post: async function (formData) {
|
||||
return await request.post('system/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,164 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
// ==================== 全局滚动条样式优化 ====================
|
||||
// Webkit 滚动条基础样式
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
// 滚动条轨道
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 8px;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
// 滚动条滑块 - 渐变色设计
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #d9d9d9 0%, #bfbfbf 100%);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(180deg, #c0c0c0 0%, #a6a6a6 100%);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: linear-gradient(180deg, #a6a6a6 0%, #8c8c8c 100%);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动条两端按钮
|
||||
::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 滚动条角落
|
||||
::-webkit-scrollbar-corner {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// Firefox 滚动条样式
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d4d4d4 rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.pages {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #ffffff;
|
||||
|
||||
.tool-bar {
|
||||
padding: 12px 16px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.left-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
|
||||
:deep(.ant-form) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label) {
|
||||
min-width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 按钮组样式
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 搜索输入框样式
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select-selector) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// 按钮样式优化
|
||||
:deep(.ant-btn) {
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 主按钮特殊样式
|
||||
:deep(.ant-btn-primary) {
|
||||
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// 危险按钮样式
|
||||
:deep(.ant-btn-dangerous) {
|
||||
&:hover {
|
||||
background: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
// 认证页面统一样式文件
|
||||
// 使用明亮暖色调配色方案
|
||||
|
||||
// ===== 颜色变量 =====
|
||||
$primary-color: #ff6b35; // 橙红色
|
||||
$primary-light: #ff8a5b; // 浅橙红色
|
||||
$primary-dark: #e55a2b; // 深橙红色
|
||||
$secondary-color: #ffd93d; // 金黄色
|
||||
$accent-color: #ffb84d; // 橙黄色
|
||||
|
||||
$bg-dark: #1a1a2e; // 深色背景
|
||||
$bg-light: #16213e; // 浅色背景
|
||||
$bg-gradient-start: #0f0f23; // 渐变开始
|
||||
$bg-gradient-end: #1a1a2e; // 渐变结束
|
||||
|
||||
$text-primary: #ffffff;
|
||||
$text-secondary: rgba(255, 255, 255, 0.7);
|
||||
$text-muted: rgba(255, 255, 255, 0.5);
|
||||
|
||||
$border-color: rgba(255, 255, 255, 0.08);
|
||||
$border-hover: rgba(255, 107, 53, 0.3);
|
||||
$border-focus: rgba(255, 107, 53, 0.6);
|
||||
|
||||
// ===== 基础容器 =====
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, $bg-gradient-start 0%, $bg-gradient-end 100%);
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
// ===== 科技感背景 =====
|
||||
.tech-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
|
||||
// 网格线
|
||||
.grid-line {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 107, 53, 0.08), transparent);
|
||||
animation: gridMove 8s linear infinite;
|
||||
|
||||
&:nth-child(1) { top: 20%; animation-delay: 0s; }
|
||||
&:nth-child(2) { top: 40%; animation-delay: 2s; }
|
||||
&:nth-child(3) { top: 60%; animation-delay: 4s; }
|
||||
&:nth-child(4) { top: 80%; animation-delay: 6s; }
|
||||
}
|
||||
|
||||
// 光点效果
|
||||
.light-spot {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: $primary-color;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px $primary-color, 0 0 20px $primary-color;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
|
||||
&:nth-child(5) { top: 15%; left: 20%; animation-delay: 0s; }
|
||||
&:nth-child(6) { top: 25%; left: 70%; animation-delay: 2s; }
|
||||
&:nth-child(7) { top: 55%; left: 15%; animation-delay: 4s; }
|
||||
&:nth-child(8) { top: 75%; left: 80%; animation-delay: 1s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) scale(1); opacity: 0.6; }
|
||||
50% { transform: translateY(-20px) scale(1.2); opacity: 1; }
|
||||
}
|
||||
|
||||
// ===== 主卡片 =====
|
||||
.auth-wrapper {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 28px;
|
||||
padding: 0;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid $border-color;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
min-height: 580px;
|
||||
animation: cardFadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes cardFadeIn {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
// ===== 左侧装饰区 =====
|
||||
.decoration-area {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, rgba(255, 107, 53, 0.08) 0%, rgba(255, 217, 61, 0.03) 100%);
|
||||
padding: 60px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border-right: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.tech-circle {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 48px;
|
||||
|
||||
.circle-inner {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 50px rgba(255, 107, 53, 0.4);
|
||||
animation: pulse 3s ease-in-out infinite;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, $secondary-color 0%, $accent-color 100%);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 30px rgba(255, 217, 61, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.circle-ring {
|
||||
position: absolute;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border: 2px solid rgba(255, 107, 53, 0.2);
|
||||
border-radius: 50%;
|
||||
animation: rotate 12s linear infinite;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: $primary-color;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 15px $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.circle-ring-2 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border: 1px solid rgba(255, 217, 61, 0.15);
|
||||
animation: rotate 18s linear infinite reverse;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.85; }
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.decoration-text {
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, $primary-color 0%, $secondary-color 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: $text-secondary;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 右侧表单区 =====
|
||||
.form-area {
|
||||
flex: 1.3;
|
||||
padding: 60px 56px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
margin-bottom: 40px;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, $primary-color 0%, $accent-color 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: $text-secondary;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 表单样式 =====
|
||||
.auth-form {
|
||||
margin-top: 0;
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label > label) {
|
||||
color: $text-secondary;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 输入框样式
|
||||
:deep(.ant-input-affix-wrapper),
|
||||
:deep(.ant-input) {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 12px;
|
||||
color: $text-primary;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: $border-hover;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&.ant-input-affix-wrapper-focused {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: $primary-color;
|
||||
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-input::placeholder) {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper > input.ant-input) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// 图标样式
|
||||
:deep(.anticon) {
|
||||
color: $text-secondary;
|
||||
font-size: 16px;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper-focused .anticon) {
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 按钮样式 =====
|
||||
.auth-form :deep(.ant-btn-primary) {
|
||||
background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
height: 48px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.5px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 53, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, $primary-light 0%, $accent-color 100%);
|
||||
box-shadow: 0 6px 25px rgba(255, 107, 53, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 10px rgba(255, 107, 53, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: $text-muted;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 表单选项 =====
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 26px;
|
||||
|
||||
:deep(.ant-checkbox-wrapper) {
|
||||
color: $text-primary;
|
||||
font-size: 14px;
|
||||
|
||||
.ant-checkbox {
|
||||
.ant-checkbox-inner {
|
||||
border-color: $border-color;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
&.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background: $primary-color;
|
||||
border-color: $primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
color: $primary-color;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: $primary-light;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 验证码输入框 =====
|
||||
.code-input-wrapper {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
|
||||
.code-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
width: 150px;
|
||||
white-space: nowrap;
|
||||
background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 53, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, $primary-light 0%, $accent-color 100%);
|
||||
box-shadow: 0 6px 25px rgba(255, 107, 53, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: $text-muted;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 协议复选框 =====
|
||||
.agreement-checkbox {
|
||||
:deep(.ant-checkbox-wrapper) {
|
||||
color: $text-secondary;
|
||||
font-size: 13px;
|
||||
align-items: flex-start;
|
||||
line-height: 1.6;
|
||||
|
||||
.ant-checkbox {
|
||||
margin-top: 2px;
|
||||
|
||||
.ant-checkbox-inner {
|
||||
border-color: $border-color;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
&.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background: $primary-color;
|
||||
border-color: $primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: $primary-color;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: $primary-light;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 表单底部 =====
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
margin-top: 28px;
|
||||
color: $text-secondary;
|
||||
font-size: 14px;
|
||||
|
||||
.auth-link {
|
||||
color: $primary-color;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: $primary-light;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 响应式设计 =====
|
||||
@media (max-width: 768px) {
|
||||
.auth-card {
|
||||
flex-direction: column;
|
||||
min-height: auto;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.decoration-area {
|
||||
padding: 48px 24px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.tech-circle {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
|
||||
.circle-inner {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
|
||||
&::after {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
.circle-ring {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.circle-ring-2 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.decoration-text {
|
||||
h2 {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-area {
|
||||
padding: 48px 32px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.code-input-wrapper {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.code-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.auth-wrapper {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.form-area {
|
||||
padding: 36px 24px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
// Auth Pages - Warm Tech Theme
|
||||
// Warm color palette with tech-inspired design
|
||||
|
||||
:root {
|
||||
--auth-primary: #ff6b35;
|
||||
--auth-primary-light: #ff8c5a;
|
||||
--auth-primary-dark: #e55a2b;
|
||||
--auth-secondary: #ffb347;
|
||||
--accent-orange: #ffa500;
|
||||
--accent-coral: #ff7f50;
|
||||
--accent-amber: #ffc107;
|
||||
|
||||
--bg-gradient-start: #fff5f0;
|
||||
--bg-gradient-end: #ffe8dc;
|
||||
--card-bg: rgba(255, 255, 255, 0.95);
|
||||
|
||||
--text-primary: #2d1810;
|
||||
--text-secondary: #6b4423;
|
||||
--text-muted: #a67c52;
|
||||
|
||||
--border-color: #ffd4b8;
|
||||
--shadow-color: rgba(255, 107, 53, 0.15);
|
||||
|
||||
--success: #28a745;
|
||||
--warning: #ffc107;
|
||||
--error: #dc3545;
|
||||
|
||||
--tech-blue: #007bff;
|
||||
--tech-purple: #6f42c1;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// Tech pattern background
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 50%, rgba(255, 107, 53, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255, 179, 71, 0.05) 0%, transparent 40%),
|
||||
radial-gradient(circle at 40% 80%, rgba(255, 127, 80, 0.04) 0%, transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Animated tech elements
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, rgba(255, 107, 53, 0.08) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
top: -200px;
|
||||
right: -200px;
|
||||
animation: float 20s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50px, 50px);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
padding: 48px 40px;
|
||||
box-shadow:
|
||||
0 20px 60px var(--shadow-color),
|
||||
0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 20px;
|
||||
|
||||
// Tech accent line
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, var(--auth-primary), var(--auth-secondary));
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.auth-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
.ant-form-item {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.08);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--auth-primary-light);
|
||||
box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&.ant-input-focused {
|
||||
border-color: var(--auth-primary);
|
||||
box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input-affix-wrapper {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.08);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--auth-primary-light);
|
||||
box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&.ant-input-affix-wrapper-focused {
|
||||
border-color: var(--auth-primary);
|
||||
box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15);
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input-prefix {
|
||||
color: var(--auth-primary);
|
||||
font-size: 18px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.ant-input-suffix {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark));
|
||||
border: none;
|
||||
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.remember-me {
|
||||
.ant-checkbox-inner {
|
||||
border-radius: 4px;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.ant-checkbox-wrapper {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&.ant-checkbox-wrapper-checked {
|
||||
.ant-checkbox-inner {
|
||||
background-color: var(--auth-primary);
|
||||
border-color: var(--auth-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
color: var(--auth-primary);
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--auth-primary-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 32px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
|
||||
.auth-footer-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
|
||||
.auth-link {
|
||||
color: var(--auth-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--auth-primary-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tech-decoration {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
.tech-circle {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(255, 107, 53, 0.1);
|
||||
border-radius: 50%;
|
||||
animation: pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tech-circle:nth-child(1) {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
top: -150px;
|
||||
left: -150px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.tech-circle:nth-child(2) {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
bottom: -100px;
|
||||
right: -100px;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.tech-circle:nth-child(3) {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
bottom: 20%;
|
||||
left: -75px;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.auth-card {
|
||||
padding: 40px 24px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
.auth-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,15 @@
|
||||
import * as AIcons from '@ant-design/icons-vue'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
install(app) {
|
||||
|
||||
for (let icon in AIcons) {
|
||||
app.component(`${icon}`, AIcons[icon])
|
||||
}
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(`El${key}`, component)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
msg: String,
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="sc-cron">
|
||||
<a-input v-model:value="innerValue" placeholder="请输入Cron表达式" allow-clear @change="handleChange" />
|
||||
<div class="cron-tips">
|
||||
<div class="tip-title">快捷设置:</div>
|
||||
<a-space>
|
||||
<a-button size="small" @click="setCron('0 0 * * *')">每天零点</a-button>
|
||||
<a-button size="small" @click="setCron('0 0 * * 0')">每周零点</a-button>
|
||||
<a-button size="small" @click="setCron('0 0 1 * *')">每月1号零点</a-button>
|
||||
<a-button size="small" @click="setCron('0 0/6 * * *')">每6小时</a-button>
|
||||
<a-button size="small" @click="setCron('0 * * * *')">每小时</a-button>
|
||||
<a-button size="small" @click="setCron('0 */30 * * *')">每30分钟</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="cron-description">
|
||||
格式: 秒 分 时 日 月 周
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'scCron'
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
shortcuts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const innerValue = ref(props.modelValue)
|
||||
|
||||
// 监听外部值变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
innerValue.value = newVal
|
||||
})
|
||||
|
||||
// 设置Cron表达式
|
||||
const setCron = (cron) => {
|
||||
innerValue.value = cron
|
||||
emit('update:modelValue', cron)
|
||||
emit('change', cron)
|
||||
}
|
||||
|
||||
// 处理输入变化
|
||||
const handleChange = (e) => {
|
||||
const value = e.target.value
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sc-cron {
|
||||
.cron-tips {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
|
||||
.tip-title {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.cron-description {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
export default class UploadAdapter {
|
||||
constructor(loader, options) {
|
||||
this.loader = loader;
|
||||
this.options = options;
|
||||
this.timeout = 60000; // 60秒超时
|
||||
}
|
||||
|
||||
upload() {
|
||||
return this.loader.file.then(
|
||||
(file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
this._initRequest();
|
||||
this._initListeners(resolve, reject, file);
|
||||
this._sendRequest(file);
|
||||
this._initTimeout(reject);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
abort() {
|
||||
if (this.xhr) {
|
||||
this.xhr.abort();
|
||||
}
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
_initRequest() {
|
||||
const xhr = (this.xhr = new XMLHttpRequest());
|
||||
|
||||
xhr.open("POST", this.options.upload.uploadUrl, true);
|
||||
xhr.responseType = "json";
|
||||
}
|
||||
|
||||
_initListeners(resolve, reject, file) {
|
||||
const xhr = this.xhr;
|
||||
const loader = this.loader;
|
||||
const genericErrorText = `Couldn't upload file: ${file.name}.`;
|
||||
|
||||
xhr.addEventListener("error", () => {
|
||||
console.error("[UploadAdapter] Upload error for file:", file.name);
|
||||
reject(genericErrorText);
|
||||
});
|
||||
|
||||
xhr.addEventListener("abort", () => {
|
||||
console.warn("[UploadAdapter] Upload aborted for file:", file.name);
|
||||
reject();
|
||||
});
|
||||
|
||||
xhr.addEventListener("timeout", () => {
|
||||
console.error("[UploadAdapter] Upload timeout for file:", file.name);
|
||||
reject(`Upload timeout: ${file.name}. Please try again.`);
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
const response = xhr.response;
|
||||
|
||||
// 检查响应状态码
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
if (!response) {
|
||||
console.error("[UploadAdapter] Empty response for file:", file.name);
|
||||
reject(genericErrorText);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查业务状态码(假设 code=1 表示成功)
|
||||
if (response.code == 1 || response.code == undefined) {
|
||||
const url = response.data?.url || response.data?.src;
|
||||
if (!url) {
|
||||
console.error("[UploadAdapter] No URL in response for file:", file.name, response);
|
||||
reject("Upload succeeded but no URL returned");
|
||||
return;
|
||||
}
|
||||
resolve({ default: url });
|
||||
} else {
|
||||
const errorMessage = response.message || genericErrorText;
|
||||
console.error("[UploadAdapter] Upload failed for file:", file.name, "Error:", errorMessage);
|
||||
reject(errorMessage);
|
||||
}
|
||||
} else {
|
||||
console.error("[UploadAdapter] HTTP error for file:", file.name, "Status:", xhr.status);
|
||||
reject(`Server error (${xhr.status}): ${file.name}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 上传进度监听
|
||||
if (xhr.upload) {
|
||||
xhr.upload.addEventListener("progress", (evt) => {
|
||||
if (evt.lengthComputable) {
|
||||
loader.uploadTotal = evt.total;
|
||||
loader.uploaded = evt.loaded;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_initTimeout(reject) {
|
||||
// 清除之前的超时定时器(如果有)
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
}
|
||||
|
||||
// 设置新的超时定时器
|
||||
this.timeoutId = setTimeout(() => {
|
||||
if (this.xhr) {
|
||||
this.xhr.abort();
|
||||
reject(new Error("Upload timeout"));
|
||||
}
|
||||
}, this.timeout);
|
||||
}
|
||||
|
||||
_sendRequest(file) {
|
||||
// 设置请求超时
|
||||
this.xhr.timeout = this.timeout;
|
||||
|
||||
// Set headers if specified.
|
||||
const headers = this.options.upload.headers || {};
|
||||
const extendData = this.options.upload.extendData || {};
|
||||
// Use the withCredentials flag if specified.
|
||||
const withCredentials = this.options.upload.withCredentials || false;
|
||||
const uploadName = this.options.upload.uploadName || "file";
|
||||
|
||||
for (const headerName of Object.keys(headers)) {
|
||||
this.xhr.setRequestHeader(headerName, headers[headerName]);
|
||||
}
|
||||
|
||||
this.xhr.withCredentials = withCredentials;
|
||||
|
||||
const data = new FormData();
|
||||
for (const key of Object.keys(extendData)) {
|
||||
data.append(key, extendData[key]);
|
||||
}
|
||||
data.append(uploadName, file);
|
||||
|
||||
this.xhr.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
export function UploadAdapterPlugin(editor) {
|
||||
editor.plugins.get("FileRepository").createUploadAdapter = (loader) => {
|
||||
return new UploadAdapter(loader, editor.config._config);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div :style="{ '--editor-height': editorHeight }">
|
||||
<ckeditor :editor="editor" v-model="editorData" :config="editorConfig" :disabled="disabled" @blur="onBlur"
|
||||
@focus="onFocus"></ckeditor>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ClassicEditor,
|
||||
Alignment,
|
||||
AutoImage,
|
||||
Autoformat,
|
||||
BlockQuote,
|
||||
Bold,
|
||||
CodeBlock,
|
||||
DataFilter,
|
||||
DataSchema,
|
||||
Essentials,
|
||||
FindAndReplace,
|
||||
FontBackgroundColor,
|
||||
FontColor,
|
||||
FontFamily,
|
||||
FontSize,
|
||||
GeneralHtmlSupport,
|
||||
Heading,
|
||||
Highlight,
|
||||
HorizontalLine,
|
||||
Image,
|
||||
ImageCaption,
|
||||
ImageInsert,
|
||||
ImageResize,
|
||||
ImageStyle,
|
||||
ImageToolbar,
|
||||
ImageUpload,
|
||||
Indent,
|
||||
IndentBlock,
|
||||
Italic,
|
||||
Link,
|
||||
LinkImage,
|
||||
List,
|
||||
MediaEmbed,
|
||||
MediaEmbedToolbar,
|
||||
Mention,
|
||||
Paragraph,
|
||||
PasteFromOffice,
|
||||
RemoveFormat,
|
||||
SelectAll,
|
||||
ShowBlocks,
|
||||
SourceEditing,
|
||||
SpecialCharacters,
|
||||
SpecialCharactersArrows,
|
||||
SpecialCharactersCurrency,
|
||||
SpecialCharactersEssentials,
|
||||
SpecialCharactersLatin,
|
||||
SpecialCharactersMathematical,
|
||||
SpecialCharactersText,
|
||||
Style,
|
||||
Subscript,
|
||||
Superscript,
|
||||
Table,
|
||||
TableCaption,
|
||||
TableCellProperties,
|
||||
TableColumnResize,
|
||||
TableProperties,
|
||||
TableToolbar,
|
||||
TextTransformation,
|
||||
TodoList,
|
||||
Underline,
|
||||
Undo,
|
||||
WordCount,
|
||||
} from "ckeditor5";
|
||||
import { Ckeditor } from "@ckeditor/ckeditor5-vue";
|
||||
import { UploadAdapterPlugin } from "./UploadAdapter.js";
|
||||
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useCurrentInstance } from "@/utils/tool";
|
||||
|
||||
import coreTranslations from "ckeditor5/translations/zh-cn.js";
|
||||
import "ckeditor5/ckeditor5.css";
|
||||
|
||||
const { proxy } = useCurrentInstance();
|
||||
|
||||
// 组件名称
|
||||
defineOptions({
|
||||
name: "scCkeditor"
|
||||
});
|
||||
|
||||
// Props 定义
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "请输入内容……",
|
||||
},
|
||||
toolbar: {
|
||||
type: String,
|
||||
default: "basic",
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: "400px",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Emits 定义
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
// 工具栏配置常量
|
||||
const TOOLBARS = {
|
||||
full: [
|
||||
"sourceEditing",
|
||||
"undo",
|
||||
"redo",
|
||||
"heading",
|
||||
"style",
|
||||
"|",
|
||||
"superscript",
|
||||
"subscript",
|
||||
"removeFormat",
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"link",
|
||||
"fontBackgroundColor",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontColor",
|
||||
"|",
|
||||
"outdent",
|
||||
"indent",
|
||||
"alignment",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"todoList",
|
||||
"|",
|
||||
"blockQuote",
|
||||
"insertTable",
|
||||
"imageInsert",
|
||||
"mediaEmbed",
|
||||
"highlight",
|
||||
"horizontalLine",
|
||||
"selectAll",
|
||||
"showBlocks",
|
||||
"specialCharacters",
|
||||
"codeBlock",
|
||||
"findAndReplace",
|
||||
],
|
||||
basic: [
|
||||
"sourceEditing",
|
||||
"undo",
|
||||
"redo",
|
||||
"heading",
|
||||
"|",
|
||||
"removeFormat",
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"link",
|
||||
"fontBackgroundColor",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontColor",
|
||||
"|",
|
||||
"outdent",
|
||||
"indent",
|
||||
"alignment",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"todoList",
|
||||
"|",
|
||||
"insertTable",
|
||||
"imageInsert",
|
||||
"mediaEmbed",
|
||||
],
|
||||
simple: [
|
||||
"undo",
|
||||
"redo",
|
||||
"heading",
|
||||
"|",
|
||||
"removeFormat",
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"link",
|
||||
"fontBackgroundColor",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontColor",
|
||||
"|",
|
||||
"insertTable",
|
||||
"imageInsert",
|
||||
"mediaEmbed",
|
||||
],
|
||||
};
|
||||
|
||||
// 插件配置常量
|
||||
const PLUGINS = [
|
||||
Alignment,
|
||||
AutoImage,
|
||||
Autoformat,
|
||||
BlockQuote,
|
||||
Bold,
|
||||
CodeBlock,
|
||||
DataFilter,
|
||||
DataSchema,
|
||||
Essentials,
|
||||
FindAndReplace,
|
||||
FontBackgroundColor,
|
||||
FontColor,
|
||||
FontFamily,
|
||||
FontSize,
|
||||
GeneralHtmlSupport,
|
||||
Heading,
|
||||
Highlight,
|
||||
HorizontalLine,
|
||||
Image,
|
||||
ImageCaption,
|
||||
ImageInsert,
|
||||
ImageResize,
|
||||
ImageStyle,
|
||||
ImageToolbar,
|
||||
ImageUpload,
|
||||
Indent,
|
||||
IndentBlock,
|
||||
Italic,
|
||||
Link,
|
||||
LinkImage,
|
||||
List,
|
||||
MediaEmbed,
|
||||
MediaEmbedToolbar,
|
||||
Mention,
|
||||
Paragraph,
|
||||
PasteFromOffice,
|
||||
RemoveFormat,
|
||||
SelectAll,
|
||||
ShowBlocks,
|
||||
SourceEditing,
|
||||
SpecialCharacters,
|
||||
SpecialCharactersArrows,
|
||||
SpecialCharactersCurrency,
|
||||
SpecialCharactersEssentials,
|
||||
SpecialCharactersLatin,
|
||||
SpecialCharactersMathematical,
|
||||
SpecialCharactersText,
|
||||
Style,
|
||||
Subscript,
|
||||
Superscript,
|
||||
Table,
|
||||
TableCaption,
|
||||
TableCellProperties,
|
||||
TableColumnResize,
|
||||
TableProperties,
|
||||
TableToolbar,
|
||||
TextTransformation,
|
||||
TodoList,
|
||||
Underline,
|
||||
Undo,
|
||||
WordCount,
|
||||
UploadAdapterPlugin,
|
||||
];
|
||||
|
||||
// 响应式数据
|
||||
const editorData = ref("");
|
||||
const editorHeight = ref(props.height);
|
||||
const editor = ClassicEditor;
|
||||
|
||||
// 编辑器配置
|
||||
const editorConfig = computed(() => ({
|
||||
language: { ui: "zh-cn", content: "zh-cn" },
|
||||
translations: [coreTranslations],
|
||||
plugins: PLUGINS,
|
||||
toolbar: {
|
||||
shouldNotGroupWhenFull: true,
|
||||
items: TOOLBARS[props.toolbar] || TOOLBARS.basic,
|
||||
},
|
||||
placeholder: props.placeholder,
|
||||
image: {
|
||||
styles: ["alignLeft", "alignCenter", "alignRight"],
|
||||
toolbar: [
|
||||
"imageTextAlternative",
|
||||
"toggleImageCaption",
|
||||
"|",
|
||||
"imageStyle:alignLeft",
|
||||
"imageStyle:alignCenter",
|
||||
"imageStyle:alignRight",
|
||||
"|",
|
||||
"linkImage",
|
||||
],
|
||||
},
|
||||
mediaEmbed: {
|
||||
previewsInData: true,
|
||||
providers: [
|
||||
{
|
||||
name: "mp4",
|
||||
url: /\.(mp4|avi|mov|flv|wmv|mkv)$/i,
|
||||
html: match => {
|
||||
const url = match["input"];
|
||||
return ('<video controls width="100%" height="100%" src="' + url + '"></video>')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
fontSize: {
|
||||
options: [10, 12, 14, 16, 18, 20, 22, 24, 26, 30, 32, 36],
|
||||
},
|
||||
style: {
|
||||
definitions: [
|
||||
{
|
||||
name: "Article category",
|
||||
element: "h3",
|
||||
classes: ["category"],
|
||||
},
|
||||
{
|
||||
name: "Info box",
|
||||
element: "p",
|
||||
classes: ["info-box"],
|
||||
},
|
||||
],
|
||||
},
|
||||
upload: {
|
||||
uploadUrl: proxy?.$API?.common?.upload?.url || "",
|
||||
withCredentials: false,
|
||||
extendData: { type: "images" },
|
||||
headers: {
|
||||
Authorization: "Bearer " + proxy?.$TOOL?.data?.get("TOKEN"),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
editorData.value = newVal ?? "";
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听 height 变化
|
||||
watch(
|
||||
() => props.height,
|
||||
(newVal) => {
|
||||
editorHeight.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
// 移除图片宽高的正则替换函数
|
||||
const stripImageDimensions = (html) => {
|
||||
return html.replace(/<img[^>]*>/gi, (match) => {
|
||||
return match
|
||||
.replace(/width="[^"]*"/gi, "")
|
||||
.replace(/height="[^"]*"/gi, "");
|
||||
});
|
||||
};
|
||||
|
||||
// 失去焦点事件 - 移除图片的固定宽高,避免响应式布局问题
|
||||
const onBlur = () => {
|
||||
const cleanedData = stripImageDimensions(editorData.value);
|
||||
editorData.value = cleanedData;
|
||||
emit("update:modelValue", cleanedData);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--ck-z-panel: 9999;
|
||||
}
|
||||
|
||||
.ck-content {
|
||||
height: var(--editor-height);
|
||||
}
|
||||
|
||||
.ck-source-editing-area,
|
||||
.ck-source-editing-area textarea {
|
||||
height: var(--editor-height);
|
||||
}
|
||||
|
||||
.ck-source-editing-area textarea {
|
||||
overflow-y: scroll !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,394 @@
|
||||
# scExport 异步导出组件
|
||||
|
||||
异步导出组件,支持表单参数配置、字段选择、格式选择、自定义文件名等功能。
|
||||
|
||||
## 基本使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<a-button type="primary" @click="showExport = true">
|
||||
<export-outlined /> 导出数据
|
||||
</a-button>
|
||||
|
||||
<sc-export
|
||||
v-model:open="showExport"
|
||||
:api="authApi.users.export.post"
|
||||
title="导出用户"
|
||||
filename="用户数据"
|
||||
@success="handleExportSuccess"
|
||||
@error="handleExportError"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import authApi from '@/api/auth'
|
||||
|
||||
const showExport = ref(false)
|
||||
|
||||
const handleExportSuccess = (data) => {
|
||||
console.log('导出成功', data)
|
||||
}
|
||||
|
||||
const handleExportError = (message) => {
|
||||
console.log('导出失败', message)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
|------|------|------|--------|
|
||||
| open | 是否显示弹窗 | Boolean | false |
|
||||
| title | 弹窗标题 | String | '导出数据' |
|
||||
| api | 导出API接口 | Function | 必填 |
|
||||
| showOptions | 是否显示导出选项 | Boolean | true |
|
||||
| showFieldSelect | 是否显示字段选择 | Boolean | false |
|
||||
| fieldOptions | 字段选项 | Array | [] |
|
||||
| showFormatSelect | 是否显示格式选择 | Boolean | false |
|
||||
| defaultFormat | 默认导出格式 | String | 'xlsx' |
|
||||
| defaultFilename | 默认文件名 | String | '' |
|
||||
| tip | 提示信息 | String | '' |
|
||||
|
||||
## Events
|
||||
|
||||
| 事件名 | 说明 | 回调参数 |
|
||||
|--------|------|----------|
|
||||
| update:open | 弹窗显示状态变化 | (visible: Boolean) |
|
||||
| success | 导出成功 | (exportParams) |
|
||||
| error | 导出失败 | (message, error) |
|
||||
| change | 导出参数变化 | (params) |
|
||||
|
||||
## Slots
|
||||
|
||||
### formParams
|
||||
|
||||
自定义表单参数插槽,可用于添加额外的表单字段。
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<sc-export
|
||||
v-model:open="showExport"
|
||||
:api="exportApi"
|
||||
@success="handleSuccess"
|
||||
>
|
||||
<template #formParams="{ formData }">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="时间范围">
|
||||
<a-range-picker
|
||||
v-model:value="formData.date_range"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select
|
||||
v-model:value="formData.status"
|
||||
placeholder="请选择状态"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
</sc-export>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 示例1:简单导出
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<a-button type="primary" @click="handleExport">
|
||||
<export-outlined /> 导出用户
|
||||
</a-button>
|
||||
|
||||
<sc-export
|
||||
v-model:open="exportVisible"
|
||||
:api="authApi.users.export.post"
|
||||
title="导出用户数据"
|
||||
filename="用户数据"
|
||||
tip="将导出当前筛选条件下的所有用户数据"
|
||||
@success="handleExportSuccess"
|
||||
@error="handleExportError"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ExportOutlined } from '@ant-design/icons-vue'
|
||||
import authApi from '@/api/auth'
|
||||
|
||||
const exportVisible = ref(false)
|
||||
|
||||
const handleExport = () => {
|
||||
exportVisible.value = true
|
||||
}
|
||||
|
||||
const handleExportSuccess = (params) => {
|
||||
message.success('导出成功')
|
||||
}
|
||||
|
||||
const handleExportError = (errorMessage) => {
|
||||
message.error('导出失败:' + errorMessage)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 示例2:带表单参数的导出
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<a-button type="primary" @click="handleExport">
|
||||
<export-outlined /> 导出用户
|
||||
</a-button>
|
||||
|
||||
<sc-export
|
||||
v-model:open="exportVisible"
|
||||
:api="authApi.users.export.post"
|
||||
title="导出用户数据"
|
||||
filename="用户数据"
|
||||
:show-options="false"
|
||||
:show-field-select="false"
|
||||
:show-format-select="false"
|
||||
@success="handleExportSuccess"
|
||||
@error="handleExportError"
|
||||
>
|
||||
<template #formParams="{ formData }">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="时间范围">
|
||||
<a-range-picker
|
||||
v-model:value="formData.date_range"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="部门">
|
||||
<a-select
|
||||
v-model:value="formData.department_id"
|
||||
placeholder="请选择部门"
|
||||
:options="departmentOptions"
|
||||
:field-names="{ label: 'name', value: 'id' }"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select
|
||||
v-model:value="formData.status"
|
||||
placeholder="请选择状态"
|
||||
:options="[
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 }
|
||||
]"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-select
|
||||
v-model:value="formData.role_ids"
|
||||
mode="multiple"
|
||||
placeholder="请选择角色"
|
||||
:options="roleOptions"
|
||||
:field-names="{ label: 'name', value: 'id' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
</sc-export>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ExportOutlined } from '@ant-design/icons-vue'
|
||||
import authApi from '@/api/auth'
|
||||
|
||||
const exportVisible = ref(false)
|
||||
const departmentOptions = ref([])
|
||||
const roleOptions = ref([])
|
||||
|
||||
// 加载选项数据
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [deptRes, roleRes] = await Promise.all([
|
||||
authApi.departments.all.get(),
|
||||
authApi.roles.all.get()
|
||||
])
|
||||
departmentOptions.value = deptRes.data || []
|
||||
roleOptions.value = roleRes.data || []
|
||||
} catch (error) {
|
||||
console.error('加载选项失败', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleExport = () => {
|
||||
exportVisible.value = true
|
||||
}
|
||||
|
||||
const handleExportSuccess = (params) => {
|
||||
message.success('导出成功')
|
||||
console.log('导出参数', params)
|
||||
}
|
||||
|
||||
const handleExportError = (errorMessage) => {
|
||||
message.error('导出失败:' + errorMessage)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 示例3:带字段和格式选择的导出
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<a-button type="primary" @click="handleExport">
|
||||
<export-outlined /> 导出数据
|
||||
</a-button>
|
||||
|
||||
<sc-export
|
||||
v-model:open="exportVisible"
|
||||
:api="exportApi"
|
||||
title="导出数据"
|
||||
filename="数据报表"
|
||||
:show-options="true"
|
||||
:show-field-select="true"
|
||||
:field-options="fieldOptions"
|
||||
:show-format-select="true"
|
||||
default-format="xlsx"
|
||||
tip="请选择需要导出的字段和格式"
|
||||
@success="handleExportSuccess"
|
||||
@error="handleExportError"
|
||||
>
|
||||
<template #formParams="{ formData }">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="数据类型">
|
||||
<a-radio-group v-model:value="formData.type">
|
||||
<a-radio value="all">全部数据</a-radio>
|
||||
<a-radio value="filtered">筛选数据</a-radio>
|
||||
<a-radio value="selected">选中数据</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
</sc-export>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ExportOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const exportVisible = ref(false)
|
||||
|
||||
// 字段选项
|
||||
const fieldOptions = ref([
|
||||
{ label: '用户名', value: 'username' },
|
||||
{ label: '姓名', value: 'name' },
|
||||
{ label: '邮箱', value: 'email' },
|
||||
{ label: '手机号', value: 'phone' },
|
||||
{ label: '部门', value: 'department' },
|
||||
{ label: '角色', value: 'roles' },
|
||||
{ label: '状态', value: 'status' },
|
||||
{ label: '创建时间', value: 'created_at' },
|
||||
{ label: '最后登录', value: 'last_login_at' }
|
||||
])
|
||||
|
||||
// 导出API
|
||||
const exportApi = async (params) => {
|
||||
// 这里调用实际的导出接口
|
||||
// 示例:
|
||||
// return await authApi.users.export.post(params)
|
||||
console.log('导出参数', params)
|
||||
// 返回一个 blob 对象
|
||||
return new Blob(['test data'], { type: 'application/vnd.ms-excel' })
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
exportVisible.value = true
|
||||
}
|
||||
|
||||
const handleExportSuccess = (params) => {
|
||||
message.success('导出成功')
|
||||
}
|
||||
|
||||
const handleExportError = (errorMessage) => {
|
||||
message.error('导出失败:' + errorMessage)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 组件会自动处理文件下载、表单参数合并等逻辑
|
||||
2. 表单参数会作为普通对象发送到后端
|
||||
3. 后端接口应返回 Blob 类型的响应
|
||||
4. 文件名会自动添加对应的扩展名(.xlsx、.xls、.csv)
|
||||
5. 字段选择功能需要在后端支持按字段过滤数据
|
||||
6. 格式选择功能需要在后端支持不同格式的导出
|
||||
|
||||
## 与表格组件结合使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<sc-table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
>
|
||||
<template #toolbar>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleExport">
|
||||
<export-outlined /> 导出数据
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</sc-table>
|
||||
|
||||
<sc-export
|
||||
v-model:open="exportVisible"
|
||||
:api="exportApi"
|
||||
title="导出用户数据"
|
||||
filename="用户数据"
|
||||
:show-options="false"
|
||||
@success="handleExportSuccess"
|
||||
>
|
||||
<template #formParams="{ formData }">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="导出范围">
|
||||
<a-radio-group v-model:value="formData.scope">
|
||||
<a-radio value="all">全部数据</a-radio>
|
||||
<a-radio value="current">当前页</a-radio>
|
||||
<a-radio value="selected">选中项</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="包含筛选条件">
|
||||
<a-switch v-model:checked="formData.with_filter" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
</sc-export>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ExportOutlined } from '@ant-design/icons-vue'
|
||||
import authApi from '@/api/auth'
|
||||
|
||||
const exportVisible = ref(false)
|
||||
|
||||
const handleExport = () => {
|
||||
exportVisible.value = true
|
||||
}
|
||||
|
||||
const exportApi = async (params) => {
|
||||
return await authApi.users.export.post(params)
|
||||
}
|
||||
|
||||
const handleExportSuccess = () => {
|
||||
// 导出成功后的处理
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="title"
|
||||
:width="600"
|
||||
:confirm-loading="loading"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="export-modal">
|
||||
<!-- 表单参数插槽 -->
|
||||
<div v-if="$slots.formParams" class="form-params">
|
||||
<slot name="formParams" :form-data="formData" />
|
||||
</div>
|
||||
|
||||
<!-- 导出选项 -->
|
||||
<div v-if="showOptions" class="export-options">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="导出字段" v-if="showFieldSelect">
|
||||
<a-select
|
||||
v-model:value="selectedFields"
|
||||
mode="multiple"
|
||||
:options="fieldOptions"
|
||||
placeholder="请选择要导出的字段"
|
||||
:show-arrow="true"
|
||||
>
|
||||
<template #dropdownRender="{ menuNode: menu }">
|
||||
<v-nodes :vnodes="menu" />
|
||||
<a-divider style="margin: 4px 0" />
|
||||
<div style="padding: 4px 8px">
|
||||
<a-button type="text" size="small" @click="selectAllFields">全选</a-button>
|
||||
<a-button type="text" size="small" @click="clearAllFields">清空</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="导出格式" v-if="showFormatSelect">
|
||||
<a-radio-group v-model:value="exportFormat">
|
||||
<a-radio value="xlsx">Excel (.xlsx)</a-radio>
|
||||
<a-radio value="xls">Excel (.xls)</a-radio>
|
||||
<a-radio value="csv">CSV</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="文件名">
|
||||
<a-input v-model:value="filename" placeholder="请输入文件名" :max-length="50" show-count />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 导出提示 -->
|
||||
<div v-if="tip" class="tip-section">
|
||||
<a-alert type="info" show-icon>
|
||||
<template #message>{{ tip }}</template>
|
||||
</a-alert>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const props = defineProps({
|
||||
// 是否显示弹窗
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 弹窗标题
|
||||
title: {
|
||||
type: String,
|
||||
default: '导出数据'
|
||||
},
|
||||
// 导出API接口
|
||||
api: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
// 是否显示导出选项
|
||||
showOptions: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 是否显示字段选择
|
||||
showFieldSelect: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 字段选项
|
||||
fieldOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 是否显示格式选择
|
||||
showFormatSelect: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 默认导出格式
|
||||
defaultFormat: {
|
||||
type: String,
|
||||
default: 'xlsx'
|
||||
},
|
||||
// 默认文件名
|
||||
defaultFilename: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 提示信息
|
||||
tip: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'success', 'error', 'change'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = ref(false)
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单数据(用于插槽)
|
||||
const formData = ref({})
|
||||
|
||||
// 导出选项
|
||||
const selectedFields = ref([])
|
||||
const exportFormat = ref(props.defaultFormat)
|
||||
const filename = ref(props.defaultFilename || '导出数据')
|
||||
|
||||
// VNodes 组件(用于 dropdownRender)
|
||||
const VNodes = (_, { attrs }) => {
|
||||
return attrs.vnodes
|
||||
}
|
||||
|
||||
// 监听外部 open 变化
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
// 打开时重置表单
|
||||
formData.value = {}
|
||||
selectedFields.value = []
|
||||
exportFormat.value = props.defaultFormat
|
||||
filename.value = props.defaultFilename || '导出数据'
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听内部 visible 变化,同步到外部
|
||||
watch(visible, (val) => {
|
||||
emit('update:open', val)
|
||||
})
|
||||
|
||||
// 全选字段
|
||||
const selectAllFields = () => {
|
||||
selectedFields.value = props.fieldOptions.map((item) => item.value)
|
||||
}
|
||||
|
||||
// 清空字段
|
||||
const clearAllFields = () => {
|
||||
selectedFields.value = []
|
||||
}
|
||||
|
||||
// 确认导出
|
||||
const handleOk = async () => {
|
||||
// 构建导出参数
|
||||
const exportParams = {}
|
||||
|
||||
// 添加表单参数(如果有)
|
||||
if (Object.keys(formData.value).length > 0) {
|
||||
Object.assign(exportParams, formData.value)
|
||||
}
|
||||
|
||||
// 添加字段选择(如果有)
|
||||
if (props.showFieldSelect && selectedFields.value.length > 0) {
|
||||
exportParams.fields = selectedFields.value
|
||||
}
|
||||
|
||||
// 添加格式选择(如果有)
|
||||
if (props.showFormatSelect) {
|
||||
exportParams.format = exportFormat.value
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 调用导出接口
|
||||
const blob = await props.api(exportParams)
|
||||
|
||||
// 确定文件扩展名
|
||||
let ext = exportFormat.value
|
||||
if (!props.showFormatSelect && blob.type) {
|
||||
// 如果没有格式选择,根据 blob 类型判断
|
||||
if (blob.type.includes('sheet') || blob.type.includes('excel')) {
|
||||
ext = 'xlsx'
|
||||
} else if (blob.type.includes('csv')) {
|
||||
ext = 'csv'
|
||||
}
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${filename.value}.${ext}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
message.success('导出成功')
|
||||
emit('success', exportParams)
|
||||
handleCancel()
|
||||
} catch (error) {
|
||||
message.error('导出失败:' + error.message)
|
||||
emit('error', error.message, error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消导出
|
||||
const handleCancel = () => {
|
||||
visible.value = false
|
||||
formData.value = {}
|
||||
selectedFields.value = []
|
||||
filename.value = props.defaultFilename || '导出数据'
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ScExport',
|
||||
components: {
|
||||
VNodes: {
|
||||
render() {
|
||||
return this.$slots.default ? this.$slots.default() : null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.export-modal {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.form-params {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.export-options {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tip-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<a-form :model="formData" :rules="rules" :label-col="labelCol" :wrapper-col="wrapperCol" :layout="layout"
|
||||
@finish="handleFinish" @finish-failed="handleFinishFailed">
|
||||
<a-form-item v-for="item in formItems" :key="item.field" :label="item.label" :name="item.field"
|
||||
:required="item.required" :colon="item.colon">
|
||||
<!-- 输入框 -->
|
||||
<template v-if="item.type === 'input'">
|
||||
<a-input v-model:value="formData[item.field]" :placeholder="item.placeholder || `请输入${item.label}`"
|
||||
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :max-length="item.maxLength"
|
||||
:type="item.inputType || 'text'" :prefix="item.prefix" :suffix="item.suffix"
|
||||
@change="item.onChange && item.onChange(formData[item.field])" />
|
||||
</template>
|
||||
|
||||
<!-- 文本域 -->
|
||||
<template v-else-if="item.type === 'textarea'">
|
||||
<a-textarea v-model:value="formData[item.field]" :placeholder="item.placeholder || `请输入${item.label}`"
|
||||
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :rows="item.rows || 4"
|
||||
:max-length="item.maxLength" :show-count="item.showCount"
|
||||
@change="item.onChange && item.onChange(formData[item.field])" />
|
||||
</template>
|
||||
|
||||
<!-- 密码输入框 -->
|
||||
<template v-else-if="item.type === 'password'">
|
||||
<a-input-password v-model:value="formData[item.field]"
|
||||
:placeholder="item.placeholder || `请输入${item.label}`" :disabled="item.disabled"
|
||||
:max-length="item.maxLength" @change="item.onChange && item.onChange(formData[item.field])" />
|
||||
</template>
|
||||
|
||||
<!-- 数字输入框 -->
|
||||
<template v-else-if="item.type === 'number'">
|
||||
<a-input-number v-model:value="formData[item.field]"
|
||||
:placeholder="item.placeholder || `请输入${item.label}`" :disabled="item.disabled" :min="item.min"
|
||||
:max="item.max" :step="item.step || 1" :precision="item.precision"
|
||||
:controls="item.controls !== false" style="width: 100%"
|
||||
@change="item.onChange && item.onChange(formData[item.field])" />
|
||||
</template>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<template v-else-if="item.type === 'select'">
|
||||
<a-select v-model:value="formData[item.field]" :placeholder="item.placeholder || `请选择${item.label}`"
|
||||
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :mode="item.mode"
|
||||
:options="item.options" :field-names="item.fieldNames" style="width: 100%"
|
||||
@change="item.onChange && item.onChange(formData[item.field])">
|
||||
<template v-if="!item.options" #notFoundContent>
|
||||
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" description="暂无数据" />
|
||||
</template>
|
||||
</a-select>
|
||||
</template>
|
||||
|
||||
<!-- 单选框 -->
|
||||
<template v-else-if="item.type === 'radio'">
|
||||
<a-radio-group v-model:value="formData[item.field]" :disabled="item.disabled"
|
||||
:button-style="item.buttonStyle" @change="item.onChange && item.onChange(formData[item.field])">
|
||||
<template v-if="item.options">
|
||||
<a-radio v-for="opt in item.options" :key="opt.value" :value="opt.value"
|
||||
:disabled="opt.disabled">
|
||||
{{ opt.label }}
|
||||
</a-radio>
|
||||
</template>
|
||||
<template v-else-if="item.buttonStyle === 'solid'">
|
||||
<a-radio-button v-for="opt in item.options" :key="opt.value" :value="opt.value"
|
||||
:disabled="opt.disabled">
|
||||
{{ opt.label }}
|
||||
</a-radio-button>
|
||||
</template>
|
||||
</a-radio-group>
|
||||
</template>
|
||||
|
||||
<!-- 多选框 -->
|
||||
<template v-else-if="item.type === 'checkbox'">
|
||||
<a-checkbox-group v-model:value="formData[item.field]" :disabled="item.disabled"
|
||||
@change="item.onChange && item.onChange(formData[item.field])">
|
||||
<template v-if="item.options">
|
||||
<a-checkbox v-for="opt in item.options" :key="opt.value" :value="opt.value"
|
||||
:disabled="opt.disabled">
|
||||
{{ opt.label }}
|
||||
</a-checkbox>
|
||||
</template>
|
||||
</a-checkbox-group>
|
||||
</template>
|
||||
|
||||
<!-- 开关 -->
|
||||
<template v-else-if="item.type === 'switch'">
|
||||
<a-switch v-model:checked="formData[item.field]" :disabled="item.disabled"
|
||||
:checked-children="item.checkedChildren || '开'" :un-checked-children="item.unCheckedChildren || '关'"
|
||||
@change="item.onChange && item.onChange(formData[item.field])" />
|
||||
</template>
|
||||
|
||||
<!-- 日期选择 -->
|
||||
<template v-else-if="item.type === 'date'">
|
||||
<a-date-picker v-model:value="formData[item.field]"
|
||||
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
|
||||
:format="item.format || 'YYYY-MM-DD'" :value-format="item.valueFormat || 'YYYY-MM-DD'"
|
||||
style="width: 100%" @change="item.onChange && item.onChange(formData[item.field])" />
|
||||
</template>
|
||||
|
||||
<!-- 日期范围选择 -->
|
||||
<template v-else-if="item.type === 'dateRange'">
|
||||
<a-range-picker v-model:value="formData[item.field]" :placeholder="item.placeholder || ['开始日期', '结束日期']"
|
||||
:disabled="item.disabled" :format="item.format || 'YYYY-MM-DD'"
|
||||
:value-format="item.valueFormat || 'YYYY-MM-DD'" style="width: 100%"
|
||||
@change="item.onChange && item.onChange(formData[item.field])" />
|
||||
</template>
|
||||
|
||||
<!-- 时间选择 -->
|
||||
<template v-else-if="item.type === 'time'">
|
||||
<a-time-picker v-model:value="formData[item.field]"
|
||||
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
|
||||
:format="item.format || 'HH:mm:ss'" :value-format="item.valueFormat || 'HH:mm:ss'"
|
||||
style="width: 100%" @change="item.onChange && item.onChange(formData[item.field])" />
|
||||
</template>
|
||||
|
||||
<!-- 上传 -->
|
||||
<template v-else-if="item.type === 'upload'">
|
||||
<a-upload v-model:file-list="formData[item.field]" :list-type="item.listType || 'text'"
|
||||
:action="item.action" :max-count="item.maxCount" :before-upload="item.beforeUpload"
|
||||
:custom-request="item.customRequest" :accept="item.accept" :disabled="item.disabled"
|
||||
@change="(info) => item.onChange && item.onChange(info)">
|
||||
<a-button v-if="item.listType !== 'picture-card'" type="primary">
|
||||
<UploadOutlined />
|
||||
点击上传
|
||||
</a-button>
|
||||
<div v-else>
|
||||
<PlusOutlined />
|
||||
<div class="ant-upload-text">上传</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</template>
|
||||
|
||||
<!-- 评分 -->
|
||||
<template v-else-if="item.type === 'rate'">
|
||||
<a-rate v-model:value="formData[item.field]" :disabled="item.disabled" :count="item.count || 5"
|
||||
:allow-half="item.allowHalf" @change="item.onChange && item.onChange(formData[item.field])" />
|
||||
</template>
|
||||
|
||||
<!-- 滑块 -->
|
||||
<template v-else-if="item.type === 'slider'">
|
||||
<a-slider v-model:value="formData[item.field]" :disabled="item.disabled" :min="item.min || 0"
|
||||
:max="item.max || 100" :step="item.step || 1" :marks="item.marks" :range="item.range"
|
||||
@change="item.onChange && item.onChange(formData[item.field])" />
|
||||
</template>
|
||||
|
||||
<!-- 级联选择 -->
|
||||
<template v-else-if="item.type === 'cascader'">
|
||||
<a-cascader v-model:value="formData[item.field]" :options="item.options"
|
||||
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
|
||||
:change-on-select="item.changeOnSelect" :field-names="item.fieldNames" style="width: 100%"
|
||||
@change="item.onChange && item.onChange(formData[item.field])" />
|
||||
</template>
|
||||
|
||||
<!-- 自定义插槽 -->
|
||||
<template v-else-if="item.type === 'slot'">
|
||||
<slot :name="item.slotName || item.field" :field="item.field" :value="formData[item.field]"></slot>
|
||||
</template>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<template v-if="item.tip">
|
||||
<div class="form-item-tip">{{ item.tip }}</div>
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 表单操作按钮 -->
|
||||
<a-form-item v-if="showActions" :wrapper-col="actionWrapperCol">
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="loading" :size="buttonSize">
|
||||
{{ submitText || '提交' }}
|
||||
</a-button>
|
||||
<a-button v-if="showReset" @click="handleReset" :size="buttonSize">
|
||||
{{ resetText || '重置' }}
|
||||
</a-button>
|
||||
<a-button v-if="showCancel" @click="handleCancel" :size="buttonSize">
|
||||
{{ cancelText || '取消' }}
|
||||
</a-button>
|
||||
<slot name="actions"></slot>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 自定义插槽 -->
|
||||
<slot></slot>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { Empty } from 'ant-design-vue'
|
||||
import { UploadOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
// 表单项配置
|
||||
formItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
required: true,
|
||||
},
|
||||
// 表单初始值
|
||||
initialValues: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
// 表单布局
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'horizontal', // horizontal, vertical, inline
|
||||
},
|
||||
// 标签宽度
|
||||
labelCol: {
|
||||
type: Object,
|
||||
default: () => ({ span: 6 }),
|
||||
},
|
||||
// 内容宽度
|
||||
wrapperCol: {
|
||||
type: Object,
|
||||
default: () => ({ span: 16 }),
|
||||
},
|
||||
// 是否显示操作按钮
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 操作按钮布局
|
||||
actionWrapperCol: {
|
||||
type: Object,
|
||||
default: () => ({ offset: 6, span: 16 }),
|
||||
},
|
||||
// 是否显示重置按钮
|
||||
showReset: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 是否显示取消按钮
|
||||
showCancel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 按钮文字
|
||||
submitText: String,
|
||||
resetText: String,
|
||||
cancelText: String,
|
||||
// 按钮大小
|
||||
buttonSize: {
|
||||
type: String,
|
||||
default: 'middle',
|
||||
},
|
||||
// 加载状态
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['finish', 'finish-failed', 'reset', 'cancel'])
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({ ...props.initialValues })
|
||||
|
||||
// 表单验证规则
|
||||
const rules = computed(() => {
|
||||
const result = {}
|
||||
props.formItems.forEach((item) => {
|
||||
if (item.rules && item.rules.length > 0) {
|
||||
result[item.field] = item.rules
|
||||
}
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
// 监听初始值变化
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
(newVal) => {
|
||||
Object.assign(formData, newVal)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 表单提交
|
||||
const handleFinish = (values) => {
|
||||
emit('finish', values)
|
||||
}
|
||||
|
||||
// 表单验证失败
|
||||
const handleFinishFailed = (errorInfo) => {
|
||||
emit('finish-failed', errorInfo)
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
Object.assign(formData, props.initialValues)
|
||||
emit('reset', formData)
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
formData,
|
||||
resetForm: handleReset,
|
||||
setFieldValue: (field, value) => {
|
||||
formData[field] = value
|
||||
},
|
||||
getFieldValue: (field) => {
|
||||
return formData[field]
|
||||
},
|
||||
setFieldsValue: (values) => {
|
||||
Object.assign(formData, values)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-item-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div class="sc-icon-picker">
|
||||
<a-input :value="selectedIcon ? '' : ''" :placeholder="placeholder" readonly @click="handleOpenPicker">
|
||||
<template #prefix v-if="selectedIcon">
|
||||
<component :is="selectedIcon" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<SearchOutlined v-if="!selectedIcon" />
|
||||
<CloseCircleFilled v-else @click.stop="handleClear" />
|
||||
</template>
|
||||
</a-input>
|
||||
|
||||
<a-modal v-model:open="visible" title="选择图标" :width="800" :footer="null" @cancel="handleCancel">
|
||||
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
|
||||
<a-tab-pane key="antd" tab="Ant Design">
|
||||
<div class="icon-search">
|
||||
<a-input v-model:value="searchAntdValue" placeholder="搜索图标..." allow-clear>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
<div class="icon-list">
|
||||
<div
|
||||
v-for="icon in filteredAntdIcons"
|
||||
:key="icon"
|
||||
:class="['icon-item', { active: tempIcon === icon }]"
|
||||
@click="handleSelectIcon(icon)"
|
||||
>
|
||||
<component :is="icon" />
|
||||
<div class="icon-name">{{ icon }}</div>
|
||||
</div>
|
||||
<a-empty v-if="filteredAntdIcons.length === 0" description="暂无图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="element" tab="Element Plus">
|
||||
<div class="icon-search">
|
||||
<a-input v-model:value="searchElementValue" placeholder="搜索图标..." allow-clear>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
<div class="icon-list">
|
||||
<div
|
||||
v-for="icon in filteredElementIcons"
|
||||
:key="icon"
|
||||
:class="['icon-item', { active: tempIcon === icon }]"
|
||||
@click="handleSelectIcon(icon)"
|
||||
>
|
||||
<component :is="icon" />
|
||||
<div class="icon-name">{{ icon.replace('El', '') }}</div>
|
||||
</div>
|
||||
<a-empty v-if="filteredElementIcons.length === 0" description="暂无图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* @component scIconPicker
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Empty } from 'ant-design-vue'
|
||||
import { SearchOutlined, CloseCircleFilled } from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请选择图标',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const visible = ref(false)
|
||||
const activeTab = ref('antd')
|
||||
const searchAntdValue = ref('')
|
||||
const searchElementValue = ref('')
|
||||
const tempIcon = ref('')
|
||||
|
||||
// Ant Design 图标列表(常用图标)
|
||||
const antdIcons = [
|
||||
'HomeOutlined',
|
||||
'UserOutlined',
|
||||
'SettingOutlined',
|
||||
'EditOutlined',
|
||||
'DeleteOutlined',
|
||||
'PlusOutlined',
|
||||
'MinusOutlined',
|
||||
'CheckOutlined',
|
||||
'CloseOutlined',
|
||||
'SearchOutlined',
|
||||
'FilterOutlined',
|
||||
'ReloadOutlined',
|
||||
'DownloadOutlined',
|
||||
'UploadOutlined',
|
||||
'FileTextOutlined',
|
||||
'FolderOutlined',
|
||||
'PictureOutlined',
|
||||
'VideoCameraOutlined',
|
||||
'AudioOutlined',
|
||||
'FileOutlined',
|
||||
'CalendarOutlined',
|
||||
'ClockCircleOutlined',
|
||||
'HeartOutlined',
|
||||
'StarOutlined',
|
||||
'ThumbUpOutlined',
|
||||
'MessageOutlined',
|
||||
'PhoneOutlined',
|
||||
'MailOutlined',
|
||||
'EnvironmentOutlined',
|
||||
'GlobalOutlined',
|
||||
'LinkOutlined',
|
||||
'LockOutlined',
|
||||
'UnlockOutlined',
|
||||
'EyeOutlined',
|
||||
'EyeInvisibleOutlined',
|
||||
'ArrowLeftOutlined',
|
||||
'ArrowRightOutlined',
|
||||
'ArrowUpOutlined',
|
||||
'ArrowDownOutlined',
|
||||
'CaretLeftOutlined',
|
||||
'CaretRightOutlined',
|
||||
'CaretUpOutlined',
|
||||
'CaretDownOutlined',
|
||||
'LeftOutlined',
|
||||
'RightOutlined',
|
||||
'UpOutlined',
|
||||
'DownOutlined',
|
||||
'MenuFoldOutlined',
|
||||
'MenuUnfoldOutlined',
|
||||
'BarsOutlined',
|
||||
'MoreOutlined',
|
||||
'EllipsisOutlined',
|
||||
'DashboardOutlined',
|
||||
'AppstoreOutlined',
|
||||
'LaptopOutlined',
|
||||
'DesktopOutlined',
|
||||
'TabletOutlined',
|
||||
'MobileOutlined',
|
||||
'WifiOutlined',
|
||||
'BluetoothOutlined',
|
||||
'ThunderboltOutlined',
|
||||
'BulbOutlined',
|
||||
'SoundOutlined',
|
||||
'NotificationOutlined',
|
||||
'BellOutlined',
|
||||
'AlertOutlined',
|
||||
'WarningOutlined',
|
||||
'InfoCircleOutlined',
|
||||
'QuestionCircleOutlined',
|
||||
'CheckCircleOutlined',
|
||||
'CloseCircleOutlined',
|
||||
'StopOutlined',
|
||||
'ExclamationCircleOutlined',
|
||||
'SafetyOutlined',
|
||||
'ShieldCheckOutlined',
|
||||
'SecurityScanOutlined',
|
||||
'KeyOutlined',
|
||||
'IdcardOutlined',
|
||||
'ProfileOutlined',
|
||||
'SolutionOutlined',
|
||||
'ContactsOutlined',
|
||||
'TeamOutlined',
|
||||
'UsergroupAddOutlined',
|
||||
'UsergroupDeleteOutlined',
|
||||
'CrownOutlined',
|
||||
'GoldOutlined',
|
||||
'MoneyCollectOutlined',
|
||||
'BankOutlined',
|
||||
'PayCircleOutlined',
|
||||
'CreditCardOutlined',
|
||||
'WalletOutlined',
|
||||
'ShoppingCartOutlined',
|
||||
'ShoppingOutlined',
|
||||
'GiftOutlined',
|
||||
'HddOutlined',
|
||||
'DatabaseOutlined',
|
||||
'CloudOutlined',
|
||||
'CloudUploadOutlined',
|
||||
'CloudDownloadOutlined',
|
||||
'ServerOutlined',
|
||||
'AuditOutlined',
|
||||
'NodeIndexOutlined',
|
||||
'ReconciliationOutlined',
|
||||
'PartitionOutlined',
|
||||
'AccountBookOutlined',
|
||||
'ProjectOutlined',
|
||||
'ControlOutlined',
|
||||
'MonitorOutlined',
|
||||
'TagsOutlined',
|
||||
'TagOutlined',
|
||||
'BookOutlined',
|
||||
'ReadOutlined',
|
||||
'ExperimentOutlined',
|
||||
'FireOutlined',
|
||||
'RocketOutlined',
|
||||
'TrophyOutlined',
|
||||
'MedalOutlined',
|
||||
'DiamondOutlined',
|
||||
'ThunderboltTwoTone',
|
||||
]
|
||||
|
||||
// Element Plus 图标列表(常用图标)
|
||||
const elementIcons = [
|
||||
'ElIconEdit',
|
||||
'ElIconDelete',
|
||||
'ElIconSearch',
|
||||
'ElIconClose',
|
||||
'ElIconCheck',
|
||||
'ElIconPlus',
|
||||
'ElIconMinus',
|
||||
'ElIconUpload',
|
||||
'ElIconDownload',
|
||||
'ElIconSetting',
|
||||
'ElIconRefresh',
|
||||
'ElIconRefreshLeft',
|
||||
'ElIconRefreshRight',
|
||||
'ElIconMenu',
|
||||
'ElIconMore',
|
||||
'ElIconMoreFilled',
|
||||
'ElIconStar',
|
||||
'ElIconStarFilled',
|
||||
'ElIconSunny',
|
||||
'ElIconMoon',
|
||||
'ElIconBell',
|
||||
'ElIconBellFilled',
|
||||
'ElIconMessage',
|
||||
'ElIconMessageFilled',
|
||||
'ElIconChatDotRound',
|
||||
'ElIconChatLineSquare',
|
||||
'ElIconChatDotSquare',
|
||||
'ElIconPhone',
|
||||
'ElIconPhoneFilled',
|
||||
'ElIconLocation',
|
||||
'ElIconLocationFilled',
|
||||
'ElIconLocationInformation',
|
||||
'ElIconView',
|
||||
'ElIconHide',
|
||||
'ElIconLock',
|
||||
'ElIconUnlock',
|
||||
'ElIconKey',
|
||||
'ElIconTickets',
|
||||
'ElIconDocument',
|
||||
'ElIconDocumentAdd',
|
||||
'ElIconDocumentDelete',
|
||||
'ElIconDocumentCopy',
|
||||
'ElIconDocumentChecked',
|
||||
'ElIconDocumentRemove',
|
||||
'ElIconFolder',
|
||||
'ElIconFolderOpened',
|
||||
'ElIconFolderAdd',
|
||||
'ElIconFolderDelete',
|
||||
'ElIconFolderChecked',
|
||||
'ElIconFiles',
|
||||
'ElIconPicture',
|
||||
'ElIconPictureRounded',
|
||||
'ElIconPictureFilled',
|
||||
'ElIconVideoCamera',
|
||||
'ElIconVideoCameraFilled',
|
||||
'ElIconMicrophone',
|
||||
'ElIconMicrophoneFilled',
|
||||
'ElIconHeadset',
|
||||
'ElIconHeadsetFilled',
|
||||
'ElIconMuteNotification',
|
||||
'ElIconNotification',
|
||||
'ElIconWarning',
|
||||
'ElIconWarningFilled',
|
||||
'ElIconInfoFilled',
|
||||
'ElIconSuccessFilled',
|
||||
'ElIconCircleCheck',
|
||||
'ElIconCircleCheckFilled',
|
||||
'ElIconCircleClose',
|
||||
'ElIconCircleCloseFilled',
|
||||
'ElIconCirclePlus',
|
||||
'ElIconCirclePlusFilled',
|
||||
'ElIconCircleMinus',
|
||||
'ElIconCircleMinusFilled',
|
||||
'ElIconAim',
|
||||
'ElIconPosition',
|
||||
'ElIconCompass',
|
||||
'ElIconMapLocation',
|
||||
'ElIconPromotion',
|
||||
'ElIconDownload',
|
||||
'ElIconUploadFilled',
|
||||
'ElIconShare',
|
||||
'ElIconConnection',
|
||||
'ElIconLink',
|
||||
'ElIconUnlink',
|
||||
'ElIconOperation',
|
||||
'ElIconDataAnalysis',
|
||||
'ElIconDataLine',
|
||||
'ElIconDataBoard',
|
||||
'ElIconHistogram',
|
||||
'ElIconTrendCharts',
|
||||
'ElIconPieChart',
|
||||
'ElIconOdometer',
|
||||
'ElIconMonitor',
|
||||
'ElIconTimer',
|
||||
'ElIconClock',
|
||||
'ElIconAlarmClock',
|
||||
'ElIconCalendar',
|
||||
'ElIconDate',
|
||||
'ElIconSwitch',
|
||||
'ElIconSwitchButton',
|
||||
'ElIconTools',
|
||||
'ElIconScrewdriver',
|
||||
'ElIconHammer',
|
||||
'ElIconBrush',
|
||||
'ElIconEditPen',
|
||||
'ElIconBriefcase',
|
||||
'ElIconWallet',
|
||||
'ElIconGoods',
|
||||
'ElIconShoppingCart',
|
||||
'ElIconShoppingCartFull',
|
||||
'ElIconShoppingBag',
|
||||
'ElIconPresent',
|
||||
'ElIconSoldOut',
|
||||
'ElIconSell',
|
||||
'ElIconDiscount',
|
||||
'ElIconTicket',
|
||||
'ElIconCoin',
|
||||
'ElIconMoney',
|
||||
'ElIconWalletFilled',
|
||||
'ElIconCreditCard',
|
||||
'ElIconUser',
|
||||
'ElIconUserFilled',
|
||||
'ElIconAvatar',
|
||||
'ElIconSuitcase',
|
||||
'ElIconGrid',
|
||||
'ElIconMenuFilled',
|
||||
'ElIconHomeFilled',
|
||||
'ElIconHouse',
|
||||
'ElIconOfficeBuilding',
|
||||
'ElIconSchool',
|
||||
'ElIconReading',
|
||||
'ElIconReadingLamp',
|
||||
'ElIconNotebook',
|
||||
'ElIconNotebookFilled',
|
||||
'ElIconFinished',
|
||||
'ElIconCollection',
|
||||
'ElIconCollectionTag',
|
||||
'ElIconFiles',
|
||||
'ElIconPostcard',
|
||||
'ElIconMemo',
|
||||
'ElIconStamp',
|
||||
'ElIconPriceTag',
|
||||
'ElIconMedal',
|
||||
'ElIconTrophy',
|
||||
'ElIconTrophyBase',
|
||||
'ElIconFirstAidKit',
|
||||
'ElIconToiletPaper',
|
||||
'ElIconAim',
|
||||
'ElIconSFlag',
|
||||
'ElIconSOpportunity',
|
||||
'ElIconMagicStick',
|
||||
'ElIconHelp',
|
||||
'ElIconQuestionFilled',
|
||||
'ElIconWarning',
|
||||
'ElIconWarningFilled',
|
||||
]
|
||||
|
||||
// 当前选中的图标
|
||||
const selectedIcon = ref(props.modelValue)
|
||||
|
||||
// 过滤后的 Ant Design 图标
|
||||
const filteredAntdIcons = computed(() => {
|
||||
if (!searchAntdValue.value) {
|
||||
return antdIcons
|
||||
}
|
||||
return antdIcons.filter((icon) =>
|
||||
icon.toLowerCase().includes(searchAntdValue.value.toLowerCase()),
|
||||
)
|
||||
})
|
||||
|
||||
// 过滤后的 Element 图标
|
||||
const filteredElementIcons = computed(() => {
|
||||
if (!searchElementValue.value) {
|
||||
return elementIcons
|
||||
}
|
||||
return elementIcons.filter((icon) =>
|
||||
icon.toLowerCase().includes(searchElementValue.value.toLowerCase()),
|
||||
)
|
||||
})
|
||||
|
||||
// 打开选择器
|
||||
const handleOpenPicker = () => {
|
||||
tempIcon.value = props.modelValue
|
||||
// 根据当前图标设置默认标签页
|
||||
if (props.modelValue) {
|
||||
activeTab.value = props.modelValue.startsWith('El') ? 'element' : 'antd'
|
||||
}
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 清除选择
|
||||
const handleClear = () => {
|
||||
emit('update:modelValue', '')
|
||||
emit('change', '')
|
||||
}
|
||||
|
||||
// 切换标签页
|
||||
const handleTabChange = (key) => {
|
||||
activeTab.value = key
|
||||
}
|
||||
|
||||
// 选择图标(直接确认并关闭)
|
||||
const handleSelectIcon = (icon) => {
|
||||
emit('update:modelValue', icon)
|
||||
emit('change', icon)
|
||||
selectedIcon.value = icon
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 取消选择
|
||||
const handleCancel = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 监听props变化,更新本地状态
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
selectedIcon.value = newVal
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sc-icon-picker {
|
||||
:deep(.ant-input) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-search {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.icon-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
:deep(svg) {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.icon-name {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
# scImport 异步导入组件
|
||||
|
||||
异步导入组件,支持文件上传、表单参数配置、模板下载等功能。
|
||||
|
||||
## 基本使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<a-button type="primary" @click="showImport = true">
|
||||
<import-outlined /> 导入数据
|
||||
</a-button>
|
||||
|
||||
<sc-import
|
||||
v-model:open="showImport"
|
||||
:api="authApi.users.import.post"
|
||||
:template-api="authApi.users.downloadTemplate.get"
|
||||
title="导入用户"
|
||||
filename="用户数据"
|
||||
@success="handleImportSuccess"
|
||||
@error="handleImportError"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import authApi from '@/api/auth'
|
||||
|
||||
const showImport = ref(false)
|
||||
|
||||
const handleImportSuccess = (data) => {
|
||||
console.log('导入成功', data)
|
||||
// 刷新列表等操作
|
||||
}
|
||||
|
||||
const handleImportError = (message) => {
|
||||
console.log('导入失败', message)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
|------|------|------|--------|
|
||||
| open | 是否显示弹窗 | Boolean | false |
|
||||
| title | 弹窗标题 | String | '导入数据' |
|
||||
| api | 导入API接口 | Function | 必填 |
|
||||
| templateApi | 下载模板API接口 | Function | null |
|
||||
| accept | 接受的文件类型 | String | '.xlsx,.xls,.csv' |
|
||||
| maxSize | 文件大小限制(MB) | Number | 10 |
|
||||
| showTemplate | 是否显示下载模板 | Boolean | true |
|
||||
| tip | 提示信息 | String | '' |
|
||||
| filename | 文件名(用于下载) | String | '导入数据' |
|
||||
|
||||
## Events
|
||||
|
||||
| 事件名 | 说明 | 回调参数 |
|
||||
|--------|------|----------|
|
||||
| update:open | 弹窗显示状态变化 | (visible: Boolean) |
|
||||
| success | 导入成功 | (data, response) |
|
||||
| error | 导出失败 | (message, error) |
|
||||
| change | 文件列表变化 | (fileList) |
|
||||
|
||||
## Slots
|
||||
|
||||
### formParams
|
||||
|
||||
自定义表单参数插槽,可用于添加额外的表单字段。
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<sc-import
|
||||
v-model:open="showImport"
|
||||
:api="importApi"
|
||||
@success="handleSuccess"
|
||||
>
|
||||
<template #formParams="{ formData }">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="部门">
|
||||
<a-select
|
||||
v-model:value="formData.department_id"
|
||||
placeholder="请选择部门"
|
||||
:options="departmentOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="覆盖已有数据">
|
||||
<a-switch v-model:checked="formData.overwrite" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
</sc-import>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<a-button type="primary" @click="handleImport">
|
||||
<import-outlined /> 导入用户
|
||||
</a-button>
|
||||
|
||||
<sc-import
|
||||
v-model:open="importVisible"
|
||||
:api="authApi.users.import.post"
|
||||
:template-api="authApi.users.downloadTemplate.get"
|
||||
title="导入用户数据"
|
||||
accept=".xlsx,.xls"
|
||||
:max-size="20"
|
||||
:show-template="true"
|
||||
tip="请按照模板格式填写数据,每次最多导入 1000 条数据"
|
||||
filename="用户数据"
|
||||
@success="handleImportSuccess"
|
||||
@error="handleImportError"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<template #formParams="{ formData }">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="所属部门" required>
|
||||
<a-select
|
||||
v-model:value="formData.department_id"
|
||||
placeholder="请选择部门"
|
||||
:options="departmentOptions"
|
||||
:field-names="{ label: 'name', value: 'id' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="用户角色">
|
||||
<a-select
|
||||
v-model:value="formData.role_ids"
|
||||
mode="multiple"
|
||||
placeholder="请选择角色"
|
||||
:options="roleOptions"
|
||||
:field-names="{ label: 'name', value: 'id' }"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="是否激活">
|
||||
<a-switch v-model:checked="formData.is_active" checked-children="是" un-checked-children="否" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
</sc-import>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ImportOutlined } from '@ant-design/icons-vue'
|
||||
import authApi from '@/api/auth'
|
||||
|
||||
const importVisible = ref(false)
|
||||
const departmentOptions = ref([])
|
||||
const roleOptions = ref([])
|
||||
|
||||
// 加载选项数据
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [deptRes, roleRes] = await Promise.all([
|
||||
authApi.departments.all.get(),
|
||||
authApi.roles.all.get()
|
||||
])
|
||||
departmentOptions.value = deptRes.data || []
|
||||
roleOptions.value = roleRes.data || []
|
||||
} catch (error) {
|
||||
console.error('加载选项失败', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleImport = () => {
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
const handleImportSuccess = (data) => {
|
||||
message.success('导入成功')
|
||||
// 刷新列表或执行其他操作
|
||||
}
|
||||
|
||||
const handleImportError = (errorMessage) => {
|
||||
message.error('导入失败:' + errorMessage)
|
||||
}
|
||||
|
||||
const handleFileChange = (fileList) => {
|
||||
console.log('文件列表变化', fileList)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 组件会自动处理文件上传、表单参数合并等逻辑
|
||||
2. 表单参数会通过 FormData 发送到后端
|
||||
3. 数组和对象类型的参数会被转换为 JSON 字符串
|
||||
4. 下载模板功能需要后端提供对应的 API 接口
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="title"
|
||||
:width="600"
|
||||
:confirm-loading="loading"
|
||||
:ok-button-props="{ disabled: !fileList.length }"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="import-modal">
|
||||
<!-- 表单参数插槽 -->
|
||||
<div v-if="$slots.formParams" class="form-params">
|
||||
<slot name="formParams" :form-data="formData" />
|
||||
</div>
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div class="upload-section">
|
||||
<a-upload-dragger
|
||||
v-model:file-list="fileList"
|
||||
:custom-request="customUpload"
|
||||
:before-upload="beforeUpload"
|
||||
:accept="accept"
|
||||
:max-count="1"
|
||||
:disabled="loading"
|
||||
@change="handleChange"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<p class="ant-upload-drag-icon">
|
||||
<inbox-outlined />
|
||||
</p>
|
||||
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||
<p class="ant-upload-hint">
|
||||
{{ acceptTip }}
|
||||
</p>
|
||||
</a-upload-dragger>
|
||||
</div>
|
||||
|
||||
<!-- 下载模板链接 -->
|
||||
<div v-if="showTemplate" class="template-section">
|
||||
<a-alert type="info" show-icon>
|
||||
<template #message>
|
||||
<span>请先下载模板,按照模板格式填写数据后上传</span>
|
||||
</template>
|
||||
<template #action>
|
||||
<a-button type="link" size="small" :loading="templateLoading" @click="handleDownloadTemplate">
|
||||
<download-outlined /> 下载模板
|
||||
</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
</div>
|
||||
|
||||
<!-- 上传提示 -->
|
||||
<div v-if="tip" class="tip-section">
|
||||
<a-alert type="warning" show-icon>
|
||||
<template #message>{{ tip }}</template>
|
||||
</a-alert>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { InboxOutlined, DownloadOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
// 是否显示弹窗
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 弹窗标题
|
||||
title: {
|
||||
type: String,
|
||||
default: '导入数据'
|
||||
},
|
||||
// 导入API接口
|
||||
api: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
// 下载模板API接口
|
||||
templateApi: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
// 接受的文件类型
|
||||
accept: {
|
||||
type: String,
|
||||
default: '.xlsx,.xls,.csv'
|
||||
},
|
||||
// 文件大小限制(MB)
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
// 是否显示下载模板
|
||||
showTemplate: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 提示信息
|
||||
tip: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 文件名(用于下载)
|
||||
filename: {
|
||||
type: String,
|
||||
default: '导入数据'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'success', 'error', 'change'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = ref(false)
|
||||
|
||||
// 文件列表
|
||||
const fileList = ref([])
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
const templateLoading = ref(false)
|
||||
|
||||
// 表单数据(用于插槽)
|
||||
const formData = ref({})
|
||||
|
||||
// 文件类型提示
|
||||
const acceptTip = ref(props.accept || '支持 .xlsx, .xls, .csv 格式文件')
|
||||
|
||||
// 监听外部 open 变化
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
// 打开时重置表单
|
||||
fileList.value = []
|
||||
formData.value = {}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听内部 visible 变化,同步到外部
|
||||
watch(visible, (val) => {
|
||||
emit('update:open', val)
|
||||
})
|
||||
|
||||
// 自定义上传
|
||||
const customUpload = (options) => {
|
||||
const { onSuccess } = options
|
||||
// 这里不直接上传,只是标记为准备上传
|
||||
onSuccess({}, options.file)
|
||||
}
|
||||
|
||||
// 上传前校验
|
||||
const beforeUpload = (file) => {
|
||||
// 文件类型校验
|
||||
const acceptTypes = props.accept.split(',').map((type) => type.trim().toLowerCase())
|
||||
const fileName = file.name.toLowerCase()
|
||||
const isValidType = acceptTypes.some((type) => fileName.endsWith(type))
|
||||
|
||||
if (!isValidType) {
|
||||
message.error(`文件格式不正确,仅支持 ${props.accept} 格式`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 文件大小校验
|
||||
const maxSizeBytes = props.maxSize * 1024 * 1024
|
||||
if (file.size > maxSizeBytes) {
|
||||
message.error(`文件大小不能超过 ${props.maxSize}MB`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理文件变化
|
||||
const handleChange = ({ fileList: newFileList }) => {
|
||||
fileList.value = newFileList
|
||||
emit('change', newFileList)
|
||||
}
|
||||
|
||||
// 拖拽相关
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
// 下载模板
|
||||
const handleDownloadTemplate = async () => {
|
||||
if (!props.templateApi) {
|
||||
message.error('未配置模板下载接口')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
templateLoading.value = true
|
||||
const blob = await props.templateApi()
|
||||
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${props.filename}-模板.xlsx`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
message.success('模板下载成功')
|
||||
} catch (error) {
|
||||
message.error('模板下载失败:' + error.message)
|
||||
} finally {
|
||||
templateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 确认导入
|
||||
const handleOk = async () => {
|
||||
if (fileList.value.length === 0) {
|
||||
message.error('请选择要导入的文件')
|
||||
return
|
||||
}
|
||||
|
||||
const file = fileList.value[0]
|
||||
const formDataObj = new FormData()
|
||||
|
||||
// 添加文件
|
||||
if (file.originFileObj) {
|
||||
formDataObj.append('file', file.originFileObj)
|
||||
} else if (file.url) {
|
||||
// 如果是已有文件,可能需要重新处理
|
||||
message.error('请重新选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 添加表单参数(如果有)
|
||||
if (Object.keys(formData.value).length > 0) {
|
||||
Object.keys(formData.value).forEach((key) => {
|
||||
const value = formData.value[key]
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
if (Array.isArray(value)) {
|
||||
formDataObj.append(key, JSON.stringify(value))
|
||||
} else if (typeof value === 'object') {
|
||||
formDataObj.append(key, JSON.stringify(value))
|
||||
} else {
|
||||
formDataObj.append(key, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await props.api(formDataObj)
|
||||
|
||||
if (res.code === 200 || res.success) {
|
||||
message.success('导入成功')
|
||||
emit('success', res.data, res)
|
||||
handleCancel()
|
||||
} else {
|
||||
message.error(res.message || '导入失败')
|
||||
emit('error', res.message, res)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('导入失败:' + error.message)
|
||||
emit('error', error.message, error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消导入
|
||||
const handleCancel = () => {
|
||||
visible.value = false
|
||||
fileList.value = []
|
||||
formData.value = {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.import-modal {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.form-params {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.template-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tip-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-upload-drag-icon {
|
||||
margin-bottom: 8px;
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.ant-upload-text {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-upload-hint {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,546 @@
|
||||
<template>
|
||||
<div class="sc-table" ref="tableWrapper">
|
||||
<!-- 表格内容 -->
|
||||
<div class="sc-table-content" ref="tableContent">
|
||||
<a-table :columns="tableColumns" :data-source="dataSource" :loading="loading" :pagination="false"
|
||||
:row-key="rowKey" :row-selection="rowSelection" :scroll="scroll" :bordered="tableSettings.bordered"
|
||||
:size="tableSettings.size" :show-header="showHeader" :locale="locale" @change="handleTableChange"
|
||||
@resizeColumn="handleResizeColumn">
|
||||
<!-- 自定义单元格内容 -->
|
||||
<template #bodyCell="{ text, record, index, column }">
|
||||
<!-- 序号列 -->
|
||||
<template v-if="column.dataIndex === '_index'">
|
||||
{{ getTableIndex(index) }}
|
||||
</template>
|
||||
<!-- 自定义插槽 -->
|
||||
<template v-else-if="column.slot">
|
||||
<slot :name="column.slot || column.dataIndex" :text="text" :record="record" :index="index"
|
||||
:column="column"></slot>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<template #emptyText>
|
||||
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="emptyText" />
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div v-if="showToolbar" class="sc-table-tool">
|
||||
<div class="tool-left">
|
||||
<a-pagination v-bind="pagination" @change="handlePaginationChange"
|
||||
@showSizeChange="handlePaginationChange" />
|
||||
</div>
|
||||
<div class="tool-right">
|
||||
<!-- 右侧工具栏插槽 -->
|
||||
<slot name="toolRight"></slot>
|
||||
<!-- 刷新按钮 -->
|
||||
<a-tooltip v-if="showRefresh" title="刷新">
|
||||
<a-button shape="circle" :loading="loading" @click="handleRefresh">
|
||||
<template #icon>
|
||||
<SyncOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 表格设置按钮 -->
|
||||
<a-tooltip v-if="showColumnSetting" title="表格设置">
|
||||
<a-popover v-model:open="tableSettingVisible" placement="topRight" trigger="click" :width="240">
|
||||
<template #content>
|
||||
<div class="table-setting">
|
||||
<div class="table-setting-header">
|
||||
<span>表格设置</span>
|
||||
</div>
|
||||
<div class="table-setting-body">
|
||||
<!-- 边框设置 -->
|
||||
<div class="setting-item">
|
||||
<span class="setting-label">显示边框</span>
|
||||
<a-switch v-model:checked="tableSettings.bordered" size="small" />
|
||||
</div>
|
||||
<!-- 表格大小 -->
|
||||
<div class="setting-item">
|
||||
<span class="setting-label">表格大小</span>
|
||||
<a-radio-group v-model:value="tableSettings.size" size="small"
|
||||
button-style="solid">
|
||||
<a-radio-button value="small">小</a-radio-button>
|
||||
<a-radio-button value="middle">中</a-radio-button>
|
||||
<a-radio-button value="large">大</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-button shape="circle">
|
||||
<template #icon>
|
||||
<TableOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popover>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 列设置按钮 -->
|
||||
<a-tooltip v-if="showColumnSetting" title="列设置">
|
||||
<a-popover v-model:open="columnSettingVisible" placement="topRight" trigger="click">
|
||||
<template #content>
|
||||
<div class="column-setting">
|
||||
<div class="column-setting-header">
|
||||
<span>显示与排序</span>
|
||||
</div>
|
||||
<div class="column-setting-list">
|
||||
<div v-for="(colKey, index) in sortedColumns" :key="colKey"
|
||||
class="column-setting-item" :class="{ dragging: draggingIndex === index }"
|
||||
draggable="true" @dragstart="handleDragStart(index, $event)"
|
||||
@dragover="handleDragOver(index, $event)" @dragend="handleDragEnd"
|
||||
@drop="handleDrop(index)">
|
||||
<HolderOutlined class="drag-handle" />
|
||||
<a-checkbox :checked="visibleColumns.includes(colKey)"
|
||||
@change="(e) => toggleColumn(colKey, e.target.checked)">
|
||||
{{ getColumnTitle(colKey) }}
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-button shape="circle">
|
||||
<template #icon>
|
||||
<HolderOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popover>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, reactive, useTemplateRef, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { Empty } from 'ant-design-vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'scTable',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
// 数据源
|
||||
dataSource: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// 列配置
|
||||
columns: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
required: true,
|
||||
},
|
||||
// 行的唯一标识
|
||||
rowKey: {
|
||||
type: [String, Function],
|
||||
default: 'id',
|
||||
},
|
||||
// 加载状态
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 分页配置
|
||||
pagination: {
|
||||
type: [Object, Boolean],
|
||||
default: () => ({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
pageSizeOptions: ['20', '50', '100', '200'],
|
||||
}),
|
||||
},
|
||||
// 行选择配置
|
||||
rowSelection: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
// 表格大小
|
||||
size: {
|
||||
type: String,
|
||||
default: 'middle', // large, middle, small
|
||||
},
|
||||
// 是否显示边框
|
||||
bordered: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 是否显示表头
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 本地化配置
|
||||
locale: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
// 是否显示序号列
|
||||
showIndex: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 序号列宽度
|
||||
indexColumnWidth: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
// 序号列标题
|
||||
indexTitle: {
|
||||
type: String,
|
||||
default: '序号',
|
||||
},
|
||||
// 是否显示工具栏
|
||||
showToolbar: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 是否显示刷新按钮
|
||||
showRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 是否显示列设置
|
||||
showColumnSetting: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 空状态文字
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '暂无数据',
|
||||
},
|
||||
})
|
||||
|
||||
const tableContent = useTemplateRef('tableContent')
|
||||
const tableWrapper = useTemplateRef('tableWrapper')
|
||||
let scroll = ref({
|
||||
scrollToFirstRowOnChange: true,
|
||||
x: 'max-content',
|
||||
y: true,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
updateTableHeight()
|
||||
})
|
||||
|
||||
const updateTableHeight = () => {
|
||||
if (tableContent.value) {
|
||||
const tableHeight = tableContent.value.clientHeight - 56
|
||||
scroll.value.y = tableHeight > 0 ? tableHeight : 400
|
||||
}
|
||||
}
|
||||
|
||||
// 根据表格宽度优化横向滚动配置
|
||||
watch(
|
||||
[() => props.columns, () => props.showIndex, tableContent],
|
||||
() => {
|
||||
// 如果列有固定宽度且总宽度较大,使用max-content
|
||||
// 否则使用true让表格自适应
|
||||
const hasFixedColumns = props.columns.some((col) => col.width)
|
||||
if (hasFixedColumns || props.showIndex) {
|
||||
scroll.value.x = 'max-content'
|
||||
} else {
|
||||
scroll.value.x = true
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
// 表格设置状态
|
||||
const tableSettings = reactive({
|
||||
bordered: props.bordered,
|
||||
size: props.size,
|
||||
})
|
||||
|
||||
// 监听props变化
|
||||
watch(
|
||||
() => props.bordered,
|
||||
(val) => {
|
||||
tableSettings.bordered = val
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.size,
|
||||
(val) => {
|
||||
tableSettings.size = val
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits(['refresh', 'change', 'resizeColumn', 'select', 'selectAll', 'selectNone', 'paginationChange'])
|
||||
|
||||
// 列设置相关
|
||||
const columnSettingVisible = ref(false)
|
||||
const tableSettingVisible = ref(false)
|
||||
const visibleColumns = ref([])
|
||||
const sortedColumns = ref([]) // 排序后的列key数组
|
||||
const draggingIndex = ref(-1) // 当前拖拽的索引
|
||||
|
||||
// 所有列
|
||||
const allColumns = computed(() => {
|
||||
return props.columns.filter((col) => col.dataIndex && col.dataIndex !== '_index')
|
||||
})
|
||||
|
||||
// 获取列标题
|
||||
const getColumnTitle = (colKey) => {
|
||||
const col = allColumns.value.find((c) => (c.dataIndex || c.key) === colKey)
|
||||
return col ? col.title : colKey
|
||||
}
|
||||
|
||||
// 初始化可见列和排序
|
||||
watch(
|
||||
() => props.columns,
|
||||
(newColumns) => {
|
||||
const columnKeys = newColumns.filter((col) => col.dataIndex && col.dataIndex !== '_index').map((col) => col.dataIndex || col.key)
|
||||
|
||||
// 如果是首次初始化,使用原始顺序
|
||||
if (sortedColumns.value.length === 0) {
|
||||
sortedColumns.value = [...columnKeys]
|
||||
} else {
|
||||
// 保留已存在的顺序,添加新列
|
||||
const existingKeys = sortedColumns.value.filter((key) => columnKeys.includes(key))
|
||||
const newKeys = columnKeys.filter((key) => !existingKeys.includes(key))
|
||||
sortedColumns.value = [...existingKeys, ...newKeys]
|
||||
}
|
||||
|
||||
visibleColumns.value = [...sortedColumns.value]
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
// 切换列的显示状态
|
||||
const toggleColumn = (colKey, checked) => {
|
||||
if (checked) {
|
||||
if (!visibleColumns.value.includes(colKey)) {
|
||||
visibleColumns.value.push(colKey)
|
||||
}
|
||||
} else {
|
||||
visibleColumns.value = visibleColumns.value.filter((key) => key !== colKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽开始
|
||||
const handleDragStart = (index, event) => {
|
||||
draggingIndex.value = index
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', index.toString())
|
||||
}
|
||||
|
||||
// 拖拽经过
|
||||
const handleDragOver = (index, event) => {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
// 拖拽结束
|
||||
const handleDragEnd = () => {
|
||||
draggingIndex.value = -1
|
||||
}
|
||||
|
||||
// 拖拽放置
|
||||
const handleDrop = (dropIndex) => {
|
||||
if (draggingIndex.value === dropIndex) return
|
||||
|
||||
const draggedKey = sortedColumns.value[draggingIndex.value]
|
||||
const newColumns = [...sortedColumns.value]
|
||||
|
||||
// 移除被拖拽的项
|
||||
newColumns.splice(draggingIndex.value, 1)
|
||||
|
||||
// 插入到新位置
|
||||
newColumns.splice(dropIndex, 0, draggedKey)
|
||||
|
||||
sortedColumns.value = newColumns
|
||||
draggingIndex.value = -1
|
||||
}
|
||||
|
||||
// 处理刷新
|
||||
const handleRefresh = () => {
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 处理分页变化
|
||||
const handlePaginationChange = (page, pageSize) => {
|
||||
emit('paginationChange', { page, pageSize })
|
||||
}
|
||||
|
||||
// 处理表格变化(排序、筛选)
|
||||
const handleTableChange = (filters, sorter, extra) => {
|
||||
emit('change', { filters, sorter, extra })
|
||||
}
|
||||
|
||||
// 处理列宽调整
|
||||
const handleResizeColumn = (width, column) => {
|
||||
emit('resizeColumn', { width, column })
|
||||
}
|
||||
|
||||
// 获取表格序号
|
||||
const getTableIndex = (index) => {
|
||||
const { current = 1, pageSize = 10 } = props.pagination || {}
|
||||
return (current - 1) * pageSize + index + 1
|
||||
}
|
||||
|
||||
|
||||
// 表格列配置
|
||||
const tableColumns = computed(() => {
|
||||
let columns = []
|
||||
|
||||
// 添加序号列
|
||||
if (props.showIndex) {
|
||||
columns.push({
|
||||
title: props.indexTitle,
|
||||
dataIndex: '_index',
|
||||
key: '_index',
|
||||
width: props.indexColumnWidth,
|
||||
align: 'center',
|
||||
fixed: 'left',
|
||||
})
|
||||
}
|
||||
|
||||
// 添加数据列(按排序顺序)
|
||||
sortedColumns.value.forEach((colKey) => {
|
||||
// 过滤掉未显示的列
|
||||
if (!visibleColumns.value.includes(colKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
const col = props.columns.find((c) => (c.dataIndex || c.key) === colKey)
|
||||
if (col) {
|
||||
columns.push({
|
||||
...col,
|
||||
customRender: col.slot ? undefined : col.customRender,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return columns
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
refresh: handleRefresh,
|
||||
getTableIndex,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sc-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
|
||||
&-tool {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
|
||||
.tool-left,
|
||||
.tool-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.column-setting {
|
||||
min-width: 200px;
|
||||
|
||||
&-header {
|
||||
padding: 8px 0;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 4px;
|
||||
cursor: move;
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.5;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
margin-right: 8px;
|
||||
color: #999;
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-checkbox-wrapper) {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-setting {
|
||||
min-width: 200px;
|
||||
|
||||
&-header {
|
||||
padding: 8px 0;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&-body {
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="file-upload">
|
||||
<a-upload
|
||||
v-model:file-list="fileList"
|
||||
:custom-request="customUpload"
|
||||
:before-upload="beforeUpload"
|
||||
:accept="accept"
|
||||
:max-count="maxCount"
|
||||
:disabled="disabled"
|
||||
:multiple="multiple"
|
||||
@change="handleChange"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<a-button v-if="!disabled">
|
||||
<upload-outlined />
|
||||
上传文件
|
||||
</a-button>
|
||||
</a-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UploadOutlined } from '@ant-design/icons-vue'
|
||||
import uploadConfig from '@/config/upload'
|
||||
|
||||
const props = defineProps({
|
||||
// 文件列表
|
||||
modelValue: {
|
||||
type: [Array, String],
|
||||
default: () => []
|
||||
},
|
||||
// 最大上传数量,默认1为单文件上传
|
||||
maxCount: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
// 接受的文件类型,例如 '.pdf,.doc,.docx' 或 '*'
|
||||
accept: {
|
||||
type: String,
|
||||
default: '*'
|
||||
},
|
||||
// 是否禁用
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否支持多选
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否返回URL字符串(单文件)或URL数组(多文件)
|
||||
returnUrl: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'remove'])
|
||||
|
||||
// 文件列表
|
||||
const fileList = ref([])
|
||||
|
||||
// 初始化文件列表
|
||||
const initFileList = () => {
|
||||
if (props.modelValue) {
|
||||
if (typeof props.modelValue === 'string') {
|
||||
// 单文件上传,字符串格式
|
||||
fileList.value = props.modelValue
|
||||
? [
|
||||
{
|
||||
uid: '-1',
|
||||
name: 'file',
|
||||
status: 'done',
|
||||
url: props.modelValue,
|
||||
response: {
|
||||
src: props.modelValue
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
} else if (Array.isArray(props.modelValue)) {
|
||||
// 多文件上传,数组格式
|
||||
fileList.value = props.modelValue.map((url, index) => ({
|
||||
uid: `-${index}`,
|
||||
name: `file${index}`,
|
||||
status: 'done',
|
||||
url: url,
|
||||
response: {
|
||||
src: url
|
||||
}
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
fileList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 监听外部值变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initFileList()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 自定义上传
|
||||
const customUpload = (options) => {
|
||||
const { file, onProgress, onSuccess, onError } = options
|
||||
const formData = new FormData()
|
||||
formData.append(uploadConfig.filename || 'file', file)
|
||||
|
||||
// 使用文件上传API对象
|
||||
const apiObj = uploadConfig.apiObjFile || uploadConfig.apiObj
|
||||
|
||||
apiObj(formData, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
|
||||
onProgress({ percent }, file)
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
const data = uploadConfig.parseData(res)
|
||||
if (data.code === uploadConfig.successCode) {
|
||||
onSuccess(data, file)
|
||||
message.success('上传成功')
|
||||
} else {
|
||||
onError(new Error(data.msg || '上传失败'))
|
||||
message.error(data.msg || '上传失败')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error)
|
||||
message.error('上传失败:' + error.message)
|
||||
})
|
||||
}
|
||||
|
||||
// 上传前校验
|
||||
const beforeUpload = (file) => {
|
||||
const maxSizeMB = uploadConfig.maxSizeFile || uploadConfig.maxSize || 10
|
||||
const maxSizeBytes = maxSizeMB * 1024 * 1024
|
||||
|
||||
if (file.size > maxSizeBytes) {
|
||||
message.error(`文件大小不能超过 ${maxSizeMB}MB`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理文件列表变化
|
||||
const handleChange = ({ fileList: newFileList }) => {
|
||||
fileList.value = newFileList
|
||||
|
||||
// 提取成功的文件URL
|
||||
const successFiles = newFileList
|
||||
.filter((file) => file.status === 'done' && (file.url || file.response?.src))
|
||||
.map((file) => file.url || file.response?.src)
|
||||
|
||||
// 触发更新事件
|
||||
if (props.returnUrl) {
|
||||
// 返回URL字符串或数组
|
||||
const value = props.maxCount === 1 ? successFiles[0] || '' : successFiles
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value, newFileList)
|
||||
} else {
|
||||
// 返回完整文件列表
|
||||
emit('update:modelValue', newFileList)
|
||||
emit('change', newFileList)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件移除
|
||||
const handleRemove = (file) => {
|
||||
emit('remove', file)
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-upload {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<div class="image-upload">
|
||||
<a-upload
|
||||
v-model:file-list="fileList"
|
||||
list-type="picture-card"
|
||||
:custom-request="customUpload"
|
||||
:before-upload="beforeUpload"
|
||||
:accept="accept"
|
||||
:max-count="maxCount"
|
||||
:disabled="disabled"
|
||||
:show-upload-list="{ showPreviewIcon: true, showRemoveIcon: !disabled }"
|
||||
@preview="handlePreview"
|
||||
@change="handleChange"
|
||||
@drop="handleDrop"
|
||||
@dragenter="handleDragEnter"
|
||||
@dragleave="handleDragLeave"
|
||||
class="custom-upload"
|
||||
:class="{ 'drag-over': isDragOver }"
|
||||
>
|
||||
<div v-if="fileList.length < maxCount && !disabled" class="upload-area">
|
||||
<loading-outlined v-if="uploading" class="upload-icon" />
|
||||
<plus-outlined v-else class="upload-icon" />
|
||||
<div class="ant-upload-text">{{ uploading ? '上传中...' : uploadText }}</div>
|
||||
<div v-if="tip" class="ant-upload-tip">{{ tip }}</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<a-modal
|
||||
:open="previewVisible"
|
||||
:title="previewTitle"
|
||||
:footer="null"
|
||||
:width="800"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<img alt="图片预览" style="width: 100%; max-height: 600px; object-fit: contain;" :src="previewImage" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons-vue'
|
||||
import uploadConfig from '@/config/upload'
|
||||
|
||||
const props = defineProps({
|
||||
// 图片列表
|
||||
modelValue: {
|
||||
type: [Array, String],
|
||||
default: () => []
|
||||
},
|
||||
// 最大上传数量,默认1为单图上传
|
||||
maxCount: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
// 接受的文件类型
|
||||
accept: {
|
||||
type: String,
|
||||
default: 'image/*'
|
||||
},
|
||||
// 是否禁用
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否返回URL字符串(单图)或URL数组(多图)
|
||||
returnUrl: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 上传按钮文字
|
||||
uploadText: {
|
||||
type: String,
|
||||
default: '上传图片'
|
||||
},
|
||||
// 提示文字
|
||||
tip: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 最小宽度(像素)
|
||||
minWidth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 最大宽度(像素)
|
||||
maxWidth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 最小高度(像素)
|
||||
minHeight: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 最大高度(像素)
|
||||
maxHeight: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 是否删除前确认
|
||||
confirmBeforeRemove: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 自定义上传按钮内容
|
||||
customUploadBtn: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'preview', 'remove', 'uploadSuccess', 'uploadError'])
|
||||
|
||||
// 文件列表
|
||||
const fileList = ref([])
|
||||
|
||||
// 预览相关
|
||||
const previewVisible = ref(false)
|
||||
const previewImage = ref('')
|
||||
const previewTitle = computed(() => {
|
||||
return previewImage.value ? '图片预览' : ''
|
||||
})
|
||||
|
||||
// 上传状态
|
||||
const uploading = ref(false)
|
||||
|
||||
// 拖拽状态
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// 初始化文件列表
|
||||
const initFileList = () => {
|
||||
if (props.modelValue) {
|
||||
if (typeof props.modelValue === 'string') {
|
||||
// 单图上传,字符串格式
|
||||
fileList.value = props.modelValue
|
||||
? [
|
||||
{
|
||||
uid: '-1',
|
||||
name: 'image.png',
|
||||
status: 'done',
|
||||
url: props.modelValue
|
||||
}
|
||||
]
|
||||
: []
|
||||
} else if (Array.isArray(props.modelValue)) {
|
||||
// 多图上传,数组格式
|
||||
fileList.value = props.modelValue.map((url, index) => ({
|
||||
uid: `-${index}`,
|
||||
name: `image${index}.png`,
|
||||
status: 'done',
|
||||
url: url
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
fileList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 监听外部值变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initFileList()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 自定义上传
|
||||
const customUpload = (options) => {
|
||||
const { file, onProgress, onSuccess, onError } = options
|
||||
const formData = new FormData()
|
||||
formData.append(uploadConfig.filename || 'file', file)
|
||||
|
||||
uploading.value = true
|
||||
|
||||
uploadConfig.apiObj(formData, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
|
||||
onProgress({ percent }, file)
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
const data = uploadConfig.parseData(res)
|
||||
if (data.code === uploadConfig.successCode) {
|
||||
onSuccess(data, file)
|
||||
message.success('上传成功')
|
||||
emit('uploadSuccess', data, file)
|
||||
} else {
|
||||
onError(new Error(data.msg || '上传失败'))
|
||||
message.error(data.msg || '上传失败')
|
||||
emit('uploadError', data.msg || '上传失败', file)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error)
|
||||
message.error('上传失败:' + error.message)
|
||||
emit('uploadError', error.message, file)
|
||||
})
|
||||
.finally(() => {
|
||||
uploading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 上传前校验
|
||||
const beforeUpload = async (file) => {
|
||||
// 文件大小校验
|
||||
const maxSizeMB = uploadConfig.maxSize || 10
|
||||
const maxSizeBytes = maxSizeMB * 1024 * 1024
|
||||
|
||||
if (file.size > maxSizeBytes) {
|
||||
message.error(`图片大小不能超过 ${maxSizeMB}MB`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 图片尺寸校验
|
||||
if (props.minWidth || props.maxWidth || props.minHeight || props.maxHeight) {
|
||||
try {
|
||||
const dimensions = await getImageDimensions(file)
|
||||
const { width, height } = dimensions
|
||||
|
||||
if (props.minWidth && width < props.minWidth) {
|
||||
message.error(`图片宽度不能小于 ${props.minWidth}px`)
|
||||
return false
|
||||
}
|
||||
if (props.maxWidth && width > props.maxWidth) {
|
||||
message.error(`图片宽度不能大于 ${props.maxWidth}px`)
|
||||
return false
|
||||
}
|
||||
if (props.minHeight && height < props.minHeight) {
|
||||
message.error(`图片高度不能小于 ${props.minHeight}px`)
|
||||
return false
|
||||
}
|
||||
if (props.maxHeight && height > props.maxHeight) {
|
||||
message.error(`图片高度不能大于 ${props.maxHeight}px`)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('图片尺寸校验失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取图片尺寸
|
||||
const getImageDimensions = (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (e) => {
|
||||
img.src = e.target.result
|
||||
img.onload = () => {
|
||||
resolve({ width: img.width, height: img.height })
|
||||
}
|
||||
img.onerror = reject
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
// 处理预览
|
||||
const handlePreview = async (file) => {
|
||||
if (!file.url && !file.preview) {
|
||||
file.preview = await getBase64(file.originFileObj)
|
||||
}
|
||||
previewImage.value = file.url || file.preview
|
||||
previewVisible.value = true
|
||||
emit('preview', file)
|
||||
}
|
||||
|
||||
// 获取Base64
|
||||
const getBase64 = (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = () => resolve(reader.result)
|
||||
reader.onerror = (error) => reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
// 处理文件列表变化
|
||||
const handleChange = ({ fileList: newFileList }) => {
|
||||
// 更新文件列表,确保上传成功的文件有正确的 url
|
||||
const updatedFileList = newFileList.map((file) => {
|
||||
// 如果文件上传成功且有响应数据但没有 url,则设置 url
|
||||
if (file.status === 'done' && file.response?.src && !file.url) {
|
||||
return {
|
||||
...file,
|
||||
url: file.response.src
|
||||
}
|
||||
}
|
||||
return file
|
||||
})
|
||||
|
||||
fileList.value = updatedFileList
|
||||
|
||||
// 过滤掉失败的文件
|
||||
const validFileList = updatedFileList.filter((file) => file.status !== 'error')
|
||||
|
||||
// 提取成功的文件URL
|
||||
const successFiles = validFileList
|
||||
.filter((file) => file.status === 'done' && (file.url || file.response?.src))
|
||||
.map((file) => file.url || file.response?.src)
|
||||
|
||||
// 触发更新事件
|
||||
if (props.returnUrl) {
|
||||
// 返回URL字符串或数组
|
||||
const value = props.maxCount === 1 ? successFiles[0] || '' : successFiles
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value, validFileList)
|
||||
} else {
|
||||
// 返回完整文件列表
|
||||
emit('update:modelValue', validFileList)
|
||||
emit('change', validFileList)
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽相关
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault()
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault()
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
// 取消预览
|
||||
const handleCancel = () => {
|
||||
previewVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 24px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ant-upload-text {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.ant-upload-tip {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
border: 2px dashed #1890ff;
|
||||
background-color: rgba(24, 144, 255, 0.05);
|
||||
}
|
||||
|
||||
.drag-over .upload-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
export default {
|
||||
APP_NAME: 'vueadmin',
|
||||
DASHBOARD_URL: '/dashboard',
|
||||
|
||||
// 白名单路由(不需要登录即可访问)
|
||||
whiteList: ['/login', '/register', '/reset-password'],
|
||||
//版本号
|
||||
APP_VER: '1.6.6',
|
||||
|
||||
//内核版本号
|
||||
CORE_VER: '1.6.6',
|
||||
|
||||
//接口地址
|
||||
API_URL: 'http://127.0.0.1:8000/admin/',
|
||||
|
||||
//请求超时
|
||||
TIMEOUT: 50000,
|
||||
|
||||
//TokenName
|
||||
TOKEN_NAME: 'authorization',
|
||||
|
||||
//Token前缀,注意最后有个空格,如不需要需设置空字符串
|
||||
TOKEN_PREFIX: 'Bearer ',
|
||||
|
||||
//追加其他头
|
||||
HEADERS: {},
|
||||
|
||||
//请求是否开启缓存
|
||||
REQUEST_CACHE: false,
|
||||
//语言
|
||||
LANG: 'zh-cn',
|
||||
|
||||
DASHBOARD_LAYOUT: 'widgets', //控制台首页默认布局
|
||||
DEFAULT_GRID: {
|
||||
//默认分栏数量和宽度 例如 [24] [18,6] [8,8,8] [6,12,6]
|
||||
layout: [24, 12, 12],
|
||||
//小组件分布,com取值:pages/home/components 文件名
|
||||
compsList: [["welcome"], ["info"], ["ver"]],
|
||||
},
|
||||
|
||||
//是否加密localStorage, 为空不加密
|
||||
//支持多种加密方式: 'AES', 'BASE64', 'DES'
|
||||
LS_ENCRYPTION: '',
|
||||
|
||||
//localStorage加密秘钥,位数建议填写8的倍数
|
||||
LS_ENCRYPTION_key: '2XNN4K8LC0ELVWN4',
|
||||
|
||||
//localStorage加密模式,AES支持: 'ECB', 'CBC', 'CTR', 'OFB', 'CFB'
|
||||
LS_ENCRYPTION_mode: 'ECB',
|
||||
|
||||
//localStorage加密填充方式,AES支持: 'Pkcs7', 'ZeroPadding', 'Iso10126', 'Iso97971'
|
||||
LS_ENCRYPTION_padding: 'Pkcs7',
|
||||
|
||||
//localStorage默认过期时间(单位:小时),0表示永不过期
|
||||
LS_DEFAULT_EXPIRE: 720, // 30天
|
||||
|
||||
//DES加密秘钥,必须是8字节
|
||||
LS_DES_key: '12345678',
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 静态路由配置
|
||||
* 这些路由会根据用户角色进行过滤后添加到路由中
|
||||
*/
|
||||
const userRoutes = []
|
||||
|
||||
export default userRoutes
|
||||
@@ -0,0 +1,20 @@
|
||||
import systemApi from "@/api/system";
|
||||
|
||||
//上传配置
|
||||
|
||||
export default {
|
||||
apiObj: systemApi.upload.post, //上传请求API对象
|
||||
filename: "file", //form请求时文件的key
|
||||
successCode: 1, //请求完成代码
|
||||
maxSize: 10, //最大文件大小 默认10MB
|
||||
parseData: function (res) {
|
||||
return {
|
||||
code: res.code, //分析状态字段结构
|
||||
fileName: res.data.name,//分析文件名称
|
||||
src: res.data.url, //分析图片远程地址结构
|
||||
msg: res.message //分析描述字段结构
|
||||
}
|
||||
},
|
||||
apiObjFile: systemApi.upload.post, //附件上传请求API对象
|
||||
maxSizeFile: 10 //最大文件大小 默认10MB
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useI18n as useVueI18n } from 'vue-i18n'
|
||||
import { useI18nStore } from '@/stores/modules/i18n'
|
||||
|
||||
export function useI18n() {
|
||||
const { t, locale, availableLocales } = useVueI18n()
|
||||
const i18nStore = useI18nStore()
|
||||
|
||||
return {
|
||||
t,
|
||||
locale,
|
||||
availableLocales,
|
||||
setLocale: i18nStore.setLocale,
|
||||
currentLocale: i18nStore.currentLocale,
|
||||
localeLabel: i18nStore.localeLabel
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
/**
|
||||
* 表格通用hooks
|
||||
* @param {Object} options 配置选项
|
||||
* @param {Function} options.api 获取列表数据的API函数,必须返回包含data和total的响应
|
||||
* @param {Object} options.searchForm 搜索表单的初始值
|
||||
* @param {Array} options.columns 表格列配置
|
||||
* @param {String} options.rowKey 行的唯一标识,默认为'id'
|
||||
* @param {Boolean} options.needPagination 是否需要分页,默认为true
|
||||
* @param {Object} options.paginationConfig 分页配置,可选
|
||||
* @param {Boolean} options.needSelection 是否需要行选择,默认为false
|
||||
* @param {Boolean} options.immediateLoad 是否在组件挂载时自动加载数据,默认为true
|
||||
* @returns {Object} 返回表格相关的状态和方法
|
||||
*/
|
||||
export function useTable(options = {}) {
|
||||
const {
|
||||
api,
|
||||
searchForm: initialSearchForm = {},
|
||||
columns = [],
|
||||
rowKey = 'id',
|
||||
needPagination = true,
|
||||
paginationConfig = {},
|
||||
needSelection = false,
|
||||
immediateLoad = true
|
||||
} = options
|
||||
|
||||
// 表格引用
|
||||
const tableRef = ref(null)
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({ ...initialSearchForm })
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref([])
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 选中的行数据
|
||||
const selectedRows = ref([])
|
||||
|
||||
// 选中的行keys
|
||||
const selectedRowKeys = computed(() => selectedRows.value.map(item => item[rowKey]))
|
||||
|
||||
// 分页配置
|
||||
const defaultPaginationConfig = {
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
pageSizeOptions: ['20', '50', '100', '200']
|
||||
}
|
||||
|
||||
const pagination = reactive({
|
||||
...defaultPaginationConfig,
|
||||
...paginationConfig
|
||||
})
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection = computed(() => {
|
||||
if (!needSelection) return null
|
||||
return {
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys, rows) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 行选择事件处理(用于scTable的@select事件)
|
||||
const handleSelectChange = (record, selected, selectedRows) => {
|
||||
if (!needSelection) return
|
||||
if (selected) {
|
||||
selectedRows.value.push(record)
|
||||
} else {
|
||||
const index = selectedRows.value.findIndex(item => item[rowKey] === record[rowKey])
|
||||
if (index > -1) {
|
||||
selectedRows.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选处理(用于scTable的@selectAll事件)
|
||||
const handleSelectAll = (selected, selectedRows, changeRows) => {
|
||||
if (!needSelection) return
|
||||
if (selected) {
|
||||
changeRows.forEach(record => {
|
||||
if (!selectedRows.value.find(item => item[rowKey] === record[rowKey])) {
|
||||
selectedRows.value.push(record)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
changeRows.forEach(record => {
|
||||
const index = selectedRows.value.findIndex(item => item[rowKey] === record[rowKey])
|
||||
if (index > -1) {
|
||||
selectedRows.value.splice(index, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const loadData = async (params = {}) => {
|
||||
if (!api) {
|
||||
console.warn('useTable: 未提供api函数,无法加载数据')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const requestParams = {
|
||||
...searchForm,
|
||||
...params
|
||||
}
|
||||
|
||||
// 如果需要分页,添加分页参数
|
||||
if (needPagination) {
|
||||
requestParams.page = pagination.current
|
||||
requestParams.limit = pagination.pageSize
|
||||
}
|
||||
|
||||
// 调用API函数,确保this上下文正确
|
||||
const res = await api(requestParams)
|
||||
|
||||
if (res.code === 1) {
|
||||
// 如果是分页数据
|
||||
if (needPagination) {
|
||||
tableData.value = res.data?.data || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} else {
|
||||
// 非分页数据(如树形数据)
|
||||
// 确保数据是数组,如果不是数组则包装成数组
|
||||
const data = res.data
|
||||
if (Array.isArray(data)) {
|
||||
tableData.value = data
|
||||
} else if (data && typeof data === 'object') {
|
||||
// 如果返回的是对象,可能包含 list 或 items 等字段
|
||||
tableData.value = data.list || data.items || data.data || []
|
||||
} else {
|
||||
tableData.value = []
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error(res.message || '加载数据失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
message.error('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分页变化处理
|
||||
const handlePaginationChange = ({ page, pageSize }) => {
|
||||
if (!needPagination) return
|
||||
pagination.current = page
|
||||
pagination.pageSize = pageSize
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
if (needPagination) {
|
||||
pagination.current = 1
|
||||
}
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
// 重置搜索表单为初始值
|
||||
Object.keys(searchForm).forEach(key => {
|
||||
searchForm[key] = initialSearchForm[key]
|
||||
})
|
||||
// 清空选择
|
||||
selectedRows.value = []
|
||||
// 重置分页
|
||||
if (needPagination) {
|
||||
pagination.current = 1
|
||||
}
|
||||
// 重新加载数据
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 刷新表格
|
||||
const refreshTable = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 清空选择
|
||||
const clearSelection = () => {
|
||||
selectedRows.value = []
|
||||
}
|
||||
|
||||
// 设置选中行
|
||||
const setSelectedRows = (rows) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
// 更新搜索表单
|
||||
const setSearchForm = (data) => {
|
||||
Object.assign(searchForm, data)
|
||||
}
|
||||
|
||||
// 直接设置表格数据(用于特殊场景)
|
||||
const setTableData = (data) => {
|
||||
tableData.value = data
|
||||
}
|
||||
|
||||
// 组件挂载时自动加载数据
|
||||
if (immediateLoad) {
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// ref
|
||||
tableRef,
|
||||
// 响应式数据
|
||||
searchForm,
|
||||
tableData,
|
||||
loading,
|
||||
pagination,
|
||||
selectedRows,
|
||||
selectedRowKeys,
|
||||
// 配置
|
||||
columns,
|
||||
rowKey,
|
||||
rowSelection,
|
||||
// 方法
|
||||
loadData,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePaginationChange,
|
||||
handleSelectChange,
|
||||
handleSelectAll,
|
||||
refreshTable,
|
||||
clearSelection,
|
||||
setSelectedRows,
|
||||
setSearchForm,
|
||||
setTableData
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zh from './locales/zh-CN'
|
||||
import en from './locales/en-US'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN',
|
||||
fallbackLocale: 'en-US',
|
||||
messages: {
|
||||
'zh-CN': zh,
|
||||
'en-US': en
|
||||
}
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,249 @@
|
||||
export default {
|
||||
common: {
|
||||
welcome: 'Welcome',
|
||||
login: 'Login',
|
||||
logout: 'Logout',
|
||||
register: 'Register',
|
||||
searchMenu: 'Search Menu',
|
||||
searchPlaceholder: 'Please enter menu name to search',
|
||||
noResults: 'No matching menus found',
|
||||
searchTips: 'Keyboard Shortcuts Tips',
|
||||
navigateResults: 'Use up/down arrows to navigate',
|
||||
selectResult: 'Press Enter to select',
|
||||
closeSearch: 'Press ESC to close',
|
||||
taskCenter: 'Task Center',
|
||||
totalTasks: 'Total Tasks',
|
||||
pendingTasks: 'Pending',
|
||||
completedTasks: 'Completed',
|
||||
searchTasks: 'Search tasks...',
|
||||
all: 'All',
|
||||
pending: 'Pending',
|
||||
completed: 'Completed',
|
||||
taskTitle: 'Task Title',
|
||||
enterTaskTitle: 'Please enter task title',
|
||||
taskPriority: 'Task Priority',
|
||||
priorityHigh: 'High',
|
||||
priorityMedium: 'Medium',
|
||||
priorityLow: 'Low',
|
||||
confirmDelete: 'Confirm Delete',
|
||||
addTask: 'Add Task',
|
||||
pleaseEnterTaskTitle: 'Please enter task title',
|
||||
added: 'Added',
|
||||
deleted: 'Deleted',
|
||||
justNow: 'Just now',
|
||||
clearCache: 'Clear Cache',
|
||||
confirmClearCache: 'Confirm Clear Cache',
|
||||
clearCacheConfirm: 'Are you sure you want to clear all cache? This will clear local storage, session storage and cached data.',
|
||||
cacheCleared: 'Cache cleared',
|
||||
clearCacheFailed: 'Failed to clear cache',
|
||||
messages: 'Messages',
|
||||
tasks: 'Tasks',
|
||||
clearAll: 'Clear All',
|
||||
noMessages: 'No Messages',
|
||||
noTasks: 'No Tasks',
|
||||
fullscreen: 'Fullscreen',
|
||||
personalCenter: 'Personal Center',
|
||||
systemSettings: 'System Settings',
|
||||
searchEmpty: 'Please enter search content',
|
||||
searching: 'Searching: ',
|
||||
cleared: 'Cleared',
|
||||
languageChanged: 'Language Changed',
|
||||
settingsDeveloping: 'System settings feature is under development',
|
||||
logoutSuccess: 'Logout Successful',
|
||||
logoutFailed: 'Logout Failed',
|
||||
confirmLogout: 'Confirm Logout',
|
||||
logoutConfirm: 'Are you sure you want to logout?',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
rememberMe: 'Remember Me',
|
||||
forgotPassword: 'Forgot Password?',
|
||||
submit: 'Submit',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
add: 'Add',
|
||||
search: 'Search',
|
||||
reset: 'Reset',
|
||||
confirm: 'Confirm',
|
||||
back: 'Back',
|
||||
next: 'Next',
|
||||
previous: 'Previous',
|
||||
refresh: 'Refresh',
|
||||
export: 'Export',
|
||||
import: 'Import',
|
||||
download: 'Download',
|
||||
upload: 'Upload',
|
||||
view: 'View',
|
||||
detail: 'Detail',
|
||||
settings: 'Settings',
|
||||
profile: 'Profile',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
loading: 'Loading...',
|
||||
noData: 'No Data',
|
||||
success: 'Operation Successful',
|
||||
error: 'Operation Failed',
|
||||
warning: 'Warning',
|
||||
info: 'Info',
|
||||
confirmDelete: 'Are you sure you want to delete?',
|
||||
confirmLogout: 'Are you sure you want to logout?',
|
||||
addConfig: 'Add Config',
|
||||
editConfig: 'Edit Config',
|
||||
configCategory: 'Config Category',
|
||||
configName: 'Config Name',
|
||||
configTitle: 'Config Title',
|
||||
configType: 'Config Type',
|
||||
configValue: 'Config Value',
|
||||
configTip: 'Config Tip',
|
||||
typeText: 'Text',
|
||||
typeTextarea: 'Textarea',
|
||||
typeNumber: 'Number',
|
||||
typeSwitch: 'Switch',
|
||||
typeSelect: 'Select',
|
||||
typeMultiselect: 'Multiselect',
|
||||
typeDatetime: 'Datetime',
|
||||
typeColor: 'Color',
|
||||
pleaseSelect: 'Please Select',
|
||||
pleaseEnter: 'Please Enter',
|
||||
noConfig: 'No Config',
|
||||
fetchConfigFailed: 'Failed to fetch config',
|
||||
addSuccess: 'Added Successfully',
|
||||
addFailed: 'Failed to Add',
|
||||
editSuccess: 'Edited Successfully',
|
||||
editFailed: 'Failed to Edit',
|
||||
saveSuccess: 'Saved Successfully',
|
||||
saveFailed: 'Failed to Save',
|
||||
resetSuccess: 'Reset Successfully',
|
||||
required: 'This field is required',
|
||||
operation: 'Operation',
|
||||
time: 'Time',
|
||||
status: 'Status',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
areaManage: 'Area Management',
|
||||
areaName: 'Area Name',
|
||||
areaCode: 'Area Code',
|
||||
areaLevel: 'Area Level',
|
||||
parentArea: 'Parent Area',
|
||||
province: 'Province',
|
||||
city: 'City',
|
||||
district: 'District',
|
||||
street: 'Street',
|
||||
unknown: 'Unknown',
|
||||
addArea: 'Add Area',
|
||||
editArea: 'Edit Area',
|
||||
remark: 'Remark',
|
||||
sort: 'Sort',
|
||||
createTime: 'Create Time',
|
||||
action: 'Action',
|
||||
batchDelete: 'Batch Delete',
|
||||
confirmBatchDelete: 'Confirm Batch Delete',
|
||||
batchDeleteConfirm: 'Are you sure you want to delete the selected',
|
||||
items: 'items?',
|
||||
deleteConfirm: 'Are you sure you want to delete',
|
||||
selectDataFirst: 'Please select data to operate first',
|
||||
pleaseEnterNumber: 'Please enter a valid number',
|
||||
exitFullScreen: 'Exit Fullscreen',
|
||||
columns: 'Columns',
|
||||
columnSettings: 'Column Settings',
|
||||
selectAll: 'Select All',
|
||||
unselectAll: 'Unselect All',
|
||||
retry: 'Retry',
|
||||
fetchDataFailed: 'Failed to fetch data'
|
||||
},
|
||||
menu: {
|
||||
dashboard: 'Dashboard',
|
||||
userManagement: 'User Management',
|
||||
roleManagement: 'Role Management',
|
||||
permissionManagement: 'Permission Management',
|
||||
systemSettings: 'System Settings',
|
||||
logManagement: 'Log Management'
|
||||
},
|
||||
login: {
|
||||
title: 'User Login',
|
||||
subtitle: 'Welcome back, please login to your account',
|
||||
loginButton: 'Login',
|
||||
loginSuccess: 'Login Successful',
|
||||
loginFailed: 'Login Failed',
|
||||
usernamePlaceholder: 'Please enter username',
|
||||
passwordPlaceholder: 'Please enter password',
|
||||
noAccount: "Don't have an account?",
|
||||
registerNow: 'Register Now',
|
||||
forgotPassword: 'Forgot Password?',
|
||||
rememberMe: 'Remember Me'
|
||||
},
|
||||
register: {
|
||||
title: 'User Registration',
|
||||
subtitle: 'Create your account and get started',
|
||||
registerButton: 'Register',
|
||||
registerSuccess: 'Registration Successful',
|
||||
registerFailed: 'Registration Failed',
|
||||
usernamePlaceholder: 'Please enter username',
|
||||
emailPlaceholder: 'Please enter email address',
|
||||
passwordPlaceholder: 'Please enter password',
|
||||
confirmPasswordPlaceholder: 'Please enter password again',
|
||||
usernameRule: 'Username length between 3 to 20 characters',
|
||||
emailRule: 'Please enter a valid email address',
|
||||
passwordRule: 'Password length between 6 to 20 characters',
|
||||
agreeRule: 'Please agree to the user agreement',
|
||||
agreeTerms: 'I have read and agree to the',
|
||||
terms: 'User Agreement',
|
||||
hasAccount: 'Already have an account?',
|
||||
loginNow: 'Login Now'
|
||||
},
|
||||
resetPassword: {
|
||||
title: 'Reset Password',
|
||||
subtitle: 'Reset your password via email verification code',
|
||||
resetButton: 'Reset Password',
|
||||
resetSuccess: 'Password reset successful',
|
||||
resetFailed: 'Reset failed',
|
||||
emailPlaceholder: 'Please enter email address',
|
||||
codePlaceholder: 'Please enter verification code',
|
||||
newPasswordPlaceholder: 'Please enter new password',
|
||||
confirmPasswordPlaceholder: 'Please enter new password again',
|
||||
emailRule: 'Please enter a valid email address',
|
||||
codeRule: 'Verification code must be 6 characters',
|
||||
passwordRule: 'Password length between 6 to 20 characters',
|
||||
sendCode: 'Send Code',
|
||||
codeSent: 'Verification code has been sent to your email',
|
||||
resendCode: 'Resend in {seconds} seconds',
|
||||
sendCodeFirst: 'Please enter email address first',
|
||||
backToLogin: 'Back to Login'
|
||||
},
|
||||
layout: {
|
||||
toggleSidebar: 'Toggle Sidebar',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand',
|
||||
logout: 'Logout'
|
||||
},
|
||||
table: {
|
||||
total: 'Total {total} items',
|
||||
selected: '{selected} items selected',
|
||||
actions: 'Actions',
|
||||
noData: 'No Data',
|
||||
sort: 'Sort',
|
||||
filter: 'Filter'
|
||||
},
|
||||
pagination: {
|
||||
goTo: 'Go to',
|
||||
page: 'Page',
|
||||
total: 'Total {total} items',
|
||||
itemsPerPage: '{size} items per page'
|
||||
},
|
||||
form: {
|
||||
required: 'This field is required',
|
||||
invalidEmail: 'Please enter a valid email address',
|
||||
invalidPhone: 'Please enter a valid phone number',
|
||||
passwordMismatch: 'Passwords do not match',
|
||||
minLength: 'Minimum {min} characters required',
|
||||
maxLength: 'Maximum {max} characters allowed'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
export default {
|
||||
common: {
|
||||
welcome: '欢迎使用',
|
||||
login: '登录',
|
||||
logout: '退出登录',
|
||||
register: '注册',
|
||||
searchMenu: '搜索菜单',
|
||||
searchPlaceholder: '请输入菜单名称进行搜索',
|
||||
noResults: '未找到匹配的菜单',
|
||||
searchTips: '快捷键操作提示',
|
||||
navigateResults: '使用上下键导航',
|
||||
selectResult: '按回车键选择',
|
||||
closeSearch: '按 ESC 关闭',
|
||||
taskCenter: '任务中心',
|
||||
totalTasks: '总任务',
|
||||
pendingTasks: '待完成',
|
||||
completedTasks: '已完成',
|
||||
searchTasks: '搜索任务...',
|
||||
all: '全部',
|
||||
pending: '待完成',
|
||||
completed: '已完成',
|
||||
taskTitle: '任务标题',
|
||||
enterTaskTitle: '请输入任务标题',
|
||||
taskPriority: '任务优先级',
|
||||
priorityHigh: '高',
|
||||
priorityMedium: '中',
|
||||
priorityLow: '低',
|
||||
confirmDelete: '确认删除',
|
||||
addTask: '添加任务',
|
||||
pleaseEnterTaskTitle: '请输入任务标题',
|
||||
added: '已添加',
|
||||
deleted: '已删除',
|
||||
justNow: '刚刚',
|
||||
clearCache: '清除缓存',
|
||||
confirmClearCache: '确认清除缓存',
|
||||
clearCacheConfirm: '确定要清除所有缓存吗?这将清除本地存储、会话存储和缓存数据。',
|
||||
cacheCleared: '缓存已清除',
|
||||
clearCacheFailed: '清除缓存失败',
|
||||
messages: '消息',
|
||||
tasks: '任务',
|
||||
clearAll: '清空全部',
|
||||
noMessages: '暂无消息',
|
||||
noTasks: '暂无任务',
|
||||
fullscreen: '全屏',
|
||||
personalCenter: '个人中心',
|
||||
systemSettings: '系统设置',
|
||||
searchEmpty: '请输入搜索内容',
|
||||
searching: '正在搜索:',
|
||||
cleared: '已清空',
|
||||
languageChanged: '语言已切换',
|
||||
settingsDeveloping: '系统设置功能开发中',
|
||||
logoutSuccess: '退出成功',
|
||||
logoutFailed: '退出失败',
|
||||
confirmLogout: '确认退出',
|
||||
logoutConfirm: '确定要退出登录吗?',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
confirmPassword: '确认密码',
|
||||
email: '邮箱',
|
||||
phone: '手机号',
|
||||
rememberMe: '记住我',
|
||||
forgotPassword: '忘记密码?',
|
||||
submit: '提交',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
add: '添加',
|
||||
search: '搜索',
|
||||
reset: '重置',
|
||||
confirm: '确认',
|
||||
back: '返回',
|
||||
next: '下一步',
|
||||
previous: '上一步',
|
||||
refresh: '刷新',
|
||||
export: '导出',
|
||||
import: '导入',
|
||||
download: '下载',
|
||||
upload: '上传',
|
||||
view: '查看',
|
||||
detail: '详情',
|
||||
settings: '设置',
|
||||
profile: '个人资料',
|
||||
language: '语言',
|
||||
theme: '主题',
|
||||
dark: '暗色',
|
||||
light: '亮色',
|
||||
loading: '加载中...',
|
||||
noData: '暂无数据',
|
||||
success: '操作成功',
|
||||
error: '操作失败',
|
||||
warning: '警告',
|
||||
info: '提示',
|
||||
confirmDelete: '确定要删除吗?',
|
||||
confirmLogout: '确定要退出登录吗?',
|
||||
addConfig: '添加配置',
|
||||
editConfig: '编辑配置',
|
||||
configCategory: '配置分类',
|
||||
configName: '配置名称',
|
||||
configTitle: '配置标题',
|
||||
configType: '配置类型',
|
||||
configValue: '配置值',
|
||||
configTip: '配置提示',
|
||||
typeText: '文本',
|
||||
typeTextarea: '文本域',
|
||||
typeNumber: '数字',
|
||||
typeSwitch: '开关',
|
||||
typeSelect: '下拉选择',
|
||||
typeMultiselect: '多选',
|
||||
typeDatetime: '日期时间',
|
||||
typeColor: '颜色',
|
||||
pleaseSelect: '请选择',
|
||||
pleaseEnter: '请输入',
|
||||
noConfig: '暂无配置',
|
||||
fetchConfigFailed: '获取配置失败',
|
||||
addSuccess: '添加成功',
|
||||
addFailed: '添加失败',
|
||||
editSuccess: '编辑成功',
|
||||
editFailed: '编辑失败',
|
||||
saveSuccess: '保存成功',
|
||||
saveFailed: '保存失败',
|
||||
resetSuccess: '重置成功',
|
||||
required: '此项为必填项',
|
||||
operation: '操作',
|
||||
time: '时间',
|
||||
status: '状态',
|
||||
enabled: '启用',
|
||||
disabled: '禁用',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
areaManage: '地区管理',
|
||||
areaName: '地区名称',
|
||||
areaCode: '地区编码',
|
||||
areaLevel: '地区级别',
|
||||
parentArea: '上级地区',
|
||||
province: '省份',
|
||||
city: '城市',
|
||||
district: '区县',
|
||||
street: '街道',
|
||||
unknown: '未知',
|
||||
addArea: '添加地区',
|
||||
editArea: '编辑地区',
|
||||
remark: '备注',
|
||||
sort: '排序',
|
||||
createTime: '创建时间',
|
||||
action: '操作',
|
||||
batchDelete: '批量删除',
|
||||
confirmBatchDelete: '确认批量删除',
|
||||
batchDeleteConfirm: '确定要删除选中的',
|
||||
items: '条数据吗?',
|
||||
deleteConfirm: '确定要删除',
|
||||
selectDataFirst: '请先选择要操作的数据',
|
||||
pleaseEnterNumber: '请输入有效的数字',
|
||||
exitFullScreen: '退出全屏',
|
||||
columns: '列设置',
|
||||
columnSettings: '列显示设置',
|
||||
selectAll: '全选',
|
||||
unselectAll: '取消全选',
|
||||
retry: '重试'
|
||||
},
|
||||
menu: {
|
||||
dashboard: '仪表板',
|
||||
userManagement: '用户管理',
|
||||
roleManagement: '角色管理',
|
||||
permissionManagement: '权限管理',
|
||||
systemSettings: '系统设置',
|
||||
logManagement: '日志管理'
|
||||
},
|
||||
login: {
|
||||
title: '用户登录',
|
||||
subtitle: '欢迎回来,请登录您的账户',
|
||||
loginButton: '登录',
|
||||
loginSuccess: '登录成功',
|
||||
loginFailed: '登录失败',
|
||||
usernamePlaceholder: '请输入用户名',
|
||||
passwordPlaceholder: '请输入密码',
|
||||
noAccount: '还没有账户?',
|
||||
registerNow: '立即注册',
|
||||
forgotPassword: '忘记密码?',
|
||||
rememberMe: '记住我'
|
||||
},
|
||||
register: {
|
||||
title: '用户注册',
|
||||
subtitle: '创建您的账户,开始使用',
|
||||
registerButton: '注册',
|
||||
registerSuccess: '注册成功',
|
||||
registerFailed: '注册失败',
|
||||
usernamePlaceholder: '请输入用户名',
|
||||
emailPlaceholder: '请输入邮箱地址',
|
||||
passwordPlaceholder: '请输入密码',
|
||||
confirmPasswordPlaceholder: '请再次输入密码',
|
||||
usernameRule: '用户名长度在 3 到 20 个字符',
|
||||
emailRule: '请输入正确的邮箱地址',
|
||||
passwordRule: '密码长度在 6 到 20 个字符',
|
||||
agreeRule: '请同意用户协议',
|
||||
agreeTerms: '我已阅读并同意',
|
||||
terms: '用户协议',
|
||||
hasAccount: '已有账户?',
|
||||
loginNow: '立即登录'
|
||||
},
|
||||
resetPassword: {
|
||||
title: '重置密码',
|
||||
subtitle: '通过邮箱验证码重置您的密码',
|
||||
resetButton: '重置密码',
|
||||
resetSuccess: '密码重置成功',
|
||||
resetFailed: '重置失败',
|
||||
emailPlaceholder: '请输入邮箱地址',
|
||||
codePlaceholder: '请输入验证码',
|
||||
newPasswordPlaceholder: '请输入新密码',
|
||||
confirmPasswordPlaceholder: '请再次输入新密码',
|
||||
emailRule: '请输入正确的邮箱地址',
|
||||
codeRule: '验证码长度为6位',
|
||||
passwordRule: '密码长度在 6 到 20 个字符',
|
||||
sendCode: '发送验证码',
|
||||
codeSent: '验证码已发送到您的邮箱',
|
||||
resendCode: '{seconds}秒后重新发送',
|
||||
sendCodeFirst: '请先输入邮箱地址',
|
||||
backToLogin: '返回登录'
|
||||
},
|
||||
layout: {
|
||||
toggleSidebar: '切换侧边栏',
|
||||
collapse: '折叠',
|
||||
expand: '展开',
|
||||
logout: '退出登录'
|
||||
},
|
||||
table: {
|
||||
total: '共 {total} 条',
|
||||
selected: '已选择 {selected} 项',
|
||||
actions: '操作',
|
||||
noData: '暂无数据',
|
||||
sort: '排序',
|
||||
filter: '筛选'
|
||||
},
|
||||
pagination: {
|
||||
goTo: '前往',
|
||||
page: '页',
|
||||
total: '共 {total} 条',
|
||||
itemsPerPage: '每页 {size} 条'
|
||||
},
|
||||
form: {
|
||||
required: '此项为必填项',
|
||||
invalidEmail: '请输入有效的邮箱地址',
|
||||
invalidPhone: '请输入有效的手机号',
|
||||
passwordMismatch: '两次输入的密码不一致',
|
||||
minLength: '最少需要 {min} 个字符',
|
||||
maxLength: '最多允许 {max} 个字符'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<a-breadcrumb class="breadcrumb">
|
||||
<a-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path">
|
||||
<span v-if="index === breadcrumbList.length - 1" class="no-redirect">
|
||||
<component :is="item.meta?.icon || 'FileTextOutlined'" />
|
||||
{{ item.meta.title }}
|
||||
</span>
|
||||
<a v-else @click.prevent="handleLink(item)">
|
||||
<component :is="item.meta?.icon || 'FileTextOutlined'" />
|
||||
{{ item.meta.title }}
|
||||
</a>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import config from '@/config'
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
defineOptions({
|
||||
name: 'LayoutBreadcrumb'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const breadcrumbList = ref([])
|
||||
|
||||
// 获取面包屑列表
|
||||
const getBreadcrumb = () => {
|
||||
let matched = route.matched.filter(item => item.meta && item.meta.title)
|
||||
|
||||
// 如果第一个不是首页,添加首页
|
||||
const first = matched[0]
|
||||
if (first && first.path !== config.DASHBOARD_URL) {
|
||||
matched = [{ path: config.DASHBOARD_URL, meta: { title: '', icon: 'HomeOutlined' } }].concat(matched)
|
||||
}
|
||||
|
||||
breadcrumbList.value = matched
|
||||
}
|
||||
|
||||
// 处理点击面包屑
|
||||
const handleLink = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
getBreadcrumb()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<template v-for="item in menuItems" :key="item.path || item.name">
|
||||
<!-- 有子菜单 - 使用递归 -->
|
||||
<a-sub-menu v-if="item.children && item.children.length > 0" :key="`${item.path}`">
|
||||
<template #icon v-if="item.meta?.icon">
|
||||
<component :is="getIconComponent(item.meta.icon)" />
|
||||
</template>
|
||||
<template #title>{{ item.meta?.title || item.name }}</template>
|
||||
<navMenu :menu-items="item.children" :active-path="activePath" :parent-path="item.path" />
|
||||
</a-sub-menu>
|
||||
<!-- 无子菜单的菜单项 -->
|
||||
<a-menu-item v-else :key="item.path" :class="{ 'ant-menu-item-selected': item.path === activePath }"
|
||||
@click="handleMenuClick(item)">
|
||||
<template #icon v-if="item.meta?.icon">
|
||||
<component :is="getIconComponent(item.meta.icon)" />
|
||||
</template>
|
||||
{{ item.meta?.title || item.name }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as icons from '@ant-design/icons-vue'
|
||||
|
||||
defineProps({
|
||||
menuItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activePath: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
parentPath: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 获取图标组件
|
||||
const getIconComponent = (iconName) => {
|
||||
return icons[iconName] || icons.FileTextOutlined
|
||||
}
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = (item) => {
|
||||
if (item.path) {
|
||||
router.push(item.path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="$t('common.searchMenu')"
|
||||
:footer="null"
|
||||
:width="600"
|
||||
:destroyOnClose="true"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div class="menu-search">
|
||||
<a-input
|
||||
v-model:value="searchKeyword"
|
||||
:placeholder="$t('common.searchPlaceholder')"
|
||||
size="large"
|
||||
allow-clear
|
||||
@input="handleSearch"
|
||||
@keydown="handleKeydown"
|
||||
ref="searchInputRef"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
|
||||
<div v-if="searchResults.length > 0" class="search-results">
|
||||
<div
|
||||
v-for="(item, index) in searchResults"
|
||||
:key="item.path"
|
||||
class="result-item"
|
||||
:class="{ active: selectedIndex === index }"
|
||||
@click="handleSelect(item)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<div class="result-icon">
|
||||
<component :is="item.icon || 'MenuOutlined'" />
|
||||
</div>
|
||||
<div class="result-content">
|
||||
<div class="result-title">{{ item.title }}</div>
|
||||
<div v-if="item.breadcrumbs" class="result-path">{{ item.breadcrumbs }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchKeyword" class="no-results">
|
||||
<a-empty :description="$t('common.noResults')" />
|
||||
</div>
|
||||
|
||||
<div v-else class="search-tips">
|
||||
<div class="tip-title">{{ $t('common.searchTips') }}</div>
|
||||
<div class="tip-list">
|
||||
<div class="tip-item">
|
||||
<kbd>↑</kbd>
|
||||
<kbd>↓</kbd>
|
||||
<span>{{ $t('common.navigateResults') }}</span>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<kbd>Enter</kbd>
|
||||
<span>{{ $t('common.selectResult') }}</span>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<kbd>Esc</kbd>
|
||||
<span>{{ $t('common.closeSearch') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { SearchOutlined, MenuOutlined } from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'MenuSearch',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const visible = defineModel('visible', { type: Boolean, default: false })
|
||||
const searchKeyword = ref('')
|
||||
const searchResults = ref([])
|
||||
const selectedIndex = ref(0)
|
||||
const searchInputRef = ref(null)
|
||||
|
||||
// 将扁平化的菜单数据转换为可搜索格式
|
||||
function flattenMenus(menus, breadcrumbs = []) {
|
||||
const result = []
|
||||
|
||||
menus.forEach((menu) => {
|
||||
if (menu.hidden) return
|
||||
|
||||
const currentBreadcrumbs = [...breadcrumbs, menu.title]
|
||||
|
||||
// 如果有路径且不是外部链接,添加到搜索结果
|
||||
if (menu.path && !menu.path.startsWith('http')) {
|
||||
result.push({
|
||||
title: menu.title,
|
||||
path: menu.path,
|
||||
icon: menu.icon,
|
||||
breadcrumbs: currentBreadcrumbs.join(' / '),
|
||||
})
|
||||
}
|
||||
|
||||
// 递归处理子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const children = flattenMenus(menu.children, currentBreadcrumbs)
|
||||
result.push(...children)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 获取所有菜单项
|
||||
const allMenus = computed(() => {
|
||||
const menus = userStore.menu || []
|
||||
return flattenMenus(menus)
|
||||
})
|
||||
|
||||
// 执行搜索
|
||||
function handleSearch() {
|
||||
if (!searchKeyword.value.trim()) {
|
||||
searchResults.value = []
|
||||
selectedIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const keyword = searchKeyword.value.toLowerCase().trim()
|
||||
searchResults.value = allMenus.value.filter((menu) => {
|
||||
return menu.title.toLowerCase().includes(keyword) ||
|
||||
menu.breadcrumbs.toLowerCase().includes(keyword)
|
||||
})
|
||||
|
||||
selectedIndex.value = 0
|
||||
}
|
||||
|
||||
// 键盘导航
|
||||
function handleKeydown(e) {
|
||||
if (!searchResults.value.length) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
selectedIndex.value = selectedIndex.value > 0
|
||||
? selectedIndex.value - 1
|
||||
: searchResults.value.length - 1
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
selectedIndex.value = selectedIndex.value < searchResults.value.length - 1
|
||||
? selectedIndex.value + 1
|
||||
: 0
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (searchResults.value[selectedIndex.value]) {
|
||||
handleSelect(searchResults.value[selectedIndex.value])
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
handleClose()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 选择菜单项
|
||||
function handleSelect(item) {
|
||||
visible.value = false
|
||||
router.push(item.path)
|
||||
}
|
||||
|
||||
// 关闭搜索弹窗
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
searchKeyword.value = ''
|
||||
searchResults.value = []
|
||||
selectedIndex.value = 0
|
||||
}
|
||||
|
||||
// 监听弹窗显示,自动聚焦输入框
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus()
|
||||
})
|
||||
} else {
|
||||
handleClose()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.menu-search {
|
||||
.search-results {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin-top: 16px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
margin-right: 12px;
|
||||
font-size: 16px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.result-title {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-path {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.search-tips {
|
||||
margin-top: 20px;
|
||||
|
||||
.tip-title {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tip-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
|
||||
.tip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<a-drawer v-model:open="open" title="布局配置" placement="right" :width="420">
|
||||
<div class="setting-content">
|
||||
<div class="setting-item">
|
||||
<div class="setting-title">布局模式</div>
|
||||
<div class="layout-mode-list">
|
||||
<div v-for="mode in layoutModes" :key="mode.value" class="layout-mode-item"
|
||||
:class="{ active: layoutStore.layoutMode === mode.value }"
|
||||
@click="handleLayoutChange(mode.value)">
|
||||
<div class="layout-preview" :class="`preview-${mode.value}`">
|
||||
<div class="preview-sidebar"></div>
|
||||
<div v-if="mode.value === 'default'" class="preview-sidebar-2"></div>
|
||||
<div class="preview-content">
|
||||
<div class="preview-header"></div>
|
||||
<div class="preview-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout-name">{{ mode.label }}</div>
|
||||
<CheckOutlined v-if="layoutStore.layoutMode === mode.value" class="check-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-title">主题颜色</div>
|
||||
<div class="color-list">
|
||||
<div v-for="color in themeColors" :key="color" class="color-item"
|
||||
:class="{ active: themeColor === color }" :style="{ backgroundColor: color }"
|
||||
@click="changeThemeColor(color)">
|
||||
<CheckOutlined v-if="themeColor === color" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-title">显示设置</div>
|
||||
<div class="toggle-list">
|
||||
<div class="toggle-item">
|
||||
<span>显示标签栏</span>
|
||||
<a-switch v-model:checked="showTags" @change="handleShowTagsChange" />
|
||||
</div>
|
||||
<div class="toggle-item">
|
||||
<span>显示面包屑</span>
|
||||
<a-switch v-model:checked="showBreadcrumb" @change="handleShowBreadcrumbChange" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-title">其他设置</div>
|
||||
<div class="action-buttons">
|
||||
<a-button type="primary" block @click="handleResetSettings">
|
||||
<ReloadOutlined />
|
||||
重置设置
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useLayoutStore } from '@/stores/modules/layout'
|
||||
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
defineOptions({
|
||||
name: 'LayoutSetting',
|
||||
})
|
||||
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
const open = ref(false)
|
||||
const themeColor = ref('#1890ff')
|
||||
const showTags = ref(true)
|
||||
const showBreadcrumb = ref(true)
|
||||
|
||||
const layoutModes = [
|
||||
{ value: 'default', label: '默认布局' },
|
||||
{ value: 'menu', label: '菜单布局' },
|
||||
{ value: 'top', label: '顶部布局' },
|
||||
]
|
||||
|
||||
const themeColors = ['#1890ff', '#f5222d', '#fa541c', '#faad14', '#13c2c2', '#52c41a', '#2f54eb', '#722ed1']
|
||||
|
||||
const openDrawer = () => {
|
||||
open.value = true
|
||||
}
|
||||
|
||||
const closeDrawer = () => {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
})
|
||||
|
||||
// 切换布局
|
||||
const handleLayoutChange = (mode) => {
|
||||
layoutStore.setLayoutMode(mode)
|
||||
const modeLabel = layoutModes.find((m) => m.value === mode)?.label || mode
|
||||
message.success(`已切换到${modeLabel}`)
|
||||
}
|
||||
|
||||
// 切换主题颜色
|
||||
const changeThemeColor = (color) => {
|
||||
themeColor.value = color
|
||||
// 更新 CSS 变量
|
||||
document.documentElement.style.setProperty('--primary-color', color)
|
||||
message.success('主题颜色已更新')
|
||||
}
|
||||
|
||||
// 切换标签栏显示
|
||||
const handleShowTagsChange = (checked) => {
|
||||
showTags.value = checked
|
||||
// 触发自定义事件或更新状态
|
||||
document.documentElement.style.setProperty('--show-tags', checked ? 'block' : 'none')
|
||||
message.success(checked ? '标签栏已显示' : '标签栏已隐藏')
|
||||
}
|
||||
|
||||
// 切换面包屑显示
|
||||
const handleShowBreadcrumbChange = (checked) => {
|
||||
showBreadcrumb.value = checked
|
||||
message.success(checked ? '面包屑已显示' : '面包屑已隐藏')
|
||||
}
|
||||
|
||||
// 重置设置
|
||||
const handleResetSettings = () => {
|
||||
themeColor.value = '#1890ff'
|
||||
showTags.value = true
|
||||
showBreadcrumb.value = true
|
||||
layoutStore.setLayoutMode('default')
|
||||
document.documentElement.style.setProperty('--primary-color', '#1890ff')
|
||||
document.documentElement.style.setProperty('--show-tags', 'block')
|
||||
message.success('设置已重置')
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 从本地存储或其他地方恢复设置
|
||||
const savedThemeColor = localStorage.getItem('themeColor')
|
||||
if (savedThemeColor) {
|
||||
themeColor.value = savedThemeColor
|
||||
document.documentElement.style.setProperty('--primary-color', savedThemeColor)
|
||||
}
|
||||
|
||||
const savedShowTags = localStorage.getItem('showTags')
|
||||
if (savedShowTags !== null) {
|
||||
showTags.value = savedShowTags === 'true'
|
||||
document.documentElement.style.setProperty('--show-tags', savedShowTags === 'true' ? 'block' : 'none')
|
||||
}
|
||||
})
|
||||
|
||||
// 监听设置变化并保存到本地存储
|
||||
watch(themeColor, (newVal) => {
|
||||
localStorage.setItem('themeColor', newVal)
|
||||
})
|
||||
|
||||
watch(showTags, (newVal) => {
|
||||
localStorage.setItem('showTags', String(newVal))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.setting-content {
|
||||
.setting-item {
|
||||
margin-bottom: 32px;
|
||||
|
||||
.setting-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.layout-mode-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
.layout-mode-item {
|
||||
position: relative;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color, #1890ff);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary-color, #1890ff);
|
||||
background-color: rgba(24, 144, 255, 0.05);
|
||||
}
|
||||
|
||||
.layout-preview {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background-color: #f0f2f5;
|
||||
|
||||
.preview-sidebar {
|
||||
background-color: #001529;
|
||||
}
|
||||
|
||||
.preview-sidebar-2 {
|
||||
background-color: #fff;
|
||||
border-left: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
|
||||
.preview-header {
|
||||
height: 8px;
|
||||
background-color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
height: calc(100% - 12px);
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
&.preview-default {
|
||||
.preview-sidebar {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.preview-sidebar-2 {
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&.preview-menu {
|
||||
.preview-sidebar {
|
||||
width: 30px;
|
||||
background-color: #fff;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
&.preview-top {
|
||||
flex-direction: column;
|
||||
|
||||
.preview-sidebar {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
.preview-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-name {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
color: var(--primary-color, #1890ff);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
.color-item {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #fff;
|
||||
box-shadow: 0 0 0 2px var(--primary-color, #1890ff);
|
||||
|
||||
.anticon {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-list {
|
||||
.toggle-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
:deep(.ant-btn) {
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<a-menu mode="inline" :theme="theme" :collapsed="collapsed" :selected-keys="selectedKeys" :open-keys="openKeys"
|
||||
@select="handleSelect" @open-change="handleOpenChange" class="side-menu">
|
||||
<template v-for="item in menuList">
|
||||
<!-- 有子菜单 -->
|
||||
<a-sub-menu v-if="item.children && item.children.length > 0" :key="item.path + '-submenu'">
|
||||
<template #icon>
|
||||
<component :is="item.meta?.icon || 'MenuOutlined'" />
|
||||
</template>
|
||||
<template #title>{{ item.meta?.title || item.name }}</template>
|
||||
<a-menu-item v-for="child in item.children.filter(sub => !sub.children || sub.children.length === 0)"
|
||||
:key="child.path">
|
||||
<template #icon>
|
||||
<component :is="child.meta?.icon || 'FileOutlined'" />
|
||||
</template>
|
||||
{{ child.meta?.title || child.name }}
|
||||
</a-menu-item>
|
||||
<a-sub-menu v-for="child in item.children.filter(sub => sub.children && sub.children.length > 0)"
|
||||
:key="child.path">
|
||||
<template #icon>
|
||||
<component :is="child.meta?.icon || 'AppstoreOutlined'" />
|
||||
</template>
|
||||
<template #title>{{ child.meta?.title || child.name }}</template>
|
||||
<a-menu-item v-for="grandChild in child.children" :key="grandChild.path">
|
||||
<template #icon>
|
||||
<component :is="grandChild.meta?.icon || 'FileOutlined'" />
|
||||
</template>
|
||||
{{ grandChild.meta?.title || grandChild.name }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</a-sub-menu>
|
||||
<!-- 无子菜单 -->
|
||||
<a-menu-item v-else :key="item.path + '-item'">
|
||||
<template #icon>
|
||||
<component :is="item.meta?.icon || 'MenuOutlined'" />
|
||||
</template>
|
||||
{{ item.meta?.title || item.name }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
</a-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getUserMenu } from '@/api/menu'
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'light'
|
||||
}
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const menuList = ref([])
|
||||
const selectedKeys = ref([])
|
||||
const openKeys = ref([])
|
||||
|
||||
// 获取菜单数据
|
||||
const getMenuList = async () => {
|
||||
try {
|
||||
const res = await getUserMenu()
|
||||
if (res.code === 200) {
|
||||
menuList.value = res.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取菜单失败:', error)
|
||||
// 模拟数据
|
||||
menuList.value = [
|
||||
{
|
||||
path: '/home',
|
||||
name: 'Home',
|
||||
meta: { title: '首页', icon: 'HomeOutlined' }
|
||||
},
|
||||
{
|
||||
path: '/system',
|
||||
name: 'System',
|
||||
meta: { title: '系统管理', icon: 'SettingOutlined' },
|
||||
children: [
|
||||
{
|
||||
path: '/system/user',
|
||||
name: 'User',
|
||||
meta: { title: '用户管理', icon: 'UserOutlined' }
|
||||
},
|
||||
{
|
||||
path: '/system/role',
|
||||
name: 'Role',
|
||||
meta: { title: '角色管理', icon: 'TeamOutlined' }
|
||||
},
|
||||
{
|
||||
path: '/system/menu',
|
||||
name: 'Menu',
|
||||
meta: { title: '菜单管理', icon: 'MenuOutlined' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 更新选中的菜单
|
||||
const updateSelectedKeys = () => {
|
||||
selectedKeys.value = [route.path]
|
||||
|
||||
// 获取父级菜单路径
|
||||
const matched = route.matched
|
||||
.filter(item => item.path !== '/' && item.path !== route.path)
|
||||
.map(item => item.path)
|
||||
|
||||
// 折叠时不自动展开
|
||||
if (!props.collapsed) {
|
||||
openKeys.value = matched
|
||||
}
|
||||
}
|
||||
|
||||
// 处理菜单选择
|
||||
const handleSelect = ({ key }) => {
|
||||
router.push(key)
|
||||
}
|
||||
|
||||
// 处理菜单展开/收起
|
||||
const handleOpenChange = (keys) => {
|
||||
openKeys.value = keys
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(() => route.path, () => {
|
||||
updateSelectedKeys()
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听折叠状态
|
||||
watch(() => props.collapsed, (val) => {
|
||||
if (val) {
|
||||
openKeys.value = []
|
||||
} else {
|
||||
updateSelectedKeys()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getMenuList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.side-menu {
|
||||
height: calc(100% - 60px);
|
||||
border-right: none;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,487 @@
|
||||
<template>
|
||||
<div v-show="showTags" class="tags-view">
|
||||
<div class="tags-wrapper" @contextmenu.prevent>
|
||||
<a-space :size="4">
|
||||
<a-tag
|
||||
v-for="tag in visitedViews"
|
||||
:key="tag.fullPath"
|
||||
:closable="!tag.meta?.affix"
|
||||
class="tag-item"
|
||||
:class="{ active: isActive(tag), 'tag-affix': tag.meta?.affix }"
|
||||
@click="clickTag(tag)"
|
||||
@close="closeSelectedTag(tag)"
|
||||
@contextmenu.prevent="handleContextMenu($event, tag)">
|
||||
<template #icon v-if="tag.meta?.affix">
|
||||
<PushpinFilled />
|
||||
</template>
|
||||
{{ tag.meta?.title || tag.name }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="tags-actions">
|
||||
<a-dropdown v-model:open="actionMenuVisible" trigger="click" placement="bottomRight">
|
||||
<a-button size="small" type="text">
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleActionMenuClick">
|
||||
<a-menu-item key="refresh">
|
||||
<ReloadOutlined />
|
||||
<span>刷新当前页</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeOthers">
|
||||
<ColumnWidthOutlined />
|
||||
<span>关闭其他</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeLeft">
|
||||
<LeftOutlined />
|
||||
<span>关闭左侧</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeRight">
|
||||
<RightOutlined />
|
||||
<span>关闭右侧</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeAll">
|
||||
<CloseCircleOutlined />
|
||||
<span>关闭所有</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
left: contextMenu.x + 'px',
|
||||
top: contextMenu.y + 'px',
|
||||
zIndex: 9999
|
||||
}"
|
||||
class="context-menu"
|
||||
@click="closeContextMenu">
|
||||
<a-menu @click="handleMenuClick">
|
||||
<a-menu-item key="refresh">
|
||||
<ReloadOutlined />
|
||||
<span>刷新</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="selectedTag && !selectedTag.meta?.affix" key="close">
|
||||
<CloseOutlined />
|
||||
<span>关闭</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeOthers">
|
||||
<ColumnWidthOutlined />
|
||||
<span>关闭其他</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeAll">
|
||||
<CloseCircleOutlined />
|
||||
<span>关闭所有</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLayoutStore } from '@/stores/modules/layout'
|
||||
import config from '@/config'
|
||||
|
||||
defineOptions({
|
||||
name: 'TagsView',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
const showTags = ref(true)
|
||||
const selectedTag = ref(null)
|
||||
const visitedViews = computed(() => layoutStore.viewTags)
|
||||
// 右键菜单状态
|
||||
const contextMenu = ref({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
// 顶部操作菜单状态
|
||||
const actionMenuVisible = ref(false)
|
||||
|
||||
// 判断是否是当前激活的标签
|
||||
const isActive = (tag) => {
|
||||
return tag.fullPath === route.fullPath
|
||||
}
|
||||
|
||||
// 添加标签
|
||||
const addTags = () => {
|
||||
const { name } = route
|
||||
if (name && !route.meta?.noCache) {
|
||||
layoutStore.updateViewTags({
|
||||
fullPath: route.fullPath,
|
||||
path: route.path,
|
||||
name: name,
|
||||
query: route.query,
|
||||
params: route.params,
|
||||
meta: route.meta
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 移除标签
|
||||
const closeSelectedTag = (view) => {
|
||||
// 如果是固定标签,不允许关闭
|
||||
if (view.meta?.affix) {
|
||||
return
|
||||
}
|
||||
|
||||
layoutStore.removeViewTags(view.fullPath)
|
||||
|
||||
// 如果关闭的是当前激活的标签,需要跳转
|
||||
if (isActive(view)) {
|
||||
const nextTag = visitedViews.value.find((tag) => tag.fullPath !== view.fullPath)
|
||||
if (nextTag) {
|
||||
router.push(nextTag.fullPath)
|
||||
} else {
|
||||
// 如果没有其他标签,跳转到首页
|
||||
router.push(config.DASHBOARD_URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭其他标签
|
||||
const closeOthersTags = () => {
|
||||
if (!selectedTag.value || !selectedTag.value.fullPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// 保留固定标签和当前选中的标签
|
||||
const tagsToKeep = visitedViews.value.filter(
|
||||
(tag) => tag.meta?.affix || tag.fullPath === selectedTag.value.fullPath
|
||||
)
|
||||
|
||||
// 更新标签列表
|
||||
layoutStore.viewTags = tagsToKeep
|
||||
|
||||
// 如果当前不在选中的标签页,跳转到选中的标签
|
||||
if (!isActive(selectedTag.value)) {
|
||||
router.push(selectedTag.value.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭所有标签
|
||||
const closeAllTags = () => {
|
||||
// 只保留固定标签
|
||||
const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix)
|
||||
layoutStore.viewTags = affixTags
|
||||
|
||||
// 如果还有固定标签,跳转到第一个固定标签
|
||||
if (affixTags.length > 0) {
|
||||
router.push(affixTags[0].fullPath)
|
||||
} else {
|
||||
// 如果没有固定标签,跳转到首页
|
||||
router.push(config.DASHBOARD_URL)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭左侧标签
|
||||
const closeLeftTags = () => {
|
||||
const currentTag = selectedTag.value || visitedViews.value.find((tag) => isActive(tag))
|
||||
if (!currentTag) return
|
||||
|
||||
const currentIndex = visitedViews.value.findIndex((tag) => tag.fullPath === currentTag.fullPath)
|
||||
if (currentIndex === -1) return
|
||||
|
||||
// 保留当前标签及其右侧的标签,以及所有固定标签
|
||||
const tagsToKeep = visitedViews.value.filter((tag, index) => {
|
||||
return tag.meta?.affix || index >= currentIndex
|
||||
})
|
||||
|
||||
layoutStore.viewTags = tagsToKeep
|
||||
}
|
||||
|
||||
// 关闭右侧标签
|
||||
const closeRightTags = () => {
|
||||
const currentTag = selectedTag.value || visitedViews.value.find((tag) => isActive(tag))
|
||||
if (!currentTag) return
|
||||
|
||||
const currentIndex = visitedViews.value.findIndex((tag) => tag.fullPath === currentTag.fullPath)
|
||||
if (currentIndex === -1) return
|
||||
|
||||
// 保留当前标签及其左侧的标签,以及所有固定标签
|
||||
const tagsToKeep = visitedViews.value.filter((tag, index) => {
|
||||
return tag.meta?.affix || index <= currentIndex
|
||||
})
|
||||
|
||||
layoutStore.viewTags = tagsToKeep
|
||||
}
|
||||
|
||||
// 点击标签
|
||||
const clickTag = (tag) => {
|
||||
if (!isActive(tag)) {
|
||||
router.push(tag.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新指定标签
|
||||
const refreshTag = (tag) => {
|
||||
// 如果刷新的是当前激活的标签
|
||||
if (isActive(tag)) {
|
||||
// 调用 store 的刷新方法,触发组件重新渲染
|
||||
layoutStore.refreshTag()
|
||||
} else {
|
||||
// 如果刷新的是其他标签,先跳转到该标签
|
||||
router.push(tag.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新当前选中的标签(用于顶部操作按钮)
|
||||
const refreshSelectedTag = () => {
|
||||
// 找到当前激活的标签
|
||||
const currentTag = visitedViews.value.find((tag) => isActive(tag))
|
||||
if (currentTag) {
|
||||
refreshTag(currentTag)
|
||||
}
|
||||
}
|
||||
|
||||
// 右键菜单处理
|
||||
const handleContextMenu = (event, tag) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
selectedTag.value = tag
|
||||
contextMenu.value = {
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭右键菜单
|
||||
const closeContextMenu = () => {
|
||||
contextMenu.value.visible = false
|
||||
}
|
||||
|
||||
// 菜单点击处理
|
||||
const handleMenuClick = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'refresh':
|
||||
if (selectedTag.value) {
|
||||
refreshTag(selectedTag.value)
|
||||
}
|
||||
break
|
||||
case 'close':
|
||||
if (selectedTag.value && !selectedTag.value.meta?.affix) {
|
||||
closeSelectedTag(selectedTag.value)
|
||||
}
|
||||
break
|
||||
case 'closeOthers':
|
||||
closeOthersTags()
|
||||
break
|
||||
case 'closeAll':
|
||||
closeAllTags()
|
||||
break
|
||||
}
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
// 顶部操作菜单点击处理
|
||||
const handleActionMenuClick = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'refresh':
|
||||
refreshSelectedTag()
|
||||
break
|
||||
case 'closeOthers':
|
||||
closeOthersTags()
|
||||
break
|
||||
case 'closeLeft':
|
||||
closeLeftTags()
|
||||
break
|
||||
case 'closeRight':
|
||||
closeRightTags()
|
||||
break
|
||||
case 'closeAll':
|
||||
closeAllTags()
|
||||
break
|
||||
}
|
||||
actionMenuVisible.value = false
|
||||
}
|
||||
|
||||
// 点击其他地方关闭右键菜单
|
||||
const handleClickOutside = (event) => {
|
||||
if (contextMenu.value.visible) {
|
||||
const menuElement = document.querySelector('.context-menu')
|
||||
if (menuElement && !menuElement.contains(event.target)) {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由变化,自动添加标签
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
addTags()
|
||||
// 更新当前选中的标签
|
||||
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
addTags()
|
||||
// 初始化选中的标签
|
||||
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null
|
||||
// 添加点击事件监听器
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 移除点击事件监听器
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tags-view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 40px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 0 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
|
||||
.tags-wrapper {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 12px;
|
||||
margin: 0 4px;
|
||||
border: 1px solid #d9d9d9;
|
||||
background-color: #fafafa;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
color: #ffffff;
|
||||
|
||||
&:hover {
|
||||
background-color: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.tag-affix {
|
||||
background-color: #fff7e6;
|
||||
border-color: #ffd591;
|
||||
color: #fa8c16;
|
||||
|
||||
&.active {
|
||||
background-color: #fa8c16;
|
||||
border-color: #fa8c16;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #ffe7ba;
|
||||
border-color: #ffa940;
|
||||
}
|
||||
|
||||
&.active:hover {
|
||||
background-color: #d46b08;
|
||||
border-color: #d46b08;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tag-close-icon) {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tags-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.ant-btn) {
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
background: #ffffff;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f0f0f0;
|
||||
padding: 4px 0;
|
||||
min-width: 160px;
|
||||
|
||||
:deep(.ant-menu) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
|
||||
.ant-menu-item {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
height: auto;
|
||||
line-height: normal;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
v-model:open="visible"
|
||||
:title="$t('common.taskCenter')"
|
||||
placement="right"
|
||||
:width="400"
|
||||
:destroyOnClose="true"
|
||||
>
|
||||
<div class="task-drawer">
|
||||
<!-- 任务统计 -->
|
||||
<div class="task-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ totalTasks }}</div>
|
||||
<div class="stat-label">{{ $t('common.totalTasks') }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number pending">{{ pendingTasks }}</div>
|
||||
<div class="stat-label">{{ $t('common.pendingTasks') }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number completed">{{ completedTasks }}</div>
|
||||
<div class="stat-label">{{ $t('common.completedTasks') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="task-actions">
|
||||
<a-input
|
||||
v-model:value="searchKeyword"
|
||||
:placeholder="$t('common.searchTasks')"
|
||||
allow-clear
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
<div class="filter-buttons">
|
||||
<a-button
|
||||
:type="filterType === 'all' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="setFilter('all')"
|
||||
>
|
||||
{{ $t('common.all') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
:type="filterType === 'pending' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="setFilter('pending')"
|
||||
>
|
||||
{{ $t('common.pending') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
:type="filterType === 'completed' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="setFilter('completed')"
|
||||
>
|
||||
{{ $t('common.completed') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<div class="task-list">
|
||||
<div v-if="filteredTasks.length > 0">
|
||||
<div
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
class="task-item"
|
||||
:class="{ completed: task.completed }"
|
||||
>
|
||||
<div class="task-checkbox">
|
||||
<a-checkbox
|
||||
:checked="task.completed"
|
||||
@change="toggleTask(task)"
|
||||
/>
|
||||
</div>
|
||||
<div class="task-content">
|
||||
<div class="task-title">{{ task.title }}</div>
|
||||
<div class="task-meta">
|
||||
<span class="task-priority" :class="task.priority">
|
||||
{{ $t(`common.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`) }}
|
||||
</span>
|
||||
<span class="task-time">{{ task.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<a-popconfirm
|
||||
:title="$t('common.confirmDelete')"
|
||||
:ok-text="$t('common.confirm')"
|
||||
:cancel-text="$t('common.cancel')"
|
||||
@confirm="deleteTask(task.id)"
|
||||
>
|
||||
<DeleteOutlined class="action-icon" />
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else :description="$t('common.noTasks')" />
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="drawer-footer">
|
||||
<a-button @click="showAddTask">
|
||||
<PlusOutlined />
|
||||
{{ $t('common.addTask') }}
|
||||
</a-button>
|
||||
<a-button danger @click="clearAllTasks">
|
||||
{{ $t('common.clearAll') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加任务弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="addTaskVisible"
|
||||
:title="$t('common.addTask')"
|
||||
:ok-text="$t('common.confirm')"
|
||||
:cancel-text="$t('common.cancel')"
|
||||
@ok="confirmAddTask"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item :label="$t('common.taskTitle')">
|
||||
<a-input v-model:value="newTask.title" :placeholder="$t('common.enterTaskTitle')" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="$t('common.taskPriority')">
|
||||
<a-select v-model:value="newTask.priority">
|
||||
<a-select-option value="low">{{ $t('common.priorityLow') }}</a-select-option>
|
||||
<a-select-option value="medium">{{ $t('common.priorityMedium') }}</a-select-option>
|
||||
<a-select-option value="high">{{ $t('common.priorityHigh') }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { SearchOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'TaskDrawer',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const visible = defineModel('visible', { type: Boolean, default: false })
|
||||
|
||||
const tasks = defineModel('tasks', { type: Array, default: () => [] })
|
||||
|
||||
// 搜索关键词
|
||||
const searchKeyword = ref('')
|
||||
// 筛选类型:all, pending, completed
|
||||
const filterType = ref('all')
|
||||
|
||||
// 添加任务弹窗
|
||||
const addTaskVisible = ref(false)
|
||||
const newTask = ref({
|
||||
title: '',
|
||||
priority: 'medium',
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
const totalTasks = computed(() => tasks.value.length)
|
||||
const pendingTasks = computed(() => tasks.value.filter(t => !t.completed).length)
|
||||
const completedTasks = computed(() => tasks.value.filter(t => t.completed).length)
|
||||
|
||||
// 筛选后的任务列表
|
||||
const filteredTasks = computed(() => {
|
||||
let result = [...tasks.value]
|
||||
|
||||
// 按状态筛选
|
||||
if (filterType.value === 'pending') {
|
||||
result = result.filter(t => !t.completed)
|
||||
} else if (filterType.value === 'completed') {
|
||||
result = result.filter(t => t.completed)
|
||||
}
|
||||
|
||||
// 按关键词搜索
|
||||
if (searchKeyword.value.trim()) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
result = result.filter(t =>
|
||||
t.title.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 切换任务状态
|
||||
const toggleTask = (task) => {
|
||||
task.completed = !task.completed
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
const deleteTask = (id) => {
|
||||
const index = tasks.value.findIndex(t => t.id === id)
|
||||
if (index > -1) {
|
||||
tasks.value.splice(index, 1)
|
||||
message.success(t('common.deleted'))
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有任务
|
||||
const clearAllTasks = () => {
|
||||
tasks.value = []
|
||||
message.success(t('common.cleared'))
|
||||
}
|
||||
|
||||
// 显示添加任务弹窗
|
||||
const showAddTask = () => {
|
||||
newTask.value = {
|
||||
title: '',
|
||||
priority: 'medium',
|
||||
}
|
||||
addTaskVisible.value = true
|
||||
}
|
||||
|
||||
// 确认添加任务
|
||||
const confirmAddTask = () => {
|
||||
if (!newTask.value.title.trim()) {
|
||||
message.warning(t('common.pleaseEnterTaskTitle'))
|
||||
return
|
||||
}
|
||||
|
||||
tasks.value.unshift({
|
||||
id: Date.now(),
|
||||
title: newTask.value.title,
|
||||
priority: newTask.value.priority,
|
||||
completed: false,
|
||||
time: t('common.justNow'),
|
||||
})
|
||||
|
||||
addTaskVisible.value = false
|
||||
message.success(t('common.added'))
|
||||
}
|
||||
|
||||
// 设置筛选类型
|
||||
const setFilter = (type) => {
|
||||
filterType.value = type
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑在 computed 中自动处理
|
||||
}
|
||||
|
||||
// 监听抽窗关闭,重置搜索和筛选
|
||||
watch(visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
searchKeyword.value = ''
|
||||
filterType.value = 'all'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.task-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.task-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&.pending {
|
||||
color: #ffd666;
|
||||
}
|
||||
|
||||
&.completed {
|
||||
color: #95de64;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.ant-input-affix-wrapper) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.ant-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
padding-right: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&.completed {
|
||||
.task-title {
|
||||
text-decoration: line-through;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.task-content {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.task-title {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
.task-priority {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
|
||||
&.high {
|
||||
background-color: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
&.medium {
|
||||
background-color: #fff7e6;
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
&.low {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
.task-time {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.action-icon {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.ant-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div class="userbar">
|
||||
<!-- 菜单搜索 -->
|
||||
<a-tooltip :title="$t('common.search')">
|
||||
<a-button type="text" @click="showSearch" class="action-btn">
|
||||
<SearchOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 消息通知 -->
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<a-badge :count="messageCount" :offset="[-5, 5]">
|
||||
<a-button type="text" class="action-btn">
|
||||
<BellOutlined />
|
||||
</a-button>
|
||||
</a-badge>
|
||||
<template #overlay>
|
||||
<a-card class="dropdown-card" :title="$t('common.messages')" :bordered="false">
|
||||
<template #extra>
|
||||
<a @click="clearMessages">{{ $t('common.clearAll') }}</a>
|
||||
</template>
|
||||
<div class="message-list">
|
||||
<div v-for="msg in messages" :key="msg.id" class="message-item" :class="{ unread: !msg.read }">
|
||||
<div class="message-content">
|
||||
<div class="message-title">{{ msg.title }}</div>
|
||||
<div class="message-time">{{ msg.time }}</div>
|
||||
</div>
|
||||
<a-badge v-if="!msg.read" dot />
|
||||
</div>
|
||||
<a-empty v-if="messages.length === 0" :description="$t('common.noMessages')" />
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<a-tooltip :title="$t('common.taskCenter')">
|
||||
<a-badge :count="taskCount" :offset="[-5, 5]">
|
||||
<a-button type="text" @click="taskVisible = true" class="action-btn">
|
||||
<CheckSquareOutlined />
|
||||
</a-button>
|
||||
</a-badge>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 语言切换 -->
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<a-button type="text" class="action-btn">
|
||||
<GlobalOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleLanguageChange">
|
||||
<a-menu-item v-for="locale in i18nStore.availableLocales" :key="locale.value"
|
||||
:disabled="i18nStore.currentLocale === locale.value">
|
||||
<span>{{ locale.label }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 全屏 -->
|
||||
<a-tooltip :title="$t('common.fullscreen')">
|
||||
<a-button type="text" @click="toggleFullscreen" class="action-btn">
|
||||
<FullscreenOutlined v-if="!isFullscreen" />
|
||||
<FullscreenExitOutlined v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<a-dropdown :trigger="['click']">
|
||||
<div class="user-info">
|
||||
<a-avatar :size="32" :src="userStore.user?.avatar || ''">
|
||||
{{ userStore.user?.username?.charAt(0)?.toUpperCase() || 'U' }}
|
||||
</a-avatar>
|
||||
<span class="username">{{ userStore.user?.username || 'Admin' }}</span>
|
||||
<DownOutlined />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleMenuClick">
|
||||
<a-menu-item key="profile">
|
||||
<UserOutlined />
|
||||
<span>{{ $t('common.personalCenter') }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="settings">
|
||||
<SettingOutlined />
|
||||
<span>{{ $t('common.systemSettings') }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="clearCache">
|
||||
<DeleteOutlined />
|
||||
<span>{{ $t('common.clearCache') }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout">
|
||||
<LogoutOutlined />
|
||||
<span>{{ $t('common.logout') }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 菜单搜索弹窗 -->
|
||||
<search v-model:visible="searchVisible" />
|
||||
|
||||
<!-- 任务抽屉 -->
|
||||
<task v-model:visible="taskVisible" v-model:tasks="tasks" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { useI18nStore } from '@/stores/modules/i18n'
|
||||
import { DownOutlined, UserOutlined, LogoutOutlined, FullscreenOutlined, FullscreenExitOutlined, BellOutlined, CheckSquareOutlined, GlobalOutlined, SearchOutlined, SettingOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import search from './search.vue'
|
||||
import task from './task.vue'
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
defineOptions({
|
||||
name: 'UserBar',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const i18nStore = useI18nStore()
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
const searchVisible = ref(false)
|
||||
const taskVisible = ref(false)
|
||||
|
||||
// 消息数据
|
||||
const messages = ref([
|
||||
{ id: 1, title: '系统通知:新版本已发布', time: '10分钟前', read: false },
|
||||
{ id: 2, title: '任务提醒:请完成待审核的用户', time: '30分钟前', read: false },
|
||||
{ id: 3, title: '安全警告:检测到异常登录', time: '1小时前', read: true },
|
||||
{ id: 4, title: '数据备份已完成', time: '2小时前', read: true },
|
||||
])
|
||||
|
||||
const messageCount = computed(() => messages.value.filter((m) => !m.read).length)
|
||||
|
||||
// 任务数据
|
||||
const tasks = ref([
|
||||
{ id: 1, title: '完成用户审核', priority: 'high', completed: false, time: '今天' },
|
||||
{ id: 2, title: '更新系统文档', priority: 'medium', completed: false, time: '明天' },
|
||||
{ id: 3, title: '优化数据库查询', priority: 'low', completed: true, time: '昨天' },
|
||||
])
|
||||
|
||||
const taskCount = computed(() => tasks.value.filter((t) => !t.completed).length)
|
||||
|
||||
// 切换全屏
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
isFullscreen.value = true
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
isFullscreen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听全屏变化
|
||||
const handleFullscreenChange = () => {
|
||||
isFullscreen.value = !!document.fullscreenElement
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
})
|
||||
|
||||
// 显示搜索功能
|
||||
const showSearch = () => {
|
||||
searchVisible.value = true
|
||||
}
|
||||
|
||||
// 清除消息
|
||||
const clearMessages = () => {
|
||||
messages.value = []
|
||||
message.success(t('common.cleared'))
|
||||
}
|
||||
|
||||
// 显示任务抽屉
|
||||
const showTasks = () => {
|
||||
taskVisible.value = true
|
||||
}
|
||||
|
||||
// 切换语言
|
||||
const handleLanguageChange = ({ key }) => {
|
||||
i18nStore.setLocale(key)
|
||||
message.success(t('common.languageChanged'))
|
||||
}
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'profile':
|
||||
router.push('/ucenter')
|
||||
break
|
||||
case 'settings':
|
||||
router.push('/system/setting')
|
||||
break
|
||||
case 'clearCache':
|
||||
handleClearCache()
|
||||
break
|
||||
case 'logout':
|
||||
handleLogout()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
const handleClearCache = () => {
|
||||
Modal.confirm({
|
||||
title: t('common.confirmClearCache'),
|
||||
content: t('common.clearCacheConfirm'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () => {
|
||||
try {
|
||||
// 清除 localStorage
|
||||
localStorage.clear()
|
||||
// 清除 sessionStorage
|
||||
sessionStorage.clear()
|
||||
// 清除所有缓存
|
||||
if ('caches' in window) {
|
||||
caches.keys().then(names => {
|
||||
names.forEach(name => {
|
||||
caches.delete(name)
|
||||
})
|
||||
})
|
||||
}
|
||||
message.success(t('common.cacheCleared'))
|
||||
// 延迟刷新页面以应用缓存清除
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
message.error(t('common.clearCacheFailed'))
|
||||
console.error('清除缓存失败:', error)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
Modal.confirm({
|
||||
title: t('common.confirmLogout'),
|
||||
content: t('common.logoutConfirm'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userStore.logout()
|
||||
message.success(t('common.logoutSuccess'))
|
||||
router.push('/login')
|
||||
} catch {
|
||||
message.error(t('common.logoutFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.userbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
margin-right: 8px;
|
||||
|
||||
:deep(.ant-input) {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.lang-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-card {
|
||||
width: 320px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
padding: 12px 16px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 12px 16px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.message-list,
|
||||
.task-list {
|
||||
|
||||
.message-item,
|
||||
.task-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background-color: rgba(24, 144, 255, 0.04);
|
||||
padding: 10px;
|
||||
margin: 0 -10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
.message-title {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.task-item {
|
||||
.completed {
|
||||
text-decoration: line-through;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,665 @@
|
||||
<template>
|
||||
<a-layout class="app-wrapper" :class="layoutClass">
|
||||
<!-- 默认布局:左侧双栏布局 -->
|
||||
<template v-if="layoutMode === 'default'">
|
||||
<!-- 第一个侧边栏:显示一级菜单 -->
|
||||
<a-layout-sider theme="dark" width="70" class="left-sidebar">
|
||||
<div class="logo-box">
|
||||
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
|
||||
</div>
|
||||
<ul class="left-nav">
|
||||
<li v-for="(item, index) in menuList" :key="index"
|
||||
:class="{ active: selectedParentMenu?.path === item.path }"
|
||||
@click="handleParentMenuClick(item)">
|
||||
<component :is="getIconComponent(item.meta?.icon)" />
|
||||
<span>{{ item.meta?.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</a-layout-sider>
|
||||
|
||||
<!-- 第二个侧边栏:显示选中的父菜单的子菜单 -->
|
||||
<a-layout-sider
|
||||
v-if="selectedParentMenu && selectedParentMenu.children && selectedParentMenu.children.length > 0"
|
||||
theme="light" :collapsed="sidebarCollapsed" :collapsible="true" @collapse="handleCollapse" width="200"
|
||||
:collapsed-width="64" class="right-sidebar">
|
||||
<div class="parent-title">
|
||||
<component :is="getIconComponent(selectedParentMenu.meta?.icon)" />
|
||||
<span v-if="!sidebarCollapsed">{{ selectedParentMenu.meta?.title }}</span>
|
||||
</div>
|
||||
<a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline"
|
||||
:selected-keys="[route.path]">
|
||||
<navMenu :menu-items="selectedParentMenu.children" :active-path="route.path" />
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout class="main-layout">
|
||||
<a-layout-header class="app-header">
|
||||
<div class="header-left">
|
||||
<breadcrumb />
|
||||
</div>
|
||||
<userbar />
|
||||
</a-layout-header>
|
||||
<tags />
|
||||
<a-layout-content class="app-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="cachedViews">
|
||||
<component :is="Component" :key="refreshKey" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<!-- Menu布局:左侧菜单栏布局 -->
|
||||
<template v-else-if="layoutMode === 'menu'">
|
||||
<a-layout-sider theme="light" style="border-right: 1px solid #f0f0f0" :collapsed="sidebarCollapsed"
|
||||
:collapsible="true" @collapse="handleCollapse" class="full-menu-sidebar" width="200"
|
||||
:collapsed-width="64">
|
||||
<div class="logo-box-full">
|
||||
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
|
||||
<span v-if="!sidebarCollapsed" class="app-name">{{ config.APP_NAME }}</span>
|
||||
</div>
|
||||
<a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline"
|
||||
:selected-keys="[route.path]">
|
||||
<navMenu :menu-items="menuList" :active-path="route.path" />
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<a-layout class="main-layout">
|
||||
<a-layout-header class="app-header">
|
||||
<div class="header-left">
|
||||
<breadcrumb />
|
||||
</div>
|
||||
<userbar />
|
||||
</a-layout-header>
|
||||
<tags />
|
||||
<a-layout-content class="app-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="cachedViews">
|
||||
<component :is="Component" :key="$route.fullPath" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<!-- Top布局:顶部菜单栏布局 -->
|
||||
<template v-else-if="layoutMode === 'top'">
|
||||
<a-layout-header class="app-header top-header">
|
||||
<div class="top-header-left">
|
||||
<div class="logo-box-top">
|
||||
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
|
||||
<span class="app-name">{{ config.APP_NAME }}</span>
|
||||
</div>
|
||||
<a-menu v-model:selectedKeys="selectedKeys" mode="horizontal" :selected-keys="[route.path]"
|
||||
style="line-height: 60px">
|
||||
<navMenu :menu-items="menuList" :active-path="route.path" />
|
||||
</a-menu>
|
||||
</div>
|
||||
<userbar />
|
||||
</a-layout-header>
|
||||
<tags />
|
||||
<a-layout-content class="app-main top-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="cachedViews">
|
||||
<component :is="Component" :key="refreshKey" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</template>
|
||||
|
||||
<!-- 漂浮的设置按钮 -->
|
||||
<a-float-button type="primary" @click="openSetting">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
</a-float-button>
|
||||
|
||||
<!-- 布局设置组件 -->
|
||||
<setting ref="settingRef" />
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLayoutStore } from '@/stores/modules/layout'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { SettingOutlined } from '@ant-design/icons-vue'
|
||||
import * as icons from '@ant-design/icons-vue'
|
||||
import config from '@/config/index.js'
|
||||
|
||||
import userbar from './components/userbar.vue'
|
||||
import navMenu from './components/navMenu.vue'
|
||||
import breadcrumb from './components/breadcrumb.vue'
|
||||
import tags from './components/tags.vue'
|
||||
import setting from './components/setting.vue'
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
defineOptions({
|
||||
name: 'AppLayouts',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const layoutStore = useLayoutStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const settingRef = ref(null)
|
||||
|
||||
const layoutMode = computed(() => layoutStore.layoutMode)
|
||||
const sidebarCollapsed = computed(() => layoutStore.sidebarCollapsed)
|
||||
const selectedParentMenu = computed(() => layoutStore.selectedParentMenu)
|
||||
|
||||
// 缓存的视图列表
|
||||
const cachedViews = computed(() => {
|
||||
return layoutStore.viewTags.filter((tag) => !tag.meta?.noCache).map((tag) => tag.name)
|
||||
})
|
||||
|
||||
// 布局类名
|
||||
const layoutClass = computed(() => {
|
||||
return {
|
||||
'layout-default': layoutMode.value === 'default',
|
||||
'layout-menu': layoutMode.value === 'menu',
|
||||
'layout-top': layoutMode.value === 'top',
|
||||
'is-collapse': sidebarCollapsed.value,
|
||||
}
|
||||
})
|
||||
|
||||
// 获取刷新 key
|
||||
const refreshKey = computed(() => layoutStore.refreshKey)
|
||||
|
||||
const openKeys = ref([])
|
||||
const selectedKeys = ref([])
|
||||
const menuList = computed(() => {
|
||||
return userStore.menu
|
||||
})
|
||||
|
||||
// 获取图标组件
|
||||
const getIconComponent = (iconName) => {
|
||||
return icons[iconName] || icons.FileTextOutlined
|
||||
}
|
||||
|
||||
// 处理父菜单点击(默认布局的第一级菜单)
|
||||
const handleParentMenuClick = (item) => {
|
||||
// 设置选中的父菜单
|
||||
layoutStore.setSelectedParentMenu(item)
|
||||
// 如果没有子菜单,直接跳转
|
||||
if (!item.children || item.children.length === 0) {
|
||||
if (item.path) {
|
||||
router.push(item.path)
|
||||
}
|
||||
} else {
|
||||
// 默认展开第一个子菜单
|
||||
if (item.children.length > 0 && item.children[0].path) {
|
||||
router.push(item.children[0].path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理折叠
|
||||
const handleCollapse = (collapsed) => {
|
||||
layoutStore.sidebarCollapsed = collapsed
|
||||
}
|
||||
|
||||
// 打开设置抽屉
|
||||
const openSetting = () => {
|
||||
settingRef.value?.openDrawer()
|
||||
}
|
||||
|
||||
// 更新选中的菜单和展开的菜单
|
||||
const updateMenuState = () => {
|
||||
selectedKeys.value = [route.path]
|
||||
|
||||
// 获取所有父级路径
|
||||
const matched = route.matched.filter((item) => item.path !== '/' && item.path !== route.path)
|
||||
const parentPaths = matched.map((item) => item.path)
|
||||
|
||||
// 对于不同的布局模式,处理方式不同
|
||||
if (layoutMode.value === 'default') {
|
||||
// 默认布局:找到当前路由对应的父菜单
|
||||
const currentMenu = findMenuByPath(menuList.value, route.path)
|
||||
if (currentMenu) {
|
||||
// 如果当前菜单有子菜单,设置为选中的父菜单
|
||||
if (currentMenu.children && currentMenu.children.length > 0) {
|
||||
layoutStore.setSelectedParentMenu(currentMenu)
|
||||
} else {
|
||||
// 如果当前菜单是子菜单,找到它的父菜单
|
||||
const parentMenu = findParentMenu(menuList.value, route.path)
|
||||
if (parentMenu) {
|
||||
layoutStore.setSelectedParentMenu(parentMenu)
|
||||
} else {
|
||||
layoutStore.setSelectedParentMenu(currentMenu)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!sidebarCollapsed.value) {
|
||||
// 其他布局模式:展开所有父级菜单
|
||||
openKeys.value = parentPaths
|
||||
}
|
||||
}
|
||||
|
||||
// 根据路径查找菜单
|
||||
const findMenuByPath = (menus, path) => {
|
||||
for (const menu of menus) {
|
||||
if (menu.path === path) {
|
||||
return menu
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const found = findMenuByPath(menu.children, path)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 查找父菜单
|
||||
const findParentMenu = (menus, path) => {
|
||||
for (const menu of menus) {
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
for (const child of menu.children) {
|
||||
if (child.path === path) {
|
||||
return menu
|
||||
}
|
||||
if (child.children && child.children.length > 0) {
|
||||
const found = findParentMenu([child], path)
|
||||
if (found) {
|
||||
return menu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 监听路由变化,更新菜单状态
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
console.log('路由变化:', newPath)
|
||||
updateMenuState()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 监听布局模式变化,确保菜单状态正确
|
||||
watch(
|
||||
() => layoutMode.value,
|
||||
() => {
|
||||
updateMenuState()
|
||||
},
|
||||
)
|
||||
|
||||
// 监听折叠状态
|
||||
watch(
|
||||
() => sidebarCollapsed.value,
|
||||
(val) => {
|
||||
if (val) {
|
||||
openKeys.value = []
|
||||
} else {
|
||||
updateMenuState()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 如果还没有选中的父菜单,默认选中第一个
|
||||
if (layoutMode.value === 'default' && !selectedParentMenu.value && menuList.value.length > 0) {
|
||||
layoutStore.setSelectedParentMenu(menuList.value[0])
|
||||
}
|
||||
updateMenuState()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
height: 60px;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 16px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-main {
|
||||
background-color: #f0f2f5;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
height: calc(100vh - 106px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 默认布局 - 双栏菜单 */
|
||||
&.layout-default {
|
||||
.left-sidebar {
|
||||
background-color: #001529;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 10;
|
||||
|
||||
.logo-box {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
.logo-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.left-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
li {
|
||||
height: 70px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border-left: 3px solid transparent;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #ffffff;
|
||||
background-color: #1890ff;
|
||||
border-left-color: #ffffff;
|
||||
}
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
word-break: break-all;
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-sidebar {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 1px 0 6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 9;
|
||||
|
||||
.parent-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
gap: 5px;
|
||||
height: 60px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:deep(.ant-menu) {
|
||||
border-right: none;
|
||||
|
||||
.ant-menu-item {
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
&.ant-menu-item-selected {
|
||||
background-color: #e6f7ff;
|
||||
|
||||
&::after {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-submenu {
|
||||
>.ant-menu-submenu-title {
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-submenu-open {
|
||||
>.ant-menu-submenu-title {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
height: calc(100vh - 106px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu布局 */
|
||||
&.layout-menu {
|
||||
.full-menu-sidebar {
|
||||
background-color: #ffffff;
|
||||
z-index: 10;
|
||||
transition: all 0.2s;
|
||||
|
||||
.logo-box-full {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: all 0.2s;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
|
||||
.logo-image {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-menu) {
|
||||
border-right: none;
|
||||
height: calc(100% - 60px);
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
margin: 0;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
padding-left: 20px !important;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
&.ant-menu-item-selected {
|
||||
background-color: #e6f7ff;
|
||||
|
||||
&::after {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-submenu {
|
||||
>.ant-menu-submenu-title {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin: 0;
|
||||
padding-left: 20px !important;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-submenu-open {
|
||||
>.ant-menu-submenu-title {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-sub {
|
||||
background-color: #fafafa;
|
||||
|
||||
.ant-menu-item {
|
||||
padding-left: 40px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
height: calc(100vh - 106px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Top布局 */
|
||||
&.layout-top {
|
||||
flex-direction: column;
|
||||
|
||||
.top-header {
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
border-bottom: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.top-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
|
||||
.logo-box-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.logo-image {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-content {
|
||||
height: calc(100vh - 116px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 通用菜单样式优化 */
|
||||
:deep(.ant-menu-item-icon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="not-found-container">
|
||||
<div class="tech-decoration">
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
</div>
|
||||
|
||||
<div class="not-found-content">
|
||||
<div class="error-code">404</div>
|
||||
<div class="error-title">页面未找到</div>
|
||||
<div class="error-description">抱歉,您访问的页面不存在或已被移除</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<a-button type="primary" size="large" @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftOutlined />
|
||||
</template>
|
||||
返回上一页
|
||||
</a-button>
|
||||
<a-button size="large" @click="goHome">
|
||||
<template #icon>
|
||||
<HomeOutlined />
|
||||
</template>
|
||||
返回首页
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeftOutlined, HomeOutlined } from '@ant-design/icons-vue'
|
||||
import '@/assets/style/auth.scss'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Go back to previous page
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// Go to home page
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.not-found-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.tech-decoration {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
.tech-circle {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(255, 107, 53, 0.1);
|
||||
border-radius: 50%;
|
||||
animation: pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tech-circle:nth-child(1) {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
top: -150px;
|
||||
left: -150px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.tech-circle:nth-child(2) {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
bottom: -100px;
|
||||
right: -100px;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.tech-circle:nth-child(3) {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
bottom: 20%;
|
||||
left: -75px;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.error-code {
|
||||
font-size: 120px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.ant-btn {
|
||||
height: 48px;
|
||||
padding: 0 32px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark));
|
||||
border: none;
|
||||
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.ant-btn-primary) {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--auth-primary);
|
||||
color: var(--auth-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(255, 107, 53, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.not-found-content {
|
||||
padding: 20px;
|
||||
|
||||
.error-code {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
.ant-btn {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="empty-container">
|
||||
<div class="tech-decoration">
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
</div>
|
||||
|
||||
<div class="empty-content">
|
||||
<div class="empty-icon">
|
||||
<InboxOutlined :style="{ fontSize: '120px', color: '#ff6b35' }" />
|
||||
</div>
|
||||
|
||||
<div class="empty-title">暂无数据</div>
|
||||
<div class="empty-description">
|
||||
{{ description || '当前页面暂无数据,请稍后再试' }}
|
||||
</div>
|
||||
|
||||
<a-button v-if="showButton" type="primary" size="large" @click="handleAction">
|
||||
<template #icon v-if="buttonIcon">
|
||||
<component :is="buttonIcon" />
|
||||
</template>
|
||||
{{ buttonText || '刷新页面' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { InboxOutlined } from '@ant-design/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
defineOptions({
|
||||
name: 'EmptyPage',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
defineProps({
|
||||
description: {
|
||||
type: String,
|
||||
default: '当前页面暂无数据,请稍后再试',
|
||||
},
|
||||
showButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: '刷新页面',
|
||||
},
|
||||
buttonIcon: {
|
||||
type: [String, Object],
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['action'])
|
||||
|
||||
const handleAction = () => {
|
||||
emit('action')
|
||||
// Default behavior: refresh page
|
||||
router.go(0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.empty-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.tech-decoration {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
.tech-circle {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(255, 107, 53, 0.1);
|
||||
border-radius: 50%;
|
||||
animation: pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tech-circle:nth-child(1) {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
top: -150px;
|
||||
left: -150px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.tech-circle:nth-child(2) {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
bottom: -100px;
|
||||
right: -100px;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.tech-circle:nth-child(3) {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
bottom: 20%;
|
||||
left: -75px;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 32px;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
line-height: 1.6;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
height: 48px;
|
||||
padding: 0 40px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark));
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.empty-content {
|
||||
padding: 20px;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import '@/assets/style/app.scss'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './stores'
|
||||
import i18n from './i18n'
|
||||
import boot from './boot'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(Antd)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
app.use(boot)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="icon-picker-demo">
|
||||
<a-card title="图标选择器演示" style="max-width: 600px; margin: 20px auto;">
|
||||
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
|
||||
<a-form-item label="选择图标">
|
||||
<sc-icon-picker v-model="selectedIcon" @change="handleIconChange" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="选中值">
|
||||
<a-input v-model:value="selectedIcon" readonly />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="图标预览">
|
||||
<div v-if="selectedIcon" class="icon-preview">
|
||||
<component :is="selectedIcon" style="font-size: 48px;" />
|
||||
</div>
|
||||
<a-empty v-else description="未选择图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Empty } from 'ant-design-vue'
|
||||
import ScIconPicker from '@/components/scIconPicker/index.vue'
|
||||
|
||||
const selectedIcon = ref('')
|
||||
|
||||
const handleIconChange = (value) => {
|
||||
console.log('图标已选择:', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.icon-picker-demo {
|
||||
padding: 20px;
|
||||
|
||||
.icon-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
min-height: 88px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<a-skeleton v-if="loading" active />
|
||||
<component v-else :is="dashboardComponent" @on-mounted="handleMounted" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, defineAsyncComponent } from 'vue'
|
||||
import config from '@/config'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'HomePage',
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const dashboard = ref(config.DASHBOARD_LAYOUT || 'work')
|
||||
|
||||
// 动态导入组件
|
||||
const components = {
|
||||
work: defineAsyncComponent(() => import('./work/index.vue')),
|
||||
widgets: defineAsyncComponent(() => import('./widgets/index.vue')),
|
||||
}
|
||||
|
||||
const dashboardComponent = computed(() => {
|
||||
return components[dashboard.value] || components.work
|
||||
})
|
||||
|
||||
const handleMounted = () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 模拟加载延迟
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 300)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<a-card :bordered="true" title="关于项目" class="about-card">
|
||||
<p>高性能 / 精致 / 优雅。基于Vue3 + Ant Design Vue 的中后台前端解决方案,如果喜欢就点个星星支持一下。</p>
|
||||
<p>
|
||||
<a href="https://gitee.com/lolicode/scui" target="_blank">
|
||||
<img src="https://gitee.com/lolicode/scui/badge/star.svg?theme=dark" alt="star" style="vertical-align: middle;">
|
||||
</a>
|
||||
</p>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'AboutWidget',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.about-card {
|
||||
p {
|
||||
color: #999;
|
||||
margin-top: 10px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<a-card :bordered="true" title="实时收入">
|
||||
<a-spin :spinning="loading">
|
||||
<div ref="chartRef" style="width: 100%; height: 300px;"></div>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'EchartsWidget',
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
const loading = ref(true)
|
||||
let chart = null
|
||||
let timer = null
|
||||
|
||||
// 初始化图表
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chart = echarts.init(chartRef.value)
|
||||
|
||||
// 生成初始数据
|
||||
const now = new Date()
|
||||
const xData = []
|
||||
const yData = []
|
||||
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const time = new Date(now.getTime() - i * 2000)
|
||||
xData.unshift(time.toLocaleTimeString().replace(/^\D*/, ''))
|
||||
yData.push(Math.round(Math.random() * 0))
|
||||
}
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
xAxis: {
|
||||
boundaryGap: false,
|
||||
type: 'category',
|
||||
data: xData,
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '价格',
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '收入',
|
||||
type: 'line',
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#409EFF',
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.1,
|
||||
color: '#79bbff',
|
||||
},
|
||||
data: yData,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
chart.setOption(option)
|
||||
|
||||
// 模拟实时更新
|
||||
timer = setInterval(() => {
|
||||
const newTime = new Date().toLocaleTimeString().replace(/^\D*/, '')
|
||||
const newValue = Math.round(Math.random() * 100)
|
||||
|
||||
xData.shift()
|
||||
xData.push(newTime)
|
||||
|
||||
yData.shift()
|
||||
yData.push(newValue)
|
||||
|
||||
chart.setOption({
|
||||
xAxis: {
|
||||
data: xData,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: yData,
|
||||
},
|
||||
],
|
||||
})
|
||||
}, 2100)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 模拟加载延迟
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
initChart()
|
||||
}, 500)
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
const handleResize = () => {
|
||||
if (chart) {
|
||||
chart.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
if (chart) {
|
||||
chart.dispose()
|
||||
}
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 样式根据需要添加
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { markRaw } from 'vue'
|
||||
const resultComps = {}
|
||||
const files = import.meta.glob('./*.vue', { eager: true })
|
||||
Object.keys(files).forEach((fileName) => {
|
||||
let comp = files[fileName]
|
||||
resultComps[fileName.replace(/^\.\/(.*)\.\w+$/, '$1')] = comp.default
|
||||
})
|
||||
export default markRaw(resultComps)
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="info-card">
|
||||
<div class="header">
|
||||
<span class="title">系统信息</span>
|
||||
</div>
|
||||
<a-spin :spinning="loading">
|
||||
<a-descriptions bordered :column="1">
|
||||
<a-descriptions-item v-for="(item, index) in sysInfo" :key="index" :label="item.label">
|
||||
{{ item.values }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import systemApi from '@/api/system'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'InfoWidget',
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const sysInfo = ref([])
|
||||
|
||||
const getSystemList = async () => {
|
||||
try {
|
||||
const res = await systemApi.info.get()
|
||||
if (res.code === 1) {
|
||||
sysInfo.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统信息失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getSystemList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.info-card {
|
||||
background-color: #ffffff;
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
margin: 16px 0;
|
||||
.header{
|
||||
padding-bottom: 10px;
|
||||
.title{
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
width: 140px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<a-card :bordered="true" title="进度环">
|
||||
<div class="progress">
|
||||
<a-progress type="dashboard" :percent="85.5" :width="160">
|
||||
<template #format="percent">
|
||||
<div class="percentage-value">{{ percent }}%</div>
|
||||
<div class="percentage-label">当前进度</div>
|
||||
</template>
|
||||
</a-progress>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'ProgressWidget',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.progress {
|
||||
text-align: center;
|
||||
|
||||
.percentage-value {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.percentage-label {
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<a-row :gutter="10">
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="今日数量">
|
||||
<a-statistic :value="count.today" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="昨日数量">
|
||||
<a-statistic :value="count.yesterday" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="本周数量">
|
||||
<a-statistic :value="count.week" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="上周数量">
|
||||
<a-statistic :value="count.last_week" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="今年数量">
|
||||
<a-statistic :value="count.year" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="总数量">
|
||||
<a-statistic :value="count.all" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import systemApi from '@/api/system'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'SmsWidget',
|
||||
})
|
||||
|
||||
const count = ref({})
|
||||
|
||||
const getSmsCount = async () => {
|
||||
try {
|
||||
// 注意:API中可能没有短信统计接口,这里使用模拟数据
|
||||
// 如果有接口,请取消注释以下代码
|
||||
// const res = await systemApi.sms.count.get()
|
||||
// if (res.code === 1) {
|
||||
// count.value = res.data
|
||||
// }
|
||||
|
||||
// 模拟数据
|
||||
count.value = {
|
||||
today: 1234,
|
||||
yesterday: 5678,
|
||||
week: 45678,
|
||||
last_week: 43210,
|
||||
year: 567890,
|
||||
all: 1234567
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取短信统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumber = (value) => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getSmsCount()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.ant-card-head-title) {
|
||||
padding: 12px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-content) {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<a-card :bordered="true" title="时钟" class="time-card">
|
||||
<div class="time">
|
||||
<h2>{{ time }}</h2>
|
||||
<p>{{ day }}</p>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import tool from '@/utils/tool'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'TimeWidget',
|
||||
})
|
||||
|
||||
const time = ref('')
|
||||
const day = ref('')
|
||||
let timer = null
|
||||
|
||||
const showTime = () => {
|
||||
time.value = tool.dateFormat(new Date(), 'hh:mm:ss')
|
||||
day.value = tool.dateFormat(new Date(), 'yyyy年MM月dd日')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
showTime()
|
||||
timer = setInterval(() => {
|
||||
showTime()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.time-card {
|
||||
background: linear-gradient(to right, #8e54e9, #4776e6);
|
||||
color: #fff;
|
||||
|
||||
:deep(.ant-card-head-title) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
h2 {
|
||||
font-size: 40px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
margin-top: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="ver-card">
|
||||
<div class="header">
|
||||
<span class="title">版本信息</span>
|
||||
</div>
|
||||
<a-spin :spinning="loading">
|
||||
<a-descriptions bordered :column="1">
|
||||
<a-descriptions-item v-for="(item, index) in sysInfo" :key="index" :label="item.label">
|
||||
{{ item.values }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import systemApi from '@/api/system'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'VerWidget',
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const sysInfo = ref([])
|
||||
|
||||
const getSystemList = async () => {
|
||||
try {
|
||||
const res = await systemApi.version.get()
|
||||
if (res.code === 1) {
|
||||
sysInfo.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取版本信息失败:', error)
|
||||
// 使用模拟数据作为fallback
|
||||
sysInfo.value = [
|
||||
{ label: '系统版本', values: '1.0.0' },
|
||||
{ label: '框架版本', values: 'Vue 3.x' },
|
||||
{ label: '构建时间', values: '2024-01-01' },
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getSystemList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ver-card {
|
||||
background-color: #ffffff;
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
margin: 16px 0;
|
||||
.header{
|
||||
padding-bottom: 10px;
|
||||
.title{
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
width: 160px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<a-card :bordered="true" title="欢迎">
|
||||
<div class="welcome">
|
||||
<div class="logo">
|
||||
<img :src="logoImage" alt="logo">
|
||||
<h2>VueAdmin</h2>
|
||||
</div>
|
||||
<div class="tips">
|
||||
<div class="tips-item">
|
||||
<div class="tips-item-icon"><MenuOutlined /></div>
|
||||
<div class="tips-item-message">这里是项目控制台,你可以点击右上方的"自定义"按钮来添加移除或者移动部件。</div>
|
||||
</div>
|
||||
<div class="tips-item">
|
||||
<div class="tips-item-icon"><RocketOutlined /></div>
|
||||
<div class="tips-item-message">在提高前端算力、减少带宽请求和代码执行力上多次优化,并且持续着。</div>
|
||||
</div>
|
||||
<div class="tips-item">
|
||||
<div class="tips-item-icon"><CoffeeOutlined /></div>
|
||||
<div class="tips-item-message">项目目的:让前端工作更快乐</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { MenuOutlined, RocketOutlined, CoffeeOutlined } from '@ant-design/icons-vue'
|
||||
import logoImage from '@/assets/images/logo.png'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'WelcomeWidget',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.welcome {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.welcome .logo {
|
||||
text-align: center;
|
||||
padding: 0 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 30px;
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
padding: 0 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tips-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7.5px 0;
|
||||
}
|
||||
|
||||
.tips-item-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 18px;
|
||||
margin-right: 20px;
|
||||
color: #1890ff;
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.tips-item-message {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<div :class="['widgets-home', customizing ? 'customizing' : '']" ref="main">
|
||||
<div class="widgets-content">
|
||||
<div class="widgets-top">
|
||||
<div class="widgets-top-title">控制台</div>
|
||||
<div class="widgets-top-actions">
|
||||
<a-button v-if="customizing" type="primary" shape="round" @click="handleSave">
|
||||
<template #icon>
|
||||
<CheckOutlined />
|
||||
</template>
|
||||
完成
|
||||
</a-button>
|
||||
<a-button v-else type="primary" shape="round" @click="handleCustom">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
自定义
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="widgets" ref="widgetsRef">
|
||||
<div class="widgets-wrapper">
|
||||
<div v-if="nowCompsList.length <= 0" class="no-widgets">
|
||||
<a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||
</div>
|
||||
<a-row :gutter="15">
|
||||
<a-col v-for="(item, index) in grid.layout" :key="index" :md="item" :xs="24">
|
||||
<div class="draggable-wrapper">
|
||||
<draggable v-model="grid.compsList[index]" item-key="key" :animation="200"
|
||||
handle=".customize-overlay" group="widgets" class="draggable-box">
|
||||
<template #item="{ element }">
|
||||
<div class="widgets-item">
|
||||
<component :is="allComps[element]" />
|
||||
<div v-if="customizing" class="customize-overlay">
|
||||
<a-button class="close" type="primary" ghost shape="circle"
|
||||
@click="removeComp(element)">
|
||||
<template #icon>
|
||||
<CloseOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<label>
|
||||
<component :is="allComps[element].icon" />
|
||||
{{ allComps[element].title }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义侧边栏 -->
|
||||
<a-drawer v-if="customizing" :open="customizing" :width="360" placement="right" :closable="false" :mask="false"
|
||||
class="widgets-drawer">
|
||||
<template #title>
|
||||
<div class="widgets-aside-title">
|
||||
<PlusCircleOutlined /> 添加部件
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<a-button type="text" @click="handleClose">
|
||||
<template #icon>
|
||||
<CloseOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 布局选择 -->
|
||||
<div class="select-layout">
|
||||
<h3>选择布局</h3>
|
||||
<div class="select-layout-options">
|
||||
<div class="select-layout-item item01" :class="{ active: grid.layout.join(',') === '12,6,6' }"
|
||||
@click="setLayout([12, 6, 6])">
|
||||
<a-row :gutter="2">
|
||||
<a-col :span="12"><span></span></a-col>
|
||||
<a-col :span="6"><span></span></a-col>
|
||||
<a-col :span="6"><span></span></a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<div class="select-layout-item item02" :class="{ active: grid.layout.join(',') === '24,16,8' }"
|
||||
@click="setLayout([24, 16, 8])">
|
||||
<a-row :gutter="2">
|
||||
<a-col :span="24"><span></span></a-col>
|
||||
<a-col :span="16"><span></span></a-col>
|
||||
<a-col :span="8"><span></span></a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<div class="select-layout-item item03" :class="{ active: grid.layout.join(',') === '24' }"
|
||||
@click="setLayout([24])">
|
||||
<a-row :gutter="2">
|
||||
<a-col :span="24"><span></span></a-col>
|
||||
<a-col :span="24"><span></span></a-col>
|
||||
<a-col :span="24"><span></span></a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 部件列表 -->
|
||||
<div class="widgets-list">
|
||||
<h3>可用部件</h3>
|
||||
<div v-if="myCompsList.length <= 0" class="widgets-list-nodata">
|
||||
<a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||
</div>
|
||||
<div v-for="item in myCompsList" :key="item.key" class="widgets-list-item">
|
||||
<div class="item-logo">
|
||||
<component :is="item.icon" />
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<h2>{{ item.title }}</h2>
|
||||
<p>{{ item.description }}</p>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a-button type="primary" @click="addComp(item)">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="handleResetDefault">恢复默认</a-button>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { Empty } from 'ant-design-vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import allComps from './components'
|
||||
import config from '@/config'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'WidgetsPage',
|
||||
})
|
||||
|
||||
const customizing = ref(false)
|
||||
const widgetsRef = ref(null)
|
||||
const defaultGrid = config.DEFAULT_GRID
|
||||
const grid = reactive({ layout: [], compsList: [] })
|
||||
|
||||
// 初始化
|
||||
const initGrid = () => {
|
||||
const savedGrid = localStorage.getItem('widgetsGrid')
|
||||
if (savedGrid) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedGrid)
|
||||
grid.layout = parsed.layout
|
||||
grid.compsList = parsed.compsList
|
||||
} catch {
|
||||
resetToDefault()
|
||||
}
|
||||
} else {
|
||||
resetToDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const resetToDefault = () => {
|
||||
grid.layout = [...defaultGrid.layout]
|
||||
grid.compsList = defaultGrid.compsList.map((arr) => [...arr])
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const allCompsList = computed(() => {
|
||||
const list = []
|
||||
for (const key in allComps) {
|
||||
list.push({
|
||||
key,
|
||||
title: allComps[key].title,
|
||||
icon: allComps[key].icon,
|
||||
description: allComps[key].description,
|
||||
})
|
||||
}
|
||||
const myCompKeys = grid.compsList.flat()
|
||||
list.forEach((comp) => {
|
||||
comp.disabled = myCompKeys.includes(comp.key)
|
||||
})
|
||||
return list
|
||||
})
|
||||
|
||||
const myCompsList = computed(() => {
|
||||
return allCompsList.value.filter((item) => !item.disabled)
|
||||
})
|
||||
|
||||
const nowCompsList = computed(() => {
|
||||
return grid.compsList.flat()
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleCustom = () => {
|
||||
customizing.value = true
|
||||
const oldWidth = widgetsRef.value?.offsetWidth || 0
|
||||
nextTick(() => {
|
||||
if (widgetsRef.value) {
|
||||
const scale = widgetsRef.value.offsetWidth / oldWidth
|
||||
widgetsRef.value.style.setProperty('transform', `scale(${scale})`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setLayout = (layout) => {
|
||||
grid.layout = layout
|
||||
if (layout.join(',') === '24') {
|
||||
grid.compsList[0] = [...grid.compsList[0], ...grid.compsList[1], ...grid.compsList[2]]
|
||||
grid.compsList[1] = []
|
||||
grid.compsList[2] = []
|
||||
}
|
||||
}
|
||||
|
||||
const addComp = (item) => {
|
||||
grid.compsList[0].push(item.key)
|
||||
}
|
||||
|
||||
const removeComp = (key) => grid.compsList.forEach((list, index) => {
|
||||
grid.compsList[index] = list.filter((k) => k !== key)
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
customizing.value = false
|
||||
if (widgetsRef.value) {
|
||||
widgetsRef.value.style.removeProperty('transform')
|
||||
}
|
||||
localStorage.setItem('widgetsGrid', JSON.stringify(grid))
|
||||
emit('on-mounted')
|
||||
}
|
||||
|
||||
const handleResetDefault = () => {
|
||||
customizing.value = false
|
||||
if (widgetsRef.value) {
|
||||
widgetsRef.value.style.removeProperty('transform')
|
||||
}
|
||||
resetToDefault()
|
||||
localStorage.removeItem('widgetsGrid')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
customizing.value = false
|
||||
if (widgetsRef.value) {
|
||||
widgetsRef.value.style.removeProperty('transform')
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
initGrid()
|
||||
emit('on-mounted')
|
||||
})
|
||||
|
||||
const emit = defineEmits(['on-mounted'])
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.widgets-home {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.widgets-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.widgets-top {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.widgets-top-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.widgets {
|
||||
transform-origin: top left;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.customizing .widgets-wrapper {
|
||||
margin-right: -360px;
|
||||
}
|
||||
|
||||
.customizing .widgets-wrapper .ant-col {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.customizing .widgets-wrapper .draggable-wrapper {
|
||||
border: 1px dashed #1890ff;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.customizing .widgets-wrapper .no-widgets {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.customizing .widgets-item {
|
||||
position: relative;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.customize-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.customize-overlay:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.customize-overlay label {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
height: 40px;
|
||||
padding: 0 30px;
|
||||
border-radius: 40px;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
margin-top: 8px;
|
||||
|
||||
.anticon {
|
||||
margin-right: 15px;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.customize-overlay .close {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
}
|
||||
|
||||
.widgets-list {
|
||||
margin-top: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.widgets-list-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 15px;
|
||||
align-items: center;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.widgets-list-item .item-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(180, 180, 180, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
margin-right: 15px;
|
||||
color: #6a8bad;
|
||||
}
|
||||
|
||||
.widgets-list-item .item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.widgets-list-item .item-info h2 {
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
cursor: default;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.widgets-list-item .item-info p {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
cursor: default;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.widgets-wrapper .sortable-ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.select-layout {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-layout-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.select-layout-item {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid #d9d9d9;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
background: #d9d9d9;
|
||||
height: 46px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&.item02 span {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
&.item02 .ant-col:nth-child(1) span {
|
||||
height: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&.item03 span {
|
||||
height: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #1890ff;
|
||||
|
||||
span {
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.widgets-drawer {
|
||||
:deep(.ant-drawer-body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.widgets-aside-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.anticon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.customizing .widgets {
|
||||
transform: scale(1) !important;
|
||||
}
|
||||
|
||||
.customizing .widgets-drawer {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.customizing .widgets-wrapper {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<div class="my-app">
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<SettingOutlined />
|
||||
<span>我的常用应用</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="showDrawer">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
添加应用
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<div v-if="myApps.length === 0" class="empty-state">
|
||||
<a-empty description="暂无常用应用,请点击上方按钮添加" />
|
||||
</div>
|
||||
|
||||
<div v-else class="apps-grid">
|
||||
<draggable v-model="myApps" item-key="path" :animation="200" ghost-class="ghost" drag-class="dragging"
|
||||
class="draggable-grid">
|
||||
<template #item="{ element }">
|
||||
<div class="app-item" @click="handleAppClick(element)">
|
||||
<div class="app-icon">
|
||||
<component :is="getIconComponent(element.meta?.icon)" />
|
||||
</div>
|
||||
<div class="app-name">{{ element.meta?.title }}</div>
|
||||
<div class="app-description">{{ element.meta?.description || '点击打开' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 添加应用抽屉 -->
|
||||
<a-drawer v-model:open="drawerVisible" title="管理应用" :width="650" placement="right">
|
||||
<div class="drawer-content">
|
||||
<div class="app-section">
|
||||
<div class="section-header">
|
||||
<h3>
|
||||
<StarFilled />
|
||||
我的常用
|
||||
</h3>
|
||||
<span class="count">{{ myApps.length }} 个应用</span>
|
||||
</div>
|
||||
<p class="tips">拖拽卡片调整顺序,点击移除按钮移除应用</p>
|
||||
<draggable v-model="myApps" item-key="path" :animation="200" ghost-class="drawer-ghost"
|
||||
drag-class="drawer-dragging" class="drawer-grid" group="apps">
|
||||
<template #item="{ element }">
|
||||
<div class="drawer-app-card">
|
||||
<div class="remove-btn" @click.stop="removeApp(element.path)">
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
<div class="app-icon">
|
||||
<component :is="getIconComponent(element.meta?.icon)" />
|
||||
</div>
|
||||
<div class="app-name">{{ element.meta?.title }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div v-if="myApps.length === 0" class="empty-zone">
|
||||
<a-empty description="暂无常用应用,从下方拖入应用" :image="false" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<a-divider style="margin: 24px 0" />
|
||||
|
||||
<div class="app-section">
|
||||
<div class="section-header">
|
||||
<h3>
|
||||
<AppstoreOutlined />
|
||||
全部应用
|
||||
</h3>
|
||||
<span class="count">{{ allApps.length }} 个可用</span>
|
||||
</div>
|
||||
<p class="tips">拖拽卡片到上方添加为常用应用</p>
|
||||
<draggable v-model="allApps" item-key="path" :animation="200" ghost-class="drawer-ghost"
|
||||
drag-class="drawer-dragging" class="drawer-grid" group="apps">
|
||||
<template #item="{ element }">
|
||||
<div class="drawer-app-card">
|
||||
<div class="app-icon">
|
||||
<component :is="getIconComponent(element.meta?.icon)" />
|
||||
</div>
|
||||
<div class="app-name">{{ element.meta?.title }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div v-if="allApps.length === 0" class="empty-zone">
|
||||
<a-empty description="所有应用已添加" :image="false" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="drawerVisible = false">取消</a-button>
|
||||
<a-button type="primary" @click="handleSave">保存设置</a-button>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import * as icons from '@ant-design/icons-vue'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'MyApp',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 从菜单中提取所有应用项(扁平化菜单树)
|
||||
const extractMenuItems = (menus) => {
|
||||
const items = []
|
||||
|
||||
const traverse = (menuList) => {
|
||||
for (const menu of menuList) {
|
||||
// 只添加有路径的菜单项(排除父级菜单项)
|
||||
if (menu.path && (!menu.children || menu.children.length === 0)) {
|
||||
items.push(menu)
|
||||
}
|
||||
// 递归处理子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
traverse(menu.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(menus)
|
||||
return items
|
||||
}
|
||||
|
||||
// 获取所有可用应用
|
||||
const allAvailableApps = computed(() => {
|
||||
return extractMenuItems(userStore.menu || [])
|
||||
})
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const myApps = ref([])
|
||||
const allApps = ref([])
|
||||
|
||||
// 获取图标组件
|
||||
const getIconComponent = (iconName) => {
|
||||
return icons[iconName] || icons.FileTextOutlined
|
||||
}
|
||||
|
||||
// 从本地存储加载数据
|
||||
const loadApps = () => {
|
||||
const savedApps = localStorage.getItem('myApps')
|
||||
if (savedApps) {
|
||||
const savedPaths = JSON.parse(savedApps)
|
||||
myApps.value = allAvailableApps.value.filter(app => savedPaths.includes(app.path))
|
||||
} else {
|
||||
// 默认显示前4个应用
|
||||
myApps.value = allAvailableApps.value.slice(0, 4)
|
||||
}
|
||||
updateAllApps()
|
||||
}
|
||||
|
||||
// 更新全部应用列表(排除已添加的)
|
||||
const updateAllApps = () => {
|
||||
const myAppPaths = myApps.value.map(app => app.path)
|
||||
allApps.value = allAvailableApps.value.filter(app => !myAppPaths.includes(app.path))
|
||||
}
|
||||
|
||||
// 显示抽屉
|
||||
const showDrawer = () => {
|
||||
drawerVisible.value = true
|
||||
updateAllApps()
|
||||
}
|
||||
|
||||
// 移除应用
|
||||
const removeApp = (path) => {
|
||||
const index = myApps.value.findIndex(app => app.path === path)
|
||||
if (index > -1) {
|
||||
myApps.value.splice(index, 1)
|
||||
updateAllApps()
|
||||
}
|
||||
}
|
||||
|
||||
// 应用点击处理
|
||||
const handleAppClick = (app) => {
|
||||
if (app.path) {
|
||||
router.push(app.path)
|
||||
} else {
|
||||
message.warning('该应用没有配置路由')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
const handleSave = () => {
|
||||
const appPaths = myApps.value.map(app => app.path)
|
||||
localStorage.setItem('myApps', JSON.stringify(appPaths))
|
||||
message.success('保存成功')
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadApps()
|
||||
|
||||
// 监听菜单变化,重新加载应用
|
||||
watch(
|
||||
() => userStore.menu,
|
||||
() => {
|
||||
loadApps()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-app {
|
||||
padding: 20px;
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.apps-grid {
|
||||
.draggable-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid #f0f0f0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
opacity: 0.3;
|
||||
background: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
font-size: 40px;
|
||||
color: #1890ff;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.app-description {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 抽屉样式 - 使用全局样式避免深度问题
|
||||
:deep(.my-app .ant-drawer-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
:deep(.my-app .ant-drawer-footer) {
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
.app-section {
|
||||
margin-bottom: 0;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #262626;
|
||||
|
||||
.anticon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
background: #f5f5f5;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-zone {
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
background: #fafafa;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
|
||||
.ant-empty-description {
|
||||
color: #bfbfbf;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 抽屉内卡片式网格布局
|
||||
.drawer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.drawer-app-card {
|
||||
position: relative;
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
|
||||
&:hover {
|
||||
background: #ff7875;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .remove-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
|
||||
border-radius: 12px;
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-description {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽时的样式
|
||||
.drawer-ghost {
|
||||
opacity: 0.4;
|
||||
background: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.drawer-dragging {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="work-page">
|
||||
<MyApp />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MyApp from './components/myapp.vue'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'WorkPage',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.work-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="auth-container">
|
||||
<div class="tech-decoration">
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1 class="auth-title">欢迎回来</h1>
|
||||
<p class="auth-subtitle">登录您的账户继续探索科技世界</p>
|
||||
</div>
|
||||
|
||||
<a-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="auth-form" @finish="handleLogin" layout="vertical">
|
||||
<a-form-item name="username">
|
||||
<a-input v-model:value="loginForm.username" placeholder="请输入用户名/邮箱" size="large">
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password">
|
||||
<a-input-password v-model:value="loginForm.password" placeholder="请输入密码" size="large" @pressEnter="handleLogin">
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<div class="auth-links">
|
||||
<a-checkbox v-model:checked="loginForm.rememberMe" class="remember-me"> 记住我 </a-checkbox>
|
||||
<router-link to="/reset-password" class="forgot-password"> 忘记密码? </router-link>
|
||||
</div>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p class="auth-footer-text">
|
||||
还没有账户?
|
||||
<router-link to="/userRegister" class="auth-link"> 立即注册 </router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import auth from '@/api/auth'
|
||||
import config from '@/config'
|
||||
import '@/assets/style/auth.scss'
|
||||
|
||||
defineOptions({
|
||||
name: 'LoginPage',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const loginFormRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// Login form data
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
})
|
||||
|
||||
// Form validation rules
|
||||
const loginRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名或邮箱' },
|
||||
{ min: 3, max: 50, message: '长度在 3 到 50 个字符' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码长度不能少于 6 位' },
|
||||
],
|
||||
}
|
||||
|
||||
// Handle login
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
try {
|
||||
// Validate form
|
||||
await loginFormRef.value.validate()
|
||||
loading.value = true
|
||||
|
||||
// 1. Call login API
|
||||
const loginResponse = await auth.login.post({
|
||||
username: loginForm.username,
|
||||
password: loginForm.password,
|
||||
})
|
||||
|
||||
// Check if login was successful
|
||||
if (loginResponse.code !== 200) {
|
||||
throw new Error(loginResponse.message || '登录失败')
|
||||
}
|
||||
|
||||
const loginData = loginResponse.data
|
||||
|
||||
// 2. Store token persistently (接口返回的是 token 字段)
|
||||
if (loginData.token) {
|
||||
userStore.setToken(loginData.token)
|
||||
}
|
||||
|
||||
// Store user information if available (登录接口已返回用户信息)
|
||||
if (loginData.user) {
|
||||
userStore.setUserInfo(loginData.user)
|
||||
}
|
||||
|
||||
// 3. Store menu and permissions from login response
|
||||
// 根据接口文档,登录接口已返回 menu 和 permissions 数据
|
||||
if (loginData.menu) {
|
||||
userStore.setMenu(loginData.menu)
|
||||
}
|
||||
|
||||
if (loginData.permissions) {
|
||||
userStore.setPermissions(loginData.permissions)
|
||||
}
|
||||
|
||||
// Success message
|
||||
message.success('登录成功!')
|
||||
|
||||
// Redirect to dashboard or redirect parameter
|
||||
setTimeout(() => {
|
||||
// Get redirect from query parameter
|
||||
const redirect = route.query.redirect
|
||||
|
||||
if (redirect) {
|
||||
// If there's a redirect parameter, go there
|
||||
router.push(redirect)
|
||||
} else {
|
||||
// Otherwise, go to configured dashboard URL
|
||||
router.push(config.DASHBOARD_URL)
|
||||
}
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
// Clear user data on login failure
|
||||
userStore.logout()
|
||||
|
||||
// Show error message
|
||||
const errorMsg = error.response?.data?.msg || error.msg || error.message || '登录失败,请检查用户名和密码'
|
||||
message.error(errorMsg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="auth-container">
|
||||
<div class="tech-decoration">
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1 class="auth-title">找回密码</h1>
|
||||
<p class="auth-subtitle">输入您的邮箱,我们将发送重置密码链接</p>
|
||||
</div>
|
||||
|
||||
<a-form ref="forgotFormRef" :model="forgotForm" :rules="forgotRules" class="auth-form" @finish="handleSubmit" layout="vertical">
|
||||
<a-form-item name="email">
|
||||
<a-input v-model:value="forgotForm.email" placeholder="请输入注册邮箱" size="large" @pressEnter="handleSubmit">
|
||||
<template #prefix>
|
||||
<MailOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="captcha" v-if="showCaptcha">
|
||||
<div style="display: flex; gap: 12px">
|
||||
<a-input v-model:value="forgotForm.captcha" placeholder="请输入验证码" size="large" style="flex: 1">
|
||||
<template #prefix>
|
||||
<SafetyOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
<a-button type="default" size="large" :disabled="captchaDisabled" @click="sendCaptcha">
|
||||
{{ captchaButtonText }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
|
||||
{{ loading ? '提交中...' : '发送重置链接' }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p class="auth-footer-text">
|
||||
想起密码了?
|
||||
<router-link to="/login" class="auth-link"> 返回登录 </router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { MailOutlined, SafetyOutlined } from '@ant-design/icons-vue'
|
||||
import '@/assets/style/auth.scss'
|
||||
|
||||
defineOptions({
|
||||
name: 'ResetPasswordPage',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const forgotFormRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const showCaptcha = ref(false)
|
||||
const captchaDisabled = ref(false)
|
||||
const countdown = ref(60)
|
||||
|
||||
// Forgot password form data
|
||||
const forgotForm = reactive({
|
||||
email: '',
|
||||
captcha: '',
|
||||
})
|
||||
|
||||
// Captcha button text
|
||||
const captchaButtonText = ref('获取验证码')
|
||||
|
||||
// Form validation rules
|
||||
const forgotRules = {
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址' },
|
||||
{ type: 'email', message: '请输入正确的邮箱地址' },
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: '请输入验证码' },
|
||||
{ len: 6, message: '验证码为6位数字' },
|
||||
],
|
||||
}
|
||||
|
||||
// Send captcha code
|
||||
const sendCaptcha = async () => {
|
||||
if (!forgotForm.email) {
|
||||
message.warning('请先输入邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(forgotForm.email)) {
|
||||
message.warning('请输入正确的邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Simulate API call - Replace with actual API call
|
||||
// Example: const response = await sendCaptchaApi(forgotForm.email)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
message.success('验证码已发送至您的邮箱')
|
||||
|
||||
// Start countdown
|
||||
captchaDisabled.value = true
|
||||
const timer = setInterval(() => {
|
||||
countdown.value--
|
||||
captchaButtonText.value = `${countdown.value}秒后重试`
|
||||
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
captchaDisabled.value = false
|
||||
captchaButtonText.value = '获取验证码'
|
||||
countdown.value = 60
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
showCaptcha.value = true
|
||||
} catch (error) {
|
||||
console.error('Send captcha failed:', error)
|
||||
message.error('发送验证码失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = async () => {
|
||||
if (!forgotFormRef.value) return
|
||||
|
||||
try {
|
||||
await forgotFormRef.value.validate()
|
||||
loading.value = true
|
||||
|
||||
// Simulate API call - Replace with actual API call
|
||||
// Example: const response = await forgotPasswordApi(forgotForm)
|
||||
|
||||
// Simulated delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// Success message
|
||||
message.success('密码重置链接已发送至您的邮箱,请注意查收')
|
||||
|
||||
// Redirect to login page
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
console.error('Forgot password failed:', error)
|
||||
message.error('提交失败,请检查邮箱地址和验证码')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="auth-container">
|
||||
<div class="tech-decoration">
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1 class="auth-title">创建账户</h1>
|
||||
<p class="auth-subtitle">加入我们,开启科技之旅</p>
|
||||
</div>
|
||||
|
||||
<a-form ref="registerFormRef" :model="registerForm" :rules="registerRules" class="auth-form" @finish="handleRegister" layout="vertical">
|
||||
<a-form-item name="username">
|
||||
<a-input v-model:value="registerForm.username" placeholder="请输入用户名" size="large">
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="email">
|
||||
<a-input v-model:value="registerForm.email" placeholder="请输入邮箱地址" size="large">
|
||||
<template #prefix>
|
||||
<MailOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password">
|
||||
<a-input-password v-model:value="registerForm.password" placeholder="请输入密码(至少6位)" size="large">
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="confirmPassword">
|
||||
<a-input-password v-model:value="registerForm.confirmPassword" placeholder="请再次输入密码" size="large" @pressEnter="handleRegister">
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="agreeTerms">
|
||||
<a-checkbox v-model:checked="registerForm.agreeTerms" class="remember-me">
|
||||
我已阅读并同意
|
||||
<a href="#" class="auth-link">服务条款</a>
|
||||
和
|
||||
<a href="#" class="auth-link">隐私政策</a>
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p class="auth-footer-text">
|
||||
已有账户?
|
||||
<router-link to="/login" class="auth-link"> 立即登录 </router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, MailOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import '@/assets/style/auth.scss'
|
||||
|
||||
defineOptions({
|
||||
name: 'RegisterPage',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const registerFormRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Register form data
|
||||
const registerForm = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agreeTerms: false,
|
||||
})
|
||||
|
||||
// Form validation rules
|
||||
const registerRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, max: 20, message: '长度在 3 到 20 个字符' },
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址' },
|
||||
{ type: 'email', message: '请输入正确的邮箱地址' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码长度不能少于 6 位' },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入密码' },
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (value !== registerForm.password) {
|
||||
return Promise.reject('两次输入的密码不一致')
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
},
|
||||
],
|
||||
agreeTerms: [
|
||||
{
|
||||
type: 'enum',
|
||||
enum: [true],
|
||||
message: '请阅读并同意服务条款和隐私政策',
|
||||
transform: (value) => value || false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Handle register
|
||||
const handleRegister = async () => {
|
||||
if (!registerFormRef.value) return
|
||||
|
||||
try {
|
||||
await registerFormRef.value.validate()
|
||||
loading.value = true
|
||||
|
||||
// Simulate API call - Replace with actual API call
|
||||
// Example: const response = await registerApi(registerForm)
|
||||
|
||||
// Simulated delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// Success message
|
||||
message.success('注册成功!正在跳转到登录页面...')
|
||||
|
||||
// Redirect to login page
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
} catch (error) {
|
||||
console.error('Register failed:', error)
|
||||
message.error('注册失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<scForm :form-items="formItems" :initial-values="initialValues" :loading="loading" @finish="handleFinish"
|
||||
@reset="handleReset" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import scForm from '@/components/scForm/index.vue'
|
||||
import api from '@/api/auth'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
|
||||
const props = defineProps({
|
||||
userInfo: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单初始值
|
||||
const initialValues = computed(() => ({
|
||||
username: props.userInfo.username || '',
|
||||
nickname: props.userInfo.nickname || '',
|
||||
mobile: props.userInfo.mobile || '',
|
||||
email: props.userInfo.email || '',
|
||||
gender: props.userInfo.gender || 0,
|
||||
birthday: props.userInfo.birthday || null,
|
||||
bio: props.userInfo.bio || '',
|
||||
}))
|
||||
|
||||
// 表单项配置
|
||||
const formItems = [
|
||||
{ field: 'username', label: '用户名', type: 'input' },
|
||||
{
|
||||
field: 'nickname', label: '昵称', type: 'input', required: true,
|
||||
rules: [
|
||||
{ required: true, message: '请输入昵称', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' },
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'phone', label: '手机号', type: 'input',
|
||||
rules: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
|
||||
},
|
||||
{
|
||||
field: 'email', label: '邮箱', type: 'input',
|
||||
rules: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
|
||||
},
|
||||
{
|
||||
field: 'gender', label: '性别', type: 'radio',
|
||||
options: [
|
||||
{ label: '男', value: 1 },
|
||||
{ label: '女', value: 2 },
|
||||
{ label: '保密', value: 0 },
|
||||
],
|
||||
},
|
||||
{ field: 'remark', label: '个人简介', type: 'textarea', rows: 4, maxLength: 200, showCount: true, },
|
||||
]
|
||||
|
||||
// 表单提交
|
||||
const handleFinish = async (values) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 调用更新当前用户信息接口
|
||||
let res = await api.users.edit.post({
|
||||
username: values.username,
|
||||
nickname: values.nickname,
|
||||
mobile: values.mobile,
|
||||
email: values.email,
|
||||
gender: values.gender,
|
||||
remark: values.remark,
|
||||
})
|
||||
|
||||
if (!res || res.code !== 1) {
|
||||
throw new Error(res.message || '保存失败,请重试')
|
||||
}
|
||||
// 重新获取用户信息
|
||||
const response = await api.user.get()
|
||||
if (response && response.data) {
|
||||
userStore.setUserInfo(response.data)
|
||||
}
|
||||
|
||||
// 通知父组件更新
|
||||
emit('update', values)
|
||||
message.success('保存成功')
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
message.info('已重置')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<scForm :form-items="formItems" :initial-values="initialValues" :loading="loading" submit-text="修改密码"
|
||||
@finish="handleFinish" @reset="handleReset" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import scForm from '@/components/scForm/index.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单初始值
|
||||
const initialValues = {
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
}
|
||||
|
||||
// 表单项配置
|
||||
const formItems = [
|
||||
{
|
||||
field: 'oldPassword',
|
||||
label: '原密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||||
},
|
||||
{
|
||||
field: 'newPassword',
|
||||
label: '新密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
rules: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
rules: [
|
||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (value !== initialValues.newPassword) {
|
||||
return Promise.reject('两次输入的密码不一致')
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 表单提交
|
||||
const handleFinish = (values) => {
|
||||
loading.value = true
|
||||
// 模拟接口请求
|
||||
setTimeout(() => {
|
||||
message.success('密码修改成功,请重新登录')
|
||||
emit('success')
|
||||
handleReset()
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
initialValues.oldPassword = ''
|
||||
initialValues.newPassword = ''
|
||||
initialValues.confirmPassword = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="profile-info">
|
||||
<div class="avatar-wrapper">
|
||||
<a-avatar :size="100" :src="userInfo.avatar" @click="handleAvatarClick">
|
||||
{{ userInfo.nickname?.charAt(0) }}
|
||||
</a-avatar>
|
||||
</div>
|
||||
<div class="user-name">{{ userInfo.nickname || userInfo.username }}</div>
|
||||
<a-tag :color="userInfo.status === 1 ? 'green' : 'red'">
|
||||
{{ userInfo.status === 1 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
userInfo: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['avatar-click'])
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
emit('avatar-click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
|
||||
.avatar-wrapper {
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
.ant-avatar {
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ant-tag {
|
||||
margin: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<a-list :data-source="securityList" item-layout="horizontal">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ item.title }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ item.description }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-button type="primary" size="small" @click="handleAction(item.action)">
|
||||
{{ item.buttonText }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const emit = defineEmits(['change-password'])
|
||||
|
||||
const securityList = ref([
|
||||
{
|
||||
title: '登录密码',
|
||||
description: '用于登录系统的密码,建议定期更换',
|
||||
buttonText: '修改',
|
||||
action: 'password',
|
||||
},
|
||||
{
|
||||
title: '手机验证',
|
||||
description: '用于接收重要通知和安全验证',
|
||||
buttonText: '已绑定',
|
||||
action: 'phone',
|
||||
},
|
||||
{
|
||||
title: '邮箱验证',
|
||||
description: '用于接收重要通知和账号找回',
|
||||
buttonText: '已绑定',
|
||||
action: 'email',
|
||||
},
|
||||
{
|
||||
title: '登录设备',
|
||||
description: '查看和管理已登录的设备',
|
||||
buttonText: '查看',
|
||||
action: 'device',
|
||||
},
|
||||
])
|
||||
|
||||
const handleAction = (action) => {
|
||||
switch (action) {
|
||||
case 'password':
|
||||
emit('change-password')
|
||||
break
|
||||
case 'phone':
|
||||
message.info('手机绑定功能开发中')
|
||||
break
|
||||
case 'email':
|
||||
message.info('邮箱绑定功能开发中')
|
||||
break
|
||||
case 'device':
|
||||
message.info('登录设备管理功能开发中')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.ant-list-item) {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:deep(.ant-list-item:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="ucenter">
|
||||
<a-card>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="6">
|
||||
<ProfileInfo :user-info="userInfo" @avatar-click="showAvatarModal = true" />
|
||||
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" class="menu">
|
||||
<a-menu-item key="basic">
|
||||
<UserOutlined />
|
||||
基本信息
|
||||
</a-menu-item>
|
||||
<a-menu-item key="password">
|
||||
<LockOutlined />
|
||||
修改密码
|
||||
</a-menu-item>
|
||||
<a-menu-item key="security">
|
||||
<SafetyOutlined />
|
||||
账号安全
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-col>
|
||||
<a-col :span="18">
|
||||
<div class="content-wrapper">
|
||||
<BasicInfo v-if="selectedKeys[0] === 'basic'" :user-info="userInfo"
|
||||
@update="handleUpdateUserInfo" />
|
||||
<Password v-else-if="selectedKeys[0] === 'password'" @success="handlePasswordSuccess" />
|
||||
<Security v-else-if="selectedKeys[0] === 'security'" @change-password="handleChangePassword" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 头像上传弹窗 -->
|
||||
<a-modal v-model:open="showAvatarModal" title="更换头像" :confirm-loading="loading" @ok="handleAvatarUpload"
|
||||
@cancel="showAvatarModal = false">
|
||||
<div class="avatar-upload">
|
||||
<a-upload list-type="picture-card" :max-count="1" :before-upload="beforeUpload"
|
||||
@change="handleAvatarChange" :file-list="avatarFileList">
|
||||
<div v-if="avatarFileList.length === 0">
|
||||
<PlusOutlined />
|
||||
<div class="ant-upload-text">上传头像</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div class="upload-tip">
|
||||
<a-typography-text type="secondary"> 支持 JPG、PNG 格式,文件大小不超过 2MB </a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import ProfileInfo from './components/ProfileInfo.vue'
|
||||
import BasicInfo from './components/BasicInfo.vue'
|
||||
import Password from './components/Password.vue'
|
||||
import Security from './components/Security.vue'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import api from '@/api/auth'
|
||||
|
||||
defineOptions({
|
||||
name: 'UserCenter',
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 用户信息
|
||||
const userInfo = ref({})
|
||||
|
||||
// 选中的菜单
|
||||
const selectedKeys = ref(['basic'])
|
||||
|
||||
// 头像上传
|
||||
const showAvatarModal = ref(false)
|
||||
const avatarFileList = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 初始化用户信息
|
||||
const initUserInfo = async () => {
|
||||
try {
|
||||
// 从 store 获取用户信息
|
||||
const storeUserInfo = userStore.userInfo
|
||||
if (storeUserInfo) {
|
||||
userInfo.value = storeUserInfo
|
||||
} else {
|
||||
// 如果 store 中没有用户信息,则从接口获取
|
||||
const response = await api.user.get()
|
||||
if (response && response.data) {
|
||||
userStore.setUserInfo(response.data)
|
||||
userInfo.value = response.data
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(err.message || '获取用户信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
const handleUpdateUserInfo = (data) => {
|
||||
// 更新本地用户信息
|
||||
Object.assign(userInfo.value, data)
|
||||
|
||||
// 如果 birthday 有值,转换为 dayjs 对象
|
||||
if (data.birthday && typeof data.birthday === 'string') {
|
||||
userInfo.value.birthday = dayjs(data.birthday)
|
||||
}
|
||||
}
|
||||
|
||||
// 密码修改成功
|
||||
const handlePasswordSuccess = () => {
|
||||
// 密码修改成功后的处理
|
||||
}
|
||||
|
||||
// 切换到密码修改页面
|
||||
const handleChangePassword = () => {
|
||||
selectedKeys.value = ['password']
|
||||
}
|
||||
|
||||
// 头像上传前校验
|
||||
const beforeUpload = (file) => {
|
||||
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
|
||||
if (!isJpgOrPng) {
|
||||
message.error('只能上传 JPG/PNG 格式的文件!')
|
||||
return false
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2
|
||||
if (!isLt2M) {
|
||||
message.error('图片大小不能超过 2MB!')
|
||||
return false
|
||||
}
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
// 头像文件变化
|
||||
const handleAvatarChange = ({ fileList }) => {
|
||||
avatarFileList.value = fileList
|
||||
}
|
||||
|
||||
// 上传头像
|
||||
const handleAvatarUpload = () => {
|
||||
if (avatarFileList.value.length === 0) {
|
||||
message.warning('请先选择头像')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
// 模拟上传
|
||||
setTimeout(() => {
|
||||
const file = avatarFileList.value[0]
|
||||
userInfo.value.avatar = URL.createObjectURL(file.originFileObj)
|
||||
message.success('头像更新成功')
|
||||
showAvatarModal.value = false
|
||||
avatarFileList.value = []
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initUserInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ucenter {
|
||||
padding: 16px;
|
||||
.content-wrapper {
|
||||
padding: 24px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
margin-top: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
.ant-menu-item {
|
||||
border-radius: 6px;
|
||||
margin: 4px 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&.ant-menu-item-selected {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
|
||||
&::after {
|
||||
border-right-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-upload {
|
||||
.upload-tip {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-card-head-title) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import config from '../config'
|
||||
import { useUserStore } from '../stores/modules/user'
|
||||
import systemRoutes from './systemRoutes'
|
||||
|
||||
// 配置 NProgress
|
||||
NProgress.configure({
|
||||
showSpinner: false,
|
||||
trickleSpeed: 200,
|
||||
minimum: 0.3
|
||||
})
|
||||
|
||||
/**
|
||||
* 404 路由
|
||||
*/
|
||||
const notFoundRoute = {
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('../layouts/other/404.vue'),
|
||||
meta: {
|
||||
title: '404',
|
||||
hidden: true
|
||||
}
|
||||
}
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: systemRoutes
|
||||
})
|
||||
|
||||
/**
|
||||
* 组件导入映射
|
||||
*/
|
||||
const modules = import.meta.glob('../pages/**/*.vue')
|
||||
|
||||
/**
|
||||
* 动态加载组件
|
||||
* @param {string} componentPath - 组件路径
|
||||
* @returns {Promise} 组件
|
||||
*/
|
||||
function loadComponent(componentPath) {
|
||||
// 如果组件路径以 'views/' 或 'pages/' 开头,则从相应目录加载
|
||||
if (componentPath.startsWith('views/')) {
|
||||
const path = componentPath.replace('views/', '../pages/')
|
||||
return modules[`${path}.vue`]
|
||||
}
|
||||
|
||||
// 如果是简单的组件名称,从 pages 目录加载
|
||||
return modules[`../pages/${componentPath}/index.vue`]
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端菜单转换为路由格式
|
||||
* @param {Array} menus - 后端返回的菜单数据
|
||||
* @returns {Array} 路由数组
|
||||
*/
|
||||
function transformMenusToRoutes(menus) {
|
||||
if (!menus || !Array.isArray(menus)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return menus
|
||||
.filter(menu => menu && menu.path)
|
||||
.map(menu => {
|
||||
const route = {
|
||||
path: menu.path,
|
||||
name: menu.name || menu.path.replace(/\//g, '-'),
|
||||
meta: {
|
||||
title: menu.meta?.title || menu.title,
|
||||
icon: menu.meta?.icon || menu.icon,
|
||||
hidden: menu.hidden || menu.meta?.hidden,
|
||||
keepAlive: menu.meta?.keepAlive || false,
|
||||
affix: menu.meta?.affix || 0,
|
||||
role: menu.meta?.role || []
|
||||
}
|
||||
}
|
||||
|
||||
// 处理组件
|
||||
if (menu.component) {
|
||||
route.component = loadComponent(menu.component)
|
||||
}
|
||||
|
||||
// 处理子路由
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
route.children = transformMenusToRoutes(menu.children)
|
||||
}
|
||||
|
||||
// 处理重定向
|
||||
if (menu.redirect) {
|
||||
route.redirect = menu.redirect
|
||||
}
|
||||
|
||||
return route
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由守卫
|
||||
*/
|
||||
let isDynamicRouteLoaded = false
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 开始进度条
|
||||
NProgress.start()
|
||||
|
||||
// 设置页面标题
|
||||
document.title = to.meta.title
|
||||
? `${to.meta.title} - ${config.APP_NAME}`
|
||||
: config.APP_NAME
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isLoggedIn = userStore.isLoggedIn()
|
||||
const whiteList = config.whiteList || []
|
||||
|
||||
// 1. 如果在白名单中,直接放行
|
||||
if (whiteList.includes(to.path)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 如果未登录,跳转到登录页
|
||||
if (!isLoggedIn) {
|
||||
// 保存目标路由,登录后跳转
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 已登录情况
|
||||
// 如果访问登录页,重定向到首页
|
||||
if (to.path === '/login') {
|
||||
next({ path: config.DASHBOARD_URL })
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 动态路由加载
|
||||
if (!isDynamicRouteLoaded) {
|
||||
try {
|
||||
// 获取后端返回的用户菜单
|
||||
const mergedMenus = userStore.getMenu()
|
||||
|
||||
if (mergedMenus && mergedMenus.length > 0) {
|
||||
// 将合并后的菜单转换为路由
|
||||
const dynamicRoutes = transformMenusToRoutes(mergedMenus)
|
||||
|
||||
// 添加动态路由到 Layout 的子路由
|
||||
dynamicRoutes.forEach(route => {
|
||||
router.addRoute('Layout', route)
|
||||
})
|
||||
|
||||
// 添加 404 路由(必须在最后添加)
|
||||
router.addRoute(notFoundRoute)
|
||||
|
||||
isDynamicRouteLoaded = true
|
||||
|
||||
// 重新导航,确保新添加的路由被正确匹配
|
||||
next({ ...to, replace: true })
|
||||
} else {
|
||||
// 如果没有菜单数据,重置并跳转到登录页
|
||||
userStore.logout()
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('动态路由加载失败:', error)
|
||||
|
||||
// 加载失败,清除用户信息并跳转到登录页
|
||||
userStore.logout()
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 动态路由已加载,直接放行
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
// 结束进度条
|
||||
NProgress.done()
|
||||
})
|
||||
|
||||
/**
|
||||
* 重置路由(用于登出时)
|
||||
*/
|
||||
export function resetRouter() {
|
||||
// 移除所有动态添加的路由
|
||||
isDynamicRouteLoaded = false
|
||||
|
||||
// 重置为初始路由
|
||||
const newRouter = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: systemRoutes
|
||||
})
|
||||
|
||||
router.matcher = newRouter.matcher
|
||||
}
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,43 @@
|
||||
import config from '@/config'
|
||||
|
||||
/**
|
||||
* 基础路由(不需要登录)
|
||||
*/
|
||||
const systemRoutes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../pages/login/index.vue'),
|
||||
meta: {
|
||||
title: 'login',
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('../pages/login/userRegister.vue'),
|
||||
meta: {
|
||||
title: 'register',
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'ResetPassword',
|
||||
component: () => import('../pages/login/resetPassword.vue'),
|
||||
meta: {
|
||||
title: 'resetPassword',
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Layout',
|
||||
component: () => import('@/layouts/index.vue'),
|
||||
redirect: config.DASHBOARD_URL,
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
|
||||
export default systemRoutes
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册持久化插件
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
export default pinia
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import i18n from '@/i18n'
|
||||
import { customStorage } from '../persist'
|
||||
|
||||
export const useI18nStore = defineStore(
|
||||
'i18n',
|
||||
{
|
||||
state: () => ({
|
||||
currentLocale: 'zh-CN',
|
||||
availableLocales: [
|
||||
{ label: '简体中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' }
|
||||
]
|
||||
}),
|
||||
|
||||
getters: {
|
||||
localeLabel: (state) => {
|
||||
const locale = state.availableLocales.find((item) => item.value === state.currentLocale)
|
||||
return locale ? locale.label : ''
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
setLocale(locale) {
|
||||
this.currentLocale = locale
|
||||
i18n.global.locale.value = locale
|
||||
}
|
||||
},
|
||||
|
||||
persist: {
|
||||
key: 'i18n-store',
|
||||
storage: customStorage,
|
||||
pick: ['currentLocale']
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { customStorage } from '../persist'
|
||||
|
||||
export const useLayoutStore = defineStore(
|
||||
'layout',
|
||||
() => {
|
||||
// 布局模式:'default', 'menu', 'top'
|
||||
const layoutMode = ref('default')
|
||||
|
||||
// 侧边栏折叠状态
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
// 主题颜色
|
||||
const themeColor = ref('#1890ff')
|
||||
|
||||
// 显示标签栏
|
||||
const showTags = ref(true)
|
||||
|
||||
// 显示面包屑
|
||||
const showBreadcrumb = ref(true)
|
||||
|
||||
// 当前选中的父菜单(用于双栏布局)
|
||||
const selectedParentMenu = ref(null)
|
||||
|
||||
// 视图标签页(用于记录页面滚动位置)
|
||||
const viewTags = ref([])
|
||||
|
||||
// 刷新标签的 key,用于触发组件刷新
|
||||
const refreshKey = ref(0)
|
||||
|
||||
// 切换侧边栏折叠
|
||||
const toggleSidebar = () => {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
// 设置选中的父菜单
|
||||
const setSelectedParentMenu = (menu) => {
|
||||
selectedParentMenu.value = menu
|
||||
}
|
||||
|
||||
// 设置布局模式
|
||||
const setLayoutMode = (mode) => {
|
||||
layoutMode.value = mode
|
||||
}
|
||||
|
||||
// 更新视图标签
|
||||
const updateViewTags = (tag) => {
|
||||
const index = viewTags.value.findIndex((item) => item.fullPath === tag.fullPath)
|
||||
if (index !== -1) {
|
||||
viewTags.value[index] = tag
|
||||
} else {
|
||||
viewTags.value.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除视图标签
|
||||
const removeViewTags = (fullPath) => {
|
||||
const index = viewTags.value.findIndex((item) => item.fullPath === fullPath)
|
||||
if (index !== -1) {
|
||||
viewTags.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空视图标签
|
||||
const clearViewTags = () => {
|
||||
viewTags.value = []
|
||||
}
|
||||
|
||||
// 设置主题颜色
|
||||
const setThemeColor = (color) => {
|
||||
themeColor.value = color
|
||||
document.documentElement.style.setProperty('--primary-color', color)
|
||||
}
|
||||
|
||||
// 设置标签栏显示
|
||||
const setShowTags = (show) => {
|
||||
showTags.value = show
|
||||
document.documentElement.style.setProperty('--show-tags', show ? 'block' : 'none')
|
||||
}
|
||||
|
||||
// 设置面包屑显示
|
||||
const setShowBreadcrumb = (show) => {
|
||||
showBreadcrumb.value = show
|
||||
}
|
||||
|
||||
// 刷新标签
|
||||
const refreshTag = () => {
|
||||
refreshKey.value++
|
||||
}
|
||||
|
||||
// 重置主题设置
|
||||
const resetTheme = () => {
|
||||
themeColor.value = '#1890ff'
|
||||
showTags.value = true
|
||||
showBreadcrumb.value = true
|
||||
document.documentElement.style.setProperty('--primary-color', '#1890ff')
|
||||
document.documentElement.style.setProperty('--show-tags', 'block')
|
||||
}
|
||||
|
||||
return {
|
||||
layoutMode,
|
||||
sidebarCollapsed,
|
||||
selectedParentMenu,
|
||||
viewTags,
|
||||
themeColor,
|
||||
showTags,
|
||||
showBreadcrumb,
|
||||
refreshKey,
|
||||
toggleSidebar,
|
||||
setLayoutMode,
|
||||
setSelectedParentMenu,
|
||||
updateViewTags,
|
||||
removeViewTags,
|
||||
clearViewTags,
|
||||
setThemeColor,
|
||||
setShowTags,
|
||||
setShowBreadcrumb,
|
||||
resetTheme,
|
||||
refreshTag,
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'layout-store',
|
||||
storage: customStorage,
|
||||
pick: ['layoutMode', 'sidebarCollapsed', 'themeColor', 'showTags', 'showBreadcrumb', 'viewTags'],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { resetRouter } from '../../router'
|
||||
import { customStorage } from '../persist'
|
||||
import userRoutes from '@/config/routes'
|
||||
|
||||
export const useUserStore = defineStore(
|
||||
'user',
|
||||
() => {
|
||||
const token = ref('')
|
||||
const refreshToken = ref('')
|
||||
const userInfo = ref(null)
|
||||
const menu = ref([])
|
||||
const permissions = ref([])
|
||||
|
||||
// 设置 token
|
||||
function setToken(newToken) {
|
||||
token.value = newToken
|
||||
}
|
||||
|
||||
// 设置 refresh token
|
||||
function setRefreshToken(newRefreshToken) {
|
||||
refreshToken.value = newRefreshToken
|
||||
}
|
||||
|
||||
// 设置用户信息
|
||||
function setUserInfo(info) {
|
||||
userInfo.value = info
|
||||
}
|
||||
|
||||
// 设置菜单
|
||||
function setMenu(newMenu) {
|
||||
const staticMenus = userRoutes || []
|
||||
|
||||
// 合并静态菜单和后端菜单
|
||||
// 如果后端菜单为空,只使用静态菜单
|
||||
// 如果后端菜单不为空,合并两个菜单,后端菜单优先
|
||||
let mergedMenus = [...staticMenus]
|
||||
|
||||
if (newMenu && newMenu.length > 0) {
|
||||
// 创建菜单映射,用于去重(以路径为唯一标识)
|
||||
const menuMap = new Map()
|
||||
|
||||
// 先添加静态菜单
|
||||
staticMenus.forEach(menu => {
|
||||
if (menu.path) {
|
||||
menuMap.set(menu.path, menu)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加后端菜单,如果路径重复则覆盖
|
||||
newMenu.forEach(menu => {
|
||||
if (menu.path) {
|
||||
menuMap.set(menu.path, menu)
|
||||
}
|
||||
})
|
||||
|
||||
// 转换为数组
|
||||
mergedMenus = Array.from(menuMap.values())
|
||||
}
|
||||
menu.value = mergedMenus
|
||||
}
|
||||
|
||||
// 获取菜单
|
||||
function getMenu() {
|
||||
return menu.value
|
||||
}
|
||||
|
||||
// 清除菜单
|
||||
function clearMenu() {
|
||||
menu.value = []
|
||||
}
|
||||
|
||||
// 设置权限
|
||||
function setPermissions(data){
|
||||
permissions.value = data
|
||||
}
|
||||
|
||||
// 登出
|
||||
function logout() {
|
||||
token.value = ''
|
||||
refreshToken.value = ''
|
||||
userInfo.value = null
|
||||
menu.value = []
|
||||
|
||||
// 重置路由
|
||||
resetRouter()
|
||||
}
|
||||
|
||||
// 检查是否已登录
|
||||
function isLoggedIn() {
|
||||
return !!token.value
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
userInfo,
|
||||
menu,
|
||||
setToken,
|
||||
setRefreshToken,
|
||||
setUserInfo,
|
||||
setMenu,
|
||||
getMenu,
|
||||
clearMenu,
|
||||
setPermissions,
|
||||
logout,
|
||||
isLoggedIn,
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'user-store',
|
||||
storage: customStorage,
|
||||
pick: ['token', 'refreshToken', 'userInfo', 'menu']
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* @Descripttion: Pinia 持久化存储适配器 - 使用 tool.data 封装的 localStorage
|
||||
* @version: 1.0
|
||||
*/
|
||||
|
||||
import tool from '@/utils/tool'
|
||||
|
||||
/**
|
||||
* 自定义存储适配器
|
||||
* 使用 tool.data 的 set/get/remove 方法,支持加密和过期时间
|
||||
*/
|
||||
export const customStorage = {
|
||||
/**
|
||||
* 获取数据
|
||||
* @param {string} key - 存储键
|
||||
* @returns {any} - 存储的数据
|
||||
*/
|
||||
getItem: (key) => {
|
||||
return tool.data.get(key)
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置数据
|
||||
* @param {string} key - 存储键
|
||||
* @param {any} value - 要存储的值
|
||||
*/
|
||||
setItem: (key, value) => {
|
||||
tool.data.set(key, value)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除数据
|
||||
* @param {string} key - 存储键
|
||||
*/
|
||||
removeItem: (key) => {
|
||||
tool.data.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认持久化配置
|
||||
*/
|
||||
export const defaultPersistConfig = {
|
||||
storage: customStorage,
|
||||
// 可以在这里添加其他全局配置,如过期时间等
|
||||
// serializer: {
|
||||
// serialize: (state) => JSON.stringify(state),
|
||||
// deserialize: (value) => JSON.parse(value)
|
||||
// }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import axios from "axios";
|
||||
import config from "@/config";
|
||||
import { useUserStore } from "@/stores/modules/user";
|
||||
import { message } from "ant-design-vue";
|
||||
import router from "@/router";
|
||||
|
||||
const request = axios.create({
|
||||
timeout: 30000,
|
||||
baseURL: config.API_URL,
|
||||
});
|
||||
|
||||
// 是否正在刷新 token
|
||||
let isRefreshing = false;
|
||||
// 存储待重试的请求
|
||||
let requests = [];
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const userStore = useUserStore();
|
||||
const token = userStore.token;
|
||||
|
||||
// 如果有 token,添加到请求头
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
// 根据后端返回的数据结构进行处理
|
||||
// 后端返回格式为 { code, message, data }
|
||||
const { code, data, message: msg } = response.data;
|
||||
|
||||
// 请求成功
|
||||
if (code === 200 || code === 1) {
|
||||
return { code, data, message: msg };
|
||||
}
|
||||
|
||||
// 其他错误码处理
|
||||
message.error(msg || "请求失败");
|
||||
return Promise.reject(new Error(msg || "请求失败"));
|
||||
},
|
||||
async (error) => {
|
||||
const userStore = useUserStore();
|
||||
const { response } = error;
|
||||
|
||||
// 无响应(网络错误、超时等)
|
||||
if (!response) {
|
||||
message.error("网络错误,请检查网络连接");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const { status, data } = response;
|
||||
|
||||
// 401 未授权 - token 过期或无效
|
||||
if (status === 401) {
|
||||
// 如果正在刷新 token,将请求加入队列
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
requests.push((token) => {
|
||||
// 重新设置请求头
|
||||
error.config.headers["Authorization"] =
|
||||
`Bearer ${token}`;
|
||||
resolve(http(error.config));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 标记正在刷新
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
// 尝试刷新 token
|
||||
const newToken = await refreshToken();
|
||||
|
||||
// 刷新成功,更新 token
|
||||
userStore.setToken(newToken);
|
||||
|
||||
// 执行队列中的所有请求
|
||||
requests.forEach((callback) => callback(newToken));
|
||||
requests = [];
|
||||
|
||||
// 重新执行当前请求
|
||||
error.config.headers["Authorization"] = `Bearer ${newToken}`;
|
||||
return request(error.config);
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,清空队列并跳转登录页
|
||||
requests = [];
|
||||
userStore.logout();
|
||||
router.push("/login");
|
||||
message.error("登录已过期,请重新登录");
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 403 禁止访问
|
||||
if (status === 403) {
|
||||
message.error("没有权限访问该资源");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 404 资源不存在
|
||||
if (status === 404) {
|
||||
message.error("请求的资源不存在");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 500 服务器错误
|
||||
if (status >= 500) {
|
||||
message.error("服务器错误,请稍后重试");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
const errorMessage = data?.message || error.message || "请求失败";
|
||||
message.error(errorMessage);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// 刷新 token 的方法
|
||||
async function refreshToken() {
|
||||
// 刷新接口需要携带当前token在请求头中
|
||||
const response = await request.post('auth/refresh');
|
||||
|
||||
// 返回格式为 { code, data: { token } }
|
||||
return response.data.token;
|
||||
}
|
||||
|
||||
export default request;
|
||||
@@ -0,0 +1,499 @@
|
||||
/*
|
||||
* @Descripttion: 工具集
|
||||
* @version: 2.0
|
||||
* @LastEditors: sakuya
|
||||
* @LastEditTime: 2026年1月15日
|
||||
*/
|
||||
|
||||
import CryptoJS from "crypto-js";
|
||||
import sysConfig from "@/config";
|
||||
|
||||
const tool = {};
|
||||
|
||||
/**
|
||||
* 检查是否为有效的值(非null、非undefined、非空字符串、非空数组、非空对象)
|
||||
* @param {*} value - 要检查的值
|
||||
* @returns {boolean}
|
||||
*/
|
||||
tool.isValid = function (value) {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "object" && Object.keys(value).length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param {Function} func - 要执行的函数
|
||||
* @param {number} wait - 等待时间(毫秒)
|
||||
* @param {boolean} immediate - 是否立即执行
|
||||
* @returns {Function}
|
||||
*/
|
||||
tool.debounce = function (func, wait = 300, immediate = false) {
|
||||
let timeout;
|
||||
return function (...args) {
|
||||
const context = this;
|
||||
clearTimeout(timeout);
|
||||
if (immediate && !timeout) {
|
||||
func.apply(context, args);
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
if (!immediate) {
|
||||
func.apply(context, args);
|
||||
}
|
||||
}, wait);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param {Function} func - 要执行的函数
|
||||
* @param {number} wait - 等待时间(毫秒)
|
||||
* @param {Object} options - 配置选项 { leading: boolean, trailing: boolean }
|
||||
* @returns {Function}
|
||||
*/
|
||||
tool.throttle = function (func, wait = 300, options = {}) {
|
||||
let timeout;
|
||||
let previous = 0;
|
||||
const { leading = true, trailing = true } = options;
|
||||
|
||||
return function (...args) {
|
||||
const context = this;
|
||||
const now = Date.now();
|
||||
|
||||
if (!previous && !leading) {
|
||||
previous = now;
|
||||
}
|
||||
|
||||
const remaining = wait - (now - previous);
|
||||
|
||||
if (remaining <= 0 || remaining > wait) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
previous = now;
|
||||
func.apply(context, args);
|
||||
} else if (!timeout && trailing) {
|
||||
timeout = setTimeout(() => {
|
||||
previous = leading ? Date.now() : 0;
|
||||
timeout = null;
|
||||
func.apply(context, args);
|
||||
}, remaining);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 深拷贝对象(支持循环引用)
|
||||
* @param {*} obj - 要拷贝的对象
|
||||
* @param {WeakMap} hash - 用于检测循环引用
|
||||
* @returns {*}
|
||||
*/
|
||||
tool.deepClone = function (obj, hash = new WeakMap()) {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (hash.has(obj)) {
|
||||
return hash.get(obj);
|
||||
}
|
||||
|
||||
const clone = Array.isArray(obj) ? [] : {};
|
||||
hash.set(obj, clone);
|
||||
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
clone[key] = tool.deepClone(obj[key], hash);
|
||||
}
|
||||
}
|
||||
|
||||
return clone;
|
||||
};
|
||||
|
||||
/* localStorage */
|
||||
tool.data = {
|
||||
set(key, data, datetime = 0) {
|
||||
//加密
|
||||
if (sysConfig.LS_ENCRYPTION == "AES") {
|
||||
data = tool.crypto.AES.encrypt(
|
||||
JSON.stringify(data),
|
||||
sysConfig.LS_ENCRYPTION_key,
|
||||
);
|
||||
}
|
||||
let cacheValue = {
|
||||
content: data,
|
||||
datetime:
|
||||
parseInt(datetime) === 0
|
||||
? 0
|
||||
: new Date().getTime() + parseInt(datetime) * 1000,
|
||||
};
|
||||
return localStorage.setItem(key, JSON.stringify(cacheValue));
|
||||
},
|
||||
get(key) {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key));
|
||||
if (value) {
|
||||
let nowTime = new Date().getTime();
|
||||
if (nowTime > value.datetime && value.datetime != 0) {
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
//解密
|
||||
if (sysConfig.LS_ENCRYPTION == "AES") {
|
||||
value.content = JSON.parse(
|
||||
tool.crypto.AES.decrypt(
|
||||
value.content,
|
||||
sysConfig.LS_ENCRYPTION_key,
|
||||
),
|
||||
);
|
||||
}
|
||||
return value.content;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
remove(key) {
|
||||
return localStorage.removeItem(key);
|
||||
},
|
||||
clear() {
|
||||
return localStorage.clear();
|
||||
},
|
||||
};
|
||||
|
||||
/*sessionStorage*/
|
||||
tool.session = {
|
||||
set(table, settings) {
|
||||
const _set = JSON.stringify(settings);
|
||||
return sessionStorage.setItem(table, _set);
|
||||
},
|
||||
get(table) {
|
||||
const data = sessionStorage.getItem(table);
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
remove(table) {
|
||||
return sessionStorage.removeItem(table);
|
||||
},
|
||||
clear() {
|
||||
return sessionStorage.clear();
|
||||
},
|
||||
};
|
||||
|
||||
/*cookie*/
|
||||
tool.cookie = {
|
||||
/**
|
||||
* 设置cookie
|
||||
* @param {string} name - cookie名称
|
||||
* @param {string} value - cookie值
|
||||
* @param {Object} config - 配置选项
|
||||
*/
|
||||
set(name, value, config = {}) {
|
||||
const cfg = {
|
||||
expires: null,
|
||||
path: null,
|
||||
domain: null,
|
||||
secure: false,
|
||||
httpOnly: false,
|
||||
sameSite: "Lax",
|
||||
...config,
|
||||
};
|
||||
let cookieStr = `${name}=${encodeURIComponent(value)}`;
|
||||
if (cfg.expires) {
|
||||
const exp = new Date();
|
||||
exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000);
|
||||
cookieStr += `;expires=${exp.toUTCString()}`;
|
||||
}
|
||||
if (cfg.path) {
|
||||
cookieStr += `;path=${cfg.path}`;
|
||||
}
|
||||
if (cfg.domain) {
|
||||
cookieStr += `;domain=${cfg.domain}`;
|
||||
}
|
||||
if (cfg.secure) {
|
||||
cookieStr += `;secure`;
|
||||
}
|
||||
if (cfg.sameSite) {
|
||||
cookieStr += `;SameSite=${cfg.sameSite}`;
|
||||
}
|
||||
document.cookie = cookieStr;
|
||||
},
|
||||
/**
|
||||
* 获取cookie
|
||||
* @param {string} name - cookie名称
|
||||
* @returns {string|null}
|
||||
*/
|
||||
get(name) {
|
||||
const arr = document.cookie.match(
|
||||
new RegExp("(^| )" + name + "=([^;]*)(;|$)"),
|
||||
);
|
||||
if (arr != null) {
|
||||
return decodeURIComponent(arr[2]);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* 删除cookie
|
||||
* @param {string} name - cookie名称
|
||||
*/
|
||||
remove(name) {
|
||||
const exp = new Date();
|
||||
exp.setTime(exp.getTime() - 1);
|
||||
document.cookie = `${name}=;expires=${exp.toUTCString()}`;
|
||||
},
|
||||
};
|
||||
|
||||
/* Fullscreen */
|
||||
/**
|
||||
* 切换全屏状态
|
||||
* @param {HTMLElement} element - 要全屏的元素
|
||||
*/
|
||||
tool.screen = function (element) {
|
||||
const isFull = !!(
|
||||
document.webkitIsFullScreen ||
|
||||
document.mozFullScreen ||
|
||||
document.msFullscreenElement ||
|
||||
document.fullscreenElement
|
||||
);
|
||||
if (isFull) {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.msExitFullscreen) {
|
||||
document.msExitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
document.mozCancelFullScreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (element.requestFullscreen) {
|
||||
element.requestFullscreen();
|
||||
} else if (element.msRequestFullscreen) {
|
||||
element.msRequestFullscreen();
|
||||
} else if (element.mozRequestFullScreen) {
|
||||
element.mozRequestFullScreen();
|
||||
} else if (element.webkitRequestFullscreen) {
|
||||
element.webkitRequestFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* 复制对象(浅拷贝) */
|
||||
/**
|
||||
* 浅拷贝对象
|
||||
* @param {*} obj - 要拷贝的对象
|
||||
* @returns {*} - 拷贝后的对象
|
||||
*/
|
||||
tool.objCopy = function (obj) {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj;
|
||||
}
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
};
|
||||
|
||||
/* 日期格式化 */
|
||||
/**
|
||||
* 格式化日期
|
||||
* @param {Date|string|number} date - 日期对象、时间戳或日期字符串
|
||||
* @param {string} fmt - 格式化字符串,默认 "yyyy-MM-dd hh:mm:ss"
|
||||
* @returns {string} - 格式化后的日期字符串
|
||||
*/
|
||||
tool.dateFormat = function (date, fmt = "yyyy-MM-dd hh:mm:ss") {
|
||||
if (!date) return "";
|
||||
const dateObj = new Date(date);
|
||||
if (isNaN(dateObj.getTime())) return "";
|
||||
|
||||
const o = {
|
||||
"M+": dateObj.getMonth() + 1, // 月份
|
||||
"d+": dateObj.getDate(), // 日
|
||||
"h+": dateObj.getHours(), // 小时
|
||||
"m+": dateObj.getMinutes(), // 分
|
||||
"s+": dateObj.getSeconds(), // 秒
|
||||
"q+": Math.floor((dateObj.getMonth() + 3) / 3), // 季度
|
||||
S: dateObj.getMilliseconds(), // 毫秒
|
||||
};
|
||||
if (/(y+)/.test(fmt)) {
|
||||
fmt = fmt.replace(
|
||||
RegExp.$1,
|
||||
(dateObj.getFullYear() + "").substr(4 - RegExp.$1.length),
|
||||
);
|
||||
}
|
||||
for (const k in o) {
|
||||
if (new RegExp("(" + k + ")").test(fmt)) {
|
||||
fmt = fmt.replace(
|
||||
RegExp.$1,
|
||||
RegExp.$1.length == 1
|
||||
? o[k]
|
||||
: ("00" + o[k]).substr(("" + o[k]).length),
|
||||
);
|
||||
}
|
||||
}
|
||||
return fmt;
|
||||
};
|
||||
|
||||
/* 千分符 */
|
||||
/**
|
||||
* 格式化数字,添加千分位分隔符
|
||||
* @param {number|string} num - 要格式化的数字
|
||||
* @param {number} decimals - 保留小数位数,默认为0
|
||||
* @returns {string} - 格式化后的字符串
|
||||
*/
|
||||
tool.groupSeparator = function (num, decimals = 0) {
|
||||
if (num === null || num === undefined || num === "") return "";
|
||||
const numStr = Number(num).toFixed(decimals);
|
||||
const parts = numStr.split(".");
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
return parts.join(".");
|
||||
};
|
||||
|
||||
/* 常用加解密 */
|
||||
tool.crypto = {
|
||||
//MD5加密
|
||||
MD5(data) {
|
||||
return CryptoJS.MD5(data).toString();
|
||||
},
|
||||
//BASE64加解密
|
||||
BASE64: {
|
||||
encrypt(data) {
|
||||
return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data));
|
||||
},
|
||||
decrypt(cipher) {
|
||||
return CryptoJS.enc.Base64.parse(cipher).toString(
|
||||
CryptoJS.enc.Utf8,
|
||||
);
|
||||
},
|
||||
},
|
||||
//AES加解密
|
||||
AES: {
|
||||
encrypt(data, secretKey, config = {}) {
|
||||
if (secretKey.length % 8 != 0) {
|
||||
console.warn(
|
||||
"[SCUI error]: 秘钥长度需为8的倍数,否则解密将会失败。",
|
||||
);
|
||||
}
|
||||
const result = CryptoJS.AES.encrypt(
|
||||
data,
|
||||
CryptoJS.enc.Utf8.parse(secretKey),
|
||||
{
|
||||
iv: CryptoJS.enc.Utf8.parse(config.iv || ""),
|
||||
mode: CryptoJS.mode[config.mode || "ECB"],
|
||||
padding: CryptoJS.pad[config.padding || "Pkcs7"],
|
||||
},
|
||||
);
|
||||
return result.toString();
|
||||
},
|
||||
decrypt(cipher, secretKey, config = {}) {
|
||||
const result = CryptoJS.AES.decrypt(
|
||||
cipher,
|
||||
CryptoJS.enc.Utf8.parse(secretKey),
|
||||
{
|
||||
iv: CryptoJS.enc.Utf8.parse(config.iv || ""),
|
||||
mode: CryptoJS.mode[config.mode || "ECB"],
|
||||
padding: CryptoJS.pad[config.padding || "Pkcs7"],
|
||||
},
|
||||
);
|
||||
return CryptoJS.enc.Utf8.stringify(result);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/* 树形数据转扁平数组 */
|
||||
/**
|
||||
* 将树形结构转换为扁平数组
|
||||
* @param {Array} tree - 树形数组
|
||||
* @param {Object} config - 配置项 { children: "children" }
|
||||
* @returns {Array} - 扁平化后的数组
|
||||
*/
|
||||
tool.treeToList = function (tree, config = { children: "children" }) {
|
||||
const result = [];
|
||||
tree.forEach((item) => {
|
||||
const tmp = { ...item };
|
||||
const childrenKey = config.children || "children";
|
||||
|
||||
if (tmp[childrenKey] && tmp[childrenKey].length > 0) {
|
||||
result.push({ ...item });
|
||||
const childrenRoutes = tool.treeToList(tmp[childrenKey], config);
|
||||
result.push(...childrenRoutes);
|
||||
} else {
|
||||
result.push(tmp);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
/* 获取父节点数据(保留原有函数名) */
|
||||
/**
|
||||
* 根据ID获取父节点数据
|
||||
* @param {Array} list - 数据列表
|
||||
* @param {number|string} targetId - 目标ID
|
||||
* @param {Object} config - 配置项 { pid: "parent_id", idField: "id", field: [] }
|
||||
* @returns {*} - 父节点数据或指定字段
|
||||
*/
|
||||
tool.get_parents = function (
|
||||
list,
|
||||
targetId = 0,
|
||||
config = { pid: "parent_id", idField: "id", field: [] },
|
||||
) {
|
||||
let res = null;
|
||||
list.forEach((item) => {
|
||||
if (item[config.idField || "id"] === targetId) {
|
||||
if (config.field && config.field.length > 1) {
|
||||
res = {};
|
||||
config.field.forEach((field) => {
|
||||
res[field] = item[field];
|
||||
});
|
||||
} else if (config.field && config.field.length === 1) {
|
||||
res = item[config.field[0]];
|
||||
} else {
|
||||
res = item;
|
||||
}
|
||||
}
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
/* 获取数据字段 */
|
||||
/**
|
||||
* 从数据对象中提取指定字段
|
||||
* @param {Object} data - 数据对象
|
||||
* @param {Array} fields - 字段名数组
|
||||
* @returns {*} - 提取的字段数据
|
||||
*/
|
||||
tool.getDataField = function (data, fields = []) {
|
||||
if (!data || typeof data !== "object") {
|
||||
return data;
|
||||
}
|
||||
if (fields.length === 0) {
|
||||
return data;
|
||||
}
|
||||
if (fields.length === 1) {
|
||||
return data[fields[0]];
|
||||
} else {
|
||||
const result = {};
|
||||
fields.forEach((field) => {
|
||||
result[field] = data[field];
|
||||
});
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
// 兼容旧函数名
|
||||
tool.tree_to_list = tool.treeToList;
|
||||
tool.get_data_field = tool.getDataField;
|
||||
|
||||
export default tool;
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* WebSocket Client Helper
|
||||
*
|
||||
* Provides a simple interface for WebSocket connections
|
||||
*/
|
||||
|
||||
class WebSocketClient {
|
||||
constructor(url, options = {}) {
|
||||
this.url = url
|
||||
this.ws = null
|
||||
this.reconnectAttempts = 0
|
||||
this.maxReconnectAttempts = options.maxReconnectAttempts || 5
|
||||
this.reconnectInterval = options.reconnectInterval || 3000
|
||||
this.reconnectDelay = options.reconnectDelay || 1000
|
||||
this.heartbeatInterval = options.heartbeatInterval || 30000
|
||||
this.heartbeatTimer = null
|
||||
this.isManualClose = false
|
||||
this.isConnecting = false
|
||||
|
||||
// Event handlers
|
||||
this.onOpen = options.onOpen || null
|
||||
this.onMessage = options.onMessage || null
|
||||
this.onError = options.onError || null
|
||||
this.onClose = options.onClose || null
|
||||
|
||||
// Message handlers
|
||||
this.messageHandlers = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
connect() {
|
||||
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isConnecting = true
|
||||
this.isManualClose = false
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
|
||||
this.ws.onopen = (event) => {
|
||||
console.log('WebSocket connected', event)
|
||||
this.isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
|
||||
// Start heartbeat
|
||||
this.startHeartbeat()
|
||||
|
||||
// Call onOpen handler
|
||||
if (this.onOpen) {
|
||||
this.onOpen(event)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data)
|
||||
console.log('WebSocket message received', message)
|
||||
|
||||
// Handle different message types
|
||||
this.handleMessage(message)
|
||||
|
||||
// Call onMessage handler
|
||||
if (this.onMessage) {
|
||||
this.onMessage(message, event)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error', error)
|
||||
this.isConnecting = false
|
||||
|
||||
// Stop heartbeat
|
||||
this.stopHeartbeat()
|
||||
|
||||
// Call onError handler
|
||||
if (this.onError) {
|
||||
this.onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log('WebSocket closed', event)
|
||||
this.isConnecting = false
|
||||
|
||||
// Stop heartbeat
|
||||
this.stopHeartbeat()
|
||||
|
||||
// Call onClose handler
|
||||
if (this.onClose) {
|
||||
this.onClose(event)
|
||||
}
|
||||
|
||||
// Attempt to reconnect if not manually closed
|
||||
if (!this.isManualClose && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnect()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket connection', error)
|
||||
this.isConnecting = false
|
||||
|
||||
// Call onError handler
|
||||
if (this.onError) {
|
||||
this.onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to WebSocket server
|
||||
*/
|
||||
reconnect() {
|
||||
if (this.isConnecting || this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.log('Max reconnection attempts reached')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
|
||||
|
||||
console.log(`Reconnecting attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`)
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from WebSocket server
|
||||
*/
|
||||
disconnect() {
|
||||
this.isManualClose = true
|
||||
this.stopHeartbeat()
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to server
|
||||
*/
|
||||
send(type, data = {}) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const message = JSON.stringify({
|
||||
type,
|
||||
data
|
||||
})
|
||||
this.ws.send(message)
|
||||
console.log('WebSocket message sent', { type, data })
|
||||
} else {
|
||||
console.warn('WebSocket is not connected')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages
|
||||
*/
|
||||
handleMessage(message) {
|
||||
const { type, data } = message
|
||||
|
||||
// Get handler for this message type
|
||||
const handler = this.messageHandlers.get(type)
|
||||
if (handler) {
|
||||
handler(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register message handler
|
||||
*/
|
||||
on(messageType, handler) {
|
||||
this.messageHandlers.set(messageType, handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister message handler
|
||||
*/
|
||||
off(messageType) {
|
||||
this.messageHandlers.delete(messageType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat
|
||||
*/
|
||||
startHeartbeat() {
|
||||
this.stopHeartbeat()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.send('heartbeat', { timestamp: Date.now() })
|
||||
}, this.heartbeatInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat
|
||||
*/
|
||||
stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection state
|
||||
*/
|
||||
get readyState() {
|
||||
if (!this.ws) return WebSocket.CLOSED
|
||||
return this.ws.readyState
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
get isConnected() {
|
||||
return this.ws && this.ws.readyState === WebSocket.OPEN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create WebSocket connection
|
||||
*/
|
||||
export function createWebSocket(userId, token, options = {}) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const url = `${protocol}//${host}/ws?user_id=${userId}&token=${token}`
|
||||
|
||||
return new WebSocketClient(url, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket singleton instance
|
||||
*/
|
||||
let wsClient = null
|
||||
|
||||
export function getWebSocket(userId, token, options = {}) {
|
||||
if (!wsClient || !wsClient.isConnected) {
|
||||
wsClient = createWebSocket(userId, token, options)
|
||||
}
|
||||
return wsClient
|
||||
}
|
||||
|
||||
export function closeWebSocket() {
|
||||
if (wsClient) {
|
||||
wsClient.disconnect()
|
||||
wsClient = null
|
||||
}
|
||||
}
|
||||
|
||||
export default WebSocketClient
|
||||
@@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,426 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="基于 Laravel + Laravel-S + Swoole 的高性能后端 API 系统">
|
||||
<title>Laravel-S - 高性能后端 API 系统</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 20px 0;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
padding: 200px 0 100px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 18px;
|
||||
margin-bottom: 40px;
|
||||
opacity: 0.9;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 15px 40px;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
border-radius: 30px;
|
||||
font-weight: bold;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
.features {
|
||||
padding: 100px 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 36px;
|
||||
margin-bottom: 60px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 40px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 15px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Tech Stack Section */
|
||||
.tech-stack {
|
||||
padding: 100px 0;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
}
|
||||
|
||||
.tech-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.tech-item {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tech-item h4 {
|
||||
color: #667eea;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
/* Architecture Section */
|
||||
.architecture {
|
||||
padding: 100px 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.architecture-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 50px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.architecture-text h3 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 20px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.architecture-text p {
|
||||
margin-bottom: 15px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.architecture-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.architecture-list li {
|
||||
padding: 10px 0;
|
||||
padding-left: 30px;
|
||||
position: relative;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.architecture-list li:before {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #667eea;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
padding: 40px 0;
|
||||
background: #333;
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer p {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.hero h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.architecture-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeInUp {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="container">
|
||||
<nav>
|
||||
<a href="#" class="logo">🚀 Laravel-S</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="#features">特性</a></li>
|
||||
<li><a href="#tech">技术栈</a></li>
|
||||
<li><a href="#architecture">架构</a></li>
|
||||
<li><a href="/admin">后台管理</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="container animate-fadeInUp">
|
||||
<h1>高性能后端 API 系统</h1>
|
||||
<p>基于 Laravel + Laravel-S + Swoole 构建的现代化后端 API 系统,提供卓越的性能和开发效率</p>
|
||||
<div>
|
||||
<a href="/admin" class="btn btn-primary">进入后台管理</a>
|
||||
<a href="https://github.com/hhxsv5/laravel-s" target="_blank" class="btn btn-secondary">查看文档</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features" id="features">
|
||||
<div class="container">
|
||||
<h2 class="section-title">核心特性</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3>高性能</h3>
|
||||
<p>基于 Swoole 协程框架,提供卓越的并发处理能力,轻松应对高并发场景</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🧩</div>
|
||||
<h3>模块化</h3>
|
||||
<p>采用 Laravel Modules 实现模块化架构,业务模块独立管理,易于扩展和维护</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔒</div>
|
||||
<h3>安全可靠</h3>
|
||||
<p>JWT 认证、RBAC 权限控制、数据验证等多重安全防护机制</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3>完整后台</h3>
|
||||
<p>基于 Vue3 + Ant Design Vue 构建的现代化后台管理系统</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔄</div>
|
||||
<h3>热重载</h3>
|
||||
<p>开发环境支持文件监控热重载,提升开发体验</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📝</div>
|
||||
<h3>RESTful API</h3>
|
||||
<p>遵循 RESTful 规范的 API 设计,统一的响应格式</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tech-stack" id="tech">
|
||||
<div class="container">
|
||||
<h2 class="section-title">技术栈</h2>
|
||||
<div class="tech-grid">
|
||||
<div class="tech-item">
|
||||
<div style="font-size: 36px;">🐘</div>
|
||||
<h4>PHP</h4>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<div style="font-size: 36px;">🔷</div>
|
||||
<h4>Laravel</h4>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<div style="font-size: 36px;">🚀</div>
|
||||
<h4>Swoole</h4>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<div style="font-size: 36px;">⚡</div>
|
||||
<h4>Laravel-S</h4>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<div style="font-size: 36px;">🔑</div>
|
||||
<h4>JWT-Auth</h4>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<div style="font-size: 36px;">📦</div>
|
||||
<h4>Laravel Modules</h4>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<div style="font-size: 36px;">💚</div>
|
||||
<h4>MySQL</h4>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<div style="font-size: 36px;">🔴</div>
|
||||
<h4>Redis</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="architecture" id="architecture">
|
||||
<div class="container">
|
||||
<h2 class="section-title">系统架构</h2>
|
||||
<div class="architecture-content">
|
||||
<div class="architecture-text">
|
||||
<h3>模块化设计</h3>
|
||||
<p>项目采用清晰的分层架构,将业务逻辑合理划分:</p>
|
||||
<ul class="architecture-list">
|
||||
<li>基础模块(Auth、System):不使用 Laravel Modules 扩展</li>
|
||||
<li>业务模块:使用 Laravel Modules 独立管理</li>
|
||||
<li>Controller 层:处理 HTTP 请求</li>
|
||||
<li>Service 层:业务逻辑处理</li>
|
||||
<li>Model 层:数据模型定义</li>
|
||||
<li>统一的 API 响应格式</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="architecture-diagram">
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 10px;">
|
||||
<h4 style="text-align: center; margin-bottom: 20px; color: #667eea;">快速开始</h4>
|
||||
<pre style="background: #333; color: #fff; padding: 20px; border-radius: 5px; overflow-x: auto;"><code># 安装依赖
|
||||
composer install
|
||||
|
||||
# 配置环境
|
||||
cp .env.example .env
|
||||
|
||||
# 执行迁移
|
||||
php artisan migrate
|
||||
|
||||
# 启动 Laravel-S
|
||||
php bin/laravels start
|
||||
|
||||
# 访问后台
|
||||
# http://localhost:8000/admin</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2024 Laravel-S. Built with ❤️ using Laravel & Swoole</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user