From 0ec0e585f7582ba9a2ee9d092f20b2c173b15899 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sat, 18 Jul 2026 03:39:20 +0800 Subject: [PATCH] fix(mobile): distinguish auth failure from HR mismatch on position sync page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:登录态失效(token 冻结/无效,后端返 401)时,同步岗位页把认证错误 误显示成「人力系统中未匹配到当前手机号的在职岗位,请联系 HR」,员工被 困在该页无路可退。 修复: - api.ts: 新增 ApiError(statusCode/bizCode) 类型化错误与 isAuthApiError, readPayload/parsePayloadText/XHR 路径统一抛出带状态码的错误,消息不变 - position.ts: fetchOrgPositionResult 返回四态判别结果 matched/unmatched/unauthorized/error;hydratePositionFromOrg 行为不变 - position/index.vue: 按四态分支处理—— · unauthorized -> 清除登录态并自动回登录页(保留 redirect) · error -> 提示「网络异常…请重试」,保留重试按钮 · unmatched -> 才显示联系 HR 文案 并新增「退出重新登录」链接(确认弹窗后清登录态回登录页), 避免任何异常场景下用户被困无退出路径 验证(构建产物 + 生产 API,Playwright 五场景全过,截图见 .data/visual-audit-20260718/position-fix/): S1 固定码真实登录->今日页(保洁岗匹配) S2 无效token->自动回登录 S3 断网->网络异常提示留在本页 S4 有效token->自动通过 S5 退出链接->确认->回登录且登录态已清 Co-Authored-By: Claude Fable 5 --- mobile-uni/src/pages/auth/position/index.vue | 56 +++++++++++++++++--- mobile-uni/src/services/api.ts | 26 +++++++-- mobile-uni/src/services/position.ts | 28 +++++++--- 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/mobile-uni/src/pages/auth/position/index.vue b/mobile-uni/src/pages/auth/position/index.vue index f8d0021f..93fa8cf5 100644 --- a/mobile-uni/src/pages/auth/position/index.vue +++ b/mobile-uni/src/pages/auth/position/index.vue @@ -27,6 +27,10 @@ + + + 退出重新登录 + @@ -34,12 +38,13 @@ import { computed, ref } from 'vue'; import { onLoad, onShow } from '@dcloudio/uni-app'; import { + clearAuth, getAuth, isLoggedIn, rememberLoginRedirect, type UserPosition } from '@/services/auth'; -import { goToMobileTarget, hydratePositionFromOrg } from '@/services/position'; +import { fetchOrgPositionResult, goToMobileTarget } from '@/services/position'; import { resetPageScroll } from '@/services/navigation'; const redirect = ref('/pages/user/today/index'); @@ -52,22 +57,49 @@ const finish = () => { goToMobileTarget(redirect.value || '/pages/user/today/index'); }; +const goLogin = () => { + rememberLoginRedirect(redirect.value); + uni.redirectTo({ url: '/pages/auth/login/index' }); +}; + +const logoutAndRelogin = () => { + uni.showModal({ + title: '退出重新登录', + content: '将清除当前登录状态并返回登录页,确认退出?', + confirmText: '退出', + cancelText: '再想想', + success: (res) => { + if (!res.confirm) return; + clearAuth(); + goLogin(); + } + }); +}; + const autoFillFromOrg = async () => { if (saving.value) return; if (!isLoggedIn()) { - rememberLoginRedirect(redirect.value); - uni.redirectTo({ url: '/pages/auth/login/index' }); + goLogin(); return; } saving.value = true; message.value = ''; try { - const officialPosition = await hydratePositionFromOrg(); - if (officialPosition) { - selectedPosition.value = officialPosition; + const result = await fetchOrgPositionResult(); + if (result.status === 'matched') { + selectedPosition.value = result.position; finish(); return; } + if (result.status === 'unauthorized') { + clearAuth(); + goLogin(); + return; + } + if (result.status === 'error') { + message.value = '网络异常,暂时没有同步到岗位。请检查网络后点击「重新同步岗位」重试。'; + return; + } message.value = '人力系统中未匹配到当前手机号的在职岗位,请联系 HR 核对手机号、在职状态和岗位信息。'; } finally { saving.value = false; @@ -208,4 +240,16 @@ onShow(() => { .confirm-button[disabled] { opacity: 0.45; } + +.logout-row { + margin-top: 22px; + text-align: center; +} + +.logout-link { + color: #8a93a0; + font-size: 15px; + text-decoration: underline; + padding: 10px 14px; +} diff --git a/mobile-uni/src/services/api.ts b/mobile-uni/src/services/api.ts index 31dfc9c6..c2fe5ee9 100644 --- a/mobile-uni/src/services/api.ts +++ b/mobile-uni/src/services/api.ts @@ -15,6 +15,22 @@ export interface RequestOptions { clearAuthOnUnauthorized?: boolean; } +export class ApiError extends Error { + readonly statusCode: number; + readonly bizCode?: number; + + constructor(message: string, statusCode: number, bizCode?: number) { + super(message); + this.name = 'ApiError'; + this.statusCode = statusCode; + this.bizCode = bizCode; + } +} + +export const isAuthApiError = (error: unknown): boolean => + error instanceof ApiError + && (error.statusCode === 401 || error.statusCode === 403 || error.bizCode === 401 || error.bizCode === 403); + const normalizeUrl = (url: string) => `${apiBase}${url.startsWith('/') ? url : `/${url}`}`; export const apiUrl = normalizeUrl; @@ -35,7 +51,7 @@ export const readPayload = (statusCode: number, data: unknown, clearAuthOnUna if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403 || code === 401 || code === 403)) { clearAuth(); } - throw new Error(payload?.msg || `HTTP ${statusCode}`); + throw new ApiError(payload?.msg || `HTTP ${statusCode}`, statusCode, code); } return payload?.data as T; @@ -47,7 +63,7 @@ export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUn } catch (error) { if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403)) clearAuth(); throw error instanceof SyntaxError - ? new Error(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败') + ? new ApiError(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败', statusCode) : error; } }; @@ -72,8 +88,10 @@ const requestWithXhr = async (options: RequestOptions): Promise => { status: xhr.status, data: parsePayloadText(xhr.status, xhr.responseText || 'null', options.clearAuthOnUnauthorized !== false) }); - } catch { - reject(new Error(xhr.status >= 400 ? `HTTP ${xhr.status}` : '响应解析失败')); + } catch (error) { + reject(error instanceof ApiError + ? error + : new Error(xhr.status >= 400 ? `HTTP ${xhr.status}` : '响应解析失败')); } }; xhr.onerror = () => reject(new Error('网络请求失败')); diff --git a/mobile-uni/src/services/position.ts b/mobile-uni/src/services/position.ts index b6704abe..36d9e575 100644 --- a/mobile-uni/src/services/position.ts +++ b/mobile-uni/src/services/position.ts @@ -1,5 +1,5 @@ import type { MobileIdentity, RoleKey } from '@/types/api'; -import { apiRequest } from './api'; +import { apiRequest, isAuthApiError } from './api'; import { clearOrgPosition, getSelectedPosition, @@ -63,7 +63,13 @@ export const goToMobileTarget = (url: string) => { uni.redirectTo({ url }); }; -export const hydratePositionFromOrg = async (): Promise => { +export type OrgPositionResult = + | { status: 'matched'; position: UserPosition } + | { status: 'unmatched' } + | { status: 'unauthorized' } + | { status: 'error' }; + +export const fetchOrgPositionResult = async (): Promise => { try { const identity = await apiRequest({ url: '/api/aihr/mobile/me', @@ -72,13 +78,23 @@ export const hydratePositionFromOrg = async (): Promise => { clearAuthOnUnauthorized: false }); const officialPosition = saveOrgPosition(identity.positionName); - if (!officialPosition) clearOrgPosition(); - return officialPosition; - } catch { - return hasOrgPosition() ? getSelectedPosition() : ''; + if (!officialPosition) { + clearOrgPosition(); + return { status: 'unmatched' }; + } + return { status: 'matched', position: officialPosition }; + } catch (error) { + return { status: isAuthApiError(error) ? 'unauthorized' : 'error' }; } }; +export const hydratePositionFromOrg = async (): Promise => { + const result = await fetchOrgPositionResult(); + if (result.status === 'matched') return result.position; + if (result.status === 'unmatched') return ''; + return hasOrgPosition() ? getSelectedPosition() : ''; +}; + export const nextUserRouteAfterLogin = async (redirect: string) => { const role = await hydrateMobileRole(); const roleRoute = routeForMobileRole(role, redirect);