feat: 声匣 —— 网易云 / QQ 音乐自托管下载服务

由 163-music-dl CLI 重构为 Web 服务:
- 下载流水线、歌词与标签逻辑沿用旧版(node-id3 / ffmpeg)
- 网易云(chksz 接口 + music.163.com 元数据)与 QQ 音乐(VIP Cookie)provider
- QQ 扫码登录与 musickey 自动续期
- SQLite 持久化的下载队列,SSE 实时进度
- 曲库工具(M3U / ZIP / 回收站 / 补全标签)、历史统计与导出、访问密码
- Vite 8 + Vue 3 前端,浅色 / 深色
- Docker 部署配置(飞牛 NAS)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-10 22:05:30 +08:00
commit 21945cc54f
73 changed files with 12507 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>声匣</title>
<script>
try {
const t = localStorage.getItem('sx-theme');
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
} catch {}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="15" fill="#1F1D1A"/><circle cx="16" cy="16" r="11" fill="none" stroke="#3a3631" stroke-width=".8"/><circle cx="16" cy="16" r="8" fill="none" stroke="#3a3631" stroke-width=".8"/><circle cx="16" cy="16" r="4.5" fill="#B5523B"/><circle cx="16" cy="16" r="1.2" fill="#F4F1EA"/></svg>

After

Width:  |  Height:  |  Size: 367 B

+411
View File
@@ -0,0 +1,411 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import PlayerBar from './components/PlayerBar.vue';
import ToastHost from './components/ToastHost.vue';
import { formatSpeed } from './lib/format.ts';
import { activeCount, auth, bootstrap, connected, progress, qqStatus, summary } from './lib/store.ts';
import { cycleTheme, themeMode } from './lib/theme.ts';
import { navItems } from './router.ts';
import LoginView from './views/LoginView.vue';
onMounted(() => bootstrap().catch(() => {}));
const themeLabel = { auto: '跟随系统', light: '纸', dark: '墨' } as const;
const totalSpeed = computed(() => [...progress.values()].reduce((s, p) => s + (p.speed || 0), 0));
const qqLabel = computed(() => {
const s = qqStatus.value;
if (!s) return '…';
if (!s.loggedIn) return '未登录';
if (s.valid === false) return '已失效';
return s.vip?.active ? s.vip.label : '已登录';
});
</script>
<template>
<LoginView v-if="auth.checked && !auth.authenticated" @success="bootstrap" />
<div v-else class="shell">
<aside class="side">
<RouterLink to="/" class="brand">
<span class="disc" aria-hidden="true"><i /></span>
<span class="brand-text">
<strong>声匣</strong>
<span class="eyebrow">Sound&nbsp;Case</span>
</span>
</RouterLink>
<nav class="nav">
<RouterLink v-for="(item, i) in navItems" :key="item.path" :to="item.path" class="nav-item" :class="{ active: item.path === '/' ? $route.path === '/' || ['album', 'playlist', 'artist'].includes(String($route.name)) : $route.path.startsWith(item.path) }">
<span class="nav-no mono">{{ String(i + 1).padStart(2, '0') }}</span>
<span class="nav-label">{{ item.label }}</span>
<span v-if="item.name === 'queue' && activeCount" class="nav-badge mono">{{ activeCount }}</span>
</RouterLink>
</nav>
<RouterLink v-if="activeCount || summary.paused" to="/queue" class="dock">
<div class="dock-head">
<span class="eyebrow">{{ summary.paused ? 'Paused' : 'Live' }}</span>
<span class="mono faint">{{ summary.paused ? '已暂停' : formatSpeed(totalSpeed) }}</span>
</div>
<div class="dock-line">
<span class="pulse" :class="{ off: summary.paused }" />
<span>进行 <b class="mono">{{ summary.counts.running ?? 0 }}</b> · 排队 <b class="mono">{{ summary.counts.queued ?? 0 }}</b></span>
</div>
</RouterLink>
<div class="side-foot">
<div class="sources">
<div class="src">
<span class="dot netease" :class="{ on: connected }" />
网易云
<span class="faint mono">{{ connected ? '可用' : '连接中' }}</span>
</div>
<RouterLink to="/settings" class="src">
<span class="dot qq" :class="{ on: qqStatus?.loggedIn && qqStatus.valid !== false }" />
QQ 音乐
<span class="faint mono">{{ qqLabel }}</span>
</RouterLink>
</div>
<button class="theme" type="button" @click="cycleTheme" :title="`主题:${themeLabel[themeMode]}`">
<span class="theme-swatch" :data-mode="themeMode" />
{{ themeLabel[themeMode] }}
</button>
</div>
</aside>
<div class="column">
<main class="main">
<RouterView v-slot="{ Component, route }">
<Transition name="page" mode="out-in">
<component :is="Component" :key="route.fullPath" />
</Transition>
</RouterView>
</main>
<PlayerBar />
</div>
</div>
<ToastHost />
</template>
<style scoped>
.shell {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
height: 100%;
}
.side {
display: flex;
flex-direction: column;
gap: 32px;
padding: 28px 18px 22px 24px;
border-right: 1px solid var(--line);
overflow-y: auto;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand strong {
display: block;
font: 600 22px/1.1 var(--font-serif);
letter-spacing: 0.08em;
}
.brand .eyebrow {
font-size: 9.5px;
}
.disc {
position: relative;
width: 34px;
height: 34px;
border-radius: 50%;
background: repeating-radial-gradient(circle, var(--ink) 0 1.4px, color-mix(in oklab, var(--ink) 82%, var(--bg)) 1.4px 2.6px);
transition: transform 1.2s var(--ease);
}
.disc i {
position: absolute;
inset: 11px;
border-radius: 50%;
background: var(--accent);
box-shadow: inset 0 0 0 4px var(--accent), inset 0 0 0 5px var(--bg);
}
.brand:hover .disc {
transform: rotate(160deg);
}
.nav {
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
position: relative;
display: flex;
align-items: baseline;
gap: 12px;
padding: 8px 10px;
border-radius: var(--radius-sm);
color: var(--ink-2);
transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
}
.nav-item:hover {
color: var(--ink);
}
.nav-no {
font-size: 10.5px;
color: var(--ink-3);
}
.nav-label {
font-size: 15px;
letter-spacing: 0.06em;
}
.nav-badge {
margin-left: auto;
align-self: center;
min-width: 20px;
padding: 0 6px;
border-radius: 999px;
background: var(--accent);
color: var(--accent-ink);
font-size: 10.5px;
line-height: 18px;
text-align: center;
}
.nav-item.active {
color: var(--ink);
background: var(--surface-2);
}
.nav-item.active .nav-no {
color: var(--accent);
}
.dock {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
font-size: 12.5px;
transition: border-color var(--dur) var(--ease);
}
.dock:hover {
border-color: var(--line-strong);
}
.dock-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
font-size: 11px;
white-space: nowrap;
}
.dock-head .eyebrow {
font-size: 10px;
letter-spacing: 0.1em;
}
.dock-line {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink-2);
}
.dock b {
color: var(--ink);
font-weight: 600;
}
.pulse {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
animation: pulse 1.6s var(--ease) infinite;
}
.pulse.off {
background: var(--ink-3);
animation: none;
}
@keyframes pulse {
50% {
box-shadow: 0 0 0 5px var(--accent-soft);
}
}
.side-foot {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.sources {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12.5px;
color: var(--ink-2);
}
.src {
display: flex;
align-items: center;
gap: 8px;
}
.src .mono {
margin-left: auto;
font-size: 11px;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
border: 1.5px solid currentColor;
color: var(--ink-3);
}
.dot.on.netease {
background: var(--netease);
border-color: var(--netease);
}
.dot.on.qq {
background: var(--qq);
border-color: var(--qq);
}
.theme {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border: 0;
background: none;
color: var(--ink-3);
font-size: 12px;
}
.theme:hover {
color: var(--ink);
}
.theme-swatch {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--line-strong);
background: linear-gradient(135deg, #f4f1ea 50%, #121315 50%);
}
.theme-swatch[data-mode='light'] {
background: #f4f1ea;
}
.theme-swatch[data-mode='dark'] {
background: #121315;
}
.column {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.main {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-gutter: stable;
}
.page-enter-active,
.page-leave-active {
transition: opacity 150ms var(--ease), transform 150ms var(--ease);
}
.page-enter-from {
opacity: 0;
transform: translateY(4px);
}
.page-leave-to {
opacity: 0;
}
@media (max-width: 760px) {
.shell {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
}
.column {
min-height: 0;
}
.side {
order: 2;
flex-direction: row;
align-items: center;
gap: 0;
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
border-right: 0;
border-top: 1px solid var(--line);
background: var(--surface);
overflow: visible;
}
.brand,
.side-foot,
.dock {
display: none;
}
.nav {
flex: 1;
flex-direction: row;
justify-content: space-around;
}
.nav-item {
flex-direction: column;
align-items: center;
gap: 0;
padding: 6px 10px;
}
.nav-badge {
position: absolute;
top: 0;
right: 0;
}
.nav-label {
font-size: 13px;
}
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { coverThumb } from '../lib/format.ts';
const props = defineProps<{ src?: string | null; size?: number; round?: boolean; px?: number }>();
const failed = ref(false);
watch(
() => props.src,
() => (failed.value = false),
);
const url = computed(() => (failed.value ? undefined : coverThumb(props.src, props.px ?? (props.size ?? 44) * 2)));
</script>
<template>
<span class="cover" :class="{ round }" :style="{ width: `${size ?? 44}px`, height: `${size ?? 44}px` }">
<img v-if="url" :src="url" alt="" loading="lazy" referrerpolicy="no-referrer" @error="failed = true" />
</span>
</template>
<style scoped>
.cover {
position: relative;
display: block;
flex-shrink: 0;
overflow: hidden;
border-radius: 4px;
background:
radial-gradient(circle at 50% 50%, var(--line-strong) 0 7%, transparent 7.5%),
repeating-radial-gradient(circle, var(--surface-2) 0 2px, color-mix(in oklab, var(--surface-2) 70%, var(--line)) 2px 3px);
box-shadow: inset 0 0 0 1px var(--line);
}
.cover.round {
border-radius: 50%;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { ref } from 'vue';
import type { Source } from '../lib/api.ts';
import { SOURCE_LABEL } from '../lib/format.ts';
import CoverArt from './CoverArt.vue';
defineProps<{
source: Source;
kind: string;
title: string;
cover?: string;
round?: boolean;
meta?: Array<string | undefined | false>;
description?: string;
}>();
const expanded = ref(false);
</script>
<template>
<header class="dh" :class="source">
<div class="art" :class="{ round }">
<CoverArt :src="cover" :size="196" :px="500" :round="round" />
<span v-if="!round" class="vinyl" aria-hidden="true" />
</div>
<div class="info">
<span class="eyebrow kicker"><i class="dot" />{{ kind }} · {{ SOURCE_LABEL[source] }}</span>
<h1>{{ title }}</h1>
<div class="by"><slot name="by" /></div>
<div v-if="meta?.some(Boolean)" class="meta mono faint">
<template v-for="(m, i) in meta.filter(Boolean)" :key="i">
<span v-if="i" class="sep">/</span><span>{{ m }}</span>
</template>
</div>
<p v-if="description" class="desc muted" :class="{ open: expanded }" @click="expanded = !expanded">{{ description }}</p>
<div class="actions"><slot name="actions" /></div>
</div>
</header>
</template>
<style scoped>
.dh {
display: grid;
grid-template-columns: auto 1fr;
gap: 40px;
align-items: end;
padding: 8px 0 30px;
}
.art {
position: relative;
width: 196px;
height: 196px;
isolation: isolate;
}
.art :deep(.cover) {
box-shadow: var(--shadow), inset 0 0 0 1px var(--line);
}
.vinyl {
position: absolute;
top: 6px;
left: 70px;
width: 184px;
height: 184px;
z-index: -1;
border-radius: 50%;
background:
radial-gradient(circle, var(--accent) 0 13%, var(--ink) 13% 14%, transparent 14%),
repeating-radial-gradient(circle, var(--ink) 0 1.5px, color-mix(in oklab, var(--ink) 84%, var(--bg)) 1.5px 3px);
transition: transform 600ms var(--ease);
}
.dh:hover .vinyl {
transform: translateX(14px) rotate(40deg);
}
.info {
min-width: 0;
padding-left: 60px;
}
.art.round + .info {
padding-left: 0;
}
.kicker {
display: inline-flex;
align-items: center;
gap: 8px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--netease);
}
.qq .dot {
background: var(--qq);
}
h1 {
margin-top: 12px;
font-size: clamp(26px, 3.4vw, 40px);
line-height: 1.15;
word-break: break-word;
}
.by {
margin-top: 10px;
font-size: 15px;
color: var(--ink-2);
}
.by :deep(a:hover) {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
font-size: 12px;
}
.sep {
color: var(--line-strong);
}
.desc {
margin: 14px 0 0;
max-width: 640px;
font-size: 13px;
line-height: 1.7;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: pre-line;
cursor: pointer;
}
.desc.open {
-webkit-line-clamp: unset;
line-clamp: unset;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.actions:empty {
display: none;
}
@media (max-width: 760px) {
.dh {
grid-template-columns: 1fr;
gap: 18px;
}
.art {
width: 140px;
height: 140px;
}
.art :deep(.cover) {
width: 140px !important;
height: 140px !important;
}
.vinyl {
width: 130px;
height: 130px;
left: 50px;
}
.info {
padding-left: 0;
}
}
</style>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
// 线性图标,1.6px 描边,跟随 currentColor
defineProps<{ name: string; size?: number }>();
</script>
<script lang="ts">
const paths = {
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>',
pause: '<rect x="7" y="5.5" width="3.4" height="13" rx="1" fill="currentColor" stroke="none"/><rect x="13.6" y="5.5" width="3.4" height="13" rx="1" fill="currentColor" stroke="none"/>',
download: '<path d="M12 4v11m0 0-4.5-4.5M12 15l4.5-4.5M5 19.5h14"/>',
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
x: '<path d="M6 6l12 12M18 6 6 18"/>',
retry: '<path d="M4.5 12a7.5 7.5 0 1 0 2.2-5.3M4.5 4.5v4h4"/>',
folder: '<path d="M3.5 7.5a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v7.5a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"/>',
music: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
file: '<path d="M7 3.5h7l4 4v13H7z"/><path d="M14 3.5v4h4"/>',
trash: '<path d="M5 7h14M10 7V5h4v2M7 7l1 12.5h8L17 7"/>',
zip: '<path d="M7 3.5h10v17H7z"/><path d="M12 3.5v2m0 2v2m0 2v2"/><rect x="10.5" y="14" width="3" height="3" rx=".6"/>',
list: '<path d="M9 7h11M9 12h11M9 17h11M4.5 7h.01M4.5 12h.01M4.5 17h.01"/>',
search: '<circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.4-4.4"/>',
chevron: '<path d="m9 6 6 6-6 6"/>',
back: '<path d="m15 6-6 6 6 6"/>',
tag: '<path d="M4 4h7.5l8.5 8.5-7.5 7.5L4 11.5z"/><circle cx="8.5" cy="8.5" r="1.3"/>',
pauseCircle: '<circle cx="12" cy="12" r="8.5"/><path d="M10 9v6M14 9v6"/>',
playCircle: '<circle cx="12" cy="12" r="8.5"/><path d="m10.2 8.8 5 3.2-5 3.2z"/>',
volume: '<path d="M4.5 9.5h3l4.5-4v13l-4.5-4h-3z"/><path d="M15.5 9a4 4 0 0 1 0 6M18 6.5a7.5 7.5 0 0 1 0 11"/>',
link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
user: '<circle cx="12" cy="8.5" r="3.8"/><path d="M4.5 20c1.2-3.8 4.2-5.5 7.5-5.5s6.3 1.7 7.5 5.5"/>',
disc: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2.2"/>',
export: '<path d="M12 15V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v5.5h14V14"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
logout: '<path d="M14 4.5H6.5v15H14M10.5 12H20m0 0-3.5-3.5M20 12l-3.5 3.5"/>',
} as const;
</script>
<template>
<svg
class="icon"
:width="size ?? 18"
:height="size ?? 18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
v-html="paths[name as keyof typeof paths]"
/>
</template>
<style scoped>
.icon {
flex-shrink: 0;
display: block;
}
</style>
+240
View File
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatSeconds } from '../lib/format.ts';
import { currentLyric, player, seek, setVolume, stop, toggle } from '../lib/player.ts';
import CoverArt from './CoverArt.vue';
import Icon from './Icon.vue';
const pct = computed(() => (player.duration ? (player.time / player.duration) * 100 : 0));
function onSeek(e: MouseEvent) {
const el = e.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
seek((e.clientX - rect.left) / rect.width);
}
</script>
<template>
<Transition name="slide">
<div v-if="player.current" class="player">
<div class="disc" :class="{ spin: player.playing }">
<CoverArt :src="player.current.cover" :size="46" round />
<i class="hole" />
</div>
<div class="meta">
<div class="t">{{ player.current.title }}</div>
<div class="a muted">
<Transition name="fade" mode="out-in">
<span v-if="currentLyric && player.playing" :key="currentLyric" class="lyric">{{ currentLyric }}</span>
<span v-else>{{ player.current.artists }}</span>
</Transition>
</div>
</div>
<button class="pp" type="button" @click="toggle" :aria-label="player.playing ? '暂停' : '播放'">
<Icon :name="player.playing ? 'pause' : 'play'" :size="20" />
</button>
<div class="timeline">
<span class="mono faint">{{ formatSeconds(player.time) }}</span>
<div class="track" @click="onSeek">
<div class="fill" :style="{ width: `${pct}%` }" />
<div class="knob" :style="{ left: `${pct}%` }" />
</div>
<span class="mono faint">{{ formatSeconds(player.duration) }}</span>
</div>
<label class="vol" title="音量">
<Icon name="volume" :size="17" />
<input type="range" min="0" max="1" step="0.01" :value="player.volume" @input="setVolume(Number(($event.target as HTMLInputElement).value))" />
</label>
<button class="close" type="button" @click="stop" aria-label="关闭"><Icon name="x" :size="16" /></button>
</div>
</Transition>
</template>
<style scoped>
.player {
display: grid;
grid-template-columns: auto minmax(120px, 240px) auto 1fr auto auto;
align-items: center;
gap: 18px;
padding: 10px 20px 10px 16px;
border-top: 1px solid var(--line);
background: color-mix(in oklab, var(--surface) 94%, transparent);
backdrop-filter: blur(12px);
}
.disc {
position: relative;
border-radius: 50%;
box-shadow: 0 0 0 3px var(--ink), var(--shadow);
}
.disc.spin {
animation: spin 7s linear infinite;
}
.hole {
position: absolute;
inset: 19px;
border-radius: 50%;
background: var(--bg);
box-shadow: 0 0 0 2px var(--ink);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.meta {
min-width: 0;
}
.t {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.a {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lyric {
font-family: var(--font-serif);
color: var(--ink);
}
.pp {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: 0;
border-radius: 50%;
background: var(--ink);
color: var(--bg);
transition: transform var(--dur) var(--ease);
}
.pp:hover {
transform: scale(1.06);
}
.timeline {
display: flex;
align-items: center;
gap: 12px;
font-size: 11px;
min-width: 0;
}
.track {
position: relative;
flex: 1;
height: 14px;
cursor: pointer;
}
.track::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 6px;
height: 2px;
border-radius: 2px;
background: var(--line-strong);
}
.fill {
position: absolute;
left: 0;
top: 6px;
height: 2px;
border-radius: 2px;
background: var(--accent);
}
.knob {
position: absolute;
top: 3px;
width: 8px;
height: 8px;
margin-left: -4px;
border-radius: 50%;
background: var(--accent);
opacity: 0;
transition: opacity var(--dur) var(--ease);
}
.track:hover .knob {
opacity: 1;
}
.vol {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink-3);
}
.vol input {
width: 80px;
accent-color: var(--accent);
}
.close {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
background: none;
color: var(--ink-3);
}
.close:hover {
color: var(--ink);
background: var(--surface-2);
}
.slide-enter-active,
.slide-leave-active {
transition: transform 220ms var(--ease), opacity 220ms var(--ease);
}
.slide-enter-from,
.slide-leave-to {
transform: translateY(100%);
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 180ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 760px) {
.player {
grid-template-columns: auto 1fr auto auto;
gap: 12px;
padding: 8px 12px;
}
.timeline,
.vol {
display: none;
}
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { api, type QQStatus, type QRState } from '../lib/api.ts';
import Icon from './Icon.vue';
const emit = defineEmits<{ close: []; success: [qq: QQStatus] }>();
const image = ref('');
const state = ref<QRState | 'loading'>('loading');
const message = ref('');
let id = '';
let timer: ReturnType<typeof setTimeout> | undefined;
let alive = true;
const hint = computed(
() =>
({
loading: '正在获取二维码…',
waiting: '请使用手机 QQ 扫描二维码',
scanned: '已扫描,请在手机上确认登录',
expired: '二维码已过期',
refused: '已在手机上取消登录',
done: '登录成功',
error: message.value || '登录失败',
})[state.value],
);
async function start() {
clearTimeout(timer);
state.value = 'loading';
image.value = '';
try {
const qr = await api.qqQR();
id = qr.id;
image.value = qr.image;
state.value = 'waiting';
poll();
} catch (e) {
state.value = 'error';
message.value = (e as Error).message;
}
}
function poll() {
timer = setTimeout(async () => {
if (!alive) return;
try {
const res = await api.qqQRStatus(id);
state.value = res.state;
message.value = res.message ?? '';
if (res.state === 'done' && res.qq) {
emit('success', res.qq);
return;
}
if (res.state === 'waiting' || res.state === 'scanned') poll();
} catch (e) {
state.value = 'error';
message.value = (e as Error).message;
}
}, 1500);
}
onMounted(start);
onBeforeUnmount(() => {
alive = false;
clearTimeout(timer);
});
</script>
<template>
<div class="backdrop" @click.self="emit('close')">
<div class="dialog" role="dialog" aria-label="QQ 扫码登录">
<button class="x" type="button" aria-label="关闭" @click="emit('close')"><Icon name="x" :size="16" /></button>
<span class="eyebrow">QQ 音乐 · 扫码登录</span>
<div class="frame" :class="state">
<img v-if="image" :src="image" alt="登录二维码" />
<div v-if="state !== 'waiting' && state !== 'loading'" class="veil">
<Icon v-if="state === 'scanned' || state === 'done'" name="check" :size="34" />
<button v-else type="button" class="btn sm" @click="start"><Icon name="retry" :size="14" /> 刷新二维码</button>
</div>
</div>
<p class="hint" :class="state">{{ hint }}</p>
<p class="faint small">登录后自动获取 refresh_token,凭据会在后台定期续期。</p>
</div>
</div>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 16px;
background: color-mix(in oklab, var(--ink) 35%, transparent);
backdrop-filter: blur(3px);
}
.dialog {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 320px;
max-width: 100%;
padding: 26px 26px 22px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface);
box-shadow: var(--shadow);
text-align: center;
}
.x {
position: absolute;
top: 10px;
right: 10px;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
background: none;
color: var(--ink-3);
}
.x:hover {
background: var(--surface-2);
color: var(--ink);
}
.frame {
position: relative;
width: 200px;
height: 200px;
padding: 10px;
border-radius: 10px;
background: #fff;
box-shadow: inset 0 0 0 1px var(--line);
}
.frame img {
width: 100%;
height: 100%;
image-rendering: pixelated;
}
.veil {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 10px;
background: color-mix(in oklab, var(--surface) 88%, transparent);
color: var(--qq);
}
.hint {
margin: 0;
font-size: 14px;
}
.hint.scanned,
.hint.done {
color: var(--qq);
}
.hint.expired,
.hint.refused,
.hint.error {
color: var(--danger);
}
.small {
margin: -6px 0 0;
font-size: 12px;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { QualityKey, Source } from '../lib/api.ts';
import { QUALITY_BADGE, QUALITY_LABEL } from '../lib/format.ts';
import { meta } from '../lib/store.ts';
const props = defineProps<{ source: Source }>();
const model = defineModel<QualityKey>({ required: true });
const options = computed(() => {
const supported = meta.value?.sourceQualities[props.source];
const all = (meta.value?.qualities.map((q) => q.key) ?? []) as QualityKey[];
return (supported?.length ? all.filter((k) => supported.includes(k)) : all).reverse();
});
</script>
<template>
<label class="qp" title="下载音质(不可用时自动降级)">
<span class="eyebrow">音质</span>
<select v-model="model">
<option v-for="q in options" :key="q" :value="q">{{ QUALITY_LABEL[q] }} · {{ QUALITY_BADGE[q] }}</option>
</select>
</label>
</template>
<style scoped>
.qp {
display: inline-flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 4px 0 12px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface);
}
select {
height: 100%;
border: 0;
background: transparent;
padding: 0 6px;
font-size: 13px;
outline: none;
cursor: pointer;
}
option {
background: var(--surface);
color: var(--ink);
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { toasts } from '../lib/store.ts';
</script>
<template>
<div class="toasts" aria-live="polite">
<TransitionGroup name="toast">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.kind">
<i class="mark" />
<span>{{ t.text }}</span>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.toasts {
position: fixed;
right: 20px;
top: 20px;
z-index: 50;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
max-width: 380px;
padding: 10px 16px 10px 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow);
font-size: 13px;
pointer-events: auto;
}
.mark {
width: 6px;
height: 6px;
flex-shrink: 0;
border-radius: 50%;
background: var(--ink-3);
}
.ok .mark {
background: var(--ok);
}
.error .mark {
background: var(--danger);
}
.toast-enter-active,
.toast-leave-active {
transition: all 220ms var(--ease);
}
.toast-enter-from {
opacity: 0;
transform: translateY(-6px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(10px);
}
@media (max-width: 760px) {
.toasts {
left: 12px;
right: 12px;
top: 12px;
}
}
</style>
+464
View File
@@ -0,0 +1,464 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { QualityKey, TrackSummary } from '../lib/api.ts';
import { bestQuality, formatDuration, isGold, QUALITY_BADGE, STAGE_LABEL } from '../lib/format.ts';
import { isPlaying, player, playOnline } from '../lib/player.ts';
import { activeByTrack, defaultQualityFor, download, isDownloaded, progress, trackKey } from '../lib/store.ts';
import CoverArt from './CoverArt.vue';
import Icon from './Icon.vue';
import QualityPicker from './QualityPicker.vue';
const props = withDefaults(
defineProps<{
tracks: TrackSummary[];
group?: string;
showCover?: boolean;
showAlbum?: boolean;
numbering?: 'index' | 'track';
hideToolbar?: boolean;
}>(),
{ showCover: true, showAlbum: true, numbering: 'index' },
);
const source = computed(() => props.tracks[0]?.source ?? 'netease');
const quality = ref<QualityKey>(defaultQualityFor(source.value));
watch(source, (s) => (quality.value = defaultQualityFor(s)));
const selected = ref(new Set<string>());
let anchor = -1;
watch(
() => props.tracks,
() => {
selected.value = new Set();
anchor = -1;
},
);
const allSelected = computed(() => props.tracks.length > 0 && selected.value.size === props.tracks.length);
const pendingCount = computed(() => props.tracks.filter((t) => !isDownloaded(t)).length);
function toggleRow(index: number, e: MouseEvent) {
const next = new Set(selected.value);
const key = props.tracks[index]!.id;
if (e.shiftKey && anchor >= 0) {
const [a, b] = [Math.min(anchor, index), Math.max(anchor, index)];
const value = !next.has(key);
for (let i = a; i <= b; i++) value ? next.add(props.tracks[i]!.id) : next.delete(props.tracks[i]!.id);
} else {
next.has(key) ? next.delete(key) : next.add(key);
}
anchor = index;
selected.value = next;
}
function toggleAll() {
selected.value = allSelected.value ? new Set() : new Set(props.tracks.map((t) => t.id));
}
function selectPending() {
selected.value = new Set(props.tracks.filter((t) => !isDownloaded(t)).map((t) => t.id));
}
async function downloadSelected() {
const items = props.tracks.filter((t) => selected.value.has(t.id));
await download(items, quality.value, props.group);
selected.value = new Set();
}
function downloadAll() {
download(props.tracks, quality.value, props.group);
}
function rowState(t: TrackSummary) {
const task = activeByTrack.value.get(trackKey(t.source, t.id));
if (task) {
if (task.status === 'running') {
const p = progress.get(task.id);
if (p && p.stage === 'download' && p.total) return { kind: 'running', text: `${Math.round((p.received / p.total) * 100)}%`, pct: p.received / p.total };
return { kind: 'running', text: STAGE_LABEL[task.stage ?? 'resolve'] ?? '处理中', pct: 0 };
}
return { kind: task.status, text: task.status === 'paused' ? '已暂停' : '排队中', pct: 0 };
}
if (isDownloaded(t)) return { kind: 'done', text: '已下载', pct: 1 };
return null;
}
function number(t: TrackSummary, i: number) {
return String(props.numbering === 'track' && t.trackNo ? t.trackNo : i + 1).padStart(2, '0');
}
const best = (t: TrackSummary) => bestQuality(t.qualities);
</script>
<template>
<div class="tracklist" :class="{ 'no-cover': !showCover }">
<div v-if="!hideToolbar" class="toolbar">
<button type="button" class="check" :class="{ on: allSelected, part: selected.size && !allSelected }" @click="toggleAll" title="全选">
<Icon v-if="allSelected" name="check" :size="13" />
</button>
<span class="sel muted">
<template v-if="selected.size">已选 <b class="mono">{{ selected.size }}</b> 首</template>
<template v-else>
共 <b class="mono">{{ tracks.length }}</b> 首
<button v-if="pendingCount && pendingCount < tracks.length" class="link" type="button" @click="selectPending">
· 选择未下载的 {{ pendingCount }} 首
</button>
</template>
</span>
<div class="spacer" />
<QualityPicker v-model="quality" :source="source" />
<button v-if="selected.size" class="btn primary" type="button" @click="downloadSelected">
<Icon name="download" :size="16" /> 下载所选
</button>
<button v-else class="btn" type="button" :disabled="!tracks.length" @click="downloadAll">
<Icon name="download" :size="16" /> 全部下载
</button>
</div>
<ol class="rows">
<li
v-for="(t, i) in tracks"
:key="`${t.source}-${t.id}-${i}`"
class="row"
:class="[t.source, { selected: selected.has(t.id), current: player.current?.key === `${t.source}:${t.id}` }]"
@dblclick="playOnline(t)"
>
<button type="button" class="check" :class="{ on: selected.has(t.id) }" @click="toggleRow(i, $event)" :aria-label="`选择 ${t.name}`">
<Icon v-if="selected.has(t.id)" name="check" :size="13" />
</button>
<span class="num">
<span class="mono faint idx">{{ number(t, i) }}</span>
<button type="button" class="play" @click="playOnline(t)" :aria-label="`试听 ${t.name}`">
<Icon :name="isPlaying(`${t.source}:${t.id}`) ? 'pause' : 'play'" :size="16" />
</button>
</span>
<CoverArt v-if="showCover" :src="t.cover" :size="40" />
<div class="title">
<div class="name">
{{ t.name }}
<span v-if="t.vip" class="vip" title="VIP 歌曲">VIP</span>
</div>
<div class="sub muted">
<template v-if="t.artistRefs?.length">
<template v-for="(a, ai) in t.artistRefs" :key="a.id">
<span v-if="ai" class="faint"> / </span>
<RouterLink :to="`/artist/${t.source}/${a.id}`" class="ln">{{ a.name }}</RouterLink>
</template>
</template>
<template v-else>{{ t.artists }}</template>
<template v-if="showAlbum && t.album">
<span class="faint"> · </span>
<RouterLink v-if="t.albumId" :to="`/album/${t.source}/${t.albumId}`" class="ln faint">{{ t.album }}</RouterLink>
<span v-else class="faint">{{ t.album }}</span>
</template>
</div>
</div>
<span class="badges">
<span v-if="best(t)" class="badge" :class="{ gold: isGold(best(t)) }">{{ QUALITY_BADGE[best(t)!] }}</span>
</span>
<span class="state">
<template v-if="rowState(t)">
<span class="st" :class="rowState(t)!.kind">
<Icon v-if="rowState(t)!.kind === 'done'" name="check" :size="14" />
{{ rowState(t)!.kind === 'done' ? '' : rowState(t)!.text }}
</span>
<span v-if="rowState(t)!.kind === 'running'" class="bar"><i :style="{ width: `${rowState(t)!.pct * 100}%` }" /></span>
</template>
</span>
<span class="dur mono faint">{{ formatDuration(t.duration) }}</span>
<button type="button" class="dl" title="下载" @click="download([t], quality, group)">
<Icon name="download" :size="17" />
</button>
</li>
</ol>
</div>
</template>
<style scoped>
.toolbar {
position: sticky;
top: 0;
z-index: 3;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 10px 12px 12px;
background: color-mix(in oklab, var(--bg) 92%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1.5px solid var(--ink);
}
.sel {
font-size: 13px;
}
.sel b {
color: var(--ink);
font-weight: 600;
}
.link {
border: 0;
background: none;
padding: 0;
color: var(--accent);
font-size: 13px;
}
.spacer {
flex: 1;
}
.check {
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 1.5px solid var(--line-strong);
border-radius: 5px;
background: transparent;
color: var(--accent-ink);
transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.check:hover {
border-color: var(--ink-3);
}
.check.on {
background: var(--accent);
border-color: var(--accent);
}
.check.part {
background: linear-gradient(var(--accent), var(--accent)) center / 8px 2px no-repeat;
border-color: var(--accent);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
}
.row {
position: relative;
display: grid;
grid-template-columns: 18px 30px auto minmax(0, 1fr) auto 96px 44px 32px;
align-items: center;
gap: 14px;
padding: 9px 10px 9px 12px;
border-bottom: 1px solid var(--line);
transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.row::before {
content: '';
position: absolute;
left: 0;
top: 13px;
bottom: 13px;
width: 2px;
border-radius: 2px;
background: var(--netease);
opacity: 0;
transition: opacity var(--dur) var(--ease);
}
.row.qq::before {
background: var(--qq);
}
.row:hover::before,
.row.selected::before,
.row.current::before {
opacity: 0.8;
}
.row:hover {
border-bottom-color: var(--line-strong);
background: color-mix(in oklab, var(--surface) 60%, transparent);
}
.row.selected {
background: var(--accent-soft);
}
.no-cover .row {
grid-template-columns: 18px 30px minmax(0, 1fr) auto 96px 44px 32px;
}
.num {
position: relative;
display: grid;
place-items: center;
height: 30px;
}
.idx {
font-size: 11px;
}
.play {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: var(--ink);
color: var(--bg);
opacity: 0;
transform: scale(0.85);
transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.row:hover .play,
.row.current .play,
.play:focus-visible {
opacity: 1;
transform: none;
}
.row.current .play {
background: var(--accent);
color: var(--accent-ink);
}
.title {
min-width: 0;
}
.name {
font-size: 14.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row.current .name {
color: var(--accent);
}
.vip {
margin-left: 6px;
padding: 0 4px;
border-radius: 3px;
font: 600 9px/14px var(--font-mono);
letter-spacing: 0.05em;
color: var(--qq);
border: 1px solid color-mix(in oklab, var(--qq) 50%, transparent);
vertical-align: 2px;
}
.row.netease .vip {
color: var(--netease);
border-color: color-mix(in oklab, var(--netease) 50%, transparent);
}
.sub {
font-size: 12.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ln:hover {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: var(--line-strong);
}
.badges {
display: flex;
gap: 4px;
}
.state {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 11.5px;
}
.st {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--ink-3);
font-family: var(--font-mono);
}
.st.done {
color: var(--ok);
}
.st.running {
color: var(--accent);
}
.bar {
width: 72px;
height: 2px;
border-radius: 2px;
background: var(--line);
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: var(--accent);
transition: width 300ms linear;
}
.dur {
font-size: 12px;
text-align: right;
}
.dl {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--ink-3);
transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
}
.dl:hover {
color: var(--accent);
background: var(--accent-soft);
}
@media (max-width: 760px) {
.toolbar {
flex-wrap: wrap;
}
.row {
grid-template-columns: 18px auto minmax(0, 1fr) auto 32px;
gap: 10px;
}
.no-cover .row {
grid-template-columns: 18px minmax(0, 1fr) auto 32px;
}
.num,
.badges,
.dur {
display: none;
}
.state .bar {
width: 44px;
}
}
</style>
+323
View File
@@ -0,0 +1,323 @@
export type Source = 'netease' | 'qq';
export type QualityKey = 'standard' | 'high' | 'lossless' | 'hires' | 'master' | 'spatial' | 'effect';
export interface ArtistRef {
id: string;
name: string;
}
export interface TrackSummary {
source: Source;
id: string;
name: string;
artists: string;
artistRefs?: ArtistRef[];
album: string;
albumId?: string;
cover?: string;
duration?: number;
trackNo?: number;
qualities?: QualityKey[];
vip?: boolean;
downloaded?: boolean;
}
export interface Paged<T> {
items: T[];
total: number;
page: number;
limit: number;
}
export interface AlbumSummary {
source: Source;
id: string;
name: string;
artist: string;
cover?: string;
publishTime?: number;
size?: number;
type?: string;
}
export interface AlbumDetail extends AlbumSummary {
artistRefs?: ArtistRef[];
company?: string;
description?: string;
tracks: TrackSummary[];
}
export interface PlaylistDetail {
source: Source;
id: string;
name: string;
cover?: string;
creator?: string;
description?: string;
trackCount: number;
tracks: TrackSummary[];
}
export interface ArtistDetail {
source: Source;
id: string;
name: string;
avatar?: string;
description?: string;
tracks: TrackSummary[];
albums: AlbumSummary[];
}
export interface TrackMeta {
source: Source;
id: string;
name: string;
artists: string;
album: string;
albumId?: string;
cover?: string;
duration?: number;
trackNo?: number;
trackTotal?: number;
year?: number;
date?: string;
genre?: string;
publisher?: string;
downloaded?: boolean;
}
export interface QualityOption {
key: QualityKey;
label: string;
badge: string;
}
export interface Meta {
sources: Source[];
qualities: QualityOption[];
sourceQualities: Record<Source, QualityKey[]>;
musicDir: string;
}
export interface Settings {
quality: QualityKey;
layout: 'album' | 'flat';
retries: number;
lyric: boolean;
translatedLyric: boolean;
embedTags: boolean;
albumCover: boolean;
concurrency: number;
gapMs: number;
}
export interface Health {
ok: boolean;
ffmpeg: boolean;
sources: Source[];
}
export interface QQStatus {
loggedIn: boolean;
valid?: boolean;
uin?: string;
nick?: string;
avatar?: string;
vip?: { active: boolean; label: string; expires?: string };
keyCreatedAt?: string;
keyExpiresAt?: string;
savedAt?: string;
via?: 'cookie' | 'file' | 'qr' | 'refresh';
canRefresh?: boolean;
lastRefresh?: { at: string; ok: boolean; error?: string } | null;
error?: string;
}
export type QRState = 'waiting' | 'scanned' | 'expired' | 'refused' | 'done' | 'error';
export type TaskStatus = 'queued' | 'running' | 'paused' | 'done' | 'failed' | 'canceled';
export type Stage = 'resolve' | 'metadata' | 'download' | 'lyric' | 'cover' | 'tag';
export interface Task {
id: number;
source: Source;
trackId: string;
name: string;
artists: string;
album: string;
cover: string | null;
quality: QualityKey;
groupTitle: string | null;
status: TaskStatus;
stage: Stage | null;
error: string | null;
actualQuality: QualityKey | null;
bitrate: number | null;
size: number | null;
file: string | null;
skipped: boolean;
warnings: string[];
createdAt: string;
updatedAt: string;
}
export interface TaskProgress {
id: number;
stage?: Stage;
received: number;
total: number;
speed: number;
}
export interface QueueSummary {
paused: boolean;
concurrency: number;
counts: Partial<Record<TaskStatus, number>>;
}
export interface HistoryEntry {
id: number;
source: Source;
trackId: string;
name: string;
artists: string;
album: string;
quality: QualityKey | null;
bitrate: number | null;
size: number | null;
file: string | null;
createdAt: string;
}
export interface Stats {
count: number;
size: number;
bySource: Array<{ key: string; count: number }>;
byQuality: Array<{ key: string; count: number }>;
topArtists: Array<{ key: string; count: number }>;
daily: Array<{ key: string; count: number }>;
}
export interface LinkRef {
source: Source;
kind: 'track' | 'album' | 'playlist' | 'artist';
id: string;
}
export interface LibraryEntry {
name: string;
path: string;
type: 'dir' | 'audio' | 'other';
size: number;
mtime: number;
hasLyric?: boolean;
cover?: string;
}
export interface LibraryListing {
path: string;
hasCover: boolean;
entries: LibraryEntry[];
}
export interface RetagJob {
id: number;
path: string;
source: Source;
total: number;
done: number;
current?: string;
results: Array<{ file: string; ok: boolean; match?: string; error?: string }>;
finished: boolean;
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
/** 401 时由 store 跳转登录 */
export let onUnauthorized: () => void = () => {};
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
...init,
headers: init?.body ? { 'Content-Type': 'application/json', ...init?.headers } : init?.headers,
});
const body = await res.json().catch(() => ({}));
if (res.status === 401 && !path.startsWith('/auth/')) onUnauthorized();
if (!res.ok) throw new ApiError(body.error || `请求失败 (${res.status})`, res.status);
return body as T;
}
const qs = (params: Record<string, string | number | undefined>) =>
new URLSearchParams(Object.entries(params).filter(([, v]) => v !== undefined && v !== '') as Array<[string, string]>).toString();
const json = (method: string, data?: unknown): RequestInit => ({ method, body: JSON.stringify(data ?? {}) });
export const api = {
health: () => request<Health>('/health'),
meta: () => request<Meta>('/meta'),
authStatus: () => request<{ required: boolean; authenticated: boolean }>('/auth/status'),
login: (password: string) => request<{ ok: true }>('/auth/login', json('POST', { password })),
logout: () => request<{ ok: true }>('/auth/logout', json('POST')),
setPassword: (current: string | undefined, password: string | undefined) =>
request<{ required: boolean }>('/auth/password', json('PUT', { current, password })),
settings: () => request<Settings>('/settings'),
saveSettings: (patch: Partial<Settings>) => request<Settings>('/settings', json('PUT', patch)),
accounts: (refresh = false) => request<{ qq: QQStatus }>(`/accounts${refresh ? '?refresh=1' : ''}`),
saveQQCookie: (cookie: string) => request<{ qq: QQStatus }>('/accounts/qq/cookie', json('POST', { cookie })),
logoutQQ: () => request<{ qq: QQStatus }>('/accounts/qq', { method: 'DELETE' }),
qqQR: () => request<{ id: string; image: string }>('/accounts/qq/qr', json('POST')),
qqQRStatus: (id: string) => request<{ state: QRState; message?: string; qq?: QQStatus }>(`/accounts/qq/qr/${id}`),
refreshQQ: () => request<{ ok: boolean; qq: QQStatus }>('/accounts/qq/refresh', json('POST')),
search: (source: Source, q: string, page = 1, limit = 30) =>
request<Paged<TrackSummary>>(`/search?${qs({ source, q, page, limit })}`),
resolveLink: (url: string) => request<LinkRef>(`/resolve-link?${qs({ url })}`),
track: (source: Source, id: string) => request<TrackMeta>(`/${source}/track/${encodeURIComponent(id)}`),
album: (source: Source, id: string) => request<AlbumDetail>(`/${source}/album/${encodeURIComponent(id)}`),
playlist: (source: Source, id: string) => request<PlaylistDetail>(`/${source}/playlist/${encodeURIComponent(id)}`),
artist: (source: Source, id: string) => request<ArtistDetail>(`/${source}/artist/${encodeURIComponent(id)}`),
lyric: (source: Source, id: string) => request<{ lrc?: string; tlyric?: string }>(`/${source}/lyric/${encodeURIComponent(id)}`),
streamUrl: (source: Source, id: string) => `/api/${source}/stream/${encodeURIComponent(id)}`,
tasks: (scope: 'active' | 'finished' | 'all' = 'all') =>
request<{ tasks: Task[]; progress: TaskProgress[]; summary: QueueSummary }>(`/tasks?scope=${scope}`),
enqueue: (items: TrackSummary[], quality: QualityKey, group?: string) =>
request<{ added: number; duplicates: number }>(
'/tasks',
json('POST', {
quality,
group,
items: items.map((t) => ({ source: t.source, id: t.id, name: t.name, artists: t.artists, album: t.album, cover: t.cover })),
}),
),
taskAction: (id: number, action: 'pause' | 'resume' | 'retry' | 'cancel') => request(`/tasks/${id}/${action}`, json('POST')),
removeTask: (id: number) => request(`/tasks/${id}`, { method: 'DELETE' }),
queueAction: (action: 'pause' | 'resume' | 'retry-failed' | 'clear', data?: unknown) => request<any>(`/queue/${action}`, json('POST', data)),
history: (params: { limit?: number; offset?: number; q?: string; source?: string }) =>
request<{ items: HistoryEntry[]; total: number }>(`/history?${qs(params)}`),
historyExportUrl: (format: 'csv' | 'json') => `/api/history/export?format=${format}`,
stats: () => request<Stats>('/stats'),
library: (path = '') => request<LibraryListing>(`/library?${qs({ path })}`),
libraryFileUrl: (path: string) => `/api/library/file?${qs({ path })}`,
libraryZipUrl: (path: string) => `/api/library/zip?${qs({ path })}`,
writeM3u: (path: string) => request<{ file: string }>('/library/m3u', json('POST', { path })),
trash: (path: string) => request<{ moved: string }>('/library/trash', json('POST', { path })),
trashInfo: () => request<{ count: number; size: number }>('/library/trash'),
emptyTrash: () => request('/library/trash', { method: 'DELETE' }),
retagJob: () => request<{ job: RetagJob | null }>('/library/retag').then((r) => r.job),
retag: (path: string, source: Source, onlyMissingLyric: boolean) =>
request<RetagJob>('/library/retag', json('POST', { path, source, onlyMissingLyric })),
};
+89
View File
@@ -0,0 +1,89 @@
import type { QualityKey, Source } from './api.ts';
export const SOURCE_LABEL: Record<Source, string> = { netease: '网易云', qq: 'QQ 音乐' };
export const QUALITY_BADGE: Record<QualityKey, string> = {
standard: '128K',
high: '320K',
lossless: 'FLAC',
hires: 'HI-RES',
master: 'MASTER',
spatial: 'SPATIAL',
effect: 'EFFECT',
};
export const QUALITY_LABEL: Record<QualityKey, string> = {
standard: '标准',
high: '极高',
lossless: '无损',
hires: 'Hi-Res',
master: '超清母带',
spatial: '空间音频',
effect: '高清臻品',
};
const QUALITY_RANK: QualityKey[] = ['effect', 'spatial', 'master', 'hires', 'lossless', 'high', 'standard'];
export function isGold(q: string | null | undefined): boolean {
return q === 'hires' || q === 'master' || q === 'spatial' || q === 'effect';
}
/** 该曲目声明的最佳音质(用于徽章) */
export function bestQuality(qs: QualityKey[] | undefined): QualityKey | undefined {
if (!qs?.length) return undefined;
return QUALITY_RANK.find((q) => qs.includes(q));
}
export function formatDuration(ms?: number | null): string {
if (!ms) return '--:--';
const s = Math.round(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
export function formatSeconds(sec: number): string {
if (!Number.isFinite(sec)) return '0:00';
const s = Math.floor(sec);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
export function formatSize(bytes?: number | null): string {
if (!bytes) return '—';
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(2)} GB`;
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
export function formatSpeed(bps: number): string {
return bps >= 1048576 ? `${(bps / 1048576).toFixed(1)} MB/s` : `${Math.round(bps / 1024)} KB/s`;
}
export function formatDate(ts?: number | string | null): string {
if (!ts) return '';
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? '' : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
const dtf = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
export function formatDateTime(ts: string): string {
return dtf.format(new Date(ts));
}
export function coverThumb(url: string | undefined | null, size = 120): string | undefined {
if (!url) return undefined;
const https = url.replace(/^http:/, 'https:');
if (/music\.126\.net/.test(https)) return `${https.split('?')[0]}?param=${size}y${size}`;
if (/y\.gtimg\.cn\/music\/photo_new\/T00\dR\d+x\d+/.test(https)) {
const s = size <= 150 ? 150 : size <= 300 ? 300 : 800;
return https.replace(/R\d+x\d+/, `R${s}x${s}`);
}
return https;
}
export const STAGE_LABEL: Record<string, string> = {
resolve: '解析音源',
metadata: '获取信息',
download: '下载中',
lyric: '保存歌词',
cover: '获取封面',
tag: '写入标签',
};
+128
View File
@@ -0,0 +1,128 @@
// 试听播放器:单个 <audio>,支持在线音源(服务端代理)与本地曲库文件,并同步滚动歌词
import { computed, reactive, ref } from 'vue';
import { api, type Source } from './api.ts';
import { toastError } from './store.ts';
export interface PlayItem {
key: string;
title: string;
artists: string;
cover?: string;
src: string;
/** 在线曲目用于加载歌词 */
source?: Source;
id?: string;
}
interface LrcLine {
t: number;
text: string;
}
export const player = reactive({
current: null as PlayItem | null,
playing: false,
loading: false,
time: 0,
duration: 0,
volume: 0.85,
});
const lyric = ref<LrcLine[]>([]);
let audio: HTMLAudioElement | null = null;
function parseLrc(text: string | undefined): LrcLine[] {
if (!text) return [];
const out: LrcLine[] = [];
for (const line of text.split('\n')) {
const stamps = [...line.matchAll(/\[(\d+):(\d+(?:\.\d+)?)\]/g)];
const content = line.replace(/\[[^\]]*\]/g, '').trim();
if (!stamps.length || !content) continue;
for (const m of stamps) out.push({ t: Number(m[1]) * 60 + Number(m[2]), text: content });
}
return out.sort((a, b) => a.t - b.t);
}
export const currentLyric = computed(() => {
const lines = lyric.value;
let found = '';
for (const l of lines) {
if (l.t > player.time + 0.2) break;
found = l.text;
}
return found;
});
function ensureAudio(): HTMLAudioElement {
if (audio) return audio;
audio = new Audio();
audio.preload = 'auto';
audio.volume = player.volume;
audio.addEventListener('timeupdate', () => (player.time = audio!.currentTime));
audio.addEventListener('durationchange', () => (player.duration = audio!.duration || 0));
audio.addEventListener('play', () => (player.playing = true));
audio.addEventListener('pause', () => (player.playing = false));
audio.addEventListener('waiting', () => (player.loading = true));
audio.addEventListener('playing', () => (player.loading = false));
audio.addEventListener('ended', () => (player.playing = false));
audio.addEventListener('error', () => {
player.loading = false;
player.playing = false;
if (player.current) toastError(new Error('试听失败:音源不可用'));
});
return audio;
}
export function play(item: PlayItem) {
const a = ensureAudio();
if (player.current?.key === item.key) {
toggle();
return;
}
player.current = item;
player.time = 0;
player.duration = 0;
player.loading = true;
lyric.value = [];
a.src = item.src;
a.play().catch(() => {});
if (item.source && item.id) {
api
.lyric(item.source, item.id)
.then((l) => {
if (player.current?.key === item.key) lyric.value = parseLrc(l.lrc);
})
.catch(() => {});
}
}
export function playOnline(t: { source: Source; id: string; name: string; artists: string; cover?: string }) {
play({ key: `${t.source}:${t.id}`, title: t.name, artists: t.artists, cover: t.cover, src: api.streamUrl(t.source, t.id), source: t.source, id: t.id });
}
export function toggle() {
if (!audio || !player.current) return;
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
export function seek(ratio: number) {
if (audio && player.duration) audio.currentTime = Math.max(0, Math.min(1, ratio)) * player.duration;
}
export function setVolume(v: number) {
player.volume = v;
if (audio) audio.volume = v;
}
export function stop() {
audio?.pause();
if (audio) audio.removeAttribute('src');
player.current = null;
player.playing = false;
lyric.value = [];
}
export function isPlaying(key: string): boolean {
return player.current?.key === key && player.playing;
}
+156
View File
@@ -0,0 +1,156 @@
// 全局状态:元数据、设置、账号、下载队列(经 SSE 实时同步)、提示消息
import { computed, reactive, ref } from 'vue';
import {
api,
setUnauthorizedHandler,
type Meta,
type QQStatus,
type QualityKey,
type QueueSummary,
type RetagJob,
type Settings,
type Source,
type Task,
type TaskProgress,
type TrackSummary,
} from './api.ts';
export const meta = ref<Meta | null>(null);
export const settings = ref<Settings | null>(null);
export const qqStatus = ref<QQStatus | null>(null);
export const ffmpeg = ref<boolean | null>(null);
export const auth = reactive({ checked: false, required: false, authenticated: true });
export const tasks = reactive(new Map<number, Task>());
export const progress = reactive(new Map<number, TaskProgress>());
export const summary = ref<QueueSummary>({ paused: false, concurrency: 2, counts: {} });
export const retagJob = ref<RetagJob | null>(null);
/** 每次有新的下载历史时递增,供页面刷新 */
export const historyTick = ref(0);
/** 本次会话内完成下载的曲目 */
export const downloadedNow = reactive(new Set<string>());
export const connected = ref(false);
export const trackKey = (source: Source, id: string) => `${source}:${id}`;
/** 曲目 → 进行中的任务(用于列表行内显示状态) */
export const activeByTrack = computed(() => {
const map = new Map<string, Task>();
for (const t of tasks.values()) {
if (t.status === 'queued' || t.status === 'running' || t.status === 'paused') map.set(trackKey(t.source, t.trackId), t);
}
return map;
});
export const activeCount = computed(() => (summary.value.counts.running ?? 0) + (summary.value.counts.queued ?? 0));
export function isDownloaded(t: TrackSummary): boolean {
return !!t.downloaded || downloadedNow.has(trackKey(t.source, t.id));
}
// ---------- 提示 ----------
export interface Toast {
id: number;
text: string;
kind: 'info' | 'ok' | 'error';
}
export const toasts = ref<Toast[]>([]);
let toastSeq = 0;
export function toast(text: string, kind: Toast['kind'] = 'info', ms = 3200) {
const id = ++toastSeq;
toasts.value.push({ id, text, kind });
setTimeout(() => (toasts.value = toasts.value.filter((t) => t.id !== id)), ms);
}
export function toastError(e: unknown) {
toast(e instanceof Error ? e.message : String(e), 'error', 5000);
}
// ---------- 加载 ----------
export async function loadTasks() {
const res = await api.tasks('all');
tasks.clear();
for (const t of res.tasks) tasks.set(t.id, t);
progress.clear();
for (const p of res.progress) progress.set(p.id, p);
summary.value = res.summary;
}
export async function refreshAccounts(force = false) {
try {
qqStatus.value = (await api.accounts(force)).qq;
} catch {}
}
let source: EventSource | null = null;
function connectEvents() {
source?.close();
source = new EventSource('/api/events');
const on = (event: string, fn: (data: any) => void) =>
source!.addEventListener(event, (e) => fn(JSON.parse((e as MessageEvent).data)));
on('hello', (d) => {
connected.value = true;
summary.value = d.summary;
loadTasks().catch(() => {});
});
on('task', (t: Task) => {
const prev = tasks.get(t.id);
tasks.set(t.id, t);
if (t.status !== 'running') progress.delete(t.id);
if (t.status === 'done' && prev?.status !== 'done') downloadedNow.add(trackKey(t.source, t.trackId));
if (t.status === 'failed' && prev?.status !== 'failed') toast(`下载失败:${t.name} — ${t.error}`, 'error', 6000);
});
on('task-removed', (d: { ids: number[] }) => d.ids.forEach((id) => tasks.delete(id)));
on('progress', (p: TaskProgress) => progress.set(p.id, p));
on('queue', (s: QueueSummary) => (summary.value = s));
on('settings', (s: Settings) => (settings.value = s));
on('account', () => refreshAccounts(true));
on('history', () => historyTick.value++);
on('retag', (j: RetagJob) => (retagJob.value = j));
source.onerror = () => (connected.value = false);
}
export async function bootstrap() {
const status = await api.authStatus();
Object.assign(auth, status, { checked: true });
if (!status.authenticated) return;
const [m, s, h] = await Promise.all([api.meta(), api.settings(), api.health()]);
meta.value = m;
settings.value = s;
ffmpeg.value = h.ffmpeg;
connectEvents();
refreshAccounts();
api.retagJob().then((j) => (retagJob.value = j)).catch(() => {});
}
setUnauthorizedHandler(() => {
auth.authenticated = false;
source?.close();
});
// ---------- 动作 ----------
/** 平台支持的最佳音质(不超过默认设置) */
export function defaultQualityFor(src: Source): QualityKey {
const want = settings.value?.quality ?? 'master';
const supported = meta.value?.sourceQualities[src] ?? [];
if (!supported.length || supported.includes(want)) return want;
const order: QualityKey[] = ['hires', 'lossless', 'high', 'standard'];
const start = Math.max(0, order.indexOf(want));
return order.slice(start).find((q) => supported.includes(q)) ?? supported[0]!;
}
export async function download(items: TrackSummary[], quality: QualityKey, group?: string) {
if (!items.length) return;
try {
const { added, duplicates } = await api.enqueue(items, quality, group);
const extra = duplicates ? `,${duplicates} 首已在队列中` : '';
toast(added ? `已加入下载队列 ${added} 首${extra}` : `全部已在队列中`, added ? 'ok' : 'info');
} catch (e) {
toastError(e);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { ref, watch } from 'vue';
export type ThemeMode = 'auto' | 'light' | 'dark';
function read(): ThemeMode {
try {
const v = localStorage.getItem('sx-theme');
return v === 'light' || v === 'dark' ? v : 'auto';
} catch {
return 'auto';
}
}
export const themeMode = ref<ThemeMode>(read());
watch(themeMode, (mode) => {
const root = document.documentElement;
if (mode === 'auto') delete root.dataset.theme;
else root.dataset.theme = mode;
try {
if (mode === 'auto') localStorage.removeItem('sx-theme');
else localStorage.setItem('sx-theme', mode);
} catch {}
});
export function cycleTheme() {
const order: ThemeMode[] = ['auto', 'light', 'dark'];
themeMode.value = order[(order.indexOf(themeMode.value) + 1) % order.length]!;
}
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './router.ts';
import './styles/tokens.css';
import './styles/base.css';
createApp(App).use(router).mount('#app');
+26
View File
@@ -0,0 +1,26 @@
import { createRouter, createWebHistory } from 'vue-router';
import SearchView from './views/SearchView.vue';
export const navItems = [
{ path: '/', name: 'search', label: '搜索' },
{ path: '/queue', name: 'queue', label: '队列' },
{ path: '/library', name: 'library', label: '曲库' },
{ path: '/history', name: 'history', label: '历史' },
{ path: '/settings', name: 'settings', label: '设置' },
] as const;
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'search', component: SearchView },
{ path: '/album/:source/:id', name: 'album', component: () => import('./views/AlbumView.vue'), props: true },
{ path: '/playlist/:source/:id', name: 'playlist', component: () => import('./views/PlaylistView.vue'), props: true },
{ path: '/artist/:source/:id', name: 'artist', component: () => import('./views/ArtistView.vue'), props: true },
{ path: '/queue', name: 'queue', component: () => import('./views/QueueView.vue') },
{ path: '/library', name: 'library', component: () => import('./views/LibraryView.vue') },
{ path: '/history', name: 'history', component: () => import('./views/HistoryView.vue') },
{ path: '/settings', name: 'settings', component: () => import('./views/SettingsView.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
scrollBehavior: () => ({ top: 0 }),
});
+289
View File
@@ -0,0 +1,289 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--ink);
font: 14px/1.6 var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
}
/* 纸张颗粒 */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
opacity: var(--grain-opacity);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
#app {
height: 100%;
}
h1,
h2,
h3 {
font-family: var(--font-serif);
font-weight: 600;
letter-spacing: 0.01em;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
::selection {
background: var(--accent-soft);
}
.mono {
font-family: var(--font-mono);
font-feature-settings: 'tnum';
letter-spacing: -0.01em;
}
.muted {
color: var(--ink-2);
}
.faint {
color: var(--ink-3);
}
.eyebrow {
font: 500 11px/1 var(--font-mono);
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-3);
}
/* 描边小字徽章;.gold 用于 Hi-Res / 母带 */
.badge {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border: 1px solid var(--line-strong);
border-radius: 4px;
font: 600 10px/1 var(--font-mono);
letter-spacing: 0.06em;
color: var(--ink-2);
white-space: nowrap;
}
.badge.gold {
border-color: color-mix(in oklab, var(--gold) 55%, transparent);
color: var(--gold);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 14px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: transparent;
transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease);
}
.btn:hover {
border-color: var(--ink-3);
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
}
.btn.primary:hover {
background: color-mix(in oklab, var(--accent) 88%, var(--ink));
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
:focus-visible {
outline: 2px solid color-mix(in oklab, var(--accent) 60%, transparent);
outline-offset: 2px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--line-strong);
border: 3px solid var(--bg);
border-radius: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}
/* 页面通用骨架 */
.page {
max-width: 1080px;
margin: 0 auto;
padding: 40px 40px 96px;
}
.page-head {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 26px;
}
.page-head h2 {
font-size: 30px;
}
.page-head .row-between {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.count {
font: 400 14px var(--font-mono);
color: var(--ink-3);
margin-left: 6px;
}
.btn.ghost {
border-color: transparent;
}
.btn.ghost:hover {
border-color: var(--line-strong);
}
.btn.sm {
height: 28px;
padding: 0 10px;
font-size: 12.5px;
}
.btn.danger:hover {
border-color: var(--danger);
color: var(--danger);
}
.input {
height: 34px;
padding: 0 12px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface);
outline: none;
transition: border-color var(--dur) var(--ease);
}
.input:focus {
border-color: var(--ink-3);
}
.seg {
display: inline-flex;
gap: 2px;
padding: 3px;
border: 1px solid var(--line);
border-radius: 999px;
flex-shrink: 0;
}
.seg button {
height: 26px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: none;
font-size: 12.5px;
color: var(--ink-2);
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.seg button:hover {
color: var(--ink);
}
.seg button[aria-selected='true'] {
background: var(--ink);
color: var(--bg);
}
.state-msg {
padding: 80px 0;
text-align: center;
color: var(--ink-3);
}
.error-text {
color: var(--danger);
font-size: 13px;
}
.skeleton {
height: 56px;
border-bottom: 1px solid var(--line);
background: linear-gradient(90deg, transparent, var(--surface-2), transparent) 0 0 / 50% 100% no-repeat;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
from {
background-position: -50% 0;
}
to {
background-position: 150% 0;
}
}
@media (max-width: 760px) {
.page {
padding: 20px 16px 120px;
}
}
+73
View File
@@ -0,0 +1,73 @@
/* 声匣 · 设计 token —— 「唱片内页」:纸与墨,赤陶点睛,来源色只做细线 */
:root {
--bg: #f4f1ea;
--surface: #fbf9f4;
--surface-2: #efebe2;
--line: #e3ded3;
--line-strong: #cfc8ba;
--ink: #1f1d1a;
--ink-2: #6b655b;
--ink-3: #a39c8f;
--accent: #b5523b;
--accent-ink: #fbf9f4;
--accent-soft: color-mix(in oklab, var(--accent) 12%, transparent);
--netease: #c4473a;
--qq: #2f8f6b;
--gold: #a8812c;
--ok: #4f7a52;
--danger: #b3412f;
--shadow: 0 1px 0 rgba(31, 29, 26, 0.04), 0 8px 24px -12px rgba(31, 29, 26, 0.18);
--grain-opacity: 0.035;
--font-serif: 'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', 'STSong', serif;
--font-sans: 'Inter', 'HarmonyOS Sans SC', 'PingFang SC', 'Microsoft YaHei UI', 'Microsoft YaHei', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Mono', 'SF Mono', ui-monospace, Consolas, monospace;
--radius: 10px;
--radius-sm: 6px;
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
--dur: 180ms;
--sidebar-w: 216px;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--bg: #121315;
--surface: #1a1b1e;
--surface-2: #202125;
--line: #2a2b2f;
--line-strong: #3a3b40;
--ink: #ece8df;
--ink-2: #9a958b;
--ink-3: #5e5a53;
--accent: #e07a5f;
--accent-ink: #121315;
--netease: #e06456;
--qq: #42a07d;
--gold: #d9b45a;
--ok: #7fae82;
--danger: #e0715f;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.3), 0 12px 32px -16px rgba(0, 0, 0, 0.6);
--grain-opacity: 0.05;
}
}
:root[data-theme='dark'] {
--bg: #121315;
--surface: #1a1b1e;
--surface-2: #202125;
--line: #2a2b2f;
--line-strong: #3a3b40;
--ink: #ece8df;
--ink-2: #9a958b;
--ink-3: #5e5a53;
--accent: #e07a5f;
--accent-ink: #121315;
--netease: #e06456;
--qq: #42a07d;
--gold: #d9b45a;
--ok: #7fae82;
--danger: #e0715f;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.3), 0 12px 32px -16px rgba(0, 0, 0, 0.6);
--grain-opacity: 0.05;
}
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import DetailHeader from '../components/DetailHeader.vue';
import Icon from '../components/Icon.vue';
import TrackList from '../components/TrackList.vue';
import { api, type AlbumDetail, type Source } from '../lib/api.ts';
import { formatDate } from '../lib/format.ts';
import { playOnline } from '../lib/player.ts';
const props = defineProps<{ source: Source; id: string }>();
const album = ref<AlbumDetail | null>(null);
const error = ref('');
onMounted(async () => {
try {
album.value = await api.album(props.source, props.id);
} catch (e) {
error.value = (e as Error).message;
}
});
const tracks = computed(() => (album.value?.tracks ?? []).map((t) => ({ ...t, cover: t.cover || album.value?.cover })));
function playFirst() {
const t = album.value?.tracks[0];
if (t) playOnline({ ...t, cover: t.cover || album.value?.cover });
}
</script>
<template>
<section class="page">
<button class="back btn ghost sm" type="button" @click="$router.back()"><Icon name="back" :size="15" /> 返回</button>
<p v-if="error" class="state-msg">{{ error }}</p>
<template v-else-if="album">
<DetailHeader
:source="album.source"
:kind="album.type || '专辑'"
:title="album.name"
:cover="album.cover"
:meta="[formatDate(album.publishTime), album.company, `${album.tracks.length} 首`]"
:description="album.description"
>
<template #by>
<template v-if="album.artistRefs?.length">
<template v-for="(a, i) in album.artistRefs" :key="a.id">
<span v-if="i" class="faint"> / </span>
<RouterLink :to="`/artist/${album.source}/${a.id}`">{{ a.name }}</RouterLink>
</template>
</template>
<span v-else>{{ album.artist }}</span>
</template>
<template #actions>
<button class="btn" type="button" @click="playFirst"><Icon name="play" :size="14" /> 试听</button>
</template>
</DetailHeader>
<TrackList :tracks="tracks" :show-cover="false" :show-album="false" numbering="track" :group="`专辑 · ${album.name}`" />
</template>
<div v-else class="loading"><div v-for="i in 8" :key="i" class="skeleton" /></div>
</section>
</template>
<style scoped>
.back {
margin: -16px 0 14px -10px;
color: var(--ink-2);
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import CoverArt from '../components/CoverArt.vue';
import DetailHeader from '../components/DetailHeader.vue';
import Icon from '../components/Icon.vue';
import TrackList from '../components/TrackList.vue';
import { api, type ArtistDetail, type Source } from '../lib/api.ts';
import { formatDate } from '../lib/format.ts';
const props = defineProps<{ source: Source; id: string }>();
const artist = ref<ArtistDetail | null>(null);
const error = ref('');
const tab = ref<'tracks' | 'albums'>('tracks');
onMounted(async () => {
try {
artist.value = await api.artist(props.source, props.id);
} catch (e) {
error.value = (e as Error).message;
}
});
</script>
<template>
<section class="page">
<button class="back btn ghost sm" type="button" @click="$router.back()"><Icon name="back" :size="15" /> 返回</button>
<p v-if="error" class="state-msg">{{ error }}</p>
<template v-else-if="artist">
<DetailHeader
:source="artist.source"
kind="歌手"
:title="artist.name"
:cover="artist.avatar"
round
:meta="[`热门 ${artist.tracks.length} 首`, `专辑 ${artist.albums.length} 张`]"
:description="artist.description"
>
<template #actions>
<div class="seg" role="tablist">
<button type="button" role="tab" :aria-selected="tab === 'tracks'" @click="tab = 'tracks'">热门歌曲</button>
<button type="button" role="tab" :aria-selected="tab === 'albums'" @click="tab = 'albums'">专辑</button>
</div>
</template>
</DetailHeader>
<TrackList v-if="tab === 'tracks'" :tracks="artist.tracks" :group="`歌手 · ${artist.name}`" />
<div v-else class="albums">
<RouterLink v-for="a in artist.albums" :key="a.id" :to="`/album/${a.source}/${a.id}`" class="album">
<div class="sleeve">
<CoverArt :src="a.cover" :size="168" :px="300" />
</div>
<div class="a-name">{{ a.name }}</div>
<div class="a-meta mono faint">
{{ formatDate(a.publishTime).slice(0, 4) }}<template v-if="a.size"> · {{ a.size }} 首</template><template v-if="a.type"> · {{ a.type }}</template>
</div>
</RouterLink>
<p v-if="!artist.albums.length" class="state-msg">暂无专辑</p>
</div>
</template>
<div v-else class="loading"><div v-for="i in 8" :key="i" class="skeleton" /></div>
</section>
</template>
<style scoped>
.back {
margin: -16px 0 14px -10px;
color: var(--ink-2);
}
.albums {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 30px 22px;
padding-top: 18px;
border-top: 1.5px solid var(--ink);
}
.album {
min-width: 0;
}
.sleeve {
position: relative;
aspect-ratio: 1;
transition: transform 260ms var(--ease);
}
.sleeve :deep(.cover) {
width: 100% !important;
height: 100% !important;
box-shadow: var(--shadow), inset 0 0 0 1px var(--line);
}
.album:hover .sleeve {
transform: translateY(-3px);
}
.a-name {
margin-top: 10px;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.a-meta {
font-size: 11px;
margin-top: 2px;
}
</style>
+463
View File
@@ -0,0 +1,463 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import Icon from '../components/Icon.vue';
import { api, type HistoryEntry, type Source, type Stats } from '../lib/api.ts';
import { formatDateTime, formatSize, isGold, QUALITY_BADGE, QUALITY_LABEL, SOURCE_LABEL } from '../lib/format.ts';
import { historyTick } from '../lib/store.ts';
const items = ref<HistoryEntry[]>([]);
const total = ref(0);
const loading = ref(true);
const error = ref('');
const stats = ref<Stats | null>(null);
const q = ref('');
const source = ref<'' | Source>('');
const hasMore = computed(() => items.value.length < total.value);
async function load(reset = false) {
loading.value = true;
try {
const res = await api.history({ limit: 50, offset: reset ? 0 : items.value.length, q: q.value.trim(), source: source.value });
items.value = reset ? res.items : [...items.value, ...res.items];
total.value = res.total;
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
async function loadStats() {
stats.value = await api.stats().catch(() => null);
}
onMounted(() => {
load(true);
loadStats();
});
let timer: ReturnType<typeof setTimeout> | undefined;
watch([q, source], () => {
clearTimeout(timer);
timer = setTimeout(() => load(true), 250);
});
watch(historyTick, () => {
load(true);
loadStats();
});
// ---- 统计 ----
/** 近 30 天每日下载数(补齐没有下载的日期) */
const days = computed(() => {
const map = new Map(stats.value?.daily.map((d) => [d.key, d.count]) ?? []);
const out: Array<{ key: string; count: number }> = [];
for (let i = 29; i >= 0; i--) {
const key = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10);
out.push({ key, count: map.get(key) ?? 0 });
}
return out;
});
const dayMax = computed(() => Math.max(1, ...days.value.map((d) => d.count)));
const last30 = computed(() => days.value.reduce((s, d) => s + d.count, 0));
const hoverDay = ref<{ key: string; count: number; x: number } | null>(null);
const sources = computed(() => {
const all = stats.value?.bySource ?? [];
const sum = all.reduce((s, x) => s + x.count, 0) || 1;
return all.map((x) => ({ ...x, pct: (x.count / sum) * 100 }));
});
const sourceLabel = (key: string) => SOURCE_LABEL[key as Source] ?? key;
const sourcesAria = computed(() => sources.value.map((s) => `${sourceLabel(s.key)} ${s.count} 首`).join(','));
const qualities = computed(() => {
const all = stats.value?.byQuality ?? [];
const max = Math.max(1, ...all.map((x) => x.count));
return all.map((x) => ({ ...x, pct: (x.count / max) * 100 }));
});
</script>
<template>
<section class="page">
<header class="page-head">
<span class="eyebrow">04 · History</span>
<div class="row-between">
<h2>下载历史</h2>
<div class="exports">
<a class="btn sm" :href="api.historyExportUrl('csv')"><Icon name="export" :size="14" /> CSV</a>
<a class="btn sm" :href="api.historyExportUrl('json')"><Icon name="export" :size="14" /> JSON</a>
</div>
</div>
</header>
<div v-if="stats" class="stats">
<div class="tile">
<span class="eyebrow">累计</span>
<span class="big mono">{{ stats.count.toLocaleString() }}</span>
<span class="faint small">首歌曲</span>
</div>
<div class="tile">
<span class="eyebrow">占用</span>
<span class="big mono">{{ formatSize(stats.size) }}</span>
<span class="faint small">按下载时文件大小</span>
</div>
<figure class="tile wide chart" aria-label="近 30 天每日下载数">
<figcaption>
<span class="eyebrow">近 30 天</span>
<span class="small muted"><b class="mono">{{ last30 }}</b> 首</span>
</figcaption>
<div class="bars" @mouseleave="hoverDay = null">
<div
v-for="(d, i) in days"
:key="d.key"
class="col"
@mouseenter="hoverDay = { ...d, x: (i + 0.5) / days.length }"
>
<i :style="{ height: d.count ? `${Math.max(4, (d.count / dayMax) * 100)}%` : '0' }" :class="{ hot: hoverDay?.key === d.key }" />
</div>
<div v-if="hoverDay" class="tip" :style="{ left: `${hoverDay.x * 100}%` }">
<span class="mono">{{ hoverDay.key.slice(5) }}</span> · {{ hoverDay.count }} 首
</div>
</div>
<div class="axis mono faint"><span>{{ days[0]?.key.slice(5) }}</span><span>今天</span></div>
</figure>
<div class="tile wide">
<span class="eyebrow">来源</span>
<div class="meter" role="img" :aria-label="sourcesAria">
<i v-for="s in sources" :key="s.key" :class="s.key" :style="{ width: `${s.pct}%` }" :title="`${s.count} 首`" />
</div>
<div class="legend small">
<span v-for="s in sources" :key="s.key"><i class="sw" :class="s.key" />{{ sourceLabel(s.key) }} <b class="mono">{{ s.count }}</b> <span class="faint mono">{{ Math.round(s.pct) }}%</span></span>
</div>
<span class="eyebrow q-title">音质</span>
<ul class="qlist small">
<li v-for="x in qualities" :key="x.key">
<span class="ql">{{ QUALITY_LABEL[x.key as keyof typeof QUALITY_LABEL] ?? '未知' }}</span>
<span class="qbar"><i :style="{ width: `${x.pct}%` }" :class="{ gold: isGold(x.key) }" /></span>
<span class="mono muted qn">{{ x.count }}</span>
</li>
</ul>
</div>
</div>
<div class="filters">
<input v-model="q" class="input" type="search" placeholder="搜索歌名、歌手、专辑…" />
<div class="seg" role="tablist">
<button type="button" role="tab" :aria-selected="source === ''" @click="source = ''">全部</button>
<button type="button" role="tab" :aria-selected="source === 'netease'" @click="source = 'netease'">网易云</button>
<button type="button" role="tab" :aria-selected="source === 'qq'" @click="source = 'qq'">QQ 音乐</button>
</div>
<span class="faint mono small">{{ total }} 条</span>
</div>
<p v-if="error" class="error-text">{{ error }}</p>
<ol v-if="items.length" class="list">
<li v-for="h in items" :key="h.id" class="row" :class="h.source">
<time class="mono faint">{{ formatDateTime(h.createdAt) }}</time>
<div class="title">
<div class="name">{{ h.name }}</div>
<div class="sub muted">{{ h.artists }}<span v-if="h.album" class="faint"> · {{ h.album }}</span></div>
</div>
<span v-if="h.quality" class="badge" :class="{ gold: isGold(h.quality) }">
{{ QUALITY_BADGE[h.quality] ?? h.quality.toUpperCase() }}
</span>
<span v-else />
<span class="mono faint size">{{ formatSize(h.size) }}</span>
</li>
</ol>
<div v-else-if="!loading" class="state-msg">没有记录</div>
<div class="more">
<button v-if="hasMore" class="btn" :disabled="loading" @click="load()">{{ loading ? '加载中…' : '加载更多' }}</button>
</div>
</section>
</template>
<style scoped>
.exports {
display: flex;
gap: 8px;
}
.small {
font-size: 12px;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr 2fr 2fr;
gap: 14px;
margin-bottom: 30px;
}
.tile {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
margin: 0;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.big {
font-size: 28px;
line-height: 1.2;
font-weight: 500;
letter-spacing: -0.02em;
}
figcaption {
display: flex;
justify-content: space-between;
align-items: baseline;
}
figcaption b {
color: var(--ink);
font-weight: 600;
}
.bars {
position: relative;
display: flex;
align-items: flex-end;
gap: 2px;
height: 74px;
margin-top: 6px;
border-bottom: 1px solid var(--line);
}
.col {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
cursor: default;
}
.col i {
display: block;
width: 100%;
border-radius: 3px 3px 0 0;
background: var(--accent);
opacity: 0.78;
transition: opacity var(--dur) var(--ease);
}
.col i.hot {
opacity: 1;
}
.tip {
position: absolute;
bottom: calc(100% + 4px);
transform: translateX(-50%);
padding: 3px 8px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface);
box-shadow: var(--shadow);
font-size: 11.5px;
white-space: nowrap;
pointer-events: none;
}
.axis {
display: flex;
justify-content: space-between;
font-size: 10px;
}
.meter {
display: flex;
gap: 2px;
height: 8px;
margin-top: 4px;
border-radius: 4px;
overflow: hidden;
background: var(--line);
}
.meter i {
display: block;
height: 100%;
}
.netease {
background: var(--netease);
}
.qq {
background: var(--qq);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
color: var(--ink-2);
}
.legend > span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.legend b {
color: var(--ink);
font-weight: 600;
}
.sw {
width: 8px;
height: 8px;
border-radius: 2px;
}
.q-title {
margin-top: 8px;
}
.qlist {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.qlist li {
display: grid;
grid-template-columns: 64px 1fr 40px;
align-items: center;
gap: 8px;
}
.ql {
color: var(--ink-2);
}
.qbar {
height: 4px;
border-radius: 2px;
background: var(--surface-2);
overflow: hidden;
}
.qbar i {
display: block;
height: 100%;
border-radius: 2px;
background: var(--ink-3);
}
.qbar i.gold {
background: var(--gold);
}
.qn {
text-align: right;
}
.filters {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1.5px solid var(--ink);
}
.filters .input {
width: 260px;
}
.filters .small {
margin-left: auto;
}
.list {
list-style: none;
margin: 0;
padding: 0;
}
.row {
display: grid;
grid-template-columns: 96px 1fr auto 72px;
align-items: center;
gap: 18px;
padding: 12px 4px;
border-bottom: 1px solid var(--line);
background: none;
}
.row time {
font-size: 11.5px;
}
.title {
min-width: 0;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 12.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.size {
font-size: 11.5px;
text-align: right;
}
.more {
display: flex;
justify-content: center;
padding: 24px 0;
}
@media (max-width: 960px) {
.stats {
grid-template-columns: 1fr 1fr;
}
.wide {
grid-column: span 2;
}
}
@media (max-width: 760px) {
.filters {
flex-wrap: wrap;
}
.filters .input {
width: 100%;
}
.row {
grid-template-columns: 1fr auto;
}
.row time,
.size {
display: none;
}
}
</style>
+566
View File
@@ -0,0 +1,566 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Icon from '../components/Icon.vue';
import { api, type LibraryEntry, type LibraryListing, type Source } from '../lib/api.ts';
import { formatSize } from '../lib/format.ts';
import { isPlaying, play } from '../lib/player.ts';
import { historyTick, retagJob, toast, toastError } from '../lib/store.ts';
const route = useRoute();
const router = useRouter();
const listing = ref<LibraryListing | null>(null);
const error = ref('');
const loading = ref(false);
const showOther = ref(false);
const retagOpen = ref(false);
const retagSource = ref<Source>('netease');
const retagMissingOnly = ref(true);
const trash = ref<{ count: number; size: number } | null>(null);
const path = computed(() => String(route.query.path ?? ''));
const crumbs = computed(() => {
const parts = path.value ? path.value.split('/') : [];
return parts.map((name, i) => ({ name, path: parts.slice(0, i + 1).join('/') }));
});
const dirs = computed(() => listing.value?.entries.filter((e) => e.type === 'dir') ?? []);
const audio = computed(() => listing.value?.entries.filter((e) => e.type === 'audio') ?? []);
const other = computed(() => listing.value?.entries.filter((e) => e.type === 'other') ?? []);
const totalSize = computed(() => audio.value.reduce((s, e) => s + e.size, 0));
const folderName = computed(() => crumbs.value.at(-1)?.name ?? '音乐目录');
const artistName = computed(() => (crumbs.value.length >= 2 ? crumbs.value.at(-2)!.name : ''));
async function load() {
loading.value = true;
error.value = '';
try {
listing.value = await api.library(path.value);
} catch (e) {
error.value = (e as Error).message;
listing.value = null;
} finally {
loading.value = false;
}
if (!path.value) api.trashInfo().then((t) => (trash.value = t)).catch(() => {});
}
onMounted(load);
watch(path, load);
watch(historyTick, () => !loading.value && load());
watch(
() => retagJob.value?.finished,
(finished) => finished && load(),
);
function go(p: string) {
router.push({ path: '/library', query: p ? { path: p } : {} });
}
function stripExt(name: string) {
return name.replace(/\.[^.]+$/, '');
}
function playFile(e: LibraryEntry) {
play({
key: `file:${e.path}`,
title: stripExt(e.name).replace(/^\d+\.\s*/, ''),
artists: artistName.value || folderName.value,
cover: listing.value?.hasCover ? api.libraryFileUrl(`${path.value}/cover.jpg`) : undefined,
src: api.libraryFileUrl(e.path),
});
}
async function m3u() {
try {
const { file } = await api.writeM3u(path.value);
toast(`已生成 ${file}`, 'ok');
load();
} catch (e) {
toastError(e);
}
}
async function trashPath(p: string, label: string) {
if (!confirm(`将「${label}」移入回收站?\n(可在数据目录 .trash 中找回)`)) return;
try {
await api.trash(p);
toast('已移入回收站');
if (p === path.value) go(crumbs.value.slice(0, -1).map((c) => c.name).join('/'));
else load();
} catch (e) {
toastError(e);
}
}
async function startRetag() {
try {
retagJob.value = await api.retag(path.value, retagSource.value, retagMissingOnly.value);
retagOpen.value = false;
if (!retagJob.value.total) toast('没有需要补全的文件');
} catch (e) {
toastError(e);
}
}
async function emptyTrash() {
if (!confirm('永久删除回收站中的所有文件?此操作不可撤销。')) return;
await api.emptyTrash().catch(toastError);
trash.value = { count: 0, size: 0 };
}
const jobPct = computed(() => (retagJob.value?.total ? (retagJob.value.done / retagJob.value.total) * 100 : 0));
const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok) ?? []);
</script>
<template>
<section class="page">
<header class="page-head">
<span class="eyebrow">03 · Library</span>
<nav class="crumbs">
<button type="button" class="crumb" :class="{ cur: !crumbs.length }" @click="go('')">音乐目录</button>
<template v-for="(c, i) in crumbs" :key="c.path">
<Icon name="chevron" :size="14" class="faint" />
<button type="button" class="crumb" :class="{ cur: i === crumbs.length - 1 }" @click="go(c.path)">{{ c.name }}</button>
</template>
</nav>
<div class="row-between">
<h2>
{{ folderName }}
<span class="count">
<template v-if="dirs.length">{{ dirs.length }} 个文件夹</template>
<template v-if="dirs.length && audio.length"> · </template>
<template v-if="audio.length">{{ audio.length }} 首 · {{ formatSize(totalSize) }}</template>
</span>
</h2>
<div class="tools">
<button class="btn sm" type="button" title="补全标签与歌词" @click="retagOpen = !retagOpen"><Icon name="tag" :size="15" /> 补全标签</button>
<template v-if="path">
<button class="btn sm" type="button" title="生成 M3U 播放列表" @click="m3u"><Icon name="list" :size="15" /> M3U</button>
<a class="btn sm" :href="api.libraryZipUrl(path)" title="打包下载"><Icon name="zip" :size="15" /> ZIP</a>
<button class="btn sm danger" type="button" title="移入回收站" @click="trashPath(path, folderName)"><Icon name="trash" :size="15" /></button>
</template>
</div>
</div>
</header>
<Transition name="fold">
<div v-if="retagOpen" class="panel">
<div>
<div class="panel-title">补全标签</div>
<p class="faint panel-desc">按「歌手/专辑/序号. 歌名」或「歌手 - 歌名」识别文件,搜索匹配后写入封面、标签与歌词。范围:{{ path || '整个音乐目录' }}</p>
</div>
<div class="panel-row">
<div class="seg" role="tablist">
<button type="button" role="tab" :aria-selected="retagSource === 'netease'" @click="retagSource = 'netease'">网易云</button>
<button type="button" role="tab" :aria-selected="retagSource === 'qq'" @click="retagSource = 'qq'">QQ 音乐</button>
</div>
<label class="chk"><input v-model="retagMissingOnly" type="checkbox" /> 只处理缺少歌词的文件</label>
<div class="spacer" />
<button class="btn sm ghost" type="button" @click="retagOpen = false">取消</button>
<button class="btn sm primary" type="button" :disabled="!!retagJob && !retagJob.finished" @click="startRetag">开始</button>
</div>
</div>
</Transition>
<div v-if="retagJob && (!retagJob.finished || retagJob.results.length)" class="job">
<div class="job-head">
<span>
<b>{{ retagJob.finished ? '补全完成' : '正在补全标签' }}</b>
<span class="faint"> · {{ retagJob.path || '音乐目录' }}</span>
</span>
<span class="mono faint">{{ retagJob.done }}/{{ retagJob.total }}<template v-if="jobFailures.length"> · 失败 {{ jobFailures.length }}</template></span>
</div>
<span class="bar"><i :style="{ width: `${jobPct}%` }" /></span>
<div v-if="retagJob.current" class="faint mono small">{{ retagJob.current }}</div>
<details v-if="retagJob.results.length" class="small">
<summary class="faint">查看明细</summary>
<ul>
<li v-for="r in retagJob.results" :key="r.file" :class="{ bad: !r.ok }">
<span class="mono">{{ r.file.split('/').pop() }}</span> → {{ r.ok ? r.match : r.error }}
</li>
</ul>
</details>
</div>
<p v-if="error" class="state-msg">{{ error }}</p>
<div v-if="dirs.length" class="grid">
<button v-for="d in dirs" :key="d.path" type="button" class="tile" @click="go(d.path)">
<span class="art">
<img v-if="d.cover" :src="api.libraryFileUrl(d.cover)" alt="" loading="lazy" />
<span v-else class="glyph"><Icon name="folder" :size="30" /></span>
</span>
<span class="tile-name">{{ d.name }}</span>
</button>
</div>
<ol v-if="audio.length" class="files">
<li v-for="f in audio" :key="f.path" class="file" :class="{ current: isPlaying(`file:${f.path}`) }">
<button type="button" class="play" @click="playFile(f)" :aria-label="`播放 ${f.name}`">
<Icon :name="isPlaying(`file:${f.path}`) ? 'pause' : 'play'" :size="14" />
</button>
<span class="fname">{{ stripExt(f.name) }}</span>
<span class="ext mono">{{ f.name.split('.').pop()?.toUpperCase() }}</span>
<span class="lrc mono" :class="{ on: f.hasLyric }" :title="f.hasLyric ? '有歌词' : '缺少歌词'">LRC</span>
<span class="size mono faint">{{ formatSize(f.size) }}</span>
<button type="button" class="del" title="移入回收站" @click="trashPath(f.path, f.name)"><Icon name="trash" :size="15" /></button>
</li>
</ol>
<div v-if="other.length" class="other">
<button type="button" class="link faint" @click="showOther = !showOther">其他文件 {{ other.length }} 个 {{ showOther ? '▴' : '▾' }}</button>
<ul v-if="showOther">
<li v-for="o in other" :key="o.path" class="faint mono">
<a :href="api.libraryFileUrl(o.path)" target="_blank">{{ o.name }}</a> <span>{{ formatSize(o.size) }}</span>
</li>
</ul>
</div>
<p v-if="listing && !listing.entries.length" class="state-msg">空文件夹</p>
<footer v-if="!path && trash" class="trash faint">
<Icon name="trash" :size="14" /> 回收站 {{ trash.count }} 个文件 · {{ formatSize(trash.size) }}
<button v-if="trash.count" class="link" type="button" @click="emptyTrash">清空</button>
</footer>
</section>
</template>
<style scoped>
.crumbs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: 13px;
}
.crumb {
border: 0;
background: none;
padding: 2px 4px;
color: var(--ink-2);
border-radius: 4px;
}
.crumb:hover {
color: var(--ink);
background: var(--surface-2);
}
.crumb.cur {
color: var(--ink);
}
h2 {
word-break: break-word;
}
.tools {
display: flex;
gap: 8px;
}
.panel {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 20px;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.panel-title {
font: 600 15px var(--font-serif);
}
.panel-desc {
margin: 4px 0 0;
font-size: 12.5px;
}
.panel-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px;
}
.chk {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--ink-2);
}
.chk input {
accent-color: var(--accent);
}
.spacer {
flex: 1;
}
.fold-enter-active,
.fold-leave-active {
transition: opacity 180ms var(--ease), transform 180ms var(--ease);
}
.fold-enter-from,
.fold-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.job {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 22px;
padding: 14px 18px;
border-left: 2px solid var(--accent);
background: var(--surface);
font-size: 13px;
}
.job-head {
display: flex;
justify-content: space-between;
gap: 12px;
}
.bar {
display: block;
height: 3px;
border-radius: 3px;
background: var(--line);
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: var(--accent);
transition: width 300ms linear;
}
.small {
font-size: 12px;
}
details ul {
margin: 8px 0 0;
padding-left: 18px;
max-height: 220px;
overflow: auto;
}
details li.bad {
color: var(--danger);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(142px, 1fr));
gap: 24px 18px;
padding: 20px 0 28px;
border-top: 1.5px solid var(--ink);
}
.tile {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
border: 0;
background: none;
text-align: left;
min-width: 0;
}
.art {
position: relative;
display: block;
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--line);
transition: transform 240ms var(--ease), box-shadow 240ms var(--ease);
}
.tile:hover .art {
transform: translateY(-3px);
box-shadow: var(--shadow), inset 0 0 0 1px var(--line);
}
.art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.glyph {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--ink-3);
background: repeating-radial-gradient(circle at 50% 50%, transparent 0 6px, color-mix(in oklab, var(--line) 60%, transparent) 6px 7px);
}
.tile-name {
font-size: 13.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.files {
list-style: none;
margin: 0;
padding: 0;
border-top: 1.5px solid var(--ink);
}
.file {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto auto 72px 30px;
align-items: center;
gap: 14px;
padding: 9px 6px;
border-bottom: 1px solid var(--line);
}
.file:hover {
background: color-mix(in oklab, var(--surface) 60%, transparent);
}
.file.current .fname {
color: var(--accent);
}
.play {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 1px solid var(--line-strong);
border-radius: 50%;
background: none;
color: var(--ink-2);
}
.play:hover,
.file.current .play {
background: var(--ink);
border-color: var(--ink);
color: var(--bg);
}
.fname {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ext,
.lrc {
font-size: 10px;
letter-spacing: 0.06em;
color: var(--ink-3);
}
.lrc {
text-decoration: line-through;
opacity: 0.6;
}
.lrc.on {
text-decoration: none;
opacity: 1;
color: var(--ok);
}
.size {
font-size: 11.5px;
text-align: right;
}
.del {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
background: none;
color: var(--ink-3);
opacity: 0;
}
.file:hover .del {
opacity: 1;
}
.del:hover {
color: var(--danger);
}
.other {
margin-top: 16px;
font-size: 12.5px;
}
.other ul {
list-style: none;
padding: 8px 0 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.other a:hover {
color: var(--ink);
}
.link {
border: 0;
background: none;
padding: 0;
color: var(--accent);
font-size: inherit;
}
.link.faint {
color: var(--ink-3);
}
.trash {
display: flex;
align-items: center;
gap: 8px;
margin-top: 40px;
font-size: 12px;
}
@media (max-width: 760px) {
.file {
grid-template-columns: 28px minmax(0, 1fr) auto 30px;
}
.ext,
.size {
display: none;
}
.del {
opacity: 1;
}
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref } from 'vue';
import { api } from '../lib/api.ts';
import { auth } from '../lib/store.ts';
const emit = defineEmits<{ success: [] }>();
const password = ref('');
const error = ref('');
const busy = ref(false);
async function submit() {
busy.value = true;
error.value = '';
try {
await api.login(password.value);
auth.authenticated = true;
emit('success');
} catch (e) {
error.value = (e as Error).message;
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="login">
<form class="card" @submit.prevent="submit">
<div class="rings" aria-hidden="true" />
<h1>声匣</h1>
<p class="faint eyebrow">Sound Case</p>
<input v-model="password" class="input" type="password" placeholder="访问密码" autofocus autocomplete="current-password" />
<button class="btn primary" type="submit" :disabled="busy || !password">进入</button>
<p v-if="error" class="error-text">{{ error }}</p>
</form>
</div>
</template>
<style scoped>
.login {
display: grid;
place-items: center;
min-height: 100%;
padding: 24px;
}
.card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 300px;
}
.rings {
width: 120px;
height: 120px;
margin-bottom: 12px;
border-radius: 50%;
background:
radial-gradient(circle, var(--accent) 0 12%, var(--bg) 12% 14%, transparent 14%),
repeating-radial-gradient(circle, var(--ink) 0 1.4px, color-mix(in oklab, var(--ink) 84%, var(--bg)) 1.4px 2.8px);
animation: spin 18s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
h1 {
font-size: 30px;
letter-spacing: 0.12em;
}
.eyebrow {
margin: -6px 0 14px;
}
.input,
.btn {
width: 100%;
justify-content: center;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import DetailHeader from '../components/DetailHeader.vue';
import Icon from '../components/Icon.vue';
import TrackList from '../components/TrackList.vue';
import { api, type PlaylistDetail, type Source } from '../lib/api.ts';
const props = defineProps<{ source: Source; id: string }>();
const playlist = ref<PlaylistDetail | null>(null);
const error = ref('');
const filter = ref('');
onMounted(async () => {
try {
playlist.value = await api.playlist(props.source, props.id);
} catch (e) {
error.value = (e as Error).message;
}
});
const tracks = computed(() => {
const list = playlist.value?.tracks ?? [];
const q = filter.value.trim().toLowerCase();
return q ? list.filter((t) => `${t.name} ${t.artists} ${t.album}`.toLowerCase().includes(q)) : list;
});
</script>
<template>
<section class="page">
<button class="back btn ghost sm" type="button" @click="$router.back()"><Icon name="back" :size="15" /> 返回</button>
<p v-if="error" class="state-msg">{{ error }}</p>
<template v-else-if="playlist">
<DetailHeader
:source="playlist.source"
kind="歌单"
:title="playlist.name"
:cover="playlist.cover"
:meta="[`${playlist.trackCount} 首`, playlist.tracks.length < playlist.trackCount && `已获取 ${playlist.tracks.length} 首`]"
:description="playlist.description"
>
<template #by>
<span v-if="playlist.creator">by {{ playlist.creator }}</span>
</template>
<template #actions>
<input v-model="filter" class="input" type="search" placeholder="在歌单中筛选…" />
</template>
</DetailHeader>
<TrackList :tracks="tracks" :group="`歌单 · ${playlist.name}`" />
</template>
<div v-else class="loading"><div v-for="i in 8" :key="i" class="skeleton" /></div>
</section>
</template>
<style scoped>
.back {
margin: -16px 0 14px -10px;
color: var(--ink-2);
}
.input {
width: 240px;
}
</style>
+393
View File
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import CoverArt from '../components/CoverArt.vue';
import Icon from '../components/Icon.vue';
import { api, type Task } from '../lib/api.ts';
import { formatSize, formatSpeed, isGold, QUALITY_BADGE, QUALITY_LABEL, SOURCE_LABEL, STAGE_LABEL } from '../lib/format.ts';
import { isPlaying, play } from '../lib/player.ts';
import { progress, summary, tasks, toast, toastError } from '../lib/store.ts';
type Tab = 'active' | 'done' | 'failed';
const tab = ref<Tab>('active');
const ORDER: Record<string, number> = { running: 0, queued: 1, paused: 2 };
const lists = computed(() => {
const all = [...tasks.values()];
return {
active: all
.filter((t) => t.status in ORDER)
.sort((a, b) => ORDER[a.status]! - ORDER[b.status]! || a.id - b.id),
done: all.filter((t) => t.status === 'done').sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
failed: all
.filter((t) => t.status === 'failed' || t.status === 'canceled')
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
};
});
const shown = computed(() => lists.value[tab.value].slice(0, 400));
async function act(t: Task, action: 'pause' | 'resume' | 'retry' | 'cancel') {
try {
await api.taskAction(t.id, action);
} catch (e) {
toastError(e);
}
}
async function remove(t: Task) {
await api.removeTask(t.id).catch(toastError);
}
async function togglePause() {
await api.queueAction(summary.value.paused ? 'resume' : 'pause').catch(toastError);
}
async function retryFailed() {
const res = await api.queueAction('retry-failed').catch(toastError);
if (res) toast(`已重新排队 ${res.retried} 首`, 'ok');
}
async function clear() {
const res = await api.queueAction('clear', { includeFailed: tab.value === 'failed' }).catch(toastError);
if (res) toast(`已清理 ${res.removed} 条记录`);
}
function pct(t: Task) {
const p = progress.get(t.id);
return p?.total ? Math.min(100, (p.received / p.total) * 100) : 0;
}
function playLocal(t: Task) {
if (!t.file) return;
play({ key: `file:${t.file}`, title: t.name, artists: t.artists, cover: t.cover ?? undefined, src: api.libraryFileUrl(t.file), source: t.source, id: t.trackId });
}
</script>
<template>
<section class="page">
<header class="page-head">
<span class="eyebrow">02 · Queue</span>
<div class="row-between">
<h2>下载队列</h2>
<div class="actions">
<span class="faint mono conc">并发 {{ summary.concurrency }}</span>
<button class="btn" type="button" @click="togglePause">
<Icon :name="summary.paused ? 'playCircle' : 'pauseCircle'" :size="16" />
{{ summary.paused ? '继续队列' : '暂停队列' }}
</button>
</div>
</div>
<p v-if="summary.paused" class="paused-note">队列已暂停:进行中的任务会完成,新任务暂不开始。</p>
</header>
<div class="tabs">
<div class="seg" role="tablist">
<button type="button" role="tab" :aria-selected="tab === 'active'" @click="tab = 'active'">
进行中 <span class="mono">{{ lists.active.length }}</span>
</button>
<button type="button" role="tab" :aria-selected="tab === 'done'" @click="tab = 'done'">
已完成 <span class="mono">{{ lists.done.length }}</span>
</button>
<button type="button" role="tab" :aria-selected="tab === 'failed'" @click="tab = 'failed'">
失败 / 取消 <span class="mono">{{ lists.failed.length }}</span>
</button>
</div>
<div class="tab-actions">
<button v-if="tab === 'failed' && lists.failed.some((t) => t.status === 'failed')" class="btn sm" type="button" @click="retryFailed">
<Icon name="retry" :size="14" /> 全部重试
</button>
<button v-if="tab !== 'active' && shown.length" class="btn sm ghost" type="button" @click="clear">清理记录</button>
</div>
</div>
<TransitionGroup v-if="shown.length" tag="ol" name="list" class="rows">
<li v-for="t in shown" :key="t.id" class="row" :class="[t.status, t.source]">
<CoverArt :src="t.cover" :size="42" />
<div class="title">
<div class="name">{{ t.name }}</div>
<div class="sub muted">
{{ t.artists || '—' }}<span v-if="t.album" class="faint"> · {{ t.album }}</span>
</div>
<div v-if="t.status === 'running'" class="prog">
<span class="bar"><i :style="{ width: `${pct(t)}%` }" /></span>
</div>
<div v-else-if="t.status === 'failed'" class="err">{{ t.error }}</div>
<div v-else-if="t.status === 'done' && t.file" class="file faint mono" :title="t.file">{{ t.file }}</div>
</div>
<div class="info">
<template v-if="t.status === 'running'">
<span class="st run">{{ STAGE_LABEL[progress.get(t.id)?.stage ?? t.stage ?? 'resolve'] }}</span>
<span v-if="progress.get(t.id)?.total" class="mono faint small">
{{ Math.round(pct(t)) }}% · {{ formatSize(progress.get(t.id)!.total) }} · {{ formatSpeed(progress.get(t.id)!.speed) }}
</span>
</template>
<template v-else-if="t.status === 'done'">
<span class="badge" :class="{ gold: isGold(t.actualQuality) }" :title="t.actualQuality && t.actualQuality !== t.quality ? `请求 ${QUALITY_LABEL[t.quality]},实际 ${QUALITY_LABEL[t.actualQuality]}` : ''">
{{ t.actualQuality ? QUALITY_BADGE[t.actualQuality] : '—' }}
</span>
<span class="mono faint small">
{{ formatSize(t.size) }}<template v-if="t.skipped"> · 已存在</template><template v-if="t.warnings.length"> · <span :title="t.warnings.join('\n')" class="warn">{{ t.warnings.length }} 提示</span></template>
</span>
</template>
<template v-else>
<span class="st">{{ { queued: '排队中', paused: '已暂停', failed: '失败', canceled: '已取消' }[t.status as string] }}</span>
<span class="mono faint small">{{ SOURCE_LABEL[t.source] }} · {{ QUALITY_LABEL[t.quality] }}</span>
</template>
</div>
<div class="ops">
<template v-if="t.status === 'running' || t.status === 'queued'">
<button type="button" title="暂停" @click="act(t, 'pause')"><Icon name="pauseCircle" /></button>
<button type="button" title="取消" @click="act(t, 'cancel')"><Icon name="x" /></button>
</template>
<template v-else-if="t.status === 'paused'">
<button type="button" title="继续" @click="act(t, 'resume')"><Icon name="playCircle" /></button>
<button type="button" title="取消" @click="act(t, 'cancel')"><Icon name="x" /></button>
</template>
<template v-else-if="t.status === 'done'">
<button type="button" title="播放本地文件" @click="playLocal(t)"><Icon :name="isPlaying(`file:${t.file}`) ? 'pause' : 'play'" /></button>
<button type="button" title="移除记录" @click="remove(t)"><Icon name="x" /></button>
</template>
<template v-else>
<button type="button" title="重试" @click="act(t, 'retry')"><Icon name="retry" /></button>
<button type="button" title="移除记录" @click="remove(t)"><Icon name="x" /></button>
</template>
</div>
</li>
</TransitionGroup>
<div v-else class="empty">
<div class="sleeve" aria-hidden="true"><div class="record" /></div>
<p class="faint">{{ tab === 'active' ? '队列是空的 —— 去搜索页挑几首歌吧' : '暂无记录' }}</p>
</div>
</section>
</template>
<style scoped>
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.conc {
font-size: 12px;
}
.paused-note {
margin: 0;
font-size: 12.5px;
color: var(--gold);
}
.tabs {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1.5px solid var(--ink);
}
.seg .mono {
margin-left: 4px;
font-size: 11px;
opacity: 0.7;
}
.tab-actions {
display: flex;
gap: 8px;
}
.rows {
list-style: none;
margin: 0;
padding: 0;
}
.row {
position: relative;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 210px 76px;
align-items: center;
gap: 16px;
padding: 12px 6px 12px 12px;
border-bottom: 1px solid var(--line);
}
.row::before {
content: '';
position: absolute;
left: 0;
top: 16px;
bottom: 16px;
width: 2px;
border-radius: 2px;
background: var(--netease);
opacity: 0.6;
}
.row.qq::before {
background: var(--qq);
}
.row.paused,
.row.canceled {
opacity: 0.62;
}
.title {
min-width: 0;
}
.name {
font-size: 14.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 12.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prog {
margin-top: 8px;
}
.bar {
display: block;
height: 3px;
border-radius: 3px;
background: var(--line);
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: var(--accent);
transition: width 300ms linear;
}
.err {
margin-top: 4px;
font-size: 12px;
color: var(--danger);
}
.file {
margin-top: 4px;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
text-align: right;
}
.st {
font-size: 12.5px;
color: var(--ink-2);
}
.st.run {
color: var(--accent);
}
.small {
font-size: 11px;
}
.warn {
color: var(--gold);
cursor: help;
}
.ops {
display: flex;
justify-content: flex-end;
gap: 2px;
}
.ops button {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
background: none;
color: var(--ink-3);
}
.ops button:hover {
color: var(--ink);
background: var(--surface-2);
}
.list-enter-active,
.list-leave-active {
transition: all 220ms var(--ease);
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-8px);
}
.empty {
display: grid;
place-items: center;
gap: 26px;
padding: 80px 0;
}
.sleeve {
position: relative;
width: 120px;
height: 120px;
border-radius: 4px;
background: var(--surface);
box-shadow: var(--shadow), inset 0 0 0 1px var(--line);
isolation: isolate;
}
.record {
position: absolute;
top: 6px;
left: 40px;
width: 108px;
height: 108px;
z-index: -1;
border-radius: 50%;
background:
radial-gradient(circle, var(--accent) 0 14%, var(--ink) 14% 15%, transparent 15%),
repeating-radial-gradient(circle, var(--ink) 0 1.5px, color-mix(in oklab, var(--ink) 85%, var(--bg)) 1.5px 3px);
}
@media (max-width: 760px) {
.tabs {
flex-wrap: wrap;
}
.row {
grid-template-columns: 42px minmax(0, 1fr) 70px;
}
.info {
display: none;
}
}
</style>
+285
View File
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import Icon from '../components/Icon.vue';
import TrackList from '../components/TrackList.vue';
import { api, type Source, type TrackSummary } from '../lib/api.ts';
import { SOURCE_LABEL } from '../lib/format.ts';
import { qqStatus } from '../lib/store.ts';
// 模块级状态:离开页面再回来时保留搜索结果
const state = reactive({
source: 'netease' as Source,
query: '',
searched: '',
items: [] as TrackSummary[],
total: 0,
page: 1,
});
const RECENT_KEY = 'sx-recent';
function readRecent(): string[] {
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
} catch {
return [];
}
}
const recent = ref<string[]>(readRecent());
function remember(q: string) {
recent.value = [q, ...recent.value.filter((x) => x !== q)].slice(0, 8);
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(recent.value));
} catch {}
}
const router = useRouter();
const loading = ref(false);
const error = ref('');
const hasMore = computed(() => state.items.length < state.total);
const looksLikeLink = (text: string) => /https?:\/\/|^\d{5,}$|^00[0-9A-Za-z]{12}$/.test(text.trim());
async function openLink(text: string): Promise<boolean> {
const link = await api.resolveLink(text).catch(() => null);
if (!link) return false;
if (link.kind === 'track') {
const meta = await api.track(link.source, link.id);
state.source = link.source;
state.items = [{ ...meta, artistRefs: undefined }];
state.total = 1;
state.searched = `${SOURCE_LABEL[link.source]} 歌曲 · ${meta.name}`;
} else {
router.push(`/${link.kind}/${link.source}/${link.id}`);
}
return true;
}
async function submit(text = state.query) {
const q = text.trim();
if (!q) return;
state.query = q;
error.value = '';
loading.value = true;
try {
if (looksLikeLink(q) && (await openLink(q))) return;
const res = await api.search(state.source, q, 1);
state.items = res.items;
state.total = res.total;
state.page = 1;
state.searched = q;
remember(q);
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
function switchSource(s: Source) {
if (state.source === s) return;
state.source = s;
if (state.searched && !looksLikeLink(state.query)) submit(state.query);
}
async function loadMore() {
loading.value = true;
try {
const res = await api.search(state.source, state.searched, state.page + 1);
state.page += 1;
state.items = [...state.items, ...res.items];
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
const qqHint = computed(() => state.source === 'qq' && qqStatus.value && !qqStatus.value.loggedIn);
</script>
<template>
<section class="page">
<header class="head">
<span class="eyebrow">01 · Search</span>
<form class="searchbar" @submit.prevent="submit()">
<Icon name="search" :size="22" class="lead" />
<input v-model="state.query" class="q" type="search" placeholder="搜索歌曲、歌手,或粘贴分享链接" autocomplete="off" autofocus />
<div class="seg" role="tablist" aria-label="音乐来源">
<button type="button" role="tab" :aria-selected="state.source === 'netease'" @click="switchSource('netease')">网易云</button>
<button type="button" role="tab" :aria-selected="state.source === 'qq'" @click="switchSource('qq')">QQ 音乐</button>
</div>
</form>
<p v-if="qqHint" class="hint">
QQ 音乐尚未登录,只能下载免费曲目。<RouterLink to="/settings">去导入 Cookie →</RouterLink>
</p>
</header>
<p v-if="error" class="error-text">{{ error }}</p>
<template v-if="state.items.length">
<div class="meta-line">
<span>「{{ state.searched }}」</span>
<span class="faint mono">{{ state.total }} results · {{ SOURCE_LABEL[state.source] }}</span>
</div>
<TrackList :tracks="state.items" />
<div class="more">
<button v-if="hasMore" class="btn" :disabled="loading" @click="loadMore">{{ loading ? '加载中…' : '加载更多' }}</button>
<span v-else class="faint">— 已到末尾 —</span>
</div>
</template>
<div v-else-if="loading" class="loading">
<div v-for="i in 6" :key="i" class="skeleton" />
</div>
<div v-else class="empty">
<div class="rings" aria-hidden="true" />
<p class="empty-title">从一首歌开始。</p>
<p class="faint">关键词、歌曲 ID,或网易云 / QQ 音乐的歌曲、专辑、歌单、歌手链接</p>
<div v-if="recent.length" class="recent">
<button v-for="r in recent" :key="r" type="button" class="chip" @click="submit(r)">{{ r }}</button>
</div>
</div>
</section>
</template>
<style scoped>
.head {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 18px;
}
.searchbar {
display: flex;
align-items: center;
gap: 14px;
border-bottom: 1.5px solid var(--ink);
}
.lead {
color: var(--ink-3);
}
.q {
flex: 1;
min-width: 0;
padding: 10px 0 12px;
border: 0;
background: none;
outline: none;
font: 400 26px/1.3 var(--font-serif);
letter-spacing: 0.02em;
}
.q::placeholder {
color: var(--ink-3);
}
.q::-webkit-search-cancel-button {
display: none;
}
.hint {
margin: 0;
font-size: 12.5px;
color: var(--ink-2);
}
.hint a {
color: var(--accent);
}
.meta-line {
display: flex;
justify-content: space-between;
align-items: baseline;
margin: 6px 0 4px;
font-size: 13px;
}
.more {
display: flex;
justify-content: center;
padding: 28px 0;
font-size: 12px;
}
.empty {
display: grid;
place-items: center;
padding: 80px 0 40px;
text-align: center;
}
.rings {
width: 180px;
height: 180px;
border-radius: 50%;
margin-bottom: 28px;
background:
radial-gradient(circle, var(--accent) 0 9%, var(--bg) 9% 10.5%, transparent 10.5%),
repeating-radial-gradient(circle, var(--line) 0 1px, transparent 1px 7px);
mask: radial-gradient(circle, #000 60%, transparent 71%);
animation: spin 24s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-title {
margin: 0 0 6px;
font: 500 24px/1.4 var(--font-serif);
letter-spacing: 0.06em;
}
.empty p {
margin: 0;
font-size: 13px;
}
.recent {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
max-width: 520px;
margin-top: 26px;
}
.chip {
height: 28px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
font-size: 12.5px;
color: var(--ink-2);
}
.chip:hover {
border-color: var(--line-strong);
color: var(--ink);
}
@media (max-width: 760px) {
.searchbar {
flex-wrap: wrap;
border-bottom: 0;
}
.lead {
display: none;
}
.q {
flex-basis: 100%;
border-bottom: 1.5px solid var(--ink);
font-size: 21px;
}
}
</style>
+589
View File
@@ -0,0 +1,589 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import Icon from '../components/Icon.vue';
import QRLoginDialog from '../components/QRLoginDialog.vue';
import { api, type QQStatus, type QualityKey, type Settings } from '../lib/api.ts';
import { formatDate, QUALITY_BADGE, QUALITY_LABEL } from '../lib/format.ts';
import { auth, ffmpeg, meta, qqStatus, refreshAccounts, settings, toast, toastError } from '../lib/store.ts';
import { themeMode, type ThemeMode } from '../lib/theme.ts';
// ---- QQ 账号 ----
const cookieInput = ref('');
const showCookieForm = ref(false);
const savingCookie = ref(false);
const checking = ref(false);
async function saveCookie() {
savingCookie.value = true;
try {
qqStatus.value = (await api.saveQQCookie(cookieInput.value)).qq;
cookieInput.value = '';
showCookieForm.value = false;
toast(qqStatus.value.valid ? `已登录:${qqStatus.value.nick ?? 'QQ 音乐'}` : 'Cookie 已保存,但校验未通过', qqStatus.value.valid ? 'ok' : 'error');
} catch (e) {
toastError(e);
} finally {
savingCookie.value = false;
}
}
async function check() {
checking.value = true;
await refreshAccounts(true);
checking.value = false;
}
async function logoutQQ() {
if (!confirm('清除已保存的 QQ 音乐 Cookie?')) return;
qqStatus.value = (await api.logoutQQ()).qq;
}
const showQR = ref(false);
const renewing = ref(false);
function onQRSuccess(qq: QQStatus) {
qqStatus.value = qq;
showQR.value = false;
toast(`已登录:${qq.nick ?? 'QQ 音乐'}`, 'ok');
}
async function renew() {
renewing.value = true;
try {
const res = await api.refreshQQ();
qqStatus.value = res.qq;
toast(res.ok ? '凭据已续期' : `续期失败:${res.qq.lastRefresh?.error ?? ''}`, res.ok ? 'ok' : 'error');
} catch (e) {
toastError(e);
} finally {
renewing.value = false;
}
}
function relTime(iso: string | undefined, future = false): string {
if (!iso) return '';
const diff = (Date.parse(iso) - Date.now()) / 1000 * (future ? 1 : -1);
if (diff < 0) return future ? '已过期' : '刚刚';
if (diff < 3600) return `${Math.max(1, Math.round(diff / 60))} 分钟${future ? '后' : '前'}`;
if (diff < 86400) return `${Math.round(diff / 3600)} 小时${future ? '后' : '前'}`;
return `${Math.round(diff / 86400)} 天${future ? '后' : '前'}`;
}
const VIA_LABEL = { cookie: '粘贴 Cookie', file: 'Cookie 文件', qr: '扫码登录', refresh: '自动续期' } as const;
// ---- 下载设置(修改即保存) ----
async function save(patch: Partial<Settings>) {
try {
settings.value = await api.saveSettings(patch);
} catch (e) {
toastError(e);
}
}
const qualityOptions = computed(() => [...(meta.value?.qualities ?? [])].reverse());
// ---- 访问密码 ----
const pwCurrent = ref('');
const pwNext = ref('');
async function savePassword(clear = false) {
try {
const res = await api.setPassword(pwCurrent.value || undefined, clear ? undefined : pwNext.value);
auth.required = res.required;
pwCurrent.value = '';
pwNext.value = '';
toast(res.required ? '访问密码已更新' : '已关闭访问密码', 'ok');
} catch (e) {
toastError(e);
}
}
async function logoutSite() {
await api.logout();
auth.authenticated = false;
}
const themes: Array<{ key: ThemeMode; label: string }> = [
{ key: 'auto', label: '跟随系统' },
{ key: 'light', label: '纸 · 浅色' },
{ key: 'dark', label: '墨 · 深色' },
];
onMounted(() => refreshAccounts());
</script>
<template>
<section class="page narrow">
<header class="page-head">
<span class="eyebrow">05 · Settings</span>
<h2>设置</h2>
</header>
<!-- QQ 音乐账号 -->
<section class="group">
<h3>QQ 音乐账号</h3>
<div class="account" :class="{ off: !qqStatus?.loggedIn }">
<img v-if="qqStatus?.avatar" :src="qqStatus.avatar" class="avatar" alt="" referrerpolicy="no-referrer" />
<span v-else class="avatar ph"><Icon name="user" :size="22" /></span>
<div class="acc-info">
<template v-if="qqStatus?.loggedIn">
<div class="acc-name">
{{ qqStatus.nick ?? 'QQ 用户' }}
<span v-if="qqStatus.vip?.active" class="vip">{{ qqStatus.vip.label }}</span>
<span v-if="qqStatus.valid === false" class="bad">登录已失效</span>
</div>
<div class="faint mono small">
{{ qqStatus.uin }}
<template v-if="qqStatus.vip?.expires"> · 会员至 {{ qqStatus.vip.expires }}</template>
</div>
</template>
<template v-else>
<div class="acc-name">未登录</div>
<div class="faint small">扫码或导入 y.qq.com 的 Cookie 后可下载 VIP 曲目(最高 FLAC / Hi-Res)</div>
</template>
</div>
<div class="acc-ops">
<button v-if="qqStatus?.loggedIn" class="btn sm" type="button" :disabled="checking" @click="check"><Icon name="retry" :size="14" /> 校验</button>
<button class="btn sm" :class="{ primary: !qqStatus?.loggedIn }" type="button" @click="showQR = true">扫码登录</button>
<button v-if="qqStatus?.loggedIn" class="btn sm ghost danger" type="button" title="退出登录" @click="logoutQQ"><Icon name="logout" :size="14" /></button>
</div>
</div>
<dl v-if="qqStatus?.loggedIn" class="cred small">
<dt>凭据来源</dt>
<dd>{{ qqStatus.via ? VIA_LABEL[qqStatus.via] : '—' }} <span v-if="qqStatus.savedAt" class="faint">· {{ relTime(qqStatus.savedAt) }}</span></dd>
<dt>musickey</dt>
<dd>
生成于 {{ relTime(qqStatus.keyCreatedAt) || '未知' }}
<template v-if="qqStatus.keyExpiresAt"> · {{ relTime(qqStatus.keyExpiresAt, true) }}过期</template>
</dd>
<dt>自动续期</dt>
<dd>
<template v-if="qqStatus.canRefresh">
已开启
<span v-if="qqStatus.lastRefresh" :class="qqStatus.lastRefresh.ok ? 'ok' : 'bad'">
· 上次{{ qqStatus.lastRefresh.ok ? '成功' : `失败(${qqStatus.lastRefresh.error})` }},{{ relTime(qqStatus.lastRefresh.at) }}
</span>
<button class="link" type="button" :disabled="renewing" @click="renew">{{ renewing ? '续期中…' : '立即续期' }}</button>
</template>
<span v-else class="faint">当前凭据缺少 refresh_token,请扫码登录</span>
</dd>
</dl>
<button v-if="!showCookieForm" class="link small" type="button" @click="showCookieForm = true">或手动粘贴 Cookie</button>
<form v-if="showCookieForm" class="cookie-form" @submit.prevent="saveCookie">
<textarea v-model="cookieInput" class="input area" rows="4" placeholder="在浏览器登录 y.qq.com 后,开发者工具 → Network → 任一请求的 Cookie 请求头,整段粘贴到这里" />
<div class="form-row">
<span class="faint small">需包含 uin 与 qm_keyst(或 qqmusic_key)。Cookie 加密保存在本机,不会回传到页面。</span>
<button class="btn sm primary" type="submit" :disabled="savingCookie || !cookieInput.trim()">保存并校验</button>
</div>
</form>
<p v-if="showCookieForm" class="faint small note">也可以把 Cookie 写入 config.json 中 qq.cookieFile 指向的文件,文件更新后重启即会自动导入。</p>
<QRLoginDialog v-if="showQR" @close="showQR = false" @success="onQRSuccess" />
</section>
<!-- 下载 -->
<section v-if="settings" class="group">
<h3>下载</h3>
<div class="field">
<div class="label">默认音质<span class="faint">不可用时自动降级;QQ 音乐最高 Hi-Res</span></div>
<select class="input" :value="settings.quality" @change="save({ quality: ($event.target as HTMLSelectElement).value as QualityKey })">
<option v-for="q in qualityOptions" :key="q.key" :value="q.key">{{ QUALITY_LABEL[q.key] }} · {{ QUALITY_BADGE[q.key] }}</option>
</select>
</div>
<div class="field">
<div class="label">目录结构<span class="faint">{{ meta?.musicDir }}</span></div>
<div class="seg" role="tablist">
<button type="button" role="tab" :aria-selected="settings.layout === 'album'" @click="save({ layout: 'album' })">歌手 / 专辑 / 01. 歌名</button>
<button type="button" role="tab" :aria-selected="settings.layout === 'flat'" @click="save({ layout: 'flat' })">歌手 - 歌名</button>
</div>
</div>
<div class="field">
<div class="label">同时下载<span class="faint">并发任务数</span></div>
<div class="stepper">
<button type="button" :disabled="settings.concurrency <= 1" @click="save({ concurrency: settings.concurrency - 1 })">−</button>
<span class="mono">{{ settings.concurrency }}</span>
<button type="button" :disabled="settings.concurrency >= 6" @click="save({ concurrency: settings.concurrency + 1 })">+</button>
</div>
</div>
<div class="field">
<div class="label">失败重试<span class="faint">每首最多尝试次数</span></div>
<div class="stepper">
<button type="button" :disabled="settings.retries <= 1" @click="save({ retries: settings.retries - 1 })">−</button>
<span class="mono">{{ settings.retries }}</span>
<button type="button" :disabled="settings.retries >= 10" @click="save({ retries: settings.retries + 1 })">+</button>
</div>
</div>
<div class="field">
<div class="label">任务间隔<span class="faint">避免请求过快被限流</span></div>
<select class="input" :value="settings.gapMs" @change="save({ gapMs: Number(($event.target as HTMLSelectElement).value) })">
<option v-for="ms in [0, 200, 400, 800, 1500, 3000]" :key="ms" :value="ms">{{ ms ? `${ms} ms` : '不间隔' }}</option>
</select>
</div>
<div class="toggles">
<label v-for="opt in [
{ key: 'lyric', label: '保存 .lrc 歌词' },
{ key: 'translatedLyric', label: '额外保存合并翻译歌词' },
{ key: 'embedTags', label: '写入封面与标签' },
{ key: 'albumCover', label: '专辑目录保存 cover.jpg' },
] as const" :key="opt.key" class="toggle">
<input type="checkbox" :checked="settings[opt.key]" @change="save({ [opt.key]: ($event.target as HTMLInputElement).checked })" />
<span class="sw" />
{{ opt.label }}
</label>
</div>
<p v-if="ffmpeg === false" class="warn small">未检测到 ffmpeg:FLAC 等非 MP3 文件无法写入标签。请安装 ffmpeg 并加入 PATH。</p>
</section>
<!-- 访问密码 -->
<section class="group">
<h3>访问密码 <span class="faint state">{{ auth.required ? '已开启' : '未开启' }}</span></h3>
<p class="faint small">在 NAS 上暴露到局域网 / 公网时建议开启。</p>
<form class="pw" @submit.prevent="savePassword()">
<input v-if="auth.required" v-model="pwCurrent" class="input" type="password" placeholder="当前密码" autocomplete="current-password" />
<input v-model="pwNext" class="input" type="password" :placeholder="auth.required ? '新密码' : '设置密码(至少 4 位)'" autocomplete="new-password" />
<button class="btn sm primary" type="submit" :disabled="pwNext.length < 4">{{ auth.required ? '修改' : '开启' }}</button>
<button v-if="auth.required" class="btn sm ghost danger" type="button" :disabled="!pwCurrent" @click="savePassword(true)">关闭密码</button>
<button v-if="auth.required" class="btn sm ghost" type="button" @click="logoutSite">退出登录</button>
</form>
</section>
<!-- 外观 -->
<section class="group">
<h3>外观</h3>
<div class="themes">
<button v-for="t in themes" :key="t.key" type="button" class="theme-opt" :class="{ active: themeMode === t.key }" @click="themeMode = t.key">
<span class="swatch" :data-mode="t.key" />
{{ t.label }}
</button>
</div>
</section>
<p class="faint small foot">声匣 · 数据目录中的 shengxia.db 保存历史、队列与设置 · {{ formatDate(Date.now()) }}</p>
</section>
</template>
<style scoped>
.narrow {
max-width: 780px;
}
.group {
padding: 24px 0;
border-top: 1px solid var(--line);
}
h3 {
font-size: 17px;
margin-bottom: 14px;
}
.state {
font: 400 12px var(--font-sans);
margin-left: 6px;
}
.small {
font-size: 12.5px;
}
.note {
margin: 10px 0 0;
}
.account {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 18px;
border: 1px solid var(--line);
border-left: 3px solid var(--qq);
border-radius: var(--radius);
background: var(--surface);
}
.account.off {
border-left-color: var(--line-strong);
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.avatar.ph {
display: grid;
place-items: center;
background: var(--surface-2);
color: var(--ink-3);
}
.acc-info {
flex: 1;
min-width: 0;
}
.acc-name {
display: flex;
align-items: center;
gap: 8px;
font: 600 16px var(--font-serif);
}
.vip {
padding: 0 6px;
border-radius: 4px;
font: 600 10.5px/18px var(--font-sans);
color: var(--qq);
border: 1px solid color-mix(in oklab, var(--qq) 50%, transparent);
}
.bad {
font: 500 12px var(--font-sans);
color: var(--danger);
}
.acc-ops {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.cred {
display: grid;
grid-template-columns: 88px 1fr;
gap: 6px 14px;
margin: 14px 0 6px;
padding: 0 4px;
}
.cred dt {
color: var(--ink-3);
}
.cred dd {
margin: 0;
}
.cred .ok {
color: var(--ok);
}
.cred .bad {
font: inherit;
color: var(--danger);
}
.link {
margin-left: 8px;
padding: 0;
border: 0;
background: none;
color: var(--accent);
font-size: inherit;
}
.link.small {
margin: 8px 0 0 4px;
}
.cookie-form {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.area {
height: auto;
padding: 10px 12px;
resize: vertical;
font: 12px/1.6 var(--font-mono);
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 0;
}
.label {
display: flex;
flex-direction: column;
font-size: 14px;
}
.label .faint {
font-size: 12px;
word-break: break-all;
}
.stepper {
display: inline-flex;
align-items: center;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
overflow: hidden;
}
.stepper button {
width: 32px;
height: 32px;
border: 0;
background: none;
font-size: 16px;
color: var(--ink-2);
}
.stepper button:hover:not(:disabled) {
background: var(--surface-2);
color: var(--ink);
}
.stepper button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.stepper span {
min-width: 34px;
text-align: center;
}
.toggles {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 24px;
margin-top: 14px;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
font-size: 13.5px;
cursor: pointer;
}
.toggle input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.sw {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
border-radius: 999px;
background: var(--line-strong);
transition: background var(--dur) var(--ease);
}
.sw::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--surface);
transition: transform var(--dur) var(--ease);
}
.toggle input:checked + .sw {
background: var(--accent);
}
.toggle input:checked + .sw::after {
transform: translateX(14px);
}
.toggle input:focus-visible + .sw {
outline: 2px solid color-mix(in oklab, var(--accent) 60%, transparent);
outline-offset: 2px;
}
.warn {
color: var(--gold);
margin: 14px 0 0;
}
.pw {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.pw .input {
width: 200px;
}
.themes {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.theme-opt {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink-2);
}
.theme-opt.active {
border-color: var(--accent);
color: var(--ink);
}
.swatch {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--line-strong);
background: linear-gradient(135deg, #f4f1ea 50%, #121315 50%);
}
.swatch[data-mode='light'] {
background: #f4f1ea;
}
.swatch[data-mode='dark'] {
background: #121315;
}
.foot {
margin-top: 30px;
}
@media (max-width: 760px) {
.account {
flex-wrap: wrap;
}
.toggles {
grid-template-columns: 1fr;
}
.field {
flex-wrap: wrap;
}
}
</style>
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"noEmit": true,
"noUncheckedIndexedAccess": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}