feat: new projiect
This commit is contained in:
456
index.html
Normal file
456
index.html
Normal file
@@ -0,0 +1,456 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user