Files
hometown/index.html
2026-03-07 19:13:49 +08:00

457 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="lib/pannellum.css">
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
.pano-root { flex: 1 1 0%; position: relative; width: 100%; height: 100%; }
#krp { position: absolute; inset: 0; z-index: 0; }
#player_krp {
position: relative; overflow: hidden; isolation: isolate;
height: 100%; width: 100%; line-height: normal; font-weight: normal; font-style: normal;
outline: 0; -webkit-tap-highlight-color: transparent; background: #000;
}
#panorama { position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden; }
.load-error {
position: absolute; left: 0; top: 0; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
padding: 2em; text-align: center; font-family: sans-serif; color: #999;
background: #000; z-index: 2;
}
.load-error a { color: #08c; }
/* 弹幕层 */
#danmaku-wrap {
position: absolute; left: 0; top: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 5; overflow: hidden;
}
.danmaku-line {
position: absolute; white-space: nowrap;
font-size: 14px; color: #fff; text-shadow: 0 0 2px #000, 0 1px 4px rgba(0,0,0,.8);
animation: danmaku-scroll 15s linear forwards;
}
@keyframes danmaku-scroll {
from { transform: translateX(100vw); }
to { transform: translateX(-100%); }
}
/* 顶部区域 */
.topWp { position: absolute; left: 0; top: 0; width: 100%; pointer-events: none; z-index: 10; }
.topWp > * { pointer-events: auto; }
.LeftBtn { position: absolute; left: 0; top: 12px; padding: 0 16px; }
.authorPvBox { margin-bottom: 8px; }
.Author { color: #fff; text-decoration: none; font-size: 13px; opacity: 1; }
.view-stats { color: rgba(255,255,255,.7); font-size: 12px; margin-top: 4px; }
.view-stats span { margin-right: 12px; }
.RightBtn { position: absolute; right: 12px; top: 12px; display: flex; gap: 8px; align-items: center; }
.RightBtn .btn-wrap {
display: flex; flex-direction: column; align-items: center; cursor: pointer;
padding: 6px 10px; background: rgba(0,0,0,.3); border-radius: 6px; color: #fff;
font-size: 12px; border: none; font-family: inherit;
}
.RightBtn .btn-wrap:hover { background: rgba(0,0,0,.5); }
.RightBtn .btn-wrap svg { width: 24px; height: 24px; margin-bottom: 2px; }
/* 底部区域 */
.bottom { position: absolute; left: 0; bottom: 0; width: 100%; z-index: 10; pointer-events: none; }
.bottom > * { pointer-events: auto; }
.bottomWp { display: flex; justify-content: flex-end; align-items: center; padding: 12px 16px; }
.bottom_right .CustomButton {
display: inline-flex; flex-direction: column; align-items: center; padding: 8px 12px;
background: rgba(0,0,0,.3); border-radius: 8px; color: #fff; cursor: pointer;
font-size: 12px; border: 2px solid transparent; margin: 0 4px;
}
.bottom_right .CustomButton:hover { background: rgba(0,0,0,.5); }
.bottom_right .CustomButton.selected { border-color: #fa6400; }
.CustomButton .count { font-size: 10px; opacity: .9; margin-top: 2px; }
.safeHeight { height: env(safe-area-inset-bottom, 0); }
/* 留言弹窗 */
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; display: flex; align-items: center; justify-content: center; padding: 20px; }
.modal-mask.hide { display: none; }
.modal-box { background: #333; color: #fff; border-radius: 12px; padding: 20px; min-width: 280px; max-width: 90vw; }
.modal-box h3 { margin: 0 0 12px; font-size: 16px; }
.modal-box input, .modal-box textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; margin-bottom: 10px; border: 1px solid #555; border-radius: 6px; background: #222; color: #fff; font-size: 14px; }
.modal-box textarea { min-height: 72px; resize: vertical; }
.modal-box .btns { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
.modal-box .btns button { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
.modal-box .btns .btn-ok { background: #fa6400; color: #fff; }
.modal-box .btns .btn-cancel { background: #555; color: #fff; }
/* 隐藏 Pannellum 左侧控件及左下角标志 */
#panorama .pnlm-controls-container { display: none !important; }
#panorama .pnlm-about-msg { display: none !important; }
</style>
</head>
<body>
<div class="pano-root">
<div id="krp">
<div id="player_krp" tabindex="-1">
<div style="position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden;">
<div id="panorama"></div>
</div>
<div id="danmaku-wrap"></div>
<div id="error" class="load-error" style="display:none;"></div>
</div>
<div class="topWp">
<div class="LeftBtn">
<div class="authorPvBox">
<a class="Author" href="#" target="_blank" rel="noopener" id="authorName">创作者:本地</a>
</div>
<div class="view-stats">
<span id="watchingNow">0 人在看</span>
<span id="viewCount">共 0 次播放</span>
</div>
</div>
<div class="RightBtn">
<button type="button" class="btn-wrap" id="btnFullscreen" title="全屏模式">
<svg fill="none" viewBox="0 0 24 24"><path d="m20.04 3.753-4.435.55a.187.187 0 0 0-.11.317l1.282 1.282-3.598 3.597a.188.188 0 0 0 0 .265l1.057 1.057a.188.188 0 0 0 .265 0l3.6-3.6 1.282 1.282c.11.11.297.045.316-.11l.549-4.432a.185.185 0 0 0-.209-.208Z" fill="rgba(255,255,255)"/></svg>
<span>全屏</span>
</button>
</div>
</div>
<div class="bottom">
<div class="bottomWp">
<div class="bottom_right">
<div class="CustomButton" id="btnIntro" title="简介">简介</div>
<div class="CustomButton" id="btnShare" title="分享">分享 <span class="count" id="shareCount">0</span></div>
<div class="CustomButton" id="btnLike" title="赞"><span class="count" id="likeCount">0</span></div>
<div class="CustomButton" id="btnComment" title="留言">留言</div>
</div>
</div>
<div class="safeHeight"></div>
</div>
</div>
</div>
<div id="commentModal" class="modal-mask hide">
<div class="modal-box">
<h3>留言(弹幕)</h3>
<input type="text" id="commentNickname" placeholder="昵称(选填)" maxlength="32">
<textarea id="commentContent" placeholder="说点什么…" maxlength="200"></textarea>
<div class="btns">
<button type="button" class="btn-cancel" id="commentCancel">取消</button>
<button type="button" class="btn-ok" id="commentSubmit">发送</button>
</div>
</div>
</div>
<script src="lib/pannellum.js"></script>
<script>
(function() {
var API = '/api';
var container = document.getElementById('panorama');
var errorEl = document.getElementById('error');
var currentViewer = null;
var currentBlobUrls = [];
var viewerId = null;
var statsInterval = null;
var danmakuLastId = 0;
function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c === 'y' ? 0x8 : 0x3;
return (v | r).toString(16);
});
}
function revokeBlobUrls() {
currentBlobUrls.forEach(function(u) { try { URL.revokeObjectURL(u); } catch (e) {} });
currentBlobUrls = [];
}
function showError(msg) {
errorEl.style.display = 'flex';
errorEl.innerHTML = '<p>' + (msg || '加载失败') + '</p>';
}
function hideError() {
errorEl.style.display = 'none';
}
function destroyViewer() {
if (currentViewer) {
try { currentViewer.destroy(); } catch (e) {}
currentViewer = null;
}
revokeBlobUrls();
if (container) container.innerHTML = '';
}
function buildViewer(config) {
destroyViewer();
hideError();
if (container) container.style.display = 'block';
config.autoLoad = config.autoLoad !== false;
config.hfov = config.hfov || 100;
config.minHfov = config.minHfov != null ? config.minHfov : 50;
config.maxHfov = config.maxHfov != null ? config.maxHfov : 120;
config.showZoomCtrl = false;
config.compass = false;
config.showFullscreenCtrl = false;
try {
currentViewer = pannellum.viewer('panorama', config);
currentViewer.on('error', function(err) {
showError('全景图加载失败:' + (err && err.message ? err.message : '未知错误'));
});
} catch (e) {
showError('创建查看器失败:' + (e.message || e));
}
}
function updateStatsUI(stats) {
if (!stats) return;
var w = document.getElementById('watchingNow');
if (w) w.textContent = (stats.watchingNow || 0) + ' 人在看';
var v = document.getElementById('viewCount');
if (v) v.textContent = '共 ' + (stats.viewCount || 0) + ' 次播放';
var l = document.getElementById('likeCount');
if (l) l.textContent = stats.likeCount || 0;
var s = document.getElementById('shareCount');
if (s) s.textContent = stats.shareCount || 0;
}
function fetchStats(cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', API + '/stats', true);
xhr.onload = function() {
if (xhr.status === 200) {
try {
var s = JSON.parse(xhr.responseText);
updateStatsUI(s);
if (cb) cb(s);
} catch (e) {}
}
};
xhr.onerror = function() { if (cb) cb(null); };
xhr.send();
}
function sendView() {
var xhr = new XMLHttpRequest();
xhr.open('POST', API + '/view', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
if (xhr.status === 200) {
try { updateStatsUI(JSON.parse(xhr.responseText)); } catch (e) {}
}
};
xhr.send('{}');
}
function sendJoin() {
viewerId = sessionStorage.getItem('pano_viewer_id') || uuid();
sessionStorage.setItem('pano_viewer_id', viewerId);
var xhr = new XMLHttpRequest();
xhr.open('POST', API + '/join', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
if (xhr.status === 200) {
try {
var r = JSON.parse(xhr.responseText);
updateStatsUI({ watchingNow: r.watchingNow });
} catch (e) {}
}
};
xhr.send(JSON.stringify({ viewerId: viewerId }));
}
function sendLeave() {
if (!viewerId) return;
navigator.sendBeacon && navigator.sendBeacon(API + '/leave', JSON.stringify({ viewerId: viewerId }));
viewerId = null;
}
function addDanmakuLine(text, nickname) {
var wrap = document.getElementById('danmaku-wrap');
if (!wrap) return;
var line = document.createElement('div');
line.className = 'danmaku-line';
line.textContent = (nickname ? nickname + '' : '') + text;
line.style.top = (10 + Math.random() * 75) + '%';
line.style.animationDuration = (12 + Math.random() * 8) + 's';
wrap.appendChild(line);
setTimeout(function() {
if (line.parentNode) line.parentNode.removeChild(line);
}, 25000);
}
function loadDanmaku() {
var xhr = new XMLHttpRequest();
xhr.open('GET', API + '/comments?limit=50', true);
xhr.onload = function() {
if (xhr.status !== 200) return;
try {
var list = JSON.parse(xhr.responseText);
var wrap = document.getElementById('danmaku-wrap');
if (!wrap) return;
list.forEach(function(c, i) {
var line = document.createElement('div');
line.className = 'danmaku-line';
line.textContent = (c.nickname || '游客') + '' + (c.content || '');
line.style.top = (10 + (i % 8) * 10 + Math.random() * 5) + '%';
line.style.animationDuration = (14 + (i % 5)) + 's';
line.style.animationDelay = (i * 0.8) + 's';
wrap.appendChild(line);
});
danmakuLastId = list.length ? list[list.length - 1].id : 0;
} catch (e) {}
};
xhr.send();
}
function updateUIFromConfig(config) {
var authorEl = document.getElementById('authorName');
if (authorEl && config.authorName) {
authorEl.textContent = config.authorName;
if (config.authorUrl) authorEl.href = config.authorUrl;
}
}
function loadFromConfig(cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', 'config.json', true);
xhr.onload = function() {
if (xhr.status !== 200) { if (cb) cb(null); return; }
var config;
try { config = JSON.parse(xhr.responseText); } catch (e) { if (cb) cb(null); return; }
config.autoLoad = config.autoLoad !== false;
var toCheck = config.panorama || (config.cubeMap && config.cubeMap[0]) || 'panorama/panorama.jpg';
var check = new XMLHttpRequest();
check.open('HEAD', toCheck, true);
check.onload = function() {
if (check.status === 200) {
buildViewer(config);
updateUIFromConfig(config);
if (cb) cb(config);
} else if (cb) cb(null);
};
check.onerror = function() { if (cb) cb(null); };
check.send();
};
xhr.onerror = function() { if (cb) cb(null); };
xhr.send();
}
loadFromConfig(function(config) {
if (!config) {
showError('无法加载 config.json 或图片,请通过 http 访问(如 npm start。');
return;
}
fetchStats();
sendView();
sendJoin();
loadDanmaku();
statsInterval = setInterval(fetchStats, 8000);
});
window.addEventListener('beforeunload', sendLeave);
window.addEventListener('pagehide', sendLeave);
document.getElementById('btnFullscreen').addEventListener('click', function() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(function() {});
} else {
document.exitFullscreen();
}
});
document.getElementById('btnIntro').addEventListener('click', function() {
alert('简介:可在 config.json 中配置 intro 文案。');
});
document.getElementById('btnShare').addEventListener('click', function() {
var url = location.href;
if (navigator.share) {
navigator.share({ title: document.title, url: url }).catch(function() { copyUrl(url); });
} else {
copyUrl(url);
}
var xhr = new XMLHttpRequest();
xhr.open('POST', API + '/share', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
if (xhr.status === 200) {
try { updateStatsUI({ shareCount: JSON.parse(xhr.responseText).shareCount }); } catch (e) {}
}
};
xhr.send('{}');
});
function copyUrl(url) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function() { alert('链接已复制'); }).catch(function() { prompt('复制链接:', url); });
} else {
prompt('复制链接:', url);
}
}
document.getElementById('btnLike').addEventListener('click', function() {
var btn = this;
if (btn.classList.contains('selected')) return;
var xhr = new XMLHttpRequest();
xhr.open('POST', API + '/like', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
if (xhr.status === 200) {
try {
var r = JSON.parse(xhr.responseText);
btn.classList.add('selected');
updateStatsUI({ likeCount: r.likeCount });
} catch (e) {}
}
};
xhr.send('{}');
});
var modal = document.getElementById('commentModal');
var commentContent = document.getElementById('commentContent');
var commentNickname = document.getElementById('commentNickname');
document.getElementById('btnComment').addEventListener('click', function() {
commentContent.value = '';
commentNickname.value = localStorage.getItem('pano_nickname') || '';
modal.classList.remove('hide');
commentContent.focus();
});
document.getElementById('commentCancel').addEventListener('click', function() {
modal.classList.add('hide');
});
document.getElementById('commentSubmit').addEventListener('click', function() {
var content = (commentContent.value || '').trim();
var nickname = (commentNickname.value || '').trim();
if (!content) {
alert('请输入留言内容');
return;
}
localStorage.setItem('pano_nickname', nickname);
var xhr = new XMLHttpRequest();
xhr.open('POST', API + '/comments', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
modal.classList.add('hide');
if (xhr.status === 200) {
try {
var c = JSON.parse(xhr.responseText);
addDanmakuLine(c.content, c.nickname);
} catch (e) {}
} else {
alert('发送失败');
}
};
xhr.onerror = function() { alert('发送失败'); };
xhr.send(JSON.stringify({ content: content, nickname: nickname || undefined }));
});
})();
</script>
</body>
</html>