Files
shaguabijia-app-server/docs/superpowers/plans/2026-06-30-h5-home-feed-h2b.md
guke a563c1ca4b @ (#111)
docs/api目录文档分类和补全

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #111
2026-07-03 15:00:37 +08:00

28 KiB
Raw Permalink Blame History

H2b 首页 feed 真接入 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal:h5/home/index.html 写死的 mock feed 换成真实 /api/v1/meituan/* 数据,三排序 tab 接通、「抢」拉起美团,对齐原生 HomeViewModel/HomeScreen 全功能。

Architecture: 纯前端 feed 模块(裸 fetch 同源相对 /api/v1)按 CouponCard 渲染进现有 .feed-card 标记;rec/distance/meituan/feed,sales/meituan/top-sales,「抢」→/meituan/referral-linkSGBridge.openMeituan。坐标由新增 SGBridge.getLocation() 提供(安卓取 FusedLocation,拿不到落北京默认)。后端零改动。

Tech Stack: 原生 ES5 风格 JS(对齐 home 现有写法)+ FastAPI StaticFiles + pytest(TestClient 内容冒烟)+ Kotlin(安卓 SGBridge / WebViewScreen)。

测试说明(本仓约定): 本仓无 JS 单测框架;H5 的既有测试是 tests/test_h5_hosting.py服务端内容冒烟(断言 StaticFiles 下发的 HTML/JS 含关键标记 + 响应头)。故本计划:① 用 pytest 内容冒烟做可自动化的回归护栏(test-first);② feed 的运行时行为(渲染/翻页/三态/抢)用浏览器手动验证步骤(无桥→mock 北京)逐项核对;③ 安卓改动用编译 + 真机验证。这是与本仓现状一致的取舍,非偷懒。

参考源(原生权威实现):

  • shaguabijia-app-android ui/home/HomeViewModel.kt(tab 映射 / 翻页 / 缓存 / 竞态 / getReferralLink)
  • ui/home/HomeScreen.kt:1694(FeedCard 渲染)、:615(抢点击)、:421(FusedLocation lastLocation)
  • 后端 app/api/v1/meituan.py + app/schemas/meituan.py(CouponCard 字段)

File Structure

文件 责任 改动
h5/shared/bridge.js SGBridge JS 桥 新增 getLocation()(查询类同步)
h5/home/index.html 首页 H5 删 mock 静态卡;新增 feed 模块 <script>;替换排序 tab 处理器
tests/test_h5_hosting.py H5 托管冒烟 加 bridge getLocation + home feed 接线断言
…/ui/webview/SGBridge.kt(安卓) 原生桥 新增 getLocation(): String + 构造器 locationProvider
…/ui/webview/WebViewScreen.kt(安卓) WebView 壳 取 FusedLocation lastLocation 缓存,注入 locationProvider

Task 1: bridge.js 新增 getLocation()

Files:

  • Modify: h5/shared/bridge.js(查询类区 + global.SGBridge 导出)

  • Test: tests/test_h5_hosting.py

  • Step 1: 写失败测试

tests/test_h5_hosting.py 末尾追加:

def test_h5_bridge_has_get_location(client) -> None:
    # H2b: 首页 feed 需经纬度, bridge 暴露同步 getLocation()(无桥 mock 北京)
    resp = client.get("/h5/shared/bridge.js")
    assert resp.status_code == 200
    body = resp.text
    assert "getLocation" in body
    assert "116.404" in body and "39.928" in body  # 浏览器 mock 北京兜底
  • Step 2: 跑测试确认失败

Run: pytest tests/test_h5_hosting.py::test_h5_bridge_has_get_location -q Expected: FAIL(assert "getLocation" in body —— 还没实现)

  • Step 3: 实现 getLocation()

h5/shared/bridge.jsisLocationGranted 函数之后(约第 137 行 } 后)插入:

  /** 取设备当前经纬度 → {longitude, latitude};原生拿不到(未授权/无缓存)返 null,浏览器无原生 mock 北京。
   *  原生侧同步返宿主缓存的 lastLocation(见 SGBridge.kt getLocation);H5 拿到 null 自行落默认坐标。 */
  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;
    }
    return { longitude: 116.404, latitude: 39.928 };
  }

global.SGBridge = { ... } 的查询类导出里(isLocationGranted: isLocationGranted, 之后)加一行:

    getLocation: getLocation,
  • Step 4: 跑测试确认通过

Run: pytest tests/test_h5_hosting.py::test_h5_bridge_has_get_location -q Expected: PASS

  • Step 5: 提交
git add h5/shared/bridge.js tests/test_h5_hosting.py
git commit -m "feat(h5): SGBridge 新增 getLocation()(首页 feed 取经纬度;无桥 mock 北京)"

Task 2: 安卓 SGBridge.getLocation() + WebView 注入 FusedLocation

⚠️shaguabijia-app-android 仓。该仓有独立分支/提交。本任务用编译 + 真机验证(无 JVM 单测,@JavascriptInterface 依赖 Android Context)。

Files:

  • Modify: app/src/main/java/com/jishisongfu/shaguabijia/ui/webview/SGBridge.kt

  • Modify: app/src/main/java/com/jishisongfu/shaguabijia/ui/webview/WebViewScreen.kt

  • Step 1: SGBridge 加构造器参数 + getLocation()

SGBridge.kt:构造器末尾加参数(默认值保证其它调用点不破):

class SGBridge(
    private val context: Context,
    private val onClose: () -> Unit,
    private val onRequestLogin: () -> Unit,
    private val onNavigate: (String) -> Unit,
    private val onRequestLocation: () -> Unit,
    private val locationProvider: () -> Pair<Double, Double>? = { null },
) {

isLocationGranted() 之后加方法(并确保文件已 import org.json.JSONObject —— 已有 import org.json.JSONArray,补 JSONObject):

    /** 设备当前经纬度 JSON {longitude, latitude};宿主缓存的 lastLocation,拿不到/未授权返空串。
     *  @JavascriptInterface 同步、不能等异步 GPS,故宿主(WebViewScreen)预取 lastLocation 缓存后经
     *  locationProvider 注入;H5 SGBridge.getLocation() 据此取,空则落北京默认。 */
    @JavascriptInterface
    fun getLocation(): String {
        val loc = locationProvider() ?: return ""
        return JSONObject().put("longitude", loc.first).put("latitude", loc.second).toString()
    }
  • Step 2: WebViewScreen 取 lastLocation 缓存并注入

WebViewScreen.kt 顶部补 import(若缺):

import android.Manifest
import android.content.pm.PackageManager
import android.location.Location
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume

WebViewScreen 函数体内(var loadError 附近)加缓存 + 预取:

    val locContext = LocalContext.current
    val lastLocation = remember { mutableStateOf<Pair<Double, Double>?>(null) }
    LaunchedEffect(Unit) {
        val granted = ContextCompat.checkSelfPermission(locContext, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
            ContextCompat.checkSelfPermission(locContext, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
        if (granted) {
            val fused = LocationServices.getFusedLocationProviderClient(locContext)
            val loc = withTimeoutOrNull(3_000L) { fused.awaitLastLocation() }
            if (loc != null) lastLocation.value = loc.longitude to loc.latitude
        }
    }

把工厂里的 SGBridge(...) 调用加上 locationProvider:

                            addJavascriptInterface(
                                SGBridge(
                                    context = ctx,
                                    onClose = onBack,
                                    onRequestLogin = onRequestLogin,
                                    onNavigate = onNavigate,
                                    onRequestLocation = onRequestLocation,
                                    locationProvider = { lastLocation.value },
                                ),
                                SGBridge.NAME,
                            )

文件末尾加 awaitLastLocation 扩展(镜像 HomeScreen.kt:728):

@SuppressLint("MissingPermission")
private suspend fun FusedLocationProviderClient.awaitLastLocation(): Location? =
    suspendCancellableCoroutine { cont ->
        lastLocation
            .addOnSuccessListener { cont.resume(it) }
            .addOnFailureListener { cont.resume(null) }
    }
  • Step 3: 编译

Run(android 仓根):./gradlew :app:compileDebugKotlin Expected: BUILD SUCCESSFUL(无未解析引用)

  • Step 4: 提交(android 仓)
git add app/src/main/java/com/jishisongfu/shaguabijia/ui/webview/SGBridge.kt \
        app/src/main/java/com/jishisongfu/shaguabijia/ui/webview/WebViewScreen.kt
git commit -m "feat(webview): SGBridge.getLocation() 注入 FusedLocation lastLocation(首页 feed 距离最近用真实定位)"

Task 3: home/index.html — feed 数据/渲染模块 + 替换排序处理器

Files:

  • Modify: h5/home/index.html(删静态卡 @5047-5158;替换排序处理器 @6664-6686;新增 feed 模块 <script>)

  • Step 1: 清空 .home-feed 静态 mock 卡

<div class="home-feed">(行 5046)与其闭合 </div> 之间的 7 个 <div class="feed-card">…</div>(行 50475158)全部删掉,只留空容器:

            <div class="home-feed"></div>
            <div class="feed-end" style="display:none">已经到底了</div>

(原 .feed-end 行 5159 改成默认 display:none,由模块按 hasNext 控制。)

  • Step 2: 替换排序 tab 处理器

把行 66646686 的整段 document.addEventListener('click', function (e) { … feed-sort … });(只切高亮 + asc/desc + scrollIntoView 那段)整体替换为下面这段(保留 scrollIntoView 体验,删 asc/desc,接真实切换):

// ===== feed 排序 tab(智能推荐/距离最近/销量最高)→ 接真实数据(H2b) =====
document.addEventListener('click', function (e) {
  var item = e.target.closest('#home .feed-sort .fs-item');
  if (!item || item.classList.contains('active')) return;
  var group = item.closest('.feed-sort');
  group.querySelectorAll('.fs-item').forEach(function (o) { o.classList.remove('active'); });
  item.classList.add('active');
  // 未授权定位:只切高亮、不打接口(对齐原生 selectTabWithoutLoad);授权后由 applyLocationState 拉
  if (window.HomeFeed) {
    if (HomeFeed.locationGranted()) HomeFeed.switchTab(item.dataset.key);
    else HomeFeed.selectTabWithoutLoad(item.dataset.key);
  }
  var stickY = 82;
  if (group.getBoundingClientRect().top > stickY + 2) {
    group.scrollIntoView({ block: 'start', behavior: 'smooth' });
  }
});
  • Step 3: 新增 feed 模块 <script>

<script src="../shared/bridge.js"></script>(行 10179)之后新增一个 <script> 块,内容为完整 feed 模块:

<script>
/* H2b 首页 feed:rec/distance→/meituan/feed,sales→/meituan/top-sales;触底翻页 + 每 tab 5min 缓存
   + loading/empty/degraded 三态 + 去重 + 切 tab 防竞态 + 未授权不打接口。对齐原生 HomeViewModel。 */
(function (global) {
  'use strict';
  var BEIJING = { longitude: 116.404, latitude: 39.928 };
  var ENDPOINT = { rec: 'feed', distance: 'feed', sales: 'top-sales' };
  var CACHE_TTL = 5 * 60 * 1000;
  var SCROLL_THRESHOLD = 400; // 距底 px 触发翻页

  var st = { tab: 'rec', page: 0, hasNext: true, loading: false, loadedOnce: false,
             items: [], seq: 0, coords: null, cache: {} };

  function $feed() { return document.querySelector('#home .home-feed'); }
  function $end() { return document.querySelector('#home .feed-end'); }
  function $scroll() { return document.querySelector('#home .home-scroll'); }

  function locationGranted() {
    try { if (global.SGBridge && SGBridge.isLocationGranted) return !!SGBridge.isLocationGranted(); } catch (e) {}
    return false;
  }
  function getCoords() {
    try {
      if (global.SGBridge && SGBridge.getLocation) {
        var c = SGBridge.getLocation();
        if (c && typeof c.longitude === 'number' && typeof c.latitude === 'number') return c;
      }
    } catch (e) {}
    return BEIJING;
  }
  function esc(s) {
    return String(s == null ? '' : s).replace(/[&<>"']/g, function (m) {
      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m];
    });
  }
  function splitPrice(p) {
    var s = String(p == null ? '' : p), i = s.indexOf('.');
    return i < 0 ? [s, ''] : [s.slice(0, i), s.slice(i)];
  }

  // CouponCard → .feed-card(1:1 照原生 FeedCard;feed-tag 只用 price_label/rank_label,不显示「比团购省」)
  function renderCard(card) {
    var isWaimai = card.platform === 1;
    var shop = card.brand_name
      ? '<span class="shop-name">' + esc(card.brand_name) + '</span><span class="title-sep"></span>' : '';
    var title = esc(card.name || '');
    if (card.available_poi_num) title += '' + card.available_poi_num + '店通用';
    var metaLeft = '';
    if (card.distance_text) metaLeft += esc(card.distance_text);
    if (card.poi_name) metaLeft += (metaLeft ? ' ' : '') + esc(card.poi_name);
    var saleVol = card.sale_volume ? '<span class="meta-right">' + esc(card.sale_volume) + '</span>' : '';
    var labels = [];
    if (card.price_label && card.price_label.trim()) labels.push(card.price_label.trim());
    if (card.rank_label && card.rank_label.trim()) labels.push(card.rank_label.trim());
    var tag = labels.length
      ? '<div class="feed-tag">' + esc(labels[Math.floor(Math.random() * labels.length)]) + '</div>' : '';
    var pr = splitPrice(card.sell_price);

    var el = document.createElement('div');
    el.className = 'feed-card';
    el.dataset.sign = card.product_view_sign || '';
    el.dataset.platform = card.platform;
    if (card.biz_line != null) el.dataset.bizLine = card.biz_line;
    el.innerHTML =
      '<div class="feed-img"><img src="' + esc(card.head_image_url || '') + '" alt="">' +
        '<span class="feed-cat-badge ' + (isWaimai ? 'cat-waimai">外卖' : 'cat-tuangou">团购') + '</span></div>' +
      '<div class="feed-body">' +
        '<div class="feed-title">' + shop + title + '</div>' +
        '<div class="feed-meta"><span class="meta-left">' + metaLeft + '</span>' + saleVol + '</div>' +
        tag +
        '<div class="feed-pricerow">' +
          '<div class="feed-price"><span class="yuan">¥</span><span class="num">' + esc(pr[0]) +
            (pr[1] ? '<small>' + esc(pr[1]) + '</small>' : '') + '</span></div>' +
          '<span class="feed-original">¥' + esc(card.original_price || '') + '</span>' +
        '</div>' +
      '</div>' +
      '<div class="feed-grab">抢</div>';
    return el;
  }

  function renderList(newItems, append) {
    var feed = $feed(); if (!feed) return;
    if (!append) feed.innerHTML = '';
    newItems.forEach(function (c) { feed.appendChild(renderCard(c)); });
    var end = $end();
    if (end) end.style.display = (!st.hasNext && st.items.length) ? '' : 'none';
  }

  function body(tab, page, coords) {
    return tab === 'sales'
      ? { page: page }
      : { longitude: coords.longitude, latitude: coords.latitude, page: page, tab: tab };
  }

  function loadPage(page, append) {
    var tab = st.tab, coords = getCoords(), mySeq = ++st.seq;
    st.coords = coords; st.loading = true;
    if (global.HomeFeedStates) HomeFeedStates(append ? 'loadingMore' : 'loading');
    fetch('/api/v1/meituan/' + ENDPOINT[tab], {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body(tab, page, coords)),
    }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
      if (mySeq !== st.seq) return;                 // 竞态:已切 tab/重载 → 丢弃旧响应
      st.loading = false; st.loadedOnce = true;
      if (!d) { if (global.HomeFeedStates) HomeFeedStates('degraded'); return; }
      var items = d.items || [];
      if (append) {
        var seen = {}; st.items.forEach(function (c) { seen[c.product_view_sign] = 1; });
        items = items.filter(function (c) { return c.product_view_sign && !seen[c.product_view_sign]; });
        st.items = st.items.concat(items);
      } else { st.items = items; }
      st.page = page; st.hasNext = !!d.has_next;
      st.cache[tab] = { items: st.items.slice(), page: st.page, hasNext: st.hasNext,
        loadedAt: (append && st.cache[tab]) ? st.cache[tab].loadedAt : Date.now() };
      renderList(append ? items : st.items, append);
      if (global.HomeFeedStates) HomeFeedStates(d.status === 'degraded' ? 'degraded' : (st.items.length ? 'ok' : 'empty'));
    }).catch(function () {
      if (mySeq !== st.seq) return;
      st.loading = false; st.loadedOnce = true;
      if (global.HomeFeedStates) HomeFeedStates('degraded');
    });
  }

  function loadFirst() { st.page = 0; st.hasNext = true; st.items = []; renderList([], false); loadPage(1, false); }
  function loadNext() { if (!st.hasNext || st.loading) return; loadPage(st.page + 1, true); }

  function switchTab(tab) {
    if (tab === st.tab) return;
    st.seq++;                                        // 作废旧 tab 在飞的请求
    st.tab = tab;
    var c = st.cache[tab];
    if (c && Date.now() - c.loadedAt < CACHE_TTL) {  // 命中新鲜缓存 → 直接恢复,不转圈
      st.items = c.items.slice(); st.page = c.page; st.hasNext = c.hasNext; st.loading = false;
      renderList(st.items, false);
      if (global.HomeFeedStates) HomeFeedStates(st.items.length ? 'ok' : 'empty');
    } else { loadFirst(); }
  }
  function selectTabWithoutLoad(tab) {            // 未授权:只切状态,不打接口
    if (tab === st.tab) return;
    st.seq++; st.tab = tab; st.page = 0; st.hasNext = true; st.items = []; renderList([], false);
  }

  // 坐标变化(rec/distance 依赖坐标)→ 清全部缓存并重拉当前 tab
  function refreshIfCoordsChanged() {
    var c = getCoords();
    if (st.coords && (st.coords.longitude !== c.longitude || st.coords.latitude !== c.latitude)) {
      st.cache = {};
      if (locationGranted()) loadFirst();
    }
  }

  // 触底翻页
  function bindScroll() {
    var sc = $scroll(); if (!sc || sc._feedBound) return; sc._feedBound = true;
    sc.addEventListener('scroll', function () {
      if (sc.scrollTop + sc.clientHeight >= sc.scrollHeight - SCROLL_THRESHOLD) loadNext();
    });
  }

  global.HomeFeed = {
    loadFirst: loadFirst, switchTab: switchTab, selectTabWithoutLoad: selectTabWithoutLoad,
    refreshIfCoordsChanged: refreshIfCoordsChanged, bindScroll: bindScroll,
    locationGranted: locationGranted, state: st,
  };
  document.addEventListener('DOMContentLoaded', bindScroll);
})(window);
</script>
  • Step 4: 浏览器手动验证(无桥→mock 北京)

Run:uvicorn app.main:app --port 8770(或 ./run.sh),浏览器开 http://localhost:8770/h5/home/ 临时在 console 执行 HomeFeed.loadFirst()(Task 4 接 applyLocationState 后会自动触发)。 Expected:.home-feed 出现真实券卡(头图/店名/价格/标签);滚到底自动追加下一页;点「智能推荐/距离最近/销量最高」切换列表;再切回 1s 内命中缓存不转圈。

  • Step 5: 提交
git add h5/home/index.html
git commit -m "feat(h5): 首页 feed 接 /meituan/feed+top-sales(渲染/翻页/缓存/切 tab/防竞态;删 mock 静态卡)"

Task 4: 接定位状态(applyLocationState 联动)+ 三态 UI + 抢按钮

Files:

  • Modify: h5/home/index.html(applyLocationState @9135;新增 HomeFeedStates + 抢委托)

  • Step 1: 新增 HomeFeedStates(loading/empty/degraded → 复用 H2a 容器)

在 Task 3 的 feed 模块 <script> 之后再加一个 <script>:

<script>
/* H2b feed 三态:loading 首次转圈 / empty 复用「附近暂无…去比价」/ degraded 服务繁忙。
   与 H2a applyLocationState 协作:未授权时 applyLocationState 已把 feed 区切「去授权」空态,这里只管已授权后的态。 */
(function (global) {
  'use strict';
  function el(id) { return document.getElementById(id); }
  global.HomeFeedStates = function (kind) {
    var feed = document.querySelector('#home .home-feed');
    var noDeal = el('homeFeedNoDealEmpty');
    var loading = el('homeFeedLoading');
    var degraded = el('homeFeedDegraded');
    var loaded = global.HomeFeed && HomeFeed.state.loadedOnce;
    var hasItems = global.HomeFeed && HomeFeed.state.items.length;
    // 仅在已授权(未授权由 applyLocationState 管)时切这些态
    if (feed) feed.style.display = (kind === 'ok' || (kind === 'loadingMore')) && hasItems ? '' : (hasItems ? '' : 'none');
    if (loading) loading.style.display = (kind === 'loading' && !hasItems) ? 'flex' : 'none';
    if (noDeal) noDeal.style.display = (kind === 'empty' && loaded) ? 'flex' : 'none';
    if (degraded) degraded.style.display = (kind === 'degraded' && !hasItems) ? 'flex' : 'none';
  };
})(window);
</script>
  • Step 2: 加 loading / degraded 两个空态容器

#homeFeedNoDealEmpty(行 5041 那个 div)之后插入两个容器(复用其样式骨架):

            <!-- feed 首次加载中 -->
            <div class="home-feed-empty" id="homeFeedLoading" style="display:none;flex-direction:column;align-items:center;justify-content:flex-start;background:#fff;padding:32px 24px;text-align:center;">
              <div style="font-size:14px;color:#999;">加载中…</div>
            </div>
            <!-- feed 服务繁忙(degraded) -->
            <div class="home-feed-empty" id="homeFeedDegraded" style="display:none;flex-direction:column;align-items:center;justify-content:flex-start;background:#fff;padding:18px 24px 24px;text-align:center;">
              <div style="font-size:14px;color:#999;line-height:1.5;">服务繁忙,下拉重试~</div>
              <button onclick="HomeFeed.loadFirst()" style="margin-top:12px;height:32px;padding:0 20px;border:none;border-radius:18px;font-size:13px;font-weight:600;background:linear-gradient(180deg,#FFE066 0%,#FFC400 100%);color:#1A1A1A;cursor:pointer;">重试</button>
            </div>
  • Step 3: applyLocationState 接 feed 加载

applyLocationState(行 9135)函数体末尾 } 之前加:已授权则首次拉/刷新坐标,未授权则清 feed 三态由 H2a 空态接管:

  // H2b: 已授权 → 拉当前 tab(首次)或按坐标变化刷新;未授权 → 关 feed 三态(H2a 去授权空态接管)
  if (locationGranted) {
    if (window.HomeFeed) {
      if (!HomeFeed.state.loadedOnce) HomeFeed.loadFirst();
      else HomeFeed.refreshIfCoordsChanged();
    }
  } else {
    ['homeFeedLoading', 'homeFeedDegraded', 'homeFeedNoDealEmpty'].forEach(function (id) {
      var n = document.getElementById(id); if (n) n.style.display = 'none';
    });
  }
  • Step 4: 抢按钮 → referral-link → openMeituan(事件委托)

HomeFeedStates<script> 里(IIFE 内,global.HomeFeedStates = … 之后)加抢委托:

  // 抢:换链(linkTypeList=[1,3])→ deeplink 优先, 退 h5 → SGBridge.openMeituan
  document.addEventListener('click', function (e) {
    var grab = e.target.closest('#home .feed-card .feed-grab');
    if (!grab) return;
    var card = grab.closest('.feed-card');
    if (!card || card._grabbing) return;
    card._grabbing = true;
    var prev = grab.textContent; grab.textContent = '…';
    fetch('/api/v1/meituan/referral-link', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        product_view_sign: card.dataset.sign,
        platform: Number(card.dataset.platform) || 1,
        biz_line: card.dataset.bizLine != null ? Number(card.dataset.bizLine) : null,
        link_type_list: [1, 3],
      }),
    }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
      var map = (d && d.link_map) || {};
      var uri = map['3'] || map['1'] || (d && d.link) || '';
      if (uri && global.SGBridge && SGBridge.openMeituan) SGBridge.openMeituan(uri);
      else if (global.SGBridge && SGBridge.toast) SGBridge.toast('暂时无法跳转,请稍后再试');
      else console.log('[抢] openMeituan →', uri);
    }).catch(function () {
      if (global.SGBridge && SGBridge.toast) SGBridge.toast('暂时无法跳转,请稍后再试');
    }).then(function () { grab.textContent = prev; card._grabbing = false; });
  });
  • Step 5: 浏览器手动验证

浏览器开 http://localhost:8770/h5/home/(无桥 → isLocationGranted mock 返 true → 自动 loadFirst): Expected:首屏自动出 feed(不需手动调);切 tab 正常;命中空数据显示「附近暂无…去比价」;断网/后端返 degraded 显示「服务繁忙」+重试;点「抢」console 打印 openMeituan → <link>(无桥 mock)。

  • Step 6: 提交
git add h5/home/index.html
git commit -m "feat(h5): 首页 feed 接定位态联动 + loading/empty/degraded 三态 + 抢按钮换链拉起美团"

Task 5: home feed 接线冒烟测试(pytest 内容护栏)

Files:

  • Test: tests/test_h5_hosting.py

  • Step 1: 写测试(此时应已通过 —— 作回归护栏)

追加:

def test_h5_home_feed_wired(client) -> None:
    # H2b: 首页 feed 接真实美团端点 + 抢换链;mock 静态卡已移除
    resp = client.get("/h5/home/index.html")
    assert resp.status_code == 200
    body = resp.text
    assert "/api/v1/meituan/feed" in body          # rec/distance
    assert "/api/v1/meituan/top-sales" in body     # 销量最高
    assert "/api/v1/meituan/referral-link" in body  # 抢
    assert "HomeFeed" in body                        # feed 模块已挂
    assert "比团购省" not in body                     # 随原生:不显示「比团购省」(mock 卡已删)
    assert "chabaidao.png" not in body               # 写死的 mock 头图已移除
  • Step 2: 跑测试

Run: pytest tests/test_h5_hosting.py -q Expected: PASS(全绿;若 比团购省/chabaidao.png 仍在,回 Task 3 Step 1 确认静态卡删净)

  • Step 3: 提交
git add tests/test_h5_hosting.py
git commit -m "test(h5): 首页 feed 接线冒烟(meituan 端点 + 抢 + mock 卡清零)"

Task 6: 端到端验证 + 收尾

Files: 无(验证 + 分支收尾)

  • Step 1: 后端全量回归

Run: pytest -q Expected: 全绿(本计划不改后端;H5 冒烟新增项通过)

  • Step 2: Lint

Run: ruff check . Expected: 无新增告警(仅改了 tests/test_h5_hosting.py;H5/JS 不在 ruff 范围)

  • Step 3: 浏览器端到端走查

http://localhost:8770/h5/home/,逐项核对(对照原生 HomeScreen feed):

  • 三 tab 各自拉到数据、卡片渲染对(头图/外卖团购徽章/店名橙色/距离·店名/销量/标签/现价原价/抢)。

  • 触底翻页累加且无重复;切 tab→切回命中缓存不转圈;距离最近一次性到底(不再翻页)。

  • 「抢」console 打印 openMeituan;空数据/降级态文案正确。

  • Step 4: 真机走查(需 Task 2 安卓包)

装含 Task 2 改动的 debug 包,进首页:

  • 已授权定位:距离最近按真实位置由近及远;rec/sales 正常;点抢拉起美团 App/H5。

  • 未授权:三 tab 均「去授权」空态,切 tab 不打接口;授权回前台自动出 feed。

  • Step 5: 收尾

调用 superpowers:finishing-a-development-branch 决定合并/PR;两仓分别提交(app-server feat/h5-sgbridge-gk;android 自有分支)。


Self-Review

  • Spec coverage: §2 范围→Task 1/2/3/4/5;§4 tab 映射→Task 3(ENDPOINT);§5 定位→Task 1+2+Task3 getCoords+Task4 refreshIfCoordsChanged;§6 渲染→Task 3 renderCard;§7 列表行为(翻页/缓存/三态/竞态/未授权)→Task 3+4;§8 抢→Task 4 Step 4;§9 裸 fetch→Task 3/4;§10 验收→Task 5/6。无遗漏。
  • Placeholder scan: 无 TBD/TODO;每步含实际代码/命令/期望。
  • Type consistency: HomeFeed.{loadFirst,switchTab,selectTabWithoutLoad,refreshIfCoordsChanged,bindScroll,locationGranted,state}HomeFeedStates(kind) 在 Task 3/4 定义并被排序处理器 / applyLocationState 一致引用;SGBridge.getLocation()(Task1 JS / Task2 Kotlin)签名一致(同步,JSON/对象 ↔ null/空串)。