One-time alignment to close drift between the running container (qihu-aiflow / image qihuaiflow:with-deps) and this Git repository. Container hotfixes previously persisted only via `docker commit` are now captured in Git so Git becomes the single source of truth. Backend: - chat_service.py: accumulated fixes incl. EigenFlux README guidance - chat_context.py / chat_media.py: URL/web-repo context injection - api/url_reader.py: NEW SSRF-safe web/README reader (Issue #725) - skills_admin.py & Skills.tsx: 14-scene capability unification - pgvector_search.py, orchestrator.py, platform_knowledge.py, market.py, admin/models.py: drift sync Frontend: - ChatPage.tsx: collapsible AI think/act block (Issue #724) - MarketPage / EvolutionDashboard / TokenDashboard styling sync Migrations: - 018_normalize_marketapp_scene.sql: fixed capability allowlist (prevents capability wipe on restart) Alignment performed 2026-07-23 05:42:47. No deletions; secrets/venv/node_modules/ caches were not touched (tracked-source-only copy).
683 lines
29 KiB
TypeScript
683 lines
29 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { adminAPI } from '@/api';
|
||
import type { SkillState } from '@/types';
|
||
|
||
type AdminSkill = {
|
||
id: string;
|
||
user_id: string;
|
||
owner_username?: string;
|
||
skill_name: string;
|
||
description?: string;
|
||
tags: string[];
|
||
category?: string;
|
||
category_label?: string;
|
||
state: SkillState;
|
||
use_count: number;
|
||
version: number;
|
||
skill_md_path?: string;
|
||
last_used_at?: string;
|
||
created_at?: string;
|
||
updated_at?: string;
|
||
skill_md_content?: string;
|
||
publish_records?: PublishRecord[];
|
||
};
|
||
|
||
interface PublishRecord {
|
||
id: string;
|
||
price: number;
|
||
status: string;
|
||
published_at?: string;
|
||
}
|
||
|
||
interface SkillVersion {
|
||
id: string;
|
||
version: number;
|
||
description?: string;
|
||
skill_md_content?: string;
|
||
created_at?: string;
|
||
}
|
||
|
||
interface SkillsStats {
|
||
total_skills: number;
|
||
by_state: Record<string, number>;
|
||
by_category: Record<string, number>;
|
||
total_published: number;
|
||
total_versions: number;
|
||
}
|
||
|
||
const STATE_CONFIG: Record<SkillState, { label: string; color: string; bg: string }> = {
|
||
baby: { label: '萌芽期', color: '#6B7280', bg: '#F3F4F6' },
|
||
main: { label: '主力期', color: '#10B981', bg: '#D1FAE5' },
|
||
staging: { label: '灰度期', color: '#F59E0B', bg: '#FEF3C7' },
|
||
frozen: { label: '已冻结', color: '#EF4444', bg: '#FEE2E2' },
|
||
};
|
||
|
||
// 业务场景分类:与前端「应用市场」保持一致的 14 类标准(前台分类为准)
|
||
const CATEGORIES = [
|
||
{ id: 'design', label: '🎨 做设计' },
|
||
{ id: 'frontend', label: '💻 写前端' },
|
||
{ id: 'mobile', label: '📱 做移动端' },
|
||
{ id: 'backend', label: '🧪 后端/全栈' },
|
||
{ id: 'qa', label: '🧪 测试质量' },
|
||
{ id: 'search', label: '🔍 搜索信息' },
|
||
{ id: 'docs', label: '✍️ 写文档' },
|
||
{ id: 'marketing', label: '📢 推广获客' },
|
||
{ id: 'workflow', label: '🔄 工作流' },
|
||
{ id: 'secops', label: '🛡️ 安全运维' },
|
||
{ id: 'learning', label: '🎓 学习教学' },
|
||
{ id: 'video', label: '🎬 视频多媒体' },
|
||
{ id: 'collab', label: '🏢 办公协作' },
|
||
{ id: 'ai', label: '🛠️ AI能力' },
|
||
];
|
||
|
||
const PAGE_SIZE = 15;
|
||
|
||
const Skills: React.FC = () => {
|
||
const [skills, setSkills] = useState<AdminSkill[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [loading, setLoading] = useState(false);
|
||
const [stats, setStats] = useState<any>(null);
|
||
|
||
// Filters
|
||
const [keyword, setKeyword] = useState('');
|
||
const [filterState, setFilterState] = useState<string>('');
|
||
const [filterCategory, setFilterCategory] = useState<string>('');
|
||
|
||
// Create/Edit Modal
|
||
const [showModal, setShowModal] = useState(false);
|
||
const [editingSkill, setEditingSkill] = useState<AdminSkill | null>(null);
|
||
const [form, setForm] = useState({
|
||
skill_name: '',
|
||
user_id: '',
|
||
description: '',
|
||
tags: '',
|
||
category: '',
|
||
skill_md_content: '## 技能定义\n\n### 触发词\n- /skill\n\n### 能力说明\n请描述这个技能的核心能力...\n\n### 使用场景\n1. 场景一\n2. 场景二\n\n### 示例对话\n**用户**: \n**AI**: ',
|
||
});
|
||
|
||
// Version Drawer
|
||
const [showVersions, setShowVersions] = useState(false);
|
||
const [versions, setVersions] = useState<any[]>([]);
|
||
const [versionSkill, setVersionSkill] = useState<AdminSkill | null>(null);
|
||
const [loadingVersions, setLoadingVersions] = useState(false);
|
||
|
||
// Users list (for owner selection)
|
||
const [users, setUsers] = useState<{id: string; username: string}[]>([]);
|
||
|
||
// Confirm delete
|
||
const [deleteTarget, setDeleteTarget] = useState<AdminSkill | null>(null);
|
||
|
||
// Toast
|
||
const [toast, setToast] = useState<{msg: string; type: 'success' | 'error'} | null>(null);
|
||
|
||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||
setToast({ msg, type });
|
||
setTimeout(() => setToast(null), 3000);
|
||
};
|
||
|
||
// Load skills list
|
||
const loadSkills = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params: any = { page, page_size: PAGE_SIZE };
|
||
if (keyword) params.keyword = keyword;
|
||
if (filterState) params.state = filterState;
|
||
if (filterCategory) params.category = filterCategory;
|
||
|
||
const result = await adminAPI.getAdminSkills(params) as any;
|
||
const data = result.data || result;
|
||
setSkills(data.items || []);
|
||
setTotal(data.total || 0);
|
||
} catch (e: any) {
|
||
showToast(`加载失败: ${e.message || e}`, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// Load stats
|
||
const loadStats = async () => {
|
||
try {
|
||
const result = await adminAPI.getAdminSkillsStats() as any;
|
||
const data = result.data || result;
|
||
setStats(data);
|
||
} catch {}
|
||
};
|
||
|
||
// Load users for owner selection
|
||
const loadUsers = async () => {
|
||
try {
|
||
const result = await adminAPI.getUsers({ page_size: 100 }) as any;
|
||
const data = result.data || result;
|
||
setUsers((data.items || data || []).slice(0, 20));
|
||
} catch {}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadSkills();
|
||
}, [page, keyword, filterState, filterCategory]);
|
||
|
||
useEffect(() => {
|
||
loadStats();
|
||
loadUsers();
|
||
}, []);
|
||
|
||
// Search
|
||
const handleSearch = () => {
|
||
setPage(1);
|
||
loadSkills();
|
||
};
|
||
|
||
// Open create modal
|
||
const openCreate = () => {
|
||
setEditingSkill(null);
|
||
setForm({
|
||
skill_name: '',
|
||
user_id: users[0]?.id || '',
|
||
description: '',
|
||
tags: '',
|
||
category: '',
|
||
skill_md_content: '## 技能定义\n\n### 触发词\n- /skill\n\n### 能力说明\n请描述这个技能的核心能力...\n\n### 使用场景\n1. 场景一\n2. 场景二\n\n### 示例对话\n**用户**: \n**AI**: ',
|
||
});
|
||
setShowModal(true);
|
||
};
|
||
|
||
// Open edit modal
|
||
const openEdit = (skill: AdminSkill) => {
|
||
setEditingSkill(skill);
|
||
setForm({
|
||
skill_name: skill.skill_name,
|
||
user_id: skill.user_id,
|
||
description: skill.description || '',
|
||
tags: (skill.tags || []).join(', '),
|
||
category: skill.category || '',
|
||
skill_md_content: skill.skill_md_content || '',
|
||
});
|
||
setShowModal(true);
|
||
};
|
||
|
||
// Save skill
|
||
const handleSave = async () => {
|
||
if (!form.skill_name.trim()) {
|
||
showToast('技能名称不能为空', 'error');
|
||
return;
|
||
}
|
||
if (!form.user_id) {
|
||
showToast('请选择所属用户', 'error');
|
||
return;
|
||
}
|
||
|
||
const tags = form.tags.split(',').map(t => t.trim()).filter(Boolean);
|
||
const payload: any = {
|
||
skill_name: form.skill_name,
|
||
user_id: form.user_id,
|
||
description: form.description,
|
||
tags,
|
||
category: form.category || undefined,
|
||
skill_md_content: form.skill_md_content,
|
||
};
|
||
|
||
try {
|
||
if (editingSkill) {
|
||
await adminAPI.updateAdminSkill(editingSkill.id, payload);
|
||
showToast('技能已更新');
|
||
} else {
|
||
await adminAPI.createAdminSkill(payload);
|
||
showToast('技能已创建');
|
||
}
|
||
setShowModal(false);
|
||
loadSkills();
|
||
loadStats();
|
||
} catch (e: any) {
|
||
showToast(`保存失败: ${e.message || e}`, 'error');
|
||
}
|
||
};
|
||
|
||
// Delete skill
|
||
const handleDelete = async () => {
|
||
if (!deleteTarget) return;
|
||
try {
|
||
await adminAPI.deleteAdminSkill(deleteTarget.id);
|
||
showToast('技能已删除');
|
||
setDeleteTarget(null);
|
||
loadSkills();
|
||
loadStats();
|
||
} catch (e: any) {
|
||
showToast(`删除失败: ${e.message || e}`, 'error');
|
||
}
|
||
};
|
||
|
||
// Change state
|
||
const handleChangeState = async (skill: AdminSkill, newState: string) => {
|
||
try {
|
||
await adminAPI.changeAdminSkillState(skill.id, { state: newState });
|
||
showToast(`状态已变更为 ${STATE_CONFIG[newState as SkillState].label}`);
|
||
loadSkills();
|
||
loadStats();
|
||
} catch (e: any) {
|
||
showToast(`状态修改失败: ${e.message || e}`, 'error');
|
||
}
|
||
};
|
||
|
||
// Load version history
|
||
const handleShowVersions = async (skill: AdminSkill) => {
|
||
setVersionSkill(skill);
|
||
setShowVersions(true);
|
||
setLoadingVersions(true);
|
||
try {
|
||
const result = await adminAPI.getAdminSkillVersions(skill.id) as any;
|
||
const data = result.data || result;
|
||
setVersions(data.versions || []);
|
||
} catch (e: any) {
|
||
showToast(`加载版本历史失败: ${e.message || e}`, 'error');
|
||
setVersions([]);
|
||
} finally {
|
||
setLoadingVersions(false);
|
||
}
|
||
};
|
||
|
||
// Rollback version
|
||
const handleRollback = async (skill: AdminSkill, targetVersion: number) => {
|
||
if (!confirm(`确定回滚到版本 ${targetVersion} 吗?`)) return;
|
||
try {
|
||
await adminAPI.rollbackAdminSkill(skill.id, { version: targetVersion });
|
||
showToast(`已回滚到版本 ${targetVersion}`);
|
||
setShowVersions(false);
|
||
loadSkills();
|
||
} catch (e: any) {
|
||
showToast(`回滚失败: ${e.message || e}`, 'error');
|
||
}
|
||
};
|
||
|
||
// Publish/Unpublish
|
||
const handlePublish = async (skill: AdminSkill) => {
|
||
const price = parseFloat(prompt('设置定价(元,0=免费):', '0') || '0');
|
||
if (isNaN(price) || price < 0) return;
|
||
try {
|
||
await adminAPI.publishAdminSkill(skill.id, { price, market_description: skill.description || '' });
|
||
showToast('已发布到应用市场');
|
||
loadSkills();
|
||
} catch (e: any) {
|
||
showToast(`发布失败: ${e.message || e}`, 'error');
|
||
}
|
||
};
|
||
|
||
const handleUnpublish = async (skill: AdminSkill) => {
|
||
if (!confirm('确定从市场下架吗?')) return;
|
||
try {
|
||
await adminAPI.unpublishAdminSkill(skill.id);
|
||
showToast('已从市场下架');
|
||
loadSkills();
|
||
} catch (e: any) {
|
||
showToast(`下架失败: ${e.message || e}`, 'error');
|
||
}
|
||
};
|
||
|
||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||
const isPublished = (skill: AdminSkill) =>
|
||
(skill.publish_records || []).some(r => r.status === 'published');
|
||
|
||
return (
|
||
<div className="admin-page">
|
||
{/* Toast */}
|
||
{toast && (
|
||
<div className={`toast ${toast.type}`} style={{
|
||
position: 'fixed', top: 20, right: 20, zIndex: 9999,
|
||
padding: '12px 20px', borderRadius: 8,
|
||
background: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||
color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||
}}>
|
||
{toast.msg}
|
||
</div>
|
||
)}
|
||
|
||
{/* Header */}
|
||
<div className="admin-page-header">
|
||
<div>
|
||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>Skills技能管理</h2>
|
||
<p style={{ margin: '4px 0 0', color: '#6B7280', fontSize: 13 }}>
|
||
管理全平台 AI 技能,支持版本历史与市场发布
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats Cards */}
|
||
{stats && (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||
<div className="stat-card">
|
||
<div className="stat-value">{stats.total_skills}</div>
|
||
<div className="stat-label">技能总数</div>
|
||
</div>
|
||
{Object.entries(STATE_CONFIG).map(([state, cfg]) => (
|
||
<div key={state} className="stat-card" style={{ borderLeft: `3px solid ${cfg.color}` }}>
|
||
<div className="stat-value">{stats.by_state[state] || 0}</div>
|
||
<div className="stat-label">{cfg.label}</div>
|
||
</div>
|
||
))}
|
||
<div className="stat-card" style={{ borderLeft: '3px solid #8B5CF6' }}>
|
||
<div className="stat-value">{stats.total_published}</div>
|
||
<div className="stat-label">已发布市场</div>
|
||
</div>
|
||
<div className="stat-card" style={{ borderLeft: '3px solid #3B82F6' }}>
|
||
<div className="stat-value">{stats.total_versions}</div>
|
||
<div className="stat-label">版本记录</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filters */}
|
||
<div className="admin-filters" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
|
||
<input
|
||
className="admin-form-input"
|
||
placeholder="搜索技能名称或描述..."
|
||
value={keyword}
|
||
onChange={e => setKeyword(e.target.value)}
|
||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||
style={{ maxWidth: 280 }}
|
||
/>
|
||
<select className="admin-form-input" value={filterState} onChange={e => { setFilterState(e.target.value); setPage(1); }} style={{ maxWidth: 140 }}>
|
||
<option value="">全部状态</option>
|
||
{Object.entries(STATE_CONFIG).map(([k, v]) => (
|
||
<option key={k} value={k}>{v.label}</option>
|
||
))}
|
||
</select>
|
||
<select className="admin-form-input" value={filterCategory} onChange={e => { setFilterCategory(e.target.value); setPage(1); }} style={{ maxWidth: 160 }}>
|
||
<option value="">全部分类</option>
|
||
{CATEGORIES.map(c => (
|
||
<option key={c.id} value={c.id}>{c.label}</option>
|
||
))}
|
||
</select>
|
||
<button className="admin-btn" onClick={handleSearch}>搜索</button>
|
||
{(keyword || filterState || filterCategory) && (
|
||
<button className="admin-btn" onClick={() => { setKeyword(''); setFilterState(''); setFilterCategory(''); setPage(1); }}>
|
||
清空筛选
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<div className="admin-table-wrap">
|
||
<table className="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>技能名称</th>
|
||
<th>状态</th>
|
||
<th>分类</th>
|
||
<th>标签</th>
|
||
<th>所有者</th>
|
||
<th>版本</th>
|
||
<th>使用次数</th>
|
||
<th>市场</th>
|
||
<th>更新时间</th>
|
||
<th style={{ width: 280 }}>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? (
|
||
<tr><td colSpan={10} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr>
|
||
) : skills.length === 0 ? (
|
||
<tr><td colSpan={10} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无技能</td></tr>
|
||
) : skills.map(skill => {
|
||
const cfg = STATE_CONFIG[skill.state as SkillState] || STATE_CONFIG.baby;
|
||
const published = isPublished(skill);
|
||
return (
|
||
<tr key={skill.id}>
|
||
<td>
|
||
<div style={{ fontWeight: 500 }}>{skill.skill_name}</div>
|
||
{skill.description && (
|
||
<div style={{ fontSize: 12, color: '#6B7280', marginTop: 2, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{skill.description}
|
||
</div>
|
||
)}
|
||
{skill.tags && skill.tags.length > 0 && (
|
||
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
|
||
{skill.tags.slice(0, 3).map(tag => (
|
||
<span key={tag} style={{ fontSize: 11, padding: '1px 6px', background: '#EEF2FF', color: '#6366F1', borderRadius: 4 }}>{tag}</span>
|
||
))}
|
||
{skill.tags.length > 3 && <span style={{ fontSize: 11, color: '#6B7280' }}>+{skill.tags.length - 3}</span>}
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<span style={{
|
||
display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 12,
|
||
background: cfg.bg, color: cfg.color, fontWeight: 500
|
||
}}>
|
||
{cfg.label}
|
||
</span>
|
||
</td>
|
||
<td>{skill.category_label || '-'}</td>
|
||
<td>
|
||
{skill.tags && skill.tags.length > 0 ? (
|
||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||
{skill.tags.slice(0, 3).map(tag => (
|
||
<span key={tag} style={{ fontSize: 11, padding: '1px 6px', background: '#EEF2FF', color: '#6366F1', borderRadius: 4 }}>{tag}</span>
|
||
))}
|
||
{skill.tags.length > 3 && <span style={{ fontSize: 11, color: '#6B7280' }}>+{skill.tags.length - 3}</span>}
|
||
</div>
|
||
) : <span style={{ color: '#9CA3AF', fontSize: 12 }}>-</span>}
|
||
</td>
|
||
<td>{skill.owner_username || skill.user_id}</td>
|
||
<td>v{skill.version}</td>
|
||
<td>{skill.use_count}</td>
|
||
<td>
|
||
{published ? (
|
||
<span style={{ color: '#10B981', fontSize: 13 }}>✓ 已发布</span>
|
||
) : (
|
||
<span style={{ color: '#9CA3AF', fontSize: 13 }}>未发布</span>
|
||
)}
|
||
</td>
|
||
<td style={{ fontSize: 12, color: '#6B7280' }}>
|
||
{skill.updated_at ? skill.updated_at.slice(0, 16).replace('T', ' ') : '-'}
|
||
</td>
|
||
<td>
|
||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||
<button className="admin-btn small" onClick={() => openEdit(skill)}>编辑</button>
|
||
<button className="admin-btn small" onClick={() => handleShowVersions(skill)}>版本</button>
|
||
{published ? (
|
||
<button className="admin-btn small warning" onClick={() => handleUnpublish(skill)}>下架</button>
|
||
) : (
|
||
<button className="admin-btn small success" onClick={() => handlePublish(skill)}>发布</button>
|
||
)}
|
||
{/* State change */}
|
||
<select
|
||
className="admin-form-input small"
|
||
value={skill.state}
|
||
onChange={e => handleChangeState(skill, e.target.value)}
|
||
style={{ padding: '2px 4px', fontSize: 12, maxWidth: 90 }}
|
||
>
|
||
{Object.entries(STATE_CONFIG).map(([k, v]) => (
|
||
<option key={k} value={k}>{v.label}</option>
|
||
))}
|
||
</select>
|
||
<button className="admin-btn small danger" onClick={() => setDeleteTarget(skill)}>删除</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="admin-pagination">
|
||
<span style={{ color: '#6B7280', fontSize: 13 }}>
|
||
共 {total} 条,第 {page}/{totalPages} 页
|
||
</span>
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<button className="admin-btn small" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||
const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i;
|
||
return p <= totalPages && (
|
||
<button key={p} className={`admin-btn small ${p === page ? 'primary' : ''}`}
|
||
onClick={() => setPage(p)}>{p}</button>
|
||
);
|
||
})}
|
||
<button className="admin-btn small" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create/Edit Modal */}
|
||
{showModal && (
|
||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||
<div className="modal-content" style={{ maxWidth: 800 }} onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h3>{editingSkill ? '编辑技能' : '创建技能'}</h3>
|
||
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
|
||
</div>
|
||
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||
<div className="form-group">
|
||
<label className="form-label">技能名称 *</label>
|
||
<input className="admin-form-input" value={form.skill_name}
|
||
onChange={e => setForm(f => ({ ...f, skill_name: e.target.value }))}
|
||
placeholder="例如: 代码审查助手" />
|
||
</div>
|
||
{!editingSkill && (
|
||
<div className="form-group">
|
||
<label className="form-label">所属用户 *</label>
|
||
<select className="admin-form-input" value={form.user_id}
|
||
onChange={e => setForm(f => ({ ...f, user_id: e.target.value }))}>
|
||
{users.map(u => (
|
||
<option key={u.id} value={u.id}>{u.username}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div className="form-group">
|
||
<label className="form-label">分类</label>
|
||
<select className="admin-form-input" value={form.category}
|
||
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}>
|
||
<option value="">选择分类</option>
|
||
{CATEGORIES.map(c => (
|
||
<option key={c.id} value={c.id}>{c.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||
<label className="form-label">标签(逗号分隔)</label>
|
||
<input className="admin-form-input" value={form.tags}
|
||
onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||
placeholder="例如: 代码, 审查, 自动化" />
|
||
</div>
|
||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||
<label className="form-label">描述</label>
|
||
<textarea className="admin-form-input" value={form.description} rows={3}
|
||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||
placeholder="描述技能的功能和使用场景..." />
|
||
</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">SKILL.md 内容(技能定义)</label>
|
||
<textarea
|
||
className="admin-form-input"
|
||
value={form.skill_md_content}
|
||
onChange={e => setForm(f => ({ ...f, skill_md_content: e.target.value }))}
|
||
style={{ fontFamily: 'monospace', fontSize: 13, minHeight: 400, lineHeight: 1.6 }}
|
||
placeholder="## 技能定义..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
|
||
<button className="admin-btn primary" onClick={handleSave}>
|
||
{editingSkill ? '保存更改' : '创建技能'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Version History Drawer */}
|
||
{showVersions && versionSkill && (
|
||
<div className="modal-overlay" onClick={() => setShowVersions(false)}>
|
||
<div className="modal-content" style={{ maxWidth: 700 }} onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h3>版本历史 - {versionSkill.skill_name}</h3>
|
||
<button className="modal-close" onClick={() => setShowVersions(false)}>×</button>
|
||
</div>
|
||
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||
{loadingVersions ? (
|
||
<div style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</div>
|
||
) : versions.length === 0 ? (
|
||
<div style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无版本记录</div>
|
||
) : (
|
||
<div>
|
||
{versions.map((v, idx) => (
|
||
<div key={v.id} style={{
|
||
padding: 16, marginBottom: 12, border: `1px solid ${idx === 0 ? '#10B981' : '#E5E7EB'}`,
|
||
borderRadius: 8, background: idx === 0 ? '#F0FDF4' : '#fff'
|
||
}}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||
<div>
|
||
<span style={{ fontWeight: 600, fontSize: 15 }}>版本 {v.version}</span>
|
||
{idx === 0 && <span style={{ marginLeft: 8, fontSize: 12, color: '#10B981', background: '#D1FAE5', padding: '1px 8px', borderRadius: 12 }}>当前</span>}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<span style={{ fontSize: 12, color: '#6B7280' }}>{v.created_at?.slice(0, 16).replace('T', ' ')}</span>
|
||
{idx > 0 && (
|
||
<button className="admin-btn small" onClick={() => handleRollback(versionSkill, v.version)}>回滚至此</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{v.description && <div style={{ fontSize: 13, color: '#6B7280', marginBottom: 8 }}>{v.description}</div>}
|
||
{v.skill_md_content && (
|
||
<details>
|
||
<summary style={{ fontSize: 13, color: '#6366F1', cursor: 'pointer' }}>查看 SKILL.md 内容</summary>
|
||
<pre style={{ fontSize: 12, background: '#F9FAFB', padding: 12, borderRadius: 6, marginTop: 8, maxHeight: 300, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||
{v.skill_md_content}
|
||
</pre>
|
||
</details>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Delete Confirm */}
|
||
{deleteTarget && (
|
||
<div className="modal-overlay" onClick={() => setDeleteTarget(null)}>
|
||
<div className="modal-content" style={{ maxWidth: 400 }} onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h3>确认删除</h3>
|
||
</div>
|
||
<div className="modal-body">
|
||
<p>确定要删除技能 <strong>{deleteTarget.skill_name}</strong> 吗?</p>
|
||
<p style={{ fontSize: 13, color: '#EF4444' }}>此操作不可恢复,相关版本历史也将被删除。</p>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="admin-btn" onClick={() => setDeleteTarget(null)}>取消</button>
|
||
<button className="admin-btn danger" onClick={handleDelete}>确认删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<style>{`
|
||
.stat-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||
.stat-value { font-size: 24px; font-weight: 700; color: #1F2937; }
|
||
.stat-label { font-size: 13px; color: #6B7280; margin-top: 4px; }
|
||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
||
.modal-content { background: #fff; border-radius: 12px; width: 90%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
|
||
.modal-header { padding: 20px 24px; border-bottom: 1px solid #E5E7EB; display: flex; justify-content: space-between; align-items: center; }
|
||
.modal-header h3 { margin: 0; font-size: 18px; }
|
||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6B7280; }
|
||
.modal-body { padding: 24px; overflow-y: auto; }
|
||
.modal-footer { padding: 16px 24px; border-top: 1px solid #E5E7EB; display: flex; justify-content: flex-end; gap: 12px; }
|
||
.form-group { margin-bottom: 16px; }
|
||
.form-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
|
||
`}</style>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Skills;
|