/* 呼籁旅行 · 内容管理后台 */
'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 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) =>
'
';
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 '' +
'';
}
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 = '
';
html += src
? ''
: '
' + label + '(空)
';
html += '
' + label + '
';
html += '
';
if (src) {
html += '';
} else {
html += makeFileInput(path, i, '上传');
html += '';
}
html += '
';
return html;
}
function imageSlots(path) {
const arr = getByPath(DATA, path) || [];
const count = Math.min(MAX_IMGS, Math.max(MIN_IMGS, arr.length));
let html = '
';
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 += '
' +
'
+
' +
'
添加图片
' +
'
' + makeFileInput(path, i, '上传') +
'' +
'
';
}
html += '
前台要求至少 3 张图才展示图片区,最多可上传 9 张(第 1 张为主图)。
';
return html;
}
/* ---------- 渲染 ---------- */
function render() {
if (!DATA) return;
renderSettings();
renderLines();
renderTrips();
renderAlts();
}
/* 站点设置 */
function renderSettings() {
const el = $('#view-settings');
el.innerHTML =
'