Files
hubproxy/src/public/images.html
2025-06-13 13:59:06 +08:00

789 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">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Docker镜像流式下载工具,即点即下,无需等待">
<meta name="keywords" content="Docker,镜像下载,流式下载,即时下载">
<meta name="color-scheme" content="dark light">
<title>Docker离线镜像下载</title>
<link rel="icon" href="./favicon.ico">
<style>
:root {
--background: #ffffff;
--foreground: #0f172a;
--card: #ffffff;
--card-foreground: #0f172a;
--primary: #2563eb;
--primary-foreground: #f8fafc;
--secondary: #f1f5f9;
--secondary-foreground: #0f172a;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #f1f5f9;
--accent-foreground: #0f172a;
--border: #e2e8f0;
--input: #ffffff;
--ring: #2563eb;
--radius: 0.5rem;
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
}
.dark {
--background: #0f172a;
--foreground: #f8fafc;
--card: #1e293b;
--card-foreground: #f8fafc;
--primary: #3b82f6;
--primary-foreground: #f8fafc;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #1e293b;
--accent-foreground: #f8fafc;
--border: #334155;
--input: #1e293b;
--ring: #3b82f6;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0f172a;
--foreground: #f8fafc;
--card: #1e293b;
--card-foreground: #f8fafc;
--primary: #3b82f6;
--primary-foreground: #f8fafc;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #1e293b;
--accent-foreground: #f8fafc;
--border: #334155;
--input: #1e293b;
--ring: #3b82f6;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background-color: var(--background);
color: var(--foreground);
line-height: 1.5;
min-height: 100vh;
display: flex;
flex-direction: column;
transition: background-color 0.3s, color 0.3s;
}
/* 导航栏 */
.navbar {
position: sticky;
top: 0;
z-index: 50;
width: 100%;
border-bottom: 1px solid var(--border);
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
padding: 0;
}
.dark .navbar {
background-color: rgba(15, 23, 42, 0.95);
}
.navbar-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 4rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--foreground);
font-weight: 600;
font-size: 1.125rem;
}
.logo-icon {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: linear-gradient(135deg, var(--primary), #3b82f6);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.nav-links {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-link {
padding: 0.5rem 1rem;
border-radius: var(--radius);
text-decoration: none;
color: var(--muted-foreground);
transition: all 0.2s;
font-weight: 500;
}
.nav-link:hover,
.nav-link.active {
color: var(--foreground);
background-color: var(--muted);
}
.theme-toggle {
padding: 0.5rem;
border: none;
border-radius: var(--radius);
background-color: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition: all 0.2s;
}
.theme-toggle:hover {
background-color: var(--muted);
color: var(--foreground);
}
/* 主要内容 */
.main {
flex: 1;
padding: 2rem 1rem;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 3rem;
}
.title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
background: linear-gradient(135deg, var(--primary), #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
font-size: 1.125rem;
color: var(--muted-foreground);
margin-bottom: 2rem;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-top: 2rem;
}
.feature {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
font-weight: 500;
}
.feature-icon {
font-size: 1.25rem;
}
/* 下载区域 */
.download-section,
.batch-section {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
margin-bottom: 2rem;
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: var(--foreground);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--foreground);
}
.form-input,
.form-select,
.textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background-color: var(--input);
color: var(--foreground);
font-size: 1rem;
transition: all 0.2s;
}
.form-input:focus,
.form-select:focus,
.textarea:focus {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.textarea {
min-height: 120px;
resize: vertical;
font-family: monospace;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius);
font-weight: 500;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.btn-primary {
background-color: var(--primary);
color: var(--primary-foreground);
}
.btn-primary:hover:not(:disabled) {
background-color: #1d4ed8;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-full {
width: 100%;
}
.status {
padding: 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-weight: 500;
}
.status-success {
background-color: rgba(16, 185, 129, 0.1);
color: var(--success);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.status-error {
background-color: rgba(239, 68, 68, 0.1);
color: var(--error);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.status-warning {
background-color: rgba(245, 158, 11, 0.1);
color: var(--warning);
border: 1px solid rgba(245, 158, 11, 0.2);
}
.help-text {
font-size: 0.875rem;
color: var(--muted-foreground);
margin-top: 0.25rem;
}
@media (max-width: 768px) {
.navbar-container {
padding: 0 0.5rem;
}
.nav-links {
gap: 0.25rem;
}
.nav-link {
padding: 0.5rem;
font-size: 0.875rem;
}
.main {
padding: 1rem 0.5rem;
}
.download-section,
.batch-section {
padding: 1.5rem;
}
.form-row {
grid-template-columns: 1fr;
}
.features {
grid-template-columns: 1fr;
}
.title {
font-size: 2rem;
}
}
.loading {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.hidden {
display: none;
}
.mobile-menu-toggle {
display: none;
}
@media (max-width: 768px) {
.navbar-container {
padding: 0 0.5rem;
}
.nav-links {
position: fixed;
top: 70px;
left: 0;
right: 0;
background: var(--background);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 12px 12px;
padding: 1rem;
flex-direction: column;
gap: 0.5rem;
z-index: 1000;
transform: translateY(-100vh);
transition: transform 0.3s ease;
}
.nav-links.active {
transform: translateY(0);
}
.mobile-menu-toggle {
display: block !important;
background: none;
border: none;
color: var(--foreground);
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
border-radius: var(--radius);
transition: background-color 0.2s;
}
.mobile-menu-toggle:hover {
background-color: var(--muted);
}
.navbar-container {
justify-content: space-between !important;
}
.main {
padding: 1rem 0.5rem;
}
.download-section,
.batch-section {
padding: 1.5rem;
}
.form-row {
grid-template-columns: 1fr;
}
.features {
grid-template-columns: 1fr;
}
.title {
font-size: 2rem;
}
}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-container">
<a href="/" class="logo">
<div class="logo-icon">
</div>
加速服务
</a>
<button class="mobile-menu-toggle" id="mobileMenuToggle">
</button>
<div class="nav-links" id="navLinks">
<a href="/" class="nav-link">🚀 GitHub加速</a>
<a href="/images.html" class="nav-link active">🐳 离线镜像下载</a>
<a href="/search.html" class="nav-link">🔍 镜像搜索</a>
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="nav-link">📄 Hosts</a>
<button class="theme-toggle" id="themeToggle">
🌙
</button>
</div>
</div>
</nav>
<main class="main">
<div class="container">
<div class="header">
<h1 class="title">Docker离线镜像下载</h1>
<p class="subtitle">即点即下无需等待打包完全符合docker load加载标准</p>
<div class="features">
<div class="feature">
<span class="feature-icon"></span>
<span>即时下载</span>
</div>
<div class="feature">
<span class="feature-icon">🔄</span>
<span>流式传输</span>
</div>
<div class="feature">
<span class="feature-icon">💾</span>
<span>无需打包</span>
</div>
<div class="feature">
<span class="feature-icon">🏗️</span>
<span>多架构支持</span>
</div>
</div>
</div>
<div class="download-section">
<h2 class="section-title">单镜像下载</h2>
<div id="singleStatus"></div>
<form id="singleForm">
<div class="form-group">
<label class="form-label" for="imageInput">镜像名称</label>
<input
type="text"
id="imageInput"
class="form-input"
placeholder="例如: nginx:alpine"
>
</div>
<div class="form-group">
<label class="form-label" for="platformInput">目标架构(可选)</label>
<input
type="text"
id="platformInput"
class="form-input"
placeholder="linux/amd64"
value="linux/amd64"
>
<div class="help-text">
常用平台: linux/amd64, linux/arm64, linux/arm/v7
</div>
</div>
<button type="submit" class="btn btn-primary btn-full" id="downloadBtn">
<span id="downloadText">立即下载</span>
<span id="downloadLoading" class="loading hidden"></span>
</button>
</form>
</div>
<div class="batch-section">
<h2 class="section-title">多个镜像批量下载</h2>
<div id="batchStatus"></div>
<form id="batchForm">
<div class="form-group">
<label class="form-label" for="imagesTextarea">镜像列表每行一个会将多个镜像自动合并符合官方标准完全兼容docker load</label>
<textarea
id="imagesTextarea"
class="textarea"
placeholder="alpine&#10;redis:alpine&#10;stilleshan/frpc:0.62.1"
></textarea>
</div>
<div class="form-group">
<label class="form-label" for="batchPlatformInput">目标架构(可选)</label>
<input
type="text"
id="batchPlatformInput"
class="form-input"
placeholder="linux/amd64"
value="linux/amd64"
>
<div class="help-text">
所有镜像将使用相同的目标架构
</div>
</div>
<button type="submit" class="btn btn-primary btn-full" id="batchDownloadBtn">
<span id="batchDownloadText">开始下载</span>
<span id="batchDownloadLoading" class="loading hidden"></span>
</button>
</form>
</div>
</div>
</main>
<script>
function initTheme() {
const themeToggle = document.getElementById('themeToggle');
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
html.classList.add('dark');
themeToggle.textContent = '☀️';
}
themeToggle.addEventListener('click', () => {
html.classList.toggle('dark');
const isDark = html.classList.contains('dark');
themeToggle.textContent = isDark ? '☀️' : '🌙';
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
}
function showStatus(elementId, message, type = 'success') {
const element = document.getElementById(elementId);
element.className = `status status-${type}`;
element.textContent = message;
element.classList.remove('hidden');
}
function hideStatus(elementId) {
document.getElementById(elementId).classList.add('hidden');
}
function setButtonLoading(btnId, textId, loadingId, loading) {
const btn = document.getElementById(btnId);
const text = document.getElementById(textId);
const loadingSpinner = document.getElementById(loadingId);
btn.disabled = loading;
if (loading) {
text.classList.add('hidden');
loadingSpinner.classList.remove('hidden');
} else {
text.classList.remove('hidden');
loadingSpinner.classList.add('hidden');
}
}
function buildDownloadUrl(imageName, platform = '') {
const encodedImage = imageName.replace(/\//g, '_');
let url = `/api/image/download/${encodedImage}`;
if (platform && platform.trim()) {
url += `?platform=${encodeURIComponent(platform.trim())}`;
}
return url;
}
document.getElementById('singleForm').addEventListener('submit', function(e) {
e.preventDefault();
const imageName = document.getElementById('imageInput').value.trim();
if (!imageName) {
showStatus('singleStatus', '请输入镜像名称', 'error');
return;
}
const platform = document.getElementById('platformInput').value.trim();
hideStatus('singleStatus');
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', true);
const downloadUrl = buildDownloadUrl(imageName, platform);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = '';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const platformText = platform ? ` (${platform})` : '';
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success');
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
});
document.getElementById('batchForm').addEventListener('submit', async function(e) {
e.preventDefault();
const imagesText = document.getElementById('imagesTextarea').value.trim();
if (!imagesText) {
showStatus('batchStatus', '请输入镜像列表', 'error');
return;
}
const images = imagesText.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
if (images.length === 0) {
showStatus('batchStatus', '镜像列表为空', 'error');
return;
}
const platform = document.getElementById('batchPlatformInput').value.trim();
const options = {
images: images
};
if (platform) {
options.platform = platform;
}
hideStatus('batchStatus');
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', true);
try {
const response = await fetch('/api/image/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(options)
});
if (response.ok) {
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `batch_${images.length}_images.tar`;
if (contentDisposition) {
const matches = contentDisposition.match(/filename="(.+)"/);
if (matches) filename = matches[1];
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
const platformText = platform ? ` (${platform})` : '';
showStatus('batchStatus', `开始下载 ${images.length} 个镜像${platformText}`, 'success');
} else {
const error = await response.json();
showStatus('batchStatus', error.error || '下载失败', 'error');
}
} catch (error) {
showStatus('batchStatus', '网络错误: ' + error.message, 'error');
} finally {
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', false);
}
});
function initMobileMenu() {
const mobileMenuToggle = document.getElementById('mobileMenuToggle');
const navLinks = document.getElementById('navLinks');
if (mobileMenuToggle && navLinks) {
mobileMenuToggle.addEventListener('click', () => {
navLinks.classList.toggle('active');
});
navLinks.addEventListener('click', (e) => {
if (e.target.classList.contains('nav-link')) {
navLinks.classList.remove('active');
}
});
}
}
initTheme();
initMobileMenu();
</script>
</body>
</html>