Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ WELINK_CARD_INI_PATH=
# 对外访问根地址(无尾部 /),一键通知卡片内链接依赖,示例:https://report.example.com
PUBLIC_APP_URL=

# 详细执行历史 Drawer 外部链接(留空则前端展示「暂无」)
TEST_CODE_REPO_URL=
# 取包地址:模板 URL,占位符 code_branch / start_time / package_name(测试/开发环境可配不同值)
PACKAGE_INIT_URL=
PACKAGE_NAME_MAC=
PACKAGE_NAME_OH=

# LDAP 域登录(LDAP_HOST 非空时启用域账号登录,留空则使用 MVP 工号+统一密码模式)
LDAP_HOST=
LDAP_PORT=389
Expand Down
3 changes: 2 additions & 1 deletion backend/api/router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import APIRouter

from backend.api.v1 import auth, dashboard, overview, history, analysis, cases, report, notification, admin, ut_gate_run
from backend.api.v1 import auth, dashboard, overview, history, analysis, cases, report, notification, admin, ut_gate_run, app

api_router = APIRouter(prefix="/api/v1")

Expand All @@ -14,3 +14,4 @@
api_router.include_router(notification.router)
api_router.include_router(admin.router)
api_router.include_router(ut_gate_run.router)
api_router.include_router(app.router)
29 changes: 29 additions & 0 deletions backend/api/v1/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# ============================================================
# API 路由层 — 应用级前端配置(/api/v1/app)
# ============================================================

from typing import Optional

from fastapi import APIRouter, Depends

from backend.core.config import settings
from backend.core.dependencies import get_current_user
from backend.schemas.app_config import FrontendConfigResponse

router = APIRouter(prefix="/app", tags=["应用配置"])


def _opt_str(val: str) -> Optional[str]:
s = (val or "").strip()
return s if s else None


@router.get("/frontend-config", response_model=FrontendConfigResponse)
async def get_frontend_config(_: dict = Depends(get_current_user)):
"""返回前端展示用只读配置(测试代码仓、取包地址模板等),值来自 .env。"""
return FrontendConfigResponse(
test_code_repo_url=_opt_str(settings.TEST_CODE_REPO_URL),
package_init_url=_opt_str(settings.PACKAGE_INIT_URL),
package_name_mac=_opt_str(settings.PACKAGE_NAME_MAC),
package_name_oh=_opt_str(settings.PACKAGE_NAME_OH),
)
7 changes: 7 additions & 0 deletions backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ class Settings(BaseSettings):
# 站点对外根 URL(无尾部斜杠),用于一键通知 WeLink 卡片内 /history 绝对链接
PUBLIC_APP_URL: str = ""

# 详细执行历史 Drawer:测试代码仓链接(空则前端展示「暂无」)
TEST_CODE_REPO_URL: str = ""
# 取包地址:模板 URL,占位符 code_branch / start_time / package_name
PACKAGE_INIT_URL: str = ""
PACKAGE_NAME_MAC: str = ""
PACKAGE_NAME_OH: str = ""

# AI 失败分析:main_module → 仓库映射(YAML,模板见 config/module_repo_mapping.yaml.example)
AI_MODULE_REPO_MAPPING_PATH: str = ""
# AI 失败分析:AIFA 服务地址(不含 /v1/analyze)
Expand Down
21 changes: 21 additions & 0 deletions backend/schemas/app_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# ============================================================
# 前端只读配置 — Schema
# ============================================================

from typing import Optional

from pydantic import BaseModel, Field


class FrontendConfigResponse(BaseModel):
"""GET /app/frontend-config:Drawer 外部链接等前端展示用配置(来自 .env)。"""

test_code_repo_url: Optional[str] = Field(
None, description="测试代码仓地址;未配置则为 None"
)
package_init_url: Optional[str] = Field(
None,
description="取包链接模板,占位符 code_branch / start_time / package_name",
)
package_name_mac: Optional[str] = Field(None, description="mac 平台包名")
package_name_oh: Optional[str] = Field(None, description="oh 平台包名")
4 changes: 4 additions & 0 deletions backend/schemas/batch_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ class BatchReportResponse(BaseModel):
model_config = {"from_attributes": True}

start_time: str = Field(..., description="轮次(批次)")
code_branch: Optional[str] = Field(
None,
description="构建分支;同批次多分支时用顿号拼接,无则 None",
)
total: int = Field(..., ge=0)
passed: int = Field(..., ge=0)
failed: int = Field(..., ge=0, description="failed + error")
Expand Down
16 changes: 16 additions & 0 deletions backend/services/batch_report_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ async def get_batch_report(db: AsyncSession, start_time: str) -> BatchReportResp
failed = int(agg_row.failed or 0)
skip = int(agg_row.skip or 0)

branch_stmt = (
select(Ph.code_branch)
.where(Ph.start_time == batch)
.where(Ph.code_branch.isnot(None))
.where(func.trim(Ph.code_branch) != "")
.distinct()
.order_by(Ph.code_branch)
)
branch_rows = (await db.execute(branch_stmt)).scalars().all()
code_branch: Optional[str] = None
if branch_rows:
branches = [str(b).strip() for b in branch_rows if b and str(b).strip()]
if branches:
code_branch = "、".join(branches)

# ----- 2) bug 归因 × 主模块(单批次内数据量可控,使用 JOIN)-----
bug_type = func.lower(func.trim(Pfr.failed_type))
main_mod = func.coalesce(func.nullif(func.trim(Ph.main_module), ""), "")
Expand Down Expand Up @@ -150,6 +165,7 @@ async def get_batch_report(db: AsyncSession, start_time: str) -> BatchReportResp

return BatchReportResponse(
start_time=batch,
code_branch=code_branch,
total=total,
passed=passed,
failed=failed,
Expand Down
53 changes: 53 additions & 0 deletions backend/tests/test_package_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""resolve_package_url 单元测试。"""

from backend.utils.package_url import resolve_package_url

_TEMPLATE = (
"https://example.com/path?branch=code_branch&time=start_time&pkg=package_name"
)


def test_mac_platform_builds_url():
url, hint = resolve_package_url(
_TEMPLATE,
"mac.pkg",
"oh.pkg",
"master",
"2026_0110_1200",
"MAC",
)
assert hint is None
assert url == "https://example.com/path?branch=master&time=202601101200&pkg=mac.pkg"


def test_oh_platform_builds_url():
url, hint = resolve_package_url(
_TEMPLATE,
"mac.pkg",
"oh.hap",
"930bugfix",
"202601211000",
"oh",
)
assert hint is None
assert url == "https://example.com/path?branch=930bugfix&time=202601211000&pkg=oh.hap"


def test_unknown_platform():
url, hint = resolve_package_url(
_TEMPLATE,
"mac.pkg",
"oh.pkg",
"master",
"202601211000",
"android",
)
assert url is None
assert hint == "unknown_platform"


def test_missing_config_or_fields():
assert resolve_package_url("", "a", "b", "master", "t", "mac") == (None, None)
assert resolve_package_url(_TEMPLATE, "", "b", "master", "t", "mac") == (None, None)
assert resolve_package_url(_TEMPLATE, "a", "b", "", "t", "mac") == (None, None)
assert resolve_package_url(_TEMPLATE, "a", "b", "master", "", "mac") == (None, None)
46 changes: 46 additions & 0 deletions backend/utils/package_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# ============================================================
# 取包地址拼接(占位符:code_branch、start_time、package_name)
# ============================================================

from typing import Optional, Tuple


def resolve_package_url(
init_url: str,
package_name_mac: str,
package_name_oh: str,
code_branch: Optional[str],
start_time: Optional[str],
platform: Optional[str],
) -> Tuple[Optional[str], Optional[str]]:
"""
生成取包链接。

:return: (url, hint)。hint 为 ``unknown_platform`` 时表示平台不支持;其余失败为 (None, None)。
"""
template = (init_url or "").strip()
if not template:
return None, None

branch = (code_branch or "").strip()
batch = (start_time or "").strip().replace("_", "")
if not branch or not batch:
return None, None

plat = (platform or "").strip().lower()
if plat == "mac":
package_name = (package_name_mac or "").strip()
elif plat == "oh":
package_name = (package_name_oh or "").strip()
else:
return None, "unknown_platform"

if not package_name:
return None, None

url = (
template.replace("code_branch", branch)
.replace("start_time", batch)
.replace("package_name", package_name)
)
return url, None
3 changes: 2 additions & 1 deletion docs/02_prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
- **Given** 图表进行数据渲染,**When** 绘制趋势线,**Then** 提供多条折线(或双 Y 轴折线+柱状图组合),明确展示关键指标的走势:失败用例数(折线A)、总用例数(折线B)、执行耗时/执行时间(折线C)。
- **Given** 用户想要查看某个具体批次的详细数据,**When** 将鼠标悬停在 X 轴的某个"批次"节点上(触发 Hover),**Then** 出现提示框(Tooltip),完整展示该批次的所有次要数据,包含:具体的执行发生时间(Timestamp)、成功数、未处理数、以及具体的执行耗时。
- **Given** 底层 MySQL 中存在海量的历史批次数据,**When** 渲染折线图时,**Then** 系统默认仅查询并展示最近 N 个(如最近 30 个)批次的数据点,以避免 X 轴刻度过于拥挤,并保证图表渲染性能。
- **Given** 用户在趋势图上点击某批次的「失败用例数」折线数据点,**When** 跳转至详细执行历史,**Then** URL 自动带入该批次(`start_time`)及执行结果筛选(`case_result=failed` 与 `error`);点击「总用例数」折线数据点则仅带入批次筛选。
- **数据来源:** 以 `pipeline_overview` 为主表,按 `batch` 聚合:`SUM(case_num)` 总用例数、`SUM(failed_num)` 失败数、`SUM(passed_num)` 通过数;执行耗时通过 `MAX(batch_end) - MIN(batch_start)` 计算。

#### Story 1.2: 多层级失败明细下钻
Expand Down Expand Up @@ -297,7 +298,7 @@
| 区域 | 内容 |
|------|------|
| **基本信息** | 用例名、批次、分组、主模块、用例级别、平台、代码分支 |
| **外部链接** | 日志URL、截图URL、测试报告URL、流水线URL(均以可点击链接形式展示) |
| **外部链接** | 日志URL、截图URL、测试报告URL、流水线URL、**测试代码仓**(`.env`)、**取包地址**(按行字段 + `.env` 模板拼接) |
| **归因分析区** | 失败类型下拉选择(来源 `case_failed_type`)、详细原因文本框、DTS 单号输入框、恢复批次输入框 |
| **流转操作区** | 当前负责人展示、指派给其他人(下拉选择来源 `ums_email`) |
| **操作时间线** | 按时间倒序展示历次操作记录(来源 `owner_history` 及审计日志) |
Expand Down
8 changes: 8 additions & 0 deletions docs/03_deployment_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,14 @@ WELINK_CARD_INI_PATH=

# 一键通知 WeLink 卡片中的「详细执行历史」链接:填写用户浏览器可访问的根地址(无尾部 /)
PUBLIC_APP_URL=https://your-report-host.example.com

# 详细执行历史 Drawer:测试代码仓(留空则展示「暂无」)
TEST_CODE_REPO_URL=https://your-code-repo.example.com

# 取包地址:模板与 mac/oh 包名(测试、开发环境可分别配置)
PACKAGE_INIT_URL=https://clouddragon.huawei.com/artifact/artifactcenter/product?repoKey=product_generic&path=vnext%2Fdaily%2Frolling_test%2Fsmoke%2Fcode_branch%2Fstart_time%2Fpackage_name&coordinate=vnext&packageType=Generic&fileType=file&from=link
PACKAGE_NAME_MAC=bitfun_ide_smoke_test_macos_arm64.dmg
PACKAGE_NAME_OH=bitfun_ide_uitest_ohos_aarch64.hap
```

### 4.4 启动后端
Expand Down
5 changes: 3 additions & 2 deletions docs/04_project_structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ dt-report/

| 文件 | 说明 |
|------|------|
| `config.py` | 全局配置。使用 Pydantic `BaseSettings` 从 `.env` 文件读取 `DATABASE_URL`、`SECRET_KEY`、`ADMIN_EMPLOYEE_IDS`、WeLink API 配置、`WELINK_CARD_INI_PATH`(WeLink 卡片 INI 路径)、`PUBLIC_APP_URL`(一键通知卡片内绝对链接根地址)、日志配置(ENV、LOG_LEVEL、LOG_DIR 等) |
| `config.py` | 全局配置。使用 Pydantic `BaseSettings` 从 `.env` 文件读取 `DATABASE_URL`、`SECRET_KEY`、`ADMIN_EMPLOYEE_IDS`、WeLink API 配置、`WELINK_CARD_INI_PATH`(WeLink 卡片 INI 路径)、`PUBLIC_APP_URL`(一键通知卡片内绝对链接根地址)、`TEST_CODE_REPO_URL` / `PACKAGE_INIT_URL` / `PACKAGE_NAME_MAC` / `PACKAGE_NAME_OH`(详细执行历史 Drawer 外部链接)、日志配置(ENV、LOG_LEVEL、LOG_DIR 等) |
| `dashboard_defaults.py` | 首页看板代码内默认策略(非环境变量):如是否按 `pipeline_overview.batch` 前缀过滤轮次;后续可迁到 `Settings` / `.env` |
| `database.py` | 数据库连接层。创建 SQLAlchemy 异步引擎(`create_async_engine`)和会话工厂(`async_sessionmaker`),提供 `get_db()` 异步生成器用于 FastAPI 依赖注入 |
| `security.py` | 认证鉴权工具。JWT Token 的生成(`create_access_token`)与验证(`verify_token`),管理员权限校验依赖项(`require_admin`) |
Expand Down Expand Up @@ -118,6 +118,7 @@ Schema 定义 API 的请求参数格式和响应 JSON 格式,由 FastAPI 自
|------|---------|------|---------|
| `router.py` | `/api/v1` | 总路由注册,将所有子模块路由挂载到 `/api/v1` 下 | ✅ 已实现 |
| `v1/history.py` | `/api/v1/history` | 执行明细:`GET /history` 分页筛选(未选批次时默认最近 30 批;已传非空 `start_time_contains` 或已选非空 `case_name` / `case_name_contains` 且无批次 IN 时不注入,见 Spec 08 §3.1 / §3.1.1;各维度可选 `*_contains` 子串);`GET /history/batch-report` 轮次通报;`GET /history/oh-daily-export` OH 日报 TSV(格式见 `constants/oh_daily_export_table.py`);`POST /history/failure-process` 失败标注(仍为 bug 且跟踪人变更时可向新跟踪人发 WeLink);`POST /history/inherit-failure-reason` 继承;`POST /history/one-click-analyze` 一键分析(整批 bug);`POST /history/one-click-bug-notify` 一键通知(WeLink,spec/13) | ✅ 已实现 |
| `v1/app.py` | `/api/v1/app` | 前端只读配置:`GET /app/frontend-config`(测试代码仓、取包地址模板等,来自 `.env`) | ✅ 已实现 |
| `v1/auth.py` | `/api/v1/auth` | 认证接口(登录/登出) | 🔲 占位 |
| `v1/dashboard.py` | `/api/v1/dashboard` | 数据看板接口 | 🔲 占位 |
| `v1/overview.py` | `/api/v1/overview` | 分组概览接口 | 🔲 占位 |
Expand Down Expand Up @@ -223,7 +224,7 @@ Schema 定义 API 的请求参数格式和响应 JSON 格式,由 FastAPI 自
| `history/HistoryPage.tsx` | 详细执行历史页面。Table 展示 pipeline_history 数据(含跟踪人、失败原因列),支持分页与多维度筛选(字符串维度下拉可搜索;有匹配候选项且搜索非空时首行「全部」应用子串筛选,与 URL `*_contains` 同步;`allowClear` 清除该维度 IN 与子串),Drawer 含基本信息区、失败归因区(仅 failed 时展示)、外部链接区;用例名链至钻取页;工具栏含分析处理、继承、一键分析、**一键通知**(spec/13)、一键生成通报、**日报数据**(`GET /history/oh-daily-export`,批次子串搜索弹窗 + TSV 复制);**搜索模板**:「保存模板」在「日报数据」之后,仅「筛选确认」后可保存(`historyApi` 调 `GET/POST/DELETE /history/search-templates`,每用户最多 10 条,交互与校验见 `docs/02_prd.md` Story 1.3);模板条展示于通报按钮同区域后部,点击应用查询、× 删除确认,删除当前所用模板时不改 URL;钻取模式应用模板时保留用例/平台/分支锚定。“已分析”列新增行级「分析」按钮,点击后复用与工具栏「分析处理」相同的弹窗与提交流程;**分析处理**弹窗在失败类型为 bug 时跟踪人为可编辑输入,默认按模块带出「姓名 工号」(spec/04);「详细原因」为多行 `Input.TextArea`,有本地缓存时上方提供可搜索 `Select`(`localStorage`)一键写入历史文案;失败标注 Modal 宽度约 580px、`body` 设 `maxHeight`+纵向滚动,避免内容与页脚重叠。布局:主内容区内占满剩余高度,筛选区与表头、分页固定,**仅表体区域纵向滚动**(`ResizeObserver` + `scroll.y`);样式见 `history-table.css` | ✅ 已实现 |
| `history/HistoryStringMultiFilter.tsx` | 历史页字符串多选筛选项:搜索、`dropdownRender` 内「全部」(不展示数量)、子串条件以灰色 `Tag` 与多选同框展示(`history-table.css`)、隐藏 `*_contains` 与 `Select` 的 Form 联动 | ✅ 已实现 |
| `history/CaseExecutionsHistoryPage.tsx` | 用例执行历史钻取壳组件,渲染 `HistoryPage drilldown`(`/history/case-executions`),规约 spec/12 | ✅ 已实现 |
| `dashboard/DashboardPage.tsx` | 首页大盘 | 🔲 占位 |
| `dashboard/DashboardPage.tsx` | 首页大盘:最新批次统计卡片(点击跳转 History 带 `start_time`);master / bugfix 双趋势折线图(ECharts);点击「失败用例数」折线点跳转 History 并预填 `case_result=failed&error`,点击「总用例数」折线点仅带 `start_time`(见 spec/09 §3.7) | ✅ 已实现 |
| `overview/OverviewPage.tsx` | 分组执行历史 | 🔲 占位 |
| `cases/CasesPage.tsx` | 用例管理 | 🔲 占位 |
| `report/ReportPage.tsx` | 总结报告 | 🔲 占位 |
Expand Down
32 changes: 25 additions & 7 deletions frontend/src/pages/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import {
type LatestBatchItem,
} from "../../services";

const FAILED_CASE_RESULTS = ["failed", "error"] as const;

function buildHistoryHref(batch: string, caseResults?: readonly string[]): string {
const q = new URLSearchParams();
q.append("start_time", batch);
caseResults?.forEach((r) => q.append("case_result", r));
return `/history?${q.toString()}`;
}

function buildChartOption(items: BatchTrendItem[]) {
return {
tooltip: {
Expand Down Expand Up @@ -123,15 +132,24 @@ export default function DashboardPage() {
}, []);

const handleCardClick = (batch: string) => {
navigate(`/history?start_time=${encodeURIComponent(batch)}`);
navigate(buildHistoryHref(batch));
};

const createChartClickHandler = (items: BatchTrendItem[]) => (params: { dataIndex: number }) => {
const item = items[params.dataIndex];
if (item?.batch) {
navigate(`/history?start_time=${encodeURIComponent(item.batch)}`);
}
};
const createChartClickHandler =
(items: BatchTrendItem[]) =>
(params: { dataIndex?: number; seriesName?: string }) => {
const idx = params.dataIndex;
if (idx == null || idx < 0) return;
const item = items[idx];
if (!item?.batch) return;
const isFailedSeries = params.seriesName === "失败用例数";
navigate(
buildHistoryHref(
item.batch,
isFailedSeries ? FAILED_CASE_RESULTS : undefined,
),
);
};

const hasLatestBatch = latestBatch && Object.keys(latestBatch).length > 0;

Expand Down
Loading
Loading