feat: 完成多执行人系统及计划管理功能优化
This commit is contained in:
parent
4cfaf21b22
commit
ca6b010f59
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
80
DESIGN.md
Normal file
80
DESIGN.md
Normal file
@ -0,0 +1,80 @@
|
||||
## 1. 项目背景与战略目标
|
||||
在大型项目(如字节跳动内部项目)中,测试用例的管理需要兼顾**灵活性**与**规范性**。本项目致力于实现:
|
||||
- **测试管理与创建**:支持多场景(Web、移动端、API等)测试用例的创建与维护,确保全链路覆盖。
|
||||
- **场景规范化**:通过模板化和标准化流程,统一不同研发、测试人员对业务场景的定义,减少沟通误差。
|
||||
- **报告数据化**:全量采集执行结果,自动生成多维报表,驱动效率优化与质量分析。
|
||||
|
||||
## 2. 核心功能设计
|
||||
|
||||
### 2.1 脑图与表格双向联动
|
||||
- **思维导图视图**:用于梳理测试逻辑、功能模块和边界情况。支持快捷键(Enter 新增兄弟节点,Tab 新增子节点)。
|
||||
- **表格视图**:用于录入详细的“操作步骤”、“预期结果”和“优先级”。
|
||||
- **实时同步**:在脑图中修改节点名称,表格自动更新;在表格中增删行,脑图结构随之变化。
|
||||
|
||||
### 2.2 测试计划管理 (Test Plan)
|
||||
- **多类型支持**:支持创建不同类型的测试计划,包括**需求测试**、**回归测试**、**开发自测**、**冒烟测试**等。
|
||||
- **独立上下文**:每个测试计划可以关联不同的用例集,记录独立的执行状态和评审进度。
|
||||
- **一键拉群关联**:飞书拉群功能将直接关联到特定的测试计划,群内实时推送该计划的执行进度。
|
||||
|
||||
### 2.3 用例详情与属性 (Property Panel)
|
||||
- **步骤管理**:右侧面板支持录入详细的“操作步骤”与“预期结果”,支持动态增删。
|
||||
- **优先级标记**:支持 P0-P3 快速切换。
|
||||
- **评审记录**:支持记录节点的状态变化。
|
||||
|
||||
### 2.4 用例分级 (Priority Management)
|
||||
- **等级定义**:
|
||||
- **P0**:冒烟测试核心用例,阻塞性故障。
|
||||
- **P1**:主要功能路径。
|
||||
- **P2**:次要功能及边界。
|
||||
- **P3**:视觉、文案等细微问题。
|
||||
- **视觉呈现**:脑图节点支持标记 P0-P3 图标,表格支持按优先级过滤。
|
||||
|
||||
### 2.3 飞书(Feishu/Lark)一键拉群
|
||||
- **场景**:针对某个“测试计划”或“高优用例集”,点击按钮直接调起飞书 API 创建群聊。
|
||||
- **功能点**:
|
||||
- 自动邀请相关测试人员与开发人员。
|
||||
- 群名自动生成(例如:`【测试专项】XX项目_20240428`)。
|
||||
- 群内自动推送测试报告概览卡片。
|
||||
|
||||
### 2.4 用例评审与执行
|
||||
- **评审流**:支持节点标记“已通过”、“待修改”。
|
||||
- **执行模式**:进入执行态后,点击节点可快速标记 Pass/Fail,并自动记录执行人。
|
||||
|
||||
## 3. 技术架构方案
|
||||
|
||||
### 3.1 前端技术栈
|
||||
- **框架**:React + TypeScript (提供强类型支持,适配复杂逻辑)。
|
||||
- **样式**:Vanilla CSS + CSS Variables (打造极致的响应式与动态主题,如深色模式)。
|
||||
- **脑图引擎**:基于 `React Flow` 或自研 SVG 引擎,实现平滑的缩放与拖拽。
|
||||
- **状态管理**:`Zustand` (轻量级,适配频繁同步的脑图数据)。
|
||||
|
||||
### 3.2 数据结构设计
|
||||
用例以**树状结构**存储,每个节点包含:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"text": "登录功能",
|
||||
"priority": "P0",
|
||||
"children": [...],
|
||||
"steps": [
|
||||
{"action": "输入账号", "expected": "账号回显正确"}
|
||||
],
|
||||
"status": "pass" | "fail" | "blocked"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 界面交互设计 (Premium Aesthetics)
|
||||
- **配色**:采用字节跳动风格的“极客蓝”配合深色/亮色毛玻璃效果。
|
||||
- **动画**:使用 `Framer Motion` 实现视图切换时的平滑过渡。
|
||||
- **布局**:
|
||||
- 左侧:项目/目录树。
|
||||
- 顶部:工具栏(视图切换、飞书拉群、全局搜索)。
|
||||
- 中央:核心编辑器(脑图/表格)。
|
||||
- 右侧:属性面板(选中节点的详细信息、评审记录)。
|
||||
|
||||
## 5. 开发路线图
|
||||
1. **Phase 1**: 基础框架搭建,实现脑图与表格的渲染。
|
||||
2. **Phase 2**: 实现数据的本地/持久化存储及双向同步逻辑。
|
||||
3. **Phase 3**: 集成用例标级功能与右侧属性面板。
|
||||
4. **Phase 4**: 模拟飞书 API 集成与一键拉群交互。
|
||||
5. **Phase 5**: 整体视觉打磨与性能优化。
|
||||
BIN
backend/__pycache__/database.cpython-313.pyc
Normal file
BIN
backend/__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/feishu_client.cpython-313.pyc
Normal file
BIN
backend/__pycache__/feishu_client.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-313.pyc
Normal file
BIN
backend/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/models.cpython-313.pyc
Normal file
BIN
backend/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
40
backend/add_column_migration.py
Normal file
40
backend/add_column_migration.py
Normal file
@ -0,0 +1,40 @@
|
||||
import pymysql
|
||||
import os
|
||||
|
||||
DB_HOST = "mysql1.rdsmbk3ednsgnnt.rds.bj.baidubce.com"
|
||||
DB_PORT = 3306
|
||||
DB_NAME = "case_platform"
|
||||
DB_USER = "root_dev"
|
||||
DB_PASS = "Kdse89sd"
|
||||
|
||||
def run_migration():
|
||||
try:
|
||||
connection = pymysql.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASS,
|
||||
database=DB_NAME,
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
with connection.cursor() as cursor:
|
||||
# Check if assignees column exists in test_plans
|
||||
cursor.execute("SHOW COLUMNS FROM test_plans LIKE 'assignees'")
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
print("Adding 'assignees' column to 'test_plans' table...")
|
||||
cursor.execute("ALTER TABLE test_plans ADD COLUMN assignees JSON NULL AFTER case_ids")
|
||||
print("Column added successfully.")
|
||||
else:
|
||||
print("Column 'assignees' already exists in 'test_plans'.")
|
||||
|
||||
connection.commit()
|
||||
except Exception as e:
|
||||
print(f"Migration failed: {e}")
|
||||
finally:
|
||||
if 'connection' in locals():
|
||||
connection.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_migration()
|
||||
40
backend/add_column_migration_tasks.py
Normal file
40
backend/add_column_migration_tasks.py
Normal file
@ -0,0 +1,40 @@
|
||||
import pymysql
|
||||
import os
|
||||
|
||||
DB_HOST = "mysql1.rdsmbk3ednsgnnt.rds.bj.baidubce.com"
|
||||
DB_PORT = 3306
|
||||
DB_NAME = "case_platform"
|
||||
DB_USER = "root_dev"
|
||||
DB_PASS = "Kdse89sd"
|
||||
|
||||
def run_migration():
|
||||
try:
|
||||
connection = pymysql.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASS,
|
||||
database=DB_NAME,
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
with connection.cursor() as cursor:
|
||||
# Check if assignees column exists in test_tasks
|
||||
cursor.execute("SHOW COLUMNS FROM test_tasks LIKE 'assignees'")
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
print("Adding 'assignees' column to 'test_tasks' table...")
|
||||
cursor.execute("ALTER TABLE test_tasks ADD COLUMN assignees JSON NULL AFTER plan_id")
|
||||
print("Column added successfully.")
|
||||
else:
|
||||
print("Column 'assignees' already exists in 'test_tasks'.")
|
||||
|
||||
connection.commit()
|
||||
except Exception as e:
|
||||
print(f"Migration failed: {e}")
|
||||
finally:
|
||||
if 'connection' in locals():
|
||||
connection.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_migration()
|
||||
54
backend/database.py
Normal file
54
backend/database.py
Normal file
@ -0,0 +1,54 @@
|
||||
import os
|
||||
import pymysql
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
# ── 数据库配置 ──────────────────────────────────────────────────────────────
|
||||
DB_HOST = os.getenv("DB_HOST", "mysql1.rdsmbk3ednsgnnt.rds.bj.baidubce.com")
|
||||
DB_PORT = os.getenv("DB_PORT", "3306")
|
||||
DB_NAME = os.getenv("DB_NAME", "case_platform")
|
||||
DB_USER = os.getenv("DB_USER", "root_dev")
|
||||
DB_PASS = os.getenv("DB_PASS", "Kdse89sd")
|
||||
|
||||
MYSQL_URL = f"mysql+pymysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
|
||||
SQLITE_URL = "sqlite:///./quantum_test.db"
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# 尝试连接 MySQL
|
||||
engine = create_engine(
|
||||
MYSQL_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=1800,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
# 测试连接
|
||||
with engine.connect() as conn:
|
||||
pass
|
||||
print("✅ 成功连接到 MySQL (百度云 RDS)")
|
||||
return engine
|
||||
except Exception as e:
|
||||
print(f"⚠️ MySQL 连接失败 (IP白名单限制或凭证错误) - {e}")
|
||||
print("🔄 自动降级:使用本地 SQLite 数据库以保证应用正常运行...")
|
||||
engine = create_engine(
|
||||
SQLITE_URL,
|
||||
connect_args={"check_same_thread": False}
|
||||
)
|
||||
return engine
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
87
backend/dump_mysql.sql
Normal file
87
backend/dump_mysql.sql
Normal file
@ -0,0 +1,87 @@
|
||||
-- QuantumTest SQLite → MySQL 导出
|
||||
-- 生成时间: 2026-05-06 12:07:40
|
||||
-- 执行前请确保 case_platform 数据库已存在
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- ─── test_cases (60 条) ───────────────
|
||||
DELETE FROM `test_cases`;
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-001', 'TC-001', '账号密码正确登录成功', '用户管理', 'General', 'P2', 'Reviewed', 'UNTESTED', '张三', 'REQ-1001', NULL, '[{"action": "\u6253\u5f00\u767b\u5f55\u9875\u9762", "expected": "\u767b\u5f55\u9875\u6b63\u5e38\u5c55\u793a"}, {"action": "\u8f93\u5165\u6b63\u786e\u7684\u8d26\u53f7\u5bc6\u7801\u70b9\u51fb\u767b\u5f55", "expected": "\u767b\u5f55\u6210\u529f\u8df3\u8f6c\u5230\u9996\u9875"}]', '["\u5192\u70df", "\u6838\u5fc3\u6d41\u7a0b"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-002', 'TC-002', '手机验证码登录', '用户管理', 'Web', 'P0', 'Reviewed', 'PASS', '张三', 'REQ-1001', NULL, '[{"action": "\u8f93\u5165\u624b\u673a\u53f7\u83b7\u53d6\u9a8c\u8bc1\u7801", "expected": "\u9a8c\u8bc1\u7801\u53d1\u9001\u6210\u529f"}, {"action": "\u8f93\u5165\u6b63\u786e\u9a8c\u8bc1\u7801\u70b9\u51fb\u767b\u5f55", "expected": "\u767b\u5f55\u6210\u529f"}]', '["\u5192\u70df"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-003', 'TC-003', '第三方微信扫码登录', '用户管理', 'Web', 'P1', 'Reviewed', 'PASS', '李四', 'REQ-1002', NULL, '[{"action": "\u70b9\u51fb\u5fae\u4fe1\u767b\u5f55\u56fe\u6807", "expected": "\u5f39\u51fa\u4e8c\u7ef4\u7801"}, {"action": "\u624b\u673a\u626b\u7801\u786e\u8ba4", "expected": "\u767b\u5f55\u6210\u529f\u8df3\u8f6c\u9996\u9875"}]', '["\u9700\u6c42"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-004', 'TC-004', '密码错误登录失败提示', '用户管理', 'Web', 'P1', 'Reviewed', 'FAIL', '张三', NULL, 'BUG-001', '[{"action": "\u8f93\u5165\u6b63\u786e\u8d26\u53f7\u548c\u9519\u8bef\u5bc6\u7801", "expected": "\u63d0\u793a\u5bc6\u7801\u9519\u8bef"}, {"action": "\u8fde\u7eed5\u6b21\u9519\u8bef\u8f93\u5165", "expected": "\u8d26\u53f7\u88ab\u9501\u5b9a15\u5206\u949f"}]', '["\u56de\u5f52"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-005', 'TC-005', '登录状态过期自动跳转', '用户管理', 'Web', 'P2', 'Draft', 'UNTESTED', '张三', NULL, NULL, NULL, '["\u56de\u5f52"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-register', NULL, '注册功能', '用户管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-006', 'TC-006', '手机号注册新账号', '用户管理', 'Web', 'P0', 'Reviewed', 'PASS', '李四', 'REQ-1003', NULL, '[{"action": "\u8f93\u5165\u624b\u673a\u53f7\u83b7\u53d6\u9a8c\u8bc1\u7801", "expected": "\u9a8c\u8bc1\u7801\u53d1\u9001\u6210\u529f"}, {"action": "\u586b\u5199\u4fe1\u606f\u63d0\u4ea4\u6ce8\u518c", "expected": "\u6ce8\u518c\u6210\u529f\u81ea\u52a8\u767b\u5f55"}]', '["\u5192\u70df", "\u6838\u5fc3\u6d41\u7a0b"]', 'dir-register');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-007', 'TC-007', '邮箱注册新账号', '用户管理', 'Web', 'P1', 'Reviewed', 'PASS', '李四', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-register');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-008', 'TC-008', '重复手机号注册校验', '用户管理', 'Web', 'P1', 'Reviewed', 'FAIL', '李四', NULL, 'BUG-002', NULL, '["\u56de\u5f52"]', 'dir-register');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-perm', NULL, '权限管理', '用户管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-009', 'TC-009', '管理员角色权限验证', '用户管理', 'Web', 'P0', 'Reviewed', 'PASS', '王五', NULL, NULL, NULL, '["\u5192\u70df", "\u6743\u9650"]', 'dir-perm');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-010', 'TC-010', '普通用户越权访问拦截', '用户管理', 'API', 'P0', 'Reviewed', 'PASS', '王五', NULL, NULL, NULL, '["\u5b89\u5168", "\u56de\u5f52"]', 'dir-perm');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('mod-order', NULL, '订单管理模块', '订单管理', 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, '["\u6838\u5fc3\u6a21\u5757"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-create-order', NULL, '创建订单', '订单管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-011', 'TC-011', '正常商品下单流程', '订单管理', 'Web', 'P0', 'Reviewed', 'PASS', '赵六', 'REQ-2001', NULL, '[{"action": "\u9009\u62e9\u5546\u54c1\u52a0\u5165\u8d2d\u7269\u8f66", "expected": "\u5546\u54c1\u6dfb\u52a0\u6210\u529f"}, {"action": "\u70b9\u51fb\u7ed3\u7b97\u63d0\u4ea4\u8ba2\u5355", "expected": "\u8ba2\u5355\u521b\u5efa\u6210\u529f\u663e\u793a\u5f85\u652f\u4ed8"}]', '["\u5192\u70df", "\u6838\u5fc3\u6d41\u7a0b"]', 'dir-create-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-012', 'TC-012', '库存不足下单提示', '订单管理', 'Web', 'P1', 'Reviewed', 'PASS', '赵六', NULL, NULL, NULL, '["\u56de\u5f52"]', 'dir-create-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-013', 'TC-013', '优惠券叠加使用校验', '订单管理', 'Web', 'P2', 'Draft', 'UNTESTED', '赵六', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-create-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-payment', NULL, '支付流程', '订单管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-014', 'TC-014', '微信支付成功场景', '订单管理', 'Web', 'P0', 'Reviewed', 'PASS', '赵六', 'REQ-2002', NULL, '[{"action": "\u9009\u62e9\u5fae\u4fe1\u652f\u4ed8", "expected": "\u5524\u8d77\u5fae\u4fe1\u652f\u4ed8"}, {"action": "\u786e\u8ba4\u652f\u4ed8", "expected": "\u652f\u4ed8\u6210\u529f\u8df3\u8f6c\u8ba2\u5355\u8be6\u60c5"}]', '["\u5192\u70df", "\u6838\u5fc3\u6d41\u7a0b"]', 'dir-payment');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-015', 'TC-015', '支付宝支付成功场景', '订单管理', 'Web', 'P0', 'Reviewed', 'PASS', '赵六', NULL, NULL, NULL, '["\u5192\u70df"]', 'dir-payment');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-016', 'TC-016', '余额不足支付失败', '订单管理', 'Web', 'P1', 'Reviewed', 'FAIL', '张三', NULL, 'BUG-003', NULL, '["\u56de\u5f52"]', 'dir-payment');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-017', 'TC-017', '支付超时自动取消订单', '订单管理', 'Web', 'P2', 'PendingReview', 'UNTESTED', '张三', NULL, NULL, NULL, '["\u56de\u5f52"]', 'dir-payment');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-refund', NULL, '退款流程', '订单管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-018', 'TC-018', '已支付订单申请退款', '订单管理', 'Web', 'P0', 'Reviewed', 'PASS', '李四', 'REQ-2003', NULL, '[{"action": "\u8fdb\u5165\u8ba2\u5355\u8be6\u60c5\u70b9\u51fb\u7533\u8bf7\u9000\u6b3e", "expected": "\u9000\u6b3e\u7533\u8bf7\u63d0\u4ea4\u6210\u529f"}, {"action": "\u5ba1\u6838\u901a\u8fc7", "expected": "\u9000\u6b3e\u5230\u8d26\u901a\u77e5"}]', '["\u5192\u70df", "\u6838\u5fc3\u6d41\u7a0b"]', 'dir-refund');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-019', 'TC-019', '部分退款金额校验', '订单管理', 'Web', 'P1', 'Reviewed', 'UNTESTED', '李四', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-refund');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('mod-product', NULL, '商品管理模块', '商品管理', 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, '["\u6838\u5fc3\u6a21\u5757"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-search', NULL, '商品搜索', '商品管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-product');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-020', 'TC-020', '关键词精确搜索商品', '商品管理', 'Web', 'P0', 'Reviewed', 'PASS', '王五', NULL, NULL, '[{"action": "\u8f93\u5165\u5546\u54c1\u540d\u79f0\u641c\u7d22", "expected": "\u8fd4\u56de\u5339\u914d\u7684\u5546\u54c1\u5217\u8868"}]', '["\u5192\u70df"]', 'dir-search');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-021', 'TC-021', '筛选条件组合搜索', '商品管理', 'Web', 'P1', 'Reviewed', 'PASS', '王五', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-search');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-022', 'TC-022', '搜索无结果页面展示', '商品管理', 'Web', 'P2', 'Draft', 'UNTESTED', '王五', NULL, NULL, NULL, '["\u56de\u5f52"]', 'dir-search');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-product-detail', NULL, '商品详情', '商品管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-product');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-023', 'TC-023', '商品详情页信息展示', '商品管理', 'Web', 'P0', 'Reviewed', 'PASS', '王五', NULL, NULL, NULL, '["\u5192\u70df"]', 'dir-product-detail');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-024', 'TC-024', '商品评价列表分页', '商品管理', 'Web', 'P2', 'PendingReview', 'UNTESTED', '王五', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-product-detail');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('mod-settings', NULL, '系统设置模块', '系统设置', 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, '["\u57fa\u7840\u6a21\u5757"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-notification', NULL, '通知配置', '系统设置', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-settings');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-025', 'TC-025', '站内消息通知开关', '系统设置', 'Web', 'P2', 'Draft', 'UNTESTED', '赵六', NULL, NULL, NULL, '["\u81ea\u6d4b"]', 'dir-notification');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-026', 'TC-026', '邮件通知模板配置', '系统设置', 'Web', 'P3', 'Draft', 'UNTESTED', '赵六', NULL, NULL, NULL, '["\u81ea\u6d4b"]', 'dir-notification');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-profile', NULL, '个人中心', '系统设置', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-settings');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-027', 'TC-027', '个人信息修改保存', '系统设置', 'Web', 'P1', 'Reviewed', 'PASS', '李四', NULL, NULL, NULL, '["\u81ea\u6d4b"]', 'dir-profile');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-028', 'TC-028', '头像上传与裁剪', '系统设置', 'Web', 'P2', 'PendingReview', 'UNTESTED', '李四', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-profile');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777446852656', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'tc-001');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777447096599', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777447100961', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777447219669', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777449733175', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777450666625', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'tc-001');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777450667553', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'node-1777450666625');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777450723141', NULL, 'fgfggs', NULL, 'General', 'P2', 'Draft', 'PASS', NULL, NULL, NULL, NULL, NULL, 'tc-010');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517187752', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517188135', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517188637', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517189036', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517189422', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517189798', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517190165', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517190507', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517190802', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517191001', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517191191', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517191362', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- ─── test_plans (1 条) ───────────────
|
||||
DELETE FROM `test_plans`;
|
||||
INSERT INTO `test_plans` (`id`, `name`, `type`, `case_ids`, `assignee`, `created_at`) VALUES ('p-1778039425608', '1111', 'Requirement', '["tc-002", "tc-006", "tc-009", "tc-010", "node-1777450723141", "tc-011", "tc-014", "tc-015", "tc-018", "tc-020", "tc-023"]', 'ou_9128e3d3d9a4526f6a41bf84dae9c523', '2026-05-06');
|
||||
|
||||
-- ─── test_tasks (6 条) ───────────────
|
||||
DELETE FROM `test_tasks`;
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('task-smoke-1', 'v3.0 核心流程冒烟测试', 'RUNNING', 'plan-smoke', '张三', '2026-04-29T12:09:40.318533');
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('task-reg-1', 'v3.0 全模块回归测试', 'PENDING', 'plan-regression', '李四', '2026-04-29T12:09:40.318533');
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('task-req-1', 'REQ-2001 订单模块需求测试', 'RUNNING', 'plan-requirement', '赵六', '2026-04-29T12:09:40.318533');
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('task-self-1', '系统设置模块自测', 'COMPLETED', 'plan-selftest', '王五', '2026-04-29T12:09:40.318533');
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('fb7c9029-06d9-46df-8113-e7c67e69ee2c', '【执行任务】v3.0 ', 'PENDING', 'p-1777445018915', '张三', '2026-04-29T14:43:38.986667');
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('c1b2a896-1091-4905-8ddd-c2eaa3e424e3', '【执行任务】1111', 'COMPLETED', 'p-1778039425608', 'ou_9128e3d3d9a4526f6a41bf84dae9c523', '2026-05-06T11:50:25.625136');
|
||||
|
||||
-- ─── bugs (0 条) ───────────────
|
||||
DELETE FROM `bugs`;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
97
backend/dump_mysql_full.sql
Normal file
97
backend/dump_mysql_full.sql
Normal file
@ -0,0 +1,97 @@
|
||||
-- QuantumTest SQLite → MySQL 全量导出(建表 + 数据)
|
||||
-- 生成时间: 2026-05-06 12:07:40
|
||||
-- 执行前请确保 case_platform 数据库已存在
|
||||
|
||||
-- QuantumTest 表结构 DDL (MySQL 8.0+)
|
||||
-- 生成时间: 2026-05-06
|
||||
-- 执行前请确保已创建数据库: CREATE DATABASE IF NOT EXISTS case_platform DEFAULT CHARACTER SET utf8mb4;
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
|
||||
-- ═══════════════════════════════════════
|
||||
-- 数据
|
||||
-- ═══════════════════════════════════════
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- ─── test_cases (60 条) ───────────────
|
||||
DELETE FROM `test_cases`;
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-001', 'TC-001', '账号密码正确登录成功', '用户管理', 'General', 'P2', 'Reviewed', 'UNTESTED', '张三', 'REQ-1001', NULL, '[{"action": "\u6253\u5f00\u767b\u5f55\u9875\u9762", "expected": "\u767b\u5f55\u9875\u6b63\u5e38\u5c55\u793a"}, {"action": "\u8f93\u5165\u6b63\u786e\u7684\u8d26\u53f7\u5bc6\u7801\u70b9\u51fb\u767b\u5f55", "expected": "\u767b\u5f55\u6210\u529f\u8df3\u8f6c\u5230\u9996\u9875"}]', '["\u5192\u70df", "\u6838\u5fc3\u6d41\u7a0b"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-002', 'TC-002', '手机验证码登录', '用户管理', 'Web', 'P0', 'Reviewed', 'PASS', '张三', 'REQ-1001', NULL, '[{"action": "\u8f93\u5165\u624b\u673a\u53f7\u83b7\u53d6\u9a8c\u8bc1\u7801", "expected": "\u9a8c\u8bc1\u7801\u53d1\u9001\u6210\u529f"}, {"action": "\u8f93\u5165\u6b63\u786e\u9a8c\u8bc1\u7801\u70b9\u51fb\u767b\u5f55", "expected": "\u767b\u5f55\u6210\u529f"}]', '["\u5192\u70df"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-003', 'TC-003', '第三方微信扫码登录', '用户管理', 'Web', 'P1', 'Reviewed', 'PASS', '李四', 'REQ-1002', NULL, '[{"action": "\u70b9\u51fb\u5fae\u4fe1\u767b\u5f55\u56fe\u6807", "expected": "\u5f39\u51fa\u4e8c\u7ef4\u7801"}, {"action": "\u624b\u673a\u626b\u7801\u786e\u8ba4", "expected": "\u767b\u5f55\u6210\u529f\u8df3\u8f6c\u9996\u9875"}]', '["\u9700\u6c42"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-004', 'TC-004', '密码错误登录失败提示', '用户管理', 'Web', 'P1', 'Reviewed', 'FAIL', '张三', NULL, 'BUG-001', '[{"action": "\u8f93\u5165\u6b63\u786e\u8d26\u53f7\u548c\u9519\u8bef\u5bc6\u7801", "expected": "\u63d0\u793a\u5bc6\u7801\u9519\u8bef"}, {"action": "\u8fde\u7eed5\u6b21\u9519\u8bef\u8f93\u5165", "expected": "\u8d26\u53f7\u88ab\u9501\u5b9a15\u5206\u949f"}]', '["\u56de\u5f52"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-005', 'TC-005', '登录状态过期自动跳转', '用户管理', 'Web', 'P2', 'Draft', 'UNTESTED', '张三', NULL, NULL, NULL, '["\u56de\u5f52"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-register', NULL, '注册功能', '用户管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-006', 'TC-006', '手机号注册新账号', '用户管理', 'Web', 'P0', 'Reviewed', 'PASS', '李四', 'REQ-1003', NULL, '[{"action": "\u8f93\u5165\u624b\u673a\u53f7\u83b7\u53d6\u9a8c\u8bc1\u7801", "expected": "\u9a8c\u8bc1\u7801\u53d1\u9001\u6210\u529f"}, {"action": "\u586b\u5199\u4fe1\u606f\u63d0\u4ea4\u6ce8\u518c", "expected": "\u6ce8\u518c\u6210\u529f\u81ea\u52a8\u767b\u5f55"}]', '["\u5192\u70df", "\u6838\u5fc3\u6d41\u7a0b"]', 'dir-register');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-007', 'TC-007', '邮箱注册新账号', '用户管理', 'Web', 'P1', 'Reviewed', 'PASS', '李四', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-register');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-008', 'TC-008', '重复手机号注册校验', '用户管理', 'Web', 'P1', 'Reviewed', 'FAIL', '李四', NULL, 'BUG-002', NULL, '["\u56de\u5f52"]', 'dir-register');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-perm', NULL, '权限管理', '用户管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-009', 'TC-009', '管理员角色权限验证', '用户管理', 'Web', 'P0', 'Reviewed', 'PASS', '王五', NULL, NULL, NULL, '["\u5192\u70df", "\u6743\u9650"]', 'dir-perm');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-010', 'TC-010', '普通用户越权访问拦截', '用户管理', 'API', 'P0', 'Reviewed', 'PASS', '王五', NULL, NULL, NULL, '["\u5b89\u5168", "\u56de\u5f52"]', 'dir-perm');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('mod-order', NULL, '订单管理模块', '订单管理', 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, '["\u6838\u5fc3\u6a21\u5757"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-create-order', NULL, '创建订单', '订单管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-011', 'TC-011', '正常商品下单流程', '订单管理', 'Web', 'P0', 'Reviewed', 'PASS', '赵六', 'REQ-2001', NULL, '[{"action": "\u9009\u62e9\u5546\u54c1\u52a0\u5165\u8d2d\u7269\u8f66", "expected": "\u5546\u54c1\u6dfb\u52a0\u6210\u529f"}, {"action": "\u70b9\u51fb\u7ed3\u7b97\u63d0\u4ea4\u8ba2\u5355", "expected": "\u8ba2\u5355\u521b\u5efa\u6210\u529f\u663e\u793a\u5f85\u652f\u4ed8"}]', '["\u5192\u70df", "\u6838\u5fc3\u6d41\u7a0b"]', 'dir-create-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-012', 'TC-012', '库存不足下单提示', '订单管理', 'Web', 'P1', 'Reviewed', 'PASS', '赵六', NULL, NULL, NULL, '["\u56de\u5f52"]', 'dir-create-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-013', 'TC-013', '优惠券叠加使用校验', '订单管理', 'Web', 'P2', 'Draft', 'UNTESTED', '赵六', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-create-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-payment', NULL, '支付流程', '订单管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-014', 'TC-014', '微信支付成功场景', '订单管理', 'Web', 'P0', 'Reviewed', 'PASS', '赵六', 'REQ-2002', NULL, '[{"action": "\u9009\u62e9\u5fae\u4fe1\u652f\u4ed8", "expected": "\u5524\u8d77\u5fae\u4fe1\u652f\u4ed8"}, {"action": "\u786e\u8ba4\u652f\u4ed8", "expected": "\u652f\u4ed8\u6210\u529f\u8df3\u8f6c\u8ba2\u5355\u8be6\u60c5"}]', '["\u5192\u70df", "\u6838\u5fc3\u6d41\u7a0b"]', 'dir-payment');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-015', 'TC-015', '支付宝支付成功场景', '订单管理', 'Web', 'P0', 'Reviewed', 'PASS', '赵六', NULL, NULL, NULL, '["\u5192\u70df"]', 'dir-payment');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-016', 'TC-016', '余额不足支付失败', '订单管理', 'Web', 'P1', 'Reviewed', 'FAIL', '张三', NULL, 'BUG-003', NULL, '["\u56de\u5f52"]', 'dir-payment');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-017', 'TC-017', '支付超时自动取消订单', '订单管理', 'Web', 'P2', 'PendingReview', 'UNTESTED', '张三', NULL, NULL, NULL, '["\u56de\u5f52"]', 'dir-payment');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-refund', NULL, '退款流程', '订单管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-order');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-018', 'TC-018', '已支付订单申请退款', '订单管理', 'Web', 'P0', 'Reviewed', 'PASS', '李四', 'REQ-2003', NULL, '[{"action": "\u8fdb\u5165\u8ba2\u5355\u8be6\u60c5\u70b9\u51fb\u7533\u8bf7\u9000\u6b3e", "expected": "\u9000\u6b3e\u7533\u8bf7\u63d0\u4ea4\u6210\u529f"}, {"action": "\u5ba1\u6838\u901a\u8fc7", "expected": "\u9000\u6b3e\u5230\u8d26\u901a\u77e5"}]', '["\u5192\u70df", "\u6838\u5fc3\u6d41\u7a0b"]', 'dir-refund');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-019', 'TC-019', '部分退款金额校验', '订单管理', 'Web', 'P1', 'Reviewed', 'UNTESTED', '李四', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-refund');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('mod-product', NULL, '商品管理模块', '商品管理', 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, '["\u6838\u5fc3\u6a21\u5757"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-search', NULL, '商品搜索', '商品管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-product');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-020', 'TC-020', '关键词精确搜索商品', '商品管理', 'Web', 'P0', 'Reviewed', 'PASS', '王五', NULL, NULL, '[{"action": "\u8f93\u5165\u5546\u54c1\u540d\u79f0\u641c\u7d22", "expected": "\u8fd4\u56de\u5339\u914d\u7684\u5546\u54c1\u5217\u8868"}]', '["\u5192\u70df"]', 'dir-search');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-021', 'TC-021', '筛选条件组合搜索', '商品管理', 'Web', 'P1', 'Reviewed', 'PASS', '王五', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-search');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-022', 'TC-022', '搜索无结果页面展示', '商品管理', 'Web', 'P2', 'Draft', 'UNTESTED', '王五', NULL, NULL, NULL, '["\u56de\u5f52"]', 'dir-search');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-product-detail', NULL, '商品详情', '商品管理', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-product');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-023', 'TC-023', '商品详情页信息展示', '商品管理', 'Web', 'P0', 'Reviewed', 'PASS', '王五', NULL, NULL, NULL, '["\u5192\u70df"]', 'dir-product-detail');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-024', 'TC-024', '商品评价列表分页', '商品管理', 'Web', 'P2', 'PendingReview', 'UNTESTED', '王五', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-product-detail');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('mod-settings', NULL, '系统设置模块', '系统设置', 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, '["\u57fa\u7840\u6a21\u5757"]', NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-notification', NULL, '通知配置', '系统设置', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-settings');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-025', 'TC-025', '站内消息通知开关', '系统设置', 'Web', 'P2', 'Draft', 'UNTESTED', '赵六', NULL, NULL, NULL, '["\u81ea\u6d4b"]', 'dir-notification');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-026', 'TC-026', '邮件通知模板配置', '系统设置', 'Web', 'P3', 'Draft', 'UNTESTED', '赵六', NULL, NULL, NULL, '["\u81ea\u6d4b"]', 'dir-notification');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('dir-profile', NULL, '个人中心', '系统设置', 'Web', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'mod-settings');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-027', 'TC-027', '个人信息修改保存', '系统设置', 'Web', 'P1', 'Reviewed', 'PASS', '李四', NULL, NULL, NULL, '["\u81ea\u6d4b"]', 'dir-profile');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('tc-028', 'TC-028', '头像上传与裁剪', '系统设置', 'Web', 'P2', 'PendingReview', 'UNTESTED', '李四', NULL, NULL, NULL, '["\u9700\u6c42"]', 'dir-profile');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777446852656', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'tc-001');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777447096599', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777447100961', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777447219669', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777449733175', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777450666625', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'tc-001');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777450667553', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, 'node-1777450666625');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777450723141', NULL, 'fgfggs', NULL, 'General', 'P2', 'Draft', 'PASS', NULL, NULL, NULL, NULL, NULL, 'tc-010');
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517187752', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517188135', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517188637', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517189036', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517189422', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517189798', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517190165', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517190507', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517190802', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517191001', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517191191', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `test_cases` (`id`, `case_id`, `text`, `module`, `type`, `priority`, `review_status`, `execution_status`, `maintainer`, `requirement_id`, `bug_id`, `steps`, `tags`, `parent_id`) VALUES ('node-1777517191362', NULL, '新建用例', NULL, 'General', 'P2', 'Draft', 'UNTESTED', NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- ─── test_plans (1 条) ───────────────
|
||||
DELETE FROM `test_plans`;
|
||||
INSERT INTO `test_plans` (`id`, `name`, `type`, `case_ids`, `assignee`, `created_at`) VALUES ('p-1778039425608', '1111', 'Requirement', '["tc-002", "tc-006", "tc-009", "tc-010", "node-1777450723141", "tc-011", "tc-014", "tc-015", "tc-018", "tc-020", "tc-023"]', 'ou_9128e3d3d9a4526f6a41bf84dae9c523', '2026-05-06');
|
||||
|
||||
-- ─── test_tasks (6 条) ───────────────
|
||||
DELETE FROM `test_tasks`;
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('task-smoke-1', 'v3.0 核心流程冒烟测试', 'RUNNING', 'plan-smoke', '张三', '2026-04-29T12:09:40.318533');
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('task-reg-1', 'v3.0 全模块回归测试', 'PENDING', 'plan-regression', '李四', '2026-04-29T12:09:40.318533');
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('task-req-1', 'REQ-2001 订单模块需求测试', 'RUNNING', 'plan-requirement', '赵六', '2026-04-29T12:09:40.318533');
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('task-self-1', '系统设置模块自测', 'COMPLETED', 'plan-selftest', '王五', '2026-04-29T12:09:40.318533');
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('fb7c9029-06d9-46df-8113-e7c67e69ee2c', '【执行任务】v3.0 ', 'PENDING', 'p-1777445018915', '张三', '2026-04-29T14:43:38.986667');
|
||||
INSERT INTO `test_tasks` (`id`, `name`, `status`, `plan_id`, `assignee`, `created_at`) VALUES ('c1b2a896-1091-4905-8ddd-c2eaa3e424e3', '【执行任务】1111', 'COMPLETED', 'p-1778039425608', 'ou_9128e3d3d9a4526f6a41bf84dae9c523', '2026-05-06T11:50:25.625136');
|
||||
|
||||
-- ─── bugs (0 条) ───────────────
|
||||
DELETE FROM `bugs`;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
112
backend/feishu_client.py
Normal file
112
backend/feishu_client.py
Normal file
@ -0,0 +1,112 @@
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
|
||||
class FeishuClient:
|
||||
def __init__(self, app_id, app_secret):
|
||||
self.app_id = app_id
|
||||
self.app_secret = app_secret
|
||||
self.base_url = "https://open.feishu.cn/open-apis"
|
||||
self._app_access_token = None
|
||||
|
||||
def get_token(self):
|
||||
url = f"{self.base_url}/auth/v3/app_access_token/internal"
|
||||
payload = {
|
||||
"app_id": self.app_id,
|
||||
"app_secret": self.app_secret
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._app_access_token = data.get("app_access_token")
|
||||
return self._app_access_token
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to get Feishu token: {e}")
|
||||
return None
|
||||
|
||||
def send_message(self, receive_id_type, receive_id, msg_type, content):
|
||||
token = self.get_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/im/v1/messages?receive_id_type={receive_id_type}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"receive_id": receive_id,
|
||||
"msg_type": msg_type,
|
||||
"content": json.dumps(content) if isinstance(content, dict) else content
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=headers)
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send Feishu message: {e}")
|
||||
return None
|
||||
|
||||
def create_group(self, name, description, user_ids):
|
||||
"""
|
||||
Creates a group (chat) and adds users in one call.
|
||||
user_ids should be a list of open_ids.
|
||||
"""
|
||||
token = self.get_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/im/v1/chats"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"user_id_list": user_ids
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=headers)
|
||||
result = resp.json()
|
||||
print(f"DEBUG create_group response: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to create Feishu group: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_user_ids_by_emails(self, emails):
|
||||
"""
|
||||
Retrieves open_ids for a list of emails.
|
||||
"""
|
||||
token = self.get_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/contact/v3/users/batch_get_id"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"emails": emails
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=headers)
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to fetch user IDs: {e}")
|
||||
return None
|
||||
|
||||
def send_card(self, receive_id, card_data):
|
||||
|
||||
"""
|
||||
Sends an interactive card message.
|
||||
"""
|
||||
return self.send_message(
|
||||
receive_id_type="chat_id" if receive_id.startswith("oc_") else "open_id",
|
||||
receive_id=receive_id,
|
||||
msg_type="interactive",
|
||||
content=card_data
|
||||
)
|
||||
22
backend/head.md
Normal file
22
backend/head.md
Normal file
@ -0,0 +1,22 @@
|
||||
孙悦:yue02.sun@d-robotics.cc
|
||||
侯志勇:zhiyong.hou@d-robotics.cc
|
||||
刘亚琼:yaqiong.liu@d-robotics.cc
|
||||
杜凯:kai.du@d-robotics.cc
|
||||
路强:qiang01.lu@d-robotics.cc
|
||||
张家浩:jiahao01.zhang@d-robotics.cc
|
||||
赵晗:han.zhao@d-robotics.cc
|
||||
陈适:shi.chen@d-robotics.cc
|
||||
付程玲:chengling.fu@d-robotics.cc
|
||||
杨祎伟:yiwei.yang@d-robotics.cc
|
||||
杨磊:lei01.yang@d-robotics.cc
|
||||
辛海丹:haidan.xin@d-robotics.cc
|
||||
圣正杰:zhengjie.sheng@d-robotics.cc
|
||||
汪明:ming01.wang@d-robotics.cc
|
||||
王斌:bin02.wang@d-robotics.cc
|
||||
李莹:ying01.li@d-robotics.cc
|
||||
周天如:tianru.zhou@d-robotics.cc
|
||||
吴龙涛:longtao.wu@d-robotics.cc
|
||||
吴超:chao01.wu@d-robotics.cc
|
||||
郝建新:jianxin.hao@d-robotics.cc
|
||||
董红帅:hongshuai.dong@d-robotics.cc
|
||||
陈钦洋:qinyang.chen@d-robotics.cc
|
||||
664
backend/main.py
Normal file
664
backend/main.py
Normal file
@ -0,0 +1,664 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Any
|
||||
from pydantic import BaseModel
|
||||
import uuid, datetime
|
||||
|
||||
import models
|
||||
from database import engine, get_db
|
||||
from feishu_client import FeishuClient
|
||||
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Initialize Feishu Client
|
||||
FEISHU_APP_ID = "cli_a9aeb4fb2c78dcb6"
|
||||
FEISHU_APP_SECRET = "7nYs724srjEn4jgNPJW9cfuqL4e2OVT6"
|
||||
feishu = FeishuClient(FEISHU_APP_ID, FEISHU_APP_SECRET)
|
||||
|
||||
|
||||
app = FastAPI(title="QuantumTest Backend")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Pydantic Schemas
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
class TestCaseSchema(BaseModel):
|
||||
id: Optional[str] = None
|
||||
caseId: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
module: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
reviewStatus: Optional[str] = None
|
||||
executionStatus: Optional[str] = None
|
||||
maintainer: Optional[str] = None
|
||||
requirementId: Optional[str] = None
|
||||
bugId: Optional[str] = None
|
||||
parentId: Optional[str] = None
|
||||
steps: Optional[list] = None
|
||||
tags: Optional[List[str]] = None
|
||||
reviewers: Optional[List[str]] = None
|
||||
children: Optional[List[Any]] = []
|
||||
|
||||
|
||||
|
||||
class TaskSchema(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: str
|
||||
status: Optional[str] = "PENDING"
|
||||
planId: Optional[str] = None
|
||||
assignees: Optional[List[str]] = []
|
||||
|
||||
|
||||
|
||||
class TaskStatusUpdate(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class TestPlanSchema(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: str
|
||||
type: str
|
||||
caseIds: Optional[List[str]] = []
|
||||
assignees: Optional[List[str]] = []
|
||||
createdAt: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
class BugSchema(BaseModel):
|
||||
id: Optional[str] = None
|
||||
title: str
|
||||
status: Optional[str] = "OPEN"
|
||||
caseId: Optional[str] = None
|
||||
|
||||
class BatchReviewSchema(BaseModel):
|
||||
caseIds: List[str]
|
||||
reviewerOpenId: str
|
||||
moduleName: Optional[str] = None
|
||||
message: Optional[str] = "请协助评审以下测试用例"
|
||||
|
||||
class BatchUpdateSchema(BaseModel):
|
||||
caseIds: List[str]
|
||||
maintainer: Optional[str] = None
|
||||
reviewers: Optional[List[str]] = None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class SpaceSchema(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: str
|
||||
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Health check
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "QuantumTest API is running", "version": "2.0"}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Test Cases
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _build_tree(cases):
|
||||
case_dict = {}
|
||||
for c in cases:
|
||||
case_dict[c.id] = {
|
||||
"id": c.id,
|
||||
"caseId": c.case_id,
|
||||
"text": c.text,
|
||||
"module": c.module,
|
||||
"type": c.type,
|
||||
"priority": c.priority,
|
||||
"reviewStatus": c.review_status,
|
||||
"executionStatus": c.execution_status,
|
||||
"maintainer": c.maintainer,
|
||||
"requirementId": c.requirement_id,
|
||||
"bugId": c.bug_id,
|
||||
"steps": c.steps,
|
||||
"tags": c.tags,
|
||||
"reviewers": c.reviewers or [],
|
||||
"parentId": c.parent_id,
|
||||
"children": [],
|
||||
|
||||
}
|
||||
root_nodes = []
|
||||
for c in cases:
|
||||
if c.parent_id and c.parent_id in case_dict:
|
||||
case_dict[c.parent_id]["children"].append(case_dict[c.id])
|
||||
else:
|
||||
root_nodes.append(case_dict[c.id])
|
||||
return root_nodes
|
||||
|
||||
|
||||
@app.get("/api/cases")
|
||||
def get_cases(space_id: str, db: Session = Depends(get_db)):
|
||||
cases = db.query(models.TestCase).filter(models.TestCase.space_id == space_id).all()
|
||||
return {"data": _build_tree(cases)}
|
||||
|
||||
|
||||
@app.post("/api/cases")
|
||||
def create_case(case: TestCaseSchema, space_id: str, db: Session = Depends(get_db)):
|
||||
existing = db.query(models.TestCase).filter(models.TestCase.id == case.id).first()
|
||||
if existing:
|
||||
return {"message": "already exists", "id": case.id}
|
||||
db_case = models.TestCase(
|
||||
id=case.id,
|
||||
case_id=case.caseId,
|
||||
text=case.text or "新建用例",
|
||||
module=case.module,
|
||||
type=case.type,
|
||||
priority=case.priority,
|
||||
review_status=case.reviewStatus,
|
||||
execution_status=case.executionStatus,
|
||||
maintainer=case.maintainer,
|
||||
requirement_id=case.requirementId,
|
||||
bug_id=case.bugId,
|
||||
parent_id=case.parentId,
|
||||
space_id=space_id,
|
||||
steps=case.steps,
|
||||
tags=case.tags,
|
||||
reviewers=case.reviewers,
|
||||
)
|
||||
|
||||
db.add(db_case)
|
||||
db.commit()
|
||||
return {"message": "success", "id": db_case.id}
|
||||
|
||||
|
||||
|
||||
@app.post("/api/cases/batch")
|
||||
def batch_create_cases(cases: List[TestCaseSchema], space_id: str, db: Session = Depends(get_db)):
|
||||
for case in cases:
|
||||
db_case = models.TestCase(
|
||||
id=case.id,
|
||||
case_id=case.caseId,
|
||||
text=case.text or "新建用例",
|
||||
module=case.module,
|
||||
type=case.type,
|
||||
priority=case.priority,
|
||||
review_status=case.reviewStatus,
|
||||
execution_status=case.executionStatus,
|
||||
maintainer=case.maintainer,
|
||||
requirement_id=case.requirementId,
|
||||
bug_id=case.bugId,
|
||||
parent_id=case.parentId,
|
||||
space_id=space_id,
|
||||
steps=case.steps,
|
||||
tags=case.tags,
|
||||
reviewers=case.reviewers,
|
||||
)
|
||||
|
||||
db.add(db_case)
|
||||
db.commit()
|
||||
return {"message": "success", "count": len(cases)}
|
||||
|
||||
|
||||
@app.post("/api/cases/batch")
|
||||
async def batch_update_cases(data: BatchUpdateSchema, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Batch update multiple cases (maintainer and/or reviewers).
|
||||
"""
|
||||
try:
|
||||
cases = db.query(TestCase).filter(TestCase.id.in_(data.caseIds)).all()
|
||||
for c in cases:
|
||||
if data.maintainer is not None:
|
||||
c.maintainer = data.maintainer
|
||||
if data.reviewers is not None:
|
||||
c.reviewers = data.reviewers
|
||||
db.commit()
|
||||
return {"message": "success", "count": len(cases)}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.put("/api/cases/{case_id}")
|
||||
|
||||
|
||||
def update_case(case_id: str, case: TestCaseSchema, db: Session = Depends(get_db)):
|
||||
print(f"DEBUG: Updating case {case_id} with data: {case.model_dump(exclude_unset=True)}")
|
||||
db_case = db.query(models.TestCase).filter(models.TestCase.id == case_id).first()
|
||||
|
||||
if not db_case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
if case.text is not None: db_case.text = case.text
|
||||
if case.caseId is not None: db_case.case_id = case.caseId
|
||||
if case.module is not None: db_case.module = case.module
|
||||
if case.type is not None: db_case.type = case.type
|
||||
if case.priority is not None: db_case.priority = case.priority
|
||||
if case.reviewStatus is not None: db_case.review_status = case.reviewStatus
|
||||
if case.executionStatus is not None: db_case.execution_status = case.executionStatus
|
||||
if case.maintainer is not None: db_case.maintainer = case.maintainer
|
||||
if case.requirementId is not None: db_case.requirement_id = case.requirementId
|
||||
if case.bugId is not None: db_case.bug_id = case.bugId
|
||||
if case.steps is not None: db_case.steps = case.steps
|
||||
if case.tags is not None: db_case.tags = case.tags
|
||||
if case.reviewers is not None: db_case.reviewers = case.reviewers
|
||||
db.commit()
|
||||
|
||||
return {"message": "success"}
|
||||
|
||||
|
||||
@app.delete("/api/cases/{case_id}")
|
||||
def delete_case(case_id: str, db: Session = Depends(get_db)):
|
||||
# Recursively collect all descendant IDs
|
||||
def collect_ids(cid):
|
||||
ids = [cid]
|
||||
children = db.query(models.TestCase).filter(models.TestCase.parent_id == cid).all()
|
||||
for child in children:
|
||||
ids.extend(collect_ids(child.id))
|
||||
return ids
|
||||
|
||||
all_ids = collect_ids(case_id)
|
||||
db.query(models.TestCase).filter(models.TestCase.id.in_(all_ids)).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return {"message": "success", "deleted": len(all_ids)}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Test Plans
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/plans")
|
||||
def get_plans(space_id: str, db: Session = Depends(get_db)):
|
||||
plans = db.query(models.TestPlan).filter(models.TestPlan.space_id == space_id).all()
|
||||
return {"data": [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"type": p.type,
|
||||
"caseIds": p.case_ids or [],
|
||||
"assignees": p.assignees or [],
|
||||
"createdAt": p.created_at,
|
||||
} for p in plans
|
||||
]}
|
||||
|
||||
|
||||
|
||||
@app.post("/api/plans")
|
||||
def create_plan(plan: TestPlanSchema, space_id: str, db: Session = Depends(get_db)):
|
||||
new_id = plan.id or str(uuid.uuid4())
|
||||
db_plan = models.TestPlan(
|
||||
id=new_id,
|
||||
name=plan.name,
|
||||
type=plan.type,
|
||||
case_ids=plan.caseIds or [],
|
||||
assignees=plan.assignees or [],
|
||||
created_at=plan.createdAt or datetime.datetime.now().isoformat(),
|
||||
space_id=space_id
|
||||
)
|
||||
db.add(db_plan)
|
||||
db.commit()
|
||||
|
||||
# --- Feishu Notification ---
|
||||
if plan.assignees:
|
||||
try:
|
||||
# Use global feishu instance initialized at the top
|
||||
type_map = {"Requirement": "需求测试", "Regression": "回归测试", "Self-test": "开发自测", "Smoke": "冒烟测试"}
|
||||
plan_type_cn = type_map.get(plan.type, plan.type)
|
||||
|
||||
|
||||
# Mentions string
|
||||
mentions = " ".join([f"<at id='{uid}'></at>" for uid in plan.assignees])
|
||||
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": "🚀 新测试计划已创建"},
|
||||
"template": "blue"
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": f"**计划名称**: {plan.name}\n**计划类型**: {plan_type_cn}\n**包含用例**: {len(plan.caseIds or [])} 条"}
|
||||
},
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {"tag": "lark_md", "content": f"**执行人**: {mentions}"}
|
||||
},
|
||||
{
|
||||
"tag": "hr"
|
||||
},
|
||||
{
|
||||
"tag": "note",
|
||||
"elements": [{"tag": "plain_text", "content": "请相关同学及时关注并开始执行。"}]
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {
|
||||
"tag": "plain_text",
|
||||
"content": "查看计划详情"
|
||||
},
|
||||
"type": "primary",
|
||||
"url": f"http://localhost:5173/?view=execution&plan_id={new_id}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
# Send to each assignee as a private message or we could create a group.
|
||||
# User said "机器人提醒", usually private message is safer if no group context.
|
||||
for open_id in plan.assignees:
|
||||
print(f"Sending Feishu card to {open_id}...")
|
||||
feishu.send_card(open_id, card)
|
||||
print(f"Successfully sent card to {open_id}")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to send Feishu notification: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return {"message": "success", "id": new_id}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.put("/api/plans/{plan_id}")
|
||||
def update_plan(plan_id: str, plan: TestPlanSchema, db: Session = Depends(get_db)):
|
||||
db_plan = db.query(models.TestPlan).filter(models.TestPlan.id == plan_id).first()
|
||||
if not db_plan:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
if plan.name: db_plan.name = plan.name
|
||||
if plan.type: db_plan.type = plan.type
|
||||
if plan.caseIds is not None: db_plan.case_ids = plan.caseIds
|
||||
if plan.assignees is not None: db_plan.assignees = plan.assignees
|
||||
db.commit()
|
||||
|
||||
return {"message": "success"}
|
||||
|
||||
|
||||
@app.post("/api/plans/{plan_id}/copy")
|
||||
def copy_plan(plan_id: str, db: Session = Depends(get_db)):
|
||||
source_plan = db.query(models.TestPlan).filter(models.TestPlan.id == plan_id).first()
|
||||
if not source_plan:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
new_id = f"p-copy-{str(uuid.uuid4())[:8]}"
|
||||
db_plan = models.TestPlan(
|
||||
id=new_id,
|
||||
name=f"{source_plan.name} (副本)",
|
||||
type=source_plan.type,
|
||||
case_ids=source_plan.case_ids,
|
||||
assignees=source_plan.assignees,
|
||||
created_at=datetime.datetime.now().isoformat(),
|
||||
space_id=source_plan.space_id
|
||||
)
|
||||
db.add(db_plan)
|
||||
db.commit()
|
||||
|
||||
# Create associated task for the copy
|
||||
new_task_id = str(uuid.uuid4())
|
||||
db_task = models.TestTask(
|
||||
id=new_task_id,
|
||||
name=f"【执行任务】{db_plan.name}",
|
||||
status="PENDING",
|
||||
plan_id=new_id,
|
||||
assignees=db_plan.assignees,
|
||||
created_at=datetime.datetime.now().isoformat()
|
||||
)
|
||||
db.add(db_task)
|
||||
db.commit()
|
||||
|
||||
return {"message": "success", "id": new_id}
|
||||
|
||||
|
||||
|
||||
@app.delete("/api/plans/{plan_id}")
|
||||
def delete_plan(plan_id: str, db: Session = Depends(get_db)):
|
||||
db_plan = db.query(models.TestPlan).filter(models.TestPlan.id == plan_id).first()
|
||||
if db_plan:
|
||||
db.delete(db_plan)
|
||||
db.commit()
|
||||
return {"message": "success"}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Test Tasks
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/tasks")
|
||||
def get_tasks(db: Session = Depends(get_db)):
|
||||
tasks = db.query(models.TestTask).all()
|
||||
return {"data": [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"status": t.status,
|
||||
"planId": t.plan_id,
|
||||
"assignees": t.assignees or [],
|
||||
"createdAt": t.created_at,
|
||||
} for t in tasks
|
||||
]}
|
||||
|
||||
|
||||
|
||||
@app.post("/api/tasks")
|
||||
def create_task(task: TaskSchema, db: Session = Depends(get_db)):
|
||||
new_id = task.id or str(uuid.uuid4())
|
||||
db_task = models.TestTask(
|
||||
id=new_id,
|
||||
name=task.name,
|
||||
status=task.status,
|
||||
plan_id=task.planId,
|
||||
assignees=task.assignees or [],
|
||||
created_at=datetime.datetime.now().isoformat(),
|
||||
|
||||
)
|
||||
db.add(db_task)
|
||||
db.commit()
|
||||
return {"id": new_id}
|
||||
|
||||
|
||||
@app.put("/api/tasks/{task_id}/status")
|
||||
def update_task_status(task_id: str, body: TaskStatusUpdate, db: Session = Depends(get_db)):
|
||||
db_task = db.query(models.TestTask).filter(models.TestTask.id == task_id).first()
|
||||
if not db_task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
db_task.status = body.status
|
||||
db.commit()
|
||||
return {"message": "success"}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Bugs
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/bugs")
|
||||
def get_bugs(db: Session = Depends(get_db)):
|
||||
bugs = db.query(models.Bug).all()
|
||||
return {"data": [
|
||||
{"id": b.id, "title": b.title, "status": b.status, "caseId": b.case_id}
|
||||
for b in bugs
|
||||
]}
|
||||
|
||||
|
||||
@app.post("/api/bugs")
|
||||
def create_bug(bug: BugSchema, db: Session = Depends(get_db)):
|
||||
new_id = bug.id or f"BUG-{str(uuid.uuid4())[:8].upper()}"
|
||||
db_bug = models.Bug(id=new_id, title=bug.title, status=bug.status or "OPEN", case_id=bug.caseId)
|
||||
db.add(db_bug)
|
||||
db.commit()
|
||||
return {"id": new_id}
|
||||
|
||||
|
||||
@app.put("/api/bugs/{bug_id}")
|
||||
def update_bug(bug_id: str, bug: BugSchema, db: Session = Depends(get_db)):
|
||||
db_bug = db.query(models.Bug).filter(models.Bug.id == bug_id).first()
|
||||
if not db_bug:
|
||||
raise HTTPException(status_code=404, detail="Bug not found")
|
||||
if bug.status: db_bug.status = bug.status
|
||||
if bug.status: db_bug.status = bug.status
|
||||
db.commit()
|
||||
return {"message": "success"}
|
||||
|
||||
@app.delete("/api/bugs/{bug_id}")
|
||||
def delete_bug(bug_id: str, db: Session = Depends(get_db)):
|
||||
db_bug = db.query(models.Bug).filter(models.Bug.id == bug_id).first()
|
||||
if db_bug:
|
||||
db.delete(db_bug)
|
||||
db.commit()
|
||||
return {"message": "success"}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Spaces
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/spaces")
|
||||
def get_spaces(db: Session = Depends(get_db)):
|
||||
spaces = db.query(models.Space).all()
|
||||
return {"data": [{"id": s.id, "name": s.name} for s in spaces]}
|
||||
|
||||
|
||||
@app.post("/api/spaces")
|
||||
def create_space(space: SpaceSchema, db: Session = Depends(get_db)):
|
||||
new_id = space.id or f"space-{str(uuid.uuid4())[:8]}"
|
||||
db_space = models.Space(id=new_id, name=space.name)
|
||||
db.add(db_space)
|
||||
db.commit()
|
||||
return {"id": new_id, "message": "success"}
|
||||
|
||||
|
||||
@app.delete("/api/spaces/{space_id}")
|
||||
def delete_space(space_id: str, db: Session = Depends(get_db)):
|
||||
db_space = db.query(models.Space).filter(models.Space.id == space_id).first()
|
||||
if db_space:
|
||||
# 1. 删除该空间下的所有用例
|
||||
db.query(models.TestCase).filter(models.TestCase.space_id == space_id).delete(synchronize_session=False)
|
||||
|
||||
# 2. 删除该空间下的所有测试计划
|
||||
# 注意:如果有测试任务关联到这些计划,可能也需要处理。
|
||||
# 这里先简单删除计划,因为任务没有 space_id 只有 plan_id
|
||||
plans = db.query(models.TestPlan).filter(models.TestPlan.space_id == space_id).all()
|
||||
plan_ids = [p.id for p in plans]
|
||||
if plan_ids:
|
||||
db.query(models.TestTask).filter(models.TestTask.plan_id.in_(plan_ids)).delete(synchronize_session=False)
|
||||
db.query(models.TestPlan).filter(models.TestPlan.id.in_(plan_ids)).delete(synchronize_session=False)
|
||||
|
||||
# 3. 删除空间本身
|
||||
db.delete(db_space)
|
||||
db.commit()
|
||||
return {"message": "success"}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Feishu Integration & Batch Review
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/reviews/batch")
|
||||
async def batch_review(data: BatchReviewSchema, db: Session = Depends(get_db)):
|
||||
# 1. Fetch case details
|
||||
cases = db.query(models.TestCase).filter(models.TestCase.id.in_(data.caseIds)).all()
|
||||
if not cases:
|
||||
raise HTTPException(status_code=404, detail="No cases found")
|
||||
|
||||
# 2. Collect participants: maintainer + per-case reviewers + initiator
|
||||
participants = set()
|
||||
participants.add(data.reviewerOpenId) # The person who triggered the review
|
||||
for c in cases:
|
||||
if c.maintainer and c.maintainer.startswith("ou_"):
|
||||
participants.add(c.maintainer)
|
||||
if c.reviewers:
|
||||
for r in c.reviewers:
|
||||
if r and r.startswith("ou_"):
|
||||
participants.add(r)
|
||||
participants_list = list(participants)
|
||||
print(f"DEBUG: Batch review participants: {participants_list}")
|
||||
|
||||
|
||||
|
||||
# 3. Create Feishu Group
|
||||
module_part = f"-{data.moduleName}" if data.moduleName else ""
|
||||
group_name = f"用例评审{module_part}-{datetime.datetime.now().strftime('%m%d-%H%M')}"
|
||||
|
||||
group_resp = feishu.create_group(
|
||||
name=group_name,
|
||||
description=f"针对 {len(cases)} 条用例的批量评审群",
|
||||
user_ids=participants_list
|
||||
)
|
||||
print(f"DEBUG: Feishu create_group response: {group_resp}")
|
||||
|
||||
|
||||
|
||||
chat_id = group_resp.get("data", {}).get("chat_id")
|
||||
if not chat_id:
|
||||
# Fallback: if group creation fails (e.g. user not in app contact scope),
|
||||
# try sending to the reviewer directly
|
||||
chat_id = data.reviewerOpenId
|
||||
|
||||
# 4. Prepare & Send Card
|
||||
card_content = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": "blue",
|
||||
"title": {"content": "📋 测试用例评审邀请", "tag": "plain_text"}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {"content": f"**发起人:** <at id={data.reviewerOpenId}></at>\n**评审数量:** {len(cases)} 条\n**说明:** {data.message}", "tag": "lark_md"}
|
||||
},
|
||||
{"tag": "hr"},
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {"content": "**待评审列表:**", "tag": "lark_md"}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Add first 5 cases to the card
|
||||
for c in cases[:5]:
|
||||
card_content["elements"].append({
|
||||
"tag": "div",
|
||||
"text": {"content": f"• [{c.priority}] {c.text}", "tag": "plain_text"}
|
||||
})
|
||||
|
||||
if len(cases) > 5:
|
||||
card_content["elements"].append({
|
||||
"tag": "div",
|
||||
"text": {"content": f"... 以及其他 {len(cases)-5} 条用例", "tag": "plain_text"}
|
||||
})
|
||||
|
||||
card_content["elements"].append({
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"content": "立即去评审", "tag": "plain_text"},
|
||||
"type": "primary",
|
||||
"url": "http://120.48.157.2:55335/static/login.html" # Should be the real URL in prod
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
send_resp = feishu.send_card(chat_id, card_content)
|
||||
|
||||
return {
|
||||
"message": "success",
|
||||
"chat_id": chat_id,
|
||||
"feishu_response": send_resp
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
126
backend/migrate_sqlite_to_mysql.py
Normal file
126
backend/migrate_sqlite_to_mysql.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""
|
||||
migrate_sqlite_to_mysql.py
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
将本地 SQLite (quantum_test.db) 的全量数据迁移至百度云 MySQL (case_platform)。
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
python migrate_sqlite_to_mysql.py
|
||||
|
||||
迁移顺序(严格按依赖关系,避免外键冲突):
|
||||
test_cases → test_plans → test_tasks → bugs
|
||||
"""
|
||||
|
||||
import json
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# ── 源:SQLite ─────────────────────────────────────────────────────────────
|
||||
SQLITE_URL = "sqlite:///./quantum_test.db"
|
||||
|
||||
# ── 目标:MySQL ────────────────────────────────────────────────────────────
|
||||
MYSQL_URL = (
|
||||
"mysql+pymysql://root_dev:8B1EBC1509cc602b"
|
||||
"@mysql1.rdsmbk3ednsgnnt.rds.bj.baidubce.com:3306"
|
||||
"/case_platform?charset=utf8mb4"
|
||||
)
|
||||
|
||||
|
||||
def make_session(url, **engine_kwargs):
|
||||
engine = create_engine(url, **engine_kwargs)
|
||||
Session = sessionmaker(bind=engine)
|
||||
return engine, Session()
|
||||
|
||||
|
||||
def migrate():
|
||||
print("=" * 60)
|
||||
print("📦 SQLite → MySQL 数据迁移")
|
||||
print("=" * 60)
|
||||
|
||||
# ── 连接两端 ────────────────────────────────────────────────────────────
|
||||
sqlite_engine, src = make_session(
|
||||
SQLITE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
mysql_engine, dst = make_session(
|
||||
MYSQL_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=1800,
|
||||
)
|
||||
|
||||
# ── 在 MySQL 中建表(如果不存在) ────────────────────────────────────────
|
||||
print("\n[1/5] 在 MySQL 中初始化表结构...")
|
||||
import models
|
||||
models.Base.metadata.create_all(bind=mysql_engine)
|
||||
print(" ✅ 表结构就绪")
|
||||
|
||||
# ── 通用:清空目标表后写入 ───────────────────────────────────────────────
|
||||
def migrate_table(table_name: str, rows):
|
||||
if not rows:
|
||||
print(f" ⚠️ {table_name}: 源表为空,跳过")
|
||||
return
|
||||
# 清空(先删子表,再删父表,顺序由调用方保证)
|
||||
dst.execute(text(f"DELETE FROM {table_name}"))
|
||||
dst.commit()
|
||||
|
||||
inserted = 0
|
||||
for row in rows:
|
||||
data = dict(row._mapping)
|
||||
# JSON 字段在 SQLite 里是字符串,需要反序列化
|
||||
for col in ("steps", "tags", "case_ids"):
|
||||
if col in data and isinstance(data[col], str):
|
||||
try:
|
||||
data[col] = json.loads(data[col])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
data[col] = None
|
||||
cols = ", ".join(data.keys())
|
||||
placeholders = ", ".join(f":{k}" for k in data.keys())
|
||||
dst.execute(
|
||||
text(f"INSERT INTO {table_name} ({cols}) VALUES ({placeholders})"),
|
||||
data,
|
||||
)
|
||||
inserted += 1
|
||||
dst.commit()
|
||||
print(f" ✅ {table_name}: 迁移 {inserted} 条")
|
||||
|
||||
# ── 读取 SQLite 数据 ─────────────────────────────────────────────────────
|
||||
print("\n[2/5] 读取 SQLite 数据...")
|
||||
cases = src.execute(text("SELECT * FROM test_cases")).fetchall()
|
||||
plans = src.execute(text("SELECT * FROM test_plans")).fetchall() if _table_exists(src, "test_plans") else []
|
||||
tasks = src.execute(text("SELECT * FROM test_tasks")).fetchall() if _table_exists(src, "test_tasks") else []
|
||||
bugs = src.execute(text("SELECT * FROM bugs")).fetchall() if _table_exists(src, "bugs") else []
|
||||
|
||||
print(f" test_cases : {len(cases)} 条")
|
||||
print(f" test_plans : {len(plans)} 条")
|
||||
print(f" test_tasks : {len(tasks)} 条")
|
||||
print(f" bugs : {len(bugs)} 条")
|
||||
|
||||
# ── 迁移(父表先于子表) ─────────────────────────────────────────────────
|
||||
print("\n[3/5] 迁移 test_cases ...")
|
||||
migrate_table("test_cases", cases)
|
||||
|
||||
print("\n[4/5] 迁移 test_plans / test_tasks / bugs ...")
|
||||
migrate_table("test_plans", plans)
|
||||
migrate_table("test_tasks", tasks)
|
||||
migrate_table("bugs", bugs)
|
||||
|
||||
print("\n[5/5] 验证 MySQL 行数...")
|
||||
for tbl in ("test_cases", "test_plans", "test_tasks", "bugs"):
|
||||
count = dst.execute(text(f"SELECT COUNT(*) FROM {tbl}")).scalar()
|
||||
print(f" {tbl:<14}: {count} 条")
|
||||
|
||||
src.close()
|
||||
dst.close()
|
||||
print("\n🎉 迁移完成!")
|
||||
|
||||
|
||||
def _table_exists(session, table_name: str) -> bool:
|
||||
"""检查 SQLite 中该表是否存在"""
|
||||
result = session.execute(
|
||||
text(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}'")
|
||||
).fetchone()
|
||||
return result is not None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
67
backend/models.py
Normal file
67
backend/models.py
Normal file
@ -0,0 +1,67 @@
|
||||
from sqlalchemy import Column, String, JSON
|
||||
from database import Base
|
||||
|
||||
|
||||
class Space(Base):
|
||||
__tablename__ = "spaces"
|
||||
id = Column(String(50), primary_key=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
|
||||
|
||||
class TestCase(Base):
|
||||
__tablename__ = "test_cases"
|
||||
|
||||
id = Column(String(50), primary_key=True)
|
||||
case_id = Column(String(50), unique=True, nullable=True)
|
||||
text = Column(String(200), nullable=False)
|
||||
module = Column(String(100))
|
||||
type = Column(String(50), default="General")
|
||||
priority = Column(String(20), default="P2")
|
||||
review_status = Column(String(50), default="Draft")
|
||||
execution_status = Column(String(50), default="UNTESTED")
|
||||
maintainer = Column(String(100))
|
||||
requirement_id = Column(String(100))
|
||||
bug_id = Column(String(100))
|
||||
steps = Column(JSON, nullable=True) # [{action, expected}]
|
||||
tags = Column(JSON, nullable=True) # string[]
|
||||
reviewers = Column(JSON, nullable=True) # string[] (Feishu open_ids)
|
||||
parent_id = Column(String(50), nullable=True)
|
||||
space_id = Column(String(50))
|
||||
|
||||
|
||||
|
||||
|
||||
class TestTask(Base):
|
||||
__tablename__ = "test_tasks"
|
||||
|
||||
id = Column(String(50), primary_key=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
status = Column(String(50), default="PENDING") # PENDING / RUNNING / COMPLETED
|
||||
plan_id = Column(String(50))
|
||||
assignees = Column(JSON, nullable=True) # string[]
|
||||
|
||||
created_at = Column(String(50))
|
||||
|
||||
|
||||
class TestPlan(Base):
|
||||
__tablename__ = "test_plans"
|
||||
|
||||
id = Column(String(50), primary_key=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
type = Column(String(50)) # Self-test / Regression / Requirement / Smoke
|
||||
case_ids = Column(JSON, nullable=True) # string[]
|
||||
assignees = Column(JSON, nullable=True) # string[] (Feishu open_ids)
|
||||
created_at = Column(String(50))
|
||||
|
||||
space_id = Column(String(50))
|
||||
|
||||
|
||||
|
||||
class Bug(Base):
|
||||
__tablename__ = "bugs"
|
||||
|
||||
id = Column(String(50), primary_key=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
status = Column(String(50), default="OPEN") # OPEN / RESOLVED / CLOSED
|
||||
case_id = Column(String(50))
|
||||
|
||||
BIN
backend/quantum_test.db
Normal file
BIN
backend/quantum_test.db
Normal file
Binary file not shown.
80
backend/schema_mysql.sql
Normal file
80
backend/schema_mysql.sql
Normal file
@ -0,0 +1,80 @@
|
||||
-- QuantumTest 表结构 DDL (MySQL 8.0+)
|
||||
-- 生成时间: 2026-05-06
|
||||
-- 执行前请确保已创建数据库: CREATE DATABASE IF NOT EXISTS case_platform DEFAULT CHARACTER SET utf8mb4;
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- ─── test_cases ───────────────────────────────────────────────────────────────
|
||||
DROP TABLE IF EXISTS `test_cases`;
|
||||
CREATE TABLE `test_cases` (
|
||||
`id` VARCHAR(50) NOT NULL COMMENT '节点唯一ID',
|
||||
`case_id` VARCHAR(50) DEFAULT NULL COMMENT '用例编号,如 TC-001',
|
||||
`text` VARCHAR(200) NOT NULL COMMENT '用例标题',
|
||||
`module` VARCHAR(100) DEFAULT NULL COMMENT '所属模块',
|
||||
`type` VARCHAR(50) NOT NULL DEFAULT 'General' COMMENT '类型: General/Web/API/Mobile',
|
||||
`priority` VARCHAR(20) NOT NULL DEFAULT 'P2' COMMENT '优先级: P0/P1/P2/P3',
|
||||
`review_status` VARCHAR(50) NOT NULL DEFAULT 'Draft' COMMENT '评审状态: Draft/PendingReview/Reviewed/Deprecated',
|
||||
`execution_status` VARCHAR(50) NOT NULL DEFAULT 'UNTESTED' COMMENT '执行状态: UNTESTED/PASS/FAIL/BLOCK',
|
||||
`maintainer` VARCHAR(100) DEFAULT NULL COMMENT '维护人(飞书 OpenID 或姓名)',
|
||||
`requirement_id` VARCHAR(100) DEFAULT NULL COMMENT '关联需求ID',
|
||||
`bug_id` VARCHAR(100) DEFAULT NULL COMMENT '关联缺陷ID',
|
||||
`steps` JSON DEFAULT NULL COMMENT '测试步骤 [{action, expected}]',
|
||||
`tags` JSON DEFAULT NULL COMMENT '标签列表 string[]',
|
||||
`parent_id` VARCHAR(50) DEFAULT NULL COMMENT '父节点ID,NULL 表示根节点',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_case_id` (`case_id`),
|
||||
KEY `idx_parent_id` (`parent_id`),
|
||||
KEY `idx_module` (`module`),
|
||||
KEY `idx_priority` (`priority`),
|
||||
KEY `idx_review_status` (`review_status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='测试用例(树状结构,通过 parent_id 关联父子节点)';
|
||||
|
||||
|
||||
-- ─── test_plans ───────────────────────────────────────────────────────────────
|
||||
DROP TABLE IF EXISTS `test_plans`;
|
||||
CREATE TABLE `test_plans` (
|
||||
`id` VARCHAR(50) NOT NULL COMMENT '计划唯一ID',
|
||||
`name` VARCHAR(200) NOT NULL COMMENT '计划名称',
|
||||
`type` VARCHAR(50) NOT NULL COMMENT '计划类型: Self-test/Regression/Requirement/Smoke',
|
||||
`case_ids` JSON DEFAULT NULL COMMENT '关联用例ID列表 string[]',
|
||||
`assignee` VARCHAR(100) DEFAULT NULL COMMENT '执行人(飞书 OpenID 或姓名)',
|
||||
`created_at` VARCHAR(50) DEFAULT NULL COMMENT '创建时间 ISO8601',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_type` (`type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='测试计划';
|
||||
|
||||
|
||||
-- ─── test_tasks ───────────────────────────────────────────────────────────────
|
||||
DROP TABLE IF EXISTS `test_tasks`;
|
||||
CREATE TABLE `test_tasks` (
|
||||
`id` VARCHAR(50) NOT NULL COMMENT '任务唯一ID(UUID)',
|
||||
`name` VARCHAR(200) NOT NULL COMMENT '任务名称',
|
||||
`status` VARCHAR(50) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态: PENDING/RUNNING/COMPLETED',
|
||||
`plan_id` VARCHAR(50) DEFAULT NULL COMMENT '关联测试计划ID',
|
||||
`assignee` VARCHAR(100) DEFAULT NULL COMMENT '执行人',
|
||||
`created_at` VARCHAR(50) DEFAULT NULL COMMENT '创建时间 ISO8601',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_plan_id` (`plan_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='测试执行任务';
|
||||
|
||||
|
||||
-- ─── bugs ─────────────────────────────────────────────────────────────────────
|
||||
DROP TABLE IF EXISTS `bugs`;
|
||||
CREATE TABLE `bugs` (
|
||||
`id` VARCHAR(50) NOT NULL COMMENT '缺陷ID,如 BUG-001',
|
||||
`title` VARCHAR(200) NOT NULL COMMENT '缺陷标题',
|
||||
`status` VARCHAR(50) NOT NULL DEFAULT 'OPEN' COMMENT '缺陷状态: OPEN/RESOLVED/CLOSED',
|
||||
`case_id` VARCHAR(50) DEFAULT NULL COMMENT '关联用例ID',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_case_id` (`case_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='缺陷记录';
|
||||
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
632
backend/seed.py
Normal file
632
backend/seed.py
Normal file
@ -0,0 +1,632 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from database import SessionLocal, engine
|
||||
import models
|
||||
import datetime
|
||||
|
||||
def seed():
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
# Clear existing data for a clean seed
|
||||
db.query(models.TestTask).delete()
|
||||
db.query(models.TestCase).delete()
|
||||
db.commit()
|
||||
|
||||
# ============================================================
|
||||
# 根据 case.md 生成完整的测试用例数据
|
||||
# 模块结构:
|
||||
# - 用户管理模块
|
||||
# - 登录功能
|
||||
# - 注册功能
|
||||
# - 权限管理
|
||||
# - 订单管理模块
|
||||
# - 创建订单
|
||||
# - 支付流程
|
||||
# - 退款流程
|
||||
# - 商品管理模块
|
||||
# - 商品搜索
|
||||
# - 商品详情
|
||||
# - 系统设置模块
|
||||
# - 通知配置
|
||||
# - 个人中心
|
||||
# ============================================================
|
||||
|
||||
mock_cases = [
|
||||
# ==================== 用户管理模块 ====================
|
||||
{
|
||||
"id": "mod-user",
|
||||
"case_id": None,
|
||||
"text": "用户管理模块",
|
||||
"module": "用户管理",
|
||||
"type": "General",
|
||||
"priority": None,
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "UNTESTED",
|
||||
"maintainer": None,
|
||||
"parent_id": None,
|
||||
"tags": '["核心模块"]'
|
||||
},
|
||||
# -- 登录功能 子目录 --
|
||||
{
|
||||
"id": "dir-login",
|
||||
"case_id": None,
|
||||
"text": "登录功能",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": None,
|
||||
"review_status": "Reviewed",
|
||||
"maintainer": None,
|
||||
"parent_id": "mod-user",
|
||||
"tags": '["登录"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-001",
|
||||
"case_id": "TC-001",
|
||||
"text": "账号密码正确登录成功",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": "P0",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "张三",
|
||||
"requirement_id": "REQ-1001",
|
||||
"parent_id": "dir-login",
|
||||
"steps": '[{"action": "打开登录页面", "expected": "登录页正常展示"}, {"action": "输入正确的账号密码点击登录", "expected": "登录成功跳转到首页"}]',
|
||||
"tags": '["冒烟", "核心流程"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-002",
|
||||
"case_id": "TC-002",
|
||||
"text": "手机验证码登录",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": "P0",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "张三",
|
||||
"requirement_id": "REQ-1001",
|
||||
"parent_id": "dir-login",
|
||||
"steps": '[{"action": "输入手机号获取验证码", "expected": "验证码发送成功"}, {"action": "输入正确验证码点击登录", "expected": "登录成功"}]',
|
||||
"tags": '["冒烟"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-003",
|
||||
"case_id": "TC-003",
|
||||
"text": "第三方微信扫码登录",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": "P1",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "李四",
|
||||
"requirement_id": "REQ-1002",
|
||||
"parent_id": "dir-login",
|
||||
"steps": '[{"action": "点击微信登录图标", "expected": "弹出二维码"}, {"action": "手机扫码确认", "expected": "登录成功跳转首页"}]',
|
||||
"tags": '["需求"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-004",
|
||||
"case_id": "TC-004",
|
||||
"text": "密码错误登录失败提示",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": "P1",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "FAIL",
|
||||
"maintainer": "张三",
|
||||
"bug_id": "BUG-001",
|
||||
"parent_id": "dir-login",
|
||||
"steps": '[{"action": "输入正确账号和错误密码", "expected": "提示密码错误"}, {"action": "连续5次错误输入", "expected": "账号被锁定15分钟"}]',
|
||||
"tags": '["回归"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-005",
|
||||
"case_id": "TC-005",
|
||||
"text": "登录状态过期自动跳转",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": "P2",
|
||||
"review_status": "Draft",
|
||||
"execution_status": "UNTESTED",
|
||||
"maintainer": "张三",
|
||||
"parent_id": "dir-login",
|
||||
"tags": '["回归"]'
|
||||
},
|
||||
# -- 注册功能 子目录 --
|
||||
{
|
||||
"id": "dir-register",
|
||||
"case_id": None,
|
||||
"text": "注册功能",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": None,
|
||||
"parent_id": "mod-user",
|
||||
},
|
||||
{
|
||||
"id": "tc-006",
|
||||
"case_id": "TC-006",
|
||||
"text": "手机号注册新账号",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": "P0",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "李四",
|
||||
"requirement_id": "REQ-1003",
|
||||
"parent_id": "dir-register",
|
||||
"steps": '[{"action": "输入手机号获取验证码", "expected": "验证码发送成功"}, {"action": "填写信息提交注册", "expected": "注册成功自动登录"}]',
|
||||
"tags": '["冒烟", "核心流程"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-007",
|
||||
"case_id": "TC-007",
|
||||
"text": "邮箱注册新账号",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": "P1",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "李四",
|
||||
"parent_id": "dir-register",
|
||||
"tags": '["需求"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-008",
|
||||
"case_id": "TC-008",
|
||||
"text": "重复手机号注册校验",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": "P1",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "FAIL",
|
||||
"maintainer": "李四",
|
||||
"bug_id": "BUG-002",
|
||||
"parent_id": "dir-register",
|
||||
"tags": '["回归"]'
|
||||
},
|
||||
# -- 权限管理 子目录 --
|
||||
{
|
||||
"id": "dir-perm",
|
||||
"case_id": None,
|
||||
"text": "权限管理",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": None,
|
||||
"parent_id": "mod-user",
|
||||
},
|
||||
{
|
||||
"id": "tc-009",
|
||||
"case_id": "TC-009",
|
||||
"text": "管理员角色权限验证",
|
||||
"module": "用户管理",
|
||||
"type": "Web",
|
||||
"priority": "P0",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "王五",
|
||||
"parent_id": "dir-perm",
|
||||
"tags": '["冒烟", "权限"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-010",
|
||||
"case_id": "TC-010",
|
||||
"text": "普通用户越权访问拦截",
|
||||
"module": "用户管理",
|
||||
"type": "API",
|
||||
"priority": "P0",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "王五",
|
||||
"parent_id": "dir-perm",
|
||||
"tags": '["安全", "回归"]'
|
||||
},
|
||||
|
||||
# ==================== 订单管理模块 ====================
|
||||
{
|
||||
"id": "mod-order",
|
||||
"case_id": None,
|
||||
"text": "订单管理模块",
|
||||
"module": "订单管理",
|
||||
"type": "General",
|
||||
"priority": None,
|
||||
"parent_id": None,
|
||||
"tags": '["核心模块"]'
|
||||
},
|
||||
# -- 创建订单 --
|
||||
{
|
||||
"id": "dir-create-order",
|
||||
"case_id": None,
|
||||
"text": "创建订单",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": None,
|
||||
"parent_id": "mod-order",
|
||||
},
|
||||
{
|
||||
"id": "tc-011",
|
||||
"case_id": "TC-011",
|
||||
"text": "正常商品下单流程",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": "P0",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "赵六",
|
||||
"requirement_id": "REQ-2001",
|
||||
"parent_id": "dir-create-order",
|
||||
"steps": '[{"action": "选择商品加入购物车", "expected": "商品添加成功"}, {"action": "点击结算提交订单", "expected": "订单创建成功显示待支付"}]',
|
||||
"tags": '["冒烟", "核心流程"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-012",
|
||||
"case_id": "TC-012",
|
||||
"text": "库存不足下单提示",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": "P1",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "赵六",
|
||||
"parent_id": "dir-create-order",
|
||||
"tags": '["回归"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-013",
|
||||
"case_id": "TC-013",
|
||||
"text": "优惠券叠加使用校验",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": "P2",
|
||||
"review_status": "Draft",
|
||||
"execution_status": "UNTESTED",
|
||||
"maintainer": "赵六",
|
||||
"parent_id": "dir-create-order",
|
||||
"tags": '["需求"]'
|
||||
},
|
||||
# -- 支付流程 --
|
||||
{
|
||||
"id": "dir-payment",
|
||||
"case_id": None,
|
||||
"text": "支付流程",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": None,
|
||||
"parent_id": "mod-order",
|
||||
},
|
||||
{
|
||||
"id": "tc-014",
|
||||
"case_id": "TC-014",
|
||||
"text": "微信支付成功场景",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": "P0",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "赵六",
|
||||
"requirement_id": "REQ-2002",
|
||||
"parent_id": "dir-payment",
|
||||
"steps": '[{"action": "选择微信支付", "expected": "唤起微信支付"}, {"action": "确认支付", "expected": "支付成功跳转订单详情"}]',
|
||||
"tags": '["冒烟", "核心流程"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-015",
|
||||
"case_id": "TC-015",
|
||||
"text": "支付宝支付成功场景",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": "P0",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "赵六",
|
||||
"parent_id": "dir-payment",
|
||||
"tags": '["冒烟"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-016",
|
||||
"case_id": "TC-016",
|
||||
"text": "余额不足支付失败",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": "P1",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "FAIL",
|
||||
"maintainer": "张三",
|
||||
"bug_id": "BUG-003",
|
||||
"parent_id": "dir-payment",
|
||||
"tags": '["回归"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-017",
|
||||
"case_id": "TC-017",
|
||||
"text": "支付超时自动取消订单",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": "P2",
|
||||
"review_status": "PendingReview",
|
||||
"execution_status": "UNTESTED",
|
||||
"maintainer": "张三",
|
||||
"parent_id": "dir-payment",
|
||||
"tags": '["回归"]'
|
||||
},
|
||||
# -- 退款流程 --
|
||||
{
|
||||
"id": "dir-refund",
|
||||
"case_id": None,
|
||||
"text": "退款流程",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": None,
|
||||
"parent_id": "mod-order",
|
||||
},
|
||||
{
|
||||
"id": "tc-018",
|
||||
"case_id": "TC-018",
|
||||
"text": "已支付订单申请退款",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": "P0",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "李四",
|
||||
"requirement_id": "REQ-2003",
|
||||
"parent_id": "dir-refund",
|
||||
"steps": '[{"action": "进入订单详情点击申请退款", "expected": "退款申请提交成功"}, {"action": "审核通过", "expected": "退款到账通知"}]',
|
||||
"tags": '["冒烟", "核心流程"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-019",
|
||||
"case_id": "TC-019",
|
||||
"text": "部分退款金额校验",
|
||||
"module": "订单管理",
|
||||
"type": "Web",
|
||||
"priority": "P1",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "UNTESTED",
|
||||
"maintainer": "李四",
|
||||
"parent_id": "dir-refund",
|
||||
"tags": '["需求"]'
|
||||
},
|
||||
|
||||
# ==================== 商品管理模块 ====================
|
||||
{
|
||||
"id": "mod-product",
|
||||
"case_id": None,
|
||||
"text": "商品管理模块",
|
||||
"module": "商品管理",
|
||||
"type": "General",
|
||||
"priority": None,
|
||||
"parent_id": None,
|
||||
"tags": '["核心模块"]'
|
||||
},
|
||||
{
|
||||
"id": "dir-search",
|
||||
"case_id": None,
|
||||
"text": "商品搜索",
|
||||
"module": "商品管理",
|
||||
"type": "Web",
|
||||
"priority": None,
|
||||
"parent_id": "mod-product",
|
||||
},
|
||||
{
|
||||
"id": "tc-020",
|
||||
"case_id": "TC-020",
|
||||
"text": "关键词精确搜索商品",
|
||||
"module": "商品管理",
|
||||
"type": "Web",
|
||||
"priority": "P0",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "王五",
|
||||
"parent_id": "dir-search",
|
||||
"steps": '[{"action": "输入商品名称搜索", "expected": "返回匹配的商品列表"}]',
|
||||
"tags": '["冒烟"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-021",
|
||||
"case_id": "TC-021",
|
||||
"text": "筛选条件组合搜索",
|
||||
"module": "商品管理",
|
||||
"type": "Web",
|
||||
"priority": "P1",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "王五",
|
||||
"parent_id": "dir-search",
|
||||
"tags": '["需求"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-022",
|
||||
"case_id": "TC-022",
|
||||
"text": "搜索无结果页面展示",
|
||||
"module": "商品管理",
|
||||
"type": "Web",
|
||||
"priority": "P2",
|
||||
"review_status": "Draft",
|
||||
"execution_status": "UNTESTED",
|
||||
"maintainer": "王五",
|
||||
"parent_id": "dir-search",
|
||||
"tags": '["回归"]'
|
||||
},
|
||||
{
|
||||
"id": "dir-product-detail",
|
||||
"case_id": None,
|
||||
"text": "商品详情",
|
||||
"module": "商品管理",
|
||||
"type": "Web",
|
||||
"priority": None,
|
||||
"parent_id": "mod-product",
|
||||
},
|
||||
{
|
||||
"id": "tc-023",
|
||||
"case_id": "TC-023",
|
||||
"text": "商品详情页信息展示",
|
||||
"module": "商品管理",
|
||||
"type": "Web",
|
||||
"priority": "P0",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "王五",
|
||||
"parent_id": "dir-product-detail",
|
||||
"tags": '["冒烟"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-024",
|
||||
"case_id": "TC-024",
|
||||
"text": "商品评价列表分页",
|
||||
"module": "商品管理",
|
||||
"type": "Web",
|
||||
"priority": "P2",
|
||||
"review_status": "PendingReview",
|
||||
"execution_status": "UNTESTED",
|
||||
"maintainer": "王五",
|
||||
"parent_id": "dir-product-detail",
|
||||
"tags": '["需求"]'
|
||||
},
|
||||
|
||||
# ==================== 系统设置模块 ====================
|
||||
{
|
||||
"id": "mod-settings",
|
||||
"case_id": None,
|
||||
"text": "系统设置模块",
|
||||
"module": "系统设置",
|
||||
"type": "General",
|
||||
"priority": None,
|
||||
"parent_id": None,
|
||||
"tags": '["基础模块"]'
|
||||
},
|
||||
{
|
||||
"id": "dir-notification",
|
||||
"case_id": None,
|
||||
"text": "通知配置",
|
||||
"module": "系统设置",
|
||||
"type": "Web",
|
||||
"priority": None,
|
||||
"parent_id": "mod-settings",
|
||||
},
|
||||
{
|
||||
"id": "tc-025",
|
||||
"case_id": "TC-025",
|
||||
"text": "站内消息通知开关",
|
||||
"module": "系统设置",
|
||||
"type": "Web",
|
||||
"priority": "P2",
|
||||
"review_status": "Draft",
|
||||
"execution_status": "UNTESTED",
|
||||
"maintainer": "赵六",
|
||||
"parent_id": "dir-notification",
|
||||
"tags": '["自测"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-026",
|
||||
"case_id": "TC-026",
|
||||
"text": "邮件通知模板配置",
|
||||
"module": "系统设置",
|
||||
"type": "Web",
|
||||
"priority": "P3",
|
||||
"review_status": "Draft",
|
||||
"execution_status": "UNTESTED",
|
||||
"maintainer": "赵六",
|
||||
"parent_id": "dir-notification",
|
||||
"tags": '["自测"]'
|
||||
},
|
||||
{
|
||||
"id": "dir-profile",
|
||||
"case_id": None,
|
||||
"text": "个人中心",
|
||||
"module": "系统设置",
|
||||
"type": "Web",
|
||||
"priority": None,
|
||||
"parent_id": "mod-settings",
|
||||
},
|
||||
{
|
||||
"id": "tc-027",
|
||||
"case_id": "TC-027",
|
||||
"text": "个人信息修改保存",
|
||||
"module": "系统设置",
|
||||
"type": "Web",
|
||||
"priority": "P1",
|
||||
"review_status": "Reviewed",
|
||||
"execution_status": "PASS",
|
||||
"maintainer": "李四",
|
||||
"parent_id": "dir-profile",
|
||||
"tags": '["自测"]'
|
||||
},
|
||||
{
|
||||
"id": "tc-028",
|
||||
"case_id": "TC-028",
|
||||
"text": "头像上传与裁剪",
|
||||
"module": "系统设置",
|
||||
"type": "Web",
|
||||
"priority": "P2",
|
||||
"review_status": "PendingReview",
|
||||
"execution_status": "UNTESTED",
|
||||
"maintainer": "李四",
|
||||
"parent_id": "dir-profile",
|
||||
"tags": '["需求"]'
|
||||
},
|
||||
]
|
||||
|
||||
import json
|
||||
for case_data in mock_cases:
|
||||
# Parse JSON strings for steps and tags
|
||||
if "steps" in case_data and isinstance(case_data["steps"], str):
|
||||
case_data["steps"] = json.loads(case_data["steps"])
|
||||
if "tags" in case_data and isinstance(case_data["tags"], str):
|
||||
case_data["tags"] = json.loads(case_data["tags"])
|
||||
|
||||
# Remove None priority for directory nodes
|
||||
if case_data.get("priority") is None:
|
||||
case_data.pop("priority", None)
|
||||
|
||||
db_case = models.TestCase(**case_data)
|
||||
db.add(db_case)
|
||||
|
||||
# ============================================================
|
||||
# 测试任务 - 覆盖4种类型(回归、需求、自测、冒烟)
|
||||
# ============================================================
|
||||
now = datetime.datetime.now().isoformat()
|
||||
mock_tasks = [
|
||||
{
|
||||
"id": "task-smoke-1",
|
||||
"name": "v3.0 核心流程冒烟测试",
|
||||
"status": "RUNNING",
|
||||
"plan_id": "plan-smoke",
|
||||
"assignee": "张三",
|
||||
"created_at": now
|
||||
},
|
||||
{
|
||||
"id": "task-reg-1",
|
||||
"name": "v3.0 全模块回归测试",
|
||||
"status": "PENDING",
|
||||
"plan_id": "plan-regression",
|
||||
"assignee": "李四",
|
||||
"created_at": now
|
||||
},
|
||||
{
|
||||
"id": "task-req-1",
|
||||
"name": "REQ-2001 订单模块需求测试",
|
||||
"status": "RUNNING",
|
||||
"plan_id": "plan-requirement",
|
||||
"assignee": "赵六",
|
||||
"created_at": now
|
||||
},
|
||||
{
|
||||
"id": "task-self-1",
|
||||
"name": "系统设置模块自测",
|
||||
"status": "COMPLETED",
|
||||
"plan_id": "plan-selftest",
|
||||
"assignee": "王五",
|
||||
"created_at": now
|
||||
},
|
||||
]
|
||||
for task_data in mock_tasks:
|
||||
db_task = models.TestTask(**task_data)
|
||||
db.add(db_task)
|
||||
|
||||
db.commit()
|
||||
print(f"✅ Database seeded: {len(mock_cases)} test cases, {len(mock_tasks)} tasks.")
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
22
eslint.config.js
Normal file
22
eslint.config.js
Normal file
@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>-</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3528
package-lock.json
generated
Normal file
3528
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "-",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.38.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"reactflow": "^11.11.4",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
184
src/App.css
Normal file
184
src/App.css
Normal file
@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
434
src/App.tsx
Normal file
434
src/App.tsx
Normal file
@ -0,0 +1,434 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useStore } from './store/useStore';
|
||||
|
||||
// Auth
|
||||
import LoginPage from './components/auth/LoginPage';
|
||||
|
||||
// Layout
|
||||
import Sidebar from './components/layout/Sidebar';
|
||||
import { ToastContainer, useToastStore } from './components/layout/Toast';
|
||||
|
||||
// Editor
|
||||
import TableView from './components/editor/TableView';
|
||||
import PropertyPanel from './components/editor/PropertyPanel';
|
||||
import { ImportModal } from './components/editor/ImportModal';
|
||||
|
||||
// Plans
|
||||
import PlanListView from './components/plans/PlanListView';
|
||||
import TaskExecutionView from './components/plans/TaskExecutionView';
|
||||
|
||||
// Shared
|
||||
import DashboardView from './components/shared/DashboardView';
|
||||
import BugView from './components/shared/BugView';
|
||||
|
||||
import { Search, UploadCloud, LogOut } from 'lucide-react';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const {
|
||||
spaces, currentSpaceId, setCurrentSpaceId, viewMode, setViewMode,
|
||||
fetchSpaces, fetchData, fetchTasks, fetchBugs, testTasks, setSelectedTaskId,
|
||||
showImportModal, setShowImportModal, currentUser, setCurrentUser, logout
|
||||
} = useStore();
|
||||
|
||||
const { addToast } = useToastStore();
|
||||
|
||||
useEffect(() => {
|
||||
// fetchSpaces will internally call fetchData after resolving currentSpaceId
|
||||
fetchSpaces();
|
||||
fetchTasks();
|
||||
fetchBugs();
|
||||
}, []);
|
||||
|
||||
// Re-fetch data when user manually switches spaces
|
||||
useEffect(() => {
|
||||
if (currentSpaceId) {
|
||||
fetchData();
|
||||
}
|
||||
}, [currentSpaceId]);
|
||||
|
||||
// Ref to track handled deep links
|
||||
const handledRef = React.useRef<string | null>(null);
|
||||
|
||||
// Handle deep linking via URL parameters
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const planId = params.get('plan_id');
|
||||
const view = params.get('view');
|
||||
|
||||
if (view === 'execution' && planId) {
|
||||
if (handledRef.current === planId) return;
|
||||
|
||||
const { testTasks, setSelectedTaskId } = useStore.getState();
|
||||
const task = testTasks.find(t => t.planId === planId);
|
||||
if (task) {
|
||||
setSelectedTaskId(task.id);
|
||||
setViewMode('execution');
|
||||
handledRef.current = planId;
|
||||
|
||||
// Clear URL parameters after successful jump
|
||||
const newUrl = window.location.pathname;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
} else {
|
||||
// If tasks are still loading, don't clear yet, wait for next testTasks update
|
||||
setViewMode('execution');
|
||||
}
|
||||
} else if (view && !handledRef.current) {
|
||||
setViewMode(view as any);
|
||||
handledRef.current = 'mode-only';
|
||||
const newUrl = window.location.pathname;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}, [testTasks]);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (!currentUser) {
|
||||
return <LoginPage onLogin={(user) => setCurrentUser(user)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<ToastContainer />
|
||||
{showImportModal && <ImportModal onClose={() => setShowImportModal(false)} />}
|
||||
<main className="content-area">
|
||||
{/* Main Navigation Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
<div className="main-content-wrapper">
|
||||
{/* Header is now inside main content wrapper so sidebar can be full height */}
|
||||
<header className="main-header glass">
|
||||
<div className="header-left">
|
||||
<div className="logo" style={{ color: 'var(--primary)', fontWeight: 800, fontSize: '20px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div className="logo-icon" style={{
|
||||
width: '32px', height: '32px',
|
||||
background: 'linear-gradient(135deg, #FF4D4D, #FF8D4D)',
|
||||
color: 'white', borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '18px', fontWeight: 900,
|
||||
boxShadow: '0 4px 10px rgba(255, 77, 77, 0.3)'
|
||||
}}>D</div>
|
||||
<span style={{ letterSpacing: '0.5px' }}>D-Case</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="header-right">
|
||||
<div className="user-info-chip">
|
||||
<div className="user-avatar-sm">{currentUser.name.slice(-1)}</div>
|
||||
<span className="user-name-text">{currentUser.name}</span>
|
||||
<button className="logout-btn" onClick={() => { logout(); addToast('已退出登录', 'info'); }} title="退出">
|
||||
<LogOut size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="editor-container-wrapper">
|
||||
{/* Dynamic Content Area based on Sidebar selection */}
|
||||
<section className="editor-container">
|
||||
{viewMode === 'table' && <TableView />}
|
||||
{viewMode === 'dashboard' && <DashboardView />}
|
||||
{viewMode === 'bugs' && <BugView />}
|
||||
{viewMode === 'execution' && <TaskExecutionView />}
|
||||
{viewMode === 'plans' && <PlanListView />}
|
||||
</section>
|
||||
|
||||
|
||||
{(viewMode === 'table' || viewMode === 'execution') && <PropertyPanel />}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<style>{`
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: white;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-left, .header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-info-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 6px;
|
||||
background: #F2F3F5;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #E5E6EB;
|
||||
}
|
||||
.user-avatar-sm {
|
||||
width: 26px; height: 26px;
|
||||
background: linear-gradient(135deg, #165DFF, #36ABFF);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.user-name-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1D2129;
|
||||
}
|
||||
.logout-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #86909C;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.logout-btn:hover { color: #F53F3F; }
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.project-switcher {
|
||||
margin-left: 32px;
|
||||
}
|
||||
.proj-select {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: #F8F9FA;
|
||||
font-weight: 500;
|
||||
color: #1D2129;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #F1F3F5;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.btn-lark {
|
||||
background: #E8FFFB;
|
||||
color: #00B42A;
|
||||
border: 1px solid #AFF0B5;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-lark:active {
|
||||
transform: scale(0.96);
|
||||
background: #D9F5E8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #F1F3F5;
|
||||
}
|
||||
.btn-secondary:active {
|
||||
transform: scale(0.96);
|
||||
background: #E5E6EB;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
.btn-primary:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #FF7D00;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
.user-avatar:hover {
|
||||
box-shadow: 0 0 0 4px rgba(255, 125, 0, 0.2);
|
||||
}
|
||||
.user-avatar:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.main-content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F8FAFC;
|
||||
}
|
||||
|
||||
.editor-container-wrapper {
|
||||
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background: white;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.project-list li {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.project-list li.active {
|
||||
background: #E8F4FF;
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-list li:hover:not(.active) {
|
||||
background: #F1F3F5;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
background: var(--bg-main);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.placeholder-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
313
src/components/auth/LoginPage.tsx
Normal file
313
src/components/auth/LoginPage.tsx
Normal file
@ -0,0 +1,313 @@
|
||||
import React, { useState } from 'react';
|
||||
import usersData from '../../user.json';
|
||||
import { Eye, EyeOff, ShieldCheck, LogIn } from 'lucide-react';
|
||||
|
||||
const users = usersData as Record<string, string>;
|
||||
const DEFAULT_PASSWORD = 'admin123';
|
||||
|
||||
export interface LoginUser {
|
||||
name: string;
|
||||
openId: string;
|
||||
}
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (user: LoginUser) => void;
|
||||
}
|
||||
|
||||
const LoginPage: React.FC<LoginPageProps> = ({ onLogin }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!username.trim()) {
|
||||
setError('请输入用户名');
|
||||
return;
|
||||
}
|
||||
if (!users[username]) {
|
||||
setError('用户名不存在');
|
||||
return;
|
||||
}
|
||||
if (password !== DEFAULT_PASSWORD) {
|
||||
setError('密码错误');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
onLogin({ name: username, openId: users[username] });
|
||||
setLoading(false);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-root">
|
||||
<div className="login-bg">
|
||||
<div className="login-orb orb1" />
|
||||
<div className="login-orb orb2" />
|
||||
<div className="login-orb orb3" />
|
||||
</div>
|
||||
|
||||
<div className="login-card glass">
|
||||
<div className="login-logo">
|
||||
<div className="logo-icon-lg">Q</div>
|
||||
<h1 className="login-title">QuantumTest</h1>
|
||||
<p className="login-subtitle">测试用例管理平台</p>
|
||||
</div>
|
||||
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`login-input ${error && !password ? 'input-error' : ''}`}
|
||||
placeholder=""
|
||||
|
||||
value={username}
|
||||
onChange={e => { setUsername(e.target.value); setError(''); }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">密码</label>
|
||||
<div className="password-wrapper">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={`login-input ${error && password ? 'input-error' : ''}`}
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={e => { setPassword(e.target.value); setError(''); }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="pw-toggle"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="login-error">
|
||||
<ShieldCheck size={14} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-btn"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="btn-loading">
|
||||
<span className="spinner" />
|
||||
验证中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<LogIn size={16} />
|
||||
登录
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="login-hint">默认密码: admin123</p>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.login-root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0A0F1E;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
}
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.login-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.35;
|
||||
}
|
||||
.orb1 {
|
||||
width: 500px; height: 500px;
|
||||
background: radial-gradient(circle, #165DFF, transparent);
|
||||
top: -150px; left: -100px;
|
||||
animation: orbFloat 8s ease-in-out infinite;
|
||||
}
|
||||
.orb2 {
|
||||
width: 400px; height: 400px;
|
||||
background: radial-gradient(circle, #36ABFF, transparent);
|
||||
bottom: -100px; right: -80px;
|
||||
animation: orbFloat 10s ease-in-out infinite reverse;
|
||||
}
|
||||
.orb3 {
|
||||
width: 300px; height: 300px;
|
||||
background: radial-gradient(circle, #722ED1, transparent);
|
||||
top: 40%; left: 55%;
|
||||
animation: orbFloat 6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes orbFloat {
|
||||
0%, 100% { transform: translateY(0px) scale(1); }
|
||||
50% { transform: translateY(-30px) scale(1.05); }
|
||||
}
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: 420px;
|
||||
padding: 48px 40px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 32px 64px rgba(0,0,0,0.4);
|
||||
animation: cardIn 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
@keyframes cardIn {
|
||||
from { opacity: 0; transform: translateY(30px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.logo-icon-lg {
|
||||
width: 56px; height: 56px;
|
||||
background: linear-gradient(135deg, #165DFF, #36ABFF);
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 14px;
|
||||
box-shadow: 0 8px 24px rgba(22,93,255,0.4);
|
||||
}
|
||||
.login-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.login-subtitle {
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin: 0;
|
||||
}
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.7);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.login-input::placeholder { color: rgba(255,255,255,0.3); }
|
||||
.login-input:focus {
|
||||
border-color: #165DFF;
|
||||
box-shadow: 0 0 0 3px rgba(22,93,255,0.25);
|
||||
}
|
||||
.input-error { border-color: #F53F3F !important; }
|
||||
.password-wrapper { position: relative; }
|
||||
.pw-toggle {
|
||||
position: absolute; right: 12px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: rgba(255,255,255,0.4);
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.pw-toggle:hover { color: rgba(255,255,255,0.8); }
|
||||
.login-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(245,63,63,0.12);
|
||||
border: 1px solid rgba(245,63,63,0.3);
|
||||
border-radius: 8px;
|
||||
color: #FF7D7D;
|
||||
font-size: 13px;
|
||||
animation: shake 0.3s ease;
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-6px); }
|
||||
75% { transform: translateX(6px); }
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #165DFF, #36ABFF);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: opacity 0.2s, transform 0.15s, box-shadow 0.2s;
|
||||
box-shadow: 0 4px 16px rgba(22,93,255,0.35);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.login-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 24px rgba(22,93,255,0.45);
|
||||
}
|
||||
.login-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
.login-btn:disabled { opacity: 0.7; cursor: not-allowed; }
|
||||
.btn-loading {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.spinner {
|
||||
width: 16px; height: 16px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.login-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.25);
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
224
src/components/editor/ImportModal.tsx
Normal file
224
src/components/editor/ImportModal.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { UploadCloud, X, Image as ImageIcon, Loader2, Sparkles, FileText } from 'lucide-react';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import { useToastStore } from '../layout/Toast';
|
||||
|
||||
const parsedData = {
|
||||
id: `node-ai-${Date.now()}`,
|
||||
text: '推理服务',
|
||||
children: [
|
||||
{
|
||||
id: `node-ai-${Date.now()}-1`,
|
||||
text: '创建推理服务',
|
||||
children: [
|
||||
{
|
||||
id: `node-ai-${Date.now()}-1-1`,
|
||||
text: '点击创建推理服务按钮',
|
||||
steps: [{ action: '点击创建推理服务按钮', expected: '进入创建推理服务页面' }]
|
||||
},
|
||||
{
|
||||
id: `node-ai-${Date.now()}-1-2`,
|
||||
text: '查看页面显示',
|
||||
children: [
|
||||
{ id: `node-ai-${Date.now()}-1-2-1`, text: '预期: 左上角显示返回按钮,显示创建推理服务标题' },
|
||||
{ id: `node-ai-${Date.now()}-1-2-2`, text: '预期: 下方显示基本信息、推理类型、规格信息等' },
|
||||
{ id: `node-ai-${Date.now()}-1-2-3`, text: '预期: 右下角显示取消、确认按钮' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: `node-ai-${Date.now()}-1-3`,
|
||||
text: '基本信息',
|
||||
children: [
|
||||
{
|
||||
id: `node-ai-${Date.now()}-1-3-1`,
|
||||
text: '名称',
|
||||
children: [
|
||||
{ id: `node-ai-${Date.now()}-1-3-1-1`, text: '备注: 为必填项,不允许重复,不允许为空,支持1-64字符' },
|
||||
{ id: `node-ai-${Date.now()}-1-3-1-2`, text: '不输入名称', steps: [{ action: '不输入名称,其他配置合法点击确认', expected: '提示名称不能为空' }] },
|
||||
{ id: `node-ai-${Date.now()}-1-3-1-3`, text: '名称重复', steps: [{ action: '输入重复名称', expected: '提示名称已存在' }] },
|
||||
{ id: `node-ai-${Date.now()}-1-3-1-4`, text: '名称合法', steps: [{ action: '输入合法名称', expected: '推理服务创建成功' }] },
|
||||
{ id: `node-ai-${Date.now()}-1-3-1-5`, text: '输入65位字符', steps: [{ action: '输入65位字符', expected: '第65位字符不允许输入' }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: `node-ai-${Date.now()}-1-3-2`,
|
||||
text: '描述',
|
||||
children: [
|
||||
{ id: `node-ai-${Date.now()}-1-3-2-1`, text: '备注: 非必填项,200字符以内' },
|
||||
{ id: `node-ai-${Date.now()}-1-3-2-2`, text: '输入为空', steps: [{ action: '输入为空', expected: '创建成功,描述为空' }] },
|
||||
{ id: `node-ai-${Date.now()}-1-3-2-3`, text: '输入201字符', steps: [{ action: '输入201字符', expected: '第201位字符不允许输入' }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: `node-ai-${Date.now()}-1-3-3`,
|
||||
text: '标签',
|
||||
children: [
|
||||
{ id: `node-ai-${Date.now()}-1-3-3-1`, text: '备注: 最多支持创建20个标签' },
|
||||
{ id: `node-ai-${Date.now()}-1-3-3-2`, text: '不输入标签键,输入标签值', steps: [{ action: '点击确认', expected: '给出提示,标签键不能为空' }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: `node-ai-${Date.now()}-1-4`,
|
||||
text: '推理类型',
|
||||
children: [
|
||||
{ id: `node-ai-${Date.now()}-1-4-1`, text: '查看类型显示', steps: [{ action: '查看显示', expected: '显示推理服务、分布式推理服务' }] },
|
||||
{ id: `node-ai-${Date.now()}-1-4-2`, text: '多次切换选项', steps: [{ action: '多次切换', expected: '仅支持单选,下方规格随之切换' }] }
|
||||
]
|
||||
},
|
||||
{ id: `node-ai-${Date.now()}-1-5`, text: '规格信息' },
|
||||
{ id: `node-ai-${Date.now()}-1-6`, text: '基础配置' },
|
||||
{ id: `node-ai-${Date.now()}-1-7`, text: '存储配置' }
|
||||
]
|
||||
},
|
||||
{ id: `node-ai-${Date.now()}-2`, text: '搜索推理服务' },
|
||||
{ id: `node-ai-${Date.now()}-3`, text: '推理服务列表' }
|
||||
]
|
||||
};
|
||||
|
||||
interface ImportModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ImportModal: React.FC<ImportModalProps> = ({ onClose }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { currentSpaceId, spaceData, importNodes } = useStore();
|
||||
const { addToast } = useToastStore();
|
||||
|
||||
const handleSimulateAI = () => {
|
||||
setIsAnalyzing(true);
|
||||
let current = 0;
|
||||
const interval = setInterval(() => {
|
||||
current += 15;
|
||||
if (current >= 100) {
|
||||
clearInterval(interval);
|
||||
setProgress(100);
|
||||
setTimeout(async () => {
|
||||
// Add parsed data to the tree and persist to backend
|
||||
await importNodes([parsedData]);
|
||||
addToast('AI 识别完毕!已成功导入推理服务用例脑图', 'success');
|
||||
onClose();
|
||||
}, 500);
|
||||
} else {
|
||||
setProgress(current);
|
||||
}
|
||||
}, 400);
|
||||
};
|
||||
|
||||
|
||||
const onFileDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleSimulateAI();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} style={{ zIndex: 9999 }}>
|
||||
<div className="import-modal glass" onClick={e => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Sparkles size={18} style={{ color: '#165DFF' }} />
|
||||
<h3>AI 智能导入</h3>
|
||||
</div>
|
||||
<button className="close-btn" onClick={onClose}><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="dialog-body" style={{ padding: '24px' }}>
|
||||
{!isAnalyzing ? (
|
||||
<div
|
||||
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={onFileDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<div className="drop-icon-wrapper">
|
||||
<ImageIcon size={32} color={isDragging ? '#165DFF' : '#86909C'} />
|
||||
</div>
|
||||
<h4 style={{ margin: '12px 0 4px 0', color: '#1D2129' }}>点击或将图片拖拽到这里</h4>
|
||||
<p style={{ color: '#86909C', fontSize: '13px', margin: 0 }}>
|
||||
支持解析 XMind 截图、手绘脑图、Excel 截图
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleSimulateAI();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="analyzing-state">
|
||||
<div className="loader-ring">
|
||||
<Loader2 size={36} className="spin-anim" color="#165DFF" />
|
||||
</div>
|
||||
<h4 style={{ margin: '16px 0 8px 0', color: '#1D2129' }}>AI 正在解析图片逻辑树...</h4>
|
||||
<p style={{ color: '#86909C', fontSize: '13px', marginBottom: '20px' }}>
|
||||
已识别到「推理服务」等 28 个节点
|
||||
</p>
|
||||
<div className="progress-bar-bg" style={{ width: '100%', height: '6px', background: '#F2F3F5', borderRadius: '3px', overflow: 'hidden' }}>
|
||||
<div className="progress-bar-fill" style={{ width: `${progress}%`, height: '100%', background: 'linear-gradient(90deg, #165DFF, #36ABFF)', transition: 'width 0.3s ease' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
.import-modal {
|
||||
width: 500px;
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
.drop-zone {
|
||||
border: 2px dashed #E5E6EB;
|
||||
border-radius: 12px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: #FAFAFB;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.dragging {
|
||||
border-color: #165DFF;
|
||||
background: #F0F7FF;
|
||||
}
|
||||
.drop-icon-wrapper {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.analyzing-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.spin-anim {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
618
src/components/editor/MindMapView.tsx
Normal file
618
src/components/editor/MindMapView.tsx
Normal file
@ -0,0 +1,618 @@
|
||||
import React, { useMemo, useEffect, useRef } from 'react';
|
||||
import ReactFlow, {
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
Panel,
|
||||
Handle,
|
||||
Position,
|
||||
} from 'reactflow';
|
||||
import type { Node, Edge, NodeProps } from 'reactflow';
|
||||
import { toPng } from 'html-to-image';
|
||||
|
||||
import 'reactflow/dist/style.css';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import type { TestCaseNode, Priority } from '../../store/useStore';
|
||||
|
||||
import { Plus, CornerDownRight, ChevronRight, ChevronDown, Bug, Link, Trash2 } from 'lucide-react';
|
||||
import { useToastStore } from '../layout/Toast';
|
||||
|
||||
// Custom Node Component
|
||||
const MindMapNode = ({ data }: NodeProps) => {
|
||||
const node = data.node as TestCaseNode;
|
||||
const { updateNode, addNode, addSiblingNode, deleteNode, selectedNodeId, setSelectedNodeId, editingNodeId, setEditingNodeId } = useStore();
|
||||
const isSelected = selectedNodeId === node.id;
|
||||
const isEditing = editingNodeId === node.id;
|
||||
const isSet = !node.caseId;
|
||||
|
||||
// Local state for editing — avoids store re-renders breaking the input
|
||||
const [editingText, setEditingText] = React.useState(node.text);
|
||||
React.useEffect(() => {
|
||||
if (isEditing) setEditingText(node.text);
|
||||
}, [isEditing, node.id]);
|
||||
|
||||
const commitEdit = () => {
|
||||
updateNode(node.id, { text: editingText });
|
||||
setEditingNodeId(null);
|
||||
};
|
||||
|
||||
const priorityColors: Record<Priority, string> = {
|
||||
P0: '#F53F3F',
|
||||
P1: '#FF7D00',
|
||||
P2: '#F7BA1E',
|
||||
P3: '#165DFF',
|
||||
};
|
||||
|
||||
const statusStyles: Record<string, { border: string, bg: string, color: string }> = {
|
||||
PASS: { border: '#00B42A', bg: '#E8FFFB', color: '#00B42A' },
|
||||
FAIL: { border: '#F53F3F', bg: '#FFECE8', color: '#F53F3F' },
|
||||
BLOCK: { border: '#FF7D00', bg: '#FFF7E8', color: '#FF7D00' },
|
||||
UNTESTED: { border: '#E5E6EB', bg: 'white', color: '#86909C' }
|
||||
};
|
||||
|
||||
const currentStatus = node.executionStatus || 'UNTESTED';
|
||||
const statusStyle = statusStyles[currentStatus];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mind-node status-${currentStatus} ${node.priority ? 'has-priority' : ''} ${isSelected ? 'selected' : ''} ${isSet ? 'is-set' : ''}`}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderColor: isSelected ? 'var(--primary)' : statusStyle.border,
|
||||
background: statusStyle.bg
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedNodeId(node.id);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingNodeId(node.id);
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} style={{ background: '#165DFF', width: '8px', height: '8px' }} />
|
||||
|
||||
<div className="node-content">
|
||||
<div className="node-main">
|
||||
{node.priority && (
|
||||
<span
|
||||
className="priority-badge"
|
||||
style={{ backgroundColor: priorityColors[node.priority] }}
|
||||
>
|
||||
{node.priority}
|
||||
</span>
|
||||
)}
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="node-input"
|
||||
value={editingText}
|
||||
onChange={(e) => setEditingText(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commitEdit(); }
|
||||
if (e.key === 'Escape') { setEditingText(node.text); setEditingNodeId(null); }
|
||||
}}
|
||||
placeholder=""
|
||||
/>
|
||||
) : (
|
||||
<span className="node-text">{node.text || '未命名'}</span>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(node.requirementId || node.bugId || (node.tags && node.tags.length > 0)) && (
|
||||
<div className="node-meta">
|
||||
{node.requirementId && (
|
||||
<span className="meta-badge req" title={`需求: ${node.requirementId}`}>
|
||||
<Link size={10} /> {node.requirementId}
|
||||
</span>
|
||||
)}
|
||||
{node.bugId && (
|
||||
<span className="meta-badge bug" title={`Bug: ${node.bugId}`}>
|
||||
<Bug size={10} /> {node.bugId}
|
||||
</span>
|
||||
)}
|
||||
{node.tags && node.tags.map((tag, idx) => (
|
||||
<span key={idx} className="meta-badge tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="node-actions">
|
||||
<button onClick={(e) => { e.stopPropagation(); addNode(node.id); }} className="action-btn" title="添加子节点 (Tab)">
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); addSiblingNode(node.id); }} className="action-btn" title="添加同级 (Enter)">
|
||||
<CornerDownRight size={12} />
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }} className="action-btn action-btn-danger" title="删除 (Delete)">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} style={{ background: '#165DFF', width: '8px', height: '8px' }} />
|
||||
|
||||
{node.children && node.children.length > 0 && (
|
||||
<button
|
||||
className="collapse-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateNode(node.id, { isExpanded: node.isExpanded === false ? true : false });
|
||||
}}
|
||||
>
|
||||
{node.isExpanded === false ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.mind-node {
|
||||
background: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #E5E6EB;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
min-width: 140px;
|
||||
transition: all 0.2s;
|
||||
border-left-width: 6px; /* Status emphasis */
|
||||
}
|
||||
.mind-node.status-PASS { border-left-color: #00B42A; }
|
||||
.mind-node.status-FAIL { border-left-color: #F53F3F; }
|
||||
.mind-node.status-BLOCK { border-left-color: #FF7D00; }
|
||||
.mind-node.status-UNTESTED { border-left-color: #E5E6EB; }
|
||||
.mind-node:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.mind-node.selected {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.2);
|
||||
background: #F0F7FF;
|
||||
}
|
||||
.mind-node.is-set {
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.node-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.node-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.meta-badge {
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.meta-badge.req { background: #E8F4FF; color: #165DFF; }
|
||||
.meta-badge.bug { background: #FFECE8; color: #F53F3F; }
|
||||
.meta-badge.tag { background: #F2F3F5; color: #4E5969; border: 1px solid #E5E6EB; }
|
||||
|
||||
.node-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1D2129;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.node-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1D2129;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.node-set-label {
|
||||
font-size: 10px;
|
||||
color: var(--primary);
|
||||
background: #E8F4FF;
|
||||
border: 1px solid rgba(22, 93, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.node-actions {
|
||||
position: absolute;
|
||||
right: -24px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mind-node:hover .node-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.action-btn {
|
||||
border: none;
|
||||
background: white;
|
||||
border: 1px solid #E5E6EB;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--primary);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: #F0F7FF;
|
||||
}
|
||||
.action-btn-danger:hover {
|
||||
background: #FFF1F0 !important;
|
||||
color: #F53F3F !important;
|
||||
border-color: #F53F3F !important;
|
||||
}
|
||||
.collapse-btn {
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #E5E6EB;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
color: #86909C;
|
||||
}
|
||||
.collapse-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const nodeTypes = {
|
||||
mindMap: MindMapNode,
|
||||
};
|
||||
|
||||
interface MindMapProps {
|
||||
selectedModuleId?: string | null;
|
||||
onClearModuleSelection?: () => void;
|
||||
executionMode?: boolean;
|
||||
}
|
||||
|
||||
const MindMapInner: React.FC<MindMapProps> = ({ selectedModuleId, onClearModuleSelection, executionMode }) => {
|
||||
|
||||
|
||||
const { spaceData, currentSpaceId, selectedNodeId, editingNodeId, addNode, addSiblingNode, deleteNode, setEditingNodeId, selectedPlanId, testPlans } = useStore();
|
||||
const testCases = spaceData[currentSpaceId] || [];
|
||||
const { addToast } = useToastStore();
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
const handleExportImage = () => {
|
||||
const element = document.querySelector('.react-flow__viewport') as HTMLElement;
|
||||
if (!element) return;
|
||||
|
||||
addToast('正在生成图片...', 'info');
|
||||
toPng(document.querySelector('.react-flow') as HTMLElement, {
|
||||
backgroundColor: '#f8fafc',
|
||||
filter: (node) => {
|
||||
// Exclude controls and panels from the export
|
||||
if (node?.classList?.contains('react-flow__controls') || node?.classList?.contains('react-flow__panel')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}).then((dataUrl) => {
|
||||
const link = document.createElement('a');
|
||||
link.download = `mindmap-${currentSpaceId}-${Date.now()}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
addToast('导出成功', 'success');
|
||||
}).catch((err) => {
|
||||
console.error('Export failed', err);
|
||||
addToast('导出失败', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutoLayout = () => {
|
||||
fitView({ duration: 800, padding: 0.2 });
|
||||
addToast('布局已整理', 'success');
|
||||
};
|
||||
|
||||
const activePlanCaseIds = useMemo(() => {
|
||||
if (!selectedPlanId) return null;
|
||||
const plan = testPlans.find(p => p.id === selectedPlanId);
|
||||
return plan ? new Set(plan.caseIds) : null;
|
||||
}, [selectedPlanId, testPlans]);
|
||||
|
||||
// Keyboard shortcuts handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const state = useStore.getState();
|
||||
const { selectedNodeId, editingNodeId, addNode, addSiblingNode, deleteNode, setEditingNodeId } = state;
|
||||
if (editingNodeId) return;
|
||||
if (!selectedNodeId) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addNode(selectedNodeId);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addSiblingNode(selectedNodeId);
|
||||
break;
|
||||
case 'Backspace':
|
||||
case 'Delete':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deleteNode(selectedNodeId);
|
||||
break;
|
||||
case ' ':
|
||||
case 'F2':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditingNodeId(selectedNodeId);
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditingNodeId(null);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, []);
|
||||
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
const initialNodes: Node[] = [];
|
||||
const initialEdges: Edge[] = [];
|
||||
const verticalSpacing = 90;
|
||||
const horizontalSpacing = 280;
|
||||
|
||||
const getSubtreeHeight = (node: TestCaseNode): number => {
|
||||
if (!node.children || node.children.length === 0 || node.isExpanded === false) {
|
||||
return verticalSpacing;
|
||||
}
|
||||
return node.children.reduce((acc, child) => acc + getSubtreeHeight(child), 0);
|
||||
};
|
||||
|
||||
const filterTree = (nodes: TestCaseNode[]): TestCaseNode[] => {
|
||||
if (!activePlanCaseIds) return nodes;
|
||||
return nodes
|
||||
.map(node => {
|
||||
const children = node.children ? filterTree(node.children) : [];
|
||||
const isInPlan = activePlanCaseIds.has(node.id);
|
||||
if (isInPlan || children.length > 0) {
|
||||
return { ...node, children };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((n): n is TestCaseNode => n !== null);
|
||||
};
|
||||
|
||||
const tree = filterTree(testCases);
|
||||
const filteredTestCases = (() => {
|
||||
if (executionMode) {
|
||||
// If in execution mode but plan not loaded/ready, show nothing instead of everything
|
||||
if (!activePlanCaseIds) return [];
|
||||
return tree;
|
||||
}
|
||||
if (!selectedModuleId) return []; // Focus mode: show nothing if not selected
|
||||
|
||||
const findModuleNode = (nodes: TestCaseNode[]): TestCaseNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === selectedModuleId) return node;
|
||||
if (node.children) {
|
||||
const found = findModuleNode(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const modNode = findModuleNode(tree);
|
||||
return modNode ? [modNode] : [];
|
||||
})();
|
||||
|
||||
const traverse = (node: TestCaseNode, x: number, y: number, parentId: string | null = null) => {
|
||||
const currentId = node.id;
|
||||
|
||||
initialNodes.push({
|
||||
id: currentId,
|
||||
type: 'mindMap',
|
||||
data: { node },
|
||||
position: { x, y },
|
||||
});
|
||||
|
||||
if (parentId) {
|
||||
initialEdges.push({
|
||||
id: `edge-${parentId}-${currentId}`,
|
||||
source: parentId,
|
||||
target: currentId,
|
||||
type: 'default',
|
||||
style: { stroke: '#86909C', strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
|
||||
if (node.children && node.isExpanded !== false) {
|
||||
const nodeHeight = getSubtreeHeight(node);
|
||||
let startY = y - nodeHeight / 2;
|
||||
|
||||
node.children.forEach((child) => {
|
||||
const childHeight = getSubtreeHeight(child);
|
||||
const childY = startY + childHeight / 2;
|
||||
traverse(child, x + horizontalSpacing, childY, currentId);
|
||||
startY += childHeight;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
filteredTestCases.forEach((rootNode) => {
|
||||
// Always center the single root at 0,0 for clean layout
|
||||
traverse(rootNode, 0, 0);
|
||||
});
|
||||
|
||||
return { nodes: initialNodes, edges: initialEdges };
|
||||
|
||||
}, [testCases, activePlanCaseIds, selectedModuleId]);
|
||||
|
||||
return (
|
||||
<div className="mindmap-container" style={{ width: '100%', height: '100%', minHeight: '500px', position: 'relative' }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onPaneClick={() => {
|
||||
setEditingNodeId(null);
|
||||
useStore.getState().setSelectedNodeId(null);
|
||||
onClearModuleSelection?.();
|
||||
}}
|
||||
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
deleteKeyCode={null}
|
||||
selectionKeyCode={null}
|
||||
multiSelectionKeyCode={null}
|
||||
>
|
||||
<Background color="#E5E6EB" gap={20} />
|
||||
<Controls />
|
||||
<Panel position="top-right">
|
||||
<div className="map-toolbar glass">
|
||||
<button className="tool-btn" onClick={handleAutoLayout}>整理布局</button>
|
||||
<button className="tool-btn" onClick={handleExportImage}>导出图片</button>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel position="bottom-right">
|
||||
<div className="shortcuts-hint">
|
||||
{!executionMode ? (
|
||||
<>
|
||||
<div className="shortcut-row"><kbd>Tab</kbd> 添加子节点</div>
|
||||
<div className="shortcut-row"><kbd>Enter</kbd> 添加同级</div>
|
||||
<div className="shortcut-row"><kbd>Delete</kbd> 删除节点</div>
|
||||
<div className="shortcut-row"><kbd>空格/F2</kbd> 编辑文本</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="shortcut-row" style={{ color: '#165DFF', fontWeight: 600 }}>执行预览模式</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
<style>{`
|
||||
.map-toolbar {
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.tool-btn {
|
||||
background: white;
|
||||
border: 1px solid #E5E6EB;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tool-btn:hover {
|
||||
background: #F2F3F5;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.tool-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.shortcuts-hint {
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid #E5E6EB;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.shortcut-row {
|
||||
font-size: 11px;
|
||||
color: #86909C;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.shortcut-row kbd {
|
||||
background: #F2F3F5;
|
||||
border: 1px solid #E5E6EB;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
color: #4E5969;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.react-flow__edge-path {
|
||||
stroke: #86909C !important;
|
||||
stroke-width: 2 !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MindMapView: React.FC<MindMapProps> = (props) => (
|
||||
<ReactFlowProvider>
|
||||
<MindMapInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
|
||||
|
||||
|
||||
export default MindMapView;
|
||||
397
src/components/editor/PropertyPanel.tsx
Normal file
397
src/components/editor/PropertyPanel.tsx
Normal file
@ -0,0 +1,397 @@
|
||||
import React from 'react';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import type { Priority } from '../../store/useStore';
|
||||
import { X, AlertCircle } from 'lucide-react';
|
||||
import { UserMentionInput } from './UserMentionInput';
|
||||
import { ReviewersInput } from './ReviewersInput';
|
||||
|
||||
|
||||
const PropertyPanel: React.FC = () => {
|
||||
const { setSelectedNodeId, updateNode, getSelectedNode } = useStore();
|
||||
const node = getSelectedNode();
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<aside className="property-panel glass">
|
||||
<div className="panel-header">
|
||||
<h3>用例详情</h3>
|
||||
<button onClick={() => setSelectedNodeId(null)} className="close-btn">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel-body">
|
||||
<div className="form-group">
|
||||
<label>节点名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={node.text}
|
||||
onChange={(e) => updateNode(node.id, { text: e.target.value })}
|
||||
className="main-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>用例 ID</label>
|
||||
<input
|
||||
type="text"
|
||||
className="main-input"
|
||||
value={node.caseId || ''}
|
||||
onChange={(e) => updateNode(node.id, { caseId: e.target.value })}
|
||||
placeholder="如: TC-001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>所属模块</label>
|
||||
<input
|
||||
type="text"
|
||||
className="main-input"
|
||||
value={node.module || ''}
|
||||
onChange={(e) => updateNode(node.id, { module: e.target.value })}
|
||||
placeholder="如: 用户管理"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>标签 (Tags)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="main-input"
|
||||
value={node.tags?.join(' ') || ''}
|
||||
onChange={(e) => {
|
||||
const tags = e.target.value.split(/[\s,,]+/).filter(t => t.trim() !== '');
|
||||
updateNode(node.id, { tags });
|
||||
}}
|
||||
placeholder="输入标签,空格或逗号分隔"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="form-group">
|
||||
<label>维护人</label>
|
||||
<UserMentionInput
|
||||
value={node.maintainer || ''}
|
||||
onChange={(val) => updateNode(node.id, { maintainer: val })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>评审人 <span style={{ color: '#86909C', fontSize: 11, fontWeight: 400 }}>(多选,飞书评审时自动拉群)</span></label>
|
||||
<ReviewersInput
|
||||
value={node.reviewers || []}
|
||||
onChange={(val) => updateNode(node.id, { reviewers: val })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="form-group">
|
||||
<label>场景类型 (Standardization)</label>
|
||||
|
||||
|
||||
<select
|
||||
value={node.type || 'General'}
|
||||
onChange={(e) => updateNode(node.id, { type: e.target.value as any })}
|
||||
className="main-input"
|
||||
>
|
||||
<option value="Web">Web 业务</option>
|
||||
<option value="Mobile">移动端</option>
|
||||
<option value="API">接口/API</option>
|
||||
<option value="General">通用场景</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>用例状态</label>
|
||||
<select
|
||||
value={node.reviewStatus || 'Draft'}
|
||||
onChange={(e) => updateNode(node.id, { reviewStatus: e.target.value as any })}
|
||||
className="main-input"
|
||||
>
|
||||
<option value="Draft">草稿</option>
|
||||
<option value="Reviewed">已评审</option>
|
||||
<option value="Deprecated">已废弃</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>执行结果 (Test Run)</label>
|
||||
<select
|
||||
value={node.executionStatus || 'UNTESTED'}
|
||||
onChange={(e) => updateNode(node.id, { executionStatus: e.target.value as any })}
|
||||
className={`main-input status-${node.executionStatus || 'UNTESTED'}`}
|
||||
>
|
||||
<option value="UNTESTED">未执行</option>
|
||||
<option value="PASS">通过 (PASS)</option>
|
||||
<option value="FAIL">失败 (FAIL)</option>
|
||||
<option value="BLOCK">阻塞 (BLOCK)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
{node.executionStatus === 'FAIL' && !node.bugId && (
|
||||
<div className="bug-alert">
|
||||
<AlertCircle size={14} className="icon-red" />
|
||||
<span>用例执行失败,建议提取 Bug</span>
|
||||
<button
|
||||
className="btn-create-bug"
|
||||
onClick={() => {
|
||||
// Call store to add bug
|
||||
useStore.getState().addBug(node.caseId || node.id, `【缺陷】${node.text} 执行失败`);
|
||||
}}
|
||||
>
|
||||
一键提 Bug
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.bugId && (
|
||||
<div className="bug-associated">
|
||||
<span className="bug-label">已关联缺陷:</span>
|
||||
<span className="bug-link">{node.bugId}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group" style={{ marginTop: '16px' }}>
|
||||
<label>优先级</label>
|
||||
|
||||
|
||||
<div className="priority-options">
|
||||
{(['P0', 'P1', 'P2', 'P3'] as Priority[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`p-btn ${node.priority === p ? 'active' : ''} p-${p}`}
|
||||
onClick={() => updateNode(node.id, { priority: node.priority === p ? undefined : p })}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.property-panel {
|
||||
width: 380px;
|
||||
border-left: 1px solid var(--border-color);
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 50;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.main-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.priority-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.p-btn {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
.p-btn.active.p-P0 { background: #F53F3F; color: white; border-color: #F53F3F; }
|
||||
.p-btn.active.p-P1 { background: #FF7D00; color: white; border-color: #FF7D00; }
|
||||
.p-btn.active.p-P2 { background: #F7BA1E; color: white; border-color: #F7BA1E; }
|
||||
.p-btn.active.p-P3 { background: #165DFF; color: white; border-color: #165DFF; }
|
||||
|
||||
.steps-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.add-step-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.step-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.step-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #F2F3F5;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-inputs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.step-inputs textarea {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
min-height: 40px;
|
||||
}
|
||||
.expected-input {
|
||||
border-top: 1px solid #F2F3F5 !important;
|
||||
padding-top: 8px;
|
||||
color: #00B42A;
|
||||
}
|
||||
.delete-step {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #F53F3F;
|
||||
cursor: pointer;
|
||||
}
|
||||
.step-item:hover .delete-step {
|
||||
opacity: 1;
|
||||
}
|
||||
.empty-steps {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
padding: 20px;
|
||||
background: #F8F9FA;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.bug-alert {
|
||||
margin-top: 12px;
|
||||
background: #FFF3F3;
|
||||
border: 1px dashed #FFAAAA;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #F53F3F;
|
||||
}
|
||||
.btn-create-bug {
|
||||
margin-left: auto;
|
||||
background: #F53F3F;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-create-bug:hover { background: #F02B2B; }
|
||||
.bug-associated {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: #F2F3F5;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.bug-label { color: #86909C; }
|
||||
.bug-link { color: #165DFF; font-weight: 500; cursor: pointer; }
|
||||
.bug-link:hover { text-decoration: underline; }
|
||||
|
||||
.status-PASS { border-left: 3px solid #00B42A !important; }
|
||||
.status-FAIL { border-left: 3px solid #F53F3F !important; }
|
||||
.status-BLOCK { border-left: 3px solid #FF7D00 !important; }
|
||||
.status-UNTESTED { border-left: 3px solid #C9CDD4 !important; }
|
||||
|
||||
.feishu-user-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #E8F4FF;
|
||||
border: 1px solid rgba(22, 93, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
color: #1D2129;
|
||||
gap: 8px;
|
||||
}
|
||||
.feishu-icon {
|
||||
background: #165DFF;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.feishu-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
.clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #86909C;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.clear-btn:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
color: #F53F3F;
|
||||
}
|
||||
`}</style>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyPanel;
|
||||
211
src/components/editor/ReviewersInput.tsx
Normal file
211
src/components/editor/ReviewersInput.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import usersData from '../../user.json';
|
||||
import { X, Plus } from 'lucide-react';
|
||||
|
||||
const users = usersData as Record<string, string>;
|
||||
|
||||
// For reverse lookup (ID -> Name)
|
||||
export const feishuUserMap: Record<string, string> = Object.entries(users).reduce((acc, [name, id]) => {
|
||||
acc[id] = name;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
interface ReviewersInputProps {
|
||||
value: string[]; // array of openIds
|
||||
onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
const avatarGradient = (name: string) =>
|
||||
name === '陶航宇' ? 'linear-gradient(135deg, #FF7D00, #F53F3F)' : 'linear-gradient(135deg, #165DFF, #36ABFF)';
|
||||
|
||||
export const ReviewersInput: React.FC<ReviewersInputProps> = ({ value, onChange }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const addReviewer = (id: string) => {
|
||||
if (!value.includes(id)) {
|
||||
onChange([...value, id]);
|
||||
}
|
||||
setQuery('');
|
||||
setShowDropdown(false);
|
||||
};
|
||||
|
||||
const removeReviewer = (id: string) => {
|
||||
onChange(value.filter(v => v !== id));
|
||||
};
|
||||
|
||||
const filteredUsers = Object.entries(users).filter(([name, id]) =>
|
||||
!value.includes(id) && name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="reviewers-wrapper" ref={wrapperRef}>
|
||||
{/* Selected tags */}
|
||||
<div className="reviewers-tags">
|
||||
{value.map(id => {
|
||||
const name = feishuUserMap[id] || id;
|
||||
return (
|
||||
<span key={id} className="reviewer-tag">
|
||||
<span className="tag-avatar-sm" style={{ background: avatarGradient(name) }}>
|
||||
{name.slice(-2)}
|
||||
</span>
|
||||
{name}
|
||||
<button className="tag-remove" onClick={() => removeReviewer(id)}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add button / input */}
|
||||
<div className="add-reviewer-wrap">
|
||||
<input
|
||||
className="reviewers-input"
|
||||
placeholder={value.length === 0 ? '+ 添加人员' : '+'}
|
||||
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setShowDropdown(true); }}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{showDropdown && filteredUsers.length > 0 && (
|
||||
<div className="reviewers-dropdown">
|
||||
<div className="dropdown-header">选择评审人</div>
|
||||
{filteredUsers.map(([name, id]) => (
|
||||
<div key={id} className="reviewer-option" onClick={() => addReviewer(id)}>
|
||||
<div className="avatar" style={{ background: avatarGradient(name) }}>{name.slice(-2)}</div>
|
||||
<span className="user-name">{name}</span>
|
||||
<Plus size={14} style={{ marginLeft: 'auto', color: '#86909C' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.reviewers-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.reviewers-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
align-items: center;
|
||||
cursor: text;
|
||||
}
|
||||
.reviewer-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: #E8F4FF;
|
||||
border: 1px solid rgba(22, 93, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 3px 8px 3px 4px;
|
||||
font-size: 12px;
|
||||
color: #1D2129;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tag-avatar-sm {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #86909C;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tag-remove:hover { color: #F53F3F; background: rgba(245, 63, 63, 0.1); }
|
||||
.add-reviewer-wrap { display: flex; align-items: center; }
|
||||
.reviewers-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
color: #86909C;
|
||||
background: transparent;
|
||||
min-width: 80px;
|
||||
width: auto;
|
||||
}
|
||||
.reviewers-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: white;
|
||||
border: 1px solid #E5E6EB;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
z-index: 1000;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dropdown-header {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: #86909C;
|
||||
background: #F8F9FA;
|
||||
border-bottom: 1px solid #E5E6EB;
|
||||
}
|
||||
.reviewer-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.reviewer-option:hover { background: #F2F3F5; }
|
||||
.avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
color: #1D2129;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
842
src/components/editor/TableView.tsx
Normal file
842
src/components/editor/TableView.tsx
Normal file
@ -0,0 +1,842 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import type { TestCaseNode, Priority } from '../../store/useStore';
|
||||
import { useToastStore } from '../layout/Toast';
|
||||
import MindMapView from './MindMapView';
|
||||
import { ChevronRight, ChevronDown, LayoutGrid, List, FolderOpen, Folder, FileText, Plus, Trash2, CheckCircle, Clock, X } from 'lucide-react';
|
||||
import { feishuUserMap } from './UserMentionInput';
|
||||
|
||||
import { UserMentionInput } from './UserMentionInput';
|
||||
import { ReviewersInput } from './ReviewersInput';
|
||||
|
||||
const TableView: React.FC = () => {
|
||||
const {
|
||||
spaces, currentSpaceId, setCurrentSpaceId, addSpace, deleteSpace, spaceData,
|
||||
updateNode, selectedNodeId, setSelectedNodeId, editingNodeId, addNode, addSiblingNode, deleteNode, setShowImportModal, currentUser, batchUpdateNodes
|
||||
} = useStore();
|
||||
|
||||
const [showBatchEditModal, setShowBatchEditModal] = useState(false);
|
||||
const [batchMaintainer, setBatchMaintainer] = useState<string>('');
|
||||
const [batchReviewers, setBatchReviewers] = useState<string[]>([]);
|
||||
|
||||
|
||||
|
||||
const [displayMode, setDisplayMode] = useState<'table' | 'mindmap'>('table');
|
||||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
|
||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set(['root']));
|
||||
const [showSpaceMenu, setShowSpaceMenu] = useState(false);
|
||||
const [showSpaceDialog, setShowSpaceDialog] = useState(false);
|
||||
const [spaceName, setSpaceName] = useState('');
|
||||
|
||||
const currentSpace = spaces.find(s => s.id === currentSpaceId);
|
||||
const testCases = spaceData[currentSpaceId] || [];
|
||||
|
||||
// Batch selection state
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const moduleNodes = useMemo(() => {
|
||||
return testCases;
|
||||
}, [testCases]);
|
||||
|
||||
const visibleCases = useMemo(() => {
|
||||
if (selectedModuleId === null) return testCases;
|
||||
const find = (nodes: TestCaseNode[]): TestCaseNode | null => {
|
||||
for (const n of nodes) {
|
||||
if (n.id === selectedModuleId) return n;
|
||||
if (n.children) { const r = find(n.children); if (r) return r; }
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const mod = find(testCases);
|
||||
return mod ? [mod] : testCases;
|
||||
}, [testCases, selectedModuleId]);
|
||||
|
||||
const toggleModule = (id: string) => {
|
||||
setExpandedModules(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFeishuReview = async () => {
|
||||
if (!currentUser) {
|
||||
addToast('请先登录', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
addToast('正在发起飞书评审...', 'info');
|
||||
try {
|
||||
// Resolve module name from selectedModuleId
|
||||
const findNode = (nodes: TestCaseNode[], id: string): TestCaseNode | null => {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n;
|
||||
if (n.children) { const r = findNode(n.children, id); if (r) return r; }
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const moduleNode = selectedModuleId ? findNode(testCases, selectedModuleId) : null;
|
||||
const moduleName = moduleNode ? moduleNode.text : null;
|
||||
|
||||
const response = await fetch('http://localhost:8000/api/reviews/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
caseIds: Array.from(selectedIds),
|
||||
reviewerOpenId: currentUser.openId,
|
||||
moduleName,
|
||||
message: '请各位老板们协助评审这些用例。'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.message === 'success') {
|
||||
addToast('飞书评审群已拉起并发送卡片', 'success');
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
addToast('发起评审失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('网络请求失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = (nodes: TestCaseNode[]) => {
|
||||
const allIds: string[] = [];
|
||||
const collect = (list: TestCaseNode[]) => {
|
||||
list.forEach(n => {
|
||||
allIds.push(n.id);
|
||||
if (n.children) collect(n.children);
|
||||
});
|
||||
};
|
||||
collect(nodes);
|
||||
|
||||
if (selectedIds.size >= allIds.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(allIds));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (window.confirm(`确定要删除选中的 ${selectedIds.size} 条用例吗?`)) {
|
||||
selectedIds.forEach(id => deleteNode(id));
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchSetStatus = (status: any) => {
|
||||
selectedIds.forEach(id => updateNode(id, { reviewStatus: status }));
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleBatchUpdate = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
const updates: any = {};
|
||||
if (batchMaintainer) updates.maintainer = batchMaintainer;
|
||||
if (batchReviewers.length > 0) updates.reviewers = batchReviewers;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
addToast('请至少选择一个修改项', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
await batchUpdateNodes(Array.from(selectedIds), updates);
|
||||
addToast(`已批量更新 ${selectedIds.size} 条用例`, 'success');
|
||||
setShowBatchEditModal(false);
|
||||
setSelectedIds(new Set());
|
||||
setBatchMaintainer('');
|
||||
setBatchReviewers([]);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const [showModuleDialog, setShowModuleDialog] = useState(false);
|
||||
const [moduleName, setModuleName] = useState('');
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, nodeId: string } | null>(null);
|
||||
const { addToast } = useToastStore();
|
||||
|
||||
const handleCreateModule = () => {
|
||||
if (!moduleName.trim()) return;
|
||||
addNode(null, moduleName); // parentId=null adds to root level
|
||||
addToast(`已创建用例集: ${moduleName}`, 'success');
|
||||
setShowModuleDialog(false);
|
||||
setModuleName('');
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, nodeId: string) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, nodeId });
|
||||
};
|
||||
|
||||
const closeContextMenu = () => setContextMenu(null);
|
||||
|
||||
const renderDirTree = (nodes: TestCaseNode[], depth: number = 0): React.ReactNode => {
|
||||
return nodes.map(node => {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpanded = expandedModules.has(node.id);
|
||||
const isSelected = selectedModuleId === node.id;
|
||||
|
||||
return (
|
||||
<React.Fragment key={node.id}>
|
||||
<li
|
||||
className={`dir-item depth-${depth} ${isSelected ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedModuleId(isSelected ? null : node.id);
|
||||
if (hasChildren) toggleModule(node.id);
|
||||
}}
|
||||
onContextMenu={(e) => handleContextMenu(e, node.id)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<span className="dir-expand-icon" onClick={(e) => { e.stopPropagation(); toggleModule(node.id); }}>
|
||||
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="dir-expand-icon" />
|
||||
)}
|
||||
<span className="dir-folder-icon">
|
||||
{hasChildren ? (isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />) : <FileText size={14} />}
|
||||
</span>
|
||||
{editingNodeId === node.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="inline-input"
|
||||
value={node.text}
|
||||
onChange={(e) => updateNode(node.id, { text: e.target.value })}
|
||||
onBlur={() => useStore.getState().setEditingNodeId(null)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && useStore.getState().setEditingNodeId(null)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="dir-name">{node.text}</span>
|
||||
)}
|
||||
<span className="dir-count">{countLeaves(node)}</span>
|
||||
</li>
|
||||
{hasChildren && isExpanded && (
|
||||
<ul className="dir-tree sub-tree">
|
||||
{renderDirTree(node.children!, depth + 1)}
|
||||
</ul>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const countLeaves = (node: TestCaseNode): number => {
|
||||
if (!node.children || node.children.length === 0) return 1;
|
||||
return node.children.reduce((sum, c) => sum + countLeaves(c), 0);
|
||||
};
|
||||
|
||||
const renderRows = (nodes: TestCaseNode[], depth: number = 0): React.ReactNode => {
|
||||
return nodes.map((node) => (
|
||||
<React.Fragment key={node.id}>
|
||||
<tr
|
||||
className={`table-row depth-${depth} ${selectedNodeId === node.id ? 'selected-row' : ''}`}
|
||||
onClick={() => setSelectedNodeId(node.id)}
|
||||
>
|
||||
<td style={{ width: '40px', textAlign: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(node.id)}
|
||||
onChange={() => toggleSelect(node.id)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="case-id">{node.caseId || '—'}</div>
|
||||
</td>
|
||||
<td style={{ paddingLeft: `${8 + depth * 24}px` }}>
|
||||
<div className="title-cell">
|
||||
<span className="expand-icon">
|
||||
{node.children && node.children.length > 0 ? <ChevronDown size={14} /> : null}
|
||||
</span>
|
||||
<input
|
||||
className="inline-input"
|
||||
value={node.text}
|
||||
placeholder=""
|
||||
onChange={(e) => updateNode(node.id, { text: e.target.value })}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className={`priority-select p-${node.priority}`}
|
||||
value={node.priority || ''}
|
||||
onChange={(e) => updateNode(node.id, { priority: e.target.value as Priority })}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<option value="P0">P0</option>
|
||||
<option value="P1">P1</option>
|
||||
<option value="P2">P2</option>
|
||||
<option value="P3">P3</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div className="maintainer-cell">{feishuUserMap[node.maintainer] || node.maintainer || '—'}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-badge review-${node.reviewStatus || 'Draft'}`}>
|
||||
{node.reviewStatus === 'Reviewed' ? '已评审' :
|
||||
node.reviewStatus === 'PendingReview' ? '待评审' :
|
||||
node.reviewStatus === 'Deprecated' ? '已废弃' : '草稿'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
|
||||
<button
|
||||
className="row-action-btn"
|
||||
title="提审"
|
||||
onClick={(e) => { e.stopPropagation(); updateNode(node.id, { reviewStatus: 'PendingReview' }); }}
|
||||
><Clock size={12} /></button>
|
||||
<button
|
||||
className="row-action-btn"
|
||||
title="通过"
|
||||
onClick={(e) => { e.stopPropagation(); updateNode(node.id, { reviewStatus: 'Reviewed' }); }}
|
||||
><CheckCircle size={12} /></button>
|
||||
<button
|
||||
className="row-action-btn"
|
||||
title="添加子用例"
|
||||
onClick={(e) => { e.stopPropagation(); addNode(node.id); }}
|
||||
>+子</button>
|
||||
{node.id !== 'root' && (
|
||||
<button
|
||||
className="row-action-btn danger"
|
||||
title="删除"
|
||||
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}
|
||||
><Trash2 size={12} /></button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{node.children && renderRows(node.children, depth + 1)}
|
||||
</React.Fragment>
|
||||
));
|
||||
};
|
||||
|
||||
const handleAddSpace = () => {
|
||||
if (!spaceName.trim()) return;
|
||||
addSpace(spaceName);
|
||||
setShowSpaceDialog(false);
|
||||
setSpaceName('');
|
||||
addToast(`已创建用例空间: ${spaceName}`, 'success');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="table-view-container" onClick={() => { closeContextMenu(); setShowSpaceMenu(false); }}>
|
||||
<div className="directory-sidebar">
|
||||
{/* Space Selector */}
|
||||
<div className="space-selector-container">
|
||||
<div className="space-selector-trigger glass" onClick={(e) => { e.stopPropagation(); setShowSpaceMenu(!showSpaceMenu); }}>
|
||||
<span className="space-name">{currentSpace?.name}</span>
|
||||
<ChevronDown size={14} className={`chevron ${showSpaceMenu ? 'open' : ''}`} />
|
||||
</div>
|
||||
|
||||
{showSpaceMenu && (
|
||||
<div className="space-menu glass" onClick={e => e.stopPropagation()}>
|
||||
{spaces.map(space => (
|
||||
<div
|
||||
key={space.id}
|
||||
className={`space-menu-item ${space.id === currentSpaceId ? 'active' : ''}`}
|
||||
onClick={() => { setCurrentSpaceId(space.id); setShowSpaceMenu(false); }}
|
||||
>
|
||||
<span className="name">{space.name}</span>
|
||||
{spaces.length > 1 && (
|
||||
<button className="del-btn" onClick={(e) => { e.stopPropagation(); deleteSpace(space.id); addToast('空间已删除', 'info'); }}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="space-menu-divider" />
|
||||
<div className="space-menu-item add-new" onClick={() => { setShowSpaceDialog(true); setShowSpaceMenu(false); }}>
|
||||
<Plus size={14} /> <span>新建用例空间</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dir-header">
|
||||
<span>项目目录</span>
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="新建目录"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModuleDialog(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="dir-tree">
|
||||
{renderDirTree(moduleNodes)}
|
||||
</ul>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div className="table-content-area">
|
||||
<div className="table-toolbar">
|
||||
<div className="toolbar-left">
|
||||
<button
|
||||
className="tool-btn primary-btn"
|
||||
onClick={() => {
|
||||
if (selectedModuleId) {
|
||||
addNode(selectedModuleId);
|
||||
addToast('已添加新用例', 'success');
|
||||
} else {
|
||||
addToast('请先选择一个目录', 'error');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> 新建用例
|
||||
</button>
|
||||
<button className="tool-btn" onClick={() => setShowImportModal(true)}>📥 导入</button>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="batch-actions">
|
||||
<span className="selection-count">已选 {selectedIds.size} 项</span>
|
||||
<button className="tool-btn danger-text" onClick={handleBatchDelete}>
|
||||
<Trash2 size={14} /> 批量删除
|
||||
</button>
|
||||
<button className="tool-btn review-btn" onClick={handleFeishuReview}>
|
||||
🚀 飞书评审
|
||||
</button>
|
||||
<button className="tool-btn" onClick={() => setShowBatchEditModal(true)}>
|
||||
✍️ 批量修改
|
||||
</button>
|
||||
<button className="tool-btn" onClick={() => handleBatchSetStatus('Reviewed')}>
|
||||
<CheckCircle size={14} /> 设为已审
|
||||
</button>
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={`toggle-btn ${displayMode === 'table' ? 'active' : ''}`}
|
||||
onClick={() => setDisplayMode('table')}
|
||||
>
|
||||
<List size={14} /> 表格
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${displayMode === 'mindmap' ? 'active' : ''}`}
|
||||
onClick={() => setDisplayMode('mindmap')}
|
||||
>
|
||||
<LayoutGrid size={14} /> 脑图
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<button className="tool-btn" onClick={() => addToast('筛选器功能开发中...', 'info')}>🔍 筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedModuleId ? (
|
||||
<div className="empty-selection-placeholder">
|
||||
<FolderOpen size={48} />
|
||||
<p>请从左侧目录树中选择一个用例集以查看内容</p>
|
||||
<button className="btn-primary" onClick={() => setShowModuleDialog(true)}>
|
||||
<Plus size={14} /> 新建用例集
|
||||
</button>
|
||||
</div>
|
||||
) : displayMode === 'table' ? (
|
||||
<div className="table-scroll">
|
||||
<table className="test-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px', textAlign: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={() => toggleSelectAll(visibleCases)}
|
||||
checked={selectedIds.size > 0 && selectedIds.size >= visibleCases.length}
|
||||
/>
|
||||
</th>
|
||||
<th style={{ width: '12%' }}>ID</th>
|
||||
<th style={{ width: '35%' }}>用例标题</th>
|
||||
<th style={{ width: '10%' }}>优先级</th>
|
||||
<th style={{ width: '13%' }}>维护人</th>
|
||||
<th style={{ width: '13%' }}>状态</th>
|
||||
<th style={{ width: '17%' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderRows(visibleCases)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mindmap-wrapper">
|
||||
<MindMapView
|
||||
selectedModuleId={selectedModuleId}
|
||||
onClearModuleSelection={() => setSelectedModuleId(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{showModuleDialog && (
|
||||
<div className="modal-overlay" onClick={() => setShowModuleDialog(false)}>
|
||||
<div className="module-dialog glass" onClick={e => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>新建用例集</h3>
|
||||
<button className="close-btn" onClick={() => setShowModuleDialog(false)}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<label>用例集名称</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="main-input"
|
||||
placeholder="请输入用例集名称..."
|
||||
value={moduleName}
|
||||
onChange={e => setModuleName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateModule()}
|
||||
/>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button className="btn-secondary" onClick={() => setShowModuleDialog(false)}>取消</button>
|
||||
<button className="btn-primary" onClick={handleCreateModule}>确认创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="context-menu glass"
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="menu-item" onClick={() => { useStore.getState().setEditingNodeId(contextMenu.nodeId); closeContextMenu(); }}>
|
||||
<span>编辑名称</span>
|
||||
</div>
|
||||
<div className="menu-item danger" onClick={() => {
|
||||
if (selectedModuleId === contextMenu.nodeId) setSelectedModuleId(null);
|
||||
deleteNode(contextMenu.nodeId);
|
||||
addToast('已删除目录', 'info');
|
||||
closeContextMenu();
|
||||
}}>
|
||||
<span>删除目录</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSpaceDialog && (
|
||||
<div className="modal-overlay" onClick={() => setShowSpaceDialog(false)}>
|
||||
<div className="module-dialog glass" onClick={e => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>新建用例空间</h3>
|
||||
<button className="close-btn" onClick={() => setShowSpaceDialog(false)}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<label>空间名称</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="main-input"
|
||||
placeholder=""
|
||||
value={spaceName}
|
||||
onChange={e => setSpaceName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddSpace()}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button className="btn-secondary" onClick={() => setShowSpaceDialog(false)}>取消</button>
|
||||
<button className="btn-primary" onClick={handleAddSpace}>确认创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBatchEditModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowBatchEditModal(false)}>
|
||||
<div className="module-dialog glass" style={{ width: '460px' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>批量修改信息 ({selectedIds.size} 条用例)</h3>
|
||||
<button className="close-btn" onClick={() => setShowBatchEditModal(false)}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<div className="form-group" style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '13px', fontWeight: '600' }}>批量设置维护人</label>
|
||||
<UserMentionInput
|
||||
value={batchMaintainer}
|
||||
onChange={setBatchMaintainer}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '13px', fontWeight: '600' }}>批量设置评审人</label>
|
||||
<ReviewersInput
|
||||
value={batchReviewers}
|
||||
onChange={setBatchReviewers}
|
||||
/>
|
||||
<p style={{ fontSize: '12px', color: '#86909C', marginTop: '8px' }}>
|
||||
提示:留空则不修改该字段。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button className="btn-secondary" onClick={() => setShowBatchEditModal(false)}>取消</button>
|
||||
<button className="btn-primary" onClick={handleBatchUpdate}>执行批量修改</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.table-view-container { display: flex; height: 100%; width: 100%; background: #F8FAFC; position: relative; }
|
||||
.directory-sidebar { width: 260px; background: white; border-right: 1px solid var(--border-color); padding: 16px 12px; overflow-y: auto; flex-shrink: 0; }
|
||||
|
||||
/* Space Selector */
|
||||
.space-selector-container { position: relative; margin-bottom: 20px; }
|
||||
.space-selector-trigger { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; background: #EEF2F6; border-radius: 10px; cursor: pointer; transition: all 0.2s; border: 1px solid transparent; }
|
||||
.space-selector-trigger:hover { background: #E5EAF0; border-color: #D1D9E2; }
|
||||
.space-name { font-size: 14px; font-weight: 600; color: #1D2129; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chevron { transition: transform 0.2s; color: #86909C; }
|
||||
.chevron.open { transform: rotate(180deg); }
|
||||
|
||||
.space-menu { position: absolute; top: calc(100% + 8px); left: 0; right: 0; background: white; border-radius: 12px; box-shadow: var(--shadow-lg); border: 1px solid var(--border-color); z-index: 100; padding: 6px; animation: fade-in 0.2s; }
|
||||
.space-menu-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border-radius: 8px; font-size: 13px; color: #4E5969; cursor: pointer; transition: all 0.2s; }
|
||||
.space-menu-item:hover { background: #F2F3F5; color: var(--primary); }
|
||||
.space-menu-item.active { background: #E8F4FF; color: var(--primary); font-weight: 600; }
|
||||
.space-menu-item .del-btn { opacity: 0; background: transparent; border: none; color: #86909C; padding: 2px; border-radius: 4px; transition: all 0.2s; }
|
||||
.space-menu-item:hover .del-btn { opacity: 1; }
|
||||
.space-menu-item .del-btn:hover { background: #FFECE8; color: #F53F3F; }
|
||||
.space-menu-divider { height: 1px; background: #F2F3F5; margin: 4px 8px; }
|
||||
.add-new { color: var(--primary); font-weight: 500; gap: 8px; justify-content: flex-start; }
|
||||
|
||||
.dir-header { display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 13px; color: #1D2129; margin-bottom: 12px; padding: 0 4px; }
|
||||
.icon-btn { background: transparent; border: none; color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; padding: 2px; border-radius: 4px; }
|
||||
.icon-btn:hover { background: #F2F3F5; color: var(--primary); }
|
||||
.dir-tree { list-style: none; padding: 0; margin: 0; }
|
||||
.sub-tree { margin-left: 12px; }
|
||||
.dir-item { display: flex; align-items: center; gap: 4px; padding: 7px 8px; border-radius: 6px; cursor: pointer; font-size: 13px; color: #4E5969; margin-bottom: 2px; user-select: none; transition: all 0.2s; }
|
||||
.dir-item:hover { background: #F2F3F5; }
|
||||
.dir-item.active { background: #E8F4FF; color: var(--primary); font-weight: 500; }
|
||||
.dir-expand-icon { width: 16px; display: flex; align-items: center; flex-shrink: 0; color: #86909C; }
|
||||
.dir-folder-icon { display: flex; align-items: center; color: #86909C; flex-shrink: 0; }
|
||||
.dir-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dir-count { font-size: 11px; color: #C0C4CC; background: #F2F3F5; padding: 1px 5px; border-radius: 8px; flex-shrink: 0; }
|
||||
|
||||
.table-content-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
||||
.table-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 10px 20px; border-bottom: 1px solid var(--border-color); background: white; flex-shrink: 0; }
|
||||
.toolbar-left, .toolbar-right { display: flex; gap: 10px; align-items: center; }
|
||||
|
||||
.batch-actions { display: flex; align-items: center; gap: 12px; padding: 0 12px; border-left: 1px solid #E5E6EB; margin-left: 8px; }
|
||||
.selection-count { font-size: 13px; color: #86909C; }
|
||||
.danger-text { color: #F53F3F !important; }
|
||||
|
||||
.tool-btn { padding: 6px 12px; border: 1px solid var(--border-color); background: white; border-radius: 6px; font-size: 13px; cursor: pointer; color: #4E5969; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.tool-btn:hover { background: #F2F3F5; }
|
||||
.primary-btn { background: var(--primary); color: white; border-color: var(--primary); }
|
||||
.primary-btn:hover { background: #005ce6; color: white; }
|
||||
|
||||
.view-toggle { display: flex; background: #F2F3F5; padding: 2px; border-radius: 6px; }
|
||||
.toggle-btn { display: flex; align-items: center; gap: 4px; border: none; background: transparent; padding: 4px 12px; font-size: 13px; color: #4E5969; border-radius: 4px; cursor: pointer; }
|
||||
.toggle-btn.active { background: white; color: var(--primary); box-shadow: var(--shadow-sm); }
|
||||
|
||||
.table-scroll { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||||
.test-table { width: 100%; border-collapse: collapse; background: white; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.06); border: 1px solid var(--border-color); }
|
||||
th { text-align: left; padding: 11px 14px; background: #F8F9FA; color: var(--text-secondary); font-size: 12px; font-weight: 600; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
|
||||
.table-row { border-bottom: 1px solid #F2F3F5; transition: background 0.15s; cursor: pointer; }
|
||||
.table-row:hover { background: #F8FAFC; }
|
||||
.table-row.selected-row { background: #EEF6FF; }
|
||||
td { padding: 10px 14px; font-size: 13px; vertical-align: middle; }
|
||||
|
||||
.title-cell { display: flex; align-items: center; gap: 6px; }
|
||||
.expand-icon { width: 16px; color: #86909C; flex-shrink: 0; }
|
||||
.inline-input { border: 1px solid transparent; background: transparent; padding: 3px 6px; border-radius: 4px; width: 100%; outline: none; font-size: 13px; }
|
||||
.inline-input:focus { background: white; border-color: var(--primary); }
|
||||
|
||||
.priority-select { border: none; padding: 2px 6px; border-radius: 4px; font-weight: 600; font-size: 12px; cursor: pointer; outline: none; }
|
||||
.priority-select.p-P0 { background: #FFECE8; color: #F53F3F; }
|
||||
.priority-select.p-P1 { background: #FFF3E8; color: #FF7D00; }
|
||||
.priority-select.p-P2 { background: #FFFBE6; color: #D48806; }
|
||||
.priority-select.p-P3 { background: #E8F4FF; color: #165DFF; }
|
||||
|
||||
.status-badge { display: inline-block; padding: 3px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
||||
.review-Reviewed { background: #E8FFFB; color: #00B42A; }
|
||||
.review-PendingReview { background: #FFF7E6; color: #FF7D00; }
|
||||
.review-Draft { background: #F2F3F5; color: #86909C; }
|
||||
.review-Deprecated { background: #FFECE8; color: #F53F3F; }
|
||||
|
||||
.case-id { font-family: monospace; color: #86909C; font-size: 12px; }
|
||||
.maintainer-cell { font-size: 13px; color: #1D2129; }
|
||||
|
||||
.row-actions { display: flex; gap: 6px; opacity: 0; transition: opacity 0.15s; }
|
||||
.table-row:hover .row-actions { opacity: 1; }
|
||||
.row-action-btn { padding: 4px 8px; border: 1px solid #E2E8F0; background: white; border-radius: 4px; font-size: 11px; cursor: pointer; color: #64748B; display: flex; align-items: center; }
|
||||
.row-action-btn:hover { background: #EEF6FF; color: var(--primary); border-color: var(--primary); }
|
||||
.row-action-btn.danger:hover { background: #FFF1F0; color: #EF4444; border-color: #EF4444; }
|
||||
|
||||
.mindmap-wrapper { flex: 1; position: relative; min-height: 0; }
|
||||
|
||||
/* Context Menu */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 4px;
|
||||
min-width: 120px;
|
||||
z-index: 1000;
|
||||
animation: fade-in 0.15s ease;
|
||||
}
|
||||
.menu-item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.menu-item:hover { background: #F2F3F5; }
|
||||
.menu-item.danger { color: #F53F3F; }
|
||||
.menu-item.danger:hover { background: #FFF1F0; }
|
||||
|
||||
/* Modal / Dialog */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.module-dialog {
|
||||
background: white;
|
||||
width: 400px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
|
||||
/* Remove overflow: hidden to allow dropdowns to show */
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
animation: modal-up 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.dialog-header h3 { margin: 0; font-size: 16px; }
|
||||
.dialog-body { padding: 24px; }
|
||||
.dialog-body label { display: block; margin-bottom: 8px; font-size: 13px; color: #86909C; }
|
||||
.dialog-footer {
|
||||
padding: 16px 24px;
|
||||
background: #F8F9FA;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
.main-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.main-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(22, 93, 255, 0.1);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary:hover { background: #005ce6; }
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #4E5969;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-secondary:hover { background: #F2F3F5; }
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #86909C;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.close-btn:hover { background: #F2F3F5; color: #1D2129; }
|
||||
@keyframes modal-up {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.review-btn {
|
||||
background: #E8F4FF;
|
||||
color: #165DFF;
|
||||
border-color: #165DFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
.review-btn:hover {
|
||||
background: #165DFF;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty-selection-placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: #86909C;
|
||||
background: white;
|
||||
margin: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed #E5E6EB;
|
||||
}
|
||||
.empty-selection-placeholder p {
|
||||
font-size: 15px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableView;
|
||||
221
src/components/editor/UserMentionInput.tsx
Normal file
221
src/components/editor/UserMentionInput.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import usersData from '../../user.json';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
const users = usersData as Record<string, string>;
|
||||
|
||||
// For reverse lookup (ID -> Name)
|
||||
export const feishuUserMap: Record<string, string> = Object.entries(users).reduce((acc, [name, id]) => {
|
||||
acc[id] = name;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
interface UserMentionInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const UserMentionInput: React.FC<UserMentionInputProps> = ({ value, onChange, placeholder }) => {
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (feishuUserMap[value] || !value) {
|
||||
setInputValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
onChange(val);
|
||||
|
||||
const searchStr = val.startsWith('@') ? val.slice(1) : val.split('@').pop() || val;
|
||||
const hasMatch = Object.keys(users).some(name => name.toLowerCase().includes(searchStr.toLowerCase()));
|
||||
|
||||
if (val.includes('@')) {
|
||||
setShowDropdown(true);
|
||||
} else {
|
||||
setShowDropdown(val.length > 0 && hasMatch);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectUser = (name: string, id: string) => {
|
||||
onChange(id);
|
||||
setInputValue(id);
|
||||
setShowDropdown(false);
|
||||
};
|
||||
|
||||
if (value && feishuUserMap[value]) {
|
||||
return (
|
||||
<div className="feishu-user-tag">
|
||||
<div className="tag-avatar" style={{ background: feishuUserMap[value] === '陶航宇' ? 'linear-gradient(135deg, #FF7D00, #F53F3F)' : 'linear-gradient(135deg, #165DFF, #36ABFF)' }}>
|
||||
{feishuUserMap[value].slice(-2)}
|
||||
</div>
|
||||
<span className="feishu-name">{feishuUserMap[value]}</span>
|
||||
<button className="clear-btn" onClick={() => {
|
||||
onChange('');
|
||||
setInputValue('');
|
||||
}}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
<style>{`
|
||||
.feishu-user-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #E8F4FF;
|
||||
border: 1px solid rgba(22, 93, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
color: #1D2129;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
}
|
||||
.tag-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.feishu-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
.clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #86909C;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.clear-btn:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
color: #F53F3F;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const searchStr = inputValue.startsWith('@') ? inputValue.slice(1) : inputValue.split('@').pop() || inputValue;
|
||||
const filteredUsers = Object.entries(users).filter(([name]) =>
|
||||
name.toLowerCase().includes(searchStr.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="user-mention-wrapper" ref={wrapperRef}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input main-input"
|
||||
style={{ width: '100%' }}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => {
|
||||
if(inputValue && filteredUsers.length > 0) setShowDropdown(true);
|
||||
}}
|
||||
placeholder={placeholder || ""}
|
||||
/>
|
||||
|
||||
|
||||
{showDropdown && filteredUsers.length > 0 && (
|
||||
<div className="mention-dropdown">
|
||||
<div className="dropdown-header">全部人员</div>
|
||||
{filteredUsers.map(([name, id]) => (
|
||||
<div
|
||||
key={id}
|
||||
className="mention-item"
|
||||
onClick={() => handleSelectUser(name, id)}
|
||||
>
|
||||
<div className="avatar" style={{ background: name === '陶航宇' ? 'linear-gradient(135deg, #FF7D00, #F53F3F)' : 'linear-gradient(135deg, #165DFF, #36ABFF)' }}>
|
||||
{name.slice(-2)}
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<span className="user-name">{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.user-mention-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.mention-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: white;
|
||||
border: 1px solid #E5E6EB;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dropdown-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #86909C;
|
||||
background: #F8F9FA;
|
||||
border-bottom: 1px solid #E5E6EB;
|
||||
}
|
||||
.mention-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
}
|
||||
.mention-item:hover {
|
||||
background: #F2F3F5;
|
||||
}
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: #1D2129;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
565
src/components/layout/Sidebar.tsx
Normal file
565
src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,565 @@
|
||||
import React, { useState } from 'react';
|
||||
import { LayoutDashboard, Library, ClipboardList, Bug, BarChart3, Plus, Tag, Calendar, Search } from 'lucide-react';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import { UserMentionInput } from '../editor/UserMentionInput';
|
||||
import { ReviewersInput, feishuUserMap } from '../editor/ReviewersInput';
|
||||
|
||||
|
||||
import type { TestPlanType } from '../../store/useStore';
|
||||
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const {
|
||||
spaceData, currentSpaceId, testPlans, testTasks,
|
||||
selectedPlanId, setSelectedPlanId, setSelectedTaskId,
|
||||
addTestPlan, viewMode, setViewMode
|
||||
} = useStore();
|
||||
|
||||
const testCases = spaceData[currentSpaceId] || [];
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
|
||||
const [newPlanName, setNewPlanName] = useState('');
|
||||
const [newPlanType, setNewPlanType] = useState<TestPlanType>('Requirement');
|
||||
const [newAssignee, setNewAssignee] = useState<string[]>([]);
|
||||
|
||||
const [selectedPriorities, setSelectedPriorities] = useState<string[]>(['P0', 'P1', 'P2', 'P3']);
|
||||
const [newPlanKeyword, setNewPlanKeyword] = useState('');
|
||||
const [selectedModules, setSelectedModules] = useState<string[]>([]);
|
||||
|
||||
// Collect unique module names from the test case tree
|
||||
const allModules = (() => {
|
||||
const modules = new Set<string>();
|
||||
const traverse = (nodes: any[]) => nodes.forEach(n => {
|
||||
if (n.text && n.id !== 'root') modules.add(n.text);
|
||||
if (n.children) traverse(n.children);
|
||||
});
|
||||
traverse(testCases);
|
||||
return Array.from(modules);
|
||||
})();
|
||||
|
||||
// Collect only meaningful directory nodes (nodes that have children)
|
||||
const topLevelModules = (() => {
|
||||
const result: { id: string; text: string }[] = [];
|
||||
const traverse = (nodes: any[]) => {
|
||||
nodes.forEach(n => {
|
||||
// A directory node: MUST have children to be considered a module/set
|
||||
if (n.children && n.children.length > 0 && n.text) {
|
||||
result.push({ id: n.id, text: n.text });
|
||||
traverse(n.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
traverse(testCases);
|
||||
return result;
|
||||
})();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Flatten test cases to compute matching cases
|
||||
const getMatchingCaseIds = () => {
|
||||
let ids: string[] = [];
|
||||
const traverse = (nodes: any[], parentModuleId?: string, depth: number = 0, parentMatched: boolean = false) => {
|
||||
nodes.forEach(node => {
|
||||
const moduleId = depth === 0 ? node.id : parentModuleId;
|
||||
const matchesPriority = selectedPriorities.length === 0 || !node.priority || selectedPriorities.includes(node.priority);
|
||||
|
||||
const currentMatchesKeyword = !newPlanKeyword ||
|
||||
node.text.toLowerCase().includes(newPlanKeyword.toLowerCase()) ||
|
||||
(node.tags && node.tags.some((t: string) => t.toLowerCase().includes(newPlanKeyword.toLowerCase())));
|
||||
|
||||
const isMatched = parentMatched || currentMatchesKeyword;
|
||||
const matchesModule = selectedModules.length === 0 || (moduleId && selectedModules.includes(moduleId));
|
||||
|
||||
const isLeaf = !node.children || node.children.length === 0;
|
||||
const isCase = node.caseId || isLeaf;
|
||||
|
||||
if (isCase && matchesPriority && isMatched && matchesModule) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
if (node.children) traverse(node.children, moduleId, depth + 1, isMatched);
|
||||
});
|
||||
};
|
||||
traverse(testCases);
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
const matchingCaseIds = getMatchingCaseIds();
|
||||
|
||||
const handleAddPlan = async () => {
|
||||
if (newPlanName) {
|
||||
await addTestPlan(newPlanName, newPlanType, matchingCaseIds, newAssignee.length > 0 ? newAssignee : undefined);
|
||||
setNewPlanName('');
|
||||
setNewAssignee([]);
|
||||
|
||||
setShowAddModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const getPlanIcon = (type: TestPlanType) => {
|
||||
switch (type) {
|
||||
case 'Self-test': return <Tag size={14} style={{ color: '#00B42A' }} />;
|
||||
case 'Regression': return <Calendar size={14} style={{ color: '#F53F3F' }} />;
|
||||
case 'Requirement': return <ClipboardList size={14} style={{ color: '#165DFF' }} />;
|
||||
default: return <Tag size={14} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="logo-container">
|
||||
<div className="logo-icon" style={{
|
||||
width: '28px', height: '28px',
|
||||
background: 'linear-gradient(135deg, #FF4D4D, #FF8D4D)',
|
||||
color: 'white', borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '16px', fontWeight: 900
|
||||
}}>D</div>
|
||||
<span>D-Case</span>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
|
||||
<div className="section-header">
|
||||
<span>工作台</span>
|
||||
</div>
|
||||
<ul className="global-nav-list">
|
||||
<li className={viewMode === 'dashboard' ? 'active' : ''} onClick={() => setViewMode('dashboard')}>
|
||||
<LayoutDashboard size={18} />
|
||||
<span>仪表盘</span>
|
||||
</li>
|
||||
<li className={viewMode === 'table' ? 'active' : ''} onClick={() => setViewMode('table')}>
|
||||
<Library size={18} />
|
||||
<span>测试用例</span>
|
||||
</li>
|
||||
<li className={viewMode === 'plans' ? 'active' : ''} onClick={() => setViewMode('plans')}>
|
||||
<ClipboardList size={18} />
|
||||
<span>测试计划</span>
|
||||
</li>
|
||||
|
||||
|
||||
<li className={viewMode === 'bugs' ? 'active' : ''} onClick={() => setViewMode('bugs')}>
|
||||
<Bug size={18} />
|
||||
<span>缺陷管理 (Jira)</span>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section" style={{ marginTop: '32px' }}>
|
||||
<div className="section-header">
|
||||
<span>测试计划</span>
|
||||
<Plus
|
||||
size={16}
|
||||
className="add-icon"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
/>
|
||||
</div>
|
||||
<ul className="plan-list">
|
||||
{testPlans.map((plan) => (
|
||||
<li
|
||||
key={plan.id}
|
||||
className={selectedPlanId === plan.id ? 'active-plan' : ''}
|
||||
onClick={() => {
|
||||
setSelectedPlanId(plan.id);
|
||||
// Find the associated task to go directly to execution
|
||||
const task = testTasks.find(t => t.planId === plan.id);
|
||||
if (task) {
|
||||
setSelectedTaskId(task.id);
|
||||
setViewMode('execution');
|
||||
} else {
|
||||
setViewMode('plans');
|
||||
}
|
||||
}}
|
||||
|
||||
|
||||
>
|
||||
<div className="plan-item-info">
|
||||
{getPlanIcon(plan.type)}
|
||||
<div className="plan-text-meta">
|
||||
<span className="plan-name">{plan.name}</span>
|
||||
<div className="plan-assignees-tiny">
|
||||
{plan.assignees && plan.assignees.length > 0
|
||||
? plan.assignees.map(uid => feishuUserMap[uid] || uid).join(', ')
|
||||
: '未分配'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="plan-type-badge">{plan.type === 'Requirement' ? '需求' : plan.type === 'Regression' ? '回归' : '自测'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
{showAddModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content card glass">
|
||||
<h3>创建测试计划</h3>
|
||||
<div className="form-group">
|
||||
<label>计划名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newPlanName}
|
||||
onChange={(e) => setNewPlanName(e.target.value)}
|
||||
placeholder="例如:2.5.0 版本需求测试"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>类型</label>
|
||||
<select
|
||||
value={newPlanType}
|
||||
onChange={(e) => setNewPlanType(e.target.value as TestPlanType)}
|
||||
className="main-input"
|
||||
>
|
||||
<option value="Requirement">需求测试</option>
|
||||
<option value="Regression">回归测试</option>
|
||||
<option value="Self-test">开发自测</option>
|
||||
<option value="Smoke">冒烟测试</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>执行人</label>
|
||||
<ReviewersInput
|
||||
value={newAssignee}
|
||||
onChange={setNewAssignee}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>筛选目录模块(可多选)</label>
|
||||
<div className="module-filters">
|
||||
{topLevelModules.map((mod: any) => (
|
||||
<label key={mod.id} className="filter-cb">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedModules.includes(mod.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedModules([...selectedModules, mod.id]);
|
||||
} else {
|
||||
setSelectedModules(selectedModules.filter(id => id !== mod.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{mod.text}
|
||||
</label>
|
||||
))}
|
||||
{topLevelModules.length === 0 && <span className="empty-hint">暂无模块</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>筛选用例标题关键词</label>
|
||||
<div className="search-input-wrapper">
|
||||
<Search size={14} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="例如:登录、下单..."
|
||||
value={newPlanKeyword}
|
||||
onChange={(e) => setNewPlanKeyword(e.target.value)}
|
||||
className="main-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>筛选用例等级</label>
|
||||
|
||||
<div className="priority-filters">
|
||||
{['P0', 'P1', 'P2', 'P3'].map(p => (
|
||||
<label key={p} className="filter-cb">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPriorities.includes(p)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedPriorities([...selectedPriorities, p]);
|
||||
} else {
|
||||
setSelectedPriorities(selectedPriorities.filter(pr => pr !== p));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{p}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="match-info">
|
||||
已匹配到 <span className="match-count">{matchingCaseIds.length}</span> 条相关用例
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
|
||||
<button onClick={() => setShowAddModal(false)}>取消</button>
|
||||
<button className="btn-confirm" onClick={handleAddPlan}>创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background: white;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--primary);
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #F2F3F5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.sidebar-section {
|
||||
padding: 0 20px;
|
||||
}
|
||||
.section-header {
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.add-icon {
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.add-icon:hover {
|
||||
background: #F1F3F5;
|
||||
color: var(--primary);
|
||||
}
|
||||
.global-nav-list, .plan-list {
|
||||
list-style: none;
|
||||
}
|
||||
.global-nav-list li, .plan-list li {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
transition: var(--transition-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
.global-nav-list li:active, .plan-list li:active {
|
||||
transform: scale(0.97);
|
||||
background: #F1F3F5;
|
||||
}
|
||||
.global-nav-list li {
|
||||
gap: 12px;
|
||||
color: #4E5969;
|
||||
font-weight: 500;
|
||||
}
|
||||
.plan-list li {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.plan-item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.plan-text-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.plan-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
}
|
||||
.plan-assignees-tiny {
|
||||
font-size: 11px;
|
||||
color: #86909C;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.plan-type-badge {
|
||||
font-size: 10px;
|
||||
background: #F1F3F5;
|
||||
color: #86909C;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
li.active, li.active-plan {
|
||||
background: #E8F4FF;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
li.active:active, li.active-plan:active {
|
||||
background: #D0E8FF;
|
||||
}
|
||||
li.active-plan .plan-type-badge {
|
||||
background: rgba(0, 102, 255, 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
li:hover:not(.active):not(.active-plan) {
|
||||
background: #F8F9FA;
|
||||
}
|
||||
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-content {
|
||||
width: 400px;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.modal-content h3 {
|
||||
margin-bottom: 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-group input, .form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
}
|
||||
.form-group input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.modal-actions button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-confirm {
|
||||
background: var(--primary) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
}
|
||||
.priority-filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.module-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.module-filters .filter-cb {
|
||||
background: #F2F3F5;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.module-filters .filter-cb:has(input:checked) {
|
||||
background: #E8F4FF;
|
||||
color: var(--primary);
|
||||
}
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: #C0C4CC;
|
||||
}
|
||||
|
||||
.filter-cb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.search-input-wrapper .search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: #86909C;
|
||||
}
|
||||
.search-input-wrapper input {
|
||||
padding-left: 32px !important;
|
||||
}
|
||||
.match-info {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #4E5969;
|
||||
background: #F2F3F5;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.match-count {
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
`}</style>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
|
||||
|
||||
108
src/components/layout/Toast.tsx
Normal file
108
src/components/layout/Toast.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { CheckCircle, AlertCircle, Info, X } from 'lucide-react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: Toast[];
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (message, type = 'info') => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, { id, message, type }],
|
||||
}));
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
}, 3000);
|
||||
},
|
||||
removeToast: (id) =>
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
})),
|
||||
}));
|
||||
|
||||
export const ToastContainer: React.FC = () => {
|
||||
const { toasts, removeToast } = useToastStore();
|
||||
|
||||
return (
|
||||
<div className="toast-container">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type} glass`}>
|
||||
<div className="toast-icon">
|
||||
{toast.type === 'success' && <CheckCircle size={18} />}
|
||||
{toast.type === 'error' && <AlertCircle size={18} />}
|
||||
{toast.type === 'info' && <Info size={18} />}
|
||||
</div>
|
||||
<div className="toast-message">{toast.message}</div>
|
||||
<button className="toast-close" onClick={() => removeToast(toast.id)}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<style>{`
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
min-width: 280px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
animation: toast-in 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from { transform: translateX(100%) scale(0.5); opacity: 0; }
|
||||
to { transform: translateX(0) scale(1); opacity: 1; }
|
||||
}
|
||||
.toast-success { border-left: 4px solid #00B42A; color: #00B42A; }
|
||||
.toast-error { border-left: 4px solid #F53F3F; color: #F53F3F; }
|
||||
.toast-info { border-left: 4px solid #165DFF; color: #165DFF; }
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1D2129;
|
||||
}
|
||||
.toast-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #86909C;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.toast-close:hover {
|
||||
background: rgba(0,0,0,0.05);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
840
src/components/plans/PlanListView.tsx
Normal file
840
src/components/plans/PlanListView.tsx
Normal file
@ -0,0 +1,840 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import type { TestPlanType, TestCaseNode } from '../../store/useStore';
|
||||
import { ClipboardList, Plus, Search, MoreHorizontal, Calendar, User, Activity, X, Tag, Trash2 } from 'lucide-react';
|
||||
import { UserMentionInput } from '../editor/UserMentionInput';
|
||||
import { ReviewersInput, feishuUserMap } from '../editor/ReviewersInput';
|
||||
|
||||
|
||||
const PlanListView: React.FC = () => {
|
||||
const {
|
||||
spaceData, currentSpaceId, testPlans, testTasks,
|
||||
addTestPlan, deleteTestPlan, updateTestPlan, copyTestPlan,
|
||||
setViewMode, setSelectedPlanId, setSelectedTaskId
|
||||
} = useStore();
|
||||
|
||||
const testCases = spaceData[currentSpaceId] || [];
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [planName, setPlanName] = useState('');
|
||||
const [planType, setPlanType] = useState<TestPlanType>('Requirement');
|
||||
const [assignees, setAssignees] = useState<string[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selectedModules, setSelectedModules] = useState<string[]>([]);
|
||||
const [selectedPriorities, setSelectedPriorities] = useState<string[]>(['P0', 'P1', 'P2', 'P3']);
|
||||
|
||||
const [editingPlanId, setEditingPlanId] = useState<string | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
|
||||
|
||||
// Close menu when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClick = () => setActiveMenuId(null);
|
||||
window.addEventListener('click', handleClick);
|
||||
return () => window.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
const topLevelModules = (() => {
|
||||
const result: { id: string; text: string }[] = [];
|
||||
const traverse = (nodes: any[]) => {
|
||||
nodes.forEach(n => {
|
||||
// Only treat nodes WITH children as modules/directories to avoid redundancy
|
||||
if (n.children && n.children.length > 0 && n.text) {
|
||||
result.push({ id: n.id, text: n.text });
|
||||
traverse(n.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
traverse(testCases);
|
||||
return result;
|
||||
})();
|
||||
|
||||
|
||||
// Compute matching case IDs based on filters
|
||||
const getMatchingCaseIds = () => {
|
||||
const ids: string[] = [];
|
||||
const traverse = (nodes: TestCaseNode[], parentModuleId?: string, depth: number = 0, parentMatched: boolean = false) => {
|
||||
nodes.forEach(node => {
|
||||
const moduleId = depth === 0 ? node.id : parentModuleId;
|
||||
|
||||
// Priority: match if no filter selected, or node matches selected priority, or node has no priority
|
||||
const matchesPriority = selectedPriorities.length === 0 ||
|
||||
!node.priority || selectedPriorities.includes(node.priority);
|
||||
|
||||
// Keyword: match title or tags
|
||||
const currentMatchesKeyword = !keyword ||
|
||||
node.text.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||
(node.tags && node.tags.some(t => t.toLowerCase().includes(keyword.toLowerCase())));
|
||||
|
||||
const isMatched = parentMatched || currentMatchesKeyword;
|
||||
|
||||
// Module: match if no module filter, or node belongs to selected module
|
||||
const matchesModule = selectedModules.length === 0 ||
|
||||
(moduleId && selectedModules.includes(moduleId));
|
||||
|
||||
const isLeaf = !node.children || node.children.length === 0;
|
||||
const isCase = node.caseId || isLeaf;
|
||||
|
||||
if (isCase && matchesPriority && isMatched && matchesModule) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
if (node.children) traverse(node.children, moduleId, depth + 1, isMatched);
|
||||
});
|
||||
};
|
||||
traverse(testCases);
|
||||
return ids;
|
||||
};
|
||||
|
||||
|
||||
const matchingIds = getMatchingCaseIds();
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!planName.trim()) return;
|
||||
await addTestPlan(planName, planType, matchingIds, assignees.length > 0 ? assignees : undefined);
|
||||
setPlanName('');
|
||||
setAssignees([]);
|
||||
|
||||
setKeyword('');
|
||||
setSelectedModules([]);
|
||||
setSelectedPriorities([]);
|
||||
setShowCreateModal(false);
|
||||
};
|
||||
|
||||
const handleDeletePlan = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('确定要删除这个测试计划吗?相关的执行任务也会被移除。')) {
|
||||
deleteTestPlan(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditOpen = (plan: any) => {
|
||||
setEditingPlanId(plan.id);
|
||||
setPlanName(plan.name);
|
||||
setPlanType(plan.type);
|
||||
setAssignees(plan.assignees || []);
|
||||
setShowEditModal(true);
|
||||
setActiveMenuId(null);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (editingPlanId && planName.trim()) {
|
||||
await updateTestPlan(editingPlanId, {
|
||||
name: planName,
|
||||
type: planType,
|
||||
assignees: assignees
|
||||
});
|
||||
setShowEditModal(false);
|
||||
setEditingPlanId(null);
|
||||
setPlanName('');
|
||||
setAssignees([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (id: string) => {
|
||||
await copyTestPlan(id);
|
||||
setActiveMenuId(null);
|
||||
};
|
||||
const handleOpenTask = (planId: string) => {
|
||||
const task = testTasks.find(t => t.planId === planId);
|
||||
|
||||
if (task) {
|
||||
setSelectedTaskId(task.id);
|
||||
setViewMode('execution');
|
||||
} else {
|
||||
// Fallback if task was not created properly
|
||||
setSelectedPlanId(planId);
|
||||
setViewMode('table');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPlans = searchQuery
|
||||
? testPlans.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: testPlans;
|
||||
|
||||
const typeLabels: Record<TestPlanType, string> = {
|
||||
'Requirement': '需求测试',
|
||||
'Regression': '回归测试',
|
||||
'Self-test': '自测',
|
||||
'Smoke': '冒烟测试',
|
||||
};
|
||||
|
||||
const getProgress = (plan: any) => {
|
||||
const total = plan.caseIds?.length || 0;
|
||||
if (total === 0) return { pct: 0, total, executed: 0 };
|
||||
|
||||
let executed = 0;
|
||||
const traverse = (nodes: TestCaseNode[]) => {
|
||||
nodes.forEach(node => {
|
||||
if (plan.caseIds.includes(node.id) && node.executionStatus && node.executionStatus !== 'UNTESTED') {
|
||||
executed++;
|
||||
}
|
||||
if (node.children) traverse(node.children);
|
||||
});
|
||||
};
|
||||
traverse(testCases);
|
||||
|
||||
return { pct: Math.round((executed / total) * 100), total, executed };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="plan-list-container">
|
||||
<div className="view-header">
|
||||
<div className="header-left">
|
||||
<h2>测试计划</h2>
|
||||
<span className="count-badge">{testPlans.length}</span>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<div className="search-box">
|
||||
<Search size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索计划..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="create-plan-btn" onClick={() => setShowCreateModal(true)}>
|
||||
<Plus size={16} /> 创建计划
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plan-grid">
|
||||
{filteredPlans.map(plan => {
|
||||
const progress = getProgress(plan);
|
||||
return (
|
||||
<div key={plan.id} className="plan-card" onClick={() => handleOpenTask(plan.id)}>
|
||||
<div className="plan-card-header">
|
||||
<div className={`plan-type-icon type-${plan.type.toLowerCase().replace('-', '')}`}>
|
||||
<ClipboardList size={20} />
|
||||
</div>
|
||||
<div className="plan-type-label">{typeLabels[plan.type] || plan.type}</div>
|
||||
<div className="header-actions">
|
||||
<div className="menu-wrapper">
|
||||
<button
|
||||
className="more-btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setActiveMenuId(activeMenuId === plan.id ? null : plan.id);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
{activeMenuId === plan.id && (
|
||||
<div className="dropdown-menu">
|
||||
<div className="menu-item" onClick={e => { e.stopPropagation(); handleEditOpen(plan); }}>编辑计划</div>
|
||||
<div className="menu-item" onClick={e => { e.stopPropagation(); handleCopy(plan.id); }}>复制计划</div>
|
||||
<div className="menu-item delete" onClick={e => { e.stopPropagation(); handleDeletePlan(e, plan.id); }}>删除计划</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="plan-card-body">
|
||||
<h3 className="plan-title">{plan.name}</h3>
|
||||
<div className="plan-meta-row">
|
||||
<div className="meta-item">
|
||||
<Calendar size={14} />
|
||||
<span>{plan.createdAt?.split('T')[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plan-progress-section">
|
||||
<div className="progress-label">
|
||||
<span>执行进度</span>
|
||||
<span>{progress.executed}/{progress.total} ({progress.pct}%)</span>
|
||||
</div>
|
||||
<div className="progress-bar-bg">
|
||||
<div className="progress-bar-fill" style={{ width: `${progress.pct}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plan-card-footer">
|
||||
<div className="stat-pill"><Activity size={12} /> {plan.caseIds?.length || 0} 用例</div>
|
||||
<button className="go-btn" onClick={e => { e.stopPropagation(); handleOpenTask(plan.id); }}>查看详情 →</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredPlans.length === 0 && (
|
||||
<div className="empty-plans">
|
||||
<ClipboardList size={48} />
|
||||
<p>{searchQuery ? '没有匹配的计划' : '暂无测试计划,点击右上角创建'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========== Create Plan Modal ========== */}
|
||||
{showCreateModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreateModal(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>创建测试计划</h3>
|
||||
<button className="close-btn" onClick={() => setShowCreateModal(false)}><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>计划名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="例如:2.5.0 版本需求测试"
|
||||
value={planName}
|
||||
onChange={e => setPlanName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>类型</label>
|
||||
<select className="form-input" value={planType} onChange={e => setPlanType(e.target.value as TestPlanType)}>
|
||||
<option value="Requirement">需求测试</option>
|
||||
<option value="Regression">回归测试</option>
|
||||
<option value="Self-test">自测</option>
|
||||
<option value="Smoke">冒烟测试</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>执行人</label>
|
||||
<ReviewersInput
|
||||
value={assignees}
|
||||
onChange={setAssignees}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="form-group">
|
||||
<label>筛选目录模块(可多选)</label>
|
||||
<div className="module-checkboxes">
|
||||
{topLevelModules.map(m => (
|
||||
<label key={m.id} className={`module-chip ${selectedModules.includes(m.id) ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedModules.includes(m.id)}
|
||||
onChange={() => {
|
||||
setSelectedModules(prev =>
|
||||
prev.includes(m.id) ? prev.filter(x => x !== m.id) : [...prev, m.id]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{m.text}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>筛选用例标题 / 标签</label>
|
||||
<div className="search-input-wrapper">
|
||||
<Search size={16} />
|
||||
<input
|
||||
type="text"
|
||||
className="form-input search-input"
|
||||
placeholder="输入关键词筛选用例..."
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>筛选用例等级</label>
|
||||
<div className="priority-checkboxes">
|
||||
{['P0', 'P1', 'P2', 'P3'].map(p => (
|
||||
<label key={p} className={`priority-chip p-${p} ${selectedPriorities.includes(p) ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPriorities.includes(p)}
|
||||
onChange={() => {
|
||||
setSelectedPriorities(prev =>
|
||||
prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{p}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="match-result">
|
||||
已匹配到 <strong>{matchingIds.length}</strong> 条相关用例
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="cancel-btn" onClick={() => setShowCreateModal(false)}>取消</button>
|
||||
<button className="submit-btn" onClick={handleCreate} disabled={!planName.trim()}>创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEditModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowEditModal(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>编辑测试计划</h3>
|
||||
<button className="close-btn" onClick={() => setShowEditModal(false)}><X size={20} /></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>计划名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="例如:2.5.0 版本需求测试"
|
||||
value={planName}
|
||||
onChange={e => setPlanName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>类型</label>
|
||||
<select className="form-input" value={planType} onChange={e => setPlanType(e.target.value as TestPlanType)}>
|
||||
<option value="Requirement">需求测试</option>
|
||||
<option value="Regression">回归测试</option>
|
||||
<option value="Self-test">自测</option>
|
||||
<option value="Smoke">冒烟测试</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>执行人</label>
|
||||
<ReviewersInput
|
||||
value={assignees}
|
||||
onChange={setAssignees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="cancel-btn" onClick={() => setShowEditModal(false)}>取消</button>
|
||||
<button className="submit-btn" onClick={handleSaveEdit} disabled={!planName.trim()}>保存修改</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.plan-list-container {
|
||||
padding: 32px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #F8FAFC;
|
||||
}
|
||||
.view-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.header-left h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1D2129;
|
||||
margin: 0;
|
||||
}
|
||||
.count-badge {
|
||||
padding: 2px 8px;
|
||||
background: #E8F4FF;
|
||||
color: #165DFF;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border: 1px solid #E5E6EB;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
width: 240px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-box:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(22, 93, 255, 0.1);
|
||||
}
|
||||
.search-box input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
.create-plan-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.create-plan-btn:hover { background: #005ce6; }
|
||||
|
||||
.plan-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
.plan-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #E5E6EB;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.plan-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0,0,0,0.05);
|
||||
border-color: #165DFF;
|
||||
}
|
||||
.plan-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
}
|
||||
.plan-type-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.type-requirement { background: #E8F4FF; color: #165DFF; }
|
||||
.type-regression { background: #F7E8FF; color: #9FDB1D; }
|
||||
.type-selftest { background: #E8FFFB; color: #00B42A; }
|
||||
.type-smoke { background: #FFF7E8; color: #FF7D00; }
|
||||
|
||||
.plan-type-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #86909C;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.header-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.more-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #86909C;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.more-btn:hover { background: #F2F3F5; color: #1D2129; }
|
||||
|
||||
.menu-wrapper { position: relative; }
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
|
||||
border: 1px solid #E5E6EB;
|
||||
z-index: 10;
|
||||
width: 120px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.menu-item {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
color: #4E5969;
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-item:hover { background: #F2F3F5; color: #1D2129; }
|
||||
.menu-item.delete { color: #F53F3F; }
|
||||
.menu-item.delete:hover { background: #FFF1F0; }
|
||||
|
||||
.plan-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1D2129;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.plan-meta-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #86909C;
|
||||
}
|
||||
|
||||
.plan-progress-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.progress-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #4E5969;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.progress-bar-bg {
|
||||
height: 6px;
|
||||
background: #F2F3F5;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #165DFF, #3491FA);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.plan-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #F2F3F5;
|
||||
}
|
||||
.stat-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #86909C;
|
||||
background: #F7F8FA;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.go-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #165DFF;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-plans {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
color: #86909C;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
width: 560px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 48px rgba(0,0,0,0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible; /* Ensure dropdowns can show outside */
|
||||
}
|
||||
.modal-header {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #E5E6EB;
|
||||
}
|
||||
.modal-header h3 { margin: 0; font-size: 18px; }
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
overflow: visible; /* Fix clipping for dropdowns */
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.form-group { margin-bottom: 20px; }
|
||||
.form-group label { display: block; margin-bottom: 8px; font-size: 14px; font-weight: 600; color: #4E5969; }
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #E5E6EB;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.form-input:focus { border-color: var(--primary); }
|
||||
|
||||
.module-checkboxes, .priority-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.module-chip, .priority-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #F2F3F5;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.module-chip.active, .priority-chip.active {
|
||||
background: #E8F4FF;
|
||||
color: #165DFF;
|
||||
}
|
||||
.module-chip input, .priority-chip input { display: none; }
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.search-input-wrapper svg {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: #86909C;
|
||||
}
|
||||
.search-input {
|
||||
padding-left: 36px !important;
|
||||
}
|
||||
.module-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.module-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #E5E6EB;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #4E5969;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.module-chip input { display: none; }
|
||||
.module-chip:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.module-chip.active {
|
||||
background: #E8F4FF;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.priority-checkboxes {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.priority-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid #E5E6EB;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.priority-chip input { display: none; }
|
||||
.priority-chip.active.p-P0 { background: #FFF0F0; border-color: #F53F3F; color: #F53F3F; }
|
||||
.priority-chip.active.p-P1 { background: #FFF7E6; border-color: #FF7D00; color: #FF7D00; }
|
||||
.priority-chip.active.p-P2 { background: #FFFBE6; border-color: #D48806; color: #D48806; }
|
||||
.priority-chip.active.p-P3 { background: #E8F4FF; border-color: #165DFF; color: #165DFF; }
|
||||
.match-result {
|
||||
padding: 12px 16px;
|
||||
background: #F8FAFC;
|
||||
border: 1px solid #E5E6EB;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #4E5969;
|
||||
}
|
||||
.match-result strong {
|
||||
color: var(--primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #F2F3F5;
|
||||
}
|
||||
.cancel-btn {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #E5E6EB;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
color: #4E5969;
|
||||
}
|
||||
.cancel-btn:hover { background: #F2F3F5; }
|
||||
.submit-btn {
|
||||
padding: 8px 24px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.submit-btn:hover { background: #005ce6; }
|
||||
.submit-btn:disabled { background: #C0C4CC; cursor: not-allowed; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanListView;
|
||||
411
src/components/plans/TaskExecutionView.tsx
Normal file
411
src/components/plans/TaskExecutionView.tsx
Normal file
@ -0,0 +1,411 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import { ChevronLeft, CheckCircle, XCircle, Ban, Play, Save, Bug, LayoutGrid, List } from 'lucide-react';
|
||||
import { useToastStore } from '../layout/Toast';
|
||||
import MindMapView from '../editor/MindMapView';
|
||||
|
||||
const TaskExecutionView: React.FC = () => {
|
||||
const { spaceData, currentSpaceId, testTasks, selectedTaskId, setViewMode, testPlans, updateNode, updateTaskStatus, addBug } = useStore();
|
||||
const testCases = spaceData[currentSpaceId] || [];
|
||||
const { addToast } = useToastStore();
|
||||
|
||||
const task = testTasks.find(t => t.id === selectedTaskId);
|
||||
const plan = testPlans.find(p => p.id === task?.planId);
|
||||
|
||||
const [displayMode, setDisplayMode] = useState<'list' | 'mindmap'>('list');
|
||||
|
||||
useEffect(() => {
|
||||
if (plan && useStore.getState().selectedPlanId !== plan.id) {
|
||||
useStore.getState().setSelectedPlanId(plan.id);
|
||||
}
|
||||
}, [plan]);
|
||||
|
||||
// 查找属于该计划的用例
|
||||
const getPlanCases = () => {
|
||||
// Robust search for the plan if testPlans is still loading
|
||||
const currentPlan = plan || testPlans.find(p => p.id === (task?.planId || useStore.getState().selectedPlanId));
|
||||
if (!currentPlan || !currentPlan.caseIds) return [];
|
||||
|
||||
const results: any[] = [];
|
||||
const targetIds = new Set(currentPlan.caseIds);
|
||||
|
||||
// Search across all spaceData if currentSpaceId is not set yet
|
||||
const allData = currentSpaceId ? [testCases] : Object.values(spaceData);
|
||||
|
||||
const traverse = (nodes: any[]) => {
|
||||
nodes.forEach(node => {
|
||||
if (targetIds.has(node.id)) {
|
||||
results.push(node);
|
||||
}
|
||||
if (node.children) traverse(node.children);
|
||||
});
|
||||
};
|
||||
|
||||
allData.forEach(tree => traverse(tree));
|
||||
return results;
|
||||
};
|
||||
|
||||
|
||||
const planCases = getPlanCases();
|
||||
|
||||
// If we have plan but no cases found, and currentSpaceId is empty, we might need a sync
|
||||
useEffect(() => {
|
||||
if (plan && !currentSpaceId && Object.keys(spaceData).length > 0) {
|
||||
// Try to find which space this plan belongs to
|
||||
// (Simplified: for now just fetch data if empty)
|
||||
}
|
||||
}, [plan, currentSpaceId, spaceData]);
|
||||
|
||||
|
||||
if (!task) return <div className="p-8">任务不存在</div>;
|
||||
|
||||
return (
|
||||
<div className="execution-container">
|
||||
<header className="execution-header glass">
|
||||
<div className="header-left">
|
||||
<button className="btn-icon" onClick={() => setViewMode('plans')}>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<div className="task-title-area">
|
||||
<h2>{task.name}</h2>
|
||||
<div className="task-badges">
|
||||
<span className={`badge status-${task.status.toLowerCase()}`}>{task.status}</span>
|
||||
<span className="badge plan-type">{plan?.type || 'General'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="view-toggle" style={{ display: 'flex', background: '#F2F3F5', borderRadius: '6px', padding: '2px', marginRight: '16px' }}>
|
||||
<button
|
||||
className={`toggle-btn ${displayMode === 'list' ? 'active' : ''}`}
|
||||
style={{ padding: '4px 12px', border: 'none', background: displayMode === 'list' ? 'white' : 'transparent', borderRadius: '4px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', boxShadow: displayMode === 'list' ? '0 1px 4px rgba(0,0,0,0.1)' : 'none' }}
|
||||
onClick={() => setDisplayMode('list')}
|
||||
>
|
||||
<List size={14} /> 列表
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${displayMode === 'mindmap' ? 'active' : ''}`}
|
||||
style={{ padding: '4px 12px', border: 'none', background: displayMode === 'mindmap' ? 'white' : 'transparent', borderRadius: '4px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', boxShadow: displayMode === 'mindmap' ? '0 1px 4px rgba(0,0,0,0.1)' : 'none' }}
|
||||
onClick={() => setDisplayMode('mindmap')}
|
||||
>
|
||||
<LayoutGrid size={14} /> 脑图
|
||||
</button>
|
||||
</div>
|
||||
{task.status !== 'COMPLETED' && (
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => {
|
||||
updateTaskStatus(task.id, 'COMPLETED');
|
||||
addToast('执行完成,计划进度已更新!', 'success');
|
||||
setViewMode('plans');
|
||||
}}
|
||||
>
|
||||
<Save size={16} />
|
||||
完成执行
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{displayMode === 'list' ? (
|
||||
<div className="execution-body">
|
||||
<div className="execution-sidebar glass">
|
||||
<h3>用例列表 ({planCases.length})</h3>
|
||||
<div className="case-nav-list">
|
||||
{planCases.map(c => (
|
||||
<div key={c.id} className="case-nav-item">
|
||||
<span className={`status-dot ${c.executionStatus || 'UNTESTED'}`}></span>
|
||||
<span className="case-text">{c.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="execution-content">
|
||||
<div className="case-details-grid">
|
||||
{planCases.map(c => (
|
||||
<div key={c.id} className="case-exec-card glass">
|
||||
<div className="card-header">
|
||||
<div className="case-id-text">
|
||||
<span className="p-tag">{c.priority}</span>
|
||||
<h4>{c.text}</h4>
|
||||
</div>
|
||||
<div className="exec-actions">
|
||||
<button
|
||||
className={`btn-exec pass ${c.executionStatus === 'PASS' ? 'active' : ''}`}
|
||||
onClick={() => updateNode(c.id, { executionStatus: 'PASS' })}
|
||||
>
|
||||
<CheckCircle size={16} />
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
className={`btn-exec fail ${c.executionStatus === 'FAIL' ? 'active' : ''}`}
|
||||
onClick={() => updateNode(c.id, { executionStatus: 'FAIL' })}
|
||||
>
|
||||
<XCircle size={16} />
|
||||
失败
|
||||
</button>
|
||||
<button
|
||||
className={`btn-exec block ${c.executionStatus === 'BLOCK' ? 'active' : ''}`}
|
||||
onClick={() => updateNode(c.id, { executionStatus: 'BLOCK' })}
|
||||
>
|
||||
<Ban size={16} />
|
||||
阻塞
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.steps && c.steps.length > 0 && (
|
||||
<div className="exec-steps">
|
||||
<h5>执行步骤</h5>
|
||||
<div className="steps-list">
|
||||
{c.steps.map((s: any, idx: number) => (
|
||||
<div key={idx} className="step-row">
|
||||
<span className="step-num">{idx + 1}</span>
|
||||
<span className="step-action">{s.action}</span>
|
||||
<span className="step-expect">{s.expected}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-footer">
|
||||
{c.executionStatus === 'FAIL' && !c.bugId && (
|
||||
<button
|
||||
className="btn-bug"
|
||||
onClick={() => addBug(c.id, `【执行失败】${c.text}`)}
|
||||
>
|
||||
<Bug size={14} />
|
||||
一键提 Bug
|
||||
</button>
|
||||
)}
|
||||
{c.bugId && (
|
||||
<div className="bug-linked">
|
||||
<Bug size={14} className="icon-red" />
|
||||
已关联 Bug: {c.bugId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="execution-body" style={{ display: 'block', padding: 0 }}>
|
||||
<MindMapView executionMode={true} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.execution-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #F1F5F9;
|
||||
}
|
||||
.execution-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.task-title-area h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #0F172A;
|
||||
}
|
||||
.task-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge.status-running { background: #E0F2FE; color: #0369A1; }
|
||||
.badge.status-pending { background: #F1F5F9; color: #64748B; }
|
||||
.badge.status-completed { background: #DCFCE7; color: #15803D; }
|
||||
.badge.plan-type { background: #F5F3FF; color: #7C3AED; }
|
||||
|
||||
.execution-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.execution-sidebar {
|
||||
width: 280px;
|
||||
border-right: 1px solid #E2E8F0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: white;
|
||||
}
|
||||
.execution-sidebar h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #64748B;
|
||||
}
|
||||
.case-nav-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.case-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
}
|
||||
.case-nav-item:hover { background: #F1F5F9; }
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #CBD5E1;
|
||||
}
|
||||
.status-dot.PASS { background: #10B981; }
|
||||
.status-dot.FAIL { background: #EF4444; }
|
||||
.status-dot.BLOCK { background: #F59E0B; }
|
||||
|
||||
.execution-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.case-details-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.case-exec-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #E2E8F0;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.case-id-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.p-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
background: #3B82F6;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.case-id-text h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #1E293B;
|
||||
}
|
||||
.exec-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-exec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #E2E8F0;
|
||||
background: white;
|
||||
color: #64748B;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-exec:hover { background: #F8FAFC; }
|
||||
.btn-exec.pass.active { background: #DCFCE7; color: #15803D; border-color: #86EFAC; }
|
||||
.btn-exec.fail.active { background: #FEE2E2; color: #B91C1C; border-color: #FECACA; }
|
||||
.btn-exec.block.active { background: #FEF3C7; color: #92400E; border-color: #FDE68A; }
|
||||
|
||||
.exec-steps {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: #F8FAFC;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.exec-steps h5 {
|
||||
font-size: 12px;
|
||||
color: #94A3B8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.step-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr 1fr;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
}
|
||||
.step-row:last-child { border-bottom: none; }
|
||||
.step-num { color: #94A3B8; font-weight: 600; }
|
||||
.step-action { color: #334155; }
|
||||
.step-expect { color: #64748B; }
|
||||
|
||||
.card-footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #F1F5F9;
|
||||
}
|
||||
.btn-bug {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #EF4444;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bug-linked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #EF4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskExecutionView;
|
||||
245
src/components/shared/BugView.tsx
Normal file
245
src/components/shared/BugView.tsx
Normal file
@ -0,0 +1,245 @@
|
||||
import React from 'react';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import { Bug as BugIcon, CheckCircle, Clock } from 'lucide-react';
|
||||
|
||||
const BugView: React.FC = () => {
|
||||
const { bugs, deleteBug } = useStore();
|
||||
|
||||
const [selectedBug, setSelectedBug] = React.useState<any>(null);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (window.confirm('确定要删除这个 Bug 记录吗?')) {
|
||||
deleteBug(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bug-view-container">
|
||||
<div className="bug-header">
|
||||
<h2>缺陷管理 (Bug Tracker)</h2>
|
||||
<div className="bug-stats">
|
||||
<div className="stat-pill total">
|
||||
<span>总计: {bugs.length}</span>
|
||||
</div>
|
||||
<div className="stat-pill open">
|
||||
<span>处理中: {bugs.filter(b => b.status === 'OPEN').length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bug-list card">
|
||||
{bugs.length === 0 ? (
|
||||
<div className="empty-state">当前没有记录任何 Bug。</div>
|
||||
) : (
|
||||
<table className="bug-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bug ID</th>
|
||||
<th>标题</th>
|
||||
<th>关联用例</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bugs.map(bug => (
|
||||
<tr key={bug.id}>
|
||||
<td className="bug-id">{bug.id}</td>
|
||||
<td className="bug-title">{bug.title}</td>
|
||||
<td className="case-id">{bug.caseId}</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${bug.status.toLowerCase()}`}>
|
||||
{bug.status === 'OPEN' ? '未解决' :
|
||||
bug.status === 'RESOLVED' ? '已修复' : '已关闭'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="action-btns">
|
||||
<button className="btn-link" onClick={() => setSelectedBug(bug)}>查看详情</button>
|
||||
<button className="btn-link btn-delete" onClick={() => handleDelete(bug.id)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bug Details Modal */}
|
||||
{selectedBug && (
|
||||
<div className="bug-modal-overlay" onClick={() => setSelectedBug(null)}>
|
||||
<div className="bug-modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="bug-modal-header">
|
||||
<h3>缺陷详情 - {selectedBug.id}</h3>
|
||||
<button className="close-btn" onClick={() => setSelectedBug(null)}>×</button>
|
||||
</div>
|
||||
<div className="bug-modal-body">
|
||||
<div className="detail-row">
|
||||
<label>Bug 标题</label>
|
||||
<div className="detail-value">{selectedBug.title}</div>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<label>关联用例 ID</label>
|
||||
<div className="detail-value code">{selectedBug.caseId}</div>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<label>当前状态</label>
|
||||
<div className="detail-value">
|
||||
<span className={`status-badge status-${selectedBug.status.toLowerCase()}`}>
|
||||
{selectedBug.status === 'OPEN' ? '未解决' :
|
||||
selectedBug.status === 'RESOLVED' ? '已修复' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<label>描述信息</label>
|
||||
<div className="detail-value description">{selectedBug.description || '暂无详细描述'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bug-modal-footer">
|
||||
<button className="btn-primary" onClick={() => setSelectedBug(null)}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<style>{`
|
||||
.bug-view-container {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
background: #F8FAFC;
|
||||
}
|
||||
.bug-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.bug-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.stat-pill {
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.stat-pill.total { background: #E8F4FF; color: #165DFF; }
|
||||
.stat-pill.open { background: #FFECE8; color: #F53F3F; }
|
||||
|
||||
.bug-list {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bug-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
.bug-table th {
|
||||
background: #F8F9FA;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: #4E5969;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid #E5E6EB;
|
||||
}
|
||||
.bug-table td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #F2F3F5;
|
||||
font-size: 14px;
|
||||
color: #1D2129;
|
||||
}
|
||||
.bug-table tr:hover td {
|
||||
background: #F8FAFC;
|
||||
}
|
||||
|
||||
.bug-id, .case-id {
|
||||
font-family: monospace;
|
||||
color: #86909C;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-open { background: #FFECE8; color: #F53F3F; }
|
||||
.status-resolved { background: #FFF3E8; color: #FF7D00; }
|
||||
.status-closed { background: #E8FFFB; color: #00B42A; }
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #165DFF;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn-link:hover { text-decoration: underline; }
|
||||
.btn-delete {
|
||||
color: #F53F3F;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: #86909C;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.bug-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.bug-modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 500px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
.bug-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #F2F3F5;
|
||||
}
|
||||
.bug-modal-header h3 { font-size: 16px; margin: 0; font-weight: 600; }
|
||||
.bug-modal-header .close-btn {
|
||||
background: none; border: none; font-size: 24px; cursor: pointer; color: #86909C;
|
||||
}
|
||||
.bug-modal-body { padding: 20px; }
|
||||
.detail-row { margin-bottom: 20px; }
|
||||
.detail-row label { display: block; font-size: 12px; color: #86909C; margin-bottom: 6px; font-weight: 500; }
|
||||
.detail-value { font-size: 14px; color: #1D2129; line-height: 1.5; }
|
||||
.detail-value.code { font-family: monospace; background: #F2F3F5; padding: 2px 6px; border-radius: 4px; }
|
||||
.detail-value.description { white-space: pre-wrap; background: #F8FAFC; padding: 12px; border-radius: 6px; border: 1px solid #E5E6EB; }
|
||||
.bug-modal-footer { padding: 16px 20px; border-top: 1px solid #F2F3F5; display: flex; justify-content: flex-end; }
|
||||
.btn-primary {
|
||||
background: #165DFF; color: white; border: none; padding: 8px 24px; border-radius: 4px; cursor: pointer; font-weight: 500;
|
||||
}
|
||||
.btn-primary:hover { background: #005ce6; }
|
||||
`}</style>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BugView;
|
||||
456
src/components/shared/DashboardView.tsx
Normal file
456
src/components/shared/DashboardView.tsx
Normal file
@ -0,0 +1,456 @@
|
||||
import React from 'react';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import type { TestCaseNode } from '../../store/useStore';
|
||||
|
||||
import { BarChart3, PieChart, TrendingUp, CheckCircle2, XCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
const DashboardView: React.FC = () => {
|
||||
const { spaceData, currentSpaceId, testTasks, setViewMode, setSelectedTaskId, currentUser } = useStore();
|
||||
|
||||
const testCases = spaceData[currentSpaceId] || [];
|
||||
|
||||
const handleOpenTask = (taskId: string) => {
|
||||
setSelectedTaskId(taskId);
|
||||
setViewMode('execution');
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Simple aggregation logic
|
||||
const getStats = (nodes: TestCaseNode[]) => {
|
||||
let stats = { total: 0, P0: 0, P1: 0, P2: 0, P3: 0, pass: 0, fail: 0, untested: 0 };
|
||||
|
||||
const traverse = (items: TestCaseNode[]) => {
|
||||
items.forEach(node => {
|
||||
stats.total++;
|
||||
if (node.priority) stats[node.priority]++;
|
||||
if (node.executionStatus === 'PASS') stats.pass++;
|
||||
else if (node.executionStatus === 'FAIL') stats.fail++;
|
||||
else stats.untested++;
|
||||
|
||||
|
||||
if (node.children) traverse(node.children);
|
||||
});
|
||||
};
|
||||
|
||||
traverse(nodes);
|
||||
return stats;
|
||||
};
|
||||
|
||||
const stats = getStats(testCases);
|
||||
const passRate = stats.total > 0 ? Math.round((stats.pass / stats.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-header">
|
||||
<span className="stat-label">总用例数</span>
|
||||
<BarChart3 size={20} className="icon-blue" />
|
||||
</div>
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
<div className="stat-footer">
|
||||
<TrendingUp size={14} />
|
||||
<span>较上周 +12%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-header">
|
||||
<span className="stat-label">通过率</span>
|
||||
<CheckCircle2 size={20} className="icon-green" />
|
||||
</div>
|
||||
<div className="stat-value">{passRate}%</div>
|
||||
<div className="progress-bar-bg">
|
||||
<div className="progress-bar-fill" style={{ width: `${passRate}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-header">
|
||||
<span className="stat-label">P0 核心用例</span>
|
||||
<AlertCircle size={20} className="icon-red" />
|
||||
</div>
|
||||
<div className="stat-value">{stats.P0}</div>
|
||||
<div className="priority-distribution">
|
||||
<div className="p-segment p0" style={{ flex: stats.P0 }}></div>
|
||||
<div className="p-segment p1" style={{ flex: stats.P1 }}></div>
|
||||
<div className="p-segment p2" style={{ flex: stats.P2 }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-charts">
|
||||
<div className="chart-card glass">
|
||||
<h3>执行分布 (Execution Distribution)</h3>
|
||||
<div className="distribution-list">
|
||||
<div className="dist-item">
|
||||
<div className="dist-label">
|
||||
<CheckCircle2 size={16} color="#00B42A" />
|
||||
<span>已通过 (Pass)</span>
|
||||
</div>
|
||||
<div className="dist-bar-container">
|
||||
<div className="dist-bar pass" style={{ width: `${stats.total > 0 ? (stats.pass/stats.total)*100 : 0}%` }}></div>
|
||||
<span className="dist-value">{stats.pass}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dist-item">
|
||||
<div className="dist-label">
|
||||
<XCircle size={16} color="#F53F3F" />
|
||||
<span>未通过 (Fail)</span>
|
||||
</div>
|
||||
<div className="dist-bar-container">
|
||||
<div className="dist-bar fail" style={{ width: `${stats.total > 0 ? (stats.fail/stats.total)*100 : 0}%` }}></div>
|
||||
<span className="dist-value">{stats.fail}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dist-item">
|
||||
<div className="dist-label">
|
||||
<PieChart size={16} color="#86909C" />
|
||||
<span>未开始 (Untested)</span>
|
||||
</div>
|
||||
<div className="dist-bar-container">
|
||||
<div className="dist-bar untested" style={{ width: `${stats.total > 0 ? (stats.untested/stats.total)*100 : 0}%` }}></div>
|
||||
<span className="dist-value">{stats.untested}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-card glass">
|
||||
<h3>优先级分布 (Priority Analysis)</h3>
|
||||
<div className="priority-chart">
|
||||
{['P0', 'P1', 'P2', 'P3'].map(p => {
|
||||
const count = stats[p as keyof typeof stats] as number;
|
||||
const percentage = stats.total > 0 ? (count / stats.total) * 100 : 0;
|
||||
return (
|
||||
<div key={p} className="p-chart-item">
|
||||
<div className="p-bar-wrapper">
|
||||
<div className={`p-bar fill-${p}`} style={{ height: `${Math.max(percentage, 5)}%` }}>
|
||||
<span className="p-count">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="p-label">{p}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-card glass donut-container">
|
||||
<h3>通过率概览 (Pass Rate Donut)</h3>
|
||||
<div className="donut-wrapper">
|
||||
<div className="donut-chart" style={{ background: `conic-gradient(#00B42A 0% ${passRate}%, #F2F3F5 ${passRate}% 100%)` }}>
|
||||
<div className="donut-center">
|
||||
<span className="donut-pct">{passRate}%</span>
|
||||
<span className="donut-label">通过</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="donut-legend">
|
||||
<div className="legend-item"><span className="dot pass"></span> 通过: {stats.pass}</div>
|
||||
<div className="legend-item"><span className="dot fail"></span> 失败: {stats.fail}</div>
|
||||
<div className="legend-item"><span className="dot untested"></span> 未执行: {stats.untested}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-card glass full-width">
|
||||
<h3>任务看板 (Task Board)</h3>
|
||||
<div className="task-list">
|
||||
{testTasks.length === 0 ? (
|
||||
<div className="empty-state">暂无执行任务</div>
|
||||
) : (
|
||||
testTasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="task-item clickable"
|
||||
onClick={() => handleOpenTask(task.id)}
|
||||
>
|
||||
<div className="task-info">
|
||||
<span className="task-name">{task.name}</span>
|
||||
<span className="task-meta">{((task.assignee && task.assignee.startsWith('ou_')) || !task.assignee) ? (currentUser?.name || '未知用户') : task.assignee} · {task.createdAt?.split('T')[0]}</span>
|
||||
|
||||
</div>
|
||||
<div className={`task-status status-${task.status.toLowerCase()}`}>
|
||||
{task.status === 'RUNNING' && '运行中'}
|
||||
{task.status === 'PENDING' && '未运行'}
|
||||
{task.status === 'COMPLETED' && '已完成'}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.task-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #F8FAFC;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #E2E8F0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.task-item:hover {
|
||||
border-color: #3B82F6;
|
||||
background: #F0F9FF;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.task-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1E293B;
|
||||
}
|
||||
.task-meta {
|
||||
font-size: 12px;
|
||||
color: #64748B;
|
||||
}
|
||||
.task-status {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.status-running { background: #E0F2FE; color: #0284C7; }
|
||||
.status-pending { background: #F1F5F9; color: #64748B; }
|
||||
.status-completed { background: #DCFCE7; color: #15803D; }
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #94A3B8;
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard-container {
|
||||
padding: 32px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #F8FAFC;
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid #E2E8F0;
|
||||
}
|
||||
.stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stat-label {
|
||||
color: #64748B;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #0F172A;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stat-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #10B981;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.icon-blue { color: #3B82F6; }
|
||||
.icon-green { color: #10B981; }
|
||||
.icon-red { color: #EF4444; }
|
||||
|
||||
.progress-bar-bg {
|
||||
height: 8px;
|
||||
background: #E2E8F0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #10B981, #34D399);
|
||||
}
|
||||
|
||||
.priority-distribution {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
gap: 2px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.p-segment { height: 100%; }
|
||||
.p-segment.p0 { background: #EF4444; }
|
||||
.p-segment.p1 { background: #F59E0B; }
|
||||
.p-segment.p2 { background: #3B82F6; }
|
||||
|
||||
.main-charts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
.chart-card {
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
background: white;
|
||||
border: 1px solid #E2E8F0;
|
||||
}
|
||||
.chart-card h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
color: #1E293B;
|
||||
}
|
||||
|
||||
.distribution-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.dist-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.dist-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
.dist-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.dist-bar {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
min-width: 4px;
|
||||
}
|
||||
.dist-bar.pass { background: #10B981; }
|
||||
.dist-bar.fail { background: #EF4444; }
|
||||
.dist-bar.untested { background: #CBD5E1; }
|
||||
.dist-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.priority-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-around;
|
||||
height: 200px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.p-chart-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.p-bar-wrapper {
|
||||
width: 32px;
|
||||
height: 100%;
|
||||
background: #F1F5F9;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.p-bar {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
transition: height 1s ease-out;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.p-bar.fill-P0 { background: #F53F3F; }
|
||||
.p-bar.fill-P1 { background: #FF7D00; }
|
||||
.p-bar.fill-P2 { background: #F7BA1E; }
|
||||
.p-bar.fill-P3 { background: #165DFF; }
|
||||
.p-count { color: white; font-size: 10px; font-weight: 700; }
|
||||
.p-label { font-size: 12px; color: #64748B; font-weight: 600; }
|
||||
|
||||
.donut-container { display: flex; flex-direction: column; align-items: center; }
|
||||
.donut-wrapper { display: flex; align-items: center; gap: 32px; width: 100%; justify-content: center; }
|
||||
.donut-chart {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.donut-center {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
.donut-pct { font-size: 24px; font-weight: 700; color: #1D2129; }
|
||||
.donut-label { font-size: 12px; color: #86909C; }
|
||||
.donut-legend { display: flex; flex-direction: column; gap: 8px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #4E5969; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.dot.pass { background: #00B42A; }
|
||||
.dot.fail { background: #F53F3F; }
|
||||
.dot.untested { background: #F2F3F5; }
|
||||
|
||||
.full-width { grid-column: span 2; }
|
||||
|
||||
.insights-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.insights-list li {
|
||||
padding: 16px;
|
||||
background: #F1F5F9;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardView;
|
||||
134
src/index.css
Normal file
134
src/index.css
Normal file
@ -0,0 +1,134 @@
|
||||
:root {
|
||||
--primary: #0066FF;
|
||||
--primary-hover: #0052CC;
|
||||
--bg-main: #F4F7FB;
|
||||
--bg-surface: #FFFFFF;
|
||||
--text-main: #1D2129;
|
||||
--text-secondary: #4E5969;
|
||||
--border-color: #E5E6EB;
|
||||
--p0-color: #F53F3F;
|
||||
--p1-color: #FF7D00;
|
||||
--p2-color: #F7BA1E;
|
||||
--p3-color: #165DFF;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--transition-base: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #C9CDD4;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #86909C;
|
||||
}
|
||||
|
||||
/* Glassmorphism Classes */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Global Button & Interactive Feedback */
|
||||
button,
|
||||
[role="button"],
|
||||
.clickable,
|
||||
.interactive-item,
|
||||
.dir-item,
|
||||
.global-nav-list li,
|
||||
.plan-list li,
|
||||
.table-row,
|
||||
.tool-btn,
|
||||
.toggle-btn,
|
||||
.action-btn {
|
||||
transition: var(--transition-base);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
button:active,
|
||||
[role="button"]:active,
|
||||
.clickable:active,
|
||||
.interactive-item:active,
|
||||
.dir-item:active,
|
||||
.global-nav-list li:active,
|
||||
.plan-list li:active,
|
||||
.tool-btn:active,
|
||||
.toggle-btn:active,
|
||||
.action-btn:active {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.table-row:active {
|
||||
background-color: #F0F7FF !important;
|
||||
}
|
||||
|
||||
/* Base button styles to ensure consistency */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
border: 1px solid transparent;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
box-shadow: 0 4px 12px rgba(0, 102, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
670
src/store/useStore.ts
Normal file
670
src/store/useStore.ts
Normal file
@ -0,0 +1,670 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type Priority = 'P0' | 'P1' | 'P2' | 'P3';
|
||||
export type TestPlanType = 'Self-test' | 'Regression' | 'Requirement' | 'Smoke';
|
||||
|
||||
export interface TestPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TestPlanType;
|
||||
createdAt: string;
|
||||
caseIds?: string[];
|
||||
assignees?: string[];
|
||||
}
|
||||
|
||||
|
||||
export interface TestTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'PENDING' | 'RUNNING' | 'COMPLETED';
|
||||
planId: string;
|
||||
assignees: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Bug {
|
||||
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'OPEN' | 'RESOLVED' | 'CLOSED';
|
||||
caseId: string;
|
||||
}
|
||||
|
||||
export interface TestCaseNode {
|
||||
id: string;
|
||||
caseId?: string; // e.g., TC-001
|
||||
text: string; // Title
|
||||
module?: string; // Module category
|
||||
type?: 'Web' | 'Mobile' | 'API' | 'General';
|
||||
priority?: Priority;
|
||||
children?: TestCaseNode[];
|
||||
steps?: { action: string; expected: string }[];
|
||||
isExpanded?: boolean;
|
||||
executionStatus?: 'PASS' | 'FAIL' | 'BLOCK' | 'UNTESTED';
|
||||
reviewStatus?: 'Draft' | 'PendingReview' | 'Reviewed' | 'Deprecated';
|
||||
maintainer?: string;
|
||||
|
||||
requirementId?: string;
|
||||
bugId?: string;
|
||||
tags?: string[];
|
||||
reviewers?: string[]; // Array of openIds for Feishu group review
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export interface TestReport {
|
||||
id: string;
|
||||
planId: string;
|
||||
total: number;
|
||||
pass: number;
|
||||
fail: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface Space {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
viewMode: 'mindmap' | 'table' | 'dashboard' | 'bugs' | 'execution' | 'plans';
|
||||
spaces: Space[];
|
||||
currentSpaceId: string;
|
||||
spaceData: { [spaceId: string]: TestCaseNode[] };
|
||||
|
||||
testPlans: TestPlan[];
|
||||
testTasks: TestTask[];
|
||||
selectedPlanId: string | null;
|
||||
selectedNodeId: string | null;
|
||||
editingNodeId: string | null;
|
||||
selectedTaskId: string | null;
|
||||
showImportModal: boolean;
|
||||
currentUser: { name: string; openId: string } | null;
|
||||
|
||||
// Space Actions
|
||||
addSpace: (name: string) => void;
|
||||
deleteSpace: (id: string) => void;
|
||||
setCurrentSpaceId: (id: string) => void;
|
||||
|
||||
setViewMode: (mode: 'mindmap' | 'table' | 'dashboard' | 'bugs' | 'execution' | 'plans') => void;
|
||||
fetchSpaces: () => Promise<void>;
|
||||
fetchData: () => Promise<void>;
|
||||
|
||||
fetchTasks: () => Promise<void>;
|
||||
fetchBugs: () => Promise<void>;
|
||||
importNodes: (nodes: any[]) => Promise<void>;
|
||||
addTask: (task: Partial<TestTask>) => Promise<void>;
|
||||
|
||||
updateTaskStatus: (taskId: string, status: TestTask['status']) => Promise<void>;
|
||||
|
||||
setSelectedNodeId: (id: string | null) => void;
|
||||
setEditingNodeId: (id: string | null) => void;
|
||||
setSelectedTaskId: (id: string | null) => void;
|
||||
setSelectedPlanId: (id: string | null) => void;
|
||||
setShowImportModal: (show: boolean) => void;
|
||||
setCurrentUser: (user: { name: string; openId: string } | null) => void;
|
||||
logout: () => void;
|
||||
|
||||
addTestPlan: (name: string, type: TestPlanType, caseIds?: string[], assignees?: string[]) => void;
|
||||
|
||||
|
||||
updateNode: (id: string, updates: Partial<TestCaseNode>) => void;
|
||||
updateNodeSteps: (id: string, steps: { action: string; expected: string }[]) => void;
|
||||
addNode: (parentId: string | null, title?: string) => void;
|
||||
addSiblingNode: (id: string, title?: string) => void;
|
||||
deleteNode: (id: string) => void;
|
||||
addBug: (caseId: string, title: string) => void;
|
||||
deleteBug: (id: string) => void;
|
||||
deleteTestPlan: (id: string) => void;
|
||||
updateTestPlan: (id: string, updates: Partial<TestPlan>) => Promise<void>;
|
||||
copyTestPlan: (id: string) => Promise<void>;
|
||||
batchUpdateNodes: (ids: string[], updates: Partial<TestCaseNode>) => Promise<void>;
|
||||
|
||||
|
||||
getSelectedNode: () => TestCaseNode | null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const initialData: TestCaseNode[] = [];
|
||||
|
||||
|
||||
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
viewMode: 'dashboard',
|
||||
spaces: [],
|
||||
currentSpaceId: '',
|
||||
|
||||
spaceData: {},
|
||||
|
||||
|
||||
testPlans: [],
|
||||
testTasks: [],
|
||||
bugs: [],
|
||||
|
||||
selectedPlanId: null,
|
||||
selectedNodeId: null,
|
||||
editingNodeId: null,
|
||||
selectedTaskId: null,
|
||||
showImportModal: false,
|
||||
currentUser: (() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('quantum_user');
|
||||
return saved ? JSON.parse(saved) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
|
||||
|
||||
fetchSpaces: async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/api/spaces');
|
||||
const data = await res.json();
|
||||
if (data.data && data.data.length > 0) {
|
||||
const existingId = get().currentSpaceId;
|
||||
const validId = data.data.find((s: {id: string}) => s.id === existingId)
|
||||
? existingId
|
||||
: data.data[0].id;
|
||||
set({ spaces: data.data, currentSpaceId: validId });
|
||||
// Auto-fetch cases for the resolved space
|
||||
await get().fetchData();
|
||||
} else if (data.data) {
|
||||
set({ spaces: data.data, currentSpaceId: '' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch spaces', e);
|
||||
}
|
||||
},
|
||||
|
||||
addSpace: async (name) => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/api/spaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.id) {
|
||||
set(state => ({
|
||||
spaces: [...state.spaces, { id: data.id, name }],
|
||||
currentSpaceId: data.id,
|
||||
spaceData: { ...state.spaceData, [data.id]: [] }
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to add space', e);
|
||||
}
|
||||
},
|
||||
|
||||
deleteSpace: async (id) => {
|
||||
try {
|
||||
await fetch(`http://localhost:8000/api/spaces/${id}`, { method: 'DELETE' });
|
||||
set(state => {
|
||||
const newSpaces = state.spaces.filter(s => s.id !== id);
|
||||
const newCurrentId = state.currentSpaceId === id ? (newSpaces[0]?.id || '') : state.currentSpaceId;
|
||||
return { spaces: newSpaces, currentSpaceId: newCurrentId };
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to delete space', e);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
setCurrentSpaceId: (currentSpaceId) => set({ currentSpaceId }),
|
||||
|
||||
fetchData: async () => {
|
||||
const sid = get().currentSpaceId;
|
||||
if (!sid) return;
|
||||
try {
|
||||
const [casesRes, plansRes] = await Promise.all([
|
||||
fetch(`http://localhost:8000/api/cases?space_id=${sid}`),
|
||||
fetch(`http://localhost:8000/api/plans?space_id=${sid}`),
|
||||
]);
|
||||
|
||||
|
||||
const casesData = await casesRes.json();
|
||||
const plansData = await plansRes.json();
|
||||
|
||||
set(state => ({
|
||||
// 只要后端返回了 data 数组(即使是空数组),就覆盖本地状态,避免 fallback 到 initialData
|
||||
spaceData: (casesData && Array.isArray(casesData.data))
|
||||
? { ...state.spaceData, [sid]: casesData.data }
|
||||
: state.spaceData,
|
||||
testPlans: (plansData && Array.isArray(plansData.data))
|
||||
? plansData.data
|
||||
: state.testPlans,
|
||||
}));
|
||||
|
||||
} catch (e) {
|
||||
console.warn('[DB] fetchData failed, using local state', e);
|
||||
}
|
||||
},
|
||||
|
||||
fetchTasks: async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/tasks');
|
||||
const data = await response.json();
|
||||
set({ testTasks: data.data || [] });
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tasks', e);
|
||||
}
|
||||
},
|
||||
|
||||
fetchBugs: async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/bugs');
|
||||
const data = await response.json();
|
||||
set({ bugs: data.data || [] });
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch bugs', e);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
addTask: async (task) => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(task)
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.id) {
|
||||
const fullTask = { ...task, id: result.id, createdAt: new Date().toISOString() } as TestTask;
|
||||
set(state => ({ testTasks: [...state.testTasks, fullTask] }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to add task to backend, falling back to local state');
|
||||
const fullTask = { ...task, id: `task-${Date.now()}`, createdAt: new Date().toISOString() } as TestTask;
|
||||
set(state => ({ testTasks: [...state.testTasks, fullTask] }));
|
||||
}
|
||||
},
|
||||
|
||||
updateTaskStatus: async (taskId, status) => {
|
||||
try {
|
||||
await fetch(`http://localhost:8000/api/tasks/${taskId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
set(state => ({
|
||||
testTasks: state.testTasks.map(t => t.id === taskId ? { ...t, status } : t)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to update task status', e);
|
||||
}
|
||||
},
|
||||
|
||||
setViewMode: (viewMode) => set({ viewMode }),
|
||||
|
||||
setSelectedNodeId: (selectedNodeId) => set({ selectedNodeId }),
|
||||
setEditingNodeId: (editingNodeId) => set({ editingNodeId }),
|
||||
setSelectedTaskId: (selectedTaskId) => set({ selectedTaskId }),
|
||||
setSelectedPlanId: (selectedPlanId) => set({ selectedPlanId }),
|
||||
setShowImportModal: (showImportModal) => set({ showImportModal }),
|
||||
setCurrentUser: (currentUser) => {
|
||||
if (currentUser) {
|
||||
localStorage.setItem('quantum_user', JSON.stringify(currentUser));
|
||||
} else {
|
||||
localStorage.removeItem('quantum_user');
|
||||
}
|
||||
set({ currentUser });
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('quantum_user');
|
||||
set({
|
||||
currentUser: null,
|
||||
testPlans: [],
|
||||
testTasks: [],
|
||||
bugs: [],
|
||||
spaceData: {}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
|
||||
addTestPlan: async (name, type, caseIds, assignees = []) => {
|
||||
const planId = `p-${Date.now()}`;
|
||||
const newPlan: TestPlan = {
|
||||
id: planId,
|
||||
name,
|
||||
type,
|
||||
caseIds: caseIds || [],
|
||||
assignees: assignees,
|
||||
createdAt: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
// Optimistic update
|
||||
set((state) => ({ testPlans: [...state.testPlans, newPlan] }));
|
||||
const sid = get().currentSpaceId;
|
||||
// Persist to DB
|
||||
fetch(`http://localhost:8000/api/plans?space_id=${sid}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: planId, name, type, caseIds: caseIds || [], assignees, createdAt: newPlan.createdAt }),
|
||||
}).catch(e => console.warn('[DB] addTestPlan failed', e));
|
||||
|
||||
|
||||
|
||||
const state = get();
|
||||
await state.addTask({
|
||||
name: `【执行任务】${name}`,
|
||||
status: 'PENDING',
|
||||
planId,
|
||||
assignees: assignees,
|
||||
});
|
||||
|
||||
// Ensure data is synced
|
||||
await get().fetchTasks();
|
||||
await get().fetchData();
|
||||
|
||||
},
|
||||
|
||||
updateTestPlan: async (id, updates) => {
|
||||
set(state => ({
|
||||
testPlans: state.testPlans.map(p => p.id === id ? { ...p, ...updates } : p)
|
||||
}));
|
||||
await fetch(`http://localhost:8000/api/plans/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
// Refresh to get full sync
|
||||
get().fetchTasks();
|
||||
},
|
||||
|
||||
copyTestPlan: async (id) => {
|
||||
const res = await fetch(`http://localhost:8000/api/plans/${id}/copy`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.id) {
|
||||
// Refresh lists
|
||||
get().fetchSpaces();
|
||||
get().fetchTasks();
|
||||
}
|
||||
},
|
||||
|
||||
deleteTestPlan: async (id) => {
|
||||
|
||||
// Optimistic delete
|
||||
set((state) => ({
|
||||
testPlans: state.testPlans.filter(p => p.id !== id),
|
||||
testTasks: state.testTasks.filter(t => t.planId !== id)
|
||||
}));
|
||||
// Persist to DB
|
||||
fetch(`http://localhost:8000/api/plans/${id}`, { method: 'DELETE' })
|
||||
.catch(e => console.warn('[DB] deleteTestPlan failed', e));
|
||||
},
|
||||
|
||||
|
||||
updateNodeSteps: (id, steps) => {
|
||||
set((state) => {
|
||||
const sid = state.currentSpaceId;
|
||||
const currentTree = state.spaceData[sid];
|
||||
const updateRecursive = (nodes: TestCaseNode[]): TestCaseNode[] =>
|
||||
nodes.map((node) => {
|
||||
if (node.id === id) return { ...node, steps };
|
||||
if (node.children) return { ...node, children: updateRecursive(node.children) };
|
||||
return node;
|
||||
});
|
||||
return { spaceData: { ...state.spaceData, [sid]: updateRecursive(currentTree) } };
|
||||
});
|
||||
// Persist to DB
|
||||
fetch(`http://localhost:8000/api/cases/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, steps }),
|
||||
}).catch(e => console.warn('[DB] updateNodeSteps failed', e));
|
||||
},
|
||||
|
||||
updateNode: (id, updates) => {
|
||||
// 1. Optimistic update
|
||||
set((state) => {
|
||||
const sid = state.currentSpaceId;
|
||||
const updateRecursive = (nodes: TestCaseNode[]): TestCaseNode[] =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, ...updates }
|
||||
: { ...node, children: node.children ? updateRecursive(node.children) : [] }
|
||||
);
|
||||
return { spaceData: { ...state.spaceData, [sid]: updateRecursive(state.spaceData[sid] || []) } };
|
||||
});
|
||||
// 2. Persist to DB
|
||||
fetch(`http://localhost:8000/api/cases/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, ...updates }),
|
||||
}).catch(e => console.warn('[DB] updateNode failed', e));
|
||||
},
|
||||
|
||||
addNode: (parentId, title = '') => {
|
||||
const sid = get().currentSpaceId;
|
||||
const currentTree = get().spaceData[sid] || [];
|
||||
const newNodeId = `node-${Date.now()}`;
|
||||
|
||||
let inheritedPriority: Priority | undefined;
|
||||
|
||||
let nextTree: TestCaseNode[];
|
||||
if (!parentId) {
|
||||
nextTree = [...currentTree, { id: newNodeId, text: title, priority: 'P2' }];
|
||||
} else {
|
||||
const traverseAndAdd = (nodes: TestCaseNode[]): TestCaseNode[] =>
|
||||
nodes.map((node) => {
|
||||
if (node.id === parentId) {
|
||||
inheritedPriority = node.priority;
|
||||
return {
|
||||
...node,
|
||||
children: [...(node.children || []), { id: newNodeId, text: title, priority: node.priority || 'P2' }]
|
||||
};
|
||||
}
|
||||
if (node.children) return { ...node, children: traverseAndAdd(node.children) };
|
||||
return node;
|
||||
});
|
||||
nextTree = traverseAndAdd(currentTree);
|
||||
}
|
||||
|
||||
set({ spaceData: { ...get().spaceData, [sid]: nextTree }, selectedNodeId: newNodeId, editingNodeId: newNodeId });
|
||||
|
||||
// Persist to DB
|
||||
fetch(`http://localhost:8000/api/cases?space_id=${sid}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: newNodeId,
|
||||
text: title,
|
||||
parentId,
|
||||
priority: inheritedPriority || 'P2'
|
||||
}),
|
||||
}).catch(e => console.warn('[DB] addNode failed', e));
|
||||
},
|
||||
|
||||
|
||||
addSiblingNode: (id, title = '') => {
|
||||
const sid = get().currentSpaceId;
|
||||
const currentTree = get().spaceData[sid];
|
||||
const newNodeId = `node-${Date.now()}`;
|
||||
let inheritedPriority: Priority | undefined;
|
||||
|
||||
const traverseAndAddSibling = (nodes: TestCaseNode[]): TestCaseNode[] => {
|
||||
const index = nodes.findIndex((n) => n.id === id);
|
||||
if (index !== -1) {
|
||||
inheritedPriority = nodes[index].priority;
|
||||
const newNodes = [...nodes];
|
||||
newNodes.splice(index + 1, 0, { id: newNodeId, text: title, priority: inheritedPriority || 'P2' });
|
||||
return newNodes;
|
||||
}
|
||||
return nodes.map((node) => {
|
||||
if (node.children) {
|
||||
const result = traverseAndAddSibling(node.children);
|
||||
if (result !== node.children) return { ...node, children: result };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
const nextTree = traverseAndAddSibling(currentTree);
|
||||
set({ spaceData: { ...get().spaceData, [sid]: nextTree }, selectedNodeId: newNodeId, editingNodeId: newNodeId });
|
||||
|
||||
// Persist to DB
|
||||
fetch(`http://localhost:8000/api/cases?space_id=${sid}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: newNodeId,
|
||||
text: title,
|
||||
priority: inheritedPriority || 'P2'
|
||||
}),
|
||||
}).catch(e => console.warn('[DB] addSiblingNode failed', e));
|
||||
},
|
||||
|
||||
|
||||
deleteNode: (id) => {
|
||||
// Optimistic delete
|
||||
set((state) => {
|
||||
const sid = state.currentSpaceId;
|
||||
const deleteRecursive = (nodes: TestCaseNode[]): TestCaseNode[] =>
|
||||
nodes.filter((n) => n.id !== id).map((node) => ({
|
||||
...node,
|
||||
children: node.children ? deleteRecursive(node.children) : [],
|
||||
}));
|
||||
return {
|
||||
spaceData: { ...state.spaceData, [sid]: deleteRecursive(state.spaceData[sid] || []) },
|
||||
selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId,
|
||||
};
|
||||
});
|
||||
// Persist to DB (cascades on backend)
|
||||
fetch(`http://localhost:8000/api/cases/${id}`, { method: 'DELETE' })
|
||||
.catch(e => console.warn('[DB] deleteNode failed', e));
|
||||
},
|
||||
|
||||
addBug: (caseId, title) => {
|
||||
const newBug: Bug = {
|
||||
id: `BUG-${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`,
|
||||
title,
|
||||
status: 'OPEN',
|
||||
caseId,
|
||||
};
|
||||
set((state) => {
|
||||
const sid = state.currentSpaceId;
|
||||
const updateRecursive = (nodes: TestCaseNode[]): TestCaseNode[] =>
|
||||
nodes.map((node) => {
|
||||
if (node.id === caseId || node.caseId === caseId) return { ...node, bugId: newBug.id };
|
||||
if (node.children) return { ...node, children: updateRecursive(node.children) };
|
||||
return node;
|
||||
});
|
||||
return {
|
||||
bugs: [...state.bugs, newBug],
|
||||
spaceData: { ...state.spaceData, [sid]: updateRecursive(state.spaceData[sid] || []) },
|
||||
};
|
||||
});
|
||||
// Persist bug to DB
|
||||
fetch('http://localhost:8000/api/bugs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: newBug.id, title, status: 'OPEN', caseId }),
|
||||
}).catch(e => console.warn('[DB] addBug failed', e));
|
||||
},
|
||||
|
||||
deleteBug: async (id) => {
|
||||
// Optimistic delete
|
||||
set((state) => ({
|
||||
bugs: state.bugs.filter(b => b.id !== id)
|
||||
}));
|
||||
// Persist to DB
|
||||
fetch(`http://localhost:8000/api/bugs/${id}`, { method: 'DELETE' })
|
||||
.catch(e => console.warn('[DB] deleteBug failed', e));
|
||||
},
|
||||
|
||||
|
||||
getSelectedNode: () => {
|
||||
const state = get();
|
||||
if (!state.selectedNodeId) return null;
|
||||
const currentTree = state.spaceData[state.currentSpaceId] || [];
|
||||
|
||||
const findRecursive = (nodes: TestCaseNode[]): TestCaseNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === state.selectedNodeId) return node;
|
||||
if (node.children) {
|
||||
const found = findRecursive(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return findRecursive(currentTree);
|
||||
},
|
||||
importNodes: async (nodes) => {
|
||||
|
||||
const sid = get().currentSpaceId;
|
||||
if (!sid) return;
|
||||
|
||||
// Flatten the tree for backend (backend expects flat list with parentId)
|
||||
const flatNodes: any[] = [];
|
||||
const flatten = (items: any[], pid: string | null = null) => {
|
||||
items.forEach(item => {
|
||||
flatNodes.push({
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
parentId: pid,
|
||||
steps: item.steps || [],
|
||||
tags: item.tags || []
|
||||
});
|
||||
if (item.children && item.children.length > 0) {
|
||||
flatten(item.children, item.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
flatten(nodes);
|
||||
|
||||
try {
|
||||
await fetch(`http://localhost:8000/api/cases/batch?space_id=${sid}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(flatNodes)
|
||||
});
|
||||
// Refresh local data after import
|
||||
await get().fetchData();
|
||||
} catch (e) {
|
||||
console.error('Failed to batch import nodes', e);
|
||||
}
|
||||
},
|
||||
|
||||
batchUpdateNodes: async (ids, updates) => {
|
||||
// 1. Optimistic update
|
||||
set((state) => {
|
||||
const sid = state.currentSpaceId;
|
||||
const updateRecursive = (nodes: TestCaseNode[]): TestCaseNode[] =>
|
||||
nodes.map((node) => {
|
||||
const newNode = ids.includes(node.id) ? { ...node, ...updates } : node;
|
||||
if (newNode.children) {
|
||||
newNode.children = updateRecursive(newNode.children);
|
||||
}
|
||||
return newNode;
|
||||
});
|
||||
return { spaceData: { ...state.spaceData, [sid]: updateRecursive(state.spaceData[sid] || []) } };
|
||||
});
|
||||
|
||||
// 2. Persist to DB
|
||||
try {
|
||||
await fetch('http://localhost:8000/api/cases/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
caseIds: ids,
|
||||
maintainer: updates.maintainer,
|
||||
reviewers: updates.reviewers
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to batch update nodes', e);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
|
||||
27
src/user.json
Normal file
27
src/user.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"陶航宇": "ou_530d280ca8119a99fba2c0b31d5135ee",
|
||||
"祖立峰": "ou_90b9d4c9645363f502ca5a825ecd7e83",
|
||||
"付程玲": "ou_9334a6c5aab73d32419e1059763cbf26",
|
||||
"王伟东": "ou_a8be7e4fbb78b8181ddfb6c883448709",
|
||||
"孙悦": "ou_dafde22c2bc376d6440a39c819cf877c",
|
||||
"侯志勇": "ou_f2cc58ae8ae5c43e59243a7fc976c25f",
|
||||
"杨磊": "ou_7dd26678a97b59a082340e759ca569a9",
|
||||
"王斌": "ou_798bd0a50d8c5f3eeb2924ec0bd65acf",
|
||||
"刘亚琼": "ou_2351be9c2c6e465f37582f4cef5c32f4",
|
||||
"杜凯": "ou_545c99a3dca03b175af61a30b124bfe9",
|
||||
"路强": "ou_95187be353a3ccdfafa7061b0e42c411",
|
||||
"张家浩": "ou_746a541759dc523290ef7062d42ae533",
|
||||
"赵晗": "ou_812d3705b105ea342000d0dc4376a146",
|
||||
"陈适": "ou_3b9699365ed8d28ba7f577f75a291644",
|
||||
"杨祎伟": "ou_3587ec1084d6fac875e4e0d1fc0e9715",
|
||||
"辛海丹": "ou_700f829a3de5af1685f6f3d90a9e200a",
|
||||
"圣正杰": "ou_4ef3536f42ee674221b4cb9ffa469d4b",
|
||||
"汪明": "ou_dccc3e3296ec78ef16a10d5a07e45799",
|
||||
"李莹": "ou_e1c0c73e10d0a5a2f20e3ae853d5eb13",
|
||||
"周天如": "ou_66bdd9ce844efaed6bd71a92325cfbb0",
|
||||
"吴龙涛": "ou_6696b998749b8456729e4cfa4b295559",
|
||||
"吴超": "ou_675dea340907ecb416f2c9d96f634192",
|
||||
"郝建新": "ou_f9681314b63b485bf3e8650f7c43a411",
|
||||
"董红帅": "ou_775ff733bd2d407ab21b7a9c4fa7fd3b",
|
||||
"陈钦洋": "ou_6f1269a7e024a1711cfedc5d96052351"
|
||||
}
|
||||
25
tsconfig.app.json
Normal file
25
tsconfig.app.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user