feat: improve async feedback and export workflow
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "删除管理员"}]},
|
||||
]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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