# H2b — 首页 feed 真接入设计(H5 端 + 安卓 getLocation)

> 仓库:`shaguabijia-app-server`(分支 `feat/h5-sgbridge-gk`)+ `shaguabijia-app-android`
> 日期:2026-06-30　里程碑:H5-WebView 改造方案 M3(首页)子项 H2b
> 配套:`docs/H5-WebView改造方案.md`、原生参考 `ui/home/HomeViewModel.kt` + `HomeScreen.kt`

## 1. 目标

把 [h5/home/index.html](../../../h5/home/index.html) 里**写死的 7 张 mock feed 卡**换成真实 `/api/v1/meituan/*` 数据,三个排序 tab(智能推荐 / 距离最近 / 销量最高)接通后端,「抢」按钮接换链拉起美团 —— **忠实移植**原生 `HomeViewModel` + `HomeScreen` 的 feed 逻辑(全功能,非精简版)。

**已确认决策:**
- D1:**本轮一并接安卓真实定位** —— 安卓 `SGBridge.kt` 加 `getLocation()`,H5 据此拿经纬度;拿不到落北京默认。
- D2:**对齐原生全功能** —— 触底翻页 + 每 tab 缓存 + 三态 + 去重 + 防竞态 + 未授权不打接口。

## 2. 范围(两仓改动;后端零改动)

| 仓 | 文件 | 改动 |
|---|---|---|
| app-server | `h5/shared/bridge.js` | 新增 `getLocation()`(查询类,同步返 `{longitude, latitude}`;无桥 mock 北京) |
| app-server | `h5/home/index.html` | feed 模块重写:渲染真实卡 + 三 tab 接端点 + 翻页/缓存/三态 + 抢按钮 |
| android | `ui/webview/SGBridge.kt` | 新增 `@JavascriptInterface fun getLocation(): String`(返宿主缓存的 lastLocation JSON,空串=拿不到) |
| android | `ui/webview/WebViewScreen.kt` | 取 `FusedLocationProviderClient.lastLocation`(3s 超时)缓存,经构造器 `locationProvider` 注入 SGBridge |

**后端不动:** `/api/v1/meituan/feed`、`/top-sales`、`/referral-link` 及 `CouponCard` 均已就绪。

## 3. 数据契约(已存在,只读)

- **`POST /api/v1/meituan/feed`** `{longitude, latitude, page, page_size?, tab}` → `{items: CouponCard[], has_next, page, status}`
  - `tab="rec"` 智能推荐(离线库,佣金≥3%,分页);`tab="distance"` 距离最近(实时打美团,一次性返回 `has_next=false`)。
- **`POST /api/v1/meituan/top-sales`** `{page, page_size?, platform?}` → `{items, has_next, status}`(销量最高,离线库,**不传经纬度**)。
- **`POST /api/v1/meituan/referral-link`** `{product_view_sign, platform, biz_line, link_type_list:[1,3]}` → `{link, link_map}`(`link_map["3"]`=deeplink,`["1"]`=H5 长链)。
- 三个端点**均不鉴权**;`status`:`ok` 有数据 / `empty` 暂无 / `degraded` 上游失败已降级。

## 4. tab → 端点映射(对齐 `HomeViewModel.FeedSort` + `loadPage`)

| tab(`data-key`) | label | 端点 | 备注 |
|---|---|---|---|
| `rec`(默认激活) | 智能推荐 | `/feed` `tab=rec` | 传经纬度;离线库,坐标实际不影响结果 |
| `distance` | 距离最近 | `/feed` `tab=distance` | 传经纬度;一次性,`has_next=false` 天然不触底翻页 |
| `sales` | 销量最高 | `/top-sales` | **不传经纬度**;离线库按销量降序 |

> 现有点击处理器(行 6664)里的 `asc/desc` 方向翻转是 mock 残留,后端不支持方向 → **删除**,改为真实拉数据。

## 5. 定位坐标流程(D1)

**bridge.js `getLocation()`(新增,查询类同步):**
```js
function getLocation() {
  if (hasNative && native.getLocation) {
    var o = safeParse(native.getLocation(), null);
    if (o && typeof o.longitude === 'number' && typeof o.latitude === 'number') return o;
    return null;                      // 原生拿不到(空串/未授权)→ null,调用方落默认
  }
  return { longitude: 116.404, latitude: 39.928 };  // 浏览器 mock:北京
}
```

**H5 home:**
- 进页 / `visibilitychange` 可见时取 `var c = SGBridge.getLocation() || {longitude:116.404, latitude:39.928}`(北京默认,对齐原生 `HomeViewModel` 初值)。
- 坐标变化 → 清空所有 tab 缓存并重拉当前 tab 第一页(对齐 `updateLocation`)。

**android `SGBridge.getLocation(): String`:** 返宿主缓存的 `{"longitude":..,"latitude":..}` 或 `""`。`@JavascriptInterface` 同步、不能等异步 GPS,故由 `WebViewScreen` 预取 `FusedLocationProviderClient.lastLocation`(镜像 `HomeScreen.awaitLastLocation`,3s 超时)缓存到 remember 状态,经构造器 `locationProvider: () -> Pair<Double,Double>?` 注入。未授权 / 拿不到 → `""`(H5 落北京默认,**rec/sales 不受影响**,distance 暂北京中心,回前台拿到后刷新转真实)。

## 6. 卡片渲染(1:1 照 `FeedCard` HomeScreen.kt:1694,复用现有 `.feed-card` 标记/CSS)

| 卡片元素 | CSS | CouponCard 字段 | 规则 |
|---|---|---|---|
| 头图 | `.feed-img img@src` | `head_image_url` | 直用(后端已缩放转 WebP) |
| 品类徽章 | `.feed-cat-badge` | `platform` | `1`→「外卖」`cat-waimai`;否则→「团购」`cat-tuangou` |
| 店名 | `.feed-title .shop-name` | `brand_name` | 空则不渲染店名 + 分隔符「｜」 |
| 标题 | `.feed-title`(分隔符后) | `name` + `available_poi_num` | `name`+(`available_poi_num?` `「｜N店通用」`);CSS 已 2 行省略 |
| 距离·店名 | `.feed-meta .meta-left` | `distance_text` + `poi_name` | `distanceText`+`" "`+`poiName`(rec 无距离→仅店名) |
| 销量 | `.feed-meta .meta-right` | `sale_volume` | 直用(可空,空则不渲染) |
| 优惠标签 | `.feed-tag` | `price_label` / `rank_label` | 二者非空中**随机取一**(按 sign 固定);都空则不渲染 |
| 现价 | `.feed-price` | `sell_price` | `splitPrice`→整数 + 小数;`¥`+整数+`<small>.x</small>` |
| 原价 | `.feed-original` | `original_price` | `¥{original_price}`(删除线) |
| 抢 | `.feed-grab` | — | 点击→换链→`openMeituan` |

> ⚠️ **与 mock 的差异(随原生):** mock 卡的 `.feed-tag` 显示「比团购省 X 元」,但原生 `FeedCard` 只用 `price_label`/`rank_label`,**不展示「比团购省」**。**已确认(2026-06-30):随原生去掉「比团购省」**。通则:**原生实现与 H5 原型冲突时,一律以原生为准**(原生是已上线、经产品校准的真实行为)。

## 7. 列表行为(照 `HomeViewModel`)

- **翻页:** `currentPage`/`hasNext`,触底(滚到底部阈值)`loadNextPage`;`append` 时按 `product_view_sign` 去重再拼接;`distance`/`sales` 的 `has_next` 由后端控制。`feedJob`/`AbortController` 在跑则不重入。
- **每 tab 缓存:** `{coupons, page, hasNext, loadedAt}`,TTL 5 分钟。切 tab 命中新鲜缓存 → 直接恢复列表+分页、不转圈;过期/无缓存 → 重置拉第一页。**坐标变化清空全部缓存**(rec/distance 依赖坐标)。
- **三态(复用 H2a 容器):**
  - `loading`:首次进 tab 转圈(用 `feedLoadedOnce` 区分「首次加载中」vs「已加载但空」,防空态闪现)。
  - `empty`:已加载且列表空 → 复用 `#homeFeedNoDealEmpty`(「附近暂无…去比价」)。
  - `degraded`:`status=degraded` → 「服务繁忙,下拉重试」提示。
- **防竞态:** 切 tab `abort` 旧请求(旧 tab 响应晚到不覆盖新 tab)。
- **未授权定位:** 三 tab 都显示 `#homeFeedEmpty`(「去授权」),切 tab **只切高亮不打接口**(对齐 `selectTabWithoutLoad`,尤其 distance 避免拿默认坐标空打美团)。复用 H2a `applyLocationState` 联动。

## 8. 「抢」流程(照 `HomeScreen.kt:615` + `HomeViewModel.getReferralLink`)

点「抢」→ `POST /referral-link` `{product_view_sign, platform, biz_line, link_type_list:[1,3]}` → `deeplink = link_map["3"]`,`h5 = link_map["1"] || link` → `SGBridge.openMeituan(deeplink || h5)`;两者皆空 → `SGBridge.toast('暂时无法跳转')`。请求中给按钮加 loading/防连点。

## 9. 调用方式

feed/top-sales/referral-link 均不鉴权 → 用**裸 `fetch` + 相对 `/api/v1`**(对齐 home 现有 `/api/v1/platform/*` 调法,无需引 `api.js`)。POST、`Content-Type: application/json`。

## 10. 验收 / 测试

- **浏览器(无桥,mock 北京):** 三 tab 切换各自拉到数据;触底翻页累加;切回命中缓存不转圈;空/降级态文案正确;点抢 console 打印 `openMeituan`。
- **真机:** 距离最近按真实定位由近及远;rec/sales 正常;点抢拉起美团 App / H5。未授权时三 tab 均「去授权」空态、不打接口;授权回前台自动出 feed。
- **后端:** 现有 `pytest`(meituan feed 冒烟)仍通过(本设计不改后端)。

## 11. 风险

| 风险 | 缓解 |
|---|---|
| `getLocation` 同步 vs GPS 异步 | 宿主预取并缓存 lastLocation,`getLocation()` 同步返缓存;首次可能空→落北京默认,回前台刷新转真实 |
| 安卓改动跨仓 | 安卓侧仅 +1 桥方法 + 宿主取定位;H5 用 `SGBridge.getLocation()` 抽象,安卓未上线时浏览器/真机均落北京默认,H5 不阻塞 |
| distance 一次性返回被误触底翻页 | `has_next=false` 天然挡住;翻页逻辑只认 `hasNext` |
| 缓存与定位变化不一致 | 坐标变化统一清空全部 tab 缓存 |
