feat: 粘贴网易云 / QQ 音乐歌单分享链接导入为自建歌单
- POST /api/playlists/import:resolveLink 识别歌单 / 专辑链接,拉取全部曲目后建歌单,简介注明来源与原简介 - 歌单页新增「导入歌单」;上游只返回部分曲目时提示实际获取数 - 网易云歌单详情补上 description Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user