Add Vue user H5 client

This commit is contained in:
2026-07-06 17:34:37 +08:00
parent 17cba7cf61
commit e9f3db9ab2
20 changed files with 2433 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
<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";
interface UiMessage {
id: string;
role: "user" | "assistant";
content: string;
streaming?: boolean;
}
const user = ref<UserProfile | null>(null);
const sessions = ref<ChatSession[]>([]);
const activeSessionId = ref<number | null>(null);
const messages = ref<UiMessage[]>([]);
const drawerOpen = ref(false);
const loading = ref(false);
const booting = ref(true);
const statusText = ref("连接后端中");
const bottomRef = ref<HTMLElement | null>(null);
onMounted(async () => {
if (!getToken()) {
booting.value = false;
statusText.value = "请先登录";
return;
}
try {
user.value = await api.profile();
await loadSessions();
statusText.value = "已连接知识库服务";
} catch {
clearToken();
statusText.value = "登录已失效";
} finally {
booting.value = false;
}
});
async function onLoggedIn(profile: UserProfile) {
user.value = profile;
statusText.value = "已连接知识库服务";
await loadSessions();
}
async function loadSessions() {
sessions.value = await api.listSessions();
if (sessions.value.length === 0) {
await createSession();
return;
}
await selectSession(sessions.value[0].id);
}
async function createSession() {
const result = await api.createSession();
await refreshSessions(result.sessionId);
drawerOpen.value = false;
}
async function refreshSessions(selectId = activeSessionId.value) {
sessions.value = await api.listSessions();
if (selectId) await selectSession(selectId);
}
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 };
messages.value.push(userMessage, assistantMessage);
loading.value = true;
await scrollToBottom();
try {
await streamChat(activeSessionId.value, message, async (chunk) => {
assistantMessage.content += chunk;
await scrollToBottom();
});
assistantMessage.streaming = false;
await refreshSessions(activeSessionId.value);
} catch (error) {
assistantMessage.streaming = false;
assistantMessage.content = error instanceof Error ? error.message : "AI 回复失败";
} finally {
loading.value = false;
}
}
async function stop() {
if (!activeSessionId.value) return;
await api.stop(activeSessionId.value);
loading.value = false;
}
async function logout() {
try {
await api.logout();
} finally {
clearToken();
user.value = null;
sessions.value = [];
messages.value = [];
activeSessionId.value = null;
statusText.value = "请先登录";
}
}
function toUiMessage(message: ApiMessage): UiMessage {
return {
id: String(message.id),
role: message.role,
content: message.content,
streaming: message.message_status === "GENERATING",
};
}
async function scrollToBottom() {
await nextTick();
bottomRef.value?.scrollIntoView({ behavior: "smooth", block: "end" });
}
</script>
<template>
<main class="app-shell">
<section class="phone-frame">
<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>AI知识库助手</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>当前阶段已接入后端 mock 聊天链路后续会替换为真实飞书知识库检索</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" />
<SessionDrawer
:open="drawerOpen"
:sessions="sessions"
:active-session-id="activeSessionId"
@close="drawerOpen = false"
@select="selectSession"
@create="createSession"
/>
</template>
</section>
</main>
</template>