feat(mobile): owner identity selector on realtime practice

- default persona falls back to owner-calm; picker below persona cards
  (compact single row), default own community via defaultProfileId,
  storage persistence incl. explicit none, hidden on empty/failure
- profileId passed to realtime session creation when selected
This commit is contained in:
2026-08-05 03:18:50 +08:00
parent d9ff43eae3
commit 6b8c89bcdb
2 changed files with 136 additions and 5 deletions
+115 -3
View File
@@ -41,6 +41,22 @@
</view> </view>
</scroll-view> </scroll-view>
</view> </view>
<view v-if="showOwnerProfilePicker" class="owner-profile-row">
<text class="owner-profile-label">业主身份</text>
<picker
class="owner-profile-picker"
mode="selector"
:range="ownerProfilePickerOptions"
:value="ownerProfilePickerIndex"
:disabled="busy"
@change="handleOwnerProfileChange"
>
<view class="owner-profile-value">
<text class="owner-profile-value-text">{{ ownerProfilePickerOptions[ownerProfilePickerIndex] }}</text>
<text class="owner-profile-arrow" aria-hidden="true">›</text>
</view>
</picker>
</view>
<button <button
v-if="status === 'idle' || status === 'finished'" v-if="status === 'idle' || status === 'finished'"
class="primary-button realtime-start-button" class="primary-button realtime-start-button"
@@ -530,7 +546,7 @@ import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile
import { isNativeAppRuntime, startSpeechCapture } from '@/services/speech-capture'; import { isNativeAppRuntime, startSpeechCapture } from '@/services/speech-capture';
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture'; import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope'; import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
import { createRealtimePracticeController, createRealtimeSession, fetchRealtimePersonas, invokeRealtimeTool, type RealtimePersonaOption } from '@/services/realtime-practice'; import { createRealtimePracticeController, createRealtimeSession, fetchOwnerProfiles, fetchRealtimePersonas, invokeRealtimeTool, type OwnerProfileOption, type RealtimePersonaOption } from '@/services/realtime-practice';
import { stripInternalCodes } from '@/services/text'; import { stripInternalCodes } from '@/services/text';
import { describeMicrophoneError, microphoneDeniedMessage, requestMicrophonePermission } from '@/utils/microphone'; import { describeMicrophoneError, microphoneDeniedMessage, requestMicrophonePermission } from '@/utils/microphone';
import { isFileSelectionCancelled } from '@/utils/file-selection'; import { isFileSelectionCancelled } from '@/utils/file-selection';
@@ -552,6 +568,10 @@ const defaultScenarioName = '投诉接待';
const pendingAssignmentKey = 'aihr_pending_assignment_id'; const pendingAssignmentKey = 'aihr_pending_assignment_id';
const realtimePersonaStorageKey = 'aihr_realtime_practice_persona'; const realtimePersonaStorageKey = 'aihr_realtime_practice_persona';
const storedRealtimePersona = uni.getStorageSync(realtimePersonaStorageKey); const storedRealtimePersona = uni.getStorageSync(realtimePersonaStorageKey);
// 业主身份选择持久化:存 profileId;用户显式选「不指定」时存 NONE 标记,
// 以便与「从未选择」(用服务端 defaultProfileId 作首次默认)区分。
const ownerProfileStorageKey = 'aihr_realtime_owner_profile';
const ownerProfileNoneMarker = 'NONE';
const status = ref<PracticeStatus>('idle'); const status = ref<PracticeStatus>('idle');
const sessionId = ref(''); const sessionId = ref('');
const roundIndex = ref(0); const roundIndex = ref(0);
@@ -589,6 +609,8 @@ const realtimeAudioBlocked = ref(false);
const realtimeSessionId = ref(''); const realtimeSessionId = ref('');
const realtimePersonaOptions = ref<RealtimePersonaOption[]>([]); const realtimePersonaOptions = ref<RealtimePersonaOption[]>([]);
const selectedRealtimePersonaId = ref(typeof storedRealtimePersona === 'string' ? storedRealtimePersona : ''); const selectedRealtimePersonaId = ref(typeof storedRealtimePersona === 'string' ? storedRealtimePersona : '');
const ownerProfiles = ref<OwnerProfileOption[]>([]);
const selectedOwnerProfileId = ref('');
const realtimeAppBridge = ref<{ const realtimeAppBridge = ref<{
start: (sessionUpdate: Record<string, unknown>, sessionId?: string) => Promise<void>; start: (sessionUpdate: Record<string, unknown>, sessionId?: string) => Promise<void>;
stop: () => void; stop: () => void;
@@ -662,6 +684,16 @@ const canStartPractice = computed(() =>
const canStartRealtime = computed(() => const canStartRealtime = computed(() =>
status.value === 'idle' || status.value === 'finished' status.value === 'idle' || status.value === 'finished'
); );
// 业主身份选择器仅在选中 owner 类人设且服务端返回了档案时展示;
// 档案为空或接口失败时整体隐藏,静默降级为无画像对练。
const showOwnerProfilePicker = computed(() =>
selectedRealtimePersonaId.value.startsWith('owner') && ownerProfiles.value.length > 0
);
const ownerProfilePickerOptions = computed(() => ['不指定', ...ownerProfiles.value.map((item) => item.label)]);
const ownerProfilePickerIndex = computed(() => {
const index = ownerProfiles.value.findIndex((item) => item.profileId === selectedOwnerProfileId.value);
return index < 0 ? 0 : index + 1;
});
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee')); const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value); const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value); const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
@@ -996,6 +1028,8 @@ const clearPracticeData = () => {
status.value = 'idle'; status.value = 'idle';
selectedScenarioId.value = defaultScenarioId; selectedScenarioId.value = defaultScenarioId;
activeScenarioName.value = defaultScenarioName; activeScenarioName.value = defaultScenarioName;
ownerProfiles.value = [];
selectedOwnerProfileId.value = '';
helpLoading.value = false; helpLoading.value = false;
helpAnswer.value = ''; helpAnswer.value = '';
helpRefs.value = []; helpRefs.value = [];
@@ -1083,7 +1117,8 @@ const loadRealtimePersonas = async (request: RequestScopeTicket = requests.begin
if (!requests.isCurrent(request)) return; if (!requests.isCurrent(request)) return;
realtimePersonaOptions.value = options; realtimePersonaOptions.value = options;
if (!options.some((item) => item.id === selectedRealtimePersonaId.value)) { if (!options.some((item) => item.id === selectedRealtimePersonaId.value)) {
selectedRealtimePersonaId.value = options[0]?.id || ''; // 「业主·急躁」下线后默认选中「业主」(owner-calm),其余情况回落首项
selectedRealtimePersonaId.value = options.find((item) => item.id === 'owner-calm')?.id || options[0]?.id || '';
} }
} catch { } catch {
// 人设列表拉取失败不阻塞页面:会话创建接口对空白 personaId 回落默认人设 // 人设列表拉取失败不阻塞页面:会话创建接口对空白 personaId 回落默认人设
@@ -1091,6 +1126,35 @@ const loadRealtimePersonas = async (request: RequestScopeTicket = requests.begin
} }
}; };
const loadOwnerProfiles = async (request: RequestScopeTicket = requests.begin('owner-profiles')) => {
try {
const data = await fetchOwnerProfiles();
if (!requests.isCurrent(request)) return;
const profiles = Array.isArray(data?.profiles) ? data.profiles : [];
ownerProfiles.value = profiles;
const stored = uni.getStorageSync(ownerProfileStorageKey);
if (stored === ownerProfileNoneMarker) {
selectedOwnerProfileId.value = '';
} else if (typeof stored === 'string' && stored && profiles.some((item) => item.profileId === stored)) {
selectedOwnerProfileId.value = stored;
} else if (data?.defaultProfileId && profiles.some((item) => item.profileId === data.defaultProfileId)) {
selectedOwnerProfileId.value = data.defaultProfileId;
} else {
selectedOwnerProfileId.value = '';
}
} catch {
// 业主档案拉取失败不阻塞页面:选择器整体隐藏,降级为无画像对练
if (requests.isCurrent(request)) ownerProfiles.value = [];
}
};
const handleOwnerProfileChange = (event: { detail: { value: number | string } }) => {
const index = Number(event.detail.value);
if (!Number.isInteger(index) || index < 0 || index > ownerProfiles.value.length) return;
selectedOwnerProfileId.value = index === 0 ? '' : ownerProfiles.value[index - 1].profileId;
uni.setStorageSync(ownerProfileStorageKey, selectedOwnerProfileId.value || ownerProfileNoneMarker);
};
const stopRealtime = () => { const stopRealtime = () => {
if (!realtimeActive.value && realtimeStatus.value === 'idle') return; if (!realtimeActive.value && realtimeStatus.value === 'idle') return;
if (isNativeAppRuntime()) realtimeAppBridge.value?.stop(); if (isNativeAppRuntime()) realtimeAppBridge.value?.stop();
@@ -1129,7 +1193,11 @@ const startRealtime = async () => {
throw new Error(microphoneDeniedMessage(permission)); throw new Error(microphoneDeniedMessage(permission));
} }
if (!realtimeActive.value) return; if (!realtimeActive.value) return;
const session = await createRealtimeSession(selectedRealtimePersonaId.value || undefined, selectedScenarioId.value || undefined); const session = await createRealtimeSession(
selectedRealtimePersonaId.value || undefined,
selectedScenarioId.value || undefined,
showOwnerProfilePicker.value && selectedOwnerProfileId.value ? selectedOwnerProfileId.value : undefined
);
if (!realtimeActive.value) return; if (!realtimeActive.value) return;
realtimeSessionId.value = session.sessionId; realtimeSessionId.value = session.sessionId;
activeScenarioName.value = session.personaName || '实时陪练'; activeScenarioName.value = session.personaName || '实时陪练';
@@ -1823,6 +1891,7 @@ const handleShow = async () => {
void loadAssignments(requests.begin('assignments')); void loadAssignments(requests.begin('assignments'));
void loadCurriculum(requests.begin('curriculum')); void loadCurriculum(requests.begin('curriculum'));
void loadRealtimePersonas(requests.begin('realtime-personas')); void loadRealtimePersonas(requests.begin('realtime-personas'));
void loadOwnerProfiles(requests.begin('owner-profiles'));
}; };
const invalidateRequests = () => { const invalidateRequests = () => {
@@ -3781,6 +3850,49 @@ onUnload(() => {
line-height: 1.35; line-height: 1.35;
} }
.owner-profile-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border: 1px solid #f1e3e4;
border-radius: 12px;
background: #fffafa;
}
.owner-profile-label {
flex-shrink: 0;
color: #5e6672;
font-size: 12px;
}
.owner-profile-picker {
flex: 1;
min-width: 0;
}
.owner-profile-value {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.owner-profile-value-text {
overflow: hidden;
color: #25282d;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.owner-profile-arrow {
flex-shrink: 0;
color: #b6bcc6;
font-size: 14px;
}
.status-message { .status-message {
display: block; display: block;
margin-top: 12px; margin-top: 12px;
+21 -2
View File
@@ -14,6 +14,18 @@ export interface RealtimePersonaOption {
hasTools: boolean; hasTools: boolean;
} }
export interface OwnerProfileOption {
profileId: string;
projectCode: string;
communityName: string;
label: string;
}
export interface OwnerProfilesResponse {
profiles: OwnerProfileOption[];
defaultProfileId: string | null;
}
export interface RealtimeSessionResponse { export interface RealtimeSessionResponse {
sessionId: string; sessionId: string;
personaId: string; personaId: string;
@@ -45,11 +57,18 @@ export const fetchRealtimePersonas = () =>
timeout: 15000 timeout: 15000
}); });
export const createRealtimeSession = (personaId?: string, scenarioId?: string) => export const fetchOwnerProfiles = () =>
apiRequest<OwnerProfilesResponse>({
url: '/api/aihr/practice/realtime/owner-profiles',
method: 'GET',
timeout: 15000
});
export const createRealtimeSession = (personaId?: string, scenarioId?: string, profileId?: string) =>
apiRequest<RealtimeSessionResponse>({ apiRequest<RealtimeSessionResponse>({
url: '/api/train/practice/realtime/session', url: '/api/train/practice/realtime/session',
method: 'POST', method: 'POST',
data: { personaId: personaId || null, scenarioId: scenarioId || null }, data: { personaId: personaId || null, scenarioId: scenarioId || null, profileId: profileId || null },
timeout: 15000 timeout: 15000
}); });