提交 7c1c0488 authored 作者: 陈泽健's avatar 陈泽健

feat(performance): API接口预设功能 - 预设管理CRUD与任务表单预设下拉继承

- 后端: ApiPreset 数据模型/CRUD路由服务/预设继承逻辑
- 前端: 预设管理列表页+编辑弹窗+任务表单预设下拉自动填充
- 数据库: 迁移补齐 performance_tasks.capture_rules/preset_id
- 路由: 注册 api_preset/performance_output 子路由
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 d0738690
......@@ -123,6 +123,8 @@ async def _ensure_columns(conn) -> None:
# 导入模型以确保表被创建(必须导入才会注册到 Base.metadata)
from app.models import device_sim # noqa: F401
from app.models import performance # noqa: F401
from app.models import performance_output # noqa: F401
from app.models import api_preset # noqa: F401
# 需要补齐的字段:[(表名, 列名, 列定义SQL)]
columns_to_add = [
......@@ -134,6 +136,10 @@ async def _ensure_columns(conn) -> None:
("device_env_configs", "topics", "JSON"),
# 性能测试:登录接口压测需签名(旧库升级)
("performance_tasks", "sign_request", "BOOLEAN DEFAULT 0"),
# 性能测试:响应捕获规则(旧库升级)
("performance_tasks", "capture_rules", "JSON"),
# 性能测试:任务关联的接口预设(旧库升级)
("performance_tasks", "preset_id", "VARCHAR(64) DEFAULT NULL"),
]
def _do_ensure(sync_conn) -> None:
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:template_resolver.py
模块描述:性能测试请求体模板变量替换器
支持占位符:
- {__NOW__} → 当前时间 "YYYY-MM-DD HH:mm"
- {__NOW_+N_H__} → 当前时间 + N 小时
- {__NOW_+N_M__} → 当前时间 + N 分钟
- {__NOW_+N_D__} → 当前时间 + N 天
- {__RANDOM_NAME_N__} → N 位随机字母数字
- {__UUID__} → 随机 UUID(无横线)
- {__RANDOM_INT_N_M__}→ N~M 随机整数
- {__INDEX__} → 请求序号
- {__REF:task_id:key__} → 引用其他任务输出
作者:czj
创建日期:2026-08-13
"""
import random
import re
import string
import uuid
from datetime import datetime, timedelta
from typing import Any, Callable, Optional
# 占位符正则:{__TOKEN__}
# token 支持字母、下划线、数字、冒号、加号(如 NOW_+2_H / REF:task:key)
PATTERN = re.compile(r"\{__([A-Za-z_+0-9:]+?)__\}")
class TemplateResolver:
"""
请求体模板变量替换器
不对原模板做修改,resolve() 返回新的容器(dict/list)。
Usage:
>>> resolver = TemplateResolver()
>>> body = resolver.resolve({"name": "{__RANDOM_NAME_8__}", "time": "{__NOW__}"})
"""
def __init__(self, ref_provider: Optional[Callable[[str, str], list]] = None):
"""
Args:
ref_provider: 跨任务引用取值回调 (task_id, key) -> list
None 时 REF 占位符不替换(返回空字符串)
"""
self._ref_provider = ref_provider
def resolve(self, template: Any, request_index: int = 0) -> Any:
"""
递归替换模板中的占位符
Args:
template: dict/list/str,支持嵌套
request_index: 当前请求序号(对应 {__INDEX__})
Returns:
替换后的 dict/list/str(不修改原模板)
"""
if isinstance(template, dict):
return {
k: self.resolve(v, request_index)
for k, v in template.items()
}
if isinstance(template, list):
return [self.resolve(v, request_index) for v in template]
if isinstance(template, str):
return self._replace_string(template, request_index)
return template
def _replace_string(self, text: str, request_index: int) -> str:
"""替换单个字符串中的所有占位符"""
if "__" not in text:
return text
def _repl(match):
token = match.group(1)
return self._resolve_token(token, request_index)
return PATTERN.sub(_repl, text)
def _resolve_token(self, token: str, request_index: int) -> str:
"""
解析单个占位符 token
支持格式:
- NOW / NOW_+2_H / NOW_+30_M / NOW_+1_D(_ 分隔)
- RANDOM_NAME_6(_ 分隔)
- RANDOM_INT_1_100(_ 分隔)
- UUID / INDEX
- REF:task_id:key(: 分隔)
Args:
token: 占位符内部内容(不含 {__ __} 外壳)
request_index: 当前请求序号
Returns:
str: 替换后的值;无法解析时保留原样
"""
# REF 引用:冒号分隔(REF:task_id:key)
if token.startswith("REF:"):
parts = token.split(":")
if len(parts) >= 3:
task_id, key = parts[1], parts[2]
if self._ref_provider:
values = self._ref_provider(task_id, key)
if values:
# 轮询取用,避免并发下都取第一个
return str(values[request_index % len(values)])
# 引用为空或没有 provider → 空串(调用方记录警告)
return ""
return f"{{__{token}__}}"
# 其余占位符按下划线分隔解析
parts = token.split("_")
kind = parts[0]
if kind == "NOW":
# NOW / NOW_+2_H / NOW_+30_M / NOW_+1_D
if len(parts) > 1:
delta = parts[1]
unit = parts[2] if len(parts) > 2 else "M"
return self._now_with_offset(delta, unit)
return self._now_str()
if token.startswith("RANDOM_NAME_"):
length = int(parts[2]) if len(parts) > 2 else 8
return self._random_string(length)
if token.startswith("RANDOM_INT_"):
try:
lo, hi = int(parts[2]), int(parts[3])
if lo > hi:
lo, hi = hi, lo
return str(random.randint(lo, hi))
except (ValueError, IndexError):
return f"{{__{token}__}}"
if kind == "UUID":
return uuid.uuid4().hex
if kind == "INDEX":
return str(request_index)
# 未知占位符 → 保留原样
return f"{{__{token}__}}"
# ---------- 时间相关 ----------
def _now_str(self) -> str:
"""当前时间字符串 YYYY-MM-DD HH:mm"""
return datetime.now().strftime("%Y-%m-%d %H:%M")
def _now_with_offset(self, delta: str, unit: str) -> str:
"""
当前时间 + 偏移
Args:
delta: 偏移量(如 "+2")
unit: 单位(H=小时, M=分钟, D=天)
Returns:
str: 偏移后的时间字符串
"""
try:
n = int(delta.lstrip("+"))
except ValueError:
return self._now_str()
base = datetime.now()
if unit == "H":
base += timedelta(hours=n)
elif unit == "D":
base += timedelta(days=n)
else: # 默认分钟
base += timedelta(minutes=n)
return base.strftime("%Y-%m-%d %H:%M")
# ---------- 随机值相关 ----------
@staticmethod
def _random_string(length: int) -> str:
"""生成 N 位随机字母数字字符串"""
length = max(length, 1)
chars = string.ascii_letters + string.digits
return "".join(random.choice(chars) for _ in range(length))
\ No newline at end of file
......@@ -27,7 +27,7 @@ from fastapi.responses import FileResponse
from app.config import settings
from app.database import init_db
from app.routers import modules, cases, executions, recorder, stats, reports, cleanup, batch, dependencies, security, ai_locator, device_sim, system, element_locator, login_template, smart_locate, performance, deploy, functional_report
from app.routers import modules, cases, executions, recorder, stats, reports, cleanup, batch, dependencies, security, ai_locator, device_sim, system, element_locator, login_template, smart_locate, performance, performance_output, api_preset, deploy, functional_report
# 配置日志
logging.basicConfig(
......@@ -218,6 +218,20 @@ app.include_router(
tags=["性能测试"]
)
# 性能测试任务输出(响应捕获值,供 REF 引用验证)
app.include_router(
performance_output.router,
prefix="/api/performance",
tags=["性能测试-输出"]
)
# API 接口预设管理
app.include_router(
api_preset.router,
prefix="/api/performance",
tags=["性能测试-预设"]
)
app.include_router(
deploy.router,
prefix="/api/deploy",
......
......@@ -22,6 +22,8 @@ from app.models.deploy_script import DeployScript
from app.models.deploy_execution import DeployExecution
from app.models.deploy_log import DeployLog
from app.models.performance import PerformanceTask, PerformanceSnapshot
from app.models.performance_output import PerformanceTaskOutput
from app.models.api_preset import ApiPreset
from app.models.erp_config import ErpConfig
__all__ = [
......@@ -42,5 +44,7 @@ __all__ = [
"DeployLog",
"PerformanceTask",
"PerformanceSnapshot",
"PerformanceTaskOutput",
"ApiPreset",
"ErpConfig",
]
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:api_preset.py
模块描述:API 接口预设数据库模型定义
作者:czj
创建日期:2026-08-13
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Integer, JSON, DateTime, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ApiPreset(Base):
"""
API 接口预设数据库模型
将接口配置(方法、URL、请求头、请求体、认证配置等)抽象为可复用的预设,
创建性能测试任务时只需选择预设即可自动填充接口字段。
Attributes:
id (str): 唯一标识 (preset_xxx)
name (str): 预设名称,如"新建会议V3"(唯一)
description (str): 描述,说明用途
# 接口配置
method (str): HTTP 方法 (GET/POST/PUT/DELETE)
target_url (str): 目标接口完整 URL(含协议和域名)
headers (dict): 自定义请求头
body (dict|list): 静态请求体(与 body_template 互斥)
body_template (dict): 请求体模板(支持 {__XXX__} 占位符替换)
# 认证配置
auth_required (bool): 是否需要登录 Token
sign_request (bool): 是否需要签名 (X-RANDOM/X-TIMESTAMP/X-SIGN)
account_key (str): 使用账号 key(superadmin/admin/user)
# 响应捕获
capture_rules (list): 捕获规则列表
# 时间
created_at (datetime): 创建时间
updated_at (datetime): 更新时间
"""
__tablename__ = "api_presets"
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="预设ID (preset_xxx)")
name: Mapped[str] = mapped_column(
String(200), unique=True, index=True, nullable=False, comment="预设名称(唯一)"
)
description: Mapped[str] = mapped_column(Text, default="", comment="描述")
# 接口配置
method: Mapped[str] = mapped_column(String(10), default="GET", comment="HTTP方法")
target_url: Mapped[str] = mapped_column(Text, default="", comment="目标接口完整URL")
headers: Mapped[dict] = mapped_column(JSON, default=dict, comment="自定义请求头")
body: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="静态请求体")
body_template: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="请求体模板")
# 认证配置
auth_required: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否需要登录Token")
sign_request: Mapped[bool] = mapped_column(
Boolean, default=False, comment="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)"
)
account_key: Mapped[str] = mapped_column(String(50), default="superadmin", comment="使用账号key")
# 响应捕获
capture_rules: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="捕获规则列表")
# 时间
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
def __repr__(self) -> str:
"""字符串表示"""
return f"<ApiPreset(id={self.id}, name={self.name}, method={self.method})>"
def to_dict(self) -> dict:
"""
转换为字典
Returns:
dict: 预设数据字典
"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"method": self.method,
"target_url": self.target_url,
"headers": self.headers or {},
"body": self.body,
"body_template": self.body_template,
"auth_required": self.auth_required,
"sign_request": self.sign_request,
"account_key": self.account_key,
"capture_rules": self.capture_rules or [],
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
\ No newline at end of file
......@@ -112,9 +112,17 @@ class PerformanceTask(Base):
# 签名配置
sign_request: Mapped[bool] = mapped_column(Boolean, default=False, comment="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
# 关联接口预设(可选,仅记录来源,不参与执行)
preset_id: Mapped[Optional[str]] = mapped_column(
String(64), nullable=True, default=None, comment="关联的接口预设ID"
)
# 断言配置
assertions: Mapped[list] = mapped_column(JSON, default=list, comment="断言规则列表")
# 响应捕获规则
capture_rules: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="响应捕获规则列表")
# 结果统计
total_requests: Mapped[int] = mapped_column(Integer, default=0, comment="总请求数")
success_count: Mapped[int] = mapped_column(Integer, default=0, comment="成功数")
......@@ -178,7 +186,9 @@ class PerformanceTask(Base):
"auth_required": self.auth_required,
"account_key": self.account_key,
"sign_request": self.sign_request,
"preset_id": self.preset_id,
"assertions": self.assertions or [],
"capture_rules": self.capture_rules or [],
"total_requests": self.total_requests,
"success_count": self.success_count,
"fail_count": self.fail_count,
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:performance_output.py
模块描述:性能测试任务输出数据库模型 — 存储从响应体捕获的字段值
作者:czj
创建日期:2026-08-13
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Integer, JSON, DateTime, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class PerformanceTaskOutput(Base):
"""
性能测试任务输出
记录性能测试执行过程中从响应体捕获的字段值,
供其他任务引用(如创建会议后捕获 messageId,用于取消会议)。
Attributes:
id (int): 自增主键
task_id (str): 关联任务 ID
key (str): 输出键名(如 "message_ids")
value (list): 输出值列表(JSON 数组)
created_at (datetime): 创建时间
"""
__tablename__ = "performance_task_outputs"
__table_args__ = (
UniqueConstraint("task_id", "key", name="uq_task_output_key"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="自增主键")
task_id: Mapped[str] = mapped_column(String(64), index=True, comment="关联任务ID")
key: Mapped[str] = mapped_column(String(100), comment="输出键名")
value: Mapped[list] = mapped_column(JSON, default=list, comment="输出值列表(JSON数组)")
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, comment="创建时间"
)
def __repr__(self) -> str:
return f"<PerformanceTaskOutput(id={self.id}, task_id={self.task_id}, key={self.key})>"
def to_dict(self) -> dict:
return {
"id": self.id,
"task_id": self.task_id,
"key": self.key,
"value": self.value or [],
"created_at": self.created_at.isoformat() if self.created_at else None,
}
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:api_preset.py
模块描述:API 接口预设 CRUD 路由
作者:czj
创建日期:2026-08-13
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.api_preset import (
ApiPresetCreate,
ApiPresetUpdate,
ApiPresetResponse,
ApiPresetListItem,
ApiPresetListResponse,
)
from app.services.api_preset_service import ApiPresetService
logger = logging.getLogger(__name__)
router = APIRouter()
# ==================== 依赖注入 ====================
async def get_preset_service(db: AsyncSession = Depends(get_db)) -> ApiPresetService:
"""获取 ApiPresetService 实例"""
return ApiPresetService(db)
# ==================== CRUD 接口 ====================
@router.get("/presets", response_model=ApiPresetListResponse)
async def list_presets(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
keyword: Optional[str] = Query(None, description="按名称/URL 搜索"),
service: ApiPresetService = Depends(get_preset_service),
):
"""获取接口预设列表(分页)"""
items, total = await service.list_presets(page=page, page_size=page_size, keyword=keyword)
return ApiPresetListResponse(
total=total,
items=[ApiPresetListItem(**p.to_dict()) for p in items],
)
@router.post("/presets", response_model=ApiPresetResponse, status_code=201)
async def create_preset(
data: ApiPresetCreate,
service: ApiPresetService = Depends(get_preset_service),
):
"""创建接口预设"""
preset = await service.create_preset(data.model_dump())
return ApiPresetResponse(**preset.to_dict())
@router.get("/presets/{preset_id}", response_model=ApiPresetResponse)
async def get_preset(
preset_id: str,
service: ApiPresetService = Depends(get_preset_service),
):
"""获取接口预设详情"""
preset = await service.get_preset(preset_id)
if not preset:
raise HTTPException(status_code=404, detail="预设不存在")
return ApiPresetResponse(**preset.to_dict())
@router.put("/presets/{preset_id}", response_model=ApiPresetResponse)
async def update_preset(
preset_id: str,
data: ApiPresetUpdate,
service: ApiPresetService = Depends(get_preset_service),
):
"""更新接口预设"""
try:
preset = await service.update_preset(preset_id, data.model_dump(exclude_unset=True))
if not preset:
raise HTTPException(status_code=404, detail="预设不存在")
return ApiPresetResponse(**preset.to_dict())
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/presets/{preset_id}", status_code=204)
async def delete_preset(
preset_id: str,
service: ApiPresetService = Depends(get_preset_service),
):
"""删除接口预设"""
deleted = await service.delete_preset(preset_id)
if not deleted:
raise HTTPException(status_code=404, detail="预设不存在")
\ No newline at end of file
......@@ -72,7 +72,7 @@ async def create_task(
service: PerformanceService = Depends(get_perf_service),
):
"""创建性能测试任务"""
task = await service.create_task(data.model_dump())
task = await service.create_task(data)
return PerformanceTaskResponse(**task.to_dict())
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:performance_output.py
模块描述:性能测试任务输出查询 API 路由
提供从响应体捕获的字段值查询接口,供前端展示和 REF 引用验证。
作者:czj
创建日期:2026-08-13
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.performance_output import PerformanceTaskOutput
from app.schemas.performance_output import (
PerformanceTaskOutputResponse,
PerformanceTaskOutputListResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/outputs", response_model=PerformanceTaskOutputListResponse)
async def list_outputs(
task_id: Optional[str] = Query(None, description="按任务ID过滤"),
key: Optional[str] = Query(None, description="按键名过滤"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""
获取任务输出列表(分页)
支持按 task_id 和 key 过滤,用于 REF 引用验证和前端展示。
"""
query = select(PerformanceTaskOutput)
count_query = select(func.count()).select_from(PerformanceTaskOutput)
if task_id:
query = query.where(PerformanceTaskOutput.task_id == task_id)
count_query = count_query.where(PerformanceTaskOutput.task_id == task_id)
if key:
query = query.where(PerformanceTaskOutput.key == key)
count_query = count_query.where(PerformanceTaskOutput.key == key)
total = (await db.execute(count_query)).scalar() or 0
query = (
query.order_by(desc(PerformanceTaskOutput.created_at))
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(query)
items = list(result.scalars().all())
return PerformanceTaskOutputListResponse(
total=total,
items=[PerformanceTaskOutputResponse(**item.to_dict()) for item in items],
)
@router.get("/outputs/{output_id}", response_model=PerformanceTaskOutputResponse)
async def get_output(
output_id: int,
db: AsyncSession = Depends(get_db),
):
"""获取单个任务输出详情"""
output = await db.get(PerformanceTaskOutput, output_id)
if not output:
raise HTTPException(status_code=404, detail="输出记录不存在")
return PerformanceTaskOutputResponse(**output.to_dict())
@router.get("/tasks/{task_id}/outputs", response_model=PerformanceTaskOutputListResponse)
async def get_task_outputs(
task_id: str,
key: Optional[str] = Query(None, description="按键名过滤"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""
获取某个任务的所有输出
便捷接口,直接按 task_id 查询,可指定 key 过滤特定字段。
"""
query = select(PerformanceTaskOutput).where(
PerformanceTaskOutput.task_id == task_id
)
count_query = (
select(func.count())
.select_from(PerformanceTaskOutput)
.where(PerformanceTaskOutput.task_id == task_id)
)
if key:
query = query.where(PerformanceTaskOutput.key == key)
count_query = count_query.where(PerformanceTaskOutput.key == key)
total = (await db.execute(count_query)).scalar() or 0
query = (
query.order_by(PerformanceTaskOutput.created_at.asc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(query)
items = list(result.scalars().all())
return PerformanceTaskOutputListResponse(
total=total,
items=[PerformanceTaskOutputResponse(**item.to_dict()) for item in items],
)
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:api_preset.py
模块描述:API 接口预设 Pydantic Schema
作者:czj
创建日期:2026-08-13
"""
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, ConfigDict
from app.schemas.performance_output import CaptureRule
def to_camel(string: str) -> str:
"""Convert snake_case to camelCase"""
components = string.split('_')
return components[0] + ''.join(
x if x[0].isdigit() else x.title()
for x in components[1:]
)
# ==================== 创建/更新 ====================
class ApiPresetCreate(BaseModel):
"""创建 API 接口预设"""
name: str = Field(..., min_length=1, max_length=200, description="预设名称")
description: str = Field("", description="描述")
# 接口配置
method: str = Field("GET", description="HTTP 方法 (GET/POST/PUT/DELETE)")
target_url: str = Field(..., description="目标接口完整 URL")
headers: Dict[str, str] = Field(default_factory=dict, description="自定义请求头")
body: Optional[Any] = Field(None, description="静态请求体(与 body_template 互斥)")
body_template: Optional[Dict[str, Any]] = Field(None, description="请求体模板")
# 认证配置
auth_required: bool = Field(True, description="是否需要登录 Token")
sign_request: bool = Field(False, description="是否需要签名")
account_key: str = Field("superadmin", description="使用账号 key")
# 响应捕获
capture_rules: Optional[List[CaptureRule]] = Field(None, description="捕获规则列表")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class ApiPresetUpdate(BaseModel):
"""更新 API 接口预设(所有字段可选)"""
name: Optional[str] = Field(None, min_length=1, max_length=200, description="预设名称")
description: Optional[str] = Field(None, description="描述")
method: Optional[str] = Field(None, description="HTTP 方法")
target_url: Optional[str] = Field(None, description="目标接口完整 URL")
headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头")
body: Optional[Any] = Field(None, description="静态请求体")
body_template: Optional[Dict[str, Any]] = Field(None, description="请求体模板")
auth_required: Optional[bool] = Field(None, description="是否需要登录 Token")
sign_request: Optional[bool] = Field(None, description="是否需要签名")
account_key: Optional[str] = Field(None, description="使用账号 key")
capture_rules: Optional[List[CaptureRule]] = Field(None, description="捕获规则列表")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== 响应 ====================
class ApiPresetResponse(BaseModel):
"""API 接口预设响应"""
id: str
name: str
description: str = ""
method: str = "GET"
target_url: str = ""
headers: Dict[str, str] = {}
body: Optional[Any] = None
body_template: Optional[Dict[str, Any]] = None
auth_required: bool = True
sign_request: bool = False
account_key: str = "superadmin"
capture_rules: List[Dict[str, Any]] = []
created_at: Optional[str] = None
updated_at: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class ApiPresetListItem(BaseModel):
"""API 接口预设列表项(简洁版)"""
id: str
name: str
description: str = ""
method: str = "GET"
target_url: str = ""
updated_at: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class ApiPresetListResponse(BaseModel):
"""API 接口预设列表响应"""
total: int
items: List[ApiPresetListItem]
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
\ No newline at end of file
......@@ -13,6 +13,8 @@ from datetime import datetime
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
from app.schemas.performance_output import CaptureRule
def to_camel(string: str) -> str:
"""Convert snake_case to camelCase"""
......@@ -46,7 +48,7 @@ class PerformanceTaskCreate(BaseModel):
target_url: str = Field(..., description="目标接口完整URL")
method: str = Field("GET", description="HTTP方法 (GET/POST/PUT/DELETE)")
headers: Dict[str, str] = Field(default_factory=dict, description="自定义请求头")
body: Optional[Dict[str, Any]] = Field(None, description="请求体")
body: Optional[Any] = Field(None, description="请求体(支持 dict 或 list)")
body_template: Optional[Dict[str, Any]] = Field(None, description="请求体模板")
# 压测配置
......@@ -65,9 +67,15 @@ class PerformanceTaskCreate(BaseModel):
# 签名配置
sign_request: bool = Field(False, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
# 关联接口预设
preset_id: Optional[str] = Field(None, description="关联的接口预设ID")
# 断言配置
assertions: List[AssertionRule] = Field(default_factory=list, description="断言规则列表")
# 响应捕获规则
capture_rules: Optional[List[CaptureRule]] = Field(None, description="响应捕获规则列表")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
......@@ -78,7 +86,7 @@ class PerformanceTaskUpdate(BaseModel):
target_url: Optional[str] = Field(None, description="目标接口完整URL")
method: Optional[str] = Field(None, description="HTTP方法")
headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头")
body: Optional[Dict[str, Any]] = Field(None, description="请求体")
body: Optional[Any] = Field(None, description="请求体(支持 dict 或 list)")
body_template: Optional[Dict[str, Any]] = Field(None, description="请求体模板")
mode: Optional[str] = Field(None, description="压测模式")
concurrency: Optional[int] = Field(None, ge=1, le=500, description="并发数")
......@@ -90,7 +98,9 @@ class PerformanceTaskUpdate(BaseModel):
auth_required: Optional[bool] = Field(None, description="是否需要登录Token")
account_key: Optional[str] = Field(None, description="使用的账号")
sign_request: Optional[bool] = Field(None, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
preset_id: Optional[str] = Field(None, description="关联的接口预设ID")
assertions: Optional[List[AssertionRule]] = Field(None, description="断言规则列表")
capture_rules: Optional[List[CaptureRule]] = Field(None, description="响应捕获规则列表")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
......@@ -105,7 +115,7 @@ class PerformanceTaskResponse(BaseModel):
target_url: str = ""
method: str = "GET"
headers: Dict[str, str] = {}
body: Optional[Dict[str, Any]] = None
body: Optional[Any] = None
body_template: Optional[Dict[str, Any]] = None
mode: str = "concurrent"
concurrency: int = 10
......@@ -118,6 +128,8 @@ class PerformanceTaskResponse(BaseModel):
account_key: str = "superadmin"
sign_request: bool = False
assertions: List[Dict[str, Any]] = []
capture_rules: Optional[List[Dict[str, Any]]] = None
preset_id: Optional[str] = None
# 结果统计
total_requests: int = 0
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:performance_output.py
模块描述:性能测试任务输出 Pydantic Schema
作者:czj
创建日期:2026-08-13
"""
from datetime import datetime
from typing import Any, List, Optional
from pydantic import BaseModel, Field, ConfigDict
# ==================== 捕获规则 ====================
class CaptureRule(BaseModel):
"""响应捕获规则"""
key: str = Field(..., description="输出键名")
json_path: str = Field(..., description="JSON Path 表达式,如 $.data.messageId")
multiple: bool = Field(True, description="是否允许多值追加")
description: str = Field("", description="规则描述")
model_config = ConfigDict(populate_by_name=True, from_attributes=True)
# ==================== 输出 Schema ====================
class PerformanceTaskOutputResponse(BaseModel):
"""任务输出响应"""
id: int
task_id: str
key: str
value: List[Any] = []
created_at: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, from_attributes=True)
class PerformanceTaskOutputListResponse(BaseModel):
"""任务输出列表响应"""
total: int
items: List[PerformanceTaskOutputResponse]
model_config = ConfigDict(populate_by_name=True, from_attributes=True)
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:api_preset_service.py
模块描述:API 接口预设服务层,提供 CRUD 业务逻辑
作者:czj
创建日期:2026-08-13
"""
import logging
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api_preset import ApiPreset
from app.utils.id_generator import generate_id
logger = logging.getLogger(__name__)
class ApiPresetService:
"""
API 接口预设服务
提供接口预设的增删改查业务逻辑。
"""
def __init__(self, db: AsyncSession):
"""
初始化服务
Args:
db: 数据库会话
"""
self.db = db
async def get_preset(self, preset_id: str) -> Optional[ApiPreset]:
"""
获取预设详情
Args:
preset_id: 预设ID
Returns:
ApiPreset: 预设对象,不存在返回 None
"""
return await self.db.get(ApiPreset, preset_id)
async def list_presets(
self,
page: int = 1,
page_size: int = 20,
keyword: Optional[str] = None,
) -> Tuple[List[ApiPreset], int]:
"""
获取预设列表(分页)
Args:
page: 页码
page_size: 每页数量
keyword: 按名称/URL 关键词搜索
Returns:
tuple: (预设列表, 总数)
"""
query = select(ApiPreset)
count_query = select(func.count()).select_from(ApiPreset)
if keyword:
like_pattern = f"%{keyword}%"
query = query.where(
(ApiPreset.name.like(like_pattern)) | (ApiPreset.target_url.like(like_pattern))
)
count_query = count_query.where(
(ApiPreset.name.like(like_pattern)) | (ApiPreset.target_url.like(like_pattern))
)
total = (await self.db.execute(count_query)).scalar() or 0
query = (
query.order_by(desc(ApiPreset.updated_at))
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await self.db.execute(query)
items = list(result.scalars().all())
return items, total
async def create_preset(self, data: Dict[str, Any]) -> ApiPreset:
"""
创建接口预设
Args:
data: 预设数据
Returns:
ApiPreset: 创建的预设对象
Raises:
HTTPException: 名称已存在时抛出 409
"""
# 检查名称唯一性
name = data.get("name", "").strip()
if name:
existing = await self.db.execute(
select(ApiPreset).where(ApiPreset.name == name)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=409,
detail=f"预设名称已存在: {name}"
)
capture_rules = data.pop("capture_rules", None)
preset = ApiPreset(
id=generate_id("preset"),
**data,
)
if capture_rules is not None:
preset.capture_rules = capture_rules
self.db.add(preset)
await self.db.commit()
await self.db.refresh(preset)
logger.info(f"创建接口预设: {preset.id} - {preset.name}")
return preset
async def update_preset(
self, preset_id: str, data: Dict[str, Any]
) -> Optional[ApiPreset]:
"""
更新接口预设
Args:
preset_id: 预设ID
data: 更新数据
Returns:
ApiPreset: 更新后的预设,不存在返回 None
Raises:
HTTPException: 名称冲突时抛出 409
"""
preset = await self.get_preset(preset_id)
if not preset:
return None
# 检查名称唯一性(排除自身)
name = data.get("name")
if name and name != preset.name:
existing = await self.db.execute(
select(ApiPreset).where(
ApiPreset.name == name,
ApiPreset.id != preset_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=409,
detail=f"预设名称已存在: {name}"
)
capture_rules = data.pop("capture_rules", None)
for key, value in data.items():
if value is not None and hasattr(preset, key):
setattr(preset, key, value)
if capture_rules is not None:
preset.capture_rules = capture_rules
await self.db.commit()
await self.db.refresh(preset)
return preset
async def delete_preset(self, preset_id: str) -> bool:
"""
删除接口预设
Args:
preset_id: 预设ID
Returns:
bool: 是否成功删除
"""
preset = await self.get_preset(preset_id)
if not preset:
return False
await self.db.delete(preset)
await self.db.commit()
logger.info(f"删除接口预设: {preset_id} - {preset.name}")
return True
\ No newline at end of file
......@@ -21,6 +21,9 @@ from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.performance import PerformanceTask, PerformanceSnapshot
from app.models.performance_output import PerformanceTaskOutput
from app.models.api_preset import ApiPreset
from app.schemas.performance import PerformanceTaskCreate
from app.executors.performance_executor import PerformanceExecutor
from app.websocket.manager import manager
from app.utils.id_generator import generate_id
......@@ -111,20 +114,41 @@ class PerformanceService:
return items, total
async def create_task(self, data: Dict[str, Any]) -> PerformanceTask:
async def create_task(self, data: PerformanceTaskCreate) -> PerformanceTask:
"""
创建性能测试任务
如果传入了 preset_id,从接口预设继承接口配置字段
(method/target_url/headers/body/body_template/capture_rules/
auth_required/sign_request/account_key),
任务显式传入的字段覆盖预设值。
Args:
data: 任务数据
data: 任务创建 Schema(含 preset_id 时只继承未显式传入的接口字段)
Returns:
PerformanceTask: 创建的任务对象
"""
assertions = data.pop("assertions", [])
# 用户显式传入的字段(排除 schema 默认值,避免默认值覆盖预设)
explicit = data.model_dump(exclude_unset=True)
# 从接口预设继承字段
preset_id = explicit.get("preset_id")
if preset_id:
preset = await self.db.get(ApiPreset, preset_id)
if not preset:
raise ValueError(f"接口预设不存在: {preset_id}")
explicit = self._inherit_from_preset(explicit, preset)
if not explicit.get("target_url"):
raise ValueError("目标接口 URL 不能为空")
# 用 schema 默认值补齐其余压测参数
payload = PerformanceTaskCreate(**explicit).model_dump()
assertions = payload.pop("assertions", [])
task = PerformanceTask(
id=generate_id("perf"),
**data,
**payload,
)
task.assertions = assertions
self.db.add(task)
......@@ -133,6 +157,78 @@ class PerformanceService:
logger.info(f"创建性能测试任务: {task.id} - {task.name}")
return task
@staticmethod
def _inherit_from_preset(data: Dict[str, Any], preset: ApiPreset) -> Dict[str, Any]:
"""
从接口预设继承字段(任务显式传入的值覆盖预设默认值)
输入的 data 为 exclude_unset 后的显式字段字典,
仅填充未显式传入的接口配置字段。
Args:
data: 任务显式传入的字段(exclude_unset 结果)
preset: 接口预设对象
Returns:
dict: 合并后的任务数据
"""
# 接口配置字段:任务显式传入的优先,否则用预设值
if not data.get("method"):
data["method"] = preset.method
if not data.get("target_url"):
data["target_url"] = preset.target_url
if not data.get("headers"):
data["headers"] = preset.headers or {}
if data.get("body") is None:
data["body"] = preset.body
if not data.get("body_template"):
data["body_template"] = preset.body_template
if data.get("capture_rules") is None:
data["capture_rules"] = preset.capture_rules or []
if data.get("auth_required") is None:
data["auth_required"] = preset.auth_required
if data.get("sign_request") is None:
data["sign_request"] = preset.sign_request
if not data.get("account_key"):
data["account_key"] = preset.account_key
return data
@staticmethod
def _inherit_from_preset(data: Dict[str, Any], preset: ApiPreset) -> Dict[str, Any]:
"""
从接口预设继承字段(任务显式传入的值覆盖预设默认值)
Args:
data: 任务创建数据(model_dump 后含全部字段)
preset: 接口预设对象
Returns:
dict: 合并后的任务数据
"""
# 预设字段作为默认值,任务显式传入的字段覆盖
data["method"] = data.get("method") or preset.method
data["target_url"] = data.get("target_url") or preset.target_url
data["headers"] = data.get("headers") or (preset.headers or {})
data["body"] = data["body"] if data.get("body") is not None else preset.body
data["body_template"] = data.get("body_template") or preset.body_template
data["capture_rules"] = (
data.get("capture_rules")
if data.get("capture_rules") is not None
else (preset.capture_rules or [])
)
data["auth_required"] = (
data.get("auth_required")
if data.get("auth_required") is not None
else preset.auth_required
)
data["sign_request"] = (
data.get("sign_request")
if data.get("sign_request") is not None
else preset.sign_request
)
data["account_key"] = data.get("account_key") or preset.account_key
return data
async def update_task(
self, task_id: str, data: Dict[str, Any]
) -> Optional[PerformanceTask]:
......@@ -294,6 +390,9 @@ class PerformanceService:
# 批量保存快照
await self._save_snapshots(task_id, snapshot_buffer)
# 保存响应捕获输出(供其他任务 REF 引用)
await self._save_outputs(task_id, result.get("captured") if result else None)
await self.db.commit()
# 清理运行缓存
......@@ -342,7 +441,7 @@ class PerformanceService:
# 创建 SimpleNamespace 以支持属性访问(executor 使用 task.mode 等)
task_obj = SimpleNamespace(**task_dict)
executor = PerformanceExecutor(client_config)
executor = PerformanceExecutor(client_config, ref_provider=self._build_ref_provider())
self._running_executors[task_dict.get("id", "")] = executor
result = loop.run_until_complete(
......@@ -587,3 +686,108 @@ class PerformanceService:
await self.db.flush()
logger.info(f"批量保存快照: {len(batch)} 条 (task_id={task_id})")
return len(batch)
async def _save_outputs(
self, task_id: str, captured: Optional[Dict[str, List[Any]]]
) -> int:
"""
保存响应捕获输出到数据库
使用 INSERT ... ON CONFLICT 语义(先查后写),
每个 task_id+key 唯一,已存在时追加值。
Args:
task_id: 任务ID
captured: 捕获数据 {key: [values]}
Returns:
int: 保存/更新的输出条目数
"""
if not captured:
return 0
saved = 0
for key, values in captured.items():
if not values:
continue
# 查重:同一 task_id+key 已存在则追加
result = await self.db.execute(
select(PerformanceTaskOutput)
.where(
PerformanceTaskOutput.task_id == task_id,
PerformanceTaskOutput.key == key,
)
)
existing = result.scalar_one_or_none()
if existing:
# 合并去重
existing_values = list(existing.value) if existing.value else []
seen = set(str(v) for v in existing_values)
for v in values:
key_str = str(v)
if key_str not in seen:
existing_values.append(v)
seen.add(key_str)
existing.value = existing_values
else:
self.db.add(PerformanceTaskOutput(
task_id=task_id,
key=key,
value=list(values),
))
saved += 1
await self.db.flush()
logger.info(f"保存响应捕获输出: {saved} 条 (task_id={task_id})")
return saved
def _build_ref_provider(self) -> Callable[[str, str], list]:
"""
构建跨任务引用取值回调
供 TemplateResolver 的 REF 占位符使用,
用 asyncio.run_coroutine_threadsafe 在事件循环中查询数据库。
Returns:
Callable: (task_id, key) -> list
"""
db = self.db
def _ref_provider(task_id: str, key: str) -> list:
"""同步回调,在 executor 工作线程中调用"""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
future = asyncio.run_coroutine_threadsafe(
self._query_output(task_id, key), loop
)
return future.result(timeout=5) or []
return []
except Exception as e:
logger.warning(f"REF 查询失败 task_id={task_id}, key={key}: {e}")
return []
return _ref_provider
async def _query_output(self, task_id: str, key: str) -> list:
"""
查询某个任务的输出值
Args:
task_id: 任务ID
key: 输出键名
Returns:
list: 输出值列表
"""
result = await self.db.execute(
select(PerformanceTaskOutput)
.where(
PerformanceTaskOutput.task_id == task_id,
PerformanceTaskOutput.key == key,
)
)
output = result.scalar_one_or_none()
return list(output.value) if output and output.value else []
\ No newline at end of file
......@@ -27,16 +27,6 @@
<span>总览</span>
</el-menu-item>
<!-- 模块管理(父菜单) -->
<el-sub-menu index="/modules">
<template #title>
<el-icon><Folder /></el-icon>
<span>模块管理</span>
</template>
<el-menu-item index="/modules/standard">标准模块</el-menu-item>
<el-menu-item index="/modules/custom">项目定制模块</el-menu-item>
</el-sub-menu>
<!-- 自动化测试(父菜单,UI 自动化) -->
<el-sub-menu index="/auto">
<template #title>
......@@ -44,6 +34,12 @@
<span>自动化测试</span>
</template>
<!-- 模块管理(并入自动化测试,第一位) -->
<el-menu-item index="/modules">
<el-icon><Folder /></el-icon>
<span>模块管理</span>
</el-menu-item>
<!-- 用例管理 -->
<el-menu-item index="/cases/ui">
<el-icon><Document /></el-icon>
......@@ -83,16 +79,7 @@
<el-menu-item index="/performance/tasks">任务管理</el-menu-item>
<el-menu-item index="/performance/monitor">执行监控</el-menu-item>
<el-menu-item index="/performance/report">报告查看</el-menu-item>
</el-sub-menu>
<!-- 功能测试报告(父菜单,含生成报告和ERP配置两个子页面) -->
<el-sub-menu index="/reports/functional">
<template #title>
<el-icon><DocumentChecked /></el-icon>
<span>功能测试报告</span>
</template>
<el-menu-item index="/reports/functional/generate">生成报告</el-menu-item>
<el-menu-item index="/reports/functional/erp-config">ERP 配置</el-menu-item>
<el-menu-item index="/performance/presets">接口预设</el-menu-item>
</el-sub-menu>
<!-- 自动化部署测试(父菜单) -->
......@@ -106,15 +93,6 @@
<el-menu-item index="/deploy/executions">部署执行</el-menu-item>
</el-sub-menu>
<!-- 辅助工具(父菜单) -->
<el-sub-menu index="/tools">
<template #title>
<el-icon><Tools /></el-icon>
<span>辅助工具</span>
</template>
<el-menu-item index="/recorder">用例录制</el-menu-item>
</el-sub-menu>
<!-- 设备模拟(独立一级菜单) -->
<el-sub-menu index="/device-sim">
<template #title>
......@@ -128,15 +106,15 @@
<el-menu-item index="/device-sim/client">集控客户端模拟</el-menu-item>
</el-sub-menu>
<!-- 系统管理(父菜单) -->
<el-sub-menu index="/system">
<!-- 功能测试报告(父菜单,含生成报告和ERP配置两个子页面) -->
<el-sub-menu index="/reports/functional">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统管理</span>
<el-icon><DocumentChecked /></el-icon>
<span>功能测试报告</span>
</template>
<el-menu-item index="/system/settings">系统配置</el-menu-item>
<el-menu-item index="/system/info">系统信息</el-menu-item>
<el-menu-item index="/system/logs">操作日志</el-menu-item>
<el-menu-item index="/reports/functional/generate">生成报告</el-menu-item>
<el-menu-item index="/reports/functional/template-config">模板配置</el-menu-item>
<el-menu-item index="/reports/functional/erp-config">ERP 配置</el-menu-item>
</el-sub-menu>
<!-- 文档管理(父菜单,含文档校正优化和文档翻译两个子页面) -->
......@@ -148,6 +126,26 @@
<el-menu-item index="/document/optimize">文档校正优化</el-menu-item>
<el-menu-item index="/document/translate">文档翻译</el-menu-item>
</el-sub-menu>
<!-- 辅助工具(父菜单) -->
<el-sub-menu index="/tools">
<template #title>
<el-icon><Tools /></el-icon>
<span>辅助工具</span>
</template>
<el-menu-item index="/recorder">用例录制</el-menu-item>
</el-sub-menu>
<!-- 系统管理(父菜单) -->
<el-sub-menu index="/system">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item index="/system/settings">系统配置</el-menu-item>
<el-menu-item index="/system/info">系统信息</el-menu-item>
<el-menu-item index="/system/logs">操作日志</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
......@@ -238,12 +236,6 @@ const REPORT_TYPE_LABELS: Record<string, string> = {
functional: '功能测试报告',
}
/** 模块类型标签映射 */
const MODULE_TYPE_LABELS: Record<string, string> = {
standard: '标准模块',
custom: '项目定制模块',
}
/** 系统管理子页面标签映射 */
const SYSTEM_LABELS: Record<string, string> = {
settings: '系统配置',
......@@ -263,11 +255,13 @@ const PERFORMANCE_LABELS: Record<string, string> = {
tasks: '任务管理',
monitor: '执行监控',
report: '报告查看',
presets: '接口预设',
}
/** 功能测试报告子页面标签映射 */
const FUNCTIONAL_LABELS: Record<string, string> = {
generate: '生成报告',
'template-config': '模板配置',
'erp-config': 'ERP 配置',
}
......@@ -282,8 +276,8 @@ const currentRoute = computed(() => {
const seg = route.path.split('/').filter(Boolean)
const root = '/' + (seg[0] || '')
// 无 type 参数时回退到默认子项,保证菜单高亮
if (seg.length === 1 && ['/modules', '/cases', '/execution', '/reports', '/system', '/deploy', '/device-sim', '/performance', '/security', '/document'].includes(root)) {
const defaultType = root === '/modules' ? 'standard' : root === '/system' ? 'settings' : root === '/deploy' ? 'servers' : root === '/device-sim' ? 'settings' : root === '/performance' ? 'tasks' : root === '/security' ? 'security' : root === '/document' ? 'optimize' : 'ui'
if (seg.length === 1 && ['/cases', '/execution', '/reports', '/system', '/deploy', '/device-sim', '/performance', '/security', '/document'].includes(root)) {
const defaultType = root === '/system' ? 'settings' : root === '/deploy' ? 'servers' : root === '/device-sim' ? 'settings' : root === '/performance' ? 'tasks' : root === '/security' ? 'security' : root === '/document' ? 'optimize' : 'ui'
return `${root}/${defaultType}`
}
return route.path
......@@ -319,7 +313,6 @@ const pageTitle = computed(() => {
}
const typeLabel = (() => {
if (root === '/modules') return MODULE_TYPE_LABELS[type]
if (root === '/reports') return type === 'ui' ? '' : REPORT_TYPE_LABELS[type]
if (root === '/cases' || root === '/execution') return type === 'ui' ? '' : CASE_TYPE_LABELS[type]
if (root === '/device-sim') return DEVICE_SIM_LABELS[type]
......
......@@ -120,3 +120,34 @@ export function createPerfMonitorStream(
return ws
}
// ==================== API 接口预设 ====================
/** 获取预设列表 */
export function listPresets(params?: {
page?: number
pageSize?: number
keyword?: string
}): Promise<any> {
return request.get(`${BASE}/presets`, { params })
}
/** 获取预设详情 */
export function getPreset(id: string): Promise<any> {
return request.get(`${BASE}/presets/${id}`)
}
/** 创建预设 */
export function createPreset(data: any): Promise<any> {
return request.post(`${BASE}/presets`, data)
}
/** 更新预设 */
export function updatePreset(id: string, data: any): Promise<any> {
return request.put(`${BASE}/presets/${id}`, data)
}
/** 删除预设 */
export function deletePreset(id: string): Promise<void> {
return request.delete(`${BASE}/presets/${id}`)
}
\ No newline at end of file
......@@ -61,6 +61,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/functional-report/FunctionalReport.vue'),
meta: { title: '生成报告' }
},
{
path: 'template-config',
name: 'TemplateConfig',
component: () => import('@/views/functional-report/TemplateConfig.vue'),
meta: { title: '模板配置' }
},
{
path: 'erp-config',
name: 'ErpConfig',
......@@ -168,6 +174,12 @@ const routes: RouteRecordRaw[] = [
name: 'PerfReport',
component: () => import('@/views/performance/ReportPanel.vue'),
meta: { title: '报告查看' }
},
{
path: 'presets',
name: 'PerfPresets',
component: () => import('@/views/performance/ApiPresetList.vue'),
meta: { title: '接口预设' }
}
]
},
......@@ -212,6 +224,28 @@ const routes: RouteRecordRaw[] = [
}
]
},
// 文档管理(父菜单,含文档校正优化和文档翻译两个子页面)
{
path: '/document',
name: 'Document',
component: () => import('@/views/document/index.vue'),
redirect: '/document/optimize',
meta: { title: '文档管理' },
children: [
{
path: 'optimize',
name: 'DocumentOptimize',
component: () => import('@/views/document/DocumentOptimize.vue'),
meta: { title: '文档校正优化' }
},
{
path: 'translate',
name: 'DocumentTranslate',
component: () => import('@/views/document/DocumentTranslate.vue'),
meta: { title: '文档翻译' }
}
]
},
// 旧 /settings 路由 → 重定向到 /system/settings
{
path: '/settings',
......
......@@ -63,6 +63,8 @@ export interface PerformanceTask {
startTime: string | null
endTime: string | null
durationActual: number | null
presetId: string | null
captureRules: any[] | null
createdAt: string
updatedAt: string
}
......@@ -243,3 +245,54 @@ export interface PerfWsComplete {
status: string
data: Record<string, any>
}
// ==================== API 接口预设 ====================
/** 接口预设 */
export interface ApiPreset {
id: string
name: string
description: string
method: string
targetUrl: string
headers: Record<string, string> | null
body: any | null
bodyTemplate: any | null
authRequired: boolean
signRequest: boolean
accountKey: string
captureRules: any[] | null
createdAt: string | null
updatedAt: string | null
}
/** 创建接口预设请求 */
export interface ApiPresetCreate {
name: string
description?: string
method?: string
targetUrl: string
headers?: Record<string, string> | null
body?: any | null
bodyTemplate?: any | null
authRequired?: boolean
signRequest?: boolean
accountKey?: string
captureRules?: any[] | null
}
/** 接口预设列表项 */
export interface ApiPresetListItem {
id: string
name: string
description: string
method: string
targetUrl: string
updatedAt: string | null
}
/** 接口预设列表响应 */
export interface ApiPresetListResponse {
total: number
items: ApiPresetListItem[]
}
\ No newline at end of file
<!--
组件名称:ApiPresetDialog.vue
组件描述:API 接口预设编辑对话框,支持创建/编辑预设
@author czj
@date 2026-08-13
-->
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑接口预设' : '新建接口预设'"
width="680px"
top="3vh"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="form" label-position="top" :rules="formRules">
<!-- 基本信息 -->
<el-row :gutter="16">
<el-col :span="16">
<el-form-item label="预设名称" prop="name">
<el-input v-model="form.name" placeholder="例如:新建会议V3" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="HTTP 方法" prop="method">
<el-select v-model="form.method" style="width: 100%">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="目标 URL" prop="targetUrl">
<el-input v-model="form.targetUrl" placeholder="https://192.168.5.44/platform/api/..." />
</el-form-item>
<!-- 请求头与请求体 -->
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="请求头(JSON)" prop="headers">
<el-input
v-model="form.headers"
type="textarea"
:rows="3"
placeholder='{"Content-Type": "application/json"}'
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="请求体类型">
<el-radio-group v-model="form.bodyType">
<el-radio value="static">静态</el-radio>
<el-radio value="template">模板</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item v-if="form.bodyType === 'static'" label="请求体(JSON)" prop="body">
<el-input
v-model="form.body"
type="textarea"
:rows="4"
placeholder='{"key": "value"}'
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item v-if="form.bodyType === 'template'" label="请求体模板" prop="bodyTemplate">
<el-input
v-model="form.bodyTemplate"
type="textarea"
:rows="4"
placeholder='{"__NOW__": "datetime", "name": "会议名称"}'
/>
</el-form-item>
</el-col>
</el-row>
<!-- 认证配置 -->
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="需要登录认证">
<el-switch v-model="form.authRequired" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="请求需要签名">
<el-switch v-model="form.signRequest" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item v-if="form.authRequired" label="账号" prop="accountKey">
<el-select v-model="form.accountKey" style="width: 100%">
<el-option label="超级管理员" value="superadmin" />
<el-option label="管理员" value="admin" />
<el-option label="普通用户" value="user" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 描述 -->
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="预设描述(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
/**
* API 接口预设编辑对话框
*
* 功能:创建/编辑接口预设,包含接口配置、认证配置等字段
*/
import { reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { createPreset, updatePreset } from '@/api/performance'
import type { ApiPreset } from '@/types/performance'
// ==================== Props & Emits ====================
const props = defineProps<{
modelValue: boolean
preset?: ApiPreset | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
// ==================== 响应式数据 ====================
const visible = ref(props.modelValue)
const isEdit = ref(false)
const saving = ref(false)
const formRef = ref()
interface PresetForm {
name: string
description: string
method: string
targetUrl: string
headers: string
bodyType: 'static' | 'template'
body: string
bodyTemplate: string
authRequired: boolean
signRequest: boolean
accountKey: string
}
const defaultForm = (): PresetForm => ({
name: '',
description: '',
method: 'GET',
targetUrl: '',
headers: '',
bodyType: 'static',
body: '',
bodyTemplate: '',
authRequired: true,
signRequest: false,
accountKey: 'superadmin',
})
const form = reactive<PresetForm>(defaultForm())
const formRules = {
name: [{ required: true, message: '请输入预设名称', trigger: 'blur' }],
targetUrl: [{ required: true, message: '请输入目标 URL', trigger: 'blur' }],
method: [{ required: true, message: '请选择 HTTP 方法', trigger: 'change' }],
}
// ==================== 监听 modelValue ====================
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
initForm()
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
// ==================== 方法 ====================
/** 初始化表单:编辑时填充预设值,新建时重置 */
function initForm() {
if (props.preset) {
isEdit.value = true
const p = props.preset
form.name = p.name
form.description = p.description || ''
form.method = p.method
form.targetUrl = p.targetUrl
form.headers = p.headers ? JSON.stringify(p.headers, null, 2) : ''
if (p.bodyTemplate) {
form.bodyType = 'template'
form.bodyTemplate = typeof p.bodyTemplate === 'string' ? p.bodyTemplate : JSON.stringify(p.bodyTemplate, null, 2)
form.body = ''
} else {
form.bodyType = 'static'
form.body = p.body ? (typeof p.body === 'string' ? p.body : JSON.stringify(p.body, null, 2)) : ''
form.bodyTemplate = ''
}
form.authRequired = p.authRequired
form.signRequest = p.signRequest
form.accountKey = p.accountKey || 'superadmin'
} else {
isEdit.value = false
Object.assign(form, defaultForm())
}
}
/** 关闭弹窗 */
function handleClose() {
visible.value = false
}
/** 保存 */
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
const data: any = {
name: form.name,
description: form.description || '',
method: form.method,
targetUrl: form.targetUrl,
headers: form.headers ? JSON.parse(form.headers) : {},
authRequired: form.authRequired,
signRequest: form.signRequest,
accountKey: form.authRequired ? form.accountKey : null,
}
if (form.bodyType === 'static' && form.body) {
data.body = JSON.parse(form.body)
data.bodyTemplate = null
} else if (form.bodyType === 'template' && form.bodyTemplate) {
data.bodyTemplate = JSON.parse(form.bodyTemplate)
data.body = null
}
if (isEdit.value && props.preset) {
await updatePreset(props.preset.id, data)
ElMessage.success('预设已更新')
} else {
await createPreset(data)
ElMessage.success('预设已创建')
}
visible.value = false
emit('saved')
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message || ''))
} finally {
saving.value = false
}
}
</script>
\ No newline at end of file
<!--
组件名称:ApiPresetList.vue
组件描述:API 接口预设列表页面,支持搜索、分页、新建、编辑、删除
@author czj
@date 2026-08-13
-->
<template>
<div class="api-preset-list">
<!-- 工具栏 -->
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>新建预设
</el-button>
<el-input
v-model="keyword"
placeholder="搜索预设名称…"
clearable
style="width: 240px"
@input="onSearch"
/>
<el-button @click="fetchData">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<!-- 提示 -->
<el-alert
title="接口预设用于快速填充任务配置。修改或删除预设不影响已创建的任务。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<!-- 表格 -->
<el-table :data="list" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="name" label="名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="method" label="方法" width="90" align="center">
<template #default="{ row }">
<el-tag :type="methodTagType(row.method)" size="small">{{ row.method }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="targetUrl" label="目标 URL" min-width="300" show-overflow-tooltip />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-muted">{{ row.description || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="170">
<template #default="{ row }">
{{ row.updatedAt ? formatTime(row.updatedAt) : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
<el-popconfirm
title="确定删除该预设?"
confirm-button-text="删除"
@confirm="handleDelete(row)"
>
<template #reference>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
<!-- 编辑弹窗 -->
<ApiPresetDialog
v-model="dialogVisible"
:preset="editingPreset"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
/**
* API 接口预设列表页
*
* 功能:展示预设列表,支持搜索、分页、新建、编辑、删除
*/
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import { listPresets, deletePreset } from '@/api/performance'
import type { ApiPreset, ApiPresetListItem } from '@/types/performance'
import ApiPresetDialog from './ApiPresetDialog.vue'
// ==================== 响应式数据 ====================
const loading = ref(false)
const list = ref<ApiPresetListItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const dialogVisible = ref(false)
const editingPreset = ref<ApiPreset | null>(null)
// ==================== 生命周期 ====================
onMounted(() => {
fetchData()
})
// ==================== 方法 ====================
/** 获取预设列表 */
async function fetchData() {
loading.value = true
try {
const res = await listPresets({
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value || undefined,
})
list.value = res.items || []
total.value = res.total || 0
} catch (e: any) {
ElMessage.error('加载预设列表失败: ' + (e.message || ''))
} finally {
loading.value = false
}
}
/** 搜索防抖 */
let searchTimer: ReturnType<typeof setTimeout> | null = null
function onSearch() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
page.value = 1
fetchData()
}, 300)
}
/** 打开新建弹窗 */
function openCreateDialog() {
editingPreset.value = null
dialogVisible.value = true
}
/** 打开编辑弹窗 */
function openEditDialog(row: ApiPresetListItem) {
// 编辑时需要完整数据,从已有列表项构造
editingPreset.value = row as unknown as ApiPreset
dialogVisible.value = true
}
/** 删除 */
async function handleDelete(row: ApiPresetListItem) {
try {
await deletePreset(row.id)
ElMessage.success('预设已删除')
fetchData()
} catch (e: any) {
ElMessage.error('删除失败: ' + (e.response?.data?.detail || e.message || ''))
}
}
/** 保存成功后刷新 */
function onSaved() {
page.value = 1
fetchData()
}
/** 方法标签颜色 */
function methodTagType(method: string) {
const map: Record<string, string> = {
GET: 'success',
POST: 'primary',
PUT: 'warning',
DELETE: 'danger',
}
return map[method] || 'info'
}
/** 格式化时间 */
function formatTime(ts: string) {
if (!ts) return '-'
try {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
} catch {
return ts
}
}
</script>
<style scoped>
.api-preset-list {
padding: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.text-muted {
color: #999;
}
</style>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论