From 90d013bce51e6f2440cd8b3546cbf3c8d787bdcb Mon Sep 17 00:00:00 2001 From: sudoooooo Date: Wed, 5 Jun 2024 11:44:31 +0800 Subject: [PATCH 1/9] feat: Update README_EN.md --- README_EN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README_EN.md b/README_EN.md index b69c0da0..b827ecf7 100644 --- a/README_EN.md +++ b/README_EN.md @@ -19,10 +19,10 @@ pr - docs + docs - docs + docs From 2cd79e6091701a956ca6a9da43e01807aa12ace0 Mon Sep 17 00:00:00 2001 From: sudoooooo Date: Wed, 5 Jun 2024 11:45:24 +0800 Subject: [PATCH 2/9] feat: Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0973f75d..ddd9e1ab 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ docs - docs + docs From 70827bbd5f76f34691a3e84da5797deec93850ac Mon Sep 17 00:00:00 2001 From: chaorenluo <1243357953@qq.com> Date: Tue, 11 Jun 2024 16:04:00 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9E=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20(#257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat:问卷预览功能 * feat:修复样式问题 --- nginx/nginx.conf | 5 + .../survey/controllers/survey.controller.ts | 32 +++ web/public/imgs/preview-phone.png | Bin 0 -> 7320 bytes .../pages/edit/components/ModuleNavbar.vue | 2 + .../modules/contentModule/PreviewPanel.vue | 184 ++++++++++++++++++ web/src/render/App.vue | 66 ++++--- web/src/render/api/survey.js | 8 + web/src/render/pages/IndexPage.vue | 8 +- web/vite.config.ts | 4 + 9 files changed, 281 insertions(+), 28 deletions(-) create mode 100644 web/public/imgs/preview-phone.png create mode 100644 web/src/management/pages/edit/modules/contentModule/PreviewPanel.vue diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 88c6265d..34854cdf 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -39,6 +39,11 @@ http { try_files $uri $uri/ /management.html; } + location /management/preview/ { + try_files $uri $uri/ /render.html; + } + + location /render/ { try_files $uri $uri/ /render.html; } diff --git a/server/src/modules/survey/controllers/survey.controller.ts b/server/src/modules/survey/controllers/survey.controller.ts index c8b59fb2..df1a3d62 100644 --- a/server/src/modules/survey/controllers/survey.controller.ts +++ b/server/src/modules/survey/controllers/survey.controller.ts @@ -223,6 +223,38 @@ export class SurveyController { }; } + @Get('/getPreviewSchema') + @HttpCode(200) + async getPreviewSchema( + @Query() + queryInfo: { + surveyPath: string; + }, + @Request() + req, + ) { + const { value, error } = Joi.object({ + surveyId: Joi.string().required(), + }).validate({ surveyId: queryInfo.surveyPath }); + + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const surveyId = value.surveyId; + const surveyConf = + await this.surveyConfService.getSurveyConfBySurveyId(surveyId); + const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId }); + return { + code: 200, + data: { + ...surveyConf, + title: surveyMeta?.title, + surveyPath: surveyMeta?.surveyPath, + }, + }; + } + @Post('/publishSurvey') @HttpCode(200) @UseGuards(SurveyGuard) diff --git a/web/public/imgs/preview-phone.png b/web/public/imgs/preview-phone.png new file mode 100644 index 0000000000000000000000000000000000000000..375bf1b52f21b1bc8908a667f238e53e4ebe4665 GIT binary patch literal 7320 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⪚IPmv0-cc7BIQ>1hHk?2-r9yooS@+X)LL+I)_-+ zZ*%`u<0)3OM&;}c0eM%tzMXUSO4UAE^h(#-?;}lZ`~}dOsN-B+BbV^u3aCdum7_bG zIwwWWeV}WRE3Uq{09@SKX=;=)cf5tkCTSQ2rLXZAOlNzH3$YdoG-tpfYQl;JOQl4Rhf{aM%>F=R{$6Mz`y33Yy^jz(7QY-AZYm4=#4CcK)E{74;+XjBco13q=G{#HZ|irbnyGSJ}}O9IhCA zqRm@RS7aJ8XHr{%p_6JZ4JS40&$QQANtZC2-68(IGLn2Y zoN&zdOzeBHCTD$mOFta);2Yn-K!}6~YbfVq2uGVi{OIjz1n*TN8$x?fJ%t$Oo{Jl) zcfPM-So55;yUX5>UA8SA3PNiDtEX#D41G1Yp=`NOkI|xipjJ_f%RHlFQKvXoqjo7flru`<816Z zyccEr9!;qF#RWx&l?f%6%L~UfZh(A5Op%>Gzik44t^(8Ns)9+H>%sh1@AM#K*+cNX zC2LDGKd2LG=BdCGThd@vsCk)wC&N~ODWHg28+6WQV)Ct6Q$FAh#AE8|s3E)gu3hsh zkc+M@5VDdyH8c*|TT1OWOZ@_J>EpZ45?e(Pa)*<{0GexJ2jZ+m0!KdEsb6mcg;l8n z?qtSxkY0CWELUSRYq=6bX|Hg=wvW)WTs1yOu97CpCW$zLAEem#0jCktGR)^{=X|&0 zmQ#oEiMHyPbfz$0geRuLpu-~nDi8hVWDDI^oo+jTmqOL}mI9v~J} n+Nc^RMkBAkEyrv=LGzqaYNvTW_BogQ@3ufyNef;AvkdqjZ+&Uy literal 0 HcmV?d00001 diff --git a/web/src/management/pages/edit/components/ModuleNavbar.vue b/web/src/management/pages/edit/components/ModuleNavbar.vue index 4754af42..0965cddf 100644 --- a/web/src/management/pages/edit/components/ModuleNavbar.vue +++ b/web/src/management/pages/edit/components/ModuleNavbar.vue @@ -8,6 +8,7 @@
+ @@ -23,6 +24,7 @@ import BackPanel from '../modules/generalModule/BackPanel.vue' import TitlePanel from '../modules/generalModule/TitlePanel.vue' import NavPanel from '../modules/generalModule/NavPanel.vue' 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' diff --git a/web/src/management/pages/edit/modules/contentModule/PreviewPanel.vue b/web/src/management/pages/edit/modules/contentModule/PreviewPanel.vue new file mode 100644 index 00000000..4d8ca94e --- /dev/null +++ b/web/src/management/pages/edit/modules/contentModule/PreviewPanel.vue @@ -0,0 +1,184 @@ + + + diff --git a/web/src/render/App.vue b/web/src/render/App.vue index 1ffd4724..7a8b11ce 100644 --- a/web/src/render/App.vue +++ b/web/src/render/App.vue @@ -20,7 +20,7 @@ import { computed, watch, onMounted } from 'vue' import { useStore } from 'vuex' -import { getPublishedSurveyInfo } from './api/survey' +import { getPublishedSurveyInfo, getPreviewSchema } from './api/survey' import useCommandComponent from './hooks/useCommandComponent' import EmptyPage from './pages/EmptyPage.vue' @@ -65,6 +65,39 @@ const updateSkinConfig = (value: any) => { } } +const loadData = (res: any, surveyPath: string) => { + if (res.code === 200) { + const data = res.data + const { + bannerConf, + baseConf, + bottomConf, + dataConf, + skinConf, + submitConf, + logicConf + } = data.code + const questionData = { + bannerConf, + baseConf, + bottomConf, + dataConf, + skinConf, + submitConf + } + + document.title = data.title + + updateSkinConfig(skinConf) + + store.commit('setSurveyPath', surveyPath) + store.dispatch('init', questionData) + initRuleEngine(logicConf?.showLogicConf) + } else { + throw new Error(res.errmsg) + } +} + watch(skinConf, (value) => { updateSkinConfig(value) }) @@ -78,33 +111,14 @@ onMounted(async () => { } 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, logicConf } = - 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') - initRuleEngine(logicConf?.showLogicConf) + if (surveyPath.length > 8) { + const res: any = await getPreviewSchema({ surveyPath }) + loadData(res, surveyPath) } else { - throw new Error(res.errmsg) + const res: any = await getPublishedSurveyInfo({ surveyPath }) + loadData(res, surveyPath) + store.dispatch('getEncryptInfo') } } catch (error: any) { console.log(error) diff --git a/web/src/render/api/survey.js b/web/src/render/api/survey.js index 8ae6929c..5b6824f3 100644 --- a/web/src/render/api/survey.js +++ b/web/src/render/api/survey.js @@ -8,6 +8,14 @@ export const getPublishedSurveyInfo = ({ surveyPath }) => { }) } +export const getPreviewSchema = ({ surveyPath }) => { + return axios.get('/survey/getPreviewSchema', { + params: { + surveyPath + } + }) +} + export const submitForm = (data) => { return axios.post('/surveyResponse/createResponse', data) } diff --git a/web/src/render/pages/IndexPage.vue b/web/src/render/pages/IndexPage.vue index 7f02b5cd..af2cb0c5 100644 --- a/web/src/render/pages/IndexPage.vue +++ b/web/src/render/pages/IndexPage.vue @@ -60,6 +60,7 @@ const bannerConf = computed(() => store.state?.bannerConf || {}) const renderData = computed(() => store.getters.renderData) const submitConf = computed(() => store.state?.submitConf || {}) const logoConf = computed(() => store.state?.bottomConf || {}) +const surveyPath = computed(() => store.state?.surveyPath || '') const validate = (cbk: (v: boolean) => void) => { const index = 0 @@ -70,10 +71,9 @@ const normalizationRequestBody = () => { const enterTime = store.state.enterTime const encryptInfo = store.state.encryptInfo const formValues = store.state.formValues - const surveyPath = store.state.surveyPath const result: any = { - surveyPath, + surveyPath: surveyPath.value, data: JSON.stringify(formValues), difTime: Date.now() - enterTime, clientTime: Date.now() @@ -96,6 +96,10 @@ const normalizationRequestBody = () => { } const submitSurver = async () => { + if (surveyPath.value.length > 8) { + store.commit('setRouter', 'successPage') + return + } try { const params = normalizationRequestBody() console.log(params) diff --git a/web/vite.config.ts b/web/vite.config.ts index d7e5a8d8..1d36bdcf 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -34,6 +34,10 @@ const mpaPlugin = createMpaPlugin({ from: /render/, to: () => normalizePath('/src/render/index.html') }, + { + from: /management\/preview/, + to: () => normalizePath('/src/render/index.html') + }, { from: /\/|\/management\/.?/, to: () => normalizePath('/src/management/index.html') From cd75ac18bc95572b0de905c963409fe9885d2bd1 Mon Sep 17 00:00:00 2001 From: sudoooooo Date: Tue, 11 Jun 2024 19:30:29 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/components.d.ts | 4 + .../modules/contentModule/PreviewPanel.vue | 75 +++++++++---------- 2 files changed, 38 insertions(+), 41 deletions(-) diff --git a/web/components.d.ts b/web/components.d.ts index 8824b169..c897a9e0 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -46,8 +46,10 @@ declare module 'vue' { 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'] @@ -58,6 +60,8 @@ declare module 'vue' { 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'] } diff --git a/web/src/management/pages/edit/modules/contentModule/PreviewPanel.vue b/web/src/management/pages/edit/modules/contentModule/PreviewPanel.vue index 4d8ca94e..7c51c200 100644 --- a/web/src/management/pages/edit/modules/contentModule/PreviewPanel.vue +++ b/web/src/management/pages/edit/modules/contentModule/PreviewPanel.vue @@ -1,6 +1,6 @@ @@ -38,7 +49,7 @@ import 'element-plus/theme-chalk/src/message.scss' import EmptyIndex from '@/management/components/EmptyIndex.vue' import LeftMenu from '@/management/components/LeftMenu.vue' -import { getRecycleList } from '@/management/api/analysis' +import { getRecycleList, downloadSurvey } from '@/management/api/analysis' import DataTable from './components/DataTable.vue' @@ -59,7 +70,8 @@ export default { }, currentPage: 1, isShowOriginData: false, - tmpIsShowOriginData: false + tmpIsShowOriginData: false, + isDownloadDesensitive: true } }, computed: {}, @@ -89,6 +101,34 @@ export default { ElMessage.error('查询回收数据失败,请重试') } }, + async onDownload() { + try { + await ElMessageBox.confirm('是否确认下载?', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + } catch (error) { + console.log('取消下载') + return + } + this.exportData() + this.gotoDownloadList() + }, + async gotoDownloadList() { + try { + await ElMessageBox.confirm('计算中,是否前往下载中心?', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + } catch (error) { + console.log('取消跳转') + return + } + + this.$router.push('/survey/download') + }, handleCurrentChange(current) { if (this.mainTableLoading) { return @@ -125,6 +165,29 @@ export default { this.tmpIsShowOriginData = data await this.init() this.isShowOriginData = data + }, + async onisDownloadDesensitive() { + if (this.isDownloadDesensitive) { + this.isDownloadDesensitive = false + } else { + this.isDownloadDesensitive = true + } + }, + + async exportData() { + try { + const res = await downloadSurvey({ + surveyId: String(this.$route.params.id), + isDesensitive: this.isDownloadDesensitive + }) + console.log(this.$route.params.id) + if (res.code === 200) { + ElMessage.success('下载成功') + } + } catch (error) { + ElMessage.error('下载失败') + ElMessage.error(error.message) + } } }, @@ -158,6 +221,8 @@ export default { .menus { margin-bottom: 20px; + display: flex; + justify-content: space-between; } .content-wrapper { diff --git a/web/src/management/pages/download/SurveyDownloadPage.vue b/web/src/management/pages/download/SurveyDownloadPage.vue new file mode 100644 index 00000000..8a2d8b1e --- /dev/null +++ b/web/src/management/pages/download/SurveyDownloadPage.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/web/src/management/pages/download/components/DownloadList.vue b/web/src/management/pages/download/components/DownloadList.vue new file mode 100644 index 00000000..a13a3531 --- /dev/null +++ b/web/src/management/pages/download/components/DownloadList.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/web/src/management/pages/list/config/index.js b/web/src/management/pages/list/config/index.js index 5576d50a..cd724748 100644 --- a/web/src/management/pages/list/config/index.js +++ b/web/src/management/pages/list/config/index.js @@ -92,7 +92,7 @@ export const statusMaps = { new: '未发布', editing: '修改中', published: '已发布', - removed: '', + removed: '已删除', pausing: '' } diff --git a/web/src/management/pages/list/index.vue b/web/src/management/pages/list/index.vue index 3a86203a..248b4bed 100644 --- a/web/src/management/pages/list/index.vue +++ b/web/src/management/pages/list/index.vue @@ -5,6 +5,7 @@ logo 问卷列表 + 下载页面