Bladeren bron

feat(web): 醒目时间边界条 + README 更新

- 漏斗页标题下方加"数据范围"条(带边框/图标):单日显示数据日期,
  近7/30天显示起~止区间,显式标注"数据 T+1,不含今日";原埋在卡片
  底部的小灰字 caption 移除。period.ts 加 funnelRangeText。
- README 更新到最新:拼团漏斗 MVP、shadcn 主题、5 个 L1 IA、两表数据源、
  可运行命令、分支模型、CentOS7 部署说明。
- Vitest 35 passed、build clean。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tianyu.chu 1 maand geleden
bovenliggende
commit
f6e0b24923

+ 6 - 0
CHANGELOG.md

@@ -4,6 +4,12 @@
 
 ## 2026-06-25
 
+### 修复
+- **时间边界文案不可见**(`apps/web`):原"数据快照日/数据截至"小灰字埋在结果卡片**最底部**,用户感知不到漏斗覆盖的时间范围。改为在标题区下方放**醒目的"数据范围"条**(带边框 + 日历图标):单日「数据日期 YYYY-MM-DD」、近 7/30 天「近 N 天 起 ~ 止」,并显式标注「数据 T+1,不含今日」。`period.ts` 加 `funnelRangeText`;`FunnelResult` 去掉底部 caption(`period` prop 移除)。
+
+### 变更
+- **README 更新到最新进度**:拼团漏斗 MVP、shadcn 主题、五个 L1 IA、两表数据源、真实可运行命令、分支模型、CentOS7 服务器部署说明(conda 或静态托管)。
+
 ### 新增
 - **远程开发准备**:新增 `.gitattributes`(文本统一 LF,避免 Windows CRLF 污染 Linux 服务器)与 `infra/setup.sh`(Debian/Ubuntu 一键装 Node20/pnpm/Python3.11 + 依赖 + 启动说明)。首个 git commit 落地(此前仅 `git init`,0 提交)。
 

+ 83 - 36
README.md

@@ -1,60 +1,107 @@
-# hs-data 内部数据服务平台 (hs-data)
+# hs-data 内部数据平台
 
-数仓行为数据的可视化自助分析平台 · 五大能力域 · MVP 先交付泛用 UV 漏斗
+面向内部的一站式数据平台门户。**MVP 已端到端交付一个可用模块:拼团漏斗**(`行为分析 > 漏斗分析 > 拼团漏斗`),其余能力域进导航、出"待开发"占位
 
-> 完整需求见 [docs/01-产品需求.md](docs/01-产品需求-MVP.md);
-> 开发约定(技术栈、工程铁律、留痕规范)见 [docs/05-agent协作准则.md](docs/05-agent协作准则.md)(新项目根 `CLAUDE.md` 蓝本)。
+> 产品与信息架构见 [docs/01](docs/01-产品需求-MVP.md);技术架构与契约见 [docs/02](docs/02-技术架构.md) / [docs/03](docs/03-数据契约.md);视觉设计定稿见 [docs/04](docs/04-设计规范.md);协作约定见 [docs/05](docs/05-agent协作准则.md) 与根 [CLAUDE.md](CLAUDE.md)。改动留痕见 [CHANGELOG.md](CHANGELOG.md)。
+
+## 能力域(信息架构,5 个 L1)
+
+| L1 能力域 | 内含(L2) | 状态 |
+|---|---|---|
+| **行为分析** | 漏斗分析(**拼团漏斗**)、留存/路径/事件/分布/归因 | **拼团漏斗可用**,余待开发 |
+| 指标体系 | 指标目录 / 指标大盘 / 指标监控 | 待开发 |
+| 画像体系 | 用户画像 / 商家画像 / 产品画像 | 待开发 |
+| 数据看板 | 业务大盘 / 实时大盘 / 关键报表 | 待开发 |
+| 营销触达 | 触达规则 / 触达记录 / 触达效果 | 待开发 |
+
+导航为三级 IA(L1 能力域 → L2 模块 → L3 报表),拼团漏斗即 L3。详见 docs/01 §2。
 
 ## Monorepo 结构
 
 ```
 .
 ├── apps/
-│   ├── web/               # 前端 (React + Vite · 平台框架 + 漏斗模块)
-│   └── api/               # 后端 (Python + FastAPI · 只读查询服务)
-│       └── modules/       # 按能力域分模块:funnel /(后续 retention / path / profile / realtime / touch)
+│   ├── web/            # 前端(React 19 + Vite + Tailwind v4 + shadcn/ui + ECharts)
+│   └── api/            # 后端(FastAPI · 只读查询服务)
 ├── packages/
-│   └── api-types/         # 由后端 OpenAPI 生成的 TS 类型(前端共享,勿手改)
-├── docs/                  # 产品 / 技术 / 数据契约 / 设计 / 协作 / ADR
-├── infra/                 # 本地开发与部署配置
-└── CLAUDE.md              # 给 AI 与人的开发硬约束
+│   └── api-types/      # 由后端 OpenAPI 生成的 TS 类型(勿手改)
+├── docs/               # 产品 / 技术 / 数据契约 / 设计 / 协作
+├── infra/              # docker-compose(PG)、setup.sh(服务器装环境)
+├── CLAUDE.md           # 给 AI 与人的开发硬约束
+└── CHANGELOG.md
 ```
 
 ## 技术栈
 
 | 层 | 选型 |
 |----|------|
-| 前端 | React 19 + Vite + TypeScript + Ant Design 5 + ECharts + TanStack Query |
-| 后端 | Python 3.11+ + FastAPI + Pydantic v2 |
-| 数据 | PostgreSQL 16 · roaring bitmap (`bytea` · pyroaring) |
-| 契约 | 后端 OpenAPI → 前端生成 TS 类型;DB 迁移走 Alembic |
-| 部署 | Docker |
+| 前端 | React 19 + Vite + TypeScript + **Tailwind v4 + shadcn/ui**(神策风 mint 主题)+ ECharts + TanStack Query |
+| 后端 | Python 3.11+ + FastAPI + Pydantic v2 + SQLAlchemy(async) |
+| 数据 | PostgreSQL 16 · **拼团漏斗预聚合两表**(`ads_trd_group_funnel_daily` / `_rolling`,见 docs/03 §11) |
+| 契约 | 后端 OpenAPI → 前端生成 TS 类型(`pnpm gen:api-types`) |
 
-详见 [docs/02-技术架构.md](docs/02-技术架构.md)
+> 注:`pnpm` 用 corepack 调用(`corepack pnpm@10 …`)即可,无需全局安装
 
-## 快速开始
+## 快速开始(本地开发)
 
-前置:Node ≥ 20、pnpm 10、Python ≥ 3.11、PostgreSQL 16
+前置:Node ≥ 20、Python ≥ 3.11(后端,可选)
 
 ```bash
-# TODO: 待 Monorepo 脚手架建立后填入
-pnpm install                 # 安装前端依赖
-pnpm dev:web                 # 启动前端
-pnpm gen:api-types           # 由后端 OpenAPI 生成 TS 类型
-# 后端
-cd apps/api && uvicorn app.main:app --reload
-alembic upgrade head         # DB 迁移
-pytest                       # 后端测试
+# 前端(默认 mock 数据,无需后端即可看页面)
+corepack pnpm@10 install
+corepack pnpm@10 --filter @hs-data/web dev        # http://localhost:5173
+
+# 后端(可选;USE_FAKE_DATA 默认 true,无需 DB 即可起)
+cd apps/api
+python -m venv .venv && .venv/Scripts/python -m pip install -e ".[dev]"   # Win
+.venv/Scripts/uvicorn app.main:app --reload --port 8000                    # http://localhost:8000/docs
+
+# 接真实 Postgres(可选)
+docker compose -f infra/docker-compose.yml up -d
+cd apps/api && alembic upgrade head && python -m scripts.seed
+# 然后 apps/api/.env 设 USE_FAKE_DATA=false 再起后端
+
+# 让前端连真后端:apps/web/.env 设 VITE_USE_MOCK=false
+
+# 测试
+corepack pnpm@10 --filter @hs-data/web test       # Vitest
+cd apps/api && .venv/Scripts/pytest -q             # pytest
 ```
 
-## 平台能力(五大能力域)
+数据为 **T+1**(最大可查日为昨日):单日支持历史回溯;近 7/30 天为滚动窗口(只有最新 as-of)。详见 docs/02 §5(v3)、docs/03 §11。
+
+## 分支模型与协作
+
+开发在本地(Windows),通过 git 与远程互通。分支模型:
 
-| 能力域 | MVP 状态 |
-|--------|----------|
-| 漏斗 | **可用(MVP)** |
-| 埋点完整(留存 / 路径) | 待开发 |
-| 用户画像(标签 / 人群包) | 待开发 |
-| 实时(实时大盘) | 待开发 |
-| 营销触达 | 待开发 |
+| 分支 | 定位 | 保护 | 合并来源 |
+|---|---|---|---|
+| `master` | 稳定版本归档 | 是 | release(打 Tag) |
+| `release` | 线上运行版本 | 是 | feature |
+| `feature` | 公共开发分支 | 是 | feature-xxx(PR + Review) |
+| `feature-xxx` | 个人开发分支(`feature-<人>-<模块>-<日期>`) | 否 | —— |
+
+日常:从 `feature` 切个人分支 → 改 → push → **PR 合入 `feature`**(管理员合并,合并后自动删)。
+
+## 部署到服务器(对外提供 web 服务)
+
+目标服务器为 **CentOS 7**(glibc 2.17,自带 Node 16 / Python 3.6 / 无 docker)。Node 20 / Python 3.11 的官方二进制在此跑不起来,两种部署法:
+
+**① conda 自建环境(源码留 git,服务器自行构建/运行)**
+```bash
+# 服务器一次性:装 Miniconda,再
+conda create -n hs python=3.11 nodejs=20 -y && conda activate hs
+git clone http://git.hobbystocks.cn/tianyu.chu/hs-data.git ~/hs-data && cd ~/hs-data
+bash infra/setup.sh           # 装依赖
+corepack pnpm@10 --filter @hs-data/web build      # 出 dist/
+# 用 nginx 或 python -m http.server 托管 apps/web/dist
+```
+
+**② 本地构建 + 服务器只托管静态**(服务器不需要 Node/Python)
+```bash
+# 本地(Windows)
+corepack pnpm@10 --filter @hs-data/web build       # 出 apps/web/dist
+# 把 dist/ 传到服务器,用 nginx / python3 -m http.server 托管
+```
 
-MVP 只开漏斗模块端到端可用,其余进导航、占位"待开发"。能力域定位与演进路线见 CLAUDE.md §1 与 docs/01。
+前端单靠 mock 数据即可独立提供 web 服务;后端(FastAPI)按需用 conda 的 Python 3.11 起 `uvicorn`,或接真实 `ads_trd_group_funnel_*` 两表

+ 18 - 2
apps/web/src/modules/funnel/FunnelPage.tsx

@@ -1,4 +1,5 @@
 import { useEffect, useState } from 'react';
+import { CalendarRange } from 'lucide-react';
 import { useMutation } from '@tanstack/react-query';
 import { queryFunnel, USE_MOCK } from '../../api/funnel';
 import type {
@@ -6,7 +7,12 @@ import type {
   FunnelQueryRequest,
   FunnelQueryResponse,
 } from '../../api/types';
-import { DEFAULT_PERIOD, toSnapshotParam, yesterday } from './period';
+import {
+  DEFAULT_PERIOD,
+  funnelRangeText,
+  toSnapshotParam,
+  yesterday,
+} from './period';
 import { TimePeriodSelect } from './components/TimePeriodSelect';
 import { FunnelResult } from './components/FunnelResult';
 import { Badge } from '@/components/ui/badge';
@@ -71,8 +77,18 @@ export function FunnelPage() {
         拼团漏斗:启动 → 曝光 → 拼团详情 → 下单 → 成功
       </p>
 
+      {/* 时间边界 —— 醒目展示这次漏斗覆盖的日期范围 + 不含今日(T+1) */}
+      {mutation.data?.snapshot_dt && (
+        <div className="inline-flex w-fit items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-1.5 text-sm">
+          <CalendarRange className="size-4 shrink-0 text-primary" />
+          <span className="font-medium text-foreground">
+            {funnelRangeText(period, mutation.data.snapshot_dt)}
+          </span>
+          <span className="text-muted-foreground">· 数据 T+1,不含今日</span>
+        </div>
+      )}
+
       <FunnelResult
-        period={period}
         data={mutation.data}
         isLoading={mutation.isPending}
         isError={mutation.isError}

+ 8 - 0
apps/web/src/modules/funnel/__tests__/FunnelPage.test.tsx

@@ -133,6 +133,14 @@ describe('FunnelPage — fixed funnel + period selection', () => {
     }
   });
 
+  it('shows a prominent time-boundary line (range + 不含今日)', async () => {
+    queryFunnelMock.mockResolvedValue(readyResponse());
+    renderPage();
+    // default 单日 → 「数据日期 <snapshot>」 + 「不含今日」
+    expect(await screen.findByText(/数据日期 2026-06-23/)).toBeInTheDocument();
+    expect(screen.getByText(/不含今日/)).toBeInTheDocument();
+  });
+
   /** Open the always-on 单日 calendar popover and return its dialog. */
   async function openCalendar(user: ReturnType<typeof userEvent.setup>) {
     await user.click(screen.getByRole('button', { name: '选择历史日期' }));

+ 0 - 30
apps/web/src/modules/funnel/__tests__/FunnelResult.test.tsx

@@ -32,7 +32,6 @@ const readyData: FunnelQueryResponse = {
 };
 
 const baseProps = {
-  period: 'last_7d' as const,
   isLoading: false,
   isError: false,
 };
@@ -49,35 +48,6 @@ describe('FunnelResult states', () => {
     expect(screen.getByText(/曝光/)).toBeInTheDocument();
   });
 
-  it('ready (7d): shows the rolling "数据截至 … (近 7 天)" caption', () => {
-    renderResult({ ...baseProps, data: readyData });
-    expect(
-      screen.getByText(/数据截至 2026-06-23(近 7 天)/),
-    ).toBeInTheDocument();
-    expect(screen.queryByText(/数据快照日/)).not.toBeInTheDocument();
-  });
-
-  it('ready (30d): caption says (近 30 天)', () => {
-    renderResult({
-      ...baseProps,
-      period: 'last_30d',
-      data: { ...readyData, period: 'last_30d' },
-    });
-    expect(
-      screen.getByText(/数据截至 2026-06-23(近 30 天)/),
-    ).toBeInTheDocument();
-  });
-
-  it('ready (day): shows the "数据快照日" caption', () => {
-    renderResult({
-      ...baseProps,
-      period: 'day',
-      data: { ...readyData, period: 'day' },
-    });
-    expect(screen.getByText(/数据快照日:2026-06-23/)).toBeInTheDocument();
-    expect(screen.queryByText(/数据截至/)).not.toBeInTheDocument();
-  });
-
   it('ready: step 1 null conversion renders as "—", not 0%', () => {
     renderResult({ ...baseProps, data: readyData });
     const dashes = screen.getAllByText('—');

+ 3 - 42
apps/web/src/modules/funnel/components/FunnelResult.tsx

@@ -1,7 +1,6 @@
 import { lazy, Suspense } from 'react';
 import { Inbox, AlertCircle } from 'lucide-react';
-import type { FunnelPeriod, FunnelQueryResponse } from '../../../api/types';
-import { formatSnapshotDt } from '../period';
+import type { FunnelQueryResponse } from '../../../api/types';
 import { ResultTable } from './ResultTable';
 import { Card, CardContent } from '@/components/ui/card';
 import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -14,8 +13,6 @@ const FunnelChart = lazy(() =>
 );
 
 interface Props {
-  /** Active period — drives the caption wording (快照日 vs 数据截至). */
-  period: FunnelPeriod;
   /** Undefined before any query has run. */
   data?: FunnelQueryResponse;
   isLoading: boolean;
@@ -26,12 +23,11 @@ interface Props {
 
 /**
  * 结果区 — renders each data_status explicitly:
- *   ready   -> chart + table + snapshot caption
+ *   ready   -> chart + table(时间边界文案由 FunnelPage 顶部展示)
  *   missing -> "数据缺失" empty state (NO zero-fill)
  * Plus a generic transport/HTTP error state and an initial loading state.
  */
 export function FunnelResult({
-  period,
   data,
   isLoading,
   isError,
@@ -78,13 +74,12 @@ export function FunnelResult({
           <p className="text-sm text-muted-foreground m-0">
             数据缺失:所选周期暂无产出数据,未做补零处理。
           </p>
-          <SnapshotCaption period={period} snapshotDt={data.snapshot_dt} />
         </CardContent>
       </Card>
     );
   }
 
-  // ready — 单卡:漏斗图 + 分隔线 + 结果明细 + 快照说明
+  // ready — 单卡:漏斗图 + 分隔线 + 结果明细。
   return (
     <Card>
       <CardContent className="flex flex-col gap-6 pt-6">
@@ -93,41 +88,7 @@ export function FunnelResult({
         </Suspense>
         <Separator />
         <ResultTable results={data.results} />
-        <SnapshotCaption period={period} snapshotDt={data.snapshot_dt} />
       </CardContent>
     </Card>
   );
 }
-
-/**
- * Two caption variants from the response snapshot_dt:
- *  - day:        “数据快照日:YYYY-MM-DD” (the queried day).
- *  - 7d/30d:     “数据截至 YYYY-MM-DD(近 7 天 / 近 30 天)” — the rolling
- *                window's as-of/end date, making clear it excludes today.
- * Renders nothing when the backend returned no row (snapshot_dt === null).
- */
-function SnapshotCaption({
-  period,
-  snapshotDt,
-}: {
-  period: FunnelPeriod;
-  snapshotDt: string | null;
-}) {
-  if (!snapshotDt) return null;
-  const dt = formatSnapshotDt(snapshotDt);
-
-  if (period === 'day') {
-    return (
-      <p className="text-sm text-muted-foreground mt-3 mb-0">
-        数据快照日:{dt}
-      </p>
-    );
-  }
-
-  const window = period === 'last_7d' ? '近 7 天' : '近 30 天';
-  return (
-    <p className="text-sm text-muted-foreground mt-3 mb-0">
-      数据截至 {dt}({window})
-    </p>
-  );
-}

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

@@ -20,6 +20,35 @@ export function formatSnapshotDt(dt: string): string {
   return dt;
 }
 
+function parseSnapshot(dt: string): Date {
+  return new Date(
+    Number(dt.slice(0, 4)),
+    Number(dt.slice(4, 6)) - 1,
+    Number(dt.slice(6, 8)),
+  );
+}
+
+/**
+ * 时间边界文案 —— 显示这次漏斗覆盖的日期范围(响应的 `snapshot_dt` 为"止/快照日",yyyyMMdd):
+ *   单日   → 「数据日期 2026-06-24」
+ *   近7天  → 「近 7 天 2026-06-18 ~ 2026-06-24」
+ *   近30天 → 「近 30 天 2026-05-26 ~ 2026-06-24」
+ */
+export function funnelRangeText(
+  period: FunnelPeriod,
+  snapshotDt: string,
+): string {
+  if (!/^\d{8}$/.test(snapshotDt)) return formatSnapshotDt(snapshotDt);
+  const end = parseSnapshot(snapshotDt);
+  if (period === 'day') return `数据日期 ${formatSnapshotDt(snapshotDt)}`;
+  const span = period === 'last_7d' ? 7 : 30;
+  const start = new Date(end);
+  start.setDate(start.getDate() - (span - 1));
+  const startStr = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}`;
+  const win = period === 'last_7d' ? '近 7 天' : '近 30 天';
+  return `${win} ${startStr} ~ ${formatSnapshotDt(snapshotDt)}`;
+}
+
 /** Local-midnight `Date` for yesterday — the latest selectable / queryable day (data is T+1). */
 export function yesterday(): Date {
   const d = new Date();