提交 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: ...@@ -123,6 +123,8 @@ async def _ensure_columns(conn) -> None:
# 导入模型以确保表被创建(必须导入才会注册到 Base.metadata) # 导入模型以确保表被创建(必须导入才会注册到 Base.metadata)
from app.models import device_sim # noqa: F401 from app.models import device_sim # noqa: F401
from app.models import performance # 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)] # 需要补齐的字段:[(表名, 列名, 列定义SQL)]
columns_to_add = [ columns_to_add = [
...@@ -134,6 +136,10 @@ async def _ensure_columns(conn) -> None: ...@@ -134,6 +136,10 @@ async def _ensure_columns(conn) -> None:
("device_env_configs", "topics", "JSON"), ("device_env_configs", "topics", "JSON"),
# 性能测试:登录接口压测需签名(旧库升级) # 性能测试:登录接口压测需签名(旧库升级)
("performance_tasks", "sign_request", "BOOLEAN DEFAULT 0"), ("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: 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 ...@@ -27,7 +27,7 @@ from fastapi.responses import FileResponse
from app.config import settings from app.config import settings
from app.database import init_db 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( logging.basicConfig(
...@@ -218,6 +218,20 @@ app.include_router( ...@@ -218,6 +218,20 @@ app.include_router(
tags=["性能测试"] 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( app.include_router(
deploy.router, deploy.router,
prefix="/api/deploy", prefix="/api/deploy",
......
...@@ -22,6 +22,8 @@ from app.models.deploy_script import DeployScript ...@@ -22,6 +22,8 @@ from app.models.deploy_script import DeployScript
from app.models.deploy_execution import DeployExecution from app.models.deploy_execution import DeployExecution
from app.models.deploy_log import DeployLog from app.models.deploy_log import DeployLog
from app.models.performance import PerformanceTask, PerformanceSnapshot 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 from app.models.erp_config import ErpConfig
__all__ = [ __all__ = [
...@@ -42,5 +44,7 @@ __all__ = [ ...@@ -42,5 +44,7 @@ __all__ = [
"DeployLog", "DeployLog",
"PerformanceTask", "PerformanceTask",
"PerformanceSnapshot", "PerformanceSnapshot",
"PerformanceTaskOutput",
"ApiPreset",
"ErpConfig", "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): ...@@ -112,9 +112,17 @@ class PerformanceTask(Base):
# 签名配置 # 签名配置
sign_request: Mapped[bool] = mapped_column(Boolean, default=False, comment="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)") 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="断言规则列表") 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="总请求数") total_requests: Mapped[int] = mapped_column(Integer, default=0, comment="总请求数")
success_count: 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): ...@@ -178,7 +186,9 @@ class PerformanceTask(Base):
"auth_required": self.auth_required, "auth_required": self.auth_required,
"account_key": self.account_key, "account_key": self.account_key,
"sign_request": self.sign_request, "sign_request": self.sign_request,
"preset_id": self.preset_id,
"assertions": self.assertions or [], "assertions": self.assertions or [],
"capture_rules": self.capture_rules or [],
"total_requests": self.total_requests, "total_requests": self.total_requests,
"success_count": self.success_count, "success_count": self.success_count,
"fail_count": self.fail_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( ...@@ -72,7 +72,7 @@ async def create_task(
service: PerformanceService = Depends(get_perf_service), 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()) 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 ...@@ -13,6 +13,8 @@ from datetime import datetime
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from app.schemas.performance_output import CaptureRule
def to_camel(string: str) -> str: def to_camel(string: str) -> str:
"""Convert snake_case to camelCase""" """Convert snake_case to camelCase"""
...@@ -46,7 +48,7 @@ class PerformanceTaskCreate(BaseModel): ...@@ -46,7 +48,7 @@ class PerformanceTaskCreate(BaseModel):
target_url: str = Field(..., description="目标接口完整URL") target_url: str = Field(..., description="目标接口完整URL")
method: str = Field("GET", description="HTTP方法 (GET/POST/PUT/DELETE)") method: str = Field("GET", description="HTTP方法 (GET/POST/PUT/DELETE)")
headers: Dict[str, str] = Field(default_factory=dict, description="自定义请求头") 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="请求体模板") body_template: Optional[Dict[str, Any]] = Field(None, description="请求体模板")
# 压测配置 # 压测配置
...@@ -65,9 +67,15 @@ class PerformanceTaskCreate(BaseModel): ...@@ -65,9 +67,15 @@ class PerformanceTaskCreate(BaseModel):
# 签名配置 # 签名配置
sign_request: bool = Field(False, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)") 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="断言规则列表") 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) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
...@@ -78,7 +86,7 @@ class PerformanceTaskUpdate(BaseModel): ...@@ -78,7 +86,7 @@ class PerformanceTaskUpdate(BaseModel):
target_url: Optional[str] = Field(None, description="目标接口完整URL") target_url: Optional[str] = Field(None, description="目标接口完整URL")
method: Optional[str] = Field(None, description="HTTP方法") method: Optional[str] = Field(None, description="HTTP方法")
headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头") 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="请求体模板") body_template: Optional[Dict[str, Any]] = Field(None, description="请求体模板")
mode: Optional[str] = Field(None, description="压测模式") mode: Optional[str] = Field(None, description="压测模式")
concurrency: Optional[int] = Field(None, ge=1, le=500, description="并发数") concurrency: Optional[int] = Field(None, ge=1, le=500, description="并发数")
...@@ -90,7 +98,9 @@ class PerformanceTaskUpdate(BaseModel): ...@@ -90,7 +98,9 @@ class PerformanceTaskUpdate(BaseModel):
auth_required: Optional[bool] = Field(None, description="是否需要登录Token") auth_required: Optional[bool] = Field(None, description="是否需要登录Token")
account_key: Optional[str] = Field(None, description="使用的账号") account_key: Optional[str] = Field(None, description="使用的账号")
sign_request: Optional[bool] = Field(None, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)") 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="断言规则列表") 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) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
...@@ -105,7 +115,7 @@ class PerformanceTaskResponse(BaseModel): ...@@ -105,7 +115,7 @@ class PerformanceTaskResponse(BaseModel):
target_url: str = "" target_url: str = ""
method: str = "GET" method: str = "GET"
headers: Dict[str, str] = {} headers: Dict[str, str] = {}
body: Optional[Dict[str, Any]] = None body: Optional[Any] = None
body_template: Optional[Dict[str, Any]] = None body_template: Optional[Dict[str, Any]] = None
mode: str = "concurrent" mode: str = "concurrent"
concurrency: int = 10 concurrency: int = 10
...@@ -118,6 +128,8 @@ class PerformanceTaskResponse(BaseModel): ...@@ -118,6 +128,8 @@ class PerformanceTaskResponse(BaseModel):
account_key: str = "superadmin" account_key: str = "superadmin"
sign_request: bool = False sign_request: bool = False
assertions: List[Dict[str, Any]] = [] assertions: List[Dict[str, Any]] = []
capture_rules: Optional[List[Dict[str, Any]]] = None
preset_id: Optional[str] = None
# 结果统计 # 结果统计
total_requests: int = 0 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
...@@ -27,16 +27,6 @@ ...@@ -27,16 +27,6 @@
<span>总览</span> <span>总览</span>
</el-menu-item> </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 自动化) --> <!-- 自动化测试(父菜单,UI 自动化) -->
<el-sub-menu index="/auto"> <el-sub-menu index="/auto">
<template #title> <template #title>
...@@ -44,6 +34,12 @@ ...@@ -44,6 +34,12 @@
<span>自动化测试</span> <span>自动化测试</span>
</template> </template>
<!-- 模块管理(并入自动化测试,第一位) -->
<el-menu-item index="/modules">
<el-icon><Folder /></el-icon>
<span>模块管理</span>
</el-menu-item>
<!-- 用例管理 --> <!-- 用例管理 -->
<el-menu-item index="/cases/ui"> <el-menu-item index="/cases/ui">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
...@@ -83,16 +79,7 @@ ...@@ -83,16 +79,7 @@
<el-menu-item index="/performance/tasks">任务管理</el-menu-item> <el-menu-item index="/performance/tasks">任务管理</el-menu-item>
<el-menu-item index="/performance/monitor">执行监控</el-menu-item> <el-menu-item index="/performance/monitor">执行监控</el-menu-item>
<el-menu-item index="/performance/report">报告查看</el-menu-item> <el-menu-item index="/performance/report">报告查看</el-menu-item>
</el-sub-menu> <el-menu-item index="/performance/presets">接口预设</el-menu-item>
<!-- 功能测试报告(父菜单,含生成报告和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-sub-menu> </el-sub-menu>
<!-- 自动化部署测试(父菜单) --> <!-- 自动化部署测试(父菜单) -->
...@@ -106,15 +93,6 @@ ...@@ -106,15 +93,6 @@
<el-menu-item index="/deploy/executions">部署执行</el-menu-item> <el-menu-item index="/deploy/executions">部署执行</el-menu-item>
</el-sub-menu> </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"> <el-sub-menu index="/device-sim">
<template #title> <template #title>
...@@ -128,15 +106,15 @@ ...@@ -128,15 +106,15 @@
<el-menu-item index="/device-sim/client">集控客户端模拟</el-menu-item> <el-menu-item index="/device-sim/client">集控客户端模拟</el-menu-item>
</el-sub-menu> </el-sub-menu>
<!-- 系统管理(父菜单) --> <!-- 功能测试报告(父菜单,含生成报告和ERP配置两个子页面) -->
<el-sub-menu index="/system"> <el-sub-menu index="/reports/functional">
<template #title> <template #title>
<el-icon><Setting /></el-icon> <el-icon><DocumentChecked /></el-icon>
<span>系统管理</span> <span>功能测试报告</span>
</template> </template>
<el-menu-item index="/system/settings">系统配置</el-menu-item> <el-menu-item index="/reports/functional/generate">生成报告</el-menu-item>
<el-menu-item index="/system/info">系统信息</el-menu-item> <el-menu-item index="/reports/functional/template-config">模板配置</el-menu-item>
<el-menu-item index="/system/logs">操作日志</el-menu-item> <el-menu-item index="/reports/functional/erp-config">ERP 配置</el-menu-item>
</el-sub-menu> </el-sub-menu>
<!-- 文档管理(父菜单,含文档校正优化和文档翻译两个子页面) --> <!-- 文档管理(父菜单,含文档校正优化和文档翻译两个子页面) -->
...@@ -148,6 +126,26 @@ ...@@ -148,6 +126,26 @@
<el-menu-item index="/document/optimize">文档校正优化</el-menu-item> <el-menu-item index="/document/optimize">文档校正优化</el-menu-item>
<el-menu-item index="/document/translate">文档翻译</el-menu-item> <el-menu-item index="/document/translate">文档翻译</el-menu-item>
</el-sub-menu> </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-menu>
</el-aside> </el-aside>
...@@ -238,12 +236,6 @@ const REPORT_TYPE_LABELS: Record<string, string> = { ...@@ -238,12 +236,6 @@ const REPORT_TYPE_LABELS: Record<string, string> = {
functional: '功能测试报告', functional: '功能测试报告',
} }
/** 模块类型标签映射 */
const MODULE_TYPE_LABELS: Record<string, string> = {
standard: '标准模块',
custom: '项目定制模块',
}
/** 系统管理子页面标签映射 */ /** 系统管理子页面标签映射 */
const SYSTEM_LABELS: Record<string, string> = { const SYSTEM_LABELS: Record<string, string> = {
settings: '系统配置', settings: '系统配置',
...@@ -263,11 +255,13 @@ const PERFORMANCE_LABELS: Record<string, string> = { ...@@ -263,11 +255,13 @@ const PERFORMANCE_LABELS: Record<string, string> = {
tasks: '任务管理', tasks: '任务管理',
monitor: '执行监控', monitor: '执行监控',
report: '报告查看', report: '报告查看',
presets: '接口预设',
} }
/** 功能测试报告子页面标签映射 */ /** 功能测试报告子页面标签映射 */
const FUNCTIONAL_LABELS: Record<string, string> = { const FUNCTIONAL_LABELS: Record<string, string> = {
generate: '生成报告', generate: '生成报告',
'template-config': '模板配置',
'erp-config': 'ERP 配置', 'erp-config': 'ERP 配置',
} }
...@@ -282,8 +276,8 @@ const currentRoute = computed(() => { ...@@ -282,8 +276,8 @@ const currentRoute = computed(() => {
const seg = route.path.split('/').filter(Boolean) const seg = route.path.split('/').filter(Boolean)
const root = '/' + (seg[0] || '') const root = '/' + (seg[0] || '')
// 无 type 参数时回退到默认子项,保证菜单高亮 // 无 type 参数时回退到默认子项,保证菜单高亮
if (seg.length === 1 && ['/modules', '/cases', '/execution', '/reports', '/system', '/deploy', '/device-sim', '/performance', '/security', '/document'].includes(root)) { if (seg.length === 1 && ['/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' 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 `${root}/${defaultType}`
} }
return route.path return route.path
...@@ -319,7 +313,6 @@ const pageTitle = computed(() => { ...@@ -319,7 +313,6 @@ const pageTitle = computed(() => {
} }
const typeLabel = (() => { const typeLabel = (() => {
if (root === '/modules') return MODULE_TYPE_LABELS[type]
if (root === '/reports') return type === 'ui' ? '' : REPORT_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 === '/cases' || root === '/execution') return type === 'ui' ? '' : CASE_TYPE_LABELS[type]
if (root === '/device-sim') return DEVICE_SIM_LABELS[type] if (root === '/device-sim') return DEVICE_SIM_LABELS[type]
......
...@@ -119,4 +119,35 @@ export function createPerfMonitorStream( ...@@ -119,4 +119,35 @@ export function createPerfMonitorStream(
} }
return ws 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[] = [ ...@@ -61,6 +61,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/functional-report/FunctionalReport.vue'), component: () => import('@/views/functional-report/FunctionalReport.vue'),
meta: { title: '生成报告' } meta: { title: '生成报告' }
}, },
{
path: 'template-config',
name: 'TemplateConfig',
component: () => import('@/views/functional-report/TemplateConfig.vue'),
meta: { title: '模板配置' }
},
{ {
path: 'erp-config', path: 'erp-config',
name: 'ErpConfig', name: 'ErpConfig',
...@@ -168,6 +174,12 @@ const routes: RouteRecordRaw[] = [ ...@@ -168,6 +174,12 @@ const routes: RouteRecordRaw[] = [
name: 'PerfReport', name: 'PerfReport',
component: () => import('@/views/performance/ReportPanel.vue'), component: () => import('@/views/performance/ReportPanel.vue'),
meta: { title: '报告查看' } meta: { title: '报告查看' }
},
{
path: 'presets',
name: 'PerfPresets',
component: () => import('@/views/performance/ApiPresetList.vue'),
meta: { title: '接口预设' }
} }
] ]
}, },
...@@ -212,6 +224,28 @@ const routes: RouteRecordRaw[] = [ ...@@ -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 // 旧 /settings 路由 → 重定向到 /system/settings
{ {
path: '/settings', path: '/settings',
......
...@@ -63,6 +63,8 @@ export interface PerformanceTask { ...@@ -63,6 +63,8 @@ export interface PerformanceTask {
startTime: string | null startTime: string | null
endTime: string | null endTime: string | null
durationActual: number | null durationActual: number | null
presetId: string | null
captureRules: any[] | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
...@@ -242,4 +244,55 @@ export interface PerfWsComplete { ...@@ -242,4 +244,55 @@ export interface PerfWsComplete {
taskId: string taskId: string
status: string status: string
data: Record<string, any> 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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论