diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 00000000..5ea69f33 --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,39 @@ +# Unit Test Coverage Report +name: Test Coverage + +on: + pull_request: + branches: + - feature/workflow + - develop + - main + - releases/** + - feature/** + paths: + - server/** + workflow_dispatch: + +jobs: + build: + name: Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: cd server && npm install + + - name: Run tests and collect coverage + run: cd server && npm run test:cov + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/server-lint.yml b/.github/workflows/server-lint.yml new file mode 100644 index 00000000..dec0c544 --- /dev/null +++ b/.github/workflows/server-lint.yml @@ -0,0 +1,34 @@ +# Lint +name: Server Lint + +on: + pull_request: + branches: + - feature/workflow + - develop + - main + - releases/** + - feature/** + paths: + - server/** + workflow_dispatch: + +jobs: + build: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: cd server && npm install + + - name: Lint + run: cd server && npm run lint diff --git a/.github/workflows/web-lint.yml b/.github/workflows/web-lint.yml new file mode 100644 index 00000000..f0963789 --- /dev/null +++ b/.github/workflows/web-lint.yml @@ -0,0 +1,34 @@ +# Lint +name: Web Lint + +on: + pull_request: + branches: + - feature/workflow + - develop + - main + - releases/** + - feature/** + paths: + - web/** + workflow_dispatch: + +jobs: + build: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: cd web && npm install + + - name: Lint + run: cd web && npm run lint diff --git a/web/package.json b/web/package.json index 063fb000..1b879af2 100644 --- a/web/package.json +++ b/web/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "serve": "vite", - "dev": "vite", + "serve": "npm run dev", + "dev": "vite --open", "build": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", "build-only": "vite build", @@ -45,6 +45,7 @@ "@vue/tsconfig": "^0.5.1", "eslint": "^8.49.0", "eslint-plugin-vue": "^9.17.0", + "husky": "^9.0.11", "npm-run-all2": "^6.1.1", "prettier": "^3.0.3", "sass": "^1.72.0", @@ -56,8 +57,19 @@ "vite-plugin-virtual-mpa": "^1.11.0", "vue-tsc": "^1.8.27" }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "src/**/*.{.vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts}": [ + "prettier --write", + "eslint --fix" + ] + }, "engines": { - "node": ">=14.21.0", - "npm": ">=6.14.17" + "node": ">=18.0.0", + "npm": ">=8.6.0" } } diff --git a/web/src/management/config/dnd.ts b/web/src/management/config/dnd.ts new file mode 100644 index 00000000..11039a41 --- /dev/null +++ b/web/src/management/config/dnd.ts @@ -0,0 +1 @@ +export const DND_GROUP = 'question' \ No newline at end of file diff --git a/web/src/management/pages/edit/components/MaterialGroup.vue b/web/src/management/pages/edit/components/MaterialGroup.vue index e581ca5c..c3edfd9e 100644 --- a/web/src/management/pages/edit/components/MaterialGroup.vue +++ b/web/src/management/pages/edit/components/MaterialGroup.vue @@ -1,8 +1,9 @@ <template> <draggable - :list="renderData" + v-model="renderData" handle=".question-wrapper.isSelected" filter=".question-wrapper.isSelected .question.isSelected" + :group="DND_GROUP" :onEnd="checkEnd" :move="checkMove" itemKey="field" @@ -34,10 +35,12 @@ <script> import { computed, defineComponent, ref, getCurrentInstance } from 'vue' +import { useStore } from 'vuex' 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({ components: { @@ -58,8 +61,15 @@ export default defineComponent({ } }, setup(props, { emit }) { - const renderData = computed(() => { - return filterQuestionPreviewData(props.questionDataList) + const store = useStore() + + const renderData = computed({ + get () { + return filterQuestionPreviewData(props.questionDataList) + }, + set (questionDataList) { + store.commit('edit/setQuestionDataList', questionDataList) + } }) const handleSelect = (index) => { emit('select', index) @@ -89,6 +99,7 @@ export default defineComponent({ } return { + DND_GROUP, renderData, handleSelect, handleChangeSeq, diff --git a/web/src/management/pages/edit/modules/questionModule/components/TypeList.vue b/web/src/management/pages/edit/modules/questionModule/components/TypeList.vue index 652feb7f..e74100dd 100644 --- a/web/src/management/pages/edit/modules/questionModule/components/TypeList.vue +++ b/web/src/management/pages/edit/modules/questionModule/components/TypeList.vue @@ -6,58 +6,91 @@ :name="index" :key="index" > - <div class="questiontype-list"> - <el-popover - v-for="(item, index) in item.questionList" - :key="item.type" - placement="right" - trigger="hover" - :popper-class="'qtype-popper-' + (index % 3)" - :popper-style="{ width: '369px' }" + <draggable + class="questiontype-list" + :list="item.questionList" + :group="{ name: DND_GROUP, pull: 'clone', put: false }" + :clone="getNewQuestion" + item-key="path" > - <img :src="item.snapshot" width="345px" /> - <template #reference> - <div :key="item.type" class="qtopic-item" @click="onQuestionType({ type: item.type })"> - <i class="iconfont" :class="['icon-' + item.icon]"></i> - <p class="text">{{ item.title }}</p> - </div> + <template #item="{ element }"> + <div + :key="element.type" + 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> + <p class="text">{{ element.title }}</p> + </div> </template> - </el-popover> - </div> + + </draggable> </el-collapse-item> + <Teleport to="body"> + <div class="preview-popover" v-show="isShowPreviewImage" :style="{ top: previewTop + 'px'}"> + <img :src="previewImg" class="preview-image"/> + <span class="preview-arrow"></span> + </div> + </Teleport> </el-collapse> </template> <script setup> import questionLoader from '@/materials/questions/questionLoader' +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 { useStore } from 'vuex' -import { get as _get } from 'lodash-es' +import { get as _get, isNumber as _isNumber } from 'lodash-es' import { computed, ref } from 'vue' -const activeNames = ref([0, 1]) - const store = useStore() + +const activeNames = ref([0, 1]) +const previewImg = ref('') +const isShowPreviewImage = ref(false) +const previewTop = ref(0) const questionDataList = computed(() => _get(store, 'state.edit.schema.questionDataList')) +const newQuestionIndex = computed(() => { + const currentEditOne = _get(store, 'state.edit.currentEditOne') + const index = _isNumber(currentEditOne) ? currentEditOne + 1 : questionDataList.value.length + return index +}) questionLoader.init({ typeList: questionTypeList.map((item) => item.type) }) -const onQuestionType = ({ type }) => { +const getNewQuestion = ({ type }) => { const fields = questionDataList.value.map((item) => item.field) - const currentEditOne = _get(store, 'state.edit.currentEditOne') - const index = - typeof currentEditOne === 'number' ? currentEditOne + 1 : questionDataList.value.length const newQuestion = getQuestionByType(type, fields) - newQuestion.title = newQuestion.title = `标题${index + 1}` + newQuestion.title = newQuestion.title = `标题${newQuestionIndex.value + 1}` if (type === 'vote') { newQuestion.innerType = 'radio' } - store.dispatch('edit/addQuestion', { question: newQuestion, index }) - store.commit('edit/setCurrentEditOne', index) + return newQuestion +} + +const onQuestionType = ({ type }) => { + const newQuestion = getNewQuestion({ type }) + store.dispatch('edit/addQuestion', { question: newQuestion, index: newQuestionIndex.value }) + store.commit('edit/setCurrentEditOne', newQuestionIndex.value) +} + +const showPreview = ({ snapshot }, id) => { + previewImg.value = snapshot + + const dragEl = document.getElementById(id) + const { top, height } = dragEl.getBoundingClientRect() + previewTop.value = top + height / 2 + + isShowPreviewImage.value = true } </script> @@ -103,9 +136,10 @@ const onQuestionType = ({ type }) => { background-color: $primary-color-light; border: 1px solid $primary-color; } - + .text { font-size: 12px; + user-select: none; } } @@ -128,4 +162,64 @@ const onQuestionType = ({ type }) => { .qtype-popper-2 { transform: translateX(30px); } +// 设置拖拽到编辑区的样式 +.box .qtopic-item { + height: 2px; + width: 100%; + background-color: var(--primary-color); + * { + display: none; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.preview-popover { + position: fixed; + left: 390px; + z-index: 9; + width: 371px; + padding: 12px; + background: white; + border: 1px solid var(--el-border-color-light); + box-shadow: var(--el-box-shadow-light); + transform: translateY(-50%); + animation: fadeIn 100ms linear forwards; + + .preview-image { + width: 100%; + object-fit: contain; + } + + .preview-arrow { + position: absolute; + top: 50%; + left: -6px; + height: 10px; + width: 10px; + transform: translateX(-50%); + background: var(--el-border-color-light); + z-index: -1; + transform: rotate(-45deg); + + &::before { + position: absolute; + content: ""; + height: 10px; + width: 10px; + border: 1px solid var(--el-border-color-light); + background: #ffffff; + border-bottom-color: transparent; + border-right-color: transparent; + } + + } +} </style> diff --git a/web/src/management/store/edit/mutations.js b/web/src/management/store/edit/mutations.js index fab89077..412b87a9 100644 --- a/web/src/management/store/edit/mutations.js +++ b/web/src/management/store/edit/mutations.js @@ -61,5 +61,8 @@ export default { Object.keys(presets).forEach((key) => { _set(state.schema, key, presets[key]) }) + }, + setQuestionDataList(state, data) { + state.schema.questionDataList = data } } diff --git a/web/src/render/App.vue b/web/src/render/App.vue index 6fba4f81..185c00c8 100644 --- a/web/src/render/App.vue +++ b/web/src/render/App.vue @@ -1,11 +1,21 @@ <template> <div id="app"> - <Component v-if="$store.state.router" :is="$store.state.router"></Component> - <LogoIcon v-if="!['successPage', 'indexPage'].includes($store.state.router)" /> + <Component + v-if="store.state.router" + :is=" + components[ + upperFirst(store.state.router) as 'IndexPage' | 'EmptyPage' | 'ErrorPage' | 'SuccessPage' + ] + " + > + </Component> + <LogoIcon v-if="!['successPage', 'indexPage'].includes(store.state.router)" /> </div> </template> +<script setup lang="ts"> +import { computed, watch, onMounted } from 'vue' +import { useStore } from 'vuex' -<script> import { getPublishedSurveyInfo } from './api/survey' import useCommandComponent from './hooks/useCommandComponent' @@ -16,89 +26,82 @@ import SuccessPage from './pages/SuccessPage.vue' import AlertDialog from './components/AlertDialog.vue' import LogoIcon from './components/LogoIcon.vue' -import { get as _get } from 'lodash-es' -import { initRuleEngine } from '@/render/hooks/useRuleEngine.js' - -export default { - name: 'App', - components: { - EmptyPage, - IndexPage, - ErrorPage, - SuccessPage, - LogoIcon - }, - data() { - return {} - }, - computed: { - skinConf() { - return _get(this.$store, 'state.skinConf', {}) - } - }, - watch: { - skinConf(value) { - this.setSkin(value) - } - }, - async created() { - this.init() - this.alert = useCommandComponent(AlertDialog) - }, - beforeCreate() {}, - methods: { - async init() { - const surveyPath = location.pathname.split('/').pop() - if (!surveyPath) { - this.$store.commit('setRouter', 'EmptyPage') - } else { - try { - const res = await getPublishedSurveyInfo({ surveyPath }) - if (res.code === 200) { - const data = res.data - const { bannerConf, baseConf, bottomConf, dataConf, skinConf, submitConf, logicConf } = data.code - document.title = data.title - const questionData = { - bannerConf, - baseConf, - bottomConf, - dataConf, - skinConf, - submitConf - } - this.setSkin(skinConf) - this.$store.commit('setSurveyPath', surveyPath) - this.$store.dispatch('init', questionData) - initRuleEngine(logicConf?.showLogicConf) - this.$store.dispatch('getEncryptInfo') - } else { - throw new Error(res.errmsg) - } - } catch (error) { - console.log(error) - this.alert({ - title: error.message || '获取问卷失败' - }) - } - } - }, - setSkin(skinConf) { - const { themeConf, backgroundConf, contentConf } = skinConf - const root = document.documentElement - if (themeConf?.color) { - root.style.setProperty('--primary-color', themeConf?.color) // 设置主题颜色 - } - if (backgroundConf?.color) { - root.style.setProperty('--primary-background-color', backgroundConf?.color) // 设置背景颜色 - } - if (contentConf?.opacity.toString()) { - root.style.setProperty('--opacity', contentConf?.opacity / 100) // 设置全局透明度 +import { get as _get, upperFirst } from 'lodash-es' + +const store = useStore() +const skinConf = computed(() => _get(store, 'state.skinConf', {})) +const components = { + EmptyPage, + IndexPage, + ErrorPage, + SuccessPage +} + +const updateSkinConfig = (value: any) => { + const root = document.documentElement + const { themeConf, backgroundConf, contentConf } = value + + if (themeConf?.color) { + // 设置主题颜色 + root.style.setProperty('--primary-color', themeConf?.color) + } + + if (backgroundConf?.color) { + // 设置背景颜色 + root.style.setProperty('--primary-background-color', backgroundConf?.color) + } + + if (contentConf?.opacity.toString()) { + // 设置全局透明度 + root.style.setProperty('--opacity', `${parseInt(contentConf.opacity) / 100}`) + } +} + +watch(skinConf, (value) => { + updateSkinConfig(value) +}) + +onMounted(async () => { + const surveyPath = location.pathname.split('/').pop() + + if (!surveyPath) { + store.commit('setRouter', 'EmptyPage') + return + } + + const alert = useCommandComponent(AlertDialog) + + try { + const res: any = await getPublishedSurveyInfo({ surveyPath }) + + if (res.code === 200) { + const data = res.data + const { bannerConf, baseConf, bottomConf, dataConf, skinConf, submitConf } = data.code + const questionData = { + bannerConf, + baseConf, + bottomConf, + dataConf, + skinConf, + submitConf } + + document.title = data.title + + updateSkinConfig(skinConf) + + store.commit('setSurveyPath', surveyPath) + store.dispatch('init', questionData) + store.dispatch('getEncryptInfo') + } else { + throw new Error(res.errmsg) } + } catch (error: any) { + console.log(error) + alert({ title: error.message || '获取问卷失败' }) } -} +}) </script> - <style lang="scss"> @import url('./styles/icon.scss'); @import url('../materials/questions/common/css/icon.scss'); diff --git a/web/src/render/main.js b/web/src/render/main.js index cd5b7278..ecc3e906 100644 --- a/web/src/render/main.js +++ b/web/src/render/main.js @@ -3,7 +3,6 @@ import App from './App.vue' import EventBus from './utils/eventbus' import store from './store' -import './styles/reset.scss' const app = createApp(App) diff --git a/web/src/render/pages/ErrorPage.vue b/web/src/render/pages/ErrorPage.vue index c7fb81f5..30c5a30d 100755 --- a/web/src/render/pages/ErrorPage.vue +++ b/web/src/render/pages/ErrorPage.vue @@ -8,29 +8,23 @@ </div> </div> </template> -<script> -export default { - name: 'errorPage', - data() { - return { - imageMap: { - overTime: '/imgs/icons/overtime.webp' - } - } - }, - computed: { - errorImageUrl() { - return this.imageMap[this.errorType] || this.imageMap.default - }, - errorType() { - return this.$store.state?.errorInfo?.errorType - }, - errorMsg() { - return this.$store.state?.errorInfo?.errorMsg - } - }, - mounted() {} -} +<script setup lang="ts"> +import { computed } from 'vue' +import { useStore } from 'vuex' + +const store = useStore() + +const errorImageUrl = computed(() => { + const errorType = store.state?.errorInfo?.errorType + const imageMap = { + overTime: '/imgs/icons/overtime.webp', + default: '/imgs/icons/error.webp' + } + + return imageMap[errorType as 'overTime'] || imageMap.default +}) + +const errorMsg = computed(() => store.state?.errorInfo?.errorMsg) </script> <style lang="scss" scoped> .result-page-wrap { diff --git a/web/src/render/pages/IndexPage.vue b/web/src/render/pages/IndexPage.vue index 02450124..6878dbca 100644 --- a/web/src/render/pages/IndexPage.vue +++ b/web/src/render/pages/IndexPage.vue @@ -1,19 +1,21 @@ <template> <div class="index"> - <ProgressBar /> - <div class="wrapper" ref="box"> + <progressBar /> + <div class="wrapper" ref="boxRef"> <HeaderSetter></HeaderSetter> <div class="content"> <MainTitle></MainTitle> - <MainRenderer ref="main"></MainRenderer> - <Submit :validate="validate" :renderData="renderData" @submit="onSubmit"></Submit> + <MainRenderer ref="mainRef"></MainRenderer> + <submit :validate="validate" :renderData="renderData" @submit="handleSubmit"></submit> <LogoIcon /> </div> </div> </div> </template> +<script setup lang="ts"> +import { computed, ref } from 'vue' +import { useStore } from 'vuex' -<script> import HeaderSetter from '../components/HeaderSetter.vue' import MainTitle from '../components/MainTitle.vue' import Submit from '../components/SubmitSetter.vue' @@ -27,117 +29,102 @@ import { submitForm } from '../api/survey' import encrypt from '../utils/encrypt' import useCommandComponent from '../hooks/useCommandComponent' -import { cloneDeep } from 'lodash-es' - -export default { - name: 'indexPage', - props: { - questionInfo: { - type: Object, - default: () => ({}) - }, - isMobile: { - type: Boolean, - default: false + +interface Props { + questionInfo?: any + isMobile?: boolean +} + +withDefaults(defineProps<Props>(), { + questionInfo: {}, + isMobile: false +}) + +const mainRef = ref<any>() +const boxRef = ref<HTMLElement>() + +const alert = useCommandComponent(AlertDialog) +const confirm = useCommandComponent(ConfirmDialog) + +const store = useStore() + +const renderData = computed(() => store.getters.renderData) + +const validate = (cbk: (v: boolean) => void) => { + const index = 0 + mainRef.value.$refs.formGroup[index].validate(cbk) +} + +const normalizationRequestBody = () => { + const enterTime = store.state.enterTime + const encryptInfo = store.state.encryptInfo + const formModel = store.getters.formModel + const surveyPath = store.state.surveyPath + + const result: any = { + surveyPath, + data: JSON.stringify(formModel), + difTime: Date.now() - enterTime, + clientTime: Date.now() + } + + if (encryptInfo?.encryptType) { + result.encryptType = encryptInfo?.encryptType + result.data = encrypt[result.encryptType as 'rsa']({ + data: result.data, + secretKey: encryptInfo?.data?.secretKey + }) + if (encryptInfo?.data?.sessionId) { + result.sessionId = encryptInfo.data.sessionId } - }, - components: { - HeaderSetter, - MainTitle, - Submit, - MainRenderer, - ProgressBar, - LogoIcon - }, - computed: { - confirmAgain() { - return this.$store.state.submitConf.confirmAgain - }, - surveyPath() { - return this.$store.state.surveyPath - }, - renderData() { - return this.$store.getters.renderData - }, - encryptInfo() { - return this.$store.state.encryptInfo + } else { + result.data = JSON.stringify(result.data) + } + + return result +} + +const submitSurver = async () => { + try { + const params = normalizationRequestBody() + console.log(params) + const res: any = await submitForm(params) + if (res.code === 200) { + store.commit('setRouter', 'successPage') + } else { + alert({ + title: res.errmsg || '提交失败' + }) } - }, - created() { - this.alert = useCommandComponent(AlertDialog) - this.confirm = useCommandComponent(ConfirmDialog) - }, - methods: { - validate(cbk) { - const index = 0 - this.$refs.main.$refs.formGroup[index].validate(cbk) - }, - onSubmit() { - const { again_text, is_again } = this.confirmAgain - if (is_again) { - this.confirm({ - title: again_text, - onConfirm: async () => { - try { - await this.submitForm() - } catch (error) { - console.error(error) - } finally { - this.confirm.close() - } - } - }) - } else { - this.submitForm() - } - }, - getSubmitData() { - const formValues = cloneDeep(this.$store.state.formValues) - - const result = { - surveyPath: this.surveyPath, - data: JSON.stringify(formValues), - difTime: Date.now() - this.$store.state.enterTime, - clientTime: Date.now() - } - if (this.encryptInfo?.encryptType) { - result.encryptType = this.encryptInfo?.encryptType - result.data = encrypt[result.encryptType]({ - data: result.data, - secretKey: this.encryptInfo?.data?.secretKey - }) - if (this.encryptInfo?.data?.sessionId) { - result.sessionId = this.encryptInfo.data.sessionId - } - } else { - result.data = JSON.stringify(result.data) - } + } catch (error) { + console.log(error) + } +} - return result - }, - async submitForm() { - try { - const submitData = this.getSubmitData() - - const res = await submitForm(submitData) - if (res.code === 200) { - this.$store.commit('setRouter', 'successPage') - } else { - this.alert({ - title: res.errmsg || '提交失败' - }) +const handleSubmit = () => { + const confirmAgain = store.state.submitConf.confirmAgain + const { again_text, is_again } = confirmAgain + + if (is_again) { + confirm({ + title: again_text, + onConfirm: async () => { + try { + submitSurver() + } catch (error) { + console.log(error) + } finally { + confirm.close() } - } catch (error) { - console.log(error) } - } + }) + } else { + submitSurver() } } </script> - <style scoped lang="scss"> .index { - // padding-bottom: 0.8rem; min-height: 100%; .wrapper { min-height: 100%; diff --git a/web/src/render/pages/SuccessPage.vue b/web/src/render/pages/SuccessPage.vue index 171cdbfb..587eb3e7 100644 --- a/web/src/render/pages/SuccessPage.vue +++ b/web/src/render/pages/SuccessPage.vue @@ -9,20 +9,17 @@ </div> </div> </template> -<script> +<script setup lang="ts"> +import { computed } from 'vue' +import { useStore } from 'vuex' import LogoIcon from '../components/LogoIcon.vue' -export default { - name: 'resultPage', - components: { LogoIcon }, - computed: { - submitConf() { - return this.$store?.state?.submitConf || {} - }, - successMsg() { - return this.submitConf?.msgContent?.msg_200 || '提交成功' - } - } -} + +const store = useStore() + +const successMsg = computed(() => { + const msgContent = store.state?.submitConf?.msgContent || {} + return msgContent?.msg_200 || '提交成功' +}) </script> <style lang="scss" scoped> @import '@/render/styles/variable.scss';