Files
qihuaiflow/frontend/src/pages/admin/Skills.tsx
T
QihuAI Design 771c8456b8 chore: normalize line endings to LF via .gitattributes
Root-cause fix for mixed CRLF/LF in the repo (no .gitattributes previously).
Adds '* text=auto eol=lf' and explicit binary rules so all future checkouts
are LF. Renormalizes all tracked text files (no logical content change).
Backup tags: image qihuaiflow:backup-lineend-20260725-121632, git backup-lineend-pre-20260725-121632.
2026-07-25 12:16:39 +08:00

683 lines
28 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 } 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;