oudecheng eaee29841d feat(web): 对齐 deepseek-harness 设计语言,重构前端视觉与三栏布局
- 设计 token 重映射为 DeepSeek 调色板(深浅双主题),正文改无衬线字体栈,全面去霓虹化
- 移除顶部 Header,控件迁入左栏(Logo/连接/通道/会话/tabs/底部操作区),左右栏支持拖拽调宽并持久化,折叠为图标 rail
- 对话区对齐 DeepSeek 聊天风:hero/docked 状态机、736px 居中列、用户右对齐柔和气泡、助手无气泡平铺、composer 卡片与反差发送键;子智能体点击导航完整保留
- 全功能区同步换肤:侧栏列表、右栏面板、ConfigPage、弹窗、选择器等
- fix: 修复 virtual-core 3.17.x 陈旧行高导致消息行重叠(宽度变化/滚动停止后强制 resizeItem 重测)
- fix: 修复 ConfigPage/Modal 引用不存在的 fadeIn/scaleIn keyframes 致入场动画失效
2026-08-14 19:15:45 +08:00

411 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Shared UI primitives extracted from ConfigPage.tsx
import { useState, type ReactNode } from 'react';
import { X, Plus, Trash2 } from 'lucide-react';
import { inputCls } from './constants';
import type { KnownSource } from './types';
export function Field({
label,
children,
hint,
}: {
label: string;
children: ReactNode;
hint?: string;
}) {
return (
<div className="space-y-1.5">
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
{children}
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
</div>
);
}
export function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`}
/>
</button>
);
}
export function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
const [input, setInput] = useState('');
const add = () => {
const v = input.trim();
if (v && !tags.includes(v)) {
onChange([...tags, v]);
setInput('');
}
};
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<span
key={t}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]"
>
{t}
<button
onClick={() => onChange(tags.filter((_, j) => j !== i))}
className="hover:text-white transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())}
placeholder="输入后按 Enter"
className={inputCls + ' !text-xs'}
/>
<button
onClick={add}
className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
export function SectionCard({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: ReactNode;
}) {
return (
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
{subtitle && (
<span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">
{subtitle}
</span>
)}
</div>
</div>
<div className="p-4 space-y-4">{children}</div>
</div>
);
}
/** 模态框标题栏:图标 + 标题 + X 关闭按钮,与项目模态框惯例一致 */
export function ModalHeader({
icon,
title,
onClose,
}: {
icon: ReactNode;
title: string;
onClose: () => void;
}) {
return (
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
{icon}
<span className="text-base font-semibold text-[var(--text-primary)]">{title}</span>
<button
onClick={onClose}
className="ml-auto p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
aria-label="关闭"
title="关闭 (Esc)"
>
<X className="h-5 w-5" />
</button>
</div>
);
}
/** 模态框底部按钮区,与项目模态框惯例一致 */
export function ModalFooter({ children }: { children: ReactNode }) {
return (
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center gap-3 justify-end">
{children}
</div>
);
}
export function SourceEditor({
sources,
onChange,
knownSources,
examplePaths,
showCustom = true,
}: {
sources: string[];
onChange: (s: string[]) => void;
knownSources: KnownSource[];
examplePaths?: string[];
showCustom?: boolean;
}) {
const [customInput, setCustomInput] = useState('');
const knownKeys = new Set(knownSources.map((k) => k.key));
const customPaths = sources.filter((s) => !knownKeys.has(s));
const toggleKnown = (key: string) => {
if (sources.includes(key)) {
onChange(sources.filter((s) => s !== key));
} else {
onChange([...sources, key]);
}
};
const addCustom = () => {
const v = customInput.trim();
if (v && !sources.includes(v)) {
onChange([...sources, v]);
setCustomInput('');
}
};
const removeCustom = (path: string) => {
onChange(sources.filter((s) => s !== path));
};
return (
<div className="space-y-4">
{/* Known sources as toggles */}
<div className="space-y-2">
{knownSources.map((src) => (
<div key={src.key} className="flex items-center justify-between py-1.5">
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)]">{src.label}</div>
<div className="text-xs text-[var(--text-muted)] font-mono">{src.description}</div>
</div>
<Toggle checked={sources.includes(src.key)} onChange={() => toggleKnown(src.key)} />
</div>
))}
</div>
{/* Custom paths (only shown when showCustom is true) */}
{showCustom && (
<div className="space-y-2">
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
</div>
{customPaths.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{customPaths.map((p) => (
<span
key={p}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono"
>
{p}
<button
onClick={() => removeCustom(p)}
className="hover:text-white transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustom())}
placeholder="输入绝对路径,如 D:\my-skills"
className={inputCls + ' !text-xs font-mono'}
/>
<button
onClick={addCustom}
className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{examplePaths && (
<p className="text-xs text-[var(--text-muted)]">: {examplePaths.join('、')}</p>
)}
</div>
)}
</div>
);
}
export interface CheckboxListOption {
key: string;
label: string;
description?: string;
/** 可选分组标识,配合 groupBy 使用 */
group?: string;
}
interface CheckboxListProps {
options: CheckboxListOption[];
selected: string[];
onChange: (selected: string[]) => void;
/** 未在 options 中出现但已选中的值legacy 数据),以可移除标签形式展示 */
extraSelected?: string[];
emptyHint?: string;
/** 可选:按返回的分组名分组展示(如 "builtin" / "mcp:xxx" */
groupBy?: (option: CheckboxListOption) => string;
}
/**
* 通用勾选列表组件:用 Toggle 切换每个预设选项;额外的 legacy 已选值以可移除标签展示。
*/
export function CheckboxList({
options,
selected,
onChange,
extraSelected = [],
emptyHint,
groupBy,
}: CheckboxListProps) {
const toggle = (key: string) =>
onChange(selected.includes(key) ? selected.filter((k) => k !== key) : [...selected, key]);
const selectedSet = new Set(selected);
const optionsInList = new Set(options.map((o) => o.key));
// 仅展示未出现在 options 中的额外已选值
const extra = extraSelected.filter((k) => !optionsInList.has(k));
// 分组渲染
const renderOptions = (opts: CheckboxListOption[]) => (
<div className="space-y-1">
{opts.map((option) => (
<div key={option.key} className="flex items-center justify-between py-1.5 gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)] font-mono">{option.label}</div>
{option.description && (
<div className="text-xs text-[var(--text-muted)] truncate">{option.description}</div>
)}
</div>
<Toggle checked={selectedSet.has(option.key)} onChange={() => toggle(option.key)} />
</div>
))}
</div>
);
let body: ReactNode;
if (options.length === 0) {
body = <p className="text-xs text-[var(--text-muted)]">{emptyHint || '无可用选项'}</p>;
} else if (groupBy) {
const groups = new Map<string, CheckboxListOption[]>();
for (const opt of options) {
const g = groupBy(opt);
const arr = groups.get(g) ?? [];
arr.push(opt);
groups.set(g, arr);
}
body = (
<div className="space-y-3">
{Array.from(groups.entries()).map(([g, opts]) => (
<div key={g}>
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">
{g}
</div>
{renderOptions(opts)}
</div>
))}
</div>
);
} else {
body = renderOptions(options);
}
return (
<div className="space-y-2">
{body}
{extra.length > 0 && (
<div>
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">
</div>
<div className="flex flex-wrap gap-1.5">
{extra.map((key) => (
<span
key={key}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono"
>
{key}
<button onClick={() => toggle(key)} className="hover:text-white transition-colors">
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
</div>
)}
</div>
);
}
export function MapEntryHeader({
name,
onDelete,
onRename,
}: {
name: string;
onDelete: () => void;
onRename?: (n: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(name);
return (
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
{editing ? (
<input
value={val}
onChange={(e) => setVal(e.target.value)}
onBlur={() => {
setEditing(false);
onRename?.(val.trim() || name);
}}
onKeyDown={(e) =>
e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))
}
className={inputCls + ' !py-1 !text-xs max-w-[200px]'}
autoFocus
/>
) : (
<span
className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer"
onClick={() => {
if (onRename) {
setVal(name);
setEditing(true);
}
}}
>
{name}
</span>
)}
<div className="flex-1" />
<button
onClick={onDelete}
className="p-1 rounded text-[rgb(242,90,90)]/60 hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
);
}