feat: 完成多执行人系统及计划管理功能优化

This commit is contained in:
hangyu.tao 2026-05-06 19:47:01 +08:00
parent 4cfaf21b22
commit ca6b010f59
51 changed files with 12723 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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**: 整体视觉打磨与性能优化。

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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()

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

View 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
View 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
View 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
View 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)

View 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
View 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

Binary file not shown.

80
backend/schema_mysql.sql Normal file
View 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 '父节点IDNULL 表示根节点',
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 '任务唯一IDUUID',
`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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View 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
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})