feat: 消息级别分享功能
- 新增消息选择模式,支持在当前对话内多选消息分享 - MessageBubble 添加选择模式UI(复选框、选中样式) - MessageList 添加选择操作栏(全选、取消、确认分享) - ShareModal 支持消息分享和对话分享两种模式 - 后端分享API支持直接传递消息数据 - chat store 新增消息选择状态和方法 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9566c6e0c4
commit
7b4fb72cdc
|
|
@ -23,7 +23,8 @@ from core import log_error, log_info
|
||||||
|
|
||||||
class CreateShareRequest(BaseModel):
|
class CreateShareRequest(BaseModel):
|
||||||
"""创建分享请求"""
|
"""创建分享请求"""
|
||||||
conversationIds: List[str]
|
conversationIds: List[str] = []
|
||||||
|
messages: Optional[List[dict]] = None # 直接传递消息数据(消息分享模式)
|
||||||
passwordHash: str
|
passwordHash: str
|
||||||
expiresIn: Optional[int] = 604800 # 默认7天(秒)
|
expiresIn: Optional[int] = 604800 # 默认7天(秒)
|
||||||
|
|
||||||
|
|
@ -42,27 +43,68 @@ async def create_share_handler(data: dict):
|
||||||
请求体:
|
请求体:
|
||||||
{
|
{
|
||||||
"conversationIds": ["conv-1", "conv-2"],
|
"conversationIds": ["conv-1", "conv-2"],
|
||||||
|
"messages": [...], // 可选,消息分享模式
|
||||||
"passwordHash": "sha256-hash",
|
"passwordHash": "sha256-hash",
|
||||||
"expiresIn": 604800
|
"expiresIn": 604800
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
conversation_ids = data.get("conversationIds", [])
|
conversation_ids = data.get("conversationIds", [])
|
||||||
|
messages = data.get("messages") # 消息分享模式
|
||||||
password_hash = data.get("passwordHash", "")
|
password_hash = data.get("passwordHash", "")
|
||||||
expires_in = data.get("expiresIn", 604800)
|
expires_in = data.get("expiresIn", 604800)
|
||||||
|
|
||||||
if not conversation_ids:
|
|
||||||
raise HTTPException(status_code=400, detail="请选择要分享的对话")
|
|
||||||
|
|
||||||
if len(conversation_ids) > 10:
|
|
||||||
raise HTTPException(status_code=400, detail="最多分享10个对话")
|
|
||||||
|
|
||||||
if not password_hash:
|
if not password_hash:
|
||||||
raise HTTPException(status_code=400, detail="请设置访问密码")
|
raise HTTPException(status_code=400, detail="请设置访问密码")
|
||||||
|
|
||||||
# 获取数据库实例
|
# 获取数据库实例
|
||||||
db = get_db()
|
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"/#/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 = []
|
conversations = []
|
||||||
for conv_id in conversation_ids:
|
for conv_id in conversation_ids:
|
||||||
|
|
@ -81,10 +123,6 @@ async def create_share_handler(data: dict):
|
||||||
if not conversations:
|
if not conversations:
|
||||||
raise HTTPException(status_code=404, detail="未找到有效的对话")
|
raise HTTPException(status_code=404, detail="未找到有效的对话")
|
||||||
|
|
||||||
# 计算过期时间
|
|
||||||
now = int(datetime.now(timezone.utc).timestamp() * 1000)
|
|
||||||
expires_at = now + (expires_in * 1000)
|
|
||||||
|
|
||||||
# 创建分享记录
|
# 创建分享记录
|
||||||
share_data = {
|
share_data = {
|
||||||
"conversationIds": conversation_ids,
|
"conversationIds": conversation_ids,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,8 @@
|
||||||
v-if="
|
v-if="
|
||||||
message.role === 'assistant' &&
|
message.role === 'assistant' &&
|
||||||
!message.isStreaming &&
|
!message.isStreaming &&
|
||||||
!message.isError
|
!message.isError &&
|
||||||
|
!isMessageSelectMode
|
||||||
"
|
"
|
||||||
:content="message.content.text || ''"
|
:content="message.content.text || ''"
|
||||||
:feedback="message.feedback"
|
:feedback="message.feedback"
|
||||||
|
|
@ -176,6 +187,7 @@
|
||||||
@like="handleLike"
|
@like="handleLike"
|
||||||
@dislike="handleDislike"
|
@dislike="handleDislike"
|
||||||
@regenerate="$emit('regenerate')"
|
@regenerate="$emit('regenerate')"
|
||||||
|
@share="handleShareClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -195,6 +207,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 +221,14 @@ const props = withDefaults(
|
||||||
showTimestamp?: boolean;
|
showTimestamp?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
|
isMessageSelectMode?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
showTimestamp: true,
|
showTimestamp: true,
|
||||||
compact: false,
|
compact: false,
|
||||||
|
isMessageSelectMode: false,
|
||||||
|
isSelected: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { copy } = useClipboard({ legacy: true });
|
const { copy } = useClipboard({ legacy: true });
|
||||||
|
|
@ -225,10 +242,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 +310,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;
|
||||||
|
|
||||||
|
|
@ -830,4 +888,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>
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,35 @@
|
||||||
<div v-if="show" class="modal-overlay" @click.self="handleClose">
|
<div v-if="show" class="modal-overlay" @click.self="handleClose">
|
||||||
<div class="modal-container">
|
<div class="modal-container">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>分享对话</h3>
|
<h3>{{ isMessageShare ? '分享消息' : '分享对话' }}</h3>
|
||||||
<button class="close-btn" @click="handleClose">
|
<button class="close-btn" @click="handleClose">
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<!-- 已选择的对话预览 -->
|
<!-- 消息分享预览 -->
|
||||||
<div class="selected-preview">
|
<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">
|
<div class="preview-header">
|
||||||
<span class="preview-title">已选择 {{ selectedCount }} 个对话</span>
|
<span class="preview-title">已选择 {{ selectedCount }} 个对话</span>
|
||||||
<span class="preview-hint">最多分享 10 个对话</span>
|
<span class="preview-hint">最多分享 10 个对话</span>
|
||||||
|
|
@ -86,6 +106,7 @@ import { useSettingsStore } from "@/stores/settings";
|
||||||
import { hashPassword } from "@/utils/crypto";
|
import { hashPassword } from "@/utils/crypto";
|
||||||
import { shareApi } from "@/services/shareApi";
|
import { shareApi } from "@/services/shareApi";
|
||||||
import { SHARE_LIMITS } from "@/types/share";
|
import { SHARE_LIMITS } from "@/types/share";
|
||||||
|
import type { Message } from "@/types/chat";
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Lock,
|
Lock,
|
||||||
|
|
@ -99,7 +120,10 @@ const settingsStore = useSettingsStore();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
const show = computed(() => settingsStore.showShareModal);
|
const show = computed(() => settingsStore.showShareModal);
|
||||||
const { selectedConversations, selectedCount } = storeToRefs(chatStore);
|
const { selectedConversations, selectedCount, isMessageSelectMode, selectedMessages, selectedMessageCount } = storeToRefs(chatStore);
|
||||||
|
|
||||||
|
// 是否是消息分享模式
|
||||||
|
const isMessageShare = computed(() => isMessageSelectMode.value);
|
||||||
|
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
|
|
@ -109,6 +133,12 @@ const isValidPassword = computed(() => {
|
||||||
return password.value.length >= 4 && password.value.length <= 20;
|
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() {
|
function togglePasswordVisibility() {
|
||||||
showPassword.value = !showPassword.value;
|
showPassword.value = !showPassword.value;
|
||||||
const input = document.querySelector('.password-input') as HTMLInputElement;
|
const input = document.querySelector('.password-input') as HTMLInputElement;
|
||||||
|
|
@ -120,27 +150,43 @@ function togglePasswordVisibility() {
|
||||||
async function handleCreateShare() {
|
async function handleCreateShare() {
|
||||||
if (!isValidPassword.value || isCreating.value) return;
|
if (!isValidPassword.value || isCreating.value) return;
|
||||||
|
|
||||||
// 检查数量限制
|
|
||||||
if (selectedCount.value > SHARE_LIMITS.MAX_CONVERSATIONS) {
|
|
||||||
window.$toast?.(`最多分享 ${SHARE_LIMITS.MAX_CONVERSATIONS} 个对话`, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isCreating.value = true;
|
isCreating.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 哈希密码
|
// 哈希密码
|
||||||
const passwordHash = await hashPassword(password.value);
|
const passwordHash = await hashPassword(password.value);
|
||||||
|
|
||||||
// 获取选中的对话数据
|
let result;
|
||||||
const conversationIds = selectedConversations.value.map(c => c.id);
|
|
||||||
|
|
||||||
// 调用 API 创建分享
|
if (isMessageShare.value) {
|
||||||
const result = await shareApi.createShare({
|
// 消息分享模式
|
||||||
conversationIds,
|
result = await shareApi.createShare({
|
||||||
passwordHash,
|
conversationIds: [],
|
||||||
expiresIn: SHARE_LIMITS.DEFAULT_EXPIRE_SECONDS,
|
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()
|
handleClose()
|
||||||
|
|
@ -159,7 +205,11 @@ async function handleCreateShare() {
|
||||||
|
|
||||||
// 清空密码和退出选择模式
|
// 清空密码和退出选择模式
|
||||||
password.value = "";
|
password.value = "";
|
||||||
chatStore.exitSelectMode();
|
if (isMessageShare.value) {
|
||||||
|
chatStore.exitMessageSelectMode();
|
||||||
|
} else {
|
||||||
|
chatStore.exitSelectMode();
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create share:', error);
|
console.error('Failed to create share:', error);
|
||||||
|
|
@ -327,6 +377,38 @@ watch(show, (newVal: boolean) => {
|
||||||
color: #9ca3af;
|
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-section {
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ function handleDelete() {
|
||||||
.action-btn {
|
.action-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify: center;
|
justify-content: center;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
||||||
|
|
@ -19,10 +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 isSelectMode = ref(false);
|
||||||
const selectedConversationIds = ref<string[]>([]);
|
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 (
|
||||||
|
|
@ -55,6 +60,16 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
// 选中的对话数量
|
// 选中的对话数量
|
||||||
const selectedCount = computed(() => selectedConversationIds.value.length);
|
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;
|
||||||
|
|
@ -546,6 +561,47 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
return selectedConversationIds.value.includes(id);
|
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();
|
||||||
|
|
||||||
|
|
@ -560,6 +616,12 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
// 分享多选模式状态
|
// 分享多选模式状态
|
||||||
isSelectMode,
|
isSelectMode,
|
||||||
selectedConversationIds,
|
selectedConversationIds,
|
||||||
|
// 消息分享选择模式状态
|
||||||
|
isMessageSelectMode,
|
||||||
|
selectedMessageIds,
|
||||||
|
sourceConversationId,
|
||||||
|
selectedMessages,
|
||||||
|
selectedMessageCount,
|
||||||
// 计算属性
|
// 计算属性
|
||||||
currentConversation,
|
currentConversation,
|
||||||
sortedConversations,
|
sortedConversations,
|
||||||
|
|
@ -595,5 +657,11 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
selectAllConversations,
|
selectAllConversations,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
isConversationSelected,
|
isConversationSelected,
|
||||||
|
// 消息分享选择模式方法
|
||||||
|
enterMessageSelectMode,
|
||||||
|
exitMessageSelectMode,
|
||||||
|
toggleMessageSelection,
|
||||||
|
selectAllMessages,
|
||||||
|
isMessageSelected,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -18,10 +18,21 @@ export interface Share {
|
||||||
*/
|
*/
|
||||||
export interface ShareCreateRequest {
|
export interface ShareCreateRequest {
|
||||||
conversationIds: string[]; // 要分享的对话ID列表
|
conversationIds: string[]; // 要分享的对话ID列表
|
||||||
|
messages?: MessageData[]; // 直接传递消息数据(消息分享模式)
|
||||||
passwordHash: string; // 密码哈希值
|
passwordHash: string; // 密码哈希值
|
||||||
expiresIn?: number; // 过期时间(秒),默认7天
|
expiresIn?: number; // 过期时间(秒),默认7天
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息数据(用于分享)
|
||||||
|
*/
|
||||||
|
export interface MessageData {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
content: any;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建分享响应
|
* 创建分享响应
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,10 @@
|
||||||
共 {{ shareData?.conversations?.length || 0 }} 个对话
|
共 {{ shareData?.conversations?.length || 0 }} 个对话
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="theme-toggle" @click="toggleTheme">
|
<!-- <button class="theme-toggle" @click="toggleTheme">
|
||||||
<Sun v-if="isDark" :size="20" />
|
<Sun v-if="isDark" :size="20" />
|
||||||
<Moon v-else :size="20" />
|
<Moon v-else :size="20" />
|
||||||
</button>
|
</button> -->
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- 对话切换标签 -->
|
<!-- 对话切换标签 -->
|
||||||
|
|
@ -126,8 +126,6 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Sun,
|
|
||||||
Moon,
|
|
||||||
} from '@/components/icons'
|
} from '@/components/icons'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -171,10 +169,6 @@ const visibleMessages = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
function toggleTheme() {
|
|
||||||
settingsStore.toggleTheme()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadShareInfo() {
|
async function loadShareInfo() {
|
||||||
const shareId = route.params.id as string
|
const shareId = route.params.id as string
|
||||||
if (!shareId) {
|
if (!shareId) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue