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)
这个提交包含在:
@@ -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,26 +166,43 @@ window.fillPlaceholder = function (path, idx) {
|
||||
}
|
||||
markDirty(); render();
|
||||
};
|
||||
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">' + 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>';
|
||||
} else {
|
||||
html += makeFileInput(path, i, '上传');
|
||||
html += '<button class="btn btn-line btn-mini" onclick="fillPlaceholder(' + q(path) + ',' + i + ')">占位图</button>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
return html;
|
||||
}
|
||||
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 < 3; i++) {
|
||||
const src = arr[i];
|
||||
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>';
|
||||
html += '<div class="slot-actions">';
|
||||
if (src) {
|
||||
html += '<button class="btn btn-line btn-mini" onclick="removeSlot(' + q(path) + ',' + i + ')">移除</button>';
|
||||
} else {
|
||||
html += makeFileInput(path, i, '上传');
|
||||
html += '<button class="btn btn-line btn-mini" onclick="fillPlaceholder(' + q(path) + ',' + i + ')">占位图</button>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
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 张图才展示图片区,建议保持 3 张齐全。</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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户