Files
xdl/frontend/src/views/Admin.vue

534 lines
23 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="admin">
<!-- Tab switcher -->
<div class="tabs">
<button :class="{ active: tab === 'videos' }" @click="tab = 'videos'">📹 Video Library</button>
<button :class="{ active: tab === 'logs' }" @click="tab = 'logs'; fetchLogs()">📋 Download Logs</button>
<button :class="{ active: tab === 'settings' }" @click="tab = 'settings'; fetchCleanup()"> Settings</button>
<div class="stats" v-if="stats">📊 {{ stats.total_videos }} videos · {{ stats.total_size_human }}</div>
</div>
<!-- Video Library -->
<template v-if="tab === 'videos'">
<div class="search-bar">
<input v-model="search" placeholder="Search videos..." @input="debouncedFetch" />
<select v-model="filterStatus" @change="fetchVideos">
<option value="">All Status</option>
<option value="done">Done</option>
<option value="downloading">Downloading</option>
<option value="error">Error</option>
<option value="pending">Pending</option>
</select>
</div>
<div class="video-list">
<div v-for="v in videos" :key="v.id" class="video-card">
<div class="video-main">
<img v-if="v.thumbnail" :src="v.thumbnail" class="thumb" />
<div class="info">
<h4>{{ v.title || 'Untitled' }}</h4>
<div class="meta">
<span :class="'status-' + v.status">{{ v.status }}</span>
<span>{{ v.platform }}</span>
<span>{{ v.quality }}</span>
<span>{{ humanSize(v.file_size) }}</span>
<span>{{ fmtTime(v.created_at) }}</span>
</div>
</div>
</div>
<div class="actions">
<button v-if="v.status === 'done'" @click="showLogs(v)" class="btn-log" title="Download logs">📋</button>
<button v-if="v.status === 'done'" @click="playVideo(v)" class="btn-play"> Play</button>
<a v-if="v.status === 'done'" :href="'/api/file/' + v.id" class="btn-dl"
:download="v.filename" @click.prevent="downloadAuth(v)">💾</a>
<button @click="deleteVideo(v)" class="btn-del">🗑</button>
</div>
</div>
<div v-if="!videos.length" class="empty">No videos found</div>
</div>
<div v-if="totalPages > 1" class="pagination">
<button :disabled="page <= 1" @click="page--; fetchVideos()">Prev</button>
<span>{{ page }} / {{ totalPages }}</span>
<button :disabled="page >= totalPages" @click="page++; fetchVideos()">Next</button>
</div>
</template>
<!-- Download Logs -->
<template v-if="tab === 'logs'">
<div class="log-filter">
<input v-model="logSearch" placeholder="Filter by IP or video title..." @input="debouncedLogs" />
<button v-if="logVideoFilter" @click="clearLogFilter" class="clear-btn"> {{ logVideoTitle }}</button>
</div>
<div class="log-table-wrap">
<table class="log-table">
<thead>
<tr>
<th>Time</th>
<th>IP</th>
<th>Browser</th>
<th>Device</th>
<th>Video</th>
<th>Location</th>
</tr>
</thead>
<tbody>
<tr v-for="l in filteredLogs" :key="l.id">
<td class="td-time">{{ fmtTime(l.downloaded_at) }}</td>
<td class="td-ip">{{ l.ip }}</td>
<td class="td-browser">{{ browserIcon(l.browser) }} {{ l.browser }}</td>
<td class="td-device">{{ deviceIcon(l.device) }} {{ l.device }}</td>
<td class="td-video">
<span class="platform-badge" :class="'plat-' + l.video_platform">{{ l.video_platform }}</span>
{{ l.video_title || `#${l.video_id}` }}
</td>
<td class="td-location">
<span v-if="l.country_code" class="flag">{{ countryFlag(l.country_code) }}</span>
<span class="location-text">{{ [l.city, l.country].filter(Boolean).join(', ') || '—' }}</span>
</td>
</tr>
</tbody>
</table>
<div v-if="!filteredLogs.length" class="empty">No logs found</div>
</div>
<div v-if="logTotalPages > 1" class="pagination">
<button :disabled="logPage <= 1" @click="logPage--; fetchLogs()">Prev</button>
<span>{{ logPage }} / {{ logTotalPages }}</span>
<button :disabled="logPage >= logTotalPages" @click="logPage++; fetchLogs()">Next</button>
</div>
</template>
<!-- Settings -->
<template v-if="tab === 'settings'">
<div class="settings-card">
<h3>🗑 Auto Cleanup</h3>
<p class="settings-desc">Automatically delete downloaded videos based on age or disk usage.</p>
<div class="setting-row">
<label>Enable auto cleanup</label>
<label class="toggle">
<input type="checkbox" v-model="cleanup.enabled" />
<span class="slider"></span>
</label>
</div>
<div class="setting-row">
<label>Retention period</label>
<div class="retention-input">
<input type="number" v-model.number="retentionValue" min="1" class="num-input" />
<select v-model="retentionUnit" class="unit-select">
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
</select>
<span class="minutes-hint">= {{ cleanupMinutes.toLocaleString() }} min</span>
</div>
</div>
<div class="setting-row">
<label>Storage limit</label>
<div class="storage-limit-input">
<input type="number" v-model.number="cleanup.storage_limit_pct" min="10" max="95" class="num-input" />
<span>%</span>
<span class="hint">of total disk</span>
</div>
</div>
<!-- Disk usage bar -->
<div class="disk-section" v-if="cleanupStatus">
<div class="disk-header">
<span>Disk Usage</span>
<span :class="diskClass">{{ cleanupStatus.disk.used_pct }}%</span>
</div>
<div class="disk-bar">
<div class="disk-fill" :style="{ width: cleanupStatus.disk.used_pct + '%' }" :class="diskClass"></div>
<div class="disk-limit-line" :style="{ left: cleanup.storage_limit_pct + '%' }" title="Storage limit"></div>
</div>
<div class="disk-labels">
<span>{{ humanSize(cleanupStatus.disk.used) }} used</span>
<span>{{ humanSize(cleanupStatus.disk.free) }} free</span>
<span>{{ humanSize(cleanupStatus.disk.total) }} total</span>
</div>
</div>
<!-- Last run info -->
<div class="last-run" v-if="cleanupStatus?.last_run">
<span class="last-run-label">Last run:</span>
{{ fmtTime(cleanupStatus.last_run) }}
<span v-if="cleanupStatus.last_result?.time_deleted !== undefined" class="run-result">
deleted {{ cleanupStatus.last_result.time_deleted + (cleanupStatus.last_result.storage_deleted || 0) }} videos,
freed {{ cleanupStatus.last_result.freed_mb }} MB
</span>
</div>
<div class="last-run" v-else-if="cleanupStatus">Never run yet</div>
<div class="settings-actions">
<button class="btn-save" @click="saveCleanup" :disabled="saving">
{{ saving ? '⏳ Saving...' : '💾 Save Settings' }}
</button>
<button class="btn-run" @click="runCleanup" :disabled="running">
{{ running ? '⏳ Running...' : '🗑 Run Now' }}
</button>
</div>
<div v-if="settingsMsg" :class="['settings-msg', settingsMsgType]">{{ settingsMsg }}</div>
</div>
</template>
<!-- Video Player Modal -->
<div v-if="playing" class="modal" @click.self="playing = null">
<div class="player-wrap">
<video :src="playUrl" controls autoplay class="player"></video>
<button @click="playing = null" class="close-btn"></button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import axios from 'axios'
import { useAuthStore } from '../stores/auth.js'
const auth = useAuthStore()
const tab = ref('videos')
// ── Videos ──
const videos = ref([])
const stats = ref(null)
const search = ref('')
const filterStatus = ref('')
const page = ref(1)
const total = ref(0)
const pageSize = 20
const playing = ref(null)
const playUrl = ref('')
const totalPages = computed(() => Math.ceil(total.value / pageSize) || 1)
// ── Settings / Cleanup ──
const cleanupStatus = ref(null)
const cleanup = ref({ enabled: true, retention_minutes: 10080, storage_limit_pct: 80 })
const retentionValue = ref(7)
const retentionUnit = ref('days')
const saving = ref(false)
const running = ref(false)
const settingsMsg = ref('')
const settingsMsgType = ref('ok')
const cleanupMinutes = computed(() => {
const m = { hours: 60, days: 1440, weeks: 10080, months: 43200 }
return retentionValue.value * (m[retentionUnit.value] || 1440)
})
const diskClass = computed(() => {
const pct = cleanupStatus.value?.disk?.used_pct || 0
if (pct >= 90) return 'disk-danger'
if (pct >= 75) return 'disk-warn'
return 'disk-ok'
})
function minutesToUnit(min) {
if (min % 43200 === 0) return { value: min / 43200, unit: 'months' }
if (min % 10080 === 0) return { value: min / 10080, unit: 'weeks' }
if (min % 1440 === 0) return { value: min / 1440, unit: 'days' }
return { value: min / 60, unit: 'hours' }
}
async function fetchCleanup() {
try {
const res = await axios.get('/api/admin/settings/cleanup', { headers: auth.getHeaders() })
cleanupStatus.value = res.data
cleanup.value = { ...res.data.config }
const { value, unit } = minutesToUnit(res.data.config.retention_minutes)
retentionValue.value = value
retentionUnit.value = unit
} catch {}
}
async function saveCleanup() {
saving.value = true; settingsMsg.value = ''
try {
cleanup.value.retention_minutes = cleanupMinutes.value
const res = await axios.put('/api/admin/settings/cleanup', cleanup.value, { headers: auth.getHeaders() })
cleanupStatus.value = res.data
settingsMsg.value = '✅ Settings saved'
settingsMsgType.value = 'ok'
} catch { settingsMsg.value = '❌ Save failed'; settingsMsgType.value = 'err' }
finally { saving.value = false; setTimeout(() => settingsMsg.value = '', 3000) }
}
async function runCleanup() {
running.value = true; settingsMsg.value = ''
try {
const res = await axios.post('/api/admin/cleanup/run', {}, { headers: auth.getHeaders() })
const r = res.data
if (r.skipped) {
settingsMsg.value = '⚠️ Cleanup is disabled'
settingsMsgType.value = 'warn'
} else {
settingsMsg.value = `✅ Done — deleted ${(r.time_deleted||0) + (r.storage_deleted||0)} videos, freed ${r.freed_mb} MB`
settingsMsgType.value = 'ok'
}
await fetchCleanup()
} catch { settingsMsg.value = '❌ Run failed'; settingsMsgType.value = 'err' }
finally { running.value = false }
}
// ── Logs ──
const logs = ref([])
const logSearch = ref('')
const logPage = ref(1)
const logTotal = ref(0)
const logPageSize = 50
const logVideoFilter = ref(null)
const logVideoTitle = ref('')
const logTotalPages = computed(() => Math.ceil(logTotal.value / logPageSize) || 1)
const filteredLogs = computed(() => {
if (!logSearch.value) return logs.value
const q = logSearch.value.toLowerCase()
return logs.value.filter(l =>
l.ip.includes(q) || l.video_title.toLowerCase().includes(q) ||
l.browser.toLowerCase().includes(q) || l.device.toLowerCase().includes(q)
)
})
// ── Helpers ──
function humanSize(bytes) {
if (!bytes) return '0 B'
for (const u of ['B', 'KB', 'MB', 'GB']) {
if (bytes < 1024) return `${bytes.toFixed(1)} ${u}`
bytes /= 1024
}
return `${bytes.toFixed(1)} TB`
}
function fmtTime(ts) {
if (!ts) return ''
return new Date(ts).toLocaleString('zh-CN', { hour12: false })
}
function browserIcon(b) {
return { Chrome: '🌐', Firefox: '🦊', Safari: '🧭', Edge: '🔷', Opera: '🔴', Samsung: '📱' }[b] || '🌐'
}
function deviceIcon(d) {
return { mobile: '📱', tablet: '📟', desktop: '💻', bot: '🤖' }[d] || '💻'
}
function countryFlag(code) {
if (!code || code.length !== 2) return ''
// Convert country code to regional indicator emoji
return [...code.toUpperCase()].map(c => String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65)).join('')
}
// ── Video methods ──
let debounceTimer
function debouncedFetch() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => { page.value = 1; fetchVideos() }, 300)
}
async function fetchVideos() {
try {
const res = await axios.get('/api/admin/videos', {
params: { page: page.value, page_size: pageSize, search: search.value, status: filterStatus.value },
headers: auth.getHeaders()
})
videos.value = res.data.videos
total.value = res.data.total
} catch (e) {
if (e.response?.status === 401) { auth.logout(); location.href = '/login' }
}
}
async function fetchStats() {
try {
const res = await axios.get('/api/admin/stats', { headers: auth.getHeaders() })
stats.value = res.data
} catch {}
}
function playVideo(v) {
playing.value = v
playUrl.value = `/api/stream/${v.id}?token=${auth.token}`
}
async function downloadAuth(v) {
const res = await axios.get(`/api/file/${v.id}`, { headers: auth.getHeaders(), responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a'); a.href = url; a.download = v.filename; a.click()
URL.revokeObjectURL(url)
}
async function deleteVideo(v) {
if (!confirm(`Delete "${v.title}"?`)) return
try {
await axios.delete(`/api/admin/videos/${v.id}`, { headers: auth.getHeaders() })
fetchVideos(); fetchStats()
} catch { alert('Delete failed') }
}
function showLogs(v) {
logVideoFilter.value = v.id
logVideoTitle.value = v.title || `#${v.id}`
logPage.value = 1
tab.value = 'logs'
fetchLogs()
}
function clearLogFilter() {
logVideoFilter.value = null
logVideoTitle.value = ''
logPage.value = 1
fetchLogs()
}
// ── Log methods ──
let logDebounce
function debouncedLogs() {
clearTimeout(logDebounce)
logDebounce = setTimeout(() => { logPage.value = 1; fetchLogs() }, 300)
}
async function fetchLogs() {
try {
const params = { page: logPage.value, page_size: logPageSize }
if (logVideoFilter.value) params.video_id = logVideoFilter.value
const res = await axios.get('/api/admin/download-logs', { params, headers: auth.getHeaders() })
logs.value = res.data.logs
logTotal.value = res.data.total
} catch (e) {
if (e.response?.status === 401) { auth.logout(); location.href = '/login' }
}
}
onMounted(() => { fetchVideos(); fetchStats() })
</script>
<style scoped>
.tabs { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; border-bottom: 1px solid #333; padding-bottom: 0.8rem; }
.tabs button {
padding: 0.5rem 1.2rem; border: 1px solid #444; border-radius: 8px;
background: transparent; color: #aaa; cursor: pointer; font-size: 0.95rem; transition: all 0.2s;
}
.tabs button.active { background: #1da1f2; border-color: #1da1f2; color: #fff; }
.tabs button:hover:not(.active) { border-color: #888; color: #fff; }
.stats { margin-left: auto; color: #888; font-size: 0.9rem; }
/* Video library */
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
.search-bar input { flex: 1; padding: 0.6rem 1rem; border: 1px solid #444; border-radius: 8px; background: #1a1a2e; color: #fff; }
.search-bar select { padding: 0.6rem; border: 1px solid #444; border-radius: 8px; background: #1a1a2e; color: #fff; }
.video-card {
display: flex; justify-content: space-between; align-items: center;
background: #1a1a2e; border-radius: 10px; padding: 1.1rem 1.3rem; margin-bottom: 0.9rem; gap: 1.2rem;
border: 1px solid #2a2a3e;
}
.video-main { display: flex; gap: 1.2rem; flex: 1; min-width: 0; }
.thumb { width: 112px; height: 63px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.info { min-width: 0; }
.info h4 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0.4rem; font-size: 1rem; }
.meta { display: flex; gap: 1rem; font-size: 0.88rem; color: #888; flex-wrap: wrap; }
.status-done { color: #27ae60; }
.status-downloading { color: #f39c12; }
.status-error { color: #e74c3c; }
.status-pending { color: #888; }
.actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
.actions button, .actions a {
padding: 0.4rem 0.6rem; border: 1px solid #444; border-radius: 6px;
background: transparent; color: #fff; cursor: pointer; text-decoration: none; font-size: 0.9rem;
}
.actions button:hover, .actions a:hover { border-color: #1da1f2; }
.btn-del:hover { border-color: #e74c3c !important; }
/* Logs */
.log-filter { display: flex; gap: 0.5rem; margin-bottom: 1.2rem; align-items: center; }
.log-filter input { flex: 1; padding: 0.7rem 1rem; border: 1px solid #444; border-radius: 8px; background: #1a1a2e; color: #fff; font-size: 0.95rem; }
.clear-btn { padding: 0.6rem 1rem; border: 1px solid #f39c12; border-radius: 8px; background: transparent; color: #f39c12; cursor: pointer; white-space: nowrap; font-size: 0.88rem; }
.log-table-wrap { overflow-x: auto; border-radius: 10px; border: 1px solid #2a2a3e; }
.log-table { width: 100%; border-collapse: collapse; font-size: 0.95rem; min-width: 900px; }
.log-table th {
text-align: left; padding: 0.9rem 1.2rem;
background: #12122a; color: #999; font-size: 0.82rem; letter-spacing: 0.05em; text-transform: uppercase;
border-bottom: 1px solid #2a2a3e; white-space: nowrap;
}
.log-table td { padding: 0.85rem 1.2rem; border-bottom: 1px solid #1e1e30; vertical-align: middle; }
.log-table tr:last-child td { border-bottom: none; }
.log-table tr:hover td { background: rgba(255,255,255,0.04); }
.td-time { color: #888; white-space: nowrap; font-size: 0.88rem; min-width: 140px; }
.td-ip { font-family: monospace; color: #7fdbff; white-space: nowrap; font-size: 0.92rem; min-width: 120px; }
.td-location { white-space: nowrap; min-width: 160px; }
.flag { margin-right: 0.35rem; font-size: 1.1rem; }
.location-text { color: #ccc; }
.td-browser, .td-device { color: #ddd; white-space: nowrap; min-width: 100px; }
.td-video { max-width: 380px; min-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.platform-badge {
display: inline-block; font-size: 0.72rem; padding: 0.15rem 0.5rem;
border-radius: 4px; margin-right: 0.4rem; vertical-align: middle; font-weight: 700; letter-spacing: 0.03em;
}
.plat-youtube { background: #c00; color: #fff; }
.plat-twitter { background: #1da1f2; color: #fff; }
/* Settings */
.settings-card { background: #1a1a2e; border: 1px solid #2a2a3e; border-radius: 12px; padding: 2rem; max-width: 680px; }
.settings-card h3 { margin-bottom: 0.4rem; font-size: 1.15rem; }
.settings-desc { color: #888; font-size: 0.9rem; margin-bottom: 1.8rem; }
.setting-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; gap: 1rem; }
.setting-row label:first-child { color: #ccc; font-size: 0.95rem; white-space: nowrap; }
.retention-input, .storage-limit-input { display: flex; align-items: center; gap: 0.6rem; }
.num-input { width: 80px; padding: 0.5rem 0.7rem; border: 1px solid #444; border-radius: 8px; background: #12122a; color: #fff; font-size: 0.95rem; text-align: center; }
.unit-select { padding: 0.5rem 0.7rem; border: 1px solid #444; border-radius: 8px; background: #12122a; color: #fff; font-size: 0.95rem; }
.minutes-hint { color: #666; font-size: 0.82rem; white-space: nowrap; }
.hint { color: #666; font-size: 0.85rem; }
/* Toggle switch */
.toggle { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; inset: 0; background: #444; border-radius: 26px; transition: 0.3s; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 4px; bottom: 4px; background: #fff; border-radius: 50%; transition: 0.3s; }
.toggle input:checked + .slider { background: #1da1f2; }
.toggle input:checked + .slider:before { transform: translateX(22px); }
/* Disk bar */
.disk-section { margin: 1.4rem 0; }
.disk-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.9rem; color: #aaa; }
.disk-bar { position: relative; height: 12px; background: #333; border-radius: 6px; overflow: visible; margin-bottom: 0.5rem; }
.disk-fill { height: 100%; border-radius: 6px; transition: width 0.4s; }
.disk-limit-line { position: absolute; top: -4px; bottom: -4px; width: 2px; background: #f39c12; border-radius: 2px; transform: translateX(-50%); }
.disk-labels { display: flex; justify-content: space-between; font-size: 0.8rem; color: #666; }
.disk-ok { color: #27ae60; } .disk-fill.disk-ok { background: #27ae60; }
.disk-warn { color: #f39c12; } .disk-fill.disk-warn { background: #f39c12; }
.disk-danger { color: #e74c3c; } .disk-fill.disk-danger { background: #e74c3c; }
/* Last run */
.last-run { font-size: 0.88rem; color: #888; margin-bottom: 1.4rem; }
.last-run-label { color: #666; margin-right: 0.3rem; }
.run-result { color: #27ae60; }
/* Action buttons */
.settings-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; }
.btn-save, .btn-run {
padding: 0.7rem 1.4rem; border: none; border-radius: 8px; cursor: pointer;
font-size: 0.95rem; font-weight: 600; transition: opacity 0.2s;
}
.btn-save { background: #1da1f2; color: #fff; }
.btn-run { background: #e74c3c; color: #fff; }
.btn-save:disabled, .btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-msg { margin-top: 0.8rem; font-size: 0.9rem; padding: 0.5rem 0; }
.settings-msg.ok { color: #27ae60; }
.settings-msg.warn { color: #f39c12; }
.settings-msg.err { color: #e74c3c; }
.empty { text-align: center; color: #666; padding: 3rem; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 1rem; margin-top: 1.5rem; }
.pagination button { padding: 0.5rem 1rem; border: 1px solid #444; border-radius: 6px; background: transparent; color: #fff; cursor: pointer; }
.pagination button:disabled { opacity: 0.3; cursor: not-allowed; }
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.9); display: flex; align-items: center; justify-content: center; z-index: 999; }
.player-wrap { position: relative; max-width: 90vw; max-height: 90vh; }
.player { max-width: 90vw; max-height: 85vh; border-radius: 8px; }
.close-btn { position: absolute; top: -40px; right: 0; background: none; border: none; color: #fff; font-size: 1.5rem; cursor: pointer; }
</style>