/* 呼籁旅行 · 内容管理后台 */ '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 = '

站点设置

修改后点击右上角「保存并发布」,前台即刻生效。
' + '

页面文字

' + field('siteName', '顶部/主标题', 'siteName') + field('siteSubtitle', '副标题', 'siteSubtitle') + field('siteEn', '英文副标题', 'siteEn') + field('siteTitle', '浏览器标签标题', 'siteTitle') + field('tipsLabel', '提示语前缀', 'tipsLabel') + field('footerText', '页脚文字', 'footerText') + '
' + '
' + '
' + '
' + '

顶部主图(hero)

' + '
' + '' + '
' + 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–9 张)、备选酒店均可增删改。
'; 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) + ')' : '') + '' + '' + fmtRating(h) + '' + '
' + (open ? '
' + '
' + '
' + '
' + '
' + '
' + ratingSelects(h, 'onHotelField', i) + '
' + '
' + '
' + '
' + '
' + '
' + '
' + 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 === '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 = '

备选酒店资料库

' + '
同一家备选酒店可能出现在多条线路。这里维护一份,保存发布时自动同步到所有引用处;改名也会同步。
'; const lib = altLibArr(); if (!lib.length) { html += '
暂无备选酒店。
'; el.innerHTML = html; return; } lib.forEach((a, idx) => { html += '
' + '' + esc(a.name) + '' + '' + '
' + '
' + '
' + ratingSelects(a, 'onAltField', idx) + '
' + '
' + '
' + '
' + '
' + '
' + '
' + 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' || 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 = '

备份与恢复

' + '
每次「保存并发布」都会自动生成一份历史备份(保留最近 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, 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();