样式优化,markdown优化

This commit is contained in:
2025-08-22 17:14:38 +08:00
parent a1537e3f9f
commit b3d63e4181
11 changed files with 163 additions and 36 deletions

View File

@@ -3,6 +3,7 @@ import { Schema, model, Document } from 'mongoose';
export interface IUser extends Document {
email: string;
passwordHash: string;
name?: string;
createdAt: Date;
updatedAt: Date;
}
@@ -10,6 +11,7 @@ export interface IUser extends Document {
const UserSchema = new Schema<IUser>({
email: { type: String, required: true, unique: true, index: true },
passwordHash: { type: String, required: true }
, name: { type: String }
}, { timestamps: true });
export const User = model<IUser>('User', UserSchema);

View File

@@ -6,13 +6,13 @@ import { signToken } from '../middleware/auth.js';
const router = Router();
router.post('/register', async (req, res) => {
const { email, password } = req.body || {};
const { email, password, name } = req.body || {};
if (!email || !password) return res.status(400).json({ error: 'MISSING_FIELDS' });
const exist = await User.findOne({ email });
if (exist) return res.status(409).json({ error: 'EMAIL_EXISTS' });
const passwordHash = await bcrypt.hash(password, 10);
const user = await User.create({ email, passwordHash });
return res.json({ token: signToken(user.id), user: { id: user.id, email: user.email } });
const user = await User.create({ email, passwordHash, name });
return res.json({ token: signToken(user.id), user: { id: user.id, email: user.email, name: user.name } });
});
router.post('/login', async (req, res) => {
@@ -22,7 +22,7 @@ router.post('/login', async (req, res) => {
if (!user) return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
return res.json({ token: signToken(user.id), user: { id: user.id, email: user.email } });
return res.json({ token: signToken(user.id), user: { id: user.id, email: user.email, name: user.name } });
});
export default router;

View File

@@ -15,6 +15,9 @@
"pinia": "^2.1.7",
"vue": "^3.4.29",
"vue-router": "^4.3.0"
,"marked": "^6.0.0",
"dompurify": "^3.0.0",
"highlight.js": "^11.8.0"
},
"devDependencies": {
"@types/node": "^24.3.0",

View File

@@ -9,12 +9,24 @@
</div>
<ConversationList @new="handleNewConv" />
<div class="sidebar-bottom">
<div class="avatar-area left-align">
<button class="avatar-btn" @click="authStore.isAuthed ? goSettings() : goLogin()">
<img v-if="authStore.user" :src="avatarUrl(userEmail)" alt="avatar" />
<div v-else class="avatar-fallback">{{ userInitial }}</div>
</button>
</div>
<div class="avatar-area left-align" style="position:relative;">
<button class="avatar-btn" @click="authStore.isAuthed ? (showUserMenu = !showUserMenu) : goLogin()">
<img v-if="authStore.user" :src="avatarUrl(userEmail)" alt="avatar" />
<div v-else class="avatar-fallback">{{ userInitial }}</div>
</button>
<div v-if="showUserMenu" class="user-menu">
<div class="user-info">
<div class="user-name">{{ authStore.user?.userName || '访客' }}</div>
<div class="user-id" v-if="authStore.user">ID: {{ authStore.user.userId }}</div>
<div class="user-status">{{ authStore.isAuthed ? '已登录' : '未登录' }}</div>
</div>
<div class="user-actions">
<button v-if="authStore.isAuthed" @click="logout">登出</button>
<button v-else @click="goLogin">登录</button>
<button @click="goSettings">设置</button>
</div>
</div>
</div>
<div class="right-icons">
<button class="icon-btn" title="设置" @click="goSettings"></button>
<button class="icon-btn" title="关于" @click="goAbout"></button>
@@ -49,10 +61,26 @@ const themeLabel = computed(() => themeStore.theme === 'dark' ? '切换为浅色
const menuOpen = ref(false);
const userEmail = computed(() => authStore.user?.email || '');
const userInitial = computed(() => authStore.user ? authStore.user.email[0] : '访');
const userInitial = computed(() => authStore.user ? (authStore.user.userName || authStore.user.email)[0] : '访');
const showUserMenu = ref(false);
function logout() { authStore.logout(); showUserMenu.value = false; router.push('/'); }
function toggleTheme() { themeStore.toggle(); }
function handleNewConv() { convStore.createNewConversation('已创建新会话(此消息不会保存)'); router.push('/chat'); }
async function handleNewConv() {
if (authStore.isAuthed) {
// create empty conversation on server and navigate
const conv = await convStore.createConversation(modelStore.currentModel, [], '新会话');
if (conv && conv._id) {
router.push('/chat');
// let ChatView load via store currentId watcher
return;
}
}
// fallback: local transient
convStore.createNewConversation('已创建新会话(此消息不会保存)');
router.push('/chat');
}
function goSettings() { router.push('/settings'); }
function goAbout() { router.push('/about'); }
function goLogin() { router.push('/login'); }
@@ -110,4 +138,10 @@ button:hover { filter: brightness(1.05); }
button:active { filter: brightness(.95); }
.main { flex: 1; display: flex; flex-direction: column; }
.main { overflow:hidden; }
/* user menu positioning */
.user-menu { position:absolute; bottom:56px; left:0; background:var(--sidebar-bg); border:1px solid var(--input-border); padding:8px; border-radius:8px; min-width:160px; box-shadow:0 6px 18px rgba(0,0,0,0.12); }
.user-menu .user-name { font-weight:600; }
.user-menu .user-id { font-size:12px; opacity:.7; }
.user-menu .user-actions { display:flex; gap:8px; margin-top:8px; }
.user-menu button { padding:6px 8px; border-radius:6px; }
</style>

View File

@@ -0,0 +1,17 @@
<template>
<div class="markdown-renderer" v-html="html"></div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { renderMarkdown } from '@/utils/markdown';
const props = defineProps<{ content?: string }>();
const html = computed(() => renderMarkdown(props.content ?? ''));
</script>
<style scoped>
.markdown-renderer { color: var(--text-color); line-height:1.6; word-break:break-word; }
.markdown-renderer pre { background:var(--code-bg,#0b1220); color:var(--code-color,#e6edf3); padding:12px; border-radius:8px; overflow:auto; }
.markdown-renderer code { background:rgba(0,0,0,0.04); padding:2px 6px; border-radius:4px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Noto Mono", monospace; }
</style>

View File

@@ -3,6 +3,8 @@ import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import './styles/global.css';
// optional: highlight.js style for code blocks in markdown
import 'highlight.js/styles/github-dark.css';
import { useThemeStore } from './stores/themeStore';
const app = createApp(App);

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia';
import axios from 'axios';
interface UserInfo { id: string; email: string; }
interface UserInfo { userId: string; userName: string; email: string; }
interface AuthState {
token: string | null;
@@ -13,7 +13,7 @@ interface AuthState {
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
token: localStorage.getItem('token'),
user: null,
user: null,
loading: false,
error: null,
}),
@@ -26,7 +26,7 @@ export const useAuthStore = defineStore('auth', {
try {
const { data } = await axios.post('/api/auth/login', { email, password });
this.token = data.token; localStorage.setItem('token', data.token);
this.user = data.user ? { id: data.user.id, email: data.user.email } : { id: 'me', email };
this.user = data.user ? { userId: data.user.id, userName: data.user.name || data.user.email.split('@')[0], email: data.user.email } : { userId: 'me', userName: email.split('@')[0], email };
} catch (e:any) {
this.error = e.response?.data?.error || e.message;
throw e;
@@ -34,12 +34,14 @@ export const useAuthStore = defineStore('auth', {
this.loading = false;
}
},
async register(email: string, password: string) {
async register(email: string, password: string, userName?: string) {
this.loading = true; this.error = null;
try {
const { data } = await axios.post('/api/auth/register', { email, password });
const payload: any = { email, password };
if (userName) payload.name = userName;
const { data } = await axios.post('/api/auth/register', payload);
this.token = data.token; localStorage.setItem('token', data.token);
this.user = data.user ? { id: data.user.id, email: data.user.email } : { id: 'me', email };
this.user = data.user ? { userId: data.user.id, userName: data.user.name || data.user.email.split('@')[0], email: data.user.email } : { userId: 'me', userName: email.split('@')[0], email };
} catch (e:any) {
this.error = e.response?.data?.error || e.message;
throw e;

View File

@@ -45,6 +45,17 @@ export const useConversationStore = defineStore('conversation', {
this.messages.push({ role: 'system', content: notify, transient: true });
}
},
async createConversation(modelId: string, messages: any[] = [], title?: string) {
const auth = useAuthStore();
if (!auth.token) return null;
try {
const { data } = await axios.post('/api/conversations', { modelId, messages, title }, { headers: { Authorization: 'Bearer ' + auth.token } });
// prepend to list
this.list.unshift(data);
this.currentId = data._id;
return data;
} catch (e:any) { this.error = e.response?.data?.error || e.message; return null; }
},
pushMessage(m: ConversationMessage) { this.messages.push(m); },
patchLastAssistant(content: string) {
for (let i = this.messages.length - 1; i >=0; i--) {

View File

@@ -0,0 +1,28 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js';
// Configure marked to use highlight.js for code blocks
marked.setOptions({
gfm: true,
breaks: false,
smartLists: true,
smartypants: false,
highlight: (code: string, lang?: string) => {
try {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
} catch (e) {
return code;
}
},
});
export function renderMarkdown(markdown = ''): string {
const raw = String(markdown || '');
const html = marked.parse(raw);
// DOMPurify.sanitize 在浏览器环境下工作
return DOMPurify.sanitize(html);
}

View File

@@ -3,18 +3,18 @@
<div class="conv-meta" v-if="conversationId">当前会话: {{ conversationId }}</div>
<div class="messages" ref="messagesEl">
<template v-for="m in messages" :key="m.id">
<div v-if="m.role==='system'" class="msg system">
<div class="bubble system-bubble" v-html="formatMessage(m.content || '')" />
</div>
<div v-if="m.role==='system'" class="msg system">
<div class="bubble system-bubble" v-html="m.html || ''"></div>
</div>
<div v-else-if="m.role==='user'" :class="['msg', 'user']">
<div class="avatar"></div>
<div class="bubble" v-html="formatMessage(m.content || '')" />
<div class="bubble" v-html="m.html || ''"></div>
</div>
<div v-else :class="['msg', 'assistant']">
<div class="avatar">AI</div>
<div class="bubble">
<template v-for="(seg,i) in m.segments" :key="i">
<div :class="['segment', seg.kind || 'answer']" v-html="formatMessage(seg.text)" />
<div :class="['segment', seg.kind || 'answer']" v-html="seg.html || ''"></div>
</template>
<div v-if="m.loading" class="streaming-indicator"> 正在生成...</div>
</div>
@@ -41,9 +41,11 @@ import { useModelStore } from '@/stores/modelStore';
import { chatWithModel, StreamPayload } from '@/services/chatService';
import { useConversationStore } from '@/stores/conversationStore';
import { useAuthStore } from '@/stores/authStore';
import MarkdownRenderer from '@/components/MarkdownRenderer.vue';
import { renderMarkdown } from '@/utils/markdown';
interface AssistantSegment { kind?: 'reasoning' | 'answer'; text: string; }
interface ChatMessage { id: string; role: 'user' | 'assistant' | 'system'; content?: string; segments?: AssistantSegment[]; loading?: boolean; transient?: boolean }
interface AssistantSegment { kind?: 'reasoning' | 'answer'; text: string; html?: string }
interface ChatMessage { id: string; role: 'user' | 'assistant' | 'system'; content?: string; html?: string; segments?: AssistantSegment[]; loading?: boolean; transient?: boolean }
const modelStore = useModelStore();
const convStore = useConversationStore();
@@ -61,17 +63,17 @@ function scrollBottom() {
}
function formatMessage(md: string) {
// TODO: markdown 渲染,可后续替换为 marked
return md.replace(/\n/g, '<br/>');
// 保留给老逻辑的兼容函数,优先使用 MarkdownRenderer 组件渲染
return renderMarkdown(md || '');
}
async function handleSend() {
const text = input.value.trim();
if (!text || pending.value) return;
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: text };
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: text, html: renderMarkdown(text) };
messages.value.push(userMsg);
const aiId = crypto.randomUUID();
messages.value.push({ id: aiId, role: 'assistant', segments: [], loading: true });
messages.value.push({ id: aiId, role: 'assistant', segments: [], loading: true, html: '' });
input.value = '';
pending.value = true;
scrollBottom();
@@ -101,15 +103,29 @@ async function handleSend() {
const target = messages.value.find(m => m.id === aiId);
if (!target) continue;
if (chunk.kind === 'reasoning') {
target.segments!.push({ kind: 'reasoning', text: chunk.text });
const seg = { kind: 'reasoning', text: chunk.text, html: renderMarkdown(chunk.text) } as AssistantSegment;
target.segments!.push(seg);
} else if (chunk.kind === 'answer') {
answerBuffer += chunk.text;
const existingAnswer = target.segments!.find(s => s.kind === 'answer');
if (existingAnswer) existingAnswer.text = answerBuffer; else target.segments!.push({ kind: 'answer', text: answerBuffer });
if (existingAnswer) {
existingAnswer.text = answerBuffer;
existingAnswer.html = renderMarkdown(answerBuffer);
} else {
target.segments!.push({ kind: 'answer', text: answerBuffer, html: renderMarkdown(answerBuffer) });
}
// also update message-level html cache
target.html = target.segments!.map(s => s.html || '').join('');
} else if (chunk.text) {
answerBuffer += chunk.text;
const existingAnswer = target.segments!.find(s => s.kind === 'answer');
if (existingAnswer) existingAnswer.text = answerBuffer; else target.segments!.push({ kind: 'answer', text: answerBuffer });
if (existingAnswer) {
existingAnswer.text = answerBuffer;
existingAnswer.html = renderMarkdown(answerBuffer);
} else {
target.segments!.push({ kind: 'answer', text: answerBuffer, html: renderMarkdown(answerBuffer) });
}
target.html = target.segments!.map(s => s.html || '').join('');
}
scrollBottom();
}
@@ -128,8 +144,11 @@ function clear() { messages.value = []; conversationId.value = null; convStore.r
watch(() => convStore.currentId, (id) => {
if (!id) { messages.value = []; conversationId.value = null; return; }
// load messages from store
messages.value = convStore.messages.map(m => m.role === 'user' ? { id: crypto.randomUUID(), role: 'user', content: m.content } : { id: crypto.randomUUID(), role: 'assistant', segments: [{ kind: 'answer', text: m.content }] });
// load messages from store and precompute html
messages.value = convStore.messages.map(m => m.role === 'user'
? { id: crypto.randomUUID(), role: 'user', content: m.content, html: renderMarkdown(m.content) }
: { id: crypto.randomUUID(), role: 'assistant', segments: [{ kind: 'answer', text: m.content, html: renderMarkdown(m.content) }] }
);
conversationId.value = id;
scrollBottom();
});
@@ -137,7 +156,10 @@ watch(() => convStore.currentId, (id) => {
onMounted(() => {
scrollBottom();
if (convStore.currentId && convStore.messages.length) {
messages.value = convStore.messages.map(m => m.role === 'user' ? { id: crypto.randomUUID(), role: 'user', content: m.content } : { id: crypto.randomUUID(), role: 'assistant', segments: [{ kind: 'answer', text: m.content }] });
messages.value = convStore.messages.map(m => m.role === 'user'
? { id: crypto.randomUUID(), role: 'user', content: m.content, html: renderMarkdown(m.content) }
: { id: crypto.randomUUID(), role: 'assistant', segments: [{ kind: 'answer', text: m.content, html: renderMarkdown(m.content) }] }
);
conversationId.value = convStore.currentId;
}
});

View File

@@ -4,6 +4,7 @@
<form @submit.prevent="handleSubmit">
<input v-model="email" placeholder="邮箱" type="email" required />
<input v-model="password" placeholder="密码" type="password" required />
<input v-if="mode==='register'" v-model="userName" placeholder="用户名(注册)" type="text" />
<div class="actions">
<button type="submit" :disabled="loading">{{ mode==='login' ? '登录' : '注册' }}</button>
<button type="button" @click="toggle" :disabled="loading">切换为{{ mode==='login' ? '注册' : '登录' }}</button>
@@ -24,9 +25,14 @@ const password = ref('');
const mode = ref<'login'|'register'>('login');
const loading = computed(()=>auth.loading);
const error = computed(()=>auth.error);
const userName = ref('');
async function handleSubmit() {
if (mode.value==='login') await auth.login(email.value, password.value); else await auth.register(email.value, password.value);
if (mode.value==='login') {
await auth.login(email.value, password.value);
} else {
await auth.register(email.value, password.value, userName.value);
}
router.push('/chat');
}
function toggle() { mode.value = mode.value==='login' ? 'register' : 'login'; }