fix(mobile): handle file picker cancellation

This commit is contained in:
key
2026-07-29 02:01:45 +08:00
parent fd59421574
commit cb928edafd
18 changed files with 202 additions and 26 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "aihr-mobile-uni",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -3,8 +3,8 @@
"appid" : "__UNI__B36D8BE",
"description" : "帮道员工端",
"vueVersion" : "3",
"versionName" : "0.1.8",
"versionCode" : "108",
"versionName" : "0.1.9",
"versionCode" : "109",
"uniStatistics" : {
"enable" : false
},
@@ -164,6 +164,7 @@ import {
} from '@/services/speech';
import { startSpeechCapture } from '@/services/speech-capture';
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
import { isFileSelectionCancelled } from '@/utils/file-selection';
const currentPath = '/pages/candidate/interview/index';
const records = ref<InterviewRecord[]>([]);
@@ -342,6 +343,10 @@ const transcribeQuestionFile = async (questionId: string) => {
applyQuestionAsrResult(questionId, result);
} catch (error) {
if (generation !== questionTranscriptionGeneration) return;
if (isFileSelectionCancelled(error)) {
message.value = '';
return;
}
handleRequestError(error, '语音转写失败');
} finally {
if (generation === questionTranscriptionGeneration) {
@@ -72,6 +72,7 @@ import {
} from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
import StateBlock from '@/components/StateBlock.vue';
import { isFileSelectionCancelled } from '@/utils/file-selection';
const currentPath = '/pages/candidate/materials/index';
const materials = ref<CandidateMaterial[]>([]);
@@ -131,7 +132,7 @@ const uploadMaterial = async () => {
message.value = `资料已上传:#${saved.id}`;
materials.value = await getCandidateMaterials();
} catch (error) {
handleRequestError(error, '资料上传失败');
if (!isFileSelectionCancelled(error)) handleRequestError(error, '资料上传失败');
} finally {
uploading.value = false;
}
@@ -27,6 +27,7 @@
import { ref } from 'vue';
import { choosePersonalFile, createTextItem, createUrlItem, uploadPersonalFile, type PersonalSelectedFile } from '@/services/personal-assistant';
import type { PersonalItemSourceType } from '@/types/api';
import { isFileSelectionCancelled } from '@/utils/file-selection';
const typeOptions = [{ label: '文字', value: 'TEXT' }, { label: '文件', value: 'FILE' }, { label: '网页', value: 'URL' }] as const;
const captureType = ref<PersonalItemSourceType>('TEXT');
@@ -36,7 +37,15 @@ const url = ref('');
const selectedFile = ref<PersonalSelectedFile>();
const submitting = ref(false);
const message = ref('');
const chooseFile = async () => { try { selectedFile.value = await choosePersonalFile(); } catch (error) { message.value = error instanceof Error ? error.message : '未选择文件'; } };
const chooseFile = async () => {
try {
selectedFile.value = await choosePersonalFile();
} catch (error) {
if (!isFileSelectionCancelled(error)) {
message.value = error instanceof Error ? error.message : '选择文件失败';
}
}
};
const submit = async () => {
if (submitting.value) return;
if (captureType.value === 'TEXT' && !content.value.trim()) { message.value = '请输入需要收藏的文字'; return; }
@@ -148,6 +148,7 @@ import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
import StateBlock from '@/components/StateBlock.vue';
import { isFileSelectionCancelled } from '@/utils/file-selection';
const loggedIn = ref(false);
const selectedFile = ref<CaseSelectedFile | null>(null);
@@ -213,8 +214,10 @@ const selectAudio = async () => {
selectedFile.value = await chooseCaseAudio();
resetResult();
} catch (error) {
if (!isFileSelectionCancelled(error)) {
message.value = error instanceof Error ? error.message : '选择音频失败';
}
}
};
const loadRecords = async () => {
+8 -1
View File
@@ -532,6 +532,7 @@ import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } fr
import { createRealtimePracticeController, createRealtimeSession, fetchRealtimePersonas, invokeRealtimeTool, type RealtimePersonaOption } from '@/services/realtime-practice';
import { stripInternalCodes } from '@/services/text';
import { describeMicrophoneError, microphoneDeniedMessage, requestMicrophonePermission } from '@/utils/microphone';
import { isFileSelectionCancelled } from '@/utils/file-selection';
import { safeTopPadding } from '@/utils/safe-area';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
@@ -913,8 +914,10 @@ const composerPickAudio = async () => {
await submitTranscribed(text, asr.audioUrl || '', normalizeOssId(asr.ossId));
} catch (error) {
if (generation !== transcriptionGeneration) return;
if (!isFileSelectionCancelled(error)) {
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
if (text !== '未选择音频') uni.showToast({ title: text, icon: 'none' });
uni.showToast({ title: text, icon: 'none' });
}
} finally {
if (generation === transcriptionGeneration) {
abortTranscription = null;
@@ -1226,6 +1229,10 @@ const transcribeCapture = async (capture: SpeechCapture, assignmentId?: number)
}
} catch (error) {
if (generation !== transcriptionGeneration) return;
if (isFileSelectionCancelled(error)) {
message.value = '';
return;
}
handleError(error, '语音转写失败');
} finally {
if (generation === transcriptionGeneration) {
+7 -2
View File
@@ -244,6 +244,7 @@ import {
} from '@/services/speech';
import type { SpeechCapture } from '@/services/speech-capture';
import type { SpeechPlaybackStatus } from '@/types/api';
import { isFileSelectionCancelled } from '@/utils/file-selection';
import {
listMyWorkReports,
isCurrentWorkReportRequest,
@@ -547,8 +548,10 @@ const addMediaAttachment = async () => {
});
} catch (error) {
if (!requestIsCurrent(generation, mediaSelectionGeneration, accountKey)) return;
if (!isFileSelectionCancelled(error)) {
const text = error instanceof Error ? error.message : '附件选择失败';
if (text !== '未选择附件') pushSystem(text);
pushSystem(text);
}
} finally {
if (requestIsCurrent(generation, mediaSelectionGeneration, accountKey)) mediaSelecting.value = false;
}
@@ -580,8 +583,10 @@ const pickVoiceAudio = async () => {
});
} catch (error) {
if (!requestIsCurrent(generation, voiceTranscriptionGeneration, accountKey)) return;
if (!isFileSelectionCancelled(error)) {
const text = error instanceof Error ? error.message : '音频转写失败,请重试或改用键盘';
if (text !== '未选择音频') pushSystem(text);
pushSystem(text);
}
} finally {
if (requestIsCurrent(generation, voiceTranscriptionGeneration, accountKey)) {
abortVoiceTranscription = null;
+7 -2
View File
@@ -463,6 +463,7 @@ import { isNativeAppRuntime, type SpeechCapture } from '@/services/speech-captur
import { downloadSummaryCardImage } from '@/services/summary-card-image';
import { getMemoryCandidates } from '@/services/personal-assistant';
import { currentAccountKey } from '@/services/request-scope';
import { isFileSelectionCancelled } from '@/utils/file-selection';
import {
clearBroadcastQuestionContext,
getBroadcastMessage,
@@ -1084,8 +1085,10 @@ const pickMedia = async () => {
icon: 'none'
});
} catch (error) {
if (!isFileSelectionCancelled(error)) {
const text = error instanceof Error ? error.message : '附件选择失败';
if (text !== '未选择附件') uni.showToast({ title: text, icon: 'none' });
uni.showToast({ title: text, icon: 'none' });
}
}
};
@@ -1150,8 +1153,10 @@ const pickVoiceAudio = async () => {
void sendQuestion(text, { voice: true });
} catch (error) {
if (generation !== voiceTranscriptionGeneration) return;
if (!isFileSelectionCancelled(error)) {
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
if (text !== '未选择音频') pushSystem(text);
pushSystem(text);
}
} finally {
if (generation === voiceTranscriptionGeneration) {
abortVoiceTranscription = null;
+2 -1
View File
@@ -218,6 +218,7 @@ import { stripInternalCodes } from '@/services/text';
import { ensureEmployeePosition } from '@/services/position';
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
import { getBroadcastUnreadCount } from '@/services/broadcast';
import { isFileSelectionCancelled } from '@/utils/file-selection';
const home = ref<RoleHome | null>(null);
const assignments = ref<PracticeAssignment[]>([]);
@@ -499,7 +500,7 @@ const pickAudioFallback = async () => {
if (text) ask(text);
else message.value = '没听清,请再说一次';
} catch (error) {
if (error instanceof Error && error.message !== '未选择音频') {
if (!isFileSelectionCancelled(error) && error instanceof Error) {
message.value = error.message;
}
} finally {
+7 -2
View File
@@ -123,6 +123,7 @@ import { chooseKnowledgeMedia } from '@/services/knowledge';
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 { isFileSelectionCancelled } from '@/utils/file-selection';
import { renderMarkdown } from '@/utils/markdown';
const path = '/pages/user/web-ai/index';
@@ -330,8 +331,10 @@ const handlePanelAction = async (key: string) => {
composerRef.value?.setDraft('');
uni.showToast({ title: '已选择图片,发送时将联网识图', icon: 'none' });
} catch (error) {
if (!isFileSelectionCancelled(error)) {
const text = error instanceof Error ? error.message : '图片选择失败';
if (text !== '未选择附件') uni.showToast({ title: text, icon: 'none' });
uni.showToast({ title: text, icon: 'none' });
}
}
return;
}
@@ -353,8 +356,10 @@ const handlePanelAction = async (key: string) => {
void sendQuestion(text, { voice: true });
} catch (error) {
if (generation !== voiceTranscriptionGeneration) return;
if (!isFileSelectionCancelled(error)) {
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
if (text !== '未选择音频') pushSystem(text);
pushSystem(text);
}
} finally {
if (generation === voiceTranscriptionGeneration) {
abortVoiceTranscription = null;
+2 -1
View File
@@ -7,6 +7,7 @@ import type {
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { getAuth } from './auth';
import { fileSelectionFailure } from '../utils/file-selection';
const materialExtensions = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.webp'];
@@ -57,7 +58,7 @@ export const chooseCandidateFile = () =>
size: Number(file.size || 0)
});
},
fail: () => reject(new Error('未选择文件'))
fail: (error) => reject(fileSelectionFailure(error, '文件'))
});
});
+2 -1
View File
@@ -8,6 +8,7 @@ import type {
CaseUploadResponse
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, authenticatedFileUrl, readTextPayload } from './api';
import { fileSelectionFailure } from '../utils/file-selection';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
@@ -31,7 +32,7 @@ export const chooseCaseAudio = () =>
size: Number(file.size || 0)
});
},
fail: () => reject(new Error('未选择音频'))
fail: (error) => reject(fileSelectionFailure(error, '音频'))
});
});
+2 -1
View File
@@ -10,6 +10,7 @@ import type {
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { getSelectedPosition } from './auth';
import { fileSelectionFailure } from '../utils/file-selection';
type RawKnowledgeSnippet = KnowledgeSnippet & {
title?: string;
@@ -44,7 +45,7 @@ export const chooseKnowledgeMedia = () =>
}
resolve({ path, name, size, kind });
},
fail: () => reject(new Error('未选择附件'))
fail: (error) => reject(fileSelectionFailure(error, '附件'))
});
});
@@ -17,6 +17,7 @@ import type {
PersonalSpace,
ServiceMemory
} from '@/types/api';
import { fileSelectionFailure } from '../utils/file-selection';
import type { CaptureStatusHistory } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { personalItemApiPath, personalSessionApiPath } from './personal-assistant-id';
@@ -145,7 +146,7 @@ export const choosePersonalFile = () => new Promise<PersonalSelectedFile>((resol
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: () => reject(new Error('未选择文件')) });
}, fail: (error) => reject(fileSelectionFailure(error, '文件')) });
});
export const uploadPersonalFile = (file: PersonalSelectedFile, title?: string, capturedAt?: string) =>
+2 -1
View File
@@ -5,6 +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';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
@@ -45,7 +46,7 @@ export const chooseSpeechAudio = () =>
size: Number(file.size || 0)
});
},
fail: () => reject(new Error('未选择音频'))
fail: (error) => reject(fileSelectionFailure(error, '音频'))
});
});
+28
View File
@@ -0,0 +1,28 @@
export const FILE_SELECTION_CANCELLED = 'FILE_SELECTION_CANCELLED';
export class FileSelectionCancelledError extends Error {
readonly code = FILE_SELECTION_CANCELLED;
constructor(label: string) {
super(`未选择${label}`);
this.name = 'FileSelectionCancelledError';
}
}
const failureSignal = (failure: unknown): string => {
if (typeof failure === 'string') return failure;
if (!failure || typeof failure !== 'object') return '';
const value = failure as { errMsg?: unknown; message?: unknown };
return String(value.errMsg || value.message || '');
};
export const fileSelectionFailure = (failure: unknown, label: string): Error => {
const signal = failureSignal(failure).toLowerCase();
if (signal.includes('cancel')) return new FileSelectionCancelledError(label);
return new Error(`无法选择${label},请重试`);
};
export const isFileSelectionCancelled = (error: unknown): boolean =>
error instanceof FileSelectionCancelledError
|| Boolean(error && typeof error === 'object'
&& (error as { code?: unknown }).code === FILE_SELECTION_CANCELLED);
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
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'
});
const originalUni = globalThis.uni;
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;
});
const [
fileSelection,
speech,
caseService,
candidate,
knowledge,
personalAssistant
] = await Promise.all([
vite.ssrLoadModule('/src/utils/file-selection.ts'),
vite.ssrLoadModule('/src/services/speech.ts'),
vite.ssrLoadModule('/src/services/case.ts'),
vite.ssrLoadModule('/src/services/candidate.ts'),
vite.ssrLoadModule('/src/services/knowledge.ts'),
vite.ssrLoadModule('/src/services/personal-assistant.ts')
]);
const pickers = [
['音频', speech.chooseSpeechAudio],
['音频', caseService.chooseCaseAudio],
['文件', candidate.chooseCandidateFile],
['附件', knowledge.chooseKnowledgeMedia],
['文件', personalAssistant.choosePersonalFile]
];
test('所有文件选择器把用户取消识别为同一可静默处理的信号', async () => {
globalThis.uni = {
chooseFile: ({ fail }) => fail({ errMsg: 'chooseFile:fail cancel' })
};
for (const [label, picker] of pickers) {
await assert.rejects(picker(), (error) => {
assert.equal(fileSelection.isFileSelectionCancelled(error), true);
assert.equal(error.code, fileSelection.FILE_SELECTION_CANCELLED);
assert.equal(error.message, `未选择${label}`);
return true;
});
}
});
test('系统选择失败不是用户取消,并只展示稳定中文提示', async () => {
globalThis.uni = {
chooseFile: ({ fail }) => fail({ errMsg: 'chooseFile:fail permission denied' })
};
for (const [label, picker] of pickers) {
await assert.rejects(picker(), (error) => {
assert.equal(fileSelection.isFileSelectionCancelled(error), false);
assert.equal(error.message, `无法选择${label},请重试`);
assert.doesNotMatch(error.message, /permission|denied|chooseFile/i);
return true;
});
}
});
test('无结构异常也不会被误判为取消', () => {
const error = fileSelection.fileSelectionFailure(undefined, '附件');
assert.equal(fileSelection.isFileSelectionCancelled(error), false);
assert.equal(error.message, '无法选择附件,请重试');
});
test('所有使用文件选择器的页面都按取消信号恢复空闲态而不展示错误', async () => {
const pages = await Promise.all([
'../src/pages/candidate/interview/index.vue',
'../src/pages/candidate/materials/index.vue',
'../src/pages/user/web-ai/index.vue',
'../src/pages/user/today/index.vue',
'../src/pages/user/cases/index.vue',
'../src/pages/user/sop/index.vue',
'../src/pages/user/practice/index.vue',
'../src/pages/user/report/index.vue',
'../src/pages/user/assistant/capture.vue'
].map(read));
const combined = pages.join('\n');
for (const page of pages) assert.match(page, /isFileSelectionCancelled/);
assert.doesNotMatch(combined, /(?:!==|===)\s*['"]未选择(?:音频|附件|文件)['"]/);
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/);
});