重构离线镜像下载
This commit is contained in:
@@ -11,9 +11,6 @@ FROM alpine
|
|||||||
|
|
||||||
WORKDIR /root/
|
WORKDIR /root/
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
RUN apk add --no-cache skopeo
|
|
||||||
|
|
||||||
COPY --from=builder /app/hubproxy .
|
COPY --from=builder /app/hubproxy .
|
||||||
COPY --from=builder /app/config.toml .
|
COPY --from=builder /app/config.toml .
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ else
|
|||||||
|
|
||||||
# 检查依赖
|
# 检查依赖
|
||||||
missing_deps=()
|
missing_deps=()
|
||||||
for cmd in curl jq tar skopeo; do
|
for cmd in curl jq tar; do
|
||||||
if ! command -v $cmd &> /dev/null; then
|
if ! command -v $cmd &> /dev/null; then
|
||||||
missing_deps+=($cmd)
|
missing_deps+=($cmd)
|
||||||
fi
|
fi
|
||||||
@@ -72,14 +72,14 @@ else
|
|||||||
echo -e "${YELLOW}检测到缺少依赖: ${missing_deps[*]}${NC}"
|
echo -e "${YELLOW}检测到缺少依赖: ${missing_deps[*]}${NC}"
|
||||||
echo -e "${BLUE}正在自动安装依赖...${NC}"
|
echo -e "${BLUE}正在自动安装依赖...${NC}"
|
||||||
|
|
||||||
apt update && apt install -y curl jq skopeo
|
apt update && apt install -y curl jq
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo -e "${RED}依赖安装失败${NC}"
|
echo -e "${RED}依赖安装失败${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 重新检查依赖
|
# 重新检查依赖
|
||||||
for cmd in curl jq tar skopeo; do
|
for cmd in curl jq tar; do
|
||||||
if ! command -v $cmd &> /dev/null; then
|
if ! command -v $cmd &> /dev/null; then
|
||||||
echo -e "${RED}依赖安装后仍缺少: $cmd${NC}"
|
echo -e "${RED}依赖安装后仍缺少: $cmd${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ require (
|
|||||||
github.com/fsnotify/fsnotify v1.8.0
|
github.com/fsnotify/fsnotify v1.8.0
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/google/go-containerregistry v0.20.5
|
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/pelletier/go-toml/v2 v2.2.3
|
||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.20.1
|
||||||
golang.org/x/sync v0.14.0
|
|
||||||
golang.org/x/time v0.11.0
|
golang.org/x/time v0.11.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,6 +53,7 @@ require (
|
|||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.32.0 // indirect
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
golang.org/x/net v0.33.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/sys v0.33.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.21.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.3 // indirect
|
google.golang.org/protobuf v1.36.3 // indirect
|
||||||
|
|||||||
@@ -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 h1:4RnlYcDs5hoA++CeFjlbZ/U9Yp1EuWr+UhhTyYQjOP0=
|
||||||
github.com/google/go-containerregistry v0.20.5/go.mod h1:Q14vdOOzug02bwnhMkZKD4e30pDaD9W65qzXpyzF49E=
|
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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
|||||||
577
src/imagetar.go
Normal file
577
src/imagetar.go
Normal 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
|
||||||
|
}
|
||||||
12
src/main.go
12
src/main.go
@@ -60,11 +60,14 @@ func main() {
|
|||||||
// 初始化Docker流式代理
|
// 初始化Docker流式代理
|
||||||
initDockerProxy()
|
initDockerProxy()
|
||||||
|
|
||||||
|
// 初始化镜像流式下载器
|
||||||
|
initImageStreamer()
|
||||||
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
|
|
||||||
// 初始化skopeo路由(静态文件和API路由)
|
// 初始化镜像tar下载路由
|
||||||
initSkopeoRoutes(router)
|
initImageTarRoutes(router)
|
||||||
|
|
||||||
// 静态文件路由(使用嵌入文件)
|
// 静态文件路由(使用嵌入文件)
|
||||||
router.GET("/", func(c *gin.Context) {
|
router.GET("/", func(c *gin.Context) {
|
||||||
@@ -74,8 +77,9 @@ func main() {
|
|||||||
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
|
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||||
serveEmbedFile(c, "public/"+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) {
|
router.GET("/search.html", func(c *gin.Context) {
|
||||||
serveEmbedFile(c, "public/search.html")
|
serveEmbedFile(c, "public/search.html")
|
||||||
|
|||||||
813
src/public/images.html
Normal file
813
src/public/images.html
Normal 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 redis:alpine ubuntu:20.04 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>
|
||||||
@@ -588,7 +588,7 @@
|
|||||||
|
|
||||||
<div class="nav-links" id="navLinks">
|
<div class="nav-links" id="navLinks">
|
||||||
<a href="/" class="nav-link active">🚀 GitHub加速</a>
|
<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="/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>
|
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="nav-link">📄 Hosts</a>
|
||||||
|
|
||||||
|
|||||||
@@ -743,7 +743,7 @@
|
|||||||
|
|
||||||
<div class="nav-links" id="navLinks">
|
<div class="nav-links" id="navLinks">
|
||||||
<a href="/" class="nav-link">🚀 GitHub加速</a>
|
<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="/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>
|
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="nav-link">📄 Hosts</a>
|
||||||
|
|
||||||
|
|||||||
@@ -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="例如: 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') {
|
|
||||||
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
Reference in New Issue
Block a user