Files
map-client-vue/web/src/components/Chat/ChatLayout.vue
2025-10-16 12:45:05 +08:00

1107 lines
28 KiB
Vue

<template>
<div class="chat-container">
<!-- 主对话区域 -->
<div class="chat-main">
<div v-if="!store.state.currentTopicId" class="empty-chat">
<div class="empty-icon">
<n-icon :component="MessageIcon" size="64" />
</div>
<h3>选择一个对话开始聊天</h3>
<p>或者创建一个新对话</p>
<n-button type="primary" @click="handleCreateTopic">
<template #icon>
<n-icon :component="PlusIcon" />
</template>
新建对话
</n-button>
</div>
<template v-else>
<!-- 对话头部 -->
<div class="chat-header">
<div class="header-info">
<h3>{{ store.currentTopic.value?.name }}</h3>
<span class="message-count">{{ store.state.messages.length }} 条消息</span>
</div>
<div class="header-actions">
<n-button text @click="showSidebar = !showSidebar">
<n-icon :component="showSidebar ? ChevronRightIcon : ChevronLeftIcon" />
<span style="margin-left: 4px;">{{ showSidebar ? '隐藏列表' : '显示列表' }}</span>
</n-button>
<n-button text @click="handleClearMessages">
<n-icon :component="TrashIcon" />
</n-button>
</div>
</div>
<!-- 消息列表 -->
<n-scrollbar class="messages-container" ref="messagesScrollRef">
<div class="messages-list">
<div
v-for="msg in store.state.messages"
:key="msg.id"
class="message-item"
:class="msg.role"
>
<div class="message-avatar">
<n-icon
:component="msg.role === 'user' ? UserIcon : RobotIcon"
size="20"
/>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-role">{{
msg.role === 'user' ? '你' : 'AI 助手'
}}</span>
<span class="message-time">{{ formatTime(msg.timestamp) }}</span>
<n-tag v-if="msg.status === 'sending'" type="info" size="small">
发送中...
</n-tag>
<n-tag v-else-if="msg.status === 'paused'" type="warning" size="small">
已停止
</n-tag>
<n-tag v-else-if="msg.status === 'error'" type="error" size="small">
发送失败
</n-tag>
</div>
<div class="message-text">
<div v-if="msg.status === 'sending'" class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
<template v-else>
{{ msg.content }}
</template>
<div v-if="msg.error" class="message-error">
{{ msg.error }}
</div>
</div>
<!-- 用户消息操作按钮 -->
<div v-if="msg.role === 'user'" class="message-actions">
<n-tooltip trigger="hover">
<template #trigger>
<n-button text size="tiny" type="primary" @click="handleCopyMessage(msg.content)">
<n-icon :component="CopyIcon" size="14" />
</n-button>
</template>
复制
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<n-button text size="tiny" type="error" @click="handleDeleteMessage(msg.id)">
<n-icon :component="TrashIcon" size="14" />
</n-button>
</template>
删除
</n-tooltip>
</div>
<!-- AI 消息操作按钮 -->
<div v-if="msg.role === 'assistant' && (msg.status === 'success' || msg.status === 'paused')" class="message-actions">
<n-tooltip trigger="hover">
<template #trigger>
<n-button text size="tiny" type="primary" @click="handleCopyMessage(msg.content)">
<n-icon :component="CopyIcon" size="14" />
</n-button>
</template>
复制
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<n-button text size="tiny" type="info" @click="handleRegenerateMessage(msg.id)">
<n-icon :component="RefreshIcon" size="14" />
</n-button>
</template>
重新生成
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<n-button text size="tiny" type="error" @click="handleDeleteMessage(msg.id)">
<n-icon :component="TrashIcon" size="14" />
</n-button>
</template>
删除
</n-tooltip>
</div>
</div>
</div>
</div>
</n-scrollbar>
<!-- 输入框和工具栏 -->
<div class="input-container">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<!-- MCP 选择器 -->
<n-dropdown
trigger="click"
:options="mcpOptions"
@select="handleSelectMCP"
>
<n-button size="small" quaternary>
<template #icon>
<n-icon :component="PlugIcon" />
</template>
{{ selectedMCPName }}
<n-icon :component="ChevronDownIcon" size="14" style="margin-left: 4px;" />
</n-button>
</n-dropdown>
<!-- 模型选择器 -->
<n-dropdown
trigger="click"
:options="modelOptions"
@select="handleSelectModel"
>
<n-button size="small" quaternary>
<template #icon>
<n-icon :component="BrainIcon" />
</template>
{{ selectedModelName || '选择模型' }}
<n-icon :component="ChevronDownIcon" size="14" style="margin-left: 4px;" />
</n-button>
</n-dropdown>
</div>
<!--
<span class="toolbar-divider">|</span>
-->
<div class="toolbar-right">
<!-- 快捷操作 -->
<n-button-group size="small">
<n-button quaternary>
<n-icon :component="FileTextIcon" size="16" />
</n-button>
<n-button quaternary>
<n-icon :component="PaperclipIcon" size="16" />
</n-button>
<n-button quaternary>
<n-icon :component="MicIcon" size="16" />
</n-button>
</n-button-group>
<!-- 发送/停止按钮 -->
<n-button
size="small"
:type="store.state.isSending ? 'error' : 'primary'"
:disabled="!store.state.isSending && !inputText.trim()"
@click="handleButtonClick"
>
{{ store.state.isSending ? '停止' : '发送' }}
</n-button>
</div>
</div>
<!-- 输入框 -->
<n-input
v-model:value="inputText"
type="textarea"
placeholder="在这里输入消息,按 Enter 发送"
:autosize="{ minRows: 3, maxRows: 10 }"
:disabled="store.state.isSending"
@keydown.enter="handleKeyDown"
class="message-input"
/>
</div>
</template>
</div>
<!-- 右侧对话列表 -->
<div class="topics-sidebar" :class="{ visible: showSidebar }">
<div class="sidebar-header">
<h3>对话列表</h3>
<div class="header-actions">
<n-button size="small" type="primary" @click="handleCreateTopic">
<template #icon>
<n-icon :component="PlusIcon" />
</template>
</n-button>
</div>
</div>
<!-- 搜索框 -->
<div class="search-box">
<n-input
v-model:value="searchKeyword"
placeholder="搜索对话..."
clearable
size="small"
>
<template #prefix>
<n-icon :component="SearchIcon" />
</template>
</n-input>
</div>
<!-- 话题列表 -->
<n-scrollbar class="topics-list">
<div
v-for="topic in filteredTopics"
:key="topic.id"
class="topic-item"
:class="{ active: store.state.currentTopicId === topic.id }"
@click="handleSelectTopic(topic.id)"
>
<div class="topic-content">
<div class="topic-header">
<span class="topic-name">{{ topic.name }}</span>
<n-dropdown
trigger="click"
:options="getTopicMenuOptions(topic)"
@select="(key) => handleTopicMenu(key, topic)"
>
<n-button text size="tiny" @click.stop>
<n-icon :component="DotsVerticalIcon" size="16" />
</n-button>
</n-dropdown>
</div>
<div class="topic-preview">{{ topic.lastMessage || '暂无消息' }}</div>
<div class="topic-meta">
<span>{{ topic.messageCount }} 条消息</span>
<span>{{ formatDate(topic.updatedAt) }}</span>
</div>
</div>
</div>
<div v-if="filteredTopics.length === 0" class="empty-topics">
<p>{{ searchKeyword ? '未找到匹配的对话' : '暂无对话' }}</p>
</div>
</n-scrollbar>
</div>
<!-- 编辑话题弹窗 -->
<n-modal
v-model:show="showEditModal"
preset="card"
title="编辑对话"
style="width: 500px"
>
<n-form>
<n-form-item label="对话名称">
<n-input v-model:value="editForm.name" placeholder="请输入对话名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="editForm.description"
type="textarea"
placeholder="可选"
:rows="3"
/>
</n-form-item>
</n-form>
<template #footer>
<div style="display: flex; justify-content: flex-end; gap: 12px">
<n-button @click="showEditModal = false">取消</n-button>
<n-button type="primary" @click="handleSaveEdit">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, h, watch } from 'vue'
import {
NButton,
NButtonGroup,
NIcon,
NInput,
NScrollbar,
NTag,
NDropdown,
NModal,
NForm,
NFormItem,
useMessage
} from 'naive-ui'
import {
Plus as PlusIcon,
Search as SearchIcon,
DotsVertical as DotsVerticalIcon,
Message as MessageIcon,
Trash as TrashIcon,
User as UserIcon,
Robot as RobotIcon,
Copy as CopyIcon,
Refresh as RefreshIcon,
Plug as PlugIcon,
Cpu as BrainIcon,
ChevronDown as ChevronDownIcon,
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon,
FileText as FileTextIcon,
Paperclip as PaperclipIcon,
Microphone as MicIcon
} from '@vicons/tabler'
import { useChatStore } from '../../stores/chatStore'
import { useModelStore } from '../../stores/modelStore'
import { useServerStore } from '../../stores/newServer'
import type { Topic } from '../../types/chat'
const message = useMessage()
const store = useChatStore()
const modelStore = useModelStore()
const mcpStore = useServerStore()
// 响应式数据
const searchKeyword = ref('')
const inputText = ref('')
const showEditModal = ref(false)
const showSidebar = ref(true)
const editingTopic = ref<Topic | null>(null)
const editForm = ref({
name: '',
description: ''
})
const messagesScrollRef = ref()
const selectedModel = ref<string>()
const selectedMCP = ref<string>()
// 从 localStorage 加载上次选择
const loadLastSelection = () => {
try {
const lastModel = localStorage.getItem('chat-selected-model')
const lastMCP = localStorage.getItem('chat-selected-mcp')
if (lastModel) {
selectedModel.value = lastModel
console.log('🔍 [loadLastSelection] 恢复模型选择:', lastModel)
}
if (lastMCP) {
selectedMCP.value = lastMCP
console.log('🔍 [loadLastSelection] 恢复MCP选择:', lastMCP)
}
} catch (error) {
console.warn('⚠️ [loadLastSelection] 加载失败:', error)
}
}
// 保存选择到 localStorage
const saveSelection = () => {
try {
if (selectedModel.value) {
localStorage.setItem('chat-selected-model', selectedModel.value)
}
if (selectedMCP.value) {
localStorage.setItem('chat-selected-mcp', selectedMCP.value)
}
} catch (error) {
console.warn('⚠️ [saveSelection] 保存失败:', error)
}
}
// 监听选择变化
watch(selectedModel, () => {
saveSelection()
})
watch(selectedMCP, () => {
saveSelection()
})
// 计算属性
const filteredTopics = computed(() => {
let topics = store.state.topics
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
topics = topics.filter(
t =>
t.name.toLowerCase().includes(keyword) ||
t.lastMessage?.toLowerCase().includes(keyword)
)
}
return topics
})
// 模型选项
const modelOptions = computed(() => {
const services = modelStore.providers
const options: any[] = []
console.log('[modelOptions] 处理服务列表:', services)
services.forEach((service: any) => {
console.log(`[modelOptions] 服务: ${service.name}, enabled: ${service.enabled}, models数量: ${service.models?.length || 0}`)
// 检查 enabled 属性,如果不存在则默认为 true (兼容 status === 'connected')
const isEnabled = service.enabled !== undefined ? service.enabled : (service.status === 'connected')
if (isEnabled && service.models && Array.isArray(service.models) && service.models.length > 0) {
service.models.forEach((model: any) => {
// 支持两种格式:
// 1. 对象格式: {id: "xxx", name: "xxx"}
// 2. 字符串格式: "model-id"
let modelId: string
let modelName: string
if (typeof model === 'string') {
// 字符串格式
modelId = model
modelName = model
} else {
// 对象格式
modelId = model.id || model
modelName = model.name || model.id || model
}
const option = {
label: `${service.name} | ${modelName}`,
key: `${service.id}:${modelId}`,
icon: () => h(NIcon, { component: BrainIcon })
}
console.log('[modelOptions] 添加模型选项:', option.label)
options.push(option)
})
}
})
console.log('[modelOptions] 最终选项数量:', options.length, options)
return options
})
// 选中的模型名称
const selectedModelName = computed(() => {
if (!selectedModel.value) return undefined
const [serviceId, modelId] = selectedModel.value.split(':')
const service = modelStore.providers.find((s: any) => s.id === serviceId)
// 支持两种模型格式
let modelName: string | undefined
if (service?.models) {
const model = service.models.find((m: any) => {
if (typeof m === 'string') {
return m === modelId
} else {
return m.id === modelId
}
})
if (model) {
modelName = typeof model === 'string' ? model : (model.name || model.id)
}
}
return modelName ? `${service?.name} | ${modelName}` : undefined
})
// MCP 选项 - 从 mcpStore 动态读取已连接的服务器
const mcpOptions = computed(() => {
const options: any[] = [
{
label: '不启用',
key: 'none'
}
]
// 添加已连接的服务器
mcpStore.connectedServers.forEach((server) => {
const toolCount = server.capabilities?.tools?.length || 0
options.push({
label: `${server.name} (${toolCount} 个工具)`,
key: server.id,
icon: () => h(NIcon, { component: PlugIcon })
})
})
return options
})
// 选中的 MCP 服务器名称
const selectedMCPName = computed(() => {
if (!selectedMCP.value || selectedMCP.value === 'none') return '不启用 MCP 服务'
const server = mcpStore.servers.find(s => s.id === selectedMCP.value)
return server?.name || '未知服务'
})
// 话题菜单选项
const getTopicMenuOptions = (topic: Topic) => [
{
label: topic.pinned ? '取消置顶' : '置顶',
key: 'pin'
},
{
label: '重命名',
key: 'rename'
},
{
label: '删除',
key: 'delete'
}
]
// 处理话题菜单
const handleTopicMenu = (key: string, topic: Topic) => {
switch (key) {
case 'pin':
store.toggleTopicPin(topic.id)
message.success(topic.pinned ? '已取消置顶' : '已置顶')
break
case 'rename':
editingTopic.value = topic
editForm.value.name = topic.name
editForm.value.description = topic.description || ''
showEditModal.value = true
break
case 'delete':
if (confirm(`确定要删除对话"${topic.name}"吗?`)) {
store.deleteTopic(topic.id)
message.success('已删除')
}
break
}
}
// 创建话题
const handleCreateTopic = () => {
const name = prompt('请输入对话名称:', '新对话')
if (name) {
store.createTopic(name.trim())
message.success('已创建')
showSidebar.value = true
}
}
// 选择话题
const handleSelectTopic = (topicId: string) => {
store.setCurrentTopic(topicId)
if (window.innerWidth < 1200) {
showSidebar.value = false
}
}
// 保存编辑
const handleSaveEdit = () => {
if (!editingTopic.value) return
store.updateTopic(editingTopic.value.id, {
name: editForm.value.name,
description: editForm.value.description
})
showEditModal.value = false
editingTopic.value = null
message.success('已保存')
}
// 清空消息
const handleClearMessages = () => {
if (confirm('确定要清空当前对话的所有消息吗?')) {
// TODO: 实现清空消息
message.success('已清空')
}
}
// 选择模型
const handleSelectModel = (key: string) => {
selectedModel.value = key
message.success('已切换模型')
}
// 选择 MCP
const handleSelectMCP = (key: string) => {
if (key === 'none') {
selectedMCP.value = undefined
message.info('已禁用 MCP 服务')
} else {
selectedMCP.value = key
message.success(`已选择 ${key}`)
}
}
// 统一的按钮点击处理(参考 cherry-studio 的 PAUSED 状态逻辑)
const handleButtonClick = () => {
if (store.state.isSending) {
handleStopGeneration()
} else {
handleSendMessage()
}
}
// 停止生成
const handleStopGeneration = () => {
console.log('🛑 [handleStopGeneration] 用户请求停止生成')
store.stopGeneration()
message.info('已停止生成')
}
// 发送消息
const handleSendMessage = async () => {
if (!inputText.value.trim() || store.state.isSending) return
const content = inputText.value.trim()
inputText.value = ''
// 发送消息后立即滚动到底部(显示用户消息)
nextTick(() => {
scrollToBottom()
})
try {
// 传递选中的模型和 MCP 服务器 ID
const mcpId = selectedMCP.value === 'none' ? undefined : selectedMCP.value
// 从 "serviceId:modelId" 格式中提取纯的 modelId
let modelId = selectedModel.value
if (modelId && modelId.includes(':')) {
const [, extractedModelId] = modelId.split(':')
modelId = extractedModelId
console.log('🔍 [handleSendMessage] 提取模型ID:', selectedModel.value, '→', modelId)
}
await store.sendMessageStream(
content,
modelId,
mcpId,
() => {
// 每次收到消息块时滚动
scrollToBottom()
}
)
// 消息完成后再滚动一次
scrollToBottom()
} catch (error) {
message.error(error instanceof Error ? error.message : '发送失败')
}
}
// 键盘事件
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
// 复制消息
const handleCopyMessage = (content: string) => {
navigator.clipboard
.writeText(content)
.then(() => message.success('已复制'))
.catch(() => message.error('复制失败'))
}
// 重新生成
const handleRegenerateMessage = async (messageId: string) => {
try {
await store.regenerateMessage(messageId)
message.success('正在重新生成...')
scrollToBottom()
} catch (error) {
message.error(error instanceof Error ? error.message : '重新生成失败')
}
}
// 删除消息
const handleDeleteMessage = (messageId: string) => {
if (confirm('确定要删除这条消息吗?')) {
store.deleteMessage(messageId)
message.success('已删除')
}
}
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messagesScrollRef.value) {
const scrollbarEl = messagesScrollRef.value
// Naive UI NScrollbar 的正确用法
if (scrollbarEl.scrollTo) {
scrollbarEl.scrollTo({ top: 999999, behavior: 'smooth' })
} else if (scrollbarEl.$el) {
// 降级方案:直接操作 DOM
const container = scrollbarEl.$el.querySelector('.n-scrollbar-container')
if (container) {
container.scrollTop = container.scrollHeight
}
}
}
})
}
// 格式化日期
const formatDate = (date: Date): string => {
const now = new Date()
const diff = now.getTime() - new Date(date).getTime()
const mins = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (mins < 1) return '刚刚'
if (mins < 60) return `${mins}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return new Date(date).toLocaleDateString('zh-CN')
}
// 格式化时间
const formatTime = (date: Date): string => {
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 初始化
onMounted(async () => {
store.initialize()
modelStore.initialize() // 初始化模型服务
// 加载上次选择的模型和MCP服务器
loadLastSelection()
// 调试:打印模型服务配置
console.log('[ChatLayout] modelStore.providers:', modelStore.providers)
console.log('[ChatLayout] modelOptions:', modelOptions.value)
console.log('[ChatLayout] selectedModel:', selectedModel.value)
console.log('[ChatLayout] selectedMCP:', selectedMCP.value)
scrollToBottom()
// 加载 MCP 服务器配置
mcpStore.loadServers()
// 尝试自动重连之前已连接的服务器
try {
await mcpStore.autoReconnect()
} catch (error) {
console.warn('自动重连 MCP 服务器失败:', error)
}
})
</script>
<style scoped>
.chat-container {
display: flex;
height: 100%;
background: var(--color-base);
position: relative;
}
/* 主对话区域 */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
transition: margin-right 0.3s ease;
}
.empty-chat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.empty-icon {
color: var(--border-color);
}
.empty-chat h3 {
margin: 0;
font-size: 20px;
}
.empty-chat p {
margin: 0;
color: var(--text-color-3);
}
/* 对话头部 */
.chat-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-target-bg);
}
.header-info h3 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
}
.message-count {
font-size: 12px;
color: var(--text-color-3);
}
.header-actions {
display: flex;
gap: 8px;
}
/* 消息列表 */
.messages-container {
flex: 1;
padding: 24px;
background: var(--color-base);
}
.messages-list {
display: flex;
flex-direction: column;
gap: 24px;
}
.message-item {
display: flex;
gap: 12px;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--primary-color);
color: white;
}
.message-item.assistant .message-avatar {
background: var(--success-color);
}
.message-content {
flex: 1;
min-width: 0;
}
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.message-role {
font-weight: 600;
font-size: 14px;
}
.message-time {
font-size: 12px;
color: var(--text-color-3);
}
.message-text {
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.message-error {
color: var(--error-color);
font-size: 12px;
margin-top: 8px;
}
.message-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.typing-indicator {
display: flex;
gap: 4px;
padding: 8px 0;
}
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary-color);
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
opacity: 0.3;
}
30% {
opacity: 1;
}
}
/* 输入框容器 */
.input-container {
border-top: 1px solid var(--border-color);
background: var(--color-target-bg);
padding: 12px 16px;
}
/* 工具栏 */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding: 4px 0;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-divider {
color: var(--border-color);
margin: 0 4px;
}
/* 输入框 */
.message-input {
margin-bottom: 8px;
}
/* 输入框底部 */
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.hint-text {
font-size: 11px;
color: var(--text-color-3);
}
/* 右侧对话列表 */
.topics-sidebar {
width: 320px;
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
background: var(--color-target-bg);
position: relative;
transition: all 0.3s ease;
overflow: hidden;
}
.topics-sidebar:not(.visible) {
width: 0;
border-left: none;
opacity: 0;
}
.sidebar-header {
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.search-box {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
.topics-list {
flex: 1;
padding: 8px;
}
.topic-item {
padding: 12px;
margin-bottom: 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.topic-item:hover {
background: var(--hover-color);
}
.topic-item.active {
background: var(--primary-color-hover);
}
.topic-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.topic-name {
font-weight: 500;
font-size: 14px;
}
.topic-preview {
font-size: 12px;
color: var(--text-color-3);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topic-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-color-3);
}
.empty-topics {
text-align: center;
padding: 40px 20px;
color: var(--text-color-3);
}
/* 响应式 */
@media (max-width: 1200px) {
.topics-sidebar {
position: absolute;
right: 0;
top: 0;
bottom: 0;
transform: translateX(100%);
z-index: 100;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
}
.topics-sidebar.visible {
transform: translateX(0);
}
}
</style>