diff --git a/.github/workflows/server-lint.yml b/.github/workflows/server-lint.yml index 8e1634a2..17cabfe3 100644 --- a/.github/workflows/server-lint.yml +++ b/.github/workflows/server-lint.yml @@ -37,6 +37,3 @@ jobs: - name: Lint run: cd server && npm run lint - - - name: Format - run: cd server && npm run format diff --git a/.github/workflows/web-lint.yml b/.github/workflows/web-lint.yml index ab1b576e..4af9aa2e 100644 --- a/.github/workflows/web-lint.yml +++ b/.github/workflows/web-lint.yml @@ -40,6 +40,3 @@ jobs: - name: Lint run: cd web && npm run lint - - - name: Format - run: cd web && npm run format diff --git a/.gitignore b/.gitignore index a35571b2..e0d3138e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ pnpm-debug.log* *.sw? .history +components.d.ts + +# 默认的上传文件夹 +userUpload diff --git a/README.md b/README.md index a9071bc9..02093549 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,10 @@
-  **XIAOJUSURVEY**是一套轻量、安全的**问卷系统基座**,提供面向个人和企业的一站式产品级解决方案,快速满足各类线上调研场景。 +  **XIAOJUSURVEY**是一套轻量、安全的问卷系统,提供面向个人和企业的一站式产品级解决方案,用于构建各类问卷、考试、测评和复杂表单,快速满足各类线上调研场景。   内部系统已沉淀 40+种题型,累积精选模板 100+,适用于市场调研、客户满意度调研、在线考试、投票、报道、测评等众多场景。数据能力上,经过上亿量级打磨,沉淀了分题统计、交叉分析、多渠道分析等在线报表能力,快速满足专业化分析。 -  开源项目以打造**调研基座**为核心,围绕**平台能力**、**工程架构**、**研发体系**进行建设,大家可以「快速」打造「专属」问卷系统:[快速了解生态发展理念](https://xiaojusurvey.didi.cn/docs/next/community/%E7%94%9F%E6%80%81%E5%BB%BA%E8%AE%BE) - # 功能简介 - 问卷管理:创、编、投、收、数据分析 @@ -45,7 +43,7 @@ - 数据安全:传输加密、脱敏等 -> 更全的建设请查阅 [官方 Feature](https://github.com/didi/xiaoju-survey/issues/45) +> 更全的建设请查阅 [功能介绍](https://xiaojusurvey.didi.cn/docs/next/document/%E4%BA%A7%E5%93%81%E6%89%8B%E5%86%8C/%E5%8A%9F%E8%83%BD%E4%BB%8B%E7%BB%8D/%E5%9F%BA%E7%A1%80%E6%B5%81%E7%A8%8B) @@ -144,13 +142,11 @@ npm run local ### 方案二、(生产推荐) -#### 1、启动数据库 +#### 1、配置数据库 -> 项目使用 MongoDB:[MongoDB 安装指导](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93#%E5%AE%89%E8%A3%85) +> 项目使用 MongoDB,需要提前准备,请查看[如何拥有 MongoDB 指南](./数据库#安装) -- 配置数据库,查看[MongoDB 配置](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93) - -- 启动本地数据库,查看[MongoDB 启动](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93#%E4%BA%94%E5%90%AF%E5%8A%A8) +配置数据库信息,查看[MongoDB 配置](./数据库)。 #### 2、安装依赖 @@ -220,16 +216,10 @@ npm run serve 如果你想成为贡献者或者扩展技术栈,请查看:[贡献者指南](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),你的加入使我们最大的荣幸。 -## Feature +## Future Tasks -关注每周推出的建设:[官方 Feature](https://github.com/didi/xiaoju-survey/issues/45) +[欢迎共建](https://github.com/didi/xiaoju-survey/issues/85) ## CHANGELOG 关注重大变更:[MAJOR CHANGELOG](https://github.com/didi/xiaoju-survey/issues/48) - -## 文章分享 - -1、[掘金](https://juejin.cn/user/3705833332160473/posts)、2、[InfoQ](https://www.infoq.cn/profile/7E08AC616A07B2/publish) - -[欢迎投稿](https://xiaojusurvey.didi.cn/docs/next/article/%E7%AE%80%E4%BB%8B) diff --git a/README_EN.md b/README_EN.md index 644fdf4e..84902ad4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -29,12 +29,10 @@
-  XIAOJUSURVEY is a lightweight, secure questionnaire system foundation that provides one-stop product-level solutions for individuals and enterprises, quickly meeting various online survey scenarios. +  XIAOJUSURVEY is an open-source form builder and analytics platform to create questionnaires, exams, polls, quizzes, and analyze data online.   The internal system has accumulated over 40 question types and more than 100 selected templates, suitable for market research, customer satisfaction surveys, online exams, voting, reporting, evaluations, and many other scenarios. In terms of data capabilities, it has been honed through hundreds of millions of iterations, resulting in the ability to provide online reports with per-question statistics, cross-analysis, and multi-channel analysis, quickly meeting professional analysis needs. -  The open-source project focuses on building a survey foundation, constructing around platform capabilities, engineering structure, and development systems, allowing everyone to 「quickly」 create their own 「exclusive」 questionnaire system: [quickly understanding the ecological development philosophy](https://xiaojusurvey.didi.cn/docs/next/community/%E7%94%9F%E6%80%81%E5%BB%BA%E8%AE%BE). - # Function Overview - Questionnaire Management: Create, edit, distribute, collect, data analysis. @@ -45,7 +43,7 @@ - Data Security: Encrypted transmission, data masking, etc. -> For more comprehensive features, please refer to the official Feature documentation. +> For more comprehensive features, please refer to the [documentation](https://xiaojusurvey.didi.cn/docs/next/document/%E4%BA%A7%E5%93%81%E6%89%8B%E5%86%8C/%E5%8A%9F%E8%83%BD%E4%BB%8B%E7%BB%8D/%E5%9F%BA%E7%A1%80%E6%B5%81%E7%A8%8B). @@ -145,12 +143,11 @@ npm run local ### Option 2: (Recommended for Production) -#### 1.Start Database +#### 1.Configure Database -> The project uses MongoDB: [MongoDB Installation Guide](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93#%E5%AE%89%E8%A3%85) +> The project uses MongoDB: [MongoDB Guide](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93#%E5%AE%89%E8%A3%85) -- Configure the database, check MongoDB configuration. -- Start local database, check MongoDB startup. +Configure the database, check MongoDB configuration. #### 2.Install Dependencies @@ -218,16 +215,11 @@ If you use this project, please leave feedback:[I'm using](https://github.com/di If you want to become a contributor or expand your technical stack, please check: [Contributor Guide](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). Your participation is our greatest honor. -## Feature +## Future Tasks -Pay attention to weekly construction updates: [Official Feature](https://github.com/didi/xiaoju-survey/issues/45) +1. [Official Feature](https://github.com/didi/xiaoju-survey/issues/45) +2. [WIP](https://github.com/didi/xiaoju-survey/labels/WIP) ## CHANGELOG Follow major changes: [MAJOR CHANGELOG](https://github.com/didi/xiaoju-survey/issues/48) - -## Article Sharing - -1、[JueJin](https://juejin.cn/user/3705833332160473/posts)、2、[InfoQ](https://www.infoq.cn/profile/7E08AC616A07B2/publish) - -[Welcome to contribute.](https://xiaojusurvey.didi.cn/docs/next/article/%E7%AE%80%E4%BB%8B) diff --git a/docker-compose.yaml b/docker-compose.yaml index 261fd8d0..33e37dbf 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -15,7 +15,7 @@ services: - xiaoju-survey xiaoju-survey: - image: "xiaojusurvey/xiaoju-survey:1.1.2-slim" + image: "xiaojusurvey/xiaoju-survey:1.1.6-slim" # 最新版本:https://hub.docker.com/r/xiaojusurvey/xiaoju-survey/tags container_name: xiaoju-survey restart: always ports: diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 34854cdf..30e5d7fc 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -51,6 +51,12 @@ http { location /api { proxy_pass http://127.0.0.1:3000; } + + # 静态文件的默认存储文件夹 + # 文件夹的配置在 server/src/modules/file/config/index.ts SERVER_LOCAL_CONFIG.FILE_KEY_PREFIX + location /userUpload { + proxy_pass http://127.0.0.1:3000; + } error_page 500 502 503 504 /500.html; client_max_body_size 20M; diff --git a/server/package.json b/server/package.json index 408c2552..7f2bbe69 100644 --- a/server/package.json +++ b/server/package.json @@ -63,8 +63,8 @@ "@types/node": "^20.3.1", "@types/node-forge": "^1.3.11", "@types/supertest": "^2.0.12", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", + "@typescript-eslint/eslint-plugin": "^7.16.0", + "@typescript-eslint/parser": "^7.16.0", "cross-env": "^7.0.3", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", @@ -78,7 +78,7 @@ "ts-loader": "^9.4.3", "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", - "typescript": "^5.1.3" + "typescript": "^5.5.3" }, "jest": { "moduleFileExtensions": [ diff --git a/server/src/enums/exceptionCode.ts b/server/src/enums/exceptionCode.ts index 30d943ba..85cffaa7 100644 --- a/server/src/enums/exceptionCode.ts +++ b/server/src/enums/exceptionCode.ts @@ -12,6 +12,7 @@ export enum EXCEPTION_CODE { SURVEY_NOT_FOUND = 3004, // 问卷不存在 SURVEY_CONTENT_NOT_ALLOW = 3005, // 存在禁用内容 CAPTCHA_INCORRECT = 4001, // 验证码不正确 + WHITELIST_ERROR = 4002, // 白名单校验错误 RESPONSE_SIGN_ERROR = 9001, // 签名不正确 RESPONSE_CURRENT_TIME_NOT_ALLOW = 9002, // 当前时间不允许提交 diff --git a/server/src/enums/question.ts b/server/src/enums/question.ts new file mode 100644 index 00000000..e55b0c46 --- /dev/null +++ b/server/src/enums/question.ts @@ -0,0 +1,37 @@ +/** + * @description 问卷题目类型 + */ +export enum QUESTION_TYPE { + /** + * 单行输入框 + */ + TEXT = 'text', + /** + * 多行输入框 + */ + TEXTAREA = 'textarea', + /** + * 单项选择 + */ + RADIO = 'radio', + /** + * 多项选择 + */ + CHECKBOX = 'checkbox', + /** + * 判断题 + */ + BINARY_CHOICE = 'binary-choice', + /** + * 评分 + */ + RADIO_STAR = 'radio-star', + /** + * nps评分 + */ + RADIO_NPS = 'radio-nps', + /** + * 投票 + */ + VOTE = 'vote', +} diff --git a/server/src/interfaces/survey.ts b/server/src/interfaces/survey.ts index 25729dca..d683dc7b 100644 --- a/server/src/interfaces/survey.ts +++ b/server/src/interfaces/survey.ts @@ -97,6 +97,23 @@ export interface SubmitConf { msgContent: MsgContent; } +// 白名单类型 +export enum WhitelistType { + ALL = 'ALL', + // 空间成员 + MEMBER = 'MEMBER', + // 自定义 + CUSTOM = 'CUSTOM', +} + +// 白名单用户类型 +export enum MemberType { + // 手机号 + MOBILE = 'MOBILE', + // 邮箱 + EMAIL = 'EMAIL', +} + export interface BaseConf { begTime: string; endTime: string; @@ -104,6 +121,18 @@ export interface BaseConf { answerEndTime: string; tLimit: number; language: string; + // 访问密码开关 + passwordSwitch?: boolean; + // 密码 + password?: string | null; + // 白名单类型 + whitelistType?: WhitelistType; + // 白名单用户类型 + memberType?: MemberType; + // 白名单列表 + whitelist?: string[]; + // 提示语 + whitelistTip?: string; } export interface SkinConf { diff --git a/server/src/models/surveyResponse.entity.ts b/server/src/models/surveyResponse.entity.ts index adce23d3..fce0bfd1 100644 --- a/server/src/models/surveyResponse.entity.ts +++ b/server/src/models/surveyResponse.entity.ts @@ -14,7 +14,7 @@ export class SurveyResponse extends BaseEntity { data: Record; @Column() - difTime: number; + diffTime: number; @Column() clientTime: number; diff --git a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts index a2ca640f..5b9b5d82 100644 --- a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts +++ b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts @@ -106,12 +106,13 @@ describe('DataStatisticController', () => { field: 'xxx', title: 'xxx', type: 'xxx', + diffTime: 'xxx', othersCode: 'xxx', }, ], listBody: [ - { difTime: '0.5', createDate: '2024-02-11' }, - { difTime: '0.5', createDate: '2024-02-11' }, + { diffTime: '0.5', createDate: '2024-02-11' }, + { diffTime: '0.5', createDate: '2024-02-11' }, ], }; @@ -151,12 +152,13 @@ describe('DataStatisticController', () => { field: 'xxx', title: 'xxx', type: 'xxx', + diffTime: 'xxx', othersCode: 'xxx', }, ], listBody: [ - { difTime: '0.5', createDate: '2024-02-11', data123: '15200000000' }, - { difTime: '0.5', createDate: '2024-02-11', data123: '13800000000' }, + { diffTime: '0.5', createDate: '2024-02-11', data123: '15200000000' }, + { diffTime: '0.5', createDate: '2024-02-11', data123: '13800000000' }, ], }; diff --git a/server/src/modules/survey/__test/dataStatistic.service.spec.ts b/server/src/modules/survey/__test/dataStatistic.service.spec.ts index 1130364f..bf7b69bd 100644 --- a/server/src/modules/survey/__test/dataStatistic.service.spec.ts +++ b/server/src/modules/survey/__test/dataStatistic.service.spec.ts @@ -70,7 +70,7 @@ describe('DataStatisticService', () => { data413: 3, data863: '109239', }, - difTime: 21278, + diffTime: 21278, clientTime: 1710340862733.0, secretKeys: [], optionTextAndId: { @@ -197,7 +197,7 @@ describe('DataStatisticService', () => { data413_3: expect.any(String), data413: expect.any(Number), data863: expect.any(String), - difTime: expect.any(String), + diffTime: expect.any(String), createDate: expect.any(String), }), ]), @@ -220,7 +220,7 @@ describe('DataStatisticService', () => { 'U2FsdGVkX19bRmf3uEmXAJ/6zXd1Znr3cZsD5v4Nocr2v5XG1taXluz8cohFkDyH', data770: 'U2FsdGVkX18ldQMhJjFXO8aerjftZLpFnRQ4/FVcCLI=', }, - difTime: 806707, + diffTime: 806707, clientTime: 1710400229573.0, secretKeys: ['data458', 'data450', 'data405', 'data770'], optionTextAndId: { @@ -303,7 +303,7 @@ describe('DataStatisticService', () => { data458: expect.any(String), data515: expect.any(String), data770: expect.any(String), - difTime: expect.any(String), + diffTime: expect.any(String), }), ]), ); diff --git a/server/src/modules/survey/controllers/dataStatistic.controller.ts b/server/src/modules/survey/controllers/dataStatistic.controller.ts index 12f80d2f..752bc68a 100644 --- a/server/src/modules/survey/controllers/dataStatistic.controller.ts +++ b/server/src/modules/survey/controllers/dataStatistic.controller.ts @@ -22,6 +22,7 @@ import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { AggregationStatisDto } from '../dto/aggregationStatis.dto'; import { handleAggretionData } from '../utils'; +import { QUESTION_TYPE } from 'src/enums/question'; import { SurveyDownloadService } from '../services/surveyDownload.service'; @ApiTags('survey') @@ -106,15 +107,15 @@ export class DataStatisticController { }; } const allowQuestionType = [ - 'radio', - 'checkbox', - 'binary-choice', - 'radio-star', - 'radio-nps', - 'vote', + QUESTION_TYPE.RADIO, + QUESTION_TYPE.CHECKBOX, + QUESTION_TYPE.BINARY_CHOICE, + QUESTION_TYPE.RADIO_STAR, + QUESTION_TYPE.RADIO_NPS, + QUESTION_TYPE.VOTE, ]; const fieldList = responseSchema.code.dataConf.dataList - .filter((item) => allowQuestionType.includes(item.type)) + .filter((item) => allowQuestionType.includes(item.type as QUESTION_TYPE)) .map((item) => item.field); const dataMap = responseSchema.code.dataConf.dataList.reduce((pre, cur) => { pre[cur.field] = cur; diff --git a/server/src/modules/survey/controllers/survey.controller.ts b/server/src/modules/survey/controllers/survey.controller.ts index 888c0400..911a3ce2 100644 --- a/server/src/modules/survey/controllers/survey.controller.ts +++ b/server/src/modules/survey/controllers/survey.controller.ts @@ -32,6 +32,7 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import { WorkspaceGuard } from 'src/guards/workspace.guard'; import { PERMISSION as WORKSPACE_PERMISSION } from 'src/enums/workspace'; +import { MemberType, WhitelistType } from 'src/interfaces/survey'; @ApiTags('survey') @Controller('/api/survey') @@ -218,6 +219,16 @@ export class SurveyController { surveyMeta.isCollaborated = false; } + // 白名单相关字段的默认值 + const baseConf = surveyConf.code?.baseConf; + if (baseConf) { + baseConf.passwordSwitch = baseConf.passwordSwitch ?? false; + baseConf.password = baseConf.password ?? ''; + baseConf.whitelistType = baseConf.whitelistType ?? WhitelistType.ALL; + baseConf.whitelist = baseConf.whitelist ?? []; + baseConf.memberType = baseConf.memberType ?? MemberType.MOBILE; + } + return { code: 200, data: { diff --git a/server/src/modules/survey/services/dataStatistic.service.ts b/server/src/modules/survey/services/dataStatistic.service.ts index ce31fed6..0551b432 100644 --- a/server/src/modules/survey/services/dataStatistic.service.ts +++ b/server/src/modules/survey/services/dataStatistic.service.ts @@ -8,9 +8,10 @@ import { keyBy } from 'lodash'; import { DataItem } from 'src/interfaces/survey'; import { ResponseSchema } from 'src/models/responseSchema.entity'; import { getListHeadByDataList, transformAndMergeArrayFields } from '../utils'; +import { QUESTION_TYPE } from 'src/enums/question'; @Injectable() export class DataStatisticService { - private radioType = ['radio-star', 'radio-nps']; + private radioType = [QUESTION_TYPE.RADIO_STAR, QUESTION_TYPE.RADIO_NPS]; constructor( @InjectRepository(SurveyResponse) @@ -68,7 +69,7 @@ export class DataStatisticService { } // 处理选项的更多输入框 if ( - this.radioType.includes(itemConfig.type) && + this.radioType.includes(itemConfig.type as QUESTION_TYPE) && !data[`${itemConfigKey}_custom`] ) { data[`${itemConfigKey}_custom`] = @@ -89,7 +90,7 @@ export class DataStatisticService { } return { ...data, - difTime: (submitedData.difTime / 1000).toFixed(2), + diffTime: (submitedData.diffTime / 1000).toFixed(2), createDate: moment(submitedData.createDate).format( 'YYYY-MM-DD HH:mm:ss', ), diff --git a/server/src/modules/survey/template/surveyTemplate/templateBase.json b/server/src/modules/survey/template/surveyTemplate/templateBase.json index 56574dcc..fd3e76e9 100644 --- a/server/src/modules/survey/template/surveyTemplate/templateBase.json +++ b/server/src/modules/survey/template/surveyTemplate/templateBase.json @@ -1,55 +1,56 @@ { - "bannerConf": { - "titleConfig": { - "mainTitle": "

欢迎填写问卷

为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,期待您的参与!

", - "subTitle": "" - }, - "bannerConfig": { - "bgImage": "/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp", - "videoLink": "", - "postImg": "" - } + "bannerConf": { + "titleConfig": { + "mainTitle": "

欢迎填写问卷

为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,期待您的参与!

", + "subTitle": "" }, - "submitConf": { - "submitTitle": "提交", - "confirmAgain": { - "is_again": true, - "again_text": "确认要提交吗?" - }, - "msgContent": { - "msg_200": "提交成功", - "msg_9001": "您来晚了,感谢支持问卷~", - "msg_9002": "请勿多次提交!", - "msg_9003": "您来晚了,已经满额!", - "msg_9004": "提交失败!" - } - }, - "bottomConf": { - "logoImage": "/imgs/Logo.webp", - "logoImageWidth": "60%" - }, - "baseConf": { - "begTime": "2024-01-01 00:00:00", - "endTime": "2034-01-01 00:00:00", - "tLimit": 0, - "language": "chinese", - "answerBegTime": "00:00:00", - "answerEndTime": "23:59:59" - }, - "skinConf": { - "skinColor": "#4a4c5b", - "inputBgColor": "#ffffff", - "backgroundConf": { - "color": "#fff" - }, - "themeConf": { - "color": "#ffa600" - }, - "contentConf": { - "opacity": 100 - } - }, - "logicConf": { - "showLogicConf": [] + "bannerConfig": { + "bgImage": "/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp", + "videoLink": "", + "postImg": "" } + }, + "submitConf": { + "submitTitle": "提交", + "confirmAgain": { + "is_again": true, + "again_text": "确认要提交吗?" + }, + "msgContent": { + "msg_200": "提交成功", + "msg_9001": "您来晚了,感谢支持问卷~", + "msg_9002": "请勿多次提交!", + "msg_9003": "您来晚了,已经满额!", + "msg_9004": "提交失败!" + } + }, + "bottomConf": { + "logoImage": "/imgs/Logo.webp", + "logoImageWidth": "60%" + }, + "baseConf": { + "begTime": "2024-01-01 00:00:00", + "endTime": "2034-01-01 00:00:00", + "tLimit": 0, + "language": "chinese", + "answerBegTime": "00:00:00", + "answerEndTime": "23:59:59" + }, + "skinConf": { + "skinColor": "#4a4c5b", + "inputBgColor": "#ffffff", + "backgroundConf": { + "color": "#ffffff" + }, + "themeConf": { + "color": "#ffa600" + }, + "contentConf": { + "opacity": 100 + } + }, + "pageConf": [], + "logicConf": { + "showLogicConf": [] } +} diff --git a/server/src/modules/survey/utils/index.ts b/server/src/modules/survey/utils/index.ts index c8e426ee..a6a64ccd 100644 --- a/server/src/modules/survey/utils/index.ts +++ b/server/src/modules/survey/utils/index.ts @@ -5,6 +5,7 @@ import normalCode from '../template/surveyTemplate/survey/normal.json'; import npsCode from '../template/surveyTemplate/survey/nps.json'; import registerCode from '../template/surveyTemplate/survey/register.json'; import voteCode from '../template/surveyTemplate/survey/vote.json'; +import { QUESTION_TYPE } from 'src/enums/question'; const schemaDataMap = { normal: normalCode, @@ -31,9 +32,11 @@ export async function getSchemaBySurveyType(surveyType: string) { export function getListHeadByDataList(dataList) { const listHead = dataList.map((question) => { let othersCode; - const radioType = ['radio-star', 'radio-nps']; + const radioType = [QUESTION_TYPE.RADIO_STAR, QUESTION_TYPE.RADIO_NPS]; if (radioType.includes(question.type)) { - const rangeConfigKeys = question.rangeConfig ? Object.keys(question.rangeConfig) : []; + const rangeConfigKeys = question.rangeConfig + ? Object.keys(question.rangeConfig) + : []; if (rangeConfigKeys.length > 0) { othersCode = [{ code: `${question.field}_custom`, option: '填写理由' }]; } @@ -55,14 +58,14 @@ export function getListHeadByDataList(dataList) { }; }); listHead.push({ - field: 'difTime', + field: 'diffTime', title: '答题耗时(秒)', - type: 'text', + type: QUESTION_TYPE.TEXT, }); listHead.push({ field: 'createDate', title: '提交时间', - type: 'text', + type: QUESTION_TYPE.TEXT, }); return listHead; } @@ -109,7 +112,14 @@ export function handleAggretionData({ dataMap, item }) { pre[cur.id] = cur; return pre; }, {}); - if (['radio', 'checkbox', 'vote', 'binary-choice'].includes(type)) { + if ( + [ + QUESTION_TYPE.RADIO, + QUESTION_TYPE.CHECKBOX, + QUESTION_TYPE.VOTE, + QUESTION_TYPE.BINARY_CHOICE, + ].includes(type) + ) { return { ...item, title: dataMap[item.field].title, @@ -125,7 +135,9 @@ export function handleAggretionData({ dataMap, item }) { }), }, }; - } else if (['radio-star', 'radio-nps'].includes(type)) { + } else if ( + [QUESTION_TYPE.RADIO_STAR, QUESTION_TYPE.RADIO_NPS].includes(type) + ) { const summary: Record = {}; const average = getAverage({ aggregation: item.data.aggregation }); const median = getMedian({ aggregation: item.data.aggregation }); @@ -136,10 +148,10 @@ export function handleAggretionData({ dataMap, item }) { summary['average'] = average; summary['median'] = median; summary['variance'] = variance; - if (type === 'radio-nps') { + if (type === QUESTION_TYPE.RADIO_NPS) { summary['nps'] = getNps({ aggregation: item.data.aggregation }); } - const range = type === 'radio-nps' ? [0, 10] : [1, 5]; + const range = type === QUESTION_TYPE.RADIO_NPS ? [0, 10] : [1, 5]; const arr = []; for (let i = range[0]; i <= range[1]; i++) { arr.push(i); diff --git a/server/src/modules/surveyResponse/__test/responseSchema.controller.spec.ts b/server/src/modules/surveyResponse/__test/responseSchema.controller.spec.ts index bb1395e9..497580df 100644 --- a/server/src/modules/surveyResponse/__test/responseSchema.controller.spec.ts +++ b/server/src/modules/surveyResponse/__test/responseSchema.controller.spec.ts @@ -6,6 +6,11 @@ import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { RECORD_STATUS } from 'src/enums'; import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { Logger } from 'src/logger'; +import { UserService } from 'src/modules/auth/services/user.service'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; +import { AuthService } from 'src/modules/auth/services/auth.service'; +import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; jest.mock('../services/responseScheme.service'); @@ -16,7 +21,40 @@ describe('ResponseSchemaController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ResponseSchemaController], - providers: [ResponseSchemaService], + providers: [ + ResponseSchemaService, + AuthService, + { + provide: Logger, + useValue: { + info: jest.fn(), + }, + }, + { + provide: UserService, + useValue: { + getUserByUsername: jest.fn(), + }, + }, + { + provide: WorkspaceMemberService, + useValue: { + findAllByUserId: jest.fn(), + }, + }, + { + provide: AuthService, + useValue: { + create: jest.fn(), + }, + }, + { + provide: Logger, + useValue: { + error: jest.fn(), + }, + }, + ], }).compile(); controller = module.get(ResponseSchemaController); @@ -66,5 +104,146 @@ describe('ResponseSchemaController', () => { new HttpException('问卷已删除', EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED), ); }); + + it('whitelistValidate should throw SurveyNotFoundException when survey is removed', async () => { + const surveyPath = ''; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue(null); + await expect( + controller.whitelistValidate(surveyPath, { + password: '123456', + }), + ).rejects.toThrow(new SurveyNotFoundException('该问卷不存在,无法提交')); + }); + + it('whitelistValidate should throw WHITELIST_ERROR code when password is incorrect', async () => { + const surveyPath = ''; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue({ + curStatus: { + status: 'published', + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + }, + }, + } as ResponseSchema); + await expect( + controller.whitelistValidate(surveyPath, { + password: '123457', + }), + ).rejects.toThrow( + new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR), + ); + }); + + it('whitelistValidate should be successfully', async () => { + const surveyPath = 'test'; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue({ + curStatus: { + status: 'published', + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + }, + }, + } as ResponseSchema); + + await expect( + controller.whitelistValidate(surveyPath, { + password: '123456', + }), + ).resolves.toEqual({ code: 200, data: null }); + }); + + it('whitelistValidate should throw WHITELIST_ERROR code when mobile or email is incorrect', async () => { + const surveyPath = ''; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue({ + curStatus: { + status: 'published', + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + whitelistType: 'CUSTOM', + memberType: 'MOBILE', + whitelist: ['13500000000'], + }, + }, + } as ResponseSchema); + await expect( + controller.whitelistValidate(surveyPath, { + password: '123456', + whitelist: '13500000001', + }), + ).rejects.toThrow( + new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR), + ); + }); + + it('whitelistValidate should throw WHITELIST_ERROR code when member is incorrect', async () => { + const surveyPath = ''; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue({ + curStatus: { + status: 'published', + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + whitelistType: 'MEMBER', + whitelist: ['Jack'], + }, + }, + } as ResponseSchema); + await expect( + controller.whitelistValidate(surveyPath, { + password: '123456', + whitelist: 'James', + }), + ).rejects.toThrow( + new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR), + ); + }); + }); + + it('whitelistValidate should return verifyId successfully', async () => { + const surveyPath = ''; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue({ + curStatus: { + status: 'published', + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + whitelistType: 'CUSTOM', + memberType: 'MOBILE', + whitelist: ['13500000000'], + }, + }, + } as ResponseSchema); + + await expect( + controller.whitelistValidate(surveyPath, { + password: '123456', + whitelist: '13500000000', + }), + ).resolves.toEqual({ code: 200, data: null }); }); }); diff --git a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts index 5cb5a950..b7b1a6eb 100644 --- a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts +++ b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts @@ -21,6 +21,10 @@ import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugi import { RECORD_STATUS } from 'src/enums'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; import { Logger } from 'src/logger'; +import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { UserService } from 'src/modules/auth/services/user.service'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; const mockDecryptErrorBody = { surveyPath: 'EBzdmnSp', @@ -28,11 +32,11 @@ const mockDecryptErrorBody = { 'SkyfsbS6MDvFrrxFJQDMxsvm53G3PTURktfZikJP2fKilC8wPW5ZdfX29Fixor5ldHBBNyILsDtxhbNahEbNCDw8n1wS8IIckFuQcaJtn6MLOD+h+Iuywka3ig4ecTN87RpdcfEQe7f38rSSx0zoFU8j37eojjSF7eETBSrz5m9WaNesQo4hhC6p7wmAo1jggkdbG8PVrFqrZPbkHN5jOBrzQEqdqYu9A5wHMM7nUteqlPpkiogEDYmBIccqmPdtO54y7LoPslXgXj6jNja8oVNaYlnp7UsisT+i5cuQ7lbDukEhrfpAIFRsT2IUwVlLjWHtFQm4/4I5HyvVBirTng==', 'IMn0E7R6cYCQPI497mz3x99CPA4cImAFEfIv8Q98Gm5bFcgKJX6KFYS7PF/VtIuI1leKwwNYexQy7+2HnF40by/huVugoPYnPd4pTpUdG6f1kh8EpzIir2+8P98Dcz2+NZ/khP2RIAM8nOr+KSC99TNGhuKaKQCItyLFDkr80s3zv+INieGc8wULIrGoWDJGN2KdU/jSq+hkV0QXypd81N5IyAoNhZLkZeM/FU6grGFPnGRtcDDc5W8YWVHO87VymlxPCTRawXRTDcvGIUqb3GuZfxvA7AULqbspmN9kzt3rktuZLNb2TFQDsJfqUuCmi+b28qP/G4OrT9/VAHhYKw==', ], - difTime: 806707, + diffTime: 806707, clientTime: 1710400229573, encryptType: 'rsa', sessionId: '65f2664c92862d6a9067ad18', - sign: '8c9ca8804c9d94de6055d68a1f3c423fe50c95b4bd69f809ee2da8fcd82fd960.1710400229589', + sign: '95d6ff5dd3d9ddc205cbab88defe40ebe889952961f1d60e760fa411e2cb39fe.1710400229589', }; const mockSubmitData = { @@ -41,11 +45,11 @@ const mockSubmitData = { 'SkyfsbS6MDvFrrxFJQDMxsvm53G3PTURktfZikJP2fKilC8wPW5ZdfX29Fixor5ldHBBNyILsDtxhbNahEbNCDw8n1wS8IIckFuQcaJtn6MLOD+h+Iuywka3ig4ecTN87RpdcfEQe7f38rSSx0zoFU8j37eojjSF7eETBSrz5m9WaNesQo4hhC6p7wmAo1jggkdbG8PVrFqrZPbkHN5jOBrzQEqdqYu9A5wHMM7nUteqlPpkiogEDYmBIccqmPdtO54y7LoPslXgXj6jNja8oVNaYlnp7UsisT+i5cuQ7lbDukEhrfpAIFRsT2IUwVlLjWHtFQm4/4I5HyvVBirTng==', 'IMn0E7R6cYCQPI497mz3x99CPA4cImAFEfIv8Q98Gm5bFcgKJX6KFYS7PF/VtIuI1leKwwNYexQy7+2HnF40by/huVugoPYnPd4pTpUdG6f1kh8EpzIir2+8P98Dcz2+NZ/khP2RIAM8nOr+KSC99TNGhuKaKQCItyLFDkr80s3zv+INieGc8wULIrGoWDJGN2KdU/jSq+hkV0QXypd81N5IyAoNhZLkZeM/FU6grGFPnGRtcDDc5W8YWVHO87VymlxPCTRawXRTDcvGIUqb3GuZfxvA7AULqbspmN9kzt3rktuZLNb2TFQDsJfqUuCmi+b28qP/G4OrT9/VAHhYKw==', ], - difTime: 806707, + diffTime: 806707, clientTime: 1710400229573, encryptType: 'rsa', sessionId: '65f29fc192862d6a9067ad28', - sign: '8c9ca8804c9d94de6055d68a1f3c423fe50c95b4bd69f809ee2da8fcd82fd960.1710400229589', + sign: '95d6ff5dd3d9ddc205cbab88defe40ebe889952961f1d60e760fa411e2cb39fe.1710400229589', }; const mockClientEncryptInfo = { @@ -124,6 +128,18 @@ describe('SurveyResponseController', () => { info: jest.fn(), }, }, + { + provide: UserService, + useValue: { + getUserByUsername: jest.fn(), + }, + }, + { + provide: WorkspaceMemberService, + useValue: { + findAllByUserId: jest.fn(), + }, + }, ], }).compile(); @@ -169,7 +185,7 @@ describe('SurveyResponseController', () => { status: RECORD_STATUS.NEW, date: 1711025113146, }, - difTime: 30518, + diffTime: 30518, data: { data458: '15000000000', data515: '115019', @@ -204,7 +220,6 @@ describe('SurveyResponseController', () => { jest .spyOn(clientEncryptService, 'deleteEncryptInfo') .mockResolvedValueOnce(undefined); - const result = await controller.createResponse(reqBody, {}); expect(result).toEqual({ code: 200, msg: '提交成功' }); @@ -224,7 +239,7 @@ describe('SurveyResponseController', () => { data770: '123456@qq.com', }, clientTime: reqBody.clientTime, - difTime: reqBody.difTime, + diffTime: reqBody.diffTime, surveyId: mockResponseSchema.pageId, optionTextAndId: { data515: [ @@ -306,5 +321,31 @@ describe('SurveyResponseController', () => { HttpException, ); }); + + it('should throw HttpException if password does not match', async () => { + const reqBody = { + ...mockSubmitData, + password: '123457', + sign: '145595d85079af3b1fb30784177c348555f442837c051d90f57a01ce1ff53c32.1710400229589', + }; + + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValueOnce({ + curStatus: { + status: RECORD_STATUS.PUBLISHED, + }, + code: { + baseConf: { + passwordSwitch: true, + password: '123456', + }, + }, + } as ResponseSchema); + + await expect(controller.createResponse(reqBody, {})).rejects.toThrow( + new HttpException('白名单验证失败', EXCEPTION_CODE.WHITELIST_ERROR), + ); + }); }); }); diff --git a/server/src/modules/surveyResponse/__test/surveyResponse.service.spec.ts b/server/src/modules/surveyResponse/__test/surveyResponse.service.spec.ts index 91d9cb37..f91df266 100644 --- a/server/src/modules/surveyResponse/__test/surveyResponse.service.spec.ts +++ b/server/src/modules/surveyResponse/__test/surveyResponse.service.spec.ts @@ -33,7 +33,7 @@ describe('SurveyResponseService', () => { const surveyData = { data: {}, clientTime: new Date(), - difTime: 0, + diffTime: 0, surveyId: 'testId', surveyPath: 'testPath', optionTextAndId: {}, @@ -59,7 +59,7 @@ describe('SurveyResponseService', () => { surveyPath: surveyData.surveyPath, data: surveyData.data, clientTime: surveyData.clientTime, - difTime: surveyData.difTime, + diffTime: surveyData.diffTime, pageId: surveyData.surveyId, secretKeys: [], optionTextAndId: surveyData.optionTextAndId, diff --git a/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts b/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts index 30fd2055..8df092bf 100644 --- a/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts +++ b/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts @@ -1,14 +1,33 @@ -import { Controller, Get, HttpCode, Query } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + Param, + Post, + Query, +} from '@nestjs/common'; import { ResponseSchemaService } from '../services/responseScheme.service'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { RECORD_STATUS } from 'src/enums'; import { ApiTags } from '@nestjs/swagger'; +import Joi from 'joi'; +import { Logger } from 'src/logger'; +import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; +import { WhitelistType } from 'src/interfaces/survey'; +import { UserService } from 'src/modules/auth/services/user.service'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; @ApiTags('surveyResponse') @Controller('/api/responseSchema') export class ResponseSchemaController { - constructor(private readonly responseSchemaService: ResponseSchemaService) {} + constructor( + private readonly responseSchemaService: ResponseSchemaService, + private readonly logger: Logger, + private readonly userService: UserService, + private readonly workspaceMemberService: WorkspaceMemberService, + ) {} @Get('/getSchema') @HttpCode(200) @@ -34,9 +53,79 @@ export class ResponseSchemaController { EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED, ); } + + // 去掉C端的敏感字段 + if (responseSchema.code?.baseConf) { + responseSchema.code.baseConf.password = null; + responseSchema.code.baseConf.whitelist = []; + } return { code: 200, data: responseSchema, }; } + + // 白名单验证 + @Post('/:surveyPath/validate') + @HttpCode(200) + async whitelistValidate(@Param('surveyPath') surveyPath, @Body() body) { + const { value, error } = Joi.object({ + password: Joi.string().allow(null, ''), + whitelist: Joi.string().allow(null, ''), + }).validate(body, { allowUnknown: true }); + + if (error) { + this.logger.error(`whitelistValidate error: ${error.message}`, {}); + throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + // 问卷信息 + const schema = + await this.responseSchemaService.getResponseSchemaByPath(surveyPath); + if (!schema || schema.curStatus.status === 'removed') { + throw new SurveyNotFoundException('该问卷不存在,无法提交'); + } + + const { password, whitelist: whitelistValue } = value; + const { + passwordSwitch, + password: settingPassword, + whitelistType, + whitelist, + } = schema.code.baseConf; + + // 密码校验 + if (passwordSwitch) { + if (settingPassword !== password) { + throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR); + } + } + + // 名单校验(手机号/邮箱) + if (whitelistType === WhitelistType.CUSTOM) { + if (!whitelist.includes(whitelistValue)) { + throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR); + } + } + + // 团队成员昵称校验 + if (whitelistType === WhitelistType.MEMBER) { + const user = await this.userService.getUserByUsername(whitelistValue); + if (!user) { + throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR); + } + + const workspaceMember = await this.workspaceMemberService.findAllByUserId( + { userId: user._id.toString() }, + ); + if (!workspaceMember.length) { + throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR); + } + } + + return { + code: 200, + data: null, + }; + } } diff --git a/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts b/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts index 47cd3bdf..336314a5 100644 --- a/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts +++ b/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts @@ -19,6 +19,9 @@ import { ApiTags } from '@nestjs/swagger'; import { MutexService } from 'src/modules/mutex/services/mutexService.service'; import { CounterService } from '../services/counter.service'; import { Logger } from 'src/logger'; +import { WhitelistType } from 'src/interfaces/survey'; +import { UserService } from 'src/modules/auth/services/user.service'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; @ApiTags('surveyResponse') @Controller('/api/surveyResponse') @@ -31,6 +34,8 @@ export class SurveyResponseController { private readonly mutexService: MutexService, private readonly counterService: CounterService, private readonly logger: Logger, + private readonly userService: UserService, + private readonly workspaceMemberService: WorkspaceMemberService, ) {} @Post('/createResponse') @@ -45,7 +50,9 @@ export class SurveyResponseController { encryptType: Joi.string(), sessionId: Joi.string(), clientTime: Joi.number().required(), - difTime: Joi.number(), + diffTime: Joi.number(), + password: Joi.string().allow(null, ''), + whitelist: Joi.string().allow(null, ''), }).validate(reqBody, { allowUnknown: true }); if (error) { @@ -55,8 +62,16 @@ export class SurveyResponseController { throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); } - const { surveyPath, encryptType, data, sessionId, clientTime, difTime } = - value; + const { + surveyPath, + encryptType, + data, + sessionId, + clientTime, + diffTime, + password, + whitelist: whitelistValue, + } = value; // 查询schema const responseSchema = @@ -65,6 +80,50 @@ export class SurveyResponseController { throw new SurveyNotFoundException('该问卷不存在,无法提交'); } + // 白名单的verifyId校验 + const baseConf = responseSchema.code.baseConf; + + // 密码校验 + if (baseConf?.passwordSwitch && baseConf.password) { + if (baseConf.password !== password) { + throw new HttpException( + '白名单验证失败', + EXCEPTION_CODE.WHITELIST_ERROR, + ); + } + } + + // 名单校验(手机号/邮箱) + if (baseConf?.whitelistType === WhitelistType.CUSTOM) { + if (!baseConf.whitelist.includes(whitelistValue)) { + throw new HttpException( + '白名单验证失败', + EXCEPTION_CODE.WHITELIST_ERROR, + ); + } + } + + // 团队成员昵称校验 + if (baseConf?.whitelistType === WhitelistType.MEMBER) { + const user = await this.userService.getUserByUsername(whitelistValue); + if (!user) { + throw new HttpException( + '白名单验证失败', + EXCEPTION_CODE.WHITELIST_ERROR, + ); + } + + const workspaceMember = await this.workspaceMemberService.findAllByUserId( + { userId: user._id.toString() }, + ); + if (!workspaceMember.length) { + throw new HttpException( + '白名单验证失败', + EXCEPTION_CODE.WHITELIST_ERROR, + ); + } + } + const now = Date.now(); // 提交时间限制 const begTime = responseSchema.code?.baseConf?.begTime || 0; @@ -224,7 +283,7 @@ export class SurveyResponseController { surveyPath: value.surveyPath, data: decryptedData, clientTime, - difTime, + diffTime, surveyId: responseSchema.pageId, optionTextAndId, }); diff --git a/server/src/modules/surveyResponse/services/surveyResponse.service.ts b/server/src/modules/surveyResponse/services/surveyResponse.service.ts index b9438a81..59fa3543 100644 --- a/server/src/modules/surveyResponse/services/surveyResponse.service.ts +++ b/server/src/modules/surveyResponse/services/surveyResponse.service.ts @@ -12,7 +12,7 @@ export class SurveyResponseService { async createSurveyResponse({ data, clientTime, - difTime, + diffTime, surveyId, surveyPath, optionTextAndId, @@ -22,7 +22,7 @@ export class SurveyResponseService { data, secretKeys: [], clientTime, - difTime, + diffTime, pageId: surveyId, optionTextAndId, }); diff --git a/server/src/modules/surveyResponse/surveyResponse.module.ts b/server/src/modules/surveyResponse/surveyResponse.module.ts index 245d63c4..912e4294 100644 --- a/server/src/modules/surveyResponse/surveyResponse.module.ts +++ b/server/src/modules/surveyResponse/surveyResponse.module.ts @@ -17,6 +17,8 @@ import { CounterController } from './controllers/counter.controller'; import { ResponseSchemaController } from './controllers/responseSchema.controller'; import { SurveyResponseController } from './controllers/surveyResponse.controller'; import { SurveyResponseUIController } from './controllers/surveyResponseUI.controller'; +import { AuthModule } from '../auth/auth.module'; +import { WorkspaceModule } from '../workspace/workspace.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; @@ -32,6 +34,8 @@ import { MutexModule } from '../mutex/mutex.module'; ]), ConfigModule, MessageModule, + AuthModule, + WorkspaceModule, MutexModule, ], controllers: [ diff --git a/server/src/modules/workspace/_test/workspace.controller.spec.ts b/server/src/modules/workspace/_test/workspace.controller.spec.ts index 3a5b2733..73d84ab3 100644 --- a/server/src/modules/workspace/_test/workspace.controller.spec.ts +++ b/server/src/modules/workspace/_test/workspace.controller.spec.ts @@ -32,8 +32,10 @@ describe('WorkspaceController', () => { useValue: { create: jest.fn(), findAllById: jest.fn(), + findAllByIdWithPagination: jest.fn(), update: jest.fn(), delete: jest.fn(), + findAllByUserId: jest.fn(), }, }, { @@ -45,6 +47,7 @@ describe('WorkspaceController', () => { batchUpdate: jest.fn(), batchDelete: jest.fn(), countByWorkspaceId: jest.fn(), + batchSearchByWorkspace: jest.fn(), }, }, { @@ -145,20 +148,30 @@ describe('WorkspaceController', () => { jest .spyOn(workspaceMemberService, 'findAllByUserId') .mockResolvedValue(memberList as unknown as Array); + jest - .spyOn(workspaceService, 'findAllById') - .mockResolvedValue(workspaces as Array); + .spyOn(workspaceService, 'findAllByIdWithPagination') + .mockResolvedValue({ + list: workspaces as Array, + count: workspaces.length, + }); jest.spyOn(userService, 'getUserListByIds').mockResolvedValue([]); - const result = await controller.findAll(req); + const result = await controller.findAll(req, { + curPage: 1, + pageSize: 10, + }); expect(result.code).toEqual(200); expect(workspaceMemberService.findAllByUserId).toHaveBeenCalledWith({ userId: req.user._id.toString(), }); - expect(workspaceService.findAllById).toHaveBeenCalledWith({ + expect(workspaceService.findAllByIdWithPagination).toHaveBeenCalledWith({ workspaceIdList: memberList.map((item) => item.workspaceId), + page: 1, + limit: 10, + name: undefined, }); }); }); @@ -226,4 +239,31 @@ describe('WorkspaceController', () => { expect(workspaceService.delete).toHaveBeenCalledWith(id); }); }); + + describe('getWorkspaceAndMember', () => { + it('should return a list of workspaces and members for the user', async () => { + const req = { user: { _id: new ObjectId() } }; + + const workspaceId = new ObjectId(); + const memberList = [{ workspaceId, userId: new ObjectId() }]; + const workspaces = [{ _id: workspaceId, name: 'Test Workspace' }]; + + jest + .spyOn(workspaceService, 'findAllByUserId') + .mockResolvedValue(workspaces as Array); + jest + .spyOn(workspaceMemberService, 'batchSearchByWorkspace') + .mockResolvedValue(memberList as unknown as Array); + + const result = await controller.getWorkspaceAndMember(req); + + expect(result.code).toEqual(200); + expect(workspaceService.findAllByUserId).toHaveBeenCalledWith( + req.user._id.toString(), + ); + expect( + workspaceMemberService.batchSearchByWorkspace, + ).toHaveBeenCalledWith(workspaces.map((item) => item._id.toString())); + }); + }); }); diff --git a/server/src/modules/workspace/_test/workspace.service.spec.ts b/server/src/modules/workspace/_test/workspace.service.spec.ts index 1eb12233..a243dec6 100644 --- a/server/src/modules/workspace/_test/workspace.service.spec.ts +++ b/server/src/modules/workspace/_test/workspace.service.spec.ts @@ -123,4 +123,25 @@ describe('WorkspaceService', () => { expect(surveyMetaRepository.updateMany).toHaveBeenCalledTimes(1); }); }); + + describe('findAllByUserId', () => { + it('should return all workspaces under a user', async () => { + const workspaceIdList = [ + new ObjectId().toString(), + new ObjectId().toString(), + ]; + const workspaces = [ + { _id: workspaceIdList[0], name: 'Workspace 1' }, + { _id: workspaceIdList[1], name: 'Workspace 2' }, + ]; + + jest + .spyOn(workspaceRepository, 'find') + .mockResolvedValue(workspaces as any); + + const result = await service.findAllByUserId(''); + expect(result).toEqual(workspaces); + expect(workspaceRepository.find).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/server/src/modules/workspace/_test/workspaceMember.service.spec.ts b/server/src/modules/workspace/_test/workspaceMember.service.spec.ts index d115c3fe..8770bf20 100644 --- a/server/src/modules/workspace/_test/workspaceMember.service.spec.ts +++ b/server/src/modules/workspace/_test/workspaceMember.service.spec.ts @@ -193,4 +193,21 @@ describe('WorkspaceMemberService', () => { }); }); }); + + describe('batchSearchByWorkspace', () => { + it('should return all workspace members by workspace id list', async () => { + const workspaceList = ['workspaceId1', 'workspaceId2']; + const members = [ + { userId: 'userId1', workspaceId: workspaceList[0] }, + { userId: 'userId2', workspaceId: workspaceList[1] }, + ]; + + jest.spyOn(repository, 'find').mockResolvedValue(members as any); + + const result = await service.batchSearchByWorkspace(workspaceList); + + expect(result).toEqual(members); + expect(repository.find).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/server/src/modules/workspace/controllers/workspace.controller.ts b/server/src/modules/workspace/controllers/workspace.controller.ts index 81196830..75da6319 100644 --- a/server/src/modules/workspace/controllers/workspace.controller.ts +++ b/server/src/modules/workspace/controllers/workspace.controller.ts @@ -9,6 +9,7 @@ import { Request, SetMetadata, HttpCode, + Query, } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import moment from 'moment'; @@ -31,6 +32,9 @@ import { splitMembers } from '../utils/splitMember'; import { UserService } from 'src/modules/auth/services/user.service'; import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service'; import { Logger } from 'src/logger'; +import { GetWorkspaceListDto } from '../dto/getWorkspaceList.dto'; +import { WorkspaceMember } from 'src/models/workspaceMember.entity'; +import { Workspace } from 'src/models/workspace.entity'; @ApiTags('workspace') @ApiBearerAuth() @@ -128,8 +132,21 @@ export class WorkspaceController { @Get() @HttpCode(200) - async findAll(@Request() req) { + async findAll(@Request() req, @Query() queryInfo: GetWorkspaceListDto) { + const { value, error } = GetWorkspaceListDto.validate(queryInfo); + if (error) { + this.logger.error( + `GetWorkspaceListDto validate failed: ${error.message}`, + { req }, + ); + throw new HttpException( + `参数错误: 请联系管理员`, + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } const userId = req.user._id.toString(); + const curPage = Number(value.curPage); + const pageSize = Number(value.pageSize); // 查询当前用户参与的空间 const workspaceInfoList = await this.workspaceMemberService.findAllByUserId( { userId }, @@ -139,9 +156,16 @@ export class WorkspaceController { pre[cur.workspaceId] = cur; return pre; }, {}); + // 查询当前用户的空间列表 - const list = await this.workspaceService.findAllById({ workspaceIdList }); - const ownerIdList = list.map((item) => item.ownerId); + const { list, count } = + await this.workspaceService.findAllByIdWithPagination({ + workspaceIdList, + page: curPage, + limit: pageSize, + name: queryInfo.name, + }); + const ownerIdList = list.map((item: { ownerId: any }) => item.ownerId); const userList = await this.userService.getUserListByIds({ idList: ownerIdList, }); @@ -150,6 +174,7 @@ export class WorkspaceController { pre[id] = cur; return pre; }, {}); + const surveyTotalList = await Promise.all( workspaceIdList.map((item) => { return this.surveyMetaService.countSurveyMetaByWorkspaceId({ @@ -193,6 +218,7 @@ export class WorkspaceController { memberTotal: memberTotalMap[workspaceId] || 0, }; }), + count, }, }; } @@ -326,4 +352,53 @@ export class WorkspaceController { code: 200, }; } + + @Get('/member/list') + @HttpCode(200) + async getWorkspaceAndMember(@Request() req) { + const userId = req.user._id.toString(); + + // 所在所有空间 + const workspaceList = await this.workspaceService.findAllByUserId(userId); + if (!workspaceList.length) { + return { + code: 200, + data: [], + }; + } + + // 所有空间下的所有成员 + const workspaceMemberList = + await this.workspaceMemberService.batchSearchByWorkspace( + workspaceList.map((item) => item._id.toString()), + ); + + // 查询成员姓名 + const userList = await this.userService.getUserListByIds({ + idList: workspaceMemberList.map((member) => member.userId), + }); + const userInfoMap = userList.reduce((pre, cur) => { + const id = cur._id.toString(); + pre[id] = cur; + return pre; + }, {}); + + const temp: Record = {}; + const list = workspaceList.map( + (item: Workspace & { members: WorkspaceMember[] }) => { + temp[item._id.toString()] = item.members = []; + return item; + }, + ); + + workspaceMemberList.forEach((member: WorkspaceMember) => { + (member as any).username = userInfoMap[member.userId.toString()].username; + temp[member.workspaceId.toString()].push(member); + }); + + return { + code: 200, + data: list, + }; + } } diff --git a/server/src/modules/workspace/dto/getWorkspaceList.dto.ts b/server/src/modules/workspace/dto/getWorkspaceList.dto.ts new file mode 100644 index 00000000..5990ff82 --- /dev/null +++ b/server/src/modules/workspace/dto/getWorkspaceList.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; + +export class GetWorkspaceListDto { + @ApiProperty({ description: '当前页码', required: true }) + curPage: number; + + @ApiProperty({ description: '分页', required: false }) + pageSize: number; + + @ApiProperty({ description: '空间名称', required: false }) + name?: string; + + static validate(data: Partial): Joi.ValidationResult { + return Joi.object({ + curPage: Joi.number().required(), + pageSize: Joi.number().allow(null).default(10), + name: Joi.string().allow(null, '').optional(), + }).validate(data); + } +} diff --git a/server/src/modules/workspace/services/workspace.service.ts b/server/src/modules/workspace/services/workspace.service.ts index aaf37f69..fb097a7a 100644 --- a/server/src/modules/workspace/services/workspace.service.ts +++ b/server/src/modules/workspace/services/workspace.service.ts @@ -8,6 +8,17 @@ import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { ObjectId } from 'mongodb'; import { RECORD_STATUS } from 'src/enums'; +interface FindAllByIdWithPaginationParams { + workspaceIdList: string[]; + page: number; + limit: number; + name?: string; +} +interface FindAllByIdWithPaginationResult { + list: Workspace[]; + count: number; +} + @Injectable() export class WorkspaceService { constructor( @@ -41,15 +52,17 @@ export class WorkspaceService { }: { workspaceIdList: string[]; }): Promise { - return this.workspaceRepository.find({ - where: { - _id: { - $in: workspaceIdList.map((item) => new ObjectId(item)), - }, - 'curStatus.status': { - $ne: RECORD_STATUS.REMOVED, - }, + const query = { + _id: { + $in: workspaceIdList.map((item) => new ObjectId(item)), }, + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }; + + return this.workspaceRepository.find({ + where: query, order: { _id: -1, }, @@ -64,6 +77,38 @@ export class WorkspaceService { }); } + async findAllByIdWithPagination({ + workspaceIdList, + page, + limit, + name, + }: FindAllByIdWithPaginationParams): Promise { + const skip = (page - 1) * limit; + if (!Array.isArray(workspaceIdList) || workspaceIdList.length === 0) { + return { list: [], count: 0 }; + } + const query = { + _id: { + $in: workspaceIdList.map((m) => new ObjectId(m)), + }, + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }; + if (name) { + query['name'] = { $regex: name, $options: 'i' }; + } + const [data, count] = await this.workspaceRepository.findAndCount({ + where: query, + skip, + take: limit, + order: { + createDate: -1, + }, + }); + return { list: data, count }; + } + update(id: string, workspace: Partial) { return this.workspaceRepository.update(id, workspace); } @@ -104,4 +149,27 @@ export class WorkspaceService { surveyRes, }; } + + // 用户下的所有空间 + async findAllByUserId(userId: string) { + return await this.workspaceRepository.find({ + where: { + ownerId: userId, + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }, + order: { + _id: -1, + }, + select: [ + '_id', + 'curStatus', + 'name', + 'description', + 'ownerId', + 'createDate', + ], + }); + } } diff --git a/server/src/modules/workspace/services/workspaceMember.service.ts b/server/src/modules/workspace/services/workspaceMember.service.ts index f4535f75..2f711963 100644 --- a/server/src/modules/workspace/services/workspaceMember.service.ts +++ b/server/src/modules/workspace/services/workspaceMember.service.ts @@ -140,4 +140,19 @@ export class WorkspaceMemberService { }, }); } + + // 根据空间id批量查询成员 + async batchSearchByWorkspace(workspaceList: string[]) { + return await this.workspaceMemberRepository.find({ + where: { + workspaceId: { + $in: workspaceList, + }, + }, + order: { + _id: -1, + }, + select: ['_id', 'userId', 'role', 'workspaceId'], + }); + } } diff --git a/server/src/utils/messagePushing.spec.ts b/server/src/utils/messagePushing.spec.ts index 010706ad..9788c814 100644 --- a/server/src/utils/messagePushing.spec.ts +++ b/server/src/utils/messagePushing.spec.ts @@ -12,7 +12,7 @@ describe('getPushingData', () => { status: RECORD_STATUS.NEW, date: 1711025113146, }, - difTime: 30518, + diffTime: 30518, data: { data458: '15000000000', data515: '115019', diff --git a/web/components.d.ts b/web/components.d.ts deleted file mode 100644 index 0a546688..00000000 --- a/web/components.d.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* eslint-disable */ -/* prettier-ignore */ -// @ts-nocheck -// Generated by unplugin-vue-components -// Read more: https://github.com/vuejs/core/pull/3399 -export {} - -declare module 'vue' { - export interface GlobalComponents { - ElButton: typeof import('element-plus/es')['ElButton'] - ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] - ElCollapse: typeof import('element-plus/es')['ElCollapse'] - ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] - 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'] - ElDivider: typeof import('element-plus/es')['ElDivider'] - ElForm: typeof import('element-plus/es')['ElForm'] - ElFormItem: typeof import('element-plus/es')['ElFormItem'] - ElIcon: typeof import('element-plus/es')['ElIcon'] - ElInput: typeof import('element-plus/es')['ElInput'] - ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] - ElMenu: typeof import('element-plus/es')['ElMenu'] - ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] - ElMenuItemGroup: typeof import('element-plus/es')['ElMenuItemGroup'] - ElOption: typeof import('element-plus/es')['ElOption'] - ElPagination: typeof import('element-plus/es')['ElPagination'] - ElPopover: typeof import('element-plus/es')['ElPopover'] - ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] - ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] - 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'] - ElSwitch: typeof import('element-plus/es')['ElSwitch'] - ElTable: typeof import('element-plus/es')['ElTable'] - ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] - ElTabPane: typeof import('element-plus/es')['ElTabPane'] - ElTabs: typeof import('element-plus/es')['ElTabs'] - ElTag: typeof import('element-plus/es')['ElTag'] - ElTimePicker: typeof import('element-plus/es')['ElTimePicker'] - ElTooltip: typeof import('element-plus/es')['ElTooltip'] - IEpBottom: typeof import('~icons/ep/bottom')['default'] - IEpCheck: typeof import('~icons/ep/check')['default'] - IEpCirclePlus: typeof import('~icons/ep/circle-plus')['default'] - IEpClose: typeof import('~icons/ep/close')['default'] - IEpCopyDocument: typeof import('~icons/ep/copy-document')['default'] - IEpDelete: typeof import('~icons/ep/delete')['default'] - IEpIphone: typeof import('~icons/ep/iphone')['default'] - IEpLoading: typeof import('~icons/ep/loading')['default'] - IEpMinus: typeof import('~icons/ep/minus')['default'] - IEpMonitor: typeof import('~icons/ep/monitor')['default'] - IEpMore: typeof import('~icons/ep/more')['default'] - IEpPlus: typeof import('~icons/ep/plus')['default'] - IEpQuestionFilled: typeof import('~icons/ep/question-filled')['default'] - IEpRank: typeof import('~icons/ep/rank')['default'] - IEpRemove: typeof import('~icons/ep/remove')['default'] - IEpSearch: typeof import('~icons/ep/search')['default'] - IEpSort: typeof import('~icons/ep/sort')['default'] - IEpSortDown: typeof import('~icons/ep/sort-down')['default'] - IEpSortUp: typeof import('~icons/ep/sort-up')['default'] - IEpTop: typeof import('~icons/ep/top')['default'] - IEpView: typeof import('~icons/ep/view')['default'] - IEpWarningFilled: typeof import('~icons/ep/warning-filled')['default'] - RouterLink: typeof import('vue-router')['RouterLink'] - RouterView: typeof import('vue-router')['RouterView'] - } - export interface ComponentCustomProperties { - vLoading: typeof import('element-plus/es')['ElLoadingDirective'] - } -} diff --git a/web/env.d.ts b/web/env.d.ts index 9706856f..11f02fe2 100644 --- a/web/env.d.ts +++ b/web/env.d.ts @@ -1,8 +1 @@ /// -declare module "vuex" { - export * from "vuex/types/index.d.ts"; - export * from "vuex/types/helpers.d.ts"; - export * from "vuex/types/logger.d.ts"; - export * from "vuex/types/vue.d.ts"; - } - \ No newline at end of file diff --git a/web/package.json b/web/package.json index 45656c0a..041059b5 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,8 @@ "format": "prettier --write src/materials/setters/widgets/QuotaConfig.vue" }, "dependencies": { + "@logicflow/core": "2.0.0", + "@logicflow/extension": "2.0.0", "@wangeditor/editor": "^5.1.23", "@wangeditor/editor-for-vue": "^5.1.12", "async-validator": "^4.2.5", @@ -26,12 +28,12 @@ "moment": "^2.29.4", "nanoid": "^5.0.7", "node-forge": "^1.3.1", + "pinia": "^2.1.7", "qrcode": "^1.5.3", "uuid": "^10.0.0", "vue": "^3.4.15", "vue-router": "^4.2.5", "vuedraggable": "^4.1.0", - "vuex": "^4.0.2", "xss": "^1.0.14", "yup": "^1.4.0" }, @@ -53,7 +55,7 @@ "husky": "^9.0.11", "npm-run-all2": "^6.1.1", "prettier": "^3.0.3", - "sass": "^1.72.0", + "sass": "1.77.6", "typescript": "~5.3.0", "unplugin-auto-import": "^0.17.5", "unplugin-icons": "^0.18.5", diff --git a/web/src/common/Editor/RichEditor.vue b/web/src/common/Editor/RichEditor.vue index 4d616459..2696fa16 100644 --- a/web/src/common/Editor/RichEditor.vue +++ b/web/src/common/Editor/RichEditor.vue @@ -23,31 +23,69 @@ diff --git a/web/src/management/pages/analysis/components/ImagePreview.vue b/web/src/management/pages/analysis/components/ImagePreview.vue new file mode 100644 index 00000000..3e0d99ce --- /dev/null +++ b/web/src/management/pages/analysis/components/ImagePreview.vue @@ -0,0 +1,70 @@ + + + diff --git a/web/src/management/pages/create/components/CreateForm.vue b/web/src/management/pages/create/components/CreateForm.vue index ae07eac5..43b0c628 100644 --- a/web/src/management/pages/create/components/CreateForm.vue +++ b/web/src/management/pages/create/components/CreateForm.vue @@ -38,13 +38,11 @@ diff --git a/web/src/management/pages/edit/components/Pagination/PaginationWrapper.vue b/web/src/management/pages/edit/components/Pagination/PaginationWrapper.vue new file mode 100644 index 00000000..020fcf16 --- /dev/null +++ b/web/src/management/pages/edit/components/Pagination/PaginationWrapper.vue @@ -0,0 +1,125 @@ + + + + diff --git a/web/src/management/pages/edit/components/QuestionWrapper.vue b/web/src/management/pages/edit/components/QuestionWrapper.vue index fa25395e..9d8b2cab 100644 --- a/web/src/management/pages/edit/components/QuestionWrapper.vue +++ b/web/src/management/pages/edit/components/QuestionWrapper.vue @@ -1,6 +1,6 @@ diff --git a/web/src/management/pages/edit/modules/contentModule/HistoryPanel.vue b/web/src/management/pages/edit/modules/contentModule/HistoryPanel.vue index 094921e7..b7cea2eb 100644 --- a/web/src/management/pages/edit/modules/contentModule/HistoryPanel.vue +++ b/web/src/management/pages/edit/modules/contentModule/HistoryPanel.vue @@ -25,9 +25,9 @@ + + + + diff --git a/web/src/management/pages/edit/pages/edit/LogicEditPage.vue b/web/src/management/pages/edit/modules/logicModule/ShowLogic.vue similarity index 67% rename from web/src/management/pages/edit/pages/edit/LogicEditPage.vue rename to web/src/management/pages/edit/modules/logicModule/ShowLogic.vue index 8d2d63f8..8520b13e 100644 --- a/web/src/management/pages/edit/pages/edit/LogicEditPage.vue +++ b/web/src/management/pages/edit/modules/logicModule/ShowLogic.vue @@ -5,17 +5,15 @@ diff --git a/web/src/management/pages/edit/modules/logicModule/components/ConditionView.vue b/web/src/management/pages/edit/modules/logicModule/components/ConditionView.vue index 01065654..ae98af75 100644 --- a/web/src/management/pages/edit/modules/logicModule/components/ConditionView.vue +++ b/web/src/management/pages/edit/modules/logicModule/components/ConditionView.vue @@ -57,7 +57,7 @@ import { computed, inject, ref, type ComputedRef } from 'vue' import { ConditionNode, RuleNode } from '@/common/logicEngine/RuleBuild' import { CHOICES } from '@/common/typeEnum' -import { cleanRichText } from '@/common/xss' +import { cleanRichTextWithMediaTag } from '@/common/xss' const renderData = inject>>('renderData') || ref([]) const props = defineProps({ index: { @@ -88,7 +88,7 @@ const fieldList = computed(() => { .filter((question: any) => CHOICES.includes(question.type)) .map((item: any) => { return { - label: `${item.showIndex ? item.indexNumber + '.' : ''} ${cleanRichText(item.title)}`, + label: `${item.showIndex ? item.indexNumber + '.' : ''} ${cleanRichTextWithMediaTag(item.title)}`, value: item.field } }) @@ -102,7 +102,7 @@ const getRelyOptions = computed(() => { return ( currentQuestion?.options.map((item: any) => { return { - label: cleanRichText(item.text), + label: cleanRichTextWithMediaTag(item.text), value: item.hash } }) || [] diff --git a/web/src/management/pages/edit/modules/logicModule/components/RuleNodeView.vue b/web/src/management/pages/edit/modules/logicModule/components/RuleNodeView.vue index f24a9705..3a1c1d40 100644 --- a/web/src/management/pages/edit/modules/logicModule/components/RuleNodeView.vue +++ b/web/src/management/pages/edit/modules/logicModule/components/RuleNodeView.vue @@ -52,7 +52,10 @@ import { ElMessageBox } from 'element-plus' import 'element-plus/theme-chalk/src/message-box.scss' import { RuleNode } from '@/common/logicEngine/RuleBuild' import { cleanRichText } from '@/common/xss' -import { showLogicEngine } from '@/management/hooks/useShowLogicEngine' +import { useEditStore } from '@/management/stores/edit' +import { storeToRefs } from 'pinia' +const editStore = useEditStore() +const { showLogicEngine } = storeToRefs(editStore) import ConditionView from './ConditionView.vue' const renderData = inject>>('renderData') || ref([]) diff --git a/web/src/management/pages/edit/modules/logicModule/RulePanel.vue b/web/src/management/pages/edit/modules/logicModule/components/RulePanel.vue similarity index 88% rename from web/src/management/pages/edit/modules/logicModule/RulePanel.vue rename to web/src/management/pages/edit/modules/logicModule/components/RulePanel.vue index ddee0c26..b3afb01e 100644 --- a/web/src/management/pages/edit/modules/logicModule/RulePanel.vue +++ b/web/src/management/pages/edit/modules/logicModule/components/RulePanel.vue @@ -21,8 +21,12 @@ diff --git a/web/src/management/pages/edit/modules/questionModule/PreviewPanel.vue b/web/src/management/pages/edit/modules/questionModule/PreviewPanel.vue index ea59380a..e886ae13 100644 --- a/web/src/management/pages/edit/modules/questionModule/PreviewPanel.vue +++ b/web/src/management/pages/edit/modules/questionModule/PreviewPanel.vue @@ -1,28 +1,37 @@ diff --git a/web/src/materials/questions/widgets/EditOptions/AdvancedConfig/OptionConfig.vue b/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/OptionConfig.vue similarity index 61% rename from web/src/materials/questions/widgets/EditOptions/AdvancedConfig/OptionConfig.vue rename to web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/OptionConfig.vue index 8f9c9cac..4cbca449 100644 --- a/web/src/materials/questions/widgets/EditOptions/AdvancedConfig/OptionConfig.vue +++ b/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/OptionConfig.vue @@ -1,77 +1,74 @@ diff --git a/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/index.vue b/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/index.vue new file mode 100644 index 00000000..ba18f6c2 --- /dev/null +++ b/web/src/management/pages/edit/modules/questionModule/components/AdvancedConfig/index.vue @@ -0,0 +1,47 @@ + + + diff --git a/web/src/management/pages/edit/modules/questionModule/components/QuestionCatalog.vue b/web/src/management/pages/edit/modules/questionModule/components/QuestionCatalog.vue index cff08a4e..807ba8ef 100644 --- a/web/src/management/pages/edit/modules/questionModule/components/QuestionCatalog.vue +++ b/web/src/management/pages/edit/modules/questionModule/components/QuestionCatalog.vue @@ -1,72 +1,108 @@ diff --git a/web/src/management/pages/edit/modules/settingModule/components/OverTime.vue b/web/src/management/pages/edit/modules/resultModule/components/OverTime.vue similarity index 100% rename from web/src/management/pages/edit/modules/settingModule/components/OverTime.vue rename to web/src/management/pages/edit/modules/resultModule/components/OverTime.vue diff --git a/web/src/management/pages/edit/modules/settingModule/components/SuccessContent.vue b/web/src/management/pages/edit/modules/resultModule/components/SuccessContent.vue similarity index 100% rename from web/src/management/pages/edit/modules/settingModule/components/SuccessContent.vue rename to web/src/management/pages/edit/modules/resultModule/components/SuccessContent.vue diff --git a/web/src/management/pages/edit/modules/settingModule/enum.js b/web/src/management/pages/edit/modules/resultModule/components/enum.js similarity index 100% rename from web/src/management/pages/edit/modules/settingModule/enum.js rename to web/src/management/pages/edit/modules/resultModule/components/enum.js diff --git a/web/src/management/pages/edit/modules/settingModule/SettingPanel.vue b/web/src/management/pages/edit/modules/settingModule/SettingPanel.vue index 629b713e..747b79bb 100644 --- a/web/src/management/pages/edit/modules/settingModule/SettingPanel.vue +++ b/web/src/management/pages/edit/modules/settingModule/SettingPanel.vue @@ -2,117 +2,57 @@
-
+
{{ form.title }}
- - - + :form-config-list="form.formList" + :module-config="baseConf" + :custom-components="{ + WhiteList, + TeamMemberList + }" + @form-change="handleFormChange" + >
diff --git a/web/src/management/pages/edit/modules/settingModule/components/WhiteList.vue b/web/src/management/pages/edit/modules/settingModule/components/WhiteList.vue new file mode 100644 index 00000000..6db2d252 --- /dev/null +++ b/web/src/management/pages/edit/modules/settingModule/components/WhiteList.vue @@ -0,0 +1,155 @@ + + + diff --git a/web/src/management/pages/edit/modules/settingModule/config/baseConfig.js b/web/src/management/pages/edit/modules/settingModule/config/baseConfig.js deleted file mode 100644 index 6af2a296..00000000 --- a/web/src/management/pages/edit/modules/settingModule/config/baseConfig.js +++ /dev/null @@ -1,12 +0,0 @@ -export default [ - { - title: '时间配置', - key: 'timeConfig', - formList: ['base_effectTime', 'limit_answerTime'] - }, - { - title: '提交限制', - key: 'limitConfig', - formList: ['limit_tLimit', 'limit_breakAnswer', 'limit_backAnswer'] - } -] diff --git a/web/src/management/pages/edit/modules/settingModule/config/baseFormConfig.js b/web/src/management/pages/edit/modules/settingModule/config/baseFormConfig.js deleted file mode 100644 index e1556f87..00000000 --- a/web/src/management/pages/edit/modules/settingModule/config/baseFormConfig.js +++ /dev/null @@ -1,39 +0,0 @@ -// 问卷设置,定义了字段和对应的设置器 -export default { - base_effectTime: { - keys: ['baseConf.begTime', 'baseConf.endTime'], - label: '答题有效期', - type: 'QuestionTime', - placeholder: 'yyyy-MM-dd hh:mm:ss' - }, - limit_tLimit: { - key: 'baseConf.tLimit', - label: '问卷回收总数', - type: 'InputNumber', - tip: '0为无限制,此功能用于限制该问卷总提交的数据量。当数据量达到限额时,该问卷将不能继续提交', - tipShow: true, - placement: 'top', - min: 0 - }, - limit_answerTime: { - keys: ['baseConf.answerBegTime', 'baseConf.answerEndTime'], - label: '答题时段', - tip: '问卷仅在指定时间段内可填写', - type: 'QuestionTimeHour', - placement: 'top' - }, - limit_breakAnswer: { - key: 'baseConf.breakAnswer', - label: '允许断点续答', - tip: '回填前一次作答中的内容(注:更换设备/浏览器/清除缓存/更改内容重新发布则此功能失效)', - type: 'ELSwitch', - value: false - }, - limit_backAnswer: { - key: 'baseConf.backAnswer', - label: '自动填充上次填写内容', - tip: '回填前一次提交的内容(注:更换设备/浏览器/清除缓存/更改内容重新发布则此功能失效)', - type: 'ELSwitch', - value: false - } -} diff --git a/web/src/management/pages/edit/modules/settingModule/result/SetterPanel.vue b/web/src/management/pages/edit/modules/settingModule/result/SetterPanel.vue deleted file mode 100644 index 28c74093..00000000 --- a/web/src/management/pages/edit/modules/settingModule/result/SetterPanel.vue +++ /dev/null @@ -1,120 +0,0 @@ - - - diff --git a/web/src/management/pages/edit/modules/settingModule/skin/CatalogPanel.vue b/web/src/management/pages/edit/modules/skinModule/CatalogPanel.vue similarity index 91% rename from web/src/management/pages/edit/modules/settingModule/skin/CatalogPanel.vue rename to web/src/management/pages/edit/modules/skinModule/CatalogPanel.vue index 7f663cd6..6d6c7275 100644 --- a/web/src/management/pages/edit/modules/settingModule/skin/CatalogPanel.vue +++ b/web/src/management/pages/edit/modules/skinModule/CatalogPanel.vue @@ -27,13 +27,14 @@ diff --git a/web/src/management/pages/edit/pages/edit/index.vue b/web/src/management/pages/edit/pages/edit/index.vue index 3565a758..d0b0cb7c 100644 --- a/web/src/management/pages/edit/pages/edit/index.vue +++ b/web/src/management/pages/edit/pages/edit/index.vue @@ -37,7 +37,9 @@ const activeRouter = ref(route.name) watch( activeRouter, (val: any) => { - router.push({ name: val }) + // 避免编辑页刷新丢失query + const query = route.query + router.push({ name: val, query }) }, { immediate: true @@ -55,6 +57,8 @@ watch( .navbar-tab { position: absolute; top: 10px; + left: 50%; + transform: translate(-50%); cursor: pointer; :deep(.el-radio-button__original-radio + .el-radio-button__inner) { font-size: 12px; diff --git a/web/src/management/pages/edit/pages/skin/ContentPage.vue b/web/src/management/pages/edit/pages/skin/ContentPage.vue index 5960d677..50bfe5f0 100644 --- a/web/src/management/pages/edit/pages/skin/ContentPage.vue +++ b/web/src/management/pages/edit/pages/skin/ContentPage.vue @@ -13,17 +13,17 @@ diff --git a/web/src/management/pages/list/components/SpaceList.vue b/web/src/management/pages/list/components/SpaceList.vue index a9237e96..b2128f01 100644 --- a/web/src/management/pages/list/components/SpaceList.vue +++ b/web/src/management/pages/list/components/SpaceList.vue @@ -1,14 +1,19 @@ -
+
+ +
+
+ + +
diff --git a/web/src/management/pages/list/components/SpaceModify.vue b/web/src/management/pages/list/components/SpaceModify.vue index 668fd038..5e6dac82 100644 --- a/web/src/management/pages/list/components/SpaceModify.vue +++ b/web/src/management/pages/list/components/SpaceModify.vue @@ -44,15 +44,17 @@ diff --git a/web/src/management/pages/list/components/TextSearch.vue b/web/src/management/pages/list/components/TextSearch.vue index 10a39692..5c3ace96 100644 --- a/web/src/management/pages/list/components/TextSearch.vue +++ b/web/src/management/pages/list/components/TextSearch.vue @@ -7,7 +7,7 @@ :placeholder="placeholder" > diff --git a/web/src/management/pages/list/index.vue b/web/src/management/pages/list/index.vue index 248b4bed..e9f0f23e 100644 --- a/web/src/management/pages/list/index.vue +++ b/web/src/management/pages/list/index.vue @@ -18,10 +18,12 @@
-

{{ spaceType === SpaceType.Group ? '团队空间' : '问卷' }}列表

+

+ {{ spaceType === SpaceType.Group ? '团队空间' : currentTeamSpace?.name || '问卷列表' }} +

创建团队空间 + + + 团队管理 + - +
import { ref, computed, onMounted } from 'vue' -import { useStore } from 'vuex' +import { storeToRefs } from 'pinia' import { useRouter } from 'vue-router' import BaseList from './components/BaseList.vue' import SpaceList from './components/SpaceList.vue' import SliderBar from './components/SliderBar.vue' import SpaceModify from './components/SpaceModify.vue' import { SpaceType } from '@/management/utils/types/workSpace' +import { useUserStore } from '@/management/stores/user' +import { useWorkSpaceStore } from '@/management/stores/workSpace' +import { useSurveyListStore } from '@/management/stores/surveyList' -const store = useStore() +const userStore = useUserStore() +const workSpaceStore = useWorkSpaceStore() +const surveyListStore = useSurveyListStore() + +const { surveyList, surveyTotal } = storeToRefs(surveyListStore) +const { spaceMenus, workSpaceId, spaceType, workSpaceList, workSpaceListTotal } = + storeToRefs(workSpaceStore) const router = useRouter() const userInfo = computed(() => { - return store.state.user.userInfo + return userStore.userInfo }) + const loading = ref(false) -const surveyList = computed(() => { - return store.state.list.surveyList -}) -const surveyTotal = computed(() => { - return store.state.list.surveyTotal -}) + +const spaceListRef = ref(null) +const spaceLoading = ref(false) + const activeIndex = ref('1') -const spaceMenus = computed(() => { - return store.state.list.spaceMenus -}) -const workSpaceId = computed(() => { - return store.state.list.workSpaceId -}) -const spaceType = computed(() => { - return store.state.list.spaceType -}) -const handleSpaceSelect = (id: any) => { - if (id === SpaceType.Personal) { - // 点击个人空间菜单 - if (store.state.list.spaceType === SpaceType.Personal) { - return - } - store.commit('list/changeSpaceType', SpaceType.Personal) - store.commit('list/changeWorkSpace', '') - } else if (id === SpaceType.Group) { - // 点击团队空间组菜单 - if (store.state.list.spaceType === SpaceType.Group) { - return - } - store.commit('list/changeSpaceType', SpaceType.Group) - store.commit('list/changeWorkSpace', '') - } else if (!Object.values(SpaceType).includes(id)) { - // 点击具体团队空间 - if (store.state.list.workSpaceId === id) { - return - } - store.commit('list/changeSpaceType', SpaceType.Teamwork) - store.commit('list/changeWorkSpace', id) +const fetchSpaceList = async (params?: any) => { + spaceLoading.value = true + workSpaceStore.changeWorkSpace('') + workSpaceStore.getSpaceList(params) + spaceLoading.value = false +} + +const handleSpaceSelect = (id: SpaceType | string) => { + if (id === spaceType.value || id === workSpaceId.value) { + return void 0 } + switch (id) { + case SpaceType.Personal: + workSpaceStore.changeSpaceType(SpaceType.Personal) + workSpaceStore.changeWorkSpace('') + break + case SpaceType.Group: + workSpaceStore.changeSpaceType(SpaceType.Group) + workSpaceStore.changeWorkSpace('') + fetchSpaceList() + break + default: + workSpaceStore.changeSpaceType(SpaceType.Teamwork) + workSpaceStore.changeWorkSpace(id) + break + } fetchSurveyList() } -onMounted(() => { - fetchSpaceList() - fetchSurveyList() -}) -const fetchSpaceList = () => { - store.dispatch('list/getSpaceList') -} + const fetchSurveyList = async (params?: any) => { if (!params) { params = { @@ -136,15 +145,35 @@ const fetchSurveyList = async (params?: any) => { params.workspaceId = workSpaceId.value } loading.value = true - await store.dispatch('list/getSurveyList', params) + await surveyListStore.getSurveyList(params) loading.value = false } + +onMounted(() => { + fetchSpaceList() + fetchSurveyList() +}) + const modifyType = ref('add') const showSpaceModify = ref(false) +// 当前团队信息 +const currentTeamSpace = computed(() => { + return workSpaceList.value.find((item: any) => item._id === workSpaceId.value) +}) + +const onSetGroup = async () => { + await workSpaceStore.getSpaceDetail(workSpaceId.value) + modifyType.value = 'edit' + showSpaceModify.value = true +} + const onCloseModify = (type: string) => { showSpaceModify.value = false - if (type === 'update') fetchSpaceList() + if (type === 'update' && spaceListRef.value) { + fetchSpaceList() + spaceListRef.value.onCloseModify() + } } const onSpaceCreate = () => { showSpaceModify.value = true @@ -153,7 +182,7 @@ const onCreate = () => { router.push('/create') } const handleLogout = () => { - store.dispatch('user/logout') + userStore.logout() router.replace({ name: 'login' }) } // 下载页面 @@ -242,10 +271,9 @@ const handleDownload = () => { .create-btn { background: #4a4c5b; + color: #fff; } - .space-btn { - background: $primary-color; - } + .btn { width: 132px; height: 32px; @@ -253,10 +281,9 @@ const handleDownload = () => { justify-content: center; align-items: center; - color: #fff; - + .icon-shujuliebiao, .icon-chuangjian { - padding-right: 8px; + padding-right: 5px; font-size: 14px; } diff --git a/web/src/management/pages/login/LoginPage.vue b/web/src/management/pages/login/LoginPage.vue index d502cf84..6564c9bf 100644 --- a/web/src/management/pages/login/LoginPage.vue +++ b/web/src/management/pages/login/LoginPage.vue @@ -57,7 +57,6 @@ - diff --git a/web/src/materials/questions/widgets/EditOptions/Options/OptionEdit.vue b/web/src/materials/questions/widgets/EditOptions/Options/OptionEdit.vue index 8025a528..0fa44cf9 100644 --- a/web/src/materials/questions/widgets/EditOptions/Options/OptionEdit.vue +++ b/web/src/materials/questions/widgets/EditOptions/Options/OptionEdit.vue @@ -12,6 +12,7 @@
diff --git a/web/src/materials/questions/widgets/EditOptions/Options/OptionEditBar.vue b/web/src/materials/questions/widgets/EditOptions/Options/OptionEditBar.vue index 58a66a64..e9689c47 100644 --- a/web/src/materials/questions/widgets/EditOptions/Options/OptionEditBar.vue +++ b/web/src/materials/questions/widgets/EditOptions/Options/OptionEditBar.vue @@ -1,60 +1,22 @@ diff --git a/web/src/materials/setters/widgets/RichText.vue b/web/src/materials/setters/widgets/RichText.vue index 80160930..a6194ccf 100644 --- a/web/src/materials/setters/widgets/RichText.vue +++ b/web/src/materials/setters/widgets/RichText.vue @@ -15,12 +15,7 @@ interface Props { formConfig: any } -interface Emit { - (ev: typeof FORM_CHANGE_EVENT_KEY, arg: { key: string; value: string }): void - (ev: 'change' | 'input', value: string): void -} - -const emit = defineEmits() +const emit = defineEmits([FORM_CHANGE_EVENT_KEY, 'change', 'input']) const props = withDefaults(defineProps(), { formConfig: {} }) const handleEditorValueChange = (eventType: 'change' | 'input', value: string) => { diff --git a/web/src/render/App.vue b/web/src/render/App.vue index c4f914ea..28416b75 100644 --- a/web/src/render/App.vue +++ b/web/src/render/App.vue @@ -1,15 +1,13 @@ diff --git a/web/src/render/components/MaterialGroup.vue b/web/src/render/components/MaterialGroup.vue index 78b3b6fb..35852283 100644 --- a/web/src/render/components/MaterialGroup.vue +++ b/web/src/render/components/MaterialGroup.vue @@ -13,6 +13,7 @@ diff --git a/web/src/render/components/VerifyWhiteDialog.vue b/web/src/render/components/VerifyWhiteDialog.vue new file mode 100644 index 00000000..4e61768f --- /dev/null +++ b/web/src/render/components/VerifyWhiteDialog.vue @@ -0,0 +1,147 @@ + + + diff --git a/web/src/render/hooks/useProgress.js b/web/src/render/hooks/useProgress.js index 731b0a48..b76981cb 100644 --- a/web/src/render/hooks/useProgress.js +++ b/web/src/render/hooks/useProgress.js @@ -1,6 +1,7 @@ -import store from '../store/index' +import { useSurveyStore } from '../stores/survey' import { computed } from 'vue' export const useProgressBar = () => { + const surveyStore = useSurveyStore() const isVariableEmpty = (variable) => { if (variable === undefined || variable === null) { return true @@ -22,7 +23,7 @@ export const useProgressBar = () => { fillCount: 0, topicCount: 0 } - const formValues = store.state.formValues + const formValues = surveyStore.formValues for (let key in formValues) { if (key.split('_').length > 1) continue diff --git a/web/src/render/hooks/useRuleEngine.js b/web/src/render/hooks/useRuleEngine.js deleted file mode 100644 index 23f4980d..00000000 --- a/web/src/render/hooks/useRuleEngine.js +++ /dev/null @@ -1,6 +0,0 @@ -import { RuleMatch } from '@/common/logicEngine/RulesMatch' - -export const ruleEngine = new RuleMatch() -export const initRuleEngine = (ruleConf) => { - ruleEngine.fromJson(ruleConf) -} diff --git a/web/src/render/hooks/useShowInput.js b/web/src/render/hooks/useShowInput.js index 2bc446ee..b4982c4f 100644 --- a/web/src/render/hooks/useShowInput.js +++ b/web/src/render/hooks/useShowInput.js @@ -1,8 +1,12 @@ -import store from '../store/index' +import { useQuestionStore } from '../stores/question' +import { useSurveyStore } from '../stores/survey' + export const useShowInput = (questionKey) => { - const formValues = store.state.formValues + const questionStore = useQuestionStore() + const surveyStore = useSurveyStore() + const formValues = surveyStore.formValues const questionVal = formValues[questionKey] - let rangeConfig = store.state.questionData[questionKey].rangeConfig + let rangeConfig = questionStore.questionData[questionKey].rangeConfig let othersValue = {} if (rangeConfig && Object.keys(rangeConfig).length > 0) { for (let key in rangeConfig) { @@ -18,7 +22,8 @@ export const useShowInput = (questionKey) => { key: rangeKey, value: '' } - store.commit('changeFormData', data) + + surveyStore.changeData(data) } } } diff --git a/web/src/render/hooks/useShowOthers.js b/web/src/render/hooks/useShowOthers.js index 379c9874..df5a8b9e 100644 --- a/web/src/render/hooks/useShowOthers.js +++ b/web/src/render/hooks/useShowOthers.js @@ -1,9 +1,13 @@ -import store from '../store/index' +import { useQuestionStore } from '../stores/question' +import { useSurveyStore } from '../stores/survey' + export const useShowOthers = (questionKey) => { - const formValues = store.state.formValues + const questionStore = useQuestionStore() + const surveyStore = useSurveyStore() + const formValues = surveyStore.formValues const questionVal = formValues[questionKey] let othersValue = {} - let options = store.state.questionData[questionKey].options.map((optionItem) => { + let options = questionStore.questionData[questionKey].options.map((optionItem) => { if (optionItem.others) { const opKey = `${questionKey}_${optionItem.hash}` othersValue[opKey] = formValues[opKey] @@ -13,7 +17,7 @@ export const useShowOthers = (questionKey) => { key: opKey, value: '' } - store.commit('changeFormData', data) + surveyStore.changeData(data) } return { ...optionItem, diff --git a/web/src/render/hooks/useVoteMap.js b/web/src/render/hooks/useVoteMap.js index b59b9f59..31a1b397 100644 --- a/web/src/render/hooks/useVoteMap.js +++ b/web/src/render/hooks/useVoteMap.js @@ -1,9 +1,12 @@ -import store from '../store/index' +import { useQuestionStore } from '../stores/question' + export const useVoteMap = (questionKey) => { - let voteTotal = store.state.voteMap?.[questionKey]?.total || 0 - const options = store.state.questionData[questionKey].options.map((option) => { + const questionStore = useQuestionStore() + let voteTotal = questionStore.voteMap?.[questionKey]?.total || 0 + + const options = questionStore.questionData[questionKey].options.map((option) => { const optionHash = option.hash - const voteCount = store.state.voteMap?.[questionKey]?.[optionHash] || 0 + const voteCount = questionStore.voteMap?.[questionKey]?.[optionHash] || 0 return { ...option, diff --git a/web/src/render/index.html b/web/src/render/index.html index 7a55f7f4..cd6aaa9b 100644 --- a/web/src/render/index.html +++ b/web/src/render/index.html @@ -38,7 +38,14 @@ window.addEventListener('resize', resetRemUnit) })() - + diff --git a/web/src/render/main.js b/web/src/render/main.js index e8cc2762..ca68f229 100644 --- a/web/src/render/main.js +++ b/web/src/render/main.js @@ -2,15 +2,16 @@ import { createApp } from 'vue' import App from './App.vue' import EventBus from './utils/eventbus' import router from './router' -import store from './store' +import { createPinia } from 'pinia' const app = createApp(App) +const pinia = createPinia() const $bus = new EventBus() app.provide('$bus', $bus) // 挂载到this上 app.config.globalProperties.$bus = $bus +app.use(pinia) app.use(router) -app.use(store) app.mount('#app') diff --git a/web/src/render/pages/EmptyPage.vue b/web/src/render/pages/EmptyPage.vue index 74c7206f..2bb2f28d 100644 --- a/web/src/render/pages/EmptyPage.vue +++ b/web/src/render/pages/EmptyPage.vue @@ -7,9 +7,7 @@
- + diff --git a/web/src/render/store/getters.js b/web/src/render/store/getters.js deleted file mode 100644 index 79d8a23f..00000000 --- a/web/src/render/store/getters.js +++ /dev/null @@ -1,30 +0,0 @@ -export default { - // 题目列表 - renderData: (state) => { - const { questionSeq, questionData } = state - - let index = 1 - return ( - questionSeq && - questionSeq.reduce((pre, item) => { - const questionArr = [] - - item.forEach((questionKey) => { - const question = { ...questionData[questionKey] } - // 开启显示序号 - if (question.showIndex) { - question.indexNumber = index++ - } - - questionArr.push(question) - }) - - if (questionArr && questionArr.length) { - pre.push(questionArr) - } - - return pre - }, []) - ) - } -} diff --git a/web/src/render/store/index.js b/web/src/render/store/index.js deleted file mode 100644 index 4013b40c..00000000 --- a/web/src/render/store/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import { createStore } from 'vuex' - -import state from './state' -import getters from './getters' -import mutations from './mutations' -import actions from './actions' - -export default createStore({ - state, - getters, - mutations, - actions -}) diff --git a/web/src/render/stores/errorInfo.js b/web/src/render/stores/errorInfo.js new file mode 100644 index 00000000..b0b6e7a0 --- /dev/null +++ b/web/src/render/stores/errorInfo.js @@ -0,0 +1,22 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' + +export const useErrorInfo = defineStore('errorInfo', () => { + const errorInfo = ref({ + errorType: '', + errorMsg: '' + }) + + const setErrorInfo = ({ errorType, errorMsg }) => { + errorInfo.value = { + errorType, + errorMsg + } + } + + return { + errorInfo, + + setErrorInfo + } +}) diff --git a/web/src/render/stores/question.js b/web/src/render/stores/question.js new file mode 100644 index 00000000..0f810353 --- /dev/null +++ b/web/src/render/stores/question.js @@ -0,0 +1,224 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' +import { set } from 'lodash-es' +import { useSurveyStore } from '@/render/stores/survey' +import { queryVote } from '@/render/api/survey' + +const VOTE_INFO_KEY = 'voteinfo' + +export const useQuestionStore = defineStore('question', () => { + const voteMap = ref({}) + const questionData = ref(null) + const questionSeq = ref([]) // 题目的顺序,因为可能会有分页的情况,所以是一个二维数组[[qid1, qid2], [qid3,qid4]] + const pageIndex = ref(1) // 当前分页的索引 + const changeField = ref(null) + const changeIndex = computed(() => { + return questionData.value[changeField.value].index + }) + const needHideFields = ref([]) + + // 题目列表 + const questionList = computed(() => { + let index = 1 + return ( + questionSeq.value && + questionSeq.value.reduce((pre, item) => { + const questionArr = [] + + item.forEach((questionKey) => { + const question = { ...questionData.value[questionKey] } + // 开启显示序号 + if (question.showIndex) { + question.indexNumber = index++ + } + + questionArr.push(question) + }) + + if (questionArr && questionArr.length) { + pre.push(questionArr) + } + + return pre + }, []) + ) + }) + + const renderData = computed(() => { + const { startIndex, endIndex } = getSorter() + const data = questionList.value[0] + if (!data || !Array.isArray(data) || data.length === 0) return [] + return [data.slice(startIndex, endIndex)] + }) + + const isFinallyPage = computed(() => { + const surveyStore = useSurveyStore() + return pageIndex.value === surveyStore.pageConf.length + }) + + const addPageIndex = () => { + pageIndex.value++ + } + + const getSorter = () => { + let startIndex = 0 + const surveyStore = useSurveyStore() + const newPageEditOne = pageIndex.value + const endIndex = surveyStore.pageConf[newPageEditOne - 1] + + for (let index = 0; index < surveyStore.pageConf.length; index++) { + const item = surveyStore.pageConf[index] + if (newPageEditOne - 1 == index) { + break + } + startIndex += item + } + return { + startIndex, + endIndex: startIndex + endIndex + } + } + + const setQuestionData = (data) => { + questionData.value = data + } + + const changeSelectMoreData = (data) => { + const { key, value, field } = data + set(questionData.value, `${field}.othersValue.${key}`, value) + } + + const setQuestionSeq = (data) => { + questionSeq.value = data + } + + const setVoteMap = (data) => { + voteMap.value = data + } + + const updateVoteMapByKey = (data) => { + const { questionKey, voteKey, voteValue } = data + // 兼容为空的情况 + if (!voteMap.value[questionKey]) { + voteMap.value[questionKey] = {} + } + voteMap.value[questionKey][voteKey] = voteValue + } + + //初始化投票题的数据 + const initVoteData = async () => { + const surveyStore = useSurveyStore() + const surveyPath = surveyStore.surveyPath + + const fieldList = [] + + for (const field in questionData.value) { + const { type } = questionData.value[field] + if (/vote/.test(type)) { + fieldList.push(field) + } + } + + if (fieldList.length <= 0) { + return + } + try { + localStorage.removeItem(VOTE_INFO_KEY) + const voteRes = await queryVote({ + surveyPath, + fieldList: fieldList.join(',') + }) + + if (voteRes.code === 200) { + localStorage.setItem( + VOTE_INFO_KEY, + JSON.stringify({ + ...voteRes.data + }) + ) + setVoteMap(voteRes.data) + } + } catch (error) { + console.log(error) + } + } + + const updateVoteData = (data) => { + const { key: questionKey, value: questionVal } = data + // 更新前获取接口缓存在localStorage中的数据 + const localData = localStorage.getItem(VOTE_INFO_KEY) + const voteinfo = JSON.parse(localData) + const currentQuestion = questionData.value[questionKey] + const options = currentQuestion.options + const voteTotal = voteinfo?.[questionKey]?.total || 0 + let totalPayload = { + questionKey, + voteKey: 'total', + voteValue: voteTotal + } + options.forEach((option) => { + const optionhash = option.hash + const voteCount = voteinfo?.[questionKey]?.[optionhash] || 0 + // 如果选中值包含该选项,对应voteCount 和 voteTotal + 1 + if ( + Array.isArray(questionVal) ? questionVal.includes(optionhash) : questionVal === optionhash + ) { + const countPayload = { + questionKey, + voteKey: optionhash, + voteValue: voteCount + 1 + } + totalPayload.voteValue += 1 + updateVoteMapByKey(countPayload) + } else { + const countPayload = { + questionKey, + voteKey: optionhash, + voteValue: voteCount + } + updateVoteMapByKey(countPayload) + } + updateVoteMapByKey(totalPayload) + }) + } + + const setChangeField = (field) => { + changeField.value = field + } + const getQuestionIndexByField = (field) => { + return questionData.value[field].index + } + const addNeedHideFields = (fields) => { + fields.forEach(field => { + if(!needHideFields.value.includes(field)) { + needHideFields.value.push(field) + } + }) + } + const removeNeedHideFields = (fields) => { + needHideFields.value = needHideFields.value.filter(field => !fields.includes(field)) + } + return { + voteMap, + questionData, + questionSeq, + renderData, + isFinallyPage, + pageIndex, + addPageIndex, + setQuestionData, + changeSelectMoreData, + setQuestionSeq, + setVoteMap, + updateVoteMapByKey, + initVoteData, + updateVoteData, + changeField, + changeIndex, + setChangeField, + needHideFields, + addNeedHideFields, + removeNeedHideFields, + getQuestionIndexByField + } +}) diff --git a/web/src/render/stores/survey.js b/web/src/render/stores/survey.js new file mode 100644 index 00000000..9a188d3a --- /dev/null +++ b/web/src/render/stores/survey.js @@ -0,0 +1,202 @@ +import { ref } from 'vue' +import { useRouter } from 'vue-router' +import { defineStore } from 'pinia' +import { pick } from 'lodash-es' + +import { isMobile as isInMobile } from '@/render/utils/index' +import { getEncryptInfo as getEncryptInfoApi } from '@/render/api/survey' +import { useQuestionStore } from '@/render/stores/question' +import { useErrorInfo } from '@/render/stores/errorInfo' + +import moment from 'moment' +// 引入中文 +import 'moment/locale/zh-cn' +// 设置中文 +moment.locale('zh-cn') + +import adapter from '../adapter' +import { RuleMatch } from '@/common/logicEngine/RulesMatch' +// import { jumpLogicRule } from '@/common/logicEngine/jumpLogicRule' + +/** + * CODE_MAP不从management引入,在dev阶段,会导致B端 router被加载,进而导致C端路由被添加 baseUrl: /management + */ +const CODE_MAP = { + SUCCESS: 200, + ERROR: 500, + NO_AUTH: 403 +} +export const useSurveyStore = defineStore('survey', () => { + const surveyPath = ref('') + const isMobile = ref(isInMobile()) + const enterTime = ref(0) + const encryptInfo = ref(null) + const rules = ref({}) + const bannerConf = ref({}) + const baseConf = ref({}) + const bottomConf = ref({}) + const dataConf = ref({}) + const skinConf = ref({}) + const submitConf = ref({}) + const formValues = ref({}) + const whiteData = ref({}) + const pageConf = ref([]) + + + const router = useRouter() + const questionStore = useQuestionStore() + const { setErrorInfo } = useErrorInfo() + + const setWhiteData = (data) => { + whiteData.value = data + } + + const setSurveyPath = (data) => { + surveyPath.value = data + } + + const setEnterTime = () => { + enterTime.value = Date.now() + } + + const getEncryptInfo = async () => { + try { + const res = await getEncryptInfoApi() + if (res.code === CODE_MAP.SUCCESS) { + encryptInfo.value = res.data + } + } catch (error) { + console.log(error) + } + } + + const canFillQuestionnaire = (baseConf, submitConf) => { + const { begTime, endTime, answerBegTime, answerEndTime } = baseConf + const { msgContent } = submitConf + const now = Date.now() + let isSuccess = true + + if (now < new Date(begTime).getTime()) { + isSuccess = false + setErrorInfo({ + errorType: 'overTime', + errorMsg: `

问卷未到开始填写时间,暂时无法进行填写

+

开始时间为: ${begTime}

` + }) + } else if (now > new Date(endTime).getTime()) { + isSuccess = false + setErrorInfo({ + errorType: 'overTime', + errorMsg: msgContent.msg_9001 || '您来晚了,感谢支持问卷~' + }) + } else if (answerBegTime && answerEndTime) { + const momentNow = moment() + const todayStr = momentNow.format('yyyy-MM-DD') + const momentStartTime = moment(`${todayStr} ${answerBegTime}`) + const momentEndTime = moment(`${todayStr} ${answerEndTime}`) + if (momentNow.isBefore(momentStartTime) || momentNow.isAfter(momentEndTime)) { + isSuccess = false + setErrorInfo({ + errorType: 'overTime', + errorMsg: `

不在答题时间范围内,暂时无法进行填写

+

答题时间为: ${answerBegTime} ~ ${answerEndTime}

` + }) + } + } + + if (!isSuccess) { + router.push({ name: 'errorPage' }) + } + + return isSuccess + } + const initSurvey = (option) => { + setEnterTime() + + if (!canFillQuestionnaire(option.baseConf, option.submitConf)) { + return + } + + + + // 根据初始的schema生成questionData, questionSeq, rules, formValues, 这四个字段 + const { + questionData, + questionSeq, + rules: _rules, + formValues: _formValues + } = adapter.generateData( + pick(option, [ + 'bannerConf', + 'baseConf', + 'bottomConf', + 'dataConf', + 'skinConf', + 'submitConf', + 'whiteData', + 'pageConf' + ]) + ) + questionStore.questionData = questionData + questionStore.questionSeq = questionSeq + + // 将数据设置到state上 + rules.value = _rules + bannerConf.value = option.bannerConf + baseConf.value = option.baseConf + bottomConf.value = option.bottomConf + dataConf.value = option.dataConf + skinConf.value = option.skinConf + submitConf.value = option.submitConf + formValues.value = _formValues + whiteData.value = option.whiteData + pageConf.value = option.pageConf + // 获取已投票数据 + questionStore.initVoteData() + } + + // 用户输入或者选择后,更新表单数据 + const changeData = (data) => { + let { key, value } = data + if (key in formValues.value) { + formValues.value[key] = value + } + questionStore.setChangeField(key) + } + + const showLogicEngine = ref() + const initShowLogicEngine = (showLogicConf) => { + showLogicEngine.value = new RuleMatch().fromJson(showLogicConf) + } + const jumpLogicEngine = ref() + const initJumpLogicEngine = (jumpLogicConf) => { + jumpLogicEngine.value = new RuleMatch().fromJson(jumpLogicConf) + } + + return { + surveyPath, + isMobile, + enterTime, + encryptInfo, + rules, + bannerConf, + baseConf, + bottomConf, + dataConf, + skinConf, + submitConf, + formValues, + whiteData, + pageConf, + initSurvey, + changeData, + setWhiteData, + setSurveyPath, + setEnterTime, + getEncryptInfo, + showLogicEngine, + initShowLogicEngine, + jumpLogicEngine, + initJumpLogicEngine + } +}) diff --git a/web/src/render/styles/default.scss b/web/src/render/styles/default.scss deleted file mode 100644 index eaebf2a6..00000000 --- a/web/src/render/styles/default.scss +++ /dev/null @@ -1,22 +0,0 @@ -$primary-color: #faa600; -$primary-color-light: hsl(48, 100%, 97%); - -$title-color-deep: #292a36; -$title-color: #4a4c5b; -$font-color: #6e707c; -$remark-color: #4a4c5b; -$placeholder-color: #c8c9cd; -$light-focus-color: #666666; - -$disable-color: #f2f4f7; -$border-color: #dee2e6; - -$spliter-color: #f7f7f7; - -$error-color: #ec4e29; - -@import './variable'; - -$title-size: 0.32rem; -$font-size: 0.28rem; -$tip-size: 0.22rem; diff --git a/web/src/render/styles/variable.scss b/web/src/render/styles/variable.scss index 2b0db56a..eeb4fb54 100644 --- a/web/src/render/styles/variable.scss +++ b/web/src/render/styles/variable.scss @@ -10,6 +10,6 @@ $placeholder-color: #c8c9cd; $disable-color: #f2f4f7; $border-color: #dee2e6; -$spliter-color: #f7f7f7; +$spliter-color: hsl(0, 0%, 97%, 0.5); $error-color: #ec4e29; diff --git a/web/src/render/utils/index.js b/web/src/render/utils/index.js index 610e506f..7f9cf2b2 100644 --- a/web/src/render/utils/index.js +++ b/web/src/render/utils/index.js @@ -29,4 +29,4 @@ export const formatLink = (url) => { return url } return `http://${url}` -} +} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts index f8527f21..1b551271 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -61,7 +61,13 @@ export default defineConfig({ 'clipboard', 'qrcode', 'moment', - 'moment/locale/zh-cn' + 'moment/locale/zh-cn', + 'echarts', + 'nanoid', + 'yup', + 'crypto-js/sha256', + 'element-plus/es/locale/lang/zh-cn', + 'node-forge' ] }, plugins: [ @@ -114,6 +120,11 @@ export default defineConfig({ '/api': { target: 'http://127.0.0.1:3000', changeOrigin: true + }, + // 静态文件的默认存储文件夹 + '/userUpload': { + target: 'http://127.0.0.1:3000', + changeOrigin: true } } },