fix(mobile): distinguish auth failure from HR mismatch on position sync page
问题:登录态失效(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 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,10 @@
|
|||||||
<button class="confirm-button" :disabled="saving" @click="autoFillFromOrg">
|
<button class="confirm-button" :disabled="saving" @click="autoFillFromOrg">
|
||||||
{{ saving ? '同步中…' : '重新同步岗位' }}
|
{{ saving ? '同步中…' : '重新同步岗位' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<view class="logout-row">
|
||||||
|
<text class="logout-link" @click="logoutAndRelogin">退出重新登录</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -34,12 +38,13 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||||
import {
|
import {
|
||||||
|
clearAuth,
|
||||||
getAuth,
|
getAuth,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
rememberLoginRedirect,
|
rememberLoginRedirect,
|
||||||
type UserPosition
|
type UserPosition
|
||||||
} from '@/services/auth';
|
} from '@/services/auth';
|
||||||
import { goToMobileTarget, hydratePositionFromOrg } from '@/services/position';
|
import { fetchOrgPositionResult, goToMobileTarget } from '@/services/position';
|
||||||
import { resetPageScroll } from '@/services/navigation';
|
import { resetPageScroll } from '@/services/navigation';
|
||||||
|
|
||||||
const redirect = ref('/pages/user/today/index');
|
const redirect = ref('/pages/user/today/index');
|
||||||
@@ -52,22 +57,49 @@ const finish = () => {
|
|||||||
goToMobileTarget(redirect.value || '/pages/user/today/index');
|
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 () => {
|
const autoFillFromOrg = async () => {
|
||||||
if (saving.value) return;
|
if (saving.value) return;
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
rememberLoginRedirect(redirect.value);
|
goLogin();
|
||||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
try {
|
try {
|
||||||
const officialPosition = await hydratePositionFromOrg();
|
const result = await fetchOrgPositionResult();
|
||||||
if (officialPosition) {
|
if (result.status === 'matched') {
|
||||||
selectedPosition.value = officialPosition;
|
selectedPosition.value = result.position;
|
||||||
finish();
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.status === 'unauthorized') {
|
||||||
|
clearAuth();
|
||||||
|
goLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === 'error') {
|
||||||
|
message.value = '网络异常,暂时没有同步到岗位。请检查网络后点击「重新同步岗位」重试。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
message.value = '人力系统中未匹配到当前手机号的在职岗位,请联系 HR 核对手机号、在职状态和岗位信息。';
|
message.value = '人力系统中未匹配到当前手机号的在职岗位,请联系 HR 核对手机号、在职状态和岗位信息。';
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
@@ -208,4 +240,16 @@ onShow(() => {
|
|||||||
.confirm-button[disabled] {
|
.confirm-button[disabled] {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logout-row {
|
||||||
|
margin-top: 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-link {
|
||||||
|
color: #8a93a0;
|
||||||
|
font-size: 15px;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,6 +15,22 @@ export interface RequestOptions {
|
|||||||
clearAuthOnUnauthorized?: boolean;
|
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}`}`;
|
const normalizeUrl = (url: string) => `${apiBase}${url.startsWith('/') ? url : `/${url}`}`;
|
||||||
|
|
||||||
export const apiUrl = normalizeUrl;
|
export const apiUrl = normalizeUrl;
|
||||||
@@ -35,7 +51,7 @@ export const readPayload = <T>(statusCode: number, data: unknown, clearAuthOnUna
|
|||||||
if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403 || code === 401 || code === 403)) {
|
if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403 || code === 401 || code === 403)) {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
}
|
}
|
||||||
throw new Error(payload?.msg || `HTTP ${statusCode}`);
|
throw new ApiError(payload?.msg || `HTTP ${statusCode}`, statusCode, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload?.data as T;
|
return payload?.data as T;
|
||||||
@@ -47,7 +63,7 @@ export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUn
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403)) clearAuth();
|
if (clearAuthOnUnauthorized && (statusCode === 401 || statusCode === 403)) clearAuth();
|
||||||
throw error instanceof SyntaxError
|
throw error instanceof SyntaxError
|
||||||
? new Error(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败')
|
? new ApiError(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败', statusCode)
|
||||||
: error;
|
: error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -72,8 +88,10 @@ const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
|
|||||||
status: xhr.status,
|
status: xhr.status,
|
||||||
data: parsePayloadText(xhr.status, xhr.responseText || 'null', options.clearAuthOnUnauthorized !== false)
|
data: parsePayloadText(xhr.status, xhr.responseText || 'null', options.clearAuthOnUnauthorized !== false)
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
reject(new Error(xhr.status >= 400 ? `HTTP ${xhr.status}` : '响应解析失败'));
|
reject(error instanceof ApiError
|
||||||
|
? error
|
||||||
|
: new Error(xhr.status >= 400 ? `HTTP ${xhr.status}` : '响应解析失败'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
xhr.onerror = () => reject(new Error('网络请求失败'));
|
xhr.onerror = () => reject(new Error('网络请求失败'));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MobileIdentity, RoleKey } from '@/types/api';
|
import type { MobileIdentity, RoleKey } from '@/types/api';
|
||||||
import { apiRequest } from './api';
|
import { apiRequest, isAuthApiError } from './api';
|
||||||
import {
|
import {
|
||||||
clearOrgPosition,
|
clearOrgPosition,
|
||||||
getSelectedPosition,
|
getSelectedPosition,
|
||||||
@@ -63,7 +63,13 @@ export const goToMobileTarget = (url: string) => {
|
|||||||
uni.redirectTo({ url });
|
uni.redirectTo({ url });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hydratePositionFromOrg = async (): Promise<UserPosition | ''> => {
|
export type OrgPositionResult =
|
||||||
|
| { status: 'matched'; position: UserPosition }
|
||||||
|
| { status: 'unmatched' }
|
||||||
|
| { status: 'unauthorized' }
|
||||||
|
| { status: 'error' };
|
||||||
|
|
||||||
|
export const fetchOrgPositionResult = async (): Promise<OrgPositionResult> => {
|
||||||
try {
|
try {
|
||||||
const identity = await apiRequest<MobileIdentity>({
|
const identity = await apiRequest<MobileIdentity>({
|
||||||
url: '/api/aihr/mobile/me',
|
url: '/api/aihr/mobile/me',
|
||||||
@@ -72,13 +78,23 @@ export const hydratePositionFromOrg = async (): Promise<UserPosition | ''> => {
|
|||||||
clearAuthOnUnauthorized: false
|
clearAuthOnUnauthorized: false
|
||||||
});
|
});
|
||||||
const officialPosition = saveOrgPosition(identity.positionName);
|
const officialPosition = saveOrgPosition(identity.positionName);
|
||||||
if (!officialPosition) clearOrgPosition();
|
if (!officialPosition) {
|
||||||
return officialPosition;
|
clearOrgPosition();
|
||||||
} catch {
|
return { status: 'unmatched' };
|
||||||
return hasOrgPosition() ? getSelectedPosition() : '';
|
}
|
||||||
|
return { status: 'matched', position: officialPosition };
|
||||||
|
} catch (error) {
|
||||||
|
return { status: isAuthApiError(error) ? 'unauthorized' : 'error' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const hydratePositionFromOrg = async (): Promise<UserPosition | ''> => {
|
||||||
|
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) => {
|
export const nextUserRouteAfterLogin = async (redirect: string) => {
|
||||||
const role = await hydrateMobileRole();
|
const role = await hydrateMobileRole();
|
||||||
const roleRoute = routeForMobileRole(role, redirect);
|
const roleRoute = routeForMobileRole(role, redirect);
|
||||||
|
|||||||
Reference in New Issue
Block a user