Files
am-downloader/index.html
T

1243 lines
57 KiB
HTML
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,viewport-fit=cover">
<title>AM Downloader - 音乐下载与同步</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-base: #080c14;
--bg-surface: #0f1624;
--bg-surface-elevated: #162032;
--bg-card: rgba(17, 26, 42, 0.75);
--bg-card-hover: rgba(24, 37, 60, 0.85);
--border-subtle: rgba(255, 255, 255, 0.08);
--border-focus: rgba(16, 185, 129, 0.5);
--text-main: #f1f5f9;
--text-muted: #94a3b8;
--text-dim: #64748b;
--primary: #10b981;
--primary-hover: #059669;
--primary-glow: rgba(16, 185, 129, 0.25);
--accent: #38bdf8;
--danger: #f43f5e;
--warning: #f59e0b;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-full: 9999px;
--transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.35);
--shadow-glow: 0 0 30px rgba(16, 185, 129, 0.15);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
margin: 0;
background-color: var(--bg-base);
background-image:
radial-gradient(at 0% 0%, rgba(16, 185, 129, 0.07) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(56, 189, 248, 0.06) 0px, transparent 50%);
background-attachment: fixed;
color: var(--text-main);
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
.wrap {
max-width: 1120px;
margin: auto;
padding: 24px 20px 96px;
}
/* Header & Top Bar */
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
}
.brand {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.5px;
display: flex;
align-items: center;
gap: 8px;
}
.brand b {
color: var(--primary);
text-shadow: 0 0 16px var(--primary-glow);
}
.sub {
color: var(--text-muted);
font-size: 12px;
margin-top: 2px;
font-weight: 500;
}
.nav {
display: flex;
gap: 4px;
background: rgba(15, 22, 36, 0.85);
backdrop-filter: blur(12px);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 5px;
box-shadow: var(--shadow-sm);
}
.nav button {
border: 0;
background: transparent;
color: var(--text-muted);
padding: 8px 16px;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 600;
font-size: 13.5px;
transition: var(--transition);
display: flex;
align-items: center;
gap: 6px;
}
.nav button:hover {
color: var(--text-main);
background: rgba(255, 255, 255, 0.04);
}
.nav button.active {
background: var(--bg-surface-elevated);
color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-subtle);
}
.badge {
display: inline-flex;
min-width: 18px;
height: 18px;
padding: 0 5px;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
background: var(--primary);
color: #032014;
font-size: 11px;
font-weight: 700;
}
/* User Pill & Auth Bar */
.authbar {
display: flex;
align-items: center;
gap: 10px;
justify-content: flex-end;
margin: -6px 0 18px;
}
.userpill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-full);
background: rgba(17, 26, 42, 0.6);
backdrop-filter: blur(8px);
color: var(--text-muted);
font-size: 12.5px;
font-weight: 500;
}
.userpill::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0 0 8px var(--primary);
}
/* Views */
.view { display: none; }
.view.active {
display: block;
animation: fadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Cards & Layout */
.card {
background: var(--bg-card);
backdrop-filter: blur(16px);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 22px;
margin-bottom: 18px;
box-shadow: var(--shadow-sm);
transition: var(--transition);
}
.card:hover {
border-color: rgba(255, 255, 255, 0.12);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 18px;
}
.title {
font-size: 17px;
font-weight: 700;
letter-spacing: -0.3px;
color: var(--text-main);
}
.muted {
color: var(--text-muted);
font-size: 12.5px;
margin-top: 2px;
}
/* Hero Section */
.hero {
padding: 56px 16px 42px;
text-align: center;
position: relative;
}
.hero h1 {
font-size: 38px;
font-weight: 800;
letter-spacing: -1px;
margin: 0 0 10px;
background: linear-gradient(135deg, #ffffff 40%, #94a3b8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero p {
color: var(--text-muted);
font-size: 15px;
max-width: 500px;
margin: 0 auto 28px;
}
/* Inputs & Form Controls */
.input {
width: 100%;
padding: 11px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
background: rgba(10, 16, 28, 0.85);
color: #fff;
font-size: 13.5px;
outline: none;
transition: var(--transition);
font-family: inherit;
}
.input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-glow);
background: rgba(12, 20, 36, 0.95);
}
select.input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
cursor: pointer;
}
/* Buttons */
.btn {
border: 0;
border-radius: var(--radius-md);
padding: 10px 18px;
background: var(--primary);
color: #032014;
font-weight: 700;
font-size: 13.5px;
cursor: pointer;
white-space: nowrap;
transition: var(--transition);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
box-shadow: 0 2px 10px var(--primary-glow);
}
.btn:hover:not(:disabled) {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(16, 185, 129, 0.35);
}
.btn:active:not(:disabled) {
transform: translateY(0);
}
.btn.alt {
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border-subtle);
color: var(--text-main);
box-shadow: none;
}
.btn.alt:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.16);
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* Search Bar */
.searchbar {
display: flex;
max-width: 740px;
margin: auto;
gap: 10px;
background: rgba(15, 22, 36, 0.7);
padding: 6px;
border-radius: var(--radius-lg);
border: 1px solid var(--border-subtle);
backdrop-filter: blur(12px);
box-shadow: var(--shadow-md);
}
.searchbar .input {
border: 1px solid transparent;
background: transparent;
}
.searchbar .input:focus {
background: rgba(255, 255, 255, 0.03);
box-shadow: none;
}
.searchbar select.input {
max-width: 110px;
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
/* Row & Field Layout */
.row { display: flex; gap: 10px; align-items: center; }
.row > * { min-width: 0; flex: 1; }
.field { margin-top: 14px; }
.field label {
display: block;
color: var(--text-muted);
font-size: 12.5px;
font-weight: 600;
margin-bottom: 6px;
}
/* Music Result Card & List */
.results { display: grid; gap: 6px; }
.result {
display: grid;
grid-template-columns: 52px minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
padding: 12px 14px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.015);
border: 1px solid transparent;
transition: var(--transition);
}
.result:hover {
background: rgba(255, 255, 255, 0.04);
border-color: var(--border-subtle);
}
.cover {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
object-fit: cover;
background: #151e30;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.name {
font-weight: 700;
font-size: 14.5px;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toolbar { display: flex; gap: 8px; align-items: center; }
.quality-select {
max-width: 136px;
padding: 8px 12px;
font-size: 12.5px;
}
/* Chips & Quality Badges */
.file-info { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.chip {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.05);
color: var(--text-muted);
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
border: 1px solid var(--border-subtle);
}
.chip.actual {
color: #6ee7b7;
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.25);
}
.chip.warn {
color: #fcd34d;
background: rgba(245, 158, 11, 0.1);
border-color: rgba(245, 158, 11, 0.25);
}
/* Progress & Status */
.progress {
height: 6px;
background: rgba(255, 255, 255, 0.06);
border-radius: var(--radius-full);
margin-top: 8px;
overflow: hidden;
}
.progress i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--primary), #34d399);
border-radius: var(--radius-full);
transition: width 0.3s ease;
}
/* Task Tabs */
.task-tabs {
display: flex;
gap: 6px;
overflow-x: auto;
margin-bottom: 16px;
padding-bottom: 4px;
}
.task-tabs button {
border: 1px solid var(--border-subtle);
background: rgba(15, 22, 36, 0.6);
color: var(--text-muted);
border-radius: var(--radius-md);
padding: 7px 14px;
white-space: nowrap;
cursor: pointer;
font-weight: 600;
font-size: 13px;
transition: var(--transition);
}
.task-tabs button:hover {
color: var(--text-main);
background: rgba(255, 255, 255, 0.05);
}
.task-tabs button.active {
background: var(--bg-surface-elevated);
border-color: rgba(255, 255, 255, 0.16);
color: #fff;
}
/* Settings & Account Grids */
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.setting-card h3 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 700;
color: var(--text-main);
}
.setting-card p {
margin: 0 0 16px;
color: var(--text-muted);
font-size: 12.5px;
}
/* Notices & Alerts */
.notice {
padding: 12px 14px;
background: rgba(16, 26, 44, 0.6);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-muted);
font-size: 12.5px;
margin-top: 14px;
line-height: 1.5;
}
/* Admin Shell & Dashboard */
.admin-shell { display: grid; gap: 18px; }
.admin-header { padding: 4px 2px; }
.admin-header h2 {
margin: 4px 0 6px;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.6px;
}
.eyebrow {
font-size: 11px;
letter-spacing: 2px;
color: var(--primary);
font-weight: 800;
text-transform: uppercase;
}
.admin-dashboard {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.admin-tile {
border: 1px solid var(--border-subtle);
background: var(--bg-card);
backdrop-filter: blur(12px);
color: var(--text-main);
border-radius: var(--radius-lg);
padding: 22px;
text-align: left;
display: grid;
gap: 6px;
cursor: pointer;
transition: var(--transition);
box-shadow: var(--shadow-sm);
}
.admin-tile:hover {
background: var(--bg-card-hover);
border-color: rgba(255, 255, 255, 0.16);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.admin-tile span { font-size: 26px; }
.admin-tile strong { font-size: 16.5px; font-weight: 700; color: #fff; }
.admin-tile small { color: var(--text-muted); font-size: 12px; }
/* Admin Panels & Lists */
.admin-panel {
background: var(--bg-card);
backdrop-filter: blur(16px);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 20px;
}
.panel-head {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
}
.panel-head > div:nth-child(2) { flex: 1; }
.user-filters {
display: flex;
gap: 6px;
overflow-x: auto;
margin: 16px 0;
padding-bottom: 4px;
}
.user-filters button {
border: 1px solid var(--border-subtle);
background: rgba(15, 22, 36, 0.6);
color: var(--text-muted);
border-radius: var(--radius-md);
padding: 7px 14px;
white-space: nowrap;
font-weight: 600;
font-size: 12.5px;
cursor: pointer;
transition: var(--transition);
}
.user-filters button.active {
background: var(--bg-surface-elevated);
border-color: rgba(255, 255, 255, 0.16);
color: #fff;
}
.user-group { margin-top: 16px; }
.group-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 2px 10px;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 10px;
}
.group-list { display: grid; gap: 8px; }
.user-card {
background: rgba(11, 18, 30, 0.6);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 14px;
transition: var(--transition);
}
.user-card:hover {
border-color: rgba(255, 255, 255, 0.14);
background: rgba(14, 23, 38, 0.7);
}
.user-main {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: center;
}
.user-stats { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.stat-chip {
background: rgba(255, 255, 255, 0.04);
color: var(--text-muted);
padding: 3px 8px;
border-radius: var(--radius-sm);
font-size: 11.5px;
border: 1px solid var(--border-subtle);
}
.user-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
/* Admin Summaries & Ledgers */
.admin-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-top: 16px;
}
.summary-card {
background: rgba(11, 18, 30, 0.6);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 16px;
}
.summary-card strong {
display: block;
font-size: 24px;
font-weight: 800;
color: var(--primary);
margin-top: 4px;
}
.ledger { display: grid; gap: 8px; margin-top: 14px; }
.ledger-item {
padding: 12px 14px;
border: 1px solid var(--border-subtle);
background: rgba(11, 18, 30, 0.6);
border-radius: var(--radius-md);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.detail-card {
background: rgba(11, 18, 30, 0.6);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 18px;
}
.detail-card h4 { margin: 0 0 12px; font-size: 15px; }
.detail-value { font-size: 28px; font-weight: 800; color: var(--primary); }
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
/* Empty state */
.empty {
text-align: center;
padding: 48px 16px;
color: var(--text-dim);
font-size: 13.5px;
}
/* Login Modal */
.login-overlay {
position: fixed;
inset: 0;
background: rgba(6, 10, 18, 0.85);
backdrop-filter: blur(20px);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-box {
width: min(390px, 100%);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 28px;
box-shadow: var(--shadow-md);
animation: modalIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.96) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.login-box h2 {
margin: 0 0 6px;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.5px;
}
.login-box p {
color: var(--text-muted);
font-size: 13px;
margin: 0 0 20px;
}
.login-error {
color: var(--danger);
font-size: 12px;
margin-top: 10px;
min-height: 16px;
}
/* Mobile Bottom Navigation Dock */
.mobile-nav {
display: none;
}
@media(max-width: 720px) {
.wrap { padding: 16px 14px 110px; }
.top .nav { display: none; }
.hero { padding: 36px 8px 26px; }
.hero h1 { font-size: 28px; }
.searchbar { flex-wrap: wrap; }
.searchbar .input { flex-basis: 100%; }
.searchbar select, .searchbar button { flex: 1; }
.settings-grid { grid-template-columns: 1fr; }
.result {
grid-template-columns: 44px minmax(0, 1fr);
gap: 10px;
padding: 10px;
}
.cover { width: 44px; height: 44px; }
.result .toolbar {
grid-column: 2;
display: flex;
width: 100%;
gap: 8px;
margin-top: 6px;
}
.result .quality-select { max-width: none; min-width: 0; flex: 1; }
.result .toolbar .btn { padding: 9px 14px; min-width: 76px; }
.mobile-nav {
position: fixed;
left: 12px;
right: 12px;
bottom: max(12px, env(safe-area-inset-bottom));
display: flex;
justify-content: space-around;
gap: 4px;
padding: 6px;
background: rgba(15, 22, 36, 0.88);
backdrop-filter: blur(20px);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
z-index: 100;
box-shadow: var(--shadow-md);
}
.mobile-nav button {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
color: var(--text-muted);
padding: 8px 2px;
border-radius: var(--radius-md);
font-size: 11px;
font-weight: 600;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
cursor: pointer;
transition: var(--transition);
}
.mobile-nav button.active {
background: var(--bg-surface-elevated);
color: #fff;
}
.admin-dashboard { grid-template-columns: 1fr; }
.admin-panel { padding: 14px; }
.admin-create > * { flex-basis: 100%; }
.user-main { align-items: flex-start; flex-direction: column; }
.user-actions .btn { flex: 1; min-width: 100px; }
.admin-summary { grid-template-columns: 1fr 1fr; }
.detail-grid { grid-template-columns: 1fr; }
.row { flex-wrap: wrap; }
.row > * { flex-basis: 100%; }
}
</style>
</head>
<body>
<div id="loginOverlay" class="login-overlay">
<form id="loginForm" class="login-box">
<h2>AM Downloader</h2>
<p>登录后管理你的下载任务、积分和同步设置。</p>
<div class="field">
<label>用户名</label>
<input id="loginUser" class="input" autocomplete="username" placeholder="请输入用户名">
</div>
<div class="field">
<label>密码</label>
<input id="loginPass" type="password" class="input" autocomplete="current-password" placeholder="请输入密码">
</div>
<div id="loginError" class="login-error"></div>
<button class="btn" style="width:100%;margin-top:14px">立即登录</button>
</form>
</div>
<div class="wrap">
<header class="top">
<div>
<div class="brand">AM <b>Downloader</b></div>
<div class="sub">搜索 · 下载 · WebDAV 云端同步</div>
</div>
<nav class="nav" id="topNav">
<button data-view="home">首页</button>
<button data-view="search">搜索</button>
<button data-view="queue">下载<span id="navBadge" class="badge">0</span></button>
<button data-view="account">账户</button>
<button id="adminNav" data-view="admin">管理</button>
<button data-view="settings">设置</button>
</nav>
</header>
<div class="authbar">
<span id="userPill" class="userpill">加载中…</span>
<button id="logoutBtn" class="btn alt">退出</button>
</div>
<main>
<!-- 首页视图 -->
<section id="home" class="view">
<div class="hero">
<h1>找到你想听的音乐</h1>
<p>支持多源音乐检索、无损解析,一键加入下载队列并自动同步。</p>
<form id="homeSearch" class="searchbar">
<input id="homeKeyword" class="input" placeholder="输入歌曲名、歌手或专辑名称…" autocomplete="off">
<select id="homeSource" class="input">
<option value="kw">小窝</option>
<option value="kg">小狗</option>
<option value="wy">小云</option>
<option value="tx">小Q</option>
</select>
<button class="btn">开始搜索</button>
</form>
</div>
<div class="card">
<div class="section-head">
<div>
<div class="title">最近下载</div>
<div class="muted">最近提交的音频下载任务历史</div>
</div>
<button class="btn alt" data-go="queue">查看全部</button>
</div>
<div id="recentTasks"></div>
</div>
</section>
<!-- 搜索视图 -->
<section id="search" class="view">
<div class="card">
<div class="section-head">
<div>
<div class="title">搜索音乐</div>
<div class="muted">支持歌曲名、歌手、专辑多源聚合检索</div>
</div>
</div>
<form id="searchForm" class="row">
<input id="keyword" class="input" placeholder="输入关键词" autocomplete="off">
<select id="source" class="input" style="max-width:140px">
<option value="kw">小窝</option>
<option value="kg">小狗</option>
<option value="wy">小云</option>
<option value="tx">小Q</option>
</select>
<button class="btn" style="min-width:100px">搜索</button>
</form>
</div>
<div id="resultsCard" class="card" style="display:none">
<div class="section-head">
<div>
<div class="title">搜索结果</div>
<div id="resultHint" class="muted"></div>
</div>
</div>
<div id="results" class="results"></div>
<button id="loadMore" class="btn alt loadmore" style="display:none;width:100%;margin-top:14px">加载更多</button>
</div>
</section>
<!-- 队列视图 -->
<section id="queue" class="view">
<div class="card">
<div class="section-head">
<div>
<div class="title">下载队列</div>
<div class="muted">任务会自动处理;本地文件是否保留由“设置 → 下载 → 本地文件策略”决定。</div>
</div>
</div>
<div id="taskTabs" class="task-tabs">
<button class="active" data-filter="all">全部</button>
<button data-filter="active">进行中</button>
<button data-filter="done">已完成</button>
<button data-filter="error">失败</button>
</div>
<div id="tasks"></div>
</div>
</section>
<!-- 账户视图 -->
<section id="account" class="view">
<div class="settings-grid">
<div class="card setting-card">
<h3>账户与积分</h3>
<p>积分用于新的实际下载;失败会自动全额退回流水。</p>
<div class="notice" id="creditPanel">加载中…</div>
<div class="field">
<label>兑换码充值</label>
<input id="redeemCode" class="input" placeholder="输入兑换码 (例如 XXXX-XXXX-XXXX)">
<button id="redeemBtn" class="btn" style="margin-top:10px">兑换积分</button>
</div>
</div>
<div id="userDavCard" class="card setting-card">
<h3>我的 WebDAV</h3>
<p>高级用户与管理员专属。文件下载后将自动同步至你的私有 WebDAV。</p>
<div class="field">
<label>服务器地址</label>
<input id="userDavUrl" class="input" placeholder="https://dav.example.com/music">
</div>
<div class="row">
<div class="field" style="margin-top:0">
<label>用户名</label>
<input id="userDavUser" class="input" placeholder="用户名">
</div>
<div class="field" style="margin-top:0">
<label>密码</label>
<input id="userDavPass" type="password" class="input" placeholder="留空不修改">
</div>
</div>
<div class="field">
<label>存储根目录</label>
<input id="userDavRoot" class="input" placeholder="Music">
</div>
<div class="row" style="margin-top:16px">
<button id="testUserDav" class="btn alt">测试连接</button>
<button id="saveUserDav" class="btn">保存设置</button>
</div>
</div>
</div>
</section>
<!-- 管理中心视图 -->
<section id="admin" class="view">
<div class="admin-shell">
<div class="admin-header">
<div>
<div class="eyebrow">Admin Center</div>
<h2>管理中心</h2>
<p>系统用户、积分账本、卡密批次与全局审计监控。</p>
</div>
</div>
<div class="admin-dashboard" id="adminDashboard">
<button class="admin-tile" data-admin-view="users">
<span>👥</span>
<strong>用户管理</strong>
<small>账号、角色、状态与积分详情</small>
</button>
<button class="admin-tile" data-admin-view="credits">
<span>◎</span>
<strong>积分管理</strong>
<small>系统余额总览与额度流水</small>
</button>
<button class="admin-tile" data-admin-view="redeem">
<span>◇</span>
<strong>兑换码</strong>
<small>批次生成、使用情况与批量撤销</small>
</button>
<button class="admin-tile" data-admin-view="audit">
<span>≡</span>
<strong>审计日志</strong>
<small>管理员关键操作轨迹</small>
</button>
</div>
<!-- 用户管理子面板 -->
<div class="admin-panel" id="adminUsersPanel" style="display:none">
<div class="panel-head">
<button class="btn alt admin-back">← 返回中心</button>
<div>
<div class="title">用户管理</div>
<div class="muted">按用户组分类查看与授权调度。</div>
</div>
</div>
<div class="row admin-create" style="margin-bottom:14px">
<input id="newUser" class="input" placeholder="用户名">
<input id="newPass" type="password" class="input" placeholder="初始密码(至少8位)">
<select id="newRole" class="input">
<option value="user">普通用户</option>
<option value="privileged">高级用户</option>
<option value="admin">管理员</option>
</select>
<input id="newCredits" class="input" type="number" min="0" value="0" placeholder="初始积分">
<button id="createUser" class="btn">创建用户</button>
</div>
<div class="user-filters">
<button class="active" data-role-filter="all">全部</button>
<button data-role-filter="admin">管理员</button>
<button data-role-filter="privileged">高级用户</button>
<button data-role-filter="user">普通用户</button>
</div>
<div id="users"></div>
</div>
<!-- 积分管理子面板 -->
<div class="admin-panel" id="adminCreditsPanel" style="display:none">
<div class="panel-head">
<button class="btn alt admin-back">← 返回中心</button>
<div>
<div class="title">积分管理</div>
<div class="muted">集中查看积分流水与系统账户总额。</div>
</div>
</div>
<div class="notice">积分充值、下载扣除、卡密兑换及失败退款均进入不可变流水记账。</div>
<div id="adminCreditSummary" class="admin-summary"></div>
<div id="creditLedger" class="ledger"></div>
</div>
<!-- 兑换码子面板 -->
<div class="admin-panel" id="adminRedeemPanel" style="display:none">
<div class="panel-head">
<button class="btn alt admin-back">← 返回中心</button>
<div>
<div class="title">兑换码管理</div>
<div class="muted">批量生成积分卡密并追踪兑换状态。</div>
</div>
</div>
<div class="row" style="margin-bottom:14px">
<input id="batchCredits" class="input" type="number" min="1" value="10" placeholder="单码面额">
<input id="batchCount" class="input" type="number" min="1" max="10000" value="10" placeholder="生成张数">
<input id="batchNote" class="input" placeholder="批次备注说明">
<button id="createBatch" class="btn">生成卡密</button>
</div>
<pre id="batchOutput" class="notice" style="white-space:pre-wrap;max-height:220px;overflow:auto"></pre>
<div id="batches"></div>
</div>
<!-- 审计日志子面板 -->
<div class="admin-panel" id="adminAuditPanel" style="display:none">
<div class="panel-head">
<button class="btn alt admin-back">← 返回中心</button>
<div>
<div class="title">安全审计日志</div>
<div class="muted">系统记录管理员敏感操作历史。</div>
</div>
<button id="loadAudit" class="btn alt">刷新</button>
</div>
<div id="audit" class="ledger"></div>
</div>
<!-- 用户详情子面板 -->
<div class="admin-panel" id="adminUserDetail" style="display:none">
<div class="panel-head">
<button class="btn alt" id="backToUsers">← 返回列表</button>
<div>
<div class="title" id="detailUserName">用户详情</div>
<div class="muted" id="detailUserMeta"></div>
</div>
</div>
<div id="userDetailContent"></div>
</div>
</div>
</section>
<!-- 设置视图 -->
<section id="settings" class="view">
<div class="settings-grid">
<div class="card setting-card">
<h3>音乐解析 API</h3>
<p>用于上游音乐搜索和音源解析(独立微服务)。</p>
<div class="field">
<label>API 接口地址</label>
<input id="amApi" class="input" placeholder="http://127.0.0.1:9530">
</div>
<div class="notice">架构说明:Downloader Web 运行于端口 9540,解析 API 默认运行于端口 9530。</div>
</div>
<div class="card setting-card">
<h3>下载设置</h3>
<p>文件保存路径、音质默认偏好与转存策略。</p>
<div class="field">
<label>本地下载目录</label>
<input id="downloadDir" class="input">
</div>
<div class="field">
<label>本地存储策略</label>
<select id="localFilePolicy" class="input">
<option value="keep">保留本地文件</option>
<option value="delete_after_upload">WebDAV 上传成功后自动清除本地副本</option>
</select>
<div class="notice">仅在 WebDAV 校验上传完整后安全删除;未开启 WebDAV 或上传失败绝不删除。</div>
</div>
<div class="field">
<label>默认下载音质</label>
<select id="defaultQuality" class="input">
<option value="standard">标准 (128kbps)</option>
<option value="exhigh">高品质 (320kbps)</option>
<option value="lossless">无损 FLAC</option>
<option value="hires">Hi-Res 高解析</option>
<option value="sky">Sky 空间音频</option>
<option value="jyeffect">高清音效</option>
<option value="jymaster">母带级</option>
<option value="atmos">杜比全景声 Atmos</option>
<option value="atmos_plus">Atmos+ 进阶</option>
<option value="master">Master 录音室</option>
</select>
</div>
</div>
<div class="card setting-card">
<h3>系统全局 WebDAV</h3>
<p>全局云盘存储同步配置(未配置私有 WebDAV 时兜底使用)。</p>
<div class="field">
<label>WebDAV 地址</label>
<input id="davUrl" class="input" placeholder="https://nas.example.com/dav/music">
</div>
<div class="row">
<div class="field" style="margin-top:0">
<label>用户名</label>
<input id="davUser" class="input">
</div>
<div class="field" style="margin-top:0">
<label>密码</label>
<input id="davPass" type="password" class="input">
</div>
</div>
<div class="field">
<label>远端音乐目录</label>
<input id="davPath" class="input" placeholder="Music">
</div>
<div class="field">
<label>同曲不同音质策略</label>
<select id="davVersionPolicy" class="input">
<option value="keep">保留全部版本</option>
<option value="replace_lower">高音质下载完成后清理旧的低音质</option>
</select>
</div>
<div class="row" style="margin-top:16px">
<button id="testDav" type="button" class="btn alt">测试连接</button>
<button id="saveSettings" type="button" class="btn">保存系统配置</button>
</div>
</div>
</div>
</section>
</main>
<!-- 移动端底栏导航 -->
<nav class="mobile-nav" id="mobileNav">
<button data-view="home">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
首页
</button>
<button data-view="search">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
搜索
</button>
<button data-view="queue">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
下载 <span id="mobileBadge" class="badge" style="font-size:9px;min-width:14px;height:14px;padding:0 3px">0</span>
</button>
<button data-view="account">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
账户
</button>
<button id="mobileAdminNav" data-view="admin" style="display:none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
管理
</button>
<button id="mobileSettingsNav" data-view="settings" style="display:none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
设置
</button>
</nav>
</div>
<script>
const $=id=>document.getElementById(id);let searchPage=1,searchKeyword='',searchSource='kw',searchLoading=false,hasMore=false,taskFilter='all';
async function api(u,o){const r=await fetch('/api'+u,o);if(r.status===401){showLogin();throw new Error('请先登录')}if(!r.ok)throw new Error(await r.text());return r.status===204?null:r.json()}
function showLogin(){document.getElementById('loginOverlay').style.display='flex'}
async function loadAccount(){try{const c=await api('/credits');$('creditPanel').textContent='余额 '+c.balance+' · 预留 '+c.reserved+' · 可用 '+c.available;const u=window.currentUser||{};$('userDavCard').style.display=(u.role==='admin'||u.role==='privileged')?'':'none';if(u.role==='admin'||u.role==='privileged'){const d=await api('/webdav/config');$('userDavUrl').value=d.url||'';$('userDavUser').value=d.username||'';$('userDavRoot').value=d.rootPath||''}}catch(e){$('creditPanel').textContent=e.message}}
let adminUsersCache=[];let adminRoleFilter='all';
function showAdminPanel(name){['adminDashboard','adminUsersPanel','adminCreditsPanel','adminRedeemPanel','adminAuditPanel','adminUserDetail'].forEach(id=>$(id).style.display='none');$(name).style.display='block';if(name==='adminUsersPanel')renderAdminUsers();if(name==='adminCreditsPanel')loadCreditLedger();if(name==='adminRedeemPanel')loadRedeemAdmin();if(name==='adminAuditPanel')loadAuditAdmin()}
async function loadAdmin(){if(!window.isAdmin)return;try{const x=await api('/admin/users');adminUsersCache=x.data||[];showAdminPanel('adminDashboard')}catch(e){$('adminDashboard').innerHTML='<div class="notice">加载失败:'+esc(e.message)+'</div>'}}
function renderAdminUsers(){const users=adminUsersCache.filter(u=>adminRoleFilter==='all'||u.role===adminRoleFilter);const groups=[['admin','管理员'],['privileged','高级用户'],['user','普通用户']];$('users').innerHTML=groups.filter(g=>adminRoleFilter==='all'||adminRoleFilter===g[0]).map(([role,label])=>{const list=users.filter(u=>u.role===role);return `<div class="user-group"><div class="group-head"><div><div class="title">${label}</div><div class="muted">${list.length} 个账号</div></div></div><div class="group-list">${list.length?list.map(u=>`<div class="user-card"><div class="user-main"><div><div class="name">${esc(u.username)}</div><div class="meta">#${u.id} · ${esc(u.status)}</div><div class="user-stats"><span class="stat-chip" id="credit-${u.id}">积分加载中…</span></div></div><div class="user-actions"><button class="btn alt" onclick="openUserDetail(${u.id})">查看详情</button><button class="btn alt" onclick="toggleUser(${u.id},'${u.status==='active'?'disabled':'active'}')">${u.status==='active'?'禁用':'启用'}</button></div></div></div>`).join(''):'<div class="empty">暂无账号</div>'}</div></div>`}).join('');users.forEach(u=>loadUserCredit(u.id))}
async function loadUserCredit(id){try{const c=await api('/admin/users/'+id+'/credits');const e=$('credit-'+id);if(e)e.textContent='余额 '+c.balance+' · 可用 '+c.available+' · 下载 '+c.tasks}catch(e){}}
async function openUserDetail(id){const u=adminUsersCache.find(x=>x.id===id);if(!u)return;const next=u.status==='active'?'disabled':'active';$("detailUserName").textContent=u.username;$("detailUserMeta").textContent="#"+u.id+" · "+u.role+" · "+u.status;$("userDetailContent").innerHTML="<div class='detail-grid'><div class='detail-card'><h4>账户</h4><div class='muted'>角色</div><div>"+esc(u.role)+"</div><div class='muted' style='margin-top:8px'>状态</div><div>"+esc(u.status)+"</div><div class='detail-actions'><button class='btn alt' id='detailReset'>重置密码</button><button class='btn alt' id='detailToggle'>"+(u.status==='active'?'禁用账户':'启用账户')+"</button></div></div><div class='detail-card' id='detailCredits'>加载积分中…</div></div>";showAdminPanel('adminUserDetail');$("detailReset").onclick=()=>resetUserPassword(id);$("detailToggle").onclick=()=>toggleUser(id,next);try{const c=await api('/admin/users/'+id+'/credits');$("detailCredits").innerHTML="<h4>积分</h4><div class='detail-value'>"+c.balance+"</div><div class='muted'>可用 "+c.available+" · 预留 "+c.reserved+" · 下载 "+c.tasks+" 次</div><div class='detail-actions'><button class='btn' id='detailAdd'>增加积分</button><button class='btn alt' id='detailSub'>扣除积分</button></div>";$("detailAdd").onclick=()=>adjustCredits(id,1);$("detailSub").onclick=()=>adjustCredits(id,-1)}catch(e){$("detailCredits").textContent=e.message}}
async function adjustCredits(id,sign){const raw=prompt(sign>0?'增加多少积分?':'扣除多少积分?','10');if(raw===null)return;const amount=Math.floor(Number(raw));if(!Number.isFinite(amount)||amount<=0){alert('请输入正整数');return}const reason=prompt('调整原因(可选)','管理员调整积分');if(reason===null)return;try{const x=await api('/admin/users/'+id+'/credits',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({amount:sign*amount,reason})});alert('调整成功,当前可用积分:'+x.available);openUserDetail(id);loadAdmin()}catch(e){alert('积分调整失败:'+e.message)}}
async function resetUserPassword(id){const pass=prompt('输入新密码(至少 8 位):');if(pass===null)return;if(pass.length<8){alert('密码至少 8 位');return}try{await api('/admin/users/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pass})});alert('密码已重置,该用户的其他登录会话已失效')}catch(e){alert('密码重置失败:'+e.message)}}
async function loadCreditLedger(){try{const u=await api('/admin/users');const users=u.data||[];let total=0;for(const x of users){try{const c=await api('/admin/users/'+x.id+'/credits');total+=Number(c.balance)||0}catch(e){}}$('adminCreditSummary').innerHTML='<div class="summary-card">用户<strong>'+users.length+'</strong></div><div class="summary-card">当前余额<strong>'+total+'</strong></div><div class="summary-card">单位<strong>积分</strong></div>';$('creditLedger').innerHTML='<div class="notice">积分流水按用户查看。进入「用户管理 → 查看详情」即可调整积分并查看该用户当前余额。</div>'}catch(e){$('adminCreditSummary').textContent=e.message}}
async function loadRedeemAdmin(){const b=await api('/admin/redeem');$('batches').innerHTML=(b.data||[]).map(x=>'<div class="notice">#'+x.id+' · '+x.credits+' 积分 × '+x.count+(x.note?' · '+esc(x.note):'')+' <button class="btn alt" onclick="revokeBatch('+x.id+')">撤销未使用</button></div>').join('')}
async function loadAuditAdmin(){const l=await api('/admin/audit');$('audit').innerHTML=(l.data||[]).map(x=>'<div class="ledger-item"><b>'+esc(x.action)+'</b><div class="meta">'+esc(x.targetType)+'#'+esc(x.targetId)+' · '+esc(x.metadata||'')+' · '+esc(x.createdAt)+'</div></div>').join('')||'<div class="empty">暂无日志</div>'}
async function toggleUser(id,status){const u=adminUsersCache.find(x=>x.id===id);status=status||(u&&u.status==='active'?'disabled':'active');try{await api('/admin/users/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status})});const u=adminUsersCache.find(x=>x.id===id);if(u)u.status=status;renderAdminUsers()}catch(e){alert('操作失败:'+e.message)}}
async function revokeBatch(id){try{await api('/admin/redeem/'+id+'/revoke',{method:'POST'});loadRedeemAdmin()}catch(e){alert('撤销失败:'+e.message)}}
function hideLogin(){document.getElementById('loginOverlay').style.display='none'}
async function initAuth(){try{const x=await api('/auth/me');const u=x.user;window.currentUser=u;window.isAdmin=u.role==='admin';const pill=document.getElementById('userPill');if(pill)pill.textContent=u.username+' · '+u.role;const s=document.getElementById('settings');if(s)s.style.display=u.role==='admin'?'':'none';const an=document.getElementById('adminNav');if(an)an.style.display=u.role==='admin'?'':'none';const mn=document.getElementById('mobileAdminNav');if(mn)mn.style.display=u.role==='admin'?'':'none';const msn=document.getElementById('mobileSettingsNav');if(msn)msn.style.display=u.role==='admin'?'':'none';const ae=document.getElementById('accountAdminEntry');if(ae)ae.style.display=u.role==='admin'?'':'none';document.querySelectorAll('[data-view="settings"]').forEach(x=>x.style.display=u.role==='admin'?'':'none');const c=await api('/credits');if(pill)pill.textContent=u.username+' · '+u.role+' · 积分 '+c.balance+' / 可用 '+c.available;hideLogin();return true}catch(e){console.error('initAuth error:',e);showLogin();return false}}
document.querySelectorAll('[data-admin-view]').forEach(b=>b.addEventListener('click',()=>showAdminPanel('admin'+b.dataset.adminView.charAt(0).toUpperCase()+b.dataset.adminView.slice(1)+'Panel')));document.querySelectorAll('.admin-back').forEach(b=>b.addEventListener('click',()=>showAdminPanel('adminDashboard')));document.querySelectorAll('[data-role-filter]').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('[data-role-filter]').forEach(x=>x.classList.remove('active'));b.classList.add('active');adminRoleFilter=b.dataset.roleFilter;renderAdminUsers()}));$('backToUsers').addEventListener('click',()=>showAdminPanel('adminUsersPanel'));
$('loginForm').addEventListener('submit',async e=>{e.preventDefault();$('loginError').textContent='登录中…';try{const x=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('loginUser').value.trim(),password:$('loginPass').value})});if(!x.ok)throw new Error('用户名或密码错误');await initAuth();go(location.hash.slice(1)||'home');loadRecent();loadAccount()}catch(e){$('loginError').textContent=e.message}});
$('logoutBtn').addEventListener('click',async()=>{try{await api('/auth/logout',{method:'POST'})}finally{showLogin()}});
function esc(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
function go(view){if(view==='admin' && !window.isAdmin){view='home'}document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view===view));if(view==='settings')loadSettings();if(view==='account')loadAccount();if(view==='admin')loadAdmin();if(view==='queue')loadTasks();if(view==='home')loadRecent();history.replaceState(null,'','#'+view)}
document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>go(b.dataset.view)));document.querySelectorAll('[data-go]').forEach(b=>b.addEventListener('click',()=>go(b.dataset.go)));
function qualityRank(q){return {standard:1,exhigh:2,sq:3,lossless:4,hires:5,jyeffect:6,sky:7,jymaster:8,atmos:9,atmos_plus:10,master:11}[q]||0}
function qualityName(q){return {standard:'标准',exhigh:'高品质',sq:'SQ',lossless:'无损',hires:'Hi-Res',jyeffect:'高清音效',sky:'Sky',jymaster:'母带',atmos:'Atmos',atmos_plus:'Atmos+',master:'Master'}[q]||q}
function pickQuality(s){const qs=Array.isArray(s.qualities)?s.qualities.map(x=>typeof x==='string'?{level:x}:x).filter(x=>x.level):[];const def=$('defaultQuality').value||'standard';if(qs.some(x=>x.level===def))return def;return qs.slice().sort((a,b)=>qualityRank(b.level)-qualityRank(a.level))[0]?.level||def}
async function runSearch(reset=true){const k=$('keyword').value.trim();if(!k)return;if(reset){searchPage=1;searchKeyword=k;searchSource=$('source').value;$('results').innerHTML=''}if(searchLoading)return;searchLoading=true;$('resultsCard').style.display='block';$('resultHint').textContent=reset?'搜索中…':'加载中…';$('loadMore').disabled=true;try{const x=await api('/search?keyword='+encodeURIComponent(searchKeyword)+'&source='+encodeURIComponent(searchSource)+'&page='+searchPage+'&limit=20');const songs=x.data?.songs||[];const seen=new Set([...$('results').querySelectorAll('[data-key]')].map(x=>x.dataset.key));songs.forEach(s=>{const key=String(s.rid||s.id||s.title+'|'+s.artist);if(seen.has(key))return;const el=document.createElement('div');el.className='result';el.dataset.key=key;const qs=Array.isArray(s.qualities)?s.qualities.map(x=>typeof x==='string'?{level:x}:x).filter(x=>x.level):[];const selected=pickQuality(s);const options=qs.length?qs.slice().sort((a,b)=>qualityRank(a.level)-qualityRank(b.level)).map(q=>'<option value="'+esc(q.level)+'" '+(q.level===selected?'selected':'')+'>'+esc(qualityName(q.level))+(q.format?' · '+esc(String(q.format).toUpperCase()):'')+'</option>').join(''):'';el.innerHTML='<img class="cover" src="'+esc(s.pic||'')+'" onerror="this.style.display=\'none\'"><div><div class="name">'+esc(s.title)+'</div><div class="meta">'+esc(s.artist||'')+' · '+esc(s.album||'')+'</div></div><div class="toolbar">'+(qs.length?'<select class="input quality-select">'+options+'</select>':'')+'<button class="btn">下载</button></div>';el.querySelector('button').addEventListener('click',()=>{const q=el.querySelector('.quality-select');queueSong(s,q?q.value:selected)});$('results').appendChild(el)});hasMore=songs.length>=20;$('resultHint').textContent='第 '+searchPage+' 页 · 已显示 '+$('results').children.length+' 首';$('loadMore').style.display=hasMore?'block':'none'}catch(e){$('resultHint').textContent='搜索失败:'+e.message}finally{searchLoading=false;$('loadMore').disabled=false}}
$('searchForm').addEventListener('submit',e=>{e.preventDefault();runSearch(true)});$('loadMore').addEventListener('click',()=>{searchPage++;runSearch(false)});$('homeSearch').addEventListener('submit',e=>{e.preventDefault();$('keyword').value=$('homeKeyword').value;$('source').value=$('homeSource').value;go('search');runSearch(true)});
async function queueSong(s,quality){s.quality=quality||pickQuality(s);try{await api('/tasks',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(s)});go('queue');await loadTasks()}catch(e){alert('加入失败:'+e.message)}}
function formatSize(n){n=Number(n)||0;if(n<1024)return n+' B';const units=['KB','MB','GB','TB'];let v=n/1024;for(let i=0;i<units.length;i++){if(v<1024||i===units.length-1)return v.toFixed(v>=100?0:v>=10?1:2)+' '+units[i];v/=1024}return n+' B'}
function taskHtml(t){let pct=Math.max(0,Math.min(100,Number(t.progress)||0));let status=t.status==='done'?'已完成':t.status==='error'?'失败':t.status==='pending'?'等待中':t.status==='resolving'?'解析中':t.status==='downloading'?'下载中':t.status==='uploading'?'上传中':esc(t.status);let actual=(t.format||'').toUpperCase();let requested=qualityName(t.quality||'');let actualQ=t.actualQuality?qualityName(t.actualQuality):'';let apiQ=t.resolvedQuality?qualityName(t.resolvedQuality):'';let action=t.status==='error'?'<button class="btn alt retry">重试</button>':'';let highLossless=t.quality==='lossless'||t.quality==='hires';let mismatch=(actualQ&&actualQ!==requested)||(highLossless&&actual!=='FLAC');let info=t.status==='done'?`<div class="file-info"><span class="chip actual">格式 ${esc(actual||'未知')}</span><span class="chip">大小 ${esc(formatSize(t.totalBytes||t.downloadedBytes))}</span>${actualQ&&!mismatch?`<span class="chip actual">实际 ${esc(actualQ)}</span>`:''}${mismatch?`<span class="chip warn">请求 ${esc(requested)} · 实际 ${esc(actualQ||actual||'未知')}${apiQ&&apiQ!==actualQ?' · API '+esc(apiQ):''}</span>`:''}</div>`:'';return `<div class="result"><img class="cover" src="${esc(t.pic||'')}" onerror="this.style.display='none'"><div><div class="name">${esc(t.title)} <span class="muted">${esc(t.artist||'')}</span></div><div class="meta">${esc(t.source)} · 请求 ${esc(requested)} · ${status}${t.status==='error'?' · '+esc(t.error||''):''}</div>${info}${t.status==='downloading'||t.status==='resolving'?`<div class="progress"><i style="width:${pct}%"></i></div>`:''}</div>${action}</div>`}
async function loadTasks(){try{const x=await api('/tasks');const all=x.data||[];const active=all.filter(t=>['pending','resolving','downloading'].includes(t.status));$('navBadge').textContent=active.length;$('mobileBadge').textContent=active.length;let arr=taskFilter==='active'?active:taskFilter==='done'?all.filter(t=>t.status==='done'):taskFilter==='error'?all.filter(t=>t.status==='error'):all;$('tasks').innerHTML=arr.length?arr.map(taskHtml).join(''):'<div class="empty">暂无任务</div>';$('tasks').querySelectorAll('.retry').forEach((b,i)=>b.addEventListener('click',async()=>{const errs=arr.filter(t=>t.status==='error');await api('/tasks/'+errs[i].id+'/retry',{method:'POST'});loadTasks()}))}catch(e){$('tasks').innerHTML='<div class="empty">加载失败:'+esc(e.message)+'</div>'}}
async function loadRecent(){try{const x=await api('/tasks');const a=(x.data||[]).slice(0,5);$('recentTasks').innerHTML=a.length?a.map(taskHtml).join(''):'<div class="empty">还没有下载记录</div>'}catch(e){$('recentTasks').innerHTML='<div class="empty">加载失败</div>'}}
document.querySelectorAll('#taskTabs button').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#taskTabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');taskFilter=b.dataset.filter;loadTasks()}));
async function loadSettings(){try{const s=await api('/admin/settings');$('amApi').value=s.amApi||'';$('downloadDir').value=s.downloadDir||'';$('localFilePolicy').value=s.localFilePolicy||'keep';$('davUrl').value=s.webdavUrl||'';$('davUser').value=s.webdavUser||'';$('davPass').value=s.webdavPass||'';$('davPath').value=s.webdavPath||'';$('defaultQuality').value=s.quality||'standard';$('davVersionPolicy').value=s.webdavVersionPolicy||'keep'}catch(e){alert('设置加载失败:'+e.message)}}
$('saveSettings').addEventListener('click',async()=>{const b={amApi:$('amApi').value,downloadDir:$('downloadDir').value,localFilePolicy:$('localFilePolicy').value,webdavUrl:$('davUrl').value,webdavUser:$('davUser').value,webdavPass:$('davPass').value,webdavPath:$('davPath').value,quality:$('defaultQuality').value,webdavVersionPolicy:$('davVersionPolicy').value};try{await api('/admin/settings',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});alert('设置已保存')}catch(e){alert('保存失败:'+e.message)}});
$('testDav').addEventListener('click',async()=>{try{const b={url:$('davUrl').value,user:$('davUser').value,pass:$('davPass').value,path:$('davPath').value};await api('/admin/webdav/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});alert('WebDAV 连接成功')}catch(e){alert('WebDAV 连接失败:'+e.message)}});
$('redeemBtn').addEventListener('click',async()=>{try{const x=await api('/redeem',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code:$('redeemCode').value})});alert('兑换成功:+'+x.credits+' 积分');$('redeemCode').value='';loadAccount()}catch(e){alert('兑换失败:'+e.message)}});
$('saveUserDav').addEventListener('click',async()=>{try{await api('/webdav/config',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,url:$('userDavUrl').value,username:$('userDavUser').value,password:$('userDavPass').value,rootPath:$('userDavRoot').value})});alert('WebDAV 已保存')}catch(e){alert('保存失败:'+e.message)}});
$('testUserDav').addEventListener('click',async()=>{try{await api('/webdav/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:$('userDavUrl').value,username:$('userDavUser').value,password:$('userDavPass').value,rootPath:$('userDavRoot').value})});alert('连接成功')}catch(e){alert('连接失败:'+e.message)}});
$('createUser').addEventListener('click',async()=>{try{await api('/admin/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('newUser').value,password:$('newPass').value,role:$('newRole').value,credits:Number($('newCredits').value||0)})});$('newUser').value='';$('newPass').value='';loadAdmin()}catch(e){alert('创建失败:'+e.message)}});
$('createBatch').addEventListener('click',async()=>{try{const x=await api('/admin/redeem',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({credits:Number($('batchCredits').value),count:Number($('batchCount').value),note:$('batchNote').value})});$('batchOutput').textContent=x.codes.join('\n');loadAdmin()}catch(e){alert('生成失败:'+e.message)}});
$('loadAudit').addEventListener('click',loadAdmin);
initAuth().then(ok=>{if(ok){go(location.hash.slice(1)||'home');loadRecent();loadAccount()}});setInterval(()=>{loadTasks();if(document.getElementById('home').classList.contains('active'))loadRecent()},3000);
</script>
</body>
</html>