Showing
5 changed files
with
283 additions
and
135 deletions
| ... | @@ -9,9 +9,10 @@ import { createApp } from 'vue' | ... | @@ -9,9 +9,10 @@ import { createApp } from 'vue' |
| 9 | import './style.css' | 9 | import './style.css' |
| 10 | import App from './App.vue' | 10 | import App from './App.vue' |
| 11 | import router from './router' | 11 | import router from './router' |
| 12 | -import axios from '@/utils/axios'; | 12 | +import axios from '@/utils/axios' |
| 13 | +import { restoreHashAfterOAuth } from '@/utils/oauthHashRestore' | ||
| 13 | import 'vant/lib/index.css' | 14 | import 'vant/lib/index.css' |
| 14 | -import '@vant/touch-emulator'; | 15 | +import '@vant/touch-emulator' |
| 15 | 16 | ||
| 16 | /* import the fontawesome core */ | 17 | /* import the fontawesome core */ |
| 17 | import { library } from '@fortawesome/fontawesome-svg-core' | 18 | import { library } from '@fortawesome/fontawesome-svg-core' |
| ... | @@ -19,47 +20,80 @@ import { library } from '@fortawesome/fontawesome-svg-core' | ... | @@ -19,47 +20,80 @@ import { library } from '@fortawesome/fontawesome-svg-core' |
| 19 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' | 20 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' |
| 20 | import { Icon as IconifyIcon } from '@iconify/vue' | 21 | import { Icon as IconifyIcon } from '@iconify/vue' |
| 21 | /* import specific icons */ | 22 | /* import specific icons */ |
| 22 | -import { faCirclePause, faCirclePlay, faPlay, faPause, faBackwardStep, faForwardStep, faVolumeUp, faRedo, faRepeat, faList, faChevronDown, faVolumeOff, faXmark, faFileAlt, faTimes, faEye, faFilePdf, faExternalLinkAlt, faSpinner, faExclamationCircle, faDownload, faVenus, faMars, faMagnifyingGlassPlus, faMagnifyingGlassMinus } from '@fortawesome/free-solid-svg-icons' | 23 | +import { |
| 24 | + faCirclePause, | ||
| 25 | + faCirclePlay, | ||
| 26 | + faPlay, | ||
| 27 | + faPause, | ||
| 28 | + faBackwardStep, | ||
| 29 | + faForwardStep, | ||
| 30 | + faVolumeUp, | ||
| 31 | + faRedo, | ||
| 32 | + faRepeat, | ||
| 33 | + faList, | ||
| 34 | + faChevronDown, | ||
| 35 | + faVolumeOff, | ||
| 36 | + faXmark, | ||
| 37 | + faFileAlt, | ||
| 38 | + faTimes, | ||
| 39 | + faEye, | ||
| 40 | + faFilePdf, | ||
| 41 | + faExternalLinkAlt, | ||
| 42 | + faSpinner, | ||
| 43 | + faExclamationCircle, | ||
| 44 | + faDownload, | ||
| 45 | + faVenus, | ||
| 46 | + faMars, | ||
| 47 | + faMagnifyingGlassPlus, | ||
| 48 | + faMagnifyingGlassMinus, | ||
| 49 | +} from '@fortawesome/free-solid-svg-icons' | ||
| 23 | 50 | ||
| 24 | /* add icons to the library */ | 51 | /* add icons to the library */ |
| 25 | -library.add(faCirclePause, faCirclePlay, faPlay, faPause, faBackwardStep, faForwardStep, faVolumeUp, faRedo, faRepeat, faList, faChevronDown, faVolumeOff, faXmark, faFileAlt, faTimes, faEye, faFilePdf, faExternalLinkAlt, faSpinner, faExclamationCircle, faDownload, faVenus, faMars, faMagnifyingGlassPlus, faMagnifyingGlassMinus ) | 52 | +library.add( |
| 53 | + faCirclePause, | ||
| 54 | + faCirclePlay, | ||
| 55 | + faPlay, | ||
| 56 | + faPause, | ||
| 57 | + faBackwardStep, | ||
| 58 | + faForwardStep, | ||
| 59 | + faVolumeUp, | ||
| 60 | + faRedo, | ||
| 61 | + faRepeat, | ||
| 62 | + faList, | ||
| 63 | + faChevronDown, | ||
| 64 | + faVolumeOff, | ||
| 65 | + faXmark, | ||
| 66 | + faFileAlt, | ||
| 67 | + faTimes, | ||
| 68 | + faEye, | ||
| 69 | + faFilePdf, | ||
| 70 | + faExternalLinkAlt, | ||
| 71 | + faSpinner, | ||
| 72 | + faExclamationCircle, | ||
| 73 | + faDownload, | ||
| 74 | + faVenus, | ||
| 75 | + faMars, | ||
| 76 | + faMagnifyingGlassPlus, | ||
| 77 | + faMagnifyingGlassMinus | ||
| 78 | +) | ||
| 26 | 79 | ||
| 27 | if (!Array.prototype.at) { | 80 | if (!Array.prototype.at) { |
| 28 | - Array.prototype.at = function(n) { | 81 | + Array.prototype.at = function (n) { |
| 29 | - n = Math.trunc(n) || 0; | 82 | + n = Math.trunc(n) || 0 |
| 30 | - if (n < 0) n += this.length; | 83 | + if (n < 0) n += this.length |
| 31 | - if (n < 0 || n >= this.length) return undefined; | 84 | + if (n < 0 || n >= this.length) return undefined |
| 32 | - return this[n]; | 85 | + return this[n] |
| 33 | - }; | 86 | + } |
| 34 | } | 87 | } |
| 35 | const app = createApp(App) | 88 | const app = createApp(App) |
| 36 | app.component('Icon', IconifyIcon) | 89 | app.component('Icon', IconifyIcon) |
| 37 | -app.component('font-awesome-icon', FontAwesomeIcon) | 90 | +app.component('FontAwesomeIcon', FontAwesomeIcon) |
| 38 | // 屏蔽警告信息 | 91 | // 屏蔽警告信息 |
| 39 | -app.config.warnHandler = () => null; | 92 | +app.config.warnHandler = () => null |
| 40 | - | ||
| 41 | -app.config.globalProperties.$http = axios; // 关键语句 | ||
| 42 | -/** | ||
| 43 | - * @function restoreHashAfterOAuth | ||
| 44 | - * @description 前端复原 OAuth 回跳的 hash 路由位置:当 URL 中存在 ret_hash 参数且当前无 hash 时,将其拼回地址栏。 | ||
| 45 | - * @returns {void} | ||
| 46 | - */ | ||
| 47 | -// function restoreHashAfterOAuth() { | ||
| 48 | -// const url = new URL(window.location.href); | ||
| 49 | -// const ret_hash = url.searchParams.get('ret_hash'); | ||
| 50 | -// if (ret_hash && !window.location.hash) { | ||
| 51 | -// // 删除 ret_hash,保留其他查询参数 | ||
| 52 | -// url.searchParams.delete('ret_hash'); | ||
| 53 | -// const base = url.toString().split('#')[0]; | ||
| 54 | -// const new_url = base + ret_hash; | ||
| 55 | -// // 使用 replaceState 避免再次刷新与历史记录污染 | ||
| 56 | -// window.history.replaceState(null, '', new_url); | ||
| 57 | -// } | ||
| 58 | -// } | ||
| 59 | -// void restoreHashAfterOAuth | ||
| 60 | 93 | ||
| 94 | +app.config.globalProperties.$http = axios // 关键语句 | ||
| 61 | // 在安装路由前进行一次 hash 复原,确保初始路由正确 | 95 | // 在安装路由前进行一次 hash 复原,确保初始路由正确 |
| 62 | -// restoreHashAfterOAuth() | 96 | +restoreHashAfterOAuth() |
| 63 | app.use(router) | 97 | app.use(router) |
| 64 | 98 | ||
| 65 | // 开发环境添加欢迎页调试工具 | 99 | // 开发环境添加欢迎页调试工具 | ... | ... |
| ... | @@ -6,34 +6,35 @@ | ... | @@ -6,34 +6,35 @@ |
| 6 | * @Description: 路由守卫逻辑 | 6 | * @Description: 路由守卫逻辑 |
| 7 | */ | 7 | */ |
| 8 | import { getAuthInfoAPI } from '@/api/auth' | 8 | import { getAuthInfoAPI } from '@/api/auth' |
| 9 | -import { wxInfo } from "@/utils/tools" | 9 | +import { buildOAuthAuthorizeUrl } from '@/utils/oauthHashRestore' |
| 10 | +import { wxInfo } from '@/utils/tools' | ||
| 10 | 11 | ||
| 11 | // TAG: 需要登录才能访问的路由 | 12 | // TAG: 需要登录才能访问的路由 |
| 12 | export const authRequiredRoutes = [ | 13 | export const authRequiredRoutes = [ |
| 13 | - { | 14 | + { |
| 14 | - path: '/profile', | 15 | + path: '/profile', |
| 15 | - exact: false, | 16 | + exact: false, |
| 16 | - }, | 17 | + }, |
| 17 | - { | 18 | + { |
| 18 | - path: '/checkout', | 19 | + path: '/checkout', |
| 19 | - exact: true, | 20 | + exact: true, |
| 20 | - }, | 21 | + }, |
| 21 | - { | 22 | + { |
| 22 | - path: '/activities/[^/]+/signup', | 23 | + path: '/activities/[^/]+/signup', |
| 23 | - regex: true, | 24 | + regex: true, |
| 24 | - }, | 25 | + }, |
| 25 | - { | 26 | + { |
| 26 | - path: '/checkin', | 27 | + path: '/checkin', |
| 27 | - exact: false, | 28 | + exact: false, |
| 28 | - }, | 29 | + }, |
| 29 | - { | 30 | + { |
| 30 | - path: '/teacher', | 31 | + path: '/teacher', |
| 31 | - exact: false, | 32 | + exact: false, |
| 32 | - }, | 33 | + }, |
| 33 | - { | 34 | + { |
| 34 | - path: '/studyDetail', | 35 | + path: '/studyDetail', |
| 35 | - exact: false, | 36 | + exact: false, |
| 36 | - }, | 37 | + }, |
| 37 | ] | 38 | ] |
| 38 | 39 | ||
| 39 | /** | 40 | /** |
| ... | @@ -41,17 +42,17 @@ export const authRequiredRoutes = [ | ... | @@ -41,17 +42,17 @@ export const authRequiredRoutes = [ |
| 41 | * @returns {boolean} 始终返回true,不在路由守卫内自动触发授权 | 42 | * @returns {boolean} 始终返回true,不在路由守卫内自动触发授权 |
| 42 | */ | 43 | */ |
| 43 | export const checkWxAuth = async () => { | 44 | export const checkWxAuth = async () => { |
| 44 | - // 说明:根据新业务需求,微信授权不应在路由守卫中自动触发 | 45 | + // 说明:根据新业务需求,微信授权不应在路由守卫中自动触发 |
| 45 | - // 此函数保留以兼容旧代码调用,但不再进行重定向 | 46 | + // 此函数保留以兼容旧代码调用,但不再进行重定向 |
| 46 | - try { | 47 | + try { |
| 47 | - if (!import.meta.env.DEV && wxInfo().isWeiXin) { | 48 | + if (!import.meta.env.DEV && wxInfo().isWeiXin) { |
| 48 | - // 仅做一次授权状态探测,避免无意义请求 | 49 | + // 仅做一次授权状态探测,避免无意义请求 |
| 49 | - await getAuthInfoAPI(); | 50 | + await getAuthInfoAPI() |
| 50 | - } | ||
| 51 | - } catch (error) { | ||
| 52 | - // 忽略授权探测错误,不影响后续流程 | ||
| 53 | } | 51 | } |
| 54 | - return true; | 52 | + } catch (error) { |
| 53 | + // 忽略授权探测错误,不影响后续流程 | ||
| 54 | + } | ||
| 55 | + return true | ||
| 55 | } | 56 | } |
| 56 | 57 | ||
| 57 | /** | 58 | /** |
| ... | @@ -64,32 +65,36 @@ export const checkWxAuth = async () => { | ... | @@ -64,32 +65,36 @@ export const checkWxAuth = async () => { |
| 64 | * @returns {void} | 65 | * @returns {void} |
| 65 | */ | 66 | */ |
| 66 | export const startWxAuth = async () => { | 67 | export const startWxAuth = async () => { |
| 67 | - // 开发环境不触发微信授权 | 68 | + // 开发环境不触发微信授权 |
| 68 | - if (import.meta.env.DEV) { | 69 | + if (import.meta.env.DEV) { |
| 69 | - // 开发环境下不触发微信授权登录 | 70 | + // 开发环境下不触发微信授权登录 |
| 70 | - return; | 71 | + return |
| 72 | + } | ||
| 73 | + | ||
| 74 | + const info = wxInfo() | ||
| 75 | + // 非微信环境不进行授权跳转 | ||
| 76 | + if (!info.isWeiXin) { | ||
| 77 | + return | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + // 如果已授权则不跳转;否则进入授权页 | ||
| 81 | + try { | ||
| 82 | + const { code, data } = await getAuthInfoAPI() | ||
| 83 | + if (code && data.openid_has) { | ||
| 84 | + return | ||
| 71 | } | 85 | } |
| 72 | - | 86 | + } catch (e) { |
| 73 | - const info = wxInfo(); | 87 | + // 探测失败不影响授权流程,继续跳转 |
| 74 | - // 非微信环境不进行授权跳转 | 88 | + } |
| 75 | - if (!info.isWeiXin) { | 89 | + |
| 76 | - return; | 90 | + // 跳转到微信授权地址。 |
| 77 | - } | 91 | + // OAuth 回跳链路对 hash 路由并不稳定,这里显式拆分 base_url 与 ret_hash, |
| 78 | - | 92 | + // 由前端在 main.js 安装路由前进行一次 hash 复原。 |
| 79 | - // 如果已授权则不跳转;否则进入授权页 | 93 | + const short_url = buildOAuthAuthorizeUrl(window.location.href) |
| 80 | - try { | 94 | + if (!short_url) { |
| 81 | - const { code, data } = await getAuthInfoAPI(); | 95 | + return |
| 82 | - if (code && data.openid_has) { | 96 | + } |
| 83 | - return; | 97 | + location.href = short_url |
| 84 | - } | ||
| 85 | - } catch (e) { | ||
| 86 | - // 探测失败不影响授权流程,继续跳转 | ||
| 87 | - } | ||
| 88 | - | ||
| 89 | - // 跳转到微信授权地址 | ||
| 90 | - const raw_url = encodeURIComponent(location.href); | ||
| 91 | - const short_url = `/srv/?f=behalo&a=openid&res=${raw_url}`; | ||
| 92 | - location.href = short_url; | ||
| 93 | } | 98 | } |
| 94 | 99 | ||
| 95 | // 首次访问标志 | 100 | // 首次访问标志 |
| ... | @@ -100,9 +105,7 @@ const WELCOME_VISITED_AT = 'welcome_visited_at' | ... | @@ -100,9 +105,7 @@ const WELCOME_VISITED_AT = 'welcome_visited_at' |
| 100 | * @description 检查用户是否已访问过欢迎页 | 105 | * @description 检查用户是否已访问过欢迎页 |
| 101 | * @returns {boolean} | 106 | * @returns {boolean} |
| 102 | */ | 107 | */ |
| 103 | -export const hasVisitedWelcome = () => { | 108 | +export const hasVisitedWelcome = () => localStorage.getItem(HAS_VISITED_WELCOME) === 'true' |
| 104 | - return localStorage.getItem(HAS_VISITED_WELCOME) === 'true' | ||
| 105 | -} | ||
| 106 | 109 | ||
| 107 | /** | 110 | /** |
| 108 | * @description 标记用户已访问欢迎页 | 111 | * @description 标记用户已访问欢迎页 |
| ... | @@ -124,42 +127,41 @@ export const resetWelcomeFlag = () => { | ... | @@ -124,42 +127,41 @@ export const resetWelcomeFlag = () => { |
| 124 | 127 | ||
| 125 | // 检查用户是否已登录 | 128 | // 检查用户是否已登录 |
| 126 | 129 | ||
| 127 | - | ||
| 128 | /** | 130 | /** |
| 129 | * @description 登录权限检查,未登录时重定向到登录页 | 131 | * @description 登录权限检查,未登录时重定向到登录页 |
| 130 | * @param {*} to 目标路由对象 | 132 | * @param {*} to 目标路由对象 |
| 131 | * @returns {true|Object} 允许通过或返回重定向对象 | 133 | * @returns {true|Object} 允许通过或返回重定向对象 |
| 132 | */ | 134 | */ |
| 133 | -export const checkAuth = (to) => { | 135 | +export const checkAuth = to => { |
| 134 | - const currentUser = JSON.parse(localStorage.getItem('currentUser')) | 136 | + const currentUser = JSON.parse(localStorage.getItem('currentUser')) |
| 135 | - | 137 | + |
| 136 | - // 检查当前路由是否需要认证 | 138 | + // 检查当前路由是否需要认证 |
| 137 | - // 方式一:白名单匹配(兼容旧逻辑) | 139 | + // 方式一:白名单匹配(兼容旧逻辑) |
| 138 | - const needAuthByList = authRequiredRoutes.some((route) => { | 140 | + const needAuthByList = authRequiredRoutes.some(route => { |
| 139 | - // 如果是正则匹配模式 | 141 | + // 如果是正则匹配模式 |
| 140 | - if (route.regex) { | 142 | + if (route.regex) { |
| 141 | - return new RegExp(`^${route.path}$`).test(to.path) | 143 | + return new RegExp(`^${route.path}$`).test(to.path) |
| 142 | - } | ||
| 143 | - // 如果是精确匹配模式 | ||
| 144 | - if (route.exact) { | ||
| 145 | - return to.path === route.path | ||
| 146 | - } | ||
| 147 | - // 默认前缀匹配模式 | ||
| 148 | - return to.path.startsWith(route.path) | ||
| 149 | - }) | ||
| 150 | - // 方式二:读取路由元信息 requiresAuth(推荐) | ||
| 151 | - // 注意:axios 拦截器中调用时,to 可能不是完整的 Route 对象,可能缺少 matched 属性 | ||
| 152 | - // 如果没有 matched 属性,则回退到仅依赖 path 的白名单匹配 | ||
| 153 | - const needAuthByMeta = to.matched | ||
| 154 | - ? to.matched.some(record => record.meta && record.meta.requiresAuth === true) | ||
| 155 | - : false; | ||
| 156 | - | ||
| 157 | - const needAuth = needAuthByList || needAuthByMeta | ||
| 158 | - | ||
| 159 | - if (needAuth && !currentUser) { | ||
| 160 | - // 未登录时重定向到登录页面 | ||
| 161 | - return { path: '/login', query: { redirect: to.fullPath } } | ||
| 162 | } | 144 | } |
| 163 | - | 145 | + // 如果是精确匹配模式 |
| 164 | - return true | 146 | + if (route.exact) { |
| 147 | + return to.path === route.path | ||
| 148 | + } | ||
| 149 | + // 默认前缀匹配模式 | ||
| 150 | + return to.path.startsWith(route.path) | ||
| 151 | + }) | ||
| 152 | + // 方式二:读取路由元信息 requiresAuth(推荐) | ||
| 153 | + // 注意:axios 拦截器中调用时,to 可能不是完整的 Route 对象,可能缺少 matched 属性 | ||
| 154 | + // 如果没有 matched 属性,则回退到仅依赖 path 的白名单匹配 | ||
| 155 | + const needAuthByMeta = to.matched | ||
| 156 | + ? to.matched.some(record => record.meta && record.meta.requiresAuth === true) | ||
| 157 | + : false | ||
| 158 | + | ||
| 159 | + const needAuth = needAuthByList || needAuthByMeta | ||
| 160 | + | ||
| 161 | + if (needAuth && !currentUser) { | ||
| 162 | + // 未登录时重定向到登录页面 | ||
| 163 | + return { path: '/login', query: { redirect: to.fullPath } } | ||
| 164 | + } | ||
| 165 | + | ||
| 166 | + return true | ||
| 165 | } | 167 | } | ... | ... |
src/utils/__tests__/oauthHashRestore.test.js
0 → 100644
| 1 | +import { describe, expect, it } from 'vitest' | ||
| 2 | + | ||
| 3 | +import { buildOAuthAuthorizeUrl, getOAuthRestoredUrl } from '../oauthHashRestore' | ||
| 4 | + | ||
| 5 | +describe('oauthHashRestore', () => { | ||
| 6 | + it('should build oauth authorize url with split base and hash', () => { | ||
| 7 | + const href = 'https://wxm.behalo.cc/f/mlaj/#/studyDetail/3552321?from_course_id=2995248' | ||
| 8 | + | ||
| 9 | + expect(buildOAuthAuthorizeUrl(href)).toBe( | ||
| 10 | + '/srv/?f=behalo&a=openid&res=https%3A%2F%2Fwxm.behalo.cc%2Ff%2Fmlaj%2F&ret_hash=%23%2FstudyDetail%2F3552321%3Ffrom_course_id%3D2995248' | ||
| 11 | + ) | ||
| 12 | + }) | ||
| 13 | + | ||
| 14 | + it('should append test_openid in dev mode', () => { | ||
| 15 | + const href = 'https://wxm.behalo.cc/f/mlaj/#/studyDetail/3552321' | ||
| 16 | + | ||
| 17 | + expect(buildOAuthAuthorizeUrl(href, { is_dev: true, test_openid: 'abc123' })).toBe( | ||
| 18 | + '/srv/?f=behalo&a=openid&res=https%3A%2F%2Fwxm.behalo.cc%2Ff%2Fmlaj%2F&ret_hash=%23%2FstudyDetail%2F3552321&test_openid=abc123' | ||
| 19 | + ) | ||
| 20 | + }) | ||
| 21 | + | ||
| 22 | + it('should restore ret_hash when current url has no hash', () => { | ||
| 23 | + const href = | ||
| 24 | + 'https://wxm.behalo.cc/f/mlaj/?code=abc&state=1&ret_hash=%23%2Flogin%3Fredirect%3D%252FstudyDetail%252F3552321%253Ffrom_course_id%253D2995248' | ||
| 25 | + | ||
| 26 | + expect(getOAuthRestoredUrl(href)).toBe( | ||
| 27 | + 'https://wxm.behalo.cc/f/mlaj/?code=abc&state=1#/login?redirect=%2FstudyDetail%2F3552321%3Ffrom_course_id%3D2995248' | ||
| 28 | + ) | ||
| 29 | + }) | ||
| 30 | + | ||
| 31 | + it('should not override an existing hash', () => { | ||
| 32 | + const href = | ||
| 33 | + 'https://wxm.behalo.cc/f/mlaj/?ret_hash=%23%2FstudyDetail%2F3552321#/checkin/index?id=3189684' | ||
| 34 | + | ||
| 35 | + expect(getOAuthRestoredUrl(href)).toBeNull() | ||
| 36 | + }) | ||
| 37 | + | ||
| 38 | + it('should return null when ret_hash is missing', () => { | ||
| 39 | + const href = 'https://wxm.behalo.cc/f/mlaj/?code=abc&state=1' | ||
| 40 | + | ||
| 41 | + expect(getOAuthRestoredUrl(href)).toBeNull() | ||
| 42 | + }) | ||
| 43 | + | ||
| 44 | + it('should return null for invalid authorize target', () => { | ||
| 45 | + expect(buildOAuthAuthorizeUrl('')).toBeNull() | ||
| 46 | + }) | ||
| 47 | +}) |
src/utils/oauthHashRestore.js
0 → 100644
| 1 | +/** | ||
| 2 | + * @description 从 OAuth 回跳 URL 中恢复 hash 路由。 | ||
| 3 | + * 某些回跳链路只会稳定保留 base URL,因此需要借助 ret_hash 在前端复原原始 hash。 | ||
| 4 | + * @param {string} href 当前完整地址 | ||
| 5 | + * @returns {string|null} 需要替换的新地址;无需处理时返回 null | ||
| 6 | + */ | ||
| 7 | +export const getOAuthRestoredUrl = href => { | ||
| 8 | + if (!href || typeof href !== 'string') return null | ||
| 9 | + | ||
| 10 | + let url = null | ||
| 11 | + try { | ||
| 12 | + url = new URL(href) | ||
| 13 | + } catch (error) { | ||
| 14 | + return null | ||
| 15 | + } | ||
| 16 | + | ||
| 17 | + const ret_hash = url.searchParams.get('ret_hash') | ||
| 18 | + if (!ret_hash || url.hash) return null | ||
| 19 | + | ||
| 20 | + url.searchParams.delete('ret_hash') | ||
| 21 | + const base = url.toString().split('#')[0] | ||
| 22 | + return `${base}${ret_hash}` | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +/** | ||
| 26 | + * @description 构造微信 OAuth 授权地址,显式拆分 base_url 与 hash。 | ||
| 27 | + * @param {string} target_href 授权前的目标地址 | ||
| 28 | + * @param {{ is_dev?: boolean, test_openid?: string }} [options] 额外选项 | ||
| 29 | + * @returns {string|null} 可直接跳转的授权地址;输入非法时返回 null | ||
| 30 | + */ | ||
| 31 | +export const buildOAuthAuthorizeUrl = (target_href, options = {}) => { | ||
| 32 | + if (!target_href || typeof target_href !== 'string') return null | ||
| 33 | + | ||
| 34 | + const [base_part, ...hash_parts] = target_href.split('#') | ||
| 35 | + if (!base_part) return null | ||
| 36 | + | ||
| 37 | + const ret_hash = hash_parts.length ? `#${hash_parts.join('#')}` : '' | ||
| 38 | + const base_url = encodeURIComponent(base_part) | ||
| 39 | + const encoded_hash = encodeURIComponent(ret_hash) | ||
| 40 | + | ||
| 41 | + let short_url = `/srv/?f=behalo&a=openid&res=${base_url}&ret_hash=${encoded_hash}` | ||
| 42 | + if (options.is_dev && options.test_openid) { | ||
| 43 | + short_url += `&test_openid=${encodeURIComponent(options.test_openid)}` | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + return short_url | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +/** | ||
| 50 | + * @description 在应用初始化前尝试恢复 OAuth 回跳丢失的 hash。 | ||
| 51 | + * @returns {boolean} 是否发生了地址恢复 | ||
| 52 | + */ | ||
| 53 | +export const restoreHashAfterOAuth = () => { | ||
| 54 | + if (typeof window === 'undefined' || typeof window.location === 'undefined') { | ||
| 55 | + return false | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + const next_url = getOAuthRestoredUrl(window.location.href) | ||
| 59 | + if (!next_url) return false | ||
| 60 | + | ||
| 61 | + window.history.replaceState(null, '', next_url) | ||
| 62 | + return true | ||
| 63 | +} |
| ... | @@ -12,8 +12,9 @@ | ... | @@ -12,8 +12,9 @@ |
| 12 | <script setup> | 12 | <script setup> |
| 13 | import { onMounted } from 'vue' | 13 | import { onMounted } from 'vue' |
| 14 | import { useRoute } from 'vue-router' | 14 | import { useRoute } from 'vue-router' |
| 15 | +import { buildOAuthAuthorizeUrl } from '@/utils/oauthHashRestore' | ||
| 15 | 16 | ||
| 16 | -const $route = useRoute(); | 17 | +const $route = useRoute() |
| 17 | 18 | ||
| 18 | onMounted(() => { | 19 | onMounted(() => { |
| 19 | // php需要先跳转链接获取openid | 20 | // php需要先跳转链接获取openid |
| ... | @@ -22,11 +23,12 @@ onMounted(() => { | ... | @@ -22,11 +23,12 @@ onMounted(() => { |
| 22 | * 该方法不会对 ASCII 字母和数字进行编码,也不会对这些 ASCII 标点符号进行编码: - _ . ! ~ * ' ( ) 。 | 23 | * 该方法不会对 ASCII 字母和数字进行编码,也不会对这些 ASCII 标点符号进行编码: - _ . ! ~ * ' ( ) 。 |
| 23 | * 其他字符(比如 :;/?:@&=+$,# 这些用于分隔 URI 组件的标点符号),都是由一个或多个十六进制的转义序列替换的。 | 24 | * 其他字符(比如 :;/?:@&=+$,# 这些用于分隔 URI 组件的标点符号),都是由一个或多个十六进制的转义序列替换的。 |
| 24 | */ | 25 | */ |
| 25 | - let raw_url = encodeURIComponent(location.origin + location.pathname + $route.query.href); // 未授权的地址 | 26 | + const target_href = `${location.origin}${location.pathname}${String($route.query.href || '')}` |
| 26 | - // TAG: 开发环境测试数据 | 27 | + const short_url = buildOAuthAuthorizeUrl(target_href, { |
| 27 | - const short_url = `/srv/?f=behalo&a=openid&res=${raw_url}`; | 28 | + is_dev: import.meta.env.DEV, |
| 28 | - location.href = import.meta.env.DEV | 29 | + test_openid: import.meta.env.VITE_OPENID, |
| 29 | - ? `${short_url}&test_openid=${import.meta.env.VITE_OPENID}` | 30 | + }) |
| 30 | - : `${short_url}`; | 31 | + if (!short_url) return |
| 32 | + location.href = short_url | ||
| 31 | }) | 33 | }) |
| 32 | </script> | 34 | </script> | ... | ... |
-
Please register or login to post a comment