feat: 呼籁旅行-甄选酒店站点初始化——访客页+后台管理(零依赖Node,data.json内容存储,自动备份50份,图片上传回收)

来源: hulai-travel-site.zip (2026-08-18 Kimi版复刻+CMS,移除Kimi外链脚本)
这个提交包含在:
wx
2026-08-18 11:39:45 +08:00
当前提交 45adc7ad47
共修改 169 个文件,包含 9389 行新增和 0 行删除
+13
查看文件
@@ -0,0 +1,13 @@
# 运行时生成,勿入库
config.json # 管理密码哈希(首次启动自动生成)
data-backups/ # 后台每次保存自动备份(最多 50 份)
hotels/_trash/ # 删除的图片回收目录
server.log
nohup.out
# 系统/编辑器垃圾
__MACOSX/
._*
.DS_Store
Thumbs.db
node_modules/
+78
查看文件
@@ -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
+95
查看文件
@@ -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}
}
+51
查看文件
@@ -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>
+689
查看文件
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// 生成可直接放进 onclick="..." 的 JS 字符串字面量
function q(s) {
return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
}
function altLibArr() { return Array.from(ALT_LIB.values()); }
function getByPath(obj, path) {
if (path.startsWith('__altLib__.')) {
const rest = path.slice('__altLib__.'.length);
const dot = rest.indexOf('.');
const idx = parseInt(dot < 0 ? rest : rest.slice(0, dot), 10);
const a = altLibArr()[idx];
if (!a) return undefined;
return dot < 0 ? a : getByPath(a, rest.slice(dot + 1));
}
return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
}
function setByPath(obj, path, value) {
if (path.startsWith('__altLib__.')) {
const rest = path.slice('__altLib__.'.length);
const dot = rest.indexOf('.');
const idx = parseInt(dot < 0 ? rest : rest.slice(0, dot), 10);
const a = altLibArr()[idx];
if (!a) return;
if (dot < 0) { altLibArr()[idx] = value; return; }
setByPath(a, rest.slice(dot + 1), value);
return;
}
const keys = path.split('.');
let o = obj;
for (let i = 0; i < keys.length - 1; i++) {
if (o[keys[i]] == null) o[keys[i]] = {};
o = o[keys[i]];
}
o[keys[keys.length - 1]] = value;
}
function markDirty() { DIRTY = true; $('#btn-save').disabled = false; $('#save-status').textContent = '有未保存修改'; }
function markClean() { DIRTY = false; $('#btn-save').disabled = true; $('#save-status').textContent = '已同步'; }
function toast(msg, isErr) {
const t = $('#toast');
t.textContent = msg;
t.style.background = isErr ? 'rgba(160,40,30,.94)' : 'rgba(40,40,40,.92)';
t.classList.add('show');
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove('show'), 2600);
}
async function api(path, opts) {
opts = opts || {};
const headers = Object.assign({ 'X-Requested-With': 'hulai-admin' }, opts.headers || {});
if (opts.json !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(path, { method: opts.method || 'GET', headers, body: opts.json !== undefined ? JSON.stringify(opts.json) : opts.body });
let data = null;
try { data = await res.json(); } catch (e) {}
if (!res.ok) {
const err = new Error((data && data.error) || ('请求失败 ' + res.status));
err.status = res.status;
throw err;
}
return data;
}
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
function newHotel(day) {
return { day: day || 1, city: '', name: '新酒店', subtitle: '', stars: 4, year: '2025年装修',
location: '', bedType: '', facilities: '', breakfast: '含早餐', alternatives: [], images: [], altHotels: [] };
}
function newAltHotel(name) {
return { name: name || '新备选酒店', subtitle: '', stars: 4, year: '', location: '', bedType: '',
facilities: '', breakfast: '', images: [] };
}
/* ---------- 资料库 ---------- */
function buildAltLib() {
const map = new Map();
for (const rk of Object.keys(DATA.Am || {})) {
for (const h of (DATA.Am[rk].hotels || [])) {
for (const a of (h.altHotels || [])) {
if (!map.has(a.name)) map.set(a.name, JSON.parse(JSON.stringify(a)));
}
}
}
return map;
}
/* ---------- 图片 ---------- */
function uploadImage(file, cb) {
if (!file) return;
if (file.size > 12 * 1024 * 1024) { toast('图片超过 12MB,请压缩后再传', true); return; }
const reader = new FileReader();
reader.onload = async () => {
try {
const r = await api('/api/upload', { method: 'POST', json: { filename: file.name, data: reader.result } });
cb(r.path);
toast('图片上传成功');
markDirty(); render();
} catch (e) { toast(e.message, true); }
};
reader.readAsDataURL(file);
}
function makeFileInput(path, idx, label) {
const id = 'f_' + uid();
return '<label class="btn btn-line btn-mini" for="' + id + '">' + label + '</label>' +
'<input id="' + id + '" type="file" accept="image/*" style="display:none" ' +
'onchange="onSlotFile(this,' + q(path) + ',' + idx + ')"/>';
}
window.onSlotFile = function (input, path, idx) {
const file = input.files && input.files[0];
uploadImage(file, (p) => {
const cur = getByPath(DATA, path);
if (Array.isArray(cur)) {
while (cur.length <= idx) cur.push(null);
cur[idx] = p;
} else {
setByPath(DATA, path, p);
}
});
};
window.removeSlot = function (path, idx) {
if (!confirm('确定移除该图片?图片文件将移入回收目录。')) return;
const arr = getByPath(DATA, path);
const p = arr[idx];
arr.splice(idx, 1);
api('/api/delete-image', { method: 'POST', json: { path: p } }).catch(() => {});
markDirty(); render();
};
window.fillPlaceholder = function (path, idx) {
const cur = getByPath(DATA, path);
if (Array.isArray(cur)) {
while (cur.length <= idx) cur.push(null);
cur[idx] = '/hotels/_placeholder.png';
} else {
setByPath(DATA, path, '/hotels/_placeholder.png');
}
markDirty(); render();
};
function imageSlots(path) {
const arr = getByPath(DATA, path) || [];
let html = '<div class="img-row">';
for (let i = 0; i < 3; i++) {
const src = arr[i];
html += '<div class="img-slot">';
html += src
? '<img class="thumb" src="' + esc(src) + '?v=' + uid() + '"/>'
: '<div class="thumb" style="display:flex;align-items:center;justify-content:center;color:#c9b98f">' + ['房间图','外观图','设施图'][i] + '(空)</div>';
html += '<div class="slot-label">' + ['房间图','外观图','设施图'][i] + '</div>';
html += '<div class="slot-actions">';
if (src) {
html += '<button class="btn btn-line btn-mini" onclick="removeSlot(' + q(path) + ',' + i + ')">移除</button>';
} else {
html += makeFileInput(path, i, '上传');
html += '<button class="btn btn-line btn-mini" onclick="fillPlaceholder(' + q(path) + ',' + i + ')">占位图</button>';
}
html += '</div></div>';
}
html += '</div><div class="hint">前台要求至少 3 张图才展示图片区,建议保持 3 张齐全。</div>';
return html;
}
/* ---------- 渲染 ---------- */
function render() {
if (!DATA) return;
renderSettings();
renderLines();
renderTrips();
renderAlts();
}
/* 站点设置 */
function renderSettings() {
const el = $('#view-settings');
el.innerHTML =
'<h2>站点设置</h2><div class="desc">修改后点击右上角「保存并发布」,前台即刻生效。</div>' +
'<div class="card"><h3>页面文字</h3><div class="grid2">' +
field('siteName', '顶部/主标题', 'siteName') +
field('siteSubtitle', '副标题', 'siteSubtitle') +
field('siteEn', '英文副标题', 'siteEn') +
field('siteTitle', '浏览器标签标题', 'siteTitle') +
field('tipsLabel', '提示语前缀', 'tipsLabel') +
field('footerText', '页脚文字', 'footerText') +
'</div>' +
'<div class="field" style="margin-top:12px"><label>温馨提示正文</label>' +
'<textarea onchange="onField(this)" data-path="tipsText">' + esc(DATA.tipsText) + '</textarea></div>' +
'</div>' +
'<div class="card"><h3>顶部主图(hero)</h3>' +
'<div class="img-row"><div class="img-slot" style="width:340px">' +
'<img class="thumb" style="height:150px" src="' + esc(DATA.heroBg) + '?v=' + uid() + '"/>' +
'<div class="slot-actions">' + makeFileInput('heroBg', 0, '更换图片') +
'<button class="btn btn-line btn-mini" onclick="DATA.heroBg=\'/hotels/hero-bg.png\';markDirty();render()">恢复默认</button>' +
'</div></div></div>' +
'<div class="hint">建议尺寸 1920×1080。上传后自动保存到 /hotels/ 目录。</div>' +
'</div>' +
'<div class="card"><h3>修改管理密码</h3><div class="grid3">' +
'<div class="field"><label>当前密码</label><input id="pwd-old" type="password" autocomplete="off"/></div>' +
'<div class="field"><label>新密码</label><input id="pwd-new" type="password" autocomplete="off"/></div>' +
'<div class="field"><label>确认新密码</label><input id="pwd-confirm" type="password" autocomplete="off"/></div>' +
'</div><div style="margin-top:10px"><button class="btn btn-gold" onclick="changePassword()">更新密码</button></div>' +
'<div class="hint">密码保存在服务器 config.json 中(加密存储),忘记时可删除 config.json 后重启服务,密码会恢复为初始密码。</div></div>';
}
function field(id, label, path) {
const v = getByPath(DATA, path);
return '<div class="field"><label for="f_' + id + '">' + esc(label) + '</label>' +
'<input id="f_' + id + '" value="' + esc(v) + '" onchange="onField(this)" data-path="' + esc(path) + '"/></div>';
}
window.onField = function (input) {
setByPath(DATA, input.dataset.path, input.value);
markDirty();
};
window.changePassword = async function () {
const o = $('#pwd-old').value, n = $('#pwd-new').value, c = $('#pwd-confirm').value;
if (!o || !n) return toast('请填写完整', true);
if (n !== c) return toast('两次输入的新密码不一致', true);
if (n.length < 6) return toast('新密码至少 6 位', true);
try {
await api('/api/change-password', { method: 'POST', json: { old: o, next: n } });
toast('密码已更新');
$('#pwd-old').value = $('#pwd-new').value = $('#pwd-confirm').value = '';
} catch (e) { toast(e.message, true); }
};
/* 产品线 */
function renderLines() {
const el = $('#view-lines');
let html = '<h2>产品线(线路 / 天数标签)</h2>' +
'<div class="desc">「线路」= 前台顶部导航(南线/北线等);「天数标签」= 线路下的行程选择。均可增、删、改名、排序。</div>';
const av = DATA.av || [];
av.forEach((line, li) => {
const kf = DATA.Kf[line] || { label: line, routes: [] };
html += '<div class="card line-block"><div class="row-actions" style="margin-bottom:12px">' +
'<div class="field" style="flex:1"><label>线路名称</label>' +
'<input value="' + esc(kf.label) + '" onchange="onField(this)" data-path="Kf.' + esc(line) + '.label"/></div>' +
'<button class="btn btn-line btn-mini" onclick="moveLine(' + li + ',-1)">↑</button>' +
'<button class="btn btn-line btn-mini" onclick="moveLine(' + li + ',1)">↓</button>' +
'<button class="btn btn-danger btn-mini" onclick="deleteLine(' + li + ')">删除线路</button>' +
'</div>';
(kf.routes || []).forEach((rk, ri) => {
const am = DATA.Am[rk] || { label: '', days: '', hotels: [] };
html += '<div class="route-row">' +
'<span class="rkey">' + esc(rk) + '</span>' +
'<div class="field" style="width:170px"><label>标签文字</label>' +
'<input value="' + esc(am.days) + '" onchange="onField(this)" data-path="Am.' + esc(rk) + '.days"/></div>' +
'<div class="field" style="width:200px"><label>信息栏文案</label>' +
'<input value="' + esc(am.label) + '" onchange="onField(this)" data-path="Am.' + esc(rk) + '.label"/></div>' +
'<div class="hint" style="flex:1">酒店数:' + (am.hotels || []).length + '</div>' +
'<button class="btn btn-line btn-mini" onclick="moveRoute(' + q(line) + ',' + ri + ',-1)">↑</button>' +
'<button class="btn btn-line btn-mini" onclick="moveRoute(' + q(line) + ',' + ri + ',1)">↓</button>' +
'<button class="btn btn-danger btn-mini" onclick="deleteRoute(' + q(line) + ',' + ri + ')">删除</button>' +
'</div>';
});
html += '<div style="margin-top:8px"><button class="btn btn-line btn-mini" onclick="addRoute(' + q(line) + ')">+ 添加天数标签</button></div></div>';
});
html += '<div><button class="btn btn-gold" onclick="addLine()">+ 添加线路</button></div>';
el.innerHTML = html;
}
window.moveLine = function (i, d) {
const av = DATA.av;
const j = i + d;
if (j < 0 || j >= av.length) return;
[av[i], av[j]] = [av[j], av[i]];
markDirty(); render();
};
window.deleteLine = function (i) {
const av = DATA.av;
const line = av[i];
if (!confirm('删除线路「' + DATA.Kf[line].label + '」?其下行程与酒店数据也会一并删除。')) return;
(DATA.Kf[line].routes || []).forEach(rk => delete DATA.Am[rk]);
delete DATA.Kf[line];
av.splice(i, 1);
markDirty(); render();
};
window.addLine = function () {
const base = '新线路';
let key = base, n = 1;
while (DATA.Kf[key]) key = base + n++;
DATA.av.push(key);
DATA.Kf[key] = { label: '新线路', routes: [] };
markDirty(); render();
};
window.moveRoute = function (line, i, d) {
const rs = DATA.Kf[line].routes;
const j = i + d;
if (j < 0 || j >= rs.length) return;
[rs[i], rs[j]] = [rs[j], rs[i]];
markDirty(); render();
};
window.deleteRoute = function (line, i) {
const rs = DATA.Kf[line].routes;
const rk = rs[i];
if (!confirm('删除行程「' + DATA.Am[rk].days + '」?其中 ' + (DATA.Am[rk].hotels || []).length + ' 天的酒店数据也会删除。')) return;
rs.splice(i, 1);
delete DATA.Am[rk];
markDirty(); render();
};
window.addRoute = function (line) {
const days = prompt('请输入标签文字(如:6天5晚)');
if (!days) return;
let key = line + days, n = 1;
while (DATA.Am[key]) key = line + days + '-' + (n++);
DATA.Kf[line].routes.push(key);
DATA.Am[key] = { label: days, days, hotels: [] };
markDirty(); render();
};
/* 行程与酒店 */
function renderTrips() {
const el = $('#view-trips');
const av = DATA.av || [];
if (!TRIPS.line || !DATA.Kf[TRIPS.line]) TRIPS.line = av[0] || null;
const routes = (TRIPS.line && DATA.Kf[TRIPS.line]) ? (DATA.Kf[TRIPS.line].routes || []) : [];
if (!TRIPS.route || !DATA.Am[TRIPS.route]) TRIPS.route = routes[0] || null;
if (!TRIPS.line) { el.innerHTML = '<h2>行程与酒店</h2><div class="desc">请先在「产品线」中添加线路。</div>'; return; }
let html = '<h2>行程与酒店</h2><div class="desc">维护每个行程每天的酒店:文字、星级、3 张图片、备选酒店均可增删改。</div>';
html += '<div class="card"><div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px">' +
'<select class="trip-sel" onchange="tripSelLine(this.value)">' +
av.map(l => '<option value="' + esc(l) + '"' + (l === TRIPS.line ? ' selected' : '') + '>' + esc(DATA.Kf[l].label) + '</option>').join('') +
'</select>' +
'<select class="trip-sel" onchange="tripSelRoute(this.value)">' +
routes.map(rk => '<option value="' + esc(rk) + '"' + (rk === TRIPS.route ? ' selected' : '') + '>' + esc(DATA.Am[rk].days) + '</option>').join('') +
'</select>' +
'<button class="btn btn-gold" onclick="addHotel()">+ 添加一天酒店</button>' +
'</div></div>';
const am = DATA.Am[TRIPS.route] || { hotels: [] };
(am.hotels || []).forEach((h, i) => {
const key = TRIPS.route + ':' + i;
const open = OPEN_HOTELS.has(key);
html += '<div class="card hotel-card' + (open ? ' open' : '') + '">' +
'<div class="hotel-head" onclick="toggleHotel(' + q(key) + ')">' +
'<span class="arrow">▶</span>' +
'<span class="badge">DAY ' + esc(h.day) + '</span>' +
'<span class="title">' + esc(h.city ? (h.city + ' · ') : '') + esc(h.name) + (h.subtitle ? '(' + esc(h.subtitle) + ')' : '') + '</span>' +
'<span style="color:#c9a96e">' + '💎'.repeat(h.stars || 0) + '</span>' +
'</div>' +
(open ? '<div class="hotel-body">' +
'<div class="grid3">' +
'<div class="field"><label>第几天 DAY</label><input type="number" min="1" value="' + esc(h.day) + '" onchange="onHotelField(this,' + i + ')" data-key="day"/></div>' +
'<div class="field"><label>城市/地点</label><input value="' + esc(h.city) + '" onchange="onHotelField(this,' + i + ')" data-key="city"/></div>' +
'<div class="field"><label>酒店名称</label><input value="' + esc(h.name) + '" onchange="onHotelField(this,' + i + ')" data-key="name"/></div>' +
'<div class="field"><label>房型/副标题</label><input value="' + esc(h.subtitle) + '" onchange="onHotelField(this,' + i + ')" data-key="subtitle"/></div>' +
'<div class="field"><label>星级(1-5)</label><select onchange="onHotelField(this,' + i + ')" data-key="stars">' +
[1,2,3,4,5].map(s => '<option value="' + s + '"' + (h.stars === s ? ' selected' : '') + '>' + '💎'.repeat(s) + '</option>').join('') +
'</select></div>' +
'<div class="field"><label>装修年份</label><input value="' + esc(h.year) + '" onchange="onHotelField(this,' + i + ')" data-key="year"/></div>' +
'<div class="field"><label>位置</label><input value="' + esc(h.location) + '" onchange="onHotelField(this,' + i + ')" data-key="location"/></div>' +
'<div class="field"><label>床型</label><input value="' + esc(h.bedType) + '" onchange="onHotelField(this,' + i + ')" data-key="bedType"/></div>' +
'<div class="field"><label>设施</label><input value="' + esc(h.facilities) + '" onchange="onHotelField(this,' + i + ')" data-key="facilities"/></div>' +
'</div>' +
'<div class="field" style="margin-top:12px"><label>餐饮</label><input value="' + esc(h.breakfast) + '" onchange="onHotelField(this,' + i + ')" data-key="breakfast"/></div>' +
'<div style="margin-top:14px"><label style="font-size:12px;color:#8a7d63">酒店图片(3 张:房间/外观/设施)</label><div style="margin-top:6px">' + imageSlots('Am.' + TRIPS.route + '.hotels.' + i + '.images') + '</div></div>' +
'<div style="margin-top:16px"><label style="font-size:12px;color:#8a7d63">备选酒店(前台「备选酒店」按钮)</label>' +
'<div style="margin-top:6px">' + renderAltList(i) + '</div></div>' +
'<div class="row-actions" style="margin-top:16px">' +
'<button class="btn btn-line btn-mini" onclick="moveHotel(' + i + ',-1)">↑ 上移</button>' +
'<button class="btn btn-line btn-mini" onclick="moveHotel(' + i + ',1)">↓ 下移</button>' +
'<button class="btn btn-line btn-mini" onclick="dupHotel(' + i + ')">复制</button>' +
'<button class="btn btn-danger btn-mini" onclick="deleteHotel(' + i + ')">删除这一天</button>' +
'</div></div>' : '') +
'</div>';
});
el.innerHTML = html;
}
function renderAltList(i) {
const am = DATA.Am[TRIPS.route];
const h = am.hotels[i];
const alts = h.altHotels || [];
let html = alts.map((a, ai) =>
'<div class="alt-item"><span class="name">' + esc(a.name) + (a.subtitle ? '(' + esc(a.subtitle) + ')' : '') + '</span>' +
'<button class="btn btn-line btn-mini" onclick="editAlt(' + i + ',' + ai + ')">改名</button>' +
'<button class="btn btn-danger btn-mini" onclick="removeAlt(' + i + ',' + ai + ')">移除</button></div>'
).join('');
const used = new Set(alts.map(a => a.name));
const options = altLibArr().filter(a => !used.has(a.name))
.map(a => '<option value="' + esc(a.name) + '">' + esc(a.name) + '</option>').join('');
html += '<div class="row-actions" style="margin-top:8px;flex-wrap:wrap">' +
'<select id="alt-add-sel-' + i + '" style="padding:6px 8px;border:1px solid #e5e0d5;border-radius:8px;max-width:260px">' +
(options ? options : '<option value="">(资料库中暂无未添加的备选)</option>') + '</select>' +
'<button class="btn btn-line btn-mini" onclick="addAlt(' + i + ')">添加</button>' +
'<button class="btn btn-line btn-mini" onclick="createAlt(' + i + ')">+ 新建备选</button>' +
'</div>';
return html;
}
window.tripSelLine = function (v) { TRIPS.line = v; const rs = DATA.Kf[v].routes || []; TRIPS.route = rs[0] || null; renderTrips(); };
window.tripSelRoute = function (v) { TRIPS.route = v; renderTrips(); };
window.toggleHotel = function (key) { OPEN_HOTELS.has(key) ? OPEN_HOTELS.delete(key) : OPEN_HOTELS.add(key); renderTrips(); };
window.onHotelField = function (input, i) {
const h = DATA.Am[TRIPS.route].hotels[i];
const k = input.dataset.key;
let v = input.value;
if (k === 'stars' || k === 'day') v = parseInt(v, 10) || 1;
h[k] = v;
markDirty();
};
window.addHotel = function () {
const am = DATA.Am[TRIPS.route];
am.hotels = am.hotels || [];
am.hotels.push(newHotel(am.hotels.length + 1));
OPEN_HOTELS.add(TRIPS.route + ':' + (am.hotels.length - 1));
markDirty(); renderTrips();
};
window.deleteHotel = function (i) {
const am = DATA.Am[TRIPS.route];
if (!confirm('删除 DAY ' + am.hotels[i].day + '(' + am.hotels[i].name + ')?')) return;
am.hotels.splice(i, 1);
markDirty(); renderTrips();
};
window.moveHotel = function (i, d) {
const hs = DATA.Am[TRIPS.route].hotels;
const j = i + d;
if (j < 0 || j >= hs.length) return;
[hs[i], hs[j]] = [hs[j], hs[i]];
markDirty(); renderTrips();
};
window.dupHotel = function (i) {
const hs = DATA.Am[TRIPS.route].hotels;
hs.splice(i + 1, 0, JSON.parse(JSON.stringify(hs[i])));
markDirty(); renderTrips();
};
window.addAlt = function (i) {
const sel = $('#alt-add-sel-' + i);
const name = sel.value;
if (!name) return;
const lib = ALT_LIB.get(name);
const h = DATA.Am[TRIPS.route].hotels[i];
h.altHotels = h.altHotels || [];
h.altHotels.push(JSON.parse(JSON.stringify(lib)));
markDirty(); renderTrips();
};
window.removeAlt = function (i, ai) {
DATA.Am[TRIPS.route].hotels[i].altHotels.splice(ai, 1);
markDirty(); renderTrips();
};
window.createAlt = function (i) {
const name = prompt('新备选酒店名称:');
if (!name) return;
const h = DATA.Am[TRIPS.route].hotels[i];
h.altHotels = h.altHotels || [];
h.altHotels.push(newAltHotel(name));
ALT_LIB = buildAltLib();
markDirty(); renderTrips();
};
window.editAlt = function (i, ai) {
const h = DATA.Am[TRIPS.route].hotels[i];
const a = h.altHotels[ai];
const name = prompt('备选酒店名称:', a.name);
if (!name || name === a.name) return;
a.name = name;
ALT_LIB = buildAltLib();
markDirty(); renderTrips();
};
/* 备选酒店资料库 */
function renderAlts() {
const el = $('#view-alts');
let html = '<h2>备选酒店资料库</h2>' +
'<div class="desc">同一家备选酒店可能出现在多条线路。这里维护一份,保存发布时自动同步到所有引用处;改名也会同步。</div>';
const lib = altLibArr();
if (!lib.length) { html += '<div class="hint">暂无备选酒店。</div>'; el.innerHTML = html; return; }
lib.forEach((a, idx) => {
html += '<div class="card alt-card"><div class="row-actions" style="margin-bottom:10px">' +
'<span style="font-weight:700;font-size:15px;flex:1">' + esc(a.name) + '</span>' +
'<button class="btn btn-line btn-mini" onclick="renameAlt(' + idx + ')">改名</button>' +
'<button class="btn btn-danger btn-mini" onclick="deleteAltFromLib(' + idx + ')">从资料库删除</button></div>' +
'<div class="grid3">' +
'<div class="field"><label>副标题/房型</label><input value="' + esc(a.subtitle) + '" onchange="onAltField(this,' + idx + ')" data-key="subtitle"/></div>' +
'<div class="field"><label>星级</label><select onchange="onAltField(this,' + idx + ')" data-key="stars">' +
[1,2,3,4,5].map(s => '<option value="' + s + '"' + (a.stars === s ? ' selected' : '') + '>' + '💎'.repeat(s) + '</option>').join('') + '</select></div>' +
'<div class="field"><label>装修年份</label><input value="' + esc(a.year) + '" onchange="onAltField(this,' + idx + ')" data-key="year"/></div>' +
'<div class="field"><label>位置</label><input value="' + esc(a.location) + '" onchange="onAltField(this,' + idx + ')" data-key="location"/></div>' +
'<div class="field"><label>床型</label><input value="' + esc(a.bedType) + '" onchange="onAltField(this,' + idx + ')" data-key="bedType"/></div>' +
'<div class="field"><label>设施</label><input value="' + esc(a.facilities) + '" onchange="onAltField(this,' + idx + ')" data-key="facilities"/></div>' +
'</div>' +
'<div class="field" style="margin-top:12px"><label>餐饮</label><input value="' + esc(a.breakfast) + '" onchange="onAltField(this,' + idx + ')" data-key="breakfast"/></div>' +
'<div style="margin-top:14px"><label style="font-size:12px;color:#8a7d63">图片(3 张)</label><div style="margin-top:6px">' + imageSlots('__altLib__.' + idx + '.images') + '</div></div>' +
'</div>';
});
el.innerHTML = html;
}
window.onAltField = function (input, idx) {
const a = altLibArr()[idx];
const k = input.dataset.key;
a[k] = (k === 'stars') ? (parseInt(input.value, 10) || 1) : input.value;
markDirty();
};
window.renameAlt = function (idx) {
const a = altLibArr()[idx];
const old = a.name;
const next = prompt('新名称:', old);
if (!next || next === old) return;
for (const rk of Object.keys(DATA.Am)) {
for (const h of DATA.Am[rk].hotels) {
for (const x of (h.altHotels || [])) if (x.name === old) x.name = next;
if (h.alternatives) h.alternatives = h.alternatives.map(n => n === old ? next : n);
}
}
a.name = next;
ALT_LIB = buildAltLib();
markDirty(); render();
};
window.deleteAltFromLib = function (idx) {
const a = altLibArr()[idx];
if (!confirm('从资料库删除「' + a.name + '」,并同时从所有行程中移除该备选?')) return;
for (const rk of Object.keys(DATA.Am)) {
for (const h of DATA.Am[rk].hotels) {
h.altHotels = (h.altHotels || []).filter(x => x.name !== a.name);
if (h.alternatives) h.alternatives = h.alternatives.filter(n => n !== a.name);
}
}
ALT_LIB = buildAltLib();
markDirty(); render();
};
/* 备份与恢复 */
function renderBackup() {
const el = $('#view-backup');
el.innerHTML = '<h2>备份与恢复</h2>' +
'<div class="desc">每次「保存并发布」都会自动生成一份历史备份(保留最近 50 份)。</div>' +
'<div class="card"><h3>数据操作</h3><div class="row-actions" style="flex-wrap:wrap">' +
'<button class="btn btn-line" onclick="downloadData()">下载当前数据(JSON)</button>' +
'<label class="btn btn-line" for="import-file">导入 JSON 数据</label>' +
'<input id="import-file" type="file" accept=".json,application/json" style="display:none" onchange="onImport(this)"/>' +
'<button class="btn btn-line" onclick="restoreSeed()">恢复为初始数据</button>' +
'</div><div class="hint">导入会整体替换当前内容,请先下载备份。</div></div>' +
'<div class="card"><h3>历史备份</h3><div id="backup-list">加载中…</div></div>';
api('/api/backups').then(r => {
const list = $('#backup-list');
if (!r.files || !r.files.length) { list.innerHTML = '<div class="hint">暂无备份。</div>'; return; }
list.innerHTML = r.files.map(f =>
'<div class="backup-row"><span class="fname">' + esc(f) + '</span>' +
'<button class="btn btn-line btn-mini" onclick="restoreBackup(' + q(f) + ')">恢复此版本</button></div>'
).join('');
}).catch(e => { $('#backup-list').innerHTML = '<div class="hint">加载失败:' + esc(e.message) + '</div>'; });
}
window.downloadData = function () {
const blob = new Blob([JSON.stringify(DATA, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'hulai-data-' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
};
window.onImport = function (input) {
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const obj = JSON.parse(reader.result);
if (!obj || typeof obj.Am !== 'object' || typeof obj.Kf !== 'object' || !Array.isArray(obj.av)) throw new Error('结构不对');
DATA = obj;
ALT_LIB = buildAltLib();
TRIPS = { line: DATA.av[0] || null, route: null };
OPEN_HOTELS.clear();
markDirty(); render();
toast('已导入,点击「保存并发布」生效');
} catch (e) { toast('导入失败:' + e.message, true); }
};
reader.readAsText(file);
input.value = '';
};
window.restoreSeed = async function () {
if (!confirm('恢复为初始数据?当前修改将丢失(会先自动备份当前数据)。')) return;
try {
const r = await api('/data.seed.json');
DATA = r; ALT_LIB = buildAltLib();
TRIPS = { line: DATA.av[0] || null, route: null };
OPEN_HOTELS.clear();
markDirty(); render();
toast('已载入初始数据,点击「保存并发布」生效');
} catch (e) { toast(e.message, true); }
};
window.restoreBackup = async function (file) {
if (!confirm('恢复到 ' + file + ' ?当前数据会先自动备份。')) return;
try {
await api('/api/restore-backup', { method: 'POST', json: { file } });
const d = await api('/api/data');
DATA = d; ALT_LIB = buildAltLib();
TRIPS = { line: DATA.av[0] || null, route: null };
OPEN_HOTELS.clear();
markClean(); render();
toast('已恢复该版本,前台立即生效');
} catch (e) { toast(e.message, true); }
};
/* ---------- 保存 ---------- */
window.saveAll = async function () {
try {
// 资料库同步到所有引用 + 重建 alternatives + 清洗
for (const rk of Object.keys(DATA.Am)) {
for (const h of DATA.Am[rk].hotels) {
h.altHotels = (h.altHotels || []).map(a => {
const lib = ALT_LIB.get(a.name);
return lib ? JSON.parse(JSON.stringify(lib)) : a;
});
h.alternatives = h.altHotels.map(a => a.name);
h.images = (h.images || []).filter(Boolean).slice(0, 3);
h.stars = Math.min(5, Math.max(1, parseInt(h.stars, 10) || 1));
h.day = parseInt(h.day, 10) || 1;
}
}
// 清理孤儿数据
const usedRoutes = new Set();
for (const line of DATA.av) {
if (!DATA.Kf[line]) continue;
(DATA.Kf[line].routes || []).forEach(rk => usedRoutes.add(rk));
}
for (const rk of Object.keys(DATA.Am)) if (!usedRoutes.has(rk)) delete DATA.Am[rk];
for (const a of altLibArr()) a.images = (a.images || []).filter(Boolean).slice(0, 3);
await api('/api/data', { method: 'PUT', json: DATA });
markClean();
toast('已发布,前台立即生效 ✨');
} catch (e) {
if (e.status === 401) { showLogin(); return; }
toast('保存失败:' + e.message, true);
}
};
/* ---------- 视图切换 ---------- */
function switchView(v) {
VIEW = v;
$$('.side-item').forEach(b => b.classList.toggle('active', b.dataset.view === v));
$$('.view').forEach(x => x.classList.add('hidden'));
$('#view-' + v).classList.remove('hidden');
if (v === 'backup') renderBackup();
}
$$('.side-item').forEach(b => b.addEventListener('click', () => switchView(b.dataset.view)));
/* ---------- 登录 ---------- */
function showLogin() {
$('#login-view').classList.remove('hidden');
$('#app-view').classList.add('hidden');
}
function showApp() {
$('#login-view').classList.add('hidden');
$('#app-view').classList.remove('hidden');
}
async function doLogin() {
const pwd = $('#login-password').value;
$('#login-error').textContent = '';
try {
await api('/api/login', { method: 'POST', json: { password: pwd } });
await initApp();
} catch (e) {
$('#login-error').textContent = e.message || '登录失败';
}
}
$('#login-btn').addEventListener('click', doLogin);
$('#login-password').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
$('#btn-logout').addEventListener('click', async () => {
await api('/api/logout', { method: 'POST' }).catch(() => {});
DATA = null; DIRTY = false;
showLogin();
});
$('#btn-save').addEventListener('click', saveAll);
$('#btn-preview').addEventListener('click', () => window.open('/', '_blank'));
/* ---------- 初始化 ---------- */
async function initApp() {
try {
const me = await api('/api/me');
if (!me.authed) return showLogin();
const d = await api('/api/data');
DATA = d;
ALT_LIB = buildAltLib();
TRIPS = { line: DATA.av[0] || null, route: null };
OPEN_HOTELS.clear();
markClean();
showApp();
render();
} catch (e) {
$('#login-error').textContent = '加载失败:' + e.message;
showLogin();
}
}
initApp();
文件差异因一行或多行过长而隐藏
文件差异因一行或多行过长而隐藏
+4042
查看文件
文件差异内容过多而无法显示 加载差异
+4039
查看文件
文件差异内容过多而无法显示 加载差异
二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 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

某些文件未显示,因为此 diff 中更改的文件太多 显示更多