- 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>
216 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|