From 8f73cb0c840341b6a94f149b157cfd46e1650960 Mon Sep 17 00:00:00 2001 From: let5sne Date: Thu, 9 Jul 2026 17:09:26 +0800 Subject: [PATCH] fix(mobile): require employee position before user flow --- .../aihr/service/AihrOrgSyncService.java | 5 +- mobile-uni/src/pages.json | 6 + mobile-uni/src/pages/auth/login/index.vue | 20 +-- mobile-uni/src/pages/auth/position/index.vue | 157 ++++++++++++++++++ mobile-uni/src/pages/user/practice/index.vue | 10 +- mobile-uni/src/pages/user/profile/index.vue | 89 +++++++++- mobile-uni/src/pages/user/sop/index.vue | 26 ++- mobile-uni/src/pages/user/today/index.vue | 20 ++- mobile-uni/src/services/auth.ts | 86 +++++++++- mobile-uni/src/services/knowledge.ts | 4 +- mobile-uni/src/services/position.ts | 58 +++++++ mobile-uni/src/services/practice.ts | 3 +- mobile-uni/src/types/api.ts | 22 +++ 13 files changed, 473 insertions(+), 33 deletions(-) create mode 100644 mobile-uni/src/pages/auth/position/index.vue create mode 100644 mobile-uni/src/services/position.ts diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java index 1c1ddf91..d87efcc3 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java @@ -374,14 +374,15 @@ public class AihrOrgSyncService { if (!keywordValue.isBlank()) { String like = "%" + keywordValue + "%"; where.append(""" - and (person_name like ? or ext_party_id like ? or position_name like ? - or dept_name like ? or project_name like ?) + and (person_name like ? or ext_party_id like ? or person_phone like ? + or position_name like ? or dept_name like ? or project_name like ?) """); args.add(like); args.add(like); args.add(like); args.add(like); args.add(like); + args.add(like); } String project = clean(projectCode); if (!project.isBlank()) { diff --git a/mobile-uni/src/pages.json b/mobile-uni/src/pages.json index bc16011e..b4745569 100644 --- a/mobile-uni/src/pages.json +++ b/mobile-uni/src/pages.json @@ -37,6 +37,12 @@ "navigationBarTitleText": "登录" } }, + { + "path": "pages/auth/position/index", + "style": { + "navigationBarTitleText": "确认岗位" + } + }, { "path": "pages/common/building/index", "style": { diff --git a/mobile-uni/src/pages/auth/login/index.vue b/mobile-uni/src/pages/auth/login/index.vue index 5a1830d0..68f8c0be 100644 --- a/mobile-uni/src/pages/auth/login/index.vue +++ b/mobile-uni/src/pages/auth/login/index.vue @@ -41,6 +41,7 @@ import type { LoginResult } from '@/types/api'; import { apiRequest } from '@/services/api'; import { consumeLoginRedirect, getAuth, isLoggedIn, phoneKey, saveAuth, validatePhone } from '@/services/auth'; import { resetPageScroll } from '@/services/navigation'; +import { goToMobileTarget, nextUserRouteAfterLogin } from '@/services/position'; const stored = getAuth(); const phone = ref(stored.phone || uni.getStorageSync(phoneKey) || ''); @@ -48,20 +49,9 @@ const smsCode = ref(''); const sending = ref(false); const loggingIn = ref(false); const message = ref(''); -const tabPages = new Set([ - '/pages/user/today/index', - '/pages/user/practice/index', - '/pages/user/sop/index', - '/pages/user/profile/index' -]); - -const goAfterLogin = () => { +const goAfterLogin = async () => { const redirect = consumeLoginRedirect() || '/pages/user/today/index'; - if (tabPages.has(redirect)) { - uni.switchTab({ url: redirect }); - return; - } - uni.redirectTo({ url: redirect }); + goToMobileTarget(await nextUserRouteAfterLogin(redirect)); }; const sendCode = async () => { @@ -115,7 +105,7 @@ const login = async () => { throw new Error('登录响应缺少认证信息'); } saveAuth(result, value); - goAfterLogin(); + await goAfterLogin(); } catch (error) { message.value = error instanceof Error ? error.message : '登录失败'; } finally { @@ -126,7 +116,7 @@ const login = async () => { onShow(() => { resetPageScroll(); if (isLoggedIn()) { - goAfterLogin(); + void goAfterLogin(); } }); diff --git a/mobile-uni/src/pages/auth/position/index.vue b/mobile-uni/src/pages/auth/position/index.vue new file mode 100644 index 00000000..6940f090 --- /dev/null +++ b/mobile-uni/src/pages/auth/position/index.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue index d9b1ee94..8d84ad04 100644 --- a/mobile-uni/src/pages/user/practice/index.vue +++ b/mobile-uni/src/pages/user/practice/index.vue @@ -177,6 +177,7 @@ import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticePr import { isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { searchKnowledge } from '@/services/knowledge'; import { resetPageScroll } from '@/services/navigation'; +import { ensureEmployeePosition } from '@/services/position'; import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech'; type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished'; @@ -675,11 +676,18 @@ const finish = async (force = false) => { } }; -onShow(() => { +const handleShow = async () => { resetPageScroll(); refreshAuthState(); + if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/practice/index'))) { + return; + } void loadAssignments(); void loadPrepCard(); +}; + +onShow(() => { + void handleShow(); }); diff --git a/mobile-uni/src/pages/user/profile/index.vue b/mobile-uni/src/pages/user/profile/index.vue index c3056422..2c22d3a4 100644 --- a/mobile-uni/src/pages/user/profile/index.vue +++ b/mobile-uni/src/pages/user/profile/index.vue @@ -14,6 +14,26 @@ + + + + 岗位身份 + {{ positionProfile.value }} · {{ positionProfile.heroTitle }} + + + + + + + 成长摘要 @@ -117,9 +137,20 @@ import { ref } from 'vue'; import { onShow } from '@dcloudio/uni-app'; import type { CompetencyProfile, PracticeRecord, PromotionEvidence } from '@/types/api'; -import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; +import { + clearAuth, + getAuth, + getPositionProfile, + getSelectedPositionProfile, + isLoggedIn, + rememberLoginRedirect, + saveSelectedPosition, + userPositionOptions, + type UserPosition +} from '@/services/auth'; import { getCompetencyProfile, getPracticeHistory, getPromotionEvidence } from '@/services/practice'; import { resetPageScroll } from '@/services/navigation'; +import { ensureEmployeePosition } from '@/services/position'; const loggedIn = ref(false); const phone = ref(''); @@ -128,11 +159,14 @@ const message = ref(''); const profile = ref(null); const history = ref([]); const evidence = ref(null); +const positionProfile = ref(getSelectedPositionProfile()); +const positionCards = userPositionOptions.map((item) => getPositionProfile(item)); const refreshAuth = () => { const auth = getAuth(); loggedIn.value = isLoggedIn(); phone.value = auth.phone; + positionProfile.value = getSelectedPositionProfile(); }; const clearProfileData = () => { @@ -147,6 +181,10 @@ const loadData = async () => { clearProfileData(); return; } + if (!(await ensureEmployeePosition('/pages/user/profile/index'))) { + return; + } + refreshAuth(); loading.value = true; message.value = ''; try { @@ -183,6 +221,13 @@ const logout = () => { uni.showToast({ title: '已退出', icon: 'none' }); }; +const changePosition = (position: UserPosition) => { + const saved = saveSelectedPosition(position); + if (!saved) return; + positionProfile.value = getSelectedPositionProfile(); + uni.showToast({ title: '岗位已更新', icon: 'none' }); +}; + onShow(() => { resetPageScroll(); void loadData(); @@ -214,6 +259,48 @@ onShow(() => { min-width: 84px; } +.position-current { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.position-title { + display: block; + margin-top: 8px; + color: #2563eb; + font-size: 16px; + font-weight: 900; +} + +.position-actions { + display: grid; + margin-top: 14px; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} + +.position-chip { + display: flex; + min-height: 38px; + align-items: center; + justify-content: center; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #f8fafc; + color: #475569; + font-size: 13px; + font-weight: 900; + white-space: nowrap; +} + +.position-chip.active { + border-color: #2563eb; + background: #eff6ff; + color: #1d4ed8; +} + .score-row { display: grid; margin-top: 16px; diff --git a/mobile-uni/src/pages/user/sop/index.vue b/mobile-uni/src/pages/user/sop/index.vue index 5aafbf0d..fbd8984b 100644 --- a/mobile-uni/src/pages/user/sop/index.vue +++ b/mobile-uni/src/pages/user/sop/index.vue @@ -8,7 +8,7 @@ v-model="query" class="textarea" maxlength="200" - placeholder="例如: 业主投诉噪音,生活顾问应该怎么处理?" + :placeholder="quickQuestionPlaceholder" /> @@ -109,14 +109,17 @@ import { onShow } from '@dcloudio/uni-app'; import type { PracticeAssignment, RoleHome } from '@/types/api'; import { getMobileHome } from '@/services/home'; import { getPracticeAssignments } from '@/services/practice'; -import { getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; +import { getAuth, getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { resetPageScroll } from '@/services/navigation'; +import { ensureEmployeePosition } from '@/services/position'; const home = ref(null); const assignments = ref([]); const message = ref(''); const loggedIn = ref(false); -const quickQuestions = ['催费话术', '投诉应对', '收费标准']; +const positionProfile = ref(getSelectedPositionProfile()); +const quickQuestions = computed(() => positionProfile.value.quickQuestions); +const primaryTipQuestion = computed(() => quickQuestions.value[0] || '岗位SOP'); const pendingSopQueryKey = 'aihr_sop_pending_query'; const pendingAssignmentKey = 'aihr_pending_assignment_id'; @@ -176,12 +179,17 @@ const mentorBubbleText = computed(() => const loadHome = async () => { loggedIn.value = isLoggedIn(); + positionProfile.value = getSelectedPositionProfile(); message.value = ''; assignments.value = []; if (!loggedIn.value) { home.value = null; return; } + if (!(await ensureEmployeePosition('/pages/user/today/index'))) { + return; + } + positionProfile.value = getSelectedPositionProfile(); const [homeResult, assignmentResult] = await Promise.allSettled([ getMobileHome('user'), getPracticeAssignments(true) diff --git a/mobile-uni/src/services/auth.ts b/mobile-uni/src/services/auth.ts index 8ef168a9..5f153f04 100644 --- a/mobile-uni/src/services/auth.ts +++ b/mobile-uni/src/services/auth.ts @@ -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 = { + 生活顾问: { + 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); }; diff --git a/mobile-uni/src/services/knowledge.ts b/mobile-uni/src/services/knowledge.ts index 71db047b..95971f93 100644 --- a/mobile-uni/src/services/knowledge.ts +++ b/mobile-uni/src/services/knowledge.ts @@ -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 diff --git a/mobile-uni/src/services/position.ts b/mobile-uni/src/services/position.ts new file mode 100644 index 00000000..023aea7e --- /dev/null +++ b/mobile-uni/src/services/position.ts @@ -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 => { + const keyword = phone.trim(); + if (!keyword) return ''; + try { + const snapshot = await apiRequest({ + 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; +}; diff --git a/mobile-uni/src/services/practice.ts b/mobile-uni/src/services/practice.ts index 706a7585..33813f47 100644 --- a/mobile-uni/src/services/practice.ts +++ b/mobile-uni/src/services/practice.ts @@ -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 }, diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index a22c206f..100133c7 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -48,6 +48,28 @@ export interface RoleHome { todos?: TodoItem[]; } +export interface OrgPersonRow { + id: number; + projectCode: string; + projectName: string; + deptName: string; + extPartyId: string; + personName: string; + positionName: string; + positionLevel: string; + employmentStatus: string; + snapshotDate: string; +} + +export interface OrgSnapshotResponse { + total: number; + active: number; + departed: number; + pageNum: number; + pageSize: number; + rows: OrgPersonRow[]; +} + export interface KnowledgeSnippet { docTitle: string; content: string;