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

feat: 点击策略优化/性能测试A0027修复/报告模板与设备模拟增强

- 执行器点击策略重排序:表格行/复选框策略提前至force点击前,
  force点击checkbox增加前后状态校验,避免隐藏复选框假成功
- 性能测试:template_resolver时间占位符升级为秒精度(修复A0027),
  body_template放宽为Any以兼容list请求体,报告面板图表加载顺序修复
- 功能测试报告模板:模型/路由/schema新增描述字段,Word/Markdown/Excel生成器适配
- 系统设置:新增钉钉对接与文档管理配置项
- 设备模拟:路由/schema/service及前端五个模拟页面增强,元素映射更新
- 杂项:安全用例脚本支持MySQL DATABASE_URL,deploy_execution.script_id改为nullable
- 同步各模块HANDOFF交接文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 5b5b35de
@echo off
cd /d E:\github\ubains-module-test\platform-auto-test
cd /d E:\GithubData\ubains-module-test\platform-auto-test
E:\nodejs\claude.cmd --permission-mode bypassPermissions
\ No newline at end of file
......@@ -17,7 +17,7 @@
**当前多窗口状态**:本项目有多个窗口分工开发,交接文档分散在:
- `HANDOFF_自动化部署测试.md` — 自动化部署测试模块(最新,本次会话)
- `HANDOFF_功能测试报告.md` — 功能测试报告模块
- `Docs/PRD/功能测试/交接文档/HANDOFF_功能测试报告.md` — 功能测试报告模块
- `HANDOFF.md`(本文档)— **UI自动化用例录入优化 + 执行引擎死锁修复 + P1端到端验证通过**
- `HANDOFF_V2部署升级.md` — V2 部署详细交接
- `HANDOFF_UI自动化.md` — UI 自动化测试模块
......
此差异已折叠。
此差异已折叠。
此差异已折叠。
......@@ -74,6 +74,16 @@ class Settings:
ERP_TYPE_ID: int = int(os.getenv("ERP_TYPE_ID", "2"))
ERP_IMAGE_URL_PREFIX: str = os.getenv("ERP_IMAGE_URL_PREFIX", "https://office.ubainsyun.com:5015/")
# 钉钉对接配置
DINGTALK_APP_KEY: str = os.getenv("DINGTALK_APP_KEY", "")
DINGTALK_APP_SECRET: str = os.getenv("DINGTALK_APP_SECRET", "")
DINGTALK_AGENT_ID: int = int(os.getenv("DINGTALK_AGENT_ID", "0"))
# 文档管理配置
DOCUMENT_UPLOAD_DIR: str = os.getenv("DOCUMENT_UPLOAD_DIR", "./data/documents/upload")
DOCUMENT_OUTPUT_DIR: str = os.getenv("DOCUMENT_OUTPUT_DIR", "./data/documents/output")
DOCUMENT_MAX_SIZE_MB: int = int(os.getenv("DOCUMENT_MAX_SIZE_MB", "50"))
class Config:
"""Pydantic 配置"""
env_file = ".env"
......
{
"version": "1.2",
"updated_at": "2026-08-11",
"updated_at": "2026-08-17",
"source": "前端开发人员提供 - 门户首页+功能中心元素定位键值表 + 会议预约子页面元素定位键值表 + 登录页DevTools捕获",
"source_files": [
"web/pc-vue2-platform/src/views/Home/index.vue",
......@@ -835,6 +835,18 @@
"tier": "会议预约-创建会议",
"description": "创建会议 - 时间选择按钮组"
},
"创建会议-预定会议": {
"selector": ".time_btn > div:last-child .btns",
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议 - 时间选择按钮组中的\"预定会议\"按钮"
},
"创建会议-立即开始": {
"selector": ".time_btn .btns.button_active",
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议 - 时间选择按钮组中的\"立即开始\"按钮"
},
"创建会议-主题名称": {
"selector": ".topic_item",
"type": "css",
......@@ -901,6 +913,12 @@
"tier": "会议预约-创建会议",
"description": "创建会议 - 右侧参会人表格"
},
"创建会议-会议室选择": {
"selector": ".el-checkbox",
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议 - 右侧会议室选择复选框"
},
"会议模板-搜索": {
"selector": ".search .el-input",
"type": "css",
......@@ -1380,6 +1398,18 @@
"type": "css",
"tier": "会议预约-会议列表",
"description": "会议列表[历史] - 评价弹窗星级评分"
},
"创建会议-编辑会议室": {
"selector": ".room .delimg",
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议 - 左侧会议室框中的编辑按钮(点击切换右侧到参会人配置模式)"
},
"创建会议-确定创建": {
"selector": ".btn button.el-button",
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议 - 底部\"确定创建\"按钮"
}
}
}
\ No newline at end of file
......@@ -140,6 +140,8 @@ async def _ensure_columns(conn) -> None:
("performance_tasks", "capture_rules", "JSON"),
# 性能测试:任务关联的接口预设(旧库升级)
("performance_tasks", "preset_id", "VARCHAR(64) DEFAULT NULL"),
# 功能测试报告模板:描述字段(旧库升级)
("report_templates", "description", "VARCHAR(200) DEFAULT ''"),
]
def _do_ensure(sync_conn) -> None:
......
......@@ -1412,59 +1412,26 @@ class PlaywrightExecutor:
last_error = e
logger.debug(f"⚠ 主页面点击失败 {selector}: {e}")
# 策略2: 主页面force点击(忽略遮挡)
if not force:
try:
self._page.click(selector, timeout=5000, force=True)
logger.debug(f"✓ 主页面force点击成功: {selector}")
self._wait_for_loading_overlay()
return
except Exception as e2:
last_error = e2
logger.debug(f"⚠ 主页面force点击失败 {selector}: {e2}")
# 策略3: iframe 中点击
for frame in self._page.frames:
if frame == self._page.main_frame:
continue
try:
frame.wait_for_selector(selector, state="visible", timeout=5000)
frame.click(selector, timeout=5000, force=True)
logger.debug(f"✓ iframe 中点击成功: {selector}")
self._wait_for_loading_overlay()
return
except Exception as e3:
last_error = e3
continue
# 策略4: JS 点击回退(处理元素被遮挡或不可操作的情况)
try:
el = self._page.query_selector(selector)
if el:
self._page.evaluate('(el) => el.click()', el)
logger.debug(f"✓ JS点击成功: {selector}")
self._wait_for_loading_overlay()
return
except Exception as e4:
last_error = e4
logger.debug(f"⚠ JS点击失败 {selector}: {e4}")
# ==================== 策略5: Element UI 表格行选择回退 ====================
# ==================== 策略5(提前): Element UI 表格行选择回退 ====================
# Element UI 表格的选择列复选框带 is-hidden class(隐藏列),直接点击
# .el-checkbox:has-text("X") 会失败。从选择器中提取目标文本,
# 点击包含该文本的表格行(行点击会触发选中)。
# 点击包含该文本的表格行或行内复选框(行点击会触发选中)。
# 放在策略2(force点击)之前执行,避免 force 点击隐藏 checkbox 假成功
m = re.search(r':has-text\("([^"]+)"\)', selector)
if m:
target_text = m.group(1)
row_selectors = [
# 优先:点击行内复选框(含文本的行内的复选框)
f'.el-table__row:has-text("{target_text}") .el-checkbox',
# 次选:点击整行
f'.el-table__row:has-text("{target_text}")',
# 最后:点击 tr
f'tr:has-text("{target_text}")',
f'.el-table__row:has-text("{target_text}") .el-checkbox',
]
for row_sel in row_selectors:
try:
row_el = self._page.query_selector(row_sel)
if row_el and row_el.is_visible():
if row_el:
row_el.scroll_into_view_if_needed()
self._page.wait_for_timeout(200)
row_el.click(force=True)
......@@ -1475,8 +1442,9 @@ class PlaywrightExecutor:
last_error = e5
logger.debug(f"⚠ 表格行点击失败 {row_sel}: {e5}")
# 策略6: 若选择器是隐藏复选框,尝试点击其所在表格行
# 策略6(提前): 若选择器是隐藏复选框,尝试点击其所在表格行
# 覆盖 .el-checkbox 无 :has-text 的情况(如勾选用户 admin@xty12)
# 也放在策略2之前,避免 force 点击假成功
if selector in (".el-checkbox", ".el-checkbox__inner", "input[type=checkbox]"):
try:
row_el = self._page.evaluate(
......@@ -1498,6 +1466,97 @@ class PlaywrightExecutor:
last_error = e6
logger.debug(f"⚠ 点击复选框所在行失败 {selector}: {e6}")
# 策略2: 主页面force点击(忽略遮挡)
# 注意:对 checkbox 类元素,force 点击隐藏 input 可能不触发框架事件,
# 因此需要加 before/after 状态校验
if not force:
try:
# force 点击前:记录 checkbox 选中状态(仅限 checkbox 类元素 + 能爬到 tr)
checkbox_checked_before = None
if selector in (".el-checkbox", ".el-checkbox__inner", "input[type=checkbox]"):
try:
el = self._page.query_selector(selector)
if el:
checkbox_checked_before = self._page.evaluate(
"""(sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const row = el.closest('tr, .el-table__row');
if (!row) return null;
const cb = row.querySelector('.el-checkbox');
return cb ? cb.classList.contains('is-checked') : null;
}""",
selector,
)
except Exception:
pass
self._page.click(selector, timeout=5000, force=True)
self._page.wait_for_timeout(300)
# force 点击后:校验 checkbox 状态是否变化
if checkbox_checked_before is not None:
try:
checked_after = self._page.evaluate(
"""(sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const row = el.closest('tr, .el-table__row');
if (!row) return null;
const cb = row.querySelector('.el-checkbox');
return cb ? cb.classList.contains('is-checked') : null;
}""",
selector,
)
if checked_after is not None and checked_after == checkbox_checked_before:
logger.debug(f"⚠ force 点击后 checkbox 状态未变化(假通过),继续尝试后续策略: {selector}")
# 不 return,继续走后续策略
else:
logger.debug(f"✓ 主页面force点击成功,checkbox 状态已变化: {selector}")
self._wait_for_loading_overlay()
return
except Exception:
# 校验失败不阻塞,视为 force 点击成功
logger.debug(f"✓ 主页面force点击成功(校验跳过): {selector}")
self._wait_for_loading_overlay()
return
else:
logger.debug(f"✓ 主页面force点击成功: {selector}")
self._wait_for_loading_overlay()
return
except Exception as e2:
last_error = e2
logger.debug(f"⚠ 主页面force点击失败 {selector}: {e2}")
# 策略3: iframe 中点击
for frame in self._page.frames:
if frame == self._page.main_frame:
continue
try:
frame.wait_for_selector(selector, state="visible", timeout=5000)
frame.click(selector, timeout=5000, force=True)
logger.debug(f"✓ iframe 中点击成功: {selector}")
self._wait_for_loading_overlay()
return
except Exception as e3:
last_error = e3
continue
# 策略4: JS 点击回退(处理元素被遮挡或不可操作的情况)
try:
el = self._page.query_selector(selector)
if el:
self._page.evaluate('(el) => el.click()', el)
logger.debug(f"✓ JS点击成功: {selector}")
self._wait_for_loading_overlay()
return
except Exception as e4:
last_error = e4
logger.debug(f"⚠ JS点击失败 {selector}: {e4}")
# 注:策略5(表格行点击)和策略6(复选框所在行点击)已前移到策略2(force点击)之前,
# 以避免 force 点击隐藏 checkbox 假成功
raise Exception(f"✗ 无法点击元素(已尝试 {len(selectors)} 个选择器): {selectors} 最后错误: {last_error}")
def _do_fill(self, params: dict, force: bool = False) -> None:
......
......@@ -5,7 +5,7 @@
模块描述:性能测试请求体模板变量替换器
支持占位符:
- {__NOW__} → 当前时间 "YYYY-MM-DD HH:mm"
- {__NOW__} → 当前时间 "YYYY-MM-DD HH:mm:ss"
- {__NOW_+N_H__} → 当前时间 + N 小时
- {__NOW_+N_M__} → 当前时间 + N 分钟
- {__NOW_+N_D__} → 当前时间 + N 天
......@@ -153,8 +153,8 @@ class TemplateResolver:
# ---------- 时间相关 ----------
def _now_str(self) -> str:
"""当前时间字符串 YYYY-MM-DD HH:mm"""
return datetime.now().strftime("%Y-%m-%d %H:%M")
"""当前时间字符串 YYYY-MM-DD HH:mm:ss"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _now_with_offset(self, delta: str, unit: str) -> str:
"""
......@@ -179,7 +179,7 @@ class TemplateResolver:
base += timedelta(days=n)
else: # 默认分钟
base += timedelta(minutes=n)
return base.strftime("%Y-%m-%d %H:%M")
return base.strftime("%Y-%m-%d %H:%M:%S")
# ---------- 随机值相关 ----------
......
......@@ -50,7 +50,7 @@ class DeployExecution(Base):
)
script_id: Mapped[str] = mapped_column(
String(64), ForeignKey("deploy_scripts.id", ondelete="SET NULL"),
nullable=False, comment="关联的脚本配置 ID"
nullable=True, comment="关联的脚本配置 ID"
)
server_id: Mapped[str] = mapped_column(
String(64), nullable=False, comment="关联的目标服务器 ID"
......
......@@ -38,6 +38,7 @@ class ReportTemplate(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键")
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="模板名称")
description: Mapped[str] = mapped_column(String(200), nullable=True, default="", comment="模板描述")
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否默认模板")
config: Mapped[str] = mapped_column(Text, nullable=False, default="{}", comment="完整配置JSON字符串")
created_at: Mapped[Optional[datetime]] = mapped_column(
......@@ -56,6 +57,7 @@ class ReportTemplate(Base):
return {
"id": self.id,
"name": self.name,
"description": self.description or "",
"is_default": self.is_default,
"config": json.loads(self.config) if self.config else {},
"created_at": self.created_at.strftime(DATE_FORMAT) if self.created_at else None,
......@@ -68,6 +70,7 @@ class ReportTemplate(Base):
return {
"id": self.id,
"name": self.name,
"description": self.description or "",
"is_default": self.is_default,
"updated_at": self.updated_at.strftime(DATE_FORMAT) if self.updated_at else None,
}
\ No newline at end of file
......@@ -25,7 +25,7 @@ from app.schemas.device_sim import (
ManualReportRequest, TestConnectionResponse,
TopicTemplateResponse, TopicTemplateListResponse,
BatchOperationRequest, BatchAllOperationRequest, BatchOperationResponse,
DeviceImportResponse,
DeviceImportResponse, DeviceStatsResponse,
)
from app.services.device_sim_service import DeviceSimService
......@@ -230,6 +230,28 @@ async def list_simulators(
raise HTTPException(status_code=500, detail=f"获取模拟设备列表失败: {str(e)}")
@router.get("/devices/stats", response_model=DeviceStatsResponse)
async def get_device_stats(
device_type: Optional[str] = Query(None, pattern="^(door|paperless|central|client)$", description="设备类型筛选"),
env_config_id: Optional[str] = Query(None, description="环境配置筛选"),
service: DeviceSimService = Depends(get_device_sim_service)
):
"""
获取设备统计信息
返回总设备数、运行中、已停止数量,以及按设备类型的分组统计。
注意:必须注册在 /devices/{device_id} 之前,避免 stats 被识别为 device_id。
"""
try:
return await service.get_device_stats(
device_type=device_type,
env_config_id=env_config_id,
)
except Exception as e:
logger.error(f"获取设备统计失败: {e}")
raise HTTPException(status_code=500, detail=f"获取设备统计失败: {str(e)}")
@router.post("/devices", response_model=SimulatorResponse, status_code=201)
async def create_simulator(
data: SimulatorCreate,
......
......@@ -136,6 +136,7 @@ async def create_template(
name=request.name,
is_default=is_default,
config=json.dumps(config, ensure_ascii=False),
description=request.description or "",
)
db.add(template)
await db.commit()
......@@ -177,6 +178,9 @@ async def update_template(
if request.name is not None:
template.name = request.name
if request.description is not None:
template.description = request.description
if request.config is not None:
template.config = json.dumps(_validate_config(request.config), ensure_ascii=False)
......@@ -251,4 +255,45 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
await db.commit()
logger.info(f"删除默认模板后,自动将 {first.name} (id={first.id}) 设为默认")
return {"success": True, "message": "模板已删除"}
\ No newline at end of file
return {"success": True, "message": "模板已删除"}
# ==================== 复制模板 ====================
@router.post("/{template_id}/duplicate", response_model=ReportTemplateResponse, summary="复制模板")
async def duplicate_template(template_id: int, db: AsyncSession = Depends(get_db)):
"""
复制报告模板
复制后的模板:
- 名称自动加"副本"后缀(已存在同名则追加序号)
- config 与 description 完整继承
- is_default 强制设为 False
"""
template = await db.get(ReportTemplate, template_id)
if not template:
raise HTTPException(status_code=404, detail=f"模板不存在: {template_id}")
# 生成不重复的名称
base_name = template.name
new_name = f"{base_name}_副本"
num = 2
while True:
result = await db.execute(select(ReportTemplate).where(ReportTemplate.name == new_name))
if not result.scalar_one_or_none():
break
new_name = f"{base_name}_副本{num}"
num += 1
new_template = ReportTemplate(
name=new_name,
is_default=False,
config=template.config,
description=template.description or "",
)
db.add(new_template)
await db.commit()
await db.refresh(new_template)
logger.info(f"复制报告模板: {template.name} → {new_template.name} (id={new_template.id})")
return new_template.to_dict()
\ No newline at end of file
......@@ -14,6 +14,7 @@ import platform
import sys
import time
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import func, select, text
......@@ -23,6 +24,8 @@ from app.config import settings
from app.database import get_db
from app.models.module import Module
from app.models.test_case import TestCase
from app.models.dingtalk_config import DingTalkConfig
from app.schemas.dingtalk_config import DingTalkConfigResponse, DingTalkConfigUpdateRequest
logger = logging.getLogger(__name__)
......@@ -107,3 +110,76 @@ async def get_system_info(db: AsyncSession = Depends(get_db)):
"playwright_headless": settings.PLAYWRIGHT_HEADLESS,
"frontend_version": "1.0.0",
}
# ==================== 钉钉对接配置 ====================
@router.get("/dingtalk-config", response_model=DingTalkConfigResponse, summary="获取钉钉对接配置")
async def get_dingtalk_config(db: AsyncSession = Depends(get_db)):
"""
获取当前钉钉对接配置
优先从数据库读取,若数据库无记录则回退到环境变量默认值。
"""
result = await db.execute(select(DingTalkConfig).where(DingTalkConfig.id == 1))
config = result.scalar_one_or_none()
if config:
# 数据库有记录,但空字段回退到环境变量默认值(保证配置自动填充)
return DingTalkConfigResponse(
app_key=config.app_key or settings.DINGTALK_APP_KEY or "",
app_secret=config.app_secret or settings.DINGTALK_APP_SECRET or "",
agent_id=config.agent_id or settings.DINGTALK_AGENT_ID or 0,
)
# 数据库无记录,回退到环境变量
return DingTalkConfigResponse(
app_key=settings.DINGTALK_APP_KEY or "",
app_secret=settings.DINGTALK_APP_SECRET or "",
agent_id=settings.DINGTALK_AGENT_ID or 0,
)
@router.put("/dingtalk-config", response_model=DingTalkConfigResponse, summary="更新钉钉对接配置")
async def update_dingtalk_config(request: DingTalkConfigUpdateRequest, db: AsyncSession = Depends(get_db)):
"""
更新钉钉对接配置(持久化到数据库)
更新后的配置会持久化到数据库,服务重启后依然有效。
"""
# 从数据库读取或创建新记录
result = await db.execute(select(DingTalkConfig).where(DingTalkConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
# 新记录:先用环境变量默认值填充,再用请求值覆盖
config = DingTalkConfig(
id=1,
app_key=settings.DINGTALK_APP_KEY or "",
app_secret=settings.DINGTALK_APP_SECRET or "",
agent_id=settings.DINGTALK_AGENT_ID or 0,
)
db.add(config)
# 更新字段
if request.app_key is not None:
config.app_key = request.app_key
settings.DINGTALK_APP_KEY = request.app_key
if request.app_secret is not None:
config.app_secret = request.app_secret
settings.DINGTALK_APP_SECRET = request.app_secret
if request.agent_id is not None:
config.agent_id = request.agent_id
settings.DINGTALK_AGENT_ID = request.agent_id
await db.commit()
await db.refresh(config)
logger.info("钉钉对接配置已更新(持久化到数据库)")
return DingTalkConfigResponse(
app_key=config.app_key or settings.DINGTALK_APP_KEY or "",
app_secret=config.app_secret or settings.DINGTALK_APP_SECRET or "",
agent_id=config.agent_id or settings.DINGTALK_AGENT_ID or 0,
)
......@@ -193,6 +193,20 @@ class SimulatorListResponse(BaseModel):
total: int = Field(default=0, description="总数")
class DeviceStatsResponse(BaseModel):
"""设备统计响应"""
total: int = Field(default=0, description="总设备数")
running: int = Field(default=0, description="运行中设备数")
stopped: int = Field(default=0, description="已停止设备数")
by_type: Dict[str, dict] = Field(default={}, description="按设备类型分组统计: {type: {total, running, stopped}}")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
# ==================== 上报记录 Schema ====================
class ReportLogResponse(BaseModel):
......
......@@ -49,7 +49,7 @@ class PerformanceTaskCreate(BaseModel):
method: str = Field("GET", description="HTTP方法 (GET/POST/PUT/DELETE)")
headers: Dict[str, str] = Field(default_factory=dict, description="自定义请求头")
body: Optional[Any] = Field(None, description="请求体(支持 dict 或 list)")
body_template: Optional[Dict[str, Any]] = Field(None, description="请求体模板")
body_template: Optional[Any] = Field(None, description="请求体模板")
# 压测配置
mode: str = Field("concurrent", description="压测模式: concurrent/qps/step")
......@@ -87,7 +87,7 @@ class PerformanceTaskUpdate(BaseModel):
method: Optional[str] = Field(None, description="HTTP方法")
headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头")
body: Optional[Any] = Field(None, description="请求体(支持 dict 或 list)")
body_template: Optional[Dict[str, Any]] = Field(None, description="请求体模板")
body_template: Optional[Any] = Field(None, description="请求体模板")
mode: Optional[str] = Field(None, description="压测模式")
concurrency: Optional[int] = Field(None, ge=1, le=500, description="并发数")
qps_target: Optional[int] = Field(None, ge=1, description="目标QPS")
......@@ -116,7 +116,7 @@ class PerformanceTaskResponse(BaseModel):
method: str = "GET"
headers: Dict[str, str] = {}
body: Optional[Any] = None
body_template: Optional[Dict[str, Any]] = None
body_template: Optional[Any] = None
mode: str = "concurrent"
concurrency: int = 10
qps_target: int = 100
......
......@@ -16,6 +16,7 @@ from pydantic import BaseModel, Field
class ReportTemplateCreateRequest(BaseModel):
"""创建报告模板请求"""
name: str = Field(..., min_length=1, max_length=100, description="模板名称")
description: Optional[str] = Field(None, max_length=200, description="模板描述")
is_default: bool = Field(False, description="是否设为默认模板")
config: Dict = Field(default_factory=dict, description="模板完整配置(章节开关/样式/默认值)")
......@@ -23,6 +24,7 @@ class ReportTemplateCreateRequest(BaseModel):
class ReportTemplateUpdateRequest(BaseModel):
"""更新报告模板请求"""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="模板名称")
description: Optional[str] = Field(None, max_length=200, description="模板描述")
is_default: Optional[bool] = Field(None, description="是否设为默认模板")
config: Optional[Dict] = Field(None, description="模板完整配置")
......@@ -31,6 +33,7 @@ class ReportTemplateListItem(BaseModel):
"""报告模板列表项(不含 config 详情)"""
id: int = Field(..., description="模板ID")
name: str = Field(..., description="模板名称")
description: Optional[str] = Field(None, description="模板描述")
is_default: bool = Field(False, description="是否默认模板")
updated_at: Optional[str] = Field(None, description="更新时间")
......@@ -39,6 +42,7 @@ class ReportTemplateResponse(BaseModel):
"""报告模板完整响应"""
id: int = Field(..., description="模板ID")
name: str = Field(..., description="模板名称")
description: Optional[str] = Field(None, description="模板描述")
is_default: bool = Field(False, description="是否默认模板")
config: Dict = Field(default_factory=dict, description="模板完整配置")
created_at: Optional[str] = Field(None, description="创建时间")
......
......@@ -393,6 +393,71 @@ class DeviceSimService:
return items, total
async def get_device_stats(
self,
device_type: Optional[str] = None,
env_config_id: Optional[str] = None,
) -> dict:
"""
获取设备统计信息(总设备/运行中/已停止,支持按类型分组)
Args:
device_type: 设备类型筛选(不传则统计全部类型)
env_config_id: 环境配置筛选
Returns:
dict: {
"total": int, "running": int, "stopped": int,
"by_type": {door: {total, running, stopped}, ...}
}
"""
conditions = []
if device_type:
conditions.append(DeviceSimulator.device_type == device_type)
if env_config_id:
conditions.append(DeviceSimulator.env_config_id == env_config_id)
# 查询全部设备(不分页,仅取统计所需列)
query = select(DeviceSimulator.id, DeviceSimulator.device_type, DeviceSimulator.status)
if conditions:
query = query.where(and_(*conditions))
result = await self.db.execute(query)
rows = result.all()
# 以内存 _running_simulators 为准修正实时运行状态
running_ids = set()
with _running_simulators_lock:
for rid, sim in _running_simulators.items():
if sim.is_running():
running_ids.add(rid)
# 聚合统计
total = len(rows)
running = 0
stopped = 0
by_type: dict = {}
type_order = ["door", "paperless", "central", "client"]
for rid, rtype, rstatus in rows:
is_running = rid in running_ids
if is_running:
running += 1
else:
stopped += 1
t = rtype or "unknown"
group = by_type.setdefault(t, {"total": 0, "running": 0, "stopped": 0})
group["total"] += 1
if is_running:
group["running"] += 1
else:
group["stopped"] += 1
return {
"total": total,
"running": running,
"stopped": stopped,
"by_type": by_type,
}
async def get_simulator(self, device_id: str) -> Optional[DeviceSimulator]:
"""
获取模拟设备详情
......@@ -1232,7 +1297,7 @@ async def message_consumer_loop():
db.add(log)
# 更新设备统计
query = select(DeviceSimulator).where(DeviceSimulator.id == msg.device_id)
query = select(DeviceSimulator).where(DeviceSimulator.device_id == msg.device_id)
result = await db.execute(query)
sim = result.scalar_one_or_none()
if sim:
......
......@@ -43,6 +43,12 @@ SYNONYM_MAP = {
"新建": "创建",
"创建": "新建",
"新增": "创建",
# P0 修复:创建会议页的"会议室选择:北京展厅会议室"步骤。
# 在创建会议页,房间选择是通过右侧会议室表格行的勾选实现的,
# 语义上等价于"勾选会议室",必须命中"创建会议-会议室选择"条目。
# 同时避免误配同义词"会议预约-会议室"层级的"会议室-区域树"。
"会议室选择": "勾选会议室",
"勾选会议室": "会议室选择",
}
# 映射表优先级(最高)
......@@ -232,6 +238,9 @@ class ElementMappingService:
strong_count = 0
weak_count = 0
# 已匹配过的映射键关键词(避免同一 kk 被多个 token 重复计分)
matched_key_kws = set()
for sk in step_keywords:
if len(sk) < 2:
continue
......@@ -243,9 +252,12 @@ class ElementMappingService:
strong_hit = False
for var in variants:
for kk in key_kws:
if kk in matched_key_kws:
continue # 同一映射键关键词只计分一次,避免"会议室"被多 token 重复命中
if var == kk or kk in var:
score += len(kk) * 2.0
strong_count += 1
matched_key_kws.add(kk)
strong_hit = True
break
if strong_hit:
......@@ -255,8 +267,11 @@ class ElementMappingService:
continue
# 弱匹配:步骤关键词是映射键关键词的子串
# 已匹配过强匹配的 kk 不再计入弱匹配(same-key 防重复)
for kk in key_kws:
if sk in kk:
if kk in matched_key_kws or sk in kk:
if kk in matched_key_kws:
continue
score += 1.0
weak_count += 1
break
......@@ -310,11 +325,32 @@ class ElementMappingService:
score += 1.0
# 完整映射键段出现在步骤中,额外加分(更精确的命中)
# 注意:'选择' 是动作词,不单独加分
for kk in key_kws:
if len(kk) >= 3 and kk in step_clean:
if len(kk) >= 3 and kk != "选择" and kk in step_clean:
score += 2.0
break
# 选择时间特殊处理:步骤名含"选择时间"时,优先含"选择时间"关键词的条目
# 解决"点击选择时间"被动作词剥离后只剩"时间",误配"会议列表-全部-时间"的问题
# 期望命中"创建会议-选择时间"(选择器 .time_btn .btns)
if "选择时间" in step_name and "选择时间" in key:
score += 8.0
# 时间切换特殊处理:步骤名含"预定会议"/"立即开始"时,优先同名词精确条目
# 解决"时间切换为:预定会议"误配"会议列表-全部-时间"(仅"时间"+"会议"两个弱匹配)的问题
# 期望命中"创建会议-预定会议"/"创建会议-立即开始"(.time_btn 内的两个具体按钮)
# 注意:不能加给"创建会议-选择时间"(.time_btn .btns 会点到第1个"立即开始")
if "预定会议" in step_name and "预定会议" in key:
score += 20.0
if "立即开始" in step_name and "立即开始" in key:
score += 20.0
# "时间切换为/时间选择为"步骤且条目在创建会议tier的"选择时间"族(预定会议/立即开始/选择时间)
# 给兜底加成,确保压过"会议列表-全部-时间"等跨页弱匹配
if ("切换为" in step_name or "选择为" in step_name) and (
"预定会议" in key or "立即开始" in key or "选择时间" in key):
score += 10.0
if score > best_score:
best_score = score
best_key = key
......@@ -411,6 +447,19 @@ class ElementMappingService:
"""清理步骤描述:去除动作词、标点、元素类型词"""
# 去除【】内容保留(如"点击【新建会议】" -> "新建会议")
cleaned = step_name
# "时间切换为:预定会议" / "时间选择为:立即开始" 规范化为"选择时间-预定会议"等
# 否则"时间切换为"被 jieba 分词为"时间"+"切换为","切换为"无意义,只剩"时间"弱匹配
# 导致"时间切换为:预定会议"误配"会议列表-全部-时间"(仅"时间"+"会议"两个弱匹配)
# 使用 __SELECT_TIME__ 占位符保护,避免后续"选择"被动作词剥离
cleaned = re.sub(r"时间(切换为|选择为)[::]\s*", "__SELECT_TIME__-", cleaned)
# "选择会议室"/"勾选会议室" 规范化为 "会议室选择"
# 否则动作词"选择/勾选"被下方 ^ 前缀正则剥离后只剩"会议室",丢失语义
# ("会议室选择"是创建会议页选房间的动作,不能坍缩成"会议室"去匹配区域树)
cleaned = re.sub(r"(选择|勾选)会议室", "会议室选择", cleaned)
# "选择开始时间"/"选择结束时间" 规范化为"选择时间"并按占位符保护
# 否则"选择"被剥离后只剩"开始时间"/"时间",token"时间"弱匹配"选择时间"仅1次
# 达不到 2 弱匹配阈值,或强匹配"开始时间"误配"会议列表-全部-时间"
cleaned = re.sub(r"选择(开始|结束)?时间", "__SELECT_TIME__", cleaned)
# 去除常见动作前缀
# 注意:"验证"不能作为动作词去除,否则会破坏"验证码"(如"输入验证码:csba")
for action_word in ["点击", "单击", "双击", "输入", "填写", "填入", "选择",
......@@ -423,17 +472,30 @@ class ElementMappingService:
cleaned = cleaned.replace(elem_word, "")
# 去除括号和引号
cleaned = re.sub(r"[【】\[\]()()\"']", "", cleaned)
# 恢复"选择时间"占位符(__SELECT_TIME__-预定会议 → 选择时间-预定会议)
cleaned = cleaned.replace("__SELECT_TIME__", "选择时间")
return cleaned.strip()
@staticmethod
def _clean_step_name_without_elem(step_name: str) -> str:
"""清理步骤描述:仅去除动作词和标点,保留元素类型词用于匹配"""
cleaned = step_name
# "时间切换为:预定会议" / "时间选择为:立即开始" 规范化为"选择时间-预定会议"等
# 与 _clean_step_name 保持同步(两个清理函数必须同规则,否则 token 不一致导致匹配漂移)
# 使用 __SELECT_TIME__ 占位符保护(含义:选择时间)
cleaned = re.sub(r"时间(切换为|选择为)[::]\s*", "__SELECT_TIME__-", cleaned)
# "选择会议室"/"勾选会议室" 规范化为 "会议室选择"
# 与 _clean_step_name 保持同步,避免动作词剥离后"会议室"变成孤立 token
cleaned = re.sub(r"(选择|勾选)会议室", "会议室选择", cleaned)
# "选择开始时间"/"选择结束时间" 规范化为"选择时间"并按占位符保护(与 _clean_step_name 同步)
cleaned = re.sub(r"选择(开始|结束)?时间", "__SELECT_TIME__", cleaned)
for action_word in ["点击", "单击", "双击", "输入", "填写", "填入", "选择",
"打开", "展开", "收起", "导航到", "进入", "按下", "勾选",
"取消", "悬停", "滚动", "等待", "检查", "查看"]:
cleaned = re.sub(rf"^{action_word}", "", cleaned)
cleaned = re.sub(r"[【】\[\]()()\"']", "", cleaned)
# 恢复"选择时间"占位符
cleaned = cleaned.replace("__SELECT_TIME__", "选择时间")
return cleaned.strip()
@staticmethod
......
......@@ -13,8 +13,13 @@
"""
import re
import io
import os
import zipfile
import tempfile
from dataclasses import dataclass, field
from typing import List, Optional
from lxml import etree
import openpyxl
......@@ -114,6 +119,59 @@ def _extract_case_number_from_steps(steps: str) -> List[str]:
return re.findall(pattern, steps)
def _fix_excel_auto_filter(file_path: str) -> str:
"""
修复 Excel 文件中损坏的 autoFilter XML
某些 Excel 文件的 autoFilter 中包含非数字/非通配符的 filter val,
导致 openpyxl 的 non-read_only 模式报错。本函数通过移除损坏的
autoFilter 元素来修复,不影响其他数据。
Args:
file_path: 原始 Excel 文件路径
Returns:
修复后的临时文件路径(如果无需修复则返回原路径)
"""
# 先尝试正常打开,成功则跳过修复
try:
wb = openpyxl.load_workbook(file_path, data_only=True)
wb.close()
return file_path
except Exception:
pass
ns = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
fixed = False
with zipfile.ZipFile(file_path, 'r') as zin:
data = {}
for name in zin.namelist():
data[name] = zin.read(name)
for name in list(data.keys()):
if name.startswith('xl/worksheets/') and name.endswith('.xml'):
root = etree.fromstring(data[name])
# 查找并移除 autoFilter 元素
for af in root.findall(f'{{{ns}}}autoFilter'):
root.remove(af)
fixed = True
if fixed:
data[name] = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
if not fixed:
return file_path
# 写入临时文件
with tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx', mode='wb') as tmp:
tmp_path = tmp.name
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zout:
for name, content in data.items():
zout.writestr(name, content)
return tmp_path
def _get_cell_value(row, col_idx: int, default: str = "") -> str:
"""
获取单元格值,处理 None 和空值
......@@ -153,7 +211,11 @@ def _get_int_cell_value(row, col_idx: int, default: int = 0) -> int:
def read_test_cases(file_path: str) -> List[TestCase]:
"""
读取测试用例Excel文件
读取测试用例Excel文件(支持多个 Sheet)
遍历工作簿中所有 Sheet,合并读取所有测试用例数据。
每个 Sheet 结构与数据起始行保持一致(表头第3行,数据第4行起)。
自动修复损坏的 autoFilter XML(某些 Excel 导出文件常见问题)。
Args:
file_path: Excel文件路径
......@@ -161,40 +223,53 @@ def read_test_cases(file_path: str) -> List[TestCase]:
Returns:
测试用例列表
"""
wb = openpyxl.load_workbook(file_path, data_only=True)
ws = wb.active
# 修复损坏的 autoFilter(如果存在)
safe_path = _fix_excel_auto_filter(file_path)
wb = openpyxl.load_workbook(safe_path, data_only=True)
test_cases = []
for row in ws.iter_rows(min_row=CASE_DATA_START_ROW):
serial_number = _get_int_cell_value(row, CASE_COL_SERIAL_NUMBER)
# 遍历所有 Sheet,合并读取(多 Sheet 文件如:预约会议/会议看板/会议室管理)
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
for row in ws.iter_rows(min_row=CASE_DATA_START_ROW):
serial_number = _get_int_cell_value(row, CASE_COL_SERIAL_NUMBER)
# 序列号为0时跳过该行
if serial_number == 0:
continue
case = TestCase(
serial_number=serial_number,
module=_get_cell_value(row, CASE_COL_MODULE),
category=_get_cell_value(row, CASE_COL_CATEGORY),
case_number=_get_cell_value(row, CASE_COL_CASE_NUMBER),
description=_get_cell_value(row, CASE_COL_DESCRIPTION),
level=_get_cell_value(row, CASE_COL_LEVEL),
function_number=_get_cell_value(row, CASE_COL_FUNCTION_NUMBER),
name=_get_cell_value(row, CASE_COL_NAME),
pre_condition=_get_cell_value(row, CASE_COL_PRE_CONDITION),
steps=_get_cell_value(row, CASE_COL_STEPS),
json_config=_get_cell_value(row, CASE_COL_JSON),
expected_result=_get_cell_value(row, CASE_COL_EXPECTED_RESULT),
test_result=normalize_test_result(_get_cell_value(row, CASE_COL_TEST_RESULT)),
test_frequency=_get_cell_value(row, CASE_COL_TEST_FREQUENCY),
logs_screenshots=_get_cell_value(row, CASE_COL_LOGS_SCREENSHOTS),
screenshot2=_get_cell_value(row, CASE_COL_SCREENSHOT2),
remarks=_get_cell_value(row, CASE_COL_REMARKS),
)
test_cases.append(case)
# 序列号为0时跳过该行
if serial_number == 0:
continue
wb.close()
case = TestCase(
serial_number=serial_number,
module=_get_cell_value(row, CASE_COL_MODULE),
category=_get_cell_value(row, CASE_COL_CATEGORY),
case_number=_get_cell_value(row, CASE_COL_CASE_NUMBER),
description=_get_cell_value(row, CASE_COL_DESCRIPTION),
level=_get_cell_value(row, CASE_COL_LEVEL),
function_number=_get_cell_value(row, CASE_COL_FUNCTION_NUMBER),
name=_get_cell_value(row, CASE_COL_NAME),
pre_condition=_get_cell_value(row, CASE_COL_PRE_CONDITION),
steps=_get_cell_value(row, CASE_COL_STEPS),
json_config=_get_cell_value(row, CASE_COL_JSON),
expected_result=_get_cell_value(row, CASE_COL_EXPECTED_RESULT),
test_result=normalize_test_result(_get_cell_value(row, CASE_COL_TEST_RESULT)),
test_frequency=_get_cell_value(row, CASE_COL_TEST_FREQUENCY),
logs_screenshots=_get_cell_value(row, CASE_COL_LOGS_SCREENSHOTS),
screenshot2=_get_cell_value(row, CASE_COL_SCREENSHOT2),
remarks=_get_cell_value(row, CASE_COL_REMARKS),
)
test_cases.append(case)
# 清理临时文件
if safe_path != file_path:
try:
os.remove(safe_path)
except Exception:
pass
wb.close()
return test_cases
......@@ -299,7 +374,7 @@ def normalize_test_result(result: str) -> str:
result = result.strip()
# 通过结果映射
pass_aliases = {"通过", "Pass", "PASS", "pass", "成功", "P", "√", "✓", "是", "yes", "Yes", "YES"}
pass_aliases = {"通过", "Pass", "PASS", "pass", "成功", "P", "√", "✓", "是", "yes", "Yes", "YES", "功能验证"}
if result in pass_aliases:
return TEST_RESULT_PASS
......
......@@ -1789,9 +1789,20 @@ SECURITY_CASES = [
def create_security_modules_and_cases():
"""创建安全测试模块和用例"""
# 连接数据库
db_path = os.path.join(os.path.dirname(__file__), "..", "data", "test_platform.db")
db_url = f"sqlite:///{os.path.abspath(db_path)}"
# 连接数据库(支持环境变量 DATABASE_URL 覆盖,用于 Docker/MySQL 部署)
db_url = os.environ.get("DATABASE_URL")
if not db_url:
db_path = os.path.join(os.path.dirname(__file__), "..", "data", "test_platform.db")
db_url = f"sqlite:///{os.path.abspath(db_path)}"
# 本脚本使用同步 SQLAlchemy 会话;若 DATABASE_URL 是异步 aiomysql URL,
# 需翻译成同步 pymysql URL(模块导入仍用原 env,此处仅用于同步引擎)
if db_url.startswith("mysql+aiomysql://"):
sync_url = "mysql+pymysql://" + db_url[len("mysql+aiomysql://"):]
print(f" 数据库: {db_url} → (同步引擎切换为 {sync_url})")
db_url = sync_url
else:
print(f" 数据库: {db_url}")
engine = create_engine(db_url)
Session = sessionmaker(bind=engine)
......
......@@ -20,6 +20,7 @@ import type {
TestConnectionResponse,
TopicTemplateListResponse,
DeviceImportResponse,
DeviceStats,
} from '@/types/device'
const BASE = '/api/device-sim'
......@@ -149,6 +150,17 @@ export function getTopicTemplates(deviceType: string): Promise<TopicTemplateList
return request.get(`${BASE}/topic-templates/${deviceType}`)
}
// ==================== 统计 API ====================
/** 获取设备统计信息 */
export function getDeviceStats(params?: { deviceType?: string; envConfigId?: string }): Promise<DeviceStats> {
// 转换参数名为 snake_case(后端要求),与 listSimulators() 保持一致
const snakeParams: Record<string, any> = {}
if (params?.deviceType !== undefined) snakeParams.device_type = params.deviceType
if (params?.envConfigId !== undefined) snakeParams.env_config_id = params.envConfigId
return request.get(`${BASE}/devices/stats`, { params: snakeParams })
}
// ==================== 批量操作 API ====================
/** 批量启动设备 */
......
......@@ -61,6 +61,7 @@ export interface ReportTemplateConfig {
export interface ReportTemplateListItem {
id: number
name: string
description?: string
is_default: boolean
updated_at?: string
}
......@@ -69,6 +70,7 @@ export interface ReportTemplateListItem {
export interface ReportTemplateResponse {
id: number
name: string
description?: string
is_default: boolean
config: ReportTemplateConfig
created_at?: string
......@@ -78,6 +80,7 @@ export interface ReportTemplateResponse {
/** 创建模板请求 */
export interface ReportTemplateCreateRequest {
name: string
description?: string
is_default?: boolean
config: ReportTemplateConfig
}
......@@ -85,6 +88,7 @@ export interface ReportTemplateCreateRequest {
/** 更新模板请求 */
export interface ReportTemplateUpdateRequest {
name?: string
description?: string
is_default?: boolean
config?: ReportTemplateConfig
}
......@@ -145,5 +149,14 @@ export const reportTemplateApi = {
async setDefault(id: number): Promise<ReportTemplateResponse> {
const response = await request.put(`/api/functional-report/templates/${id}/default`)
return response as any
},
/**
* 复制模板
* @param id 原模板 ID
*/
async duplicate(id: number): Promise<ReportTemplateResponse> {
const response = await request.post(`/api/functional-report/templates/${id}/duplicate`)
return response as any
}
}
\ No newline at end of file
......@@ -37,4 +37,24 @@ export const systemApi = {
const response = await request.get('/api/system/info')
return response as any
},
/**
* 获取钉钉对接配置
*/
async getDingTalkConfig(): Promise<{ app_key: string; app_secret: string; agent_id: number }> {
const response = await request.get('/api/system/dingtalk-config')
return response as any
},
/**
* 更新钉钉对接配置
*/
async updateDingTalkConfig(config: {
app_key?: string
app_secret?: string
agent_id?: number
}): Promise<any> {
const response = await request.put('/api/system/dingtalk-config', config)
return response as any
},
}
......@@ -10,7 +10,7 @@
<div class="device-list">
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select v-model="filterEnvConfigId" placeholder="选择环境" clearable style="width: 200px" @change="loadDevices">
<el-select v-model="filterEnvConfigId" placeholder="选择环境" clearable style="width: 200px" @change="onInternalEnvChange">
<el-option v-for="env in envConfigs" :key="env.id" :label="env.name" :value="env.id" />
</el-select>
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 120px" @change="loadDevices">
......@@ -391,7 +391,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Upload, ArrowDown, Check } from '@element-plus/icons-vue'
import {
......@@ -418,14 +418,24 @@ import { DEVICE_TYPE_LABELS, CENTRAL_DEVICE_TYPES } from '@/types/device'
const props = defineProps<{
deviceType: DeviceType
envConfigId?: string
}>()
const emit = defineEmits<{
(e: 'device-changed'): void
(e: 'env-change', envId: string): void
}>()
const deviceTypeLabel = computed(() => DEVICE_TYPE_LABELS[props.deviceType])
// 同步父组件传入的环境选择到内部筛选变量
watch(() => props.envConfigId, (newVal) => {
if (newVal !== undefined) {
filterEnvConfigId.value = newVal || ''
}
loadDevices()
})
// 设备列表
const loading = ref(false)
const devices = ref<Simulator[]>([])
......@@ -519,6 +529,12 @@ const importFileList = ref<any[]>([])
const importResult = ref<DeviceImportResponse | null>(null)
const downloadingTemplate = ref(false)
/** 内部环境筛选变化:刷新列表 + 通知父页面刷新统计卡片 */
function onInternalEnvChange() {
loadDevices()
emit('env-change', filterEnvConfigId.value)
}
/** 加载设备列表 */
async function loadDevices() {
loading.value = true
......
......@@ -242,4 +242,16 @@ export interface DeviceImportResponse {
failedCount: number
results: DeviceImportRowResult[]
message: string
}
/** 设备统计 */
export interface DeviceStats {
total: number
running: number
stopped: number
byType: Record<string, {
total: number
running: number
stopped: number
}>
}
\ No newline at end of file
......@@ -279,8 +279,8 @@
<el-form-item label="执行名称">
<el-input v-model="runConfig.name" placeholder="可选,留空自动生成" />
</el-form-item>
<!-- UI用例显示浏览器模式选项,安全测试不显示 -->
<el-form-item label="浏览器模式" v-if="currentType !== 'security'">
<!-- UI用例显示浏览器模式选项,安全/接口测试不显示 -->
<el-form-item label="浏览器模式" v-if="currentType === 'ui'">
<el-switch v-model="runConfig.headless" active-text="无头" inactive-text="有头" />
</el-form-item>
<!-- 安全测试显示配置选择 -->
......@@ -294,7 +294,7 @@
/>
</el-select>
</el-form-item>
<el-form-item label="执行环境" v-if="currentType !== 'security'">
<el-form-item label="执行环境" v-if="currentType === 'ui'">
<el-select v-model="runConfig.environment" style="width: 100%">
<el-option label="默认环境" value="default" />
<el-option label="测试环境" value="test" />
......@@ -369,6 +369,7 @@ import {
createSecurityExecution,
runSecurityExecution,
} from '@/api/security'
import { apiTestApi } from '@/api/apiTest'
import { WebSocketClient } from '@/utils/websocket'
import StepDetailPanel from '@/components/StepDetailPanel.vue'
......@@ -390,8 +391,12 @@ const currentPage = ref(1)
const pageSize = ref(10)
const filterStatus = ref('')
/** 当前用例类型(来自路由参数,默认 ui) */
const currentType = computed(() => (route.params.type as string) || 'ui')
/** 当前用例类型(来自路由参数,默认 ui;接口测试模块固定为 api) */
const currentType = computed(() => {
// 接口测试模块路由 /api-test/execution → 固定为 api
if (route.path.startsWith('/api-test')) return 'api'
return (route.params.type as string) || 'ui'
})
const activeExecution = ref<any>(null)
const runDialogVisible = ref(false)
......@@ -772,6 +777,32 @@ const createAndRunExecution = async () => {
ElMessage.info('安全测试已启动,请等待...')
startPolling()
await loadExecutions()
} else if (currentType.value === 'api') {
// 接口测试走 apiTestApi
const execResponse: any = await apiTestApi.createExecution({
case_ids: selectedCases.value,
name: runConfig.value.name || `接口测试-${new Date().toLocaleString()}`,
})
const execId = execResponse.execution_id
// 获取完整执行记录
const fullExec = await executionApi.get(execId)
activeExecution.value = fullExec
runDialogVisible.value = false
selectedCases.value = []
runConfig.value.name = ''
// 触发接口测试执行
apiTestApi.runExecution(execId).then(() => {
ElMessage.success('接口测试执行完成')
loadExecutions()
}).catch((error: any) => {
console.warn('接口测试执行异常:', error.message)
loadExecutions()
})
ElMessage.info('接口测试已启动,请等待...')
startPolling()
await loadExecutions()
} else {
// UI用例走 executionApi
execution = await executionApi.create({
......
......@@ -11,14 +11,30 @@
<div class="page-header">
<h2>中控设备模拟</h2>
<div class="header-actions">
<el-select v-model="currentEnvId" placeholder="选择环境" clearable style="width: 220px">
<el-select v-model="currentEnvId" placeholder="选择环境" clearable style="width: 220px" @change="onEnvChange">
<el-option v-for="env in envConfigs" :key="env.id" :label="env.name" :value="env.id" />
</el-select>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">{{ total }}</div>
<div class="stat-label">总设备</div>
</div>
<div class="stat-card running">
<div class="stat-value">{{ runningCount }}</div>
<div class="stat-label">运行中</div>
</div>
<div class="stat-card stopped">
<div class="stat-value">{{ total - runningCount }}</div>
<div class="stat-label">已停止</div>
</div>
</div>
<!-- 设备列表 -->
<DeviceList device-type="central" @device-changed="onDeviceChanged" />
<DeviceList device-type="central" :env-config-id="currentEnvId" @device-changed="onDeviceChanged" @env-change="onDeviceEnvChange" />
</div>
</template>
......@@ -26,22 +42,47 @@
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue'
import { listEnvConfigs } from '@/api/deviceSim'
import { listEnvConfigs, getDeviceStats } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device'
const envConfigs = ref<EnvConfig[]>([])
const currentEnvId = ref('')
const total = ref(0)
const runningCount = ref(0)
async function loadStats() {
try {
const stats = await getDeviceStats({ deviceType: 'central', envConfigId: currentEnvId.value || undefined })
total.value = stats.total
runningCount.value = stats.running
} catch (e: any) {
// 静默失败,不影响设备列表加载
}
}
function onDeviceChanged() {}
function onEnvChange() {
loadStats()
}
function onDeviceChanged() {
loadStats()
}
/** DeviceList 内部环境筛选变化时,同步页面顶部环境选择并刷新统计 */
function onDeviceEnvChange(envId: string) {
currentEnvId.value = envId
loadStats()
}
onMounted(async () => {
try {
const res = await listEnvConfigs({ limit: 100 })
envConfigs.value = res.items
if (res.items.length > 0) currentEnvId.value = res.items[0].id
// 不自动选中环境,默认不传 envConfigId 查全部设备
} catch (e: any) {
ElMessage.error('加载环境配置失败')
}
await loadStats()
})
</script>
......@@ -50,4 +91,13 @@ onMounted(async () => {
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.stats-row { display: flex; gap: 16px; margin-bottom: 16px; }
.stat-card { flex: 1; padding: 16px 20px; background: #f5f7fa; border-radius: 8px; text-align: center; }
.stat-card .stat-value { font-size: 28px; font-weight: 700; color: #303133; }
.stat-card .stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
.stat-card.running { background: #f0f9eb; }
.stat-card.running .stat-value { color: #67c23a; }
.stat-card.stopped { background: #f4f4f5; }
.stat-card.stopped .stat-value { color: #909399; }
</style>
\ No newline at end of file
......@@ -11,14 +11,30 @@
<div class="page-header">
<h2>集控客户端模拟</h2>
<div class="header-actions">
<el-select v-model="currentEnvId" placeholder="选择环境" clearable style="width: 220px">
<el-select v-model="currentEnvId" placeholder="选择环境" clearable style="width: 220px" @change="onEnvChange">
<el-option v-for="env in envConfigs" :key="env.id" :label="env.name" :value="env.id" />
</el-select>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">{{ total }}</div>
<div class="stat-label">总设备</div>
</div>
<div class="stat-card running">
<div class="stat-value">{{ runningCount }}</div>
<div class="stat-label">运行中</div>
</div>
<div class="stat-card stopped">
<div class="stat-value">{{ total - runningCount }}</div>
<div class="stat-label">已停止</div>
</div>
</div>
<!-- 设备列表 -->
<DeviceList device-type="client" @device-changed="onDeviceChanged" />
<DeviceList device-type="client" :env-config-id="currentEnvId" @device-changed="onDeviceChanged" @env-change="onDeviceEnvChange" />
</div>
</template>
......@@ -26,22 +42,47 @@
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue'
import { listEnvConfigs } from '@/api/deviceSim'
import { listEnvConfigs, getDeviceStats } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device'
const envConfigs = ref<EnvConfig[]>([])
const currentEnvId = ref('')
const total = ref(0)
const runningCount = ref(0)
async function loadStats() {
try {
const stats = await getDeviceStats({ deviceType: 'client', envConfigId: currentEnvId.value || undefined })
total.value = stats.total
runningCount.value = stats.running
} catch (e: any) {
// 静默失败,不影响设备列表加载
}
}
function onDeviceChanged() {}
function onEnvChange() {
loadStats()
}
function onDeviceChanged() {
loadStats()
}
/** DeviceList 内部环境筛选变化时,同步页面顶部环境选择并刷新统计 */
function onDeviceEnvChange(envId: string) {
currentEnvId.value = envId
loadStats()
}
onMounted(async () => {
try {
const res = await listEnvConfigs({ limit: 100 })
envConfigs.value = res.items
if (res.items.length > 0) currentEnvId.value = res.items[0].id
// 不自动选中环境,默认不传 envConfigId 查全部设备
} catch (e: any) {
ElMessage.error('加载环境配置失败')
}
await loadStats()
})
</script>
......@@ -50,4 +91,13 @@ onMounted(async () => {
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.stats-row { display: flex; gap: 16px; margin-bottom: 16px; }
.stat-card { flex: 1; padding: 16px 20px; background: #f5f7fa; border-radius: 8px; text-align: center; }
.stat-card .stat-value { font-size: 28px; font-weight: 700; color: #303133; }
.stat-card .stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
.stat-card.running { background: #f0f9eb; }
.stat-card.running .stat-value { color: #67c23a; }
.stat-card.stopped { background: #f4f4f5; }
.stat-card.stopped .stat-value { color: #909399; }
</style>
\ No newline at end of file
......@@ -41,7 +41,7 @@
</div>
<!-- 设备列表 -->
<DeviceList device-type="door" :env-config-id="currentEnvId" @device-changed="onDeviceChanged" />
<DeviceList device-type="door" :env-config-id="currentEnvId" @device-changed="onDeviceChanged" @env-change="onDeviceEnvChange" />
</div>
</template>
......@@ -49,7 +49,7 @@
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue'
import { listEnvConfigs } from '@/api/deviceSim'
import { listEnvConfigs, getDeviceStats } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device'
const envConfigs = ref<EnvConfig[]>([])
......@@ -57,24 +57,39 @@ const currentEnvId = ref('')
const total = ref(0)
const runningCount = ref(0)
async function loadStats() {
try {
const stats = await getDeviceStats({ deviceType: 'door', envConfigId: currentEnvId.value || undefined })
total.value = stats.total
runningCount.value = stats.running
} catch (e: any) {
// 静默失败,不影响设备列表加载
}
}
function onEnvChange() {
// 环境切换时重新加载
loadStats()
}
function onDeviceChanged() {
// 设备变化时刷新统计
loadStats()
}
/** DeviceList 内部环境筛选变化时,同步页面顶部环境选择并刷新统计 */
function onDeviceEnvChange(envId: string) {
currentEnvId.value = envId
loadStats()
}
onMounted(async () => {
try {
const res = await listEnvConfigs({ limit: 100 })
envConfigs.value = res.items
if (res.items.length > 0 && !currentEnvId.value) {
currentEnvId.value = res.items[0].id
}
// 不自动选中环境,默认不传 envConfigId 查全部设备
} catch (e: any) {
ElMessage.error('加载环境配置失败')
}
await loadStats()
})
</script>
......
......@@ -90,6 +90,23 @@
</el-col>
</el-row>
<!-- 主题前缀配置(多主题) -->
<el-divider content-position="left">主题前缀配置</el-divider>
<el-row :gutter="16">
<el-col :span="12" v-for="dt in DEVICE_TYPES" :key="dt">
<el-form-item :label="DEVICE_TYPE_LABELS[dt]">
<el-input v-model="form.topics![dt]" :placeholder="`device/${dt}`" size="small" />
</el-form-item>
</el-col>
</el-row>
<el-alert
type="info"
:closable="false"
show-icon
title="各设备类型使用独立的 MQTT 主题前缀,留空默认按 device/{设备类型} 生成"
style="margin-bottom: 8px"
/>
<!-- 默认主题参数配置 -->
<el-divider content-position="left">默认主题参数</el-divider>
<el-row :gutter="16">
......@@ -179,6 +196,20 @@ import {
testEnvConnection,
} from '@/api/deviceSim'
import type { EnvConfig, EnvConfigCreate } from '@/types/device'
import { DEVICE_TYPE_LABELS } from '@/types/device'
/** 支持配置主题前缀的设备类型(按固定顺序展示) */
const DEVICE_TYPES = ['door', 'paperless', 'central', 'client'] as const
/** 由主题前缀自动生成默认多主题映射 */
function defaultTopics(): Record<string, string> {
return {
door: 'device/door',
paperless: 'device/paperless',
central: 'device/central',
client: 'device/client',
}
}
const loading = ref(false)
const configs = ref<EnvConfig[]>([])
......@@ -196,6 +227,7 @@ const defaultForm = (): EnvConfigCreate => ({
password: 'mqtt@webpassw0RD',
useTls: false,
clientIdPrefix: 'sim',
topics: defaultTopics(),
defaultTopicParams: {
company_id: '',
room_ids: [''],
......@@ -251,6 +283,7 @@ function openEditDialog(row: EnvConfig) {
password: '',
useTls: row.useTls,
clientIdPrefix: row.clientIdPrefix,
topics: row.topics ? { ...row.topics } : defaultTopics(),
defaultTopicParams: row.defaultTopicParams ? {
company_id: row.defaultTopicParams.company_id || '',
room_ids: row.defaultTopicParams.room_ids?.length ? [...row.defaultTopicParams.room_ids] : [''],
......
......@@ -11,14 +11,30 @@
<div class="page-header">
<h2>无纸化模拟</h2>
<div class="header-actions">
<el-select v-model="currentEnvId" placeholder="选择环境" clearable style="width: 220px">
<el-select v-model="currentEnvId" placeholder="选择环境" clearable style="width: 220px" @change="onEnvChange">
<el-option v-for="env in envConfigs" :key="env.id" :label="env.name" :value="env.id" />
</el-select>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">{{ total }}</div>
<div class="stat-label">总设备</div>
</div>
<div class="stat-card running">
<div class="stat-value">{{ runningCount }}</div>
<div class="stat-label">运行中</div>
</div>
<div class="stat-card stopped">
<div class="stat-value">{{ total - runningCount }}</div>
<div class="stat-label">已停止</div>
</div>
</div>
<!-- 设备列表 -->
<DeviceList device-type="paperless" @device-changed="onDeviceChanged" />
<DeviceList device-type="paperless" :env-config-id="currentEnvId" @device-changed="onDeviceChanged" @env-change="onDeviceEnvChange" />
</div>
</template>
......@@ -26,22 +42,47 @@
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import DeviceList from '@/components/device/DeviceList.vue'
import { listEnvConfigs } from '@/api/deviceSim'
import { listEnvConfigs, getDeviceStats } from '@/api/deviceSim'
import type { EnvConfig } from '@/types/device'
const envConfigs = ref<EnvConfig[]>([])
const currentEnvId = ref('')
const total = ref(0)
const runningCount = ref(0)
async function loadStats() {
try {
const stats = await getDeviceStats({ deviceType: 'paperless', envConfigId: currentEnvId.value || undefined })
total.value = stats.total
runningCount.value = stats.running
} catch (e: any) {
// 静默失败,不影响设备列表加载
}
}
function onDeviceChanged() {}
function onEnvChange() {
loadStats()
}
function onDeviceChanged() {
loadStats()
}
/** DeviceList 内部环境筛选变化时,同步页面顶部环境选择并刷新统计 */
function onDeviceEnvChange(envId: string) {
currentEnvId.value = envId
loadStats()
}
onMounted(async () => {
try {
const res = await listEnvConfigs({ limit: 100 })
envConfigs.value = res.items
if (res.items.length > 0) currentEnvId.value = res.items[0].id
// 不自动选中环境,默认不传 envConfigId 查全部设备
} catch (e: any) {
ElMessage.error('加载环境配置失败')
}
await loadStats()
})
</script>
......@@ -50,4 +91,13 @@ onMounted(async () => {
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h2 { font-size: 20px; font-weight: 600; color: #303133; margin: 0; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.stats-row { display: flex; gap: 16px; margin-bottom: 16px; }
.stat-card { flex: 1; padding: 16px 20px; background: #f5f7fa; border-radius: 8px; text-align: center; }
.stat-card .stat-value { font-size: 28px; font-weight: 700; color: #303133; }
.stat-card .stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
.stat-card.running { background: #f0f9eb; }
.stat-card.running .stat-value { color: #67c23a; }
.stat-card.stopped { background: #f4f4f5; }
.stat-card.stopped .stat-value { color: #909399; }
</style>
\ No newline at end of file
......@@ -33,9 +33,12 @@
:class="{ active: selectedId === tpl.id }"
@click="selectTemplate(tpl.id)"
>
<div class="template-info">
<span class="template-name">{{ tpl.name }}</span>
<el-tag v-if="tpl.is_default" size="small" type="success" class="default-tag">默认</el-tag>
<div class="template-left">
<div class="template-info">
<span class="template-name">{{ tpl.name }}</span>
<el-tag v-if="tpl.is_default" size="small" type="success" class="default-tag">默认</el-tag>
</div>
<div v-if="tpl.description" class="template-desc" :title="tpl.description">{{ tpl.description }}</div>
</div>
<div class="template-actions">
<el-tooltip content="设为默认" placement="top">
......@@ -49,6 +52,16 @@
<el-icon><Star /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="复制模板" placement="top">
<el-button
size="small"
circle
text
@click.stop="handleDuplicate(tpl)"
>
<el-icon><CopyDocument /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button
size="small"
......@@ -94,6 +107,13 @@
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="18">
<el-form-item label="模板描述">
<el-input v-model="editForm.description" type="textarea" :rows="2" maxlength="200" show-word-limit placeholder="模板用途说明(可选)" />
</el-form-item>
</el-col>
</el-row>
<!-- ===== 报告章节开关 ===== -->
<el-divider content-position="left">报告章节开关</el-divider>
......@@ -291,7 +311,7 @@
import { reactive, ref, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Star } from '@element-plus/icons-vue'
import { Plus, Delete, Star, CopyDocument } from '@element-plus/icons-vue'
import {
reportTemplateApi,
type ReportTemplateListItem,
......@@ -303,6 +323,7 @@ import {
interface EditForm {
name: string
description: string
is_default: boolean
config: ReportTemplateConfig
}
......@@ -395,6 +416,7 @@ const saving = ref(false)
/** 编辑表单数据 */
const editForm = reactive<EditForm>({
name: '',
description: '',
is_default: false,
config: createDefaultConfig(),
})
......@@ -422,6 +444,7 @@ async function loadTemplateDetail(id: number) {
try {
const detail: ReportTemplateResponse = await reportTemplateApi.get(id)
editForm.name = detail.name
editForm.description = detail.description || ''
editForm.is_default = detail.is_default
// 合并配置,确保所有字段存在
editForm.config = deepClone(mergeConfig(detail.config))
......@@ -506,6 +529,7 @@ async function handleSave() {
try {
await reportTemplateApi.update(selectedId.value, {
name: editForm.name,
description: editForm.description,
is_default: editForm.is_default,
config: editForm.config,
})
......@@ -534,6 +558,19 @@ async function handleSetDefault(tpl: ReportTemplateListItem) {
}
}
/** 复制模板 */
async function handleDuplicate(tpl: ReportTemplateListItem) {
try {
const result = await reportTemplateApi.duplicate(tpl.id)
ElMessage.success(`已复制模板「${result.name}」`)
await loadTemplates()
selectedId.value = result.id
await loadTemplateDetail(result.id)
} catch (err: any) {
ElMessage.error('复制模板失败:' + (err?.message || '未知错误'))
}
}
/** 删除模板 */
async function handleDelete(tpl: ReportTemplateListItem) {
try {
......@@ -566,6 +603,7 @@ function handleReset() {
if (!originalSnapshot) return
const snapshot = JSON.parse(originalSnapshot)
editForm.name = snapshot.name
editForm.description = snapshot.description
editForm.is_default = snapshot.is_default
editForm.config = snapshot.config
ElMessage.success('已重置到上次保存状态')
......@@ -642,6 +680,14 @@ onMounted(() => {
border-color: #409eff;
}
.template-left {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.template-info {
display: flex;
align-items: center;
......
......@@ -250,12 +250,13 @@ async function loadReport() {
try {
const res = await getReport(taskId.value)
report.value = res
// 先隐藏骨架屏让图表 div 渲染到 DOM,再初始化图表(v-if/v-else-if 互斥,否则 ref 为 undefined)
loading.value = false
await nextTick()
initCharts()
updateCharts()
} catch (e: any) {
ElMessage.error('加载报告失败: ' + (e.response?.data?.detail || e.message || ''))
} finally {
loading.value = false
}
}
......
<!--
页面名称:Settings.vue
页面描述:系统配置页面(待完善
页面描述:系统配置页面(钉钉对接配置
路径:/system/settings
@author czj
@date 2026-07-09
@date 2026-08-13
-->
<template>
<div class="settings-page">
<el-empty description="系统配置页面 - 待实现" />
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>钉钉对接配置</span>
</div>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="140px"
label-position="right"
style="max-width: 600px"
>
<el-form-item label="AppKey" prop="app_key">
<el-input
v-model="form.app_key"
type="password"
show-password
placeholder="请输入钉钉应用 AppKey"
clearable
/>
</el-form-item>
<el-form-item label="AppSecret" prop="app_secret">
<el-input
v-model="form.app_secret"
type="password"
show-password
placeholder="请输入钉钉应用 AppSecret"
clearable
/>
</el-form-item>
<el-form-item label="AgentId" prop="agent_id">
<el-input-number
v-model="form.agent_id"
:min="0"
:max="999999999"
:step="1"
style="width: 100%"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存配置' }}
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
/**
* 系统配置页面
*
* 功能:
* 1. 加载时自动获取当前钉钉对接配置(从数据库 / 环境变量回退)
* 2. 修改后保存到数据库,重启后持久化
*/
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { systemApi } from '@/api/system'
// ==================== 类型定义 ====================
interface DingTalkConfigForm {
app_key: string
app_secret: string
agent_id: number
}
// ==================== 响应式数据 ====================
const formRef = ref()
const saving = ref(false)
const form = reactive<DingTalkConfigForm>({
app_key: '',
app_secret: '',
agent_id: 0,
})
/** 原始数据快照,用于重置 */
let originalSnapshot = ''
// ==================== 表单校验规则 ====================
const rules = {
app_key: [{ required: true, message: '请输入钉钉 AppKey', trigger: 'blur' }],
app_secret: [{ required: true, message: '请输入钉钉 AppSecret', trigger: 'blur' }],
agent_id: [{ required: true, message: '请输入钉钉 AgentId', trigger: 'blur' }],
}
// ==================== 方法 ====================
/** 加载配置 */
async function loadConfig() {
try {
const res = await systemApi.getDingTalkConfig()
Object.assign(form, res)
originalSnapshot = JSON.stringify(res)
} catch (err: any) {
ElMessage.error('加载钉钉配置失败:' + (err?.message || '未知错误'))
}
}
/** 保存配置 */
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
await systemApi.updateDingTalkConfig({
app_key: form.app_key,
app_secret: form.app_secret,
agent_id: form.agent_id,
})
ElMessage.success('钉钉配置已保存')
originalSnapshot = JSON.stringify({ ...form })
} catch (err: any) {
ElMessage.error('保存钉钉配置失败:' + (err?.message || '未知错误'))
} finally {
saving.value = false
}
}
/** 重置表单到加载时的值 */
function handleReset() {
const snapshot = JSON.parse(originalSnapshot)
Object.assign(form, snapshot)
formRef.value?.clearValidate()
}
// ==================== 生命周期 ====================
onMounted(() => {
loadConfig()
})
</script>
<style scoped>
.settings-page {
padding: 40px;
max-width: 800px;
}
.card-header {
font-size: 16px;
font-weight: 600;
}
</style>
</style>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论