feat: share my playlists from the playlist menu

This commit is contained in:
DiheChen committed 2026-11-11 00:09:38 +08:00
1 parent 8b941764b1
commit 1f99bd939b
4 files changed
+210 -7

No files matched your search

@@ -242,7 +242,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
{selecting ? '完成' : '选择'}
</button>
)}
{playlist && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} onExport={() => void exportToFile(playlist)} />}
{playlist && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" shareable onDeleted={onBack} onExport={() => void exportToFile(playlist)} />}
</div>
</div>
</header>
+60 -4
View File
@@ -1,22 +1,52 @@
import type { MenuAction } from './action-menu'
import type { MyPlaylist } from '@/core/realtime'
import { Ellipsis, FileDown, Pencil, Trash2 } from 'lucide-react'
import { Copy, Ellipsis, FileDown, Link2Off, Pencil, RefreshCw, Share2, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { ConfirmDialog, Modal } from '@/shared/ui'
import { ConfirmDialog, Modal, notificationStore } from '@/shared/ui'
import { ActionMenu } from './action-menu'
import { playlistsStore } from './playlists-store'
export function PlaylistMenu({ playlist, align = 'end', className = '', onDeleted, onExport }: {
async function copyShareLink(shareId: string, copied: string): Promise<void> {
const url = `${window.location.origin}/s/${shareId}`
try {
await navigator.clipboard.writeText(url)
notificationStore.success(copied)
}
catch {
// 非安全上下文没有剪贴板接口,用户也可能拒绝授权;有的浏览器在请求返回之后已不算用户操作,也会拒绝写入。
notificationStore.info(`请手动复制分享链接:${url}`, 8000)
}
}
async function share(playlist: MyPlaylist, copied: string): Promise<void> {
const shareId = await playlistsStore.share(playlist)
if (shareId)
await copyShareLink(shareId, copied)
}
export function PlaylistMenu({ playlist, align = 'end', className = '', shareable = false, onDeleted, onExport }: {
playlist: MyPlaylist
align?: 'start' | 'end'
className?: string
shareable?: boolean
onDeleted?: () => void
onExport?: () => void
}) {
const [dialog, setDialog] = useState<'rename' | 'delete' | null>(null)
const [dialog, setDialog] = useState<'rename' | 'delete' | 'reshare' | 'unshare' | null>(null)
const actions: MenuAction[] = []
if (!playlist.favorites)
actions.push({ icon: Pencil, label: '重命名', run: () => setDialog('rename') })
if (shareable && playlist.shareId) {
const { shareId } = playlist
actions.push(
{ icon: Copy, label: '复制分享链接', run: () => void copyShareLink(shareId, '已复制分享链接') },
{ icon: RefreshCw, label: '重新生成分享链接', run: () => setDialog('reshare') },
{ icon: Link2Off, label: '停止分享', run: () => setDialog('unshare') },
)
}
else if (shareable) {
actions.push({ icon: Share2, label: '分享', run: () => void share(playlist, '已开启分享,链接已复制') })
}
if (onExport)
actions.push({ icon: FileDown, label: '导出', run: onExport })
if (!playlist.favorites)
@@ -50,6 +80,32 @@ export function PlaylistMenu({ playlist, align = 'end', className = '', onDelete
{`删除“${playlist.name}”之后,里面的歌也一起删除。`}
</ConfirmDialog>
)}
{dialog === 'reshare' && (
<ConfirmDialog
title="重新生成分享链接"
confirmLabel="重新生成"
closeOnConfirm
onConfirm={() => share(playlist, '已生成新链接并复制')}
onClose={() => setDialog(null)}
>
生成新链接之后,之前分享出去的链接就打不开了。
</ConfirmDialog>
)}
{dialog === 'unshare' && (
<ConfirmDialog
title="停止分享"
confirmLabel="停止分享"
danger
closeOnConfirm
onConfirm={async () => {
if (await playlistsStore.unshare(playlist))
notificationStore.success('已停止分享')
}}
onClose={() => setDialog(null)}
>
停止之后,分享出去的链接就打不开了。
</ConfirmDialog>
)}
</>
)
}
@@ -1,4 +1,4 @@
import type { Track } from '@/core/realtime'
import type { MyPlaylist, Track } from '@/core/realtime'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
class FakeStorage {
@@ -35,6 +35,7 @@ const full = '一个歌单最多 1000 首歌'
const changed = '歌单已经变了,刷新之后再试'
const imported = { id: 'p3', name: '导入的歌单', favorites: false, trackCount: 2, createdAtMs: 2, updatedAtMs: 2, coverUrl: null, shareId: null }
const fileTrack = { track: sunny, title: '晴天', durationMs: 269000 }
const copied = { id: 'p4', name: '抄来的', favorites: false, trackCount: 2, createdAtMs: 3, updatedAtMs: 3, coverUrl: null, shareId: null }
let requests: string[]
let release: (() => void)[]
@@ -43,10 +44,12 @@ let failAdd: boolean
let failCopy: boolean
let failMove: boolean
let importFailure: { status: number, code: string, message: string } | null
let shareFailure: { status: number, code: string, message: string } | null
let shares: number
let bodies: unknown[]
let holdReads: boolean
let failReads: boolean
let serverPlaylists: typeof favorites[]
let serverPlaylists: MyPlaylist[]
let server: Map<string, Map<string, Track>>
function json(status: number, body: unknown): Response {
@@ -84,6 +87,8 @@ beforeEach(() => {
failCopy = false
failMove = false
importFailure = null
shareFailure = null
shares = 0
bodies = []
holdReads = false
failReads = false
@@ -153,6 +158,18 @@ beforeEach(() => {
return json(409, { error: { code: 'CONFLICT', message: changed } })
return new Response(null, { status: 204 })
}
if (shareFailure && /^(?:POST|DELETE) \/api\/(?:me\/playlists\/\w+\/share|shared-playlists\/[\w-]+\/copies)$/.test(request))
return json(shareFailure.status, { error: { code: shareFailure.code, message: shareFailure.message } })
const share = /^(POST|DELETE) \/api\/me\/playlists\/(\w+)\/share$/.exec(request)
if (share) {
const shareId = share[1] === 'POST' ? `share-${++shares}` : null
serverPlaylists = serverPlaylists.map(p => p.id === share[2] ? { ...p, shareId } : p)
return shareId ? json(200, { shareId }) : new Response(null, { status: 204 })
}
if (request === 'POST /api/shared-playlists/share-1/copies') {
serverPlaylists.push(copied)
return json(201, copied)
}
throw new Error(`没有预期的请求 ${request}`)
})
})
@@ -629,4 +646,91 @@ describe('playlistsStore', () => {
expect([await first, await second]).toEqual([null, null])
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['歌单名要 1 到 50 个字。', '导入太频繁了,稍后再试'])
})
it('share 返回新的分享 ID,列表里立刻换上它,再重新读取', async () => {
serverPlaylists.push(commute)
const { playlistsStore } = await import('./playlists-store')
await playlistsStore.load()
requests = []
holdReads = true
const first = playlistsStore.share(commute)
await flush()
release.shift()!()
expect(await first).toBe('share-1')
expect(playlistsStore.state.playlists).toEqual([favorites, { ...commute, shareId: 'share-1' }])
const second = playlistsStore.share({ ...commute, shareId: 'share-1' })
await flush()
release.splice(1, 1)[0]()
expect(await second).toBe('share-2')
expect(playlistsStore.state.playlists).toEqual([favorites, { ...commute, shareId: 'share-2' }])
holdReads = false
await settle()
expect(requests).toEqual(['POST /api/me/playlists/p2/share', 'GET /api/me/playlists', 'POST /api/me/playlists/p2/share', 'GET /api/me/playlists'])
expect(playlistsStore.state.playlists).toEqual([favorites, { ...commute, shareId: 'share-2' }])
})
it('unshare 之后列表里立刻去掉分享 ID,再重新读取', async () => {
serverPlaylists.push({ ...commute, shareId: 'share-0' })
const { playlistsStore } = await import('./playlists-store')
await playlistsStore.load()
requests = []
holdReads = true
const done = playlistsStore.unshare({ ...commute, shareId: 'share-0' })
await flush()
release.shift()!()
expect(await done).toBe(true)
expect(playlistsStore.state.playlists).toEqual([favorites, commute])
holdReads = false
await settle()
expect(requests).toEqual(['DELETE /api/me/playlists/p2/share', 'GET /api/me/playlists'])
expect(playlistsStore.state.playlists).toEqual([favorites, commute])
})
it('share 和 unshare 失败时提示服务端的文案,列表不变,不重新读取', async () => {
serverPlaylists.push(commute)
const { playlistsStore } = await import('./playlists-store')
const { notificationStore } = await import('@/shared/ui')
await playlistsStore.load()
requests = []
shareFailure = { status: 404, code: 'NOT_FOUND', message: '歌单不存在' }
const shared = playlistsStore.share(commute)
await settle()
const unshared = playlistsStore.unshare(commute)
await settle()
expect([await shared, await unshared]).toEqual([null, false])
expect(requests).toEqual(['POST /api/me/playlists/p2/share', 'DELETE /api/me/playlists/p2/share'])
expect(playlistsStore.state.playlists).toEqual([favorites, commute])
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['歌单不存在', '歌单不存在'])
})
it('copyShared 成功后新歌单立刻出现在列表里,再重新读取', async () => {
const { playlistsStore } = await import('./playlists-store')
await playlistsStore.load()
requests = []
holdReads = true
const done = playlistsStore.copyShared('share-1', '抄来的')
await flush()
release.shift()!()
expect(await done).toEqual(copied)
expect(playlistsStore.state.playlists).toEqual([favorites, copied])
expect(bodies).toEqual([{ name: '抄来的' }])
holdReads = false
await settle()
expect(requests).toEqual(['POST /api/shared-playlists/share-1/copies', 'GET /api/me/playlists'])
expect(playlistsStore.state.playlists).toEqual([favorites, copied])
})
it('copyShared 歌单名不合法时说明规则,其他错误提示服务端的文案', async () => {
const { playlistsStore } = await import('./playlists-store')
const { notificationStore } = await import('@/shared/ui')
shareFailure = { status: 400, code: 'INVALID_ARGUMENT', message: 'name:去掉首尾空白后应有 1 到 50 个字符' }
const first = playlistsStore.copyShared('share-1', ' ')
await settle()
shareFailure = { status: 404, code: 'NOT_FOUND', message: '分享已失效' }
const second = playlistsStore.copyShared('share-1', '抄来的')
await settle()
expect([await first, await second]).toEqual([null, null])
expect(requests).toEqual(['POST /api/shared-playlists/share-1/copies', 'POST /api/shared-playlists/share-1/copies'])
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['歌单名要 1 到 50 个字。', '分享已失效'])
})
})
@@ -210,6 +210,49 @@ class PlaylistsStore extends Store<PlaylistsState> {
}
}
// 开启分享和重新生成链接都用它,返回新的分享 ID;失败时返回 null。
async share(playlist: MyPlaylist): Promise<string | null> {
try {
const { shareId } = await api.shareMyPlaylist(playlist.id)
this.#changed(playlist.id, { shareId })
return shareId
}
catch (error) {
notificationStore.error(errorMessage(error))
return null
}
}
async unshare(playlist: MyPlaylist): Promise<boolean> {
try {
await api.unshareMyPlaylist(playlist.id)
this.#changed(playlist.id, { shareId: null })
return true
}
catch (error) {
notificationStore.error(errorMessage(error))
return false
}
}
async copyShared(shareId: string, name: string): Promise<MyPlaylist | null> {
try {
const playlist = await api.copySharedPlaylist(shareId, { name })
this.#added(playlist)
return playlist
}
catch (error) {
notificationStore.error(errorMessage(error, nameTexts))
return null
}
}
// 先改列表再重新读取:菜单要立刻显示新的分享状态,重新读取也让进行中的旧读取作废。
#changed(id: string, change: Partial<MyPlaylist>): void {
this.setState({ playlists: this.state.playlists?.map(p => p.id === id ? { ...p, ...change } : p) ?? null })
void this.load()
}
// 先放进列表再重新读取:打开新歌单时,列表里要已经有它,否则详情页会以为它被删掉了。
#added(playlist: MyPlaylist): void {
this.setState({ playlists: [...(this.state.playlists ?? []), playlist] })