You need to sign in or sign up before continuing.
hookehuyr

Merge branch 'feature/授权页面优化' into develop

...@@ -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 事件监听器
......
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>
......