249 lines
8.9 KiB
TypeScript
249 lines
8.9 KiB
TypeScript
import React from 'react';
|
|
import { Check, Plus, RefreshCcw, Sparkles, UserSearch } from 'lucide-react';
|
|
import { useTheme } from '../../contexts/ThemeContext';
|
|
import { InlineCollapsible, StageHeader, ThinkingBlock } from './SixStepCards';
|
|
import type { ExpertCard, ExpertMatchData } from './types';
|
|
|
|
interface ExpertCardItemProps {
|
|
card: ExpertCard;
|
|
index: number;
|
|
selected: boolean;
|
|
disabled?: boolean;
|
|
onSelect: (id: string) => void;
|
|
}
|
|
|
|
const renderAvatar = (avatar: string | undefined, fallback: React.ReactNode, isDark: boolean) => {
|
|
if (avatar && avatar.trim().startsWith('<svg')) {
|
|
return (
|
|
<span
|
|
className={`flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl ${
|
|
isDark ? 'bg-slate-700/40' : 'bg-white shadow-sm'
|
|
}`}
|
|
// eslint-disable-next-line react/no-danger
|
|
dangerouslySetInnerHTML={{ __html: avatar }}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<span
|
|
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${
|
|
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-500'
|
|
}`}
|
|
>
|
|
{fallback}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const ExpertCardItem: React.FC<ExpertCardItemProps> = ({ card, index, selected, disabled, onSelect }) => {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === 'dark';
|
|
|
|
const baseBorder = selected
|
|
? isDark
|
|
? 'border-cyan-300/60 ring-1 ring-cyan-300/40 shadow-lg shadow-cyan-500/10 bg-gradient-to-br from-slate-800/70 to-cyan-900/30'
|
|
: 'border-red-300 ring-1 ring-red-200 shadow-md shadow-red-100 bg-gradient-to-br from-white to-red-50/60'
|
|
: isDark
|
|
? 'border-slate-700/60 bg-gradient-to-br from-slate-800/40 to-slate-900/20 hover:border-cyan-300/40'
|
|
: 'border-gray-100 bg-white hover:border-red-200';
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => onSelect(card.id)}
|
|
className={`w-full text-left rounded-2xl border p-3 transition-all sixstep-fade-up disabled:cursor-not-allowed disabled:opacity-60 ${baseBorder}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<span
|
|
className={`w-7 h-7 rounded-xl border flex items-center justify-center text-xs font-semibold shrink-0 ${
|
|
isDark
|
|
? 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30'
|
|
: 'bg-red-50 text-red-600 border-red-200'
|
|
}`}
|
|
>
|
|
{index.toString().padStart(2, '0')}
|
|
</span>
|
|
{renderAvatar(card.avatarUrl, <UserSearch className="w-5 h-5" />, isDark)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`text-sm font-semibold leading-snug truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
|
{card.name}
|
|
</div>
|
|
{card.isCustom && (
|
|
<span
|
|
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
|
|
isDark ? 'bg-violet-500/20 text-violet-200' : 'bg-violet-50 text-violet-600'
|
|
}`}
|
|
>
|
|
新建
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className={`text-xs mt-1 leading-relaxed line-clamp-3 ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
|
|
{card.detail || '暂无描述'}
|
|
</div>
|
|
</div>
|
|
<span
|
|
className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition ${
|
|
selected
|
|
? isDark
|
|
? 'border-cyan-300 bg-cyan-400/30 text-cyan-100'
|
|
: 'border-red-400 bg-red-500 text-white'
|
|
: isDark
|
|
? 'border-slate-600 text-transparent'
|
|
: 'border-gray-300 text-transparent'
|
|
}`}
|
|
>
|
|
<Check className="w-3.5 h-3.5" />
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
interface ExpertMatchCardsProps {
|
|
data: ExpertMatchData;
|
|
onSelectExpert: (id: string) => void;
|
|
onRefetch: () => void;
|
|
onCreateExpert: () => void;
|
|
onConfirm: () => void;
|
|
locked?: boolean;
|
|
}
|
|
|
|
const ExpertMatchCards: React.FC<ExpertMatchCardsProps> = ({
|
|
data,
|
|
onSelectExpert,
|
|
onRefetch,
|
|
onCreateExpert,
|
|
onConfirm,
|
|
locked,
|
|
}) => {
|
|
const { theme } = useTheme();
|
|
const isDark = theme === 'dark';
|
|
|
|
const isSearching = data.status === 'searching';
|
|
const isCreating = data.status === 'creating';
|
|
const isReady = data.status === 'ready' || data.status === 'confirmed';
|
|
const cards = data.experts ?? [];
|
|
|
|
return (
|
|
<InlineCollapsible
|
|
defaultOpen
|
|
title={
|
|
<StageHeader
|
|
title="专家匹配"
|
|
desc={
|
|
isSearching
|
|
? cards.length > 0
|
|
? '专家已匹配完成,可选择一位进入下一步'
|
|
: '正在调用查找专家 MCP...'
|
|
: isCreating
|
|
? '正在调用创建专家 MCP...'
|
|
: data.status === 'error'
|
|
? data.errorMessage || '专家匹配失败'
|
|
: '请选择一位专家进入下一步'
|
|
}
|
|
count={cards.length}
|
|
loading={(isSearching && cards.length === 0) || isCreating}
|
|
done={data.status === 'confirmed'}
|
|
icon={UserSearch}
|
|
accent="actor"
|
|
/>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<ThinkingBlock text={data.thinking} loading={isSearching && cards.length === 0 && !data.thinking} />
|
|
|
|
<InlineCollapsible
|
|
variant="subtle"
|
|
defaultOpen
|
|
title={
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium">推荐专家列表</span>
|
|
<span className="text-[10px] opacity-70">{cards.length} 位</span>
|
|
</div>
|
|
}
|
|
>
|
|
{cards.length === 0 ? (
|
|
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
|
|
{isSearching && cards.length === 0 ? '正在匹配专家...' : '暂无匹配的专家'}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{cards.map((card, idx) => (
|
|
<ExpertCardItem
|
|
key={card.id}
|
|
card={card}
|
|
index={idx + 1}
|
|
selected={data.selectedExpertId === card.id}
|
|
disabled={locked}
|
|
onSelect={onSelectExpert}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</InlineCollapsible>
|
|
|
|
{data.status === 'error' && (
|
|
<div className={`rounded-xl border px-3 py-2 text-xs ${
|
|
isDark ? 'border-rose-500/30 bg-rose-500/10 text-rose-200' : 'border-rose-200 bg-rose-50 text-rose-700'
|
|
}`}>
|
|
{data.errorMessage}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={onRefetch}
|
|
disabled={locked || isSearching || isCreating}
|
|
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
isDark
|
|
? 'border-slate-600 bg-slate-800/60 text-slate-200 hover:bg-slate-700'
|
|
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<RefreshCcw className={`w-3.5 h-3.5 ${isSearching ? 'animate-spin' : ''}`} />
|
|
重新查找
|
|
</button>
|
|
|
|
<div className={`flex-1 text-right text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
|
没有想要的专家?
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onCreateExpert}
|
|
disabled={locked || isCreating || isSearching}
|
|
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
isDark
|
|
? 'border-violet-400/40 bg-violet-500/15 text-violet-100 hover:bg-violet-500/25'
|
|
: 'border-violet-200 bg-violet-50 text-violet-700 hover:bg-violet-100'
|
|
}`}
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
创建专家
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onConfirm}
|
|
disabled={locked || !data.selectedExpertId || !isReady}
|
|
className={`inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold text-white transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
isDark
|
|
? 'bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400'
|
|
: 'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400'
|
|
}`}
|
|
>
|
|
<Sparkles className="w-3.5 h-3.5" />
|
|
选择该专家
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</InlineCollapsible>
|
|
);
|
|
};
|
|
|
|
export default ExpertMatchCards;
|