feat: v2 内容更新——assets 换 v2 构建+缓存策略改 no-cache+行程数据扩充+图片调整

- assets: index js/css 升级 v2(修复手改资源文件名哈希不变导致长缓存不生效)
- server.js: assets 缓存 immutable→no-cache
- admin.js: 后台功能更新(上传/占位图相关)
- data.json/data.seed.json: 行程与酒店内容扩充(4041→4274 行)
- hotels: 删 _placeholder.png, 增 序成品稿-封面.jpg

来源: hulai-travel-site(1).zip (2026-08-18)
这个提交包含在:
wx
2026-08-18 14:44:08 +08:00
父节点 45adc7ad47
当前提交 2a979362c2
共修改 9 个文件,包含 1468 行新增和 966 行删除
+61 -25
查看文件
@@ -77,12 +77,28 @@ async function api(path, opts) {
return data;
}
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
function clampRating(v) { v = parseInt(v, 10); return (v >= 1 && v <= 5) ? v : null; }
function fmtRating(o) {
const st = clampRating(o.stars), di = clampRating(o.diamonds);
const parts = [];
if (st) parts.push('⭐'.repeat(st));
if (di) parts.push('💎'.repeat(di));
return parts.join(' ');
}
function ratingSelects(obj, handler, arg) {
const mk = (key, icon, label) =>
'<div class="field"><label>' + label + '</label><select onchange="' + handler + '(this,' + arg + ')" data-key="' + key + '">' +
'<option value="">无</option>' +
[1, 2, 3, 4, 5].map(s => '<option value="' + s + '"' + (clampRating(obj[key]) === s ? ' selected' : '') + '>' + icon.repeat(s) + '</option>').join('') +
'</select></div>';
return mk('stars', '⭐', '星级 ⭐') + mk('diamonds', '💎', '钻级 💎');
}
function newHotel(day) {
return { day: day || 1, city: '', name: '新酒店', subtitle: '', stars: 4, year: '2025年装修',
return { day: day || 1, city: '', name: '新酒店', subtitle: '', stars: null, diamonds: null, year: '2025年装修',
location: '', bedType: '', facilities: '', breakfast: '含早餐', alternatives: [], images: [], altHotels: [] };
}
function newAltHotel(name) {
return { name: name || '新备选酒店', subtitle: '', stars: 4, year: '', location: '', bedType: '',
return { name: name || '新备选酒店', subtitle: '', stars: null, diamonds: null, year: '', location: '', bedType: '',
facilities: '', breakfast: '', images: [] };
}
@@ -150,16 +166,17 @@ window.fillPlaceholder = function (path, idx) {
}
markDirty(); render();
};
function imageSlots(path) {
const arr = getByPath(DATA, path) || [];
let html = '<div class="img-row">';
for (let i = 0; i < 3; i++) {
const src = arr[i];
html += '<div class="img-slot">';
const MAX_IMGS = 9;
const MIN_IMGS = 3;
const SLOT_LABELS = ['房间图', '外观图', '设施图'];
function slotLabel(i) { return SLOT_LABELS[i] || ('图片 ' + (i + 1)); }
function oneSlot(path, i, src) {
const label = slotLabel(i);
let html = '<div class="img-slot">';
html += src
? '<img class="thumb" src="' + esc(src) + '?v=' + uid() + '"/>'
: '<div class="thumb" style="display:flex;align-items:center;justify-content:center;color:#c9b98f">' + ['房间图','外观图','设施图'][i] + '(空)</div>';
html += '<div class="slot-label">' + ['房间图','外观图','设施图'][i] + '</div>';
: '<div class="thumb" style="display:flex;align-items:center;justify-content:center;color:#c9b98f">' + label + '(空)</div>';
html += '<div class="slot-label">' + label + '</div>';
html += '<div class="slot-actions">';
if (src) {
html += '<button class="btn btn-line btn-mini" onclick="removeSlot(' + q(path) + ',' + i + ')">移除</button>';
@@ -168,8 +185,24 @@ function imageSlots(path) {
html += '<button class="btn btn-line btn-mini" onclick="fillPlaceholder(' + q(path) + ',' + i + ')">占位图</button>';
}
html += '</div></div>';
return html;
}
html += '</div><div class="hint">前台要求至少 3 张图才展示图片区,建议保持 3 张齐全。</div>';
function imageSlots(path) {
const arr = getByPath(DATA, path) || [];
const count = Math.min(MAX_IMGS, Math.max(MIN_IMGS, arr.length));
let html = '<div class="img-row">';
for (let i = 0; i < count; i++) html += oneSlot(path, i, arr[i]);
// 已满足最少 3 张且未到 9 张上限时,提供「添加图片」入口
if (arr.length >= MIN_IMGS && arr.length < MAX_IMGS) {
const i = arr.length;
html += '<div class="img-slot">' +
'<div class="thumb" style="display:flex;align-items:center;justify-content:center;color:#c9b98f;font-size:22px">+</div>' +
'<div class="slot-label">添加图片</div>' +
'<div class="slot-actions">' + makeFileInput(path, i, '上传') +
'<button class="btn btn-line btn-mini" onclick="fillPlaceholder(' + q(path) + ',' + i + ')">占位图</button>' +
'</div></div>';
}
html += '</div><div class="hint">前台要求至少 3 张图才展示图片区,最多可上传 9 张(第 1 张为主图)。</div>';
return html;
}
@@ -325,7 +358,7 @@ function renderTrips() {
const routes = (TRIPS.line && DATA.Kf[TRIPS.line]) ? (DATA.Kf[TRIPS.line].routes || []) : [];
if (!TRIPS.route || !DATA.Am[TRIPS.route]) TRIPS.route = routes[0] || null;
if (!TRIPS.line) { el.innerHTML = '<h2>行程与酒店</h2><div class="desc">请先在「产品线」中添加线路。</div>'; return; }
let html = '<h2>行程与酒店</h2><div class="desc">维护每个行程每天的酒店:文字、星级、3 张图片、备选酒店均可增删改。</div>';
let html = '<h2>行程与酒店</h2><div class="desc">维护每个行程每天的酒店:文字、星级/钻级、图片(3–9 张)、备选酒店均可增删改。</div>';
html += '<div class="card"><div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px">' +
'<select class="trip-sel" onchange="tripSelLine(this.value)">' +
av.map(l => '<option value="' + esc(l) + '"' + (l === TRIPS.line ? ' selected' : '') + '>' + esc(DATA.Kf[l].label) + '</option>').join('') +
@@ -344,7 +377,7 @@ function renderTrips() {
'<span class="arrow">▶</span>' +
'<span class="badge">DAY ' + esc(h.day) + '</span>' +
'<span class="title">' + esc(h.city ? (h.city + ' · ') : '') + esc(h.name) + (h.subtitle ? '(' + esc(h.subtitle) + ')' : '') + '</span>' +
'<span style="color:#c9a96e">' + '💎'.repeat(h.stars || 0) + '</span>' +
'<span style="color:#c9a96e">' + fmtRating(h) + '</span>' +
'</div>' +
(open ? '<div class="hotel-body">' +
'<div class="grid3">' +
@@ -352,16 +385,14 @@ function renderTrips() {
'<div class="field"><label>城市/地点</label><input value="' + esc(h.city) + '" onchange="onHotelField(this,' + i + ')" data-key="city"/></div>' +
'<div class="field"><label>酒店名称</label><input value="' + esc(h.name) + '" onchange="onHotelField(this,' + i + ')" data-key="name"/></div>' +
'<div class="field"><label>房型/副标题</label><input value="' + esc(h.subtitle) + '" onchange="onHotelField(this,' + i + ')" data-key="subtitle"/></div>' +
'<div class="field"><label>星级(1-5)</label><select onchange="onHotelField(this,' + i + ')" data-key="stars">' +
[1,2,3,4,5].map(s => '<option value="' + s + '"' + (h.stars === s ? ' selected' : '') + '>' + '💎'.repeat(s) + '</option>').join('') +
'</select></div>' +
ratingSelects(h, 'onHotelField', i) +
'<div class="field"><label>装修年份</label><input value="' + esc(h.year) + '" onchange="onHotelField(this,' + i + ')" data-key="year"/></div>' +
'<div class="field"><label>位置</label><input value="' + esc(h.location) + '" onchange="onHotelField(this,' + i + ')" data-key="location"/></div>' +
'<div class="field"><label>床型</label><input value="' + esc(h.bedType) + '" onchange="onHotelField(this,' + i + ')" data-key="bedType"/></div>' +
'<div class="field"><label>设施</label><input value="' + esc(h.facilities) + '" onchange="onHotelField(this,' + i + ')" data-key="facilities"/></div>' +
'</div>' +
'<div class="field" style="margin-top:12px"><label>餐饮</label><input value="' + esc(h.breakfast) + '" onchange="onHotelField(this,' + i + ')" data-key="breakfast"/></div>' +
'<div style="margin-top:14px"><label style="font-size:12px;color:#8a7d63">酒店图片(3 张:房间/外观/设施)</label><div style="margin-top:6px">' + imageSlots('Am.' + TRIPS.route + '.hotels.' + i + '.images') + '</div></div>' +
'<div style="margin-top:14px"><label style="font-size:12px;color:#8a7d63">酒店图片(3–9 张,第 1 张为主图)</label><div style="margin-top:6px">' + imageSlots('Am.' + TRIPS.route + '.hotels.' + i + '.images') + '</div></div>' +
'<div style="margin-top:16px"><label style="font-size:12px;color:#8a7d63">备选酒店(前台「备选酒店」按钮)</label>' +
'<div style="margin-top:6px">' + renderAltList(i) + '</div></div>' +
'<div class="row-actions" style="margin-top:16px">' +
@@ -401,7 +432,8 @@ window.onHotelField = function (input, i) {
const h = DATA.Am[TRIPS.route].hotels[i];
const k = input.dataset.key;
let v = input.value;
if (k === 'stars' || k === 'day') v = parseInt(v, 10) || 1;
if (k === 'day') v = parseInt(v, 10) || 1;
else if (k === 'stars' || k === 'diamonds') v = clampRating(v);
h[k] = v;
markDirty();
};
@@ -477,15 +509,14 @@ function renderAlts() {
'<button class="btn btn-danger btn-mini" onclick="deleteAltFromLib(' + idx + ')">从资料库删除</button></div>' +
'<div class="grid3">' +
'<div class="field"><label>副标题/房型</label><input value="' + esc(a.subtitle) + '" onchange="onAltField(this,' + idx + ')" data-key="subtitle"/></div>' +
'<div class="field"><label>星级</label><select onchange="onAltField(this,' + idx + ')" data-key="stars">' +
[1,2,3,4,5].map(s => '<option value="' + s + '"' + (a.stars === s ? ' selected' : '') + '>' + '💎'.repeat(s) + '</option>').join('') + '</select></div>' +
ratingSelects(a, 'onAltField', idx) +
'<div class="field"><label>装修年份</label><input value="' + esc(a.year) + '" onchange="onAltField(this,' + idx + ')" data-key="year"/></div>' +
'<div class="field"><label>位置</label><input value="' + esc(a.location) + '" onchange="onAltField(this,' + idx + ')" data-key="location"/></div>' +
'<div class="field"><label>床型</label><input value="' + esc(a.bedType) + '" onchange="onAltField(this,' + idx + ')" data-key="bedType"/></div>' +
'<div class="field"><label>设施</label><input value="' + esc(a.facilities) + '" onchange="onAltField(this,' + idx + ')" data-key="facilities"/></div>' +
'</div>' +
'<div class="field" style="margin-top:12px"><label>餐饮</label><input value="' + esc(a.breakfast) + '" onchange="onAltField(this,' + idx + ')" data-key="breakfast"/></div>' +
'<div style="margin-top:14px"><label style="font-size:12px;color:#8a7d63">图片(3 张)</label><div style="margin-top:6px">' + imageSlots('__altLib__.' + idx + '.images') + '</div></div>' +
'<div style="margin-top:14px"><label style="font-size:12px;color:#8a7d63">图片(3–9 张)</label><div style="margin-top:6px">' + imageSlots('__altLib__.' + idx + '.images') + '</div></div>' +
'</div>';
});
el.innerHTML = html;
@@ -493,7 +524,7 @@ function renderAlts() {
window.onAltField = function (input, idx) {
const a = altLibArr()[idx];
const k = input.dataset.key;
a[k] = (k === 'stars') ? (parseInt(input.value, 10) || 1) : input.value;
a[k] = (k === 'stars' || k === 'diamonds') ? clampRating(input.value) : input.value;
markDirty();
};
window.renameAlt = function (idx) {
@@ -606,8 +637,9 @@ window.saveAll = async function () {
return lib ? JSON.parse(JSON.stringify(lib)) : a;
});
h.alternatives = h.altHotels.map(a => a.name);
h.images = (h.images || []).filter(Boolean).slice(0, 3);
h.stars = Math.min(5, Math.max(1, parseInt(h.stars, 10) || 1));
h.images = (h.images || []).filter(Boolean).slice(0, 9);
h.stars = clampRating(h.stars);
h.diamonds = clampRating(h.diamonds);
h.day = parseInt(h.day, 10) || 1;
}
}
@@ -618,7 +650,11 @@ window.saveAll = async function () {
(DATA.Kf[line].routes || []).forEach(rk => usedRoutes.add(rk));
}
for (const rk of Object.keys(DATA.Am)) if (!usedRoutes.has(rk)) delete DATA.Am[rk];
for (const a of altLibArr()) a.images = (a.images || []).filter(Boolean).slice(0, 3);
for (const a of altLibArr()) {
a.images = (a.images || []).filter(Boolean).slice(0, 9);
a.stars = clampRating(a.stars);
a.diamonds = clampRating(a.diamonds);
}
await api('/api/data', { method: 'PUT', json: DATA });
markClean();
文件差异因一行或多行过长而隐藏
文件差异因一行或多行过长而隐藏
+699 -466
查看文件
文件差异内容过多而无法显示 加载差异
+697 -465
查看文件
文件差异内容过多而无法显示 加载差异
二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 32 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 518 KiB

+2 -2
查看文件
@@ -3,8 +3,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>呼籁旅行 - 甄选酒店</title>
<link rel="icon" type="image/png" href="favicon.png"/>
<script type="module" crossorigin="" src="./assets/index-BIX6B1UZ.js"></script>
<link rel="stylesheet" crossorigin="" href="./assets/index-_glDPOmP.css"/>
<script type="module" crossorigin="" src="./assets/index-BIX6B1UZ.v2.js"></script>
<link rel="stylesheet" crossorigin="" href="./assets/index-_glDPOmP.v2.css"/>
</head>
<body>
<div id="root"></div>
+2 -1
查看文件
@@ -147,7 +147,8 @@ function serveStatic(req, res, urlPath) {
const headers = { 'Content-Type': MIME[ext] || 'application/octet-stream' };
const abs = p.startsWith('/assets/');
const isHotels = p.startsWith('/hotels/');
if (abs) headers['Cache-Control'] = 'public, max-age=31536000, immutable';
// 无构建步骤、资源为手改(文件名哈希不随内容变),不用 immutable 长缓存,避免改动不生效
if (abs) headers['Cache-Control'] = 'no-cache';
else if (isHotels || ext === '.json') headers['Cache-Control'] = 'no-cache';
else headers['Cache-Control'] = 'no-cache';
res.writeHead(200, headers);