diff --git a/server/src/models/surveyMeta.entity.ts b/server/src/models/surveyMeta.entity.ts index f3daa3117..09a11e69e 100644 --- a/server/src/models/surveyMeta.entity.ts +++ b/server/src/models/surveyMeta.entity.ts @@ -70,6 +70,9 @@ export class SurveyMeta extends BaseEntity { @Column() deletedAt: Date; + @Column({ default: false }) + isPermanentDeleted: boolean; // 是否彻底删除 + @BeforeInsert() initDefaultInfo() { const now = Date.now(); @@ -82,5 +85,13 @@ export class SurveyMeta extends BaseEntity { const subStatus = { status: RECORD_SUB_STATUS.DEFAULT, date: now }; this.subStatus = subStatus; } + + if (this.isDeleted === undefined || this.isDeleted === null) { + this.isDeleted = false; + } + + if (this.isPermanentDeleted === undefined || this.isPermanentDeleted === null) { + this.isPermanentDeleted = false; + } } } diff --git a/server/src/modules/survey/controllers/recycleBin.controller.ts b/server/src/modules/survey/controllers/recycleBin.controller.ts new file mode 100644 index 000000000..77bbad804 --- /dev/null +++ b/server/src/modules/survey/controllers/recycleBin.controller.ts @@ -0,0 +1,142 @@ +import { + Controller, + Post, + Body, + HttpCode, + UseGuards, + Request, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Authentication } from 'src/guards/authentication.guard'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { Logger } from 'src/logger'; + +import { RecycleBinService } from '../services/recycleBin.service'; +import { RecycleBinListDto, RecycleBinItemDto } from '../dto/recycleBin.dto'; + +@ApiTags('回收站') +@Controller('/api/survey/recyclebin') +@UseGuards(Authentication) +export class RecycleBinController { + constructor( + private readonly recycleBinService: RecycleBinService, + private readonly logger: Logger, + ) {} + + @Post('/list') + @HttpCode(200) + @ApiOperation({ summary: '获取回收站问卷列表' }) + @ApiResponse({ status: 200, description: '操作成功' }) + async getRecycleBinList(@Request() req, @Body() dto: RecycleBinListDto) { + const { value, error } = RecycleBinListDto.validate(dto); + + if (error) { + this.logger.error(`getRecycleBinList_parameter error: ${error.message}`); + throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const userId = req.user._id.toString(); + const { list, count } = await this.recycleBinService.getRecycleBinList(userId, value); + + return { + code: 200, + data: { + list, + count, + }, + }; + } + + @Post('/restore') + @HttpCode(200) + @ApiOperation({ summary: '恢复问卷' }) + @ApiResponse({ status: 200, description: '操作成功' }) + async restoreSurvey(@Request() req, @Body() dto: RecycleBinItemDto) { + const { value, error } = RecycleBinItemDto.validate(dto); + + if (error) { + this.logger.error(`restoreSurvey_parameter error: ${error.message}`); + throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const userId = req.user._id.toString(); + await this.recycleBinService.restoreSurvey(userId, value.id, value.surveyPath); + + return { + code: 200, + data: { + success: true, + }, + }; + } + + @Post('/delete') + @HttpCode(200) + @ApiOperation({ summary: '永久删除问卷' }) + @ApiResponse({ status: 200, description: '操作成功' }) + async permanentDeleteSurvey(@Request() req, @Body() dto: RecycleBinItemDto) { + const { value, error } = RecycleBinItemDto.validate(dto); + + if (error) { + this.logger.error(`permanentDeleteSurvey_parameter error: ${error.message}`); + throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const userId = req.user._id.toString(); + await this.recycleBinService.permanentDeleteSurvey(userId, value.id); + + return { + code: 200, + data: { + success: true, + }, + }; + } + + @Post('/move') + @HttpCode(200) + @ApiOperation({ summary: '将问卷移至回收站' }) + @ApiResponse({ status: 200, description: '操作成功' }) + async moveToRecycleBin(@Request() req, @Body() dto: RecycleBinItemDto) { + console.log(`[RecycleBin] 收到移动问卷请求:`, JSON.stringify(dto)); + this.logger.info(`moveToRecycleBin_start: ${JSON.stringify(dto)}`); + this.logger.info(`req.user: ${JSON.stringify(req.user)}`); + const { value, error } = RecycleBinItemDto.validate(dto); + + if (error) { + const errorMsg = `moveToRecycleBin_parameter error: ${error.message}`; + console.error(errorMsg); + this.logger.error(errorMsg); + throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + try { + const userId = req.user._id.toString(); + console.log(`[RecycleBin] 处理移动问卷: userId=${userId}, surveyId=${value.id}`); + this.logger.info(`moveToRecycleBin_processing: userId=${userId}, surveyId=${value.id}`); + + await this.recycleBinService.moveToRecycleBin(userId, value.id, value.surveyPath); + + console.log(`[RecycleBin] 成功移动问卷: surveyId=${value.id}`); + this.logger.info(`moveToRecycleBin_success: userId=${userId}, surveyId=${value.id}`); + return { + code: 200, + data: { + success: true, + }, + }; + } catch (error) { + const errorMsg = `moveToRecycleBin_error: ${error.message || '未知错误'}`; + console.error(`[RecycleBin] 错误:`, errorMsg); + console.error(`[RecycleBin] 堆栈:`, error.stack); + this.logger.error(errorMsg); + this.logger.error(`moveToRecycleBin_stack: ${error.stack || 'No stack trace'}`); + + return { + code: 500, + errmsg: error.message || '移动到回收站失败', + }; + } + } +} \ No newline at end of file diff --git a/server/src/modules/survey/dto/recycleBin.dto.ts b/server/src/modules/survey/dto/recycleBin.dto.ts new file mode 100644 index 000000000..94aedaf67 --- /dev/null +++ b/server/src/modules/survey/dto/recycleBin.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; + +export class RecycleBinListDto { + @ApiProperty({ description: '当前页码', required: true }) + curPage: number; + + @ApiProperty({ description: '分页大小', required: false }) + pageSize: number; + + static validate(data) { + return Joi.object({ + curPage: Joi.number().required(), + pageSize: Joi.number().allow(null).default(10), + }).validate(data); + } +} + +export class RecycleBinItemDto { + @ApiProperty({ description: '问卷ID', required: true }) + id: string; + + @ApiProperty({ description: '问卷路径', required: true }) + surveyPath: string; + + static validate(data) { + return Joi.object({ + id: Joi.string().required(), + surveyPath: Joi.string().required(), + }).validate(data); + } +} \ No newline at end of file diff --git a/server/src/modules/survey/services/recycleBin.service.ts b/server/src/modules/survey/services/recycleBin.service.ts new file mode 100644 index 000000000..d791f6430 --- /dev/null +++ b/server/src/modules/survey/services/recycleBin.service.ts @@ -0,0 +1,189 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { SurveyMeta } from '../../../models/surveyMeta.entity'; +import { RecycleBinListDto } from '../dto/recycleBin.dto'; +import { CollaboratorService } from './collaborator.service'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; +import { RECORD_STATUS } from '../../../enums'; +import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { MongoRepository } from 'typeorm'; + +@Injectable() +export class RecycleBinService { + private readonly logger = new Logger(RecycleBinService.name); + + constructor( + @InjectRepository(SurveyMeta) + private readonly surveyMetaRepository: Repository, + private readonly collaboratorService: CollaboratorService, + @InjectRepository(ResponseSchema) + private readonly responseSchemaRepository: MongoRepository, + ) {} + + // 检查用户是否有权限操作该问卷 + private async checkPermission(userId: string, surveyId: string) { + const survey = await this.surveyMetaRepository.findOne({ + where: { _id: new ObjectId(surveyId) } + }); + + if (!survey) { + throw new NotFoundException('问卷不存在'); + } + + if (survey.ownerId === userId) { + return survey; + } + + const collaborator = await this.collaboratorService.getCollaborator({ userId, surveyId }); + this.logger.debug('协作者权限检查:', collaborator); + + if ( + collaborator && + Array.isArray(collaborator.permissions) && + collaborator.permissions.includes(SURVEY_PERMISSION.SURVEY_CONF_MANAGE) + ) { + return survey; + } + + throw new NotFoundException('没有操作权限'); + } + + async getRecycleBinList(userId: string, dto: RecycleBinListDto) { + const { value: params } = RecycleBinListDto.validate(dto); + const { curPage, pageSize } = params; + const skip = (curPage - 1) * pageSize; + + const query = { + $or: [{ ownerId: userId }], + isDeleted: true, + isPermanentDeleted: false, + }; + + const [list, count] = await this.surveyMetaRepository.findAndCount({ + where: query, + select: ['_id', 'title', 'createdAt', 'deletedAt', 'ownerId', 'surveyPath'], + skip, + take: pageSize, + order: { + deletedAt: 'DESC', + }, + }); + + return { list, count }; + } + + async restoreSurvey(userId: string, id: string, surveyPath: string) { + const survey = await this.checkPermission(userId, id); + + const dbSurvey = await this.surveyMetaRepository.findOne({ + where: { + _id: new ObjectId(id), + surveyPath: surveyPath, + isDeleted: true, + ownerId: survey.ownerId, + }, + }); + + if (!dbSurvey) { + throw new NotFoundException('问卷不存在或已被恢复'); + } + + const now = Date.now(); + + // 如果当前状态为 PUBLISHED,则恢复为 NEW 状态 + if (dbSurvey.curStatus?.status === RECORD_STATUS.PUBLISHED) { + dbSurvey.curStatus = { status: RECORD_STATUS.NEW, date: now }; + dbSurvey.statusList = Array.isArray(dbSurvey.statusList) + ? [...dbSurvey.statusList, { status: RECORD_STATUS.NEW, date: now }] + : [{ status: RECORD_STATUS.NEW, date: now }]; + } + + dbSurvey.isDeleted = false; + dbSurvey.deletedAt = null; + + await this.surveyMetaRepository.save(dbSurvey); + + return { success: true }; + } + + async permanentDeleteSurvey(userId: string, id: string) { + await this.checkPermission(userId, id); + + const survey = await this.surveyMetaRepository.findOne({ + where: { + _id: new ObjectId(id), + isDeleted: true, + ownerId: userId, + }, + }); + + if (!survey) { + throw new NotFoundException('问卷不存在'); + } + + await this.surveyMetaRepository.update( + { _id: new ObjectId(id) }, + { + isPermanentDeleted: true, + updatedAt: new Date(), + } + ); + + return { success: true }; + } + + async moveToRecycleBin(userId: string, id: string, surveyPath: string){ + + await this.checkPermission(userId, id); + + let objectId: ObjectId; + try { + objectId = new ObjectId(id); + } catch (err) { + throw new NotFoundException(`无效的问卷ID: ${id}`); + } + + const survey = await this.surveyMetaRepository.findOne({ + where: { + _id: objectId, + isDeleted: false, + ownerId: userId, + }, + }); + + if (!survey) { + throw new NotFoundException('问卷不存在或已在回收站'); + } + + const updateResult = await this.surveyMetaRepository.update( + { _id: objectId }, + { + isDeleted: true, + deletedAt: new Date(), + }, + ); + + // 同步 C 端状态 + const surveyC = await this.responseSchemaRepository.findOne({ + where: { surveyPath: surveyPath} + }); + + if (surveyC && !surveyC.isDeleted) { + await this.responseSchemaRepository.update( + { surveyPath: surveyPath }, + { + isDeleted: true, // C 端的删除状态字段为 isDeleted + updatedAt: new Date() + } + ); + } + + if (updateResult.affected === 0) { + throw new Error('更新问卷状态失败'); + } + + return { success: true }; + } +} diff --git a/server/src/modules/survey/services/surveyMeta.service.ts b/server/src/modules/survey/services/surveyMeta.service.ts index 301aa4c9f..16afd2b3e 100644 --- a/server/src/modules/survey/services/surveyMeta.service.ts +++ b/server/src/modules/survey/services/surveyMeta.service.ts @@ -75,6 +75,7 @@ export class SurveyMetaService { createFrom, workspaceId, groupId: groupId && groupId !== '' ? groupId : null, + isDeleted: false, }); return await this.surveyRepository.save(newSurvey); @@ -242,6 +243,7 @@ export class SurveyMetaService { skip, take: pageSize, order, + select: ['_id', 'title', 'remark', 'owner', 'createdAt', 'updatedAt', 'curStatus', 'subStatus', 'surveyPath', 'ownerId', 'isDeleted', 'isPermanentDeleted', 'deletedAt'], }); return { data, count }; } catch (error) { diff --git a/server/src/modules/survey/survey.module.ts b/server/src/modules/survey/survey.module.ts index ea867912b..b48a78221 100644 --- a/server/src/modules/survey/survey.module.ts +++ b/server/src/modules/survey/survey.module.ts @@ -18,10 +18,12 @@ import { CollaboratorController } from './controllers/collaborator.controller'; import { DownloadTaskController } from './controllers/downloadTask.controller'; import { SessionController } from './controllers/session.controller'; import { SurveyGroupController } from './controllers/surveyGroup.controller'; +import { RecycleBinController } from './controllers/recycleBin.controller'; import { SurveyConf } from 'src/models/surveyConf.entity'; import { SurveyHistory } from 'src/models/surveyHistory.entity'; import { SurveyMeta } from 'src/models/surveyMeta.entity'; +import { ResponseSchema } from 'src/models/responseSchema.entity'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; import { SurveyGroup } from 'src/models/surveyGroup.entity'; import { Word } from 'src/models/word.entity'; @@ -41,6 +43,7 @@ import { FileService } from '../file/services/file.service'; import { DownloadTaskService } from './services/downloadTask.service'; import { SessionService } from './services/session.service'; import { SurveyGroupService } from './services/surveyGroup.service'; +import { RecycleBinService } from './services/recycleBin.service'; import { Session } from 'src/models/session.entity'; @Module({ @@ -56,6 +59,7 @@ import { Session } from 'src/models/session.entity'; DownloadTask, Session, SurveyGroup, + ResponseSchema, ]), ConfigModule, SurveyResponseModule, @@ -73,6 +77,7 @@ import { Session } from 'src/models/session.entity'; DownloadTaskController, SessionController, SurveyGroupController, + RecycleBinController, ], providers: [ DataStatisticService, @@ -88,6 +93,7 @@ import { Session } from 'src/models/session.entity'; FileService, SessionService, SurveyGroupService, + RecycleBinService, ], }) export class SurveyModule {} diff --git a/server/src/modules/surveyResponse/services/responseScheme.service.ts b/server/src/modules/surveyResponse/services/responseScheme.service.ts index b1dc793c1..756faeedc 100644 --- a/server/src/modules/surveyResponse/services/responseScheme.service.ts +++ b/server/src/modules/surveyResponse/services/responseScheme.service.ts @@ -27,6 +27,7 @@ export class ResponseSchemaService { status: RECORD_SUB_STATUS.DEFAULT, date: Date.now(), }; + clientSurvey.isDeleted = false; return this.responseSchemaRepository.save(clientSurvey); } else { const curStatus = { @@ -44,6 +45,7 @@ export class ResponseSchemaService { pageId, curStatus, subStatus, + isDeleted: false, }); return this.responseSchemaRepository.save(newClientSurvey); } @@ -51,7 +53,10 @@ export class ResponseSchemaService { async getResponseSchemaByPath(surveyPath: string) { return this.responseSchemaRepository.findOne({ - where: { surveyPath }, + where: { + surveyPath, + isDeleted: false + }, }); } diff --git a/web/src/management/api/base.js b/web/src/management/api/base.js index ab096cb8a..210db93fa 100644 --- a/web/src/management/api/base.js +++ b/web/src/management/api/base.js @@ -18,7 +18,8 @@ const instance = axios.create({ instance.interceptors.response.use( (response) => { if (response.status !== 200) { - throw new Error('http请求出错') + console.error('HTTP请求非200状态码:', response.status) + throw new Error(`HTTP请求出错: ${response.status}`) } const res = response.data if (res.code === CODE_MAP.NO_AUTH || res.code === CODE_MAP.ERR_AUTH) { @@ -31,7 +32,22 @@ instance.interceptors.response.use( } }, (err) => { - throw new Error(err) + console.error('HTTP请求失败:', err) + + // 提取更详细的错误信息 + if (err.response) { + console.error('错误响应:', err.response) + + // 如果服务器返回了错误信息 + if (err.response.data && err.response.data.errmsg) { + console.error('服务器错误信息:', err.response.data.errmsg) + err.message = err.response.data.errmsg + } else { + err.message = `请求失败 (${err.response.status}): ${err.message}` + } + } + + throw err } ) diff --git a/web/src/management/api/recycleBin.ts b/web/src/management/api/recycleBin.ts new file mode 100644 index 000000000..01fd50b81 --- /dev/null +++ b/web/src/management/api/recycleBin.ts @@ -0,0 +1,21 @@ +import axios from './base' + +// 获取回收站问卷列表 +export function getRecycleBinList(params = {}) { + return axios.post('/survey/recyclebin/list', params) +} + +// 恢复问卷 +export function restoreSurvey(id: string, surveyPath: string) { + return axios.post('/survey/recyclebin/restore', { id, surveyPath }) +} + +// 永久删除问卷 +export function permanentDeleteSurvey(id: string) { + return axios.post('/survey/recyclebin/delete', { id }) +} + +// 将问卷移至回收站 +export function moveToRecycleBin(id: string, surveyPath: string) { + return axios.post('/survey/recyclebin/move', { id, surveyPath }) +} \ No newline at end of file diff --git a/web/src/management/api/survey.js b/web/src/management/api/survey.js index 40467e6c6..ed861af11 100644 --- a/web/src/management/api/survey.js +++ b/web/src/management/api/survey.js @@ -71,3 +71,7 @@ export const getSessionId = ({ surveyId }) => { export const seizeSession = ({ sessionId }) => { return axios.post('/session/seize', { sessionId }) } + +export const getSurveyMeta = (surveyId) => { + return axios.get(`/survey/meta/${surveyId}`) +} diff --git a/web/src/management/pages/list/components/BaseList.vue b/web/src/management/pages/list/components/BaseList.vue index c8a69c4ed..f203b6843 100644 --- a/web/src/management/pages/list/components/BaseList.vue +++ b/web/src/management/pages/list/components/BaseList.vue @@ -124,6 +124,7 @@ import { QOP_MAP } from '@/management/utils/constant.ts' import { deleteSurvey, pausingSurvey } from '@/management/api/survey' import { useWorkSpaceStore } from '@/management/stores/workSpace' import { useSurveyListStore } from '@/management/stores/surveyList' +import { useRecycleBinStore } from '@/management/stores/recycleBin' import ModifyDialog from './ModifyDialog.vue' import TagModule from './TagModule.vue' import StateModule from './StateModule.vue' @@ -145,6 +146,7 @@ import { const surveyListStore = useSurveyListStore() const workSpaceStore = useWorkSpaceStore() +const recycleBinStore = useRecycleBinStore() const { workSpaceId, groupAllList, menuType } = storeToRefs(workSpaceStore) const router = useRouter() const props = defineProps({ @@ -363,7 +365,7 @@ const handleClick = (key, data) => { } const onDelete = async (row) => { try { - await ElMessageBox.confirm('是否确认删除?', '提示', { + await ElMessageBox.confirm('删除问卷后将移至回收站,是否确认删除?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' @@ -372,20 +374,21 @@ const onDelete = async (row) => { return } - const res = await deleteSurvey(row._id) - if (res.code === CODE_MAP.SUCCESS) { - ElMessage.success('删除成功') + // 调用移至回收站API + const success = await recycleBinStore.moveSurveyToRecycleBin(row._id, row.surveyPath) + if (success) { + ElMessage.success('问卷已移至回收站') onRefresh() workSpaceStore.getGroupList() workSpaceStore.getSpaceList() - } else { - ElMessage.error(res.errmsg || '删除失败') + // 更新回收站数量 + workSpaceStore.updateRecycleBinCount() } } const onPausing = async (row) => { try { - await ElMessageBox.confirm('“暂停回收”后问卷将不能填写,是否继续?', '提示', { + await ElMessageBox.confirm('"暂停回收"后问卷将不能填写,是否继续?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' diff --git a/web/src/management/pages/list/components/RecycleBinList.vue b/web/src/management/pages/list/components/RecycleBinList.vue new file mode 100644 index 000000000..b6189fa5e --- /dev/null +++ b/web/src/management/pages/list/components/RecycleBinList.vue @@ -0,0 +1,148 @@ + + + + + \ No newline at end of file diff --git a/web/src/management/pages/list/components/SliderBar.vue b/web/src/management/pages/list/components/SliderBar.vue index cc054d8fe..40b358935 100644 --- a/web/src/management/pages/list/components/SliderBar.vue +++ b/web/src/management/pages/list/components/SliderBar.vue @@ -6,52 +6,75 @@ @select="handleMenu" :default-openeds="[MenuType.PersonalGroup, MenuType.SpaceGroup]" > - + + \ No newline at end of file