Compare commits

..

No commits in common. "c733dc5ffe2e2d507ee416d9cb900bf75c2aa13b" and "3dbcf804029e4d22e8d54ba9b830da11c39e1b07" have entirely different histories.

17 changed files with 639 additions and 108 deletions

2
.env
View File

@ -11,7 +11,7 @@ VITE_ROUTE_MODE = web
VITE_ROUTE_LOAD_MODE = dynamic VITE_ROUTE_LOAD_MODE = dynamic
# 设置登陆后跳转地址 # 设置登陆后跳转地址
VITE_HOME_PATH = /dashboard VITE_HOME_PATH = /dashboard/monitor
# 本地存储前缀 # 本地存储前缀
VITE_STORAGE_PREFIX = VITE_STORAGE_PREFIX =

View File

@ -52,7 +52,6 @@
type="primary" type="primary"
size="medium" size="medium"
:loading="confirmLoading" :loading="confirmLoading"
:disabled="confirmDisabled"
@click="handleConfirm" @click="handleConfirm"
> >
{{ confirmText }} {{ confirmText }}
@ -84,8 +83,6 @@ interface IDialogProps {
fullscreen?: boolean fullscreen?: boolean
/** 确认按钮加载状态 */ /** 确认按钮加载状态 */
loading?: boolean loading?: boolean
/** 确认按钮禁用状态 */
confirmDisabled?: boolean
/** 隐藏底部按钮 */ /** 隐藏底部按钮 */
footerHidden?: boolean footerHidden?: boolean
/** 显示确认按钮 */ /** 显示确认按钮 */
@ -111,7 +108,6 @@ const props = withDefaults(defineProps<IDialogProps>(), {
cancelText: '取消', cancelText: '取消',
fullscreen: false, fullscreen: false,
loading: false, loading: false,
confirmDisabled: false,
footerHidden: false, footerHidden: false,
showConfirm: true, showConfirm: true,
showCancel: true, showCancel: true,

View File

@ -96,7 +96,7 @@ export function setupRouterGuard(router: Router) {
// 如果用户已登录且访问login页面重定向到首页 // 如果用户已登录且访问login页面重定向到首页
if (to.name === 'login' && isLogin) { if (to.name === 'login' && isLogin) {
next({ path: import.meta.env.VITE_HOME_PATH || '/dashboard' }) next({ path: '/' })
return return
} }

View File

@ -1,4 +1,16 @@
export const staticRoutes: AppRoute.RowRoute[] = [ export const staticRoutes: AppRoute.RowRoute[] = [
{
name: 'monitor',
path: '/dashboard/monitor',
title: '仪表盘',
requiresAuth: true,
icon: 'icon-park-outline:dashboard-one',
menuType: '2',
componentPath: '/dashboard/monitor/index',
id: 3,
pid: null,
pinTab: true,
},
// { // {
// name: 'personal-center', // name: 'personal-center',
// path: '/personal-center', // path: '/personal-center',

View File

@ -124,7 +124,7 @@ export const useAuthStore = defineStore('auth-store', {
coiMsgSuccess('登录成功!') coiMsgSuccess('登录成功!')
router.push({ router.push({
path: query.redirect || import.meta.env.VITE_HOME_PATH || '/dashboard', path: query.redirect || '/',
}) })
}, },

View File

@ -140,7 +140,7 @@ export function createRoutes(routeData: (AppRoute.BackendRoute | AppRoute.RowRou
const appRootRoute: RouteRecordRaw = { const appRootRoute: RouteRecordRaw = {
path: '/appRoot', path: '/appRoot',
name: 'appRoot', name: 'appRoot',
redirect: import.meta.env.VITE_HOME_PATH || '/dashboard', redirect: import.meta.env.VITE_HOME_PATH || '/dashboard/monitor',
component: Layout, component: Layout,
meta: { meta: {
title: '', title: '',

View File

@ -0,0 +1,423 @@
<template>
<div class="operation-pie-chart">
<!-- 图表容器 -->
<div class="chart-container">
<svg
class="pie-svg"
:width="chartSize"
:height="chartSize"
:viewBox="`0 0 ${chartSize} ${chartSize}`"
>
<!-- 饼图片段 -->
<g class="pie-segments" :transform="`translate(${center}, ${center})`">
<path
v-for="(segment, index) in pieSegments"
:key="index"
:d="segment.path"
:fill="segment.color"
class="pie-segment" :class="[{ active: hoveredIndex === index }]"
@mouseenter="hoverSegment(index, $event)"
@mouseleave="unhoverSegment"
/>
</g>
<!-- 中心文字 -->
<g class="center-text" :transform="`translate(${center}, ${center})`">
<text
x="0"
y="-10"
text-anchor="middle"
class="center-title"
>
总操作数
</text>
<text
x="0"
y="15"
text-anchor="middle"
class="center-value"
>
{{ totalOperations.toLocaleString() }}
</text>
</g>
</svg>
<!-- 工具提示 -->
<div
v-if="tooltip.visible"
class="chart-tooltip"
:style="tooltipStyle"
>
<div class="tooltip-content">
<div class="tooltip-header">
<span class="tooltip-dot" :style="{ backgroundColor: tooltip.color }" />
<span class="tooltip-type">{{ tooltip.type }}</span>
</div>
<div class="tooltip-stats">
<div>数量: {{ tooltip.count?.toLocaleString() }}</div>
<div>占比: {{ tooltip.percentage }}%</div>
</div>
</div>
</div>
</div>
<!-- 图例 -->
<div class="chart-legend">
<div
v-for="(item, index) in chartData"
:key="index"
class="legend-item"
:class="{ active: hoveredIndex === index }"
@mouseenter="hoverSegment(index)"
@mouseleave="unhoverSegment"
>
<div class="legend-color" :style="{ backgroundColor: item.color }" />
<div class="legend-content">
<div class="legend-type">
{{ item.type }}
</div>
<div class="legend-stats">
<span class="legend-count">{{ item.count.toLocaleString() }}</span>
<span class="legend-percentage">{{ item.percentage }}%</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
// Props
interface OperationData {
type: string
count: number
percentage: number
color: string
}
const props = defineProps<{
data: OperationData[]
}>()
//
const hoveredIndex = ref<number | null>(null)
const chartSize = 240
const center = chartSize / 2
const radius = 80
const innerRadius = 30
//
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
type: '',
count: 0,
percentage: 0,
color: '',
})
//
const chartData = computed(() => props.data || [])
const totalOperations = computed(() => {
return chartData.value.reduce((sum, item) => sum + item.count, 0)
})
//
const pieSegments = computed(() => {
let currentAngle = -Math.PI / 2 //
return chartData.value.map((item, index) => {
const angleSize = (item.percentage / 100) * 2 * Math.PI
const startAngle = currentAngle
const endAngle = currentAngle + angleSize
//
const path = createArcPath(0, 0, innerRadius, radius, startAngle, endAngle)
currentAngle = endAngle
return {
path,
color: item.color,
data: item,
index,
}
})
})
//
function createArcPath(
centerX: number,
centerY: number,
innerRadius: number,
outerRadius: number,
startAngle: number,
endAngle: number,
): string {
const startOuterX = centerX + outerRadius * Math.cos(startAngle)
const startOuterY = centerY + outerRadius * Math.sin(startAngle)
const endOuterX = centerX + outerRadius * Math.cos(endAngle)
const endOuterY = centerY + outerRadius * Math.sin(endAngle)
const startInnerX = centerX + innerRadius * Math.cos(endAngle)
const startInnerY = centerY + innerRadius * Math.sin(endAngle)
const endInnerX = centerX + innerRadius * Math.cos(startAngle)
const endInnerY = centerY + innerRadius * Math.sin(startAngle)
const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0
return [
'M',
startOuterX,
startOuterY,
'A',
outerRadius,
outerRadius,
0,
largeArcFlag,
1,
endOuterX,
endOuterY,
'L',
startInnerX,
startInnerY,
'A',
innerRadius,
innerRadius,
0,
largeArcFlag,
0,
endInnerX,
endInnerY,
'Z',
].join(' ')
}
//
const tooltipStyle = computed(() => ({
left: `${tooltip.x}px`,
top: `${tooltip.y}px`,
}))
//
function hoverSegment(index: number, event?: MouseEvent) {
hoveredIndex.value = index
const item = chartData.value[index]
if (event) {
const rect = (event.target as Element).closest('.chart-container')?.getBoundingClientRect()
if (rect) {
tooltip.visible = true
tooltip.x = event.clientX - rect.left + 10
tooltip.y = event.clientY - rect.top - 40
tooltip.type = item.type
tooltip.count = item.count
tooltip.percentage = item.percentage
tooltip.color = item.color
}
}
}
function unhoverSegment() {
hoveredIndex.value = null
tooltip.visible = false
}
</script>
<style scoped>
.operation-pie-chart {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
padding: 16px;
}
.chart-container {
position: relative;
margin-bottom: 20px;
}
.pie-svg {
width: 100%;
height: auto;
}
.pie-segment {
cursor: pointer;
transition: all 0.3s ease;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
.pie-segment:hover,
.pie-segment.active {
transform: scale(1.05);
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2));
}
.center-text .center-title {
font-size: 12px;
fill: var(--text-color-2);
font-weight: 500;
}
.center-text .center-value {
font-size: 16px;
fill: var(--text-color-1);
font-weight: bold;
}
.chart-tooltip {
position: absolute;
background: var(--popover-color);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
pointer-events: none;
font-size: 12px;
min-width: 120px;
}
.tooltip-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.tooltip-header {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
color: var(--text-color-1);
}
.tooltip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.tooltip-stats {
display: flex;
flex-direction: column;
gap: 2px;
color: var(--text-color-2);
font-size: 11px;
}
.chart-legend {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 200px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.legend-item:hover,
.legend-item.active {
background: var(--hover-color);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-content {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.legend-type {
font-size: 13px;
color: var(--text-color-1);
font-weight: 500;
}
.legend-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
}
.legend-count {
font-size: 12px;
color: var(--text-color-1);
font-weight: 600;
}
.legend-percentage {
font-size: 11px;
color: var(--text-color-3);
}
/* 响应式设计 */
@media (max-width: 768px) {
.operation-pie-chart {
padding: 12px;
}
.chart-container {
margin-bottom: 16px;
}
.legend-item {
padding: 4px 6px;
}
.legend-type {
font-size: 12px;
}
.legend-count {
font-size: 11px;
}
}
/* 动画效果 */
.pie-segment {
transform-origin: center;
animation: fadeInScale 0.6s ease-out forwards;
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 为每个片段添加延迟动画 */
.pie-segment:nth-child(1) { animation-delay: 0.1s; }
.pie-segment:nth-child(2) { animation-delay: 0.2s; }
.pie-segment:nth-child(3) { animation-delay: 0.3s; }
.pie-segment:nth-child(4) { animation-delay: 0.4s; }
.pie-segment:nth-child(5) { animation-delay: 0.5s; }
</style>

View File

@ -0,0 +1,24 @@
<template>
<div class="chart-placeholder h-200px w-full flex-center">
<div class="text-center">
<div class="text-lg font-bold text-gray-400 mb-2">
监控图表
</div>
<div class="text-sm text-gray-500">
图表功能已简化
</div>
</div>
</div>
</template>
<script setup lang="ts">
//
</script>
<style scoped>
.chart-placeholder {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 8px;
border: 1px solid #e0e0e0;
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<div class="chart-placeholder h-200px w-full flex-center">
<div class="text-center">
<div class="text-lg font-bold text-gray-400 mb-2">
柱状图
</div>
<div class="text-sm text-gray-500">
图表功能已简化
</div>
</div>
</div>
</template>
<script setup lang="ts">
//
</script>
<style scoped>
.chart-placeholder {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 8px;
border: 1px solid #e0e0e0;
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<div class="chart-placeholder h-200px w-full flex-center">
<div class="text-center">
<div class="text-lg font-bold text-gray-400 mb-2">
饼图
</div>
<div class="text-sm text-gray-500">
图表功能已简化
</div>
</div>
</div>
</template>
<script setup lang="ts">
//
</script>
<style scoped>
.chart-placeholder {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 8px;
border: 1px solid #e0e0e0;
}
</style>

View File

@ -0,0 +1,116 @@
import type { DashboardData } from './types'
// 生成模拟仪表盘数据
export function generateMockDashboardData(): DashboardData {
return {
// 用户统计数据
userStats: {
totalUsers: 1286,
todayNewUsers: 23,
activeUsers: 856,
onlineUsers: 142,
},
// 登录统计数据
loginStats: {
todayLogins: 468,
totalLogins: 45672,
loginTrend: generateLoginTrendData(),
},
// 存储统计数据
storageStats: {
totalFiles: 8924,
totalImages: 3420,
totalSize: '2.3 GB',
todayUploads: 67,
storageUsage: 67.5,
availableSpace: '1.2 GB',
},
// 今日活跃数据
dailyActivityStats: {
todayVisits: 1247,
todayOperations: 856,
activeUsers: 142,
newContent: 23,
apiCalls: 3420,
avgResponseTime: 235,
},
// 系统状态
systemStatus: {
diskUsage: 67.5,
memoryUsage: 43.2,
cpuUsage: 28.7,
systemHealth: 'good',
uptime: '15天 8小时 23分钟',
lastBackup: '2024-01-15 02:30:00',
},
}
}
// 生成登录趋势数据最近7天
function generateLoginTrendData() {
const trendData = []
const today = new Date()
for (let i = 6; i >= 0; i--) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const dateStr = date.toISOString().split('T')[0]
const label = date.toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
})
// 生成随机但合理的登录数量
const baseCount = 300
const randomVariation = Math.floor(Math.random() * 200) - 100
const weekendMultiplier
= date.getDay() === 0 || date.getDay() === 6 ? 0.6 : 1
const count = Math.max(
50,
Math.floor((baseCount + randomVariation) * weekendMultiplier),
)
trendData.push({
date: dateStr,
count,
label,
})
}
return trendData
}
// 生成随机颜色
export function generateRandomColor(): string {
const colors = [
'#18A058',
'#2080F0',
'#F0A020',
'#D03050',
'#722ED1',
'#13C2C2',
'#52C41A',
'#1890FF',
'#FAAD14',
'#F5222D',
]
return colors[Math.floor(Math.random() * colors.length)]
}
// 获取状态颜色
export function getStatusColor(status: string): string {
const colorMap: Record<string, string> = {
success: 'var(--success-color)',
failed: 'var(--error-color)',
warning: 'var(--warning-color)',
info: 'var(--info-color)',
good: 'var(--success-color)',
critical: 'var(--error-color)',
}
return colorMap[status] || 'var(--text-color-3)'
}

View File

@ -196,7 +196,6 @@
height="auto" height="auto"
confirm-text="确定" confirm-text="确定"
cancel-text="取消" cancel-text="取消"
:confirm-disabled="isConfirmDisabled"
@coi-confirm="handleUploadConfirm" @coi-confirm="handleUploadConfirm"
@coi-cancel="handleUploadCancel" @coi-cancel="handleUploadCancel"
> >
@ -277,7 +276,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue' import { h, onMounted, ref } from 'vue'
import { NButton, NIcon, NP, NPopconfirm, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui' import { NButton, NIcon, NP, NPopconfirm, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui'
import type { DataTableColumns, DataTableInst, FormInst, UploadFileInfo, UploadInst } from 'naive-ui' import type { DataTableColumns, DataTableInst, FormInst, UploadFileInfo, UploadInst } from 'naive-ui'
import CoiEmpty from '@/components/common/CoiEmpty.vue' import CoiEmpty from '@/components/common/CoiEmpty.vue'
@ -346,34 +345,6 @@ const searchForm = ref<SysFileSearchForm>({
const uploadFileList = ref<UploadFileInfo[]>([]) const uploadFileList = ref<UploadFileInfo[]>([])
const uploading = ref(false) const uploading = ref(false)
//
const isConfirmDisabled = computed(() => {
const fileList = uploadFileList.value
//
if (fileList.length === 0) {
return true
}
// pending uploading
const uploadingFiles = fileList.filter(file =>
file.status === 'pending' || file.status === 'uploading',
)
if (uploadingFiles.length > 0) {
return true
}
// error
const errorFiles = fileList.filter(file => file.status === 'error')
if (errorFiles.length > 0) {
return true
}
//
const finishedFiles = fileList.filter(file => file.status === 'finished')
return finishedFiles.length === 0
})
// //
const uploadForm = ref({ const uploadForm = ref({
fileService: 'LOCAL', fileService: 'LOCAL',
@ -778,20 +749,7 @@ async function customUpload({ file, onProgress, onFinish, onError }: any) {
onProgress({ percent: 10 }) onProgress({ percent: 10 })
// API // API
const result = await uploadFile(fileObj, folderName, 2, '-1', uploadForm.value.fileService) await uploadFile(fileObj, folderName, 2, '-1', uploadForm.value.fileService)
//
if (result.isSuccess === false) {
// HTTP200
// HTTP
onError()
//
const index = uploadFileList.value.findIndex(item => item.id === file.id)
if (index !== -1) {
uploadFileList.value.splice(index, 1)
}
return
}
// //
onProgress({ percent: 100 }) onProgress({ percent: 100 })
@ -804,12 +762,6 @@ async function customUpload({ file, onProgress, onFinish, onError }: any) {
catch (error: any) { catch (error: any) {
onError() onError()
//
const index = uploadFileList.value.findIndex(item => item.id === file.id)
if (index !== -1) {
uploadFileList.value.splice(index, 1)
}
// //
let errorMessage = `文件 ${file.file.name} 上传失败` let errorMessage = `文件 ${file.file.name} 上传失败`

View File

@ -317,7 +317,6 @@
height="auto" height="auto"
confirm-text="确定" confirm-text="确定"
cancel-text="取消" cancel-text="取消"
:confirm-disabled="isConfirmDisabled"
@coi-confirm="handleUploadConfirm" @coi-confirm="handleUploadConfirm"
@coi-cancel="handleUploadCancel" @coi-cancel="handleUploadCancel"
> >
@ -406,7 +405,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue' import { h, onMounted, ref } from 'vue'
import { NButton, NIcon, NP, NPopconfirm, NSpace, NSpin, NTag, NText, NTooltip, NUpload, NUploadDragger } from 'naive-ui' import { NButton, NIcon, NP, NPopconfirm, NSpace, NSpin, NTag, NText, NTooltip, NUpload, NUploadDragger } from 'naive-ui'
import type { DataTableColumns, DataTableInst, FormInst, UploadFileInfo, UploadInst } from 'naive-ui' import type { DataTableColumns, DataTableInst, FormInst, UploadFileInfo, UploadInst } from 'naive-ui'
import CoiEmpty from '@/components/common/CoiEmpty.vue' import CoiEmpty from '@/components/common/CoiEmpty.vue'
@ -481,34 +480,6 @@ const searchForm = ref<SysPictureSearchForm>({
const uploadFileList = ref<UploadFileInfo[]>([]) const uploadFileList = ref<UploadFileInfo[]>([])
const uploading = ref(false) const uploading = ref(false)
//
const isConfirmDisabled = computed(() => {
const fileList = uploadFileList.value
//
if (fileList.length === 0) {
return true
}
// pending uploading
const uploadingFiles = fileList.filter(file =>
file.status === 'pending' || file.status === 'uploading',
)
if (uploadingFiles.length > 0) {
return true
}
// error
const errorFiles = fileList.filter(file => file.status === 'error')
if (errorFiles.length > 0) {
return true
}
//
const finishedFiles = fileList.filter(file => file.status === 'finished')
return finishedFiles.length === 0
})
// //
const uploadForm = ref({ const uploadForm = ref({
pictureService: 'LOCAL', pictureService: 'LOCAL',
@ -912,24 +883,19 @@ async function customUpload({ file, onProgress, onFinish, onError }: any) {
try { try {
const fileObj = file.file as File const fileObj = file.file as File
//
// console.log('customUpload :', {
// fileName: fileObj.name,
// fileSize: fileObj.size,
// fileSizeMB: (fileObj.size / 1024 / 1024).toFixed(2),
// })
// //
onProgress({ percent: 10 }) onProgress({ percent: 10 })
// API - 使 // API - 使
const result = await uploadPicture(fileObj, uploadForm.value.pictureType, 2, uploadForm.value.pictureService) // console.log('API:', { pictureType: uploadForm.value.pictureType, pictureService: uploadForm.value.pictureService, fileSize: 2 })
await uploadPicture(fileObj, uploadForm.value.pictureType, 2, uploadForm.value.pictureService)
//
if (result.isSuccess === false) {
// HTTP200
// HTTP
onError()
//
const index = uploadFileList.value.findIndex(item => item.id === file.id)
if (index !== -1) {
uploadFileList.value.splice(index, 1)
}
return
}
// //
onProgress({ percent: 100 }) onProgress({ percent: 100 })
@ -944,12 +910,6 @@ async function customUpload({ file, onProgress, onFinish, onError }: any) {
console.error('图片上传失败:', error) console.error('图片上传失败:', error)
//
const index = uploadFileList.value.findIndex(item => item.id === file.id)
if (index !== -1) {
uploadFileList.value.splice(index, 1)
}
// //
let errorMessage = `图片 ${file.file.name} 上传失败` let errorMessage = `图片 ${file.file.name} 上传失败`