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

This commit is contained in:
dayou 2024-07-09 11:33:09 +08:00
parent 3003c2cbfa
commit 212a3329ad
70 changed files with 3365 additions and 608 deletions

View File

@ -53,11 +53,11 @@ _**(个人和企业用户均可快速构建特定领域的调研类解决方案
# 技术 # 技术
Web 端Vue3 + ElementPlusC 端多端渲染(在建,[申请加入共建](https://xiaojusurvey.didi.cn/docs/next/share/%E5%A6%82%E4%BD%95%E5%8F%82%E4%B8%8E%E8%B4%A1%E7%8C%AE#%E6%88%90%E4%B8%BA%E5%AE%98%E6%96%B9%E4%B8%93%E9%A1%B9%E5%BB%BA%E8%AE%BE%E8%80%85) Web 端Vue3 + ElementPlusC 端多端渲染(规划中
Server 端Nestjs + MongoDBJava在建[申请加入共建](https://xiaojusurvey.didi.cn/docs/next/share/%E5%A6%82%E4%BD%95%E5%8F%82%E4%B8%8E%E8%B4%A1%E7%8C%AE#%E6%88%90%E4%B8%BA%E5%AE%98%E6%96%B9%E4%B8%93%E9%A1%B9%E5%BB%BA%E8%AE%BE%E8%80%85) Server 端Nestjs + MongoDBJava在建[欢迎加入共建](https://github.com/didi/xiaoju-survey/issues/306)
智能化基座:(在建 智能化基座:(规划中
# 项目优势 # 项目优势
@ -194,18 +194,18 @@ npm run serve
<br /><br /> <br /><br />
## 微信交流群(推荐)
官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入。任何问题和合作可以联系小助手:
<img src="https://img-hxy021.didistatic.com/static/starimg/img/KXKvc7sjHz1700061188156.png" width="200" />
## QQ 交流群 ## QQ 交流群
官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入: 官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入:
[<img src="https://img-hxy021.didistatic.com/static/starimg/img/iJUmLIHKV21700192846057.png" width="210" />](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=P61UJI_q8AzizyBLGOm-bUvzNrUnSQq-&authKey=yZFtL9biGB5yiIME3%2Bi%2Bf6XMOdTNiuf0pCIaviEEAIryySNzVy6LJ4xl7uHdEcrM&noverify=0&group_code=920623419) [<img src="https://img-hxy021.didistatic.com/static/starimg/img/iJUmLIHKV21700192846057.png" width="210" />](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=P61UJI_q8AzizyBLGOm-bUvzNrUnSQq-&authKey=yZFtL9biGB5yiIME3%2Bi%2Bf6XMOdTNiuf0pCIaviEEAIryySNzVy6LJ4xl7uHdEcrM&noverify=0&group_code=920623419)
## 微信交流群
官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入。任何问题和合作可以联系小助手:
<img src="https://img-hxy021.didistatic.com/static/starimg/img/KXKvc7sjHz1700061188156.png" width="200" />
## Star ## Star
开源不易,如果该项目对你有帮助,请 star 一下 ❤️❤️❤️,你的支持是我们最大的动力。 开源不易,如果该项目对你有帮助,请 star 一下 ❤️❤️❤️,你的支持是我们最大的动力。

View File

@ -47,17 +47,17 @@
> For more comprehensive features, please refer to the official Feature documentation. > For more comprehensive features, please refer to the official Feature documentation.
<img src="https://github.com/didi/xiaoju-survey/assets/16012672/dd427471-368d-49d9-bc44-13c34d84e3be" width="700" /> <img src="https://github.com/didi/xiaoju-survey/assets/16012672/508ce30f-0ae8-4f5f-84a7-e96de8238a7f" width="700" />
_**(Both individual and enterprise users can quickly build survey solutions specific to their fields.)**_ _**(Both individual and enterprise users can quickly build survey solutions specific to their fields.)**_
# Technology # Technology
Web: Vue3 + ElementPlus; Multi-end rendering for C-end (under construction, application for co-construction welcome). Web: Vue3 + ElementPlus; Multi-end rendering for C-end (planning).
Server: Nestjs + MongoDB; Java (under construction). Server: Nestjs + MongoDB; Java ([under construction](https://github.com/didi/xiaoju-survey/issues/306)).
Intelligent Foundation: (under construction). Intelligent Foundation: (planning).
# Project Advantages # Project Advantages
@ -194,18 +194,18 @@ Create and publish a questionnaire.
<br /><br /> <br /><br />
## QQ Group
The official group will release the latest project news, construction plans, and community activities. Welcome to join:
[<img src="https://img-hxy021.didistatic.com/static/starimg/img/iJUmLIHKV21700192846057.png" width="210" />](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=P61UJI_q8AzizyBLGOm-bUvzNrUnSQq-&authKey=yZFtL9biGB5yiIME3%2Bi%2Bf6XMOdTNiuf0pCIaviEEAIryySNzVy6LJ4xl7uHdEcrM&noverify=0&group_code=920623419)
## WeChat Group ## WeChat Group
The official group will release the latest project news, construction plans, and community activities. Any questions and cooperation can contact the assistant: The official group will release the latest project news, construction plans, and community activities. Any questions and cooperation can contact the assistant:
<img src="https://img-hxy021.didistatic.com/static/starimg/img/KXKvc7sjHz1700061188156.png" width="200" /> <img src="https://img-hxy021.didistatic.com/static/starimg/img/KXKvc7sjHz1700061188156.png" width="200" />
## QQ Group
The official group will release the latest project news, construction plans, and community activities. Welcome to join:
[<img src="https://img-hxy021.didistatic.com/static/starimg/img/iJUmLIHKV21700192846057.png" width="210" />](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=P61UJI_q8AzizyBLGOm-bUvzNrUnSQq-&authKey=yZFtL9biGB5yiIME3%2Bi%2Bf6XMOdTNiuf0pCIaviEEAIryySNzVy6LJ4xl7uHdEcrM&noverify=0&group_code=920623419)
## Star ## Star
Open source is not easy. If this project helps you, please star it ❤️❤️❤️. Your support is our greatest motivation. Open source is not easy. If this project helps you, please star it ❤️❤️❤️. Your support is our greatest motivation.

View File

@ -11,6 +11,7 @@ import { NoPermissionException } from 'src/exceptions/noPermissionException';
import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { SurveyMeta } from 'src/models/surveyMeta.entity';
import { WorkspaceMember } from 'src/models/workspaceMember.entity'; import { WorkspaceMember } from 'src/models/workspaceMember.entity';
import { Collaborator } from 'src/models/collaborator.entity'; import { Collaborator } from 'src/models/collaborator.entity';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
describe('SurveyGuard', () => { describe('SurveyGuard', () => {
let guard: SurveyGuard; let guard: SurveyGuard;
@ -81,7 +82,19 @@ describe('SurveyGuard', () => {
); );
}); });
it('should allow access if user is the owner of the survey', async () => { it('should allow access if user is the owner of the survey by ownerId', async () => {
const context = createMockExecutionContext();
const surveyMeta = { ownerId: 'testUserId', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId');
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should allow access if user is the owner of the survey by username', async () => {
const context = createMockExecutionContext(); const context = createMockExecutionContext();
const surveyMeta = { owner: 'testUser', workspaceId: null }; const surveyMeta = { owner: 'testUser', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId'); jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId');
@ -108,7 +121,35 @@ describe('SurveyGuard', () => {
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should throw NoPermissionException if user has no permissions', async () => { it('should throw NoPermissionException if user is not a workspace member', async () => {
const context = createMockExecutionContext();
const surveyMeta = { owner: 'anotherUser', workspaceId: 'workspaceId' };
jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId');
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
jest.spyOn(workspaceMemberService, 'findOne').mockResolvedValue(null);
await expect(guard.canActivate(context)).rejects.toThrow(
NoPermissionException,
);
});
it('should throw NoPermissionException if no permissions are provided', async () => {
const context = createMockExecutionContext();
const surveyMeta = { owner: 'anotherUser', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId');
jest.spyOn(reflector, 'get').mockReturnValueOnce(null);
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
await expect(guard.canActivate(context)).rejects.toThrow(
NoPermissionException,
);
});
it('should throw NoPermissionException if user has no matching permissions', async () => {
const context = createMockExecutionContext(); const context = createMockExecutionContext();
const surveyMeta = { owner: 'anotherUser', workspaceId: null }; const surveyMeta = { owner: 'anotherUser', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId'); jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId');
@ -125,6 +166,24 @@ describe('SurveyGuard', () => {
); );
}); });
it('should allow access if user has the required permissions', async () => {
const context = createMockExecutionContext();
const surveyMeta = { owner: 'anotherUser', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId');
jest
.spyOn(reflector, 'get')
.mockReturnValueOnce([SURVEY_PERMISSION.SURVEY_CONF_MANAGE]);
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
jest.spyOn(collaboratorService, 'getCollaborator').mockResolvedValue({
permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE],
} as Collaborator);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
function createMockExecutionContext(): ExecutionContext { function createMockExecutionContext(): ExecutionContext {
return { return {
switchToHttp: jest.fn().mockReturnValue({ switchToHttp: jest.fn().mockReturnValue({

View File

@ -6,6 +6,7 @@ import { User } from 'src/models/user.entity';
import { HttpException } from 'src/exceptions/httpException'; import { HttpException } from 'src/exceptions/httpException';
import { hash256 } from 'src/utils/hash256'; import { hash256 } from 'src/utils/hash256';
import { RECORD_STATUS } from 'src/enums'; import { RECORD_STATUS } from 'src/enums';
import { ObjectId } from 'mongodb';
describe('UserService', () => { describe('UserService', () => {
let service: UserService; let service: UserService;
@ -21,6 +22,7 @@ describe('UserService', () => {
create: jest.fn(), create: jest.fn(),
save: jest.fn(), save: jest.fn(),
findOne: jest.fn(), findOne: jest.fn(),
find: jest.fn(),
}, },
}, },
], ],
@ -32,6 +34,10 @@ describe('UserService', () => {
); );
}); });
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should create a user', async () => { it('should create a user', async () => {
const userInfo = { const userInfo = {
username: 'testUser', username: 'testUser',
@ -102,7 +108,7 @@ describe('UserService', () => {
expect(user).toEqual({ ...userInfo, password: hashedPassword }); expect(user).toEqual({ ...userInfo, password: hashedPassword });
}); });
it('should return undefined when user is not found by credentials', async () => { it('should return null when user is not found by credentials', async () => {
const userInfo = { const userInfo = {
username: 'nonExistingUser', username: 'nonExistingUser',
password: 'nonExistingPassword', password: 'nonExistingPassword',
@ -129,7 +135,8 @@ describe('UserService', () => {
const userInfo = { const userInfo = {
username: username, username: username,
password: 'existingPassword', password: 'existingPassword',
} as User; curStatus: { status: 'ACTIVE' },
} as unknown as User;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo); jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo);
@ -137,10 +144,129 @@ describe('UserService', () => {
expect(userRepository.findOne).toHaveBeenCalledWith({ expect(userRepository.findOne).toHaveBeenCalledWith({
where: { where: {
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
username: username, username: username,
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
}, },
}); });
expect(user).toEqual(userInfo); expect(user).toEqual(userInfo);
}); });
it('should return null when user is not found by username', async () => {
const username = 'nonExistingUser';
const findOneSpy = jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(null);
const user = await service.getUserByUsername(username);
expect(findOneSpy).toHaveBeenCalledWith({
where: {
username: username,
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
});
expect(user).toBe(null);
});
it('should return a user by id', async () => {
const id = '60c72b2f9b1e8a5f4b123456';
const userInfo = {
_id: new ObjectId(id),
username: 'testUser',
curStatus: { status: 'ACTIVE' },
} as unknown as User;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo);
const user = await service.getUserById(id);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: {
_id: new ObjectId(id),
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
});
expect(user).toEqual(userInfo);
});
it('should return null when user is not found by id', async () => {
const id = '60c72b2f9b1e8a5f4b123456';
const findOneSpy = jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(null);
const user = await service.getUserById(id);
expect(findOneSpy).toHaveBeenCalledWith({
where: {
_id: new ObjectId(id),
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
});
expect(user).toBe(null);
});
it('should return a list of users by username', async () => {
const username = 'test';
const userList = [
{ _id: new ObjectId(), username: 'testUser1', createDate: new Date() },
{ _id: new ObjectId(), username: 'testUser2', createDate: new Date() },
];
jest
.spyOn(userRepository, 'find')
.mockResolvedValue(userList as unknown as User[]);
const result = await service.getUserListByUsername({
username,
skip: 0,
take: 10,
});
expect(userRepository.find).toHaveBeenCalledWith({
where: {
username: new RegExp(username),
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
skip: 0,
take: 10,
select: ['_id', 'username', 'createDate'],
});
expect(result).toEqual(userList);
});
it('should return a list of users by ids', async () => {
const idList = ['60c72b2f9b1e8a5f4b123456', '60c72b2f9b1e8a5f4b123457'];
const userList = [
{
_id: new ObjectId(idList[0]),
username: 'testUser1',
createDate: new Date(),
},
{
_id: new ObjectId(idList[1]),
username: 'testUser2',
createDate: new Date(),
},
];
jest
.spyOn(userRepository, 'find')
.mockResolvedValue(userList as unknown as User[]);
const result = await service.getUserListByIds({ idList });
expect(userRepository.find).toHaveBeenCalledWith({
where: {
_id: {
$in: idList.map((id) => new ObjectId(id)),
},
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
select: ['_id', 'username', 'createDate'],
});
expect(result).toEqual(userList);
});
}); });

View File

@ -10,7 +10,13 @@ import { UserService } from 'src/modules/auth/services/user.service';
import { ObjectId } from 'mongodb'; import { ObjectId } from 'mongodb';
import { SurveyMetaService } from '../services/surveyMeta.service'; import { SurveyMetaService } from '../services/surveyMeta.service';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import {
SURVEY_PERMISSION,
SURVEY_PERMISSION_DESCRIPTION,
} from 'src/enums/surveyPermission';
import { BatchSaveCollaboratorDto } from '../dto/batchSaveCollaborator.dto';
import { User } from 'src/models/user.entity';
import { SurveyMeta } from 'src/models/surveyMeta.entity';
jest.mock('src/guards/authentication.guard'); jest.mock('src/guards/authentication.guard');
jest.mock('src/guards/survey.guard'); jest.mock('src/guards/survey.guard');
@ -21,6 +27,8 @@ describe('CollaboratorController', () => {
let collaboratorService: CollaboratorService; let collaboratorService: CollaboratorService;
let logger: Logger; let logger: Logger;
let userService: UserService; let userService: UserService;
let surveyMetaService: SurveyMetaService;
let workspaceMemberServie: WorkspaceMemberService;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@ -34,12 +42,18 @@ describe('CollaboratorController', () => {
changeUserPermission: jest.fn(), changeUserPermission: jest.fn(),
deleteCollaborator: jest.fn(), deleteCollaborator: jest.fn(),
getCollaborator: jest.fn(), getCollaborator: jest.fn(),
batchDeleteBySurveyId: jest.fn(),
batchCreate: jest.fn(),
batchDelete: jest.fn(),
updateById: jest.fn(),
batchSaveCollaborator: jest.fn(),
}, },
}, },
{ {
provide: Logger, provide: Logger,
useValue: { useValue: {
error: jest.fn(), error: jest.fn(),
info: jest.fn(),
}, },
}, },
{ {
@ -72,6 +86,10 @@ describe('CollaboratorController', () => {
collaboratorService = module.get<CollaboratorService>(CollaboratorService); collaboratorService = module.get<CollaboratorService>(CollaboratorService);
logger = module.get<Logger>(Logger); logger = module.get<Logger>(Logger);
userService = module.get<UserService>(UserService); userService = module.get<UserService>(UserService);
surveyMetaService = module.get<SurveyMetaService>(SurveyMetaService);
workspaceMemberServie = module.get<WorkspaceMemberService>(
WorkspaceMemberService,
);
}); });
it('should be defined', () => { it('should be defined', () => {
@ -115,6 +133,59 @@ describe('CollaboratorController', () => {
HttpException, HttpException,
); );
}); });
it('should throw an exception if user does not exist', async () => {
const reqBody: CreateCollaboratorDto = {
surveyId: 'surveyId',
userId: new ObjectId().toString(),
permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE],
};
const req = {
user: { _id: 'userId' },
surveyMeta: { ownerId: new ObjectId().toString() },
};
jest.spyOn(userService, 'getUserById').mockResolvedValue(null);
await expect(controller.addCollaborator(reqBody, req)).rejects.toThrow(
HttpException,
);
});
it('should throw an exception if user is the survey owner', async () => {
const userId = new ObjectId().toString();
const reqBody: CreateCollaboratorDto = {
surveyId: 'surveyId',
userId: userId,
permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE],
};
const req = { user: { _id: 'userId' }, surveyMeta: { ownerId: userId } };
await expect(controller.addCollaborator(reqBody, req)).rejects.toThrow(
HttpException,
);
});
it('should throw an exception if user is already a collaborator', async () => {
const userId = new ObjectId().toString();
const reqBody: CreateCollaboratorDto = {
surveyId: 'surveyId',
userId: userId,
permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE],
};
const req = {
user: { _id: 'userId' },
surveyMeta: { ownerId: new ObjectId().toString() },
};
jest
.spyOn(collaboratorService, 'getCollaborator')
.mockResolvedValue({} as unknown as Collaborator);
await expect(controller.addCollaborator(reqBody, req)).rejects.toThrow(
HttpException,
);
});
}); });
describe('getSurveyCollaboratorList', () => { describe('getSurveyCollaboratorList', () => {
@ -217,4 +288,229 @@ describe('CollaboratorController', () => {
expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledTimes(1);
}); });
}); });
// 新增的测试方法
describe('getPermissionList', () => {
it('should return the permission list', async () => {
const result = Object.values(SURVEY_PERMISSION_DESCRIPTION);
const response = await controller.getPermissionList();
expect(response).toEqual({
code: 200,
data: result,
});
});
});
describe('batchSaveCollaborator', () => {
it('should batch save collaborators successfully', async () => {
const userId0 = new ObjectId().toString();
const userId1 = new ObjectId().toString();
const existsCollaboratorId = new ObjectId().toString();
const surveyId = new ObjectId().toString();
const reqBody: BatchSaveCollaboratorDto = {
surveyId: surveyId,
collaborators: [
{
userId: userId0,
permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE],
},
{
_id: existsCollaboratorId,
userId: userId1,
permissions: [SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE],
},
],
};
const req = {
user: { _id: 'requestUserId' },
surveyMeta: { ownerId: 'ownerId' },
};
const userList = [
{ _id: new ObjectId(userId0) },
{ _id: new ObjectId(userId1) },
];
jest
.spyOn(userService, 'getUserListByIds')
.mockResolvedValue(userList as unknown as User[]);
jest
.spyOn(collaboratorService, 'batchDelete')
.mockResolvedValue({ deletedCount: 1, acknowledged: true });
jest
.spyOn(collaboratorService, 'batchCreate')
.mockResolvedValue([{}] as any);
jest.spyOn(collaboratorService, 'updateById').mockResolvedValue({});
const response = await controller.batchSaveCollaborator(reqBody, req);
expect(response).toEqual({
code: 200,
});
expect(userService.getUserListByIds).toHaveBeenCalled();
expect(collaboratorService.batchDelete).toHaveBeenCalled();
expect(collaboratorService.batchCreate).toHaveBeenCalled();
expect(collaboratorService.updateById).toHaveBeenCalled();
});
it('should throw an exception if validation fails', async () => {
const reqBody: BatchSaveCollaboratorDto = {
surveyId: '',
collaborators: [
{
userId: '',
permissions: [SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE],
},
],
};
const req = { user: { _id: 'userId' } };
await expect(
controller.batchSaveCollaborator(reqBody, req),
).rejects.toThrow(HttpException);
expect(logger.error).toHaveBeenCalledTimes(1);
});
});
describe('getUserSurveyPermissions', () => {
it('should return owner permissions if user is the owner', async () => {
const req = {
user: { _id: new ObjectId(), username: 'owner' },
};
const query = { surveyId: 'surveyId' };
const surveyMeta = {
ownerId: req.user._id.toString(),
owner: req.user.username,
workspaceId: 'workspaceId',
};
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
const response = await controller.getUserSurveyPermissions(req, query);
expect(response).toEqual({
code: 200,
data: {
isOwner: true,
permissions: [
SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE,
SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE,
SURVEY_PERMISSION.SURVEY_CONF_MANAGE,
],
},
});
});
it('should return default permissions if user is a workspace member', async () => {
const req = {
user: { _id: new ObjectId(), username: 'user' },
};
const query = { surveyId: 'surveyId' };
const surveyMeta = {
ownerId: 'ownerId',
owner: 'owner',
workspaceId: 'workspaceId',
};
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
jest.spyOn(workspaceMemberServie, 'findOne').mockResolvedValue({} as any);
const response = await controller.getUserSurveyPermissions(req, query);
expect(response).toEqual({
code: 200,
data: {
isOwner: false,
permissions: [
SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE,
SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE,
SURVEY_PERMISSION.SURVEY_CONF_MANAGE,
],
},
});
});
it('should return collaborator permissions if user is a collaborator', async () => {
const req = {
user: { _id: new ObjectId(), username: 'user' },
};
const query = { surveyId: 'surveyId' };
const surveyMeta = {
ownerId: 'ownerId',
owner: 'owner',
workspaceId: 'workspaceId',
};
const collaborator = {
permissions: ['read', 'write'],
};
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
jest.spyOn(workspaceMemberServie, 'findOne').mockResolvedValue(null);
jest
.spyOn(collaboratorService, 'getCollaborator')
.mockResolvedValue(collaborator as Collaborator);
const response = await controller.getUserSurveyPermissions(req, query);
expect(response).toEqual({
code: 200,
data: {
isOwner: false,
permissions: collaborator.permissions,
},
});
});
it('should return empty permissions if user has no permissions', async () => {
const req = {
user: { _id: new ObjectId(), username: 'user' },
};
const query = { surveyId: 'surveyId' };
const surveyMeta = {
ownerId: 'ownerId',
owner: 'owner',
workspaceId: 'workspaceId',
};
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
jest.spyOn(workspaceMemberServie, 'findOne').mockResolvedValue(null);
jest
.spyOn(collaboratorService, 'getCollaborator')
.mockResolvedValue(null);
const response = await controller.getUserSurveyPermissions(req, query);
expect(response).toEqual({
code: 200,
data: {
isOwner: false,
permissions: [],
},
});
});
it('should throw an exception if survey does not exist', async () => {
const req = { user: { _id: 'userId' } };
const query = { surveyId: 'nonexistentSurveyId' };
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValueOnce(null);
await expect(
controller.getUserSurveyPermissions(req, query),
).rejects.toThrow(HttpException);
expect(logger.error).toHaveBeenCalledTimes(1);
});
});
}); });

View File

@ -14,6 +14,7 @@ import { Logger } from 'src/logger';
import { UserService } from 'src/modules/auth/services/user.service'; import { UserService } from 'src/modules/auth/services/user.service';
import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin'; import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin';
import { AuthService } from 'src/modules/auth/services/auth.service'; import { AuthService } from 'src/modules/auth/services/auth.service';
import { HttpException } from 'src/exceptions/httpException';
jest.mock('../services/dataStatistic.service'); jest.mock('../services/dataStatistic.service');
jest.mock('../services/surveyMeta.service'); jest.mock('../services/surveyMeta.service');
@ -21,11 +22,13 @@ jest.mock('../../surveyResponse/services/responseScheme.service');
jest.mock('src/guards/authentication.guard'); jest.mock('src/guards/authentication.guard');
jest.mock('src/guards/survey.guard'); jest.mock('src/guards/survey.guard');
jest.mock('src/guards/workspace.guard');
describe('DataStatisticController', () => { describe('DataStatisticController', () => {
let controller: DataStatisticController; let controller: DataStatisticController;
let dataStatisticService: DataStatisticService; let dataStatisticService: DataStatisticService;
let responseSchemaService: ResponseSchemaService;
let pluginManager: XiaojuSurveyPluginManager;
let logger: Logger;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@ -64,9 +67,14 @@ describe('DataStatisticController', () => {
controller = module.get<DataStatisticController>(DataStatisticController); controller = module.get<DataStatisticController>(DataStatisticController);
dataStatisticService = dataStatisticService =
module.get<DataStatisticService>(DataStatisticService); module.get<DataStatisticService>(DataStatisticService);
const pluginManager = module.get<XiaojuSurveyPluginManager>( responseSchemaService = module.get<ResponseSchemaService>(
ResponseSchemaService,
);
pluginManager = module.get<XiaojuSurveyPluginManager>(
XiaojuSurveyPluginManager, XiaojuSurveyPluginManager,
); );
logger = module.get<Logger>(Logger);
pluginManager.registerPlugin( pluginManager.registerPlugin(
new ResponseSecurityPlugin('dataAesEncryptSecretKey'), new ResponseSecurityPlugin('dataAesEncryptSecretKey'),
); );
@ -82,6 +90,9 @@ describe('DataStatisticController', () => {
const mockRequest = { const mockRequest = {
query: { query: {
surveyId, surveyId,
isDesensitive: false,
page: 1,
pageSize: 10,
}, },
user: { user: {
username: 'testUser', username: 'testUser',
@ -105,13 +116,13 @@ describe('DataStatisticController', () => {
}; };
jest jest
.spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId') .spyOn(responseSchemaService, 'getResponseSchemaByPageId')
.mockResolvedValueOnce({} as any); .mockResolvedValueOnce({} as any);
jest jest
.spyOn(dataStatisticService, 'getDataTable') .spyOn(dataStatisticService, 'getDataTable')
.mockResolvedValueOnce(mockDataTable); .mockResolvedValueOnce(mockDataTable);
const result = await controller.data(mockRequest.query, {}); const result = await controller.data(mockRequest.query, mockRequest);
expect(result).toEqual({ expect(result).toEqual({
code: 200, code: 200,
@ -125,6 +136,8 @@ describe('DataStatisticController', () => {
query: { query: {
surveyId, surveyId,
isDesensitive: true, isDesensitive: true,
page: 1,
pageSize: 10,
}, },
user: { user: {
username: 'testUser', username: 'testUser',
@ -146,19 +159,499 @@ describe('DataStatisticController', () => {
{ difTime: '0.5', createDate: '2024-02-11', data123: '13800000000' }, { difTime: '0.5', createDate: '2024-02-11', data123: '13800000000' },
], ],
}; };
jest jest
.spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId') .spyOn(responseSchemaService, 'getResponseSchemaByPageId')
.mockResolvedValueOnce({} as any); .mockResolvedValueOnce({} as any);
jest jest
.spyOn(dataStatisticService, 'getDataTable') .spyOn(dataStatisticService, 'getDataTable')
.mockResolvedValueOnce(mockDataTable); .mockResolvedValueOnce(mockDataTable);
const result = await controller.data(mockRequest.query, {}); const result = await controller.data(mockRequest.query, mockRequest);
expect(result).toEqual({ expect(result).toEqual({
code: 200, code: 200,
data: mockDataTable, data: mockDataTable,
}); });
}); });
it('should throw an exception if validation fails', async () => {
const mockRequest = {
query: {
surveyId: '',
},
user: {
username: 'testUser',
},
};
await expect(
controller.data(mockRequest.query, mockRequest),
).rejects.toThrow(HttpException);
expect(logger.error).toHaveBeenCalledTimes(1);
});
});
describe('aggregationStatis', () => {
it('should return aggregation statistics', async () => {
const mockRequest = {
query: {
surveyId: new ObjectId().toString(),
},
};
const mockResponseSchema = {
_id: new ObjectId('6659c3283b1cb279bc2e2b0c'),
curStatus: {
status: 'published',
date: 1717159136024,
},
statusList: [
{
status: 'published',
date: 1717158851823,
},
],
createDate: 1717158851823,
updateDate: 1717159136025,
title: '问卷调研',
surveyPath: 'ZdGNzTTR',
code: {
bannerConf: {
titleConfig: {
mainTitle:
'<h3 style="text-align: center">欢迎填写问卷</h3><p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style="color: rgb(204, 0, 0)">期待您的参与!</span></p>',
subTitle: '',
applyTitle: '',
},
bannerConfig: {
bgImage: '/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp',
bgImageAllowJump: false,
bgImageJumpLink: '',
videoLink: '',
postImg: '',
},
},
baseConf: {
begTime: '2024-05-31 20:31:36',
endTime: '2034-05-31 20:31:36',
language: 'chinese',
showVoteProcess: 'allow',
tLimit: 0,
answerBegTime: '00:00:00',
answerEndTime: '23:59:59',
answerLimitTime: 0,
},
bottomConf: {
logoImage: '/imgs/Logo.webp',
logoImageWidth: '60%',
},
skinConf: {
backgroundConf: {
color: '#fff',
},
themeConf: {
color: '#ffa600',
},
contentConf: {
opacity: 100,
},
skinColor: '#4a4c5b',
inputBgColor: '#ffffff',
},
submitConf: {
submitTitle: '提交',
msgContent: {
msg_200: '提交成功',
msg_9001: '您来晚了,感谢支持问卷~',
msg_9002: '请勿多次提交!',
msg_9003: '您来晚了,已经满额!',
msg_9004: '提交失败!',
},
confirmAgain: {
is_again: true,
again_text: '确认要提交吗?',
},
link: '',
},
logicConf: {
showLogicConf: [],
},
dataConf: {
dataList: [
{
isRequired: true,
showIndex: true,
showType: true,
showSpliter: true,
type: 'radio',
placeholderDesc: '',
field: 'data515',
title: '标题2',
placeholder: '',
randomSort: false,
checked: false,
minNum: '',
maxNum: '',
options: [
{
text: '选项1',
imageUrl: '',
others: false,
mustOthers: false,
othersKey: '',
placeholderDesc: '',
hash: '115019',
},
{
text: '选项2',
imageUrl: '',
others: false,
mustOthers: false,
othersKey: '',
placeholderDesc: '',
hash: '115020',
},
],
importKey: 'single',
importData: '',
cOption: '',
cOptions: [],
star: 5,
exclude: false,
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
{
field: 'data893',
showIndex: true,
showType: true,
showSpliter: true,
type: 'checkbox',
placeholderDesc: '',
sLimit: 0,
mhLimit: 0,
title: '标题2',
placeholder: '',
valid: '',
isRequired: true,
randomSort: false,
showLeftNum: true,
innerRandom: false,
checked: false,
selectType: 'radio',
sortWay: 'v',
noNps: '',
minNum: '',
maxNum: '',
starStyle: 'star',
starMin: 1,
starMax: 5,
min: 0,
max: 10,
minMsg: '极不满意',
maxMsg: '十分满意',
rangeConfig: {},
options: [
{
text: '选项1',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '466671',
},
{
text: '选项2',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '095415',
},
],
star: 5,
optionOrigin: '',
originType: 'selected',
matrixOptionsRely: '',
numberRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '1000',
value: 1000,
},
},
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
{
field: 'data820',
showIndex: true,
showType: true,
showSpliter: true,
type: 'radio-nps',
placeholderDesc: '',
sLimit: 0,
mhLimit: 0,
title: '标题3',
placeholder: '',
valid: '',
isRequired: true,
randomSort: false,
showLeftNum: true,
innerRandom: false,
checked: false,
selectType: 'radio',
sortWay: 'v',
noNps: '',
minNum: '',
maxNum: '',
starStyle: 'star',
starMin: 1,
starMax: 5,
min: 0,
max: 10,
minMsg: '极不满意',
maxMsg: '十分满意',
rangeConfig: {},
options: [
{
text: '选项1',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '268884',
},
{
text: '选项2',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '371166',
},
],
star: 5,
optionOrigin: '',
originType: 'selected',
matrixOptionsRely: '',
numberRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '1000',
value: 1000,
},
},
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
{
field: 'data549',
showIndex: true,
showType: true,
showSpliter: true,
type: 'radio-star',
placeholderDesc: '',
sLimit: 0,
mhLimit: 0,
title: '标题4',
placeholder: '',
valid: '',
isRequired: true,
randomSort: false,
showLeftNum: true,
innerRandom: false,
checked: false,
selectType: 'radio',
sortWay: 'v',
noNps: '',
minNum: '',
maxNum: '',
starStyle: 'star',
starMin: 1,
starMax: 5,
min: 0,
max: 10,
minMsg: '极不满意',
maxMsg: '十分满意',
rangeConfig: {},
options: [
{
text: '选项1',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '274183',
},
{
text: '选项2',
others: false,
othersKey: '',
placeholderDesc: '',
hash: '842967',
},
],
star: 5,
optionOrigin: '',
originType: 'selected',
matrixOptionsRely: '',
numberRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '1000',
value: 1000,
},
},
textRange: {
min: {
placeholder: '0',
value: 0,
},
max: {
placeholder: '500',
value: 500,
},
},
},
],
},
},
pageId: '6659c3283b1cb279bc2e2b0c',
};
const mockAggregationResult = [
{
field: 'data515',
data: {
aggregation: [
{
id: '115019',
count: 1,
},
{
id: '115020',
count: 1,
},
],
submitionCount: 2,
},
},
{
field: 'data893',
data: {
aggregation: [
{
id: '466671',
count: 2,
},
{
id: '095415',
count: 1,
},
],
submitionCount: 2,
},
},
{
field: 'data820',
data: {
aggregation: [
{
id: '8',
count: 1,
},
],
submitionCount: 1,
},
},
{
field: 'data549',
data: {
aggregation: [
{
id: '5',
count: 1,
},
],
submitionCount: 1,
},
},
];
jest
.spyOn(responseSchemaService, 'getResponseSchemaByPageId')
.mockResolvedValueOnce(mockResponseSchema as any);
jest
.spyOn(dataStatisticService, 'aggregationStatis')
.mockResolvedValueOnce(mockAggregationResult);
const result = await controller.aggregationStatis(mockRequest.query);
expect(result).toEqual({
code: 200,
data: expect.any(Array),
});
});
it('should throw an exception if validation fails', async () => {
const mockRequest = {
query: {
surveyId: '',
},
};
await expect(
controller.aggregationStatis(mockRequest.query),
).rejects.toThrow(HttpException);
});
it('should return empty data if response schema does not exist', async () => {
const mockRequest = {
query: {
surveyId: new ObjectId().toString(),
},
};
jest
.spyOn(responseSchemaService, 'getResponseSchemaByPageId')
.mockResolvedValueOnce(null);
const result = await controller.aggregationStatis(mockRequest.query);
expect(result).toEqual({
code: 200,
data: [],
});
});
}); });
}); });

View File

@ -197,7 +197,6 @@ describe('DataStatisticService', () => {
data413_3: expect.any(String), data413_3: expect.any(String),
data413: expect.any(Number), data413: expect.any(Number),
data863: expect.any(String), data863: expect.any(String),
data413_custom: expect.any(String),
difTime: expect.any(String), difTime: expect.any(String),
createDate: expect.any(String), createDate: expect.any(String),
}), }),
@ -310,4 +309,161 @@ describe('DataStatisticService', () => {
); );
}); });
}); });
describe('aggregationStatis', () => {
it('should return correct aggregation data', async () => {
const surveyId = '65afc62904d5db18534c0f78';
const mockAggregationResult = {
data515: [
{
count: 1,
data: {
data515: '115019',
},
},
{
count: 1,
data: {
data515: '115020',
},
},
],
data893: [
{
count: 1,
data: {
data893: ['466671'],
},
},
{
count: 1,
data: {
data893: ['466671', '095415'],
},
},
],
data820: [
{
count: 1,
data: {
data820: 8,
},
},
],
data549: [
{
count: 1,
data: {
data549: 5,
},
},
],
};
const fieldList = Object.keys(mockAggregationResult);
jest.spyOn(surveyResponseRepository, 'aggregate').mockReturnValue({
next: jest.fn().mockResolvedValue(mockAggregationResult),
} as any);
const result = await service.aggregationStatis({
surveyId,
fieldList,
});
expect(result).toEqual(
expect.arrayContaining([
{
field: 'data515',
data: {
aggregation: [
{
id: '115019',
count: 1,
},
{
id: '115020',
count: 1,
},
],
submitionCount: 2,
},
},
{
field: 'data893',
data: {
aggregation: [
{
id: '466671',
count: 2,
},
{
id: '095415',
count: 1,
},
],
submitionCount: 2,
},
},
{
field: 'data820',
data: {
aggregation: [
{
id: '8',
count: 1,
},
],
submitionCount: 1,
},
},
{
field: 'data549',
data: {
aggregation: [
{
id: '5',
count: 1,
},
],
submitionCount: 1,
},
},
]),
);
});
it('should return empty aggregation data when no responses', async () => {
const surveyId = '65afc62904d5db18534c0f78';
const fieldList = ['data458', 'data515'];
jest.spyOn(surveyResponseRepository, 'aggregate').mockReturnValue({
next: jest.fn().mockResolvedValue({}),
} as any);
const result = await service.aggregationStatis({
surveyId,
fieldList,
});
expect(result).toEqual(
expect.arrayContaining([
{
field: 'data458',
data: {
aggregation: [],
submitionCount: 0,
},
},
{
field: 'data515',
data: {
aggregation: [],
submitionCount: 0,
},
},
]),
);
});
});
}); });

View File

@ -319,6 +319,7 @@ export class CollaboratorController {
const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId }); const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId });
if (!surveyMeta) { if (!surveyMeta) {
this.logger.error(`问卷不存在: ${surveyId}`, { req });
throw new HttpException('问卷不存在', EXCEPTION_CODE.SURVEY_NOT_FOUND); throw new HttpException('问卷不存在', EXCEPTION_CODE.SURVEY_NOT_FOUND);
} }

View File

@ -20,6 +20,8 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { Logger } from 'src/logger'; import { Logger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException'; import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { AggregationStatisDto } from '../dto/aggregationStatis.dto';
import { handleAggretionData } from '../utils';
import { SurveyDownloadService } from '../services/surveyDownload.service'; import { SurveyDownloadService } from '../services/surveyDownload.service';
@ApiTags('survey') @ApiTags('survey')
@ -83,4 +85,50 @@ export class DataStatisticController {
}, },
}; };
} }
@Get('/aggregationStatis')
@HttpCode(200)
@UseGuards(Authentication)
async aggregationStatis(@Query() queryInfo: AggregationStatisDto) {
// 聚合统计
const { value, error } = AggregationStatisDto.validate(queryInfo);
if (error) {
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const responseSchema =
await this.responseSchemaService.getResponseSchemaByPageId(
value.surveyId,
);
if (!responseSchema) {
return {
code: 200,
data: [],
};
}
const allowQuestionType = [
'radio',
'checkbox',
'binary-choice',
'radio-star',
'radio-nps',
'vote',
];
const fieldList = responseSchema.code.dataConf.dataList
.filter((item) => allowQuestionType.includes(item.type))
.map((item) => item.field);
const dataMap = responseSchema.code.dataConf.dataList.reduce((pre, cur) => {
pre[cur.field] = cur;
return pre;
}, {});
const res = await this.dataStatisticService.aggregationStatis({
surveyId: value.surveyId,
fieldList,
});
return {
code: 200,
data: res.map((item) => {
return handleAggretionData({ item, dataMap });
}),
};
}
} }

View File

@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import Joi from 'joi';
export class AggregationStatisDto {
@ApiProperty({ description: '问卷id', required: true })
surveyId: string;
static validate(data) {
return Joi.object({
surveyId: Joi.string().required(),
}).validate(data);
}
}

View File

@ -3,7 +3,10 @@ import Joi from 'joi';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
export class CollaboratorDto { export class CollaboratorDto {
@ApiProperty({ description: '用户id', required: false }) @ApiProperty({ description: '协作id', required: false })
_id?: string;
@ApiProperty({ description: '用户id', required: true })
userId: string; userId: string;
@ApiProperty({ @ApiProperty({
@ -16,7 +19,7 @@ export class CollaboratorDto {
SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE,
], ],
}) })
permissions: Array<number>; permissions: Array<string>;
} }
export class BatchSaveCollaboratorDto { export class BatchSaveCollaboratorDto {

View File

@ -7,8 +7,7 @@ import moment from 'moment';
import { keyBy } from 'lodash'; import { keyBy } from 'lodash';
import { DataItem } from 'src/interfaces/survey'; import { DataItem } from 'src/interfaces/survey';
import { ResponseSchema } from 'src/models/responseSchema.entity'; import { ResponseSchema } from 'src/models/responseSchema.entity';
import { getListHeadByDataList } from '../utils'; import { getListHeadByDataList, transformAndMergeArrayFields } from '../utils';
@Injectable() @Injectable()
export class DataStatisticService { export class DataStatisticService {
private radioType = ['radio-star', 'radio-nps']; private radioType = ['radio-star', 'radio-nps'];
@ -102,4 +101,62 @@ export class DataStatisticService {
listBody, listBody,
}; };
} }
async aggregationStatis({ surveyId, fieldList }) {
const $facet = fieldList.reduce((pre, cur) => {
const $match = { $match: { [`data.${cur}`]: { $nin: [[], '', null] } } };
const $group = { $group: { _id: `$data.${cur}`, count: { $sum: 1 } } };
const $project = {
$project: {
_id: 0,
count: 1,
secretKeys: 1,
sensitiveKeys: 1,
[`data.${cur}`]: '$_id',
},
};
pre[cur] = [$match, $group, $project];
return pre;
}, {});
const aggregation = this.surveyResponseRepository.aggregate(
[
{
$match: {
pageId: surveyId,
'curStatus.status': {
$ne: 'removed',
},
},
},
{ $facet },
],
{ maxTimeMS: 30000, allowDiskUse: true },
);
const res = await aggregation.next();
const submitionCountMap: Record<string, number> = {};
for (const field in res) {
let count = 0;
if (Array.isArray(res[field])) {
for (const optionItem of res[field]) {
count += optionItem.count;
}
}
submitionCountMap[field] = count;
}
const transformedData = transformAndMergeArrayFields(res);
return fieldList.map((field) => {
return {
field,
data: {
aggregation: (transformedData?.[field] || []).map((optionItem) => {
return {
id: optionItem.data[field],
count: optionItem.count,
};
}),
submitionCount: submitionCountMap?.[field] || 0,
},
};
});
}
} }

View File

@ -11,13 +11,16 @@
"field": "data458", "field": "data458",
"title": "标题1", "title": "标题1",
"placeholder": "", "placeholder": "",
"randomSort": false, "numberRange": {
"checked": false, "max": {
"minNum": "", "placeholder": "1000",
"maxNum": "", "value": 1000
"star": 5, },
"placeholderDesc": "", "min": {
"urlKey": "", "placeholder": "0",
"value": 0
}
},
"textRange": { "textRange": {
"min": { "min": {
"placeholder": "0", "placeholder": "0",
@ -35,14 +38,8 @@
"showType": true, "showType": true,
"showSpliter": true, "showSpliter": true,
"type": "radio", "type": "radio",
"placeholderDesc": "",
"field": "data515", "field": "data515",
"title": "标题2", "title": "标题2",
"placeholder": "",
"randomSort": false,
"checked": false,
"minNum": "",
"maxNum": "",
"options": [ "options": [
{ {
"text": "选项1", "text": "选项1",
@ -65,22 +62,6 @@
"quota": "0" "quota": "0"
} }
], ],
"importKey": "single",
"importData": "",
"cOption": "",
"cOptions": [],
"star": 5,
"exclude": false,
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
},
"deleteRecover": false, "deleteRecover": false,
"noDisplay": false "noDisplay": false
} }

View File

@ -6,30 +6,21 @@
"showIndex": true, "showIndex": true,
"showType": true, "showType": true,
"showSpliter": true, "showSpliter": true,
"placeholderDesc": "",
"placeholder": "", "placeholder": "",
"isRequired": true, "isRequired": true,
"randomSort": false,
"innerRandom": false,
"hideSubTitleIndex": false,
"checked": false,
"minNum": "",
"maxNum": "",
"relyType": "and",
"extraOptions": [],
"importKey": "single",
"importData": "",
"addressType": 3,
"isAuto": false,
"urlKey": "",
"hasRely": true,
"relyList": [],
"optionOrigin": "",
"answerTip": "",
"type": "text", "type": "text",
"valid": "", "valid": "",
"title": "标题1", "title": "标题1",
"answer": "", "numberRange": {
"max": {
"placeholder": "1000",
"value": 1000
},
"min": {
"placeholder": "0",
"value": 0
}
},
"textRange": { "textRange": {
"min": { "min": {
"placeholder": "0", "placeholder": "0",
@ -46,45 +37,12 @@
"showIndex": true, "showIndex": true,
"showType": true, "showType": true,
"showSpliter": true, "showSpliter": true,
"placeholderDesc": "",
"placeholder": "", "placeholder": "",
"isRequired": true, "isRequired": true,
"randomSort": false, "min": "",
"innerRandom": false, "max": "",
"hideSubTitleIndex": false,
"checked": false,
"minNum": "",
"maxNum": "",
"relyType": "and",
"extraOptions": [],
"importKey": "single",
"importData": "",
"cOption": "",
"cOptions": [],
"star": 5,
"urlKey": "",
"defaultProps": {
"children": "children",
"label": "name",
"id": "id"
},
"hasRely": true,
"relyList": [],
"optionOrigin": "",
"answerTip": "",
"type": "radio-star", "type": "radio-star",
"title": "标题2", "title": "标题2"
"answer": "",
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
}
} }
] ]
} }

View File

@ -11,13 +11,16 @@
"field": "data458", "field": "data458",
"title": "姓名", "title": "姓名",
"placeholder": "", "placeholder": "",
"randomSort": false, "numberRange": {
"checked": false, "max": {
"minNum": "", "placeholder": "1000",
"maxNum": "", "value": 1000
"star": 5, },
"exclude": false, "min": {
"placeholderDesc": "", "placeholder": "0",
"value": 0
}
},
"textRange": { "textRange": {
"min": { "min": {
"placeholder": "0", "placeholder": "0",
@ -40,10 +43,6 @@
"title": "选择您感兴趣的课程进行报名", "title": "选择您感兴趣的课程进行报名",
"placeholder": "", "placeholder": "",
"valid": "", "valid": "",
"randomSort": false,
"checked": false,
"minNum": "",
"maxNum": "",
"options": [ "options": [
{ {
"text": "课程1", "text": "课程1",
@ -81,27 +80,7 @@
"othersKey": "", "othersKey": "",
"placeholderDesc": "" "placeholderDesc": ""
} }
], ]
"star": 5,
"exclude": false,
"urlKey": "",
"defaultProps": {
"children": "children",
"label": "name",
"id": "id"
},
"startDate": "",
"endDate": "",
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
}
} }
] ]
} }

View File

@ -11,15 +11,16 @@
"field": "data631", "field": "data631",
"title": "标题1", "title": "标题1",
"placeholder": "", "placeholder": "",
"sLimit": 1, "numberRange": {
"randomSort": false, "max": {
"checked": false, "placeholder": "1000",
"minNum": "", "value": 1000
"maxNum": "", },
"star": 5, "min": {
"exclude": false, "placeholder": "0",
"placeholderDesc": "", "value": 0
"urlKey": "", }
},
"textRange": { "textRange": {
"min": { "min": {
"placeholder": "0", "placeholder": "0",
@ -38,12 +39,8 @@
"showSpliter": true, "showSpliter": true,
"type": "vote", "type": "vote",
"innerType": "radio", "innerType": "radio",
"placeholderDesc": "",
"field": "data606", "field": "data606",
"title": "标题2", "title": "标题2",
"placeholder": "",
"randomSort": false,
"checked": false,
"minNum": "", "minNum": "",
"maxNum": "", "maxNum": "",
"options": [ "options": [
@ -65,18 +62,7 @@
"placeholderDesc": "", "placeholderDesc": "",
"hash": "115020" "hash": "115020"
} }
], ]
"star": 5,
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
}
} }
] ]
} }

View File

@ -33,7 +33,7 @@ export function getListHeadByDataList(dataList) {
let othersCode; let othersCode;
const radioType = ['radio-star', 'radio-nps']; const radioType = ['radio-star', 'radio-nps'];
if (radioType.includes(question.type)) { if (radioType.includes(question.type)) {
const rangeConfigKeys = Object.keys(question.rangeConfig); const rangeConfigKeys = question.rangeConfig ? Object.keys(question.rangeConfig) : [];
if (rangeConfigKeys.length > 0) { if (rangeConfigKeys.length > 0) {
othersCode = [{ code: `${question.field}_custom`, option: '填写理由' }]; othersCode = [{ code: `${question.field}_custom`, option: '填写理由' }];
} }
@ -66,3 +66,177 @@ export function getListHeadByDataList(dataList) {
}); });
return listHead; return listHead;
} }
export function transformAndMergeArrayFields(data) {
const transformedData = {};
for (const key in data) {
const valueMap: Record<string, number> = {};
for (const entry of data[key]) {
const nestedDataKey = Object.keys(entry.data)[0];
const nestedDataValue = entry.data[nestedDataKey];
if (Array.isArray(nestedDataValue)) {
for (const value of nestedDataValue) {
if (!valueMap[value]) {
valueMap[value] = 0;
}
valueMap[value] += entry.count;
}
} else {
if (!valueMap[nestedDataValue]) {
valueMap[nestedDataValue] = 0;
}
valueMap[nestedDataValue] += entry.count;
}
}
transformedData[key] = Object.keys(valueMap).map((value) => ({
count: valueMap[value],
data: {
[key]: value,
},
}));
}
return transformedData;
}
export function handleAggretionData({ dataMap, item }) {
const type = dataMap[item.field].type;
const aggregationMap = item.data.aggregation.reduce((pre, cur) => {
pre[cur.id] = cur;
return pre;
}, {});
if (['radio', 'checkbox', 'vote', 'binary-choice'].includes(type)) {
return {
...item,
title: dataMap[item.field].title,
type: dataMap[item.field].type,
data: {
...item.data,
aggregation: dataMap[item.field].options.map((optionItem) => {
return {
id: optionItem.hash,
text: optionItem.text,
count: aggregationMap[optionItem.hash]?.count || 0,
};
}),
},
};
} else if (['radio-star', 'radio-nps'].includes(type)) {
const summary: Record<string, any> = {};
const average = getAverage({ aggregation: item.data.aggregation });
const median = getMedian({ aggregation: item.data.aggregation });
const variance = getVariance({
aggregation: item.data.aggregation,
average,
});
summary['average'] = average;
summary['median'] = median;
summary['variance'] = variance;
if (type === 'radio-nps') {
summary['nps'] = getNps({ aggregation: item.data.aggregation });
}
const range = type === 'radio-nps' ? [0, 10] : [1, 5];
const arr = [];
for (let i = range[0]; i <= range[1]; i++) {
arr.push(i);
}
return {
...item,
title: dataMap[item.field].title,
type: dataMap[item.field].type,
data: {
aggregation: arr.map((item) => {
const num = item.toString();
return {
text: num,
id: num,
count: aggregationMap?.[num]?.count || 0,
};
}),
submitionCount: item.data.submitionCount,
summary,
},
};
} else {
return {
...item,
title: dataMap[item.field].title,
type: dataMap[item.field].type,
};
}
}
function getAverage({ aggregation }) {
const { sum, count } = aggregation.reduce(
(pre, cur) => {
const num = parseInt(cur.id);
pre.sum += num * cur.count;
pre.count += cur.count;
return pre;
},
{ sum: 0, count: 0 },
);
if (count === 0) {
return 0;
}
return (sum / count).toFixed(2);
}
function getMedian({ aggregation }) {
const sortedArr = aggregation.sort((a, b) => {
return parseInt(a.id) - parseInt(b.id);
});
const resArr = [];
for (const item of sortedArr) {
const tmp = new Array(item.count).fill(parseInt(item.id));
resArr.push(...tmp);
}
if (resArr.length === 0) {
return 0;
}
if (resArr.length % 2 === 1) {
const midIndex = Math.floor(resArr.length / 2);
return resArr[midIndex].toFixed(2);
}
const rightIndex = resArr.length / 2;
const leftIndex = rightIndex - 1;
return ((resArr[leftIndex] + resArr[rightIndex]) / 2).toFixed(2);
}
function getVariance({ aggregation, average }) {
const { sum, count } = aggregation.reduce(
(pre, cur) => {
const sub = Number(cur.id) - average;
pre.sum += sub * sub;
pre.count += cur.count;
return pre;
},
{ sum: 0, count: 0 },
);
if (count === 0 || count === 1) {
return '0.00';
}
return (sum / (count - 1)).toFixed(2);
}
function getNps({ aggregation }) {
// 净推荐值(NPS)=(推荐者数/总样本数)×100%-(贬损者数/总样本数)×100%
// 010分举例子推荐者9-10分被动者7-8分贬损者0-6分
let recommand = 0,
derogatory = 0,
total = 0;
for (const item of aggregation) {
const num = parseInt(item.id);
if (num >= 9) {
recommand += item.count;
} else if (num <= 6) {
derogatory += item.count;
}
total += item.count;
}
return ((recommand / total - derogatory / total) * 100).toFixed(2) + '%';
}

17
web/components.d.ts vendored
View File

@ -14,13 +14,11 @@ declare module 'vue' {
ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider'] ElDivider: typeof import('element-plus/es')['ElDivider']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
@ -32,12 +30,7 @@ declare module 'vue' {
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElRow: typeof import('element-plus/es')['ElRow'] ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSelectV2: typeof import('element-plus/es')['ElSelectV2'] ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
ElSlider: typeof import('element-plus/es')['ElSlider'] ElSlider: typeof import('element-plus/es')['ElSlider']
@ -48,8 +41,6 @@ declare module 'vue' {
ElTabs: typeof import('element-plus/es')['ElTabs'] ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker'] ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
IEpBottom: typeof import('~icons/ep/bottom')['default'] IEpBottom: typeof import('~icons/ep/bottom')['default']
IEpCheck: typeof import('~icons/ep/check')['default'] IEpCheck: typeof import('~icons/ep/check')['default']
@ -62,8 +53,6 @@ declare module 'vue' {
IEpMinus: typeof import('~icons/ep/minus')['default'] IEpMinus: typeof import('~icons/ep/minus')['default']
IEpMonitor: typeof import('~icons/ep/monitor')['default'] IEpMonitor: typeof import('~icons/ep/monitor')['default']
IEpMore: typeof import('~icons/ep/more')['default'] IEpMore: typeof import('~icons/ep/more')['default']
IEpMonitor: typeof import('~icons/ep/monitor')['default']
IEpMore: typeof import('~icons/ep/more')['default']
IEpPlus: typeof import('~icons/ep/plus')['default'] IEpPlus: typeof import('~icons/ep/plus')['default']
IEpQuestionFilled: typeof import('~icons/ep/question-filled')['default'] IEpQuestionFilled: typeof import('~icons/ep/question-filled')['default']
IEpRank: typeof import('~icons/ep/rank')['default'] IEpRank: typeof import('~icons/ep/rank')['default']
@ -75,8 +64,6 @@ declare module 'vue' {
IEpTop: typeof import('~icons/ep/top')['default'] IEpTop: typeof import('~icons/ep/top')['default']
IEpView: typeof import('~icons/ep/view')['default'] IEpView: typeof import('~icons/ep/view')['default']
IEpWarningFilled: typeof import('~icons/ep/warning-filled')['default'] IEpWarningFilled: typeof import('~icons/ep/warning-filled')['default']
IEpView: typeof import('~icons/ep/view')['default']
IEpWarningFilled: typeof import('~icons/ep/warning-filled')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }

View File

@ -14,13 +14,13 @@
"format": "prettier --write src/materials/setters/widgets/QuotaConfig.vue" "format": "prettier --write src/materials/setters/widgets/QuotaConfig.vue"
}, },
"dependencies": { "dependencies": {
"@types/lodash-es": "^4.17.12",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"async-validator": "^4.2.5", "async-validator": "^4.2.5",
"axios": "^1.4.0", "axios": "^1.4.0",
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"echarts": "^5.5.0",
"element-plus": "^2.7.0", "element-plus": "^2.7.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
@ -39,6 +39,7 @@
"@iconify-json/ep": "^1.1.15", "@iconify-json/ep": "^1.1.15",
"@rushstack/eslint-patch": "^1.10.2", "@rushstack/eslint-patch": "^1.10.2",
"@tsconfig/node20": "^20.1.2", "@tsconfig/node20": "^20.1.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.19", "@types/node": "^20.11.19",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "^5.0.3",

View File

@ -8,6 +8,14 @@ export const getRecycleList = (data) => {
} }
}) })
} }
export const getStatisticList = (data) => {
return axios.get('/survey/dataStatistic/aggregationStatis', {
params: {
...data
}
})
}
//问卷下载 //问卷下载
export const downloadSurvey = ({ surveyId, isDesensitive }) => { export const downloadSurvey = ({ surveyId, isDesensitive }) => {
return axios.get('/survey/surveyDownload/download', { return axios.get('/survey/surveyDownload/download', {

View File

@ -0,0 +1,77 @@
import { menuItems } from './questionMenuConfig'
export const noDataConfig = {
title: '暂无数据',
desc: '您的问卷当前还没有数据,快去回收问卷吧!',
img: '/imgs/icons/analysis-empty.webp'
}
export const separateItemListHead = [
{
title: '选项',
field: 'text'
},
{
title: '数量',
field: 'count'
},
{
title: '占比',
field: 'percent'
}
]
// 图表名称需要和./chartConfig.js中保持一致
export const questionChartsConfig = {
[menuItems['checkbox']['type']]: ['bar'],
[menuItems['radio-nps']['type']]: ['gauge', 'pie', 'bar'],
default: ['pie', 'bar']
}
export const analysisTypeMap = {
dataTable: 'dataTable',
separateStatistics: 'separateStatistics'
}
export const analysisType = [
{
value: analysisTypeMap.dataTable,
label: '数据列表',
icon: 'icon-shujuliebiao'
},
{
value: analysisTypeMap.separateStatistics,
label: '分题统计',
icon: 'icon-fentitongji'
}
]
export const summaryType = {
between: 'between'
}
export const summaryItemConfig = {
'radio-nps': [
{
text: '推荐者',
field: 'id',
type: summaryType.between,
max: 10,
min: 9
},
{
text: '中立者',
field: 'id',
type: summaryType.between,
max: 8,
min: 7
},
{
text: '贬损者',
field: 'id',
type: summaryType.between,
max: 6,
min: 0
}
]
}

View File

@ -0,0 +1,57 @@
/**
* @Description: 柱状图配置
* @CreateDate: 2024-04-30
*/
export default (data) => {
const xAxisData = data.map((item) => item.name)
return {
color: ['#55A8FD'],
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: '{a} <br/>{b}: {c}'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: [
{
type: 'category',
data: xAxisData,
axisTick: {
alignWithLabel: true
},
axisLabel: {
interval: 0,
formatter(value) {
return value
}
}
}
],
yAxis: [
{
type: 'value',
splitLine: {
lineStyle: {
type: 'dashed'
}
}
}
],
series: [
{
showAllSymbol: true,
name: '提交人数',
type: 'bar',
barMaxWidth: 50,
data
}
]
}
}

View File

@ -0,0 +1,146 @@
/**
* @Description: gauge(仪表盘)
* @CreateDate: 2024-04-30
*/
export default (data) => {
return {
series: [
{
type: 'gauge',
startAngle: 180,
endAngle: 0,
min: -100,
max: 100,
radius: '130%',
center: ['50%', '80%'],
splitNumber: 4,
z: 2,
axisLabel: {
show: false,
distance: 0,
color: '#AAB1C0',
fontSize: 12,
fontFamily: 'DaQi-Font'
},
splitLine: {
show: false
},
axisTick: {
show: false
},
axisLine: {
lineStyle: {
width: 40,
color: [[1, '#e3efff']]
}
}
},
{
type: 'gauge',
startAngle: 174,
endAngle: 5,
min: -100,
max: 100,
radius: '130%',
splitNumber: 4,
center: ['50%', '80%'],
z: 3,
axisLabel: {
distance: -5,
color: '#666',
rotate: 'tangential',
fontSize: 12,
fontFamily: 'DaQi-Font'
},
splitLine: {
show: false
},
axisTick: {
show: false
},
axisLine: {
lineStyle: {
width: 40,
color: [[1, '#e3efff']]
}
}
},
{
type: 'gauge',
startAngle: 178,
endAngle: 0,
min: -100,
max: 100,
radius: '109%',
z: 4,
center: ['50%', '80%'],
splitNumber: 4,
itemStyle: {
color: '#58D9F9'
},
progress: {
show: true,
roundCap: true,
width: 15,
itemStyle: {
color: '#55A8FD',
shadowBlur: 10,
shadowColor: '#55A8FD'
}
},
pointer: {
icon: 'triangle',
length: '10%',
width: 8,
offsetCenter: [0, '-80%'],
itemStyle: {
color: '#55A8FD'
}
},
axisLine: {
lineStyle: {
width: 15,
color: [[1, '#d3e5fe']]
}
},
axisTick: {
show: false
},
splitLine: {
show: false
},
axisLabel: {
show: false
},
title: {
offsetCenter: [0, '-15%'],
fontSize: 18,
color: '#666'
},
detail: {
fontSize: 46,
lineHeight: 40,
height: 40,
offsetCenter: [0, '-45%'],
valueAnimation: true,
color: '#55A8FD',
formatter: function (value) {
if (value) {
return value + '%'
} else if (value === 0) {
return value
} else {
return '--'
}
}
},
data: [
{
value: data,
name: 'NPS'
}
]
}
]
}
}

View File

@ -0,0 +1,9 @@
import pie from './pie'
import bar from './bar'
import gauge from './gauge'
export const getOption = {
pie,
bar,
gauge
}

View File

@ -0,0 +1,57 @@
const color = [
'#55A8FD',
'#36CBCB',
'#FAD337',
'#A6D6FF',
'#A177DC',
'#F46C73',
'#FFBA62',
'#ACE474',
'#BEECD6',
'#AFD2FF'
]
/*
* @Description: 饼图配置
* @CreateDate: 2024-04-30
*/
export default (data) => {
return {
color,
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
right: 12,
top: 12,
tooltip: {
show: true
},
formatter(name) {
return name.length > 17 ? name.substr(0, 17) + '...' : name
}
},
series: [
{
name: '提交人数',
type: 'pie',
radius: ['50%', '80%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter({ data }) {
const name = data?.name || ''
return name.length > 17 ? name.substr(0, 17) + '...' : name
}
},
data
}
]
}
}

View File

@ -1,4 +1,4 @@
const menuItems = { export const menuItems = {
text: { text: {
type: 'text', type: 'text',
snapshot: '/imgs/question-type-snapshot/iL84te6xxU1657702189333.webp', snapshot: '/imgs/question-type-snapshot/iL84te6xxU1657702189333.webp',

View File

@ -0,0 +1,25 @@
import * as echarts from 'echarts'
import { getOption } from '@/management/config/chartConfig'
/**
* 绘制图表
* @param {Object} el
* @param {String} type
* @param {Array} data
*/
export default (el, type, data) => {
const chart = echarts.init(el)
const option = getOption[type](data)
chart.setOption(option, true)
const resize = () => {
chart.resize()
}
const changeType = (type, data) => {
chart.setOption(getOption[type](data), true)
}
return { chart, resize, changeType }
}

View File

@ -0,0 +1,20 @@
// 引入防抖函数
import { debounce as _debounce } from 'lodash-es'
/**
* @description: 监听元素尺寸变化
* @param {*} el 元素dom
* @param {*} cb resize变化时执行的方法
* @param {*} wait 防抖间隔
* @return {*}
*/
export default (el, cb, wait = 200) => {
const resizeObserver = new ResizeObserver(_debounce(cb, wait))
resizeObserver.observe(el)
const destroy = () => {
resizeObserver.disconnect(el)
}
return { destroy, resizeObserver }
}

View File

@ -0,0 +1,77 @@
import { ref, watchEffect } from 'vue'
import { cleanRichText } from '@/common/xss'
import { questionChartsConfig } from '../config/analysisConfig'
// 饼图数据处理
const pie = (data) => {
const aggregation = data?.aggregation
return (
aggregation?.map?.((item) => {
const { id, count, text } = item
return {
id,
value: count,
name: cleanRichText(text)
}
}) || []
)
}
// 柱状图数据处理
const bar = (data) => {
const aggregation = data?.aggregation
return (
aggregation?.map?.((item) => {
const { id, count, text } = item
return {
id,
value: count,
name: cleanRichText(text)
}
}) || []
)
}
// 仪表盘数据处理
const gauge = (data) => {
return parseFloat(data?.summary?.nps) || 0
}
const dataFormateConfig = {
pie,
bar,
gauge
}
/**
* @description: 分题统计图表hook
* @param {*} chartType
* @param {*} data
* @return {*} chartRef 图表实例 chartTypeList 图表类型列表 chartType 图表类型 chartData 图表数据
*/
export default ({ questionType, data }) => {
const chartRef = ref(null)
const chartTypeList = ref([])
const chartType = ref('')
const chartData = ref({})
watchEffect(() => {
if (questionType.value) {
// 根据题型获取图表类型列表
chartTypeList.value = questionChartsConfig[questionType.value] || questionChartsConfig.default
if (!chartType.value) {
// 默认选中第一项
chartType.value = chartTypeList.value?.[0]
}
if (chartType.value) {
// 根据图表类型获取图表数据
chartData.value = dataFormateConfig[chartType.value](data)
}
}
})
return {
chartRef,
chartTypeList,
chartType,
chartData
}
}

View File

@ -1,202 +1,28 @@
<template> <template>
<div class="analysis-page"> <div class="analysis-page">
<leftMenu class="left"></leftMenu> <leftMenu class="left"></leftMenu>
<div class="content-wrapper right"> <div class="right">
<template v-if="tableData.total"> <div class="analysis-tabs">
<h2 class="data-list">数据列表</h2> <router-link
<div class="menus"> v-for="item in analysisType"
<el-switch class="analysis-tabs__item"
:model-value="isShowOriginData" :key="item.value"
active-text="是否展示原数据" :to="{ name: item.value }"
@input="onIsShowOriginChange"
> >
</el-switch> <i class="iconfont" :class="item.icon"></i>
<div style="display: flex; justify-content: flex-end"> <span>{{ item.label }}</span>
<el-switch </router-link>
:model-value="isDownloadDesensitive"
active-text="是否下载脱敏数据"
@input="onisDownloadDesensitive"
style="margin-right: 20px"
>
</el-switch>
<el-button type="primary" @click="onDownload">导出数据</el-button>
</div> </div>
<!-- <el-button type="primary" @click="exportData">导出数据</el-button> --> <div class="content-wrapper">
</div> <router-view />
</template>
<template v-if="tableData.total">
<DataTable :main-table-loading="mainTableLoading" :table-data="tableData" />
<el-pagination
background
layout="prev, pager, next"
popper-class="analysis-pagination"
:total="tableData.total"
@current-change="handleCurrentChange"
>
</el-pagination>
</template>
<div v-else>
<EmptyIndex :data="noDataConfig" />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script setup>
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import EmptyIndex from '@/management/components/EmptyIndex.vue'
import LeftMenu from '@/management/components/LeftMenu.vue' import LeftMenu from '@/management/components/LeftMenu.vue'
import { getRecycleList, downloadSurvey } from '@/management/api/analysis' import { analysisType } from '@/management/config/analysisConfig'
import DataTable from './components/DataTable.vue'
export default {
name: 'AnalysisPage',
data() {
return {
mainTableLoading: false,
tableData: {
total: 0,
listHead: [],
listBody: []
},
noDataConfig: {
title: '暂无数据',
desc: '您的问卷当前还没有数据,快去回收问卷吧!',
img: '/imgs/icons/analysis-empty.webp'
},
currentPage: 1,
isShowOriginData: false,
tmpIsShowOriginData: false,
isDownloadDesensitive: true
}
},
computed: {},
created() {
this.init()
},
methods: {
async init() {
if (!this.$route.params.id) {
ElMessage.error('没有传入问卷参数~')
return
}
this.mainTableLoading = true
try {
const res = await getRecycleList({
page: this.currentPage,
surveyId: this.$route.params.id,
isDesensitive: !this.tmpIsShowOriginData // isShowOriginData
})
if (res.code === 200) {
const listHead = this.formatHead(res.data.listHead)
this.tableData = { ...res.data, listHead }
this.mainTableLoading = false
}
} catch (error) {
ElMessage.error('查询回收数据失败,请重试')
}
},
async onDownload() {
try {
await ElMessageBox.confirm('是否确认下载?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch (error) {
console.log('取消下载')
return
}
this.exportData()
this.gotoDownloadList()
},
async gotoDownloadList() {
try {
await ElMessageBox.confirm('计算中,是否前往下载中心?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch (error) {
console.log('取消跳转')
return
}
this.$router.push('/survey/download')
},
handleCurrentChange(current) {
if (this.mainTableLoading) {
return
}
this.currentPage = current
this.init()
},
formatHead(listHead = []) {
const head = []
listHead.forEach((headItem) => {
head.push({
field: headItem.field,
title: headItem.title
})
if (headItem.othersCode?.length) {
headItem.othersCode.forEach((item) => {
head.push({
field: item.code,
title: `${headItem.title}-${item.option}`
})
})
}
})
return head
},
async onIsShowOriginChange(data) {
if (this.mainTableLoading) {
return
}
// console.log(data)
this.tmpIsShowOriginData = data
await this.init()
this.isShowOriginData = data
},
async onisDownloadDesensitive() {
if (this.isDownloadDesensitive) {
this.isDownloadDesensitive = false
} else {
this.isDownloadDesensitive = true
}
},
async exportData() {
try {
const res = await downloadSurvey({
surveyId: String(this.$route.params.id),
isDesensitive: this.isDownloadDesensitive
})
console.log(this.$route.params.id)
if (res.code === 200) {
ElMessage.success('下载成功')
}
} catch (error) {
ElMessage.error('下载失败')
ElMessage.error(error.message)
}
}
},
components: {
DataTable,
EmptyIndex,
LeftMenu
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -215,31 +41,60 @@ export default {
.right { .right {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding-left: 120px; min-width: 1160px;
}
}
.menus {
margin-bottom: 20px;
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
} overflow: hidden;
.content-wrapper {
padding: 30px 40px 50px 40px;
border-radius: 2px;
background-color: #f6f7f9; background-color: #f6f7f9;
box-sizing: border-box;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
:deep(.el-pagination) { .analysis-tabs {
margin-top: 20px; flex: none;
gap: 40px;
font-size: 14px;
font-weight: normal;
width: 100%;
height: 56px;
position: relative;
display: flex; display: flex;
justify-content: flex-end; justify-content: center;
align-items: center;
background-color: #fff;
border-bottom: 1px solid #e7e9eb;
&__item {
cursor: pointer;
padding: 8px 0;
color: #92949d;
.iconfont {
margin-right: 8px;
}
} }
.data-list { .router-link-active {
margin-bottom: 20px; color: $font-color-title;
position: relative;
height: 100%;
display: flex;
align-items: center;
&::before {
content: '';
position: absolute;
width: calc(100% + 5px);
height: 3px;
background-color: $primary-color;
bottom: 0;
left: 0;
}
}
}
}
.content-wrapper {
flex: auto;
overflow: hidden;
padding: 24px 24px 24px 104px;
} }
} }
</style> </style>

View File

@ -17,22 +17,23 @@
minWidth="200" minWidth="200"
> >
<template #header="scope"> <template #header="scope">
<div class="table-row-cell"> <div
<span class="table-row-cell"
@mouseover="onPopoverRefOver(scope, 'head')" @mouseover="onPopoverRefOver(scope, 'head')"
:ref="(el) => (popoverRefMap[scope.column.id] = el)" :ref="(el) => (popoverRefMap[scope.column.id] = el)"
> >
<span>
{{ scope.column.label.replace(/&nbsp;/g, '') }} {{ scope.column.label.replace(/&nbsp;/g, '') }}
</span> </span>
</div> </div>
</template> </template>
<template #default="scope"> <template #default="scope">
<div> <div
<span
class="table-row-cell" class="table-row-cell"
@mouseover="onPopoverRefOver(scope, 'content')" @mouseover="onPopoverRefOver(scope, 'content')"
:ref="(el) => (popoverRefMap[scope.$index + scope.column.property] = el)" :ref="(el) => (popoverRefMap[scope.$index + scope.column.property] = el)"
> >
<span>
{{ getContent(scope.row[scope.column.property]) }} {{ getContent(scope.row[scope.column.property]) }}
</span> </span>
</div> </div>
@ -44,6 +45,7 @@
popper-style="text-align: center;" popper-style="text-align: center;"
:virtual-ref="popoverVirtualRef" :virtual-ref="popoverVirtualRef"
placement="top" placement="top"
width="400"
trigger="hover" trigger="hover"
virtual-triggering virtual-triggering
:content="popoverContent" :content="popoverContent"
@ -62,6 +64,10 @@ const props = defineProps({
}, },
mainTableLoading: { mainTableLoading: {
type: Boolean type: Boolean
},
tableMinHeight: {
type: String,
default: '620px'
} }
}) })
const popoverRefMap = ref({}) const popoverRefMap = ref({})
@ -94,15 +100,18 @@ const onPopoverRefOver = (scope, type) => {
position: relative; position: relative;
width: 100%; width: 100%;
padding-bottom: 20px; padding-bottom: 20px;
min-height: 620px; min-height: v-bind('tableMinHeight');
background: #fff; background: #fff;
padding: 10px 20px; padding: 10px 20px;
.table-border { .table-border {
box-sizing: border-box; box-sizing: border-box;
text-align: center; text-align: center;
} }
:deep(.el-table__header) { :deep(.el-table__header) {
width: 100%; width: 100%;
.thead-cell .el-table__cell { .thead-cell .el-table__cell {
.cell { .cell {
height: 24px; height: 24px;
@ -111,10 +120,16 @@ const onPopoverRefOver = (scope, type) => {
} }
} }
} }
.table-row-cell { .table-row-cell {
white-space: nowrap; /* 禁止自动换行 */ max-width: 100%;
overflow: hidden; /* 超出部分隐藏 */ display: inline-block;
text-overflow: ellipsis; /* 显示省略号 */ white-space: nowrap;
/* 禁止自动换行 */
overflow: hidden;
/* 超出部分隐藏 */
text-overflow: ellipsis;
/* 显示省略号 */
} }
} }
</style> </style>

View File

@ -0,0 +1,242 @@
<template>
<div class="separate-item">
<div class="separate-item-title">
<el-popover
placement="top"
width="400"
trigger="hover"
:disabled="!titlePoppverShow"
:content="cleanRichText(StatisticsData.title)"
>
<template #reference>
<p ref="titleRef" class="text" v-html="cleanRichText(StatisticsData.title)"></p>
</template>
</el-popover>
<p v-if="questionTypeDesc" class="type">{{ questionTypeDesc }}</p>
</div>
<div class="separate-item-content">
<div class="chart-wrapper">
<div ref="chartRef" class="chart"></div>
<div v-if="chartTypeList.length > 1" class="chart-type-list">
<el-segmented v-model="chartType" :options="chartTypeList" size="small">
<template #default="{ item }">
<i class="iconfont" :class="`icon-${item}`"></i>
</template>
</el-segmented>
</div>
</div>
<div class="table-wrapper">
<data-table :table-data :table-min-height />
</div>
</div>
</div>
</template>
<script setup>
import { reactive, toRefs, computed, watch, onMounted, onUnmounted, ref } from 'vue'
import { cloneDeep as _cloneDeep } from 'lodash-es'
import {
separateItemListHead,
summaryType,
summaryItemConfig
} from '@/management/config/analysisConfig'
import useCharts from '@/management/hooks/useCharts'
import useStatisticsItemChart from '@/management/hooks/useStatisticsItemChart'
import { cleanRichText } from '@/common/xss'
import { menuItems } from '@/management/config/questionMenuConfig'
import DataTable from './DataTable.vue'
import useResizeObserver from '@/management/hooks/useResizeObserver'
const props = defineProps({
StatisticsData: {
type: Object,
required: true
}
})
const questionType = computed(() => {
return props?.StatisticsData?.type
})
const questionTypeDesc = computed(() => {
return menuItems?.[questionType.value]?.title || ''
})
//
const separateItemListBody = computed(() => {
try {
const aggregation = _cloneDeep(props?.StatisticsData?.data?.aggregation)
const submitionCount = props?.StatisticsData?.data?.submitionCount
const summaryList = summaryItemConfig[questionType.value]
//
if (summaryList?.length) {
summaryList.forEach((item, index) => {
const { type, text, field, max, min } = item
if (text && field && type === summaryType.between) {
aggregation.push({
id: `summary_${index}`,
text,
count: aggregation.reduce((n, item) => {
if (item[field] >= min && item[field] <= max) {
return n + item.count
}
return n
}, 0)
})
}
})
}
return (
aggregation?.map((item) => {
const { id, count, text } = item
const percent = submitionCount ? `${((count / submitionCount) * 100).toFixed(1)}%` : '0%'
return {
id,
count,
text,
percent
}
}) || []
)
} catch (e) {
console.log(e)
return []
}
})
const separateItemState = reactive({
tableData: {
total: 0,
listHead: separateItemListHead,
listBody: separateItemListBody
},
tableMinHeight: '0px'
})
const { tableData, tableMinHeight } = toRefs(separateItemState)
const titlePoppverShow = ref(false)
const titleRef = ref(null)
const titleResize = () => {
if (titleRef.value?.scrollWidth > titleRef.value?.offsetWidth) {
titlePoppverShow.value = true
} else {
titlePoppverShow.value = false
}
}
const { chartRef, chartTypeList, chartType, chartData } = useStatisticsItemChart({
questionType,
data: props?.StatisticsData?.data
})
onMounted(() => {
// dommounted
const { changeType, resize: chartResize } = useCharts(
chartRef.value,
chartType.value,
chartData.value
)
const { destroy } = useResizeObserver(chartRef.value, () => {
chartResize()
titleResize()
})
//
watch(chartType, () => {
changeType(chartType.value, chartData.value)
})
// resizeObserver
onUnmounted(destroy)
})
</script>
<style lang="scss" scoped>
.separate-item {
padding: 32px 12px;
border-bottom: 1px solid #efefef;
&:nth-last-of-type(1) {
border-bottom: none;
}
&-title {
font-size: 16px;
color: #333;
font-weight: 600;
margin-bottom: 24px;
display: flex;
align-items: center;
.text {
max-width: 50%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.type {
font-size: 12px;
margin-left: 8px;
color: white;
background-color: var(--primary-color);
border-radius: 7px 3px;
padding: 2px 6px;
}
}
&-content {
display: flex;
justify-content: space-between;
gap: 50px;
.chart-wrapper {
position: relative;
flex: auto;
width: 50%;
min-width: 300px;
height: 320px;
max-width: 1000px;
box-shadow: 0 2px 8px -2px rgba(136, 136, 157, 0.2);
border-radius: 2px;
padding: 24px;
.chart-type-list {
position: absolute;
left: 0;
top: 0;
.iconfont {
font-size: 12px;
}
}
.chart {
width: 100%;
height: 100%;
}
}
.table-wrapper {
flex: auto;
width: 50%;
min-width: 300px;
max-width: 1000px;
}
}
@media screen and (min-width: 1660px) {
&-content {
gap: 80px;
.chart-wrapper {
height: 400px;
}
}
}
}
</style>

View File

@ -0,0 +1,207 @@
<template>
<div class="data-table-page">
<template v-if="tableData.total">
<div class="menus">
<el-switch
:model-value="isShowOriginData"
active-text="是否展示原数据"
@input="onIsShowOriginChange"
>
</el-switch>
<div style="display: flex; justify-content: flex-end">
<el-switch
:model-value="isDownloadDesensitive"
active-text="是否下载脱敏数据"
@input="onisDownloadDesensitive"
style="margin-right: 20px"
>
</el-switch>
<el-button type="primary" @click="onDownload">导出数据</el-button>
</div>
</div>
</template>
<template v-if="tableData.total">
<DataTable :main-table-loading :table-data />
<el-pagination
background
layout="prev, pager, next"
popper-class="analysis-pagination"
:total="tableData.total"
@current-change="handleCurrentChange"
>
</el-pagination>
</template>
<div v-else>
<EmptyIndex :data="noDataConfig" />
</div>
</div>
</template>
<script setup>
import { reactive, toRefs, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import EmptyIndex from '@/management/components/EmptyIndex.vue'
import { getRecycleList, downloadSurvey } from '@/management/api/analysis'
import { noDataConfig } from '@/management/config/analysisConfig'
import DataTable from '../components/DataTable.vue'
const dataTableState = reactive({
mainTableLoading: false,
tableData: {
total: 0,
listHead: [],
listBody: []
},
currentPage: 1,
isShowOriginData: false,
tmpIsShowOriginData: false,
isDownloadDesensitive: true
})
const { mainTableLoading, tableData, isShowOriginData, isDownloadDesensitive } = toRefs(dataTableState)
const route = useRoute()
const router = useRouter()
const formatHead = (listHead) => {
const head = []
listHead.forEach((headItem) => {
head.push({
field: headItem.field,
title: headItem.title
})
if (headItem.othersCode?.length) {
headItem.othersCode.forEach((item) => {
head.push({
field: item.code,
title: `${headItem.title}-${item.option}`
})
})
}
})
return head
}
const onIsShowOriginChange = async (data) => {
if (dataTableState.mainTableLoading) {
return
}
dataTableState.tmpIsShowOriginData = data
await init()
dataTableState.isShowOriginData = data
}
const handleCurrentChange = async (page) => {
if (dataTableState.mainTableLoading) {
return
}
dataTableState.currentPage = page
await init()
}
const init = async () => {
if (!route.params.id) {
ElMessage.error('没有传入问卷参数~')
return
}
dataTableState.mainTableLoading = true
try {
const res = await getRecycleList({
page: dataTableState.currentPage,
surveyId: route.params.id,
isDesensitive: !dataTableState.tmpIsShowOriginData // isShowOriginData
})
if (res.code === 200) {
const listHead = formatHead(res.data.listHead)
dataTableState.tableData = { ...res.data, listHead }
dataTableState.mainTableLoading = false
}
} catch (error) {
ElMessage.error('查询回收数据失败,请重试')
}
}
onMounted(() => {
init()
})
const onDownload = async () => {
try {
await ElMessageBox.confirm('是否确认下载?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch (error) {
console.log('取消下载')
return
}
exportData()
gotoDownloadList()
}
const gotoDownloadList = async () => {
try {
await ElMessageBox.confirm('计算中,是否前往下载中心?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch (error) {
console.log('取消跳转')
return
}
router.push('/survey/download')
}
const onisDownloadDesensitive = async () => {
if (dataTableState.isDownloadDesensitive) {
dataTableState.isDownloadDesensitive = false
} else {
dataTableState.isDownloadDesensitive = true
}
}
const exportData = async () => {
try {
const res = await downloadSurvey({
surveyId: String(route.params.id),
isDesensitive: dataTableState.isDownloadDesensitive
})
if (res.code === 200) {
ElMessage.success('下载成功')
}
} catch (error) {
ElMessage.error('下载失败')
ElMessage.error(error.message)
}
}
</script>
<style lang="scss" scoped>
.data-table-page {
width: 100%;
height: 100%;
overflow: hidden;
}
.menus {
margin-bottom: 20px;
}
:deep(.el-pagination) {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.data-list {
margin-bottom: 20px;
}
</style>

View File

@ -0,0 +1,49 @@
<template>
<div v-if="data.length" class="separate-statis-page">
<StatisticsItem v-for="StatisticsData in data" :key="StatisticsData.field" :StatisticsData />
</div>
<div v-else>
<EmptyIndex :data="noDataConfig" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import { noDataConfig } from '@/management/config/analysisConfig'
import EmptyIndex from '@/management/components/EmptyIndex.vue'
import { getStatisticList } from '@/management/api/analysis'
import StatisticsItem from '../components/StatisticsItem.vue'
const route = useRoute()
const data = ref([])
const initData = async () => {
try {
const res = await getStatisticList({
surveyId: route.params.id
})
if (res.code === 200) {
data.value = res?.data || []
} else {
ElMessage.error(res?.errmsg)
}
} catch (error) {
ElMessage.error(error?.message || '查询回收数据失败,请重试')
}
}
onMounted(initData)
</script>
<style lang="scss" scoped>
.separate-statis-page {
height: 100%;
background: #fff;
padding: 0 24px;
overflow-y: auto;
}
</style>

View File

@ -59,8 +59,8 @@ const emit = defineEmits<Emit>()
// //
const formatValue = ({ item, moduleConfig }: any) => { const formatValue = ({ item, moduleConfig }: any) => {
if (_isFunction(item.valueAdapter)) { if (_isFunction(item.valueGetter)) {
const value = item.valueAdapter({ moduleConfig }) const value = item.valueGetter({ moduleConfig })
return value return value
} else { } else {
@ -82,8 +82,8 @@ const init = ref<boolean>(true)
const components = shallowRef<any>({}) const components = shallowRef<any>({})
const handleFormChange = (data: any, formConfig: any) => { const handleFormChange = (data: any, formConfig: any) => {
if (_isFunction(formConfig?.setterAdapter)) { if (_isFunction(formConfig?.valueSetter)) {
const resultData = formConfig.setterAdapter(data) const resultData = formConfig.valueSetter(data)
if (Array.isArray(resultData)) { if (Array.isArray(resultData)) {
resultData.forEach((item) => { resultData.forEach((item) => {

View File

@ -139,7 +139,7 @@ import {
noSearchDataConfig, noSearchDataConfig,
selectOptionsDict, selectOptionsDict,
buttonOptionsDict buttonOptionsDict
} from '../config' } from '@/management/config/listConfig'
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()

View File

@ -15,7 +15,7 @@
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, withDefaults } from 'vue' import { computed } from 'vue'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { type IMember, type ListItem } from '@/management/utils/types/workSpace' import { type IMember, type ListItem } from '@/management/utils/types/workSpace'
import OperationSelect from './OperationSelect.vue' import OperationSelect from './OperationSelect.vue'

View File

@ -22,7 +22,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, withDefaults } from 'vue' import { ref } from 'vue'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import MemberList from './MemberList.vue' import MemberList from './MemberList.vue'
import { getUserList } from '@/management/api/space' import { getUserList } from '@/management/api/space'

View File

@ -75,7 +75,7 @@ import { useStore } from 'vuex'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import 'element-plus/theme-chalk/src/message-box.scss' import 'element-plus/theme-chalk/src/message-box.scss'
import { get, map } from 'lodash-es' import { get, map } from 'lodash-es'
import { spaceListConfig } from '../config' import { spaceListConfig } from '@/management/config/listConfig'
import SpaceModify from './SpaceModify.vue' import SpaceModify from './SpaceModify.vue'
import { UserRole } from '@/management/utils/types/workSpace' import { UserRole } from '@/management/utils/types/workSpace'

View File

@ -6,7 +6,7 @@
</template> </template>
<script> <script>
import { statusMaps } from '../config' import { statusMaps } from '@/management/config/listConfig'
export default { export default {
name: 'StateModule', name: 'StateModule',
props: { props: {

View File

@ -6,7 +6,7 @@
</template> </template>
<script> <script>
import { type as surveyType } from '../config' import { type as surveyType } from '@/management/config/listConfig'
export default { export default {
name: 'TagModule', name: 'TagModule',
props: { props: {

View File

@ -2,6 +2,7 @@ import { createRouter, createWebHistory, type RouteLocationNormalized, type Navi
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { useStore, type Store } from 'vuex' import { useStore, type Store } from 'vuex'
import { SurveyPermissions } from '@/management/utils/types/workSpace' import { SurveyPermissions } from '@/management/utils/types/workSpace'
import { analysisTypeMap } from '@/management/config/analysisConfig'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss' import 'element-plus/theme-chalk/src/message.scss'
@ -100,11 +101,34 @@ const routes: RouteRecordRaw[] = [
{ {
path: '/survey/:id/analysis', path: '/survey/:id/analysis',
name: 'analysisPage', name: 'analysisPage',
redirect: {
name: analysisTypeMap.dataTable
},
meta: { meta: {
needLogin: true, needLogin: true,
permissions: [SurveyPermissions.DataManage] permissions: [SurveyPermissions.DataManage]
}, },
component: () => import('../pages/analysis/AnalysisPage.vue') component: () => import('../pages/analysis/AnalysisPage.vue'),
children: [
{
path: analysisTypeMap.dataTable,
name: analysisTypeMap.dataTable,
meta: {
needLogin: true,
premissions: [SurveyPermissions.DataManage]
},
component: () => import('../pages/analysis/pages/DataTablePage.vue')
},
{
path: analysisTypeMap.separateStatistics,
name: analysisTypeMap.separateStatistics,
meta: {
needLogin: true,
premissions: [SurveyPermissions.DataManage]
},
component: () => import('../pages/analysis/pages/SeparateStatisticsPage.vue')
}
]
}, },
{ {
path: '/survey/:id/publish', path: '/survey/:id/publish',

View File

@ -1,9 +1,10 @@
@font-face { @font-face {
font-family: 'iconfont'; /* Project id 4263849 */ font-family: 'iconfont';
/* Project id 4263849 */
src: src:
url('//at.alicdn.com/t/c/font_4263849_xfsn9z31epc.woff2?t=1716556097756') format('woff2'), url('//at.alicdn.com/t/c/font_4263849_tj4thybmhq.woff2?t=1717580126029') format('woff2'),
url('//at.alicdn.com/t/c/font_4263849_xfsn9z31epc.woff?t=1716556097756') format('woff'), url('//at.alicdn.com/t/c/font_4263849_tj4thybmhq.woff?t=1717580126029') format('woff'),
url('//at.alicdn.com/t/c/font_4263849_xfsn9z31epc.ttf?t=1716556097756') format('truetype'); url('//at.alicdn.com/t/c/font_4263849_tj4thybmhq.ttf?t=1717580126029') format('truetype');
} }
.iconfont { .iconfont {
@ -125,15 +126,39 @@
.icon-erweima:before { .icon-erweima:before {
content: '\e6c0'; content: '\e6c0';
} }
.icon-yangshishezhi:before { .icon-yangshishezhi:before {
content: '\e6e6'; content: '\e6e6';
} }
.icon-NPSpingfen::before { .icon-NPSpingfen::before {
content: '\e6e7'; content: '\e6e7';
} }
.icon-wodekongjian::before { .icon-wodekongjian::before {
content: '\e6ee'; content: '\e6ee';
} }
.icon-tuanduikongjian::before { .icon-tuanduikongjian::before {
content: '\e6ec'; content: '\e6ec';
} }
.icon-shujuliebiao:before {
content: '\e6f2';
}
.icon-fentitongji:before {
content: '\e6f3';
}
.icon-bar:before {
content: '\e600';
}
.icon-pie:before {
content: '\e606';
}
.icon-gauge:before {
content: '\e6db';
}

View File

@ -1,6 +1,7 @@
import { defaultQuestionConfig } from '../config/questionConfig' import { defaultQuestionConfig } from '../config/questionConfig'
import { cloneDeep as _cloneDeep, map as _map } from 'lodash-es' import { map as _map } from 'lodash-es'
import { QUESTION_TYPE } from '@/common/typeEnum.ts' import questionLoader from '@/materials/questions/questionLoader'
const generateQuestionField = () => { const generateQuestionField = () => {
const num = Math.floor(Math.random() * 1000) const num = Math.floor(Math.random() * 1000)
return `data${num}` return `data${num}`
@ -23,15 +24,6 @@ const generateHash = (hashList) => {
return hash return hash
} }
function getOptions(type) {
const options = [].concat({ ..._cloneDeep(defaultQuestionConfig) }.options)
if (type === QUESTION_TYPE.BINARY_CHOICE) {
options[0].text = '对'
options[1].text = '错'
}
return options
}
export const getNewField = (fields) => { export const getNewField = (fields) => {
let field = generateQuestionField() let field = generateQuestionField()
let isFieldExists = fields.includes(field) let isFieldExists = fields.includes(field)
@ -44,16 +36,30 @@ export const getNewField = (fields) => {
} }
export const getQuestionByType = (type, fields) => { export const getQuestionByType = (type, fields) => {
const newQuestion = _cloneDeep(defaultQuestionConfig) const questionMeta = questionLoader.getMeta(type)
const { attrs } = questionMeta
let newQuestion = defaultQuestionConfig
if( attrs ) {
let questionSchema = {}
attrs.forEach(element => {
questionSchema[element.name] = element.defaultValue
});
newQuestion = questionSchema
} else {
newQuestion = defaultQuestionConfig
newQuestion.type = type newQuestion.type = type
newQuestion.field = getNewField(fields) }
newQuestion.options = getOptions(type)
newQuestion.field = getNewField(fields) // 动态生成题目id
if('options ' in newQuestion) { // 动态更新选项的hash-id
const hashList = [] const hashList = []
for (const option of newQuestion.options) { for (const option of newQuestion.options) {
const hash = generateHash(hashList) const hash = generateHash(hashList)
hashList.push(hash) hashList.push(hash)
option.hash = hash option.hash = hash
} }
}
return newQuestion return newQuestion
} }

View File

@ -109,13 +109,13 @@ export default defineComponent({
<EditOptions <EditOptions
moduleConfig={props.moduleConfig} moduleConfig={props.moduleConfig}
editConfigure={questionMeta?.editConfigure} editConfigure={questionMeta?.editConfigure}
onChange={this.onChange}
> >
<dynamicComponent <dynamicComponent
readonly readonly
{...props} {...props}
onBlur={this.onBlur} onBlur={this.onBlur}
onFocus={this.onFocus} onFocus={this.onFocus}
change={this.onChange}
/> />
</EditOptions> </EditOptions>
) : ( ) : (
@ -124,7 +124,6 @@ export default defineComponent({
{...props} {...props}
onBlur={this.onBlur} onBlur={this.onBlur}
onFocus={this.onFocus} onFocus={this.onFocus}
change={this.onChange}
/> />
)} )}
</div> </div>

View File

@ -24,7 +24,7 @@ export default {
tip: '题目下方分割线,仅在移动端展示。' tip: '题目下方分割线,仅在移动端展示。'
} }
], ],
valueAdapter({ moduleConfig }) { valueGetter({ moduleConfig }) {
return _pick( return _pick(
moduleConfig, moduleConfig,
this.options.map((item) => item.key) this.options.map((item) => item.key)

View File

@ -4,6 +4,75 @@ const meta = {
title: '判断题', title: '判断题',
type: 'binary-choice', type: 'binary-choice',
componentName: 'BinaryChoiceModule', componentName: 'BinaryChoiceModule',
attrs: [
{
name: 'field',
propType: 'String',
description: '这是用于描述题目id',
defaultValue: ''
},
{
name: 'title',
propType: 'String',
description: '这是用于描述题目标题',
defaultValue: '标题一'
},
{
name: 'type',
propType: 'String',
description: '这是用于描述题目类型',
defaultValue: 'binary-choice'
},
{
name: 'isRequired',
propType: Boolean,
description: '是否必填',
defaultValue: true
},
{
name: 'showIndex',
propType: Boolean,
description: '显示序号',
defaultValue: true
},
{
name: 'showType',
propType: Boolean,
description: '显示类型',
defaultValue: true
},
{
name: 'showSpliter',
propType: Boolean,
description: '显示分割线',
defaultValue: true
},
{
name: 'options',
propType: Array,
description: '这是用于描述选项',
defaultValue: [
{
"text": "对",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115019"
},
{
"text": "错",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115020"
}
]
},
],
formConfig: [basicConfig], formConfig: [basicConfig],
editConfigure: { editConfigure: {
optionEdit: { optionEdit: {

View File

@ -4,13 +4,93 @@ const meta = {
title: '多选', title: '多选',
type: 'checkbox', type: 'checkbox',
componentName: 'CheckBoxModule', componentName: 'CheckBoxModule',
attrs: [
{
name: 'field',
propType: 'String',
description: '这是用于描述题目id',
defaultValue: ''
},
{
name: 'title',
propType: 'String',
description: '这是用于描述题目标题',
defaultValue: '标题一'
},
{
name: 'type',
propType: 'String',
description: '这是用于描述题目类型',
defaultValue: 'checkbox'
},
{
name: 'isRequired',
propType: Boolean,
description: '是否必填',
defaultValue: true
},
{
name: 'showIndex',
propType: Boolean,
description: '显示序号',
defaultValue: true
},
{
name: 'showType',
propType: Boolean,
description: '显示类型',
defaultValue: true
},
{
name: 'showSpliter',
propType: Boolean,
description: '显示分割线',
defaultValue: true
},
{
name: 'options',
propType: Array,
description: '这是用于描述选项',
defaultValue: [
{
"text": "选项1",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115019"
},
{
"text": "选项2",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115020"
}
]
},
{
name: 'minNum',
propType: Number,
description: '最少选择数',
defaultValue: 0
},
{
name: 'maxNum',
propType: Number,
description: '最多选择数',
defaultValue: 0
}
],
formConfig: [ formConfig: [
basicConfig, basicConfig,
{ {
name: 'optionConfig', name: 'optionConfig',
title: '选项配置', title: '选项配置',
type: 'Customed', type: 'Customed',
key: 'optionConfig',
content: [ content: [
{ {
label: '至少选择数', label: '至少选择数',

View File

@ -43,7 +43,6 @@
</template> </template>
<script> <script>
import { get as _get } from 'lodash-es' import { get as _get } from 'lodash-es'
import { mapGetters } from 'vuex'
export default { export default {
props: { props: {
min: { min: {
@ -80,9 +79,6 @@ export default {
this.initRange() this.initRange()
}, },
computed: { computed: {
...mapGetters({
currentEditKey: 'edit/currentEditKey'
}),
innerVisible: { innerVisible: {
get() { get() {
return this.visible return this.visible
@ -119,9 +115,8 @@ export default {
explain: item.explain explain: item.explain
} }
} }
const paramsKey = `rangeConfig`
const payload = { const payload = {
key: `${this.currentEditKey}.${paramsKey}`, key: `rangeConfig`,
value: res value: res
} }
this.$emit('confirm', payload) this.$emit('confirm', payload)

View File

@ -39,20 +39,15 @@
<script> <script>
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { mapGetters } from 'vuex'
import { cloneDeep as _cloneDeep } from 'lodash-es' import { cloneDeep as _cloneDeep } from 'lodash-es'
import RichEditor from '@/common/Editor/RichEditor.vue' import RichEditor from '@/common/Editor/RichEditor.vue'
export default { export default {
name: 'OptionEdit', name: 'OptionEdit',
computed: {
...mapGetters({
currentEditKey: 'edit/currentEditKey'
})
},
props: { props: {
optionList: { optionList: {
type: Array type: Array,
default: () => []
}, },
isShowOperation: { isShowOperation: {
type: Boolean, type: Boolean,
@ -92,7 +87,7 @@ export default {
handleChange(index, value) { handleChange(index, value) {
// //
const optionKey = `options[${index}].text` const optionKey = `options[${index}].text`
const key = `${this.currentEditKey}.${optionKey}` const key = `${optionKey}`
this.$emit('change', { key, value }) this.$emit('change', { key, value })
}, },
onAddOption(index) { onAddOption(index) {

View File

@ -1,7 +1,5 @@
import { defineComponent, ref, computed, onMounted } from 'vue' import { defineComponent, ref, computed, onMounted } from 'vue'
import store from '@/management/store'
import OptionEdit from './Options/OptionEdit.vue' import OptionEdit from './Options/OptionEdit.vue'
import OptionEditBar from './Options/OptionEditBar.vue' import OptionEditBar from './Options/OptionEditBar.vue'
import UseOptionBase from './Options/UseOptionBase' import UseOptionBase from './Options/UseOptionBase'
@ -10,7 +8,6 @@ export default defineComponent({
name: 'EditOptions', name: 'EditOptions',
provide() { provide() {
return { return {
currentEditKey: store.getters['edit/currentEditKey'],
moduleConfig: computed(() => this.moduleConfig) moduleConfig: computed(() => this.moduleConfig)
} }
}, },
@ -24,10 +21,7 @@ export default defineComponent({
required: true required: true
} }
}, },
setup(props, { slots }) { setup(props, { slots, emit }) {
const currentEditKey = computed(() => {
return store.getters['edit/currentEditKey']
})
const getOptions = computed(() => { const getOptions = computed(() => {
return props.moduleConfig.options return props.moduleConfig.options
}) })
@ -44,12 +38,12 @@ export default defineComponent({
const handleOptionChange = (value) => { const handleOptionChange = (value) => {
const optionKey = `options` const optionKey = `options`
const key = `${currentEditKey.value}.${optionKey}` const key = `${optionKey}`
handleChange({ key, value }) handleChange({ key, value })
} }
const handleChange = ({ key, value }) => { const handleChange = ({ key, value }) => {
store.dispatch('edit/changeSchema', { key, value }) emit('change', { key, value })
} }
const hasAdvancedConfig = ref(false) const hasAdvancedConfig = ref(false)

View File

@ -4,6 +4,93 @@ export const meta = {
title: '单行输入框', title: '单行输入框',
type: 'text', type: 'text',
componentName: 'InputModule', componentName: 'InputModule',
attrs: [
{
name: 'field',
propType: 'String',
description: '这是用于描述题目id',
defaultValue: ''
},
{
name: 'title',
propType: 'String',
description: '这是用于描述题目标题',
defaultValue: '标题一'
},
{
name: 'type',
propType: 'String',
description: '这是用于描述题目类型',
defaultValue: 'text'
},
{
name: 'isRequired',
propType: Boolean,
description: '是否必填',
defaultValue: true
},
{
name: 'showIndex',
propType: Boolean,
description: '显示序号',
defaultValue: true
},
{
name: 'showType',
propType: Boolean,
description: '显示类型',
defaultValue: true
},
{
name: 'showSpliter',
propType: Boolean,
description: '显示分割线',
defaultValue: true
},
{
name: 'placeholder',
propType: String,
description: '这是用于描述引导提示文案',
defaultValue: ''
},
{
name: 'valid',
propType: String,
description: '这是用于描述内容限制格式',
defaultValue: ''
},
{
name: 'numberRange',
propType: Object,
description: '这是用于数字限制',
defaultValue: {
max: {
placeholder: '1000',
value: 1000
},
min: {
placeholder: '0',
value: 0
}
}
},
{
name: 'textRange',
propType: Object,
description: '这是用于字数限制',
defaultValue: {
max: {
placeholder: '500',
value: 500
},
min: {
placeholder: '0',
value: 0
}
}
}
],
formConfig: [ formConfig: [
basicConfig, basicConfig,
{ {
@ -42,28 +129,14 @@ export const meta = {
name: 'numberRange', name: 'numberRange',
title: '数字限制', title: '数字限制',
type: 'RangeSetter', type: 'RangeSetter',
options: [],
key: 'numberRange', key: 'numberRange',
value: [], value: [],
cleanKeys: {
numberRange: {
min: {
placeholder: '0',
value: 0
},
max: {
placeholder: '1000',
value: 1000
}
}
},
relyFunc: (data) => data.valid && data.valid === 'n' relyFunc: (data) => data.valid && data.valid === 'n'
}, },
{ {
name: 'textRange', name: 'textRange',
title: '字数限制', title: '字数限制',
type: 'RangeSetter', type: 'RangeSetter',
options: [],
key: 'textRange', key: 'textRange',
value: [] value: []
}, },

View File

@ -121,7 +121,8 @@ export default defineComponent({
max, max,
readonly, readonly,
rangeConfig, rangeConfig,
onMoreDataChange onMoreDataChange,
selectMoreView
} = this } = this
return ( return (
@ -139,7 +140,7 @@ export default defineComponent({
iconClass="number" iconClass="number"
onChange={confirmNps} onChange={confirmNps}
class={!readonly ? 'radio-nps-hover' : ''} class={!readonly ? 'radio-nps-hover' : ''}
> />
{isShowInput && ( {isShowInput && (
<selectMoreView <selectMoreView
showTitle={false} showTitle={false}
@ -153,7 +154,6 @@ export default defineComponent({
onChange={(e) => onMoreDataChange(e)} onChange={(e) => onMoreDataChange(e)}
></selectMoreView> ></selectMoreView>
)} )}
</BaseRate>
</div> </div>
) )
} }

View File

@ -2,9 +2,84 @@ import { ElMessage } from 'element-plus'
import basicConfig from '@materials/questions/common/config/basicConfig' import basicConfig from '@materials/questions/common/config/basicConfig'
const meta = { const meta = {
title: '评分', title: 'nps评分',
type: 'radio-nps', type: 'radio-nps',
componentName: 'NpsModule', componentName: 'NpsModule',
attrs: [
{
name: 'field',
propType: 'String',
description: '这是用于描述题目id',
defaultValue: ''
},
{
name: 'title',
propType: 'String',
description: '这是用于描述题目标题',
defaultValue: '标题一'
},
{
name: 'type',
propType: 'String',
description: '这是用于描述题目类型',
defaultValue: 'radio-nps'
},
{
name: 'isRequired',
propType: Boolean,
description: '是否必填',
defaultValue: true
},
{
name: 'showIndex',
propType: Boolean,
description: '显示序号',
defaultValue: true
},
{
name: 'showType',
propType: Boolean,
description: '显示类型',
defaultValue: true
},
{
name: 'showSpliter',
propType: Boolean,
description: '显示分割线',
defaultValue: true
},
{
name: 'min',
propType: Number,
description: '这是用于描述NPS量表最小值',
defaultValue: 1
},
{
name: 'max',
propType: Number,
description: '这是用于描述NPS量表最大值',
defaultValue: 10
},
{
name: 'minMsg',
propType: String,
description: '这是用于描述最小值文案',
defaultValue: '极不满意'
},
{
name: 'maxMsg',
propType: String,
description: '这是用于描述最大值文案',
defaultValue: '十分满意'
},
{
name: 'rangeConfig',
propType: Object,
description: '这是用于描述评分高级设置',
defaultValue: {}
}
],
formConfig: [ formConfig: [
basicConfig, basicConfig,
{ {
@ -17,11 +92,12 @@ const meta = {
value: v, value: v,
label: v label: v
})), })),
valueSetter: (val, moduleConfig) => { validate: (val, moduleConfig) => {
if (moduleConfig['max'] && val >= moduleConfig['max']) { if (moduleConfig['max'] && val >= moduleConfig['max']) {
ElMessage.info('最小值不可大于最大值') ElMessage.info('最小值不可大于最大值')
return true return false
} }
return true
} }
}, },
{ {
@ -34,11 +110,12 @@ const meta = {
value: v, value: v,
label: v label: v
})), })),
valueSetter: (val, moduleConfig) => { validate: (val, moduleConfig) => {
if (moduleConfig['min'] && val <= moduleConfig['min']) { if (moduleConfig['min'] && val <= moduleConfig['min']) {
ElMessage.info('最大值不可小于最小值') ElMessage.info('最大值不可小于最小值')
return true return false
} }
return true
} }
}, },
{ {

View File

@ -18,10 +18,8 @@
} }
} }
} }
@media (max-width: 930px) { .question-block {
:deep(.question-block) { padding: 0!important;
padding: 0;
}
} }
.radio-nps-hover { .radio-nps-hover {
.rate-item { .rate-item {

View File

@ -4,7 +4,13 @@ const meta = {
title: '单选', title: '单选',
type: 'radio', type: 'radio',
componentName: 'RadioModule', componentName: 'RadioModule',
props: [ attrs: [
{
name: 'field',
propType: 'String',
description: '这是用于描述题目id',
defaultValue: ''
},
{ {
name: 'title', name: 'title',
propType: 'String', propType: 'String',
@ -15,14 +21,57 @@ const meta = {
name: 'type', name: 'type',
propType: 'String', propType: 'String',
description: '这是用于描述题目类型', description: '这是用于描述题目类型',
defaultValue: '标题一' defaultValue: 'radio'
}, },
{ {
name: 'extraOptions', name: 'isRequired',
propType: Boolean,
description: '是否必填',
defaultValue: true
},
{
name: 'showIndex',
propType: Boolean,
description: '显示序号',
defaultValue: true
},
{
name: 'showType',
propType: Boolean,
description: '显示类型',
defaultValue: true
},
{
name: 'showSpliter',
propType: Boolean,
description: '显示分割线',
defaultValue: true
},
{
name: 'options',
propType: Array, propType: Array,
description: '这是用于固定选项配置', description: '这是用于描述选项',
defaultValue: [] defaultValue: [
{
"text": "选项1",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115019"
},
{
"text": "选项2",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115020"
} }
]
},
], ],
formConfig: [ formConfig: [
basicConfig, basicConfig,

View File

@ -4,6 +4,74 @@ const meta = {
title: '评分', title: '评分',
type: 'radio-star', type: 'radio-star',
componentName: 'StarModule', componentName: 'StarModule',
attrs: [
{
name: 'field',
propType: 'String',
description: '这是用于描述题目id',
defaultValue: ''
},
{
name: 'title',
propType: 'String',
description: '这是用于描述题目标题',
defaultValue: '标题一'
},
{
name: 'type',
propType: 'String',
description: '这是用于描述题目类型',
defaultValue: 'radio-star'
},
{
name: 'isRequired',
propType: Boolean,
description: '是否必填',
defaultValue: true
},
{
name: 'showIndex',
propType: Boolean,
description: '显示序号',
defaultValue: true
},
{
name: 'showType',
propType: Boolean,
description: '显示类型',
defaultValue: true
},
{
name: 'showSpliter',
propType: Boolean,
description: '显示分割线',
defaultValue: true
},
{
name: 'starMin',
propType: Number,
description: '这是用于描述评分最小值',
defaultValue: 1
},
{
name: 'starMax',
propType: Number,
description: '这是用于描述评分最大值',
defaultValue: 5
},
{
name: 'starStyle',
propType: String,
description: '',
defaultValue: 'star',
},
{
name: 'rangeConfig',
propType: Object,
description: '这是用于描述评分高级设置',
defaultValue: {}
},
],
formConfig: [ formConfig: [
basicConfig, basicConfig,
{ {

View File

@ -4,6 +4,92 @@ const meta = {
title: '多行输入框', title: '多行输入框',
type: 'textarea', type: 'textarea',
componentName: 'TextareaModule', componentName: 'TextareaModule',
attrs: [
{
name: 'field',
propType: 'String',
description: '这是用于描述题目id',
defaultValue: ''
},
{
name: 'title',
propType: 'String',
description: '这是用于描述题目标题',
defaultValue: '标题一'
},
{
name: 'type',
propType: 'String',
description: '这是用于描述题目类型',
defaultValue: 'textarea'
},
{
name: 'isRequired',
propType: Boolean,
description: '是否必填',
defaultValue: true
},
{
name: 'showIndex',
propType: Boolean,
description: '显示序号',
defaultValue: true
},
{
name: 'showType',
propType: Boolean,
description: '显示类型',
defaultValue: true
},
{
name: 'showSpliter',
propType: Boolean,
description: '显示分割线',
defaultValue: true
},
{
name: 'placeholder',
propType: String,
description: '这是用于描述引导提示文案',
defaultValue: ''
},
{
name: 'valid',
propType: String,
description: '这是用于描述内容限制格式',
defaultValue: ''
},
{
name: 'numberRange',
propType: Object,
description: '这是用于数字限制',
defaultValue: {
max: {
placeholder: '1000',
value: 1000
},
min: {
placeholder: '0',
value: 0
}
}
},
{
name: 'textRange',
propType: Object,
description: '这是用于字数限制',
defaultValue: {
max: {
placeholder: '500',
value: 500
},
min: {
placeholder: '0',
value: 0
}
}
}
],
formConfig: [ formConfig: [
basicConfig, basicConfig,
{ {
@ -42,28 +128,14 @@ const meta = {
name: 'numberRange', name: 'numberRange',
title: '数字限制', title: '数字限制',
type: 'RangeSetter', type: 'RangeSetter',
options: [],
key: 'numberRange', key: 'numberRange',
value: [], value: [],
cleanKeys: {
numberRange: {
min: {
placeholder: '0',
value: 0
},
max: {
placeholder: '1000',
value: 1000
}
}
},
relyFunc: (data) => data.valid && data.valid === 'n' relyFunc: (data) => data.valid && data.valid === 'n'
}, },
{ {
name: 'textRange', name: 'textRange',
title: '字数限制', title: '字数限制',
type: 'RangeSetter', type: 'RangeSetter',
options: [],
key: 'textRange', key: 'textRange',
value: [] value: []
}, },

View File

@ -4,6 +4,87 @@ const meta = {
title: '投票', title: '投票',
type: 'vote', type: 'vote',
componentName: 'VoteModule', componentName: 'VoteModule',
attrs: [
{
name: 'field',
propType: 'String',
description: '这是用于描述题目id',
defaultValue: ''
},
{
name: 'title',
propType: 'String',
description: '这是用于描述题目标题',
defaultValue: '标题一'
},
{
name: 'type',
propType: 'String',
description: '这是用于描述题目类型',
defaultValue: 'vote'
},
{
name: 'isRequired',
propType: Boolean,
description: '是否必填',
defaultValue: true
},
{
name: 'showIndex',
propType: Boolean,
description: '显示序号',
defaultValue: true
},
{
name: 'showType',
propType: Boolean,
description: '显示类型',
defaultValue: true
},
{
name: 'showSpliter',
propType: Boolean,
description: '显示分割线',
defaultValue: true
},
{
name: 'options',
propType: Array,
description: '这是用于描述选项',
defaultValue: [
{
"text": "选项1",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115019"
},
{
"text": "选项2",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115020"
}
]
},
{
name: 'minNum',
propType: Number,
description: '最少选择数',
defaultValue: 1
},
{
name: 'maxNum',
propType: Number,
description: '最多选择数',
defaultValue: 1
}
],
formConfig: [ formConfig: [
basicConfig, basicConfig,
{ {
@ -18,7 +99,7 @@ const meta = {
key: 'innerType', key: 'innerType',
value: false, value: false,
// 输入转换 // 输入转换
valueAdapter({ moduleConfig }) { valueGetter({ moduleConfig }) {
if (moduleConfig.innerType === 'checkbox') { if (moduleConfig.innerType === 'checkbox') {
return true return true
} else { } else {
@ -26,7 +107,7 @@ const meta = {
} }
}, },
// 输出转换 // 输出转换
setterAdapter({ value }) { valueSetter({ value }) {
return { return {
key: 'innerType', key: 'innerType',
value: value ? 'checkbox' : 'radio' value: value ? 'checkbox' : 'radio'

View File

@ -11,7 +11,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss' import 'element-plus/theme-chalk/src/message.scss'
import { QUESTION_TYPE } from '@/common/typeEnum'
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant' import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
interface Props { interface Props {
@ -25,18 +24,11 @@ interface Emit {
const emit = defineEmits<Emit>() const emit = defineEmits<Emit>()
const props = defineProps<Props>() const props = defineProps<Props>()
const setterTypes = [QUESTION_TYPE.CHECKBOX, QUESTION_TYPE.VOTE] const modelValue = ref(Number(props.formConfig.value) || 0)
const modelValue = ref(props.formConfig.value || 0)
const minModelValue = computed(() => { const minModelValue = computed(() => {
const { min } = props.formConfig const { min } = props.formConfig
const { type } = props.moduleConfig if (min) {
if (typeof min === 'function') {
if (min !== undefined) {
if (typeof min === 'string') {
return setterTypes.includes(type)
? Number(props.moduleConfig[min])
: Number(Number(props.moduleConfig[min]) + 1)
} else if (typeof props.formConfig.min === 'function') {
return min(props.moduleConfig) return min(props.moduleConfig)
} else { } else {
return Number(min) return Number(min)
@ -46,18 +38,14 @@ const minModelValue = computed(() => {
}) })
const maxModelValue = computed(() => { const maxModelValue = computed(() => {
const { type } = props.moduleConfig
const { max, min } = props.formConfig const { max, min } = props.formConfig
if (max) { if (max) {
if (typeof max === 'string') { if (typeof max === 'function') {
return setterTypes.includes(type)
? Number(props.moduleConfig[max])
: props.moduleConfig[max] - 1
} else if (typeof max === 'function') {
return max(props.moduleConfig) return max(props.moduleConfig)
} } else {
return Number(max) return Number(max)
}
} else if (min !== undefined && Array.isArray(props.moduleConfig?.options)) { } else if (min !== undefined && Array.isArray(props.moduleConfig?.options)) {
return props.moduleConfig.options.length return props.moduleConfig.options.length
} else { } else {

View File

@ -33,25 +33,15 @@ const emit = defineEmits<Emit>()
const props = defineProps<Props>() const props = defineProps<Props>()
const minModelValue = computed(() => { const minModelValue = computed(() => {
const key = props.formConfig.key
const minValue = props.formConfig?.value?.min?.value const minValue = props.formConfig?.value?.min?.value
if (key === 'textRange') {
return parseInt(minValue) return parseInt(minValue)
}
return minValue || 1
}) })
const maxModelValue = computed(() => { const maxModelValue = computed(() => {
const key = props.formConfig.key
const maxValue = props.formConfig?.value?.max?.value const maxValue = props.formConfig?.value?.max?.value
if (key === 'textRange') { return maxValue ? parseInt(maxValue) : 1
return parseInt(maxValue)
}
return maxValue || 1
}) })
const handleRangeChange = (eventType: 'max' | 'min', value: number) => { const handleRangeChange = (eventType: 'max' | 'min', value: number) => {

View File

@ -61,10 +61,10 @@ const modelValue = ref(
) )
const handleSelectChange = (value: string) => { const handleSelectChange = (value: string) => {
const { key, valueSetter } = props.formConfig const { key, validate } = props.formConfig
if (valueSetter && typeof valueSetter == 'function') { if (validate && typeof validate == 'function') {
let verification: boolean = valueSetter(value, props.moduleConfig) let verification: boolean = validate(value, props.moduleConfig)
if (!verification) { if (!verification) {
return return
@ -72,7 +72,6 @@ const handleSelectChange = (value: string) => {
modelValue.value = props.moduleConfig[key] modelValue.value = props.moduleConfig[key]
} }
emit(FORM_CHANGE_EVENT_KEY, { key, value }) emit(FORM_CHANGE_EVENT_KEY, { key, value })
} }

View File

@ -18,7 +18,7 @@ export default function ({ dataConf }) {
// } // }
// 题型是多选或者子题型是多选innerType是用于投票 // 题型是多选或者子题型是多选innerType是用于投票
if (/checkbox/.test(type) || innerType === QUESTION_TYPE.CHECKBOX) { if (type === QUESTION_TYPE.CHECKBOX || innerType === QUESTION_TYPE.CHECKBOX) {
value = value ? [value] : [] value = value ? [value] : []
} }
formValues[key] = value formValues[key] = value

View File

@ -2,26 +2,7 @@
* 处理单题的配置 * 处理单题的配置
*/ */
import { get as _get, map as _map } from 'lodash-es' import { get as _get } from 'lodash-es'
import { QUESTION_TYPE } from '@/common/typeEnum.ts'
// 处理选择题的options
function handleOptions(item) {
const { type } = item
const options = item.options || []
const arr = _map(options, (optionItem) => {
const cleanOption = {}
// 投票逻辑处理
if (type.indexOf(QUESTION_TYPE.VOTE) > -1) {
cleanOption.voteCount = 0
}
return { value: optionItem['hash'], ...optionItem, ...cleanOption }
})
return { options: arr }
}
export default function (questionConfig) { export default function (questionConfig) {
let dataList = _get(questionConfig, 'dataConf.dataList') let dataList = _get(questionConfig, 'dataConf.dataList')
@ -31,8 +12,7 @@ export default function (questionConfig) {
[item.field]: { [item.field]: {
indexNumber: '', indexNumber: '',
voteTotal: 0, voteTotal: 0,
...item, ...item
...handleOptions(item)
} }
}) })
return pre return pre

View File

@ -195,17 +195,19 @@ export function generateValidArr(
// 生成选择类或者评分类的题目的更多输入框 // 生成选择类或者评分类的题目的更多输入框
const generateOthersKeyMap = (question) => { const generateOthersKeyMap = (question) => {
const { type, field, options, rangeConfig } = question const { type, field } = question
let othersKeyMap = undefined let othersKeyMap = undefined
if (RATES.includes(type)) { if (RATES.includes(type)) {
const { rangeConfig } = question
othersKeyMap = {} othersKeyMap = {}
for (const key in rangeConfig) { for (const key in rangeConfig) {
if (rangeConfig[key].isShowInput) { if (rangeConfig[key].isShowInput) {
othersKeyMap[`${field}_${key}`] = key othersKeyMap[`${field}_${key}`] = key
} }
} }
} else if (type.includes(QUESTION_TYPE.RADIO) || type.includes(QUESTION_TYPE.CHECKBOX)) { } else if (type?.includes(QUESTION_TYPE.RADIO) || type?.includes(QUESTION_TYPE.CHECKBOX)) {
const { options } = question
othersKeyMap = {} othersKeyMap = {}
options options
.filter((op) => op.others) .filter((op) => op.others)

View File

@ -38,7 +38,7 @@ const formValues = computed(() => {
}) })
const questionConfig = computed(() => { const questionConfig = computed(() => {
let moduleConfig = props.moduleConfig let moduleConfig = props.moduleConfig
const { type, field, options, ...rest } = cloneDeep(moduleConfig) const { type, field, options = [], ...rest } = cloneDeep(moduleConfig)
// console.log(field,'formValuechange') // console.log(field,'formValuechange')
let alloptions = options let alloptions = options
if (type === QUESTION_TYPE.VOTE || NORMAL_CHOICES.includes(type)) { if (type === QUESTION_TYPE.VOTE || NORMAL_CHOICES.includes(type)) {
@ -57,8 +57,8 @@ const questionConfig = computed(() => {
moduleConfig.othersValue = unref(othersValue) moduleConfig.othersValue = unref(othersValue)
} }
if ( if (
RATES.includes(type) && RATES.includes(type) && rest?.rangeConfig &&
Object.keys(rest.rangeConfig).filter((index) => rest.rangeConfig[index].isShowInput).length > 0 Object.keys(rest?.rangeConfig).filter((index) => rest?.rangeConfig[index].isShowInput).length > 0
) { ) {
let { rangeConfig, othersValue } = useShowInput(field) let { rangeConfig, othersValue } = useShowInput(field)
moduleConfig.rangeConfig = unref(rangeConfig) moduleConfig.rangeConfig = unref(rangeConfig)

View File

@ -15,8 +15,10 @@
if (!/Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent)) { if (!/Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent)) {
width = width < PC_W ? width : PC_W width = width < PC_W ? width : PC_W
if (!docEl.className.includes('ispc-html')) {
docEl.className += ' ispc-html' docEl.className += ' ispc-html'
} }
}
var f = Math.min(width / 7.5, 50) var f = Math.min(width / 7.5, 50)
docEl.style.fontSize = f + 'px' docEl.style.fontSize = f + 'px'

View File

@ -62,6 +62,8 @@ export default {
return return
} }
} }
router.push({ name: 'renderPage' })
//回填,断点续填 //回填,断点续填
const localData = JSON.parse(localStorage.getItem(state.surveyPath + "_questionData")) const localData = JSON.parse(localStorage.getItem(state.surveyPath + "_questionData"))

View File

@ -2,7 +2,9 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig, normalizePath } from 'vite' import { defineConfig, normalizePath } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx' import vueJsx from '@vitejs/plugin-vue-jsx'
import { createMpaPlugin, createPages } from 'vite-plugin-virtual-mpa' import { createMpaPlugin, createPages } from 'vite-plugin-virtual-mpa'
import AutoImport from 'unplugin-auto-import/vite' import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
import Icons from 'unplugin-icons/vite' import Icons from 'unplugin-icons/vite'
@ -116,6 +118,31 @@ export default defineConfig({
} }
}, },
build: { build: {
rollupOptions: {} rollupOptions: {
output: {
assetFileNames: '[ext]/[name]-[hash].[ext]',
chunkFileNames: 'js/[name]-[hash].js',
entryFileNames: 'js/[name]-[hash].js',
manualChunks(id) {
// 建议根据项目生产实际情况进行优化部分可走cdn或进行小资源包合并
if (id.includes('element-plus')) {
return 'element-plus'
}
if (id.includes('wangeditor')) {
return 'wangeditor'
}
if (id.includes('node-forg')) {
return 'node-forg'
}
if (id.includes('echarts')) {
return 'echarts'
}
if (id.includes('node_modules')) {
return 'packages'
}
}
}
}
} }
}) })