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
+9 -4
View File
@@ -1,7 +1,10 @@
import type { ApiPayload } from '@/types/api';
import { clearAuth, getAuth, redirectToLoginForAuthLoss } from './auth';
import { isNativeAppRuntime } from './speech-capture';
const apiBase = import.meta.env.VITE_API_BASE || '/dev-api';
const configuredApiBase = import.meta.env.VITE_API_BASE;
const currentApiBase = () => configuredApiBase
|| (isNativeAppRuntime() ? 'https://peilian.njzhmj.top/dev-api' : '/dev-api');
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
@@ -31,7 +34,7 @@ export const isAuthApiError = (error: unknown): boolean =>
error instanceof ApiError
&& (error.statusCode === 401 || error.statusCode === 403 || error.bizCode === 401 || error.bizCode === 403);
const normalizeUrl = (url: string) => `${apiBase}${url.startsWith('/') ? url : `/${url}`}`;
const normalizeUrl = (url: string) => `${currentApiBase()}${url.startsWith('/') ? url : `/${url}`}`;
export const apiUrl = normalizeUrl;
@@ -77,6 +80,8 @@ export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUn
export const readTextPayload = <T>(statusCode: number, text: string): T =>
readPayload<T>(statusCode, parsePayloadText(statusCode, text));
const shouldUseXhr = () => !isNativeAppRuntime() && typeof XMLHttpRequest !== 'undefined';
const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
const method = options.method || 'GET';
const hasBody = method !== 'GET' && options.data !== undefined;
@@ -109,7 +114,7 @@ const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
};
export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
if (typeof XMLHttpRequest !== 'undefined') {
if (shouldUseXhr()) {
return requestWithXhr<T>(options);
}
@@ -133,7 +138,7 @@ export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
export const authenticatedFileUrl = async (url: string): Promise<string> => {
const fullUrl = normalizeUrl(url);
if (typeof XMLHttpRequest !== 'undefined' && typeof URL !== 'undefined') {
if (shouldUseXhr() && typeof URL !== 'undefined') {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', fullUrl);
+3 -1
View File
@@ -1,4 +1,5 @@
import { apiRequest } from './api';
import { isNativeAppRuntime } from './speech-capture';
type RealtimeStatus = 'idle' | 'connecting' | 'connected' | 'listening' | 'speaking';
@@ -122,7 +123,8 @@ export interface RealtimePracticeController {
}
const isSupported = () =>
typeof navigator !== 'undefined'
!isNativeAppRuntime()
&& typeof navigator !== 'undefined'
&& Boolean(navigator.mediaDevices?.getUserMedia)
&& typeof RTCPeerConnection !== 'undefined';
+170
View File
@@ -0,0 +1,170 @@
import type { SpeechSelectedFile } from '@/types/api';
export type SpeechCapture =
| { kind: 'blob'; blob: Blob; filename: string }
| { kind: 'file'; file: SpeechSelectedFile };
export interface SpeechCaptureSession {
stop: () => void;
cancel: () => void;
dispose: () => void;
}
interface AppRecorderResult {
tempFilePath?: string;
fileSize?: number;
}
interface AppRecorderError {
errMsg?: string;
}
interface AppRecorderManager {
start: (options?: { duration?: number; sampleRate?: number; format?: 'mp3' }) => void;
stop: () => void;
onStop: (callback: (result: AppRecorderResult) => void) => void;
onError: (callback: (error: AppRecorderError) => void) => void;
}
type UniWithRecorder = typeof uni & {
getRecorderManager?: () => AppRecorderManager;
};
interface SpeechCaptureHandlers {
onCapture: (capture: SpeechCapture) => void;
onError?: (error: Error) => void;
}
const isAppRuntime = () => Boolean((globalThis as typeof globalThis & { plus?: unknown }).plus);
const supportsBrowserRecording = () =>
typeof navigator !== 'undefined'
&& Boolean(navigator.mediaDevices?.getUserMedia)
&& typeof MediaRecorder !== 'undefined';
let activeNativeSession: SpeechCaptureSession | null = null;
const startNativeCapture = (handlers: SpeechCaptureHandlers): SpeechCaptureSession | null => {
const manager = (uni as UniWithRecorder).getRecorderManager?.();
if (!manager) return null;
activeNativeSession?.cancel();
let active = true;
let discarded = false;
const cleanup = () => {
active = false;
if (activeNativeSession === session) activeNativeSession = null;
};
const finishWithError = (error: Error) => {
if (!active) return;
cleanup();
if (!discarded) handlers.onError?.(error);
};
const session: SpeechCaptureSession = {
stop: () => {
if (!active) return;
try {
manager.stop();
} catch (error) {
finishWithError(error instanceof Error ? error : new Error('停止录音失败'));
}
},
cancel: () => {
if (!active) return;
discarded = true;
cleanup();
try {
manager.stop();
} catch {
// The capture has already been discarded; there is nothing to surface.
}
},
dispose: () => session.cancel()
};
manager.onStop((result) => {
if (!active) return;
const path = String(result.tempFilePath || '');
cleanup();
if (discarded) return;
if (!path) {
handlers.onError?.(new Error('未获取到录音文件,请改用选择音频'));
return;
}
handlers.onCapture({
kind: 'file',
file: {
path,
name: `recording-${Date.now()}.mp3`,
size: Number(result.fileSize || 0)
}
});
});
manager.onError((error) => finishWithError(new Error(error.errMsg || '录音失败,请检查麦克风权限')));
try {
manager.start({ duration: 600000, sampleRate: 16000, format: 'mp3' });
activeNativeSession = session;
return session;
} catch (error) {
finishWithError(error instanceof Error ? error : new Error('录音启动失败'));
return null;
}
};
const startBrowserCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
if (!supportsBrowserRecording()) return null;
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
let active = true;
let discarded = false;
const releaseStream = () => stream.getTracks().forEach((track) => track.stop());
const session: SpeechCaptureSession = {
stop: () => {
if (active && recorder.state !== 'inactive') recorder.stop();
},
cancel: () => {
if (!active) return;
discarded = true;
active = false;
if (recorder.state !== 'inactive') recorder.stop();
releaseStream();
},
dispose: () => session.cancel()
};
recorder.ondataavailable = (event) => {
if (event.data?.size) chunks.push(event.data);
};
recorder.onerror = () => {
if (!active) return;
active = false;
releaseStream();
handlers.onError?.(new Error('录音失败,请检查麦克风权限'));
};
recorder.onstop = () => {
if (!active) return;
active = false;
releaseStream();
if (discarded) return;
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
if (!blob.size) {
handlers.onError?.(new Error('未录到音频,请改用选择音频'));
return;
}
handlers.onCapture({ kind: 'blob', blob, filename: 'recording.webm' });
};
recorder.start();
return session;
};
/**
* Starts a microphone capture on the current platform. App-Plus produces a
* native temporary file; H5 preserves the existing MediaRecorder blob flow.
*/
export const startSpeechCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
if (isAppRuntime()) return startNativeCapture(handlers);
return startBrowserCapture(handlers);
};
export const isNativeAppRuntime = isAppRuntime;
+9
View File
@@ -1,5 +1,6 @@
import type { AsrResponse, SpeechPlaybackStatus, SpeechSelectedFile, SpeechVoiceProfile, TtsResponse } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import type { SpeechCapture } from './speech-capture';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
@@ -91,6 +92,14 @@ export const transcribeSpeechBlob = (
xhr.send(form);
});
export const transcribeSpeechCapture = (
capture: SpeechCapture,
filename: string,
registerAbort?: SpeechAbortRegistrar
) => capture.kind === 'blob'
? transcribeSpeechBlob(capture.blob, filename, registerAbort)
: transcribeSpeechFile(capture.file, registerAbort);
export const synthesizeSpeech = (text: string, voiceProfile?: SpeechVoiceProfile) =>
apiRequest<TtsResponse>({
url: '/api/ai/tts',