Files
JieXi/templates/index.html
2025-11-30 19:49:25 +08:00

668 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>短视频解析平台 - 免费高清无水印</title>
<link rel="stylesheet" href="/static/css/ui-components.css?v=3">
<style>
body {
background: linear-gradient(135deg, #f0f9ff 0%, #e0e7ff 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.hero-container {
width: 100%;
max-width: 700px;
text-align: center;
margin-bottom: 2rem;
animation: fadeIn 0.8s ease-out;
}
.logo-text {
font-size: 3rem;
font-weight: 800;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
letter-spacing: -0.05em;
}
.subtitle {
font-size: 1.125rem;
color: var(--secondary-500);
margin-bottom: 2rem;
}
.main-card {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
border-radius: 24px;
padding: 3rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
width: 100%;
max-width: 700px;
border: 1px solid rgba(255, 255, 255, 0.5);
animation: slideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.input-wrapper {
position: relative;
margin-bottom: 1.5rem;
}
.url-input {
width: 100%;
padding: 1.25rem 1.5rem;
font-size: 1rem;
border: 2px solid transparent;
border-radius: 16px;
background: #f1f5f9;
transition: all 0.3s ease;
}
.url-input:focus {
outline: none;
background: white;
border-color: var(--primary-500);
box-shadow: 0 0 0 4px var(--primary-100);
}
.action-btn {
width: 100%;
padding: 1.25rem;
font-size: 1.125rem;
font-weight: 600;
border-radius: 16px;
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
color: white;
border: none;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 10px 20px -5px rgba(79, 70, 229, 0.4);
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 15px 30px -5px rgba(79, 70, 229, 0.5);
}
.action-btn:active {
transform: scale(0.98);
}
.action-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.result-card {
margin-top: 2rem;
background: white;
border-radius: 20px;
overflow: hidden;
display: none;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
animation: fadeIn 0.5s ease;
}
.result-card.show {
display: block;
}
.video-preview {
width: 100%;
aspect-ratio: 16/9;
background: black;
object-fit: contain;
}
.result-content {
padding: 1.5rem;
}
.video-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--secondary-900);
margin-bottom: 0.5rem;
line-height: 1.4;
}
.video-meta {
font-size: 0.875rem;
color: var(--secondary-500);
margin-bottom: 1.5rem;
}
.download-actions {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
}
@media (max-width: 640px) {
.download-actions {
grid-template-columns: 1fr;
}
}
.nav-links {
margin-top: 2rem;
display: flex;
gap: 1.5rem;
justify-content: center;
}
.nav-link {
color: var(--secondary-500);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: color 0.2s;
}
.nav-link:hover {
color: var(--primary-600);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s linear infinite;
display: none;
margin: 0 auto;
}
.btn-text { display: inline; }
.action-btn.loading .btn-text { display: none; }
.action-btn.loading .loading-spinner { display: block; }
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 输入框按钮组 */
.input-group {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.input-group .url-input {
flex: 1;
margin-bottom: 0;
}
.input-btn {
padding: 0 1.25rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 12px;
border: 2px solid var(--primary-200);
background: white;
color: var(--primary-600);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.input-btn:hover {
background: var(--primary-50);
border-color: var(--primary-400);
}
.input-btn:active {
transform: scale(0.96);
}
/* 进度条 */
.progress-container {
display: none;
margin-top: 1.5rem;
}
.progress-container.show {
display: block;
}
.progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--secondary-600);
}
.progress-bar {
height: 8px;
background: var(--secondary-100);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 100%);
border-radius: 4px;
width: 0%;
transition: width 0.3s ease;
}
</style>
</head>
<body>
{% if config.get('site_notice') %}
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); padding: 1rem; text-align: center; border-radius: 12px; margin: 1rem auto; max-width: 700px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<div style="color: #92400e; font-weight: 500; margin: 0;">{{ config.get('site_notice') | safe }}</div>
</div>
{% endif %}
<div class="hero-container">
<h1 class="logo-text">{{ config.get('home_title', 'JieXi Pro') }}</h1>
<p class="subtitle">{{ config.get('home_subtitle', '新一代全能短视频去水印解析工具') }}</p>
</div>
<div class="main-card">
<div class="input-wrapper">
<div class="input-group">
<input type="text" class="url-input" id="videoUrl" placeholder="在此粘贴 抖音/TikTok/B站/快手/皮皮虾/微博 视频链接...">
<button class="input-btn" onclick="pasteUrl()">粘贴</button>
<button class="input-btn" onclick="clearAll()">清空</button>
</div>
</div>
<button class="action-btn" onclick="parseVideo()" id="parseBtn">
<span class="btn-text">立即解析</span>
<div class="loading-spinner"></div>
</button>
<div class="progress-container" id="progressContainer">
<div class="progress-label">
<span id="progressText">正在解析...</span>
<span id="progressPercent">0%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
<div id="result" class="result-card">
<video id="videoPlayer" class="video-preview" controls></video>
<div class="result-content">
<h3 class="video-title" id="title"></h3>
<p class="video-meta" id="description"></p>
<div class="download-actions">
<button class="ui-btn ui-btn-primary" onclick="downloadVideo()">
下载视频
</button>
<button class="ui-btn ui-btn-primary" onclick="downloadCover()">
下载封面
</button>
<button class="ui-btn ui-btn-secondary" onclick="copyLink()">
复制链接
</button>
</div>
<input type="hidden" id="videoLink" value="">
<a id="coverLink" href="" style="display: none;"></a>
</div>
</div>
</div>
<div class="nav-links" id="navLinks">
<a href="/api-docs" class="nav-link">API 文档</a>
<a href="/auth/login" class="nav-link">登录账号</a>
<a href="/auth/register" class="nav-link">注册新用户</a>
</div>
{% if config.get('site_footer') %}
<footer style="margin-top: 3rem; text-align: center; color: var(--secondary-500); font-size: 0.875rem; line-height: 1.8;">
{{ config.get('site_footer') | safe }}
</footer>
{% endif %}
<script src="/static/js/ui-components.js"></script>
<script>
// 粘贴按钮
async function pasteUrl() {
try {
const text = await navigator.clipboard.readText();
document.getElementById('videoUrl').value = text;
UI.notify('已粘贴', 'success');
} catch (e) {
UI.notify('无法访问剪贴板,请手动粘贴', 'warning');
}
}
// 清空按钮
function clearAll() {
document.getElementById('videoUrl').value = '';
document.getElementById('result').classList.remove('show');
document.getElementById('progressContainer').classList.remove('show');
document.getElementById('videoPlayer').src = '';
document.getElementById('videoPlayer').poster = '';
UI.notify('已清空', 'info');
}
// 进度条控制
let progressInterval = null;
function startProgress() {
const container = document.getElementById('progressContainer');
const fill = document.getElementById('progressFill');
const text = document.getElementById('progressText');
const percent = document.getElementById('progressPercent');
container.classList.add('show');
let progress = 0;
const steps = [
{ p: 15, t: '正在连接服务器...' },
{ p: 35, t: '正在解析链接...' },
{ p: 55, t: '正在获取视频信息...' },
{ p: 75, t: '正在提取无水印地址...' },
{ p: 90, t: '即将完成...' }
];
let stepIndex = 0;
progressInterval = setInterval(() => {
if (stepIndex < steps.length && progress >= steps[stepIndex].p - 10) {
text.textContent = steps[stepIndex].t;
stepIndex++;
}
if (progress < 90) {
progress += Math.random() * 8 + 2;
if (progress > 90) progress = 90;
fill.style.width = progress + '%';
percent.textContent = Math.round(progress) + '%';
}
}, 300);
}
function completeProgress() {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
const fill = document.getElementById('progressFill');
const text = document.getElementById('progressText');
const percent = document.getElementById('progressPercent');
fill.style.width = '100%';
percent.textContent = '100%';
text.textContent = '解析完成!';
setTimeout(() => {
document.getElementById('progressContainer').classList.remove('show');
fill.style.width = '0%';
}, 500);
}
function resetProgress() {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
const container = document.getElementById('progressContainer');
const fill = document.getElementById('progressFill');
container.classList.remove('show');
fill.style.width = '0%';
}
function extractVideoUrl(text) {
const patterns = [
/https?:\/\/v\.douyin\.com\/[A-Za-z0-9_-]+/,
/https?:\/\/www\.douyin\.com\/video\/\d+/,
/https?:\/\/(?:www\.)?bilibili\.com\/video\/[A-Za-z0-9?&=]+/,
/https?:\/\/b23\.tv\/[A-Za-z0-9]+/,
/https?:\/\/(?:www\.)?tiktok\.com\/@[^\/]+\/video\/\d+/,
/https?:\/\/(?:www\.)?kuaishou\.com\/[A-Za-z0-9\/_-]+/,
/https?:\/\/h5\.pipix\.com\/s\/[A-Za-z0-9_-]+\/?/,
/https?:\/\/(?:www\.)?pipix\.com\/[A-Za-z0-9\/_-]+/,
/https?:\/\/(?:video\.)?weibo\.com\/show\?fid=[0-9:]+/,
/https?:\/\/(?:www\.)?weibo\.com\/[A-Za-z0-9\/]+/
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) return match[0];
}
return text.trim();
}
async function parseVideo() {
const input = document.getElementById('videoUrl');
const btn = document.getElementById('parseBtn');
const resultCard = document.getElementById('result');
const rawUrl = input.value.trim();
if (!rawUrl) {
UI.notify('请先粘贴视频链接', 'warning');
return;
}
const url = extractVideoUrl(rawUrl);
if (!url) {
UI.notify('无法识别有效的视频链接', 'error');
return;
}
// Reset UI
resultCard.classList.remove('show');
btn.disabled = true;
btn.classList.add('loading');
startProgress();
try {
const response = await fetch('/api/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url })
});
const data = await response.json();
if (data.success) {
if (data.status === 'completed') {
showResult(data.data);
} else if (data.status === 'queued') {
pollTaskResult(data.task_id);
}
} else {
throw new Error(data.message || '解析失败');
}
} catch (error) {
UI.notify(error.message, 'error');
btn.disabled = false;
btn.classList.remove('loading');
resetProgress();
}
}
async function pollTaskResult(taskId) {
const maxAttempts = 30;
let attempts = 0;
const btn = document.getElementById('parseBtn');
const poll = async () => {
if (attempts >= maxAttempts) {
UI.notify('解析超时,请稍后重试', 'error');
btn.disabled = false;
btn.classList.remove('loading');
resetProgress();
return;
}
try {
const response = await fetch(`/api/task/${taskId}`);
const data = await response.json();
if (data.status === 'completed') {
showResult(data.data);
} else if (data.status === 'failed') {
throw new Error(data.message || '解析失败');
} else {
attempts++;
setTimeout(poll, 2000);
}
} catch (error) {
UI.notify(error.message, 'error');
btn.disabled = false;
btn.classList.remove('loading');
resetProgress();
}
};
poll();
}
function showResult(data) {
const btn = document.getElementById('parseBtn');
const resultCard = document.getElementById('result');
completeProgress();
document.getElementById('title').textContent = data.title || '无标题';
document.getElementById('description').textContent = data.description || '';
const player = document.getElementById('videoPlayer');
// 先停止并清空当前视频
player.pause();
player.removeAttribute('src');
player.load();
// 使用代理播放视频,绕过防盗链
const proxyVideoUrl = '/proxy/download?url=' + encodeURIComponent(data.video_url);
player.src = proxyVideoUrl;
// 封面也使用代理
if (data.cover) {
const proxyCoverUrl = '/proxy/download?url=' + encodeURIComponent(data.cover);
player.poster = proxyCoverUrl;
}
// 强制重新加载视频
player.load();
document.getElementById('videoLink').value = data.video_url;
document.getElementById('coverLink').href = data.cover;
resultCard.classList.add('show');
btn.disabled = false;
btn.classList.remove('loading');
UI.notify('解析成功', 'success');
}
function downloadVideo() {
const videoUrl = document.getElementById('videoLink').value;
if (!videoUrl) {
UI.notify('视频链接无效', 'error');
return;
}
// 使用代理下载
const proxyUrl = '/proxy/download?url=' + encodeURIComponent(videoUrl);
window.open(proxyUrl, '_blank');
}
async function downloadCover() {
const coverUrl = document.getElementById('coverLink').href;
if (!coverUrl) {
UI.notify('封面链接无效', 'error');
return;
}
try {
UI.notify('正在下载封面...', 'info');
// 使用 fetch 下载,设置 no-referrer 策略
const response = await fetch(coverUrl, {
method: 'GET',
referrerPolicy: 'no-referrer'
});
if (!response.ok) {
throw new Error('下载失败');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'cover_' + Date.now() + '.jpg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
UI.notify('封面下载成功', 'success');
} catch (error) {
UI.notify('下载失败: ' + error.message, 'error');
}
}
function copyLink() {
const url = document.getElementById('videoLink').href;
navigator.clipboard.writeText(url).then(() => {
UI.notify('链接已复制', 'success');
}).catch(() => {
UI.notify('复制失败', 'error');
});
}
document.getElementById('videoUrl').addEventListener('keypress', (e) => {
if (e.key === 'Enter') parseVideo();
});
// 检查登录状态并更新导航
async function checkLoginStatus() {
try {
const response = await fetch('/auth/user-info');
if (response.ok) {
const data = await response.json();
if (data.success) {
document.getElementById('navLinks').innerHTML = `
<a href="/api-docs" class="nav-link">API 文档</a>
<a href="/auth/profile" class="nav-link">个人中心</a>
<a href="#" class="nav-link" onclick="logout(); return false;">退出登录</a>
`;
}
}
} catch (e) {
// 未登录,保持默认导航
}
}
async function logout() {
try {
await fetch('/auth/logout', { method: 'POST' });
UI.notify('已退出登录', 'success');
setTimeout(() => location.reload(), 1000);
} catch (e) {
UI.notify('退出失败', 'error');
}
}
checkLoginStatus();
</script>
</body>
</html>