feat: 修改验收问题 (#421)

This commit is contained in:
luch 2024-09-12 18:06:16 +08:00 committed by GitHub
parent 6cbfe20be1
commit b484b786ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 235 additions and 146 deletions

View File

@ -1,5 +1,5 @@
XIAOJU_SURVEY_MONGO_DB_NAME= xiaojuSurvey
XIAOJU_SURVEY_MONGO_URL= # mongodb://localhost:27017 # 建议设置强密码
XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey
XIAOJU_SURVEY_MONGO_URL= # mongodb://127.0.0.1:27017 # 建议设置强密码
XIAOJU_SURVEY_MONGO_AUTH_SOURCE= # admin
XIAOJU_SURVEY_REDIS_HOST=

4
server/.gitignore vendored
View File

@ -38,4 +38,6 @@ yarn.lock
!.vscode/launch.json
!.vscode/extensions.json
tmp
tmp
exportfile
userUpload

View File

@ -12,4 +12,7 @@ export class Session extends BaseEntity {
@Column()
surveyId: string;
@Column()
userId: string;
}

View File

@ -79,15 +79,16 @@ export class DownloadTaskController {
async downloadList(
@Query()
queryInfo: GetDownloadTaskListDto,
@Request() req,
) {
const { value, error } = GetDownloadTaskListDto.validate(queryInfo);
if (error) {
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { ownerId, pageIndex, pageSize } = value;
const { pageIndex, pageSize } = value;
const { total, list } = await this.downloadTaskService.getDownloadTaskList({
ownerId,
ownerId: req.user._id.toString(),
pageIndex,
pageSize,
});

View File

@ -39,6 +39,8 @@ export class SessionController {
reqBody: {
surveyId: string;
},
@Request()
req,
) {
const { value, error } = Joi.object({
surveyId: Joi.string().required(),
@ -50,7 +52,10 @@ export class SessionController {
}
const surveyId = value.surveyId;
const session = await this.sessionService.create({ surveyId });
const session = await this.sessionService.create({
surveyId,
userId: req.user._id.toString(),
});
return {
code: 200,

View File

@ -34,6 +34,7 @@ import { WorkspaceGuard } from 'src/guards/workspace.guard';
import { PERMISSION as WORKSPACE_PERMISSION } from 'src/enums/workspace';
import { SessionService } from '../services/session.service';
import { MemberType, WhitelistType } from 'src/interfaces/survey';
import { UserService } from 'src/modules/auth/services/user.service';
@ApiTags('survey')
@Controller('/api/survey')
@ -47,6 +48,7 @@ export class SurveyController {
private readonly logger: XiaojuSurveyLogger,
private readonly counterService: CounterService,
private readonly sessionService: SessionService,
private readonly userService: UserService,
) {}
@Get('/getBannerData')
@ -146,11 +148,21 @@ export class SurveyController {
if (latestEditingOne && latestEditingOne._id.toString() !== sessionId) {
const curSession = await this.sessionService.findOne(sessionId);
if (curSession.createDate <= latestEditingOne.updateDate) {
// 在当前用户打开之后,有人保存过了
throw new HttpException(
'当前问卷已在其它页面开启编辑',
EXCEPTION_CODE.SURVEY_SAVE_CONFLICT,
);
// 在当前用户打开之后,被其他页面保存过了
const isSameOperator =
latestEditingOne.userId === req.user._id.toString();
let preOperator;
if (!isSameOperator) {
preOperator = await this.userService.getUserById(
latestEditingOne.userId,
);
}
return {
code: EXCEPTION_CODE.SURVEY_SAVE_CONFLICT,
errmsg: isSameOperator
? '当前问卷已在其它页面开启编辑,刷新以获取最新内容'
: `当前问卷已由 ${preOperator.username} 编辑,刷新以获取最新内容`,
};
}
}
await this.sessionService.updateSessionToEditing({ sessionId, surveyId });
@ -331,11 +343,6 @@ export class SurveyController {
pageId: surveyId,
});
await this.counterService.createCounters({
surveyPath: surveyMeta.surveyPath,
dataList: surveyConf.code.dataConf.dataList,
});
await this.surveyHistoryService.addHistory({
surveyId,
schema: surveyConf.code,

View File

@ -13,6 +13,7 @@ import { load } from 'cheerio';
import { get } from 'lodash';
import { FileService } from 'src/modules/file/services/file.service';
import { XiaojuSurveyLogger } from 'src/logger';
import moment from 'moment';
@Injectable()
export class DownloadTaskService {
@ -41,6 +42,7 @@ export class DownloadTaskService {
operatorId: string;
params: any;
}) {
const filename = `${responseSchema.title}-${params.isDesensitive ? '脱敏' : '原'}回收数据-${moment().format('YYYYMMDDHHmmss')}.xlsx`;
const downloadTask = this.downloadTaskRepository.create({
surveyId,
surveyPath: responseSchema.surveyPath,
@ -50,6 +52,7 @@ export class DownloadTaskService {
...params,
title: responseSchema.title,
},
filename,
});
await this.downloadTaskRepository.save(downloadTask);
return downloadTask._id.toString();
@ -65,7 +68,7 @@ export class DownloadTaskService {
pageSize: number;
}) {
const where = {
onwer: ownerId,
ownerId,
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},
@ -209,16 +212,12 @@ export class DownloadTaskService {
{ name: 'sheet1', data: xlsxData, options: {} },
]);
const isDesensitive = taskInfo.params?.isDesensitive;
const originalname = `${taskInfo.params.title}-${isDesensitive ? '脱敏' : '原'}回收数据.xlsx`;
const file: Express.Multer.File = {
fieldname: 'file',
originalname: originalname,
originalname: taskInfo.filename,
encoding: '7bit',
mimetype: 'application/octet-stream',
filename: originalname,
filename: taskInfo.filename,
size: buffer.length,
buffer: buffer,
stream: null,
@ -246,7 +245,6 @@ export class DownloadTaskService {
$set: {
curStatus,
url,
filename: originalname,
fileKey: key,
fileSize: buffer.length,
},

View File

@ -12,9 +12,10 @@ export class SessionService {
private readonly sessionRepository: MongoRepository<Session>,
) {}
create({ surveyId }) {
create({ surveyId, userId }) {
const session = this.sessionRepository.create({
surveyId,
userId,
});
return this.sessionRepository.save(session);
}

View File

@ -2,7 +2,6 @@ import { Controller, Post, Body, HttpCode } from '@nestjs/common';
import { HttpException } from 'src/exceptions/httpException';
import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException';
import { checkSign } from 'src/utils/checkSign';
import { cleanRichTextWithMediaTag } from 'src/utils/xss'
import { ENCRYPT_TYPE } from 'src/enums/encrypt';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { getPushingData } from 'src/utils/messagePushing';
@ -23,6 +22,14 @@ import { XiaojuSurveyLogger } from 'src/logger';
import { WhitelistType } from 'src/interfaces/survey';
import { UserService } from 'src/modules/auth/services/user.service';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
import { QUESTION_TYPE } from 'src/enums/question';
const optionQuestionType: Array<string> = [
QUESTION_TYPE.RADIO,
QUESTION_TYPE.CHECKBOX,
QUESTION_TYPE.BINARY_CHOICE,
QUESTION_TYPE.VOTE,
];
@ApiTags('surveyResponse')
@Controller('/api/surveyResponse')
@ -207,6 +214,7 @@ export class SurveyResponseController {
const optionTextAndId = dataList
.filter((questionItem) => {
return (
optionQuestionType.includes(questionItem.type) &&
Array.isArray(questionItem.options) &&
questionItem.options.length > 0 &&
decryptedData[questionItem.field]
@ -222,20 +230,23 @@ export class SurveyResponseController {
return pre;
}, {});
// 使用redis作为锁校验选项配额
const surveyId = responseSchema.pageId;
const lockKey = `locks:optionSelectedCount:${surveyId}`;
const lock = await this.redisService.lockResource(lockKey, 1000);
this.logger.info(`lockKey: ${lockKey}`);
try {
const successParams = [];
for (const field in decryptedData) {
const value = decryptedData[field];
const values = Array.isArray(value) ? value : [value];
if (field in optionTextAndId) {
const optionCountData = await this.counterService.get({
key: field,
surveyPath,
type: 'option',
});
const optionCountData =
(await this.counterService.get({
key: field,
surveyPath,
type: 'option',
})) || {};
//遍历选项hash值
for (const val of values) {
@ -243,38 +254,41 @@ export class SurveyResponseController {
(opt) => opt['hash'] === val,
);
const quota = parseInt(option['quota']);
if (quota !== 0 && quota <= optionCountData[val]) {
const item = dataList.find((item) => item['field'] === field);
throw new HttpException(
`${cleanRichTextWithMediaTag(item['title'])}】中的【${cleanRichTextWithMediaTag(option['text'])}】所选人数已达到上限,请重新选择`,
EXCEPTION_CODE.RESPONSE_OVER_LIMIT,
);
if (
quota &&
optionCountData?.[val] &&
quota <= optionCountData[val]
) {
return {
code: EXCEPTION_CODE.RESPONSE_OVER_LIMIT,
data: {
field,
optionHash: option.hash,
},
};
}
if (!optionCountData[val]) {
optionCountData[val] = 0;
}
optionCountData[val]++;
}
}
}
for (const field in decryptedData) {
const value = decryptedData[field];
const values = Array.isArray(value) ? value : [value];
if (field in optionTextAndId) {
const optionCountData = await this.counterService.get({
if (!optionCountData['total']) {
optionCountData['total'] = 1;
} else {
optionCountData['total']++;
}
successParams.push({
key: field,
surveyPath,
type: 'option',
data: optionCountData,
});
for (const val of values) {
optionCountData[val]++;
this.counterService.set({
key: field,
surveyPath,
type: 'option',
data: optionCountData,
});
}
optionCountData['total']++;
}
}
// 校验通过后统一更新
await Promise.all(
successParams.map((item) => this.counterService.set(item)),
);
} catch (error) {
this.logger.error(error.message);
throw error;

View File

@ -65,25 +65,4 @@ export class CounterService {
return pre;
}, {});
}
async createCounters({ surveyPath, dataList }) {
const optionList = dataList.filter((questionItem) => {
return (
Array.isArray(questionItem.options) && questionItem.options.length > 0
);
});
optionList.forEach((option) => {
const data = {};
option.options.forEach((option) => {
data[option.hash] = 0;
});
data['total'] = 0;
this.set({
surveyPath,
key: option.field,
type: 'option',
data: data,
});
});
}
}

View File

@ -1,53 +1,53 @@
import xss from 'xss'
import xss from 'xss';
const myxss = new (xss as any).FilterXSS({
onIgnoreTagAttr(tag, name, value) {
if (name === 'style' || name === 'class') {
return `${name}="${value}"`
return `${name}="${value}"`;
}
return undefined
return undefined;
},
onIgnoreTag(tag, html) {
// <xxx>过滤为空,否则不过滤为空
var re1 = new RegExp('<.+?>', 'g')
const re1 = new RegExp('<.+?>', 'g');
if (re1.test(html)) {
return ''
return '';
} else {
return html
return html;
}
}
})
},
});
export const cleanRichTextWithMediaTag = (text) => {
if (!text) {
return text === 0 ? 0 : ''
return text === 0 ? 0 : '';
}
const html = transformHtmlTag(text)
.replace(/<img([\w\W]+?)\/>/g, '[图片]')
.replace(/<video.*\/video>/g, '[视频]')
const content = html.replace(/<[^<>]+>/g, '').replace(/&nbsp;/g, '')
.replace(/<video.*\/video>/g, '[视频]');
const content = html.replace(/<[^<>]+>/g, '').replace(/&nbsp;/g, '');
return content
}
return content;
};
export function escapeHtml(html) {
return html.replace(/</g, '&lt;').replace(/>/g, '&gt;')
return html.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export const transformHtmlTag = (html) => {
if (!html) return ''
if (typeof html !== 'string') return html + ''
if (!html) return '';
if (typeof html !== 'string') return html + '';
return html
.replace(html ? /&(?!#?\w+;)/g : /&/g, '&amp;')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\\\n/g, '\\n')
.replace(/\\\n/g, '\\n');
//.replace(/&nbsp;/g, "")
}
};
const filterXSSClone = myxss.process.bind(myxss)
const filterXSSClone = myxss.process.bind(myxss);
export const filterXSS = (html) => filterXSSClone(transformHtmlTag(html))
export const filterXSS = (html) => filterXSSClone(transformHtmlTag(html));
export const escapeFilterXSS = (html) => escapeHtml(filterXSS(html))
export const escapeFilterXSS = (html) => escapeHtml(filterXSS(html));

View File

@ -60,6 +60,7 @@
<script setup>
import { ref } from 'vue'
import ImagePreview from './ImagePreview.vue'
import { cleanRichTextWithMediaTag } from '@/common/xss'
const props = defineProps({
tableData: {
@ -78,8 +79,16 @@ const popoverVirtualRef = ref()
const popoverContent = ref('')
const getContent = (content) => {
// const content = cleanRichText(value)
return content === 0 ? 0 : content || '未知'
if (Array.isArray(content)) {
return content.map(item => getContent(item)).join(',');
}
if (content === null || content === undefined) {
return ''
}
if (typeof content !== 'string') {
content = content + ''
}
return cleanRichTextWithMediaTag(content) || '未知'
}
const setPopoverContent = (content) => {
popoverContent.value = content

View File

@ -32,14 +32,22 @@
v-model="downloadDialogVisible"
title="导出确认"
width="500"
style="padding: 40px;"
>
<el-form :model="downloadForm">
<el-form :model="downloadForm" label-width="100px" label-position="left" >
<el-form-item label="导出内容">
<el-radio-group v-model="downloadForm.isDesensitive">
<el-radio :value="true">脱敏数据</el-radio>
<el-radio value="Venue">原回收数据</el-radio>
<el-radio :value="false">原回收数据</el-radio>
</el-radio-group>
</el-form-item>
<div class="download-tips">
<div></div>
<div>
<p>推荐优先下载脱敏数据如手机号1***3</p>
<p>原回收数据可能存在敏感信息请谨慎下载</p>
</div>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
@ -163,9 +171,9 @@ const confirmDownload = async () => {
const createRes = await createDownloadSurveyResponseTask({ surveyId: route.params.id, isDesensitive: downloadForm.isDesensitive })
dataTableState.downloadDialogVisible = false
if (createRes.code === 200) {
ElMessage.success(`下载文件计算中,可前往“下载中心”查看`)
try {
const taskInfo = await checkIsTaskFinished(createRes.data.taskId)
console.log(taskInfo)
if (taskInfo.url) {
window.open(taskInfo.url)
ElMessage.success("导出成功")
@ -217,6 +225,11 @@ const checkIsTaskFinished = (taskId) => {
overflow: hidden;
}
.download-tips {
display: flex;
color: #ec4e29;
}
.menus {
margin-bottom: 20px;
}

View File

@ -43,7 +43,7 @@ const handleLogout = () => {
const activeIndex = ref('2')
</script>
<style>
<style lang="scss" scoped>
.question-list-root {
height: 100%;
background-color: #f6f7f9;
@ -94,7 +94,7 @@ const activeIndex = ref('2')
}
}
.table-container {
padding: 20px;
margin-top: 20px;
display: flex;
justify-content: center;
width: 100%; /* 确保容器宽度为100% */

View File

@ -19,14 +19,14 @@
:prop="field.key"
:label="field.title"
:width="field.width"
class-name="link"
:class-name="[field.key]"
:formatter="field.formatter"
>
</el-table-column>
<el-table-column label="操作" width="200">
<template v-slot="{ row }">
<el-button size="small" @click="handleDownload(row)"> 下载 </el-button>
<el-button type="primary" size="small" @click="openDeleteDialog(row)"> 删除 </el-button>
<span v-if="row.curStatus?.status === 'finished'" class="text-btn download-btn" @click="handleDownload(row)"> 下载 </span>
<span class="text-btn delete-btn" @click="openDeleteDialog(row)"> 删除 </span>
</template>
</el-table-column>
</el-table>
@ -35,31 +35,21 @@
background
layout="prev, pager, next"
:total="total"
:size="pageSize"
small
:page-size="pageSize"
@current-change="handleCurrentChange"
>
</el-pagination>
</div>
<el-dialog v-model="centerDialogVisible" title="" width="500" align-center>
<span>确认删除下载记录吗</span>
<template #footer>
<div class="dialog-footer">
<el-button @click="centerDialogVisible = false"> 取消 </el-button>
<el-button type="primary" @click="confirmDelete"> 确认 </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { get, map } from 'lodash-es'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { deleteDownloadTask, getDownloadTaskList } from '@/management/api/downloadTask'
import { CODE_MAP } from '@/management/api/base'
import 'element-plus/theme-chalk/src/message.scss'
import 'element-plus/theme-chalk/src/message-box.scss'
import moment from 'moment'
//
@ -68,9 +58,9 @@ import 'moment/locale/zh-cn'
moment.locale('zh-cn')
const loading = ref(false)
const pageSize = ref(15)
const pageSize = ref(10)
const total = ref(0)
const dataList = reactive([])
const dataList: Array<any> = reactive([])
onMounted(() => {
getList({ pageIndex: 1 })
@ -87,7 +77,8 @@ const getList = async ({ pageIndex }: { pageIndex: number }) => {
const res: Record<string, any> = await getDownloadTaskList(params)
if (res.code === CODE_MAP.SUCCESS) {
total.value = res.data.total
dataList.values = res.data.list
const list = res.data.list as any
dataList.splice(0, dataList.length, ...list);
}
loading.value = false
}
@ -99,7 +90,6 @@ const statusTextMap: Record<string, string> = {
removed: '已删除',
};
const centerDialogVisible = ref(false)
let currentDelRow: Record<string, any> = {}
//
const handleDownload = async (row: any) => {
@ -112,20 +102,33 @@ const handleDownload = async (row: any) => {
}
}
//
const openDeleteDialog = (row: any) => {
centerDialogVisible.value = true
currentDelRow = row
const openDeleteDialog = async (row: any) => {
try {
await ElMessageBox.confirm('是否确认删除?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
currentDelRow = row
confirmDelete()
} catch (error) {
console.log('取消删除')
}
}
//
const confirmDelete = async () => {
try {
await deleteDownloadTask(currentDelRow.taskId)
await getList({ pageIndex: 1 })
const res: Record<string, any> = await deleteDownloadTask(currentDelRow.taskId)
if (res.code !== CODE_MAP.SUCCESS) {
ElMessage.error(res.errmsg)
} else {
ElMessage.success('删除成功');
await getList({ pageIndex: 1 })
}
} catch (error) {
ElMessage.error("删除失败,请刷新重试")
}
centerDialogVisible.value = false
}
const fields = ['filename', 'fileSize', 'createDate', 'curStatus']
@ -177,13 +180,30 @@ const handleCurrentChange = (val: number) => {
background-color: #f6f7f9;
.list-wrapper {
width: 90%;
min-width: 1080px;
padding: 10px 20px;
background: #fff;
margin: 0 auto;
.list-table {
.cell {
text-align: center;
}
.text-btn {
font-size: 14px;
cursor: pointer;
margin-left: 20px;
&:first-child {
margin-left: 0;
}
}
.download-btn {
color: $primary-color;
}
.delete-btn {
color: red;
}
}
.small-text {
color: red;

View File

@ -7,11 +7,12 @@
import { ref, computed } from 'vue'
import { useEditStore } from '@/management/stores/edit'
import { useRouter } from 'vue-router'
import { ElMessage, } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import { publishSurvey, saveSurvey } from '@/management/api/survey'
import { publishSurvey, saveSurvey, seizeSession } from '@/management/api/survey'
import buildData from './buildData'
import { storeToRefs } from 'pinia'
import { CODE_MAP } from '@/management/api/base'
interface Props {
updateLogicConf: any
@ -27,6 +28,16 @@ const { schema, sessionId } = storeToRefs(editStore)
const saveData = computed(() => {
return buildData(schema.value, sessionId.value)
})
const seize = async () => {
const seizeRes: Record<string, any> = await seizeSession({ sessionId: sessionId.value })
if (seizeRes.code === 200) {
location.reload();
} else {
ElMessage.error('获取权限失败,请重试')
}
}
const router = useRouter()
const validate = () => {
@ -61,8 +72,33 @@ const onSave = async () => {
return null
}
const res: Record<string, any> = await saveSurvey(saveData.value)
return res
try {
const res: any = await saveSurvey(saveData.value)
if(!res) {
return null
}
if (res.code === 200) {
ElMessage.success('保存成功')
return res
} else if (res.code === 3006) {
ElMessageBox.alert(res.errmsg, '提示', {
confirmButtonText: '刷新同步',
callback: (action: string) => {
if (action === 'confirm') {
seize();
}
}
});
return null
} else {
ElMessage.error(res.errmsg)
return null
}
} catch (error) {
ElMessage.error('保存问卷失败')
return null
}
}
const handlePublish = async () => {
if (isPublishing.value) {
@ -81,11 +117,7 @@ const handlePublish = async () => {
try {
const saveRes: any = await onSave()
if (!saveRes) {
return
}
if(saveRes && saveRes?.code !== 200) {
ElMessage.error(`保存失败 ${saveRes.errmsg}`)
if (!saveRes || saveRes.code !== CODE_MAP.SUCCESS) {
return
}
const publishRes: any = await publishSurvey({ surveyId: saveData.value.surveyId })

View File

@ -86,7 +86,7 @@ const onSave = async () => {
}
const seize = async () => {
const seizeRes: Record<string, any> = await seizeSession({ sessionId:sessionId.value })
const seizeRes: Record<string, any> = await seizeSession({ sessionId: sessionId.value })
if (seizeRes.code === 200) {
location.reload();
} else {
@ -154,10 +154,12 @@ const handleSave = async () => {
ElMessage.success('保存成功')
return res
} else if (res.code === 3006) {
ElMessageBox.alert('当前问卷已在其它页面开启编辑,点击“抢占”以获取保存权限。', '提示', {
confirmButtonText: '抢占',
callback: () => {
seize();
ElMessageBox.alert(res.errmsg, '提示', {
confirmButtonText: '刷新同步',
callback: (action: string) => {
if (action === 'confirm') {
seize();
}
}
});
} else {

View File

@ -187,7 +187,7 @@ const handleLogout = () => {
}
//
const handleDownload = () => {
router.push('/survey/downloadTask/')
router.push({ name: 'download' })
}
</script>

View File

@ -9,6 +9,9 @@ import { SurveyPermissions } from '@/management/utils/types/workSpace'
import { analysisTypeMap } from '@/management/config/analysisConfig'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import 'element-plus/theme-chalk/src/message-box.scss'
import 'element-plus/theme-chalk/src/button.scss'
import 'element-plus/theme-chalk/src/overlay.scss'
import { useUserStore } from '@/management/stores/user'
import { useEditStore } from '@/management/stores/edit'
@ -27,7 +30,7 @@ const routes: RouteRecordRaw[] = [
}
},
{
path: '/survey/downloadTask/',
path: '/download',
name: 'download',
component: () => import('../pages/downloadTask/TaskList.vue'),
meta: {