对 47 个前端文件统一执行 npm run format,消除存量格式差异。 随后在 CI 与 Makefile check 中启用 format:check,确保后续提交强制遵守 prettier 风格。
263 lines
9.9 KiB
TypeScript
263 lines
9.9 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react';
|
||
import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts';
|
||
|
||
interface ExpertItem {
|
||
name: string;
|
||
description: string;
|
||
source: string;
|
||
path?: string;
|
||
body?: string;
|
||
disabled_in_scopes: string[];
|
||
}
|
||
|
||
interface SelectedExpert {
|
||
name: string;
|
||
description: string;
|
||
}
|
||
|
||
interface ExpertSelectorProps {
|
||
sessionId: string | null;
|
||
onManageExperts?: () => void;
|
||
onSelectionChange?: (expert: SelectedExpert | null) => void;
|
||
/** 设置弹窗关闭时触发的刷新信号(每次关闭时递增) */
|
||
settingsClosedTick?: number;
|
||
}
|
||
|
||
export function ExpertSelector({
|
||
sessionId,
|
||
onManageExperts,
|
||
onSelectionChange,
|
||
settingsClosedTick,
|
||
}: ExpertSelectorProps) {
|
||
const [selectedExpert, setSelectedExpert] = useState<SelectedExpert | null>(null);
|
||
const [expertList, setExpertList] = useState<ExpertItem[]>([]);
|
||
const [open, setOpen] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [listLoading, setListLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 刷新当前会话选中的专家(后端会对禁用专家返回 null)
|
||
const refreshSelection = useCallback(() => {
|
||
if (!sessionId) {
|
||
setSelectedExpert(null);
|
||
onSelectionChange?.(null);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError(null);
|
||
getSelectedExpert(sessionId)
|
||
.then((data) => {
|
||
if (data?.expert) {
|
||
setSelectedExpert({ name: data.expert.name, description: data.expert.description });
|
||
onSelectionChange?.({ name: data.expert.name, description: data.expert.description });
|
||
} else {
|
||
// 已选专家被禁用/删除时,后端返回 null,前端同步清除
|
||
setSelectedExpert(null);
|
||
onSelectionChange?.(null);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// Silent fail: default to no expert
|
||
setSelectedExpert(null);
|
||
onSelectionChange?.(null);
|
||
})
|
||
.finally(() => setLoading(false));
|
||
}, [sessionId, onSelectionChange]);
|
||
|
||
// Load current selection whenever sessionId changes
|
||
useEffect(() => {
|
||
refreshSelection();
|
||
}, [refreshSelection]);
|
||
|
||
// 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况)
|
||
useEffect(() => {
|
||
if (settingsClosedTick === undefined) return;
|
||
refreshSelection();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [settingsClosedTick]);
|
||
|
||
// Click outside to close dropdown
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const handler = (e: MouseEvent) => {
|
||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||
setOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [open]);
|
||
|
||
const fetchExpertList = useCallback(async () => {
|
||
setListLoading(true);
|
||
const data = await listExperts();
|
||
if (data) {
|
||
// Only show enabled experts (disabled_in_scopes.length === 0)
|
||
const enabled = (data.experts ?? []).filter(
|
||
(e: ExpertItem) => e.disabled_in_scopes.length === 0,
|
||
);
|
||
setExpertList(enabled);
|
||
}
|
||
setListLoading(false);
|
||
}, []);
|
||
|
||
const handleToggleOpen = () => {
|
||
const next = !open;
|
||
setOpen(next);
|
||
// 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映
|
||
if (next) {
|
||
fetchExpertList();
|
||
refreshSelection();
|
||
}
|
||
};
|
||
|
||
const handleSelect = async (expert: SelectedExpert | null) => {
|
||
if (!sessionId) return;
|
||
// Optimistic update
|
||
const prev = selectedExpert;
|
||
setSelectedExpert(expert);
|
||
onSelectionChange?.(expert);
|
||
setOpen(false);
|
||
try {
|
||
const result = await selectExpert(sessionId, expert?.name ?? null);
|
||
if (!result.success) {
|
||
// Revert
|
||
setSelectedExpert(prev);
|
||
onSelectionChange?.(prev);
|
||
setError(result.error || '切换专家失败');
|
||
setTimeout(() => setError(null), 3000);
|
||
}
|
||
} catch {
|
||
setSelectedExpert(prev);
|
||
onSelectionChange?.(prev);
|
||
setError('网络错误,切换专家失败');
|
||
setTimeout(() => setError(null), 3000);
|
||
}
|
||
};
|
||
|
||
const handleManage = () => {
|
||
setOpen(false);
|
||
onManageExperts?.();
|
||
};
|
||
|
||
// If sessionId is null, render nothing
|
||
if (!sessionId) return null;
|
||
|
||
return (
|
||
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
|
||
<div className="relative">
|
||
<button
|
||
onClick={handleToggleOpen}
|
||
disabled={loading}
|
||
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
|
||
title={
|
||
selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'
|
||
}
|
||
>
|
||
{loading ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
|
||
) : (
|
||
<UserCheck
|
||
className={`h-3.5 w-3.5 ${selectedExpert ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
|
||
/>
|
||
)}
|
||
{selectedExpert ? (
|
||
<span className="flex items-center gap-1 min-w-0">
|
||
<span className="text-[var(--text-primary)] font-medium truncate max-w-[120px]">
|
||
{selectedExpert.name}
|
||
</span>
|
||
<span className="text-[var(--text-muted)] truncate max-w-[180px]">
|
||
{selectedExpert.description}
|
||
</span>
|
||
</span>
|
||
) : (
|
||
<span className="text-[var(--text-muted)]">无专家</span>
|
||
)}
|
||
<ChevronDown
|
||
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
|
||
/>
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden">
|
||
{listLoading && expertList.length === 0 ? (
|
||
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 加载中...
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* 无专家 option */}
|
||
<button
|
||
onClick={() => handleSelect(null)}
|
||
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[var(--bg-hover)] transition-colors"
|
||
>
|
||
<UserCheck className="h-4 w-4 text-[var(--text-muted)] shrink-0" />
|
||
<span className="text-[var(--text-primary)]">无专家</span>
|
||
{!selectedExpert && (
|
||
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
|
||
)}
|
||
</button>
|
||
{expertList.length > 0 ? (
|
||
<div className="border-t border-[var(--border-color)]">
|
||
{expertList.map((expert) => {
|
||
const isSelected = selectedExpert?.name === expert.name;
|
||
return (
|
||
<button
|
||
key={expert.name}
|
||
onClick={() =>
|
||
handleSelect({ name: expert.name, description: expert.description })
|
||
}
|
||
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
|
||
>
|
||
<UserCheck
|
||
className={`h-4 w-4 shrink-0 mt-0.5 ${isSelected ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-start gap-1.5">
|
||
<span className="text-sm font-medium text-[var(--text-primary)] break-all">
|
||
{expert.name}
|
||
</span>
|
||
{isSelected && (
|
||
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0 mt-0.5" />
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||
{expert.description}
|
||
</p>
|
||
{expert.path && (
|
||
<p
|
||
className="text-[10px] text-[var(--text-muted)]/60 truncate mt-1 font-mono"
|
||
title={expert.path}
|
||
>
|
||
{expert.path}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="border-t border-[var(--border-color)]">
|
||
<button
|
||
onClick={handleManage}
|
||
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-secondary)] hover:text-[var(--accent-cyan)] hover:bg-[var(--bg-hover)] transition-colors"
|
||
>
|
||
<Settings className="h-4 w-4" />
|
||
<span>管理专家...</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{error && <span className="text-xs text-red-400 truncate">{error}</span>}
|
||
</div>
|
||
);
|
||
}
|