feat: 重构移动端用户聊天页面

This commit is contained in:
2026-07-11 16:40:40 +08:00
parent 4f75e11a8d
commit 603fb70715
8 changed files with 1157 additions and 221 deletions

View File

@@ -1,29 +1,33 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from "vue";
import ChatComposer from "./components/ChatComposer.vue";
import ChatMessage from "./components/ChatMessage.vue";
import LoginPanel from "./components/LoginPanel.vue";
import SessionDrawer from "./components/SessionDrawer.vue";
import { api, clearToken, getToken, streamChat } from "./services/api";
import type { ChatMessage as ApiMessage, ChatSession, UserProfile } from "./types/api";
import { onBeforeUnmount, onMounted, ref } from "vue";
interface UiMessage {
id: string;
role: "user" | "assistant";
content: string;
streaming?: boolean;
}
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 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, UserProfile } from "./types/api";
const user = ref<UserProfile | null>(null);
const sessions = ref<ChatSession[]>([]);
const activeSessionId = ref<number | null>(null);
const messages = ref<UiMessage[]>([]);
const messages = ref<DisplayMessage[]>([]);
const drawerOpen = ref(false);
const loading = 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 statusText = ref("连接后端中");
const bottomRef = ref<HTMLElement | null>(null);
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;
onMounted(async () => {
if (!getToken()) {
@@ -35,187 +39,277 @@ onMounted(async () => {
user.value = await api.profile();
await loadSessions();
statusText.value = "已连接大本营答疑服务";
} catch {
clearToken();
statusText.value = "登录已失效";
} catch (error) {
handleError(error, "页面初始化失败");
} finally {
booting.value = false;
}
});
onBeforeUnmount(() => {
activeAbortController.value?.abort();
if (toastTimer) window.clearTimeout(toastTimer);
document.body.classList.remove("drawer-open");
});
async function onLoggedIn(profile: UserProfile) {
user.value = profile;
statusText.value = "已连接大本营答疑服务";
await loadSessions();
try {
await loadSessions();
} catch (error) {
handleError(error, "会话加载失败");
}
}
async function loadSessions() {
sessions.value = await api.listSessions();
if (sessions.value.length === 0) {
await createSession();
return;
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;
}
await selectSession(sessions.value[0].id);
}
async function createSession() {
const result = await api.createSession();
await refreshSessions(result.sessionId);
drawerOpen.value = false;
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 refreshSessions(selectId = activeSessionId.value) {
sessions.value = await api.listSessions();
if (selectId) await selectSession(selectId);
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 selectSession(sessionId: number) {
activeSessionId.value = sessionId;
const history = await api.history(sessionId);
messages.value = history.map(toUiMessage);
drawerOpen.value = false;
await scrollToBottom();
}
async function send(message: string) {
if (!activeSessionId.value || loading.value) return;
const userMessage: UiMessage = { id: `user-${Date.now()}`, role: "user", content: message };
const assistantMessage: UiMessage = { id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true };
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);
loading.value = true;
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 scrollToBottom();
await messageList.value?.scrollToMessage(userMessage.id);
try {
await streamChat(
activeSessionId.value,
message,
async (chunk) => {
if (!hasContent) {
assistantMessage.content = "";
currentAssistant().content = "";
hasContent = true;
}
assistantMessage.content += chunk;
await scrollToBottom();
currentAssistant().content += chunk;
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
async (statusMessage, statusType) => {
if (statusType === "queued") {
assistantMessage.content = statusMessage || "当前请求较多,正在排队中。";
} else if (statusType === "generating" && !hasContent) {
assistantMessage.content = "";
}
await scrollToBottom();
if (statusType === "queued") currentAssistant().content = statusMessage || "当前请求较多,正在排队中。";
if (statusType === "generating" && !hasContent) currentAssistant().content = "";
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
activeAbortController.value.signal,
);
assistantMessage.streaming = false;
await refreshSessions(activeSessionId.value);
user.value = await api.profile();
currentAssistant().streaming = false;
currentAssistant().createdAt = new Date().toISOString();
complete(true);
await refreshSessionList();
await refreshProfile();
} catch (error) {
assistantMessage.streaming = false;
currentAssistant().streaming = false;
if (error instanceof DOMException && error.name === "AbortError") {
assistantMessage.content = assistantMessage.content || "已停止生成";
currentAssistant().content = currentAssistant().content || "已停止生成";
complete(true);
} else {
assistantMessage.content = error instanceof Error ? error.message : "AI 回复失败";
currentAssistant().content = currentAssistant().content || "回答生成失败,请稍后重试。";
complete(false);
handleError(error, "AI 回复失败");
}
} finally {
loading.value = false;
sending.value = false;
activeAbortController.value = null;
}
}
async function stop() {
if (!activeSessionId.value) return;
if (!activeSessionId.value || !sending.value) return;
activeAbortController.value?.abort();
await api.stop(activeSessionId.value);
loading.value = false;
}
async function renameSession(sessionId: number, title: string) {
await api.renameSession(sessionId, title);
await refreshSessions(sessionId);
}
async function deleteSession(sessionId: number) {
await api.deleteSession(sessionId);
sessions.value = await api.listSessions();
if (activeSessionId.value === sessionId) {
messages.value = [];
activeSessionId.value = null;
if (sessions.value.length > 0) {
await selectSession(sessions.value[0].id);
}
try {
await api.stop(activeSessionId.value);
} catch (error) {
handleError(error, "停止生成失败");
}
}
async function logout() {
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 {
clearToken();
user.value = null;
sessions.value = [];
messages.value = [];
activeSessionId.value = null;
statusText.value = "请先登录";
sessionOperationPending.value = false;
logoutDialogOpen.value = false;
clearUserState();
}
}
function toUiMessage(message: ApiMessage): UiMessage {
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,
createdAt: message.created_at,
streaming: message.message_status === "GENERATING",
};
}
async function scrollToBottom() {
await nextTick();
bottomRef.value?.scrollIntoView({ behavior: "smooth", block: "end" });
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;
statusText.value = "请先登录";
}
function showToast(message: string) {
toastText.value = message;
if (toastTimer) window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => {
toastText.value = "";
toastTimer = null;
}, 3200);
}
</script>
<template>
<main class="app-shell">
<section class="phone-frame" :class="{ 'login-mode': !user && !booting }">
<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>
<header class="topbar">
<button type="button" class="icon-btn" @click="drawerOpen = true"></button>
<div>
<h1>大本营答疑助手</h1>
<p>{{ statusText }}</p>
</div>
<button type="button" class="plain-btn" @click="logout">退出</button>
</header>
<section class="quota-strip">
<span>{{ user.name }}</span>
<strong>{{ user.todayUsed }}/{{ user.dailyLimit }}</strong>
</section>
<section class="chat-area">
<div v-if="messages.length === 0" class="empty-state">
<strong>可以开始提问了</strong>
<p>输入问题我会尽力为你解答</p>
</div>
<ChatMessage
v-for="message in messages"
:key="message.id"
:role="message.role"
:content="message.content"
:streaming="message.streaming"
/>
<div ref="bottomRef"></div>
</section>
<ChatComposer :loading="loading" :disabled="!activeSessionId" @send="send" @stop="stop" />
<ChatHeader :user="user" :status-text="statusText" @open-history="drawerOpen = true" @logout="logoutDialogOpen = true" />
<SessionQuota :used="user.todayUsed" :limit="user.dailyLimit" />
<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"
@@ -224,5 +318,19 @@ async function scrollToBottom() {
/>
</template>
</section>
<div v-if="toastText" class="chat-toast" role="status">{{ toastText }}</div>
<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>