Files
am-downloader/index.html
T

1423 lines
61 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%; }
}
/* Admin Segmented Tabs & Modern Header */
.admin-tab-nav {
display: flex;
gap: 6px;
background: rgba(15, 22, 36, 0.75);
backdrop-filter: blur(12px);
padding: 5px;
border-radius: var(--radius-lg);
border: 1px solid var(--border-subtle);
overflow-x: auto;
margin-bottom: 20px;
}
.admin-tab-nav button {
border: 0;
background: transparent;
color: var(--text-muted);
padding: 9px 18px;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 13.5px;
cursor: pointer;
white-space: nowrap;
transition: var(--transition);
display: flex;
align-items: center;
gap: 8px;
}
.admin-tab-nav button:hover {
color: var(--text-main);
background: rgba(255, 255, 255, 0.04);
}
.admin-tab-nav button.active {
background: var(--bg-surface-elevated);
color: #fff;
border: 1px solid var(--border-subtle);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
/* User Drawer Overlay & Panel */
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(5, 8, 15, 0.65);
backdrop-filter: blur(6px);
z-index: 200;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.drawer-backdrop.active {
opacity: 1;
pointer-events: auto;
}
.admin-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(520px, 92vw);
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
box-shadow: -10px 0 35px rgba(0, 0, 0, 0.5);
z-index: 201;
transform: translateX(100%);
transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
overflow: hidden;
}
.admin-drawer.active {
transform: translateX(0);
}
.drawer-header {
padding: 18px 20px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(15, 22, 36, 0.95);
}
.drawer-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
@media (max-width: 720px) {
.admin-drawer {
top: auto;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
max-height: 85vh;
border-left: 0;
border-top: 1px solid var(--border-subtle);
border-radius: 20px 20px 0 0;
transform: translateY(100%);
}
.admin-drawer.active {
transform: translateY(0);
}
}
</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" style="display:none">0</span></button>
<button data-view="account">账户</button>
<button id="adminNav" data-view="admin">管理</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>
<!-- 分段 Tab 导航 (Segmented Tabs) -->
<div class="admin-tab-nav" id="adminTabNav">
<button class="admin-tab-btn active" data-admin-tab="users">
<span>👥</span> 用户管理
</button>
<button class="admin-tab-btn" data-admin-tab="credits">
<span>◎</span> 积分账本
</button>
<button class="admin-tab-btn" data-admin-tab="redeem">
<span>◇</span> 卡密批次
</button>
<button class="admin-tab-btn" data-admin-tab="audit">
<span>≡</span> 审计日志
</button>
<button class="admin-tab-btn" data-admin-tab="settings">
<span>⚙️</span> 全局设置
</button>
</div>
<!-- 保持原有 ID 避免破坏已有绑定契约 -->
<div id="adminDashboard" style="display:none">
<button class="admin-tile" data-admin-view="users" style="display:none"></button>
<button class="admin-tile" data-admin-view="credits" style="display:none"></button>
<button class="admin-tile" data-admin-view="redeem" style="display:none"></button>
<button class="admin-tile" data-admin-view="audit" style="display:none"></button>
</div>
<!-- 1. 用户管理面板 -->
<div class="admin-panel" id="adminUsersPanel">
<div class="panel-head">
<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>
<!-- 2. 积分管理面板 -->
<div class="admin-panel" id="adminCreditsPanel" style="display:none">
<div class="panel-head">
<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>
<!-- 3. 兑换码面板 -->
<div class="admin-panel" id="adminRedeemPanel" style="display:none">
<div class="panel-head">
<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>
<!-- 4. 审计日志面板 -->
<div class="admin-panel" id="adminAuditPanel" style="display:none">
<div class="panel-head">
<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>
<!-- 5. 全局系统设置面板(内嵌在管理后台) -->
<div class="admin-panel" id="adminSettingsPanel" style="display:none">
<div class="panel-head">
<div>
<div class="title">系统全局运行设置</div>
<div class="muted">调整上游解析微服务、全局转存策略与系统级存储。</div>
</div>
</div>
<div class="settings-grid">
<div class="card setting-card" style="background:rgba(11,18,30,0.6)">
<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" style="background:rgba(11,18,30,0.6)">
<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" style="grid-column: 1 / -1; background:rgba(11,18,30,0.6)">
<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>
</div>
</div>
</section>
<!-- 用户管理:右侧/底部抽屉详情(Drawer) -->
<div class="drawer-backdrop" id="drawerBackdrop"></div>
<div class="admin-drawer" id="adminUserDetail">
<div class="drawer-header">
<div>
<div class="title" id="detailUserName">用户详情</div>
<div class="muted" id="detailUserMeta"></div>
</div>
<button class="btn alt" id="backToUsers" style="padding:6px 12px;font-size:12px">✕ 关闭</button>
</div>
<div class="drawer-body">
<div id="userDetailContent"></div>
</div>
</div>
</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" style="display:none">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>
</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';
let currentAdminTab = 'users';
function switchAdminTab(tab){
currentAdminTab = tab;
document.querySelectorAll('.admin-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.adminTab === tab);
});
const panelMap = {
users: 'adminUsersPanel',
credits: 'adminCreditsPanel',
redeem: 'adminRedeemPanel',
audit: 'adminAuditPanel',
settings: 'adminSettingsPanel'
};
['adminUsersPanel','adminCreditsPanel','adminRedeemPanel','adminAuditPanel','adminSettingsPanel'].forEach(id => {
const el = $(id);
if(el) el.style.display = 'none';
});
const activePanelId = panelMap[tab] || 'adminUsersPanel';
if($(activePanelId)) $(activePanelId).style.display = 'block';
if(tab === 'users') renderAdminUsers();
if(tab === 'credits') loadCreditLedger();
if(tab === 'redeem') loadRedeemAdmin();
if(tab === 'audit') loadAuditAdmin();
if(tab === 'settings') loadSettings();
}
function showAdminPanel(name){
if(name === 'adminUserDetail') return;
const map = {
adminDashboard: 'users',
adminUsersPanel: 'users',
adminCreditsPanel: 'credits',
adminRedeemPanel: 'redeem',
adminAuditPanel: 'audit',
adminSettingsPanel: 'settings'
};
switchAdminTab(map[name] || 'users');
}
async function loadAdmin(){
if(!window.isAdmin) return;
try {
const x = await api('/admin/users');
adminUsersCache = x.data || [];
switchAdminTab(currentAdminTab || 'users');
} catch(e) {
alert('加载管理信息失败:' + e.message);
}
}
function closeUserDrawer(){
const drawer = $('adminUserDetail');
const backdrop = $('drawerBackdrop');
if(drawer) drawer.classList.remove('active');
if(backdrop) backdrop.classList.remove('active');
}
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>";const drawer = $('adminUserDetail');
const backdrop = $('drawerBackdrop');
if(drawer) drawer.classList.add('active');
if(backdrop) backdrop.classList.add('active');$("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);const l=await api('/admin/users/'+id+'/credits/transactions?limit=30');const ledger=document.createElement('div');ledger.className='ledger';ledger.style.marginTop='18px';ledger.innerHTML="<div class='muted' style='margin-bottom:6px'>最近积分流水</div>"+((l.data||[]).map(t=>"<div class='ledger-item'><div><b>"+esc(t.type)+"</b> <span class='"+(t.amount>=0?'positive':'negative')+"'>"+(t.amount>=0?'+':'')+t.amount+"</span></div><div class='meta'>余额 "+t.balanceAfter+" · "+esc(t.reason||'')+" · "+esc(t.createdAt||'')+"</div></div>").join('')||"<div class='empty'>暂无流水</div>");$("detailCredits").appendChild(ledger)}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)total+=Number(x.balance)||0;const l=await api('/admin/credits/transactions?limit=100');$('adminCreditSummary').innerHTML='<div class="summary-card">用户<strong>'+users.length+'</strong></div><div class="summary-card">当前余额<strong>'+total+'</strong></div><div class="summary-card">流水<strong>'+((l.data||[]).length)+'</strong></div>';$('creditLedger').innerHTML=(l.data||[]).map(t=>'<div class="ledger-item"><div><b>#'+t.userId+'</b> · '+esc(t.type)+' <span class="'+(t.amount>=0?'positive':'negative')+'">'+(t.amount>=0?'+':'')+t.amount+'</span></div><div class="meta">余额 '+t.balanceAfter+' · '+esc(t.reason||'')+' · '+esc(t.createdAt||'')+'</div></div>').join('')||'<div class="empty">暂无积分流水</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 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()}));
document.querySelectorAll('.admin-tab-btn').forEach(btn => {
btn.addEventListener('click', () => switchAdminTab(btn.dataset.adminTab));
});
if($('drawerBackdrop')) $('drawerBackdrop').addEventListener('click', closeUserDrawer);
if($('backToUsers')) $('backToUsers').addEventListener('click', closeUserDrawer);
$('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 === 'settings') {
if(!window.isAdmin) { view = 'home'; }
else { view = 'admin'; switchAdminTab('settings'); }
} else 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>