fix: 修复专家发现与前端选择多项问题
后端: - split_frontmatter 兼容 CRLF/LF 行尾符,修复 Windows 下 EXPERT.md 解析失败 - Config::load_from 移除 fallback 到 cwd/config.json 的逻辑,避免静默加载项目目录配置导致 experts 字段缺失 - ExpertRuntime 新增 update_config 方法,save_config 时同步更新 ExpertRuntime 内部 config,sources 变更即时生效 - source_order 空数组不再兜底返回 [User, Project],尊重用户关闭所有源的意图 - from_config_with_cwd 增加诊断日志,输出 enabled/sources/cwd/discovered 前端: - ExpertSelector 弹窗改为按钮上方居中展开 - 专家卡片 name 完整显示(break-all),路径显示在卡片底部 - "管理专家"入口仅在无专家时显示 - 每次打开下拉都重新拉取列表和选中状态 - 监听设置弹窗关闭事件刷新已选专家(处理已选专家被禁用的情况)
This commit is contained in:
parent
46a1ca6853
commit
0a8d21fe40
@ -256,6 +256,14 @@ impl ExpertRuntime {
|
|||||||
// session selections are persisted in the project-scope state file
|
// session selections are persisted in the project-scope state file
|
||||||
let session_experts = load_project_session_experts(&cwd);
|
let session_experts = load_project_session_experts(&cwd);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
enabled = config.enabled,
|
||||||
|
sources = ?config.sources,
|
||||||
|
cwd = %cwd.display(),
|
||||||
|
discovered = catalog.len(),
|
||||||
|
"ExpertRuntime initialized"
|
||||||
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config: RwLock::new(config),
|
config: RwLock::new(config),
|
||||||
catalog: RwLock::new(catalog),
|
catalog: RwLock::new(catalog),
|
||||||
@ -816,12 +824,23 @@ fn parse_expert_file(path: &Path, source: ExpertSource) -> Result<Expert, String
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
|
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
|
||||||
let rest = content.strip_prefix("---\n")?;
|
// 兼容 CRLF(Windows)和 LF(Unix)行尾符
|
||||||
|
let rest = content
|
||||||
|
.strip_prefix("---\n")
|
||||||
|
.or_else(|| content.strip_prefix("---\r\n"))?;
|
||||||
let marker = "\n---\n";
|
let marker = "\n---\n";
|
||||||
let idx = rest.find(marker)?;
|
let marker_crlf = "\n---\r\n";
|
||||||
let frontmatter = &rest[..idx];
|
if let Some(idx) = rest.find(marker) {
|
||||||
let body = &rest[idx + marker.len()..];
|
let frontmatter = &rest[..idx];
|
||||||
Some((frontmatter, body))
|
let body = &rest[idx + marker.len()..];
|
||||||
|
Some((frontmatter, body))
|
||||||
|
} else if let Some(idx) = rest.find(marker_crlf) {
|
||||||
|
let frontmatter = &rest[..idx];
|
||||||
|
let body = &rest[idx + marker_crlf.len()..];
|
||||||
|
Some((frontmatter, body))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== State file I/O ==========
|
// ========== State file I/O ==========
|
||||||
|
|||||||
@ -167,6 +167,8 @@ function App() {
|
|||||||
|
|
||||||
const [configPageOpen, setConfigPageOpen] = useState(false)
|
const [configPageOpen, setConfigPageOpen] = useState(false)
|
||||||
const [configInitialTab, setConfigInitialTab] = useState<'providers' | 'experts'>('providers')
|
const [configInitialTab, setConfigInitialTab] = useState<'providers' | 'experts'>('providers')
|
||||||
|
// 设置弹窗关闭计数器:每次关闭时递增,用于通知 ExpertSelector 刷新已选专家状态
|
||||||
|
const [settingsClosedTick, setSettingsClosedTick] = useState(0)
|
||||||
|
|
||||||
const handleSaveConnection = useCallback((host: string, port: number) => {
|
const handleSaveConnection = useCallback((host: string, port: number) => {
|
||||||
setGatewaySettings({ host, port })
|
setGatewaySettings({ host, port })
|
||||||
@ -806,6 +808,7 @@ function App() {
|
|||||||
viewKey={viewKey}
|
viewKey={viewKey}
|
||||||
highlightedMessageId={highlightedMessageId}
|
highlightedMessageId={highlightedMessageId}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
|
settingsClosedTick={settingsClosedTick}
|
||||||
onOpenSettings={() => {
|
onOpenSettings={() => {
|
||||||
setConfigInitialTab('experts')
|
setConfigInitialTab('experts')
|
||||||
setConfigPageOpen(true)
|
setConfigPageOpen(true)
|
||||||
@ -899,7 +902,10 @@ function App() {
|
|||||||
{/* 系统配置页面 */}
|
{/* 系统配置页面 */}
|
||||||
{configPageOpen && (
|
{configPageOpen && (
|
||||||
<ConfigPage
|
<ConfigPage
|
||||||
onClose={() => setConfigPageOpen(false)}
|
onClose={() => {
|
||||||
|
setConfigPageOpen(false)
|
||||||
|
setSettingsClosedTick(t => t + 1)
|
||||||
|
}}
|
||||||
onSaveConnection={handleSaveConnection}
|
onSaveConnection={handleSaveConnection}
|
||||||
initialTab={configInitialTab}
|
initialTab={configInitialTab}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -21,6 +21,8 @@ interface ChatContainerProps {
|
|||||||
sessionId?: string | null
|
sessionId?: string | null
|
||||||
/** 打开设置页(用于专家管理入口) */
|
/** 打开设置页(用于专家管理入口) */
|
||||||
onOpenSettings?: () => void
|
onOpenSettings?: () => void
|
||||||
|
/** 设置弹窗关闭信号(每次关闭递增,用于触发 ExpertSelector 刷新) */
|
||||||
|
settingsClosedTick?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatContainer({
|
export function ChatContainer({
|
||||||
@ -36,6 +38,7 @@ export function ChatContainer({
|
|||||||
highlightedMessageId,
|
highlightedMessageId,
|
||||||
sessionId,
|
sessionId,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
settingsClosedTick,
|
||||||
}: ChatContainerProps) {
|
}: ChatContainerProps) {
|
||||||
const [selectedExpert, setSelectedExpert] = useState<{ name: string; description: string } | null>(null)
|
const [selectedExpert, setSelectedExpert] = useState<{ name: string; description: string } | null>(null)
|
||||||
|
|
||||||
@ -48,6 +51,7 @@ export function ChatContainer({
|
|||||||
sessionId={sessionId ?? null}
|
sessionId={sessionId ?? null}
|
||||||
onManageExperts={onOpenSettings}
|
onManageExperts={onOpenSettings}
|
||||||
onSelectionChange={setSelectedExpert}
|
onSelectionChange={setSelectedExpert}
|
||||||
|
settingsClosedTick={settingsClosedTick}
|
||||||
/>
|
/>
|
||||||
<MessageInput
|
<MessageInput
|
||||||
onSend={onSendMessage}
|
onSend={onSendMessage}
|
||||||
|
|||||||
@ -20,9 +20,11 @@ interface ExpertSelectorProps {
|
|||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
onManageExperts?: () => void
|
onManageExperts?: () => void
|
||||||
onSelectionChange?: (expert: SelectedExpert | null) => void
|
onSelectionChange?: (expert: SelectedExpert | null) => void
|
||||||
|
/** 设置弹窗关闭时触发的刷新信号(每次关闭时递增) */
|
||||||
|
settingsClosedTick?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }: ExpertSelectorProps) {
|
export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange, settingsClosedTick }: ExpertSelectorProps) {
|
||||||
const [selectedExpert, setSelectedExpert] = useState<SelectedExpert | null>(null)
|
const [selectedExpert, setSelectedExpert] = useState<SelectedExpert | null>(null)
|
||||||
const [expertList, setExpertList] = useState<ExpertItem[]>([])
|
const [expertList, setExpertList] = useState<ExpertItem[]>([])
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@ -31,41 +33,46 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const listFetchedRef = useRef(false)
|
|
||||||
|
|
||||||
// Load current selection whenever sessionId changes
|
// 刷新当前会话选中的专家(后端会对禁用专家返回 null)
|
||||||
useEffect(() => {
|
const refreshSelection = useCallback(() => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
setSelectedExpert(null)
|
setSelectedExpert(null)
|
||||||
onSelectionChange?.(null)
|
onSelectionChange?.(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let cancelled = false
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
getSelectedExpert(sessionId)
|
getSelectedExpert(sessionId)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (cancelled) return
|
|
||||||
if (data?.expert) {
|
if (data?.expert) {
|
||||||
setSelectedExpert({ name: data.expert.name, description: data.expert.description })
|
setSelectedExpert({ name: data.expert.name, description: data.expert.description })
|
||||||
onSelectionChange?.({ name: data.expert.name, description: data.expert.description })
|
onSelectionChange?.({ name: data.expert.name, description: data.expert.description })
|
||||||
} else {
|
} else {
|
||||||
|
// 已选专家被禁用/删除时,后端返回 null,前端同步清除
|
||||||
setSelectedExpert(null)
|
setSelectedExpert(null)
|
||||||
onSelectionChange?.(null)
|
onSelectionChange?.(null)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return
|
|
||||||
// Silent fail: default to no expert
|
// Silent fail: default to no expert
|
||||||
setSelectedExpert(null)
|
setSelectedExpert(null)
|
||||||
onSelectionChange?.(null)
|
onSelectionChange?.(null)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => setLoading(false))
|
||||||
if (!cancelled) setLoading(false)
|
}, [sessionId, onSelectionChange])
|
||||||
})
|
|
||||||
return () => { cancelled = true }
|
// Load current selection whenever sessionId changes
|
||||||
|
useEffect(() => {
|
||||||
|
refreshSelection()
|
||||||
|
}, [refreshSelection])
|
||||||
|
|
||||||
|
// 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况)
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsClosedTick === undefined) return
|
||||||
|
refreshSelection()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [sessionId])
|
}, [settingsClosedTick])
|
||||||
|
|
||||||
// Click outside to close dropdown
|
// Click outside to close dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -95,9 +102,10 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
|
|||||||
const handleToggleOpen = () => {
|
const handleToggleOpen = () => {
|
||||||
const next = !open
|
const next = !open
|
||||||
setOpen(next)
|
setOpen(next)
|
||||||
if (next && !listFetchedRef.current) {
|
// 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映
|
||||||
listFetchedRef.current = true
|
if (next) {
|
||||||
fetchExpertList()
|
fetchExpertList()
|
||||||
|
refreshSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,109 +144,116 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
|
|||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative shrink-0 px-4 pt-2 pb-0">
|
<div ref={containerRef} className="relative shrink-0 px-4 pt-2 pb-0">
|
||||||
<div className="max-w-5xl mx-auto flex items-center gap-2">
|
<div className="max-w-5xl mx-auto flex items-center gap-2">
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={handleToggleOpen}
|
<button
|
||||||
disabled={loading}
|
onClick={handleToggleOpen}
|
||||||
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"
|
disabled={loading}
|
||||||
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
|
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)]" />
|
{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)]'}`}
|
<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>
|
||||||
)}
|
)}
|
||||||
{selectedExpert ? (
|
</div>
|
||||||
<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>
|
|
||||||
{error && (
|
{error && (
|
||||||
<span className="text-xs text-red-400 truncate">{error}</span>
|
<span className="text-xs text-red-400 truncate">{error}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
className="absolute z-30 mt-1 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden"
|
|
||||||
style={{ left: 'max(1rem, calc(50% - 18rem))' }}
|
|
||||||
>
|
|
||||||
{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-center gap-1.5">
|
|
||||||
<span className="text-sm font-medium text-[var(--text-primary)] truncate">
|
|
||||||
{expert.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] px-1 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider shrink-0">
|
|
||||||
{expert.source}
|
|
||||||
</span>
|
|
||||||
{isSelected && (
|
|
||||||
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">
|
|
||||||
{expert.description}
|
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user