重构离线镜像下载

This commit is contained in:
user123456
2025-06-13 08:22:30 +08:00
parent e0dbf304e2
commit 80b3f8959f
11 changed files with 1404 additions and 2326 deletions

View File

@@ -11,9 +11,6 @@ FROM alpine
WORKDIR /root/
# 安装依赖
RUN apk add --no-cache skopeo
COPY --from=builder /app/hubproxy .
COPY --from=builder /app/config.toml .

View File

@@ -62,7 +62,7 @@ else
# 检查依赖
missing_deps=()
for cmd in curl jq tar skopeo; do
for cmd in curl jq tar; do
if ! command -v $cmd &> /dev/null; then
missing_deps+=($cmd)
fi
@@ -72,14 +72,14 @@ else
echo -e "${YELLOW}检测到缺少依赖: ${missing_deps[*]}${NC}"
echo -e "${BLUE}正在自动安装依赖...${NC}"
apt update && apt install -y curl jq skopeo
apt update && apt install -y curl jq
if [ $? -ne 0 ]; then
echo -e "${RED}依赖安装失败${NC}"
exit 1
fi
# 重新检查依赖
for cmd in curl jq tar skopeo; do
for cmd in curl jq tar; do
if ! command -v $cmd &> /dev/null; then
echo -e "${RED}依赖安装后仍缺少: $cmd${NC}"
exit 1

View File

@@ -6,10 +6,8 @@ require (
github.com/fsnotify/fsnotify v1.8.0
github.com/gin-gonic/gin v1.10.0
github.com/google/go-containerregistry v0.20.5
github.com/gorilla/websocket v1.5.1
github.com/pelletier/go-toml/v2 v2.2.3
github.com/spf13/viper v1.20.1
golang.org/x/sync v0.14.0
golang.org/x/time v0.11.0
)
@@ -55,6 +53,7 @@ require (
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.36.3 // indirect

View File

@@ -44,8 +44,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/go-containerregistry v0.20.5 h1:4RnlYcDs5hoA++CeFjlbZ/U9Yp1EuWr+UhhTyYQjOP0=
github.com/google/go-containerregistry v0.20.5/go.mod h1:Q14vdOOzug02bwnhMkZKD4e30pDaD9W65qzXpyzF49E=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=

577
src/imagetar.go Normal file
View File

@@ -0,0 +1,577 @@
package main
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/types"
)
// ImageStreamer 镜像流式下载器
type ImageStreamer struct {
concurrency int
remoteOptions []remote.Option
}
// ImageStreamerConfig 下载器配置
type ImageStreamerConfig struct {
Concurrency int
}
// NewImageStreamer 创建镜像下载器
func NewImageStreamer(config *ImageStreamerConfig) *ImageStreamer {
if config == nil {
config = &ImageStreamerConfig{}
}
concurrency := config.Concurrency
if concurrency <= 0 {
cfg := GetConfig()
concurrency = cfg.Download.MaxImages
if concurrency <= 0 {
concurrency = 10
}
}
remoteOptions := []remote.Option{
remote.WithAuth(authn.Anonymous),
remote.WithTransport(GetGlobalHTTPClient().Transport),
}
return &ImageStreamer{
concurrency: concurrency,
remoteOptions: remoteOptions,
}
}
// StreamOptions 下载选项
type StreamOptions struct {
Platform string
Compression bool
}
// StreamImageToWriter 流式下载镜像到Writer
func (is *ImageStreamer) StreamImageToWriter(ctx context.Context, imageRef string, writer io.Writer, options *StreamOptions) error {
if options == nil {
options = &StreamOptions{}
}
ref, err := name.ParseReference(imageRef)
if err != nil {
return fmt.Errorf("解析镜像引用失败: %w", err)
}
log.Printf("开始下载镜像: %s", ref.String())
contextOptions := append(is.remoteOptions, remote.WithContext(ctx))
desc, err := is.getImageDescriptor(ref, contextOptions)
if err != nil {
return fmt.Errorf("获取镜像描述失败: %w", err)
}
switch desc.MediaType {
case types.OCIImageIndex, types.DockerManifestList:
return is.streamMultiArchImage(ctx, desc, writer, options, contextOptions)
case types.OCIManifestSchema1, types.DockerManifestSchema2:
return is.streamSingleImage(ctx, desc, writer, options, contextOptions)
default:
return is.streamSingleImage(ctx, desc, writer, options, contextOptions)
}
}
// getImageDescriptor 获取镜像描述符
func (is *ImageStreamer) getImageDescriptor(ref name.Reference, options []remote.Option) (*remote.Descriptor, error) {
if isCacheEnabled() {
var reference string
if tagged, ok := ref.(name.Tag); ok {
reference = tagged.TagStr()
} else if digested, ok := ref.(name.Digest); ok {
reference = digested.DigestStr()
}
if reference != "" {
cacheKey := buildManifestCacheKey(ref.Context().String(), reference)
if cachedItem := globalCache.Get(cacheKey); cachedItem != nil {
desc := &remote.Descriptor{
Manifest: cachedItem.Data,
MediaType: types.MediaType(cachedItem.ContentType),
}
log.Printf("使用缓存的manifest: %s", ref.String())
return desc, nil
}
}
}
desc, err := remote.Get(ref, options...)
if err != nil {
return nil, err
}
if isCacheEnabled() {
var reference string
if tagged, ok := ref.(name.Tag); ok {
reference = tagged.TagStr()
} else if digested, ok := ref.(name.Digest); ok {
reference = digested.DigestStr()
}
if reference != "" {
cacheKey := buildManifestCacheKey(ref.Context().String(), reference)
ttl := getManifestTTL(reference)
headers := map[string]string{
"Docker-Content-Digest": desc.Digest.String(),
}
globalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl)
log.Printf("缓存manifest: %s (TTL: %v)", ref.String(), ttl)
}
}
return desc, nil
}
// StreamImageToGin 流式响应到Gin
func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, c *gin.Context, options *StreamOptions) error {
if options == nil {
options = &StreamOptions{}
}
filename := strings.ReplaceAll(imageRef, "/", "_") + ".docker"
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
if options.Compression {
c.Header("Content-Encoding", "gzip")
}
return is.StreamImageToWriter(ctx, imageRef, c.Writer, options)
}
// streamMultiArchImage 处理多架构镜像
func (is *ImageStreamer) streamMultiArchImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, remoteOptions []remote.Option) error {
index, err := desc.ImageIndex()
if err != nil {
return fmt.Errorf("获取镜像索引失败: %w", err)
}
manifest, err := index.IndexManifest()
if err != nil {
return fmt.Errorf("获取索引清单失败: %w", err)
}
// 选择合适的平台
var selectedDesc *v1.Descriptor
for _, m := range manifest.Manifests {
if m.Platform == nil {
continue
}
if options.Platform != "" {
platformParts := strings.Split(options.Platform, "/")
if len(platformParts) == 2 &&
m.Platform.OS == platformParts[0] &&
m.Platform.Architecture == platformParts[1] {
selectedDesc = &m
break
}
} else if m.Platform.OS == "linux" && m.Platform.Architecture == "amd64" {
selectedDesc = &m
break
}
}
if selectedDesc == nil && len(manifest.Manifests) > 0 {
selectedDesc = &manifest.Manifests[0]
}
if selectedDesc == nil {
return fmt.Errorf("未找到合适的平台镜像")
}
img, err := index.Image(selectedDesc.Digest)
if err != nil {
return fmt.Errorf("获取选中镜像失败: %w", err)
}
return is.streamImageLayers(ctx, img, writer, options)
}
// streamSingleImage 处理单架构镜像
func (is *ImageStreamer) streamSingleImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, remoteOptions []remote.Option) error {
img, err := desc.Image()
if err != nil {
return fmt.Errorf("获取镜像失败: %w", err)
}
return is.streamImageLayers(ctx, img, writer, options)
}
// streamImageLayers 处理镜像层
func (is *ImageStreamer) streamImageLayers(ctx context.Context, img v1.Image, writer io.Writer, options *StreamOptions) error {
var finalWriter io.Writer = writer
if options.Compression {
gzWriter := gzip.NewWriter(writer)
defer gzWriter.Close()
finalWriter = gzWriter
}
tarWriter := tar.NewWriter(finalWriter)
defer tarWriter.Close()
configFile, err := img.ConfigFile()
if err != nil {
return fmt.Errorf("获取镜像配置失败: %w", err)
}
layers, err := img.Layers()
if err != nil {
return fmt.Errorf("获取镜像层失败: %w", err)
}
log.Printf("镜像包含 %d 层", len(layers))
return is.streamDockerFormat(ctx, tarWriter, img, layers, configFile)
}
// streamDockerFormat 生成Docker格式
func (is *ImageStreamer) streamDockerFormat(ctx context.Context, tarWriter *tar.Writer, img v1.Image, layers []v1.Layer, configFile *v1.ConfigFile) error {
configDigest, err := img.ConfigName()
if err != nil {
return err
}
configData, err := json.Marshal(configFile)
if err != nil {
return err
}
configHeader := &tar.Header{
Name: configDigest.String() + ".json",
Size: int64(len(configData)),
Mode: 0644,
}
if err := tarWriter.WriteHeader(configHeader); err != nil {
return err
}
if _, err := tarWriter.Write(configData); err != nil {
return err
}
layerDigests := make([]string, len(layers))
for i, layer := range layers {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
digest, err := layer.Digest()
if err != nil {
return err
}
layerDigests[i] = digest.String()
layerDir := digest.String()
layerHeader := &tar.Header{
Name: layerDir + "/",
Typeflag: tar.TypeDir,
Mode: 0755,
}
if err := tarWriter.WriteHeader(layerHeader); err != nil {
return err
}
layerReader, err := layer.Uncompressed()
if err != nil {
return err
}
defer layerReader.Close()
size, err := layer.Size()
if err != nil {
return err
}
layerTarHeader := &tar.Header{
Name: layerDir + "/layer.tar",
Size: size,
Mode: 0644,
}
if err := tarWriter.WriteHeader(layerTarHeader); err != nil {
return err
}
if _, err := io.Copy(tarWriter, layerReader); err != nil {
return err
}
log.Printf("已处理层 %d/%d", i+1, len(layers))
}
manifest := []map[string]interface{}{{
"Config": configDigest.String() + ".json",
"RepoTags": []string{"imported:latest"},
"Layers": func() []string {
var layers []string
for _, digest := range layerDigests {
layers = append(layers, digest+"/layer.tar")
}
return layers
}(),
}}
manifestData, err := json.Marshal(manifest)
if err != nil {
return err
}
manifestHeader := &tar.Header{
Name: "manifest.json",
Size: int64(len(manifestData)),
Mode: 0644,
}
if err := tarWriter.WriteHeader(manifestHeader); err != nil {
return err
}
_, err = tarWriter.Write(manifestData)
return err
}
var globalImageStreamer *ImageStreamer
// initImageStreamer 初始化镜像下载器
func initImageStreamer() {
globalImageStreamer = NewImageStreamer(nil)
log.Printf("镜像下载器初始化完成,并发数: %d缓存: %v",
globalImageStreamer.concurrency, isCacheEnabled())
}
// formatPlatformText 格式化平台文本
func formatPlatformText(platform string) string {
if platform == "" {
return "自动选择"
}
return platform
}
// initImageTarRoutes 初始化镜像下载路由
func initImageTarRoutes(router *gin.Engine) {
imageAPI := router.Group("/api/image")
{
imageAPI.GET("/download/:image", RateLimitMiddleware(globalLimiter), handleDirectImageDownload)
imageAPI.GET("/info/:image", RateLimitMiddleware(globalLimiter), handleImageInfo)
imageAPI.POST("/batch", RateLimitMiddleware(globalLimiter), handleSimpleBatchDownload)
}
}
// handleDirectImageDownload 处理单镜像下载
func handleDirectImageDownload(c *gin.Context) {
imageParam := c.Param("image")
if imageParam == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"})
return
}
imageRef := strings.ReplaceAll(imageParam, "_", "/")
platform := c.Query("platform")
tag := c.DefaultQuery("tag")
if tag != "" && !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
imageRef = imageRef + ":" + tag
} else if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
imageRef = imageRef + ":latest"
}
if _, err := name.ParseReference(imageRef); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
return
}
options := &StreamOptions{
Platform: platform,
Compression: false,
}
ctx := c.Request.Context()
log.Printf("下载镜像: %s (平台: %s)", imageRef, formatPlatformText(platform))
if err := globalImageStreamer.StreamImageToGin(ctx, imageRef, c, options); err != nil {
log.Printf("镜像下载失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "镜像下载失败: " + err.Error()})
return
}
}
// handleSimpleBatchDownload 处理批量下载
func handleSimpleBatchDownload(c *gin.Context) {
var req struct {
Images []string `json:"images" binding:"required"`
Platform string `json:"platform"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误: " + err.Error()})
return
}
if len(req.Images) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
return
}
cfg := GetConfig()
if len(req.Images) > cfg.Download.MaxImages {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("镜像数量超过限制,最大允许: %d", cfg.Download.MaxImages),
})
return
}
options := &StreamOptions{
Platform: req.Platform,
Compression: true,
}
ctx := c.Request.Context()
log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform))
filename := fmt.Sprintf("batch_%d_images.docker.gz", len(req.Images))
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Header("Content-Encoding", "gzip")
if err := globalImageStreamer.StreamMultipleImages(ctx, req.Images, c.Writer, options); err != nil {
log.Printf("批量镜像下载失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量镜像下载失败: " + err.Error()})
return
}
}
// handleImageInfo 处理镜像信息查询
func handleImageInfo(c *gin.Context) {
imageParam := c.Param("image")
if imageParam == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"})
return
}
imageRef := strings.ReplaceAll(imageParam, "_", "/")
tag := c.DefaultQuery("tag", "latest")
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
imageRef = imageRef + ":" + tag
}
ref, err := name.ParseReference(imageRef)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
return
}
ctx := c.Request.Context()
contextOptions := append(globalImageStreamer.remoteOptions, remote.WithContext(ctx))
desc, err := globalImageStreamer.getImageDescriptor(ref, contextOptions)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取镜像信息失败: " + err.Error()})
return
}
info := gin.H{
"name": ref.String(),
"mediaType": desc.MediaType,
"digest": desc.Digest.String(),
"size": desc.Size,
}
if desc.MediaType == types.OCIImageIndex || desc.MediaType == types.DockerManifestList {
index, err := desc.ImageIndex()
if err == nil {
manifest, err := index.IndexManifest()
if err == nil {
var platforms []string
for _, m := range manifest.Manifests {
if m.Platform != nil {
platforms = append(platforms, m.Platform.OS+"/"+m.Platform.Architecture)
}
}
info["platforms"] = platforms
info["multiArch"] = true
}
}
} else {
info["multiArch"] = false
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": info})
}
// StreamMultipleImages 批量下载多个镜像
func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []string, writer io.Writer, options *StreamOptions) error {
if options == nil {
options = &StreamOptions{}
}
var finalWriter io.Writer = writer
if options.Compression {
gzWriter := gzip.NewWriter(writer)
defer gzWriter.Close()
finalWriter = gzWriter
}
tarWriter := tar.NewWriter(finalWriter)
defer tarWriter.Close()
for i, imageRef := range imageRefs {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
log.Printf("处理镜像 %d/%d: %s", i+1, len(imageRefs), imageRef)
dirName := fmt.Sprintf("image_%d_%s/", i, strings.ReplaceAll(imageRef, "/", "_"))
dirHeader := &tar.Header{
Name: dirName,
Typeflag: tar.TypeDir,
Mode: 0755,
}
if err := tarWriter.WriteHeader(dirHeader); err != nil {
return fmt.Errorf("创建镜像目录失败: %w", err)
}
if err := is.StreamImageToWriter(ctx, imageRef, tarWriter, &StreamOptions{
Platform: options.Platform,
Compression: false,
}); err != nil {
log.Printf("下载镜像 %s 失败: %v", imageRef, err)
continue
}
}
return nil
}

View File

@@ -60,11 +60,14 @@ func main() {
// 初始化Docker流式代理
initDockerProxy()
// 初始化镜像流式下载器
initImageStreamer()
gin.SetMode(gin.ReleaseMode)
router := gin.Default()
// 初始化skopeo路由静态文件和API路由
initSkopeoRoutes(router)
// 初始化镜像tar下载路由
initImageTarRoutes(router)
// 静态文件路由(使用嵌入文件)
router.GET("/", func(c *gin.Context) {
@@ -74,8 +77,9 @@ func main() {
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
serveEmbedFile(c, "public/"+filepath)
})
router.GET("/skopeo.html", func(c *gin.Context) {
serveEmbedFile(c, "public/skopeo.html")
router.GET("/images.html", func(c *gin.Context) {
serveEmbedFile(c, "public/images.html")
})
router.GET("/search.html", func(c *gin.Context) {
serveEmbedFile(c, "public/search.html")

813
src/public/images.html Normal file
View File

@@ -0,0 +1,813 @@
<!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:latest, ubuntu:20.04, redis:alpine"
value="nginx:latest"
>
</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">立即下载 (Docker格式)</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">镜像列表(每行一个)</label>
<textarea
id="imagesTextarea"
class="textarea"
placeholder="nginx:latest&#10;redis:alpine&#10;ubuntu:20.04&#10;mysql:8.0"
></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">批量下载 (Docker格式自动压缩)</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');
}
}
// 构建下载URL
function buildDownloadUrl(imageName, platform = '') {
// 将斜杠替换为下划线以适应URL路径
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.docker.gz`;
if (contentDisposition) {
const matches = contentDisposition.match(/filename="(.+)"/);
if (matches) filename = matches[1];
}
// 创建blob并下载
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>

View File

@@ -588,7 +588,7 @@
<div class="nav-links" id="navLinks">
<a href="/" class="nav-link active">🚀 GitHub加速</a>
<a href="/skopeo.html" class="nav-link">🐳 离线镜像下载</a>
<a href="/images.html" class="nav-link">🐳 离线镜像下载</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>

View File

@@ -743,7 +743,7 @@
<div class="nav-links" id="navLinks">
<a href="/" class="nav-link">🚀 GitHub加速</a>
<a href="/skopeo.html" class="nav-link">🐳 离线镜像下载</a>
<a href="/images.html" class="nav-link">🐳 离线镜像下载</a>
<a href="/search.html" class="nav-link active">🔍 镜像搜索</a>
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="nav-link">📄 Hosts</a>

View File

@@ -1,792 +0,0 @@
<!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') {
document.querySelectorAll('.image-progress-text').forEach(function(textEl) {
if (textEl.textContent === '打包中') {
textEl.textContent = '完成';
}
});
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>

File diff suppressed because it is too large Load Diff