Files
hubproxy/src/public/search.html
2025-09-02 12:34:42 +08:00

1491 lines
53 KiB
HTML
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<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、镜像搜索、docker search">
<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;
}
.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;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: var(--foreground);
font-weight: bold;
margin-bottom: 30px;
text-align: center;
}
.search-container {
margin-bottom: 30px;
}
.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;
}
.input-group {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
width: 100%;
max-width: 600px;
margin: 0 auto;
}
.input-group-append {
display: flex;
width: 100%;
justify-content: center;
}
.form-control {
display: block;
width: 100%;
background-color: var(--input);
color: var(--foreground);
border: 1px solid var(--border);
border-radius: 20px;
padding: 12px 20px;
height: 46px;
transition: all 0.2s;
font-size: 1rem;
}
.form-control:focus {
background-color: var(--input);
color: var(--foreground);
border-color: var(--ring);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
outline: 0;
}
.search-button {
border-radius: 20px;
padding: 12px 40px;
background-color: var(--primary);
color: var(--primary-foreground);
border: none;
transition: all 0.3s ease;
height: 46px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
min-width: 120px;
}
.search-button:hover {
transform: scale(1.05);
background-color: #1d4ed8;
}
.result-card {
background-color: var(--card);
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
transition: all 0.3s ease;
cursor: pointer;
border: 1px solid var(--border);
}
.result-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.result-title {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 10px;
color: var(--primary);
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.result-description {
color: var(--muted-foreground);
margin: 10px 0;
font-size: 0.95rem;
line-height: 1.5;
opacity: 0.9;
}
.result-meta {
display: flex;
justify-content: space-between;
align-items: flex-start;
font-size: 0.9rem;
color: var(--muted-foreground);
margin-top: 15px;
flex-wrap: wrap;
gap: 10px;
}
.meta-stats {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
font-size: 0.95rem;
}
.meta-pulls {
font-size: 0.85rem;
color: var(--muted-foreground);
text-align: right;
}
.meta-pulls .pulls-count {
font-size: 1rem;
color: var(--foreground);
margin-top: 4px;
}
.badge {
padding: 3px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: normal;
white-space: nowrap;
}
.badge-official {
background-color: #28a745;
color: white;
}
.badge-organization {
background-color: #6c757d;
color: white;
font-weight: normal;
}
.badge-automated {
background-color: #17a2b8;
color: white;
}
.meta-item {
display: inline-flex;
align-items: center;
white-space: nowrap;
color: var(--muted-foreground);
}
.meta-item .icon {
opacity: 0.7;
}
.back-button {
position: fixed;
top: 20px;
left: 20px;
padding: 2px 8px;
background-color: #f5f5f5;
border: 0px solid #fafafa;
color: #333;
border-radius: 10px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
text-decoration: none;
}
.back-button:hover {
background-color: #39c5bc;
color: white;
transform: scale(1.05);
text-decoration: none;
}
.loading {
text-align: center;
padding: 20px;
display: none;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--muted);
border-top: 4px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
#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;
display: none;
}
.pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
.pagination button {
margin: 0 5px;
padding: 5px 15px;
border: none;
border-radius: 5px;
background-color: var(--primary);
color: var(--primary-foreground);
cursor: pointer;
transition: all 0.3s ease;
}
.pagination button:disabled {
background-color: var(--muted);
color: var(--muted-foreground);
cursor: not-allowed;
}
.pagination button:hover:not(:disabled) {
transform: scale(1.05);
background-color: #1d4ed8;
}
.tag-list {
margin-top: 20px;
display: none;
}
.tag-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding: 20px;
background-color: var(--card);
border-radius: 10px;
border: 1px solid var(--border);
flex-direction: column;
}
.tag-info {
flex: 1;
width: 100%;
}
.tag-search-container {
width: 100%;
margin: 15px 0 20px 0;
position: relative;
background: var(--card);
padding: 15px;
border-radius: 10px;
border: 2px solid var(--primary);
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
}
.tag-search-container::before {
content: '🔍 标签搜索';
display: block;
margin-bottom: 10px;
color: var(--primary);
font-weight: bold;
font-size: 0.9rem;
}
.tag-search-input {
width: 100%;
padding: 12px 40px 12px 15px;
border-radius: 8px;
border: 1px solid var(--border);
background-color: var(--input);
color: var(--foreground);
font-size: 1rem;
transition: all 0.3s ease;
}
.tag-search-input:focus {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}
.tag-search-input::placeholder {
color: #888;
font-style: italic;
}
.tag-search-clear {
position: absolute;
right: 25px;
top: 50%;
transform: translateY(-5%);
background: none;
border: none;
color: var(--foreground);
cursor: pointer;
padding: 8px;
opacity: 0.6;
font-size: 1.2rem;
transition: all 0.3s ease;
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.tag-search-clear:hover {
opacity: 1;
background-color: rgba(37, 99, 235, 0.1);
transform: translateY(-5%) scale(1.1);
}
.tag-search-input::placeholder {
color: var(--muted-foreground);
}
.tag-title {
font-size: 1.5rem;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.tag-description {
color: var(--foreground);
opacity: 0.8;
}
.tag-pull-command {
background-color: var(--input);
padding: 10px 15px;
border-radius: 5px;
font-family: monospace;
margin-top: 10px;
position: relative;
}
.copy-button {
position: absolute;
right: 5px;
top: 5px;
background: var(--primary);
color: var(--primary-foreground);
border: none;
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 0.8rem;
}
.tag-item {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 15px;
margin-bottom: 15px;
}
.tag-name {
font-size: 1.1rem;
font-weight: bold;
margin-bottom: 10px;
color: var(--primary);
}
.tag-meta {
font-size: 0.9rem;
color: var(--foreground);
opacity: 0.8;
margin-bottom: 10px;
}
.tag-architectures {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.arch-item {
background-color: var(--input);
padding: 5px 10px;
border-radius: 5px;
font-size: 0.8rem;
}
.vulnerability-indicator {
display: inline-flex;
gap: 5px;
margin-left: 10px;
}
.vulnerability-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.vulnerability-critical { background-color: #dc3545; }
.vulnerability-high { background-color: #fd7e14; }
.vulnerability-medium { background-color: #ffc107; }
.vulnerability-low { background-color: #28a745; }
.vulnerability-unknown { background-color: #6c757d; }
.search-results {
transition: all 0.3s ease;
}
.back-to-search {
margin-bottom: 20px;
color: var(--primary);
cursor: pointer;
display: none;
}
.back-to-search:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.result-meta {
flex-direction: column;
align-items: flex-start;
}
.meta-pulls {
text-align: left;
width: 100%;
}
.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;
}
.input-group {
max-width: 100%;
padding: 0 10px;
}
.search-button {
width: 100%;
max-width: 200px;
}
}
.mobile-menu-toggle {
display: none;
}
.icon {
display: inline-block;
vertical-align: middle;
margin-right: 4px;
margin-top: -2px;
}
</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">🐳 离线镜像下载</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>
<button class="theme-toggle" id="themeToggle">
🌙
</button>
</div>
</div>
</nav>
<main class="main">
<div class="container">
<h1>Docker镜像搜索</h1>
<div class="search-container">
<div class="input-group">
<input type="text" class="form-control" id="searchInput" placeholder="输入镜像名称搜索...">
<div class="input-group-append">
<button class="btn search-button" id="searchButton">搜索</button>
</div>
</div>
</div>
<div class="loading">
<div class="loading-spinner"></div>
<p>正在加载...</p>
</div>
<div class="back-to-search">← 返回搜索结果</div>
<div class="search-results">
<div id="searchResults"></div>
<div class="pagination">
<button id="prevPage" disabled>上一页</button>
<button id="nextPage" disabled>下一页</button>
</div>
</div>
<div class="tag-list" id="tagList">
<div class="pagination" id="tagPagination" style="display: none;">
<button id="tagPrevPage" disabled>上一页</button>
<button id="tagNextPage" disabled>下一页</button>
</div>
</div>
</div>
<div id="toast"></div>
<script>
const formatUtils = {
formatNumber(num) {
if (num >= 1000000000) return (num / 1000000000).toFixed(1) + 'B+';
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M+';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K+';
return num.toString();
},
formatTimeAgo(dateString) {
if (!dateString) return '未知时间';
try {
let date;
date = new Date(dateString);
if (isNaN(date.getTime())) {
const formats = [
{ regex: /^(\d{4})-(\d{2})-(\d{2})$/, handler: (m) => new Date(m[1], m[2] - 1, m[3]) },
{ regex: /^(\d{4})\/(\d{2})\/(\d{2})$/, handler: (m) => new Date(m[1], m[2] - 1, m[3]) },
{ regex: /^(\d{2})\/(\d{2})\/(\d{4})$/, handler: (m) => new Date(m[3], m[1] - 1, m[2]) }
];
for (const format of formats) {
const match = dateString.match(format.regex);
if (match) {
date = format.handler(match);
if (!isNaN(date.getTime())) break;
}
}
if (isNaN(date.getTime())) {
return '未知时间';
}
}
const now = new Date();
const diffTime = Math.abs(now - date);
const diffMinutes = Math.floor(diffTime / (1000 * 60));
const diffHours = Math.floor(diffTime / (1000 * 60 * 60));
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffDays / 365);
if (diffMinutes < 1) return '刚刚';
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
if (diffHours < 24) return `${diffHours}小时前`;
if (diffDays < 7) return `${diffDays}天前`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)}周前`;
if (diffMonths < 12) return `${diffMonths}个月前`;
if (diffYears < 1) return '近1年';
return `${diffYears}年前`;
} catch (error) {
console.warn('日期处理错误:', error);
return '未知时间';
}
},
formatSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
};
let currentPage = 1;
let totalPages = 1;
let currentQuery = '';
let currentRepo = null;
// 标签分页相关变量
let currentTagPage = 1;
let totalTagPages = 1;
document.getElementById('searchButton').addEventListener('click', () => {
currentPage = 1;
performSearch();
});
document.getElementById('searchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
currentPage = 1;
performSearch();
}
});
document.getElementById('prevPage').addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
performSearch();
}
});
document.getElementById('nextPage').addEventListener('click', () => {
if (currentPage < totalPages) {
currentPage++;
performSearch();
}
});
document.querySelector('.back-to-search').addEventListener('click', () => {
showSearchResults();
});
// 使用事件委托处理分页按钮点击避免DOM重建导致事件丢失
document.addEventListener('click', (e) => {
if (e.target.id === 'tagPrevPage') {
if (currentTagPage > 1) {
currentTagPage--;
loadTagPage();
}
} else if (e.target.id === 'tagNextPage') {
if (currentTagPage < totalTagPages) {
currentTagPage++;
loadTagPage();
}
}
});
function showLoading() {
document.querySelector('.loading').style.display = 'block';
}
function hideLoading() {
document.querySelector('.loading').style.display = 'none';
}
function showToast(message) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, 3000);
}
// 统一分页更新函数(支持搜索和标签分页)
function updatePagination(config = {}) {
const {
currentPage: page = currentPage,
totalPages: total = totalPages,
prefix = ''
} = config;
const prevButtonId = prefix ? `${prefix}PrevPage` : 'prevPage';
const nextButtonId = prefix ? `${prefix}NextPage` : 'nextPage';
const paginationId = prefix ? `${prefix}Pagination` : '.pagination';
const prevButton = document.getElementById(prevButtonId);
const nextButton = document.getElementById(nextButtonId);
const paginationDiv = prefix ? document.getElementById(paginationId) : document.querySelector(paginationId);
if (!prevButton || !nextButton || !paginationDiv) {
return; // 静默处理,避免控制台警告
}
// 更新按钮状态
prevButton.disabled = page <= 1;
nextButton.disabled = page >= total;
// 更新或创建页面信息
const pageInfoId = prefix ? `${prefix}PageInfo` : 'pageInfo';
let pageInfo = document.getElementById(pageInfoId);
if (!pageInfo) {
pageInfo = createPageInfo(pageInfoId, prefix, total);
paginationDiv.insertBefore(pageInfo, nextButton);
}
updatePageInfo(pageInfo, page, total, prefix);
paginationDiv.style.display = total > 1 ? 'flex' : 'none';
}
// 创建页面信息元素
function createPageInfo(pageInfoId, prefix, total) {
const container = document.createElement('div');
container.id = pageInfoId;
container.style.cssText = 'margin: 0 10px; display: flex; align-items: center; gap: 10px;';
const pageText = document.createElement('span');
pageText.id = prefix ? `${prefix}PageText` : 'pageText';
const jumpInput = document.createElement('input');
jumpInput.type = 'number';
jumpInput.min = '1';
jumpInput.max = prefix === 'tag' ? total : Math.min(total, 100); // 搜索页面限制100页
jumpInput.id = prefix ? `${prefix}JumpPage` : 'jumpPage';
jumpInput.style.cssText = 'width: 60px; padding: 4px; border-radius: 4px; border: 1px solid var(--border); background-color: var(--input); color: var(--foreground);';
const jumpButton = document.createElement('button');
jumpButton.textContent = '跳转';
jumpButton.className = 'btn search-button';
jumpButton.style.padding = '4px 8px';
jumpButton.onclick = () => handlePageJump(jumpInput, prefix, total);
container.append(pageText, jumpInput, jumpButton);
return container;
}
// 更新页面信息显示
function updatePageInfo(pageInfo, page, total, prefix) {
const pageText = pageInfo.querySelector('span');
const jumpInput = pageInfo.querySelector('input');
// 标签分页显示策略:根据是否确定总页数显示不同格式
const isTagPagination = prefix === 'tag';
const maxDisplayPages = isTagPagination ? total : Math.min(total, 100);
const pageTextContent = isTagPagination
? `${page}` + (total > page ? ` (至少 ${total} 页)` : ` (共 ${total} 页)`)
: `${page} / ${maxDisplayPages} 页 共 ${maxDisplayPages}` + (total > 100 ? ' (最多100页)' : '');
pageText.textContent = pageTextContent;
jumpInput.max = maxDisplayPages;
jumpInput.value = page;
}
// 处理页面跳转
function handlePageJump(jumpInput, prefix, total) {
const inputPage = parseInt(jumpInput.value);
const maxPage = prefix === 'tag' ? total : Math.min(total, 100);
if (!inputPage || inputPage < 1 || inputPage > maxPage) {
const limitText = prefix === 'tag' ? '页码' : '页码 (最多100页)';
showToast(`请输入有效的${limitText}`);
return;
}
if (prefix === 'tag') {
currentTagPage = inputPage;
loadTagPage();
} else {
currentPage = inputPage;
performSearch();
}
}
// 统一仓库信息处理
function parseRepositoryInfo(repo) {
const namespace = repo.namespace || (repo.is_official ? 'library' : '');
let name = repo.name || repo.repo_name || '';
// 清理名称,确保不包含命名空间前缀
if (name.includes('/')) {
const parts = name.split('/');
name = parts[parts.length - 1];
}
const cleanName = name.replace(/^library\//, '');
const fullRepoName = repo.is_official ? cleanName : `${namespace}/${cleanName}`;
return {
namespace,
name,
cleanName,
fullRepoName
};
}
// 分页更新函数
const updateSearchPagination = () => updatePagination();
const updateTagPagination = () => updatePagination({
currentPage: currentTagPage,
totalPages: totalTagPages,
prefix: 'tag'
});
function showSearchResults() {
document.querySelector('.search-results').style.display = 'block';
document.querySelector('.tag-list').style.display = 'none';
document.querySelector('.back-to-search').style.display = 'none';
document.querySelector('.search-container').style.display = 'block';
}
function showTagList() {
document.querySelector('.search-results').style.display = 'none';
document.querySelector('.tag-list').style.display = 'block';
document.querySelector('.back-to-search').style.display = 'block';
document.querySelector('.search-container').style.display = 'none';
}
async function performSearch() {
const query = document.getElementById('searchInput').value.trim();
if (!query) {
showToast('请输入搜索关键词');
return;
}
currentQuery = query;
showLoading();
try {
let searchQuery = query;
let targetRepo = '';
if (query.includes('/')) {
const [namespace, repo] = query.split('/');
searchQuery = namespace;
targetRepo = repo.toLowerCase();
}
const response = await fetch(`/search?q=${encodeURIComponent(searchQuery)}&page=${currentPage}&page_size=25`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '搜索请求失败');
}
totalPages = Math.min(Math.ceil(data.count / 25), 100);
updatePagination();
displayResults(data.results, targetRepo);
} catch (error) {
console.error('搜索错误:', error);
showToast(error.message || '搜索失败,请稍后重试');
} finally {
hideLoading();
}
}
function displayResults(results, targetRepo = '') {
const resultsContainer = document.getElementById('searchResults');
resultsContainer.innerHTML = '';
if (!results || results.length === 0) {
resultsContainer.innerHTML = '<div class="text-center">未找到相关镜像</div>';
return;
}
results.sort((a, b) => {
if (targetRepo) {
const aName = (a.name || a.repo_name || '').toLowerCase();
const bName = (b.name || b.repo_name || '').toLowerCase();
const aMatch = aName === targetRepo || aName.endsWith('/' + targetRepo);
const bMatch = bName === targetRepo || bName.endsWith('/' + targetRepo);
if (aMatch && !bMatch) return -1;
if (!aMatch && bMatch) return 1;
}
if (a.is_official !== b.is_official) {
return b.is_official - a.is_official;
}
return b.pull_count - a.pull_count;
});
results.forEach(result => {
const card = document.createElement('div');
card.className = 'result-card';
let displayName = '';
if (result.is_official) {
displayName = (result.name || result.repo_name || '').replace('library/', '');
} else {
const name = result.name || result.repo_name || '';
displayName = result.namespace ? `${result.namespace}/${name}` : name;
}
const description = result.short_description || '暂无描述';
const starCount = result.star_count || 0;
const pullCount = result.pull_count || 0;
const pullsLastWeek = result.pulls_last_week || 0;
const lastUpdated = result.last_updated || '';
const organization = result.affiliation || '';
const badges = [];
if (result.is_official) badges.push('<span class="badge badge-official">官方</span>');
if (organization) badges.push(`<span class="badge badge-organization">By ${organization}</span>`);
const stats = [];
if (starCount > 0) stats.push(`<span class="meta-item">⭐ ${formatUtils.formatNumber(starCount)}</span>`);
if (pullCount > 0) stats.push(`<span class="meta-item">⬇️ ${formatUtils.formatNumber(pullCount)}+</span>`);
card.innerHTML = `
<div class="result-title">
${displayName}
${badges.join(' ')}
</div>
<div class="result-description">${description}</div>
<div class="result-meta">
<div class="meta-stats">
${stats.join(' ')}
${lastUpdated ? `<span class="meta-item">更新于 ${formatUtils.formatTimeAgo(lastUpdated)}</span>` : ''}
</div>
${pullsLastWeek > 0 ? `
<div class="meta-pulls">
<div>本周拉取次数</div>
<div class="pulls-count">${formatUtils.formatNumber(pullsLastWeek)}</div>
</div>
` : ''}
</div>
`;
card.addEventListener('click', () => {
currentRepo = result;
const namespace = result.namespace || (result.is_official ? 'library' : '');
const name = result.name || result.repo_name || '';
const repoName = name.replace('library/', '');
if (!namespace || !repoName) {
showToast('无效的仓库信息');
return;
}
loadTags(namespace, repoName);
});
resultsContainer.appendChild(card);
});
}
// 内存管理
async function loadTags(namespace, name) {
currentTagPage = 1;
await loadTagPage(namespace, name);
}
async function loadTagPage(namespace = null, name = null) {
showLoading();
try {
// 如果传入了新的namespace和name更新currentRepo
if (namespace && name) {
// 清理旧数据,防止内存泄露
cleanupOldTagData();
}
// 获取当前仓库信息
const repoInfo = parseRepositoryInfo(currentRepo);
const currentNamespace = namespace || repoInfo.namespace;
const currentName = name || repoInfo.name;
// 调试日志
console.log(`loadTagPage: namespace=${currentNamespace}, name=${currentName}, page=${currentTagPage}`);
if (!currentNamespace || !currentName) {
showToast('命名空间和镜像名称不能为空');
return;
}
const response = await fetch(`/tags/${encodeURIComponent(currentNamespace)}/${encodeURIComponent(currentName)}?page=${currentTagPage}&page_size=100`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || '获取标签信息失败');
}
const data = await response.json();
// 改进的总页数计算:使用更准确的分页策略
if (data.has_more) {
// 如果还有更多页面,至少有当前页+1页但可能更多
totalTagPages = Math.max(currentTagPage + 1, totalTagPages);
} else {
// 如果没有更多页面,当前页就是最后一页
totalTagPages = currentTagPage;
}
displayTags(data.tags, data.has_more);
updateTagPagination();
if (namespace && name) {
showTagList();
}
} catch (error) {
console.error('加载标签错误:', error);
showToast(error.message || '获取标签信息失败,请稍后重试');
} finally {
hideLoading();
}
}
function cleanupOldTagData() {
// 清理全局变量,释放内存
if (window.currentPageTags) {
window.currentPageTags.length = 0;
window.currentPageTags = null;
}
// 清理DOM缓存
const tagsContainer = document.getElementById('tagsContainer');
if (tagsContainer) {
tagsContainer.innerHTML = '';
}
}
function displayTags(tags, hasMore = false) {
const tagList = document.getElementById('tagList');
const repoInfo = parseRepositoryInfo(currentRepo);
const { fullRepoName } = repoInfo;
let header = `
<div class="tag-header">
<div class="tag-info">
<div class="tag-title">
${fullRepoName}
${currentRepo.is_official ? '<span class="badge badge-official">官方</span>' : ''}
${currentRepo.affiliation ? `<span class="badge badge-organization">By ${currentRepo.affiliation}</span>` : ''}
</div>
<div class="tag-description">${currentRepo.short_description || '暂无描述'}</div>
<div class="tag-meta">
${currentRepo.star_count > 0 ? `<span class="meta-item">⭐ ${formatUtils.formatNumber(currentRepo.star_count)}</span>` : ''}
${currentRepo.pull_count > 0 ? `<span class="meta-item">⬇️ ${formatUtils.formatNumber(currentRepo.pull_count)}+</span>` : ''}
${currentRepo.last_updated ? `<span class="meta-item">更新于 ${formatUtils.formatTimeAgo(currentRepo.last_updated)}</span>` : ''}
</div>
<div class="tag-pull-command">
docker pull ${fullRepoName}
<button class="copy-button" onclick="copyToClipboard('docker pull ${fullRepoName}')">复制</button>
</div>
</div>
</div>
<div class="tag-search-container">
<input type="text" class="tag-search-input" placeholder="输入关键词搜索标签,支持模糊匹配..." oninput="filterTags(this.value)">
<button class="tag-search-clear" onclick="clearTagSearch()">×</button>
</div>
<div id="tagsContainer"></div>
<div class="pagination" id="tagPagination" style="display: none;">
<button id="tagPrevPage" disabled>上一页</button>
<button id="tagNextPage" disabled>下一页</button>
</div>
`;
tagList.innerHTML = header;
// 存储当前页标签数据
window.currentPageTags = tags;
renderFilteredTags(tags);
}
function renderFilteredTags(filteredTags) {
const tagsContainer = document.getElementById('tagsContainer');
const repoInfo = parseRepositoryInfo(currentRepo);
const { fullRepoName } = repoInfo;
if (filteredTags.length === 0) {
tagsContainer.innerHTML = '<div class="text-center" style="padding: 20px;">未找到匹配的标签</div>';
return;
}
// 渐进式渲染:分批处理大数据集
const BATCH_SIZE = 50;
if (filteredTags.length <= BATCH_SIZE) {
// 小数据集:直接渲染
renderTagsBatch(filteredTags, fullRepoName, tagsContainer, true);
} else {
// 大数据集:分批渲染
tagsContainer.innerHTML = ''; // 清空容器
let currentBatch = 0;
function renderNextBatch() {
const start = currentBatch * BATCH_SIZE;
const end = Math.min(start + BATCH_SIZE, filteredTags.length);
const batch = filteredTags.slice(start, end);
renderTagsBatch(batch, fullRepoName, tagsContainer, false);
currentBatch++;
if (end < filteredTags.length) {
// 使用requestAnimationFrame确保UI响应性
requestAnimationFrame(renderNextBatch);
}
}
renderNextBatch();
}
}
function renderTagsBatch(tags, fullRepoName, container, replaceContent = false) {
const tagsHtml = tags.map(tag => {
const vulnIndicators = Object.entries(tag.vulnerabilities || {})
.map(([level, count]) => count > 0 ? `<span class="vulnerability-dot vulnerability-${level.toLowerCase()}" title="${level}: ${count}"></span>` : '')
.join('');
const images = tag.images || [];
const architectures = images.map(img => {
const arch = `${img.os}/${img.architecture}${img.variant ? '/' + img.variant : ''}`;
const size = formatUtils.formatSize(img.size);
return `<div class="arch-item" title="大小: ${size}">${arch}</div>`;
}).join('');
return `
<div class="tag-item">
<div class="tag-name">
${tag.name}
${vulnIndicators ? `<div class="vulnerability-indicator">${vulnIndicators}</div>` : ''}
</div>
<div class="tag-meta">
<span>最后更新: ${formatUtils.formatTimeAgo(tag.last_updated)}</span>
${tag.last_pusher ? `<span>由 ${tag.last_pusher} 推送</span>` : ''}
${tag.full_size ? `<span>大小: ${formatUtils.formatSize(tag.full_size)}</span>` : ''}
</div>
<div class="tag-pull-command">
docker pull ${fullRepoName}:${tag.name}
<button class="copy-button" onclick="copyToClipboard('docker pull ${fullRepoName}:${tag.name}')">复制</button>
</div>
${architectures ? `<div class="tag-architectures">${architectures}</div>` : ''}
</div>
`;
}).join('');
if (replaceContent) {
container.innerHTML = tagsHtml;
} else {
container.insertAdjacentHTML('beforeend', tagsHtml);
}
}
function filterTags(searchText) {
if (!window.currentPageTags) return;
const searchLower = searchText.toLowerCase();
let filteredTags;
if (!searchText) {
filteredTags = window.currentPageTags;
} else {
const scoredTags = window.currentPageTags.map(tag => {
const name = tag.name.toLowerCase();
let score = 0;
if (name === searchLower) {
score += 100;
}
else if (name.startsWith(searchLower)) {
score += 50;
}
else if (name.includes(searchLower)) {
score += 30;
}
else if (searchLower.split(/\s+/).some(word => name.includes(word))) {
score += 10;
}
return { tag, score };
}).filter(item => item.score > 0);
scoredTags.sort((a, b) => b.score - a.score);
filteredTags = scoredTags.map(item => item.tag);
}
renderFilteredTags(filteredTags);
}
function clearTagSearch() {
const searchInput = document.querySelector('.tag-search-input');
if (searchInput) {
searchInput.value = '';
filterTags('');
}
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
showToast('已复制到剪贴板');
}).catch(() => {
showToast('复制失败');
});
}
const urlParams = new URLSearchParams(window.location.search);
const initialQuery = urlParams.get('q');
if (initialQuery) {
document.getElementById('searchInput').value = initialQuery;
performSearch();
}
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>
</body>
</html>