feat: 呼籁旅行-甄选酒店站点初始化——访客页+后台管理(零依赖Node,data.json内容存储,自动备份50份,图片上传回收)
来源: hulai-travel-site.zip (2026-08-18 Kimi版复刻+CMS,移除Kimi外链脚本)
@@ -0,0 +1,13 @@
|
||||
# 运行时生成,勿入库
|
||||
config.json # 管理密码哈希(首次启动自动生成)
|
||||
data-backups/ # 后台每次保存自动备份(最多 50 份)
|
||||
hotels/_trash/ # 删除的图片回收目录
|
||||
server.log
|
||||
nohup.out
|
||||
|
||||
# 系统/编辑器垃圾
|
||||
__MACOSX/
|
||||
._*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
node_modules/
|
||||
@@ -0,0 +1,78 @@
|
||||
# 呼籁旅行 - 甄选酒店(独立部署版 · 带后台管理)
|
||||
|
||||
本目录是 Kimi 版「呼籁旅行 - 甄选酒店」页面的完整复刻 + 内容管理系统。
|
||||
已移除 Kimi 的 sdk-seed.js(Kimi 外链脚本,即"风险提示"弹窗/水印的来源),
|
||||
页面本身的功能、文字、图片与样式与原文一致。
|
||||
|
||||
## 功能
|
||||
|
||||
- 访客:打开首页直接浏览,无需登录,无任何干扰
|
||||
- 管理:隐藏入口 + 密码登录,可在后台对以下内容增删改查,保存后**所有访客立即看到更新**
|
||||
- 产品线:线路(南线/北线/南北线·满)+ 天数标签(5天4晚/6天5晚/7天6晚…),可增删改排序
|
||||
- 行程:每天入住的酒店(名称、房型、星级、年份、位置、床型、设施、餐饮)
|
||||
- 图片:每家酒店 3 张图(房间/外观/设施)、顶部主图,可直接上传替换/删除
|
||||
- 备选酒店:统一资料库,改一处自动同步到所有线路
|
||||
- 文字:站点标题、页脚、温馨提示等
|
||||
- 安全:自动历史备份(50 份)、一键恢复、导入/导出 JSON
|
||||
|
||||
## 目录结构
|
||||
|
||||
hulai-travel-site/
|
||||
├── server.js # 站点服务(零依赖 Node.js,启动它即可)
|
||||
├── data.json # 全部内容数据(后台维护的就是这个文件)
|
||||
├── data.seed.json # 初始数据底稿(后台「恢复为初始数据」用)
|
||||
├── index.html # 访客页面(服务端会注入最新数据)
|
||||
├── admin.html/admin.js/admin.css # 管理后台
|
||||
├── assets/ # 前端构建产物(勿改)
|
||||
├── hotels/ # 154 张酒店图片 + 上传的图片
|
||||
├── config.json # 首次启动自动生成,保存管理密码(哈希)
|
||||
├── data-backups/ # 自动生成的历史备份
|
||||
├── nginx.conf.example # Nginx 反向代理示例(可选)
|
||||
└── README.md
|
||||
(运行时还会自动创建 hotels/_trash 图片回收目录)
|
||||
|
||||
## 部署
|
||||
|
||||
要求:服务器已安装 Node.js(16 及以上,无任何 npm 依赖)。
|
||||
|
||||
1. 把整个目录上传到服务器,例如 /www/hulai-travel-site
|
||||
2. 进入目录执行:
|
||||
|
||||
node server.js # 直接前台运行
|
||||
# 或后台常驻(推荐,配合 PM2 / pm2 管理器):
|
||||
pm2 start server.js --name hulai-travel
|
||||
# 或 nohup node server.js > server.log 2>&1 &
|
||||
|
||||
3. 访问 http://服务器IP:3000/ 即可(端口可用环境变量修改:PORT=8080 node server.js)
|
||||
4. 有域名的话,用 Nginx 反向代理到 3000 端口并配置 HTTPS,参考 nginx.conf.example
|
||||
|
||||
宝塔面板:选择「Node 项目」或直接 SSH 用 pm2 运行 server.js 即可。
|
||||
|
||||
## 管理入口(隐式,不影响客人)
|
||||
|
||||
三种方式任选:
|
||||
|
||||
1. 访客页面上,**连续快速点击页脚文字 5 次** → 自动跳转管理后台
|
||||
2. 直接访问 http://你的域名/admin.html
|
||||
3. 初始密码: qscy2014@@
|
||||
(登录后可在「站点设置 → 修改管理密码」中修改;密码存在服务器 config.json,
|
||||
忘记时删除 config.json 并重启服务,密码恢复为初始密码)
|
||||
|
||||
## 日常维护
|
||||
|
||||
- 登录后台 → 左侧选择要维护的模块 → 修改 → 点右上角「保存并发布」→ 前台立即生效
|
||||
- 图片:每个图片位都有「上传 / 移除 / 占位图」按钮,上传前最好压到 2MB 以内
|
||||
- 误操作:备份与恢复里可一键恢复任意历史版本或初始数据
|
||||
- 所有修改都会先自动备份,放心改
|
||||
|
||||
## 关于 5 张图片的说明
|
||||
|
||||
原 Kimi 服务器上以下图片缺失(404 或 0 字节):百年俄式酒店 3 张、
|
||||
友谊宾馆 1 张、旺泉宾馆 1 张(原页面这些位置本来就是裂图)。
|
||||
这里已放置同风格占位图(标有"图片待补充")。有真实照片后,
|
||||
直接在后台对应酒店处上传替换即可。
|
||||
|
||||
## 本地预览
|
||||
|
||||
node server.js
|
||||
# 打开 http://localhost:3000/ ;管理后台 http://localhost:3000/admin.html
|
||||
@@ -0,0 +1,95 @@
|
||||
:root{
|
||||
--gold:#b8883a; --gold-d:#8b5e1c; --cream:#fdf6ec; --ink:#333;
|
||||
--line:#e5e0d5; --bg:#f6f3ec; --danger:#c0392b; --ok:#2e7d32;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--ink);font-size:14px}
|
||||
.hidden{display:none!important}
|
||||
|
||||
/* login */
|
||||
.login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(160deg,#fdf6ec,#efe3c8 60%,#e6d3ac)}
|
||||
.login-card{width:360px;background:#fff;border-radius:14px;padding:36px 32px;box-shadow:0 18px 50px rgba(91,61,28,.18);text-align:center}
|
||||
.login-logo{font-size:26px;font-weight:700;color:var(--gold-d);letter-spacing:2px}
|
||||
.login-sub{color:#999;margin:6px 0 26px;font-size:13px}
|
||||
.login-card input{width:100%;padding:11px 14px;border:1px solid var(--line);border-radius:8px;font-size:15px;margin-bottom:14px;outline:none}
|
||||
.login-card input:focus{border-color:var(--gold)}
|
||||
.login-error{color:var(--danger);font-size:13px;min-height:18px;margin-top:10px}
|
||||
|
||||
/* topbar */
|
||||
.topbar{position:sticky;top:0;z-index:50;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:56px}
|
||||
.topbar-brand{font-weight:700;color:var(--gold-d);font-size:16px;letter-spacing:1px}
|
||||
.topbar-actions{display:flex;align-items:center;gap:10px}
|
||||
.save-status{font-size:12px;color:#999;margin-right:4px}
|
||||
|
||||
/* buttons */
|
||||
.btn{border:1px solid transparent;border-radius:8px;padding:9px 16px;font-size:14px;cursor:pointer;transition:.15s}
|
||||
.btn-gold{background:linear-gradient(90deg,var(--gold),#9a6e2e);color:#fff}
|
||||
.btn-gold:hover{filter:brightness(1.06)}
|
||||
.btn-gold:disabled{opacity:.45;cursor:not-allowed}
|
||||
.btn-line{background:#fff;border-color:var(--line);color:var(--ink)}
|
||||
.btn-line:hover{border-color:var(--gold);color:var(--gold-d)}
|
||||
.btn-danger{background:#fff;border-color:#e8c4c0;color:var(--danger)}
|
||||
.btn-danger:hover{background:#fdf1f0}
|
||||
.btn-mini{padding:4px 10px;font-size:12px;border-radius:6px}
|
||||
|
||||
/* layout */
|
||||
.layout{display:flex;min-height:calc(100vh - 56px)}
|
||||
.side{width:200px;background:#fff;border-right:1px solid var(--line);padding:14px 10px;flex-shrink:0}
|
||||
.side-item{display:block;width:100%;text-align:left;border:none;background:transparent;padding:11px 14px;border-radius:8px;font-size:14px;color:var(--ink);cursor:pointer;margin-bottom:4px}
|
||||
.side-item:hover{background:#faf6ee}
|
||||
.side-item.active{background:linear-gradient(90deg,#f7ecd8,#fdf6ec);color:var(--gold-d);font-weight:600}
|
||||
.content{flex:1;padding:22px 26px;max-width:1060px}
|
||||
.view h2{font-size:18px;color:var(--gold-d);margin-bottom:6px}
|
||||
.view .desc{color:#999;font-size:13px;margin-bottom:18px}
|
||||
|
||||
/* cards & forms */
|
||||
.card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:18px;margin-bottom:14px}
|
||||
.card h3{font-size:15px;margin-bottom:12px;color:#555}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px 16px}
|
||||
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px 16px}
|
||||
.field{display:flex;flex-direction:column;gap:4px}
|
||||
.field label{font-size:12px;color:#8a7d63}
|
||||
.field input,.field select,.field textarea{padding:8px 10px;border:1px solid var(--line);border-radius:8px;font-size:14px;outline:none;font-family:inherit;background:#fff}
|
||||
.field input:focus,.field textarea:focus,.field select:focus{border-color:var(--gold)}
|
||||
.field textarea{resize:vertical;min-height:64px}
|
||||
|
||||
/* image slots */
|
||||
.img-row{display:flex;gap:12px;flex-wrap:wrap}
|
||||
.img-slot{width:180px;border:1px dashed #d8cdb6;border-radius:10px;background:#fbf8f1;overflow:hidden}
|
||||
.img-slot .thumb{width:100%;height:112px;object-fit:cover;display:block;background:#eee}
|
||||
.img-slot .slot-label{font-size:11px;color:#9a8d70;padding:6px 8px 2px;text-align:center}
|
||||
.img-slot .slot-actions{display:flex;gap:6px;justify-content:center;padding:6px 8px 10px;flex-wrap:wrap}
|
||||
|
||||
/* hotel rows in trips */
|
||||
.hotel-card{margin-bottom:12px}
|
||||
.hotel-head{display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none}
|
||||
.hotel-head .title{font-weight:600;color:var(--ink);flex:1}
|
||||
.hotel-head .badge{font-size:11px;color:#fff;background:var(--gold);border-radius:10px;padding:2px 8px}
|
||||
.hotel-head .arrow{color:#aaa;transition:.2s}
|
||||
.hotel-card.open .arrow{transform:rotate(90deg)}
|
||||
.hotel-body{border-top:1px dashed var(--line);margin-top:12px;padding-top:14px}
|
||||
.alt-item{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin-bottom:6px;background:#fdfbf6}
|
||||
.alt-item .name{flex:1;font-weight:500}
|
||||
.row-actions{display:flex;gap:8px;align-items:center}
|
||||
|
||||
/* misc */
|
||||
.section-title{display:flex;align-items:center;gap:10px;margin:16px 0 10px}
|
||||
.section-title h3{font-size:15px;color:var(--gold-d)}
|
||||
.section-title .line{flex:1;height:1px;background:var(--line)}
|
||||
.hint{font-size:12px;color:#a89a7d;margin-top:8px;line-height:1.6}
|
||||
.toast{position:fixed;left:50%;bottom:34px;transform:translateX(-50%);background:rgba(40,40,40,.92);color:#fff;padding:11px 20px;border-radius:24px;font-size:14px;z-index:999;opacity:0;pointer-events:none;transition:.25s}
|
||||
.toast.show{opacity:1}
|
||||
.route-row{display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:8px;background:#fdfbf6;flex-wrap:wrap}
|
||||
.route-row .rkey{font-size:11px;color:#aaa;font-family:monospace}
|
||||
.line-block{margin-bottom:20px}
|
||||
.alt-card{margin-bottom:12px}
|
||||
.backup-row{display:flex;align-items:center;gap:12px;border:1px solid var(--line);border-radius:8px;padding:8px 12px;margin-bottom:6px;background:#fdfbf6}
|
||||
.backup-row .fname{flex:1;font-family:monospace;font-size:12px;color:#666}
|
||||
select.trip-sel{padding:8px 10px;border:1px solid var(--line);border-radius:8px;margin-right:10px;font-size:14px}
|
||||
@media (max-width:860px){
|
||||
.layout{flex-direction:column}
|
||||
.side{width:100%;display:flex;flex-wrap:wrap;gap:4px;border-right:none;border-bottom:1px solid var(--line)}
|
||||
.side-item{width:auto}
|
||||
.grid2,.grid3{grid-template-columns:1fr}
|
||||
.content{padding:16px}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>呼籁旅行 · 内容管理</title>
|
||||
<link rel="stylesheet" href="/admin.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="login-view" class="login-wrap hidden">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">呼籁旅行</div>
|
||||
<div class="login-sub">甄选酒店 · 内容管理系统</div>
|
||||
<input id="login-password" type="password" placeholder="请输入管理密码" autocomplete="current-password"/>
|
||||
<button id="login-btn" class="btn btn-gold">进入管理后台</button>
|
||||
<div id="login-error" class="login-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app-view" class="app hidden">
|
||||
<header class="topbar">
|
||||
<div class="topbar-brand">呼籁旅行 · 内容管理</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="save-status" class="save-status">—</span>
|
||||
<button id="btn-preview" class="btn btn-line">预览站点</button>
|
||||
<button id="btn-save" class="btn btn-gold" disabled>保存并发布</button>
|
||||
<button id="btn-logout" class="btn btn-line">退出登录</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<nav class="side">
|
||||
<button class="side-item active" data-view="settings">站点设置</button>
|
||||
<button class="side-item" data-view="lines">产品线(线路/天数)</button>
|
||||
<button class="side-item" data-view="trips">行程与酒店</button>
|
||||
<button class="side-item" data-view="alts">备选酒店资料库</button>
|
||||
<button class="side-item" data-view="backup">备份与恢复</button>
|
||||
</nav>
|
||||
<main class="content">
|
||||
<div id="view-settings" class="view"></div>
|
||||
<div id="view-lines" class="view hidden"></div>
|
||||
<div id="view-trips" class="view hidden"></div>
|
||||
<div id="view-alts" class="view hidden"></div>
|
||||
<div id="view-backup" class="view hidden"></div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
</div>
|
||||
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,689 @@
|
||||
/* 呼籁旅行 · 内容管理后台 */
|
||||
'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 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();
|
||||
|
之后 宽度: | 高度: | 大小: 2.8 KiB |
|
之后 宽度: | 高度: | 大小: 32 KiB |
|
之后 宽度: | 高度: | 大小: 148 KiB |
|
之后 宽度: | 高度: | 大小: 258 KiB |
|
之后 宽度: | 高度: | 大小: 228 KiB |
|
之后 宽度: | 高度: | 大小: 1.2 MiB |
|
之后 宽度: | 高度: | 大小: 1.9 MiB |
|
之后 宽度: | 高度: | 大小: 2.0 MiB |
|
之后 宽度: | 高度: | 大小: 1.1 MiB |
|
之后 宽度: | 高度: | 大小: 1.4 MiB |
|
之后 宽度: | 高度: | 大小: 1.3 MiB |
|
之后 宽度: | 高度: | 大小: 34 KiB |
|
之后 宽度: | 高度: | 大小: 34 KiB |
|
之后 宽度: | 高度: | 大小: 35 KiB |
|
之后 宽度: | 高度: | 大小: 1.4 MiB |
|
之后 宽度: | 高度: | 大小: 1.4 MiB |
|
之后 宽度: | 高度: | 大小: 674 KiB |
|
之后 宽度: | 高度: | 大小: 2.3 MiB |
|
之后 宽度: | 高度: | 大小: 2.1 MiB |
|
之后 宽度: | 高度: | 大小: 2.0 MiB |
|
之后 宽度: | 高度: | 大小: 2.0 MiB |
|
之后 宽度: | 高度: | 大小: 248 KiB |
|
之后 宽度: | 高度: | 大小: 2.0 MiB |
|
之后 宽度: | 高度: | 大小: 994 KiB |
|
之后 宽度: | 高度: | 大小: 1.6 MiB |
|
之后 宽度: | 高度: | 大小: 1.2 MiB |
|
之后 宽度: | 高度: | 大小: 1.3 MiB |
|
之后 宽度: | 高度: | 大小: 1.3 MiB |
|
之后 宽度: | 高度: | 大小: 1.9 MiB |
|
之后 宽度: | 高度: | 大小: 1.9 MiB |
|
之后 宽度: | 高度: | 大小: 236 KiB |
|
之后 宽度: | 高度: | 大小: 472 KiB |
|
之后 宽度: | 高度: | 大小: 1.2 MiB |
|
之后 宽度: | 高度: | 大小: 257 KiB |
|
之后 宽度: | 高度: | 大小: 1.2 MiB |
|
之后 宽度: | 高度: | 大小: 1.2 MiB |
|
之后 宽度: | 高度: | 大小: 1.2 MiB |
|
之后 宽度: | 高度: | 大小: 716 KiB |
|
之后 宽度: | 高度: | 大小: 1.4 MiB |
|
之后 宽度: | 高度: | 大小: 1.7 MiB |
|
之后 宽度: | 高度: | 大小: 297 KiB |
|
之后 宽度: | 高度: | 大小: 1004 KiB |
|
之后 宽度: | 高度: | 大小: 1.2 MiB |
|
之后 宽度: | 高度: | 大小: 209 KiB |
|
之后 宽度: | 高度: | 大小: 1.6 MiB |
|
之后 宽度: | 高度: | 大小: 1.4 MiB |
|
之后 宽度: | 高度: | 大小: 926 KiB |
|
之后 宽度: | 高度: | 大小: 241 KiB |
|
之后 宽度: | 高度: | 大小: 1.4 MiB |
|
之后 宽度: | 高度: | 大小: 1.1 MiB |
|
之后 宽度: | 高度: | 大小: 218 KiB |
|
之后 宽度: | 高度: | 大小: 348 KiB |
|
之后 宽度: | 高度: | 大小: 346 KiB |
|
之后 宽度: | 高度: | 大小: 187 KiB |
|
之后 宽度: | 高度: | 大小: 330 KiB |
|
之后 宽度: | 高度: | 大小: 265 KiB |
|
之后 宽度: | 高度: | 大小: 677 KiB |
|
之后 宽度: | 高度: | 大小: 1.2 MiB |
|
之后 宽度: | 高度: | 大小: 1.0 MiB |
|
之后 宽度: | 高度: | 大小: 1.7 MiB |
|
之后 宽度: | 高度: | 大小: 1.3 MiB |
|
之后 宽度: | 高度: | 大小: 1.3 MiB |
|
之后 宽度: | 高度: | 大小: 1.4 MiB |
|
之后 宽度: | 高度: | 大小: 1012 KiB |
|
之后 宽度: | 高度: | 大小: 213 KiB |
|
之后 宽度: | 高度: | 大小: 1.7 MiB |
|
之后 宽度: | 高度: | 大小: 1.8 MiB |
|
之后 宽度: | 高度: | 大小: 2.4 MiB |
|
之后 宽度: | 高度: | 大小: 2.0 MiB |
|
之后 宽度: | 高度: | 大小: 1.7 MiB |
|
之后 宽度: | 高度: | 大小: 2.1 MiB |
|
之后 宽度: | 高度: | 大小: 1.9 MiB |
|
之后 宽度: | 高度: | 大小: 2.0 MiB |
|
之后 宽度: | 高度: | 大小: 2.1 MiB |
|
之后 宽度: | 高度: | 大小: 1.3 MiB |
|
之后 宽度: | 高度: | 大小: 792 KiB |
|
之后 宽度: | 高度: | 大小: 804 KiB |
|
之后 宽度: | 高度: | 大小: 1.7 MiB |
|
之后 宽度: | 高度: | 大小: 1.3 MiB |
|
之后 宽度: | 高度: | 大小: 1.2 MiB |
|
之后 宽度: | 高度: | 大小: 246 KiB |
|
之后 宽度: | 高度: | 大小: 1.4 MiB |
|
之后 宽度: | 高度: | 大小: 1.5 MiB |
|
之后 宽度: | 高度: | 大小: 1.2 MiB |
|
之后 宽度: | 高度: | 大小: 2.1 MiB |
|
之后 宽度: | 高度: | 大小: 1.3 MiB |
|
之后 宽度: | 高度: | 大小: 919 KiB |
|
之后 宽度: | 高度: | 大小: 1.2 MiB |
|
之后 宽度: | 高度: | 大小: 1.4 MiB |
|
之后 宽度: | 高度: | 大小: 1.8 MiB |
|
之后 宽度: | 高度: | 大小: 2.2 MiB |