361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
ChevronDown,
|
|
Compass,
|
|
FileSearch,
|
|
Megaphone,
|
|
ScrollText,
|
|
Sparkles,
|
|
Target,
|
|
TrendingUp,
|
|
UserCheck,
|
|
Users,
|
|
Zap,
|
|
} from 'lucide-react';
|
|
import { useTheme } from '../../contexts/ThemeContext';
|
|
import type {
|
|
ActorCard,
|
|
BehaviorDriverCard,
|
|
NarrativeCard,
|
|
PurposeCard,
|
|
} from './types';
|
|
|
|
interface CollapsibleProps {
|
|
title: React.ReactNode;
|
|
children: React.ReactNode;
|
|
defaultOpen?: boolean;
|
|
variant?: 'default' | 'subtle';
|
|
}
|
|
|
|
export const InlineCollapsible: React.FC<CollapsibleProps> = ({ title, children, defaultOpen = true, variant = 'default' }) => {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === 'dark';
|
|
const [open, setOpen] = React.useState(defaultOpen);
|
|
|
|
const wrapperClass = variant === 'subtle'
|
|
? `${isDark ? 'border-slate-700/50 bg-slate-900/30' : 'border-gray-100 bg-white/80'}`
|
|
: `${isDark ? 'border-slate-700/60 bg-slate-800/40' : 'border-red-100 bg-white'}`;
|
|
|
|
return (
|
|
<div className={`rounded-2xl border overflow-hidden ${wrapperClass}`}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className={`w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${
|
|
isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
|
|
}`}
|
|
>
|
|
<div className="flex-1 min-w-0">{title}</div>
|
|
<ChevronDown
|
|
className={`w-4 h-4 shrink-0 transition-transform duration-300 ${
|
|
open ? 'rotate-0' : '-rotate-90'
|
|
} ${isDark ? 'text-slate-400' : 'text-gray-500'}`}
|
|
/>
|
|
</button>
|
|
<div className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
|
|
open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
|
}`}>
|
|
<div className="overflow-hidden">
|
|
<div className="px-4 pb-4 pt-1">{children}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface CardIndexBadgeProps {
|
|
index: number;
|
|
variant: 'purpose' | 'actor' | 'behavior' | 'narrative';
|
|
}
|
|
|
|
const VARIANT_COLOR: Record<CardIndexBadgeProps['variant'], { dark: string; light: string }> = {
|
|
purpose: { dark: 'bg-violet-500/20 text-violet-200 border-violet-400/30', light: 'bg-orange-50 text-orange-600 border-orange-200' },
|
|
actor: { dark: 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30', light: 'bg-red-50 text-red-600 border-red-200' },
|
|
behavior: { dark: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30', light: 'bg-emerald-50 text-emerald-600 border-emerald-200' },
|
|
narrative: { dark: 'bg-amber-500/20 text-amber-200 border-amber-400/30', light: 'bg-amber-50 text-amber-600 border-amber-200' },
|
|
};
|
|
|
|
const CardIndexBadge: React.FC<CardIndexBadgeProps> = ({ index, variant }) => {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === 'dark';
|
|
const cls = VARIANT_COLOR[variant][isDark ? 'dark' : 'light'];
|
|
return (
|
|
<span className={`w-7 h-7 rounded-xl border flex items-center justify-center text-xs font-semibold shrink-0 ${cls}`}>
|
|
{index.toString().padStart(2, '0')}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
export const PurposeCardItem: React.FC<{ card: PurposeCard }> = ({ card }) => {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === 'dark';
|
|
return (
|
|
<div className={`group rounded-2xl border p-3 transition-all sixstep-fade-up ${
|
|
isDark
|
|
? 'border-violet-400/20 bg-gradient-to-br from-slate-800/60 to-violet-900/20 hover:border-violet-300/40'
|
|
: 'border-orange-100 bg-gradient-to-br from-white to-orange-50/40 hover:border-orange-200'
|
|
}`}>
|
|
<div className="flex items-start gap-3">
|
|
<CardIndexBadge index={card.index} variant="purpose" />
|
|
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
|
isDark ? 'bg-violet-500/20 text-violet-200' : 'bg-orange-50 text-orange-500'
|
|
}`}>
|
|
<Target className="w-4 h-4" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
|
{card.title}
|
|
</div>
|
|
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
|
<span className={`inline-flex items-center gap-1 mr-1 font-medium ${isDark ? 'text-violet-200' : 'text-orange-600'}`}>
|
|
<Compass className="w-3 h-3" />理由
|
|
</span>
|
|
{card.reason}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const ActorCardItem: React.FC<{ card: ActorCard }> = ({ card }) => {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === 'dark';
|
|
return (
|
|
<div className={`rounded-2xl border p-3 transition-all sixstep-fade-up ${
|
|
isDark
|
|
? 'border-cyan-400/20 bg-gradient-to-br from-slate-800/60 to-cyan-900/20 hover:border-cyan-300/40'
|
|
: 'border-red-100 bg-gradient-to-br from-white to-red-50/40 hover:border-red-200'
|
|
}`}>
|
|
<div className="flex items-start gap-3">
|
|
<CardIndexBadge index={card.index} variant="actor" />
|
|
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
|
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-500'
|
|
}`}>
|
|
<UserCheck className="w-4 h-4" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
|
{card.title}
|
|
</div>
|
|
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
|
{card.content}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const BehaviorCardItem: React.FC<{ card: BehaviorDriverCard }> = ({ card }) => {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === 'dark';
|
|
return (
|
|
<div className={`rounded-2xl border overflow-hidden transition-all sixstep-fade-up ${
|
|
isDark
|
|
? 'border-emerald-400/20 bg-gradient-to-br from-slate-800/60 to-emerald-900/20 hover:border-emerald-300/40'
|
|
: 'border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40 hover:border-emerald-200'
|
|
}`}>
|
|
<div className="p-3">
|
|
<div className="flex items-start gap-3">
|
|
<CardIndexBadge index={card.index} variant="behavior" />
|
|
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
|
isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-50 text-emerald-600'
|
|
}`}>
|
|
<TrendingUp className="w-4 h-4" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className={`text-[11px] uppercase tracking-wider font-medium mb-1 ${isDark ? 'text-emerald-300/80' : 'text-emerald-600'}`}>
|
|
预期行为
|
|
</div>
|
|
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
|
{card.expectedBehavior}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className={`border-t px-3 py-3 ${
|
|
isDark ? 'border-emerald-400/15 bg-emerald-500/5' : 'border-emerald-100 bg-emerald-50/40'
|
|
}`}>
|
|
<div className="flex items-start gap-2">
|
|
<Zap className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${isDark ? 'text-emerald-300' : 'text-emerald-600'}`} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className={`text-[11px] uppercase tracking-wider font-medium mb-1 ${isDark ? 'text-emerald-300/80' : 'text-emerald-600'}`}>
|
|
驱动因素
|
|
</div>
|
|
<div className={`text-xs leading-relaxed ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
|
|
{card.driver}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const NarrativeCardItem: React.FC<{ card: NarrativeCard }> = ({ card }) => {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === 'dark';
|
|
return (
|
|
<div className={`rounded-2xl border p-3 transition-all sixstep-fade-up ${
|
|
isDark
|
|
? 'border-amber-400/20 bg-gradient-to-br from-slate-800/60 to-amber-900/20 hover:border-amber-300/40'
|
|
: 'border-amber-100 bg-gradient-to-br from-white to-amber-50/40 hover:border-amber-200'
|
|
}`}>
|
|
<div className="flex items-start gap-3">
|
|
<CardIndexBadge index={card.index} variant="narrative" />
|
|
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
|
|
isDark ? 'bg-amber-500/20 text-amber-200' : 'bg-amber-50 text-amber-600'
|
|
}`}>
|
|
<ScrollText className="w-4 h-4" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
|
{card.title}
|
|
</div>
|
|
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
|
<span className={`inline-flex items-center gap-1 mr-1 font-medium ${isDark ? 'text-amber-200' : 'text-amber-600'}`}>
|
|
<Sparkles className="w-3 h-3" />理由
|
|
</span>
|
|
{card.reason}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface GroupHeaderProps {
|
|
label: string;
|
|
parentTitle: string;
|
|
count: number;
|
|
variant: 'actor' | 'behavior' | 'narrative';
|
|
}
|
|
|
|
export const ParentGroupHeader: React.FC<GroupHeaderProps> = ({ label, parentTitle, count, variant }) => {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === 'dark';
|
|
const Icon = variant === 'actor' ? Target : variant === 'behavior' ? Users : Zap;
|
|
const accent = variant === 'actor'
|
|
? (isDark ? 'text-violet-200 bg-violet-500/15 border-violet-400/30' : 'text-orange-600 bg-orange-50 border-orange-200')
|
|
: variant === 'behavior'
|
|
? (isDark ? 'text-cyan-200 bg-cyan-500/15 border-cyan-400/30' : 'text-red-600 bg-red-50 border-red-200')
|
|
: (isDark ? 'text-emerald-200 bg-emerald-500/15 border-emerald-400/30' : 'text-emerald-600 bg-emerald-50 border-emerald-200');
|
|
|
|
return (
|
|
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full border w-fit ${accent}`}>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
<span className="text-[11px] font-medium">{label}</span>
|
|
<span className={`text-xs font-semibold ${isDark ? 'text-white' : 'text-gray-900'} truncate max-w-[260px]`} title={parentTitle}>
|
|
{parentTitle}
|
|
</span>
|
|
<span className={`text-[10px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>· {count} 张</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ThinkingBlockProps {
|
|
text: string;
|
|
loading?: boolean;
|
|
empty?: string;
|
|
}
|
|
|
|
export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({ text, loading, empty = '正在生成思考内容...' }) => {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === 'dark';
|
|
return (
|
|
<div className={`rounded-2xl border px-4 py-3 ${
|
|
isDark ? 'bg-slate-900/30 border-slate-700/60' : 'bg-white border-gray-100'
|
|
}`}>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<FileSearch className={`w-3.5 h-3.5 ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />
|
|
<span className={`text-[11px] font-medium uppercase tracking-wider ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
|
思考过程
|
|
</span>
|
|
{loading && (
|
|
<span className={`text-[11px] ${isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}`}>
|
|
生成中
|
|
</span>
|
|
)}
|
|
</div>
|
|
{text ? (
|
|
<pre className={`whitespace-pre-wrap text-xs leading-relaxed font-sans ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
|
|
{text}
|
|
</pre>
|
|
) : (
|
|
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{empty}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface StageHeaderProps {
|
|
title: string;
|
|
desc?: string;
|
|
count?: number;
|
|
loading?: boolean;
|
|
done?: boolean;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
accent: 'purpose' | 'actor' | 'behavior' | 'narrative';
|
|
}
|
|
|
|
const ACCENT_BG: Record<StageHeaderProps['accent'], { dark: string; light: string }> = {
|
|
purpose: { dark: 'bg-violet-500/15 text-violet-200', light: 'bg-orange-50 text-orange-500' },
|
|
actor: { dark: 'bg-cyan-500/15 text-cyan-200', light: 'bg-red-50 text-red-500' },
|
|
behavior: { dark: 'bg-emerald-500/15 text-emerald-200', light: 'bg-emerald-50 text-emerald-600' },
|
|
narrative: { dark: 'bg-amber-500/15 text-amber-200', light: 'bg-amber-50 text-amber-600' },
|
|
};
|
|
|
|
export const StageHeader: React.FC<StageHeaderProps> = ({ title, desc, count, loading, done, icon: Icon, accent }) => {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === 'dark';
|
|
const cls = ACCENT_BG[accent][isDark ? 'dark' : 'light'];
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${cls} ${loading ? 'sixstep-pulse' : ''}`}>
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'} flex items-center gap-2`}>
|
|
<span className="truncate">{title}</span>
|
|
{typeof count === 'number' && (
|
|
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
|
isDark ? 'bg-slate-700 text-slate-300' : 'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
{count} 张
|
|
</span>
|
|
)}
|
|
{done && (
|
|
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
|
isDark ? 'bg-emerald-500/20 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
|
|
}`}>
|
|
已完成
|
|
</span>
|
|
)}
|
|
{loading && (
|
|
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
|
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-600'
|
|
}`}>
|
|
生成中
|
|
</span>
|
|
)}
|
|
</div>
|
|
{desc && (
|
|
<div className={`text-xs mt-0.5 ${
|
|
loading ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-slate-400' : 'text-gray-500')
|
|
}`}>
|
|
{desc}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const STAGE_ICONS = {
|
|
purpose: Target,
|
|
actor: Users,
|
|
behavior: Zap,
|
|
narrative: Megaphone,
|
|
};
|