feat(h5): 首页 feed 真接入 /meituan/feed+top-sales(渲染/翻页/缓存/三态/抢)

删 7 张写死 mock 卡改 CouponCard 动态渲染(随原生:feed-tag 只用 price_label/rank_label,去掉「比团购省」);三排序 tab 智能推荐/距离最近→feed、销量最高→top-sales(删 mock 残留 asc/desc);触底翻页 + 每 tab 5min 缓存 + loading/empty/degraded 三态 + 去重 + 切 tab 防竞态 + 未授权只切高亮不打接口;抢→referral-link([1,3])→SGBridge.openMeituan(deeplink||h5)。含原 H2a 定位空态 WIP 一并提交。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
guke
2026-06-30 10:18:45 +08:00
parent 674b8c5589
commit 6682f9d0d4
2 changed files with 357 additions and 159 deletions
+343 -159
View File
@@ -4798,6 +4798,15 @@ html,body{background:#fff;display:block;height:100%;margin:0;cursor:auto;overflo
.screen,.home-scroll{overscroll-behavior:none;-webkit-overflow-scrolling:touch}
#variantToggle,#debugBack{display:none!important}
.tab-bar{display:none!important} /* 底栏归原生壳:隐藏 H5 原型自带 tabbar,修双底栏 */
/* 顶部品牌带吸顶(去框后).第一版用 flex 固定头(#home overflow:hidden + .home-scroll min-height:0)
在本机 WebView 上把页面整个卡死、无法滚动,放弃。改用与已验证可用的 records 页同款「绝对定位」法:
.screen(position:absolute 定位容器)内,品牌带绝对贴顶、home-scroll 绝对占满其下并自身滚动——
不依赖 flex 高度链或 position:sticky,在本机 WebView 上最稳(对齐 records .hist-scroll 的 top/bottom 定位)。
margin-top:0 去框后无假状态栏,贴系统状态栏下(原生壳已 statusBarsPadding);feed-sort top:0 紧贴品牌带下。 */
#home{overflow:hidden}
#home .brand-strip{position:absolute; top:0; left:0; right:0; margin-top:0; z-index:5}
#home .home-scroll{position:absolute; top:38px; left:0; right:0; bottom:0}
#home .feed-sort{top:0}
</style>
</head>
<body>
@@ -4840,8 +4849,7 @@ TODO: 补充 @owner / @from / @to / @notes(参照 FLOW.md
<!-- ===== HOME TAB ===== -->
<div class="screen active" id="home" style="background: var(--bg);">
<div class="home-scroll" style="flex: 1;overflow-y: auto;">
<!-- 顶部品牌带 wordmark -->
<!-- 顶部品牌带 wordmark(吸顶:移出滚动区,作 .screen 固定头,不随内容滚动) -->
<div class="brand-strip">
<div class="brand-strip-left">
<img class="brand-strip-logo" src="assets/shared/logos/sb-brand.png" alt="">
@@ -4849,6 +4857,7 @@ TODO: 补充 @owner / @from / @to / @notes(参照 FLOW.md
</div>
<span class="brand-strip-tagline">跨平台比价,用傻瓜</span>
</div>
<div class="home-scroll" style="flex: 1;overflow-y: auto;">
<!-- 顶部 Banner —— 3 个点位的可滑动轮播 -->
<div class="home-banner" id="homeBanner">
<div class="home-banner-track" id="homeBannerTrack">
@@ -5034,121 +5043,18 @@ TODO: 补充 @owner / @from / @to / @notes(参照 FLOW.md
<div style="font-size: 14px;color: #999;line-height: 1.5;">附近暂无优惠商品~去外卖平台解锁底价</div>
<button onclick="onHomeCompareClick()" 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;box-shadow: inset 0 1px 0 rgba(255,255,255,.7), 0 2px 6px rgba(255,179,0,.35);cursor: pointer;display: inline-flex;align-items: center;justify-content: center;">去比价</button>
</div>
<div class="home-feed">
<div class="feed-card">
<div class="feed-img"><img src="assets/home/index/feed/chabaidao.png" alt=""><span class="feed-cat-badge cat-waimai">外卖</span></div>
<div class="feed-body">
<div class="feed-title"><span class="shop-name">茶百道</span><span class="title-sep"></span>【新品】杨梅系列双杯兑换券·1张|208店通用</div>
<div class="feed-meta">
<span class="meta-left">600m 国贸店</span>
<span class="meta-right">热销8.5万+</span>
<!-- 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>
<div class="feed-tag">比团购省 5.5 元</div>
<div class="feed-pricerow">
<div class="feed-price"><span class="yuan">¥</span><span class="num">22<small>.5</small></span></div>
<span class="feed-original">¥28.0</span>
</div>
</div>
<div class="feed-grab"></div>
</div>
<div class="feed-card">
<div class="feed-img"><img src="assets/home/index/feed/subway.png" alt=""><span class="feed-cat-badge cat-waimai">外卖</span></div>
<div class="feed-body">
<div class="feed-title"><span class="shop-name">SUBWAY赛百味</span><span class="title-sep"></span>香溢金枪鱼双拼三明治小食两件套兑换券·1张|159店通用</div>
<div class="feed-meta">
<span class="meta-left">1.2km 大悦城店</span>
<span class="meta-right">热销3.2万+</span>
</div>
<div class="feed-tag">比团购省 19.1 元</div>
<div class="feed-pricerow">
<div class="feed-price"><span class="yuan">¥</span><span class="num">27<small>.9</small></span></div>
<span class="feed-original">¥47.0</span>
</div>
</div>
<div class="feed-grab"></div>
</div>
<div class="feed-card">
<div class="feed-img"><img src="assets/home/index/feed/luckin.png" alt=""><span class="feed-cat-badge cat-waimai">外卖</span></div>
<div class="feed-body">
<div class="feed-title"><span class="shop-name">瑞幸咖啡</span><span class="title-sep"></span>【双杯】人气拿铁-含生椰-城市专享兑换券·1张|27店通用</div>
<div class="feed-meta">
<span class="meta-left">800m 朝阳店</span>
<span class="meta-right">热销28万+</span>
</div>
<div class="feed-tag">外卖榜第 1</div>
<div class="feed-pricerow">
<div class="feed-price"><span class="yuan">¥</span><span class="num">20<small>.9</small></span></div>
<span class="feed-original">¥44.0</span>
</div>
</div>
<div class="feed-grab"></div>
</div>
<div class="feed-card">
<div class="feed-img"><img src="assets/home/index/feed/pizzahut.png" alt=""><span class="feed-cat-badge cat-waimai">外卖</span></div>
<div class="feed-body">
<div class="feed-title"><span class="shop-name">必胜客</span><span class="title-sep"></span>超级大满贯双拼披萨手拍普装兑换券·1张|13店通用</div>
<div class="feed-meta">
<span class="meta-left">1.5km 西单店</span>
<span class="meta-right">热销5.6万+</span>
</div>
<div class="feed-tag">比团购省 34.1 元</div>
<div class="feed-pricerow">
<div class="feed-price"><span class="yuan">¥</span><span class="num">34<small>.9</small></span></div>
<span class="feed-original">¥69.0</span>
</div>
</div>
<div class="feed-grab"></div>
</div>
<div class="feed-card">
<div class="feed-img"><img src="assets/home/index/feed/jubaoyuan.png" alt=""><span class="feed-cat-badge cat-tuangou">团购</span></div>
<div class="feed-body">
<div class="feed-title"><span class="shop-name">聚宝源</span><span class="title-sep"></span>300元代金券|不限张数|54店通用</div>
<div class="feed-meta">
<span class="meta-left">2.3km 簋街店</span>
<span class="meta-right">热销1.2万+</span>
</div>
<div class="feed-tag">全年低价</div>
<div class="feed-pricerow">
<div class="feed-price"><span class="yuan">¥</span><span class="num">218</span></div>
<span class="feed-original">¥300.0</span>
</div>
</div>
<div class="feed-grab"></div>
</div>
<div class="feed-card">
<div class="feed-img"><img src="assets/home/index/feed/mcdonalds.png" alt=""><span class="feed-cat-badge cat-waimai">外卖</span></div>
<div class="feed-body">
<div class="feed-title"><span class="shop-name">麦当劳</span><span class="title-sep"></span>【翅堡喝足】堡堡双人餐6件套|601店通用</div>
<div class="feed-meta">
<span class="meta-left">450m 国贸店</span>
<span class="meta-right">热销50万+</span>
</div>
<div class="feed-tag">外卖榜第 1</div>
<div class="feed-pricerow">
<div class="feed-price"><span class="yuan">¥</span><span class="num">37<small>.1</small></span></div>
<span class="feed-original">¥87.0</span>
</div>
</div>
<div class="feed-grab"></div>
</div>
<div class="feed-card">
<div class="feed-img"><img src="assets/home/index/feed/yoshinoya.png" alt=""><span class="feed-cat-badge cat-tuangou">团购</span></div>
<div class="feed-body">
<div class="feed-title"><span class="shop-name">吉野家</span><span class="title-sep"></span>【吉划算】1+1对对碰随心餐|174店通用</div>
<div class="feed-meta">
<span class="meta-left">1.1km 王府井店</span>
<span class="meta-right">热销12万+</span>
</div>
<div class="feed-tag">全年低价</div>
<div class="feed-pricerow">
<div class="feed-price"><span class="yuan">¥</span><span class="num">19<small>.9</small></span></div>
<span class="feed-original">¥27.5</span>
</div>
</div>
<div class="feed-grab"></div>
</div>
<div class="feed-end">已经到底了</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>
<!-- H2b: feed 卡片由 JS 按 /api/v1/meituan/feed|top-sales 动态渲染(原 mock 静态卡已移除) -->
<div class="home-feed"></div>
<div class="feed-end" style="display:none">已经到底了</div>
</div>
</div>
<div class="tab-bar">
@@ -6648,30 +6554,22 @@ function startFlipBanner() {
}
document.addEventListener('DOMContentLoaded', startFlipBanner);
// ===== feed 排序 tab智能推荐/距离最近/销量最高 =====
// 点未激活 tab → 激活 + 默认 desc;再点已激活 tab → desc⇄asc 翻转
// 「智能推荐」tab 无方向。data-dir 仅记录状态,无 UI 表达
// 点击任意 tab 后将 .feed-sort 整条 scrollIntoView 到 home-scroll 顶部
// ===== feed 排序 tab(智能推荐/距离最近/销量最高)→ 接真实数据(H2b) =====
// 点未激活 tab → 切高亮 + 拉真实数据(原 asc/desc 方向翻转是 mock 残留、后端不支持方向, 已删)
document.addEventListener('click', function (e) {
var item = e.target.closest('#home .feed-sort .fs-item');
if (!item) return;
if (!item || item.classList.contains('active')) return;
var group = item.closest('.feed-sort');
var isRec = item.dataset.key === 'rec';
if (!item.classList.contains('active')) {
group.querySelectorAll('.fs-item').forEach(function (o) {
o.classList.remove('active');
delete o.dataset.dir;
});
group.querySelectorAll('.fs-item').forEach(function (o) { o.classList.remove('active'); });
item.classList.add('active');
if (!isRec) item.dataset.dir = 'desc';
} else if (!isRec) {
item.dataset.dir = item.dataset.dir === 'desc' ? 'asc' : 'desc';
// 未授权定位:只切高亮、不打接口(对齐原生 selectTabWithoutLoad);授权后由 applyLocationState 拉
if (window.HomeFeed) {
if (HomeFeed.locationGranted()) HomeFeed.switchTab(item.dataset.key);
else HomeFeed.selectTabWithoutLoad(item.dataset.key);
}
// 自动滚到顶部仅当 sort 还未贴顶时才滚贴顶位置 device y=44 + sticky top=38 = y=82
// 已贴顶时若再调 scrollIntoView,浏览器会做一次 0 距离的 smooth scroll,造成可见的微位移
// 自动滚到顶部:仅当 sort 还未贴顶时才滚(贴顶位置 device y=44 + sticky top=38 = 82)
var stickY = 82;
var topNow = group.getBoundingClientRect().top;
if (topNow > stickY + 2) {
if (group.getBoundingClientRect().top > stickY + 2) {
group.scrollIntoView({ block: 'start', behavior: 'smooth' });
}
});
@@ -7713,10 +7611,12 @@ function couponChipShortName(name) {
function renderCouponPromptPlatforms() {
const grid = document.getElementById('couponPlatformGrid');
if (!grid) return;
const installedList = getInstalledPkgSet(); // 真装机态(同去点餐,替换写死的 mock app.installed)
const platforms = getVisibleApps(); // 3 张外卖版卡片
grid.innerHTML = platforms.map(app => {
const shortName = couponChipShortName(app.name);
if (app.installed) {
const installed = (PICKER_PACKAGES[app.name] || []).some(function(p){ return installedList.indexOf(p) !== -1; });
if (installed) {
return '<div data-checked="true" onclick="toggleCouponPlatform(this)" class="cp-card cp-card-yellow" style="background: #fff;border: 2px solid #FF5630;border-radius: 14px;padding: 12px 4px 8px;position: relative;">' +
'<img src="' + app.icon + '" style="width: 42px;height: 42px;border-radius: 8px;margin: 0 auto;display: block;"/>' +
'<div style="font-size: 12px;font-weight: 700;color: var(--gray-900);margin-top: 8px;">' + shortName + '</div>' +
@@ -8860,11 +8760,11 @@ function demoNext() {
// ===== 一键领券弹窗按钮 =====
function onClaimCouponConfirm() {
// 首页弹窗确认 → 跳 coupon 模块跑领券流程(#claim 跳过 phase-prompt,因为 home 弹窗已经选过平台)
// H4:确认 → 触发原生领券(权限校验→缺则 OEM 引导→前台服务领券)。[] = 全领(对齐原生 platformSelectable=false);
// 领券时用户在外卖 App,进度由原生悬浮窗展示(D2),H5 不接事件流、也不再跳原型 coupon 页。
const prompt = document.getElementById('demoCouponPrompt');
if (prompt) prompt.style.display = 'none';
try { localStorage.setItem('proto_homeClaimMode', 'true'); } catch(e) {}
location.href = '../coupon/index.html#claim';
if (window.SGBridge && typeof SGBridge.startCouponClaim === 'function') SGBridge.startCouponClaim([]);
}
// ===== 首页"一键领优惠"卡片:去领取 → 弹窗 → agent 领券 → 完成 → 回首页 =====
@@ -8883,22 +8783,14 @@ function renderCouponDoneFromLogos() {
}
function onTriClaimClick() {
// 未登录:先弹登录页(登录成功后回首页)
if (!isLoggedIn) { try{localStorage.setItem('proto_loginFrom','home');}catch(e){} window.loginFrom='home'; go('login'); return; }
// 已领取过:直接弹结算/战报弹窗,不重启 agent 流
if (homeCouponClaimed) {
if (homeCouponClaimSummary) {
couponTotal = homeCouponClaimSummary.total || 0;
}
const toast = document.getElementById('demoCouponDoneToast');
if (toast) {
toast.style.display = 'block';
renderCouponDoneFromLogos();
if (typeof runKCountUp === 'function') runKCountUp('demoCouponDoneCount', couponTotal, 1500, 200);
if (typeof startMidnightCountdown === 'function') startMidnightCountdown();
}
// H4:登录门槛(领券要归属用户)→ 未登录拉极光一键登录
if (window.SGBridge && typeof SGBridge.getToken === 'function' && !SGBridge.getToken()) { SGBridge.requestLogin(); return; }
// 今日已领 → 提示,不重复触发(完整「上次结算卡」回显留后续)
if (window.SGBridge && typeof SGBridge.getCouponClaimedToday === 'function' && SGBridge.getCouponClaimedToday()) {
if (typeof showToast === 'function') showToast('今日已领取~');
return;
}
// 弹领券确认弹窗:「一键自动领取」→ onClaimCouponConfirm → SGBridge.startCouponClaim
homeClaimMode = true;
couponTotal = 0;
renderCouponPromptPlatforms();
@@ -9064,6 +8956,8 @@ function getHomeLocationMode() {
return '';
}
function isHomeLocationGranted() {
// H2a:优先读真·系统定位权限;无桥(浏览器无 bridge)兜底原 localStorage 模式。
try { if (window.SGBridge && typeof SGBridge.isLocationGranted === 'function') return !!SGBridge.isLocationGranted(); } catch(e) {}
var mode = getHomeLocationMode();
if (mode === 'whileUsing') return true;
if (mode === 'once') {
@@ -9098,8 +8992,12 @@ function openLocSysPopup() {
if (s) s.style.display = 'flex';
}
function goAuthLocation() {
// H2a:接真·系统定位权限窗(永久拒绝由原生跳设置);无桥兜底原型假弹窗。
if (window.SGBridge && typeof SGBridge.requestLocationPermission === 'function') { SGBridge.requestLocationPermission(); return; }
openLocSysPopup();
}
// 回前台(定位授权窗 / 设置返回)重核授权态并刷新 feed 区(纯 H5,不依赖原生事件)。
document.addEventListener('visibilitychange', function(){ if (document.visibilityState === 'visible') { try { applyLocationState(); } catch(e){} } });
function isHomeNearbyEmpty(feed) {
try {
var params = new URLSearchParams(location.search);
@@ -9139,10 +9037,19 @@ function applyLocationState() {
var wrap = document.querySelector('#home .home-feed-wrap');
if (wrap) wrap.style.borderColor = (locationGranted && !nearbyEmpty) ? '' : 'transparent';
// 空态时让白色 feed 区撑满到底栏,下方不留灰
if (!locationGranted) { syncHomeFeedEmptyHeight(empty); return; }
if (!locationGranted) {
var fl0 = document.getElementById('homeFeedLoading'); if (fl0) fl0.style.display = 'none';
var fd0 = document.getElementById('homeFeedDegraded'); if (fd0) fd0.style.display = 'none';
syncHomeFeedEmptyHeight(empty); return;
}
empty.style.minHeight = '';
if (nearbyEmpty && noDealEmpty) syncHomeFeedEmptyHeight(noDealEmpty);
else if (noDealEmpty) noDealEmpty.style.minHeight = '';
// H2b: 已授权(未授权上面已 return)→ 首次拉 feed / 坐标变化刷新;loading/empty/degraded 由 HomeFeedStates 接管
if (window.HomeFeed) {
if (!HomeFeed.state.loadedOnce && !HomeFeed.state.loading) HomeFeed.loadFirst();
else HomeFeed.refreshIfCoordsChanged();
}
}
document.addEventListener('DOMContentLoaded', applyLocationState);
window.addEventListener('load', applyLocationState);
@@ -10077,13 +9984,26 @@ function onPlatformStatsClick() {
if (typeof go === 'function') go('profile');
}
// 比价入口 picker:动态渲染 3 平台(已装/未装区分)
// 平台候选包(对齐原生 HOME_PICKER_APPS):任一已装即「已装」;点击拉起把整组候选包交原生取首个已装。
var PICKER_PACKAGES = {
'美团': ['com.sankuai.meituan', 'com.sankuai.meituan.takeoutnew'],
'淘宝': ['com.taobao.taobao', 'me.ele'],
'京东': ['com.jingdong.app.mall'],
};
function getInstalledPkgSet() {
try { if (window.SGBridge && typeof SGBridge.getInstalledApps === 'function') return SGBridge.getInstalledApps() || []; } catch(e) {}
return [];
}
// 比价入口 picker:动态渲染 3 平台(真装机态:getInstalledApps 判候选包是否已装)
function renderHomePickerPlatforms() {
var grid = document.getElementById('homePickerGrid');
if (!grid || typeof getVisibleApps !== 'function') return;
var installedList = getInstalledPkgSet();
var platforms = getVisibleApps();
grid.innerHTML = platforms.map(function(app) {
if (app.installed) {
var pkgs = PICKER_PACKAGES[app.name] || [];
var installed = pkgs.some(function(p){ return installedList.indexOf(p) !== -1; });
if (installed) {
return '<div class="home-picker-item" data-platform="' + app.name + '">' +
'<div class="home-picker-logo"><img src="' + app.icon + '" alt=""></div>' +
'<div class="home-picker-name">' + app.name + '</div>' +
@@ -10101,8 +10021,7 @@ function renderHomePickerPlatforms() {
// 比价入口 onclick → 渲染平台 + 弹 picker
function onHomeCompareClick() {
// 未登录:先弹登录页(登录成功后回首页)
if (!isLoggedIn) { try{localStorage.setItem('proto_loginFrom','home');}catch(e){} window.loginFrom='home'; go('login'); return; }
// H3:拉起外卖不需登录态;「未登录引导」连同 getAuthState 留后续登录态子项。直接渲染 + 弹平台选择。
renderHomePickerPlatforms();
var mask = document.getElementById('homePickerMask');
if (mask) mask.classList.add('show');
@@ -10130,7 +10049,9 @@ function closeHomePicker() {
if (app) onPlatformInstallClick(app.id);
}
} else {
if (typeof showToast === 'function') showToast('已跳「' + name + '」');
// 已装:拉起对应平台 App(整组候选包交原生取首个已装,NEW_TASK|CLEAR_TASK 冷启首页)
var pkgs = PICKER_PACKAGES[name] || [];
if (pkgs.length && window.SGBridge && typeof SGBridge.launchApp === 'function') SGBridge.launchApp(pkgs);
}
});
})();
@@ -10153,9 +10074,272 @@ function closeHomePicker() {
else updateWelfareCheckinTabDot();
})();
</script>
<script src="../shared/bridge.js"></script>
<script>
/* H1:首页静态壳,导航/触发类先 no-op,H2-H4 接 SGBridge */
window.go=function(){};window.onTriClaimClick=function(){};window.onHomeCompareClick=function(){};window.goAuthLocation=function(){};
/* 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: '/api/v1/meituan/feed', distance: '/api/v1/meituan/feed', sales: '/api/v1/meituan/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 reqBody(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(ENDPOINT[tab], {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqBody(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>
<script>
/* H2b feed 三态 + 抢:loading 首次转圈 / empty 复用「附近暂无…去比价」/ degraded 服务繁忙;
抢 → referral-link([1,3]) → openMeituan(deeplink||h5)。与 H2a applyLocationState 协作切态。 */
(function (global) {
'use strict';
function el(id) { return document.getElementById(id); }
function hide(n) { if (n) n.style.display = 'none'; }
// feed 子态机:loading/degraded 本函数管;ok/empty 交回 applyLocationState 按卡片数切 feed/附近暂无空态。
global.HomeFeedStates = function (kind) {
var feed = document.querySelector('#home .home-feed');
var loading = el('homeFeedLoading');
var degraded = el('homeFeedDegraded');
var noDeal = el('homeFeedNoDealEmpty');
var hasItems = !!(global.HomeFeed && HomeFeed.state.items.length);
if (kind === 'loading' && !hasItems) { // 首次加载中:转圈,压住空态/feed
if (loading) loading.style.display = 'flex';
hide(degraded); hide(noDeal); if (feed) feed.style.display = 'none';
return;
}
if (kind === 'degraded' && !hasItems) { // 上游失败且无数据:服务繁忙
if (degraded) degraded.style.display = 'flex';
hide(loading); hide(noDeal); if (feed) feed.style.display = 'none';
return;
}
// ok / empty / loadingMore / 有数据时的 degraded:收起 loading+degraded,交 H2a 按卡片数定 feed/附近暂无
hide(loading); hide(degraded);
try { applyLocationState(); } catch (e) {}
};
// 抢:换链(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; });
});
})(window);
</script>
<script>
/* H1→收尾:导航类 go() 已接 SGBridge.navigate(原生壳跳 教程/邀请/比价结果);去领取/去点餐/去授权仍占位(H3/H4)。
占位 = 轻量 toast + console,不触发原型多屏切换(其它 .screen 已删,原型切屏会 JS 报错)。
仅真实点击才弹 toast:pointerdown 记最近手势,过滤加载时的自动调用(如领券提醒 setTimeout(onTriClaimClick))。 */
(function(){
var lastGesture = 0;
document.addEventListener('pointerdown', function(){ lastGesture = Date.now(); }, true);
var hideTimer;
function placeholderToast(){
var el = document.getElementById('h1PlaceholderToast');
if (!el){
el = document.createElement('div');
el.id = 'h1PlaceholderToast';
el.style.cssText = 'position:fixed;left:50%;bottom:96px;transform:translateX(-50%);max-width:78%;'
+ 'background:rgba(0,0,0,.8);color:#fff;font-size:13px;font-weight:500;line-height:1.4;text-align:center;'
+ 'padding:9px 16px;border-radius:10px;z-index:9999;opacity:0;transition:opacity .18s;pointer-events:none;';
document.body.appendChild(el);
}
el.textContent = '功能开发中,敬请期待';
el.style.opacity = '1';
clearTimeout(hideTimer);
hideTimer = setTimeout(function(){ el.style.opacity = '0'; }, 1400);
}
function placeholder(label){
return function(){
try { console.log('[H1 占位] ' + label + ' — H2-H4 接桥'); } catch(e){}
if (Date.now() - lastGesture < 1200) placeholderToast(); // 仅手势触发才弹,滤掉加载时自动调用
};
}
// 导航类按钮 → SGBridge.navigate(原生壳跳真实页);其余目标本轮未接,维持占位
var NAV_ROUTES = { guideVideo: 'guideVideo', invite: 'invite', compresult: 'compareResult' };
window.go = function(target){
var route = NAV_ROUTES[target];
if (route && window.SGBridge && typeof SGBridge.navigate === 'function') { SGBridge.navigate(route); return; }
try { console.log('[H1 占位] go(' + (target == null ? '' : target) + ') — 未接线/无桥,占位'); } catch(e){}
if (Date.now() - lastGesture < 1200) placeholderToast();
};
// onTriClaimClick(去领取)已接真实领券(见上方定义),从占位移除
// onHomeCompareClick(去点餐)已接真实 picker + launchApp(见上方定义),从占位移除;去领取/去授权仍占位
// goAuthLocation(去授权)已接真·定位权限(见上方定义),从占位移除;去领取仍占位
})();
/* H1:三数据卡 + 跑马灯接同源 /api/v1/platform/*(均不鉴权;失败回落原型 mock) */
(function(){
+14
View File
@@ -56,3 +56,17 @@ def test_h5_bridge_has_get_location(client) -> None:
body = resp.text
assert "getLocation" in body
assert "116.404" in body and "39.928" in body # 浏览器 mock 北京兜底
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 头图已移除
assert "比团购省 5.5 元" not in body # 随原生:mock 卡的「比团购省」标签已删(渲染只用 price_label/rank_label)