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
@@ -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()) {
+6
View File
@@ -37,6 +37,12 @@
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/auth/position/index",
"style": {
"navigationBarTitleText": "确认岗位"
}
},
{
"path": "pages/common/building/index",
"style": {
+5 -15
View File
@@ -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();
}
});
</script>
@@ -0,0 +1,157 @@
<template>
<view class="page position-page">
<view class="position-head">
<text class="page-title">确认岗位</text>
<text class="page-subtitle">{{ phone }}</text>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view class="panel">
<text class="panel-title">选择当前岗位</text>
<view class="position-list">
<button
v-for="item in positionCards"
:key="item.value"
class="position-card"
:class="{ active: item.value === selectedPosition }"
:disabled="saving"
@click="selectPosition(item.value)"
>
<view class="position-copy">
<text class="position-name">{{ item.value }}</text>
<text class="position-title">{{ item.heroTitle }}</text>
</view>
<text class="position-mark">{{ item.value === selectedPosition ? '已选' : '选择' }}</text>
</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import {
getAuth,
getPositionProfile,
isLoggedIn,
rememberLoginRedirect,
saveSelectedPosition,
userPositionOptions,
type UserPosition
} from '@/services/auth';
import { goToMobileTarget, hydratePositionFromOrg } from '@/services/position';
import { resetPageScroll } from '@/services/navigation';
const redirect = ref('/pages/user/today/index');
const selectedPosition = ref<UserPosition | ''>('');
const saving = ref(false);
const message = ref('');
const phone = computed(() => getAuth().phone || '未登录');
const positionCards = userPositionOptions.map((item) => getPositionProfile(item));
const finish = () => {
goToMobileTarget(redirect.value || '/pages/user/today/index');
};
const selectPosition = (position: UserPosition) => {
if (saving.value) return;
saving.value = true;
const saved = saveSelectedPosition(position);
selectedPosition.value = saved;
finish();
};
const autoFillFromOrg = async () => {
if (!isLoggedIn()) {
rememberLoginRedirect(redirect.value);
uni.redirectTo({ url: '/pages/auth/login/index' });
return;
}
const officialPosition = await hydratePositionFromOrg();
if (officialPosition) {
selectedPosition.value = officialPosition;
finish();
return;
}
message.value = '未从组织数据匹配到岗位,请确认当前岗位';
};
onLoad((query) => {
if (typeof query?.redirect === 'string') {
const target = decodeURIComponent(query.redirect);
if (target.startsWith('/pages/')) {
redirect.value = target;
}
}
});
onShow(() => {
resetPageScroll();
void autoFillFromOrg();
});
</script>
<style scoped>
.position-page {
display: flex;
min-height: 100vh;
flex-direction: column;
justify-content: center;
}
.position-head {
margin-bottom: 10px;
}
.position-list {
display: flex;
margin-top: 16px;
flex-direction: column;
gap: 10px;
}
.position-card {
display: flex;
min-height: 72px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #f8fafc;
text-align: left;
}
.position-card.active {
border-color: #2563eb;
background: #eff6ff;
}
.position-copy {
display: flex;
min-width: 0;
flex-direction: column;
}
.position-name {
color: #0f172a;
font-size: 18px;
font-weight: 900;
}
.position-title {
margin-top: 6px;
color: #64748b;
font-size: 13px;
}
.position-mark {
color: #2563eb;
font-size: 14px;
font-weight: 900;
white-space: nowrap;
}
</style>
+9 -1
View File
@@ -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();
});
</script>
+88 -1
View File
@@ -14,6 +14,26 @@
<button v-else class="secondary-button account-action" @click="logout">退出</button>
</view>
<view v-if="loggedIn" class="panel position-panel">
<view class="position-current">
<view>
<text class="panel-title">岗位身份</text>
<text class="position-title">{{ positionProfile.value }} · {{ positionProfile.heroTitle }}</text>
</view>
</view>
<view class="position-actions">
<button
v-for="item in positionCards"
:key="item.value"
class="position-chip"
:class="{ active: item.value === positionProfile.value }"
@click="changePosition(item.value)"
>
{{ item.value }}
</button>
</view>
</view>
<view v-if="profile" class="panel">
<text class="panel-title">成长摘要</text>
<view class="score-row">
@@ -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<CompetencyProfile | null>(null);
const history = ref<PracticeRecord[]>([]);
const evidence = ref<PromotionEvidence | null>(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;
+21 -5
View File
@@ -8,7 +8,7 @@
v-model="query"
class="textarea"
maxlength="200"
placeholder="例如: 业主投诉噪音,生活顾问应该怎么处理?"
:placeholder="quickQuestionPlaceholder"
/>
<view class="chip-row">
<button v-for="item in quickQuestions" :key="item" class="chip" :disabled="loading" @click="useQuickQuestion(item)">
@@ -107,7 +107,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type {
AnswerFeedbackVerdict,
@@ -119,10 +119,13 @@ import {
searchKnowledge,
submitAnswerFeedback
} from '@/services/knowledge';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
const quickQuestions = ['业主投诉噪音怎么安抚', '停车费催缴怎么说', '生活顾问的一天做什么'];
const positionProfile = ref(getSelectedPositionProfile());
const quickQuestions = computed(() => positionProfile.value.quickQuestions);
const quickQuestionPlaceholder = computed(() => `例如: ${quickQuestions.value[0] || '岗位SOP怎么处理?'}`);
const query = ref('');
const result = ref<KnowledgeSearchResponse | null>(null);
const summaryCard = ref<SummaryCardResponse | null>(null);
@@ -143,6 +146,7 @@ const resetResultState = () => {
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
positionProfile.value = getSelectedPositionProfile();
};
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
@@ -169,6 +173,10 @@ const search = async () => {
goLogin();
return;
}
if (!(await ensureEmployeePosition('/pages/user/sop/index'))) {
return;
}
positionProfile.value = getSelectedPositionProfile();
loading.value = true;
message.value = '';
resetResultState();
@@ -256,9 +264,13 @@ const goLogin = () => {
uni.navigateTo({ url: '/pages/auth/login/index' });
};
onShow(() => {
const handleShow = async () => {
resetPageScroll();
refreshAuthState();
if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/sop/index'))) {
return;
}
positionProfile.value = getSelectedPositionProfile();
if (!loggedIn.value) {
resetResultState();
}
@@ -276,6 +288,10 @@ onShow(() => {
} else {
message.value = '登录后继续查询该问题';
}
};
onShow(() => {
void handleShow();
});
</script>
+14 -6
View File
@@ -4,8 +4,8 @@
<view class="mentor-hero">
<view class="hero-copy">
<text class="hero-role">生活顾问</text>
<text class="hero-title">客服管家</text>
<text class="hero-role">{{ positionProfile.value }}</text>
<text class="hero-title">{{ positionProfile.heroTitle }}</text>
<text class="hero-sub">有问题,随时问老师傅</text>
</view>
<image class="mentor-image" src="/static/mentor.png" mode="aspectFit" />
@@ -95,9 +95,9 @@
<view v-else class="empty">暂无工具入口</view>
</view>
<button class="tip-card" @click="ask('催费话术')">
<button class="tip-card" @click="ask(primaryTipQuestion)">
<text class="tip-mark">贴</text>
<text>今日小贴士:催费沟通先共情,再说明,再给方案。</text>
<text>{{ positionProfile.tip }}</text>
<text class="tip-arrow">›</text>
</button>
</view>
@@ -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<RoleHome | null>(null);
const assignments = ref<PracticeAssignment[]>([]);
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)
+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
},
+22
View File
@@ -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;