diff --git a/.env.example b/.env.example
index 32a49b8..54347bf 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/backend/api/router.py b/backend/api/router.py
index 1496531..793041a 100644
--- a/backend/api/router.py
+++ b/backend/api/router.py
@@ -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")
@@ -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)
diff --git a/backend/api/v1/app.py b/backend/api/v1/app.py
new file mode 100644
index 0000000..1bef28e
--- /dev/null
+++ b/backend/api/v1/app.py
@@ -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),
+ )
diff --git a/backend/core/config.py b/backend/core/config.py
index b4c07b3..12f8c28 100644
--- a/backend/core/config.py
+++ b/backend/core/config.py
@@ -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)
diff --git a/backend/schemas/app_config.py b/backend/schemas/app_config.py
new file mode 100644
index 0000000..a842d1b
--- /dev/null
+++ b/backend/schemas/app_config.py
@@ -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 平台包名")
diff --git a/backend/schemas/batch_report.py b/backend/schemas/batch_report.py
index 9fcdb98..270f1e4 100644
--- a/backend/schemas/batch_report.py
+++ b/backend/schemas/batch_report.py
@@ -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")
diff --git a/backend/services/batch_report_service.py b/backend/services/batch_report_service.py
index 3e267d0..e8a5586 100644
--- a/backend/services/batch_report_service.py
+++ b/backend/services/batch_report_service.py
@@ -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), ""), "")
@@ -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,
diff --git a/backend/tests/test_package_url.py b/backend/tests/test_package_url.py
new file mode 100644
index 0000000..127dfd9
--- /dev/null
+++ b/backend/tests/test_package_url.py
@@ -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)
diff --git a/backend/utils/package_url.py b/backend/utils/package_url.py
new file mode 100644
index 0000000..dfdff6f
--- /dev/null
+++ b/backend/utils/package_url.py
@@ -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
diff --git a/docs/02_prd.md b/docs/02_prd.md
index 96d78a9..ee24f16 100644
--- a/docs/02_prd.md
+++ b/docs/02_prd.md
@@ -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: 多层级失败明细下钻
@@ -297,7 +298,7 @@
| 区域 | 内容 |
|------|------|
| **基本信息** | 用例名、批次、分组、主模块、用例级别、平台、代码分支 |
-| **外部链接** | 日志URL、截图URL、测试报告URL、流水线URL(均以可点击链接形式展示) |
+| **外部链接** | 日志URL、截图URL、测试报告URL、流水线URL、**测试代码仓**(`.env`)、**取包地址**(按行字段 + `.env` 模板拼接) |
| **归因分析区** | 失败类型下拉选择(来源 `case_failed_type`)、详细原因文本框、DTS 单号输入框、恢复批次输入框 |
| **流转操作区** | 当前负责人展示、指派给其他人(下拉选择来源 `ums_email`) |
| **操作时间线** | 按时间倒序展示历次操作记录(来源 `owner_history` 及审计日志) |
diff --git a/docs/03_deployment_guide.md b/docs/03_deployment_guide.md
index d839b8a..af8d4cd 100644
--- a/docs/03_deployment_guide.md
+++ b/docs/03_deployment_guide.md
@@ -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 启动后端
diff --git a/docs/04_project_structure.md b/docs/04_project_structure.md
index ee39b9c..915ae22 100644
--- a/docs/04_project_structure.md
+++ b/docs/04_project_structure.md
@@ -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`) |
@@ -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` | 分组概览接口 | 🔲 占位 |
@@ -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` | 总结报告 | 🔲 占位 |
diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx
index f661665..cb18097 100644
--- a/frontend/src/pages/dashboard/DashboardPage.tsx
+++ b/frontend/src/pages/dashboard/DashboardPage.tsx
@@ -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: {
@@ -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;
diff --git a/frontend/src/pages/history/HistoryPage.tsx b/frontend/src/pages/history/HistoryPage.tsx
index d4472b6..87f120c 100644
--- a/frontend/src/pages/history/HistoryPage.tsx
+++ b/frontend/src/pages/history/HistoryPage.tsx
@@ -40,7 +40,10 @@ import {
type InheritSourceRecordItem,
type BatchReportResponse,
type HistorySearchTemplateItem,
+ appApi,
+ type FrontendConfig,
} from "../../services";
+import { resolvePackageUrl } from "../../utils/packageUrl";
import AIFailureAnalysisTab from "./components/ai_analysis/AIFailureAnalysisTab";
const { Text, Title, Paragraph } = Typography;
@@ -64,6 +67,7 @@ function buildRollingReportMarkdown(data: BatchReportResponse): string {
const lines: string[] = [];
lines.push("【rolling线看护进展通告】");
lines.push(`batch:${data.start_time}`);
+ lines.push(`构建分支:${data.code_branch ?? "—"}`);
lines.push(
`用例总数:${data.total},成功:${data.passed},失败:${data.failed},跳过:${data.skip}`,
);
@@ -142,6 +146,28 @@ function UrlLink({
return 暂无;
}
+function PackageUrlLink({
+ config,
+ record,
+}: {
+ config: FrontendConfig | null;
+ record: HistoryItem;
+}) {
+ const resolved = resolvePackageUrl(
+ config,
+ record.code_branch,
+ record.start_time,
+ record.platform,
+ );
+ if (resolved.kind === "url") {
+ return ;
+ }
+ if (resolved.kind === "unknown_platform") {
+ return 未知平台;
+ }
+ return 暂无;
+}
+
/** 仅当内容被遮挡时悬停显示 */
function EllipsisTooltip({
title,
@@ -268,6 +294,7 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) {
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState(null);
const [optionsLoading, setOptionsLoading] = useState(false);
+ const [frontendConfig, setFrontendConfig] = useState(null);
const [drawerVisible, setDrawerVisible] = useState(false);
const [drawerRecord, setDrawerRecord] = useState(null);
const [drawerFailureTabKey, setDrawerFailureTabKey] = useState("failure");
@@ -463,8 +490,18 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) {
}
};
+ const fetchFrontendConfig = async () => {
+ try {
+ const cfg = await appApi.frontendConfig();
+ setFrontendConfig(cfg);
+ } catch {
+ setFrontendConfig(null);
+ }
+ };
+
useEffect(() => {
fetchOptions();
+ void fetchFrontendConfig();
}, []);
const loadSearchTemplates = useCallback(async () => {
@@ -2596,10 +2633,18 @@ export default function HistoryPage({ drilldown = false }: HistoryPageProps) {
日志:
-
>
)}
diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts
index 3872eb8..5720d12 100644
--- a/frontend/src/services/index.ts
+++ b/frontend/src/services/index.ts
@@ -202,6 +202,7 @@ export interface BatchReportPlatformGroup {
export interface BatchReportResponse {
start_time: string;
+ code_branch: string | null;
total: number;
passed: number;
failed: number;
@@ -349,6 +350,19 @@ export const historyApi = {
// --- Dashboard API ---
+/** GET /app/frontend-config 前端只读配置 */
+export interface FrontendConfig {
+ test_code_repo_url: string | null;
+ package_init_url: string | null;
+ package_name_mac: string | null;
+ package_name_oh: string | null;
+}
+
+export const appApi = {
+ frontendConfig: (): Promise =>
+ request.get("/app/frontend-config") as Promise,
+};
+
export interface LatestBatchItem {
batch: string;
total_case_num: number;
diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts
index 1c87180..e09fd15 100644
--- a/frontend/src/utils/index.ts
+++ b/frontend/src/utils/index.ts
@@ -1 +1,2 @@
export { getSafeRedirectPath } from "./safeRedirect";
+export { resolvePackageUrl, type PackageUrlResolve } from "./packageUrl";
diff --git a/frontend/src/utils/packageUrl.ts b/frontend/src/utils/packageUrl.ts
new file mode 100644
index 0000000..a5bb982
--- /dev/null
+++ b/frontend/src/utils/packageUrl.ts
@@ -0,0 +1,43 @@
+import type { FrontendConfig } from "../services";
+
+export type PackageUrlResolve =
+ | { kind: "url"; url: string }
+ | { kind: "unknown_platform" }
+ | { kind: "unavailable" };
+
+/** 根据环境配置与行数据拼接取包地址(platform 忽略大小写) */
+export function resolvePackageUrl(
+ config: Pick<
+ FrontendConfig,
+ "package_init_url" | "package_name_mac" | "package_name_oh"
+ > | null | undefined,
+ codeBranch: string | null | undefined,
+ startTime: string | null | undefined,
+ platform: string | null | undefined,
+): PackageUrlResolve {
+ const template = config?.package_init_url?.trim();
+ if (!template) return { kind: "unavailable" };
+
+ const branch = codeBranch?.trim();
+ const batch = startTime?.trim().replace(/_/g, "");
+ if (!branch || !batch) return { kind: "unavailable" };
+
+ const plat = platform?.trim().toLowerCase();
+ let packageName: string | undefined;
+ if (plat === "mac") {
+ packageName = config?.package_name_mac?.trim();
+ } else if (plat === "oh") {
+ packageName = config?.package_name_oh?.trim();
+ } else {
+ return { kind: "unknown_platform" };
+ }
+
+ if (!packageName) return { kind: "unavailable" };
+
+ const url = template
+ .replace(/code_branch/g, branch)
+ .replace(/start_time/g, batch)
+ .replace(/package_name/g, packageName);
+
+ return { kind: "url", url };
+}
diff --git a/spec/02_history_fields_spec.md b/spec/02_history_fields_spec.md
index 96e8b5d..08cb4bc 100644
--- a/spec/02_history_fields_spec.md
+++ b/spec/02_history_fields_spec.md
@@ -48,6 +48,10 @@
|--------|----------|
| log_url | 可点击链接(新窗口打开),无值则显示「暂无」 |
| pipeline_url | 可点击链接(新窗口打开),无值则显示「暂无」 |
+| test_code_repo_url | 可点击链接;值来自环境变量 `TEST_CODE_REPO_URL`,未配置则「暂无」 |
+| package_url(取包地址) | 由当前行 `code_branch`、`start_time`、`platform` 与 `.env` 中 `PACKAGE_INIT_URL`、`PACKAGE_NAME_MAC`、`PACKAGE_NAME_OH` 拼接;`platform` 忽略大小写,仅支持 `mac` / `oh`;不支持的平台显示「未知平台」;配置或行字段缺失则「暂无」 |
+
+**说明:** 测试代码仓与取包模板通过 `GET /api/v1/app/frontend-config` 下发(需登录),详见部署文档。
**说明:** API 响应字段 `owner` 在 Drawer 基本信息区展示为「用例开发责任人」,值为按 `main_module` 与 `ums_module_owner.module` 关联解析后的「姓名+工号」展示串;**模块匹配按小写等价**(`LOWER(main_module)` 与 `LOWER(module)`),避免流水线写入大小写不一致导致无负责人。`owner_history` 本次实现不考虑。
@@ -90,5 +94,7 @@
| reports_url | ✓ | ✓ 基本信息(链接) | — |
| log_url | — | ✓ 外部链接 | — |
| pipeline_url | — | ✓ 外部链接 | — |
+| test_code_repo_url | — | ✓ 外部链接(配置) | — |
+| package_url | — | ✓ 外部链接(拼接) | — |
| owner_history | — | — | — |
| id / created_at / updated_at | — | — | — |
diff --git a/spec/09_homepage_batch_status_trend_spec.md b/spec/09_homepage_batch_status_trend_spec.md
index 4f7aef4..235b116 100644
--- a/spec/09_homepage_batch_status_trend_spec.md
+++ b/spec/09_homepage_batch_status_trend_spec.md
@@ -127,7 +127,9 @@
### 3.7 交互
- **图例**:支持点击图例切换显示/隐藏对应折线系列。
-- **点击数据点**:点击某批次的数据点,跳转到 History 页(`/history`),URL 带筛选参数 `?start_time=批次值`(可附加 `code_branch` 以保持筛选一致性),直接展示该批次的用例明细。
+- **点击数据点**:按所点折线系列区分跳转目标(同一 `start_time` 仅对应一个 `code_branch`,跳转**不带** `code_branch` 参数):
+ - **失败用例数**折线:跳转 History(`/history`),URL 为 `?start_time=批次值&case_result=failed&case_result=error`,预填「执行结果」为失败与异常,直接展示该批次待跟进用例。
+ - **总用例数**折线:跳转 History,URL 为 `?start_time=批次值`,展示该批次全部用例明细。
### 3.8 数据范围