feat: 分享对话功能;需要优化:不能分享单独几条对话,适用范围窄;在Dialog中展示对话,记录没有样式,很难看。

This commit is contained in:
肖应宇 2026-03-25 15:12:50 +08:00
parent b51831dd15
commit 4d2caddeee
24 changed files with 4386 additions and 247 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"
} }
} }

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

@ -0,0 +1,192 @@
"""
分享功能路由 - 创建分享验证密码获取分享内容
"""
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]
passwordHash: str
expiresIn: Optional[int] = 604800 # 默认7天
class VerifyShareRequest(BaseModel):
"""验证分享请求"""
passwordHash: str
# ── 分享处理器 ──────────────────────────────────────────────────────────
async def create_share_handler(data: dict):
"""
创建分享
请求体:
{
"conversationIds": ["conv-1", "conv-2"],
"passwordHash": "sha256-hash",
"expiresIn": 604800
}
"""
try:
conversation_ids = data.get("conversationIds", [])
password_hash = data.get("passwordHash", "")
expires_in = data.get("expiresIn", 604800)
if not conversation_ids:
raise HTTPException(status_code=400, detail="请选择要分享的对话")
if len(conversation_ids) > 10:
raise HTTPException(status_code=400, detail="最多分享10个对话")
if not password_hash:
raise HTTPException(status_code=400, detail="请设置访问密码")
# 获取数据库实例
db = get_db()
# 获取对话数据(快照)
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", [])[:100], # 限制消息数量
"createdAt": conv["createdAt"],
"updatedAt": conv["updatedAt"],
}
conversations.append(conv_snapshot)
if not conversations:
raise HTTPException(status_code=404, detail="未找到有效的对话")
# 计算过期时间
now = int(datetime.now(timezone.utc).timestamp() * 1000)
expires_at = now + (expires_in * 1000)
# 创建分享记录
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"/#/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

@ -11,6 +11,9 @@
<ShortcutsModal /> <ShortcutsModal />
<SettingsModal /> <SettingsModal />
<ConversationSettingsModal /> <ConversationSettingsModal />
<ShareModal />
<ShareResultModal />
<ShareViewModal />
<!-- Toast 通知 --> <!-- Toast 通知 -->
<Teleport to="body"> <Teleport to="body">
@ -43,6 +46,9 @@ import SearchModal from "@/components/modals/SearchModal.vue";
import ShortcutsModal from "@/components/modals/ShortcutsModal.vue"; import ShortcutsModal from "@/components/modals/ShortcutsModal.vue";
import SettingsModal from "@/components/modals/SettingsModal.vue"; import SettingsModal from "@/components/modals/SettingsModal.vue";
import ConversationSettingsModal from "@/components/modals/ConversationSettingsModal.vue"; import ConversationSettingsModal from "@/components/modals/ConversationSettingsModal.vue";
import ShareModal from "@/components/modals/ShareModal.vue";
import ShareResultModal from "@/components/modals/ShareResultModal.vue";
import ShareViewModal from "@/components/modals/ShareViewModal.vue";
import { Check, AlertCircle, Info } from "@/components/icons"; import { Check, AlertCircle, Info } from "@/components/icons";
import { useAuthStore } from "./stores/auth"; import { useAuthStore } from "./stores/auth";
const authStore = useAuthStore(); const authStore = useAuthStore();
@ -130,12 +136,32 @@ onMounted(() => {
authStore.init(); authStore.init();
console.log(authStore.token); console.log(authStore.token);
// Hash
handleHashRoute();
// hash
window.addEventListener('hashchange', handleHashRoute);
// // // //
// if (chatStore.conversations.length === 0) { // if (chatStore.conversations.length === 0) {
// chatStore.createConversation(); // chatStore.createConversation();
// } // }
}); });
// Hash
function handleHashRoute() {
const hash = window.location.hash;
const shareMatch = hash.match(/^#\/share\/(.+)$/);
if (shareMatch) {
const shareId = shareMatch[1];
//
settingsStore.openShareViewModal();
//
console.log('Share ID:', shareId);
}
}
// 使 // 使
window.$toast = showToast; window.$toast = showToast;
</script> </script>

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/#/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

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

View File

@ -0,0 +1,494 @@
<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="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 {
X,
Lock,
Eye,
EyeOff,
Clock,
MessageSquare,
} from "@/components/icons";
const settingsStore = useSettingsStore();
const chatStore = useChatStore();
const show = computed(() => settingsStore.showShareModal);
const { selectedConversations, selectedCount } = storeToRefs(chatStore);
const password = ref("");
const showPassword = ref(false);
const isCreating = ref(false);
const isValidPassword = computed(() => {
return password.value.length >= 4 && password.value.length <= 20;
});
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;
//
if (selectedCount.value > SHARE_LIMITS.MAX_CONVERSATIONS) {
window.$toast?.(`最多分享 ${SHARE_LIMITS.MAX_CONVERSATIONS} 个对话`, 'error');
return;
}
isCreating.value = true;
try {
//
const passwordHash = await hashPassword(password.value);
//
const conversationIds = selectedConversations.value.map(c => c.id);
// API
const result = await shareApi.createShare({
conversationIds,
passwordHash,
expiresIn: SHARE_LIMITS.DEFAULT_EXPIRE_SECONDS,
});
//
handleClose();
//
settingsStore.setShareResult({
shareId: result.id,
shareUrl: result.shareUrl,
password: password.value,
expiresAt: result.expiresAt,
});
settingsStore.openShareResultModal();
// 退
password.value = "";
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;
}
}
}
.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,408 @@
<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 linkInput = ref<HTMLInputElement | null>(null);
const passwordInput = ref<HTMLInputElement | null>(null);
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

@ -0,0 +1,550 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="modal-overlay" @click.self="handleClose">
<div class="modal-container share-view-modal">
<div class="modal-header">
<h3>查看分享内容</h3>
<button class="close-btn" @click="handleClose">
<X :size="20" />
</button>
</div>
<div class="modal-content">
<!-- 加载状态 -->
<div v-if="isLoading" class="loading-state">
<Loader2 :size="32" class="spin" />
<span>加载中...</span>
</div>
<!-- 密码验证 -->
<div v-else-if="!isVerified" class="password-verify">
<div class="verify-icon">
<Lock :size="48" />
</div>
<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="password"
class="password-input"
placeholder="请输入访问密码"
@keydown.enter="handleVerify"
/>
</div>
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
<button
class="verify-btn"
:disabled="!password || isVerifying"
@click="handleVerify"
>
{{ isVerifying ? '验证中...' : '查看内容' }}
</button>
</div>
<!-- 分享内容 -->
<div v-else class="share-content">
<div class="content-header">
<span class="conversation-count">
{{ shareData?.conversations?.length || 0 }} 个对话
</span>
</div>
<div 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">
<div
v-for="message in activeConversation.messages"
:key="message.id"
class="message-item"
:class="message.role"
>
<div class="message-role">
{{ message.role === 'user' ? '用户' : '助手' }}
</div>
<div class="message-content">
{{ message.content.text }}
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { shareApi } from "@/services/shareApi";
import { hashPassword } from "@/utils/crypto";
import { formatTimestamp } from "@/utils/helpers";
import type { Share, ShareGetResponse } from "@/types/share";
import {
X,
Lock,
Loader2,
MessageSquare,
Clock,
AlertCircle,
} from "@/components/icons";
const settingsStore = useSettingsStore();
const show = computed(() => settingsStore.showShareViewModal);
const isLoading = ref(false);
const isVerifying = ref(false);
const isVerified = ref(false);
const password = ref("");
const errorMsg = ref("");
const shareInfo = ref<ShareGetResponse | null>(null);
const shareData = ref<Share | null>(null);
const activeConversationIndex = ref(0);
const activeConversation = computed(() => {
return shareData.value?.conversations?.[activeConversationIndex.value];
});
const formatExpiresAt = computed(() => {
if (!shareInfo.value) return "";
return formatTimestamp(shareInfo.value.expiresAt);
});
// ID
function getShareIdFromHash(): string | null {
const hash = window.location.hash;
const match = hash.match(/^#\/share\/(.+)$/);
return match ? match[1] : null;
}
//
async function loadShareInfo() {
const shareId = getShareIdFromHash();
if (!shareId) {
errorMsg.value = "无效的分享链接";
return;
}
isLoading.value = true;
errorMsg.value = "";
try {
shareInfo.value = await shareApi.getShare(shareId);
//
if (shareInfo.value.isExpired) {
errorMsg.value = "分享链接已过期";
}
//
if (!shareInfo.value.hasPassword) {
await verifyWithPassword("");
}
} catch (error) {
console.error("Failed to load share info:", error);
errorMsg.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 = getShareIdFromHash();
if (!shareId) return;
isVerifying.value = true;
errorMsg.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 {
errorMsg.value = "密码错误,请重试";
}
} catch (error) {
console.error("Failed to verify share:", error);
errorMsg.value = "验证失败,请重试";
} finally {
isVerifying.value = false;
}
}
function handleClose() {
settingsStore.closeShareViewModal();
// hash
window.location.hash = "";
}
//
watch(show, (newVal: boolean) => {
if (newVal) {
loadShareInfo();
} else {
//
password.value = "";
errorMsg.value = "";
isVerified.value = false;
shareInfo.value = null;
shareData.value = null;
activeConversationIndex.value = 0;
}
});
</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: 640px;
max-height: 85vh;
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;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 40px;
color: #6b7280;
.spin {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.password-verify {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px 0;
}
.verify-icon {
color: #6b7280;
}
.verify-hint {
font-size: 14px;
color: #6b7280;
margin: 0;
}
.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 {
width: 100%;
max-width: 300px;
margin-top: 8px;
}
.password-input {
width: 100%;
padding: 12px 16px;
border: 1px solid #e5e7eb;
border-radius: 10px;
font-size: 14px;
text-align: center;
letter-spacing: 2px;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
}
}
.error-msg {
font-size: 13px;
color: #ef4444;
margin: 0;
}
.verify-btn {
padding: 12px 32px;
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;
}
}
.share-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.conversation-count {
font-size: 13px;
color: #6b7280;
}
.conversation-tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
}
.tab-btn {
flex-shrink: 0;
padding: 8px 16px;
border: none;
border-radius: 8px;
background: #f3f4f6;
color: #6b7280;
font-size: 13px;
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;
max-height: 400px;
overflow-y: auto;
padding: 16px;
background: #f9fafb;
border-radius: 12px;
.dark & {
background: #2d2d3d;
}
}
.message-item {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
&.user {
.message-role {
color: #3b82f6;
}
}
&.assistant {
.message-role {
color: #10b981;
}
}
}
.message-role {
font-size: 12px;
font-weight: 500;
margin-bottom: 4px;
}
.message-content {
font-size: 14px;
color: #374151;
line-height: 1.6;
white-space: pre-wrap;
.dark & {
color: #e5e7eb;
}
}
//
.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>
@ -153,6 +162,7 @@ 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,7 +175,7 @@ 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 {
@ -228,6 +238,14 @@ function togglePinConversation(id: string) {
chatStore.togglePinConversation(id); chatStore.togglePinConversation(id);
} }
function toggleConversationSelection(id: string) {
chatStore.toggleConversationSelection(id);
}
function isConversationSelected(id: string): boolean {
return selectedConversationIds.value.includes(id);
}
// //
const isResizing = ref(false); const isResizing = ref(false);

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,6 +105,18 @@ const formattedTime = computed(() => {
return formatTimestamp(props.conversation.updatedAt); return formatTimestamp(props.conversation.updatedAt);
}); });
function handleClick() {
if (props.isSelectMode) {
emit("toggleSelect", props.conversation.id);
} else if (!isEditing.value) {
emit("select", props.conversation.id);
}
}
function handleToggleSelect() {
emit("toggleSelect", props.conversation.id);
}
function handleSelect() { function handleSelect() {
if (!isEditing.value) { if (!isEditing.value) {
emit("select", props.conversation.id); emit("select", props.conversation.id);
@ -103,6 +128,7 @@ function handleTogglePin() {
} }
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(() => {
@ -249,7 +275,7 @@ function handleDelete() {
.action-btn { .action-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify: center;
width: 26px; width: 26px;
height: 26px; height: 26px;
border: none; border: none;
@ -274,4 +300,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,166 @@
<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 { computed } from "vue";
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>

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

@ -0,0 +1,78 @@
/**
* API
*/
import { getAuthHeaders } from './request';
import type {
Share,
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,10 @@ 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 currentConversation = computed(() => { const currentConversation = computed(() => {
return ( return (
@ -43,6 +47,14 @@ 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);
// 初始化方法 - 从后端 API 加载数据 // 初始化方法 - 从后端 API 加载数据
async function initializeFromApi() { async function initializeFromApi() {
if (isInitialized.value || isLoading.value) return; if (isInitialized.value || isLoading.value) return;
@ -486,6 +498,54 @@ 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);
}
// 初始化 // 初始化
initializeFromApi(); initializeFromApi();
@ -497,11 +557,16 @@ export const useChatStore = defineStore("chat", () => {
streamController, streamController,
isInitialized, isInitialized,
isLoading, isLoading,
// 分享多选模式状态
isSelectMode,
selectedConversationIds,
// 计算属性 // 计算属性
currentConversation, currentConversation,
sortedConversations, sortedConversations,
pinnedConversations, pinnedConversations,
recentConversations, recentConversations,
selectedConversations,
selectedCount,
// 方法 // 方法
initializeFromApi, initializeFromApi,
createConversation, createConversation,
@ -522,5 +587,13 @@ export const useChatStore = defineStore("chat", () => {
clearConversation, clearConversation,
loadFromStorage, loadFromStorage,
saveToStorage, saveToStorage,
// 分享多选模式方法
toggleSelectMode,
enterSelectMode,
exitSelectMode,
toggleConversationSelection,
selectAllConversations,
clearSelection,
isConversationSelected,
}; };
}); });

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,12 @@ 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 showShareViewModal = 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 +189,39 @@ 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 openShareViewModal() {
showShareViewModal.value = true;
}
function closeShareViewModal() {
showShareViewModal.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 +345,11 @@ export const useSettingsStore = defineStore("settings", () => {
showSettingsModal, showSettingsModal,
showConversationSettingsModal, showConversationSettingsModal,
availableModels, availableModels,
// 分享相关状态
showShareModal,
showShareResultModal,
showShareViewModal,
shareResult,
// 方法 // 方法
toggleTheme, toggleTheme,
@ -313,6 +365,15 @@ export const useSettingsStore = defineStore("settings", () => {
closeSettingsModal, closeSettingsModal,
openConversationSettingsModal, openConversationSettingsModal,
closeConversationSettingsModal, closeConversationSettingsModal,
// 分享模态框方法
openShareModal,
closeShareModal,
openShareResultModal,
closeShareResultModal,
openShareViewModal,
closeShareViewModal,
setShareResult,
clearShareResult,
updateSettings, updateSettings,
resetSettings, resetSettings,
exportSettings, exportSettings,

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

@ -0,0 +1,68 @@
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列表
passwordHash: string; // 密码哈希值
expiresIn?: number; // 过期时间默认7天
}
/**
*
*/
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;
}

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'),
},
},
})