接入上报记录列表 H5(第三个 session 的孤儿页,补提交整合)

h5/reports/index.html(审核中/已通过/未通过三态+计数,真接 /api/v1/report/records)+ logos;android REPORT_RECORDS 路由稍后接 H5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
陈世睿
2026-06-29 17:13:21 +08:00
parent 8d08986c00
commit cdfe66dcee
4 changed files with 601 additions and 0 deletions
+601
View File
@@ -0,0 +1,601 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>上报记录 · 傻瓜比价</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
/* 【去框】原型 html/body 是黑底 + flex 居中(浏览器手机框预览用)。WebView 里直接铺页面色、不居中。
height:100% 链让 .device/.screen 能拿到 WebView 真实高度(min-height:100vh 在 WebView 不可靠会坍缩)。 */
html, body { height: 100%; }
html { background: var(--color-surface-page); }
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
background: var(--color-surface-page);
}
:root {
--color-primary: #FFD600;
--color-on-primary: #1A1A1A;
--color-surface-page: #F5F5F5;
--color-surface-card: #FFFFFF;
--color-surface-muted: #FAFAFA;
--color-ink: #1A1A1A;
--color-ink-muted: #666666;
--color-ink-subtle: #999999;
--color-ink-disabled: #CCCCCC;
--color-divider: #F0F0F0;
--color-divider-strong: #E5E5E5;
--color-price-lowest: #FF3D00;
--color-price-lowest-bg: #FFEBEE;
--color-state-success-dark: #2E7D32;
--color-state-success-bg: #E8F5E9;
--color-state-warning-bg: #FFF3E0;
--color-state-warning-ink: #B57400;
--color-state-danger: #E53935;
--color-state-danger-bg: #FFEBEE;
--color-coin-deep: #5A3A00;
--radius: 14px;
--radius-sm: 10px;
--radius-pill: 999px;
--shadow-card: 0 1px 2px rgba(0,0,0,.03);
--font-num: 'DIN Alternate', 'DIN Pro', 'SF Pro Display', system-ui;
}
/* 【去框】原型 .device 是 375x812 的手机预览框(圆角 + 阴影 + 固定宽高)。
WebView 里改成全屏自适应容器:满宽、最少占满视口高、无圆角/阴影。 */
.device {
width: 100%;
height: 100%;
min-height: 100vh;
background: var(--color-surface-page);
overflow: hidden;
position: relative;
}
.screen {
position: relative;
height: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 【调试返回】原型左下角的浏览器调试返回按钮,WebView 里不需要 */
#debugBack { display: none; }
/* 【去框】原型假状态栏(9:41 + 信号格),App 顶部有原生状态栏/标题栏,这里隐藏 */
.statusbar { display: none; }
.topnav {
height: 44px;
display: flex;
align-items: center;
padding: 0 12px;
background: var(--color-surface-page);
flex-shrink: 0;
position: relative;
}
.topnav-back {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
background: none; border: none;
}
.topnav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: var(--color-ink);
}
.topnav-right {
margin-left: auto;
padding: 6px 4px;
font-size: 14px;
color: var(--color-ink-muted);
font-weight: 400;
background: none; border: none;
cursor: pointer;
}
.scroll {
flex: 1;
overflow-y: auto;
padding: 8px 14px 24px;
}
.scroll::-webkit-scrollbar { display: none; }
.filter-row {
display: flex;
gap: 8px;
padding: 4px 0 12px;
overflow-x: auto;
}
.filter-row::-webkit-scrollbar { display: none; }
.filter-chip {
flex-shrink: 0;
padding: 6px 14px;
border-radius: var(--radius-pill);
font-size: 13px;
font-weight: 400;
color: var(--color-ink-muted);
background: var(--color-surface-card);
border: .5px solid var(--color-divider-strong);
cursor: pointer;
}
.filter-chip.active {
background: var(--color-ink);
color: #fff;
font-weight: 600;
border-color: var(--color-ink);
}
.filter-chip .count { margin-left: 4px; opacity: .7; font-weight: 400; }
.rec-card {
background: var(--color-surface-card);
border-radius: var(--radius);
padding: 14px;
margin-bottom: 12px;
box-shadow: var(--shadow-card);
overflow: hidden;
}
.rec-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.rec-store {
font-size: 15px;
font-weight: 400;
color: var(--color-ink);
line-height: 1.4;
flex: 1;
min-width: 0;
}
.rec-dish {
font-size: 13px;
color: var(--color-ink-muted);
line-height: 1.5;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px 8px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.badge svg { width: 12px; height: 12px; }
.badge-pending { background: var(--color-state-warning-bg); color: var(--color-state-warning-ink); }
.badge-success { background: var(--color-state-success-bg); color: var(--color-state-success-dark); }
.badge-danger { background: var(--color-state-danger-bg); color: var(--color-state-danger); }
.rec-compare {
margin-top: 12px;
background: var(--color-surface-muted);
border-radius: var(--radius-sm);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.rec-compare-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.rec-compare-label {
color: var(--color-ink);
font-weight: 400;
width: 56px;
flex-shrink: 0;
}
.rec-compare-pl {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px 2px 2px;
background: #fff;
border-radius: 5px;
border: .5px solid var(--color-divider-strong);
}
.rec-compare-pl img {
width: 16px; height: 16px;
border-radius: 3px;
object-fit: cover;
flex-shrink: 0;
}
.rec-compare-pl span {
font-size: 12px;
color: var(--color-ink);
font-weight: 400;
line-height: 1.4;
}
.rec-compare-price {
margin-left: auto;
font-family: var(--font-num);
font-weight: 700;
font-size: 15px;
color: var(--color-ink);
}
.rec-compare-price .cur { font-size: 11px; font-weight: 600; margin-right: 1px; }
.rec-compare-row.reported .rec-compare-price { color: var(--color-price-lowest); }
.rec-reward {
margin-top: 12px;
background: linear-gradient(90deg, #FFF8E1 0%, #FFFDE7 100%);
border-radius: var(--radius-sm);
padding: 8px 12px;
display: flex;
align-items: baseline;
gap: 4px;
font-size: 13px;
color: var(--color-coin-deep);
font-weight: 400;
}
.rec-reward-num {
font-family: var(--font-num);
font-size: 15px;
font-weight: 700;
}
.rec-reject {
margin-top: 12px;
background: var(--color-state-danger-bg);
border-radius: var(--radius-sm);
padding: 10px 12px;
font-size: 13px;
color: var(--color-state-danger);
line-height: 1.5;
}
.rec-reject b { font-weight: 600; }
.list-end {
text-align: center;
padding: 24px 0 8px;
font-size: 12px;
color: var(--color-ink-disabled);
font-weight: 400;
}
/* 空态(未登录 / 无记录)—— 原型没有,照"比价记录页"补一个轻量居中空态,沿用其字号/颜色 */
.rec-empty {
display: none;
padding: 96px 24px;
text-align: center;
color: var(--color-ink-subtle);
font-size: 14px;
line-height: 1.7;
}
</style>
<script src="../shared/bridge.js"></script>
</head>
<body>
<div class="device" id="app">
<div id="debugBack" onclick="goBack()">返回</div>
<div class="screen active" id="reports">
<!-- 【去框】假状态栏:App 内有原生状态栏,已 display:none 隐藏,保留 DOM 防脚本引用报错 -->
<div class="statusbar">
<span>9:41</span>
<span class="statusbar-right">
<svg width="18" height="10" viewBox="0 0 18 10" fill="#1A1A1A"><rect x="0" y="6" width="3" height="4" rx=".5"/><rect x="5" y="4" width="3" height="6" rx=".5"/><rect x="10" y="2" width="3" height="8" rx=".5"/><rect x="15" y="0" width="3" height="10" rx=".5"/></svg>
<svg width="16" height="11" viewBox="0 0 16 11" fill="#1A1A1A"><path d="M8 11l8-8c-4.5-4-11.5-4-16 0l8 8z"/></svg>
<svg width="24" height="11" viewBox="0 0 24 11" fill="none" stroke="#1A1A1A"><rect x=".5" y=".5" width="20" height="10" rx="2.5"/><rect x="2" y="2" width="17" height="7" rx="1.2" fill="#1A1A1A"/><rect x="22" y="3.5" width="1.5" height="4" rx=".5" fill="#1A1A1A"/></svg>
</span>
</div>
<div class="topnav">
<!-- 返回交给原生接管(占位 console.log),保留按钮视觉 -->
<button class="topnav-back" onclick="goBack()" aria-label="返回">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#1A1A1A" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<div class="topnav-title">上报记录</div>
</div>
<div class="scroll">
<!-- 四态筛选:count 用 counts;点击本地按 status 过滤已拉全量 records 重渲染(不重新请求) -->
<div class="filter-row" id="filterRow">
<div class="filter-chip active" data-status="all" onclick="onFilterClick(this)">全部<span class="count" data-count="all">0</span></div>
<div class="filter-chip" data-status="pending" onclick="onFilterClick(this)">审核中<span class="count" data-count="pending">0</span></div>
<div class="filter-chip" data-status="approved" onclick="onFilterClick(this)">已通过<span class="count" data-count="approved">0</span></div>
<div class="filter-chip" data-status="rejected" onclick="onFilterClick(this)">未通过<span class="count" data-count="rejected">0</span></div>
</div>
<!-- 卡片列表(JS 动态渲染,替代原型 4 张写死 rec-card -->
<div id="recList"></div>
<!-- 列表末尾「— 已显示全部 N 条 —」,N = 当前筛选后条数 -->
<div class="list-end" id="listEnd"></div>
<!-- 空态(未登录 / 无记录),由 JS 控制显隐 + 文案 -->
<div class="rec-empty" id="recEmpty"></div>
</div>
</div>
</div>
<script>
/* ============================================================
* 数据层(与「比价记录页」records/index.html 完全同款写法)
* SGBridge.getToken(同源相对路径,无 getApiBase)、401→requestLogin、
* 无 token→空态、无 Bridge→假数据预览;snake_case → 渲染字段
* ============================================================ */
// ===== 平台 → logo 映射 =====
// 原型用 ../../assets/shared/logos/{meituan-waimai,taobao-shanguang,jd-waimai}.png
// 原封不动拷进本页 logos/(保留原型文件名 -waimai/-shanguang,与原型逐字一致)。
// 原最低价 / 上报价的平台是动态的(original_platform_id / reported_platform_id),先归一化再取图。
var PLATFORM_LOGOS = {
'meituan-waimai': 'logos/meituan-waimai.png',
'taobao-shanguang': 'logos/taobao-shanguang.png',
'jd-waimai': 'logos/jd-waimai.png',
};
// ===== 平台别名归一化(复用「比价记录页」PLATFORM_ALIASES 思路)=====
// 后端 platform_id 是短名(meituan/taobao_flash/jd_waimai),统一归一成规范 id 再取 logo;找不到返回 null。
var PLATFORM_ALIASES = {};
[
['meituan-waimai', ['meituan','meituan-waimai','meituan_waimai','美团','美团外卖']],
['taobao-shanguang', ['taobao_flash','taobao-shanguang','taobao_shanguang','taobao','淘宝闪购','淘宝']],
['jd-waimai', ['jd_waimai','jd_waimai_standalone','jd-waimai','jingdong-waimai','jingdong_waimai','jingdong','jd','京东外卖','京东','京东到家']],
].forEach(function (pair) {
pair[1].forEach(function (a) { PLATFORM_ALIASES[a.toLowerCase()] = pair[0]; });
});
function resolvePlatformId(idOrName) {
return PLATFORM_ALIASES[String(idOrName || '').toLowerCase()] || null;
}
// 取平台 logo:归一化命中→对应 png;命中不了→空串(img 不显示也不破图)
function platformLogo(idOrName) {
var id = resolvePlatformId(idOrName);
return id ? PLATFORM_LOGOS[id] : '';
}
// ===== 状态映射:后端 status → badge class + 文案 + 图标 =====
// 图标 svg 照搬原型三个 badge 内对应的 svg(审核中=时钟 / 已通过=对勾 / 未通过=叉)。
var STATUS_MAP = {
pending: { cls: 'badge-pending', text: '审核中',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>' },
approved: { cls: 'badge-success', text: '已通过',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7"/></svg>' },
rejected: { cls: 'badge-danger', text: '未通过',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9 9l6 6M15 9l-6 6"/></svg>' },
};
// ===== 价格:后端是分(cents),显示 = (cents/100).toFixed(2) =====
function fenToYuan(cents) {
return (Number(cents || 0) / 100).toFixed(2);
}
// ===== 假数据(仅电脑浏览器预览用;装进 App 后走真接口)=====
// 字段用后端真实会返回的 snake_case(顺带验证归一化 + 分换算)。覆盖三态 + 不同平台。
var MOCK = {
records: [
{
store_name: '麦稻中式健康菜(首都机场店)',
dish_summary: '秘制口水鸡、干锅土豆片、白米饭',
status: 'pending',
original_platform_id: 'taobao_flash', original_platform_name: '淘宝闪购', original_price_cents: 1910,
reported_platform_id: 'meituan', reported_platform_name: '美团外卖', reported_price_cents: 1750,
},
{
store_name: '蜀大侠火锅(朝阳大悦城店)',
dish_summary: '毛肚拼盘、鲜鸭血、招牌牛肉',
status: 'approved', reward_coins: 1000,
original_platform_id: 'meituan', original_platform_name: '美团外卖', original_price_cents: 18800,
reported_platform_id: 'jd_waimai', reported_platform_name: '京东外卖', reported_price_cents: 15600,
},
{
store_name: '和府捞面(国贸店)',
dish_summary: '草本汤养鸡汤面、卤蛋',
status: 'rejected', reject_reason: '截图模糊,无法确认商品和价格信息。请重新上传清晰截图。',
original_platform_id: 'meituan', original_platform_name: '美团外卖', original_price_cents: 4200,
reported_platform_id: 'taobao_flash', reported_platform_name: '淘宝闪购', reported_price_cents: 3850,
},
{
store_name: '瑞幸咖啡(望京 SOHO 店)',
dish_summary: '生椰拿铁、丝绒拿铁',
status: 'approved', reward_coins: 1000,
original_platform_id: 'jd_waimai', original_platform_name: '京东外卖', original_price_cents: 2280,
reported_platform_id: 'taobao_flash', reported_platform_name: '淘宝闪购', reported_price_cents: 1790,
},
],
counts: { all: 4, pending: 1, approved: 2, rejected: 1 },
};
// ===== 数据加载:App 里走真接口,浏览器预览走假数据 =====
// 同源部署:fetch 走相对路径 /api/v1/...(不依赖原生给域名,无 getApiBase)。
// 返回 null 表示未登录(无 token)→ 空态"登录后查看上报记录"。
async function loadReports() {
var hasBridge = window.SGBridge && typeof SGBridge.getToken === 'function';
if (hasBridge) {
var token = SGBridge.getToken();
if (!token) return null; // 未登录 → 空态
try {
// status 不传 = 全部(四态切换在本地过滤,不重新请求)
var res = await fetch('/api/v1/report/records',
{ headers: { 'Authorization': 'Bearer ' + token } });
if (res.status === 401) { SGBridge.requestLogin && SGBridge.requestLogin(); return null; }
var data = await res.json();
return {
records: data.records || [],
counts: data.counts || computeCounts(data.records || []),
};
} catch (e) { /* 网络失败,落回预览数据 */ }
}
return MOCK; // 浏览器预览(无 Bridge
}
// counts 兜底:万一后端没带 counts,就本地数一遍
function computeCounts(records) {
var c = { all: records.length, pending: 0, approved: 0, rejected: 0 };
records.forEach(function (r) { if (c[r.status] != null) c[r.status]++; });
return c;
}
/* ============================================================
* 渲染(用原型的 class,把 4 张写死卡片改成动态生成)
* ============================================================ */
var RECORDS = []; // null = 未登录态
var COUNTS = { all: 0, pending: 0, approved: 0, rejected: 0 };
var currentStatus = 'all'; // 当前筛选:all|pending|approved|rejected
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
// 单行「原最低价 / 您上报的」比价行
// label:「原最低价」=original / 「您上报的」=reported(走红色,由 .reported 类控制)
function renderCompareRow(kind, platformId, platformName, cents) {
var logo = platformLogo(platformId || platformName);
var label = (kind === 'reported') ? '您上报的' : '原最低价';
// platform_name 直接用后端中文名显示
return '<div class="rec-compare-row ' + kind + '">' +
'<span class="rec-compare-label">' + label + '</span>' +
'<span class="rec-compare-pl">' +
(logo ? '<img src="' + logo + '" alt="">' : '') +
'<span>' + esc(platformName || '') + '</span>' +
'</span>' +
'<span class="rec-compare-price"><span class="cur">¥</span>' + fenToYuan(cents) + '</span>' +
'</div>';
}
// 单张上报卡片(结构/class 完全对齐原型)
function renderRecCard(rec) {
var st = STATUS_MAP[rec.status] || STATUS_MAP.pending;
var head = '<div class="rec-head">' +
'<div>' +
'<div class="rec-store">' + esc(rec.store_name || '') + '</div>' +
'<div class="rec-dish">' + esc(rec.dish_summary || '') + '</div>' +
'</div>' +
'<span class="badge ' + st.cls + '">' + st.icon + st.text + '</span>' +
'</div>';
var compare = '<div class="rec-compare">' +
renderCompareRow('original', rec.original_platform_id, rec.original_platform_name, rec.original_price_cents) +
renderCompareRow('reported', rec.reported_platform_id, rec.reported_platform_name, rec.reported_price_cents) +
'</div>';
// 通过 → 金币到账横幅(用后端 reward_coins);未通过 → 原因;审核中 → 都不追加
var extra = '';
if (rec.status === 'approved') {
extra = '<div class="rec-reward"><span>+</span>' +
'<span class="rec-reward-num">' + Number(rec.reward_coins || 0) + '</span>' +
'<span>金币已到账</span></div>';
} else if (rec.status === 'rejected') {
extra = '<div class="rec-reject"><b>未通过原因:</b>' + esc(rec.reject_reason || '') + '</div>';
}
var card = document.createElement('div');
card.className = 'rec-card';
card.innerHTML = head + compare + extra;
return card;
}
// 刷新四态 chip 的 count 数字
function renderCounts() {
['all', 'pending', 'approved', 'rejected'].forEach(function (k) {
var el = document.querySelector('.count[data-count="' + k + '"]');
if (el) el.textContent = (COUNTS[k] != null ? COUNTS[k] : 0);
});
}
// 渲染列表 + 末尾「已显示全部 N 条」+ 空态
function renderList() {
var listEl = document.getElementById('recList');
var endEl = document.getElementById('listEnd');
var emptyEl = document.getElementById('recEmpty');
if (!listEl) return;
listEl.innerHTML = '';
// 未登录态:清空列表,空态提示登录
if (RECORDS === null) {
endEl.style.display = 'none';
emptyEl.style.display = 'block';
emptyEl.textContent = '登录后查看上报记录';
return;
}
// 本地按当前 status 过滤已拉全量(all = 不过滤)
var shown = (currentStatus === 'all')
? RECORDS
: RECORDS.filter(function (r) { return r.status === currentStatus; });
if (shown.length === 0) {
endEl.style.display = 'none';
emptyEl.style.display = 'block';
emptyEl.textContent = '暂无上报记录';
return;
}
emptyEl.style.display = 'none';
shown.forEach(function (rec) { listEl.appendChild(renderRecCard(rec)); });
// N = 当前筛选后条数
endEl.style.display = 'block';
endEl.textContent = '— 已显示全部 ' + shown.length + ' 条 —';
}
// 点 chip:切 active + 本地按 status 过滤重渲染(不重新请求)
function onFilterClick(el) {
if (RECORDS === null) return; // 未登录态不响应筛选
document.querySelectorAll('.filter-chip').forEach(function (c) { c.classList.remove('active'); });
el.classList.add('active');
currentStatus = el.getAttribute('data-status') || 'all';
renderList();
}
/* ============================================================
* 导航占位(App 原生接管返回 / 跳转),同「比价记录页」用 console.log 兜底
* ============================================================ */
function navigate(id) { console.log('[stub] navigateApp 内由原生处理跳转):', id); }
window.go = navigate;
// 顶栏返回箭头:调原生 SGBridge.closePage() 回上一页(WebViewScreen 的 onBack → popBackStack);浏览器预览仅 log
function goBack() {
if (window.SGBridge && typeof SGBridge.closePage === 'function') { SGBridge.closePage(); }
else { console.log('[stub] goBack(浏览器预览,无原生返回)'); }
}
window.goBack = goBack;
function goBackToProfile() { goBack(); } // 从「我的」进来的返回同样走 closePage 回上一页
window.goBackToProfile = goBackToProfile;
/* ============================================================
* 启动:拉数据 → 渲染
* ============================================================ */
(async function init(){
var data = await loadReports();
if (data === null) {
// 未登录(无 token)→ 空态
RECORDS = null;
COUNTS = { all: 0, pending: 0, approved: 0, rejected: 0 };
} else {
RECORDS = data.records || [];
COUNTS = data.counts || computeCounts(RECORDS);
}
renderCounts();
renderList();
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB