Files
deerflow-code/web-code/components/qa/TemplateEditor.tsx
T
2026-05-28 18:29:37 +08:00

1521 lines
68 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.
import React, { useState, useEffect, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTheme } from '../../contexts/ThemeContext';
import {
Edit2, Plus, Trash2, Check, X, ChevronDown, ChevronRight,
Target, Package, Globe, BookOpen, MessageSquare, Zap,
Settings, FileText, Info, RefreshCw, AlertCircle, Brain, Save,
} from 'lucide-react';
// ─── Types ────────────────────────────────────────────────────────────────────
interface FieldRequirement {
title: string;
goal: string;
must_cover: string[];
done_criteria: string[];
blockers: string[];
dependencies: string[];
evidence_needs: string[];
preferred_agents: string[];
style_notes: string;
min_chars?: number;
min_items?: number;
}
interface AgentData {
id: string;
name: string;
dimension: string;
expertise: string;
relevant_fields: string[];
analysis_contract: string[];
}
interface CoordinatorConfig {
system_prompt_prefix?: string;
round1_guidance?: string;
middle_round_guidance?: string;
final_round_guidance?: string;
speaker_selection_rules?: string;
focus_instruction_rules?: string;
dependency_driving_rules?: string[];
consistency_check_rules?: string[];
revise_prompt_prefix?: string;
}
interface TemplateData {
template_id: string;
template_name: string;
template_brief: string;
usage_notes: string[];
report_schema: Record<string, string>;
report_examples: Record<string, string>;
field_requirements: Record<string, FieldRequirement>;
agents: AgentData[];
coordinator_config: CoordinatorConfig;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const FIELD_LABELS: Record<string, string> = {
template_id: '模板ID',
template_name: '模板名称',
template_brief: '模板简介',
usage_notes: '使用说明',
report_schema: '报告字段结构',
report_examples: '报告字段示例',
field_requirements: '字段要求配置',
agents: '智能体配置',
coordinator_config: '协调者配置',
title: '字段标题',
goal: '字段目标',
must_cover: '必须覆盖内容',
done_criteria: '完成标准',
blockers: '阻塞条件',
dependencies: '依赖字段',
evidence_needs: '证据需求',
preferred_agents: '推荐智能体',
style_notes: '风格说明',
min_chars: '最小字符数',
min_items: '最小条目数',
id: '智能体ID',
name: '智能体名称',
dimension: '分析维度',
expertise: '专业能力',
relevant_fields: '关联字段',
analysis_contract: '分析契约',
system_prompt_prefix: '系统提示词前缀',
round1_guidance: '第一轮指导',
middle_round_guidance: '中间轮指导',
final_round_guidance: '最终轮指导',
speaker_selection_rules: '发言者选择规则',
focus_instruction_rules: '焦点指令规则',
dependency_driving_rules: '依赖驱动规则',
consistency_check_rules: '一致性检查规则',
revise_prompt_prefix: '修改提示前缀',
};
const REPORT_SCHEMA_LABELS: Record<string, string> = {
task_background: '任务背景与领导指示研判',
resource_assessment: '各方口径与己方资源研判',
information_environment: '战场/舆论场环境分析',
historical_cultural_context: '历史与人文环境分析',
actor_dynamics: '敌我双方动态与优劣势',
opponent_narrative_logic: '对方核心叙事逻辑梳理',
strategic_weakness: '核心战略薄弱点研判',
assessment_conclusion: '研判结论',
};
interface AgentVisual {
Icon: React.ElementType;
lightCard: string;
darkCard: string;
iconLight: string;
iconDark: string;
tagLight: string;
tagDark: string;
}
const AGENT_VISUALS: Record<string, AgentVisual> = {
background: {
Icon: Target,
lightCard: 'from-blue-50 to-sky-50 border-blue-200 hover:border-blue-400 hover:shadow-blue-200/60',
darkCard: 'from-slate-900/80 to-blue-950/20 border-slate-700/70 hover:border-blue-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-blue-100 text-blue-600',
iconDark: 'bg-slate-800/90 text-blue-300 ring-1 ring-blue-500/20',
tagLight: 'bg-blue-100 text-blue-700 border-blue-200',
tagDark: 'bg-slate-800/70 text-blue-200 border-blue-500/25',
},
resource: {
Icon: Package,
lightCard: 'from-emerald-50 to-teal-50 border-emerald-200 hover:border-emerald-400 hover:shadow-emerald-200/60',
darkCard: 'from-slate-900/80 to-emerald-950/20 border-slate-700/70 hover:border-emerald-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-emerald-100 text-emerald-600',
iconDark: 'bg-slate-800/90 text-emerald-300 ring-1 ring-emerald-500/20',
tagLight: 'bg-emerald-100 text-emerald-700 border-emerald-200',
tagDark: 'bg-slate-800/70 text-emerald-200 border-emerald-500/25',
},
information_environment: {
Icon: Globe,
lightCard: 'from-purple-50 to-violet-50 border-purple-200 hover:border-purple-400 hover:shadow-purple-200/60',
darkCard: 'from-slate-900/80 to-purple-950/20 border-slate-700/70 hover:border-purple-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-purple-100 text-purple-600',
iconDark: 'bg-slate-800/90 text-purple-300 ring-1 ring-purple-500/20',
tagLight: 'bg-purple-100 text-purple-700 border-purple-200',
tagDark: 'bg-slate-800/70 text-purple-200 border-purple-500/25',
},
history_culture: {
Icon: BookOpen,
lightCard: 'from-amber-50 to-orange-50 border-amber-200 hover:border-amber-400 hover:shadow-amber-200/60',
darkCard: 'from-slate-900/80 to-amber-950/20 border-slate-700/70 hover:border-amber-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-amber-100 text-amber-600',
iconDark: 'bg-slate-800/90 text-amber-300 ring-1 ring-amber-500/20',
tagLight: 'bg-amber-100 text-amber-700 border-amber-200',
tagDark: 'bg-slate-800/70 text-amber-200 border-amber-500/25',
},
opponent_narrative: {
Icon: MessageSquare,
lightCard: 'from-rose-50 to-pink-50 border-rose-200 hover:border-rose-400 hover:shadow-rose-200/60',
darkCard: 'from-slate-900/80 to-rose-950/20 border-slate-700/70 hover:border-rose-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-rose-100 text-rose-600',
iconDark: 'bg-slate-800/90 text-rose-300 ring-1 ring-rose-500/20',
tagLight: 'bg-rose-100 text-rose-700 border-rose-200',
tagDark: 'bg-slate-800/70 text-rose-200 border-rose-500/25',
},
strategy: {
Icon: Zap,
lightCard: 'from-indigo-50 to-slate-50 border-indigo-200 hover:border-indigo-400 hover:shadow-indigo-200/60',
darkCard: 'from-slate-900/80 to-indigo-950/20 border-slate-700/70 hover:border-indigo-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-indigo-100 text-indigo-600',
iconDark: 'bg-slate-800/90 text-indigo-300 ring-1 ring-indigo-500/20',
tagLight: 'bg-indigo-100 text-indigo-700 border-indigo-200',
tagDark: 'bg-slate-800/70 text-indigo-200 border-indigo-500/25',
},
};
const DEFAULT_VISUAL: AgentVisual = {
Icon: Brain,
lightCard: 'from-gray-50 to-slate-50 border-gray-200 hover:border-gray-400 hover:shadow-gray-200/60',
darkCard: 'from-slate-900/80 to-slate-800/50 border-slate-700/70 hover:border-slate-500/50 hover:shadow-slate-950/50',
iconLight: 'bg-gray-100 text-gray-600',
iconDark: 'bg-slate-800/90 text-slate-300 ring-1 ring-slate-500/20',
tagLight: 'bg-gray-100 text-gray-700 border-gray-200',
tagDark: 'bg-slate-800/70 text-slate-200 border-slate-500/25',
};
const mapFieldLabel = (v: string) => REPORT_SCHEMA_LABELS[v] || v;
function parseExpertiseConfig(expertise: string, fields: string[]) {
const match = expertise.match(/(你的分析应服务于?|你服务于)\s*/);
if (!match || match.index === undefined) {
return {
prefix: expertise,
marker: '你的分析应服务于',
fields: fields.filter(f => expertise.includes(f)),
suffix: '。',
};
}
const prefix = expertise.slice(0, match.index);
const marker = match[1] === '你服务于' ? '你的分析应服务于' : match[1];
const rest = expertise.slice(match.index + match[0].length);
const endIndex = rest.indexOf('。');
const serviceText = endIndex >= 0 ? rest.slice(0, endIndex) : rest;
const suffix = endIndex >= 0 ? rest.slice(endIndex) : '。';
return {
prefix,
marker,
fields: fields.filter(f => serviceText.includes(f)),
suffix,
};
}
function buildExpertise(prefix: string, fields: string[], suffix: string) {
const normalizedPrefix = prefix.trim();
const prefixSeparator = normalizedPrefix && !/[。!?.!?]$/.test(normalizedPrefix) ? '。' : '';
const serviceFields = fields.join('、');
const normalizedSuffix = suffix.trim() || '。';
return `${normalizedPrefix}${prefixSeparator}你的分析应服务于 ${serviceFields}${normalizedSuffix.startsWith('。') ? normalizedSuffix : `。${normalizedSuffix}`}`;
}
function replaceFieldKeysWithLabels(text: string, fields: string[]) {
return fields.reduce((next, field) => next.replaceAll(field, mapFieldLabel(field)), text);
}
function replaceFieldLabelsWithKeys(text: string, fields: string[]) {
return fields.reduce((next, field) => next.replaceAll(mapFieldLabel(field), field), text);
}
function extractFieldTokens(text: string, fields: string[]) {
return fields.filter(field => text.includes(field));
}
function removeFieldToken(text: string, field: string) {
return text
.replaceAll(field, '')
.replace(/、{2,}/g, '、')
.replace(/\s{2,}/g, ' ')
.replace(/、([,。;;,.])/g, '$1')
.replace(/([、,;;,])\s*([。,.])/g, '$2')
.trim();
}
function appendFieldTokens(text: string, fields: string[]) {
const missing = fields.filter(field => !text.includes(field));
if (!missing.length) return text;
const separator = text.trim().endsWith('。') ? '' : '。';
return `${text.trim()}${separator}相关字段:${missing.join('、')}。`;
}
function getValueType(value: unknown) {
if (Array.isArray(value)) return 'array';
if (value === null) return 'null';
return typeof value;
}
function compareJsonShape(reference: unknown, current: unknown, path = 'root') {
const issues: string[] = [];
const refType = getValueType(reference);
const curType = getValueType(current);
if (refType !== curType) {
issues.push(`${path}: 类型不一致,期望 ${refType},实际 ${curType}`);
return issues;
}
if (refType === 'array') {
const refArr = reference as unknown[];
const curArr = current as unknown[];
if (refArr.length > 0 && curArr.length > 0) {
issues.push(...compareJsonShape(refArr[0], curArr[0], `${path}[]`));
}
return issues;
}
if (refType === 'object') {
const refObj = reference as Record<string, unknown>;
const curObj = current as Record<string, unknown>;
const refKeys = Object.keys(refObj);
const curKeys = Object.keys(curObj);
refKeys.filter(key => !(key in curObj)).forEach(key => issues.push(`${path}.${key}: 缺少字段`));
curKeys.filter(key => !(key in refObj)).forEach(key => issues.push(`${path}.${key}: 多余字段`));
refKeys
.filter(key => key in curObj)
.forEach(key => issues.push(...compareJsonShape(refObj[key], curObj[key], `${path}.${key}`)));
}
return issues;
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function RestoreAgentModal({
agents,
onClose,
onRestore,
isDark,
}: {
agents: AgentData[];
onClose: () => void;
onRestore: (agent: AgentData) => void;
isDark: boolean;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
className={`relative w-full max-w-xl rounded-2xl border shadow-2xl ${isDark ? 'bg-slate-900 border-slate-700' : 'bg-white border-gray-200'}`}
onClick={e => e.stopPropagation()}
>
<div className={`flex items-center justify-between px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<h3 className={`font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>恢复已删除智能体</h3>
<button onClick={onClose}><X className="w-4 h-4" /></button>
</div>
<div className="p-5 space-y-2 max-h-[50vh] overflow-y-auto">
{agents.map(agent => (
<button
key={agent.id}
onClick={() => onRestore(agent)}
className={`w-full text-left rounded-xl border p-3 transition-all duration-200 hover:scale-[1.01] ${isDark ? 'border-slate-700 hover:border-blue-500 bg-slate-800/40' : 'border-gray-200 hover:border-blue-300 bg-gray-50'}`}
>
<div className={`font-medium text-sm ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{agent.name}</div>
<div className={`text-xs mt-1 ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>{agent.id} · {agent.dimension}</div>
</button>
))}
</div>
</div>
</div>
);
}
function CoordinatorRuleList({
rules,
fields,
onChange,
isDark,
}: {
rules: string[];
fields: string[];
onChange: (rules: string[]) => void;
isDark: boolean;
}) {
return (
<div className="space-y-2">
{rules.map((rule, index) => (
<div key={index} className={`rounded-lg border px-3 py-2 ${isDark ? 'border-slate-700/60 bg-slate-800/30' : 'border-gray-200 bg-gray-50/60'}`}>
<CoordinatorFieldText
value={rule}
fields={fields}
onChange={nextRule => onChange(rules.map((item, i) => i === index ? nextRule : item))}
isDark={isDark}
/>
<button
onClick={() => onChange(rules.filter((_, i) => i !== index))}
className={`mt-2 inline-flex items-center gap-1 text-xs transition-colors ${isDark ? 'text-slate-500 hover:text-red-400' : 'text-gray-400 hover:text-red-500'}`}
>
<Trash2 className="w-3 h-3" /> 删除规则
</button>
</div>
))}
<button
onClick={() => onChange([...rules, fields[0] ? `补充 ${fields[0]} 的依赖驱动规则。` : '补充新的依赖驱动规则。'])}
className={`inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded-full border border-dashed transition-all duration-200 hover:scale-105 ${isDark ? 'border-slate-600 text-slate-500 hover:border-teal-500 hover:text-teal-400' : 'border-gray-300 text-gray-400 hover:border-teal-400 hover:text-teal-500'}`}
>
<Plus className="w-3 h-3" /> 添加规则
</button>
</div>
);
}
function CoordinatorFieldText({
value,
fields,
onChange,
isDark,
}: {
value: string;
fields: string[];
onChange: (v: string) => void;
isDark: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(replaceFieldKeysWithLabels(value, fields));
const [pickerOpen, setPickerOpen] = useState(false);
const selectedFields = extractFieldTokens(value, fields);
const fieldOptions = fields.map(f => ({ value: f, label: mapFieldLabel(f) }));
const inputCls = isDark
? 'bg-slate-800 border-blue-500/60 text-slate-100 focus:border-blue-400'
: 'bg-white border-blue-300 text-gray-800 focus:border-blue-500';
const confirm = () => {
onChange(replaceFieldLabelsWithKeys(draft, fields));
setEditing(false);
};
const removeField = (field: string) => {
onChange(removeFieldToken(value, field));
setDraft(replaceFieldKeysWithLabels(removeFieldToken(value, field), fields));
};
const addFields = (nextFields: string[]) => {
const next = appendFieldTokens(value, nextFields);
onChange(next);
setDraft(replaceFieldKeysWithLabels(next, fields));
setPickerOpen(false);
};
return (
<div className="space-y-2">
{editing ? (
<div className="space-y-2">
<textarea
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
rows={5}
className={`w-full px-3 py-2 text-sm rounded-lg border outline-none resize-y leading-relaxed transition-colors ${inputCls}`}
/>
<div className="flex gap-2">
<button onClick={confirm} className="flex items-center gap-1 px-3 py-1 text-xs rounded-lg bg-green-500 hover:bg-green-400 text-white transition-colors"><Check className="w-3 h-3" /> 确认</button>
<button onClick={() => { setDraft(replaceFieldKeysWithLabels(value, fields)); setEditing(false); }} className={`flex items-center gap-1 px-3 py-1 text-xs rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}><X className="w-3 h-3" /> 取消</button>
</div>
</div>
) : (
<div className="group/editable flex items-start gap-2 rounded-lg px-2 py-1.5 -mx-2 -my-1.5 transition-all duration-200 hover:bg-black/5 dark:hover:bg-white/5 cursor-pointer" onClick={() => { setDraft(replaceFieldKeysWithLabels(value, fields)); setEditing(true); }}>
<span className={`flex-1 text-sm leading-relaxed break-words whitespace-pre-wrap ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>{replaceFieldKeysWithLabels(value, fields)}</span>
<Edit2 className={`flex-shrink-0 w-3.5 h-3.5 mt-0.5 opacity-0 group-hover/editable:opacity-100 transition-all duration-200 ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
</div>
)}
<div className="flex flex-wrap gap-1.5 items-center">
{selectedFields.map(field => (
<span key={field} className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs rounded-full border ${isDark ? 'bg-slate-800/70 text-cyan-200 border-cyan-500/25' : 'bg-cyan-50 text-cyan-700 border-cyan-200'}`}>
{mapFieldLabel(field)}
<button onClick={() => removeField(field)} className="hover:text-red-500"><X className="w-3 h-3" /></button>
</span>
))}
<button onClick={() => setPickerOpen(true)} className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-dashed transition-all duration-200 hover:scale-105 ${isDark ? 'border-slate-600 text-slate-500 hover:border-cyan-500 hover:text-cyan-400' : 'border-gray-300 text-gray-400 hover:border-cyan-400 hover:text-cyan-500'}`}>
<Plus className="w-3 h-3" /> 添加字段
</button>
</div>
{pickerOpen && (
<MultiSelectModal
title="添加第一轮配置字段"
selected={selectedFields}
options={fieldOptions}
onClose={() => setPickerOpen(false)}
onSave={addFields}
isDark={isDark}
/>
)}
</div>
);
}
function MultiSelectModal({
title,
selected,
options,
minKeep = 0,
onClose,
onSave,
isDark,
}: {
title: string;
selected: string[];
options: { value: string; label: string }[];
minKeep?: number;
onClose: () => void;
onSave: (v: string[]) => void;
isDark: boolean;
}) {
const [draft, setDraft] = useState<string[]>([...selected]);
const toggle = (value: string) => {
const has = draft.includes(value);
if (has && draft.length <= minKeep) {
window.alert(`最少必须保留 ${minKeep} 个`);
return;
}
setDraft(prev => has ? prev.filter(v => v !== value) : [...prev, value]);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
className={`relative w-full max-w-xl rounded-2xl border shadow-2xl ${isDark ? 'bg-slate-900 border-slate-700' : 'bg-white border-gray-200'}`}
onClick={e => e.stopPropagation()}
>
<div className={`flex items-center justify-between px-5 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<h3 className={`font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{title}</h3>
<button onClick={onClose}><X className="w-4 h-4" /></button>
</div>
<div className="p-5 flex flex-wrap gap-2 max-h-[45vh] overflow-y-auto">
{options.map(op => {
const active = draft.includes(op.value);
return (
<button
key={op.value}
onClick={() => toggle(op.value)}
className={`px-2.5 py-1 text-xs rounded-full border transition-all ${
active
? (isDark ? 'bg-blue-700/60 border-blue-500 text-blue-200' : 'bg-blue-100 border-blue-400 text-blue-700')
: (isDark ? 'border-slate-600 text-slate-400' : 'border-gray-300 text-gray-600')
}`}
>
{op.label}
</button>
);
})}
</div>
<div className={`flex justify-end gap-2 px-5 py-4 border-t ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<button onClick={onClose} className="px-3 py-1.5 text-sm rounded-lg border">取消</button>
<button
onClick={() => {
if (draft.length < minKeep) {
window.alert(`最少必须保留 ${minKeep} 个`);
return;
}
onSave(draft);
}}
className="px-3 py-1.5 text-sm rounded-lg bg-blue-500 text-white"
>
确认
</button>
</div>
</div>
</div>
);
}
function TagList({
tags,
onChange,
isDark,
tagLight = 'bg-sky-50 text-sky-700 border-sky-200',
tagDark = 'bg-slate-800/70 text-sky-200 border-sky-500/25',
}: {
tags: string[];
onChange: (tags: string[]) => void;
isDark: boolean;
tagLight?: string;
tagDark?: string;
}) {
const [editIdx, setEditIdx] = useState<number | null>(null);
const [editVal, setEditVal] = useState('');
const [adding, setAdding] = useState(false);
const [addVal, setAddVal] = useState('');
const tagCls = isDark ? tagDark : tagLight;
const inputCls = isDark
? 'bg-slate-800 border-blue-500/60 text-slate-100 placeholder-slate-500 focus:border-blue-400'
: 'bg-white border-blue-400 text-gray-800 placeholder-gray-400 focus:border-blue-500';
const confirmEdit = () => {
if (editIdx === null) return;
const next = [...tags];
if (editVal.trim()) next[editIdx] = editVal.trim();
else next.splice(editIdx, 1);
onChange(next);
setEditIdx(null);
};
const confirmAdd = () => {
if (addVal.trim()) onChange([...tags, addVal.trim()]);
setAdding(false);
setAddVal('');
};
return (
<div className="flex flex-wrap gap-1.5 items-center">
{tags.map((tag, i) =>
editIdx === i ? (
<span key={i} className="inline-flex items-center gap-1">
<input
autoFocus
value={editVal}
onChange={e => setEditVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmEdit(); if (e.key === 'Escape') setEditIdx(null); }}
className={`px-2 py-0.5 text-xs rounded border outline-none w-36 transition-colors ${inputCls}`}
/>
<button onClick={confirmEdit} className="text-green-500 hover:text-green-400 transition-colors"><Check className="w-3 h-3" /></button>
<button onClick={() => setEditIdx(null)} className="text-red-400 hover:text-red-300 transition-colors"><X className="w-3 h-3" /></button>
</span>
) : (
<span key={i} className="group/tag inline-flex items-center gap-1">
<span
onClick={() => { setEditIdx(i); setEditVal(tag); setAdding(false); }}
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs rounded-full border cursor-pointer transition-all duration-200 hover:scale-105 hover:opacity-90 ${tagCls}`}
>
{tag}
<button
onClick={e => { e.stopPropagation(); onChange(tags.filter((_, j) => j !== i)); }}
className="opacity-0 group-hover/tag:opacity-100 transition-opacity hover:text-red-500 ml-0.5"
>
<X className="w-2.5 h-2.5" />
</button>
</span>
</span>
)
)}
{adding ? (
<span className="inline-flex items-center gap-1">
<input
autoFocus
value={addVal}
onChange={e => setAddVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') { setAdding(false); setAddVal(''); } }}
placeholder="输入内容…"
className={`px-2 py-0.5 text-xs rounded border outline-none w-36 transition-colors ${inputCls}`}
/>
<button onClick={confirmAdd} className="text-green-500 hover:text-green-400 transition-colors"><Check className="w-3 h-3" /></button>
<button onClick={() => { setAdding(false); setAddVal(''); }} className="text-red-400 hover:text-red-300 transition-colors"><X className="w-3 h-3" /></button>
</span>
) : (
<button
onClick={() => { setAdding(true); setEditIdx(null); }}
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-dashed transition-all duration-200 hover:scale-105 ${isDark ? 'border-slate-600 text-slate-500 hover:border-blue-500 hover:text-blue-400' : 'border-gray-300 text-gray-400 hover:border-blue-400 hover:text-blue-500'}`}
>
<Plus className="w-3 h-3" /> 添加
</button>
)}
</div>
);
}
function EditableText({
value,
onChange,
isDark,
multiline = false,
}: {
value: string;
onChange: (v: string) => void;
isDark: boolean;
multiline?: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const confirm = () => { onChange(draft); setEditing(false); };
const cancel = () => { setDraft(value); setEditing(false); };
const inputCls = isDark
? 'bg-slate-800 border-blue-500/60 text-slate-100 focus:border-blue-400'
: 'bg-white border-blue-300 text-gray-800 focus:border-blue-500';
if (editing) {
return (
<div className="space-y-2">
{multiline ? (
<textarea
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
rows={multiline ? 5 : 2}
className={`w-full px-3 py-2 text-sm rounded-lg border outline-none resize-y font-mono leading-relaxed transition-colors ${inputCls}`}
/>
) : (
<input
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') cancel(); }}
className={`w-full px-3 py-1.5 text-sm rounded-lg border outline-none transition-colors ${inputCls}`}
/>
)}
<div className="flex gap-2">
<button onClick={confirm} className="flex items-center gap-1 px-3 py-1 text-xs rounded-lg bg-green-500 hover:bg-green-400 text-white transition-colors">
<Check className="w-3 h-3" /> 确认
</button>
<button onClick={cancel} className={`flex items-center gap-1 px-3 py-1 text-xs rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}>
<X className="w-3 h-3" /> 取消
</button>
</div>
</div>
);
}
return (
<div className="group/editable flex items-start gap-2 rounded-lg px-2 py-1.5 -mx-2 -my-1.5 transition-all duration-200 hover:bg-black/5 dark:hover:bg-white/5 cursor-pointer" onClick={() => { setDraft(value); setEditing(true); }}>
<span className={`flex-1 text-sm leading-relaxed break-words ${isDark ? 'text-slate-300' : 'text-gray-700'} ${multiline ? 'whitespace-pre-wrap font-mono text-xs' : ''}`}>
{value || <span className={isDark ? 'text-slate-600 italic' : 'text-gray-300 italic'}>(空)</span>}
</span>
<Edit2 className={`flex-shrink-0 w-3.5 h-3.5 mt-0.5 opacity-0 group-hover/editable:opacity-100 transition-all duration-200 ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
</div>
);
}
function EditableMarkdown({
value,
onChange,
isDark,
}: {
value: string;
onChange: (v: string) => void;
isDark: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
if (editing) {
return (
<div className="space-y-2">
<textarea
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
rows={10}
className={`w-full px-3 py-2 text-sm rounded-lg border outline-none resize-y font-mono leading-relaxed transition-colors ${isDark ? 'bg-slate-800 border-blue-500/60 text-slate-100 focus:border-blue-400' : 'bg-white border-blue-300 text-gray-800 focus:border-blue-500'}`}
/>
<div className="flex gap-2">
<button
onClick={() => { onChange(draft); setEditing(false); }}
className="flex items-center gap-1 px-3 py-1 text-xs rounded-lg bg-green-500 hover:bg-green-400 text-white transition-colors"
>
<Check className="w-3 h-3" /> 确认
</button>
<button
onClick={() => { setDraft(value); setEditing(false); }}
className={`flex items-center gap-1 px-3 py-1 text-xs rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}
>
<X className="w-3 h-3" /> 取消
</button>
</div>
</div>
);
}
return (
<div
className={`group/markdown relative rounded-lg border px-3 py-3 transition-colors cursor-pointer ${isDark ? 'border-slate-700/50 bg-slate-900/20 hover:border-slate-600' : 'border-gray-200 bg-white hover:border-gray-300'}`}
onClick={() => { setDraft(value); setEditing(true); }}
>
<Edit2 className={`absolute top-2 right-2 w-4 h-4 opacity-0 group-hover/markdown:opacity-100 transition-opacity ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
<div className={`prose prose-sm max-w-none ${isDark ? 'prose-invert' : ''}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{value}</ReactMarkdown>
</div>
</div>
);
}
function EditableNumber({
value,
onChange,
isDark,
}: {
value: number | undefined;
onChange: (v: number | undefined) => void;
isDark: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(String(value ?? ''));
const confirm = () => {
const n = parseInt(draft, 10);
onChange(isNaN(n) ? undefined : n);
setEditing(false);
};
if (editing) {
return (
<span className="inline-flex items-center gap-1">
<input
autoFocus
type="number"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') setEditing(false); }}
className={`w-20 px-2 py-0.5 text-sm rounded border outline-none ${isDark ? 'bg-slate-800 border-blue-500/60 text-slate-100' : 'bg-white border-blue-300 text-gray-800'}`}
/>
<button onClick={confirm} className="text-green-500 hover:text-green-400"><Check className="w-3.5 h-3.5" /></button>
<button onClick={() => setEditing(false)} className="text-red-400 hover:text-red-300"><X className="w-3.5 h-3.5" /></button>
</span>
);
}
return (
<span
onClick={() => { setDraft(String(value ?? '')); setEditing(true); }}
className={`group/num inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded cursor-pointer transition-all duration-200 hover:scale-105 ${isDark ? 'text-blue-300 bg-blue-900/30 hover:bg-blue-900/50' : 'text-blue-600 bg-blue-50 hover:bg-blue-100'}`}
>
{value ?? <span className="italic opacity-50">未设置</span>}
<Edit2 className="w-3 h-3 opacity-0 group-hover/num:opacity-100 transition-opacity" />
</span>
);
}
function SectionWrapper({
title,
icon: Icon,
children,
isDark,
defaultOpen = true,
}: {
title: string;
icon: React.ElementType;
children: React.ReactNode;
isDark: boolean;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`rounded-xl border overflow-hidden transition-all duration-200 ${isDark ? 'bg-slate-800/40 border-slate-700/60' : 'bg-white border-gray-200 shadow-sm'}`}>
<button
onClick={() => setOpen(o => !o)}
className={`w-full flex items-center justify-between px-5 py-4 transition-colors duration-200 ${isDark ? 'hover:bg-slate-700/30' : 'hover:bg-gray-50'}`}
>
<div className="flex items-center gap-2.5">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${isDark ? 'bg-slate-700' : 'bg-red-50'}`}>
<Icon className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
</div>
<span className={`font-semibold text-sm ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{title}</span>
</div>
{open
? <ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />
: <ChevronRight className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />}
</button>
<div className={`grid transition-all duration-300 ease-in-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-70'}`}>
<div className="overflow-hidden">
<div className="px-5 pb-5 space-y-4">{children}</div>
</div>
</div>
</div>
);
}
function LabelRow({
label,
children,
isDark,
}: {
label: string;
children: React.ReactNode;
isDark: boolean;
}) {
return (
<div className="space-y-1.5">
<span className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{label}</span>
<div>{children}</div>
</div>
);
}
// ─── Agent Edit Modal ─────────────────────────────────────────────────────────
function AgentEditModal({
agent,
existingFields,
onSave,
onClose,
isDark,
}: {
agent: AgentData;
existingFields: string[];
onSave: (a: AgentData) => void;
onClose: () => void;
isDark: boolean;
}) {
const [form, setForm] = useState<AgentData>({ ...agent, relevant_fields: [...agent.relevant_fields], analysis_contract: [...agent.analysis_contract] });
const initialExpertise = useMemo(() => parseExpertiseConfig(agent.expertise, existingFields), [agent.expertise, existingFields]);
const [expertisePrefix, setExpertisePrefix] = useState(initialExpertise.prefix);
const [expertiseFields, setExpertiseFields] = useState<string[]>(initialExpertise.fields.length ? initialExpertise.fields : existingFields.slice(0, 1));
const [expertiseSuffix] = useState(initialExpertise.suffix);
const [fieldPickerOpen, setFieldPickerOpen] = useState(false);
const [expertisePickerOpen, setExpertisePickerOpen] = useState(false);
const set = (k: keyof AgentData, v: unknown) => setForm(p => ({ ...p, [k]: v }));
const inputCls = `w-full px-3 py-2 text-sm rounded-lg border outline-none transition-colors ${isDark ? 'bg-slate-800 border-slate-600 text-slate-100 focus:border-blue-400' : 'bg-white border-gray-300 text-gray-800 focus:border-blue-400'}`;
const labelCls = `text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`;
const fieldOptions = existingFields.map(f => ({ value: f, label: mapFieldLabel(f) }));
const removeRelevantField = (field: string) => {
if (form.relevant_fields.length <= 1) {
window.alert('关联字段最少必须保留 1 个');
return;
}
set('relevant_fields', form.relevant_fields.filter(x => x !== field));
};
const removeExpertiseField = (field: string) => {
if (expertiseFields.length <= 1) {
window.alert('专业能力服务字段最少必须保留 1 个');
return;
}
setExpertiseFields(prev => prev.filter(x => x !== field));
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
className={`relative w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-2xl border shadow-2xl ${isDark ? 'bg-slate-900 border-slate-700' : 'bg-white border-gray-200'}`}
onClick={e => e.stopPropagation()}
>
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<h2 className={`font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>编辑智能体</h2>
<button onClick={onClose} className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-800 text-slate-400' : 'hover:bg-gray-100 text-gray-500'}`}><X className="w-4 h-4" /></button>
</div>
<div className="p-6 space-y-4">
{(['id', 'name', 'dimension'] as const).map(k => (
<div key={k} className="space-y-1">
<label className={labelCls}>{FIELD_LABELS[k]}</label>
<input value={form[k]} onChange={e => set(k, e.target.value)} className={inputCls} />
</div>
))}
<div className="space-y-2">
<label className={labelCls}>{FIELD_LABELS.expertise}</label>
<div className="space-y-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>分析职责描述(“你的分析应服务于”前面的内容)</span>
<textarea
value={expertisePrefix}
onChange={e => setExpertisePrefix(e.target.value)}
rows={3}
className={inputCls + ' resize-y text-xs leading-relaxed'}
/>
</div>
<div className="space-y-1.5">
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>你的分析应服务于</span>
<div className="flex flex-wrap gap-1.5">
{expertiseFields.map(f => (
<span key={f} className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs rounded-full border ${isDark ? 'bg-slate-800/70 text-emerald-200 border-emerald-500/25' : 'bg-emerald-50 text-emerald-700 border-emerald-200'}`}>
{mapFieldLabel(f)}
<button onClick={() => removeExpertiseField(f)} className="hover:text-red-500"><X className="w-3 h-3" /></button>
</span>
))}
<button onClick={() => setExpertisePickerOpen(true)} className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-dashed ${isDark ? 'border-slate-600 text-slate-400 hover:border-emerald-500 hover:text-emerald-300' : 'border-gray-300 text-gray-500 hover:border-emerald-400 hover:text-emerald-600'}`}>
<Plus className="w-3 h-3" /> 添加
</button>
</div>
</div>
</div>
<div className="space-y-1.5">
<label className={labelCls}>{FIELD_LABELS.relevant_fields}</label>
<div className="flex flex-wrap gap-1.5">
{form.relevant_fields.map(f => (
<span key={f} className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs rounded-full border ${isDark ? 'bg-slate-800/70 text-blue-200 border-blue-500/25' : 'bg-blue-50 text-blue-700 border-blue-200'}`}>
{mapFieldLabel(f)}
<button onClick={() => removeRelevantField(f)} className="hover:text-red-500"><X className="w-3 h-3" /></button>
</span>
))}
<button onClick={() => setFieldPickerOpen(true)} className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-dashed ${isDark ? 'border-slate-600 text-slate-400 hover:border-blue-500 hover:text-blue-300' : 'border-gray-300 text-gray-500 hover:border-blue-400 hover:text-blue-600'}`}>
<Plus className="w-3 h-3" /> 添加
</button>
</div>
</div>
<div className="space-y-1.5">
<label className={labelCls}>{FIELD_LABELS.analysis_contract}</label>
<TagList tags={form.analysis_contract} onChange={v => set('analysis_contract', v)} isDark={isDark} tagLight="bg-violet-50 text-violet-700 border-violet-200" tagDark="bg-slate-800/70 text-violet-200 border-violet-500/25" />
</div>
</div>
<div className={`flex justify-end gap-3 px-6 py-4 border-t ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<button onClick={onClose} className={`px-4 py-2 text-sm rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}>取消</button>
<button onClick={() => onSave({ ...form, expertise: buildExpertise(expertisePrefix, expertiseFields, expertiseSuffix) })} className="px-4 py-2 text-sm rounded-lg bg-blue-500 hover:bg-blue-400 text-white transition-colors flex items-center gap-1.5">
<Check className="w-3.5 h-3.5" /> 保存
</button>
</div>
</div>
{fieldPickerOpen && (
<MultiSelectModal
title="选择关联字段"
selected={form.relevant_fields}
options={fieldOptions}
minKeep={1}
onClose={() => setFieldPickerOpen(false)}
onSave={v => { set('relevant_fields', v); setFieldPickerOpen(false); }}
isDark={isDark}
/>
)}
{expertisePickerOpen && (
<MultiSelectModal
title="选择专业能力服务字段"
selected={expertiseFields}
options={fieldOptions}
minKeep={1}
onClose={() => setExpertisePickerOpen(false)}
onSave={v => { setExpertiseFields(v); setExpertisePickerOpen(false); }}
isDark={isDark}
/>
)}
</div>
);
}
// ─── Agent Card ───────────────────────────────────────────────────────────────
function AgentCard({
agent,
existingFields,
onUpdate,
onDelete,
isDark,
}: {
agent: AgentData;
existingFields: string[];
onUpdate: (a: AgentData) => void;
onDelete: () => void;
isDark: boolean;
}) {
const [editing, setEditing] = useState(false);
const [hovered, setHovered] = useState(false);
const vis = AGENT_VISUALS[agent.id] ?? DEFAULT_VISUAL;
const { Icon } = vis;
const cardCls = isDark ? vis.darkCard : vis.lightCard;
const iconCls = isDark ? vis.iconDark : vis.iconLight;
const tagCls = isDark ? vis.tagDark : vis.tagLight;
const expertiseConfig = parseExpertiseConfig(agent.expertise, existingFields);
return (
<>
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`relative rounded-xl border p-4 bg-gradient-to-br transition-all duration-300 ${cardCls} ${hovered ? 'scale-[1.02] shadow-lg -translate-y-0.5' : 'scale-100 shadow-sm'}`}
>
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-transform duration-300 ${iconCls} ${hovered ? 'scale-110 rotate-3' : ''}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className={`font-semibold text-sm ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{agent.name}</p>
<p className={`text-xs mt-0.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{agent.dimension}</p>
</div>
</div>
<div className={`flex items-center gap-1 transition-opacity duration-200 ${hovered ? 'opacity-100' : 'opacity-0'}`}>
<button
onClick={() => setEditing(true)}
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700 text-slate-400 hover:text-blue-300' : 'hover:bg-white/70 text-gray-400 hover:text-blue-500'}`}
title="编辑"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700 text-slate-400 hover:text-red-400' : 'hover:bg-white/70 text-gray-400 hover:text-red-500'}`}
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* ID badge */}
<code className={`inline-block text-xs px-2 py-0.5 rounded-full border mb-2 font-mono ${tagCls}`}>{agent.id}</code>
{/* Expertise */}
<div className="mb-3">
<p className={`text-xs leading-relaxed mb-1.5 line-clamp-2 ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>{expertiseConfig.prefix}</p>
<div className="flex flex-wrap gap-1">
{expertiseConfig.fields.map(f => (
<span key={f} className={`text-xs px-2 py-0.5 rounded-full border ${isDark ? 'bg-slate-800/70 text-emerald-200 border-emerald-500/25' : 'bg-emerald-50 text-emerald-700 border-emerald-200'}`}>{mapFieldLabel(f)}</span>
))}
</div>
</div>
{/* Relevant fields */}
{agent.relevant_fields.length > 0 && (
<div className="mb-2">
<p className={`text-xs font-medium mb-1.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.relevant_fields}</p>
<div className="flex flex-wrap gap-1">
{agent.relevant_fields.map(f => (
<span key={f} className={`text-xs px-2 py-0.5 rounded-full border ${tagCls}`}>{mapFieldLabel(f)}</span>
))}
</div>
</div>
)}
{/* Analysis contract */}
{agent.analysis_contract.length > 0 && (
<div>
<p className={`text-xs font-medium mb-1.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.analysis_contract}</p>
<ul className="space-y-1">
{agent.analysis_contract.map((c, i) => (
<li key={i} className={`text-xs flex gap-1.5 items-start ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>
<span className={`mt-1 flex-shrink-0 w-1.5 h-1.5 rounded-full ${isDark ? 'bg-slate-600' : 'bg-gray-300'}`} />
{c}
</li>
))}
</ul>
</div>
)}
</div>
{editing && (
<AgentEditModal
agent={agent}
existingFields={existingFields}
onSave={updated => { onUpdate(updated); setEditing(false); }}
onClose={() => setEditing(false)}
isDark={isDark}
/>
)}
</>
);
}
// ─── Field Requirement Card ───────────────────────────────────────────────────
function FieldRequirementCard({
fieldKey,
req,
onChange,
isDark,
fieldOptions,
agentOptions,
}: {
fieldKey: string;
req: FieldRequirement;
onChange: (r: FieldRequirement) => void;
isDark: boolean;
fieldOptions: { value: string; label: string }[];
agentOptions: { value: string; label: string }[];
}) {
const [open, setOpen] = useState(false);
const [picker, setPicker] = useState<'dependencies' | 'preferred_agents' | null>(null);
const label = REPORT_SCHEMA_LABELS[fieldKey] || fieldKey;
const setField = <K extends keyof FieldRequirement>(k: K, v: FieldRequirement[K]) =>
onChange({ ...req, [k]: v });
const ARRAY_FIELDS: (keyof FieldRequirement)[] = ['must_cover', 'done_criteria', 'blockers', 'evidence_needs'];
const removeRequiredItem = (key: 'dependencies' | 'preferred_agents', value: string) => {
const current = req[key];
if (current.length <= 1) {
window.alert(key === 'dependencies' ? '依赖字段最少必须保留 1 个' : '推荐智能体最少必须保留 1 个');
return;
}
setField(key, current.filter(x => x !== value));
};
return (
<div className={`rounded-xl border overflow-hidden transition-all duration-200 ${isDark ? 'border-slate-700/60 bg-slate-800/30' : 'border-gray-200 bg-gray-50'}`}>
<button
onClick={() => setOpen(o => !o)}
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors duration-200 group/fld ${isDark ? 'hover:bg-slate-700/30' : 'hover:bg-white'}`}
>
<div className="flex items-center gap-2 min-w-0">
<code className={`text-xs px-2 py-0.5 rounded font-mono flex-shrink-0 ${isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-500'}`}>{fieldKey}</code>
<span className={`text-sm font-medium truncate ${isDark ? 'text-slate-200' : 'text-gray-800'}`}>{label}</span>
</div>
{open
? <ChevronDown className={`w-4 h-4 flex-shrink-0 transition-transform duration-300 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />
: <ChevronRight className={`w-4 h-4 flex-shrink-0 transition-transform duration-300 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />}
</button>
<div className={`grid transition-all duration-300 ease-in-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-70'}`}>
<div className="overflow-hidden">
<div className={`px-4 pb-4 pt-1 space-y-3 border-t ${isDark ? 'border-slate-700/60' : 'border-gray-200'}`}>
{(['title', 'goal', 'style_notes'] as const).map(k => (
<LabelRow key={k} label={FIELD_LABELS[k]} isDark={isDark}>
<EditableText
value={req[k] as string}
onChange={v => setField(k, v)}
isDark={isDark}
multiline={k === 'style_notes'}
/>
</LabelRow>
))}
<div className="flex gap-6">
{(['min_chars', 'min_items'] as const).map(k => (
<LabelRow key={k} label={FIELD_LABELS[k]} isDark={isDark}>
<EditableNumber value={req[k]} onChange={v => setField(k, v)} isDark={isDark} />
</LabelRow>
))}
</div>
{ARRAY_FIELDS.map((k, index) => (
<LabelRow key={String(k)} label={FIELD_LABELS[String(k)]} isDark={isDark}>
<TagList
tags={req[k] as string[]}
onChange={v => setField(k as keyof FieldRequirement, v as FieldRequirement[typeof k])}
isDark={isDark}
tagLight={index % 2 === 0 ? 'bg-emerald-50 text-emerald-700 border-emerald-200' : 'bg-amber-50 text-amber-700 border-amber-200'}
tagDark={index % 2 === 0 ? 'bg-slate-800/70 text-emerald-200 border-emerald-500/25' : 'bg-slate-800/70 text-amber-200 border-amber-500/25'}
/>
</LabelRow>
))}
<LabelRow label={FIELD_LABELS.dependencies} isDark={isDark}>
<div className="flex flex-wrap gap-1.5 items-center">
{req.dependencies.map(v => (
<span key={v} className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs rounded-full border ${isDark ? 'bg-slate-800/70 text-violet-200 border-violet-500/25' : 'bg-violet-50 text-violet-700 border-violet-200'}`}>
{fieldOptions.find(x => x.value === v)?.label || v}
<button onClick={() => removeRequiredItem('dependencies', v)} className="hover:text-red-500"><X className="w-3 h-3" /></button>
</span>
))}
<button onClick={() => setPicker('dependencies')} className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-dashed transition-all duration-200 hover:scale-105 ${isDark ? 'border-slate-600 text-slate-500 hover:border-violet-500 hover:text-violet-400' : 'border-gray-300 text-gray-400 hover:border-violet-400 hover:text-violet-500'}`}>
<Plus className="w-3 h-3" /> 添加
</button>
</div>
</LabelRow>
<LabelRow label={FIELD_LABELS.preferred_agents} isDark={isDark}>
<div className="flex flex-wrap gap-1.5 items-center">
{req.preferred_agents.map(v => (
<span key={v} className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs rounded-full border ${isDark ? 'bg-slate-800/70 text-rose-200 border-rose-500/25' : 'bg-rose-50 text-rose-700 border-rose-200'}`}>
{agentOptions.find(x => x.value === v)?.label || v}
<button onClick={() => removeRequiredItem('preferred_agents', v)} className="hover:text-red-500"><X className="w-3 h-3" /></button>
</span>
))}
<button onClick={() => setPicker('preferred_agents')} className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-dashed transition-all duration-200 hover:scale-105 ${isDark ? 'border-slate-600 text-slate-500 hover:border-rose-500 hover:text-rose-400' : 'border-gray-300 text-gray-400 hover:border-rose-400 hover:text-rose-500'}`}>
<Plus className="w-3 h-3" /> 添加
</button>
</div>
</LabelRow>
</div>
</div>
</div>
{picker === 'dependencies' && (
<MultiSelectModal
title="选择依赖字段"
selected={req.dependencies}
options={fieldOptions}
minKeep={1}
onClose={() => setPicker(null)}
onSave={v => { setField('dependencies', v); setPicker(null); }}
isDark={isDark}
/>
)}
{picker === 'preferred_agents' && (
<MultiSelectModal
title="选择推荐智能体"
selected={req.preferred_agents}
options={agentOptions}
minKeep={1}
onClose={() => setPicker(null)}
onSave={v => { setField('preferred_agents', v); setPicker(null); }}
isDark={isDark}
/>
)}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function TemplateEditor() {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [template, setTemplate] = useState<TemplateData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [addingAgent, setAddingAgent] = useState(false);
const [fixedAgents, setFixedAgents] = useState<AgentData[]>([]);
const [originalTemplate, setOriginalTemplate] = useState<TemplateData | null>(null);
useEffect(() => {
fetch('https://ch1.b.uat.4.cn/zncmweb/templates/3q_template.json')
.then(r => r.json())
.then((data: TemplateData) => { setTemplate(data); setOriginalTemplate(data); setFixedAgents(data.agents); setLoading(false); })
.catch(e => { setError(e.message); setLoading(false); });
}, []);
const update = <K extends keyof TemplateData>(key: K, value: TemplateData[K]) => {
setTemplate(prev => prev ? { ...prev, [key]: value } : null);
setDirty(true);
};
const handleSave = () => {
if (!template || !originalTemplate) return;
const issues = compareJsonShape(originalTemplate, template);
const valid = issues.length === 0;
const result = {
valid,
message: valid ? '保存校验通过:字段和类型与 https://ch1.b.uat.4.cn/zncmweb/templates/3q_template.json 一致。' : '保存校验失败:字段或类型不一致。',
issues,
data: template,
};
console.log('[TemplateEditor 保存结果]', result);
console.log('[TemplateEditor 保存 JSON]', JSON.stringify(template, null, 2));
window.alert(result.message + ' 详情已打印到控制台。');
if (valid) setDirty(false);
};
const existingFields = template ? Object.keys(template.report_schema) : [];
const fieldOptions = template ? existingFields.map(f => ({ value: f, label: mapFieldLabel(f) })) : [];
const agentOptions = template ? template.agents.map(a => ({ value: a.id, label: `${a.name || a.id}(${a.id})` })) : [];
const deletedFixedAgents = template ? fixedAgents.filter(agent => !template.agents.some(current => current.id === agent.id)) : [];
// ── Loading / Error states ─────────────────────────────────────────────────
if (loading) {
return (
<div className={`flex items-center justify-center min-h-64 ${isDark ? 'text-slate-400' : 'text-gray-400'}`}>
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
<span className="text-sm">加载模板中…</span>
</div>
);
}
if (error || !template) {
return (
<div className={`flex items-center gap-2 p-4 rounded-xl ${isDark ? 'bg-red-950/30 text-red-400 border border-red-900/50' : 'bg-red-50 text-red-600 border border-red-200'}`}>
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">模板加载失败:{error}</span>
</div>
);
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="h-[calc(100vh-24px)] max-h-[calc(100vh-24px)] flex flex-col max-w-5xl mx-auto px-1 pb-3">
<div className={`flex-shrink-0 flex items-center justify-between gap-4 py-3 border-b ${isDark ? 'border-slate-800' : 'border-gray-200'}`}>
<div>
<h1 className={`text-xl font-bold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>模板配置编辑器</h1>
<p className={`text-sm mt-0.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>三Q研判报告 · {template.template_id}</p>
</div>
<div className="flex items-center gap-3">
{dirty && (
<div className={`flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border ${isDark ? 'bg-amber-900/20 border-amber-700/40 text-amber-300' : 'bg-amber-50 border-amber-200 text-amber-600'}`}>
<Info className="w-3.5 h-3.5" />
已有未保存修改(仅本地预览)
</div>
)}
<button
onClick={handleSave}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl border shadow-sm transition-all duration-200 hover:scale-[1.02] ${isDark ? 'bg-slate-900 border-slate-700 text-slate-100 hover:border-blue-500/40' : 'bg-white border-gray-200 text-gray-800 hover:border-blue-300'}`}
>
<Save className="w-4 h-4" /> 保存
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-3 pr-1 space-y-4">
{/* ── 1. Basic Info ────────────────────────────────────────────────────── */}
<SectionWrapper title="基本信息" icon={FileText} isDark={isDark}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(['template_id', 'template_name'] as const).map(k => (
<LabelRow key={k} label={FIELD_LABELS[k]} isDark={isDark}>
<EditableText value={template[k]} onChange={v => update(k, v)} isDark={isDark} />
</LabelRow>
))}
</div>
<LabelRow label={FIELD_LABELS.template_brief} isDark={isDark}>
<EditableText value={template.template_brief} onChange={v => update('template_brief', v)} isDark={isDark} multiline />
</LabelRow>
</SectionWrapper>
{/* ── 2. Usage Notes ───────────────────────────────────────────────────── */}
<SectionWrapper title={FIELD_LABELS.usage_notes} icon={Info} isDark={isDark}>
<TagList
tags={template.usage_notes}
onChange={v => update('usage_notes', v)}
isDark={isDark}
tagLight="bg-amber-50 text-amber-700 border-amber-200"
tagDark="bg-slate-800/70 text-amber-200 border-amber-500/25"
/>
</SectionWrapper>
{/* ── 3. Report Schema ─────────────────────────────────────────────────── */}
<SectionWrapper title={FIELD_LABELS.report_schema} icon={Settings} isDark={isDark}>
<div className="space-y-3">
{Object.entries(template.report_schema).map(([key, desc]) => (
<div
key={key}
className={`group/row rounded-lg border px-4 py-3 transition-all duration-200 hover:scale-[1.005] ${isDark ? 'border-slate-700/50 bg-slate-800/30 hover:border-slate-600' : 'border-gray-200 bg-gray-50/50 hover:border-gray-300 hover:shadow-sm'}`}
>
<div className="flex items-center gap-2 mb-1.5">
<code className={`text-xs px-2 py-0.5 rounded font-mono ${isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-500'}`}>{key}</code>
<span className={`text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>{REPORT_SCHEMA_LABELS[key] || key}</span>
</div>
<EditableText
value={desc}
onChange={v => update('report_schema', { ...template.report_schema, [key]: v })}
isDark={isDark}
multiline
/>
</div>
))}
</div>
</SectionWrapper>
{/* ── 4. Field Requirements ────────────────────────────────────────────── */}
<SectionWrapper title={FIELD_LABELS.field_requirements} icon={Settings} isDark={isDark} defaultOpen={false}>
<div className="space-y-2">
{Object.entries(template.field_requirements).map(([key, req]) => (
<FieldRequirementCard
key={key}
fieldKey={key}
req={req}
onChange={updated => update('field_requirements', { ...template.field_requirements, [key]: updated })}
fieldOptions={fieldOptions}
agentOptions={agentOptions}
isDark={isDark}
/>
))}
</div>
</SectionWrapper>
{/* ── 5. Agents ────────────────────────────────────────────────────────── */}
<SectionWrapper title={FIELD_LABELS.agents} icon={Brain} isDark={isDark}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{template.agents.map((agent, i) => (
<AgentCard
key={agent.id + i}
agent={agent}
existingFields={existingFields}
onUpdate={updated => {
const next = [...template.agents];
next[i] = updated;
update('agents', next);
}}
onDelete={() => {
if (template.agents.length <= 1) {
window.alert('智能体最少必须保留 1 个');
return;
}
update('agents', template.agents.filter((_, j) => j !== i));
}}
isDark={isDark}
/>
))}
{deletedFixedAgents.length > 0 && (
<button
onClick={() => setAddingAgent(true)}
className={`rounded-xl border-2 border-dashed p-4 flex flex-col items-center justify-center gap-2 min-h-[120px] transition-all duration-200 hover:scale-[1.02] ${isDark ? 'border-slate-700 text-slate-500 hover:border-blue-600/60 hover:text-blue-400 hover:bg-blue-950/10' : 'border-gray-200 text-gray-400 hover:border-blue-300 hover:text-blue-500 hover:bg-blue-50/50'}`}
>
<Plus className="w-6 h-6" />
<span className="text-sm font-medium">添加智能体</span>
<span className="text-xs opacity-70">恢复已删除智能体</span>
</button>
)}
</div>
</SectionWrapper>
{/* ── 6. Coordinator Config ─────────────────────────────────────────────── */}
<SectionWrapper title={FIELD_LABELS.coordinator_config} icon={Settings} isDark={isDark} defaultOpen={false}>
<div className="space-y-4">
{(Object.entries(template.coordinator_config) as [keyof CoordinatorConfig, unknown][]).map(([key, value]) => (
<LabelRow key={key} label={FIELD_LABELS[key] || key} isDark={isDark}>
{Array.isArray(value) ? (
key === 'dependency_driving_rules' ? (
<CoordinatorRuleList
rules={value as string[]}
fields={existingFields}
onChange={v => update('coordinator_config', { ...template.coordinator_config, [key]: v })}
isDark={isDark}
/>
) : (
<TagList
tags={value as string[]}
onChange={v => update('coordinator_config', { ...template.coordinator_config, [key]: v })}
isDark={isDark}
tagLight="bg-teal-50 text-teal-700 border-teal-200"
tagDark="bg-slate-800/70 text-teal-200 border-teal-500/25"
/>
)
) : (
<CoordinatorFieldText
value={value as string}
fields={existingFields}
onChange={v => update('coordinator_config', { ...template.coordinator_config, [key]: v })}
isDark={isDark}
/>
)}
</LabelRow>
))}
</div>
</SectionWrapper>
{/* ── 7. Report Examples ───────────────────────────────────────────────── */}
<SectionWrapper title={FIELD_LABELS.report_examples} icon={FileText} isDark={isDark} defaultOpen={false}>
<div className="space-y-3">
{Object.entries(template.report_examples).map(([key, example]) => (
<div
key={key}
className={`rounded-lg border overflow-hidden ${isDark ? 'border-slate-700/50' : 'border-gray-200'}`}
>
<div className={`flex items-center gap-2 px-4 py-2 border-b ${isDark ? 'border-slate-700/50 bg-slate-800/50' : 'border-gray-200 bg-gray-50'}`}>
<code className={`text-xs px-2 py-0.5 rounded font-mono ${isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-500'}`}>{key}</code>
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{REPORT_SCHEMA_LABELS[key] || key}</span>
</div>
<div className="px-4 py-3">
<EditableMarkdown
value={example}
onChange={v => update('report_examples', { ...template.report_examples, [key]: v })}
isDark={isDark}
/>
</div>
</div>
))}
</div>
</SectionWrapper>
{/* Restore Agent Modal */}
</div>
{addingAgent && (
<RestoreAgentModal
agents={deletedFixedAgents}
onRestore={agent => {
update('agents', [...template.agents, agent]);
setAddingAgent(false);
}}
onClose={() => setAddingAgent(false)}
isDark={isDark}
/>
)}
</div>
);
}