wuzhen 信息采集和统计分析完成

This commit is contained in:
wuzhen committed 2025-07-18 11:48:34 +08:00
1 parent 27fcea613c
commit 2f56204113
122 files changed
+29130 -101

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import { CollectionBatchVO, CollectionBatchForm, CollectionBatchQuery } from './types';
/**
* 查询信息采集批次列表
*/
export const listCollectionBatch = (query?: CollectionBatchQuery): AxiosPromise<CollectionBatchVO[]> => {
return request({
url: '/xg/collectionBatch/list',
method: 'get',
params: query
});
};
/**
* 查询信息采集批次详细
*/
export const getCollectionBatch = (id: string | number): AxiosPromise<CollectionBatchVO> => {
return request({
url: `/xg/collectionBatch/${id}`,
method: 'get'
});
};
/**
* 根据批次编码查询批次信息
*/
export const getCollectionBatchByCode = (batchCode: string): AxiosPromise<CollectionBatchVO> => {
return request({
url: `/xg/collectionBatch/code/${batchCode}`,
method: 'get'
});
};
/**
* 新增信息采集批次
*/
export const addCollectionBatch = (data: CollectionBatchForm) => {
return request({
url: '/xg/collectionBatch',
method: 'post',
data: data
});
};
/**
* 修改信息采集批次
*/
export const updateCollectionBatch = (data: CollectionBatchForm) => {
return request({
url: '/xg/collectionBatch',
method: 'put',
data: data
});
};
/**
* 删除信息采集批次
*/
export const delCollectionBatch = (ids: string | number | Array<string | number>) => {
return request({
url: `/xg/collectionBatch/${ids}`,
method: 'delete'
});
};
/**
* 导出信息采集批次
*/
export const exportCollectionBatch = (query?: CollectionBatchQuery) => {
return request({
url: '/xg/collectionBatch/export',
method: 'post',
data: query
});
};
/**
* 查询用户权限内的批次列表
*/
export const getUserPermissionBatches = (): AxiosPromise<CollectionBatchVO[]> => {
return request({
url: '/xg/collectionBatch/userPermission',
method: 'get'
});
};
/**
* 查询活跃的批次列表
*/
export const getActiveBatches = (): AxiosPromise<CollectionBatchVO[]> => {
return request({
url: '/xg/collectionBatch/active',
method: 'get'
});
};
/**
* 查询即将开始的批次列表
*/
export const getUpcomingBatches = (): AxiosPromise<CollectionBatchVO[]> => {
return request({
url: '/xg/collectionBatch/upcoming',
method: 'get'
});
};
/**
* 查询即将结束的批次列表
*/
export const getEndingSoonBatches = (): AxiosPromise<CollectionBatchVO[]> => {
return request({
url: '/xg/collectionBatch/endingSoon',
method: 'get'
});
};
/**
* 开始批次
*/
export const startBatch = (batchId: string | number) => {
return request({
url: `/xg/collectionBatch/start/${batchId}`,
method: 'post'
});
};
/**
* 暂停批次
*/
export const pauseBatch = (batchId: string | number) => {
return request({
url: `/xg/collectionBatch/pause/${batchId}`,
method: 'post'
});
};
/**
* 恢复批次
*/
export const resumeBatch = (batchId: string | number) => {
return request({
url: `/xg/collectionBatch/resume/${batchId}`,
method: 'post'
});
};
/**
* 结束批次
*/
export const endBatch = (batchId: string | number) => {
return request({
url: `/xg/collectionBatch/end/${batchId}`,
method: 'post'
});
};
/**
* 复制批次
*/
export const copyBatch = (batchId: string | number, newBatchCode: string, newBatchName: string) => {
return request({
url: `/xg/collectionBatch/copy/${batchId}`,
method: 'post',
data: {
newBatchCode,
newBatchName
}
});
};
/**
* 获取批次统计信息
*/
export const getBatchStatistics = (batchId: string | number) => {
return request({
url: `/xg/collectionBatch/statistics/${batchId}`,
method: 'get'
});
};
/**
* 获取整体统计信息
*/
export const getOverallStatistics = () => {
return request({
url: '/xg/collectionBatch/statistics/overall',
method: 'get'
});
};
/**
* 获取批次完成度统计
*/
export const getBatchCompletionStatistics = (batchId: string | number) => {
return request({
url: `/xg/collectionBatch/statistics/completion/${batchId}`,
method: 'get'
});
};
/**
* 获取批次多维度统计
*/
export const getBatchDimensionStatistics = (batchId: string | number, dimension: string) => {
return request({
url: `/xg/collectionBatch/statistics/dimension/${batchId}`,
method: 'get',
params: { dimension }
});
};
/**
* 批量更新批次状态
*/
export const batchUpdateStatus = (batchIds: Array<string | number>, status: string) => {
return request({
url: '/xg/collectionBatch/batchUpdateStatus',
method: 'post',
data: {
batchIds,
status
}
});
};
/**
* 检查批次是否可编辑
*/
export const checkBatchEditable = (batchId: string | number) => {
return request({
url: `/xg/collectionBatch/checkEditable/${batchId}`,
method: 'get'
});
};
/**
* 检查批次是否可删除
*/
export const checkBatchDeletable = (batchId: string | number) => {
return request({
url: `/xg/collectionBatch/checkDeletable/${batchId}`,
method: 'get'
});
};
/**
* 获取可用批次选项
*/
export const getAvailableBatches = () => {
return request({
url: '/xg/collectionBatch/available',
method: 'get'
});
};
/**
* 导入批次数据
*/
export const importBatchData = (file: File) => {
const formData = new FormData();
formData.append('file', file);
return request({
url: '/xg/collectionBatch/import',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
});
};
/**
* 下载批次数据导入模板
*/
export const downloadBatchTemplate = () => {
return request({
url: '/xg/collectionBatch/importTemplate',
method: 'post',
responseType: 'blob'
});
};
+501
View File
@@ -0,0 +1,501 @@
import request from '@/utils/request'
import { AxiosPromise } from 'axios'
// ========== 统计分析相关类型定义 ==========
// 统计查询参数
export interface StatisticsQuery {
pageNum?: number
pageSize?: number
configName?: string
configType?: string
dimensionType?: string
isEnabled?: boolean
createTime?: string[]
}
// 统计分析查询参数
export interface StatisticsAnalysisQuery {
statisticsType?: string
dimension?: string
field?: string
groupBy?: string
conditions?: Record<string, any>
startTime?: string
endTime?: string
collegeIds?: number[]
majorIds?: number[]
classIds?: number[]
grades?: string[]
genders?: string[]
politicalStatuses?: string[]
nationalities?: string[]
studentStatuses?: string[]
trainingTypes?: string[]
useCache?: boolean
}
// 统计配置视图对象
export interface StatisticsConfigVo {
id?: number
configName?: string
configType?: string
dimensionType?: string
queryConditions?: string
displaySettings?: string
chartType?: string
isEnabled?: boolean
sortOrder?: number
remark?: string
createBy?: number
createTime?: string
updateBy?: number
updateTime?: string
}
// 统计配置业务对象
export interface StatisticsConfigBo {
id?: number
configName?: string
configType?: string
dimensionType?: string
queryConditions?: string
displaySettings?: string
chartType?: string
isEnabled?: boolean
sortOrder?: number
remark?: string
}
// 统计结果视图对象
export interface StatisticsResultVo {
title?: string
type?: string
dimension?: string
data?: Array<Record<string, any>>
total?: number
statisticsTime?: string
fromCache?: boolean
cacheKey?: string
extra?: Record<string, any>
chartConfig?: Record<string, any>
groupData?: Record<string, Array<Record<string, any>>>
summary?: Record<string, any>
}
// 统计报表视图对象
export interface StatisticsReportVo {
reportType?: string
title?: string
description?: string
data?: Array<Record<string, any>>
summary?: Record<string, any>
generateTime?: string
metadata?: {
total: number
conditions: Record<string, any>
}
}
// 统计缓存信息
export interface StatisticsCacheInfo {
key?: string
hitCount?: number
missCount?: number
hitRate?: number
size?: number
lastAccessTime?: string
createTime?: string
expireTime?: string
}
// 导出参数
export interface StatisticsExportQuery {
dimension?: string
format?: 'excel' | 'csv'
includeChart?: boolean
conditions?: Record<string, any>
reportType?: string
}
// ========== 统计配置管理API ==========
// 查询统计配置列表
export function listStatisticsConfig(query: StatisticsQuery): AxiosPromise<any> {
return request({
url: '/xg/statisticsConfig/list',
method: 'get',
params: query
})
}
// 获取统计配置详情
export function getStatisticsConfig(id: number): AxiosPromise<StatisticsConfigVo> {
return request({
url: `/xg/statisticsConfig/${id}`,
method: 'get'
})
}
// 新增统计配置
export function addStatisticsConfig(data: StatisticsConfigBo): AxiosPromise<any> {
return request({
url: '/xg/statisticsConfig',
method: 'post',
data: data
})
}
// 修改统计配置
export function updateStatisticsConfig(data: StatisticsConfigBo): AxiosPromise<any> {
return request({
url: '/xg/statisticsConfig',
method: 'put',
data: data
})
}
// 删除统计配置
export function delStatisticsConfig(ids: number[]): AxiosPromise<any> {
return request({
url: `/xg/statisticsConfig/${ids}`,
method: 'delete'
})
}
// 导出统计配置
export function exportStatisticsConfig(query: StatisticsQuery): AxiosPromise<any> {
return request({
url: '/xg/statisticsConfig/export',
method: 'post',
data: query
})
}
// 启用/禁用统计配置
export function changeStatisticsConfigStatus(id: number, status: boolean): AxiosPromise<any> {
return request({
url: `/xg/statisticsConfig/changeStatus`,
method: 'put',
data: { id, isEnabled: status }
})
}
// ========== 统计分析API ==========
// 获取统计概览
export function getStatisticsOverview(): AxiosPromise<any> {
return request({
url: '/xg/statisticsAnalysis/overview',
method: 'get'
})
}
// 按维度统计 - 学院统计
export function getCollegeStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/college',
method: 'get',
params: query
})
}
// 按维度统计 - 专业统计
export function getMajorStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/major',
method: 'get',
params: query
})
}
// 按维度统计 - 班级统计
export function getClassStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/class',
method: 'get',
params: query
})
}
// 按维度统计 - 年级统计
export function getGradeStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/grade',
method: 'get',
params: query
})
}
// 按维度统计 - 性别统计
export function getGenderStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/gender',
method: 'get',
params: query
})
}
// 按维度统计 - 年龄统计
export function getAgeStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/age',
method: 'get',
params: query
})
}
// 按维度统计 - 政治面貌统计
export function getPoliticalStatusStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/political',
method: 'get',
params: query
})
}
// 按维度统计 - 民族统计
export function getNationStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/nation',
method: 'get',
params: query
})
}
// 按维度统计 - 生源地统计
export function getSourceRegionStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/sourceRegion',
method: 'get',
params: query
})
}
// 按维度统计 - 血型统计
export function getBloodTypeStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/bloodType',
method: 'get',
params: query
})
}
// 按维度统计 - 宗教信仰统计
export function getReligionStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/religion',
method: 'get',
params: query
})
}
// 按维度统计 - 学籍状态统计
export function getStudentStatusStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/studentStatus',
method: 'get',
params: query
})
}
// 按维度统计 - 培养方式统计
export function getTrainingTypeStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/trainingType',
method: 'get',
params: query
})
}
// 高级统计 - 身高统计
export function getHeightStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/height',
method: 'get',
params: query
})
}
// 高级统计 - 体重统计
export function getWeightStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/weight',
method: 'get',
params: query
})
}
// 多维度组合统计
export function getMultiDimensionStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/multiDimension',
method: 'post',
data: query
})
}
// 时间趋势统计
export function getTimeTrendStatistics(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: '/xg/statisticsAnalysis/timeTrend',
method: 'get',
params: query
})
}
// ========== 统计报表API ==========
// 生成学生信息汇总报表
export function generateStudentSummaryReport(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsReportVo> {
return request({
url: '/xg/statisticsAnalysis/report/studentSummary',
method: 'post',
data: query
})
}
// 生成班级统计报表
export function generateClassStatisticsReport(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsReportVo> {
return request({
url: '/xg/statisticsAnalysis/report/classStatistics',
method: 'post',
data: query
})
}
// 生成学院统计报表
export function generateCollegeStatisticsReport(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsReportVo> {
return request({
url: '/xg/statisticsAnalysis/report/collegeStatistics',
method: 'post',
data: query
})
}
// 生成困难学生统计报表
export function generateDifficultStudentReport(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsReportVo> {
return request({
url: '/xg/statisticsAnalysis/report/difficultStudent',
method: 'post',
data: query
})
}
// 生成特殊群体统计报表
export function generateSpecialGroupReport(query: StatisticsAnalysisQuery): AxiosPromise<StatisticsReportVo> {
return request({
url: '/xg/statisticsAnalysis/report/specialGroup',
method: 'post',
data: query
})
}
// ========== 统计导出API ==========
// 导出统计结果
export function exportStatisticsResult(query: StatisticsExportQuery): AxiosPromise<any> {
return request({
url: '/xg/statisticsAnalysis/export',
method: 'post',
data: query,
responseType: 'blob'
})
}
// 导出统计报表
export function exportStatisticsReport(query: StatisticsExportQuery): AxiosPromise<any> {
return request({
url: '/xg/statisticsAnalysis/exportReport',
method: 'post',
data: query,
responseType: 'blob'
})
}
// ========== 统计缓存API ==========
// 获取缓存统计信息
export function getCacheStatistics(): AxiosPromise<StatisticsCacheInfo> {
return request({
url: '/xg/statisticsAnalysis/cache/info',
method: 'get'
})
}
// 清空统计缓存
export function clearStatisticsCache(): AxiosPromise<any> {
return request({
url: '/xg/statisticsAnalysis/cache/clear',
method: 'delete'
})
}
// 预热统计缓存
export function warmupStatisticsCache(): AxiosPromise<any> {
return request({
url: '/xg/statisticsAnalysis/cache/warmup',
method: 'post'
})
}
// ========== 辅助API ==========
// 获取统计维度选项
export function getStatisticsDimensionOptions(): AxiosPromise<any> {
return request({
url: '/xg/statisticsAnalysis/dimensionOptions',
method: 'get'
})
}
// 获取图表类型选项
export function getChartTypeOptions(): AxiosPromise<any> {
return request({
url: '/xg/statisticsAnalysis/chartTypeOptions',
method: 'get'
})
}
// 获取学院选项
export function getCollegeOptions(): AxiosPromise<any> {
return request({
url: '/xg/statisticsAnalysis/collegeOptions',
method: 'get'
})
}
// 获取专业选项
export function getMajorOptions(collegeCode?: string): AxiosPromise<any> {
return request({
url: '/xg/statisticsAnalysis/majorOptions',
method: 'get',
params: { collegeCode }
})
}
// 获取班级选项
export function getClassOptions(majorCode?: string): AxiosPromise<any> {
return request({
url: '/xg/statisticsAnalysis/classOptions',
method: 'get',
params: { majorCode }
})
}
// 获取年级选项
export function getGradeOptions(): AxiosPromise<any> {
return request({
url: '/xg/statisticsAnalysis/gradeOptions',
method: 'get'
})
}
// 执行统计配置
export function executeStatisticsConfig(configId: number, query: StatisticsAnalysisQuery): AxiosPromise<StatisticsResultVo> {
return request({
url: `/xg/statisticsAnalysis/executeConfig/${configId}`,
method: 'post',
data: query
})
}
+152
View File
@@ -1,5 +1,157 @@
// 学生信息管理模块类型定义
// ========== 信息采集批次管理相关类型 ==========
// 信息采集批次VO
export interface CollectionBatchVO {
id?: number
batchCode?: string
batchName?: string
batchType?: string
academicYear?: string
semester?: string
batchStatus?: string
publishTime?: string
startTime?: string
endTime?: string
archiveTime?: string
targetColleges?: string
targetGrades?: string
targetMajors?: string
requiredFields?: string
optionalFields?: string
auditLevels?: string
totalStudents?: number
submittedCount?: number
approvedCount?: number
rejectedCount?: number
completionRate?: number
// 新增统计字段
studentCount?: number // 兼容性字段,映射到 totalStudents
pendingCount?: number // 待提交数量
description?: string
editPermissions?: string
auditPermissions?: string
viewPermissions?: string
autoReminder?: number
reminderFrequency?: number
allowModifyAfterSubmit?: number
createTime?: string
updateTime?: string
createBy?: number
updateBy?: number
}
// 信息采集批次表单
export interface CollectionBatchForm {
id?: number
batchCode?: string
batchName?: string
batchType?: string
academicYear?: string
semester?: string
batchStatus?: string
publishTime?: string
startTime?: string
endTime?: string
archiveTime?: string
targetColleges?: string
targetGrades?: string
targetMajors?: string
requiredFields?: string
optionalFields?: string
auditLevels?: string
description?: string
editPermissions?: string
auditPermissions?: string
viewPermissions?: string
autoReminder?: number
reminderFrequency?: number
allowModifyAfterSubmit?: number
}
// 信息采集批次查询参数
export interface CollectionBatchQuery {
pageNum?: number
pageSize?: number
batchCode?: string
batchName?: string
batchType?: string
academicYear?: string
semester?: string
batchStatus?: string
targetColleges?: string
targetGrades?: string
targetMajors?: string
publishTimeStart?: string
publishTimeEnd?: string
startTimeStart?: string
startTimeEnd?: string
endTimeStart?: string
endTimeEnd?: string
description?: string
}
// 批次统计信息
export interface BatchStatistics {
totalBatches?: number
activeBatches?: number
completedBatches?: number
draftBatches?: number
totalStudents?: number
submittedCount?: number
approvedCount?: number
rejectedCount?: number
completionRate?: number
averageCompletionTime?: number
}
// 批次完成度统计
export interface BatchCompletionStatistics {
batchId?: number
batchName?: string
totalStudents?: number
submittedCount?: number
approvedCount?: number
rejectedCount?: number
pendingCount?: number
completionRate?: number
submitRate?: number
approvalRate?: number
collegeStatistics?: CollegeCompletionStatistics[]
}
// 学院完成度统计
export interface CollegeCompletionStatistics {
collegeId?: number
collegeName?: string
totalStudents?: number
submittedCount?: number
approvedCount?: number
rejectedCount?: number
pendingCount?: number
completionRate?: number
submitRate?: number
approvalRate?: number
}
// 批次多维度统计
export interface BatchDimensionStatistics {
dimension?: string
dimensionName?: string
items?: DimensionStatisticsItem[]
}
// 维度统计项
export interface DimensionStatisticsItem {
key?: string
label?: string
count?: number
percentage?: number
}
// ========== 学生信息相关类型 ==========
// 学生信息完整数据结构
export interface StudentInfoVo {
id?: number
@@ -0,0 +1,285 @@
# 信息采集批次管理前端应用
## 📋 概述
信息采集批次管理模块是学工管理系统的重要组成部分,用于管理学生信息采集的批次流程,包括批次创建、状态管理、权限配置、统计分析等功能。
## 🏗️ 项目结构
```
web/src/views/xg-xx/collectionBatch/
├── components/ # 组件目录
│ ├── BatchActions.vue # 批次操作组件
│ ├── BatchStatusTag.vue # 批次状态标签组件
│ ├── BatchTypeTag.vue # 批次类型标签组件
│ ├── ProgressBar.vue # 进度条组件
│ └── SearchForm.vue # 搜索表单组件
├── index.vue # 批次列表页面
├── form.vue # 批次编辑表单页面
├── detail.vue # 批次详情页面
├── statistics.vue # 批次统计页面
├── permission.vue # 权限配置页面
├── route.ts # 路由配置
└── README.md # 使用文档
```
## 🎯 功能特性
### 1. 批次列表管理
- ✅ 批次列表展示(支持分页)
- ✅ 多条件搜索过滤
- ✅ 批次状态和类型标签展示
- ✅ 完成进度可视化
- ✅ 批次操作(新增、编辑、删除)
### 2. 批次表单管理
- ✅ 批次新增和编辑
- ✅ 分组表单设计(基本信息、时间设置、目标设置等)
- ✅ 表单验证
- ✅ 保存和发布功能
### 3. 批次详情查看
- ✅ 批次详细信息展示
- ✅ 卡片式布局
- ✅ 实时状态更新
- ✅ 批次操作按钮
### 4. 统计分析
- ✅ 批次统计数据展示
- ✅ 图表可视化(ECharts)
- ✅ 学院完成情况统计
- ✅ 多维度数据分析
### 5. 权限配置
- ✅ 角色权限管理
- ✅ 批量权限配置
- ✅ 权限有效期设置
- ✅ 权限状态管理
## 🛠️ 技术栈
- **Vue 3.5.13** - 前端框架
- **TypeScript 5.8.3** - 类型支持
- **Element Plus** - UI组件库
- **ECharts** - 图表库
- **Vue Router** - 路由管理
- **Axios** - HTTP请求
## 📦 组件说明
### BatchStatusTag.vue
批次状态标签组件,用于显示批次的当前状态。
```vue
<BatchStatusTag :status="batchStatus" :show-icon="true" />
```
**Props:**
- `status`: 批次状态(draft, published, active, paused, ended, archived)
- `showIcon`: 是否显示图标
- `effect`: 标签效果(dark, light, plain)
### BatchTypeTag.vue
批次类型标签组件,用于显示批次的类型。
```vue
<BatchTypeTag :type="batchType" :show-icon="true" />
```
**Props:**
- `type`: 批次类型(student_info, scholarship, evaluation, other)
- `showIcon`: 是否显示图标
- `effect`: 标签效果
### ProgressBar.vue
进度条组件,用于显示批次的完成进度。
```vue
<ProgressBar :current="submittedCount" :total="totalStudents" />
```
**Props:**
- `current`: 当前完成数量
- `total`: 总数量
- `strokeWidth`: 进度条宽度
- `showText`: 是否显示文本
- `showDetail`: 是否显示详细信息
### BatchActions.vue
批次操作组件,提供批次相关的操作按钮。
```vue
<BatchActions :batch="batchData" @start="handleStart" @pause="handlePause" />
```
**Props:**
- `batch`: 批次数据对象
**Events:**
- `start`: 开始批次
- `pause`: 暂停批次
- `resume`: 恢复批次
- `end`: 结束批次
- `copy`: 复制批次
- `statistics`: 统计分析
- `permission`: 权限配置
- `export`: 导出数据
- `delete`: 删除批次
### SearchForm.vue
搜索表单组件,用于批次列表的搜索过滤。
```vue
<SearchForm @search="handleSearch" @reset="handleReset" />
```
**Events:**
- `search`: 搜索事件,返回查询参数
- `reset`: 重置事件
## 🚀 使用指南
### 1. 批次管理流程
1. **创建批次**
- 点击"新增批次"按钮
- 填写批次基本信息、时间设置、目标设置等
- 保存草稿或直接发布
2. **管理批次**
- 在列表中查看批次状态和进度
- 使用搜索功能快速定位批次
- 通过操作按钮管理批次状态
3. **查看统计**
- 进入统计页面查看批次数据
- 查看图表分析和学院完成情况
- 导出统计报告
4. **配置权限**
- 设置不同角色的权限范围
- 配置权限有效期
- 批量设置权限
### 2. 页面导航
- `/xg-xx/collectionBatch` - 批次列表页面
- `/xg-xx/collectionBatch/add` - 新增批次页面
- `/xg-xx/collectionBatch/edit?id={id}` - 编辑批次页面
- `/xg-xx/collectionBatch/detail?id={id}` - 批次详情页面
- `/xg-xx/collectionBatch/statistics` - 统计分析页面
- `/xg-xx/collectionBatch/permission` - 权限配置页面
### 3. 权限控制
系统基于角色和权限进行访问控制:
- `xg:collectionBatch:list` - 查看批次列表
- `xg:collectionBatch:add` - 新增批次
- `xg:collectionBatch:edit` - 编辑批次
- `xg:collectionBatch:remove` - 删除批次
- `xg:collectionBatch:query` - 查看批次详情
- `xg:collectionBatch:export` - 导出批次数据
- `xg:collectionBatch:statistics` - 查看统计分析
- `xg:collectionBatch:permission` - 配置权限
## 🔧 开发指南
### 1. 本地开发
```bash
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
```
### 2. 代码规范
- 使用 TypeScript 进行类型检查
- 遵循 Vue 3 Composition API 规范
- 使用 ESLint 进行代码检查
- 组件命名使用 PascalCase
### 3. 样式规范
- 使用 Element Plus 设计系统
- 遵循后勤管理模块的颜色和样式规范
- 使用 Tailwind CSS 类名进行样式定制
- 响应式设计适配不同屏幕尺寸
## 📝 API 接口
### 批次管理接口
```typescript
// 查询批次列表
listCollectionBatch(query: CollectionBatchQuery): Promise<CollectionBatchVO[]>
// 获取批次详情
getCollectionBatch(id: string | number): Promise<CollectionBatchVO>
// 新增批次
addCollectionBatch(data: CollectionBatchForm): Promise<void>
// 更新批次
updateCollectionBatch(data: CollectionBatchForm): Promise<void>
// 删除批次
delCollectionBatch(ids: string | number | Array<string | number>): Promise<void>
// 批次状态操作
startBatch(batchId: string | number): Promise<void>
pauseBatch(batchId: string | number): Promise<void>
resumeBatch(batchId: string | number): Promise<void>
endBatch(batchId: string | number): Promise<void>
// 统计分析
getBatchStatistics(batchId: string | number): Promise<any>
getOverallStatistics(): Promise<any>
```
## 🐛 问题排查
### 常见问题
1. **页面无法访问**
- 检查路由配置是否正确
- 确认用户权限是否充足
2. **数据加载失败**
- 检查 API 接口是否正常
- 确认后端服务是否启动
3. **图表显示异常**
- 检查 ECharts 是否正确引入
- 确认数据格式是否正确
4. **权限配置无效**
- 检查权限标识是否正确
- 确认用户角色是否匹配
### 调试技巧
1. 使用浏览器开发者工具查看网络请求
2. 检查 Console 中的错误信息
3. 使用 Vue DevTools 调试组件状态
4. 查看后端日志确认接口调用情况
## 📞 技术支持
如有问题,请联系开发团队或查看相关文档:
- 项目文档:`docs/开发文档/API/`
- 后端接口:`backend/ruoyi-modules/ruoyi-xg-xx/`
- 前端代码:`web/src/views/xg-xx/collectionBatch/`
---
**版本信息**:V1.0.0
**更新时间**:2025-01-17
**维护团队**:前端开发组
@@ -0,0 +1,123 @@
<template>
<div class="batch-actions">
<el-dropdown @command="handleCommand" trigger="click">
<el-button type="primary" size="small">
批次操作
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-if="batch.batchStatus === 'draft'"
command="start"
icon="VideoPlay"
>
开始批次
</el-dropdown-item>
<el-dropdown-item
v-if="batch.batchStatus === 'active'"
command="pause"
icon="VideoPause"
>
暂停批次
</el-dropdown-item>
<el-dropdown-item
v-if="batch.batchStatus === 'paused'"
command="resume"
icon="VideoPlay"
>
恢复批次
</el-dropdown-item>
<el-dropdown-item
v-if="['active', 'paused'].includes(batch.batchStatus)"
command="end"
icon="VideoCamera"
>
结束批次
</el-dropdown-item>
<el-dropdown-item divided />
<el-dropdown-item command="copy" icon="CopyDocument">
复制批次
</el-dropdown-item>
<el-dropdown-item command="statistics" icon="TrendCharts">
统计分析
</el-dropdown-item>
<el-dropdown-item command="permission" icon="Lock">
权限配置
</el-dropdown-item>
<el-dropdown-item divided />
<el-dropdown-item command="export" icon="Download">
导出数据
</el-dropdown-item>
<el-dropdown-item
command="delete"
icon="Delete"
style="color: #f56c6c"
>
删除批次
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup lang="ts">
import { CollectionBatchVO } from '@/api/xg-xx/types';
interface Props {
batch: CollectionBatchVO;
}
const props = defineProps<Props>();
const emit = defineEmits<{
start: [batch: CollectionBatchVO];
pause: [batch: CollectionBatchVO];
resume: [batch: CollectionBatchVO];
end: [batch: CollectionBatchVO];
copy: [batch: CollectionBatchVO];
statistics: [batch: CollectionBatchVO];
permission: [batch: CollectionBatchVO];
export: [batch: CollectionBatchVO];
delete: [batch: CollectionBatchVO];
}>();
const handleCommand = (command: string) => {
switch (command) {
case 'start':
emit('start', props.batch);
break;
case 'pause':
emit('pause', props.batch);
break;
case 'resume':
emit('resume', props.batch);
break;
case 'end':
emit('end', props.batch);
break;
case 'copy':
emit('copy', props.batch);
break;
case 'statistics':
emit('statistics', props.batch);
break;
case 'permission':
emit('permission', props.batch);
break;
case 'export':
emit('export', props.batch);
break;
case 'delete':
emit('delete', props.batch);
break;
}
};
</script>
<style scoped>
.batch-actions {
display: inline-block;
}
</style>
@@ -0,0 +1,75 @@
<template>
<el-tag :type="tagType" :effect="effect">
<el-icon v-if="showIcon" class="mr-1">
<component :is="iconComponent" />
</el-icon>
{{ statusText }}
</el-tag>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
status: string;
showIcon?: boolean;
effect?: 'dark' | 'light' | 'plain';
}
const props = withDefaults(defineProps<Props>(), {
showIcon: true,
effect: 'light'
});
// 状态映射配置
const statusConfig = {
draft: {
text: '草稿',
type: 'info',
icon: 'Edit'
},
published: {
text: '已发布',
type: 'warning',
icon: 'Bell'
},
active: {
text: '进行中',
type: 'success',
icon: 'VideoPlay'
},
paused: {
text: '已暂停',
type: 'warning',
icon: 'VideoPause'
},
ended: {
text: '已结束',
type: 'danger',
icon: 'VideoCamera'
},
archived: {
text: '已归档',
type: 'info',
icon: 'FolderOpened'
}
};
const tagType = computed(() => {
return statusConfig[props.status as keyof typeof statusConfig]?.type || 'default';
});
const statusText = computed(() => {
return statusConfig[props.status as keyof typeof statusConfig]?.text || props.status;
});
const iconComponent = computed(() => {
return statusConfig[props.status as keyof typeof statusConfig]?.icon || 'QuestionFilled';
});
</script>
<style scoped>
.mr-1 {
margin-right: 0.25rem;
}
</style>
@@ -0,0 +1,65 @@
<template>
<el-tag :type="tagType" :effect="effect">
<el-icon v-if="showIcon" class="mr-1">
<component :is="iconComponent" />
</el-icon>
{{ typeText }}
</el-tag>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
type: string;
showIcon?: boolean;
effect?: 'dark' | 'light' | 'plain';
}
const props = withDefaults(defineProps<Props>(), {
showIcon: true,
effect: 'light'
});
// 类型映射配置
const typeConfig = {
student_info: {
text: '学生信息采集',
type: 'primary',
icon: 'User'
},
scholarship: {
text: '奖助学金申请',
type: 'success',
icon: 'Trophy'
},
evaluation: {
text: '综合测评',
type: 'warning',
icon: 'DataAnalysis'
},
other: {
text: '其他',
type: 'info',
icon: 'More'
}
};
const tagType = computed(() => {
return typeConfig[props.type as keyof typeof typeConfig]?.type as 'primary' | 'success' | 'warning' | 'info' | 'danger' || 'info';
});
const typeText = computed(() => {
return typeConfig[props.type as keyof typeof typeConfig]?.text || props.type;
});
const iconComponent = computed(() => {
return typeConfig[props.type as keyof typeof typeConfig]?.icon || 'QuestionFilled';
});
</script>
<style scoped>
.mr-1 {
margin-right: 0.25rem;
}
</style>
@@ -0,0 +1,81 @@
<template>
<div class="progress-container">
<el-progress
:percentage="percentage"
:stroke-width="strokeWidth"
:color="progressColor"
:show-text="showText"
:format="format"
/>
<div v-if="showDetail" class="progress-detail">
<span class="detail-text">{{ current }}/{{ total }}</span>
<span v-if="showRate" class="rate-text">({{ percentage.toFixed(1) }}%)</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
current: number;
total: number;
strokeWidth?: number;
showText?: boolean;
showDetail?: boolean;
showRate?: boolean;
colorThresholds?: {
low: number;
medium: number;
};
}
const props = withDefaults(defineProps<Props>(), {
strokeWidth: 12,
showText: true,
showDetail: true,
showRate: true,
colorThresholds: () => ({
low: 30,
medium: 70
})
});
const percentage = computed(() => {
if (props.total === 0) return 0;
return Math.round((props.current / props.total) * 100);
});
const progressColor = computed(() => {
const rate = percentage.value;
if (rate < props.colorThresholds.low) return '#f56c6c';
if (rate < props.colorThresholds.medium) return '#e6a23c';
return '#67c23a';
});
const format = (percentage: number) => {
return `${percentage}%`;
};
</script>
<style scoped>
.progress-container {
width: 100%;
}
.progress-detail {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
font-size: 12px;
}
.detail-text {
color: #606266;
}
.rate-text {
color: #909399;
}
</style>
@@ -0,0 +1,145 @@
<template>
<el-card shadow="hover">
<el-form ref="searchFormRef" :model="searchForm" label-width="100px">
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="批次名称" prop="batchName">
<el-input
v-model="searchForm.batchName"
placeholder="请输入批次名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="批次编码" prop="batchCode">
<el-input
v-model="searchForm.batchCode"
placeholder="请输入批次编码"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="批次类型" prop="batchType">
<el-select v-model="searchForm.batchType" placeholder="请选择批次类型" clearable style="width: 100%">
<el-option label="学生信息采集" value="student_info" />
<el-option label="奖助学金申请" value="scholarship" />
<el-option label="综合测评" value="evaluation" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="批次状态" prop="batchStatus">
<el-select v-model="searchForm.batchStatus" placeholder="请选择批次状态" clearable style="width: 100%">
<el-option label="草稿" value="draft" />
<el-option label="已发布" value="published" />
<el-option label="进行中" value="active" />
<el-option label="已暂停" value="paused" />
<el-option label="已结束" value="ended" />
<el-option label="已归档" value="archived" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="学年学期" prop="academicYear">
<el-input
v-model="searchForm.academicYear"
placeholder="请输入学年学期"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="目标届别" prop="targetGrades">
<el-input
v-model="searchForm.targetGrades"
placeholder="请输入目标届别"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="searchForm.createTime"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item>
<el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
<el-button icon="Refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { CollectionBatchQuery } from '@/api/xg-xx/types';
const emit = defineEmits<{
search: [query: CollectionBatchQuery];
reset: [];
}>();
const searchFormRef = ref<ElFormInstance>();
const searchForm = reactive<CollectionBatchQuery>({
batchName: '',
batchCode: '',
batchType: '',
batchStatus: '',
academicYear: '',
targetGrades: '',
createTime: []
});
const handleSearch = () => {
// 处理时间范围
const query = { ...searchForm };
if (query.createTime && query.createTime.length === 2) {
query.publishTimeStart = query.createTime[0];
query.publishTimeEnd = query.createTime[1];
}
delete query.createTime;
emit('search', query);
};
const handleReset = () => {
searchFormRef.value?.resetFields();
Object.assign(searchForm, {
batchName: '',
batchCode: '',
batchType: '',
batchStatus: '',
academicYear: '',
targetGrades: '',
createTime: []
});
emit('reset');
};
</script>
<style scoped>
/* 样式可以根据需要添加 */
</style>
@@ -0,0 +1,590 @@
<template>
<div class="p-2 page-container">
<el-card shadow="never" class="mb-4">
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg font-semibold">批次详情</span>
<el-button @click="goBack" icon="Back">返回</el-button>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- 基本信息 -->
<el-card shadow="hover" class="info-card">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-blue-500"><InfoFilled /></el-icon>
<span>基本信息</span>
</div>
</template>
<div class="space-y-3">
<div class="info-item">
<label>批次编码:</label>
<span>{{ batchInfo.batchCode }}</span>
</div>
<div class="info-item">
<label>批次名称:</label>
<span>{{ batchInfo.batchName }}</span>
</div>
<div class="info-item">
<label>批次类型:</label>
<el-tag v-if="batchInfo.batchType === 'student_info'" type="primary">学生信息采集</el-tag>
<el-tag v-else-if="batchInfo.batchType === 'scholarship'" type="success">奖助学金申请</el-tag>
<el-tag v-else-if="batchInfo.batchType === 'evaluation'" type="warning">综合测评</el-tag>
<el-tag v-else type="info">其他</el-tag>
</div>
<div class="info-item">
<label>学年学期:</label>
<span>{{ batchInfo.academicYear }}</span>
</div>
<div class="info-item">
<label>批次状态:</label>
<el-tag v-if="batchInfo.batchStatus === 'draft'" type="info">草稿</el-tag>
<el-tag v-else-if="batchInfo.batchStatus === 'published'" type="warning">已发布</el-tag>
<el-tag v-else-if="batchInfo.batchStatus === 'active'" type="success">进行中</el-tag>
<el-tag v-else-if="batchInfo.batchStatus === 'paused'" type="warning">已暂停</el-tag>
<el-tag v-else-if="batchInfo.batchStatus === 'ended'" type="danger">已结束</el-tag>
<el-tag v-else-if="batchInfo.batchStatus === 'archived'" type="info">已归档</el-tag>
</div>
</div>
</el-card>
<!-- 时间信息 -->
<el-card shadow="hover" class="info-card">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-green-500"><Clock /></el-icon>
<span>时间信息</span>
</div>
</template>
<div class="space-y-3">
<div class="info-item">
<label>发布时间:</label>
<span>{{ parseTime(batchInfo.publishTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</div>
<div class="info-item">
<label>开始时间:</label>
<span>{{ parseTime(batchInfo.startTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</div>
<div class="info-item">
<label>结束时间:</label>
<span>{{ parseTime(batchInfo.endTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</div>
<div class="info-item">
<label>归档时间:</label>
<span>{{ parseTime(batchInfo.archiveTime, '{y}-{m}-{d} {h}:{i}') || '未归档' }}</span>
</div>
<div class="info-item">
<label>剩余时间:</label>
<span :class="getRemainingTimeClass()">{{ getRemainingTime() }}</span>
</div>
</div>
</el-card>
<!-- 统计信息 -->
<el-card shadow="hover" class="info-card">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-purple-500"><DataAnalysis /></el-icon>
<span>统计信息</span>
</div>
</template>
<div class="space-y-3">
<div class="info-item">
<label>目标学生:</label>
<span class="text-blue-600 font-semibold">{{ batchInfo.totalStudents || 0 }}</span>
</div>
<div class="info-item">
<label>已提交:</label>
<span class="text-green-600 font-semibold">{{ batchInfo.submittedCount || 0 }}</span>
</div>
<div class="info-item">
<label>已审核:</label>
<span class="text-purple-600 font-semibold">{{ batchInfo.approvedCount || 0 }}</span>
</div>
<div class="info-item">
<label>被驳回:</label>
<span class="text-red-600 font-semibold">{{ batchInfo.rejectedCount || 0 }}</span>
</div>
<div class="info-item">
<label>完成率:</label>
<div class="flex items-center">
<el-progress
:percentage="batchInfo.completionRate || 0"
:stroke-width="8"
:color="getProgressColor(batchInfo.completionRate || 0)"
class="flex-1 mr-2"
/>
<span class="text-sm">{{ (batchInfo.completionRate || 0).toFixed(1) }}%</span>
</div>
</div>
</div>
</el-card>
</div>
<!-- 目标设置 -->
<el-card shadow="hover" class="mt-6">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-orange-500"><Target /></el-icon>
<span>目标设置</span>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="info-item">
<label>目标届别:</label>
<span>{{ batchInfo.targetGrades || '全部' }}</span>
</div>
<div class="info-item">
<label>目标学院:</label>
<span>{{ batchInfo.targetColleges || '全部' }}</span>
</div>
<div class="info-item">
<label>目标专业:</label>
<span>{{ batchInfo.targetMajors || '全部' }}</span>
</div>
</div>
</el-card>
<!-- 配置信息 -->
<el-card shadow="hover" class="mt-6">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-cyan-500"><Setting /></el-icon>
<span>配置信息</span>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="info-item">
<label>开始前提醒:</label>
<span>{{ batchInfo.remindBeforeStart || 0 }} 小时</span>
</div>
<div class="info-item">
<label>结束前提醒:</label>
<span>{{ batchInfo.remindBeforeEnd || 0 }} 小时</span>
</div>
<div class="info-item">
<label>自动关闭:</label>
<el-tag :type="batchInfo.autoClose ? 'success' : 'danger'">
{{ batchInfo.autoClose ? '是' : '否' }}
</el-tag>
</div>
<div class="info-item">
<label>通知发送:</label>
<el-tag :type="batchInfo.notificationSent ? 'success' : 'warning'">
{{ batchInfo.notificationSent ? '已发送' : '未发送' }}
</el-tag>
</div>
</div>
</el-card>
<!-- 批次描述 -->
<el-card shadow="hover" class="mt-6" v-if="batchInfo.batchDescription">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-gray-500"><Document /></el-icon>
<span>批次描述</span>
</div>
</template>
<div class="whitespace-pre-wrap text-gray-700">
{{ batchInfo.batchDescription }}
</div>
</el-card>
<!-- 操作按钮 -->
<div class="mt-6 flex justify-center space-x-4">
<el-button
v-if="batchInfo.batchStatus === 'draft'"
type="primary"
icon="VideoPlay"
@click="handleStart"
v-hasPermi="['xg:collectionBatch:edit']"
>
开始批次
</el-button>
<el-button
v-if="batchInfo.batchStatus === 'active'"
type="warning"
icon="VideoPause"
@click="handlePause"
v-hasPermi="['xg:collectionBatch:edit']"
>
暂停批次
</el-button>
<el-button
v-if="batchInfo.batchStatus === 'paused'"
type="success"
icon="VideoPlay"
@click="handleResume"
v-hasPermi="['xg:collectionBatch:edit']"
>
恢复批次
</el-button>
<el-button
v-if="['active', 'paused'].includes(batchInfo.batchStatus)"
type="danger"
icon="VideoCamera"
@click="handleEnd"
v-hasPermi="['xg:collectionBatch:edit']"
>
结束批次
</el-button>
<el-button
type="info"
icon="Edit"
@click="handleEdit"
v-hasPermi="['xg:collectionBatch:edit']"
>
编辑批次
</el-button>
<el-button
type="success"
icon="TrendCharts"
@click="handleStatistics"
v-hasPermi="['xg:collectionBatch:statistics']"
>
统计分析
</el-button>
<el-button
type="warning"
icon="CopyDocument"
@click="handleCopy"
v-hasPermi="['xg:collectionBatch:add']"
>
复制批次
</el-button>
</div>
</el-card>
</div>
</template>
<script setup name="CollectionBatchDetail" lang="ts">
import {
getCollectionBatch,
startBatch,
pauseBatch,
resumeBatch,
endBatch
} from '@/api/xg-xx/collectionBatch';
import { CollectionBatchVO } from '@/api/xg-xx/types';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const route = useRoute();
const router = useRouter();
const batchInfo = ref<CollectionBatchVO>({});
const loading = ref(true);
/** 获取批次详情 */
const getBatchDetail = async () => {
const batchId = route.query.id as string;
if (!batchId) {
proxy?.$modal.msgError('批次ID不能为空');
return;
}
try {
loading.value = true;
const res = await getCollectionBatch(batchId);
batchInfo.value = res.data;
} catch (error) {
proxy?.$modal.msgError('获取批次详情失败');
} finally {
loading.value = false;
}
};
/** 返回列表 */
const goBack = () => {
router.push('/xg-xx/collectionBatch');
};
/** 开始批次 */
const handleStart = async () => {
try {
await startBatch(batchInfo.value.id!);
proxy?.$modal.msgSuccess('批次已开始');
await getBatchDetail();
} catch (error) {
proxy?.$modal.msgError('开始批次失败');
}
};
/** 暂停批次 */
const handlePause = async () => {
try {
await pauseBatch(batchInfo.value.id!);
proxy?.$modal.msgSuccess('批次已暂停');
await getBatchDetail();
} catch (error) {
proxy?.$modal.msgError('暂停批次失败');
}
};
/** 恢复批次 */
const handleResume = async () => {
try {
await resumeBatch(batchInfo.value.id!);
proxy?.$modal.msgSuccess('批次已恢复');
await getBatchDetail();
} catch (error) {
proxy?.$modal.msgError('恢复批次失败');
}
};
/** 结束批次 */
const handleEnd = async () => {
try {
await proxy?.$modal.confirm('确认要结束此批次吗?结束后将无法恢复。');
await endBatch(batchInfo.value.id!);
proxy?.$modal.msgSuccess('批次已结束');
await getBatchDetail();
} catch (error) {
if (error !== 'cancel') {
proxy?.$modal.msgError('结束批次失败');
}
}
};
/** 编辑批次 */
const handleEdit = () => {
router.push({ path: '/xg-xx/collectionBatch/edit', query: { id: batchInfo.value.id } });
};
/** 统计分析 */
const handleStatistics = () => {
router.push({ path: '/xg-xx/collectionBatch/statistics', query: { batchId: batchInfo.value.id } });
};
/** 复制批次 */
const handleCopy = () => {
router.push({ path: '/xg-xx/collectionBatch/copy', query: { id: batchInfo.value.id } });
};
/** 获取剩余时间 */
const getRemainingTime = () => {
if (!batchInfo.value.endTime || batchInfo.value.batchStatus === 'ended') {
return '已结束';
}
const now = new Date();
const endTime = new Date(batchInfo.value.endTime);
const diff = endTime.getTime() - now.getTime();
if (diff <= 0) {
return '已超时';
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) {
return `${days}天${hours}小时`;
} else if (hours > 0) {
return `${hours}小时${minutes}分钟`;
} else {
return `${minutes}分钟`;
}
};
/** 获取剩余时间样式类 */
const getRemainingTimeClass = () => {
if (!batchInfo.value.endTime || batchInfo.value.batchStatus === 'ended') {
return 'text-gray-500';
}
const now = new Date();
const endTime = new Date(batchInfo.value.endTime);
const diff = endTime.getTime() - now.getTime();
if (diff <= 0) {
return 'text-red-600 font-semibold';
}
const hours = diff / (1000 * 60 * 60);
if (hours < 24) {
return 'text-red-600 font-semibold';
} else if (hours < 72) {
return 'text-orange-600 font-semibold';
} else {
return 'text-green-600';
}
};
/** 获取进度条颜色 */
const getProgressColor = (percentage: number) => {
if (percentage < 30) return '#f56c6c';
if (percentage < 70) return '#e6a23c';
return '#67c23a';
};
onMounted(() => {
getBatchDetail();
});
</script>
<style scoped>
.page-container {
background-color: #f5f7fa;
}
.info-card {
height: 100%;
}
.info-item {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.info-item label {
font-weight: 500;
color: #606266;
min-width: 80px;
margin-right: 8px;
}
.info-item span {
color: #303133;
flex: 1;
}
.grid {
display: grid;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.gap-4 {
gap: 1rem;
}
.gap-6 {
gap: 1.5rem;
}
.space-y-3 > * + * {
margin-top: 0.75rem;
}
.space-x-4 > * + * {
margin-left: 1rem;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
.text-lg {
font-size: 1.125rem;
}
.font-semibold {
font-weight: 600;
}
.mb-4 {
margin-bottom: 1rem;
}
.mt-6 {
margin-top: 1.5rem;
}
.mr-2 {
margin-right: 0.5rem;
}
.text-blue-500 {
color: #3b82f6;
}
.text-green-500 {
color: #10b981;
}
.text-purple-500 {
color: #8b5cf6;
}
.text-orange-500 {
color: #f59e0b;
}
.text-cyan-500 {
color: #06b6d4;
}
.text-gray-500 {
color: #6b7280;
}
.text-blue-600 {
color: #2563eb;
}
.text-green-600 {
color: #059669;
}
.text-purple-600 {
color: #7c3aed;
}
.text-red-600 {
color: #dc2626;
}
.text-orange-600 {
color: #ea580c;
}
.text-gray-700 {
color: #374151;
}
.whitespace-pre-wrap {
white-space: pre-wrap;
}
@media (min-width: 768px) {
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.md\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,490 @@
<template>
<div class="p-2 page-container">
<el-card shadow="never">
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg font-semibold">{{ isEdit ? '编辑批次' : '新增批次' }}</span>
<el-button @click="goBack" icon="Back">返回</el-button>
</div>
</template>
<el-form ref="batchFormRef" :model="form" :rules="rules" label-width="120px" class="max-w-4xl">
<!-- 基本信息 -->
<el-card shadow="hover" class="mb-6">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-blue-500"><InfoFilled /></el-icon>
<span>基本信息</span>
</div>
</template>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="批次编码" prop="batchCode">
<el-input v-model="form.batchCode" placeholder="请输入批次编码" :disabled="isEdit" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="批次名称" prop="batchName">
<el-input v-model="form.batchName" placeholder="请输入批次名称" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="批次类型" prop="batchType">
<el-select v-model="form.batchType" placeholder="请选择批次类型" style="width: 100%">
<el-option label="学生信息采集" value="student_info" />
<el-option label="奖助学金申请" value="scholarship" />
<el-option label="综合测评" value="evaluation" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学年学期" prop="academicYear">
<el-input v-model="form.academicYear" placeholder="请输入学年学期,如:2024-2025第一学期" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="学期" prop="semester">
<el-select v-model="form.semester" placeholder="请选择学期" style="width: 100%">
<el-option label="第一学期" value="1" />
<el-option label="第二学期" value="2" />
<el-option label="第三学期" value="3" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="批次状态" prop="batchStatus">
<el-select v-model="form.batchStatus" placeholder="请选择批次状态" style="width: 100%">
<el-option label="草稿" value="draft" />
<el-option label="已发布" value="published" />
<el-option label="进行中" value="active" />
<el-option label="已暂停" value="paused" />
<el-option label="已结束" value="ended" />
<el-option label="已归档" value="archived" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-card>
<!-- 时间设置 -->
<el-card shadow="hover" class="mb-6">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-green-500"><Clock /></el-icon>
<span>时间设置</span>
</div>
</template>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="发布时间" prop="publishTime">
<el-date-picker
v-model="form.publishTime"
type="datetime"
placeholder="选择发布时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开始时间" prop="startTime">
<el-date-picker
v-model="form.startTime"
type="datetime"
placeholder="选择开始时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="结束时间" prop="endTime">
<el-date-picker
v-model="form.endTime"
type="datetime"
placeholder="选择结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="归档时间" prop="archiveTime">
<el-date-picker
v-model="form.archiveTime"
type="datetime"
placeholder="选择归档时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</el-card>
<!-- 目标设置 -->
<el-card shadow="hover" class="mb-6">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-orange-500"><Target /></el-icon>
<span>目标设置</span>
</div>
</template>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="目标学院" prop="targetColleges">
<el-input v-model="form.targetColleges" placeholder="请输入目标学院,多个用逗号分隔" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标届别" prop="targetGrades">
<el-input v-model="form.targetGrades" placeholder="请输入目标届别,如:2021,2022,2023" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="目标专业" prop="targetMajors">
<el-input v-model="form.targetMajors" placeholder="请输入目标专业,多个用逗号分隔" />
</el-form-item>
</el-col>
</el-row>
</el-card>
<!-- 字段配置 -->
<el-card shadow="hover" class="mb-6">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-purple-500"><Setting /></el-icon>
<span>字段配置</span>
</div>
</template>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="必填字段" prop="requiredFields">
<el-input v-model="form.requiredFields" type="textarea" :rows="3" placeholder="请输入必填字段,多个用逗号分隔" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="可选字段" prop="optionalFields">
<el-input v-model="form.optionalFields" type="textarea" :rows="3" placeholder="请输入可选字段,多个用逗号分隔" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="审核层级" prop="auditLevels">
<el-input v-model="form.auditLevels" placeholder="请输入审核层级,如:辅导员,学院,学生处" />
</el-form-item>
</el-col>
</el-row>
</el-card>
<!-- 权限配置 -->
<el-card shadow="hover" class="mb-6">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-cyan-500"><Lock /></el-icon>
<span>权限配置</span>
</div>
</template>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="编辑权限" prop="editPermissions">
<el-input v-model="form.editPermissions" placeholder="请输入编辑权限配置" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="审核权限" prop="auditPermissions">
<el-input v-model="form.auditPermissions" placeholder="请输入审核权限配置" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="查看权限" prop="viewPermissions">
<el-input v-model="form.viewPermissions" placeholder="请输入查看权限配置" />
</el-form-item>
</el-col>
</el-row>
</el-card>
<!-- 提醒设置 -->
<el-card shadow="hover" class="mb-6">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-yellow-500"><Bell /></el-icon>
<span>提醒设置</span>
</div>
</template>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="自动提醒" prop="autoReminder">
<el-input-number v-model="form.autoReminder" :min="0" :max="720" placeholder="小时" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="提醒频率" prop="reminderFrequency">
<el-input-number v-model="form.reminderFrequency" :min="0" :max="720" placeholder="小时" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="允许修改" prop="allowModifyAfterSubmit">
<el-switch v-model="form.allowModifyAfterSubmit" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-col>
</el-row>
</el-card>
<!-- 批次描述 -->
<el-card shadow="hover" class="mb-6">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-gray-500"><Document /></el-icon>
<span>批次描述</span>
</div>
</template>
<el-form-item label="批次描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="6" placeholder="请输入批次描述" />
</el-form-item>
</el-card>
<!-- 操作按钮 -->
<div class="flex justify-center space-x-4 mt-6">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="loading">保存</el-button>
<el-button type="success" @click="handleSaveAndPublish" :loading="loading" v-if="!isEdit">保存并发布</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup name="CollectionBatchForm" lang="ts">
import {
getCollectionBatch,
addCollectionBatch,
updateCollectionBatch
} from '@/api/xg-xx/collectionBatch';
import { CollectionBatchForm } from '@/api/xg-xx/types';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const isEdit = ref(false);
const batchFormRef = ref<ElFormInstance>();
const form = ref<CollectionBatchForm>({
id: undefined,
batchCode: '',
batchName: '',
batchType: '',
academicYear: '',
semester: '',
batchStatus: 'draft',
publishTime: '',
startTime: '',
endTime: '',
archiveTime: '',
targetColleges: '',
targetGrades: '',
targetMajors: '',
requiredFields: '',
optionalFields: '',
auditLevels: '',
editPermissions: '',
auditPermissions: '',
viewPermissions: '',
autoReminder: 24,
reminderFrequency: 24,
allowModifyAfterSubmit: 1,
description: ''
});
const rules = ref({
batchCode: [
{ required: true, message: '批次编码不能为空', trigger: 'blur' },
{ min: 2, max: 50, message: '批次编码长度在 2 到 50 个字符', trigger: 'blur' }
],
batchName: [
{ required: true, message: '批次名称不能为空', trigger: 'blur' },
{ min: 2, max: 100, message: '批次名称长度在 2 到 100 个字符', trigger: 'blur' }
],
batchType: [
{ required: true, message: '请选择批次类型', trigger: 'change' }
],
academicYear: [
{ required: true, message: '学年学期不能为空', trigger: 'blur' }
],
startTime: [
{ required: true, message: '开始时间不能为空', trigger: 'change' }
],
endTime: [
{ required: true, message: '结束时间不能为空', trigger: 'change' }
]
});
/** 获取批次详情 */
const getBatchDetail = async () => {
const batchId = route.query.id as string;
if (!batchId) return;
try {
loading.value = true;
const res = await getCollectionBatch(batchId);
Object.assign(form.value, res.data);
isEdit.value = true;
} catch (error) {
proxy?.$modal.msgError('获取批次详情失败');
} finally {
loading.value = false;
}
};
/** 保存 */
const handleSave = () => {
batchFormRef.value?.validate(async (valid: boolean) => {
if (valid) {
try {
loading.value = true;
if (isEdit.value) {
await updateCollectionBatch(form.value);
proxy?.$modal.msgSuccess('更新成功');
} else {
await addCollectionBatch(form.value);
proxy?.$modal.msgSuccess('新增成功');
}
goBack();
} catch (error) {
proxy?.$modal.msgError(isEdit.value ? '更新失败' : '新增失败');
} finally {
loading.value = false;
}
}
});
};
/** 保存并发布 */
const handleSaveAndPublish = () => {
form.value.batchStatus = 'published';
handleSave();
};
/** 返回 */
const goBack = () => {
router.push('/xg-xx/collectionBatch');
};
onMounted(() => {
if (route.query.id) {
getBatchDetail();
}
});
</script>
<style scoped>
.page-container {
background-color: #f5f7fa;
}
.max-w-4xl {
max-width: 56rem;
}
.space-x-4 > * + * {
margin-left: 1rem;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
.text-lg {
font-size: 1.125rem;
}
.font-semibold {
font-weight: 600;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.mt-6 {
margin-top: 1.5rem;
}
.mr-2 {
margin-right: 0.5rem;
}
.text-blue-500 {
color: #3b82f6;
}
.text-green-500 {
color: #10b981;
}
.text-orange-500 {
color: #f59e0b;
}
.text-purple-500 {
color: #8b5cf6;
}
.text-cyan-500 {
color: #06b6d4;
}
.text-yellow-500 {
color: #eab308;
}
.text-gray-500 {
color: #6b7280;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,568 @@
<template>
<div class="p-2 page-container">
<el-card shadow="never">
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg font-semibold">批次权限配置</span>
<el-button @click="goBack" icon="Back">返回</el-button>
</div>
</template>
<!-- 批次选择 -->
<div class="mb-4">
<el-form :model="queryParams" inline>
<el-form-item label="选择批次">
<el-select v-model="queryParams.batchId" placeholder="请选择批次" @change="handleBatchChange" style="width: 300px">
<el-option
v-for="batch in batchList"
:key="batch.id"
:label="`${batch.batchName} (${batch.batchCode})`"
:value="batch.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button type="success" @click="handleBatchPermission" :disabled="!queryParams.batchId">批量配置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 权限配置表格 -->
<el-table v-loading="loading" :data="permissionList" stripe>
<el-table-column prop="roleType" label="角色类型" align="center" width="120">
<template #default="scope">
<el-tag v-if="scope.row.roleType === 'student'" type="primary">学生</el-tag>
<el-tag v-else-if="scope.row.roleType === 'counselor'" type="success">辅导员</el-tag>
<el-tag v-else-if="scope.row.roleType === 'class_teacher'" type="warning">班主任</el-tag>
<el-tag v-else-if="scope.row.roleType === 'college_admin'" type="info">学院管理员</el-tag>
<el-tag v-else-if="scope.row.roleType === 'student_affairs'" type="danger">学生处</el-tag>
<el-tag v-else type="default">其他</el-tag>
</template>
</el-table-column>
<el-table-column prop="roleName" label="角色名称" align="center" min-width="150" />
<el-table-column prop="targetScope" label="管理范围" align="center" min-width="200" show-overflow-tooltip />
<el-table-column label="查看权限" align="center" width="100">
<template #default="scope">
<el-switch
v-model="scope.row.canView"
@change="handlePermissionChange(scope.row, 'canView')"
/>
</template>
</el-table-column>
<el-table-column label="编辑权限" align="center" width="100">
<template #default="scope">
<el-switch
v-model="scope.row.canEdit"
@change="handlePermissionChange(scope.row, 'canEdit')"
/>
</template>
</el-table-column>
<el-table-column label="审核权限" align="center" width="100">
<template #default="scope">
<el-switch
v-model="scope.row.canAudit"
@change="handlePermissionChange(scope.row, 'canAudit')"
/>
</template>
</el-table-column>
<el-table-column label="统计权限" align="center" width="100">
<template #default="scope">
<el-switch
v-model="scope.row.canStatistics"
@change="handlePermissionChange(scope.row, 'canStatistics')"
/>
</template>
</el-table-column>
<el-table-column label="导出权限" align="center" width="100">
<template #default="scope">
<el-switch
v-model="scope.row.canExport"
@change="handlePermissionChange(scope.row, 'canExport')"
/>
</template>
</el-table-column>
<el-table-column label="有效期" align="center" width="180">
<template #default="scope">
<span>{{ parseTime(scope.row.validFrom, '{y}-{m}-{d}') }} 至 {{ parseTime(scope.row.validTo, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 'active' ? 'success' : 'danger'">
{{ scope.row.status === 'active' ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="150">
<template #default="scope">
<el-button link type="primary" @click="handleEdit(scope.row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</el-card>
<!-- 权限配置对话框 -->
<el-dialog :title="dialog.title" v-model="dialog.visible" width="600px" append-to-body>
<el-form ref="permissionFormRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="角色类型" prop="roleType">
<el-select v-model="form.roleType" placeholder="请选择角色类型" style="width: 100%">
<el-option label="学生" value="student" />
<el-option label="辅导员" value="counselor" />
<el-option label="班主任" value="class_teacher" />
<el-option label="学院管理员" value="college_admin" />
<el-option label="学生处" value="student_affairs" />
</el-select>
</el-form-item>
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="form.roleName" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="管理范围" prop="targetScope">
<el-input v-model="form.targetScope" type="textarea" :rows="3" placeholder="请输入管理范围描述" />
</el-form-item>
<el-form-item label="权限设置">
<div class="permission-grid">
<el-checkbox v-model="form.canView">查看权限</el-checkbox>
<el-checkbox v-model="form.canEdit">编辑权限</el-checkbox>
<el-checkbox v-model="form.canAudit">审核权限</el-checkbox>
<el-checkbox v-model="form.canStatistics">统计权限</el-checkbox>
<el-checkbox v-model="form.canExport">导出权限</el-checkbox>
</div>
</el-form-item>
<el-form-item label="有效期" prop="validPeriod">
<el-date-picker
v-model="form.validPeriod"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio value="active">启用</el-radio>
<el-radio value="inactive">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel">取 消</el-button>
<el-button type="primary" @click="submitForm">确 定</el-button>
</div>
</template>
</el-dialog>
<!-- 批量配置对话框 -->
<el-dialog title="批量权限配置" v-model="batchDialog.visible" width="800px" append-to-body>
<div class="mb-4">
<el-alert
title="批量配置说明"
description="选择要配置的角色类型,系统将为该类型的所有用户统一设置权限。"
type="info"
show-icon
:closable="false"
/>
</div>
<el-form ref="batchFormRef" :model="batchForm" label-width="100px">
<el-form-item label="角色类型" prop="roleTypes">
<el-checkbox-group v-model="batchForm.roleTypes">
<el-checkbox value="student">学生</el-checkbox>
<el-checkbox value="counselor">辅导员</el-checkbox>
<el-checkbox value="class_teacher">班主任</el-checkbox>
<el-checkbox value="college_admin">学院管理员</el-checkbox>
<el-checkbox value="student_affairs">学生处</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="权限设置">
<div class="permission-grid">
<el-checkbox v-model="batchForm.canView">查看权限</el-checkbox>
<el-checkbox v-model="batchForm.canEdit">编辑权限</el-checkbox>
<el-checkbox v-model="batchForm.canAudit">审核权限</el-checkbox>
<el-checkbox v-model="batchForm.canStatistics">统计权限</el-checkbox>
<el-checkbox v-model="batchForm.canExport">导出权限</el-checkbox>
</div>
</el-form-item>
<el-form-item label="有效期">
<el-date-picker
v-model="batchForm.validPeriod"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="batchForm.status">
<el-radio value="active">启用</el-radio>
<el-radio value="inactive">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="batchDialog.visible = false">取 消</el-button>
<el-button type="primary" @click="submitBatchForm">确 定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="CollectionBatchPermission" lang="ts">
import { getActiveBatches } from '@/api/xg-xx/collectionBatch';
import { CollectionBatchVO } from '@/api/xg-xx/types';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const router = useRouter();
// 数据定义
const batchList = ref<CollectionBatchVO[]>([]);
const permissionList = ref<any[]>([]);
const loading = ref(false);
const total = ref(0);
// 查询参数
const queryParams = ref({
pageNum: 1,
pageSize: 10,
batchId: undefined
});
// 对话框
const dialog = reactive({
visible: false,
title: ''
});
const batchDialog = reactive({
visible: false,
title: '批量权限配置'
});
// 表单数据
const form = ref({
id: undefined,
batchId: undefined,
roleType: '',
roleName: '',
targetScope: '',
canView: false,
canEdit: false,
canAudit: false,
canStatistics: false,
canExport: false,
validPeriod: [],
validFrom: '',
validTo: '',
status: 'active'
});
const batchForm = ref({
roleTypes: [],
canView: false,
canEdit: false,
canAudit: false,
canStatistics: false,
canExport: false,
validPeriod: [],
status: 'active'
});
// 表单验证规则
const rules = ref({
roleType: [{ required: true, message: '请选择角色类型', trigger: 'change' }],
roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
targetScope: [{ required: true, message: '请输入管理范围', trigger: 'blur' }],
validPeriod: [{ required: true, message: '请选择有效期', trigger: 'change' }]
});
const permissionFormRef = ref<ElFormInstance>();
const batchFormRef = ref<ElFormInstance>();
/** 获取批次列表 */
const getBatchList = async () => {
try {
const res = await getActiveBatches();
batchList.value = res.data;
} catch (error) {
proxy?.$modal.msgError('获取批次列表失败');
}
};
/** 获取权限列表 */
const getList = async () => {
if (!queryParams.value.batchId) {
permissionList.value = [];
total.value = 0;
return;
}
loading.value = true;
// 模拟数据
setTimeout(() => {
permissionList.value = [
{
id: 1,
batchId: queryParams.value.batchId,
roleType: 'student',
roleName: '学生',
targetScope: '本人信息',
canView: true,
canEdit: true,
canAudit: false,
canStatistics: false,
canExport: false,
validFrom: '2024-01-01',
validTo: '2024-12-31',
status: 'active'
},
{
id: 2,
batchId: queryParams.value.batchId,
roleType: 'counselor',
roleName: '辅导员',
targetScope: '所负责学生',
canView: true,
canEdit: true,
canAudit: true,
canStatistics: true,
canExport: true,
validFrom: '2024-01-01',
validTo: '2024-12-31',
status: 'active'
},
{
id: 3,
batchId: queryParams.value.batchId,
roleType: 'class_teacher',
roleName: '班主任',
targetScope: '所负责班级学生',
canView: true,
canEdit: true,
canAudit: true,
canStatistics: true,
canExport: true,
validFrom: '2024-01-01',
validTo: '2024-12-31',
status: 'active'
},
{
id: 4,
batchId: queryParams.value.batchId,
roleType: 'college_admin',
roleName: '学院管理员',
targetScope: '本学院所有学生',
canView: true,
canEdit: true,
canAudit: true,
canStatistics: true,
canExport: true,
validFrom: '2024-01-01',
validTo: '2024-12-31',
status: 'active'
},
{
id: 5,
batchId: queryParams.value.batchId,
roleType: 'student_affairs',
roleName: '学生处',
targetScope: '全校所有学生',
canView: true,
canEdit: true,
canAudit: true,
canStatistics: true,
canExport: true,
validFrom: '2024-01-01',
validTo: '2024-12-31',
status: 'active'
}
];
total.value = permissionList.value.length;
loading.value = false;
}, 500);
};
/** 批次变更处理 */
const handleBatchChange = () => {
getList();
};
/** 查询 */
const handleQuery = () => {
queryParams.value.pageNum = 1;
getList();
};
/** 权限变更处理 */
const handlePermissionChange = (row: any, field: string) => {
// 这里应该调用API更新权限
console.log('更新权限:', row.id, field, row[field]);
proxy?.$modal.msgSuccess('权限更新成功');
};
/** 编辑 */
const handleEdit = (row: any) => {
form.value = {
...row,
validPeriod: [row.validFrom, row.validTo]
};
dialog.visible = true;
dialog.title = '编辑权限';
};
/** 删除 */
const handleDelete = async (row: any) => {
try {
await proxy?.$modal.confirm('确认要删除此权限配置吗?');
// 这里应该调用API删除权限
proxy?.$modal.msgSuccess('删除成功');
getList();
} catch (error) {
if (error !== 'cancel') {
proxy?.$modal.msgError('删除失败');
}
}
};
/** 批量配置 */
const handleBatchPermission = () => {
batchForm.value = {
roleTypes: [],
canView: false,
canEdit: false,
canAudit: false,
canStatistics: false,
canExport: false,
validPeriod: [],
status: 'active'
};
batchDialog.visible = true;
};
/** 提交表单 */
const submitForm = () => {
permissionFormRef.value?.validate((valid: boolean) => {
if (valid) {
// 处理有效期
if (form.value.validPeriod && form.value.validPeriod.length === 2) {
form.value.validFrom = form.value.validPeriod[0];
form.value.validTo = form.value.validPeriod[1];
}
// 这里应该调用API保存权限
console.log('保存权限:', form.value);
proxy?.$modal.msgSuccess('保存成功');
dialog.visible = false;
getList();
}
});
};
/** 提交批量表单 */
const submitBatchForm = () => {
if (batchForm.value.roleTypes.length === 0) {
proxy?.$modal.msgError('请至少选择一个角色类型');
return;
}
// 这里应该调用API批量保存权限
console.log('批量保存权限:', batchForm.value);
proxy?.$modal.msgSuccess('批量配置成功');
batchDialog.visible = false;
getList();
};
/** 取消 */
const cancel = () => {
dialog.visible = false;
form.value = {
id: undefined,
batchId: undefined,
roleType: '',
roleName: '',
targetScope: '',
canView: false,
canEdit: false,
canAudit: false,
canStatistics: false,
canExport: false,
validPeriod: [],
validFrom: '',
validTo: '',
status: 'active'
};
};
/** 返回 */
const goBack = () => {
router.push('/xg-xx/collectionBatch');
};
onMounted(() => {
getBatchList();
});
</script>
<style scoped>
.page-container {
background-color: #f5f7fa;
}
.permission-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.text-lg {
font-size: 1.125rem;
}
.font-semibold {
font-weight: 600;
}
.mb-4 {
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.permission-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,72 @@
import { RouteRecordRaw } from 'vue-router';
// 信息采集批次管理路由配置
export const collectionBatchRoutes: RouteRecordRaw[] = [
{
path: '/xg-xx/collectionBatch',
name: 'CollectionBatch',
component: () => import('./index.vue'),
meta: {
title: '信息采集批次管理',
icon: 'list',
permissions: ['xg:collectionBatch:list']
}
},
{
path: '/xg-xx/collectionBatch/add',
name: 'CollectionBatchAdd',
component: () => import('./form.vue'),
meta: {
title: '新增批次',
activeMenu: '/xg-xx/collectionBatch',
permissions: ['xg:collectionBatch:add']
},
hidden: true
},
{
path: '/xg-xx/collectionBatch/edit',
name: 'CollectionBatchEdit',
component: () => import('./form.vue'),
meta: {
title: '编辑批次',
activeMenu: '/xg-xx/collectionBatch',
permissions: ['xg:collectionBatch:edit']
},
hidden: true
},
{
path: '/xg-xx/collectionBatch/detail',
name: 'CollectionBatchDetail',
component: () => import('./detail.vue'),
meta: {
title: '批次详情',
activeMenu: '/xg-xx/collectionBatch',
permissions: ['xg:collectionBatch:query']
},
hidden: true
},
{
path: '/xg-xx/collectionBatch/statistics',
name: 'CollectionBatchStatistics',
component: () => import('./statistics.vue'),
meta: {
title: '批次统计',
activeMenu: '/xg-xx/collectionBatch',
permissions: ['xg:collectionBatch:statistics']
},
hidden: true
},
{
path: '/xg-xx/collectionBatch/permission',
name: 'CollectionBatchPermission',
component: () => import('./permission.vue'),
meta: {
title: '权限配置',
activeMenu: '/xg-xx/collectionBatch',
permissions: ['xg:collectionBatch:permission']
},
hidden: true
}
];
export default collectionBatchRoutes;
@@ -0,0 +1,735 @@
<template>
<div class="p-2 page-container">
<el-card shadow="never" class="mb-4">
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg font-semibold">批次统计分析</span>
<div class="flex space-x-2">
<el-select v-model="selectedBatch" placeholder="选择批次" @change="handleBatchChange" style="width: 200px">
<el-option
v-for="batch in batchList"
:key="batch.id"
:label="batch.batchName"
:value="batch.id"
/>
</el-select>
<el-button @click="goBack" icon="Back">返回</el-button>
</div>
</div>
</template>
<!-- 总览统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<div class="stat-icon bg-blue-100">
<el-icon class="text-blue-500 text-2xl"><User /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value text-blue-600">{{ overallStats.totalStudents || 0 }}</div>
<div class="stat-label">目标学生</div>
</div>
</div>
</el-card>
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<div class="stat-icon bg-green-100">
<el-icon class="text-green-500 text-2xl"><Check /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value text-green-600">{{ overallStats.submittedCount || 0 }}</div>
<div class="stat-label">已提交</div>
</div>
</div>
</el-card>
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<div class="stat-icon bg-purple-100">
<el-icon class="text-purple-500 text-2xl"><CircleCheck /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value text-purple-600">{{ overallStats.approvedCount || 0 }}</div>
<div class="stat-label">已审核</div>
</div>
</div>
</el-card>
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<div class="stat-icon bg-orange-100">
<el-icon class="text-orange-500 text-2xl"><TrendCharts /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value text-orange-600">{{ (overallStats.completionRate || 0).toFixed(1) }}%</div>
<div class="stat-label">完成率</div>
</div>
</div>
</el-card>
</div>
<!-- 图表区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- 完成度饼图 -->
<el-card shadow="hover">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-blue-500"><PieChart /></el-icon>
<span>完成度分布</span>
</div>
</template>
<div ref="completionChartRef" style="width: 100%; height: 300px;"></div>
</el-card>
<!-- 进度趋势图 -->
<el-card shadow="hover">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-green-500"><TrendCharts /></el-icon>
<span>提交趋势</span>
</div>
</template>
<div ref="trendChartRef" style="width: 100%; height: 300px;"></div>
</el-card>
</div>
<!-- 学院统计表格 -->
<el-card shadow="hover" class="mb-6">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-purple-500"><DataAnalysis /></el-icon>
<span>学院完成情况</span>
</div>
</template>
<el-table :data="collegeStats" v-loading="loading" stripe>
<el-table-column prop="collegeName" label="学院名称" align="center" min-width="150" />
<el-table-column prop="totalStudents" label="目标学生" align="center" width="100" />
<el-table-column prop="submittedCount" label="已提交" align="center" width="100" />
<el-table-column prop="approvedCount" label="已审核" align="center" width="100" />
<el-table-column prop="rejectedCount" label="被驳回" align="center" width="100" />
<el-table-column prop="pendingCount" label="待审核" align="center" width="100" />
<el-table-column label="提交率" align="center" width="120">
<template #default="scope">
<el-progress
:percentage="scope.row.submitRate || 0"
:stroke-width="8"
:color="getProgressColor(scope.row.submitRate || 0)"
/>
</template>
</el-table-column>
<el-table-column label="完成率" align="center" width="120">
<template #default="scope">
<el-progress
:percentage="scope.row.completionRate || 0"
:stroke-width="8"
:color="getProgressColor(scope.row.completionRate || 0)"
/>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 多维度统计 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 年级分布 -->
<el-card shadow="hover">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-cyan-500"><School /></el-icon>
<span>年级分布</span>
</div>
</template>
<div ref="gradeChartRef" style="width: 100%; height: 250px;"></div>
</el-card>
<!-- 专业分布 -->
<el-card shadow="hover">
<template #header>
<div class="flex items-center">
<el-icon class="mr-2 text-orange-500"><Reading /></el-icon>
<span>专业分布</span>
</div>
</template>
<div ref="majorChartRef" style="width: 100%; height: 250px;"></div>
</el-card>
</div>
</el-card>
</div>
</template>
<script setup name="CollectionBatchStatistics" lang="ts">
import {
getActiveBatches,
getBatchStatistics,
getOverallStatistics,
getBatchCompletionStatistics,
getBatchDimensionStatistics
} from '@/api/xg-xx/collectionBatch';
import {
CollectionBatchVO,
BatchStatistics,
BatchCompletionStatistics,
CollegeCompletionStatistics,
BatchDimensionStatistics
} from '@/api/xg-xx/types';
import * as echarts from 'echarts';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const route = useRoute();
const router = useRouter();
const batchList = ref<CollectionBatchVO[]>([]);
const selectedBatch = ref<number>();
const loading = ref(false);
const overallStats = ref<BatchStatistics>({});
const completionStats = ref<BatchCompletionStatistics>({});
const collegeStats = ref<CollegeCompletionStatistics[]>([]);
const gradeStats = ref<BatchDimensionStatistics>({});
const majorStats = ref<BatchDimensionStatistics>({});
// 图表引用
const completionChartRef = ref<HTMLDivElement>();
const trendChartRef = ref<HTMLDivElement>();
const gradeChartRef = ref<HTMLDivElement>();
const majorChartRef = ref<HTMLDivElement>();
// 图表实例
let completionChart: echarts.ECharts;
let trendChart: echarts.ECharts;
let gradeChart: echarts.ECharts;
let majorChart: echarts.ECharts;
/** 获取批次列表 */
const getBatchList = async () => {
try {
const res = await getActiveBatches();
batchList.value = res.data;
// 如果有路由参数,设置选中的批次
const batchId = route.query.batchId as string;
if (batchId) {
selectedBatch.value = parseInt(batchId);
} else if (batchList.value.length > 0) {
selectedBatch.value = batchList.value[0].id;
}
} catch (error) {
proxy?.$modal.msgError('获取批次列表失败');
}
};
/** 获取整体统计 */
const getOverallStats = async () => {
try {
const res = await getOverallStatistics();
overallStats.value = res.data;
} catch (error) {
proxy?.$modal.msgError('获取整体统计失败');
}
};
/** 获取批次完成统计 */
const getBatchCompletionStats = async () => {
if (!selectedBatch.value) return;
try {
loading.value = true;
const res = await getBatchCompletionStatistics(selectedBatch.value);
completionStats.value = res.data;
collegeStats.value = res.data.collegeStatistics || [];
} catch (error) {
proxy?.$modal.msgError('获取批次完成统计失败');
} finally {
loading.value = false;
}
};
/** 获取维度统计 */
const getDimensionStats = async () => {
if (!selectedBatch.value) return;
try {
const [gradeRes, majorRes] = await Promise.all([
getBatchDimensionStatistics(selectedBatch.value, 'grade'),
getBatchDimensionStatistics(selectedBatch.value, 'major')
]);
gradeStats.value = gradeRes.data;
majorStats.value = majorRes.data;
} catch (error) {
proxy?.$modal.msgError('获取维度统计失败');
}
};
/** 批次变更处理 */
const handleBatchChange = () => {
loadStatistics();
};
/** 加载统计数据 */
const loadStatistics = async () => {
await Promise.all([
getBatchCompletionStats(),
getDimensionStats()
]);
// 更新图表
nextTick(() => {
updateCharts();
});
};
/** 初始化图表 */
const initCharts = () => {
if (completionChartRef.value) {
completionChart = echarts.init(completionChartRef.value);
}
if (trendChartRef.value) {
trendChart = echarts.init(trendChartRef.value);
}
if (gradeChartRef.value) {
gradeChart = echarts.init(gradeChartRef.value);
}
if (majorChartRef.value) {
majorChart = echarts.init(majorChartRef.value);
}
};
/** 更新图表 */
const updateCharts = () => {
updateCompletionChart();
updateTrendChart();
updateGradeChart();
updateMajorChart();
};
/** 更新完成度饼图 */
const updateCompletionChart = () => {
if (!completionChart || !completionStats.value) return;
const option = {
title: {
text: '完成度分布',
left: 'center'
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
name: '完成情况',
type: 'pie',
radius: '50%',
data: [
{ value: completionStats.value.approvedCount || 0, name: '已审核' },
{ value: completionStats.value.pendingCount || 0, name: '待审核' },
{ value: completionStats.value.rejectedCount || 0, name: '被驳回' },
{
value: (completionStats.value.totalStudents || 0) - (completionStats.value.submittedCount || 0),
name: '未提交'
}
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
};
completionChart.setOption(option);
};
/** 更新趋势图 */
const updateTrendChart = () => {
if (!trendChart) return;
// 模拟趋势数据
const dates = [];
const submitted = [];
const approved = [];
for (let i = 7; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
dates.push(date.toISOString().split('T')[0]);
submitted.push(Math.floor(Math.random() * 100) + (completionStats.value.submittedCount || 0) * (7 - i) / 7);
approved.push(Math.floor(Math.random() * 80) + (completionStats.value.approvedCount || 0) * (7 - i) / 7);
}
const option = {
title: {
text: '提交趋势',
left: 'center'
},
tooltip: {
trigger: 'axis'
},
legend: {
data: ['已提交', '已审核']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates
},
yAxis: {
type: 'value'
},
series: [
{
name: '已提交',
type: 'line',
stack: 'Total',
data: submitted,
smooth: true,
lineStyle: {
color: '#67c23a'
},
areaStyle: {
color: 'rgba(103, 194, 58, 0.3)'
}
},
{
name: '已审核',
type: 'line',
stack: 'Total',
data: approved,
smooth: true,
lineStyle: {
color: '#409eff'
},
areaStyle: {
color: 'rgba(64, 158, 255, 0.3)'
}
}
]
};
trendChart.setOption(option);
};
/** 更新年级图表 */
const updateGradeChart = () => {
if (!gradeChart || !gradeStats.value.items) return;
const option = {
title: {
text: '年级分布',
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: gradeStats.value.items.map(item => item.label)
},
yAxis: {
type: 'value'
},
series: [
{
data: gradeStats.value.items.map(item => item.count),
type: 'bar',
itemStyle: {
color: '#5470c6'
}
}
]
};
gradeChart.setOption(option);
};
/** 更新专业图表 */
const updateMajorChart = () => {
if (!majorChart || !majorStats.value.items) return;
const option = {
title: {
text: '专业分布',
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: majorStats.value.items.map(item => item.label),
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value'
},
series: [
{
data: majorStats.value.items.map(item => item.count),
type: 'bar',
itemStyle: {
color: '#91cc75'
}
}
]
};
majorChart.setOption(option);
};
/** 获取进度条颜色 */
const getProgressColor = (percentage: number) => {
if (percentage < 30) return '#f56c6c';
if (percentage < 70) return '#e6a23c';
return '#67c23a';
};
/** 返回 */
const goBack = () => {
router.push('/xg-xx/collectionBatch');
};
/** 窗口大小变化处理 */
const handleResize = () => {
completionChart?.resize();
trendChart?.resize();
gradeChart?.resize();
majorChart?.resize();
};
onMounted(async () => {
await getBatchList();
await getOverallStats();
nextTick(() => {
initCharts();
if (selectedBatch.value) {
loadStatistics();
}
});
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
completionChart?.dispose();
trendChart?.dispose();
gradeChart?.dispose();
majorChart?.dispose();
});
watch(selectedBatch, (newVal) => {
if (newVal) {
loadStatistics();
}
});
</script>
<style scoped>
.page-container {
background-color: #f5f7fa;
}
.stat-card {
height: 120px;
}
.stat-item {
display: flex;
align-items: center;
height: 100%;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 24px;
font-weight: bold;
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #909399;
}
.grid {
display: grid;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.gap-4 {
gap: 1rem;
}
.gap-6 {
gap: 1.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.mr-2 {
margin-right: 0.5rem;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.space-x-2 > * + * {
margin-left: 0.5rem;
}
.text-lg {
font-size: 1.125rem;
}
.text-2xl {
font-size: 1.5rem;
}
.font-semibold {
font-weight: 600;
}
.bg-blue-100 {
background-color: #e3f2fd;
}
.bg-green-100 {
background-color: #e8f5e8;
}
.bg-purple-100 {
background-color: #f3e5f5;
}
.bg-orange-100 {
background-color: #fff3e0;
}
.text-blue-500 {
color: #3b82f6;
}
.text-green-500 {
color: #10b981;
}
.text-purple-500 {
color: #8b5cf6;
}
.text-orange-500 {
color: #f59e0b;
}
.text-cyan-500 {
color: #06b6d4;
}
.text-blue-600 {
color: #2563eb;
}
.text-green-600 {
color: #059669;
}
.text-purple-600 {
color: #7c3aed;
}
.text-orange-600 {
color: #ea580c;
}
@media (min-width: 768px) {
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.lg\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lg\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,476 @@
<template>
<div class="page-container">
<h2>信息采集批次管理 - 样式测试</h2>
<!-- 统计卡片测试 -->
<el-row :gutter="10" class="mb-8">
<el-col :span="6">
<el-card class="stat-card stat-card-primary" shadow="hover">
<div class="stat-content">
<div class="stat-icon">
<el-icon><Collection /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">12</div>
<div class="stat-label">总批次数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card stat-card-success" shadow="hover">
<div class="stat-content">
<div class="stat-icon">
<el-icon><Select /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">5</div>
<div class="stat-label">进行中批次</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card stat-card-warning" shadow="hover">
<div class="stat-content">
<div class="stat-icon">
<el-icon><User /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">1,234</div>
<div class="stat-label">参与学生数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card stat-card-info" shadow="hover">
<div class="stat-content">
<div class="stat-icon">
<el-icon><DocumentCopy /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">7</div>
<div class="stat-label">已完成批次</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 按钮卡片测试 -->
<el-card class="button-card" shadow="never">
<template #header>
<div class="card-header">
<span class="toolbar-title">信息采集批次管理</span>
<div class="toolbar-actions">
<el-button type="primary">
<el-icon><Plus /></el-icon>
新增批次
</el-button>
<el-button type="success">
<el-icon><Download /></el-icon>
导出数据
</el-button>
</div>
</div>
</template>
</el-card>
<!-- 表格测试 -->
<el-card class="table-card" shadow="never">
<el-table :data="testData" class="scroll-table" stripe>
<el-table-column label="批次名称" prop="batchName" />
<el-table-column label="批次类型" prop="batchType" width="120" align="center">
<template #default="scope">
<el-tag type="primary" size="small">{{ scope.row.batchType }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" prop="batchStatus" width="120" align="center">
<template #default="scope">
<el-tag type="success" size="small">{{ getStatusText(scope.row.batchStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="学生数" prop="totalStudents" width="120" align="center">
<template #default="scope">
<span class="student-count">{{ scope.row.totalStudents }}</span>
</template>
</el-table-column>
<el-table-column label="完成率" prop="completionRate" width="150" align="center">
<template #default="scope">
<el-progress
:percentage="scope.row.completionRate"
:stroke-width="6"
:show-text="false"
color="#5d87ff"
/>
<div class="progress-text">{{ scope.row.completionRate }}%</div>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center">
<template #default="scope">
<div class="action-buttons">
<el-button type="primary" size="small" circle>
<el-icon><View /></el-icon>
</el-button>
<el-button type="warning" size="small" circle>
<el-icon><Edit /></el-icon>
</el-button>
<el-button type="danger" size="small" circle>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- API 测试结果 -->
<el-card class="api-test-card" shadow="never">
<template #header>
<div class="card-header">
<span class="toolbar-title">API 测试结果</span>
<el-button type="primary" @click="testApi">测试 API</el-button>
</div>
</template>
<div v-if="loading" class="loading">
<el-icon class="is-loading"><Loading /></el-icon>
正在加载...
</div>
<div v-else-if="apiResult">
<pre>{{ JSON.stringify(apiResult, null, 2) }}</pre>
</div>
<div v-else class="no-data">
点击"测试 API"按钮查看结果
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { listCollectionBatch } from '@/api/xg-xx/collectionBatch'
import {
Collection,
Select,
User,
DocumentCopy,
Plus,
Download,
View,
Edit,
Delete,
Loading
} from '@element-plus/icons-vue'
const loading = ref(false)
const apiResult = ref(null)
// 状态处理方法
const getStatusText = (status: string) => {
const statusMap = {
'1': '草稿',
'2': '发布',
'3': '进行中',
'4': '已结束',
'5': '已归档'
}
return statusMap[status] || status
}
const testData = ref([
{
batchName: '2024春季学生信息采集',
batchType: '学生信息',
batchStatus: '3',
totalStudents: 1234,
completionRate: 75
},
{
batchName: '2024家庭信息补充',
batchType: '家庭信息',
batchStatus: '4',
totalStudents: 856,
completionRate: 100
},
{
batchName: '2024其他信息收集',
batchType: '其他信息',
batchStatus: '1',
totalStudents: 0,
completionRate: 0
}
])
const testApi = async () => {
loading.value = true
try {
console.log('开始测试 API...')
const response = await listCollectionBatch({
pageNum: 1,
pageSize: 10
})
console.log('API 响应:', response)
apiResult.value = response
} catch (error) {
console.error('API 测试失败:', error)
apiResult.value = { error: (error as Error).message }
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* 页面容器样式 */
.page-container {
padding: 8px;
}
/* 统计卡片样式 - 参考后勤管理模块 */
.stat-card {
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
height: 90px;
border: none;
overflow: hidden;
position: relative;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
}
.stat-content {
display: flex;
align-items: center;
padding: 20px;
height: 100%;
box-sizing: border-box;
position: relative;
z-index: 1;
.stat-icon {
font-size: 28px;
margin-right: 16px;
opacity: 0.9;
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
}
.stat-info {
flex: 1;
.stat-value {
font-size: 24px;
font-weight: 700;
line-height: 1;
margin-bottom: 6px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 14px;
opacity: 0.9;
line-height: 1;
font-weight: 500;
}
}
}
/* 渐变背景 - 参考后勤管理模块 */
&.stat-card-primary {
background: linear-gradient(135deg, rgb(93, 135, 255) 0%, rgb(73, 190, 255) 100%);
color: white;
}
&.stat-card-success {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
&.stat-card-warning {
background: linear-gradient(135deg, #ff9a56 0%, #ffad56 100%);
color: white;
}
&.stat-card-info {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
color: #333;
}
/* 添加光泽效果 */
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
}
}
/* 按钮卡片样式 */
.button-card {
margin: 10px 0;
border-radius: 12px;
border: 1px solid #e4e7ed;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
:deep(.el-card__header) {
border-bottom: 1px solid rgba(93, 135, 255, 0.1);
background: linear-gradient(135deg, #fafbff 0%, #f8fafc 100%);
padding: 16px 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.toolbar-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
&::before {
content: '';
width: 4px;
height: 20px;
background: linear-gradient(135deg, rgb(93, 135, 255), rgb(73, 190, 255));
border-radius: 2px;
margin-right: 12px;
}
}
.toolbar-actions {
display: flex;
gap: 12px;
}
}
}
/* 表格卡片样式 */
.table-card {
margin: 10px 0;
border-radius: 12px;
border: 1px solid #e4e7ed;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
:deep(.el-card__body) {
padding: 20px;
}
}
/* API 测试卡片样式 */
.api-test-card {
margin: 10px 0;
border-radius: 12px;
border: 1px solid #e4e7ed;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
:deep(.el-card__header) {
border-bottom: 1px solid rgba(93, 135, 255, 0.1);
background: linear-gradient(135deg, #fafbff 0%, #f8fafc 100%);
padding: 16px 20px;
}
:deep(.el-card__body) {
padding: 20px;
}
}
/* 表格样式 - 参考后勤管理模块 */
.scroll-table {
border-radius: 8px;
overflow: hidden;
:deep(.el-table__header-wrapper) {
.el-table__header {
th {
background: linear-gradient(135deg, #fafbff 0%, #f8fafc 100%);
color: #1e293b;
font-weight: 600;
border-bottom: 1px solid rgba(93, 135, 255, 0.1);
}
}
}
:deep(.el-table__body-wrapper) {
.el-table__row {
transition: all 0.3s ease;
&:hover {
background-color: rgba(93, 135, 255, 0.04) !important;
}
}
}
}
/* 学生数量样式 */
.student-count {
font-weight: 600;
color: rgb(93, 135, 255);
}
/* 进度条样式 */
.progress-text {
font-size: 12px;
color: #606266;
margin-top: 4px;
text-align: center;
}
/* 操作按钮样式 */
.action-buttons {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
.el-button {
width: 32px !important;
height: 32px !important;
padding: 0 !important;
border-radius: 8px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
}
/* 加载和无数据样式 */
.loading {
text-align: center;
padding: 40px;
color: #909399;
}
.no-data {
text-align: center;
padding: 40px;
color: #909399;
}
/* 代码显示样式 */
pre {
background: #f5f7fa;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-size: 12px;
color: #333;
}
</style>
@@ -0,0 +1,567 @@
<template>
<el-dialog
title="缓存管理"
v-model="dialogVisible"
width="600px"
:before-close="handleClose"
append-to-body
>
<div class="cache-manage-container" v-loading="loading">
<!-- 缓存统计信息 -->
<div class="cache-stats">
<div class="stats-header">
<h3>缓存统计信息</h3>
<el-button type="primary" icon="Refresh" @click="refreshCacheInfo" :loading="refreshing">
刷新
</el-button>
</div>
<el-row :gutter="16" class="stats-cards">
<el-col :span="8">
<div class="stat-card hit-rate">
<div class="stat-icon">
<el-icon><TrendCharts /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ cacheInfo.hitRate }}%</div>
<div class="stat-label">命中率</div>
</div>
</div>
</el-col>
<el-col :span="8">
<div class="stat-card hit-count">
<div class="stat-icon">
<el-icon><Check /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ cacheInfo.hitCount }}</div>
<div class="stat-label">命中次数</div>
</div>
</div>
</el-col>
<el-col :span="8">
<div class="stat-card miss-count">
<div class="stat-icon">
<el-icon><Close /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ cacheInfo.missCount }}</div>
<div class="stat-label">未命中次数</div>
</div>
</div>
</el-col>
</el-row>
<div class="cache-details">
<el-descriptions :column="2" border>
<el-descriptions-item label="缓存大小">
{{ formatBytes(cacheInfo.size) }}
</el-descriptions-item>
<el-descriptions-item label="最后访问时间">
{{ formatTime(cacheInfo.lastAccessTime) }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatTime(cacheInfo.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="过期时间">
{{ formatTime(cacheInfo.expireTime) }}
</el-descriptions-item>
</el-descriptions>
</div>
</div>
<!-- 缓存操作 -->
<div class="cache-operations">
<div class="operations-header">
<h3>缓存操作</h3>
</div>
<div class="operations-content">
<div class="operation-item">
<div class="operation-info">
<div class="operation-title">清空缓存</div>
<div class="operation-desc">清空所有统计分析缓存数据,下次查询将重新计算</div>
</div>
<el-button
type="danger"
:loading="clearingCache"
@click="handleClearCache"
>
清空缓存
</el-button>
</div>
<div class="operation-item">
<div class="operation-info">
<div class="operation-title">预热缓存</div>
<div class="operation-desc">预先加载常用统计数据到缓存,提升查询性能</div>
</div>
<el-button
type="success"
:loading="warmingCache"
@click="handleWarmupCache"
>
预热缓存
</el-button>
</div>
</div>
</div>
<!-- 缓存配置 -->
<div class="cache-config">
<div class="config-header">
<h3>缓存配置</h3>
</div>
<el-form :model="cacheConfig" label-width="120px">
<el-form-item label="缓存时间">
<el-select v-model="cacheConfig.expireTime" @change="handleConfigChange">
<el-option label="5分钟" :value="5" />
<el-option label="10分钟" :value="10" />
<el-option label="30分钟" :value="30" />
<el-option label="1小时" :value="60" />
<el-option label="2小时" :value="120" />
<el-option label="6小时" :value="360" />
</el-select>
</el-form-item>
<el-form-item label="最大缓存数">
<el-input-number
v-model="cacheConfig.maxSize"
:min="10"
:max="1000"
:step="10"
@change="handleConfigChange"
/>
</el-form-item>
<el-form-item label="自动清理">
<el-switch
v-model="cacheConfig.autoClean"
@change="handleConfigChange"
/>
</el-form-item>
</el-form>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
<el-button type="primary" @click="handleSaveConfig" :loading="saving">
保存配置
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getCacheStatistics,
clearStatisticsCache,
warmupStatisticsCache,
type StatisticsCacheInfo
} from '@/api/xg-xx/statistics'
// Props
interface Props {
visible: boolean
}
const props = withDefaults(defineProps<Props>(), {
visible: false
})
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
// 响应式数据
const loading = ref(false)
const refreshing = ref(false)
const clearingCache = ref(false)
const warmingCache = ref(false)
const saving = ref(false)
// 缓存信息
const cacheInfo = reactive<StatisticsCacheInfo>({
key: '',
hitCount: 0,
missCount: 0,
hitRate: 0,
size: 0,
lastAccessTime: '',
createTime: '',
expireTime: ''
})
// 缓存配置
const cacheConfig = reactive({
expireTime: 30, // 分钟
maxSize: 100,
autoClean: true
})
// 计算属性
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
// 监听器
watch(() => props.visible, (newVal) => {
if (newVal) {
loadCacheInfo()
}
})
// 方法定义
const loadCacheInfo = async () => {
loading.value = true
try {
const response = await getCacheStatistics()
Object.assign(cacheInfo, response.data)
} catch (error) {
console.error('获取缓存信息失败:', error)
ElMessage.error('获取缓存信息失败')
} finally {
loading.value = false
}
}
const refreshCacheInfo = async () => {
refreshing.value = true
try {
const response = await getCacheStatistics()
Object.assign(cacheInfo, response.data)
ElMessage.success('缓存信息已刷新')
} catch (error) {
console.error('刷新缓存信息失败:', error)
ElMessage.error('刷新缓存信息失败')
} finally {
refreshing.value = false
}
}
const handleClearCache = async () => {
try {
await ElMessageBox.confirm(
'确定要清空所有统计分析缓存吗?此操作不可恢复。',
'清空缓存',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
clearingCache.value = true
await clearStatisticsCache()
ElMessage.success('缓存已清空')
await refreshCacheInfo()
} catch (error) {
if (error !== 'cancel') {
console.error('清空缓存失败:', error)
ElMessage.error('清空缓存失败')
}
} finally {
clearingCache.value = false
}
}
const handleWarmupCache = async () => {
try {
await ElMessageBox.confirm(
'确定要预热缓存吗?此操作可能需要一些时间。',
'预热缓存',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}
)
warmingCache.value = true
await warmupStatisticsCache()
ElMessage.success('缓存预热完成')
await refreshCacheInfo()
} catch (error) {
if (error !== 'cancel') {
console.error('预热缓存失败:', error)
ElMessage.error('预热缓存失败')
}
} finally {
warmingCache.value = false
}
}
const handleConfigChange = () => {
// 配置变更时的处理逻辑
console.log('缓存配置变更:', cacheConfig)
}
const handleSaveConfig = async () => {
saving.value = true
try {
// 这里应该调用保存配置的API
// await saveCacheConfig(cacheConfig)
// 模拟保存
await new Promise(resolve => setTimeout(resolve, 1000))
ElMessage.success('配置已保存')
} catch (error) {
console.error('保存配置失败:', error)
ElMessage.error('保存配置失败')
} finally {
saving.value = false
}
}
const handleClose = () => {
dialogVisible.value = false
}
// 工具方法
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const formatTime = (timeStr: string): string => {
if (!timeStr) return '-'
try {
const date = new Date(timeStr)
return date.toLocaleString('zh-CN')
} catch (error) {
return timeStr
}
}
// 生命周期
onMounted(() => {
if (props.visible) {
loadCacheInfo()
}
})
</script>
<style scoped>
.cache-manage-container {
max-height: 70vh;
overflow-y: auto;
}
.cache-stats {
margin-bottom: 24px;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.stats-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.stats-cards {
margin-bottom: 16px;
}
.stat-card {
display: flex;
align-items: center;
padding: 16px;
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 8px;
transition: all 0.3s ease;
}
.stat-card:hover {
border-color: rgba(93, 135, 255, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-card.hit-rate {
border-left: 4px solid #67c23a;
}
.stat-card.hit-count {
border-left: 4px solid #409eff;
}
.stat-card.miss-count {
border-left: 4px solid #f56c6c;
}
.stat-icon {
font-size: 24px;
margin-right: 12px;
opacity: 0.8;
}
.hit-rate .stat-icon {
color: #67c23a;
}
.hit-count .stat-icon {
color: #409eff;
}
.miss-count .stat-icon {
color: #f56c6c;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 18px;
font-weight: 600;
line-height: 1;
margin-bottom: 4px;
}
.hit-rate .stat-value {
color: #67c23a;
}
.hit-count .stat-value {
color: #409eff;
}
.miss-count .stat-value {
color: #f56c6c;
}
.stat-label {
font-size: 12px;
color: #909399;
line-height: 1;
}
.cache-details {
margin-top: 16px;
}
.cache-operations {
margin-bottom: 24px;
padding-top: 24px;
border-top: 1px solid #e4e7ed;
}
.operations-header h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.operations-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.operation-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.operation-info {
flex: 1;
}
.operation-title {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.operation-desc {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.cache-config {
padding-top: 24px;
border-top: 1px solid #e4e7ed;
}
.config-header h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.stats-cards {
flex-direction: column;
gap: 12px;
}
.operation-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.operation-item .el-button {
width: 100%;
}
}
/* 滚动条样式 */
.cache-manage-container::-webkit-scrollbar {
width: 6px;
}
.cache-manage-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.cache-manage-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.cache-manage-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
@@ -0,0 +1,278 @@
<template>
<el-dialog
title="配置详情"
v-model="dialogVisible"
width="600px"
:before-close="handleClose"
append-to-body
>
<div class="config-detail-container" v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item label="配置名称">
{{ configData?.configName || '-' }}
</el-descriptions-item>
<el-descriptions-item label="配置类型">
<el-tag v-if="configData?.configType === 'dimension'" type="primary">维度统计</el-tag>
<el-tag v-else-if="configData?.configType === 'report'" type="success">报表配置</el-tag>
<el-tag v-else-if="configData?.configType === 'chart'" type="info">图表配置</el-tag>
<el-tag v-else type="warning">其他</el-tag>
</el-descriptions-item>
<el-descriptions-item label="维度类型">
{{ getDimensionTypeName(configData?.dimensionType) }}
</el-descriptions-item>
<el-descriptions-item label="图表类型">
<el-tag v-if="configData?.chartType === 'bar'" size="small">柱状图</el-tag>
<el-tag v-else-if="configData?.chartType === 'pie'" size="small" type="success">饼图</el-tag>
<el-tag v-else-if="configData?.chartType === 'line'" size="small" type="info">折线图</el-tag>
<el-tag v-else-if="configData?.chartType === 'doughnut'" size="small" type="warning">环形图</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag v-if="configData?.isEnabled" type="success">启用</el-tag>
<el-tag v-else type="danger">禁用</el-tag>
</el-descriptions-item>
<el-descriptions-item label="排序序号">
{{ configData?.sortOrder || 0 }}
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">
{{ formatTime(configData?.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">
{{ formatTime(configData?.updateTime) }}
</el-descriptions-item>
</el-descriptions>
<div class="config-json-section" v-if="configData?.queryConditions">
<h3>查询条件</h3>
<div class="json-viewer">
<pre>{{ formatJson(configData.queryConditions) }}</pre>
</div>
</div>
<div class="config-json-section" v-if="configData?.displaySettings">
<h3>显示设置</h3>
<div class="json-viewer">
<pre>{{ formatJson(configData.displaySettings) }}</pre>
</div>
</div>
<div class="config-remark-section" v-if="configData?.remark">
<h3>备注</h3>
<div class="remark-content">
{{ configData.remark }}
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { StatisticsConfigVo } from '@/api/xg-xx/statistics'
// Props
interface Props {
visible: boolean
configData?: StatisticsConfigVo | null
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
configData: null
})
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
// 响应式数据
const loading = false
// 计算属性
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
// 方法定义
const getDimensionTypeName = (type?: string): string => {
const dimensionMap: Record<string, string> = {
college: '学院统计',
major: '专业统计',
class: '班级统计',
grade: '年级统计',
gender: '性别统计',
age: '年龄统计',
political: '政治面貌统计',
nation: '民族统计',
sourceRegion: '生源地统计',
bloodType: '血型统计',
religion: '宗教信仰统计',
studentStatus: '学籍状态统计',
trainingType: '培养方式统计'
}
return type ? dimensionMap[type] || type : '-'
}
const formatTime = (timeStr?: string): string => {
if (!timeStr) return '-'
try {
const date = new Date(timeStr)
return date.toLocaleString('zh-CN')
} catch (error) {
return timeStr
}
}
const formatJson = (jsonStr?: string): string => {
if (!jsonStr) return ''
try {
const parsed = JSON.parse(jsonStr)
return JSON.stringify(parsed, null, 2)
} catch (error) {
return jsonStr
}
}
const handleClose = () => {
dialogVisible.value = false
}
</script>
<style scoped>
.config-detail-container {
max-height: 70vh;
overflow-y: auto;
}
.config-json-section {
margin-top: 24px;
}
.config-json-section h3 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.json-viewer {
background: #f8f9fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 16px;
overflow-x: auto;
}
.json-viewer pre {
margin: 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
line-height: 1.5;
color: #303133;
white-space: pre-wrap;
word-break: break-all;
}
.config-remark-section {
margin-top: 24px;
}
.config-remark-section h3 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.remark-content {
background: #f8f9fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 16px;
line-height: 1.6;
color: #303133;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 描述列表样式优化 */
:deep(.el-descriptions__header) {
margin-bottom: 16px;
}
:deep(.el-descriptions__title) {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
:deep(.el-descriptions__body) {
.el-descriptions__table {
border-radius: 6px;
overflow: hidden;
}
.el-descriptions__label {
background: #f8f9fa;
color: #606266;
font-weight: 500;
}
.el-descriptions__content {
background: #ffffff;
color: #303133;
}
}
/* 滚动条样式 */
.config-detail-container::-webkit-scrollbar {
width: 6px;
}
.config-detail-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.config-detail-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.config-detail-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.json-viewer::-webkit-scrollbar {
height: 6px;
}
.json-viewer::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.json-viewer::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.json-viewer::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
@@ -0,0 +1,359 @@
<template>
<el-dialog
:title="isEdit ? '编辑配置' : '新增配置'"
v-model="dialogVisible"
width="600px"
:before-close="handleClose"
append-to-body
>
<el-form
ref="configForm"
:model="form"
:rules="rules"
label-width="100px"
v-loading="loading"
>
<el-form-item label="配置名称" prop="configName">
<el-input
v-model="form.configName"
placeholder="请输入配置名称"
clearable
/>
</el-form-item>
<el-form-item label="配置类型" prop="configType">
<el-select v-model="form.configType" placeholder="请选择配置类型" @change="handleConfigTypeChange">
<el-option label="维度统计" value="dimension" />
<el-option label="报表配置" value="report" />
<el-option label="图表配置" value="chart" />
</el-select>
</el-form-item>
<el-form-item label="维度类型" prop="dimensionType" v-if="form.configType === 'dimension'">
<el-select v-model="form.dimensionType" placeholder="请选择维度类型">
<el-option label="学院统计" value="college" />
<el-option label="专业统计" value="major" />
<el-option label="班级统计" value="class" />
<el-option label="年级统计" value="grade" />
<el-option label="性别统计" value="gender" />
<el-option label="年龄统计" value="age" />
<el-option label="政治面貌统计" value="political" />
<el-option label="民族统计" value="nation" />
<el-option label="生源地统计" value="sourceRegion" />
<el-option label="血型统计" value="bloodType" />
<el-option label="宗教信仰统计" value="religion" />
<el-option label="学籍状态统计" value="studentStatus" />
<el-option label="培养方式统计" value="trainingType" />
</el-select>
</el-form-item>
<el-form-item label="图表类型" prop="chartType">
<el-select v-model="form.chartType" placeholder="请选择图表类型">
<el-option label="柱状图" value="bar" />
<el-option label="饼图" value="pie" />
<el-option label="折线图" value="line" />
<el-option label="环形图" value="doughnut" />
</el-select>
</el-form-item>
<el-form-item label="查询条件" prop="queryConditions">
<el-input
v-model="form.queryConditions"
type="textarea"
:rows="3"
placeholder="请输入查询条件(JSON格式)"
/>
</el-form-item>
<el-form-item label="显示设置" prop="displaySettings">
<el-input
v-model="form.displaySettings"
type="textarea"
:rows="3"
placeholder="请输入显示设置(JSON格式)"
/>
</el-form-item>
<el-form-item label="排序序号" prop="sortOrder">
<el-input-number
v-model="form.sortOrder"
:min="0"
:max="999"
placeholder="排序序号"
/>
</el-form-item>
<el-form-item label="状态" prop="isEnabled">
<el-switch
v-model="form.isEnabled"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
addStatisticsConfig,
updateStatisticsConfig,
getStatisticsConfig,
type StatisticsConfigVo,
type StatisticsConfigBo
} from '@/api/xg-xx/statistics'
// Props
interface Props {
visible: boolean
configData?: StatisticsConfigVo | null
isEdit?: boolean
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
configData: null,
isEdit: false
})
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean]
'refresh': []
}>()
// 响应式数据
const loading = ref(false)
const configForm = ref()
// 表单数据
const form = reactive<StatisticsConfigBo>({
id: undefined,
configName: '',
configType: '',
dimensionType: '',
queryConditions: '',
displaySettings: '',
chartType: '',
isEnabled: true,
sortOrder: 0,
remark: ''
})
// 表单验证规则
const rules = {
configName: [
{ required: true, message: '请输入配置名称', trigger: 'blur' },
{ min: 2, max: 50, message: '配置名称长度在 2 到 50 个字符', trigger: 'blur' }
],
configType: [
{ required: true, message: '请选择配置类型', trigger: 'change' }
],
dimensionType: [
{ required: true, message: '请选择维度类型', trigger: 'change' }
],
chartType: [
{ required: true, message: '请选择图表类型', trigger: 'change' }
],
queryConditions: [
{ validator: validateJson, trigger: 'blur' }
],
displaySettings: [
{ validator: validateJson, trigger: 'blur' }
],
sortOrder: [
{ type: 'number' as const, message: '排序序号必须是数字', trigger: 'blur' }
]
}
// 计算属性
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
// 监听器
watch(() => props.visible, (newVal) => {
if (newVal) {
resetForm()
if (props.isEdit && props.configData) {
loadConfigData()
}
}
})
// 方法定义
function validateJson(rule: any, value: string, callback: any) {
if (!value) {
callback()
return
}
try {
JSON.parse(value)
callback()
} catch (error) {
callback(new Error('请输入有效的JSON格式'))
}
}
const resetForm = () => {
Object.assign(form, {
id: undefined,
configName: '',
configType: '',
dimensionType: '',
queryConditions: '',
displaySettings: '',
chartType: '',
isEnabled: true,
sortOrder: 0,
remark: ''
})
configForm.value?.clearValidate()
}
const loadConfigData = async () => {
if (!props.configData?.id) return
loading.value = true
try {
const response = await getStatisticsConfig(props.configData.id)
const data = response.data
Object.assign(form, {
id: data.id,
configName: data.configName,
configType: data.configType,
dimensionType: data.dimensionType,
queryConditions: data.queryConditions,
displaySettings: data.displaySettings,
chartType: data.chartType,
isEnabled: data.isEnabled,
sortOrder: data.sortOrder,
remark: data.remark
})
} catch (error) {
console.error('加载配置数据失败:', error)
ElMessage.error('加载配置数据失败')
} finally {
loading.value = false
}
}
const handleConfigTypeChange = (value: string) => {
if (value !== 'dimension') {
form.dimensionType = ''
}
}
const handleSubmit = async () => {
if (!configForm.value) return
try {
await configForm.value.validate()
} catch (error) {
return
}
loading.value = true
try {
if (props.isEdit) {
await updateStatisticsConfig(form)
ElMessage.success('配置更新成功')
} else {
await addStatisticsConfig(form)
ElMessage.success('配置创建成功')
}
emit('refresh')
handleClose()
} catch (error) {
console.error('保存配置失败:', error)
ElMessage.error('保存配置失败')
} finally {
loading.value = false
}
}
const handleClose = () => {
dialogVisible.value = false
}
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 表单样式优化 */
:deep(.el-form-item__label) {
font-weight: 500;
color: #1e293b;
}
:deep(.el-input__wrapper) {
border-radius: 6px;
transition: all 0.3s ease;
}
:deep(.el-input__wrapper:hover) {
border-color: rgba(93, 135, 255, 0.6);
}
:deep(.el-input__wrapper.is-focus) {
border-color: rgb(93, 135, 255);
box-shadow: 0 0 0 2px rgba(93, 135, 255, 0.2);
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-textarea__inner) {
border-radius: 6px;
transition: all 0.3s ease;
}
:deep(.el-textarea__inner:hover) {
border-color: rgba(93, 135, 255, 0.6);
}
:deep(.el-textarea__inner:focus) {
border-color: rgb(93, 135, 255);
box-shadow: 0 0 0 2px rgba(93, 135, 255, 0.2);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-switch.is-checked .el-switch__core) {
background-color: rgb(93, 135, 255);
border-color: rgb(93, 135, 255);
}
</style>
@@ -0,0 +1,539 @@
<template>
<el-dialog
title="统计配置管理"
v-model="dialogVisible"
width="80%"
:before-close="handleClose"
append-to-body
class="config-manage-dialog"
>
<div class="config-manage-container">
<!-- 搜索区域 -->
<div class="search-section">
<el-form :model="queryParams" ref="queryRef" :inline="true" label-width="80px">
<el-form-item label="配置名称" prop="configName">
<el-input
v-model="queryParams.configName"
placeholder="请输入配置名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="配置类型" prop="configType">
<el-select v-model="queryParams.configType" placeholder="请选择配置类型" clearable>
<el-option label="维度统计" value="dimension" />
<el-option label="报表配置" value="report" />
<el-option label="图表配置" value="chart" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="isEnabled">
<el-select v-model="queryParams.isEnabled" placeholder="请选择状态" clearable>
<el-option label="启用" :value="true" />
<el-option label="禁用" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 操作按钮 -->
<div class="toolbar-section">
<div class="toolbar-left">
<el-button type="primary" icon="Plus" @click="handleAdd">新增配置</el-button>
<el-button type="success" icon="Edit" :disabled="single" @click="handleUpdate">修改</el-button>
<el-button type="danger" icon="Delete" :disabled="multiple" @click="handleDelete">删除</el-button>
</div>
<div class="toolbar-right">
<el-button type="info" icon="Download" @click="handleExport">导出</el-button>
</div>
</div>
<!-- 配置列表 -->
<div class="table-section">
<el-table
v-loading="loading"
:data="configList"
@selection-change="handleSelectionChange"
class="config-table"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="配置名称" prop="configName" width="150" />
<el-table-column label="配置类型" prop="configType" width="100">
<template #default="scope">
<el-tag v-if="scope.row.configType === 'dimension'" type="primary">维度统计</el-tag>
<el-tag v-else-if="scope.row.configType === 'report'" type="success">报表配置</el-tag>
<el-tag v-else-if="scope.row.configType === 'chart'" type="info">图表配置</el-tag>
<el-tag v-else type="warning">其他</el-tag>
</template>
</el-table-column>
<el-table-column label="维度类型" prop="dimensionType" width="120" />
<el-table-column label="图表类型" prop="chartType" width="100">
<template #default="scope">
<el-tag v-if="scope.row.chartType === 'bar'" size="small">柱状图</el-tag>
<el-tag v-else-if="scope.row.chartType === 'pie'" size="small" type="success">饼图</el-tag>
<el-tag v-else-if="scope.row.chartType === 'line'" size="small" type="info">折线图</el-tag>
<el-tag v-else-if="scope.row.chartType === 'doughnut'" size="small" type="warning">环形图</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" prop="isEnabled" width="80" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.isEnabled"
@change="handleStatusChange(scope.row)"
/>
</template>
</el-table-column>
<el-table-column label="排序" prop="sortOrder" width="80" align="center" />
<el-table-column label="创建时间" prop="createTime" width="160" />
<el-table-column label="备注" prop="remark" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="scope">
<div class="action-buttons">
<el-button
type="primary"
icon="View"
size="small"
@click="handleView(scope.row)"
title="查看"
/>
<el-button
type="success"
icon="Edit"
size="small"
@click="handleUpdate(scope.row)"
title="编辑"
/>
<el-button
type="danger"
icon="Delete"
size="small"
@click="handleDelete(scope.row)"
title="删除"
/>
</div>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="pagination-section">
<el-pagination
v-show="total > 0"
:current-page="queryParams.pageNum"
:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 配置编辑对话框 -->
<ConfigEditDialog
:visible="editVisible"
:config-data="selectedConfig"
:is-edit="isEdit"
@update:visible="(val) => editVisible = val"
@refresh="getList"
/>
<!-- 配置详情对话框 -->
<ConfigDetailDialog
:visible="detailVisible"
:config-data="selectedConfig"
@update:visible="(val) => detailVisible = val"
/>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listStatisticsConfig,
delStatisticsConfig,
changeStatisticsConfigStatus,
exportStatisticsConfig,
type StatisticsQuery,
type StatisticsConfigVo
} from '@/api/xg-xx/statistics'
import ConfigEditDialog from './ConfigEditDialog.vue'
import ConfigDetailDialog from './ConfigDetailDialog.vue'
// Props
interface Props {
visible: boolean
}
const props = withDefaults(defineProps<Props>(), {
visible: false
})
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean]
'refresh': []
}>()
// 响应式数据
const loading = ref(false)
const queryRef = ref()
// 查询参数
const queryParams = reactive<StatisticsQuery>({
pageNum: 1,
pageSize: 10,
configName: '',
configType: '',
isEnabled: undefined
})
// 列表数据
const configList = ref<StatisticsConfigVo[]>([])
const total = ref(0)
// 选择数据
const selectedIds = ref<number[]>([])
const selectedRows = ref<StatisticsConfigVo[]>([])
const single = ref(true)
const multiple = ref(true)
// 对话框状态
const editVisible = ref(false)
const detailVisible = ref(false)
const selectedConfig = ref<StatisticsConfigVo | null>(null)
const isEdit = ref(false)
// 计算属性
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
// 方法定义
const getList = async () => {
loading.value = true
try {
const response = await listStatisticsConfig(queryParams)
configList.value = response.rows
total.value = response.total
} catch (error) {
console.error('获取配置列表失败:', error)
ElMessage.error('获取配置列表失败')
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNum = 1
getList()
}
const resetQuery = () => {
queryRef.value?.resetFields()
queryParams.pageNum = 1
queryParams.pageSize = 10
queryParams.configName = ''
queryParams.configType = ''
queryParams.isEnabled = undefined
getList()
}
const handleSelectionChange = (selection: StatisticsConfigVo[]) => {
selectedIds.value = selection.map(item => item.id!).filter(id => id !== undefined)
selectedRows.value = selection
single.value = selection.length !== 1
multiple.value = !selection.length
}
const handleAdd = () => {
selectedConfig.value = null
isEdit.value = false
editVisible.value = true
}
const handleUpdate = (row?: StatisticsConfigVo) => {
const config = row || selectedRows.value[0]
selectedConfig.value = config
isEdit.value = true
editVisible.value = true
}
const handleView = (row: StatisticsConfigVo) => {
selectedConfig.value = row
detailVisible.value = true
}
const handleDelete = async (row?: StatisticsConfigVo) => {
const ids = row ? [row.id!] : selectedIds.value
const names = row ? [row.configName!] : selectedRows.value.map(item => item.configName!)
try {
await ElMessageBox.confirm(
`确定要删除配置"${names.join('、')}"吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await delStatisticsConfig(ids)
ElMessage.success('删除成功')
getList()
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
}
}
const handleStatusChange = async (row: StatisticsConfigVo) => {
try {
await changeStatisticsConfigStatus(row.id!, row.isEnabled!)
ElMessage.success(`${row.isEnabled ? '启用' : '禁用'}成功`)
} catch (error) {
console.error('状态变更失败:', error)
ElMessage.error('状态变更失败')
// 恢复原状态
row.isEnabled = !row.isEnabled
}
}
const handleExport = async () => {
try {
const response = await exportStatisticsConfig(queryParams)
// 处理文件下载
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `统计配置_${new Date().toISOString().slice(0, 10)}.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败')
}
}
const handleSizeChange = (size: number) => {
queryParams.pageSize = size
getList()
}
const handleCurrentChange = (page: number) => {
queryParams.pageNum = page
getList()
}
const handleClose = () => {
dialogVisible.value = false
}
// 生命周期
onMounted(() => {
if (props.visible) {
getList()
}
})
</script>
<style scoped>
.config-manage-dialog {
:deep(.el-dialog) {
border-radius: 12px;
overflow: hidden;
}
:deep(.el-dialog__header) {
background: linear-gradient(135deg, rgb(93, 135, 255) 0%, rgb(73, 190, 255) 100%);
color: white;
padding: 20px 24px;
margin: 0;
}
:deep(.el-dialog__title) {
color: white;
font-size: 18px;
font-weight: 600;
}
:deep(.el-dialog__headerbtn) {
.el-dialog__close {
color: white;
font-size: 20px;
}
}
:deep(.el-dialog__body) {
padding: 20px 24px;
}
:deep(.el-dialog__footer) {
padding: 16px 24px 20px;
border-top: 1px solid rgba(93, 135, 255, 0.1);
}
}
.config-manage-container {
max-height: 70vh;
overflow-y: auto;
}
.search-section {
margin-bottom: 16px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.toolbar-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 0 4px;
}
.toolbar-left {
display: flex;
gap: 8px;
}
.toolbar-right {
display: flex;
gap: 8px;
}
.table-section {
margin-bottom: 16px;
}
.config-table {
width: 100%;
border-radius: 8px;
overflow: hidden;
}
.config-table :deep(.el-table__header-wrapper) {
.el-table__header {
th {
background: linear-gradient(135deg, #fafbff 0%, #f8fafc 100%);
color: #1e293b;
font-weight: 600;
border-bottom: 1px solid rgba(93, 135, 255, 0.1);
}
}
}
.config-table :deep(.el-table__body-wrapper) {
.el-table__row {
transition: all 0.3s ease;
}
.el-table__row:hover {
background-color: rgba(93, 135, 255, 0.04) !important;
}
}
.action-buttons {
display: flex;
gap: 4px;
justify-content: center;
align-items: center;
}
.action-buttons .el-button {
width: 28px !important;
height: 28px !important;
padding: 0 !important;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.action-buttons .el-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.pagination-section {
display: flex;
justify-content: flex-end;
padding: 16px 0;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.toolbar-section {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.toolbar-left,
.toolbar-right {
width: 100%;
justify-content: center;
}
.config-table {
font-size: 12px;
}
.action-buttons {
flex-direction: column;
gap: 2px;
}
}
/* 滚动条样式 */
.config-manage-container::-webkit-scrollbar {
width: 6px;
}
.config-manage-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.config-manage-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.config-manage-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
@@ -0,0 +1,295 @@
<template>
<el-dialog
title="导出统计数据"
v-model="dialogVisible"
width="500px"
:before-close="handleClose"
append-to-body
>
<el-form
ref="exportForm"
:model="form"
:rules="rules"
label-width="100px"
v-loading="loading"
>
<el-form-item label="导出格式" prop="format">
<el-radio-group v-model="form.format">
<el-radio label="excel">Excel格式</el-radio>
<el-radio label="csv">CSV格式</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="包含内容" prop="includeChart">
<el-checkbox v-model="form.includeChart" :disabled="form.format === 'csv'">
包含图表
</el-checkbox>
</el-form-item>
<el-form-item label="文件名称" prop="fileName">
<el-input
v-model="form.fileName"
placeholder="请输入文件名称"
:suffix="form.format === 'excel' ? '.xlsx' : '.csv'"
/>
</el-form-item>
<el-form-item label="数据预览">
<div class="data-preview">
<div class="preview-header">
<span>共 {{ statisticsData.length }} 条数据</span>
</div>
<div class="preview-content">
<div
v-for="(item, index) in statisticsData.slice(0, 5)"
:key="index"
class="preview-item"
>
<span class="item-label">{{ item.label }}</span>
<span class="item-value">{{ item.value }}</span>
<span class="item-percentage">{{ item.percentage }}%</span>
</div>
<div v-if="statisticsData.length > 5" class="preview-more">
...还有 {{ statisticsData.length - 5 }} 条数据
</div>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleExport" :loading="loading">
导出
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { exportStatisticsResult, type StatisticsExportQuery } from '@/api/xg-xx/statistics'
// Props
interface Props {
visible: boolean
queryParams: any
statisticsData: Array<{
label: string
value: number
percentage: number
}>
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
queryParams: () => ({}),
statisticsData: () => []
})
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
// 响应式数据
const loading = ref(false)
const exportForm = ref()
// 表单数据
const form = reactive({
format: 'excel' as 'excel' | 'csv',
includeChart: true,
fileName: ''
})
// 表单验证规则
const rules = {
format: [
{ required: true, message: '请选择导出格式', trigger: 'change' }
],
fileName: [
{ required: true, message: '请输入文件名称', trigger: 'blur' },
{ min: 1, max: 50, message: '文件名称长度在 1 到 50 个字符', trigger: 'blur' }
]
}
// 计算属性
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
// 监听器
watch(() => props.visible, (newVal) => {
if (newVal) {
resetForm()
generateFileName()
}
})
watch(() => form.format, (newVal) => {
if (newVal === 'csv') {
form.includeChart = false
}
generateFileName()
})
// 方法定义
const generateFileName = () => {
const now = new Date()
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '')
const timeStr = now.toTimeString().slice(0, 8).replace(/:/g, '')
let prefix = '统计数据'
if (props.queryParams.dimension) {
const dimensionMap: Record<string, string> = {
college: '学院统计',
major: '专业统计',
class: '班级统计',
grade: '年级统计',
gender: '性别统计',
age: '年龄统计',
political: '政治面貌统计',
nation: '民族统计',
sourceRegion: '生源地统计',
bloodType: '血型统计',
religion: '宗教信仰统计',
studentStatus: '学籍状态统计',
trainingType: '培养方式统计'
}
prefix = dimensionMap[props.queryParams.dimension] || '统计数据'
}
form.fileName = `${prefix}_${dateStr}_${timeStr}`
}
const resetForm = () => {
form.format = 'excel'
form.includeChart = true
form.fileName = ''
}
const handleClose = () => {
dialogVisible.value = false
}
const handleExport = async () => {
if (!exportForm.value) return
try {
await exportForm.value.validate()
} catch (error) {
return
}
loading.value = true
try {
const exportQuery: StatisticsExportQuery = {
...props.queryParams,
format: form.format,
includeChart: form.includeChart
}
const response = await exportStatisticsResult(exportQuery)
// 创建下载链接
const blob = new Blob([response.data], {
type: form.format === 'excel'
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'text/csv;charset=utf-8'
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${form.fileName}.${form.format === 'excel' ? 'xlsx' : 'csv'}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
handleClose()
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.data-preview {
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
}
.preview-header {
padding: 12px 16px;
background: #f8f9fa;
border-bottom: 1px solid #e4e7ed;
font-size: 14px;
color: #606266;
font-weight: 500;
}
.preview-content {
max-height: 200px;
overflow-y: auto;
}
.preview-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
border-bottom: 1px solid #f0f2f5;
font-size: 13px;
}
.preview-item:last-child {
border-bottom: none;
}
.item-label {
flex: 1;
color: #303133;
}
.item-value {
width: 80px;
text-align: right;
color: rgb(93, 135, 255);
font-weight: 500;
}
.item-percentage {
width: 60px;
text-align: right;
color: #909399;
font-size: 12px;
}
.preview-more {
padding: 8px 16px;
text-align: center;
color: #909399;
font-size: 12px;
font-style: italic;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>
@@ -0,0 +1,494 @@
<template>
<div class="statistics-chart">
<div class="chart-header">
<div class="chart-title">{{ title }}</div>
<div class="chart-actions">
<el-radio-group v-model="currentChartType" @change="handleChartTypeChange">
<el-radio-button label="bar">柱状图</el-radio-button>
<el-radio-button label="pie">饼图</el-radio-button>
<el-radio-button label="line">折线图</el-radio-button>
<el-radio-button label="doughnut">环形图</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="chart-container">
<div ref="chartRef" class="chart-wrapper" v-loading="loading"></div>
<div v-if="!data || data.length === 0" class="chart-empty">
<el-empty description="暂无数据">
<el-icon class="empty-icon"><DataAnalysis /></el-icon>
</el-empty>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
// Props
interface ChartData {
label: string
value: number
percentage: number
}
interface Props {
title?: string
data: ChartData[]
chartType?: 'bar' | 'pie' | 'line' | 'doughnut'
loading?: boolean
height?: number
}
const props = withDefaults(defineProps<Props>(), {
title: '统计图表',
data: () => [],
chartType: 'bar',
loading: false,
height: 400
})
// Emits
const emit = defineEmits<{
'chart-click': [data: ChartData]
'chart-type-change': [type: string]
}>()
// 响应式数据
const chartRef = ref<HTMLElement>()
const chartInstance = ref<echarts.ECharts>()
const currentChartType = ref(props.chartType)
// 图表配色方案
const colorScheme = [
'#5d87ff', '#49beff', '#ff9a56', '#ffad56',
'#4facfe', '#00f2fe', '#a8edea', '#fed6e3',
'#667eea', '#764ba2', '#f093fb', '#f5576c',
'#4facfe', '#00f2fe', '#43e97b', '#38f9d7'
]
// 监听器
watch(() => props.data, () => {
renderChart()
}, { deep: true })
watch(() => props.loading, (newVal) => {
if (!newVal) {
nextTick(() => {
renderChart()
})
}
})
// 方法定义
const initChart = () => {
if (!chartRef.value) return
chartInstance.value = echarts.init(chartRef.value)
// 监听图表点击事件
chartInstance.value.on('click', (params: any) => {
const clickedData = props.data.find(item => item.label === params.name)
if (clickedData) {
emit('chart-click', clickedData)
}
})
// 监听窗口大小变化
window.addEventListener('resize', handleResize)
}
const handleResize = () => {
if (chartInstance.value) {
chartInstance.value.resize()
}
}
const handleChartTypeChange = (type: string) => {
currentChartType.value = type as any
emit('chart-type-change', type)
renderChart()
}
const renderChart = () => {
if (!chartInstance.value || !props.data || props.data.length === 0) {
return
}
const option = generateChartOption()
chartInstance.value.setOption(option, true)
}
const generateChartOption = (): echarts.EChartsCoreOption => {
const labels = props.data.map(item => item.label)
const values = props.data.map(item => item.value)
const baseOption = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'rgba(0, 0, 0, 0.8)',
textStyle: {
color: '#fff'
}
},
legend: {
show: currentChartType.value === 'pie' || currentChartType.value === 'doughnut',
orient: 'vertical',
right: 10,
top: 20,
bottom: 20,
textStyle: {
color: '#606266'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
}
}
switch (currentChartType.value) {
case 'pie':
return {
...baseOption,
tooltip: {
...baseOption.tooltip,
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [{
name: props.title,
type: 'pie',
radius: '60%',
center: ['40%', '50%'],
data: props.data.map((item, index) => ({
name: item.label,
value: item.value,
itemStyle: {
color: colorScheme[index % colorScheme.length]
}
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
label: {
show: true,
formatter: '{b}: {d}%'
}
}]
}
case 'doughnut':
return {
...baseOption,
tooltip: {
...baseOption.tooltip,
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [{
name: props.title,
type: 'pie',
radius: ['40%', '70%'],
center: ['40%', '50%'],
data: props.data.map((item, index) => ({
name: item.label,
value: item.value,
itemStyle: {
color: colorScheme[index % colorScheme.length]
}
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
label: {
show: true,
formatter: '{b}: {d}%'
}
}]
}
case 'line':
return {
...baseOption,
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'rgba(0, 0, 0, 0.8)',
textStyle: {
color: '#fff'
}
},
xAxis: {
type: 'category',
data: labels,
axisLabel: {
color: '#606266',
interval: 0,
rotate: labels.length > 6 ? 45 : 0
},
axisLine: {
lineStyle: {
color: '#e4e7ed'
}
}
},
yAxis: {
type: 'value',
axisLabel: {
color: '#606266'
},
axisLine: {
lineStyle: {
color: '#e4e7ed'
}
},
splitLine: {
lineStyle: {
color: '#f0f2f5'
}
}
},
series: [{
name: props.title,
type: 'line',
data: values,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
color: colorScheme[0],
width: 3
},
itemStyle: {
color: colorScheme[0]
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: colorScheme[0] + '40' },
{ offset: 1, color: colorScheme[0] + '10' }
])
}
}]
}
default: // bar
return {
...baseOption,
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'rgba(0, 0, 0, 0.8)',
textStyle: {
color: '#fff'
}
},
xAxis: {
type: 'category',
data: labels,
axisLabel: {
color: '#606266',
interval: 0,
rotate: labels.length > 6 ? 45 : 0
},
axisLine: {
lineStyle: {
color: '#e4e7ed'
}
}
},
yAxis: {
type: 'value',
axisLabel: {
color: '#606266'
},
axisLine: {
lineStyle: {
color: '#e4e7ed'
}
},
splitLine: {
lineStyle: {
color: '#f0f2f5'
}
}
},
series: [{
name: props.title,
type: 'bar',
data: values.map((value, index) => ({
value,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: colorScheme[index % colorScheme.length] },
{ offset: 1, color: colorScheme[index % colorScheme.length] + '80' }
])
}
})),
barWidth: '60%',
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}]
}
}
}
const exportChart = (type: 'png' | 'jpg' | 'svg' = 'png') => {
if (!chartInstance.value) return null
return chartInstance.value.getDataURL({
type: type,
pixelRatio: 2,
backgroundColor: '#fff'
})
}
// 暴露方法给父组件
defineExpose({
exportChart,
resize: handleResize
})
// 生命周期
onMounted(() => {
nextTick(() => {
initChart()
renderChart()
})
})
onUnmounted(() => {
if (chartInstance.value) {
chartInstance.value.dispose()
}
window.removeEventListener('resize', handleResize)
})
</script>
<style scoped>
.statistics-chart {
width: 100%;
height: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.chart-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
}
.chart-title::before {
content: '';
width: 4px;
height: 16px;
background: linear-gradient(135deg, rgb(93, 135, 255), rgb(73, 190, 255));
border-radius: 2px;
margin-right: 8px;
}
.chart-actions {
display: flex;
align-items: center;
}
.chart-container {
position: relative;
width: 100%;
height: 400px;
}
.chart-wrapper {
width: 100%;
height: 100%;
}
.chart-empty {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
}
.empty-icon {
font-size: 48px;
color: #c0c4cc;
margin-bottom: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.chart-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.chart-actions {
width: 100%;
overflow-x: auto;
}
.chart-container {
height: 300px;
}
}
/* 图表类型切换按钮样式 */
.chart-actions :deep(.el-radio-button__inner) {
border-radius: 6px;
border: 1px solid #e4e7ed;
background: #ffffff;
color: #606266;
transition: all 0.3s ease;
}
.chart-actions :deep(.el-radio-button__inner:hover) {
border-color: rgba(93, 135, 255, 0.6);
color: rgb(93, 135, 255);
}
.chart-actions :deep(.el-radio-button.is-active .el-radio-button__inner) {
background: linear-gradient(135deg, rgb(93, 135, 255), rgb(73, 190, 255));
border-color: rgb(93, 135, 255);
color: white;
box-shadow: 0 2px 8px rgba(93, 135, 255, 0.3);
}
.chart-actions :deep(.el-radio-group) {
display: flex;
gap: 4px;
}
.chart-actions :deep(.el-radio-button) {
margin: 0;
}
</style>
+704
View File
@@ -0,0 +1,704 @@
<template>
<div class="page-container">
<!-- 统计概览卡片 -->
<div class="mb-[10px]">
<el-row :gutter="16">
<el-col :span="6">
<el-card shadow="hover" class="stat-card stat-card-primary">
<div class="stat-content">
<div class="stat-icon">
<el-icon><User /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ overview.totalStudents }}</div>
<div class="stat-label">学生总数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card stat-card-success">
<div class="stat-content">
<div class="stat-icon">
<el-icon><School /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ overview.totalColleges }}</div>
<div class="stat-label">学院数量</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card stat-card-warning">
<div class="stat-content">
<div class="stat-icon">
<el-icon><Reading /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ overview.totalMajors }}</div>
<div class="stat-label">专业数量</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card stat-card-info">
<div class="stat-content">
<div class="stat-icon">
<el-icon><Tickets /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ overview.totalClasses }}</div>
<div class="stat-label">班级数量</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 查询条件 -->
<el-card shadow="never" class="search-card">
<el-form :model="queryParams" ref="queryRef" :inline="true" label-width="80px">
<el-form-item label="统计维度" prop="dimension">
<el-select v-model="queryParams.dimension" placeholder="请选择统计维度" clearable>
<el-option
v-for="item in dimensionOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="学院" prop="collegeIds">
<el-select v-model="queryParams.collegeIds" placeholder="请选择学院" clearable multiple @change="handleCollegeChange">
<el-option
v-for="item in collegeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="专业" prop="majorIds">
<el-select v-model="queryParams.majorIds" placeholder="请选择专业" clearable multiple>
<el-option
v-for="item in majorOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="search-btn-container">
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">统计分析</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</div>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card shadow="never" class="button-card">
<template #header>
<div class="card-header">
<div class="toolbar-title">数据统计分析</div>
<div class="toolbar-actions">
<el-button type="primary" icon="DataAnalysis" @click="handleQuickAnalysis">快速分析</el-button>
<el-button type="success" icon="Download" @click="handleExport">导出数据</el-button>
<el-button type="info" icon="Setting" @click="handleConfigManage">配置管理</el-button>
<el-button type="warning" icon="RefreshRight" @click="handleCacheManage">缓存管理</el-button>
</div>
</div>
</template>
</el-card>
<!-- 统计结果展示 -->
<el-row :gutter="16" v-if="statisticsData.length > 0">
<el-col :span="16">
<el-card shadow="never" class="chart-card">
<StatisticsChart
:title="currentDimensionLabel + '统计图表'"
:data="statisticsData"
:chart-type="chartType"
:loading="loading"
@chart-click="handleChartClick"
@chart-type-change="handleChartTypeChange"
/>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="data-card">
<template #header>
<div class="data-title">统计数据详情</div>
</template>
<div class="data-list">
<div
v-for="(item, index) in statisticsData"
:key="index"
class="data-item"
>
<div class="item-info">
<div class="item-label">{{ item.label }}</div>
<div class="item-value">{{ item.value }}</div>
</div>
<div class="item-percentage">{{ item.percentage }}%</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 空状态 -->
<el-card shadow="never" class="empty-card" v-else>
<el-empty description="请选择统计维度并点击统计分析">
<el-button type="primary" @click="handleQuickAnalysis">开始分析</el-button>
</el-empty>
</el-card>
<!-- 导出对话框 -->
<ExportDialog
:visible="exportVisible"
:query-params="queryParams"
:statistics-data="statisticsData"
@update:visible="(val) => exportVisible = val"
/>
<!-- 配置管理对话框 -->
<ConfigManageDialog
:visible="configVisible"
@update:visible="(val) => configVisible = val"
@refresh="handleQuery"
/>
<!-- 缓存管理对话框 -->
<CacheManageDialog
:visible="cacheVisible"
@update:visible="(val) => cacheVisible = val"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
getStatisticsOverview,
getStatisticsDimensionOptions,
getCollegeOptions,
getMajorOptions,
getCollegeStatistics,
getMajorStatistics,
getClassStatistics,
getGradeStatistics,
getGenderStatistics,
getAgeStatistics,
getPoliticalStatusStatistics,
getNationStatistics,
getSourceRegionStatistics,
getBloodTypeStatistics,
getTrainingTypeStatistics,
type StatisticsAnalysisQuery
} from '@/api/xg-xx/statistics'
import ExportDialog from './components/ExportDialog.vue'
import StatisticsChart from './components/StatisticsChart.vue'
import CacheManageDialog from './components/CacheManageDialog.vue'
import ConfigManageDialog from './components/ConfigManageDialog.vue'
// 响应式数据
const loading = ref(false)
const chartRef = ref<HTMLElement>()
const chartType = ref<'bar' | 'pie' | 'line' | 'doughnut'>('bar')
const exportVisible = ref(false)
const configVisible = ref(false)
const cacheVisible = ref(false)
const currentDimensionLabel = ref('')
// 统计概览
const overview = reactive({
totalStudents: 0,
totalColleges: 0,
totalMajors: 0,
totalClasses: 0
})
// 查询参数
const queryParams = reactive<StatisticsAnalysisQuery>({
statisticsType: 'dimension',
dimension: '',
collegeIds: [],
majorIds: [],
classIds: [],
grades: [],
genders: [],
politicalStatuses: [],
nationalities: [],
studentStatuses: [],
trainingTypes: [],
useCache: true
})
// 选项数据
const dimensionOptions = ref<Array<{ label: string; value: string }>>([])
const collegeOptions = ref<Array<{ label: string; value: string }>>([])
const majorOptions = ref<Array<{ label: string; value: string }>>([])
// 统计数据
const statisticsData = ref<Array<{
label: string
value: number
percentage: number
}>>([])
// 方法定义
const getOverview = async () => {
try {
const response = await getStatisticsOverview()
Object.assign(overview, response.data)
} catch (error) {
console.error('获取概览失败:', error)
}
}
const getDimensionOptions = async () => {
try {
const response = await getStatisticsDimensionOptions()
dimensionOptions.value = response.data
} catch (error) {
console.error('获取维度选项失败:', error)
}
}
const getCollegeList = async () => {
try {
const response = await getCollegeOptions()
collegeOptions.value = response.data
} catch (error) {
console.error('获取学院选项失败:', error)
}
}
const getMajorList = async (collegeId?: string) => {
try {
const response = await getMajorOptions(collegeId)
majorOptions.value = response.data
} catch (error) {
console.error('获取专业选项失败:', error)
}
}
const handleQuery = async () => {
if (!queryParams.dimension) {
ElMessage.warning('请选择统计维度')
return
}
loading.value = true
try {
let response
// 根据统计维度调用不同的API
switch (queryParams.dimension) {
case 'college':
response = await getCollegeStatistics(queryParams)
break
case 'major':
response = await getMajorStatistics(queryParams)
break
case 'class':
response = await getClassStatistics(queryParams)
break
case 'grade':
response = await getGradeStatistics(queryParams)
break
case 'gender':
response = await getGenderStatistics(queryParams)
break
case 'age':
response = await getAgeStatistics(queryParams)
break
case 'political_status':
response = await getPoliticalStatusStatistics(queryParams)
break
case 'nationality':
response = await getNationStatistics(queryParams)
break
case 'source_region':
response = await getSourceRegionStatistics(queryParams)
break
case 'blood_type':
response = await getBloodTypeStatistics(queryParams)
break
case 'training_type':
response = await getTrainingTypeStatistics(queryParams)
break
default:
ElMessage.error('不支持的统计维度')
return
}
const result = response.data
if (result.data && result.data.length > 0) {
statisticsData.value = result.data.map(item => ({
label: item.label || item.name || item.key,
value: Number(item.value || item.count),
percentage: Number(((Number(item.value || item.count) / (result.total || 1)) * 100).toFixed(2))
}))
// 设置当前维度标签
const dimensionOption = dimensionOptions.value.find(opt => opt.value === queryParams.dimension)
currentDimensionLabel.value = dimensionOption ? dimensionOption.label : queryParams.dimension
} else {
statisticsData.value = []
ElMessage.warning('暂无统计数据')
}
ElMessage.success('统计分析完成')
} catch (error) {
console.error('统计分析失败:', error)
ElMessage.error('统计分析失败')
} finally {
loading.value = false
}
}
const resetQuery = () => {
Object.assign(queryParams, {
dimension: '',
collegeIds: [],
majorIds: [],
classIds: [],
grades: [],
genders: [],
politicalStatuses: [],
nationalities: [],
studentStatuses: [],
trainingTypes: []
})
statisticsData.value = []
}
const handleQuickAnalysis = () => {
if (!queryParams.dimension) {
queryParams.dimension = 'college'
}
handleQuery()
}
const handleExport = () => {
if (statisticsData.value.length === 0) {
ElMessage.warning('请先执行统计分析')
return
}
exportVisible.value = true
}
const handleChartClick = (data: any) => {
console.log('图表点击:', data)
// 可以在这里处理图表点击事件
}
const handleChartTypeChange = (type: string) => {
chartType.value = type as 'bar' | 'pie' | 'line' | 'doughnut'
}
const handleConfigManage = () => {
configVisible.value = true
}
const handleCacheManage = () => {
cacheVisible.value = true
}
// 初始化
onMounted(async () => {
await Promise.all([
getOverview(),
getDimensionOptions(),
getCollegeList()
])
})
// 学院变更处理
const handleCollegeChange = async () => {
// 清空专业选择
queryParams.majorIds = []
// 获取当前选中学院的专业选项
if (queryParams.collegeIds && queryParams.collegeIds.length > 0) {
// 如果选择了学院,获取对应的专业选项
await getMajorList(queryParams.collegeIds[0].toString())
} else {
// 如果没有选择学院,获取所有专业选项
await getMajorList()
}
}
</script>
<style scoped>
.page-container {
padding: 8px;
}
/* 统计卡片样式 - 参考后勤管理模块 */
.stat-card {
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
height: 90px;
border: none;
overflow: hidden;
position: relative;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
}
.stat-content {
display: flex;
align-items: center;
padding: 20px;
height: 100%;
box-sizing: border-box;
position: relative;
z-index: 1;
}
.stat-icon {
font-size: 28px;
margin-right: 16px;
opacity: 0.9;
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 24px;
font-weight: 700;
line-height: 1;
margin-bottom: 6px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 14px;
opacity: 0.9;
line-height: 1;
font-weight: 500;
}
/* 渐变背景 */
.stat-card-primary {
background: linear-gradient(135deg, rgb(93, 135, 255) 0%, rgb(73, 190, 255) 100%);
color: white;
}
.stat-card-success {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.stat-card-warning {
background: linear-gradient(135deg, #ff9a56 0%, #ffad56 100%);
color: white;
}
.stat-card-info {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
color: #333;
}
/* 搜索卡片样式 */
.search-card {
margin-bottom: 10px;
border-radius: 12px;
border: 1px solid #e4e7ed;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.search-card :deep(.el-card__body) {
padding: 20px;
}
.search-btn-container {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.search-btn-container .el-form-item {
margin-bottom: 0 !important;
}
/* 按钮卡片样式 */
.button-card {
margin-bottom: 10px;
border-radius: 12px;
border: 1px solid #e4e7ed;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.button-card :deep(.el-card__header) {
border-bottom: 1px solid rgba(93, 135, 255, 0.1);
background: linear-gradient(135deg, #fafbff 0%, #f8fafc 100%);
padding: 16px 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.toolbar-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
}
.toolbar-title::before {
content: '';
width: 4px;
height: 20px;
background: linear-gradient(135deg, rgb(93, 135, 255), rgb(73, 190, 255));
border-radius: 2px;
margin-right: 12px;
}
.toolbar-actions {
display: flex;
gap: 12px;
}
/* 图表卡片样式 */
.chart-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin-bottom: 16px;
}
.chart-card :deep(.el-card__header) {
border-bottom: 1px solid rgba(93, 135, 255, 0.1);
background: linear-gradient(135deg, #fafbff 0%, #f8fafc 100%);
padding: 16px 20px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.chart-container {
padding: 20px;
height: 400px;
}
.chart-wrapper {
width: 100%;
height: 100%;
}
/* 数据卡片样式 */
.data-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin-bottom: 16px;
}
.data-card :deep(.el-card__header) {
border-bottom: 1px solid rgba(93, 135, 255, 0.1);
background: linear-gradient(135deg, #fafbff 0%, #f8fafc 100%);
padding: 16px 20px;
}
.data-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.data-list {
padding: 20px;
max-height: 400px;
overflow-y: auto;
}
.data-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 8px;
transition: all 0.3s ease;
}
.data-item:hover {
border-color: rgba(93, 135, 255, 0.3);
background: rgba(93, 135, 255, 0.02);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.item-info {
flex: 1;
}
.item-label {
font-size: 14px;
color: #1e293b;
font-weight: 500;
margin-bottom: 4px;
}
.item-value {
font-size: 16px;
font-weight: 600;
color: rgb(93, 135, 255);
}
.item-percentage {
font-size: 12px;
color: #606266;
font-weight: 500;
}
/* 空状态卡片样式 */
.empty-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin-top: 16px;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
+21 -30
View File
@@ -377,11 +377,13 @@ import {
getStudent,
delStudent,
exportStudents,
getStatisticsOverview,
getStatisticsOverview
} from '@/api/xg-xx/student'
import {
getCollegeOptions,
getMajorOptions,
getClassOptions
} from '@/api/xg-xx/student'
} from '@/api/xg-xx/statistics'
import StudentDetailDialog from './components/StudentDetailDialog'
import StudentEditDialog from './components/StudentEditDialog'
import AuditDialog from './components/AuditDialog'
@@ -518,35 +520,22 @@ export default {
},
/** 获取统计数据 */
getStatistics() {
console.log('开始获取统计数据')
getStatisticsOverview().then(response => {
console.log('获取统计数据成功:', response)
const data = response.data
this.statistics.totalStudents = data.totalCount || 0
this.statistics.activeStudents = data.activeCount || 0
this.statistics.completeStudents = data.completeCount || 0
this.statistics.pendingStudents = data.pendingCount || 0
this.statistics = response.data
}).catch(error => {
console.error('获取统计数据失败:', error)
let errorMessage = '获取统计数据失败'
if (error.response && error.response.data && error.response.data.msg) {
errorMessage += ': ' + error.response.data.msg
}
this.$modal.msgError(errorMessage)
this.$modal.msgError('获取统计数据失败')
})
},
/** 获取学院列表 */
getCollegeList() {
console.log('开始获取学院列表')
getCollegeOptions().then(response => {
console.log('获取学院列表成功:', response)
this.collegeOptions = response.data
this.collegeOptions = response.data || []
}).catch(error => {
console.error('获取学院列表失败:', error)
let errorMessage = '获取学院列表失败'
if (error.response && error.response.data && error.response.data.msg) {
errorMessage += ': ' + error.response.data.msg
@@ -555,20 +544,21 @@ export default {
})
},
/** 学院变化处理 */
handleCollegeChange(collegeCode) {
handleCollegeChange(collegeId) {
console.log('学院变化,清空专业和班级选择:', collegeId)
this.queryParams.majorCode = null
this.queryParams.classCode = null
this.majorOptions = []
this.classOptions = []
if (collegeCode) {
console.log('学院变化,获取专业列表:', collegeCode)
getMajorOptions(collegeCode).then(response => {
if (collegeId) {
console.log('学院变化,获取专业列表:', collegeId)
// 根据学院ID获取专业列表
getMajorOptions(collegeId).then(response => {
console.log('获取专业列表成功:', response)
this.majorOptions = response.data
this.majorOptions = response.data || []
}).catch(error => {
console.error('获取专业列表失败:', error)
let errorMessage = '获取专业列表失败'
if (error.response && error.response.data && error.response.data.msg) {
errorMessage += ': ' + error.response.data.msg
@@ -578,18 +568,19 @@ export default {
}
},
/** 专业变化处理 */
handleMajorChange(majorCode) {
handleMajorChange(majorId) {
console.log('专业变化,清空班级选择:', majorId)
this.queryParams.classCode = null
this.classOptions = []
if (majorCode) {
console.log('专业变化,获取班级列表:', majorCode)
getClassOptions(majorCode).then(response => {
if (majorId) {
console.log('专业变化,获取班级列表:', majorId)
// 根据专业ID获取班级列表
getClassOptions(majorId).then(response => {
console.log('获取班级列表成功:', response)
this.classOptions = response.data
this.classOptions = response.data || []
}).catch(error => {
console.error('获取班级列表失败:', error)
let errorMessage = '获取班级列表失败'
if (error.response && error.response.data && error.response.data.msg) {
errorMessage += ': ' + error.response.data.msg