Compare commits

...

57 Commits

Author SHA1 Message Date
dayou
adec40cfc2
fix: 跳转逻辑优化 (#397)
* fix: 跳转逻辑优化

* fix: processJumpSkip逻辑放在题目组件中
2024-08-14 16:30:11 +08:00
taoshaung
965dc89c54 Merge branch 'develop' into feature/jumpLogic 2024-08-13 20:15:09 +08:00
sudoooooo
9e07e8330a feat: 升级iconfont 2024-08-13 11:46:59 +08:00
sudoooooo
8950073141 fix: 新建问卷重置计数 2024-08-12 23:10:21 +08:00
Jiangchunfu
4d580bb789
feat: 整卷增加基础配置:必填、显示类型、显示序号、显示分割线 (#391)
* feat: 增加整卷配置功能

* fix: 限制单题修改配置时只对基础配置进行更新全局基础配置操作

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-12 21:39:19 +08:00
Jiangchunfu
f73bfb0ab3
feat: C端增加重新填写入口, #182 (#392)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-12 19:43:55 +08:00
sudoooooo
8109d350e4 feat: 优化readme 2024-08-12 19:42:01 +08:00
sudoooooo
b233023bb3 fix: 连续添加题目频繁触发事件导致页面卡顿 2024-08-12 16:08:57 +08:00
sudoooooo
fd7cc2ea96 feat: action不处理format 2024-08-12 14:09:29 +08:00
Jiangchunfu
724535a735
refactor: 题目删除和逻辑关联优化,#182 (#393)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-12 14:06:38 +08:00
dayou
47ae3ec095
feat: 跳转逻辑 (#388) 2024-08-08 20:05:16 +08:00
sudoooooo
b5c7ec3008 fix: 皮肤设置tab问题 2024-08-07 22:09:55 +08:00
sudoooooo
c5698ad631 fix: 修复白名单切换和空间成员名字问题 2024-08-07 22:07:00 +08:00
chaorenluo
6fb337633c
fix:修复C端白名单验证弹框不出现的问题 (#389) 2024-08-07 18:33:23 +08:00
Jiangchunfu
42b8d74ead
refactor: 设置器加载统一,代码优化 #269 (#383)
* feat: 小功能建设(4)

* refactor: 设置器加载统一,代码优化 #269

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-07 14:53:14 +08:00
sudoooooo
d8e76dc2e6 feat: 修改Readme 2024-08-06 23:46:21 +08:00
sudoooooo
4f2cd4ca47 feat: 修改字段 2024-08-06 19:59:35 +08:00
sudoooooo
82c98ec1f5 feat: 优化分页器结构 2024-08-06 19:33:11 +08:00
chaorenluo
fbc654f21b
feat:新增分页功能 (#382)
* feat:新增分页功能

* 修复type-check检查

* fix: server  type-check

* fix:修改服务端测试用例

* fix:修复分页bug
2024-08-06 17:30:12 +08:00
Jiangchunfu
9d89a1ceca fix: 修复题目未聚焦时拖拽按钮失效问题 (#375)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-04 12:41:42 +08:00
Jiangchunfu
517906f77f
feat: 小功能建设(4) (#379) 2024-08-04 12:38:28 +08:00
sudoooooo
681e8fa3ae fix: 修复字段difTime->diffTime 2024-08-04 12:21:08 +08:00
Jiangchunfu
4d5c3eb15d
小功能优化8 (#371)
* refactor: 去除重复元素

* feat: edit 布局优化

* style: 题目大纲滚动时固定tab-header

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-07-31 17:21:54 +08:00
离谱
9a35de7e36
fix(Navbar):修复了短标题hover空白处会触发指示框的bug (#365)
fix(Navbar):修复了短标题hover空白处会触发指示框的bug
2024-07-30 15:27:49 +08:00
dayou
c4b730c8af
fix: 修复提交设置 (#370) 2024-07-30 14:51:33 +08:00
Jiangchunfu
8ea8869ca7 fix: 修复新增同类型题目设置值不变问题 (#359)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-07-23 15:43:10 +08:00
dayou
7e5a8ae5c1
fix: 修复设置器不更新问题 (#361) 2024-07-23 15:39:43 +08:00
sudoooooo
cf495b60d1 Merge branch 'feature/pinia' into develop
# Conflicts:
#	web/src/management/components/LeftMenu.vue
#	web/src/management/pages/edit/components/ModuleNavbar.vue
#	web/src/management/pages/edit/modules/contentModule/PublishPanel.vue
#	web/src/management/pages/edit/modules/contentModule/SavePanel.vue
#	web/src/management/pages/edit/modules/resultModule/CatalogPanel.vue
#	web/src/management/pages/edit/modules/settingModule/SettingPanel.vue
#	web/src/management/pages/list/components/BaseList.vue
#	web/src/management/pages/list/components/SpaceList.vue
#	web/src/management/pages/list/components/SpaceModify.vue
#	web/src/management/pages/list/index.vue
#	web/src/management/router/index.ts
#	web/src/management/store/edit/getters.js
#	web/src/management/store/list/index.js
#	web/src/management/utils/index.js
#	web/src/materials/questions/widgets/BinaryChoiceModule/meta.js
#	web/src/materials/questions/widgets/CheckboxModule/meta.js
#	web/src/materials/questions/widgets/RadioModule/meta.js
#	web/src/materials/questions/widgets/VoteModule/meta.js
#	web/src/render/pages/RenderPage.vue
#	web/src/render/store/actions.js
#	web/src/render/store/mutations.js
#	web/src/render/store/state.js
2024-07-23 14:14:52 +08:00
sudoooooo
fb57eaaba7 feat: 优化结构 2024-07-22 20:37:11 +08:00
sudoooooo
b494bd6174 feat: 优化编辑页结构 2024-07-22 20:27:12 +08:00
sudoooooo
310fe0d325 feat: 优化编辑页模块和配置结构 2024-07-22 17:33:59 +08:00
Stahsf
ba418c5cd7
feat: 白名单功能-服务端 (#357) 2024-07-20 14:11:19 +08:00
chaorenluo
9596cd07a1
前端新增白名单功能 (#356) 2024-07-19 22:45:40 +08:00
sudoooooo
2ed5b64b18 feat: 优化图片尺寸用于移动端 2024-07-18 21:07:25 +08:00
nil
492e0055f0
feat: 题目与选项支持图片 (#291)
* feat: 题目与选项支持图片

* fix: 修复使用本地存储时文件访问路径不正确的问题

* fix: 图片编辑表单无法输入

* chore: 添加上传文件夹到gitignore

* fix: 两个#app的问题
2024-07-18 10:21:09 +08:00
sudoooooo
93938702fe feat: 更新readme和docker tag 2024-07-17 23:12:17 +08:00
sudoooooo
e8907ca4fb fix: format报错 2024-07-16 11:33:16 +08:00
Jiangchunfu
2b32850046
feat: 题型硬编码优化 (#343)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-07-16 11:25:08 +08:00
sudoooooo
da1749fb53 feat: 代码format、升级ts和lint 2024-07-15 22:38:14 +08:00
Ken
5bc5eb8719
feat: 空间列表分页和搜索及数据列表优化 (#344)
1、fix: 修复因滚动条宽度影响皮肤标签的问题
2、feat: 空间列表分页和搜索及数据列表样式优化
3、feat: 对部分代码进行了优化
2024-07-15 18:30:50 +08:00
sudoooooo
3227a799f9 fix: 移除选项默认选中 2024-07-15 18:00:07 +08:00
Jiangchunfu
8740685a4d
feat: 功能18优化 (#342)
题目标题编辑自动focus
2024-07-15 17:50:00 +08:00
sudoooooo
5c3915a74d fix: 空间列表高度 2024-07-15 12:11:57 +08:00
ysansan
b1958ec8ff
问卷编辑页面题型选择tab、团队空间列表按钮优化 (#337)
* feat:问卷编辑页面题型选择tab优化

* feat: 团队空间列表按钮优化
2024-07-15 11:24:51 +08:00
sudoooooo
bc39e9933d fix: 避免element-plus提示sass语法 2024-07-15 11:21:35 +08:00
sudoooooo
36dd5a4f2d feat: 增加csdn和x 2024-07-12 16:49:40 +08:00
sudoooooo
a101878313 feat: 优化协作管理路径 2024-07-10 18:36:54 +08:00
Ken
32e43b8260
feat: 协作者管理优化和皮肤标签样式调整 (#333)
* feat: 皮肤设置内主题分类标签样式调整

* feat: 协作者管理优化
2024-07-10 18:06:39 +08:00
Jiangchunfu
9afb23c08e
feat(小功能建设): 15团队空间问卷列表页优化 (#334) 2024-07-10 16:20:31 +08:00
sudoooooo
eaa1abda82 fix: sass1.77.7规则升级warning 2024-07-10 16:07:29 +08:00
hiStephen
6431cc3210
feat: echarts按需引入 (#332) 2024-07-10 15:38:08 +08:00
sudoooooo
122f584cad fix: 修复预览页logo展示问题 2024-07-09 15:23:02 +08:00
Jiangchunfu
f45cf7982f
小功能优化 (#329)
* feat(换肤设置优化): 边距的颜色优化成背景色一致

* feat: 问卷设置优化
2024-07-09 14:17:44 +08:00
Ken
2f0736fd95
feat: 移动端预览优化 (#326)
* feat: 移动端预览优化

* feat: C端底部logo优化
2024-07-09 12:12:13 +08:00
sudoooooo
6c72344204 fix: 修复nginx启动render页空白问题 2024-07-08 21:06:30 +08:00
sudoooooo
61fd6e09af fix: 优化依赖项 2024-07-01 14:14:07 +08:00
若川
349b4dad8c
fix: 🐛 修复引入 import lodash cloneDeep debounce 错误 改为 lodash-es 了 (#322) 2024-07-01 14:08:57 +08:00
174 changed files with 4967 additions and 1109 deletions

View File

@ -37,6 +37,3 @@ jobs:
- name: Lint
run: cd server && npm run lint
- name: Format
run: cd server && npm run format

View File

@ -40,6 +40,3 @@ jobs:
- name: Lint
run: cd web && npm run lint
- name: Format
run: cd web && npm run format

4
.gitignore vendored
View File

@ -25,3 +25,7 @@ pnpm-debug.log*
*.sw?
.history
components.d.ts
# 默认的上传文件夹
userUpload

View File

@ -29,12 +29,10 @@
<br />
&ensp;&ensp;**XIAOJUSURVEY**是一套轻量、安全的**问卷系统基座**,提供面向个人和企业的一站式产品级解决方案,快速满足各类线上调研场景。
&ensp;&ensp;**XIAOJUSURVEY**是一套轻量、安全的问卷系统,提供面向个人和企业的一站式产品级解决方案,用于构建各类问卷、考试、测评和复杂表单,快速满足各类线上调研场景。
&ensp;&ensp;内部系统已沉淀 40+种题型,累积精选模板 100+,适用于市场调研、客户满意度调研、在线考试、投票、报道、测评等众多场景。数据能力上,经过上亿量级打磨,沉淀了分题统计、交叉分析、多渠道分析等在线报表能力,快速满足专业化分析。
&ensp;&ensp;开源项目以打造**调研基座**为核心,围绕**平台能力**、**工程架构**、**研发体系**进行建设,大家可以「快速」打造「专属」问卷系统:[快速了解生态发展理念](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)
<img src="https://github.com/didi/xiaoju-survey/assets/16012672/dd427471-368d-49d9-bc44-13c34d84e3be" width="700" />
@ -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)

View File

@ -29,12 +29,10 @@
<br />
&ensp;&ensp;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.
&ensp;&ensp;XIAOJUSURVEY is an open-source form builder and analytics platform to create questionnaires, exams, polls, quizzes, and analyze data online.
&ensp;&ensp;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.
&ensp;&ensp;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).
<img src="https://github.com/didi/xiaoju-survey/assets/16012672/508ce30f-0ae8-4f5f-84a7-e96de8238a7f" width="700" />
@ -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)

View File

@ -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:

View File

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

View File

@ -61,8 +61,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",
@ -76,7 +76,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": [

View File

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

View File

@ -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',
}

View File

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

View File

@ -14,7 +14,7 @@ export class SurveyResponse extends BaseEntity {
data: Record<string, any>;
@Column()
difTime: number;
diffTime: number;
@Column()
clientTime: number;

View File

@ -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' },
],
};

View File

@ -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),
}),
]),
);

View File

@ -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';
@ApiTags('survey')
@ApiBearerAuth()
@ -103,15 +104,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;

View File

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

View File

@ -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',
),

View File

@ -1,55 +1,56 @@
{
"bannerConf": {
"titleConfig": {
"mainTitle": "<h3 style=\"text-align: center\">欢迎填写问卷</h3><p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style=\"color: rgb(204, 0, 0)\">期待您的参与!</span></p>",
"subTitle": ""
},
"bannerConfig": {
"bgImage": "/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp",
"videoLink": "",
"postImg": ""
}
"bannerConf": {
"titleConfig": {
"mainTitle": "<h3 style=\"text-align: center\">欢迎填写问卷</h3><p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style=\"color: rgb(204, 0, 0)\">期待您的参与!</span></p>",
"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": []
}
}

View File

@ -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<string, any> = {};
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);

View File

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

View File

@ -21,6 +21,10 @@ import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugi
import { RECORD_STATUS } from 'src/enums';
import { SurveyResponse } from 'src/models/surveyResponse.entity';
import { Logger } from 'src/logger';
import { ResponseSchema } from 'src/models/responseSchema.entity';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { UserService } from 'src/modules/auth/services/user.service';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
const mockDecryptErrorBody = {
surveyPath: 'EBzdmnSp',
@ -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),
);
});
});
});

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@ -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<WorkspaceMember>);
jest
.spyOn(workspaceService, 'findAllById')
.mockResolvedValue(workspaces as Array<Workspace>);
.spyOn(workspaceService, 'findAllByIdWithPagination')
.mockResolvedValue({
list: workspaces as Array<Workspace>,
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<Workspace>);
jest
.spyOn(workspaceMemberService, 'batchSearchByWorkspace')
.mockResolvedValue(memberList as unknown as Array<WorkspaceMember>);
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()));
});
});
});

View File

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

View File

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

View File

@ -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<string, WorkspaceMember[]> = {};
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,
};
}
}

View File

@ -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<GetWorkspaceListDto>): Joi.ValidationResult {
return Joi.object({
curPage: Joi.number().required(),
pageSize: Joi.number().allow(null).default(10),
name: Joi.string().allow(null, '').optional(),
}).validate(data);
}
}

View File

@ -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<Workspace[]> {
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<FindAllByIdWithPaginationResult> {
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<Workspace>) {
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',
],
});
}
}

View File

@ -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'],
});
}
}

View File

@ -12,7 +12,7 @@ describe('getPushingData', () => {
status: RECORD_STATUS.NEW,
date: 1711025113146,
},
difTime: 30518,
diffTime: 30518,
data: {
data458: '15000000000',
data515: '115019',

73
web/components.d.ts vendored
View File

@ -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']
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']
ElRadio: typeof import('element-plus/es')['ElRadio']
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']
}
}

7
web/env.d.ts vendored
View File

@ -1,8 +1 @@
/// <reference types="vite/client" />
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";
}

View File

@ -14,7 +14,8 @@
"format": "prettier --write src/"
},
"dependencies": {
"@types/lodash-es": "^4.17.12",
"@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",
@ -32,7 +33,6 @@
"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"
},
@ -40,6 +40,7 @@
"@iconify-json/ep": "^1.1.15",
"@rushstack/eslint-patch": "^1.10.2",
"@tsconfig/node20": "^20.1.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.19",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^5.0.3",
@ -52,7 +53,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",

View File

@ -23,31 +23,69 @@
</template>
<script setup>
import { ref, shallowRef, onBeforeMount, watch, computed } from 'vue'
import { get as _get } from 'lodash-es'
import '@wangeditor/editor/dist/css/style.css'
import './styles/reset-wangeditor.scss'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { ref, shallowRef, onBeforeMount, watch } from 'vue'
const emit = defineEmits(['input', 'onFocus', 'change', 'blur'])
import { useUserStore } from '@/management/stores/user'
import { replacePxWithRem } from './utils'
const emit = defineEmits(['input', 'onFocus', 'change', 'blur', 'created'])
const model = defineModel()
const props = defineProps(['staticToolBar'])
const props = defineProps({
staticToolBar: { default: false, required: false },
needUploadImage: { default: false, required: false }
})
const curValue = ref('')
const editorRef = shallowRef()
const showToolbar = ref(props.staticToolBar || false)
const showToolbar = ref(props.staticToolBar)
const mode = 'simple'
const toolbarConfig = {
toolbarKeys: [
'color', //
'bgColor', //
'bold',
'insertLink' //
]
const toolbarConfig = computed(() => {
const config = {
toolbarKeys: [
'color', //
'bgColor', //
'bold',
'insertLink' //
]
}
if (props.needUploadImage) {
config.toolbarKeys.push('uploadImage')
}
return config
})
const editorConfig = {
MENU_CONF: {}
}
const editorConfig = {}
const userStore = useUserStore()
const token = _get(userStore, 'userInfo.token')
//
editorConfig.MENU_CONF['uploadImage'] = {
allowedFileTypes: ['image/jpeg', 'image/png'],
server: '/api/file/upload',
fieldName: 'file',
meta: {
//! channelchannel
channel: 'upload'
},
headers: {
Authorization: `Bearer ${token}`
},
customInsert(res, insertFn) {
const url = res.data.url
insertFn(url, '', '')
}
}
const setHtml = (newHtml) => {
const editor = editorRef.value
@ -60,9 +98,11 @@ const onCreated = (editor) => {
if (model.value) {
setHtml(model.value)
}
emit('created', editor)
}
const onChange = (editor) => {
const editorHtml = editor.getHtml()
const editorHtml = replacePxWithRem(editor.getHtml())
curValue.value = editorHtml // html
emit('input', editorHtml) // v-model
}
@ -113,6 +153,7 @@ onBeforeMount(() => {
.static-toolbar {
border-bottom: 1px solid #dedede;
}
.dynamic-toolbar {
position: absolute;
left: 0;

View File

@ -0,0 +1,28 @@
// px 转换为 rem
const pxToRem = (px) => {
return `${(parseFloat(px) / 50).toFixed(2)}rem`
}
// 图片style的宽高改成rem
export const replacePxWithRem = (html) => {
const imgRegex = /<img[^>]*style=["'][^"']*\b(?:width|height):\s*\d+(\.\d+)?px[^"']*["'][^>]*>/gi
const styleRegex = /style="([^"]*)"/g
if (!imgRegex.test(html)) {
return html
}
const res = html.replaceAll(imgRegex, (imgHtml) => {
return imgHtml.replace(styleRegex, (match, content) => {
let styleContent = content
const pxRegex = /(width|height):\s*(\d+(\.\d+)?)px/g
styleContent = styleContent.replace(pxRegex, (pxMatch, prop, value) => {
return `${prop}: ${pxToRem(value)}`
})
return `style="${styleContent}"`
})
})
return res
}

View File

@ -33,8 +33,8 @@ export class RuleNode {
conditions: ConditionNode[] = []
scope: string = Scope.Question
target: string = ''
constructor(scope: string = Scope.Question, target: string = '') {
this.id = generateID(PrefixID.Rule)
constructor(target: string = '', scope: string = Scope.Question, id?: string) {
this.id = id || generateID(PrefixID.Rule)
this.scope = scope
this.target = target
}
@ -54,14 +54,8 @@ export class RuleNode {
export class RuleBuild {
rules: RuleNode[] = []
static instance: RuleBuild
constructor() {
this.rules = []
if (!RuleBuild.instance) {
RuleBuild.instance = this
}
return RuleBuild.instance
}
// 添加条件规则到规则引擎中
@ -71,6 +65,9 @@ export class RuleBuild {
removeRule(ruleId: string) {
this.rules = this.rules.filter((rule) => rule.id !== ruleId)
}
clear() {
this.rules = []
}
findRule(ruleId: string) {
return this.rules.find((rule) => rule.id === ruleId)
}
@ -94,7 +91,7 @@ export class RuleBuild {
if (ruleConf instanceof Array) {
ruleConf.forEach((rule: any) => {
const { scope, target } = rule
const ruleNode = new RuleNode(scope, target)
const ruleNode = new RuleNode(target, scope)
rule.conditions.forEach((condition: any) => {
const { field, operator, value } = condition
const conditionNode = new ConditionNode(field, operator, value)
@ -112,19 +109,19 @@ export class RuleBuild {
findTargetsByScope(scope: string) {
return this.rules.filter((rule) => rule.scope === scope).map((rule) => rule.target)
}
// 实现前置题删除校验
findTargetsByFields(field: string) {
const nodes = this.rules.filter((rule: RuleNode) => {
const conditions = rule.conditions.filter((item: any) => {
return item.field === field
})
return conditions.length > 0
findRulesByField(field: string) {
return this.rules.filter((rule) => {
return rule.conditions.filter((condition) => condition.field === field).length
})
}
// 实现前置题删除校验
findTargetsByField(field: string) {
const nodes = this.findRulesByField(field)
return nodes.map((item: any) => {
return item.target
})
}
// 根据目标题获取显示逻辑
// 根据目标题获取关联的逻辑条件
findConditionByTarget(target: string) {
return this.rules.filter((rule) => rule.target === target).map((item) => item.conditions)
}

View File

@ -3,7 +3,7 @@ import { Operator, type FieldTypes, type Fact } from './BasicType'
// 定义条件规则类
export class ConditionNode<F extends string, O extends Operator> {
// 默认显示
public result: boolean = false
public result: boolean | undefined = undefined
constructor(
public field: F,
public operator: O,
@ -16,7 +16,7 @@ export class ConditionNode<F extends string, O extends Operator> {
return this.field + this.operator + this.value
}
match(facts: Fact): boolean {
match(facts: Fact): boolean | undefined {
// console.log(this.calculateHash())
// 如果该特征在事实对象中不存在则直接返回false
if (!facts[this.field]) {
@ -45,7 +45,7 @@ export class ConditionNode<F extends string, O extends Operator> {
this.result = this.value.some((v) => !facts[this.field].includes(v))
return this.result
} else {
this.result = facts[this.field].includes(this.value)
this.result = !facts[this.field].includes(this.value)
return this.result
}
case Operator.NotEqual:
@ -53,7 +53,7 @@ export class ConditionNode<F extends string, O extends Operator> {
this.result = this.value.every((v) => !facts[this.field].includes(v))
return this.result
} else {
this.result = facts[this.field].includes(this.value)
this.result = facts[this.field].toString() !== this.value
return this.result
}
// 其他比较操作符的判断逻辑
@ -69,7 +69,7 @@ export class ConditionNode<F extends string, O extends Operator> {
export class RuleNode {
conditions: Map<string, ConditionNode<string, Operator>> // 使用哈希表存储条件规则对象
public result: boolean = false
public result: boolean | undefined
constructor(
public target: string,
public scope: string
@ -83,15 +83,28 @@ export class RuleNode {
}
// 匹配条件规则
match(fact: Fact) {
const res = Array.from(this.conditions.entries()).every(([, value]) => {
const res = value.match(fact)
if (res) {
return true
} else {
return false
}
})
match(fact: Fact, comparor?: any) {
let res: boolean | undefined = undefined
if (comparor === 'or') {
res = Array.from(this.conditions.entries()).some(([, value]) => {
const res = value.match(fact)
if (res) {
return true
} else {
return false
}
})
} else {
res = Array.from(this.conditions.entries()).every(([, value]) => {
const res = value.match(fact)
if (res) {
return true
} else {
return false
}
})
}
this.result = res
return res
}
@ -121,14 +134,14 @@ export class RuleNode {
export class RuleMatch {
rules: Map<string, RuleNode>
static instance: any
// static instance: any
constructor() {
this.rules = new Map()
if (!RuleMatch.instance) {
RuleMatch.instance = this
}
// if (!RuleMatch.instance) {
// RuleMatch.instance = this
// }
return RuleMatch.instance
// return RuleMatch.instance
}
fromJson(ruleConf: any) {
if (ruleConf instanceof Array) {
@ -145,6 +158,7 @@ export class RuleMatch {
this.addRule(ruleNode)
})
}
return this
}
// 添加条件规则到规则引擎中
@ -160,22 +174,31 @@ export class RuleMatch {
this.rules.set(hash, rule)
}
// 匹配条件规则
match(target: string, scope: string, fact: Fact) {
// 特定目标题规则匹配
match(target: string, scope: string, fact: Fact, comparor?: any) {
const hash = this.calculateHash(target, scope)
const rule = this.rules.get(hash)
if (rule) {
const result = rule.match(fact)
// this.matchCache.set(hash, result);
const result = rule.match(fact, comparor)
return result
} else {
// 默认显示
return true
}
}
getResult(target: string, scope: string) {
/* 获取条件题关联的多个目标题匹配情况 */
getResultsByField(field: string, fact: Fact) {
const rules = this.findRulesByField(field)
return rules.map(([, rule]) => {
return {
target: rule.target,
result: this.match(rule.target, 'question', fact, 'or')
}
})
}
/* 获取目标题的规则是否匹配 */
getResultByTarget(target: string, scope: string) {
const hash = this.calculateHash(target, scope)
const rule = this.rules.get(hash)
if (rule) {
@ -191,15 +214,18 @@ export class RuleMatch {
// 假设哈希值计算方法为简单的字符串拼接或其他哈希算法
return target + scope
}
findTargetsByField(field: string) {
const rules = new Map(
[...this.rules.entries()].filter(([, value]) => {
return [...value.conditions.entries()].filter(([, value]) => {
return value.field === field
})
// 查找条件题的规则
findRulesByField(field: string) {
const list = [...this.rules.entries()]
const match = list.filter(([, ruleValue]) => {
const list = [...ruleValue.conditions.entries()]
const res = list.filter(([, conditionValue]) => {
const hit = conditionValue.field === field
return hit
})
)
return [...rules.values()].map((obj) => obj.target)
return res.length
})
return match
}
toJson() {
return Array.from(this.rules.entries()).map(([, value]) => {

View File

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

View File

@ -27,6 +27,18 @@ const isVideo = (html) => {
return html.indexOf('<video') > -1
}
export const cleanRichTextWithMediaTag = (text) => {
if (!text) {
return text === 0 ? 0 : ''
}
const html = transformHtmlTag(text)
.replace(/<img([\w\W]+?)\/>/g, '[图片]')
.replace(/<video.*\/video>/g, '[视频]')
const content = html.replace(/<[^<>]+>/g, '').replace(/&nbsp;/g, '')
return content
}
export const cleanRichText = (text) => {
if (!text) {
return text === 0 ? 0 : ''

View File

@ -1,7 +1,5 @@
<template>
<div id="app">
<router-view></router-view>
</div>
<router-view></router-view>
</template>
<script>
@ -14,6 +12,7 @@ export default {
@import url('./styles/icon.scss');
@import url('../materials/questions/common/css/icon.scss');
@import url('./styles/reset.scss');
@import url('./styles/common.scss');
html {
font-size: 50px;

View File

@ -8,14 +8,20 @@ export const updateSpace = ({ workspaceId, name, description, members }: any) =>
return axios.post(`/workspace/${workspaceId}`, { name, description, members })
}
export const getSpaceList = () => {
return axios.get('/workspace')
export const getSpaceList = (params: any) => {
return axios.get('/workspace', {
params
})
}
export const getSpaceDetail = (workspaceId: string) => {
return axios.get(`/workspace/${workspaceId}`)
}
export const getMemberList = () => {
return axios.get('/workspace/member/list')
}
export const deleteSpace = (workspaceId: string) => {
return axios.delete(`/workspace/${workspaceId}`)
}

View File

@ -43,10 +43,13 @@
import { computed, ref, shallowRef, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import MemberSelect from './MemberSelect.vue'
import { getPermissionList, getCollaborator, saveCollaborator } from '@/management/api/space'
import { type IMember, SurveyPermissions } from '@/management/utils/types/workSpace'
import { CODE_MAP } from '@/management/api/base'
import MemberSelect from './MemberSelect.vue'
const emit = defineEmits(['on-close-codify', 'onFocus', 'change', 'blur'])
const props = withDefaults(
defineProps<{
@ -110,9 +113,6 @@ const rules = {
{
trigger: 'change',
validator: (rule: any, value: IMember[], callback: Function) => {
if (value.length === 0) {
callback('请至少添加一名协作者')
}
if (value.filter((item: IMember) => !item.role.length).length) {
callback('请设置协作者对应权限')
}
@ -132,7 +132,6 @@ const rules = {
const formDisabled = computed(() => {
return false
})
const onClose = () => {
emit('on-close-codify')
}
@ -140,7 +139,7 @@ const onConfirm = async () => {
ruleForm.value?.validate(async (valid: boolean) => {
if (valid) {
try {
const collaborators = formModel.value.members.map((i: any) => {
const collaborators = formModel.value.members.map((i) => {
const collaborator = {
userId: i.userId,
permissions: i.role

View File

@ -6,9 +6,12 @@
<div
:class="[
'tab-btn',
(['QuestionEditIndex', 'QuestionEditSetting', 'QuestionSkinSetting'].includes(
route.name
) &&
([
'QuestionEditIndex',
'QuestionEditSetting',
'QuestionSkinSetting',
'QuestionEditResultConfig'
].includes(route.name) &&
tab.to.name === 'QuestionEditIndex') ||
isActive
? 'router-link-active'

View File

@ -1 +1,2 @@
export const DND_GROUP = 'question'
export const QUESTION_CATALOG = 'questionCatalog'

View File

@ -9,7 +9,7 @@ export const spaceListConfig = {
name: {
title: '空间名称',
key: 'name',
width: 300
width: 200
},
surveyTotal: {
title: '问卷数',
@ -82,6 +82,16 @@ export const noListDataConfig = {
img: '/imgs/icons/list-empty.webp'
}
export const noSpaceDataConfig = {
title: '您还没有创建团队空间',
desc: '赶快点击右上角立即创建团队空间吧!',
img: '/imgs/icons/list-empty.webp'
}
export const noSpaceSearchDataConfig = {
title: '没有满足该查询条件的团队空间哦',
desc: '可以更换条件查询试试',
img: '/imgs/icons/list-empty.webp'
}
export const noSearchDataConfig = {
title: '没有满足该查询条件的问卷哦',
desc: '可以更换条件查询试试',

View File

@ -53,6 +53,7 @@ export const defaultQuestionConfig = {
star: 5,
optionOrigin: '',
originType: 'selected',
innerType: '',
matrixOptionsRely: '',
numberRange: {
min: {

View File

@ -1,6 +1,25 @@
import * as echarts from 'echarts'
import * as echarts from 'echarts/core'
import { BarChart, PieChart, GaugeChart } from 'echarts/charts'
import {
TooltipComponent,
TitleComponent,
GridComponent,
LegendComponent
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { getOption } from '@/management/config/chartConfig'
echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
BarChart,
PieChart,
GaugeChart,
CanvasRenderer
])
/**
* 绘制图表
* @param {Object} el

View File

@ -0,0 +1,171 @@
import { useEditStore } from '../stores/edit'
import { Operator } from '@/common/logicEngine/BasicType'
import { cleanRichText } from '@/common/xss'
import { CHOICES } from '@/common/typeEnum'
export const generateNodes = (questionDataList: [any]) => {
let x = 50
const y = 300
const startNode = [
{
id: 'start',
type: 'start-node',
x: 50,
y,
text: '开始'
}
]
const nodes: any[] = questionDataList.map((item) => {
x = x + 300
let options = []
if (CHOICES.includes(item.type)) {
options = item?.options.map((option: any) => {
return {
key: option?.hash,
type: cleanRichText(option?.text)
}
})
}
return {
id: item?.field,
type: 'q-node',
x,
y,
properties: {
questionType: item?.type,
field: item.field,
title: cleanRichText(item?.title),
options
}
}
})
const endNode = [
{
id: 'end',
type: 'end-node',
x: x + 200,
y,
text: '结束'
}
]
return startNode.concat(nodes).concat(endNode)
}
/* 跳转逻辑的初始化 */
export const generateLine = (models: Array<any>) => {
const acc: Array<any> = []
const editStore = useEditStore()
const jumpLogicRule = editStore.jumpLogicEngine?.toJson()
const edges = models.reduce((prev: any, point: any, index: number, array: any[]) => {
if (index === 0) {
return acc
}
const previousPoint: any = array[index - 1]
if (!previousPoint) {
return acc
}
let edge
if (previousPoint?.type === 'start-node') {
// 开始节点连接线
edge = {
type: 'q-edge',
sourceNodeId: previousPoint?.id,
targetNodeId: point?.id,
sourceAnchorId: `${previousPoint.anchors[0].id}`,
targetAnchorId: `${point?.anchors[0].id}`,
// properties: {
draggable: false
// }
}
acc.push(edge)
} else if (previousPoint?.type === 'q-node') {
// 生成题目节点连接线
// 方案1以条件节点为主体
const editStore = useEditStore()
const rules = editStore.jumpLogicEngine.findRulesByField(previousPoint.id)
if (!jumpLogicRule.length || !rules.length) {
edge = {
type: 'q-edge',
sourceNodeId: previousPoint?.id,
targetNodeId: point?.id,
sourceAnchorId: `${previousPoint.anchors[1].id}`,
targetAnchorId: `${point?.anchors[0].id}`
}
acc.push(edge)
} else {
const hasDefault = rules.filter((i: any) => {
return i.conditions.filter((item: any) => item.operator === Operator.NotEqual).length
})
if (!hasDefault.length) {
// 如果规则中没有默认答题跳转则生成一条默认的题目答完链接线
edge = {
type: 'q-edge',
sourceNodeId: previousPoint?.id,
targetNodeId: point?.id,
sourceAnchorId: `${previousPoint.anchors[1].id}`,
targetAnchorId: `${point?.anchors[0].id}`
}
acc.push(edge)
}
rules.forEach((rule: any) => {
const condition = rule.conditions[0]
let sourceAnchorId = `${condition.field}_right`
if (condition.operator === 'in') {
sourceAnchorId = `${condition.value}_right`
}
const targetAnchorId = `${rule.target}_left`
edge = {
type: 'q-edge',
sourceNodeId: previousPoint?.id,
targetNodeId: rule.target,
sourceAnchorId: `${sourceAnchorId}`,
targetAnchorId: `${targetAnchorId}`,
properties: {
ruleId: rule.id
}
}
acc.push(edge)
})
}
} else {
edge = {
type: 'q-edge',
sourceNodeId: previousPoint?.id,
targetNodeId: point?.id,
sourceAnchorId: `${previousPoint.anchors[1].id}`,
targetAnchorId: `${point?.anchors[0].id}`,
draggable: false
}
acc.push(edge)
}
return acc
})
return edges
}
export const getNodesStep = (source: string, target: string, questionDataList: any[]) => {
const sourceIndex = questionDataList.findIndex((item: any) => item.field === source)
const targetIndex = questionDataList.findIndex((item: any) => item.field === target)
return targetIndex - sourceIndex
}
export const getCondition = (sourceInfo: any): any => {
const { nodeId, anchorId } = sourceInfo
const anchorKey = anchorId.split('_right')[0]
if (nodeId === anchorKey) {
// 答完跳转
return {
field: nodeId,
operator: Operator.NotEqual,
value: ''
}
} else {
// 选中optionhash跳转
return {
field: nodeId,
operator: Operator.Include,
value: anchorKey
}
}
}

View File

@ -0,0 +1,42 @@
import { computed, unref } from 'vue'
import { useQuestionInfo } from './useQuestionInfo'
import { useEditStore } from '../stores/edit'
import { storeToRefs } from 'pinia'
const editStore = useEditStore()
const { jumpLogicEngine } = storeToRefs(editStore)
// 目标题的显示逻辑提示文案
export const useJumpLogicInfo = (field) => {
const hasJumpLogic = computed(() => {
const logicEngine = jumpLogicEngine.value
// 判断该题是否作为了跳转逻辑条件
const isField = logicEngine?.findTargetsByField(field)?.length > 0
// 判断该题是否作为了跳转目标
const isTarget = logicEngine?.findConditionByTarget(field)?.length > 0
return isField || isTarget
})
const getJumpLogicText = computed(() => {
const logicEngine = jumpLogicEngine.value
if (!logicEngine) return
// 获取跳转
const rules = logicEngine?.findRulesByField(field) || []
if (!rules) return
const ruleText = rules.map((rule) => {
const conditions = rule.conditions.map((condition) => {
const { getOptionTitle } = useQuestionInfo(condition.field)
if (condition.operator === 'in') {
return `<span> 选择了 【${getOptionTitle.value(unref(condition.value)).join('')}】</span>`
} else if (condition.operator === 'neq') {
return `<span> 答完题目 </span>`
}
return ''
})
const { getQuestionTitle } = useQuestionInfo(rule.target)
return (
conditions.join('') + `<span> &nbsp;则跳转到 【${getQuestionTitle.value()}】</span> </br>`
)
})
return ruleText.join('')
})
return { hasJumpLogic, getJumpLogicText }
}

View File

@ -8,7 +8,8 @@ export const useQuestionInfo = (field) => {
const getQuestionTitle = computed(() => {
return () => {
return questionDataList.value.find((item) => item.field === field)?.title
if (field === 'end') return '问卷末尾'
return cleanRichText(questionDataList.value.find((item) => item.field === field)?.title)
}
})
const getOptionTitle = computed(() => {

View File

@ -1,7 +0,0 @@
import { ref } from 'vue'
import { RuleBuild } from '@/common/logicEngine/RuleBuild'
export const showLogicEngine = ref()
export const initShowLogicEngine = (ruleConf) => {
showLogicEngine.value = new RuleBuild().fromJson(ruleConf)
}

View File

@ -2,16 +2,19 @@ import { computed, unref } from 'vue'
import { useQuestionInfo } from './useQuestionInfo'
import { flatten } from 'lodash-es'
import { cleanRichText } from '@/common/xss'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
import { useEditStore } from '../stores/edit'
import { storeToRefs } from 'pinia'
const editStore = useEditStore()
const { showLogicEngine } = storeToRefs(editStore)
// 目标题的显示逻辑提示文案
export const useShowLogicInfo = (field) => {
const hasShowLogic = computed(() => {
const logicEngine = showLogicEngine.value
// 判断该题是否作为了显示逻辑前置题
const isField = logicEngine?.findTargetsByFields(field)?.length > 0
const isField = logicEngine?.findTargetsByField(field)?.length > 0
// 判断该题是否作为了显示逻辑目标题
const isTarget = logicEngine?.findTargetsByScope(field)?.length > 0
const isTarget = logicEngine?.findConditionByTarget(field)?.length > 0
return isField || isTarget
})
const getShowLogicText = computed(() => {

View File

@ -41,7 +41,7 @@ import { analysisType } from '@/management/config/analysisConfig'
.right {
width: 100%;
height: 100%;
min-width: 1160px;
min-width: 1300px;
display: flex;
flex-direction: column;
overflow: hidden;

View File

@ -13,28 +13,30 @@
v-for="item in props.tableData.listHead"
:key="item.field"
:prop="item.field"
:label="cleanRichText(item.title)"
:label="item.title"
minWidth="200"
>
<template #header="scope">
<div
class="table-row-cell"
@mouseover="onPopoverRefOver(scope, 'head')"
:ref="(el) => (popoverRefMap[scope.column.id] = el)"
>
<span>
{{ scope.column.label.replace(/&nbsp;/g, '') }}
<div class="table-row-cell">
<span
class="table-row-head"
@click="onPreviewImage"
@mouseover="onPopoverRefOver(scope, 'head')"
:ref="(el) => (popoverRefMap[scope.column.id] = el)"
v-html="item.title"
>
</span>
</div>
</template>
<template #default="scope">
<div
class="table-row-cell"
@mouseover="onPopoverRefOver(scope, 'content')"
:ref="(el) => (popoverRefMap[scope.$index + scope.column.property] = el)"
>
<span>
{{ getContent(scope.row[scope.column.property]) }}
<div>
<span
class="table-row-cell"
@mouseover="onPopoverRefOver(scope, 'content')"
@click="onPreviewImage"
:ref="(el) => (popoverRefMap[scope.$index + scope.column.property] = el)"
v-html="getContent(scope.row[scope.column.property])"
>
</span>
</div>
</template>
@ -42,21 +44,22 @@
</el-table>
<el-popover
ref="popover"
popper-style="text-align: center;"
popper-style="text-align: center;font-size: 13px;"
:virtual-ref="popoverVirtualRef"
placement="top"
width="400"
placement="bottom"
trigger="hover"
virtual-triggering
:content="popoverContent"
>
<div v-html="popoverContent"></div>
</el-popover>
<ImagePreview :url="previewImageUrl" v-model:visible="showPreviewImage" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import { cleanRichText } from '@/common/xss'
import ImagePreview from './ImagePreview.vue'
const props = defineProps({
tableData: {
@ -74,8 +77,8 @@ const popoverRefMap = ref({})
const popoverVirtualRef = ref()
const popoverContent = ref('')
const getContent = (value) => {
const content = cleanRichText(value)
const getContent = (content) => {
// const content = cleanRichText(value)
return content === 0 ? 0 : content || '未知'
}
const setPopoverContent = (content) => {
@ -83,23 +86,31 @@ const setPopoverContent = (content) => {
}
const onPopoverRefOver = (scope, type) => {
let popoverContent
if (type == 'head') {
if (type === 'head') {
popoverVirtualRef.value = popoverRefMap.value[scope.column.id]
popoverContent = scope.column.label.replace(/&nbsp;/g, '')
}
if (type == 'content') {
if (type === 'content') {
popoverVirtualRef.value = popoverRefMap.value[scope.$index + scope.column.property]
popoverContent = getContent(scope.row[scope.column.property])
}
setPopoverContent(popoverContent)
}
const previewImageUrl = ref('')
const showPreviewImage = ref(false)
const onPreviewImage = (e) => {
if (e.target.tagName === 'IMG') {
previewImageUrl.value = e.target.src
showPreviewImage.value = true
}
}
</script>
<style lang="scss" scoped>
.data-table-wrapper {
position: relative;
width: 100%;
padding-bottom: 20px;
min-height: v-bind('tableMinHeight');
background: #fff;
padding: 10px 20px;
@ -122,14 +133,19 @@ const onPopoverRefOver = (scope, type) => {
}
.table-row-cell {
max-width: 100%;
display: inline-block;
white-space: nowrap;
/* 禁止自动换行 */
overflow: hidden;
/* 超出部分隐藏 */
text-overflow: ellipsis;
/* 显示省略号 */
:deep(img) {
height: 23px !important;
width: auto !important;
object-fit: cover;
margin-left: 5px;
}
:deep(p) {
display: flex;
align-items: center;
}
}
}
:deep(.el-table td.el-table__cell div) {
font-size: 13px;
}
</style>

View File

@ -0,0 +1,70 @@
<template>
<Teleport to="body">
<Transition>
<div class="image-preview" v-show="visible">
<div class="close-btn" @click="visible = false"><i-ep-close /></div>
<div class="image-con">
<img :src="props.url" class="image-item" />
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const props = defineProps<{
url: string
}>()
const visible = defineModel('visible')
</script>
<style lang="scss" scoped>
.v-enter-active,
.v-leave-active {
transition: opacity 0.2s ease-out;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
.image-preview {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba($color: #000000, $alpha: 0.4);
z-index: 2024;
display: flex;
align-items: center;
justify-content: center;
.image-con {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.image-item {
max-width: 80%;
max-height: 70%;
}
}
.close-btn {
position: absolute;
right: 20px;
top: 20px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 50%;
background-color: #d0d0d0;
}
}
</style>

View File

@ -1,18 +1,18 @@
<template>
<div class="main">
<div class="nav" v-if="slots.hasOwnProperty('nav')">
<div class="nav" v-if="slots.nav">
<slot name="nav"></slot>
</div>
<div class="body">
<slot v-if="slots.hasOwnProperty('body')" name="body"></slot>
<slot v-if="slots.body" name="body"></slot>
<template v-else>
<div class="left" v-if="slots.hasOwnProperty('left')">
<div class="left" v-if="slots.left">
<slot name="left"></slot>
</div>
<div class="center" v-if="slots.hasOwnProperty('center')">
<div class="center" v-if="slots.center">
<slot name="center"></slot>
</div>
<div class="right" v-if="slots.hasOwnProperty('right')">
<div class="right" v-if="slots.right">
<slot name="right"></slot>
</div>
</template>

View File

@ -1,8 +1,9 @@
<template>
<draggable
v-model="renderData"
handle=".question-wrapper.isSelected"
filter=".question-wrapper.isSelected .question.isSelected"
handle=".question-wrapper.is-move"
filter=".question-wrapper.is-move .question.isSelected"
:preventOnFilter="false"
:group="DND_GROUP"
:onEnd="checkEnd"
:move="checkMove"
@ -13,8 +14,9 @@
:ref="`questionWrapper-${element.field}`"
:moduleConfig="element"
:qIndex="element.qIndex"
:isFirst="index == 0"
:indexNumber="element.indexNumber"
:isSelected="currentEditOne === index"
:isSelected="currentEditOne === element.qIndex"
:isLast="index + 1 === questionDataList.length"
@select="handleSelect"
@changeSeq="handleChangeSeq"
@ -23,7 +25,7 @@
:type="element.type"
:moduleConfig="element"
:indexNumber="element.indexNumber"
:isSelected="currentEditOne === index"
:isSelected="currentEditOne === element.qIndex"
:readonly="true"
@change="handleChange"
></QuestionContainerB>
@ -38,7 +40,6 @@ import { useEditStore } from '@/management/stores/edit'
import QuestionContainerB from '@/materials/questions/QuestionContainerB'
import QuestionWrapper from '@/management/pages/edit/components/QuestionWrapper.vue'
import draggable from 'vuedraggable'
import { filterQuestionPreviewData } from '@/management/utils/index'
import { DND_GROUP } from '@/management/config/dnd'
export default defineComponent({
@ -59,15 +60,15 @@ export default defineComponent({
}
}
},
emits: ['change', 'select', 'changeSeq'],
emits: ['change', 'select', 'changeSeq', 'change'],
setup(props, { emit }) {
const editStore = useEditStore()
const renderData = computed({
get() {
return filterQuestionPreviewData(props.questionDataList)
return props.questionDataList //filterQuestionPreviewData(props.questionDataList)
},
set(value) {
editStore.setQuestionDataList(value)
editStore.moveQuestionDataList(value)
}
})
const handleSelect = (index) => {

View File

@ -8,16 +8,28 @@
<NavPanel></NavPanel>
</div>
<div class="right-group">
<CooperationPanel>
<template #content="{ onCooper }">
<div class="btn" @click="onCooper">
<i-ep-connection class="view-icon" :size="20" />
<span class="btn-txt">协作</span>
</div>
</template>
</CooperationPanel>
<PreviewPanel></PreviewPanel>
<HistoryPanel></HistoryPanel>
<SavePanel></SavePanel>
<PublishPanel></PublishPanel>
<SavePanel :updateLogicConf="updateLogicConf" :updateWhiteConf="updateWhiteConf"></SavePanel>
<PublishPanel
:updateLogicConf="updateLogicConf"
:updateWhiteConf="updateWhiteConf"
></PublishPanel>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useEditStore } from '@/management/stores/edit'
import { storeToRefs } from 'pinia'
import BackPanel from '../modules/generalModule/BackPanel.vue'
import TitlePanel from '../modules/generalModule/TitlePanel.vue'
@ -26,11 +38,80 @@ import HistoryPanel from '../modules/contentModule/HistoryPanel.vue'
import PreviewPanel from '../modules/contentModule/PreviewPanel.vue'
import SavePanel from '../modules/contentModule/SavePanel.vue'
import PublishPanel from '../modules/contentModule/PublishPanel.vue'
import CooperationPanel from '../modules/contentModule/CooperationPanel.vue'
const editStore = useEditStore()
const { schema, changeSchema } = editStore
const title = computed(() => (editStore.schema?.metaData as any)?.title || '')
const { showLogicEngine, jumpLogicEngine } = storeToRefs(editStore)
// -
const updateLogicConf = () => {
let res = {
validated: true,
message: ''
}
if (
showLogicEngine.value &&
showLogicEngine.value.rules &&
showLogicEngine.value.rules.length !== 0
) {
try {
showLogicEngine.value.validateSchema()
} catch (error) {
res = {
validated: false,
message: '逻辑配置不能为空'
}
return res
}
const showLogicConf = showLogicEngine.value.toJson()
//
changeSchema({ key: 'logicConf', value: { showLogicConf } })
return res
}
const jumpLogicConf = jumpLogicEngine.value.toJson()
changeSchema({ key: 'logicConf', value: { jumpLogicConf } })
return res
}
// -
const updateWhiteConf = () => {
let res = {
validated: true,
message: ''
}
const baseConf = (schema?.baseConf as any) || {}
if (baseConf.passwordSwitch && !baseConf.password) {
res = {
validated: false,
message: '访问密码不能为空'
}
return res
}
if (baseConf.whitelistType != 'ALL' && !baseConf.whitelist?.length) {
res = {
validated: false,
message: '白名单不能为空'
}
return res
}
return res
}
</script>
<style lang="scss" scoped>
@import url('@/management/styles/edit-btn.scss');
.view-icon {
font-size: 20px;
height: 29px;
line-height: 29px;
}
.nav {
width: 100%;
height: 56px;

View File

@ -0,0 +1,306 @@
<template>
<div v-if="props.totalPage > 0" class="com-pagination">
<span :class="['com-pagination-item', prev_class]" @click="changePage(prev_page)">
<i-ep-ArrowLeft />
</span>
<template v-if="!is_more_filled">
<div
v-for="i in firstPagination"
:key="i"
:class="['com-pagination-item', `page-${i}`, now_page == i ? 'current' : '']"
@click="changePage(i)"
>
<span>{{ i }}</span>
<div v-if="!props.readonly" :class="['moreControls']" @click.stop="showTooltipVisible(i)">
<i-ep-MoreFilled />
</div>
</div>
</template>
<template v-else>
<div
v-for="i in more_filled_arr.startArr"
@click="changePage(i)"
:key="i"
:class="['com-pagination-item', ` page-${i}`, now_page == i ? 'current' : '']"
>
<span>{{ i }}</span>
<div v-if="!props.readonly" :class="['moreControls']" @click.stop="showTooltipVisible(i)">
<i-ep-MoreFilled />
</div>
</div>
<el-tooltip class="controls-wrap" effect="light" placement="bottom" :visible="moreVisible">
<span class="com-pagination-item" @click.stop="moreVisible = true">
<i-ep-MoreFilled />
</span>
<template #content>
<div class="bubble-wrap">
<div
class="bubble-item"
v-for="i in more_filled_arr.bubbleArr"
:key="i"
@click="changePage(i)"
>
<span>{{ i }}</span>
</div>
</div>
</template>
</el-tooltip>
<div
v-for="i in more_filled_arr.endArr"
:key="i"
:class="['com-pagination-item', `page-${i}`, now_page == i ? 'current' : '']"
@click="changePage(i)"
>
<span>{{ i }}</span>
<div v-if="!props.readonly" :class="['moreControls']" @click.stop="showTooltipVisible(i)">
<i-ep-MoreFilled />
</div>
</div>
</template>
<span :class="['com-pagination-item', next_class]" @click="changePage(next_page)">
<i-ep-ArrowRight />
</span>
<el-tooltip
v-if="slot.tooltip && props.readonly == false"
:visible="tooltipVisible"
:popper-options="{
modifiers: [
{
name: 'computeStyles',
options: {
adaptive: false,
enabled: false
}
}
]
}"
:virtual-ref="triggerBtn"
virtual-triggering
effect="light"
popper-class="singleton-tooltip"
>
<template #content>
<slot name="tooltip" :index="tooltipIndex"></slot>
</template>
</el-tooltip>
</div>
</template>
<script setup lang="ts">
import { clone } from 'lodash-es'
import { reactive, computed, watch, ref, onMounted, onUnmounted, nextTick, useSlots } from 'vue'
interface Props {
modelValue: number //
totalPage?: number
intervalCount?: number
readonly?: boolean
}
const props = withDefaults(defineProps<Props>(), {
modelValue: 1,
totalPage: 1,
intervalCount: 8,
readonly: false
})
const emit = defineEmits(['change-page', 'update:modelValue'])
const state = reactive({
now: props.modelValue,
jump: ''
})
const slot = useSlots()
const moreVisible = ref(false)
const tooltipVisible = ref(false)
const triggerBtn = ref<EventTarget | null>(null)
const tooltipIndex = ref(0)
const now_page = computed(() => {
return state.now * 1
})
const prev_class = computed(() => {
return now_page.value == 1 ? 'disabled' : ''
})
const next_class = computed(() => {
return now_page.value == props.totalPage ? 'disabled' : ''
})
const prev_page = computed(() => {
return now_page.value > 1 ? now_page.value - 1 : 1
})
const next_page = computed(() => {
return now_page.value < props.totalPage ? now_page.value + 1 : props.totalPage
})
const is_more_filled = computed(() => {
const intervalNum = props.totalPage - now_page.value + 1
if (intervalNum >= props.intervalCount + 1) {
return true
}
return false
})
const totalArr = computed(() => {
const arr = []
for (let i = 0; i < props.totalPage; i++) {
arr.push(i + 1)
}
return arr
})
const more_filled_arr = computed(() => {
let startArr = []
let bubbleArr = []
let endArr = []
const arr = clone(totalArr.value)
const intervalNum = Math.round(props.intervalCount / 2)
startArr = arr.slice(now_page.value - 1, intervalNum + now_page.value - 1)
endArr = arr.slice(intervalNum * -1)
bubbleArr = arr.slice(startArr[startArr.length - 1], endArr[0] - 1)
return {
startArr,
bubbleArr,
endArr
}
})
const firstPagination = computed(() => {
const arr = clone(totalArr.value)
return arr.splice(props.intervalCount * -1)
})
const changePage = (page: number) => {
state.now = page
emit('update:modelValue', state.now)
emit('change-page', state.now)
}
const showTooltipVisible = (index: number) => {
if (slot.tooltip) {
nextTick(() => {
tooltipIndex.value = index
triggerBtn.value = document.getElementsByClassName(`page-${index}`)[0] || null
tooltipVisible.value = true
})
}
}
const hideMoreVisible = () => {
moreVisible.value = false
}
const hideTooltipVisible = () => {
tooltipVisible.value = false
}
onMounted(() => {
document.addEventListener('click', hideMoreVisible)
if (slot.tooltip) {
document.addEventListener('click', hideTooltipVisible)
}
})
onUnmounted(() => {
document.removeEventListener('click', hideMoreVisible)
if (slot.tooltip) {
document.removeEventListener('click', hideTooltipVisible)
}
})
watch(
() => props.modelValue,
() => {
state.now = props.modelValue
}
)
</script>
<style lang="scss" scoped>
.bubble-wrap {
.bubble-item {
max-width: 100px;
min-width: 10px;
text-align: center;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
&:hover {
background-color: #cccc;
}
}
}
.com-pagination {
display: flex;
justify-content: center;
align-items: center;
font-size: 0;
user-select: none;
.moreControls {
color: #6e707c;
display: none;
position: absolute;
left: 22px;
svg {
transform: rotate(90deg);
font-size: 10px;
}
}
&-item {
display: inline-block;
position: relative;
// padding: 0 4px;
min-width: 32px;
height: 32px;
color: #303133;
border-radius: 2px;
cursor: pointer;
text-align: center;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: $primary-color;
.moreControls {
display: block;
}
}
&.active {
color: $primary-color;
.moreControls {
display: block;
}
}
svg {
display: block;
font-size: 12px;
font-weight: bold;
width: inherit;
cursor: pointer;
&:hover {
color: $primary-color;
}
}
}
.disabled,
.disabled:hover {
svg {
cursor: not-allowed;
color: #303133 !important;
}
}
.current,
.current:hover {
color: $primary-color;
}
}
</style>

View File

@ -0,0 +1,125 @@
<template>
<div class="pagination-wrap">
<PaginationPanel
v-model="schema.pageEditOne"
:readonly="props.readonly"
:totalPage="pageCount"
@changePage="updatePage"
:intervalCount="10"
>
<template #tooltip="{ index }">
<div>
<div v-if="index != 1" class="controls-wrap-item" @click="movePage(index, 'up')">
前移一页
</div>
<div
v-if="index != pageCount"
class="mt8 controls-wrap-item"
@click="movePage(index, 'down')"
>
后移一页
</div>
<div class="mt8 controls-wrap-item" @click="copyPage(index)">复制</div>
<div class="mt8 controls-wrap-item" @click="deletePage(index)">删除</div>
</div>
</template>
</PaginationPanel>
<i-ep-plus
v-if="!props.readonly"
style="font-size: 12px"
@click="addPageControls"
class="plus-add"
/>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit'
import { QUESTION_TYPE } from '@/common/typeEnum.ts'
import PaginationPanel from './PaginationPanel.vue'
const props = defineProps({
readonly: {
type: Boolean,
default: false
}
})
const editStore = useEditStore()
const { pageCount, schema, newQuestionIndex } = storeToRefs(editStore)
const {
updatePageEditOne,
addPage,
createNewQuestion,
addQuestion,
setCurrentEditOne,
deletePage,
swapArrayRanges,
copyPage
} = editStore
const updatePage = (index) => {
setCurrentEditOne(null)
updatePageEditOne(index)
}
const movePage = (position, type) => {
setCurrentEditOne(null)
const pageIndex = type === 'up' ? position - 1 : position + 1
updatePageEditOne(pageIndex)
if (type === 'up') {
swapArrayRanges(position, position - 1)
}
if (type === 'down') {
swapArrayRanges(position + 1, position)
}
}
const addPageControls = () => {
const newQuestion = createNewQuestion({ type: QUESTION_TYPE.TEXT })
updatePageEditOne(pageCount.value + 1)
setCurrentEditOne(null)
addQuestion({ question: newQuestion, index: newQuestionIndex.value })
setCurrentEditOne(newQuestionIndex.value)
addPage()
}
</script>
<style lang="scss" scoped>
.mt8 {
margin-top: 8px;
}
.controls-wrap {
&-item {
color: #4a4c5b;
font-size: 12px;
font-weight: 400;
cursor: pointer;
&:hover {
color: $primary-color;
}
}
}
.pagination-wrap {
display: flex;
align-items: center;
background: #ffffff;
box-shadow: 0px 2px 10px -2px rgba(82, 82, 102, 0.2);
border-radius: 4px;
margin-bottom: 12px;
.plus-add {
cursor: pointer;
margin-left: 12px;
&:hover {
color: $primary-color;
}
}
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div
:class="itemClass"
:class="[itemClass, { 'is-move': isSelected || isMove }]"
@mouseenter="onMouseenter"
@mouseleave="onMouseleave"
@click="clickFormItem"
@ -8,7 +8,12 @@
<div><slot v-if="moduleConfig.type !== 'section'"></slot></div>
<div :class="[showHover ? 'visibily' : 'hidden', 'hoverItem']">
<div class="item el-icon-rank" @click.stop.prevent="onMove">
<div
class="item el-icon-rank"
@click.stop.prevent
@mouseenter="setMoveState(true)"
@mouseleave="setMoveState(false)"
>
<i-ep-rank />
</div>
<div v-if="showUp" class="item" @click.stop.prevent="onMoveUp">
@ -24,7 +29,8 @@
<i-ep-close />
</div>
</div>
<div class="logic-text" v-html="getShowLogicText"></div>
<div class="logic-text showText" v-html="getShowLogicText"></div>
<div class="logic-text jumpText" v-html="getJumpLogicText"></div>
</div>
</template>
<script setup lang="ts">
@ -32,6 +38,7 @@ import { ref, computed, unref } from 'vue'
import { ElMessageBox } from 'element-plus'
import 'element-plus/theme-chalk/src/message-box.scss'
import { useShowLogicInfo } from '@/management/hooks/useShowLogicInfo'
import { useJumpLogicInfo } from '@/management/hooks/useJumpLogicInfo'
const props = defineProps({
qIndex: {
@ -46,6 +53,10 @@ const props = defineProps({
type: Boolean,
default: false
},
isFirst: {
type: Boolean,
default: false
},
isLast: {
type: Boolean,
default: false
@ -60,8 +71,10 @@ const props = defineProps({
const emit = defineEmits(['changeSeq', 'select'])
const { getShowLogicText, hasShowLogic } = useShowLogicInfo(props.moduleConfig.field)
const { getJumpLogicText, hasJumpLogic } = useJumpLogicInfo(props.moduleConfig.field)
const isHover = ref(false)
const isMove = ref(false)
const itemClass = computed(() => {
return {
@ -75,7 +88,7 @@ const showHover = computed(() => {
return isHover.value || props.isSelected
})
const showUp = computed(() => {
return props.qIndex !== 0
return !props.isFirst
})
const showDown = computed(() => {
return !props.isLast
@ -128,8 +141,15 @@ const onMoveDown = () => {
}
}
const onDelete = async () => {
if (unref(hasShowLogic)) {
ElMessageBox.alert('该问题被逻辑依赖,请先删除逻辑依赖', '提示', {
if (unref(hasShowLogic) || getShowLogicText.value) {
ElMessageBox.alert('该题目被显示逻辑关联,请先清除逻辑依赖', '提示', {
confirmButtonText: '确定',
type: 'warning'
})
return
}
if (unref(hasJumpLogic)) {
ElMessageBox.alert('该题目被跳转逻辑关联,请先清除逻辑依赖', '提示', {
confirmButtonText: '确定',
type: 'warning'
})
@ -154,7 +174,9 @@ const onDelete = async () => {
}
}
const onMove = () => {}
const setMoveState = (state: boolean) => {
isMove.value = state
}
</script>
<style lang="scss" scoped>
@ -163,7 +185,7 @@ const onMove = () => {}
padding: 0.36rem 0 0.36rem;
border: 1px solid transparent;
&.spliter {
border-bottom: 0.12rem solid $spliter-color;
border-bottom: 0.1rem solid $spliter-color;
}
&.mouse-hover {

View File

@ -37,8 +37,8 @@
</el-form>
</template>
<script setup lang="ts">
import { watch, ref, shallowRef } from 'vue'
import { get as _get, pick as _pick, isFunction as _isFunction } from 'lodash-es'
import { watch, ref, shallowRef, type Component } from 'vue'
import { get as _get, pick as _pick, isFunction as _isFunction, values as _values } from 'lodash-es'
import FormItem from '@/materials/setters/widgets/FormItem.vue'
import setterLoader from '@/materials/setters/setterLoader'
@ -46,8 +46,9 @@ import setterLoader from '@/materials/setters/setterLoader'
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
interface Props {
formConfigList: Array<any>
moduleConfig: any
formConfigList: Array<any> //
moduleConfig: any // schema
customComponents?: Record<string, Component>
}
interface Emit {
@ -70,7 +71,7 @@ const formatValue = ({ item, moduleConfig }: any) => {
result = _get(moduleConfig, key, item.value)
}
if (keys) {
result = _pick(moduleConfig, keys)
result = _values(_pick(moduleConfig, keys))
}
return result
@ -79,12 +80,14 @@ const formatValue = ({ item, moduleConfig }: any) => {
const formFieldData = ref<Array<any>>([])
const init = ref<boolean>(true)
const components = shallowRef<any>({})
const components = shallowRef<any>(props.customComponents || {})
const handleFormChange = (data: any, formConfig: any) => {
//
if (_isFunction(formConfig?.valueSetter)) {
const resultData = formConfig.valueSetter(data)
//
if (Array.isArray(resultData)) {
resultData.forEach((item) => {
emit(FORM_CHANGE_EVENT_KEY, item)
@ -124,7 +127,7 @@ const normalizationValues = (configList: Array<any> = []) => {
.map((item: any) => {
return {
...item,
value: formatValue({ item, moduleConfig: props.moduleConfig }) //
value: formatValue({ item, moduleConfig: props.moduleConfig }) //
}
})
}
@ -132,13 +135,15 @@ const normalizationValues = (configList: Array<any> = []) => {
const registerComponents = async (formFieldData: any) => {
let innerSetters: Array<any> = []
const setters = formFieldData.map((item: any) => {
if (item.type === 'Customed') {
innerSetters.push(...(item.content || []).map((content: any) => content.type))
}
const setters = formFieldData
.filter((item: any) => !item.custom)
.map((item: any) => {
if (item.type === 'Customed') {
innerSetters.push(...(item.content || []).map((content: any) => content.type))
}
return item.type
})
return item.type
})
const settersSet = new Set([...setters, ...innerSetters])
const settersArr = Array.from(settersSet)
@ -167,14 +172,13 @@ const registerComponents = async (formFieldData: any) => {
}
watch(
() => props.formConfigList,
() => props.formConfigList, //
async (newVal: Array<any>) => {
init.value = true
if (!newVal || !newVal.length) {
return
}
await registerComponents(newVal)
init.value = false
formFieldData.value = normalizationValues(newVal)
@ -186,7 +190,7 @@ watch(
)
watch(
() => props.moduleConfig,
() => props.moduleConfig, // schema
() => {
// value
if (init.value) {

View File

@ -24,10 +24,8 @@ import LeftMenu from '@/management/components/LeftMenu.vue'
import CommonTemplate from './components/CommonTemplate.vue'
import Navbar from './components/ModuleNavbar.vue'
import { initShowLogicEngine } from '@/management/hooks/useShowLogicEngine'
const editStore = useEditStore()
const { schema, init, setSurveyId } = editStore
const { init, setSurveyId } = editStore
const router = useRouter()
const route = useRoute()
@ -36,7 +34,6 @@ onMounted(async () => {
try {
await init()
await initShowLogicEngine(schema.logicConf.showLogicConf || {})
} catch (err: any) {
ElMessage.error(err.message)
@ -60,7 +57,7 @@ onMounted(async () => {
}
.right {
min-width: 1160px;
min-width: 1300px;
height: 100%;
padding-left: 80px;
overflow: hidden;

View File

@ -0,0 +1,35 @@
<template>
<div class="btn" @click="onCooper">
<i-ep-connection class="view-icon" :size="20" />
<span class="btn-txt">协作</span>
</div>
<CooperModify :modifyId="cooperId" :visible="cooperModify" @on-close-codify="onCooperClose" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import 'element-plus/theme-chalk/src/message.scss'
import CooperModify from '@/management/components/CooperModify/ModifyDialog.vue'
defineSlots<{
content: (scope: { onCooper: () => void }) => any
}>()
const route = useRoute()
const surveyId = route.params?.id as string
const cooperModify = ref<boolean>(false)
const cooperId = ref<string>('')
const onCooper = (): void => {
cooperId.value = surveyId
cooperModify.value = true
}
const onCooperClose = (): void => {
cooperModify.value = false
}
</script>
<style lang="scss" scoped>
@import url('@/management/styles/edit-btn.scss');
</style>

View File

@ -87,12 +87,6 @@ const closedDialog = () => {
margin-left: 75px;
}
.view-icon {
font-size: 20px;
height: 29px;
line-height: 29px;
}
.preview-tab {
display: flex;
align-items: center;
@ -131,7 +125,7 @@ const closedDialog = () => {
&.pc {
display: flex;
justify-content: center;
background: #ffffff;
background: #f7f7f7;
box-shadow: 0px 2px 10px -2px rgba(82, 82, 102, 0.2);
height: 726px;
.wrapper {

View File

@ -11,24 +11,37 @@ import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import { publishSurvey, saveSurvey } from '@/management/api/survey'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
import buildData from './buildData'
interface Props {
updateLogicConf: any
updateWhiteConf: any
}
const props = defineProps<Props>()
const isPublishing = ref<boolean>(false)
const editStore = useEditStore()
const { schema, changeSchema, getSchemaFromRemote } = editStore
const { schema, getSchemaFromRemote } = editStore
const router = useRouter()
const updateLogicConf = () => {
if (
showLogicEngine.value &&
showLogicEngine.value.rules &&
showLogicEngine.value.rules.length !== 0
) {
showLogicEngine.value.validateSchema()
const showLogicConf = showLogicEngine.value.toJson()
//
changeSchema({ key: 'logicConf', value: { showLogicConf } })
const validate = () => {
let checked = true
let msg = ''
const { validated, message } = props.updateLogicConf()
if (!validated) {
checked = validated
msg = `检查页面"问卷编辑>显示逻辑"${message}`
}
const { validated: whiteValidated, message: whiteMsg } = props.updateWhiteConf()
if (!whiteValidated) {
checked = whiteValidated
msg = `检查页面"问卷设置>作答限制"${whiteMsg}`
}
return {
checked,
msg
}
}
@ -39,11 +52,11 @@ const handlePublish = async () => {
isPublishing.value = true
try {
updateLogicConf()
} catch (err) {
//
const { checked, msg } = validate()
if (!checked) {
isPublishing.value = false
ElMessage.error('请检查逻辑配置是否有误')
ElMessage.error(msg)
return
}

View File

@ -15,15 +15,22 @@
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import { saveSurvey } from '@/management/api/survey'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
import buildData from './buildData'
interface Props {
updateLogicConf: any
updateWhiteConf: any
}
const route = useRoute()
const props = defineProps<Props>()
const isSaving = ref<boolean>(false)
const isShowAutoSave = ref<boolean>(false)
const autoSaveStatus = ref<'succeed' | 'saving' | 'failed'>('succeed')
@ -38,7 +45,28 @@ const saveText = computed(
const editStore = useEditStore()
const { schemaUpdateTime } = storeToRefs(editStore)
const { schema, changeSchema } = editStore
const { schema } = editStore
const validate = () => {
let checked = true
let msg = ''
if (route.path.includes('edit/logic')) {
const { validated, message } = props.updateLogicConf()
checked = validated
msg = message
}
if (route.path.includes('edit/setting')) {
const { validated, message } = props.updateWhiteConf()
checked = validated
msg = message
}
return {
checked,
msg
}
}
const saveData = async () => {
const saveData = buildData(schema)
@ -52,19 +80,6 @@ const saveData = async () => {
return res
}
const updateLogicConf = () => {
if (
showLogicEngine.value &&
showLogicEngine.value.rules &&
showLogicEngine.value.rules.length !== 0
) {
showLogicEngine.value.validateSchema()
const showLogicConf = showLogicEngine.value.toJson()
//
changeSchema({ key: 'logicConf', value: { showLogicConf } })
}
}
const timerHandle = ref<NodeJS.Timeout | number | null>(null)
const triggerAutoSave = () => {
if (autoSaveStatus.value === 'saving') {
@ -108,11 +123,11 @@ const handleSave = async () => {
isSaving.value = true
isShowAutoSave.value = false
try {
updateLogicConf()
} catch (error) {
//
const { checked, msg } = validate()
if (!checked) {
isSaving.value = false
ElMessage.error('请检查逻辑配置是否有误')
ElMessage.error(msg)
return
}

View File

@ -10,6 +10,7 @@ export default function (schema) {
'skinConf',
'submitConf',
'questionDataList',
'pageConf',
'logicConf'
])
configData.dataConf = {

View File

@ -49,14 +49,13 @@ const hideFullTitle = () => {
<style lang="scss" scoped>
.title-container {
position: relative;
width: 280px;
max-width: 280px;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
cursor: pointer;
}

View File

@ -0,0 +1,449 @@
<script setup lang="ts">
import { onMounted, ref, watch, toRaw, computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/src/message.scss'
import LogicFlow from '@logicflow/core'
import { MiniMap, Control } from '@logicflow/extension'
import '@logicflow/extension/lib/style/index.css'
import '@logicflow/core/es/index.css'
import { useEditStore } from '@/management/stores/edit'
import { RuleNode, ConditionNode } from '@/common/logicEngine/RuleBuild'
import {
generateNodes,
generateLine,
getNodesStep,
getCondition
} from '@/management/hooks/useJumpLogicFlow'
import NodeExtension from './components/nodeExtension/index'
const editStore = useEditStore()
const jumpLogicEngine = computed(() => {
return editStore.jumpLogicEngine
})
const questionDataList = computed(() => {
return editStore.schema.questionDataList
})
const config: Partial<LogicFlow.Options> = {
snapline: false,
isSilentMode: false,
stopScrollGraph: true,
stopZoomGraph: true,
style: {
rect: {
rx: 5,
ry: 5,
strokeWidth: 2
},
circle: {
fill: '#f5f5f5',
stroke: '#666'
},
ellipse: {
fill: '#dae8fc',
stroke: '#6c8ebf'
},
polygon: {
fill: '#d5e8d4',
stroke: '#82b366'
},
diamond: {
fill: '#ffe6cc',
stroke: '#d79b00'
},
text: {
color: '#b85450',
fontSize: 12
}
},
adjustEdgeStartAndEnd: true,
adjustEdgeStart: false,
adjustEdgeEnd: true
// grid: true
}
const customTheme: Partial<LogicFlow.Theme> = {
baseNode: {
stroke: '#FBC559'
},
nodeText: {
overflowMode: 'ellipsis',
lineHeight: 1.5,
fontSize: 13
},
edgeText: {
overflowMode: 'ellipsis',
lineHeight: 1.5,
fontSize: 13,
textWidth: 100
}, // textWidth
polyline: {
stroke: 'red'
},
rect: {
width: 200,
height: 40
},
arrow: {
offset: 4, //
verticalLength: 2 //
}
}
const data = {
nodes: []
}
const lfRef = ref<LogicFlow | null>(null)
const containerRef = ref<HTMLDivElement | null>(null)
const initGraph = (questionDataList: any) => {
const list = toRaw(questionDataList)
if (list.length) {
const nodes = generateNodes(list)
let models: any[] = []
nodes.forEach((item: any) => {
const nodeModel = lfRef.value?.addNode(item)
models.push(nodeModel)
})
const edges = generateLine(models)
edges.forEach((item: any) => {
const edgeModel = lfRef.value?.addEdge(item)
if (edgeModel && ('start_node' === item.sourceNodeId || 'end_node' === item.targetNodeId)) {
edgeModel.draggable = false
edgeModel.isSelected = false
edgeModel.isShowAdjustPoint = false
}
})
}
}
const registerEvents = (lf: LogicFlow) => {
//
lf.on('anchor:drop', ({ edgeModel }) => {
/* 添加规则 **/
const { sourceNodeId, sourceAnchorId, targetNodeId } = edgeModel
const target = targetNodeId
const { field, operator, value } = getCondition({
anchorId: sourceAnchorId,
nodeId: sourceNodeId
})
const conditionNode = new ConditionNode(field, operator, value)
const ruleNode = new RuleNode(target)
ruleNode.addCondition(conditionNode)
edgeModel.setProperties({
ruleId: ruleNode.id,
conditionId: conditionNode.id
})
jumpLogicEngine.value.addRule(ruleNode)
})
// 线
lf.on('edge:exchange-node', ({ data }: any) => {
console.log('edge:exchange-node', { data })
/* 更新规则目标 **/
const { newEdge, oldEdge } = data
const ruleId = oldEdge.properties.ruleId
// 线 == 1
if (
getNodesStep(newEdge.sourceNodeId, newEdge.targetNodeId, questionDataList.value) === 1 &&
ruleId
) {
/** 删除逻辑。step n --> 1 */
console.log('删除逻辑。step n --> 1')
jumpLogicEngine.value.removeRule(ruleId)
} else {
if (ruleId) {
/** 更新逻辑. step n --> m */
console.log('更新逻辑. step n --> m ')
// const ruleId = oldEdge.properties.ruleId
const ruleNode = jumpLogicEngine.value.findRule(ruleId)
ruleNode.setTarget(newEdge.targetNodeId)
} else {
/** 添加逻辑。step 1 --> n */
console.log('添加逻辑。step 1 --> n')
const newEdgeModel = lf.graphModel.getEdgeModelById(newEdge.id)
const { sourceNodeId, sourceAnchorId } = newEdge
const { field, operator, value } = getCondition({
anchorId: sourceAnchorId,
nodeId: sourceNodeId
})
const conditionNode = new ConditionNode(field, operator, value)
const ruleNode = new RuleNode(newEdge.targetNodeId)
ruleNode.addCondition(conditionNode)
newEdgeModel?.setProperties({
ruleId: ruleNode.id,
conditionId: conditionNode.id
})
jumpLogicEngine.value.addRule(ruleNode)
}
}
})
}
onMounted(() => {
if (containerRef.value) {
const lf = new LogicFlow({
...config,
container: containerRef.value,
// height: 700,
translateCenter: true,
multipleSelectKey: 'shift',
disabledTools: ['multipleSelect'],
autoExpand: true,
adjustEdgeStartAndEnd: true,
allowRotate: false,
edgeTextEdit: false,
nodeTextEdit: false,
keyboard: {
enabled: true,
shortcuts: [
{
keys: ['backspace'],
callback: () => {
ElMessageBox.confirm('确定要删除吗?', '删除提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const elements = lf.getSelectElements(true)
lf.clearSelectElements()
elements.edges.forEach((edge) => {
console.log({ edge })
const { sourceNodeId, sourceAnchorId } = edge
if (sourceAnchorId?.split('_right')[0] === sourceNodeId) {
ElMessage({
message: '题目答完跳转的连接线不可以删除',
type: 'warning'
})
} else {
const { properties } = edge
jumpLogicEngine.value.removeRule(properties?.ruleId)
lf.deleteEdge(edge.id)
}
})
console.log(42, elements)
})
.catch(() => {})
}
}
]
},
partial: false,
background: {
color: '#FFFFFF'
},
edgeTextDraggable: false,
edgeType: 'bezier',
style: {
inputText: {
background: 'black',
color: 'white'
}
},
idGenerator(type) {
return type + '_' + Math.random()
},
plugins: [NodeExtension, MiniMap, Control],
pluginsOptions: {
miniMap: {
width: 284,
height: 84,
isShowHeader: false,
isShowCloseIcon: false,
leftPosition: 0,
rightPosition: 0,
bottomPosition: 50,
showEdge: false,
isShow: true
}
}
})
lf.setTheme(customTheme)
registerEvents(lf)
const control = lf.extension.control as Control
control.removeItem('zoom-out')
control.removeItem('zoom-in')
control.removeItem('reset')
control.removeItem('reset')
control.removeItem('undo')
control.removeItem('redo')
control.addItem({
key: 'zoom-out',
iconClass: 'iconfont icon-suoxiao',
title: '缩小流程图',
text: '缩小',
onClick: () => {
lf.zoom(false)
}
})
control.addItem({
key: 'zoom-in',
iconClass: 'iconfont icon-fangda',
title: '放大流程图',
text: '放大',
onClick: () => {
lf.zoom(true)
}
})
control.addItem({
key: 'reset',
iconClass: 'iconfont icon-shiying',
title: '恢复流程原有尺寸',
text: '适应',
onClick: () => {
lf.resetZoom()
}
})
lf.render(data)
const miniMap = lf.extension.miniMap as MiniMap
miniMap?.show()
lfRef.value = lf
if (questionDataList.value.length) {
initGraph(questionDataList.value)
}
}
})
watch(
() => questionDataList.value,
(value) => {
const list = toRaw(value)
if (list.length) {
initGraph(list)
}
}
)
</script>
<template>
<div ref="containerRef" id="graph" class="viewport"></div>
</template>
<style lang="scss">
.viewport {
height: 100%;
/* q-node */
.table-container {
box-sizing: border-box;
padding: 10px;
}
.table-node {
width: 100%;
height: 100%;
overflow: hidden;
background: #fff;
border-radius: 4px;
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
.table-node::before {
display: block;
width: 100%;
height: 8px;
background: #fbc559;
content: '';
}
.table-node.table-color-1::before {
background: #9673a6;
}
.table-node.table-color-2::before {
background: #dae8fc;
}
.table-node.table-color-3::before {
background: #82b366;
}
.table-node.table-color-4::before {
background: #f8cecc;
}
.table-name {
padding: 0 10px;
height: 32px;
font-size: 14px;
line-height: 32px;
text-align: left;
color: #4a4c5b;
width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.table-feild {
height: 28x;
padding: 0 10px;
font-size: 14px;
line-height: 28px;
white-space: nowrap;
overflow: hidden;
color: #4a4c5b;
text-overflow: ellipsis;
}
.feild-type {
color: #9f9c9f;
}
/* 自定义锚点样式 */
.custom-anchor {
cursor: crosshair;
fill: #d9d9d9;
stroke: #999;
stroke-width: 1;
rx: 3;
ry: 3;
}
.custom-anchor:hover {
fill: #ff7f0e;
stroke: #ff7f0e;
}
.lf-node-not-allow .custom-anchor:hover {
cursor: not-allowed;
fill: #d9d9d9;
stroke: #999;
}
.incomming-anchor {
stroke: #d79b00;
}
.outgoing-anchor {
stroke: #82b366;
}
.lf-mini-map {
position: absolute;
width: 300px;
height: 100px;
background: #f6f7f9;
border: none;
box-shadow: 0 2px 10px -2px rgba(82, 82, 102, 0.2);
border-radius: 4px;
}
.lf-control {
.iconfont {
color: #6e707c;
}
color: #6e707c;
}
}
</style>

View File

@ -9,7 +9,7 @@ import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit'
import { cloneDeep } from 'lodash-es'
import RulePanel from '../../modules/logicModule/RulePanel.vue'
import RulePanel from './components/RulePanel.vue'
import { filterQuestionPreviewData } from '@/management/utils/index'
const editStore = useEditStore()
@ -23,11 +23,11 @@ provide('renderData', renderData)
</script>
<style lang="scss" scoped>
.logic-wrapper {
height: calc(100% - 120px);
height: 100%;
width: 100%;
margin: 12px;
background: #fff;
text-align: center;
overflow: auto;
// position: fixed;
}
</style>

View File

@ -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<ComputedRef<Array<any>>>('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
}
}) || []

View File

@ -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<ComputedRef<Array<any>>>('renderData') || ref([])

View File

@ -21,8 +21,12 @@
<script setup lang="ts">
import { shallowRef, computed } from 'vue'
import { RuleNode, ConditionNode } from '@/common/logicEngine/RuleBuild'
import { showLogicEngine } from '@/management/hooks/useShowLogicEngine'
import RuleNodeView from './components/RuleNodeView.vue'
import { useEditStore } from '@/management/stores/edit'
import { storeToRefs } from 'pinia'
const editStore = useEditStore()
const { showLogicEngine } = storeToRefs(editStore)
import RuleNodeView from './RuleNodeView.vue'
const list = computed(() => {
return showLogicEngine.value?.rules || []

View File

@ -0,0 +1,55 @@
import { BezierEdge, BezierEdgeModel } from '@logicflow/core'
class CustomEdge2 extends BezierEdge {}
class CustomEdgeModel2 extends BezierEdgeModel {
getEdgeStyle() {
const style = super.getEdgeStyle()
// svg属性
style.strokeWidth = 1
style.stroke = '#ababac'
return style
}
/**
* 重写此方法使保存数据是能带上锚点数据
*/
getData() {
const data = super.getData()
data.sourceAnchorId = this.sourceAnchorId
data.targetAnchorId = this.targetAnchorId
return data
}
/**
* 给边自定义方案使其支持基于锚点的位置更新边的路径
*/
updatePathByAnchor() {
// TODO
const sourceNodeModel = this.graphModel.getNodeModelById(this.sourceNodeId)
const sourceAnchor = sourceNodeModel
.getDefaultAnchor()
.find((anchor) => anchor.id === this.sourceAnchorId)
const targetNodeModel = this.graphModel.getNodeModelById(this.targetNodeId)
const targetAnchor = targetNodeModel
.getDefaultAnchor()
.find((anchor) => anchor.id === this.targetAnchorId)
const startPoint = {
x: sourceAnchor.x,
y: sourceAnchor.y
}
this.updateStartPoint(startPoint)
const endPoint = {
x: targetAnchor.x,
y: targetAnchor.y
}
this.updateEndPoint(endPoint)
// 这里需要将原有的pointsList设置为空才能触发bezier的自动计算control点。
this.pointsList = []
this.initPoints()
}
}
export default {
type: 'q-edge',
view: CustomEdge2,
model: CustomEdgeModel2
}

View File

@ -0,0 +1,17 @@
import StartNode from './nodes/StartNode'
import EndNode from './nodes/EndNode'
import QNode from './nodes/QNode'
import QEdge from './edges/QEdge'
class NodeExtension {
static pluginName = 'NodeExtension'
constructor({ lf }) {
lf.register(StartNode)
lf.register(EndNode)
lf.register(QNode)
lf.register(QEdge)
lf.setDefaultEdgeType('q-edge')
}
}
export default NodeExtension

View File

@ -0,0 +1,41 @@
import { CircleNode, CircleNodeModel } from '@logicflow/core'
class EndNodeModel extends CircleNodeModel {
constructor(data, graphModel) {
data.text = {
value: data.text,
x: data.x,
y: data.y
}
super(data, graphModel)
this.r = 30
}
/**
* 重写定义锚点
*/
getDefaultAnchor() {
const { x, y, id, width } = this
const anchors = [
{
x: x - width / 2,
y: y,
id: `${id}_left`,
type: 'left'
}
]
return anchors
}
setIsShowAnchor() {
return false
}
isAllowConnectedAsSource() {
return false
}
}
export default {
type: 'end-node',
model: EndNodeModel,
view: CircleNode
}

View File

@ -0,0 +1,177 @@
import { HtmlNode, HtmlNodeModel, h } from '@logicflow/core'
class QNode extends HtmlNode {
/**
* 1.1.7版本后支持在view中重写锚点形状
* 重写锚点新增
*/
getAnchorShape(anchorData) {
const { x, y, type } = anchorData
return h('rect', {
x: x - 5,
y: y - 5,
width: 10,
height: 10,
className: `custom-anchor ${type === 'left' ? 'incomming-anchor' : 'outgoing-anchor'}`
})
}
setHtml(rootEl) {
rootEl.innerHTML = ''
const {
properties: { options = [], title }
} = this.props.model
rootEl.setAttribute('class', 'table-container')
const container = document.createElement('div')
container.className = `table-node`
const tableNameElement = document.createElement('div')
tableNameElement.innerHTML = title
tableNameElement.className = 'table-name'
container.appendChild(tableNameElement)
const fragment = document.createDocumentFragment()
for (let i = 0; i < options.length; i++) {
const item = options[i]
const itemElement = document.createElement('div')
itemElement.className = 'table-feild'
const itemKey = document.createElement('span')
itemKey.innerHTML = item.type
itemElement.appendChild(itemKey)
// itemKey.innerText = item.key;
// const itemType = document.createElement('span');
// itemType.innerHTML = item.type;
// itemType.className = 'feild-type';
// itemElement.appendChild(itemType);
fragment.appendChild(itemElement)
}
container.appendChild(fragment)
rootEl.appendChild(container)
}
}
class QNodeModel extends HtmlNodeModel {
getOutlineStyle() {
const style = super.getOutlineStyle()
style.stroke = 'none'
style.hover.stroke = 'none'
return style
}
// 如果不用修改锚地形状,可以重写颜色相关样式
getAnchorStyle(anchorInfo) {
const style = super.getAnchorStyle()
if (anchorInfo.type === 'left') {
style.fill = 'red'
style.hover.fill = 'transparent'
style.hover.stroke = 'transpanrent'
style.className = 'lf-hide-default'
} else {
style.fill = 'green'
}
return style
}
setAttributes() {
this.width = 200
const {
properties: { options = [] }
} = this
this.height = 60 + options.length * 28
const circleOnlyAsTarget = {
message: '只允许从右边的锚点连出',
validate: (sourceNode, targetNode, sourceAnchor) => {
return sourceAnchor.type === 'right'
}
}
this.sourceRules.push(circleOnlyAsTarget)
this.targetRules.push({
message: '只允许连接左边的锚点',
validate: (sourceNode, targetNode, sourceAnchor, targetAnchor) => {
return targetAnchor.type === 'left'
}
})
}
getDefaultAnchor() {
const {
id,
x,
y,
width,
height,
properties: { options }
} = this
const anchors = [
{
x: x - width / 2 + 10,
y: y - height / 2 + 60 - 28,
id: `${id}_left`,
edgeAddable: false,
type: 'left'
},
{
x: x + width / 2 - 10,
y: y - height / 2 + 60 - 28,
id: `${id}_right`,
edgeAddable: false,
type: 'right'
}
]
options.forEach((feild, index) => {
const anchorId = `${feild.key}_right`
const { edges } = this.outgoing
let edgeAddable = true
if (edges.length) {
const sourceAnchorIds = edges.map((edge) => edge.sourceAnchorId)
edgeAddable = !sourceAnchorIds.includes(anchorId)
}
anchors.push({
x: x + width / 2 - 10,
y: y - height / 2 + 60 - 28 + (index + 1) * 30,
id: anchorId,
type: 'right',
key: feild.key,
edgeAddable
})
})
return anchors
}
setIsShowAnchor() {
return false
}
// 获取当前节点作为边的起始节点规则。
// getConnectedSourceRules() {
// const rules = super.getConnectedSourceRules();
// // 开始节点的锚点只能拉出一条连接线
// const geteWayOnlyAsTarget = {
// message: "开始节点的锚点只能拉出一条连接线",
// validate: (
// source,
// target,
// sourceAnchor,
// targetAnchor
// ) => {
// // 获取该节点下目标连接线
// const edges = this.graphModel.getNodeOutgoingEdge(source.id);
// console.log({edges});
// // 如果连接线的锚点id存在则不允许链接
// const sourceAnchorIds = edges.map(edge => edge.sourceAnchorId);
// // 判断该新拉出的边是否已经有了目标链接有的话disable
// let isValid = true;
// if (sourceAnchorIds.includes(sourceAnchor.id)) {
// isValid = false;
// }
// console.log(edges[0].targetNodeId,target.id, sourceAnchorIds, sourceAnchor.id, isValid)
// return isValid;
// },
// };
// // 如果该题存在无条件跳转则禁用选项跳转
// // 如果该题存在选项跳转则禁用无条件跳转
// rules.push(geteWayOnlyAsTarget);
// return rules;
// }
}
export default {
type: 'q-node',
model: QNodeModel,
view: QNode
}

View File

@ -0,0 +1,42 @@
import { CircleNode, CircleNodeModel } from '@logicflow/core'
class StartNodeModel extends CircleNodeModel {
constructor(data, graphModel) {
data.text = {
value: data.text,
x: data.x,
y: data.y
}
super(data, graphModel)
this.r = 30
}
/**
* 重写定义锚点
*/
getDefaultAnchor() {
const { x, y, id, width } = this
const anchors = [
{
x: x + width / 2,
y: y,
id: `${id}_right`,
type: 'right'
}
]
return anchors
}
setIsShowAnchor() {
return false
}
isAllowConnectedAsTarget() {
return false
}
}
export default {
type: 'start-node',
model: StartNodeModel,
view: CircleNode
}

View File

@ -0,0 +1,3 @@
# 说明
参考node-red样式以logicflow插件的方式实现。

View File

@ -0,0 +1,23 @@
.custom-anchor {
stroke: #999;
stroke-width: 1;
fill: #d9d9d9;
cursor: crosshair;
rx: 3;
ry: 3;
}
.custom-anchor:hover {
fill: #ff7f0e;
stroke: #ff7f0e;
}
.node-red-palette {
width: 150px;
overflow: hidden;
position: absolute;
left: 0px;
top: 0px;
}
.node-red-start {
cursor: pointer;
pointer-events: all;
}

View File

@ -0,0 +1,18 @@
/* 求字符串的字节长度 */
export const getBytesLength = (word) => {
if (!word) {
return 0
}
let totalLength = 0
for (let i = 0; i < word.length; i++) {
const c = word.charCodeAt(i)
if (word.match(/[A-Z]/)) {
totalLength += 1.5
} else if ((c >= 0x0001 && c <= 0x007e) || (c >= 0xff60 && c <= 0xff9f)) {
totalLength += 1
} else {
totalLength += 1.8
}
}
return totalLength
}

View File

@ -1,5 +1,5 @@
<template>
<el-tabs type="border-card" v-model="tabSelected" class="tab-box">
<el-tabs type="border-card" v-model="tabSelected" stretch class="tab-box">
<el-tab-pane label="题型选择">
<TypeList />
</el-tab-pane>
@ -18,17 +18,25 @@ const tabSelected = ref<string>('0')
</script>
<style lang="scss" scoped>
.tab-box {
width: 300px;
width: 360px;
height: 100%;
box-shadow: none;
border: none;
overflow-y: auto;
display: flex;
flex-direction: column;
:deep(.el-tabs__nav) {
width: 100%;
}
:deep(.el-tabs__content) {
flex: 1;
overflow-y: auto;
}
:deep(.el-tabs__item) {
width: 50%;
text-align: center;
}
&.el-tabs--border-card :deep(.el-tabs__item:last-child.is-active) {
border-right-color: transparent;
}
}
</style>

View File

@ -1,8 +1,12 @@
<template>
<div class="main-operation" @click="onMainClick" ref="mainOperation">
<div class="pagination-wrapper">
<PageWrapper :readonly="false" />
</div>
<div class="operation-wrapper" ref="operationWrapper">
<div class="box content" ref="box">
<MainTitle
v-if="pageEditOne == 1"
:bannerConf="bannerConf"
:readonly="false"
:is-selected="currentEditOne === 'mainTitle'"
@ -11,7 +15,7 @@
/>
<MaterialGroup
:current-edit-one="parseInt(currentEditOne)"
:questionDataList="questionDataList"
:questionDataList="pageQuestionData"
@select="setCurrentEditOne"
@change="handleChange"
@changeSeq="onQuestionOperation"
@ -21,6 +25,7 @@
:submit-conf="submitConf"
:readonly="false"
:skin-conf="skinConf"
:is-finally-page="isFinallyPage"
:is-selected="currentEditOne === 'submit'"
@select="setCurrentEditOne('submit')"
/>
@ -32,6 +37,7 @@
<script setup>
import { ref, watch, toRefs } from 'vue'
import communalLoader from '@materials/communals/communalLoader.js'
import PageWrapper from '@/management/pages/edit/components/Pagination/PaginationWrapper.vue'
import MaterialGroup from '@/management/pages/edit/components/MaterialGroup.vue'
import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit'
@ -40,13 +46,15 @@ const MainTitle = communalLoader.loadComponent('MainTitle')
const SubmitButton = communalLoader.loadComponent('SubmitButton')
const editStore = useEditStore()
const { questionDataList, currentEditOne, currentEditKey } = storeToRefs(editStore)
const { currentEditOne, currentEditKey, pageQuestionData, isFinallyPage, pageEditOne } =
storeToRefs(editStore)
const { schema, changeSchema, moveQuestion, copyQuestion, deleteQuestion, setCurrentEditOne } =
editStore
const mainOperation = ref(null)
const materialGroup = ref(null)
const { bannerConf, submitConf, skinConf } = toRefs(schema)
// const autoScrollData = computed(() => {
// return {
// currentEditOne: currentEditOne.value,
@ -138,6 +146,13 @@ watch(
align-items: center;
background-color: #f6f7f9;
}
.pagination-wrapper {
width: 90%;
padding-right: 30px;
margin-right: -30px;
position: relative;
top: 50px;
}
.toolbar {
width: 100%;

View File

@ -1,37 +1,69 @@
<template>
<div class="setter-wrapper">
<div class="no-select-question" v-if="currentEditOne === null">
<img src="/imgs/icons/unselected.webp" />
<h4 class="tipFont">选中题型可以编辑</h4>
<span class="tip">试试看</span>
</div>
<template v-else>
<div class="setter-title">{{ currentEditMeta?.title || '' }}</div>
<SetterField
class="question-config-form"
:form-config-list="formConfigList"
:module-config="moduleConfig"
@form-change="handleFormChange"
/>
</template>
<el-tabs v-model="confType" type="border-card" stretch>
<el-tab-pane name="baseConf" label="单题设置">
<div v-if="currentEditMeta?.title" class="setter-title">
{{ currentEditMeta?.title || '' }}
</div>
<div
class="no-select-question"
v-if="editStore.currentEditOne === 'mainTitle' || editStore.currentEditOne === null"
>
<img src="/imgs/icons/unselected.webp" />
<h4 class="tipFont">选中题型可以编辑</h4>
<span class="tip">试试看</span>
</div>
<SetterField
v-else
:form-config-list="formConfigList"
:module-config="moduleConfig"
@form-change="handleFormChange"
/>
</el-tab-pane>
<el-tab-pane name="globalBaseConf" label="整卷设置">
<SetterField
:form-config-list="[basicConfig]"
:module-config="editStore.editGlobalBaseConf.globalBaseConfig"
@form-change="editStore.editGlobalBaseConf.updateGlobalConfOption"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit'
<script setup lang="ts">
import { ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit'
import basicConfig from '@/materials/questions/common/config/basicConfig'
import SetterField from '@/management/pages/edit/components/SetterField.vue'
const confType = ref('baseConf')
const editStore = useEditStore()
const { currentEditOne, currentEditKey, currentEditMeta, formConfigList, moduleConfig } =
storeToRefs(editStore)
const { currentEditKey, currentEditMeta, formConfigList, moduleConfig } = storeToRefs(editStore)
const { changeSchema } = editStore
const handleFormChange = (data: any) => {
const { key, value } = data
const resultKey = `${currentEditKey.value}.${key}`
changeSchema({ key: resultKey, value })
if (key in editStore.editGlobalBaseConf.globalBaseConfig)
editStore.editGlobalBaseConf.updateCounts('MODIFY', { key, value })
}
watch(
() => editStore.currentEditOne,
(newVal) => {
if (newVal === 0 || (!!newVal && newVal !== 'mainTitle')) {
confType.value = 'baseConf'
}
}
)
</script>
<style lang="scss" scoped>
.setter-wrapper {
width: 360px;
@ -39,36 +71,53 @@ const handleFormChange = (data: any) => {
overflow-x: hidden;
overflow-y: auto;
background-color: #fff;
}
.no-select-question {
padding-top: 125px;
display: flex;
flex-direction: column;
align-items: center;
.setter-title {
height: 40px;
line-height: 40px;
font-size: 14px;
color: $primary-color;
padding-left: 20px;
border-bottom: 1px solid #edeffc;
}
img {
width: 160px;
padding: 25px;
}
.no-select-question {
padding-top: 125px;
display: flex;
flex-direction: column;
align-items: center;
img {
width: 160px;
padding: 25px;
.tip {
font-size: 14px;
color: $normal-color;
letter-spacing: 0;
}
}
.tip {
.setter-title {
height: 40px;
line-height: 40px;
font-size: 14px;
color: $normal-color;
letter-spacing: 0;
color: $primary-color;
padding-left: 20px;
border-bottom: 1px solid #edeffc;
}
}
.question-config-form {
padding: 30px 20px 50px 20px;
:deep(.el-tabs) {
width: 360px;
height: 100%;
border: none;
display: flex;
flex-direction: column;
.el-tabs__nav {
width: 100%;
}
.el-tabs__content {
flex: 1;
overflow-y: auto;
padding: 0;
.config-form {
padding: 30px 20px 50px 20px;
}
}
}
}
</style>

View File

@ -1,68 +1,108 @@
<template>
<div class="question-catalog-wrapper">
<draggable
:list="renderData"
@end="handleDragEnd"
itemKey="field"
handle=".draggHandle"
host-class="catalog-item-ghost"
>
<template #item="{ element, index }">
<CatalogItem
:title="element.title"
:indexNumber="element.indexNumber"
:showIndex="element.showIndex"
@select="setCurrentEditOne(index)"
/>
</template>
</draggable>
<el-collapse>
<el-collapse-item v-for="(v, i) in renderData" :key="v" :title="`第${i + 1}页`" :name="i + 1">
<draggable
v-model="renderData[i]"
itemKey="field"
:group="QUESTION_CATALOG"
handle=".draggHandle"
host-class="catalog-item-ghost"
>
<template #item="{ element }">
<CatalogItem
:title="element.title"
:indexNumber="element.indexNumber"
:showIndex="element.showIndex"
@select="setPageOneEdit(element.qIndex, i + 1)"
/>
</template>
</draggable>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { watch, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit'
import draggable from 'vuedraggable'
import CatalogItem from './CatalogItem.vue'
import { filterQuestionPreviewData } from '@/management/utils/index'
import { QUESTION_CATALOG } from '@/management/config/dnd'
const editStore = useEditStore()
const { questionDataList, currentEditOne } = storeToRefs(editStore)
const { moveQuestion, setCurrentEditOne } = editStore
const renderData = computed(() => {
return filterQuestionPreviewData(questionDataList.value) || []
})
const { questionDataList, pageConf } = storeToRefs(editStore)
const { setCurrentEditOne, getPageQuestionData, updatePageEditOne, setPage, compareQuestionSeq } =
editStore
const handleDragEnd = ({ newIndex, oldIndex }: any) => {
if (currentEditOne.value === oldIndex) {
setCurrentEditOne(newIndex)
}
const renderData: any = ref([])
moveQuestion({
index: oldIndex,
range: newIndex - oldIndex
})
const setPageOneEdit = (qIndex: number, pageIndex: number) => {
updatePageEditOne(pageIndex)
setCurrentEditOne(qIndex)
}
watch(
() => [pageConf.value, questionDataList.value],
() => {
renderData.value = []
for (let index = 0; index < pageConf.value.length; index++) {
renderData.value.push(getPageQuestionData(index + 1))
}
},
{
deep: true,
immediate: true
}
)
watch(
() => renderData.value,
(newVal) => {
if (newVal.length == 0) return
let pageData: Array<number> = []
let questionList: Array<any> = []
newVal.map((v: any) => {
pageData.push(v.length)
questionList.push(...v)
})
setPage(pageData)
compareQuestionSeq(questionList)
},
{
deep: true
}
)
</script>
<style lang="scss" scoped>
.question-catalog-wrapper {
padding-bottom: 400px; //
.catelog-first-page {
font-size: 12px;
color: #999999;
padding-bottom: 8px;
}
.catalog-item-ghost {
&.question-catalog-item {
.catalog-item {
color: $normal-color-light;
.draggHandle {
color: $normal-color-light;
}
}
}
}
:deep(.el-collapse-item__header) {
font-size: 14px;
color: #6e707c;
font-weight: 500;
}
.catalog-item-dragging {
opacity: 1;
background: #ffffff;

View File

@ -10,7 +10,7 @@
class="questiontype-list"
:list="item.questionList"
:group="{ name: DND_GROUP, pull: 'clone', put: false }"
:clone="getNewQuestion"
:clone="createNewQuestion"
item-key="path"
>
<template #item="{ element }">
@ -19,11 +19,14 @@
class="qtopic-item"
:id="'qtopic' + element.type"
@click="onQuestionType({ type: element.type })"
@mouseenter="showPreview(element, 'qtopic' + element.type)"
@mouseleave="isShowPreviewImage = false"
@mousedown="isShowPreviewImage = false"
>
<i class="iconfont" :class="['icon-' + element.icon]"></i>
<i
class="iconfont"
:class="['icon-' + element.icon]"
@mouseenter="showPreview(element, 'qtopic' + element.type)"
@mouseleave="isShowPreviewImage = false"
@mousedown="isShowPreviewImage = false"
></i>
<p class="text">{{ element.title }}</p>
</div>
</template>
@ -44,44 +47,25 @@ import draggable from 'vuedraggable'
import { DND_GROUP } from '@/management/config/dnd'
import questionMenuConfig, { questionTypeList } from '@/management/config/questionMenuConfig'
import { getQuestionByType } from '@/management/utils/index'
import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit'
import { isNumber as _isNumber } from 'lodash-es'
import { computed, ref } from 'vue'
import { QUESTION_TYPE } from '@/common/typeEnum.ts'
import { ref } from 'vue'
const editStore = useEditStore()
const { questionDataList, currentEditOne } = storeToRefs(editStore)
const { addQuestion, setCurrentEditOne } = editStore
const { newQuestionIndex } = storeToRefs(editStore)
const { addQuestion, setCurrentEditOne, createNewQuestion } = editStore
const activeNames = ref([0, 1])
const previewImg = ref('')
const isShowPreviewImage = ref(false)
const previewTop = ref(0)
const newQuestionIndex = computed(() => {
const index = _isNumber(currentEditOne.value)
? currentEditOne.value + 1
: questionDataList.value.length
return index
})
questionLoader.init({
typeList: questionTypeList.map((item) => item.type)
})
const getNewQuestion = ({ type }) => {
const fields = questionDataList.value.map((item) => item.field)
const newQuestion = getQuestionByType(type, fields)
newQuestion.title = newQuestion.title = `标题${newQuestionIndex.value + 1}`
if (type === QUESTION_TYPE.VOTE) {
newQuestion.innerType = QUESTION_TYPE.RADIO
}
return newQuestion
}
const onQuestionType = ({ type }) => {
const newQuestion = getNewQuestion({ type })
const newQuestion = createNewQuestion({ type })
addQuestion({ question: newQuestion, index: newQuestionIndex.value })
setCurrentEditOne(newQuestionIndex.value)
}

View File

@ -17,10 +17,11 @@
</div>
</template>
<script setup lang="ts">
import { useEditStore } from '@/management/stores/edit'
import { EDIT_STATUS_MAP } from '../enum'
import { storeToRefs } from 'pinia'
import { EDIT_STATUS_MAP } from './components/enum'
import { useEditStore } from '@/management/stores/edit'
const editStore = useEditStore()
const { currentEditStatus } = storeToRefs(editStore)
const statusList = [
@ -44,7 +45,7 @@ const handleChangePreview = (data: any) => {
</script>
<style lang="scss" scoped>
.tab-box {
width: 300px;
width: 360px;
height: 100%;
box-shadow: none;
border: none;

View File

@ -16,9 +16,9 @@ import { toRef } from 'vue'
import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit'
import SuccessContent from '../components/SuccessContent.vue'
import OverTime from '../components/OverTime.vue'
import { EDIT_STATUS_MAP } from '../enum'
import SuccessContent from './components/SuccessContent.vue'
import OverTime from './components/OverTime.vue'
import { EDIT_STATUS_MAP } from './components/enum'
const components = {
[EDIT_STATUS_MAP.SUCCESS]: SuccessContent,

View File

@ -0,0 +1,74 @@
<template>
<div class="question-edit-form">
<div class="setter-title">
{{ currentEditText }}
</div>
<SetterField
class="question-config-form"
label-position="top"
:form-config-list="formFields"
:module-config="moduleConfig"
@form-change="handleFormChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { storeToRefs } from 'pinia'
import { useEditStore } from '@/management/stores/edit'
import { get as _get } from 'lodash-es'
import statusConfig from '@/management/pages/edit/setterConfig/statusConfig'
import SetterField from '@/management/pages/edit/components/SetterField.vue'
const textMap = {
Success: '提交成功页面配置',
OverTime: '问卷过期页面配置'
}
const editStore = useEditStore()
const { currentEditStatus } = storeToRefs(editStore)
const { schema, changeSchema } = editStore
const moduleConfig = toRef(schema, 'submitConf')
const currentEditText = computed(() => (textMap as any)[currentEditStatus.value])
const formFields = computed(() => {
const formList = (statusConfig as any)[currentEditStatus.value] || []
const list = formList.map((item: any) => {
const value = _get(moduleConfig.value, item.key, item.value)
return { ...item, value }
})
return list
})
const handleFormChange = ({ key, value }: any) => {
changeSchema({
key: `submitConf.${key}`,
value
})
}
</script>
<style lang="scss" scoped>
.question-edit-form {
width: 360px;
height: 100%;
overflow-y: auto;
background-color: #ffffff;
}
.setter-title {
height: 40px;
line-height: 40px;
font-size: 14px;
color: $primary-color;
padding-left: 20px;
// background: #f9fafc;
border-bottom: 1px solid #edeffc;
}
.question-config-form {
padding: 30px 20px 50px 20px;
}
</style>

Some files were not shown because too many files have changed in this diff Show More