Files
nanocode/album-viewer/frontend/index.html
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

798 lines
25 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, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>相册查看器</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
min-height: 100vh;
}
/* ========== 登录弹窗 ========== */
.login-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(5px);
}
.login-overlay.hidden {
display: none !important;
}
.login-box {
background: white;
padding: 30px;
border-radius: 16px;
width: 90%;
max-width: 360px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.login-title {
text-align: center;
font-size: 22px;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.login-subtitle {
text-align: center;
font-size: 14px;
color: #999;
margin-bottom: 24px;
}
.login-input {
width: 100%;
padding: 14px 16px;
margin-bottom: 12px;
border: 1px solid #e0e0e0;
border-radius: 10px;
font-size: 16px;
transition: border-color 0.2s;
-webkit-appearance: none;
}
.login-input:focus {
outline: none;
border-color: #007aff;
}
.login-btn {
width: 100%;
padding: 14px;
background: #007aff;
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
margin-top: 8px;
}
.login-btn:hover {
background: #0056cc;
}
.login-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.sso-login-btn {
width: 100%;
padding: 12px;
margin-top: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
cursor: pointer;
transition: opacity 0.2s;
}
.sso-login-btn:hover {
opacity: 0.9;
}
.login-divider {
display: flex;
align-items: center;
margin: 12px 0;
color: #aaa;
font-size: 13px;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
border-bottom: 1px solid #ddd;
}
.login-divider::before { margin-right: 8px; }
.login-divider::after { margin-left: 8px; }
.login-error {
color: #ff3b30;
font-size: 14px;
margin-top: 12px;
text-align: center;
display: none;
}
.login-error.show {
display: block;
}
/* ========== 顶部导航 ========== */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 50px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
padding: 0 16px;
z-index: 100;
}
.back-btn {
display: none;
padding: 8px 12px;
background: #007aff;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.back-btn.visible {
display: block;
}
.title {
flex: 1;
text-align: center;
font-size: 17px;
font-weight: 600;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 10px;
}
.user-info {
display: none;
align-items: center;
gap: 8px;
font-size: 13px;
color: #666;
}
.user-info.visible {
display: flex;
}
.logout-btn {
padding: 4px 10px;
background: #ff3b30;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
/* ========== 主内容区 ========== */
.content {
padding: 66px 12px 20px;
}
/* 网格布局:每行3个 */
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
/* 缩略图项 */
.item {
position: relative;
aspect-ratio: 2/3;
background: #e0e0e0;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.3s;
}
.item img.lazy {
opacity: 0;
}
.item img.loaded {
opacity: 1;
}
/* 文件名标签 */
.item-filename {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 6px 8px;
background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent);
color: white;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
z-index: 2;
pointer-events: none;
}
/* 相册图标 */
.album-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.album-icon svg {
width: 50%;
height: 50%;
opacity: 0.9;
}
/* 相册封面 */
.album-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 相册名称 */
.item-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20px 8px 8px;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
color: white;
font-size: 12px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ========== 图片查看器 ========== */
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.95);
z-index: 1000;
justify-content: center;
align-items: center;
}
.lightbox.active {
display: flex;
}
.lightbox img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: transform 0.2s ease, opacity 0.2s ease;
user-select: none;
-webkit-user-drag: none;
}
.lightbox-close {
position: absolute;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
background: rgba(255,255,255,0.2);
border: none;
border-radius: 50%;
color: white;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* ========== 加载/空状态 ========== */
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.empty {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
}
.login-required {
text-align: center;
padding: 60px 20px;
color: #999;
}
.login-required-icon {
font-size: 64px;
margin-bottom: 16px;
}
</style>
</head>
<body>
<!-- 登录弹窗 -->
<div class="login-overlay" id="loginOverlay">
<div class="login-box">
<div class="login-title">🔒 相册查看器</div>
<div class="login-subtitle">请先登录后继续</div>
<input type="text" class="login-input" id="username" placeholder="用户名" autocomplete="username">
<input type="password" class="login-input" id="password" placeholder="密码" autocomplete="current-password">
<button class="login-btn" id="loginBtn" onclick="doLogin()">登录</button>
<div class="login-divider">或</div>
<button class="sso-login-btn" onclick="doSSOLogin()">🔐 SSO 统一登录</button>
<div class="login-error" id="loginError"></div>
</div>
</div>
<!-- 顶部导航 -->
<div class="header">
<button class="back-btn" onclick="goBack()">← 返回</button>
<div class="title">相册首页</div>
<div class="user-info" id="userInfo">
<span id="usernameDisplay"></span>
<button class="logout-btn" onclick="doLogout()">退出</button>
</div>
</div>
<!-- 内容区 -->
<div class="content">
<div class="grid" id="grid">
<div class="loading">加载中...</div>
</div>
</div>
<!-- 图片查看器 -->
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<button class="lightbox-close" onclick="event.stopPropagation(); closeLightbox()">×</button>
<img id="lightbox-img" src="" alt="" onclick="event.stopPropagation()">
</div>
<script>
// ========== 状态 ==========
let isAuthenticated = false;
let pathStack = [''];
let currentImages = [];
let currentImageIndex = -1;
let touchStartX = 0;
let touchStartY = 0;
let touchEndX = 0;
let isSwiping = false;
// ========== 初始化 ==========
document.addEventListener('DOMContentLoaded', async () => {
// 检查登录状态
await checkAuth();
if (isAuthenticated) {
loadAlbum('');
setupSwipeNavigation();
}
// 回车键登录
document.getElementById('password').addEventListener('keypress', (e) => {
if (e.key === 'Enter') doLogin();
});
});
// ========== 认证相关 ==========
async function checkAuth() {
try {
const response = await fetch('/api/auth/status', {
credentials: 'include', // 携带 cookie
cache: 'no-cache' // 禁用缓存
});
const data = await response.json();
isAuthenticated = data.authenticated;
if (isAuthenticated) {
showMainUI(data.user);
} else {
showLoginUI();
}
} catch (error) {
console.error('检查登录状态失败:', error);
showLoginUI();
}
}
async function doLogin() {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
const btn = document.getElementById('loginBtn');
const errorDiv = document.getElementById('loginError');
if (!username || !password) {
errorDiv.textContent = '请输入用户名和密码';
errorDiv.classList.add('show');
return;
}
btn.disabled = true;
btn.textContent = '登录中...';
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (data.success) {
isAuthenticated = true;
showMainUI(data.user);
loadAlbum('');
setupSwipeNavigation();
} else {
errorDiv.textContent = data.error || '登录失败';
errorDiv.classList.add('show');
}
} catch (error) {
errorDiv.textContent = '网络错误: ' + error.message;
errorDiv.classList.add('show');
console.error('登录错误:', error);
} finally {
btn.disabled = false;
btn.textContent = '登录';
}
}
async function doLogout() {
try {
// 调后端同时清除本地 Session 和 SSO Cookie
await fetch('/api/auth/sso-logout', {
method: 'POST',
credentials: 'include'
});
} catch (e) {}
isAuthenticated = false;
location.reload();
}
function doSSOLogin() {
// 跳转到 SSO 中心,携带 redirect 参数
const redirect = window.location.origin;
window.location.href = 'http://129.211.173.87:9000/?redirect=' + encodeURIComponent(redirect + '/');
}
function showLoginUI() {
document.getElementById('loginOverlay').classList.remove('hidden');
document.getElementById('userInfo').classList.remove('visible');
document.getElementById('grid').innerHTML = `
<div class="login-required" style="grid-column: span 3;">
<div class="login-required-icon">🔒</div>
<div>请先登录查看相册</div>
</div>
`;
}
function showMainUI(user) {
document.getElementById('loginOverlay').classList.add('hidden');
document.getElementById('userInfo').classList.add('visible');
document.getElementById('usernameDisplay').textContent = user.username;
// 清空输入
document.getElementById('username').value = '';
document.getElementById('password').value = '';
document.getElementById('loginError').classList.remove('show');
}
// ========== API 请求封装(自动处理认证)==========
async function authFetch(url, options = {}) {
options.credentials = 'include'; // 携带 cookie
const response = await fetch(url, options);
// 401 未授权,显示登录
if (response.status === 401) {
isAuthenticated = false;
showLoginUI();
throw new Error('未登录');
}
return response;
}
// ========== 相册功能(保持原有逻辑)==========
async function loadAlbum(path) {
if (!isAuthenticated) return;
const grid = document.getElementById('grid');
grid.innerHTML = '<div class="loading">加载中...</div>';
try {
const response = await authFetch(`/api/albums/${path}`);
const data = await response.json();
if (data.error) {
grid.innerHTML = `<div class="empty">加载失败: ${data.error}</div>`;
return;
}
document.querySelector('.title').textContent = data.name || '相册';
const backBtn = document.querySelector('.back-btn');
if (pathStack.length > 1) {
backBtn.classList.add('visible');
} else {
backBtn.classList.remove('visible');
}
renderGrid(data);
} catch (error) {
if (error.message !== '未登录') {
grid.innerHTML = `<div class="empty">加载失败,请刷新重试</div>`;
}
}
}
function renderGrid(data) {
const grid = document.getElementById('grid');
grid.innerHTML = '';
const items = [...data.albums, ...data.images];
currentImages = data.images || [];
if (items.length === 0) {
grid.innerHTML = `
<div class="empty" style="grid-column: span 3;">
<div class="empty-icon">🖼️</div>
<div>暂无内容</div>
</div>
`;
return;
}
items.forEach(item => {
const div = document.createElement('div');
div.className = 'item';
if (item.type === 'album') {
const coverHtml = item.cover
? `<img class="album-cover lazy" data-src="${item.cover}" alt="${item.name}">`
: `<div class="album-icon">
<svg viewBox="0 0 24 24" fill="white">
<path d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z"/>
<circle cx="12" cy="13" r="3"/>
</svg>
</div>`;
div.innerHTML = `
${coverHtml}
<div class="item-name">${item.name}</div>
`;
div.onclick = () => openAlbum(item.path);
} else {
const thumbUrl = item.thumbnail || `/api/image/${item.path}`;
const imageIndex = data.images.indexOf(item);
div.innerHTML = `
<div class="item-filename">${item.name}</div>
<img class="lazy" data-src="${thumbUrl}" alt="${item.name}" onclick="showImage(${imageIndex})">
`;
}
grid.appendChild(div);
});
setupLazyLoad();
}
function openAlbum(path) {
pathStack.push(path);
loadAlbum(path);
}
function goBack() {
if (pathStack.length > 1) {
pathStack.pop();
loadAlbum(pathStack[pathStack.length - 1]);
}
}
function showImage(index) {
event.stopPropagation();
if (index < 0 || index >= currentImages.length) return;
currentImageIndex = index;
const image = currentImages[index];
const lightbox = document.getElementById('lightbox');
const img = document.getElementById('lightbox-img');
img.src = `/api/image/${image.path}`;
lightbox.classList.add('active');
document.body.style.overflow = 'hidden';
}
function navigateImage(direction) {
const newIndex = currentImageIndex + direction;
if (newIndex < 0 || newIndex >= currentImages.length) {
const img = document.getElementById('lightbox-img');
img.style.transform = direction > 0 ? 'translateX(-20px)' : 'translateX(20px)';
setTimeout(() => {
img.style.transform = 'translateX(0)';
}, 150);
return;
}
currentImageIndex = newIndex;
const image = currentImages[currentImageIndex];
const img = document.getElementById('lightbox-img');
img.style.opacity = '0.5';
img.style.transform = direction > 0 ? 'translateX(-50px)' : 'translateX(50px)';
setTimeout(() => {
img.src = `/api/image/${image.path}`;
img.style.transition = 'none';
img.style.transform = direction > 0 ? 'translateX(50px)' : 'translateX(-50px)';
setTimeout(() => {
img.style.transition = 'all 0.2s ease';
img.style.opacity = '1';
img.style.transform = 'translateX(0)';
}, 50);
}, 150);
}
function closeLightbox() {
const lightbox = document.getElementById('lightbox');
lightbox.classList.remove('active');
document.body.style.overflow = '';
}
function setupSwipeNavigation() {
const lightbox = document.getElementById('lightbox');
lightbox.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
isSwiping = false;
}, { passive: true });
lightbox.addEventListener('touchmove', (e) => {
if (!lightbox.classList.contains('active')) return;
touchEndX = e.touches[0].clientX;
const diffX = touchStartX - touchEndX;
const diffY = Math.abs(e.touches[0].clientY - touchStartY);
if (Math.abs(diffX) > diffY && Math.abs(diffX) > 10) {
isSwiping = true;
}
}, { passive: true });
lightbox.addEventListener('touchend', (e) => {
if (!lightbox.classList.contains('active') || !isSwiping) return;
const diffX = touchStartX - touchEndX;
const threshold = 50;
if (Math.abs(diffX) > threshold) {
if (diffX > 0) {
navigateImage(1);
} else {
navigateImage(-1);
}
}
isSwiping = false;
}, { passive: true });
document.addEventListener('keydown', (e) => {
if (!lightbox.classList.contains('active')) return;
if (e.key === 'ArrowLeft') navigateImage(-1);
else if (e.key === 'ArrowRight') navigateImage(1);
else if (e.key === 'Escape') closeLightbox();
});
}
function setupLazyLoad() {
const images = document.querySelectorAll('img.lazy');
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.onload = () => img.classList.add('loaded');
img.classList.remove('lazy');
observer.unobserve(img);
}
});
}, {
rootMargin: '50px 0px',
threshold: 0.01
});
images.forEach(img => imageObserver.observe(img));
}
window.onpopstate = function(event) {
if (pathStack.length > 1) {
pathStack.pop();
loadAlbum(pathStack[pathStack.length - 1]);
}
};
</script>
</body>
</html>