feat: 粘贴网易云 / QQ 音乐歌单分享链接导入为自建歌单

- POST /api/playlists/import:resolveLink 识别歌单 / 专辑链接,拉取全部曲目后建歌单,简介注明来源与原简介
- 歌单页新增「导入歌单」;上游只返回部分曲目时提示实际获取数
- 网易云歌单详情补上 description

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-11 11:45:38 +08:00
parent 676541457b
commit 53d523d7a0
7 changed files with 108 additions and 14 deletions
+1
View File
@@ -403,6 +403,7 @@ export const api = {
emptyTrash: () => request('/library/trash', { method: 'DELETE' }),
myPlaylists: () => request<{ items: MyPlaylistSummary[] }>('/playlists').then((r) => r.items),
myPlaylist: (id: number) => request<MyPlaylist>(`/playlists/${id}`),
importPlaylist: (url: string) => request<{ playlist: MyPlaylistSummary; fetched: number; total: number }>('/playlists/import', json('POST', { url })),
createPlaylist: (name: string, items?: PlaylistItemInput[], description?: string) =>
request<MyPlaylistSummary>('/playlists', json('POST', { name, description, items })),
updatePlaylist: (id: number, patch: { name?: string; description?: string }) => request<MyPlaylistSummary>(`/playlists/${id}`, json('PATCH', patch)),
+77 -11
View File
@@ -7,27 +7,51 @@ import { api, type MyPlaylistSummary } from '../lib/api.ts';
import { formatTotal } from '../lib/format.ts';
import { fromPlaylistItem, player, playQueue, toggle } from '../lib/player.ts';
import { createMyPlaylist, loadMyPlaylists, myPlaylists } from '../lib/playlists.ts';
import { toastError } from '../lib/store.ts';
import { toast, toastError } from '../lib/store.ts';
const router = useRouter();
const creating = ref(false);
const mode = ref<'create' | 'import' | null>(null);
const name = ref('');
const link = ref('');
const importing = ref(false);
const input = ref<HTMLInputElement | null>(null);
onMounted(() => loadMyPlaylists(true));
function startCreate() {
creating.value = true;
function start(next: 'create' | 'import') {
mode.value = next;
name.value = '';
link.value = '';
nextTick(() => input.value?.focus());
}
async function importLink() {
const url = link.value.trim();
if (!url || importing.value) return;
importing.value = true;
try {
const { playlist, fetched, total } = await api.importPlaylist(url);
toast(
fetched < total ? `已导入「${playlist.name}」${playlist.count} 首(来源共 ${total} 首,只获取到 ${fetched} 首)` : `已导入「${playlist.name}」${playlist.count} 首`,
fetched < total ? 'info' : 'ok',
fetched < total ? 6000 : undefined,
);
mode.value = null;
loadMyPlaylists(true);
router.push(`/playlists/${playlist.id}`);
} catch (e) {
toastError(e);
} finally {
importing.value = false;
}
}
async function create() {
const title = name.value.trim();
if (!title) return;
const created = await createMyPlaylist(title);
if (created) {
creating.value = false;
mode.value = null;
router.push(`/playlists/${created.id}`);
}
}
@@ -54,12 +78,27 @@ async function playAll(p: MyPlaylistSummary) {
歌单
<span v-if="myPlaylists?.length" class="count">{{ myPlaylists.length }} 个</span>
</h2>
<form v-if="creating" class="create" @submit.prevent="create">
<input ref="input" v-model="name" class="input" maxlength="100" placeholder="歌单名称" @keydown.esc="creating = false" />
<form v-if="mode === 'create'" class="create" @submit.prevent="create">
<input ref="input" v-model="name" class="input" maxlength="100" placeholder="歌单名称" @keydown.esc="mode = null" />
<button class="btn sm primary" type="submit" :disabled="!name.trim()">创建</button>
<button class="btn sm ghost" type="button" @click="creating = false">取消</button>
<button class="btn sm ghost" type="button" @click="mode = null">取消</button>
</form>
<button v-else class="btn sm" type="button" @click="startCreate"><Icon name="plus" :size="15" /> 新建歌单</button>
<form v-else-if="mode === 'import'" class="create import" @submit.prevent="importLink">
<input
ref="input"
v-model="link"
class="input"
:disabled="importing"
placeholder="粘贴网易云 / QQ 音乐歌单分享链接"
@keydown.esc="mode = null"
/>
<button class="btn sm primary" type="submit" :disabled="!link.trim() || importing">{{ importing ? '导入中…' : '导入' }}</button>
<button class="btn sm ghost" type="button" :disabled="importing" @click="mode = null">取消</button>
</form>
<div v-else class="actions">
<button class="btn sm" type="button" @click="start('import')"><Icon name="link" :size="15" /> 导入歌单</button>
<button class="btn sm" type="button" @click="start('create')"><Icon name="plus" :size="15" /> 新建歌单</button>
</div>
</div>
</header>
@@ -81,8 +120,11 @@ async function playAll(p: MyPlaylistSummary) {
<div v-else-if="myPlaylists" class="empty">
<span class="empty-art"><Icon name="queue" :size="30" /></span>
<p class="muted">还没有歌单</p>
<p class="faint small">在搜索结果、专辑或曲库里勾选歌曲后点「加入歌单」,在线曲目和本地文件可以放进同一个歌单。</p>
<button class="btn primary" type="button" @click="startCreate"><Icon name="plus" :size="16" /> 新建歌单</button>
<p class="faint small">在搜索结果、专辑或曲库里勾选歌曲后点「加入歌单」,在线曲目和本地文件可以放进同一个歌单;也可以直接粘贴网易云 / QQ 音乐的歌单分享链接导入。</p>
<div class="actions">
<button class="btn" type="button" @click="start('import')"><Icon name="link" :size="16" /> 导入歌单</button>
<button class="btn primary" type="button" @click="start('create')"><Icon name="plus" :size="16" /> 新建歌单</button>
</div>
</div>
<div v-else class="grid">
<div v-for="i in 5" :key="i" class="tile"><span class="art skeleton" /></div>
@@ -101,6 +143,15 @@ async function playAll(p: MyPlaylistSummary) {
height: 28px;
}
.create.import .input {
width: 340px;
}
.actions {
display: flex;
gap: 8px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
@@ -210,6 +261,21 @@ async function playAll(p: MyPlaylistSummary) {
}
@media (max-width: 760px) {
.row-between:has(.import) {
flex-wrap: wrap;
gap: 10px;
}
.create.import {
width: 100%;
}
.create.import .input {
flex: 1;
width: auto;
min-width: 0;
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 20px 14px;