mirror of
https://github.com/sky22333/hubproxy.git
synced 2026-06-27 10:31:24 +08:00
717 lines
24 KiB
HTML
717 lines
24 KiB
HTML
<!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镜像批量下载工具,docker镜像打包下载">
|
||
<meta name="keywords" content="Docker,镜像下载,skopeo,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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 导航栏样式 - 与首页完全一致,使用!important确保优先级 */
|
||
.navbar {
|
||
position: sticky !important;
|
||
top: 0 !important;
|
||
z-index: 50 !important;
|
||
width: 100% !important;
|
||
border-bottom: 1px solid var(--border) !important;
|
||
background-color: var(--background) !important;
|
||
backdrop-filter: blur(8px) !important;
|
||
background-color: rgba(255, 255, 255, 0.95) !important;
|
||
padding: 0 !important;
|
||
margin: 0 !important;
|
||
}
|
||
|
||
.dark .navbar {
|
||
background-color: rgba(15, 23, 42, 0.95) !important;
|
||
}
|
||
|
||
.navbar-container {
|
||
max-width: 1200px !important;
|
||
margin: 0 auto !important;
|
||
padding: 0 1rem !important;
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
justify-content: space-between !important;
|
||
height: 4rem !important;
|
||
}
|
||
|
||
.logo {
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
gap: 0.5rem !important;
|
||
text-decoration: none !important;
|
||
color: var(--foreground) !important;
|
||
font-weight: 600 !important;
|
||
font-size: 1.125rem !important;
|
||
}
|
||
|
||
.logo-icon {
|
||
width: 2rem !important;
|
||
height: 2rem !important;
|
||
border-radius: 0.5rem !important;
|
||
background: linear-gradient(135deg, var(--primary), #3b82f6) !important;
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
justify-content: center !important;
|
||
color: white !important;
|
||
}
|
||
|
||
.nav-links {
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
gap: 0.5rem !important;
|
||
}
|
||
|
||
.nav-link {
|
||
padding: 0.5rem 1rem !important;
|
||
border-radius: var(--radius) !important;
|
||
text-decoration: none !important;
|
||
color: var(--muted-foreground) !important;
|
||
transition: all 0.2s !important;
|
||
font-weight: 500 !important;
|
||
}
|
||
|
||
.nav-link:hover,
|
||
.nav-link.active {
|
||
color: var(--foreground) !important;
|
||
background-color: var(--muted) !important;
|
||
}
|
||
|
||
.theme-toggle {
|
||
padding: 0.5rem !important;
|
||
border: none !important;
|
||
border-radius: var(--radius) !important;
|
||
background-color: transparent !important;
|
||
color: var(--muted-foreground) !important;
|
||
cursor: pointer !important;
|
||
transition: all 0.2s !important;
|
||
}
|
||
|
||
.theme-toggle:hover {
|
||
background-color: var(--muted) !important;
|
||
color: var(--foreground) !important;
|
||
}
|
||
|
||
/* 主要内容区域 */
|
||
.main {
|
||
flex: 1;
|
||
padding: 2rem 1rem;
|
||
}
|
||
|
||
*::-webkit-scrollbar {
|
||
height: 10px;
|
||
margin-top: 0px;
|
||
}
|
||
|
||
*::-webkit-scrollbar-track {
|
||
background-color: var(--muted);
|
||
}
|
||
|
||
*::-webkit-scrollbar-thumb {
|
||
background: var(--primary);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.container {
|
||
max-width: 80%;
|
||
text-align: center;
|
||
min-height: 65%;
|
||
line-height: 1.25;
|
||
margin: 2rem auto 0; /* 保持原有布局但使用更标准的边距 */
|
||
}
|
||
|
||
h1 {
|
||
color: var(--foreground);
|
||
font-weight: bold;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.rounded-button {
|
||
border-radius: 6px;
|
||
transition: background-color 0.3s, transform 0.2s;
|
||
padding: 10px 30px;
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
border: none;
|
||
margin-bottom: 3%;
|
||
}
|
||
|
||
.rounded-button:hover {
|
||
background-color: #1d4ed8;
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
footer {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 0;
|
||
right: 0;
|
||
text-align: center;
|
||
padding: 10px;
|
||
line-height: 1.25;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
pre {
|
||
background: var(--muted);
|
||
color: var(--primary);
|
||
padding: 15px 20px 15px 20px;
|
||
margin: 0px 0;
|
||
border-radius: 0.5rem;
|
||
overflow-x: auto;
|
||
position: relative;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
pre::before {
|
||
content: " ";
|
||
display: block;
|
||
position: absolute;
|
||
top: 6px;
|
||
left: 6px;
|
||
width: 10px;
|
||
height: 10px;
|
||
background: #dc3545;
|
||
border-radius: 50%;
|
||
box-shadow: 20px 0 0 #ffc107, 40px 0 0 #28a745;
|
||
}
|
||
|
||
code {
|
||
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||
font-size: 0.9em;
|
||
margin-bottom: 0px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.container {
|
||
max-width: 100%;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.nav-links {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.container {
|
||
max-width: 65%;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
h1 {
|
||
margin-bottom: 30px;
|
||
}
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 3%;
|
||
}
|
||
|
||
/* 基础样式定义,替代Bootstrap */
|
||
.btn {
|
||
display: inline-block;
|
||
padding: 0.375rem 0.75rem;
|
||
margin-bottom: 0;
|
||
font-size: 1rem;
|
||
font-weight: 400;
|
||
line-height: 1.5;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
vertical-align: middle;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
border-radius: 0.375rem;
|
||
text-decoration: none;
|
||
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||
}
|
||
|
||
.form-control {
|
||
display: block;
|
||
width: 100%;
|
||
background-color: var(--input);
|
||
color: var(--foreground);
|
||
border: 1px solid var(--border);
|
||
border-radius: 0.375rem;
|
||
padding: 0.375rem 0.75rem;
|
||
font-size: 1rem;
|
||
line-height: 1.5;
|
||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||
}
|
||
|
||
.form-control:focus {
|
||
background-color: var(--input);
|
||
color: var(--foreground);
|
||
border-color: var(--ring);
|
||
box-shadow: 0 0 0 0.2rem rgba(37, 99, 235, 0.25);
|
||
outline: 0;
|
||
}
|
||
|
||
#toast {
|
||
position: fixed;
|
||
top: 10%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
padding: 15px 20px;
|
||
border-radius: 10px;
|
||
font-size: 90%;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.back-button {
|
||
position: fixed;
|
||
top: 20px;
|
||
left: 20px;
|
||
padding: 2px 8px;
|
||
background-color: var(--muted);
|
||
border: 1px solid var(--border);
|
||
color: var(--foreground);
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
font-size: 14px;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.back-button:hover {
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
transform: scale(1.05);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.progress-container {
|
||
margin-top: 20px;
|
||
display: none;
|
||
}
|
||
|
||
.total-progress-text {
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
margin-bottom: 20px;
|
||
padding: 10px;
|
||
background-color: var(--input);
|
||
border-radius: 10px;
|
||
display: inline-block;
|
||
}
|
||
|
||
.image-progress {
|
||
margin-top: 15px;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.image-progress-item {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.image-progress-name {
|
||
flex: 0 0 200px;
|
||
text-align: left;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.image-progress-bar-container {
|
||
flex-grow: 1;
|
||
height: 15px;
|
||
background-color: #ddd;
|
||
border-radius: 5px;
|
||
margin: 0 10px;
|
||
}
|
||
|
||
.image-progress-bar {
|
||
height: 100%;
|
||
background-color: #39c5bb;
|
||
border-radius: 5px;
|
||
width: 0%;
|
||
}
|
||
|
||
.image-progress-text {
|
||
flex: 0 0 50px;
|
||
text-align: right;
|
||
}
|
||
|
||
.download-button {
|
||
display: none;
|
||
margin-top: 20px;
|
||
padding: 10px 20px;
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
border: none;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.download-button:hover {
|
||
background-color: #1d4ed8;
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
textarea.form-control {
|
||
min-height: 150px;
|
||
resize: vertical;
|
||
}
|
||
|
||
.info-text {
|
||
font-size: 0.9rem;
|
||
color: var(--foreground);
|
||
opacity: 0.8;
|
||
margin-bottom: 15px;
|
||
text-align: left;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<!-- 现代化导航栏 -->
|
||
<nav class="navbar">
|
||
<div class="navbar-container">
|
||
<a href="/" class="logo">
|
||
<div class="logo-icon">
|
||
⚡
|
||
</div>
|
||
加速服务
|
||
</a>
|
||
|
||
<div class="nav-links">
|
||
<a href="/" class="nav-link">🚀 GitHub加速</a>
|
||
<a href="/skopeo.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">
|
||
<h1>Docker离线镜像包下载</h1>
|
||
|
||
<div class="form-group">
|
||
<div class="info-text">每行输入一个镜像,跟docker pull的格式一样,多个镜像会自动打包到一起为zip包,单个镜像为tar包。导入镜像后需要手动为镜像添加名称和标签,例如:docker tag 1856948a5aa7 镜像名称:标签</div>
|
||
<textarea class="form-control" id="imageInput" placeholder="例如: nginx stilleshan/frpc stilleshan/frpc:0.62.1"></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<div class="info-text">镜像架构,默认为 amd64</div>
|
||
<input type="text" class="form-control" id="platformInput" placeholder="输入架构,例如:amd64, arm64等" value="amd64">
|
||
</div>
|
||
|
||
<button class="btn rounded-button" id="downloadButton">开始下载</button>
|
||
|
||
<div class="progress-container" id="progressContainer">
|
||
<div class="total-progress-text" id="totalProgressText">0/0 - 0%</div>
|
||
|
||
<div class="image-progress" id="imageProgressList">
|
||
<!-- Image progress items will be added here -->
|
||
</div>
|
||
|
||
<button class="download-button" id="getFileButton">下载文件</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast" style="display:none;"></div>
|
||
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const imageInput = document.getElementById('imageInput');
|
||
const platformInput = document.getElementById('platformInput');
|
||
const downloadButton = document.getElementById('downloadButton');
|
||
const progressContainer = document.getElementById('progressContainer');
|
||
const totalProgressText = document.getElementById('totalProgressText');
|
||
const imageProgressList = document.getElementById('imageProgressList');
|
||
const getFileButton = document.getElementById('getFileButton');
|
||
|
||
let images = [];
|
||
let currentTaskId = null;
|
||
let websocket = null;
|
||
|
||
function parseImageList() {
|
||
const text = imageInput.value.trim();
|
||
if (!text) {
|
||
return [];
|
||
}
|
||
|
||
return text.split('\n')
|
||
.map(line => line.trim())
|
||
.filter(line => line.length > 0);
|
||
}
|
||
|
||
function startDownload() {
|
||
images = parseImageList();
|
||
|
||
if (images.length === 0) {
|
||
showToast('请至少输入一个镜像');
|
||
return;
|
||
}
|
||
|
||
const platform = platformInput.value.trim() || 'amd64';
|
||
const requestData = {
|
||
images: images,
|
||
platform: platform
|
||
};
|
||
|
||
fetch('/api/download', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(requestData)
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.taskId) {
|
||
currentTaskId = data.taskId;
|
||
showProgressUI();
|
||
connectWebSocket(currentTaskId);
|
||
|
||
const totalCount = data.totalCount || images.length;
|
||
totalProgressText.textContent = `0/${totalCount} - 0%`;
|
||
} else {
|
||
showToast('下载任务创建失败');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
showToast('请求失败: ' + error.message);
|
||
});
|
||
}
|
||
|
||
function showProgressUI() {
|
||
progressContainer.style.display = 'block';
|
||
downloadButton.style.display = 'none';
|
||
imageInput.disabled = true;
|
||
platformInput.disabled = true;
|
||
|
||
const totalCount = images.length;
|
||
totalProgressText.textContent = `0/${totalCount} - 0%`;
|
||
|
||
imageProgressList.innerHTML = '';
|
||
images.forEach(image => {
|
||
addImageProgressItem(image, 0);
|
||
});
|
||
}
|
||
|
||
function addImageProgressItem(image, progress) {
|
||
const itemDiv = document.createElement('div');
|
||
itemDiv.className = 'image-progress-item';
|
||
itemDiv.id = `progress-${image.replace(/[\/\.:]/g, '_')}`;
|
||
|
||
const nameDiv = document.createElement('div');
|
||
nameDiv.className = 'image-progress-name';
|
||
nameDiv.title = image;
|
||
nameDiv.textContent = image;
|
||
|
||
const barContainerDiv = document.createElement('div');
|
||
barContainerDiv.className = 'image-progress-bar-container';
|
||
|
||
const barDiv = document.createElement('div');
|
||
barDiv.className = 'image-progress-bar';
|
||
barDiv.style.width = `${progress}%`;
|
||
|
||
const textDiv = document.createElement('div');
|
||
textDiv.className = 'image-progress-text';
|
||
textDiv.textContent = `${Math.round(progress)}%`;
|
||
|
||
barContainerDiv.appendChild(barDiv);
|
||
itemDiv.appendChild(nameDiv);
|
||
itemDiv.appendChild(barContainerDiv);
|
||
itemDiv.appendChild(textDiv);
|
||
|
||
imageProgressList.appendChild(itemDiv);
|
||
}
|
||
|
||
function updateImageProgress(image, progress, status) {
|
||
const itemId = `progress-${image.replace(/[\/\.:]/g, '_')}`;
|
||
const item = document.getElementById(itemId);
|
||
|
||
if (item) {
|
||
const bar = item.querySelector('.image-progress-bar');
|
||
const text = item.querySelector('.image-progress-text');
|
||
|
||
bar.style.width = `${progress}%`;
|
||
text.textContent = `${Math.round(progress)}%`;
|
||
|
||
if (status === 'failed') {
|
||
bar.style.backgroundColor = '#bd3c35';
|
||
text.textContent = '失败';
|
||
} else if (status === 'completed') {
|
||
bar.style.backgroundColor = '#4CAF50';
|
||
text.textContent = '完成';
|
||
}
|
||
}
|
||
}
|
||
|
||
function connectWebSocket(taskId) {
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${window.location.host}/ws/${taskId}`;
|
||
|
||
websocket = new WebSocket(wsUrl);
|
||
|
||
websocket.onopen = function() {
|
||
console.log('ws');
|
||
};
|
||
|
||
websocket.onmessage = function(event) {
|
||
const data = JSON.parse(event.data);
|
||
updateProgress(data);
|
||
};
|
||
|
||
websocket.onerror = function(error) {
|
||
console.error('WebSocket错误:', error);
|
||
showToast('WebSocket连接错误');
|
||
};
|
||
|
||
websocket.onclose = function() {
|
||
console.log('WebSocket连接已关闭');
|
||
};
|
||
}
|
||
|
||
function updateProgress(data) {
|
||
const progressPercent = data.totalCount > 0 ? (data.completedCount / data.totalCount) * 100 : 0;
|
||
totalProgressText.textContent = `${data.completedCount}/${data.totalCount} - ${Math.round(progressPercent)}%`;
|
||
|
||
data.images.forEach(imgData => {
|
||
updateImageProgress(imgData.image, imgData.progress, imgData.status);
|
||
});
|
||
|
||
if (data.status === 'completed') {
|
||
getFileButton.style.display = 'inline-block';
|
||
|
||
if (websocket) {
|
||
websocket.close();
|
||
}
|
||
}
|
||
}
|
||
|
||
function downloadFile() {
|
||
if (!currentTaskId) {
|
||
showToast('没有可下载的文件');
|
||
return;
|
||
}
|
||
|
||
window.location.href = `/api/files/${currentTaskId}_file`;
|
||
}
|
||
|
||
function showToast(message) {
|
||
const toast = document.getElementById('toast');
|
||
toast.textContent = message;
|
||
toast.style.display = 'block';
|
||
|
||
setTimeout(() => {
|
||
toast.style.display = 'none';
|
||
}, 3000);
|
||
}
|
||
|
||
downloadButton.addEventListener('click', startDownload);
|
||
getFileButton.addEventListener('click', downloadFile);
|
||
|
||
// 主题切换功能
|
||
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');
|
||
});
|
||
});
|
||
</script>
|
||
</main> <!-- 关闭 main -->
|
||
</body>
|
||
</html> |