Files
vtsuru.live/src/views/view/songListTemplate/TraditionalSongListTemplate.vue
Megghy 8bed5bbc1a feat: 更新组件和配置,增强功能和用户体验, 添加签到功能
- 在 .editorconfig 中调整文件格式设置,统一代码风格。
- 在 default.d.ts 中为 naive-ui 添加 TabPaneSlots 接口声明,增强类型支持。
- 在多个组件中优化了模板和样式,提升用户交互体验。
- 在 ClientAutoAction.vue 中新增签到设置标签页,丰富功能选项。
- 在 Utils.ts 中增强 GUID 处理逻辑,增加输入验证和错误处理。
- 更新多个组件的逻辑,简化代码结构,提升可读性和维护性。
2025-04-26 01:35:59 +08:00

1538 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, h, ref, watch, VNode } from 'vue';
import { getUserAvatarUrl, isDarkMode } from '@/Utils';
import { SongListConfigTypeWithConfig } from '@/data/TemplateTypes';
import { defineTemplateConfig, ExtractConfigData } from '@/data/VTsuruTypes';
import { FILE_BASE_URL } from '@/data/constants';
import { NButton, NFlex, NIcon, NInput, NInputGroup, NInputGroupLabel, NTag, NTooltip, NSelect } from 'naive-ui';
import bilibili from '@/svgs/bilibili.svg';
import neteaseMusic from '@/svgs/neteaseMusic.svg';
import qqMusic from '@/svgs/qqMusic.svg';
import douyin from '@/svgs/douyin.svg';
import { SongFrom, SongsInfo, SongRequestOption } from '@/api/api-models';
import FiveSingIcon from '@/svgs/fivesing.svg';
import { SquareArrowForward24Filled, ArrowCounterclockwise20Filled, ArrowSortDown20Filled, ArrowSortUp20Filled } from '@vicons/fluent';
import { List } from 'linqts';
// Interface Tab - can be reused for both language and tag buttons
interface FilterButton {
id: number;
name: string;
}
const props = defineProps<SongListConfigTypeWithConfig<TraditionalConfigType>>();
defineExpose({ Config, DefaultConfig });
const emits = defineEmits(['requestSong']);
const isHovering = ref(false);
// --- State for Filters ---
const selectedLanguage = ref<string | undefined>();
const selectedTag = ref<string | undefined>(); // Renamed from activeTab for clarity
const searchQuery = ref<string>('');
const selectedArtist = ref<string | null>(null);
// 添加点歌条件筛选状态
const selectedOption = ref<string | undefined>();
// --- New: Sorting State ---
type SortKey = 'name' | 'author' | 'language' | 'tags' | 'options' | 'description' | null;
const sortKey = ref<SortKey>(null); // 当前排序列
const sortOrder = ref<'asc' | 'desc'>('asc'); // 当前排序顺序
// --- Computed Properties for Filter Buttons ---
// Extract unique languages
const allUniqueLanguages = computed<string[]>(() => {
const languages = new Set<string>();
// 添加"未设定"语言选项
languages.add('未设定');
props.data?.forEach(song => {
song.language?.forEach(lang => {
if (lang?.trim()) {
languages.add(lang.trim());
}
});
});
return Array.from(languages).sort();
});
// Create structure for language buttons
const languageButtons = computed<FilterButton[]>(() =>
allUniqueLanguages.value.map((lang, index) => ({ id: index, name: lang }))
);
// Extract unique tags (similar to original 'tabs' logic)
const allUniqueTags = computed<string[]>(() => {
const tags = new Set<string>();
// 添加"未设定"标签选项
tags.add('未设定');
props.data?.forEach(song => {
song.tags?.forEach(tag => {
if (tag?.trim()) {
tags.add(tag.trim());
}
});
});
return Array.from(tags).sort();
});
// Create structure for tag buttons (reuse FilterButton interface)
const tagButtons = computed<FilterButton[]>(() =>
allUniqueTags.value.map((tag, index) => ({ id: index, name: tag }))
);
// --- 添加点歌条件筛选按钮 ---
// 提取所有唯一的点歌条件类型
const allOptionTypes = computed<string[]>(() => {
const optionTypes = new Set<string>();
// 添加"未设定"选项
optionTypes.add('未设定');
// 添加基本选项类型
optionTypes.add('舰长');
optionTypes.add('提督');
optionTypes.add('总督');
optionTypes.add('粉丝牌');
optionTypes.add('SC');
return Array.from(optionTypes);
});
// 创建点歌条件筛选按钮
const optionButtons = computed<FilterButton[]>(() =>
allOptionTypes.value.map((option, index) => ({ id: index, name: option }))
);
// --- Computed Properties for Data ---
// Get unique artists for the dropdown (unchanged)
const allArtists = computed(() => {
const artists = new Set<string>();
props.data?.forEach(song => {
song.author?.forEach(author => {
if (author?.trim()) {
artists.add(author.trim());
}
});
});
return Array.from(artists).sort();
});
// Format artists for NSelect options (unchanged)
const artistOptions = computed(() => {
return allArtists.value.map(artist => ({ label: artist, value: artist }));
});
// --- Updated Filtered & Sorted Songs Logic using linq-ts ---
const filteredAndSortedSongs = computed(() => {
if (!props.data) return [];
let query = new List<SongsInfo>(props.data);
// 1. Filter by Selected Language
if (selectedLanguage.value) {
const lang = selectedLanguage.value;
if (lang === '未设定') {
// 筛选没有设置语言或语言数组为空的歌曲
query = query.Where(song => !song.language || song.language.length === 0);
} else {
query = query.Where(song => song.language?.includes(lang));
}
}
// 2. Filter by Selected Tag
if (selectedTag.value) {
const tag = selectedTag.value;
if (tag === '未设定') {
// 筛选没有设置标签或标签数组为空的歌曲
query = query.Where(song => !song.tags || song.tags.length === 0);
} else {
query = query.Where(song => song.tags?.includes(tag) ?? false);
}
}
// 3. Filter by Selected Artist
if (selectedArtist.value) {
const artist = selectedArtist.value;
query = query.Where(song => song.author?.includes(artist) ?? false);
}
// 新增: 4. 根据点歌条件筛选
if (selectedOption.value) {
const option = selectedOption.value;
if (option === '未设定') {
// 筛选没有设置点歌条件的歌曲
query = query.Where(song => !song.options);
} else if (option === '舰长') {
query = query.Where(song => song.options?.needJianzhang === true);
} else if (option === '提督') {
query = query.Where(song => song.options?.needTidu === true);
} else if (option === '总督') {
query = query.Where(song => song.options?.needZongdu === true);
} else if (option === '粉丝牌') {
query = query.Where(song => (song.options?.fanMedalMinLevel ?? 0) > 0);
} else if (option === 'SC') {
query = query.Where(song => (song.options?.scMinPrice ?? 0) > 0);
}
}
// 原有的搜索逻辑
// 4. Filter by Search Query (case-insensitive, including tags)
if (searchQuery.value.trim()) {
const lowerSearch = searchQuery.value.toLowerCase().trim();
query = query.Where(song =>
song.name.toLowerCase().includes(lowerSearch) ||
(song.author?.some(a => a.toLowerCase().includes(lowerSearch)) ?? false) ||
(song.language?.some(l => l.toLowerCase().includes(lowerSearch)) ?? false) ||
(song.tags?.some(t => t.toLowerCase().includes(lowerSearch)) ?? false) ||
(song.description?.toLowerCase().includes(lowerSearch) ?? false)
);
}
// 5. Sort the filtered songs using linq-ts
if (sortKey.value) {
const key = sortKey.value;
// Define selector function for linq-ts
const keySelector = (song: SongsInfo): any => {
if (key === 'options') {
// Prefer sorting by specific conditions first if needed, then by presence
// Example: Sort by 'needZongdu' first if key is 'options'
// For simplicity, just sorting by presence (1) vs absence (0)
return song.options ? 1 : 0;
}
let val = song[key];
// Handle potential array values for sorting (simple join)
if (Array.isArray(val)) return val.join('').toLowerCase(); // Lowercase for consistent string sort
// Handle strings and other types, provide default for null/undefined
return (typeof val === 'string' ? val.toLowerCase() : val) ?? '';
};
// Define a stable secondary sort key selector
const secondaryKeySelector = (song: SongsInfo): string | number => {
return song.id ?? (song.name + '-' + (song.author?.join('/') ?? '')); // Use ID or fallback key
};
if (sortOrder.value === 'asc') {
query = query.OrderBy(keySelector).ThenBy(secondaryKeySelector); // Add ThenBy for stability
} else {
query = query.OrderByDescending(keySelector).ThenBy(secondaryKeySelector); // Add ThenBy for stability
}
}
// else if no primary sort key, maybe apply a default sort? e.g., by name
// else {
// query = query.OrderBy(s => s.name);
// }
return query.ToArray(); // Get the final array
});
// --- Methods ---
// Select/Deselect Language
const selectLanguage = (langName: string) => {
if (langName === selectedLanguage.value) {
selectedLanguage.value = undefined; // Clear filter if clicking the active one
} else {
selectedLanguage.value = langName;
}
};
// Select/Deselect Tag
const selectTag = (tagName: string) => {
if (tagName === selectedTag.value) {
selectedTag.value = undefined; // Clear filter if clicking the active one
} else {
selectedTag.value = tagName;
}
};
// 新增: 选择/取消选择点歌条件
const selectOption = (optionName: string) => {
if (optionName === selectedOption.value) {
selectedOption.value = undefined; // 点击已激活的按钮则取消筛选
} else {
selectedOption.value = optionName;
}
};
// Select Artist (from table click, updated to allow deselect)
const selectArtistFromTable = (artist: string) => {
if (selectedArtist.value === artist) {
selectedArtist.value = null; // Deselect if clicking the already selected artist
} else {
selectedArtist.value = artist; // Select the new artist
}
};
// Select Language (from table click, allows deselect)
const selectLanguageFromTable = (lang: string) => {
if (selectedLanguage.value === lang) {
selectedLanguage.value = undefined; // Use undefined based on existing filter logic
} else {
selectedLanguage.value = lang;
}
};
// --- New: Clear All Filters ---
const clearFilters = () => {
selectedLanguage.value = undefined;
selectedTag.value = undefined;
selectedArtist.value = null; // Reset NSelect value
selectedOption.value = undefined; // 清除点歌条件筛选
searchQuery.value = '';
};
// --- Updated Sorting Method ---
const handleSort = (key: SortKey) => {
if (sortKey.value === key) {
// Cycle through asc -> desc -> null (clear sort)
if (sortOrder.value === 'asc') {
sortOrder.value = 'desc';
} else {
// If already desc, clear the sort
sortKey.value = null;
// Optional: Reset sortOrder, though it doesn't matter when sortKey is null
// sortOrder.value = 'asc';
}
} else {
// Set new key and default to ascending order
sortKey.value = key;
sortOrder.value = 'asc';
}
};
// --- Updated Helper function for Sort Icons ---
const getSortIcon = (key: SortKey) => {
if (sortKey.value !== key) {
// Show inactive sort icon (down arrow as placeholder)
return h(NIcon, { component: ArrowSortDown20Filled, style: { opacity: 0.3, marginLeft: '4px', verticalAlign: 'middle' } });
}
// Show active sort icon (up or down)
return h(NIcon, { component: sortOrder.value === 'asc' ? ArrowSortUp20Filled : ArrowSortDown20Filled, style: { marginLeft: '4px', verticalAlign: 'middle' } });
// Note: We don't need a specific 'clear' icon here, as clicking 'desc' clears the sort and the icon reverts to inactive.
};
// Watcher for artist selection (unchanged, good practice)
watch(allArtists, (newArtists) => {
if (selectedArtist.value && !newArtists.includes(selectedArtist.value)) {
selectedArtist.value = null;
}
});
const randomOrder = () => {
const songsToChooseFrom = filteredAndSortedSongs.value.length > 0 ? filteredAndSortedSongs.value : props.data ?? [];
if (songsToChooseFrom.length === 0) {
window.$message?.warning('歌单为空或当前筛选无结果,无法随机点歌');
return;
}
const song = songsToChooseFrom[Math.floor(Math.random() * songsToChooseFrom.length)];
window.$modal.create({
preset: 'dialog',
type: 'success',
title: '随机点歌',
content: `你抽到的歌曲是: ${song.name}, 来自 ${song.author?.join('/')}`,
positiveText: '点歌',
negativeText: '算了',
onPositiveClick: () => {
emits('requestSong', song);
},
});
};
function onSongClick(song: SongsInfo) {
window.$modal.create({
preset: 'dialog',
title: '点歌',
content: `确定要点 ${song.name}`,
positiveText: '点歌',
negativeText: '算了',
onPositiveClick: () => {
emits('requestSong', song);
},
});
}
// GetPlayButton function remains the same
function GetPlayButton(song: SongsInfo) {
// ... (GetPlayButton function implementation - unchanged) ...
switch (song.from) {
case SongFrom.FiveSing: {
return h(NTooltip, null, {
trigger: () =>
h(
h(
NButton,
{
size: 'small',
color: '#00BBB3',
text: true,
onClick: (e) => {
e.stopPropagation(); // Prevent row click
window.open(`http://5sing.kugou.com/bz/${song.id}.html`);
},
},
{
icon: () => h(FiveSingIcon, { class: 'svg-icon fivesing' }),
},
),
),
default: () => '在5sing打开',
});
}
case SongFrom.Netease:
return h(NTooltip, null, {
trigger: () =>
h(
NButton,
{
size: 'small',
color: '#C20C0C',
text: true,
onClick: (e) => {
e.stopPropagation(); // Prevent row click
window.open(`https://music.163.com/#/song?id=${song.id}`);
},
},
{
icon: () => h(neteaseMusic, { class: 'svg-icon netease' }),
},
),
default: () => '在网易云打开',
});
case SongFrom.Custom:
return song.url
? h(NTooltip, null, {
trigger: () =>
h(
NButton,
{
size: 'small',
color: '#6b95bd',
text: true,
onClick: (e) => {
e.stopPropagation(); // Prevent row click
window.open(song.url);
},
},
{
icon: () => h(NIcon, { component: SquareArrowForward24Filled }),
},
),
default: () => '打开链接',
})
: null;
}
}
// --- New: Helper function for Song Request Options ---
function getOptionDisplay(options?: SongRequestOption) {
if (!options) {
// 直接返回空元素,不显示"无特殊要求"
return h('span', {});
}
const conditions: VNode[] = [];
if (options.needJianzhang) {
conditions.push(h(NTag, { size: 'small', type: 'info', style: { marginRight: '4px', marginBottom: '2px'} }, () => '舰长'));
}
if (options.needTidu) {
conditions.push(h(NTag, { size: 'small', type: 'warning', style: { marginRight: '4px', marginBottom: '2px'} }, () => '提督'));
}
if (options.needZongdu) {
conditions.push(h(NTag, { size: 'small', type: 'error', style: { marginRight: '4px', marginBottom: '2px'} }, () => '总督'));
}
if (options.fanMedalMinLevel && options.fanMedalMinLevel > 0) {
conditions.push(h(NTag, { size: 'small', type: 'success', style: { marginRight: '4px', marginBottom: '2px'} }, () => `粉丝牌 ${options.fanMedalMinLevel}`));
}
if (options.scMinPrice && options.scMinPrice > 0) {
conditions.push(h(NTag, { size: 'small', color: { color: '#E85A4F', textColor: '#fff' }, style: { marginRight: '4px', marginBottom: '2px'} }, () => `SC ¥${options.scMinPrice}`));
}
if (conditions.length === 0) {
// 如果没有条件,直接返回空元素,不显示"无特殊要求"
return h('span', {});
}
// Use NFlex for better wrapping
return h(NFlex, { size: 4, wrap: true, style: { gap: '4px' } }, () => conditions);
}
</script>
<script lang="ts">
// --- Config section remains the same ---
const tempLinks = ref<{ name: string, url: string; }>({
name: '',
url: ''
});
export type TraditionalConfigType = ExtractConfigData<typeof Config>;
export const DefaultConfig = {} as TraditionalConfigType;
export const Config = defineTemplateConfig([
{
name: '背景',
type: 'image',
imageLimit: 1,
key: 'background',
onUploaded: (url, config) => {
config.background = url;
},
},
{
name: '固定歌曲列表高度',
type: 'boolean',
key: 'fixedHeight',
default: true,
description: '如果不勾选,歌曲较多时会整个页面滚动, 否则只会滚动歌单部分',
},
{
name: '标题',
type: 'string',
key: 'title',
default: '我的歌单'
},
{
name: '简介',
type: 'string',
key: 'description',
},
{
name: '详情页标题',
type: 'string',
key: 'detailTitle',
placeholder: '链接页里头的',
},
{
name: '详情页介绍',
type: 'string',
key: 'longDescription',
placeholder: '链接页里头的',
inputType: 'textarea'
},
{
type: 'string',
name: '网易云链接',
key: 'neteaseLink',
placeholder: '可为空, 需要以 http(s):// 开头',
},
{
type: 'string',
name: 'QQ音乐链接',
key: 'qqMusicLink',
placeholder: '可为空, 需要以 http(s):// 开头',
},
{
type: 'string',
name: '抖音链接',
key: 'douyinLink',
placeholder: '可为空, 需要以 http(s):// 开头',
},
{
type: 'render',
name: '自定义其他链接',
key: 'links',
default: [
{
name: '📺 哔哩哔哩',
url: 'https://www.bilibili.com/',
}
],
render: (config) => {
return h(
NFlex,
{
justify: 'start',
align: 'center',
style: {
width: '100%',
padding: '10px 0',
flexWrap: 'wrap', // Allow wrapping for smaller screens
},
},
() => [
config.links?.map((link: { name: string; url: string; }) => {
return h(
NTag,
{
style: {
margin: '5px 5px 5px 0', // Adjust margin
cursor: 'pointer',
},
onClick: () => {
window.open(link.url, '_blank');
},
closable: true,
onClose: () => {
config.links = config.links.filter((l: { name: string; url: string; }) => l.name !== link.name);
},
},
() => link.name
);
}),
h(NFlex, { style: { marginTop: '5px', flexGrow: 1, minWidth: '300px' }, align:'center' }, () => [ // Wrap inputs and button
h(NInputGroup, { size: 'small', style:{ marginRight: '5px'} }, () => [
h(NInputGroupLabel, { style: { width: 'auto' } }, () => '名称'), // Auto width
h(NInput, {
placeholder: '链接名称',
value: tempLinks.value.name,
onUpdateValue: (value) => {
tempLinks.value.name = value;
},
}),
]),
h(NInputGroup, { size: 'small', style:{ marginRight: '5px'} }, () => [
h(NInputGroupLabel, { style: { width: 'auto' } }, () => '地址'), // Auto width
h(NInput, {
placeholder: 'http(s)://...',
value: tempLinks.value.url,
onUpdateValue: (value) => {
tempLinks.value.url = value;
},
}),
]),
h(NButton, {
type: 'primary',
size: 'small',
onClick: () => {
if (tempLinks.value.name && tempLinks.value.url) { // Basic validation
config.links = config.links || [];
config.links.push({ ...tempLinks.value }); // Push a copy
tempLinks.value = { // Reset
name: '',
url: '',
};
} else {
window.$message?.warning("请输入链接名称和地址");
}
},
disabled: !tempLinks.value.name || !tempLinks.value.url // Disable if fields are empty
}, () => '添加'),
])
]
);
},
// onUploaded seems irrelevant here, keep if needed elsewhere
// onUploaded(data, config) {
// tempLinks.value = {
// name: '',
// url: '',
// };
// },
}
])
</script>
<template>
<!-- 新增: 外部背景和模糊容器 -->
<div
class="song-list-background-wrapper"
:style="{
backgroundImage: props.config?.background ? `url(${FILE_BASE_URL + props.config.background})` : 'none',
}"
>
<!-- 原始: 滚动和内容容器 -->
<div class="song-list-template">
<div class="profile-card-container">
<!-- Profile Hover Area (unchanged) -->
<div
class="profile-hover-area"
:class="{ 'is-hovering': isHovering }"
@mouseenter="isHovering = true"
@mouseleave="isHovering = false"
>
<!-- Avatar -->
<img
:src="'https://fetch.vtsuru.live/' + props.userInfo?.streamerInfo?.faceUrl + '@256w_256h'"
alt="Avatar"
class="profile-avatar"
referrerpolicy="no-referrer"
>
<!-- Basic Info (Always Visible) -->
<div class="profile-info">
<h2 class="profile-name">
{{ props.config?.title ?? `${props.userInfo?.name} 的歌单` }}
</h2>
<p class="profile-description">
{{ props.config?.description }}
</p>
<span class="profile-extra-info">点击歌名进行点歌</span>
</div>
<!-- Social Links (Visible on Hover) -->
<div class="social-links">
<p class="social-links-title">
关于我
</p>
<p class="social-links-subtitle">
{{ props.config?.longDescription ?? '暂时没有填写介绍' }}
</p>
<div class="social-icons-bar">
<!-- Add actual icons here -->
<a
v-if="props.userInfo?.biliId"
:href="'https://space.bilibili.com/' + props.userInfo?.biliId"
class="icon icon-bilibili"
title="Bilibili 链接"
target="_blank"
>
<bilibili />
</a>
<a
v-if="props.config?.douyinLink"
:href="props.config?.douyinLink"
title="抖音链接"
target="_blank"
class="icon"
>
<douyin />
</a>
<a
v-if="props.config?.neteaseLink"
:href="props.config?.neteaseLink"
title="网易云链接"
target="_blank"
class="icon"
>
<neteaseMusic />
</a>
<a
v-if="props.config?.qqMusicLink"
:href="props.config?.qqMusicLink"
title="QQ音乐链接"
target="_blank"
class="icon"
>
<qqMusic />
</a>
</div>
<div
v-if="props.config?.links && props.config.links.length > 0"
class="social-grid"
>
<a
v-for="link in props.config?.links"
:key="link.name"
:href="link.url"
target="_blank"
class="social-link"
>
<span>{{ link.name }}</span>
<span class="arrow">></span>
</a>
</div>
</div>
</div>
<!-- Song List Content Area -->
<div class="song-list-container">
<!-- Language Filter Buttons -->
<div
v-if="languageButtons.length > 0"
class="filter-button-group language-filters"
>
<span class="filter-label">语言:</span>
<button
v-for="lang in languageButtons"
:key="lang.id"
:class="{ active: selectedLanguage === lang.name }"
class="filter-button"
@click="selectLanguage(lang.name)"
>
{{ lang.name }}
</button>
</div>
<!-- Tag Filter Buttons (Replaces original tabs) -->
<div
v-if="tagButtons.length > 0"
class="filter-button-group tag-filters"
>
<span class="filter-label">标签:</span>
<button
v-for="tag in tagButtons"
:key="tag.id"
:class="{ active: selectedTag === tag.name }"
class="filter-button"
@click="selectTag(tag.name)"
>
{{ tag.name }}
</button>
</div>
<!-- 新增: 点歌条件筛选按钮 -->
<div
v-if="optionButtons.length > 0"
class="filter-button-group option-filters"
>
<span class="filter-label">点歌条件:</span>
<button
v-for="option in optionButtons"
:key="option.id"
:class="{ active: selectedOption === option.name }"
class="filter-button"
@click="selectOption(option.name)"
>
{{ option.name }}
</button>
</div>
<!-- Divider -->
<div class="filter-divider" />
<!-- Filter/Search Bar Row -->
<n-flex
class="song-list-filter"
justify="space-between"
align="center"
>
<!-- Left side filters: Artist, Search, Clear -->
<n-flex
align="center"
:wrap="true"
style="flex-grow: 1;"
>
<!-- Artist Filter Dropdown -->
<n-select
v-model:value="selectedArtist"
:options="artistOptions"
placeholder="筛选歌手"
clearable
style="max-width: 160px; margin-right: 10px; margin-bottom: 5px;"
size="small"
/>
<!-- Search Input -->
<div
class="search-wrapper"
style="margin-right: 10px; margin-bottom: 5px;"
>
<span class="search-icon">🔍</span>
<input
v-model="searchQuery"
type="text"
placeholder="筛选歌名/歌手/语言/标签/备注"
class="filter-input"
style="min-width: 220px;"
>
</div>
<!-- Clear Filters Button -->
<n-button
size="small"
class="clear-button"
ghost
:disabled="!selectedLanguage && !selectedTag && !selectedArtist && !searchQuery"
@click="clearFilters"
>
<template #icon>
<n-icon :component="ArrowCounterclockwise20Filled" />
</template>
清空筛选
</n-button>
</n-flex>
<!-- Right side: Random Button -->
<n-button
class="refresh-button"
size="small"
ghost
@click="randomOrder"
>
随机点歌
</n-button>
</n-flex>
<!-- Song Table -->
<NScrollbar
class="song-table-wrapper"
trigger="none"
:style="{ height: props.config?.fixedHeight ? '55vh' : 'none' }"
>
<table class="song-list-table">
<thead>
<tr>
<th
style="cursor: pointer;"
@click="handleSort('name')"
>
歌名 <component :is="getSortIcon('name')" />
</th>
<th
style="cursor: pointer;"
@click="handleSort('author')"
>
歌手 <component :is="getSortIcon('author')" />
</th>
<th
style="cursor: pointer;"
@click="handleSort('language')"
>
语言 <component :is="getSortIcon('language')" />
</th>
<th
style="cursor: pointer;"
@click="handleSort('tags')"
>
标签 <component :is="getSortIcon('tags')" />
</th>
<th
style="cursor: pointer;"
@click="handleSort('options')"
>
点歌条件 <component :is="getSortIcon('options')" />
</th>
<th
style="cursor: pointer;"
@click="handleSort('description')"
>
备注 <component :is="getSortIcon('description')" />
</th>
</tr>
</thead>
<tbody>
<tr v-if="!props.data || props.data.length === 0">
<td
colspan="6"
class="no-results"
>
歌单里还没有歌曲哦~
</td>
</tr>
<tr v-else-if="filteredAndSortedSongs.length === 0">
<td
colspan="6"
class="no-results"
>
当前筛选条件下暂无匹配歌曲
</td>
</tr>
<tr
v-for="song in filteredAndSortedSongs"
:key="song.key || (song.name + '-' + song.author?.join('/'))"
:style="{
textShadow: isDarkMode ? '0px 1px 2px rgba(0, 0, 0, 0.4)' : '0px 1px 2px rgba(255, 255, 255, 0.4)',
}"
class="song-row"
>
<td>
<span class="song-name">
<component :is="GetPlayButton(song)" />
<span
style="cursor: pointer;"
@click="onSongClick(song)"
>
{{ song.name }}
</span>
</span>
</td>
<td>
<!-- Updated Artist Cell (Clickable) -->
<span v-if="song.author && song.author.length > 0">
<span
v-for="(artist, index) in song.author"
:key="artist"
>
<span
class="artist-link"
:class="{ 'selected-artist': selectedArtist === artist }"
:title="`筛选: ${artist}`"
@click.stop="selectArtistFromTable(artist)"
>
{{ artist }}
</span>
<!-- Add separator only if not the last artist -->
<span v-if="index < song.author.length - 1"> / </span>
</span>
</span>
<span v-else>未知</span>
</td>
<td>
<span v-if="song.language && song.language.length > 0">
<span
v-for="(lang, index) in song.language"
:key="lang"
>
<span
class="language-link"
:class="{ 'selected-language': selectedLanguage === lang }"
:title="`筛选: ${lang}`"
@click.stop="selectLanguageFromTable(lang)"
>
{{ lang }}
</span>
<!-- Add separator only if not the last language -->
<span v-if="index < song.language.length - 1">, </span>
</span>
</span>
<!-- 移除了 "未知" 占位文本 -->
</td>
<td>
<n-flex
v-if="song.tags && song.tags.length > 0"
:size="4"
:wrap="true"
style="gap: 4px;"
>
<!-- Use NFlex for tag wrapping -->
<n-tag
v-for="tag in song.tags"
:key="tag"
size="small"
checkable
:checked="selectedTag === tag"
@update:checked="selectTag(tag)"
>
{{ tag }}
</n-tag>
</n-flex>
<!-- 移除了 "无标签" 占位文本 -->
</td>
<td>
<component :is="getOptionDisplay(song.options)" />
</td>
<td>{{ song.description }}</td>
</tr>
</tbody>
</table>
</NScrollbar>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Reuse existing styles where possible */
/* --- Styles for Filter Button Groups --- */
.filter-button-group {
display: flex;
align-items: center;
flex-wrap: wrap; /* Allow buttons to wrap */
gap: 8px; /* Spacing between buttons */
margin-bottom: 10px; /* Spacing below the group */
}
.filter-label {
font-size: 0.9em;
margin-right: 5px;
font-weight: 500;
color: #555555;
white-space: nowrap; /* Prevent label from wrapping */
}
html.dark .filter-label {
color: var(--text-color-2);
}
/* Style for individual language/tag buttons */
.filter-button {
padding: 4px 12px; /* Smaller padding */
border: 1px solid transparent; /* Start transparent */
border-radius: 15px;
background-color: rgba(0, 0, 0, 0.04); /* Subtle background */
font-size: 0.85em; /* Slightly smaller font */
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
color: #555555;
line-height: 1.4; /* Adjust line height */
}
html.dark .filter-button {
background-color: var(--button-color-2);
color: var(--text-color-2);
}
.filter-button.active {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color); /* Add border for active */
font-weight: bold;
}
.filter-button:hover:not(.active) {
background-color: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.1); /* Subtle border on hover */
}
html.dark .filter-button:hover:not(.active) {
background-color: var(--item-color-hover);
}
/* Divider between filters and search bar */
.filter-divider {
height: 1px;
background-color: rgba(0, 0, 0, 0.1);
margin: 10px 0 15px 0; /* Add margin */
}
html.dark .filter-divider {
background-color: var(--border-color);
}
/* --- Adjustments for Filter/Search Bar --- */
.song-list-filter {
/* NFlex handles alignment, wrap defaults */
margin-bottom: 15px;
}
.search-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 0.9em;
pointer-events: none;
color: #aaaaaa;
}
html.dark .search-icon {
color: var(--text-color-3);
}
.filter-input {
height: 30px;
box-sizing: border-box;
padding: 6px 15px 6px 30px; /* Left padding for icon */
border-radius: 15px;
/* width: 250px; */ /* Let flexbox handle width or use min-width */
font-size: 0.9em;
line-height: normal;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
border: 1px solid rgba(0, 0, 0, 0.15);
background-color: rgba(255, 255, 255, 0.8);
color: #333333;
}
html.dark .filter-input {
border-color: var(--border-color);
background-color: var(--input-color);
color: var(--text-color-1);
}
.filter-input::placeholder {
color: #aaaaaa;
}
html.dark .filter-input::placeholder {
color: var(--text-color-3);
}
.filter-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-a3);
}
/* --- Naive UI Select Styling (Keep existing) --- */
:deep(.song-list-filter .n-select .n-base-selection) {
--n-height: 30px !important;
--n-padding-single: 0 26px 0 10px !important;
border-radius: 15px !important;
}
:deep(.song-list-filter .n-select .n-base-selection .n-base-selection-placeholder),
:deep(.song-list-filter .n-select .n-base-selection .n-base-selection-input) {
height: 30px !important;
line-height: 30px !important;
}
/* --- Clear Button Styling --- */
.clear-button {
height: 30px; /* Match input height */
/* padding: 0 10px; */ /* NButton handles padding well */
border-radius: 15px; /* Match input style */
font-size: 0.85em; /* Slightly smaller */
line-height: 28px; /* Adjust if needed */
}
/* --- Random Button Styling (Keep Existing) --- */
.refresh-button {
height: 30px;
/* padding: 0 15px; Use NButton padding */
border-radius: 15px;
font-size: 0.9em;
line-height: 28px;
white-space: nowrap;
}
/* --- MODIFIED: Structure Styles --- */
/* --- NEW: Outer Background & Blur Wrapper --- */
.song-list-background-wrapper {
position: relative; /* Anchor for ::before */
height: calc(100vh - var(--vtsuru-header-height) - var(--vtsuru-content-padding) - var(--vtsuru-content-padding));
border-radius: 8px; /* Apply rounding here */
background-size: cover;
background-position: center center;
background-attachment: fixed; /* Keep background fixed */
overflow: hidden; /* Clip the ::before pseudo-element */
}
/* Blur effect on the wrapper's ::before */
.song-list-background-wrapper::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
background-color: rgba(80, 80, 80, 0.1); /* Optional overlay */
border-radius: inherit; /* Inherit rounding */
z-index: 1; /* Below content */
pointer-events: none;
}
html.dark .song-list-background-wrapper::before {
background-color: rgba(255, 255, 255, 0.05); /* Dark mode overlay */
}
/* --- MODIFIED: Inner Scrolling Container --- */
.song-list-template {
height: 100%; /* Fill the wrapper */
overflow-y: auto; /* Enable vertical scrolling for content */
position: relative; /* Needed for z-index */
z-index: 2; /* Place above the ::before blur layer */
background: transparent !important; /* Ensure no background color obscures the wrapper */
border-radius: inherit; /* Inherit rounding for scrollbar area */
min-width: 400px;
/* Keep scrollbar styles */
&::-webkit-scrollbar { width: 8px; }
&::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.05); border-radius: 4px; }
&::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); border-radius: 4px; }
&::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.3); }
}
/* Dark mode scrollbar styles for the scrolling container */
html.dark .song-list-template {
&::-webkit-scrollbar-track { background: var(--scrollbar-color); }
&::-webkit-scrollbar-thumb { background: var(--scrollbar-color-hover); }
&::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-color-active); }
}
/* --- MODIFIED: Main Content Container --- */
.profile-card-container {
position: relative; /* Keep for potential absolute children if any */
/* z-index: 2; */ /* Removed: Handled by .song-list-template */
padding: 20px;
font-family: sans-serif;
color: #333333;
/* height: 100%; */ /* Removed: Let content define height */
min-height: 100%; /* Ensure it tries to fill the scroll container */
box-sizing: border-box; /* Include padding in height calculation */
}
html.dark .profile-card-container {
color: var(--text-color-1);
}
/* --- Profile Hover Area Styles (Unchanged) --- */
.profile-hover-area {
position: relative; display: flex; align-items: flex-start;
width: fit-content; min-width: 300px; margin: 0 auto 20px auto;
padding: 15px; border-radius: 15px;
transition: transform 0.4s ease-in-out; z-index: 100; /* High z-index for hover effect */
box-shadow: var(--box-shadow-1);
background-color: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(0, 0, 0, 0.08);
}
html.dark .profile-hover-area {
background-color: rgba(40, 40, 40, 0.75);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.profile-avatar {
width: 100px; height: 100px; border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.8);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
margin-right: 20px; position: relative; z-index: 10;
transition: transform 0.4s ease-in-out; cursor: pointer; flex-shrink: 0;
}
html.dark .profile-avatar {
border-color: rgba(255, 255, 255, 0.6);
}
.profile-info {
flex-grow: 1; min-width: 180px; text-align: left;
}
.profile-name {
margin-top: 5px; margin-bottom: 8px; font-size: 1.8em; font-weight: bold;
color: #1a1a1a;
}
html.dark .profile-name {
color: var(--text-color-base);
}
.profile-description {
margin-bottom: 10px; font-size: 0.95em; line-height: 1.4;
color: #4d4d4d;
}
html.dark .profile-description {
color: var(--text-color-2);
}
.profile-extra-info {
font-size: 0.8em; color: #666666;
}
html.dark .profile-extra-info {
color: var(--text-color-3);
}
.social-links {
position: absolute; top: 5px; left: calc(100px + 20px + 10px);
width: 380px; padding: 15px 20px; border-radius: 10px;
box-shadow: var(--box-shadow-2); z-index: 20; /* High z-index within hover area */
background-color: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.1);
opacity: 0; visibility: hidden; transform: translateX(20px);
transition: opacity 0.4s ease-in-out, visibility 0.4s ease-in-out, transform 0.4s ease-in-out, left 0.4s ease-in-out;
}
html.dark .social-links {
background-color: rgba(50, 50, 50, 0.85);
border-color: rgba(255, 255, 255, 0.15);
}
.social-links-title{ font-weight: bold; color: #4d4d4d;}
.social-links-subtitle{ white-space: pre-wrap; color: #4d4d4d;}
html.dark .social-links-title, html.dark .social-links-subtitle { color: var(--text-color-2); }
.social-icons-bar {
position: absolute; top: 15px; right: 20px; display: flex; gap: 8px;
}
.social-icons-bar .icon {
display: inline-block; width: 24px; height: 24px; border-radius: 4px;
font-size: 1em; cursor: pointer; color: #555555; transition: color 0.2s;
}
html.dark .social-icons-bar .icon { color: var(--text-color-2); }
.social-icons-bar .icon:hover { color: var(--primary-color); }
.social-icons-bar .icon svg { display: block; width: 100%; height: 100%; fill: currentColor; }
.social-grid {
display: grid; grid-template-columns: repeat(2, 1fr);
gap: 8px 15px; margin-top: 45px;
}
.social-link {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 8px; border-radius: 5px; font-size: 0.85em; text-decoration: none;
color: #0066cc; background-color: rgba(0, 102, 204, 0.1);
transition: background-color 0.2s ease, color 0.2s ease;
}
html.dark .social-link {
color: var(--primary-color-light);
background-color: rgba(var(--primary-color-rgb), 0.15);
border: 1px solid rgba(122, 159, 197, 0.2);
}
.social-link:hover { background-color: rgba(0, 102, 204, 0.2); }
html.dark .social-link:hover { background-color: rgba(var(--primary-color-rgb), 0.25); }
.social-link span:first-child { margin-right: 5px; }
.social-link .arrow { font-weight: bold; color: #aaaaaa; }
html.dark .social-link .arrow { color: var(--text-color-3); }
.profile-hover-area.is-hovering .profile-avatar { transform: translateX(-60px); }
.profile-hover-area.is-hovering .social-links {
opacity: 1; visibility: visible; transform: translateX(0);
left: calc(100px - 60px + 15px);
}
/* --- Song List Container Styles (Unchanged except background/border) --- */
.song-list-container {
padding: 15px 25px; border-radius: 15px; font-family: sans-serif;
box-shadow: var(--box-shadow-1);
background-color: rgba(255, 255, 255, 0.50); /* Matched profile */
border: 1px solid rgba(0, 0, 0, 0.08);
}
html.dark .song-list-container {
background-color: rgba(40, 40, 40, 0.75);
border-color: rgba(255, 255, 255, 0.1);
}
/* --- Table Styles (Unchanged) --- */
.song-table-wrapper {
overflow-y: auto; /* This overflow handles table content, not main scroll */
/* min-height: 200px; */ /* Might not be needed if max-height is set */
border-radius: 8px;
/* Scrollbar styling specific to this inner table scroll if needed */
scroll-behavior: smooth;
}
.song-list-table {
width: 100%; border-collapse: collapse; font-size: 0.9em;
min-width: 600px;
}
.song-list-table thead th {
position: sticky; top: 0; z-index: 1; /* Sticky within .song-table-wrapper */
padding: 10px 12px; text-align: left; font-weight: 600;
background-color: rgba(245, 245, 245, 0.8); /* Slightly less transparent */
backdrop-filter: blur(2px); /* Blur header slightly */
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
color: #444444;
user-select: none; /* Prevent text selection on click */
}
html.dark .song-list-table thead th {
background-color: rgba(55, 55, 55, 0.85); /* Dark mode header bg */
border-bottom-color: var(--border-color);
color: var(--text-color-2);
}
.song-list-table th:nth-child(1) { width: 22%; }
.song-list-table th:nth-child(2) { width: 15%; }
.song-list-table th:nth-child(3) { width: 10%; }
.song-list-table th:nth-child(4) { width: 13%; }
.song-list-table th:nth-child(5) { width: 15%; }
.song-list-table th:nth-child(6) { width: 25%; }
.song-list-table tbody tr { transition: background-color 0.15s ease; }
.song-list-table tbody td {
padding: 10px 12px; vertical-align: middle;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
color: #4d4d4d;
word-break: break-word; /* Prevent long text overflow */
}
html.dark .song-list-table tbody td {
border-bottom-color: var(--border-color);
color: var(--text-color-2);
}
.song-list-table tbody tr:nth-child(even) { background-color: rgba(0, 0, 0, 0.02); }
html.dark .song-list-table tbody tr:nth-child(even) { background-color: var(--item-color-striped); }
.song-list-table tbody tr:hover { background-color: rgba(0, 0, 0, 0.05); }
html.dark .song-list-table tbody tr:hover { background-color: var(--item-color-hover); }
.song-name {
display: flex; align-items: center; gap: 8px; font-weight: 600;
text-decoration: none; color: #2c2c2c;
}
html.dark .song-name { color: var(--text-color-1); }
.artist-link {
padding: 1px 0; cursor: pointer; text-decoration: none;
color: var(--text-color-2); /* Use theme primary for links */
transition: color 0.2s ease, text-decoration 0.2s ease;
}
.artist-link:hover { text-decoration: underline; }
.song-name :deep(.n-button .n-icon),
.song-name :deep(.n-button .svg-icon) {
color: currentColor !important; fill: currentColor !important;
}
.svg-icon {
display: inline-block; width: 1em; height: 1em; vertical-align: -0.15em;
fill: currentColor; overflow: hidden;
}
.no-results td {
padding: 30px 12px; text-align: center; font-style: italic; color: #999999;
}
html.dark .no-results td { color: var(--text-color-3); }
/* Ensure NTag in table wraps properly */
.song-list-table td .n-tag {
margin-bottom: 2px; /* Add slight spacing if tags wrap */
margin-right: 4px;
}
/* --- NEW: Selected Artist Highlight --- */
.artist-link.selected-artist {
background-color: var(--primary-color-a4); /* 增加背景不透明度 */
border: 1px solid var(--primary-color-a6); /* 添加边框 */
font-weight: bold;
padding: 1px 3px; /* 调整内边距,使边框更明显 */
border-radius: 4px; /* 轻微调整圆角 */
color: var(--primary-color-dark); /* 亮色模式下使用较深的主题色文字 */
/* text-decoration: underline; */ /* 如果需要可以取消注释 */
}
html.dark .artist-link.selected-artist {
background-color: var(--primary-color-a6); /* 增加背景不透明度 */
border: 1px solid var(--primary-color-a8); /* 添加边框 */
color: var(--primary-color-light); /* 暗色模式下使用亮色文字 */
}
/* --- END: Selected Artist Highlight --- */
/* Base style for clickable language */
.language-link {
padding: 1px 0;
cursor: pointer;
text-decoration: none;
color: var(--text-color-2); /* Use theme primary for links */
transition: color 0.2s ease, text-decoration 0.2s ease;
}
.language-link:hover {
text-decoration: underline;
}
/* --- NEW: Selected Artist/Language Highlight --- */
.artist-link.selected-artist,
.language-link.selected-language {
background-color: var(--primary-color-a4); /* 增加背景不透明度 */
border: 1px solid var(--primary-color-a6); /* 添加边框 */
font-weight: bold;
padding: 1px 3px; /* 调整内边距,使边框更明显 */
border-radius: 4px; /* 轻微调整圆角 */
color: var(--primary-color-dark); /* 亮色模式下使用较深的主题色文字 */
/* text-decoration: underline; */ /* 如果需要可以取消注释 */
}
html.dark .artist-link.selected-artist,
html.dark .language-link.selected-language {
background-color: var(--primary-color-a6); /* 增加背景不透明度 */
border: 1px solid var(--primary-color-a8); /* 添加边框 */
color: var(--primary-color-light); /* 暗色模式下使用亮色文字 */
}
/* --- END: Selected Artist/Language Highlight --- */
.song-name :deep(.n-button .n-icon),
.song-name :deep(.n-button .svg-icon) {
color: currentColor !important; fill: currentColor !important;
}
/* --- NEW: Style for empty placeholders --- */
.empty-placeholder {
color: #999999; /* Use a standard gray color */
font-style: italic; /* Optional: make it italic */
font-size: 0.9em; /* Optional: slightly smaller */
}
html.dark .empty-placeholder {
color: var(--text-color-3); /* Use theme variable for dark mode */
}
</style>