/* 呼籁旅行 · 内容管理后台 */
'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, ''');
}
// 生成可直接放进 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 '' +
'';
}
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 = '
';
for (let i = 0; i < 3; i++) {
const src = arr[i];
html += '
';
html += src
? '
 + '?v=' + uid() + ')
'
: '
' + ['房间图','外观图','设施图'][i] + '(空)
';
html += '
' + ['房间图','外观图','设施图'][i] + '
';
html += '
';
if (src) {
html += '';
} else {
html += makeFileInput(path, i, '上传');
html += '';
}
html += '
';
}
html += '
前台要求至少 3 张图才展示图片区,建议保持 3 张齐全。
';
return html;
}
/* ---------- 渲染 ---------- */
function render() {
if (!DATA) return;
renderSettings();
renderLines();
renderTrips();
renderAlts();
}
/* 站点设置 */
function renderSettings() {
const el = $('#view-settings');
el.innerHTML =
'站点设置
修改后点击右上角「保存并发布」,前台即刻生效。
' +
'页面文字
' +
field('siteName', '顶部/主标题', 'siteName') +
field('siteSubtitle', '副标题', 'siteSubtitle') +
field('siteEn', '英文副标题', 'siteEn') +
field('siteTitle', '浏览器标签标题', 'siteTitle') +
field('tipsLabel', '提示语前缀', 'tipsLabel') +
field('footerText', '页脚文字', 'footerText') +
'
' +
'
' +
'
' +
'
' +
'顶部主图(hero)
' +
'
' +
'
 + '?v=' + uid() + ')
' +
'
' + makeFileInput('heroBg', 0, '更换图片') +
'' +
'
' +
'
建议尺寸 1920×1080。上传后自动保存到 /hotels/ 目录。
' +
'
' +
'修改管理密码
' +
'
密码保存在服务器 config.json 中(加密存储),忘记时可删除 config.json 后重启服务,密码会恢复为初始密码。
';
}
function field(id, label, path) {
const v = getByPath(DATA, path);
return '' +
'
';
}
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 = '产品线(线路 / 天数标签)
' +
'「线路」= 前台顶部导航(南线/北线等);「天数标签」= 线路下的行程选择。均可增、删、改名、排序。
';
const av = DATA.av || [];
av.forEach((line, li) => {
const kf = DATA.Kf[line] || { label: line, routes: [] };
html += '' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
';
(kf.routes || []).forEach((rk, ri) => {
const am = DATA.Am[rk] || { label: '', days: '', hotels: [] };
html += '
' +
'
' + esc(rk) + '' +
'
' +
'
' +
'
' +
'
' +
'
酒店数:' + (am.hotels || []).length + '
' +
'
' +
'
' +
'
' +
'
';
});
html += '
';
});
html += '';
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 = '行程与酒店
请先在「产品线」中添加线路。
'; return; }
let html = '行程与酒店
维护每个行程每天的酒店:文字、星级、3 张图片、备选酒店均可增删改。
';
html += '' +
'' +
'' +
'' +
'
';
const am = DATA.Am[TRIPS.route] || { hotels: [] };
(am.hotels || []).forEach((h, i) => {
const key = TRIPS.route + ':' + i;
const open = OPEN_HOTELS.has(key);
html += '' +
'
' +
'▶' +
'DAY ' + esc(h.day) + '' +
'' + esc(h.city ? (h.city + ' · ') : '') + esc(h.name) + (h.subtitle ? '(' + esc(h.subtitle) + ')' : '') + '' +
'' + '💎'.repeat(h.stars || 0) + '' +
'
' +
(open ? '
' +
'
' +
'
' +
'
' + imageSlots('Am.' + TRIPS.route + '.hotels.' + i + '.images') + '
' +
'
' +
'
' + renderAltList(i) + '
' +
'
' +
'' +
'' +
'' +
'' +
'
' : '') +
'
';
});
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) =>
'' + esc(a.name) + (a.subtitle ? '(' + esc(a.subtitle) + ')' : '') + '' +
'' +
'
'
).join('');
const used = new Set(alts.map(a => a.name));
const options = altLibArr().filter(a => !used.has(a.name))
.map(a => '').join('');
html += '' +
'' +
'' +
'' +
'
';
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 = '备选酒店资料库
' +
'同一家备选酒店可能出现在多条线路。这里维护一份,保存发布时自动同步到所有引用处;改名也会同步。
';
const lib = altLibArr();
if (!lib.length) { html += '暂无备选酒店。
'; el.innerHTML = html; return; }
lib.forEach((a, idx) => {
html += '' +
'' + esc(a.name) + '' +
'' +
'
' +
'
' +
'
' +
'
' + imageSlots('__altLib__.' + idx + '.images') + '
' +
'
';
});
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 = '备份与恢复
' +
'每次「保存并发布」都会自动生成一份历史备份(保留最近 50 份)。
' +
'' +
'';
api('/api/backups').then(r => {
const list = $('#backup-list');
if (!r.files || !r.files.length) { list.innerHTML = '暂无备份。
'; return; }
list.innerHTML = r.files.map(f =>
'' + esc(f) + '' +
'
'
).join('');
}).catch(e => { $('#backup-list').innerHTML = '加载失败:' + esc(e.message) + '
'; });
}
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();