feat: 完善工作台展示场景 - 新增 Modal/Toast 组件、EmptyState 使用、确认弹窗、筛选分页
- 新增 Modal 确认弹窗组件和 Toast 消息提示组件 - 在 SkillsPage、LogsPage、TasksPage、ProjectsPage 使用 EmptyState - 为删除任务、取消订阅、移除成员、技能订阅添加确认弹窗 - 丰富聊天场景:代码展示、表格数据、多轮对话、错误提示 - 优化 ChatPage 布局,修复对话区域滚动问题 - 为 ProjectsPage 添加筛选卡片和分页组件 - 添加表单校验错误状态展示 - 同步 specs 到主目录
This commit is contained in:
125
README.md
125
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';
|
||||
|
||||
<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)
|
||||
- 代码架构重构:新增自定义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*
|
||||
|
||||
@@ -2,7 +2,7 @@ schema: spec-driven
|
||||
|
||||
context: |
|
||||
- 交流、文档、注释、提交信息使用中文,代码命名使用英文
|
||||
- 纯前端原型项目,无后端交互,供内部开发人员参考UI界面使用,目标在于展示页面布局、样式和组件能力
|
||||
- 纯前端展示原型项目(非功能原型),无后端交互,供内部开发人员参考UI界面使用,目标在于展示页面布局、样式和组件能力
|
||||
- 允许轻量级交互展示(如表单验证、弹框),状态展示策略:不重叠的状态通过静态数据驱动展示,重叠/覆盖类状态(弹框、下拉、抽屉等)允许简单交互切换
|
||||
- 示例数据应精心设计,展示不同的页面元素状态
|
||||
- 不引入UI库,使用当前SCSS样式方案
|
||||
|
||||
33
openspec/specs/chat-scenarios/spec.md
Normal file
33
openspec/specs/chat-scenarios/spec.md
Normal 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** 页面展示助手返回错误提示的对话内容
|
||||
37
openspec/specs/empty-state-display/spec.md
Normal file
37
openspec/specs/empty-state-display/spec.md
Normal 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 组件,显示"暂无匹配成员"提示
|
||||
39
openspec/specs/feedback-display/spec.md
Normal file
39
openspec/specs/feedback-display/spec.md
Normal 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** 页面顶部展示红色错误提示"操作失败,请重试"
|
||||
20
openspec/specs/form-validation-display/spec.md
Normal file
20
openspec/specs/form-validation-display/spec.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## Purpose
|
||||
|
||||
定义表单校验错误状态的展示规范。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 表单校验错误状态展示
|
||||
系统 SHALL 在表单中展示校验错误状态。
|
||||
|
||||
#### Scenario: 必填项为空
|
||||
- **WHEN** 用户在修改密码表单中不输入当前密码直接点击"更新密码"
|
||||
- **THEN** 当前密码输入框边框变红,下方显示"请输入当前密码"错误提示
|
||||
|
||||
#### Scenario: 邮箱格式错误
|
||||
- **WHEN** 用户在个人信息表单中输入无效邮箱格式
|
||||
- **THEN** 邮箱输入框边框变红,下方显示"请输入有效的邮箱地址"错误提示
|
||||
|
||||
#### Scenario: 密码不一致
|
||||
- **WHEN** 用户在修改密码表单中输入两次不同的新密码
|
||||
- **THEN** 确认密码输入框边框变红,下方显示"两次输入的密码不一致"错误提示
|
||||
27
src/components/common/Modal.jsx
Normal file
27
src/components/common/Modal.jsx
Normal 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;
|
||||
26
src/components/common/Toast.jsx
Normal file
26
src/components/common/Toast.jsx
Normal 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;
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user