Complete V2 user H5 flow

This commit is contained in:
2026-07-06 18:00:21 +08:00
parent 207340973c
commit 86cbed2437
10 changed files with 383 additions and 15 deletions

View File

@@ -23,6 +23,7 @@ const loading = ref(false);
const booting = ref(true);
const statusText = ref("连接后端中");
const bottomRef = ref<HTMLElement | null>(null);
const activeAbortController = ref<AbortController | null>(null);
onMounted(async () => {
if (!getToken()) {
@@ -82,28 +83,53 @@ async function send(message: string) {
const assistantMessage: UiMessage = { id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true };
messages.value.push(userMessage, assistantMessage);
loading.value = true;
activeAbortController.value = new AbortController();
await scrollToBottom();
try {
await streamChat(activeSessionId.value, message, async (chunk) => {
assistantMessage.content += chunk;
await scrollToBottom();
});
}, activeAbortController.value.signal);
assistantMessage.streaming = false;
await refreshSessions(activeSessionId.value);
user.value = await api.profile();
} catch (error) {
assistantMessage.streaming = false;
assistantMessage.content = error instanceof Error ? error.message : "AI 回复失败";
if (error instanceof DOMException && error.name === "AbortError") {
assistantMessage.content = assistantMessage.content || "已停止生成";
} else {
assistantMessage.content = error instanceof Error ? error.message : "AI 回复失败";
}
} finally {
loading.value = false;
activeAbortController.value = null;
}
}
async function stop() {
if (!activeSessionId.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);
}
}
}
async function logout() {
try {
await api.logout();
@@ -175,6 +201,8 @@ async function scrollToBottom() {
@close="drawerOpen = false"
@select="selectSession"
@create="createSession"
@rename="renameSession"
@delete="deleteSession"
/>
</template>
</section>

View File

@@ -1,16 +1,31 @@
<script setup lang="ts">
defineProps<{
import MarkdownIt from "markdown-it";
import { computed } from "vue";
const props = defineProps<{
role: "user" | "assistant";
content: string;
streaming?: boolean;
}>();
const markdown = new MarkdownIt({
breaks: true,
html: false,
linkify: true,
});
const renderedContent = computed(() => {
if (props.role === "user") return props.content;
return markdown.render(props.content || "");
});
</script>
<template>
<article class="message" :class="role">
<div class="avatar">{{ role === "assistant" ? "AI" : "我" }}</div>
<div class="bubble">
<div class="content">{{ content }}</div>
<div v-if="role === 'assistant'" class="content markdown-content" v-html="renderedContent"></div>
<div v-else class="content">{{ renderedContent }}</div>
<span v-if="streaming" class="typing">正在生成</span>
</div>
</article>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { ref } from "vue";
import type { ChatSession } from "../types/api";
defineProps<{
@@ -11,8 +13,29 @@ const emit = defineEmits<{
close: [];
select: [sessionId: number];
create: [];
rename: [sessionId: number, title: string];
delete: [sessionId: number];
}>();
const editingId = ref<number | null>(null);
const editingTitle = ref("");
function startEdit(session: ChatSession) {
editingId.value = session.id;
editingTitle.value = session.title;
}
function submitEdit(sessionId: number) {
const title = editingTitle.value.trim();
if (title) emit("rename", sessionId, title);
editingId.value = null;
editingTitle.value = "";
}
function confirmDelete(session: ChatSession) {
const ok = window.confirm(`确定删除会话「${session.title}」吗?`);
if (ok) emit("delete", session.id);
}
</script>
<template>
@@ -24,17 +47,27 @@ const emit = defineEmits<{
</div>
<button type="button" class="new-session-btn" @click="emit('create')"> 新聊天</button>
<div class="session-list">
<button
<div
v-for="session in sessions"
:key="session.id"
type="button"
class="session-item"
:class="{ active: session.id === activeSessionId }"
@click="emit('select', session.id)"
>
<span>{{ session.title }}</span>
<small>{{ session.messageCount }} 条消息</small>
</button>
<form v-if="editingId === session.id" class="session-edit" @submit.prevent="submitEdit(session.id)">
<input v-model="editingTitle" maxlength="100" @keydown.escape="editingId = null" />
<button type="submit">保存</button>
</form>
<template v-else>
<button type="button" class="session-main" @click="emit('select', session.id)">
<span>{{ session.title }}</span>
<small>{{ session.messageCount }} 条消息</small>
</button>
<div class="session-actions">
<button type="button" @click="startEdit(session)">改名</button>
<button type="button" @click="confirmDelete(session)">删除</button>
</div>
</template>
</div>
</div>
</aside>
</template>

View File

@@ -45,7 +45,12 @@ export const api = {
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
};
export async function streamChat(sessionId: number, message: string, onChunk: (chunk: string) => void) {
export async function streamChat(
sessionId: number,
message: string,
onChunk: (chunk: string) => void,
signal?: AbortSignal,
) {
const token = getToken();
const response = await fetch(`${API_BASE}/chat/completions`, {
method: "POST",
@@ -54,9 +59,11 @@ export async function streamChat(sessionId: number, message: string, onChunk: (c
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ sessionId, message }),
signal,
});
if (!response.ok || !response.body) {
throw new Error("AI 回复连接失败");
const message = await readErrorMessage(response);
throw new Error(message || "AI 回复连接失败");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
@@ -77,3 +84,12 @@ export async function streamChat(sessionId: number, message: string, onChunk: (c
}
}
}
async function readErrorMessage(response: Response) {
try {
const body = (await response.json()) as ApiResponse<unknown>;
return body.message;
} catch {
return "";
}
}

View File

@@ -287,6 +287,78 @@ button:disabled {
font-size: 15px;
}
.markdown-content {
white-space: normal;
}
.markdown-content > :first-child {
margin-top: 0;
}
.markdown-content > :last-child {
margin-bottom: 0;
}
.markdown-content p {
margin: 0 0 10px;
}
.markdown-content ul,
.markdown-content ol {
margin: 8px 0 10px;
padding-left: 20px;
}
.markdown-content li + li {
margin-top: 6px;
}
.markdown-content code {
padding: 2px 5px;
border-radius: 6px;
background: #edf5f2;
color: #0f735d;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.92em;
}
.markdown-content pre {
max-width: 100%;
overflow-x: auto;
margin: 10px 0;
padding: 10px;
border-radius: 10px;
background: #13231e;
color: #f4fffb;
}
.markdown-content pre code {
padding: 0;
background: transparent;
color: inherit;
}
.markdown-content table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 10px 0;
font-size: 13px;
}
.markdown-content th,
.markdown-content td {
padding: 7px 9px;
border: 1px solid #dbe7e3;
white-space: nowrap;
}
.markdown-content a {
color: #0f735d;
font-weight: 700;
}
.typing {
display: inline-block;
margin-top: 8px;
@@ -385,7 +457,7 @@ button:disabled {
.session-item {
display: grid;
gap: 5px;
gap: 8px;
width: 100%;
padding: 12px;
border: 1px solid #e0ebe7;
@@ -400,17 +472,62 @@ button:disabled {
background: #eaf5f1;
}
.session-item span {
.session-main {
display: grid;
gap: 5px;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
}
.session-main span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.session-item small {
.session-main small {
color: #6f817a;
}
.session-actions {
display: flex;
gap: 8px;
}
.session-actions button,
.session-edit button {
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 9px;
background: #edf5f2;
color: #285247;
font-size: 12px;
font-weight: 700;
}
.session-edit {
display: grid;
grid-template-columns: minmax(0, 1fr) 54px;
gap: 8px;
}
.session-edit input {
min-width: 0;
height: 32px;
padding: 0 9px;
border: 1px solid #cad8d2;
border-radius: 9px;
background: #ffffff;
color: #17211d;
outline: none;
}
@media (max-width: 520px) {
.app-shell {
display: block;