Compare commits
9 Commits
fix/web-se
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
b66bdaedd2 | |
|
|
1df9ee3cf2 | |
|
|
379e033e17 | |
|
|
eff089c7ad | |
|
|
7b4fb72cdc | |
|
|
9566c6e0c4 | |
|
|
4d2caddeee | |
|
|
b51831dd15 | |
|
|
3b7a831840 |
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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:
|
||||||
|
"""生成分享 ID(8位短链接)"""
|
||||||
|
import uuid
|
||||||
|
return uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""初始化数据库(应用启动时调用)"""
|
"""初始化数据库(应用启动时调用)"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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", "")
|
||||||
|
|
|
||||||
82
src/App.vue
82
src/App.vue
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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(),
|
||||||
|
})))
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ export {
|
||||||
|
|
||||||
// 其他
|
// 其他
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
CheckCircle,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Lock,
|
Lock,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖拽调整宽度
|
// 拖拽调整宽度
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue