feat: 新增歌单导入功能

添加歌单导入功能,支持通过链接、文本和元数据三种方式导入歌单
- 实现链接导入、文本导入和元数据导入三种方式
- 添加导入状态检查和显示功能
This commit is contained in:
alger
2025-06-04 22:53:49 +08:00
parent 8988cdb082
commit edd393c8ac
9 changed files with 803 additions and 4 deletions

View File

@@ -27,6 +27,7 @@ export default {
refresh: 'Refresh',
retry: 'Retry',
reset: 'Reset',
back: 'Back',
copySuccess: 'Copied to clipboard',
copyFailed: 'Copy failed',
validation: {

View File

@@ -120,5 +120,65 @@ export default {
addToPlaylistSuccess: 'Add to Playlist Success',
operationFailed: 'Operation Failed',
songsAlreadyInPlaylist: 'Songs already in playlist'
},
playlist: {
import: {
button: 'Import Playlist',
title: 'Import Playlist',
description: 'Import playlists via metadata, text, or links',
linkTab: 'Import by Link',
textTab: 'Import by Text',
localTab: 'Import by Metadata',
linkPlaceholder: 'Enter playlist links, one per line',
textPlaceholder: 'Enter song information in format: Song Name Artist Name',
localPlaceholder: 'Enter song metadata in JSON format',
linkTips: 'Supported link sources:',
linkTip1: 'Copy links after sharing playlists to WeChat/Weibo/QQ',
linkTip2: 'Directly copy playlist/profile links',
linkTip3: 'Directly copy article links',
textTips: 'Enter song information, one song per line',
textFormat: 'Format: Song Name Artist Name',
localTips: 'Add song metadata',
localFormat: 'Format example:',
songNamePlaceholder: 'Song Name',
artistNamePlaceholder: 'Artist Name',
albumNamePlaceholder: 'Album Name',
addSongButton: 'Add Song',
addLinkButton: 'Add Link',
importToStarPlaylist: 'Import to My Favorite Music',
playlistNamePlaceholder: 'Enter playlist name',
importButton: 'Start Import',
emptyLinkWarning: 'Please enter playlist links',
emptyTextWarning: 'Please enter song information',
emptyLocalWarning: 'Please enter song metadata',
invalidJsonFormat: 'Invalid JSON format',
importSuccess: 'Import task created successfully',
importFailed: 'Import failed',
importStatus: 'Import Status',
refresh: 'Refresh',
taskId: 'Task ID',
status: 'Status',
successCount: 'Success Count',
failReason: 'Failure Reason',
unknownError: 'Unknown error',
statusPending: 'Pending',
statusProcessing: 'Processing',
statusSuccess: 'Success',
statusFailed: 'Failed',
statusUnknown: 'Unknown',
taskList: 'Task List',
taskListTitle: 'Import Task List',
action: 'Action',
select: 'Select',
fetchTaskListFailed: 'Failed to fetch task list',
noTasks: 'No import tasks',
clearTasks: 'Clear Tasks',
clearTasksConfirmTitle: 'Confirm Clear',
clearTasksConfirmContent: 'Are you sure you want to clear all import task records? This action cannot be undone.',
confirm: 'Confirm',
cancel: 'Cancel',
clearTasksSuccess: 'Task list cleared',
clearTasksFailed: 'Failed to clear task list'
}
}
};

View File

@@ -27,6 +27,7 @@ export default {
refresh: '刷新',
retry: '重试',
reset: '重置',
back: '返回',
copySuccess: '已复制到剪贴板',
copyFailed: '复制失败',
validation: {

View File

@@ -118,5 +118,65 @@ export default {
addToPlaylist: '添加到播放列表',
addToPlaylistSuccess: '添加到播放列表成功',
songsAlreadyInPlaylist: '歌曲已存在于播放列表中'
},
playlist: {
import: {
button: '歌单导入',
title: '歌单导入',
description: '支持通过元数据/文字/链接三种方式导入歌单',
linkTab: '链接导入',
textTab: '文字导入',
localTab: '元数据导入',
linkPlaceholder: '请输入歌单链接,每行一个',
textPlaceholder: '请输入歌曲信息,格式为:歌曲名 歌手名',
localPlaceholder: '请输入JSON格式的歌曲元数据',
linkTips: '支持的链接来源:',
linkTip1: '将歌单分享到微信/微博/QQ后复制链接',
linkTip2: '直接复制歌单/个人主页链接',
linkTip3: '直接复制文章链接',
textTips: '请输入歌曲信息,每行一首歌',
textFormat: '格式:歌曲名 歌手名',
localTips: '请添加歌曲元数据',
localFormat: '格式示例:',
songNamePlaceholder: '歌曲名称',
artistNamePlaceholder: '艺术家名称',
albumNamePlaceholder: '专辑名称',
addSongButton: '添加歌曲',
addLinkButton: '添加链接',
importToStarPlaylist: '导入到我喜欢的音乐',
playlistNamePlaceholder: '请输入歌单名称',
importButton: '开始导入',
emptyLinkWarning: '请输入歌单链接',
emptyTextWarning: '请输入歌曲信息',
emptyLocalWarning: '请输入歌曲元数据',
invalidJsonFormat: 'JSON格式不正确',
importSuccess: '导入任务创建成功',
importFailed: '导入失败',
importStatus: '导入状态',
refresh: '刷新',
taskId: '任务ID',
status: '状态',
successCount: '成功数量',
failReason: '失败原因',
unknownError: '未知错误',
statusPending: '等待处理',
statusProcessing: '处理中',
statusSuccess: '导入成功',
statusFailed: '导入失败',
statusUnknown: '未知状态',
taskList: '任务列表',
taskListTitle: '导入任务列表',
action: '操作',
select: '选择',
fetchTaskListFailed: '获取任务列表失败',
noTasks: '暂无导入任务',
clearTasks: '清除任务',
clearTasksConfirmTitle: '确认清除',
clearTasksConfirmContent: '确定要清除所有导入任务记录吗?此操作不可恢复。',
confirm: '确认',
cancel: '取消',
clearTasksSuccess: '任务列表已清除',
clearTasksFailed: '清除任务列表失败'
}
}
};

View File

@@ -0,0 +1,27 @@
import request from '@/utils/request';
/**
* 歌单导入 - 元数据/文字/链接导入
* @param params 导入参数
*/
export function importPlaylist(params: {
local?: string;
text?: string;
link?: string;
importStarPlaylist?: boolean;
playlistName?: string;
}) {
return request.post('/playlist/import/name/task/create', params);
}
/**
* 歌单导入 - 任务状态
* @param id 任务ID
*/
export function getImportTaskStatus(id: string | number) {
return request({
url: '/playlist/import/task/status',
method: 'get',
params: { id }
});
}

View File

@@ -13,6 +13,7 @@ declare module 'vue' {
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCard: typeof import('naive-ui')['NCard']
NCarousel: typeof import('naive-ui')['NCarousel']
NCarouselItem: typeof import('naive-ui')['NCarouselItem']
NCheckbox: typeof import('naive-ui')['NCheckbox']

View File

@@ -76,6 +76,16 @@ const otherRouter = [
back: true
},
component: () => import('@/views/music/MusicListPage.vue')
}
},
{
path: '/playlist/import',
name: 'playlistImport',
meta: {
title: '歌单导入',
keepAlive: true,
back: true
},
component: () => import('@/views/playlist/ImportPlaylist.vue')
},
];
export default otherRouter;

View File

@@ -0,0 +1,627 @@
<template>
<div class="import-playlist-page">
<div class="import-header" :class="setAnimationClass('animate__fadeInLeft')">
<div class="import-header-left">
<h2>{{ t('comp.playlist.import.title') }}</h2>
<div class="import-desc">{{ t('comp.playlist.import.description') }}</div>
</div>
</div>
<div class="import-content" :class="setAnimationClass('animate__fadeInUp')">
<n-card class="import-card">
<n-tabs type="line" animated>
<!-- 链接导入 -->
<n-tab-pane name="link" :tab="t('comp.playlist.import.linkTab')">
<div class="tab-content">
<div class="link-inputs">
<div v-for="(link, index) in linkInputs" :key="index" class="link-row">
<n-input
v-model:value="link.value"
:placeholder="t('comp.playlist.import.linkPlaceholder')"
class="link-input"
/>
<n-button
quaternary
circle
type="error"
@click="removeLinkRow(index)"
v-if="linkInputs.length > 1"
>
<template #icon>
<i class="iconfont ri-delete-bin-line"></i>
</template>
</n-button>
</div>
<div class="link-actions">
<n-button @click="addLinkRow" secondary size="small">
<template #icon>
<i class="iconfont ri-add-line"></i>
</template>
{{ t('comp.playlist.import.addLinkButton') }}
</n-button>
</div>
</div>
<div class="link-tips">
<p>{{ t('comp.playlist.import.linkTips') }}</p>
<ul>
<li>{{ t('comp.playlist.import.linkTip1') }}</li>
<li>{{ t('comp.playlist.import.linkTip2') }}</li>
<li>{{ t('comp.playlist.import.linkTip3') }}</li>
</ul>
</div>
<div class="action-buttons">
<n-checkbox v-model:checked="importToStarPlaylist">
{{ t('comp.playlist.import.importToStarPlaylist') }}
</n-checkbox>
<n-input
v-if="!importToStarPlaylist"
v-model:value="playlistName"
:placeholder="t('comp.playlist.import.playlistNamePlaceholder')"
class="playlist-name-input"
/>
<n-button
type="primary"
:loading="importing"
:disabled="!isLinkInputValid"
@click="handleImportByLink"
>
{{ t('comp.playlist.import.importButton') }}
</n-button>
</div>
</div>
</n-tab-pane>
<!-- 文字导入 -->
<n-tab-pane name="text" :tab="t('comp.playlist.import.textTab')">
<div class="tab-content">
<n-input
v-model:value="textInput"
type="textarea"
:placeholder="t('comp.playlist.import.textPlaceholder')"
:rows="6"
/>
<div class="text-tips">
<p>{{ t('comp.playlist.import.textTips') }}</p>
<p class="text-format">{{ t('comp.playlist.import.textFormat') }}</p>
</div>
<div class="action-buttons">
<n-checkbox v-model:checked="importToStarPlaylist">
{{ t('comp.playlist.import.importToStarPlaylist') }}
</n-checkbox>
<n-input
v-if="!importToStarPlaylist"
v-model:value="playlistName"
:placeholder="t('comp.playlist.import.playlistNamePlaceholder')"
class="playlist-name-input"
/>
<n-button
type="primary"
:loading="importing"
:disabled="!textInput.trim()"
@click="handleImportByText"
>
{{ t('comp.playlist.import.importButton') }}
</n-button>
</div>
</div>
</n-tab-pane>
<!-- 元数据导入 -->
<n-tab-pane name="local" :tab="t('comp.playlist.import.localTab')">
<div class="tab-content">
<div class="metadata-inputs">
<div v-for="(item, index) in localMetadata" :key="index" class="metadata-row">
<n-input
v-model:value="item.name"
:placeholder="t('comp.playlist.import.songNamePlaceholder')"
class="metadata-input"
/>
<n-input
v-model:value="item.artist"
:placeholder="t('comp.playlist.import.artistNamePlaceholder')"
class="metadata-input"
/>
<n-input
v-model:value="item.album"
:placeholder="t('comp.playlist.import.albumNamePlaceholder')"
class="metadata-input"
/>
<n-button
quaternary
circle
type="error"
@click="removeMetadataRow(index)"
v-if="localMetadata.length > 1"
>
<template #icon>
<i class="iconfont ri-delete-bin-line"></i>
</template>
</n-button>
</div>
<div class="metadata-actions">
<n-button @click="addMetadataRow" secondary size="small">
<template #icon>
<i class="iconfont ri-add-line"></i>
</template>
{{ t('comp.playlist.import.addSongButton') }}
</n-button>
</div>
</div>
<div class="local-tips">
<p>{{ t('comp.playlist.import.localTips') }}</p>
</div>
<div class="action-buttons">
<n-checkbox v-model:checked="importToStarPlaylist">
{{ t('comp.playlist.import.importToStarPlaylist') }}
</n-checkbox>
<n-input
v-if="!importToStarPlaylist"
v-model:value="playlistName"
:placeholder="t('comp.playlist.import.playlistNamePlaceholder')"
class="playlist-name-input"
/>
<n-button
type="primary"
:loading="importing"
:disabled="!isLocalMetadataValid"
@click="handleImportByLocal"
>
{{ t('comp.playlist.import.importButton') }}
</n-button>
</div>
</div>
</n-tab-pane>
</n-tabs>
</n-card>
<!-- 导入状态 -->
<n-card v-if="taskId" class="import-status-card">
<div class="status-header">
<h3>{{ t('comp.playlist.import.importStatus') }}</h3>
<n-button text @click="refreshStatus">
<template #icon>
<i class="iconfont ri-refresh-line"></i>
</template>
{{ t('comp.playlist.import.refresh') }}
</n-button>
</div>
<n-spin :show="checkingStatus">
<div class="status-content">
<div class="status-item">
<span class="status-label">{{ t('comp.playlist.import.taskId') }}:</span>
<span class="status-value">{{ taskId }}</span>
</div>
<div class="status-item">
<span class="status-label">{{ t('comp.playlist.import.status') }}:</span>
<span class="status-value" :class="`status-${taskStatus}`">
{{ getStatusText(taskStatus) }}
</span>
</div>
<div v-if="taskStatus === 'success'" class="status-item">
<span class="status-label">{{ t('comp.playlist.import.successCount') }}:</span>
<span class="status-value success-count">{{ successCount }}</span>
</div>
<div v-if="taskStatus === 'failed'" class="status-item">
<span class="status-label">{{ t('comp.playlist.import.failReason') }}:</span>
<span class="status-value fail-reason">{{ failReason }}</span>
</div>
</div>
</n-spin>
</n-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessage } from 'naive-ui';
import { importPlaylist, getImportTaskStatus } from '@/api/playlist';
import { setAnimationClass } from '@/utils';
const { t } = useI18n();
const message = useMessage();
// 表单数据
const linkInputs = ref([{ value: '' }]);
const textInput = ref('');
const localMetadata = ref([{ name: '', artist: '', album: '' }]);
const playlistName = ref('');
const importToStarPlaylist = ref(false);
// 链接相关函数
const addLinkRow = () => {
linkInputs.value.push({ value: '' });
};
const removeLinkRow = (index: number) => {
linkInputs.value.splice(index, 1);
};
// 验证链接是否有效
const isLinkInputValid = computed(() => {
return linkInputs.value.some(item => item.value.trim() !== '');
});
// 元数据相关函数
const addMetadataRow = () => {
localMetadata.value.push({ name: '', artist: '', album: '' });
};
const removeMetadataRow = (index: number) => {
localMetadata.value.splice(index, 1);
};
// 验证元数据是否有效
const isLocalMetadataValid = computed(() => {
return localMetadata.value.some(item => item.name.trim() !== '');
});
// 导入状态
const importing = ref(false);
const taskId = ref('');
const taskStatus = ref('');
const successCount = ref(0);
const failReason = ref('');
const checkingStatus = ref(false);
const statusCheckInterval = ref<number | null>(null);
// 处理链接导入
const handleImportByLink = async () => {
if (!isLinkInputValid.value) {
message.warning(t('comp.playlist.import.emptyLinkWarning'));
return;
}
try {
importing.value = true;
// 处理链接格式
const links = linkInputs.value
.filter(link => link.value.trim())
.map(link => link.value.trim());
const encodedLinks = JSON.stringify(links);
const params: any = {
link: encodedLinks
};
if (importToStarPlaylist.value) {
params.importStarPlaylist = true;
} else if (playlistName.value) {
params.playlistName = playlistName.value;
}
const res = await importPlaylist(params);
if (res.data.code === 200) {
message.success(t('comp.playlist.import.importSuccess'));
taskId.value = res.data.data.taskId;
startStatusCheck();
} else {
message.error(res.data.message || t('comp.playlist.import.importFailed'));
}
} catch (error) {
console.error('导入歌单失败:', error);
message.error(t('comp.playlist.import.importFailed'));
} finally {
importing.value = false;
}
};
// 处理文字导入
const handleImportByText = async () => {
if (!textInput.value.trim()) {
message.warning(t('comp.playlist.import.emptyTextWarning'));
return;
}
try {
importing.value = true;
const encodedText = encodeURIComponent(textInput.value);
const params: any = {
text: encodedText
};
if (importToStarPlaylist.value) {
params.importStarPlaylist = true;
} else if (playlistName.value) {
params.playlistName = playlistName.value;
}
const res = await importPlaylist(params);
if (res.data.code === 200) {
message.success(t('comp.playlist.import.importSuccess'));
taskId.value = res.data.data.taskId;
startStatusCheck();
} else {
message.error(res.data.message || t('comp.playlist.import.importFailed'));
}
} catch (error) {
console.error('导入歌单失败:', error);
message.error(t('comp.playlist.import.importFailed'));
} finally {
importing.value = false;
}
};
// 处理元数据导入
const handleImportByLocal = async () => {
if (!isLocalMetadataValid.value) {
message.warning(t('comp.playlist.import.emptyLocalWarning'));
return;
}
try {
importing.value = true;
// 过滤掉空的行
const filteredData = localMetadata.value.filter(item => item.name.trim() !== '');
const encodedLocal = JSON.stringify(filteredData);
const params: any = {
local: encodedLocal
};
if (importToStarPlaylist.value) {
params.importStarPlaylist = true;
} else if (playlistName.value) {
params.playlistName = playlistName.value;
}
const res = await importPlaylist(params);
if (res.data.code === 200) {
message.success(t('comp.playlist.import.importSuccess'));
taskId.value = res.data.data.taskId;
startStatusCheck();
} else {
message.error(res.data.message || t('comp.playlist.import.importFailed'));
}
} catch (error) {
console.error('导入歌单失败:', error);
message.error(t('comp.playlist.import.importFailed'));
} finally {
importing.value = false;
}
};
// 开始检查任务状态
const startStatusCheck = () => {
// 清除之前的定时器
if (statusCheckInterval.value) {
clearInterval(statusCheckInterval.value);
}
// 立即检查一次
checkTaskStatus();
// 设置定时检查
statusCheckInterval.value = window.setInterval(() => {
checkTaskStatus();
}, 3000); // 每3秒检查一次
};
// 检查任务状态
const checkTaskStatus = async () => {
if (!taskId.value) return;
try {
checkingStatus.value = true;
const res = await getImportTaskStatus(taskId.value);
if (res.data.code === 200) {
// 新的API返回格式处理
if (res.data.data.tasks && res.data.data.tasks.length > 0) {
const taskData = res.data.data.tasks[0];
// 将API返回的status映射到组件内部使用的taskStatus
const statusMap: Record<string, string> = {
'PENDING': 'pending',
'PROCESSING': 'processing',
'COMPLETE': 'success',
'FAILED': 'failed'
};
taskStatus.value = statusMap[taskData.status] || 'pending';
if (taskStatus.value === 'success') {
successCount.value = taskData.succCount || 0;
// 成功后停止检查
if (statusCheckInterval.value) {
clearInterval(statusCheckInterval.value);
statusCheckInterval.value = null;
}
} else if (taskStatus.value === 'failed') {
failReason.value = taskData.msg || t('comp.playlist.import.unknownError');
// 失败后停止检查
if (statusCheckInterval.value) {
clearInterval(statusCheckInterval.value);
statusCheckInterval.value = null;
}
}
}
}
} catch (error) {
console.error('检查任务状态失败:', error);
} finally {
checkingStatus.value = false;
}
};
// 手动刷新状态
const refreshStatus = () => {
checkTaskStatus();
};
// 获取状态文本
const getStatusText = (status: string) => {
switch (status) {
case 'pending':
return t('comp.playlist.import.statusPending');
case 'processing':
return t('comp.playlist.import.statusProcessing');
case 'success':
return t('comp.playlist.import.statusSuccess');
case 'failed':
return t('comp.playlist.import.statusFailed');
default:
return t('comp.playlist.import.statusUnknown');
}
};
onMounted(() => {
// 如果有任务ID开始检查状态
if (taskId.value) {
startStatusCheck();
}
});
onUnmounted(() => {
// 清除定时器
if (statusCheckInterval.value) {
clearInterval(statusCheckInterval.value);
statusCheckInterval.value = null;
}
});
</script>
<style lang="scss" scoped>
.import-playlist-page {
@apply h-full overflow-auto pr-4;
}
.import-header {
@apply flex justify-between items-center mb-6;
.import-header-left {
h2 {
@apply text-2xl font-bold text-gray-900 dark:text-white mb-2;
}
.import-desc {
@apply text-sm text-gray-500 dark:text-gray-400;
}
}
}
.import-content {
@apply space-y-6;
}
.import-card {
@apply rounded-lg;
.tab-content {
@apply mt-4 space-y-4;
}
.link-tips, .text-tips, .local-tips {
@apply text-sm text-gray-500 dark:text-gray-400;
ul {
@apply list-disc pl-5 mt-2;
}
}
.text-format, .local-format {
@apply mt-2 font-medium;
}
.code-example {
@apply mt-2 p-3 bg-gray-100 dark:bg-gray-800 rounded text-sm overflow-auto;
}
.link-inputs {
@apply space-y-3;
.link-row {
@apply flex items-center space-x-2;
.link-input {
@apply flex-1;
}
}
.link-actions {
@apply mt-3 flex justify-end;
}
}
.metadata-inputs {
@apply space-y-3;
.metadata-row {
@apply flex items-center space-x-2;
.metadata-input {
@apply flex-1;
}
}
.metadata-actions {
@apply mt-3 flex justify-end;
}
}
.action-buttons {
@apply flex items-center space-x-4 mt-6;
.playlist-name-input {
@apply max-w-xs;
}
}
}
.import-status-card {
@apply rounded-lg;
.status-header {
@apply flex justify-between items-center mb-4;
h3 {
@apply text-lg font-medium text-gray-900 dark:text-white;
}
}
.status-content {
@apply space-y-3;
}
.status-item {
@apply flex items-center;
.status-label {
@apply text-gray-500 dark:text-gray-400 w-24;
}
.status-value {
@apply font-medium;
}
.status-pending, .status-processing {
@apply text-blue-500;
}
.status-success {
@apply text-green-500;
}
.status-failed {
@apply text-red-500;
}
.success-count {
@apply text-green-500;
}
.fail-reason {
@apply text-red-500;
}
}
}
</style>

View File

@@ -28,7 +28,10 @@
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
<div class="title">{{ t('user.playlist.created') }}</div>
<div class="title">
<div>{{ t('user.playlist.created') }}</div>
<div class="import-btn" @click="goToImportPlaylist" v-if="isElectron">{{ t('comp.playlist.import.button') }}</div>
</div>
<n-scrollbar>
<div
v-for="(item, index) in playList"
@@ -105,7 +108,7 @@ import { usePlayerStore } from '@/store/modules/player';
import { useUserStore } from '@/store/modules/user';
import type { Playlist } from '@/type/listDetail';
import type { IUserDetail } from '@/type/user';
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import { getImgUrl, isElectron, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({
name: 'User'
@@ -126,6 +129,10 @@ const message = useMessage();
const user = computed(() => userStore.user);
const goToImportPlaylist = () => {
router.push('/playlist/import');
};
onBeforeUnmount(() => {
mounted.value = false;
});
@@ -278,8 +285,13 @@ const showFollowList = () => {
@apply bg-black bg-opacity-40;
}
.title {
@apply text-lg font-bold;
@apply text-lg font-bold flex items-center justify-between;
@apply text-gray-900 dark:text-white;
.import-btn {
@apply bg-light-100 font-normal rounded-lg px-2 py-1 text-opacity-70 text-sm hover:bg-light-200 hover:text-green-500 dark:bg-dark-200 dark:hover:bg-dark-300 dark:hover:text-green-400;
@apply cursor-pointer;
@apply transition-all duration-200;
}
}
.user-name {