197 lines
8.0 KiB
TypeScript
197 lines
8.0 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
CheckCircle2,
|
|
Database,
|
|
FileCheck2,
|
|
Loader2,
|
|
Megaphone,
|
|
Target,
|
|
Users,
|
|
Workflow,
|
|
Zap,
|
|
} from 'lucide-react';
|
|
import type { MainStepKey, StepStatus, SubStepKey } from './types';
|
|
|
|
interface Props {
|
|
mainStatus: Record<MainStepKey, StepStatus>;
|
|
subStatus: Record<SubStepKey, StepStatus>;
|
|
isDark: boolean;
|
|
}
|
|
|
|
interface StepDef<T extends string> {
|
|
key: T;
|
|
title: string;
|
|
desc: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
color: { dark: string; light: string };
|
|
}
|
|
|
|
const MAIN_STEPS: StepDef<MainStepKey>[] = [
|
|
{ key: 'info', title: '信息准备', desc: '梳理输入素材与上下文', icon: Database, color: { dark: 'text-sky-300 bg-sky-500/15 border-sky-400/40', light: 'text-sky-600 bg-sky-50 border-sky-200' } },
|
|
{ key: 'sixstep', title: '6 步法推演', desc: '依次完成四个阶段推演', icon: Workflow, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-violet-50 border-violet-200' } },
|
|
{ key: 'final', title: '最终结果生成', desc: '汇总各阶段产出,输出结论', icon: FileCheck2, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
|
|
];
|
|
|
|
const SUB_STEPS: StepDef<SubStepKey>[] = [
|
|
{ key: 'purpose', title: '目的生成', desc: '识别核心目的方向', icon: Target, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-orange-50 border-orange-200' } },
|
|
{ key: 'actor', title: '行为体生成', desc: '匹配行为体角色', icon: Users, color: { dark: 'text-cyan-300 bg-cyan-500/15 border-cyan-400/40', light: 'text-red-600 bg-red-50 border-red-200' } },
|
|
{ key: 'behavior', title: '预期行为与驱动因素', desc: '推演行为与动因', icon: Zap, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
|
|
{ key: 'narrative', title: '叙事生成', desc: '构建叙事框架', icon: Megaphone, color: { dark: 'text-amber-300 bg-amber-500/15 border-amber-400/40', light: 'text-amber-600 bg-amber-50 border-amber-200' } },
|
|
];
|
|
|
|
function statusLabel(s: StepStatus) {
|
|
return s === 'done' ? '已完成' : s === 'running' ? '生成中' : '待开始';
|
|
}
|
|
|
|
function nodeStateClass(status: StepStatus, isDark: boolean) {
|
|
if (status === 'running') {
|
|
return `${isDark ? 'ring-2 ring-cyan-400/30' : 'ring-2 ring-red-200'} sixstep-pulse`;
|
|
}
|
|
if (status === 'done') {
|
|
return isDark ? 'ring-1 ring-emerald-400/40' : 'ring-1 ring-emerald-300';
|
|
}
|
|
return isDark ? 'opacity-55 saturate-50' : 'opacity-70 saturate-50';
|
|
}
|
|
|
|
function connectorClass(status: StepStatus, isDark: boolean) {
|
|
if (status === 'done') return isDark ? 'bg-emerald-400/60' : 'bg-emerald-400';
|
|
if (status === 'running') return isDark ? 'bg-cyan-400/50' : 'bg-red-300';
|
|
return isDark ? 'bg-slate-700/60' : 'bg-gray-200';
|
|
}
|
|
|
|
interface NodeProps {
|
|
status: StepStatus;
|
|
Icon: React.ComponentType<{ className?: string }>;
|
|
color: string;
|
|
size?: 'md' | 'sm';
|
|
title: string;
|
|
desc: string;
|
|
isDark: boolean;
|
|
}
|
|
|
|
const StepNode: React.FC<NodeProps> = ({ status, Icon, color, size = 'md', title, desc, isDark }) => {
|
|
const wrapSize = size === 'md' ? 'w-9 h-9' : 'w-7 h-7';
|
|
const iconSize = size === 'md' ? 'w-4 h-4' : 'w-3.5 h-3.5';
|
|
const badgeSize = size === 'md' ? 'w-4 h-4' : 'w-3.5 h-3.5';
|
|
const badgeIcon = size === 'md' ? 'w-3 h-3' : 'w-2.5 h-2.5';
|
|
|
|
return (
|
|
<div className="relative group shrink-0">
|
|
<div
|
|
className={`${wrapSize} rounded-xl border flex items-center justify-center transition-all ${color} ${nodeStateClass(status, isDark)}`}
|
|
>
|
|
{status === 'running'
|
|
? <Loader2 className={`${iconSize} sixstep-spin-soft`} />
|
|
: <Icon className={iconSize} />
|
|
}
|
|
</div>
|
|
|
|
{status === 'done' && (
|
|
<div
|
|
className={`absolute -bottom-0.5 -right-0.5 ${badgeSize} rounded-full flex items-center justify-center shadow-sm ${
|
|
isDark ? 'bg-emerald-500 text-slate-900' : 'bg-emerald-500 text-white'
|
|
}`}
|
|
>
|
|
<CheckCircle2 className={badgeIcon} />
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={`absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2.5 py-1.5 rounded-lg whitespace-nowrap pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-50 shadow-lg border ${
|
|
isDark
|
|
? 'bg-slate-900 text-slate-100 border-slate-700'
|
|
: 'bg-white text-gray-800 border-gray-200'
|
|
}`}
|
|
>
|
|
<div className="text-xs font-semibold">{title}</div>
|
|
<div
|
|
className={`text-[11px] ${
|
|
status === 'running'
|
|
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
|
|
: isDark ? 'text-slate-400' : 'text-gray-500'
|
|
}`}
|
|
>
|
|
{desc}
|
|
</div>
|
|
<div
|
|
className={`text-[10px] mt-0.5 flex items-center gap-1 ${
|
|
status === 'done'
|
|
? isDark ? 'text-emerald-300' : 'text-emerald-600'
|
|
: status === 'running'
|
|
? isDark ? 'text-cyan-300' : 'text-red-500'
|
|
: isDark ? 'text-slate-500' : 'text-gray-400'
|
|
}`}
|
|
>
|
|
{status === 'running' && <Loader2 className="w-2.5 h-2.5 sixstep-spin-soft" />}
|
|
<span>{statusLabel(status)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SixStepProgressHorizontal: React.FC<Props> = ({ mainStatus, subStatus, isDark }) => {
|
|
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 flex-nowrap py-1">
|
|
{MAIN_STEPS.map((step, idx) => {
|
|
const status = mainStatus[step.key];
|
|
const clr = step.color[isDark ? 'dark' : 'light'];
|
|
const isSixStep = step.key === 'sixstep';
|
|
const prevStatus = idx > 0 ? mainStatus[MAIN_STEPS[idx - 1].key] : 'pending';
|
|
|
|
return (
|
|
<React.Fragment key={step.key}>
|
|
{idx > 0 && (
|
|
<div className={`h-[2px] w-5 rounded shrink-0 transition-colors ${connectorClass(prevStatus, isDark)}`} />
|
|
)}
|
|
<StepNode
|
|
status={status}
|
|
Icon={step.icon}
|
|
color={clr}
|
|
title={step.title}
|
|
desc={step.desc}
|
|
isDark={isDark}
|
|
/>
|
|
{isSixStep && sixstepUnlocked && (
|
|
<>
|
|
<div className={`h-[2px] w-3 rounded shrink-0 transition-colors ${connectorClass(status, isDark)}`} />
|
|
<div
|
|
className={`flex items-center gap-1.5 px-2 py-1 rounded-xl border shrink-0 ${
|
|
isDark ? 'bg-slate-900/40 border-slate-700/60' : 'bg-gray-50 border-gray-200'
|
|
}`}
|
|
>
|
|
{SUB_STEPS.map((sub, sidx) => {
|
|
const ss = subStatus[sub.key];
|
|
const sc = sub.color[isDark ? 'dark' : 'light'];
|
|
const prevSub = sidx > 0 ? subStatus[SUB_STEPS[sidx - 1].key] : 'pending';
|
|
return (
|
|
<React.Fragment key={sub.key}>
|
|
{sidx > 0 && (
|
|
<div className={`h-[2px] w-2 rounded shrink-0 transition-colors ${connectorClass(prevSub, isDark)}`} />
|
|
)}
|
|
<StepNode
|
|
status={ss}
|
|
Icon={sub.icon}
|
|
color={sc}
|
|
size="sm"
|
|
title={sub.title}
|
|
desc={sub.desc}
|
|
isDark={isDark}
|
|
/>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SixStepProgressHorizontal;
|