chore(web): 删除 scan.html,清理 /scan/ nginx 路由和文档

scan.html 从未被实际触发——二维码 URL 格式为 /product/<uuid>,
由 Flutter Web PublicProductScreen 处理,scan.html 是孤立死页。

- 删除 web/scan.html
- 移除 nginx-jiu.conf 中 /scan/ location 块
- 更新文档路由表(/scan/<id> → /product/<uuid>)
- 标记 todo #4 完成

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-08 02:07:19 +08:00
parent ce92d619ed
commit 99f34a6223
5 changed files with 29 additions and 415 deletions
-6
View File
@@ -53,12 +53,6 @@ server {
try_files $uri /app/index.html;
}
# 扫码页(/scan/<id> → scan.htmlJS 从 URL 读取 id
location /scan/ {
root /opt/jiu/marketing;
try_files /scan.html =404;
}
# 桌面客户端安装包下载(Windows .exe / macOS .zip
location /downloads/ {
alias /opt/jiu/downloads/;
+2 -3
View File
@@ -155,8 +155,7 @@ git push nas main && git push nas v1.1.0
│ ├── assets/
│ ├── index.html
│ ├── docs.html
── download.html
│ └── scan.html
── download.html
├── images/ # 商品图片上传目录
└── web-old/ # 上一版本 Flutter Web(回滚备用)
```
@@ -175,5 +174,5 @@ Systemd 服务名:`jiu`
| `/api/*`, `/health`, `/version` | Go 后端 API |
| `/images/*` | 商品图片静态文件 |
| `/app/` | Flutter 管理端 SPA |
| `/product/<uuid>` | 商品扫码公开页(Flutter Web 路由) |
| `/` | 营销首页 |
| `/scan/<id>` | 商品扫码页 |
+23 -23
View File
@@ -102,8 +102,8 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="header-meta">生成于 2026-06-08 · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>6</strong>全部</div>
<div class="stat-pill"><strong>5</strong>未完成</div>
<div class="stat-pill"><strong>1</strong>已完成</div>
<div class="stat-pill"><strong>4</strong>未完成</div>
<div class="stat-pill"><strong>2</strong>已完成</div>
</div>
</div>
</header>
@@ -132,7 +132,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="wrap">
<div class="section-title">📋 未完成(5</div>
<div class="section-title">📋 未完成(4</div>
<ul class="todo-list" id="pendingList">
<li class="todo-card"
@@ -173,25 +173,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
</li>
<li class="todo-card"
data-id="4"
data-level="low"
data-tags="前端,Web"
data-done="0">
<div class="card-header">
<span class="item-title">scan.html / features 走 passthrough 不享模板</span>
<span class="tag t-low">一般 / 优化</span>
</div>
<div class="item-desc">features/scan.html 直接复制,不经 njk 模板,页脚/备案/CSS 都得手动同步。建议长期改造为模板化。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li class="todo-card"
data-id="5"
data-level="low"
@@ -232,10 +213,29 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</ul>
<div class="done-section">
<div class="section-title done-title">✅ 已完成(1</div>
<div class="section-title done-title">✅ 已完成(2</div>
<button class="done-toggle" id="doneToggle">▾ 展开已完成列表</button>
<ul class="todo-list hidden" id="doneList">
<li class="todo-card done"
data-id="4"
data-level="low"
data-tags="前端,Web"
data-done="1">
<div class="card-header">
<span class="item-title">scan.html / features 走 passthrough 不享模板</span>
<span class="tag t-low">一般 / 优化</span>
</div>
<div class="item-desc">features/scan.html 直接复制,不经 njk 模板,页脚/备案/CSS 都得手动同步。建议长期改造为模板化。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li class="todo-card done"
data-id="1"
data-level="mid"
+4 -4
View File
@@ -1,7 +1,7 @@
{
"meta": {
"title": "酒库管理系统 — 项目 TODO",
"updated_at": "2026-06-07T17:48:38.554Z"
"updated_at": "2026-06-07T18:07:13.211Z"
},
"seq": 7,
"items": [
@@ -56,9 +56,9 @@
"Web"
],
"created_at": "2026-06-07T17:30:32.287Z",
"done": false,
"completed_at": null,
"version": null
"done": true,
"completed_at": "2026-06-07T18:07:13.211Z",
"version": "v1.0.23"
},
{
"id": 5,
-379
View File
@@ -1,379 +0,0 @@
<!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" />
<title>商品验真 — 岩美</title>
<meta name="description" content="岩美防伪验证 — 扫码查看商品详情与防伪信息" />
<link rel="stylesheet" href="/assets/color.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; font-family: var(--font-sans); color: #1A1612; background: #F7F3EE; -webkit-font-smoothing: antialiased; min-height: 100vh; }
img { display: block; max-width: 100%; }
/* STATE SCREENS */
.state-screen { display: flex; align-items: center; justify-content: center; min-height: 100vh; flex-direction: column; gap: 16px; padding: 32px; text-align: center; }
.spinner { width: 36px; height: 36px; border: 3px solid #E8E2D9; border-top-color: var(--brand-500); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.state-text { font-size: 14px; color: #7A7068; }
.error-icon { width: 48px; height: 48px; background: var(--danger-50); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 24px; }
.error-title { font-size: 16px; font-weight: 600; color: #1A1612; }
.error-sub { font-size: 13px; color: #7A7068; max-width: 260px; }
.retry-btn { margin-top: 4px; padding: 10px 24px; background: var(--brand-500); color: #fff; border: none; border-radius: 6px; font-size: 14px; font-family: inherit; cursor: pointer; }
.retry-btn:active { background: var(--brand-700); }
/* MAIN CONTENT */
#content { display: none; }
/* IMAGE GALLERY */
.gallery { position: relative; background: #EDE8E2; }
.gallery-main { position: relative; overflow: hidden; }
.gallery-main img { width: 100%; aspect-ratio: 1; object-fit: cover; display: none; }
.gallery-main img.active { display: block; }
.gallery-placeholder { width: 100%; aspect-ratio: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; }
.placeholder-mark { width: 56px; height: 56px; background: #0F3057; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.placeholder-mark::after { content: '岩'; font-size: 24px; font-weight: 700; color: rgba(255,255,255,0.9); }
.placeholder-text { font-size: 13px; color: #9E9580; }
.gallery-counter { position: absolute; right: 12px; bottom: 12px; background: rgba(0,0,0,0.5); color: #fff; font-size: 12px; padding: 3px 10px; border-radius: 12px; }
.thumbnails { display: flex; gap: 8px; padding: 10px 14px; overflow-x: auto; background: #EDE8E2; }
.thumbnails::-webkit-scrollbar { display: none; }
.thumb { width: 52px; height: 52px; border-radius: 4px; object-fit: cover; border: 2px solid transparent; flex-shrink: 0; cursor: pointer; }
.thumb.active { border-color: var(--brand-500); }
/* VERIFIED RIBBON */
.ribbon { background: #EAF4ED; padding: 10px 16px; display: flex; align-items: center; gap: 10px; }
.ribbon-check { width: 22px; height: 22px; background: #2E8B57; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.ribbon-check::after { content: '✓'; color: #fff; font-size: 13px; font-weight: 700; }
.ribbon-text { flex: 1; }
.ribbon-title { font-size: 13px; font-weight: 600; color: #1F6B41; }
.ribbon-sub { font-size: 11px; color: #4A7A5C; margin-top: 1px; }
.ribbon-icon { font-size: 18px; color: #2E8B57; }
/* TITLE */
.title-section { background: #fff; padding: 20px 16px 18px; }
.product-name { font-size: 22px; font-weight: 700; color: #1A1612; line-height: 1.3; margin: 0 0 6px; }
.product-subtitle { font-size: 14px; color: #7A7068; margin: 0 0 14px; line-height: 1.4; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag { display: inline-block; padding: 3px 9px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.tag-brand { background: #EEF4FB; color: #154072; }
.tag-unit { background: #F0ECE6; color: #6B6259; }
/* QUICK SPECS */
.quick-specs { background: #fff; display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid #F0ECE6; }
.quick-spec-item { padding: 16px 8px; text-align: center; border-left: 1px solid #F5F1EB; }
.quick-spec-item:first-child { border-left: none; }
.quick-spec-val { font-size: 20px; font-weight: 700; color: #1A1612; line-height: 1; font-variant-numeric: tabular-nums; }
.quick-spec-unit { font-size: 9px; color: #9E9580; font-weight: 500; margin-left: 1px; }
.quick-spec-label { font-size: 11px; color: #9E9580; margin-top: 4px; letter-spacing: 0.04em; }
/* PARAMS */
.params-card { background: #fff; margin-top: 10px; }
.card-header { padding: 14px 16px 12px; border-bottom: 1px solid #F0ECE6; font-size: 14px; font-weight: 600; color: #1A1612; display: flex; align-items: center; gap: 8px; }
.card-header::before { content: ''; display: block; width: 3px; height: 14px; background: #8B2331; border-radius: 2px; }
.param-row { display: flex; align-items: flex-start; padding: 10px 16px; border-bottom: 1px solid #F5F1EB; }
.param-row:last-child { border-bottom: none; }
.param-label { font-size: 13px; color: #9E9580; min-width: 72px; flex-shrink: 0; padding-top: 1px; }
.param-val { font-size: 13px; color: #1A1612; flex: 1; line-height: 1.5; }
/* AUTHENTICITY */
.auth-card { background: #FAF8F5; margin-top: 10px; padding: 20px 16px; }
.auth-inner { background: #fff; border: 1px solid #E8E4DC; border-radius: 8px; padding: 16px; display: flex; gap: 14px; align-items: center; }
.auth-rows { flex: 1; display: grid; grid-template-columns: 64px 1fr; gap: 8px; font-size: 12px; line-height: 1.7; }
.auth-lbl { color: #9E9580; }
.auth-val { color: #1A1612; font-weight: 500; }
.auth-mono { font-family: 'JetBrains Mono', monospace; color: var(--brand-700); font-weight: 600; letter-spacing: 0.04em; }
.auth-stamp { width: 72px; height: 72px; border-radius: 50%; border: 2px solid #8B2331; background: rgba(139,35,49,0.04); display: grid; place-items: center; flex-shrink: 0; position: relative; transform: rotate(-8deg); overflow: hidden; }
.auth-stamp-text { font-size: 8px; color: #8B2331; font-weight: 700; text-align: center; letter-spacing: 0.02em; line-height: 1.4; }
.auth-note { font-size: 11px; color: #A89E92; margin-top: 12px; text-align: center; line-height: 1.6; }
/* SHOP CARD */
.shop-card { background: #fff; margin-top: 10px; }
.shop-name-row { display: flex; align-items: center; gap: 8px; padding: 14px 16px 12px; border-bottom: 1px solid #F0ECE6; }
.shop-icon { font-size: 16px; }
.shop-name-text { font-size: 14px; font-weight: 600; color: #1A1612; }
/* FOOTER */
.footer { padding: 20px 16px 32px; display: flex; align-items: center; justify-content: center; gap: 8px; }
.footer-mark { width: 18px; height: 18px; background: #0F3057; border-radius: 3px; display: flex; align-items: center; justify-content: center; }
.footer-mark::after { content: '岩'; color: rgba(255,255,255,0.9); font-size: 10px; font-weight: 700; }
.footer-text { font-size: 11px; color: #A89E92; }
</style>
</head>
<body>
<div id="state-loading" class="state-screen">
<div class="spinner"></div>
<div class="state-text">正在查询商品信息…</div>
</div>
<div id="state-error" class="state-screen" style="display:none">
<div class="error-icon"></div>
<div class="error-title">商品不存在或已下架</div>
<div class="error-sub">请检查二维码是否完整,或联系出售方确认。</div>
<button class="retry-btn" onclick="load()">重试</button>
</div>
<div id="content">
<div class="gallery" id="gallery">
<div class="gallery-main" id="gallery-main">
<div class="gallery-placeholder" id="gallery-placeholder">
<div class="placeholder-mark"></div>
<div class="placeholder-text">暂无图片</div>
</div>
</div>
<div class="thumbnails" id="thumbnails" style="display:none"></div>
</div>
<div class="ribbon">
<div class="ribbon-check"></div>
<div class="ribbon-text">
<div class="ribbon-title">已通过岩美防伪验证</div>
<div class="ribbon-sub" id="ribbon-shop"></div>
</div>
<div class="ribbon-icon"></div>
</div>
<div class="title-section">
<h1 class="product-name" id="product-name"></h1>
<p class="product-subtitle" id="product-subtitle"></p>
<div class="tags" id="product-tags"></div>
</div>
<div class="quick-specs" id="quick-specs" style="display:none">
<!-- filled by JS -->
</div>
<div class="params-card" id="params-card">
<div class="card-header">商品参数</div>
<div id="params-rows"></div>
</div>
<div class="auth-card" id="auth-card" style="display:none">
<div class="card-header">批次与防伪</div>
<div class="auth-inner">
<div class="auth-rows" id="auth-rows"></div>
<div class="auth-stamp">
<div class="auth-stamp-text">岩美<br/>防伪<br/></div>
</div>
</div>
<div class="auth-note">如发现可疑商品请联系出售方确认</div>
</div>
<div class="shop-card" id="shop-card" style="display:none">
<div class="shop-name-row">
<span class="shop-icon">🏪</span>
<span class="shop-name-text" id="shop-display-name">门店信息</span>
</div>
<div id="shop-rows"></div>
</div>
<div class="footer">
<div class="footer-mark"></div>
<div class="footer-text">由岩美酒库管理系统提供防伪验证</div>
</div>
</div>
<script>
const API_BASE = location.hostname === 'localhost' ? 'http://localhost:8080/api/v1' : '/api/v1';
function getPublicId() {
// URL pattern: /scan/<public_id>
const parts = location.pathname.split('/').filter(Boolean);
const idx = parts.indexOf('scan');
return idx >= 0 ? parts[idx + 1] : null;
}
let _imgs = [];
let _current = 0;
function showImg(i) {
_current = i;
document.querySelectorAll('#gallery-main img').forEach((el, j) => {
el.classList.toggle('active', j === i);
});
document.querySelectorAll('.thumb').forEach((el, j) => {
el.classList.toggle('active', j === i);
});
const counter = document.getElementById('gallery-counter');
if (counter) counter.textContent = `${i + 1} / ${_imgs.length}`;
}
function renderGallery(imageUrls) {
const main = document.getElementById('gallery-main');
const thumbsEl = document.getElementById('thumbnails');
const placeholder = document.getElementById('gallery-placeholder');
if (!imageUrls || imageUrls.length === 0) return;
_imgs = imageUrls;
placeholder.style.display = 'none';
imageUrls.forEach((url, i) => {
const img = document.createElement('img');
img.src = url;
img.alt = '';
if (i === 0) img.classList.add('active');
img.onclick = () => openFullscreen(i);
main.appendChild(img);
});
if (imageUrls.length > 1) {
const counter = document.createElement('div');
counter.className = 'gallery-counter';
counter.id = 'gallery-counter';
counter.textContent = `1 / ${imageUrls.length}`;
main.appendChild(counter);
thumbsEl.style.display = 'flex';
imageUrls.forEach((url, i) => {
const t = document.createElement('img');
t.src = url;
t.className = 'thumb' + (i === 0 ? ' active' : '');
t.onclick = () => showImg(i);
thumbsEl.appendChild(t);
});
}
}
function openFullscreen(i) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.9);z-index:999;display:flex;align-items:center;justify-content:center;';
const img = document.createElement('img');
img.src = _imgs[i];
img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;';
overlay.appendChild(img);
overlay.onclick = () => document.body.removeChild(overlay);
document.body.appendChild(overlay);
}
function parseQuickSpecs(spec, productionDate) {
if (!spec) return [];
const items = [];
const degM = spec.match(/(\d+(?:\.\d+)?)\s*(?:度|°|%vol)/i);
if (degM) items.push({ v: degM[1], u: '°', l: '酒精度' });
const mlM = spec.match(/(\d+(?:\.\d+)?)\s*ml/i);
if (mlM) items.push({ v: mlM[1], u: 'ml', l: '净含量' });
const xiangM = spec.match(/([清浓酱兼馥凤]香)/);
if (xiangM) items.push({ v: xiangM[1], u: '', l: '香型' });
if (productionDate) {
const yrM = productionDate.match(/^(\d{4})/);
if (yrM) items.push({ v: yrM[1], u: '', l: '年份' });
}
return items;
}
function paramRow(label, value) {
return `<div class="param-row"><div class="param-label">${label}</div><div class="param-val">${value}</div></div>`;
}
function render(d) {
const name = d.name || '';
const series = d.series || '';
const spec = d.spec || '';
const brand = d.brand || '';
const unit = d.unit || '';
const description = d.description || '';
const shop = d.shop;
const batch = d.batch;
const baseUrl = location.origin;
const images = (d.images || []).map(img => baseUrl + img.url);
renderGallery(images);
document.getElementById('product-name').textContent = name;
const subtitleParts = [series, spec].filter(Boolean);
const subtitleEl = document.getElementById('product-subtitle');
subtitleEl.textContent = subtitleParts.join(' · ');
if (!subtitleParts.length) subtitleEl.style.display = 'none';
const tagsEl = document.getElementById('product-tags');
if (brand) tagsEl.innerHTML += `<span class="tag tag-brand">${brand}</span>`;
if (unit) tagsEl.innerHTML += `<span class="tag tag-unit">${unit}</span>`;
// Ribbon shop info
if (shop && shop.name) {
document.getElementById('ribbon-shop').textContent = `出售方:${shop.name}`;
}
// Quick Specs — parse from spec string
const quickSpecsEl = document.getElementById('quick-specs');
const qs = parseQuickSpecs(spec, batch ? batch.production_date : null);
if (qs.length > 0) {
quickSpecsEl.style.display = 'grid';
quickSpecsEl.innerHTML = qs.map(it => `
<div class="quick-spec-item">
<div class="quick-spec-val">${it.v}<span class="quick-spec-unit">${it.u||''}</span></div>
<div class="quick-spec-label">${it.l}</div>
</div>`).join('');
}
// Params
let paramsHtml = '';
if (spec) paramsHtml += paramRow('规格', spec);
if (brand) paramsHtml += paramRow('品牌', brand);
if (unit) paramsHtml += paramRow('单位', unit);
if (description) paramsHtml += paramRow('描述', description);
if (paramsHtml) {
document.getElementById('params-rows').innerHTML = paramsHtml;
} else {
document.getElementById('params-card').style.display = 'none';
}
// Authenticity (batch info)
if (batch && (batch.batch_no || batch.production_date || batch.in_stock_date)) {
const authCard = document.getElementById('auth-card');
let authHtml = '';
if (batch.batch_no) authHtml += `<div class="auth-lbl">批次号</div><div class="auth-val auth-mono">${batch.batch_no}</div>`;
if (batch.production_date) authHtml += `<div class="auth-lbl">生产日期</div><div class="auth-val">${batch.production_date}</div>`;
if (batch.in_stock_date) authHtml += `<div class="auth-lbl">入库日期</div><div class="auth-val">${batch.in_stock_date}</div>`;
document.getElementById('auth-rows').innerHTML = authHtml;
authCard.style.display = 'block';
}
// Shop card
if (shop) {
const shopCard = document.getElementById('shop-card');
const shopName = shop.name || '';
document.getElementById('shop-display-name').textContent = shopName || '门店信息';
let shopHtml = '';
if (shop.code) shopHtml += paramRow('门店编号', shop.code);
if (shop.address) shopHtml += paramRow('地址', shop.address);
if (shop.phone) shopHtml += paramRow('电话', shop.phone);
if (shopHtml) {
document.getElementById('shop-rows').innerHTML = shopHtml;
shopCard.style.display = 'block';
}
}
document.getElementById('state-loading').style.display = 'none';
document.getElementById('content').style.display = 'block';
}
function load() {
const pid = getPublicId();
if (!pid) {
document.getElementById('state-loading').style.display = 'none';
document.getElementById('state-error').style.display = 'flex';
return;
}
document.getElementById('state-loading').style.display = 'flex';
document.getElementById('state-error').style.display = 'none';
document.getElementById('content').style.display = 'none';
fetch(`${API_BASE}/public/products/${pid}`)
.then(r => { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(data => render(data.data))
.catch(() => {
document.getElementById('state-loading').style.display = 'none';
document.getElementById('state-error').style.display = 'flex';
});
}
load();
</script>
</body>
</html>