feat: 完善工作台展示场景 - 新增 Modal/Toast 组件、EmptyState 使用、确认弹窗、筛选分页

- 新增 Modal 确认弹窗组件和 Toast 消息提示组件
- 在 SkillsPage、LogsPage、TasksPage、ProjectsPage 使用 EmptyState
- 为删除任务、取消订阅、移除成员、技能订阅添加确认弹窗
- 丰富聊天场景:代码展示、表格数据、多轮对话、错误提示
- 优化 ChatPage 布局,修复对话区域滚动问题
- 为 ProjectsPage 添加筛选卡片和分页组件
- 添加表单校验错误状态展示
- 同步 specs 到主目录
This commit is contained in:
2026-03-20 11:44:25 +08:00
parent 9f407c3aea
commit 181cf09ad2
17 changed files with 1147 additions and 229 deletions

View File

@@ -0,0 +1,27 @@
import { FiX } from 'react-icons/fi';
function Modal({ visible, title, children, onConfirm, onCancel, confirmText = '确定', cancelText = '取消' }) {
if (!visible) return null;
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">{title}</div>
<div className="modal-close" onClick={onCancel}>
<FiX />
</div>
</div>
<div className="modal-body">
{children}
</div>
<div className="modal-footer">
<button className="btn" onClick={onCancel}>{cancelText}</button>
<button className="btn btn-primary" onClick={onConfirm}>{confirmText}</button>
</div>
</div>
</div>
);
}
export default Modal;

View File

@@ -0,0 +1,26 @@
import { FiCheckCircle, FiXCircle, FiAlertCircle, FiInfo } from 'react-icons/fi';
const icons = {
success: <FiCheckCircle />,
error: <FiXCircle />,
warning: <FiAlertCircle />,
info: <FiInfo />,
};
function Toast({ visible, type = 'info', message, onClose }) {
if (!visible) return null;
return (
<div className={`toast toast-${type}`}>
<span className="toast-icon">{icons[type]}</span>
<span className="toast-message">{message}</span>
{onClose && (
<span className="toast-close" onClick={onClose}>
<FiXCircle />
</span>
)}
</div>
);
}
export default Toast;

View File

@@ -5,6 +5,10 @@ export const conversations = [
{ id: 'text', title: '代码重构方案讨论', time: '普通对话', scene: 'text', status: 'running' },
{ id: 'skill', title: '查询客户数据', time: '调用 Skill', scene: 'skill', status: 'running' },
{ id: 'file', title: '分析上传的报表', time: '上传文件', scene: 'file', status: 'running' },
{ id: 'code', title: '生成 Python 函数', time: '代码展示', scene: 'code', status: 'running' },
{ id: 'table', title: '查询销售报表', time: '表格数据', scene: 'table', status: 'running' },
{ id: 'multiTurn', title: '产品方案讨论', time: '多轮对话', scene: 'multiTurn', status: 'running' },
{ id: 'error', title: '数据库连接失败', time: '请求失败', scene: 'error', status: 'running' },
{ id: 'starting', title: '文档生成助手', time: '启动中', scene: 'starting', status: 'starting' }
];
@@ -213,6 +217,245 @@ export function getChatScenes() {
<div class="message-time">16:22</div>
</div>
</div>
`,
code: `
<div class="message user">
<div class="message-avatar user">张</div>
<div class="message-content">
<div class="message-bubble">帮我写一个 Python 函数,实现列表去重并保持原顺序</div>
<div class="message-time">09:15</div>
</div>
</div>
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-thinking">
<div class="message-thinking-header">
<span class="message-thinking-icon">▶</span>
<span>已深度思考</span>
</div>
<div class="message-thinking-content">
<p>分析需求:列表去重并保持顺序</p>
<ul>
<li>使用字典键的有序性Python 3.7+</li>
<li>或者使用集合配合列表推导式</li>
<li>考虑性能O(n) 时间复杂度</li>
</ul>
</div>
</div>
<div class="message-bubble">
<p>好的,这是一个常用的工具函数。以下提供两种实现方式:</p>
<pre style="margin-top: 12px; padding: 16px; background: #1E293B; border-radius: 8px; overflow-x: auto;"><code style="color: #E2E8F0; font-family: 'Fira Code', monospace; font-size: 13px; line-height: 1.6;"><span style="color: #60A5FA;">def</span> <span style="color: #34D399;">dedupe_ordered</span>(lst):
<span style="color: #94A3B8;">"""列表去重并保持原顺序"""</span>
<span style="color: #60A5FA;">return</span> <span style="color: #F472B6;">list</span>(<span style="color: #F472B6;">dict</span>.<span style="color: #34D399;">fromkeys</span>(lst))
<span style="color: #94A3B8;"># 或者使用集合</span>
<span style="color: #60A5FA;">def</span> <span style="color: #34D399;">dedupe_ordered_v2</span>(lst):
seen = <span style="color: #F472B6;">set</span>()
<span style="color: #60A5FA;">return</span> [x <span style="color: #60A5FA;">for</span> x <span style="color: #60A5FA;">in</span> lst <span style="color: #60A5FA;">if</span> <span style="color: #60A5FA;">not</span> (x <span style="color: #60A5FA;">in</span> seen <span style="color: #60A5FA;">or</span> seen.<span style="color: #34D399;">add</span>(x))]
<span style="color: #94A3B8;"># 测试</span>
data = [<span style="color: #A78BFA;">1</span>, <span style="color: #A78BFA;">2</span>, <span style="color: #A78BFA;">3</span>, <span style="color: #A78BFA;">1</span>, <span style="color: #A78BFA;">2</span>, <span style="color: #A78BFA;">4</span>, <span style="color: #A78BFA;">5</span>, <span style="color: #A78BFA;">3</span>]
<span style="color: #F472B6;">print</span>(<span style="color: #34D399;">dedupe_ordered</span>(data)) <span style="color: #94A3B8;"># [1, 2, 3, 4, 5]</span></code></pre>
<p style="margin-top: 12px; color: #64748B; font-size: 13px;">推荐使用第一种方式,代码更简洁且性能更优。</p>
</div>
<div class="message-time">09:16</div>
</div>
</div>
`,
table: `
<div class="message user">
<div class="message-avatar user">张</div>
<div class="message-content">
<div class="message-bubble">查询本月各部门的销售业绩汇总</div>
<div class="message-time">11:30</div>
</div>
</div>
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-thinking">
<div class="message-thinking-header">
<span class="message-thinking-icon">▶</span>
<span>已深度思考</span>
</div>
<div class="message-thinking-content">
<p>正在查询销售数据...</p>
<ul>
<li>时间范围2026年3月1日 - 3月19日</li>
<li>维度:按部门汇总</li>
<li>指标:销售额、订单数、完成率</li>
</ul>
</div>
</div>
<div class="message-bubble">
<p><strong>本月各部门销售业绩汇总:</strong></p>
<table style="margin-top: 12px; width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="background: #F1F5F9;">
<th style="padding: 10px 12px; text-align: left; border: 1px solid #E2E8F0; font-weight: 600;">部门</th>
<th style="padding: 10px 12px; text-align: right; border: 1px solid #E2E8F0; font-weight: 600;">销售额</th>
<th style="padding: 10px 12px; text-align: right; border: 1px solid #E2E8F0; font-weight: 600;">订单数</th>
<th style="padding: 10px 12px; text-align: right; border: 1px solid #E2E8F0; font-weight: 600;">完成率</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0;">华东区</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right; font-weight: 600; color: #10B981;">¥528,600</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;">186</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;"><span style="color: #10B981;">112%</span></td>
</tr>
<tr style="background: #FAFAFA;">
<td style="padding: 10px 12px; border: 1px solid #E2E8F0;">华南区</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right; font-weight: 600;">¥412,300</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;">142</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;"><span style="color: #F59E0B;">98%</span></td>
</tr>
<tr>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0;">华北区</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right; font-weight: 600;">¥385,900</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;">128</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;"><span style="color: #10B981;">105%</span></td>
</tr>
<tr style="background: #FAFAFA;">
<td style="padding: 10px 12px; border: 1px solid #E2E8F0;">西南区</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right; font-weight: 600; color: #EF4444;">¥267,400</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;">95</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;"><span style="color: #EF4444;">76%</span></td>
</tr>
</tbody>
<tfoot>
<tr style="background: #F1F5F9; font-weight: 600;">
<td style="padding: 10px 12px; border: 1px solid #E2E8F0;">合计</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right; color: #3B82F6;">¥1,594,200</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;">551</td>
<td style="padding: 10px 12px; border: 1px solid #E2E8F0; text-align: right;"><span style="color: #3B82F6;">98%</span></td>
</tr>
</tfoot>
</table>
<p style="margin-top: 12px; font-size: 13px; color: #64748B;">数据更新时间2026-03-19 11:30</p>
</div>
<div class="message-time">11:31</div>
</div>
</div>
`,
multiTurn: `
<div class="message user">
<div class="message-avatar user">张</div>
<div class="message-content">
<div class="message-bubble">我想设计一个用户权限系统,有什么建议?</div>
<div class="message-time">14:00</div>
</div>
</div>
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-bubble">
<p>设计用户权限系统,建议采用 <strong>RBAC基于角色的访问控制</strong> 模型。核心要素包括:</p>
<ul style="margin-top: 8px; padding-left: 20px;">
<li><strong>用户User</strong>:系统使用者</li>
<li><strong>角色Role</strong>:权限的集合,如管理员、编辑、访客</li>
<li><strong>权限Permission</strong>:具体操作,如读、写、删除</li>
<li><strong>资源Resource</strong>:被控制的对象,如文章、订单</li>
</ul>
<p style="margin-top: 8px;">需要我详细说明某个部分吗?</p>
</div>
<div class="message-time">14:01</div>
</div>
</div>
<div class="message user">
<div class="message-avatar user">张</div>
<div class="message-content">
<div class="message-bubble">角色和权限之间是什么关系?多对多吗?</div>
<div class="message-time">14:02</div>
</div>
</div>
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-bubble">
<p>是的,<strong>角色和权限是多对多关系</strong>。</p>
<p style="margin-top: 8px;">一个角色可以拥有多个权限,一个权限也可以分配给多个角色。通常通过中间表来维护这种关系:</p>
<div style="margin-top: 12px; padding: 12px; background: #F8FAFC; border-radius: 8px; font-family: monospace; font-size: 13px;">
<div>roles ──┐</div>
<div> ├──▶ role_permissions</div>
<div>permissions ──┘</div>
</div>
<p style="margin-top: 8px;">这样设计的好处是灵活,可以随时调整角色的权限而不需要修改用户数据。</p>
</div>
<div class="message-time">14:03</div>
</div>
</div>
<div class="message user">
<div class="message-avatar user">张</div>
<div class="message-content">
<div class="message-bubble">那用户可以同时拥有多个角色吗?</div>
<div class="message-time">14:04</div>
</div>
</div>
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-bubble">
<p>可以,<strong>用户和角色也是多对多关系</strong>。</p>
<p style="margin-top: 8px;">一个用户可以同时拥有多个角色,其最终权限是所有角色权限的并集。比如:</p>
<ul style="margin-top: 8px; padding-left: 20px;">
<li>用户A同时是"内容编辑"和"数据分析员"</li>
<li>内容编辑:可以创建、编辑文章</li>
<li>数据分析员:可以查看报表、导出数据</li>
<li>用户A的最终权限 = 编辑权限 分析权限</li>
</ul>
<p style="margin-top: 8px;">这种设计在企业场景中很常见,一个员工可能身兼多职。</p>
</div>
<div class="message-time">14:05</div>
</div>
</div>
`,
error: `
<div class="message user">
<div class="message-avatar user">张</div>
<div class="message-content">
<div class="message-bubble">帮我查询一下最近7天的系统访问日志</div>
<div class="message-time">15:30</div>
</div>
</div>
<div class="message assistant">
<div class="message-avatar assistant">🤖</div>
<div class="message-content">
<div class="message-thinking">
<div class="message-thinking-header">
<span class="message-thinking-icon">▶</span>
<span>已深度思考</span>
</div>
<div class="message-thinking-content">
<p>正在连接日志数据库...</p>
<ul>
<li>目标数据库log-db-prod-01</li>
<li>查询范围2026-03-12 至 2026-03-19</li>
<li>执行查询...</li>
</ul>
</div>
</div>
<div class="message-bubble" style="border-left: 3px solid #EF4444; background: #FEF2F2;">
<p style="display: flex; align-items: center; gap: 8px; color: #EF4444; font-weight: 600;">
❌ 请求失败
</p>
<div style="margin-top: 12px; padding: 12px; background: #FFF; border-radius: 8px; font-size: 13px;">
<p><strong>错误类型:</strong>数据库连接超时</p>
<p style="margin-top: 6px;"><strong>错误详情:</strong>Connection timeout after 30s to log-db-prod-01:5432</p>
<p style="margin-top: 6px;"><strong>可能原因:</strong></p>
<ul style="margin-top: 4px; padding-left: 20px; color: #64748B;">
<li>数据库服务器负载过高</li>
<li>网络连接不稳定</li>
<li>数据库服务可能正在维护</li>
</ul>
</div>
<p style="margin-top: 12px; font-size: 13px; color: #64748B;">建议稍后重试,或联系系统管理员确认服务状态。</p>
</div>
<div class="message-time">15:31</div>
</div>
</div>
`
};
}

View File

@@ -1,4 +1,41 @@
import { useState } from 'react';
import Toast from '../../components/common/Toast.jsx';
function AccountPage() {
const [profileToast, setProfileToast] = useState(null);
const [passwordErrors, setPasswordErrors] = useState({});
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const handleProfileSave = () => {
setProfileToast({ type: 'success', message: '保存成功' });
setTimeout(() => setProfileToast(null), 3000);
};
const handlePasswordChange = (field, value) => {
setPasswordForm(prev => ({ ...prev, [field]: value }));
setPasswordErrors(prev => ({ ...prev, [field]: '' }));
};
const handlePasswordSubmit = () => {
const errors = {};
if (!passwordForm.currentPassword) {
errors.currentPassword = '请输入当前密码';
}
if (!passwordForm.newPassword) {
errors.newPassword = '请输入新密码';
}
if (!passwordForm.confirmPassword) {
errors.confirmPassword = '请再次输入新密码';
} else if (passwordForm.newPassword !== passwordForm.confirmPassword) {
errors.confirmPassword = '两次输入的密码不一致';
}
setPasswordErrors(errors);
};
return (
<>
<div className="card">
@@ -55,7 +92,7 @@ function AccountPage() {
<label className="form-label">所属部门</label>
<input type="text" className="form-control" defaultValue="AI 产品部" readOnly style={{ background: '#F8FAFC' }} />
</div>
<button className="btn btn-primary">保存修改</button>
<button className="btn btn-primary" onClick={handleProfileSave}>保存修改</button>
</div>
</div>
<div className="card">
@@ -65,19 +102,52 @@ function AccountPage() {
<div className="card-body">
<div className="form-group">
<label className="form-label">当前密码</label>
<input type="password" className="form-control" placeholder="请输入当前密码" />
<input
type="password"
className={`form-control ${passwordErrors.currentPassword ? 'is-invalid' : ''}`}
placeholder="请输入当前密码"
value={passwordForm.currentPassword}
onChange={e => handlePasswordChange('currentPassword', e.target.value)}
/>
{passwordErrors.currentPassword && (
<div className="form-error">{passwordErrors.currentPassword}</div>
)}
</div>
<div className="form-group">
<label className="form-label">新密码</label>
<input type="password" className="form-control" placeholder="请输入新密码" />
<input
type="password"
className={`form-control ${passwordErrors.newPassword ? 'is-invalid' : ''}`}
placeholder="请输入新密码"
value={passwordForm.newPassword}
onChange={e => handlePasswordChange('newPassword', e.target.value)}
/>
{passwordErrors.newPassword && (
<div className="form-error">{passwordErrors.newPassword}</div>
)}
</div>
<div className="form-group">
<label className="form-label">确认新密码</label>
<input type="password" className="form-control" placeholder="请再次输入新密码" />
<input
type="password"
className={`form-control ${passwordErrors.confirmPassword ? 'is-invalid' : ''}`}
placeholder="请再次输入新密码"
value={passwordForm.confirmPassword}
onChange={e => handlePasswordChange('confirmPassword', e.target.value)}
/>
{passwordErrors.confirmPassword && (
<div className="form-error">{passwordErrors.confirmPassword}</div>
)}
</div>
<button className="btn btn-primary">更新密码</button>
<button className="btn btn-primary" onClick={handlePasswordSubmit}>更新密码</button>
</div>
</div>
<Toast
visible={!!profileToast}
type={profileToast?.type}
message={profileToast?.message}
onClose={() => setProfileToast(null)}
/>
</>
);
}

View File

@@ -30,12 +30,12 @@ function ChatPage({ scene }) {
}, [scene, html]); // 依赖场景和html内容
return (
<div className="chat-layout" style={{ height: '100%' }}>
<div className="chat-layout">
<div className="chat-content">
<div className="chat-messages" style={{ padding: '16px 24px 8px' }}>
<div className="chat-messages">
<div ref={chatMessagesRef} dangerouslySetInnerHTML={{ __html: html }} />
</div>
<div className="chat-input-wrapper" style={{ padding: '12px 24px 20px' }}>
<div className="chat-input-wrapper">
<div className="chat-input-container">
<div className="chat-input-box">
<div className="chat-input-main">

View File

@@ -1,6 +1,40 @@
import { useState } from 'react';
import { FiInbox } from 'react-icons/fi';
import { logs } from '../../data/logs.js';
import EmptyState from '../../components/common/EmptyState.jsx';
function LogsPage() {
const [filters, setFilters] = useState({
keyword: '',
user: '',
type: '',
status: '',
});
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
const handleReset = () => {
setFilters({ keyword: '', user: '', type: '', status: '' });
};
const filteredLogs = logs.filter(log => {
if (filters.keyword && !log.action.includes(filters.keyword) && !log.detail.includes(filters.keyword)) {
return false;
}
if (filters.user && log.user !== filters.user) {
return false;
}
if (filters.type && log.type !== filters.type) {
return false;
}
if (filters.status && log.status !== filters.status) {
return false;
}
return true;
});
return (
<>
<div className="card">
@@ -8,12 +42,22 @@ function LogsPage() {
<div className="search-bar">
<div className="search-item" style={{ flex: 1, minWidth: '200px' }}>
<label>关键词</label>
<input type="text" className="form-control" placeholder="搜索操作、详情..." />
<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">
<option>全部用户</option>
<select
className="form-control"
value={filters.user}
onChange={e => handleFilterChange('user', e.target.value)}
>
<option value="">全部用户</option>
<option>张三</option>
<option>李四</option>
<option>王五</option>
@@ -21,8 +65,12 @@ function LogsPage() {
</div>
<div className="search-item">
<label>类型</label>
<select className="form-control">
<option>全部类型</option>
<select
className="form-control"
value={filters.type}
onChange={e => handleFilterChange('type', e.target.value)}
>
<option value="">全部类型</option>
<option>登录</option>
<option>实例操作</option>
<option>技能</option>
@@ -32,8 +80,12 @@ function LogsPage() {
</div>
<div className="search-item">
<label>状态</label>
<select className="form-control">
<option>全部</option>
<select
className="form-control"
value={filters.status}
onChange={e => handleFilterChange('status', e.target.value)}
>
<option value="">全部</option>
<option>成功</option>
<option>失败</option>
<option>警告</option>
@@ -42,7 +94,7 @@ function LogsPage() {
</div>
<div className="search-actions">
<button className="btn btn-primary">查询</button>
<button className="btn">重置</button>
<button className="btn" onClick={handleReset}>重置</button>
</div>
</div>
</div>
@@ -52,39 +104,49 @@ function LogsPage() {
<button className="btn btn-primary btn-sm">导出日志</button>
</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>
</tr>
</thead>
<tbody>
{logs.map((log, index) => (
<tr key={index}>
<td>{log.time}</td>
<td>{log.user}</td>
<td>{log.type}</td>
<td>{log.action}</td>
<td><span className={`status ${log.status === '成功' ? 'status-running' : log.status === '失败' ? 'status-error' : 'status-warning'}`}>{log.status}</span></td>
<td>{log.detail}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pagination">
<div className="pagination-item"></div>
<div className="pagination-item active">1</div>
<div className="pagination-item">2</div>
<div className="pagination-item">3</div>
<div className="pagination-item"></div>
</div>
{filteredLogs.length > 0 ? (
<>
<div className="table-wrapper">
<table className="table">
<thead>
<tr>
<th>时间</th>
<th>用户</th>
<th>类型</th>
<th>操作</th>
<th>状态</th>
<th>详情</th>
</tr>
</thead>
<tbody>
{filteredLogs.map((log, index) => (
<tr key={index}>
<td>{log.time}</td>
<td>{log.user}</td>
<td>{log.type}</td>
<td>{log.action}</td>
<td><span className={`status ${log.status === '成功' ? 'status-running' : log.status === '失败' ? 'status-error' : 'status-warning'}`}>{log.status}</span></td>
<td>{log.detail}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pagination">
<div className="pagination-item"></div>
<div className="pagination-item active">1</div>
<div className="pagination-item">2</div>
<div className="pagination-item">3</div>
<div className="pagination-item"></div>
</div>
</>
) : (
<EmptyState
icon={<FiInbox size={48} />}
message="暂无匹配日志"
description="当前筛选条件下没有日志记录"
/>
)}
</div>
</div>
</>

View File

@@ -1,44 +1,148 @@
import { useState } from 'react';
import { FiUsers, FiSearch } from 'react-icons/fi';
import { projectMembers } from '../../data/members.js';
import EmptyState from '../../components/common/EmptyState.jsx';
import Modal from '../../components/common/Modal.jsx';
function ProjectsPage({ onAddMember }) {
const [members, setMembers] = useState(projectMembers);
const [removeTarget, setRemoveTarget] = useState(null);
const [filters, setFilters] = useState({
keyword: '',
role: '',
});
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
const handleReset = () => {
setFilters({ keyword: '', role: '' });
};
const filteredMembers = members.filter(member => {
if (filters.keyword && !member.name.includes(filters.keyword)) {
return false;
}
if (filters.role && member.role !== filters.role) {
return false;
}
return true;
});
const handleRemoveClick = (member) => {
setRemoveTarget(member);
};
const confirmRemove = () => {
if (removeTarget) {
setMembers(prev => prev.filter(m => m.id !== removeTarget.id));
setRemoveTarget(null);
}
};
const cancelRemove = () => {
setRemoveTarget(null);
};
return (
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="card-title">项目管理</div>
<button className="btn btn-primary btn-sm" onClick={onAddMember}>增加成员</button>
</div>
<div className="card-body">
<div className="table-wrapper">
<table className="table">
<thead>
<tr>
<th>成员</th>
<th style={{ width: '100px' }}>角色</th>
<th style={{ width: '80px' }}>操作</th>
</tr>
</thead>
<tbody>
{projectMembers.map(member => (
<tr key={member.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div className="user-avatar" style={{ width: '32px', height: '32px', fontSize: '13px' }}>
{member.name.charAt(0)}
</div>
{member.name}
</div>
</td>
<td><span className={`status ${member.role === '管理员' ? 'role-admin' : 'role-member'}`}>{member.role}</span></td>
<td style={{ width: '80px' }}>
<button className="text-btn text-btn-primary">配置</button>
</td>
</tr>
))}
</tbody>
</table>
<>
<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.role}
onChange={e => handleFilterChange('role', e.target.value)}
>
<option value="">全部角色</option>
<option>管理员</option>
<option>成员</option>
</select>
</div>
</div>
<div className="search-actions">
<button className="btn btn-primary"><FiSearch /> 查询</button>
<button className="btn" onClick={handleReset}>重置</button>
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="card-title">成员列表</div>
<button className="btn btn-primary btn-sm" onClick={onAddMember}>增加成员</button>
</div>
<div className="card-body">
{filteredMembers.length > 0 ? (
<>
<div className="table-wrapper">
<table className="table">
<thead>
<tr>
<th>成员</th>
<th style={{ width: '100px' }}>角色</th>
<th style={{ width: '120px' }}>操作</th>
</tr>
</thead>
<tbody>
{filteredMembers.map(member => (
<tr key={member.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div className="user-avatar" style={{ width: '32px', height: '32px', fontSize: '13px' }}>
{member.name.charAt(0)}
</div>
{member.name}
</div>
</td>
<td><span className={`status ${member.role === '管理员' ? 'role-admin' : 'role-member'}`}>{member.role}</span></td>
<td style={{ width: '120px' }}>
<button className="text-btn text-btn-primary">配置</button>
<button className="text-btn text-btn-danger" style={{ marginLeft: '8px' }} onClick={() => handleRemoveClick(member)}>移除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pagination">
<div className="pagination-item"></div>
<div className="pagination-item active">1</div>
<div className="pagination-item">2</div>
<div className="pagination-item"></div>
</div>
</>
) : (
<EmptyState
icon={<FiUsers size={48} />}
message="暂无匹配成员"
description={filters.keyword || filters.role ? '当前筛选条件下没有成员' : '还没有添加任何项目成员'}
/>
)}
</div>
</div>
<Modal
visible={!!removeTarget}
title="确认移除"
onConfirm={confirmRemove}
onCancel={cancelRemove}
confirmText="移除"
>
确定要将成员"{removeTarget?.name}"移出项目吗
</Modal>
</>
);
}

View File

@@ -1,12 +1,34 @@
import { useState } from 'react';
import { FiChevronLeft, FiFile } from 'react-icons/fi';
import { skills, getSkillIcon, skillFiles, skillVersions } from '../../data/skills.js';
import Modal from '../../components/common/Modal.jsx';
function SkillDetailPage({ skillId, onBack }) {
const skill = skills.find(s => s.id === skillId);
const [subscribed, setSubscribed] = useState(skill?.subscribed || false);
const [showUnsubModal, setShowUnsubModal] = useState(false);
if (!skill) {
return <div>Skill not found</div>;
}
const handleSubscribeClick = () => {
if (subscribed) {
setShowUnsubModal(true);
} else {
setSubscribed(true);
}
};
const confirmUnsubscribe = () => {
setSubscribed(false);
setShowUnsubModal(false);
};
const cancelUnsubscribe = () => {
setShowUnsubModal(false);
};
return (
<>
<div className="skill-back-btn" onClick={onBack}>
@@ -30,8 +52,8 @@ function SkillDetailPage({ skillId, onBack }) {
</div>
</div>
<div style={{ flexShrink: 0 }}>
<button className={`btn ${skill.subscribed ? '' : 'btn-primary'}`}>
{skill.subscribed ? '取消订阅' : '立即订阅'}
<button className={`btn ${subscribed ? '' : 'btn-primary'}`} onClick={handleSubscribeClick}>
{subscribed ? '取消订阅' : '立即订阅'}
</button>
</div>
</div>
@@ -70,6 +92,15 @@ function SkillDetailPage({ skillId, onBack }) {
</div>
</div>
</div>
<Modal
visible={showUnsubModal}
title="确认取消订阅"
onConfirm={confirmUnsubscribe}
onCancel={cancelUnsubscribe}
confirmText="取消订阅"
>
确定要取消订阅"{skill.name}"取消后将无法使用该技能
</Modal>
</>
);
}

View File

@@ -1,8 +1,11 @@
import { useState } from 'react';
import { FiUser, FiStar, FiSearch } from 'react-icons/fi';
import { FaBoxOpen } from 'react-icons/fa';
import { skills, getSkillIcon } from '../../data/skills.js';
import EmptyState from '../../components/common/EmptyState.jsx';
import Modal from '../../components/common/Modal.jsx';
function SkillCard({ skill, onClick }) {
function SkillCard({ skill, onClick, onSubscribe }) {
return (
<div className="skill-card" onClick={onClick}>
<div className="skill-header">
@@ -27,7 +30,10 @@ function SkillCard({ skill, onClick }) {
<FiStar /> {skill.rating}
</span>
</div>
<button className={`btn ${skill.subscribed ? 'btn-primary' : ''} btn-sm`} onClick={e => e.stopPropagation()}>
<button
className={`btn ${skill.subscribed ? 'btn-primary' : ''} btn-sm`}
onClick={e => { e.stopPropagation(); onSubscribe(skill); }}
>
{skill.subscribed ? '已订阅' : '订阅'}
</button>
</div>
@@ -38,17 +44,45 @@ function SkillCard({ skill, onClick }) {
function SkillsPage({ onSkillClick }) {
const [filter, setFilter] = useState('all');
const [sort, setSort] = useState('subs');
const [searchQuery, setSearchQuery] = useState('');
const [skillsState, setSkillsState] = useState(skills);
const [modalTarget, setModalTarget] = useState(null);
const filteredSkills = filter === 'subscribed'
? skills.filter(s => s.subscribed)
: [...skills];
? skillsState.filter(s => s.subscribed)
: [...skillsState];
filteredSkills.sort((a, b) => {
const searchedSkills = searchQuery
? filteredSkills.filter(s =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.desc.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.tags.some(t => t.toLowerCase().includes(searchQuery.toLowerCase()))
)
: filteredSkills;
searchedSkills.sort((a, b) => {
if (sort === 'subs') return b.subs - a.subs;
if (sort === 'rating') return b.rating - a.rating;
return 0;
});
const handleSubscribeClick = (skill) => {
setModalTarget(skill);
};
const confirmSubscribe = () => {
if (modalTarget) {
setSkillsState(prev => prev.map(s =>
s.id === modalTarget.id ? { ...s, subscribed: !s.subscribed } : s
));
setModalTarget(null);
}
};
const cancelSubscribe = () => {
setModalTarget(null);
};
return (
<>
<div className="card">
@@ -56,7 +90,13 @@ function SkillsPage({ onSkillClick }) {
<div className="search-bar">
<div className="search-item" style={{ flex: 1, minWidth: '200px' }}>
<label>关键词</label>
<input type="text" className="form-control" placeholder="搜索技能..." />
<input
type="text"
className="form-control"
placeholder="搜索技能..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<div className="search-item">
<label>分类</label>
@@ -85,15 +125,40 @@ function SkillsPage({ onSkillClick }) {
<div className="btn-group">
<button className={`btn ${filter === 'all' ? 'btn-primary' : ''}`} onClick={() => setFilter('all')}>全部技能</button>
<button className={`btn ${filter === 'subscribed' ? 'btn-primary' : ''}`} onClick={() => setFilter('subscribed')}>
已订阅 ({skills.filter(s => s.subscribed).length})
已订阅 ({skillsState.filter(s => s.subscribed).length})
</button>
</div>
</div>
<div className="skill-grid">
{filteredSkills.map(skill => (
<SkillCard key={skill.id} skill={skill} onClick={() => onSkillClick(skill.id)} />
))}
</div>
{searchedSkills.length > 0 ? (
<div className="skill-grid">
{searchedSkills.map(skill => (
<SkillCard
key={skill.id}
skill={skill}
onClick={() => onSkillClick(skill.id)}
onSubscribe={handleSubscribeClick}
/>
))}
</div>
) : (
<EmptyState
icon={<FaBoxOpen size={48} />}
message="暂无匹配技能"
description={searchQuery ? `未找到与"${searchQuery}"相关的技能` : '当前筛选条件下没有技能'}
/>
)}
<Modal
visible={!!modalTarget}
title={modalTarget?.subscribed ? '确认取消订阅' : '确认订阅'}
onConfirm={confirmSubscribe}
onCancel={cancelSubscribe}
confirmText={modalTarget?.subscribed ? '取消订阅' : '订阅'}
>
{modalTarget?.subscribed
? `确定要取消订阅"${modalTarget?.name}"吗?取消后将无法使用该技能。`
: `确定要订阅"${modalTarget?.name}"吗?`
}
</Modal>
</>
);
}

View File

@@ -1,8 +1,12 @@
import { useState } from 'react';
import { FiClock } from 'react-icons/fi';
import { scheduledTasks } from '../../data/tasks.js';
import EmptyState from '../../components/common/EmptyState.jsx';
import Modal from '../../components/common/Modal.jsx';
function TasksPage({ onViewDetail }) {
const [tasks, setTasks] = useState(scheduledTasks);
const [deleteTarget, setDeleteTarget] = useState(null);
const toggleTask = (taskId) => {
setTasks(prev => prev.map(task =>
@@ -10,50 +14,84 @@ function TasksPage({ onViewDetail }) {
));
};
const handleDeleteClick = (task) => {
setDeleteTarget(task);
};
const confirmDelete = () => {
if (deleteTarget) {
setTasks(prev => prev.filter(task => task.id !== deleteTarget.id));
setDeleteTarget(null);
}
};
const cancelDelete = () => {
setDeleteTarget(null);
};
return (
<div className="card">
<div className="card-header">
<div className="card-title">定时任务</div>
<>
<div className="card">
<div className="card-header">
<div className="card-title">定时任务</div>
</div>
<div className="card-body">
{tasks.length > 0 ? (
<table className="table">
<thead>
<tr>
<th>任务名称</th>
<th>频率</th>
<th>上次触发</th>
<th>下次触发</th>
<th>上次运行状态</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{tasks.map(task => (
<tr key={task.id}>
<td>{task.name}</td>
<td>{task.frequency}</td>
<td>{task.lastTriggered}</td>
<td>{task.nextTrigger}</td>
<td>
<span className={`status ${task.lastStatus === '成功' ? 'status-running' : task.lastStatus === '失败' ? 'status-error' : 'status-warning'}`}>
{task.lastStatus}
</span>
</td>
<td><span className={`status ${task.enabled ? 'status-running' : 'status-stopped'}`}>{task.enabled ? '启用' : '禁用'}</span></td>
<td>
<button className={`text-btn ${task.enabled ? 'text-btn-danger' : 'text-btn-primary'}`} onClick={() => toggleTask(task.id)}>
{task.enabled ? '禁用' : '启用'}
</button>
<button className="text-btn text-btn-primary" style={{ marginLeft: '8px' }} onClick={() => onViewDetail(task.id)}>详情</button>
<button className="text-btn text-btn-danger" style={{ marginLeft: '8px' }} onClick={() => handleDeleteClick(task)}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<EmptyState
icon={<FiClock size={48} />}
message="暂无定时任务"
description="还没有创建任何定时任务"
/>
)}
</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>
{tasks.map(task => (
<tr key={task.id}>
<td>{task.name}</td>
<td>{task.frequency}</td>
<td>{task.lastTriggered}</td>
<td>{task.nextTrigger}</td>
<td>
<span className={`status ${task.lastStatus === '成功' ? 'status-running' : task.lastStatus === '失败' ? 'status-error' : 'status-warning'}`}>
{task.lastStatus}
</span>
</td>
<td><span className={`status ${task.enabled ? 'status-running' : 'status-stopped'}`}>{task.enabled ? '启用' : '禁用'}</span></td>
<td>
<button className={`text-btn ${task.enabled ? 'text-btn-danger' : 'text-btn-primary'}`} onClick={() => toggleTask(task.id)}>
{task.enabled ? '禁用' : '启用'}
</button>
<button className="text-btn text-btn-primary" style={{ marginLeft: '8px' }} onClick={() => onViewDetail(task.id)}>详情</button>
<button className="text-btn text-btn-danger" style={{ marginLeft: '8px' }}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<Modal
visible={!!deleteTarget}
title="确认删除"
onConfirm={confirmDelete}
onCancel={cancelDelete}
confirmText="删除"
>
确定要删除任务"{deleteTarget?.name}"此操作不可撤销
</Modal>
</>
);
}

View File

@@ -1058,6 +1058,7 @@ input:checked + .slider:before {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--color-bg-1);
}
@@ -1272,6 +1273,8 @@ input:checked + .slider:before {
display: flex;
flex-direction: column;
background: var(--color-bg-1);
min-height: 0;
overflow: hidden;
}
.chat-messages {
@@ -2542,3 +2545,174 @@ input:checked + .slider:before {
.message-thinking {
cursor: pointer;
}
/* ===== Modal 弹窗样式 ===== */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
backdrop-filter: blur(2px);
}
.modal {
background: var(--color-bg-1);
border-radius: var(--radius-lg);
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.16);
width: 420px;
max-width: 90vw;
animation: modal-in 0.2s ease-out;
}
@keyframes modal-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border-2);
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
}
.modal-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-3);
transition: all 0.2s;
&:hover {
background: var(--color-bg-2);
color: var(--color-text-1);
}
}
.modal-body {
padding: 20px;
font-size: 14px;
color: var(--color-text-2);
line-height: 1.6;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--color-border-2);
}
/* ===== Toast 消息提示样式 ===== */
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: var(--radius-md);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.12);
z-index: 3000;
animation: toast-in 0.3s ease-out;
font-size: 14px;
font-weight: 500;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.toast-success {
background: var(--color-success-light);
color: var(--color-success);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.toast-error {
background: var(--color-danger-light);
color: var(--color-danger);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.toast-warning {
background: var(--color-warning-light);
color: var(--color-warning);
border: 1px solid rgba(245, 158, 11, 0.2);
}
.toast-info {
background: var(--color-primary-light);
color: var(--color-primary);
border: 1px solid rgba(59, 130, 246, 0.2);
}
.toast-icon {
display: flex;
align-items: center;
font-size: 16px;
}
.toast-message {
flex: 1;
}
.toast-close {
display: flex;
align-items: center;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
/* ===== 表单校验错误样式 ===== */
.form-control.is-invalid {
border-color: var(--color-danger);
&:focus {
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
}
.form-error {
font-size: 12px;
color: var(--color-danger);
margin-top: 4px;
}