文件
hulai-travel-site/admin.js
T
2026-08-18 11:39:45 +08:00

690 行
32 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/* 呼籁旅行 · 内容管理后台 */
'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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// 生成可直接放进 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 newHotel(day) {
return { day: day || 1, city: '', name: '新酒店', subtitle: '', stars: 4, year: '2025年装修',
location: '', bedType: '', facilities: '', breakfast: '含早餐', alternatives: [], images: [], altHotels: [] };
}
function newAltHotel(name) {
return { name: name || '新备选酒店', subtitle: '', stars: 4, 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();
};
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">';
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>';
}
html += '</div><div class="hint">前台要求至少 3 张图才展示图片区,建议保持 3 张齐全。</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 张图片、备选酒店均可增删改。</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">' + '💎'.repeat(h.stars || 0) + '</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>' +
'<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>' +
'<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: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 === 'stars' || k === 'day') v = parseInt(v, 10) || 1;
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>' +
'<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>' +
'<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>';
});
el.innerHTML = html;
}
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;
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, 3);
h.stars = Math.min(5, Math.max(1, parseInt(h.stars, 10) || 1));
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, 3);
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();