Compare commits

...

9 Commits

Author SHA1 Message Date
肖应宇 b66bdaedd2 Merge branch 'feat/share' 2026-03-26 10:45:08 +08:00
肖应宇 1df9ee3cf2 fix: 移除路由 base 路径并优化图片展示布局
- Vue Router base 路径从 /chat-ui/ 改为 /
- MessageBubble 添加 readonly prop,分享页面隐藏操作栏
- 图片网格改用 inline-flex 布局,按内容宽度收缩

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 10:17:58 +08:00
肖应宇 379e033e17 fix(oss): 统一从 .env 文件读取所有配置
将 AccessKey 从系统环境变量改为从 .env 文件读取,
与其他 OSS 配置保持一致。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 09:15:33 +08:00
肖应宇 eff089c7ad fix(share): 修复分享链接路径不一致并移除对话消息截断 2026-03-25 16:41:32 +08:00
肖应宇 7b4fb72cdc feat: 消息级别分享功能
- 新增消息选择模式,支持在当前对话内多选消息分享
- MessageBubble 添加选择模式UI(复选框、选中样式)
- MessageList 添加选择操作栏(全选、取消、确认分享)
- ShareModal 支持消息分享和对话分享两种模式
- 后端分享API支持直接传递消息数据
- chat store 新增消息选择状态和方法

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:20:47 +08:00
肖应宇 9566c6e0c4 feat: 分享页面改为独立路由渲染
- 引入 vue-router,配置 / 和 /share/:id 两个路由
- 新增 ShareView.vue 独立页面,复用 MessageBubble 组件渲染消息
- 新增 HomeView.vue 提取主应用逻辑
- 分享链接格式改为 /chat-ui/share/{id}
- 删除废弃的 ShareViewModal.vue 对话框组件
- 清理 settingsStore 中废弃的 showShareViewModal 状态

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 15:38:38 +08:00
肖应宇 4d2caddeee feat: 分享对话功能;需要优化:不能分享单独几条对话,适用范围窄;在Dialog中展示对话,记录没有样式,很难看。 2026-03-25 15:12:50 +08:00
肖应宇 b51831dd15 build: build 通过 2026-03-12 14:23:23 +08:00
SuperManTouX 3b7a831840 Merge branch 'fix/web-search-and-css' 2026-03-12 14:13:22 +08:00
30 changed files with 4935 additions and 343 deletions

1705
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,10 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
@ -30,14 +33,18 @@
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^4.1.1",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"happy-dom": "^20.8.8",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"sass": "^1.97.3", "sass": "^1.97.3",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.2.4", "vite": "^7.2.4",
"vitest": "^4.1.1",
"vue-tsc": "^3.1.4" "vue-tsc": "^3.1.4"
} }
} }

230
server/api/share_routes.py Normal file
View File

@ -0,0 +1,230 @@
"""
分享功能路由 - 创建分享验证密码获取分享内容
"""
import json
import time
import uuid
from datetime import datetime, timezone
from typing import Dict, List, Optional
from fastapi import HTTPException
from pydantic import BaseModel
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent))
from database import get_db
from core import log_error, log_info
# ── 请求/响应模型 ─────────────────────────────────────────────────────
class CreateShareRequest(BaseModel):
"""创建分享请求"""
conversationIds: List[str] = []
messages: Optional[List[dict]] = None # 直接传递消息数据(消息分享模式)
passwordHash: str
expiresIn: Optional[int] = 604800 # 默认7天
class VerifyShareRequest(BaseModel):
"""验证分享请求"""
passwordHash: str
# ── 分享处理器 ──────────────────────────────────────────────────────────
async def create_share_handler(data: dict):
"""
创建分享
请求体:
{
"conversationIds": ["conv-1", "conv-2"],
"messages": [...], // 可选消息分享模式
"passwordHash": "sha256-hash",
"expiresIn": 604800
}
"""
try:
conversation_ids = data.get("conversationIds", [])
messages = data.get("messages") # 消息分享模式
password_hash = data.get("passwordHash", "")
expires_in = data.get("expiresIn", 604800)
if not password_hash:
raise HTTPException(status_code=400, detail="请设置访问密码")
# 获取数据库实例
db = get_db()
# 计算过期时间
now = int(datetime.now(timezone.utc).timestamp() * 1000)
expires_at = now + (expires_in * 1000)
# 消息分享模式
if messages:
if len(messages) == 0:
raise HTTPException(status_code=400, detail="请选择要分享的消息")
# 创建虚拟对话
virtual_conv_id = str(uuid.uuid4())
virtual_conversation = {
"id": virtual_conv_id,
"title": f"分享的消息 ({len(messages)}条)",
"messages": messages,
"createdAt": now,
"updatedAt": now,
}
# 创建分享记录
share_data = {
"conversationIds": [virtual_conv_id],
"conversations": [virtual_conversation],
"passwordHash": password_hash,
"expiresAt": expires_at,
}
# 保存到数据库
share = db.create_share(share_data)
log_info(f"[分享] 消息分享创建成功: {share['id']}, 消息数: {len(messages)}")
return {
"id": share["id"],
"shareUrl": f"/chat-ui/share/{share['id']}",
"expiresAt": share["expiresAt"],
}
# 对话分享模式
if not conversation_ids:
raise HTTPException(status_code=400, detail="请选择要分享的对话")
if len(conversation_ids) > 10:
raise HTTPException(status_code=400, detail="最多分享10个对话")
# 获取对话数据(快照)
conversations = []
for conv_id in conversation_ids:
conv = db.get_conversation(conv_id)
if conv:
# 创建对话快照(去除敏感信息)
conv_snapshot = {
"id": conv["id"],
"title": conv["title"],
"messages": conv.get("messages", []),
"createdAt": conv["createdAt"],
"updatedAt": conv["updatedAt"],
}
conversations.append(conv_snapshot)
if not conversations:
raise HTTPException(status_code=404, detail="未找到有效的对话")
# 创建分享记录
share_data = {
"conversationIds": conversation_ids,
"conversations": conversations,
"passwordHash": password_hash,
"expiresAt": expires_at,
}
# 保存到数据库
share = db.create_share(share_data)
log_info(f"[分享] 创建成功: {share['id']}, 对话数: {len(conversations)}")
# 返回分享信息
return {
"id": share["id"],
"shareUrl": f"/chat-ui/share/{share['id']}",
"expiresAt": share["expiresAt"],
}
except HTTPException:
raise
except Exception as e:
log_error(f"[分享] 创建失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"创建分享失败: {str(e)}")
async def get_share_handler(share_id: str):
"""
获取分享基本信息不含内容
用于显示分享预览检查是否过期等
"""
db = get_db()
share = db.get_share(share_id)
if not share:
raise HTTPException(status_code=404, detail="分享不存在")
# 检查是否过期
now = int(datetime.now(timezone.utc).timestamp() * 1000)
is_expired = now > share["expiresAt"]
if is_expired:
# 删除过期分享
db.delete_share(share_id)
raise HTTPException(status_code=404, detail="分享已过期")
return {
"id": share["id"],
"hasPassword": bool(share["passwordHash"]),
"expiresAt": share["expiresAt"],
"isExpired": is_expired,
"conversationCount": len(share["conversationIds"]),
}
async def verify_share_handler(share_id: str, data: dict):
"""
验证密码并获取分享内容
请求体:
{
"passwordHash": "sha256-hash"
}
"""
db = get_db()
share = db.get_share(share_id)
if not share:
raise HTTPException(status_code=404, detail="分享不存在")
# 检查是否过期
now = int(datetime.now(timezone.utc).timestamp() * 1000)
if now > share["expiresAt"]:
db.delete_share(share_id)
raise HTTPException(status_code=404, detail="分享已过期")
# 验证密码
password_hash = data.get("passwordHash", "")
if password_hash != share["passwordHash"]:
log_info(f"[分享] 密码验证失败: {share_id}")
return {
"valid": False,
}
# 增加访问计数
view_count = db.update_share_view_count(share_id)
log_info(f"[分享] 验证成功: {share_id}, 访问次数: {view_count}")
# 返回分享内容
return {
"valid": True,
"share": {
"id": share["id"],
"conversationIds": share["conversationIds"],
"conversations": share["conversations"],
"createdAt": share["createdAt"],
"expiresAt": share["expiresAt"],
"viewCount": view_count,
"hasPassword": bool(share["passwordHash"]),
},
}

View File

@ -113,6 +113,25 @@ class Database:
ON conversations(user_id) ON conversations(user_id)
""") """)
# 创建分享表
cursor.execute("""
CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
conversation_ids TEXT NOT NULL,
conversations TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at INTEGER,
expires_at INTEGER,
view_count INTEGER DEFAULT 0
)
""")
# 创建分享过期时间索引
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_shares_expires
ON shares(expires_at)
""")
conn.commit() conn.commit()
# ── 会话 CRUD ───────────────────────────────────────────────────── # ── 会话 CRUD ─────────────────────────────────────────────────────
@ -384,6 +403,106 @@ class Database:
import uuid import uuid
return str(uuid.uuid4()) return str(uuid.uuid4())
# ── 分享 CRUD ───────────────────────────────────────────────────────
def create_share(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""创建分享"""
conn = self._get_connection()
cursor = conn.cursor()
now = int(datetime.now(timezone.utc).timestamp() * 1000)
share_id = data.get("id") or self._generate_share_id()
cursor.execute(
"""
INSERT INTO shares (id, conversation_ids, conversations, password_hash, created_at, expires_at, view_count)
VALUES (?, ?, ?, ?, ?, ?, 0)
""",
(
share_id,
json.dumps(data.get("conversationIds", [])),
json.dumps(data.get("conversations", [])),
data.get("passwordHash", ""),
now,
data.get("expiresAt", now + 604800000), # 默认7天
),
)
conn.commit()
return self.get_share(share_id)
def get_share(self, share_id: str) -> Optional[Dict[str, Any]]:
"""获取分享"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM shares WHERE id = ?", (share_id,))
row = cursor.fetchone()
if not row:
return None
return self._row_to_share(row)
def update_share_view_count(self, share_id: str) -> int:
"""增加分享访问计数"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE shares SET view_count = view_count + 1 WHERE id = ?",
(share_id,),
)
conn.commit()
# 返回更新后的计数
cursor.execute("SELECT view_count FROM shares WHERE id = ?", (share_id,))
row = cursor.fetchone()
return row["view_count"] if row else 0
def delete_share(self, share_id: str) -> bool:
"""删除分享"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM shares WHERE id = ?", (share_id,))
conn.commit()
return cursor.rowcount > 0
def cleanup_expired_shares(self) -> int:
"""清理过期分享"""
conn = self._get_connection()
cursor = conn.cursor()
now = int(datetime.now(timezone.utc).timestamp() * 1000)
cursor.execute("DELETE FROM shares WHERE expires_at < ?", (now,))
conn.commit()
deleted_count = cursor.rowcount
if deleted_count > 0:
print(f"[数据库] 已清理 {deleted_count} 个过期分享")
return deleted_count
def _row_to_share(self, row: sqlite3.Row) -> Dict[str, Any]:
"""将数据库行转换为分享字典"""
return {
"id": row["id"],
"conversationIds": json.loads(row["conversation_ids"]),
"conversations": json.loads(row["conversations"]),
"passwordHash": row["password_hash"],
"createdAt": row["created_at"],
"expiresAt": row["expires_at"],
"viewCount": row["view_count"],
}
def _generate_share_id(self) -> str:
"""生成分享 ID8位短链接"""
import uuid
return uuid.uuid4().hex[:8]
def init_db(): def init_db():
"""初始化数据库(应用启动时调用)""" """初始化数据库(应用启动时调用)"""

View File

@ -66,6 +66,11 @@ from api.conversation_routes import (add_message_handler,
update_message_handler, update_message_handler,
upload_file_handler) upload_file_handler)
# ── 分享功能路由处理器 ────────────────────────────────────────────────
from api.share_routes import (create_share_handler,
get_share_handler,
verify_share_handler)
# ── OpenAI 兼容网关初始化 ─────────────────────────────────────────────── # ── OpenAI 兼容网关初始化 ───────────────────────────────────────────────
from api.openai_gateway import init_adapters, router as openai_router from api.openai_gateway import init_adapters, router as openai_router
@ -113,6 +118,7 @@ async def health_check():
"openai_compatible": "/v1/chat/completions", "openai_compatible": "/v1/chat/completions",
"models": "/v1/models", "models": "/v1/models",
"conversations": "/api/chat-ui/conversations", "conversations": "/api/chat-ui/conversations",
"shares": "/api/chat-ui/shares",
}, },
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
} }
@ -233,6 +239,27 @@ async def delete_attachment(url: str):
return await delete_attachment_handler(url) return await delete_attachment_handler(url)
# ── 分享功能路由 ──────────────────────────────────────────────────────
@app.post("/api/chat-ui/shares")
async def create_share(request: Request):
"""创建分享"""
return await create_share_handler(await request.json())
@app.get("/api/chat-ui/shares/{share_id}")
async def get_share(share_id: str):
"""获取分享基本信息"""
return await get_share_handler(share_id)
@app.post("/api/chat-ui/shares/{share_id}/verify")
async def verify_share(share_id: str, request: Request):
"""验证密码并获取分享内容"""
return await verify_share_handler(share_id, await request.json())
# ── 程序入口 ────────────────────────────────────────────────────────── # ── 程序入口 ──────────────────────────────────────────────────────────
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@ -33,10 +33,9 @@ from dotenv import load_dotenv
# ── 加载环境变量 ────────────────────────────────────────────── # ── 加载环境变量 ──────────────────────────────────────────────
load_dotenv() load_dotenv()
# AccessKey 从系统环境变量读取(~/.bashrc 中 export 设置) # 所有配置从 .env 文件读取
OSS_ACCESS_KEY_ID = os.environ.get("OSS_ACCESS_KEY_ID", "") OSS_ACCESS_KEY_ID = os.getenv("OSS_ACCESS_KEY_ID", "")
OSS_ACCESS_KEY_SECRET = os.environ.get("OSS_ACCESS_KEY_SECRET", "") OSS_ACCESS_KEY_SECRET = os.getenv("OSS_ACCESS_KEY_SECRET", "")
# 以下配置从 .env 文件读取
OSS_BUCKET_NAME = os.getenv("OSS_BUCKET_NAME", "") OSS_BUCKET_NAME = os.getenv("OSS_BUCKET_NAME", "")
OSS_ENDPOINT = os.getenv("OSS_ENDPOINT", "") OSS_ENDPOINT = os.getenv("OSS_ENDPOINT", "")
OSS_REGION = os.getenv("OSS_REGION", "") OSS_REGION = os.getenv("OSS_REGION", "")

View File

@ -1,16 +1,6 @@
<template> <template>
<div class="app" :class="{ dark: isDark }"> <div class="app" :class="{ dark: isDark }">
<!-- 侧边栏 --> <router-view />
<ChatSidebar />
<!-- 主内容区 -->
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
<!-- 模态框 -->
<SearchModal />
<ShortcutsModal />
<SettingsModal />
<ConversationSettingsModal />
<!-- Toast 通知 --> <!-- Toast 通知 -->
<Teleport to="body"> <Teleport to="body">
@ -32,29 +22,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed } from "vue";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings"; 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"; import { Check, AlertCircle, Info } from "@/components/icons";
import { useAuthStore } from "./stores/auth";
const authStore = useAuthStore();
// Stores
const chatStore = useChatStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { settings } = storeToRefs(settingsStore); const { settings } = storeToRefs(settingsStore);
// Refs
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null);
// //
const isDark = computed(() => { const isDark = computed(() => {
if (settings.value.theme === "system") { if (settings.value.theme === "system") {
@ -85,57 +60,6 @@ function showToast(message: string, type: Toast["type"] = "info") {
}, 3000); }, 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(() => {
authStore.init();
console.log(authStore.token);
// //
// if (chatStore.conversations.length === 0) {
// chatStore.createConversation();
// }
});
// 使 // 使
window.$toast = showToast; window.$toast = showToast;
</script> </script>
@ -223,4 +147,4 @@ window.$toast = showToast;
.toast-move { .toast-move {
transition: transform 0.3s ease; transition: transform 0.3s ease;
} }
</style> </style>

View File

@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ShareButton from '@/components/sidebar/ShareButton.vue'
// Mock window.$toast
vi.stubGlobal('$toast', vi.fn())
describe('ShareButton 组件测试', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('应该渲染分享按钮', () => {
const wrapper = mount(ShareButton)
expect(wrapper.find('.share-btn').exists()).toBe(true)
expect(wrapper.text()).toContain('分享对话')
})
it('点击按钮应该进入选择模式', async () => {
const wrapper = mount(ShareButton)
const pinia = createPinia()
setActivePinia(pinia)
await wrapper.find('.share-btn').trigger('click')
// 检查是否切换到选择模式
// 这里需要通过 store 来验证
})
it('在选择模式下应该显示选择信息', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const wrapper = mount(ShareButton)
// 模拟进入选择模式
const { useChatStore } = await import('@/stores/chat')
const store = useChatStore()
store.enterSelectMode()
store.selectedConversationIds = ['conv-1', 'conv-2']
await wrapper.vm.$nextTick()
expect(wrapper.find('.select-actions').exists()).toBe(true)
expect(wrapper.text()).toContain('已选择 2 个对话')
})
it('选择数量为 0 时确认按钮应该禁用', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const wrapper = mount(ShareButton)
const { useChatStore } = await import('@/stores/chat')
const store = useChatStore()
store.enterSelectMode()
// selectedConversationIds 为空
await wrapper.vm.$nextTick()
const confirmBtn = wrapper.find('.action-btn.confirm')
expect(confirmBtn.attributes('disabled')).toBeDefined()
})
})

View File

@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useChatStore } from '@/stores/chat'
import type { Conversation } from '@/types/chat'
describe('Chat Store - 多选模式测试', () => {
beforeEach(() => {
// 每个测试前创建新的 Pinia 实例
setActivePinia(createPinia())
})
describe('多选模式状态', () => {
it('初始状态应该是未选择模式', () => {
const store = useChatStore()
expect(store.isSelectMode).toBe(false)
expect(store.selectedConversationIds).toEqual([])
})
it('toggleSelectMode 应该切换选择模式', () => {
const store = useChatStore()
expect(store.isSelectMode).toBe(false)
store.toggleSelectMode()
expect(store.isSelectMode).toBe(true)
store.toggleSelectMode()
expect(store.isSelectMode).toBe(false)
})
it('enterSelectMode 应该进入选择模式', () => {
const store = useChatStore()
store.enterSelectMode()
expect(store.isSelectMode).toBe(true)
})
it('exitSelectMode 应该退出选择模式并清除选择', () => {
const store = useChatStore()
store.enterSelectMode()
store.selectedConversationIds.push('conv-1', 'conv-2')
store.exitSelectMode()
expect(store.isSelectMode).toBe(false)
expect(store.selectedConversationIds).toEqual([])
})
})
describe('对话选择操作', () => {
it('toggleConversationSelection 应该切换对话选择状态', () => {
const store = useChatStore()
// 选择对话
store.toggleConversationSelection('conv-1')
expect(store.selectedConversationIds).toContain('conv-1')
// 取消选择
store.toggleConversationSelection('conv-1')
expect(store.selectedConversationIds).not.toContain('conv-1')
})
it('selectAllConversations 应该选择所有非归档对话', () => {
const store = useChatStore()
// 手动添加一些对话用于测试
store.conversations = [
{ id: 'conv-1', archived: false } as Conversation,
{ id: 'conv-2', archived: false } as Conversation,
{ id: 'conv-3', archived: true } as Conversation, // 归档的不应该被选择
]
store.selectAllConversations()
expect(store.selectedConversationIds).toContain('conv-1')
expect(store.selectedConversationIds).toContain('conv-2')
expect(store.selectedConversationIds).not.toContain('conv-3')
})
it('clearSelection 应该清除所有选择', () => {
const store = useChatStore()
store.selectedConversationIds = ['conv-1', 'conv-2', 'conv-3']
store.clearSelection()
expect(store.selectedConversationIds).toEqual([])
})
it('isConversationSelected 应该正确判断对话是否被选中', () => {
const store = useChatStore()
store.selectedConversationIds = ['conv-1', 'conv-2']
expect(store.isConversationSelected('conv-1')).toBe(true)
expect(store.isConversationSelected('conv-2')).toBe(true)
expect(store.isConversationSelected('conv-3')).toBe(false)
})
})
describe('计算属性', () => {
it('selectedCount 应该返回选中的对话数量', () => {
const store = useChatStore()
store.selectedConversationIds = ['conv-1', 'conv-2', 'conv-3']
expect(store.selectedCount).toBe(3)
})
it('selectedConversations 应该返回选中的对话对象数组', () => {
const store = useChatStore()
store.conversations = [
{ id: 'conv-1', title: '对话1' } as Conversation,
{ id: 'conv-2', title: '对话2' } as Conversation,
{ id: 'conv-3', title: '对话3' } as Conversation,
]
store.selectedConversationIds = ['conv-1', 'conv-3']
expect(store.selectedConversations).toHaveLength(2)
expect(store.selectedConversations.map(c => c.id)).toEqual(['conv-1', 'conv-3'])
})
})
})

View File

@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest'
import { sha256, hashPassword, verifyPassword } from '@/utils/crypto'
describe('crypto 工具测试', () => {
describe('sha256', () => {
it('应该正确计算字符串的 SHA-256 哈希值', async () => {
const text = 'hello'
const hash = await sha256(text)
// SHA-256('hello') 的已知结果
expect(hash).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824')
})
it('空字符串应该返回正确的哈希值', async () => {
const hash = await sha256('')
// SHA-256('') 的已知结果
expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
})
it('不同字符串应该产生不同的哈希值', async () => {
const hash1 = await sha256('password1')
const hash2 = await sha256('password2')
expect(hash1).not.toBe(hash2)
})
})
describe('hashPassword', () => {
it('应该对密码进行加盐哈希', async () => {
const password = 'mypassword'
const hash = await hashPassword(password)
// 验证返回的是64字符的十六进制字符串
expect(hash).toHaveLength(64)
expect(/^[a-f0-9]{64}$/.test(hash)).toBe(true)
})
it('相同密码应该产生相同的哈希值', async () => {
const password = 'test123'
const hash1 = await hashPassword(password)
const hash2 = await hashPassword(password)
expect(hash1).toBe(hash2)
})
})
describe('verifyPassword', () => {
it('正确的密码应该验证通过', async () => {
const password = 'correctpassword'
const hash = await hashPassword(password)
const isValid = await verifyPassword(password, hash)
expect(isValid).toBe(true)
})
it('错误的密码应该验证失败', async () => {
const password = 'correctpassword'
const hash = await hashPassword(password)
const isValid = await verifyPassword('wrongpassword', hash)
expect(isValid).toBe(false)
})
it('空密码应该验证失败', async () => {
const hash = await hashPassword('somepassword')
const isValid = await verifyPassword('', hash)
expect(isValid).toBe(false)
})
})
})

67
src/__tests__/setup.ts Normal file
View File

@ -0,0 +1,67 @@
// 测试环境全局配置
import { vi } from 'vitest'
// Mock window.$toast
vi.stubGlobal('$toast', vi.fn())
// Mock 所有 API 请求,避免测试时连接后端
const mockFetch = vi.fn((url: string) => {
// 根据 URL 返回不同的 mock 数据
if (url.includes('/api/chat-ui/conversations')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]), // 返回空对话列表
})
}
if (url.includes('/api/chat-ui/models')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
object: 'list',
data: [
{ id: 'glm-4-flash', name: 'GLM-4-Flash', description: '测试模型', maxTokens: 8192, provider: 'Zhipu', supports_thinking: false, supports_web_search: false, supports_vision: false, supports_files: false }
]
}),
})
}
// 默认返回空响应
return Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
})
vi.stubGlobal('fetch', mockFetch)
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value
},
removeItem: (key: string) => {
delete store[key]
},
clear: () => {
store = {}
},
}
})()
vi.stubGlobal('localStorage', localStorageMock)
// Mock matchMedia
vi.stubGlobal('matchMedia', vi.fn(() => ({
matches: false,
media: '',
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})))

View File

@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { shareApi } from '@/services/shareApi'
import type { ShareCreateResponse, ShareVerifyResponse, ShareGetResponse } from '@/types/share'
// Mock fetch
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// Mock getAuthHeaders
vi.mock('@/services/request', () => ({
getAuthHeaders: () => ({ 'Authorization': 'Bearer test-token' }),
}))
describe('分享 API 测试', () => {
beforeEach(() => {
mockFetch.mockReset()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('createShare', () => {
it('应该成功创建分享', async () => {
const mockResponse: ShareCreateResponse = {
id: 'share-123',
shareUrl: 'https://example.com/chat-ui/share/share-123',
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
})
const result = await shareApi.createShare({
conversationIds: ['conv-1', 'conv-2'],
passwordHash: 'hashed-password',
expiresIn: 604800,
})
expect(mockFetch).toHaveBeenCalledWith(
'/api/chat-ui/shares',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
})
)
expect(result.id).toBe('share-123')
expect(result.shareUrl).toContain('share-123')
})
it('创建失败应该抛出错误', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve('Internal Server Error'),
})
await expect(
shareApi.createShare({
conversationIds: ['conv-1'],
passwordHash: 'hash',
})
).rejects.toThrow()
})
})
describe('getShare', () => {
it('应该获取分享基本信息', async () => {
const mockResponse: ShareGetResponse = {
id: 'share-123',
hasPassword: true,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
isExpired: false,
conversationCount: 2,
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
})
const result = await shareApi.getShare('share-123')
expect(result.id).toBe('share-123')
expect(result.hasPassword).toBe(true)
expect(result.isExpired).toBe(false)
})
it('分享不存在应该抛出错误', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
})
await expect(shareApi.getShare('invalid-id')).rejects.toThrow('分享不存在或已过期')
})
})
describe('verifyShare', () => {
it('正确密码应该验证成功', async () => {
const mockResponse: ShareVerifyResponse = {
valid: true,
share: {
id: 'share-123',
conversationIds: ['conv-1'],
conversations: [],
createdAt: Date.now(),
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
viewCount: 0,
hasPassword: true,
},
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
})
const result = await shareApi.verifyShare('share-123', {
passwordHash: 'correct-hash',
})
expect(result.valid).toBe(true)
expect(result.share).toBeDefined()
})
it('错误密码应该验证失败', async () => {
const mockResponse: ShareVerifyResponse = {
valid: false,
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
})
const result = await shareApi.verifyShare('share-123', {
passwordHash: 'wrong-hash',
})
expect(result.valid).toBe(false)
expect(result.share).toBeUndefined()
})
})
})

View File

@ -1,5 +1,29 @@
<template> <template>
<div class="message-list-container"> <div class="message-list-container">
<!-- 消息选择操作栏 -->
<Transition name="slide-down">
<div v-if="isMessageSelectMode" class="message-select-bar">
<div class="select-info">
<span class="select-count">已选择 {{ selectedMessageCount }} 条消息</span>
</div>
<div class="select-actions">
<button class="action-btn select-all" @click="handleSelectAll">
全选
</button>
<button class="action-btn cancel" @click="handleCancelSelect">
取消
</button>
<button
class="action-btn confirm"
:disabled="selectedMessageCount === 0"
@click="handleConfirmShare"
>
确认分享
</button>
</div>
</div>
</Transition>
<div ref="containerRef" class="message-list" @scroll="handleScroll"> <div ref="containerRef" class="message-list" @scroll="handleScroll">
<!-- 欢迎界面 --> <!-- 欢迎界面 -->
<WelcomeScreen <WelcomeScreen
@ -18,6 +42,8 @@
:show-timestamp="showTimestamp" :show-timestamp="showTimestamp"
:compact="compact" :compact="compact"
:is-New="index === visibleMessages.length - 1" :is-New="index === visibleMessages.length - 1"
:is-message-select-mode="isMessageSelectMode"
:is-selected="isMessageSelected(message.id)"
@retry="$emit('retry', message.id)" @retry="$emit('retry', message.id)"
@regenerate="$emit('regenerate', message.id)" @regenerate="$emit('regenerate', message.id)"
@copy="handleCopy(message)" @copy="handleCopy(message)"
@ -27,6 +53,8 @@
@preview-image="handlePreviewImage" @preview-image="handlePreviewImage"
@play-video="handlePlayVideo" @play-video="handlePlayVideo"
@download-file="handleDownloadFile" @download-file="handleDownloadFile"
@toggle-select="handleToggleMessageSelect(message.id)"
@enter-select-mode="handleEnterSelectMode(message.id)"
/> />
</TransitionGroup> </TransitionGroup>
@ -64,6 +92,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick, onMounted, computed } from "vue"; import { ref, watch, nextTick, onMounted, computed } from "vue";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import MessageBubble from "@/components/message/MessageBubble.vue"; import MessageBubble from "@/components/message/MessageBubble.vue";
import WelcomeScreen from "./WelcomeScreen.vue"; import WelcomeScreen from "./WelcomeScreen.vue";
import { Bot, ChevronDown } from "@/components/icons"; import { Bot, ChevronDown } from "@/components/icons";
@ -101,6 +130,11 @@ const emit = defineEmits<{
}>(); }>();
const chatStore = useChatStore(); const chatStore = useChatStore();
const settingsStore = useSettingsStore();
//
const isMessageSelectMode = computed(() => chatStore.isMessageSelectMode);
const selectedMessageCount = computed(() => chatStore.selectedMessageCount);
// //
const containerRef = ref<HTMLElement | null>(null); const containerRef = ref<HTMLElement | null>(null);
@ -180,6 +214,33 @@ function handleDownloadFile(file: Attachment) {
emit("download-file", file); emit("download-file", file);
} }
//
function handleEnterSelectMode(messageId: string) {
chatStore.enterMessageSelectMode(chatStore.currentConversationId || '', messageId);
}
function handleToggleMessageSelect(messageId: string) {
chatStore.toggleMessageSelection(messageId);
}
function handleSelectAll() {
chatStore.selectAllMessages();
}
function handleCancelSelect() {
chatStore.exitMessageSelectMode();
}
function handleConfirmShare() {
if (chatStore.selectedMessageCount > 0) {
settingsStore.openShareModal();
}
}
function isMessageSelected(messageId: string): boolean {
return chatStore.isMessageSelected(messageId);
}
// //
watch( watch(
() => visibleMessages.value.length, () => visibleMessages.value.length,
@ -408,4 +469,97 @@ onMounted(() => {
opacity: 1; opacity: 1;
} }
} }
//
.message-select-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: white;
border-bottom: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.dark & {
background: #1e1e2e;
border-bottom-color: #2d2d3d;
}
}
.select-info {
display: flex;
align-items: center;
gap: 12px;
}
.select-count {
font-size: 14px;
font-weight: 500;
color: #374151;
.dark & {
color: #e5e7eb;
}
}
.select-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&.select-all,
&.cancel {
background: #f3f4f6;
color: #6b7280;
.dark & {
background: #2d2d3d;
color: #9ca3af;
}
&:hover {
background: #e5e7eb;
.dark & {
background: #374151;
}
}
}
&.confirm {
background: #3b82f6;
color: white;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
//
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
transform: translateY(-100%);
}
</style> </style>

View File

@ -123,6 +123,7 @@ export {
// 其他 // 其他
HelpCircle, HelpCircle,
CheckCircle,
Eye, Eye,
EyeOff, EyeOff,
Lock, Lock,

View File

@ -8,11 +8,21 @@
'is-end': !message.isEnd && message.role !== 'user', 'is-end': !message.isEnd && message.role !== 'user',
'is-error': message.isError, 'is-error': message.isError,
compact: compact, compact: compact,
'message-select-mode': isMessageSelectMode,
'message-selected': isSelected,
}, },
]" ]"
@click="handleBubbleClick"
@mouseenter="isHovered = true" @mouseenter="isHovered = true"
@mouseleave="isHovered = false" @mouseleave="isHovered = false"
> >
<!-- 消息选择模式复选框 -->
<div v-if="isMessageSelectMode" class="message-checkbox" @click.stop="handleToggleSelect">
<div class="checkbox" :class="{ checked: isSelected }">
<Check v-if="isSelected" :size="14" />
</div>
</div>
<!-- 头像 --> <!-- 头像 -->
<div class="avatar"> <div class="avatar">
<div class="avatar-inner" :class="message.role"> <div class="avatar-inner" :class="message.role">
@ -164,7 +174,9 @@
v-if=" v-if="
message.role === 'assistant' && message.role === 'assistant' &&
!message.isStreaming && !message.isStreaming &&
!message.isError !message.isError &&
!readonly &&
!isMessageSelectMode
" "
:content="message.content.text || ''" :content="message.content.text || ''"
:feedback="message.feedback" :feedback="message.feedback"
@ -176,6 +188,7 @@
@like="handleLike" @like="handleLike"
@dislike="handleDislike" @dislike="handleDislike"
@regenerate="$emit('regenerate')" @regenerate="$emit('regenerate')"
@share="handleShareClick"
/> />
</div> </div>
</div> </div>
@ -195,6 +208,7 @@ import {
Zap, Zap,
Maximize2, Maximize2,
Play, Play,
Check,
} from "@/components/icons"; } from "@/components/icons";
import MessageActions from "./MessageActions.vue"; import MessageActions from "./MessageActions.vue";
import { formatFileSize, getFileIcon } from "@/utils/helpers"; import { formatFileSize, getFileIcon } from "@/utils/helpers";
@ -208,10 +222,16 @@ const props = withDefaults(
showTimestamp?: boolean; showTimestamp?: boolean;
compact?: boolean; compact?: boolean;
isNew?: boolean; isNew?: boolean;
isMessageSelectMode?: boolean;
isSelected?: boolean;
readonly?: boolean;
}>(), }>(),
{ {
showTimestamp: true, showTimestamp: true,
compact: false, compact: false,
isMessageSelectMode: false,
isSelected: false,
readonly: false,
}, },
); );
const { copy } = useClipboard({ legacy: true }); const { copy } = useClipboard({ legacy: true });
@ -225,10 +245,29 @@ const emit = defineEmits<{
"preview-image": [image: Attachment, index: number]; "preview-image": [image: Attachment, index: number];
"play-video": [video: VideoInfo]; "play-video": [video: VideoInfo];
"download-file": [file: Attachment]; "download-file": [file: Attachment];
"toggle-select": [];
"enter-select-mode": [];
}>(); }>();
const isHovered = ref(false); const isHovered = ref(false);
//
function handleBubbleClick() {
if (props.isMessageSelectMode) {
emit("toggle-select");
}
}
//
function handleToggleSelect() {
emit("toggle-select");
}
//
function handleShareClick() {
emit("enter-select-mode");
}
function getFileEmoji(mimeType?: string) { function getFileEmoji(mimeType?: string) {
return getFileIcon(mimeType || ""); return getFileIcon(mimeType || "");
} }
@ -274,6 +313,28 @@ setCustomComponents("playground-demo", {
padding: 20px 10%; padding: 20px 10%;
animation: fadeIn 0.3s ease; animation: fadeIn 0.3s ease;
//
&.message-select-mode {
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: rgba(59, 130, 246, 0.05);
.dark & {
background: rgba(59, 130, 246, 0.1);
}
}
}
&.message-selected {
background: rgba(59, 130, 246, 0.1);
.dark & {
background: rgba(59, 130, 246, 0.15);
}
}
&.role-user { &.role-user {
flex-direction: row-reverse; flex-direction: row-reverse;
@ -430,7 +491,7 @@ setCustomComponents("playground-demo", {
// markstream-vue // markstream-vue
.text-content { .text-content {
:deep(p) { :deep(p) {
margin: 0 0 12px; margin: 0 0 12px;
&:last-child { &:last-child {
@ -606,15 +667,16 @@ setCustomComponents("playground-demo", {
} }
} }
.images-grid { .images-flex {
display: grid; display: inline-flex;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); flex-wrap: wrap;
gap: 8px; gap: 7px;
margin-top: 12px; margin-top: 12px;
} }
.image-item { .image-item {
position: relative; position: relative;
width: 130px;
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
@ -830,4 +892,41 @@ setCustomComponents("playground-demo", {
opacity: 1; opacity: 1;
} }
} }
//
.message-checkbox {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
z-index: 10;
.checkbox {
width: 22px;
height: 22px;
border: 2px solid #d1d5db;
border-radius: 6px;
background: white;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
cursor: pointer;
.dark & {
border-color: #4b5563;
background: #2d2d3d;
}
&.checked {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
&:hover {
border-color: #3b82f6;
}
}
}
</style> </style>

View File

@ -0,0 +1,579 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="modal-overlay" @click.self="handleClose">
<div class="modal-container">
<div class="modal-header">
<h3>{{ isMessageShare ? '分享消息' : '分享对话' }}</h3>
<button class="close-btn" @click="handleClose">
<X :size="20" />
</button>
</div>
<div class="modal-content">
<!-- 消息分享预览 -->
<div v-if="isMessageShare" class="selected-preview">
<div class="preview-header">
<span class="preview-title">已选择 {{ selectedMessageCount }} 条消息</span>
<span class="preview-hint">来自当前对话</span>
</div>
<div class="preview-list messages-preview">
<div
v-for="msg in selectedMessages"
:key="msg.id"
class="preview-item message-item"
>
<span class="message-role" :class="msg.role">
{{ msg.role === 'user' ? '用户' : 'AI' }}
</span>
<span class="item-title">{{ getMessagePreview(msg) }}</span>
</div>
</div>
</div>
<!-- 对话分享预览 -->
<div v-else class="selected-preview">
<div class="preview-header">
<span class="preview-title">已选择 {{ selectedCount }} 个对话</span>
<span class="preview-hint">最多分享 10 个对话</span>
</div>
<div class="preview-list">
<div
v-for="conv in selectedConversations"
:key="conv.id"
class="preview-item"
>
<MessageSquare :size="14" />
<span class="item-title">{{ conv.title }}</span>
<span class="item-count">{{ conv.messages.length }} 条消息</span>
</div>
</div>
</div>
<!-- 密码设置 -->
<div class="password-section">
<label class="password-label">
<Lock :size="16" />
设置访问密码
</label>
<div class="password-input-wrapper">
<input
v-model="password"
type="password"
class="password-input"
placeholder="请输入 4-20 位密码"
maxlength="20"
/>
<button
class="toggle-visibility"
@click="togglePasswordVisibility"
>
<Eye v-if="!showPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
<p class="password-hint">查看者需要输入此密码才能访问分享内容</p>
</div>
<!-- 过期时间提示 -->
<div class="expire-info">
<Clock :size="16" />
<span>分享链接将在 7 天后自动失效</span>
</div>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="handleClose">取消</button>
<button
class="btn-confirm"
:disabled="!isValidPassword || isCreating"
@click="handleCreateShare"
>
{{ isCreating ? '创建中...' : '创建分享链接' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { hashPassword } from "@/utils/crypto";
import { shareApi } from "@/services/shareApi";
import { SHARE_LIMITS } from "@/types/share";
import type { Message } from "@/types/chat";
import {
X,
Lock,
Eye,
EyeOff,
Clock,
MessageSquare,
} from "@/components/icons";
const settingsStore = useSettingsStore();
const chatStore = useChatStore();
const show = computed(() => settingsStore.showShareModal);
const { selectedConversations, selectedCount, isMessageSelectMode, selectedMessages, selectedMessageCount } = storeToRefs(chatStore);
//
const isMessageShare = computed(() => isMessageSelectMode.value);
const password = ref("");
const showPassword = ref(false);
const isCreating = ref(false);
const isValidPassword = computed(() => {
return password.value.length >= 4 && password.value.length <= 20;
});
//
function getMessagePreview(message: Message): string {
const text = message.content.text || '';
return text.length > 50 ? text.substring(0, 50) + '...' : text;
}
function togglePasswordVisibility() {
showPassword.value = !showPassword.value;
const input = document.querySelector('.password-input') as HTMLInputElement;
if (input) {
input.type = showPassword.value ? 'text' : 'password';
}
}
async function handleCreateShare() {
if (!isValidPassword.value || isCreating.value) return;
isCreating.value = true;
try {
//
const passwordHash = await hashPassword(password.value);
let result;
if (isMessageShare.value) {
//
result = await shareApi.createShare({
conversationIds: [],
messages: selectedMessages.value.map(m => ({
id: m.id,
role: m.role,
content: m.content,
timestamp: m.timestamp,
})),
passwordHash,
expiresIn: SHARE_LIMITS.DEFAULT_EXPIRE_SECONDS,
});
} else {
//
//
if (selectedCount.value > SHARE_LIMITS.MAX_CONVERSATIONS) {
window.$toast?.(`最多分享 ${SHARE_LIMITS.MAX_CONVERSATIONS} 个对话`, 'error');
return;
}
const conversationIds = selectedConversations.value.map(c => c.id);
result = await shareApi.createShare({
conversationIds,
passwordHash,
expiresIn: SHARE_LIMITS.DEFAULT_EXPIRE_SECONDS,
});
}
//
handleClose()
// 使
const shareUrl = new URL(result.shareUrl, window.location.origin).toString()
//
settingsStore.setShareResult({
shareId: result.id,
shareUrl,
password: password.value,
expiresAt: result.expiresAt,
})
settingsStore.openShareResultModal();
// 退
password.value = "";
if (isMessageShare.value) {
chatStore.exitMessageSelectMode();
} else {
chatStore.exitSelectMode();
}
} catch (error) {
console.error('Failed to create share:', error);
window.$toast?.('创建分享失败,请重试', 'error');
} finally {
isCreating.value = false;
}
}
function handleClose() {
settingsStore.closeShareModal();
}
//
watch(show, (newVal: boolean) => {
if (!newVal) {
password.value = "";
showPassword.value = false;
}
});
</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);
}
.modal-container {
background: white;
border-radius: 16px;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
.dark & {
background: #1e1e2e;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
.dark & {
border-bottom-color: #2d2d3d;
}
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: 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;
}
}
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.selected-preview {
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.preview-title {
font-size: 14px;
font-weight: 500;
color: #374151;
.dark & {
color: #e5e7eb;
}
}
.preview-hint {
font-size: 12px;
color: #9ca3af;
}
.preview-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 150px;
overflow-y: auto;
padding: 12px;
background: #f9fafb;
border-radius: 10px;
.dark & {
background: #2d2d3d;
}
}
.preview-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #4b5563;
.dark & {
color: #9ca3af;
}
.item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-count {
font-size: 12px;
color: #9ca3af;
}
}
&.messages-preview {
max-height: 200px;
}
.message-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
.message-role {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
&.user {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
&.assistant {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
}
.item-title {
font-size: 12px;
line-height: 1.4;
}
}
}
.password-section {
.password-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: #374151;
margin-bottom: 10px;
.dark & {
color: #e5e7eb;
}
}
.password-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.password-input {
width: 100%;
padding: 12px 44px 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 10px;
font-size: 14px;
color: #1f2937;
background: white;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.toggle-visibility {
position: absolute;
right: 10px;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
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;
}
}
}
.password-hint {
margin: 8px 0 0;
font-size: 12px;
color: #6b7280;
}
}
.expire-info {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: rgba(59, 130, 246, 0.05);
border-radius: 10px;
font-size: 13px;
color: #3b82f6;
.dark & {
background: rgba(59, 130, 246, 0.1);
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e5e7eb;
.dark & {
border-top-color: #2d2d3d;
}
}
.btn-cancel {
padding: 10px 20px;
border: none;
border-radius: 10px;
background: transparent;
color: #6b7280;
font-size: 14px;
font-weight: 500;
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;
}
}
}
.btn-confirm {
padding: 10px 20px;
border: none;
border-radius: 10px;
background: #3b82f6;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
//
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.modal-container {
transform: scale(0.95);
}
}
</style>

View File

@ -0,0 +1,406 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="modal-overlay" @click.self="handleClose">
<div class="modal-container">
<div class="modal-header">
<h3>分享创建成功</h3>
<button class="close-btn" @click="handleClose">
<X :size="20" />
</button>
</div>
<div class="modal-content">
<div class="success-icon">
<CheckCircle :size="48" />
</div>
<p class="success-message">
您的对话已成功分享将链接和密码发送给朋友即可查看
</p>
<!-- 分享链接 -->
<div class="share-section">
<label class="share-label">分享链接</label>
<div class="share-input-wrapper">
<input
ref="linkInput"
:value="shareUrl"
readonly
class="share-input"
/>
<button class="copy-btn" @click="copyLink">
<Copy :size="16" />
{{ linkCopied ? '已复制' : '复制' }}
</button>
</div>
</div>
<!-- 访问密码 -->
<div class="share-section">
<label class="share-label">访问密码</label>
<div class="share-input-wrapper">
<input
ref="passwordInput"
:value="password"
readonly
class="share-input password"
/>
<button class="copy-btn" @click="copyPassword">
<Copy :size="16" />
{{ passwordCopied ? '已复制' : '复制' }}
</button>
</div>
</div>
<!-- 过期时间 -->
<div class="expire-info">
<Clock :size="16" />
<span>链接将于 {{ formattedExpiresAt }} 过期</span>
</div>
</div>
<div class="modal-footer">
<button class="btn-copy-all" @click="copyAll">
<Copy :size="16" />
复制链接和密码
</button>
<button class="btn-close" @click="handleClose">
完成
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { formatTimestamp } from "@/utils/helpers";
import {
X,
CheckCircle,
Copy,
Clock,
} from "@/components/icons";
const settingsStore = useSettingsStore();
const show = computed(() => settingsStore.showShareResultModal);
const shareResult = computed(() => settingsStore.shareResult);
const shareUrl = computed(() => shareResult.value?.shareUrl || '');
const password = computed(() => shareResult.value?.password || '');
const expiresAt = computed(() => shareResult.value?.expiresAt || 0);
const linkCopied = ref(false);
const passwordCopied = ref(false);
const formattedExpiresAt = computed(() => {
return formatTimestamp(expiresAt.value);
});
async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fallback for older browsers
const input = document.createElement('input');
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
return true;
}
}
async function copyLink() {
if (await copyToClipboard(shareUrl.value)) {
linkCopied.value = true;
window.$toast?.('链接已复制到剪贴板', 'success');
setTimeout(() => {
linkCopied.value = false;
}, 2000);
}
}
async function copyPassword() {
if (await copyToClipboard(password.value)) {
passwordCopied.value = true;
window.$toast?.('密码已复制到剪贴板', 'success');
setTimeout(() => {
passwordCopied.value = false;
}, 2000);
}
}
async function copyAll() {
const text = `分享链接: ${shareUrl.value}\n访问密码: ${password.value}`;
if (await copyToClipboard(text)) {
window.$toast?.('链接和密码已复制到剪贴板', 'success');
}
}
function handleClose() {
settingsStore.closeShareResultModal();
}
//
watch(show, (newVal: boolean) => {
if (!newVal) {
linkCopied.value = false;
passwordCopied.value = false;
}
});
</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);
}
.modal-container {
background: white;
border-radius: 16px;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
.dark & {
background: #1e1e2e;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
.dark & {
border-bottom-color: #2d2d3d;
}
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: 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;
}
}
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.success-icon {
display: flex;
justify-content: center;
color: #10b981;
}
.success-message {
text-align: center;
font-size: 14px;
color: #6b7280;
margin: 0;
.dark & {
color: #9ca3af;
}
}
.share-section {
.share-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
.dark & {
color: #e5e7eb;
}
}
.share-input-wrapper {
display: flex;
gap: 8px;
}
.share-input {
flex: 1;
padding: 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 10px;
font-size: 13px;
color: #1f2937;
background: #f9fafb;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&.password {
font-family: monospace;
letter-spacing: 2px;
}
}
.copy-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 0 16px;
border: none;
border-radius: 10px;
background: #f3f4f6;
color: #374151;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
.dark & {
background: #374151;
color: #e5e7eb;
}
&:hover {
background: #e5e7eb;
.dark & {
background: #4b5563;
}
}
}
}
.expire-info {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
background: rgba(245, 158, 11, 0.1);
border-radius: 10px;
font-size: 13px;
color: #f59e0b;
}
.modal-footer {
display: flex;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e5e7eb;
.dark & {
border-top-color: #2d2d3d;
}
}
.btn-copy-all {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border: 1px solid #3b82f6;
border-radius: 10px;
background: transparent;
color: #3b82f6;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(59, 130, 246, 0.1);
}
}
.btn-close {
flex: 1;
padding: 12px;
border: none;
border-radius: 10px;
background: #3b82f6;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #2563eb;
}
}
//
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.modal-container {
transform: scale(0.95);
}
}
</style>

View File

@ -59,6 +59,9 @@
</button> </button>
</div> </div>
<!-- 分享按钮 -->
<ShareButton />
<!-- 搜索框 --> <!-- 搜索框 -->
<!-- <div class="search-section"> <!-- <div class="search-section">
<div class="search-box" @click="openSearch"> <div class="search-box" @click="openSearch">
@ -82,10 +85,13 @@
:key="conv.id" :key="conv.id"
:conversation="conv" :conversation="conv"
:is-active="conv.id === currentConversationId" :is-active="conv.id === currentConversationId"
:is-select-mode="isSelectMode"
:is-selected="isConversationSelected(conv.id)"
@select="selectConversation" @select="selectConversation"
@delete="deleteConversation" @delete="deleteConversation"
@rename="renameConversation" @rename="renameConversation"
@toggle-pin="togglePinConversation" @toggle-pin="togglePinConversation"
@toggle-select="toggleConversationSelection"
/> />
</div> </div>
</div> </div>
@ -102,10 +108,13 @@
:key="conv.id" :key="conv.id"
:conversation="conv" :conversation="conv"
:is-active="conv.id === currentConversationId" :is-active="conv.id === currentConversationId"
:is-select-mode="isSelectMode"
:is-selected="isConversationSelected(conv.id)"
@select="selectConversation" @select="selectConversation"
@delete="deleteConversation" @delete="deleteConversation"
@rename="renameConversation" @rename="renameConversation"
@toggle-pin="togglePinConversation" @toggle-pin="togglePinConversation"
@toggle-select="toggleConversationSelection"
/> />
</div> </div>
</div> </div>
@ -147,12 +156,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
import { chatApi } from "@/services/api.ts"; import { chatApi } from "@/services/api.ts";
import ConversationItem from "./ConversationItem.vue"; import ConversationItem from "./ConversationItem.vue";
import ShareButton from "./ShareButton.vue";
import { import {
Plus, Plus,
Pin, Pin,
@ -165,17 +175,14 @@ import {
const chatStore = useChatStore(); const chatStore = useChatStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { currentConversationId, pinnedConversations, recentConversations } = const { currentConversationId, pinnedConversations, recentConversations, isSelectMode, selectedConversationIds } =
storeToRefs(chatStore); storeToRefs(chatStore);
const { const {
sidebarCollapsed: isCollapsed, sidebarCollapsed: isCollapsed,
sidebarWidth, sidebarWidth,
settings,
} = storeToRefs(settingsStore); } = storeToRefs(settingsStore);
const currentTheme = computed(() => settings.value.theme);
// //
const showModelMenu = ref(false); const showModelMenu = ref(false);
const currentModel = ref(localStorage.getItem("modelSelect") || ""); const currentModel = ref(localStorage.getItem("modelSelect") || "");
@ -231,8 +238,12 @@ function togglePinConversation(id: string) {
chatStore.togglePinConversation(id); chatStore.togglePinConversation(id);
} }
function toggleTheme() { function toggleConversationSelection(id: string) {
settingsStore.toggleTheme(); chatStore.toggleConversationSelection(id);
}
function isConversationSelected(id: string): boolean {
return selectedConversationIds.value.includes(id);
} }
// //

View File

@ -4,12 +4,21 @@
:class="{ :class="{
active: isActive, active: isActive,
pinned: conversation.pinned, pinned: conversation.pinned,
selected: isSelected,
'select-mode': isSelectMode,
}" }"
@click="handleSelect" @click="handleClick"
@dblclick="handleRename" @dblclick="handleRename"
> >
<!-- 选择模式复选框 -->
<div v-if="isSelectMode" class="item-checkbox" @click.stop="handleToggleSelect">
<div class="checkbox" :class="{ checked: isSelected }">
<Check v-if="isSelected" :size="14" />
</div>
</div>
<!-- 图标 --> <!-- 图标 -->
<div class="item-icon"> <div v-if="!isSelectMode" class="item-icon">
<MessageSquare :size="18" /> <MessageSquare :size="18" />
</div> </div>
@ -35,12 +44,12 @@
</div> </div>
<!-- 置顶标识 --> <!-- 置顶标识 -->
<div v-if="conversation.pinned" class="pin-indicator"> <div v-if="conversation.pinned && !isSelectMode" class="pin-indicator">
<Pin :size="12" /> <Pin :size="12" />
</div> </div>
<!-- 操作按钮 --> <!-- 操作按钮 (非选择模式显示) -->
<div class="item-actions" @click.stop> <div v-if="!isSelectMode" class="item-actions" @click.stop>
<button <button
class="action-btn" class="action-btn"
:title="conversation.pinned ? '取消置顶' : '置顶'" :title="conversation.pinned ? '取消置顶' : '置顶'"
@ -68,6 +77,7 @@ import {
Edit3, Edit3,
Trash2, Trash2,
Clock, Clock,
Check,
} from "@/components/icons"; } from "@/components/icons";
import { formatTimestamp } from "@/utils/helpers"; import { formatTimestamp } from "@/utils/helpers";
import type { Conversation } from "@/types/chat"; import type { Conversation } from "@/types/chat";
@ -75,6 +85,8 @@ import type { Conversation } from "@/types/chat";
const props = defineProps<{ const props = defineProps<{
conversation: Conversation; conversation: Conversation;
isActive: boolean; isActive: boolean;
isSelectMode?: boolean;
isSelected?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -82,6 +94,7 @@ const emit = defineEmits<{
delete: [id: string]; delete: [id: string];
rename: [id: string, title: string]; rename: [id: string, title: string];
togglePin: [id: string]; togglePin: [id: string];
toggleSelect: [id: string];
}>(); }>();
const isEditing = ref(false); const isEditing = ref(false);
@ -92,17 +105,24 @@ const formattedTime = computed(() => {
return formatTimestamp(props.conversation.updatedAt); return formatTimestamp(props.conversation.updatedAt);
}); });
function handleSelect() { function handleClick() {
if (!isEditing.value) { if (props.isSelectMode) {
emit("toggleSelect", props.conversation.id);
} else if (!isEditing.value) {
emit("select", props.conversation.id); emit("select", props.conversation.id);
} }
} }
function handleToggleSelect() {
emit("toggleSelect", props.conversation.id);
}
function handleTogglePin() { function handleTogglePin() {
emit("togglePin", props.conversation.id); emit("togglePin", props.conversation.id);
} }
function handleRename() { function handleRename() {
if (props.isSelectMode) return;
isEditing.value = true; isEditing.value = true;
editTitle.value = props.conversation.title; editTitle.value = props.conversation.title;
nextTick(() => { nextTick(() => {
@ -274,4 +294,57 @@ function handleDelete() {
color: #ef4444; color: #ef4444;
} }
} }
//
.item-checkbox {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding-right: 4px;
}
.checkbox {
width: 18px;
height: 18px;
border: 2px solid #d1d5db;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
cursor: pointer;
.dark & {
border-color: #4b5563;
}
&:hover {
border-color: #3b82f6;
}
&.checked {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
}
.conversation-item.select-mode {
&:hover {
background: rgba(59, 130, 246, 0.05);
.dark & {
background: rgba(59, 130, 246, 0.1);
}
}
&.selected {
background: rgba(59, 130, 246, 0.1);
.dark & {
background: rgba(59, 130, 246, 0.15);
}
}
}
</style> </style>

View File

@ -0,0 +1,165 @@
<template>
<div class="share-button-wrapper">
<button
v-if="!isSelectMode"
class="share-btn"
:disabled="conversations.length === 0"
@click="handleStartSelect"
>
<Share2 :size="16" />
<span>分享对话</span>
</button>
<div v-else class="select-actions">
<span class="select-info">
已选择 {{ selectedCount }} 个对话
</span>
<div class="action-buttons">
<button class="action-btn cancel" @click="handleCancel">
取消
</button>
<button
class="action-btn confirm"
:disabled="selectedCount === 0"
@click="handleConfirm"
>
确认分享
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { Share2 } from "@/components/icons";
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const { isSelectMode, selectedCount, conversations } = storeToRefs(chatStore);
function handleStartSelect() {
chatStore.enterSelectMode();
}
function handleCancel() {
chatStore.exitSelectMode();
}
function handleConfirm() {
if (chatStore.selectedCount > 0) {
//
settingsStore.openShareModal();
}
}
</script>
<style lang="scss" scoped>
.share-button-wrapper {
padding: 6px 16px;
}
.share-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
border-radius: 12px;
background: #f3f4f5;
color: #374151;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
}
&:hover:not(:disabled) {
background: #000f33;
color: #e5e7eb;
.dark & {
background: #0475ed;
color: #e5e7eb;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.select-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.select-info {
font-size: 13px;
color: #6b7280;
text-align: center;
.dark & {
color: #9ca3af;
}
}
.action-buttons {
display: flex;
gap: 8px;
}
.action-btn {
flex: 1;
padding: 8px 12px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
&.cancel {
background: rgba(0, 0, 0, 0.05);
color: #6b7280;
.dark & {
background: rgba(255, 255, 255, 0.05);
color: #9ca3af;
}
&:hover {
background: rgba(0, 0, 0, 0.1);
.dark & {
background: rgba(255, 255, 255, 0.1);
}
}
}
&.confirm {
background: #3b82f6;
color: white;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
</style>

View File

@ -1,5 +1,6 @@
import { createApp } from "vue"; import { createApp } from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import router from "./router";
import App from "./App.vue"; import App from "./App.vue";
// 样式 // 样式
@ -15,6 +16,9 @@ const app = createApp(App);
const pinia = createPinia(); const pinia = createPinia();
app.use(pinia); app.use(pinia);
// 使用 Router
app.use(router);
// 挂载应用 // 挂载应用
app.mount("#app"); app.mount("#app");

20
src/router/index.ts Normal file
View File

@ -0,0 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
const router = createRouter({
history: createWebHistory('/'),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/share/:id',
name: 'share',
component: () => import('@/views/ShareView.vue'),
},
],
})
export default router

77
src/services/shareApi.ts Normal file
View File

@ -0,0 +1,77 @@
/**
* API
*/
import { getAuthHeaders } from './request';
import type {
ShareCreateRequest,
ShareCreateResponse,
ShareVerifyRequest,
ShareVerifyResponse,
ShareGetResponse,
} from '@/types/share';
const API_BASE = '/api/chat-ui';
export const shareApi = {
/**
*
*/
async createShare(request: ShareCreateRequest): Promise<ShareCreateResponse> {
const response = await fetch(`${API_BASE}/shares`, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || `创建分享失败: HTTP ${response.status}`);
}
return response.json();
},
/**
*
*/
async getShare(shareId: string): Promise<ShareGetResponse> {
const response = await fetch(`${API_BASE}/shares/${shareId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('分享不存在或已过期');
}
throw new Error(`获取分享失败: HTTP ${response.status}`);
}
return response.json();
},
/**
*
*/
async verifyShare(shareId: string, request: ShareVerifyRequest): Promise<ShareVerifyResponse> {
const response = await fetch(`${API_BASE}/shares/${shareId}/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || `验证失败: HTTP ${response.status}`);
}
return response.json();
},
};

View File

@ -19,6 +19,15 @@ export const useChatStore = defineStore("chat", () => {
const isInitialized = ref(false); const isInitialized = ref(false);
const isLoading = ref(false); const isLoading = ref(false);
// 分享多选模式状态(对话级别)
const isSelectMode = ref(false);
const selectedConversationIds = ref<string[]>([]);
// 消息分享选择模式状态
const isMessageSelectMode = ref(false);
const selectedMessageIds = ref<string[]>([]);
const sourceConversationId = ref<string | null>(null);
// 计算属性 // 计算属性
const currentConversation = computed(() => { const currentConversation = computed(() => {
return ( return (
@ -43,6 +52,24 @@ export const useChatStore = defineStore("chat", () => {
return sortedConversations.value.filter((c) => !c.pinned && !c.archived); return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
}); });
// 选中的对话列表
const selectedConversations = computed(() => {
return conversations.value.filter((c) => selectedConversationIds.value.includes(c.id));
});
// 选中的对话数量
const selectedCount = computed(() => selectedConversationIds.value.length);
// 选中的消息列表
const selectedMessages = computed(() => {
const conv = conversations.value.find((c) => c.id === sourceConversationId.value);
if (!conv) return [];
return conv.messages.filter((m) => selectedMessageIds.value.includes(m.id));
});
// 选中的消息数量
const selectedMessageCount = computed(() => selectedMessageIds.value.length);
// 初始化方法 - 从后端 API 加载数据 // 初始化方法 - 从后端 API 加载数据
async function initializeFromApi() { async function initializeFromApi() {
if (isInitialized.value || isLoading.value) return; if (isInitialized.value || isLoading.value) return;
@ -486,6 +513,95 @@ export const useChatStore = defineStore("chat", () => {
} }
} }
// ========== 分享多选模式方法 ==========
// 切换选择模式
function toggleSelectMode() {
isSelectMode.value = !isSelectMode.value;
if (!isSelectMode.value) {
clearSelection();
}
}
// 进入选择模式
function enterSelectMode() {
isSelectMode.value = true;
}
// 退出选择模式
function exitSelectMode() {
isSelectMode.value = false;
clearSelection();
}
// 切换对话选择状态
function toggleConversationSelection(id: string) {
const index = selectedConversationIds.value.indexOf(id);
if (index === -1) {
selectedConversationIds.value.push(id);
} else {
selectedConversationIds.value.splice(index, 1);
}
}
// 全选
function selectAllConversations() {
selectedConversationIds.value = conversations.value
.filter((c) => !c.archived)
.map((c) => c.id);
}
// 清除选择
function clearSelection() {
selectedConversationIds.value = [];
}
// 检查对话是否被选中
function isConversationSelected(id: string): boolean {
return selectedConversationIds.value.includes(id);
}
// ========== 消息分享选择模式方法 ==========
// 进入消息选择模式
function enterMessageSelectMode(conversationId: string, initialMessageId?: string) {
isMessageSelectMode.value = true;
sourceConversationId.value = conversationId;
selectedMessageIds.value = initialMessageId ? [initialMessageId] : [];
}
// 退出消息选择模式
function exitMessageSelectMode() {
isMessageSelectMode.value = false;
sourceConversationId.value = null;
selectedMessageIds.value = [];
}
// 切换消息选择状态
function toggleMessageSelection(messageId: string) {
const index = selectedMessageIds.value.indexOf(messageId);
if (index === -1) {
selectedMessageIds.value.push(messageId);
} else {
selectedMessageIds.value.splice(index, 1);
}
}
// 全选当前对话消息
function selectAllMessages() {
const conv = conversations.value.find((c) => c.id === sourceConversationId.value);
if (conv) {
selectedMessageIds.value = conv.messages
.filter((m) => m.role !== MessageRole.SYSTEM)
.map((m) => m.id);
}
}
// 检查消息是否被选中
function isMessageSelected(messageId: string): boolean {
return selectedMessageIds.value.includes(messageId);
}
// 初始化 // 初始化
initializeFromApi(); initializeFromApi();
@ -497,11 +613,22 @@ export const useChatStore = defineStore("chat", () => {
streamController, streamController,
isInitialized, isInitialized,
isLoading, isLoading,
// 分享多选模式状态
isSelectMode,
selectedConversationIds,
// 消息分享选择模式状态
isMessageSelectMode,
selectedMessageIds,
sourceConversationId,
selectedMessages,
selectedMessageCount,
// 计算属性 // 计算属性
currentConversation, currentConversation,
sortedConversations, sortedConversations,
pinnedConversations, pinnedConversations,
recentConversations, recentConversations,
selectedConversations,
selectedCount,
// 方法 // 方法
initializeFromApi, initializeFromApi,
createConversation, createConversation,
@ -522,5 +649,19 @@ export const useChatStore = defineStore("chat", () => {
clearConversation, clearConversation,
loadFromStorage, loadFromStorage,
saveToStorage, saveToStorage,
// 分享多选模式方法
toggleSelectMode,
enterSelectMode,
exitSelectMode,
toggleConversationSelection,
selectAllConversations,
clearSelection,
isConversationSelected,
// 消息分享选择模式方法
enterMessageSelectMode,
exitMessageSelectMode,
toggleMessageSelection,
selectAllMessages,
isMessageSelected,
}; };
}); });

View File

@ -2,6 +2,14 @@ import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import type { AppSettings, AIModel } from "@/types/chat"; import type { AppSettings, AIModel } from "@/types/chat";
// 分享结果类型
export interface ShareResult {
shareId: string;
shareUrl: string;
password: string;
expiresAt: number;
}
export const useSettingsStore = defineStore("settings", () => { export const useSettingsStore = defineStore("settings", () => {
// 默认设置 // 默认设置
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
@ -86,6 +94,11 @@ export const useSettingsStore = defineStore("settings", () => {
const showSettingsModal = ref(false); const showSettingsModal = ref(false);
const showConversationSettingsModal = ref(false); const showConversationSettingsModal = ref(false);
// 分享相关状态
const showShareModal = ref(false);
const showShareResultModal = ref(false);
const shareResult = ref<ShareResult | null>(null);
// 主题相关 // 主题相关
function applyTheme(theme: AppSettings["theme"]) { function applyTheme(theme: AppSettings["theme"]) {
const root = document.documentElement; const root = document.documentElement;
@ -175,6 +188,31 @@ export const useSettingsStore = defineStore("settings", () => {
showConversationSettingsModal.value = false; showConversationSettingsModal.value = false;
} }
// 分享模态框
function openShareModal() {
showShareModal.value = true;
}
function closeShareModal() {
showShareModal.value = false;
}
function openShareResultModal() {
showShareResultModal.value = true;
}
function closeShareResultModal() {
showShareResultModal.value = false;
}
function setShareResult(result: ShareResult) {
shareResult.value = result;
}
function clearShareResult() {
shareResult.value = null;
}
// 更新设置 // 更新设置
function updateSettings(updates: Partial<AppSettings>) { function updateSettings(updates: Partial<AppSettings>) {
Object.assign(settings.value, updates); Object.assign(settings.value, updates);
@ -298,6 +336,10 @@ export const useSettingsStore = defineStore("settings", () => {
showSettingsModal, showSettingsModal,
showConversationSettingsModal, showConversationSettingsModal,
availableModels, availableModels,
// 分享相关状态
showShareModal,
showShareResultModal,
shareResult,
// 方法 // 方法
toggleTheme, toggleTheme,
@ -313,6 +355,13 @@ export const useSettingsStore = defineStore("settings", () => {
closeSettingsModal, closeSettingsModal,
openConversationSettingsModal, openConversationSettingsModal,
closeConversationSettingsModal, closeConversationSettingsModal,
// 分享模态框方法
openShareModal,
closeShareModal,
openShareResultModal,
closeShareResultModal,
setShareResult,
clearShareResult,
updateSettings, updateSettings,
resetSettings, resetSettings,
exportSettings, exportSettings,

79
src/types/share.ts Normal file
View File

@ -0,0 +1,79 @@
import type { Conversation } from "./chat";
/**
*
*/
export interface Share {
id: string; // 分享ID
conversationIds: string[]; // 分享的对话ID列表
conversations: Conversation[]; // 完整对话数据(快照)
createdAt: number; // 创建时间
expiresAt: number; // 过期时间
viewCount: number; // 访问次数
hasPassword: boolean; // 是否有密码保护
}
/**
*
*/
export interface ShareCreateRequest {
conversationIds: string[]; // 要分享的对话ID列表
messages?: MessageData[]; // 直接传递消息数据(消息分享模式)
passwordHash: string; // 密码哈希值
expiresIn?: number; // 过期时间默认7天
}
/**
*
*/
export interface MessageData {
id: string;
role: string;
content: any;
timestamp: number;
}
/**
*
*/
export interface ShareCreateResponse {
id: string; // 分享ID
shareUrl: string; // 分享链接
expiresAt: number; // 过期时间
}
/**
*
*/
export interface ShareVerifyRequest {
passwordHash: string; // 密码哈希值
}
/**
*
*/
export interface ShareVerifyResponse {
valid: boolean; // 密码是否正确
share?: Share; // 分享数据(验证成功时返回)
error?: string; // 错误信息
}
/**
*
*/
export interface ShareGetResponse {
id: string;
hasPassword: boolean;
expiresAt: number;
isExpired: boolean;
conversationCount: number;
}
/**
*
*/
export const SHARE_LIMITS = {
MAX_CONVERSATIONS: 10, // 单次分享最多对话数
MAX_MESSAGES_PER_CONVERSATION: 100, // 单个对话最多消息数
DEFAULT_EXPIRE_SECONDS: 7 * 24 * 60 * 60, // 默认过期时间7天
} as const;

45
src/utils/crypto.ts Normal file
View File

@ -0,0 +1,45 @@
/**
*
* 使 SHA-256
*/
/**
* 使 SHA-256
* @param text
* @returns
*/
export async function sha256(text: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return hashHex;
}
/**
*
* @param password
* @returns
*/
export async function hashPassword(password: string): Promise<string> {
// 添加前缀盐值增加安全性
const saltedPassword = `share_${password}_chat`;
return sha256(saltedPassword);
}
/**
*
* @param password
* @param storedHash
* @returns
*/
export async function verifyPassword(
password: string,
storedHash: string
): Promise<boolean> {
const hash = await hashPassword(password);
return hash === storedHash;
}

90
src/views/HomeView.vue Normal file
View File

@ -0,0 +1,90 @@
<template>
<div class="home-view">
<!-- 侧边栏 -->
<ChatSidebar />
<!-- 主内容区 -->
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
<!-- 模态框 -->
<SearchModal />
<ShortcutsModal />
<SettingsModal />
<ConversationSettingsModal />
<ShareModal />
<ShareResultModal />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
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 ShareModal from '@/components/modals/ShareModal.vue'
import ShareResultModal from '@/components/modals/ShareResultModal.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const chatStore = useChatStore()
const settingsStore = useSettingsStore()
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null)
function toggleSidebar() {
settingsStore.toggleSidebar()
}
function newChat() {
chatStore.createConversation()
window.$toast?.('已创建新对话', 'success')
}
function focusInput() {
chatMainRef.value?.focusInput()
}
//
useKeyboard(
getDefaultShortcuts({
newChat,
toggleSidebar,
focusInput,
sendMessage: () => {},
cancelStream: () => {
if (chatStore.isStreaming) {
chatStore.stopStreaming()
window.$toast?.('已停止生成', 'info')
}
},
toggleTheme: () => {
settingsStore.toggleTheme()
window.$toast?.(`主题已切换`, 'success')
},
showShortcuts: () => {
settingsStore.openShortcutsModal()
},
searchConversations: () => {
settingsStore.openSearchModal()
},
}),
)
//
authStore.init()
</script>
<style lang="scss" scoped>
.home-view {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>

566
src/views/ShareView.vue Normal file
View File

@ -0,0 +1,566 @@
<template>
<div class="share-view" :class="{ dark: isDark }">
<!-- 加载状态 -->
<div v-if="isLoading" class="loading-state">
<Loader2 :size="32" class="spin" />
<span>加载中...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<AlertCircle :size="48" />
<p class="error-message">{{ error }}</p>
<button class="retry-btn" @click="loadShareInfo">重试</button>
</div>
<!-- 密码验证 -->
<div v-else-if="!isVerified" class="password-verify">
<div class="verify-container">
<div class="verify-icon">
<Lock :size="48" />
</div>
<h2 class="verify-title">查看分享内容</h2>
<p class="verify-hint">请输入访问密码查看分享内容</p>
<div v-if="shareInfo" class="share-info">
<span class="info-item">
<MessageSquare :size="14" />
{{ shareInfo.conversationCount }} 个对话
</span>
<span v-if="shareInfo.isExpired" class="info-item expired">
<AlertCircle :size="14" />
已过期
</span>
<span v-else class="info-item">
<Clock :size="14" />
{{ formatExpiresAt }} 过期
</span>
</div>
<div class="password-input-wrapper">
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
class="password-input"
placeholder="请输入访问密码"
@keydown.enter="handleVerify"
/>
<button class="toggle-visibility" @click="showPassword = !showPassword">
<Eye v-if="!showPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
<p v-if="verifyError" class="verify-error">{{ verifyError }}</p>
<button
class="verify-btn"
:disabled="!password || isVerifying"
@click="handleVerify"
>
{{ isVerifying ? '验证中...' : '查看内容' }}
</button>
</div>
</div>
<!-- 分享内容 -->
<div v-else class="share-content">
<!-- 顶部导航栏 -->
<header class="share-header">
<div class="header-left">
<h1 class="share-title">分享的对话</h1>
<span class="conversation-count">
{{ shareData?.conversations?.length || 0 }} 个对话
</span>
</div>
<!-- <button class="theme-toggle" @click="toggleTheme">
<Sun v-if="isDark" :size="20" />
<Moon v-else :size="20" />
</button> -->
</header>
<!-- 对话切换标签 -->
<div v-if="shareData?.conversations && shareData.conversations.length > 1" class="conversation-tabs">
<button
v-for="(conv, index) in shareData.conversations"
:key="conv.id"
class="tab-btn"
:class="{ active: activeConversationIndex === index }"
@click="activeConversationIndex = index"
>
{{ conv.title || '未命名对话' }}
</button>
</div>
<!-- 消息列表 -->
<div class="message-list">
<template v-if="activeConversation">
<MessageBubble
v-for="message in visibleMessages"
:key="message.id"
:message="message"
:show-timestamp="true"
:readonly="true"
/>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useSettingsStore } from '@/stores/settings'
import { storeToRefs } from 'pinia'
import { shareApi } from '@/services/shareApi'
import { hashPassword } from '@/utils/crypto'
import { formatTimestamp } from '@/utils/helpers'
import { MessageRole } from '@/types/chat'
import type { Share, ShareGetResponse } from '@/types/share'
import MessageBubble from '@/components/message/MessageBubble.vue'
import {
Lock,
Loader2,
MessageSquare,
Clock,
AlertCircle,
Eye,
EyeOff,
} from '@/components/icons'
const route = useRoute()
const settingsStore = useSettingsStore()
const { settings } = storeToRefs(settingsStore)
//
const isLoading = ref(false)
const isVerifying = ref(false)
const isVerified = ref(false)
const password = ref('')
const showPassword = ref(false)
const error = ref('')
const verifyError = ref('')
const shareInfo = ref<ShareGetResponse | null>(null)
const shareData = ref<Share | null>(null)
const activeConversationIndex = ref(0)
//
const isDark = computed(() => {
if (settings.value.theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return settings.value.theme === 'dark'
})
const formatExpiresAt = computed(() => {
if (!shareInfo.value) return ''
return formatTimestamp(shareInfo.value.expiresAt)
})
const activeConversation = computed(() => {
return shareData.value?.conversations?.[activeConversationIndex.value]
})
const visibleMessages = computed(() => {
if (!activeConversation.value?.messages) return []
return activeConversation.value.messages.filter(
(message) => message.role !== MessageRole.SYSTEM
)
})
//
async function loadShareInfo() {
const shareId = route.params.id as string
if (!shareId) {
error.value = '无效的分享链接'
return
}
isLoading.value = true
error.value = ''
try {
shareInfo.value = await shareApi.getShare(shareId)
if (shareInfo.value.isExpired) {
error.value = '分享链接已过期'
return
}
//
if (!shareInfo.value.hasPassword) {
await verifyWithPassword('')
}
} catch (err) {
console.error('Failed to load share info:', err)
error.value = '分享不存在或已过期'
} finally {
isLoading.value = false
}
}
async function handleVerify() {
if (!password.value || isVerifying.value) return
await verifyWithPassword(password.value)
}
async function verifyWithPassword(pwd: string) {
const shareId = route.params.id as string
if (!shareId) return
isVerifying.value = true
verifyError.value = ''
try {
const passwordHash = pwd ? await hashPassword(pwd) : ''
const result = await shareApi.verifyShare(shareId, { passwordHash })
if (result.valid && result.share) {
shareData.value = result.share
isVerified.value = true
} else {
verifyError.value = '密码错误,请重试'
}
} catch (err) {
console.error('Failed to verify share:', err)
verifyError.value = '验证失败,请重试'
} finally {
isVerifying.value = false
}
}
onMounted(() => {
loadShareInfo()
})
</script>
<style lang="scss" scoped>
.share-view {
width: 100%;
min-height: 100vh;
background: #ffffff;
&.dark {
background: #11111b;
color: #e5e7eb;
}
}
//
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 16px;
color: #6b7280;
.spin {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
//
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 16px;
color: #6b7280;
.error-message {
font-size: 16px;
color: #ef4444;
margin: 0;
}
.retry-btn {
padding: 10px 24px;
border: none;
border-radius: 10px;
background: #3b82f6;
color: white;
font-size: 14px;
cursor: pointer;
&:hover {
background: #2563eb;
}
}
}
//
.password-verify {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.verify-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
max-width: 400px;
}
.verify-icon {
color: #6b7280;
}
.verify-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.verify-hint {
font-size: 14px;
color: #6b7280;
margin: 0;
text-align: center;
}
.share-info {
display: flex;
gap: 16px;
margin-top: 8px;
.info-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6b7280;
&.expired {
color: #ef4444;
}
}
}
.password-input-wrapper {
position: relative;
width: 100%;
max-width: 320px;
margin-top: 8px;
}
.password-input {
width: 100%;
padding: 14px 48px 14px 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
font-size: 15px;
text-align: center;
letter-spacing: 2px;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.toggle-visibility {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7280;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.05);
.dark & {
background: rgba(255, 255, 255, 0.05);
}
}
}
.verify-error {
font-size: 13px;
color: #ef4444;
margin: 0;
}
.verify-btn {
width: 100%;
max-width: 320px;
padding: 14px;
border: none;
border-radius: 12px;
background: #3b82f6;
color: white;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
//
.share-content {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100%;
}
.share-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #e5e7eb;
background: white;
position: sticky;
top: 0;
z-index: 10;
.dark & {
background: #1e1e2e;
border-bottom-color: #2d2d3d;
}
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.share-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.conversation-count {
font-size: 13px;
color: #6b7280;
}
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: #f3f4f6;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
}
&:hover {
background: #e5e7eb;
.dark & {
background: #374151;
}
}
}
.conversation-tabs {
display: flex;
gap: 8px;
padding: 12px 24px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
overflow-x: auto;
.dark & {
background: #1e1e2e;
border-bottom-color: #2d2d3d;
}
}
.tab-btn {
flex-shrink: 0;
padding: 10px 20px;
border: none;
border-radius: 10px;
background: white;
color: #6b7280;
font-size: 14px;
white-space: nowrap;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
color: #9ca3af;
}
&:hover {
background: #e5e7eb;
.dark & {
background: #374151;
}
}
&.active {
background: #3b82f6;
color: white;
}
}
.message-list {
flex: 1;
padding: 20px 0;
overflow-y: auto;
}
</style>

35
vitest.config.ts Normal file
View File

@ -0,0 +1,35 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
// 使用 happy-dom 作为测试环境
environment: 'happy-dom',
// 全局变量
globals: true,
// 测试文件匹配模式
include: ['src/**/*.{test,spec}.{js,ts}'],
// 测试 setup 文件
setupFiles: ['src/__tests__/setup.ts'],
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,vue}'],
exclude: [
'src/**/*.d.ts',
'src/main.ts',
'src/**/*.test.ts',
'src/**/*.spec.ts',
'src/__tests__/setup.ts',
],
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
})