第一次提交

This commit is contained in:
2026-05-07 22:27:55 +08:00
commit 463e8e95b6
95 changed files with 13167 additions and 0 deletions

56
src/views/DailySongs.vue Normal file
View File

@ -0,0 +1,56 @@
<template>
<div class="p-8 text-white">
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
返回
</button>
<h1 class="text-2xl font-bold mb-6">每日推荐</h1>
<div v-if="loading" class="text-gray-400">加载中...</div>
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id"
@click="player.play(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
>
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
const player = usePlayerStore();
const songs = ref<any[]>([]);
const loading = ref(true);
onMounted(async () => {
try {
const jsonStr: string = await invoke('recommend_songs');
const data = JSON.parse(jsonStr);
songs.value = data.data?.dailySongs || [];
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
});
function formatDuration(ms: number): string {
const sec = Math.floor(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>

123
src/views/Discover.vue Normal file
View File

@ -0,0 +1,123 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
<!-- 搜索框 -->
<input
v-model="keyword"
@keyup.enter="handleSearch"
placeholder="搜索歌曲、歌手、专辑..."
class="mb-4 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
/>
<!-- 热门搜索标签仅在没有搜索且未显示结果时出现 -->
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in hotTags"
:key="tag.searchWord"
@click="searchTag(tag.searchWord)"
class="px-3 py-1 rounded-full bg-white/10 hover:bg-white/20 cursor-pointer transition text-sm"
>
{{ tag.searchWord }}
</span>
</div>
</div>
<!-- 输出设备选择 -->
<!-- <div class="mb-4">
<label class="mr-2 text-sm text-gray-400">输出设备</label>
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1 text-sm">
<option :value="null">跟随系统默认</option>
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div> -->
<!-- 搜索结果 -->
<div v-if="loading" class="text-gray-400">搜索中...</div>
<div v-else class="space-y-3">
<div
v-for="song in results"
:key="song.id"
@click="playSong(song)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-white/5 hover:bg-white/10 border border-white/5 cursor-pointer transition"
>
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
<div>
<p class="font-medium">{{ song.name }}</p>
<p class="text-sm text-gray-400">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'DiscoverView' });
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
const router = useRouter();
const route = useRoute();
const player = usePlayerStore();
const keyword = ref('');
const results = ref<any[]>([]);
const loading = ref(false);
const hasSearched = ref(false);
const hotTags = ref<any[]>([]);
const devices = ref<string[]>([]);
onMounted(async () => {
// 获取输出设备列表
try { devices.value = await invoke('get_output_devices'); } catch {}
// 获取热门搜索
try {
const json = await invoke('get_hot_search');
const data = JSON.parse(json as string);
hotTags.value = (data.data || []).slice(0, 12);
} catch {}
// 检查路由是否有查询关键词,自动搜索
const q = route.query.q as string;
if (q) {
keyword.value = q;
await handleSearch();
router.replace({ query: {} });
}
});
async function handleSearch() {
if (!keyword.value.trim()) return;
loading.value = true;
hasSearched.value = true;
try {
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
const data = JSON.parse(jsonStr);
results.value = data.result?.songs || [];
} catch (e) {
console.error('搜索出错:', e);
} finally {
loading.value = false;
}
}
function searchTag(tag: string) {
keyword.value = tag;
handleSearch();
}
async function playSong(song: any) {
player.play(song);
}
</script>

View File

@ -0,0 +1,6 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4"> 我喜欢的音乐</h1>
<p class="text-gray-400">正在施工...</p>
</div>
</template>

204
src/views/Home.vue Normal file
View File

@ -0,0 +1,204 @@
<template>
<div class="p-8 text-white">
<!-- 第一行每日推荐 & 私人漫游 卡片 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
<!-- 每日推荐 -->
<div
class="h-48 bg-gradient-to-br from-pink-600 to-purple-700 rounded-3xl overflow-hidden relative cursor-pointer group"
@click="goDaily"
>
<div class="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition"></div>
<div class="relative z-10 p-6 flex flex-col justify-between h-full">
<div>
<p class="text-xs text-white/60 mb-1">📅 {{ todayStr }}</p>
<h2 class="text-2xl font-bold">每日推荐</h2>
</div>
<p class="text-xs text-white/60">根据你的口味生成每天 6:00 更新</p>
</div>
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20">🎧</div>
</div>
<!-- 私人漫游 卡片 -->
<!-- 私人漫游 卡片 -->
<div
class="h-48 bg-gradient-to-br from-blue-600 to-cyan-500 rounded-3xl overflow-hidden relative group select-none"
@click="!userStore.isLoggedIn ? goLogin() : null"
>
<!-- 模糊封面层仅在有歌曲且有封面时显示低透明度模糊 -->
<div
v-if="player.fmSong && fmCoverUrl"
class="absolute inset-0 bg-cover bg-center opacity-30 blur-md scale-110"
:style="{ backgroundImage: `url(${fmCoverUrl})` }"
></div>
<!-- 遮罩 -->
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition"></div>
<!-- 内容 -->
<div class="relative z-10 h-full">
<!-- 未登录 -->
<div v-if="!userStore.isLoggedIn" class="flex flex-col items-center justify-center h-full">
<p class="text-xs text-white/60 mb-1">🌀 一键探索</p>
<h2 class="text-2xl font-bold">私人漫游</h2>
<p class="text-xs text-white/60 mt-2">登录后即可开启沉浸式音乐探索</p>
</div>
<!-- 登录后无歌曲 垂直居中播放按钮 -->
<div
v-else-if="!player.fmSong"
class="flex flex-col items-center justify-center h-full gap-3 cursor-pointer"
@click.stop="startFmPlay"
>
<p class="text-xs text-white/60">🌀 一键探索</p>
<h2 class="text-2xl font-bold">私人漫游</h2>
<button
class="w-12 h-12 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition mt-2"
>
<svg width="22" height="22" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
</div>
<!-- 有歌曲 横向布局左侧信息右侧按钮 -->
<div v-else class="flex items-center justify-between h-full px-6 cursor-pointer" @click.stop="player.toggleFm">
<!-- 左侧封面 + 歌曲信息 -->
<div class="flex items-center gap-3 min-w-0">
<img :src="fmCoverUrl" class="w-14 h-14 rounded-xl object-cover flex-shrink-0" />
<div class="min-w-0">
<p class="text-sm font-semibold truncate">{{ fmDisplayName }}</p>
<p class="text-xs text-white/70 truncate">{{ fmDisplayArtists }}</p>
</div>
</div>
<!-- 右侧控制按钮 -->
<div class="flex items-center gap-3 ml-4">
<button @click.stop="player.toggleFm"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition"
>
<svg v-if="player.fmPlaying" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<svg v-else width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button @click.stop="player.nextFm" class="text-xl text-white/80 hover:text-white transition"></button>
</div>
</div>
</div>
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20 pointer-events-none">🌊</div>
</div>
</div>
<!-- 第二行为你推荐需登录 -->
<div v-if="userStore.isLoggedIn && recPlaylists.length" class="mb-10">
<h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="pl in recPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer">
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
<div class="p-3">
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
<p class="text-xs text-gray-400 mt-1">{{ pl.copywriter || '' }}</p>
</div>
</div>
</div>
</div>
<!-- 第三行热门歌单排行榜 -->
<div>
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="pl in rankPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer backdrop-blur-sm">
<img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
<div class="p-3">
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { useUserStore } from '../stores/user';
import { usePlayerStore } from '../stores/player';
const player = usePlayerStore();
const router = useRouter();
const userStore = useUserStore();
const rankPlaylists = ref<any[]>([]);
const recPlaylists = ref<any[]>([]);
const todayStr = ref('');
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
import { computed } from 'vue';
const fmCoverUrl = computed(() => {
return player.fmSong?.al?.picUrl || player.fmSong?.album?.picUrl || '';
});
const fmDisplayName = computed(() => player.fmSong?.name || '私人漫游');
const fmDisplayArtists = computed(() => {
if (!player.fmSong) return '';
return player.fmSong.ar?.map((a: any) => a.name).join(' / ') ||
player.fmSong.artists?.map((a: any) => a.name).join(' / ') || '';
});
// 首次点击播放按钮:开始 FM 并播放
async function startFmPlay() {
// 如果还没加载过 FM或者之前加载了但被停止了重新加载
if (!player.fmSong) {
await player.loadFm(); // loadFm 内部会设置 fmSong 并播放
} else {
// 已有歌曲但未播放状态(比如之前暂停/停止了),直接播放
await player.toggleFm();
}
}
onMounted(async () => {
const d = new Date();
todayStr.value = `${d.getMonth() + 1}${d.getDate()}`;
// 排行榜
const results = await Promise.allSettled(
RANK_IDS.map(id => invoke('get_playlist_detail', { id }))
);
rankPlaylists.value = results
.filter(r => r.status === 'fulfilled')
.map((r: any) => {
const data = JSON.parse(r.value);
return data.playlist;
})
.filter(Boolean);
// 推荐歌单(需登录)
if (userStore.isLoggedIn) {
try {
const json = await invoke('recommend_resource');
const data = JSON.parse(json as string);
recPlaylists.value = data.recommend || [];
} catch { }
}
});
function goDaily() {
router.push('/daily');
}
function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
function goLogin() {
router.push('/login');
}
</script>

148
src/views/Login.vue Normal file
View File

@ -0,0 +1,148 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-950 text-white">
<div class="bg-white/5 backdrop-blur-md border border-white/10 p-8 rounded-2xl w-full max-w-sm text-center">
<h1 class="text-xl font-bold mb-4">扫码登录</h1>
<p class="text-sm text-gray-400 mb-6">请使用网易云音乐 App 扫描二维码</p>
<!-- 二维码展示区 -->
<div v-if="qrimg" class="bg-white p-3 rounded-xl inline-block mb-4">
<img :src="qrimg" alt="二维码" class="w-48 h-48" />
</div>
<div v-else class="w-48 h-48 bg-white/5 rounded-xl flex items-center justify-center mx-auto mb-4">
<span v-if="qrLoading" class="text-gray-400">加载中...</span>
<span v-else-if="qrError" class="text-red-400 text-sm">{{ qrError }}</span>
</div>
<!-- 状态提示 -->
<p class="text-sm" :class="statusColor">{{ statusText }}</p>
<button @click="refreshQr" class="mt-4 text-xs text-green-400 hover:underline">重新获取二维码</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { useRouter } from 'vue-router';
import { useUserStore } from '../stores/user';
import QRCode from 'qrcode';
const router = useRouter();
const userStore = useUserStore();
const qrimg = ref('');
const qrLoading = ref(true);
const qrError = ref('');
const statusText = ref('等待扫码...');
const statusColor = ref('text-gray-400');
let qrKey = '';
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMounted(async () => {
if (userStore.isLoggedIn) {
router.push('/');
return;
}
await refreshQr();
});
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer);
});
async function refreshQr() {
qrLoading.value = true;
qrError.value = '';
if (pollTimer) clearInterval(pollTimer);
try {
// 1. 获取 unikey
qrKey = await invoke('get_qr_key');
if (!qrKey) {
qrError.value = '未获取到登录密钥';
qrLoading.value = false;
return;
}
// 2. 拼接网易云标准扫码链接(无需 create_qr
const qrUrl = `https://music.163.com/login?codekey=${qrKey}&type=1`;
// 3. 用 qrcode 生成二维码图片
const canvas = document.createElement('canvas');
await QRCode.toCanvas(canvas, qrUrl, { width: 200, margin: 1 });
qrimg.value = canvas.toDataURL('image/png');
qrLoading.value = false;
// 4. 开始轮询状态
startPolling();
} catch (e: any) {
qrError.value = '获取二维码失败';
qrLoading.value = false;
}
}
// 新增函数:用 Canvas 生成二维码并赋值给 qrimg
// async function drawQrCode(url: string) {
// try {
// // 等待 DOM 准备好 canvas 元素
// const canvas = document.createElement('canvas');
// await QRCode.toCanvas(canvas, url, { width: 201, margin: 1 });
// // 转为 data URL 赋值给响应式的图片地址
// qrimg.value = canvas.toDataURL('image/png');
// } catch (e) {
// console.error('生成二维码失败', e);
// qrError.value = '生成二维码失败';
// }
// }
function startPolling() {
pollTimer = setInterval(async () => {
try {
const jsonStr: string = await invoke('check_qr_status', { query: { key: qrKey } });
const data = JSON.parse(jsonStr);
const code = data.code;
if (code === 800) {
statusText.value = '二维码已过期,请刷新';
statusColor.value = 'text-red-400';
clearInterval(pollTimer!);
} else if (code === 801) {
statusText.value = '等待扫码...';
statusColor.value = 'text-gray-400';
} else if (code === 802) {
statusText.value = '请在手机上确认登录';
statusColor.value = 'text-yellow-400';
} else if (code === 803) {
// 登录成功
clearInterval(pollTimer!);
statusText.value = '登录成功!';
statusColor.value = 'text-green-400';
// 存储 cookie 到 NcmApi后台线程中自动保留后续请求都带登录态
// 获取用户信息(简化,可从 /login/status 获取)
// 这里需要额外调用获取用户详情的 API但因为 NcmApi 已有 cookie可以直接在后台线程中添加
// 暂时用简易方式:调用 /user/account 获取用户简档
await fetchUserProfile();
setTimeout(() => router.push('/'), 500);
}
} catch (e) {
console.error('轮询失败', e);
}
}, 3000);
}
async function fetchUserProfile() {
try {
// 添加一个快速获取用户信息的命令(可复用之前的 login 命令中获取 profile 的逻辑)
// 这里简化,由于后台 NcmApi 已有 cookie我们可以直接用 reqwest 调 /user/account
// 但最好添加一个新命令,这里直接调用现有的 login 逻辑不适用,因此我们在 Rust 侧添加一个 get_login_status 命令
const profileJson: string = await invoke('get_login_status');
const profile = JSON.parse(profileJson);
if (profile.profile) {
userStore.setUser({
userId: profile.profile.userId,
nickname: profile.profile.nickname,
avatarUrl: profile.profile.avatarUrl,
});
}
} catch (e) {
console.error('获取用户信息失败', e);
}
}
</script>

View File

@ -0,0 +1,91 @@
<template>
<div class="p-8 text-white">
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
返回
</button>
<!-- 歌单信息 -->
<div v-if="playlist" class="flex gap-6 mb-8">
<img :src="playlist.coverImgUrl" class="w-40 h-40 rounded-xl object-cover shadow-lg" />
<div>
<h1 class="text-2xl font-bold">{{ playlist.name }}</h1>
<p class="text-sm text-gray-400 mt-2">{{ playlist.description }}</p>
<p class="text-xs text-gray-500 mt-2">
{{ playlist.trackCount }} 首歌曲 · 播放 {{ playlist.playCount }}
</p>
<button
@click="playAll"
class="mt-4 px-4 py-2 bg-green-500 hover:bg-green-600 rounded-full text-white font-medium transition"
>
播放全部
</button>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="text-gray-400">加载中...</div>
<!-- 歌曲列表 -->
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id"
@click="playSingle(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
>
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
const route = useRoute();
const player = usePlayerStore();
const playlist = ref<any>(null);
const songs = ref<any[]>([]);
const loading = ref(true);
onMounted(async () => {
const id = Number(route.params.id);
try {
const jsonStr: string = await invoke('get_playlist_detail', { id });
const data = JSON.parse(jsonStr);
playlist.value = data.playlist;
songs.value = data.playlist.tracks || [];
} catch (e) {
console.error('获取歌单详情失败', e);
} finally {
loading.value = false;
}
});
function formatDuration(ms: number): string {
const sec = Math.floor(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
async function playSingle(song: any) {
player.play(song);
}
function playAll() {
if (songs.value.length === 0) return;
player.playAll(songs.value);
}
</script>

View File

@ -0,0 +1,6 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4">🕐 最近播放</h1>
<p class="text-gray-400">正在施工...</p>
</div>
</template>

126
src/views/Roam.vue Normal file
View File

@ -0,0 +1,126 @@
<template>
<div class="p-8 text-white flex flex-col items-center justify-center min-h-full">
<!-- 无歌曲时提示 -->
<div v-if="!currentSong" class="text-center">
<p class="text-gray-400 mb-4">私人漫游未启动</p>
<button
@click="startFm"
class="px-6 py-2 bg-white/10 hover:bg-white/20 rounded-full transition"
>
开始漫游
</button>
</div>
<!-- 歌曲信息展示 -->
<template v-else>
<!-- 专辑封面 -->
<img
:src="currentSong.al?.picUrl || currentSong.album?.picUrl"
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
/>
<!-- 歌曲名和艺术家 -->
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
<p class="text-lg text-gray-400 mb-8">
{{ artists }}
</p>
<!-- 控制按钮 -->
<div class="flex items-center gap-8">
<button
@click="togglePlay"
class="w-16 h-16 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20"
>
<!-- 暂停图标 -->
<svg v-if="player.playing" width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<!-- 播放图标 -->
<svg v-else width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button
@click="nextSong"
class="text-3xl text-gray-400 hover:text-white transition"
>
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { usePlayerStore } from '../stores/player';
import { invoke } from '@tauri-apps/api/core';
const player = usePlayerStore();
// 当前正在播放的歌曲如果处于FM模式则显示当前歌曲
const currentSong = computed(() => {
// FM 模式下直接显示正在播放的歌曲可能是FM歌曲
if (player.isFmMode && player.currentSong) {
return player.currentSong;
}
return null;
});
const artists = computed(() => {
if (!currentSong.value) return '';
return currentSong.value.ar?.map((a: any) => a.name).join(' / ') ||
currentSong.value.artists?.map((a: any) => a.name).join(' / ') || '';
});
// 进入页面时如果FM未启动自动开始
onMounted(async () => {
if (!player.isFmMode || !player.currentSong) {
await startFm();
}
});
async function startFm() {
try {
const jsonStr: string = await invoke('personal_fm');
const data = JSON.parse(jsonStr);
const songs = data.data || data;
if (songs && songs.length > 0) {
const song = normalizeSong(songs[0]);
player.enableFmMode(nextSong);
await player.playFmSong(song);
}
} catch (e) {
console.error('启动漫游失败', e);
}
}
function normalizeSong(song: any) {
const normalized = { ...song };
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
}
if (!normalized.ar || normalized.ar.length === 0) {
normalized.ar = normalized.artists || [];
}
return normalized;
}
async function togglePlay() {
if (player.playing) {
await invoke('pause_audio');
} else {
if (player.currentSong) {
// 恢复播放
await invoke('resume_audio');
} else {
await startFm();
}
}
}
async function nextSong() {
await startFm();
}
</script>

108
src/views/Search.vue Normal file
View File

@ -0,0 +1,108 @@
<template>
<div class="text-white">
<h1 class="text-2xl font-bold mb-4">搜索</h1>
<!-- 输出设备选择-->
<!-- <div class="mb-4">
<label class="mr-2">输出设备</label>
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1">
<option :value="null">跟随系统默认</option>
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div> -->
<input
v-model="keyword"
@keyup.enter="handleSearch"
placeholder="搜索歌曲..."
class="mb-6 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
/>
<div v-if="loading" class="text-gray-400">搜索中...</div>
<div v-else class="space-y-3">
<div
v-for="song in results"
:key="song.id"
@click="playSong(song)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-white/5 hover:bg-white/10 border border-white/5 cursor-pointer transition-all duration-200 hover:scale-[1.01] active:scale-95"
>
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
<div>
<p class="font-medium">{{ song.name }}</p>
<p class="text-sm text-gray-400">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'SearchView' });
import { useRoute } from 'vue-router';
import { watch } from 'vue';
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useRouter } from 'vue-router';
const router = useRouter();
const keyword = ref('');
const results = ref<any[]>([]);
const loading = ref(false);
const hasSearched = ref(false);
const player = usePlayerStore();
const route = useRoute();
// 监听从首页或其他地方传来的 query 参数,自动搜索
watch(
() => route.query.q,
(newQ) => {
if (newQ) {
keyword.value = newQ as string;
handleSearch();
// 清除 query防止刷新后重复搜索
router.replace({ query: {} });
}
},
{ immediate: true }
);
async function handleSearch() {
if (!keyword.value.trim()) return;
loading.value = true;
hasSearched.value = true;
try {
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
const data = JSON.parse(jsonStr);
results.value = data.result?.songs || [];
} catch (e) {
console.error('搜索出错:', e);
} finally {
loading.value = false;
}
}
async function playSong(song: any) {
try {
await player.play(song);
} catch (e) {
alert('暂无播放源或需登录');
}
}
const devices = ref<string[]>([]);
// const selectedDevice = ref<string | null>(null);
onMounted(async () => {
devices.value = await invoke('get_output_devices');
});
// async function changeDevice() {
// await invoke('set_output_device', { device: selectedDevice.value });
// }
</script>