feat: improve async feedback and export workflow

This commit is contained in:
2026-08-11 18:45:39 +08:00
parent ff4ba1148b
commit 83b259af2d
11 changed files with 248 additions and 28 deletions

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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; }