fix(mobile): require employee position before user flow

This commit is contained in:
2026-07-09 17:09:26 +08:00
parent 8b92a7e9ac
commit 8f73cb0c84
13 changed files with 473 additions and 33 deletions
+85 -1
View File
@@ -4,18 +4,97 @@ export const tokenKey = 'aihr_mobile_access_token';
export const clientIdKey = 'aihr_mobile_client_id';
export const phoneKey = 'aihr_mobile_phone';
export const loginRedirectKey = 'aihr_mobile_login_redirect';
export const positionKey = 'aihr_mobile_position';
export const phonePattern = /^1[3-9]\d{9}$/;
export const userPositionOptions = ['生活顾问', '保安', '保洁', '保修', '客服'] as const;
export type UserPosition = typeof userPositionOptions[number];
export interface UserPositionProfile {
value: UserPosition;
heroTitle: string;
quickQuestions: string[];
tip: string;
}
export interface AuthState {
token: string;
clientId: string;
phone: string;
position: UserPosition | '';
}
const positionProfiles: Record<UserPosition, UserPositionProfile> = {
生活顾问: {
value: '生活顾问',
heroTitle: '客服管家',
quickQuestions: ['催费话术', '投诉应对', '收费标准'],
tip: '今日小贴士:催费沟通先共情,再说明,再给方案。'
},
保安: {
value: '保安',
heroTitle: '秩序岗',
quickQuestions: ['门岗核验', '巡逻异常', '车辆指引'],
tip: '今日小贴士:异常先确认人车物,再登记,再同步主管。'
},
保洁: {
value: '保洁',
heroTitle: '环境岗',
quickQuestions: ['公区清洁', '垃圾分类', '消杀标准'],
tip: '今日小贴士:高频区域先看地面、扶手、电梯按钮。'
},
保修: {
value: '保修',
heroTitle: '工程维修',
quickQuestions: ['报修接单', '上门话术', '安全检查'],
tip: '今日小贴士:上门前确认故障、工具、预约时间。'
},
客服: {
value: '客服',
heroTitle: '服务前台',
quickQuestions: ['来访接待', '投诉登记', '工单跟进'],
tip: '今日小贴士:复杂诉求先复述确认,再记录责任人与时限。'
}
};
const cleanText = (value: unknown) => String(value || '').trim();
export const normalizePosition = (value: unknown): UserPosition | '' => {
const text = cleanText(value);
if (!text) return '';
if (text.includes('生活顾问') || text.includes('客服管家') || text.includes('物业管家') || text.includes('管家')) {
return '生活顾问';
}
if (text.includes('保安') || text.includes('秩序') || text.includes('安保')) return '保安';
if (text.includes('保洁') || text.includes('环境')) return '保洁';
if (text.includes('保修') || text.includes('维修') || text.includes('工程')) return '保修';
if (text.includes('客服') || text.includes('前台')) return '客服';
return (userPositionOptions as readonly string[]).includes(text) ? (text as UserPosition) : '';
};
export const hasSelectedPosition = () => Boolean(normalizePosition(uni.getStorageSync(positionKey)));
export const getSelectedPosition = (): UserPosition =>
normalizePosition(uni.getStorageSync(positionKey)) || '生活顾问';
export const getPositionProfile = (position: unknown) =>
positionProfiles[normalizePosition(position) || '生活顾问'];
export const getSelectedPositionProfile = () => getPositionProfile(getSelectedPosition());
export const saveSelectedPosition = (position: unknown) => {
const normalized = normalizePosition(position);
if (!normalized) return '';
uni.setStorageSync(positionKey, normalized);
return normalized;
};
export const getAuth = (): AuthState => ({
token: uni.getStorageSync(tokenKey) || '',
clientId: uni.getStorageSync(clientIdKey) || '',
phone: uni.getStorageSync(phoneKey) || ''
phone: uni.getStorageSync(phoneKey) || '',
position: normalizePosition(uni.getStorageSync(positionKey))
});
export const isLoggedIn = () => {
@@ -24,6 +103,10 @@ export const isLoggedIn = () => {
};
export const saveAuth = (result: LoginResult, phone: string) => {
const previousPhone = uni.getStorageSync(phoneKey) || '';
if (previousPhone && previousPhone !== phone) {
uni.removeStorageSync(positionKey);
}
uni.setStorageSync(tokenKey, result.access_token);
uni.setStorageSync(clientIdKey, result.client_id);
uni.setStorageSync(phoneKey, phone);
@@ -33,6 +116,7 @@ export const clearAuth = () => {
uni.removeStorageSync(tokenKey);
uni.removeStorageSync(clientIdKey);
uni.removeStorageSync(phoneKey);
uni.removeStorageSync(positionKey);
uni.removeStorageSync(loginRedirectKey);
};
+3 -1
View File
@@ -5,6 +5,7 @@ import type {
SummaryCardResponse
} from '@/types/api';
import { apiRequest } from './api';
import { getSelectedPosition } from './auth';
type RawKnowledgeSnippet = KnowledgeSnippet & {
title?: string;
@@ -24,6 +25,7 @@ export const searchKnowledge = async (queryText: string) => {
data: {
queryText,
category: 'sop',
position: getSelectedPosition(),
limit: 5
},
timeout: 30000
@@ -47,7 +49,7 @@ export const submitAnswerFeedback = (params: {
fragmentIds: params.fragmentIds,
verdict: params.verdict,
category: 'sop',
position: '生活顾问',
position: getSelectedPosition(),
source: 'mobile_uni_sop'
},
timeout: 15000
+58
View File
@@ -0,0 +1,58 @@
import type { OrgSnapshotResponse } from '@/types/api';
import { apiRequest } from './api';
import {
getAuth,
hasSelectedPosition,
normalizePosition,
saveSelectedPosition,
type UserPosition
} from './auth';
export const userTabPages = new Set([
'/pages/user/today/index',
'/pages/user/practice/index',
'/pages/user/sop/index',
'/pages/user/profile/index'
]);
export const isUserRoute = (url: string) => url.startsWith('/pages/user/');
export const positionSetupUrl = (redirect: string) =>
`/pages/auth/position/index?redirect=${encodeURIComponent(redirect || '/pages/user/today/index')}`;
export const goToMobileTarget = (url: string) => {
if (userTabPages.has(url)) {
uni.switchTab({ url });
return;
}
uni.redirectTo({ url });
};
export const hydratePositionFromOrg = async (phone = getAuth().phone): Promise<UserPosition | ''> => {
const keyword = phone.trim();
if (!keyword) return '';
try {
const snapshot = await apiRequest<OrgSnapshotResponse>({
url: `/api/aihr/org/snapshot?keyword=${encodeURIComponent(keyword)}&status=active&pageSize=1`,
method: 'GET',
timeout: 15000
});
const officialPosition = snapshot.rows?.[0]?.positionName || '';
return saveSelectedPosition(officialPosition);
} catch {
return '';
}
};
export const nextUserRouteAfterLogin = async (redirect: string) => {
if (!isUserRoute(redirect) || hasSelectedPosition()) return redirect;
const officialPosition = await hydratePositionFromOrg();
return normalizePosition(officialPosition) ? redirect : positionSetupUrl(redirect);
};
export const ensureEmployeePosition = async (redirect: string) => {
const nextRoute = await nextUserRouteAfterLogin(redirect);
if (nextRoute === redirect) return true;
uni.navigateTo({ url: nextRoute });
return false;
};
+2 -1
View File
@@ -14,7 +14,7 @@ import type {
PromotionEvidence
} from '@/types/api';
import { apiRequest } from './api';
import { getAuth } from './auth';
import { getAuth, getSelectedPosition } from './auth';
const defaultScenarioId = 'complaint-water';
@@ -40,6 +40,7 @@ export const startPractice = (scenarioId = defaultScenarioId, assignmentId?: num
data: {
extPartyId: currentExtPartyId(),
scenarioId,
position: getSelectedPosition(),
mode: 'mobile',
assignmentId
},