From a5c94f0d19a555e7f3d122bbe787f28beff1ba98 Mon Sep 17 00:00:00 2001 From: Oseast <1628868023@qq.com> Date: Fri, 14 Jun 2024 16:09:18 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0=E4=BA=86=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E6=96=87=E4=BB=B6=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD=E5=92=8C=E7=9B=B8=E5=BA=94=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 + server/src/app.module.ts | 2 + server/src/config/index.ts | 26 ++ server/src/enums/index.ts | 1 + server/src/models/surveyDownload.entity.ts | 48 +++ .../controllers/dataStatistic.controller.ts | 8 + .../controllers/surveyDownload.controller.ts | 216 +++++++++++ .../src/modules/survey/dto/getdownload.dto.ts | 43 +++ .../survey/services/dataStatistic.service.ts | 4 + .../survey/services/message.service.ts | 89 +++++ .../survey/services/surveyDownload.service.ts | 365 ++++++++++++++++++ server/src/modules/survey/survey.module.ts | 17 + server/src/modules/survey/utils/index.ts | 2 + web/components.d.ts | 4 - web/src/management/api/analysis.js | 9 + web/src/management/api/download.js | 33 ++ web/src/management/api/survey.js | 2 + .../pages/analysis/AnalysisPage.vue | 79 +++- .../pages/download/SurveyDownloadPage.vue | 132 +++++++ .../download/components/DownloadList.vue | 305 +++++++++++++++ web/src/management/pages/list/config/index.js | 2 +- web/src/management/pages/list/index.vue | 8 + web/src/management/router/index.ts | 8 + web/src/management/store/download/index.js | 61 +++ web/src/management/store/edit/mutations.js | 5 +- web/src/management/store/edit/state.js | 3 +- web/src/management/store/index.js | 4 +- 27 files changed, 1470 insertions(+), 11 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..760033c1 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..4ca78c6c --- /dev/null +++ b/server/src/config/index.ts @@ -0,0 +1,26 @@ +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..6c2742fe --- /dev/null +++ b/server/src/models/surveyDownload.entity.ts @@ -0,0 +1,48 @@ +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..d9c5c037 100644 --- a/server/src/modules/survey/controllers/dataStatistic.controller.ts +++ b/server/src/modules/survey/controllers/dataStatistic.controller.ts @@ -20,6 +20,10 @@ 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 { writeFile } from 'fs'; +import { join } from 'path'; +import { SurveyDownloadService } from '../services/surveyDownload.service'; @ApiTags('survey') @ApiBearerAuth() @@ -30,6 +34,8 @@ export class DataStatisticController { private readonly dataStatisticService: DataStatisticService, private readonly pluginManager: XiaojuSurveyPluginManager, private readonly logger: Logger, + // + private readonly surveyDownloadService: SurveyDownloadService, ) {} @Get('/dataTable') @@ -71,6 +77,8 @@ export class DataStatisticController { }); } + + return { code: 200, data: { 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..f5b214e1 --- /dev/null +++ b/server/src/modules/survey/controllers/surveyDownload.controller.ts @@ -0,0 +1,216 @@ +import { + Controller, + Get, + Query, + HttpCode, + UseGuards, + SetMetadata, + Request, + Res, + // Response, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; + +import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service'; + +import { Authentication } from 'src/guards/authentication.guard'; +import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; +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 { findKey } from 'lodash'; +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, + }); + } + } +} \ No newline at end of file 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..3fdbea4f --- /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..fb566096 100644 --- a/server/src/modules/survey/services/dataStatistic.service.ts +++ b/server/src/modules/survey/services/dataStatistic.service.ts @@ -8,6 +8,10 @@ import { keyBy } from 'lodash'; import { DataItem } from 'src/interfaces/survey'; import { ResponseSchema } from 'src/models/responseSchema.entity'; import { getListHeadByDataList } from '../utils'; +//后添加 +import { writeFile } from 'fs'; +import { join } from 'path'; + @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..a240696c --- /dev/null +++ b/server/src/modules/survey/services/message.service.ts @@ -0,0 +1,89 @@ +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..68cf7834 --- /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 { writeFile, stat,promises } from 'fs'; +import { join } from 'path'; +import { SurveyDownload } from 'src/models/surveyDownload.entity'; +import { SurveyMeta } from 'src/models/surveyMeta.entity'; +import e from 'express'; +import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; +import { RECORD_STATUS } from 'src/enums'; +import * as cron from 'node-cron'; + +@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, total] = + 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 fs = require('fs'); + const path = require('path'); + 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, + }: { + fileName: string; + }) {return null;} + + async deleteDownloadFile({ + owner, + fileName, + }: { + owner: string; + fileName: string; + }) { + const a=fileName; + 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: '删除成功', + }, + }; + } +} \ No newline at end of file diff --git a/server/src/modules/survey/survey.module.ts b/server/src/modules/survey/survey.module.ts index a5c58068..e127a701 100644 --- a/server/src/modules/survey/survey.module.ts +++ b/server/src/modules/survey/survey.module.ts @@ -29,6 +29,12 @@ 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 +45,8 @@ import { CollaboratorService } from './services/collaborator.service'; SurveyResponse, Word, Collaborator, + //后添加 + SurveyDownload ]), ConfigModule, SurveyResponseModule, @@ -52,6 +60,8 @@ import { CollaboratorService } from './services/collaborator.service'; SurveyMetaController, SurveyUIController, CollaboratorController, + //后添加 + SurveyDownloadController, ], providers: [ DataStatisticService, @@ -62,6 +72,13 @@ import { CollaboratorService } from './services/collaborator.service'; ContentSecurityService, CollaboratorService, LoggerProvider, + //后添加 + SurveyDownloadService, + MessageService, + { + provide: 'NumberToken', // 使用一个唯一的标识符 + useValue: 10, // 假设这是你想提供的值 + }, ], }) export class SurveyModule {} diff --git a/server/src/modules/survey/utils/index.ts b/server/src/modules/survey/utils/index.ts index fd988f55..e1c5aeba 100644 --- a/server/src/modules/survey/utils/index.ts +++ b/server/src/modules/survey/utils/index.ts @@ -6,6 +6,8 @@ import npsCode from '../template/surveyTemplate/survey/nps.json'; import registerCode from '../template/surveyTemplate/survey/register.json'; import voteCode from '../template/surveyTemplate/survey/vote.json'; + + const schemaDataMap = { normal: normalCode, nps: npsCode, diff --git a/web/components.d.ts b/web/components.d.ts index 8824b169..5d7ec366 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'] @@ -45,11 +44,8 @@ declare module 'vue' { IEpCirclePlus: typeof import('~icons/ep/circle-plus')['default'] IEpClose: typeof import('~icons/ep/close')['default'] IEpCopyDocument: typeof import('~icons/ep/copy-document')['default'] - IEpDelete: typeof import('~icons/ep/delete')['default'] IEpLoading: typeof import('~icons/ep/loading')['default'] - IEpMinus: typeof import('~icons/ep/minus')['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..43705030 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..03bae4ca --- /dev/null +++ b/web/src/management/api/download.js @@ -0,0 +1,33 @@ +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: { + owner, + fileName + }, + responseType: 'blob', + }).then(res => { + return res + }); +} +//问卷删除 +export const deleteDownloadFile = (owner,fileName) => { + return axios.get('/survey/surveyDownload/deletefileByName', { + params: { + owner, + fileName + }, + }) +} \ No newline at end of file diff --git a/web/src/management/api/survey.js b/web/src/management/api/survey.js index 8cbc1444..8075da76 100644 --- a/web/src/management/api/survey.js +++ b/web/src/management/api/survey.js @@ -52,3 +52,5 @@ export const deleteSurvey = (surveyId) => { export const updateSurvey = (data) => { return axios.post('/survey/updateMeta', data) } + + diff --git a/web/src/management/pages/analysis/AnalysisPage.vue b/web/src/management/pages/analysis/AnalysisPage.vue index 0577b8c3..19f09127 100644 --- a/web/src/management/pages/analysis/AnalysisPage.vue +++ b/web/src/management/pages/analysis/AnalysisPage.vue @@ -11,6 +11,17 @@ @input="onIsShowOriginChange" > +
+ + + 导出数据 +
+ @@ -38,9 +49,13 @@ 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' +import { el } from 'element-plus/es/locales.mjs' + + + export default { name: 'AnalysisPage', @@ -59,7 +74,8 @@ export default { }, currentPage: 1, isShowOriginData: false, - tmpIsShowOriginData: false + tmpIsShowOriginData: false, + isDownloadDesensitive: true, } }, computed: {}, @@ -89,6 +105,37 @@ 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 @@ -117,7 +164,7 @@ export default { return head }, - async onIsShowOriginChange(data) { + async onIsShowOriginChange(data) { if (this.mainTableLoading) { return } @@ -125,6 +172,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) + } } }, @@ -133,6 +203,7 @@ export default { EmptyIndex, LeftMenu } + } @@ -158,6 +229,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..07cf2c70 --- /dev/null +++ b/web/src/management/pages/download/SurveyDownloadPage.vue @@ -0,0 +1,132 @@ + + + + + \ No newline at end of file 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..11c27c39 --- /dev/null +++ b/web/src/management/pages/download/components/DownloadList.vue @@ -0,0 +1,305 @@ + + + + + + \ No newline at end of file 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..1e87321a 100644 --- a/web/src/management/pages/list/index.vue +++ b/web/src/management/pages/list/index.vue @@ -5,6 +5,7 @@ logo 问卷列表 + 下载页面 diff --git a/web/src/render/pages/IndexPage.vue b/web/src/render/pages/IndexPage.vue index 7f02b5cd..338f03a5 100644 --- a/web/src/render/pages/IndexPage.vue +++ b/web/src/render/pages/IndexPage.vue @@ -1,156 +1,71 @@ - diff --git a/web/src/render/pages/RenderPage.vue b/web/src/render/pages/RenderPage.vue new file mode 100644 index 00000000..a43e0715 --- /dev/null +++ b/web/src/render/pages/RenderPage.vue @@ -0,0 +1,162 @@ + + + diff --git a/web/src/render/router/index.ts b/web/src/render/router/index.ts new file mode 100644 index 00000000..01d8f095 --- /dev/null +++ b/web/src/render/router/index.ts @@ -0,0 +1,38 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' + +const routes: RouteRecordRaw[] = [ + { + path: '/:surveyId', + component: () => import('../pages/IndexPage.vue'), + children: [ + { + path: '', + name: 'renderPage', + component: () => import('../pages/RenderPage.vue') + }, + { + path: 'success', + name: 'successPage', + component: () => import('../pages/SuccessPage.vue') + }, + { + path: 'error', + name: 'errorPage', + component: () => import('../pages/ErrorPage.vue') + } + ] + }, + { + path: '/:catchAll(.*)', + name: 'emptyPage', + component: () => import('../pages/EmptyPage.vue') + } +] +// 兼容预览模式 +const base = window.location.pathname.includes('preview') ? 'management/preview' : 'render' +const router = createRouter({ + history: createWebHistory(base), + routes +}) + +export default router diff --git a/web/src/render/store/actions.js b/web/src/render/store/actions.js index 3438ebea..05f6c169 100644 --- a/web/src/render/store/actions.js +++ b/web/src/render/store/actions.js @@ -15,16 +15,19 @@ const CODE_MAP = { NO_AUTH: 403 } const VOTE_INFO_KEY = 'voteinfo' - +import router from '../router' export default { // 初始化 - init({ commit, dispatch }, { bannerConf, baseConf, bottomConf, dataConf, skinConf, submitConf }) { + init( + { commit, dispatch }, + { bannerConf, baseConf, bottomConf, dataConf, skinConf, submitConf } + ) { commit('setEnterTime') const { begTime, endTime, answerBegTime, answerEndTime } = baseConf const { msgContent } = submitConf const now = Date.now() if (now < new Date(begTime).getTime()) { - commit('setRouter', 'errorPage') + router.push({ name: 'errorPage' }) commit('setErrorInfo', { errorType: 'overTime', errorMsg: `

问卷未到开始填写时间,暂时无法进行填写

@@ -32,7 +35,7 @@ export default { }) return } else if (now > new Date(endTime).getTime()) { - commit('setRouter', 'errorPage') + router.push({ name: 'errorPage' }) commit('setErrorInfo', { errorType: 'overTime', errorMsg: msgContent.msg_9001 || '您来晚了,感谢支持问卷~' @@ -44,7 +47,7 @@ export default { const momentStartTime = moment(`${todayStr} ${answerBegTime}`) const momentEndTime = moment(`${todayStr} ${answerEndTime}`) if (momentNow.isBefore(momentStartTime) || momentNow.isAfter(momentEndTime)) { - commit('setRouter', 'errorPage') + router.push({ name: 'errorPage' }) commit('setErrorInfo', { errorType: 'overTime', errorMsg: `

不在答题时间范围内,暂时无法进行填写

@@ -53,7 +56,6 @@ export default { return } } - commit('setRouter', 'indexPage') // 根据初始的schema生成questionData, questionSeq, rules, formValues, 这四个字段 const { questionData, questionSeq, rules, formValues } = adapter.generateData({ diff --git a/web/src/render/store/getters.js b/web/src/render/store/getters.js index 0dd0a32f..79d8a23f 100644 --- a/web/src/render/store/getters.js +++ b/web/src/render/store/getters.js @@ -10,7 +10,6 @@ export default { const questionArr = [] item.forEach((questionKey) => { - console.log('题目重新计算') const question = { ...questionData[questionKey] } // 开启显示序号 if (question.showIndex) { diff --git a/web/src/render/store/mutations.js b/web/src/render/store/mutations.js index 33bc69f0..4ada3d86 100644 --- a/web/src/render/store/mutations.js +++ b/web/src/render/store/mutations.js @@ -9,9 +9,6 @@ export default { setQuestionData(state, data) { state.questionData = data }, - setRouter(state, data) { - state.router = data - }, setErrorInfo(state, { errorType, errorMsg }) { state.errorInfo = { errorType, diff --git a/web/src/render/store/state.js b/web/src/render/store/state.js index 233de0d3..2a63c735 100644 --- a/web/src/render/store/state.js +++ b/web/src/render/store/state.js @@ -3,7 +3,6 @@ import { isMobile } from '../utils/index' export default { surveyPath: '', questionData: null, - router: '', isMobile: isMobile(), errorInfo: { errorType: '', diff --git a/web/vite.config.ts b/web/vite.config.ts index d7e5a8d8..1d36bdcf 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -34,6 +34,10 @@ const mpaPlugin = createMpaPlugin({ from: /render/, to: () => normalizePath('/src/render/index.html') }, + { + from: /management\/preview/, + to: () => normalizePath('/src/render/index.html') + }, { from: /\/|\/management\/.?/, to: () => normalizePath('/src/management/index.html')