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:
@@ -41,6 +41,22 @@
|
||||
</view>
|
||||
</scroll-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
|
||||
v-if="status === 'idle' || status === 'finished'"
|
||||
class="primary-button realtime-start-button"
|
||||
@@ -530,7 +546,7 @@ import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile
|
||||
import { isNativeAppRuntime, startSpeechCapture } from '@/services/speech-capture';
|
||||
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
|
||||
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 { describeMicrophoneError, microphoneDeniedMessage, requestMicrophonePermission } from '@/utils/microphone';
|
||||
import { isFileSelectionCancelled } from '@/utils/file-selection';
|
||||
@@ -552,6 +568,10 @@ const defaultScenarioName = '投诉接待';
|
||||
const pendingAssignmentKey = 'aihr_pending_assignment_id';
|
||||
const realtimePersonaStorageKey = 'aihr_realtime_practice_persona';
|
||||
const storedRealtimePersona = uni.getStorageSync(realtimePersonaStorageKey);
|
||||
// 业主身份选择持久化:存 profileId;用户显式选「不指定」时存 NONE 标记,
|
||||
// 以便与「从未选择」(用服务端 defaultProfileId 作首次默认)区分。
|
||||
const ownerProfileStorageKey = 'aihr_realtime_owner_profile';
|
||||
const ownerProfileNoneMarker = 'NONE';
|
||||
const status = ref<PracticeStatus>('idle');
|
||||
const sessionId = ref('');
|
||||
const roundIndex = ref(0);
|
||||
@@ -589,6 +609,8 @@ const realtimeAudioBlocked = ref(false);
|
||||
const realtimeSessionId = ref('');
|
||||
const realtimePersonaOptions = ref<RealtimePersonaOption[]>([]);
|
||||
const selectedRealtimePersonaId = ref(typeof storedRealtimePersona === 'string' ? storedRealtimePersona : '');
|
||||
const ownerProfiles = ref<OwnerProfileOption[]>([]);
|
||||
const selectedOwnerProfileId = ref('');
|
||||
const realtimeAppBridge = ref<{
|
||||
start: (sessionUpdate: Record<string, unknown>, sessionId?: string) => Promise<void>;
|
||||
stop: () => void;
|
||||
@@ -662,6 +684,16 @@ const canStartPractice = computed(() =>
|
||||
const canStartRealtime = computed(() =>
|
||||
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 canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
|
||||
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
|
||||
@@ -996,6 +1028,8 @@ const clearPracticeData = () => {
|
||||
status.value = 'idle';
|
||||
selectedScenarioId.value = defaultScenarioId;
|
||||
activeScenarioName.value = defaultScenarioName;
|
||||
ownerProfiles.value = [];
|
||||
selectedOwnerProfileId.value = '';
|
||||
helpLoading.value = false;
|
||||
helpAnswer.value = '';
|
||||
helpRefs.value = [];
|
||||
@@ -1083,7 +1117,8 @@ const loadRealtimePersonas = async (request: RequestScopeTicket = requests.begin
|
||||
if (!requests.isCurrent(request)) return;
|
||||
realtimePersonaOptions.value = options;
|
||||
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 {
|
||||
// 人设列表拉取失败不阻塞页面:会话创建接口对空白 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 = () => {
|
||||
if (!realtimeActive.value && realtimeStatus.value === 'idle') return;
|
||||
if (isNativeAppRuntime()) realtimeAppBridge.value?.stop();
|
||||
@@ -1129,7 +1193,11 @@ const startRealtime = async () => {
|
||||
throw new Error(microphoneDeniedMessage(permission));
|
||||
}
|
||||
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;
|
||||
realtimeSessionId.value = session.sessionId;
|
||||
activeScenarioName.value = session.personaName || '实时陪练';
|
||||
@@ -1823,6 +1891,7 @@ const handleShow = async () => {
|
||||
void loadAssignments(requests.begin('assignments'));
|
||||
void loadCurriculum(requests.begin('curriculum'));
|
||||
void loadRealtimePersonas(requests.begin('realtime-personas'));
|
||||
void loadOwnerProfiles(requests.begin('owner-profiles'));
|
||||
};
|
||||
|
||||
const invalidateRequests = () => {
|
||||
@@ -3781,6 +3850,49 @@ onUnload(() => {
|
||||
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 {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
|
||||
@@ -14,6 +14,18 @@ export interface RealtimePersonaOption {
|
||||
hasTools: boolean;
|
||||
}
|
||||
|
||||
export interface OwnerProfileOption {
|
||||
profileId: string;
|
||||
projectCode: string;
|
||||
communityName: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface OwnerProfilesResponse {
|
||||
profiles: OwnerProfileOption[];
|
||||
defaultProfileId: string | null;
|
||||
}
|
||||
|
||||
export interface RealtimeSessionResponse {
|
||||
sessionId: string;
|
||||
personaId: string;
|
||||
@@ -45,11 +57,18 @@ export const fetchRealtimePersonas = () =>
|
||||
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>({
|
||||
url: '/api/train/practice/realtime/session',
|
||||
method: 'POST',
|
||||
data: { personaId: personaId || null, scenarioId: scenarioId || null },
|
||||
data: { personaId: personaId || null, scenarioId: scenarioId || null, profileId: profileId || null },
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user