Files
hubproxy/src/public/skopeo.html
T
2025-06-12 16:02:22 +08:00

786 lines
27 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) {
.hero-title {
font-size: 2rem;
}
.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;
}
.container {
padding: 0 1rem;
}
.modal-content {
padding: 1.5rem;
}
}
.mobile-menu-toggle {
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>
<button class="mobile-menu-toggle" id="mobileMenuToggle">
</button>
<div class="nav-links" id="navLinks">
<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 stilleshan/frpc</div>
<textarea class="form-control" id="imageInput" placeholder="例如:&#10;nginx&#10;stilleshan/frpc&#10;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');
});
// 移动端菜单切换
const mobileMenuToggle = document.getElementById('mobileMenuToggle');
const navLinks = document.getElementById('navLinks');
mobileMenuToggle.addEventListener('click', () => {
navLinks.classList.toggle('active');
mobileMenuToggle.textContent = navLinks.classList.contains('active') ? '✕' : '☰';
});
// 点击页面其他地方关闭菜单
document.addEventListener('click', (e) => {
if (!e.target.closest('.navbar') && navLinks.classList.contains('active')) {
navLinks.classList.remove('active');
mobileMenuToggle.textContent = '☰';
}
});
});
</script>
</main> <!-- 关闭 main -->
</body>
</html>