Compare commits

...

9 Commits

Author SHA1 Message Date
chaorenluo
7ab790285f
fix:前端白名单问题调整 (#352)
* fix:修复前端白名单bug

* fix:添加保存和发布检查判断白名单设置是否有误
2024-07-17 11:25:37 +08:00
Stahsf
9aec0fb1ea
fix: 白名单问题调整 (#349)
* feat: 注册entity、出参调整

* fix: 白名单默认值处理

* feat: 修改白名单验证逻辑

* feat: 新增获取空间及下面成员的接口
2024-07-16 21:13:37 +08:00
chaorenluo
d31f6178c0
fix:修复误上报空间id和显示逻辑问题 (#320)
* --story=1059873 --user=T8罗佳明 【ALL】2024年06月日常优化 https://www.tapd.cn/23402991/s/2202857

* feat:答卷白名单功能

* fix:修复lint问题

* fix:提交问卷时添加verifyId参数

* fix:去掉上报空间id

* fix:line代码
2024-07-01 14:24:32 +08:00
Stahsf
5a8e821159
[Fix] 白名单相关Bug修复及用例新增 (#317)
* feat: 添加问卷信息字段、去掉C端获取问卷信息的敏感字段

* feat: 白名单验证接口

* test: 白名单验证单元测试、参数类型优化

* test: 增加白名单验证单元测试

* feat: 提交问卷时校验白名单

* test: 提交问卷验证verifyId

* test: verifyId不匹配测试

* feat: 注册entity、出参调整

* style: lint

* fix: verifyId校验问题

* test: 出参数据结构调整

* fix: 成员信息查询问题

* test: whitelist service测试用例
2024-06-29 14:05:16 +08:00
chaorenluo
99e21def1c
提交问卷时又白名单新增verifyId参数 (#316)
* --story=1059873 --user=T8罗佳明 【ALL】2024年06月日常优化 https://www.tapd.cn/23402991/s/2202857

* feat:答卷白名单功能

* fix:修复lint问题

* fix:提交问卷时添加verifyId参数
2024-06-28 18:38:54 +08:00
Stahsf
01ce20570f
[Fix] 白名单问题调整 (#310)
* feat: 添加问卷信息字段、去掉C端获取问卷信息的敏感字段

* feat: 白名单验证接口

* test: 白名单验证单元测试、参数类型优化

* test: 增加白名单验证单元测试

* feat: 提交问卷时校验白名单

* test: 提交问卷验证verifyId

* test: verifyId不匹配测试

* feat: 注册entity、出参调整

* style: lint
2024-06-27 20:31:39 +08:00
sudoooooo
f6569a3899 Merge branch 'develop' into feature/whitelist 2024-06-27 11:55:12 +08:00
chaorenluo
dab83e4d4c
答卷白名单前端代码 (#309)
* --story=1059873 --user=T8罗佳明 【ALL】2024年06月日常优化 https://www.tapd.cn/23402991/s/2202857

* feat: 新增分题统计相关功能 (#275)

* feat: 分题统计前端开发 (#276)

* [refactor]: check for the ispc-html class is done using a regular expression to ensure that the class is only added once when the browser window is resized (#301)

* feat: 调整文档内容

* fix: 修复预览兼容

* fix: 修复文档内容

* feat:答卷白名单功能

* fix:修复提示框不重置的问题

* fix:修复lint问题

* fix:修复server的lint问题

---------

Co-authored-by: luch <32321690+luch1994@users.noreply.github.com>
Co-authored-by: hiStephen <30630927+1004801012@users.noreply.github.com>
Co-authored-by: yelang <happyforeveron@gmail.com>
Co-authored-by: sudoooooo <zjbbabybaby@gmail.com>
2024-06-27 11:54:43 +08:00
Stahsf
667d13962c
[Feature] 白名单功能服务端 (#302)
* feat: 添加问卷信息字段、去掉C端获取问卷信息的敏感字段

* feat: 白名单验证接口

* test: 白名单验证单元测试、参数类型优化

* test: 增加白名单验证单元测试

* feat: 提交问卷时校验白名单

* test: 提交问卷验证verifyId

* test: verifyId不匹配测试
2024-06-19 14:31:18 +08:00
31 changed files with 1145 additions and 41 deletions

View File

@ -12,6 +12,7 @@ export enum EXCEPTION_CODE {
SURVEY_NOT_FOUND = 3004, // 问卷不存在
SURVEY_CONTENT_NOT_ALLOW = 3005, // 存在禁用内容
CAPTCHA_INCORRECT = 4001, // 验证码不正确
WHITELIST_ERROR = 4002, // 白名单校验错误
RESPONSE_SIGN_ERROR = 9001, // 签名不正确
RESPONSE_CURRENT_TIME_NOT_ALLOW = 9002, // 当前时间不允许提交

View File

@ -94,6 +94,23 @@ export interface SubmitConf {
msgContent: MsgContent;
}
// 白名单类型
export enum WhitelistType {
ALL = 'ALL',
// 空间成员
MEMBER = 'MEMBER',
// 自定义
CUSTOM = 'CUSTOM',
}
// 白名单用户类型
export enum MemberType {
// 手机号
MOBILE = 'MOBILE',
// 邮箱
EMAIL = 'EMAIL',
}
export interface BaseConf {
begTime: string;
endTime: string;
@ -101,6 +118,18 @@ export interface BaseConf {
answerEndTime: string;
tLimit: number;
language: string;
// 访问密码开关
passwordSwitch?: boolean;
// 密码
password?: string | null;
// 白名单类型
whitelistType?: WhitelistType;
// 白名单用户类型
memberType?: MemberType;
// 白名单列表
whitelist?: string[];
// 提示语
whitelistTip?: string;
}
export interface SkinConf {

View File

@ -31,6 +31,7 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { WorkspaceGuard } from 'src/guards/workspace.guard';
import { PERMISSION as WORKSPACE_PERMISSION } from 'src/enums/workspace';
import { MemberType, WhitelistType } from 'src/interfaces/survey';
@ApiTags('survey')
@Controller('/api/survey')
@ -214,6 +215,16 @@ export class SurveyController {
surveyMeta.isCollaborated = false;
}
// 白名单相关字段的默认值
const baseConf = surveyConf.code?.baseConf;
if (baseConf) {
baseConf.passwordSwitch = baseConf.passwordSwitch ?? false;
baseConf.password = baseConf.password ?? '';
baseConf.whitelistType = baseConf.whitelistType ?? WhitelistType.ALL;
baseConf.whitelist = baseConf.whitelist ?? [];
baseConf.memberType = baseConf.memberType ?? MemberType.MOBILE;
}
return {
code: 200,
data: {

View File

@ -6,6 +6,11 @@ import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { RECORD_STATUS } from 'src/enums';
import { ResponseSchema } from 'src/models/responseSchema.entity';
import { Logger } from 'src/logger';
import { UserService } from 'src/modules/auth/services/user.service';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
import { AuthService } from 'src/modules/auth/services/auth.service';
import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException';
jest.mock('../services/responseScheme.service');
@ -16,7 +21,40 @@ describe('ResponseSchemaController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ResponseSchemaController],
providers: [ResponseSchemaService],
providers: [
ResponseSchemaService,
AuthService,
{
provide: Logger,
useValue: {
info: jest.fn(),
},
},
{
provide: UserService,
useValue: {
getUserByUsername: jest.fn(),
},
},
{
provide: WorkspaceMemberService,
useValue: {
findAllByUserId: jest.fn(),
},
},
{
provide: AuthService,
useValue: {
create: jest.fn(),
},
},
{
provide: Logger,
useValue: {
error: jest.fn(),
},
},
],
}).compile();
controller = module.get<ResponseSchemaController>(ResponseSchemaController);
@ -66,5 +104,146 @@ describe('ResponseSchemaController', () => {
new HttpException('问卷已删除', EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED),
);
});
it('whitelistValidate should throw SurveyNotFoundException when survey is removed', async () => {
const surveyPath = '';
jest
.spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValue(null);
await expect(
controller.whitelistValidate(surveyPath, {
password: '123456',
}),
).rejects.toThrow(new SurveyNotFoundException('该问卷不存在,无法提交'));
});
it('whitelistValidate should throw WHITELIST_ERROR code when password is incorrect', async () => {
const surveyPath = '';
jest
.spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValue({
curStatus: {
status: 'published',
},
code: {
baseConf: {
passwordSwitch: true,
password: '123456',
},
},
} as ResponseSchema);
await expect(
controller.whitelistValidate(surveyPath, {
password: '123457',
}),
).rejects.toThrow(
new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR),
);
});
it('whitelistValidate should be successfully', async () => {
const surveyPath = 'test';
jest
.spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValue({
curStatus: {
status: 'published',
},
code: {
baseConf: {
passwordSwitch: true,
password: '123456',
},
},
} as ResponseSchema);
await expect(
controller.whitelistValidate(surveyPath, {
password: '123456',
}),
).resolves.toEqual({ code: 200, data: null });
});
it('whitelistValidate should throw WHITELIST_ERROR code when mobile or email is incorrect', async () => {
const surveyPath = '';
jest
.spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValue({
curStatus: {
status: 'published',
},
code: {
baseConf: {
passwordSwitch: true,
password: '123456',
whitelistType: 'CUSTOM',
memberType: 'MOBILE',
whitelist: ['13500000000'],
},
},
} as ResponseSchema);
await expect(
controller.whitelistValidate(surveyPath, {
password: '123456',
whitelist: '13500000001',
}),
).rejects.toThrow(
new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR),
);
});
it('whitelistValidate should throw WHITELIST_ERROR code when member is incorrect', async () => {
const surveyPath = '';
jest
.spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValue({
curStatus: {
status: 'published',
},
code: {
baseConf: {
passwordSwitch: true,
password: '123456',
whitelistType: 'MEMBER',
whitelist: ['Jack'],
},
},
} as ResponseSchema);
await expect(
controller.whitelistValidate(surveyPath, {
password: '123456',
whitelist: 'James',
}),
).rejects.toThrow(
new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR),
);
});
});
it('whitelistValidate should return verifyId successfully', async () => {
const surveyPath = '';
jest
.spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValue({
curStatus: {
status: 'published',
},
code: {
baseConf: {
passwordSwitch: true,
password: '123456',
whitelistType: 'CUSTOM',
memberType: 'MOBILE',
whitelist: ['13500000000'],
},
},
} as ResponseSchema);
await expect(
controller.whitelistValidate(surveyPath, {
password: '123456',
whitelist: '13500000000',
}),
).resolves.toEqual({ code: 200, data: null });
});
});

View File

@ -21,6 +21,10 @@ 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 { ResponseSchema } from 'src/models/responseSchema.entity';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { UserService } from 'src/modules/auth/services/user.service';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
const mockDecryptErrorBody = {
surveyPath: 'EBzdmnSp',
@ -124,6 +128,18 @@ describe('SurveyResponseController', () => {
info: jest.fn(),
},
},
{
provide: UserService,
useValue: {
getUserByUsername: jest.fn(),
},
},
{
provide: WorkspaceMemberService,
useValue: {
findAllByUserId: jest.fn(),
},
},
],
}).compile();
@ -306,5 +322,31 @@ describe('SurveyResponseController', () => {
HttpException,
);
});
it('should throw HttpException if password does not match', async () => {
const reqBody = {
...mockSubmitData,
password: '123457',
sign: '4ff02062141d92d80629eae4797ba68056f29a9709cdf59bf206776fc0971c1a.1710400229589',
};
jest
.spyOn(responseSchemaService, 'getResponseSchemaByPath')
.mockResolvedValueOnce({
curStatus: {
status: RECORD_STATUS.PUBLISHED,
},
code: {
baseConf: {
passwordSwitch: true,
password: '123456',
},
},
} as ResponseSchema);
await expect(controller.createResponse(reqBody, {})).rejects.toThrow(
new HttpException('白名单验证失败', EXCEPTION_CODE.WHITELIST_ERROR),
);
});
});
});

View File

@ -1,14 +1,33 @@
import { Controller, Get, HttpCode, Query } from '@nestjs/common';
import {
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Query,
} from '@nestjs/common';
import { ResponseSchemaService } from '../services/responseScheme.service';
import { HttpException } from 'src/exceptions/httpException';
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 { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException';
import { WhitelistType } from 'src/interfaces/survey';
import { UserService } from 'src/modules/auth/services/user.service';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
@ApiTags('surveyResponse')
@Controller('/api/responseSchema')
export class ResponseSchemaController {
constructor(private readonly responseSchemaService: ResponseSchemaService) {}
constructor(
private readonly responseSchemaService: ResponseSchemaService,
private readonly logger: Logger,
private readonly userService: UserService,
private readonly workspaceMemberService: WorkspaceMemberService,
) {}
@Get('/getSchema')
@HttpCode(200)
@ -34,9 +53,79 @@ export class ResponseSchemaController {
EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED,
);
}
// 去掉C端的敏感字段
if (responseSchema.code?.baseConf) {
responseSchema.code.baseConf.password = null;
responseSchema.code.baseConf.whitelist = [];
}
return {
code: 200,
data: responseSchema,
};
}
// 白名单验证
@Post('/:surveyPath/validate')
@HttpCode(200)
async whitelistValidate(@Param('surveyPath') surveyPath, @Body() body) {
const { value, error } = Joi.object({
password: Joi.string().allow(null, ''),
whitelist: Joi.string().allow(null, ''),
}).validate(body, { allowUnknown: true });
if (error) {
this.logger.error(`whitelistValidate error: ${error.message}`, {});
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}
// 问卷信息
const schema =
await this.responseSchemaService.getResponseSchemaByPath(surveyPath);
if (!schema || schema.curStatus.status === 'removed') {
throw new SurveyNotFoundException('该问卷不存在,无法提交');
}
const { password, whitelist: whitelistValue } = value;
const {
passwordSwitch,
password: settingPassword,
whitelistType,
whitelist,
} = schema.code.baseConf;
// 密码校验
if (passwordSwitch) {
if (settingPassword !== password) {
throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR);
}
}
// 名单校验(手机号/邮箱)
if (whitelistType === WhitelistType.CUSTOM) {
if (!whitelist.includes(whitelistValue)) {
throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR);
}
}
// 团队成员昵称校验
if (whitelistType === WhitelistType.MEMBER) {
const user = await this.userService.getUserByUsername(whitelistValue);
if (!user) {
throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR);
}
const workspaceMember = await this.workspaceMemberService.findAllByUserId(
{ userId: user._id.toString() },
);
if (!workspaceMember.length) {
throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR);
}
}
return {
code: 200,
data: null,
};
}
}

View File

@ -17,6 +17,9 @@ import * as Joi from 'joi';
import * as forge from 'node-forge';
import { ApiTags } from '@nestjs/swagger';
import { Logger } 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';
@ApiTags('surveyResponse')
@Controller('/api/surveyResponse')
@ -28,6 +31,8 @@ export class SurveyResponseController {
private readonly clientEncryptService: ClientEncryptService,
private readonly messagePushingTaskService: MessagePushingTaskService,
private readonly logger: Logger,
private readonly userService: UserService,
private readonly workspaceMemberService: WorkspaceMemberService,
) {}
@Post('/createResponse')
@ -43,6 +48,8 @@ export class SurveyResponseController {
sessionId: Joi.string(),
clientTime: Joi.number().required(),
difTime: Joi.number(),
password: Joi.string().allow(null, ''),
whitelist: Joi.string().allow(null, ''),
}).validate(reqBody, { allowUnknown: true });
if (error) {
@ -52,8 +59,16 @@ export class SurveyResponseController {
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { surveyPath, encryptType, data, sessionId, clientTime, difTime } =
value;
const {
surveyPath,
encryptType,
data,
sessionId,
clientTime,
difTime,
password,
whitelist: whitelistValue,
} = value;
// 查询schema
const responseSchema =
@ -62,6 +77,50 @@ export class SurveyResponseController {
throw new SurveyNotFoundException('该问卷不存在,无法提交');
}
// 白名单的verifyId校验
const baseConf = responseSchema.code.baseConf;
// 密码校验
if (baseConf?.passwordSwitch && baseConf.password) {
if (baseConf.password !== password) {
throw new HttpException(
'白名单验证失败',
EXCEPTION_CODE.WHITELIST_ERROR,
);
}
}
// 名单校验(手机号/邮箱)
if (baseConf?.whitelistType === WhitelistType.CUSTOM) {
if (!baseConf.whitelist.includes(whitelistValue)) {
throw new HttpException(
'白名单验证失败',
EXCEPTION_CODE.WHITELIST_ERROR,
);
}
}
// 团队成员昵称校验
if (baseConf?.whitelistType === WhitelistType.MEMBER) {
const user = await this.userService.getUserByUsername(whitelistValue);
if (!user) {
throw new HttpException(
'白名单验证失败',
EXCEPTION_CODE.WHITELIST_ERROR,
);
}
const workspaceMember = await this.workspaceMemberService.findAllByUserId(
{ userId: user._id.toString() },
);
if (!workspaceMember.length) {
throw new HttpException(
'白名单验证失败',
EXCEPTION_CODE.WHITELIST_ERROR,
);
}
}
const now = Date.now();
// 提交时间限制
const begTime = responseSchema.code?.baseConf?.begTime || 0;

View File

@ -20,6 +20,8 @@ import { CounterController } from './controllers/counter.controller';
import { ResponseSchemaController } from './controllers/responseSchema.controller';
import { SurveyResponseController } from './controllers/surveyResponse.controller';
import { SurveyResponseUIController } from './controllers/surveyResponseUI.controller';
import { AuthModule } from '../auth/auth.module';
import { WorkspaceModule } from '../workspace/workspace.module';
@Module({
imports: [
@ -31,6 +33,8 @@ import { SurveyResponseUIController } from './controllers/surveyResponseUI.contr
]),
ConfigModule,
MessageModule,
AuthModule,
WorkspaceModule,
],
controllers: [
ClientEncryptController,

View File

@ -31,6 +31,8 @@ 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 { WorkspaceMember } from 'src/models/workspaceMember.entity';
import { Workspace } from 'src/models/workspace.entity';
@ApiTags('workspace')
@ApiBearerAuth()
@ -326,4 +328,42 @@ export class WorkspaceController {
code: 200,
};
}
@Get('/member/list')
@HttpCode(200)
async getWorkspaceAndMember(@Request() req) {
const userId = req.user._id.toString();
// 所在所有空间
const workspaceList = await this.workspaceService.findAllByUserId(userId);
if (!workspaceList.length) {
return {
code: 200,
data: [],
};
}
// 所有空间下的所有成员
const workspaceMemberList =
await this.workspaceMemberService.batchSearchByWorkspace(
workspaceList.map((item) => item._id.toString()),
);
const temp: Record<string, WorkspaceMember[]> = {};
const list = workspaceList.map(
(item: Workspace & { members: WorkspaceMember[] }) => {
temp[item._id.toString()] = item.members = [];
return item;
},
);
workspaceMemberList.forEach((member) => {
temp[member.workspaceId.toString()].push(member);
});
return {
code: 200,
data: list,
};
}
}

View File

@ -104,4 +104,27 @@ export class WorkspaceService {
surveyRes,
};
}
// 用户下的所有空间
async findAllByUserId(userId: string) {
return await this.workspaceRepository.find({
where: {
ownerId: userId,
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},
},
order: {
_id: -1,
},
select: [
'_id',
'curStatus',
'name',
'description',
'ownerId',
'createDate',
],
});
}
}

View File

@ -140,4 +140,20 @@ export class WorkspaceMemberService {
},
});
}
// 根据空间id批量查询成员
async batchSearchByWorkspace(workspaceList: string[]) {
console.log(workspaceList);
return await this.workspaceMemberRepository.find({
where: {
workspaceId: {
$in: workspaceList,
},
},
order: {
_id: -1,
},
select: ['_id', 'userId', 'username', 'role', 'workspaceId'],
});
}
}

1
web/components.d.ts vendored
View File

@ -42,6 +42,7 @@ declare module 'vue' {
ElTag: typeof import('element-plus/es')['ElTag']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
IEpBottom: typeof import('~icons/ep/bottom')['default']
IEpCheck: typeof import('~icons/ep/check')['default']
IEpCirclePlus: typeof import('~icons/ep/circle-plus')['default']

View File

@ -0,0 +1,11 @@
export const regexpMap = {
nd: /^-?(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d+)?$/,
m: /^[1]([3-9])[0-9]{9}$/,
idcard: /^(\d{15}$|^\d{18}$|^\d{17}(\d|X|x))$/,
strictIdcard:
/(^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$)|(^[1-9]\d{5}\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{2}$)/,
n: /^[0-9]+([.]{1}[0-9]+){0,1}$/,
e: /^(\w-*\.*)+@(\w-?)+(\.\w{2,})+$/,
licensePlate:
/^([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[a-zA-Z](([DFAG]((?![IO])[a-zA-Z0-9](?![IO]))[0-9]{4})|([0-9]{5}[DF]))|[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-Z0-9]{4,5}[A-Z0-9挂学警港澳]{1})$/
}

View File

@ -16,6 +16,10 @@ export const getSpaceDetail = (workspaceId: string) => {
return axios.get(`/workspace/${workspaceId}`)
}
export const getMemberList = () => {
return axios.get('/workspace/member/list')
}
export const deleteSpace = (workspaceId: string) => {
return axios.delete(`/workspace/${workspaceId}`)
}

View File

@ -31,6 +31,17 @@ const updateLogicConf = () => {
}
}
const updateWhiteConf = () => {
const baseConf = store.state.edit.schema.baseConf || {};
if (baseConf.passwordSwitch && !baseConf.password) {
return true;
}
if (baseConf.whitelistType!='ALL' && !baseConf.whitelist?.length) {
return true;
}
return false
}
const handlePublish = async () => {
if (isPublishing.value) {
return
@ -46,6 +57,14 @@ const handlePublish = async () => {
return
}
if(updateWhiteConf()){
isPublishing.value = false
ElMessage.error('请检查问卷设置是否有误')
return
}
const saveData = buildData(store.state.edit.schema)
if (!saveData.surveyId) {
isPublishing.value = false

View File

@ -63,6 +63,17 @@ const updateLogicConf = () => {
}
}
const updateWhiteConf = () => {
const baseConf = store.state.edit.schema.baseConf || {};
if (baseConf.passwordSwitch && !baseConf.password) {
return true;
}
if (baseConf.whitelistType!='ALL' && !baseConf.whitelist?.length) {
return true;
}
return false
}
const timerHandle = ref<NodeJS.Timeout | number | null>(null)
const triggerAutoSave = () => {
if (autoSaveStatus.value === 'saving') {
@ -114,6 +125,12 @@ const handleSave = async () => {
return
}
if(updateWhiteConf()){
isSaving.value = false
ElMessage.error('请检查问卷设置是否有误')
return
}
try {
const res: any = await saveData()
if (res.code === 200) {

View File

@ -8,26 +8,12 @@
{{ form.title }}
</span>
</div>
<el-form
class="question-config-form"
label-position="left"
label-width="200px"
@submit.prevent
>
<el-form class="question-config-form" label-position="left" label-width="200px" @submit.prevent>
<template v-for="(item, index) in form.formList">
<FormItem
v-if="item.type && !item.hidden && Boolean(registerTypes[item.type])"
:key="index"
:form-config="item"
:style="item.style"
>
<Component
v-if="Boolean(registerTypes[item.type])"
:is="components[item.type]"
:module-config="form.dataConfig"
:form-config="item"
@form-change="handleFormChange"
/>
<FormItem v-if="item.type && !item.hidden && Boolean(registerTypes[item.type])" :key="index"
:form-config="item" :style="item.style">
<Component v-if="Boolean(registerTypes[item.type])" :is="components[item.type]"
:module-config="form.dataConfig" :form-config="item" @form-change="handleFormChange" />
</FormItem>
</template>
</el-form>
@ -39,7 +25,7 @@
<script setup lang="ts">
import { computed, ref, onMounted, shallowRef } from 'vue'
import { useStore } from 'vuex'
import { cloneDeep as _cloneDeep, isArray as _isArray, get as _get } from 'lodash-es'
import { cloneDeep as _cloneDeep, isArray as _isArray, get as _get,isFunction as _isFunction} from 'lodash-es'
import baseConfig from './config/baseConfig'
import baseFormConfig from './config/baseFormConfig'
@ -49,6 +35,9 @@ import setterLoader from '@/materials/setters/setterLoader'
const formConfigList = ref<Array<any>>([])
const components = shallowRef<any>({})
const registerTypes = ref<any>({})
const store = useStore()
const schemaBaseConf = computed(() => store.state.edit?.schema?.baseConf || {})
const setterList = computed(() => {
const list = _cloneDeep(formConfigList.value)
@ -71,6 +60,14 @@ const setterList = computed(() => {
}
formItem.value = formValue
}
//
form.formList = form.formList.filter((item:any) => {
if (_isFunction(item.relyFunc)) {
return item.relyFunc(schemaBaseConf.value)
}
return true
})
form.dataConfig = dataConfig
@ -78,7 +75,6 @@ const setterList = computed(() => {
})
})
const store = useStore()
const handleFormChange = (data: any) => {
store.dispatch('edit/changeSchema', {
key: data.key,
@ -87,7 +83,7 @@ const handleFormChange = (data: any) => {
}
onMounted(async () => {
formConfigList.value = baseConfig.map((item) => ({
formConfigList.value = baseConfig.map((item) => ({
...item,
formList: item.formList.map((key) => (baseFormConfig as any)[key]).filter((config) => !!config)
}))

View File

@ -8,5 +8,11 @@ export default [
title: '提交限制',
key: 'limitConfig',
formList: ['limit_tLimit']
}, {
title: '作答限制',
key: 'respondConfig',
formList: ['interview_pwd','answer_type','white_placeholder','white_list','team_list']
}
]

View File

@ -21,5 +21,45 @@ export default {
tip: '问卷仅在指定时间段内可填写',
type: 'QuestionTimeHour',
placement: 'top'
},
interview_pwd: {
keys: ['baseConf.passwordSwitch', 'baseConf.password'],
label: '访问密码',
type: 'SwitchInput',
placeholder: '请输入6位字符串类型访问密码 ',
maxLength: 6,
},
answer_type: {
key: 'baseConf.whitelistType',
label: '答题名单',
type: 'AnswerRadio',
},
white_placeholder:{
key: 'baseConf.whitelistTip',
label: '名单登录提示语',
placeholder:'请输入名单提示语',
type: 'InputWordLimit',
maxLength: 40,
relyFunc: (data) => {
return ['CUSTOM','MEMBER'].includes(data.whitelistType)
}
},
white_list:{
keys: ['baseConf.whitelist','baseConf.memberType'],
label: '白名单列表',
type: 'whiteList',
relyFunc: (data) => {
return data.whitelistType == 'CUSTOM'
}
},
team_list:{
key: 'baseConf.whitelist',
label: '团队空间成员选择',
type: 'teamMemberList',
relyFunc: (data) => {
return data.whitelistType == 'MEMBER'
}
}
}

View File

@ -42,7 +42,13 @@ export default {
tLimit: 0,
answerBegTime: '',
answerEndTime: '',
answerLimitTime: 0
answerLimitTime: 0,
passwordSwitch: false,
password:'',
whitelistType: 'ALL',
whitelistTip:'',
whitelist: [],
memberType:'MOBILE',
},
submitConf: {
submitTitle: '',

View File

@ -0,0 +1,41 @@
<template>
<div class="answer-radio-wrap">
<el-radio-group v-model="whitelistType" @change="handleRadioGroupChange">
<el-radio value="ALL">所有人</el-radio>
<el-radio value="MEMBER">空间成员</el-radio>
<el-radio value="CUSTOM">白名单</el-radio>
</el-radio-group>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
const props = defineProps({
formConfig: Object,
})
const emit = defineEmits([FORM_CHANGE_EVENT_KEY])
const whitelistType = ref(props.formConfig?.value || 'ALL')
const handleRadioGroupChange = (value) => {
const key = props.formConfig.key
emit(FORM_CHANGE_EVENT_KEY, { key, value })
emit(FORM_CHANGE_EVENT_KEY, { key:'baseConf.whitelist', value: [] })
emit(FORM_CHANGE_EVENT_KEY, { key: 'baseConf.memberType', value: 'MOBILE' })
if (whitelistType.value == 'ALL') {
emit(FORM_CHANGE_EVENT_KEY, { key:'baseConf.whitelistTip', value:'' })
}
}
</script>
<style lang="scss" scoped>
.switch-input-wrap{
width: 100%;
.mt16{
margin-top: 16px;
}
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<el-input
:maxlength="maxLength"
v-model="modelValue"
:placeholder="placeholder"
show-word-limit
type="text"
@change="handleInputChange"
/>
</template>
<script setup>
import { computed,ref } from 'vue'
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
const props = defineProps({
formConfig: Object,
})
const emit = defineEmits([FORM_CHANGE_EVENT_KEY])
const modelValue = ref(props.formConfig.value || '')
const maxLength = computed(() => props.formConfig.maxLength || 10)
const placeholder = computed(() => props.formConfig.placeholder || '')
const handleInputChange = (value) => {
const key = props.formConfig.key
modelValue.value = value
emit(FORM_CHANGE_EVENT_KEY, { key, value })
}
</script>

View File

@ -0,0 +1,52 @@
<template>
<div class="switch-input-wrap">
<el-switch v-model="passwordSwitch" @change="changeData(props.formConfig.keys[0],passwordSwitch)" />
<InputWordLimit
v-if="passwordSwitch"
class="mt16"
@form-change="handleFormChange"
:formConfig="{
...props.formConfig,
key: props.formConfig.keys[1],
value:props.formConfig?.value[1]
}"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useStore } from 'vuex'
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
import InputWordLimit from './InputWordLimit.vue'
const store = useStore();
const props = defineProps({
formConfig: Object,
})
const emit = defineEmits([FORM_CHANGE_EVENT_KEY])
const passwordSwitch = ref(props.formConfig?.value[0] || false);
const changeData = (key, value) => {
emit(FORM_CHANGE_EVENT_KEY, {
key,
value
})
}
const handleFormChange = (data) => {
store.dispatch('edit/changeSchema', {
key: data.key,
value: data.value
})
}
</script>
<style lang="scss" scoped>
.switch-input-wrap{
width: 100%;
.mt16{
margin-top: 16px;
}
}
</style>

View File

@ -0,0 +1,95 @@
<template>
<div class="team-member-wrap">
<div class="team-tree-wrap">
<el-tree ref="treeRef" :default-expanded-keys="defaultCheckedKeys" :default-checked-keys="defaultCheckedKeys"
:data="treeData" empty-text="暂无数据" @check="handleChange" style="height:201px" highlight-current show-checkbox
node-key="id" :props="defaultProps" />
</div>
<div class="member-count">已选择 <span>{{ selectCount }}</span> </div>
</div>
</template>
<script setup>
import { ref, computed, defineProps, defineEmits, onMounted } from 'vue'
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
import {
getMemberList
} from '@/management/api/space'
import { ElMessage } from 'element-plus'
const props = defineProps({
formConfig: Object,
})
const emit = defineEmits([FORM_CHANGE_EVENT_KEY])
const treeRef = ref(null)
const treeData = ref([])
const defaultCheckedKeys = ref([])
const defaultProps = {
children: 'children',
label: 'label',
}
const handleChange = () => {
const key = props.formConfig.key;
const userKeys = treeRef.value?.getCheckedKeys(true);
if (userKeys.length > 100) {
ElMessage.error('最多添加100个')
return;
}
emit(FORM_CHANGE_EVENT_KEY, { key: key, value: userKeys });
}
const selectCount = computed(() => {
return treeRef.value?.getCheckedKeys(true).length || 0
})
const getSpaceMenus = async () => {
const res = await getMemberList();
if (res.code != 200) {
ElMessage.error('获取空间成员列表失败');
}
const data = res.data;
data.map((v) => {
const members = v.members || [];
treeData.value.push({
id: v.ownerId,
label: v.name,
children: members?.map(v => ({
id: v.userId,
label: v.role,
}))
})
})
defaultCheckedKeys.value = props.formConfig.value;
}
onMounted(() => {
getSpaceMenus();
})
</script>
<style lang="scss" scoped>
.team-member-wrap {
width: 508px;
.team-tree-wrap {
background: #FFFFFF;
border: 1px solid rgba(227, 228, 232, 1);
border-radius: 2px;
min-height: 204px;
max-height: 204px;
overflow-x: auto;
}
.member-count {
text-align: right;
span {
color: $primary-color;
}
}
}
</style>

View File

@ -0,0 +1,145 @@
<template>
<div class="white-list-wrap">
<el-button class="create-btn" type="primary" @click="whiteVisible=true">
添加
</el-button>
<el-button v-if="whitelist.length>0" class="create-btn" color="#4A4C5B" @click="delAllList">
全部删除
</el-button>
<el-table class="table-wrap" empty-text="暂无数据" :data="whitelist" height="240" style="width: 426px">
<el-table-column label="名单" width="350" >
<template #default="scope">
<div>{{ whitelist[scope.$index] }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="74" >
<template #default="scope">
<div @click="delRowItem(scope.$index)" class="flex cursor"><i-ep-delete :size="16" /></div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="whiteVisible" title="添加白名单" width="600" @closed="handleClose">
<div>
<el-form-item label-position="top" label="类型选择" label-width="auto">
<el-radio-group v-model="memberType" >
<el-radio value="MOBILE">手机号</el-radio>
<el-radio value="EMAIL">邮箱</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label-position="top" class="flex-column" label="名单录入" label-width="auto">
<el-input v-model="whiteTextarea" placeholder="多个用逗号(半角)“,”隔开" rows="7" resize="none" type="textarea" />
</el-form-item>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="whiteVisible = false">取消</el-button>
<el-button type="primary" @click="handleChange">
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref,nextTick } from 'vue'
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
import { ElMessage } from 'element-plus'
import { regexpMap } from '@/common/regexpMap.ts'
const props = defineProps({
formConfig: Object,
})
const emit = defineEmits([FORM_CHANGE_EVENT_KEY])
const whitelist = ref(props.formConfig.value[0] || [])
const memberType = ref(props.formConfig.value[1] || 'MOBILE')
const whiteVisible = ref(false)
const whiteTextarea = ref(whitelist.value.join(','))
const regularMap = {
MOBILE:regexpMap.m,
EMAIL:regexpMap.e
}
const checkValRule = (list) => {
let status = false;
if (list.length > 100) {
ElMessage.error('最多添加100个')
return true;
};
const pattern = regularMap[memberType.value];
if(!pattern) return false;
for (let i = 0; i < list.length; i++) {
if (!pattern.test(list[i])) {
status = true;
ElMessage.error('数据格式错误,请检查后重新输入~')
break;
}
}
return status;
}
const handleChange = () => {
const keys = props.formConfig.keys;
const list = whiteTextarea.value ? whiteTextarea.value.split(',') : []
if(checkValRule(list)) return
emit(FORM_CHANGE_EVENT_KEY, { key:keys[0], value: list });
emit(FORM_CHANGE_EVENT_KEY, { key: keys[1], value: memberType.value })
whiteVisible.value = false
}
const handleClose = () => {
nextTick(() => {
whitelist.value = props.formConfig.value[0] || []
whiteTextarea.value = whitelist.value.join(',')
memberType.value = props.formConfig.value[1] || 'MOBILE'
})
}
const delRowItem = (index) => {
whitelist.value.splice(index, 1);
whiteTextarea.value = whitelist.value.join(',')
const keys = props.formConfig.keys;
emit(FORM_CHANGE_EVENT_KEY, { key:keys[0], value: whitelist.value });
}
const delAllList = () => {
whitelist.value = []
whiteTextarea.value = ''
handleChange();
}
</script>
<style lang="scss" scoped>
.white-list-wrap {
.flex-column{
flex-direction: column;
}
:deep(th){
padding:4px 0;
background: #F6F7F9;
}
:deep(td){
padding:6px 0;
}
.table-wrap{
margin-top: 16px;
border: 1px solid #ebeef5;
border-radius: 2px;
overflow-x: hidden;
}
.cursor{
cursor: pointer;
}
.flex{
display: flex;
}
}
</style>

View File

@ -6,18 +6,8 @@ import {
set as _set
} from 'lodash-es'
import { INPUT, RATES, QUESTION_TYPE } from '@/common/typeEnum.ts'
import { regexpMap } from '@/common/regexpMap.ts'
const regexpMap = {
nd: /^-?(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d+)?$/,
m: /^[1]([3-9])[0-9]{9}$/,
idcard: /^(\d{15}$|^\d{18}$|^\d{17}(\d|X|x))$/,
strictIdcard:
/(^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$)|(^[1-9]\d{5}\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{2}$)/,
n: /^[0-9]+([.]{1}[0-9]+){0,1}$/,
e: /^(\w-*\.*)+@(\w-?)+(\.\w{2,})+$/,
licensePlate:
/^([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[a-zA-Z](([DFAG]((?![IO])[a-zA-Z0-9](?![IO]))[0-9]{4})|([0-9]{5}[DF]))|[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-Z0-9]{4,5}[A-Z0-9挂学警港澳]{1})$/
}
const msgMap = {
'*': '必填',

View File

@ -32,3 +32,10 @@ export const queryVote = ({ surveyPath, fieldList }) => {
export const getEncryptInfo = () => {
return axios.get('/clientEncrypt/getEncryptInfo')
}
export const validate = ({ surveyPath,password, whitelist }) => {
return axios.post(`/responseSchema/${surveyPath}/validate`, {
password,
whitelist
})
}

View File

@ -0,0 +1,138 @@
<template>
<el-dialog
v-model="whiteVisible"
title="验证"
:show-close="false"
class="verify-white-wrap"
width="315"
:close-on-press-escape="false"
:close-on-click-modal="false"
align-center
>
<template #header>
<div class="verify-white-head">
<div class="verify-white-title">验证</div>
<div v-if="whitelistTip" class="verify-white-tips">{{ whitelistTip }}</div>
</div>
</template>
<div class="verify-white-body">
<el-input v-if="isPwd" v-model="state.password" class="wd255 mb16" placeholder="请输入6位字符串类型访问密码" />
<el-input v-if="isValue" v-model="state.value" class="wd255 mb16" :placeholder="placeholder" />
<div class="submit-btn" @click="handleSubmit">验证并开始答题</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref,reactive,computed,watch} from 'vue'
import { validate } from '../api/survey'
import { useStore } from 'vuex'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
const whiteVisible = ref(false)
const store = useStore()
const state = reactive({
password: '',
value: '',
is_submit:false
})
const baseConf = computed(() => store.state.baseConf || {})
const isPwd = computed(() => baseConf.value.passwordSwitch)
const whitelistType = computed(() => baseConf.value.whitelistType)
const memberType = computed(() => baseConf.value.memberType)
const whitelistTip = computed(() => baseConf.value.whitelistTip)
const surveyPath = computed(() => store.state?.surveyPath || '')
const isValue = computed(() => {
if(!whitelistType.value) return false
return whitelistType.value!='ALL'
})
const placeholder = computed(() => {
if (whitelistType.value == 'MEMBER') {
return '请输入用户名'
}
if(memberType.value == 'MOBILE'){
return '请输入手机号'
}
if(memberType.value == 'EMAIL'){
return '请输入邮箱'
}
return ''
})
const handleSubmit = async() => {
if (state.is_submit) return;
const params = {
surveyPath:surveyPath.value
}
if (isValue.value) {
params.whitelist = state.value
}
if(isPwd.value){
params.password = state.password
}
const res = await validate(params)
if (res.code != 200) {
ElMessage.error(res.errmsg || '验证失败')
return
}
whiteVisible.value = false
store.commit('setWhiteData',params)
}
watch(()=>baseConf.value, () => {
if (whiteVisible.value) return
if(isValue.value || isPwd.value){
whiteVisible.value = true;
}
})
</script>
<style lang="scss" scoped>
.verify-white-wrap{
.verify-white-body{
padding:0 14px
}
.verify-white-head{
padding:0 14px;
margin-bottom: 8px;
margin-top:2px;
}
.mb16{
margin-bottom:16px;
}
.verify-white-tips{
text-align: center;
margin-top:8px;
font-size: 14px;
color: #92949D;
}
.verify-white-title{
font-size: 16px;
color: #292A36;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
}
.submit-btn{
background: #FAA600;
border-radius: 2px;
width:255px;
height:32px;
color:#fff;
display: flex;
align-items: center;
justify-content: center;
margin-top:4px;
margin-bottom:14px;
}
}
</style>

View File

@ -14,6 +14,7 @@
@submit="handleSubmit"
></SubmitButton>
<LogoIcon :logo-conf="logoConf" :readonly="true" />
<VerifyWhiteDialog />
</div>
</div>
</div>
@ -26,6 +27,7 @@ import { useRouter } from 'vue-router'
import communalLoader from '@materials/communals/communalLoader.js'
import MainRenderer from '../components/MainRenderer.vue'
import AlertDialog from '../components/AlertDialog.vue'
import VerifyWhiteDialog from '../components/VerifyWhiteDialog.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import ProgressBar from '../components/ProgressBar.vue'
@ -63,6 +65,7 @@ const renderData = computed(() => store.getters.renderData)
const submitConf = computed(() => store.state?.submitConf || {})
const logoConf = computed(() => store.state?.bottomConf || {})
const surveyPath = computed(() => store.state?.surveyPath || '')
const whiteData = computed(() => store.state?.whiteData || {})
const validate = (cbk: (v: boolean) => void) => {
const index = 0
@ -78,7 +81,8 @@ const normalizationRequestBody = () => {
surveyPath: surveyPath.value,
data: JSON.stringify(formValues),
difTime: Date.now() - enterTime,
clientTime: Date.now()
clientTime: Date.now(),
...whiteData.value
}
if (encryptInfo?.encryptType) {

View File

@ -49,5 +49,8 @@ export default {
},
setRuleEgine(state, ruleEngine) {
state.ruleEngine = ruleEngine
},
setWhiteData(state, data) {
state.whiteData = data
}
}

View File

@ -12,5 +12,8 @@ export default {
questionSeq: [], // 题目的顺序,因为可能会有分页的情况,所以是一个二维数组[[qid1, qid2], [qid3,qid4]]
voteMap: {},
encryptInfo: null,
ruleEngine: null
ruleEngine: null,
whiteData: {
}
}