Browse Source

feat: 漏斗页趋势Tab(按日UV/转换率折线)+ /api/funnels/trend + look后端按需重启

tianyu.chu 1 month ago
parent
commit
82b50fb784

+ 8 - 0
CHANGELOG.md

@@ -4,6 +4,14 @@
 
 ## 2026-06-26
 
+### 新增
+- **漏斗页「趋势」Tab:按日折线**(`apps/api` + `apps/web`):事件 UV 与相邻转换率的每日变化。
+  - 数据源就是 `ads_trd_group_funnel_daily`(天然时序),**无需新表/ETL**。
+  - 后端 `POST /api/funnels/trend`:范围可选(默认最新可用日往前 30 天),每点复用 `FunnelStepResult`(uv+conversion 服务端算,**口径与漏斗页一致**);**裁前导全 0 天**(上线前爬坡);缺口日缺点不补零;`end_dt>昨日`/`start>end` → 422。`repository` 加 `fetch_daily_range`。
+  - 前端:漏斗页加 **`漏斗 / 趋势` 视图 Tab**(React 状态驱动,不用 radix `data-active` 以免选中态不可见);趋势视图两张分离折线(事件 UV / 相邻转换率,4 段),范围控件 `近30天(默认) / 自定义起止`(自定义上限锁最新可用日);连续日期轴补 `null` **断线**(`connectNulls:false`,不补零);ECharts 懒加载。
+  - `look.sh`:后端按需重启(仅 `~/hs-api` HEAD 变化才重启 uvicorn,纯前端改动不抖)。
+  - 测试:后端 +7(范围/裁 0/口径/missing/校验)、前端 +9(mock 序列 + 视图/Tab 状态)。
+
 ### 修复
 - **日期范围文案挤动页面**(`apps/web` `FunnelPage`):右上角日期范围(如 `2026-06-15 ~ 2026-06-21`)原是条件挂载/卸载,显示↔隐藏(单日↔近7/30天切换、pending→加载完)时控件列高度变化,撑动外层行,把下方图表整体顶动。改为**始终占位**(`h-4` 固定行高,空内容不塌缩),仅滚动周期且查询完成时填文案——高度恒定,不再抖动。
 

+ 24 - 1
apps/api/app/api/funnels.py

@@ -8,11 +8,18 @@ from fastapi import APIRouter, Depends, HTTPException
 
 from app.config import get_settings
 from app.db.session import get_sessionmaker
-from app.schemas import FunnelQueryRequest, FunnelQueryResponse
+from app.schemas import (
+    FunnelQueryRequest,
+    FunnelQueryResponse,
+    FunnelTrendRequest,
+    FunnelTrendResponse,
+)
 from app.services.funnel import (
     FunnelRepository,
     SnapshotDateError,
+    TrendRangeError,
     run_funnel_query,
+    run_funnel_trend,
 )
 from app.services.repository import FakeFunnelRepository, SqlAlchemyFunnelRepository
 
@@ -50,3 +57,19 @@ async def query_funnel(
         return await run_funnel_query(req.period, repo, req.snapshot_dt)
     except SnapshotDateError as exc:
         raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+
+@router.post("/trend", response_model=FunnelTrendResponse)
+async def query_funnel_trend(
+    req: FunnelTrendRequest,
+    repo: FunnelRepository = Depends(get_repository),
+) -> FunnelTrendResponse:
+    """Daily UV + conversion trend over the group-buy funnel (按日折线).
+
+    Both bounds optional (default: 30 most recent available days). ``end_dt``
+    later than yesterday, or ``start_dt`` after ``end_dt``, -> 422.
+    """
+    try:
+        return await run_funnel_trend(repo, req.start_dt, req.end_dt)
+    except TrendRangeError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc

+ 49 - 0
apps/api/app/schemas.py

@@ -89,3 +89,52 @@ class FunnelQueryResponse(BaseModel):
     )
     results: list[FunnelStepResult]
     data_status: DataStatus
+
+
+# --------------------------------------------------------------------------- #
+# Trend (按日折线) — daily time series over ads_trd_group_funnel_daily         #
+# --------------------------------------------------------------------------- #
+
+
+class FunnelTrendRequest(BaseModel):
+    """Trend query request body.
+
+    Both bounds optional (ISO ``YYYY-MM-DD``). Omitted -> the 30 most recent
+    available days ending at the latest daily ``dt``. ``end_dt`` must be <=
+    yesterday (T+1); ``start_dt`` must be <= ``end_dt``.
+    """
+
+    start_dt: date | None = Field(
+        None, description="Inclusive start (YYYY-MM-DD). Default: end - 29 days."
+    )
+    end_dt: date | None = Field(
+        None,
+        description="Inclusive end (YYYY-MM-DD). Default: latest available day. <= yesterday.",
+    )
+
+
+class FunnelTrendPoint(BaseModel):
+    """One day in the trend series: its ``dt`` + the full per-step funnel.
+
+    Reuses :class:`FunnelStepResult` so uv + conversion_rate match the funnel
+    query口径 exactly (computed by the same server code).
+    """
+
+    dt: str = Field(..., description="Day as yyyyMMdd")
+    results: list[FunnelStepResult]
+
+
+class FunnelTrendResponse(BaseModel):
+    """Trend query response body.
+
+    ``points`` is ascending by ``dt``, with leading all-zero (pre-launch) days
+    trimmed. ``start_dt`` / ``end_dt`` are the bounds of the returned series
+    (yyyyMMdd) — i.e. the first kept day and the last available day — null when
+    ``missing``. Calendar gaps inside the range are simply absent points (the
+    client breaks the line; no zero-fill).
+    """
+
+    start_dt: str | None = Field(None, description="First kept day (yyyyMMdd); null when missing.")
+    end_dt: str | None = Field(None, description="Last day (yyyyMMdd); null when missing.")
+    data_status: DataStatus
+    points: list[FunnelTrendPoint]

+ 116 - 0
apps/api/app/services/funnel.py

@@ -25,9 +25,14 @@ from app.schemas import (
     DataStatus,
     FunnelQueryResponse,
     FunnelStepResult,
+    FunnelTrendPoint,
+    FunnelTrendResponse,
     Period,
 )
 
+# Default trend window (days) when the request omits a range.
+DEFAULT_TREND_DAYS = 30
+
 # Fixed funnel order: 启动 -> 曝光 -> 拼团详情 -> 下单 -> 成功 (docs/03 §11).
 # Each entry: (event_key, Chinese display name).
 FUNNEL_STEPS: list[tuple[str, str]] = [
@@ -80,6 +85,24 @@ def validate_snapshot_dt(snapshot_dt: date) -> None:
         )
 
 
+class TrendRangeError(ValueError):
+    """Raised when a trend range is invalid (end in the future, or start > end)."""
+
+
+def validate_trend_range(start_dt: date | None, end_dt: date | None) -> None:
+    """Reject an out-of-range trend window (data is T+1; bounds must be ordered)."""
+    yesterday = date.today() - timedelta(days=1)
+    if end_dt is not None and end_dt > yesterday:
+        raise TrendRangeError(
+            f"end_dt {end_dt.isoformat()} is later than yesterday "
+            f"({yesterday.isoformat()}); data is T+1."
+        )
+    if start_dt is not None and end_dt is not None and start_dt > end_dt:
+        raise TrendRangeError(
+            f"start_dt {start_dt.isoformat()} is after end_dt {end_dt.isoformat()}."
+        )
+
+
 # --------------------------------------------------------------------------- #
 # Repository protocol + snapshot container                                    #
 # --------------------------------------------------------------------------- #
@@ -113,6 +136,15 @@ class FunnelRepository(Protocol):
         """Return the single rolling row, or ``None`` when the table is empty."""
         ...
 
+    async def fetch_daily_range(
+        self, start_dt: str, end_dt: str
+    ) -> list[FunnelSnapshot]:
+        """Return daily rows with ``start_dt <= dt <= end_dt`` (yyyyMMdd), ascending.
+
+        Empty list when no rows fall in the range.
+        """
+        ...
+
 
 # --------------------------------------------------------------------------- #
 # Conversion math                                                             #
@@ -217,3 +249,87 @@ async def run_funnel_query(
     # last_7d / last_30d -> rolling row; snapshot_dt ignored.
     snapshot = await repo.fetch_rolling()
     return _assemble(period, snapshot, rolling_columns(period))
+
+
+# --------------------------------------------------------------------------- #
+# Trend orchestration (按日折线)                                              #
+# --------------------------------------------------------------------------- #
+
+
+def _parse_dt(dt: str) -> date:
+    """Parse a yyyyMMdd string to a ``date``."""
+    return date(int(dt[:4]), int(dt[4:6]), int(dt[6:8]))
+
+
+def _empty_trend() -> FunnelTrendResponse:
+    return FunnelTrendResponse(
+        start_dt=None, end_dt=None, data_status=DataStatus.missing, points=[]
+    )
+
+
+def _build_trend_points(snapshots: list[FunnelSnapshot]) -> list[FunnelTrendPoint]:
+    """Map daily snapshots -> trend points, dropping null-bearing days, then
+    trimming LEADING all-zero (pre-launch) days.
+
+    Conversion math reuses :func:`build_results` so the口径 matches the funnel
+    query exactly. Days with any NULL uv column are omitted (a calendar gap the
+    client renders as a line break); the daily table has no NULLs in practice.
+    Middle/trailing zero days are kept (real zero activity, not pre-launch).
+    """
+    cols = daily_columns()
+    points: list[FunnelTrendPoint] = []
+    for snap in sorted(snapshots, key=lambda s: s.dt):
+        raw = [snap.values.get(col) for col in cols]
+        if any(value is None for value in raw):
+            continue
+        points.append(
+            FunnelTrendPoint(dt=snap.dt, results=build_results([int(v) for v in raw]))
+        )
+    first = 0
+    while first < len(points) and points[first].results[0].uv == 0:
+        first += 1
+    return points[first:]
+
+
+async def run_funnel_trend(
+    repo: FunnelRepository,
+    start_dt: date | None = None,
+    end_dt: date | None = None,
+) -> FunnelTrendResponse:
+    """Run the按日 trend pipeline over the daily table and return the series.
+
+    Range resolution:
+    * ``end`` = ``end_dt`` (capped at yesterday) if given, else the latest
+      available daily ``dt``.
+    * ``start`` = ``start_dt`` if given, else ``end`` - (DEFAULT_TREND_DAYS - 1).
+
+    Leading pre-launch (all-zero) days are trimmed; the response bounds reflect
+    the kept series. No rows / all-zero -> ``missing``.
+    """
+    validate_trend_range(start_dt, end_dt)  # may raise TrendRangeError
+    yesterday = date.today() - timedelta(days=1)
+
+    if end_dt is not None:
+        end = min(end_dt, yesterday)
+    else:
+        latest = await repo.fetch_daily(None)
+        if latest is None:
+            return _empty_trend()
+        end = _parse_dt(latest.dt)
+
+    start = start_dt if start_dt is not None else end - timedelta(days=DEFAULT_TREND_DAYS - 1)
+    if start > end:
+        return _empty_trend()
+
+    snapshots = await repo.fetch_daily_range(
+        start.strftime("%Y%m%d"), end.strftime("%Y%m%d")
+    )
+    points = _build_trend_points(snapshots)
+    if not points:
+        return _empty_trend()
+    return FunnelTrendResponse(
+        start_dt=points[0].dt,
+        end_dt=points[-1].dt,
+        data_status=DataStatus.ready,
+        points=points,
+    )

+ 40 - 3
apps/api/app/services/repository.py

@@ -76,6 +76,26 @@ class SqlAlchemyFunnelRepository:
         values = {col: getattr(row, col) for col in _ROLLING_COLUMNS}
         return FunnelSnapshot(dt=row.dt, values=values)
 
+    async def fetch_daily_range(
+        self, start_dt: str, end_dt: str
+    ) -> list[FunnelSnapshot]:
+        # dt is varchar(8) yyyyMMdd, so lexical comparison == chronological.
+        stmt = (
+            select(AdsTrdGroupFunnelDaily)
+            .where(
+                AdsTrdGroupFunnelDaily.dt >= start_dt,
+                AdsTrdGroupFunnelDaily.dt <= end_dt,
+            )
+            .order_by(AdsTrdGroupFunnelDaily.dt)
+        )
+        rows = (await self._session.execute(stmt)).scalars().all()
+        return [
+            FunnelSnapshot(
+                dt=row.dt, values={col: getattr(row, col) for col in _DAILY_COLUMNS}
+            )
+            for row in rows
+        ]
+
 
 # Cumulative keep-rate down the group-buy funnel: start, show, detail, order, paid.
 _FUNNEL_KEEP = [1.0, 0.82, 0.51, 0.22, 0.18]
@@ -101,8 +121,12 @@ class FakeFunnelRepository:
     so the service reports ``missing``.
     """
 
-    # Number of historical daily rows (ending yesterday).
-    HISTORY_DAYS = 10
+    # Number of historical daily rows (ending yesterday). Long enough for the
+    # 90-day trend window; the oldest PRELAUNCH_DAYS are all-zero (pre-launch).
+    HISTORY_DAYS = 95
+
+    # Oldest all-zero days, so the trend's leading-zero trim is exercised.
+    PRELAUNCH_DAYS = 8
 
     # Top-step (start) daily UV for the most recent day; older days scale down.
     DAILY_TOP = 12000
@@ -122,7 +146,11 @@ class FakeFunnelRepository:
         for offset in range(self.HISTORY_DAYS):
             day = yesterday - timedelta(days=offset)
             dt = day.strftime("%Y%m%d")
-            scale = 1.0 - 0.04 * offset
+            if offset >= self.HISTORY_DAYS - self.PRELAUNCH_DAYS:
+                # Oldest days: pre-launch, all-zero funnel.
+                self._daily[dt] = {col: 0 for col in _DAILY_COLUMNS}
+                continue
+            scale = 1.0 - 0.004 * offset  # gentle decline across the window
             top = int(self.DAILY_TOP * scale)
             self._daily[dt] = {
                 col: uv for col, uv in zip(_DAILY_COLUMNS, _funnel(top))
@@ -149,3 +177,12 @@ class FakeFunnelRepository:
 
     async def fetch_rolling(self) -> FunnelSnapshot | None:
         return FunnelSnapshot(dt=self._rolling_dt, values=dict(self._rolling))
+
+    async def fetch_daily_range(
+        self, start_dt: str, end_dt: str
+    ) -> list[FunnelSnapshot]:
+        return [
+            FunnelSnapshot(dt=dt, values=dict(values))
+            for dt, values in sorted(self._daily.items())
+            if start_dt <= dt <= end_dt
+        ]

+ 9 - 0
apps/api/tests/conftest.py

@@ -55,6 +55,15 @@ class FakeRepository:
     async def fetch_rolling(self) -> FunnelSnapshot | None:
         return self._rolling
 
+    async def fetch_daily_range(
+        self, start_dt: str, end_dt: str
+    ) -> list[FunnelSnapshot]:
+        return [
+            FunnelSnapshot(dt=dt, values=dict(values))
+            for dt, values in sorted(self._daily.items())
+            if start_dt <= dt <= end_dt
+        ]
+
 
 @pytest.fixture
 def repo() -> FakeRepository:

+ 121 - 0
apps/api/tests/test_trend.py

@@ -0,0 +1,121 @@
+"""API-level tests for POST /api/funnels/trend (按日折线).
+
+Repository dependency overridden with the in-memory fake — no Postgres.
+"""
+
+from __future__ import annotations
+
+from datetime import date, timedelta
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+from app.api.funnels import get_repository
+from app.main import app
+from tests.conftest import FakeRepository
+
+
+def _daily(uvs: list[int | None]) -> dict[str, int | None]:
+    keys = ["start", "show", "detail", "order", "paid"]
+    return {f"uv_{k}": uv for k, uv in zip(keys, uvs)}
+
+
+def _dt(days_ago: int) -> str:
+    return (date.today() - timedelta(days=days_ago)).strftime("%Y%m%d")
+
+
+@pytest.fixture
+def client_and_repo():
+    repo = FakeRepository()
+    app.dependency_overrides[get_repository] = lambda: repo
+    transport = ASGITransport(app=app)
+    client = AsyncClient(transport=transport, base_url="http://test")
+    yield client, repo
+    app.dependency_overrides.clear()
+
+
+async def test_trend_default_window_returns_ascending_points(client_and_repo) -> None:
+    client, repo = client_and_repo
+    # 3 active days ending yesterday.
+    for d in (3, 2, 1):
+        repo.set_daily(_dt(d), _daily([1000 - d, 800 - d, 500 - d, 200 - d, 100 - d]))
+    async with client:
+        resp = await client.post("/api/funnels/trend", json={})
+    assert resp.status_code == 200
+    body = resp.json()
+    assert body["data_status"] == "ready"
+    dts = [p["dt"] for p in body["points"]]
+    assert dts == [_dt(3), _dt(2), _dt(1)]  # ascending
+    assert body["start_dt"] == _dt(3)
+    assert body["end_dt"] == _dt(1)
+    # each point carries the fixed 5-step funnel
+    assert all(len(p["results"]) == 5 for p in body["points"])
+
+
+async def test_trend_trims_leading_zero_days(client_and_repo) -> None:
+    client, repo = client_and_repo
+    repo.set_daily(_dt(5), _daily([0, 0, 0, 0, 0]))  # pre-launch
+    repo.set_daily(_dt(4), _daily([0, 0, 0, 0, 0]))  # pre-launch
+    repo.set_daily(_dt(3), _daily([900, 700, 400, 150, 80]))
+    repo.set_daily(_dt(2), _daily([950, 720, 410, 160, 90]))
+    repo.set_daily(_dt(1), _daily([0, 0, 0, 0, 0]))  # mid/trailing zero — kept
+    async with client:
+        resp = await client.post("/api/funnels/trend", json={})
+    body = resp.json()
+    dts = [p["dt"] for p in body["points"]]
+    assert dts == [_dt(3), _dt(2), _dt(1)]  # leading zeros dropped, trailing zero kept
+    assert body["start_dt"] == _dt(3)
+
+
+async def test_trend_conversion_matches_funnel_口径(client_and_repo) -> None:
+    client, repo = client_and_repo
+    repo.set_daily(_dt(1), _daily([1000, 800, 500, 200, 100]))
+    async with client:
+        resp = await client.post("/api/funnels/trend", json={})
+    rows = resp.json()["points"][0]["results"]
+    assert rows[0]["conversion_rate"] is None  # step 1
+    assert rows[1]["conversion_rate"] == pytest.approx(800 / 1000)
+    assert rows[3]["conversion_rate"] == pytest.approx(200 / 500)
+
+
+async def test_trend_custom_range_subsets(client_and_repo) -> None:
+    client, repo = client_and_repo
+    for d in (4, 3, 2, 1):
+        repo.set_daily(_dt(d), _daily([1000, 800, 500, 200, 100]))
+    start = (date.today() - timedelta(days=3)).isoformat()
+    end = (date.today() - timedelta(days=2)).isoformat()
+    async with client:
+        resp = await client.post(
+            "/api/funnels/trend", json={"start_dt": start, "end_dt": end}
+        )
+    dts = [p["dt"] for p in resp.json()["points"]]
+    assert dts == [_dt(3), _dt(2)]
+
+
+async def test_trend_no_data_is_missing(client_and_repo) -> None:
+    client, _ = client_and_repo
+    async with client:
+        resp = await client.post("/api/funnels/trend", json={})
+    body = resp.json()
+    assert body["data_status"] == "missing"
+    assert body["points"] == []
+    assert body["start_dt"] is None and body["end_dt"] is None
+
+
+async def test_trend_future_end_rejected(client_and_repo) -> None:
+    client, _ = client_and_repo
+    today = date.today().isoformat()
+    async with client:
+        resp = await client.post("/api/funnels/trend", json={"end_dt": today})
+    assert resp.status_code == 422
+
+
+async def test_trend_inverted_range_rejected(client_and_repo) -> None:
+    client, _ = client_and_repo
+    start = (date.today() - timedelta(days=1)).isoformat()
+    end = (date.today() - timedelta(days=5)).isoformat()
+    async with client:
+        resp = await client.post(
+            "/api/funnels/trend", json={"start_dt": start, "end_dt": end}
+        )
+    assert resp.status_code == 422

+ 45 - 0
apps/web/src/api/__tests__/funnel.trend.mock.test.ts

@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import { queryFunnelTrend } from '../funnel';
+
+/**
+ * Exercises the real mock implementation of queryFunnelTrend (USE_MOCK defaults
+ * on in tests). Verifies series shape, ascending order, and range honoring.
+ */
+describe('mock queryFunnelTrend — 按日折线', () => {
+  it('default (no bounds) returns ~30 ascending daily points, each a 5-step funnel', async () => {
+    const res = await queryFunnelTrend({});
+    expect(res.data_status).toBe('ready');
+    expect(res.points.length).toBe(30);
+    // ascending yyyyMMdd
+    const dts = res.points.map((p) => p.dt);
+    expect([...dts].sort()).toEqual(dts);
+    expect(res.start_dt).toBe(dts[0]);
+    expect(res.end_dt).toBe(dts[dts.length - 1]);
+    // each point is the fixed 5-step funnel
+    expect(res.points.every((p) => p.results.length === 5)).toBe(true);
+    expect(res.points[0].results.map((r) => r.event_key)).toEqual([
+      'start',
+      'show',
+      'detail',
+      'order',
+      'paid',
+    ]);
+  });
+
+  it('step 1 conversion is null; later steps have a numeric rate', async () => {
+    const res = await queryFunnelTrend({});
+    const first = res.points[0].results;
+    expect(first[0].conversion_rate).toBeNull();
+    expect(typeof first[1].conversion_rate).toBe('number');
+  });
+
+  it('honors an explicit start/end range (inclusive day count)', async () => {
+    const res = await queryFunnelTrend({
+      start_dt: '2026-06-01',
+      end_dt: '2026-06-07',
+    });
+    expect(res.points.length).toBe(7);
+    expect(res.start_dt).toBe('20260601');
+    expect(res.end_dt).toBe('20260607');
+  });
+});

+ 107 - 2
apps/web/src/api/funnel.ts

@@ -4,11 +4,15 @@ import type {
   FunnelQueryRequest,
   FunnelQueryResponse,
   FunnelResultRow,
+  FunnelTrendPoint,
+  FunnelTrendRequest,
+  FunnelTrendResponse,
 } from './types';
-import { FIXED_FUNNEL_STEPS } from '../modules/funnel/period';
+import { FIXED_FUNNEL_STEPS, toSnapshotParam } from '../modules/funnel/period';
 
-/** Endpoint per docs/02 §5. Proxied (Vite) to http://localhost:8000. */
+/** Endpoints per docs/02 §5. Proxied (Vite) to http://localhost:8000. */
 const FUNNEL_QUERY_ENDPOINT = '/api/funnels/query';
+const FUNNEL_TREND_ENDPOINT = '/api/funnels/trend';
 
 /** Mock mode defaults ON until the backend is wired. Set VITE_USE_MOCK=false to hit the real API. */
 export const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false';
@@ -71,6 +75,47 @@ export async function queryFunnel(
   return (await res.json()) as FunnelQueryResponse;
 }
 
+/**
+ * Query the daily UV + conversion trend (按日折线). In mock mode returns a
+ * deterministic series without a network call.
+ */
+export async function queryFunnelTrend(
+  req: FunnelTrendRequest,
+): Promise<FunnelTrendResponse> {
+  if (USE_MOCK) {
+    return mockQueryFunnelTrend(req);
+  }
+
+  let res: Response;
+  try {
+    res = await fetch(FUNNEL_TREND_ENDPOINT, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(req),
+    });
+  } catch (e) {
+    throw new FunnelApiError(
+      `网络请求失败:${e instanceof Error ? e.message : String(e)}`,
+    );
+  }
+
+  if (!res.ok) {
+    let detail = '';
+    try {
+      const body = (await res.json()) as { detail?: string; message?: string };
+      detail = body.detail ?? body.message ?? '';
+    } catch {
+      /* ignore non-JSON error bodies */
+    }
+    throw new FunnelApiError(
+      detail || `查询失败(HTTP ${res.status})`,
+      res.status,
+    );
+  }
+
+  return (await res.json()) as FunnelTrendResponse;
+}
+
 // ---------------------------------------------------------------------------
 // Mock implementation
 // ---------------------------------------------------------------------------
@@ -198,3 +243,63 @@ async function mockQueryFunnel(
     data_status: 'ready',
   };
 }
+
+/** "yyyyMMdd" for a local Date. */
+function ymd(d: Date): string {
+  const y = d.getFullYear();
+  const m = String(d.getMonth() + 1).padStart(2, '0');
+  const day = String(d.getDate()).padStart(2, '0');
+  return `${y}${m}${day}`;
+}
+
+/** Parse "yyyy-MM-dd" to a local-midnight Date. */
+function parseIso(s: string): Date {
+  const [y, m, d] = s.split('-').map(Number);
+  return new Date(y, m - 1, d);
+}
+
+/** Default mock trend window (days), mirrors backend DEFAULT_TREND_DAYS. */
+const MOCK_TREND_DAYS = 30;
+
+async function mockQueryFunnelTrend(
+  req: FunnelTrendRequest,
+): Promise<FunnelTrendResponse> {
+  await new Promise((r) => setTimeout(r, 350));
+
+  if (deriveMockStatus() === 'missing') {
+    return { start_dt: null, end_dt: null, data_status: 'missing', points: [] };
+  }
+
+  // Resolve range: end defaults to yesterday; start defaults 30 days back.
+  const end = req.end_dt ? parseIso(req.end_dt) : parseIso(yesterdayParam());
+  const start = req.start_dt ? parseIso(req.start_dt) : addDays(end, -(MOCK_TREND_DAYS - 1));
+
+  const points: FunnelTrendPoint[] = [];
+  for (const cur = new Date(start); cur <= end; cur.setDate(cur.getDate() + 1)) {
+    const iso = toSnapshotParam(cur); // yyyy-MM-dd, also keys per-day variance
+    points.push({ dt: ymd(cur), results: buildMockRows('day', iso) });
+  }
+  if (points.length === 0) {
+    return { start_dt: null, end_dt: null, data_status: 'missing', points: [] };
+  }
+  return {
+    start_dt: points[0].dt,
+    end_dt: points[points.length - 1].dt,
+    data_status: 'ready',
+    points,
+  };
+}
+
+/** Yesterday as "yyyy-MM-dd". */
+function yesterdayParam(): string {
+  const d = new Date();
+  d.setDate(d.getDate() - 1);
+  return toSnapshotParam(d);
+}
+
+/** A new Date `n` days offset from `d` (local). */
+function addDays(d: Date, n: number): Date {
+  const out = new Date(d);
+  out.setDate(out.getDate() + n);
+  return out;
+}

+ 33 - 0
apps/web/src/api/types.ts

@@ -68,3 +68,36 @@ export interface FunnelQueryResponse {
   results: FunnelResultRow[];
   data_status: DataStatus;
 }
+
+// --------------------------------------------------------------------------- //
+// Trend (按日折线) — docs/02 §5                                                //
+// --------------------------------------------------------------------------- //
+
+/**
+ * POST /api/funnels/trend request body. Both bounds optional & `yyyy-MM-dd`.
+ * Omitted → the 30 most recent available days. `end_dt` must be ≤ yesterday;
+ * `start_dt` ≤ `end_dt`.
+ */
+export interface FunnelTrendRequest {
+  start_dt?: string;
+  end_dt?: string;
+}
+
+/** One day in the trend series: its `dt` (yyyyMMdd) + the full per-step funnel. */
+export interface FunnelTrendPoint {
+  dt: string;
+  results: FunnelResultRow[];
+}
+
+/**
+ * POST /api/funnels/trend response body. `points` ascending by `dt`, leading
+ * pre-launch (all-zero) days trimmed. `start_dt`/`end_dt` are the kept series'
+ * bounds (yyyyMMdd), `null` when missing. Calendar gaps inside the range are
+ * absent points — the client breaks the line, no zero-fill.
+ */
+export interface FunnelTrendResponse {
+  start_dt: string | null;
+  end_dt: string | null;
+  data_status: DataStatus;
+  points: FunnelTrendPoint[];
+}

+ 38 - 70
apps/web/src/modules/funnel/FunnelPage.tsx

@@ -1,48 +1,30 @@
-import { useEffect, useState } from 'react';
-import { useMutation } from '@tanstack/react-query';
-import { queryFunnel, USE_MOCK } from '../../api/funnel';
-import type {
-  FunnelPeriod,
-  FunnelQueryRequest,
-  FunnelQueryResponse,
-} from '../../api/types';
-import { DEFAULT_PERIOD, funnelRangeText, toSnapshotParam } from './period';
-import { TimePeriodSelect } from './components/TimePeriodSelect';
-import { FunnelResult } from './components/FunnelResult';
+import { useState } from 'react';
+import { USE_MOCK } from '../../api/funnel';
+import { FunnelView } from './FunnelView';
+import { TrendView } from './TrendView';
 import { Badge } from '@/components/ui/badge';
+import { cn } from '@/lib/utils';
+
+type Tab = 'funnel' | 'trend';
+
+// 选中态由 React 状态直接驱动 mint 实色,不依赖 shadcn data-active(radix 用
+// data-state,曾导致选中态不可见)。与 TimePeriodSelect 同款 segmented。
+const SEG =
+  'h-8 px-4 inline-flex items-center rounded-md text-sm font-medium transition-colors cursor-pointer';
+const ON = 'bg-primary text-primary-foreground shadow-sm';
+const OFF = 'text-muted-foreground hover:text-foreground hover:bg-background';
 
 /**
- * 拼团 5-step funnel page (docs/02 §5 v3). The funnel is fixed. Inputs are the
- * standard period plus — for `day` only — a historical date. Selecting a period
- * (incl. the default on first load) or, in single-day mode, a date immediately
- * fires a query.
+ * 行为分析 > 漏斗分析页(docs/01 §2)。两视图 Tab:
+ *   漏斗 —— 固定 5 步拼团漏斗快照(单日 / 近7 / 近30)。
+ *   趋势 —— 事件 UV 与转换率的按日折线。
  */
 export function FunnelPage() {
-  const [period, setPeriod] = useState<FunnelPeriod>(DEFAULT_PERIOD);
-  // Selected calendar day for `period: 'day'`. `null` = latest available day
-  // (data is T+1 and may lag, so the default queries MAX(dt) server-side
-  // rather than hard-coding yesterday); a picked date overrides it.
-  const [snapshotDt, setSnapshotDt] = useState<Date | null>(null);
-
-  const mutation = useMutation<FunnelQueryResponse, Error, FunnelQueryRequest>({
-    mutationFn: queryFunnel,
-  });
-
-  const { mutate } = mutation;
-
-  // Query on mount and whenever the period — or, for single-day, the chosen
-  // date — changes. Send snapshot_dt ONLY for `day` with a picked date; omit it
-  // for latest single-day and for 7d/30d (the server returns the latest row).
-  const dayParam =
-    period === 'day' && snapshotDt ? toSnapshotParam(snapshotDt) : undefined;
-  useEffect(() => {
-    mutate(dayParam ? { period: 'day', snapshot_dt: dayParam } : { period });
-  }, [period, dayParam, mutate]);
+  const [tab, setTab] = useState<Tab>('funnel');
 
   return (
     <div className="flex flex-col gap-5 w-full">
-      {/* 标题行:标题 + Mock 徽章 在左,控件 ml-auto 落右;时间边界在控件正下方 */}
-      <div className="flex items-start gap-3 flex-wrap">
+      <div className="flex items-center gap-3 flex-wrap">
         <h2 className="text-xl font-semibold tracking-tight m-0">漏斗分析</h2>
         {USE_MOCK && (
           <Badge
@@ -53,42 +35,28 @@ export function FunnelPage() {
             Mock 模式
           </Badge>
         )}
-        <div className="ml-auto flex flex-col items-end gap-1">
-          {/* 单日 / 近7天 / 近30天 三选一,选中 mint 高亮;单日可选历史。 */}
-          <TimePeriodSelect
-            period={period}
-            snapshotDt={snapshotDt}
-            onPickDate={(d) => {
-              setSnapshotDt(d);
-              setPeriod('day');
-            }}
-            onPickLatest={() => {
-              setSnapshotDt(null);
-              setPeriod('day');
-            }}
-            onPickRolling={(p) => setPeriod(p)}
-          />
-          {/* 日期范围槽:始终占位(固定行高),避免显示/隐藏时挤动下方内容。
-              仅滚动周期且查询完成时填文案;单日 / pending 期间留空但不塌缩
-              (pending 不填,避免用旧 snapshot_dt 配新 period 算出错误范围)。 */}
-          <span className="h-4 text-xs leading-4 text-muted-foreground">
-            {!mutation.isPending && mutation.data?.snapshot_dt && period !== 'day'
-              ? funnelRangeText(period, mutation.data.snapshot_dt)
-              : ''}
-          </span>
+        {/* 漏斗 / 趋势 视图切换 */}
+        <div className="ml-auto inline-flex items-center gap-1 rounded-lg border border-border bg-muted/60 p-1">
+          <button
+            type="button"
+            aria-pressed={tab === 'funnel'}
+            onClick={() => setTab('funnel')}
+            className={cn(SEG, tab === 'funnel' ? ON : OFF)}
+          >
+            漏斗
+          </button>
+          <button
+            type="button"
+            aria-pressed={tab === 'trend'}
+            onClick={() => setTab('trend')}
+            className={cn(SEG, tab === 'trend' ? ON : OFF)}
+          >
+            趋势
+          </button>
         </div>
       </div>
 
-      <p className="text-sm text-muted-foreground m-0">
-        拼团漏斗:启动 → 曝光 → 拼团详情 → 下单 → 成功
-      </p>
-
-      <FunnelResult
-        data={mutation.data}
-        isLoading={mutation.isPending}
-        isError={mutation.isError}
-        errorMessage={mutation.error?.message}
-      />
+      {tab === 'funnel' ? <FunnelView /> : <TrendView />}
     </div>
   );
 }

+ 81 - 0
apps/web/src/modules/funnel/FunnelView.tsx

@@ -0,0 +1,81 @@
+import { useEffect, useState } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { queryFunnel } from '../../api/funnel';
+import type {
+  FunnelPeriod,
+  FunnelQueryRequest,
+  FunnelQueryResponse,
+} from '../../api/types';
+import { DEFAULT_PERIOD, funnelRangeText, toSnapshotParam } from './period';
+import { TimePeriodSelect } from './components/TimePeriodSelect';
+import { FunnelResult } from './components/FunnelResult';
+
+/**
+ * 漏斗视图(漏斗页「漏斗」Tab)。固定 5 步拼团漏斗;输入为标准 period,单日可选
+ * 历史日。选 period(含首次默认)或在单日模式选日期立即触发查询。
+ */
+export function FunnelView() {
+  const [period, setPeriod] = useState<FunnelPeriod>(DEFAULT_PERIOD);
+  // Selected calendar day for `period: 'day'`. `null` = latest available day
+  // (data is T+1 and may lag, so the default queries MAX(dt) server-side
+  // rather than hard-coding yesterday); a picked date overrides it.
+  const [snapshotDt, setSnapshotDt] = useState<Date | null>(null);
+
+  const mutation = useMutation<FunnelQueryResponse, Error, FunnelQueryRequest>({
+    mutationFn: queryFunnel,
+  });
+
+  const { mutate } = mutation;
+
+  // Query on mount and whenever the period — or, for single-day, the chosen
+  // date — changes. Send snapshot_dt ONLY for `day` with a picked date; omit it
+  // for latest single-day and for 7d/30d (the server returns the latest row).
+  const dayParam =
+    period === 'day' && snapshotDt ? toSnapshotParam(snapshotDt) : undefined;
+  useEffect(() => {
+    mutate(dayParam ? { period: 'day', snapshot_dt: dayParam } : { period });
+  }, [period, dayParam, mutate]);
+
+  return (
+    <div className="flex flex-col gap-5 w-full">
+      <div className="flex items-start gap-3 flex-wrap">
+        <p className="text-sm text-muted-foreground m-0 self-center">
+          拼团漏斗:启动 → 曝光 → 拼团详情 → 下单 → 成功
+        </p>
+        <div className="ml-auto flex flex-col items-end gap-1">
+          {/* 单日 / 近7天 / 近30天 三选一,选中 mint 高亮;单日可选历史。 */}
+          <TimePeriodSelect
+            period={period}
+            snapshotDt={snapshotDt}
+            onPickDate={(d) => {
+              setSnapshotDt(d);
+              setPeriod('day');
+            }}
+            onPickLatest={() => {
+              setSnapshotDt(null);
+              setPeriod('day');
+            }}
+            onPickRolling={(p) => setPeriod(p)}
+          />
+          {/* 日期范围槽:始终占位(固定行高),避免显示/隐藏时挤动下方内容。
+              仅滚动周期且查询完成时填文案;单日 / pending 期间留空但不塌缩
+              (pending 不填,避免用旧 snapshot_dt 配新 period 算出错误范围)。 */}
+          <span className="h-4 text-xs leading-4 text-muted-foreground">
+            {!mutation.isPending && mutation.data?.snapshot_dt && period !== 'day'
+              ? funnelRangeText(period, mutation.data.snapshot_dt)
+              : ''}
+          </span>
+        </div>
+      </div>
+
+      <FunnelResult
+        data={mutation.data}
+        isLoading={mutation.isPending}
+        isError={mutation.isError}
+        errorMessage={mutation.error?.message}
+      />
+    </div>
+  );
+}
+
+export default FunnelView;

+ 170 - 0
apps/web/src/modules/funnel/TrendView.tsx

@@ -0,0 +1,170 @@
+import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { AlertCircle, Inbox } from 'lucide-react';
+import type { DateRange } from 'react-day-picker';
+import { queryFunnelTrend } from '../../api/funnel';
+import type { FunnelTrendRequest, FunnelTrendResponse } from '../../api/types';
+import { eachDay, FIXED_FUNNEL_STEPS, formatSnapshotDt, toSnapshotParam } from './period';
+import { formatRate, formatUv } from './format';
+import {
+  TrendRangeSelect,
+  type TrendRangeMode,
+} from './components/TrendRangeSelect';
+import type { TrendSeries } from './components/TrendChart';
+import { Card, CardContent } from '@/components/ui/card';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Skeleton } from '@/components/ui/skeleton';
+
+// ECharts 较重 → 懒加载,与漏斗图共用同一 chunk。
+const TrendChart = lazy(() =>
+  import('./components/TrendChart').then((m) => ({ default: m.TrendChart })),
+);
+
+// 折线配色:UV 5 步 / 转换率 4 段(mint 主色 + 可区分色)。
+const UV_COLORS = ['#04CB94', '#0EA5E9', '#6366F1', '#F59E0B', '#EF4444'];
+const CONV_COLORS = ['#0EA5E9', '#6366F1', '#F59E0B', '#EF4444'];
+
+/**
+ * 趋势视图(漏斗页「趋势」Tab)。事件 UV 按日折线 + 相邻转换率按日折线,共用
+ * 一次 trend 查询。范围:近30天(默认)/ 自定义。缺口断线、不补零。
+ */
+export function TrendView() {
+  const [mode, setMode] = useState<TrendRangeMode>('recent30');
+  const [customRange, setCustomRange] = useState<DateRange | undefined>();
+
+  const mutation = useMutation<FunnelTrendResponse, Error, FunnelTrendRequest>({
+    mutationFn: queryFunnelTrend,
+  });
+  const { mutate } = mutation;
+
+  // mode==='custom' 时 customRange 必为完整区间(onPickCustomRange 同时设两者)。
+  const customFrom =
+    mode === 'custom' && customRange?.from ? toSnapshotParam(customRange.from) : undefined;
+  const customTo =
+    mode === 'custom' && customRange?.to ? toSnapshotParam(customRange.to) : undefined;
+
+  // 首次挂载(近30天)与范围变化时查询。近30天省略 bounds → 后端取最新 30 天。
+  useEffect(() => {
+    if (mode === 'recent30') {
+      mutate({});
+    } else if (customFrom && customTo) {
+      mutate({ start_dt: customFrom, end_dt: customTo });
+    }
+  }, [mode, customFrom, customTo, mutate]);
+
+  const chart = useMemo(() => {
+    const data = mutation.data;
+    if (!data || data.data_status !== 'ready' || !data.start_dt || !data.end_dt) {
+      return null;
+    }
+    // 连续日期轴:API 省略的缺口日填 null → 断线(不补零)。
+    const days = eachDay(data.start_dt, data.end_dt);
+    const byDt = new Map(data.points.map((p) => [p.dt, p]));
+    const categories = days.map(formatSnapshotDt);
+
+    const uvSeries: TrendSeries[] = FIXED_FUNNEL_STEPS.map((step, i) => ({
+      name: step.name,
+      color: UV_COLORS[i],
+      data: days.map((dt) => byDt.get(dt)?.results[i]?.uv ?? null),
+    }));
+    // 相邻转换率 = 进入该步的转化(results[i+1].conversion_rate),取步骤 2..5。
+    const convSeries: TrendSeries[] = FIXED_FUNNEL_STEPS.slice(1).map((step, i) => ({
+      name: `${step.name}率`,
+      color: CONV_COLORS[i],
+      data: days.map((dt) => byDt.get(dt)?.results[i + 1]?.conversion_rate ?? null),
+    }));
+    return { categories, uvSeries, convSeries };
+  }, [mutation.data]);
+
+  return (
+    <div className="flex flex-col gap-5 w-full">
+      <div className="flex items-start gap-3 flex-wrap">
+        <div className="flex flex-col gap-1">
+          <h3 className="text-base font-semibold tracking-tight m-0">按日趋势</h3>
+          <p className="text-sm text-muted-foreground m-0">
+            事件 UV 与相邻转换率的每日变化
+          </p>
+        </div>
+        <div className="ml-auto flex flex-col items-end gap-1">
+          <TrendRangeSelect
+            mode={mode}
+            customRange={customRange}
+            onPickRecent30={() => setMode('recent30')}
+            onPickCustomRange={(from, to) => {
+              setCustomRange({ from, to });
+              setMode('custom');
+            }}
+          />
+          {/* 范围文案:始终占位固定行高,避免显示/隐藏挤动下方内容。 */}
+          <span className="h-4 text-xs leading-4 text-muted-foreground">
+            {!mutation.isPending && mutation.data?.start_dt && mutation.data?.end_dt
+              ? `${formatSnapshotDt(mutation.data.start_dt)} ~ ${formatSnapshotDt(mutation.data.end_dt)}`
+              : ''}
+          </span>
+        </div>
+      </div>
+
+      {mutation.isError ? (
+        <Alert variant="destructive">
+          <AlertCircle className="size-4" />
+          <AlertTitle>查询失败</AlertTitle>
+          <AlertDescription>
+            {mutation.error?.message ?? '请求趋势接口时发生错误,请稍后重试。'}
+          </AlertDescription>
+        </Alert>
+      ) : mutation.isPending || !mutation.data ? (
+        <div className="flex flex-col gap-5" data-testid="trend-loading">
+          <Skeleton className="h-[300px] w-full" />
+          <Skeleton className="h-[300px] w-full" />
+        </div>
+      ) : !chart ? (
+        <Card>
+          <CardContent className="flex flex-col items-center justify-center gap-3 py-12 text-center">
+            <Inbox className="size-10 text-muted-foreground/60" />
+            <p className="text-sm text-muted-foreground m-0">
+              数据缺失:所选范围暂无产出数据,未做补零处理。
+            </p>
+          </CardContent>
+        </Card>
+      ) : (
+        <Suspense
+          fallback={
+            <div className="flex flex-col gap-5">
+              <Skeleton className="h-[300px] w-full" />
+              <Skeleton className="h-[300px] w-full" />
+            </div>
+          }
+        >
+          <Card>
+            <CardContent className="pt-5">
+              <h4 className="text-sm font-medium text-muted-foreground m-0 mb-1 px-1">
+                事件 UV
+              </h4>
+              <TrendChart
+                categories={chart.categories}
+                series={chart.uvSeries}
+                valueFormatter={formatUv}
+                testId="trend-chart-uv"
+              />
+            </CardContent>
+          </Card>
+          <Card>
+            <CardContent className="pt-5">
+              <h4 className="text-sm font-medium text-muted-foreground m-0 mb-1 px-1">
+                相邻转换率
+              </h4>
+              <TrendChart
+                categories={chart.categories}
+                series={chart.convSeries}
+                valueFormatter={(v) => formatRate(v)}
+                testId="trend-chart-conversion"
+              />
+            </CardContent>
+          </Card>
+        </Suspense>
+      )}
+    </div>
+  );
+}
+
+export default TrendView;

+ 145 - 0
apps/web/src/modules/funnel/__tests__/TrendView.test.tsx

@@ -0,0 +1,145 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { TrendView } from '../TrendView';
+import { FunnelPage } from '../FunnelPage';
+import { FIXED_FUNNEL_STEPS } from '../period';
+import type {
+  FunnelQueryResponse,
+  FunnelResultRow,
+  FunnelTrendPoint,
+  FunnelTrendResponse,
+} from '../../../api/types';
+
+const trendMock = vi.fn();
+const funnelMock = vi.fn();
+vi.mock('../../../api/funnel', () => ({
+  USE_MOCK: true,
+  queryFunnel: (...a: unknown[]) => funnelMock(...a),
+  queryFunnelTrend: (...a: unknown[]) => trendMock(...a),
+}));
+
+function point(dt: string, uvs: number[]): FunnelTrendPoint {
+  const results: FunnelResultRow[] = FIXED_FUNNEL_STEPS.map((s, i) => {
+    const conv = i === 0 || uvs[i - 1] === 0 ? null : uvs[i] / uvs[i - 1];
+    return {
+      step_index: i + 1,
+      name: s.name,
+      event_key: s.event_key,
+      uv: uvs[i],
+      conversion_rate: conv,
+      dropoff_rate: conv == null ? null : 1 - conv,
+    };
+  });
+  return { dt, results };
+}
+
+function readyTrend(): FunnelTrendResponse {
+  return {
+    start_dt: '20260619',
+    end_dt: '20260621',
+    data_status: 'ready',
+    points: [
+      point('20260619', [1000, 800, 500, 200, 100]),
+      point('20260620', [1100, 900, 560, 230, 120]),
+      point('20260621', [1050, 860, 540, 210, 110]),
+    ],
+  };
+}
+
+function readyFunnel(): FunnelQueryResponse {
+  return {
+    period: 'day',
+    snapshot_dt: '20260621',
+    data_status: 'ready',
+    results: point('20260621', [1000, 800, 500, 200, 100]).results,
+  };
+}
+
+function renderEl(el: React.ReactElement) {
+  const client = new QueryClient({
+    defaultOptions: { queries: { retry: false } },
+  });
+  return render(
+    <QueryClientProvider client={client}>{el}</QueryClientProvider>,
+  );
+}
+
+describe('TrendView — 按日折线', () => {
+  beforeEach(() => {
+    trendMock.mockReset();
+    funnelMock.mockReset();
+  });
+
+  it('auto-queries 近30天 (empty body) on mount', async () => {
+    trendMock.mockResolvedValue(readyTrend());
+    renderEl(<TrendView />);
+    await waitFor(() => expect(trendMock).toHaveBeenCalledTimes(1));
+    expect(trendMock.mock.calls[0][0]).toEqual({}); // 近30天 → omit bounds
+  });
+
+  it('ready renders both UV and conversion line charts + range caption', async () => {
+    trendMock.mockResolvedValue(readyTrend());
+    renderEl(<TrendView />);
+    await waitFor(() =>
+      expect(screen.getByTestId('trend-chart-uv')).toBeInTheDocument(),
+    );
+    expect(screen.getByTestId('trend-chart-conversion')).toBeInTheDocument();
+    expect(
+      screen.getByText(/2026-06-19 ~ 2026-06-21/),
+    ).toBeInTheDocument();
+  });
+
+  it('missing renders 数据缺失 without charts', async () => {
+    trendMock.mockResolvedValue({
+      start_dt: null,
+      end_dt: null,
+      data_status: 'missing',
+      points: [],
+    });
+    renderEl(<TrendView />);
+    await waitFor(() => expect(screen.getByText(/数据缺失/)).toBeInTheDocument());
+    expect(screen.queryByTestId('trend-chart-uv')).not.toBeInTheDocument();
+  });
+
+  it('transport error renders the error state', async () => {
+    trendMock.mockRejectedValue(new Error('网络请求失败'));
+    renderEl(<TrendView />);
+    await waitFor(() =>
+      expect(screen.getByText('查询失败')).toBeInTheDocument(),
+    );
+  });
+});
+
+describe('FunnelPage — 漏斗 / 趋势 Tab', () => {
+  beforeEach(() => {
+    trendMock.mockReset();
+    funnelMock.mockReset();
+    funnelMock.mockResolvedValue(readyFunnel());
+    trendMock.mockResolvedValue(readyTrend());
+  });
+
+  it('defaults to 漏斗 tab (funnel queried, trend not)', async () => {
+    renderEl(<FunnelPage />);
+    await waitFor(() => expect(funnelMock).toHaveBeenCalled());
+    expect(trendMock).not.toHaveBeenCalled();
+    // funnel control present
+    expect(
+      screen.getByRole('button', { name: '选择历史日期' }),
+    ).toBeInTheDocument();
+  });
+
+  it('clicking 趋势 switches to the trend view and queries the trend', async () => {
+    const user = userEvent.setup();
+    renderEl(<FunnelPage />);
+    await waitFor(() => expect(funnelMock).toHaveBeenCalled());
+
+    await user.click(screen.getByRole('button', { name: '趋势' }));
+    await waitFor(() => expect(trendMock).toHaveBeenCalled());
+    expect(trendMock.mock.calls[0][0]).toEqual({});
+    expect(
+      await screen.findByRole('button', { name: '近 30 天' }),
+    ).toBeInTheDocument();
+  });
+});

+ 93 - 0
apps/web/src/modules/funnel/components/TrendChart.tsx

@@ -0,0 +1,93 @@
+import ReactECharts from 'echarts-for-react';
+import type { EChartsOption } from 'echarts';
+
+export interface TrendSeries {
+  name: string;
+  color: string;
+  /** One value per category; `null` = no data that day → line breaks (no fill). */
+  data: (number | null)[];
+}
+
+interface Props {
+  /** X-axis day labels (yyyy-MM-dd), ascending. */
+  categories: string[];
+  series: TrendSeries[];
+  /** Format a value for tooltip + y-axis (e.g. UV thousands or "%"). */
+  valueFormatter: (v: number) => string;
+  testId: string;
+}
+
+/**
+ * 通用多系列折线图(趋势)。缺口以 `null` 表示并断线(`connectNulls:false`,不补零),
+ * 与漏斗"数据缺失不补零"一致。图例可点开关。ECharts 较重 → 由调用处懒加载。
+ */
+export function TrendChart({ categories, series, valueFormatter, testId }: Props) {
+  const option: EChartsOption = {
+    backgroundColor: 'transparent',
+    textStyle: {
+      fontFamily:
+        'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif',
+      color: '#334155',
+    },
+    grid: { left: 8, right: 16, top: 36, bottom: 8, containLabel: true },
+    legend: { type: 'scroll', top: 0, textStyle: { fontSize: 12 }, itemWidth: 18 },
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: 'rgba(15, 23, 42, 0.95)',
+      borderWidth: 0,
+      padding: [10, 12],
+      textStyle: { color: '#f8fafc', fontSize: 12, lineHeight: 18 },
+      extraCssText: 'border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.18);',
+      formatter: (params) => {
+        const arr = params as unknown as Array<{
+          axisValue: string;
+          seriesName: string;
+          value: number | null;
+          marker: string;
+        }>;
+        if (!arr.length) return '';
+        const head = `<div style="font-weight:600;margin-bottom:4px">${arr[0].axisValue}</div>`;
+        const lines = arr
+          .map(
+            (p) =>
+              `<div>${p.marker}${p.seriesName} <b>${p.value == null ? '—' : valueFormatter(p.value)}</b></div>`,
+          )
+          .join('');
+        return head + lines;
+      },
+    },
+    xAxis: {
+      type: 'category',
+      data: categories,
+      boundaryGap: false,
+      axisLabel: { fontSize: 11, hideOverlap: true },
+      axisTick: { alignWithLabel: true },
+    },
+    yAxis: {
+      type: 'value',
+      axisLabel: { fontSize: 11, formatter: (v: number) => valueFormatter(v) },
+      splitLine: { lineStyle: { color: '#eef2f6' } },
+    },
+    series: series.map((s) => ({
+      name: s.name,
+      type: 'line',
+      data: s.data,
+      connectNulls: false,
+      showSymbol: false,
+      lineStyle: { width: 2 },
+      itemStyle: { color: s.color },
+      emphasis: { focus: 'series' },
+    })),
+  };
+
+  return (
+    <div className="px-1 py-2">
+      <ReactECharts
+        option={option}
+        style={{ height: 320, width: '100%' }}
+        notMerge
+        data-testid={testId}
+      />
+    </div>
+  );
+}

+ 91 - 0
apps/web/src/modules/funnel/components/TrendRangeSelect.tsx

@@ -0,0 +1,91 @@
+import { useState } from 'react';
+import { CalendarIcon } from 'lucide-react';
+import type { DateRange } from 'react-day-picker';
+import { toSnapshotParam, yesterday } from '../period';
+import { Calendar } from '@/components/ui/calendar';
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/popover';
+import { cn } from '@/lib/utils';
+
+export type TrendRangeMode = 'recent30' | 'custom';
+
+interface Props {
+  mode: TrendRangeMode;
+  /** Current custom range (when mode === 'custom'). */
+  customRange: DateRange | undefined;
+  onPickRecent30: () => void;
+  /** Fires once a complete from–to range is chosen. */
+  onPickCustomRange: (from: Date, to: Date) => void;
+}
+
+// 与 TimePeriodSelect 同款 segmented:选中态 mint 实色,React 状态直接驱动。
+const SEG =
+  'h-8 px-3 inline-flex items-center gap-1.5 rounded-md text-sm font-medium transition-colors cursor-pointer';
+const ON = 'bg-primary text-primary-foreground shadow-sm';
+const OFF = 'text-muted-foreground hover:text-foreground hover:bg-background';
+
+/**
+ * 趋势时间范围 — 近30天(默认) / 自定义起止 两选(docs/02 §9)。自定义用日历
+ * range,上限锁最新可用日(昨日,T+1);选满 from–to 即触发查询。
+ */
+export function TrendRangeSelect({
+  mode,
+  customRange,
+  onPickRecent30,
+  onPickCustomRange,
+}: Props) {
+  const max = yesterday();
+  const [open, setOpen] = useState(false);
+
+  const customLabel =
+    customRange?.from && customRange?.to
+      ? `${toSnapshotParam(customRange.from)} ~ ${toSnapshotParam(customRange.to)}`
+      : '自定义';
+
+  return (
+    <div className="inline-flex items-center gap-1 rounded-lg border border-border bg-muted/60 p-1">
+      <button
+        type="button"
+        aria-pressed={mode === 'recent30'}
+        onClick={onPickRecent30}
+        className={cn(SEG, mode === 'recent30' ? ON : OFF)}
+      >
+        近 30 天
+      </button>
+
+      <Popover open={open} onOpenChange={setOpen}>
+        <PopoverTrigger asChild>
+          <button
+            type="button"
+            aria-label="选择自定义范围"
+            aria-pressed={mode === 'custom'}
+            className={cn(SEG, mode === 'custom' ? ON : OFF)}
+          >
+            <CalendarIcon className="size-4" />
+            <span className="tabular-nums">{customLabel}</span>
+          </button>
+        </PopoverTrigger>
+        <PopoverContent className="w-auto p-0" align="end">
+          <Calendar
+            mode="range"
+            numberOfMonths={2}
+            selected={customRange}
+            defaultMonth={customRange?.from ?? max}
+            disabled={{ after: max }}
+            onSelect={(r) => {
+              if (r?.from && r?.to) {
+                onPickCustomRange(r.from, r.to);
+                setOpen(false);
+              }
+            }}
+          />
+        </PopoverContent>
+      </Popover>
+    </div>
+  );
+}
+
+export default TrendRangeSelect;

+ 20 - 0
apps/web/src/modules/funnel/period.ts

@@ -61,3 +61,23 @@ export function toSnapshotParam(d: Date): string {
   const day = String(d.getDate()).padStart(2, '0');
   return `${y}-${m}-${day}`;
 }
+
+/**
+ * Inclusive list of "yyyyMMdd" days from `startDt` to `endDt` (both yyyyMMdd).
+ * Used to build a CONTINUOUS trend x-axis so calendar gaps (days the API
+ * omits) become `null` points and break the line — no zero-fill. Empty when
+ * bounds are malformed or inverted.
+ */
+export function eachDay(startDt: string, endDt: string): string[] {
+  if (!/^\d{8}$/.test(startDt) || !/^\d{8}$/.test(endDt)) return [];
+  const out: string[] = [];
+  const end = parseSnapshot(endDt);
+  for (let d = parseSnapshot(startDt); d <= end; d.setDate(d.getDate() + 1)) {
+    out.push(
+      `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(
+        d.getDate(),
+      ).padStart(2, '0')}`,
+    );
+  }
+  return out;
+}

+ 35 - 1
docs/02-技术架构.md

@@ -114,6 +114,40 @@ POST /api/funnels/query
 - 相邻转化率 = `uv[i]/uv[i-1]`,流失率 = `1-转化率`;`uv[i-1]==0` 时为 `null`。
 - `data_status` ∈ `ready` | `missing`(目标行不存在或对应列为空时 `missing`,不补零)。
 
+### 5.1 趋势 API(按日折线)
+
+漏斗页「趋势」Tab 用:同一批 5 个事件的**按日**时间序列(事件 UV 折线 + 相邻转换率折线)。数据源就是 `ads_trd_group_funnel_daily`(天然时序),**无需新表/ETL**。
+
+```text
+POST /api/funnels/trend
+```
+
+请求字段(均可选,`yyyy-MM-dd`):
+
+```json
+{ "start_dt": "2026-05-23", "end_dt": "2026-06-21" }
+```
+
+- 都省略 → **最新可用日往前 30 天**(`近30天`,相对最新可用日而非今天,因数据滞后)。
+- `end_dt` 上限昨日(T+1),今天/未来 → 422;`start_dt > end_dt` → 422。
+
+响应字段:
+
+```json
+{
+  "start_dt": "20260523",
+  "end_dt": "20260621",
+  "data_status": "ready",
+  "points": [
+    { "dt": "20260523", "results": [ /* 同 §5 的 5 步 FunnelStepResult */ ] }
+  ]
+}
+```
+
+- `points` 按 `dt` 升序;每个点复用 §5 的 `results`(uv + conversion 服务端算,**口径与漏斗页完全一致**)。
+- **前导全 0 天(上线前爬坡)裁掉**;`start_dt`/`end_dt` 反映裁剪后的实际首尾,无数据时为 `null` + `missing`。
+- 范围内**缺口日**(ETL 漏跑)= 直接缺该 `dt` 点;前端按连续日期轴补 `null` **断线**(不补零)。
+
 ## 6. 计算策略
 
 ### MVP(拼团漏斗,v3)
@@ -174,7 +208,7 @@ MVP 不引入:
 
 - 布局壳:统一外框(顶栏 + 左侧 L1 导航 + 内容区),各模块挂在内容区。
 - **三级路由**:L1 能力域一级路由;有可用模块的域下挂 L2(分析模块)、L3(报表/实例)。
-  - MVP 仅 `行为分析 > 漏斗分析 > 拼团漏斗` 一条 L1→L2→L3 链渲染完整分析页(当前实现路由 `/funnel` 即此 L3,后续可规整为 `/behavior/funnel/group`)。
+  - MVP 仅 `行为分析 > 漏斗分析 > 拼团漏斗` 一条 L1→L2→L3 链渲染完整分析页(当前实现路由 `/funnel` 即此 L3,后续可规整为 `/behavior/funnel/group`)。该 L3 页内含 **`漏斗 / 趋势` 两视图 Tab**:漏斗=固定 5 步快照(§5);趋势=事件 UV 与相邻转换率的按日折线(§5.1)。
   - 其余 L1/L2 路由渲染统一的"待开发"占位组件。
 - 占位机制:非可用模块复用同一占位组件(`src/modules/placeholder/`),文案统一"待开发",可正常进入、不报错、不空白。
 - L2 子菜单(域内多模块)与 L3 报表列表后续按路线图补;当前 MVP 导航可先平铺 7 个 L1 + 漏斗页,不强求展开全部 L2。

+ 62 - 1
docs/06-接口文档.md

@@ -6,7 +6,7 @@
 | 项 | 内容 |
 |----|----|
 | 阶段 | MVP 开发中(未发布) |
-| 更新日期 | 2026-06-25 |
+| 更新日期 | 2026-06-26 |
 | Base URL(本地) | `http://localhost:8000` |
 | 鉴权 | MVP 暂无(内网访问);对外开放前需加鉴权,见 `docs/01` §7 |
 
@@ -106,3 +106,64 @@ curl ... -d '{"period":"last_7d"}'
 # 今天 → 422
 curl ... -d '{"period":"day","snapshot_dt":"2026-06-25"}'
 ```
+
+---
+
+## 3. 拼团漏斗趋势(按日折线)
+
+```text
+POST /api/funnels/trend
+Content-Type: application/json
+```
+
+同一批 5 个事件的**按日**时间序列(事件 UV + 相邻转换率),供漏斗页「趋势」Tab。数据源 `ads_trd_group_funnel_daily`(天然时序)。
+
+### 3.1 请求
+
+| 字段 | 类型 | 必填 | 说明 |
+|----|----|----|----|
+| `start_dt` | string `YYYY-MM-DD` | 否 | 起始(含)。省略=`end_dt` 往前 29 天 |
+| `end_dt` | string `YYYY-MM-DD` | 否 | 结束(含)。省略=最新可用日。**上限昨日**,今天/未来 → 422 |
+
+都省略 → **最新可用日往前 30 天**。`start_dt > end_dt` → 422。
+
+```json
+{ "start_dt": "2026-05-23", "end_dt": "2026-06-21" }
+```
+
+### 3.2 响应 `200`
+
+| 字段 | 类型 | 说明 |
+|----|----|----|
+| `start_dt` | string `yyyyMMdd` \| null | 返回序列首日(裁前导 0 后);`missing` 时 `null` |
+| `end_dt` | string `yyyyMMdd` \| null | 返回序列末日;`missing` 时 `null` |
+| `data_status` | string 枚举 | `ready` / `missing` |
+| `points` | array | 按 `dt` 升序;每项 `{ dt, results }`,`results` 同 §2.2(5 步,口径一致) |
+
+```json
+{
+  "start_dt": "20260523",
+  "end_dt": "20260621",
+  "data_status": "ready",
+  "points": [
+    { "dt": "20260523", "results": [ /* 5 步 FunnelStepResult,同 §2.2 */ ] }
+  ]
+}
+```
+
+- **前导全 0 天(上线前爬坡)裁掉**;范围内**缺口日**(ETL 漏跑)直接缺该 `dt` 点 → 前端断线、不补零。
+- 整段无产出 → `data_status=missing`、`points=[]`、首尾为 `null`。
+
+### 3.3 错误 `422`
+
+- `end_dt` 为今天或未来(T+1)。
+- `start_dt > end_dt`。
+
+### 3.4 示例
+
+```bash
+# 近 30 天(默认)
+curl -X POST http://localhost:8000/api/funnels/trend -H 'Content-Type: application/json' -d '{}'
+# 自定义范围
+curl ... -d '{"start_dt":"2026-05-23","end_dt":"2026-06-21"}'
+```

+ 10 - 4
infra/look.sh

@@ -36,9 +36,15 @@ cp -r apps/web/dist/. "$WT"/
 )
 git worktree remove -f "$WT"
 
-echo "==> 4/5 服务器拉取(git pull,非 scp)+ 确保后端在跑"
-# 拉前端产物(uvicorn 以 STATIC_DIR 实时读盘,无需重启);确保 uvicorn 存活。
-# 注:后端源码(~/hs-api)更新需手动 git pull + 重启 uvicorn(见 docs/02 §部署)。
-ssh -o BatchMode=yes "$SERVER" "cd $SERVER_DIR && GIT_TERMINAL_PROMPT=0 git pull -q && bash \$HOME/serve-hs-api.sh && echo pulled-ok"
+echo "==> 4/5 服务器拉取(git pull,非 scp)+ 后端按需重启"
+# 前端产物:uvicorn 以 STATIC_DIR 实时读盘,纯前端改动无需重启(不抖)。
+# 后端源码(~/hs-api):仅当 HEAD 变化才重启 uvicorn(新依赖需手动 pip,少见)。
+ssh -o BatchMode=yes "$SERVER" '
+  cd $HOME/hs-data && GIT_TERMINAL_PROMPT=0 git pull -q
+  cd $HOME/hs-api && before=$(git rev-parse HEAD) && GIT_TERMINAL_PROMPT=0 git pull -q && after=$(git rev-parse HEAD)
+  if [ "$before" != "$after" ]; then echo "后端有更新 → 重启 uvicorn"; pkill -f "uvicorn app.main:app" || true; sleep 1; fi
+  bash $HOME/serve-hs-api.sh
+  echo pulled-ok
+'
 
 echo "==> 5/5 完成 → $URL"