@ (#111)
docs/api目录文档分类和补全 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #111
This commit was merged in pull request #111.
This commit is contained in:
@@ -0,0 +1,624 @@
|
||||
# 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-link`→`SGBridge.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` 末尾追加:
|
||||
|
||||
```python
|
||||
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.js` 的 `isLocationGranted` 函数**之后**(约第 137 行 `}` 后)插入:
|
||||
|
||||
```javascript
|
||||
/** 取设备当前经纬度 → {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,` 之后)加一行:
|
||||
|
||||
```javascript
|
||||
getLocation: getLocation,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pytest tests/test_h5_hosting.py::test_h5_bridge_has_get_location -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
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`:构造器**末尾**加参数(默认值保证其它调用点不破):
|
||||
|
||||
```kotlin
|
||||
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):
|
||||
|
||||
```kotlin
|
||||
/** 设备当前经纬度 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(若缺):
|
||||
|
||||
```kotlin
|
||||
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` 附近)加缓存 + 预取:
|
||||
|
||||
```kotlin
|
||||
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`:
|
||||
|
||||
```kotlin
|
||||
addJavascriptInterface(
|
||||
SGBridge(
|
||||
context = ctx,
|
||||
onClose = onBack,
|
||||
onRequestLogin = onRequestLogin,
|
||||
onNavigate = onNavigate,
|
||||
onRequestLocation = onRequestLocation,
|
||||
locationProvider = { lastLocation.value },
|
||||
),
|
||||
SGBridge.NAME,
|
||||
)
|
||||
```
|
||||
|
||||
文件末尾加 `awaitLastLocation` 扩展(镜像 `HomeScreen.kt:728`):
|
||||
|
||||
```kotlin
|
||||
@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 仓)**
|
||||
|
||||
```bash
|
||||
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>`**(行 5047–5158)全部删掉,只留空容器:
|
||||
|
||||
```html
|
||||
<div class="home-feed"></div>
|
||||
<div class="feed-end" style="display:none">已经到底了</div>
|
||||
```
|
||||
|
||||
(原 `.feed-end` 行 5159 改成默认 `display:none`,由模块按 `hasNext` 控制。)
|
||||
|
||||
- [ ] **Step 2: 替换排序 tab 处理器**
|
||||
|
||||
把行 6664–6686 的整段 `document.addEventListener('click', function (e) { … feed-sort … });`(只切高亮 + asc/desc + scrollIntoView 那段)**整体替换**为下面这段(保留 scrollIntoView 体验,删 asc/desc,接真实切换):
|
||||
|
||||
```javascript
|
||||
// ===== 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 模块:
|
||||
|
||||
```html
|
||||
<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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: 提交**
|
||||
|
||||
```bash
|
||||
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>`:
|
||||
|
||||
```html
|
||||
<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)**之后**插入两个容器(复用其样式骨架):
|
||||
|
||||
```html
|
||||
<!-- 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 空态接管:
|
||||
|
||||
```javascript
|
||||
// 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 = …` 之后)加抢委托:
|
||||
|
||||
```javascript
|
||||
// 抢:换链(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: 提交**
|
||||
|
||||
```bash
|
||||
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: 写测试(此时应已通过 —— 作回归护栏)**
|
||||
|
||||
追加:
|
||||
|
||||
```python
|
||||
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: 提交**
|
||||
|
||||
```bash
|
||||
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/空串)。
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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 缓存 |
|
||||
Reference in New Issue
Block a user