Files
deerflow-code/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryDropdown.tsx
T
董宏凯andClaude Opus 4.8 03b6fb724c feat(roundtable): 深链策划流程小图 + 流程图弹窗横向/缩略图/深蓝适配
- Step1 消息列表头部:深链 taskId 模式取到目的树后,左侧标题换成「策划流程」
  彩色层级小图(任务→目的→…→叙事,每级配色对齐流程图节点 + 类型小图标),
  点击打开任务目的流程图弹窗;仅第一步、有数据时出现。
- TaskPurposeDialog 流程图:横向(LR)布局 + 右下角缩略图(MiniMap)。
- FlowGraphPanel 深蓝主题适配(随 ThemeContext):容器/头部/画布点阵/连线/图例/
  小地图/节点详情弹窗 + 节点配色(FLOW_NODE_TYPE_META_DARK),浅色红主题不受影响。
- 圆桌侧栏深色兜底:neutral 家族 + 米色 hex(#FAF6F5/#ECE7E4/#E8D0CC) 映射到深蓝
  token(关系图背板、第N步 tag 等不再泛白)。

附带提交工作区其余暂存改动(AI 写作等),不含 config.yaml。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:39:19 +08:00

216 lines
7.8 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { History, Trash2, Check, X, RefreshCw, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '进行中', color: 'text-blue-500' },
// 后台挂起中:后端正自动驱动到定稿(离页/刷新/切号不打断)。
background: { label: '挂起中', color: 'text-sky-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' },
done: { label: '完成', color: 'text-green-500' },
error: { label: '错误', color: 'text-destructive' },
};
export function formatRelative(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins} 分钟前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs} 小时前`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days} 天前`;
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
export interface SessionItemProps {
session: AIWritingSessionSummary;
isSelected: boolean;
onSelect: () => void;
/** 「挂起中」记录点击时改为查看进度(流程图弹窗),不直接打开会话。 */
onShowProgress?: () => void;
onDelete: () => void;
onRename: (title: string) => void;
}
export function SessionItem({ session, isSelected, onSelect, onShowProgress, onDelete, onRename }: SessionItemProps) {
const isBackground = session.status === 'background';
const handleRowClick = isBackground && onShowProgress ? onShowProgress : onSelect;
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
const { label, color } = STATUS_LABELS[session.status] ?? { label: session.status, color: 'text-muted-foreground' };
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
function commitRename() {
const trimmed = editValue.trim();
if (trimmed && trimmed !== session.title) onRename(trimmed);
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') commitRename();
else if (e.key === 'Escape') { setEditValue(session.title); setEditing(false); }
}
return (
<div
className={cn(
'group relative flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors',
isSelected ? 'bg-accent' : 'hover:bg-accent/60',
)}
onClick={!editing ? handleRowClick : undefined}
>
<div className="flex-1 min-w-0">
{editing ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
className="flex-1 text-xs bg-background border rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-ring"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
/>
<button className="shrink-0 text-green-500 hover:text-green-600" onClick={commitRename}>
<Check className="w-3 h-3" />
</button>
<button className="shrink-0 text-muted-foreground hover:text-foreground" onClick={() => { setEditValue(session.title); setEditing(false); }}>
<X className="w-3 h-3" />
</button>
</div>
) : (
<p
className="text-xs font-medium truncate leading-snug"
onDoubleClick={(e) => { e.stopPropagation(); setEditing(true); }}
title="双击重命名"
>
{session.title}
</p>
)}
<div className="flex items-center gap-1.5 mt-0.5">
<span className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
{session.status === 'background' && <Loader2 className="w-2.5 h-2.5 animate-spin" />}
{label}
</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">{formatRelative(session.updated_at)}</span>
</div>
</div>
{/* 删除按钮,hover 时显示 */}
{!editing && (
<button
className="opacity-0 group-hover:opacity-100 shrink-0 p-0.5 text-muted-foreground hover:text-destructive transition-opacity"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
title="删除"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
);
}
interface AIWritingHistoryDropdownProps {
sessions: AIWritingSessionSummary[];
selectedId: string | null;
loading: boolean;
onSelect: (session: AIWritingSessionSummary) => void;
/** 点击「挂起中」记录时查看进度(流程图弹窗),不直接打开会话。 */
onShowProgress?: (session: AIWritingSessionSummary) => void;
onDelete: (id: string) => void;
onRename: (id: string, title: string) => void;
onRefresh: () => void;
}
export function AIWritingHistoryDropdown({
sessions,
selectedId,
loading,
onSelect,
onShowProgress,
onDelete,
onRename,
onRefresh,
}: AIWritingHistoryDropdownProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
function handleOpen() {
setOpen((v) => {
if (!v) onRefresh();
return !v;
});
}
return (
<div ref={containerRef} className="relative">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={handleOpen}
title="查看历史记录"
>
<History className="size-3" />
历史
</Button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border bg-popover shadow-lg overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-xs font-semibold">写作历史</span>
<button
className={cn('text-muted-foreground hover:text-foreground transition-transform', loading && 'animate-spin')}
onClick={onRefresh}
title="刷新"
>
<RefreshCw className="w-3 h-3" />
</button>
</div>
{/* 列表 */}
<div className="max-h-72 overflow-y-auto">
{sessions.length === 0 ? (
<div className="text-center py-6 text-xs text-muted-foreground">
{loading ? '加载中…' : '暂无历史记录'}
</div>
) : (
sessions.map((s) => (
<SessionItem
key={s.id}
session={s}
isSelected={s.id === selectedId}
onSelect={() => { onSelect(s); setOpen(false); }}
onShowProgress={onShowProgress ? () => { onShowProgress(s); setOpen(false); } : undefined}
onDelete={() => onDelete(s.id)}
onRename={(title) => onRename(s.id, title)}
/>
))
)}
</div>
</div>
)}
</div>
);
}