初始化提交
This commit is contained in:
5
frontend/.env.development
Normal file
5
frontend/.env.development
Normal 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
3
frontend/.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
NODE_ENV = 'production'
|
||||
VITE_APP_MODE = 'production'
|
||||
VITE_APP_API_URL = '/api'
|
||||
1
frontend/.nvmrc
Normal file
1
frontend/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
v16.13.0
|
||||
25
frontend/index.html
Normal file
25
frontend/index.html
Normal 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
30
frontend/mobile.html
Normal 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
29
frontend/package.json
Normal 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
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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/public/github-logo.png
Normal file
BIN
frontend/public/github-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/public/melody-192x192.png
Normal file
BIN
frontend/public/melody-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/melody-512x512.png
Normal file
BIN
frontend/public/melody-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
frontend/public/melody.png
Normal file
BIN
frontend/public/melody.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
451
frontend/src/App.vue
Normal file
451
frontend/src/App.vue
Normal 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
234
frontend/src/Mobile.vue
Normal 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
74
frontend/src/api/axios.js
Normal 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
78
frontend/src/api/index.js
Normal 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", {});
|
||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
277
frontend/src/components/Player.vue
Normal file
277
frontend/src/components/Player.vue
Normal 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>
|
||||
219
frontend/src/components/SearchResultListForMobile.vue
Normal file
219
frontend/src/components/SearchResultListForMobile.vue
Normal 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>
|
||||
328
frontend/src/components/SearchResultTable.vue
Normal file
328
frontend/src/components/SearchResultTable.vue
Normal 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>
|
||||
53
frontend/src/components/TaskNotification.js
Normal file
53
frontend/src/components/TaskNotification.js
Normal 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)
|
||||
}
|
||||
|
||||
50
frontend/src/components/TaskNotificationForMobile.js
Normal file
50
frontend/src/components/TaskNotificationForMobile.js
Normal 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
11
frontend/src/main.js
Normal 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
18
frontend/src/mobile.js
Normal 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')
|
||||
|
||||
50
frontend/src/router/index.js
Normal file
50
frontend/src/router/index.js
Normal 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
|
||||
42
frontend/src/router/mobile.js
Normal file
42
frontend/src/router/mobile.js
Normal 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
|
||||
22
frontend/src/utils/audio.js
Normal file
22
frontend/src/utils/audio.js
Normal 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;
|
||||
}
|
||||
46
frontend/src/utils/index.js
Normal file
46
frontend/src/utils/index.js
Normal 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
24
frontend/src/utils/pwa.js
Normal 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;
|
||||
}
|
||||
}
|
||||
20
frontend/src/utils/storage.js
Normal file
20
frontend/src/utils/storage.js
Normal 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)
|
||||
},
|
||||
}
|
||||
235
frontend/src/views/mobile/Account.vue
Normal file
235
frontend/src/views/mobile/Account.vue
Normal 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>
|
||||
502
frontend/src/views/mobile/Home.vue
Normal file
502
frontend/src/views/mobile/Home.vue
Normal 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>
|
||||
422
frontend/src/views/mobile/Playlist.vue
Normal file
422
frontend/src/views/mobile/Playlist.vue
Normal 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>
|
||||
842
frontend/src/views/pc/Account.vue
Normal file
842
frontend/src/views/pc/Account.vue
Normal 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点、8点、16点执行
|
||||
</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>
|
||||
514
frontend/src/views/pc/Home.vue
Normal file
514
frontend/src/views/pc/Home.vue
Normal 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>
|
||||
465
frontend/src/views/pc/Playlist.vue
Normal file
465
frontend/src/views/pc/Playlist.vue
Normal 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>
|
||||
513
frontend/src/views/pc/Setting.vue
Normal file
513
frontend/src/views/pc/Setting.vue
Normal 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点、8点、16点执行</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
100
frontend/vite.config.js
Normal 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]"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user