初始化提交

This commit is contained in:
史悦
2026-01-07 16:46:09 +08:00
commit 0dbb36be9d
113 changed files with 16197 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
NODE_ENV = 'development'
VITE_APP_MODE = 'development'
VITE_APP_API_URL = 'http://172.16.252.1:5566/api'
VITE_APP_API_URL = 'http://10.0.0.2:5566/api'
VITE_APP_API_URL = 'http://127.0.0.1:5566/api'

3
frontend/.env.production Normal file
View File

@@ -0,0 +1,3 @@
NODE_ENV = 'production'
VITE_APP_MODE = 'production'
VITE_APP_API_URL = '/api'

1
frontend/.nvmrc Normal file
View File

@@ -0,0 +1 @@
v16.13.0

25
frontend/index.html Normal file
View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="referrer" content="never" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" integrity="sha512-NhSC1YmyruXifcj/KFRWoC561YpHpc5Jtzgvbuzx5VozKpWvQ+4nXhPdFgmx8xqexRcpAglTj9sIBWINXa8x5w==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.8.1/font/bootstrap-icons.min.css" integrity="sha512-Oy+sz5W86PK0ZIkawrG0iv7XwWhYecM3exvUtMKNJMekGFJtVAhibhRPTpmyTj8+lJCkmWfnpxKgT2OopquBHA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Melody - 我的音乐精灵</title>
</head>
<body>
<div id="app"></div>
<script type="text/javascript">
if (/phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone/i.test(
navigator.userAgent)) {
window.location.href = "/mobile.html";
}
</script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

30
frontend/mobile.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="referrer" content="never" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" integrity="sha512-NhSC1YmyruXifcj/KFRWoC561YpHpc5Jtzgvbuzx5VozKpWvQ+4nXhPdFgmx8xqexRcpAglTj9sIBWINXa8x5w==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.8.1/font/bootstrap-icons.min.css" integrity="sha512-Oy+sz5W86PK0ZIkawrG0iv7XwWhYecM3exvUtMKNJMekGFJtVAhibhRPTpmyTj8+lJCkmWfnpxKgT2OopquBHA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<!-- <script type="text/javascript" src="https://vncdn.mobi88.cn/public/vconsole.min.js"></script> -->
<title>Melody - 我的音乐精灵</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/mobile.js"></script>
</body>
<style>
.popover-overlay {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
<script>
// var vConsole = new VConsole();
</script>
</html>

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "melody-frontend",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^1.1.4",
"axios": "0.26.1",
"element-plus": "2.1.9",
"howler": "github:foamzou/howler.js#0.0.1-foam",
"vant": "^3.4.9",
"vue": "3.2.33",
"vue-router": "4.0.12",
"vue-virtual-scroller": "2.0.0-alpha.1",
"vuex": "4.0.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^2.3.1",
"rollup": "^2.70.2",
"vite": "^2.9.14",
"vite-plugin-cdn-import": "^0.3.5",
"vite-plugin-pwa": "^0.12.3",
"workbox-window": "^6.5.4"
}
}

3690
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
frontend/public/melody.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

451
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,451 @@
<template>
<div class="common-layout">
<el-container>
<el-header height="120px" style="padding: 0">
<el-row>
<el-col :span="24">
<el-row>
<el-col :span="2" :offset="10">
<el-image src="/melody.png" style="width: 90px; height: 90px" />
</el-col>
<el-col :span="5" style="text-align: left; margin-top: 28px">
<el-row>
<span style="font-size: 30px; font-weight: bold">Melody</span>
</el-row>
<el-row>
<span style="font-size: 12px; color: grey; margin-left: 25px"
>我的音乐精灵</span
>
</el-row>
</el-col>
<el-col :span="7">
<a
href="https://github.com/foamzou/melody"
class="github-corner"
aria-label="View source on GitHub"
><svg
width="80"
height="80"
viewBox="0 0 250 250"
style="
fill: #151513;
color: #fff;
position: absolute;
top: 0;
border: 0;
right: 0;
"
aria-hidden="true"
>
<path
d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"
></path>
<path
d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
fill="currentColor"
style="transform-origin: 130px 106px"
class="octo-arm"
></path>
<path
d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
fill="currentColor"
class="octo-body"
></path></svg
></a>
</el-col>
</el-row>
</el-col>
</el-row>
<el-row class="nav-container">
<el-col :span="12" :offset="6">
<div class="nav-menu">
<div
v-for="(item, index) in navItems"
:key="index"
class="nav-item"
:class="{ active: currentPath === item.path }"
@click="navigate(item.path)"
>
<i :class="item.icon"></i>
<span>{{ item.label }}</span>
</div>
</div>
</el-col>
</el-row>
</el-header>
<router-view
:playTheSong="playTheSong"
:playTheSongWithPlayUrl="playTheSongWithPlayUrl"
:abortTheSong="abortTheSong"
v-slot="{ Component }"
>
<transition>
<keep-alive>
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
<el-footer
v-if="playerSongInfo.playUrl"
height="70px"
class="player-footer"
>
<div class="player-container">
<el-row align="middle" class="player-content">
<!-- 左侧封面和歌曲信息 -->
<el-col :span="6" class="song-info">
<div class="cover-image">
<el-image
:src="playerSongInfo.coverUrl"
fit="cover"
class="cover"
/>
</div>
<div class="song-details">
<div class="song-name">{{ playerSongInfo.songName }}</div>
<div class="artist-name">{{ playerSongInfo.artist }}</div>
</div>
</el-col>
<!-- 中间播放器控件 -->
<el-col :span="12" class="player-controls">
<audio
id="audio"
autoplay
:src="playerSongInfo.playUrl"
controls="controls"
class="audio-player"
/>
</el-col>
<!-- 右侧操作按钮 -->
<el-col :span="6" class="operation-buttons">
<el-tooltip
:content="
wyAccount
? '上传歌曲到云盘'
: '上传歌曲到云盘(请先绑定网易云账号)'
"
placement="top"
>
<el-button
circle
class="operation-btn"
:disabled="!playerSongInfo.pageUrl || !wyAccount"
@click="
uploadToCloud(
playerSongInfo.pageUrl,
playerSongInfo.suggestMatchSongId
)
"
>
<i class="bi bi-cloud-upload"></i>
</el-button>
</el-tooltip>
<el-tooltip content="在源站查看" placement="top">
<el-button
circle
class="operation-btn"
:disabled="!playerSongInfo.pageUrl"
@click="window.open(playerSongInfo.pageUrl, '_blank')"
>
<i class="bi bi-box-arrow-up-right"></i>
</el-button>
</el-tooltip>
</el-col>
</el-row>
</div>
</el-footer>
</el-container>
</div>
</template>
<script>
import { getPlayUrl, getSongsMeta, createSyncSongFromUrlJob } from "./api";
import { startTaskListener } from "./components/TaskNotification";
import storage from "./utils/storage";
import { getProperPlayUrl } from "./utils/audio";
export default {
data: () => {
return {
playerSongInfo: {
songName: "",
artist: "",
coverUrl: "/melody.png",
playUrl: "",
pageUrl: "",
suggestMatchSongId: "",
},
wyAccount: null,
navItems: [
{ label: "搜索", path: "/", icon: "bi bi-search" },
{ label: "我的歌单", path: "/playlist", icon: "bi bi-music-note-list" },
{ label: "我的音乐账号", path: "/account", icon: "bi bi-person" },
{ label: "设置", path: "/setting", icon: "bi bi-gear" },
],
currentPath: "/",
};
},
mounted() {
this.wyAccount = storage.get("wyAccount");
},
watch: {
$route(to) {
this.wyAccount = storage.get("wyAccount");
this.currentPath = to.path;
},
},
methods: {
async uploadToCloud(pageUrl, suggestMatchSongId) {
const ret = await createSyncSongFromUrlJob(pageUrl, suggestMatchSongId);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
search() {
this.$router.push("/");
},
account() {
this.$router.push("/account");
},
playlist() {
this.$router.push("/playlist");
},
setting() {
this.$router.push("/setting");
},
async playTheSong(metaInfo, pageUrl, suggestMatchSongId) {
console.log("------------------------");
console.log(metaInfo);
console.log(pageUrl);
let info = metaInfo;
if (!info) {
const ret = await getSongsMeta({ url: pageUrl });
info = ret.data.songMeta;
console.log(ret);
}
const resourceForbidden = info.resourceForbidden;
const songUrl = info.audios[0].url;
console.log("play: ", songUrl);
this.playerSongInfo.playUrl = getProperPlayUrl(
info.source,
songUrl,
pageUrl || info.pageUrl
);
this.playerSongInfo.coverUrl = info.coverUrl;
this.playerSongInfo.songName = info.songName;
this.playerSongInfo.artist = info.artist;
this.playerSongInfo.pageUrl = info.pageUrl || pageUrl;
this.playerSongInfo.suggestMatchSongId = suggestMatchSongId;
},
async playTheSongWithPlayUrl(playOption) {
if (!playOption.playUrl) {
const playUrlRet = await getPlayUrl(playOption.songId);
if (playUrlRet.data.playUrl) {
playOption.playUrl = playUrlRet.data.playUrl;
}
}
this.playerSongInfo.playUrl = getProperPlayUrl(
playOption.source,
playOption.playUrl,
playOption.pageUrl
);
this.playerSongInfo.coverUrl = playOption.coverUrl;
this.playerSongInfo.songName = playOption.songName;
this.playerSongInfo.artist = playOption.artist;
this.playerSongInfo.pageUrl = playOption.pageUrl;
return true;
},
abortTheSong() {
this.playerSongInfo.playUrl = "";
},
navigate(path) {
this.$router.push(path);
},
},
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 3px;
}
.nav span {
font-size: 18px;
}
.el-dialog {
margin-top: 30px;
}
.github-corner:hover .octo-arm {
animation: octocat-wave 560ms ease-in-out;
}
@keyframes octocat-wave {
0%,
100% {
transform: rotate(0);
}
20%,
60% {
transform: rotate(-25deg);
}
40%,
80% {
transform: rotate(10deg);
}
}
@media (max-width: 500px) {
.github-corner:hover .octo-arm {
animation: none;
}
.github-corner .octo-arm {
animation: octocat-wave 560ms ease-in-out;
}
}
<style scoped > .nav-container {
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
padding: 8px 0;
margin-top: 10px;
}
.nav-menu {
display: flex;
justify-content: space-around;
align-items: center;
}
.nav-item {
display: flex;
align-items: center;
padding: 8px 20px;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
color: #606266;
}
.nav-item:hover {
color: #409eff;
background: rgba(64, 158, 255, 0.1);
}
.nav-item.active {
color: #409eff;
background: rgba(64, 158, 255, 0.1);
font-weight: 500;
}
.nav-item i {
font-size: 18px;
margin-right: 6px;
}
.nav-item span {
font-size: 15px;
}
.player-footer {
position: fixed;
bottom: 0;
width: 100%;
background: linear-gradient(to right, #1a1a1a, #2d2d2d);
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 100;
padding: 0;
}
.player-container {
max-width: 1400px;
margin: 0 auto;
height: 100%;
}
.player-content {
height: 70px;
padding: 0 20px;
}
.song-info {
display: flex;
align-items: center;
gap: 15px;
}
.cover-image {
width: 50px;
height: 50px;
border-radius: 6px;
overflow: hidden;
}
.cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.song-details {
color: #fff;
}
.song-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.artist-name {
font-size: 12px;
color: #a8a8a8;
}
.player-controls {
display: flex;
align-items: center;
justify-content: center;
}
.audio-player {
width: 100%;
max-width: 600px;
height: 32px;
border-radius: 16px;
}
.player-footer .operation-btn {
border: 1px solid rgba(255, 255, 255, 0.2);
color: #000000;
transition: all 0.3s;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
}
&:disabled {
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.3);
}
i {
font-size: 16px;
}
}
</style>

234
frontend/src/Mobile.vue Normal file
View File

@@ -0,0 +1,234 @@
<template>
<div class="common-layout">
<van-row>
<van-col span="8">
<van-image src="/melody.png" style="width: 60px; height: 60px" />
</van-col>
<van-col span="8" style="margin-top: 15px">
<van-row>
<span style="font-size: 20px; font-weight: bold">Melody</span>
</van-row>
<van-row>
<span style="font-size: 12px; color: grey">我的音乐精灵</span>
</van-row>
</van-col>
<van-col span="8">
<a
href="https://github.com/foamzou/melody"
class="github-corner"
aria-label="View source on GitHub"
><svg
width="80"
height="80"
viewBox="0 0 250 250"
style="
fill: #151513;
color: #fff;
position: absolute;
top: 0;
border: 0;
right: 0;
"
aria-hidden="true"
>
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
<path
d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
fill="currentColor"
style="transform-origin: 130px 106px"
class="octo-arm"
></path>
<path
d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
fill="currentColor"
class="octo-body"
></path></svg
></a>
</van-col>
</van-row>
<router-view
:playTheSong="playTheSong"
:playTheSongWithPlayUrl="playTheSongWithPlayUrl"
v-slot="{ Component }"
:style="
songInfos.length > 0 ? 'margin-bottom: 136px;' : 'margin-bottom: 60px;'
"
>
<transition>
<keep-alive>
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
<div
v-show="songInfos.length > 0"
style="
padding-bottom: 5px;
height: 80px;
width: 100%;
position: fixed;
bottom: 50px;
left: 0;
background-color: white;
z-index: 10;
"
>
<Player
:songInfos="songInfos"
:currentSongIndex="currentSongIndex"
:changedTime="changedTime"
></Player>
</div>
<van-tabbar v-model="active" route>
<van-tabbar-item icon="search" to="/" @click="search()"
>搜索</van-tabbar-item
>
<van-tabbar-item icon="like-o" to="/playlist" @click="playlist()"
>歌单</van-tabbar-item
>
<van-tabbar-item icon="contact" to="/account" @click="account()"
>音乐账号</van-tabbar-item
>
</van-tabbar>
</div>
</template>
<script>
import { ref } from "vue";
import {
searchSongs,
getSongsMeta,
createSyncSongFromUrlJob,
getPlayUrl,
} from "./api";
import { startTaskListener } from "./components/TaskNotification";
import Player from "./components/Player.vue";
import storage from "./utils/storage";
import { Notify } from "vant";
import { getProperPlayUrl } from "./utils/audio";
export default {
setup() {
const active = ref(0);
return {
active,
};
},
components: {
Player,
},
data: () => {
return {
songInfos: [],
currentSongIndex: 0,
changedTime: 0,
wyAccount: null,
};
},
mounted() {
this.wyAccount = storage.get("wyAccount");
},
watch: {
$route(to, from) {
this.wyAccount = storage.get("wyAccount");
},
},
methods: {
search() {
this.$router.push("/");
},
account() {
this.$router.push("/account");
},
playlist() {
this.$router.push("/playlist");
},
async playTheSong(metaInfo, pageUrl, suggestMatchSongId) {
console.log("------------------------");
console.log(metaInfo);
console.log(pageUrl);
let info = metaInfo;
if (!info) {
const ret = await getSongsMeta({ url: pageUrl });
info = ret.data.songMeta;
console.log(ret);
}
const songUrl = info.audios[0].url;
console.log("play: ", songUrl);
// 处理播放 URL
const processedPlayUrl = getProperPlayUrl(
info.source,
songUrl,
pageUrl || info.pageUrl
);
this.songInfos.push({
playUrl: processedPlayUrl,
coverUrl: info.coverUrl,
songName: info.songName,
artist: info.artist,
pageUrl: info.pageUrl || pageUrl,
suggestMatchSongId,
});
this.currentSongIndex = this.songInfos.length - 1;
this.changedTime = new Date().getTime();
},
async playTheSongWithPlayUrl(playOption) {
if (!playOption.playUrl) {
const playUrlRet = await getPlayUrl(playOption.songId);
if (!playUrlRet.data.playUrl) {
Notify({ type: "warning", message: "获取播放链接失败" });
return false;
}
playOption.playUrl = playUrlRet.data.playUrl;
}
this.songInfos.push(playOption);
this.currentSongIndex = this.songInfos.length - 1;
this.changedTime = new Date().getTime();
return true;
},
},
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 3px;
}
.github-corner:hover .octo-arm {
animation: octocat-wave 560ms ease-in-out;
}
@keyframes octocat-wave {
0%,
100% {
transform: rotate(0);
}
20%,
60% {
transform: rotate(-25deg);
}
40%,
80% {
transform: rotate(10deg);
}
}
@media (max-width: 500px) {
.github-corner:hover .octo-arm {
animation: none;
}
.github-corner .octo-arm {
animation: octocat-wave 560ms ease-in-out;
}
}
</style>

74
frontend/src/api/axios.js Normal file
View File

@@ -0,0 +1,74 @@
import axios from "axios";
const axiosApiInstance = axios.create();
import storage from "../utils/storage"
axiosApiInstance.defaults.baseURL = import.meta.env.VITE_APP_API_URL
//post请求头
//允许跨域携带cookie信息
axiosApiInstance.defaults.withCredentials = true;
//设置超时
axiosApiInstance.defaults.timeout = 12000;
axiosApiInstance.interceptors.request.use(
config => {
config.headers = {
'mk': (config.params && config.params['mk']) ? config.params['mk'] : storage.get('mk')
}
return config;
},
error => {
return Promise.reject(error);
}
);
axiosApiInstance.interceptors.response.use(
response => {
return Promise.resolve(response);
},
error => {
// 返回错误响应中的数据
if (error.response && error.response.data) {
return Promise.resolve(error.response);
}
// 如果没有response.data返回一个统一的错误格式
return Promise.resolve({
data: {
code: -1,
message: error.message || '网络错误'
}
});
}
);
export const post = (url, data) => {
return new Promise((resolve, reject) => {
axiosApiInstance({
method: 'post',
url,
data,
})
.then(res => {
resolve(res ? res.data : false)
})
.catch(err => {
reject(err.data)
});
})
};
export const get = (url, data) => {
return new Promise((resolve, reject) => {
axiosApiInstance({
method: 'get',
url,
params: data,
})
.then(res => {
resolve(res ? res.data : false)
})
.catch(err => {
reject(err)
})
})
}

78
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,78 @@
import { get, post} from "./axios";
export const searchSongs = data => get("/songs", data);
export const getSongsMeta = data => get("/songs-meta", data);
export const getPlayUrl = songId => get(`/songs/netease/${songId}/playUrl`);
export const getAccount = data => get("/account", data);
export const setAccount = data => post("/account", data);
export const qrLoginCreate = _ => get("/account/qrlogin-create", {});
export const qrLoginCheck = qrKey => get("/account/qrlogin-check", {qrKey});
export const getAllPlaylist = data => get("/playlists", data);
export const getPlaylistDetail = playlistId => get(`/playlists/netease/${playlistId}/songs`);
export const getJobDetail = jobId => get(`/sync-jobs/${jobId}`);
export const createSyncSongFromUrlJob = (url, songId = "") => {
return post("/sync-jobs", {
"jobType": "SyncSongFromUrl",
"urlJob": {
"url": url,
"meta": {
"songId": songId
}
}
});
};
export const createDownloadSongFromUrlJob = (url, songId = "") => {
return post("/sync-jobs", {
"jobType": "DownloadSongFromUrl",
"urlJob": {
"url": url,
"meta": {
"songId": songId
}
}
});
};
export const createSyncSongFromPlaylistJob = (playlistId, options) => {
return post("/sync-jobs", {
"jobType": "UnblockedPlaylist",
"playlist": {
"id": playlistId,
"source": "netease"
},
"options": options
});
};
export const createSyncThePlaylistToLocalServiceJob = (playlistId) => {
return post("/sync-jobs", {
"jobType": "SyncThePlaylistToLocalService",
"playlist": {
"id": playlistId,
"source": "netease"
}
});
};
export const createSyncSongWithSongIdJob = (songId) => {
return post("/sync-jobs", {
"jobType": "UnblockedSong",
"songId": songId,
"source": "netease"
});
};
export const checkMediaFetcherLib = data => get("/media-fetcher-lib/version-check", data);
export const updateMediaFetcherLib = (version) => {
return post("/media-fetcher-lib/update", {
"version": version,
});
};
export const getGlobalConfig = _ => get("/config/global", {});
export const setGlobalConfig = (config) => {
return post("/config/global", config);
};
export const getAllAccounts = _ => get("/accounts", {});
export const getNextRunInfo = () => get("/scheduler/next-run", {});

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,277 @@
<template>
<div class="player-body">
<van-row style="height: 62px; overflow: hidden">
<van-col span="3">
<van-image
style="padding: 3px"
width="100%"
:src="currentSong.coverUrl"
/>
</van-col>
<van-col span="15" style="text-align: left; padding: 3px 0 0 10px">
<div style="font-weight: bold; font-size: 18px">
{{ keepSomeText(currentSong.songName, 22) }}
</div>
<div>
<span style="color: gray; font-size: 13px">
{{ currentSong.artist }}
</span>
</div>
</van-col>
<van-col span="3" style="height: 100%">
<i
v-if="isPlaying"
class="bi bi-pause-circle"
style="line-height: 60px; font-size: 20px; color: gray"
@click="pauseSong"
></i>
<i
v-if="!isPlaying"
class="bi bi-play-circle"
style="line-height: 60px; font-size: 20px; color: gray"
@click="continueSong"
></i>
</van-col>
<van-col span="3">
<van-popover
v-model:show="showPopover"
overlay
overlay-class="popover-overlay"
:actions="actions"
placement="left-end"
@select="onSelect"
>
<template #reference>
<i
style="line-height: 60px; font-size: 20px; color: gray"
class="bi bi-list"
></i>
</template>
</van-popover>
</van-col>
</van-row>
<van-row>
<van-col span="2" style="font-size: 10px; padding-left: 2px">{{
secondDurationToDisplayDuration(currentSeek, true)
}}</van-col>
<van-col span="20" style="padding: 5px 7px 0 7px">
<van-slider
:max="totalTime"
bar-height="2"
button-size="10"
v-model="currentSeek"
@change="onSlicerChange"
/>
</van-col>
<van-col
span="2"
style="font-size: 10px; color: gray; padding-right: 2px"
>{{ secondDurationToDisplayDuration(totalTime) }}</van-col
>
</van-row>
</div>
</template>
<style>
.player-body {
background-color: white;
padding-top: 10px;
}
:root {
--van-popover-action-font-size: 13px;
}
</style>
<script>
import { nextTick, ref } from "vue";
import * as Howler from "howler";
import { secondDurationToDisplayDuration, sleep } from "../utils";
import { createSyncSongFromUrlJob, createDownloadSongFromUrlJob } from "../api";
import { startTaskListener } from "./TaskNotificationForMobile";
let playerCtl;
let currentPlayId;
const ActionUpload = 0;
const ActionDownload = 1;
const ActionOpenRef = 2;
const ActionDownloadToLocalService = 3;
export default {
data() {
return {
totalTime: 0,
currentSeek: 0,
currentSong: this.songInfos[this.currentSongIndex] || {},
isPlaying: false,
};
},
props: {
songInfos: {
type: Array,
required: true,
},
currentSongIndex: {
type: Number,
required: false,
default: 0,
},
changedTime: {
type: Number,
required: false,
default: 0,
},
},
watch: {
changedTime: function () {
this.currentSong = this.songInfos[this.currentSongIndex];
this.playSong();
},
},
methods: {
secondDurationToDisplayDuration,
async timeChanged() {
const currentSeek = playerCtl.seek();
// for performance
if (parseInt(currentSeek) !== parseInt(this.currentSeek)) {
this.currentSeek = currentSeek;
} else {
await sleep(150);
}
requestAnimationFrame(this.timeChanged);
},
pauseSong() {
playerCtl.pause(currentPlayId);
this.isPlaying = false;
},
playSong() {
console.log(this.songInfos);
console.log(this.currentSong.playUrl);
if (playerCtl) {
playerCtl.stop();
}
playerCtl = new Howler.Howl({
src: [this.currentSong.playUrl],
html5: true,
preload: "metadata",
onload: () => {
console.log("load");
this.totalTime = playerCtl.duration();
},
onend: () => {
console.log("end");
this.isPlaying = false;
},
onloaderror: (id, err) => {
console.log("load error", id, err);
},
onplayerror: (id, err) => {
console.log("play error", id, err);
},
});
currentPlayId = playerCtl.play();
this.isPlaying = true;
this.timeChanged();
},
continueSong() {
if (!currentPlayId) {
this.playSong();
return;
}
playerCtl.play(currentPlayId);
this.isPlaying = true;
},
onSlicerChange(time) {
console.log("slicer changed", time);
playerCtl.seek(time);
},
keepSomeText(text, length) {
if (!text) {
return "";
}
if (text.length > length) {
return text.substring(0, length) + "...";
} else {
return text;
}
},
async uploadToCloud() {
if (!this.currentSong) {
return;
}
console.log(this.currentSong);
const ret = await createSyncSongFromUrlJob(
this.currentSong.pageUrl,
this.currentSong.suggestMatchSongId ?? 0
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async downloadToLocalService() {
if (!this.currentSong) {
return;
}
console.log(this.currentSong);
const ret = await createDownloadSongFromUrlJob(
this.currentSong.pageUrl,
this.currentSong.suggestMatchSongId ?? 0
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async onSelect(actionItem) {
switch (actionItem.action) {
case ActionUpload:
this.uploadToCloud();
break;
case ActionDownloadToLocalService:
this.downloadToLocalService();
break;
case ActionDownload:
const a = document.createElement("a");
a.target = "_blank";
a.href = this.currentSong.playUrl;
a.download = `${this.currentSong.songName}-${this.currentSong.artist}.mp3`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
break;
case ActionOpenRef:
window.open(this.currentSong.pageUrl, "_blank").focus();
break;
}
},
},
setup(props, { emit }) {
// const playTheSong = (songMeta, pageUrl, index) => {
// props.playTheSong(songMeta, pageUrl, index);
// };
// const abortTheSong = () => {
// props.abortTheSong();
// };
const showPopover = ref(false);
const actions = [
{ text: "上传到云盘", icon: "upgrade", action: ActionUpload },
{ text: "下载到浏览器本地", icon: "down", action: ActionDownload },
{ text: "下载到服务器", icon: "down", action: ActionDownloadToLocalService },
{ text: "打开源站", icon: "share", action: ActionOpenRef },
];
return {
showPopover,
actions,
};
},
};
</script>

View File

@@ -0,0 +1,219 @@
<template>
<div style="width: 100%">
<van-col span="22">
<van-row
v-for="(item, i) in searchResult"
:key="i"
style="margin-top: 15px; text-align: left"
>
<van-col span="23" offset="1">
<van-row>
<van-col span="22">
<van-row @click="play(null, item.url, i)">
<van-col style="font-size: 16px">
<i
v-if="item.resourceForbidden"
class="bi bi-lock-fill"
style="font-size: 13px; padding-right: 5px; color: gray"
></i>
<span>
{{ ellipsis(item.songName, 18) }}
</span>
<span
style="color: #0f1c69; font-size: 13px; padding-left: 6px"
>
{{ item.sourceName }}
</span>
</van-col>
</van-row>
<van-row style="margin-top: 4px">
<van-col style="color: gray; font-size: 10px">
{{ item.artist }} / {{ ellipsis(item.album, 20) }} /
{{ item.duration }}
</van-col>
</van-row>
</van-col>
<van-col span="1" style="line-height: 32px; color: red">
<i v-show="currentSongIndex == i" class="bi bi-soundwave"></i>
</van-col>
<van-col span="1">
<van-col
span="2"
style="float: right; color: gray; line-height: 32px"
>
<van-popover
v-model:show="showPopover[i]"
:actions="[
{
text: '上传到云盘',
icon: 'upgrade',
action: ActionUpload,
songIndex: i,
},
{
text: '下载到浏览器本地',
icon: 'down',
action: ActionDownload,
songIndex: i,
},
{
text: '下载到服务器本地',
icon: 'down',
action: ActionDownloadToLocalService,
songIndex: i,
},
{
text: '打开源站',
icon: 'share',
action: ActionOpenRef,
songIndex: i,
},
]"
placement="left"
overlay
overlay-class="popover-overlay"
@select="onSelect"
>
<template #reference>
<i class="bi bi-three-dots-vertical"></i>
</template>
</van-popover>
</van-col>
</van-col>
</van-row>
</van-col>
</van-row>
</van-col>
</div>
</template>
<script>
import { ref } from "vue";
import {
createSyncSongFromUrlJob,
getSongsMeta,
createDownloadSongFromUrlJob,
} from "../api";
import { startTaskListener } from "./TaskNotificationForMobile";
import storage from "../utils/storage";
import { ellipsis } from "../utils";
const ActionUpload = 0;
const ActionDownload = 1;
const ActionOpenRef = 2;
const ActionDownloadToLocalService = 3;
export default {
data() {
return {
currentSongIndex: -1,
wyAccount: null,
};
},
props: {
playTheSong: {
type: Function,
required: true,
},
suggestMatchSongId: {
type: String,
required: false,
},
searchResult: {
type: Array,
required: true,
},
},
created() {
this.ActionUpload = ActionUpload;
this.ActionDownload = ActionDownload;
this.ActionOpenRef = ActionOpenRef;
this.ActionDownloadToLocalService = ActionDownloadToLocalService;
},
mounted() {
this.wyAccount = storage.get("wyAccount");
},
setup(props, { emit }) {
const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => {
props.playTheSong(songMeta, pageUrl, suggestMatchSongId);
};
const showPopover = ref([]);
return {
playTheSong,
showPopover,
ellipsis,
};
},
watch: {
$route(to, from) {
this.wyAccount = storage.get("wyAccount");
},
searchResult: {
handler(val) {
this.currentSongIndex = -1;
},
deep: true,
},
},
methods: {
async uploadToCloud(pageUrl) {
const ret = await createSyncSongFromUrlJob(
pageUrl,
this.suggestMatchSongId
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async downloadToLocalService(pageUrl) {
const ret = await createDownloadSongFromUrlJob(
pageUrl,
this.suggestMatchSongId
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
play(songMeta, pageUrl, index) {
if (this.currentSongIndex === index) {
return;
}
this.currentSongIndex = index;
this.playTheSong(songMeta, pageUrl, this.suggestMatchSongId);
},
async onSelect(actionItem) {
const currentSong = this.searchResult[actionItem.songIndex];
console.log(currentSong);
switch (actionItem.action) {
case ActionUpload:
this.uploadToCloud(currentSong.url);
break;
case ActionDownloadToLocalService:
this.downloadToLocalService(currentSong.url);
break;
case ActionDownload:
const ret = await getSongsMeta({ url: currentSong.url });
const info = ret.data.songMeta;
console.log(ret);
const a = document.createElement("a");
a.href = info.audios[0].url;
a.download = `${currentSong.songName}-${currentSong.artist}.mp3`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
break;
case ActionOpenRef:
window.open(currentSong.url, "_blank").focus();
break;
}
},
},
};
</script>

View File

@@ -0,0 +1,328 @@
<template>
<el-table
:data="searchResult"
:stripe="true"
class="search-result-table"
:header-cell-style="{
background: '#f5f7fa',
color: '#606266',
fontWeight: 'bold',
fontSize: '14px',
height: '50px',
}"
:row-style="{ height: '60px' }"
>
<el-table-column type="index" width="60" align="center" />
<el-table-column label="歌曲" min-width="300" prop="songName">
<template #default="scope">
<div class="song-name-cell">
<el-tooltip
v-if="scope.row.resourceForbidden"
content="可能无法播放 / 试听版本"
placement="top"
>
<i class="bi bi-lock-fill lock-icon"></i>
</el-tooltip>
<span class="song-name">{{ scope.row.songName }}</span>
</div>
</template>
</el-table-column>
<el-table-column
prop="artist"
label="歌手"
min-width="120"
align="center"
/>
<el-table-column prop="album" label="专辑" min-width="200" align="center" />
<el-table-column
prop="duration"
label="时长"
min-width="100"
align="center"
/>
<el-table-column
prop="sourceName"
label="来源"
min-width="120"
align="center"
/>
<el-table-column label="操作" min-width="200" fixed="right" align="center">
<template #default="scope">
<div class="operation-cell">
<div class="operation-buttons">
<el-tooltip
content="停止播放"
placement="top"
v-if="scope.row.url == currentSongUrl"
>
<el-button
@click="abort()"
type="primary"
circle
class="operation-btn"
>
<i class="bi bi-stop-circle"></i>
</el-button>
</el-tooltip>
<el-tooltip content="播放歌曲" placement="top" v-else>
<el-button
@click="play(null, scope.row.url)"
type="primary"
circle
:disabled="scope.row.url.indexOf('youtube') >= 0"
class="operation-btn"
>
<i class="bi bi-play-circle"></i>
</el-button>
</el-tooltip>
<el-tooltip
:content="
wyAccount
? '上传歌曲到云盘'
: '上传歌曲到云盘(请先绑定网易云账号)'
"
placement="top"
>
<el-button
type="success"
circle
@click="uploadToCloud(scope.row.url)"
:disabled="!wyAccount"
class="operation-btn"
>
<i class="bi bi-cloud-upload"></i>
</el-button>
</el-tooltip>
<el-tooltip
:content="
globalConfig.downloadPathExisted
? '下载到服务器'
: '下载到服务器(请先配置下载路径)'
"
placement="top"
>
<el-button
type="primary"
circle
@click="downloadToLocalService(scope.row.url)"
:disabled="!globalConfig.downloadPathExisted"
class="operation-btn"
>
<i class="bi bi-download"></i>
</el-button>
</el-tooltip>
<el-tooltip content="在源站查看" placement="top">
<el-button
type="warning"
circle
class="operation-btn"
@click="openSourceUrl(scope.row.url)"
>
<i class="bi bi-box-arrow-up-right"></i>
</el-button>
</el-tooltip>
</div>
</div>
</template>
</el-table-column>
</el-table>
</template>
<style scoped>
.search-result-table {
margin-top: 20px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.song-name-cell {
display: flex;
align-items: center;
padding-left: 10px;
}
.lock-icon {
font-size: 16px;
color: #909399;
margin-right: 8px;
}
.song-name {
font-size: 14px;
color: #303133;
}
.operation-cell {
position: relative;
height: 100%;
}
.operation-buttons {
display: flex;
justify-content: center;
gap: 8px;
opacity: 0;
transition: opacity 0.3s ease;
position: relative;
z-index: 200;
}
/* 修改hover选择器的写法确保按钮在hover时不会消失 */
:deep(.el-table__row:hover) .operation-buttons,
.operation-buttons:hover {
opacity: 1;
}
.operation-btn {
padding: 6px;
font-size: 16px;
background-color: var(--el-button-bg-color);
}
:deep(.el-table__header) {
width: 100% !important;
}
:deep(.el-table__body) {
width: 100% !important;
}
:deep(.el-table__header-wrapper) {
width: 100% !important;
}
:deep(.el-table__body-wrapper) {
width: 100% !important;
}
</style>
<script>
import {
createSyncSongFromUrlJob,
createDownloadSongFromUrlJob,
getGlobalConfig,
} from "../api";
import { startTaskListener } from "../components/TaskNotification";
import storage from "../utils/storage";
export default {
data() {
return {
currentSongUrl: -1,
wyAccount: null,
globalConfig: {},
};
},
props: {
playTheSong: {
type: Function,
required: true,
},
abortTheSong: {
type: Function,
required: true,
},
suggestMatchSongId: {
type: String,
required: false,
},
searchResult: {
type: Array,
required: true,
},
},
mounted() {
this.wyAccount = storage.get("wyAccount");
this.loadGlobalConfig();
},
setup(props, { emit }) {
const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => {
props.playTheSong(songMeta, pageUrl, suggestMatchSongId);
};
const abortTheSong = () => {
props.abortTheSong();
};
return {
playTheSong,
abortTheSong,
};
},
watch: {
$route(to, from) {
this.wyAccount = storage.get("wyAccount");
if (to.path === "/" || to.path === "/home" || to.path === "") {
this.loadGlobalConfig();
}
},
},
methods: {
async uploadToCloud(pageUrl) {
const ret = await createSyncSongFromUrlJob(
pageUrl,
this.suggestMatchSongId
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async downloadToLocalService(pageUrl) {
const ret = await createDownloadSongFromUrlJob(
pageUrl,
this.suggestMatchSongId
);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async loadGlobalConfig() {
const globalConfig = await getGlobalConfig();
if (globalConfig !== false && globalConfig.data) {
this.globalConfig = globalConfig.data;
}
},
play(songMeta, pageUrl) {
this.currentSongUrl = pageUrl;
this.playTheSong(songMeta, pageUrl, this.suggestMatchSongId);
},
abort() {
this.currentSongUrl = -1;
this.abortTheSong();
},
openSourceUrl(url) {
try {
if (typeof window !== "undefined" && window?.open) {
window.open(url, "_blank", "noopener,noreferrer");
} else {
// 降级方案
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
} catch (error) {
console.error("Failed to open URL:", error);
// 可以添加用户提示
ElMessage.error("打开链接失败,请尝试复制链接手动打开");
}
},
},
};
</script>

View File

@@ -0,0 +1,53 @@
import { ElNotification } from 'element-plus'
import { getJobDetail } from "../api";
export function startTaskListener(jobID) {
let lastTip = '';
const task = (jobID) => {
getJobDetail(jobID).then(res => {
const status = res.data.jobs.status;
if (status === '已完成' || status === '失败') {
clearInterval(interval)
}
if (lastTip == res.data.jobs.tip) {
return;
};
lastTip = res.data.jobs.tip;
let title;
let type;
let duration = 4500;
if (status === '已完成') {
title = '任务完成';
type = 'success';
duration = 6000;
} else if (status === '失败') {
title = '任务失败';
type = 'error';
} else {
title = '任务进度提示';
type = 'info';
duration = 2500;
}
if (lastTip == "") {
duration = 4500;
}
ElNotification({
title,
message: "<strong>" + res.data.jobs.name + "</strong><br>" + "<strong>" + res.data.jobs.desc + "</strong><br>" + res.data.jobs.tip,
dangerouslyUseHTMLString: true,
type,
duration,
});
})
};
task(jobID);
const interval = setInterval(() => {
task(jobID);
}, 1000)
}

View File

@@ -0,0 +1,50 @@
import { getJobDetail } from "../api";
import { Notify } from 'vant';
export function startTaskListener(jobID) {
let lastTip = '';
const task = (jobID) => {
getJobDetail(jobID).then(res => {
const status = res.data.jobs.status;
if (status === '已完成' || status === '失败') {
clearInterval(interval)
}
if (lastTip == res.data.jobs.tip) {
return;
};
lastTip = res.data.jobs.tip;
let title;
let type;
let duration = 4500;
if (status === '已完成') {
type = 'success';
duration = 6000;
} else if (status === '失败') {
type = 'danger';
} else {
title = '任务进度提示';
type = 'primary';
duration = 2500;
}
if (lastTip == "") {
duration = 4500;
}
const options = {
message: `${res.data.jobs.tip}\n${res.data.jobs.name}\n${res.data.jobs.desc}`,
type,
duration,
};
Notify(options);
})
};
task(jobID);
const interval = setInterval(() => {
task(jobID);
}, 1000)
}

11
frontend/src/main.js Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import ElementPlusLocaleZhCn from 'element-plus/lib/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn })
app.mount('#app')

18
frontend/src/mobile.js Normal file
View File

@@ -0,0 +1,18 @@
import vant from 'vant';
import 'vant/lib/index.css';
import VueVirtualScroller from 'vue-virtual-scroller'
import { createApp } from 'vue'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { useRegisterSW } from 'virtual:pwa-register/vue';
import App from './Mobile.vue'
import router from './router/mobile'
useRegisterSW();
const app = createApp(App)
app.use(router)
app.use(vant)
app.use(VueVirtualScroller)
app.mount('#app')

View File

@@ -0,0 +1,50 @@
import { createRouter, createWebHistory, createWebHashHistory } from "vue-router"
import storage from "../utils/storage"
const PathPlaylist = '/playlist';
const PathSetting = '/setting';
const routes = [
{
path: '/',
component: () => import('../views/pc/Home.vue')
},
{
path: '/account',
name: "Account",
component: () => import('../views/pc/Account.vue')
},
{
path: PathPlaylist,
name: "Playlist",
component: () => import('../views/pc/Playlist.vue')
},
{
path: PathSetting,
name: "Setting",
component: () => import('../views/pc/Setting.vue')
},
]
export const router = createRouter({
history: createWebHashHistory(),
routes: routes
})
router.beforeEach((to, from, next) => {
if (to.path === "/account") {
next();
return;
}
const mk = storage.get('mk')
const wyAccount = storage.get('wyAccount')
if (!mk) {
next("/account");
}
if ([PathPlaylist].includes(to.path) && !wyAccount) {
next("/account");
return;
}
next();
});
export default router

View File

@@ -0,0 +1,42 @@
import { createRouter, createWebHistory, createWebHashHistory } from "vue-router"
import storage from "../utils/storage"
const routes = [
{
path: '/',
component: () => import('../views/mobile/Home.vue')
},
{
path: '/account',
name: "Account",
component: () => import('../views/mobile/Account.vue')
},
{
path: '/playlist',
name: "Playlist",
component: () => import('../views/mobile/Playlist.vue')
},
]
export const router = createRouter({
history: createWebHashHistory(),
routes: routes
})
router.beforeEach((to, from, next) => {
if (to.path === "/account") {
next();
return;
}
const mk = storage.get('mk')
const wyAccount = storage.get('wyAccount')
if (!mk) {
next("/account");
}
if (to.path === "/playlist" && !wyAccount) {
next("/account");
return;
}
next();
});
export default router

View File

@@ -0,0 +1,22 @@
/**
* Get the proper play URL based on source
* @param {string} source - The source platform (e.g., 'bilibili', 'netease')
* @param {string} url - The original audio URL
* @param {string} referer - The referer URL
* @returns {string} The processed play URL
*/
export function getProperPlayUrl(source, url, referer) {
console.log("--------getProperPlayUrl----------------");
console.log(source);
console.log(url);
console.log(referer);
if (source === "bilibili") {
const params = new URLSearchParams({
url: url,
source: 'bilibili',
referer: referer
});
return `${import.meta.env.VITE_APP_API_URL}/proxy/audio?${params}`;
}
return url;
}

View File

@@ -0,0 +1,46 @@
export function secondDurationToDisplayDuration(secondDuration, allowZero = false) {
if (!secondDuration) {
return allowZero ? "00:00" : " - ";
}
secondDuration = parseInt(secondDuration);
let duration = secondDuration;
let minute = Math.floor(duration / 60);
let second = duration % 60;
if (minute < 10) {
minute = "0" + minute;
}
if (second < 10) {
second = "0" + second;
}
return minute + ":" + second;
}
export function sourceCodeToName(source) {
return {
"qq": "QQ音乐",
"xiami": "虾米音乐",
"netease": "网易云音乐",
"kugou": "酷狗音乐",
"kuwo": "酷我音乐",
"migu": "咪咕音乐",
"bilibili": "Bilibili",
"douyin": "抖音",
"youtube": "YouTube",
}[source] || "未知";
}
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function ellipsis(value, maxLength) {
if (!value) {
return "";
}
value = value.trim();
if (!value) return "";
if (value.length > maxLength) {
return value.slice(0, maxLength) + "...";
}
return value;
}

24
frontend/src/utils/pwa.js Normal file
View File

@@ -0,0 +1,24 @@
let request;
let isInstallable = false;
// The file is not useful now.
window.addEventListener('beforeinstallprompt', (r) => {
console.log('beforeinstallprompt')
// Prevent Chrome 67 and earlier from automatically showing the prompt
r.preventDefault()
request = r
isInstallable = true;
});
export async function installPWA() {
if (request) {
console.log('start install')
let installResponse = await request.prompt();
console.info({installResponse});
return installResponse.outcome === 'accepted';
} else {
console.log('The request is not available');
return false;
}
}

View File

@@ -0,0 +1,20 @@
export default {
set (key, val) {
if (typeof val === 'object') {
val = JSON.stringify(val)
}
window.localStorage.setItem(key, val)
},
get (key) {
let data = window.localStorage.getItem(key)
try {
data = JSON.parse(data);
return data;
} catch {
return data;
}
},
del (key) {
window.localStorage.removeItem(key)
},
}

View File

@@ -0,0 +1,235 @@
<template>
<div>
<van-row v-if="!registedMK">
<van-col style="margin-top: 100px">
<h2 style="text-align: center; padding: 0 30px">
填写你的 Melody Key 就可以开始使用啦 😘
</h2>
<van-row>
<van-col :span="20" :offset="2">
<van-field
v-model="mk"
size="large"
maxlength="32"
required
placeholder="请输入。初始管理员的默认 key 为melody"
autofocus
clearable
@keyup.enter="checkMK"
></van-field>
</van-col>
</van-row>
<van-row>
<van-col :span="8" :offset="8" style="margin-top: 20px">
<van-button
round
type="success"
@click="checkMK"
:loading="MKChecking"
loading-text="校验中..."
>
开始使用
</van-button>
</van-col>
</van-row>
</van-col>
</van-row>
<van-row v-if="account.uid">
<van-col span="24">
<van-row style="margin-top: 10px">
<van-col :span="16" :offset="3" style="text-align: left">
当前 Melody Key: {{ account.uid }}
</van-col>
<van-col :span="4">
<van-button round type="warning" size="mini" @click="logoutMK"
>退出</van-button
>
</van-col>
</van-row>
<!-- <van-row style="margin-top: 10px">
<van-col :span="8" :offset="16" style="text-align: left">
<van-button @click="triggerInstallPWA">安装到桌面</van-button>
</van-col>
</van-row> -->
<van-row>
<van-col :offset="3" style="margin-top: 50px">
<h3>网易云账号信息</h3>
</van-col>
</van-row>
<van-row style="">
<van-image
round
:src="account.wyAccount.avatarUrl"
style="width: 80px; height: 80px; margin-left: calc(50% - 40px)"
/>
</van-row>
<van-row style="text-align: center; margin-top: 10px">
<van-col :span="24" v-if="account.wyAccount"
>{{ account.wyAccount.nickname }}已绑定</van-col
>
<span v-if="!account.wyAccount"> 请先绑定正确的网易云账号 </span>
</van-row>
<van-row style="text-align: left">
<van-col :offset="3" style="margin-top: 50px">
<van-radio-group
checked-color="#07c160"
v-model="account.loginType"
direction="horizontal"
>
<van-radio name="qrcode">扫码登录</van-radio>
<van-radio name="phone">手机号登录</van-radio>
<van-radio name="email">邮箱登录</van-radio>
</van-radio-group>
</van-col>
</van-row>
<van-row style="margin-top: 20px">
<van-col :offset="3">
<p v-if="account.loginType != 'qrcode'">
<van-field
v-if="account.loginType == 'phone'"
label="国际电话区号"
type="digit"
v-model="account.countryCode"
placeholder="默认86不需要输入 +"
maxlength="4"
></van-field>
<van-field label="账号" v-model="account.account"></van-field>
<van-field
label="密码"
v-model="account.password"
type="password"
></van-field>
</p>
<p v-else>扫码仅支持在 PC 端操作</p>
</van-col>
</van-row>
<van-row v-if="account.loginType != 'qrcode'">
<van-col :offset="8" style="margin-top: 20px">
<van-button round type="success" @click="updateAccount">
更新账号密码
</van-button>
</van-col>
</van-row>
</van-col>
</van-row>
</div>
</template>
<script>
import { getAccount, setAccount } from "../../api";
import storage from "../../utils/storage";
// import { installPWA } from "../../utils/pwa";
import { Notify } from "vant";
export default {
data: () => {
return {
mk: "",
account: {},
registedMK: false,
MKChecking: false,
};
},
async mounted() {
this.registedMK = storage.get("mk") ? true : false;
if (!this.registedMK) {
return;
}
console.log(this.registedMK);
const ret = await getAccount();
if (ret === false || !ret.data) {
this.registedMK = false;
return;
}
this.account = ret.data.account;
storage.set("wyAccount", ret.data.account.wyAccount);
console.log(this.account);
},
methods: {
// async triggerInstallPWA() {
// await installPWA();
// },
async checkMK() {
this.MKChecking = true;
this.mk = this.mk.trim();
if (!this.mk) {
this.MKChecking = false;
return;
}
const ret = await getAccount({ mk: this.mk });
if (ret !== false && ret.data) {
this.account = ret.data.account;
this.registedMK = true;
storage.set("mk", this.mk);
storage.set("wyAccount", ret.data.account.wyAccount);
this.MKChecking = false;
Notify({ type: "success", message: "Melody Key 设置成功" });
} else {
this.MKChecking = false;
Notify({ type: "warning", message: "Melody Key 不正确哦" });
}
},
async updateAccount() {
if (
!this.account.account ||
!this.account.password ||
!this.account.loginType
) {
return;
}
if (this.account.loginType == "phone") {
if (this.account.countryCode) {
if (!/^[\d]{0,4}$/.test(this.account.countryCode)) {
ElMessage({
center: true,
type: "error",
message: "国际电话区号不正确",
});
return;
}
}
}
if (this.account.loginType == "email") {
if (
!/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(
this.account.account
)
) {
ElMessage({
center: true,
type: "error",
message: "邮箱格式不正确",
});
return;
}
}
const ret = await setAccount({
loginType: this.account.loginType,
countryCode: this.account.countryCode,
account: this.account.account,
password: this.account.password,
});
if (ret.data.account) {
this.account = ret.data.account;
storage.set("wyAccount", ret.data.account.wyAccount);
}
},
logoutMK() {
storage.del("mk");
this.registedMK = false;
this.mk = "";
this.account = {};
},
},
};
</script>

View File

@@ -0,0 +1,502 @@
<template>
<div style="margin-top: 20px">
<van-row justify="space-between" v-if="searchResult.length !== 0">
<van-col span="24">
<van-search
v-model="keyword"
shape="round"
show-action
@search="onSearch"
placeholder="网页链接 / 歌名"
input-align="center"
>
<template #action>
<div @click="onSearch">搜索</div>
</template>
</van-search>
</van-col>
</van-row>
<van-loading
v-show="searchResult.length !== 0 && isSearching"
type="spinner"
/>
<div v-if="searchResult.length === 0">
<van-row style="margin-top: 150px">
<van-col span="24">
<van-search
v-model="keyword"
shape="round"
@search="onSearch"
placeholder="网页链接 / 歌名"
input-align="center"
>
</van-search>
</van-col>
</van-row>
<van-row style="margin-top: 19px">
<van-col span="24">
<van-button
round
color="#07c160"
icon="search"
type="primary"
@click="onSearch"
:loading="isSearching"
loading-text="搜索中"
style="height: 32px"
>
</van-button>
</van-col>
</van-row>
</div>
<!-- 精准搜索卡片 -->
<van-row
v-if="songMetaInfo !== null"
style="padding: 0 8px"
@click="playTheSong(songMetaInfo)"
>
<van-col span="6">
<img
:src="songMetaInfo.coverUrl"
onerror="this.src='https://cdnmusic.migu.cn/v3/static/img/common/default/img_default_240x240.jpg'"
style="width: 100%; height: 100%"
/>
</van-col>
<van-col span="18">
<div
style="
position: relative;
z-index: 2;
overflow: hidden;
width: 100%;
height: 100%;
"
>
<div
style="
width: 100%;
height: 100%;
position: absolute;
top: 0;
color: white;
margin-top: 10px;
padding-left: 18px;
"
>
<van-row style="font-size: 14px; text-align: left">
{{ ellipsis(songMetaInfo.songName, 16) }}
</van-row>
<van-row>
<van-col>
<van-row
style="margin-top: 6px; font-size: 10px; text-align: left"
>
{{ songMetaInfo.artist }} {{ songMetaInfo.album }}
</van-row>
<van-row
style="margin-top: 6px; font-size: 10px; text-align: left"
>
时长 {{ songMetaInfo.duration }}
</van-row>
</van-col>
</van-row>
</div>
<div
style="
position: absolute;
filter: blur(32px);
transform: scale(1.2);
top: 0;
z-index: -1;
width: 100%;
height: 100%;
"
>
<img
:src="songMetaInfo.coverUrl"
onerror="this.src='https://cdnmusic.migu.cn/v3/static/img/common/default/img_default_240x240.jpg'"
style="width: 100%; height: 100%"
/>
</div>
</div>
</van-col>
</van-row>
<van-row style="margin-top: 10px">
<SearchResultList
:playTheSong="playTheSong"
:suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult"
>
</SearchResultList>
</van-row>
</div>
</template>
<script>
import { searchSongs, getSongsMeta, createSyncSongFromUrlJob } from "../../api";
import SearchResultList from "../../components/SearchResultListForMobile.vue";
import {
secondDurationToDisplayDuration,
sourceCodeToName,
ellipsis,
} from "../../utils";
import { startTaskListener } from "../../components/TaskNotification";
import storage from "../../utils/storage";
import { getProperPlayUrl } from "../../utils/audio";
export default {
data: () => {
return {
suggestMatchSongId: "",
songMetaInfo: null,
// songMetaInfo: {
// songName: "搁浅",
// artist: "单依纯",
// album: "专辑",
// duration: "03:06",
// coverUrl:
// "http://d.musicapp.migu.cn/prod/file-service/file-down/8121e8df41a5c12f48b69aea89b71dab/ba75041e9311e62d10b6fc32d11d84aa/1c51d605f00caeb4643414c6eb3f5fbe",
// url: "https://www.kuwo.cn/play_detail/157612752",
// resourceForbidden: false,
// source: "kuwo",
// fromMusicPlatform: true,
// score: 173.50300000000001,
// sourceName: "酷我音乐",
// },
playUrl: "",
keyword: "",
searchTip: "",
isSearching: false,
searchResult: [],
// searchResult: [
// {
// songName: "搁浅(中国新歌声)",
// artist: "羽田",
// album: "《中国新歌声第十一期》",
// duration: " - ",
// url: "https://music.migu.cn/v3/music/song/6404689Z0BD",
// resourceForbidden: false,
// source: "migu",
// fromMusicPlatform: true,
// score: 651.854,
// sourceName: "咪咕音乐",
// },
// {
// songName: "搁浅",
// artist: "周杰伦",
// album: "《七里香》",
// duration: " - ",
// url: "https://music.migu.cn/v3/music/song/60054701938",
// resourceForbidden: false,
// source: "migu",
// fromMusicPlatform: true,
// score: 640.803,
// sourceName: "咪咕音乐",
// },
// {
// songName: "搁浅 (Live)",
// artist: "杨丞琳",
// album: "《蒙面唱将猜猜猜第五期》",
// duration: " - ",
// url: "https://music.migu.cn/v3/music/song/64046801877",
// resourceForbidden: false,
// source: "migu",
// fromMusicPlatform: true,
// score: 583.073,
// sourceName: "咪咕音乐",
// },
// {
// songName: "搁浅",
// artist: "刘大壮",
// album: " - ",
// duration: "00:14",
// url: "https://www.kuwo.cn/play_detail/97836012",
// resourceForbidden: false,
// source: "kuwo",
// fromMusicPlatform: true,
// score: 173.50300000000001,
// sourceName: "酷我音乐",
// },
// {
// songName: "搁浅",
// artist: "周杰伦",
// album: " - ",
// duration: "03:38",
// url: "https://www.kuwo.cn/play_detail/171708289",
// resourceForbidden: false,
// source: "kuwo",
// fromMusicPlatform: true,
// score: 173.50300000000001,
// sourceName: "酷我音乐",
// },
// {
// songName: "搁浅",
// artist: "单依纯",
// album: " - ",
// duration: "03:06",
// url: "https://www.kuwo.cn/play_detail/157612752",
// resourceForbidden: false,
// source: "kuwo",
// fromMusicPlatform: true,
// score: 173.50300000000001,
// sourceName: "酷我音乐",
// },
// {
// songName: "【1080P修复版】周杰伦 - 搁浅MV",
// artist: "zyl2012",
// album: " - ",
// duration: "04:25",
// url: "https://www.bilibili.com/video/BV1M4411P7gM",
// resourceForbidden: false,
// source: "bilibili",
// fromMusicPlatform: false,
// score: 145.13400000000001,
// sourceName: "Bilibili",
// },
// {
// songName: "搁浅",
// artist: "张杰",
// album: "《搁浅》",
// duration: "04:34",
// url: "https://music.163.com/#/song?id=190964",
// resourceForbidden: false,
// source: "netease",
// fromMusicPlatform: true,
// score: 70.99400000000001,
// sourceName: "网易云音乐",
// },
// {
// songName: "搁浅",
// artist: "文太Vent.T",
// album: "《Losing Boat》",
// duration: "04:03",
// url: "https://music.163.com/#/song?id=523239317",
// resourceForbidden: false,
// source: "netease",
// fromMusicPlatform: true,
// score: 57.970000000000006,
// sourceName: "网易云音乐",
// },
// {
// songName: "搁浅",
// artist: "周杰伦",
// album: "《七里香》",
// duration: "04:00",
// url: "https://www.kugou.com/song/#hash=fbc234520fed713c30c1c026e7352770&album_id=971783",
// resourceForbidden: true,
// source: "kugou",
// fromMusicPlatform: true,
// score: 23.092,
// sourceName: "酷狗音乐",
// },
// {
// songName: "搁浅",
// artist: "周杰伦",
// album: "《七里香》",
// duration: "04:00",
// url: "https://y.qq.com/n/ryqq/songDetail/001Bbywq2gicae",
// resourceForbidden: true,
// source: "qq",
// fromMusicPlatform: true,
// score: 23.092,
// sourceName: "QQ音乐",
// },
// {
// songName: "周姐查房Npc点播翻唱《搁浅》 被惊艳到赞不绝口",
// artist: "周姐日常事",
// album: " - ",
// duration: "04:41",
// url: "https://www.bilibili.com/video/BV1uq4y157Fb",
// resourceForbidden: false,
// source: "bilibili",
// fromMusicPlatform: false,
// score: 12.318,
// sourceName: "Bilibili",
// },
// {
// songName:
// "4K60P丨《搁浅》有多难唱听听未修音的周董唱得怎么样周杰伦.2004无与伦比演唱会",
// artist: "诶呦葛格",
// album: " - ",
// duration: "04:24",
// url: "https://www.bilibili.com/video/BV1KA411H78W",
// resourceForbidden: false,
// source: "bilibili",
// fromMusicPlatform: false,
// score: 11.714,
// sourceName: "Bilibili",
// },
// {
// songName:
// '"明明看透了还深陷其中,真的很可怜"#周杰伦 《#搁浅 》#无损音乐 #周杰伦音乐 #音乐推荐 #jay #七里香周杰伦 ',
// artist: "周杰伦F.M首播",
// album: " - ",
// duration: "03:55",
// url: "https://www.douyin.com/video/7082032090337922334",
// resourceForbidden: false,
// source: "douyin",
// fromMusicPlatform: false,
// score: 11.238,
// sourceName: "抖音",
// },
// {
// songName: "搁浅 (Live)",
// artist: "杨丞琳",
// album: "《蒙面唱将猜猜猜 第五期》",
// duration: "03:51",
// url: "https://www.kugou.com/song/#hash=b0f400f85edea59951dbedff35d6fbb9&album_id=1796966",
// resourceForbidden: false,
// source: "kugou",
// fromMusicPlatform: true,
// score: 4.767,
// sourceName: "酷狗音乐",
// },
// {
// songName: "搁浅 (Live)",
// artist: "周杰伦",
// album: "《周杰伦 2004 无与伦比 演唱会 Live CD》",
// duration: "04:21",
// url: "https://y.qq.com/n/ryqq/songDetail/001d94K71ipdTB",
// resourceForbidden: false,
// source: "qq",
// fromMusicPlatform: true,
// score: 4.767,
// sourceName: "QQ音乐",
// },
// {
// songName: "搁浅 (Live)",
// artist: "杨丞琳",
// album: "《蒙面唱将猜猜猜 第5期》",
// duration: "03:51",
// url: "https://y.qq.com/n/ryqq/songDetail/001Gn3RQ0IDwEK",
// resourceForbidden: false,
// source: "qq",
// fromMusicPlatform: true,
// score: 4.767,
// sourceName: "QQ音乐",
// },
// {
// songName: "搁浅(抖音原版)",
// artist: "王梦露",
// album: "《爱恋之音》",
// duration: "02:14",
// url: "https://music.163.com/#/song?id=1831481912",
// resourceForbidden: false,
// source: "netease",
// fromMusicPlatform: true,
// score: 4.152,
// sourceName: "网易云音乐",
// },
// {
// songName: "搁浅 (Live)",
// artist: "曹杨",
// album: "《2020中国好声音 第7期》",
// duration: "03:50",
// url: "https://www.kugou.com/song/#hash=feeaa10cefb9b03d6a7d2a92d9db5b04&album_id=39445387",
// resourceForbidden: true,
// source: "kugou",
// fromMusicPlatform: true,
// score: -30.773999999999997,
// sourceName: "酷狗音乐",
// },
// ],
wyAccount: null,
};
},
props: {
playTheSong: {
type: Function,
required: true,
},
},
mounted() {
this.wyAccount = storage.get("wyAccount");
},
watch: {
$route(to, from) {
this.wyAccount = storage.get("wyAccount");
},
},
setup(props, { emit }) {
const playTheSong = (songMeta, pageUrl) => {
props.playTheSong(songMeta, pageUrl);
};
return {
playTheSong,
ellipsis,
};
},
components: {
SearchResultList,
},
methods: {
async uploadToCloud(pageUrl) {
const ret = await createSyncSongFromUrlJob(pageUrl); // TODO: add songID
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async onSearch() {
if (this.keyword.trim().length === 0) {
return;
}
this.songMetaInfo = null;
this.searchTip = `正在搜索 ${this.keyword}`;
this.isSearching = true;
if (
this.keyword.indexOf("163.com") >= 0 &&
this.keyword.indexOf("/song") >= 0
) {
const songIdMatch = this.keyword.match(/id=([\d]+)/);
if (songIdMatch && songIdMatch.length > 1) {
this.suggestMatchSongId = songIdMatch[1];
}
}
try {
if (this.keyword.indexOf("http") >= 0) {
getSongsMeta({ url: this.keyword }).then((ret) => {
const info = ret.data.songMeta;
if (info) {
info.album = info.album != "" ? info.album : "未知";
info.duration = secondDurationToDisplayDuration(info.duration);
info.sourceName = sourceCodeToName(info.source);
this.songMetaInfo = info;
} else {
this.songMetaInfo = null;
}
});
}
const result = await searchSongs({ keyword: this.keyword });
console.log(result);
const songs = result.data.songs
.map((song) => {
song.album = song.album != "" ? `${song.album}` : " - ";
song.duration = secondDurationToDisplayDuration(song.duration);
song.sourceName = sourceCodeToName(song.source);
return song;
})
.filter((song) => song.songName.length > 0);
console.log(JSON.stringify(songs));
this.searchResult = songs;
this.searchTip = "";
} catch (e) {
this.searchTip = "搜索失败";
} finally {
this.isSearching = false;
}
},
},
};
</script>

View File

@@ -0,0 +1,422 @@
<template>
<div>
<van-popup
v-model:show="showPopup"
round
position="top"
closeable
:lock-scroll="false"
safe-area-inset-top
:style="{ height: 'calc(100% - 280px)' }"
@close="closeThePopup"
>
<div v-if="searchResult.length === 0" style="height: 300px">
<van-loading style="padding-top: 230px" size="24px" vertical
>拼命搜索中...</van-loading
>
</div>
<van-row style="margin-top: 30px" v-if="searchResult.length > 0">
<SearchResultList
:playTheSong="playTheSong"
:suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult"
>
</SearchResultList>
</van-row>
</van-popup>
<van-tabs
v-model:active="active"
sticky
@rendered="onRendered"
@change="onTabChange"
>
<van-tab v-for="(item, i) in playlists" :key="i">
<template #title>
<van-image round width="40" height="40" :src="item.cover" />
<div style="width: 90px; height: 60px">
{{ item.name }}
</div>
</template>
<div v-if="!playlistDetails[i]" style="height: 300px">
<van-loading style="padding-top: 130px" size="24px" vertical
>歌单拼命加载中...</van-loading
>
</div>
<div v-if="playlistDetails[i]" style="width: 100%">
<van-col
span="24"
style="margin-top: 3px; color: gray; font-size: 10px"
>
<van-divider style="margin: 5px 0">
全部{{ playlistDetails[i].songs.length }} | 待解锁{{
playlistDetails[i].songs.filter((song) => song.isBlocked).length
}}
</van-divider>
</van-col>
<van-row>
<van-col span="10">
<van-button
type="success"
round
size="small"
@click="unblockThePlaylist(playlistDetails[i].id)"
style="height: 85%"
>
<span style="font-size: 10px">解锁全部</span>
</van-button>
<span
style="
font-size: 10px;
color: grey;
position: relative;
top: 5px;
"
>
(实验性功能)
</span>
</van-col>
<van-col span="10" style="position: relative; top: 5px">
<label for="switch-showBlockSongsOnly" style="font-size: 11px"
>仅展示待解锁
</label>
<van-switch
id="switch-showBlockSongsOnly"
v-model="showBlockSongsOnly"
size="10px"
active-color="#07c160"
inactive-color="#d7d7d7"
style="position: relative; top: 2px"
/>
</van-col>
<van-col span="3">
<span
style="
color: #483d8b;
font-size: 11px;
position: relative;
top: 5px;
"
@click="refreshThePlaylist(i)"
>
刷新
</span>
</van-col>
</van-row>
<van-col span="24" style="margin-top: 18px">
<RecycleScroller
style="height: 100%"
:items="
playlistDetails[i].songs.filter((item) => {
if (showBlockSongsOnly) {
return item.isBlocked;
}
return true;
})
"
:item-size="50"
key-field="songId"
v-slot="{ item, index: j }"
>
<van-col span="23" offset="1">
<van-row>
<van-col span="19">
<van-row
@click="
internalPlayTheSongWithPlayUrl(
{
songId: item.songId,
playUrl: item.playUrl,
coverUrl: item.cover,
songName: item.songName,
pageUrl: item.pageUrl,
artist: item.artists[0],
isBlocked: item.isBlocked,
},
i,
j
)
"
>
<van-col style="font-size: 16px">
<span>
{{ ellipsis(item.songName, 18) }}
</span>
<span
style="
color: #0f1c69;
font-size: 13px;
padding-left: 6px;
"
>
<i
v-if="item.isBlocked"
class="bi bi-lock-fill"
style="font-size: 13px; color: gray"
></i>
<i
v-else-if="item.isCloud"
class="bi bi-cloud"
style="font-size: 13px"
></i>
</span>
</van-col>
</van-row>
<van-row style="margin-top: 4px">
<van-col style="color: gray; font-size: 10px">
{{ item.artists[0] }} / {{ ellipsis(item.album, 20) }} /
{{ item.duration }}
</van-col>
</van-row>
</van-col>
<van-col span="1" style="line-height: 32px; color: red">
<i
v-show="currentSongUrl == item.pageUrl"
class="bi bi-soundwave"
></i>
</van-col>
<van-col
span="4"
style="float: right; color: gray; line-height: 32px"
>
<div v-if="item.isBlocked">
<span @click="unblockTheSong(item.songId)">
<i
class="bi bi-unlock-fill"
style="font-size: 16px"
></i>
</span>
<!-- <span @click="searchTheSong(item.pageUrl)">
<i class="bi bi-search" style="font-size: 16px"></i>
</span> -->
</div>
</van-col>
</van-row>
</van-col>
</RecycleScroller>
</van-col>
</div>
</van-tab>
</van-tabs>
</div>
</template>
<style>
:root {
--van-tabs-line-height: 110px;
}
.van-overlay {
height: calc(100% - 138px);
}
.van-popup__close-icon--top-right {
position: fixed;
padding: 5px 11px 11px 5px;
width: 10px;
height: 10px;
border-radius: 100%;
background: rgb(255, 255, 255);
}
</style>
<script>
import {
searchSongs,
getAllPlaylist,
getPlaylistDetail,
createSyncSongFromPlaylistJob,
createSyncSongWithSongIdJob,
} from "../../api";
import {
secondDurationToDisplayDuration,
sourceCodeToName,
ellipsis,
} from "../../utils";
import SearchResultList from "../../components/SearchResultListForMobile.vue";
import { startTaskListener } from "../../components/TaskNotificationForMobile";
import { Notify, Dialog } from "vant";
import { ref } from "vue";
import { getProperPlayUrl } from "../../utils/audio";
export default {
data: () => {
return {
currentSongUrl: "",
lastSearch: "",
showBlockSongsOnly: false,
playlists: [],
playlistDetails: [],
searchResult: [],
suggestMatchSongId: "",
};
},
components: {
SearchResultList: SearchResultList,
},
props: {
playTheSongWithPlayUrl: {
type: Function,
required: true,
},
playTheSong: {
type: Function,
required: true,
},
},
setup(props, { emit }) {
const tableRef = ref();
const active = ref(0);
const showPopup = ref(false);
return {
tableRef,
active,
ellipsis,
showPopup,
};
},
async mounted() {
const playlistRet = await getAllPlaylist();
this.playlists = playlistRet.data.playlists;
},
methods: {
async internalPlayTheSongWithPlayUrl(playOption, playlistIndex, songIndex) {
if (playOption.isBlocked) {
this.searchTheSong(playOption.pageUrl);
return;
}
// 处理 playUrl
if (playOption.playUrl) {
playOption.playUrl = getProperPlayUrl(
playOption.source,
playOption.playUrl,
playOption.pageUrl
);
}
if (await this.playTheSongWithPlayUrl(playOption)) {
this.currentSongUrl = playOption.pageUrl;
}
},
async unblockThePlaylist(playlistId) {
Dialog.confirm({
confirmButtonText: "解锁全部",
message:
"【智能解锁全部】是一个实验性功能,会根据歌曲名和歌手尝试寻找最合适的来源,但也可能会有货不对版的情况,请谨慎使用。你也可以点击下边单首歌曲的任意区域进入搜索页面,进行手动解锁",
}).then(async () => {
// on confirm
Notify({
message: "开始解锁歌单",
type: "primary",
duration: 1000,
});
const ret = await createSyncSongFromPlaylistJob(playlistId, {
// TODO 先 hard code后面 mobile 端再做配置
syncWySong: false,
syncNotWySong: true,
});
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
});
},
async refreshThePlaylist(tabIndex) {
Notify({
message: "开始刷新",
type: "primary",
duration: 1000,
});
if (await this.showPlaylistDetail(tabIndex, false)) {
Notify({
message: "歌单刷新成功",
type: "success",
duration: 1000,
});
} else {
Notify({
message: "歌单刷新失败",
type: "error",
duration: 1000,
});
}
},
async unblockTheSong(songId) {
const ret = await createSyncSongWithSongIdJob(songId);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async showPlaylistDetail(tabIndex, useCache = true) {
if (useCache && this.playlistDetails[tabIndex]) {
return;
}
const playlistId = this.playlists[tabIndex].id;
const detailRet = await getPlaylistDetail(playlistId);
const playlists = detailRet.data.playlists;
playlists.songs = playlists.songs.map((song) => {
song.duration = secondDurationToDisplayDuration(song.duration);
return song;
});
this.playlistDetails[tabIndex] = playlists;
return true;
},
async searchTheSong(pageUrl) {
this.showPopup = true;
if (this.lastSearch === pageUrl) {
return;
}
this.searchResult = [];
console.log(pageUrl);
if (pageUrl.indexOf("163.com") >= 0 && pageUrl.indexOf("/song") >= 0) {
const songIdMatch = pageUrl.match(/id=([\d]+)/);
if (songIdMatch && songIdMatch.length > 1) {
this.suggestMatchSongId = songIdMatch[1];
}
}
const result = await searchSongs({ keyword: pageUrl });
console.log(result);
const songs = result.data.songs
.map((song) => {
song.album = song.album != "" ? `${song.album}` : " - ";
song.duration = secondDurationToDisplayDuration(song.duration);
song.sourceName = sourceCodeToName(song.source);
return song;
})
.filter((song) => song.songName.length > 0);
console.log(JSON.stringify(songs));
this.searchResult = songs;
this.lastSearch = pageUrl;
},
onTabChange(tabIndex) {
this.showPlaylistDetail(tabIndex);
},
onRendered(tabIndex) {
if (tabIndex !== 0) {
return;
}
this.showPlaylistDetail(tabIndex);
},
closeThePopup() {
this.showPopup = false;
},
},
beforeRouteLeave(to, from, next) {
if (this.showPopup) {
this.showPopup = false;
next(false);
} else {
next();
}
},
};
</script>

View File

@@ -0,0 +1,842 @@
<template>
<el-container class="account-container">
<el-main>
<!-- Melody Key 设置部分 -->
<el-row v-if="!registedMK" class="mk-setup">
<el-col :span="16" :offset="4">
<el-card class="mk-card" shadow="hover">
<div class="welcome-text">
<h2>欢迎使用 Melody</h2>
<p class="subtitle">填写你的 Melody Key 开启音乐之旅</p>
</div>
<el-row class="input-row">
<el-col :span="16" :offset="4">
<el-input
v-model="mk"
placeholder="Key 默认为 melody , 如果你部署到公网,请到配置里修改该默认值(后续支持 UI 管理账号)"
size="large"
:prefix-icon="Key"
>
</el-input>
</el-col>
</el-row>
<el-row>
<el-col :span="16" :offset="4" class="btn-wrapper">
<el-button type="primary" @click="checkMK" size="large" round>
<el-icon class="icon"><Check /></el-icon>确认
</el-button>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
<!-- 账号信息展示部分 -->
<el-row v-if="account.uid" class="account-info">
<el-col :span="16" :offset="4">
<!-- Melody Key 信息卡片 -->
<el-card class="info-card" shadow="hover">
<div class="mk-info">
<div class="mk-text">
<el-icon><Key /></el-icon>
<span>你的 Melody Key: </span>
<code>{{ account.uid }}</code>
</div>
<el-button
type="danger"
plain
size="small"
@click="logoutMK"
class="logout-btn"
>
退出 Melody 账号
</el-button>
</div>
</el-card>
<!-- 网易云账号卡片 -->
<el-card class="info-card wy-card" shadow="hover">
<template #header>
<div class="card-header">
<span>网易云账号信息</span>
</div>
</template>
<div class="wy-account-info">
<div class="avatar-section">
<el-image
:src="account.wyAccount?.avatarUrl"
class="avatar"
fit="cover"
>
<template #error>
<div class="avatar-placeholder">
<el-icon><UserFilled /></el-icon>
</div>
</template>
</el-image>
<div class="account-status">
<template v-if="account.wyAccount">
<span class="nickname">{{
account.wyAccount.nickname
}}</span>
<el-tag size="small" type="success">已绑定</el-tag>
</template>
<template v-else>
<span class="bind-tip">请先绑定正确的网易云账号</span>
</template>
</div>
</div>
<!-- 登录方式选择 -->
<div class="login-section">
<el-radio-group v-model="account.loginType" class="login-type">
<el-radio-button label="qrcode">扫码登录</el-radio-button>
<el-radio-button label="phone">手机号登录</el-radio-button>
<el-radio-button label="email">邮箱登录</el-radio-button>
</el-radio-group>
<!-- 登录表单 -->
<div
class="login-form"
v-if="['phone', 'email'].includes(account.loginType)"
>
<el-form label-position="top">
<el-form-item
v-if="account.loginType == 'phone'"
label="国际电话区号"
>
<el-input
v-model="account.countryCode"
placeholder="默认86不需要输入 +"
maxlength="4"
></el-input>
</el-form-item>
<el-form-item
:label="account.loginType == 'phone' ? '手机号' : '邮箱'"
>
<el-input v-model="account.account"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input
type="password"
v-model="account.password"
show-password
></el-input>
</el-form-item>
</el-form>
</div>
<!-- 二维码登录 -->
<div v-if="account.loginType === 'qrcode'" class="qr-section">
<div class="qr-wrapper">
<el-image :src="qrLogin.qrCode" class="qr-code">
<template #error>
<div class="qr-error">
<p>
{{
!account.wyAccount
? "二维码已失效,请点击刷新"
: "如需切换绑定的账号,点击按钮刷新二维码"
}}
</p>
</div>
</template>
</el-image>
<el-button
class="refresh-btn"
circle
type="primary"
@click="refreshQRCode"
>
<el-icon><Refresh /></el-icon>
</el-button>
</div>
<p class="qr-tip">请使用网易云音乐 app 扫码登录</p>
</div>
</div>
</div>
</el-card>
<!-- 昵称设置 -->
<el-card class="info-card name-card" shadow="hover">
<template #header>
<div class="card-header">
<span>昵称</span>
</div>
</template>
<el-input
v-model="account.name"
placeholder="请输入昵称"
></el-input>
</el-card>
<!-- 同步设置卡片 -->
<el-card class="info-card sync-card" shadow="hover">
<template #header>
<div class="card-header">
<span>备份歌单的歌曲到网易云云盘</span>
<el-tooltip placement="top">
<template #content>
<p>
1. 开启自动同步后Melody
会按照指定频率自动将你的歌单里的所有歌曲同步到网易云云盘
</p>
<p>
2.
当频率为小时时将在整点执行如每8小时则在0点816点执行
</p>
<p>3. 当频率为天时将在每天0点执行</p>
</template>
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<div class="sync-settings">
<el-form label-position="left" label-width="120px">
<el-form-item label="自动同步">
<el-switch
v-model="
account.config.playlistSyncToWyCloudDisk.autoSync.enable
"
/>
</el-form-item>
<el-form-item
label="同步频率"
v-if="
account.config.playlistSyncToWyCloudDisk.autoSync.enable
"
>
<div class="frequency-input">
<span>每</span>
<el-input-number
v-model="
account.config.playlistSyncToWyCloudDisk.autoSync
.frequency
"
:min="1"
:max="30"
controls-position="right"
/>
<el-radio-group
v-model="
account.config.playlistSyncToWyCloudDisk.autoSync
.frequencyUnit
"
>
<el-radio-button label="hour">小时</el-radio-button>
<el-radio-button label="day">天</el-radio-button>
</el-radio-group>
</div>
</el-form-item>
<el-form-item label="音质偏好">
<el-radio-group
v-model="
account.config.playlistSyncToWyCloudDisk
.soundQualityPreference
"
>
<el-radio-button label="high">高质量</el-radio-button>
<el-radio-button label="lossless">无损</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="同步选项">
<div class="sync-options">
<el-checkbox
v-model="
account.config.playlistSyncToWyCloudDisk.autoSync.onlyCreatedPlaylists
"
>
仅同步我创建的歌单
<el-tag size="small" type="success">推荐</el-tag>
</el-checkbox>
<el-checkbox
v-model="
account.config.playlistSyncToWyCloudDisk.syncWySong
"
>
上传网易云已有歌曲到云盘
<el-tag size="small" type="success">推荐</el-tag>
</el-checkbox>
<el-checkbox
v-model="
account.config.playlistSyncToWyCloudDisk.syncNotWySong
"
>
解锁灰色歌曲
<el-tag size="small" type="warning"
>不推荐自动同步</el-tag
>
</el-checkbox>
</div>
</el-form-item>
<el-card
class="info-card"
shadow="hover"
v-if="
account.config.playlistSyncToWyCloudDisk.autoSync.enable
"
>
<template #header>
<div class="card-header">
<el-icon><Timer /></el-icon>
<span class="header-text">下次同步时间</span>
</div>
</template>
<div
v-if="nextCloudRun && nextCloudRun[account.uid]"
class="next-run-content"
>
<el-row :gutter="20" justify="center" align="middle">
<el-col :span="12" class="next-run-item">
<div class="label">下次同步时间</div>
<div class="value">
{{
new Date(
nextCloudRun[account.uid].nextRunTime
).toLocaleString()
}}
</div>
</el-col>
<el-col :span="12" class="next-run-item">
<div class="label">距离下次同步</div>
<div class="value">
{{
Math.round(
nextCloudRun[account.uid].remainingMs / 1000 / 60
)
}}
分钟
</div>
</el-col>
</el-row>
</div>
<div v-else class="next-run-content">
<el-empty description="暂无调度信息" :image-size="60" />
</div>
</el-card>
</el-form>
</div>
</el-card>
<!-- 更新按钮 -->
<div class="update-btn-wrapper">
<el-button type="primary" @click="updateAccount" size="large" round>
<el-icon><Check /></el-icon>更新配置
</el-button>
</div>
</el-col>
</el-row>
</el-main>
</el-container>
</template>
<style scoped>
.account-container {
padding: 20px;
}
/* Melody Key 设置样式 */
.mk-setup {
margin-top: 60px;
}
.mk-card {
padding: 40px 20px;
}
.welcome-text {
text-align: center;
margin-bottom: 30px;
}
.welcome-text h2 {
color: #303133;
margin-bottom: 10px;
}
.subtitle {
color: #909399;
font-size: 14px;
}
.input-row {
margin-bottom: 20px;
}
.btn-wrapper {
text-align: center;
}
/* 账号信息卡片通用样式 */
.info-card {
margin-bottom: 20px;
transition: all 0.3s;
}
.info-card:hover {
transform: translateY(-2px);
}
.card-header {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 500;
}
.card-header .el-icon {
margin-left: 8px;
color: #909399;
cursor: pointer;
}
/* Melody Key 信息样式 */
.mk-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
}
.mk-text {
display: flex;
align-items: center;
gap: 8px;
}
.mk-text code {
background: #f5f7fa;
padding: 4px 8px;
border-radius: 4px;
font-family: monospace;
}
/* 网易云账号样式 */
.wy-account-info {
padding: 20px;
}
.avatar-section {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 30px;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
border: 2px solid #ebeef5;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: #dcdfe6;
}
.account-status {
display: flex;
flex-direction: column;
gap: 8px;
}
.nickname {
font-size: 18px;
font-weight: 500;
}
.bind-tip {
color: #909399;
}
/* 登录部分样式 */
.login-section {
margin-top: 20px;
}
.login-type {
margin-bottom: 20px;
}
.login-form {
max-width: 400px;
margin: 0 auto;
}
/* 二维码部分样式 */
.qr-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.qr-wrapper {
position: relative;
width: 180px;
height: 180px;
}
.qr-code {
width: 100%;
height: 100%;
}
.qr-error {
width: 100%;
height: 100%;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
color: #909399;
font-size: 14px;
}
.refresh-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.3s;
}
.qr-wrapper:hover .refresh-btn {
opacity: 1;
}
.qr-tip {
color: #909399;
font-size: 14px;
}
/* 同步设置样式 */
.sync-settings {
padding: 20px;
}
.frequency-input {
display: flex;
align-items: center;
gap: 10px;
}
.sync-options {
display: flex;
flex-direction: column;
gap: 10px;
}
/* 更新按钮容器 */
.update-btn-wrapper {
text-align: center;
margin: 40px 0;
}
:deep(.el-form-item__label) {
font-weight: 500;
}
.next-run-content {
padding: 20px 0;
}
.next-run-item {
text-align: center;
}
.next-run-item .label {
color: #909399;
font-size: 13px;
margin-bottom: 8px;
}
.next-run-item .value {
color: #303133;
font-size: 14px;
font-weight: 500;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.header-text {
font-size: 14px;
font-weight: 500;
}
:deep(.el-empty) {
padding: 20px 0;
}
:deep(.el-card__header) {
padding: 15px 20px;
}
</style>
<script>
import {
Key,
Check,
Refresh,
UserFilled,
QuestionFilled,
Timer,
} from "@element-plus/icons-vue";
import { getAccount, setAccount, qrLoginCreate, qrLoginCheck } from "../../api";
import storage from "../../utils/storage";
import { ElMessage } from "element-plus";
import { getNextRunInfo } from "../../api";
export default {
components: {
Key,
Check,
Refresh,
UserFilled,
QuestionFilled,
Timer,
},
data: () => {
return {
mk: "",
account: {
loginType: "",
account: 0,
password: "",
platform: "wy",
uid: "",
countryCode: "",
config: {
playlistSyncToWyCloudDisk: {
autoSync: {
enable: false,
frequency: 1,
frequencyUnit: "day",
onlyCreatedPlaylists: true,
},
syncWySong: true,
syncNotWySong: false,
soundQualityPreference: "high",
},
},
name: "",
},
registedMK: false,
qrLogin: {
qrCode: "",
qrKey: "",
},
nextCloudRun: null,
};
},
async mounted() {
this.registedMK = storage.get("mk") ? true : false;
if (!this.registedMK) {
return;
}
console.log(this.registedMK);
const ret = await getAccount();
if (ret === false || !ret.data) {
this.registedMK = false;
return;
}
this.account = ret.data.account;
storage.set("wyAccount", ret.data.account.wyAccount);
console.log(this.account);
this.loadNextRunInfo();
},
methods: {
async refreshQRCode() {
const ret = await qrLoginCreate();
if (ret === false || ret.status != 0) {
ElMessage({
center: true,
type: "error",
message: "生成登录二维码失败",
});
return false;
}
this.qrLogin.qrKey = ret.data.qrKey;
this.qrLogin.qrCode = ret.data.qrCode;
// loop check qr code
if (this.qrLogin.qrKey) {
console.log("start check qr code");
const IntervalID = setInterval(async () => {
if (!this.qrLogin.qrKey) {
console.log("stop check qr code");
clearInterval(IntervalID);
return;
}
if (await this.checkQRCode()) {
this.qrLogin.qrKey = "";
this.qrLogin.qrCode = "";
clearInterval(IntervalID);
}
}, 1000);
}
},
async checkQRCode() {
if (!this.qrLogin.qrKey) {
return false;
}
const ret = await qrLoginCheck(this.qrLogin.qrKey);
if (ret === false || ret.status != 0 || !ret.data.wyQrStatus) {
console.log("checkQRCode failed");
return false;
}
// 800 为二维码过期; 801 为等待扫码; 802 为待认; 803 为授权登录成功
console.log(`checkQRCode wyStatus: ${ret.data.wyQrStatus}`);
if (ret.data.wyQrStatus == 800) {
ElMessage({
center: true,
type: "error",
message: "二维码已失效, 请手动刷新",
});
this.qrLogin.qrKey = "";
this.qrLogin.qrCode = "";
return false;
}
if (ret.data.wyQrStatus != 803) {
return false;
}
this.account = ret.data.account;
storage.set("wyAccount", ret.data.account.wyAccount);
return true;
},
async checkMK() {
this.mk = this.mk.trim();
if (!this.mk) {
return;
}
const ret = await getAccount({ mk: this.mk });
if (ret !== false && ret.data) {
this.account = ret.data.account;
this.registedMK = true;
storage.set("mk", this.mk);
storage.set("wyAccount", ret.data.account.wyAccount);
ElMessage({
center: true,
type: "success",
message: "Melody Key 设置成功",
});
} else {
ElMessage({
center: true,
type: "error",
message: "Melody Key 不正确哦",
});
}
},
async updateAccount() {
if (this.account.loginType !== "qrcode") {
if (
!this.account.account ||
!this.account.password ||
!this.account.loginType
) {
return;
}
}
if (this.account.loginType == "phone") {
if (this.account.countryCode) {
if (!/^[\d]{0,4}$/.test(this.account.countryCode)) {
ElMessage({
center: true,
type: "error",
message: "国际电话区号不正确",
});
return;
}
}
}
if (this.account.loginType == "email") {
if (
!/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(
this.account.account
)
) {
ElMessage({
center: true,
type: "error",
message: "邮箱格式不正确",
});
return;
}
}
const ret = await setAccount({
loginType: this.account.loginType,
countryCode: this.account.countryCode,
account: this.account.account,
password: this.account.password,
config: this.account.config,
name: this.account.name,
});
if (ret.status != 0) {
ElMessage({
center: true,
type: "error",
message: ret.message,
});
return;
}
if (ret.data.account) {
this.account = ret.data.account;
storage.set("wyAccount", ret.data.account.wyAccount);
}
ElMessage({
center: true,
type: "success",
message: "更新配置成功",
});
},
logoutMK() {
storage.del("mk");
this.registedMK = false;
this.mk = "";
this.account = {};
},
async loadNextRunInfo() {
const ret = await getNextRunInfo();
if (ret.status === 0) {
this.nextCloudRun = ret.data.cloudNextRuns;
}
},
},
watch: {
"account.config.playlistSyncToWyCloudDisk.autoSync": {
deep: true,
handler() {
this.loadNextRunInfo();
},
},
},
};
</script>

View File

@@ -0,0 +1,514 @@
<template>
<el-main>
<!-- 搜索区域 -->
<div class="search-container">
<el-row justify="center">
<el-col :span="16">
<el-row>
<el-col :span="20">
<el-input
v-model="keyword"
placeholder="网页链接 / 歌名"
clearable
@keyup.enter.native="onSearch"
class="search-input"
>
<template #prefix>
<i class="bi bi-search"></i>
</template>
</el-input>
</el-col>
<el-col :span="4">
<el-button
type="primary"
@click="onSearch"
class="search-btn"
:loading="isSearching"
>
搜索
</el-button>
</el-col>
</el-row>
</el-col>
</el-row>
<!-- 搜索提示 -->
<el-row v-if="searchTip" class="search-tip-row">
<el-col :span="12" :offset="3">
<div class="search-tip">
<span>{{ searchTip }}</span>
<span v-loading="isSearching"></span>
</div>
</el-col>
</el-row>
</div>
<!-- 精准搜索卡片 -->
<transition name="fade">
<el-row v-if="songMetaInfo !== null" class="song-card-container">
<el-col :span="13" :offset="4">
<el-card class="song-card" shadow="hover">
<el-row>
<el-col :span="6" style="width: 160px; height: 160px">
<img
:src="songMetaInfo.coverUrl"
onerror="this.src='https://cdnmusic.migu.cn/v3/static/img/common/default/img_default_240x240.jpg'"
class="image"
style="width: 100%; height: 100%"
/>
</el-col>
<el-col :span="18">
<!-- <div style="padding: 14px"></div> -->
<div
style="
position: relative;
z-index: 2;
overflow: hidden;
width: 100%;
height: 100%;
"
>
<div
style="
width: 100%;
height: 100%;
position: absolute;
top: 0;
color: white;
margin-top: 20px;
padding-left: 18px;
"
>
<el-row style="font-size: 20px; text-align: left">
{{ songMetaInfo.songName }}
</el-row>
<el-row>
<el-col :span="10">
<el-row
style="
margin-top: 20px;
font-size: 13px;
text-align: left;
"
>
歌手 {{ songMetaInfo.artist }}
</el-row>
<el-row
style="
margin-top: 13px;
font-size: 13px;
text-align: left;
"
>
专辑{{ songMetaInfo.album }}
</el-row>
<el-row
style="
margin-top: 13px;
font-size: 13px;
text-align: left;
"
>
时长 {{ songMetaInfo.duration }}
</el-row>
</el-col>
<el-col
:span="14"
style="text-align: left; margin-top: 20px"
>
<el-link
@click="playTheSong(songMetaInfo)"
:underline="false"
style="color: white"
>
<i
class="bi bi-play-circle"
style="font-size: 40px"
></i>
</el-link>
<el-tooltip
:content="
wyAccount
? '上传歌曲到云盘'
: '上传歌曲到云盘(请先绑定网易云账号)'
"
placement="bottom"
>
<el-link
@click="uploadToCloud(songMetaInfo.pageUrl)"
:underline="false"
:disabled="!wyAccount ? true : false"
style="
color: white;
margin-left: 20px;
margin-top: 6px;
"
>
<i
class="bi bi-cloud-upload"
style="font-size: 40px"
></i>
</el-link>
</el-tooltip>
<!-- download to service local -->
<el-tooltip
:content="
globalConfig.downloadPathExisted
? '下载到服务器'
: '下载到服务器(请先配置下载路径)'
"
placement="bottom"
>
<el-link
@click="
downloadToLocalService(songMetaInfo.pageUrl)
"
:underline="false"
:disabled="
!globalConfig.downloadPathExisted ? true : false
"
style="
color: white;
margin-left: 20px;
margin-top: 6px;
"
>
<i
class="bi bi-cloud-download"
style="font-size: 40px"
></i>
</el-link>
</el-tooltip>
</el-col>
</el-row>
</div>
<div
style="
position: absolute;
filter: blur(32px);
transform: scale(1.2);
top: 0;
z-index: -1;
width: 100%;
height: 100%;
"
>
<img
:src="songMetaInfo.coverUrl"
onerror="this.src='https://cdnmusic.migu.cn/v3/static/img/common/default/img_default_240x240.jpg'"
style="width: 100%; height: 100%"
/>
</div>
</div>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
</transition>
<!-- 搜索结果表格 -->
<transition name="slide-fade">
<el-row v-if="searchResult.length > 0" class="search-result-container">
<SearchResultTable
:playTheSong="playTheSong"
:abortTheSong="abortTheSong"
:suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult"
/>
</el-row>
<!-- Add music animation when no search -->
<el-row v-else class="music-animation-container">
<div class="music-notes">
<i class="bi bi-music-note-beamed note"></i>
<i class="bi bi-music-note note"></i>
<i class="bi bi-vinyl note"></i>
<i class="bi bi-music-note-beamed note"></i>
<i class="bi bi-music-note note"></i>
</div>
</el-row>
</transition>
</el-main>
</template>
<script>
import {
searchSongs,
getSongsMeta,
createSyncSongFromUrlJob,
getGlobalConfig,
} from "../../api";
import SearchResultTable from "../../components/SearchResultTable.vue";
import { secondDurationToDisplayDuration, sourceCodeToName } from "../../utils";
import { startTaskListener } from "../../components/TaskNotification";
import storage from "../../utils/storage";
export default {
data: () => {
return {
suggestMatchSongId: "",
songMetaInfo: null,
playUrl: "",
keyword: "",
searchTip: "",
isSearching: false,
searchResult: [],
wyAccount: null,
globalConfig: null,
};
},
props: {
playTheSong: {
type: Function,
required: true,
},
abortTheSong: {
type: Function,
required: true,
},
},
async mounted() {
this.wyAccount = storage.get("wyAccount");
this.loadGlobalConfig();
},
watch: {
$route(to, from) {
this.wyAccount = storage.get("wyAccount");
if (to.path === "/" || to.path === "/home" || to.path === "") {
this.loadGlobalConfig();
}
},
},
setup(props, { emit }) {
const playTheSong = (songMeta, pageUrl, suggestMatchSongId) => {
props.playTheSong(songMeta, pageUrl, suggestMatchSongId);
};
const abortTheSong = () => {
props.abortTheSong();
};
return {
abortTheSong,
playTheSong,
};
},
components: {
SearchResultTable,
},
methods: {
async uploadToCloud(pageUrl) {
const ret = await createSyncSongFromUrlJob(pageUrl);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async loadGlobalConfig() {
const globalConfig = await getGlobalConfig();
if (globalConfig !== false && globalConfig.data) {
this.globalConfig = globalConfig.data;
}
},
async onSearch() {
if (this.keyword.trim().length === 0) {
return;
}
this.songMetaInfo = null;
this.searchTip = `正在搜索 ${this.keyword}`;
this.isSearching = true;
if (
this.keyword.indexOf("163.com") >= 0 &&
this.keyword.indexOf("/song") >= 0
) {
const songIdMatch = this.keyword.match(/id=([\d]+)/);
if (songIdMatch && songIdMatch.length > 1) {
this.suggestMatchSongId = songIdMatch[1];
}
}
try {
if (this.keyword.indexOf("http") >= 0) {
getSongsMeta({ url: this.keyword }).then((ret) => {
const info = ret.data.songMeta;
if (info) {
info.album = info.album != "" ? info.album : "未知";
info.duration = secondDurationToDisplayDuration(info.duration);
info.sourceName = sourceCodeToName(info.source);
this.songMetaInfo = info;
} else {
this.songMetaInfo = null;
}
});
}
const result = await searchSongs({ keyword: this.keyword });
console.log(result);
const songs = result.data.songs
.map((song) => {
song.album = song.album != "" ? `${song.album}` : " - ";
song.duration = secondDurationToDisplayDuration(song.duration);
song.sourceName = sourceCodeToName(song.source);
return song;
})
.filter((song) => song.songName.length > 0);
console.log(JSON.stringify(songs));
this.searchResult = songs;
this.searchTip = "";
} catch (e) {
this.searchTip = "搜索失败";
} finally {
this.isSearching = false;
}
},
},
};
</script>
<style scoped>
.search-container {
margin-top: 40px;
margin-bottom: 30px;
}
.search-input {
.el-input__inner {
border-radius: 24px;
padding-left: 45px;
height: 48px;
font-size: 16px;
}
.el-input__prefix {
left: 15px;
font-size: 18px;
color: #909399;
}
}
.search-btn {
width: 100px;
height: 48px;
border-radius: 24px;
font-size: 16px;
}
.search-tip {
font-size: 18px;
color: #606266;
margin-top: 15px;
}
.song-card-container {
margin-bottom: 30px;
}
.song-card {
transition: all 0.3s;
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
}
.search-result-container {
margin-bottom: 60px;
}
/* 动画效果 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(20px);
opacity: 0;
}
.music-animation-container {
height: 400px;
margin-top: 60px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.music-notes {
position: relative;
width: 300px;
}
.note {
position: absolute;
font-size: 24px;
color: #409eff;
opacity: 0;
animation: float 4s ease-in-out infinite;
}
.note:nth-child(1) {
left: 10%;
animation-delay: 0s;
}
.note:nth-child(2) {
left: 30%;
animation-delay: 1s;
}
.note:nth-child(3) {
left: 50%;
font-size: 32px;
animation-delay: 2s;
}
.note:nth-child(4) {
left: 70%;
animation-delay: 1.5s;
}
.note:nth-child(5) {
left: 90%;
animation-delay: 0.5s;
}
@keyframes float {
0% {
transform: translateY(120px) rotate(5deg);
opacity: 0;
}
20% {
opacity: 0.8;
}
80% {
opacity: 0.8;
}
100% {
transform: translateY(-120px) rotate(-5deg);
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,465 @@
<template>
<el-container style="margin-top: 20px">
<el-aside width="300px" style="margin-left: 80px">
<el-scrollbar height="600px">
<div v-for="item in playlists" :key="item" class="scrollbar-item">
<el-link :underline="false" @click="showPlaylistDetail(item.id)">
<el-row>
<el-col :span="5">
<el-image
style="width: 100%; height: 100%; float: left"
:src="item.cover"
fit="cover"
/>
</el-col>
<el-col :span="16" style="margin-top: 20px">
{{ item.name }}
</el-col>
</el-row>
</el-link>
</div>
</el-scrollbar>
</el-aside>
<el-main style="padding: 0; margin-left: 30px">
<el-dialog v-model="showSearchPage" width="1080px" center>
<el-scrollbar class="search-scollbar">
<p v-if="this.searchTip" style="font-size: 20px; text-align: center">
{{ this.searchTip }}
</p>
<SearchResultTable
:playTheSong="playTheSong"
:abortTheSong="abortTheSong"
:suggestMatchSongId="suggestMatchSongId"
:searchResult="searchResult"
>
</SearchResultTable>
</el-scrollbar>
</el-dialog>
<el-row v-if="playlistDetail.id" justify="center" style="height: 150px">
<el-col :span="2">
<el-image
style="width: 100px; height: 100px"
:src="playlistDetail.cover"
fit="cover"
/>
</el-col>
<el-col :span="18" :offset="1">
<el-row style="font-size: 20px">{{ playlistDetail.name }}</el-row>
<el-row style="color: grey; font-size: 10px; margin-top: 5px">
全部: {{ playlistDetail.songs.length }} | 待解锁:
{{ playlistDetail.songs.filter((song) => song.isBlocked).length }}
</el-row>
<el-row style="margin-top: 20px; text-align: left">
<el-col :span="6" style="height: 32px; line-height: 32px">
<el-link
type="primary"
:underline="false"
@click="unblockThePlaylist(playlistDetail.id)"
>
<i
class="bi bi-unlock-fill"
style="font-size: 18px; padding-right: 3px"
></i>
<span style="font-size: 15px"> 解锁全部 </span>
<span style="font-size: 10px"> (实验性功能) </span>
</el-link>
</el-col>
<el-col :span="6" style="height: 32px; line-height: 32px">
<el-link
type="primary"
:underline="false"
@click="unblockThePlaylistForWySong(playlistDetail.id)"
>
<i
class="bi bi-upload"
style="font-size: 18px; padding-right: 3px"
></i>
<span style="font-size: 15px"> 备份到网易云云盘 </span>
</el-link>
</el-col>
<el-col :span="6" style="height: 32px; line-height: 32px">
<el-link
type="primary"
:underline="false"
@click="syncThePlaylistToLocalService(playlistDetail.id)"
>
<i
class="bi bi-download"
style="font-size: 18px; padding-right: 3px"
></i>
<span style="font-size: 15px"> 同步到服务器本地 </span>
</el-link>
</el-col>
<el-col :span="7">
<el-switch
style="float: left"
v-model="showBlockSongsOnly"
active-text="仅展示无法播放的歌曲"
@change="filterHandlerChange($event)"
/>
</el-col>
</el-row>
</el-col>
</el-row>
<el-row
v-if="playlistDetail.id"
justify="center"
style="margin-bottom: 60px"
>
<el-scrollbar height="800px">
<el-table
ref="tableRef"
:data="playlistDetail.songs"
height="800"
empty-text="开心本歌单没有无法播放的歌~"
:stripe="true"
:key="tableKey"
>
<el-table-column type="index" width="50" />
<el-table-column prop="songName" label="歌曲" width="300" />
<el-table-column prop="artists[0]" label="歌手" width="100" />
<el-table-column prop="album" label="专辑" width="200" />
<el-table-column prop="duration" label="时长" width="100" />
<el-table-column
label="状态"
width="100"
:filters="[
{ text: '全部', value: 'all' },
{ text: '无法播放', value: 'blocked' },
{ text: '云盘歌曲', value: 'cloud' },
]"
:filter-method="filterHandler"
:filtered-value="tableFilterValues"
>
<template #default="scope">
<i
v-if="scope.row.isBlocked"
class="bi bi-lock-fill"
style="font-size: 20px; color: gray"
></i>
<i
v-else-if="scope.row.isCloud"
class="bi bi-cloud"
style="font-size: 20px"
></i>
<i
v-else
class="bi bi-heart-fill"
style="color: red; font-size: 20px"
></i>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<div v-if="scope.row.isBlocked">
<el-tooltip content="搜索歌曲" placement="top">
<el-link
type="primary"
:underline="false"
@click="searchTheSong(scope.row.pageUrl)"
>
<i class="bi bi-search" style="font-size: 20px"></i>
</el-link>
</el-tooltip>
<el-tooltip content="尝试解锁歌曲" placement="top">
<el-link
type="primary"
:underline="false"
@click="unblockTheSong(scope.row.songId)"
style="margin-left: 15px"
>
<i class="bi bi-unlock-fill" style="font-size: 20px"></i>
</el-link>
</el-tooltip>
</div>
<div v-else-if="!scope.row.isBlocked">
<el-tooltip content="播放歌曲" placement="top">
<el-link
type="primary"
:underline="false"
@click="
playTheSongWithPlayUrl({
songId: scope.row.songId,
playUrl: scope.row.playUrl,
coverUrl: scope.row.cover,
songName: scope.row.songName,
pageUrl: scope.row.pageUrl,
artist: scope.row.artists[0],
})
"
>
<i class="bi bi-play-circle" style="font-size: 20px"></i>
</el-link>
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
</el-scrollbar>
</el-row>
</el-main>
</el-container>
</template>
<style>
.el-overlay,
.el-overlay-dialog {
height: calc(100% - 60px);
}
</style>
<style scoped>
.scrollbar-item {
display: flex;
align-items: center;
height: 60px;
margin: 8px 12px;
padding: 0 15px;
border-radius: 8px;
background: var(--el-color-primary-light-9);
transition: all 0.3s ease;
cursor: pointer;
}
.scrollbar-item:hover {
transform: translateX(5px);
background: var(--el-color-primary-light-8);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.scrollbar-item .el-link {
width: 100%;
}
.scrollbar-item .el-row {
width: 100%;
}
.scrollbar-item .el-image {
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.scrollbar-item .el-col {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<script>
import {
searchSongs,
getAllPlaylist,
getPlaylistDetail,
createSyncSongFromPlaylistJob,
createSyncSongWithSongIdJob,
createSyncThePlaylistToLocalServiceJob,
} from "../../api";
import { secondDurationToDisplayDuration, sourceCodeToName } from "../../utils";
import SearchResultTable from "../../components/SearchResultTable.vue";
import { startTaskListener } from "../../components/TaskNotification";
import { ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
export default {
data: () => {
return {
searchTip: "",
showSearchPage: false,
tableKey: 1,
showBlockSongsOnly: true,
tableFilterValues: ["blocked"],
playlists: [],
playlistDetail: {},
searchResult: [],
suggestMatchSongId: "",
lastSearch: "",
};
},
components: {
SearchResultTable,
},
props: {
playTheSongWithPlayUrl: {
type: Function,
required: true,
},
abortTheSong: {
type: Function,
required: true,
},
playTheSong: {
type: Function,
required: true,
},
},
setup(props, { emit }) {
const tableRef = ref();
const playTheSongWithPlayUrl = (playOption) => {
props.playTheSongWithPlayUrl(playOption);
};
const playTheSong = (metaInfo, playUrl, suggestMatchSongId) => {
props.playTheSong(metaInfo, playUrl, suggestMatchSongId);
};
const abortTheSong = () => {
props.abortTheSong();
};
return {
playTheSongWithPlayUrl,
playTheSong,
abortTheSong,
tableRef,
};
},
async mounted() {
console.log("mounted");
const playlistRet = await getAllPlaylist();
this.playlists = playlistRet.data.playlists;
},
methods: {
async unblockThePlaylist(playlistId) {
ElMessageBox.confirm(
"【智能解锁全部】是一个实验性功能会根据歌曲名<E69BB2><E5908D>歌手尝试寻找最合适的来源但也可能会有货不对版的情况请谨慎使用。你也可以点击下边单首歌曲的搜索图标进入搜索页面进行手动解锁",
"Warning",
{
confirmButtonText: "解锁全部",
cancelButtonText: "取消",
type: "warning",
}
).then(async () => {
// on confirm
ElMessage({
message: "开始解锁歌单",
type: "info",
duration: 1000,
});
const ret = await createSyncSongFromPlaylistJob(playlistId, {
syncWySong: false,
syncNotWySong: true,
});
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
});
},
async unblockThePlaylistForWySong(playlistId) {
ElMessage({
message: "开始将歌单中的歌曲备份到网易云云盘",
type: "info",
duration: 1000,
});
const ret = await createSyncSongFromPlaylistJob(playlistId, {
syncWySong: true,
syncNotWySong: false,
});
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async syncThePlaylistToLocalService(playlistId) {
const ret = await createSyncThePlaylistToLocalServiceJob(playlistId);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async unblockTheSong(songId) {
const ret = await createSyncSongWithSongIdJob(songId);
console.log(ret);
if (ret.data && ret.data.jobId) {
startTaskListener(ret.data.jobId);
}
},
async showPlaylistDetail(playlistId) {
console.log(`click playlist ${playlistId}`);
const detailRet = await getPlaylistDetail(playlistId);
const playlists = detailRet.data.playlists;
playlists.songs = playlists.songs.map((song) => {
song.duration = secondDurationToDisplayDuration(song.duration);
return song;
});
this.playlistDetail = playlists;
},
filterHandler(value, row, column) {
if (value == "all") {
return true;
}
if (value == "cloud") {
return row.isCloud;
}
if (value == "blocked") {
return row.isBlocked;
}
},
filterHandlerChange(showBlockedOnly) {
if (showBlockedOnly) {
this.showBlockedOnly = true;
this.tableFilterValues = ["blocked"];
} else {
this.showBlockedOnly = false;
this.tableFilterValues = ["all"];
}
this.tableKey++;
},
async searchTheSong(pageUrl) {
this.showSearchPage = true;
if (this.lastSearch === pageUrl) {
return;
}
this.searchTip = "正在搜索...";
this.searchResult = [];
console.log(pageUrl);
if (pageUrl.indexOf("163.com") >= 0 && pageUrl.indexOf("/song") >= 0) {
const songIdMatch = pageUrl.match(/id=([\d]+)/);
if (songIdMatch && songIdMatch.length > 1) {
this.suggestMatchSongId = songIdMatch[1];
}
}
const result = await searchSongs({ keyword: pageUrl });
console.log(result);
const songs = result.data.songs
.map((song) => {
song.album = song.album != "" ? `${song.album}` : " - ";
song.duration = secondDurationToDisplayDuration(song.duration);
song.sourceName = sourceCodeToName(song.source);
return song;
})
.filter((song) => song.songName.length > 0);
console.log(JSON.stringify(songs));
this.searchResult = songs;
this.searchTip = "";
this.lastSearch = pageUrl;
},
},
};
</script>
<style scoped>
@media (min-height: 500px) {
.search-scollbar {
height: 600px;
}
}
@media (min-height: 900px) {
.search-scollbar {
height: 700px;
}
}
</style>

View File

@@ -0,0 +1,513 @@
<template>
<el-container class="setting-container">
<el-main>
<!-- 组件更新 -->
<el-card class="setting-card">
<template #header>
<div class="card-header">
<span>核心组件版本更新</span>
</div>
</template>
<el-row align="middle" class="version-info">
<el-col :span="16">
<span class="label">当前使用的 media-get 版本号:</span>
<span class="version">{{ mediaGetVersion }}</span>
<span class="label">最新的版本号:</span>
<span class="version">{{ latestVersion }}</span>
</el-col>
<el-col :span="8" style="text-align: right">
<el-button
type="primary"
:disabled="updating"
@click="updateMediaGet"
class="update-btn"
>
<template v-if="!updating">更新 media-get</template>
<template v-else>更新中</template>
</el-button>
</el-col>
</el-row>
</el-card>
<!-- 本地下载配置 -->
<el-card class="setting-card">
<template #header>
<div class="card-header">
<span>本地下载配置</span>
</div>
</template>
<el-form label-position="right" label-width="180px">
<el-form-item label="下载路径">
<el-col :span="16">
<el-input v-model="downloadPath" placeholder="下载路径">
<template #append>
<el-tooltip placement="top">
<template #content>
<p>
1. 下载路径格式Mac/Linux: /path/to/... | Windows:
C:\Users\YourUserName\Downloads
</p>
<p>
2. 请注意如果本服务部署在 Docker 下载路径应当为
Docker
容器内的路径你需要将容器内的下载路径映射到宿主机的相应目录
</p>
</template>
<i class="bi bi-question-circle"></i>
</el-tooltip>
</template>
</el-input>
</el-col>
</el-form-item>
<el-form-item label="单曲下载的文件名格式">
<el-col :span="16">
<el-input
v-model="filenameFormat"
placeholder="留空则默认为:{songName}-{artist}"
>
<template #append>
<el-tooltip placement="top">
<template #content>
<p>支持的变量: {songName}, {artist}, {album}</p>
<p>示例: {album}-{artist}-{songName}</p>
<p>支持目录结构: {artist}/{album}/{songName}</p>
</template>
<i class="bi bi-question-circle"></i>
</el-tooltip>
</template>
</el-input>
</el-col>
</el-form-item>
</el-form>
</el-card>
<!-- 歌单同步配置 -->
<el-card class="setting-card">
<template #header>
<div class="card-header">
<span>歌单同步到本地</span>
<el-tooltip placement="top">
<template #content>
<p>1. 开启自动同步后Melody 会按照指定频率自动将你的歌单里的所有歌曲下载到本地</p>
<p>2. 当频率为小时时将在整点执行如每8小时则在0点816点执行</p>
<p>3. 当频率为天时将在每天0点执行</p>
</template>
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-form label-position="right" label-width="180px">
<el-form-item label="自动同步">
<el-col :span="4">
<el-switch
v-model="playlistSyncToLocal.autoSync.enable"
></el-switch>
</el-col>
<el-col :span="20" v-if="playlistSyncToLocal.autoSync.enable">
<span></span>
<el-input-number
v-model="playlistSyncToLocal.autoSync.frequency"
:min="1"
:max="30"
controls-position="right"
style="width: 120px; margin: 0 10px"
/>
<el-radio-group
v-model="playlistSyncToLocal.autoSync.frequencyUnit"
>
<el-radio-button label="hour">小时</el-radio-button>
<el-radio-button label="day"></el-radio-button>
</el-radio-group>
</el-col>
</el-form-item>
<el-form-item>
<el-checkbox v-model="playlistSyncToLocal.deleteLocalFile">
当歌单里的歌曲移除时同时删除本地对应的歌曲文件
</el-checkbox>
</el-form-item>
<el-form-item label="歌单歌曲的文件名格式">
<el-col :span="16">
<el-input
v-model="playlistSyncToLocal.filenameFormat"
placeholder="留空则默认为:{playlistName}/{songName}-{artist}"
>
<template #append>
<el-tooltip placement="top">
<template #content>
<p>
支持的变量: {playlistName}, {songName}, {artist},
{album}
</p>
<p>示例: {playlistName}/{album}-{artist}-{songName}</p>
</template>
<i class="bi bi-question-circle"></i>
</el-tooltip>
</template>
</el-input>
</el-col>
</el-form-item>
<!-- 添加音质选择配置 -->
<el-form-item label="音质偏好">
<el-radio-group
v-model="playlistSyncToLocal.soundQualityPreference"
>
<el-radio-button label="high">高质量</el-radio-button>
<el-radio-button label="lossless">无损</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
label="同步账号"
v-if="playlistSyncToLocal.autoSync.enable"
>
<el-checkbox-group v-model="playlistSyncToLocal.syncAccounts">
<el-checkbox
v-for="account in accounts"
:key="account.uid"
:label="account.uid"
>
{{ account.name || account.uid }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<!-- 移动到这里作为表单的补充信息 -->
<div v-if="playlistSyncToLocal.autoSync.enable" class="next-run-info">
<el-divider>
<el-icon><Timer /></el-icon>
<span class="divider-text">下次同步时间</span>
</el-divider>
<div v-if="nextLocalRun" class="next-run-content">
<el-row :gutter="20" justify="center" align="middle">
<el-col :span="12" class="next-run-item">
<div class="label">下次同步时间</div>
<div class="value">
{{ new Date(nextLocalRun.nextRunTime).toLocaleString() }}
</div>
</el-col>
<el-col :span="12" class="next-run-item">
<div class="label">距离下次同步</div>
<div class="value">
{{ Math.round(nextLocalRun.remainingMs / 1000 / 60) }} 分钟
</div>
</el-col>
</el-row>
</div>
<div v-else class="next-run-content">
<el-empty description="暂无调度信息" :image-size="60" />
</div>
</div>
</el-card>
<!-- 搜索站点配置 -->
<el-card class="setting-card">
<template #header>
<div class="card-header">
<span>搜索站点配置</span>
</div>
</template>
<div class="source-tip">
搜索耗时取决于最慢的网站请尽量勾选你的服务所在网络能够访问的网站
</div>
<el-checkbox-group v-model="checkedSources" class="source-list">
<el-checkbox
v-for="s in supportedSources"
:key="s.code"
:label="s.code"
>
{{ s.label }}
</el-checkbox>
</el-checkbox-group>
</el-card>
<div class="submit-container">
<el-button type="primary" @click="updateConfig" size="large">
保存设置
</el-button>
</div>
</el-main>
</el-container>
</template>
<style scoped>
.setting-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.setting-card {
margin-bottom: 20px;
transition: all 0.3s;
}
.setting-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
align-items: center;
font-size: 16px;
font-weight: bold;
}
.version-info {
line-height: 32px;
}
.version-info .label {
color: #606266;
margin-right: 8px;
}
.version-info .version {
font-weight: 500;
margin-right: 20px;
}
.update-btn {
min-width: 120px;
}
.source-tip {
color: #909399;
margin-bottom: 15px;
}
.source-list {
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.submit-container {
text-align: center;
margin: 40px 0;
padding: 20px 0;
border-top: 1px solid #ebeef5;
}
:deep(.el-form-item__label) {
font-weight: 500;
}
:deep(.el-input-group__append) {
padding: 0 10px;
cursor: pointer;
}
:deep(.el-card__header) {
border-bottom: 2px solid #f0f2f5;
padding: 15px 20px;
}
.next-run-info {
margin-top: 30px;
padding: 0 20px;
}
.divider-text {
margin-left: 8px;
font-size: 14px;
color: #909399;
}
.next-run-content {
padding: 20px 0;
}
.next-run-item {
text-align: center;
}
.next-run-item .label {
color: #909399;
font-size: 13px;
margin-bottom: 8px;
}
.next-run-item .value {
color: #303133;
font-size: 14px;
font-weight: 500;
}
:deep(.el-divider__text) {
display: flex;
align-items: center;
background-color: #fff;
}
:deep(.el-empty) {
padding: 20px 0;
}
</style>
<script>
import {
checkMediaFetcherLib,
updateMediaFetcherLib,
getGlobalConfig,
setGlobalConfig,
getAllAccounts,
getNextRunInfo,
} from "../../api";
import { ElMessage } from "element-plus";
import { Timer } from "@element-plus/icons-vue";
export default {
data: () => {
return {
mediaGetVersion: "查询中",
latestVersion: "查询中",
updating: false,
downloadPath: "",
filenameFormat: "",
checkedSources: [],
supportedSources: [], //[{code: "bilibili", label: "哔哩哔哩"}]
playlistSyncToLocal: {
autoSync: {
enable: false,
frequency: 1,
frequencyUnit: "day",
},
deleteLocalFile: false,
filenameFormat: "",
soundQualityPreference: "high",
syncAccounts: [],
},
accounts: [],
nextLocalRun: null,
};
},
components: {
Timer,
},
async mounted() {
const globalConfig = await getGlobalConfig();
if (globalConfig !== false && globalConfig.data) {
this.downloadPath = globalConfig.data.downloadPath;
this.filenameFormat = globalConfig.data.filenameFormat;
this.playlistSyncToLocal = globalConfig.data.playlistSyncToLocal;
this.supportedSources = Object.values(globalConfig.data.sourceConsts);
this.checkedSources = globalConfig.data.sources;
}
this.checklib();
this.loadNextRunInfo();
},
methods: {
async checklib() {
const ret = await checkMediaFetcherLib({ lib: "mediaGet" });
if (ret !== false && ret.data) {
this.mediaGetVersion = ret.data.mediaGetInfo.versionInfo;
this.latestVersion = ret.data.latestVersion;
}
},
async updateMediaGet() {
if (this.mediaGetVersion === this.latestVersion) {
ElMessage({
center: true,
type: "info",
message: "已经是最新版本",
});
return false;
}
this.updating = true; // Disable the button and show "更新中" text
const ret = await updateMediaFetcherLib(this.latestVersion);
if (ret === false || ret.status != 0) {
ElMessage({
center: true,
type: "error",
message: "更新失败",
});
this.updating = false; // Enable the button again
return false;
}
ElMessage({
center: true,
type: "success",
message: "更新成功",
});
this.checklib();
this.updating = false; // Enable the button again
},
async updateConfig() {
if (
(this.filenameFormat &&
this.filenameFormat.indexOf("{songName}") === -1) ||
(this.playlistSyncToLocal.filenameFormat &&
this.playlistSyncToLocal.filenameFormat.indexOf("{songName}") === -1)
) {
ElMessage({
center: true,
type: "error",
message: "文件名格式必须包含 {songName}",
});
return false;
}
const ret = await setGlobalConfig({
downloadPath: this.downloadPath,
sources: this.checkedSources,
filenameFormat: this.filenameFormat,
playlistSyncToLocal: this.playlistSyncToLocal,
});
if (ret === false || ret.status != 0) {
ElMessage({
center: true,
type: "error",
message: "更新失败",
});
this.updating = false; // Enable the button again
return false;
}
ElMessage({
center: true,
type: "success",
message: "更新成功",
});
},
async loadNextRunInfo() {
const ret = await getNextRunInfo();
if (ret.status === 0) {
this.nextLocalRun = ret.data.localNextRun;
}
},
},
async created() {
// 获取所有账号信息
const response = await getAllAccounts();
if (response.data) {
this.accounts = Object.entries(response.data).map(([uid, account]) => ({
uid,
name: account.name || uid,
}));
}
},
watch: {
"playlistSyncToLocal.autoSync": {
deep: true,
handler() {
this.loadNextRunInfo();
},
},
},
};
</script>

100
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,100 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import importToCDN from 'vite-plugin-cdn-import'
import path from 'path'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig(({command, mode}) => {
return {
server: {
host: '0.0.0.0'
},
base: './',
define: {
'process.env': process.env
},
plugins: [
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Melody',
short_name: 'Melody',
description: 'Enjoy your music with Melody',
theme_color: '#ffffff',
start_url:"./mobile.html",
icons: [
{
src: 'melody-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'melody-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
}),
vue(),
importToCDN({
modules:[
{
name: "vue",
var: "Vue",
path: "https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.33/vue.global.min.js",
},
{
name: "vue-router",
var: "VueRouter",
path: "https://cdnjs.cloudflare.com/ajax/libs/vue-router/4.0.14/vue-router.global.min.js",
},
{
name: "vuex",
var: "Vuex",
path: 'https://cdnjs.cloudflare.com/ajax/libs/vuex/4.0.2/vuex.global.min.js',
},
{
name: "axios",
var: "axios",
path: 'https://cdnjs.cloudflare.com/ajax/libs/axios/0.26.1/axios.min.js',
},
{
name: "element-plus",
var: "ElementPlus",
path: 'https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.1.9/index.full.js',
css: ["https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.1.9/theme-chalk/index.min.css"],
},
{
name: "@element-plus/icons-vue",
var: "ElementPlusIconsVue",
path: 'https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@1.1.4/dist/index.iife.min.js',
},
{
name: "element-plus/lib/locale/lang/zh-cn",
var: "ElementPlusLocaleZhCn",
path: 'https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.1.9/locale/zh-cn.min.js',
},
{
name: "vant",
var: "vant",
path: 'https://cdnjs.cloudflare.com/ajax/libs/vant/3.4.8/vant.js',
css: ["https://cdnjs.cloudflare.com/ajax/libs/vant/3.4.8/index.min.css"],
}
]
}),
],
build: {
rollupOptions: {
input: {
index: path.resolve(__dirname, 'index.html'),
mobile: path.resolve(__dirname, 'mobile.html'),
}, output: {
chunkFileNames: 'static/js/[name]-[hash].js',
entryFileNames: "static/js/[name]-[hash].js",
assetFileNames: "static/[ext]/name-[hash].[ext]"
}
},
}
}
})