diff --git a/web/src/features/playlists/my-playlist-page.tsx b/web/src/features/playlists/my-playlist-page.tsx index cee103d..d683ca0 100644 --- a/web/src/features/playlists/my-playlist-page.tsx +++ b/web/src/features/playlists/my-playlist-page.tsx @@ -242,7 +242,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void {selecting ? '完成' : '选择'} )} - {playlist && void exportToFile(playlist)} />} + {playlist && void exportToFile(playlist)} />} diff --git a/web/src/features/playlists/playlist-menu.tsx b/web/src/features/playlists/playlist-menu.tsx index 36c38bd..19dd867 100644 --- a/web/src/features/playlists/playlist-menu.tsx +++ b/web/src/features/playlists/playlist-menu.tsx @@ -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 { + 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 { + 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}”之后,里面的歌也一起删除。`} )} + {dialog === 'reshare' && ( + share(playlist, '已生成新链接并复制')} + onClose={() => setDialog(null)} + > + 生成新链接之后,之前分享出去的链接就打不开了。 + + )} + {dialog === 'unshare' && ( + { + if (await playlistsStore.unshare(playlist)) + notificationStore.success('已停止分享') + }} + onClose={() => setDialog(null)} + > + 停止之后,分享出去的链接就打不开了。 + + )} ) } diff --git a/web/src/features/playlists/playlists-store.test.ts b/web/src/features/playlists/playlists-store.test.ts index 4f6e104..9479683 100644 --- a/web/src/features/playlists/playlists-store.test.ts +++ b/web/src/features/playlists/playlists-store.test.ts @@ -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> 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 个字。', '分享已失效']) + }) }) diff --git a/web/src/features/playlists/playlists-store.ts b/web/src/features/playlists/playlists-store.ts index 7dacc57..d745f96 100644 --- a/web/src/features/playlists/playlists-store.ts +++ b/web/src/features/playlists/playlists-store.ts @@ -210,6 +210,49 @@ class PlaylistsStore extends Store { } } + // 开启分享和重新生成链接都用它,返回新的分享 ID;失败时返回 null。 + async share(playlist: MyPlaylist): Promise { + 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 { + 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 { + 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): 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] })