fix(mobile): support Android system file picker
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aihr-mobile-uni",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aihr-mobile-uni",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.16",
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-alpha-5020220260725001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-alpha-5020220260725001",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package",
|
||||
"name": "aihr-mobile-uni",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"appid" : "__UNI__B36D8BE",
|
||||
"description" : "帮道员工端",
|
||||
"vueVersion" : "3",
|
||||
"versionName" : "0.1.13",
|
||||
"versionCode" : "113",
|
||||
"versionName" : "0.1.16",
|
||||
"versionCode" : "116",
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import type {
|
||||
AsrResponse,
|
||||
InterviewFinishResponse,
|
||||
@@ -500,7 +501,7 @@ onShow(() => {
|
||||
|
||||
onHide(() => {
|
||||
speechPlayback.stop();
|
||||
cancelQuestionRecording();
|
||||
if (!isPlatformFileSelectionActive()) cancelQuestionRecording();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
|
||||
@@ -503,6 +503,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||
import RealtimePracticeAppBridge from '@/components/practice/RealtimePracticeAppBridge.vue';
|
||||
import type {
|
||||
@@ -1851,7 +1852,7 @@ onHide(() => {
|
||||
stopRealtime();
|
||||
invalidateRequests();
|
||||
speechPlayback.stop();
|
||||
cancelRecording();
|
||||
if (!isPlatformFileSelectionActive()) cancelRecording();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { chooseKnowledgeMedia } from '@/services/knowledge';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
@@ -769,13 +770,15 @@ const goLogin = () => {
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
const teardown = (preservePlatformFileSelection = false) => {
|
||||
organizeGeneration += 1;
|
||||
uploadGeneration += 1;
|
||||
mediaSelectionGeneration += 1;
|
||||
mineGeneration += 1;
|
||||
submitGeneration += 1;
|
||||
cancelVoiceTranscription();
|
||||
if (!preservePlatformFileSelection) {
|
||||
mediaSelectionGeneration += 1;
|
||||
cancelVoiceTranscription();
|
||||
}
|
||||
activeUploadTask?.abort();
|
||||
activeUploadTask = null;
|
||||
speechPlayback.stop();
|
||||
@@ -784,7 +787,7 @@ const teardown = () => {
|
||||
uploading.value = false;
|
||||
submitting.value = false;
|
||||
loadingMine.value = false;
|
||||
mediaSelecting.value = false;
|
||||
if (!preservePlatformFileSelection) mediaSelecting.value = false;
|
||||
};
|
||||
|
||||
const clearConversationState = () => {
|
||||
@@ -833,7 +836,7 @@ onShow(() => {
|
||||
}
|
||||
});
|
||||
|
||||
onHide(teardown);
|
||||
onHide(() => teardown(isPlatformFileSelectionActive()));
|
||||
onUnload(() => {
|
||||
teardown();
|
||||
speechPlayback.destroy();
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
BroadcastKnowledgeContext,
|
||||
@@ -1565,7 +1566,7 @@ onHide(() => {
|
||||
cancelPendingBroadcastConsultation();
|
||||
speechPlayback.stop();
|
||||
composerRef.value?.teardown();
|
||||
cancelVoiceTranscription();
|
||||
if (!isPlatformFileSelectionActive()) cancelVoiceTranscription();
|
||||
cancelPendingMediaSearch();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
} from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import { getAuth } from './auth';
|
||||
import { fileSelectionFailure } from '../utils/file-selection';
|
||||
import { chooseLocalFile } from './local-file-picker';
|
||||
|
||||
const materialExtensions = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.webp'];
|
||||
|
||||
@@ -38,28 +38,12 @@ export const getCandidateInterviewRecords = () =>
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const chooseCandidateFile = () =>
|
||||
new Promise<CandidateSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: materialExtensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<CandidateSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error('未获取到文件路径'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
path,
|
||||
name: file.name || path.split('/').pop() || 'candidate-material',
|
||||
size: Number(file.size || 0)
|
||||
});
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, '文件'))
|
||||
});
|
||||
export const chooseCandidateFile = (): Promise<CandidateSelectedFile> =>
|
||||
chooseLocalFile({
|
||||
label: '文件',
|
||||
mimeType: '*/*',
|
||||
extensions: materialExtensions,
|
||||
fallbackName: 'candidate-material'
|
||||
});
|
||||
|
||||
export const uploadCandidateMaterial = (file: CandidateSelectedFile, materialType = '补充资料') =>
|
||||
|
||||
@@ -8,32 +8,16 @@ import type {
|
||||
CaseUploadResponse
|
||||
} from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, authenticatedFileUrl, readTextPayload } from './api';
|
||||
import { fileSelectionFailure } from '../utils/file-selection';
|
||||
import { chooseLocalFile } from './local-file-picker';
|
||||
|
||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||
|
||||
export const chooseCaseAudio = () =>
|
||||
new Promise<CaseSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: audioExtensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<CaseSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error('未获取到文件路径'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
path,
|
||||
name: file.name || path.split('/').pop() || 'case-audio',
|
||||
size: Number(file.size || 0)
|
||||
});
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, '音频'))
|
||||
});
|
||||
export const chooseCaseAudio = (): Promise<CaseSelectedFile> =>
|
||||
chooseLocalFile({
|
||||
label: '音频',
|
||||
mimeType: 'audio/*',
|
||||
extensions: audioExtensions,
|
||||
fallbackName: 'case-audio'
|
||||
});
|
||||
|
||||
export const uploadCaseAudio = (file: CaseSelectedFile) =>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
let activePlatformFileSelections = 0;
|
||||
|
||||
export const isPlatformFileSelectionActive = () => activePlatformFileSelections > 0;
|
||||
|
||||
export const withPlatformFileSelection = async <T>(select: () => Promise<T>): Promise<T> => {
|
||||
activePlatformFileSelections += 1;
|
||||
try {
|
||||
return await select();
|
||||
} finally {
|
||||
activePlatformFileSelections = Math.max(0, activePlatformFileSelections - 1);
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import { getSelectedPosition } from './auth';
|
||||
import { fileSelectionFailure } from '../utils/file-selection';
|
||||
import { withPlatformFileSelection } from './file-selection-lifecycle';
|
||||
import { isAndroidAppRuntime } from './local-file-picker';
|
||||
|
||||
type RawKnowledgeSnippet = KnowledgeSnippet & {
|
||||
title?: string;
|
||||
@@ -20,7 +22,44 @@ type RawKnowledgeSnippet = KnowledgeSnippet & {
|
||||
const mediaExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'];
|
||||
const videoExtensions = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']);
|
||||
|
||||
export const chooseKnowledgeMedia = () =>
|
||||
interface AppMediaFile {
|
||||
tempFilePath?: string;
|
||||
path?: string;
|
||||
size?: number;
|
||||
fileType?: 'image' | 'video';
|
||||
}
|
||||
|
||||
interface AppChooseMediaResult {
|
||||
tempFiles?: AppMediaFile[];
|
||||
}
|
||||
|
||||
type UniWithChooseMedia = {
|
||||
chooseMedia?: (options: {
|
||||
count: number;
|
||||
mediaType: Array<'image' | 'video'>;
|
||||
sourceType: Array<'album'>;
|
||||
success: (result: AppChooseMediaResult) => void;
|
||||
fail: (error: unknown) => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const validateKnowledgeMedia = (
|
||||
path: string,
|
||||
name: string,
|
||||
size: number,
|
||||
declaredKind?: 'image' | 'video'
|
||||
): KnowledgeMediaSelectedFile => {
|
||||
if (!path) throw new Error('未获取到附件文件');
|
||||
const extension = `.${name.split('.').pop()?.toLowerCase() || ''}`;
|
||||
const kind = declaredKind || (videoExtensions.has(extension) ? 'video' : 'image');
|
||||
const maxSize = kind === 'video' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
|
||||
if (size > maxSize) {
|
||||
throw new Error(kind === 'video' ? '视频不能超过 100MB' : '图片不能超过 10MB');
|
||||
}
|
||||
return { path, name, size, kind };
|
||||
};
|
||||
|
||||
const chooseKnowledgeMediaWithFileInput = () =>
|
||||
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
@@ -31,24 +70,49 @@ export const chooseKnowledgeMedia = () =>
|
||||
const file = (tempFiles[0] || {}) as { path?: string; name?: string; size?: number };
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
const name = file.name || path.split('/').pop() || '现场附件';
|
||||
const extension = `.${name.split('.').pop()?.toLowerCase() || ''}`;
|
||||
const kind = videoExtensions.has(extension) ? 'video' : 'image';
|
||||
const size = Number(file.size || 0);
|
||||
const maxSize = kind === 'video' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
|
||||
if (!path) {
|
||||
reject(new Error('未获取到附件文件'));
|
||||
return;
|
||||
try {
|
||||
resolve(validateKnowledgeMedia(path, name, size));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
if (size > maxSize) {
|
||||
reject(new Error(kind === 'video' ? '视频不能超过 100MB' : '图片不能超过 10MB'));
|
||||
return;
|
||||
}
|
||||
resolve({ path, name, size, kind });
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, '附件'))
|
||||
});
|
||||
});
|
||||
|
||||
const chooseKnowledgeMediaWithAppPicker = () =>
|
||||
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
|
||||
const chooseMedia = (uni as unknown as UniWithChooseMedia).chooseMedia;
|
||||
if (!chooseMedia) {
|
||||
reject(new Error('当前 App 版本无法选择图片或视频'));
|
||||
return;
|
||||
}
|
||||
chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image', 'video'],
|
||||
sourceType: ['album'],
|
||||
success: (result) => {
|
||||
const file = result.tempFiles?.[0] || {};
|
||||
const path = file.tempFilePath || file.path || '';
|
||||
const name = path.split('/').pop() || (file.fileType === 'video' ? '现场视频.mp4' : '现场图片.jpg');
|
||||
// App chooseMedia reports the selected file size in KiB.
|
||||
const size = Math.max(0, Number(file.size || 0)) * 1024;
|
||||
try {
|
||||
resolve(validateKnowledgeMedia(path, name, size, file.fileType));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, '附件'))
|
||||
});
|
||||
});
|
||||
|
||||
export const chooseKnowledgeMedia = () =>
|
||||
isAndroidAppRuntime()
|
||||
? withPlatformFileSelection(chooseKnowledgeMediaWithAppPicker)
|
||||
: chooseKnowledgeMediaWithFileInput();
|
||||
|
||||
const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
|
||||
...snippet,
|
||||
docTitle: snippet.docTitle || snippet.title || '',
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { FileSelectionCancelledError, fileSelectionFailure } from '../utils/file-selection';
|
||||
import { withPlatformFileSelection } from './file-selection-lifecycle';
|
||||
|
||||
export interface LocalSelectedFile {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LocalFilePickerOptions {
|
||||
label: string;
|
||||
mimeType?: string;
|
||||
extensions?: string[];
|
||||
fallbackName?: string;
|
||||
}
|
||||
|
||||
interface AndroidBridge {
|
||||
importClass: (target: unknown) => any;
|
||||
invoke: (target: unknown, method: string, ...args: unknown[]) => any;
|
||||
runtimeMainActivity: () => any;
|
||||
}
|
||||
|
||||
interface AppPlusRuntime {
|
||||
os?: { name?: string };
|
||||
android?: AndroidBridge;
|
||||
}
|
||||
|
||||
const runtimePlus = () =>
|
||||
(globalThis as typeof globalThis & { plus?: AppPlusRuntime }).plus;
|
||||
|
||||
export const isAndroidAppRuntime = () => {
|
||||
const runtime = runtimePlus();
|
||||
return runtime?.os?.name?.toLowerCase() === 'android' && Boolean(runtime.android);
|
||||
};
|
||||
|
||||
const normalizeExtension = (value: string) => {
|
||||
const extension = value.trim().toLowerCase();
|
||||
if (!extension) return '';
|
||||
return extension.startsWith('.') ? extension : `.${extension}`;
|
||||
};
|
||||
|
||||
const safeFileName = (value: string, fallback: string) => {
|
||||
const normalized = value
|
||||
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(-120);
|
||||
return normalized || fallback;
|
||||
};
|
||||
|
||||
const selectedExtension = (name: string) => {
|
||||
const index = name.lastIndexOf('.');
|
||||
return index >= 0 ? name.slice(index).toLowerCase() : '';
|
||||
};
|
||||
|
||||
const validateExtension = (name: string, options: LocalFilePickerOptions) => {
|
||||
const accepted = (options.extensions || []).map(normalizeExtension).filter(Boolean);
|
||||
if (!accepted.length || !name) return;
|
||||
if (!accepted.includes(selectedExtension(name))) {
|
||||
throw new Error(`不支持该${options.label}格式`);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseWithUni = (options: LocalFilePickerOptions) =>
|
||||
new Promise<LocalSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: options.extensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<LocalSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error(`未获取到${options.label}文件`));
|
||||
return;
|
||||
}
|
||||
const name = file.name || path.split('/').pop() || options.fallbackName || 'selected-file';
|
||||
try {
|
||||
validateExtension(name, options);
|
||||
resolve({ path, name, size: Number(file.size || 0) });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, options.label))
|
||||
});
|
||||
});
|
||||
|
||||
const readAndroidMetadata = (bridge: AndroidBridge, resolver: any, uri: any) => {
|
||||
let cursor: any = null;
|
||||
try {
|
||||
cursor = resolver.query(uri, null, null, null, null);
|
||||
if (!cursor) return { name: '', size: 0 };
|
||||
bridge.importClass(cursor);
|
||||
if (!cursor.moveToFirst()) return { name: '', size: 0 };
|
||||
const nameIndex = Number(cursor.getColumnIndex('_display_name'));
|
||||
const sizeIndex = Number(cursor.getColumnIndex('_size'));
|
||||
return {
|
||||
name: nameIndex >= 0 ? String(cursor.getString(nameIndex) || '') : '',
|
||||
size: sizeIndex >= 0 ? Number(cursor.getLong(sizeIndex) || 0) : 0
|
||||
};
|
||||
} catch {
|
||||
return { name: '', size: 0 };
|
||||
} finally {
|
||||
try {
|
||||
cursor?.close();
|
||||
} catch {
|
||||
// Metadata is optional; stream copying below remains authoritative.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyAndroidUriToCache = (
|
||||
bridge: AndroidBridge,
|
||||
main: any,
|
||||
uri: any,
|
||||
options: LocalFilePickerOptions
|
||||
): LocalSelectedFile => {
|
||||
const resolver = main.getContentResolver();
|
||||
bridge.importClass(resolver);
|
||||
const metadata = readAndroidMetadata(bridge, resolver, uri);
|
||||
if (metadata.name) validateExtension(metadata.name, options);
|
||||
|
||||
const File = bridge.importClass('java.io.File');
|
||||
const FileInputStream = bridge.importClass('java.io.FileInputStream');
|
||||
const FileOutputStream = bridge.importClass('java.io.FileOutputStream');
|
||||
// uni.uploadFile on App reliably consumes app-scoped external cache paths.
|
||||
// Files under /data/user/0/... can be opened by this activity but may be
|
||||
// rejected or truncated by DCloud's native multipart uploader.
|
||||
const cacheRoot = main.getExternalCacheDir() || main.getCacheDir();
|
||||
bridge.importClass(cacheRoot);
|
||||
const pickerDirectory = new File(cacheRoot, 'aihr-picker');
|
||||
pickerDirectory.mkdirs();
|
||||
|
||||
const fallbackName = options.fallbackName || `selected-${options.label}`;
|
||||
const name = safeFileName(metadata.name, fallbackName);
|
||||
const target = new File(pickerDirectory, `${Date.now()}-${name}`);
|
||||
const descriptor = resolver.openFileDescriptor(uri, 'r');
|
||||
if (!descriptor) throw new Error(`无法读取所选${options.label}`);
|
||||
|
||||
bridge.importClass(descriptor);
|
||||
const fileDescriptor = descriptor.getFileDescriptor();
|
||||
bridge.importClass(fileDescriptor);
|
||||
const input = new FileInputStream(fileDescriptor);
|
||||
const output = new FileOutputStream(target);
|
||||
bridge.importClass(input);
|
||||
bridge.importClass(output);
|
||||
const sourceChannel = input.getChannel();
|
||||
const targetChannel = output.getChannel();
|
||||
bridge.importClass(sourceChannel);
|
||||
bridge.importClass(targetChannel);
|
||||
let copied = 0;
|
||||
try {
|
||||
while (true) {
|
||||
// Native.js cannot reliably marshal Java byte[] arguments. FileChannel
|
||||
// keeps the SAF content copy entirely on the Java side.
|
||||
const count = Number(bridge.invoke(
|
||||
targetChannel,
|
||||
'transferFrom',
|
||||
sourceChannel,
|
||||
copied,
|
||||
16 * 1024 * 1024
|
||||
));
|
||||
if (!Number.isFinite(count) || count <= 0) break;
|
||||
copied += count;
|
||||
}
|
||||
output.flush();
|
||||
} catch {
|
||||
try {
|
||||
target.delete();
|
||||
} catch {
|
||||
// The cache directory is private and may be cleaned by the system.
|
||||
}
|
||||
throw new Error(`无法读取所选${options.label}`);
|
||||
} finally {
|
||||
try {
|
||||
input.close();
|
||||
} catch {
|
||||
// Best-effort native stream cleanup.
|
||||
}
|
||||
try {
|
||||
output.close();
|
||||
} catch {
|
||||
// Best-effort native stream cleanup.
|
||||
}
|
||||
try {
|
||||
descriptor.close();
|
||||
} catch {
|
||||
// Best-effort descriptor cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
const path = String(target.getAbsolutePath() || '');
|
||||
if (!path || copied <= 0) {
|
||||
try {
|
||||
target.delete();
|
||||
} catch {
|
||||
// The empty cache file is harmless if the platform already removed it.
|
||||
}
|
||||
throw new Error(`未获取到${options.label}文件`);
|
||||
}
|
||||
return {
|
||||
path,
|
||||
name,
|
||||
size: copied || metadata.size
|
||||
};
|
||||
};
|
||||
|
||||
const chooseWithAndroidDocumentPicker = (options: LocalFilePickerOptions) =>
|
||||
new Promise<LocalSelectedFile>((resolve, reject) => {
|
||||
const runtime = runtimePlus();
|
||||
const bridge = runtime?.android;
|
||||
if (!bridge) {
|
||||
reject(new Error(`无法选择${options.label},请重试`));
|
||||
return;
|
||||
}
|
||||
|
||||
const main = bridge.runtimeMainActivity();
|
||||
const Intent = bridge.importClass('android.content.Intent');
|
||||
const Activity = bridge.importClass('android.app.Activity');
|
||||
const requestCode = 0x4a17;
|
||||
const previousHandler = main.onActivityResult;
|
||||
let settled = false;
|
||||
const restoreHandler = () => {
|
||||
if (main.onActivityResult === onActivityResult) {
|
||||
main.onActivityResult = previousHandler || null;
|
||||
}
|
||||
};
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
restoreHandler();
|
||||
callback();
|
||||
};
|
||||
const onActivityResult = (incomingCode: number, resultCode: number, data: any) => {
|
||||
if (incomingCode !== requestCode) {
|
||||
if (typeof previousHandler === 'function') previousHandler(incomingCode, resultCode, data);
|
||||
return;
|
||||
}
|
||||
if (resultCode !== Activity.RESULT_OK || !data) {
|
||||
finish(() => reject(new FileSelectionCancelledError(options.label)));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
bridge.importClass(data);
|
||||
const uri = data.getData();
|
||||
if (!uri) {
|
||||
finish(() => reject(new FileSelectionCancelledError(options.label)));
|
||||
return;
|
||||
}
|
||||
bridge.importClass(uri);
|
||||
const selected = copyAndroidUriToCache(bridge, main, uri, options);
|
||||
finish(() => resolve(selected));
|
||||
} catch (error) {
|
||||
finish(() => reject(error instanceof Error
|
||||
? error
|
||||
: new Error(`无法选择${options.label},请重试`)));
|
||||
}
|
||||
};
|
||||
|
||||
main.onActivityResult = onActivityResult;
|
||||
try {
|
||||
const intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType(options.mimeType || '*/*');
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
main.startActivityForResult(intent, requestCode);
|
||||
} catch (error) {
|
||||
finish(() => reject(fileSelectionFailure(error, options.label)));
|
||||
}
|
||||
});
|
||||
|
||||
export const chooseLocalFile = (options: LocalFilePickerOptions) =>
|
||||
isAndroidAppRuntime()
|
||||
? withPlatformFileSelection(() => chooseWithAndroidDocumentPicker(options))
|
||||
: chooseWithUni(options);
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
PersonalSpace,
|
||||
ServiceMemory
|
||||
} from '@/types/api';
|
||||
import { fileSelectionFailure } from '../utils/file-selection';
|
||||
import { chooseLocalFile } from './local-file-picker';
|
||||
import type { CaptureStatusHistory } from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import { personalItemApiPath, personalSessionApiPath } from './personal-assistant-id';
|
||||
@@ -140,14 +140,8 @@ export const createUrlItem = (data: { url: string; title?: string; capturedAt?:
|
||||
apiRequest<PersonalItemCreated>({ url: '/api/aihr/personal-assistant/items/url', method: 'POST', data, timeout: 45000 });
|
||||
|
||||
export interface PersonalSelectedFile { path: string; name: string; size: number; }
|
||||
export const choosePersonalFile = () => new Promise<PersonalSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({ count: 1, type: 'all', success: (result) => {
|
||||
const file = (Array.isArray(result.tempFiles) ? result.tempFiles[0] : undefined) as Partial<PersonalSelectedFile> | undefined;
|
||||
const path = file?.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) return reject(new Error('未获取到文件路径'));
|
||||
resolve({ path, name: file?.name || path.split('/').pop() || 'personal-file', size: Number(file?.size || 0) });
|
||||
}, fail: (error) => reject(fileSelectionFailure(error, '文件')) });
|
||||
});
|
||||
export const choosePersonalFile = (): Promise<PersonalSelectedFile> =>
|
||||
chooseLocalFile({ label: '文件', mimeType: '*/*', fallbackName: 'personal-file' });
|
||||
|
||||
export const uploadPersonalFile = (file: PersonalSelectedFile, title?: string, capturedAt?: string) =>
|
||||
new Promise<PersonalItemCreated>((resolve, reject) => uni.uploadFile({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SpeechSelectedFile } from '@/types/api';
|
||||
import { microphoneDeniedMessage, requestMicrophonePermission } from '../utils/microphone';
|
||||
|
||||
export type SpeechCapture =
|
||||
| { kind: 'blob'; blob: Blob; filename: string }
|
||||
@@ -163,7 +164,14 @@ const startBrowserCapture = async (handlers: SpeechCaptureHandlers): Promise<Spe
|
||||
* native temporary file; H5 preserves the existing MediaRecorder blob flow.
|
||||
*/
|
||||
export const startSpeechCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
|
||||
if (isAppRuntime()) return startNativeCapture(handlers);
|
||||
if (isAppRuntime()) {
|
||||
const permission = await requestMicrophonePermission();
|
||||
if (permission === 'denied' || permission === 'denied-always') {
|
||||
handlers.onError?.(new Error(microphoneDeniedMessage(permission)));
|
||||
return null;
|
||||
}
|
||||
return startNativeCapture(handlers);
|
||||
}
|
||||
return startBrowserCapture(handlers);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { SpeechCapture } from './speech-capture';
|
||||
import { estimateMpegAudioDuration } from './mpeg-audio-duration';
|
||||
import { dataUriToBlobUrl, decodeDataAudioUri, hasNativeAudioFileSupport, isDataAudioUri, removeNativeTempAudio, writeNativeTempAudio } from './speech-audio-source';
|
||||
import { applyTtsVoicePreference, getTtsDialectPreference, getTtsVoicePreference } from './tts-voice-preference';
|
||||
import { fileSelectionFailure } from '../utils/file-selection';
|
||||
import { chooseLocalFile } from './local-file-picker';
|
||||
|
||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||
|
||||
@@ -26,28 +26,12 @@ export const normalizeOssId = (value: unknown): string | undefined => {
|
||||
return /^\d+$/.test(text) ? text : undefined;
|
||||
};
|
||||
|
||||
export const chooseSpeechAudio = () =>
|
||||
new Promise<SpeechSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: audioExtensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<SpeechSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error('未获取到音频文件'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
path,
|
||||
name: file.name || path.split('/').pop() || 'practice-audio',
|
||||
size: Number(file.size || 0)
|
||||
});
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, '音频'))
|
||||
});
|
||||
export const chooseSpeechAudio = (): Promise<SpeechSelectedFile> =>
|
||||
chooseLocalFile({
|
||||
label: '音频',
|
||||
mimeType: 'audio/*',
|
||||
extensions: audioExtensions,
|
||||
fallbackName: 'practice-audio'
|
||||
});
|
||||
|
||||
export type SpeechAbortRegistrar = (abort: (() => void) | null) => void;
|
||||
|
||||
@@ -13,12 +13,15 @@ const vite = await createServer({
|
||||
});
|
||||
|
||||
const originalUni = globalThis.uni;
|
||||
const originalPlus = globalThis.plus;
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
after(async () => {
|
||||
await vite.close();
|
||||
if (originalUni === undefined) delete globalThis.uni;
|
||||
else globalThis.uni = originalUni;
|
||||
if (originalPlus === undefined) delete globalThis.plus;
|
||||
else globalThis.plus = originalPlus;
|
||||
});
|
||||
|
||||
const [
|
||||
@@ -36,6 +39,7 @@ const [
|
||||
vite.ssrLoadModule('/src/services/knowledge.ts'),
|
||||
vite.ssrLoadModule('/src/services/personal-assistant.ts')
|
||||
]);
|
||||
const fileSelectionLifecycle = await vite.ssrLoadModule('/src/services/file-selection-lifecycle.ts');
|
||||
|
||||
const pickers = [
|
||||
['音频', speech.chooseSpeechAudio],
|
||||
@@ -45,6 +49,93 @@ const pickers = [
|
||||
['文件', personalAssistant.choosePersonalFile]
|
||||
];
|
||||
|
||||
const androidPickerRuntime = ({ cancelled = false } = {}) => {
|
||||
class FakeIntent {
|
||||
static ACTION_OPEN_DOCUMENT = 'android.intent.action.OPEN_DOCUMENT';
|
||||
static CATEGORY_OPENABLE = 'android.intent.category.OPENABLE';
|
||||
static FLAG_GRANT_READ_URI_PERMISSION = 1;
|
||||
constructor(action) { this.action = action; }
|
||||
addCategory(category) { this.category = category; }
|
||||
setType(type) { this.type = type; }
|
||||
addFlags(flags) { this.flags = flags; }
|
||||
}
|
||||
class FakeActivity {
|
||||
static RESULT_OK = 1;
|
||||
}
|
||||
class FakeFile {
|
||||
constructor(parent, name) {
|
||||
this.path = `${typeof parent === 'string' ? parent : parent.path}/${name}`;
|
||||
}
|
||||
mkdirs() {}
|
||||
delete() {}
|
||||
getAbsolutePath() { return this.path; }
|
||||
}
|
||||
class FakeFileOutputStream {
|
||||
constructor(target) { this.target = target; }
|
||||
getChannel() {
|
||||
return {
|
||||
transferFrom(source) {
|
||||
const count = source.remaining;
|
||||
source.remaining = 0;
|
||||
return count;
|
||||
}
|
||||
};
|
||||
}
|
||||
flush() {}
|
||||
close() {}
|
||||
}
|
||||
class FakeFileInputStream {
|
||||
constructor(fileDescriptor) { this.fileDescriptor = fileDescriptor; }
|
||||
getChannel() { return { remaining: 3 }; }
|
||||
close() {}
|
||||
}
|
||||
const cursor = {
|
||||
moveToFirst: () => true,
|
||||
getColumnIndex: (name) => name === '_display_name' ? 0 : 1,
|
||||
getString: () => 'acceptance.mp3',
|
||||
getLong: () => 3,
|
||||
close() {}
|
||||
};
|
||||
const resolver = {
|
||||
query: () => cursor,
|
||||
openFileDescriptor: () => ({
|
||||
getFileDescriptor: () => ({}),
|
||||
close() {}
|
||||
})
|
||||
};
|
||||
const main = {
|
||||
onActivityResult: null,
|
||||
getCacheDir: () => ({ path: '/cache' }),
|
||||
getExternalCacheDir: () => ({ path: '/external-cache' }),
|
||||
getContentResolver: () => resolver,
|
||||
startActivityForResult(intent, requestCode) {
|
||||
assert.equal(intent.action, FakeIntent.ACTION_OPEN_DOCUMENT);
|
||||
assert.equal(intent.category, FakeIntent.CATEGORY_OPENABLE);
|
||||
assert.equal(intent.type, 'audio/*');
|
||||
queueMicrotask(() => this.onActivityResult(
|
||||
requestCode,
|
||||
cancelled ? 0 : FakeActivity.RESULT_OK,
|
||||
cancelled ? null : { getData: () => ({}) }
|
||||
));
|
||||
}
|
||||
};
|
||||
const classes = {
|
||||
'android.content.Intent': FakeIntent,
|
||||
'android.app.Activity': FakeActivity,
|
||||
'java.io.File': FakeFile,
|
||||
'java.io.FileInputStream': FakeFileInputStream,
|
||||
'java.io.FileOutputStream': FakeFileOutputStream
|
||||
};
|
||||
return {
|
||||
os: { name: 'Android' },
|
||||
android: {
|
||||
importClass: (target) => typeof target === 'string' ? classes[target] : target,
|
||||
invoke: (target, method, ...args) => target[method](...args),
|
||||
runtimeMainActivity: () => main
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
test('所有文件选择器把用户取消识别为同一可静默处理的信号', async () => {
|
||||
globalThis.uni = {
|
||||
chooseFile: ({ fail }) => fail({ errMsg: 'chooseFile:fail cancel' })
|
||||
@@ -81,6 +172,65 @@ test('无结构异常也不会被误判为取消', () => {
|
||||
assert.equal(error.message, '无法选择附件,请重试');
|
||||
});
|
||||
|
||||
test('Android App 使用系统文档选择器并复制音频到应用缓存', async () => {
|
||||
globalThis.plus = androidPickerRuntime();
|
||||
globalThis.uni = { chooseFile: () => assert.fail('Android App 不应调用不受支持的 uni.chooseFile') };
|
||||
|
||||
const selected = await speech.chooseSpeechAudio();
|
||||
assert.equal(selected.name, 'acceptance.mp3');
|
||||
assert.equal(selected.size, 3);
|
||||
assert.match(selected.path, /^\/external-cache\/aihr-picker\/\d+-acceptance\.mp3$/);
|
||||
delete globalThis.plus;
|
||||
});
|
||||
|
||||
test('Android 文件选择期间保持页面请求有效,并在完成后恢复生命周期状态', async () => {
|
||||
let finishSelection;
|
||||
const selection = fileSelectionLifecycle.withPlatformFileSelection(() =>
|
||||
new Promise((resolve) => { finishSelection = resolve; })
|
||||
);
|
||||
|
||||
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), true);
|
||||
finishSelection('selected');
|
||||
assert.equal(await selection, 'selected');
|
||||
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), false);
|
||||
});
|
||||
|
||||
test('Android App 图片和视频使用系统媒体选择器', async () => {
|
||||
globalThis.plus = androidPickerRuntime();
|
||||
globalThis.uni = {
|
||||
chooseFile: () => assert.fail('Android App 媒体不应调用不受支持的 uni.chooseFile'),
|
||||
chooseMedia: ({ mediaType, sourceType, success }) => {
|
||||
assert.deepEqual(mediaType, ['image', 'video']);
|
||||
assert.deepEqual(sourceType, ['album']);
|
||||
success({
|
||||
tempFiles: [{
|
||||
tempFilePath: '/cache/acceptance.mp4',
|
||||
fileType: 'video',
|
||||
size: 12
|
||||
}]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const selected = await knowledge.chooseKnowledgeMedia();
|
||||
assert.equal(selected.path, '/cache/acceptance.mp4');
|
||||
assert.equal(selected.kind, 'video');
|
||||
assert.equal(selected.size, 12 * 1024);
|
||||
delete globalThis.plus;
|
||||
});
|
||||
|
||||
test('Android 系统文档选择器取消仍返回统一取消信号', async () => {
|
||||
globalThis.plus = androidPickerRuntime({ cancelled: true });
|
||||
globalThis.uni = { chooseFile: () => assert.fail('Android App 不应调用不受支持的 uni.chooseFile') };
|
||||
|
||||
await assert.rejects(speech.chooseSpeechAudio(), (error) => {
|
||||
assert.equal(fileSelection.isFileSelectionCancelled(error), true);
|
||||
assert.equal(error.message, '未选择音频');
|
||||
return true;
|
||||
});
|
||||
delete globalThis.plus;
|
||||
});
|
||||
|
||||
test('所有使用文件选择器的页面都按取消信号恢复空闲态而不展示错误', async () => {
|
||||
const pages = await Promise.all([
|
||||
'../src/pages/candidate/interview/index.vue',
|
||||
@@ -100,3 +250,24 @@ test('所有使用文件选择器的页面都按取消信号恢复空闲态而
|
||||
assert.match(pages[0], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/);
|
||||
assert.match(pages[6], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/);
|
||||
});
|
||||
|
||||
test('会作废转写请求的页面在 Android 系统选择器打开时保留选择任务', async () => {
|
||||
const pages = await Promise.all([
|
||||
'../src/pages/candidate/interview/index.vue',
|
||||
'../src/pages/user/practice/index.vue',
|
||||
'../src/pages/user/sop/index.vue',
|
||||
'../src/pages/user/report/index.vue'
|
||||
].map(read));
|
||||
|
||||
for (const page of pages) assert.match(page, /isPlatformFileSelectionActive\(\)/);
|
||||
assert.match(pages[0], /if \(!isPlatformFileSelectionActive\(\)\) cancelQuestionRecording\(\)/);
|
||||
assert.match(pages[1], /if \(!isPlatformFileSelectionActive\(\)\) cancelRecording\(\)/);
|
||||
assert.match(pages[2], /if \(!isPlatformFileSelectionActive\(\)\) cancelVoiceTranscription\(\)/);
|
||||
assert.match(pages[3], /teardown\(isPlatformFileSelectionActive\(\)\)/);
|
||||
});
|
||||
|
||||
test('Android SAF 复制完全在 Java FileChannel 内完成,不回退 Native.js byte 数组', async () => {
|
||||
const picker = await read('../src/services/local-file-picker.ts');
|
||||
assert.match(picker, /transferFrom/);
|
||||
assert.doesNotMatch(picker, /newObject\(\s*['"]byte\[\]/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { after, test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const vite = await createServer({
|
||||
root: fileURLToPath(new URL('..', import.meta.url)),
|
||||
configFile: false,
|
||||
optimizeDeps: { noDiscovery: true },
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await vite.close();
|
||||
delete globalThis.plus;
|
||||
delete globalThis.uni;
|
||||
});
|
||||
|
||||
test('App recording falls back before RecorderManager starts when microphone permission is denied', async () => {
|
||||
let recorderRequested = false;
|
||||
globalThis.plus = {
|
||||
android: {
|
||||
requestPermissions: (_permissions, success) => {
|
||||
success({ deniedAlways: ['android.permission.RECORD_AUDIO'] });
|
||||
}
|
||||
}
|
||||
};
|
||||
globalThis.uni = {
|
||||
getRecorderManager: () => {
|
||||
recorderRequested = true;
|
||||
throw new Error('RecorderManager should not start when permission is denied');
|
||||
}
|
||||
};
|
||||
|
||||
const { startSpeechCapture } = await vite.ssrLoadModule('/src/services/speech-capture.ts');
|
||||
let fallbackError;
|
||||
const session = await startSpeechCapture({
|
||||
onCapture: () => assert.fail('denied permission must not return a recording'),
|
||||
onError: (error) => {
|
||||
fallbackError = error;
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(session, null);
|
||||
assert.equal(recorderRequested, false);
|
||||
assert.ok(fallbackError instanceof Error);
|
||||
});
|
||||
@@ -114,7 +114,7 @@ test('异步请求具备 generation、上传取消和离页 teardown', async ()
|
||||
assert.match(page, /:disabled="draftLocked \|\| submitActionDisabled"/);
|
||||
assert.match(page, /composerRef\.value\?\.teardown\(\)/);
|
||||
assert.match(page, /speechPlayback\.stop\(\)/);
|
||||
assert.match(page, /onHide\(teardown\)/);
|
||||
assert.match(page, /onHide\(\(\) => teardown\(isPlatformFileSelectionActive\(\)\)\)/);
|
||||
assert.match(page, /onUnload/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user