1521 lines
68 KiB
TypeScript
1521 lines
68 KiB
TypeScript
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>
|
||
);
|
||
}
|