Showing
5 changed files
with
471 additions
and
185 deletions
| ... | @@ -7,13 +7,18 @@ | ... | @@ -7,13 +7,18 @@ |
| 7 | * @Description: | 7 | * @Description: |
| 8 | --> | 8 | --> |
| 9 | <template> | 9 | <template> |
| 10 | - <router-view></router-view> | 10 | + <div class="app-shell"> |
| 11 | + <router-view v-if="app_bootstrap_ready"></router-view> | ||
| 12 | + <div v-else class="app-bootstrap-loading" :style="{ background: styleColor.backgroundColor }"> | ||
| 13 | + <van-loading size="24px" vertical :color="styleColor.baseColor">页面授权中...</van-loading> | ||
| 14 | + </div> | ||
| 15 | + </div> | ||
| 11 | </template> | 16 | </template> |
| 12 | 17 | ||
| 13 | <script setup> | 18 | <script setup> |
| 14 | -import { mainStore, useTitle } from "@/utils/generatePackage"; | 19 | +import { mainStore } from "@/utils/generatePackage"; |
| 15 | -import { computed, watchEffect, onMounted, onUnmounted } from "vue"; | 20 | +import { computed, ref, onMounted, onUnmounted } from "vue"; |
| 16 | -import { useRoute, useRouter } from "vue-router"; | 21 | +import { useRouter } from "vue-router"; |
| 17 | // import { Toast } from "vant"; | 22 | // import { Toast } from "vant"; |
| 18 | // 会根据配置判断是否显示调试控件 | 23 | // 会根据配置判断是否显示调试控件 |
| 19 | // eslint-disable-next-line no-unused-vars | 24 | // eslint-disable-next-line no-unused-vars |
| ... | @@ -22,20 +27,21 @@ import vConsole from "@/utils/vconsole"; | ... | @@ -22,20 +27,21 @@ import vConsole from "@/utils/vconsole"; |
| 22 | import wx from 'weixin-js-sdk' | 27 | import wx from 'weixin-js-sdk' |
| 23 | import { wxJsAPI } from '@/api/wx/config' | 28 | import { wxJsAPI } from '@/api/wx/config' |
| 24 | import { apiList } from '@/api/wx/jsApiList.js' | 29 | import { apiList } from '@/api/wx/jsApiList.js' |
| 25 | -import { wxInfo, getUrlParams, stringifyQuery, isWithinRadius } from "@/utils/tools"; | 30 | +import { wxInfo, isWithinRadius } from "@/utils/tools"; |
| 26 | import { styleColor } from "@/constant.js"; | 31 | import { styleColor } from "@/constant.js"; |
| 27 | import { getFormSettingAPI } from "@/api/form.js"; | 32 | import { getFormSettingAPI } from "@/api/form.js"; |
| 28 | import { showDialog, showConfirmDialog } from 'vant'; | 33 | import { showDialog, showConfirmDialog } from 'vant'; |
| 29 | // import fp3 from '@/utils/fp3'; | 34 | // import fp3 from '@/utils/fp3'; |
| 30 | import { Updater } from '@/utils/versionUpdater'; | 35 | import { Updater } from '@/utils/versionUpdater'; |
| 31 | import { resetDialogState } from '@/utils/dialogControl.js'; | 36 | import { resetDialogState } from '@/utils/dialogControl.js'; |
| 37 | +import { useAuthRedirect } from '@/composables'; | ||
| 32 | 38 | ||
| 33 | // 使用 include + pinia 状态管理动态缓存页面 | 39 | // 使用 include + pinia 状态管理动态缓存页面 |
| 34 | const store = mainStore(); | 40 | const store = mainStore(); |
| 35 | -const keepPages = computed(() => store.getKeepPages); | 41 | +// 表单配置和授权判断完成前,先只显示加载态,避免用户先看到表单又被授权回跳打断 |
| 42 | +const app_bootstrap_ready = ref(false); | ||
| 36 | 43 | ||
| 37 | // TAG: 全局设置页面标题 | 44 | // TAG: 全局设置页面标题 |
| 38 | -const $route = useRoute(); | ||
| 39 | // watchEffect(() => useTitle("表单标题")); | 45 | // watchEffect(() => useTitle("表单标题")); |
| 40 | // 监听路由变化 | 46 | // 监听路由变化 |
| 41 | // 切换路由页面返回顶部 | 47 | // 切换路由页面返回顶部 |
| ... | @@ -56,12 +62,19 @@ const $router = useRouter(); | ... | @@ -56,12 +62,19 @@ const $router = useRouter(); |
| 56 | // className: 'zIndex' | 62 | // className: 'zIndex' |
| 57 | // }); | 63 | // }); |
| 58 | 64 | ||
| 59 | -// web端判断 | ||
| 60 | -const is_pc = computed(() => wxInfo().isPC); | ||
| 61 | // 微信端判断 | 65 | // 微信端判断 |
| 62 | const is_wx = computed(() => wxInfo().isWeiXin); | 66 | const is_wx = computed(() => wxInfo().isWeiXin); |
| 63 | // iframe模式判断 | 67 | // iframe模式判断 |
| 64 | const is_iframe = computed(() => window.self !== window.top); | 68 | const is_iframe = computed(() => window.self !== window.top); |
| 69 | +const { | ||
| 70 | + getQueryValue, | ||
| 71 | + getCurrentTargetHash, | ||
| 72 | + redirectToOpenIdAuth, | ||
| 73 | + savePendingAuthReturnGuard, | ||
| 74 | + consumePendingAuthReturnGuard, | ||
| 75 | + installAuthBackHistoryGuard, | ||
| 76 | + markSkipCycleCheckForAuth, | ||
| 77 | +} = useAuthRedirect(); | ||
| 65 | 78 | ||
| 66 | // 组件卸载时清理定时器(必须在 setup 同步阶段注册钩子) | 79 | // 组件卸载时清理定时器(必须在 setup 同步阶段注册钩子) |
| 67 | let upDater = null; | 80 | let upDater = null; |
| ... | @@ -72,180 +85,202 @@ onUnmounted(() => { | ... | @@ -72,180 +85,202 @@ onUnmounted(() => { |
| 72 | }); | 85 | }); |
| 73 | 86 | ||
| 74 | onMounted(async () => { | 87 | onMounted(async () => { |
| 75 | - // 重置弹框状态,确保每次访问页面时都能正常显示未完成表单弹框 | 88 | + let leaving_current_page = false; |
| 76 | - resetDialogState(); | ||
| 77 | 89 | ||
| 78 | - const code = getUrlParams(location.href) ? getUrlParams(location.href).code : ''; | 90 | + try { |
| 79 | - const model = getUrlParams(location.href) ? getUrlParams(location.href).model : ''; | 91 | + // 重置弹框状态,确保每次访问页面时都能正常显示未完成表单弹框 |
| 80 | - const data_id = getUrlParams(location.href) ? getUrlParams(location.href).data_id : ''; | 92 | + resetDialogState(); |
| 81 | - // 权限控制, 页面参数判断页面功能 | ||
| 82 | - /** | ||
| 83 | - * add 新增页 | ||
| 84 | - * info 详情页 | ||
| 85 | - * edit 编辑页 | ||
| 86 | - * flow 流程页 | ||
| 87 | - */ | ||
| 88 | - const page_type = getUrlParams(location.href) ? getUrlParams(location.href).page_type : ''; | ||
| 89 | - const raw_url = encodeURIComponent(location.pathname + location.hash); | ||
| 90 | - const flow_node_code = getUrlParams(location.href) ? getUrlParams(location.href).flow_node_code : ''; // flow_node_code 表示随机选择的流程节点的ID | ||
| 91 | - const force_back = getUrlParams(location.href) ? getUrlParams(location.href).force_back : ''; // force_back=1 时,强制按照后台用户模式检查权限 | ||
| 92 | - const x_cycle = getUrlParams(location.href) ? getUrlParams(location.href).x_cycle : ''; // 周期ID标识 | ||
| 93 | - const volunteer_source = getUrlParams(location.href) ? getUrlParams(location.href).volunteer_source : ''; // 义工来源 | ||
| 94 | - // iframe传值openid | ||
| 95 | - const iframe_openid = getUrlParams(location.href) ? getUrlParams(location.href).openid : ''; | ||
| 96 | - // 数据收集设置 | ||
| 97 | - const { data } = await getFormSettingAPI({ form_code: code, page_type, data_id, flow_node_code, force_back, x_cycle, volunteer_source, openid: iframe_openid }); | ||
| 98 | - const form_setting = {}; | ||
| 99 | - if (data.length) { | ||
| 100 | - Object.assign(form_setting, data[0]['property_list'], data[0]['extend']); | ||
| 101 | - } | ||
| 102 | - // TAG: 西园寺特有,拿到新域名跳转 | ||
| 103 | - if (!import.meta.env.DEV && form_setting.redirect_host) { | ||
| 104 | - let host = location.host; | ||
| 105 | - let url = location.href; | ||
| 106 | - let new_url = url.replace(host, form_setting.redirect_host); | ||
| 107 | - location.href = new_url; | ||
| 108 | - } | ||
| 109 | - // TAG: 是否显示流程按钮 | ||
| 110 | - if (page_type === 'add' && form_setting.flow_id) { | ||
| 111 | - form_setting.is_flow = true; | ||
| 112 | - } | ||
| 113 | - if (page_type === 'flow') { | ||
| 114 | - form_setting.is_flow = true; | ||
| 115 | - } | ||
| 116 | - // 缓存表单设置 | ||
| 117 | - store.changeFormSetting(form_setting); | ||
| 118 | - // TAG: 跳转未授权页 | ||
| 119 | - if (form_setting.auth_error) { // 权限报错信息存在 | ||
| 120 | - $router.replace({ | ||
| 121 | - path: '/no_auth', | ||
| 122 | - query: { | ||
| 123 | - code, | ||
| 124 | - data_id | ||
| 125 | - } | ||
| 126 | - }); | ||
| 127 | - } | ||
| 128 | - // 没有授权判断 | ||
| 129 | - let open_auth = form_setting.wxzq_enable && !form_setting.x_field_weixin_openid; | ||
| 130 | - // iframe传值openid | ||
| 131 | - if (iframe_openid) { // 如果获取到iframe传值openid 不再校验授权 | ||
| 132 | - open_auth = false; | ||
| 133 | - } | ||
| 134 | - const no_preview_model = model !== 'preview'; | ||
| 135 | 93 | ||
| 136 | - let record_openid = false; // 是否记录open_id | 94 | + const code = getQueryValue('code'); |
| 137 | - | 95 | + const model = getQueryValue('model'); |
| 138 | - /** | 96 | + const data_id = getQueryValue('data_id'); |
| 139 | - * is_back_user 用户登录情况 | 97 | + // 权限控制, 页面参数判断页面功能 |
| 140 | - * 1. 后台用户已登录, 新增表单(无data_id), 为了记录 openid 如果有微信增强设置,则启用微信增强 | 98 | + /** |
| 141 | - * 2. 后台用户未登录, 启用微信增强 | 99 | + * add 新增页 |
| 142 | - */ | 100 | + * info 详情页 |
| 143 | - | 101 | + * edit 编辑页 |
| 144 | - // if (!form_setting.is_back_user) { // 用户未登录 | 102 | + * flow 流程页 |
| 145 | - if (force_back !== '1') { // 非后台用户模式 | 103 | + */ |
| 146 | - record_openid = true; | 104 | + const page_type = getQueryValue('page_type'); |
| 147 | - } else { // 用户已登录 | 105 | + const raw_url = encodeURIComponent(location.pathname + location.hash); |
| 148 | - if (!is_iframe.value && page_type === 'add') { // 非iframe里面新增表单 | 106 | + const flow_node_code = getQueryValue('flow_node_code'); // flow_node_code 表示随机选择的流程节点的ID |
| 149 | - record_openid = true; | 107 | + const force_back = getQueryValue('force_back'); // force_back=1 时,强制按照后台用户模式检查权限 |
| 108 | + const x_cycle = getQueryValue('x_cycle'); // 周期ID标识 | ||
| 109 | + const volunteer_source = getQueryValue('volunteer_source'); // 义工来源 | ||
| 110 | + // iframe传值openid | ||
| 111 | + const iframe_openid = getQueryValue('openid'); | ||
| 112 | + // 数据收集设置 | ||
| 113 | + const { data } = await getFormSettingAPI({ form_code: code, page_type, data_id, flow_node_code, force_back, x_cycle, volunteer_source, openid: iframe_openid }); | ||
| 114 | + const form_setting = {}; | ||
| 115 | + if (data.length) { | ||
| 116 | + Object.assign(form_setting, data[0]['property_list'], data[0]['extend']); | ||
| 150 | } | 117 | } |
| 151 | - } | 118 | + // TAG: 西园寺特有,拿到新域名跳转 |
| 152 | - | 119 | + if (!import.meta.env.DEV && form_setting.redirect_host) { |
| 153 | - // 需要网页授权-必须要域名相同,需要上传到线上测试 | 120 | + let host = location.host; |
| 154 | - /** | 121 | + let url = location.href; |
| 155 | - * wxzq_scope 微信公众号授权模式 | 122 | + let new_url = url.replace(host, form_setting.redirect_host); |
| 156 | - * 空字符串=不授权,snsapi_base=静默授权,snsapi_userinfo=显式授权 | 123 | + leaving_current_page = true; |
| 157 | - */ | 124 | + location.href = new_url; |
| 158 | - | 125 | + return; |
| 159 | - // 非测试环境,没有openid信息,需要授权 | 126 | + } |
| 160 | - if (!import.meta.env.DEV && open_auth && form_setting.wxzq_scope && record_openid) { | 127 | + // TAG: 是否显示流程按钮 |
| 161 | - // 预览模式不开启 | 128 | + if (page_type === 'add' && form_setting.flow_id) { |
| 162 | - if (no_preview_model) { | 129 | + form_setting.is_flow = true; |
| 163 | - // 设置标识,让路由守卫跳过首次周期检查 | 130 | + } |
| 164 | - sessionStorage.setItem('skip_cycle_check_for_auth', 'true'); | 131 | + if (page_type === 'flow') { |
| 132 | + form_setting.is_flow = true; | ||
| 133 | + } | ||
| 134 | + // 缓存表单设置 | ||
| 135 | + store.changeFormSetting(form_setting); | ||
| 136 | + // TAG: 跳转未授权页 | ||
| 137 | + if (form_setting.auth_error) { // 权限报错信息存在 | ||
| 165 | $router.replace({ | 138 | $router.replace({ |
| 166 | - path: '/auth', | 139 | + path: '/no_auth', |
| 167 | query: { | 140 | query: { |
| 168 | - href: location.hash, | 141 | + code, |
| 169 | - code | 142 | + data_id |
| 170 | } | 143 | } |
| 171 | }); | 144 | }); |
| 145 | + return; | ||
| 172 | } | 146 | } |
| 173 | - } else { | 147 | + // 没有授权判断 |
| 174 | - // 启用微信增强,非预览模式 | 148 | + let open_auth = form_setting.wxzq_enable && !form_setting.x_field_weixin_openid; |
| 175 | - if (form_setting.wxzq_enable && no_preview_model) { | 149 | + // iframe传值openid |
| 176 | - const wxJs = await wxJsAPI({ form_code: code, url: raw_url }); | 150 | + if (iframe_openid) { // 如果获取到iframe传值openid 不再校验授权 |
| 177 | - wxJs.data.jsApiList = apiList; | 151 | + open_auth = false; |
| 178 | - wx.config(wxJs.data); | ||
| 179 | - wx.ready(() => { | ||
| 180 | - wx.showAllNonBaseMenuItem(); | ||
| 181 | - // TAG:判断定位填表功能, 可能会弹出来上一次的表单提示,因为如果定位正确时还是需要恢复相应的表单 | ||
| 182 | - let open_location = form_setting.geofence_enable; | ||
| 183 | - if (force_back !== '1' && open_location) { // 非后台用户模式 | ||
| 184 | - const targetLat = form_setting.geofence_center_latitude; | ||
| 185 | - const targetLng = form_setting.geofence_center_longitude; | ||
| 186 | - const radius = form_setting.geofence_circle_radius; // 半径 1000 米 | ||
| 187 | - wx.getLocation({ | ||
| 188 | - type: 'gcj02', // 默认为wgs84的gps坐标,如果要返回直接给openLocation用的火星坐标,可传入'gcj02' | ||
| 189 | - success: function (res) { | ||
| 190 | - let currentLat = res.latitude; // 纬度,浮点数,范围为90 ~ -90 | ||
| 191 | - let currentLng = res.longitude; // 经度,浮点数,范围为180 ~ -180。 | ||
| 192 | - let is_in = isWithinRadius(currentLat, currentLng, targetLat, targetLng, radius); | ||
| 193 | - if (!is_in) { | ||
| 194 | - // 表单定位错误 | ||
| 195 | - $router.push("/stop?status=location_error&latitude=" + targetLat + "&longitude=" + targetLng); | ||
| 196 | - } | ||
| 197 | - }, | ||
| 198 | - fail: function (res) { | ||
| 199 | - $router.push("/stop?status=get_location_fail"); | ||
| 200 | - }, | ||
| 201 | - }); | ||
| 202 | - } | ||
| 203 | - }); | ||
| 204 | - wx.error((err) => { | ||
| 205 | - console.warn(err); | ||
| 206 | - }); | ||
| 207 | } | 152 | } |
| 208 | - // 判断跳转页面 | 153 | + const no_preview_model = model !== 'preview'; |
| 154 | + | ||
| 155 | + let record_openid = false; // 是否记录open_id | ||
| 156 | + | ||
| 157 | + /** | ||
| 158 | + * is_back_user 用户登录情况 | ||
| 159 | + * 1. 后台用户已登录, 新增表单(无data_id), 为了记录 openid 如果有微信增强设置,则启用微信增强 | ||
| 160 | + * 2. 后台用户未登录, 启用微信增强 | ||
| 161 | + */ | ||
| 162 | + | ||
| 209 | // if (!form_setting.is_back_user) { // 用户未登录 | 163 | // if (!form_setting.is_back_user) { // 用户未登录 |
| 210 | if (force_back !== '1') { // 非后台用户模式 | 164 | if (force_back !== '1') { // 非后台用户模式 |
| 211 | - // 开启后有开始和结束时间,不在时间范围的显示表单还未开始或者已经结束 | 165 | + record_openid = true; |
| 212 | - if (form_setting.sjsj_is_time_range && form_setting.sjsj_is_time_range) { | 166 | + } else { // 用户已登录 |
| 213 | - // 未开始 | 167 | + if (!is_iframe.value && page_type === 'add') { // 非iframe里面新增表单 |
| 214 | - if (form_setting.server_time < form_setting.sjsj_begin_time) { | 168 | + record_openid = true; |
| 215 | - $router.push("/stop?status=apply"); | 169 | + } |
| 216 | - } | 170 | + } |
| 217 | - // 已结束 | 171 | + |
| 218 | - if (form_setting.server_time > form_setting.sjsj_end_time) { | 172 | + // 需要网页授权-必须要域名相同,需要上传到线上测试 |
| 219 | - $router.push("/stop?status=finish"); | 173 | + /** |
| 174 | + * wxzq_scope 微信公众号授权模式 | ||
| 175 | + * 空字符串=不授权,snsapi_base=静默授权,snsapi_userinfo=显式授权 | ||
| 176 | + */ | ||
| 177 | + | ||
| 178 | + // 非测试环境,没有openid信息,需要授权 | ||
| 179 | + if (!import.meta.env.DEV && open_auth && form_setting.wxzq_scope && record_openid) { | ||
| 180 | + // 预览模式不开启 | ||
| 181 | + if (no_preview_model) { | ||
| 182 | + const currentTargetHash = getCurrentTargetHash(); | ||
| 183 | + // 记录这次授权前的目标页和历史长度,授权成功回跳后要用来修正浏览器后退行为 | ||
| 184 | + savePendingAuthReturnGuard(code, currentTargetHash); | ||
| 185 | + // 设置标识,让路由守卫在授权返回后的首轮路由里跳过一次周期检查 | ||
| 186 | + markSkipCycleCheckForAuth(); | ||
| 187 | + // 直接跳去后端授权地址,不再先经过站内 /auth 中转页,避免浏览器历史里留下 auth 记录 | ||
| 188 | + leaving_current_page = true; | ||
| 189 | + redirectToOpenIdAuth(code, currentTargetHash); | ||
| 190 | + return; | ||
| 191 | + } | ||
| 192 | + } else { | ||
| 193 | + if (no_preview_model) { | ||
| 194 | + const currentTargetHash = getCurrentTargetHash(); | ||
| 195 | + const authReturnGuard = consumePendingAuthReturnGuard(code, currentTargetHash); | ||
| 196 | + | ||
| 197 | + // 只有授权前原本就有上一页时,才需要拦住第一次后退并跨过中间的授权地址 | ||
| 198 | + if (authReturnGuard?.historyLength > 1) { | ||
| 199 | + installAuthBackHistoryGuard(); | ||
| 220 | } | 200 | } |
| 221 | } | 201 | } |
| 222 | - if (form_setting.sjsj_enable === 0 && !form_setting.sjsj_enable && (page_type === 'add' || page_type === '')) { // 新增的时候才判断 | 202 | + |
| 223 | - // 表单已结束 | 203 | + // 启用微信增强,非预览模式 |
| 224 | - $router.push("/stop?status=disable"); | 204 | + if (form_setting.wxzq_enable && no_preview_model) { |
| 205 | + const wxJs = await wxJsAPI({ form_code: code, url: raw_url }); | ||
| 206 | + wxJs.data.jsApiList = apiList; | ||
| 207 | + wx.config(wxJs.data); | ||
| 208 | + wx.ready(() => { | ||
| 209 | + wx.showAllNonBaseMenuItem(); | ||
| 210 | + // TAG:判断定位填表功能, 可能会弹出来上一次的表单提示,因为如果定位正确时还是需要恢复相应的表单 | ||
| 211 | + let open_location = form_setting.geofence_enable; | ||
| 212 | + if (force_back !== '1' && open_location) { // 非后台用户模式 | ||
| 213 | + const targetLat = form_setting.geofence_center_latitude; | ||
| 214 | + const targetLng = form_setting.geofence_center_longitude; | ||
| 215 | + const radius = form_setting.geofence_circle_radius; // 半径 1000 米 | ||
| 216 | + wx.getLocation({ | ||
| 217 | + type: 'gcj02', // 默认为wgs84的gps坐标,如果要返回直接给openLocation用的火星坐标,可传入'gcj02' | ||
| 218 | + success: function (res) { | ||
| 219 | + let currentLat = res.latitude; // 纬度,浮点数,范围为90 ~ -90 | ||
| 220 | + let currentLng = res.longitude; // 经度,浮点数,范围为180 ~ -180。 | ||
| 221 | + let is_in = isWithinRadius(currentLat, currentLng, targetLat, targetLng, radius); | ||
| 222 | + if (!is_in) { | ||
| 223 | + // 表单定位错误 | ||
| 224 | + $router.push("/stop?status=location_error&latitude=" + targetLat + "&longitude=" + targetLng); | ||
| 225 | + } | ||
| 226 | + }, | ||
| 227 | + fail: function (res) { | ||
| 228 | + $router.push("/stop?status=get_location_fail"); | ||
| 229 | + } | ||
| 230 | + }); | ||
| 231 | + } | ||
| 232 | + }); | ||
| 233 | + wx.error((err) => { | ||
| 234 | + console.warn(err); | ||
| 235 | + }); | ||
| 225 | } | 236 | } |
| 226 | - } | 237 | + // 判断跳转页面 |
| 227 | - // 当数据量达到限额时,该表单将不能继续提交数据。 | 238 | + // if (!form_setting.is_back_user) { // 用户未登录 |
| 228 | - if (form_setting.sjsj_max_count_error) { | 239 | + if (force_back !== '1') { // 非后台用户模式 |
| 229 | - showDialog({ | 240 | + // 开启后有开始和结束时间,不在时间范围的显示表单还未开始或者已经结束 |
| 230 | - title: '温馨提示', | 241 | + if (form_setting.sjsj_is_time_range && form_setting.sjsj_is_time_range) { |
| 231 | - message: form_setting.sjsj_max_count_error, | 242 | + // 未开始 |
| 232 | - theme: 'round-button', | 243 | + if (form_setting.server_time < form_setting.sjsj_begin_time) { |
| 233 | - confirmButtonColor: styleColor.baseColor | 244 | + $router.push("/stop?status=apply"); |
| 234 | - }); | 245 | + } |
| 235 | - } | 246 | + // 已结束 |
| 236 | - // 设定填写次数 | 247 | + if (form_setting.server_time > form_setting.sjsj_end_time) { |
| 237 | - if (form_setting.wxzq_scope && no_preview_model) { | 248 | + $router.push("/stop?status=finish"); |
| 238 | - if (form_setting.fill_error) { | 249 | + } |
| 250 | + } | ||
| 251 | + if (form_setting.sjsj_enable === 0 && !form_setting.sjsj_enable && (page_type === 'add' || page_type === '')) { // 新增的时候才判断 | ||
| 252 | + // 表单已结束 | ||
| 253 | + $router.push("/stop?status=disable"); | ||
| 254 | + } | ||
| 255 | + } | ||
| 256 | + // 当数据量达到限额时,该表单将不能继续提交数据。 | ||
| 257 | + if (form_setting.sjsj_max_count_error) { | ||
| 239 | showDialog({ | 258 | showDialog({ |
| 240 | title: '温馨提示', | 259 | title: '温馨提示', |
| 241 | - message: form_setting.fill_error, | 260 | + message: form_setting.sjsj_max_count_error, |
| 242 | theme: 'round-button', | 261 | theme: 'round-button', |
| 243 | confirmButtonColor: styleColor.baseColor | 262 | confirmButtonColor: styleColor.baseColor |
| 244 | }); | 263 | }); |
| 245 | } | 264 | } |
| 265 | + // 设定填写次数 | ||
| 266 | + if (form_setting.wxzq_scope && no_preview_model) { | ||
| 267 | + if (form_setting.fill_error) { | ||
| 268 | + showDialog({ | ||
| 269 | + title: '温馨提示', | ||
| 270 | + message: form_setting.fill_error, | ||
| 271 | + theme: 'round-button', | ||
| 272 | + confirmButtonColor: styleColor.baseColor | ||
| 273 | + }); | ||
| 274 | + } | ||
| 275 | + } | ||
| 276 | + if (is_wx.value) { | ||
| 277 | + document.getElementById('app').style.maxWidth = '100vw'; | ||
| 278 | + } | ||
| 246 | } | 279 | } |
| 247 | - if (is_wx.value) { | 280 | + } finally { |
| 248 | - document.getElementById('app').style.maxWidth = '100vw'; | 281 | + // 只有在当前页不需要立刻跳去授权/跳域名时,才让真实页面开始渲染 |
| 282 | + if (!leaving_current_page && !app_bootstrap_ready.value) { | ||
| 283 | + app_bootstrap_ready.value = true; | ||
| 249 | } | 284 | } |
| 250 | } | 285 | } |
| 251 | 286 | ||
| ... | @@ -301,6 +336,22 @@ body { | ... | @@ -301,6 +336,22 @@ body { |
| 301 | position: relative; | 336 | position: relative; |
| 302 | } | 337 | } |
| 303 | 338 | ||
| 339 | +.app-shell { | ||
| 340 | + min-height: 100vh; | ||
| 341 | +} | ||
| 342 | + | ||
| 343 | +.app-bootstrap-loading { | ||
| 344 | + position: fixed; | ||
| 345 | + inset: 0; | ||
| 346 | + z-index: 5000; | ||
| 347 | + display: flex; | ||
| 348 | + align-items: center; | ||
| 349 | + justify-content: center; | ||
| 350 | + padding: 0 24px; | ||
| 351 | + box-sizing: border-box; | ||
| 352 | + background: #ffffff; | ||
| 353 | +} | ||
| 354 | + | ||
| 304 | .@{prefix} { | 355 | .@{prefix} { |
| 305 | color: red; | 356 | color: red; |
| 306 | } | 357 | } | ... | ... |
| ... | @@ -7,6 +7,7 @@ | ... | @@ -7,6 +7,7 @@ |
| 7 | * @Description: | 7 | * @Description: |
| 8 | */ | 8 | */ |
| 9 | import { onMounted, onUnmounted } from 'vue' | 9 | import { onMounted, onUnmounted } from 'vue' |
| 10 | +export { useAuthRedirect } from './useAuthRedirect.js' | ||
| 10 | 11 | ||
| 11 | /** | 12 | /** |
| 12 | * 添加和清除 DOM 事件监听器 | 13 | * 添加和清除 DOM 事件监听器 | ... | ... |
src/composables/useAuthRedirect.js
0 → 100644
| 1 | +/* | ||
| 2 | + * @Date: 2026-05-29 00:00:00 | ||
| 3 | + * @Description: 授权跳转辅助逻辑 | ||
| 4 | + */ | ||
| 5 | +import { useRoute } from 'vue-router'; | ||
| 6 | + | ||
| 7 | +// 授权返回后的首轮路由检查只需要跳过一次,避免刚拿到 openid 又被周期选择打断 | ||
| 8 | +const SKIP_CYCLE_CHECK_FOR_AUTH_KEY = 'skip_cycle_check_for_auth'; | ||
| 9 | +// 只记录“这一轮刚发起过授权”的短期状态,供授权返回后修正浏览器后退行为 | ||
| 10 | +const AUTH_RETURN_GUARD_KEY = 'data-table-auth-return-guard'; | ||
| 11 | +// 只保留很短时间,避免授权链路中断后残留旧标记 | ||
| 12 | +const AUTH_RETURN_GUARD_TTL = 10 * 60 * 1000; | ||
| 13 | + | ||
| 14 | +const toStringQueryValue = (value) => (typeof value === 'string' ? value : ''); | ||
| 15 | + | ||
| 16 | +const getQueryValueFromString = (search, key) => { | ||
| 17 | + if (!search) return ''; | ||
| 18 | + const params = new URLSearchParams(search.startsWith('?') ? search : `?${search}`); | ||
| 19 | + return params.get(key) || ''; | ||
| 20 | +}; | ||
| 21 | + | ||
| 22 | +const safeDecodeURIComponent = (value) => { | ||
| 23 | + try { | ||
| 24 | + return decodeURIComponent(value); | ||
| 25 | + } catch (error) { | ||
| 26 | + return value; | ||
| 27 | + } | ||
| 28 | +}; | ||
| 29 | + | ||
| 30 | +/** | ||
| 31 | + * 消耗一次“跳过首次周期检查”的标记。 | ||
| 32 | + * 命中后会立即清理,确保它只影响授权返回的这一轮路由。 | ||
| 33 | + * @returns {boolean} 是否需要跳过一次周期检查 | ||
| 34 | + */ | ||
| 35 | +export const consumeSkipCycleCheckForAuth = () => { | ||
| 36 | + const shouldSkip = sessionStorage.getItem(SKIP_CYCLE_CHECK_FOR_AUTH_KEY) === 'true'; | ||
| 37 | + if (shouldSkip) { | ||
| 38 | + sessionStorage.removeItem(SKIP_CYCLE_CHECK_FOR_AUTH_KEY); | ||
| 39 | + } | ||
| 40 | + return shouldSkip; | ||
| 41 | +}; | ||
| 42 | + | ||
| 43 | +const clearAuthReturnGuard = () => { | ||
| 44 | + sessionStorage.removeItem(AUTH_RETURN_GUARD_KEY); | ||
| 45 | +}; | ||
| 46 | + | ||
| 47 | +const readAuthReturnGuard = () => { | ||
| 48 | + try { | ||
| 49 | + const state = JSON.parse(sessionStorage.getItem(AUTH_RETURN_GUARD_KEY) || '{}'); | ||
| 50 | + if (!state?.createdAt || Date.now() - state.createdAt > AUTH_RETURN_GUARD_TTL) { | ||
| 51 | + clearAuthReturnGuard(); | ||
| 52 | + return null; | ||
| 53 | + } | ||
| 54 | + return state; | ||
| 55 | + } catch (error) { | ||
| 56 | + clearAuthReturnGuard(); | ||
| 57 | + return null; | ||
| 58 | + } | ||
| 59 | +}; | ||
| 60 | + | ||
| 61 | +/** | ||
| 62 | + * 统一管理授权页与业务页之间共用的跳转能力。 | ||
| 63 | + * 这里不保存“授权成功”长期标记,避免后端授权失效后前端还拿旧状态误判。 | ||
| 64 | + * @param {import('vue-router').RouteLocationNormalizedLoaded} route 当前路由实例 | ||
| 65 | + * @returns {{ | ||
| 66 | + * getQueryValue: (key: string) => string, | ||
| 67 | + * getCurrentTargetHash: () => string, | ||
| 68 | + * getAuthTargetHash: () => string, | ||
| 69 | + * buildOpenIdAuthUrl: (code: string, targetHash?: string) => string, | ||
| 70 | + * redirectToOpenIdAuth: (code: string, targetHash?: string) => void, | ||
| 71 | + * savePendingAuthReturnGuard: (code: string, targetHash?: string) => void, | ||
| 72 | + * consumePendingAuthReturnGuard: (code: string, targetHash?: string) => { historyLength: number } | null, | ||
| 73 | + * installAuthBackHistoryGuard: () => void, | ||
| 74 | + * markSkipCycleCheckForAuth: () => void, | ||
| 75 | + * consumeSkipCycleCheckForAuth: () => boolean | ||
| 76 | + * }} | ||
| 77 | + */ | ||
| 78 | +export function useAuthRedirect(route = useRoute()) { | ||
| 79 | + const getQueryValue = (key) => { | ||
| 80 | + const routeValue = toStringQueryValue(route.query[key]); | ||
| 81 | + if (routeValue) return routeValue; | ||
| 82 | + | ||
| 83 | + // 兼容两类入口: | ||
| 84 | + // 1. hash 路由标准参数:#/path?code=xxx | ||
| 85 | + // 2. 老入口或外部重定向参数:index.html?code=xxx#/path | ||
| 86 | + const searchValue = getQueryValueFromString(location.search, key); | ||
| 87 | + if (searchValue) return searchValue; | ||
| 88 | + | ||
| 89 | + const hash = location.hash || ''; | ||
| 90 | + const hashQueryIndex = hash.indexOf('?'); | ||
| 91 | + if (hashQueryIndex >= 0) { | ||
| 92 | + const hashSearch = hash.slice(hashQueryIndex + 1); | ||
| 93 | + return getQueryValueFromString(hashSearch, key); | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + return ''; | ||
| 97 | + }; | ||
| 98 | + | ||
| 99 | + // 当前表单页在 hash 模式下的完整路由,授权成功后要回到这里 | ||
| 100 | + const getCurrentTargetHash = () => `${location.search}${location.hash || `#${route.fullPath}`}`; | ||
| 101 | + | ||
| 102 | + // 优先读 target,新链路;兼容 href,避免老链接直接失效 | ||
| 103 | + const getAuthTargetHash = () => { | ||
| 104 | + const rawTarget = getQueryValue('target') || getQueryValue('href'); | ||
| 105 | + return rawTarget ? safeDecodeURIComponent(rawTarget) : ''; | ||
| 106 | + }; | ||
| 107 | + | ||
| 108 | + /** | ||
| 109 | + * 统一拼接 openid 授权链接。 | ||
| 110 | + * 新链路会在业务页里直接 replace 到这个地址,避免先进入站内 auth 页留下额外历史记录。 | ||
| 111 | + * @param {string} code 表单 code | ||
| 112 | + * @param {string} targetHash 授权成功后需要回到的 hash 路由 | ||
| 113 | + * @returns {string} 可直接跳转的授权地址 | ||
| 114 | + */ | ||
| 115 | + const buildOpenIdAuthUrl = (code, targetHash = getCurrentTargetHash()) => { | ||
| 116 | + const rawUrl = encodeURIComponent(`${location.origin}${location.pathname}${targetHash}`); | ||
| 117 | + const shortUrl = `/srv/?f=custom_form&a=openid&res=${rawUrl}&form_code=${code}`; | ||
| 118 | + | ||
| 119 | + // 开发环境继续复用旧的 openid 注入方式,避免影响本地调试体验 | ||
| 120 | + if (import.meta.env.DEV) { | ||
| 121 | + return `${shortUrl}&openid=${import.meta.env.VITE_OPENID}`; | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + return shortUrl; | ||
| 125 | + }; | ||
| 126 | + | ||
| 127 | + /** | ||
| 128 | + * 直接跳到后端 openid 授权地址。 | ||
| 129 | + * 使用 location.replace 是为了把“当前未授权页”替换掉,避免浏览器后退时再落回授权中转页。 | ||
| 130 | + * @param {string} code 表单 code | ||
| 131 | + * @param {string} targetHash 授权成功后需要回到的 hash 路由 | ||
| 132 | + */ | ||
| 133 | + const redirectToOpenIdAuth = (code, targetHash = getCurrentTargetHash()) => { | ||
| 134 | + window.location.replace(buildOpenIdAuthUrl(code, targetHash)); | ||
| 135 | + }; | ||
| 136 | + | ||
| 137 | + /** | ||
| 138 | + * 记录一次待返回的授权流程。 | ||
| 139 | + * 这里只保存当前目标页和发起前的历史长度,返回后会立刻消费掉,不作为长期登录态判断。 | ||
| 140 | + * @param {string} code 表单 code | ||
| 141 | + * @param {string} targetHash 授权成功后需要回到的 hash 路由 | ||
| 142 | + */ | ||
| 143 | + const savePendingAuthReturnGuard = (code, targetHash = getCurrentTargetHash()) => { | ||
| 144 | + sessionStorage.setItem( | ||
| 145 | + AUTH_RETURN_GUARD_KEY, | ||
| 146 | + JSON.stringify({ | ||
| 147 | + code, | ||
| 148 | + targetHash, | ||
| 149 | + historyLength: window.history.length, | ||
| 150 | + createdAt: Date.now(), | ||
| 151 | + }), | ||
| 152 | + ); | ||
| 153 | + }; | ||
| 154 | + | ||
| 155 | + /** | ||
| 156 | + * 消费一次授权返回标记。 | ||
| 157 | + * 只有当前页面和发起授权时记录的目标页一致,才认为这是同一轮授权返回。 | ||
| 158 | + * @param {string} code 表单 code | ||
| 159 | + * @param {string} targetHash 当前页面 hash | ||
| 160 | + * @returns {{ historyLength: number } | null} 命中的返回信息 | ||
| 161 | + */ | ||
| 162 | + const consumePendingAuthReturnGuard = (code, targetHash = getCurrentTargetHash()) => { | ||
| 163 | + const state = readAuthReturnGuard(); | ||
| 164 | + if (!state) return null; | ||
| 165 | + | ||
| 166 | + const matched = state.code === code && state.targetHash === targetHash; | ||
| 167 | + clearAuthReturnGuard(); | ||
| 168 | + | ||
| 169 | + if (!matched) { | ||
| 170 | + return null; | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + return { | ||
| 174 | + historyLength: Number(state.historyLength) || 0, | ||
| 175 | + }; | ||
| 176 | + }; | ||
| 177 | + | ||
| 178 | + /** | ||
| 179 | + * 在授权成功回到表单后,临时插入一个“同地址跳板”。 | ||
| 180 | + * 这样用户第一次点浏览器后退时,会先回到这个跳板,然后我们再一次性跳过授权地址回到真正的上一页。 | ||
| 181 | + */ | ||
| 182 | + const installAuthBackHistoryGuard = () => { | ||
| 183 | + const guardKey = `auth-back-guard:${Date.now()}`; | ||
| 184 | + const currentState = window.history.state || {}; | ||
| 185 | + | ||
| 186 | + // 当前记录标成“跳板底座” | ||
| 187 | + window.history.replaceState( | ||
| 188 | + { | ||
| 189 | + ...currentState, | ||
| 190 | + __authBackGuardBase: guardKey, | ||
| 191 | + }, | ||
| 192 | + '', | ||
| 193 | + location.href, | ||
| 194 | + ); | ||
| 195 | + | ||
| 196 | + // 再压入一个相同地址的“顶层占位”,让第一次后退先落到上面的底座状态 | ||
| 197 | + window.history.pushState( | ||
| 198 | + { | ||
| 199 | + ...currentState, | ||
| 200 | + __authBackGuardTop: guardKey, | ||
| 201 | + }, | ||
| 202 | + '', | ||
| 203 | + location.href, | ||
| 204 | + ); | ||
| 205 | + | ||
| 206 | + const handlePopState = (event) => { | ||
| 207 | + if (event.state?.__authBackGuardBase !== guardKey) { | ||
| 208 | + return; | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + window.removeEventListener('popstate', handlePopState); | ||
| 212 | + // 当前位置已经从顶层占位退回到底座了,再退两层即可跨过授权地址回到真实上一页 | ||
| 213 | + window.history.go(-2); | ||
| 214 | + }; | ||
| 215 | + | ||
| 216 | + window.addEventListener('popstate', handlePopState); | ||
| 217 | + }; | ||
| 218 | + | ||
| 219 | + /** | ||
| 220 | + * 标记这次授权流程已经进入外部跳转阶段。 | ||
| 221 | + * 这个标记只用于让路由守卫在授权返回时跳过一次周期检查。 | ||
| 222 | + */ | ||
| 223 | + const markSkipCycleCheckForAuth = () => { | ||
| 224 | + sessionStorage.setItem(SKIP_CYCLE_CHECK_FOR_AUTH_KEY, 'true'); | ||
| 225 | + }; | ||
| 226 | + | ||
| 227 | + return { | ||
| 228 | + getQueryValue, | ||
| 229 | + getCurrentTargetHash, | ||
| 230 | + getAuthTargetHash, | ||
| 231 | + buildOpenIdAuthUrl, | ||
| 232 | + redirectToOpenIdAuth, | ||
| 233 | + savePendingAuthReturnGuard, | ||
| 234 | + consumePendingAuthReturnGuard, | ||
| 235 | + installAuthBackHistoryGuard, | ||
| 236 | + markSkipCycleCheckForAuth, | ||
| 237 | + consumeSkipCycleCheckForAuth, | ||
| 238 | + }; | ||
| 239 | +} |
| ... | @@ -13,6 +13,7 @@ import { Loading } from "vant"; | ... | @@ -13,6 +13,7 @@ import { Loading } from "vant"; |
| 13 | import Cookies from 'js-cookie'; | 13 | import Cookies from 'js-cookie'; |
| 14 | import { showUnfinishedFormDialog, resetDialogState } from '@/utils/dialogControl.js'; | 14 | import { showUnfinishedFormDialog, resetDialogState } from '@/utils/dialogControl.js'; |
| 15 | import { getCycleListAPI } from '@/api/cycle'; | 15 | import { getCycleListAPI } from '@/api/cycle'; |
| 16 | +import { consumeSkipCycleCheckForAuth } from '@/composables/useAuthRedirect.js'; | ||
| 16 | 17 | ||
| 17 | // TAG: 路由配置表 | 18 | // TAG: 路由配置表 |
| 18 | /** | 19 | /** |
| ... | @@ -145,13 +146,8 @@ router.beforeEach((to, from, next) => { | ... | @@ -145,13 +146,8 @@ router.beforeEach((to, from, next) => { |
| 145 | return; | 146 | return; |
| 146 | } | 147 | } |
| 147 | 148 | ||
| 148 | - // 检查是否从授权页面返回,如果是首次访问且可能需要授权,则跳过周期检查 | 149 | + // 授权成功回到业务页后的第一轮路由里,跳过一次周期检查,避免刚拿到 openid 又被打断 |
| 149 | - const isFromAuth = from.path === '/auth'; | 150 | + if (consumeSkipCycleCheckForAuth()) { |
| 150 | - const skipCycleCheck = sessionStorage.getItem('skip_cycle_check_for_auth'); | ||
| 151 | - | ||
| 152 | - // 如果不是从授权页面返回,且设置了跳过标识,则先清除标识并跳过周期检查 | ||
| 153 | - if (!isFromAuth && skipCycleCheck === 'true') { | ||
| 154 | - sessionStorage.removeItem('skip_cycle_check_for_auth'); | ||
| 155 | // 直接执行表单检查逻辑,跳过周期检查 | 151 | // 直接执行表单检查逻辑,跳过周期检查 |
| 156 | if (to.query.page_type === 'add' || to.query.page_type === undefined) { | 152 | if (to.query.page_type === 'add' || to.query.page_type === undefined) { |
| 157 | const existingCookie = Cookies.get(to.query.code); | 153 | const existingCookie = Cookies.get(to.query.code); | ... | ... |
| ... | @@ -11,24 +11,23 @@ | ... | @@ -11,24 +11,23 @@ |
| 11 | 11 | ||
| 12 | <script setup> | 12 | <script setup> |
| 13 | import { onMounted } from 'vue' | 13 | import { onMounted } from 'vue' |
| 14 | -import { useRoute } from 'vue-router' | 14 | +import { useAuthRedirect } from '@/composables' |
| 15 | 15 | ||
| 16 | -const $route = useRoute(); | 16 | +const { |
| 17 | + getAuthTargetHash, | ||
| 18 | + getQueryValue, | ||
| 19 | + redirectToOpenIdAuth, | ||
| 20 | + savePendingAuthReturnGuard, | ||
| 21 | + markSkipCycleCheckForAuth, | ||
| 22 | +} = useAuthRedirect(); | ||
| 17 | 23 | ||
| 18 | onMounted(() => { | 24 | onMounted(() => { |
| 19 | - // php需要先跳转链接获取openid | 25 | + const code = getQueryValue('code'); |
| 20 | - /** | 26 | + const targetHash = getAuthTargetHash(); |
| 21 | - * encodeURIComponent() 函数可把字符串作为 URI 组件进行编码。 | 27 | + // 兼容老的 /auth 入口时,也补上同一套一次性回退保护和周期检查跳过标记 |
| 22 | - * 该方法不会对 ASCII 字母和数字进行编码,也不会对这些 ASCII 标点符号进行编码: - _ . ! ~ * ' ( ) 。 | 28 | + savePendingAuthReturnGuard(code, targetHash || '#/'); |
| 23 | - * 其他字符(比如 :;/?:@&=+$,# 这些用于分隔 URI 组件的标点符号),都是由一个或多个十六进制的转义序列替换的。 | 29 | + markSkipCycleCheckForAuth(); |
| 24 | - */ | 30 | + // 兼容老的 /auth 入口:统一交给 composable 计算并 replace 到后端授权地址 |
| 25 | - let raw_url = encodeURIComponent(location.origin + location.pathname + $route.query.href); // 未授权的地址 | 31 | + redirectToOpenIdAuth(code, targetHash || '#/'); |
| 26 | - // TAG: 开发环境测试数据 | ||
| 27 | - const short_url = `/srv/?f=custom_form&a=openid&res=${raw_url}&form_code=${$route.query.code}`; | ||
| 28 | - // 使用 replace 方法替代 href,避免在浏览器历史中留下记录 | ||
| 29 | - // 这样用户点击后退按钮时不会回到授权页面 | ||
| 30 | - window.location.replace(import.meta.env.DEV | ||
| 31 | - ? `${short_url}&openid=${import.meta.env.VITE_OPENID}` | ||
| 32 | - : `${short_url}`); | ||
| 33 | }) | 32 | }) |
| 34 | </script> | 33 | </script> | ... | ... |
-
Please register or login to post a comment