perf(code): 格式化代码以提升可读性与一致性,统一代码风格 [优化:使用 Prettier 进行格式化]
This commit is contained in:
parent
972d92ba1a
commit
6984a09737
|
|
@ -32,6 +32,7 @@
|
|||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"sass": "^1.97.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
|
|
@ -4363,6 +4364,22 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"sass": "^1.97.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
|
|
|
|||
447
src/App.vue
447
src/App.vue
|
|
@ -1,225 +1,222 @@
|
|||
<template>
|
||||
<div class="app" :class="{ 'dark': isDark }">
|
||||
<!-- 侧边栏 -->
|
||||
<ChatSidebar />
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<ChatMain
|
||||
ref="chatMainRef"
|
||||
@toggle-sidebar="toggleSidebar"
|
||||
/>
|
||||
|
||||
<!-- 模态框 -->
|
||||
<SearchModal />
|
||||
<ShortcutsModal />
|
||||
<SettingsModal />
|
||||
<ConversationSettingsModal />
|
||||
|
||||
<!-- Toast 通知 -->
|
||||
<Teleport to="body">
|
||||
<TransitionGroup name="toast" tag="div" class="toast-container">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
:class="toast.type"
|
||||
>
|
||||
<Check v-if="toast.type === 'success'" :size="18" />
|
||||
<AlertCircle v-else-if="toast.type === 'error'" :size="18" />
|
||||
<Info v-else :size="18" />
|
||||
<span>{{ toast.message }}</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useKeyboard, getDefaultShortcuts } from '@/composables/useKeyboard'
|
||||
import ChatSidebar from '@/components/sidebar/ChatSidebar.vue'
|
||||
import ChatMain from '@/components/chat/ChatMain.vue'
|
||||
import SearchModal from '@/components/modals/SearchModal.vue'
|
||||
import ShortcutsModal from '@/components/modals/ShortcutsModal.vue'
|
||||
import SettingsModal from '@/components/modals/SettingsModal.vue'
|
||||
import ConversationSettingsModal from '@/components/modals/ConversationSettingsModal.vue'
|
||||
import { Check, AlertCircle, Info } from '@/components/icons'
|
||||
|
||||
// Stores
|
||||
const chatStore = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const { settings } = storeToRefs(settingsStore)
|
||||
|
||||
// Refs
|
||||
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const isDark = computed(() => {
|
||||
if (settings.value.theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
return settings.value.theme === 'dark'
|
||||
})
|
||||
|
||||
// Toast 通知系统
|
||||
interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: 'success' | 'error' | 'info'
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([])
|
||||
let toastId = 0
|
||||
|
||||
function showToast(message: string, type: Toast['type'] = 'info') {
|
||||
const id = ++toastId
|
||||
toasts.value.push({ id, message, type })
|
||||
|
||||
setTimeout(() => {
|
||||
const index = toasts.value.findIndex(t => t.id === id)
|
||||
if (index !== -1) {
|
||||
toasts.value.splice(index, 1)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 方法
|
||||
function toggleSidebar() {
|
||||
settingsStore.toggleSidebar()
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
chatStore.createConversation()
|
||||
showToast('已创建新对话', 'success')
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
chatMainRef.value?.focusInput()
|
||||
}
|
||||
|
||||
// 快捷键
|
||||
useKeyboard(
|
||||
getDefaultShortcuts({
|
||||
newChat,
|
||||
toggleSidebar,
|
||||
focusInput,
|
||||
sendMessage: () => {}, // 由 ChatInput 内部处理
|
||||
cancelStream: () => {
|
||||
if (chatStore.isStreaming) {
|
||||
chatStore.stopStreaming()
|
||||
showToast('已停止生成', 'info')
|
||||
}
|
||||
},
|
||||
toggleTheme: () => {
|
||||
settingsStore.toggleTheme()
|
||||
showToast(`主题已切换为 ${settings.value.theme}`, 'success')
|
||||
},
|
||||
showShortcuts: () => {
|
||||
settingsStore.openShortcutsModal()
|
||||
},
|
||||
searchConversations: () => {
|
||||
settingsStore.openSearchModal()
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 如果没有对话,创建一个
|
||||
if (chatStore.conversations.length === 0) {
|
||||
chatStore.createConversation()
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露给全局使用
|
||||
window.$toast = showToast
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.app {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
|
||||
&.dark {
|
||||
background: #11111b;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
// Toast 样式
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
pointer-events: auto;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
color: #e5e7eb;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
&.success {
|
||||
svg {
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
svg {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
&.info {
|
||||
svg {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toast 动画
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
}
|
||||
|
||||
.toast-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="app" :class="{ dark: isDark }">
|
||||
<!-- 侧边栏 -->
|
||||
<ChatSidebar />
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
|
||||
|
||||
<!-- 模态框 -->
|
||||
<SearchModal />
|
||||
<ShortcutsModal />
|
||||
<SettingsModal />
|
||||
<ConversationSettingsModal />
|
||||
|
||||
<!-- Toast 通知 -->
|
||||
<Teleport to="body">
|
||||
<TransitionGroup name="toast" tag="div" class="toast-container">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
:class="toast.type"
|
||||
>
|
||||
<Check v-if="toast.type === 'success'" :size="18" />
|
||||
<AlertCircle v-else-if="toast.type === 'error'" :size="18" />
|
||||
<Info v-else :size="18" />
|
||||
<span>{{ toast.message }}</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useKeyboard, getDefaultShortcuts } from "@/composables/useKeyboard";
|
||||
import ChatSidebar from "@/components/sidebar/ChatSidebar.vue";
|
||||
import ChatMain from "@/components/chat/ChatMain.vue";
|
||||
import SearchModal from "@/components/modals/SearchModal.vue";
|
||||
import ShortcutsModal from "@/components/modals/ShortcutsModal.vue";
|
||||
import SettingsModal from "@/components/modals/SettingsModal.vue";
|
||||
import ConversationSettingsModal from "@/components/modals/ConversationSettingsModal.vue";
|
||||
import { Check, AlertCircle, Info } from "@/components/icons";
|
||||
|
||||
// Stores
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const { settings } = storeToRefs(settingsStore);
|
||||
|
||||
// Refs
|
||||
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null);
|
||||
|
||||
// 计算属性
|
||||
const isDark = computed(() => {
|
||||
if (settings.value.theme === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
return settings.value.theme === "dark";
|
||||
});
|
||||
|
||||
// Toast 通知系统
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: "success" | "error" | "info";
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([]);
|
||||
let toastId = 0;
|
||||
|
||||
function showToast(message: string, type: Toast["type"] = "info") {
|
||||
const id = ++toastId;
|
||||
toasts.value.push({ id, message, type });
|
||||
|
||||
setTimeout(() => {
|
||||
const index = toasts.value.findIndex((t) => t.id === id);
|
||||
if (index !== -1) {
|
||||
toasts.value.splice(index, 1);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 方法
|
||||
function toggleSidebar() {
|
||||
settingsStore.toggleSidebar();
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
chatStore.createConversation();
|
||||
showToast("已创建新对话", "success");
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
chatMainRef.value?.focusInput();
|
||||
}
|
||||
|
||||
// 快捷键
|
||||
useKeyboard(
|
||||
getDefaultShortcuts({
|
||||
newChat,
|
||||
toggleSidebar,
|
||||
focusInput,
|
||||
sendMessage: () => {}, // 由 ChatInput 内部处理
|
||||
cancelStream: () => {
|
||||
if (chatStore.isStreaming) {
|
||||
chatStore.stopStreaming();
|
||||
showToast("已停止生成", "info");
|
||||
}
|
||||
},
|
||||
toggleTheme: () => {
|
||||
settingsStore.toggleTheme();
|
||||
showToast(`主题已切换为 ${settings.value.theme}`, "success");
|
||||
},
|
||||
showShortcuts: () => {
|
||||
settingsStore.openShortcutsModal();
|
||||
},
|
||||
searchConversations: () => {
|
||||
settingsStore.openSearchModal();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 如果没有对话,创建一个
|
||||
if (chatStore.conversations.length === 0) {
|
||||
chatStore.createConversation();
|
||||
}
|
||||
});
|
||||
|
||||
// 暴露给全局使用
|
||||
window.$toast = showToast;
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.app {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
|
||||
&.dark {
|
||||
background: #11111b;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
// Toast 样式
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
pointer-events: auto;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
color: #e5e7eb;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
&.success {
|
||||
svg {
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
svg {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
&.info {
|
||||
svg {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toast 动画
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
}
|
||||
|
||||
.toast-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,418 +1,418 @@
|
|||
<template>
|
||||
<main class="chat-main" :class="{ 'wide-mode': isWideMode }">
|
||||
<!-- 头部 -->
|
||||
<ChatHeader
|
||||
:title="currentConversation?.title || '新对话'"
|
||||
:message-count="messages.length"
|
||||
:show-sidebar-toggle="sidebarCollapsed"
|
||||
:is-wide-mode="isWideMode"
|
||||
:is-pinned="currentConversation?.pinned"
|
||||
@toggle-sidebar="$emit('toggle-sidebar')"
|
||||
@toggle-wide-mode="toggleWideMode"
|
||||
@clear="handleClear"
|
||||
@export="handleExport"
|
||||
@pin="handlePin"
|
||||
/>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<MessageList
|
||||
ref="messageListRef"
|
||||
:messages="messages"
|
||||
:show-timestamp="settings.showTimestamp"
|
||||
:compact="settings.compactMode"
|
||||
:is-typing="isTyping"
|
||||
@retry="handleRetry"
|
||||
@regenerate="handleRegenerate"
|
||||
@select-suggestion="handleSuggestion"
|
||||
/>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-wrapper">
|
||||
<div class="input-container" :class="{ wide: isWideMode }">
|
||||
<ChatInput
|
||||
ref="chatInputRef"
|
||||
:placeholder="inputPlaceholder"
|
||||
:is-streaming="isStreaming"
|
||||
:send-on-enter="settings.sendOnEnter"
|
||||
:disabled="false"
|
||||
@send="handleSend"
|
||||
@stop="handleStop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import ChatHeader from "./ChatHeader.vue";
|
||||
import MessageList from "./MessageList.vue";
|
||||
import ChatInput from "@/components/input/ChatInput.vue";
|
||||
import { MessageType, MessageRole } from "@/types/chat";
|
||||
import type { Attachment } from "@/types/chat";
|
||||
import { chatApi } from "@/services/api";
|
||||
|
||||
defineEmits<{
|
||||
"toggle-sidebar": [];
|
||||
}>();
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const { currentConversation, isStreaming } = storeToRefs(chatStore);
|
||||
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
|
||||
|
||||
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
|
||||
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
||||
const isWideMode = ref(true);
|
||||
const isTyping = ref(false);
|
||||
const currentStreamingMessageId = ref<string | null>(null);
|
||||
const abortController: any = ref<AbortController | null>(null);
|
||||
|
||||
const messages: any = computed(() => currentConversation.value?.messages || []);
|
||||
|
||||
const inputPlaceholder = computed(() => {
|
||||
if (isStreaming.value) return "正在生成回复...";
|
||||
return "输入你的问题,按 Ctrl+Enter 发送";
|
||||
});
|
||||
|
||||
function toggleWideMode() {
|
||||
isWideMode.value = !isWideMode.value;
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (currentConversation.value) {
|
||||
chatStore.clearConversation(currentConversation.value.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (!currentConversation.value) return;
|
||||
|
||||
const data = {
|
||||
title: currentConversation.value.title,
|
||||
messages: currentConversation.value.messages,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${currentConversation.value.title}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function handlePin() {
|
||||
if (currentConversation.value) {
|
||||
chatStore.togglePinConversation(currentConversation.value.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息 - 使用真实 API
|
||||
async function handleSend(
|
||||
text: string,
|
||||
attachments: Attachment[],
|
||||
options?: {
|
||||
deepSearch?: boolean;
|
||||
webSearch?: boolean;
|
||||
deepThinking?: boolean;
|
||||
},
|
||||
) {
|
||||
console.log("handleSend", text, attachments, options);
|
||||
// 检查是否还有正在上传的附件
|
||||
const uploadingAttachments = attachments.filter((a) => a.uploading);
|
||||
if (uploadingAttachments.length > 0) {
|
||||
// 等待所有上传完成
|
||||
const uploads = uploadingAttachments.map(async (attachment) => {
|
||||
// 这里我们可以通过检查附件状态来判断是否上传完成
|
||||
// 但更简单的方法是等待一小段时间,让上传有机会完成
|
||||
return new Promise<void>((resolve) => {
|
||||
const checkUpload = () => {
|
||||
const stillUploading = attachments.some(
|
||||
(a) => a.id === attachment.id && a.uploading,
|
||||
);
|
||||
if (!stillUploading) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkUpload, 100); // 每100ms检查一次
|
||||
}
|
||||
};
|
||||
checkUpload();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(uploads);
|
||||
} catch (error) {
|
||||
console.error("等待上传完成时发生错误:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有当前对话,创建新对话
|
||||
if (!currentConversation.value) {
|
||||
chatStore.createConversation();
|
||||
}
|
||||
|
||||
// 从当前会话中提取历史消息(用于上下文记忆),在添加新消息之前提取
|
||||
const existingMessages = currentConversation.value?.messages || [];
|
||||
const MAX_HISTORY_ROUNDS = 20; // 最多保留最近 20 轮(40 条消息)
|
||||
const historyMessages = existingMessages
|
||||
.filter(
|
||||
(m: any) =>
|
||||
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
|
||||
)
|
||||
.filter((m: any) => m.content?.text) // 过滤掉空消息
|
||||
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
||||
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
||||
|
||||
// 添加用户消息
|
||||
chatStore.addMessage(MessageRole.USER, {
|
||||
type: MessageType.TEXT,
|
||||
text,
|
||||
images: attachments.filter((a) => a.type === "image"),
|
||||
files: attachments.filter((a) => a.type === "file"),
|
||||
});
|
||||
|
||||
// 添加 AI 消息占位符
|
||||
const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, {
|
||||
type: MessageType.TEXT,
|
||||
text: "",
|
||||
});
|
||||
|
||||
currentStreamingMessageId.value = aiMessage.id;
|
||||
chatStore.updateMessage(aiMessage.id, { isStreaming: true });
|
||||
chatStore.startStreaming();
|
||||
isTyping.value = true;
|
||||
|
||||
// 创建 AbortController
|
||||
abortController.value = new AbortController();
|
||||
try {
|
||||
// 提取图片URL用于发送给API
|
||||
const imageUrls = attachments
|
||||
.filter((a) => a.type === "image")
|
||||
.map((a) => a.url);
|
||||
|
||||
// 提取非图片文件URL(txt, pdf, docx 等)
|
||||
const fileUrls = attachments
|
||||
.filter((a) => a.type === "file")
|
||||
.map((a) => a.url);
|
||||
|
||||
const stream = chatApi.streamChat(
|
||||
{
|
||||
message: text,
|
||||
conversationId: currentConversation.value?.id || "",
|
||||
images: imageUrls,
|
||||
files: fileUrls,
|
||||
model: settings.value.defaultModel,
|
||||
stream: true,
|
||||
history: historyMessages,
|
||||
deepSearch: options?.deepSearch,
|
||||
webSearch: options?.webSearch,
|
||||
deepThinking: options?.deepThinking,
|
||||
},
|
||||
abortController.value.signal,
|
||||
);
|
||||
|
||||
let fullText = "";
|
||||
isTyping.value = false;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.value?.signal.aborted) break;
|
||||
fullText += chunk;
|
||||
chatStore.updateMessageContent(aiMessage.id, fullText);
|
||||
}
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
chatStore.updateMessage(aiMessage.id, {
|
||||
isStreaming: false,
|
||||
content: {
|
||||
type: MessageType.TEXT,
|
||||
text: fullText,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
chatStore.updateMessage(aiMessage.id, {
|
||||
isStreaming: false,
|
||||
isError: true,
|
||||
errorMessage: error.message || "请求失败",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
chatStore.stopStreaming();
|
||||
currentStreamingMessageId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 停止生成
|
||||
function handleStop() {
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
abortController.value = null;
|
||||
}
|
||||
|
||||
chatStore.stopStreaming();
|
||||
chatApi.stopChat(messages.value.at(-1)["messageId"]);
|
||||
if (currentStreamingMessageId.value) {
|
||||
chatStore.updateMessage(currentStreamingMessageId.value, {
|
||||
isStreaming: false,
|
||||
});
|
||||
currentStreamingMessageId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 重试
|
||||
async function handleRetry(messageId: string) {
|
||||
const message = messages.value.find((m: any) => m.id === messageId);
|
||||
if (!message || message.role !== MessageRole.ASSISTANT) return;
|
||||
|
||||
const messageIndex = messages.value.findIndex((m: any) => m.id === messageId);
|
||||
if (messageIndex <= 0) return;
|
||||
|
||||
const userMessage = messages.value[messageIndex - 1];
|
||||
if (userMessage.role !== MessageRole.USER) return;
|
||||
|
||||
// 提取重试位置之前的历史消息(用于上下文记忆)
|
||||
const MAX_HISTORY_ROUNDS = 20;
|
||||
const priorMessages = messages.value
|
||||
.slice(0, messageIndex - 1) // 不包含当前 user 消息和要重试的 assistant 消息
|
||||
.filter(
|
||||
(m: any) =>
|
||||
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
|
||||
)
|
||||
.filter((m: any) => m.content?.text)
|
||||
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
||||
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
||||
|
||||
// 重置消息状态
|
||||
chatStore.updateMessage(messageId, {
|
||||
isError: false,
|
||||
errorMessage: undefined,
|
||||
isStreaming: true,
|
||||
isEnd: true,
|
||||
content: { type: MessageType.TEXT, text: "" },
|
||||
});
|
||||
|
||||
currentStreamingMessageId.value = messageId;
|
||||
chatStore.startStreaming();
|
||||
abortController.value = new AbortController();
|
||||
|
||||
try {
|
||||
const stream = chatApi.streamChat(
|
||||
{
|
||||
message: userMessage.content.text || "",
|
||||
conversationId: currentConversation.value?.id,
|
||||
model: settings.value.defaultModel,
|
||||
stream: true,
|
||||
history: priorMessages,
|
||||
},
|
||||
abortController.value.signal,
|
||||
);
|
||||
|
||||
let fullText = "";
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.value?.signal.aborted) break;
|
||||
fullText += chunk;
|
||||
chatStore.updateMessageContent(messageId, fullText);
|
||||
}
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
chatStore.updateMessage(messageId, {
|
||||
isStreaming: false,
|
||||
content: {
|
||||
type: MessageType.TEXT,
|
||||
text: fullText,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
chatStore.updateMessage(messageId, {
|
||||
isStreaming: false,
|
||||
isError: true,
|
||||
errorMessage: error.message || "请求失败",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
chatStore.stopStreaming();
|
||||
currentStreamingMessageId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRegenerate(messageId: string) {
|
||||
handleRetry(messageId);
|
||||
}
|
||||
|
||||
function handleSuggestion(text: string) {
|
||||
handleSend(text, []);
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
chatInputRef.value?.focus();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusInput,
|
||||
messageListRef,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => currentConversation.value?.id,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
focusInput();
|
||||
});
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
border-radius: 15px;
|
||||
|
||||
.dark & {
|
||||
background: #11111b;
|
||||
}
|
||||
|
||||
&.wide-mode {
|
||||
.input-container {
|
||||
max-width: 1000px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
flex-shrink: 0;
|
||||
padding: 16px 24px 24px;
|
||||
background: linear-gradient(to top, white 80%, transparent);
|
||||
|
||||
.dark & {
|
||||
background: linear-gradient(to top, #11111b 80%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
transition: max-width 0.3s ease;
|
||||
|
||||
&.wide {
|
||||
max-width: 1000px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<main class="chat-main" :class="{ 'wide-mode': isWideMode }">
|
||||
<!-- 头部 -->
|
||||
<ChatHeader
|
||||
:title="currentConversation?.title || '新对话'"
|
||||
:message-count="messages.length"
|
||||
:show-sidebar-toggle="sidebarCollapsed"
|
||||
:is-wide-mode="isWideMode"
|
||||
:is-pinned="currentConversation?.pinned"
|
||||
@toggle-sidebar="$emit('toggle-sidebar')"
|
||||
@toggle-wide-mode="toggleWideMode"
|
||||
@clear="handleClear"
|
||||
@export="handleExport"
|
||||
@pin="handlePin"
|
||||
/>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<MessageList
|
||||
ref="messageListRef"
|
||||
:messages="messages"
|
||||
:show-timestamp="settings.showTimestamp"
|
||||
:compact="settings.compactMode"
|
||||
:is-typing="isTyping"
|
||||
@retry="handleRetry"
|
||||
@regenerate="handleRegenerate"
|
||||
@select-suggestion="handleSuggestion"
|
||||
/>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-wrapper">
|
||||
<div class="input-container" :class="{ wide: isWideMode }">
|
||||
<ChatInput
|
||||
ref="chatInputRef"
|
||||
:placeholder="inputPlaceholder"
|
||||
:is-streaming="isStreaming"
|
||||
:send-on-enter="settings.sendOnEnter"
|
||||
:disabled="false"
|
||||
@send="handleSend"
|
||||
@stop="handleStop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import ChatHeader from "./ChatHeader.vue";
|
||||
import MessageList from "./MessageList.vue";
|
||||
import ChatInput from "@/components/input/ChatInput.vue";
|
||||
import { MessageType, MessageRole } from "@/types/chat";
|
||||
import type { Attachment } from "@/types/chat";
|
||||
import { chatApi } from "@/services/api";
|
||||
|
||||
defineEmits<{
|
||||
"toggle-sidebar": [];
|
||||
}>();
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const { currentConversation, isStreaming } = storeToRefs(chatStore);
|
||||
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
|
||||
|
||||
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
|
||||
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
||||
const isWideMode = ref(true);
|
||||
const isTyping = ref(false);
|
||||
const currentStreamingMessageId = ref<string | null>(null);
|
||||
const abortController: any = ref<AbortController | null>(null);
|
||||
|
||||
const messages: any = computed(() => currentConversation.value?.messages || []);
|
||||
|
||||
const inputPlaceholder = computed(() => {
|
||||
if (isStreaming.value) return "正在生成回复...";
|
||||
return "输入你的问题,按 Ctrl+Enter 发送";
|
||||
});
|
||||
|
||||
function toggleWideMode() {
|
||||
isWideMode.value = !isWideMode.value;
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (currentConversation.value) {
|
||||
chatStore.clearConversation(currentConversation.value.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (!currentConversation.value) return;
|
||||
|
||||
const data = {
|
||||
title: currentConversation.value.title,
|
||||
messages: currentConversation.value.messages,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${currentConversation.value.title}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function handlePin() {
|
||||
if (currentConversation.value) {
|
||||
chatStore.togglePinConversation(currentConversation.value.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息 - 使用真实 API
|
||||
async function handleSend(
|
||||
text: string,
|
||||
attachments: Attachment[],
|
||||
options?: {
|
||||
deepSearch?: boolean;
|
||||
webSearch?: boolean;
|
||||
deepThinking?: boolean;
|
||||
},
|
||||
) {
|
||||
console.log("handleSend", text, attachments, options);
|
||||
// 检查是否还有正在上传的附件
|
||||
const uploadingAttachments = attachments.filter((a) => a.uploading);
|
||||
if (uploadingAttachments.length > 0) {
|
||||
// 等待所有上传完成
|
||||
const uploads = uploadingAttachments.map(async (attachment) => {
|
||||
// 这里我们可以通过检查附件状态来判断是否上传完成
|
||||
// 但更简单的方法是等待一小段时间,让上传有机会完成
|
||||
return new Promise<void>((resolve) => {
|
||||
const checkUpload = () => {
|
||||
const stillUploading = attachments.some(
|
||||
(a) => a.id === attachment.id && a.uploading,
|
||||
);
|
||||
if (!stillUploading) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkUpload, 100); // 每100ms检查一次
|
||||
}
|
||||
};
|
||||
checkUpload();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(uploads);
|
||||
} catch (error) {
|
||||
console.error("等待上传完成时发生错误:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有当前对话,创建新对话
|
||||
if (!currentConversation.value) {
|
||||
chatStore.createConversation();
|
||||
}
|
||||
|
||||
// 从当前会话中提取历史消息(用于上下文记忆),在添加新消息之前提取
|
||||
const existingMessages = currentConversation.value?.messages || [];
|
||||
const MAX_HISTORY_ROUNDS = 20; // 最多保留最近 20 轮(40 条消息)
|
||||
const historyMessages = existingMessages
|
||||
.filter(
|
||||
(m: any) =>
|
||||
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
|
||||
)
|
||||
.filter((m: any) => m.content?.text) // 过滤掉空消息
|
||||
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
||||
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
||||
|
||||
// 添加用户消息
|
||||
chatStore.addMessage(MessageRole.USER, {
|
||||
type: MessageType.TEXT,
|
||||
text,
|
||||
images: attachments.filter((a) => a.type === "image"),
|
||||
files: attachments.filter((a) => a.type === "file"),
|
||||
});
|
||||
|
||||
// 添加 AI 消息占位符
|
||||
const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, {
|
||||
type: MessageType.TEXT,
|
||||
text: "",
|
||||
});
|
||||
|
||||
currentStreamingMessageId.value = aiMessage.id;
|
||||
chatStore.updateMessage(aiMessage.id, { isStreaming: true });
|
||||
chatStore.startStreaming();
|
||||
isTyping.value = true;
|
||||
|
||||
// 创建 AbortController
|
||||
abortController.value = new AbortController();
|
||||
try {
|
||||
// 提取图片URL用于发送给API
|
||||
const imageUrls = attachments
|
||||
.filter((a) => a.type === "image")
|
||||
.map((a) => a.url);
|
||||
|
||||
// 提取非图片文件URL(txt, pdf, docx 等)
|
||||
const fileUrls = attachments
|
||||
.filter((a) => a.type === "file")
|
||||
.map((a) => a.url);
|
||||
|
||||
const stream = chatApi.streamChat(
|
||||
{
|
||||
message: text,
|
||||
conversationId: currentConversation.value?.id || "",
|
||||
images: imageUrls,
|
||||
files: fileUrls,
|
||||
model: settings.value.defaultModel,
|
||||
stream: true,
|
||||
history: historyMessages,
|
||||
deepSearch: options?.deepSearch,
|
||||
webSearch: options?.webSearch,
|
||||
deepThinking: options?.deepThinking,
|
||||
},
|
||||
abortController.value.signal,
|
||||
);
|
||||
|
||||
let fullText = "";
|
||||
isTyping.value = false;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.value?.signal.aborted) break;
|
||||
fullText += chunk;
|
||||
chatStore.updateMessageContent(aiMessage.id, fullText);
|
||||
}
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
chatStore.updateMessage(aiMessage.id, {
|
||||
isStreaming: false,
|
||||
content: {
|
||||
type: MessageType.TEXT,
|
||||
text: fullText,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
chatStore.updateMessage(aiMessage.id, {
|
||||
isStreaming: false,
|
||||
isError: true,
|
||||
errorMessage: error.message || "请求失败",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
chatStore.stopStreaming();
|
||||
currentStreamingMessageId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 停止生成
|
||||
function handleStop() {
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
abortController.value = null;
|
||||
}
|
||||
|
||||
chatStore.stopStreaming();
|
||||
chatApi.stopChat(messages.value.at(-1)["messageId"]);
|
||||
if (currentStreamingMessageId.value) {
|
||||
chatStore.updateMessage(currentStreamingMessageId.value, {
|
||||
isStreaming: false,
|
||||
});
|
||||
currentStreamingMessageId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 重试
|
||||
async function handleRetry(messageId: string) {
|
||||
const message = messages.value.find((m: any) => m.id === messageId);
|
||||
if (!message || message.role !== MessageRole.ASSISTANT) return;
|
||||
|
||||
const messageIndex = messages.value.findIndex((m: any) => m.id === messageId);
|
||||
if (messageIndex <= 0) return;
|
||||
|
||||
const userMessage = messages.value[messageIndex - 1];
|
||||
if (userMessage.role !== MessageRole.USER) return;
|
||||
|
||||
// 提取重试位置之前的历史消息(用于上下文记忆)
|
||||
const MAX_HISTORY_ROUNDS = 20;
|
||||
const priorMessages = messages.value
|
||||
.slice(0, messageIndex - 1) // 不包含当前 user 消息和要重试的 assistant 消息
|
||||
.filter(
|
||||
(m: any) =>
|
||||
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
|
||||
)
|
||||
.filter((m: any) => m.content?.text)
|
||||
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
||||
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
||||
|
||||
// 重置消息状态
|
||||
chatStore.updateMessage(messageId, {
|
||||
isError: false,
|
||||
errorMessage: undefined,
|
||||
isStreaming: true,
|
||||
isEnd: true,
|
||||
content: { type: MessageType.TEXT, text: "" },
|
||||
});
|
||||
|
||||
currentStreamingMessageId.value = messageId;
|
||||
chatStore.startStreaming();
|
||||
abortController.value = new AbortController();
|
||||
|
||||
try {
|
||||
const stream = chatApi.streamChat(
|
||||
{
|
||||
message: userMessage.content.text || "",
|
||||
conversationId: currentConversation.value?.id,
|
||||
model: settings.value.defaultModel,
|
||||
stream: true,
|
||||
history: priorMessages,
|
||||
},
|
||||
abortController.value.signal,
|
||||
);
|
||||
|
||||
let fullText = "";
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.value?.signal.aborted) break;
|
||||
fullText += chunk;
|
||||
chatStore.updateMessageContent(messageId, fullText);
|
||||
}
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
chatStore.updateMessage(messageId, {
|
||||
isStreaming: false,
|
||||
content: {
|
||||
type: MessageType.TEXT,
|
||||
text: fullText,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
chatStore.updateMessage(messageId, {
|
||||
isStreaming: false,
|
||||
isError: true,
|
||||
errorMessage: error.message || "请求失败",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
chatStore.stopStreaming();
|
||||
currentStreamingMessageId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRegenerate(messageId: string) {
|
||||
handleRetry(messageId);
|
||||
}
|
||||
|
||||
function handleSuggestion(text: string) {
|
||||
handleSend(text, []);
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
chatInputRef.value?.focus();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusInput,
|
||||
messageListRef,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => currentConversation.value?.id,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
focusInput();
|
||||
});
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
border-radius: 15px;
|
||||
|
||||
.dark & {
|
||||
background: #11111b;
|
||||
}
|
||||
|
||||
&.wide-mode {
|
||||
.input-container {
|
||||
max-width: 1000px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
flex-shrink: 0;
|
||||
padding: 16px 24px 24px;
|
||||
background: linear-gradient(to top, white 80%, transparent);
|
||||
|
||||
.dark & {
|
||||
background: linear-gradient(to top, #11111b 80%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
transition: max-width 0.3s ease;
|
||||
|
||||
&.wide {
|
||||
max-width: 1000px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,397 +1,397 @@
|
|||
<template>
|
||||
<div ref="boxRef" style="flex: 1; position: relative">
|
||||
<div ref="containerRef" class="message-list" @scroll="handleScroll">
|
||||
<!-- 欢迎界面 -->
|
||||
<WelcomeScreen
|
||||
v-if="messages.length === 0"
|
||||
@select="$emit('select-suggestion', $event)"
|
||||
/>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<template v-else>
|
||||
<div class="messages-wrapper">
|
||||
<TransitionGroup name="message">
|
||||
<MessageBubble
|
||||
v-for="(message, index) in messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:show-timestamp="showTimestamp"
|
||||
:compact="compact"
|
||||
:is-New="index === messages.length - 1"
|
||||
@retry="$emit('retry', message.id)"
|
||||
@regenerate="$emit('regenerate', message.id)"
|
||||
@copy="handleCopy(message)"
|
||||
@like="handleLike(message)"
|
||||
@dislike="handleDislike(message)"
|
||||
@select-suggestion="$emit('select-suggestion', $event.text)"
|
||||
@preview-image="handlePreviewImage"
|
||||
@play-video="handlePlayVideo"
|
||||
@download-file="handleDownloadFile"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- 正在输入指示器 -->
|
||||
<div v-if="isTyping" class="typing-indicator">
|
||||
<div class="typing-avatar">
|
||||
<Bot :size="20" />
|
||||
</div>
|
||||
<div class="typing-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<span class="typing-text">AI 正在思考...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 回到底部按钮 -->
|
||||
<Transition name="fade">
|
||||
<button
|
||||
v-if="showScrollButton"
|
||||
class="scroll-bottom-btn"
|
||||
@click="handleScrollToBottom"
|
||||
>
|
||||
<ChevronDown :size="20" />
|
||||
<span v-if="newMessageCount > 0" class="new-count">
|
||||
{{ newMessageCount }}
|
||||
</span>
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onMounted } from "vue";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import MessageBubble from "@/components/message/MessageBubble.vue";
|
||||
import WelcomeScreen from "./WelcomeScreen.vue";
|
||||
import { Bot, ChevronDown } from "@/components/icons";
|
||||
import type { Message, Attachment, VideoInfo } from "@/types/chat";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
messages: Message[];
|
||||
showTimestamp?: boolean;
|
||||
compact?: boolean;
|
||||
isTyping?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showTimestamp: true,
|
||||
compact: false,
|
||||
isTyping: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: [messageId: string];
|
||||
regenerate: [messageId: string];
|
||||
"select-suggestion": [text: string];
|
||||
"preview-image": [image: Attachment, index: number];
|
||||
"play-video": [video: VideoInfo];
|
||||
"download-file": [file: Attachment];
|
||||
}>();
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// 响应式状态
|
||||
const boxRef: any = ref<HTMLElement | null>(null);
|
||||
const containerRef: any = ref<HTMLElement | null>(null);
|
||||
const showScrollButton = ref(false);
|
||||
const newMessageCount = ref(0);
|
||||
const isAutoScrolling = ref(true);
|
||||
const lastScrollTop = ref(0);
|
||||
|
||||
onMounted(() => {
|
||||
containerRef.value.style.height = boxRef.value?.clientHeight + "px";
|
||||
});
|
||||
|
||||
// 滚动处理
|
||||
function handleScroll() {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
|
||||
|
||||
if (scrollTop < lastScrollTop.value && !isAtBottom) {
|
||||
isAutoScrolling.value = false;
|
||||
showScrollButton.value = true;
|
||||
}
|
||||
|
||||
if (isAtBottom) {
|
||||
isAutoScrolling.value = true;
|
||||
showScrollButton.value = false;
|
||||
newMessageCount.value = 0;
|
||||
}
|
||||
|
||||
lastScrollTop.value = scrollTop;
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
function scrollToBottom(smooth = true) {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: smooth ? "smooth" : "auto",
|
||||
});
|
||||
|
||||
isAutoScrolling.value = true;
|
||||
showScrollButton.value = false;
|
||||
newMessageCount.value = 0;
|
||||
}
|
||||
|
||||
// 按钮点击处理
|
||||
function handleScrollToBottom() {
|
||||
scrollToBottom(true);
|
||||
}
|
||||
|
||||
// 消息操作
|
||||
function handleCopy(message: Message) {
|
||||
chatStore.setMessageCopied(message.id);
|
||||
}
|
||||
|
||||
function handleLike(message: Message) {
|
||||
const currentLiked = message.feedback?.liked;
|
||||
chatStore.setMessageFeedback(message.id, currentLiked ? null : "like");
|
||||
}
|
||||
|
||||
function handleDislike(message: Message) {
|
||||
const currentDisliked = message.feedback?.disliked;
|
||||
chatStore.setMessageFeedback(message.id, currentDisliked ? null : "dislike");
|
||||
}
|
||||
|
||||
function handlePreviewImage(image: Attachment, index: number) {
|
||||
emit("preview-image", image, index);
|
||||
}
|
||||
|
||||
function handlePlayVideo(video: VideoInfo) {
|
||||
emit("play-video", video);
|
||||
}
|
||||
|
||||
function handleDownloadFile(file: Attachment) {
|
||||
emit("download-file", file);
|
||||
}
|
||||
|
||||
// 监听消息变化
|
||||
watch(
|
||||
() => props.messages.length,
|
||||
(newLen, oldLen) => {
|
||||
if (newLen > oldLen) {
|
||||
if (isAutoScrolling.value) {
|
||||
nextTick(() => {
|
||||
scrollToBottom(false);
|
||||
});
|
||||
} else {
|
||||
newMessageCount.value++;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听最后一条消息的内容变化
|
||||
watch(
|
||||
() => props.messages[props.messages.length - 1]?.content.text,
|
||||
() => {
|
||||
if (isAutoScrolling.value) {
|
||||
nextTick(() => {
|
||||
const container = containerRef.value;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听 isTyping 变化
|
||||
watch(
|
||||
() => props.isTyping,
|
||||
(typing) => {
|
||||
if (typing && isAutoScrolling.value) {
|
||||
nextTick(() => {
|
||||
scrollToBottom(true);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-list {
|
||||
height: 500px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.messages-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.typing-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 16px;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
}
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #9ca3af;
|
||||
border-radius: 50%;
|
||||
animation: typingBounce 1.4s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typing-text {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.scroll-bottom-btn {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
color: #374151;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
color: #e5e7eb;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.new-count {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: #ef4444;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息过渡动画
|
||||
.message-enter-active,
|
||||
.message-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.message-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.message-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
// 淡入淡出动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(10px);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.7);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div ref="boxRef" style="flex: 1; position: relative">
|
||||
<div ref="containerRef" class="message-list" @scroll="handleScroll">
|
||||
<!-- 欢迎界面 -->
|
||||
<WelcomeScreen
|
||||
v-if="messages.length === 0"
|
||||
@select="$emit('select-suggestion', $event)"
|
||||
/>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<template v-else>
|
||||
<div class="messages-wrapper">
|
||||
<TransitionGroup name="message">
|
||||
<MessageBubble
|
||||
v-for="(message, index) in messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:show-timestamp="showTimestamp"
|
||||
:compact="compact"
|
||||
:is-New="index === messages.length - 1"
|
||||
@retry="$emit('retry', message.id)"
|
||||
@regenerate="$emit('regenerate', message.id)"
|
||||
@copy="handleCopy(message)"
|
||||
@like="handleLike(message)"
|
||||
@dislike="handleDislike(message)"
|
||||
@select-suggestion="$emit('select-suggestion', $event.text)"
|
||||
@preview-image="handlePreviewImage"
|
||||
@play-video="handlePlayVideo"
|
||||
@download-file="handleDownloadFile"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- 正在输入指示器 -->
|
||||
<div v-if="isTyping" class="typing-indicator">
|
||||
<div class="typing-avatar">
|
||||
<Bot :size="20" />
|
||||
</div>
|
||||
<div class="typing-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<span class="typing-text">AI 正在思考...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 回到底部按钮 -->
|
||||
<Transition name="fade">
|
||||
<button
|
||||
v-if="showScrollButton"
|
||||
class="scroll-bottom-btn"
|
||||
@click="handleScrollToBottom"
|
||||
>
|
||||
<ChevronDown :size="20" />
|
||||
<span v-if="newMessageCount > 0" class="new-count">
|
||||
{{ newMessageCount }}
|
||||
</span>
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onMounted } from "vue";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import MessageBubble from "@/components/message/MessageBubble.vue";
|
||||
import WelcomeScreen from "./WelcomeScreen.vue";
|
||||
import { Bot, ChevronDown } from "@/components/icons";
|
||||
import type { Message, Attachment, VideoInfo } from "@/types/chat";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
messages: Message[];
|
||||
showTimestamp?: boolean;
|
||||
compact?: boolean;
|
||||
isTyping?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showTimestamp: true,
|
||||
compact: false,
|
||||
isTyping: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: [messageId: string];
|
||||
regenerate: [messageId: string];
|
||||
"select-suggestion": [text: string];
|
||||
"preview-image": [image: Attachment, index: number];
|
||||
"play-video": [video: VideoInfo];
|
||||
"download-file": [file: Attachment];
|
||||
}>();
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// 响应式状态
|
||||
const boxRef: any = ref<HTMLElement | null>(null);
|
||||
const containerRef: any = ref<HTMLElement | null>(null);
|
||||
const showScrollButton = ref(false);
|
||||
const newMessageCount = ref(0);
|
||||
const isAutoScrolling = ref(true);
|
||||
const lastScrollTop = ref(0);
|
||||
|
||||
onMounted(() => {
|
||||
containerRef.value.style.height = boxRef.value?.clientHeight + "px";
|
||||
});
|
||||
|
||||
// 滚动处理
|
||||
function handleScroll() {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
|
||||
|
||||
if (scrollTop < lastScrollTop.value && !isAtBottom) {
|
||||
isAutoScrolling.value = false;
|
||||
showScrollButton.value = true;
|
||||
}
|
||||
|
||||
if (isAtBottom) {
|
||||
isAutoScrolling.value = true;
|
||||
showScrollButton.value = false;
|
||||
newMessageCount.value = 0;
|
||||
}
|
||||
|
||||
lastScrollTop.value = scrollTop;
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
function scrollToBottom(smooth = true) {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: smooth ? "smooth" : "auto",
|
||||
});
|
||||
|
||||
isAutoScrolling.value = true;
|
||||
showScrollButton.value = false;
|
||||
newMessageCount.value = 0;
|
||||
}
|
||||
|
||||
// 按钮点击处理
|
||||
function handleScrollToBottom() {
|
||||
scrollToBottom(true);
|
||||
}
|
||||
|
||||
// 消息操作
|
||||
function handleCopy(message: Message) {
|
||||
chatStore.setMessageCopied(message.id);
|
||||
}
|
||||
|
||||
function handleLike(message: Message) {
|
||||
const currentLiked = message.feedback?.liked;
|
||||
chatStore.setMessageFeedback(message.id, currentLiked ? null : "like");
|
||||
}
|
||||
|
||||
function handleDislike(message: Message) {
|
||||
const currentDisliked = message.feedback?.disliked;
|
||||
chatStore.setMessageFeedback(message.id, currentDisliked ? null : "dislike");
|
||||
}
|
||||
|
||||
function handlePreviewImage(image: Attachment, index: number) {
|
||||
emit("preview-image", image, index);
|
||||
}
|
||||
|
||||
function handlePlayVideo(video: VideoInfo) {
|
||||
emit("play-video", video);
|
||||
}
|
||||
|
||||
function handleDownloadFile(file: Attachment) {
|
||||
emit("download-file", file);
|
||||
}
|
||||
|
||||
// 监听消息变化
|
||||
watch(
|
||||
() => props.messages.length,
|
||||
(newLen, oldLen) => {
|
||||
if (newLen > oldLen) {
|
||||
if (isAutoScrolling.value) {
|
||||
nextTick(() => {
|
||||
scrollToBottom(false);
|
||||
});
|
||||
} else {
|
||||
newMessageCount.value++;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听最后一条消息的内容变化
|
||||
watch(
|
||||
() => props.messages[props.messages.length - 1]?.content.text,
|
||||
() => {
|
||||
if (isAutoScrolling.value) {
|
||||
nextTick(() => {
|
||||
const container = containerRef.value;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听 isTyping 变化
|
||||
watch(
|
||||
() => props.isTyping,
|
||||
(typing) => {
|
||||
if (typing && isAutoScrolling.value) {
|
||||
nextTick(() => {
|
||||
scrollToBottom(true);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-list {
|
||||
height: 500px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.messages-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.typing-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 16px;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
}
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #9ca3af;
|
||||
border-radius: 50%;
|
||||
animation: typingBounce 1.4s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typing-text {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.scroll-bottom-btn {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
color: #374151;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
color: #e5e7eb;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.new-count {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: #ef4444;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息过渡动画
|
||||
.message-enter-active,
|
||||
.message-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.message-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.message-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
// 淡入淡出动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(10px);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.7);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,374 +1,378 @@
|
|||
<template>
|
||||
<div class="welcome-screen">
|
||||
<!-- Logo 和标题 -->
|
||||
<div class="welcome-header">
|
||||
<div class="logo-wrapper">
|
||||
<div class="logo-icon">
|
||||
<Bot :size="40" />
|
||||
</div>
|
||||
<div class="logo-glow"></div>
|
||||
</div>
|
||||
<h1 class="title">AI 智能助手</h1>
|
||||
<p class="subtitle">我可以帮助你解答问题、生成内容、分析数据等</p>
|
||||
</div>
|
||||
|
||||
<!-- 功能卡片 -->
|
||||
<div class="feature-cards">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature.title"
|
||||
class="feature-card"
|
||||
>
|
||||
<div class="feature-icon" :style="{ background: feature.gradient }">
|
||||
<component :is="feature.icon" :size="22" />
|
||||
</div>
|
||||
<h3>{{ feature.title }}</h3>
|
||||
<p>{{ feature.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速开始建议 -->
|
||||
<div class="quick-start">
|
||||
<h4>试试这些问题</h4>
|
||||
<div class="suggestions-grid">
|
||||
<button
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion.text"
|
||||
class="suggestion-card"
|
||||
@click="$emit('select', suggestion.text)"
|
||||
>
|
||||
<component :is="suggestion.icon" :size="18" class="suggestion-icon" />
|
||||
<span>{{ suggestion.text }}</span>
|
||||
<ChevronRight :size="16" class="arrow-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<div class="welcome-footer">
|
||||
<div class="tip">
|
||||
<Keyboard :size="14" />
|
||||
<span>按 <kbd>Ctrl</kbd> + <kbd>/</kbd> 聚焦输入框</span>
|
||||
</div>
|
||||
<div class="tip">
|
||||
<Zap :size="14" />
|
||||
<span>支持 Markdown、代码高亮、LaTeX 公式</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Bot,
|
||||
MessageSquare,
|
||||
Code,
|
||||
Image,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Keyboard,
|
||||
Zap,
|
||||
Globe,
|
||||
Lightbulb,
|
||||
PenTool,
|
||||
} from '@/components/icons'
|
||||
|
||||
defineEmits<{
|
||||
select: [text: string]
|
||||
}>()
|
||||
|
||||
const features = computed(() => [
|
||||
{
|
||||
icon: MessageSquare,
|
||||
title: '智能对话',
|
||||
description: '自然流畅的对话体验,理解上下文',
|
||||
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
||||
},
|
||||
{
|
||||
icon: Code,
|
||||
title: '代码助手',
|
||||
description: '编写、解释、优化各种编程语言代码',
|
||||
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||
},
|
||||
{
|
||||
icon: Image,
|
||||
title: '图像理解',
|
||||
description: '分析图片内容,提取关键信息',
|
||||
gradient: 'linear-gradient(135deg, #ec4899 0%, #d946ef 100%)',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: '文档处理',
|
||||
description: '阅读、总结、翻译各类文档',
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)',
|
||||
},
|
||||
])
|
||||
|
||||
const suggestions = computed(() => [
|
||||
{
|
||||
icon: Lightbulb,
|
||||
text: '帮我写一个 Vue 3 组件示例',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
text: '解释一下什么是机器学习',
|
||||
},
|
||||
{
|
||||
icon: PenTool,
|
||||
text: '帮我写一封商务邮件',
|
||||
},
|
||||
{
|
||||
icon: Code,
|
||||
text: '如何优化 React 应用性能',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.welcome-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
padding: 40px 24px;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
.welcome-header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
border-radius: 24px;
|
||||
color: white;
|
||||
box-shadow: 0 20px 40px -12px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.logo-glow {
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.2) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
|
||||
.dark & {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
margin-bottom: 48px;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
border-color: #2d2d3d;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.dark & {
|
||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
color: white;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
|
||||
.dark & {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-start {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin-bottom: 40px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
|
||||
.dark & {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
border-color: #2d2d3d;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
|
||||
.arrow-icon {
|
||||
transform: translateX(4px);
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
flex-shrink: 0;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
flex-shrink: 0;
|
||||
color: #9ca3af;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
|
||||
kbd {
|
||||
padding: 2px 8px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="welcome-screen">
|
||||
<!-- Logo 和标题 -->
|
||||
<div class="welcome-header">
|
||||
<div class="logo-wrapper">
|
||||
<div class="logo-icon">
|
||||
<Bot :size="40" />
|
||||
</div>
|
||||
<div class="logo-glow"></div>
|
||||
</div>
|
||||
<h1 class="title">AI 智能助手</h1>
|
||||
<p class="subtitle">我可以帮助你解答问题、生成内容、分析数据等</p>
|
||||
</div>
|
||||
|
||||
<!-- 功能卡片 -->
|
||||
<div class="feature-cards">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature.title"
|
||||
class="feature-card"
|
||||
>
|
||||
<div class="feature-icon" :style="{ background: feature.gradient }">
|
||||
<component :is="feature.icon" :size="22" />
|
||||
</div>
|
||||
<h3>{{ feature.title }}</h3>
|
||||
<p>{{ feature.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速开始建议 -->
|
||||
<div class="quick-start">
|
||||
<h4>试试这些问题</h4>
|
||||
<div class="suggestions-grid">
|
||||
<button
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion.text"
|
||||
class="suggestion-card"
|
||||
@click="$emit('select', suggestion.text)"
|
||||
>
|
||||
<component :is="suggestion.icon" :size="18" class="suggestion-icon" />
|
||||
<span>{{ suggestion.text }}</span>
|
||||
<ChevronRight :size="16" class="arrow-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<div class="welcome-footer">
|
||||
<div class="tip">
|
||||
<Keyboard :size="14" />
|
||||
<span>按 <kbd>Ctrl</kbd> + <kbd>/</kbd> 聚焦输入框</span>
|
||||
</div>
|
||||
<div class="tip">
|
||||
<Zap :size="14" />
|
||||
<span>支持 Markdown、代码高亮、LaTeX 公式</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
Bot,
|
||||
MessageSquare,
|
||||
Code,
|
||||
Image,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Keyboard,
|
||||
Zap,
|
||||
Globe,
|
||||
Lightbulb,
|
||||
PenTool,
|
||||
} from "@/components/icons";
|
||||
|
||||
defineEmits<{
|
||||
select: [text: string];
|
||||
}>();
|
||||
|
||||
const features = computed(() => [
|
||||
{
|
||||
icon: MessageSquare,
|
||||
title: "智能对话",
|
||||
description: "自然流畅的对话体验,理解上下文",
|
||||
gradient: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)",
|
||||
},
|
||||
{
|
||||
icon: Code,
|
||||
title: "代码助手",
|
||||
description: "编写、解释、优化各种编程语言代码",
|
||||
gradient: "linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)",
|
||||
},
|
||||
{
|
||||
icon: Image,
|
||||
title: "图像理解",
|
||||
description: "分析图片内容,提取关键信息",
|
||||
gradient: "linear-gradient(135deg, #ec4899 0%, #d946ef 100%)",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: "文档处理",
|
||||
description: "阅读、总结、翻译各类文档",
|
||||
gradient: "linear-gradient(135deg, #f59e0b 0%, #f97316 100%)",
|
||||
},
|
||||
]);
|
||||
|
||||
const suggestions = computed(() => [
|
||||
{
|
||||
icon: Lightbulb,
|
||||
text: "帮我写一个 Vue 3 组件示例",
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
text: "解释一下什么是机器学习",
|
||||
},
|
||||
{
|
||||
icon: PenTool,
|
||||
text: "帮我写一封商务邮件",
|
||||
},
|
||||
{
|
||||
icon: Code,
|
||||
text: "如何优化 React 应用性能",
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.welcome-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
padding: 40px 24px;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
.welcome-header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
border-radius: 24px;
|
||||
color: white;
|
||||
box-shadow: 0 20px 40px -12px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.logo-glow {
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(59, 130, 246, 0.2) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
|
||||
.dark & {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
margin-bottom: 48px;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
border-color: #2d2d3d;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.dark & {
|
||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
color: white;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
|
||||
.dark & {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-start {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin-bottom: 40px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
|
||||
.dark & {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
border-color: #2d2d3d;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
|
||||
.arrow-icon {
|
||||
transform: translateX(4px);
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
flex-shrink: 0;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
flex-shrink: 0;
|
||||
color: #9ca3af;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
|
||||
kbd {
|
||||
padding: 2px 8px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,133 +1,133 @@
|
|||
export {
|
||||
// 通用图标
|
||||
Menu,
|
||||
X,
|
||||
Check,
|
||||
Plus,
|
||||
Minus,
|
||||
Search,
|
||||
Settings,
|
||||
Info,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
MoreVertical,
|
||||
|
||||
// 主题图标
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
|
||||
// 用户/角色
|
||||
User,
|
||||
Bot,
|
||||
Users,
|
||||
|
||||
// 消息/对话
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
MessagesSquare,
|
||||
Send,
|
||||
SendHorizontal,
|
||||
|
||||
// 操作图标
|
||||
Copy,
|
||||
Clipboard,
|
||||
ClipboardCheck,
|
||||
Edit3,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Download,
|
||||
Upload,
|
||||
ExternalLink,
|
||||
Link,
|
||||
Share2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Brain,
|
||||
|
||||
// 反馈图标
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Heart,
|
||||
Star,
|
||||
|
||||
// 导航图标
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
|
||||
// 状态/标记
|
||||
Pin,
|
||||
PinOff,
|
||||
Archive,
|
||||
Bookmark,
|
||||
Flag,
|
||||
Clock,
|
||||
Calendar,
|
||||
History,
|
||||
|
||||
// 文件夹/文件
|
||||
Folder,
|
||||
FolderOpen,
|
||||
File,
|
||||
FileText,
|
||||
FileCode,
|
||||
FileImage,
|
||||
Paperclip,
|
||||
|
||||
// 媒体图标
|
||||
Image,
|
||||
Video,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
StopCircle,
|
||||
Mic,
|
||||
MicOff,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Camera,
|
||||
|
||||
// 功能图标
|
||||
Sparkles,
|
||||
Wand2,
|
||||
Zap,
|
||||
Globe,
|
||||
Wifi,
|
||||
Code,
|
||||
Terminal,
|
||||
Keyboard,
|
||||
Command,
|
||||
Hash,
|
||||
AtSign,
|
||||
Lightbulb,
|
||||
PenTool,
|
||||
Palette,
|
||||
|
||||
// 布局图标
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Expand,
|
||||
Shrink,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
LayoutGrid,
|
||||
List,
|
||||
|
||||
// 其他
|
||||
HelpCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
Unlock,
|
||||
Shield,
|
||||
Bell,
|
||||
BellOff,
|
||||
} from "lucide-vue-next";
|
||||
export {
|
||||
// 通用图标
|
||||
Menu,
|
||||
X,
|
||||
Check,
|
||||
Plus,
|
||||
Minus,
|
||||
Search,
|
||||
Settings,
|
||||
Info,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
MoreVertical,
|
||||
|
||||
// 主题图标
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
|
||||
// 用户/角色
|
||||
User,
|
||||
Bot,
|
||||
Users,
|
||||
|
||||
// 消息/对话
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
MessagesSquare,
|
||||
Send,
|
||||
SendHorizontal,
|
||||
|
||||
// 操作图标
|
||||
Copy,
|
||||
Clipboard,
|
||||
ClipboardCheck,
|
||||
Edit3,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Download,
|
||||
Upload,
|
||||
ExternalLink,
|
||||
Link,
|
||||
Share2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Brain,
|
||||
|
||||
// 反馈图标
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Heart,
|
||||
Star,
|
||||
|
||||
// 导航图标
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
|
||||
// 状态/标记
|
||||
Pin,
|
||||
PinOff,
|
||||
Archive,
|
||||
Bookmark,
|
||||
Flag,
|
||||
Clock,
|
||||
Calendar,
|
||||
History,
|
||||
|
||||
// 文件夹/文件
|
||||
Folder,
|
||||
FolderOpen,
|
||||
File,
|
||||
FileText,
|
||||
FileCode,
|
||||
FileImage,
|
||||
Paperclip,
|
||||
|
||||
// 媒体图标
|
||||
Image,
|
||||
Video,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
StopCircle,
|
||||
Mic,
|
||||
MicOff,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Camera,
|
||||
|
||||
// 功能图标
|
||||
Sparkles,
|
||||
Wand2,
|
||||
Zap,
|
||||
Globe,
|
||||
Wifi,
|
||||
Code,
|
||||
Terminal,
|
||||
Keyboard,
|
||||
Command,
|
||||
Hash,
|
||||
AtSign,
|
||||
Lightbulb,
|
||||
PenTool,
|
||||
Palette,
|
||||
|
||||
// 布局图标
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Expand,
|
||||
Shrink,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
LayoutGrid,
|
||||
List,
|
||||
|
||||
// 其他
|
||||
HelpCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
Unlock,
|
||||
Shield,
|
||||
Bell,
|
||||
BellOff,
|
||||
} from "lucide-vue-next";
|
||||
|
|
|
|||
|
|
@ -1,266 +1,269 @@
|
|||
<template>
|
||||
<div class="attachment-preview">
|
||||
<TransitionGroup name="attachment">
|
||||
<div
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.id"
|
||||
class="attachment-item"
|
||||
:class="attachment.type"
|
||||
>
|
||||
<!-- 图片预览 -->
|
||||
<template v-if="attachment.type === 'image'">
|
||||
<img :src="attachment.url" :alt="attachment.name" class="preview-image" />
|
||||
</template>
|
||||
|
||||
<!-- 视频预览 -->
|
||||
<template v-else-if="attachment.type === 'video'">
|
||||
<div class="preview-video">
|
||||
<img
|
||||
v-if="attachment.thumbnail"
|
||||
:src="attachment.thumbnail"
|
||||
:alt="attachment.name"
|
||||
/>
|
||||
<div v-else class="video-placeholder">
|
||||
<Video :size="24" />
|
||||
</div>
|
||||
<div class="video-badge">
|
||||
<Play :size="12" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件预览 -->
|
||||
<template v-else>
|
||||
<div class="preview-file">
|
||||
<span class="file-emoji">{{ getFileEmoji(attachment.mimeType) }}</span>
|
||||
<div class="file-details">
|
||||
<span class="file-name">{{ truncateName(attachment.name) }}</span>
|
||||
<span class="file-size">{{ formatSize(attachment.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<button
|
||||
class="remove-btn"
|
||||
@click="$emit('remove', attachment.id)"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<div v-if="attachment.uploading" class="upload-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: `${attachment.progress || 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { X, Video, Play } from '@/components/icons'
|
||||
import { formatFileSize, getFileIcon, truncateText } from '@/utils/helpers'
|
||||
|
||||
interface AttachmentWithProgress {
|
||||
id: string
|
||||
name: string
|
||||
type: 'image' | 'file' | 'video'
|
||||
url: string
|
||||
size?: number
|
||||
mimeType?: string
|
||||
thumbnail?: string
|
||||
uploading?: boolean
|
||||
progress?: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
attachments: AttachmentWithProgress[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
remove: [id: string]
|
||||
}>()
|
||||
|
||||
function getFileEmoji(mimeType?: string) {
|
||||
return getFileIcon(mimeType || '')
|
||||
}
|
||||
|
||||
function formatSize(size?: number) {
|
||||
return size ? formatFileSize(size) : ''
|
||||
}
|
||||
|
||||
function truncateName(name: string) {
|
||||
return truncateText(name, 20)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.attachment-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.dark & {
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
&.image,
|
||||
&.video {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
&.file {
|
||||
padding: 10px 40px 10px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
|
||||
.dark & {
|
||||
background: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
.video-badge {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-emoji {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.attachment-item:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.attachment-enter-active,
|
||||
.attachment-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.attachment-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.attachment-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="attachment-preview">
|
||||
<TransitionGroup name="attachment">
|
||||
<div
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.id"
|
||||
class="attachment-item"
|
||||
:class="attachment.type"
|
||||
>
|
||||
<!-- 图片预览 -->
|
||||
<template v-if="attachment.type === 'image'">
|
||||
<img
|
||||
:src="attachment.url"
|
||||
:alt="attachment.name"
|
||||
class="preview-image"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 视频预览 -->
|
||||
<template v-else-if="attachment.type === 'video'">
|
||||
<div class="preview-video">
|
||||
<img
|
||||
v-if="attachment.thumbnail"
|
||||
:src="attachment.thumbnail"
|
||||
:alt="attachment.name"
|
||||
/>
|
||||
<div v-else class="video-placeholder">
|
||||
<Video :size="24" />
|
||||
</div>
|
||||
<div class="video-badge">
|
||||
<Play :size="12" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件预览 -->
|
||||
<template v-else>
|
||||
<div class="preview-file">
|
||||
<span class="file-emoji">{{
|
||||
getFileEmoji(attachment.mimeType)
|
||||
}}</span>
|
||||
<div class="file-details">
|
||||
<span class="file-name">{{ truncateName(attachment.name) }}</span>
|
||||
<span class="file-size">{{ formatSize(attachment.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<button class="remove-btn" @click="$emit('remove', attachment.id)">
|
||||
<X :size="14" />
|
||||
</button>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<div v-if="attachment.uploading" class="upload-progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: `${attachment.progress || 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { X, Video, Play } from "@/components/icons";
|
||||
import { formatFileSize, getFileIcon, truncateText } from "@/utils/helpers";
|
||||
|
||||
interface AttachmentWithProgress {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "image" | "file" | "video";
|
||||
url: string;
|
||||
size?: number;
|
||||
mimeType?: string;
|
||||
thumbnail?: string;
|
||||
uploading?: boolean;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
attachments: AttachmentWithProgress[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
remove: [id: string];
|
||||
}>();
|
||||
|
||||
function getFileEmoji(mimeType?: string) {
|
||||
return getFileIcon(mimeType || "");
|
||||
}
|
||||
|
||||
function formatSize(size?: number) {
|
||||
return size ? formatFileSize(size) : "";
|
||||
}
|
||||
|
||||
function truncateName(name: string) {
|
||||
return truncateText(name, 20);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.attachment-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.dark & {
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
&.image,
|
||||
&.video {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
&.file {
|
||||
padding: 10px 40px 10px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
|
||||
.dark & {
|
||||
background: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
.video-badge {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-emoji {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.attachment-item:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.attachment-enter-active,
|
||||
.attachment-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.attachment-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.attachment-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,212 +1,232 @@
|
|||
<template>
|
||||
<div class="code-block" :class="{ 'is-expanded': isExpanded }">
|
||||
<!-- 代码块头部 -->
|
||||
<div class="code-header">
|
||||
<div class="code-language">
|
||||
<Code :size="14" />
|
||||
<span>{{ language || 'code' }}</span>
|
||||
</div>
|
||||
<div class="code-actions">
|
||||
<button
|
||||
v-if="canExpand"
|
||||
class="action-btn"
|
||||
:title="isExpanded ? '收起' : '展开'"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<Maximize2 v-if="!isExpanded" :size="14" />
|
||||
<Minimize2 v-else :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
:class="{ copied: isCopied }"
|
||||
title="复制代码"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<Check v-if="isCopied" :size="14" />
|
||||
<Copy v-else :size="14" />
|
||||
<span v-if="isCopied">已复制</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码内容 -->
|
||||
<div class="code-content">
|
||||
<pre><code :class="`language-${language}`">{{ code }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- 行号(可选) -->
|
||||
<div v-if="showLineNumbers" class="line-numbers">
|
||||
<span v-for="n in lineCount" :key="n">{{ n }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Code, Copy, Check, Maximize2, Minimize2 } from '@/components/icons'
|
||||
import { copyToClipboard } from '@/utils/helpers'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
code: string
|
||||
language?: string
|
||||
showLineNumbers?: boolean
|
||||
maxHeight?: number
|
||||
}>(), {
|
||||
language: 'plaintext',
|
||||
showLineNumbers: true,
|
||||
maxHeight: 400,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: []
|
||||
}>()
|
||||
|
||||
const isCopied = ref(false)
|
||||
const isExpanded = ref(false)
|
||||
|
||||
const lineCount = computed(() => {
|
||||
return props.code.split('\n').length
|
||||
})
|
||||
|
||||
const canExpand = computed(() => {
|
||||
return lineCount.value > 15
|
||||
})
|
||||
|
||||
async function handleCopy() {
|
||||
const success = await copyToClipboard(props.code)
|
||||
if (success) {
|
||||
isCopied.value = true
|
||||
emit('copy')
|
||||
setTimeout(() => {
|
||||
isCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.code-block {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #2d2d3d;
|
||||
|
||||
&.is-expanded {
|
||||
.code-content {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: #181825;
|
||||
border-bottom: 1px solid #2d2d3d;
|
||||
}
|
||||
|
||||
.code-language {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #a6adc8;
|
||||
|
||||
svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.code-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #a6adc8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
&.copied {
|
||||
background: rgba(166, 227, 161, 0.2);
|
||||
color: #a6e3a1;
|
||||
}
|
||||
}
|
||||
|
||||
.code-content {
|
||||
max-height: v-bind('maxHeight + "px"');
|
||||
overflow: auto;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #cdd6f4;
|
||||
tab-size: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 49px;
|
||||
bottom: 0;
|
||||
width: 50px;
|
||||
padding: 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
padding-right: 12px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-right: 1px solid #2d2d3d;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
span {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #585b70;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.code-content) {
|
||||
.keyword { color: #cba6f7; }
|
||||
.string { color: #a6e3a1; }
|
||||
.number { color: #fab387; }
|
||||
.comment { color: #6c7086; font-style: italic; }
|
||||
.function { color: #89b4fa; }
|
||||
.operator { color: #89dceb; }
|
||||
.punctuation { color: #9399b2; }
|
||||
.class-name { color: #f9e2af; }
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="code-block" :class="{ 'is-expanded': isExpanded }">
|
||||
<!-- 代码块头部 -->
|
||||
<div class="code-header">
|
||||
<div class="code-language">
|
||||
<Code :size="14" />
|
||||
<span>{{ language || "code" }}</span>
|
||||
</div>
|
||||
<div class="code-actions">
|
||||
<button
|
||||
v-if="canExpand"
|
||||
class="action-btn"
|
||||
:title="isExpanded ? '收起' : '展开'"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<Maximize2 v-if="!isExpanded" :size="14" />
|
||||
<Minimize2 v-else :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
:class="{ copied: isCopied }"
|
||||
title="复制代码"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<Check v-if="isCopied" :size="14" />
|
||||
<Copy v-else :size="14" />
|
||||
<span v-if="isCopied">已复制</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码内容 -->
|
||||
<div class="code-content">
|
||||
<pre><code :class="`language-${language}`">{{ code }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- 行号(可选) -->
|
||||
<div v-if="showLineNumbers" class="line-numbers">
|
||||
<span v-for="n in lineCount" :key="n">{{ n }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { Code, Copy, Check, Maximize2, Minimize2 } from "@/components/icons";
|
||||
import { copyToClipboard } from "@/utils/helpers";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
code: string;
|
||||
language?: string;
|
||||
showLineNumbers?: boolean;
|
||||
maxHeight?: number;
|
||||
}>(),
|
||||
{
|
||||
language: "plaintext",
|
||||
showLineNumbers: true,
|
||||
maxHeight: 400,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: [];
|
||||
}>();
|
||||
|
||||
const isCopied = ref(false);
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const lineCount = computed(() => {
|
||||
return props.code.split("\n").length;
|
||||
});
|
||||
|
||||
const canExpand = computed(() => {
|
||||
return lineCount.value > 15;
|
||||
});
|
||||
|
||||
async function handleCopy() {
|
||||
const success = await copyToClipboard(props.code);
|
||||
if (success) {
|
||||
isCopied.value = true;
|
||||
emit("copy");
|
||||
setTimeout(() => {
|
||||
isCopied.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.code-block {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #2d2d3d;
|
||||
|
||||
&.is-expanded {
|
||||
.code-content {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: #181825;
|
||||
border-bottom: 1px solid #2d2d3d;
|
||||
}
|
||||
|
||||
.code-language {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #a6adc8;
|
||||
|
||||
svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.code-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #a6adc8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
&.copied {
|
||||
background: rgba(166, 227, 161, 0.2);
|
||||
color: #a6e3a1;
|
||||
}
|
||||
}
|
||||
|
||||
.code-content {
|
||||
max-height: v-bind('maxHeight + "px"');
|
||||
overflow: auto;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
font-family: "JetBrains Mono", "Fira Code", "Monaco", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #cdd6f4;
|
||||
tab-size: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 49px;
|
||||
bottom: 0;
|
||||
width: 50px;
|
||||
padding: 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
padding-right: 12px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-right: 1px solid #2d2d3d;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
span {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #585b70;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.code-content) {
|
||||
.keyword {
|
||||
color: #cba6f7;
|
||||
}
|
||||
.string {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
.number {
|
||||
color: #fab387;
|
||||
}
|
||||
.comment {
|
||||
color: #6c7086;
|
||||
font-style: italic;
|
||||
}
|
||||
.function {
|
||||
color: #89b4fa;
|
||||
}
|
||||
.operator {
|
||||
color: #89dceb;
|
||||
}
|
||||
.punctuation {
|
||||
color: #9399b2;
|
||||
}
|
||||
.class-name {
|
||||
color: #f9e2af;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,320 +1,320 @@
|
|||
<template>
|
||||
<div class="message-actions" :class="{ visible: alwaysVisible || isHovered }">
|
||||
<!-- 复制按钮 -->
|
||||
<button
|
||||
v-if="!isBreak"
|
||||
class="action-btn"
|
||||
:class="{ success: copied }"
|
||||
title="复制内容"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<Check v-if="copied" :size="15" />
|
||||
<Copy v-else :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- 点赞按钮 -->
|
||||
<button
|
||||
v-if="isNew && !isBreak"
|
||||
class="action-btn"
|
||||
:class="{ active: feedback?.liked }"
|
||||
title="有帮助"
|
||||
@click="handleLike"
|
||||
>
|
||||
<ThumbsUp :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- 点踩按钮 -->
|
||||
<button
|
||||
v-if="isNew && !isBreak"
|
||||
class="action-btn"
|
||||
:class="{ active: feedback?.disliked }"
|
||||
title="没帮助"
|
||||
@click="handleDislike"
|
||||
>
|
||||
<ThumbsDown :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- 重新生成(仅AI消息) -->
|
||||
<button
|
||||
v-if="(showRegenerate && isNew) || isBreak"
|
||||
class="action-btn"
|
||||
title="重新生成"
|
||||
@click="handleRegenerate"
|
||||
>
|
||||
<RefreshCw :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- 更多操作 -->
|
||||
<div class="more-menu" v-if="showMore">
|
||||
<button class="action-btn" title="更多" @click="toggleMoreMenu">
|
||||
<MoreHorizontal :size="15" />
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div v-if="showMoreMenu" class="dropdown-menu">
|
||||
<button
|
||||
v-if="isNew && !isBreak"
|
||||
class="dropdown-item"
|
||||
@click="handleEdit"
|
||||
>
|
||||
<Edit3 :size="14" />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button v-if="!isBreak" class="dropdown-item" @click="handleShare">
|
||||
<ExternalLink :size="14" />
|
||||
<span>分享</span>
|
||||
</button>
|
||||
<button class="dropdown-item danger" @click="handleDelete">
|
||||
<Trash2 :size="14" />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
RefreshCw,
|
||||
MoreHorizontal,
|
||||
Edit3,
|
||||
ExternalLink,
|
||||
Trash2,
|
||||
} from "@/components/icons";
|
||||
import { copyToClipboard } from "@/utils/helpers";
|
||||
import type { MessageFeedback } from "@/types/chat";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: string;
|
||||
feedback?: MessageFeedback;
|
||||
showRegenerate?: boolean;
|
||||
showMore?: boolean;
|
||||
alwaysVisible?: boolean;
|
||||
isHovered?: boolean;
|
||||
isNew?: boolean;
|
||||
isBreak?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showRegenerate: false,
|
||||
showMore: true,
|
||||
alwaysVisible: false,
|
||||
isHovered: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: [];
|
||||
like: [];
|
||||
dislike: [];
|
||||
regenerate: [];
|
||||
edit: [];
|
||||
share: [];
|
||||
delete: [];
|
||||
}>();
|
||||
|
||||
const copied = ref(false);
|
||||
const showMoreMenu = ref(false);
|
||||
|
||||
async function handleCopy() {
|
||||
const success = await copyToClipboard(props.content);
|
||||
if (success) {
|
||||
copied.value = true;
|
||||
emit("copy");
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLike() {
|
||||
emit("like");
|
||||
}
|
||||
|
||||
function handleDislike() {
|
||||
emit("dislike");
|
||||
}
|
||||
|
||||
function handleRegenerate() {
|
||||
emit("regenerate");
|
||||
}
|
||||
|
||||
function toggleMoreMenu() {
|
||||
showMoreMenu.value = !showMoreMenu.value;
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
showMoreMenu.value = false;
|
||||
emit("edit");
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
showMoreMenu.value = false;
|
||||
emit("share");
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
showMoreMenu.value = false;
|
||||
emit("delete");
|
||||
}
|
||||
|
||||
// 点击外部关闭菜单
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(".more-menu")) {
|
||||
showMoreMenu.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 挂载时添加事件监听
|
||||
if (typeof window !== "undefined") {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #3b82f6;
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: #10b981;
|
||||
|
||||
&:hover {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
min-width: 140px;
|
||||
padding: 6px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
.dark & {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ef4444;
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉动画
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.95);
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="message-actions" :class="{ visible: alwaysVisible || isHovered }">
|
||||
<!-- 复制按钮 -->
|
||||
<button
|
||||
v-if="!isBreak"
|
||||
class="action-btn"
|
||||
:class="{ success: copied }"
|
||||
title="复制内容"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<Check v-if="copied" :size="15" />
|
||||
<Copy v-else :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- 点赞按钮 -->
|
||||
<button
|
||||
v-if="isNew && !isBreak"
|
||||
class="action-btn"
|
||||
:class="{ active: feedback?.liked }"
|
||||
title="有帮助"
|
||||
@click="handleLike"
|
||||
>
|
||||
<ThumbsUp :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- 点踩按钮 -->
|
||||
<button
|
||||
v-if="isNew && !isBreak"
|
||||
class="action-btn"
|
||||
:class="{ active: feedback?.disliked }"
|
||||
title="没帮助"
|
||||
@click="handleDislike"
|
||||
>
|
||||
<ThumbsDown :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- 重新生成(仅AI消息) -->
|
||||
<button
|
||||
v-if="(showRegenerate && isNew) || isBreak"
|
||||
class="action-btn"
|
||||
title="重新生成"
|
||||
@click="handleRegenerate"
|
||||
>
|
||||
<RefreshCw :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- 更多操作 -->
|
||||
<div class="more-menu" v-if="showMore">
|
||||
<button class="action-btn" title="更多" @click="toggleMoreMenu">
|
||||
<MoreHorizontal :size="15" />
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div v-if="showMoreMenu" class="dropdown-menu">
|
||||
<button
|
||||
v-if="isNew && !isBreak"
|
||||
class="dropdown-item"
|
||||
@click="handleEdit"
|
||||
>
|
||||
<Edit3 :size="14" />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button v-if="!isBreak" class="dropdown-item" @click="handleShare">
|
||||
<ExternalLink :size="14" />
|
||||
<span>分享</span>
|
||||
</button>
|
||||
<button class="dropdown-item danger" @click="handleDelete">
|
||||
<Trash2 :size="14" />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
RefreshCw,
|
||||
MoreHorizontal,
|
||||
Edit3,
|
||||
ExternalLink,
|
||||
Trash2,
|
||||
} from "@/components/icons";
|
||||
import { copyToClipboard } from "@/utils/helpers";
|
||||
import type { MessageFeedback } from "@/types/chat";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: string;
|
||||
feedback?: MessageFeedback;
|
||||
showRegenerate?: boolean;
|
||||
showMore?: boolean;
|
||||
alwaysVisible?: boolean;
|
||||
isHovered?: boolean;
|
||||
isNew?: boolean;
|
||||
isBreak?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showRegenerate: false,
|
||||
showMore: true,
|
||||
alwaysVisible: false,
|
||||
isHovered: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: [];
|
||||
like: [];
|
||||
dislike: [];
|
||||
regenerate: [];
|
||||
edit: [];
|
||||
share: [];
|
||||
delete: [];
|
||||
}>();
|
||||
|
||||
const copied = ref(false);
|
||||
const showMoreMenu = ref(false);
|
||||
|
||||
async function handleCopy() {
|
||||
const success = await copyToClipboard(props.content);
|
||||
if (success) {
|
||||
copied.value = true;
|
||||
emit("copy");
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLike() {
|
||||
emit("like");
|
||||
}
|
||||
|
||||
function handleDislike() {
|
||||
emit("dislike");
|
||||
}
|
||||
|
||||
function handleRegenerate() {
|
||||
emit("regenerate");
|
||||
}
|
||||
|
||||
function toggleMoreMenu() {
|
||||
showMoreMenu.value = !showMoreMenu.value;
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
showMoreMenu.value = false;
|
||||
emit("edit");
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
showMoreMenu.value = false;
|
||||
emit("share");
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
showMoreMenu.value = false;
|
||||
emit("delete");
|
||||
}
|
||||
|
||||
// 点击外部关闭菜单
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(".more-menu")) {
|
||||
showMoreMenu.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 挂载时添加事件监听
|
||||
if (typeof window !== "undefined") {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #3b82f6;
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: #10b981;
|
||||
|
||||
&:hover {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
min-width: 140px;
|
||||
padding: 6px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
.dark & {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ef4444;
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉动画
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.95);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,90 +1,90 @@
|
|||
<script setup lang="ts">
|
||||
import * as echarts from "echarts";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
//@ts-ignore
|
||||
import Loading from "./Loading.vue";
|
||||
|
||||
interface Props {
|
||||
node: {
|
||||
type: "vmr_container";
|
||||
name: string;
|
||||
children?: Array<{ type: string; raw: string }>;
|
||||
};
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
const isLoading = ref(false);
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// 只处理 echarts 容器
|
||||
const isEChartsContainer = computed(() => props.node.name === "echarts");
|
||||
|
||||
const chartRef = ref<HTMLElement>();
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
// 从子节点提取 JSON
|
||||
const chartOption = computed(() => {
|
||||
if (!props.node.children || props.node.children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const code = props.node.children[0].raw;
|
||||
|
||||
try {
|
||||
return JSON.parse(code);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
function initChart() {
|
||||
isLoading.value = true;
|
||||
if (!isEChartsContainer.value || !chartRef.value || !chartOption.value)
|
||||
return;
|
||||
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
}
|
||||
const theme = props.isDark ? "dark" : undefined;
|
||||
isLoading.value = false;
|
||||
chartInstance = echarts.init(chartRef.value, theme);
|
||||
chartInstance.setOption(chartOption.value, true);
|
||||
}
|
||||
|
||||
watch(() => props.isDark, initChart);
|
||||
watch(chartOption, (option) => {
|
||||
if (chartInstance && option) {
|
||||
chartInstance.setOption(option, true);
|
||||
} else if (option) {
|
||||
initChart();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(initChart);
|
||||
onBeforeUnmount(() => {
|
||||
chartInstance?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isEChartsContainer" class="vmr-container vmr-container-echarts">
|
||||
<div ref="chartRef" style="width: 100%; height: 400px" />
|
||||
<Loading :loading="isLoading" text="正在渲染数据..." />
|
||||
<slot v-if="!chartOption" />
|
||||
</div>
|
||||
<div v-else class="vmr-container" :class="`vmr-container-${node.name}`">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vmr-container-echarts {
|
||||
padding: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.dark .vmr-container-echarts {
|
||||
border-color: #374151;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import * as echarts from "echarts";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
//@ts-ignore
|
||||
import Loading from "./Loading.vue";
|
||||
|
||||
interface Props {
|
||||
node: {
|
||||
type: "vmr_container";
|
||||
name: string;
|
||||
children?: Array<{ type: string; raw: string }>;
|
||||
};
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
const isLoading = ref(false);
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// 只处理 echarts 容器
|
||||
const isEChartsContainer = computed(() => props.node.name === "echarts");
|
||||
|
||||
const chartRef = ref<HTMLElement>();
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
// 从子节点提取 JSON
|
||||
const chartOption = computed(() => {
|
||||
if (!props.node.children || props.node.children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const code = props.node.children[0].raw;
|
||||
|
||||
try {
|
||||
return JSON.parse(code);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
function initChart() {
|
||||
isLoading.value = true;
|
||||
if (!isEChartsContainer.value || !chartRef.value || !chartOption.value)
|
||||
return;
|
||||
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
}
|
||||
const theme = props.isDark ? "dark" : undefined;
|
||||
isLoading.value = false;
|
||||
chartInstance = echarts.init(chartRef.value, theme);
|
||||
chartInstance.setOption(chartOption.value, true);
|
||||
}
|
||||
|
||||
watch(() => props.isDark, initChart);
|
||||
watch(chartOption, (option) => {
|
||||
if (chartInstance && option) {
|
||||
chartInstance.setOption(option, true);
|
||||
} else if (option) {
|
||||
initChart();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(initChart);
|
||||
onBeforeUnmount(() => {
|
||||
chartInstance?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isEChartsContainer" class="vmr-container vmr-container-echarts">
|
||||
<div ref="chartRef" style="width: 100%; height: 400px" />
|
||||
<Loading :loading="isLoading" text="正在渲染数据..." />
|
||||
<slot v-if="!chartOption" />
|
||||
</div>
|
||||
<div v-else class="vmr-container" :class="`vmr-container-${node.name}`">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vmr-container-echarts {
|
||||
padding: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.dark .vmr-container-echarts {
|
||||
border-color: #374151;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,70 +1,70 @@
|
|||
<template>
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<div class="loading-spinner">
|
||||
<div class="spinner-box">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<p v-if="text" class="loading-text">{{ text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: "加载中...",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.spinner-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
text-align: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
padding-top: 15px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<div class="loading-spinner">
|
||||
<div class="spinner-box">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<p v-if="text" class="loading-text">{{ text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: "加载中...",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.spinner-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
text-align: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
padding-top: 15px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,211 +1,220 @@
|
|||
<script setup lang="ts">
|
||||
import { MarkdownRender } from "markstream-vue";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
node: {
|
||||
type: "think";
|
||||
content: string;
|
||||
children: any[];
|
||||
loading?: boolean;
|
||||
};
|
||||
}>();
|
||||
|
||||
const { copy } = useClipboard({ legacy: true });
|
||||
const collapsed = ref(false);
|
||||
|
||||
// 当思考完成时(loading 从 true 变为 false),自动折叠
|
||||
watch(
|
||||
() => props.node.loading,
|
||||
(newVal, oldVal) => {
|
||||
if (oldVal === true && newVal === false) {
|
||||
collapsed.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
|
||||
async function textCopy(data: any) {
|
||||
if (typeof data === "string") {
|
||||
copy(data);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400"
|
||||
>
|
||||
<!-- 可点击的标题栏 -->
|
||||
<div class="thinking-header" @click="toggleCollapse">
|
||||
<div class="flex-shrink-0">
|
||||
<!-- 思考图标 -->
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.8"
|
||||
fill="currentColor"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thinking-title">
|
||||
<strong class="text-sm">💭 深度思考</strong>
|
||||
<!-- 加载动画 -->
|
||||
<span v-if="node.loading" class="thinking-dots visible" aria-hidden="true">
|
||||
<span class="dot dot-1" />
|
||||
<span class="dot dot-2" />
|
||||
<span class="dot dot-3" />
|
||||
</span>
|
||||
<span v-else class="thinking-status text-xs text-slate-500 dark:text-slate-300">
|
||||
已完成
|
||||
</span>
|
||||
</div>
|
||||
<!-- 折叠/展开箭头 -->
|
||||
<div class="collapse-arrow" :class="{ collapsed }">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 可折叠的内容区域 -->
|
||||
<div class="thinking-content" :class="{ collapsed }">
|
||||
<div
|
||||
class="mt-3 text-sm leading-relaxed text-slate-800 dark:text-slate-100"
|
||||
>
|
||||
<MarkdownRender :content="node.content" @copy="textCopy" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.thinking-node {
|
||||
color: #0f172a;
|
||||
}
|
||||
.dark .thinking-node {
|
||||
color: #e6f0ff;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.thinking-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.thinking-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thinking-status {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 折叠箭头 */
|
||||
.collapse-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #64748b;
|
||||
transition: transform 0.25s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.collapse-arrow:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.dark .collapse-arrow:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.collapse-arrow.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* 可折叠内容 */
|
||||
.thinking-content {
|
||||
max-height: 2000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s ease, opacity 0.25s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
.thinking-content.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.thinking-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 12px;
|
||||
}
|
||||
.thinking-dots .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: #1e3a8a;
|
||||
opacity: 0.25;
|
||||
}
|
||||
.thinking-dots.visible .dot-1 {
|
||||
animation: think-bounce 1s infinite ease-in-out;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.thinking-dots.visible .dot-2 {
|
||||
animation: think-bounce 1s infinite ease-in-out;
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.thinking-dots.visible .dot-3 {
|
||||
animation: think-bounce 1s infinite ease-in-out;
|
||||
animation-delay: 0.24s;
|
||||
}
|
||||
.dark .thinking-dots .dot {
|
||||
background: #bfdbfe;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
@keyframes think-bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.25;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-6px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { MarkdownRender } from "markstream-vue";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
node: {
|
||||
type: "think";
|
||||
content: string;
|
||||
children: any[];
|
||||
loading?: boolean;
|
||||
};
|
||||
}>();
|
||||
|
||||
const { copy } = useClipboard({ legacy: true });
|
||||
const collapsed = ref(false);
|
||||
|
||||
// 当思考完成时(loading 从 true 变为 false),自动折叠
|
||||
watch(
|
||||
() => props.node.loading,
|
||||
(newVal, oldVal) => {
|
||||
if (oldVal === true && newVal === false) {
|
||||
collapsed.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
|
||||
async function textCopy(data: any) {
|
||||
if (typeof data === "string") {
|
||||
copy(data);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400"
|
||||
>
|
||||
<!-- 可点击的标题栏 -->
|
||||
<div class="thinking-header" @click="toggleCollapse">
|
||||
<div class="flex-shrink-0">
|
||||
<!-- 思考图标 -->
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.8"
|
||||
fill="currentColor"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thinking-title">
|
||||
<strong class="text-sm">💭 深度思考</strong>
|
||||
<!-- 加载动画 -->
|
||||
<span
|
||||
v-if="node.loading"
|
||||
class="thinking-dots visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="dot dot-1" />
|
||||
<span class="dot dot-2" />
|
||||
<span class="dot dot-3" />
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="thinking-status text-xs text-slate-500 dark:text-slate-300"
|
||||
>
|
||||
已完成
|
||||
</span>
|
||||
</div>
|
||||
<!-- 折叠/展开箭头 -->
|
||||
<div class="collapse-arrow" :class="{ collapsed }">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 可折叠的内容区域 -->
|
||||
<div class="thinking-content" :class="{ collapsed }">
|
||||
<div
|
||||
class="mt-3 text-sm leading-relaxed text-slate-800 dark:text-slate-100"
|
||||
>
|
||||
<MarkdownRender :content="node.content" @copy="textCopy" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.thinking-node {
|
||||
color: #0f172a;
|
||||
}
|
||||
.dark .thinking-node {
|
||||
color: #e6f0ff;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.thinking-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.thinking-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thinking-status {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 折叠箭头 */
|
||||
.collapse-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #64748b;
|
||||
transition: transform 0.25s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.collapse-arrow:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.dark .collapse-arrow:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.collapse-arrow.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* 可折叠内容 */
|
||||
.thinking-content {
|
||||
max-height: 2000px;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.35s ease,
|
||||
opacity 0.25s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
.thinking-content.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.thinking-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 12px;
|
||||
}
|
||||
.thinking-dots .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: #1e3a8a;
|
||||
opacity: 0.25;
|
||||
}
|
||||
.thinking-dots.visible .dot-1 {
|
||||
animation: think-bounce 1s infinite ease-in-out;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.thinking-dots.visible .dot-2 {
|
||||
animation: think-bounce 1s infinite ease-in-out;
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.thinking-dots.visible .dot-3 {
|
||||
animation: think-bounce 1s infinite ease-in-out;
|
||||
animation-delay: 0.24s;
|
||||
}
|
||||
.dark .thinking-dots .dot {
|
||||
background: #bfdbfe;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
@keyframes think-bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.25;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-6px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,369 +1,371 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="visible" class="modal-overlay" @click.self="close">
|
||||
<div class="search-modal">
|
||||
<!-- 搜索输入 -->
|
||||
<div class="search-header">
|
||||
<Search :size="20" class="search-icon" />
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="搜索对话..."
|
||||
@keydown.escape="close"
|
||||
@keydown.down.prevent="navigateDown"
|
||||
@keydown.up.prevent="navigateUp"
|
||||
@keydown.enter="selectCurrent"
|
||||
/>
|
||||
<kbd class="esc-hint">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div class="search-results">
|
||||
<div v-if="filteredConversations.length === 0" class="no-results">
|
||||
<FolderOpen :size="40" class="no-results-icon" />
|
||||
<p>没有找到相关对话</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(conv, index) in filteredConversations"
|
||||
:key="conv.id"
|
||||
class="result-item"
|
||||
:class="{ active: index === selectedIndex }"
|
||||
@click="selectConversation(conv.id)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<MessageSquare :size="18" class="result-icon" />
|
||||
<div class="result-content">
|
||||
<div class="result-title">{{ conv.title }}</div>
|
||||
<div class="result-meta">
|
||||
<span>{{ conv.messages.length }} 条消息</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{{ formatTime(conv.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Pin v-if="conv.pinned" :size="14" class="pin-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<div class="search-footer">
|
||||
<div class="hint">
|
||||
<kbd>↑↓</kbd>
|
||||
<span>导航</span>
|
||||
</div>
|
||||
<div class="hint">
|
||||
<kbd>↵</kbd>
|
||||
<span>选择</span>
|
||||
</div>
|
||||
<div class="hint">
|
||||
<kbd>ESC</kbd>
|
||||
<span>关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { Search, MessageSquare, FolderOpen, Pin } from '@/components/icons'
|
||||
import { formatTimestamp } from '@/utils/helpers'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const { conversations } = storeToRefs(chatStore)
|
||||
const { showSearchModal: visible } = storeToRefs(settingsStore)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedIndex = ref(0)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const filteredConversations = computed(() => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
return conversations.value.slice(0, 10)
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return conversations.value.filter(conv => {
|
||||
// 搜索标题
|
||||
if (conv.title.toLowerCase().includes(query)) return true
|
||||
|
||||
// 搜索消息内容
|
||||
return conv.messages.some(msg =>
|
||||
msg.content.text?.toLowerCase().includes(query)
|
||||
)
|
||||
}).slice(0, 10)
|
||||
})
|
||||
|
||||
function formatTime(timestamp: number) {
|
||||
return formatTimestamp(timestamp)
|
||||
}
|
||||
|
||||
function close() {
|
||||
settingsStore.closeSearchModal()
|
||||
searchQuery.value = ''
|
||||
selectedIndex.value = 0
|
||||
}
|
||||
|
||||
function navigateDown() {
|
||||
if (selectedIndex.value < filteredConversations.value.length - 1) {
|
||||
selectedIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
function navigateUp() {
|
||||
if (selectedIndex.value > 0) {
|
||||
selectedIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
function selectCurrent() {
|
||||
const conv = filteredConversations.value[selectedIndex.value]
|
||||
if (conv) {
|
||||
selectConversation(conv.id)
|
||||
}
|
||||
}
|
||||
|
||||
function selectConversation(id: string) {
|
||||
chatStore.selectConversation(id)
|
||||
close()
|
||||
}
|
||||
|
||||
// 打开时聚焦输入框
|
||||
watch(visible, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索内容变化时重置选中索引
|
||||
watch(searchQuery, () => {
|
||||
selectedIndex.value = 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 100px;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.search-modal {
|
||||
width: 560px;
|
||||
max-height: 480px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
}
|
||||
}
|
||||
|
||||
.search-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.dark & {
|
||||
border-bottom-color: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
background: transparent;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.esc-hint {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.search-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: #9ca3af;
|
||||
|
||||
.no-results-icon {
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background: #f3f4f6;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
|
||||
.dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
|
||||
.dark & {
|
||||
border-top-color: #2d2d3d;
|
||||
background: #181825;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
|
||||
kbd {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.search-modal {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
.search-modal {
|
||||
transform: scale(0.95) translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="visible" class="modal-overlay" @click.self="close">
|
||||
<div class="search-modal">
|
||||
<!-- 搜索输入 -->
|
||||
<div class="search-header">
|
||||
<Search :size="20" class="search-icon" />
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="搜索对话..."
|
||||
@keydown.escape="close"
|
||||
@keydown.down.prevent="navigateDown"
|
||||
@keydown.up.prevent="navigateUp"
|
||||
@keydown.enter="selectCurrent"
|
||||
/>
|
||||
<kbd class="esc-hint">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div class="search-results">
|
||||
<div v-if="filteredConversations.length === 0" class="no-results">
|
||||
<FolderOpen :size="40" class="no-results-icon" />
|
||||
<p>没有找到相关对话</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(conv, index) in filteredConversations"
|
||||
:key="conv.id"
|
||||
class="result-item"
|
||||
:class="{ active: index === selectedIndex }"
|
||||
@click="selectConversation(conv.id)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<MessageSquare :size="18" class="result-icon" />
|
||||
<div class="result-content">
|
||||
<div class="result-title">{{ conv.title }}</div>
|
||||
<div class="result-meta">
|
||||
<span>{{ conv.messages.length }} 条消息</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{{ formatTime(conv.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Pin v-if="conv.pinned" :size="14" class="pin-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<div class="search-footer">
|
||||
<div class="hint">
|
||||
<kbd>↑↓</kbd>
|
||||
<span>导航</span>
|
||||
</div>
|
||||
<div class="hint">
|
||||
<kbd>↵</kbd>
|
||||
<span>选择</span>
|
||||
</div>
|
||||
<div class="hint">
|
||||
<kbd>ESC</kbd>
|
||||
<span>关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { Search, MessageSquare, FolderOpen, Pin } from "@/components/icons";
|
||||
import { formatTimestamp } from "@/utils/helpers";
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const { conversations } = storeToRefs(chatStore);
|
||||
const { showSearchModal: visible } = storeToRefs(settingsStore);
|
||||
|
||||
const searchQuery = ref("");
|
||||
const selectedIndex = ref(0);
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const filteredConversations = computed(() => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
return conversations.value.slice(0, 10);
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return conversations.value
|
||||
.filter((conv) => {
|
||||
// 搜索标题
|
||||
if (conv.title.toLowerCase().includes(query)) return true;
|
||||
|
||||
// 搜索消息内容
|
||||
return conv.messages.some((msg) =>
|
||||
msg.content.text?.toLowerCase().includes(query),
|
||||
);
|
||||
})
|
||||
.slice(0, 10);
|
||||
});
|
||||
|
||||
function formatTime(timestamp: number) {
|
||||
return formatTimestamp(timestamp);
|
||||
}
|
||||
|
||||
function close() {
|
||||
settingsStore.closeSearchModal();
|
||||
searchQuery.value = "";
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
|
||||
function navigateDown() {
|
||||
if (selectedIndex.value < filteredConversations.value.length - 1) {
|
||||
selectedIndex.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateUp() {
|
||||
if (selectedIndex.value > 0) {
|
||||
selectedIndex.value--;
|
||||
}
|
||||
}
|
||||
|
||||
function selectCurrent() {
|
||||
const conv = filteredConversations.value[selectedIndex.value];
|
||||
if (conv) {
|
||||
selectConversation(conv.id);
|
||||
}
|
||||
}
|
||||
|
||||
function selectConversation(id: string) {
|
||||
chatStore.selectConversation(id);
|
||||
close();
|
||||
}
|
||||
|
||||
// 打开时聚焦输入框
|
||||
watch(visible, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索内容变化时重置选中索引
|
||||
watch(searchQuery, () => {
|
||||
selectedIndex.value = 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 100px;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.search-modal {
|
||||
width: 560px;
|
||||
max-height: 480px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
}
|
||||
}
|
||||
|
||||
.search-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.dark & {
|
||||
border-bottom-color: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
background: transparent;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.esc-hint {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.search-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: #9ca3af;
|
||||
|
||||
.no-results-icon {
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background: #f3f4f6;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
|
||||
.dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
|
||||
.dark & {
|
||||
border-top-color: #2d2d3d;
|
||||
background: #181825;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
|
||||
kbd {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.search-modal {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
.search-modal {
|
||||
transform: scale(0.95) translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,307 +1,309 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="visible" class="modal-overlay" @click.self="close">
|
||||
<div class="shortcuts-modal">
|
||||
<!-- 头部 -->
|
||||
<div class="modal-header">
|
||||
<div class="header-title">
|
||||
<Keyboard :size="22" />
|
||||
<h3>键盘快捷键</h3>
|
||||
</div>
|
||||
<button class="close-btn" @click="close">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 快捷键列表 -->
|
||||
<div class="shortcuts-content">
|
||||
<div
|
||||
v-for="group in shortcutGroups"
|
||||
:key="group.title"
|
||||
class="shortcut-group"
|
||||
>
|
||||
<h4 class="group-title">{{ group.title }}</h4>
|
||||
<div class="shortcuts-list">
|
||||
<div
|
||||
v-for="shortcut in group.shortcuts"
|
||||
:key="shortcut.description"
|
||||
class="shortcut-item"
|
||||
>
|
||||
<span class="shortcut-desc">{{ shortcut.description }}</span>
|
||||
<div class="shortcut-keys">
|
||||
<kbd v-for="key in shortcut.keys" :key="key">{{ key }}</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div class="modal-footer">
|
||||
<span class="tip">按 <kbd>ESC</kbd> 或 <kbd>?</kbd> 关闭此窗口</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { Keyboard, X } from '@/components/icons'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const { showShortcutsModal: visible } = storeToRefs(settingsStore)
|
||||
|
||||
const shortcutGroups = computed(() => [
|
||||
{
|
||||
title: '通用',
|
||||
shortcuts: [
|
||||
{ description: '新建对话', keys: ['⌘', 'N'] },
|
||||
{ description: '搜索对话', keys: ['⌘', 'K'] },
|
||||
{ description: '切换侧边栏', keys: ['⌘', 'B'] },
|
||||
{ description: '切换主题', keys: ['⌘', '⇧', 'D'] },
|
||||
{ description: '显示快捷键', keys: ['⌘', '?'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '对话',
|
||||
shortcuts: [
|
||||
{ description: '发送消息', keys: ['⌘', '↵'] },
|
||||
{ description: '换行', keys: ['⇧', '↵'] },
|
||||
{ description: '聚焦输入框', keys: ['⌘', '/'] },
|
||||
{ description: '停止生成', keys: ['ESC'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '消息操作',
|
||||
shortcuts: [
|
||||
{ description: '复制消息', keys: ['⌘', 'C'] },
|
||||
{ description: '重新生成', keys: ['⌘', 'R'] },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
function close() {
|
||||
settingsStore.closeShortcutsModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.shortcuts-modal {
|
||||
width: 480px;
|
||||
max-height: 80vh;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.dark & {
|
||||
border-bottom-color: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
svg {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shortcuts-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.shortcut-group {
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.shortcuts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-desc {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-keys {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.dark & {
|
||||
color: #e5e7eb;
|
||||
background: #1e1e2e;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
text-align: center;
|
||||
|
||||
.dark & {
|
||||
border-top-color: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
margin: 0 2px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
|
||||
.shortcuts-modal {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
.shortcuts-modal {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="visible" class="modal-overlay" @click.self="close">
|
||||
<div class="shortcuts-modal">
|
||||
<!-- 头部 -->
|
||||
<div class="modal-header">
|
||||
<div class="header-title">
|
||||
<Keyboard :size="22" />
|
||||
<h3>键盘快捷键</h3>
|
||||
</div>
|
||||
<button class="close-btn" @click="close">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 快捷键列表 -->
|
||||
<div class="shortcuts-content">
|
||||
<div
|
||||
v-for="group in shortcutGroups"
|
||||
:key="group.title"
|
||||
class="shortcut-group"
|
||||
>
|
||||
<h4 class="group-title">{{ group.title }}</h4>
|
||||
<div class="shortcuts-list">
|
||||
<div
|
||||
v-for="shortcut in group.shortcuts"
|
||||
:key="shortcut.description"
|
||||
class="shortcut-item"
|
||||
>
|
||||
<span class="shortcut-desc">{{ shortcut.description }}</span>
|
||||
<div class="shortcut-keys">
|
||||
<kbd v-for="key in shortcut.keys" :key="key">{{ key }}</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div class="modal-footer">
|
||||
<span class="tip"
|
||||
>按 <kbd>ESC</kbd> 或 <kbd>?</kbd> 关闭此窗口</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { Keyboard, X } from "@/components/icons";
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const { showShortcutsModal: visible } = storeToRefs(settingsStore);
|
||||
|
||||
const shortcutGroups = computed(() => [
|
||||
{
|
||||
title: "通用",
|
||||
shortcuts: [
|
||||
{ description: "新建对话", keys: ["⌘", "N"] },
|
||||
{ description: "搜索对话", keys: ["⌘", "K"] },
|
||||
{ description: "切换侧边栏", keys: ["⌘", "B"] },
|
||||
{ description: "切换主题", keys: ["⌘", "⇧", "D"] },
|
||||
{ description: "显示快捷键", keys: ["⌘", "?"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "对话",
|
||||
shortcuts: [
|
||||
{ description: "发送消息", keys: ["⌘", "↵"] },
|
||||
{ description: "换行", keys: ["⇧", "↵"] },
|
||||
{ description: "聚焦输入框", keys: ["⌘", "/"] },
|
||||
{ description: "停止生成", keys: ["ESC"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "消息操作",
|
||||
shortcuts: [
|
||||
{ description: "复制消息", keys: ["⌘", "C"] },
|
||||
{ description: "重新生成", keys: ["⌘", "R"] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
function close() {
|
||||
settingsStore.closeShortcutsModal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.shortcuts-modal {
|
||||
width: 480px;
|
||||
max-height: 80vh;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.dark & {
|
||||
border-bottom-color: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
svg {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shortcuts-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.shortcut-group {
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.shortcuts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-desc {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-keys {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.dark & {
|
||||
color: #e5e7eb;
|
||||
background: #1e1e2e;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
text-align: center;
|
||||
|
||||
.dark & {
|
||||
border-top-color: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
margin: 0 2px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
|
||||
.shortcuts-modal {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
.shortcuts-modal {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,495 +1,495 @@
|
|||
<template>
|
||||
<aside
|
||||
class="chat-sidebar"
|
||||
:class="{ collapsed: isCollapsed }"
|
||||
:style="{ width: isCollapsed ? '0px' : `${sidebarWidth}px` }"
|
||||
>
|
||||
<div class="sidebar-inner">
|
||||
<!-- 头部 -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<Bot :size="24" class="logo-icon" />
|
||||
<span v-show="!isCollapsed" class="logo-text">Kexue AI Chat</span>
|
||||
</div>
|
||||
<button
|
||||
class="collapse-btn"
|
||||
@click="toggleSidebar"
|
||||
:title="isCollapsed ? '展开侧边栏' : '收起侧边栏'"
|
||||
>
|
||||
<ChevronLeft :size="18" :class="{ rotated: isCollapsed }" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 新建对话按钮 -->
|
||||
<div class="new-chat-section">
|
||||
<button class="new-chat-btn" @click="handleNewChat">
|
||||
<Plus :size="18" />
|
||||
<span>新建对话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-section">
|
||||
<div class="search-box" @click="openSearch">
|
||||
<Search :size="16" />
|
||||
<span class="search-placeholder">搜索对话...</span>
|
||||
<kbd class="search-kbd">⌘K</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话列表 -->
|
||||
<div class="conversations-section">
|
||||
<!-- 置顶对话 -->
|
||||
<div v-if="pinnedConversations.length > 0" class="conversation-group">
|
||||
<div class="group-header">
|
||||
<Pin :size="14" />
|
||||
<span>置顶</span>
|
||||
</div>
|
||||
<div class="group-list">
|
||||
<ConversationItem
|
||||
v-for="conv in pinnedConversations"
|
||||
:key="conv.id"
|
||||
:conversation="conv"
|
||||
:is-active="conv.id === currentConversationId"
|
||||
@select="selectConversation"
|
||||
@delete="deleteConversation"
|
||||
@rename="renameConversation"
|
||||
@toggle-pin="togglePinConversation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近对话 -->
|
||||
<div class="conversation-group">
|
||||
<div class="group-header">
|
||||
<Clock :size="14" />
|
||||
<span>最近</span>
|
||||
</div>
|
||||
<div class="group-list">
|
||||
<ConversationItem
|
||||
v-for="conv in recentConversations"
|
||||
:key="conv.id"
|
||||
:conversation="conv"
|
||||
:is-active="conv.id === currentConversationId"
|
||||
@select="selectConversation"
|
||||
@delete="deleteConversation"
|
||||
@rename="renameConversation"
|
||||
@toggle-pin="togglePinConversation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="
|
||||
pinnedConversations.length === 0 && recentConversations.length === 0
|
||||
"
|
||||
class="empty-state"
|
||||
>
|
||||
<MessageSquare :size="40" class="empty-icon" />
|
||||
<p>暂无对话</p>
|
||||
<span>点击上方按钮开始新对话</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="sidebar-footer">
|
||||
<button class="footer-btn" @click="toggleTheme" title="切换主题">
|
||||
<Sun v-if="currentTheme === 'light'" :size="18" />
|
||||
<Moon v-else-if="currentTheme === 'dark'" :size="18" />
|
||||
<Monitor v-else :size="18" />
|
||||
</button>
|
||||
<button class="footer-btn" @click="openShortcuts" title="快捷键">
|
||||
<Keyboard :size="18" />
|
||||
</button>
|
||||
<button class="footer-btn" @click="openSettings" title="设置">
|
||||
<Settings :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽调整宽度 -->
|
||||
<div class="resize-handle" @mousedown="startResize" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import ConversationItem from "./ConversationItem.vue";
|
||||
import {
|
||||
Bot,
|
||||
Plus,
|
||||
Search,
|
||||
Pin,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Keyboard,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
} from "@/components/icons";
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const { currentConversationId, pinnedConversations, recentConversations } =
|
||||
storeToRefs(chatStore);
|
||||
|
||||
const {
|
||||
sidebarCollapsed: isCollapsed,
|
||||
sidebarWidth,
|
||||
settings,
|
||||
} = storeToRefs(settingsStore);
|
||||
|
||||
const currentTheme = computed(() => settings.value.theme);
|
||||
|
||||
// 方法
|
||||
function handleNewChat() {
|
||||
chatStore.createConversation();
|
||||
}
|
||||
|
||||
function selectConversation(id: string) {
|
||||
chatStore.selectConversation(id);
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
chatStore.deleteConversation(id);
|
||||
}
|
||||
|
||||
function renameConversation(id: string, title: string) {
|
||||
chatStore.renameConversation(id, title);
|
||||
}
|
||||
|
||||
function togglePinConversation(id: string) {
|
||||
chatStore.togglePinConversation(id);
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
settingsStore.toggleSidebar();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
settingsStore.toggleTheme();
|
||||
}
|
||||
|
||||
function openShortcuts() {
|
||||
settingsStore.openShortcutsModal();
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
settingsStore.openSettingsModal();
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
settingsStore.openSearchModal();
|
||||
}
|
||||
|
||||
// 拖拽调整宽度
|
||||
const isResizing = ref(false);
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
isResizing.value = true;
|
||||
const startX = e.clientX;
|
||||
const startWidth = sidebarWidth.value;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const diff = e.clientX - startX;
|
||||
settingsStore.setSidebarWidth(startWidth + diff);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isResizing.value = false;
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-sidebar {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
background: #f8fafc;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
transition: width 0.3s ease;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
border-radius: 15px;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
border-right-color: #2d2d3d;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
.sidebar-inner {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.dark & {
|
||||
border-bottom-color: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.new-chat-section {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.dark & {
|
||||
border-color: #4b5563;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.search-section {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-kbd {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.conversations-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.conversation-group {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
.dark & {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
color: #d1d5db;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.dark & {
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
|
||||
.dark & {
|
||||
border-top-color: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<aside
|
||||
class="chat-sidebar"
|
||||
:class="{ collapsed: isCollapsed }"
|
||||
:style="{ width: isCollapsed ? '0px' : `${sidebarWidth}px` }"
|
||||
>
|
||||
<div class="sidebar-inner">
|
||||
<!-- 头部 -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<Bot :size="24" class="logo-icon" />
|
||||
<span v-show="!isCollapsed" class="logo-text">AI Chat</span>
|
||||
</div>
|
||||
<button
|
||||
class="collapse-btn"
|
||||
@click="toggleSidebar"
|
||||
:title="isCollapsed ? '展开侧边栏' : '收起侧边栏'"
|
||||
>
|
||||
<ChevronLeft :size="18" :class="{ rotated: isCollapsed }" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 新建对话按钮 -->
|
||||
<div class="new-chat-section">
|
||||
<button class="new-chat-btn" @click="handleNewChat">
|
||||
<Plus :size="18" />
|
||||
<span>新建对话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-section">
|
||||
<div class="search-box" @click="openSearch">
|
||||
<Search :size="16" />
|
||||
<span class="search-placeholder">搜索对话...</span>
|
||||
<kbd class="search-kbd">⌘K</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话列表 -->
|
||||
<div class="conversations-section">
|
||||
<!-- 置顶对话 -->
|
||||
<div v-if="pinnedConversations.length > 0" class="conversation-group">
|
||||
<div class="group-header">
|
||||
<Pin :size="14" />
|
||||
<span>置顶</span>
|
||||
</div>
|
||||
<div class="group-list">
|
||||
<ConversationItem
|
||||
v-for="conv in pinnedConversations"
|
||||
:key="conv.id"
|
||||
:conversation="conv"
|
||||
:is-active="conv.id === currentConversationId"
|
||||
@select="selectConversation"
|
||||
@delete="deleteConversation"
|
||||
@rename="renameConversation"
|
||||
@toggle-pin="togglePinConversation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近对话 -->
|
||||
<div class="conversation-group">
|
||||
<div class="group-header">
|
||||
<Clock :size="14" />
|
||||
<span>最近</span>
|
||||
</div>
|
||||
<div class="group-list">
|
||||
<ConversationItem
|
||||
v-for="conv in recentConversations"
|
||||
:key="conv.id"
|
||||
:conversation="conv"
|
||||
:is-active="conv.id === currentConversationId"
|
||||
@select="selectConversation"
|
||||
@delete="deleteConversation"
|
||||
@rename="renameConversation"
|
||||
@toggle-pin="togglePinConversation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="
|
||||
pinnedConversations.length === 0 && recentConversations.length === 0
|
||||
"
|
||||
class="empty-state"
|
||||
>
|
||||
<MessageSquare :size="40" class="empty-icon" />
|
||||
<p>暂无对话</p>
|
||||
<span>点击上方按钮开始新对话</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="sidebar-footer">
|
||||
<button class="footer-btn" @click="toggleTheme" title="切换主题">
|
||||
<Sun v-if="currentTheme === 'light'" :size="18" />
|
||||
<Moon v-else-if="currentTheme === 'dark'" :size="18" />
|
||||
<Monitor v-else :size="18" />
|
||||
</button>
|
||||
<button class="footer-btn" @click="openShortcuts" title="快捷键">
|
||||
<Keyboard :size="18" />
|
||||
</button>
|
||||
<button class="footer-btn" @click="openSettings" title="设置">
|
||||
<Settings :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽调整宽度 -->
|
||||
<div class="resize-handle" @mousedown="startResize" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import ConversationItem from "./ConversationItem.vue";
|
||||
import {
|
||||
Bot,
|
||||
Plus,
|
||||
Search,
|
||||
Pin,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Keyboard,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
} from "@/components/icons";
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const { currentConversationId, pinnedConversations, recentConversations } =
|
||||
storeToRefs(chatStore);
|
||||
|
||||
const {
|
||||
sidebarCollapsed: isCollapsed,
|
||||
sidebarWidth,
|
||||
settings,
|
||||
} = storeToRefs(settingsStore);
|
||||
|
||||
const currentTheme = computed(() => settings.value.theme);
|
||||
|
||||
// 方法
|
||||
function handleNewChat() {
|
||||
chatStore.createConversation();
|
||||
}
|
||||
|
||||
function selectConversation(id: string) {
|
||||
chatStore.selectConversation(id);
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
chatStore.deleteConversation(id);
|
||||
}
|
||||
|
||||
function renameConversation(id: string, title: string) {
|
||||
chatStore.renameConversation(id, title);
|
||||
}
|
||||
|
||||
function togglePinConversation(id: string) {
|
||||
chatStore.togglePinConversation(id);
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
settingsStore.toggleSidebar();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
settingsStore.toggleTheme();
|
||||
}
|
||||
|
||||
function openShortcuts() {
|
||||
settingsStore.openShortcutsModal();
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
settingsStore.openSettingsModal();
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
settingsStore.openSearchModal();
|
||||
}
|
||||
|
||||
// 拖拽调整宽度
|
||||
const isResizing = ref(false);
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
isResizing.value = true;
|
||||
const startX = e.clientX;
|
||||
const startWidth = sidebarWidth.value;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const diff = e.clientX - startX;
|
||||
settingsStore.setSidebarWidth(startWidth + diff);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isResizing.value = false;
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-sidebar {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
background: #f8fafc;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
transition: width 0.3s ease;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
border-radius: 15px;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
border-right-color: #2d2d3d;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
.sidebar-inner {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.dark & {
|
||||
border-bottom-color: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.new-chat-section {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.dark & {
|
||||
border-color: #4b5563;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.search-section {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-kbd {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.conversations-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.conversation-group {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
.dark & {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
color: #d1d5db;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.dark & {
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
|
||||
.dark & {
|
||||
border-top-color: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,278 +1,277 @@
|
|||
<template>
|
||||
<div
|
||||
class="conversation-item group"
|
||||
:class="{
|
||||
'active': isActive,
|
||||
'pinned': conversation.pinned
|
||||
}"
|
||||
@click="handleSelect"
|
||||
@dblclick="handleRename"
|
||||
>
|
||||
<!-- 图标 -->
|
||||
<div class="item-icon">
|
||||
<MessageSquare :size="18" />
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="item-content">
|
||||
<div v-if="!isEditing" class="item-title">
|
||||
{{ conversation.title }}
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
ref="inputRef"
|
||||
v-model="editTitle"
|
||||
class="item-title-input"
|
||||
@blur="handleSaveRename"
|
||||
@keydown.enter="handleSaveRename"
|
||||
@keydown.escape="handleCancelRename"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="item-meta">
|
||||
<Clock :size="12" />
|
||||
<span>{{ formattedTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 置顶标识 -->
|
||||
<div v-if="conversation.pinned" class="pin-indicator">
|
||||
<Pin :size="12" />
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="item-actions" @click.stop>
|
||||
<button
|
||||
class="action-btn"
|
||||
:title="conversation.pinned ? '取消置顶' : '置顶'"
|
||||
@click="handleTogglePin"
|
||||
>
|
||||
<PinOff v-if="conversation.pinned" :size="14" />
|
||||
<Pin v-else :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
title="重命名"
|
||||
@click="handleRename"
|
||||
>
|
||||
<Edit3 :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn delete"
|
||||
title="删除"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { MessageSquare, Pin, PinOff, Edit3, Trash2, Clock } from '@/components/icons'
|
||||
import { formatTimestamp } from '@/utils/helpers'
|
||||
import type { Conversation } from '@/types/chat'
|
||||
|
||||
const props = defineProps<{
|
||||
conversation: Conversation
|
||||
isActive: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
delete: [id: string]
|
||||
rename: [id: string, title: string]
|
||||
togglePin: [id: string]
|
||||
}>()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const editTitle = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
return formatTimestamp(props.conversation.updatedAt)
|
||||
})
|
||||
|
||||
function handleSelect() {
|
||||
if (!isEditing.value) {
|
||||
emit('select', props.conversation.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePin() {
|
||||
emit('togglePin', props.conversation.id)
|
||||
}
|
||||
|
||||
function handleRename() {
|
||||
isEditing.value = true
|
||||
editTitle.value = props.conversation.title
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
inputRef.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function handleSaveRename() {
|
||||
if (editTitle.value.trim()) {
|
||||
emit('rename', props.conversation.id, editTitle.value.trim())
|
||||
}
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
function handleCancelRename() {
|
||||
isEditing.value = false
|
||||
editTitle.value = ''
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (confirm('确定要删除这个对话吗?')) {
|
||||
emit('delete', props.conversation.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: 2px 8px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pin-indicator {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
|
||||
.dark & {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
flex-shrink: 0;
|
||||
color: #6b7280;
|
||||
|
||||
.dark & {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.item-title-input {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
background: white;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
outline: none;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
|
||||
.dark & {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.pin-indicator {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
color: #f59e0b;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
&.delete:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div
|
||||
class="conversation-item group"
|
||||
:class="{
|
||||
active: isActive,
|
||||
pinned: conversation.pinned,
|
||||
}"
|
||||
@click="handleSelect"
|
||||
@dblclick="handleRename"
|
||||
>
|
||||
<!-- 图标 -->
|
||||
<div class="item-icon">
|
||||
<MessageSquare :size="18" />
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="item-content">
|
||||
<div v-if="!isEditing" class="item-title">
|
||||
{{ conversation.title }}
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
ref="inputRef"
|
||||
v-model="editTitle"
|
||||
class="item-title-input"
|
||||
@blur="handleSaveRename"
|
||||
@keydown.enter="handleSaveRename"
|
||||
@keydown.escape="handleCancelRename"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="item-meta">
|
||||
<Clock :size="12" />
|
||||
<span>{{ formattedTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 置顶标识 -->
|
||||
<div v-if="conversation.pinned" class="pin-indicator">
|
||||
<Pin :size="12" />
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="item-actions" @click.stop>
|
||||
<button
|
||||
class="action-btn"
|
||||
:title="conversation.pinned ? '取消置顶' : '置顶'"
|
||||
@click="handleTogglePin"
|
||||
>
|
||||
<PinOff v-if="conversation.pinned" :size="14" />
|
||||
<Pin v-else :size="14" />
|
||||
</button>
|
||||
<button class="action-btn" title="重命名" @click="handleRename">
|
||||
<Edit3 :size="14" />
|
||||
</button>
|
||||
<button class="action-btn delete" title="删除" @click="handleDelete">
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import {
|
||||
MessageSquare,
|
||||
Pin,
|
||||
PinOff,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Clock,
|
||||
} from "@/components/icons";
|
||||
import { formatTimestamp } from "@/utils/helpers";
|
||||
import type { Conversation } from "@/types/chat";
|
||||
|
||||
const props = defineProps<{
|
||||
conversation: Conversation;
|
||||
isActive: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string];
|
||||
delete: [id: string];
|
||||
rename: [id: string, title: string];
|
||||
togglePin: [id: string];
|
||||
}>();
|
||||
|
||||
const isEditing = ref(false);
|
||||
const editTitle = ref("");
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
return formatTimestamp(props.conversation.updatedAt);
|
||||
});
|
||||
|
||||
function handleSelect() {
|
||||
if (!isEditing.value) {
|
||||
emit("select", props.conversation.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePin() {
|
||||
emit("togglePin", props.conversation.id);
|
||||
}
|
||||
|
||||
function handleRename() {
|
||||
isEditing.value = true;
|
||||
editTitle.value = props.conversation.title;
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus();
|
||||
inputRef.value?.select();
|
||||
});
|
||||
}
|
||||
|
||||
function handleSaveRename() {
|
||||
if (editTitle.value.trim()) {
|
||||
emit("rename", props.conversation.id, editTitle.value.trim());
|
||||
}
|
||||
isEditing.value = false;
|
||||
}
|
||||
|
||||
function handleCancelRename() {
|
||||
isEditing.value = false;
|
||||
editTitle.value = "";
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (confirm("确定要删除这个对话吗?")) {
|
||||
emit("delete", props.conversation.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: 2px 8px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pin-indicator {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
|
||||
.dark & {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
flex-shrink: 0;
|
||||
color: #6b7280;
|
||||
|
||||
.dark & {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.item-title-input {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
background: white;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
outline: none;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
|
||||
.dark & {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.pin-indicator {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
color: #f59e0b;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: #374151;
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
&.delete:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,242 +1,242 @@
|
|||
<template>
|
||||
<div class="form-select" :class="{ open: isOpen, disabled }">
|
||||
<button class="select-trigger" :disabled="disabled" @click="toggleOpen">
|
||||
<span class="select-value">
|
||||
<slot name="selected" :option="selectedOption">
|
||||
{{ selectedOption?.label || placeholder }}
|
||||
</slot>
|
||||
</span>
|
||||
<ChevronDown :size="18" class="select-arrow" />
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen" class="select-dropdown">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="select-option"
|
||||
:class="{ active: option.value === modelValue }"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<slot name="option" :option="option">
|
||||
<span class="option-label">{{ option.label }}</span>
|
||||
<span v-if="option.description" class="option-desc">{{
|
||||
option.description
|
||||
}}</span>
|
||||
</slot>
|
||||
<Check
|
||||
v-if="option.value === modelValue"
|
||||
:size="16"
|
||||
class="check-icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { ChevronDown, Check } from "@/components/icons";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string | number;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
valueProp?: string;
|
||||
}>(),
|
||||
{
|
||||
placeholder: "请选择",
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string | number];
|
||||
}>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find((opt) => opt.value === props.modelValue);
|
||||
});
|
||||
|
||||
function toggleOpen() {
|
||||
if (!props.disabled) {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(option: any) {
|
||||
emit("update:modelValue", option[props.valueProp || "value"]);
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(".form-select")) {
|
||||
isOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.open {
|
||||
.select-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
border-color: #374151;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.select-value {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.select-arrow {
|
||||
color: #9ca3af;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.select-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
border-color: #2d2d3d;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:first-child {
|
||||
border-radius: 11px 11px 0 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 11px 11px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
|
||||
.option-label {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: #3b82f6;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
// 下拉动画
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="form-select" :class="{ open: isOpen, disabled }">
|
||||
<button class="select-trigger" :disabled="disabled" @click="toggleOpen">
|
||||
<span class="select-value">
|
||||
<slot name="selected" :option="selectedOption">
|
||||
{{ selectedOption?.label || placeholder }}
|
||||
</slot>
|
||||
</span>
|
||||
<ChevronDown :size="18" class="select-arrow" />
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen" class="select-dropdown">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="select-option"
|
||||
:class="{ active: option.value === modelValue }"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<slot name="option" :option="option">
|
||||
<span class="option-label">{{ option.label }}</span>
|
||||
<span v-if="option.description" class="option-desc">{{
|
||||
option.description
|
||||
}}</span>
|
||||
</slot>
|
||||
<Check
|
||||
v-if="option.value === modelValue"
|
||||
:size="16"
|
||||
class="check-icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { ChevronDown, Check } from "@/components/icons";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string | number;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
valueProp?: string;
|
||||
}>(),
|
||||
{
|
||||
placeholder: "请选择",
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string | number];
|
||||
}>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find((opt) => opt.value === props.modelValue);
|
||||
});
|
||||
|
||||
function toggleOpen() {
|
||||
if (!props.disabled) {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(option: any) {
|
||||
emit("update:modelValue", option[props.valueProp || "value"]);
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(".form-select")) {
|
||||
isOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.open {
|
||||
.select-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
border-color: #374151;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.select-value {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.select-arrow {
|
||||
color: #9ca3af;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.select-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
border-color: #2d2d3d;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:first-child {
|
||||
border-radius: 11px 11px 0 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 11px 11px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
|
||||
.dark & {
|
||||
background: #2d2d3d;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
|
||||
.option-label {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
|
||||
.dark & {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: #3b82f6;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
// 下拉动画
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,116 +1,119 @@
|
|||
<template>
|
||||
<div class="form-slider">
|
||||
<input
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<div class="slider-track">
|
||||
<div class="slider-fill" :style="{ width: fillPercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
|
||||
const fillPercent = computed(() => {
|
||||
return ((props.modelValue - props.min) / (props.max - props.min)) * 100
|
||||
})
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const value = parseFloat((event.target as HTMLInputElement).value)
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-slider {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
|
||||
input[type="range"] {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
transform: translateY(-50%);
|
||||
overflow: hidden;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
// 滑块样式(不同浏览器)
|
||||
.form-slider input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-slider input[type="range"]::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="form-slider">
|
||||
<input
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<div class="slider-track">
|
||||
<div class="slider-fill" :style="{ width: fillPercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: number];
|
||||
}>();
|
||||
|
||||
const fillPercent = computed(() => {
|
||||
return ((props.modelValue - props.min) / (props.max - props.min)) * 100;
|
||||
});
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||
emit("update:modelValue", value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-slider {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
|
||||
input[type="range"] {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
transform: translateY(-50%);
|
||||
overflow: hidden;
|
||||
|
||||
.dark & {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
// 滑块样式(不同浏览器)
|
||||
.form-slider input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-slider input[type="range"]::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,80 +1,82 @@
|
|||
<template>
|
||||
<label class="form-switch" :class="{ disabled }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled"
|
||||
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="switch-slider"></span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:checked + .switch-slider {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
|
||||
&::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus + .switch-slider {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.switch-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #d1d5db;
|
||||
border-radius: 24px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.dark & {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<label class="form-switch" :class="{ disabled }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled"
|
||||
@change="
|
||||
$emit('update:modelValue', ($event.target as HTMLInputElement).checked)
|
||||
"
|
||||
/>
|
||||
<span class="switch-slider"></span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:checked + .switch-slider {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
|
||||
&::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus + .switch-slider {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.switch-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #d1d5db;
|
||||
border-radius: 24px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.dark & {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,128 +1,133 @@
|
|||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
alt?: boolean
|
||||
meta?: boolean
|
||||
description: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
// 快捷键管理组合式函数
|
||||
export function useKeyboard(shortcuts: KeyboardShortcut[]) {
|
||||
const activeKeys = ref<Set<string>>(new Set())
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
activeKeys.value.add(event.key.toLowerCase())
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
|
||||
const ctrlMatch = !!shortcut.ctrl === (event.ctrlKey || event.metaKey)
|
||||
const shiftMatch = !!shortcut.shift === event.shiftKey
|
||||
const altMatch = !!shortcut.alt === event.altKey
|
||||
|
||||
if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
|
||||
// 排除在输入框中的部分快捷键
|
||||
const target = event.target as HTMLElement
|
||||
const isInput = target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
|
||||
// 这些快捷键在输入框中也生效
|
||||
const globalShortcuts = ['Escape', 'Enter']
|
||||
const needsModifier = shortcut.ctrl || shortcut.alt || shortcut.meta
|
||||
|
||||
if (isInput && !globalShortcuts.includes(shortcut.key) && !needsModifier) {
|
||||
continue
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
shortcut.action()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
activeKeys.value.delete(event.key.toLowerCase())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
})
|
||||
|
||||
return {
|
||||
activeKeys,
|
||||
}
|
||||
}
|
||||
|
||||
// 预定义的快捷键配置
|
||||
export function getDefaultShortcuts(actions: {
|
||||
newChat: () => void
|
||||
toggleSidebar: () => void
|
||||
focusInput: () => void
|
||||
sendMessage: () => void
|
||||
cancelStream: () => void
|
||||
toggleTheme: () => void
|
||||
showShortcuts: () => void
|
||||
searchConversations: () => void
|
||||
}): KeyboardShortcut[] {
|
||||
return [
|
||||
{
|
||||
key: 'n',
|
||||
ctrl: true,
|
||||
description: '新建对话',
|
||||
action: actions.newChat,
|
||||
},
|
||||
{
|
||||
key: 'b',
|
||||
ctrl: true,
|
||||
description: '切换侧边栏',
|
||||
action: actions.toggleSidebar,
|
||||
},
|
||||
{
|
||||
key: '/',
|
||||
ctrl: true,
|
||||
description: '聚焦输入框',
|
||||
action: actions.focusInput,
|
||||
},
|
||||
{
|
||||
key: 'Enter',
|
||||
ctrl: true,
|
||||
description: '发送消息',
|
||||
action: actions.sendMessage,
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
description: '取消生成',
|
||||
action: actions.cancelStream,
|
||||
},
|
||||
{
|
||||
key: 'd',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
description: '切换主题',
|
||||
action: actions.toggleTheme,
|
||||
},
|
||||
{
|
||||
key: '?',
|
||||
ctrl: true,
|
||||
description: '显示快捷键',
|
||||
action: actions.showShortcuts,
|
||||
},
|
||||
{
|
||||
key: 'k',
|
||||
ctrl: true,
|
||||
description: '搜索对话',
|
||||
action: actions.searchConversations,
|
||||
},
|
||||
]
|
||||
}
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
meta?: boolean;
|
||||
description: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
// 快捷键管理组合式函数
|
||||
export function useKeyboard(shortcuts: KeyboardShortcut[]) {
|
||||
const activeKeys = ref<Set<string>>(new Set());
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
activeKeys.value.add(event.key.toLowerCase());
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase();
|
||||
const ctrlMatch = !!shortcut.ctrl === (event.ctrlKey || event.metaKey);
|
||||
const shiftMatch = !!shortcut.shift === event.shiftKey;
|
||||
const altMatch = !!shortcut.alt === event.altKey;
|
||||
|
||||
if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
|
||||
// 排除在输入框中的部分快捷键
|
||||
const target = event.target as HTMLElement;
|
||||
const isInput =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable;
|
||||
|
||||
// 这些快捷键在输入框中也生效
|
||||
const globalShortcuts = ["Escape", "Enter"];
|
||||
const needsModifier = shortcut.ctrl || shortcut.alt || shortcut.meta;
|
||||
|
||||
if (
|
||||
isInput &&
|
||||
!globalShortcuts.includes(shortcut.key) &&
|
||||
!needsModifier
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
shortcut.action();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
activeKeys.value.delete(event.key.toLowerCase());
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
});
|
||||
|
||||
return {
|
||||
activeKeys,
|
||||
};
|
||||
}
|
||||
|
||||
// 预定义的快捷键配置
|
||||
export function getDefaultShortcuts(actions: {
|
||||
newChat: () => void;
|
||||
toggleSidebar: () => void;
|
||||
focusInput: () => void;
|
||||
sendMessage: () => void;
|
||||
cancelStream: () => void;
|
||||
toggleTheme: () => void;
|
||||
showShortcuts: () => void;
|
||||
searchConversations: () => void;
|
||||
}): KeyboardShortcut[] {
|
||||
return [
|
||||
{
|
||||
key: "n",
|
||||
ctrl: true,
|
||||
description: "新建对话",
|
||||
action: actions.newChat,
|
||||
},
|
||||
{
|
||||
key: "b",
|
||||
ctrl: true,
|
||||
description: "切换侧边栏",
|
||||
action: actions.toggleSidebar,
|
||||
},
|
||||
{
|
||||
key: "/",
|
||||
ctrl: true,
|
||||
description: "聚焦输入框",
|
||||
action: actions.focusInput,
|
||||
},
|
||||
{
|
||||
key: "Enter",
|
||||
ctrl: true,
|
||||
description: "发送消息",
|
||||
action: actions.sendMessage,
|
||||
},
|
||||
{
|
||||
key: "Escape",
|
||||
description: "取消生成",
|
||||
action: actions.cancelStream,
|
||||
},
|
||||
{
|
||||
key: "d",
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
description: "切换主题",
|
||||
action: actions.toggleTheme,
|
||||
},
|
||||
{
|
||||
key: "?",
|
||||
ctrl: true,
|
||||
description: "显示快捷键",
|
||||
action: actions.showShortcuts,
|
||||
},
|
||||
{
|
||||
key: "k",
|
||||
ctrl: true,
|
||||
description: "搜索对话",
|
||||
action: actions.searchConversations,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
52
src/main.ts
52
src/main.ts
|
|
@ -1,26 +1,26 @@
|
|||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
|
||||
// 样式
|
||||
import "@unocss/reset/tailwind.css";
|
||||
import "virtual:uno.css";
|
||||
import "./styles/main.scss";
|
||||
import "markstream-vue/index.css";
|
||||
|
||||
// 创建应用
|
||||
const app = createApp(App);
|
||||
|
||||
// 使用 Pinia
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
|
||||
// 挂载应用
|
||||
app.mount("#app");
|
||||
|
||||
// 类型声明
|
||||
declare global {
|
||||
interface Window {
|
||||
$toast: (message: string, type?: "success" | "error" | "info") => void;
|
||||
}
|
||||
}
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
|
||||
// 样式
|
||||
import "@unocss/reset/tailwind.css";
|
||||
import "virtual:uno.css";
|
||||
import "./styles/main.scss";
|
||||
import "markstream-vue/index.css";
|
||||
|
||||
// 创建应用
|
||||
const app = createApp(App);
|
||||
|
||||
// 使用 Pinia
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
|
||||
// 挂载应用
|
||||
app.mount("#app");
|
||||
|
||||
// 类型声明
|
||||
declare global {
|
||||
interface Window {
|
||||
$toast: (message: string, type?: "success" | "error" | "info") => void;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,301 +1,304 @@
|
|||
/**
|
||||
* Chat UI API 服务
|
||||
* 所有端点都是固定的,后端需要实现这些端点
|
||||
*/
|
||||
// API 端点定义(固定)
|
||||
const API_ENDPOINTS = {
|
||||
// 发送消息(流式)
|
||||
CHAT_STREAM: "/api/chat-ui/chat",
|
||||
// 发送消息(非流式)
|
||||
CHAT: "/api/chat-ui/chat",
|
||||
// 获取对话历史
|
||||
CONVERSATIONS: "/api/chat-ui/conversations",
|
||||
// 获取单个对话
|
||||
CONVERSATION: "/api/chat-ui/conversations/:id",
|
||||
// 删除对话
|
||||
DELETE_CONVERSATION: "/api/chat-ui/conversations/:id",
|
||||
// 上传文件
|
||||
UPLOAD: "/api/chat-ui/upload",
|
||||
// 获取模型列表
|
||||
MODELS: "/api/chat-ui/models",
|
||||
// 停止生成
|
||||
STOP: "/api/chat-ui/stop",
|
||||
};
|
||||
|
||||
// 请求类型定义
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
images?: string[];
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
conversationId?: string;
|
||||
message: string;
|
||||
images?: string[];
|
||||
files?: string[]; // 非图片附件 URL 列表
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
systemPrompt?: string;
|
||||
stream?: boolean;
|
||||
// 历史对话消息(用于上下文记忆)
|
||||
history?: { role: string; content: string }[];
|
||||
// 扩展选项
|
||||
deepSearch?: boolean;
|
||||
webSearch?: boolean;
|
||||
deepThinking?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
content: string;
|
||||
model: string;
|
||||
createdAt: number;
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
maxTokens: number;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
url: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
// API 调用类
|
||||
class ChatApi {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl = "") {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式对话
|
||||
*/
|
||||
async *streamChat(
|
||||
request: ChatRequest,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<string> {
|
||||
// 构建消息数组,考虑是否包含图片
|
||||
let userContent;
|
||||
if (request.images && request.images.length > 0) {
|
||||
// 如果有图片,则构建内容数组(针对阿里云DashScope API的格式)
|
||||
userContent = [{ type: "text", text: request.message }];
|
||||
// 添加图片URL到内容中(阿里云格式)
|
||||
request.images.forEach((imageUrl) => {
|
||||
userContent.push({
|
||||
type: "image_url",
|
||||
image_url: imageUrl, // 注意:阿里云格式不需要嵌套对象
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 没有图片时,使用简单的文本
|
||||
userContent = request.message;
|
||||
}
|
||||
|
||||
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
|
||||
// 构建 messages 数组:system + 历史消息 + 当前用户消息
|
||||
const systemMessage = {
|
||||
role: "system",
|
||||
content: request.systemPrompt || "你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
|
||||
};
|
||||
const currentUserMessage = {
|
||||
role: "user",
|
||||
content: userContent,
|
||||
};
|
||||
const allMessages = request.history && request.history.length > 0
|
||||
? [systemMessage, ...request.history, currentUserMessage]
|
||||
: [systemMessage, currentUserMessage];
|
||||
|
||||
const openAiRequest = {
|
||||
model: request.model || "glm-4-flash",
|
||||
messages: allMessages,
|
||||
stream: true,
|
||||
temperature: request.temperature,
|
||||
max_tokens: request.maxTokens,
|
||||
files: request.files || [],
|
||||
// 扩展参数传递给我们的 Python 后端进行特殊处理
|
||||
deepSearch: request.deepSearch,
|
||||
webSearch: request.webSearch,
|
||||
deepThinking: request.deepThinking,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(openAiRequest),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("Response body is not readable");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
// 保留最后一行未完整的 JSON
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "" || line.includes("[DONE]")) continue;
|
||||
const match = line.match(/^data:\s*(.+)$/);
|
||||
if (match) {
|
||||
try {
|
||||
const data = JSON.parse(match[1]);
|
||||
// 检查是否有完成原因,如果是完成则跳出
|
||||
const finishReason = data.choices?.[0]?.finish_reason;
|
||||
if (finishReason && finishReason !== "null") {
|
||||
break;
|
||||
}
|
||||
|
||||
const content = data.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
yield content;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("JSON解析错误", e, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 非流式对话
|
||||
*/
|
||||
async chat(request: ChatRequest): Promise<ChatResponse> {
|
||||
// 构建消息数组,考虑是否包含图片
|
||||
let userContent;
|
||||
if (request.images && request.images.length > 0) {
|
||||
// 如果有图片,则构建内容数组
|
||||
userContent = [{ type: "text", text: request.message }];
|
||||
// 添加图片URL到内容中
|
||||
request.images.forEach((imageUrl) => {
|
||||
userContent.push({
|
||||
type: "image_url",
|
||||
image_url: { url: imageUrl },
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 没有图片时,使用简单的文本
|
||||
userContent = request.message;
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
...request,
|
||||
message: userContent,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止对话
|
||||
*/
|
||||
async stopChat(messageId?: string) {
|
||||
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型列表
|
||||
*/
|
||||
async getModels(): Promise<ModelInfo[]> {
|
||||
return [
|
||||
{
|
||||
id: "glm-4.6",
|
||||
name: "智普 GLM-4.6",
|
||||
description: "最强大的模型",
|
||||
maxTokens: 8192,
|
||||
provider: "Zhipu",
|
||||
},
|
||||
// GLM-4.5,联网搜索功能有问题
|
||||
// {
|
||||
// id: "glm-4.5",
|
||||
// name: "智普 GLM-4.5",
|
||||
// description: "能力均衡",
|
||||
// maxTokens: 8192,
|
||||
// provider: "Zhipu",
|
||||
// },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async uploadFile(file: File): Promise<UploadResult> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`上传失败: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const chatApi = new ChatApi();
|
||||
|
||||
// 导出类用于自定义配置
|
||||
export { ChatApi, API_ENDPOINTS };
|
||||
|
||||
// 导出端点常量(供调试使用)
|
||||
// export {API_ENDPOINTS}
|
||||
/**
|
||||
* Chat UI API 服务
|
||||
* 所有端点都是固定的,后端需要实现这些端点
|
||||
*/
|
||||
// API 端点定义(固定)
|
||||
const API_ENDPOINTS = {
|
||||
// 发送消息(流式)
|
||||
CHAT_STREAM: "/api/chat-ui/chat",
|
||||
// 发送消息(非流式)
|
||||
CHAT: "/api/chat-ui/chat",
|
||||
// 获取对话历史
|
||||
CONVERSATIONS: "/api/chat-ui/conversations",
|
||||
// 获取单个对话
|
||||
CONVERSATION: "/api/chat-ui/conversations/:id",
|
||||
// 删除对话
|
||||
DELETE_CONVERSATION: "/api/chat-ui/conversations/:id",
|
||||
// 上传文件
|
||||
UPLOAD: "/api/chat-ui/upload",
|
||||
// 获取模型列表
|
||||
MODELS: "/api/chat-ui/models",
|
||||
// 停止生成
|
||||
STOP: "/api/chat-ui/stop",
|
||||
};
|
||||
|
||||
// 请求类型定义
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
images?: string[];
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
conversationId?: string;
|
||||
message: string;
|
||||
images?: string[];
|
||||
files?: string[]; // 非图片附件 URL 列表
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
systemPrompt?: string;
|
||||
stream?: boolean;
|
||||
// 历史对话消息(用于上下文记忆)
|
||||
history?: { role: string; content: string }[];
|
||||
// 扩展选项
|
||||
deepSearch?: boolean;
|
||||
webSearch?: boolean;
|
||||
deepThinking?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
content: string;
|
||||
model: string;
|
||||
createdAt: number;
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
maxTokens: number;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
url: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
// API 调用类
|
||||
class ChatApi {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl = "") {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式对话
|
||||
*/
|
||||
async *streamChat(
|
||||
request: ChatRequest,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<string> {
|
||||
// 构建消息数组,考虑是否包含图片
|
||||
let userContent;
|
||||
if (request.images && request.images.length > 0) {
|
||||
// 如果有图片,则构建内容数组(针对阿里云DashScope API的格式)
|
||||
userContent = [{ type: "text", text: request.message }];
|
||||
// 添加图片URL到内容中(阿里云格式)
|
||||
request.images.forEach((imageUrl) => {
|
||||
userContent.push({
|
||||
type: "image_url",
|
||||
image_url: imageUrl, // 注意:阿里云格式不需要嵌套对象
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 没有图片时,使用简单的文本
|
||||
userContent = request.message;
|
||||
}
|
||||
|
||||
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
|
||||
// 构建 messages 数组:system + 历史消息 + 当前用户消息
|
||||
const systemMessage = {
|
||||
role: "system",
|
||||
content:
|
||||
request.systemPrompt ||
|
||||
"你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
|
||||
};
|
||||
const currentUserMessage = {
|
||||
role: "user",
|
||||
content: userContent,
|
||||
};
|
||||
const allMessages =
|
||||
request.history && request.history.length > 0
|
||||
? [systemMessage, ...request.history, currentUserMessage]
|
||||
: [systemMessage, currentUserMessage];
|
||||
|
||||
const openAiRequest = {
|
||||
model: request.model || "glm-4-flash",
|
||||
messages: allMessages,
|
||||
stream: true,
|
||||
temperature: request.temperature,
|
||||
max_tokens: request.maxTokens,
|
||||
files: request.files || [],
|
||||
// 扩展参数传递给我们的 Python 后端进行特殊处理
|
||||
deepSearch: request.deepSearch,
|
||||
webSearch: request.webSearch,
|
||||
deepThinking: request.deepThinking,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(openAiRequest),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("Response body is not readable");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
// 保留最后一行未完整的 JSON
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "" || line.includes("[DONE]")) continue;
|
||||
const match = line.match(/^data:\s*(.+)$/);
|
||||
if (match) {
|
||||
try {
|
||||
const data = JSON.parse(match[1]);
|
||||
// 检查是否有完成原因,如果是完成则跳出
|
||||
const finishReason = data.choices?.[0]?.finish_reason;
|
||||
if (finishReason && finishReason !== "null") {
|
||||
break;
|
||||
}
|
||||
|
||||
const content = data.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
yield content;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("JSON解析错误", e, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 非流式对话
|
||||
*/
|
||||
async chat(request: ChatRequest): Promise<ChatResponse> {
|
||||
// 构建消息数组,考虑是否包含图片
|
||||
let userContent;
|
||||
if (request.images && request.images.length > 0) {
|
||||
// 如果有图片,则构建内容数组
|
||||
userContent = [{ type: "text", text: request.message }];
|
||||
// 添加图片URL到内容中
|
||||
request.images.forEach((imageUrl) => {
|
||||
userContent.push({
|
||||
type: "image_url",
|
||||
image_url: { url: imageUrl },
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 没有图片时,使用简单的文本
|
||||
userContent = request.message;
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
...request,
|
||||
message: userContent,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止对话
|
||||
*/
|
||||
async stopChat(messageId?: string) {
|
||||
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型列表
|
||||
*/
|
||||
async getModels(): Promise<ModelInfo[]> {
|
||||
return [
|
||||
{
|
||||
id: "glm-4.6",
|
||||
name: "智普 GLM-4.6",
|
||||
description: "最强大的模型",
|
||||
maxTokens: 8192,
|
||||
provider: "Zhipu",
|
||||
},
|
||||
// GLM-4.5,联网搜索功能有问题
|
||||
// {
|
||||
// id: "glm-4.5",
|
||||
// name: "智普 GLM-4.5",
|
||||
// description: "能力均衡",
|
||||
// maxTokens: 8192,
|
||||
// provider: "Zhipu",
|
||||
// },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async uploadFile(file: File): Promise<UploadResult> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`上传失败: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const chatApi = new ChatApi();
|
||||
|
||||
// 导出类用于自定义配置
|
||||
export { ChatApi, API_ENDPOINTS };
|
||||
|
||||
// 导出端点常量(供调试使用)
|
||||
// export {API_ENDPOINTS}
|
||||
|
|
|
|||
|
|
@ -1,285 +1,285 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
MessageContent,
|
||||
ConversationSettings,
|
||||
} from "@/types/chat";
|
||||
import { MessageRole } from "@/types/chat";
|
||||
import { generateId, extractTitleFromMessage } from "@/utils/helpers";
|
||||
|
||||
export const useChatStore = defineStore("chat", () => {
|
||||
// 状态
|
||||
const conversations = ref<Conversation[]>([]);
|
||||
const currentConversationId = ref<string | null>(null);
|
||||
const isStreaming = ref(false);
|
||||
const streamController = ref<AbortController | null>(null);
|
||||
|
||||
// 计算属性
|
||||
const currentConversation = computed(() => {
|
||||
return (
|
||||
conversations.value.find((c) => c.id === currentConversationId.value) ||
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
const sortedConversations = computed(() => {
|
||||
return [...conversations.value].sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return b.updatedAt - a.updatedAt;
|
||||
});
|
||||
});
|
||||
|
||||
const pinnedConversations = computed(() => {
|
||||
return sortedConversations.value.filter((c) => c.pinned && !c.archived);
|
||||
});
|
||||
|
||||
const recentConversations = computed(() => {
|
||||
return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
|
||||
});
|
||||
|
||||
// 方法
|
||||
function createConversation(): string {
|
||||
const newConversation: Conversation = {
|
||||
id: generateId(),
|
||||
title: "新对话",
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
pinned: false,
|
||||
archived: false,
|
||||
settings: undefined,
|
||||
};
|
||||
|
||||
conversations.value.unshift(newConversation);
|
||||
currentConversationId.value = newConversation.id;
|
||||
saveToStorage();
|
||||
|
||||
return newConversation.id;
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
const index = conversations.value.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
conversations.value.splice(index, 1);
|
||||
|
||||
if (currentConversationId.value === id) {
|
||||
currentConversationId.value = conversations.value[0]?.id || null;
|
||||
}
|
||||
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function selectConversation(id: string) {
|
||||
currentConversationId.value = id;
|
||||
}
|
||||
|
||||
function togglePinConversation(id: string) {
|
||||
const conversation = conversations.value.find((c) => c.id === id);
|
||||
if (conversation) {
|
||||
conversation.pinned = !conversation.pinned;
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function renameConversation(id: string, newTitle: string) {
|
||||
const conversation = conversations.value.find((c) => c.id === id);
|
||||
if (conversation) {
|
||||
conversation.title = newTitle;
|
||||
conversation.updatedAt = Date.now();
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function updateConversationSettings(
|
||||
id: string,
|
||||
convSettings: ConversationSettings,
|
||||
) {
|
||||
const conversation = conversations.value.find((c) => c.id === id);
|
||||
if (conversation) {
|
||||
conversation.settings = { ...conversation.settings, ...convSettings };
|
||||
conversation.updatedAt = Date.now();
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(
|
||||
role: MessageRole,
|
||||
content: MessageContent,
|
||||
conversationId?: string,
|
||||
): Message {
|
||||
const targetId = conversationId || currentConversationId.value;
|
||||
|
||||
if (!targetId) {
|
||||
createConversation();
|
||||
}
|
||||
|
||||
const conversation = conversations.value.find(
|
||||
(c) => c.id === (targetId || currentConversationId.value),
|
||||
);
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error("Conversation not found");
|
||||
}
|
||||
|
||||
const message: any = {
|
||||
id: generateId(),
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
isStreaming: false,
|
||||
};
|
||||
|
||||
conversation.messages.push(message);
|
||||
conversation.updatedAt = Date.now();
|
||||
|
||||
if (
|
||||
role === MessageRole.USER &&
|
||||
conversation.messages.length === 1 &&
|
||||
content.text
|
||||
) {
|
||||
conversation.title = extractTitleFromMessage(content.text);
|
||||
}
|
||||
|
||||
saveToStorage();
|
||||
return message;
|
||||
}
|
||||
|
||||
function updateMessage(messageId: string, updates: Partial<Message>) {
|
||||
const conversation = currentConversation.value;
|
||||
if (!conversation) return;
|
||||
|
||||
const message = conversation.messages.find((m) => m.id === messageId);
|
||||
if (message) {
|
||||
Object.assign(message, updates);
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function updateMessageContent(messageId: string, text: string) {
|
||||
const conversation = currentConversation.value;
|
||||
if (!conversation) return;
|
||||
|
||||
const message = conversation.messages.find((m) => m.id === messageId);
|
||||
if (message) {
|
||||
message.content.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
function setMessageFeedback(
|
||||
messageId: string,
|
||||
feedback: "like" | "dislike" | null,
|
||||
) {
|
||||
const conversation = currentConversation.value;
|
||||
if (!conversation) return;
|
||||
|
||||
const message = conversation.messages.find((m) => m.id === messageId);
|
||||
if (message) {
|
||||
message.feedback = {
|
||||
liked: feedback === "like",
|
||||
disliked: feedback === "dislike",
|
||||
copied: message.feedback?.copied,
|
||||
};
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function setMessageCopied(messageId: string) {
|
||||
const conversation = currentConversation.value;
|
||||
if (!conversation) return;
|
||||
|
||||
const message = conversation.messages.find((m) => m.id === messageId);
|
||||
if (message) {
|
||||
message.feedback = {
|
||||
...message.feedback,
|
||||
copied: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function startStreaming() {
|
||||
isStreaming.value = true;
|
||||
streamController.value = new AbortController();
|
||||
}
|
||||
|
||||
function stopStreaming() {
|
||||
isStreaming.value = false;
|
||||
if (streamController.value) {
|
||||
streamController.value.abort();
|
||||
streamController.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearConversation(id: string) {
|
||||
const conversation = conversations.value.find((c) => c.id === id);
|
||||
if (conversation) {
|
||||
conversation.messages = [];
|
||||
conversation.updatedAt = Date.now();
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"chat-conversations",
|
||||
JSON.stringify(conversations.value),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"chat-current-id",
|
||||
currentConversationId.value || "",
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to save to storage:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem("chat-conversations");
|
||||
if (stored) {
|
||||
conversations.value = JSON.parse(stored);
|
||||
}
|
||||
|
||||
const storedId = localStorage.getItem("chat-current-id");
|
||||
if (storedId && conversations.value.find((c) => c.id === storedId)) {
|
||||
currentConversationId.value = storedId;
|
||||
} else if (conversations.value.length > 0) {
|
||||
currentConversationId.value = conversations.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load from storage:", e);
|
||||
}
|
||||
}
|
||||
|
||||
loadFromStorage();
|
||||
|
||||
return {
|
||||
conversations,
|
||||
currentConversationId,
|
||||
isStreaming,
|
||||
streamController,
|
||||
currentConversation,
|
||||
sortedConversations,
|
||||
pinnedConversations,
|
||||
recentConversations,
|
||||
createConversation,
|
||||
deleteConversation,
|
||||
selectConversation,
|
||||
togglePinConversation,
|
||||
renameConversation,
|
||||
updateConversationSettings,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
updateMessageContent,
|
||||
setMessageFeedback,
|
||||
setMessageCopied,
|
||||
startStreaming,
|
||||
stopStreaming,
|
||||
clearConversation,
|
||||
loadFromStorage,
|
||||
};
|
||||
});
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
MessageContent,
|
||||
ConversationSettings,
|
||||
} from "@/types/chat";
|
||||
import { MessageRole } from "@/types/chat";
|
||||
import { generateId, extractTitleFromMessage } from "@/utils/helpers";
|
||||
|
||||
export const useChatStore = defineStore("chat", () => {
|
||||
// 状态
|
||||
const conversations = ref<Conversation[]>([]);
|
||||
const currentConversationId = ref<string | null>(null);
|
||||
const isStreaming = ref(false);
|
||||
const streamController = ref<AbortController | null>(null);
|
||||
|
||||
// 计算属性
|
||||
const currentConversation = computed(() => {
|
||||
return (
|
||||
conversations.value.find((c) => c.id === currentConversationId.value) ||
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
const sortedConversations = computed(() => {
|
||||
return [...conversations.value].sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return b.updatedAt - a.updatedAt;
|
||||
});
|
||||
});
|
||||
|
||||
const pinnedConversations = computed(() => {
|
||||
return sortedConversations.value.filter((c) => c.pinned && !c.archived);
|
||||
});
|
||||
|
||||
const recentConversations = computed(() => {
|
||||
return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
|
||||
});
|
||||
|
||||
// 方法
|
||||
function createConversation(): string {
|
||||
const newConversation: Conversation = {
|
||||
id: generateId(),
|
||||
title: "新对话",
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
pinned: false,
|
||||
archived: false,
|
||||
settings: undefined,
|
||||
};
|
||||
|
||||
conversations.value.unshift(newConversation);
|
||||
currentConversationId.value = newConversation.id;
|
||||
saveToStorage();
|
||||
|
||||
return newConversation.id;
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
const index = conversations.value.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
conversations.value.splice(index, 1);
|
||||
|
||||
if (currentConversationId.value === id) {
|
||||
currentConversationId.value = conversations.value[0]?.id || null;
|
||||
}
|
||||
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function selectConversation(id: string) {
|
||||
currentConversationId.value = id;
|
||||
}
|
||||
|
||||
function togglePinConversation(id: string) {
|
||||
const conversation = conversations.value.find((c) => c.id === id);
|
||||
if (conversation) {
|
||||
conversation.pinned = !conversation.pinned;
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function renameConversation(id: string, newTitle: string) {
|
||||
const conversation = conversations.value.find((c) => c.id === id);
|
||||
if (conversation) {
|
||||
conversation.title = newTitle;
|
||||
conversation.updatedAt = Date.now();
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function updateConversationSettings(
|
||||
id: string,
|
||||
convSettings: ConversationSettings,
|
||||
) {
|
||||
const conversation = conversations.value.find((c) => c.id === id);
|
||||
if (conversation) {
|
||||
conversation.settings = { ...conversation.settings, ...convSettings };
|
||||
conversation.updatedAt = Date.now();
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(
|
||||
role: MessageRole,
|
||||
content: MessageContent,
|
||||
conversationId?: string,
|
||||
): Message {
|
||||
const targetId = conversationId || currentConversationId.value;
|
||||
|
||||
if (!targetId) {
|
||||
createConversation();
|
||||
}
|
||||
|
||||
const conversation = conversations.value.find(
|
||||
(c) => c.id === (targetId || currentConversationId.value),
|
||||
);
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error("Conversation not found");
|
||||
}
|
||||
|
||||
const message: any = {
|
||||
id: generateId(),
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
isStreaming: false,
|
||||
};
|
||||
|
||||
conversation.messages.push(message);
|
||||
conversation.updatedAt = Date.now();
|
||||
|
||||
if (
|
||||
role === MessageRole.USER &&
|
||||
conversation.messages.length === 1 &&
|
||||
content.text
|
||||
) {
|
||||
conversation.title = extractTitleFromMessage(content.text);
|
||||
}
|
||||
|
||||
saveToStorage();
|
||||
return message;
|
||||
}
|
||||
|
||||
function updateMessage(messageId: string, updates: Partial<Message>) {
|
||||
const conversation = currentConversation.value;
|
||||
if (!conversation) return;
|
||||
|
||||
const message = conversation.messages.find((m) => m.id === messageId);
|
||||
if (message) {
|
||||
Object.assign(message, updates);
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function updateMessageContent(messageId: string, text: string) {
|
||||
const conversation = currentConversation.value;
|
||||
if (!conversation) return;
|
||||
|
||||
const message = conversation.messages.find((m) => m.id === messageId);
|
||||
if (message) {
|
||||
message.content.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
function setMessageFeedback(
|
||||
messageId: string,
|
||||
feedback: "like" | "dislike" | null,
|
||||
) {
|
||||
const conversation = currentConversation.value;
|
||||
if (!conversation) return;
|
||||
|
||||
const message = conversation.messages.find((m) => m.id === messageId);
|
||||
if (message) {
|
||||
message.feedback = {
|
||||
liked: feedback === "like",
|
||||
disliked: feedback === "dislike",
|
||||
copied: message.feedback?.copied,
|
||||
};
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function setMessageCopied(messageId: string) {
|
||||
const conversation = currentConversation.value;
|
||||
if (!conversation) return;
|
||||
|
||||
const message = conversation.messages.find((m) => m.id === messageId);
|
||||
if (message) {
|
||||
message.feedback = {
|
||||
...message.feedback,
|
||||
copied: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function startStreaming() {
|
||||
isStreaming.value = true;
|
||||
streamController.value = new AbortController();
|
||||
}
|
||||
|
||||
function stopStreaming() {
|
||||
isStreaming.value = false;
|
||||
if (streamController.value) {
|
||||
streamController.value.abort();
|
||||
streamController.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearConversation(id: string) {
|
||||
const conversation = conversations.value.find((c) => c.id === id);
|
||||
if (conversation) {
|
||||
conversation.messages = [];
|
||||
conversation.updatedAt = Date.now();
|
||||
saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"chat-conversations",
|
||||
JSON.stringify(conversations.value),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"chat-current-id",
|
||||
currentConversationId.value || "",
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to save to storage:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem("chat-conversations");
|
||||
if (stored) {
|
||||
conversations.value = JSON.parse(stored);
|
||||
}
|
||||
|
||||
const storedId = localStorage.getItem("chat-current-id");
|
||||
if (storedId && conversations.value.find((c) => c.id === storedId)) {
|
||||
currentConversationId.value = storedId;
|
||||
} else if (conversations.value.length > 0) {
|
||||
currentConversationId.value = conversations.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load from storage:", e);
|
||||
}
|
||||
}
|
||||
|
||||
loadFromStorage();
|
||||
|
||||
return {
|
||||
conversations,
|
||||
currentConversationId,
|
||||
isStreaming,
|
||||
streamController,
|
||||
currentConversation,
|
||||
sortedConversations,
|
||||
pinnedConversations,
|
||||
recentConversations,
|
||||
createConversation,
|
||||
deleteConversation,
|
||||
selectConversation,
|
||||
togglePinConversation,
|
||||
renameConversation,
|
||||
updateConversationSettings,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
updateMessageContent,
|
||||
setMessageFeedback,
|
||||
setMessageCopied,
|
||||
startStreaming,
|
||||
stopStreaming,
|
||||
clearConversation,
|
||||
loadFromStorage,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,304 +1,310 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { AppSettings, AIModel } from '@/types/chat'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// 默认设置
|
||||
const defaultSettings: AppSettings = {
|
||||
// 外观设置
|
||||
theme: 'system',
|
||||
language: 'zh-CN',
|
||||
fontSize: 'medium',
|
||||
|
||||
// 对话设置
|
||||
sendOnEnter: false,
|
||||
showTimestamp: true,
|
||||
compactMode: false,
|
||||
|
||||
// AI 默认设置
|
||||
defaultModel: 'glm-4.6',
|
||||
defaultTemperature: 0.7,
|
||||
defaultMaxTokens: 4096,
|
||||
defaultSystemPrompt: '你是一个有帮助的 AI 助手。',
|
||||
|
||||
// 功能设置
|
||||
enableSound: true,
|
||||
enableNotification: true,
|
||||
autoSaveInterval: 30,
|
||||
|
||||
// 隐私设置
|
||||
saveHistory: true,
|
||||
shareAnalytics: false,
|
||||
}
|
||||
|
||||
// 可用的 AI 模型
|
||||
const availableModels: AIModel[] = [
|
||||
{
|
||||
id: "glm-4.6",
|
||||
name: "智普 GLM-4.6",
|
||||
description: "最强大的模型",
|
||||
maxTokens: 8192,
|
||||
provider: "Zhipu",
|
||||
},
|
||||
{
|
||||
id: "glm-4.5",
|
||||
name: "智普 GLM-4.5",
|
||||
description: "能力均衡",
|
||||
maxTokens: 8192,
|
||||
provider: "Zhipu",
|
||||
},
|
||||
{
|
||||
id: 'glm-4-flash',
|
||||
name: '智普 GLM-4-Flash',
|
||||
description: '快速高效,适合日常对话',
|
||||
maxTokens: 8192,
|
||||
provider: 'Zhipu',
|
||||
},
|
||||
{
|
||||
id: 'glm-4v-plus',
|
||||
name: '智普 GLM-4V-Plus',
|
||||
description: '强大的视觉理解模型',
|
||||
maxTokens: 8192,
|
||||
provider: 'Zhipu',
|
||||
},
|
||||
]
|
||||
|
||||
// 状态
|
||||
const settings = ref<AppSettings>({ ...defaultSettings })
|
||||
const sidebarCollapsed = ref(false)
|
||||
const sidebarWidth = ref(280)
|
||||
const showShortcutsModal = ref(false)
|
||||
const showSearchModal = ref(false)
|
||||
const showSettingsModal = ref(false)
|
||||
const showConversationSettingsModal = ref(false)
|
||||
|
||||
// 主题相关
|
||||
function applyTheme(theme: AppSettings['theme']) {
|
||||
const root = document.documentElement
|
||||
|
||||
if (theme === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
root.classList.toggle('dark', prefersDark)
|
||||
} else {
|
||||
root.classList.toggle('dark', theme === 'dark')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const themes: AppSettings['theme'][] = ['light', 'dark', 'system']
|
||||
const currentIndex = themes.indexOf(settings.value.theme)
|
||||
settings.value.theme = themes[(currentIndex + 1) % themes.length]
|
||||
applyTheme(settings.value.theme)
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
function setTheme(theme: AppSettings['theme']) {
|
||||
settings.value.theme = theme
|
||||
applyTheme(theme)
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
// 字体大小
|
||||
function applyFontSize(size: AppSettings['fontSize']) {
|
||||
const root = document.documentElement
|
||||
const sizeMap = {
|
||||
small: '14px',
|
||||
medium: '16px',
|
||||
large: '18px',
|
||||
}
|
||||
root.style.setProperty('--base-font-size', sizeMap[size])
|
||||
}
|
||||
|
||||
function setFontSize(size: AppSettings['fontSize']) {
|
||||
settings.value.fontSize = size
|
||||
applyFontSize(size)
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
// 侧边栏
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
function setSidebarWidth(width: number) {
|
||||
sidebarWidth.value = Math.max(200, Math.min(400, width))
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
// 模态框
|
||||
function openShortcutsModal() {
|
||||
showShortcutsModal.value = true
|
||||
}
|
||||
|
||||
function closeShortcutsModal() {
|
||||
showShortcutsModal.value = false
|
||||
}
|
||||
|
||||
function openSearchModal() {
|
||||
showSearchModal.value = true
|
||||
}
|
||||
|
||||
function closeSearchModal() {
|
||||
showSearchModal.value = false
|
||||
}
|
||||
|
||||
function openSettingsModal() {
|
||||
showSettingsModal.value = true
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
showSettingsModal.value = false
|
||||
}
|
||||
|
||||
function openConversationSettingsModal() {
|
||||
showConversationSettingsModal.value = true
|
||||
}
|
||||
|
||||
function closeConversationSettingsModal() {
|
||||
showConversationSettingsModal.value = false
|
||||
}
|
||||
|
||||
// 更新设置
|
||||
function updateSettings(updates: Partial<AppSettings>) {
|
||||
Object.assign(settings.value, updates)
|
||||
|
||||
if (updates.theme) {
|
||||
applyTheme(updates.theme)
|
||||
}
|
||||
|
||||
if (updates.fontSize) {
|
||||
applyFontSize(updates.fontSize)
|
||||
}
|
||||
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
// 重置设置
|
||||
function resetSettings() {
|
||||
settings.value = { ...defaultSettings }
|
||||
applyTheme(settings.value.theme)
|
||||
applyFontSize(settings.value.fontSize)
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
// 导出设置
|
||||
function exportSettings(): string {
|
||||
return JSON.stringify(settings.value, null, 2)
|
||||
}
|
||||
|
||||
// 导入设置
|
||||
function importSettings(json: string): boolean {
|
||||
try {
|
||||
const imported = JSON.parse(json)
|
||||
settings.value = { ...defaultSettings, ...imported }
|
||||
applyTheme(settings.value.theme)
|
||||
applyFontSize(settings.value.fontSize)
|
||||
saveToStorage()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 存储
|
||||
function saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem('chat-settings', JSON.stringify(settings.value))
|
||||
localStorage.setItem('chat-sidebar-collapsed', JSON.stringify(sidebarCollapsed.value))
|
||||
localStorage.setItem('chat-sidebar-width', JSON.stringify(sidebarWidth.value))
|
||||
} catch (e) {
|
||||
console.error('Failed to save settings:', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 存储选中模型 ID 的 localStorage key
|
||||
const MODEL_ID_KEY = 'modelSelectId'
|
||||
|
||||
// 获取当前选择的模型 ID
|
||||
function getSelectedModelId(): string {
|
||||
|
||||
return defaultSettings.defaultModel
|
||||
}
|
||||
|
||||
// 设置当前选择的模型 ID
|
||||
function setSelectedModelId(modelId: string) {
|
||||
localStorage.setItem(MODEL_ID_KEY, modelId)
|
||||
// 同时更新 settings 中的 defaultModel
|
||||
settings.value.defaultModel = modelId
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem('chat-settings')
|
||||
if (stored) {
|
||||
settings.value = { ...defaultSettings, ...JSON.parse(stored) }
|
||||
}
|
||||
|
||||
const collapsedStored = localStorage.getItem('chat-sidebar-collapsed')
|
||||
if (collapsedStored) {
|
||||
sidebarCollapsed.value = JSON.parse(collapsedStored)
|
||||
}
|
||||
|
||||
const widthStored = localStorage.getItem('chat-sidebar-width')
|
||||
if (widthStored) {
|
||||
sidebarWidth.value = JSON.parse(widthStored)
|
||||
}
|
||||
|
||||
// 应用主题和字体
|
||||
applyTheme(settings.value.theme)
|
||||
applyFontSize(settings.value.fontSize)
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
if (typeof window !== 'undefined') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaQuery.addEventListener('change', () => {
|
||||
if (settings.value.theme === 'system') {
|
||||
applyTheme('system')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadFromStorage()
|
||||
|
||||
return {
|
||||
// 状态
|
||||
settings,
|
||||
sidebarCollapsed,
|
||||
sidebarWidth,
|
||||
showShortcutsModal,
|
||||
showSearchModal,
|
||||
showSettingsModal,
|
||||
showConversationSettingsModal,
|
||||
availableModels,
|
||||
|
||||
// 方法
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
setFontSize,
|
||||
toggleSidebar,
|
||||
setSidebarWidth,
|
||||
openShortcutsModal,
|
||||
closeShortcutsModal,
|
||||
openSearchModal,
|
||||
closeSearchModal,
|
||||
openSettingsModal,
|
||||
closeSettingsModal,
|
||||
openConversationSettingsModal,
|
||||
closeConversationSettingsModal,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
exportSettings,
|
||||
importSettings,
|
||||
loadFromStorage,
|
||||
getSelectedModelId,
|
||||
setSelectedModelId,
|
||||
}
|
||||
})
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import type { AppSettings, AIModel } from "@/types/chat";
|
||||
|
||||
export const useSettingsStore = defineStore("settings", () => {
|
||||
// 默认设置
|
||||
const defaultSettings: AppSettings = {
|
||||
// 外观设置
|
||||
theme: "system",
|
||||
language: "zh-CN",
|
||||
fontSize: "medium",
|
||||
|
||||
// 对话设置
|
||||
sendOnEnter: false,
|
||||
showTimestamp: true,
|
||||
compactMode: false,
|
||||
|
||||
// AI 默认设置
|
||||
defaultModel: "glm-4.6",
|
||||
defaultTemperature: 0.7,
|
||||
defaultMaxTokens: 4096,
|
||||
defaultSystemPrompt: "你是一个有帮助的 AI 助手。",
|
||||
|
||||
// 功能设置
|
||||
enableSound: true,
|
||||
enableNotification: true,
|
||||
autoSaveInterval: 30,
|
||||
|
||||
// 隐私设置
|
||||
saveHistory: true,
|
||||
shareAnalytics: false,
|
||||
};
|
||||
|
||||
// 可用的 AI 模型
|
||||
const availableModels: AIModel[] = [
|
||||
{
|
||||
id: "glm-4.6",
|
||||
name: "智普 GLM-4.6",
|
||||
description: "最强大的模型",
|
||||
maxTokens: 8192,
|
||||
provider: "Zhipu",
|
||||
},
|
||||
{
|
||||
id: "glm-4.5",
|
||||
name: "智普 GLM-4.5",
|
||||
description: "能力均衡",
|
||||
maxTokens: 8192,
|
||||
provider: "Zhipu",
|
||||
},
|
||||
{
|
||||
id: "glm-4-flash",
|
||||
name: "智普 GLM-4-Flash",
|
||||
description: "快速高效,适合日常对话",
|
||||
maxTokens: 8192,
|
||||
provider: "Zhipu",
|
||||
},
|
||||
{
|
||||
id: "glm-4v-plus",
|
||||
name: "智普 GLM-4V-Plus",
|
||||
description: "强大的视觉理解模型",
|
||||
maxTokens: 8192,
|
||||
provider: "Zhipu",
|
||||
},
|
||||
];
|
||||
|
||||
// 状态
|
||||
const settings = ref<AppSettings>({ ...defaultSettings });
|
||||
const sidebarCollapsed = ref(false);
|
||||
const sidebarWidth = ref(280);
|
||||
const showShortcutsModal = ref(false);
|
||||
const showSearchModal = ref(false);
|
||||
const showSettingsModal = ref(false);
|
||||
const showConversationSettingsModal = ref(false);
|
||||
|
||||
// 主题相关
|
||||
function applyTheme(theme: AppSettings["theme"]) {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme === "system") {
|
||||
const prefersDark = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches;
|
||||
root.classList.toggle("dark", prefersDark);
|
||||
} else {
|
||||
root.classList.toggle("dark", theme === "dark");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const themes: AppSettings["theme"][] = ["light", "dark", "system"];
|
||||
const currentIndex = themes.indexOf(settings.value.theme);
|
||||
settings.value.theme = themes[(currentIndex + 1) % themes.length];
|
||||
applyTheme(settings.value.theme);
|
||||
saveToStorage();
|
||||
}
|
||||
|
||||
function setTheme(theme: AppSettings["theme"]) {
|
||||
settings.value.theme = theme;
|
||||
applyTheme(theme);
|
||||
saveToStorage();
|
||||
}
|
||||
|
||||
// 字体大小
|
||||
function applyFontSize(size: AppSettings["fontSize"]) {
|
||||
const root = document.documentElement;
|
||||
const sizeMap = {
|
||||
small: "14px",
|
||||
medium: "16px",
|
||||
large: "18px",
|
||||
};
|
||||
root.style.setProperty("--base-font-size", sizeMap[size]);
|
||||
}
|
||||
|
||||
function setFontSize(size: AppSettings["fontSize"]) {
|
||||
settings.value.fontSize = size;
|
||||
applyFontSize(size);
|
||||
saveToStorage();
|
||||
}
|
||||
|
||||
// 侧边栏
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
saveToStorage();
|
||||
}
|
||||
|
||||
function setSidebarWidth(width: number) {
|
||||
sidebarWidth.value = Math.max(200, Math.min(400, width));
|
||||
saveToStorage();
|
||||
}
|
||||
|
||||
// 模态框
|
||||
function openShortcutsModal() {
|
||||
showShortcutsModal.value = true;
|
||||
}
|
||||
|
||||
function closeShortcutsModal() {
|
||||
showShortcutsModal.value = false;
|
||||
}
|
||||
|
||||
function openSearchModal() {
|
||||
showSearchModal.value = true;
|
||||
}
|
||||
|
||||
function closeSearchModal() {
|
||||
showSearchModal.value = false;
|
||||
}
|
||||
|
||||
function openSettingsModal() {
|
||||
showSettingsModal.value = true;
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
showSettingsModal.value = false;
|
||||
}
|
||||
|
||||
function openConversationSettingsModal() {
|
||||
showConversationSettingsModal.value = true;
|
||||
}
|
||||
|
||||
function closeConversationSettingsModal() {
|
||||
showConversationSettingsModal.value = false;
|
||||
}
|
||||
|
||||
// 更新设置
|
||||
function updateSettings(updates: Partial<AppSettings>) {
|
||||
Object.assign(settings.value, updates);
|
||||
|
||||
if (updates.theme) {
|
||||
applyTheme(updates.theme);
|
||||
}
|
||||
|
||||
if (updates.fontSize) {
|
||||
applyFontSize(updates.fontSize);
|
||||
}
|
||||
|
||||
saveToStorage();
|
||||
}
|
||||
|
||||
// 重置设置
|
||||
function resetSettings() {
|
||||
settings.value = { ...defaultSettings };
|
||||
applyTheme(settings.value.theme);
|
||||
applyFontSize(settings.value.fontSize);
|
||||
saveToStorage();
|
||||
}
|
||||
|
||||
// 导出设置
|
||||
function exportSettings(): string {
|
||||
return JSON.stringify(settings.value, null, 2);
|
||||
}
|
||||
|
||||
// 导入设置
|
||||
function importSettings(json: string): boolean {
|
||||
try {
|
||||
const imported = JSON.parse(json);
|
||||
settings.value = { ...defaultSettings, ...imported };
|
||||
applyTheme(settings.value.theme);
|
||||
applyFontSize(settings.value.fontSize);
|
||||
saveToStorage();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 存储
|
||||
function saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem("chat-settings", JSON.stringify(settings.value));
|
||||
localStorage.setItem(
|
||||
"chat-sidebar-collapsed",
|
||||
JSON.stringify(sidebarCollapsed.value),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"chat-sidebar-width",
|
||||
JSON.stringify(sidebarWidth.value),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to save settings:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 存储选中模型 ID 的 localStorage key
|
||||
const MODEL_ID_KEY = "modelSelectId";
|
||||
|
||||
// 获取当前选择的模型 ID
|
||||
function getSelectedModelId(): string {
|
||||
return defaultSettings.defaultModel;
|
||||
}
|
||||
|
||||
// 设置当前选择的模型 ID
|
||||
function setSelectedModelId(modelId: string) {
|
||||
localStorage.setItem(MODEL_ID_KEY, modelId);
|
||||
// 同时更新 settings 中的 defaultModel
|
||||
settings.value.defaultModel = modelId;
|
||||
saveToStorage();
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem("chat-settings");
|
||||
if (stored) {
|
||||
settings.value = { ...defaultSettings, ...JSON.parse(stored) };
|
||||
}
|
||||
|
||||
const collapsedStored = localStorage.getItem("chat-sidebar-collapsed");
|
||||
if (collapsedStored) {
|
||||
sidebarCollapsed.value = JSON.parse(collapsedStored);
|
||||
}
|
||||
|
||||
const widthStored = localStorage.getItem("chat-sidebar-width");
|
||||
if (widthStored) {
|
||||
sidebarWidth.value = JSON.parse(widthStored);
|
||||
}
|
||||
|
||||
// 应用主题和字体
|
||||
applyTheme(settings.value.theme);
|
||||
applyFontSize(settings.value.fontSize);
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
if (typeof window !== "undefined") {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", () => {
|
||||
if (settings.value.theme === "system") {
|
||||
applyTheme("system");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadFromStorage();
|
||||
|
||||
return {
|
||||
// 状态
|
||||
settings,
|
||||
sidebarCollapsed,
|
||||
sidebarWidth,
|
||||
showShortcutsModal,
|
||||
showSearchModal,
|
||||
showSettingsModal,
|
||||
showConversationSettingsModal,
|
||||
availableModels,
|
||||
|
||||
// 方法
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
setFontSize,
|
||||
toggleSidebar,
|
||||
setSidebarWidth,
|
||||
openShortcutsModal,
|
||||
closeShortcutsModal,
|
||||
openSearchModal,
|
||||
closeSearchModal,
|
||||
openSettingsModal,
|
||||
closeSettingsModal,
|
||||
openConversationSettingsModal,
|
||||
closeConversationSettingsModal,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
exportSettings,
|
||||
importSettings,
|
||||
loadFromStorage,
|
||||
getSelectedModelId,
|
||||
setSelectedModelId,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
158
src/style.css
158
src/style.css
|
|
@ -1,79 +1,79 @@
|
|||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +1,84 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
// 自定义滚动条
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(155, 155, 155, 0.5);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(155, 155, 155, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式滚动条
|
||||
.dark {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 100, 100, 0.5);
|
||||
|
||||
&:hover {
|
||||
background: rgba(100, 100, 100, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局变量
|
||||
:root {
|
||||
--chat-sidebar-width: 280px;
|
||||
--chat-input-height: 140px;
|
||||
--header-height: 60px;
|
||||
}
|
||||
|
||||
// 基础样式重置
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
// 自定义滚动条
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(155, 155, 155, 0.5);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(155, 155, 155, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式滚动条
|
||||
.dark {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 100, 100, 0.5);
|
||||
|
||||
&:hover {
|
||||
background: rgba(100, 100, 100, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局变量
|
||||
:root {
|
||||
--chat-sidebar-width: 280px;
|
||||
--chat-input-height: 140px;
|
||||
--header-height: 60px;
|
||||
}
|
||||
|
||||
// 基础样式重置
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,147 +1,147 @@
|
|||
// 消息类型枚举
|
||||
export enum MessageType {
|
||||
TEXT = "text",
|
||||
IMAGE = "image",
|
||||
VIDEO = "video",
|
||||
MULTI_VIDEO = "multi_video",
|
||||
FILE = "file",
|
||||
CODE = "code",
|
||||
SUGGESTION = "suggestion",
|
||||
THINKING = "thinking",
|
||||
}
|
||||
|
||||
// 消息角色
|
||||
export enum MessageRole {
|
||||
USER = "user",
|
||||
ASSISTANT = "assistant",
|
||||
SYSTEM = "system",
|
||||
}
|
||||
|
||||
// 附件类型
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "image" | "file" | "video";
|
||||
url: string;
|
||||
size?: number;
|
||||
mimeType?: string;
|
||||
thumbnail?: string;
|
||||
uploading?: boolean; // 标记附件是否正在上传中
|
||||
}
|
||||
|
||||
// 推荐选项
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
text: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
// 视频信息
|
||||
export interface VideoInfo {
|
||||
id: string;
|
||||
url: string;
|
||||
poster?: string;
|
||||
title?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// 消息内容
|
||||
export interface MessageContent {
|
||||
type: MessageType;
|
||||
text?: string;
|
||||
images?: Attachment[];
|
||||
videos?: VideoInfo[];
|
||||
files?: Attachment[];
|
||||
suggestions?: Suggestion[];
|
||||
codeLanguage?: string;
|
||||
}
|
||||
|
||||
// 消息反馈
|
||||
export interface MessageFeedback {
|
||||
liked?: boolean;
|
||||
disliked?: boolean;
|
||||
copied?: boolean;
|
||||
}
|
||||
|
||||
// 单条消息
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: MessageContent;
|
||||
timestamp: number;
|
||||
feedback?: MessageFeedback;
|
||||
isStreaming?: boolean;
|
||||
isError?: boolean;
|
||||
isEnd?: boolean;
|
||||
isBreak?: boolean;
|
||||
errorMessage?: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
// 对话设置
|
||||
export interface ConversationSettings {
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
systemPrompt: string;
|
||||
enableMemory: boolean;
|
||||
memoryLength: number;
|
||||
}
|
||||
|
||||
// 对话
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
pinned?: boolean;
|
||||
archived?: boolean;
|
||||
settings?: ConversationSettings;
|
||||
}
|
||||
|
||||
// 输入框状态
|
||||
export interface InputState {
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
isDeepSearch: boolean;
|
||||
isWebSearch: boolean;
|
||||
}
|
||||
|
||||
// 应用设置
|
||||
export interface AppSettings {
|
||||
// 外观设置
|
||||
theme: "light" | "dark" | "system";
|
||||
language: string;
|
||||
fontSize: "small" | "medium" | "large";
|
||||
|
||||
// 对话设置
|
||||
sendOnEnter: boolean;
|
||||
showTimestamp: boolean;
|
||||
compactMode: boolean;
|
||||
|
||||
// AI 默认设置
|
||||
defaultModel: string;
|
||||
defaultTemperature: number;
|
||||
defaultMaxTokens: number;
|
||||
defaultSystemPrompt: string;
|
||||
|
||||
// 功能设置
|
||||
enableSound: boolean;
|
||||
enableNotification: boolean;
|
||||
autoSaveInterval: number;
|
||||
|
||||
// 隐私设置
|
||||
saveHistory: boolean;
|
||||
shareAnalytics: boolean;
|
||||
}
|
||||
|
||||
// AI 模型配置
|
||||
export interface AIModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
maxTokens: number;
|
||||
provider: string;
|
||||
icon?: string;
|
||||
}
|
||||
// 消息类型枚举
|
||||
export enum MessageType {
|
||||
TEXT = "text",
|
||||
IMAGE = "image",
|
||||
VIDEO = "video",
|
||||
MULTI_VIDEO = "multi_video",
|
||||
FILE = "file",
|
||||
CODE = "code",
|
||||
SUGGESTION = "suggestion",
|
||||
THINKING = "thinking",
|
||||
}
|
||||
|
||||
// 消息角色
|
||||
export enum MessageRole {
|
||||
USER = "user",
|
||||
ASSISTANT = "assistant",
|
||||
SYSTEM = "system",
|
||||
}
|
||||
|
||||
// 附件类型
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "image" | "file" | "video";
|
||||
url: string;
|
||||
size?: number;
|
||||
mimeType?: string;
|
||||
thumbnail?: string;
|
||||
uploading?: boolean; // 标记附件是否正在上传中
|
||||
}
|
||||
|
||||
// 推荐选项
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
text: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
// 视频信息
|
||||
export interface VideoInfo {
|
||||
id: string;
|
||||
url: string;
|
||||
poster?: string;
|
||||
title?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// 消息内容
|
||||
export interface MessageContent {
|
||||
type: MessageType;
|
||||
text?: string;
|
||||
images?: Attachment[];
|
||||
videos?: VideoInfo[];
|
||||
files?: Attachment[];
|
||||
suggestions?: Suggestion[];
|
||||
codeLanguage?: string;
|
||||
}
|
||||
|
||||
// 消息反馈
|
||||
export interface MessageFeedback {
|
||||
liked?: boolean;
|
||||
disliked?: boolean;
|
||||
copied?: boolean;
|
||||
}
|
||||
|
||||
// 单条消息
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: MessageContent;
|
||||
timestamp: number;
|
||||
feedback?: MessageFeedback;
|
||||
isStreaming?: boolean;
|
||||
isError?: boolean;
|
||||
isEnd?: boolean;
|
||||
isBreak?: boolean;
|
||||
errorMessage?: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
// 对话设置
|
||||
export interface ConversationSettings {
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
systemPrompt: string;
|
||||
enableMemory: boolean;
|
||||
memoryLength: number;
|
||||
}
|
||||
|
||||
// 对话
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
pinned?: boolean;
|
||||
archived?: boolean;
|
||||
settings?: ConversationSettings;
|
||||
}
|
||||
|
||||
// 输入框状态
|
||||
export interface InputState {
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
isDeepSearch: boolean;
|
||||
isWebSearch: boolean;
|
||||
}
|
||||
|
||||
// 应用设置
|
||||
export interface AppSettings {
|
||||
// 外观设置
|
||||
theme: "light" | "dark" | "system";
|
||||
language: string;
|
||||
fontSize: "small" | "medium" | "large";
|
||||
|
||||
// 对话设置
|
||||
sendOnEnter: boolean;
|
||||
showTimestamp: boolean;
|
||||
compactMode: boolean;
|
||||
|
||||
// AI 默认设置
|
||||
defaultModel: string;
|
||||
defaultTemperature: number;
|
||||
defaultMaxTokens: number;
|
||||
defaultSystemPrompt: string;
|
||||
|
||||
// 功能设置
|
||||
enableSound: boolean;
|
||||
enableNotification: boolean;
|
||||
autoSaveInterval: number;
|
||||
|
||||
// 隐私设置
|
||||
saveHistory: boolean;
|
||||
shareAnalytics: boolean;
|
||||
}
|
||||
|
||||
// AI 模型配置
|
||||
export interface AIModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
maxTokens: number;
|
||||
provider: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +1,148 @@
|
|||
// 删除未使用的 nanoid 导入,使用自定义实现
|
||||
|
||||
// 生成唯一ID
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// 格式化时间戳
|
||||
export function formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 60 * 1000) {
|
||||
return "刚刚";
|
||||
}
|
||||
|
||||
if (diff < 60 * 60 * 1000) {
|
||||
const minutes = Math.floor(diff / (60 * 1000));
|
||||
return `${minutes}分钟前`;
|
||||
}
|
||||
|
||||
if (diff < 24 * 60 * 60 * 1000) {
|
||||
const hours = Math.floor(diff / (60 * 60 * 1000));
|
||||
return `${hours}小时前`;
|
||||
}
|
||||
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日 ${padZero(date.getHours())}:${padZero(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
}
|
||||
|
||||
function padZero(num: number): string {
|
||||
return num < 10 ? `0${num}` : `${num}`;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTitleFromMessage(message: string): string {
|
||||
const firstLine = message.split("\n")[0].trim();
|
||||
return truncateText(firstLine, 30) || "新对话";
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => ReturnType<T> | undefined {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
return function (this: any, ...args: Parameters<T>): any {
|
||||
const context = this;
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
return fn.apply(context, args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
limit: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle = false;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!inThrottle) {
|
||||
fn(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => {
|
||||
inThrottle = false;
|
||||
}, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getFileIcon(mimeType: string): string {
|
||||
if (mimeType.startsWith("image/")) return "🖼️";
|
||||
if (mimeType.startsWith("video/")) return "🎬";
|
||||
if (mimeType.startsWith("audio/")) return "🎵";
|
||||
if (mimeType.includes("pdf")) return "📄";
|
||||
if (mimeType.includes("word") || mimeType.includes("document")) return "📝";
|
||||
if (mimeType.includes("excel") || mimeType.includes("spreadsheet"))
|
||||
return "📊";
|
||||
if (mimeType.includes("powerpoint") || mimeType.includes("presentation"))
|
||||
return "📽️";
|
||||
if (
|
||||
mimeType.includes("zip") ||
|
||||
mimeType.includes("rar") ||
|
||||
mimeType.includes("7z")
|
||||
)
|
||||
return "📦";
|
||||
return "📎";
|
||||
}
|
||||
|
||||
export function detectCodeLanguage(code: string): string {
|
||||
if (code.includes("import React") || code.includes("jsx")) return "jsx";
|
||||
if (code.includes("<template>") || code.includes("defineComponent"))
|
||||
return "vue";
|
||||
if (code.includes("func ") && code.includes("package ")) return "go";
|
||||
if (code.includes("def ") && code.includes("import ")) return "python";
|
||||
if (code.includes("public class") || code.includes("private void"))
|
||||
return "java";
|
||||
if (code.includes("fn ") && code.includes("let mut")) return "rust";
|
||||
if (code.includes("interface ") || code.includes(": string"))
|
||||
return "typescript";
|
||||
return "javascript";
|
||||
}
|
||||
// 删除未使用的 nanoid 导入,使用自定义实现
|
||||
|
||||
// 生成唯一ID
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// 格式化时间戳
|
||||
export function formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 60 * 1000) {
|
||||
return "刚刚";
|
||||
}
|
||||
|
||||
if (diff < 60 * 60 * 1000) {
|
||||
const minutes = Math.floor(diff / (60 * 1000));
|
||||
return `${minutes}分钟前`;
|
||||
}
|
||||
|
||||
if (diff < 24 * 60 * 60 * 1000) {
|
||||
const hours = Math.floor(diff / (60 * 60 * 1000));
|
||||
return `${hours}小时前`;
|
||||
}
|
||||
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日 ${padZero(date.getHours())}:${padZero(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
}
|
||||
|
||||
function padZero(num: number): string {
|
||||
return num < 10 ? `0${num}` : `${num}`;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTitleFromMessage(message: string): string {
|
||||
const firstLine = message.split("\n")[0].trim();
|
||||
return truncateText(firstLine, 30) || "新对话";
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => ReturnType<T> | undefined {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
return function (this: any, ...args: Parameters<T>): any {
|
||||
const context = this;
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
return fn.apply(context, args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
limit: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle = false;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!inThrottle) {
|
||||
fn(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => {
|
||||
inThrottle = false;
|
||||
}, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getFileIcon(mimeType: string): string {
|
||||
if (mimeType.startsWith("image/")) return "🖼️";
|
||||
if (mimeType.startsWith("video/")) return "🎬";
|
||||
if (mimeType.startsWith("audio/")) return "🎵";
|
||||
if (mimeType.includes("pdf")) return "📄";
|
||||
if (mimeType.includes("word") || mimeType.includes("document")) return "📝";
|
||||
if (mimeType.includes("excel") || mimeType.includes("spreadsheet"))
|
||||
return "📊";
|
||||
if (mimeType.includes("powerpoint") || mimeType.includes("presentation"))
|
||||
return "📽️";
|
||||
if (
|
||||
mimeType.includes("zip") ||
|
||||
mimeType.includes("rar") ||
|
||||
mimeType.includes("7z")
|
||||
)
|
||||
return "📦";
|
||||
return "📎";
|
||||
}
|
||||
|
||||
export function detectCodeLanguage(code: string): string {
|
||||
if (code.includes("import React") || code.includes("jsx")) return "jsx";
|
||||
if (code.includes("<template>") || code.includes("defineComponent"))
|
||||
return "vue";
|
||||
if (code.includes("func ") && code.includes("package ")) return "go";
|
||||
if (code.includes("def ") && code.includes("import ")) return "python";
|
||||
if (code.includes("public class") || code.includes("private void"))
|
||||
return "java";
|
||||
if (code.includes("fn ") && code.includes("let mut")) return "rust";
|
||||
if (code.includes("interface ") || code.includes(": string"))
|
||||
return "typescript";
|
||||
return "javascript";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue