diff --git a/README.md b/README.md index 6b7621e..5bc711d 100644 --- a/README.md +++ b/README.md @@ -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'; + + + 确定要删除这个任务吗? + +``` + +#### Toast 消息提示 +用于展示操作结果的消息提示组件,支持成功、错误、警告、信息四种类型。 + +```jsx +import Toast from '../components/common/Toast.jsx'; + + 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) -- 代码架构重构:新增自定义Hooks(usePageState、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* diff --git a/openspec/config.yaml b/openspec/config.yaml index ef6e045..192391b 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -2,7 +2,7 @@ schema: spec-driven context: | - 交流、文档、注释、提交信息使用中文,代码命名使用英文 - - 纯前端原型项目,无后端交互,供内部开发人员参考UI界面使用,目标在于展示页面布局、样式和组件能力 + - 纯前端展示原型项目(非功能原型),无后端交互,供内部开发人员参考UI界面使用,目标在于展示页面布局、样式和组件能力 - 允许轻量级交互展示(如表单验证、弹框),状态展示策略:不重叠的状态通过静态数据驱动展示,重叠/覆盖类状态(弹框、下拉、抽屉等)允许简单交互切换 - 示例数据应精心设计,展示不同的页面元素状态 - 不引入UI库,使用当前SCSS样式方案 diff --git a/openspec/specs/chat-scenarios/spec.md b/openspec/specs/chat-scenarios/spec.md new file mode 100644 index 0000000..0cf0e65 --- /dev/null +++ b/openspec/specs/chat-scenarios/spec.md @@ -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** 页面展示助手返回错误提示的对话内容 diff --git a/openspec/specs/empty-state-display/spec.md b/openspec/specs/empty-state-display/spec.md new file mode 100644 index 0000000..e9061dd --- /dev/null +++ b/openspec/specs/empty-state-display/spec.md @@ -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 组件,显示"暂无匹配成员"提示 diff --git a/openspec/specs/feedback-display/spec.md b/openspec/specs/feedback-display/spec.md new file mode 100644 index 0000000..a7035a6 --- /dev/null +++ b/openspec/specs/feedback-display/spec.md @@ -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** 页面顶部展示红色错误提示"操作失败,请重试" diff --git a/openspec/specs/form-validation-display/spec.md b/openspec/specs/form-validation-display/spec.md new file mode 100644 index 0000000..4c53ac9 --- /dev/null +++ b/openspec/specs/form-validation-display/spec.md @@ -0,0 +1,20 @@ +## Purpose + +定义表单校验错误状态的展示规范。 + +## Requirements + +### Requirement: 表单校验错误状态展示 +系统 SHALL 在表单中展示校验错误状态。 + +#### Scenario: 必填项为空 +- **WHEN** 用户在修改密码表单中不输入当前密码直接点击"更新密码" +- **THEN** 当前密码输入框边框变红,下方显示"请输入当前密码"错误提示 + +#### Scenario: 邮箱格式错误 +- **WHEN** 用户在个人信息表单中输入无效邮箱格式 +- **THEN** 邮箱输入框边框变红,下方显示"请输入有效的邮箱地址"错误提示 + +#### Scenario: 密码不一致 +- **WHEN** 用户在修改密码表单中输入两次不同的新密码 +- **THEN** 确认密码输入框边框变红,下方显示"两次输入的密码不一致"错误提示 diff --git a/src/components/common/Modal.jsx b/src/components/common/Modal.jsx new file mode 100644 index 0000000..cd012d6 --- /dev/null +++ b/src/components/common/Modal.jsx @@ -0,0 +1,27 @@ +import { FiX } from 'react-icons/fi'; + +function Modal({ visible, title, children, onConfirm, onCancel, confirmText = '确定', cancelText = '取消' }) { + if (!visible) return null; + + return ( +
+
e.stopPropagation()}> +
+
{title}
+
+ +
+
+
+ {children} +
+
+ + +
+
+
+ ); +} + +export default Modal; diff --git a/src/components/common/Toast.jsx b/src/components/common/Toast.jsx new file mode 100644 index 0000000..412033d --- /dev/null +++ b/src/components/common/Toast.jsx @@ -0,0 +1,26 @@ +import { FiCheckCircle, FiXCircle, FiAlertCircle, FiInfo } from 'react-icons/fi'; + +const icons = { + success: , + error: , + warning: , + info: , +}; + +function Toast({ visible, type = 'info', message, onClose }) { + if (!visible) return null; + + return ( +
+ {icons[type]} + {message} + {onClose && ( + + + + )} +
+ ); +} + +export default Toast; diff --git a/src/data/conversations.js b/src/data/conversations.js index 6a9c7d1..74e37ac 100644 --- a/src/data/conversations.js +++ b/src/data/conversations.js @@ -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() {
16:22
+ `, + code: ` +
+
+
+
帮我写一个 Python 函数,实现列表去重并保持原顺序
+
09:15
+
+
+
+
🤖
+
+
+
+ + 已深度思考 +
+
+

分析需求:列表去重并保持顺序

+
    +
  • 使用字典键的有序性(Python 3.7+)
  • +
  • 或者使用集合配合列表推导式
  • +
  • 考虑性能:O(n) 时间复杂度
  • +
+
+
+
+

好的,这是一个常用的工具函数。以下提供两种实现方式:

+
def dedupe_ordered(lst):
+    """列表去重并保持原顺序"""
+    return list(dict.fromkeys(lst))
+
+# 或者使用集合
+def dedupe_ordered_v2(lst):
+    seen = set()
+    return [x for x in lst if not (x in seen or seen.add(x))]
+
+# 测试
+data = [1, 2, 3, 1, 2, 4, 5, 3]
+print(dedupe_ordered(data))  # [1, 2, 3, 4, 5]
+

推荐使用第一种方式,代码更简洁且性能更优。

+
+
09:16
+
+
+ `, + table: ` +
+
+
+
查询本月各部门的销售业绩汇总
+
11:30
+
+
+
+
🤖
+
+
+
+ + 已深度思考 +
+
+

正在查询销售数据...

+
    +
  • 时间范围:2026年3月1日 - 3月19日
  • +
  • 维度:按部门汇总
  • +
  • 指标:销售额、订单数、完成率
  • +
+
+
+
+

本月各部门销售业绩汇总:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
部门销售额订单数完成率
华东区¥528,600186112%
华南区¥412,30014298%
华北区¥385,900128105%
西南区¥267,4009576%
合计¥1,594,20055198%
+

数据更新时间:2026-03-19 11:30

+
+
11:31
+
+
+ `, + multiTurn: ` +
+
+
+
我想设计一个用户权限系统,有什么建议?
+
14:00
+
+
+
+
🤖
+
+
+

设计用户权限系统,建议采用 RBAC(基于角色的访问控制) 模型。核心要素包括:

+
    +
  • 用户(User):系统使用者
  • +
  • 角色(Role):权限的集合,如管理员、编辑、访客
  • +
  • 权限(Permission):具体操作,如读、写、删除
  • +
  • 资源(Resource):被控制的对象,如文章、订单
  • +
+

需要我详细说明某个部分吗?

+
+
14:01
+
+
+
+
+
+
角色和权限之间是什么关系?多对多吗?
+
14:02
+
+
+
+
🤖
+
+
+

是的,角色和权限是多对多关系

+

一个角色可以拥有多个权限,一个权限也可以分配给多个角色。通常通过中间表来维护这种关系:

+
+
roles ──┐
+
├──▶ role_permissions
+
permissions ──┘
+
+

这样设计的好处是灵活,可以随时调整角色的权限而不需要修改用户数据。

+
+
14:03
+
+
+
+
+
+
那用户可以同时拥有多个角色吗?
+
14:04
+
+
+
+
🤖
+
+
+

可以,用户和角色也是多对多关系

+

一个用户可以同时拥有多个角色,其最终权限是所有角色权限的并集。比如:

+
    +
  • 用户A同时是"内容编辑"和"数据分析员"
  • +
  • 内容编辑:可以创建、编辑文章
  • +
  • 数据分析员:可以查看报表、导出数据
  • +
  • 用户A的最终权限 = 编辑权限 ∪ 分析权限
  • +
+

这种设计在企业场景中很常见,一个员工可能身兼多职。

+
+
14:05
+
+
+ `, + error: ` +
+
+
+
帮我查询一下最近7天的系统访问日志
+
15:30
+
+
+
+
🤖
+
+
+
+ + 已深度思考 +
+
+

正在连接日志数据库...

+
    +
  • 目标数据库:log-db-prod-01
  • +
  • 查询范围:2026-03-12 至 2026-03-19
  • +
  • 执行查询...
  • +
+
+
+
+

+ ❌ 请求失败 +

+
+

错误类型:数据库连接超时

+

错误详情:Connection timeout after 30s to log-db-prod-01:5432

+

可能原因:

+
    +
  • 数据库服务器负载过高
  • +
  • 网络连接不稳定
  • +
  • 数据库服务可能正在维护
  • +
+
+

建议稍后重试,或联系系统管理员确认服务状态。

+
+
15:31
+
+
` }; } \ No newline at end of file diff --git a/src/pages/console/AccountPage.jsx b/src/pages/console/AccountPage.jsx index b708211..8a07b80 100644 --- a/src/pages/console/AccountPage.jsx +++ b/src/pages/console/AccountPage.jsx @@ -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 ( <>
@@ -55,7 +92,7 @@ function AccountPage() {
- +
@@ -65,19 +102,52 @@ function AccountPage() {
- + handlePasswordChange('currentPassword', e.target.value)} + /> + {passwordErrors.currentPassword && ( +
{passwordErrors.currentPassword}
+ )}
- + handlePasswordChange('newPassword', e.target.value)} + /> + {passwordErrors.newPassword && ( +
{passwordErrors.newPassword}
+ )}
- + handlePasswordChange('confirmPassword', e.target.value)} + /> + {passwordErrors.confirmPassword && ( +
{passwordErrors.confirmPassword}
+ )}
- +
+ setProfileToast(null)} + /> ); } diff --git a/src/pages/console/ChatPage.jsx b/src/pages/console/ChatPage.jsx index 89dd6f2..9c34c7a 100644 --- a/src/pages/console/ChatPage.jsx +++ b/src/pages/console/ChatPage.jsx @@ -30,12 +30,12 @@ function ChatPage({ scene }) { }, [scene, html]); // 依赖场景和html内容 return ( -
+
-
+
-
+
diff --git a/src/pages/console/LogsPage.jsx b/src/pages/console/LogsPage.jsx index 908fba4..9723af4 100644 --- a/src/pages/console/LogsPage.jsx +++ b/src/pages/console/LogsPage.jsx @@ -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 ( <>
@@ -8,12 +42,22 @@ function LogsPage() {
- + handleFilterChange('keyword', e.target.value)} + />
- handleFilterChange('user', e.target.value)} + > + @@ -21,8 +65,12 @@ function LogsPage() {
- handleFilterChange('type', e.target.value)} + > + @@ -32,8 +80,12 @@ function LogsPage() {
- handleFilterChange('status', e.target.value)} + > + @@ -42,7 +94,7 @@ function LogsPage() {
- +
@@ -52,39 +104,49 @@ function LogsPage() {
-
- - - - - - - - - - - - - {logs.map((log, index) => ( - - - - - - - - - ))} - -
时间用户类型操作状态详情
{log.time}{log.user}{log.type}{log.action}{log.status}{log.detail}
-
-
-
-
1
-
2
-
3
-
-
+ {filteredLogs.length > 0 ? ( + <> +
+ + + + + + + + + + + + + {filteredLogs.map((log, index) => ( + + + + + + + + + ))} + +
时间用户类型操作状态详情
{log.time}{log.user}{log.type}{log.action}{log.status}{log.detail}
+
+
+
+
1
+
2
+
3
+
+
+ + ) : ( + } + message="暂无匹配日志" + description="当前筛选条件下没有日志记录" + /> + )}
diff --git a/src/pages/console/ProjectsPage.jsx b/src/pages/console/ProjectsPage.jsx index 247c49c..c69d9b6 100644 --- a/src/pages/console/ProjectsPage.jsx +++ b/src/pages/console/ProjectsPage.jsx @@ -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 ( -
-
-
项目管理
- -
-
-
- - - - - - - - - - {projectMembers.map(member => ( - - - - - - ))} - -
成员角色操作
-
-
- {member.name.charAt(0)} -
- {member.name} -
-
{member.role} - -
+ <> +
+
+
+
+ + handleFilterChange('keyword', e.target.value)} + /> +
+
+ + +
+
+
+ + +
-
+
+
+
成员列表
+ +
+
+ {filteredMembers.length > 0 ? ( + <> +
+ + + + + + + + + + {filteredMembers.map(member => ( + + + + + + ))} + +
成员角色操作
+
+
+ {member.name.charAt(0)} +
+ {member.name} +
+
{member.role} + + +
+
+
+
+
1
+
2
+
+
+ + ) : ( + } + message="暂无匹配成员" + description={filters.keyword || filters.role ? '当前筛选条件下没有成员' : '还没有添加任何项目成员'} + /> + )} +
+
+ + 确定要将成员"{removeTarget?.name}"移出项目吗? + + ); } diff --git a/src/pages/console/SkillDetailPage.jsx b/src/pages/console/SkillDetailPage.jsx index 6427ce4..477b042 100644 --- a/src/pages/console/SkillDetailPage.jsx +++ b/src/pages/console/SkillDetailPage.jsx @@ -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
Skill not found
; } + const handleSubscribeClick = () => { + if (subscribed) { + setShowUnsubModal(true); + } else { + setSubscribed(true); + } + }; + + const confirmUnsubscribe = () => { + setSubscribed(false); + setShowUnsubModal(false); + }; + + const cancelUnsubscribe = () => { + setShowUnsubModal(false); + }; + return ( <>
@@ -30,8 +52,8 @@ function SkillDetailPage({ skillId, onBack }) {
-
@@ -70,6 +92,15 @@ function SkillDetailPage({ skillId, onBack }) {
+ + 确定要取消订阅"{skill.name}"吗?取消后将无法使用该技能。 + ); } diff --git a/src/pages/console/SkillsPage.jsx b/src/pages/console/SkillsPage.jsx index 76b2fd0..b2d84f6 100644 --- a/src/pages/console/SkillsPage.jsx +++ b/src/pages/console/SkillsPage.jsx @@ -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 (
@@ -27,7 +30,10 @@ function SkillCard({ skill, onClick }) { {skill.rating}
-
@@ -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 ( <>
@@ -56,7 +90,13 @@ function SkillsPage({ onSkillClick }) {
- + setSearchQuery(e.target.value)} + />
@@ -85,15 +125,40 @@ function SkillsPage({ onSkillClick }) {
-
- {filteredSkills.map(skill => ( - onSkillClick(skill.id)} /> - ))} -
+ {searchedSkills.length > 0 ? ( +
+ {searchedSkills.map(skill => ( + onSkillClick(skill.id)} + onSubscribe={handleSubscribeClick} + /> + ))} +
+ ) : ( + } + message="暂无匹配技能" + description={searchQuery ? `未找到与"${searchQuery}"相关的技能` : '当前筛选条件下没有技能'} + /> + )} + + {modalTarget?.subscribed + ? `确定要取消订阅"${modalTarget?.name}"吗?取消后将无法使用该技能。` + : `确定要订阅"${modalTarget?.name}"吗?` + } + ); } diff --git a/src/pages/console/TasksPage.jsx b/src/pages/console/TasksPage.jsx index 4897c9c..ee15a86 100644 --- a/src/pages/console/TasksPage.jsx +++ b/src/pages/console/TasksPage.jsx @@ -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 ( -
-
-
定时任务
+ <> +
+
+
定时任务
+
+
+ {tasks.length > 0 ? ( + + + + + + + + + + + + + + {tasks.map(task => ( + + + + + + + + + + ))} + +
任务名称频率上次触发下次触发上次运行状态状态操作
{task.name}{task.frequency}{task.lastTriggered}{task.nextTrigger} + + {task.lastStatus} + + {task.enabled ? '启用' : '禁用'} + + + +
+ ) : ( + } + message="暂无定时任务" + description="还没有创建任何定时任务" + /> + )} +
-
- - - - - - - - - - - - - - {tasks.map(task => ( - - - - - - - - - - ))} - -
任务名称频率上次触发下次触发上次运行状态状态操作
{task.name}{task.frequency}{task.lastTriggered}{task.nextTrigger} - - {task.lastStatus} - - {task.enabled ? '启用' : '禁用'} - - - -
-
-
+ + 确定要删除任务"{deleteTarget?.name}"吗?此操作不可撤销。 + + ); } diff --git a/src/styles/global.scss b/src/styles/global.scss index 81a2039..461b102 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -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; +}