# 执行跳转闭环与执行历史 - 计划执行文档

> **关联 PRD**：`_PRD_执行跳转闭环与执行历史.md`
> **文档版本**：v1.0
> **制定日期**：2026-08-24
> **执行分支**：`platform-auto-test`
> **优先级**：P0

---

## 一、执行目标

实现两个核心诉求：

1. **执行跳转闭环**：从 TaskList 点击「执行」→ 自动跳转监控页 → 执行完成后自动跳转报告页，端到端无用户手动操作
2. **执行历史留档**：每次执行创建一条不可变的执行记录，监控回放与报告按执行记录索引，解决数据覆盖、快照混存问题

---

## 二、实施原则

1. **非阻塞模式**：`POST /tasks/{task_id}/run` 改为立即返回，后台执行对齐 `_run_batch_background` 的既有模式。
2. **双写兼容**：执行结果同时写入 `PerformanceTask` 现有字段（最新一次结果）和 `PerformanceExecution` 记录（不可变历史），零回归。
3. **快照归执行**：`PerformanceSnapshot` 新增 `execution_id`，查询以 `(execution_id, elapsed)` 为条件，根除混存。
4. **存量迁移**：启动时回填 legacy 执行记录，确保旧数据在新 UI 下可看。
5. **`_ensure_columns` 自动迁移**：新表/列由 database.py 自动创建，无需手工 SQL（MySQL TEXT 列不支持 DEFAULT 需注意）。

---

## 三、阶段计划

### Phase 0：文档准备

| 工作项 | 文件 | 结果 |
|--------|------|------|
| 编写需求文档 | `_PRD_执行跳转闭环与执行历史.md` | 明确范围、FR、验收标准 |
| 编写本计划 | `_PRD_执行跳转闭环与执行历史_计划执行.md` | 明确代码阶段、文件变更、验证方式 |

### Phase 1：后端 — 数据模型扩展

#### 1.1 新增 `PerformanceExecution` 模型

文件：`backend/app/models/performance.py`

在 `PerformanceBatchExecution` 类之前，新增 `PerformanceExecution` 模型：

```python
class PerformanceExecution(Base):
    """
    性能测试执行记录

    每次执行创建一条不可变记录，承载该次执行的全部结果数据。
    供执行历史查询、历史报告、历史监控回放使用。

    Attributes:
        id (str): 执行唯一标识 (exec_xxx)
        task_id (str): 关联任务ID
        task_name (str): 任务名快照
        project_id (str): 项目ID（冗余，便于按项目过滤）
        status (str): running/completed/failed/cancelled
        error_message (str): 失败原因
        start_time (datetime): 开始时间
        end_time (datetime): 结束时间
        duration_actual (float): 实际时长（秒）
        # 统计指标 ×34（同 PerformanceTask 结果字段）
        total_requests / success_count / fail_count / error_rate /
        actual_tps / peak_tps / min·max·avg·p50·p90·p95·p99_response_time /
        std_dev / latency_avg·min·max / connect_time_avg·max /
        apdex / total_sent·received_bytes /
        error_type_timeout/connect_error/client_error/http_4xx/http_5xx/assertion /
        status_2xx·3xx·4xx·5xx
        # 汇总 JSON
        resource_summary / target_resource_summary / transaction_summary / api_summary
        # 元数据
        snapshot_count (int): 快照条数
        triggered_by (str): manual/batch/legacy
        batch_id (str): 批量执行时关联批次
        created_at (datetime): 创建时间
    """

    __tablename__ = "performance_executions"

    id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="执行唯一标识")
    task_id: Mapped[str] = mapped_column(
        String(64), ForeignKey("performance_tasks.id", ondelete="CASCADE"),
        index=True, comment="关联任务ID"
    )
    task_name: Mapped[str] = mapped_column(String(200), default="", comment="任务名快照")
    project_id: Mapped[Optional[str]] = mapped_column(
        String(64), nullable=True, index=True, comment="项目ID快照"
    )
    status: Mapped[str] = mapped_column(
        String(20), default="running", comment="状态: running/completed/failed/cancelled"
    )
    error_message: Mapped[Optional[str]] = mapped_column(
        Text, nullable=True, default=None, comment="失败原因"
    )
    start_time: Mapped[Optional[datetime]] = mapped_column(
        DateTime, nullable=True, default=None, comment="开始时间"
    )
    end_time: Mapped[Optional[datetime]] = mapped_column(
        DateTime, nullable=True, default=None, comment="结束时间"
    )
    duration_actual: Mapped[float] = mapped_column(Float, default=0.0, comment="实际时长(秒)")

    # 统计指标 ×34
    total_requests: Mapped[int] = mapped_column(Integer, default=0, comment="总请求数")
    success_count: Mapped[int] = mapped_column(Integer, default=0, comment="成功请求数")
    fail_count: Mapped[int] = mapped_column(Integer, default=0, comment="失败请求数")
    error_rate: Mapped[float] = mapped_column(Float, default=0.0, comment="错误率")
    actual_tps: Mapped[float] = mapped_column(Float, default=0.0, comment="实际TPS")
    peak_tps: Mapped[float] = mapped_column(Float, default=0.0, comment="峰值TPS")
    min_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="最小响应时间(ms)")
    max_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="最大响应时间(ms)")
    avg_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="平均响应时间(ms)")
    p50_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="P50响应时间(ms)")
    p90_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="P90响应时间(ms)")
    p95_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="P95响应时间(ms)")
    p99_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="P99响应时间(ms)")
    std_dev: Mapped[float] = mapped_column(Float, default=0.0, comment="标准差")
    latency_avg: Mapped[float] = mapped_column(Float, default=0.0, comment="平均延迟(ms)")
    latency_min: Mapped[float] = mapped_column(Float, default=0.0, comment="最小延迟(ms)")
    latency_max: Mapped[float] = mapped_column(Float, default=0.0, comment="最大延迟(ms)")
    connect_time_avg: Mapped[float] = mapped_column(Float, default=0.0, comment="平均连接时间(ms)")
    connect_time_max: Mapped[float] = mapped_column(Float, default=0.0, comment="最大连接时间(ms)")
    apdex: Mapped[float] = mapped_column(Float, default=0.0, comment="Apdex指数")
    total_sent_bytes: Mapped[int] = mapped_column(Integer, default=0, comment="发送字节数")
    total_received_bytes: Mapped[int] = mapped_column(Integer, default=0, comment="接收字节数")
    error_type_timeout: Mapped[int] = mapped_column(Integer, default=0, comment="超时错误数")
    error_type_connect_error: Mapped[int] = mapped_column(Integer, default=0, comment="连接错误数")
    error_type_client_error: Mapped[int] = mapped_column(Integer, default=0, comment="客户端错误数")
    error_type_http_4xx: Mapped[int] = mapped_column(Integer, default=0, comment="HTTP 4xx数")
    error_type_http_5xx: Mapped[int] = mapped_column(Integer, default=0, comment="HTTP 5xx数")
    error_type_assertion: Mapped[int] = mapped_column(Integer, default=0, comment="断言失败数")
    status_2xx: Mapped[int] = mapped_column(Integer, default=0, comment="2xx响应数")
    status_3xx: Mapped[int] = mapped_column(Integer, default=0, comment="3xx响应数")
    status_4xx: Mapped[int] = mapped_column(Integer, default=0, comment="4xx响应数")
    status_5xx: Mapped[int] = mapped_column(Integer, default=0, comment="5xx响应数")

    # 汇总 JSON
    resource_summary: Mapped[Optional[dict]] = mapped_column(
        JSON, nullable=True, default=None, comment="执行机资源汇总"
    )
    target_resource_summary: Mapped[Optional[dict]] = mapped_column(
        JSON, nullable=True, default=None, comment="目标机资源汇总"
    )
    transaction_summary: Mapped[Optional[list]] = mapped_column(
        JSON, nullable=True, default=None, comment="事务汇总"
    )
    api_summary: Mapped[Optional[list]] = mapped_column(
        JSON, nullable=True, default=None, comment="接口维度汇总"
    )

    # 元数据
    snapshot_count: Mapped[int] = mapped_column(Integer, default=0, comment="快照条数")
    triggered_by: Mapped[str] = mapped_column(
        String(20), default="manual", comment="触发方式: manual/batch/legacy"
    )
    batch_id: Mapped[Optional[str]] = mapped_column(
        String(64), nullable=True, default=None, comment="关联批次ID"
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime, default=datetime.utcnow, comment="创建时间"
    )

    # 关系
    snapshots: Mapped[list["PerformanceSnapshot"]] = relationship(
        "PerformanceSnapshot", back_populates="execution",
        cascade="all, delete-orphan",
        foreign_keys="PerformanceSnapshot.execution_id"
    )

    def to_dict(self) -> Dict[str, Any]:
        """序列化为字典"""
        return {
            "id": self.id,
            "task_id": self.task_id,
            "task_name": self.task_name,
            "project_id": self.project_id,
            "status": self.status,
            "error_message": self.error_message,
            "start_time": self.start_time.isoformat() if self.start_time else None,
            "end_time": self.end_time.isoformat() if self.end_time else None,
            "duration_actual": self.duration_actual,
            "total_requests": self.total_requests,
            "success_count": self.success_count,
            "fail_count": self.fail_count,
            "error_rate": self.error_rate,
            "actual_tps": self.actual_tps,
            "peak_tps": self.peak_tps,
            "min_response_time": self.min_response_time,
            "max_response_time": self.max_response_time,
            "avg_response_time": self.avg_response_time,
            "p50_response_time": self.p50_response_time,
            "p90_response_time": self.p90_response_time,
            "p95_response_time": self.p95_response_time,
            "p99_response_time": self.p99_response_time,
            "std_dev": self.std_dev,
            "latency_avg": self.latency_avg,
            "latency_min": self.latency_min,
            "latency_max": self.latency_max,
            "connect_time_avg": self.connect_time_avg,
            "connect_time_max": self.connect_time_max,
            "apdex": self.apdex,
            "total_sent_bytes": self.total_sent_bytes,
            "total_received_bytes": self.total_received_bytes,
            "error_type_timeout": self.error_type_timeout,
            "error_type_connect_error": self.error_type_connect_error,
            "error_type_client_error": self.error_type_client_error,
            "error_type_http_4xx": self.error_type_http_4xx,
            "error_type_http_5xx": self.error_type_http_5xx,
            "error_type_assertion": self.error_type_assertion,
            "status_2xx": self.status_2xx,
            "status_3xx": self.status_3xx,
            "status_4xx": self.status_4xx,
            "status_5xx": self.status_5xx,
            "resource_summary": self.resource_summary,
            "target_resource_summary": self.target_resource_summary,
            "transaction_summary": self.transaction_summary,
            "api_summary": self.api_summary,
            "snapshot_count": self.snapshot_count,
            "triggered_by": self.triggered_by,
            "batch_id": self.batch_id,
            "created_at": self.created_at.isoformat() if self.created_at else None,
        }
```

#### 1.2 `PerformanceSnapshot` 新增 `execution_id` 列

文件：`backend/app/models/performance.py`

在 `PerformanceSnapshot` 类中新增 `execution_id` 字段，并更新 `task_id` 注释：

```python
# 新增字段
execution_id: Mapped[Optional[str]] = mapped_column(
    String(64), ForeignKey("performance_executions.id", ondelete="SET NULL"),
    nullable=True, index=True, comment="关联执行记录ID"
)
```

更新关系：
```python
# 在 PerformanceSnapshot 中
execution: Mapped[Optional["PerformanceExecution"]] = relationship(
    "PerformanceExecution", back_populates="snapshots",
    foreign_keys=[execution_id]
)
```

#### 1.3 `to_dict()` 更新

`PerformanceSnapshot.to_dict()` 新增 `execution_id` 字段。

#### 1.4 `_ensure_columns` 迁移配置

文件：`backend/app/database.py`

在 `_ensure_columns` 的 `columns_to_add` 列表中新增：

```python
# performance_snapshots 表
("performance_snapshots", "execution_id", "VARCHAR(64)"),

# 无需对 performance_executions 做列迁移，该表由 init_db 的 create_all 自动创建
```

#### 1.5 模型关系配置

`PerformanceTask` 新增 `executions` 关系（一对多，CASCADE 删除）：

```python
# 在 PerformanceTask 类中
executions: Mapped[list["PerformanceExecution"]] = relationship(
    "PerformanceExecution", backref="task",
    cascade="all, delete-orphan",
    foreign_keys="PerformanceExecution.task_id"
)
```

### Phase 2：后端 — Schema 扩展

文件：`backend/app/schemas/performance.py`

#### 2.1 新增 Schema 类型

```python
class PerformanceExecutionListItem(BaseModel):
    """执行历史列表项"""
    id: str = Field(..., description="执行ID")
    task_id: str = Field(..., description="任务ID")
    task_name: str = Field(..., description="任务名")
    project_id: Optional[str] = Field(None, description="项目ID")
    status: str = Field(..., description="状态")
    start_time: Optional[str] = Field(None, description="开始时间")
    end_time: Optional[str] = Field(None, description="结束时间")
    duration_actual: float = Field(0.0, description="实际时长(秒)")
    total_requests: int = Field(0, description="总请求数")
    actual_tps: float = Field(0.0, description="实际TPS")
    avg_response_time: float = Field(0.0, description="平均响应时间(ms)")
    p95_response_time: float = Field(0.0, description="P95响应时间(ms)")
    error_rate: float = Field(0.0, description="错误率")
    triggered_by: str = Field("manual", description="触发方式")
    snapshot_count: int = Field(0, description="快照条数")

    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)


class PerformanceExecutionListResponse(BaseModel):
    """执行历史列表响应"""
    total: int = Field(..., description="总数")
    items: List[PerformanceExecutionListItem] = Field(..., description="列表")
    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)


class PerformanceExecutionDetailResponse(BaseModel):
    """执行历史详情响应"""
    id: str = Field(..., description="执行ID")
    task_id: str = Field(..., description="任务ID")
    task_name: str = Field(..., description="任务名")
    project_id: Optional[str] = Field(None, description="项目ID")
    status: str = Field(..., description="状态")
    error_message: Optional[str] = Field(None, description="失败原因")
    start_time: Optional[str] = Field(None, description="开始时间")
    end_time: Optional[str] = Field(None, description="结束时间")
    duration_actual: float = Field(0.0, description="实际时长(秒)")
    total_requests: int = Field(0, description="总请求数")
    success_count: int = Field(0, description="成功请求数")
    fail_count: int = Field(0, description="失败请求数")
    error_rate: float = Field(0.0, description="错误率")
    actual_tps: float = Field(0.0, description="实际TPS")
    peak_tps: float = Field(0.0, description="峰值TPS")
    min_response_time: float = Field(0.0, description="最小响应时间(ms)")
    max_response_time: float = Field(0.0, description="最大响应时间(ms)")
    avg_response_time: float = Field(0.0, description="平均响应时间(ms)")
    p50_response_time: float = Field(0.0, description="P50响应时间(ms)")
    p90_response_time: float = Field(0.0, description="P90响应时间(ms)")
    p95_response_time: float = Field(0.0, description="P95响应时间(ms)")
    p99_response_time: float = Field(0.0, description="P99响应时间(ms)")
    std_dev: float = Field(0.0, description="标准差")
    latency_avg: float = Field(0.0, description="平均延迟(ms)")
    latency_min: float = Field(0.0, description="最小延迟(ms)")
    latency_max: float = Field(0.0, description="最大延迟(ms)")
    connect_time_avg: float = Field(0.0, description="平均连接时间(ms)")
    connect_time_max: float = Field(0.0, description="最大连接时间(ms)")
    apdex: float = Field(0.0, description="Apdex指数")
    total_sent_bytes: int = Field(0, description="发送字节数")
    total_received_bytes: int = Field(0, description="接收字节数")
    resource_summary: Optional[Any] = Field(None, description="执行机资源汇总")
    target_resource_summary: Optional[Any] = Field(None, description="目标机资源汇总")
    transaction_summary: Optional[Any] = Field(None, description="事务汇总")
    api_summary: Optional[Any] = Field(None, description="接口维度汇总")
    snapshot_count: int = Field(0, description="快照条数")
    triggered_by: str = Field("manual", description="触发方式")
    batch_id: Optional[str] = Field(None, description="关联批次ID")
    created_at: Optional[str] = Field(None, description="创建时间")

    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)


class PerformanceExecutionReportResponse(BaseModel):
    """执行记录报告响应（与 PerformanceReportResponse 结构一致）"""
    summary: PerformanceReportSummary = Field(..., description="执行摘要")
    metrics: PerformanceReportMetrics = Field(..., description="指标统计")
    snapshots: List[PerformanceSnapshotResponse] = Field(default_factory=list, description="快照数据")
    resource_summary: Optional[Any] = Field(None, description="执行机资源汇总")
    target_resource_summary: Optional[Any] = Field(None, description="目标机资源汇总")
    transaction_summary: Optional[Any] = Field(None, description="事务汇总")
    api_summary: Optional[Any] = Field(None, description="接口维度汇总")

    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
```

#### 2.2 `PerformanceRunResponse` 扩展

```python
class PerformanceRunResponse(BaseModel):
    """执行任务响应"""
    message: str = Field(..., description="提示信息")
    task_id: str = Field(..., description="任务ID")
    execution_id: Optional[str] = Field(None, description="执行记录ID")
    status: str = Field(..., description="任务状态")
    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
```

### Phase 3：后端 — Service 层改造

文件：`backend/app/services/performance_service.py`

#### 3.1 `run_task()` 双写改造

核心改动：`run_task()` 创建 `PerformanceExecution` 记录，执行完成后将结果同时写入 execution 记录和 task 字段。

```python
async def run_task(
    self,
    task_id: str,
    config_override: Optional[Dict[str, Any]] = None,
    progress_callback: Optional[Callable] = None,
    triggered_by: str = "manual",
    batch_id: Optional[str] = None,
) -> Dict[str, Any]:
    """
    执行性能测试任务

    创建执行记录，在线程池中运行压测引擎。
    执行完成后将结果同时写入执行记录和任务字段。
    """
    task = await self.get_task(task_id)
    if not task:
        raise ValueError(f"任务不存在: {task_id}")

    if task.status == "running":
        raise ValueError("任务正在执行中，不能重复启动")

    # 应用配置覆盖
    if config_override:
        for key, value in config_override.items():
            if hasattr(task, key):
                setattr(task, key, value)

    # 创建执行记录
    execution_id = generate_id("exec")
    project_id = task.project_id
    execution = PerformanceExecution(
        id=execution_id,
        task_id=task_id,
        task_name=task.name,
        project_id=project_id,
        status="running",
        start_time=datetime.utcnow(),
        triggered_by=triggered_by,
        batch_id=batch_id,
        snapshot_count=0,
    )
    self.db.add(execution)

    # 更新任务状态为运行中
    task.status = "running"
    task.start_time = datetime.utcnow()
    task.error_message = None
    await self.db.flush()

    # 预先把 ORM 对象转成纯字典
    task_dict = task.to_dict()

    # 快照收集缓冲区
    snapshot_buffer: List[Dict[str, Any]] = []
    ws_group = f"perf_{task_id}"

    def snapshot_callback(snapshot: dict) -> None:
        """每秒快照回调：收集快照 + WebSocket 推送"""
        snapshot["task_id"] = task_id
        snapshot["execution_id"] = execution_id
        snapshot["timestamp"] = time.time()
        snapshot_buffer.append(dict(snapshot))
        # WebSocket 推送（原有逻辑不变）
        ...

    try:
        # 在线程池中执行（原有逻辑不变）
        ...
    except Exception as e:
        ...
    finally:
        task.end_time = datetime.utcnow()
        ...

        # 批量保存快照（带 execution_id）
        await self._save_snapshots(task_id, execution_id, snapshot_buffer)

        # 更新执行记录
        execution.end_time = task.end_time
        execution.duration_actual = task.duration_actual
        execution.status = task.status
        execution.error_message = task.error_message
        execution.snapshot_count = len(snapshot_buffer)
        # 同步结果字段到执行记录
        self._copy_result_to_execution(execution, task)

        await self.db.commit()
        ...

    return task.to_dict()
```

#### 3.2 新增 `_copy_result_to_execution()` 辅助方法

```python
@staticmethod
def _copy_result_to_execution(
    execution: PerformanceExecution, task: PerformanceTask
) -> None:
    """将任务结果字段同步到执行记录"""
    execution.total_requests = task.total_requests
    execution.success_count = task.success_count
    execution.fail_count = task.fail_count
    execution.error_rate = task.error_rate
    execution.actual_tps = task.actual_tps
    execution.peak_tps = task.peak_tps
    execution.min_response_time = task.min_response_time
    execution.max_response_time = task.max_response_time
    execution.avg_response_time = task.avg_response_time
    execution.p50_response_time = task.p50_response_time
    execution.p90_response_time = task.p90_response_time
    execution.p95_response_time = task.p95_response_time
    execution.p99_response_time = task.p99_response_time
    execution.std_dev = task.std_dev
    execution.latency_avg = task.latency_avg
    execution.latency_min = task.latency_min
    execution.latency_max = task.latency_max
    execution.connect_time_avg = task.connect_time_avg
    execution.connect_time_max = task.connect_time_max
    execution.apdex = task.apdex
    execution.total_sent_bytes = task.total_sent_bytes
    execution.total_received_bytes = task.total_received_bytes
    execution.error_type_timeout = task.error_type_timeout
    execution.error_type_connect_error = task.error_type_connect_error
    execution.error_type_client_error = task.error_type_client_error
    execution.error_type_http_4xx = task.error_type_http_4xx
    execution.error_type_http_5xx = task.error_type_http_5xx
    execution.error_type_assertion = task.error_type_assertion
    execution.status_2xx = task.status_2xx
    execution.status_3xx = task.status_3xx
    execution.status_4xx = task.status_4xx
    execution.status_5xx = task.status_5xx
    execution.resource_summary = task.resource_summary
    execution.target_resource_summary = task.target_resource_summary
    execution.transaction_summary = task.transaction_summary
    execution.api_summary = task.api_summary
```

#### 3.3 `_save_snapshots()` 改造

```python
async def _save_snapshots(
    self, task_id: str, execution_id: str, snapshots: List[Dict[str, Any]]
) -> int:
    """批量保存快照到数据库（带 execution_id）"""
    if not snapshots:
        return 0

    batch = []
    for s in snapshots:
        batch.append(PerformanceSnapshot(
            task_id=task_id,
            execution_id=execution_id,
            timestamp=s.get("timestamp", time.time()),
            elapsed=s.get("elapsed", 0.0),
            # ... 其余字段不变
        ))

    self.db.add_all(batch)
    await self.db.flush()
    logger.info(f"批量保存快照: {len(batch)} 条 (task_id={task_id}, execution_id={execution_id})")
    return len(batch)
```

#### 3.4 `get_report()` 改造

```python
async def get_report(
    self, task_id: Optional[str] = None, execution_id: Optional[str] = None
) -> Optional[Dict[str, Any]]:
    """
    获取性能测试报告

    优先按 execution_id 取，其次按 task_id 取最新执行。
    """
    if execution_id:
        # 按执行记录取
        execution = await self.db.get(PerformanceExecution, execution_id)
        if not execution:
            return None
        task = await self.db.get(PerformanceTask, execution.task_id)
        return self._build_report_from_execution(execution, task)
    elif task_id:
        # 向后兼容：查看该任务最新一条执行
        result = await self.db.execute(
            select(PerformanceExecution)
            .where(PerformanceExecution.task_id == task_id)
            .order_by(PerformanceExecution.created_at.desc())
            .limit(1)
        )
        execution = result.scalar_one_or_none()
        if not execution:
            # 无执行记录时回退到任务字段（兼容存量数据）
            task = await self.get_task(task_id)
            if not task:
                return None
            return self._build_report_from_task(task)
        return self._build_report_from_execution(
            execution, await self.db.get(PerformanceTask, task_id)
        )
    return None


def _build_report_from_execution(
    self, execution: PerformanceExecution, task: Optional[PerformanceTask] = None
) -> Dict[str, Any]:
    """从执行记录构造报告"""
    # 获取执行记录对应的快照
    result = self.db.execute(
        select(PerformanceSnapshot)
        .where(PerformanceSnapshot.execution_id == execution.id)
        .order_by(PerformanceSnapshot.elapsed.asc())
    )
    snapshots = list(result.scalars().all())

    # 汇总状态码
    status_2xx = sum(s.status_2xx for s in snapshots)
    status_3xx = sum(s.status_3xx for s in snapshots)
    status_4xx = sum(s.status_4xx for s in snapshots)
    status_5xx = sum(s.status_5xx for s in snapshots)

    return {
        "summary": {
            "task_id": execution.task_id,
            "task_name": execution.task_name,
            "target_url": task.target_url if task else "",
            "method": task.method if task else "",
            "mode": task.mode if task else "",
            "scenario_type": task.scenario_type if task else None,
            "concurrency": task.concurrency if task else 0,
            "duration_setting": task.duration if task else 0,
            "duration_actual": execution.duration_actual,
            "start_time": execution.start_time.isoformat() if execution.start_time else None,
            "end_time": execution.end_time.isoformat() if execution.end_time else None,
            "account_key": task.account_key if task else None,
            "execution_id": execution.id,
        },
        "metrics": {
            "total_requests": execution.total_requests,
            # ... 全部指标从 execution 读取
        },
        "snapshots": [s.to_dict() for s in snapshots],
        "resource_summary": execution.resource_summary,
        "target_resource_summary": execution.target_resource_summary,
        "transaction_summary": execution.transaction_summary,
        "api_summary": execution.api_summary,
    }
```

#### 3.5 新增执行历史查询方法

```python
async def get_executions(
    self,
    task_id: Optional[str] = None,
    project_id: Optional[str] = None,
    status: Optional[str] = None,
    page: int = 1,
    page_size: int = 20,
) -> Tuple[List[PerformanceExecution], int]:
    """
    查询执行历史

    Args:
        task_id: 按任务筛选
        project_id: 按项目筛选
        status: 按状态筛选
        page: 页码
        page_size: 每页数量

    Returns:
        tuple: (执行记录列表, 总数)
    """
    query = (
        select(PerformanceExecution)
        .order_by(PerformanceExecution.created_at.desc())
    )
    count_query = select(func.count()).select_from(PerformanceExecution)

    # 筛选条件
    where_clauses = []
    if task_id:
        where_clauses.append(PerformanceExecution.task_id == task_id)
    if project_id:
        where_clauses.append(PerformanceExecution.project_id == project_id)
    if status:
        where_clauses.append(PerformanceExecution.status == status)

    for clause in where_clauses:
        query = query.where(clause)
        count_query = count_query.where(clause)

    total = (await self.db.execute(count_query)).scalar() or 0
    query = query.offset((page - 1) * page_size).limit(page_size)
    result = await self.db.execute(query)
    executions = list(result.scalars().all())

    return executions, total


async def get_execution_report(self, execution_id: str) -> Optional[Dict[str, Any]]:
    """获取执行记录报告"""
    return await self.get_report(execution_id=execution_id)


async def get_execution_snapshots(
    self, execution_id: str, page: int = 1, page_size: int = 500
) -> Tuple[List[PerformanceSnapshot], int]:
    """获取执行记录的快照数据"""
    query = (
        select(PerformanceSnapshot)
        .where(PerformanceSnapshot.execution_id == execution_id)
        .order_by(PerformanceSnapshot.elapsed.asc())
    )
    count_query = (
        select(func.count())
        .select_from(PerformanceSnapshot)
        .where(PerformanceSnapshot.execution_id == execution_id)
    )
    total = (await self.db.execute(count_query)).scalar() or 0
    query = query.offset((page - 1) * page_size).limit(page_size)
    result = await self.db.execute(query)
    snapshots = list(result.scalars().all())
    return snapshots, total


async def delete_execution(self, execution_id: str) -> bool:
    """删除执行记录（连带快照）"""
    execution = await self.db.get(PerformanceExecution, execution_id)
    if not execution:
        return False
    await self.db.delete(execution)
    await self.db.commit()
    return True
```

#### 3.6 存量回填方法

```python
async def _migrate_legacy_executions(self) -> int:
    """
    为存量任务生成 legacy 执行记录（幂等）

    Returns:
        int: 生成的执行记录数
    """
    from app.utils.id_generator import generate_id

    # 检查是否已有 legacy 执行记录
    existing = await self.db.execute(
        select(PerformanceExecution).where(
            PerformanceExecution.triggered_by == "legacy"
        ).limit(1)
    )
    if existing.scalar_one_or_none():
        # 已在之前回填过，跳过
        logger.info("存量执行记录已回填，跳过")
        return 0

    # 查询所有有执行记录的任务
    tasks = await self.db.execute(
        select(PerformanceTask).where(PerformanceTask.start_time.isnot(None))
    )
    tasks = list(tasks.scalars().all())

    count = 0
    for task in tasks:
        execution = PerformanceExecution(
            id=generate_id("exec"),
            task_id=task.id,
            task_name=task.name,
            project_id=task.project_id,
            status=task.status if task.status in ("completed", "failed", "cancelled") else "completed",
            error_message=task.error_message,
            start_time=task.start_time,
            end_time=task.end_time,
            duration_actual=task.duration_actual,
            triggered_by="legacy",
            snapshot_count=0,
        )
        self._copy_result_to_execution(execution, task)
        self.db.add(execution)
        count += 1

        # 归并该任务名下 execution_id 为空的存量快照
        snapshot_result = await self.db.execute(
            select(PerformanceSnapshot)
            .where(
                PerformanceSnapshot.task_id == task.id,
                PerformanceSnapshot.execution_id.is_(None),
            )
        )
        old_snapshots = list(snapshot_result.scalars().all())
        for s in old_snapshots:
            s.execution_id = execution.id
            execution.snapshot_count += 1

    await self.db.commit()
    logger.info(f"存量执行记录回填: {count} 条")
    return count
```

#### 3.7 非阻塞化 `run_task_async()` 入口

新增方法，供路由调用，无需等执行完成：

```python
async def run_task_async(
    self,
    task_id: str,
    config_override: Optional[Dict[str, Any]] = None,
    triggered_by: str = "manual",
    batch_id: Optional[str] = None,
) -> str:
    """
    非阻塞执行性能测试任务

    创建执行记录后立即返回 execution_id，在后台执行。

    Returns:
        str: 执行记录ID
    """
    task = await self.get_task(task_id)
    if not task:
        raise ValueError(f"任务不存在: {task_id}")
    if task.status == "running":
        raise ValueError("任务正在执行中，不能重复启动")

    # 创建执行记录
    execution_id = generate_id("exec")
    execution = PerformanceExecution(
        id=execution_id,
        task_id=task_id,
        task_name=task.name,
        project_id=task.project_id,
        status="running",
        start_time=datetime.utcnow(),
        triggered_by=triggered_by,
        batch_id=batch_id,
    )
    self.db.add(execution)

    # 更新任务状态
    task.status = "running"
    task.start_time = datetime.utcnow()
    task.error_message = None
    await self.db.commit()

    # 后台执行
    task_obj = asyncio.create_task(
        self._run_single_background(execution_id, task_id, config_override)
    )
    self._background_tasks.add(task_obj)
    task_obj.add_done_callback(self._background_tasks.discard)

    return execution_id


async def _run_single_background(
    self,
    execution_id: str,
    task_id: str,
    config_override: Optional[Dict[str, Any]] = None,
) -> None:
    """
    后台单任务执行（使用独立数据库会话）

    参照 _run_batch_background 模式。
    """
    from app.database import async_session_maker

    async with async_session_maker() as db:
        try:
            service = PerformanceService(db)
            # 执行完成后结果已写入 execution 和 task
            await service.run_task(
                task_id=task_id,
                config_override=config_override,
                triggered_by="manual",
            )
        except Exception as e:
            logger.error(f"后台执行失败 [{execution_id}]: {e}")
            try:
                execution = await db.get(PerformanceExecution, execution_id)
                if execution:
                    execution.status = "failed"
                    execution.error_message = str(e)
                    await db.commit()
            except Exception:
                pass
```

#### 3.8 `batch_run_tasks()` 改造：内部调用 `run_task()` 带 batch 标识

```python
# 在 batch_run_tasks 中，调用 run_task 时传入 triggered_by="batch" 和 batch_id
await self.run_task(
    task_id=task_id,
    triggered_by="batch",
    batch_id=batch_id,
    progress_callback=lambda s: None,  # 批量内部已有进度回调
)
```

### Phase 4：后端 — 路由改造

文件：`backend/app/routers/performance.py`

#### 4.1 单任务执行路由非阻塞化

```python
@router.post("/tasks/{task_id}/run", response_model=PerformanceRunResponse)
async def run_task(
    task_id: str,
    data: PerformanceRunRequest = None,
    service: PerformanceService = Depends(get_perf_service),
):
    """执行性能测试任务（非阻塞，立即返回 execution_id）"""
    if data is None:
        data = PerformanceRunRequest()

    try:
        execution_id = await service.run_task_async(
            task_id,
            config_override=data.config_override,
        )
        return PerformanceRunResponse(
            message="任务已开始执行",
            task_id=task_id,
            execution_id=execution_id,
            status="running",
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
```

#### 4.2 新增执行历史路由

```python
@router.get("/executions", response_model=PerformanceExecutionListResponse)
async def list_executions(
    task_id: Optional[str] = Query(None, description="按任务筛选"),
    project_id: Optional[str] = Query(None, description="按项目筛选"),
    status: Optional[str] = Query(None, description="按状态筛选"),
    page: int = Query(1, ge=1, description="页码"),
    page_size: int = Query(20, ge=1, le=200, description="每页数量"),
    service: PerformanceService = Depends(get_perf_service),
):
    """获取执行历史列表"""
    executions, total = await service.get_executions(
        task_id=task_id, project_id=project_id, status=status,
        page=page, page_size=page_size,
    )
    return PerformanceExecutionListResponse(
        total=total,
        items=[PerformanceExecutionListItem(**e.to_dict()) for e in executions],
    )


@router.get("/executions/{execution_id}", response_model=PerformanceExecutionDetailResponse)
async def get_execution(
    execution_id: str,
    service: PerformanceService = Depends(get_perf_service),
):
    """获取执行记录详情"""
    execution = await service.db.get(PerformanceExecution, execution_id)
    if not execution:
        raise HTTPException(status_code=404, detail="执行记录不存在")
    return PerformanceExecutionDetailResponse(**execution.to_dict())


@router.get("/executions/{execution_id}/report", response_model=PerformanceExecutionReportResponse)
async def get_execution_report(
    execution_id: str,
    service: PerformanceService = Depends(get_perf_service),
):
    """获取执行记录报告"""
    report = await service.get_execution_report(execution_id)
    if not report:
        raise HTTPException(status_code=404, detail="执行记录不存在")
    return PerformanceExecutionReportResponse(
        summary=PerformanceReportSummary(**report["summary"]),
        metrics=PerformanceReportMetrics(**report["metrics"]),
        snapshots=[PerformanceSnapshotResponse(**s) for s in report["snapshots"]],
        resource_summary=report.get("resource_summary"),
        target_resource_summary=report.get("target_resource_summary"),
        transaction_summary=report.get("transaction_summary"),
        api_summary=report.get("api_summary"),
    )


@router.get("/executions/{execution_id}/snapshots", response_model=PerformanceSnapshotListResponse)
async def get_execution_snapshots(
    execution_id: str,
    page: int = Query(1, ge=1, description="页码"),
    page_size: int = Query(500, ge=1, le=5000, description="每页数量"),
    service: PerformanceService = Depends(get_perf_service),
):
    """获取执行记录的快照数据"""
    snapshots, total = await service.get_execution_snapshots(
        execution_id, page=page, page_size=page_size
    )
    return PerformanceSnapshotListResponse(
        total=total,
        items=[PerformanceSnapshotResponse(**s.to_dict()) for s in snapshots],
    )


@router.delete("/executions/{execution_id}")
async def delete_execution(
    execution_id: str,
    service: PerformanceService = Depends(get_perf_service),
):
    """删除执行记录（连带快照）"""
    deleted = await service.delete_execution(execution_id)
    if not deleted:
        raise HTTPException(status_code=404, detail="执行记录不存在")
    return {"message": "执行记录已删除"}
```

#### 4.3 现有 `GET /tasks/{task_id}/report` 保持兼容

```python
@router.get("/tasks/{task_id}/report", response_model=PerformanceReportResponse)
async def get_report(
    task_id: str,
    service: PerformanceService = Depends(get_perf_service),
):
    """
    获取性能测试报告（向后兼容）

    返回该任务最新一次执行的报告。
    若尚无执行记录，从任务字段构造报告（兼容存量数据）。
    """
    report = await service.get_report(task_id=task_id)
    if not report:
        raise HTTPException(status_code=404, detail="任务不存在或无执行记录")
    return PerformanceReportResponse(
        summary=PerformanceReportSummary(**report["summary"]),
        metrics=PerformanceReportMetrics(**report["metrics"]),
        snapshots=[PerformanceSnapshotResponse(**s) for s in report["snapshots"]],
        resource_summary=report.get("resource_summary"),
        target_resource_summary=report.get("target_resource_summary"),
        transaction_summary=report.get("transaction_summary"),
        api_summary=report.get("api_summary"),
    )
```

#### 4.4 WS 消息 `perf_complete` 扩展

在 `run_task()` 的 `finally` 块中，广播消息增加 `execution_id`：

```python
asyncio.ensure_future(
    manager.broadcast(ws_group, {
        "type": "perf_complete",
        "task_id": task_id,
        "execution_id": execution_id,
        "status": task.status,
        "data": task.to_dict(),
    })
)
```

### Phase 5：前端 — 类型与 API 扩展

#### 5.1 类型定义

文件：`frontend/src/types/performance.ts`

```typescript
/** 执行记录 */
export interface PerformanceExecution {
  id: string
  taskId: string
  taskName: string
  projectId: string | null
  status: PerfStatus
  errorMessage: string | null
  startTime: string | null
  endTime: string | null
  durationActual: number
  totalRequests: number
  actualTps: number
  avgResponseTime: number
  p95ResponseTime: number
  errorRate: number
  triggeredBy: string
  snapshotCount: number
}

/** 执行记录列表响应 */
export interface PerformanceExecutionListResponse {
  total: number
  items: PerformanceExecution[]
}

/** 执行记录报告响应 */
export interface PerformanceExecutionReportResponse {
  summary: PerformanceReportSummary
  metrics: PerformanceReportMetrics
  snapshots: PerformanceSnapshot[]
  resourceSummary: any
  targetResourceSummary: any
  transactionSummary: any
  apiSummary: any
}

/** 执行响应扩展 */
export interface PerformanceRunResponse {
  message: string
  taskId: string
  executionId?: string
  status: string
}
```

#### 5.2 API 封装

文件：`frontend/src/api/performance.ts`

```typescript
/** 执行历史列表 */
export function getExecutions(params?: {
  taskId?: string
  projectId?: string
  status?: string
  page?: number
  pageSize?: number
}): Promise<AxiosResponse<PerformanceExecutionListResponse>> {
  return request.get('/api/performance/executions', { params })
}

/** 执行记录详情 */
export function getExecutionDetail(executionId: string): Promise<AxiosResponse<PerformanceExecution>> {
  return request.get(`/api/performance/executions/${executionId}`)
}

/** 执行记录报告 */
export function getExecutionReport(executionId: string): Promise<AxiosResponse<PerformanceExecutionReportResponse>> {
  return request.get(`/api/performance/executions/${executionId}/report`)
}

/** 执行记录快照 */
export function getExecutionSnapshots(
  executionId: string,
  params?: { page?: number; pageSize?: number }
): Promise<AxiosResponse<PerformanceSnapshotListResponse>> {
  return request.get(`/api/performance/executions/${executionId}/snapshots`, { params })
}

/** 删除执行记录 */
export function deleteExecution(executionId: string): Promise<AxiosResponse> {
  return request.delete(`/api/performance/executions/${executionId}`)
}
```

### Phase 6：前端 — TaskList 跳转闭环

文件：`frontend/src/views/performance/TaskList.vue`

#### 6.1 `handleRun()` 改造

```typescript
async function handleRun(task: PerformanceTaskListItem) {
  runningId.value = task.id
  try {
    const res = await runTask(task.id)
    // 非阻塞返回，立即跳转监控页
    if (res.executionId) {
      router.push({
        path: '/performance/monitor',
        query: { taskId: task.id, executionId: res.executionId },
      })
    } else {
      // 兼容旧版（无 executionId 时）
      ElMessage.success(res.message || '任务已开始执行')
      await loadTasks()
    }
  } catch (e: any) {
    ElMessage.error('执行失败: ' + (e.response?.data?.detail || e.message || ''))
  } finally {
    runningId.value = ''
  }
}
```

#### 6.2 操作列新增「历史」入口

```typescript
<el-button
  v-if="row.status === 'completed' || row.status === 'failed'"
  link type="primary" size="small"
  @click="router.push('/performance/report?taskId=' + row.id)">
  历史
</el-button>
```

### Phase 7：前端 — MonitorPanel 实时/回放双模式

文件：`frontend/src/views/performance/MonitorPanel.vue`

#### 7.1 路由参数解析扩展

```typescript
const route = useRoute()
const router = useRouter()

// 路由参数
const taskId = computed(() => route.query.taskId as string || '')
const executionId = computed(() => route.query.executionId as string || '')

// 模式判定
const isReplayMode = computed(() => !!executionId.value && !isRunning.value)
const isLiveMode = computed(() => !!executionId.value && isRunning.value)
const isLegacyMode = computed(() => !!taskId.value && !executionId.value)

// 运行状态跟踪（实时模式通过 WS 判定）
const isRunning = ref(false)
```

#### 7.2 空态改造：最近执行列表

```vue
<template v-if="!taskId && !executionId">
  <!-- 空态：最近执行记录列表 -->
  <div class="execution-history">
    <h2>最近执行记录</h2>
    <el-table :data="recentExecutions" v-loading="loadingExecutions">
      <el-table-column prop="taskName" label="任务名" />
      <el-table-column prop="projectId" label="项目" />
      <el-table-column label="场景">
        <template #default="{ row }">
          {{ getScenarioLabel(row) }}
        </template>
      </el-table-column>
      <el-table-column prop="status" label="状态" width="100">
        <template #default="{ row }">
          <el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
        </template>
      </el-table-column>
      <el-table-column prop="startTime" label="开始时间" width="170" />
      <el-table-column prop="durationActual" label="时长(秒)" width="90" />
      <el-table-column prop="actualTps" label="TPS" width="80" />
      <el-table-column prop="errorRate" label="错误率" width="80" />
      <el-table-column label="操作" width="180" fixed="right">
        <template #default="{ row }">
          <el-button
            v-if="row.status === 'running'"
            link type="primary" size="small"
            @click="router.push('/performance/monitor?taskId=' + row.taskId + '&executionId=' + row.id)">
            实时监控
          </el-button>
          <el-button
            v-else
            link type="primary" size="small"
            @click="router.push('/performance/monitor?executionId=' + row.id)">
            监控回放
          </el-button>
          <el-button
            link type="primary" size="small"
            @click="router.push('/performance/report?executionId=' + row.id)">
            查看报告
          </el-button>
        </template>
      </el-table-column>
    </el-table>
    <el-pagination
      v-if="executionTotal > 0"
      v-model:current-page="executionPage"
      :page-size="20"
      :total="executionTotal"
      layout="prev, pager, next"
      @current-change="loadExecutions"
    />
  </div>
</template>
```

#### 7.3 回放模式：从快照 API 加载数据

```typescript
// 历史回放模式：从 API 加载全部快照
async function loadReplayData() {
  if (!executionId.value) return
  loading.value = true
  try {
    const res = await getExecutionSnapshots(executionId.value, { pageSize: 5000 })
    const snapshots = res.data.items

    // 清空现有图表数据
    timeLabels.value = []
    tpsData.value = []
    avgRtData.value = []
    p95Data.value = []
    // ... 其他图表数据

    // 填充图表数据
    snapshots.forEach((s: any) => {
      timeLabels.value.push(formatTime(s.elapsed))
      tpsData.value.push(s.tps || 0)
      avgRtData.value.push(s.avgResponseTime || 0)
      p95Data.value.push(s.p95 || 0)
      // ... 其他字段
    })

    // 更新图表
    updateCharts()
  } catch (e) {
    console.error('加载回放数据失败:', e)
  } finally {
    loading.value = false
  }
}

// 模式切换
watch([taskId, executionId], () => {
  if (isReplayMode.value) {
    loadReplayData()
  }
})
```

#### 7.4 完成横幅 + 倒计时跳转

```typescript
// 在 WS onComplete 回调中
function onComplete(msg: any) {
  isRunning.value = false
  showCompleteBanner.value = true
  completeStatus.value = msg.status
  completeData.value = msg.data

  // 倒计时 3 秒自动跳转报告
  countdown.value = 3
  countdownTimer.value = setInterval(() => {
    countdown.value--
    if (countdown.value <= 0) {
      clearInterval(countdownTimer.value)
      navigateToReport()
    }
  }, 1000)
}

function navigateToReport() {
  const execId = executionId.value
  if (execId) {
    router.push('/performance/report?executionId=' + execId)
  }
}

function cancelAutoNavigate() {
  clearInterval(countdownTimer.value)
  showCompleteBanner.value = false
}
```

```vue
<!-- 完成横幅 -->
<el-affix v-if="showCompleteBanner" position="top" :offset="0">
  <el-alert
    :title="completeStatus === 'completed' ? '✅ 执行完成' : '❌ 执行失败'"
    :type="completeStatus === 'completed' ? 'success' : 'error'"
    show-icon
    :closable="false"
  >
    <template #default>
      <div class="complete-banner-content">
        <span>请求数: {{ completeData?.totalRequests }}</span>
        <span>TPS: {{ completeData?.actualTps }}</span>
        <span>错误率: {{ (completeData?.errorRate * 100).toFixed(2) }}%</span>
        <span>P95: {{ completeData?.p95ResponseTime }}ms</span>
      </div>
      <div class="complete-banner-actions">
        <el-button @click="navigateToReport" type="primary" size="small">
          立即查看报告
        </el-button>
        <el-button @click="cancelAutoNavigate" size="small">
          留在本页（{{ countdown }}s）
        </el-button>
      </div>
    </template>
  </el-alert>
</el-affix>
```

### Phase 8：前端 — ReportPanel 执行切换器

文件：`frontend/src/views/performance/ReportPanel.vue`

#### 8.1 路由参数解析 + 执行切换器

```typescript
const route = useRoute()
const router = useRouter()

const taskId = computed(() => route.query.taskId as string || '')
const executionId = computed(() => route.query.executionId as string || '')

// 当前任务的所有执行记录（用于切换器）
const executionOptions = ref<Array<{ id: string; label: string }>>([])
const currentExecutionId = ref('')

// 加载执行历史列表
async function loadExecutionOptions() {
  const targetTaskId = taskId.value || ''
  if (!targetTaskId) {
    // 无参数进入：显示最近执行列表
    loadRecentExecutions()
    return
  }
  const res = await getExecutions({ taskId: targetTaskId, pageSize: 100 })
  executionOptions.value = (res.data.items || []).map((e: any) => ({
    id: e.id,
    label: `${formatTime(e.startTime)} #${e.status} TPS:${e.actualTps} RT:${e.avgResponseTime}ms`,
  }))
}

// 切换执行记录
function switchExecution(execId: string) {
  router.push({
    path: '/performance/report',
    query: { executionId: execId },
  })
}

// 加载报告数据
async function loadReport() {
  loading.value = true
  try {
    let reportData
    if (executionId.value) {
      const res = await getExecutionReport(executionId.value)
      reportData = res.data
    } else if (taskId.value) {
      const res = await getReport(taskId.value)
      reportData = res.data
    } else {
      return
    }
    applyReportData(reportData)
  } catch (e) {
    console.error('加载报告失败:', e)
  } finally {
    loading.value = false
  }
}
```

#### 8.2 空态改造

```vue
<template v-if="!taskId && !executionId">
  <!-- 空态：最近执行列表 -->
  <div class="execution-history">
    <h2>最近执行记录</h2>
    <!-- 同上 MonitorPanel 风格，操作列改为「查看报告」 -->
    ...
  </div>
</template>
```

#### 8.3 报告页执行切换器

```vue
<template v-else>
  <div class="report-header">
    <div class="report-header-left">
      <h2>{{ reportSummary?.taskName || '性能测试报告' }}</h2>
      <el-select
        v-if="executionOptions.length > 1"
        v-model="currentExecutionId"
        @change="switchExecution"
        placeholder="选择执行记录"
        style="width: 400px"
      >
        <el-option
          v-for="opt in executionOptions"
          :key="opt.id"
          :label="opt.label"
          :value="opt.id"
        />
      </el-select>
    </div>
    <div class="report-header-right">
      <el-tag v-if="isReplay" type="info">⏱ 历史报告</el-tag>
      <el-tag :type="reportStatusTag(reportSummary?.status)">
        {{ reportSummary?.status }}
      </el-tag>
    </div>
  </div>
  <!-- 报告内容区域（原有逻辑不变） -->
  ...
</template>
```

### Phase 9：启动回填 + 数据库迁移

文件：`backend/app/database.py`

```python
async def _ensure_columns(conn) -> None:
    """自动补充缺失的列（新增列于此）"""
    columns_to_add = [
        # ... 已有列
        # 新增列
        ("performance_snapshots", "execution_id", "VARCHAR(64)"),
    ]
    # ... 原有逻辑不变
```

文件：`backend/app/main.py`（或 `database.py` 的 `init_db()`）

```python
# 在 init_db 末尾，完成表创建 + 列迁移后，触发存量回填
from app.services.performance_service import PerformanceService
from app.database import async_session_maker

async with async_session_maker() as db:
    service = PerformanceService(db)
    count = await service._migrate_legacy_executions()
    if count > 0:
        logger.info(f"存量执行记录回填完成: {count} 条")
```

> ✅ **已实施（2026-08-24）**：`database.py` 的 `_ensure_columns` 已加入 `("performance_snapshots", "execution_id", "VARCHAR(64) DEFAULT NULL")`；`init_db()` 末尾触发 `service._migrate_legacy_executions()`（幂等，失败不阻断启动，下次重试）。

---

## 四、文件变更清单

### 新增

| 文件 | 说明 |
|------|------|
| `Docs/PRD/性能测试/需求文档/_PRD_执行跳转闭环与执行历史.md` | 需求文档 |
| `Docs/PRD/性能测试/需求文档/_PRD_执行跳转闭环与执行历史_计划执行.md` | 本执行计划 |

### 修改

| 文件 | 变更 |
|------|------|
| `backend/app/models/performance.py` | 新增 `PerformanceExecution` 模型；`PerformanceSnapshot` 新增 `execution_id` 列 + 关系；`PerformanceTask` 新增 `executions` 关系 |
| `backend/app/schemas/performance.py` | 新增 `PerformanceExecutionListItem`、`PerformanceExecutionListResponse`、`PerformanceExecutionDetailResponse`、`PerformanceExecutionReportResponse`；`PerformanceRunResponse` 新增 `execution_id` |
| `backend/app/services/performance_service.py` | `run_task()` 双写改动；`_save_snapshots()` 带 `execution_id`；新增 `run_task_async()`、`_run_single_background()`、`_copy_result_to_execution()`、`get_executions()`、`get_execution_report()`、`get_execution_snapshots()`、`delete_execution()`、`_migrate_legacy_executions()`；`get_report()` 按 execution 取数；`batch_run_tasks()` 传 batch 标识 |
| `backend/app/routers/performance.py` | `run_task` 非阻塞化；新增 `/executions` 系列 4 个端点；WS `perf_complete` 增加 `execution_id` |
| `backend/app/database.py` | `_ensure_columns` 新增 `performance_snapshots.execution_id`；`init_db` 末尾触发存量回填 |
| `frontend/src/types/performance.ts` | 新增 `PerformanceExecution`、`PerformanceExecutionListResponse`、`PerformanceExecutionReportResponse` 类型；`PerformanceRunResponse` 扩展 |
| `frontend/src/api/performance.ts` | 新增 `getExecutions`、`getExecutionDetail`、`getExecutionReport`、`getExecutionSnapshots`、`deleteExecution` 函数 |
| `frontend/src/views/performance/TaskList.vue` | `handleRun()` 跳转监控页；操作列新增「历史」入口 |
| `frontend/src/views/performance/MonitorPanel.vue` | 实时/回放双模式判定；回放模式从 API 加载快照；完成横幅 + 倒计时跳报告；空态改最近执行记录列表 |
| `frontend/src/views/performance/ReportPanel.vue` | 按 executionId 加载报告；执行切换器；空态改最近执行记录列表 |

### 明确不修改

- `backend/app/executors/performance_executor.py` — 执行引擎逻辑不变
- `backend/app/executors/target_resource_monitor.py` — 目标机监控不变
- `backend/app/models/performance.py` 的 `PerformanceBatchExecution` / `PerformanceProjectReport` — 批次和合并报告模型不变
- `backend/app/routers/performance.py` 的批量/项目路由 — 保持不变
- 前端其他页面（Projects、ProjectDetail、BatchMonitor、ProjectReport、MonitorPanel 的实时 WS 逻辑）— 不受影响

---

## 五、风险与应对

| 风险 | 应对 |
|------|------|
| `run_task_async()` 后台任务持有独立 DB 会话，MySQL 下可能有连接池耗尽风险 | `_background_tasks` 回调自动 discard；`async_session_maker` 创建短生命周期会话；MySQL `pool_recycle=3600` 已配置 |
| 存量快照 `execution_id IS NULL` 迁移后，旧报告查询可能不兼容 | `get_report(task_id)` 回退到从任务字段构造报告（`_build_report_from_task`），无执行记录时仍可查看 |
| 快照查询全量 5k 条时前端渲染卡顿 | ECharts 降采样 `sampling: 'lttb'`；快照 API 分页上限 5000；长稳任务建议 limit 500 |
| 部署时 MySQL TEXT 列不支持 DEFAULT | `performance_executions` 的 `error_message` 列 nullable=True，无 DEFAULT 约束；`_ensure_columns` 跳过 TEXT 列的 DEFAULT 设置 |
| 后台执行异常时 `execution.status` 未更新 | `_run_single_background` 的 `except` 块将状态设为 `failed` 并写 `error_message` |
| 批量执行 `batch_run_tasks` 内部调用 `run_task` 传 `triggered_by="batch"` 后，WS 消息 `perf_complete` 带 `execution_id`，BatchMonitor 需适配 | BatchMonitor 不做改动，仅透传 `execution_id` 到后续报告跳转 URL；BatchMonitor 已有自己的 WS 完成逻辑，不受影响 |

---

## 六、验收门禁

代码合并前必须满足：

- [ ] PRD 与计划执行文档已落盘
- [ ] 后端 `PerformanceExecution` 模型 + Schema 新增字段通过类型检查
- [ ] `_ensure_columns` 自动迁移新增 `performance_snapshots.execution_id` 列
- [ ] `POST /tasks/{task_id}/run` 非阻塞，立即返回 `execution_id`
- [ ] 执行后 TaskList 自动跳转 MonitorPanel（`?taskId=xxx&executionId=xxx`）
- [ ] MonitorPanel 实时模式 WS 正常接收快照和完成通知
- [ ] MonitorPanel 回放模式从 API 加载快照，图表正常渲染
- [ ] MonitorPanel 收到 `perf_complete` 后显示完成横幅 + 倒计时
- [ ] 倒计时结束自动跳转 ReportPanel（`?executionId=xxx`）
- [ ] ReportPanel 按 executionId 加载报告，显示正确指标和快照
- [ ] ReportPanel 执行切换器列出该任务全部历史执行，切换后更新报告
- [ ] MonitorPanel / ReportPanel 空态显示最近执行记录列表
- [ ] 执行历史列表 API 分页、筛选正常
- [ ] 存量数据回填后，旧任务可查看报告和监控回放
- [ ] `GET /tasks/{task_id}/report` 向后兼容，返回最新执行报告
- [ ] 批量执行流程不受影响（合并报告、BatchMonitor 正常）
- [ ] `npm run build` 通过
- [ ] HANDOFF 文档更新进度

---

*本文档由 Claude Code 生成*