- ShareModal: 分享弹窗组件,支持内容控制和二维码 - MessageSelector: 消息选择器,支持按轮次选择分享内容 - ShareNavigator: 分享页面导航组件,支持目录和返回顶部
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
import { Search, Check } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// 消息接口
|
|
interface Message {
|
|
id: string;
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
// 问答轮次接口
|
|
interface Round {
|
|
index: number;
|
|
userMessage: Message;
|
|
assistantMessage?: Message;
|
|
preview: string;
|
|
messageIds: string[];
|
|
}
|
|
|
|
// 选择模式
|
|
type SelectionMode = 'all' | 'recent_1' | 'recent_3' | 'recent_5' | 'custom';
|
|
|
|
interface MessageSelectorProps {
|
|
messages: Message[];
|
|
selectedMessageIds: string[] | null;
|
|
onSelectionChange: (messageIds: string[] | null) => void;
|
|
}
|
|
|
|
export function MessageSelector({
|
|
messages,
|
|
selectedMessageIds,
|
|
onSelectionChange,
|
|
}: MessageSelectorProps) {
|
|
const [selectionMode, setSelectionMode] = useState<SelectionMode>('all');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
// 将消息按问答轮次分组
|
|
const rounds = useMemo(() => {
|
|
const result: Round[] = [];
|
|
let currentRound: Round | null = null;
|
|
|
|
messages.forEach((msg) => {
|
|
if (msg.role === 'user') {
|
|
if (currentRound) {
|
|
result.push(currentRound);
|
|
}
|
|
currentRound = {
|
|
index: result.length + 1,
|
|
userMessage: msg,
|
|
preview: msg.content.slice(0, 50) + (msg.content.length > 50 ? '...' : ''),
|
|
messageIds: [msg.id],
|
|
};
|
|
} else if (msg.role === 'assistant' && currentRound) {
|
|
currentRound.assistantMessage = msg;
|
|
currentRound.messageIds.push(msg.id);
|
|
}
|
|
});
|
|
|
|
if (currentRound) {
|
|
result.push(currentRound);
|
|
}
|
|
|
|
return result;
|
|
}, [messages]);
|
|
|
|
// 根据消息数量生成选项
|
|
const modeOptions = useMemo(() => {
|
|
const options: { value: SelectionMode; label: string }[] = [
|
|
{ value: 'all', label: '全部对话' },
|
|
];
|
|
|
|
if (rounds.length > 1) {
|
|
options.push({ value: 'recent_1', label: '最近 1 轮' });
|
|
}
|
|
if (rounds.length > 3) {
|
|
options.push({ value: 'recent_3', label: '最近 3 轮' });
|
|
}
|
|
if (rounds.length > 5) {
|
|
options.push({ value: 'recent_5', label: '最近 5 轮' });
|
|
}
|
|
if (rounds.length > 1) {
|
|
options.push({ value: 'custom', label: '自定义选择' });
|
|
}
|
|
|
|
return options;
|
|
}, [rounds.length]);
|
|
|
|
// 根据选择模式获取选中的消息ID
|
|
const getMessageIdsByMode = useCallback(
|
|
(mode: SelectionMode): string[] | null => {
|
|
if (mode === 'all') return null;
|
|
if (mode === 'custom') return selectedMessageIds;
|
|
|
|
const recentCount = parseInt(mode.replace('recent_', ''));
|
|
const recentRounds = rounds.slice(-recentCount);
|
|
return recentRounds.flatMap((r) => r.messageIds);
|
|
},
|
|
[rounds, selectedMessageIds]
|
|
);
|
|
|
|
// 处理模式变化
|
|
const handleModeChange = (mode: SelectionMode) => {
|
|
setSelectionMode(mode);
|
|
if (mode === 'custom') {
|
|
if (selectedMessageIds === null) {
|
|
onSelectionChange(messages.map((m) => m.id));
|
|
}
|
|
} else {
|
|
onSelectionChange(getMessageIdsByMode(mode));
|
|
}
|
|
};
|
|
|
|
// 当前选中的轮次
|
|
const selectedRoundIndices = useMemo(() => {
|
|
if (selectedMessageIds === null) return new Set(rounds.map((r) => r.index));
|
|
return new Set(
|
|
rounds
|
|
.filter((r) => r.messageIds.some((id) => selectedMessageIds.includes(id)))
|
|
.map((r) => r.index)
|
|
);
|
|
}, [rounds, selectedMessageIds]);
|
|
|
|
// 切换轮次选择
|
|
const toggleRound = (round: Round) => {
|
|
if (selectionMode !== 'custom') return;
|
|
|
|
if (selectedMessageIds === null) {
|
|
const allIds = messages.map((m) => m.id);
|
|
const newIds = allIds.filter((id) => !round.messageIds.includes(id));
|
|
onSelectionChange(newIds);
|
|
} else {
|
|
const isSelected = round.messageIds.some((id) => selectedMessageIds.includes(id));
|
|
if (isSelected) {
|
|
const newIds = selectedMessageIds.filter((id) => !round.messageIds.includes(id));
|
|
onSelectionChange(newIds.length > 0 ? newIds : []);
|
|
} else {
|
|
onSelectionChange([...selectedMessageIds, ...round.messageIds]);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 快捷操作
|
|
const handleSelectAll = () => {
|
|
onSelectionChange(messages.map((m) => m.id));
|
|
};
|
|
|
|
const handleSelectNone = () => {
|
|
onSelectionChange([]);
|
|
};
|
|
|
|
const handleSelectInverse = () => {
|
|
if (selectedMessageIds === null) {
|
|
onSelectionChange([]);
|
|
} else {
|
|
const allIds = messages.map((m) => m.id);
|
|
const inverseIds = allIds.filter((id) => !selectedMessageIds.includes(id));
|
|
onSelectionChange(inverseIds);
|
|
}
|
|
};
|
|
|
|
// 搜索过滤
|
|
const filteredRounds = useMemo(() => {
|
|
if (!searchQuery.trim()) return rounds;
|
|
const query = searchQuery.toLowerCase();
|
|
return rounds.filter(
|
|
(round) =>
|
|
round.userMessage.content.toLowerCase().includes(query) ||
|
|
round.assistantMessage?.content.toLowerCase().includes(query)
|
|
);
|
|
}, [rounds, searchQuery]);
|
|
|
|
// 计算选中数量
|
|
const selectedCount = useMemo(() => {
|
|
if (selectedMessageIds === null) return rounds.length;
|
|
return rounds.filter((r) => r.messageIds.some((id) => selectedMessageIds.includes(id))).length;
|
|
}, [rounds, selectedMessageIds]);
|
|
|
|
// 如果只有一轮对话,不显示选择器
|
|
if (rounds.length <= 1) {
|
|
return null;
|
|
}
|
|
|
|
const isCustomMode = selectionMode === 'custom';
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 标题行 */}
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
|
选择分享范围
|
|
</label>
|
|
<span className="text-xs text-[var(--color-text-tertiary)]">
|
|
共 {rounds.length} 轮对话
|
|
</span>
|
|
</div>
|
|
|
|
{/* 左右分栏布局 */}
|
|
<div className="flex gap-3 border border-[var(--color-border)] rounded-lg overflow-hidden">
|
|
{/* 左侧:选择模式 */}
|
|
<div className="w-[140px] flex-shrink-0 bg-[var(--color-bg-secondary)] border-r border-[var(--color-border)] p-2 space-y-1">
|
|
{modeOptions.map((option) => (
|
|
<label
|
|
key={option.value}
|
|
className={cn(
|
|
'flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer transition-all text-sm',
|
|
selectionMode === option.value
|
|
? 'bg-[var(--color-primary)] text-white'
|
|
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
|
|
)}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="selectionMode"
|
|
value={option.value}
|
|
checked={selectionMode === option.value}
|
|
onChange={() => handleModeChange(option.value)}
|
|
className="sr-only"
|
|
/>
|
|
<span className="truncate">{option.label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{/* 右侧:消息列表 */}
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
{/* 搜索框和快捷操作 */}
|
|
<div className="flex items-center gap-2 p-2 border-b border-[var(--color-border)]">
|
|
<div className="relative flex-1">
|
|
<Search
|
|
size={14}
|
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="搜索消息..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-8 pr-3 py-1.5 text-xs bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-md text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]"
|
|
/>
|
|
</div>
|
|
{isCustomMode && (
|
|
<div className="flex items-center gap-1 text-xs flex-shrink-0">
|
|
<button
|
|
onClick={handleSelectAll}
|
|
className="px-2 py-1 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
|
|
>
|
|
全选
|
|
</button>
|
|
<span className="text-[var(--color-border)]">|</span>
|
|
<button
|
|
onClick={handleSelectNone}
|
|
className="px-2 py-1 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
|
|
>
|
|
全不选
|
|
</button>
|
|
<span className="text-[var(--color-border)]">|</span>
|
|
<button
|
|
onClick={handleSelectInverse}
|
|
className="px-2 py-1 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors"
|
|
>
|
|
反选
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 消息列表 */}
|
|
<div className="flex-1 overflow-y-auto max-h-[180px]">
|
|
{filteredRounds.length === 0 ? (
|
|
<div className="px-3 py-6 text-center text-xs text-[var(--color-text-tertiary)]">
|
|
没有找到匹配的消息
|
|
</div>
|
|
) : (
|
|
filteredRounds.map((round) => {
|
|
const isSelected = selectedRoundIndices.has(round.index);
|
|
const canToggle = isCustomMode;
|
|
|
|
return (
|
|
<div
|
|
key={round.index}
|
|
onClick={() => canToggle && toggleRound(round)}
|
|
className={cn(
|
|
'flex items-center gap-2 px-3 py-2.5 border-b border-[var(--color-border)] last:border-b-0 transition-colors',
|
|
canToggle ? 'cursor-pointer hover:bg-[var(--color-bg-hover)]' : '',
|
|
isSelected && 'bg-[var(--color-primary)]/5'
|
|
)}
|
|
>
|
|
{/* 复选框 */}
|
|
<div
|
|
className={cn(
|
|
'flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center transition-colors',
|
|
isSelected
|
|
? 'bg-[var(--color-primary)] border-[var(--color-primary)]'
|
|
: 'border-[var(--color-border)]',
|
|
!canToggle && 'opacity-60'
|
|
)}
|
|
>
|
|
{isSelected && <Check size={10} className="text-white" />}
|
|
</div>
|
|
|
|
{/* 序号 */}
|
|
<span
|
|
className={cn(
|
|
'flex-shrink-0 w-5 h-5 rounded-full text-xs flex items-center justify-center',
|
|
isSelected
|
|
? 'bg-[var(--color-primary)]/20 text-[var(--color-primary)]'
|
|
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]'
|
|
)}
|
|
>
|
|
{round.index}
|
|
</span>
|
|
|
|
{/* 内容预览 */}
|
|
<span
|
|
className={cn(
|
|
'flex-1 text-sm truncate',
|
|
isSelected
|
|
? 'text-[var(--color-text-primary)]'
|
|
: 'text-[var(--color-text-secondary)]'
|
|
)}
|
|
>
|
|
{round.preview}
|
|
</span>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* 底部统计 */}
|
|
<div className="flex items-center justify-between px-3 py-2 bg-[var(--color-bg-secondary)] border-t border-[var(--color-border)] text-xs text-[var(--color-text-tertiary)]">
|
|
<span>已选 {selectedCount}/{rounds.length} 轮</span>
|
|
{isCustomMode && selectedCount === 0 && (
|
|
<span className="text-amber-600">请至少选择一轮</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|