【Feature】:北大实践课作业 (#424)

* 【北大开源实践】增加数据导出功能 (#294)

* feat:添加了一个文件数据导出的功能和相应前端页面

* fix lint

* fix conflict

---------

Co-authored-by: dayou <853094838@qq.com>

* fix: components.d.ts文件ignore

* feat: Update README_EN.md

* feat: Update README.md

* feat:新增预览功能 (#257)

* feat:问卷预览功能
* feat:修复样式问题

* fix: 优化预览展示

* refactor: 重构vue3组合式API写法 (#265)

* feat: 抽离题型枚举 (#272)

* feat: 抽离题型枚举

* fix: 投放的链接加时间戳去掉ifream缓存

* feat: serve端的node engines

* feat: 权限接口请求优化以及修复其他问题 (#290)

* feat: c端路由改造 (#296)

* 【北大开源实践】增加数据导出功能 (#294)

* feat:添加了一个文件数据导出的功能和相应前端页面

* fix lint

* fix conflict

---------

Co-authored-by: dayou <853094838@qq.com>

* fix: 删除components.d.ts文件

* 【北大开源实践】- 问卷断点续答 - 前端 (#282)

* feat:增加断点续答功能

* feat:增加断点续答功能

* fix: 同步代码并且解决冲突

---------

Co-authored-by: dayou <853094838@qq.com>

* fix: 删除components.d.ts文件最终

* 【北大开源实践】-选项限制 (#284)

* format: 代码格式化 (#160)

* feat: 选项限制

* fix: 同步代码并解决冲突

* fix conflict

* fix conflict

* fix lint

* fix server lint

---------

Co-authored-by: dayou <853094838@qq.com>
Co-authored-by: XiaoYuan <2521510174@qq.com>

* feat: 登录失效检测 & 协作冲突检测 (#287)

Co-authored-by: Liuxinyi <liuxy0406@163.com>
Co-authored-by: dayou <853094838@qq.com>

* fix: peking分支同步develop并解决冲突

* fix: 修正颜色不统一 (#338)

* fix: 修正颜色不统一

* fix: 删除server下的lock文件

* 编辑冲突检测 (#351)

* perl: 选项配额优化

* fix: pinia改写

* feat: 完善北大课程相关的内容

* fix: 修复断点续答以及样式问题 (#420)

* feat: 修改readme

* [Feature]: 密码复杂度检测 (#407)

* feat: 密码复杂度检测

* chore: 改为服务端校验

* feat: 优化展示

* fix:修复编辑页在不同element版本下表现不一致问题 (#406)

* fix: 通过声明element最低版本来确定tab样式表现

* fix lint

* feat(选项设置扩展):选择类题型增加选项排列配置 (#403)

* build: add optimizeDeps packages

* feat(选项设置扩展):选择类题型增加选项排列配置

* feat(选项设置扩展): 验收问题修复

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>

* fix: 删除多余内容

* feat: 优化登录窗口

* fix: 修复断点续答以及样式问题

fix: 修复选项引用验收bug

fix: 修复断点续答问题

fix: 修复断点续答

fix: ignore

fix: 修复投票题默认值

fix: 优化断点续答逻辑

fix: 选中图标适应高度

fix: 回退最大最小选择

fix: 修复断点续答

fix: 修复elswitch不更新问题

fix: 修复访问密码更新不生效问题

fix: 修复样式

fix: 修复多选题最大最小限制

fix: 优化断点续答问题

修复多选题命中最多选择后无法取消问题

fix: 修复服务端的富文本解析

fix:  lint

fix: min error

fix: 修复最少最多选择

fix: 修复投票问卷的最少最多选择

fix: 兼容断点续答情况下选项配额为0的情况

fix: 兼容断点续答情况下选项配额为0的情况

fix: 兼容单选题的断点续答下的选项配额

fix: 修复添加选项问题

fix: 前端提示服务的配额已满

fix: 更新填写的过程中配额减少情况

---------

Co-authored-by: sudoooooo <zjbbabybaby@gmail.com>
Co-authored-by: Stahsf <30379566+50431040@users.noreply.github.com>
Co-authored-by: Jiangchunfu <mrj_kevin@163.com>
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>

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

* fix lint

---------

Co-authored-by: Oseast <162945153+Oseast@users.noreply.github.com>
Co-authored-by: sudoooooo <zjbbabybaby@gmail.com>
Co-authored-by: chaorenluo <1243357953@qq.com>
Co-authored-by: Realabiha <48506355+Realabiha@users.noreply.github.com>
Co-authored-by: shiyiting763 <70299297+shiyiting763@users.noreply.github.com>
Co-authored-by: yiyeah <68832436+yiyeah@users.noreply.github.com>
Co-authored-by: XiaoYuan <2521510174@qq.com>
Co-authored-by: Xinyi Liu <74805961+colmon46@users.noreply.github.com>
Co-authored-by: Liuxinyi <liuxy0406@163.com>
Co-authored-by: nil <wangweiguo2013@icloud.com>
Co-authored-by: 王晓聪 <wang86976110@126.com>
Co-authored-by: taoshuang <taoshuang@didiglobal.com>
Co-authored-by: luch1994 <1097650398@qq.com>
Co-authored-by: Stahsf <30379566+50431040@users.noreply.github.com>
Co-authored-by: Jiangchunfu <mrj_kevin@163.com>
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
Co-authored-by: luch <32321690+luch1994@users.noreply.github.com>
This commit is contained in:
dayou 2024-09-12 22:10:18 +08:00 committed by GitHub
parent 43001a12c7
commit b749cfa6f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
99 changed files with 2924 additions and 485 deletions

4
.gitignore vendored
View File

@ -3,6 +3,7 @@ node_modules
dist
package-lock.json
yarn.lock
# local env files
.env.local
@ -25,7 +26,10 @@ pnpm-debug.log*
*.sw?
.history
components.d.ts
# 默认的上传文件夹
userUpload
exportfile
yarn.lock

View File

@ -52,6 +52,9 @@ http {
proxy_pass http://127.0.0.1:3000;
}
location /exportfile {
proxy_pass http://127.0.0.1:3000;
}
# 静态文件的默认存储文件夹
# 文件夹的配置在 server/src/modules/file/config/index.ts SERVER_LOCAL_CONFIG.FILE_KEY_PREFIX
location /userUpload {

View File

@ -1,9 +1,15 @@
XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey
XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017
XIAOJU_SURVEY_MONGO_AUTH_SOURCE=admin
XIAOJU_SURVEY_MONGO_URL= # mongodb://127.0.0.1:27017 # 建议设置强密码
XIAOJU_SURVEY_MONGO_AUTH_SOURCE= # admin
XIAOJU_SURVEY_REDIS_HOST=
XIAOJU_SURVEY_REDIS_PORT=
XIAOJU_SURVEY_REDIS_USERNAME=
XIAOJU_SURVEY_REDIS_PASSWORD=
XIAOJU_SURVEY_REDIS_DB=
XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY=dataAesEncryptSecretKey
XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY= # dataAesEncryptSecretKey
XIAOJU_SURVEY_HTTP_DATA_ENCRYPT_TYPE=rsa
XIAOJU_SURVEY_JWT_SECRET=xiaojuSurveyJwtSecret

3
server/.gitignore vendored
View File

@ -13,6 +13,7 @@ pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
yarn.lock
# OS
.DS_Store
@ -38,3 +39,5 @@ lerna-debug.log*
!.vscode/extensions.json
tmp
exportfile
userUpload

View File

@ -27,10 +27,11 @@
"@nestjs/swagger": "^7.3.0",
"@nestjs/typeorm": "^10.0.1",
"ali-oss": "^6.20.0",
"cheerio": "^1.0.0-rc.12",
"cheerio": "1.0.0-rc.12",
"crypto-js": "^4.2.0",
"dotenv": "^16.3.2",
"fs-extra": "^11.2.0",
"ioredis": "^5.4.1",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
@ -41,11 +42,14 @@
"nanoid": "^3.3.7",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-xlsx": "^0.24.0",
"qiniu": "^7.11.1",
"redlock": "^5.0.0-beta.2",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"svg-captcha": "^1.4.0",
"typeorm": "^0.3.19"
"typeorm": "^0.3.19",
"xss": "^1.0.15"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
@ -70,6 +74,7 @@
"jest": "^29.5.0",
"mongodb-memory-server": "^9.1.4",
"prettier": "^3.0.0",
"redis-memory-server": "^0.11.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",

View File

@ -1,5 +1,6 @@
import { MongoMemoryServer } from 'mongodb-memory-server';
import { spawn } from 'child_process';
import { RedisMemoryServer } from 'redis-memory-server';
async function startServerAndRunScript() {
// 启动 MongoDB 内存服务器
@ -8,12 +9,19 @@ async function startServerAndRunScript() {
console.log('MongoDB Memory Server started:', mongoUri);
const redisServer = new RedisMemoryServer();
const redisHost = await redisServer.getHost();
const redisPort = await redisServer.getPort();
// 通过 spawn 运行另一个脚本,并传递 MongoDB 连接 URL 作为环境变量
const tsnode = spawn(
'cross-env',
[
`XIAOJU_SURVEY_MONGO_URL=${mongoUri}`,
`XIAOJU_SURVEY_REDIS_HOST=${redisHost}`,
`XIAOJU_SURVEY_REDIS_PORT=${redisPort}`,
'NODE_ENV=development',
'SERVER_ENV=local',
'npm',
'run',
'start:dev',
@ -31,9 +39,10 @@ async function startServerAndRunScript() {
console.error(data);
});
tsnode.on('close', (code) => {
tsnode.on('close', async (code) => {
console.log(`Nodemon process exited with code ${code}`);
mongod.stop(); // 停止 MongoDB 内存服务器
await mongod.stop(); // 停止 MongoDB 内存服务器
await redisServer.stop();
});
}

View File

@ -40,7 +40,9 @@ import { LoggerProvider } from './logger/logger.provider';
import { PluginManagerProvider } from './securityPlugin/pluginManager.provider';
import { LogRequestMiddleware } from './middlewares/logRequest.middleware';
import { XiaojuSurveyPluginManager } from './securityPlugin/pluginManager';
import { Logger } from './logger';
import { XiaojuSurveyLogger } from './logger';
import { DownloadTask } from './models/downloadTask.entity';
import { Session } from './models/session.entity';
@Module({
imports: [
@ -81,6 +83,8 @@ import { Logger } from './logger';
Workspace,
WorkspaceMember,
Collaborator,
DownloadTask,
Session,
],
};
},
@ -128,7 +132,7 @@ export class AppModule {
),
new SurveyUtilPlugin(),
);
Logger.init({
XiaojuSurveyLogger.init({
filename: this.configService.get<string>('XIAOJU_SURVEY_LOGGER_FILENAME'),
});
}

View File

@ -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 };

View File

@ -12,6 +12,7 @@ export enum EXCEPTION_CODE {
SURVEY_TYPE_ERROR = 3003, // 问卷类型错误
SURVEY_NOT_FOUND = 3004, // 问卷不存在
SURVEY_CONTENT_NOT_ALLOW = 3005, // 存在禁用内容
SURVEY_SAVE_CONFLICT = 3006, // 问卷冲突
CAPTCHA_INCORRECT = 4001, // 验证码不正确
WHITELIST_ERROR = 4002, // 白名单校验错误

View File

@ -6,6 +6,9 @@ export enum RECORD_STATUS {
PUBLISHED = 'published', // 发布
REMOVED = 'removed', // 删除
FORCE_REMOVED = 'forceRemoved', // 从回收站删除
COMOPUTETING = 'computing', // 计算中
FINISHED = 'finished', // 已完成
ERROR = 'error', // 错误
}
// 历史类型

View File

@ -0,0 +1,94 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { get } from 'lodash';
import { NoPermissionException } from 'src/exceptions/noPermissionException';
import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException';
import { SessionService } from 'src/modules/survey/services/session.service';
import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
import { CollaboratorService } from 'src/modules/survey/services/collaborator.service';
@Injectable()
export class SessionGuard implements CanActivate {
constructor(
private reflector: Reflector,
private readonly sessionService: SessionService,
private readonly surveyMetaService: SurveyMetaService,
private readonly workspaceMemberService: WorkspaceMemberService,
private readonly collaboratorService: CollaboratorService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;
const sessionIdKey = this.reflector.get<string>(
'sessionId',
context.getHandler(),
);
const sessionId = get(request, sessionIdKey);
if (!sessionId) {
throw new NoPermissionException('没有权限');
}
const saveSession = await this.sessionService.findOne(sessionId);
request.saveSession = saveSession;
const surveyId = saveSession.surveyId;
const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId });
if (!surveyMeta) {
throw new SurveyNotFoundException('问卷不存在');
}
request.surveyMeta = surveyMeta;
// 兼容老的问卷没有ownerId
if (
surveyMeta.ownerId === user._id.toString() ||
surveyMeta.owner === user.username
) {
// 问卷的owner可以访问和操作问卷
return true;
}
if (surveyMeta.workspaceId) {
const memberInfo = await this.workspaceMemberService.findOne({
workspaceId: surveyMeta.workspaceId,
userId: user._id.toString(),
});
if (!memberInfo) {
throw new NoPermissionException('没有权限');
}
return true;
}
const permissions = this.reflector.get<string[]>(
'surveyPermission',
context.getHandler(),
);
if (!Array.isArray(permissions) || permissions.length === 0) {
throw new NoPermissionException('没有权限');
}
const info = await this.collaboratorService.getCollaborator({
surveyId,
userId: user._id.toString(),
});
if (!info) {
throw new NoPermissionException('没有权限');
}
request.collaborator = info;
if (
permissions.some((permission) => info.permissions.includes(permission))
) {
return true;
}
throw new NoPermissionException('没有权限');
}
}

View File

@ -3,7 +3,6 @@ import { Reflector } from '@nestjs/core';
import { get } from 'lodash';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
import { CollaboratorService } from 'src/modules/survey/services/collaborator.service';
import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service';
import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException';

View File

@ -60,6 +60,7 @@ export interface DataItem {
rangeConfig?: any;
starStyle?: string;
innerType?: string;
quotaNoDisplay?: boolean;
}
export interface Option {
@ -69,6 +70,7 @@ export interface Option {
othersKey?: string;
placeholderDesc: string;
hash: string;
quota?: number;
}
export interface DataConf {

View File

@ -1,15 +1,15 @@
import * as log4js from 'log4js';
import moment from 'moment';
import { Request } from 'express';
import { Injectable, Scope } from '@nestjs/common';
const log4jsLogger = log4js.getLogger();
export class Logger {
@Injectable({ scope: Scope.REQUEST })
export class XiaojuSurveyLogger {
private static inited = false;
constructor() {}
private traceId: string;
static init(config: { filename: string }) {
if (this.inited) {
if (XiaojuSurveyLogger.inited) {
return;
}
log4js.configure({
@ -30,25 +30,28 @@ export class Logger {
default: { appenders: ['app'], level: 'trace' },
},
});
XiaojuSurveyLogger.inited = true;
}
_log(message, options: { dltag?: string; level: string; req?: Request }) {
_log(message, options: { dltag?: string; level: string }) {
const datetime = moment().format('YYYY-MM-DD HH:mm:ss.SSS');
const level = options?.level;
const dltag = options?.dltag ? `${options.dltag}||` : '';
const traceIdStr = options?.req?.['traceId']
? `traceid=${options?.req?.['traceId']}||`
: '';
const traceIdStr = this.traceId ? `traceid=${this.traceId}||` : '';
return log4jsLogger[level](
`[${datetime}][${level.toUpperCase()}]${dltag}${traceIdStr}${message}`,
);
}
info(message, options?: { dltag?: string; req?: Request }) {
setTraceId(traceId: string) {
this.traceId = traceId;
}
info(message, options?: { dltag?: string }) {
return this._log(message, { ...options, level: 'info' });
}
error(message, options: { dltag?: string; req?: Request }) {
error(message, options?: { dltag?: string }) {
return this._log(message, { ...options, level: 'error' });
}
}

View File

@ -1,8 +1,8 @@
import { Provider } from '@nestjs/common';
import { Logger } from './index';
import { XiaojuSurveyLogger } from './index';
export const LoggerProvider: Provider = {
provide: Logger,
useClass: Logger,
provide: XiaojuSurveyLogger,
useClass: XiaojuSurveyLogger,
};

View File

@ -10,9 +10,9 @@ const getCountStr = () => {
export const genTraceId = ({ ip }) => {
// ip转16位 + 当前时间戳(毫秒级)+自增序列1000开始自增到9000+ 当前进程id的后5位
ip = ip.replace('::ffff:', '');
ip = ip.replace('::ffff:', '').replace('::1', '');
let ipArr;
if (ip.indexOf(':') > 0) {
if (ip.indexOf(':') >= 0) {
ipArr = ip.split(':').map((segment) => {
// 将IPv6每个段转为16位并补0到长度为4
return parseInt(segment, 16).toString(16).padStart(4, '0');

View File

@ -1,26 +1,25 @@
// logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { Logger } from '../logger/index'; // 替换为你实际的logger路径
import { XiaojuSurveyLogger } from '../logger/index'; // 替换为你实际的logger路径
import { genTraceId } from '../logger/util';
@Injectable()
export class LogRequestMiddleware implements NestMiddleware {
constructor(private readonly logger: Logger) {}
constructor(private readonly logger: XiaojuSurveyLogger) {}
use(req: Request, res: Response, next: NextFunction) {
const { method, originalUrl, ip } = req;
const userAgent = req.get('user-agent') || '';
const startTime = Date.now();
const traceId = genTraceId({ ip });
req['traceId'] = traceId;
this.logger.setTraceId(traceId);
const query = JSON.stringify(req.query);
const body = JSON.stringify(req.body);
this.logger.info(
`method=${method}||uri=${originalUrl}||ip=${ip}||ua=${userAgent}||query=${query}||body=${body}`,
{
dltag: 'request_in',
req,
},
);
@ -30,7 +29,6 @@ export class LogRequestMiddleware implements NestMiddleware {
`status=${res.statusCode.toString()}||duration=${duration}ms`,
{
dltag: 'request_out',
req,
},
);
});

View File

@ -5,8 +5,7 @@ import { BaseEntity } from './base.entity';
@Entity({ name: 'captcha' })
export class Captcha extends BaseEntity {
@Index({
expireAfterSeconds:
new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000,
expireAfterSeconds: 3600,
})
@ObjectIdColumn()
_id: ObjectId;

View File

@ -6,8 +6,7 @@ import { BaseEntity } from './base.entity';
@Entity({ name: 'clientEncrypt' })
export class ClientEncrypt extends BaseEntity {
@Index({
expireAfterSeconds:
new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000,
expireAfterSeconds: 3600,
})
@ObjectIdColumn()
_id: ObjectId;

View File

@ -0,0 +1,34 @@
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';
@Entity({ name: 'downloadTask' })
export class DownloadTask extends BaseEntity {
@Column()
surveyId: string;
@Column()
surveyPath: string;
// 文件路径
@Column()
url: string;
// 文件key
@Column()
fileKey: string;
// 任务创建人
@Column()
ownerId: string;
// 文件名
@Column()
filename: string;
// 文件大小
@Column()
fileSize: string;
@Column()
params: string;
}

View File

@ -0,0 +1,18 @@
import { Entity, Column, Index, ObjectIdColumn } from 'typeorm';
import { ObjectId } from 'mongodb';
import { BaseEntity } from './base.entity';
@Entity({ name: 'session' })
export class Session extends BaseEntity {
@Index({
expireAfterSeconds: 3600,
})
@ObjectIdColumn()
_id: ObjectId;
@Column()
surveyId: string;
@Column()
userId: string;
}

View File

@ -19,4 +19,7 @@ export class SurveyHistory extends BaseEntity {
username: string;
_id: string;
};
@Column('string')
sessionId: string;
}

View File

@ -1,4 +1,11 @@
import { Controller, Get, Query, HttpCode, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Query,
HttpCode,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Authentication } from 'src/guards/authentication.guard';
@ -43,4 +50,16 @@ export class UserController {
}),
};
}
@UseGuards(Authentication)
@Get('/getUserInfo')
async getUserInfo(@Request() req) {
return {
code: 200,
data: {
userId: req.user._id.toString(),
username: req.user.username,
},
};
}
}

View File

@ -35,4 +35,13 @@ export class AuthService {
}
return user;
}
async expiredCheck(token: string) {
try {
verify(token, this.configService.get<string>('XIAOJU_SURVEY_JWT_SECRET'));
} catch (err) {
return true;
}
return false;
}
}

View File

@ -14,13 +14,18 @@ export class FileService {
configKey,
file,
pathPrefix,
keepOriginFilename,
}: {
configKey: string;
file: Express.Multer.File;
pathPrefix: string;
keepOriginFilename?: boolean;
}) {
const handler = this.getHandler(configKey);
const { key } = await handler.upload(file, { pathPrefix });
const { key } = await handler.upload(file, {
pathPrefix,
keepOriginFilename,
});
const url = await handler.getUrl(key);
return {
key,

View File

@ -12,9 +12,14 @@ export class LocalHandler implements FileUploadHandler {
async upload(
file: Express.Multer.File,
options?: { pathPrefix?: string },
options?: { pathPrefix?: string; keepOriginFilename?: boolean },
): Promise<{ key: string }> {
const filename = await generateUniqueFilename(file.originalname);
let filename;
if (options?.keepOriginFilename) {
filename = file.originalname;
} else {
filename = await generateUniqueFilename(file.originalname);
}
const filePath = join(
options?.pathPrefix ? options?.pathPrefix : '',
filename,
@ -35,6 +40,10 @@ export class LocalHandler implements FileUploadHandler {
}
getUrl(key: string): string {
if (process.env.SERVER_ENV === 'local') {
const port = process.env.PORT || 3000;
return `http://localhost:${port}/${key}`;
}
return `/${key}`;
}
}

View File

@ -0,0 +1,9 @@
// src/redis/redis.module.ts
import { Module } from '@nestjs/common';
import { RedisService } from './redis.service';
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

View File

@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { Redis } from 'ioredis';
import Redlock, { Lock } from 'redlock';
@Injectable()
export class RedisService {
private readonly redisClient: Redis;
private readonly redlock: Redlock;
constructor() {
this.redisClient = new Redis({
host: process.env.XIAOJU_SURVEY_REDIS_HOST,
port: parseInt(process.env.XIAOJU_SURVEY_REDIS_PORT),
password: process.env.XIAOJU_SURVEY_REDIS_PASSWORD || undefined,
username: process.env.XIAOJU_SURVEY_REDIS_USERNAME || undefined,
db: parseInt(process.env.XIAOJU_SURVEY_REDIS_DB) || 0,
});
this.redlock = new Redlock([this.redisClient], {
retryCount: 10,
retryDelay: 200, // ms
retryJitter: 200, // ms
});
}
async lockResource(resource: string, ttl: number): Promise<Lock> {
return this.redlock.acquire([resource], ttl);
}
async unlockResource(lock: Lock): Promise<void> {
await lock.release();
}
}

View File

@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CollaboratorController } from '../controllers/collaborator.controller';
import { CollaboratorService } from '../services/collaborator.service';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { CreateCollaboratorDto } from '../dto/createCollaborator.dto';
import { Collaborator } from 'src/models/collaborator.entity';
@ -25,7 +25,7 @@ jest.mock('src/guards/workspace.guard');
describe('CollaboratorController', () => {
let controller: CollaboratorController;
let collaboratorService: CollaboratorService;
let logger: Logger;
let logger: XiaojuSurveyLogger;
let userService: UserService;
let surveyMetaService: SurveyMetaService;
let workspaceMemberServie: WorkspaceMemberService;
@ -50,7 +50,7 @@ describe('CollaboratorController', () => {
},
},
{
provide: Logger,
provide: XiaojuSurveyLogger,
useValue: {
error: jest.fn(),
info: jest.fn(),
@ -84,7 +84,7 @@ describe('CollaboratorController', () => {
controller = module.get<CollaboratorController>(CollaboratorController);
collaboratorService = module.get<CollaboratorService>(CollaboratorService);
logger = module.get<Logger>(Logger);
logger = module.get<XiaojuSurveyLogger>(XiaojuSurveyLogger);
userService = module.get<UserService>(UserService);
surveyMetaService = module.get<SurveyMetaService>(SurveyMetaService);
workspaceMemberServie = module.get<WorkspaceMemberService>(
@ -191,7 +191,6 @@ describe('CollaboratorController', () => {
describe('getSurveyCollaboratorList', () => {
it('should return collaborator list', async () => {
const query = { surveyId: 'surveyId' };
const req = { user: { _id: 'userId' } };
const result = [
{ _id: 'collaboratorId', userId: 'userId', username: '' },
];
@ -202,7 +201,7 @@ describe('CollaboratorController', () => {
jest.spyOn(userService, 'getUserListByIds').mockResolvedValueOnce([]);
const response = await controller.getSurveyCollaboratorList(query, req);
const response = await controller.getSurveyCollaboratorList(query);
expect(response).toEqual({
code: 200,
@ -214,11 +213,10 @@ describe('CollaboratorController', () => {
const query: GetSurveyCollaboratorListDto = {
surveyId: '',
};
const req = { user: { _id: 'userId' } };
await expect(
controller.getSurveyCollaboratorList(query, req),
).rejects.toThrow(HttpException);
await expect(controller.getSurveyCollaboratorList(query)).rejects.toThrow(
HttpException,
);
expect(logger.error).toHaveBeenCalledTimes(1);
});
});
@ -230,14 +228,13 @@ describe('CollaboratorController', () => {
userId: 'userId',
permissions: ['read'],
};
const req = { user: { _id: 'userId' } };
const result = { _id: 'userId', permissions: ['read'] };
jest
.spyOn(collaboratorService, 'changeUserPermission')
.mockResolvedValue(result);
const response = await controller.changeUserPermission(reqBody, req);
const response = await controller.changeUserPermission(reqBody);
expect(response).toEqual({
code: 200,
@ -251,11 +248,10 @@ describe('CollaboratorController', () => {
userId: '',
permissions: ['surveyManage'],
};
const req = { user: { _id: 'userId' } };
await expect(
controller.changeUserPermission(reqBody, req),
).rejects.toThrow(HttpException);
await expect(controller.changeUserPermission(reqBody)).rejects.toThrow(
HttpException,
);
expect(logger.error).toHaveBeenCalledTimes(1);
});
});
@ -263,14 +259,13 @@ describe('CollaboratorController', () => {
describe('deleteCollaborator', () => {
it('should delete collaborator successfully', async () => {
const query = { surveyId: 'surveyId', userId: 'userId' };
const req = { user: { _id: 'userId' } };
const result = { acknowledged: true, deletedCount: 1 };
jest
.spyOn(collaboratorService, 'deleteCollaborator')
.mockResolvedValue(result);
const response = await controller.deleteCollaborator(query, req);
const response = await controller.deleteCollaborator(query);
expect(response).toEqual({
code: 200,
@ -280,9 +275,8 @@ describe('CollaboratorController', () => {
it('should throw an exception if validation fails', async () => {
const query = { surveyId: '', userId: '' };
const req = { user: { _id: 'userId' } };
await expect(controller.deleteCollaborator(query, req)).rejects.toThrow(
await expect(controller.deleteCollaborator(query)).rejects.toThrow(
HttpException,
);
expect(logger.error).toHaveBeenCalledTimes(1);

View File

@ -3,13 +3,13 @@ import { CollaboratorService } from '../services/collaborator.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Collaborator } from 'src/models/collaborator.entity';
import { MongoRepository } from 'typeorm';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
import { InsertManyResult, ObjectId } from 'mongodb';
describe('CollaboratorService', () => {
let service: CollaboratorService;
let repository: MongoRepository<Collaborator>;
let logger: Logger;
let logger: XiaojuSurveyLogger;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -20,7 +20,7 @@ describe('CollaboratorService', () => {
useClass: MongoRepository,
},
{
provide: Logger,
provide: XiaojuSurveyLogger,
useValue: {
info: jest.fn(),
},
@ -32,7 +32,7 @@ describe('CollaboratorService', () => {
repository = module.get<MongoRepository<Collaborator>>(
getRepositoryToken(Collaborator),
);
logger = module.get<Logger>(Logger);
logger = module.get<XiaojuSurveyLogger>(XiaojuSurveyLogger);
});
describe('create', () => {

View File

@ -9,7 +9,7 @@ import { ResponseSchemaService } from '../../surveyResponse/services/responseSch
import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider';
import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
import { UserService } from 'src/modules/auth/services/user.service';
import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin';
@ -28,7 +28,7 @@ describe('DataStatisticController', () => {
let dataStatisticService: DataStatisticService;
let responseSchemaService: ResponseSchemaService;
let pluginManager: XiaojuSurveyPluginManager;
let logger: Logger;
let logger: XiaojuSurveyLogger;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -56,7 +56,7 @@ describe('DataStatisticController', () => {
})),
},
{
provide: Logger,
provide: XiaojuSurveyLogger,
useValue: {
error: jest.fn(),
},
@ -73,7 +73,7 @@ describe('DataStatisticController', () => {
pluginManager = module.get<XiaojuSurveyPluginManager>(
XiaojuSurveyPluginManager,
);
logger = module.get<Logger>(Logger);
logger = module.get<XiaojuSurveyLogger>(XiaojuSurveyLogger);
pluginManager.registerPlugin(
new ResponseSecurityPlugin('dataAesEncryptSecretKey'),
@ -123,7 +123,7 @@ describe('DataStatisticController', () => {
.spyOn(dataStatisticService, 'getDataTable')
.mockResolvedValueOnce(mockDataTable);
const result = await controller.data(mockRequest.query, mockRequest);
const result = await controller.data(mockRequest.query);
expect(result).toEqual({
code: 200,
@ -169,7 +169,7 @@ describe('DataStatisticController', () => {
.spyOn(dataStatisticService, 'getDataTable')
.mockResolvedValueOnce(mockDataTable);
const result = await controller.data(mockRequest.query, mockRequest);
const result = await controller.data(mockRequest.query);
expect(result).toEqual({
code: 200,
@ -187,9 +187,9 @@ describe('DataStatisticController', () => {
},
};
await expect(
controller.data(mockRequest.query, mockRequest),
).rejects.toThrow(HttpException);
await expect(controller.data(mockRequest.query)).rejects.toThrow(
HttpException,
);
expect(logger.error).toHaveBeenCalledTimes(1);
});
});

View File

@ -7,7 +7,7 @@ import { SurveyMetaService } from '../services/surveyMeta.service';
import { UserService } from 'src/modules/auth/services/user.service';
import { AuthService } from 'src/modules/auth/services/auth.service';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
jest.mock('src/guards/authentication.guard');
jest.mock('src/guards/survey.guard');
@ -49,7 +49,7 @@ describe('SurveyHistoryController', () => {
useClass: jest.fn().mockImplementation(() => ({})),
},
{
provide: Logger,
provide: XiaojuSurveyLogger,
useValue: {
info: jest.fn(),
error: jest.fn(),
@ -66,7 +66,7 @@ describe('SurveyHistoryController', () => {
it('should return history list when query is valid', async () => {
const queryInfo = { surveyId: 'survey123', historyType: 'published' };
await controller.getList(queryInfo, {});
await controller.getList(queryInfo);
expect(surveyHistoryService.getHistoryList).toHaveBeenCalledWith({
surveyId: queryInfo.surveyId,

View File

@ -78,7 +78,13 @@ describe('SurveyHistoryService', () => {
.spyOn(repository, 'save')
.mockResolvedValueOnce({} as SurveyHistory);
await service.addHistory({ surveyId, schema, type, user });
await service.addHistory({
surveyId,
schema,
type,
user,
sessionId: '',
});
expect(spyCreate).toHaveBeenCalledWith({
pageId: surveyId,

View File

@ -20,7 +20,7 @@ import {
SURVEY_PERMISSION,
SURVEY_PERMISSION_DESCRIPTION,
} from 'src/enums/surveyPermission';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
import { CollaboratorService } from '../services/collaborator.service';
@ -40,7 +40,7 @@ import { SurveyMetaService } from '../services/surveyMeta.service';
export class CollaboratorController {
constructor(
private readonly collaboratorService: CollaboratorService,
private readonly logger: Logger,
private readonly logger: XiaojuSurveyLogger,
private readonly userService: UserService,
private readonly surveyMetaService: SurveyMetaService,
private readonly workspaceMemberServie: WorkspaceMemberService,
@ -69,7 +69,7 @@ export class CollaboratorController {
) {
const { error, value } = CreateCollaboratorDto.validate(reqBody);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException(
'系统错误,请联系管理员',
EXCEPTION_CODE.PARAMETER_ERROR,
@ -124,7 +124,7 @@ export class CollaboratorController {
) {
const { error, value } = BatchSaveCollaboratorDto.validate(reqBody);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException(
'系统错误,请联系管理员',
EXCEPTION_CODE.PARAMETER_ERROR,
@ -184,7 +184,7 @@ export class CollaboratorController {
neIdList: collaboratorIdList,
userIdList: newCollaboratorUserIdList,
});
this.logger.info('batchDelete:' + JSON.stringify(delRes), { req });
this.logger.info('batchDelete:' + JSON.stringify(delRes));
if (Array.isArray(newCollaborator) && newCollaborator.length > 0) {
const insertRes = await this.collaboratorService.batchCreate({
surveyId: value.surveyId,
@ -208,7 +208,7 @@ export class CollaboratorController {
const delRes = await this.collaboratorService.batchDeleteBySurveyId(
value.surveyId,
);
this.logger.info(JSON.stringify(delRes), { req });
this.logger.info(JSON.stringify(delRes));
}
return {
@ -225,11 +225,10 @@ export class CollaboratorController {
])
async getSurveyCollaboratorList(
@Query() query: GetSurveyCollaboratorListDto,
@Request() req,
) {
const { error, value } = GetSurveyCollaboratorListDto.validate(query);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
@ -263,17 +262,14 @@ export class CollaboratorController {
@SetMetadata('surveyPermission', [
SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE,
])
async changeUserPermission(
@Body() reqBody: ChangeUserPermissionDto,
@Request() req,
) {
async changeUserPermission(@Body() reqBody: ChangeUserPermissionDto) {
const { error, value } = Joi.object({
surveyId: Joi.string(),
userId: Joi.string(),
permissions: Joi.array().items(Joi.string().required()),
}).validate(reqBody);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
@ -292,13 +288,13 @@ export class CollaboratorController {
@SetMetadata('surveyPermission', [
SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE,
])
async deleteCollaborator(@Query() query, @Request() req) {
async deleteCollaborator(@Query() query) {
const { error, value } = Joi.object({
surveyId: Joi.string(),
userId: Joi.string(),
}).validate(query);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
@ -319,7 +315,7 @@ export class CollaboratorController {
const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId });
if (!surveyMeta) {
this.logger.error(`问卷不存在: ${surveyId}`, { req });
this.logger.error(`问卷不存在: ${surveyId}`);
throw new HttpException('问卷不存在', EXCEPTION_CODE.SURVEY_NOT_FOUND);
}

View File

@ -5,7 +5,6 @@ import {
HttpCode,
UseGuards,
SetMetadata,
Request,
} from '@nestjs/common';
import * as Joi from 'joi';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
@ -17,7 +16,7 @@ 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 { XiaojuSurveyLogger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { AggregationStatisDto } from '../dto/aggregationStatis.dto';
@ -32,7 +31,7 @@ export class DataStatisticController {
private readonly responseSchemaService: ResponseSchemaService,
private readonly dataStatisticService: DataStatisticService,
private readonly pluginManager: XiaojuSurveyPluginManager,
private readonly logger: Logger,
private readonly logger: XiaojuSurveyLogger,
) {}
@Get('/dataTable')
@ -44,7 +43,6 @@ export class DataStatisticController {
async data(
@Query()
queryInfo,
@Request() req,
) {
const { value, error } = await Joi.object({
surveyId: Joi.string().required(),
@ -53,7 +51,7 @@ export class DataStatisticController {
pageSize: Joi.number().default(10),
}).validate(queryInfo);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { surveyId, isDesensitive, page, pageSize } = value;

View File

@ -0,0 +1,188 @@
import {
Controller,
Get,
Query,
HttpCode,
UseGuards,
SetMetadata,
Request,
Post,
Body,
// 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 { XiaojuSurveyLogger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
//后添加
import { DownloadTaskService } from '../services/downloadTask.service';
import {
GetDownloadTaskDto,
CreateDownloadDto,
GetDownloadTaskListDto,
DeleteDownloadTaskDto,
} from '../dto/downloadTask.dto';
import moment from 'moment';
import { NoPermissionException } from 'src/exceptions/noPermissionException';
@ApiTags('downloadTask')
@ApiBearerAuth()
@Controller('/api/downloadTask')
export class DownloadTaskController {
constructor(
private readonly responseSchemaService: ResponseSchemaService,
private readonly downloadTaskService: DownloadTaskService,
private readonly logger: XiaojuSurveyLogger,
) {}
@Post('/createTask')
@HttpCode(200)
@UseGuards(SurveyGuard)
@SetMetadata('surveyId', 'body.surveyId')
@SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE])
@UseGuards(Authentication)
async createTask(
@Body()
reqBody: CreateDownloadDto,
@Request() req,
) {
const { value, error } = CreateDownloadDto.validate(reqBody);
if (error) {
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { surveyId, isDesensitive } = value;
const responseSchema =
await this.responseSchemaService.getResponseSchemaByPageId(surveyId);
const id = await this.downloadTaskService.createDownloadTask({
surveyId,
responseSchema,
operatorId: req.user._id.toString(),
params: { isDesensitive },
});
this.downloadTaskService.processDownloadTask({ taskId: id });
return {
code: 200,
data: { taskId: id },
};
}
@Get('/getDownloadTaskList')
@HttpCode(200)
@UseGuards(Authentication)
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 { pageIndex, pageSize } = value;
const { total, list } = await this.downloadTaskService.getDownloadTaskList({
ownerId: req.user._id.toString(),
pageIndex,
pageSize,
});
return {
code: 200,
data: {
total: total,
list: list.map((data) => {
const item: Record<string, any> = {};
item.taskId = data._id.toString();
item.curStatus = data.curStatus;
item.filename = data.filename;
item.url = data.url;
const fmt = 'YYYY-MM-DD HH:mm:ss';
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
let unitIndex = 0;
let size = Number(data.fileSize);
if (isNaN(size)) {
item.fileSize = data.fileSize;
} else {
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
item.fileSize = `${size.toFixed()} ${units[unitIndex]}`;
}
item.createDate = moment(Number(data.createDate)).format(fmt);
return item;
}),
},
};
}
@Get('/getDownloadTask')
@HttpCode(200)
@UseGuards(Authentication)
async getDownloadTask(@Query() query: GetDownloadTaskDto, @Request() req) {
const { value, error } = GetDownloadTaskDto.validate(query);
if (error) {
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const taskInfo = await this.downloadTaskService.getDownloadTaskById({
taskId: value.taskId,
});
if (!taskInfo) {
throw new HttpException('任务不存在', EXCEPTION_CODE.PARAMETER_ERROR);
}
if (taskInfo.ownerId !== req.user._id.toString()) {
throw new NoPermissionException('没有权限');
}
const res: Record<string, any> = {
...taskInfo,
};
res.taskId = taskInfo._id.toString();
delete res._id;
return {
code: 200,
data: res,
};
}
@Post('/deleteDownloadTask')
@HttpCode(200)
@UseGuards(Authentication)
async deleteFileByName(@Body() body: DeleteDownloadTaskDto, @Request() req) {
const { value, error } = DeleteDownloadTaskDto.validate(body);
if (error) {
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { taskId } = value;
const taskInfo = await this.downloadTaskService.getDownloadTaskById({
taskId,
});
if (!taskInfo) {
throw new HttpException('任务不存在', EXCEPTION_CODE.PARAMETER_ERROR);
}
if (taskInfo.ownerId !== req.user._id.toString()) {
throw new NoPermissionException('没有权限');
}
const delRes = await this.downloadTaskService.deleteDownloadTask({
taskId,
});
return {
code: 200,
data: delRes.modifiedCount === 1,
};
}
}

View File

@ -0,0 +1,89 @@
import {
Controller,
Post,
Body,
HttpCode,
UseGuards,
SetMetadata,
Request,
} from '@nestjs/common';
import * as Joi from 'joi';
import { ApiTags } from '@nestjs/swagger';
import { SessionService } from '../services/session.service';
import { Authentication } from 'src/guards/authentication.guard';
import { SurveyGuard } from 'src/guards/survey.guard';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { XiaojuSurveyLogger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { SessionGuard } from 'src/guards/session.guard';
@ApiTags('survey')
@Controller('/api/session')
export class SessionController {
constructor(
private readonly sessionService: SessionService,
private readonly logger: XiaojuSurveyLogger,
) {}
@Post('/create')
@HttpCode(200)
@UseGuards(SurveyGuard)
@SetMetadata('surveyId', 'body.surveyId')
@SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE])
@UseGuards(Authentication)
async create(
@Body()
reqBody: {
surveyId: string;
},
@Request()
req,
) {
const { value, error } = Joi.object({
surveyId: Joi.string().required(),
}).validate(reqBody);
if (error) {
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const surveyId = value.surveyId;
const session = await this.sessionService.create({
surveyId,
userId: req.user._id.toString(),
});
return {
code: 200,
data: {
sessionId: session._id.toString(),
},
};
}
@Post('/seize')
@HttpCode(200)
@UseGuards(SessionGuard)
@SetMetadata('sessionId', 'body.sessionId')
@SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE])
@UseGuards(Authentication)
async seize(
@Request()
req,
) {
const saveSession = req.saveSession;
await this.sessionService.updateSessionToEditing({
sessionId: saveSession._id.toString(),
surveyId: saveSession.surveyId,
});
return {
code: 200,
};
}
}

View File

@ -17,6 +17,7 @@ import { SurveyConfService } from '../services/surveyConf.service';
import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service';
import { ContentSecurityService } from '../services/contentSecurity.service';
import { SurveyHistoryService } from '../services/surveyHistory.service';
import { CounterService } from 'src/modules/surveyResponse/services/counter.service';
import BannerData from '../template/banner/index.json';
import { CreateSurveyDto } from '../dto/createSurvey.dto';
@ -25,13 +26,15 @@ import { Authentication } from 'src/guards/authentication.guard';
import { HISTORY_TYPE } from 'src/enums';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
import { SurveyGuard } from 'src/guards/survey.guard';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
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')
@ -42,7 +45,10 @@ export class SurveyController {
private readonly responseSchemaService: ResponseSchemaService,
private readonly contentSecurityService: ContentSecurityService,
private readonly surveyHistoryService: SurveyHistoryService,
private readonly logger: Logger,
private readonly logger: XiaojuSurveyLogger,
private readonly counterService: CounterService,
private readonly sessionService: SessionService,
private readonly userService: UserService,
) {}
@Get('/getBannerData')
@ -71,9 +77,7 @@ export class SurveyController {
) {
const { error, value } = CreateSurveyDto.validate(reqBody);
if (error) {
this.logger.error(`createSurvey_parameter error: ${error.message}`, {
req,
});
this.logger.error(`createSurvey_parameter error: ${error.message}`);
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}
@ -129,13 +133,41 @@ export class SurveyController {
const { value, error } = Joi.object({
surveyId: Joi.string().required(),
configData: Joi.any().required(),
sessionId: Joi.string().required(),
}).validate(surveyInfo);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const username = req.user.username;
const sessionId = value.sessionId;
const surveyId = value.surveyId;
const latestEditingOne = await this.sessionService.findLatestEditingOne({
surveyId,
});
if (latestEditingOne && latestEditingOne._id.toString() !== sessionId) {
const curSession = await this.sessionService.findOne(sessionId);
if (curSession.createDate <= latestEditingOne.updateDate) {
// 在当前用户打开之后,被其他页面保存过了
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 });
const username = req.user.username;
const configData = value.configData;
await this.surveyConfService.saveSurveyConf({
@ -198,7 +230,7 @@ export class SurveyController {
}).validate(queryInfo);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
@ -241,15 +273,13 @@ export class SurveyController {
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 });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const surveyId = value.surveyId;
@ -282,7 +312,7 @@ export class SurveyController {
surveyId: Joi.string().required(),
}).validate(surveyInfo);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const username = req.user.username;

View File

@ -5,7 +5,6 @@ import {
HttpCode,
UseGuards,
SetMetadata,
Request,
} from '@nestjs/common';
import * as Joi from 'joi';
import { ApiTags } from '@nestjs/swagger';
@ -15,16 +14,15 @@ import { SurveyHistoryService } from '../services/surveyHistory.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 { XiaojuSurveyLogger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
@ApiTags('survey')
@Controller('/api/surveyHisotry')
export class SurveyHistoryController {
constructor(
private readonly surveyHistoryService: SurveyHistoryService,
private readonly logger: Logger,
private readonly logger: XiaojuSurveyLogger,
) {}
@Get('/getList')
@ -43,7 +41,6 @@ export class SurveyHistoryController {
surveyId: string;
historyType: string;
},
@Request() req,
) {
const { value, error } = Joi.object({
surveyId: Joi.string().required(),
@ -51,7 +48,7 @@ export class SurveyHistoryController {
}).validate(queryInfo);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}

View File

@ -19,7 +19,7 @@ import { getFilter, getOrder } from 'src/utils/surveyUtil';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { Authentication } from 'src/guards/authentication.guard';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
import { SurveyGuard } from 'src/guards/survey.guard';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { WorkspaceGuard } from 'src/guards/workspace.guard';
@ -33,7 +33,7 @@ import { CollaboratorService } from '../services/collaborator.service';
export class SurveyMetaController {
constructor(
private readonly surveyMetaService: SurveyMetaService,
private readonly logger: Logger,
private readonly logger: XiaojuSurveyLogger,
private readonly collaboratorService: CollaboratorService,
) {}
@ -51,9 +51,7 @@ export class SurveyMetaController {
}).validate(reqBody, { allowUnknown: true });
if (error) {
this.logger.error(`updateMeta_parameter error: ${error.message}`, {
req,
});
this.logger.error(`updateMeta_parameter error: ${error.message}`);
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const survey = req.surveyMeta;
@ -81,7 +79,7 @@ export class SurveyMetaController {
) {
const { value, error } = GetSurveyListDto.validate(queryInfo);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { curPage, pageSize, workspaceId } = value;
@ -91,14 +89,14 @@ export class SurveyMetaController {
try {
filter = getFilter(JSON.parse(decodeURIComponent(value.filter)));
} catch (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
}
}
if (value.order) {
try {
order = order = getOrder(JSON.parse(decodeURIComponent(value.order)));
} catch (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
}
}
const userId = req.user._id.toString();

View File

@ -0,0 +1,51 @@
import { ApiProperty } from '@nestjs/swagger';
import Joi from 'joi';
export class CreateDownloadDto {
@ApiProperty({ description: '问卷id', required: true })
surveyId: string;
@ApiProperty({ description: '是否脱敏', required: false })
isDesensitive: boolean;
static validate(data) {
return Joi.object({
surveyId: Joi.string().required(),
isDesensitive: Joi.boolean().allow(null).default(false),
}).validate(data);
}
}
export class GetDownloadTaskListDto {
@ApiProperty({ description: '当前页', required: false })
pageIndex: number;
@ApiProperty({ description: '一页大小', required: false })
pageSize: number;
static validate(data) {
return Joi.object({
pageIndex: Joi.number().default(1),
pageSize: Joi.number().default(20),
}).validate(data);
}
}
export class GetDownloadTaskDto {
@ApiProperty({ description: '任务id', required: true })
taskId: string;
static validate(data) {
return Joi.object({
taskId: Joi.string().required(),
}).validate(data);
}
}
export class DeleteDownloadTaskDto {
@ApiProperty({ description: '任务id', required: true })
taskId: string;
static validate(data) {
return Joi.object({
taskId: Joi.string().required(),
}).validate(data);
}
}

View File

@ -3,14 +3,14 @@ import { Collaborator } from 'src/models/collaborator.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { ObjectId } from 'mongodb';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
@Injectable()
export class CollaboratorService {
constructor(
@InjectRepository(Collaborator)
private readonly collaboratorRepository: MongoRepository<Collaborator>,
private readonly logger: Logger,
private readonly logger: XiaojuSurveyLogger,
) {}
async create({ surveyId, userId, permissions }) {

View File

@ -0,0 +1,280 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { ResponseSchema } from 'src/models/responseSchema.entity';
import { DownloadTask } from 'src/models/downloadTask.entity';
import { RECORD_STATUS } from 'src/enums';
import { ObjectId } from 'mongodb';
import { ResponseSchemaService } from 'src/modules/surveyResponse/services/responseScheme.service';
import { SurveyResponse } from 'src/models/surveyResponse.entity';
import { DataStatisticService } from './dataStatistic.service';
import xlsx from 'node-xlsx';
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 {
private static taskList: Array<any> = [];
private static isExecuting: boolean = false;
constructor(
@InjectRepository(DownloadTask)
private readonly downloadTaskRepository: MongoRepository<DownloadTask>,
private readonly responseSchemaService: ResponseSchemaService,
@InjectRepository(SurveyResponse)
private readonly surveyResponseRepository: MongoRepository<SurveyResponse>,
private readonly dataStatisticService: DataStatisticService,
private readonly fileService: FileService,
private readonly logger: XiaojuSurveyLogger,
) {}
async createDownloadTask({
surveyId,
responseSchema,
operatorId,
params,
}: {
surveyId: string;
responseSchema: ResponseSchema;
operatorId: string;
params: any;
}) {
const filename = `${responseSchema.title}-${params.isDesensitive ? '脱敏' : '原'}回收数据-${moment().format('YYYYMMDDHHmmss')}.xlsx`;
const downloadTask = this.downloadTaskRepository.create({
surveyId,
surveyPath: responseSchema.surveyPath,
fileSize: '计算中',
ownerId: operatorId,
params: {
...params,
title: responseSchema.title,
},
filename,
});
await this.downloadTaskRepository.save(downloadTask);
return downloadTask._id.toString();
}
async getDownloadTaskList({
ownerId,
pageIndex,
pageSize,
}: {
ownerId: string;
pageIndex: number;
pageSize: number;
}) {
const where = {
ownerId,
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},
};
const [surveyDownloadList, total] =
await this.downloadTaskRepository.findAndCount({
where,
take: pageSize,
skip: (pageIndex - 1) * pageSize,
order: {
createDate: -1,
},
});
return {
total,
list: surveyDownloadList,
};
}
async getDownloadTaskById({ taskId }) {
const res = await this.downloadTaskRepository.find({
where: {
_id: new ObjectId(taskId),
},
});
if (Array.isArray(res) && res.length > 0) {
return res[0];
}
return null;
}
async deleteDownloadTask({ taskId }: { taskId: string }) {
const curStatus = {
status: RECORD_STATUS.REMOVED,
date: Date.now(),
};
return this.downloadTaskRepository.updateOne(
{
_id: new ObjectId(taskId),
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},
},
{
$set: {
curStatus,
},
$push: {
statusList: curStatus as never,
},
},
);
}
processDownloadTask({ taskId }) {
DownloadTaskService.taskList.push(taskId);
if (!DownloadTaskService.isExecuting) {
this.executeTask();
DownloadTaskService.isExecuting = true;
}
}
private async executeTask() {
try {
for (const taskId of DownloadTaskService.taskList) {
const taskInfo = await this.getDownloadTaskById({ taskId });
if (!taskInfo || taskInfo.curStatus.status === RECORD_STATUS.REMOVED) {
// 不存在或者已删除的,不处理
continue;
}
await this.handleDownloadTask({ taskInfo });
}
} finally {
DownloadTaskService.isExecuting = false;
}
}
private async handleDownloadTask({ taskInfo }) {
try {
// 更新任务状态为计算中
const updateRes = await this.downloadTaskRepository.updateOne(
{
_id: taskInfo._id,
},
{
$set: {
curStatus: {
status: RECORD_STATUS.COMOPUTETING,
date: Date.now(),
},
},
},
);
this.logger.info(JSON.stringify(updateRes));
// 开始计算任务
const surveyId = taskInfo.surveyId;
const responseSchema =
await this.responseSchemaService.getResponseSchemaByPageId(surveyId);
const where = {
pageId: surveyId,
'curStatus.status': {
$ne: 'removed',
},
};
const total = await this.surveyResponseRepository.count(where);
const pageSize = 200;
const pageTotal = Math.ceil(total / pageSize);
const xlsxHead = [];
const xlsxBody = [];
for (let pageIndex = 1; pageIndex <= pageTotal; pageIndex++) {
const { listHead, listBody } =
await this.dataStatisticService.getDataTable({
surveyId,
pageNum: pageIndex,
pageSize,
responseSchema,
});
if (xlsxHead.length === 0) {
for (const item of listHead) {
const $ = load(item.title);
const text = $.text();
xlsxHead.push(text);
}
}
for (const bodyItem of listBody) {
const bodyData = [];
for (const headItem of listHead) {
const field = headItem.field;
const val = get(bodyItem, field, '');
const $ = load(val);
const text = $.text();
bodyData.push(text);
}
xlsxBody.push(bodyData);
}
}
const xlsxData = [xlsxHead, ...xlsxBody];
const buffer = await xlsx.build([
{ name: 'sheet1', data: xlsxData, options: {} },
]);
const file: Express.Multer.File = {
fieldname: 'file',
originalname: taskInfo.filename,
encoding: '7bit',
mimetype: 'application/octet-stream',
filename: taskInfo.filename,
size: buffer.length,
buffer: buffer,
stream: null,
destination: null,
path: '',
};
const { url, key } = await this.fileService.upload({
configKey: 'SERVER_LOCAL_CONFIG',
file,
pathPrefix: 'exportfile',
keepOriginFilename: true,
});
const curStatus = {
status: RECORD_STATUS.FINISHED,
date: Date.now(),
};
// 更新计算结果
const updateFinishRes = await this.downloadTaskRepository.updateOne(
{
_id: taskInfo._id,
},
{
$set: {
curStatus,
url,
fileKey: key,
fileSize: buffer.length,
},
$push: {
statusList: curStatus as never,
},
},
);
this.logger.info(JSON.stringify(updateFinishRes));
} catch (error) {
const curStatus = {
status: RECORD_STATUS.ERROR,
date: Date.now(),
};
await this.downloadTaskRepository.updateOne(
{
_id: taskInfo._id,
},
{
$set: {
curStatus,
},
$push: {
statusList: curStatus as never,
},
},
);
this.logger.error(
`导出文件失败 taskId: ${taskInfo._id.toString()}, surveyId: ${taskInfo.surveyId}, message: ${error.message}`,
);
}
}
}

View File

@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { Session } from 'src/models/session.entity';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from 'src/enums';
@Injectable()
export class SessionService {
constructor(
@InjectRepository(Session)
private readonly sessionRepository: MongoRepository<Session>,
) {}
create({ surveyId, userId }) {
const session = this.sessionRepository.create({
surveyId,
userId,
});
return this.sessionRepository.save(session);
}
findOne(sessionId) {
return this.sessionRepository.findOne({
where: {
_id: new ObjectId(sessionId),
},
});
}
findLatestEditingOne({ surveyId }) {
return this.sessionRepository.findOne({
where: {
surveyId,
'curStatus.status': {
$ne: RECORD_STATUS.NEW,
},
},
});
}
updateSessionToEditing({ sessionId, surveyId }) {
const now = Date.now();
const editingStatus = {
status: RECORD_STATUS.EDITING,
date: now,
};
const newStatus = {
status: RECORD_STATUS.NEW,
date: now,
};
return Promise.all([
this.sessionRepository.updateOne(
{
_id: new ObjectId(sessionId),
},
{
$set: {
curStatus: editingStatus,
updateDate: now,
},
},
),
this.sessionRepository.updateMany(
{
surveyId,
_id: {
$ne: new ObjectId(sessionId),
},
},
{
$set: {
curStatus: newStatus,
updateDate: now,
},
},
),
]);
}
}

View File

@ -7,6 +7,7 @@ import { LoggerProvider } from 'src/logger/logger.provider';
import { SurveyResponseModule } from '../surveyResponse/surveyResponse.module';
import { AuthModule } from '../auth/auth.module';
import { WorkspaceModule } from '../workspace/workspace.module';
import { FileModule } from '../file/file.module';
import { DataStatisticController } from './controllers/dataStatistic.controller';
import { SurveyController } from './controllers/survey.controller';
@ -14,6 +15,8 @@ import { SurveyHistoryController } from './controllers/surveyHistory.controller'
import { SurveyMetaController } from './controllers/surveyMeta.controller';
import { SurveyUIController } from './controllers/surveyUI.controller';
import { CollaboratorController } from './controllers/collaborator.controller';
import { DownloadTaskController } from './controllers/downloadTask.controller';
import { SessionController } from './controllers/session.controller';
import { SurveyConf } from 'src/models/surveyConf.entity';
import { SurveyHistory } from 'src/models/surveyHistory.entity';
@ -21,14 +24,21 @@ import { SurveyMeta } from 'src/models/surveyMeta.entity';
import { SurveyResponse } from 'src/models/surveyResponse.entity';
import { Word } from 'src/models/word.entity';
import { Collaborator } from 'src/models/collaborator.entity';
import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider';
import { DownloadTask } from 'src/models/downloadTask.entity';
import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider';
import { DataStatisticService } from './services/dataStatistic.service';
import { SurveyConfService } from './services/surveyConf.service';
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 { Counter } from 'src/models/counter.entity';
import { CounterService } from '../surveyResponse/services/counter.service';
import { FileService } from '../file/services/file.service';
import { DownloadTaskService } from './services/downloadTask.service';
import { SessionService } from './services/session.service';
import { Session } from 'src/models/session.entity';
@Module({
imports: [
@ -39,11 +49,15 @@ import { CollaboratorService } from './services/collaborator.service';
SurveyResponse,
Word,
Collaborator,
Counter,
DownloadTask,
Session,
]),
ConfigModule,
SurveyResponseModule,
AuthModule,
WorkspaceModule,
FileModule,
],
controllers: [
DataStatisticController,
@ -52,6 +66,8 @@ import { CollaboratorService } from './services/collaborator.service';
SurveyMetaController,
SurveyUIController,
CollaboratorController,
DownloadTaskController,
SessionController,
],
providers: [
DataStatisticService,
@ -62,6 +78,10 @@ import { CollaboratorService } from './services/collaborator.service';
ContentSecurityService,
CollaboratorService,
LoggerProvider,
CounterService,
DownloadTaskService,
FileService,
SessionService,
],
})
export class SurveyModule {}

View File

@ -48,7 +48,8 @@
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115019"
"hash": "115019",
"quota": "0"
},
{
"text": "选项2",
@ -57,9 +58,11 @@
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115020"
"hash": "115020",
"quota": "0"
}
]
],
"quotaNoDisplay": false
}
]
}

View File

@ -41,8 +41,8 @@
"innerType": "radio",
"field": "data606",
"title": "标题2",
"minNum": "",
"maxNum": "",
"minNum": 0,
"maxNum": 0,
"options": [
{
"text": "选项1",

View File

@ -20,7 +20,7 @@ import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugi
import { RECORD_STATUS } from 'src/enums';
import { SurveyResponse } from 'src/models/surveyResponse.entity';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
import { ResponseSchema } from 'src/models/responseSchema.entity';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { UserService } from 'src/modules/auth/services/user.service';
@ -122,7 +122,7 @@ describe('SurveyResponseController', () => {
},
},
{
provide: Logger,
provide: XiaojuSurveyLogger,
useValue: {
error: jest.fn(),
info: jest.fn(),
@ -220,7 +220,8 @@ describe('SurveyResponseController', () => {
jest
.spyOn(clientEncryptService, 'deleteEncryptInfo')
.mockResolvedValueOnce(undefined);
const result = await controller.createResponse(reqBody, {});
const result = await controller.createResponse(reqBody);
expect(result).toEqual({ code: 200, msg: '提交成功' });
expect(
@ -267,7 +268,7 @@ describe('SurveyResponseController', () => {
.spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValueOnce(null);
await expect(controller.createResponse(reqBody, {})).rejects.toThrow(
await expect(controller.createResponse(reqBody)).rejects.toThrow(
SurveyNotFoundException,
);
});
@ -276,7 +277,7 @@ describe('SurveyResponseController', () => {
const reqBody = cloneDeep(mockSubmitData);
delete reqBody.sign;
await expect(controller.createResponse(reqBody, {})).rejects.toThrow(
await expect(controller.createResponse(reqBody)).rejects.toThrow(
HttpException,
);
@ -289,7 +290,7 @@ describe('SurveyResponseController', () => {
const reqBody = cloneDeep(mockDecryptErrorBody);
reqBody.sign = 'mock sign';
await expect(controller.createResponse(reqBody, {})).rejects.toThrow(
await expect(controller.createResponse(reqBody)).rejects.toThrow(
HttpException,
);
@ -305,7 +306,7 @@ describe('SurveyResponseController', () => {
.spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValueOnce(mockResponseSchema);
await expect(controller.createResponse(reqBody, {})).rejects.toThrow(
await expect(controller.createResponse(reqBody)).rejects.toThrow(
HttpException,
);
});
@ -317,7 +318,7 @@ describe('SurveyResponseController', () => {
.spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValueOnce(mockResponseSchema);
await expect(controller.createResponse(reqBody, {})).rejects.toThrow(
await expect(controller.createResponse(reqBody)).rejects.toThrow(
HttpException,
);
});
@ -343,7 +344,7 @@ describe('SurveyResponseController', () => {
},
} as ResponseSchema);
await expect(controller.createResponse(reqBody, {})).rejects.toThrow(
await expect(controller.createResponse(reqBody)).rejects.toThrow(
new HttpException('白名单验证失败', EXCEPTION_CODE.WHITELIST_ERROR),
);
});

View File

@ -13,7 +13,7 @@ import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { RECORD_STATUS } from 'src/enums';
import { ApiTags } from '@nestjs/swagger';
import Joi from 'joi';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException';
import { WhitelistType } from 'src/interfaces/survey';
import { UserService } from 'src/modules/auth/services/user.service';
@ -24,7 +24,7 @@ import { WorkspaceMemberService } from 'src/modules/workspace/services/workspace
export class ResponseSchemaController {
constructor(
private readonly responseSchemaService: ResponseSchemaService,
private readonly logger: Logger,
private readonly logger: XiaojuSurveyLogger,
private readonly userService: UserService,
private readonly workspaceMemberService: WorkspaceMemberService,
) {}

View File

@ -1,4 +1,4 @@
import { Controller, Post, Body, HttpCode, Request } from '@nestjs/common';
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';
@ -7,37 +7,48 @@ import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { getPushingData } from 'src/utils/messagePushing';
import { ResponseSchemaService } from '../services/responseScheme.service';
import { CounterService } from '../services/counter.service';
import { SurveyResponseService } from '../services/surveyResponse.service';
import { ClientEncryptService } from '../services/clientEncrypt.service';
import { MessagePushingTaskService } from '../../message/services/messagePushingTask.service';
import { RedisService } from 'src/modules/redis/redis.service';
import moment from 'moment';
import * as Joi from 'joi';
import * as forge from 'node-forge';
import { ApiTags } from '@nestjs/swagger';
import { Logger } from 'src/logger';
import { CounterService } from '../services/counter.service';
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')
export class SurveyResponseController {
constructor(
private readonly responseSchemaService: ResponseSchemaService,
private readonly counterService: CounterService,
private readonly surveyResponseService: SurveyResponseService,
private readonly clientEncryptService: ClientEncryptService,
private readonly messagePushingTaskService: MessagePushingTaskService,
private readonly logger: Logger,
private readonly counterService: CounterService,
private readonly logger: XiaojuSurveyLogger,
private readonly redisService: RedisService,
private readonly userService: UserService,
private readonly workspaceMemberService: WorkspaceMemberService,
) {}
@Post('/createResponse')
@HttpCode(200)
async createResponse(@Body() reqBody, @Request() req) {
async createResponse(@Body() reqBody) {
// 检查签名
checkSign(reqBody);
// 校验参数
@ -53,9 +64,7 @@ export class SurveyResponseController {
}).validate(reqBody, { allowUnknown: true });
if (error) {
this.logger.error(`updateMeta_parameter error: ${error.message}`, {
req,
});
this.logger.error(`updateMeta_parameter error: ${error.message}`);
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}
@ -205,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]
@ -214,39 +224,78 @@ export class SurveyResponseController {
const arr = cur.options.map((optionItem) => ({
hash: optionItem.hash,
text: optionItem.text,
quota: optionItem.quota,
}));
pre[cur.field] = arr;
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 val = decryptedData[field];
const vals = Array.isArray(val) ? val : [val];
const value = decryptedData[field];
const values = Array.isArray(value) ? value : [value];
if (field in optionTextAndId) {
// 记录选项的提交数量,用于投票题回显、或者拓展上限限制功能
const optionCountData: Record<string, any> =
const optionCountData =
(await this.counterService.get({
surveyPath,
key: field,
surveyPath,
type: 'option',
})) || { total: 0 };
optionCountData.total++;
for (const val of vals) {
})) || {};
//遍历选项hash值
for (const val of values) {
const option = optionTextAndId[field].find(
(opt) => opt['hash'] === val,
);
const quota = parseInt(option['quota']);
if (
quota &&
optionCountData?.[val] &&
quota <= optionCountData[val]
) {
return {
code: EXCEPTION_CODE.RESPONSE_OVER_LIMIT,
data: {
field,
optionHash: option.hash,
},
};
}
if (!optionCountData[val]) {
optionCountData[val] = 1;
} else {
optionCountData[val] = 0;
}
optionCountData[val]++;
}
if (!optionCountData['total']) {
optionCountData['total'] = 1;
} else {
optionCountData['total']++;
}
this.counterService.set({
surveyPath,
successParams.push({
key: field,
data: optionCountData,
surveyPath,
type: 'option',
data: optionCountData,
});
}
}
// 校验通过后统一更新
await Promise.all(
successParams.map((item) => this.counterService.set(item)),
);
} catch (error) {
this.logger.error(error.message);
throw error;
} finally {
await this.redisService.unlockResource(lock);
this.logger.info(`unlockResource: ${lockKey}`);
}
// 入库
const surveyResponse =
@ -259,7 +308,6 @@ export class SurveyResponseController {
optionTextAndId,
});
const surveyId = responseSchema.pageId;
const sendData = getPushingData({
surveyResponse,
questionList: responseSchema?.code?.dataConf?.dataList || [],

View File

@ -1,19 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { MessageModule } from '../message/message.module';
import { RedisModule } from '../redis/redis.module';
import { ResponseSchemaService } from './services/responseScheme.service';
import { SurveyResponseService } from './services/surveyResponse.service';
import { CounterService } from './services/counter.service';
import { ClientEncryptService } from './services/clientEncrypt.service';
import { RedisService } from '../redis/redis.service';
import { ResponseSchema } from 'src/models/responseSchema.entity';
import { Counter } from 'src/models/counter.entity';
import { SurveyResponse } from 'src/models/surveyResponse.entity';
import { ClientEncrypt } from 'src/models/clientEncrypt.entity';
import { Logger } from 'src/logger';
import { LoggerProvider } from 'src/logger/logger.provider';
import { ClientEncryptController } from './controllers/clientEncrpt.controller';
import { CounterController } from './controllers/counter.controller';
@ -23,6 +22,9 @@ import { SurveyResponseUIController } from './controllers/surveyResponseUI.contr
import { AuthModule } from '../auth/auth.module';
import { WorkspaceModule } from '../workspace/workspace.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
TypeOrmModule.forFeature([
@ -33,6 +35,7 @@ import { WorkspaceModule } from '../workspace/workspace.module';
]),
ConfigModule,
MessageModule,
RedisModule,
AuthModule,
WorkspaceModule,
],
@ -48,7 +51,8 @@ import { WorkspaceModule } from '../workspace/workspace.module';
SurveyResponseService,
CounterService,
ClientEncryptService,
Logger,
LoggerProvider,
RedisService,
],
exports: [
ResponseSchemaService,

View File

@ -10,7 +10,7 @@ import { Workspace } from 'src/models/workspace.entity';
import { WorkspaceMember } from 'src/models/workspaceMember.entity';
import { UserService } from 'src/modules/auth/services/user.service';
import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
import { User } from 'src/models/user.entity';
jest.mock('src/guards/authentication.guard');
@ -65,7 +65,7 @@ describe('WorkspaceController', () => {
},
},
{
provide: Logger,
provide: XiaojuSurveyLogger,
useValue: {
info: jest.fn(),
error: jest.fn(),

View File

@ -31,7 +31,7 @@ import {
import { splitMembers } from '../utils/splitMember';
import { UserService } from 'src/modules/auth/services/user.service';
import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service';
import { Logger } from 'src/logger';
import { XiaojuSurveyLogger } from 'src/logger';
import { GetWorkspaceListDto } from '../dto/getWorkspaceList.dto';
import { WorkspaceMember } from 'src/models/workspaceMember.entity';
import { Workspace } from 'src/models/workspace.entity';
@ -46,7 +46,7 @@ export class WorkspaceController {
private readonly workspaceMemberService: WorkspaceMemberService,
private readonly userService: UserService,
private readonly surveyMetaService: SurveyMetaService,
private readonly logger: Logger,
private readonly logger: XiaojuSurveyLogger,
) {}
@Get('getRoleList')
@ -64,10 +64,7 @@ export class WorkspaceController {
async create(@Body() workspace: CreateWorkspaceDto, @Request() req) {
const { value, error } = CreateWorkspaceDto.validate(workspace);
if (error) {
this.logger.error(
`CreateWorkspaceDto validate failed: ${error.message}`,
{ req },
);
this.logger.error(`CreateWorkspaceDto validate failed: ${error.message}`);
throw new HttpException(
`参数错误: 请联系管理员`,
EXCEPTION_CODE.PARAMETER_ERROR,
@ -137,7 +134,6 @@ export class WorkspaceController {
if (error) {
this.logger.error(
`GetWorkspaceListDto validate failed: ${error.message}`,
{ req },
);
throw new HttpException(
`参数错误: 请联系管理员`,

53
server/src/utils/xss.ts Normal file
View File

@ -0,0 +1,53 @@
import xss from 'xss';
const myxss = new (xss as any).FilterXSS({
onIgnoreTagAttr(tag, name, value) {
if (name === 'style' || name === 'class') {
return `${name}="${value}"`;
}
return undefined;
},
onIgnoreTag(tag, html) {
// <xxx>过滤为空,否则不过滤为空
const re1 = new RegExp('<.+?>', 'g');
if (re1.test(html)) {
return '';
} else {
return html;
}
},
});
export const cleanRichTextWithMediaTag = (text) => {
if (!text) {
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, '');
return content;
};
export function escapeHtml(html) {
return html.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export const transformHtmlTag = (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(/&nbsp;/g, "")
};
const filterXSSClone = myxss.process.bind(myxss);
export const filterXSS = (html) => filterXSSClone(transformHtmlTag(html));
export const escapeFilterXSS = (html) => escapeHtml(filterXSS(html));

2
web/.gitignore vendored
View File

@ -8,6 +8,8 @@ node_modules
.env.production.local
.env.local
components.d.ts
# Log files
npm-debug.log*
yarn-debug.log*

View File

@ -11,7 +11,7 @@
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
"format": "prettier --write src/materials/setters/widgets/QuotaConfig.vue"
},
"dependencies": {
"@logicflow/core": "2.0.0",
@ -30,6 +30,7 @@
"node-forge": "^1.3.1",
"pinia": "^2.1.7",
"qrcode": "^1.5.3",
"uuid": "^10.0.0",
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"vuedraggable": "^4.1.0",
@ -43,6 +44,7 @@
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.19",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-vue": "^5.0.3",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/eslint-config-prettier": "^8.0.0",

View File

@ -2,10 +2,71 @@
<router-view></router-view>
</template>
<script>
export default {
name: 'App'
<script setup lang="ts">
import { watch } from 'vue'
import { get as _get } from 'lodash-es'
import { useUserStore } from '@/management/stores/user'
import { useRouter } from 'vue-router'
import { ElMessageBox, ElMessage, type Action } from 'element-plus'
// axios
import axios from 'axios'
const userStore = useUserStore()
const router = useRouter()
let timer: any
const showConfirmBox = () => {
ElMessageBox.alert('登录状态已失效,请重新登陆。', '提示', {
confirmButtonText: '确认',
showClose: false,
callback: (action: Action) => {
if (action === 'confirm') {
userStore.logout();
router.replace({ name: 'login' });
}
}
});
}
const checkAuth = async () => {
try {
const token = _get(userStore, 'userInfo.token')
const res = await axios({
url: '/api/user/getUserInfo',
headers: {
Authorization: `Bearer ${token}`
}
})
if (res.data.code !== 200) {
showConfirmBox();
} else {
timer = setTimeout(() => {
checkAuth()
}, 30 * 60 * 1000);
}
} catch (error) {
const e = error as any
ElMessage.error(e.message)
}
}
watch(() => userStore.hasLogined, (hasLogined) => {
if (hasLogined) {
timer = setTimeout(() => {
checkAuth()
}, 30 * 60 * 1000);
} else {
clearTimeout(timer);
}
})
</script>
<style lang="scss">

View File

@ -16,3 +16,4 @@ export const getStatisticList = (data) => {
}
})
}

View File

@ -8,6 +8,9 @@ export const login = (data) => {
return axios.post('/auth/login', data)
}
export const getUserInfo = () => {
return axios.get('/user/getUserInfo')
}
/** 获取密码强度 */
export const getPasswordStrength = (password) => {
return axios.get('/auth/register/password/strength', {

View File

@ -0,0 +1,29 @@
import axios from './base'
export const createDownloadSurveyResponseTask = ({ surveyId, isDesensitive }) => {
return axios.post('/downloadTask/createTask', {
surveyId,
isDesensitive
})
}
export const getDownloadTask = taskId => {
return axios.get('/downloadTask/getDownloadTask', { params: { taskId } })
}
export const getDownloadTaskList = ({ pageIndex, pageSize }) => {
return axios.get('/downloadTask/getDownloadTaskList', {
params: {
pageIndex,
pageSize
}
})
}
//问卷删除
export const deleteDownloadTask = (taskId) => {
return axios.post('/downloadTask/deleteDownloadTask', {
taskId,
})
}

View File

@ -20,8 +20,8 @@ export const getSurveyById = (id) => {
})
}
export const saveSurvey = ({ surveyId, configData }) => {
return axios.post('/survey/updateConf', { surveyId, configData })
export const saveSurvey = ({ surveyId, configData, sessionId }) => {
return axios.post('/survey/updateConf', { surveyId, configData, sessionId })
}
export const publishSurvey = ({ surveyId }) => {
@ -52,3 +52,11 @@ export const deleteSurvey = (surveyId) => {
export const updateSurvey = (data) => {
return axios.post('/survey/updateMeta', data)
}
export const getSessionId = ({ surveyId }) => {
return axios.post('/session/create', { surveyId })
}
export const seizeSession = ({ sessionId }) => {
return axios.post('/session/seize', { sessionId })
}

View File

@ -102,7 +102,7 @@ export const statusMaps = {
new: '未发布',
editing: '修改中',
published: '已发布',
removed: '',
removed: '已删除',
pausing: ''
}

View File

@ -41,13 +41,15 @@ export const defaultQuestionConfig = {
text: '选项1',
others: false,
othersKey: '',
placeholderDesc: ''
placeholderDesc: '',
quota: '0'
},
{
text: '选项2',
others: false,
othersKey: '',
placeholderDesc: ''
placeholderDesc: '',
quota: '0'
}
],
star: 5,
@ -74,5 +76,6 @@ export const defaultQuestionConfig = {
placeholder: '500',
value: 500
}
}
},
quotaNoDisplay: false
}

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

@ -2,7 +2,9 @@
<div class="data-table-page">
<template v-if="tableData.total">
<div class="menus">
<el-button type="primary" :loading="isDownloading" @click="onDownload">导出全部数据</el-button>
<el-switch
class="desensitive-switch"
:model-value="isShowOriginData"
active-text="是否展示原数据"
@input="onIsShowOriginChange"
@ -25,11 +27,42 @@
<div v-else>
<EmptyIndex :data="noDataConfig" />
</div>
<el-dialog
v-model="downloadDialogVisible"
title="导出确认"
width="500"
style="padding: 40px;"
>
<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="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">
<el-button @click="downloadDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmDownload()">
确认
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, toRefs } from 'vue'
import { reactive, toRefs, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
@ -37,6 +70,7 @@ import EmptyIndex from '@/management/components/EmptyIndex.vue'
import { getRecycleList } from '@/management/api/analysis'
import { noDataConfig } from '@/management/config/analysisConfig'
import DataTable from '../components/DataTable.vue'
import { createDownloadSurveyResponseTask, getDownloadTask } from '@/management/api/downloadTask'
const dataTableState = reactive({
mainTableLoading: false,
@ -47,10 +81,16 @@ const dataTableState = reactive({
},
currentPage: 1,
isShowOriginData: false,
tmpIsShowOriginData: false
tmpIsShowOriginData: false,
isDownloading: false,
downloadDialogVisible: false,
downloadForm: {
isDesensitive: true,
},
})
const { mainTableLoading, tableData, isShowOriginData } = toRefs(dataTableState)
const { mainTableLoading, tableData, isShowOriginData, downloadDialogVisible, isDownloading } = toRefs(dataTableState)
const downloadForm = dataTableState.downloadForm
const route = useRoute()
@ -115,8 +155,67 @@ const init = async () => {
ElMessage.error('查询回收数据失败,请重试')
}
}
onMounted(() => {
init()
})
const onDownload = async () => {
dataTableState.downloadDialogVisible = true
}
const confirmDownload = async () => {
if (isDownloading.value) {
return
}
try {
isDownloading.value = true
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)
if (taskInfo.url) {
window.open(taskInfo.url)
ElMessage.success("导出成功")
}
} catch (error) {
ElMessage.error('导出失败,请重试')
}
} else {
ElMessage.error('导出失败,请重试')
}
} catch (error) {
ElMessage.error('导出失败,请重试')
} finally {
isDownloading.value = false
}
}
const checkIsTaskFinished = (taskId) => {
return new Promise((resolve, reject) => {
const run = () => {
getDownloadTask(taskId).then(res => {
if (res.code === 200 && res.data) {
const status = res.data.curStatus.status
if (status === 'new' || status === 'computing') {
setTimeout(() => {
run()
}, 5000)
} else {
resolve(res.data)
}
} else {
reject("导出失败");
}
})
}
run()
})
}
init()
</script>
<style lang="scss" scoped>
@ -126,6 +225,11 @@ init()
overflow: hidden;
}
.download-tips {
display: flex;
color: #ec4e29;
}
.menus {
margin-bottom: 20px;
}
@ -139,4 +243,8 @@ init()
.data-list {
margin-bottom: 20px;
}
.desensitive-switch {
float: right;
}
</style>

View File

@ -0,0 +1,103 @@
<template>
<div class="question-list-root">
<div class="top-nav">
<div class="left">
<img class="logo-img" src="/imgs/Logo.webp" alt="logo" />
<el-menu :default-active="activeIndex" class="el-menu-demo" mode="horizontal">
<el-menu-item index="1" @click="handleSurvey">问卷列表</el-menu-item>
<el-menu-item index="2">下载中心</el-menu-item>
</el-menu>
</div>
<div class="login-info">
您好{{ userInfo?.username }}
<img class="login-info-img" src="/imgs/avatar.webp" />
<span class="logout" @click="handleLogout">退出</span>
</div>
</div>
<div class="table-container">
<DownloadTaskList></DownloadTaskList>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useUserStore } from '@/management/stores/user'
import { useRouter } from 'vue-router'
import DownloadTaskList from './components/DownloadTaskList.vue'
const userStore = useUserStore()
const router = useRouter()
const userInfo = computed(() => {
return userStore.userInfo
})
const handleSurvey = () => {
router.push('/survey')
}
const handleLogout = () => {
userStore.logout()
router.replace({ name: 'login' })
}
const activeIndex = ref('2')
</script>
<style lang="scss" scoped>
.question-list-root {
height: 100%;
background-color: #f6f7f9;
.top-nav {
background: #fff;
color: #4a4c5b;
padding: 0 20px;
height: 56px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.04);
.left {
display: flex;
align-items: center;
width: calc(100% - 200px);
.logo-img {
width: 90px;
height: fit-content;
padding-right: 20px;
}
.el-menu {
width: 100%;
height: 56px;
border: none !important;
:deep(.el-menu-item, .is-active) {
border: none !important;
}
}
}
.login-info {
display: flex;
align-items: center;
.login-info-img {
margin-left: 10px;
height: 30px;
margin-top: -6px;
}
.logout {
margin-left: 20px;
}
}
span {
cursor: pointer;
color: #faa600;
}
}
.table-container {
margin-top: 20px;
display: flex;
justify-content: center;
width: 100%; /* 确保容器宽度为100% */
}
}
</style>

View File

@ -0,0 +1,220 @@
<template>
<div v-loading="loading" class="list-wrapper" v-if="total">
<el-table
v-if="total"
ref="multipleListTable"
class="list-table"
:data="dataList"
empty-text="暂无数据"
row-key="_id"
header-row-class-name="tableview-header"
row-class-name="tableview-row"
cell-class-name="tableview-cell"
style="width: 100%"
v-loading="loading"
>
<el-table-column
v-for="field in fieldList"
:key="field.key"
:prop="field.key"
:label="field.title"
:width="field.width"
:class-name="[field.key]"
:formatter="field.formatter"
>
</el-table-column>
<el-table-column label="操作" width="200">
<template v-slot="{ row }">
<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>
<div class="list-pagination" v-if="total">
<el-pagination
background
layout="prev, pager, next"
:total="total"
small
:page-size="pageSize"
@current-change="handleCurrentChange"
>
</el-pagination>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { get, map } from 'lodash-es'
import { ElMessage, ElMessageBox } from 'element-plus'
import { deleteDownloadTask, getDownloadTaskList } from '@/management/api/downloadTask'
import { CODE_MAP } from '@/management/api/base'
import moment from 'moment'
//
import 'moment/locale/zh-cn'
//
moment.locale('zh-cn')
const loading = ref(false)
const pageSize = ref(10)
const total = ref(0)
const dataList: Array<any> = reactive([])
onMounted(() => {
getList({ pageIndex: 1 })
})
const getList = async ({ pageIndex }: { pageIndex: number }) => {
if (!pageIndex) {
pageIndex = 1
}
const params = {
pageSize: pageSize.value,
pageIndex,
}
const res: Record<string, any> = await getDownloadTaskList(params)
if (res.code === CODE_MAP.SUCCESS) {
total.value = res.data.total
const list = res.data.list as any
dataList.splice(0, dataList.length, ...list);
}
loading.value = false
}
const statusTextMap: Record<string, string> = {
new: '排队中',
computing: '计算中',
finished: '已完成',
removed: '已删除',
};
let currentDelRow: Record<string, any> = {}
//
const handleDownload = async (row: any) => {
if (row.curStatus.status === 'removed') {
ElMessage.error('文件已删除')
return
}
if (row.url) {
window.open(row.url)
}
}
//
const openDeleteDialog = async (row: any) => {
try {
await ElMessageBox.confirm('是否确认删除?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
currentDelRow = row
confirmDelete()
} catch (error) {
console.log('取消删除')
}
}
//
const confirmDelete = async () => {
try {
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("删除失败,请刷新重试")
}
}
const fields = ['filename', 'fileSize', 'createDate', 'curStatus']
const fieldList = computed(() => {
return map(fields, (f) => {
return get(downloadListConfig, f)
})
})
const downloadListConfig = {
filename: {
title: '文件名称',
key: 'filename',
width: 340,
tip: true
},
fileSize: {
title: '预估大小',
key: 'fileSize',
width: 140
},
createDate: {
title: '下载时间',
key: 'createDate',
width: 240
},
curStatus: {
title: '状态',
key: 'curStatus.status',
formatter(row: Record<string, any>, column: Record<string, any>) {
console.log({
row,
column,
})
return statusTextMap[get(row, column.rawColumnKey)]
}
}
}
const handleCurrentChange = (val: number) => {
getList({ pageIndex: val })
}
</script>
<style lang="scss" scoped>
.question-list-root {
height: 100%;
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;
}
.list-pagination {
margin-top: 20px;
:deep(.el-pagination) {
display: flex;
justify-content: flex-end;
}
}
}
}
</style>

View File

@ -68,15 +68,17 @@ const updateLogicConf = () => {
}
const showLogicConf = showLogicEngine.value.toJson()
if(JSON.stringify(schema.logicConf.showLogicConf) !== JSON.stringify(showLogicConf)) {
//
changeSchema({ key: 'logicConf', value: { showLogicConf } })
}
return res
}
const jumpLogicConf = jumpLogicEngine.value.toJson()
if(JSON.stringify(schema.logicConf.jumpLogicConf) !== JSON.stringify(jumpLogicConf)){
changeSchema({ key: 'logicConf', value: { jumpLogicConf } })
}
return res
}

View File

@ -24,13 +24,16 @@ import LeftMenu from '@/management/components/LeftMenu.vue'
import CommonTemplate from './components/CommonTemplate.vue'
import Navbar from './components/ModuleNavbar.vue'
const editStore = useEditStore()
const { init, setSurveyId } = editStore
const router = useRouter()
const route = useRoute()
onMounted(async () => {
setSurveyId(route.params.id as string)
const surveyId = route.params.id as string
setSurveyId(surveyId)
try {
await init()

View File

@ -4,14 +4,15 @@
</el-button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
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
@ -22,7 +23,21 @@ const props = defineProps<Props>()
const isPublishing = ref<boolean>(false)
const editStore = useEditStore()
const { schema, getSchemaFromRemote } = editStore
const { getSchemaFromRemote } = editStore
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 = () => {
@ -45,6 +60,46 @@ const validate = () => {
}
}
const onSave = async () => {
if (!saveData.value.sessionId) {
ElMessage.error('未获取到sessionId')
return null
}
if (!saveData.value.surveyId) {
ElMessage.error('未获取到问卷id')
return null
}
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) {
return
@ -60,22 +115,12 @@ const handlePublish = async () => {
return
}
const saveData = buildData(schema)
if (!saveData.surveyId) {
isPublishing.value = false
ElMessage.error('未获取到问卷id')
return
}
try {
const saveRes: any = await saveSurvey(saveData)
if (saveRes.code !== 200) {
isPublishing.value = false
ElMessage.error(saveRes.errmsg || '问卷保存失败')
const saveRes: any = await onSave()
if (!saveRes || saveRes.code !== CODE_MAP.SUCCESS) {
return
}
const publishRes: any = await publishSurvey({ surveyId: saveData.surveyId })
const publishRes: any = await publishSurvey({ surveyId: saveData.value.surveyId })
if (publishRes.code === 200) {
ElMessage.success('发布成功')
getSchemaFromRemote()

View File

@ -18,10 +18,10 @@ import { ref, computed, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import { saveSurvey } from '@/management/api/survey'
import { saveSurvey, seizeSession } from '@/management/api/survey'
import buildData from './buildData'
interface Props {
@ -44,8 +44,8 @@ const saveText = computed(
)
const editStore = useEditStore()
const { schemaUpdateTime } = storeToRefs(editStore)
const { schema } = editStore
const { schemaUpdateTime, schema, sessionId } = storeToRefs(editStore)
const validate = () => {
let checked = true
@ -68,18 +68,32 @@ const validate = () => {
}
}
const saveData = async () => {
const saveData = buildData(schema)
const onSave = async () => {
const saveData = buildData(schema.value, sessionId.value);
if (!saveData.sessionId) {
ElMessage.error('sessionId有误')
return null
}
if (!saveData.surveyId) {
ElMessage.error('未获取到问卷id')
return null
}
const res = await saveSurvey(saveData)
const res: Record<string, any> = await saveSurvey(saveData)
return res
}
const seize = async () => {
const seizeRes: Record<string, any> = await seizeSession({ sessionId: sessionId.value })
if (seizeRes.code === 200) {
location.reload();
} else {
ElMessage.error('获取权限失败,请重试')
}
}
const timerHandle = ref<NodeJS.Timeout | number | null>(null)
const triggerAutoSave = () => {
if (autoSaveStatus.value === 'saving') {
@ -95,7 +109,7 @@ const triggerAutoSave = () => {
isShowAutoSave.value = true
nextTick(async () => {
try {
const res: any = await saveData()
const res: any = await handleSave()
if (res.code === 200) {
autoSaveStatus.value = 'succeed'
} else {
@ -120,21 +134,34 @@ const handleSave = async () => {
return
}
isSaving.value = true
isShowAutoSave.value = false
//
const { checked, msg } = validate()
if (!checked) {
isSaving.value = false
ElMessage.error(msg)
return
}
isSaving.value = true
try {
const res: any = await saveData()
const res: any = await onSave()
if(!res) {
return
}
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();
}
}
});
} else {
ElMessage.error(res.errmsg)
}

View File

@ -1,7 +1,7 @@
import { pick as _pick, get as _get } from 'lodash-es'
// 生成需要保存到接口的数据
export default function (schema) {
export default function (schema, sessionId) {
const surveyId = _get(schema, 'metaData._id')
const configData = _pick(schema, [
'bannerConf',
@ -19,6 +19,7 @@ export default function (schema) {
delete configData.questionDataList
return {
surveyId,
configData
configData,
sessionId
}
}

View File

@ -74,6 +74,7 @@ const routes = [
background-color: $primary-color;
bottom: -16px;
left: 20px;
z-index: 99;
}
}

View File

@ -80,6 +80,7 @@ import 'element-plus/theme-chalk/src/message.scss'
import { useEditStore } from '@/management/stores/edit'
import { cleanRichText } from '@/common/xss'
import { cleanRichTextWithMediaTag } from '@/common/xss'
export default {
name: 'OptionConfig',
@ -110,7 +111,7 @@ export default {
return mapData
},
textOptions() {
return this.curOptions.map((item) => item.text)
return this.curOptions.map((item) => cleanRichTextWithMediaTag(item.text))
}
},
components: {

View File

@ -7,7 +7,7 @@ export default [
{
title: '提交限制',
key: 'limitConfig',
formList: ['limit_tLimit']
formList: ['limit_tLimit', 'limit_breakAnswer', 'limit_backAnswer']
},
{
title: '作答限制',

View File

@ -22,6 +22,22 @@ export default {
type: 'QuestionTimeHour',
placement: 'top'
},
limit_breakAnswer: {
key: 'breakAnswer',
label: '允许断点续答',
tip: '回填前一次作答中的内容(注:更换设备/浏览器/清除缓存/更改内容重新发布则此功能失效)',
placement: 'top',
type: 'CustomedSwitch',
value: false,
},
limit_backAnswer: {
key: 'backAnswer',
label: '自动填充上次提交内容',
tip: '回填前一次提交的内容(注:更换设备/浏览器/清除缓存/更改内容重新发布则此功能失效)',
placement: 'top',
type: 'CustomedSwitch',
value: false,
},
interview_pwd_switch: {
key: 'passwordSwitch',
label: '访问密码',
@ -96,5 +112,5 @@ export default {
relyFunc: (data) => {
return data.whitelistType == 'MEMBER'
}
}
},
}

View File

@ -5,6 +5,7 @@
<img class="logo-img" src="/imgs/Logo.webp" alt="logo" />
<el-menu :default-active="activeIndex" class="el-menu-demo" mode="horizontal">
<el-menu-item index="1">问卷列表</el-menu-item>
<el-menu-item index="2" @click="handleDownload">下载中心</el-menu-item>
</el-menu>
</div>
<div class="login-info">
@ -184,6 +185,10 @@ const handleLogout = () => {
userStore.logout()
router.replace({ name: 'login' })
}
//
const handleDownload = () => {
router.push({ name: 'download' })
}
</script>
<style lang="scss" scoped>

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'
@ -26,6 +29,14 @@ const routes: RouteRecordRaw[] = [
title: '问卷列表'
}
},
{
path: '/download',
name: 'download',
component: () => import('../pages/downloadTask/TaskList.vue'),
meta: {
needLogin: true
}
},
{
path: '/survey/:id/edit',
meta: {

View File

@ -10,7 +10,7 @@ import { QUESTION_TYPE } from '@/common/typeEnum'
import { getQuestionByType } from '@/management/utils/index'
import { filterQuestionPreviewData } from '@/management/utils/index'
import { getSurveyById } from '@/management/api/survey'
import { getSurveyById, getSessionId } from '@/management/api/survey'
import { getNewField } from '@/management/utils'
import submitFormConfig from '@/management/pages/edit/setterConfig/submitConfig'
@ -93,6 +93,7 @@ function useInitializeSchema(surveyId: Ref<string>, initializeSchemaCallBack: ()
})
const { showLogicEngine, initShowLogicEngine, jumpLogicEngine, initJumpLogicEngine } =
useLogicEngine(schema)
function initSchema({ metaData, codeData }: { metaData: any; codeData: any }) {
schema.metaData = metaData
schema.bannerConf = _merge({}, schema.bannerConf, codeData.bannerConf)
@ -151,7 +152,7 @@ function useInitializeSchema(surveyId: Ref<string>, initializeSchemaCallBack: ()
initSchema,
getSchemaFromRemote,
showLogicEngine,
jumpLogicEngine
jumpLogicEngine,
}
}
@ -477,21 +478,48 @@ function useLogicEngine(schema: any) {
initJumpLogicEngine
}
}
type IBannerItem = {
name: string
key: string
list: Array<Object>
}
type IBannerList = Record<string, IBannerItem>
export const useEditStore = defineStore('edit', () => {
const surveyId = ref('')
const bannerList: Ref<IBannerList> = ref({})
const cooperPermissions = ref(Object.values(SurveyPermissions))
const schemaUpdateTime = ref(Date.now())
function setSurveyId(id: string) {
surveyId.value = id
}
const { schema, initSchema, getSchemaFromRemote, showLogicEngine, jumpLogicEngine } =
useInitializeSchema(surveyId, () => {
editGlobalBaseConf.initCounts()
})
const sessionId = ref('')
async function initSessionId() {
const sessionIdKey = `${surveyId.value}_sessionId`;
const localSessionId = sessionStorage.getItem(sessionIdKey)
if (localSessionId) {
sessionId.value = localSessionId
} else {
const res: Record<string, any> = await getSessionId({ surveyId: surveyId.value })
if (res.code === 200) {
sessionId.value = res.data.sessionId
sessionStorage.setItem(sessionIdKey, sessionId.value)
}
}
}
const questionDataList = toRef(schema, 'questionDataList')
const editGlobalBaseConf = useEditGlobalBaseConf(questionDataList, updateTime)
@ -499,9 +527,6 @@ export const useEditStore = defineStore('edit', () => {
schema.questionDataList = data
}
function setSurveyId(id: string) {
surveyId.value = id
}
const fetchBannerData = async () => {
const res: any = await getBannerData()
@ -509,6 +534,7 @@ export const useEditStore = defineStore('edit', () => {
bannerList.value = res.data
}
}
const fetchCooperPermissions = async (id: string) => {
const res: any = await getCollaboratorPermissions(id)
if (res.code === CODE_MAP.SUCCESS) {
@ -531,6 +557,7 @@ export const useEditStore = defineStore('edit', () => {
const { metaData } = schema
if (!metaData || (metaData as any)?._id !== surveyId.value) {
await getSchemaFromRemote()
await initSessionId()
}
currentEditOne.value = null
currentEditStatus.value = 'Success'
@ -641,7 +668,9 @@ export const useEditStore = defineStore('edit', () => {
return {
editGlobalBaseConf,
surveyId,
sessionId,
setSurveyId,
initSessionId,
bannerList,
fetchBannerData,
cooperPermissions,

View File

@ -47,6 +47,10 @@ export default defineComponent({
voteTotal: {
type: Number,
default: 10
},
quotaNoDisplay:{
type: Boolean,
default: true
}
},
emits: ['change'],
@ -141,9 +145,23 @@ export default defineComponent({
<span
v-html={filterXSS(item.text)}
class="item-title-text"
style="display: block; height: auto; padding: 9px 0"
style="display: block; height: auto; padding-top: 9px"
></span>
)}
{
//
!this.readonly && (item.quota && item.quota !== "0") && !this.quotaNoDisplay && (
<span
class="remaining-text"
style={{
display: 'block',
fontSize: 'smaller',
color: item.release === 0 ? '#EB505C' : '#92949D'
}}
>
剩余{item.release}
</span>
)}
{slots.vote?.({
option: item,
voteTotal: this.voteTotal

View File

@ -73,7 +73,7 @@
vertical-align: top;
width: 0.32rem;
height: 0.32rem;
margin: 0rem 0.24rem 0 0;
margin: 11px 0.24rem 0 0;
border: 1px solid $border-color;
border-radius: 2px;
background-color: #fff;
@ -128,7 +128,6 @@
.qicon.qicon-gouxuan {
display: inline-block;
font-size: 0.32rem;
line-height: 0.32rem;
border-color: $primary-color;
background-color: $primary-color;
color: #fff;

View File

@ -1,4 +1,4 @@
import { computed, defineComponent, shallowRef, defineAsyncComponent } from 'vue'
import { computed, defineComponent, shallowRef, defineAsyncComponent, watch } from 'vue'
import { includes } from 'lodash-es'
import BaseChoice from '../BaseChoice'
@ -41,10 +41,15 @@ export default defineComponent({
maxNum: {
type: [Number, String],
default: 1
},
quotaNoDisplay:{
type: Boolean,
default: false
}
},
emits: ['change'],
setup(props, { emit }) {
const disableState = computed(() => {
if (!props.maxNum) {
return false
@ -53,17 +58,31 @@ export default defineComponent({
})
const isDisabled = (item) => {
const { value } = props
return disableState.value && !includes(value, item.value)
return disableState.value && !includes(value, item.hash)
}
const myOptions = computed(() => {
const { options } = props
return options.map((item) => {
return {
...item,
disabled: isDisabled(item)
disabled: (item.release === 0) || isDisabled(item)
}
})
})
// 0
watch(() => props.value, (value) => {
const disabledHash = myOptions.value.filter(i => i.disabled).map(i => i.hash)
if (value && disabledHash.length) {
disabledHash.forEach(hash => {
const index = value.indexOf(hash)
if( index> -1) {
const newValue = [...value]
newValue.splice(index, 1)
onChange(newValue)
}
})
}
})
const onChange = (value) => {
const key = props.field
emit('change', {
@ -92,12 +111,13 @@ export default defineComponent({
return {
onChange,
handleSelectMoreChange,
disableState,
myOptions,
selectMoreView
}
},
render() {
const { readonly, field, myOptions, onChange, maxNum, value, selectMoreView } = this
const { readonly, field, myOptions, onChange, maxNum, value, quotaNoDisplay, selectMoreView } = this
return (
<BaseChoice
uiTarget="checkbox"
@ -108,6 +128,7 @@ export default defineComponent({
onChange={onChange}
value={value}
layout={this.layout}
quotaNoDisplay={quotaNoDisplay}
>
{{
selectMore: (scoped) => {

View File

@ -117,20 +117,40 @@ const meta = {
label: '至少选择数',
type: 'InputNumber',
key: 'minNum',
value: '',
value: 0,
min: 0,
max: 'maxNum',
max: moduleConfig => { return moduleConfig?.maxNum || 0 },
contentClass: 'input-number-config'
},
{
label: '最多选择数',
type: 'InputNumber',
key: 'maxNum',
value: '',
min: 'minNum',
value: 0,
min: moduleConfig => { return moduleConfig?.minNum || 0 },
max: moduleConfig => { return moduleConfig?.options?.length },
contentClass: 'input-number-config'
}
},
]
},
{
name: 'optionQuota',
label: '选项配额',
labelStyle: {
'font-weight': 'bold'
},
type: 'QuotaConfig',
// 输出转换
valueSetter({ options, quotaNoDisplay}) {
return [{
key: 'options',
value: options
},
{
key: 'quotaNoDisplay',
value: quotaNoDisplay
}]
}
}
],
editConfigure: {

View File

@ -6,22 +6,13 @@ import GetHash from '@materials/questions/common/utils/getOptionHash'
function useOptionBase(options) {
const optionList = ref(options)
const addOption = (text = '选项', others = false, index = -1, field) => {
// const {} = payload
let addOne
if (optionList.value[0]) {
addOne = cloneDeep(optionList.value[0])
} else {
addOne = {
let addOne = {
text: '',
hash: '',
imageUrl: '',
others: false,
mustOthers: false,
othersKey: '',
placeholderDesc: '',
score: 0,
limit: ''
}
}
if (typeof text !== 'string') {
text = '选项'

View File

@ -1,4 +1,4 @@
import { defineComponent, shallowRef, defineAsyncComponent } from 'vue'
import { defineComponent, shallowRef, watch, defineAsyncComponent } from 'vue'
import BaseChoice from '../BaseChoice'
/**
@ -31,10 +31,28 @@ export default defineComponent({
readonly: {
type: Boolean,
default: false
},
quotaNoDisplay:{
type: Boolean,
default: false
}
},
emits: ['change'],
setup(props, { emit }) {
// 0
watch(() => props.value, (value) => {
const disabledHash = props.options.filter(i => i.disabled).map(i => i.hash)
if (value && disabledHash.length) {
disabledHash.forEach(hash => {
const index = value.indexOf(hash)
if( index> -1) {
const newValue = [...value]
newValue.splice(index, 1)
onChange(newValue)
}
})
}
})
const onChange = (value) => {
const key = props.field
emit('change', {
@ -81,6 +99,7 @@ export default defineComponent({
field={this.field}
layout={this.layout}
onChange={this.onChange}
quotaNoDisplay={this.quotaNoDisplay}
>
{{
selectMore: (scoped) => {

View File

@ -53,22 +53,22 @@ const meta = {
description: '这是用于描述选项',
defaultValue: [
{
text: '选项1',
imageUrl: '',
others: false,
mustOthers: false,
othersKey: '',
placeholderDesc: '',
hash: '115019'
"text": "选项1",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115019"
},
{
text: '选项2',
imageUrl: '',
others: false,
mustOthers: false,
othersKey: '',
placeholderDesc: '',
hash: '115020'
"text": "选项2",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115020"
}
]
},
@ -77,6 +77,12 @@ const meta = {
propType: String,
description: '排列方式',
defaultValue: 'vertical'
},
{
name: 'quotaNoDisplay',
propType: Boolean,
description: '不展示配额剩余数量',
defaultValue: false
}
],
formConfig: [basicConfig, {
@ -101,6 +107,24 @@ const meta = {
]
},
]
},{
name: 'optionQuota',
label: '选项配额',
labelStyle: {
'font-weight': 'bold'
},
type: 'QuotaConfig',
// 输出转换
valueSetter({ options, quotaNoDisplay}) {
return [{
key: 'options',
value: options
},
{
key: 'quotaNoDisplay',
value: quotaNoDisplay
}]
}
}],
editConfigure: {
optionEdit: {

View File

@ -120,7 +120,7 @@ const meta = {
key: 'minNum',
value: '',
min: 0,
max: 'maxNum',
max: moduleConfig => { return moduleConfig?.maxNum || 0 },
contentClass: 'input-number-config'
},
{
@ -128,7 +128,8 @@ const meta = {
type: 'InputNumber',
key: 'maxNum',
value: '',
min: 'minNum',
min: moduleConfig => { return moduleConfig?.minNum || 0 },
max: moduleConfig => { return moduleConfig?.options?.length || 0 },
contentClass: 'input-number-config'
}
]

View File

@ -2,7 +2,7 @@
<el-switch v-model="newValue" @change="changeData" />
</template>
<script setup>
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
const props = defineProps({
@ -23,4 +23,15 @@ const changeData = (value) => {
value
})
}
watch(
() => props.formConfig.value,
(newVal) => {
if (newVal !== newValue.value) {
newValue.value = newVal
}
},
{
immediate: true
}
)
</script>

View File

@ -13,6 +13,7 @@ import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
interface Props {
formConfig: any
moduleConfig: any
@ -24,12 +25,15 @@ interface Emit {
const emit = defineEmits<Emit>()
const props = defineProps<Props>()
const modelValue = ref(Number(props.formConfig.value) || 0)
const modelValue = ref(Number(props.formConfig.value))
const myModuleConfig = ref(props.moduleConfig)
const minModelValue = computed(() => {
const { min } = props.formConfig
if (min) {
if (min !== undefined) {
if (typeof min === 'function') {
return min(props.moduleConfig)
return min(myModuleConfig.value)
} else {
return Number(min)
}
@ -38,16 +42,13 @@ const minModelValue = computed(() => {
})
const maxModelValue = computed(() => {
const { max, min } = props.formConfig
const { max } = props.formConfig
if (max) {
if (typeof max === 'function') {
return max(props.moduleConfig)
return max(myModuleConfig.value)
} else {
return Number(max)
}
} else if (min !== undefined && Array.isArray(props.moduleConfig?.options)) {
return props.moduleConfig.options.length
} else {
return Infinity
}
@ -65,6 +66,9 @@ const handleInputChange = (value: number) => {
emit(FORM_CHANGE_EVENT_KEY, { key, value })
}
watch(() => props.moduleConfig, (newVal) => {
myModuleConfig.value = newVal
})
watch(
() => props.formConfig.value,
(newVal) => {

View File

@ -0,0 +1,180 @@
<template>
<div class="quota-wrapper">
<span class="quota-config" @click="openQuotaConfig"> 设置> </span>
<el-dialog v-model="dialogVisible" @closed="cleanTempQuota" class="dialog">
<template #header>
<div class="dialog-title">选项配额</div>
</template>
<el-table
:header-cell-style="{ background: '#F6F7F9', color: '#6E707C' }"
:data="optionData"
border
style="width: 100%"
@cell-click="handleCellClick"
>
<el-table-column property="text" label="选项" style="width: 50%">
<template v-slot="scope">
<div v-html="cleanRichTextWithMediaTag(scope.row.text)"></div>
</template>
</el-table-column>
<el-table-column property="quota" style="width: 50%">
<template #header>
<div style="display: flex; align-items: center">
<span>配额设置</span>
<el-tooltip
class="tooltip"
effect="dark"
placement="right"
content="类似商品库存表示最多可以被选择多少次0为无限制已发布问卷上限修改时数量不可减小。"
>
<i-ep-questionFilled class="icon-tip" />
</el-tooltip>
</div>
</template>
<template v-slot="scope">
<el-input
v-if="scope.row.isEditing"
:id="`${scope.row.hash}editInput`"
v-model="scope.row.tempQuota"
type="number"
@blur="handleInput(scope.row)"
placeholder="请输入"
>
</el-input>
<div v-else class="item__txt">
<span v-if="scope.row.tempQuota !== '0'">{{ scope.row.tempQuota }}</span>
<span v-else style="color: #c8c9cd">请输入</span>
</div>
</template>
</el-table-column>
</el-table>
<div class="quota-no-display">
<el-checkbox v-model="quotaNoDisplayValue" label="不展示配额剩余数量"> </el-checkbox>
<el-tooltip
class="tooltip"
effect="dark"
placement="right"
content="勾选后,将不对用户展示剩余配额数量。"
>
<i-ep-questionFilled class="icon-tip" />
</el-tooltip>
</div>
<template #footer>
<div class="diaglog-footer">
<el-button @click="cancel">取消</el-button>
<el-button @click="confirm" type="primary">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
import { ElMessageBox } from 'element-plus'
import { cleanRichTextWithMediaTag } from '@/common/xss'
const props = defineProps(['formConfig', 'moduleConfig'])
const emit = defineEmits(['form-change'])
const dialogVisible = ref(false)
const moduleConfig = ref(props.moduleConfig)
const optionData = ref(props.moduleConfig.options)
const quotaNoDisplayValue = ref(moduleConfig.value.quotaNoDisplay)
const openQuotaConfig = () => {
optionData.value.forEach((item) => {
item.tempQuota = item.quota
})
dialogVisible.value = true
}
const cancel = () => {
dialogVisible.value = false
}
const confirm = () => {
dialogVisible.value = false
//
handleQuotaChange()
emit(FORM_CHANGE_EVENT_KEY, {
options: optionData.value,
quotaNoDisplay: quotaNoDisplayValue.value
})
}
const handleCellClick = (row, column) => {
if (column.property === 'quota') {
optionData.value.forEach((r) => {
if (r !== row) r.isEditing = false
})
row.tempQuota = row.tempQuota === '0' ? row.quota : row.tempQuota
row.isEditing = true
nextTick(() => {
const input = document.getElementById(`${row.hash}editInput`)
input.focus()
})
}
}
const handleInput = (row) => {
if (row.tempQuota !== '0' && +row.tempQuota < +row.quota) {
ElMessageBox.alert('配额数不可减少!', '警告', {
confirmButtonText: '确定'
})
row.tempQuota = row.quota
}
row.isEditing = false
}
const handleQuotaChange = () => {
optionData.value.forEach((item) => {
item.quota = item.tempQuota
delete item.tempQuota
})
}
const cleanTempQuota = () => {
optionData.value.forEach((item) => {
delete item.tempQuota
})
}
watch(
() => props.moduleConfig,
(val) => {
moduleConfig.value = val
optionData.value = val.options
quotaNoDisplayValue.value = val.quotaNoDisplay
},
{ immediate: true, deep: true }
)
</script>
<style lang="scss" scoped>
.quota-wrapper {
width: 90%;
display: flex;
justify-content: flex-end;
:deep(.cell) {
line-height: 35px;
}
.quota-no-display {
padding-top: 8px;
}
}
.quota-title {
font-size: 14px;
color: #606266;
margin-bottom: 20px;
font-weight: bold;
align-items: center;
}
.quota-config {
color: #ffa600;
cursor: pointer;
font-size: 14px;
}
.dialog {
width: 41vw;
.dialog-title {
color: #292a36;
font-size: 20px;
}
}
</style>

View File

@ -0,0 +1,79 @@
<template>
<div class="mask" v-show="visible">
<div class="box">
<div class="title">{{ title }}</div>
<div class="btn-box">
<div class="btn cancel" @click="handleCancel">{{ cancelBtnText }}</div>
<div class="btn confirm" @click="handleConfirm">{{ confirmBtnText }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
visible?: boolean
cancelBtnText?: string
confirmBtnText?: string
title?: string
}
interface Emit {
(ev: 'confirm', callback: () => void): void
(ev: 'cancel', callback: () => void): void
(ev: 'close'): void
}
const emit = defineEmits<Emit>()
withDefaults(defineProps<Props>(), {
visible: false,
cancelBtnText: '取消',
confirmBtnText: '确定',
title: ''
})
const handleConfirm = () => {
emit('confirm', () => {
emit('close')
})
}
const handleCancel = () => {
emit('cancel', () => {
emit('close')
})
}
</script>
<style lang="scss" scoped>
@import url('../styles/dialog.scss');
.btn-box {
padding: 20px 0 0;
display: flex;
align-items: center;
justify-content: space-between;
.btn {
width: 48%;
font-size: 0.28rem;
border-radius: 0.04rem;
text-align: center;
padding: 0.16rem 0;
line-height: 0.4rem;
cursor: pointer;
&.cancel {
background: #fff;
color: #92949d;
border: 1px solid #e3e4e8;
}
&.confirm {
background-color: #4a4c5b;
border: 1px solid #4a4c5b;
color: #fff;
}
}
}
</style>

View File

@ -4,6 +4,7 @@
:moduleConfig="questionConfig"
:indexNumber="indexNumber"
:showTitle="true"
@input="handleInput"
@change="handleChange"
></QuestionRuleContainer>
</template>
@ -14,6 +15,7 @@ import QuestionRuleContainer from '../../materials/questions/QuestionRuleContain
import { useVoteMap } from '@/render/hooks/useVoteMap'
import { useShowOthers } from '@/render/hooks/useShowOthers'
import { useShowInput } from '@/render/hooks/useShowInput'
import { useOptionsQuota } from '@/render/hooks/useOptionsQuota'
import { cloneDeep } from 'lodash-es'
import { useQuestionStore } from '../stores/question'
import { useSurveyStore } from '../stores/survey'
@ -49,16 +51,24 @@ const questionConfig = computed(() => {
let alloptions = options
if (type === QUESTION_TYPE.VOTE) {
//
const { options, voteTotal } = useVoteMap(field)
const voteOptions = unref(options)
alloptions = alloptions.map((obj, index) => Object.assign(obj, voteOptions[index]))
moduleConfig.voteTotal = unref(voteTotal)
}
if(NORMAL_CHOICES.includes(type) &&
options.some(option => option.quota > 0)) {
//
let { options: optionWithQuota } = useOptionsQuota(field)
alloptions = alloptions.map((obj, index) => Object.assign(obj, optionWithQuota[index]))
}
if (
NORMAL_CHOICES.includes(type) &&
options.filter((optionItem) => optionItem.others).length > 0
options.some(option => option.others)
) {
//
let { options, othersValue } = useShowOthers(field)
const othersOptions = unref(options)
alloptions = alloptions.map((obj, index) => Object.assign(obj, othersOptions[index]))
@ -71,6 +81,7 @@ const questionConfig = computed(() => {
Object.keys(rest?.rangeConfig).filter((index) => rest?.rangeConfig[index].isShowInput).length >
0
) {
//
let { rangeConfig, othersValue } = useShowInput(field)
moduleConfig.rangeConfig = unref(rangeConfig)
moduleConfig.othersValue = unref(othersValue)
@ -126,9 +137,19 @@ const handleChange = (data) => {
if (props.moduleConfig.type === QUESTION_TYPE.VOTE) {
questionStore.updateVoteData(data)
}
//
if (props.moduleConfig.type === NORMAL_CHOICES) {
questionStore.updateQuotaData(data)
}
//
localStorageBack()
processJumpSkip()
}
const handleInput = () => {
localStorageBack()
}
const processJumpSkip = () => {
const targetResult = surveyStore.jumpLogicEngine
.getResultsByField(changeField.value, surveyStore.formValues)
@ -169,4 +190,12 @@ const processJumpSkip = () => {
.map((item) => item.field)
questionStore.addNeedHideFields(skipKey)
}
const localStorageBack = () => {
var formData = Object.assign({}, surveyStore.formValues);
//
localStorage.removeItem(surveyStore.surveyPath + "_questionData")
localStorage.setItem(surveyStore.surveyPath + "_questionData", JSON.stringify(formData))
localStorage.setItem('isSubmit', JSON.stringify(false))
}
</script>

View File

@ -0,0 +1,23 @@
import { useQuestionStore } from '../stores/question'
export const useOptionsQuota = (questionKey) => {
const questionStore = useQuestionStore()
const options = questionStore.questionData[questionKey].options.map((option) => {
if(option.quota){
const optionHash = option.hash
const selectCount = questionStore.quotaMap?.[questionKey]?.[optionHash] || 0
const release = Number(option.quota) - Number(selectCount)
return {
...option,
disabled: release === 0,
selectCount,
release
}
} else {
return {
...option,
}
}
})
return { options }
}

View File

@ -0,0 +1,18 @@
import { useQuestionStore } from '@/render/stores/question'
import { cleanRichText } from '@/common/xss'
export const useQuestionInfo = (field: string) => {
const questionstore = useQuestionStore()
const questionTitle = cleanRichText(questionstore.questionData[field]?.title)
const getOptionTitle = (value:any) => {
const options = questionstore.questionData[field]?.options || []
if (value instanceof Array) {
return options
.filter((item:any) => value.includes(item.hash))
.map((item:any) => cleanRichText(item.text))
} else {
return options.filter((item:any) => item.hash === value).map((item:any) => cleanRichText(item.text))
}
}
return { questionTitle, getOptionTitle }
}

View File

@ -2,60 +2,10 @@
<router-view></router-view>
</template>
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { getPublishedSurveyInfo, getPreviewSchema } from '../api/survey'
import useCommandComponent from '../hooks/useCommandComponent'
import { useSurveyStore } from '../stores/survey'
import AlertDialog from '../components/AlertDialog.vue'
const route = useRoute()
const surveyStore = useSurveyStore()
const loadData = (res: any, surveyPath: string) => {
if (res.code === 200) {
const data = res.data
const {
bannerConf,
baseConf,
bottomConf,
dataConf,
skinConf,
submitConf,
logicConf,
pageConf
} = data.code
const questionData = {
bannerConf,
baseConf,
bottomConf,
dataConf,
skinConf,
submitConf,
pageConf
}
if (!pageConf || pageConf?.length == 0) {
questionData.pageConf = [dataConf.dataList.length]
}
document.title = data.title
surveyStore.setSurveyPath(surveyPath)
surveyStore.initSurvey(questionData)
surveyStore.initShowLogicEngine(logicConf?.showLogicConf)
surveyStore.initJumpLogicEngine(logicConf.jumpLogicConf)
} else {
throw new Error(res.errmsg)
}
}
onMounted(() => {
const surveyId = route.params.surveyId
console.log({ surveyId })
surveyStore.setSurveyPath(surveyId)
getDetail(surveyId as string)
})
watch(
() => route.query.t,
@ -63,22 +13,4 @@ watch(
location.reload()
}
)
const getDetail = async (surveyPath: string) => {
const alert = useCommandComponent(AlertDialog)
try {
if (surveyPath.length > 8) {
const res: any = await getPreviewSchema({ surveyPath })
loadData(res, surveyPath)
} else {
const res: any = await getPublishedSurveyInfo({ surveyPath })
loadData(res, surveyPath)
surveyStore.getEncryptInfo()
}
} catch (error: any) {
console.log(error)
alert({ title: error.message || '获取问卷失败' })
}
}
</script>

View File

@ -21,9 +21,9 @@
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
// @ts-ignore
import communalLoader from '@materials/communals/communalLoader.js'
import MainRenderer from '../components/MainRenderer.vue'
@ -38,6 +38,8 @@ import { submitForm } from '../api/survey'
import encrypt from '../utils/encrypt'
import useCommandComponent from '../hooks/useCommandComponent'
import { getPublishedSurveyInfo, getPreviewSchema } from '../api/survey'
import { useQuestionInfo } from '../hooks/useQuestionInfo'
interface Props {
questionInfo?: any
@ -70,6 +72,68 @@ const pageIndex = computed(() => questionStore.pageIndex)
const { bannerConf, submitConf, bottomConf: logoConf, whiteData } = storeToRefs(surveyStore)
const surveyPath = computed(() => surveyStore.surveyPath || '')
const route = useRoute()
onMounted(() => {
const surveyId = route.params.surveyId
console.log({ surveyId })
surveyStore.setSurveyPath(surveyId)
getDetail(surveyId as string)
})
const loadData = (res: any, surveyPath: string) => {
if (res.code === 200) {
const data = res.data
const {
bannerConf,
baseConf,
bottomConf,
dataConf,
skinConf,
submitConf,
logicConf,
pageConf
} = data.code
const questionData = {
bannerConf,
baseConf,
bottomConf,
dataConf,
skinConf,
submitConf,
pageConf
}
if (!pageConf || pageConf?.length == 0) {
questionData.pageConf = [dataConf.dataList.length]
}
document.title = data.title
surveyStore.setSurveyPath(surveyPath)
surveyStore.initSurvey(questionData)
surveyStore.initShowLogicEngine(logicConf?.showLogicConf)
surveyStore.initJumpLogicEngine(logicConf?.jumpLogicConf)
} else {
throw new Error(res.errmsg)
}
}
const getDetail = async (surveyPath: string) => {
const alert = useCommandComponent(AlertDialog)
try {
if (surveyPath.length > 8) {
const res: any = await getPreviewSchema({ surveyPath })
loadData(res, surveyPath)
} else {
const res: any = await getPublishedSurveyInfo({ surveyPath })
loadData(res, surveyPath)
surveyStore.getEncryptInfo()
}
} catch (error: any) {
console.log(error)
alert({ title: error.message || '获取问卷失败' })
}
}
const validate = (cbk: (v: boolean) => void) => {
const index = 0
mainRef.value.$refs.formGroup[index].validate(cbk)
@ -88,6 +152,15 @@ const normalizationRequestBody = () => {
...whiteData.value
}
//
localStorage.removeItem(surveyPath.value + "_questionData")
localStorage.removeItem("isSubmit")
//
var formData : Record<string, any> = Object.assign({}, surveyStore.formValues)
localStorage.setItem(surveyPath.value + "_questionData", JSON.stringify(formData))
localStorage.setItem('isSubmit', JSON.stringify(true))
if (encryptInfo?.encryptType) {
result.encryptType = encryptInfo.encryptType
result.data = encrypt[result.encryptType as 'rsa']({
@ -111,10 +184,18 @@ const submitSurver = async () => {
}
try {
const params = normalizationRequestBody()
console.log(params)
const res: any = await submitForm(params)
if (res.code === 200) {
router.replace({ name: 'successPage' })
} else if(res.code === 9003) {
//
questionStore.initQuotaMap()
const titile = useQuestionInfo(res.data.field).questionTitle
const optionText = useQuestionInfo(res.data.field).getOptionTitle(res.data.optionHash)
const message = `${titile}】的【${optionText}】配额已满,请重新选择`
alert({
title: message
})
} else {
alert({
title: res.errmsg || '提交失败'

View File

@ -3,11 +3,195 @@ import { defineStore } from 'pinia'
import { set } from 'lodash-es'
import { useSurveyStore } from '@/render/stores/survey'
import { queryVote } from '@/render/api/survey'
import { QUESTION_TYPE, NORMAL_CHOICES } from '@/common/typeEnum'
const VOTE_INFO_KEY = 'voteinfo'
const QUOTA_INFO_KEY = 'limitinfo'
// 投票进度逻辑聚合
const usevVoteMap = (questionData) => {
const voteMap = ref({})
//初始化投票题的数据
const initVoteData = async () => {
const surveyStore = useSurveyStore()
const surveyPath = surveyStore.surveyPath
const fieldList = []
for (const field in questionData.value) {
const { type } = questionData.value[field]
if (type.includes(QUESTION_TYPE.VOTE)) {
fieldList.push(field)
}
}
if (fieldList.length <= 0) {
return
}
try {
localStorage.removeItem(VOTE_INFO_KEY)
const voteRes = await queryVote({
surveyPath,
fieldList: fieldList.join(',')
})
if (voteRes.code === 200) {
localStorage.setItem(
VOTE_INFO_KEY,
JSON.stringify({
...voteRes.data
})
)
setVoteMap(voteRes.data)
}
} catch (error) {
console.log(error)
}
}
const updateVoteMapByKey = (data) => {
const { questionKey, voteKey, voteValue } = data
// 兼容为空的情况
if (!voteMap.value[questionKey]) {
voteMap.value[questionKey] = {}
}
voteMap.value[questionKey][voteKey] = voteValue
}
const setVoteMap = (data) => {
voteMap.value = data
}
const updateVoteData = (data) => {
const { key: questionKey, value: questionVal } = data
// 更新前获取接口缓存在localStorage中的数据
const localData = localStorage.getItem(VOTE_INFO_KEY)
const voteinfo = JSON.parse(localData)
const currentQuestion = questionData.value[questionKey]
const options = currentQuestion.options
const voteTotal = voteinfo?.[questionKey]?.total || 0
let totalPayload = {
questionKey,
voteKey: 'total',
voteValue: voteTotal
}
options.forEach((option) => {
const optionhash = option.hash
const voteCount = voteinfo?.[questionKey]?.[optionhash] || 0
// 如果选中值包含该选项对应voteCount 和 voteTotal + 1
if (
Array.isArray(questionVal) ? questionVal.includes(optionhash) : questionVal === optionhash
) {
const countPayload = {
questionKey,
voteKey: optionhash,
voteValue: voteCount + 1
}
totalPayload.voteValue += 1
updateVoteMapByKey(countPayload)
} else {
const countPayload = {
questionKey,
voteKey: optionhash,
voteValue: voteCount
}
updateVoteMapByKey(countPayload)
}
updateVoteMapByKey(totalPayload)
})
}
return {
voteMap,
initVoteData,
updateVoteData
}
}
// 选项配额逻辑聚合
const useQuotaMap = (questionData) => {
const quotaMap = ref({})
const updateQuotaMapByKey = ({ questionKey, optionKey, data }) =>{
// 兼容为空的情况
if (!quotaMap.value[questionKey]) {
quotaMap.value[questionKey] = {}
}
quotaMap.value[questionKey][optionKey] = data
}
const initQuotaMap = async () => {
const surveyStore = useSurveyStore()
const surveyPath = surveyStore.surveyPath
const fieldList = Object.keys(questionData.value).filter(field => {
if (NORMAL_CHOICES.includes(questionData.value[field].type)) {
return questionData.value[field].options.some(option => option.quota > 0)
}
})
// 如果不存在则不请求选项上限接口
if (fieldList.length <= 0) {
return
}
try {
localStorage.removeItem(QUOTA_INFO_KEY)
const quotaRes = await queryVote({
surveyPath,
fieldList: fieldList.join(',')
})
if (quotaRes.code === 200) {
localStorage.setItem(
QUOTA_INFO_KEY,
JSON.stringify({
...quotaRes.data
})
)
Object.keys(quotaRes.data).forEach(field => {
Object.keys(quotaRes.data[field]).forEach((optionHash) => {
updateQuotaMapByKey({ questionKey: field, optionKey: optionHash, data: quotaRes.data[field][optionHash] })
})
})
}
} catch (error) {
console.log(error)
}
}
const updateQuotaData = (data) => {
const { key: questionKey, value: questionVal } = data
// 更新前获取接口缓存在localStorage中的数据
const localData = localStorage.getItem(QUOTA_INFO_KEY)
const quotaMap = JSON.parse(localData)
// const quotaMap = state.quotaMap
const currentQuestion = questionData.value[questionKey]
const options = currentQuestion.options
options.forEach((option) => {
const optionhash = option.hash
const selectCount = quotaMap?.[questionKey]?.[optionhash].selectCount || 0
// 如果选中值包含该选项,对应 voteCount 和 voteTotal + 1
if (
Array.isArray(questionVal) ? questionVal.includes(optionhash) : questionVal === optionhash
) {
const countPayload = {
questionKey,
optionKey: optionhash,
selectCount: selectCount + 1
}
updateQuotaMapByKey(countPayload)
} else {
const countPayload = {
questionKey,
optionKey: optionhash,
selectCount: selectCount
}
updateQuotaMapByKey(countPayload)
}
})
}
return {
quotaMap,
initQuotaMap,
updateQuotaMapByKey,
updateQuotaData
}
}
export const useQuestionStore = defineStore('question', () => {
const voteMap = ref({})
const questionData = ref(null)
const questionSeq = ref([]) // 题目的顺序,因为可能会有分页的情况,所以是一个二维数组[[qid1, qid2], [qid3,qid4]]
const pageIndex = ref(1) // 当前分页的索引
@ -82,6 +266,8 @@ export const useQuestionStore = defineStore('question', () => {
const setQuestionData = (data) => {
questionData.value = data
}
const { voteMap, setVoteMap, initVoteData, updateVoteData } = usevVoteMap(questionData)
const { quotaMap, initQuotaMap, updateQuotaData } = useQuotaMap(questionData)
const changeSelectMoreData = (data) => {
const { key, value, field } = data
@ -92,95 +278,6 @@ export const useQuestionStore = defineStore('question', () => {
questionSeq.value = data
}
const setVoteMap = (data) => {
voteMap.value = data
}
const updateVoteMapByKey = (data) => {
const { questionKey, voteKey, voteValue } = data
// 兼容为空的情况
if (!voteMap.value[questionKey]) {
voteMap.value[questionKey] = {}
}
voteMap.value[questionKey][voteKey] = voteValue
}
//初始化投票题的数据
const initVoteData = async () => {
const surveyStore = useSurveyStore()
const surveyPath = surveyStore.surveyPath
const fieldList = []
for (const field in questionData.value) {
const { type } = questionData.value[field]
if (/vote/.test(type)) {
fieldList.push(field)
}
}
if (fieldList.length <= 0) {
return
}
try {
localStorage.removeItem(VOTE_INFO_KEY)
const voteRes = await queryVote({
surveyPath,
fieldList: fieldList.join(',')
})
if (voteRes.code === 200) {
localStorage.setItem(
VOTE_INFO_KEY,
JSON.stringify({
...voteRes.data
})
)
setVoteMap(voteRes.data)
}
} catch (error) {
console.log(error)
}
}
const updateVoteData = (data) => {
const { key: questionKey, value: questionVal } = data
// 更新前获取接口缓存在localStorage中的数据
const localData = localStorage.getItem(VOTE_INFO_KEY)
const voteinfo = JSON.parse(localData)
const currentQuestion = questionData.value[questionKey]
const options = currentQuestion.options
const voteTotal = voteinfo?.[questionKey]?.total || 0
let totalPayload = {
questionKey,
voteKey: 'total',
voteValue: voteTotal
}
options.forEach((option) => {
const optionhash = option.hash
const voteCount = voteinfo?.[questionKey]?.[optionhash] || 0
// 如果选中值包含该选项对应voteCount 和 voteTotal + 1
if (
Array.isArray(questionVal) ? questionVal.includes(optionhash) : questionVal === optionhash
) {
const countPayload = {
questionKey,
voteKey: optionhash,
voteValue: voteCount + 1
}
totalPayload.voteValue += 1
updateVoteMapByKey(countPayload)
} else {
const countPayload = {
questionKey,
voteKey: optionhash,
voteValue: voteCount
}
updateVoteMapByKey(countPayload)
}
updateVoteMapByKey(totalPayload)
})
}
const setChangeField = (field) => {
changeField.value = field
@ -199,7 +296,7 @@ export const useQuestionStore = defineStore('question', () => {
needHideFields.value = needHideFields.value.filter((field) => !fields.includes(field))
}
return {
voteMap,
questionData,
questionSeq,
renderData,
@ -209,8 +306,8 @@ export const useQuestionStore = defineStore('question', () => {
setQuestionData,
changeSelectMoreData,
setQuestionSeq,
voteMap,
setVoteMap,
updateVoteMapByKey,
initVoteData,
updateVoteData,
changeField,
@ -219,6 +316,9 @@ export const useQuestionStore = defineStore('question', () => {
needHideFields,
addNeedHideFields,
removeNeedHideFields,
getQuestionIndexByField
getQuestionIndexByField,
quotaMap,
initQuotaMap,
updateQuotaData
}
})

View File

@ -1,7 +1,7 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { defineStore } from 'pinia'
import { pick } from 'lodash-es'
import { cloneDeep, pick } from 'lodash-es'
import { isMobile as isInMobile } from '@/render/utils/index'
import { getEncryptInfo as getEncryptInfoApi } from '@/render/api/survey'
@ -12,12 +12,16 @@ import moment from 'moment'
// 引入中文
import 'moment/locale/zh-cn'
// 设置中文
moment.locale('zh-cn')
import adapter from '../adapter'
import { RuleMatch } from '@/common/logicEngine/RulesMatch'
// import { jumpLogicRule } from '@/common/logicEngine/jumpLogicRule'
import useCommandComponent from '../hooks/useCommandComponent'
import BackAnswerDialog from '../components/BackAnswerDialog.vue'
const confirm = useCommandComponent(BackAnswerDialog)
moment.locale('zh-cn')
/**
* CODE_MAP不从management引入在dev阶段会导致B端 router被加载进而导致C端路由被添加 baseUrl: /management
*/
@ -26,6 +30,8 @@ const CODE_MAP = {
ERROR: 500,
NO_AUTH: 403
}
export const useSurveyStore = defineStore('survey', () => {
const surveyPath = ref('')
const isMobile = ref(isInMobile())
@ -109,13 +115,11 @@ export const useSurveyStore = defineStore('survey', () => {
return isSuccess
}
const initSurvey = (option) => {
setEnterTime()
if (!canFillQuestionnaire(option.baseConf, option.submitConf)) {
return
}
// 加载空白页面
function clearFormData(option) {
// 根据初始的schema生成questionData, questionSeq, rules, formValues, 这四个字段
const {
questionData,
@ -134,6 +138,7 @@ export const useSurveyStore = defineStore('survey', () => {
'pageConf'
])
)
// todo: 建议通过questionStore提供setqueationdata方法修改属性否则不好跟踪变化
questionStore.questionData = questionData
questionStore.questionSeq = questionSeq
@ -148,8 +153,75 @@ export const useSurveyStore = defineStore('survey', () => {
formValues.value = _formValues
whiteData.value = option.whiteData
pageConf.value = option.pageConf
// 获取已投票数据
questionStore.initVoteData()
questionStore.initQuotaMap()
}
function fillFormData(formData) {
const _formValues = cloneDeep(formValues.value)
for(const key in formData){
_formValues[key] = formData[key]
}
formValues.value = _formValues
}
const initSurvey = (option) => {
setEnterTime()
if (!canFillQuestionnaire(option.baseConf, option.submitConf)) {
return
}
// 加载空白问卷
clearFormData(option)
const { breakAnswer, backAnswer } = option.baseConf
const localData = JSON.parse(localStorage.getItem(surveyPath.value + "_questionData"))
const isSubmit = JSON.parse(localStorage.getItem('isSubmit'))
if(localData) {
// 断点续答
if(breakAnswer) {
confirm({
title: "是否继续上次填写的内容?",
onConfirm: async () => {
try {
// 回填答题内容
fillFormData(localData)
} catch (error) {
console.log(error)
} finally {
confirm.close()
}
},
onCancel: async() => {
confirm.close()
}
})
} else if (backAnswer) {
if(isSubmit){
confirm({
title: "是否继续上次提交的内容?",
onConfirm: async () => {
try {
// 回填答题内容
fillFormData(localData)
} catch (error) {
console.log(error)
} finally {
confirm.close()
}
},
onCancel: async() => {
confirm.close()
}
})
}
}
} else {
clearFormData(option)
}
}
// 用户输入或者选择后,更新表单数据
@ -163,11 +235,11 @@ export const useSurveyStore = defineStore('survey', () => {
const showLogicEngine = ref()
const initShowLogicEngine = (showLogicConf) => {
showLogicEngine.value = new RuleMatch().fromJson(showLogicConf)
showLogicEngine.value = new RuleMatch().fromJson(showLogicConf || [])
}
const jumpLogicEngine = ref()
const initJumpLogicEngine = (jumpLogicConf) => {
jumpLogicEngine.value = new RuleMatch().fromJson(jumpLogicConf)
jumpLogicEngine.value = new RuleMatch().fromJson(jumpLogicConf || [])
}
return {

View File

@ -123,6 +123,10 @@ export default defineConfig({
target: 'http://127.0.0.1:3000',
changeOrigin: true
},
'/exportfile': {
target: 'http://127.0.0.1:3000',
changeOrigin: true
},
// 静态文件的默认存储文件夹
'/userUpload': {
target: 'http://127.0.0.1:3000',