731 lines
26 KiB
Vue
731 lines
26 KiB
Vue
<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, saveToken, 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 composerKey = ref(0);
|
||
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 feedbackDialogOpen = ref(false);
|
||
const feedbackMessageId = ref<number | null>(null);
|
||
const feedbackContent = ref("");
|
||
const feedbackSubmitting = ref(false);
|
||
const activeAbortController = ref<AbortController | null>(null);
|
||
let toastTimer: number | null = null;
|
||
let settlementPollVersion = 0;
|
||
|
||
onMounted(bootstrap);
|
||
|
||
async function bootstrap() {
|
||
const search = new URLSearchParams(window.location.search);
|
||
const ssoCode = search.get("sso_code")?.trim() || "";
|
||
if (ssoCode) {
|
||
search.delete("sso_code");
|
||
const query = search.toString();
|
||
window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`);
|
||
statusText.value = "正在完成免登录";
|
||
try {
|
||
const result = await api.exchangeSso(ssoCode);
|
||
saveToken(result.token);
|
||
if (result.returnUrl) window.sessionStorage.setItem("ai-kb-sso-return-url", result.returnUrl);
|
||
user.value = result.user;
|
||
await loadSessions();
|
||
statusText.value = "已连接大本营答疑服务";
|
||
} catch (error) {
|
||
clearUserState();
|
||
showToast(error instanceof Error ? `免登录失败:${error.message}` : "免登录失败,请重新进入或使用手机号登录");
|
||
} finally {
|
||
booting.value = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
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;
|
||
composerKey.value += 1;
|
||
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) {
|
||
const sessionId = activeSessionId.value;
|
||
if (!sessionId || 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;
|
||
complete(true);
|
||
await messageList.value?.scrollToMessage(userMessage.id);
|
||
await runGeneration(sessionId, message, assistantIndex, false);
|
||
}
|
||
|
||
async function retryMessage(messageId: string) {
|
||
const sessionId = activeSessionId.value;
|
||
if (!sessionId || sending.value) return;
|
||
const assistantIndex = messages.value.findIndex((item) => item.id === messageId);
|
||
const assistantMessage = messages.value[assistantIndex];
|
||
if (!assistantMessage?.retryQuestion) return;
|
||
await messageList.value?.scrollToMessage(messageId);
|
||
await runGeneration(sessionId, assistantMessage.retryQuestion, assistantIndex, true);
|
||
}
|
||
|
||
async function runGeneration(sessionId: number, message: string, assistantIndex: number, retry: boolean) {
|
||
const currentAssistant = () => messages.value[assistantIndex];
|
||
currentAssistant().content = "";
|
||
currentAssistant().reasoning = "";
|
||
currentAssistant().showReasoning = false;
|
||
currentAssistant().errorMessage = undefined;
|
||
currentAssistant().streaming = true;
|
||
sending.value = true;
|
||
followingOutput.value = true;
|
||
activeAbortController.value = new AbortController();
|
||
let hasContent = false;
|
||
try {
|
||
await streamChat(
|
||
sessionId,
|
||
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,
|
||
retry,
|
||
);
|
||
currentAssistant().streaming = false;
|
||
currentAssistant().retryQuestion = undefined;
|
||
currentAssistant().createdAt = new Date().toISOString();
|
||
try {
|
||
const latestHistory = await api.history(sessionId);
|
||
const latestAssistant = [...latestHistory].reverse().find((item) => item.role === "assistant");
|
||
if (latestAssistant) currentAssistant().id = String(latestAssistant.id);
|
||
} catch {
|
||
// 回答已经成功,历史记录偶发刷新失败不应将本次回答标记为失败。
|
||
}
|
||
await refreshSessionList();
|
||
await refreshProfile();
|
||
} catch (error) {
|
||
currentAssistant().streaming = false;
|
||
if (error instanceof DOMException && error.name === "AbortError") {
|
||
currentAssistant().content = currentAssistant().content || "已停止生成";
|
||
currentAssistant().retryQuestion = undefined;
|
||
} else {
|
||
currentAssistant().errorMessage = chatErrorMessage(error);
|
||
currentAssistant().retryQuestion = message;
|
||
showToast("本次回答未完成,可以点击重试");
|
||
}
|
||
} finally {
|
||
sending.value = false;
|
||
activeAbortController.value = null;
|
||
}
|
||
}
|
||
|
||
function openFeedback(messageId: string) {
|
||
const parsed = Number(messageId);
|
||
if (!Number.isInteger(parsed)) return;
|
||
feedbackMessageId.value = parsed;
|
||
feedbackContent.value = "";
|
||
feedbackDialogOpen.value = true;
|
||
}
|
||
|
||
async function submitFeedback() {
|
||
const content = feedbackContent.value.trim();
|
||
if (!feedbackMessageId.value || !content || feedbackSubmitting.value) return;
|
||
feedbackSubmitting.value = true;
|
||
try {
|
||
await api.submitFeedback(feedbackMessageId.value, content);
|
||
feedbackDialogOpen.value = false;
|
||
showToast("感谢反馈,管理员会查看你提交的问题");
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : "反馈提交失败");
|
||
} finally {
|
||
feedbackSubmitting.value = false;
|
||
}
|
||
}
|
||
|
||
function chatErrorMessage(error: unknown) {
|
||
const message = error instanceof Error ? error.message : "";
|
||
if (/429|too many|请求过多|排队/i.test(message)) return "当前请求较多,AI 服务暂时繁忙。";
|
||
if (/timeout|超时/i.test(message)) return "等待 AI 回复超时,请稍后重试。";
|
||
if (/network|网络|连接/i.test(message)) return "网络连接不稳定,本次回答没有完成。";
|
||
return "AI 回答生成失败,本次不会扣除成功回答额度。";
|
||
}
|
||
|
||
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"
|
||
@retry="retryMessage"
|
||
@feedback="openFeedback"
|
||
/>
|
||
<ChatComposer :key="composerKey" :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>
|
||
|
||
<AppDialog v-if="feedbackDialogOpen" title="反馈这次回答" labelled-by="feedback-dialog-title" @close="feedbackDialogOpen = false">
|
||
<section class="feedback-dialog">
|
||
<p>请简单描述这次回答存在的问题,管理员可以结合当时的对话记录查看。</p>
|
||
<textarea v-model="feedbackContent" maxlength="200" rows="5" aria-label="反馈内容" placeholder="例如:回答没有解决我的问题、内容不准确……" />
|
||
<div class="feedback-dialog-footer"><span>{{ feedbackContent.length }}/200</span><button type="button" :disabled="!feedbackContent.trim() || feedbackSubmitting" @click="submitFeedback">{{ feedbackSubmitting ? "提交中" : "提交反馈" }}</button></div>
|
||
</section>
|
||
</AppDialog>
|
||
|
||
<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>
|