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

125
README.md
View File

@@ -1,49 +1,5 @@
# GrandClaw 原型项目
> 企业级AI智算平台前端原型专注于展示页面布局和内容使用React + Vite构建。
## 文档编写原则
本文档遵循以下编写原则,以确保内容的准确性、可维护性和可读性:
### 内容原则
- **准确性优先**:所有技术描述、代码示例、路径配置必须与实际代码保持一致
- **简洁明了**:使用清晰简洁的语言,避免冗余描述
- **实用导向**:提供开发者实际需要的配置和开发指南
### 结构原则
- **分层组织**:按功能模块和技术领域分层组织内容
- **逻辑清晰**:从项目概述 → 技术栈 → 功能 → 开发指南 → 维护的顺序组织
- **易于检索**:使用明确的标题层级和目录结构
### 代码原则
- **代码即文档**:代码示例使用实际可运行的代码
- **注释克制**:仅在必要时添加注释,代码本身应自解释
## 文档更新原则
本文档随项目迭代持续更新,遵循以下更新策略:
### 更新触发条件
- 新增或移除核心功能模块
- 更改技术栈或依赖版本
- 添加新的页面或路由
- 新增通用组件或工具
- 重大架构调整
### 更新内容
- **版本对齐**:技术栈版本号必须与 package.json 保持一致
- **路径同步**:文件路径必须与实际项目结构一致
- **功能同步**:核心功能描述必须覆盖实际实现的所有功能
- **示例验证**:代码示例必须经过验证可正常运行
### 更新记录
- 每次重要更新在更新日志中记录
- 记录内容包括:日期、更新类型、具体变更
- 保持更新日志按时间倒序排列
---
## 项目概述
GrandClaw 是一个企业级AI智能助手平台的前端原型项目主要用于展示平台的主要页面布局、交互流程和视觉设计。项目采用现代化的前端技术栈实现了四大核心模块
@@ -301,6 +257,43 @@ import EmptyState from '../components/common/EmptyState.jsx';
/>
```
#### Modal 弹窗
用于展示确认操作的弹窗组件,支持自定义标题和内容。
```jsx
import Modal from '../components/common/Modal.jsx';
<Modal
visible={showModal}
title="确认删除"
onConfirm={handleConfirm}
onCancel={handleCancel}
confirmText="删除"
>
确定要删除这个任务吗
</Modal>
```
#### Toast 消息提示
用于展示操作结果的消息提示组件,支持成功、错误、警告、信息四种类型。
```jsx
import Toast from '../components/common/Toast.jsx';
<Toast
visible={showToast}
type="success"
message="操作成功"
onClose={() => setShowToast(false)}
/>
```
**支持的类型:**
- `success` - 成功(绿色)
- `error` - 错误(红色)
- `warning` - 警告(黄色)
- `info` - 信息(蓝色)
#### StatusBadge 状态标签
用于显示状态(成功、失败、警告等)的标签组件。
@@ -629,48 +622,4 @@ export default defineConfig({
3. 组件样式添加到 `_components.scss`
4. 页面特定样式添加到 `global.scss`
### 调试技巧
1. 使用 `pnpm dev` 启动开发服务器
2. 检查浏览器控制台的localStorage操作
3. 使用React Developer Tools检查组件状态
4. 检查网络面板确认资源加载
## 更新日志
### 2026-03-20
- 代码架构重构提取布局组件SidebarBrand、SidebarUser、SidebarNavItem
- 代码架构重构创建通用UI组件EmptyState、StatusBadge、TagInput、SearchBar
- 代码架构重构新增全局状态管理UserContext
- 代码架构重构新增自定义HooksusePageState、useNavigation、useLocalStorage
- 代码架构重构新增统一数据访问层src/services/api.js
- 代码架构重构新增常量配置constants/pages.js、constants/storageKeys.js
- 文档同步:更新开发台子页面列表,补充 NewVersionPage
- 文档同步:补充开发台功能描述(上传新版本)
### 2026-03-19
- 完成从静态HTML原型到React项目的重构
- 实现四大核心模块:首页、工作台、管理台、开发台
- 集成react-icons图标库
- 实现SCSS样式模块化
- 配置Vite单文件打包
- 实现导航状态持久化
- 区分主页跳转和刷新浏览器行为
### 2026-03-19功能更新
- 新增登录页面,支持验证码防爆破
- 工作台定时任务支持查看详情、执行日志
- 管理台新增用户/部门/项目管理支持新增表单
- 新增ListSelector通用列表选择器组件
- 日志查询支持按用户、类型、状态筛选
- 用户管理支持管理员/开发者/成员角色区分
- 优化页面布局和样式
## 联系方式
- 项目原型演示用途
- 基于GrandClaw团队设计
- 前端技术栈React + Vite + SCSS
---
*最后更新2026-03-19*

View File

@@ -2,7 +2,7 @@ schema: spec-driven
context: |
- 交流、文档、注释、提交信息使用中文,代码命名使用英文
- 纯前端原型项目无后端交互供内部开发人员参考UI界面使用目标在于展示页面布局、样式和组件能力
- 纯前端展示原型项目(非功能原型)无后端交互供内部开发人员参考UI界面使用目标在于展示页面布局、样式和组件能力
- 允许轻量级交互展示(如表单验证、弹框),状态展示策略:不重叠的状态通过静态数据驱动展示,重叠/覆盖类状态(弹框、下拉、抽屉等)允许简单交互切换
- 示例数据应精心设计,展示不同的页面元素状态
- 不引入UI库使用当前SCSS样式方案

View File

@@ -0,0 +1,33 @@
## Purpose
定义聊天界面中各类对话场景的展示规范。
## Requirements
### Requirement: 代码展示场景
系统 SHALL 在聊天界面展示包含代码高亮的对话场景。
#### Scenario: 代码生成对话
- **WHEN** 用户在对话列表中选择"代码生成"场景
- **THEN** 页面展示包含代码块的对话内容,代码块具有语法高亮样式
### Requirement: 表格数据场景
系统 SHALL 在聊天界面展示包含表格数据的对话场景。
#### Scenario: 数据查询对话
- **WHEN** 用户在对话列表中选择"数据查询"场景
- **THEN** 页面展示包含表格的对话内容,表格清晰展示查询结果
### Requirement: 多轮对话场景
系统 SHALL 在聊天界面展示多轮连续对话场景。
#### Scenario: 连续问答对话
- **WHEN** 用户在对话列表中选择"多轮对话"场景
- **THEN** 页面展示至少 3 轮用户与助手的交替对话
### Requirement: 错误提示场景
系统 SHALL 在聊天界面展示包含错误提示的对话场景。
#### Scenario: 请求失败对话
- **WHEN** 用户在对话列表中选择"请求失败"场景
- **THEN** 页面展示助手返回错误提示的对话内容

View File

@@ -0,0 +1,37 @@
## Purpose
定义工作台各页面空状态的展示规范。
## Requirements
### Requirement: 技能市场空状态展示
当技能市场无搜索结果时,系统 SHALL 展示 EmptyState 组件。
#### Scenario: 搜索无结果
- **WHEN** 用户在技能市场搜索框输入关键词后点击查询
- **THEN** 页面展示 EmptyState 组件,显示"暂无匹配技能"提示
### Requirement: 日志查询空状态展示
当日志查询无匹配结果时,系统 SHALL 展示 EmptyState 组件。
#### Scenario: 筛选无结果
- **WHEN** 用户选择筛选条件后点击查询按钮
- **THEN** 页面展示 EmptyState 组件,显示"暂无匹配日志"提示
### Requirement: 定时任务空状态展示
当定时任务列表为空时,系统 SHALL 展示 EmptyState 组件。
#### Scenario: 无任务
- **WHEN** 用户进入定时任务页面
- **THEN** 页面展示 EmptyState 组件,显示"暂无定时任务"提示
### Requirement: 项目管理空状态展示
当项目成员列表为空或筛选无结果时,系统 SHALL 展示 EmptyState 组件。
#### Scenario: 无成员
- **WHEN** 用户进入项目管理页面且没有成员
- **THEN** 页面展示 EmptyState 组件,显示"暂无匹配成员"提示
#### Scenario: 筛选无结果
- **WHEN** 用户选择筛选条件后点击查询按钮
- **THEN** 页面展示 EmptyState 组件,显示"暂无匹配成员"提示

View File

@@ -0,0 +1,39 @@
## Purpose
定义确认弹窗Modal和消息提示Toast的展示规范。
## Requirements
### Requirement: 确认弹窗展示
系统 SHALL 提供 Modal 组件用于展示确认弹窗。
#### Scenario: 删除任务确认
- **WHEN** 用户点击定时任务的"删除"按钮
- **THEN** 页面展示确认弹窗,标题为"确认删除",内容为"确定要删除这个任务吗?"
#### Scenario: 取消订阅确认
- **WHEN** 用户点击技能详情页的"取消订阅"按钮
- **THEN** 页面展示确认弹窗,标题为"确认取消订阅",内容为"确定要取消订阅该技能吗?"
#### Scenario: 移除成员确认
- **WHEN** 用户点击项目成员的"移除"按钮
- **THEN** 页面展示确认弹窗,标题为"确认移除",内容为"确定要将该成员移出项目吗?"
#### Scenario: 技能市场订阅确认
- **WHEN** 用户点击技能卡片的"订阅"按钮
- **THEN** 页面展示确认弹窗,标题为"确认订阅",内容为"确定要订阅该技能吗?"
#### Scenario: 技能市场取消订阅确认
- **WHEN** 用户点击技能卡片的"已订阅"按钮
- **THEN** 页面展示确认弹窗,标题为"确认取消订阅",内容为"确定要取消订阅该技能吗?取消后将无法使用该技能。"
### Requirement: 消息提示展示
系统 SHALL 提供 Toast 组件用于展示操作结果提示。
#### Scenario: 保存成功提示
- **WHEN** 用户在账号管理页面点击"保存修改"按钮
- **THEN** 页面顶部展示绿色成功提示"保存成功"
#### Scenario: 操作失败提示
- **WHEN** 用户执行操作失败
- **THEN** 页面顶部展示红色错误提示"操作失败,请重试"

View File

@@ -0,0 +1,20 @@
## Purpose
定义表单校验错误状态的展示规范。
## Requirements
### Requirement: 表单校验错误状态展示
系统 SHALL 在表单中展示校验错误状态。
#### Scenario: 必填项为空
- **WHEN** 用户在修改密码表单中不输入当前密码直接点击"更新密码"
- **THEN** 当前密码输入框边框变红,下方显示"请输入当前密码"错误提示
#### Scenario: 邮箱格式错误
- **WHEN** 用户在个人信息表单中输入无效邮箱格式
- **THEN** 邮箱输入框边框变红,下方显示"请输入有效的邮箱地址"错误提示
#### Scenario: 密码不一致
- **WHEN** 用户在修改密码表单中输入两次不同的新密码
- **THEN** 确认密码输入框边框变红,下方显示"两次输入的密码不一致"错误提示

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;
}