Files
QuestionProject/ai_knowledge_base_v2/apps/user-client/src/App.vue

630 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import AppDialog from "./components/AppDialog.vue";
import ChatComposer from "./components/ChatComposer.vue";
import ChatHeader from "./components/ChatHeader.vue";
import LoginPanel from "./components/LoginPanel.vue";
import MessageList, { type DisplayMessage } from "./components/MessageList.vue";
import PersonalCenterDialog from "./components/PersonalCenterDialog.vue";
import SessionDrawer from "./components/SessionDrawer.vue";
import SessionQuota from "./components/SessionQuota.vue";
import { ApiError, api, clearToken, getToken, streamChat } from "./services/api";
import type { ChatMessage as ApiMessage, ChatSession, PeriodicReport, PracticeReviewResult, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api";
const user = ref<UserProfile | null>(null);
const sessions = ref<ChatSession[]>([]);
const activeSessionId = ref<number | null>(null);
const messages = ref<DisplayMessage[]>([]);
const drawerOpen = ref(false);
const sending = ref(false);
const booting = ref(true);
const loadingSession = ref(false);
const loadingSessions = ref(false);
const sessionOperationPending = ref(false);
const logoutDialogOpen = ref(false);
const personalCenterOpen = ref(false);
const personalCenterSection = ref<"overview" | "review" | "records">("overview");
const helpCardDialogOpen = ref(false);
const shareDraftDialogOpen = ref(false);
const practiceReview = ref<PracticeReviewResult | null>(null);
const helpCard = ref<TeacherHelpCard | null>(null);
const helpCardContent = ref("");
const shareDraft = ref<ShareDraft | null>(null);
const shareDraftContent = ref("");
const helpCardHistory = ref<TeacherHelpCard[]>([]);
const shareDraftHistory = ref<ShareDraft[]>([]);
const reportHistory = ref<PeriodicReport[]>([]);
const personalCenterLoading = ref(false);
const cardOperationPending = ref(false);
const finishingTopic = ref(false);
const generatingHelpCard = ref(false);
const generatingShareDraft = ref(false);
const statusText = ref("连接后端中");
const toastText = ref("");
const followingOutput = ref(true);
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const activeAbortController = ref<AbortController | null>(null);
let toastTimer: number | null = null;
let settlementPollVersion = 0;
onMounted(async () => {
if (!getToken()) {
booting.value = false;
statusText.value = "请先登录";
return;
}
try {
user.value = await api.profile();
await loadSessions();
statusText.value = "已连接大本营答疑服务";
} catch (error) {
handleError(error, "页面初始化失败");
} finally {
booting.value = false;
}
});
onBeforeUnmount(() => {
settlementPollVersion += 1;
activeAbortController.value?.abort();
if (toastTimer) window.clearTimeout(toastTimer);
document.body.classList.remove("drawer-open");
});
async function onLoggedIn(profile: UserProfile) {
user.value = profile;
statusText.value = "已连接大本营答疑服务";
try {
await loadSessions();
} catch (error) {
handleError(error, "会话加载失败");
}
}
async function loadSessions() {
loadingSessions.value = true;
try {
sessions.value = await api.listSessions();
if (sessions.value.length === 0) {
await createSession();
return;
}
await selectSession(sessions.value[0].id, true);
} finally {
loadingSessions.value = false;
}
}
async function createSession() {
if (sessionOperationPending.value) return;
const existingBlank = sessions.value.find((session) => session.messageCount === 0);
if (existingBlank) {
await selectSession(existingBlank.id);
return;
}
sessionOperationPending.value = true;
try {
const result = await api.createSession();
sessions.value = await api.listSessions();
await selectSession(result.sessionId, true);
} catch (error) {
handleError(error, "新建会话失败");
} finally {
sessionOperationPending.value = false;
}
}
async function selectSession(sessionId: number, force = false) {
if (!force && activeSessionId.value === sessionId && !loadingSession.value) {
drawerOpen.value = false;
return;
}
loadingSession.value = true;
try {
const history = await api.history(sessionId);
activeSessionId.value = sessionId;
messages.value = history.map(toUiMessage);
followingOutput.value = true;
drawerOpen.value = false;
await messageList.value?.scrollToBottom("auto");
await refreshProfile();
} catch (error) {
handleError(error, "会话加载失败");
} finally {
loadingSession.value = false;
}
}
async function send(message: string, complete: (success: boolean) => void) {
if (!activeSessionId.value || sending.value) {
complete(false);
return;
}
const now = new Date().toISOString();
const userMessage: DisplayMessage = { id: `user-${Date.now()}`, role: "user", content: message, createdAt: now };
const assistantMessage: DisplayMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: "",
createdAt: now,
streaming: true,
};
messages.value.push(userMessage, assistantMessage);
const assistantIndex = messages.value.length - 1;
const currentAssistant = () => messages.value[assistantIndex];
sending.value = true;
followingOutput.value = true;
activeAbortController.value = new AbortController();
let hasContent = false;
await messageList.value?.scrollToMessage(userMessage.id);
try {
await streamChat(
activeSessionId.value,
message,
async (chunk) => {
if (!hasContent) {
currentAssistant().content = "";
hasContent = true;
}
currentAssistant().content += chunk;
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
async (chunk) => {
currentAssistant().reasoning = (currentAssistant().reasoning || "") + chunk;
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
async (statusMessage, statusType, reasoningVisible) => {
if (statusType === "queued") currentAssistant().content = statusMessage || "当前请求较多,正在排队中。";
if (statusType === "generating") {
currentAssistant().showReasoning = reasoningVisible;
if (!hasContent) currentAssistant().content = "";
}
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
activeAbortController.value.signal,
);
currentAssistant().streaming = false;
currentAssistant().createdAt = new Date().toISOString();
complete(true);
await refreshSessionList();
await refreshProfile();
} catch (error) {
currentAssistant().streaming = false;
if (error instanceof DOMException && error.name === "AbortError") {
currentAssistant().content = currentAssistant().content || "已停止生成";
complete(true);
} else {
currentAssistant().content = currentAssistant().content || "回答生成失败,请稍后重试。";
complete(false);
handleError(error, "AI 回复失败");
}
} finally {
sending.value = false;
activeAbortController.value = null;
}
}
async function stop() {
if (!activeSessionId.value || !sending.value) return;
activeAbortController.value?.abort();
try {
await api.stop(activeSessionId.value);
} catch (error) {
handleError(error, "停止生成失败");
}
}
async function finishCurrentTopic() {
if (!activeSessionId.value || sending.value || finishingTopic.value) return;
finishingTopic.value = true;
try {
const result = await api.finishTopic(activeSessionId.value);
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
showToast(result.growthProfileEnabled ? "本主题已沉淀,并更新了近期实修回顾" : "本主题已沉淀");
} else {
showToast("主题已结束,实修记录正在后台沉淀,可以继续使用其他功能");
void watchTopicSettlement(result.summary.id);
}
await refreshSessionList();
await refreshProfile();
} catch (error) {
handleError(error, "主题沉淀失败");
} finally {
finishingTopic.value = false;
}
}
async function watchTopicSettlement(summaryId: number) {
const version = ++settlementPollVersion;
for (let attempt = 0; attempt < 40; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 3000));
if (version !== settlementPollVersion || !user.value) return;
try {
const result = await api.topicSettlement(summaryId);
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
showToast(result.growthProfileEnabled ? "实修记录已沉淀,并更新了近期实修回顾" : "实修记录已沉淀完成");
await refreshProfile();
return;
}
if (result.settlementStatus === "failed") {
showToast("实修记录暂时沉淀失败,系统已保留对话,可稍后重试");
return;
}
} catch {
// 轮询失败不影响聊天;下一轮继续查询持久化任务状态。
}
}
}
async function generateHelpCard() {
if (!activeSessionId.value || sending.value || generatingHelpCard.value) return;
generatingHelpCard.value = true;
try {
const result = await api.generateHelpCard(activeSessionId.value);
helpCard.value = result;
helpCardContent.value = result.content;
helpCardDialogOpen.value = true;
showToast("求助卡已生成,可编辑后复制给老师");
await refreshProfile();
} catch (error) {
handleError(error, "求助卡生成失败");
} finally {
generatingHelpCard.value = false;
}
}
async function copyHelpCard() {
if (!helpCardContent.value.trim()) {
showToast("求助卡内容为空");
return;
}
try {
await copyText(helpCardContent.value);
if (helpCard.value) {
helpCard.value = await api.markHelpCardCopied(helpCard.value.id);
}
showToast("已复制,可粘贴给老师或班级群");
} catch (error) {
handleError(error, "复制失败,请手动选择文本复制");
}
}
async function generateShareDraft() {
if (!activeSessionId.value || sending.value || generatingShareDraft.value) return;
generatingShareDraft.value = true;
try {
const result = await api.generateShareDraft(activeSessionId.value);
shareDraft.value = result;
shareDraftContent.value = result.content;
shareDraftDialogOpen.value = true;
showToast("分享稿已生成,可编辑后复制");
await refreshProfile();
} catch (error) {
handleError(error, "分享稿生成失败");
} finally {
generatingShareDraft.value = false;
}
}
async function copyShareDraft() {
if (!shareDraftContent.value.trim()) {
showToast("分享稿内容为空");
return;
}
try {
await copyText(shareDraftContent.value);
if (shareDraft.value) {
shareDraft.value = await api.markShareDraftCopied(shareDraft.value.id);
}
showToast("已复制,可粘贴到班级群");
} catch (error) {
handleError(error, "复制失败,请手动选择文本复制");
}
}
async function copyHistoryHelpCard(card: TeacherHelpCard) {
try {
await copyText(card.content);
const updated = await api.markHelpCardCopied(card.id);
helpCardHistory.value = helpCardHistory.value.map((item) => item.id === updated.id ? updated : item);
showToast("求助卡已复制,可再次粘贴给老师");
} catch (error) {
handleError(error, "复制失败,请稍后重试");
}
}
async function copyHistoryShareDraft(draft: ShareDraft) {
try {
await copyText(draft.content);
const updated = await api.markShareDraftCopied(draft.id);
shareDraftHistory.value = shareDraftHistory.value.map((item) => item.id === updated.id ? updated : item);
showToast("分享稿已复制,可再次粘贴到班级群");
} catch (error) {
handleError(error, "复制失败,请稍后重试");
}
}
async function deleteHistoryHelpCard(cardId: number, done: (success: boolean) => void) {
if (cardOperationPending.value) return;
cardOperationPending.value = true;
try {
await api.deleteHelpCard(cardId);
helpCardHistory.value = helpCardHistory.value.filter((item) => item.id !== cardId);
if (helpCard.value?.id === cardId) helpCard.value = null;
showToast("求助卡已删除");
done(true);
} catch (error) {
done(false);
handleError(error, "求助卡删除失败");
} finally {
cardOperationPending.value = false;
}
}
async function deleteHistoryShareDraft(draftId: number, done: (success: boolean) => void) {
if (cardOperationPending.value) return;
cardOperationPending.value = true;
try {
await api.deleteShareDraft(draftId);
shareDraftHistory.value = shareDraftHistory.value.filter((item) => item.id !== draftId);
if (shareDraft.value?.id === draftId) shareDraft.value = null;
showToast("分享稿已删除");
done(true);
} catch (error) {
done(false);
handleError(error, "分享稿删除失败");
} finally {
cardOperationPending.value = false;
}
}
async function openPersonalCenter(section: "overview" | "review" | "records" = "overview") {
personalCenterSection.value = section;
personalCenterOpen.value = true;
personalCenterLoading.value = true;
try {
const [latestUser, profileResult, helpCards, shareDrafts, reports] = await Promise.all([
api.profile(),
api.practiceReview(),
api.helpCards(10),
api.shareDrafts(10),
api.periodicReports(10),
]);
user.value = latestUser;
practiceReview.value = profileResult;
helpCardHistory.value = helpCards;
shareDraftHistory.value = shareDrafts;
reportHistory.value = reports;
} catch (error) {
handleError(error, "个人中心加载失败");
} finally {
personalCenterLoading.value = false;
}
}
async function renameSession(sessionId: number, title: string, done: (success: boolean) => void) {
if (sessionOperationPending.value) return;
sessionOperationPending.value = true;
try {
await api.renameSession(sessionId, title);
await refreshSessionList();
showToast("会话名称已更新");
done(true);
} catch (error) {
done(false);
handleError(error, "会话改名失败");
} finally {
sessionOperationPending.value = false;
}
}
async function deleteSession(sessionId: number, done: (success: boolean) => void) {
if (sessionOperationPending.value) return;
sessionOperationPending.value = true;
try {
await api.deleteSession(sessionId);
sessions.value = await api.listSessions();
done(true);
showToast("会话已删除");
if (activeSessionId.value === sessionId) {
activeSessionId.value = null;
messages.value = [];
const nextSession = sessions.value[0];
if (nextSession) await selectSession(nextSession.id, true);
else {
sessionOperationPending.value = false;
await createSession();
}
}
} catch (error) {
done(false);
handleError(error, "会话删除失败");
} finally {
sessionOperationPending.value = false;
}
}
async function confirmLogout() {
sessionOperationPending.value = true;
try {
await api.logout();
} catch {
// 本地登录态必须可退出,服务端失败不阻塞用户操作。
} finally {
sessionOperationPending.value = false;
logoutDialogOpen.value = false;
clearUserState();
}
}
async function refreshSessionList() {
sessions.value = await api.listSessions();
}
async function refreshProfile() {
user.value = await api.profile();
}
function toUiMessage(message: ApiMessage): DisplayMessage {
return {
id: String(message.id),
role: message.role,
content: message.content,
showReasoning: message.role === "assistant" && /<think(?:\s[^>]*)?>/i.test(message.content),
createdAt: message.created_at,
streaming: message.message_status === "GENERATING",
};
}
function handleError(error: unknown, fallback: string) {
if (error instanceof ApiError && error.status === 401) {
clearUserState();
showToast("登录状态已失效,请重新登录");
return;
}
showToast(error instanceof Error ? error.message : fallback);
}
function clearUserState() {
clearToken();
user.value = null;
sessions.value = [];
messages.value = [];
activeSessionId.value = null;
personalCenterOpen.value = false;
statusText.value = "请先登录";
}
function showToast(message: string) {
toastText.value = message;
if (toastTimer) window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => {
toastText.value = "";
toastTimer = null;
}, 3200);
}
async function copyText(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
</script>
<template>
<main class="app-shell">
<section class="phone-frame" :class="{ 'login-mode': !user && !booting, 'chat-mode': Boolean(user) }">
<div v-if="booting" class="booting">正在启动...</div>
<LoginPanel v-else-if="!user" @logged-in="onLoggedIn" />
<template v-else>
<ChatHeader
:user="user"
:status-text="statusText"
@open-history="drawerOpen = true"
@open-personal-center="openPersonalCenter('overview')"
/>
<SessionQuota
:used="user.todayUsed"
:limit="user.dailyLimit"
:entitlement="user.entitlement"
:finishing="finishingTopic"
:generating-help-card="generatingHelpCard"
:generating-share-draft="generatingShareDraft"
@finish-topic="finishCurrentTopic"
@open-profile="openPersonalCenter('review')"
@generate-help-card="generateHelpCard"
@generate-share-draft="generateShareDraft"
/>
<MessageList
ref="messageList"
:messages="messages"
:loading-session="loadingSession"
@follow-change="followingOutput = $event"
/>
<ChatComposer :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" />
<SessionDrawer
:open="drawerOpen"
:sessions="sessions"
:active-session-id="activeSessionId"
:loading="loadingSessions"
:operation-pending="sessionOperationPending"
@close="drawerOpen = false"
@select="selectSession"
@create="createSession"
@rename="renameSession"
@delete="deleteSession"
/>
</template>
</section>
<div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div>
<PersonalCenterDialog
v-if="personalCenterOpen && user"
:user="user"
:initial-section="personalCenterSection"
:loading="personalCenterLoading"
:practice-review="practiceReview"
:reports="reportHistory"
:help-cards="helpCardHistory"
:share-drafts="shareDraftHistory"
:operation-pending="cardOperationPending"
@close="personalCenterOpen = false"
@logout="personalCenterOpen = false; logoutDialogOpen = true"
@copy-help-card="copyHistoryHelpCard"
@copy-share-draft="copyHistoryShareDraft"
@delete-help-card="deleteHistoryHelpCard"
@delete-share-draft="deleteHistoryShareDraft"
/>
<AppDialog v-if="helpCardDialogOpen" title="老师求助卡" labelled-by="help-card-title" @close="helpCardDialogOpen = false">
<section class="help-card-dialog">
<p>这不是转人工工单系统不会自动发送给老师你可以按真实情况删改后自行复制给老师或班级群确认</p>
<textarea v-model="helpCardContent" aria-label="求助卡内容" />
</section>
<template #footer>
<div class="dialog-actions">
<button type="button" class="dialog-secondary" @click="helpCardDialogOpen = false">关闭</button>
<button type="button" class="dialog-primary" @click="copyHelpCard">复制求助卡</button>
</div>
</template>
</AppDialog>
<AppDialog v-if="shareDraftDialogOpen" title="班级分享稿" labelled-by="share-draft-title" @close="shareDraftDialogOpen = false">
<section class="help-card-dialog">
<p>这只是分享草稿系统不会自动发送到任何群请删掉不想公开的隐私内容并按自己的真实状态修改后再复制</p>
<textarea v-model="shareDraftContent" aria-label="班级分享稿内容" />
</section>
<template #footer>
<div class="dialog-actions">
<button type="button" class="dialog-secondary" @click="shareDraftDialogOpen = false">关闭</button>
<button type="button" class="dialog-primary" @click="copyShareDraft">复制分享稿</button>
</div>
</template>
</AppDialog>
<AppDialog v-if="logoutDialogOpen" title="退出登录" labelled-by="logout-dialog-title" @close="logoutDialogOpen = false">
<p class="confirm-copy">确定退出当前账号吗</p>
<template #footer>
<div class="dialog-actions">
<button type="button" class="dialog-secondary" :disabled="sessionOperationPending" @click="logoutDialogOpen = false">取消</button>
<button type="button" class="dialog-primary" :disabled="sessionOperationPending" @click="confirmLogout">
{{ sessionOperationPending ? "退出中..." : "退出" }}
</button>
</div>
</template>
</AppDialog>
</main>
</template>