Files
am-downloader/index.html
T

64 lines
32 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>
<style>
*{box-sizing:border-box}body{margin:0;background:#0b1220;color:#e7edf7;font:14px system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.wrap{max-width:1100px;margin:auto;padding:20px 16px 80px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.brand{font-size:24px;font-weight:850}.brand b{color:#10b981}.sub,.muted{color:#8492a7}.sub{margin-top:4px;font-size:12px}.nav{display:flex;gap:6px;background:#101a2b;border:1px solid #22304a;border-radius:14px;padding:5px}.nav button{border:0;background:transparent;color:#9aa8bb;padding:9px 13px;border-radius:10px;cursor:pointer;font-weight:650}.nav button.active{background:#1c2b42;color:#fff}.badge{display:inline-flex;min-width:19px;height:19px;align-items:center;justify-content:center;border-radius:10px;background:#10b981;color:#06251b;font-size:11px;margin-left:5px}.view{display:none}.view.active{display:block}.hero{padding:44px 8px 34px;text-align:center}.hero h1{font-size:34px;margin:0 0 10px}.hero p{color:#8492a7;margin:0 0 24px}.searchbar{display:flex;max-width:720px;margin:auto;gap:8px}.input{width:100%;padding:12px 13px;border-radius:11px;border:1px solid #2a3852;background:#0c1525;color:#fff;outline:none}.input:focus{border-color:#10b981}.btn{border:0;border-radius:11px;padding:11px 16px;background:#10b981;color:#06251b;font-weight:750;cursor:pointer;white-space:nowrap}.btn.alt{background:#1b2940;color:#dbe5f2}.btn:disabled{opacity:.5;cursor:not-allowed}.card{background:#111b2c;border:1px solid #22304a;border-radius:17px;padding:18px;margin-bottom:15px}.section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:13px}.title{font-size:17px;font-weight:780}.toolbar{display:flex;gap:8px;align-items:center}.result{display:grid;grid-template-columns:50px minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px;border-radius:12px}.result:hover{background:#162238}.cover{width:50px;height:50px;border-radius:10px;object-fit:cover;background:#1b2638}.name{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{font-size:12px;color:#8492a7;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.results{display:grid;gap:3px}.empty{text-align:center;padding:42px 12px;color:#8492a7}.loadmore{width:100%;margin-top:12px}.task-tabs{display:flex;gap:6px;overflow:auto;margin-bottom:14px}.task-tabs button{border:1px solid #283750;background:#0e1829;color:#aab6c7;border-radius:10px;padding:8px 12px;white-space:nowrap}.task-tabs button.active{background:#1c2b42;color:#fff}.status{font-size:12px;padding:5px 8px;border-radius:8px;background:#1c2a40}.done{color:#34d399}.error{color:#fb7185}.progress{height:5px;background:#1b2940;border-radius:4px;margin-top:8px;overflow:hidden}.progress i{display:block;height:100%;background:#10b981}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.setting-card h3{margin:0 0 5px;font-size:15px}.setting-card p{margin:0 0 15px;color:#8492a7;font-size:12px}.field{margin-top:12px}.field label{display:block;color:#9aa8bb;font-size:12px;margin-bottom:6px}.row{display:flex;gap:9px}.row>*{min-width:0;flex:1}.notice{padding:10px 12px;background:#0d1728;border:1px solid #23314a;border-radius:10px;color:#9eacc0;font-size:12px;margin-top:12px}.recent{margin-top:4px}.mobile-nav{display:none}.quality-select{max-width:132px}.file-info{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}.chip{display:inline-flex;align-items:center;padding:3px 7px;border-radius:7px;background:#17243a;color:#b9c6d8;font-size:11px}.chip.actual{color:#d9fbe9;background:#123126}.chip.warn{color:#fde7b0;background:#352b16}
@media(max-width:720px){.wrap{padding:14px 12px 132px}.top{align-items:flex-start}.top .nav{display:none}.hero{padding:35px 4px 25px}.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:42px minmax(0,1fr);gap:10px;padding:10px 6px}.cover{width:42px;height:42px}.result .toolbar{grid-column:2;display:flex;width:100%;gap:7px;margin-top:4px}.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:10px;right:10px;bottom:max(10px,env(safe-area-inset-bottom));display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:3px;padding:5px;background:#111b2c;border:1px solid #2a3852;border-radius:16px;z-index:10}.mobile-nav button{border:0;background:transparent;color:#8492a7;padding:9px 4px;border-radius:11px;font-size:12px}.mobile-nav button.active{background:#1c2b42;color:#fff}.row{flex-wrap:wrap}.row>*{flex-basis:100%}}
.authbar{display:flex;align-items:center;gap:8px;justify-content:flex-end;margin:-8px 0 14px}.userpill{padding:7px 10px;border:1px solid #22304a;border-radius:10px;background:#111b2c;color:#b9c6d8}.login-overlay{position:fixed;inset:0;background:#0b1220;z-index:50;display:flex;align-items:center;justify-content:center;padding:20px}.login-box{width:min(390px,100%);background:#111b2c;border:1px solid #22304a;border-radius:18px;padding:24px}.login-box h2{margin:0 0 6px}.login-box p{color:#8492a7;font-size:12px;margin:0 0 18px}.login-box .field{margin-top:12px}.login-error{color:#fb7185;font-size:12px;margin-top:10px;min-height:16px}
.user-groups{display:grid;gap:16px}.user-group{border:1px solid var(--line);border-radius:14px;padding:14px;background:rgba(255,255,255,.02)}.group-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}.group-list{display:grid;gap:8px}.user-row{align-items:center}.user-row .toolbar{display:flex;gap:6px;flex-wrap:wrap}.user-row .meta{line-height:1.55}</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"></div><div class="field"><label>密码</label><input id="loginPass" type="password" class="input" autocomplete="current-password"></div><div id="loginError" class="login-error"></div><button class="btn" style="width:100%;margin-top:10px">登录</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 class="title">最近下载</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"><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 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">加载更多</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:9px">兑换积分</button></div></div><div id="userDavCard" class="card setting-card"><h3>我的 WebDAV</h3><p>仅 privileged / admin 可配置。密码不会回显。</p><div class="field"><label>地址</label><input id="userDavUrl" class="input"></div><div class="row"><input id="userDavUser" class="input" placeholder="用户名"><input id="userDavPass" type="password" class="input" placeholder="新密码(留空不修改)"></div><div class="field"><label>根目录</label><input id="userDavRoot" class="input" placeholder="Music"></div><div class="row" style="margin-top:12px"><button id="testUserDav" class="btn alt">测试</button><button id="saveUserDav" class="btn">保存</button></div></div></div></section>
<section id="admin" class="view"><div class="card"><div class="section-head"><div><div class="title">用户管理</div><div class="muted">创建账号、分配角色、启用或禁用。</div></div></div><div class="row"><input id="newUser" class="input" placeholder="用户名"><input id="newPass" type="password" class="input" placeholder="初始密码(8+)"><select id="newRole" class="input"><option value="user">user</option><option value="privileged">privileged</option><option value="admin">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-groups" id="users" style="margin-top:14px"></div></div><div class="card"><div class="section-head"><div><div class="title">兑换码批次</div><div class="muted">生成后明文兑换码仅在创建结果中返回。</div></div></div><div class="row"><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="card"><div class="section-head"><div class="title">审计日志</div><button id="loadAudit" class="btn alt">刷新</button></div><div id="audit" class="muted"></div></div></section>
<section id="settings" class="view"><div class="settings-grid"><div class="card setting-card"><h3>音乐 API</h3><p>用于搜索和解析音乐资源,与 Downloader Web 服务分离。</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 上传成功或远端已存在相同大小文件后删除本地成品;上传失败不会删除。</div></div><div class="field"><label>默认下载品质</label><select id="defaultQuality" class="input"><option value="standard">标准</option><option value="exhigh">高品质</option><option value="lossless">无损</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>可选的 NAS/远程音乐库同步配置。</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"><label>用户名</label><input id="davUser" class="input"></div><div class="field"><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 class="notice">“清理旧低音质”只删除数据库中已确认属于同一首歌、且音质低于当前版本的旧 WebDAV 文件,不会扫描或删除未知文件。</div></div><div class="row" style="margin-top:15px"><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">⌂<br>首页</button><button data-view="search">⌕<br>搜索</button><button data-view="queue">♫ <span id="mobileBadge">0</span><br>下载</button><button data-view="account">◎<br>账户</button><button id="mobileAdminNav" data-view="admin" style="display:none">⚙<br>管理</button><button data-view="settings">⚙<br>设置</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}}
async function loadAdmin(){if(!window.isAdmin)return;try{const x=await api('/admin/users');const users=x.data||[];const groups=[['admin','管理员'],['privileged','高级用户'],['user','普通用户']];$('users').innerHTML=groups.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=>{const next=u.status==='active'?'disabled':'active';return '<div class="result user-row"><div><div class="name">'+esc(u.username)+'</div><div class="meta">#'+u.id+' · '+esc(u.role)+' · '+esc(u.status)+'</div><div class="meta" id="credit-'+u.id+'">积分加载中…</div></div><div class="toolbar"><button class="btn alt" onclick="adjustCredits('+u.id+',1)">+积分</button><button class="btn alt" onclick="adjustCredits('+u.id+',-1)">-积分</button><button class="btn alt" onclick="resetUserPassword('+u.id+')">重置密码</button><button class="btn alt" onclick="toggleUser('+u.id+',\''+next+'\')">'+(u.status==='active'?'禁用':'启用')+'</button></div></div>'}).join(''):'<div class="empty">暂无账号</div>')+'</div></div>'}).join('');for(const u of users){loadUserCredit(u.id)}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('');const l=await api('/admin/audit');$('audit').innerHTML=(l.data||[]).map(x=>'<div class="notice">'+esc(x.action)+' · '+esc(x.targetType)+'#'+esc(x.targetId)+' · '+esc(x.metadata||'')+' · '+esc(x.createdAt)+'</div>').join('')||'暂无日志'}catch(e){$('users').textContent=e.message}}
async function loadUserCredit(id){try{const c=await api('/admin/users/'+id+'/credits');$('credit-'+id).textContent='余额 '+c.balance+' · 预留 '+c.reserved+' · 可用 '+c.available+' · 下载 '+c.tasks}catch(e){const el=$('credit-'+id);if(el)el.textContent='积分加载失败'}}
async function adjustCredits(id,sign){const raw=prompt(sign>0?'给该用户增加多少积分?':'从该用户扣除多少积分?','10');if(raw===null)return;const amount=Number(raw);if(!Number.isInteger(amount)||amount<=0){alert('请输入正整数');return}try{await api('/admin/users/'+id+'/credits',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({amount:sign*amount})});loadUserCredit(id);loadAdmin()}catch(e){alert('积分调整失败:'+e.message)}}
async function resetUserPassword(id){const p=prompt('输入新密码(至少 8 位):');if(p===null)return;if(p.length<8){alert('密码至少 8 位');return}try{await api('/admin/users/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:p})});alert('密码已重置,该用户需要重新登录')}catch(e){alert('重置失败:'+e.message)}}
async function toggleUser(id,status){try{await api('/admin/users/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status})});loadAdmin()}catch(e){alert('操作失败:'+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);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('密码已重置,该用户的其他登录会话已失效');loadAdmin()}catch(e){alert('密码重置失败:'+e.message)}}
async function revokeBatch(id){await api('/admin/redeem/'+id+'/revoke',{method:'POST'});loadAdmin()}
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';document.getElementById('userPill').textContent=u.username+' · '+u.role;document.getElementById('settings').style.display=u.role==='admin'?'':'none';document.getElementById('adminNav').style.display=u.role==='admin'?'':'none';document.getElementById('mobileAdminNav').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');document.getElementById('userPill').textContent=u.username+' · '+u.role+' · 积分 '+c.balance+' / 可用 '+c.available;hideLogin();return true}catch(e){showLogin();return false}}
$('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>