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

@@ -1119,7 +1119,12 @@ async function clearFeishuCache() {
<RetrievalLogView v-if="activeMenu === 'retrievals'" />
<AttentionManagementView v-if="activeMenu === 'attention'" />
<FeedbackManagementView v-if="activeMenu === 'feedback'" :can-delete="can('feedback.delete')" />
<FeedbackManagementView
v-if="activeMenu === 'feedback'"
:can-delete="can('feedback.delete')"
:can-detail="can('feedback.detail')"
:can-export="can('feedback.export')"
/>
<RecordAuditView v-if="activeMenu === 'records'" />
<AdminManagementView v-if="activeMenu === 'admins'" />
</section>

View File

@@ -1,25 +1,71 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, ref } from "vue";
import { api } from "../services/api";
import type { FeedbackDetail, FeedbackItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
defineProps<{ canDelete: boolean; canDetail: boolean; canExport: boolean }>();
const props = defineProps<{ canDelete: boolean }>();
const loading = ref(false);
const exporting = ref(false);
const items = ref<FeedbackItem[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const readStatus = ref("all");
const dateRange = ref<[string, string] | []>([]);
const detail = ref<FeedbackDetail | null>(null);
async function load() {
loading.value = true;
try {
const result = await api.feedbackList({ readStatus: readStatus.value, page: page.value, pageSize: 20 });
const result = await api.feedbackList({
readStatus: readStatus.value,
startDate: dateRange.value[0],
endDate: dateRange.value[1],
page: page.value,
pageSize: pageSize.value,
});
items.value = result.items;
total.value = result.total;
} catch (error) { ElMessage.error(error instanceof Error ? error.message : "反馈列表加载失败"); }
finally { loading.value = false; }
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "反馈列表加载失败");
} finally {
loading.value = false;
}
}
function applyFilters() {
page.value = 1;
void load();
}
function changePage(nextPage: number, nextPageSize: number) {
page.value = nextPage;
pageSize.value = nextPageSize;
void load();
}
async function exportExcel() {
if (dateRange.value.length !== 2) {
ElMessage.warning("请先选择导出的时间范围");
return;
}
exporting.value = true;
try {
await api.exportFeedback({
readStatus: readStatus.value,
startDate: dateRange.value[0],
endDate: dateRange.value[1],
});
ElMessage.success("反馈 Excel 已导出");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "反馈导出失败");
} finally {
exporting.value = false;
}
}
async function open(item: FeedbackItem) {
@@ -40,16 +86,23 @@ onMounted(load);
<template>
<section class="feedback-page">
<header><div><h2>反馈管理</h2><p>查看用户针对 AI 回答提交的问题与当时对话</p></div><el-segmented v-model="readStatus" :options="[{ label: '全部', value: 'all' }, { label: '未读', value: 'unread' }, { label: '已读', value: 'read' }]" @change="page=1; load()" /></header>
<header>
<div><h2>反馈管理</h2><p>查看用户针对 AI 回答提交的问题与当时对话</p></div>
</header>
<section class="feedback-toolbar" aria-label="反馈筛选与导出">
<el-segmented v-model="readStatus" :options="[{ label: '全部', value: 'all' }, { label: '未读', value: 'unread' }, { label: '已读', value: 'read' }]" @change="applyFilters" />
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" unlink-panels @change="applyFilters" />
<el-button v-if="canExport" type="primary" :loading="exporting" @click="exportExcel">导出 Excel</el-button>
</section>
<el-table v-loading="loading" :data="items" stripe>
<el-table-column label="状态" width="88"><template #default="{ row }"><el-tag :type="row.isRead ? 'info' : 'danger'">{{ row.isRead ? '已读' : '未读' }}</el-tag></template></el-table-column>
<el-table-column prop="userName" label="用户" width="130" />
<el-table-column prop="content" label="反馈内容" min-width="240" show-overflow-tooltip />
<el-table-column prop="messageContent" label="反馈的 AI 回答" min-width="280" show-overflow-tooltip />
<el-table-column prop="createdAt" label="提交时间" width="180" />
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="open(row)">查看</el-button><el-button v-if="canDelete" link type="danger" @click="remove(row)">删除</el-button></template></el-table-column>
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="canDetail" link type="primary" @click="open(row)">查看</el-button><el-button v-if="canDelete" link type="danger" @click="remove(row)">删除</el-button></template></el-table-column>
</el-table>
<el-pagination v-if="total > 20" v-model:current-page="page" :page-size="20" :total="total" layout="prev, pager, next, total" @current-change="load" />
<AdminPagination v-if="total" :page="page" :page-size="pageSize" :total="total" @change="changePage" />
<el-drawer :model-value="Boolean(detail)" title="反馈详情" size="620px" @close="detail = null">
<template v-if="detail">
<section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ detail.createdAt }}</small></section>
@@ -63,8 +116,11 @@ onMounted(load);
</template>
<style scoped>
.feedback-page header { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; margin-bottom:22px; }
.feedback-page header { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; margin-bottom:18px; }
.feedback-page h2 { margin:0 0 6px; }.feedback-page header p { margin:0; color:#7b8494; }
.feedback-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:18px; padding:14px 16px; border:1px solid #e7ebf0; border-radius:12px; background:#fafbfc; }
.feedback-toolbar :deep(.el-date-editor) { width:300px; }
.feedback-summary { display:grid; gap:9px; padding:16px; border-radius:12px; background:#f7f9fc; }.feedback-summary span,.feedback-summary small { color:#87909f; }
.feedback-history { display:grid; gap:12px; }.feedback-history article { padding:14px 16px; border:1px solid #e6eaf0; border-radius:12px; }.feedback-history article.user { margin-left:42px; background:#f5f8ff; }.feedback-history article.assistant { margin-right:42px; }.feedback-history article.target { border:2px solid #f0a020; background:#fffaf0; }.feedback-history article div { display:flex; align-items:center; justify-content:space-between; }.feedback-history p { white-space:pre-wrap; line-height:1.65; margin:8px 0 0; }
@media (max-width: 900px) { .feedback-toolbar { align-items:stretch; }.feedback-toolbar :deep(.el-date-editor) { width:100%; } }
</style>

View File

@@ -332,7 +332,9 @@ export const api = {
deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }),
clearFeishuCache: () =>
request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }),
feedbackList: (query: { readStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<import("../types/api").FeedbackItem>>(`/feedback/admin/list${queryString(query)}`),
feedbackList: (query: { readStatus?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number } = {}) => request<PageResult<import("../types/api").FeedbackItem>>(`/feedback/admin/list${queryString(query)}`),
exportFeedback: (query: { readStatus?: string; startDate: string; endDate: string }) =>
download(`/feedback/admin/export${queryString(query)}`, `反馈记录_${query.startDate}_${query.endDate}.xlsx`),
feedbackDetail: (id: number) => request<import("../types/api").FeedbackDetail>(`/feedback/admin/${id}`),
deleteFeedback: (id: number) => request<null>(`/feedback/admin/${id}`, { method: "DELETE" }),
};

View File

@@ -1,8 +1,13 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, date, datetime, time, timedelta
from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.table import Table, TableStyleInfo
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
@@ -50,22 +55,50 @@ def create_feedback(payload: FeedbackCreate, db: Session = Depends(get_db), user
@router.get("/admin/list")
def feedback_list(readStatus: str = Query(default="all", pattern="^(all|read|unread)$"), page: int = Query(default=1, ge=1), pageSize: int = Query(default=20, ge=10, le=100), db: Session = Depends(get_db), _admin: Admin = Depends(get_current_admin)) -> dict:
def feedback_list(readStatus: str = Query(default="all", pattern="^(all|read|unread)$"), startDate: date | None = None, endDate: date | None = None, page: int = Query(default=1, ge=1), pageSize: int = Query(default=20, ge=10, le=100), db: Session = Depends(get_db), _admin: Admin = Depends(get_current_admin)) -> dict:
require_permission(_admin, "feedback.view")
query = select(MessageFeedback, User, ChatMessage).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id)
if readStatus != "all":
query = query.where(MessageFeedback.is_read == (1 if readStatus == "read" else 0))
query = _feedback_query(readStatus, startDate, endDate)
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
rows = db.execute(query.order_by(MessageFeedback.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_summary(*row) for row in rows], total=total, page=page, page_size=pageSize))
return api_success(page_result([_summary(row[0], row[1], row[2]) for row in rows], total=total, page=page, page_size=pageSize))
@router.get("/admin/export")
def export_feedback(
startDate: date,
endDate: date,
readStatus: str = Query(default="all", pattern="^(all|read|unread)$"),
db: Session = Depends(get_db),
admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
require_permission(admin, "feedback.export")
if endDate < startDate:
raise HTTPException(status_code=400, detail="结束日期不能早于开始日期")
if endDate - startDate > timedelta(days=366):
raise HTTPException(status_code=400, detail="单次导出时间范围不能超过 366 天")
rows = db.execute(_feedback_query(readStatus, startDate, endDate).order_by(MessageFeedback.created_at.desc()).limit(100001)).all()
if len(rows) > 100000:
raise HTTPException(status_code=400, detail="导出数据超过 10 万条,请缩小时间范围")
workbook = _feedback_workbook(rows)
stream = BytesIO()
workbook.save(stream)
stream.seek(0)
OperationLogService.write(db, admin_id=admin.id, module="feedback", action="export")
db.commit()
filename = f"feedback_{startDate:%Y%m%d}_{endDate:%Y%m%d}.xlsx"
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/admin/{feedback_id}")
def feedback_detail(feedback_id: int, db: Session = Depends(get_db), admin: Admin = Depends(get_current_admin)) -> dict:
require_permission(admin, "feedback.detail")
row = db.execute(select(MessageFeedback, User, ChatMessage, ChatSession).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id).join(ChatSession, ChatSession.id == MessageFeedback.session_id).where(MessageFeedback.id == feedback_id)).first()
if row is None:
raise HTTPException(status_code=404, detail="反馈不存在")
require_permission(admin, "feedback.view")
feedback, user, target, session = row
if not feedback.is_read:
feedback.is_read = 1
@@ -90,3 +123,66 @@ def delete_feedback(feedback_id: int, db: Session = Depends(get_db), admin: Admi
def _summary(item: MessageFeedback, user: User, message: ChatMessage) -> dict:
return {"id": item.id, "userId": user.id, "userName": user.name, "userPhone": user.phone, "messageId": message.id, "messageContent": message.content, "content": item.content, "isRead": bool(item.is_read), "createdAt": item.created_at}
def _feedback_query(read_status: str, start_date: date | None, end_date: date | None):
query = (
select(MessageFeedback, User, ChatMessage, ChatSession)
.join(User, User.id == MessageFeedback.user_id)
.join(ChatMessage, ChatMessage.id == MessageFeedback.message_id)
.join(ChatSession, ChatSession.id == MessageFeedback.session_id)
)
if read_status != "all":
query = query.where(MessageFeedback.is_read == (1 if read_status == "read" else 0))
if start_date is not None:
query = query.where(MessageFeedback.created_at >= datetime.combine(start_date, time.min))
if end_date is not None:
query = query.where(MessageFeedback.created_at < datetime.combine(end_date + timedelta(days=1), time.min))
return query
def _feedback_workbook(rows: list[tuple]) -> Workbook:
workbook = Workbook()
sheet = workbook.active
sheet.title = "反馈记录"
sheet.sheet_view.showGridLines = False
headers = ["序号", "状态", "用户姓名", "手机号", "反馈内容", "会话标题", "消息ID", "对应AI回答", "提交时间", "阅读时间"]
sheet.append(headers)
for index, (feedback, user, message, session) in enumerate(rows, start=1):
sheet.append([
index,
"已读" if feedback.is_read else "未读",
_excel_safe_text(user.name),
_excel_safe_text(user.phone),
_excel_safe_text(feedback.content),
_excel_safe_text(session.title),
message.id,
_excel_safe_text(message.content),
feedback.created_at,
feedback.read_at,
])
header_fill = PatternFill("solid", fgColor="1F6F5F")
for cell in sheet[1]:
cell.fill = header_fill
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = f"A1:J{max(1, sheet.max_row)}"
widths = [8, 10, 16, 16, 38, 24, 12, 70, 20, 20]
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[chr(64 + index)].width = width
for row in sheet.iter_rows(min_row=2):
row[4].alignment = Alignment(vertical="top", wrap_text=True)
row[7].alignment = Alignment(vertical="top", wrap_text=True)
for cell in (row[8], row[9]):
cell.number_format = "yyyy-mm-dd hh:mm:ss"
if sheet.max_row > 1:
table = Table(displayName="FeedbackRecords", ref=f"A1:J{sheet.max_row}")
table.tableStyleInfo = TableStyleInfo(name="TableStyleMedium2", showRowStripes=True, showFirstColumn=False, showLastColumn=False)
sheet.add_table(table)
return workbook
def _excel_safe_text(value: object | None) -> str:
text_value = str(value or "")
return f"'{text_value}" if text_value.startswith(("=", "+", "-", "@")) else text_value

View File

@@ -20,7 +20,7 @@ PERMISSION_TREE = [
{"code": "records", "name": "记录审计", "children": [{"code": "records.view", "name": "查看/导出记录"}]},
{"code": "retrievals", "name": "检索日志", "children": [{"code": "retrievals.view", "name": "查看检索日志"}]},
{"code": "attention", "name": "人工关注", "children": [{"code": "attention.view", "name": "查看关注项"}, {"code": "attention.edit", "name": "处理关注项"}]},
{"code": "feedback", "name": "反馈管理", "children": [{"code": "feedback.view", "name": "查看反馈"}, {"code": "feedback.delete", "name": "删除反馈"}]},
{"code": "feedback", "name": "反馈管理", "children": [{"code": "feedback.view", "name": "查看反馈列表/筛选分页"}, {"code": "feedback.detail", "name": "查看详情/标记已读"}, {"code": "feedback.export", "name": "导出反馈"}, {"code": "feedback.delete", "name": "删除反馈"}]},
{"code": "admins", "name": "管理员与权限", "superOnly": True, "children": [{"code": "admins.view", "name": "查看管理员"}, {"code": "admins.edit", "name": "新增/编辑管理员"}, {"code": "admins.delete", "name": "删除管理员"}]},
]

View File

@@ -12,6 +12,7 @@ from app.services.admin_permission_service import ALL_PERMISSION_CODES, permissi
def test_super_admin_has_all_permissions() -> None:
admin = Admin(id=1, username="root", password="unused", name="root", status=1, is_super_admin=1)
assert permissions_for(admin) == ALL_PERMISSION_CODES
assert {"feedback.view", "feedback.detail", "feedback.export", "feedback.delete"} <= ALL_PERMISSION_CODES
def test_role_permissions_are_restricted_to_catalog() -> None:

View File

@@ -1,10 +1,13 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.feedback import FeedbackCreate, create_feedback, delete_feedback, feedback_detail, feedback_list
from app.api.feedback import FeedbackCreate, _feedback_workbook, create_feedback, delete_feedback, feedback_detail, feedback_list
from app.models import Base
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
@@ -33,3 +36,20 @@ def test_feedback_binds_target_answer_and_exposes_context() -> None:
assert detail["messages"][-1]["isTarget"] is True
delete_feedback(created["id"], db=db, admin=admin)
assert feedback_list(readStatus="all", page=1, pageSize=20, db=db, _admin=admin)["data"]["total"] == 0
def test_feedback_export_workbook_is_formatted_and_formula_safe() -> None:
created_at = datetime(2026, 8, 11, 12, 30)
row = (
SimpleNamespace(is_read=0, content="=HYPERLINK(\"bad\")", created_at=created_at, read_at=None),
SimpleNamespace(name="测试用户", phone="13800000000"),
SimpleNamespace(id=9, content="AI 回答"),
SimpleNamespace(title="测试会话"),
)
workbook = _feedback_workbook([row])
sheet = workbook["反馈记录"]
assert sheet.freeze_panes == "A2"
assert sheet["E2"].data_type == "s"
assert sheet["E2"].value.startswith("'=")
assert sheet["I2"].value == created_at
assert sheet.tables["FeedbackRecords"].ref == "A1:J2"

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