feat:优化音源配置

This commit is contained in:
alger
2025-12-20 02:30:09 +08:00
parent 0f42bfc6cb
commit 85302c611a
4 changed files with 547 additions and 507 deletions
+27
View File
@@ -119,6 +119,33 @@ export default {
imported: 'Custom Source Imported', imported: 'Custom Source Imported',
notImported: 'Not Imported' notImported: 'Not Imported'
} }
},
lxMusic: {
tabs: {
sources: 'Source Selection',
lxMusic: 'LX Music',
customApi: 'Custom API'
},
scripts: {
title: 'Imported Scripts',
importLocal: 'Import Local',
importOnline: 'Import Online',
urlPlaceholder: 'Enter LX Music Script URL',
importBtn: 'Import',
empty: 'No imported LX Music scripts',
notConfigured: 'Not configured (Configure in LX Music Tab)',
importHint: 'Import compatible custom API plugins to extend sources',
noScriptWarning: 'Please import LX Music script first',
noSelectionWarning: 'Please select an LX Music source first',
notFound: 'Source not found',
switched: 'Switched to source: {name}',
deleted: 'Deleted source: {name}',
enterUrl: 'Please enter script URL',
invalidUrl: 'Invalid URL format',
invalidScript: 'Invalid LX Music script, globalThis.lx code not found',
nameRequired: 'Name cannot be empty',
renameSuccess: 'Rename successful'
}
} }
}, },
application: { application: {
+27
View File
@@ -116,6 +116,33 @@ export default {
imported: '已导入自定义音源', imported: '已导入自定义音源',
notImported: '未导入' notImported: '未导入'
} }
},
lxMusic: {
tabs: {
sources: '音源选择',
lxMusic: '落雪音源',
customApi: '自定义API'
},
scripts: {
title: '已导入的音源脚本',
importLocal: '本地导入',
importOnline: '在线导入',
urlPlaceholder: '输入落雪音源脚本 URL',
importBtn: '导入',
empty: '暂无已导入的落雪音源',
notConfigured: '未配置 (请去落雪音源Tab配置)',
importHint: '导入兼容的自定义 API 插件以扩展音源',
noScriptWarning: '请先导入落雪音源脚本',
noSelectionWarning: '请先选择一个落雪音源',
notFound: '音源不存在',
switched: '已切换到音源: {name}',
deleted: '已删除音源: {name}',
enterUrl: '请输入脚本 URL',
invalidUrl: '无效的 URL 格式',
invalidScript: '无效的落雪音源脚本,未找到 globalThis.lx 相关代码',
nameRequired: '名称不能为空',
renameSuccess: '重命名成功'
}
} }
}, },
application: { application: {
@@ -0,0 +1,147 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="show"
class="fixed inset-0 z-[1000] flex items-center justify-center md:items-center items-end"
@click="handleMaskClick"
>
<!-- Overlay -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity"></div>
<!-- Content -->
<Transition :name="isMobile ? 'slide-up' : 'scale-fade'">
<div
v-if="show"
class="relative z-10 w-full bg-white dark:bg-[#1c1c1e] shadow-2xl overflow-hidden flex flex-col max-h-[85vh]"
:class="[
isMobile
? 'rounded-t-[20px] pb-safe'
: 'md:max-w-[720px] md:rounded-2xl'
]"
@click.stop
>
<!-- Header -->
<div
class="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-white/5 shrink-0"
>
<h3 class="text-[15px] font-semibold text-gray-900 dark:text-white truncate">
{{ title }}
</h3>
<button
class="p-1 -mr-1 rounded-full text-gray-400 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
@click="close"
>
<i class="ri-close-line text-lg"></i>
</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto overscroll-contain px-4 py-3">
<slot></slot>
</div>
<!-- Footer -->
<div
v-if="$slots.footer"
class="px-4 py-3 border-t border-gray-100 dark:border-white/5 shrink-0 bg-gray-50/50 dark:bg-white/5 backdrop-blur-xl"
>
<slot name="footer"></slot>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps<{
modelValue: boolean;
title?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'close'): void;
}>();
const show = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const isMobile = ref(false);
const checkMobile = () => {
isMobile.value = window.innerWidth < 768;
};
const close = () => {
show.value = false;
emit('close');
};
const handleMaskClick = () => {
close();
};
onMounted(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
});
onUnmounted(() => {
window.removeEventListener('resize', checkMobile);
});
// Prevent body scroll when modal is open
watch(show, (val) => {
if (val) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
});
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* PC Scale Fade Transition */
.scale-fade-enter-active,
.scale-fade-leave-active {
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.scale-fade-enter-from,
.scale-fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
/* Mobile Slide Up Transition */
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
</style>
@@ -1,253 +1,317 @@
<template> <template>
<n-modal <ResponsiveModal
v-model:show="visible" v-model="visible"
preset="dialog"
:title="t('settings.playback.musicSources')" :title="t('settings.playback.musicSources')"
:positive-text="t('common.confirm')" @close="handleCancel"
:negative-text="t('common.cancel')"
class="music-source-modal"
@positive-click="handleConfirm"
@negative-click="handleCancel"
style="width: 800px; max-width: 90vw"
> >
<div class="h-[400px]"> <div class="flex flex-col h-full">
<n-tabs type="segment" animated class="h-full flex flex-col"> <!-- Tabs Header -->
<!-- Tab 1: 音源选择 --> <div class="flex p-0.5 mb-3 bg-gray-100 dark:bg-white/5 rounded-lg shrink-0">
<n-tab-pane name="sources" tab="音源选择" class="h-full overflow-y-auto"> <button
<n-space vertical :size="20" class="pt-4 pr-2"> v-for="tab in tabs"
<p class="text-sm text-gray-600 dark:text-gray-400"> :key="tab.key"
class="flex-1 py-1 text-xs font-medium rounded-md transition-all duration-200"
:class="[
activeTab === tab.key
? 'bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
]"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- Tab Content -->
<div class="h-[400px] relative shrink-0">
<Transition name="fade" mode="out-in">
<div :key="activeTab" class="h-full overflow-y-auto overscroll-contain">
<!-- Sources Tab -->
<div v-if="activeTab === 'sources'" class="space-y-3 pb-2">
<p class="text-xs text-gray-500 dark:text-gray-400 px-1">
{{ t('settings.playback.musicSourcesDesc') }} {{ t('settings.playback.musicSourcesDesc') }}
</p> </p>
<!-- 音源卡片列表 --> <div class="grid grid-cols-2 md:grid-cols-3 gap-2">
<div class="music-sources-grid"> <!-- Standard Sources -->
<div <div
v-for="source in MUSIC_SOURCES" v-for="source in MUSIC_SOURCES"
:key="source.key" :key="source.key"
class="source-card" class="group relative flex items-center p-2.5 rounded-xl border transition-all duration-200 cursor-pointer"
:class="{ :class="[
'source-card--selected': isSourceSelected(source.key), isSourceSelected(source.key)
'source-card--disabled': source.disabled && !isSourceSelected(source.key) ? 'bg-emerald-50/50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
}" : 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10'
:style="{ '--source-color': source.color }" ]"
@click="toggleSource(source.key)" @click="toggleSource(source.key)"
> >
<div class="source-card__indicator"></div> <div
<div class="source-card__content"> class="flex items-center justify-center w-8 h-8 rounded-full mr-2.5 transition-colors shrink-0"
<div class="source-card__header"> :style="{
<span class="source-card__name">{{ source.key }}</span> backgroundColor: isSourceSelected(source.key) ? source.color : 'transparent',
<n-icon color: isSourceSelected(source.key) ? '#fff' : source.color
v-if="isSourceSelected(source.key)" }"
size="18" :class="{ 'bg-gray-100 dark:bg-white/10': !isSourceSelected(source.key) }"
class="source-card__check"
> >
<i class="ri-checkbox-circle-fill"></i> <i class="ri-music-2-fill text-base"></i>
</n-icon> </div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-900 dark:text-white text-sm truncate">{{ source.key }}</span>
<div
class="w-4 h-4 rounded-full border flex items-center justify-center transition-colors shrink-0 ml-1"
:class="[
isSourceSelected(source.key)
? 'bg-emerald-500 border-emerald-500'
: 'border-gray-300 dark:border-gray-600'
]"
>
<i v-if="isSourceSelected(source.key)" class="ri-check-line text-white text-xs scale-75"></i>
</div>
</div> </div>
<p v-if="source.description" class="source-card__description">
{{ source.description }}
</p>
</div> </div>
</div> </div>
<!-- 落雪音源卡片 (仅开关) --> <!-- LX Music Source -->
<div <div
class="source-card source-card--lxmusic" class="group relative flex items-center p-2.5 rounded-xl border transition-all duration-200 cursor-pointer"
:class="{ :class="[
'source-card--selected': isSourceSelected('lxMusic'), isSourceSelected('lxMusic')
'source-card--disabled': !activeLxApiId || lxMusicApis.length === 0 ? 'bg-emerald-50/50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
}" : 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10',
style="--source-color: #10b981" { 'opacity-60 cursor-not-allowed': !activeLxApiId || lxMusicApis.length === 0 }
]"
@click="toggleSource('lxMusic')" @click="toggleSource('lxMusic')"
> >
<div class="source-card__indicator"></div> <div
<div class="source-card__content"> class="flex items-center justify-center w-8 h-8 rounded-full mr-2.5 transition-colors shrink-0"
<div class="source-card__header"> :class="[
<span class="source-card__name">落雪音源</span> isSourceSelected('lxMusic')
<n-icon v-if="isSourceSelected('lxMusic')" size="18" class="source-card__check"> ? 'bg-emerald-500 text-white'
<i class="ri-checkbox-circle-fill"></i> : 'bg-gray-100 dark:bg-white/10 text-emerald-500'
</n-icon> ]"
>
<i class="ri-netease-cloud-music-fill text-base"></i>
</div> </div>
<p class="source-card__description">
{{ <div class="flex-1 min-w-0">
activeLxApiId && lxMusicScriptInfo <div class="flex items-center justify-between">
? lxMusicScriptInfo.name <span class="font-semibold text-gray-900 dark:text-white text-sm truncate">落雪音源</span>
: '未配置 (请去落雪音源Tab配置)' <div
}} class="w-4 h-4 rounded-full border flex items-center justify-center transition-colors shrink-0 ml-1"
:class="[
isSourceSelected('lxMusic')
? 'bg-emerald-500 border-emerald-500'
: 'border-gray-300 dark:border-gray-600'
]"
>
<i v-if="isSourceSelected('lxMusic')" class="ri-check-line text-white text-xs scale-75"></i>
</div>
</div>
<p class="text-[10px] text-gray-500 mt-0.5 truncate">
{{ activeLxApiId && lxMusicScriptInfo ? lxMusicScriptInfo.name : t('settings.playback.lxMusic.scripts.notConfigured') }}
</p> </p>
</div> </div>
</div> </div>
<!-- 自定义API卡片 (仅开关) --> <!-- Custom API Source -->
<div <div
class="source-card source-card--custom" class="group relative flex items-center p-2.5 rounded-xl border transition-all duration-200 cursor-pointer"
:class="{ :class="[
'source-card--selected': isSourceSelected('custom'), isSourceSelected('custom')
'source-card--disabled': !settingsStore.setData.customApiPlugin ? 'bg-emerald-50/50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
}" : 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5 hover:bg-gray-50 dark:hover:bg-white/10',
style="--source-color: #8b5cf6" { 'opacity-60 cursor-not-allowed': !settingsStore.setData.customApiPlugin }
]"
@click="toggleSource('custom')" @click="toggleSource('custom')"
> >
<div class="source-card__indicator"></div> <div
<div class="source-card__content"> class="flex items-center justify-center w-8 h-8 rounded-full mr-2.5 transition-colors shrink-0"
<div class="source-card__header"> :class="[
<span class="source-card__name">{{ isSourceSelected('custom')
t('settings.playback.sourceLabels.custom') ? 'bg-violet-500 text-white'
}}</span> : 'bg-gray-100 dark:bg-white/10 text-violet-500'
<n-icon v-if="isSourceSelected('custom')" size="18" class="source-card__check"> ]"
<i class="ri-checkbox-circle-fill"></i> >
</n-icon> <i class="ri-plug-fill text-base"></i>
</div> </div>
<p class="source-card__description">
{{ <div class="flex-1 min-w-0">
settingsStore.setData.customApiPlugin <div class="flex items-center justify-between">
? t('settings.playback.customApi.status.imported') <span class="font-semibold text-gray-900 dark:text-white text-sm truncate">{{ t('settings.playback.sourceLabels.custom') }}</span>
: t('settings.playback.customApi.status.notImported') <div
}} class="w-4 h-4 rounded-full border flex items-center justify-center transition-colors shrink-0 ml-1"
:class="[
isSourceSelected('custom')
? 'bg-emerald-500 border-emerald-500'
: 'border-gray-300 dark:border-gray-600'
]"
>
<i v-if="isSourceSelected('custom')" class="ri-check-line text-white text-xs scale-75"></i>
</div>
</div>
<p class="text-[10px] text-gray-500 mt-0.5 truncate">
{{ settingsStore.setData.customApiPlugin ? t('settings.playback.customApi.status.imported') : t('settings.playback.customApi.status.notImported') }}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</n-space>
</n-tab-pane>
<!-- Tab 2: 落雪音源管理 -->
<n-tab-pane name="lxMusic" tab="落雪音源" class="h-full overflow-y-auto">
<div class="pt-4 pr-2">
<div class="flex justify-between items-center mb-4">
<h3 class="text-base font-medium">已导入的音源脚本</h3>
<div class="flex gap-2">
<n-button @click="importLxMusicScript" size="small" secondary type="success">
<template #icon>
<n-icon><i class="ri-upload-line"></i></n-icon>
</template>
本地导入
</n-button>
</div>
</div> </div>
<!-- 已导入的音源列表 --> <!-- LX Music Management Tab -->
<div v-if="lxMusicApis.length > 0" class="lx-api-list mb-4"> <div v-else-if="activeTab === 'lxMusic'" class="space-y-3 pb-2">
<div class="flex justify-between items-center mb-1">
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('settings.playback.lxMusic.scripts.title') }}</h3>
<button
@click="importLxMusicScript"
class="flex items-center gap-1 px-2.5 py-1 bg-emerald-500 hover:bg-emerald-600 text-white text-xs font-medium rounded-lg transition-colors"
>
<i class="ri-upload-line"></i>
{{ t('settings.playback.lxMusic.scripts.importLocal') }}
</button>
</div>
<!-- Script List -->
<div v-if="lxMusicApis.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-2">
<div <div
v-for="api in lxMusicApis" v-for="api in lxMusicApis"
:key="api.id" :key="api.id"
class="lx-api-item" class="flex items-center p-2.5 rounded-xl border transition-all duration-200"
:class="{ 'lx-api-item--active': activeLxApiId === api.id }" :class="[
activeLxApiId === api.id
? 'bg-emerald-50/50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
: 'bg-white dark:bg-white/5 border-gray-100 dark:border-white/5'
]"
> >
<div class="lx-api-item__radio"> <div class="relative flex items-center justify-center w-4 h-4 mr-3">
<n-radio <input
type="radio"
:checked="activeLxApiId === api.id" :checked="activeLxApiId === api.id"
@update:checked="() => setActiveLxApi(api.id)" class="peer appearance-none w-4 h-4 rounded-full border border-gray-300 dark:border-gray-600 checked:border-emerald-500 checked:bg-emerald-500 transition-colors cursor-pointer"
@change="setActiveLxApi(api.id)"
/> />
<i class="ri-check-line absolute text-white text-[10px] pointer-events-none opacity-0 peer-checked:opacity-100 transition-opacity"></i>
</div> </div>
<div class="lx-api-item__info">
<div class="flex-1 min-w-0 mr-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="lx-api-item__name" v-if="editingScriptId !== api.id">{{ <span v-if="editingScriptId !== api.id" class="font-medium text-sm text-gray-900 dark:text-white truncate">
api.name {{ api.name }}
}}</span> </span>
<n-input <input
v-else v-else
v-model:value="editingName" v-model="editingName"
size="tiny"
class="w-32"
ref="renameInputRef" ref="renameInputRef"
class="w-full px-2 py-0.5 text-sm bg-white dark:bg-black/20 border border-emerald-500 rounded focus:outline-none"
@blur="saveScriptName(api.id)" @blur="saveScriptName(api.id)"
@keyup.enter="saveScriptName(api.id)" @keyup.enter="saveScriptName(api.id)"
/> />
<n-button <button
v-if="editingScriptId !== api.id" v-if="editingScriptId !== api.id"
text class="text-gray-400 hover:text-emerald-500 transition-colors"
size="tiny"
@click="startRenaming(api)" @click="startRenaming(api)"
> >
<template #icon> <i class="ri-edit-line text-sm"></i>
<n-icon class="text-gray-400 hover:text-primary" </button>
><i class="ri-edit-line"></i
></n-icon>
</template>
</n-button>
</div> </div>
<span v-if="api.info.version" class="lx-api-item__version" <div class="flex items-center gap-2 mt-0.5">
>v{{ api.info.version }}</span <span v-if="api.info.version" class="text-[10px] text-gray-500 bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">
> v{{ api.info.version }}
</span>
</div> </div>
<div class="lx-api-item__actions">
<n-button text size="tiny" type="error" @click="removeLxApi(api.id)">
<template #icon>
<n-icon><i class="ri-close-line"></i></n-icon>
</template>
</n-button>
</div>
</div>
</div>
<div v-else class="empty-state">
<n-empty description="暂无已导入的落雪音源" />
</div> </div>
<!-- URL 导入区域 --> <button
<div class="mt-6"> class="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors"
<h4 class="text-sm font-medium mb-2 text-gray-600 dark:text-gray-400">在线导入</h4> @click="removeLxApi(api.id)"
<div class="flex items-center gap-2"> >
<n-input <i class="ri-delete-bin-line text-sm"></i>
v-model:value="lxScriptUrl" </button>
placeholder="输入落雪音源脚本 URL" </div>
size="small" </div>
class="flex-1"
<div v-else class="py-6 text-center text-xs text-gray-400 bg-gray-50 dark:bg-white/5 rounded-xl border border-dashed border-gray-200 dark:border-white/10">
<p>{{ t('settings.playback.lxMusic.scripts.empty') }}</p>
</div>
<!-- URL Import -->
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-white/5">
<h4 class="text-xs font-medium mb-2 text-gray-900 dark:text-white">{{ t('settings.playback.lxMusic.scripts.importOnline') }}</h4>
<div class="flex gap-2">
<input
v-model="lxScriptUrl"
:placeholder="t('settings.playback.lxMusic.scripts.urlPlaceholder')"
class="flex-1 px-3 py-1.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-xs focus:outline-none focus:border-emerald-500 transition-colors"
:disabled="isImportingFromUrl" :disabled="isImportingFromUrl"
/> />
<n-button <button
@click="importLxMusicScriptFromUrl" @click="importLxMusicScriptFromUrl"
size="small" class="px-3 py-1.5 bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-medium rounded-xl transition-colors flex items-center gap-1"
type="primary" :disabled="!lxScriptUrl.trim() || isImportingFromUrl"
:loading="isImportingFromUrl"
:disabled="!lxScriptUrl.trim()"
> >
<template #icon> <i v-if="isImportingFromUrl" class="ri-loader-4-line animate-spin"></i>
<n-icon><i class="ri-download-line"></i></n-icon> <i v-else class="ri-download-line"></i>
</template> {{ t('settings.playback.lxMusic.scripts.importBtn') }}
导入 </button>
</n-button>
</div> </div>
</div> </div>
</div> </div>
</n-tab-pane>
<!-- Tab 3: 自定义API管理 --> <!-- Custom API Tab -->
<n-tab-pane name="customApi" tab="自定义API" class="h-full overflow-y-auto"> <div v-else-if="activeTab === 'customApi'" class="flex flex-col items-center justify-center py-6 text-center h-full">
<div class="pt-4 flex flex-col items-center justify-center h-full gap-4"> <div class="w-12 h-12 bg-violet-100 dark:bg-violet-500/20 text-violet-500 rounded-xl flex items-center justify-center mb-3">
<div class="text-center"> <i class="ri-plug-fill text-2xl"></i>
<h3 class="text-lg font-medium mb-2"> </div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-1">
{{ t('settings.playback.customApi.sectionTitle') }} {{ t('settings.playback.customApi.sectionTitle') }}
</h3> </h3>
<p class="text-gray-500 text-sm mb-4">导入兼容的自定义 API 插件以扩展音源</p> <p class="text-gray-500 dark:text-gray-400 text-xs mb-4 max-w-xs mx-auto">
</div> {{ t('settings.playback.lxMusic.scripts.importHint') }}
</p>
<div class="flex flex-col items-center gap-2"> <button
<n-button @click="importPlugin" type="primary" secondary> @click="importPlugin"
<template #icon> class="px-5 py-2 bg-violet-500 hover:bg-violet-600 text-white text-sm font-medium rounded-xl transition-colors flex items-center gap-2 shadow-lg shadow-violet-500/20"
<n-icon><i class="ri-upload-line"></i></n-icon>
</template>
{{ t('settings.playback.customApi.importConfig') }}
</n-button>
<p
v-if="settingsStore.setData.customApiPluginName"
class="text-green-600 text-sm mt-2 flex items-center gap-1"
> >
<i class="ri-check-circle-line"></i> <i class="ri-upload-line"></i>
{{ t('settings.playback.customApi.currentSource') }}: {{ t('settings.playback.customApi.importConfig') }}
<span class="font-semibold">{{ settingsStore.setData.customApiPluginName }}</span> </button>
</p>
<p v-else class="text-gray-400 text-sm mt-2"> <div v-if="settingsStore.setData.customApiPluginName" class="mt-4 flex items-center gap-2 px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg text-xs">
<i class="ri-check-circle-fill"></i>
<span>{{ t('settings.playback.customApi.currentSource') }}: <b>{{ settingsStore.setData.customApiPluginName }}</b></span>
</div>
<div v-else class="mt-4 text-xs text-gray-400">
{{ t('settings.playback.customApi.notImported') }} {{ t('settings.playback.customApi.notImported') }}
</p>
</div> </div>
</div> </div>
</n-tab-pane>
</n-tabs>
</div> </div>
</n-modal> </Transition>
</div>
</div>
<!-- Footer Actions -->
<template #footer>
<div class="flex justify-end gap-2">
<button
class="px-4 py-2 text-xs font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors"
@click="handleCancel"
>
{{ t('common.cancel') }}
</button>
<button
class="px-4 py-2 text-xs font-medium text-white bg-emerald-500 hover:bg-emerald-600 rounded-lg shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
@click="handleConfirm"
>
{{ t('common.confirm') }}
</button>
</div>
</template>
</ResponsiveModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -255,6 +319,7 @@ import { useMessage } from 'naive-ui';
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import ResponsiveModal from '@/components/common/ResponsiveModal.vue';
import { import {
initLxMusicRunner, initLxMusicRunner,
parseScriptInfo, parseScriptInfo,
@@ -303,6 +368,13 @@ const settingsStore = useSettingsStore();
const message = useMessage(); const message = useMessage();
const visible = ref(props.show); const visible = ref(props.show);
const selectedSources = ref<ExtendedPlatform[]>([...props.sources]); const selectedSources = ref<ExtendedPlatform[]>([...props.sources]);
const activeTab = ref('sources');
const tabs = computed(() => [
{ key: 'sources', label: t('settings.playback.lxMusic.tabs.sources') },
{ key: 'lxMusic', label: t('settings.playback.lxMusic.tabs.lxMusic') },
{ key: 'customApi', label: t('settings.playback.lxMusic.tabs.customApi') }
]);
// 落雪音源列表(从 store 中的脚本解析) // 落雪音源列表(从 store 中的脚本解析)
const lxMusicApis = computed<LxMusicScriptConfig[]>(() => { const lxMusicApis = computed<LxMusicScriptConfig[]>(() => {
@@ -313,7 +385,7 @@ const lxMusicApis = computed<LxMusicScriptConfig[]>(() => {
// 当前激活的音源 ID // 当前激活的音源 ID
const activeLxApiId = computed<string | null>({ const activeLxApiId = computed<string | null>({
get: () => settingsStore.setData.activeLxMusicApiId || null, get: () => settingsStore.setData.activeLxMusicApiId || null,
set: (id) => { set: (id: string | null) => {
settingsStore.setSetData({ activeLxMusicApiId: id }); settingsStore.setSetData({ activeLxMusicApiId: id });
} }
}); });
@@ -322,18 +394,9 @@ const activeLxApiId = computed<string | null>({
const lxMusicScriptInfo = computed<LxScriptInfo | null>(() => { const lxMusicScriptInfo = computed<LxScriptInfo | null>(() => {
const activeId = activeLxApiId.value; const activeId = activeLxApiId.value;
if (!activeId) { if (!activeId) {
console.log('[lxMusicScriptInfo] 没有激活的音源 ID');
return null; return null;
} }
const activeApi = lxMusicApis.value.find((api: LxMusicScriptConfig) => api.id === activeId);
const activeApi = lxMusicApis.value.find((api) => api.id === activeId);
console.log('[lxMusicScriptInfo] 查找激活的音源:', {
activeId,
found: !!activeApi,
name: activeApi?.name,
infoName: activeApi?.info?.name
});
return activeApi?.info || null; return activeApi?.info || null;
}); });
@@ -359,17 +422,20 @@ const toggleSource = (sourceKey: string) => {
// 检查是否是自定义API且未导入 // 检查是否是自定义API且未导入
if (sourceKey === 'custom' && !settingsStore.setData.customApiPlugin) { if (sourceKey === 'custom' && !settingsStore.setData.customApiPlugin) {
message.warning(t('settings.playback.customApi.enableHint')); message.warning(t('settings.playback.customApi.enableHint'));
activeTab.value = 'customApi';
return; return;
} }
// 检查是否是落雪音源且未配置 // 检查是否是落雪音源且未配置
if (sourceKey === 'lxMusic') { if (sourceKey === 'lxMusic') {
if (lxMusicApis.value.length === 0) { if (lxMusicApis.value.length === 0) {
message.warning('请先导入落雪音源脚本'); message.warning(t('settings.playback.lxMusic.scripts.noScriptWarning'));
activeTab.value = 'lxMusic';
return; return;
} }
if (!activeLxApiId.value) { if (!activeLxApiId.value) {
message.warning('请先选择一个落雪音源'); message.warning(t('settings.playback.lxMusic.scripts.noSelectionWarning'));
activeTab.value = 'lxMusic';
return; return;
} }
} }
@@ -418,7 +484,7 @@ const importLxMusicScript = async () => {
} }
} catch (error: any) { } catch (error: any) {
console.error('导入落雪音源脚本失败:', error); console.error('导入落雪音源脚本失败:', error);
message.error(`导入失败${error.message}`); message.error(`${t('common.error')}${error.message}`);
} }
}; };
@@ -428,7 +494,6 @@ const importLxMusicScript = async () => {
const addLxMusicScript = async (scriptContent: string) => { const addLxMusicScript = async (scriptContent: string) => {
// 解析脚本信息 // 解析脚本信息
const scriptInfo = parseScriptInfo(scriptContent); const scriptInfo = parseScriptInfo(scriptContent);
console.log('[MusicSourceSettings] 解析到的脚本信息:', scriptInfo);
// 尝试初始化执行器以验证脚本 // 尝试初始化执行器以验证脚本
try { try {
@@ -436,8 +501,6 @@ const addLxMusicScript = async (scriptContent: string) => {
const sources = runner.getSources(); const sources = runner.getSources();
const sourceKeys = Object.keys(sources) as LxSourceKey[]; const sourceKeys = Object.keys(sources) as LxSourceKey[];
console.log('[MusicSourceSettings] 脚本支持的音源:', sourceKeys);
// 生成唯一 ID // 生成唯一 ID
const id = `lx_api_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const id = `lx_api_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
@@ -452,13 +515,6 @@ const addLxMusicScript = async (scriptContent: string) => {
createdAt: Date.now() createdAt: Date.now()
}; };
console.log('[MusicSourceSettings] 创建的音源配置:', {
id: newApiConfig.id,
name: newApiConfig.name,
infoName: newApiConfig.info.name,
sources: newApiConfig.sources
});
// 添加到列表 // 添加到列表
const scripts = [...(settingsStore.setData.lxMusicScripts || []), newApiConfig]; const scripts = [...(settingsStore.setData.lxMusicScripts || []), newApiConfig];
@@ -467,13 +523,7 @@ const addLxMusicScript = async (scriptContent: string) => {
activeLxMusicApiId: id // 自动激活新添加的音源 activeLxMusicApiId: id // 自动激活新添加的音源
}); });
console.log('[MusicSourceSettings] 保存后的 store 数据:', { message.success(`${t('common.success')}${scriptInfo.name}`);
scriptsCount: scripts.length,
activeId: id,
firstScript: scripts[0] ? { id: scripts[0].id, name: scripts[0].name } : null
});
message.success(`音源脚本导入成功:${scriptInfo.name},支持 ${sourceKeys.length} 个音源`);
// 导入成功后自动勾选 // 导入成功后自动勾选
if (!selectedSources.value.includes('lxMusic')) { if (!selectedSources.value.includes('lxMusic')) {
@@ -481,7 +531,7 @@ const addLxMusicScript = async (scriptContent: string) => {
} }
} catch (initError: any) { } catch (initError: any) {
console.error('[MusicSourceSettings] 落雪音源脚本初始化失败:', initError); console.error('[MusicSourceSettings] 落雪音源脚本初始化失败:', initError);
message.error(`脚本初始化失败${initError.message}`); message.error(`${t('common.error')}${initError.message}`);
} }
}; };
@@ -489,26 +539,18 @@ const addLxMusicScript = async (scriptContent: string) => {
* 设置激活的落雪音源 * 设置激活的落雪音源
*/ */
const setActiveLxApi = async (apiId: string) => { const setActiveLxApi = async (apiId: string) => {
const api = lxMusicApis.value.find((a) => a.id === apiId); const api = lxMusicApis.value.find((a: LxMusicScriptConfig) => a.id === apiId);
if (!api) { if (!api) {
message.error('音源不存在'); message.error(t('settings.playback.lxMusic.scripts.notFound'));
return; return;
} }
try { try {
console.log('[MusicSourceSettings] 切换音源:', {
id: api.id,
name: api.name,
version: api.info?.version,
sources: api.sources
});
// 清除旧的 runner // 清除旧的 runner
setLxMusicRunner(null); setLxMusicRunner(null);
// 初始化新选中的脚本 // 初始化新选中的脚本
const runner = await initLxMusicRunner(api.script); await initLxMusicRunner(api.script);
console.log('[MusicSourceSettings] 音源初始化成功,支持的音源:', runner.getSources());
// 更新激活的音源 ID // 更新激活的音源 ID
activeLxApiId.value = apiId; activeLxApiId.value = apiId;
@@ -518,10 +560,10 @@ const setActiveLxApi = async (apiId: string) => {
selectedSources.value.push('lxMusic'); selectedSources.value.push('lxMusic');
} }
message.success(`已切换到音源: ${api.name}`); message.success(t('settings.playback.lxMusic.scripts.switched', { name: api.name }));
} catch (error: any) { } catch (error: any) {
console.error('[MusicSourceSettings] 切换落雪音源失败:', error); console.error('[MusicSourceSettings] 切换落雪音源失败:', error);
message.error(`切换失败${error.message}`); message.error(`${t('common.error')}${error.message}`);
} }
}; };
@@ -558,7 +600,7 @@ const removeLxApi = (apiId: string) => {
} }
} }
message.success(`已删除音源: ${removedScript.name}`); message.success(t('settings.playback.lxMusic.scripts.deleted', { name: removedScript.name }));
}; };
/** /**
@@ -567,7 +609,7 @@ const removeLxApi = (apiId: string) => {
const importLxMusicScriptFromUrl = async () => { const importLxMusicScriptFromUrl = async () => {
const url = lxScriptUrl.value.trim(); const url = lxScriptUrl.value.trim();
if (!url) { if (!url) {
message.warning('请输入脚本 URL'); message.warning(t('settings.playback.lxMusic.scripts.enterUrl'));
return; return;
} }
@@ -575,7 +617,7 @@ const importLxMusicScriptFromUrl = async () => {
try { try {
new URL(url); new URL(url);
} catch { } catch {
message.error('无效的 URL 格式'); message.error(t('settings.playback.lxMusic.scripts.invalidUrl'));
return; return;
} }
@@ -590,13 +632,14 @@ const importLxMusicScriptFromUrl = async () => {
const content = await response.text(); const content = await response.text();
// 验证脚本格式 // 验证脚本格式 - 检查是否包含 lx-music 脚本的特征
if ( // 1. 检查是否有头部注释块(包含 @name、@version 等)
!content.includes('globalThis.lx') && const hasHeaderComment = /^\/\*+[\s\S]*?@name[\s\S]*?\*\//.test(content);
!content.includes('lx.on') && // 2. 检查是否使用 lx APIlx.on 或 lx.send
!content.includes('EVENT_NAMES') const hasLxApi = content.includes('lx.on(') || content.includes('lx.send(');
) {
throw new Error('无效的落雪音源脚本,未找到 globalThis.lx 相关代码'); if (!hasHeaderComment && !hasLxApi) {
throw new Error(t('settings.playback.lxMusic.scripts.invalidScript'));
} }
// 使用统一的添加方法 // 使用统一的添加方法
@@ -606,7 +649,7 @@ const importLxMusicScriptFromUrl = async () => {
lxScriptUrl.value = ''; lxScriptUrl.value = '';
} catch (error: any) { } catch (error: any) {
console.error('从 URL 导入落雪音源脚本失败:', error); console.error('从 URL 导入落雪音源脚本失败:', error);
message.error(`在线导入失败${error.message}`); message.error(`${t('settings.playback.lxMusic.scripts.importOnline')} ${t('common.error')}${error.message}`);
} finally { } finally {
isImportingFromUrl.value = false; isImportingFromUrl.value = false;
} }
@@ -628,7 +671,7 @@ const startRenaming = (api: LxMusicScriptConfig) => {
*/ */
const saveScriptName = (apiId: string) => { const saveScriptName = (apiId: string) => {
if (!editingName.value.trim()) { if (!editingName.value.trim()) {
message.warning('名称不能为空'); message.warning(t('settings.playback.lxMusic.scripts.nameRequired'));
return; return;
} }
@@ -645,7 +688,7 @@ const saveScriptName = (apiId: string) => {
lxMusicScripts: scripts lxMusicScripts: scripts
}); });
message.success('重命名成功'); message.success(t('settings.playback.lxMusic.scripts.renameSuccess'));
} }
editingScriptId.value = null; editingScriptId.value = null;
@@ -675,7 +718,7 @@ const handleCancel = () => {
// 监听自定义插件内容变化 // 监听自定义插件内容变化
watch( watch(
() => settingsStore.setData.customApiPlugin, () => settingsStore.setData.customApiPlugin,
(newPluginContent) => { (newPluginContent: any) => {
if (!newPluginContent) { if (!newPluginContent) {
const index = selectedSources.value.indexOf('custom'); const index = selectedSources.value.indexOf('custom');
if (index > -1) { if (index > -1) {
@@ -688,7 +731,7 @@ watch(
// 监听落雪音源列表变化 // 监听落雪音源列表变化
watch( watch(
() => [lxMusicApis.value.length, activeLxApiId.value], () => [lxMusicApis.value.length, activeLxApiId.value],
([apiCount, activeId]) => { ([apiCount, activeId]: [number, string | null]) => {
// 如果没有音源或没有激活的音源,自动从已选音源中移除 lxMusic // 如果没有音源或没有激活的音源,自动从已选音源中移除 lxMusic
if (apiCount === 0 || !activeId) { if (apiCount === 0 || !activeId) {
const index = selectedSources.value.indexOf('lxMusic'); const index = selectedSources.value.indexOf('lxMusic');
@@ -703,7 +746,7 @@ watch(
// 同步外部show属性变化 // 同步外部show属性变化
watch( watch(
() => props.show, () => props.show,
(newVal) => { (newVal: boolean) => {
visible.value = newVal; visible.value = newVal;
} }
); );
@@ -711,7 +754,7 @@ watch(
// 同步内部visible变化 // 同步内部visible变化
watch( watch(
() => visible.value, () => visible.value,
(newVal) => { (newVal: boolean) => {
emit('update:show', newVal); emit('update:show', newVal);
} }
); );
@@ -719,225 +762,21 @@ watch(
// 同步外部sources属性变化 // 同步外部sources属性变化
watch( watch(
() => props.sources, () => props.sources,
(newVal) => { (newVal: ExtendedPlatform[]) => {
selectedSources.value = [...newVal]; selectedSources.value = [...newVal];
}, },
{ deep: true } { deep: true }
); );
</script> </script>
<style lang="scss" scoped> <style scoped>
.music-sources-grid { .fade-enter-active,
display: grid; .fade-leave-active {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
.source-card {
position: relative;
border-radius: 8px;
border: 2px solid transparent;
background:
linear-gradient(white, white) padding-box,
linear-gradient(135deg, var(--source-color, #ddd) 0%, transparent 100%) border-box;
padding: 16px;
cursor: pointer;
transition: all 0.2s ease;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--source-color);
opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
&__indicator { .fade-enter-from,
position: absolute; .fade-leave-to {
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--source-color);
opacity: 0; opacity: 0;
transition: opacity 0.2s ease;
}
&__content {
position: relative;
z-index: 1;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
&__name {
font-size: 15px;
font-weight: 600;
color: #333;
transition: color 0.2s ease;
}
&__check {
color: var(--source-color);
opacity: 0;
transform: scale(0.8);
transition: all 0.2s ease;
}
&__description {
font-size: 12px;
color: #999;
margin: 0;
transition: color 0.2s ease;
}
&:hover {
border-color: var(--source-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&--selected {
border-color: var(--source-color);
background:
linear-gradient(white, white) padding-box,
var(--source-color) border-box;
.source-card__indicator {
opacity: 1;
}
.source-card__check {
opacity: 1;
transform: scale(1);
}
&::before {
opacity: 0.05;
}
}
&--disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
transform: none;
box-shadow: none;
}
}
}
// 深色模式适配
:global(.dark) {
.source-card {
background:
linear-gradient(#1f1f1f, #1f1f1f) padding-box,
linear-gradient(135deg, var(--source-color, #555) 0%, transparent 100%) border-box;
&__name {
color: #e5e5e5;
}
&__description {
color: #999;
}
&--selected {
background:
linear-gradient(#1f1f1f, #1f1f1f) padding-box,
var(--source-color) border-box;
}
}
}
.lx-api-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.lx-api-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid transparent;
transition: all 0.2s ease;
&--active {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(59, 130, 246, 0.08));
border-color: rgba(16, 185, 129, 0.3);
}
&__radio {
flex-shrink: 0;
}
&__info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
&__name {
font-size: 13px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__version {
font-size: 12px;
color: #999;
background: rgba(0, 0, 0, 0.05);
padding: 1px 6px;
border-radius: 4px;
}
&__actions {
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover &__actions {
opacity: 1;
}
}
:global(.dark) {
.lx-api-item {
background: #2a2a2a;
&__name {
color: #e5e5e5;
}
&__version {
background: rgba(255, 255, 255, 0.1);
}
}
}
.empty-state {
padding: 32px 0;
display: flex;
justify-content: center;
} }
</style> </style>