feat: improve async feedback and export workflow
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Mic, Send, Square, X } from "@lucide/vue";
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
import { LoaderCircle, Mic, Send, Square, X } from "@lucide/vue";
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
import { useVoiceRecorder } from "../composables/useVoiceRecorder";
|
||||
import { api, transcribeVoice } from "../services/api";
|
||||
@@ -21,22 +21,42 @@ const voiceEnabled = ref(false);
|
||||
const voiceMaxDuration = ref(60);
|
||||
const transcribing = ref(false);
|
||||
const voiceError = ref("");
|
||||
let transcriptionController: AbortController | null = null;
|
||||
let transcriptionVersion = 0;
|
||||
|
||||
const { recording, elapsedSeconds, start: startRecording, stop: stopRecording, cancel: cancelRecording } = useVoiceRecorder(
|
||||
async (audio) => {
|
||||
const version = ++transcriptionVersion;
|
||||
const controller = new AbortController();
|
||||
transcriptionController = controller;
|
||||
transcribing.value = true;
|
||||
voiceError.value = "";
|
||||
try {
|
||||
const result = await transcribeVoice(audio);
|
||||
const result = await transcribeVoice(audio, controller.signal);
|
||||
if (version !== transcriptionVersion) return;
|
||||
insertTranscription(result.text);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || version !== transcriptionVersion) return;
|
||||
voiceError.value = error instanceof Error ? error.message : "语音识别失败,请重试";
|
||||
} finally {
|
||||
transcribing.value = false;
|
||||
if (version === transcriptionVersion) {
|
||||
transcribing.value = false;
|
||||
transcriptionController = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function cancelTranscription() {
|
||||
transcriptionVersion += 1;
|
||||
transcriptionController?.abort();
|
||||
transcriptionController = null;
|
||||
transcribing.value = false;
|
||||
voiceError.value = "";
|
||||
}
|
||||
|
||||
onBeforeUnmount(cancelTranscription);
|
||||
|
||||
async function refreshVoiceConfig() {
|
||||
try {
|
||||
const config = await api.voiceConfig();
|
||||
@@ -139,9 +159,13 @@ function formatSeconds(seconds: number) {
|
||||
<button v-if="recording" type="button" class="composer-voice cancel" aria-label="取消录音" @click="cancelRecording">
|
||||
<X :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
<button v-else-if="voiceEnabled && !loading" type="button" class="composer-voice" :disabled="disabled || transcribing" :aria-label="transcribing ? '正在识别语音' : '语音输入'" @click="beginVoiceInput">
|
||||
<button v-else-if="transcribing && !loading" type="button" class="composer-voice transcribing" aria-label="取消语音识别" @click="cancelTranscription">
|
||||
<LoaderCircle class="spinning" :size="19" aria-hidden="true" />
|
||||
<span>识别中 · 取消</span>
|
||||
</button>
|
||||
<button v-else-if="voiceEnabled && !loading" type="button" class="composer-voice" :disabled="disabled" aria-label="语音输入" @click="beginVoiceInput">
|
||||
<Mic :size="19" aria-hidden="true" />
|
||||
<span>{{ transcribing ? "识别中" : "语音" }}</span>
|
||||
<span>语音</span>
|
||||
</button>
|
||||
<button v-if="recording" type="button" class="composer-voice finish" aria-label="完成录音" @click="stopRecording">
|
||||
<Square :size="16" fill="currentColor" aria-hidden="true" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Layers3 } from "@lucide/vue";
|
||||
import { Layers3, LoaderCircle } from "@lucide/vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
import type { UserEntitlementSummary } from "../types/api";
|
||||
@@ -66,6 +66,7 @@ const guidanceText = computed(() => {
|
||||
近期回顾
|
||||
</button>
|
||||
<button type="button" class="quota-link" :disabled="finishing" @click="$emit('finishTopic')">
|
||||
<LoaderCircle v-if="finishing" class="spinning" :size="14" aria-hidden="true" />
|
||||
{{ finishing ? '沉淀中' : '沉淀本主题' }}
|
||||
</button>
|
||||
<button
|
||||
@@ -75,6 +76,7 @@ const guidanceText = computed(() => {
|
||||
:disabled="generatingHelpCard"
|
||||
@click="$emit('generateHelpCard')"
|
||||
>
|
||||
<LoaderCircle v-if="generatingHelpCard" class="spinning" :size="14" aria-hidden="true" />
|
||||
{{ generatingHelpCard ? '生成中' : '求助卡' }}
|
||||
</button>
|
||||
<button
|
||||
@@ -84,6 +86,7 @@ const guidanceText = computed(() => {
|
||||
:disabled="generatingShareDraft"
|
||||
@click="$emit('generateShareDraft')"
|
||||
>
|
||||
<LoaderCircle v-if="generatingShareDraft" class="spinning" :size="14" aria-hidden="true" />
|
||||
{{ generatingShareDraft ? '生成中' : '分享稿' }}
|
||||
</button>
|
||||
<strong>{{ usageText }}</strong>
|
||||
|
||||
@@ -122,22 +122,28 @@ export const api = {
|
||||
submitFeedback: (messageId: number, content: string) => request<{ id: number }>("/feedback", { method: "POST", body: JSON.stringify({ messageId, content }) }),
|
||||
};
|
||||
|
||||
export async function transcribeVoice(audio: Blob): Promise<VoiceTranscriptionResult> {
|
||||
export async function transcribeVoice(audio: Blob, signal?: AbortSignal): Promise<VoiceTranscriptionResult> {
|
||||
const form = new FormData();
|
||||
form.append("audio", audio, "voice-recording");
|
||||
const headers = new Headers();
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const controller = new AbortController();
|
||||
const abortFromCaller = () => controller.abort();
|
||||
signal?.addEventListener("abort", abortFromCaller, { once: true });
|
||||
const timeout = window.setTimeout(() => controller.abort(), 30_000);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}/voice/transcribe`, { method: "POST", headers, body: form, signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw new ApiError("语音识别超时,请稍后重试", 408);
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
if (signal?.aborted) throw new ApiError("语音识别已取消", 499);
|
||||
throw new ApiError("语音识别超时,请稍后重试", 408);
|
||||
}
|
||||
throw new ApiError("语音上传失败,请检查网络后重试", 0);
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", abortFromCaller);
|
||||
}
|
||||
const body = (await response.json().catch(() => ({ code: response.status, message: "服务响应异常", data: null }))) as ApiResponse<VoiceTranscriptionResult>;
|
||||
if (!response.ok || body.code !== 0) {
|
||||
|
||||
@@ -1242,6 +1242,10 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.quota-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(31, 107, 82, 0.16);
|
||||
@@ -1253,6 +1257,8 @@ textarea:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quota-link .spinning { flex: 0 0 auto; }
|
||||
|
||||
.quota-link:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
@@ -1564,6 +1570,7 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.composer-voice:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.composer-voice.transcribing { min-width: 118px; color: var(--chat-brand); }
|
||||
.composer-voice.cancel { color: var(--chat-danger); }
|
||||
.composer-voice.finish { border-color: var(--chat-brand); background: var(--chat-brand); color: #ffffff; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user