From fd2ad752a03bb5fb898dfcc82e34ba378d0531b3 Mon Sep 17 00:00:00 2001 From: Oseast <162945153+Oseast@users.noreply.github.com> Date: Thu, 4 Jul 2024 17:49:11 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=90=E5=8C=97=E5=A4=A7=E5=BC=80=E6=BA=90?= =?UTF-8?q?=E5=AE=9E=E8=B7=B5=E3=80=91=E5=A2=9E=E5=8A=A0=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD=20(#294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat:添加了一个文件数据导出的功能和相应前端页面 * fix lint * fix conflict --------- Co-authored-by: dayou <853094838@qq.com> --- package.json | 5 + server/src/app.module.ts | 2 + server/src/config/index.ts | 21 + server/src/enums/index.ts | 1 + server/src/models/surveyDownload.entity.ts | 46 +++ .../controllers/dataStatistic.controller.ts | 3 + .../controllers/surveyDownload.controller.ts | 219 +++++++++++ .../src/modules/survey/dto/getdownload.dto.ts | 43 +++ .../survey/services/dataStatistic.service.ts | 1 + .../survey/services/message.service.ts | 87 +++++ .../survey/services/surveyDownload.service.ts | 365 ++++++++++++++++++ server/src/modules/survey/survey.module.ts | 16 + web/components.d.ts | 2 - web/src/management/api/analysis.js | 9 + web/src/management/api/download.js | 34 ++ .../pages/analysis/AnalysisPage.vue | 69 +++- .../pages/download/SurveyDownloadPage.vue | 129 +++++++ .../download/components/DownloadList.vue | 273 +++++++++++++ web/src/management/pages/list/config/index.js | 2 +- web/src/management/pages/list/index.vue | 5 + web/src/management/router/index.ts | 8 + web/src/management/store/download/index.js | 54 +++ web/src/management/store/edit/mutations.js | 3 + web/src/management/store/edit/state.js | 3 +- web/src/management/store/index.js | 4 +- 25 files changed, 1397 insertions(+), 7 deletions(-) create mode 100644 package.json create mode 100644 server/src/config/index.ts create mode 100644 server/src/models/surveyDownload.entity.ts create mode 100644 server/src/modules/survey/controllers/surveyDownload.controller.ts create mode 100644 server/src/modules/survey/dto/getdownload.dto.ts create mode 100644 server/src/modules/survey/services/message.service.ts create mode 100644 server/src/modules/survey/services/surveyDownload.service.ts create mode 100644 web/src/management/api/download.js create mode 100644 web/src/management/pages/download/SurveyDownloadPage.vue create mode 100644 web/src/management/pages/download/components/DownloadList.vue create mode 100644 web/src/management/store/download/index.js diff --git a/package.json b/package.json new file mode 100644 index 00000000..1bb6abe9 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "node-cron": "^3.0.3" + } +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 3faa2987..68675073 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -41,6 +41,7 @@ import { PluginManagerProvider } from './securityPlugin/pluginManager.provider'; import { LogRequestMiddleware } from './middlewares/logRequest.middleware'; import { XiaojuSurveyPluginManager } from './securityPlugin/pluginManager'; import { Logger } from './logger'; +import { SurveyDownload } from './models/surveyDownload.entity'; @Module({ imports: [ @@ -81,6 +82,7 @@ import { Logger } from './logger'; Workspace, WorkspaceMember, Collaborator, + SurveyDownload, ], }; }, diff --git a/server/src/config/index.ts b/server/src/config/index.ts new file mode 100644 index 00000000..2068c4de --- /dev/null +++ b/server/src/config/index.ts @@ -0,0 +1,21 @@ +const mongo = { + url: process.env.XIAOJU_SURVEY_MONGO_URL || 'mongodb://localhost:27017', + dbName: process.env.XIAOJU_SURVER_MONGO_DBNAME || 'xiaojuSurvey', +}; + +const session = { + expireTime: + parseInt(process.env.XIAOJU_SURVEY_JWT_EXPIRES_IN) || 8 * 3600 * 1000, +}; + +const encrypt = { + type: process.env.XIAOJU_SURVEY_ENCRYPT_TYPE || 'aes', + aesCodelength: parseInt(process.env.XIAOJU_SURVEY_ENCRYPT_TYPE_LEN) || 10, //aes密钥长度 +}; + +const jwt = { + secret: process.env.XIAOJU_SURVEY_JWT_SECRET || 'xiaojuSurveyJwtSecret', + expiresIn: process.env.XIAOJU_SURVEY_JWT_EXPIRES_IN || '8h', +}; + +export { mongo, session, encrypt, jwt }; diff --git a/server/src/enums/index.ts b/server/src/enums/index.ts index acaa0e33..6a7eb74a 100644 --- a/server/src/enums/index.ts +++ b/server/src/enums/index.ts @@ -6,6 +6,7 @@ export enum RECORD_STATUS { PUBLISHED = 'published', // 发布 REMOVED = 'removed', // 删除 FORCE_REMOVED = 'forceRemoved', // 从回收站删除 + COMOPUTETING = 'computing', // 计算中 } // 历史类型 diff --git a/server/src/models/surveyDownload.entity.ts b/server/src/models/surveyDownload.entity.ts new file mode 100644 index 00000000..96e09b87 --- /dev/null +++ b/server/src/models/surveyDownload.entity.ts @@ -0,0 +1,46 @@ +import { Entity, Column, BeforeInsert, AfterLoad } from 'typeorm'; +import pluginManager from '../securityPlugin/pluginManager'; +import { BaseEntity } from './base.entity'; + +@Entity({ name: 'surveyDownload' }) +export class SurveyDownload extends BaseEntity { + @Column() + pageId: string; + + @Column() + surveyPath: string; + + @Column() + title: string; + + @Column() + filePath: string; + + @Column() + onwer: string; + + @Column() + filename: string; + + @Column() + fileSize: string; + + @Column() + fileType: string; + + // @Column() + // ownerId: string; + + @Column() + downloadTime: string; + + @BeforeInsert() + async onDataInsert() { + return await pluginManager.triggerHook('beforeResponseDataCreate', this); + } + + @AfterLoad() + async onDataLoaded() { + return await pluginManager.triggerHook('afterResponseDataReaded', this); + } +} diff --git a/server/src/modules/survey/controllers/dataStatistic.controller.ts b/server/src/modules/survey/controllers/dataStatistic.controller.ts index a0b1754c..f073a141 100644 --- a/server/src/modules/survey/controllers/dataStatistic.controller.ts +++ b/server/src/modules/survey/controllers/dataStatistic.controller.ts @@ -20,6 +20,7 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import { Logger } from 'src/logger'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { SurveyDownloadService } from '../services/surveyDownload.service'; @ApiTags('survey') @ApiBearerAuth() @@ -30,6 +31,8 @@ export class DataStatisticController { private readonly dataStatisticService: DataStatisticService, private readonly pluginManager: XiaojuSurveyPluginManager, private readonly logger: Logger, + // + private readonly surveyDownloadService: SurveyDownloadService, ) {} @Get('/dataTable') diff --git a/server/src/modules/survey/controllers/surveyDownload.controller.ts b/server/src/modules/survey/controllers/surveyDownload.controller.ts new file mode 100644 index 00000000..d03b8b1c --- /dev/null +++ b/server/src/modules/survey/controllers/surveyDownload.controller.ts @@ -0,0 +1,219 @@ +import { + Controller, + Get, + Query, + HttpCode, + UseGuards, + SetMetadata, + Request, + Res, + // Response, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; + +import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service'; + +import { Authentication } from 'src/guards/authentication.guard'; +import { SurveyGuard } from 'src/guards/survey.guard'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; +import { Logger } from 'src/logger'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +//后添加 +import { SurveyDownloadService } from '../services/surveyDownload.service'; +import { + DownloadFileByNameDto, + GetDownloadDto, + GetDownloadListDto, +} from '../dto/getdownload.dto'; +import { join } from 'path'; +import * as util from 'util'; +import * as fs from 'fs'; +import { Response } from 'express'; +import moment from 'moment'; +import { MessageService } from '../services/message.service'; + +@ApiTags('survey') +@ApiBearerAuth() +@Controller('/api/survey/surveyDownload') +export class SurveyDownloadController { + constructor( + private readonly responseSchemaService: ResponseSchemaService, + private readonly surveyDownloadService: SurveyDownloadService, + private readonly logger: Logger, + private readonly messageService: MessageService, + ) {} + + @Get('/download') + @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'query.surveyId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE]) + @UseGuards(Authentication) + async download( + @Query() + queryInfo: GetDownloadDto, + @Request() req, + ) { + const { value, error } = GetDownloadDto.validate(queryInfo); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const { surveyId, isDesensitive } = value; + const responseSchema = + await this.responseSchemaService.getResponseSchemaByPageId(surveyId); + const id = await this.surveyDownloadService.createDownload({ + surveyId, + responseSchema, + }); + this.messageService.addMessage({ + responseSchema, + surveyId, + isDesensitive, + id, + }); + return { + code: 200, + data: { message: '正在生成下载文件,请稍后查看' }, + }; + } + @Get('/getdownloadList') + @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'query.surveyId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE]) + @UseGuards(Authentication) + async downloadList( + @Query() + queryInfo: GetDownloadListDto, + @Request() req, + ) { + const { value, error } = GetDownloadListDto.validate(queryInfo); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const { ownerId, page, pageSize } = value; + const { total, listBody } = + await this.surveyDownloadService.getDownloadList({ + ownerId, + page, + pageSize, + }); + return { + code: 200, + data: { + total: total, + listBody: listBody.map((data) => { + const fmt = 'YYYY-MM-DD HH:mm:ss'; + const units = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + let unitIndex = 0; + let size = Number(data.fileSize); + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + data.downloadTime = moment(Number(data.downloadTime)).format(fmt); + data.fileSize = `${size.toFixed()} ${units[unitIndex]}`; + return data; + }), + }, + }; + } + + @Get('/getdownloadfileByName') + // @HttpCode(200) + // @UseGuards(SurveyGuard) + // @SetMetadata('surveyId', 'query.surveyId') + // @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE]) + // @UseGuards(Authentication) + async getDownloadfileByName( + @Query() queryInfo: DownloadFileByNameDto, + @Res() res: Response, + ) { + const { value, error } = DownloadFileByNameDto.validate(queryInfo); + if (error) { + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const { owner, fileName } = value; + const rootDir = process.cwd(); // 获取当前工作目录 + const filePath = join(rootDir, 'download', owner, fileName); + + // 使用 util.promisify 将 fs.access 转换为返回 Promise 的函数 + const access = util.promisify(fs.access); + try { + console.log('检查文件路径:', filePath); + await access(filePath, fs.constants.F_OK); + + // 文件存在,设置响应头并流式传输文件 + res.setHeader('Content-Type', 'application/octet-stream'); + console.log('文件存在,设置响应头'); + const encodedFileName = encodeURIComponent(fileName); + const contentDisposition = `attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`; + res.setHeader('Content-Disposition', contentDisposition); + console.log('设置响应头成功,文件名:', encodedFileName); + + const fileStream = fs.createReadStream(filePath); + console.log('创建文件流成功'); + fileStream.pipe(res); + + fileStream.on('end', () => { + console.log('文件传输完成'); + }); + + fileStream.on('error', (streamErr) => { + console.error('文件流错误:', streamErr); + res.status(500).send('文件传输中出现错误'); + }); + } catch (err) { + console.error('文件不存在:', filePath); + res.status(404).send('文件不存在'); + } + } + + @Get('/deletefileByName') + @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'query.surveyId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE]) + @UseGuards(Authentication) + async deleteFileByName( + @Query() queryInfo: DownloadFileByNameDto, + @Res() res: Response, + ) { + const { value, error } = DownloadFileByNameDto.validate(queryInfo); + if (error) { + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const { owner, fileName } = value; + + try { + const result = await this.surveyDownloadService.deleteDownloadFile({ + owner, + fileName, + }); + + // 根据 deleteDownloadFile 的返回值执行不同操作 + if (result === 0) { + return res.status(404).json({ + code: 404, + message: '文件状态已删除或文件不存在', + }); + } + + return res.status(200).json({ + code: 200, + message: '文件删除成功', + data: {}, + }); + } catch (error) { + return res.status(500).json({ + code: 500, + message: '删除文件时出错', + error: error.message, + }); + } + } +} diff --git a/server/src/modules/survey/dto/getdownload.dto.ts b/server/src/modules/survey/dto/getdownload.dto.ts new file mode 100644 index 00000000..5f56009f --- /dev/null +++ b/server/src/modules/survey/dto/getdownload.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; + +export class GetDownloadDto { + @ApiProperty({ description: '问卷id', required: true }) + surveyId: string; + @ApiProperty({ description: '是否脱密', required: true }) + isDesensitive: boolean; + + static validate(data) { + return Joi.object({ + surveyId: Joi.string().required(), + isDesensitive: Joi.boolean().default(true), // 默认true就是需要脱敏 + }).validate(data); + } +} +export class GetDownloadListDto { + @ApiProperty({ description: '拥有者id', required: true }) + ownerId: string; + @ApiProperty({ description: '当前页', required: false }) + page: number; + @ApiProperty({ description: '一页大小', required: false }) + pageSize: number; + + static validate(data) { + return Joi.object({ + ownerId: Joi.string().required(), + page: Joi.number().default(1), + pageSize: Joi.number().default(20), + }).validate(data); + } +} +export class DownloadFileByNameDto { + @ApiProperty({ description: '文件名', required: true }) + fileName: string; + owner: string; + static validate(data) { + return Joi.object({ + fileName: Joi.string().required(), + owner: Joi.string().required(), + }).validate(data); + } +} diff --git a/server/src/modules/survey/services/dataStatistic.service.ts b/server/src/modules/survey/services/dataStatistic.service.ts index cd958a7f..c31746af 100644 --- a/server/src/modules/survey/services/dataStatistic.service.ts +++ b/server/src/modules/survey/services/dataStatistic.service.ts @@ -8,6 +8,7 @@ import { keyBy } from 'lodash'; import { DataItem } from 'src/interfaces/survey'; import { ResponseSchema } from 'src/models/responseSchema.entity'; import { getListHeadByDataList } from '../utils'; + @Injectable() export class DataStatisticService { private radioType = ['radio-star', 'radio-nps']; diff --git a/server/src/modules/survey/services/message.service.ts b/server/src/modules/survey/services/message.service.ts new file mode 100644 index 00000000..ad18bf36 --- /dev/null +++ b/server/src/modules/survey/services/message.service.ts @@ -0,0 +1,87 @@ +import { EventEmitter } from 'events'; +import { SurveyDownloadService } from './surveyDownload.service'; +import { Inject, Injectable } from '@nestjs/common'; +import { ResponseSchema } from 'src/models/responseSchema.entity'; + +interface QueueItem { + surveyId: string; + responseSchema: ResponseSchema; + isDesensitive: boolean; + id: object; +} + +@Injectable() +export class MessageService extends EventEmitter { + private queue: QueueItem[]; + private concurrency: number; + private processing: number; + + constructor( + @Inject('NumberToken') concurrency: number, + private readonly surveyDownloadService: SurveyDownloadService, + ) { + super(); + this.queue = []; + this.concurrency = concurrency; + this.processing = 0; + this.on('messageAdded', this.processMessages); + } + + public addMessage({ + surveyId, + responseSchema, + isDesensitive, + id, + }: { + surveyId: string; + responseSchema: ResponseSchema; + isDesensitive: boolean; + id: object; + }) { + const message = { + surveyId, + responseSchema, + isDesensitive, + id, + }; + this.queue.push(message); + this.emit('messageAdded'); + } + + private processMessages = async (): Promise => { + if (this.processing >= this.concurrency || this.queue.length === 0) { + return; + } + + const messagesToProcess = Math.min( + this.queue.length, + this.concurrency - this.processing, + ); + const messages = this.queue.splice(0, messagesToProcess); + + this.processing += messagesToProcess; + + await Promise.all( + messages.map(async (message) => { + console.log(`开始计算: ${message}`); + await this.handleMessage(message); + this.emit('messageProcessed', message); + }), + ); + + this.processing -= messagesToProcess; + if (this.queue.length > 0) { + setImmediate(() => this.processMessages()); + } + }; + + async handleMessage(message: QueueItem) { + const { surveyId, responseSchema, isDesensitive, id } = message; + await this.surveyDownloadService.getDownloadPath({ + responseSchema, + surveyId, + isDesensitive, + id, + }); + } +} diff --git a/server/src/modules/survey/services/surveyDownload.service.ts b/server/src/modules/survey/services/surveyDownload.service.ts new file mode 100644 index 00000000..c5b4d76b --- /dev/null +++ b/server/src/modules/survey/services/surveyDownload.service.ts @@ -0,0 +1,365 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { SurveyResponse } from 'src/models/surveyResponse.entity'; + +import moment from 'moment'; +import { keyBy } from 'lodash'; +import { DataItem } from 'src/interfaces/survey'; +import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { getListHeadByDataList } from '../utils'; +//后添加 +import { promises } from 'fs'; +import { join } from 'path'; +import { SurveyDownload } from 'src/models/surveyDownload.entity'; +import { SurveyMeta } from 'src/models/surveyMeta.entity'; +import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; +import { RECORD_STATUS } from 'src/enums'; +import * as cron from 'node-cron'; +import fs from 'fs'; +import path from 'path'; + +@Injectable() +export class SurveyDownloadService implements OnModuleInit { + private radioType = ['radio-star', 'radio-nps']; + + constructor( + @InjectRepository(SurveyResponse) + private readonly surveyResponseRepository: MongoRepository, + @InjectRepository(SurveyDownload) + private readonly SurveyDownloadRepository: MongoRepository, + @InjectRepository(SurveyMeta) + private readonly SurveyDmetaRepository: MongoRepository, + private readonly pluginManager: XiaojuSurveyPluginManager, + ) {} + //初始化一个自动删除过期文件的方法 + async onModuleInit() { + cron.schedule('0 0 * * *', async () => { + try { + const files = await this.SurveyDownloadRepository.find({ + where: { + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }, + }); + const now = Date.now(); + + for (const file of files) { + if (!file.downloadTime || !file.filePath) { + continue; + } + + const fileSaveDate = Number(file.downloadTime); + const diffDays = (now - fileSaveDate) / (1000 * 60 * 60 * 24); + + if (diffDays > 10) { + this.deleteDownloadFile({ + owner: file.onwer, + fileName: file.filename, + }); + } + } + } catch (err) { + console.error('删除文件错误', err); + } + }); + } + + async createDownload({ + surveyId, + responseSchema, + }: { + surveyId: string; + responseSchema: ResponseSchema; + }) { + const [surveyMeta] = await this.SurveyDmetaRepository.find({ + where: { + surveyPath: responseSchema.surveyPath, + }, + }); + const newSurveyDownload = this.SurveyDownloadRepository.create({ + pageId: surveyId, + surveyPath: responseSchema.surveyPath, + title: responseSchema.title, + fileSize: '计算中', + downloadTime: String(Date.now()), + onwer: surveyMeta.owner, + }); + newSurveyDownload.curStatus = { + status: RECORD_STATUS.COMOPUTETING, + date: Date.now(), + }; + return (await this.SurveyDownloadRepository.save(newSurveyDownload))._id; + } + + private formatHead(listHead = []) { + const head = []; + + listHead.forEach((headItem) => { + head.push({ + field: headItem.field, + title: headItem.title, + }); + + if (headItem.othersCode?.length) { + headItem.othersCode.forEach((item) => { + head.push({ + field: item.code, + title: `${headItem.title}-${item.option}`, + }); + }); + } + }); + + return head; + } + async getDownloadPath({ + surveyId, + responseSchema, + isDesensitive, + id, + }: { + surveyId: string; + responseSchema: ResponseSchema; + isDesensitive: boolean; + id: object; + }) { + const dataList = responseSchema?.code?.dataConf?.dataList || []; + const Head = getListHeadByDataList(dataList); + const listHead = this.formatHead(Head); + const dataListMap = keyBy(dataList, 'field'); + const where = { + pageId: surveyId, + 'curStatus.status': { + $ne: 'removed', + }, + }; + const [surveyResponseList] = + await this.surveyResponseRepository.findAndCount({ + where, + order: { + createDate: -1, + }, + }); + const [surveyMeta] = await this.SurveyDmetaRepository.find({ + where: { + surveyPath: responseSchema.surveyPath, + }, + }); + const listBody = surveyResponseList.map((submitedData) => { + const data = submitedData.data; + const dataKeys = Object.keys(data); + + for (const itemKey of dataKeys) { + if (typeof itemKey !== 'string') { + continue; + } + if (itemKey.indexOf('data') !== 0) { + continue; + } + // 获取题目id + const itemConfigKey = itemKey.split('_')[0]; + // 获取题目 + const itemConfig: DataItem = dataListMap[itemConfigKey]; + // 题目删除会出现,数据列表报错 + if (!itemConfig) { + continue; + } + // 处理选项的更多输入框 + if ( + this.radioType.includes(itemConfig.type) && + !data[`${itemConfigKey}_custom`] + ) { + data[`${itemConfigKey}_custom`] = + data[`${itemConfigKey}_${data[itemConfigKey]}`]; + } + // 将选项id还原成选项文案 + if ( + Array.isArray(itemConfig.options) && + itemConfig.options.length > 0 + ) { + const optionTextMap = keyBy(itemConfig.options, 'hash'); + data[itemKey] = Array.isArray(data[itemKey]) + ? data[itemKey] + .map((item) => optionTextMap[item]?.text || item) + .join(',') + : optionTextMap[data[itemKey]]?.text || data[itemKey]; + } + } + return { + ...data, + difTime: (submitedData.difTime / 1000).toFixed(2), + createDate: moment(submitedData.createDate).format( + 'YYYY-MM-DD HH:mm:ss', + ), + }; + }); + if (isDesensitive) { + // 脱敏 + listBody.forEach((item) => { + this.pluginManager.triggerHook('desensitiveData', item); + }); + } + + let titlesCsv = + listHead + .map((question) => `"${question.title.replace(/<[^>]*>/g, '')}"`) + .join(',') + '\n'; + // 获取工作区根目录的路径 + const rootDir = process.cwd(); + const timestamp = Date.now(); + + const filePath = join( + rootDir, + 'download', + `${surveyMeta.owner}`, + `${surveyMeta.title}_${timestamp}.csv`, + ); + const dirPath = path.dirname(filePath); + fs.mkdirSync(dirPath, { recursive: true }); + listBody.forEach((row) => { + const rowValues = listHead.map((head) => { + const value = row[head.field]; + if (typeof value === 'string') { + // 处理字符串中的特殊字符 + return `"${value.replace(/"/g, '""').replace(/<[^>]*>/g, '')}"`; + } + return `"${value}"`; // 其他类型的值(数字、布尔等)直接转换为字符串 + }); + titlesCsv += rowValues.join(',') + '\n'; + }); + const BOM = '\uFEFF'; + let size = 0; + const newSurveyDownload = await this.SurveyDownloadRepository.findOne({ + where: { + _id: id, + }, + }); + fs.writeFile(filePath, BOM + titlesCsv, { encoding: 'utf8' }, (err) => { + if (err) { + console.error('保存文件时出错:', err); + } else { + console.log('文件已保存:', filePath); + fs.stat(filePath, (err, stats) => { + if (err) { + console.error('获取文件大小时出错:', err); + } else { + console.log('文件大小:', stats.size); + size = stats.size; + const filename = `${surveyMeta.title}_${timestamp}.csv`; + const fileType = 'csv'; + (newSurveyDownload.pageId = surveyId), + (newSurveyDownload.surveyPath = responseSchema.surveyPath), + (newSurveyDownload.title = responseSchema.title), + (newSurveyDownload.filePath = filePath), + (newSurveyDownload.filename = filename), + (newSurveyDownload.fileType = fileType), + (newSurveyDownload.fileSize = String(size)), + (newSurveyDownload.downloadTime = String(Date.now())), + (newSurveyDownload.onwer = surveyMeta.owner); + newSurveyDownload.curStatus = { + status: RECORD_STATUS.NEW, + date: Date.now(), + }; + + this.SurveyDownloadRepository.save(newSurveyDownload); + } + }); + } + }); + + return { + filePath, + }; + } + + async getDownloadList({ + ownerId, + page, + pageSize, + }: { + ownerId: string; + page: number; + pageSize: number; + }) { + const where = { + onwer: ownerId, + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }; + const [surveyDownloadList, total] = + await this.SurveyDownloadRepository.findAndCount({ + where, + take: pageSize, + skip: (page - 1) * pageSize, + order: { + createDate: -1, + }, + }); + const listBody = surveyDownloadList.map((data) => { + return { + _id: data._id, + filename: data.filename, + fileType: data.fileType, + fileSize: data.fileSize, + downloadTime: data.downloadTime, + curStatus: data.curStatus.status, + owner: data.onwer, + }; + }); + return { + total, + listBody, + }; + } + async test({}: { fileName: string }) { + return null; + } + + async deleteDownloadFile({ + owner, + fileName, + }: { + owner: string; + fileName: string; + }) { + const where = { + filename: fileName, + }; + + const [surveyDownloadList] = await this.SurveyDownloadRepository.find({ + where, + }); + if (surveyDownloadList.curStatus.status === RECORD_STATUS.REMOVED) { + return 0; + } + + const newStatusInfo = { + status: RECORD_STATUS.REMOVED, + date: Date.now(), + }; + surveyDownloadList.curStatus = newStatusInfo; + // if (Array.isArray(survey.statusList)) { + // survey.statusList.push(newStatusInfo); + // } else { + // survey.statusList = [newStatusInfo]; + // } + const rootDir = process.cwd(); // 获取当前工作目录 + const filePath = join(rootDir, 'download', owner, fileName); + try { + await promises.unlink(filePath); + console.log(`File at ${filePath} has been successfully deleted.`); + } catch (error) { + console.error(`Failed to delete file at ${filePath}:`, error); + } + await this.SurveyDownloadRepository.save(surveyDownloadList); + return { + code: 200, + data: { + message: '删除成功', + }, + }; + } +} diff --git a/server/src/modules/survey/survey.module.ts b/server/src/modules/survey/survey.module.ts index a5c58068..4c613fac 100644 --- a/server/src/modules/survey/survey.module.ts +++ b/server/src/modules/survey/survey.module.ts @@ -29,6 +29,11 @@ import { SurveyHistoryService } from './services/surveyHistory.service'; import { SurveyMetaService } from './services/surveyMeta.service'; import { ContentSecurityService } from './services/contentSecurity.service'; import { CollaboratorService } from './services/collaborator.service'; +//后添加 +import { SurveyDownload } from 'src/models/surveyDownload.entity'; +import { SurveyDownloadService } from './services/surveyDownload.service'; +import { SurveyDownloadController } from './controllers/surveyDownload.controller'; +import { MessageService } from './services/message.service'; @Module({ imports: [ @@ -39,6 +44,8 @@ import { CollaboratorService } from './services/collaborator.service'; SurveyResponse, Word, Collaborator, + //后添加 + SurveyDownload, ]), ConfigModule, SurveyResponseModule, @@ -52,6 +59,8 @@ import { CollaboratorService } from './services/collaborator.service'; SurveyMetaController, SurveyUIController, CollaboratorController, + //后添加 + SurveyDownloadController, ], providers: [ DataStatisticService, @@ -62,6 +71,13 @@ import { CollaboratorService } from './services/collaborator.service'; ContentSecurityService, CollaboratorService, LoggerProvider, + //后添加 + SurveyDownloadService, + MessageService, + { + provide: 'NumberToken', // 使用一个唯一的标识符 + useValue: 10, // 假设这是你想提供的值 + }, ], }) export class SurveyModule {} diff --git a/web/components.d.ts b/web/components.d.ts index c897a9e0..a1583a24 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -28,7 +28,6 @@ declare module 'vue' { ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] - ElRow: typeof import('element-plus/es')['ElRow'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelectV2: typeof import('element-plus/es')['ElSelectV2'] ElSlider: typeof import('element-plus/es')['ElSlider'] @@ -51,7 +50,6 @@ declare module 'vue' { IEpMinus: typeof import('~icons/ep/minus')['default'] IEpMonitor: typeof import('~icons/ep/monitor')['default'] IEpMore: typeof import('~icons/ep/more')['default'] - IEpPlus: typeof import('~icons/ep/plus')['default'] IEpQuestionFilled: typeof import('~icons/ep/question-filled')['default'] IEpRank: typeof import('~icons/ep/rank')['default'] IEpRemove: typeof import('~icons/ep/remove')['default'] diff --git a/web/src/management/api/analysis.js b/web/src/management/api/analysis.js index 8e55a48a..04b1fb35 100644 --- a/web/src/management/api/analysis.js +++ b/web/src/management/api/analysis.js @@ -8,3 +8,12 @@ export const getRecycleList = (data) => { } }) } +//问卷下载 +export const downloadSurvey = ({ surveyId, isDesensitive }) => { + return axios.get('/survey/surveyDownload/download', { + params: { + surveyId, + isDesensitive + } + }) +} diff --git a/web/src/management/api/download.js b/web/src/management/api/download.js new file mode 100644 index 00000000..ef658d9c --- /dev/null +++ b/web/src/management/api/download.js @@ -0,0 +1,34 @@ +import axios from './base' + +//问卷列表 +export const getDownloadList = ({ ownerId, page, pageSize }) => { + return axios.get('/survey/surveyDownload/getdownloadList', { + params: { + ownerId, + page, + pageSize + } + }) +} +//问卷下载 +export const getDownloadFileByName = (fileName) => { + return axios + .get('/survey/surveyDownload/getdownloadfileByName', { + params: { + fileName + }, + responseType: 'blob' + }) + .then((res) => { + return res + }) +} +//问卷删除 +export const deleteDownloadFile = (owner, fileName) => { + return axios.get('/survey/surveyDownload/deletefileByName', { + params: { + owner, + fileName + } + }) +} diff --git a/web/src/management/pages/analysis/AnalysisPage.vue b/web/src/management/pages/analysis/AnalysisPage.vue index 0577b8c3..5b2ae511 100644 --- a/web/src/management/pages/analysis/AnalysisPage.vue +++ b/web/src/management/pages/analysis/AnalysisPage.vue @@ -11,6 +11,17 @@ @input="onIsShowOriginChange" > +
+ + + 导出数据 +
+ @@ -38,7 +49,7 @@ import 'element-plus/theme-chalk/src/message.scss' import EmptyIndex from '@/management/components/EmptyIndex.vue' import LeftMenu from '@/management/components/LeftMenu.vue' -import { getRecycleList } from '@/management/api/analysis' +import { getRecycleList, downloadSurvey } from '@/management/api/analysis' import DataTable from './components/DataTable.vue' @@ -59,7 +70,8 @@ export default { }, currentPage: 1, isShowOriginData: false, - tmpIsShowOriginData: false + tmpIsShowOriginData: false, + isDownloadDesensitive: true } }, computed: {}, @@ -89,6 +101,34 @@ export default { ElMessage.error('查询回收数据失败,请重试') } }, + async onDownload() { + try { + await ElMessageBox.confirm('是否确认下载?', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + } catch (error) { + console.log('取消下载') + return + } + this.exportData() + this.gotoDownloadList() + }, + async gotoDownloadList() { + try { + await ElMessageBox.confirm('计算中,是否前往下载中心?', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + } catch (error) { + console.log('取消跳转') + return + } + + this.$router.push('/survey/download') + }, handleCurrentChange(current) { if (this.mainTableLoading) { return @@ -125,6 +165,29 @@ export default { this.tmpIsShowOriginData = data await this.init() this.isShowOriginData = data + }, + async onisDownloadDesensitive() { + if (this.isDownloadDesensitive) { + this.isDownloadDesensitive = false + } else { + this.isDownloadDesensitive = true + } + }, + + async exportData() { + try { + const res = await downloadSurvey({ + surveyId: String(this.$route.params.id), + isDesensitive: this.isDownloadDesensitive + }) + console.log(this.$route.params.id) + if (res.code === 200) { + ElMessage.success('下载成功') + } + } catch (error) { + ElMessage.error('下载失败') + ElMessage.error(error.message) + } } }, @@ -158,6 +221,8 @@ export default { .menus { margin-bottom: 20px; + display: flex; + justify-content: space-between; } .content-wrapper { diff --git a/web/src/management/pages/download/SurveyDownloadPage.vue b/web/src/management/pages/download/SurveyDownloadPage.vue new file mode 100644 index 00000000..8a2d8b1e --- /dev/null +++ b/web/src/management/pages/download/SurveyDownloadPage.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/web/src/management/pages/download/components/DownloadList.vue b/web/src/management/pages/download/components/DownloadList.vue new file mode 100644 index 00000000..a13a3531 --- /dev/null +++ b/web/src/management/pages/download/components/DownloadList.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/web/src/management/pages/list/config/index.js b/web/src/management/pages/list/config/index.js index 5576d50a..cd724748 100644 --- a/web/src/management/pages/list/config/index.js +++ b/web/src/management/pages/list/config/index.js @@ -92,7 +92,7 @@ export const statusMaps = { new: '未发布', editing: '修改中', published: '已发布', - removed: '', + removed: '已删除', pausing: '' } diff --git a/web/src/management/pages/list/index.vue b/web/src/management/pages/list/index.vue index 3a86203a..248b4bed 100644 --- a/web/src/management/pages/list/index.vue +++ b/web/src/management/pages/list/index.vue @@ -5,6 +5,7 @@ logo 问卷列表 + 下载页面