feat(history): 实现三模历史搜索、每日智能协同摘要与时间轴侧栏 (Phase P2)
This commit is contained in:
@@ -9,6 +9,8 @@ import {
|
||||
Calendar,
|
||||
Layers,
|
||||
Info,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { MessageCategory, ChatCategoryStat } from '../../types/workbench';
|
||||
|
||||
@@ -18,6 +20,8 @@ interface CategoryFilterBarProps {
|
||||
onSelectCategory: (cat: MessageCategory | 'all') => void;
|
||||
onToggleCalendar?: () => void;
|
||||
isCalendarOpen?: boolean;
|
||||
onOpenSearch?: () => void;
|
||||
onOpenDigest?: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_META: Record<
|
||||
@@ -40,6 +44,8 @@ export const CategoryFilterBar: React.FC<CategoryFilterBarProps> = ({
|
||||
onSelectCategory,
|
||||
onToggleCalendar,
|
||||
isCalendarOpen = false,
|
||||
onOpenSearch,
|
||||
onOpenDigest,
|
||||
}) => {
|
||||
const totalCount = categories.reduce((sum, c) => sum + c.count, 0);
|
||||
|
||||
@@ -155,34 +161,84 @@ export const CategoryFilterBar: React.FC<CategoryFilterBarProps> = ({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 右侧日历入口 */}
|
||||
{onToggleCalendar && (
|
||||
<button
|
||||
onClick={onToggleCalendar}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
padding: '3px 10px',
|
||||
borderRadius: 'var(--r-pill, 9999px)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
background: isCalendarOpen
|
||||
? 'rgba(79, 107, 255, 0.2)'
|
||||
: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
|
||||
color: isCalendarOpen ? 'var(--brand-400, #6b84ff)' : 'var(--text-muted, #94a3b8)',
|
||||
border: isCalendarOpen
|
||||
? '1px solid var(--brand-500, #4f6bff)'
|
||||
: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
title="展开历史日历"
|
||||
>
|
||||
<Calendar size={13} />
|
||||
<span>历史日历</span>
|
||||
</button>
|
||||
)}
|
||||
{/* 右侧工具组: 搜索、每日摘要、日历 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 }}>
|
||||
{onOpenSearch && (
|
||||
<button
|
||||
onClick={onOpenSearch}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '3px 8px',
|
||||
borderRadius: 'var(--r-pill, 9999px)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
background: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
|
||||
color: 'var(--text-muted, #94a3b8)',
|
||||
border: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
title="搜索历史对话 (支持关键词/模糊/语义)"
|
||||
>
|
||||
<Search size={12} />
|
||||
<span>搜索</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onOpenDigest && (
|
||||
<button
|
||||
onClick={onOpenDigest}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '3px 8px',
|
||||
borderRadius: 'var(--r-pill, 9999px)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
background: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
|
||||
color: 'var(--text-muted, #94a3b8)',
|
||||
border: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
title="查看每日智能协同摘要"
|
||||
>
|
||||
<Sparkles size={12} color="var(--brand-400, #6b84ff)" />
|
||||
<span>摘要</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onToggleCalendar && (
|
||||
<button
|
||||
onClick={onToggleCalendar}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '3px 9px',
|
||||
borderRadius: 'var(--r-pill, 9999px)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
background: isCalendarOpen
|
||||
? 'rgba(79, 107, 255, 0.2)'
|
||||
: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
|
||||
color: isCalendarOpen ? 'var(--brand-400, #6b84ff)' : 'var(--text-muted, #94a3b8)',
|
||||
border: isCalendarOpen
|
||||
? '1px solid var(--brand-500, #4f6bff)'
|
||||
: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
title="展开历史日历"
|
||||
>
|
||||
<Calendar size={12} />
|
||||
<span>日历</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Search, X, Loader2, Calendar, ArrowRight } from 'lucide-react';
|
||||
import { ChatSearchHit, ChatSearchMode } from '../../types/workbench';
|
||||
import { searchChatMessages } from '../../api/chatSearch';
|
||||
|
||||
interface ChatSearchModalProps {
|
||||
sessionId: string;
|
||||
onSelectHit: (hit: ChatSearchHit) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ChatSearchModal: React.FC<ChatSearchModalProps> = ({
|
||||
sessionId,
|
||||
onSelectHit,
|
||||
onClose,
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [mode, setMode] = useState<ChatSearchMode>('hybrid');
|
||||
const [hits, setHits] = useState<ChatSearchHit[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
// 防抖搜索
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
setHits([]);
|
||||
setIsLoading(false);
|
||||
setHasSearched(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const timer = setTimeout(() => {
|
||||
searchChatMessages({
|
||||
sessionId,
|
||||
q: trimmed,
|
||||
mode,
|
||||
limit: 50,
|
||||
})
|
||||
.then((res) => {
|
||||
setHits(res || []);
|
||||
setHasSearched(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('search failed:', err);
|
||||
setHits([]);
|
||||
setHasSearched(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, 280);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, mode, sessionId]);
|
||||
|
||||
// 高亮摘录中的关键词
|
||||
const renderHighlightedExcerpt = (excerpt: string, searchWord: string) => {
|
||||
if (!searchWord.trim()) return excerpt;
|
||||
const parts = excerpt.split(new RegExp(`(${escapeRegex(searchWord)})`, 'gi'));
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === searchWord.toLowerCase() ? (
|
||||
<mark
|
||||
key={i}
|
||||
style={{
|
||||
background: 'rgba(245, 158, 11, 0.3)',
|
||||
color: '#fbbf24',
|
||||
padding: '1px 3px',
|
||||
borderRadius: '3px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1200,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '80px',
|
||||
background: 'rgba(15, 23, 42, 0.65)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '680px',
|
||||
maxWidth: '92vw',
|
||||
maxHeight: '75vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--bg-card, #1e293b)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
borderRadius: 'var(--r-lg, 12px)',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.5)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 顶部搜索框 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '14px 18px',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
<Search size={18} color="var(--brand-400, #6b84ff)" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索全历史消息、决策记录与技术参数..."
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontSize: '15px',
|
||||
color: 'var(--text-strong, #f8fafc)',
|
||||
}}
|
||||
/>
|
||||
{isLoading && <Loader2 size={16} className="animate-spin" color="var(--text-muted, #94a3b8)" />}
|
||||
{query && !isLoading && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--text-muted, #94a3b8)',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-muted, #94a3b8)',
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 检索模式切换栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 18px',
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ color: 'var(--text-muted, #94a3b8)' }}>模式:</span>
|
||||
{(['hybrid', 'keyword', 'fuzzy', 'semantic'] as ChatSearchMode[]).map((m) => {
|
||||
const active = mode === m;
|
||||
const labels: Record<ChatSearchMode, string> = {
|
||||
hybrid: '综合推荐',
|
||||
keyword: '精确关键词',
|
||||
fuzzy: '模糊匹配',
|
||||
semantic: '语义理解',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: active ? 'var(--brand-500, #4f6bff)' : 'transparent',
|
||||
color: active ? '#fff' : 'var(--text-muted, #94a3b8)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: active ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{labels[m]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasSearched && (
|
||||
<span style={{ color: 'var(--text-faint, #64748b)', fontSize: '11px' }}>
|
||||
找到 {hits.length} 条相关记录
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索结果列表 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '12px 18px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{query.trim() === '' && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 0',
|
||||
color: 'var(--text-faint, #64748b)',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
输入关键词(如“预算”、“GB 9706”、“注册”、“临床”),瞬时穿梭至历史锚点
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearched && hits.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 0',
|
||||
color: 'var(--text-muted, #94a3b8)',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
未找到匹配“{query}”的历史发言,可尝试切换至“模糊匹配”或“语义理解”模式
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hits.map((hit) => {
|
||||
const roleLabel = hit.role === 'user' ? '用户' : '项目助手';
|
||||
const isUser = hit.role === 'user';
|
||||
return (
|
||||
<div
|
||||
key={hit.message_id}
|
||||
onClick={() => onSelectHit(hit)}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: 'var(--r-md, 8px)',
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(79, 107, 255, 0.08)';
|
||||
e.currentTarget.style.borderColor = 'rgba(79, 107, 255, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.03)';
|
||||
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.06)';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '11px' }}>
|
||||
<span
|
||||
style={{
|
||||
color: isUser ? '#38bdf8' : 'var(--brand-400, #6b84ff)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{roleLabel}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-faint, #64748b)' }}>•</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '3px', color: 'var(--text-muted, #94a3b8)' }}>
|
||||
<Calendar size={11} />
|
||||
{hit.day_key}
|
||||
</span>
|
||||
{hit.category && (
|
||||
<>
|
||||
<span style={{ color: 'var(--text-faint, #64748b)' }}>•</span>
|
||||
<span
|
||||
style={{
|
||||
padding: '1px 5px',
|
||||
borderRadius: '3px',
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
color: 'var(--text-muted, #94a3b8)',
|
||||
}}
|
||||
>
|
||||
{hit.category}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', color: 'var(--text-faint, #64748b)' }}>
|
||||
<span>跳转</span>
|
||||
<ArrowRight size={12} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '13px', lineHeight: 1.5, color: 'var(--text-subtle, #cbd5e1)' }}>
|
||||
{renderHighlightedExcerpt(hit.excerpt, query)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Sparkles,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { DailyDigest } from '../../types/workbench';
|
||||
import { fetchDailyDigests, generateDailyDigest } from '../../api/chatDigest';
|
||||
|
||||
interface DigestTimelineModalProps {
|
||||
sessionId: string;
|
||||
onJumpToMessage: (messageId: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DigestTimelineModal: React.FC<DigestTimelineModalProps> = ({
|
||||
sessionId,
|
||||
onJumpToMessage,
|
||||
onClose,
|
||||
}) => {
|
||||
const [digests, setDigests] = useState<DailyDigest[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [regeneratingDay, setRegeneratingDay] = useState<string | null>(null);
|
||||
const [expandedCards, setExpandedCards] = useState<Record<string, boolean>>({});
|
||||
|
||||
const loadDigests = () => {
|
||||
setIsLoading(true);
|
||||
fetchDailyDigests(sessionId)
|
||||
.then((res) => {
|
||||
setDigests(res || []);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('fetchDailyDigests error:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDigests();
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [sessionId, onClose]);
|
||||
|
||||
const toggleExpand = (dayKey: string) => {
|
||||
setExpandedCards((prev) => ({
|
||||
...prev,
|
||||
[dayKey]: !prev[dayKey],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRegenerate = async (dayKey: string) => {
|
||||
setRegeneratingDay(dayKey);
|
||||
try {
|
||||
const res = await generateDailyDigest(sessionId, dayKey);
|
||||
if (res.digest) {
|
||||
setDigests((prev) => {
|
||||
const idx = prev.findIndex((d) => d.day_key === dayKey);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = res.digest;
|
||||
return next;
|
||||
}
|
||||
return [res.digest, ...prev];
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('regenerate failed:', err);
|
||||
} finally {
|
||||
setRegeneratingDay(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1200,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
background: 'rgba(15, 23, 42, 0.65)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '740px',
|
||||
maxWidth: '94vw',
|
||||
maxHeight: '82vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--bg-card, #1e293b)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
borderRadius: 'var(--r-lg, 12px)',
|
||||
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.55)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 顶部标题栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Sparkles size={18} color="var(--brand-400, #6b84ff)" />
|
||||
<h2 style={{ margin: 0, fontSize: '15px', fontWeight: 600, color: 'var(--text-strong, #f8fafc)' }}>
|
||||
每日协同与工作决策摘要 (Daily Digest)
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={loadDigests}
|
||||
title="刷新列表"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--text-muted, #94a3b8)',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--text-muted, #94a3b8)',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体摘要时间轴列表 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '18px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
}}
|
||||
>
|
||||
{isLoading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '60px 0', gap: '8px', color: 'var(--text-muted, #94a3b8)' }}>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
<span>正在拉取每日摘要时间轴...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && digests.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0', color: 'var(--text-muted, #94a3b8)', fontSize: '13px' }}>
|
||||
当前会话尚未生成每日摘要。对话历史沉淀后,系统将自动汇总每日核心结论与拍板决策。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{digests.map((digest) => {
|
||||
const isExpanded = expandedCards[digest.day_key] !== false; // 默认展开
|
||||
const isRegenerating = regeneratingDay === digest.day_key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={digest.day_key}
|
||||
style={{
|
||||
borderRadius: 'var(--r-md, 10px)',
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* 卡片头部 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
borderBottom: isExpanded ? '1px solid rgba(255, 255, 255, 0.06)' : 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => toggleExpand(digest.day_key)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--brand-400, #6b84ff)',
|
||||
}}
|
||||
>
|
||||
<Calendar size={14} />
|
||||
<span>{digest.day_key}</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '4px',
|
||||
background: digest.source === 'llm' ? 'rgba(79, 107, 255, 0.15)' : 'rgba(255, 255, 255, 0.08)',
|
||||
color: digest.source === 'llm' ? 'var(--brand-400, #6b84ff)' : 'var(--text-muted, #94a3b8)',
|
||||
}}
|
||||
>
|
||||
{digest.source === 'llm' ? 'AI总结' : '规则沉淀'} • {digest.message_count} 条消息
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (digest.first_message_id) {
|
||||
onJumpToMessage(digest.first_message_id);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '3px 9px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid rgba(79, 107, 255, 0.4)',
|
||||
background: 'rgba(79, 107, 255, 0.1)',
|
||||
color: 'var(--brand-400, #6b84ff)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span>定位当日首条</span>
|
||||
<ArrowRight size={11} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRegenerate(digest.day_key);
|
||||
}}
|
||||
disabled={isRegenerating}
|
||||
title="重新生成摘要"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--text-faint, #64748b)',
|
||||
cursor: 'pointer',
|
||||
padding: '3px',
|
||||
}}
|
||||
>
|
||||
{isRegenerating ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||
</button>
|
||||
|
||||
{isExpanded ? <ChevronUp size={15} color="var(--text-muted, #94a3b8)" /> : <ChevronDown size={15} color="var(--text-muted, #94a3b8)" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 卡片详情 */}
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{/* 核心总结 Headline */}
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong, #f8fafc)', lineHeight: 1.5 }}>
|
||||
{digest.headline}
|
||||
</div>
|
||||
|
||||
{/* 工作要点 */}
|
||||
{digest.key_points && digest.key_points.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted, #94a3b8)' }}>
|
||||
📌 核心要点
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px', paddingLeft: '4px' }}>
|
||||
{digest.key_points.map((pt, i) => (
|
||||
<div key={i} style={{ fontSize: '12px', color: 'var(--text-subtle, #cbd5e1)', lineHeight: 1.4 }}>
|
||||
• {pt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拍板决策 */}
|
||||
{digest.decisions && digest.decisions.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', fontWeight: 600, color: '#10b981' }}>
|
||||
<CheckCircle2 size={12} />
|
||||
<span>关键决策与里程碑</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px', paddingLeft: '4px' }}>
|
||||
{digest.decisions.map((dec, i) => (
|
||||
<div key={i} style={{ fontSize: '12px', color: '#a7f3d0', lineHeight: 1.4 }}>
|
||||
✓ {dec}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未决事项 */}
|
||||
{digest.open_questions && digest.open_questions.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', fontWeight: 600, color: '#f59e0b' }}>
|
||||
<AlertCircle size={12} />
|
||||
<span>未决问题与跟进</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px', paddingLeft: '4px' }}>
|
||||
{digest.open_questions.map((q, i) => (
|
||||
<div key={i} style={{ fontSize: '12px', color: '#fde68a', lineHeight: 1.4 }}>
|
||||
? {q}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user