mirror of
https://github.com/katelya77/K-Vault.git
synced 2026-05-06 22:10:57 +08:00
1380 lines
39 KiB
HTML
1380 lines
39 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>图片浏览器 | K-Vault</title>
|
||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||
<link rel="apple-touch-icon" href="/logo.png">
|
||
<link rel="shortcut icon" href="/favicon.ico">
|
||
<link rel="stylesheet" href="/theme.css">
|
||
<script src="/theme.js?v=20260305"></script>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-ui@2.15.3/lib/theme-chalk/index.css">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free/css/all.min.css">
|
||
<style>
|
||
:root {
|
||
--primary: #8a4bff;
|
||
--primary-light: #b39ddb;
|
||
--primary-dark: #6c3ce0;
|
||
--accent: #ffd7e4;
|
||
--bg: linear-gradient(135deg, #ffd7e4 0%, #c8f1ff 100%);
|
||
--card-bg: rgba(255, 255, 255, 0.92);
|
||
--shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||
--shadow-hover: 0 8px 24px rgba(138, 75, 255, 0.12);
|
||
--transition: all 0.2s ease;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
background: var(--bg);
|
||
background-attachment: fixed;
|
||
min-height: 100vh;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif;
|
||
}
|
||
|
||
#app {
|
||
max-width: 1600px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
animation: fadeIn 0.5s ease-out;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
/* 头部 */
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 18px 28px;
|
||
background: var(--card-bg);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
border-radius: 18px;
|
||
margin-bottom: 24px;
|
||
box-shadow: var(--shadow);
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
position: sticky;
|
||
top: 20px;
|
||
z-index: 100;
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 1.5em;
|
||
font-weight: 700;
|
||
color: #333;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.header-title i { color: var(--primary); font-size: 1.1em; }
|
||
|
||
.header-search {
|
||
flex: 1;
|
||
max-width: 400px;
|
||
min-width: 200px;
|
||
}
|
||
|
||
.header-search input {
|
||
width: 100%;
|
||
padding: 10px 16px;
|
||
border: 2px solid #eee;
|
||
border-radius: 24px;
|
||
font-size: 0.95em;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.header-search input:focus {
|
||
border-color: var(--primary-light);
|
||
outline: none;
|
||
box-shadow: 0 0 0 3px rgba(138, 75, 255, 0.1);
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.header-actions .gallery-theme-toggle {
|
||
margin-left: 2px;
|
||
border: 1.5px solid rgba(138, 75, 255, 0.25);
|
||
background: linear-gradient(
|
||
135deg,
|
||
rgba(138, 75, 255, 0.16),
|
||
rgba(200, 241, 255, 0.42)
|
||
);
|
||
color: #2f3243;
|
||
font-weight: 600;
|
||
box-shadow: 0 6px 16px rgba(138, 75, 255, 0.14);
|
||
}
|
||
|
||
.header-actions .gallery-theme-toggle i {
|
||
color: var(--primary);
|
||
width: 14px;
|
||
text-align: center;
|
||
}
|
||
|
||
.header-actions .gallery-theme-toggle:hover {
|
||
border-color: var(--primary-light);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
html[data-theme="dark"] .header-actions .gallery-theme-toggle {
|
||
border-color: rgba(154, 169, 255, 0.45);
|
||
background: linear-gradient(
|
||
135deg,
|
||
rgba(154, 169, 255, 0.2),
|
||
rgba(84, 106, 156, 0.35)
|
||
);
|
||
color: #e8eeff;
|
||
}
|
||
|
||
html[data-theme="dark"] .header-actions .gallery-theme-toggle i {
|
||
color: #c4d4ff;
|
||
}
|
||
|
||
.nav-btn {
|
||
padding: 8px 16px;
|
||
border-radius: 8px;
|
||
text-decoration: none;
|
||
color: #666;
|
||
font-size: 0.9em;
|
||
transition: all 0.3s;
|
||
background: rgba(255, 255, 255, 0.8);
|
||
border: 1px solid #eee;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.nav-btn:hover {
|
||
background: var(--accent);
|
||
border-color: var(--primary-light);
|
||
color: #333;
|
||
}
|
||
|
||
.nav-btn.active {
|
||
background: var(--primary);
|
||
color: white;
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
/* 工具栏 */
|
||
.toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 14px 24px;
|
||
background: var(--card-bg);
|
||
backdrop-filter: blur(20px);
|
||
border-radius: 14px;
|
||
margin-bottom: 20px;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.toolbar-left, .toolbar-right { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||
|
||
.stat-badge {
|
||
padding: 8px 16px;
|
||
background: linear-gradient(135deg, rgba(138, 75, 255, 0.08), rgba(255, 215, 228, 0.15));
|
||
border-radius: 24px;
|
||
font-size: 0.88em;
|
||
color: #555;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.stat-badge strong { color: var(--primary); margin-left: 4px; }
|
||
|
||
/* 链接格式选择器 */
|
||
.format-selector {
|
||
display: flex;
|
||
gap: 4px;
|
||
background: #f5f5f5;
|
||
padding: 4px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.format-btn {
|
||
padding: 6px 12px;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 0.8em;
|
||
transition: all 0.2s;
|
||
color: #666;
|
||
}
|
||
|
||
.format-btn:hover { background: rgba(138, 75, 255, 0.1); }
|
||
.format-btn.active { background: var(--primary); color: white; }
|
||
|
||
/* 图片网格 */
|
||
.image-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
|
||
.image-card {
|
||
position: relative;
|
||
aspect-ratio: 1;
|
||
border-radius: 16px;
|
||
overflow: hidden;
|
||
background: var(--card-bg);
|
||
box-shadow: var(--shadow);
|
||
cursor: pointer;
|
||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
will-change: transform;
|
||
contain: layout style paint;
|
||
}
|
||
|
||
.image-card:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: var(--shadow-hover);
|
||
}
|
||
|
||
.image-card.selected {
|
||
box-shadow: 0 0 0 3px var(--primary), var(--shadow-hover);
|
||
}
|
||
|
||
.image-card img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.image-card:hover img { transform: scale(1.05); }
|
||
|
||
.image-card-overlay {
|
||
position: absolute;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: linear-gradient(to bottom, rgba(0,0,0,0) 50%, rgba(0,0,0,0.7) 100%);
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: flex-end;
|
||
padding: 12px;
|
||
}
|
||
|
||
.image-card:hover .image-card-overlay { opacity: 1; }
|
||
.image-card.selected .image-card-overlay { opacity: 1; }
|
||
|
||
.image-card-name {
|
||
color: white;
|
||
font-size: 0.8em;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.image-card-actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.card-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
color: #333;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.card-btn:hover {
|
||
background: var(--primary);
|
||
color: white;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.card-btn.delete:hover { background: #f56c6c; }
|
||
|
||
.image-card-checkbox {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 10px;
|
||
width: 24px;
|
||
height: 24px;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.image-card:hover .image-card-checkbox,
|
||
.image-card.selected .image-card-checkbox { opacity: 1; }
|
||
|
||
.image-card-checkbox.checked {
|
||
background: var(--primary);
|
||
color: white;
|
||
}
|
||
|
||
/* 批量操作栏 */
|
||
.batch-bar {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: rgba(51, 51, 51, 0.95);
|
||
backdrop-filter: blur(10px);
|
||
padding: 12px 24px;
|
||
border-radius: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
z-index: 100;
|
||
}
|
||
|
||
.batch-bar-count {
|
||
color: white;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.batch-bar-count strong { color: var(--accent); }
|
||
|
||
.batch-btn {
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 0.85em;
|
||
transition: all 0.2s;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.batch-btn.primary { background: var(--primary); color: white; }
|
||
.batch-btn.success { background: #67c23a; color: white; }
|
||
.batch-btn.danger { background: #f56c6c; color: white; }
|
||
.batch-btn.default { background: rgba(255,255,255,0.2); color: white; }
|
||
|
||
.batch-btn:hover { transform: scale(1.05); filter: brightness(1.1); }
|
||
|
||
/* 预览弹窗 - 原生图片 / iframe */
|
||
.preview-modal {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0, 0, 0, 0.9);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
padding: 20px;
|
||
}
|
||
|
||
.preview-modal img {
|
||
max-width: 90%;
|
||
max-height: 90%;
|
||
border-radius: 8px;
|
||
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
/* iframe 容器 - 90% 宽度,80% 高度 */
|
||
.preview-modal .iframe-container {
|
||
width: 90vw;
|
||
height: 80vh;
|
||
position: relative;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
background: #000;
|
||
}
|
||
|
||
.preview-modal .iframe-container iframe {
|
||
width: 100%;
|
||
height: 100%;
|
||
border: none;
|
||
}
|
||
|
||
.preview-close {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
width: 50px;
|
||
height: 50px;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
color: white;
|
||
font-size: 1.5em;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.preview-close:hover {
|
||
background: rgba(255, 255, 255, 0.4);
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.preview-info {
|
||
position: absolute;
|
||
bottom: 20px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: rgba(0, 0, 0, 0.7);
|
||
padding: 12px 24px;
|
||
border-radius: 12px;
|
||
color: white;
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: center;
|
||
}
|
||
|
||
.preview-link {
|
||
max-width: 400px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
font-family: monospace;
|
||
font-size: 0.85em;
|
||
}
|
||
|
||
/* 分页 */
|
||
.pagination {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-top: 24px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.page-btn {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.page-btn:hover { background: var(--accent); }
|
||
.page-btn.active { background: var(--primary); color: white; }
|
||
.page-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
/* 空状态 */
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 80px 20px;
|
||
color: #999;
|
||
}
|
||
|
||
.empty-state i { font-size: 4em; margin-bottom: 20px; color: #ddd; }
|
||
|
||
/* 加载状态 */
|
||
.loading-state {
|
||
text-align: center;
|
||
padding: 60px;
|
||
color: #666;
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 3px solid #eee;
|
||
border-top-color: var(--primary);
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 16px;
|
||
}
|
||
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* Toast */
|
||
.toast-container {
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
z-index: 9999;
|
||
}
|
||
|
||
.toast {
|
||
padding: 12px 20px;
|
||
background: white;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
margin-bottom: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
animation: slideIn 0.3s ease;
|
||
}
|
||
|
||
.toast.success { border-left: 4px solid #67c23a; }
|
||
.toast.error { border-left: 4px solid #f56c6c; }
|
||
.toast.info { border-left: 4px solid var(--primary); }
|
||
|
||
@keyframes slideIn {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
|
||
/* 响应式 */
|
||
@media (max-width: 768px) {
|
||
body { padding: 12px; }
|
||
|
||
.header {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 12px;
|
||
padding: 14px 18px;
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 1.2em;
|
||
justify-content: center;
|
||
}
|
||
|
||
.header-search {
|
||
max-width: none;
|
||
order: 3;
|
||
}
|
||
|
||
.header-actions {
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.header-actions .gallery-theme-toggle {
|
||
order: 4;
|
||
}
|
||
|
||
.nav-btn {
|
||
padding: 8px 14px;
|
||
font-size: 0.85em;
|
||
}
|
||
|
||
.toolbar {
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
align-items: stretch;
|
||
padding: 14px;
|
||
}
|
||
|
||
.toolbar-left {
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toolbar-right {
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.stat-badge {
|
||
padding: 6px 12px;
|
||
font-size: 0.82em;
|
||
}
|
||
|
||
.format-selector {
|
||
justify-content: center;
|
||
}
|
||
|
||
.format-btn {
|
||
padding: 6px 12px;
|
||
font-size: 0.82em;
|
||
}
|
||
|
||
.image-grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.image-card-overlay {
|
||
opacity: 1;
|
||
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, transparent 100%);
|
||
}
|
||
|
||
.image-card-name {
|
||
font-size: 0.75em;
|
||
}
|
||
|
||
.card-btn {
|
||
width: 30px;
|
||
height: 30px;
|
||
font-size: 0.8em;
|
||
}
|
||
|
||
.batch-bar {
|
||
flex-wrap: wrap;
|
||
width: calc(100% - 24px);
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
left: 12px;
|
||
bottom: 12px;
|
||
}
|
||
|
||
.batch-btn {
|
||
padding: 8px 14px;
|
||
font-size: 0.85em;
|
||
}
|
||
|
||
/* 预览弹窗优化 */
|
||
.preview-overlay .preview-content {
|
||
max-width: 95%;
|
||
max-height: 85%;
|
||
}
|
||
|
||
.preview-actions {
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
body { padding: 8px; }
|
||
|
||
.header {
|
||
padding: 12px 14px;
|
||
border-radius: 12px;
|
||
gap: 10px;
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 1.05em;
|
||
gap: 8px;
|
||
}
|
||
|
||
.header-search input {
|
||
height: 36px;
|
||
font-size: 0.9em;
|
||
padding: 0 12px;
|
||
}
|
||
|
||
.header-actions {
|
||
gap: 6px;
|
||
}
|
||
|
||
.header-actions .gallery-theme-toggle {
|
||
padding: 6px 9px;
|
||
min-width: 38px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.header-actions .gallery-theme-toggle [data-theme-label] {
|
||
display: none;
|
||
}
|
||
|
||
.nav-btn {
|
||
padding: 6px 10px;
|
||
font-size: 0.78em;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.nav-btn i {
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.toolbar {
|
||
padding: 12px;
|
||
border-radius: 12px;
|
||
gap: 10px;
|
||
}
|
||
|
||
.toolbar-left {
|
||
gap: 6px;
|
||
}
|
||
|
||
.stat-badge {
|
||
padding: 5px 10px;
|
||
font-size: 0.75em;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.format-btn {
|
||
padding: 5px 10px;
|
||
font-size: 0.75em;
|
||
min-width: 36px;
|
||
}
|
||
|
||
.image-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 8px;
|
||
}
|
||
|
||
.image-card {
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.image-card-checkbox {
|
||
width: 24px;
|
||
height: 24px;
|
||
font-size: 0.75em;
|
||
}
|
||
|
||
.card-btn {
|
||
width: 26px;
|
||
height: 26px;
|
||
font-size: 0.72em;
|
||
}
|
||
|
||
.image-card-name {
|
||
font-size: 0.68em;
|
||
padding: 6px 8px;
|
||
}
|
||
|
||
.batch-bar {
|
||
flex-direction: column;
|
||
width: calc(100% - 16px);
|
||
left: 8px;
|
||
bottom: 8px;
|
||
padding: 10px;
|
||
gap: 8px;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.batch-btn {
|
||
width: 100%;
|
||
justify-content: center;
|
||
padding: 10px 16px;
|
||
}
|
||
|
||
.toast {
|
||
max-width: 90%;
|
||
right: 5%;
|
||
padding: 12px 16px;
|
||
font-size: 0.85em;
|
||
}
|
||
|
||
/* 加载状态 */
|
||
.loading-indicator {
|
||
padding: 30px;
|
||
}
|
||
|
||
.loading-indicator i {
|
||
font-size: 2em;
|
||
}
|
||
|
||
/* 移动端禁用高消耗效果 */
|
||
.header {
|
||
backdrop-filter: none;
|
||
-webkit-backdrop-filter: none;
|
||
background: rgba(255, 255, 255, 0.98);
|
||
}
|
||
|
||
.toolbar {
|
||
backdrop-filter: none;
|
||
-webkit-backdrop-filter: none;
|
||
background: rgba(255, 255, 255, 0.98);
|
||
}
|
||
|
||
.image-card:hover {
|
||
transform: none;
|
||
}
|
||
|
||
.image-card:hover img {
|
||
transform: none;
|
||
}
|
||
|
||
.image-card-overlay {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* 用户偏好减少动画 */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*, *::before, *::after {
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.01ms !important;
|
||
}
|
||
}
|
||
</style>
|
||
<link rel="stylesheet" href="/mobile-refactor.css">
|
||
</head>
|
||
<body>
|
||
<div id="app" v-cloak>
|
||
<!-- 头部 -->
|
||
<div class="header">
|
||
<div class="header-title">
|
||
<i class="fas fa-images"></i>
|
||
<span>图片浏览器</span>
|
||
</div>
|
||
<div class="header-search">
|
||
<input type="text" v-model="searchQuery" placeholder="搜索文件名..." @input="handleSearch">
|
||
</div>
|
||
<div class="header-actions">
|
||
<a href="./" class="nav-btn"><i class="fas fa-cloud-upload-alt"></i> 上传</a>
|
||
<a href="./admin.html" class="nav-btn"><i class="fas fa-th-large"></i> 管理</a>
|
||
<a href="./" class="nav-btn"><i class="fas fa-home"></i> 首页</a>
|
||
<button type="button" class="nav-btn gallery-theme-toggle" data-theme-toggle aria-label="切换主题">
|
||
<i class="fas fa-moon" data-theme-icon></i>
|
||
<span data-theme-label>夜间</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 工具栏 -->
|
||
<div class="toolbar">
|
||
<div class="toolbar-left">
|
||
<span class="stat-badge"><i class="fas fa-images"></i> 总数<strong>{{ totalCount }}</strong></span>
|
||
<span class="stat-badge"><i class="fas fa-check-square"></i> 已选<strong>{{ selectedCount }}</strong></span>
|
||
<button class="nav-btn" @click="selectAll">
|
||
<i :class="isAllSelected ? 'fas fa-square' : 'fas fa-check-square'"></i>
|
||
{{ isAllSelected ? '取消全选' : '全选' }}
|
||
</button>
|
||
<button class="nav-btn" @click="loadMore" v-if="hasMore" :disabled="loading">
|
||
<i class="fas fa-sync" :class="{ 'fa-spin': loading }"></i>
|
||
加载更多
|
||
</button>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<span style="font-size: 0.85em; color: #666;">链接格式:</span>
|
||
<div class="format-selector">
|
||
<button class="format-btn" :class="{ active: linkFormat === 'url' }" @click="linkFormat = 'url'">URL</button>
|
||
<button class="format-btn" :class="{ active: linkFormat === 'markdown' }" @click="linkFormat = 'markdown'">MD</button>
|
||
<button class="format-btn" :class="{ active: linkFormat === 'html' }" @click="linkFormat = 'html'">HTML</button>
|
||
<button class="format-btn" :class="{ active: linkFormat === 'bbcode' }" @click="linkFormat = 'bbcode'">BB</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图片网格 -->
|
||
<div class="image-grid" v-if="!loading || images.length > 0">
|
||
<div class="image-card"
|
||
v-for="(img, index) in filteredImages"
|
||
:key="img.name"
|
||
:class="{ selected: img.selected }"
|
||
@click="toggleSelect(img)">
|
||
<img :src="'/file/' + img.name"
|
||
:alt="img.metadata?.fileName || img.name"
|
||
loading="lazy"
|
||
@error="handleImageError($event, img)">
|
||
<div class="image-card-checkbox"
|
||
:class="{ checked: img.selected }"
|
||
@click.stop="toggleSelect(img)">
|
||
<i :class="img.selected ? 'fas fa-check' : 'far fa-square'"></i>
|
||
</div>
|
||
<div class="image-card-overlay">
|
||
<div class="image-card-name">{{ img.metadata?.fileName || img.name }}</div>
|
||
<div class="image-card-actions">
|
||
<button class="card-btn" @click.stop="copyLink(img)" title="复制链接">
|
||
<i class="fas fa-copy"></i>
|
||
</button>
|
||
<button class="card-btn" @click.stop="previewImage(img)" title="预览">
|
||
<i class="fas fa-expand"></i>
|
||
</button>
|
||
<button class="card-btn" @click.stop="downloadImage(img)" title="下载">
|
||
<i class="fas fa-download"></i>
|
||
</button>
|
||
<button class="card-btn delete" @click.stop="deleteImage(img, index)" title="删除">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 加载状态 -->
|
||
<div class="loading-state" v-if="loading && images.length === 0">
|
||
<div class="loading-spinner"></div>
|
||
<div>加载中...</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div class="empty-state" v-if="!loading && filteredImages.length === 0">
|
||
<i class="fas fa-inbox"></i>
|
||
<div style="font-size: 1.2em; margin-bottom: 8px;">没有找到图片</div>
|
||
<div>{{ searchQuery ? '尝试其他搜索词' : '上传一些图片开始使用' }}</div>
|
||
<a href="./" class="nav-btn" style="margin-top: 20px; display: inline-flex;">
|
||
<i class="fas fa-cloud-upload-alt"></i> 去上传
|
||
</a>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<div class="pagination" v-if="totalPages > 1">
|
||
<button class="page-btn" @click="currentPage = 1" :disabled="currentPage === 1">
|
||
<i class="fas fa-angle-double-left"></i>
|
||
</button>
|
||
<button class="page-btn" @click="currentPage--" :disabled="currentPage === 1">
|
||
<i class="fas fa-angle-left"></i>
|
||
</button>
|
||
<template v-for="page in displayPages">
|
||
<button class="page-btn" :class="{ active: page === currentPage }" @click="currentPage = page" :key="page">
|
||
{{ page }}
|
||
</button>
|
||
</template>
|
||
<button class="page-btn" @click="currentPage++" :disabled="currentPage === totalPages">
|
||
<i class="fas fa-angle-right"></i>
|
||
</button>
|
||
<button class="page-btn" @click="currentPage = totalPages" :disabled="currentPage === totalPages">
|
||
<i class="fas fa-angle-double-right"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 批量操作栏 -->
|
||
<div class="batch-bar" v-if="selectedCount > 0">
|
||
<div class="batch-bar-count">已选择 <strong>{{ selectedCount }}</strong> 个文件</div>
|
||
<button class="batch-btn primary" @click="batchCopy">
|
||
<i class="fas fa-copy"></i> 复制链接
|
||
</button>
|
||
<button class="batch-btn success" @click="batchDownload">
|
||
<i class="fas fa-download"></i> 下载
|
||
</button>
|
||
<button class="batch-btn danger" @click="batchDelete">
|
||
<i class="fas fa-trash"></i> 删除
|
||
</button>
|
||
<button class="batch-btn default" @click="clearSelection">
|
||
<i class="fas fa-times"></i> 取消
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 预览弹窗 - 原生图片 / iframe 预览 -->
|
||
<div class="preview-modal" v-if="previewData" @click="closePreview">
|
||
<button class="preview-close" @click="closePreview">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
<!-- 原生图片预览 -->
|
||
<img v-if="previewData.type === 'native-image'" :src="previewData.url" @click.stop>
|
||
<!-- iframe 预览(视频/音频/文档) -->
|
||
<div v-else-if="previewData.type === 'iframe'" class="iframe-container" @click.stop>
|
||
<iframe
|
||
ref="previewIframe"
|
||
:src="previewData.iframeUrl"
|
||
allow="autoplay; fullscreen; encrypted-media"
|
||
allowfullscreen>
|
||
</iframe>
|
||
</div>
|
||
<div class="preview-info" @click.stop>
|
||
<span class="preview-link">{{ previewData.url }}</span>
|
||
<button class="batch-btn primary" @click="copyPreviewLink">
|
||
<i class="fas fa-copy"></i> 复制
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast 容器 -->
|
||
<div class="toast-container">
|
||
<div class="toast" :class="toast.type" v-for="(toast, index) in toasts" :key="index">
|
||
<i :class="getToastIcon(toast.type)"></i>
|
||
<span>{{ toast.message }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/element-ui@2.15.3/lib/index.js"></script>
|
||
<script>
|
||
// 统一媒体预览服务基础 URL
|
||
const PREVIEW_SERVICE_BASE = 'https://www.katelya.eu.org';
|
||
|
||
new Vue({
|
||
el: '#app',
|
||
data: {
|
||
baseURL: document.location.origin,
|
||
images: [],
|
||
loading: true,
|
||
searchQuery: '',
|
||
linkFormat: 'url',
|
||
currentPage: 1,
|
||
pageSize: 30,
|
||
nextCursor: null,
|
||
hasMore: true,
|
||
previewData: null, // 新的预览数据对象
|
||
toasts: [],
|
||
searchTimer: null
|
||
},
|
||
computed: {
|
||
filteredImages() {
|
||
let result = this.images;
|
||
|
||
// 只显示图片类型
|
||
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'];
|
||
result = result.filter(img => {
|
||
const ext = img.name.split('.').pop().toLowerCase();
|
||
return imageExts.includes(ext);
|
||
});
|
||
|
||
// 搜索过滤
|
||
if (this.searchQuery.trim()) {
|
||
const query = this.searchQuery.toLowerCase();
|
||
result = result.filter(img => {
|
||
const name = (img.metadata?.fileName || img.name).toLowerCase();
|
||
return name.includes(query);
|
||
});
|
||
}
|
||
|
||
// 分页
|
||
const start = (this.currentPage - 1) * this.pageSize;
|
||
return result.slice(start, start + this.pageSize);
|
||
},
|
||
totalCount() {
|
||
return this.images.length;
|
||
},
|
||
selectedCount() {
|
||
return this.images.filter(img => img.selected).length;
|
||
},
|
||
isAllSelected() {
|
||
return this.filteredImages.length > 0 && this.filteredImages.every(img => img.selected);
|
||
},
|
||
totalPages() {
|
||
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'];
|
||
let filtered = this.images.filter(img => {
|
||
const ext = img.name.split('.').pop().toLowerCase();
|
||
return imageExts.includes(ext);
|
||
});
|
||
if (this.searchQuery.trim()) {
|
||
const query = this.searchQuery.toLowerCase();
|
||
filtered = filtered.filter(img => {
|
||
const name = (img.metadata?.fileName || img.name).toLowerCase();
|
||
return name.includes(query);
|
||
});
|
||
}
|
||
return Math.ceil(filtered.length / this.pageSize);
|
||
},
|
||
displayPages() {
|
||
const pages = [];
|
||
const total = this.totalPages;
|
||
const current = this.currentPage;
|
||
|
||
let start = Math.max(1, current - 2);
|
||
let end = Math.min(total, current + 2);
|
||
|
||
if (end - start < 4) {
|
||
if (start === 1) end = Math.min(5, total);
|
||
else if (end === total) start = Math.max(1, total - 4);
|
||
}
|
||
|
||
for (let i = start; i <= end; i++) {
|
||
pages.push(i);
|
||
}
|
||
return pages;
|
||
}
|
||
},
|
||
methods: {
|
||
async loadImages() {
|
||
this.loading = true;
|
||
try {
|
||
const response = await fetch('./api/manage/list?limit=100', {
|
||
method: 'GET',
|
||
credentials: 'include'
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
window.location.href = './api/manage/login';
|
||
return;
|
||
}
|
||
|
||
const result = await response.json();
|
||
const invalidPrefixes = ['session:', 'chunk:', 'upload:', 'temp:'];
|
||
const filtered = (result.keys || []).filter(img => {
|
||
if (!img.name) return false;
|
||
if (invalidPrefixes.some(prefix => img.name.startsWith(prefix))) return false;
|
||
const metadata = img.metadata || {};
|
||
return metadata.fileName && metadata.TimeStamp !== undefined && metadata.TimeStamp !== null;
|
||
});
|
||
this.images = filtered.map(img => ({
|
||
...img,
|
||
selected: false,
|
||
metadata: {
|
||
...img.metadata,
|
||
fileName: img.metadata?.fileName || img.name
|
||
}
|
||
}));
|
||
this.nextCursor = result.list_complete ? null : result.cursor;
|
||
this.hasMore = !result.list_complete;
|
||
} catch (error) {
|
||
this.showToast('加载失败,请检查网络', 'error');
|
||
}
|
||
this.loading = false;
|
||
},
|
||
|
||
async loadMore() {
|
||
if (!this.nextCursor || this.loading) return;
|
||
this.loading = true;
|
||
try {
|
||
const response = await fetch(`./api/manage/list?limit=100&cursor=${encodeURIComponent(this.nextCursor)}`, {
|
||
method: 'GET',
|
||
credentials: 'include'
|
||
});
|
||
const result = await response.json();
|
||
const invalidPrefixes = ['session:', 'chunk:', 'upload:', 'temp:'];
|
||
const filtered = (result.keys || []).filter(img => {
|
||
if (!img.name) return false;
|
||
if (invalidPrefixes.some(prefix => img.name.startsWith(prefix))) return false;
|
||
const metadata = img.metadata || {};
|
||
return metadata.fileName && metadata.TimeStamp !== undefined && metadata.TimeStamp !== null;
|
||
});
|
||
const newImages = filtered.map(img => ({
|
||
...img,
|
||
selected: false,
|
||
metadata: {
|
||
...img.metadata,
|
||
fileName: img.metadata?.fileName || img.name
|
||
}
|
||
}));
|
||
this.images = [...this.images, ...newImages];
|
||
this.nextCursor = result.list_complete ? null : result.cursor;
|
||
this.hasMore = !result.list_complete;
|
||
this.showToast(`加载了 ${newImages.length} 张图片`, 'success');
|
||
} catch (error) {
|
||
this.showToast('加载失败', 'error');
|
||
}
|
||
this.loading = false;
|
||
},
|
||
|
||
handleSearch() {
|
||
clearTimeout(this.searchTimer);
|
||
this.searchTimer = setTimeout(() => {
|
||
this.currentPage = 1;
|
||
}, 300);
|
||
},
|
||
|
||
toggleSelect(img) {
|
||
img.selected = !img.selected;
|
||
},
|
||
|
||
selectAll() {
|
||
const newState = !this.isAllSelected;
|
||
this.filteredImages.forEach(img => img.selected = newState);
|
||
},
|
||
|
||
clearSelection() {
|
||
this.images.forEach(img => img.selected = false);
|
||
},
|
||
|
||
formatLink(img) {
|
||
const url = `${this.baseURL}/file/${img.name}`;
|
||
const name = img.metadata?.fileName || img.name;
|
||
switch (this.linkFormat) {
|
||
case 'markdown': return ``;
|
||
case 'html': return `<img src="${url}" alt="${name}">`;
|
||
case 'bbcode': return `[img]${url}[/img]`;
|
||
default: return url;
|
||
}
|
||
},
|
||
|
||
copyLink(img) {
|
||
const link = this.formatLink(img);
|
||
this.copyToClipboard(link);
|
||
this.showToast('链接已复制', 'success');
|
||
},
|
||
|
||
batchCopy() {
|
||
const selected = this.images.filter(img => img.selected);
|
||
const links = selected.map(img => this.formatLink(img)).join('\n');
|
||
this.copyToClipboard(links);
|
||
this.showToast(`已复制 ${selected.length} 个链接`, 'success');
|
||
},
|
||
|
||
batchDownload() {
|
||
const selected = this.images.filter(img => img.selected);
|
||
this.showToast(`开始下载 ${selected.length} 个文件`, 'info');
|
||
selected.forEach((img, i) => {
|
||
setTimeout(() => {
|
||
this.downloadImage(img);
|
||
}, i * 500);
|
||
});
|
||
},
|
||
|
||
async batchDelete() {
|
||
const selected = this.images.filter(img => img.selected);
|
||
if (!confirm(`确定要删除这 ${selected.length} 个文件吗?`)) return;
|
||
|
||
let successCount = 0;
|
||
for (const img of selected) {
|
||
try {
|
||
const response = await fetch(`./api/manage/delete/${img.name}`, {
|
||
method: 'GET',
|
||
credentials: 'include'
|
||
});
|
||
if (response.ok) {
|
||
const index = this.images.findIndex(i => i.name === img.name);
|
||
if (index > -1) this.images.splice(index, 1);
|
||
successCount++;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
this.showToast(`成功删除 ${successCount} 个文件`, 'success');
|
||
},
|
||
|
||
previewImage(img) {
|
||
// 文件信息
|
||
const fileName = img.metadata?.fileName || img.name;
|
||
// 构造绝对直链 URL(必须是完整的 https:// 链接)
|
||
const fileUrl = `${this.baseURL}/file/${img.name}`;
|
||
const ext = fileName.split('.').pop().toLowerCase();
|
||
|
||
// 图片类型 - 保持原生 Lightbox 预览
|
||
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'];
|
||
if (imageExts.includes(ext)) {
|
||
this.previewData = {
|
||
type: 'native-image',
|
||
url: `/file/${img.name}`,
|
||
fileName: fileName,
|
||
img: img
|
||
};
|
||
return;
|
||
}
|
||
|
||
// 视频/音频/文档类型 - 使用 Blog Preview iframe
|
||
const videoExts = ['mp4', 'webm', 'mkv', 'avi', 'mov', 'm3u8'];
|
||
const audioExts = ['mp3', 'wav', 'ogg', 'flac', 'm4a'];
|
||
const docExts = ['pdf', 'txt', 'doc', 'docx'];
|
||
|
||
if (videoExts.includes(ext) || audioExts.includes(ext) || docExts.includes(ext)) {
|
||
// 构造 iframe URL - 必须对参数进行 encodeURIComponent 编码
|
||
const targetUrl = encodeURIComponent(fileUrl);
|
||
const targetTitle = encodeURIComponent(fileName);
|
||
const iframeSrc = `${PREVIEW_SERVICE_BASE}/preview?url=${targetUrl}&title=${targetTitle}&autoplay=1`;
|
||
|
||
this.previewData = {
|
||
type: 'iframe',
|
||
iframeUrl: iframeSrc,
|
||
url: fileUrl,
|
||
fileName: fileName,
|
||
img: img
|
||
};
|
||
return;
|
||
}
|
||
|
||
// 其他类型(.zip, .exe 等)- 直接下载
|
||
this.downloadImage(img);
|
||
},
|
||
|
||
// 关闭预览并销毁 iframe
|
||
closePreview() {
|
||
if (this.$refs.previewIframe) {
|
||
this.$refs.previewIframe.src = 'about:blank';
|
||
}
|
||
this.previewData = null;
|
||
},
|
||
|
||
// 复制预览链接
|
||
copyPreviewLink() {
|
||
if (this.previewData) {
|
||
this.copyToClipboard(this.previewData.url);
|
||
this.showToast('链接已复制', 'success');
|
||
}
|
||
},
|
||
|
||
downloadImage(img) {
|
||
const link = document.createElement('a');
|
||
link.href = `/file/${img.name}`;
|
||
link.download = img.metadata?.fileName || img.name;
|
||
link.click();
|
||
},
|
||
|
||
async deleteImage(img, index) {
|
||
if (!confirm('确定要删除这个文件吗?')) return;
|
||
try {
|
||
const response = await fetch(`./api/manage/delete/${img.name}`, {
|
||
method: 'GET',
|
||
credentials: 'include'
|
||
});
|
||
if (response.ok) {
|
||
const realIndex = this.images.findIndex(i => i.name === img.name);
|
||
if (realIndex > -1) this.images.splice(realIndex, 1);
|
||
this.showToast('删除成功', 'success');
|
||
} else {
|
||
throw new Error('删除失败');
|
||
}
|
||
} catch (error) {
|
||
this.showToast('删除失败', 'error');
|
||
}
|
||
},
|
||
|
||
handleImageError(event, img) {
|
||
event.target.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%23f0f0f0" width="100" height="100"/><text x="50" y="55" text-anchor="middle" fill="%23999" font-size="12">加载失败</text></svg>';
|
||
},
|
||
|
||
copyToClipboard(text) {
|
||
if (navigator.clipboard) {
|
||
navigator.clipboard.writeText(text);
|
||
} else {
|
||
const textarea = document.createElement('textarea');
|
||
document.body.appendChild(textarea);
|
||
textarea.value = text;
|
||
textarea.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textarea);
|
||
}
|
||
},
|
||
|
||
showToast(message, type = 'info') {
|
||
const toast = { message, type };
|
||
this.toasts.push(toast);
|
||
setTimeout(() => {
|
||
const index = this.toasts.indexOf(toast);
|
||
if (index > -1) this.toasts.splice(index, 1);
|
||
}, 3000);
|
||
},
|
||
|
||
getToastIcon(type) {
|
||
const icons = {
|
||
success: 'fas fa-check-circle',
|
||
error: 'fas fa-times-circle',
|
||
info: 'fas fa-info-circle'
|
||
};
|
||
return icons[type] || icons.info;
|
||
},
|
||
|
||
async checkAuth() {
|
||
try {
|
||
const response = await fetch('/api/auth/check', { credentials: 'include' });
|
||
const data = await response.json();
|
||
if (data.authRequired && !data.authenticated) {
|
||
window.location.href = '/login.html?redirect=' + encodeURIComponent(window.location.pathname);
|
||
return false;
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
console.error('Auth check failed:', e);
|
||
return true;
|
||
}
|
||
}
|
||
},
|
||
async mounted() {
|
||
// 先检查认证
|
||
const isAuth = await this.checkAuth();
|
||
if (!isAuth) return;
|
||
|
||
this.loadImages();
|
||
|
||
// 键盘快捷键
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && this.previewData) {
|
||
this.closePreview();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|