gate / static /templates /cache.html
harii66's picture
Upload 23 files
b4edbc0 verified
<div class="cache-page">
<div class="section-header">
<div>
<h2 style="font-size: 1.5em; color: var(--dark); margin: 0;">🗄️ 缓存管理面板</h2>
<p style="color: #64748b; margin-top: 5px;">查看和管理系统缓存状态</p>
</div>
<button id="refreshBtn" class="btn btn-primary">
🔄 刷新状态
</button>
</div>
<div id="cacheStats" class="cache-grid">
<div class="skeleton skeleton-card"></div>
<div class="skeleton skeleton-card"></div>
<div class="skeleton skeleton-card"></div>
<div class="skeleton skeleton-card"></div>
<div class="skeleton skeleton-card"></div>
</div>
<!-- EPG 缓存详情 -->
<div class="epg-cache-section" id="epgCacheSection" style="display: none;">
<h3 style="margin-bottom: 20px; color: var(--dark); font-size: 1.3em;">📅 EPG 缓存详情</h3>
<div id="epgCacheDetails" class="epg-cache-details"></div>
</div>
<div class="cache-actions">
<h3>🧹 清理缓存</h3>
<p style="color: #64748b; margin-bottom: 15px; font-size: 0.9em;">选择要清理的缓存类型,清理后将重新获取数据</p>
<div class="button-group">
<button class="btn btn-warning" onclick="clearCache('cid')">
🔑 清理 CID
</button>
<button class="btn btn-warning" onclick="clearCache('auth')">
🎫 清理认证
</button>
<button class="btn btn-warning" onclick="clearCache('channels')">
📺 清理频道
</button>
<button class="btn btn-warning" onclick="clearCache('streams')">
🎬 清理流
</button>
<button class="btn btn-warning" onclick="clearCache('epg')">
📅 清理 EPG
</button>
<button class="btn btn-danger" onclick="clearCache('all')">
🗑️ 清理全部
</button>
</div>
</div>
</div>
<!-- ✅ 节目详情弹窗 -->
<div class="epg-detail-modal" id="epgDetailModal">
<div class="epg-detail-container">
<div class="epg-detail-header">
<h3 id="epgDetailTitle">📺 节目详情</h3>
<button class="epg-detail-close" onclick="closeEpgDetail()"></button>
</div>
<div class="epg-detail-body">
<div id="epgDetailContent" class="epg-detail-content"></div>
</div>
</div>
</div>
<style>
/* 缓存卡片样式增强 */
.cache-card {
background: white;
padding: 20px;
border-radius: 15px;
border-left: 4px solid var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
contain: layout style paint;
}
.cache-card h3 {
color: var(--dark);
margin-bottom: 15px;
font-size: 1.1em;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.cache-detail {
display: flex;
flex-direction: column;
padding: 10px 0;
border-bottom: 1px solid var(--border);
font-size: 0.85em;
}
.cache-detail:last-child {
border-bottom: none;
}
.cache-label {
font-weight: 600;
color: #64748b;
margin-bottom: 5px;
font-size: 0.9em;
}
.cache-value {
font-family: 'Courier New', monospace;
color: var(--dark);
font-weight: 600;
word-break: break-all;
background: #f8fafc;
padding: 8px 10px;
border-radius: 6px;
font-size: 0.85em;
line-height: 1.5;
border: 1px solid #e2e8f0;
}
.cache-value.clickable {
cursor: pointer;
position: relative;
padding-right: 35px;
}
.cache-value.clickable:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
.copy-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: var(--primary);
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
transition: all 0.2s;
}
.copy-btn:hover {
background: var(--primary-dark);
}
.copy-btn:active {
transform: translateY(-50%) scale(0.95);
}
.cache-value-short {
color: #94a3b8;
font-style: italic;
}
/* EPG 缓存详情样式 */
.epg-cache-section {
background: white;
padding: 25px;
border-radius: 15px;
margin-bottom: 25px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.epg-cache-details {
display: grid;
gap: 20px;
}
.epg-summary-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}
.epg-summary-item {
text-align: center;
}
.epg-summary-item .label {
font-size: 0.85em;
opacity: 0.9;
margin-bottom: 5px;
}
.epg-summary-item .value {
font-size: 1.8em;
font-weight: 700;
}
.epg-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
}
.epg-detail-box {
background: #f8fafc;
padding: 15px;
border-radius: 10px;
border: 1px solid #e2e8f0;
}
.epg-detail-box h4 {
margin: 0 0 15px 0;
color: var(--dark);
font-size: 1em;
font-weight: 700;
}
.epg-list {
max-height: 300px;
overflow-y: auto;
}
.epg-list-item {
padding: 10px;
background: white;
border-radius: 8px;
margin-bottom: 8px;
font-size: 0.85em;
border: 1px solid #e2e8f0;
cursor: pointer;
transition: all 0.2s ease;
}
.epg-list-item:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2);
transform: translateX(4px);
}
.epg-list-item .channel-id {
font-weight: 700;
color: var(--primary);
margin-bottom: 5px;
}
.epg-list-item .info {
color: #64748b;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
/* ✅ 节目详情弹窗样式 */
.epg-detail-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(10px);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 20px;
overflow-y: auto;
}
.epg-detail-modal.show {
display: flex;
}
.epg-detail-container {
background: white;
border-radius: 20px;
max-width: 1000px;
width: 100%;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: modalSlideIn 0.3s ease;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.epg-detail-header {
padding: 20px 25px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
}
.epg-detail-header h3 {
margin: 0;
font-size: 1.4em;
font-weight: 700;
}
.epg-detail-close {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 1.5em;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.epg-detail-close:hover {
background: rgba(255, 255, 255, 0.3);
transform: rotate(90deg);
}
.epg-detail-body {
padding: 25px;
max-height: calc(90vh - 80px);
overflow-y: auto;
}
.epg-detail-content {
display: flex;
flex-direction: column;
gap: 15px;
}
.date-section {
background: #f8fafc;
padding: 15px;
border-radius: 10px;
border-left: 4px solid var(--primary);
}
.date-section h4 {
margin: 0 0 15px 0;
color: var(--primary);
font-size: 1.1em;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.program-item {
background: white;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
}
.program-item:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
}
.program-time {
font-weight: 700;
color: var(--primary);
margin-bottom: 5px;
font-size: 0.9em;
}
.program-title {
color: var(--dark);
font-weight: 600;
margin-bottom: 3px;
}
.program-desc {
color: #64748b;
font-size: 0.85em;
line-height: 1.4;
}
@media (max-width: 768px) {
.epg-detail-grid {
grid-template-columns: 1fr;
}
.epg-summary-box {
grid-template-columns: 1fr;
}
}
</style>
<script>
(function() {
'use strict';
const API = window.location.origin;
// ✅ 全局变量:频道映射
let channelMap = {};
// 防抖
const debounce = window.MediaGatewayUtils?.debounce || function(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
};
// 复制到剪贴板
function copyToClipboard(text, btnElement) {
navigator.clipboard.writeText(text).then(() => {
const originalText = btnElement.textContent;
btnElement.textContent = '✓';
btnElement.style.background = '#10b981';
setTimeout(() => {
btnElement.textContent = originalText;
btnElement.style.background = '';
}, 1500);
if (window.MediaGatewayUtils) {
window.MediaGatewayUtils.showNotification('✅ 已复制到剪贴板', 'success');
}
}).catch(err => {
console.error('复制失败:', err);
if (window.MediaGatewayUtils) {
window.MediaGatewayUtils.showNotification('❌ 复制失败', 'error');
}
});
}
// HTML 转义函数
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ✅ 创建可复制的值元素(支持显示部分但复制全部)
function createCopyableValue(value, label, showPartial = false) {
if (!value) return '<span class="cache-value-short">未缓存</span>';
const id = 'copy_' + Math.random().toString(36).substr(2, 9);
// ✅ 如果需要部分显示
let displayValue = value;
if (showPartial && value.length > 40) {
displayValue = value.substring(0, 20) + '...' + value.substring(value.length - 20);
}
return `
<div class="cache-value clickable" style="position: relative;">
<span id="${id}_text" data-full-value="${escapeHtml(value)}">${escapeHtml(displayValue)}</span>
<button class="copy-btn" onclick="window.copyCache('${id}_text')">📋 复制</button>
</div>
`;
}
// ✅ 全局复制函数(复制完整值)
window.copyCache = function(elementId) {
const element = document.getElementById(elementId);
if (element) {
// ✅ 优先使用 data-full-value 属性(完整值)
const fullValue = element.getAttribute('data-full-value');
const text = fullValue || element.textContent;
const btn = element.nextElementSibling;
copyToClipboard(text, btn);
}
};
// ✅ 加载频道列表
async function loadChannelMap() {
try {
const res = await fetch(`${API}/api/list`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.success) {
const channels = data.channels || [];
channelMap = {};
channels.forEach(ch => {
channelMap[ch.id] = ch.name;
});
console.log('✅ 频道映射加载完成:', Object.keys(channelMap).length, '个频道');
}
} catch (e) {
console.error('❌ 加载频道列表失败:', e);
}
}
// ✅ 获取频道名称
function getChannelName(channelId) {
return channelMap[channelId] || `频道 ${channelId}`;
}
// ✅ 格式化时间
function formatTime(timestamp) {
const d = new Date(timestamp * 1000);
return d.toLocaleTimeString('ja-JP', {
timeZone: 'Asia/Tokyo',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
// ✅ 打开节目详情弹窗
window.openEpgDetail = async function(channelId) {
console.log('📺 打开频道详情:', channelId);
const modal = document.getElementById('epgDetailModal');
const title = document.getElementById('epgDetailTitle');
const content = document.getElementById('epgDetailContent');
if (!modal || !content) return;
const channelName = getChannelName(channelId);
if (title) title.textContent = `📺 ${channelName} - 缓存节目`;
content.innerHTML = `
<div style="text-align: center; padding: 40px;">
<div class="loading-spinner-large"></div>
<p style="margin-top: 15px; color: #64748b;">加载中...</p>
</div>
`;
modal.classList.add('show');
try {
// 获取该频道的所有缓存数据
const res = await fetch(`${API}/health`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const epgDetail = data.cache.epg_detail;
if (!epgDetail || !epgDetail.by_channel || !epgDetail.by_channel[channelId]) {
content.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📅</div>
<div class="empty-state-text">该频道暂无缓存数据</div>
</div>
`;
return;
}
const channelData = epgDetail.by_channel[channelId];
const dates = channelData.dates.sort();
const dateDetails = await Promise.all(
dates.map(async (date) => {
try {
const epgRes = await fetch(`${API}/api/epg?vid=${channelId}&date=${date}`);
if (!epgRes.ok) return null;
const epgData = await epgRes.json();
if (!epgData.success) return null;
return {
date: date,
programs: epgData.epg || []
};
} catch (e) {
console.error(`获取 ${date} 数据失败:`, e);
return null;
}
})
);
// 渲染节目列表
let html = '';
dateDetails.filter(d => d !== null).forEach(dateData => {
const { date, programs } = dateData;
const programCount = programs.length;
html += `
<div class="date-section">
<h4>
📅 ${date}
<span style="font-size: 0.85em; opacity: 0.8; font-weight: 600;">
(${programCount} 个节目)
</span>
</h4>
`;
if (programs.length === 0) {
html += `<p style="color: #94a3b8; text-align: center; padding: 20px;">暂无节目数据</p>`;
} else {
programs.forEach(prog => {
const startTime = formatTime(prog.time);
const endTime = prog.time_end ? formatTime(prog.time_end) : '未知';
const title = prog.title || prog.name || '未知节目';
const desc = prog.description || '';
html += `
<div class="program-item">
<div class="program-time">⏰ ${startTime} - ${endTime}</div>
<div class="program-title">${escapeHtml(title)}</div>
${desc ? `<div class="program-desc">📝 ${escapeHtml(desc)}</div>` : ''}
</div>
`;
});
}
html += `</div>`;
});
if (html === '') {
html = `
<div class="empty-state">
<div class="empty-state-icon">📅</div>
<div class="empty-state-text">暂无节目数据</div>
</div>
`;
}
content.innerHTML = html;
} catch (e) {
console.error('加载失败:', e);
content.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon" style="color: var(--danger);">❌</div>
<div class="empty-state-text">加载失败</div>
<div class="empty-state-subtitle">${e.message}</div>
</div>
`;
}
};
// ✅ 关闭节目详情弹窗
window.closeEpgDetail = function() {
const modal = document.getElementById('epgDetailModal');
if (modal) modal.classList.remove('show');
};
// 渲染 EPG 缓存详情
function renderEPGDetails(epgDetail) {
const section = document.getElementById('epgCacheSection');
const details = document.getElementById('epgCacheDetails');
if (!epgDetail || epgDetail.total_entries === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
let html = '';
// 摘要信息
if (epgDetail.summary) {
html += `
<div class="epg-summary-box">
<div class="epg-summary-item">
<div class="label">📦 缓存条目</div>
<div class="value">${epgDetail.total_entries}</div>
</div>
<div class="epg-summary-item">
<div class="label">📺 频道数</div>
<div class="value">${epgDetail.summary.total_channels}</div>
</div>
<div class="epg-summary-item">
<div class="label">📅 日期数</div>
<div class="value">${epgDetail.summary.total_dates}</div>
</div>
<div class="epg-summary-item">
<div class="label">🎬 节目数</div>
<div class="value">${epgDetail.summary.total_programs}</div>
</div>
</div>
`;
}
// 全量缓存状态
if (epgDetail.full_cache_available) {
html += `
<div style="background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); padding: 15px; border-radius: 10px; margin-top: 20px; border: 2px solid #6ee7b7;">
<div style="display: flex; align-items: center; gap: 10px; color: #065f46;">
<span style="font-size: 1.5em;">✅</span>
<div>
<div style="font-weight: 700; margin-bottom: 5px;">全量缓存已就绪</div>
<div style="font-size: 0.85em; opacity: 0.9;">
缓存时间: ${epgDetail.full_cache_time} (${epgDetail.full_cache_age} 前)
</div>
</div>
</div>
</div>
`;
}
// 详细信息
html += '<div class="epg-detail-grid">';
// 按频道统计
if (epgDetail.by_channel && Object.keys(epgDetail.by_channel).length > 0) {
html += `
<div class="epg-detail-box">
<h4>📺 按频道统计 (前10个)</h4>
<div class="epg-list">
`;
const channels = Object.entries(epgDetail.by_channel)
.sort((a, b) => b[1].program_count - a[1].program_count)
.slice(0, 10);
channels.forEach(([channelId, info]) => {
// ✅ 使用频道名称
const channelName = getChannelName(channelId);
html += `
<div class="epg-list-item" onclick="openEpgDetail('${channelId}')">
<div class="channel-id">${channelName}</div>
<div class="info">
<span>📅 ${info.dates.length} 个日期</span>
<span>🎬 ${info.program_count} 个节目</span>
</div>
</div>
`;
});
html += `
</div>
</div>
`;
}
// 按日期统计
if (epgDetail.by_date && Object.keys(epgDetail.by_date).length > 0) {
html += `
<div class="epg-detail-box">
<h4>📅 按日期统计</h4>
<div class="epg-list">
`;
const dates = Object.entries(epgDetail.by_date)
.sort((a, b) => b[0].localeCompare(a[0]));
dates.forEach(([date, info]) => {
html += `
<div class="epg-list-item">
<div class="channel-id">${date}</div>
<div class="info">
<span>📺 ${info.channels.length} 个频道</span>
<span>🎬 ${info.program_count} 个节目</span>
</div>
</div>
`;
});
html += `
</div>
</div>
`;
}
html += '</div>';
details.innerHTML = html;
}
async function load() {
const stats = document.getElementById('cacheStats');
if (!stats) return;
stats.innerHTML = `
<div class="skeleton skeleton-card"></div>
<div class="skeleton skeleton-card"></div>
<div class="skeleton skeleton-card"></div>
<div class="skeleton skeleton-card"></div>
<div class="skeleton skeleton-card"></div>
`;
// ✅ 加载频道映射
await loadChannelMap();
try {
const token = sessionStorage.getItem('admin_token');
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API}/health`, { headers });
const data = await res.json();
if (!data.cache) throw new Error('无法获取缓存信息');
const cache = data.cache;
const cacheCards = [
{
icon: '🔑',
title: 'CID 缓存',
color: '#6366f1',
data: cache.cid
},
{
icon: '🎫',
title: '认证缓存',
color: '#8b5cf6',
data: cache.auth
},
{
icon: '📺',
title: '频道缓存',
color: '#ec4899',
data: { cached: cache.channels }
},
{
icon: '🎬',
title: '流缓存',
color: '#f59e0b',
data: { cached: cache.streams > 0, count: cache.streams }
},
{
icon: '📅',
title: 'EPG 缓存',
color: '#10b981',
data: { cached: cache.epg > 0, count: cache.epg }
}
];
stats.innerHTML = cacheCards.map((card) => {
let content = '';
if (card.data.cached !== undefined) {
// 状态
content += `
<div class="cache-detail">
<span class="cache-label">状态</span>
<span class="cache-value">${card.data.cached ? '✅ 已缓存' : '❌ 未缓存'}</span>
</div>
`;
// CID 完整值
if (card.data.cached && card.data.value) {
content += `
<div class="cache-detail">
<span class="cache-label">完整 CID</span>
${createCopyableValue(card.data.value, 'CID', false)}
</div>
`;
}
// ✅ Access Token 完整值(显示部分但复制全部)
if (card.data.cached && card.data.token) {
content += `
<div class="cache-detail">
<span class="cache-label">Access Token</span>
${createCopyableValue(card.data.token, 'Token', true)}
</div>
`;
}
// 年龄
if (card.data.age) {
content += `
<div class="cache-detail">
<span class="cache-label">缓存时长</span>
<span class="cache-value">${card.data.age}</span>
</div>
`;
}
// 剩余时间
if (card.data.ttl) {
content += `
<div class="cache-detail">
<span class="cache-label">剩余有效期</span>
<span class="cache-value">${card.data.ttl}</span>
</div>
`;
}
// 存储位置
if (card.data.storage) {
content += `
<div class="cache-detail">
<span class="cache-label">存储位置</span>
<span class="cache-value">${card.data.storage}</span>
</div>
`;
}
// 数量
if (card.data.count !== undefined) {
content += `
<div class="cache-detail">
<span class="cache-label">缓存数量</span>
<span class="cache-value">${card.data.count} 个</span>
</div>
`;
}
}
return `
<div class="cache-card" style="border-left-color: ${card.color};">
<h3>${card.icon} ${card.title}</h3>
${content}
</div>
`;
}).join('');
// 渲染 EPG 缓存详情
if (cache.epg_detail) {
renderEPGDetails(cache.epg_detail);
}
if (window.MediaGatewayUtils) {
window.MediaGatewayUtils.showNotification('✅ 缓存状态已更新', 'success');
}
} catch (e) {
stats.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; padding: 60px 30px; color: var(--danger);">
<div style="font-size: 60px; margin-bottom: 20px;">❌</div>
<h3>加载失败</h3>
<p style="color: #64748b; margin-top: 10px;">${e.message}</p>
</div>
`;
if (window.MediaGatewayUtils) {
window.MediaGatewayUtils.showNotification('❌ 加载失败', 'error');
}
}
}
window.clearCache = debounce(async function(type) {
const typeNames = {
'cid': 'CID',
'auth': '认证',
'channels': '频道',
'streams': '流',
'epg': 'EPG',
'all': '所有'
};
const msg = type === 'all'
? '⚠️ 确定要清理所有缓存吗?这将导致所有数据重新获取。'
: `确定要清理 ${typeNames[type]} 缓存吗?`;
if (!confirm(msg)) return;
try {
const token = sessionStorage.getItem('admin_token');
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API}/api/refresh?type=${type}`, { headers });
const data = await res.json();
if (!data.success) throw new Error(data.error || '清理失败');
if (window.MediaGatewayUtils) {
window.MediaGatewayUtils.showNotification(`✅ ${data.message}`, 'success');
}
await load();
} catch (e) {
if (window.MediaGatewayUtils) {
window.MediaGatewayUtils.showNotification('❌ 清理失败: ' + e.message, 'error');
}
}
}, 300);
window.initCachePage = function() {
load();
const btn = document.getElementById('refreshBtn');
if (btn) btn.addEventListener('click', debounce(load, 300));
// ✅ ESC 键关闭弹窗
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const modal = document.getElementById('epgDetailModal');
if (modal && modal.classList.contains('show')) {
closeEpgDetail();
}
}
});
// ✅ 点击遮罩关闭弹窗
const modal = document.getElementById('epgDetailModal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeEpgDetail();
}
});
}
};
setTimeout(window.initCachePage, 0);
})();
</script>