feat(mobile-app): prepare native app packaging

This commit is contained in:
2026-07-23 14:40:23 +08:00
parent f6609808df
commit 0f5c2e8667
62 changed files with 979 additions and 833 deletions
@@ -159,9 +159,11 @@ import {
createSpeechPlaybackController,
INTERVIEWER_VOICE_PROFILE,
normalizeOssId,
transcribeSpeechBlob,
transcribeSpeechCapture,
transcribeSpeechFile
} from '@/services/speech';
import { startSpeechCapture } from '@/services/speech-capture';
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
const currentPath = '/pages/candidate/interview/index';
const records = ref<InterviewRecord[]>([]);
@@ -177,8 +179,7 @@ const message = ref('');
const transcribingQuestionId = ref('');
const recordingQuestionId = ref('');
const recordingQuestionStarting = ref(false);
const mediaRecorder = ref<MediaRecorder | null>(null);
const mediaStream = ref<MediaStream | null>(null);
let questionCapture: SpeechCaptureSession | null = null;
let questionCaptureGeneration = 0;
let questionTranscriptionGeneration = 0;
let abortQuestionTranscription: (() => void) | null = null;
@@ -298,11 +299,6 @@ const interviewResultSpeech = () => {
].filter(Boolean).join('。');
};
const stopQuestionMediaTracks = () => {
mediaStream.value?.getTracks().forEach((track) => track.stop());
mediaStream.value = null;
};
const cancelQuestionTranscription = () => {
questionTranscriptionGeneration += 1;
abortQuestionTranscription?.();
@@ -314,14 +310,8 @@ const cancelQuestionRecording = () => {
questionCaptureGeneration += 1;
cancelQuestionTranscription();
recordingQuestionStarting.value = false;
const recorder = mediaRecorder.value;
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null;
recorder.onstop = null;
recorder.stop();
}
mediaRecorder.value = null;
stopQuestionMediaTracks();
questionCapture?.cancel();
questionCapture = null;
recordingQuestionId.value = '';
};
@@ -361,13 +351,13 @@ const transcribeQuestionFile = async (questionId: string) => {
}
};
const transcribeQuestionBlob = async (questionId: string, blob: Blob) => {
const transcribeQuestionCapture = async (questionId: string, capture: SpeechCapture) => {
if (transcribingQuestionId.value) return;
const generation = ++questionTranscriptionGeneration;
transcribingQuestionId.value = questionId;
message.value = '语音转写中';
try {
const result = await transcribeSpeechBlob(blob, 'candidate-answer.webm', (abort) => {
const result = await transcribeSpeechCapture(capture, 'candidate-answer.webm', (abort) => {
if (generation === questionTranscriptionGeneration) abortQuestionTranscription = abort;
});
if (generation !== questionTranscriptionGeneration) return;
@@ -385,42 +375,35 @@ const transcribeQuestionBlob = async (questionId: string, blob: Blob) => {
const startQuestionRecording = async (questionId: string) => {
if (interviewBusy.value || recordingQuestionStarting.value || recordingQuestionId.value || transcribingQuestionId.value) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
await transcribeQuestionFile(questionId);
return;
}
const generation = ++questionCaptureGeneration;
recordingQuestionStarting.value = true;
let fallbackRequested = false;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== questionCaptureGeneration) {
stream.getTracks().forEach((track) => track.stop());
const session = await startSpeechCapture({
onCapture: (capture) => {
if (generation !== questionCaptureGeneration) return;
questionCapture = null;
recordingQuestionId.value = '';
void transcribeQuestionCapture(questionId, capture);
},
onError: () => {
if (generation !== questionCaptureGeneration) return;
fallbackRequested = true;
questionCapture = null;
recordingQuestionId.value = '';
void transcribeQuestionFile(questionId);
}
});
if (!session || generation !== questionCaptureGeneration) {
session?.cancel();
if (generation === questionCaptureGeneration && !fallbackRequested) await transcribeQuestionFile(questionId);
return;
}
mediaStream.value = stream;
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
mediaRecorder.value = recorder;
questionCapture = session;
recordingQuestionId.value = questionId;
recorder.ondataavailable = (event) => {
if (event.data?.size) chunks.push(event.data);
};
recorder.onstop = () => {
mediaRecorder.value = null;
recordingQuestionId.value = '';
stopQuestionMediaTracks();
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
if (!blob.size) {
message.value = '未录到音频,请改用文字输入';
return;
}
void transcribeQuestionBlob(questionId, blob);
};
recorder.start();
message.value = '正在录音';
} catch {
recordingQuestionId.value = '';
stopQuestionMediaTracks();
if (generation === questionCaptureGeneration) await transcribeQuestionFile(questionId);
} finally {
if (generation === questionCaptureGeneration) recordingQuestionStarting.value = false;
@@ -428,10 +411,9 @@ const startQuestionRecording = async (questionId: string) => {
};
const stopQuestionRecording = () => {
const recorder = mediaRecorder.value;
if (!recordingQuestionId.value || !recorder || recorder.state === 'inactive') return;
if (!recordingQuestionId.value || !questionCapture) return;
message.value = '录音结束,正在转写';
recorder.stop();
questionCapture.stop();
};
const toggleQuestionRecording = (questionId: string) => {
+37 -53
View File
@@ -306,7 +306,7 @@
:maxlength="500"
:panel-items="practicePanelItems"
@send="handleComposerSend"
@voice-blob="handleSessionVoiceBlob"
@voice-capture="handleSessionVoiceCapture"
@pick-audio="composerPickAudio"
@panel="handlePanelAction"
/>
@@ -397,7 +397,9 @@ import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/servic
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
import { startSpeechCapture } from '@/services/speech-capture';
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
import { createRealtimePracticeController, DEFAULT_REALTIME_PRACTICE_VOICE, isRealtimePracticeVoice, REALTIME_PRACTICE_VOICE_OPTIONS, type RealtimePracticeVoice } from '@/services/realtime-practice';
import { stripInternalCodes } from '@/services/text';
@@ -473,8 +475,7 @@ const transcribing = ref(false);
const draftAudioUrl = ref('');
const draftAudioOssId = ref<string | undefined>();
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
const mediaRecorder = ref<MediaRecorder | null>(null);
const mediaStream = ref<MediaStream | null>(null);
let recordingCapture: SpeechCaptureSession | null = null;
let recordingCaptureGeneration = 0;
let transcriptionGeneration = 0;
let abortTranscription: (() => void) | null = null;
@@ -665,12 +666,12 @@ const submitTranscribed = async (text: string, audioUrl: string, ossId?: string)
await submit();
};
const handleSessionVoiceBlob = async (blob: Blob) => {
const handleSessionVoiceCapture = async (capture: SpeechCapture) => {
if (transcribing.value || busy.value) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
try {
const asr = await transcribeSpeechBlob(blob, 'practice-turn.webm', (abort) => {
const asr = await transcribeSpeechCapture(capture, 'practice-turn.webm', (abort) => {
if (generation === transcriptionGeneration) abortTranscription = abort;
});
if (generation !== transcriptionGeneration) return;
@@ -907,11 +908,6 @@ const clearDraftAudio = () => {
draftAudioOssId.value = undefined;
};
const stopMediaTracks = () => {
mediaStream.value?.getTracks().forEach((track) => track.stop());
mediaStream.value = null;
};
const cancelTranscription = () => {
transcriptionGeneration += 1;
abortTranscription?.();
@@ -923,14 +919,8 @@ const cancelRecording = () => {
recordingCaptureGeneration += 1;
cancelTranscription();
recordingStarting.value = false;
const recorder = mediaRecorder.value;
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null;
recorder.onstop = null;
recorder.stop();
}
mediaRecorder.value = null;
stopMediaTracks();
recordingCapture?.cancel();
recordingCapture = null;
recording.value = false;
recordingAssignmentId.value = null;
};
@@ -965,13 +955,13 @@ const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audi
message.value = '每日一练语音已转成文字,请确认后提交';
};
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
const transcribeCapture = async (capture: SpeechCapture, assignmentId?: number) => {
if (transcribing.value) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
message.value = '语音转写中';
try {
const result = await transcribeSpeechBlob(blob, 'practice-audio.webm', (abort) => {
const result = await transcribeSpeechCapture(capture, 'practice-audio.webm', (abort) => {
if (generation === transcriptionGeneration) abortTranscription = abort;
});
if (generation !== transcriptionGeneration) return;
@@ -1023,45 +1013,40 @@ const pickAudio = async (assignmentId?: number) => {
const startRecording = async (assignmentId?: number) => {
if (busy.value || recordingStarting.value || recording.value || transcribing.value) return;
if (!requireLogin()) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
await pickAudio(assignmentId);
return;
}
const generation = ++recordingCaptureGeneration;
recordingStarting.value = true;
let fallbackRequested = false;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== recordingCaptureGeneration) {
stream.getTracks().forEach((track) => track.stop());
const session = await startSpeechCapture({
onCapture: (capture) => {
if (generation !== recordingCaptureGeneration) return;
recordingCapture = null;
recording.value = false;
const targetAssignmentId = assignmentId;
recordingAssignmentId.value = null;
void transcribeCapture(capture, targetAssignmentId);
},
onError: () => {
if (generation !== recordingCaptureGeneration) return;
fallbackRequested = true;
recordingCapture = null;
recording.value = false;
recordingAssignmentId.value = null;
void pickAudio(assignmentId);
}
});
if (!session || generation !== recordingCaptureGeneration) {
session?.cancel();
if (generation === recordingCaptureGeneration && !fallbackRequested) await pickAudio(assignmentId);
return;
}
mediaStream.value = stream;
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
mediaRecorder.value = recorder;
recordingCapture = session;
recordingAssignmentId.value = assignmentId ?? null;
recorder.ondataavailable = (event) => {
if (event.data?.size) chunks.push(event.data);
};
recorder.onstop = () => {
recording.value = false;
mediaRecorder.value = null;
stopMediaTracks();
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
if (!blob.size) {
message.value = '未录到音频,请改用文字输入';
return;
}
const targetAssignmentId = assignmentId;
recordingAssignmentId.value = null;
void transcribeBlob(blob, targetAssignmentId);
};
recorder.start();
recording.value = true;
message.value = '正在录音';
} catch (error) {
recordingAssignmentId.value = null;
stopMediaTracks();
recordingCapture = null;
if (generation === recordingCaptureGeneration) await pickAudio(assignmentId);
} finally {
if (generation === recordingCaptureGeneration) recordingStarting.value = false;
@@ -1069,10 +1054,9 @@ const startRecording = async (assignmentId?: number) => {
};
const stopRecording = () => {
const recorder = mediaRecorder.value;
if (!recording.value || !recorder || recorder.state === 'inactive') return;
if (!recording.value || !recordingCapture) return;
message.value = '录音结束,正在转写';
recorder.stop();
recordingCapture.stop();
};
const toggleRecording = (assignmentId?: number) => {
+5 -4
View File
@@ -219,7 +219,7 @@
:maxlength="500"
:panel-items="composerPanelItems"
@send="handleComposerSend"
@voice-blob="handleVoiceBlob"
@voice-capture="handleVoiceCapture"
@pick-audio="pickVoiceAudio"
@panel="handlePanelAction"
/>
@@ -239,9 +239,10 @@ import {
chooseSpeechAudio,
createSpeechPlaybackController,
MENTOR_VOICE_PROFILE,
transcribeSpeechBlob,
transcribeSpeechCapture,
transcribeSpeechFile
} from '@/services/speech';
import type { SpeechCapture } from '@/services/speech-capture';
import type { SpeechPlaybackStatus } from '@/types/api';
import {
listMyWorkReports,
@@ -473,13 +474,13 @@ const cancelVoiceTranscription = () => {
voiceTranscribing.value = false;
};
const handleVoiceBlob = async (blob: Blob) => {
const handleVoiceCapture = async (capture: SpeechCapture) => {
if (voiceTranscribing.value || composerDisabled.value) return;
const generation = ++voiceTranscriptionGeneration;
const accountKey = currentAccountKey();
voiceTranscribing.value = true;
try {
const result = await transcribeSpeechBlob(blob, 'work-report.webm', (abort) => {
const result = await transcribeSpeechCapture(capture, 'work-report.webm', (abort) => {
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
});
if (!requestIsCurrent(generation, voiceTranscriptionGeneration, accountKey)) return;
+10 -4
View File
@@ -282,7 +282,7 @@
:tool-label="broadcastContext ? '' : defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况'"
:panel-items="composerPanelItems"
@send="handleComposerSend"
@voice-blob="handleVoiceBlob"
@voice-capture="handleVoiceCapture"
@pick-audio="pickVoiceAudio"
@pick-media="pickMedia"
@clear-media="clearMedia"
@@ -440,7 +440,8 @@ import {
import { resetPageScroll } from '@/services/navigation';
import { ApiError, authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import { ensureEmployeePosition, fetchMobileIdentity } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
import type { SpeechCapture } from '@/services/speech-capture';
import { downloadSummaryCardImage } from '@/services/summary-card-image';
import { getMemoryCandidates } from '@/services/personal-assistant';
import { currentAccountKey } from '@/services/request-scope';
@@ -1089,12 +1090,12 @@ const cancelVoiceTranscription = () => {
voiceTranscribing.value = false;
};
const handleVoiceBlob = async (blob: Blob) => {
const handleVoiceCapture = async (capture: SpeechCapture) => {
if (voiceTranscribing.value) return;
const generation = ++voiceTranscriptionGeneration;
voiceTranscribing.value = true;
try {
const result = await transcribeSpeechBlob(blob, 'sop-question.webm', (abort) => {
const result = await transcribeSpeechCapture(capture, 'sop-question.webm', (abort) => {
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
});
if (generation !== voiceTranscriptionGeneration) return;
@@ -1552,14 +1553,19 @@ onUnload(() => {
}
.broadcast-context-action {
display: flex;
min-height: 38px;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: 1px solid #e7aeb7;
border-radius: 10px;
background: #ffffff;
color: #b51c2b;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.broadcast-context-action.end {
+32 -41
View File
@@ -211,7 +211,9 @@ import { getMobileHome } from '@/services/home';
import { getPracticeAssignments } from '@/services/practice';
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { chooseSpeechAudio, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
import { startSpeechCapture } from '@/services/speech-capture';
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
import { stripInternalCodes } from '@/services/text';
import { ensureEmployeePosition } from '@/services/position';
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
@@ -393,8 +395,7 @@ const loadBroadcastUnreadCount = async () => {
const recording = ref(false);
const cancelling = ref(false);
const transcribing = ref(false);
let recorder: MediaRecorder | null = null;
let recordStream: MediaStream | null = null;
let holdCapture: SpeechCaptureSession | null = null;
let holdGeneration = 0;
let discardRecording = false;
let holdStartY = 0;
@@ -411,57 +412,45 @@ const voiceSub = computed(() => {
return '松开发送,上滑取消';
});
const supportsRecording = () =>
typeof navigator !== 'undefined'
&& Boolean(navigator.mediaDevices?.getUserMedia)
&& typeof MediaRecorder !== 'undefined';
const stopRecordTracks = () => {
recordStream?.getTracks().forEach((track) => track.stop());
recordStream = null;
};
const startHold = async (event?: TouchEvent | MouseEvent) => {
if (recording.value || transcribing.value) return;
if (!loggedIn.value) {
goLogin();
return;
}
if (!supportsRecording()) {
void pickAudioFallback();
return;
}
const generation = ++holdGeneration;
discardRecording = false;
cancelling.value = false;
let fallbackRequested = false;
if (event && 'touches' in event && event.touches[0]) {
holdStartY = event.touches[0].clientY;
}
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== holdGeneration) {
mediaStream.getTracks().forEach((track) => track.stop());
const session = await startSpeechCapture({
onCapture: (capture) => {
if (generation !== holdGeneration) return;
holdCapture = null;
recording.value = false;
if (!discardRecording) void handleVoiceCapture(capture);
},
onError: (error) => {
if (generation !== holdGeneration) return;
fallbackRequested = true;
holdCapture = null;
recording.value = false;
message.value = error.message;
void pickAudioFallback();
}
});
if (!session || generation !== holdGeneration) {
session?.cancel();
if (generation === holdGeneration && !fallbackRequested) void pickAudioFallback();
return;
}
recordStream = mediaStream;
const mediaRecorder = new MediaRecorder(mediaStream);
const chunks: Blob[] = [];
recorder = mediaRecorder;
mediaRecorder.ondataavailable = (e) => {
if (e.data?.size) chunks.push(e.data);
};
mediaRecorder.onstop = () => {
recording.value = false;
recorder = null;
stopRecordTracks();
if (discardRecording) return;
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });
if (blob.size) void handleVoiceBlob(blob);
};
mediaRecorder.start();
holdCapture = session;
recording.value = true;
} catch {
stopRecordTracks();
holdCapture = null;
if (generation === holdGeneration) void pickAudioFallback();
}
};
@@ -475,20 +464,22 @@ const moveHold = (event: TouchEvent) => {
const endHold = () => {
if (!recording.value) return;
discardRecording = cancelling.value;
if (recorder && recorder.state !== 'inactive') recorder.stop();
holdCapture?.stop();
};
const cancelHold = () => {
if (!recording.value) return;
discardRecording = true;
if (recorder && recorder.state !== 'inactive') recorder.stop();
holdCapture?.cancel();
holdCapture = null;
recording.value = false;
};
const handleVoiceBlob = async (blob: Blob) => {
const handleVoiceCapture = async (capture: SpeechCapture) => {
transcribing.value = true;
message.value = '';
try {
const result = await transcribeSpeechBlob(blob, 'today-ask.webm');
const result = await transcribeSpeechCapture(capture, 'today-ask.webm');
const text = result.text?.trim();
if (text) ask(text);
else message.value = '没听清,请再说一次';
+5 -4
View File
@@ -105,7 +105,7 @@
{ key: 'audio', icon: 'sound', label: '选音频转文字' }
]"
@send="handleComposerSend"
@voice-blob="handleVoiceBlob"
@voice-capture="handleVoiceCapture"
@panel="handlePanelAction"
@clear-media="clearImage"
/>
@@ -120,7 +120,8 @@ import AskSourceHeader from '@/components/chat/AskSourceHeader.vue';
import ChatComposer from '@/components/chat/ChatComposer.vue';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { chooseKnowledgeMedia } from '@/services/knowledge';
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
import type { SpeechCapture } from '@/services/speech-capture';
import { getWebAiCapabilities, queryWebAi, queryWebAiWithImage } from '@/services/web-ai';
import { renderMarkdown } from '@/utils/markdown';
@@ -290,12 +291,12 @@ const handleComposerSend = (text: string) => {
void sendQuestion(text);
};
const handleVoiceBlob = async (blob: Blob) => {
const handleVoiceCapture = async (capture: SpeechCapture) => {
if (voiceTranscribing.value) return;
const generation = ++voiceTranscriptionGeneration;
voiceTranscribing.value = true;
try {
const asr = await transcribeSpeechBlob(blob, 'web-ai-question.webm', (abort) => {
const asr = await transcribeSpeechCapture(capture, 'web-ai-question.webm', (abort) => {
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
});
if (generation !== voiceTranscriptionGeneration) return;