diff --git a/README.md b/README.md index 0973f75d..ddd9e1ab 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ docs - docs + docs diff --git a/README_EN.md b/README_EN.md index b69c0da0..b827ecf7 100644 --- a/README_EN.md +++ b/README_EN.md @@ -19,10 +19,10 @@ pr - docs + docs - docs + docs diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 88c6265d..34854cdf 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -39,6 +39,11 @@ http { try_files $uri $uri/ /management.html; } + location /management/preview/ { + try_files $uri $uri/ /render.html; + } + + location /render/ { try_files $uri $uri/ /render.html; } 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/package.json b/server/package.json index 44164d74..f6aade83 100644 --- a/server/package.json +++ b/server/package.json @@ -97,5 +97,9 @@ "moduleNameMapper": { "^src/(.*)$": "/$1" } + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.6.0" } } 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/survey.controller.ts b/server/src/modules/survey/controllers/survey.controller.ts index c8b59fb2..df1a3d62 100644 --- a/server/src/modules/survey/controllers/survey.controller.ts +++ b/server/src/modules/survey/controllers/survey.controller.ts @@ -223,6 +223,38 @@ export class SurveyController { }; } + @Get('/getPreviewSchema') + @HttpCode(200) + async getPreviewSchema( + @Query() + queryInfo: { + surveyPath: string; + }, + @Request() + req, + ) { + const { value, error } = Joi.object({ + surveyId: Joi.string().required(), + }).validate({ surveyId: queryInfo.surveyPath }); + + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const surveyId = value.surveyId; + const surveyConf = + await this.surveyConfService.getSurveyConfBySurveyId(surveyId); + const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId }); + return { + code: 200, + data: { + ...surveyConf, + title: surveyMeta?.title, + surveyPath: surveyMeta?.surveyPath, + }, + }; + } + @Post('/publishSurvey') @HttpCode(200) @UseGuards(SurveyGuard) 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 8824b169..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'] @@ -46,10 +45,11 @@ declare module 'vue' { IEpClose: typeof import('~icons/ep/close')['default'] IEpCopyDocument: typeof import('~icons/ep/copy-document')['default'] IEpDelete: typeof import('~icons/ep/delete')['default'] + IEpIphone: typeof import('~icons/ep/iphone')['default'] IEpLoading: typeof import('~icons/ep/loading')['default'] 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'] @@ -58,6 +58,8 @@ declare module 'vue' { IEpSortDown: typeof import('~icons/ep/sort-down')['default'] IEpSortUp: typeof import('~icons/ep/sort-up')['default'] IEpTop: typeof import('~icons/ep/top')['default'] + IEpView: typeof import('~icons/ep/view')['default'] + IEpWarningFilled: typeof import('~icons/ep/warning-filled')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/web/public/imgs/preview-phone.png b/web/public/imgs/preview-phone.png new file mode 100644 index 00000000..375bf1b5 Binary files /dev/null and b/web/public/imgs/preview-phone.png differ diff --git a/web/src/common/typeEnum.ts b/web/src/common/typeEnum.ts new file mode 100644 index 00000000..755e6f24 --- /dev/null +++ b/web/src/common/typeEnum.ts @@ -0,0 +1,50 @@ +// 题型枚举 +export enum QUESTION_TYPE { + TEXT = 'text', + TEXTAREA = 'textarea', + RADIO = 'radio', + CHECKBOX = 'checkbox', + BINARY_CHOICE = 'binary-choice', + RADIO_STAR = 'radio-star', + RADIO_NPS = 'radio-nps', + VOTE = 'vote', +} + +// 题目类型标签映射对象 +export const typeTagLabels: Record = { + [QUESTION_TYPE.TEXT]: '单行输入框', + [QUESTION_TYPE.TEXTAREA]: '多行输入框', + [QUESTION_TYPE.RADIO]: '单选', + [QUESTION_TYPE.CHECKBOX]: '多选', + [QUESTION_TYPE.BINARY_CHOICE]: '判断', + [QUESTION_TYPE.RADIO_STAR]: '评分', + [QUESTION_TYPE.RADIO_NPS]: 'NPS评分', + [QUESTION_TYPE.VOTE]: '投票' +} + +// 输入类题型 +export const INPUT = [ + QUESTION_TYPE.TEXT, + QUESTION_TYPE.TEXTAREA +] + +// 选择类题型分类 +export const NORMAL_CHOICES = [ + QUESTION_TYPE.RADIO, + QUESTION_TYPE.CHECKBOX +] + +// 选择类题型分类 +export const CHOICES = [ + QUESTION_TYPE.RADIO, + QUESTION_TYPE.CHECKBOX, + QUESTION_TYPE.BINARY_CHOICE, + QUESTION_TYPE.VOTE +] + +// 评分题题型分类 +export const RATES = [ + QUESTION_TYPE.RADIO_STAR, + QUESTION_TYPE.RADIO_NPS +] + 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/api/space.ts b/web/src/management/api/space.ts index 24de4466..b0aadda9 100644 --- a/web/src/management/api/space.ts +++ b/web/src/management/api/space.ts @@ -1,5 +1,4 @@ import axios from './base' - // 空间 export const createSpace = ({ name, description, members }: any) => { return axios.post('/workspace', { name, description, members }) @@ -29,7 +28,7 @@ export const getUserList = (username: string) => { }) } -// 协作权限列表 +// 获取协作权限下拉框枚举 export const getPermissionList = () => { return axios.get('collaborator/getPermissionList') } @@ -72,4 +71,4 @@ export const getCollaboratorPermissions = (surveyId: string) => { surveyId } }) -} +} \ No newline at end of file diff --git a/web/src/management/components/LeftMenu.vue b/web/src/management/components/LeftMenu.vue index 7f631fe6..e9dc05ab 100644 --- a/web/src/management/components/LeftMenu.vue +++ b/web/src/management/components/LeftMenu.vue @@ -26,7 +26,7 @@ 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/edit/components/ModuleNavbar.vue b/web/src/management/pages/edit/components/ModuleNavbar.vue index 4754af42..0965cddf 100644 --- a/web/src/management/pages/edit/components/ModuleNavbar.vue +++ b/web/src/management/pages/edit/components/ModuleNavbar.vue @@ -8,6 +8,7 @@
+ @@ -23,6 +24,7 @@ import BackPanel from '../modules/generalModule/BackPanel.vue' import TitlePanel from '../modules/generalModule/TitlePanel.vue' import NavPanel from '../modules/generalModule/NavPanel.vue' import HistoryPanel from '../modules/contentModule/HistoryPanel.vue' +import PreviewPanel from '../modules/contentModule/PreviewPanel.vue' import SavePanel from '../modules/contentModule/SavePanel.vue' import PublishPanel from '../modules/contentModule/PublishPanel.vue' diff --git a/web/src/management/pages/edit/modules/contentModule/PreviewPanel.vue b/web/src/management/pages/edit/modules/contentModule/PreviewPanel.vue new file mode 100644 index 00000000..7c51c200 --- /dev/null +++ b/web/src/management/pages/edit/modules/contentModule/PreviewPanel.vue @@ -0,0 +1,177 @@ + + + diff --git a/web/src/management/pages/edit/modules/generalModule/BackPanel.vue b/web/src/management/pages/edit/modules/generalModule/BackPanel.vue index 60a595dd..ff728371 100644 --- a/web/src/management/pages/edit/modules/generalModule/BackPanel.vue +++ b/web/src/management/pages/edit/modules/generalModule/BackPanel.vue @@ -5,7 +5,13 @@
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')