- 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)
726 行
33 KiB
JavaScript
726 行
33 KiB
JavaScript
/* 呼籁旅行 · 内容管理后台 */
|
||
'use strict';
|
||
|
||
const $ = (s, el) => (el || document).querySelector(s);
|
||
const $$ = (s, el) => Array.from((el || document).querySelectorAll(s));
|
||
|
||
let DATA = null; // 工作副本
|
||
let DIRTY = false;
|
||
let VIEW = 'settings';
|
||
let TRIPS = { line: null, route: null };
|
||
let OPEN_HOTELS = new Set();
|
||
let ALT_LIB = null; // Map: name -> 备选酒店对象
|
||
|
||
/* ---------- 工具 ---------- */
|
||
function esc(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
// 生成可直接放进 onclick="..." 的 JS 字符串字面量
|
||
function q(s) {
|
||
return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
|
||
}
|
||
function altLibArr() { return Array.from(ALT_LIB.values()); }
|
||
function getByPath(obj, path) {
|
||
if (path.startsWith('__altLib__.')) {
|
||
const rest = path.slice('__altLib__.'.length);
|
||
const dot = rest.indexOf('.');
|
||
const idx = parseInt(dot < 0 ? rest : rest.slice(0, dot), 10);
|
||
const a = altLibArr()[idx];
|
||
if (!a) return undefined;
|
||
return dot < 0 ? a : getByPath(a, rest.slice(dot + 1));
|
||
}
|
||
return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
|
||
}
|
||
function setByPath(obj, path, value) {
|
||
if (path.startsWith('__altLib__.')) {
|
||
const rest = path.slice('__altLib__.'.length);
|
||
const dot = rest.indexOf('.');
|
||
const idx = parseInt(dot < 0 ? rest : rest.slice(0, dot), 10);
|
||
const a = altLibArr()[idx];
|
||
if (!a) return;
|
||
if (dot < 0) { altLibArr()[idx] = value; return; }
|
||
setByPath(a, rest.slice(dot + 1), value);
|
||
return;
|
||
}
|
||
const keys = path.split('.');
|
||
let o = obj;
|
||
for (let i = 0; i < keys.length - 1; i++) {
|
||
if (o[keys[i]] == null) o[keys[i]] = {};
|
||
o = o[keys[i]];
|
||
}
|
||
o[keys[keys.length - 1]] = value;
|
||
}
|
||
function markDirty() { DIRTY = true; $('#btn-save').disabled = false; $('#save-status').textContent = '有未保存修改'; }
|
||
function markClean() { DIRTY = false; $('#btn-save').disabled = true; $('#save-status').textContent = '已同步'; }
|
||
function toast(msg, isErr) {
|
||
const t = $('#toast');
|
||
t.textContent = msg;
|
||
t.style.background = isErr ? 'rgba(160,40,30,.94)' : 'rgba(40,40,40,.92)';
|
||
t.classList.add('show');
|
||
clearTimeout(t._timer);
|
||
t._timer = setTimeout(() => t.classList.remove('show'), 2600);
|
||
}
|
||
async function api(path, opts) {
|
||
opts = opts || {};
|
||
const headers = Object.assign({ 'X-Requested-With': 'hulai-admin' }, opts.headers || {});
|
||
if (opts.json !== undefined) headers['Content-Type'] = 'application/json';
|
||
const res = await fetch(path, { method: opts.method || 'GET', headers, body: opts.json !== undefined ? JSON.stringify(opts.json) : opts.body });
|
||
let data = null;
|
||
try { data = await res.json(); } catch (e) {}
|
||
if (!res.ok) {
|
||
const err = new Error((data && data.error) || ('请求失败 ' + res.status));
|
||
err.status = res.status;
|
||
throw err;
|
||
}
|
||
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: null, diamonds: null, year: '2025年装修',
|
||
location: '', bedType: '', facilities: '', breakfast: '含早餐', alternatives: [], images: [], altHotels: [] };
|
||
}
|
||
function newAltHotel(name) {
|
||
return { name: name || '新备选酒店', subtitle: '', stars: null, diamonds: null, year: '', location: '', bedType: '',
|
||
facilities: '', breakfast: '', images: [] };
|
||
}
|
||
|
||
/* ---------- 资料库 ---------- */
|
||
function buildAltLib() {
|
||
const map = new Map();
|
||
for (const rk of Object.keys(DATA.Am || {})) {
|
||
for (const h of (DATA.Am[rk].hotels || [])) {
|
||
for (const a of (h.altHotels || [])) {
|
||
if (!map.has(a.name)) map.set(a.name, JSON.parse(JSON.stringify(a)));
|
||
}
|
||
}
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/* ---------- 图片 ---------- */
|
||
function uploadImage(file, cb) {
|
||
if (!file) return;
|
||
if (file.size > 12 * 1024 * 1024) { toast('图片超过 12MB,请压缩后再传', true); return; }
|
||
const reader = new FileReader();
|
||
reader.onload = async () => {
|
||
try {
|
||
const r = await api('/api/upload', { method: 'POST', json: { filename: file.name, data: reader.result } });
|
||
cb(r.path);
|
||
toast('图片上传成功');
|
||
markDirty(); render();
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
function makeFileInput(path, idx, label) {
|
||
const id = 'f_' + uid();
|
||
return '<label class="btn btn-line btn-mini" for="' + id + '">' + label + '</label>' +
|
||
'<input id="' + id + '" type="file" accept="image/*" style="display:none" ' +
|
||
'onchange="onSlotFile(this,' + q(path) + ',' + idx + ')"/>';
|
||
}
|
||
window.onSlotFile = function (input, path, idx) {
|
||
const file = input.files && input.files[0];
|
||
uploadImage(file, (p) => {
|
||
const cur = getByPath(DATA, path);
|
||
if (Array.isArray(cur)) {
|
||
while (cur.length <= idx) cur.push(null);
|
||
cur[idx] = p;
|
||
} else {
|
||
setByPath(DATA, path, p);
|
||
}
|
||
});
|
||
};
|
||
window.removeSlot = function (path, idx) {
|
||
if (!confirm('确定移除该图片?图片文件将移入回收目录。')) return;
|
||
const arr = getByPath(DATA, path);
|
||
const p = arr[idx];
|
||
arr.splice(idx, 1);
|
||
api('/api/delete-image', { method: 'POST', json: { path: p } }).catch(() => {});
|
||
markDirty(); render();
|
||
};
|
||
window.fillPlaceholder = function (path, idx) {
|
||
const cur = getByPath(DATA, path);
|
||
if (Array.isArray(cur)) {
|
||
while (cur.length <= idx) cur.push(null);
|
||
cur[idx] = '/hotels/_placeholder.png';
|
||
} else {
|
||
setByPath(DATA, path, '/hotels/_placeholder.png');
|
||
}
|
||
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 < 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;
|
||
}
|
||
|
||
/* ---------- 渲染 ---------- */
|
||
function render() {
|
||
if (!DATA) return;
|
||
renderSettings();
|
||
renderLines();
|
||
renderTrips();
|
||
renderAlts();
|
||
}
|
||
|
||
/* 站点设置 */
|
||
function renderSettings() {
|
||
const el = $('#view-settings');
|
||
el.innerHTML =
|
||
'<h2>站点设置</h2><div class="desc">修改后点击右上角「保存并发布」,前台即刻生效。</div>' +
|
||
'<div class="card"><h3>页面文字</h3><div class="grid2">' +
|
||
field('siteName', '顶部/主标题', 'siteName') +
|
||
field('siteSubtitle', '副标题', 'siteSubtitle') +
|
||
field('siteEn', '英文副标题', 'siteEn') +
|
||
field('siteTitle', '浏览器标签标题', 'siteTitle') +
|
||
field('tipsLabel', '提示语前缀', 'tipsLabel') +
|
||
field('footerText', '页脚文字', 'footerText') +
|
||
'</div>' +
|
||
'<div class="field" style="margin-top:12px"><label>温馨提示正文</label>' +
|
||
'<textarea onchange="onField(this)" data-path="tipsText">' + esc(DATA.tipsText) + '</textarea></div>' +
|
||
'</div>' +
|
||
'<div class="card"><h3>顶部主图(hero)</h3>' +
|
||
'<div class="img-row"><div class="img-slot" style="width:340px">' +
|
||
'<img class="thumb" style="height:150px" src="' + esc(DATA.heroBg) + '?v=' + uid() + '"/>' +
|
||
'<div class="slot-actions">' + makeFileInput('heroBg', 0, '更换图片') +
|
||
'<button class="btn btn-line btn-mini" onclick="DATA.heroBg=\'/hotels/hero-bg.png\';markDirty();render()">恢复默认</button>' +
|
||
'</div></div></div>' +
|
||
'<div class="hint">建议尺寸 1920×1080。上传后自动保存到 /hotels/ 目录。</div>' +
|
||
'</div>' +
|
||
'<div class="card"><h3>修改管理密码</h3><div class="grid3">' +
|
||
'<div class="field"><label>当前密码</label><input id="pwd-old" type="password" autocomplete="off"/></div>' +
|
||
'<div class="field"><label>新密码</label><input id="pwd-new" type="password" autocomplete="off"/></div>' +
|
||
'<div class="field"><label>确认新密码</label><input id="pwd-confirm" type="password" autocomplete="off"/></div>' +
|
||
'</div><div style="margin-top:10px"><button class="btn btn-gold" onclick="changePassword()">更新密码</button></div>' +
|
||
'<div class="hint">密码保存在服务器 config.json 中(加密存储),忘记时可删除 config.json 后重启服务,密码会恢复为初始密码。</div></div>';
|
||
}
|
||
function field(id, label, path) {
|
||
const v = getByPath(DATA, path);
|
||
return '<div class="field"><label for="f_' + id + '">' + esc(label) + '</label>' +
|
||
'<input id="f_' + id + '" value="' + esc(v) + '" onchange="onField(this)" data-path="' + esc(path) + '"/></div>';
|
||
}
|
||
window.onField = function (input) {
|
||
setByPath(DATA, input.dataset.path, input.value);
|
||
markDirty();
|
||
};
|
||
window.changePassword = async function () {
|
||
const o = $('#pwd-old').value, n = $('#pwd-new').value, c = $('#pwd-confirm').value;
|
||
if (!o || !n) return toast('请填写完整', true);
|
||
if (n !== c) return toast('两次输入的新密码不一致', true);
|
||
if (n.length < 6) return toast('新密码至少 6 位', true);
|
||
try {
|
||
await api('/api/change-password', { method: 'POST', json: { old: o, next: n } });
|
||
toast('密码已更新');
|
||
$('#pwd-old').value = $('#pwd-new').value = $('#pwd-confirm').value = '';
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
|
||
/* 产品线 */
|
||
function renderLines() {
|
||
const el = $('#view-lines');
|
||
let html = '<h2>产品线(线路 / 天数标签)</h2>' +
|
||
'<div class="desc">「线路」= 前台顶部导航(南线/北线等);「天数标签」= 线路下的行程选择。均可增、删、改名、排序。</div>';
|
||
const av = DATA.av || [];
|
||
av.forEach((line, li) => {
|
||
const kf = DATA.Kf[line] || { label: line, routes: [] };
|
||
html += '<div class="card line-block"><div class="row-actions" style="margin-bottom:12px">' +
|
||
'<div class="field" style="flex:1"><label>线路名称</label>' +
|
||
'<input value="' + esc(kf.label) + '" onchange="onField(this)" data-path="Kf.' + esc(line) + '.label"/></div>' +
|
||
'<button class="btn btn-line btn-mini" onclick="moveLine(' + li + ',-1)">↑</button>' +
|
||
'<button class="btn btn-line btn-mini" onclick="moveLine(' + li + ',1)">↓</button>' +
|
||
'<button class="btn btn-danger btn-mini" onclick="deleteLine(' + li + ')">删除线路</button>' +
|
||
'</div>';
|
||
(kf.routes || []).forEach((rk, ri) => {
|
||
const am = DATA.Am[rk] || { label: '', days: '', hotels: [] };
|
||
html += '<div class="route-row">' +
|
||
'<span class="rkey">' + esc(rk) + '</span>' +
|
||
'<div class="field" style="width:170px"><label>标签文字</label>' +
|
||
'<input value="' + esc(am.days) + '" onchange="onField(this)" data-path="Am.' + esc(rk) + '.days"/></div>' +
|
||
'<div class="field" style="width:200px"><label>信息栏文案</label>' +
|
||
'<input value="' + esc(am.label) + '" onchange="onField(this)" data-path="Am.' + esc(rk) + '.label"/></div>' +
|
||
'<div class="hint" style="flex:1">酒店数:' + (am.hotels || []).length + '</div>' +
|
||
'<button class="btn btn-line btn-mini" onclick="moveRoute(' + q(line) + ',' + ri + ',-1)">↑</button>' +
|
||
'<button class="btn btn-line btn-mini" onclick="moveRoute(' + q(line) + ',' + ri + ',1)">↓</button>' +
|
||
'<button class="btn btn-danger btn-mini" onclick="deleteRoute(' + q(line) + ',' + ri + ')">删除</button>' +
|
||
'</div>';
|
||
});
|
||
html += '<div style="margin-top:8px"><button class="btn btn-line btn-mini" onclick="addRoute(' + q(line) + ')">+ 添加天数标签</button></div></div>';
|
||
});
|
||
html += '<div><button class="btn btn-gold" onclick="addLine()">+ 添加线路</button></div>';
|
||
el.innerHTML = html;
|
||
}
|
||
window.moveLine = function (i, d) {
|
||
const av = DATA.av;
|
||
const j = i + d;
|
||
if (j < 0 || j >= av.length) return;
|
||
[av[i], av[j]] = [av[j], av[i]];
|
||
markDirty(); render();
|
||
};
|
||
window.deleteLine = function (i) {
|
||
const av = DATA.av;
|
||
const line = av[i];
|
||
if (!confirm('删除线路「' + DATA.Kf[line].label + '」?其下行程与酒店数据也会一并删除。')) return;
|
||
(DATA.Kf[line].routes || []).forEach(rk => delete DATA.Am[rk]);
|
||
delete DATA.Kf[line];
|
||
av.splice(i, 1);
|
||
markDirty(); render();
|
||
};
|
||
window.addLine = function () {
|
||
const base = '新线路';
|
||
let key = base, n = 1;
|
||
while (DATA.Kf[key]) key = base + n++;
|
||
DATA.av.push(key);
|
||
DATA.Kf[key] = { label: '新线路', routes: [] };
|
||
markDirty(); render();
|
||
};
|
||
window.moveRoute = function (line, i, d) {
|
||
const rs = DATA.Kf[line].routes;
|
||
const j = i + d;
|
||
if (j < 0 || j >= rs.length) return;
|
||
[rs[i], rs[j]] = [rs[j], rs[i]];
|
||
markDirty(); render();
|
||
};
|
||
window.deleteRoute = function (line, i) {
|
||
const rs = DATA.Kf[line].routes;
|
||
const rk = rs[i];
|
||
if (!confirm('删除行程「' + DATA.Am[rk].days + '」?其中 ' + (DATA.Am[rk].hotels || []).length + ' 天的酒店数据也会删除。')) return;
|
||
rs.splice(i, 1);
|
||
delete DATA.Am[rk];
|
||
markDirty(); render();
|
||
};
|
||
window.addRoute = function (line) {
|
||
const days = prompt('请输入标签文字(如:6天5晚)');
|
||
if (!days) return;
|
||
let key = line + days, n = 1;
|
||
while (DATA.Am[key]) key = line + days + '-' + (n++);
|
||
DATA.Kf[line].routes.push(key);
|
||
DATA.Am[key] = { label: days, days, hotels: [] };
|
||
markDirty(); render();
|
||
};
|
||
|
||
/* 行程与酒店 */
|
||
function renderTrips() {
|
||
const el = $('#view-trips');
|
||
const av = DATA.av || [];
|
||
if (!TRIPS.line || !DATA.Kf[TRIPS.line]) TRIPS.line = av[0] || null;
|
||
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–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('') +
|
||
'</select>' +
|
||
'<select class="trip-sel" onchange="tripSelRoute(this.value)">' +
|
||
routes.map(rk => '<option value="' + esc(rk) + '"' + (rk === TRIPS.route ? ' selected' : '') + '>' + esc(DATA.Am[rk].days) + '</option>').join('') +
|
||
'</select>' +
|
||
'<button class="btn btn-gold" onclick="addHotel()">+ 添加一天酒店</button>' +
|
||
'</div></div>';
|
||
const am = DATA.Am[TRIPS.route] || { hotels: [] };
|
||
(am.hotels || []).forEach((h, i) => {
|
||
const key = TRIPS.route + ':' + i;
|
||
const open = OPEN_HOTELS.has(key);
|
||
html += '<div class="card hotel-card' + (open ? ' open' : '') + '">' +
|
||
'<div class="hotel-head" onclick="toggleHotel(' + q(key) + ')">' +
|
||
'<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">' + fmtRating(h) + '</span>' +
|
||
'</div>' +
|
||
(open ? '<div class="hotel-body">' +
|
||
'<div class="grid3">' +
|
||
'<div class="field"><label>第几天 DAY</label><input type="number" min="1" value="' + esc(h.day) + '" onchange="onHotelField(this,' + i + ')" data-key="day"/></div>' +
|
||
'<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>' +
|
||
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–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">' +
|
||
'<button class="btn btn-line btn-mini" onclick="moveHotel(' + i + ',-1)">↑ 上移</button>' +
|
||
'<button class="btn btn-line btn-mini" onclick="moveHotel(' + i + ',1)">↓ 下移</button>' +
|
||
'<button class="btn btn-line btn-mini" onclick="dupHotel(' + i + ')">复制</button>' +
|
||
'<button class="btn btn-danger btn-mini" onclick="deleteHotel(' + i + ')">删除这一天</button>' +
|
||
'</div></div>' : '') +
|
||
'</div>';
|
||
});
|
||
el.innerHTML = html;
|
||
}
|
||
function renderAltList(i) {
|
||
const am = DATA.Am[TRIPS.route];
|
||
const h = am.hotels[i];
|
||
const alts = h.altHotels || [];
|
||
let html = alts.map((a, ai) =>
|
||
'<div class="alt-item"><span class="name">' + esc(a.name) + (a.subtitle ? '(' + esc(a.subtitle) + ')' : '') + '</span>' +
|
||
'<button class="btn btn-line btn-mini" onclick="editAlt(' + i + ',' + ai + ')">改名</button>' +
|
||
'<button class="btn btn-danger btn-mini" onclick="removeAlt(' + i + ',' + ai + ')">移除</button></div>'
|
||
).join('');
|
||
const used = new Set(alts.map(a => a.name));
|
||
const options = altLibArr().filter(a => !used.has(a.name))
|
||
.map(a => '<option value="' + esc(a.name) + '">' + esc(a.name) + '</option>').join('');
|
||
html += '<div class="row-actions" style="margin-top:8px;flex-wrap:wrap">' +
|
||
'<select id="alt-add-sel-' + i + '" style="padding:6px 8px;border:1px solid #e5e0d5;border-radius:8px;max-width:260px">' +
|
||
(options ? options : '<option value="">(资料库中暂无未添加的备选)</option>') + '</select>' +
|
||
'<button class="btn btn-line btn-mini" onclick="addAlt(' + i + ')">添加</button>' +
|
||
'<button class="btn btn-line btn-mini" onclick="createAlt(' + i + ')">+ 新建备选</button>' +
|
||
'</div>';
|
||
return html;
|
||
}
|
||
window.tripSelLine = function (v) { TRIPS.line = v; const rs = DATA.Kf[v].routes || []; TRIPS.route = rs[0] || null; renderTrips(); };
|
||
window.tripSelRoute = function (v) { TRIPS.route = v; renderTrips(); };
|
||
window.toggleHotel = function (key) { OPEN_HOTELS.has(key) ? OPEN_HOTELS.delete(key) : OPEN_HOTELS.add(key); renderTrips(); };
|
||
window.onHotelField = function (input, i) {
|
||
const h = DATA.Am[TRIPS.route].hotels[i];
|
||
const k = input.dataset.key;
|
||
let v = input.value;
|
||
if (k === 'day') v = parseInt(v, 10) || 1;
|
||
else if (k === 'stars' || k === 'diamonds') v = clampRating(v);
|
||
h[k] = v;
|
||
markDirty();
|
||
};
|
||
window.addHotel = function () {
|
||
const am = DATA.Am[TRIPS.route];
|
||
am.hotels = am.hotels || [];
|
||
am.hotels.push(newHotel(am.hotels.length + 1));
|
||
OPEN_HOTELS.add(TRIPS.route + ':' + (am.hotels.length - 1));
|
||
markDirty(); renderTrips();
|
||
};
|
||
window.deleteHotel = function (i) {
|
||
const am = DATA.Am[TRIPS.route];
|
||
if (!confirm('删除 DAY ' + am.hotels[i].day + '(' + am.hotels[i].name + ')?')) return;
|
||
am.hotels.splice(i, 1);
|
||
markDirty(); renderTrips();
|
||
};
|
||
window.moveHotel = function (i, d) {
|
||
const hs = DATA.Am[TRIPS.route].hotels;
|
||
const j = i + d;
|
||
if (j < 0 || j >= hs.length) return;
|
||
[hs[i], hs[j]] = [hs[j], hs[i]];
|
||
markDirty(); renderTrips();
|
||
};
|
||
window.dupHotel = function (i) {
|
||
const hs = DATA.Am[TRIPS.route].hotels;
|
||
hs.splice(i + 1, 0, JSON.parse(JSON.stringify(hs[i])));
|
||
markDirty(); renderTrips();
|
||
};
|
||
window.addAlt = function (i) {
|
||
const sel = $('#alt-add-sel-' + i);
|
||
const name = sel.value;
|
||
if (!name) return;
|
||
const lib = ALT_LIB.get(name);
|
||
const h = DATA.Am[TRIPS.route].hotels[i];
|
||
h.altHotels = h.altHotels || [];
|
||
h.altHotels.push(JSON.parse(JSON.stringify(lib)));
|
||
markDirty(); renderTrips();
|
||
};
|
||
window.removeAlt = function (i, ai) {
|
||
DATA.Am[TRIPS.route].hotels[i].altHotels.splice(ai, 1);
|
||
markDirty(); renderTrips();
|
||
};
|
||
window.createAlt = function (i) {
|
||
const name = prompt('新备选酒店名称:');
|
||
if (!name) return;
|
||
const h = DATA.Am[TRIPS.route].hotels[i];
|
||
h.altHotels = h.altHotels || [];
|
||
h.altHotels.push(newAltHotel(name));
|
||
ALT_LIB = buildAltLib();
|
||
markDirty(); renderTrips();
|
||
};
|
||
window.editAlt = function (i, ai) {
|
||
const h = DATA.Am[TRIPS.route].hotels[i];
|
||
const a = h.altHotels[ai];
|
||
const name = prompt('备选酒店名称:', a.name);
|
||
if (!name || name === a.name) return;
|
||
a.name = name;
|
||
ALT_LIB = buildAltLib();
|
||
markDirty(); renderTrips();
|
||
};
|
||
|
||
/* 备选酒店资料库 */
|
||
function renderAlts() {
|
||
const el = $('#view-alts');
|
||
let html = '<h2>备选酒店资料库</h2>' +
|
||
'<div class="desc">同一家备选酒店可能出现在多条线路。这里维护一份,保存发布时自动同步到所有引用处;改名也会同步。</div>';
|
||
const lib = altLibArr();
|
||
if (!lib.length) { html += '<div class="hint">暂无备选酒店。</div>'; el.innerHTML = html; return; }
|
||
lib.forEach((a, idx) => {
|
||
html += '<div class="card alt-card"><div class="row-actions" style="margin-bottom:10px">' +
|
||
'<span style="font-weight:700;font-size:15px;flex:1">' + esc(a.name) + '</span>' +
|
||
'<button class="btn btn-line btn-mini" onclick="renameAlt(' + idx + ')">改名</button>' +
|
||
'<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>' +
|
||
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–9 张)</label><div style="margin-top:6px">' + imageSlots('__altLib__.' + idx + '.images') + '</div></div>' +
|
||
'</div>';
|
||
});
|
||
el.innerHTML = html;
|
||
}
|
||
window.onAltField = function (input, idx) {
|
||
const a = altLibArr()[idx];
|
||
const k = input.dataset.key;
|
||
a[k] = (k === 'stars' || k === 'diamonds') ? clampRating(input.value) : input.value;
|
||
markDirty();
|
||
};
|
||
window.renameAlt = function (idx) {
|
||
const a = altLibArr()[idx];
|
||
const old = a.name;
|
||
const next = prompt('新名称:', old);
|
||
if (!next || next === old) return;
|
||
for (const rk of Object.keys(DATA.Am)) {
|
||
for (const h of DATA.Am[rk].hotels) {
|
||
for (const x of (h.altHotels || [])) if (x.name === old) x.name = next;
|
||
if (h.alternatives) h.alternatives = h.alternatives.map(n => n === old ? next : n);
|
||
}
|
||
}
|
||
a.name = next;
|
||
ALT_LIB = buildAltLib();
|
||
markDirty(); render();
|
||
};
|
||
window.deleteAltFromLib = function (idx) {
|
||
const a = altLibArr()[idx];
|
||
if (!confirm('从资料库删除「' + a.name + '」,并同时从所有行程中移除该备选?')) return;
|
||
for (const rk of Object.keys(DATA.Am)) {
|
||
for (const h of DATA.Am[rk].hotels) {
|
||
h.altHotels = (h.altHotels || []).filter(x => x.name !== a.name);
|
||
if (h.alternatives) h.alternatives = h.alternatives.filter(n => n !== a.name);
|
||
}
|
||
}
|
||
ALT_LIB = buildAltLib();
|
||
markDirty(); render();
|
||
};
|
||
|
||
/* 备份与恢复 */
|
||
function renderBackup() {
|
||
const el = $('#view-backup');
|
||
el.innerHTML = '<h2>备份与恢复</h2>' +
|
||
'<div class="desc">每次「保存并发布」都会自动生成一份历史备份(保留最近 50 份)。</div>' +
|
||
'<div class="card"><h3>数据操作</h3><div class="row-actions" style="flex-wrap:wrap">' +
|
||
'<button class="btn btn-line" onclick="downloadData()">下载当前数据(JSON)</button>' +
|
||
'<label class="btn btn-line" for="import-file">导入 JSON 数据</label>' +
|
||
'<input id="import-file" type="file" accept=".json,application/json" style="display:none" onchange="onImport(this)"/>' +
|
||
'<button class="btn btn-line" onclick="restoreSeed()">恢复为初始数据</button>' +
|
||
'</div><div class="hint">导入会整体替换当前内容,请先下载备份。</div></div>' +
|
||
'<div class="card"><h3>历史备份</h3><div id="backup-list">加载中…</div></div>';
|
||
api('/api/backups').then(r => {
|
||
const list = $('#backup-list');
|
||
if (!r.files || !r.files.length) { list.innerHTML = '<div class="hint">暂无备份。</div>'; return; }
|
||
list.innerHTML = r.files.map(f =>
|
||
'<div class="backup-row"><span class="fname">' + esc(f) + '</span>' +
|
||
'<button class="btn btn-line btn-mini" onclick="restoreBackup(' + q(f) + ')">恢复此版本</button></div>'
|
||
).join('');
|
||
}).catch(e => { $('#backup-list').innerHTML = '<div class="hint">加载失败:' + esc(e.message) + '</div>'; });
|
||
}
|
||
window.downloadData = function () {
|
||
const blob = new Blob([JSON.stringify(DATA, null, 2)], { type: 'application/json' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'hulai-data-' + new Date().toISOString().slice(0, 10) + '.json';
|
||
a.click();
|
||
};
|
||
window.onImport = function (input) {
|
||
const file = input.files && input.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
const obj = JSON.parse(reader.result);
|
||
if (!obj || typeof obj.Am !== 'object' || typeof obj.Kf !== 'object' || !Array.isArray(obj.av)) throw new Error('结构不对');
|
||
DATA = obj;
|
||
ALT_LIB = buildAltLib();
|
||
TRIPS = { line: DATA.av[0] || null, route: null };
|
||
OPEN_HOTELS.clear();
|
||
markDirty(); render();
|
||
toast('已导入,点击「保存并发布」生效');
|
||
} catch (e) { toast('导入失败:' + e.message, true); }
|
||
};
|
||
reader.readAsText(file);
|
||
input.value = '';
|
||
};
|
||
window.restoreSeed = async function () {
|
||
if (!confirm('恢复为初始数据?当前修改将丢失(会先自动备份当前数据)。')) return;
|
||
try {
|
||
const r = await api('/data.seed.json');
|
||
DATA = r; ALT_LIB = buildAltLib();
|
||
TRIPS = { line: DATA.av[0] || null, route: null };
|
||
OPEN_HOTELS.clear();
|
||
markDirty(); render();
|
||
toast('已载入初始数据,点击「保存并发布」生效');
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
window.restoreBackup = async function (file) {
|
||
if (!confirm('恢复到 ' + file + ' ?当前数据会先自动备份。')) return;
|
||
try {
|
||
await api('/api/restore-backup', { method: 'POST', json: { file } });
|
||
const d = await api('/api/data');
|
||
DATA = d; ALT_LIB = buildAltLib();
|
||
TRIPS = { line: DATA.av[0] || null, route: null };
|
||
OPEN_HOTELS.clear();
|
||
markClean(); render();
|
||
toast('已恢复该版本,前台立即生效');
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
|
||
/* ---------- 保存 ---------- */
|
||
window.saveAll = async function () {
|
||
try {
|
||
// 资料库同步到所有引用 + 重建 alternatives + 清洗
|
||
for (const rk of Object.keys(DATA.Am)) {
|
||
for (const h of DATA.Am[rk].hotels) {
|
||
h.altHotels = (h.altHotels || []).map(a => {
|
||
const lib = ALT_LIB.get(a.name);
|
||
return lib ? JSON.parse(JSON.stringify(lib)) : a;
|
||
});
|
||
h.alternatives = h.altHotels.map(a => a.name);
|
||
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;
|
||
}
|
||
}
|
||
// 清理孤儿数据
|
||
const usedRoutes = new Set();
|
||
for (const line of DATA.av) {
|
||
if (!DATA.Kf[line]) continue;
|
||
(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, 9);
|
||
a.stars = clampRating(a.stars);
|
||
a.diamonds = clampRating(a.diamonds);
|
||
}
|
||
|
||
await api('/api/data', { method: 'PUT', json: DATA });
|
||
markClean();
|
||
toast('已发布,前台立即生效 ✨');
|
||
} catch (e) {
|
||
if (e.status === 401) { showLogin(); return; }
|
||
toast('保存失败:' + e.message, true);
|
||
}
|
||
};
|
||
|
||
/* ---------- 视图切换 ---------- */
|
||
function switchView(v) {
|
||
VIEW = v;
|
||
$$('.side-item').forEach(b => b.classList.toggle('active', b.dataset.view === v));
|
||
$$('.view').forEach(x => x.classList.add('hidden'));
|
||
$('#view-' + v).classList.remove('hidden');
|
||
if (v === 'backup') renderBackup();
|
||
}
|
||
$$('.side-item').forEach(b => b.addEventListener('click', () => switchView(b.dataset.view)));
|
||
|
||
/* ---------- 登录 ---------- */
|
||
function showLogin() {
|
||
$('#login-view').classList.remove('hidden');
|
||
$('#app-view').classList.add('hidden');
|
||
}
|
||
function showApp() {
|
||
$('#login-view').classList.add('hidden');
|
||
$('#app-view').classList.remove('hidden');
|
||
}
|
||
async function doLogin() {
|
||
const pwd = $('#login-password').value;
|
||
$('#login-error').textContent = '';
|
||
try {
|
||
await api('/api/login', { method: 'POST', json: { password: pwd } });
|
||
await initApp();
|
||
} catch (e) {
|
||
$('#login-error').textContent = e.message || '登录失败';
|
||
}
|
||
}
|
||
$('#login-btn').addEventListener('click', doLogin);
|
||
$('#login-password').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||
$('#btn-logout').addEventListener('click', async () => {
|
||
await api('/api/logout', { method: 'POST' }).catch(() => {});
|
||
DATA = null; DIRTY = false;
|
||
showLogin();
|
||
});
|
||
$('#btn-save').addEventListener('click', saveAll);
|
||
$('#btn-preview').addEventListener('click', () => window.open('/', '_blank'));
|
||
|
||
/* ---------- 初始化 ---------- */
|
||
async function initApp() {
|
||
try {
|
||
const me = await api('/api/me');
|
||
if (!me.authed) return showLogin();
|
||
const d = await api('/api/data');
|
||
DATA = d;
|
||
ALT_LIB = buildAltLib();
|
||
TRIPS = { line: DATA.av[0] || null, route: null };
|
||
OPEN_HOTELS.clear();
|
||
markClean();
|
||
showApp();
|
||
render();
|
||
} catch (e) {
|
||
$('#login-error').textContent = '加载失败:' + e.message;
|
||
showLogin();
|
||
}
|
||
}
|
||
initApp();
|