feat: 完善开发台功能 - 新增总览页、技能筛选分页、版本管理操作、基本信息独立编辑
- 新增 DevOverviewPage 开发者总览页(指标卡片、待审核项目、最近动态) - 新增 UpdateSkillInfoPage 基本信息编辑页(与版本上传分离) - MySkillsPage 增加关键词/分类/状态筛选和分页 - SkillEditorPage 基本信息只读、增加上架/下架/删除操作、版本拒绝原因展示 - NewVersionPage 简化为仅版本说明和技能包上传 - UploadSkillPage 增加动态分类、图标选择器、移除模型兼容性 - 数据层新增 icon/rejectionReason 字段和 developerOverview 总览数据 - DeveloperPage 侧边栏新增总览导航入口 - 同步更新 openspec 规格文档和 README
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { FiPlus, FiTerminal } from 'react-icons/fi';
|
||||
import { FiPlus, FiTerminal, FiHome } from 'react-icons/fi';
|
||||
import { FaPuzzlePiece } from 'react-icons/fa';
|
||||
import Layout from '../components/Layout.jsx';
|
||||
import SidebarBrand from '../components/layout/SidebarBrand.jsx';
|
||||
@@ -10,12 +10,14 @@ import usePageState from '../hooks/usePageState.js';
|
||||
import { DEVELOPER_PAGES } from '../constants/pages.js';
|
||||
import { DEVELOPER_KEYS } from '../constants/storageKeys.js';
|
||||
import api from '../services/api.js';
|
||||
import DevOverviewPage from './developer/DevOverviewPage.jsx';
|
||||
import MySkillsPage from './developer/MySkillsPage.jsx';
|
||||
import UploadSkillPage from './developer/UploadSkillPage.jsx';
|
||||
import NewVersionPage from './developer/NewVersionPage.jsx';
|
||||
import DevDocsPage from './developer/DevDocsPage.jsx';
|
||||
import DevAccountPage from './developer/DevAccountPage.jsx';
|
||||
import SkillEditorPage from './developer/SkillEditorPage.jsx';
|
||||
import UpdateSkillInfoPage from './developer/UpdateSkillInfoPage.jsx';
|
||||
|
||||
function DeveloperPage() {
|
||||
const location = useLocation();
|
||||
@@ -24,7 +26,7 @@ function DeveloperPage() {
|
||||
// 使用 usePageState 管理页面状态
|
||||
const { currentPage, setCurrentPage } = usePageState({
|
||||
storageKey: DEVELOPER_KEYS.CURRENT_PAGE,
|
||||
defaultPage: 'mySkills',
|
||||
defaultPage: 'overview',
|
||||
pageTitles: DEVELOPER_PAGES,
|
||||
});
|
||||
|
||||
@@ -37,7 +39,7 @@ function DeveloperPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.fromHome) {
|
||||
setCurrentPage('mySkills');
|
||||
setCurrentPage('overview');
|
||||
setCurrentSkillId(null);
|
||||
navigate('.', { replace: true, state: {} });
|
||||
}
|
||||
@@ -68,30 +70,47 @@ function DeveloperPage() {
|
||||
setCurrentPage('newVersion');
|
||||
};
|
||||
|
||||
const openUpdateInfoPage = (skillId) => {
|
||||
setCurrentSkillId(skillId);
|
||||
setCurrentPage('updateInfo');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentPage('mySkills');
|
||||
setCurrentSkillId(null);
|
||||
};
|
||||
|
||||
const handleNewVersionBack = () => {
|
||||
const handleEditorBack = () => {
|
||||
setCurrentPage('skillEditor');
|
||||
setNewVersionSkillName('');
|
||||
};
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'overview':
|
||||
return <DevOverviewPage onSkillClick={openSkillEditor} />;
|
||||
case 'mySkills':
|
||||
return <MySkillsPage onSkillClick={openSkillEditor} />;
|
||||
case 'uploadSkill':
|
||||
return <UploadSkillPage />;
|
||||
return <UploadSkillPage onBack={() => switchPage('mySkills')} />;
|
||||
case 'devDocs':
|
||||
return <DevDocsPage />;
|
||||
case 'devAccount':
|
||||
return <DevAccountPage />;
|
||||
case 'skillEditor':
|
||||
return <SkillEditorPage skillId={currentSkillId} onBack={handleBack} onUploadNewVersion={openNewVersionPage} />;
|
||||
return <SkillEditorPage
|
||||
skillId={currentSkillId}
|
||||
onBack={handleBack}
|
||||
onUploadNewVersion={openNewVersionPage}
|
||||
onUpdateInfo={openUpdateInfoPage}
|
||||
/>;
|
||||
case 'newVersion':
|
||||
return <NewVersionPage skillName={newVersionSkillName} onBack={handleNewVersionBack} />;
|
||||
return <NewVersionPage skillName={newVersionSkillName} onBack={handleEditorBack} />;
|
||||
case 'updateInfo':
|
||||
return <UpdateSkillInfoPage
|
||||
skill={api.developer.getSkillById(currentSkillId)}
|
||||
onBack={handleEditorBack}
|
||||
/>;
|
||||
default:
|
||||
return <div>Page not found</div>;
|
||||
}
|
||||
@@ -125,6 +144,12 @@ function DeveloperPage() {
|
||||
))}
|
||||
</div>
|
||||
<div className="chat-sidebar-nav">
|
||||
<SidebarNavItem
|
||||
icon={<FiHome />}
|
||||
label="总览"
|
||||
active={currentPage === 'overview'}
|
||||
onClick={() => switchPage('overview')}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon={<FaPuzzlePiece />}
|
||||
label="我的技能"
|
||||
@@ -153,4 +178,4 @@ function DeveloperPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default DeveloperPage;
|
||||
export default DeveloperPage;
|
||||
|
||||
91
src/pages/developer/DevOverviewPage.jsx
Normal file
91
src/pages/developer/DevOverviewPage.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { FiAlertTriangle, FiInfo } from 'react-icons/fi';
|
||||
import { api } from '../../services/api.js';
|
||||
|
||||
function DevOverviewPage({ onSkillClick }) {
|
||||
const data = api.developer.getOverview();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-title">我的技能</div>
|
||||
<div className="stat-value">{data.totalSkills}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-title">已发布</div>
|
||||
<div className="stat-value">{data.publishedCount}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-title">草稿</div>
|
||||
<div className="stat-value">{data.draftCount}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-title">待审核</div>
|
||||
<div className="stat-value">{data.pendingReview}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-bottom-row">
|
||||
<div className="card overview-anomalies">
|
||||
<div className="card-header">
|
||||
<div className="card-title">待审核项目</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{data.pendingItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`anomaly-item ${item.status === 'rejected' ? 'anomaly-warning' : 'anomaly-info'}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onSkillClick && onSkillClick(item.skillId)}
|
||||
>
|
||||
<span className="anomaly-icon">
|
||||
{item.status === 'rejected' ? <FiAlertTriangle /> : <FiInfo />}
|
||||
</span>
|
||||
<span className="anomaly-text">
|
||||
{item.skillName} {item.version}
|
||||
<span style={{ fontSize: '12px', marginLeft: '8px', opacity: 0.7 }}>{item.date}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card overview-recent-logs">
|
||||
<div className="card-header">
|
||||
<div className="card-title">最近动态</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.recentActivity.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td>{item.time}</td>
|
||||
<td>{item.action}</td>
|
||||
<td>
|
||||
<span className={`status ${
|
||||
item.status === '审核通过' ? 'status-running' :
|
||||
item.status === '审核拒绝' ? 'status-error' :
|
||||
'status-warning'
|
||||
}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DevOverviewPage;
|
||||
@@ -1,50 +1,175 @@
|
||||
import { mySkills } from '../../data/developerData.js';
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../services/api.js';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function MySkillsPage({ onSkillClick }) {
|
||||
const sourceData = api.developer.getMySkills();
|
||||
const categories = api.developer.getCategories();
|
||||
const [filters, setFilters] = useState({ keyword: '', category: '', status: '' });
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [toast, setToast] = useState({ visible: false, type: 'success', message: '' });
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilters({ keyword: '', category: '', status: '' });
|
||||
};
|
||||
|
||||
const filteredList = sourceData.filter(skill => {
|
||||
if (filters.keyword && !skill.name.includes(filters.keyword) && !skill.desc.includes(filters.keyword)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.category && skill.category !== filters.category) {
|
||||
return false;
|
||||
}
|
||||
if (filters.status === 'published' && skill.status !== 'published') return false;
|
||||
if (filters.status === 'draft' && skill.status !== 'draft') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleUnpublish = (e, skill) => {
|
||||
e.stopPropagation();
|
||||
setToast({ visible: true, type: 'success', message: '已下架' });
|
||||
};
|
||||
|
||||
const handleDelete = (e, skill) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(skill);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
setDeleteTarget(null);
|
||||
setToast({ visible: true, type: 'success', message: '已删除' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">我的技能</div>
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="search-bar">
|
||||
<div className="search-item" style={{ flex: 1, minWidth: '200px' }}>
|
||||
<label>关键词</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="搜索技能名称、描述..."
|
||||
value={filters.keyword}
|
||||
onChange={e => handleFilterChange('keyword', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="search-item">
|
||||
<label>分类</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={filters.category}
|
||||
onChange={e => handleFilterChange('category', e.target.value)}
|
||||
>
|
||||
<option value="">全部分类</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="search-item">
|
||||
<label>状态</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={filters.status}
|
||||
onChange={e => handleFilterChange('status', e.target.value)}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="search-actions">
|
||||
<button className="btn btn-primary" onClick={() => {}}>查询</button>
|
||||
<button className="btn" onClick={handleReset}>重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>技能名称</th>
|
||||
<th>分类</th>
|
||||
<th>版本</th>
|
||||
<th>状态</th>
|
||||
<th>安装量</th>
|
||||
<th>评分</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mySkills.map(skill => (
|
||||
<tr key={skill.id} onClick={() => onSkillClick(skill.id)} style={{ cursor: 'pointer' }}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{skill.name}</div>
|
||||
<div style={{ fontSize: '12px', color: '#94A3B8' }}>{skill.desc}</div>
|
||||
</td>
|
||||
<td>{skill.category}</td>
|
||||
<td>{skill.version}</td>
|
||||
<td><span className={`status ${skill.status === 'published' ? 'status-running' : 'status-stopped'}`}>
|
||||
{skill.status === 'published' ? '已发布' : '草稿'}
|
||||
</span></td>
|
||||
<td>{skill.installs}</td>
|
||||
<td>{skill.rating || '-'}</td>
|
||||
<td>
|
||||
<button className="text-btn text-btn-primary" onClick={e => { e.stopPropagation(); onSkillClick(skill.id); }}>
|
||||
编辑
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">我的技能</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="table-wrapper">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>技能名称</th>
|
||||
<th>分类</th>
|
||||
<th>版本</th>
|
||||
<th>状态</th>
|
||||
<th>安装量</th>
|
||||
<th>评分</th>
|
||||
<th style={{ width: '200px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredList.map(skill => (
|
||||
<tr key={skill.id} onClick={() => onSkillClick(skill.id)} style={{ cursor: 'pointer' }}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{skill.name}</div>
|
||||
<div style={{ fontSize: '12px', color: '#94A3B8' }}>{skill.desc}</div>
|
||||
</td>
|
||||
<td>{skill.category}</td>
|
||||
<td>{skill.version}</td>
|
||||
<td>
|
||||
<span className={`status ${skill.status === 'published' ? 'status-running' : 'status-stopped'}`}>
|
||||
{skill.status === 'published' ? '已发布' : '草稿'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{skill.installs}</td>
|
||||
<td>{skill.rating || '-'}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="text-btn text-btn-primary" onClick={e => { e.stopPropagation(); onSkillClick(skill.id); }}>
|
||||
编辑
|
||||
</button>
|
||||
{skill.status === 'published' && (
|
||||
<button className="text-btn text-btn-danger" onClick={e => handleUnpublish(e, skill)}>
|
||||
下架
|
||||
</button>
|
||||
)}
|
||||
<button className="text-btn text-btn-danger" onClick={e => handleDelete(e, skill)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pagination">
|
||||
<div className="pagination-item">←</div>
|
||||
<div className="pagination-item active">1</div>
|
||||
<div className="pagination-item">→</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
visible={!!deleteTarget}
|
||||
title="确认删除"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
confirmText="删除"
|
||||
>
|
||||
确定要删除技能"{deleteTarget?.name}"吗?此操作不可撤销。
|
||||
</Modal>
|
||||
<Toast
|
||||
visible={toast.visible}
|
||||
type={toast.type}
|
||||
message={toast.message}
|
||||
onClose={() => setToast(prev => ({ ...prev, visible: false }))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MySkillsPage;
|
||||
export default MySkillsPage;
|
||||
|
||||
@@ -1,42 +1,56 @@
|
||||
import { FiUpload } from 'react-icons/fi';
|
||||
import { useState } from 'react';
|
||||
import { FiUpload, FiChevronLeft } from 'react-icons/fi';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function NewVersionPage({ skillName, onBack }) {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
onBack();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">上传新版本</div>
|
||||
<>
|
||||
<div className="dev-back-btn" onClick={onBack}>
|
||||
<FiChevronLeft /> 返回技能详情
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label">技能名称</label>
|
||||
<input type="text" className="form-control" defaultValue={skillName} />
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">上传新版本 — {skillName}</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">技能描述</label>
|
||||
<textarea className="form-control" rows="3" placeholder="请输入技能描述"></textarea>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">技能分类</label>
|
||||
<select className="form-control">
|
||||
<option>信息查询</option>
|
||||
<option>效率工具</option>
|
||||
<option>开发工具</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">技能包上传</label>
|
||||
<div style={{ border: '2px dashed #E2E8F0', borderRadius: '8px', padding: '40px', textAlign: 'center', color: '#94A3B8' }}>
|
||||
<FiUpload size={48} style={{ marginBottom: '16px' }} />
|
||||
<div>点击或拖拽文件到此处上传</div>
|
||||
<div style={{ fontSize: '12px', marginTop: '8px' }}>支持 .zip 格式</div>
|
||||
<div className="card-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label required">版本说明</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows="3"
|
||||
placeholder="请描述本次版本更新的内容"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label required">技能包上传</label>
|
||||
<div style={{ border: '2px dashed #E2E8F0', borderRadius: '8px', padding: '40px', textAlign: 'center', color: '#94A3B8' }}>
|
||||
<FiUpload size={48} style={{ marginBottom: '16px' }} />
|
||||
<div>点击或拖拽文件到此处上传</div>
|
||||
<div style={{ fontSize: '12px', marginTop: '8px' }}>支持 .zip 格式</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleSubmit}>提交审核</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn btn-primary">提交审核</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toast
|
||||
visible={showToast}
|
||||
type="success"
|
||||
message="已提交审核"
|
||||
onClose={() => setShowToast(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
import { FiChevronLeft, FiUpload, FiDownload } from 'react-icons/fi';
|
||||
import { mySkills } from '../../data/developerData.js';
|
||||
import { useState } from 'react';
|
||||
import { FiChevronLeft, FiUpload } from 'react-icons/fi';
|
||||
import { api } from '../../services/api.js';
|
||||
import Modal from '../../components/common/Modal.jsx';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function SkillEditorPage({ skillId, onBack, onUploadNewVersion, onUpdateInfo }) {
|
||||
const skill = api.developer.getSkillById(skillId);
|
||||
const [deleteSkillModal, setDeleteSkillModal] = useState(false);
|
||||
const [deleteVersionTarget, setDeleteVersionTarget] = useState(null);
|
||||
const [toast, setToast] = useState({ visible: false, type: 'success', message: '' });
|
||||
|
||||
function SkillEditorPage({ skillId, onBack, onUploadNewVersion }) {
|
||||
const skill = mySkills.find(s => s.id === skillId);
|
||||
if (!skill) {
|
||||
return <div>Skill not found</div>;
|
||||
}
|
||||
|
||||
const handleTogglePublish = () => {
|
||||
const msg = skill.status === 'published' ? '已下架' : '已上架';
|
||||
setToast({ visible: true, type: 'success', message: msg });
|
||||
};
|
||||
|
||||
const handleDeleteSkill = () => {
|
||||
setDeleteSkillModal(false);
|
||||
setToast({ visible: true, type: 'success', message: '已删除' });
|
||||
};
|
||||
|
||||
const handleDeleteVersion = () => {
|
||||
setDeleteVersionTarget(null);
|
||||
setToast({ visible: true, type: 'success', message: '已删除' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dev-back-btn" onClick={onBack}>
|
||||
@@ -18,7 +40,7 @@ function SkillEditorPage({ skillId, onBack, onUploadNewVersion }) {
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="dev-detail-header">
|
||||
<div className="dev-detail-icon">{skill.name.charAt(0)}</div>
|
||||
<div className="dev-detail-icon">{skill.icon || skill.name.charAt(0)}</div>
|
||||
<div className="dev-detail-main">
|
||||
<h2 style={{ marginBottom: '8px' }}>{skill.name}</h2>
|
||||
<div style={{ color: '#64748B', marginBottom: '12px' }}>{skill.category}</div>
|
||||
@@ -44,6 +66,25 @@ function SkillEditorPage({ skillId, onBack, onUploadNewVersion }) {
|
||||
<span className="dev-info-label">技能描述</span>
|
||||
<span className="dev-info-value">{skill.desc}</span>
|
||||
</div>
|
||||
<div className="dev-info-row">
|
||||
<span className="dev-info-label">技能分类</span>
|
||||
<span className="dev-info-value">{skill.category}</span>
|
||||
</div>
|
||||
<div className="dev-info-row">
|
||||
<span className="dev-info-label">技能标签</span>
|
||||
<span className="dev-info-value">
|
||||
{skill.tags.map(tag => (
|
||||
<span key={tag} className="dev-detail-tag" style={{ marginRight: '6px' }}>{tag}</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '20px', paddingTop: '16px', borderTop: '1px solid #E2E8F0' }}>
|
||||
<button className="btn btn-primary" onClick={() => onUpdateInfo && onUpdateInfo(skill.id)}>更新基本信息</button>
|
||||
<button className={`btn ${skill.status === 'published' ? 'btn-danger' : 'btn-success'}`} onClick={handleTogglePublish}>
|
||||
{skill.status === 'published' ? '下架技能' : '上架技能'}
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => setDeleteSkillModal(true)}>删除技能</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,7 +105,7 @@ function SkillEditorPage({ skillId, onBack, onUploadNewVersion }) {
|
||||
<col style={{ width: '120px' }} />
|
||||
<col style={{ width: '120px' }} />
|
||||
<col style={{ width: '100px' }} />
|
||||
<col style={{ width: '160px' }} />
|
||||
<col style={{ width: '240px' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -77,10 +118,15 @@ function SkillEditorPage({ skillId, onBack, onUploadNewVersion }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{skill.versions.map(ver => (
|
||||
<tr key={ver.version}>
|
||||
{skill.versions.map((ver, index) => (
|
||||
<tr key={index}>
|
||||
<td>{ver.version}</td>
|
||||
<td>{ver.desc}</td>
|
||||
<td>
|
||||
{ver.desc}
|
||||
{ver.status === 'rejected' && ver.rejectionReason && (
|
||||
<div className="dev-rejection-reason">{ver.rejectionReason}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{ver.status === 'pending' ? (
|
||||
<span className="status status-warning">审核中</span>
|
||||
@@ -102,6 +148,11 @@ function SkillEditorPage({ skillId, onBack, onUploadNewVersion }) {
|
||||
<div className="btn-group">
|
||||
{!ver.enabled && <button className="text-btn text-btn-success">启用</button>}
|
||||
<button className="text-btn">下载</button>
|
||||
{!ver.enabled && (
|
||||
<button className="text-btn text-btn-danger" onClick={() => setDeleteVersionTarget(ver)}>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -111,8 +162,32 @@ function SkillEditorPage({ skillId, onBack, onUploadNewVersion }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
visible={deleteSkillModal}
|
||||
title="确认删除"
|
||||
onConfirm={handleDeleteSkill}
|
||||
onCancel={() => setDeleteSkillModal(false)}
|
||||
confirmText="删除"
|
||||
>
|
||||
确定要删除技能"{skill.name}"吗?此操作不可撤销。
|
||||
</Modal>
|
||||
<Modal
|
||||
visible={!!deleteVersionTarget}
|
||||
title="确认删除"
|
||||
onConfirm={handleDeleteVersion}
|
||||
onCancel={() => setDeleteVersionTarget(null)}
|
||||
confirmText="删除"
|
||||
>
|
||||
确定要删除此版本吗?此操作不可撤销。
|
||||
</Modal>
|
||||
<Toast
|
||||
visible={toast.visible}
|
||||
type={toast.type}
|
||||
message={toast.message}
|
||||
onClose={() => setToast(prev => ({ ...prev, visible: false }))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillEditorPage;
|
||||
export default SkillEditorPage;
|
||||
|
||||
132
src/pages/developer/UpdateSkillInfoPage.jsx
Normal file
132
src/pages/developer/UpdateSkillInfoPage.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { FiX, FiChevronLeft } from 'react-icons/fi';
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../services/api.js';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
const ICON_OPTIONS = ['🌤️', '📊', '📝', '🔧', '💻', '📋', '🔍', '📈', '🎯', '⚡', '🌐', '🤖'];
|
||||
|
||||
function UpdateSkillInfoPage({ skill, onBack }) {
|
||||
const categories = api.developer.getCategories();
|
||||
const [name, setName] = useState(skill?.name || '');
|
||||
const [desc, setDesc] = useState(skill?.desc || '');
|
||||
const [category, setCategory] = useState(skill?.category || categories[0]);
|
||||
const [tags, setTags] = useState(skill?.tags || []);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [icon, setIcon] = useState(skill?.icon || ICON_OPTIONS[0]);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const handleTagKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && tagInput.trim()) {
|
||||
e.preventDefault();
|
||||
if (!tags.includes(tagInput.trim()) && tags.length < 5) {
|
||||
setTags([...tags, tagInput.trim()]);
|
||||
}
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove) => {
|
||||
setTags(tags.filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
onBack();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dev-back-btn" onClick={onBack}>
|
||||
<FiChevronLeft /> 返回技能详情
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-title">更新基本信息</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label required">技能名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="请输入技能名称"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label required">技能描述</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows="3"
|
||||
placeholder="请输入技能描述"
|
||||
value={desc}
|
||||
onChange={e => setDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">技能分类</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">标签</label>
|
||||
<div className="tag-input-container">
|
||||
{tags.map(tag => (
|
||||
<span key={tag} className="tag-item">
|
||||
{tag}
|
||||
<span className="tag-remove" onClick={() => removeTag(tag)}><FiX /></span>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
className="tag-input"
|
||||
placeholder={tags.length === 0 ? '输入标签后按回车添加' : ''}
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleTagKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#94A3B8', marginTop: '6px' }}>按回车添加标签,最多5个</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">技能图标</label>
|
||||
<div className="dev-icon-picker">
|
||||
{ICON_OPTIONS.map(emoji => (
|
||||
<div
|
||||
key={emoji}
|
||||
className={`dev-icon-option ${icon === emoji ? 'selected' : ''}`}
|
||||
onClick={() => setIcon(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#94A3B8', marginTop: '6px' }}>当前选择: {icon}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleSave}>保存修改</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toast
|
||||
visible={showToast}
|
||||
type="success"
|
||||
message="保存成功"
|
||||
onClose={() => setShowToast(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpdateSkillInfoPage;
|
||||
@@ -1,14 +1,21 @@
|
||||
import { FiUpload, FiX } from 'react-icons/fi';
|
||||
import { useState } from 'react';
|
||||
import { api } from '../../services/api.js';
|
||||
import Toast from '../../components/common/Toast.jsx';
|
||||
|
||||
function UploadSkillPage() {
|
||||
const ICON_OPTIONS = ['🌤️', '📊', '📝', '🔧', '💻', '📋', '🔍', '📈', '🎯', '⚡', '🌐', '🤖'];
|
||||
|
||||
function UploadSkillPage({ onBack }) {
|
||||
const categories = api.developer.getCategories();
|
||||
const [tags, setTags] = useState([]);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [icon, setIcon] = useState(ICON_OPTIONS[0]);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
const handleTagKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && tagInput.trim()) {
|
||||
e.preventDefault();
|
||||
if (!tags.includes(tagInput.trim())) {
|
||||
if (!tags.includes(tagInput.trim()) && tags.length < 5) {
|
||||
setTags([...tags, tagInput.trim()]);
|
||||
}
|
||||
setTagInput('');
|
||||
@@ -19,6 +26,10 @@ function UploadSkillPage() {
|
||||
setTags(tags.filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setShowToast(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
@@ -27,18 +38,18 @@ function UploadSkillPage() {
|
||||
<div className="card-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label required">技能名称</label>
|
||||
<input type="text" className="form-control" placeholder="请输入技能名称" required />
|
||||
<input type="text" className="form-control" placeholder="请输入技能名称" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label required">技能描述</label>
|
||||
<textarea className="form-control" rows="3" placeholder="请输入技能描述" required></textarea>
|
||||
<textarea className="form-control" rows="3" placeholder="请输入技能描述" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">技能分类</label>
|
||||
<select className="form-control">
|
||||
<option>信息查询</option>
|
||||
<option>效率工具</option>
|
||||
<option>开发工具</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -61,6 +72,21 @@ function UploadSkillPage() {
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#94A3B8', marginTop: '6px' }}>按回车添加标签,最多5个</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">技能图标</label>
|
||||
<div className="dev-icon-picker">
|
||||
{ICON_OPTIONS.map(emoji => (
|
||||
<div
|
||||
key={emoji}
|
||||
className={`dev-icon-option ${icon === emoji ? 'selected' : ''}`}
|
||||
onClick={() => setIcon(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#94A3B8', marginTop: '6px' }}>当前选择: {icon}</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label required">技能包上传</label>
|
||||
<div style={{ border: '2px dashed #E2E8F0', borderRadius: '8px', padding: '40px', textAlign: 'center', color: '#94A3B8' }}>
|
||||
@@ -70,12 +96,18 @@ function UploadSkillPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button className="btn">取消</button>
|
||||
<button className="btn btn-primary">创建技能</button>
|
||||
<button className="btn" onClick={onBack}>取消</button>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>创建技能</button>
|
||||
</div>
|
||||
</div>
|
||||
<Toast
|
||||
visible={showToast}
|
||||
type="success"
|
||||
message="创建成功"
|
||||
onClose={() => setShowToast(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UploadSkillPage;
|
||||
export default UploadSkillPage;
|
||||
|
||||
Reference in New Issue
Block a user