fix: 修复断点续答以及样式问题 (#420)
* feat: 修改readme * [Feature]: 密码复杂度检测 (#407) * feat: 密码复杂度检测 * chore: 改为服务端校验 * feat: 优化展示 * fix:修复编辑页在不同element版本下表现不一致问题 (#406) * fix: 通过声明element最低版本来确定tab样式表现 * fix lint * feat(选项设置扩展):选择类题型增加选项排列配置 (#403) * build: add optimizeDeps packages * feat(选项设置扩展):选择类题型增加选项排列配置 * feat(选项设置扩展): 验收问题修复 --------- Co-authored-by: jiangchunfu <jiangchunfu@kaike.la> * fix: 删除多余内容 * feat: 优化登录窗口 * fix: 修复断点续答以及样式问题 fix: 修复选项引用验收bug fix: 修复断点续答问题 fix: 修复断点续答 fix: ignore fix: 修复投票题默认值 fix: 优化断点续答逻辑 fix: 选中图标适应高度 fix: 回退最大最小选择 fix: 修复断点续答 fix: 修复elswitch不更新问题 fix: 修复访问密码更新不生效问题 fix: 修复样式 fix: 修复多选题最大最小限制 fix: 优化断点续答问题 修复多选题命中最多选择后无法取消问题 fix: 修复服务端的富文本解析 fix: lint fix: min error fix: 修复最少最多选择 fix: 修复投票问卷的最少最多选择 fix: 兼容断点续答情况下选项配额为0的情况 fix: 兼容断点续答情况下选项配额为0的情况 fix: 兼容单选题的断点续答下的选项配额 fix: 修复添加选项问题 fix: 前端提示服务的配额已满 fix: 更新填写的过程中配额减少情况 --------- Co-authored-by: sudoooooo <zjbbabybaby@gmail.com> Co-authored-by: Stahsf <30379566+50431040@users.noreply.github.com> Co-authored-by: Jiangchunfu <mrj_kevin@163.com> Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
This commit is contained in:
parent
d08f1c71e5
commit
6cbfe20be1
3
.gitignore
vendored
3
.gitignore
vendored
@ -27,8 +27,9 @@ pnpm-debug.log*
|
||||
|
||||
.history
|
||||
|
||||
exportfile
|
||||
components.d.ts
|
||||
|
||||
# 默认的上传文件夹
|
||||
userUpload
|
||||
exportfile
|
||||
yarn.lock
|
@ -29,7 +29,7 @@
|
||||
|
||||
<br />
|
||||
|
||||
  **XIAOJUSURVEY**是一套轻量、安全的问卷系统,提供面向个人和企业的一站式产品级解决方案,用于构建各类问卷、考试、测评和复杂表单,快速满足各类线上调研场景。
|
||||
  **XIAOJUSURVEY**是一套轻量、安全的调研系统,提供面向个人和企业的一站式产品级解决方案,用于构建各类问卷、考试、测评和复杂表单,快速满足各类线上调研场景。
|
||||
|
||||
  内部系统已沉淀 40+种题型,累积精选模板 100+,适用于市场调研、客户满意度调研、在线考试、投票、报道、测评等众多场景。数据能力上,经过上亿量级打磨,沉淀了分题统计、交叉分析、多渠道分析等在线报表能力,快速满足专业化分析。
|
||||
|
||||
|
@ -29,7 +29,7 @@
|
||||
|
||||
<br />
|
||||
|
||||
  XIAOJUSURVEY is an open-source form builder and analytics platform to create questionnaires, exams, polls, quizzes, and analyze data online.
|
||||
  XIAOJUSURVEY is an enterprises form builder and analytics platform to create questionnaires, exams, polls, quizzes, and analyze data online.
|
||||
|
||||
  The internal system has accumulated over 40 question types and more than 100 selected templates, suitable for market research, customer satisfaction surveys, online exams, voting, reporting, evaluations, and many other scenarios. In terms of data capabilities, it has been honed through hundreds of millions of iterations, resulting in the ability to provide online reports with per-question statistics, cross-analysis, and multi-channel analysis, quickly meeting professional analysis needs.
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
XIAOJU_SURVEY_MONGO_DB_NAME= # xiaojuSurvey
|
||||
XIAOJU_SURVEY_MONGO_DB_NAME= xiaojuSurvey
|
||||
XIAOJU_SURVEY_MONGO_URL= # mongodb://localhost:27017 # 建议设置强密码
|
||||
XIAOJU_SURVEY_MONGO_AUTH_SOURCE= # admin
|
||||
|
||||
|
@ -48,7 +48,8 @@
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"typeorm": "^0.3.19"
|
||||
"typeorm": "^0.3.19",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
|
@ -6,6 +6,7 @@ export enum EXCEPTION_CODE {
|
||||
USER_EXISTS = 2001, // 用户已存在
|
||||
USER_NOT_EXISTS = 2002, // 用户不存在
|
||||
USER_PASSWORD_WRONG = 2003, // 用户名或密码错误
|
||||
PASSWORD_INVALID = 2004, // 密码无效
|
||||
NO_SURVEY_PERMISSION = 3001, // 没有问卷权限
|
||||
SURVEY_STATUS_TRANSFORM_ERROR = 3002, // 问卷状态转换报错
|
||||
SURVEY_TYPE_ERROR = 3003, // 问卷类型错误
|
||||
|
@ -82,6 +82,19 @@ describe('AuthController', () => {
|
||||
new HttpException('验证码不正确', EXCEPTION_CODE.CAPTCHA_INCORRECT),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw HttpException with PASSWORD_INVALID code when password is invalid', async () => {
|
||||
const mockUserInfo = {
|
||||
username: 'testUser',
|
||||
password: '无效的密码abc123',
|
||||
captchaId: 'testCaptchaId',
|
||||
captcha: 'testCaptcha',
|
||||
};
|
||||
|
||||
await expect(controller.register(mockUserInfo)).rejects.toThrow(
|
||||
new HttpException('密码无效', EXCEPTION_CODE.PASSWORD_INVALID),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
|
||||
import { Controller, Post, Body, HttpCode, Get, Query } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UserService } from '../services/user.service';
|
||||
import { CaptchaService } from '../services/captcha.service';
|
||||
@ -7,6 +7,9 @@ import { HttpException } from 'src/exceptions/httpException';
|
||||
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
|
||||
import { create } from 'svg-captcha';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
const passwordReg = /^[a-zA-Z0-9!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]+$/;
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('/api/auth')
|
||||
export class AuthController {
|
||||
@ -28,6 +31,24 @@ export class AuthController {
|
||||
captcha: string;
|
||||
},
|
||||
) {
|
||||
if (!userInfo.password) {
|
||||
throw new HttpException('密码无效', EXCEPTION_CODE.PASSWORD_INVALID);
|
||||
}
|
||||
|
||||
if (userInfo.password.length < 6 || userInfo.password.length > 16) {
|
||||
throw new HttpException(
|
||||
'密码长度在 6 到 16 个字符',
|
||||
EXCEPTION_CODE.PASSWORD_INVALID,
|
||||
);
|
||||
}
|
||||
|
||||
if (!passwordReg.test(userInfo.password)) {
|
||||
throw new HttpException(
|
||||
'密码只能输入数字、字母、特殊字符',
|
||||
EXCEPTION_CODE.PASSWORD_INVALID,
|
||||
);
|
||||
}
|
||||
|
||||
const isCorrect = await this.captchaService.checkCaptchaIsCorrect({
|
||||
captcha: userInfo.captcha,
|
||||
id: userInfo.captchaId,
|
||||
@ -162,4 +183,35 @@ export class AuthController {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码强度
|
||||
*/
|
||||
@Get('register/password/strength')
|
||||
@HttpCode(200)
|
||||
async getPasswordStrength(@Query('password') password: string) {
|
||||
const numberReg = /[0-9]/.test(password);
|
||||
const letterReg = /[a-zA-Z]/.test(password);
|
||||
const symbolReg = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password);
|
||||
// 包含三种、且长度大于8
|
||||
if (numberReg && letterReg && symbolReg && password.length >= 8) {
|
||||
return {
|
||||
code: 200,
|
||||
data: 'Strong',
|
||||
};
|
||||
}
|
||||
|
||||
// 满足任意两种
|
||||
if ([numberReg, letterReg, symbolReg].filter(Boolean).length >= 2) {
|
||||
return {
|
||||
code: 200,
|
||||
data: 'Medium',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: 'Weak',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -41,8 +41,8 @@
|
||||
"innerType": "radio",
|
||||
"field": "data606",
|
||||
"title": "标题2",
|
||||
"minNum": "",
|
||||
"maxNum": "",
|
||||
"minNum": 0,
|
||||
"maxNum": 0,
|
||||
"options": [
|
||||
{
|
||||
"text": "选项1",
|
||||
|
@ -2,6 +2,7 @@ import { Controller, Post, Body, HttpCode } from '@nestjs/common';
|
||||
import { HttpException } from 'src/exceptions/httpException';
|
||||
import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException';
|
||||
import { checkSign } from 'src/utils/checkSign';
|
||||
import { cleanRichTextWithMediaTag } from 'src/utils/xss'
|
||||
import { ENCRYPT_TYPE } from 'src/enums/encrypt';
|
||||
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
|
||||
import { getPushingData } from 'src/utils/messagePushing';
|
||||
@ -245,7 +246,7 @@ export class SurveyResponseController {
|
||||
if (quota !== 0 && quota <= optionCountData[val]) {
|
||||
const item = dataList.find((item) => item['field'] === field);
|
||||
throw new HttpException(
|
||||
`【${item['title']}】中的【${option['text']}】所选人数已达到上限,请重新选择`,
|
||||
`【${cleanRichTextWithMediaTag(item['title'])}】中的【${cleanRichTextWithMediaTag(option['text'])}】所选人数已达到上限,请重新选择`,
|
||||
EXCEPTION_CODE.RESPONSE_OVER_LIMIT,
|
||||
);
|
||||
}
|
||||
|
53
server/src/utils/xss.ts
Normal file
53
server/src/utils/xss.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import xss from 'xss'
|
||||
|
||||
const myxss = new (xss as any).FilterXSS({
|
||||
onIgnoreTagAttr(tag, name, value) {
|
||||
if (name === 'style' || name === 'class') {
|
||||
return `${name}="${value}"`
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
onIgnoreTag(tag, html) {
|
||||
// <xxx>过滤为空,否则不过滤为空
|
||||
var re1 = new RegExp('<.+?>', 'g')
|
||||
if (re1.test(html)) {
|
||||
return ''
|
||||
} else {
|
||||
return html
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const cleanRichTextWithMediaTag = (text) => {
|
||||
if (!text) {
|
||||
return text === 0 ? 0 : ''
|
||||
}
|
||||
const html = transformHtmlTag(text)
|
||||
.replace(/<img([\w\W]+?)\/>/g, '[图片]')
|
||||
.replace(/<video.*\/video>/g, '[视频]')
|
||||
const content = html.replace(/<[^<>]+>/g, '').replace(/ /g, '')
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
export function escapeHtml(html) {
|
||||
return html.replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
export const transformHtmlTag = (html) => {
|
||||
if (!html) return ''
|
||||
if (typeof html !== 'string') return html + ''
|
||||
return html
|
||||
.replace(html ? /&(?!#?\w+;)/g : /&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\\\n/g, '\\n')
|
||||
//.replace(/ /g, "")
|
||||
}
|
||||
|
||||
const filterXSSClone = myxss.process.bind(myxss)
|
||||
|
||||
export const filterXSS = (html) => filterXSSClone(transformHtmlTag(html))
|
||||
|
||||
export const escapeFilterXSS = (html) => escapeHtml(filterXSS(html))
|
@ -23,7 +23,7 @@
|
||||
"clipboard": "^2.0.11",
|
||||
"crypto-js": "^4.2.0",
|
||||
"echarts": "^5.5.0",
|
||||
"element-plus": "^2.7.0",
|
||||
"element-plus": "^2.8.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"nanoid": "^5.0.7",
|
||||
|
@ -10,4 +10,12 @@ export const login = (data) => {
|
||||
|
||||
export const getUserInfo = () => {
|
||||
return axios.get('/user/getUserInfo')
|
||||
}
|
||||
}
|
||||
/** 获取密码强度 */
|
||||
export const getPasswordStrength = (password) => {
|
||||
return axios.get('/auth/register/password/strength', {
|
||||
params: {
|
||||
password
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import { useQuestionInfo } from './useQuestionInfo'
|
||||
import { useEditStore } from '../stores/edit'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
|
||||
// 目标题的显示逻辑提示文案
|
||||
export const useJumpLogicInfo = (field) => {
|
||||
const editStore = useEditStore()
|
||||
|
@ -68,15 +68,17 @@ const updateLogicConf = () => {
|
||||
}
|
||||
|
||||
const showLogicConf = showLogicEngine.value.toJson()
|
||||
|
||||
// 更新逻辑配置
|
||||
changeSchema({ key: 'logicConf', value: { showLogicConf } })
|
||||
|
||||
if(JSON.stringify(schema.logicConf.showLogicConf) !== JSON.stringify(showLogicConf)) {
|
||||
// 更新逻辑配置
|
||||
changeSchema({ key: 'logicConf', value: { showLogicConf } })
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
const jumpLogicConf = jumpLogicEngine.value.toJson()
|
||||
changeSchema({ key: 'logicConf', value: { jumpLogicConf } })
|
||||
if(JSON.stringify(schema.logicConf.jumpLogicConf) !== JSON.stringify(jumpLogicConf)){
|
||||
changeSchema({ key: 'logicConf', value: { jumpLogicConf } })
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ import Navbar from './components/ModuleNavbar.vue'
|
||||
|
||||
|
||||
const editStore = useEditStore()
|
||||
const { init, setSurveyId, initSessionId } = editStore
|
||||
const { init, setSurveyId } = editStore
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
@ -86,7 +86,7 @@ const onSave = async () => {
|
||||
}
|
||||
|
||||
const seize = async () => {
|
||||
const seizeRes: Record<string, any> = await seizeSession({ sessionId })
|
||||
const seizeRes: Record<string, any> = await seizeSession({ sessionId:sessionId.value })
|
||||
if (seizeRes.code === 200) {
|
||||
location.reload();
|
||||
} else {
|
||||
@ -152,6 +152,7 @@ const handleSave = async () => {
|
||||
}
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('保存成功')
|
||||
return res
|
||||
} else if (res.code === 3006) {
|
||||
ElMessageBox.alert('当前问卷已在其它页面开启编辑,点击“抢占”以获取保存权限。', '提示', {
|
||||
confirmButtonText: '抢占',
|
||||
|
@ -74,6 +74,7 @@ const routes = [
|
||||
background-color: $primary-color;
|
||||
bottom: -16px;
|
||||
left: 20px;
|
||||
z-index: 99;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -22,8 +22,6 @@ const tabSelected = ref<string>('0')
|
||||
height: 100%;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
:deep(.el-tabs__nav) {
|
||||
width: 100%;
|
||||
}
|
||||
|
@ -102,8 +102,6 @@ watch(
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.el-tabs__nav {
|
||||
width: 100%;
|
||||
|
@ -80,6 +80,7 @@ import 'element-plus/theme-chalk/src/message.scss'
|
||||
|
||||
import { useEditStore } from '@/management/stores/edit'
|
||||
import { cleanRichText } from '@/common/xss'
|
||||
import { cleanRichTextWithMediaTag } from '@/common/xss'
|
||||
|
||||
export default {
|
||||
name: 'OptionConfig',
|
||||
@ -110,7 +111,7 @@ export default {
|
||||
return mapData
|
||||
},
|
||||
textOptions() {
|
||||
return this.curOptions.map((item) => item.text)
|
||||
return this.curOptions.map((item) => cleanRichTextWithMediaTag(item.text))
|
||||
}
|
||||
},
|
||||
components: {
|
||||
|
@ -23,18 +23,20 @@ export default {
|
||||
placement: 'top'
|
||||
},
|
||||
limit_breakAnswer: {
|
||||
key: 'baseConf.breakAnswer',
|
||||
key: 'breakAnswer',
|
||||
label: '允许断点续答',
|
||||
tip: '回填前一次作答中的内容(注:更换设备/浏览器/清除缓存/更改内容重新发布则此功能失效)',
|
||||
type: 'ELSwitch',
|
||||
value: false
|
||||
placement: 'top',
|
||||
type: 'CustomedSwitch',
|
||||
value: false,
|
||||
},
|
||||
limit_backAnswer: {
|
||||
key: 'baseConf.backAnswer',
|
||||
label: '自动填充上次填写内容',
|
||||
key: 'backAnswer',
|
||||
label: '自动填充上次提交内容',
|
||||
tip: '回填前一次提交的内容(注:更换设备/浏览器/清除缓存/更改内容重新发布则此功能失效)',
|
||||
type: 'ELSwitch',
|
||||
value: false
|
||||
placement: 'top',
|
||||
type: 'CustomedSwitch',
|
||||
value: false,
|
||||
},
|
||||
interview_pwd_switch: {
|
||||
key: 'passwordSwitch',
|
||||
|
@ -27,10 +27,19 @@
|
||||
<el-input type="password" v-model="formData.password" size="large"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="" v-if="passwordStrength">
|
||||
<span
|
||||
class="strength"
|
||||
v-for="item in 3"
|
||||
:key="item"
|
||||
:style="{ backgroundColor: strengthColor[item - 1][passwordStrength] }"
|
||||
></span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证码" prop="captcha">
|
||||
<div class="captcha-wrapper">
|
||||
<el-input style="width: 150px" v-model="formData.captcha" size="large"></el-input>
|
||||
<div class="captcha-img" @click="refreshCaptcha" v-html="captchaImgData"></div>
|
||||
<el-input style="width: 280px" v-model="formData.captcha" size="large"></el-input>
|
||||
<div class="captcha-img" click="refreshCaptcha" v-html="captchaImgData"></div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
@ -62,7 +71,9 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import 'element-plus/theme-chalk/src/message.scss'
|
||||
|
||||
import { login, register } from '@/management/api/auth'
|
||||
import { debounce as _debounce } from 'lodash-es'
|
||||
|
||||
import { getPasswordStrength, login, register } from '@/management/api/auth'
|
||||
import { refreshCaptcha as refreshCaptchaApi } from '@/management/api/captcha'
|
||||
import { CODE_MAP } from '@/management/api/base'
|
||||
import { useUserStore } from '@/management/stores/user'
|
||||
@ -89,6 +100,55 @@ const formData = reactive<FormData>({
|
||||
captchaId: ''
|
||||
})
|
||||
|
||||
// 每个滑块不同强度的颜色,索引0对应第一个滑块
|
||||
const strengthColor = reactive([
|
||||
{
|
||||
Strong: '#67C23A',
|
||||
Medium: '#ebb563',
|
||||
Weak: '#f78989'
|
||||
},
|
||||
{
|
||||
Strong: '#67C23A',
|
||||
Medium: '#ebb563',
|
||||
Weak: '#2a598a'
|
||||
},
|
||||
{
|
||||
Strong: '#67C23A',
|
||||
Medium: '#2a598a',
|
||||
Weak: '#2a598a'
|
||||
}
|
||||
])
|
||||
|
||||
// 密码内容校验
|
||||
const passwordValidator = (_: any, value: any, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入密码'))
|
||||
passwordStrength.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
if (value.length < 6 || value.length > 16) {
|
||||
callback(new Error('长度在 6 到 16 个字符'))
|
||||
passwordStrength.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]+$/.test(value)) {
|
||||
callback(new Error('只能输入数字、字母、特殊字符'))
|
||||
passwordStrength.value = undefined
|
||||
return
|
||||
}
|
||||
passwordStrengthHandle(value)
|
||||
callback()
|
||||
}
|
||||
|
||||
const passwordStrengthHandle = async (value: string) => {
|
||||
const res: any = await getPasswordStrength(value)
|
||||
if (res.code === CODE_MAP.SUCCESS) {
|
||||
passwordStrength.value = res.data
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' },
|
||||
@ -100,12 +160,9 @@ const rules = {
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{
|
||||
min: 8,
|
||||
max: 16,
|
||||
message: '长度在 8 到 16 个字符',
|
||||
trigger: 'blur'
|
||||
validator: _debounce(passwordValidator, 500),
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
captcha: [
|
||||
@ -128,6 +185,7 @@ const pending = reactive<Pending>({
|
||||
|
||||
const captchaImgData = ref<string>('')
|
||||
const formDataRef = ref<any>(null)
|
||||
const passwordStrength = ref<'Strong' | 'Medium' | 'Weak'>()
|
||||
|
||||
const submitForm = (type: 'login' | 'register') => {
|
||||
formDataRef.value.validate(async (valid: boolean) => {
|
||||
@ -220,13 +278,14 @@ const refreshCaptcha = async () => {
|
||||
background: #fff;
|
||||
box-shadow: 4px 0 20px 0 rgba(82, 82, 102, 0.15);
|
||||
margin-top: -150px;
|
||||
width: 580px;
|
||||
|
||||
.button-group {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 160px;
|
||||
width: 200px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
@ -255,8 +314,21 @@ const refreshCaptcha = async () => {
|
||||
cursor: pointer;
|
||||
:deep(> svg) {
|
||||
max-height: 40px;
|
||||
width: 120px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.strength {
|
||||
display: inline-block;
|
||||
width: 30%;
|
||||
height: 6px;
|
||||
border-radius: 8px;
|
||||
background: red;
|
||||
&:not(:first-child) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,8 +1,9 @@
|
||||
@font-face {
|
||||
font-family: 'iconfont'; /* Project id 4263849 */
|
||||
src: url('//at.alicdn.com/t/c/font_4263849_2re4gm4ryc3.woff2?t=1723600417360') format('woff2'),
|
||||
url('//at.alicdn.com/t/c/font_4263849_2re4gm4ryc3.woff?t=1723600417360') format('woff'),
|
||||
url('//at.alicdn.com/t/c/font_4263849_2re4gm4ryc3.ttf?t=1723600417360') format('truetype');
|
||||
font-family: 'iconfont'; /* Project id 4263849 */
|
||||
src:
|
||||
url('//at.alicdn.com/t/c/font_4263849_2re4gm4ryc3.woff2?t=1723600417360') format('woff2'),
|
||||
url('//at.alicdn.com/t/c/font_4263849_2re4gm4ryc3.woff?t=1723600417360') format('woff'),
|
||||
url('//at.alicdn.com/t/c/font_4263849_2re4gm4ryc3.ttf?t=1723600417360') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
|
@ -101,7 +101,7 @@ export default defineComponent({
|
||||
|
||||
return (
|
||||
<div class="choice-wrapper">
|
||||
<div class={[isMatrix ? 'nest-box' : '', 'choice-box']}>
|
||||
<div class={[isMatrix ? 'nest-box' : '', 'choice-box', this.layout || 'vertical']}>
|
||||
{getOptions.map((item, index) => {
|
||||
return (
|
||||
!item.hide && (
|
||||
@ -110,11 +110,10 @@ export default defineComponent({
|
||||
style={this.choiceStyle}
|
||||
class={['choice-outer']}
|
||||
>
|
||||
<div style="position: relative">
|
||||
<div style="position: relative" class="choice-content">
|
||||
{!/^\s*$/.test(item.text) && (
|
||||
<div
|
||||
class={[
|
||||
this.layout === 'vertical' ? 'vertical' : '',
|
||||
isChecked(item) ? 'is-checked' : '',
|
||||
index === getOptions.length - 1 ? 'lastchild' : '',
|
||||
index === getOptions.length - 2 ? 'last2child' : '',
|
||||
|
@ -25,11 +25,41 @@
|
||||
padding: 0 0.2rem !important;
|
||||
box-sizing: border-box;
|
||||
|
||||
|
||||
|
||||
// .choice-content, .choice-item {
|
||||
// width: 100%;
|
||||
// }
|
||||
|
||||
&.vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.choice-outer {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.choice-outer {
|
||||
width: 50%;
|
||||
|
||||
.question {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.choice-item {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
// align-items: center;
|
||||
width: 50%;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
vertical-align: top;
|
||||
// height: .88rem;
|
||||
@ -98,7 +128,6 @@
|
||||
.qicon.qicon-gouxuan {
|
||||
display: inline-block;
|
||||
font-size: 0.32rem;
|
||||
line-height: 0.32rem;
|
||||
border-color: $primary-color;
|
||||
background-color: $primary-color;
|
||||
color: #fff;
|
||||
|
@ -71,9 +71,37 @@ const meta = {
|
||||
hash: '115020'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'layout',
|
||||
propType: String,
|
||||
description: '排列方式',
|
||||
defaultValue: 'vertical'
|
||||
}
|
||||
],
|
||||
formConfig: [basicConfig],
|
||||
formConfig: [basicConfig, {
|
||||
name: 'optionConfig',
|
||||
title: '选项配置',
|
||||
type: 'Customed',
|
||||
content: [
|
||||
{
|
||||
label: '排列方式',
|
||||
type: 'RadioGroup',
|
||||
key: 'layout',
|
||||
value: 'vertical',
|
||||
options: [
|
||||
{
|
||||
label: '竖排',
|
||||
value: 'vertical'
|
||||
},
|
||||
{
|
||||
label: '横排',
|
||||
value: 'horizontal'
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
}],
|
||||
editConfigure: {
|
||||
optionEdit: {
|
||||
show: false
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { computed, defineComponent, shallowRef, defineAsyncComponent } from 'vue'
|
||||
import { computed, defineComponent, shallowRef, defineAsyncComponent, watch } from 'vue'
|
||||
import { includes } from 'lodash-es'
|
||||
|
||||
import BaseChoice from '../BaseChoice'
|
||||
@ -49,6 +49,7 @@ export default defineComponent({
|
||||
},
|
||||
emits: ['change'],
|
||||
setup(props, { emit }) {
|
||||
|
||||
const disableState = computed(() => {
|
||||
if (!props.maxNum) {
|
||||
return false
|
||||
@ -57,7 +58,7 @@ export default defineComponent({
|
||||
})
|
||||
const isDisabled = (item) => {
|
||||
const { value } = props
|
||||
return disableState.value && !includes(value, item.value)
|
||||
return disableState.value && !includes(value, item.hash)
|
||||
}
|
||||
const myOptions = computed(() => {
|
||||
const { options } = props
|
||||
@ -68,6 +69,20 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
})
|
||||
// 兼容断点续答情况下选项配额为0的情况
|
||||
watch(() => props.value, (value) => {
|
||||
const disabledHash = myOptions.value.filter(i => i.disabled).map(i => i.hash)
|
||||
if (value && disabledHash.length) {
|
||||
disabledHash.forEach(hash => {
|
||||
const index = value.indexOf(hash)
|
||||
if( index> -1) {
|
||||
const newValue = [...value]
|
||||
newValue.splice(index, 1)
|
||||
onChange(newValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const onChange = (value) => {
|
||||
const key = props.field
|
||||
emit('change', {
|
||||
@ -96,6 +111,7 @@ export default defineComponent({
|
||||
return {
|
||||
onChange,
|
||||
handleSelectMoreChange,
|
||||
disableState,
|
||||
myOptions,
|
||||
selectMoreView
|
||||
}
|
||||
@ -111,6 +127,7 @@ export default defineComponent({
|
||||
options={myOptions}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
layout={this.layout}
|
||||
quotaNoDisplay={quotaNoDisplay}
|
||||
>
|
||||
{{
|
||||
|
@ -1,5 +1,4 @@
|
||||
import basicConfig from '@materials/questions/common/config/basicConfig'
|
||||
|
||||
const meta = {
|
||||
title: '多选',
|
||||
type: 'checkbox',
|
||||
@ -83,6 +82,12 @@ const meta = {
|
||||
propType: Number,
|
||||
description: '最多选择数',
|
||||
defaultValue: 0
|
||||
},
|
||||
{
|
||||
name: 'layout',
|
||||
propType: String,
|
||||
description: '排列方式',
|
||||
defaultValue: 'vertical'
|
||||
}
|
||||
],
|
||||
formConfig: [
|
||||
@ -92,21 +97,38 @@ const meta = {
|
||||
title: '选项配置',
|
||||
type: 'Customed',
|
||||
content: [
|
||||
{
|
||||
label: '排列方式',
|
||||
type: 'RadioGroup',
|
||||
key: 'layout',
|
||||
value: 'vertical',
|
||||
options: [
|
||||
{
|
||||
label: '竖排',
|
||||
value: 'vertical'
|
||||
},
|
||||
{
|
||||
label: '横排',
|
||||
value: 'horizontal'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '至少选择数',
|
||||
type: 'InputNumber',
|
||||
key: 'minNum',
|
||||
value: '',
|
||||
value: 0,
|
||||
min: 0,
|
||||
max: 'maxNum',
|
||||
max: moduleConfig => { return moduleConfig?.maxNum || 0 },
|
||||
contentClass: 'input-number-config'
|
||||
},
|
||||
{
|
||||
label: '最多选择数',
|
||||
type: 'InputNumber',
|
||||
key: 'maxNum',
|
||||
value: '',
|
||||
min: 'minNum',
|
||||
value: 0,
|
||||
min: moduleConfig => { return moduleConfig?.minNum || 0 },
|
||||
max: moduleConfig => { return moduleConfig?.options?.length },
|
||||
contentClass: 'input-number-config'
|
||||
},
|
||||
]
|
||||
|
@ -15,7 +15,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
import ExtraIcon from '../ExtraIcon/index.vue'
|
||||
|
||||
defineProps({
|
||||
@ -34,14 +34,8 @@ defineProps({
|
||||
})
|
||||
const emit = defineEmits(['addOther', 'optionChange', 'change'])
|
||||
|
||||
const moduleConfig = inject('moduleConfig')
|
||||
const slots = inject('slots')
|
||||
|
||||
const optionConfigVisible = ref(false)
|
||||
const openOptionConfig = () => {
|
||||
optionConfigVisible.value = true
|
||||
}
|
||||
|
||||
const addOther = () => {
|
||||
emit('addOther')
|
||||
}
|
||||
|
@ -6,22 +6,13 @@ import GetHash from '@materials/questions/common/utils/getOptionHash'
|
||||
function useOptionBase(options) {
|
||||
const optionList = ref(options)
|
||||
const addOption = (text = '选项', others = false, index = -1, field) => {
|
||||
// const {} = payload
|
||||
let addOne
|
||||
if (optionList.value[0]) {
|
||||
addOne = cloneDeep(optionList.value[0])
|
||||
} else {
|
||||
addOne = {
|
||||
text: '',
|
||||
hash: '',
|
||||
imageUrl: '',
|
||||
others: false,
|
||||
mustOthers: false,
|
||||
othersKey: '',
|
||||
placeholderDesc: '',
|
||||
score: 0,
|
||||
limit: ''
|
||||
}
|
||||
let addOne = {
|
||||
text: '',
|
||||
hash: '',
|
||||
others: false,
|
||||
mustOthers: false,
|
||||
othersKey: '',
|
||||
placeholderDesc: '',
|
||||
}
|
||||
if (typeof text !== 'string') {
|
||||
text = '选项'
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { defineComponent, shallowRef, defineAsyncComponent } from 'vue'
|
||||
import { defineComponent, shallowRef, watch, defineAsyncComponent } from 'vue'
|
||||
import BaseChoice from '../BaseChoice'
|
||||
|
||||
/**
|
||||
@ -39,6 +39,20 @@ export default defineComponent({
|
||||
},
|
||||
emits: ['change'],
|
||||
setup(props, { emit }) {
|
||||
// 兼容断点续答情况下选项配额为0的情况
|
||||
watch(() => props.value, (value) => {
|
||||
const disabledHash = props.options.filter(i => i.disabled).map(i => i.hash)
|
||||
if (value && disabledHash.length) {
|
||||
disabledHash.forEach(hash => {
|
||||
const index = value.indexOf(hash)
|
||||
if( index> -1) {
|
||||
const newValue = [...value]
|
||||
newValue.splice(index, 1)
|
||||
onChange(newValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const onChange = (value) => {
|
||||
const key = props.field
|
||||
emit('change', {
|
||||
|
@ -72,6 +72,12 @@ const meta = {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'layout',
|
||||
propType: String,
|
||||
description: '排列方式',
|
||||
defaultValue: 'vertical'
|
||||
},
|
||||
{
|
||||
name: 'quotaNoDisplay',
|
||||
propType: Boolean,
|
||||
@ -79,39 +85,47 @@ const meta = {
|
||||
defaultValue: false
|
||||
}
|
||||
],
|
||||
formConfig: [
|
||||
basicConfig,
|
||||
{
|
||||
name: 'optionsExtra',
|
||||
label: '固定选项配置',
|
||||
labelStyle: {
|
||||
'font-weight': 'bold'
|
||||
formConfig: [basicConfig, {
|
||||
name: 'optionConfig',
|
||||
title: '选项配置',
|
||||
type: 'Customed',
|
||||
content: [
|
||||
{
|
||||
label: '排列方式',
|
||||
type: 'RadioGroup',
|
||||
key: 'layout',
|
||||
value: 'vertical',
|
||||
options: [
|
||||
{
|
||||
label: '竖排',
|
||||
value: 'vertical'
|
||||
},
|
||||
{
|
||||
label: '横排',
|
||||
value: 'horizontal'
|
||||
},
|
||||
]
|
||||
},
|
||||
type: 'Options',
|
||||
options: [],
|
||||
keys: 'extraOptions',
|
||||
hidden: true
|
||||
]
|
||||
},{
|
||||
name: 'optionQuota',
|
||||
label: '选项配额',
|
||||
labelStyle: {
|
||||
'font-weight': 'bold'
|
||||
},
|
||||
{
|
||||
name: 'optionQuota',
|
||||
label: '选项配额',
|
||||
labelStyle: {
|
||||
'font-weight': 'bold'
|
||||
type: 'QuotaConfig',
|
||||
// 输出转换
|
||||
valueSetter({ options, quotaNoDisplay}) {
|
||||
return [{
|
||||
key: 'options',
|
||||
value: options
|
||||
},
|
||||
type: 'QuotaConfig',
|
||||
// 输出转换
|
||||
valueSetter({ options, quotaNoDisplay}) {
|
||||
return [{
|
||||
key: 'options',
|
||||
value: options
|
||||
},
|
||||
{
|
||||
key: 'quotaNoDisplay',
|
||||
value: quotaNoDisplay
|
||||
}]
|
||||
}
|
||||
{
|
||||
key: 'quotaNoDisplay',
|
||||
value: quotaNoDisplay
|
||||
}]
|
||||
}
|
||||
],
|
||||
}],
|
||||
editConfigure: {
|
||||
optionEdit: {
|
||||
show: true
|
||||
|
@ -103,7 +103,6 @@ export default defineComponent({
|
||||
return (
|
||||
<BaseChoice
|
||||
uiTarget={innerType}
|
||||
layout={'vertical'}
|
||||
name={this.field}
|
||||
innerType={this.innerType}
|
||||
value={this.value}
|
||||
|
@ -120,7 +120,7 @@ const meta = {
|
||||
key: 'minNum',
|
||||
value: '',
|
||||
min: 0,
|
||||
max: 'maxNum',
|
||||
max: moduleConfig => { return moduleConfig?.maxNum || 0 },
|
||||
contentClass: 'input-number-config'
|
||||
},
|
||||
{
|
||||
@ -128,7 +128,8 @@ const meta = {
|
||||
type: 'InputNumber',
|
||||
key: 'maxNum',
|
||||
value: '',
|
||||
min: 'minNum',
|
||||
min: moduleConfig => { return moduleConfig?.minNum || 0 },
|
||||
max: moduleConfig => { return moduleConfig?.options?.length || 0 },
|
||||
contentClass: 'input-number-config'
|
||||
}
|
||||
]
|
||||
|
@ -2,7 +2,7 @@
|
||||
<el-switch v-model="newValue" @change="changeData" />
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
|
||||
|
||||
const props = defineProps({
|
||||
@ -23,4 +23,15 @@ const changeData = (value) => {
|
||||
value
|
||||
})
|
||||
}
|
||||
watch(
|
||||
() => props.formConfig.value,
|
||||
(newVal) => {
|
||||
if (newVal !== newValue.value) {
|
||||
newValue.value = newVal
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
@ -1,37 +0,0 @@
|
||||
<template>
|
||||
<el-switch
|
||||
v-model="modelValue"
|
||||
class="ml-2"
|
||||
active-text="是"
|
||||
inactive-text="否"
|
||||
@change="handleInputChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
|
||||
|
||||
interface Props {
|
||||
formConfig: any
|
||||
moduleConfig: any
|
||||
}
|
||||
|
||||
interface Emit {
|
||||
(ev: typeof FORM_CHANGE_EVENT_KEY, arg: { key: string; value: boolean }): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emit>()
|
||||
const props = defineProps<Props>()
|
||||
const modelValue = ref(props.formConfig.value)
|
||||
|
||||
const handleInputChange = (value: boolean) => {
|
||||
const key = props.formConfig.key
|
||||
|
||||
modelValue.value = value
|
||||
|
||||
emit(FORM_CHANGE_EVENT_KEY, { key, value })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -13,6 +13,7 @@ import { ElMessage } from 'element-plus'
|
||||
import 'element-plus/theme-chalk/src/message.scss'
|
||||
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
|
||||
|
||||
|
||||
interface Props {
|
||||
formConfig: any
|
||||
moduleConfig: any
|
||||
@ -24,12 +25,15 @@ interface Emit {
|
||||
|
||||
const emit = defineEmits<Emit>()
|
||||
const props = defineProps<Props>()
|
||||
const modelValue = ref(Number(props.formConfig.value) || 0)
|
||||
const modelValue = ref(Number(props.formConfig.value))
|
||||
|
||||
const myModuleConfig = ref(props.moduleConfig)
|
||||
|
||||
const minModelValue = computed(() => {
|
||||
const { min } = props.formConfig
|
||||
if (min) {
|
||||
if (min !== undefined) {
|
||||
if (typeof min === 'function') {
|
||||
return min(props.moduleConfig)
|
||||
return min(myModuleConfig.value)
|
||||
} else {
|
||||
return Number(min)
|
||||
}
|
||||
@ -38,16 +42,13 @@ const minModelValue = computed(() => {
|
||||
})
|
||||
|
||||
const maxModelValue = computed(() => {
|
||||
const { max, min } = props.formConfig
|
||||
|
||||
const { max } = props.formConfig
|
||||
if (max) {
|
||||
if (typeof max === 'function') {
|
||||
return max(props.moduleConfig)
|
||||
return max(myModuleConfig.value)
|
||||
} else {
|
||||
return Number(max)
|
||||
}
|
||||
} else if (min !== undefined && Array.isArray(props.moduleConfig?.options)) {
|
||||
return props.moduleConfig.options.length
|
||||
} else {
|
||||
return Infinity
|
||||
}
|
||||
@ -65,6 +66,9 @@ const handleInputChange = (value: number) => {
|
||||
|
||||
emit(FORM_CHANGE_EVENT_KEY, { key, value })
|
||||
}
|
||||
watch(() => props.moduleConfig, (newVal) => {
|
||||
myModuleConfig.value = newVal
|
||||
})
|
||||
watch(
|
||||
() => props.formConfig.value,
|
||||
(newVal) => {
|
||||
|
@ -12,8 +12,12 @@
|
||||
style="width: 100%"
|
||||
@cell-click="handleCellClick"
|
||||
>
|
||||
<el-table-column property="text" label="选项" style="width: 50%"></el-table-column>
|
||||
<el-table-column property="quota" style="width: 50%">
|
||||
<el-table-column property="text" label="选项" style="width: 50%">
|
||||
<template v-slot="scope">
|
||||
<div v-html="cleanRichTextWithMediaTag(scope.row.text)"></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column property="quota" style="width: 50%;">
|
||||
<template #header>
|
||||
<div style="display: flex; align-items: center">
|
||||
<span>配额设置</span>
|
||||
@ -30,6 +34,7 @@
|
||||
<template v-slot="scope">
|
||||
<el-input
|
||||
v-if="scope.row.isEditing"
|
||||
:id="`${scope.row.hash}editInput`"
|
||||
v-model="scope.row.tempQuota"
|
||||
type="number"
|
||||
@blur="handleInput(scope.row)"
|
||||
@ -43,8 +48,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div></div>
|
||||
<div>
|
||||
<div class="quota-no-display">
|
||||
<el-checkbox v-model="quotaNoDisplayValue" label="不展示配额剩余数量"> </el-checkbox>
|
||||
<el-tooltip
|
||||
class="tooltip"
|
||||
@ -56,7 +60,6 @@
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
<template #footer>
|
||||
<div class="diaglog-footer">
|
||||
<el-button @click="cancel">取消</el-button>
|
||||
@ -68,9 +71,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { FORM_CHANGE_EVENT_KEY } from '@/materials/setters/constant'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { cleanRichTextWithMediaTag } from '@/common/xss'
|
||||
|
||||
const props = defineProps(['formConfig', 'moduleConfig'])
|
||||
const emit = defineEmits(['form-change'])
|
||||
@ -105,6 +109,10 @@ const handleCellClick = (row, column) => {
|
||||
})
|
||||
row.tempQuota = row.tempQuota === '0' ? row.quota : row.tempQuota
|
||||
row.isEditing = true
|
||||
nextTick(() => {
|
||||
const input = document.getElementById(`${row.hash}editInput`)
|
||||
input.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
const handleInput = (row) => {
|
||||
@ -143,6 +151,12 @@ watch(
|
||||
width: 90%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
:deep(.cell){
|
||||
line-height: 35px;
|
||||
}
|
||||
.quota-no-display{
|
||||
padding-top: 8px
|
||||
}
|
||||
}
|
||||
.quota-title {
|
||||
font-size: 14px;
|
||||
@ -155,6 +169,7 @@ watch(
|
||||
color: #ffa600;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
|
||||
}
|
||||
.dialog {
|
||||
width: 41vw;
|
||||
|
@ -4,6 +4,7 @@
|
||||
:moduleConfig="questionConfig"
|
||||
:indexNumber="indexNumber"
|
||||
:showTitle="true"
|
||||
@input="handleInput"
|
||||
@change="handleChange"
|
||||
></QuestionRuleContainer>
|
||||
</template>
|
||||
@ -31,7 +32,7 @@ const props = defineProps({
|
||||
default: () => {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['change'])
|
||||
const questionStore = useQuestionStore()
|
||||
@ -41,11 +42,7 @@ const formValues = computed(() => {
|
||||
return surveyStore.formValues
|
||||
})
|
||||
const { showLogicEngine } = storeToRefs(surveyStore)
|
||||
const {
|
||||
changeField,
|
||||
changeIndex,
|
||||
needHideFields,
|
||||
} = storeToRefs(questionStore)
|
||||
const { changeField, changeIndex, needHideFields } = storeToRefs(questionStore)
|
||||
// 题型配置转换
|
||||
const questionConfig = computed(() => {
|
||||
let moduleConfig = props.moduleConfig
|
||||
@ -66,7 +63,6 @@ const questionConfig = computed(() => {
|
||||
let { options: optionWithQuota } = useOptionsQuota(field)
|
||||
|
||||
alloptions = alloptions.map((obj, index) => Object.assign(obj, optionWithQuota[index]))
|
||||
console.log({alloptions})
|
||||
}
|
||||
if (
|
||||
NORMAL_CHOICES.includes(type) &&
|
||||
@ -104,7 +100,6 @@ const logicshow = computed(() => {
|
||||
return result === undefined ? true : result
|
||||
})
|
||||
|
||||
|
||||
const logicskip = computed(() => {
|
||||
return needHideFields.value.includes(props.moduleConfig.field)
|
||||
})
|
||||
@ -112,7 +107,6 @@ const visibily = computed(() => {
|
||||
return logicshow.value && !logicskip.value
|
||||
})
|
||||
|
||||
|
||||
// 当题目被隐藏时,清空题目的选中项,实现a显示关联b,b显示关联c场景下,b隐藏不影响题目c的展示
|
||||
watch(
|
||||
() => visibily.value,
|
||||
@ -120,7 +114,7 @@ watch(
|
||||
const { field, type, innerType } = props.moduleConfig
|
||||
if (!newVal && oldVal) {
|
||||
// 如果被隐藏题目有选中值,则需要清空选中值
|
||||
if(formValues.value[field].toString()) {
|
||||
if (formValues.value[field].toString()) {
|
||||
let value = ''
|
||||
// 题型是多选,或者子题型是多选(innerType是用于投票)
|
||||
if (type === QUESTION_TYPE.CHECKBOX || innerType === QUESTION_TYPE.CHECKBOX) {
|
||||
@ -152,45 +146,52 @@ const handleChange = (data) => {
|
||||
processJumpSkip()
|
||||
}
|
||||
|
||||
const handleInput = () => {
|
||||
localStorageBack()
|
||||
}
|
||||
|
||||
const processJumpSkip = () => {
|
||||
const targetResult = surveyStore.jumpLogicEngine
|
||||
.getResultsByField(changeField.value, surveyStore.formValues)
|
||||
.map(item => {
|
||||
// 获取目标题的序号,处理跳转问卷末尾为最大题的序号
|
||||
const index = item.target === 'end' ? surveyStore.dataConf.dataList.length : questionStore.getQuestionIndexByField(item.target)
|
||||
return {
|
||||
index,
|
||||
...item
|
||||
}
|
||||
})
|
||||
const notMatchedFields = targetResult.filter(item => !item.result)
|
||||
const matchedFields = targetResult.filter(item => item.result)
|
||||
// 目标题均未匹配,需要展示出来条件题和目标题之间的题目
|
||||
if (notMatchedFields.length) {
|
||||
notMatchedFields.forEach(element => {
|
||||
const endIndex = element.index
|
||||
const fields = surveyStore.dataConf.dataList.slice(changeIndex.value + 1, endIndex).map(item => item.field)
|
||||
// hideMap中remove被跳过的题
|
||||
questionStore.removeNeedHideFields(fields)
|
||||
});
|
||||
}
|
||||
|
||||
if (!matchedFields.length) return
|
||||
// 匹配到多个目标题时,取最大序号的题目
|
||||
const maxIndexQuestion =
|
||||
matchedFields.filter(item => item.result).sort((a, b) => b.index - a.index)[0].index
|
||||
|
||||
// 条件题和目标题之间的题目隐藏
|
||||
const skipKey = surveyStore.dataConf.dataList
|
||||
.slice(changeIndex.value + 1, maxIndexQuestion).map(item => item.field)
|
||||
questionStore.addNeedHideFields(skipKey)
|
||||
const targetResult = surveyStore.jumpLogicEngine
|
||||
.getResultsByField(changeField.value, surveyStore.formValues)
|
||||
.map((item) => {
|
||||
// 获取目标题的序号,处理跳转问卷末尾为最大题的序号
|
||||
const index =
|
||||
item.target === 'end'
|
||||
? surveyStore.dataConf.dataList.length
|
||||
: questionStore.getQuestionIndexByField(item.target)
|
||||
return {
|
||||
index,
|
||||
...item
|
||||
}
|
||||
})
|
||||
const notMatchedFields = targetResult.filter((item) => !item.result)
|
||||
const matchedFields = targetResult.filter((item) => item.result)
|
||||
// 目标题均未匹配,需要展示出来条件题和目标题之间的题目
|
||||
if (notMatchedFields.length) {
|
||||
notMatchedFields.forEach((element) => {
|
||||
const endIndex = element.index
|
||||
const fields = surveyStore.dataConf.dataList
|
||||
.slice(changeIndex.value + 1, endIndex)
|
||||
.map((item) => item.field)
|
||||
// hideMap中remove被跳过的题
|
||||
questionStore.removeNeedHideFields(fields)
|
||||
})
|
||||
}
|
||||
|
||||
if (!matchedFields.length) return
|
||||
// 匹配到多个目标题时,取最大序号的题目
|
||||
const maxIndexQuestion = matchedFields
|
||||
.filter((item) => item.result)
|
||||
.sort((a, b) => b.index - a.index)[0].index
|
||||
|
||||
// 条件题和目标题之间的题目隐藏
|
||||
const skipKey = surveyStore.dataConf.dataList
|
||||
.slice(changeIndex.value + 1, maxIndexQuestion)
|
||||
.map((item) => item.field)
|
||||
questionStore.addNeedHideFields(skipKey)
|
||||
}
|
||||
const localStorageBack = () => {
|
||||
var formData = Object.assign({}, surveyStore.formValues);
|
||||
for(const key in formData){
|
||||
formData[key] = encodeURIComponent(formData[key])
|
||||
}
|
||||
|
||||
//浏览器存储
|
||||
localStorage.removeItem(surveyStore.surveyPath + "_questionData")
|
||||
|
18
web/src/render/hooks/useQuestionInfo.ts
Normal file
18
web/src/render/hooks/useQuestionInfo.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useQuestionStore } from '@/render/stores/question'
|
||||
import { cleanRichText } from '@/common/xss'
|
||||
export const useQuestionInfo = (field: string) => {
|
||||
const questionstore = useQuestionStore()
|
||||
|
||||
const questionTitle = cleanRichText(questionstore.questionData[field]?.title)
|
||||
const getOptionTitle = (value:any) => {
|
||||
const options = questionstore.questionData[field]?.options || []
|
||||
if (value instanceof Array) {
|
||||
return options
|
||||
.filter((item:any) => value.includes(item.hash))
|
||||
.map((item:any) => cleanRichText(item.text))
|
||||
} else {
|
||||
return options.filter((item:any) => item.hash === value).map((item:any) => cleanRichText(item.text))
|
||||
}
|
||||
}
|
||||
return { questionTitle, getOptionTitle }
|
||||
}
|
@ -2,60 +2,10 @@
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { getPublishedSurveyInfo, getPreviewSchema } from '../api/survey'
|
||||
import useCommandComponent from '../hooks/useCommandComponent'
|
||||
import { useSurveyStore } from '../stores/survey'
|
||||
|
||||
import AlertDialog from '../components/AlertDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const surveyStore = useSurveyStore()
|
||||
const loadData = (res: any, surveyPath: string) => {
|
||||
if (res.code === 200) {
|
||||
const data = res.data
|
||||
const {
|
||||
bannerConf,
|
||||
baseConf,
|
||||
bottomConf,
|
||||
dataConf,
|
||||
skinConf,
|
||||
submitConf,
|
||||
logicConf,
|
||||
pageConf
|
||||
} = data.code
|
||||
const questionData = {
|
||||
bannerConf,
|
||||
baseConf,
|
||||
bottomConf,
|
||||
dataConf,
|
||||
skinConf,
|
||||
submitConf,
|
||||
pageConf
|
||||
}
|
||||
|
||||
if (!pageConf || pageConf?.length == 0) {
|
||||
questionData.pageConf = [dataConf.dataList.length]
|
||||
}
|
||||
|
||||
document.title = data.title
|
||||
|
||||
surveyStore.setSurveyPath(surveyPath)
|
||||
surveyStore.initSurvey(questionData)
|
||||
surveyStore.initShowLogicEngine(logicConf?.showLogicConf)
|
||||
surveyStore.initJumpLogicEngine(logicConf.jumpLogicConf)
|
||||
} else {
|
||||
throw new Error(res.errmsg)
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
const surveyId = route.params.surveyId
|
||||
console.log({ surveyId })
|
||||
surveyStore.setSurveyPath(surveyId)
|
||||
getDetail(surveyId as string)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.t,
|
||||
@ -63,22 +13,4 @@ watch(
|
||||
location.reload()
|
||||
}
|
||||
)
|
||||
|
||||
const getDetail = async (surveyPath: string) => {
|
||||
const alert = useCommandComponent(AlertDialog)
|
||||
|
||||
try {
|
||||
if (surveyPath.length > 8) {
|
||||
const res: any = await getPreviewSchema({ surveyPath })
|
||||
loadData(res, surveyPath)
|
||||
} else {
|
||||
const res: any = await getPublishedSurveyInfo({ surveyPath })
|
||||
loadData(res, surveyPath)
|
||||
surveyStore.getEncryptInfo()
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
alert({ title: error.message || '获取问卷失败' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -21,9 +21,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
// @ts-ignore
|
||||
import communalLoader from '@materials/communals/communalLoader.js'
|
||||
import MainRenderer from '../components/MainRenderer.vue'
|
||||
@ -38,6 +38,8 @@ import { submitForm } from '../api/survey'
|
||||
import encrypt from '../utils/encrypt'
|
||||
|
||||
import useCommandComponent from '../hooks/useCommandComponent'
|
||||
import { getPublishedSurveyInfo, getPreviewSchema } from '../api/survey'
|
||||
import { useQuestionInfo } from '../hooks/useQuestionInfo'
|
||||
|
||||
interface Props {
|
||||
questionInfo?: any
|
||||
@ -70,6 +72,68 @@ const pageIndex = computed(() => questionStore.pageIndex)
|
||||
const { bannerConf, submitConf, bottomConf: logoConf, whiteData } = storeToRefs(surveyStore)
|
||||
const surveyPath = computed(() => surveyStore.surveyPath || '')
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
onMounted(() => {
|
||||
const surveyId = route.params.surveyId
|
||||
console.log({ surveyId })
|
||||
surveyStore.setSurveyPath(surveyId)
|
||||
getDetail(surveyId as string)
|
||||
})
|
||||
const loadData = (res: any, surveyPath: string) => {
|
||||
if (res.code === 200) {
|
||||
const data = res.data
|
||||
const {
|
||||
bannerConf,
|
||||
baseConf,
|
||||
bottomConf,
|
||||
dataConf,
|
||||
skinConf,
|
||||
submitConf,
|
||||
logicConf,
|
||||
pageConf
|
||||
} = data.code
|
||||
const questionData = {
|
||||
bannerConf,
|
||||
baseConf,
|
||||
bottomConf,
|
||||
dataConf,
|
||||
skinConf,
|
||||
submitConf,
|
||||
pageConf
|
||||
}
|
||||
|
||||
if (!pageConf || pageConf?.length == 0) {
|
||||
questionData.pageConf = [dataConf.dataList.length]
|
||||
}
|
||||
|
||||
document.title = data.title
|
||||
|
||||
surveyStore.setSurveyPath(surveyPath)
|
||||
surveyStore.initSurvey(questionData)
|
||||
surveyStore.initShowLogicEngine(logicConf?.showLogicConf)
|
||||
surveyStore.initJumpLogicEngine(logicConf?.jumpLogicConf)
|
||||
} else {
|
||||
throw new Error(res.errmsg)
|
||||
}
|
||||
}
|
||||
const getDetail = async (surveyPath: string) => {
|
||||
const alert = useCommandComponent(AlertDialog)
|
||||
|
||||
try {
|
||||
if (surveyPath.length > 8) {
|
||||
const res: any = await getPreviewSchema({ surveyPath })
|
||||
loadData(res, surveyPath)
|
||||
} else {
|
||||
const res: any = await getPublishedSurveyInfo({ surveyPath })
|
||||
loadData(res, surveyPath)
|
||||
surveyStore.getEncryptInfo()
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
alert({ title: error.message || '获取问卷失败' })
|
||||
}
|
||||
}
|
||||
const validate = (cbk: (v: boolean) => void) => {
|
||||
const index = 0
|
||||
mainRef.value.$refs.formGroup[index].validate(cbk)
|
||||
@ -93,9 +157,7 @@ const normalizationRequestBody = () => {
|
||||
localStorage.removeItem("isSubmit")
|
||||
//数据加密
|
||||
var formData : Record<string, any> = Object.assign({}, surveyStore.formValues)
|
||||
for(const key in formData){
|
||||
formData[key] = encodeURIComponent(formData[key])
|
||||
}
|
||||
|
||||
localStorage.setItem(surveyPath.value + "_questionData", JSON.stringify(formData))
|
||||
localStorage.setItem('isSubmit', JSON.stringify(true))
|
||||
|
||||
@ -125,13 +187,19 @@ const submitSurver = async () => {
|
||||
const res: any = await submitForm(params)
|
||||
if (res.code === 200) {
|
||||
router.replace({ name: 'successPage' })
|
||||
} else if(res.code === 9003) {
|
||||
// 更新填写的过程中配额减少情况
|
||||
questionStore.initQuotaMap()
|
||||
const titile = useQuestionInfo(res.data.field).questionTitle
|
||||
const optionText = useQuestionInfo(res.data.field).getOptionTitle(res.data.optionHash)
|
||||
const message = `【${titile}】的【${optionText}】配额已满,请重新选择`
|
||||
alert({
|
||||
title: message
|
||||
})
|
||||
} else {
|
||||
alert({
|
||||
title: res.errmsg || '提交失败'
|
||||
})
|
||||
if (res.code === 9003) {
|
||||
questionStore.initQuotaMap()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
|
@ -286,14 +286,14 @@ export const useQuestionStore = defineStore('question', () => {
|
||||
return questionData.value[field].index
|
||||
}
|
||||
const addNeedHideFields = (fields) => {
|
||||
fields.forEach(field => {
|
||||
if(!needHideFields.value.includes(field)) {
|
||||
fields.forEach((field) => {
|
||||
if (!needHideFields.value.includes(field)) {
|
||||
needHideFields.value.push(field)
|
||||
}
|
||||
})
|
||||
}
|
||||
const removeNeedHideFields = (fields) => {
|
||||
needHideFields.value = needHideFields.value.filter(field => !fields.includes(field))
|
||||
needHideFields.value = needHideFields.value.filter((field) => !fields.includes(field))
|
||||
}
|
||||
return {
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { defineStore } from 'pinia'
|
||||
import { pick } from 'lodash-es'
|
||||
import { cloneDeep, pick } from 'lodash-es'
|
||||
|
||||
import { isMobile as isInMobile } from '@/render/utils/index'
|
||||
import { getEncryptInfo as getEncryptInfoApi } from '@/render/api/survey'
|
||||
@ -47,7 +47,6 @@ export const useSurveyStore = defineStore('survey', () => {
|
||||
const formValues = ref({})
|
||||
const whiteData = ref({})
|
||||
const pageConf = ref([])
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const questionStore = useQuestionStore()
|
||||
@ -160,97 +159,54 @@ export const useSurveyStore = defineStore('survey', () => {
|
||||
questionStore.initQuotaMap()
|
||||
|
||||
}
|
||||
|
||||
// 加载上次填写过的数据到问卷页
|
||||
function loadFormData(params, formData) {
|
||||
// 根据初始的schema生成questionData, questionSeq, rules, formValues, 这四个字段
|
||||
const { questionData, questionSeq, rules, formValues } = adapter.generateData({
|
||||
bannerConf: params.bannerConf,
|
||||
baseConf: params.baseConf,
|
||||
bottomConf: params.bottomConf,
|
||||
dataConf: params.dataConf,
|
||||
skinConf: params.skinConf,
|
||||
submitConf: params.submitConf,
|
||||
})
|
||||
|
||||
function fillFormData(formData) {
|
||||
const _formValues = cloneDeep(formValues.value)
|
||||
for(const key in formData){
|
||||
formValues[key] = formData[key]
|
||||
_formValues[key] = formData[key]
|
||||
}
|
||||
|
||||
// todo: 建议通过questionStore提供setqueationdata方法修改属性,否则不好跟踪变化
|
||||
questionStore.questionData = questionData
|
||||
questionStore.questionSeq = questionSeq
|
||||
|
||||
// 将数据设置到state上
|
||||
rules.value = rules
|
||||
bannerConf.value = params.bannerConf
|
||||
baseConf.value = params.baseConf
|
||||
bottomConf.value = params.bottomConf
|
||||
dataConf.value = params.dataConf
|
||||
skinConf.value = params.skinConf
|
||||
submitConf.value = params.submitConf
|
||||
formValues.value = formValues
|
||||
|
||||
whiteData.value = params.whiteData
|
||||
pageConf.value = params.pageConf
|
||||
|
||||
// 获取已投票数据
|
||||
questionStore.initVoteData()
|
||||
questionStore.initQuotaMap()
|
||||
|
||||
formValues.value = _formValues
|
||||
}
|
||||
const initSurvey = (option) => {
|
||||
|
||||
setEnterTime()
|
||||
|
||||
if (!canFillQuestionnaire(option.baseConf, option.submitConf)) {
|
||||
return
|
||||
}
|
||||
// 加载空白问卷
|
||||
clearFormData(option)
|
||||
|
||||
const { breakAnswer } = option.baseConf
|
||||
|
||||
const { breakAnswer, backAnswer } = option.baseConf
|
||||
const localData = JSON.parse(localStorage.getItem(surveyPath.value + "_questionData"))
|
||||
for(const key in localData){
|
||||
localData[key] = decodeURIComponent(localData[key])
|
||||
}
|
||||
|
||||
const isSubmit = JSON.parse(localStorage.getItem('isSubmit'))
|
||||
|
||||
if(localData) {
|
||||
if(isSubmit){
|
||||
if(!option.baseConf.backAnswer) {
|
||||
clearFormData(option)
|
||||
} else {
|
||||
confirm({
|
||||
title: "您之前已提交过问卷,是否要回填?",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
loadFormData(option, localData)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
} finally {
|
||||
confirm.close()
|
||||
}
|
||||
},
|
||||
onCancel: async() => {
|
||||
try {
|
||||
clearFormData({ bannerConf, baseConf, bottomConf, dataConf, skinConf, submitConf })
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
} finally {
|
||||
confirm.close()
|
||||
}
|
||||
// 断点续答
|
||||
if(breakAnswer) {
|
||||
confirm({
|
||||
title: "是否继续上次填写的内容?",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
// 回填答题内容
|
||||
fillFormData(localData)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
} finally {
|
||||
confirm.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if(!breakAnswer) {
|
||||
clearFormData(option)
|
||||
} else {
|
||||
},
|
||||
onCancel: async() => {
|
||||
confirm.close()
|
||||
}
|
||||
})
|
||||
} else if (backAnswer) {
|
||||
if(isSubmit){
|
||||
confirm({
|
||||
title: "您之前已填写部分内容, 是否要继续填写?",
|
||||
title: "是否继续上次提交的内容?",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
loadFormData(option, localData)
|
||||
// 回填答题内容
|
||||
fillFormData(localData)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
} finally {
|
||||
@ -258,13 +214,7 @@ export const useSurveyStore = defineStore('survey', () => {
|
||||
}
|
||||
},
|
||||
onCancel: async() => {
|
||||
try {
|
||||
clearFormData(option)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
} finally {
|
||||
confirm.close()
|
||||
}
|
||||
confirm.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -285,11 +235,11 @@ export const useSurveyStore = defineStore('survey', () => {
|
||||
|
||||
const showLogicEngine = ref()
|
||||
const initShowLogicEngine = (showLogicConf) => {
|
||||
showLogicEngine.value = new RuleMatch().fromJson(showLogicConf)
|
||||
showLogicEngine.value = new RuleMatch().fromJson(showLogicConf || [])
|
||||
}
|
||||
const jumpLogicEngine = ref()
|
||||
const initJumpLogicEngine = (jumpLogicConf) => {
|
||||
jumpLogicEngine.value = new RuleMatch().fromJson(jumpLogicConf)
|
||||
jumpLogicEngine.value = new RuleMatch().fromJson(jumpLogicConf || [])
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -29,4 +29,4 @@ export const formatLink = (url) => {
|
||||
return url
|
||||
}
|
||||
return `http://${url}`
|
||||
}
|
||||
}
|
||||
|
@ -67,7 +67,9 @@ export default defineConfig({
|
||||
'yup',
|
||||
'crypto-js/sha256',
|
||||
'element-plus/es/locale/lang/zh-cn',
|
||||
'node-forge'
|
||||
'node-forge',
|
||||
'@logicflow/core',
|
||||
'@logicflow/extension'
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
|
Loading…
Reference in New Issue
Block a user