🦄 refactor: 重构整个项目 优化打包 修改后台服务为本地运行 添加更新版本检测功能

This commit is contained in:
alger
2025-01-01 02:25:18 +08:00
parent f8d421c9b1
commit 17d20fa299
260 changed files with 78557 additions and 1693 deletions
@@ -0,0 +1,143 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:mask-closable="true"
class="install-app-modal"
>
<div class="modal-content">
<div class="modal-header">
<div class="app-icon">
<img src="@/assets/logo.png" alt="App Icon" />
</div>
<div class="app-info">
<h2 class="app-name">Alger Music Player {{ config.version }}</h2>
<p class="app-desc mb-2">在桌面安装应用获得更好的体验</p>
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
</div>
</div>
<div class="modal-actions">
<n-button class="cancel-btn" @click="closeModal">暂不安装</n-button>
<n-button type="primary" class="install-btn" @click="handleInstall">立即安装</n-button>
</div>
<div class="modal-desc mt-4 text-center">
<p class="text-xs text-gray-400">
下载遇到问题
<a
class="text-green-500"
target="_blank"
href="https://github.com/algerkong/AlgerMusicPlayer/releases"
>GitHub</a
>
下载最新版本
</p>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { isElectron, isMobile } from '@/utils';
import config from '../../../../package.json';
const showModal = ref(false);
const noPrompt = ref(false);
const closeModal = () => {
showModal.value = false;
if (noPrompt.value) {
localStorage.setItem('installPromptDismissed', 'true');
}
};
onMounted(() => {
// 如果是 electron 环境,不显示安装提示
if (isElectron || isMobile.value) {
return;
}
// 检查是否已经点击过"暂不安装"
const isDismissed = localStorage.getItem('installPromptDismissed') === 'true';
if (isDismissed) {
return;
}
showModal.value = true;
});
const handleInstall = async (): Promise<void> => {
const { userAgent } = navigator;
console.log('userAgent', userAgent);
const isMac: boolean = userAgent.includes('Mac');
const isWindows: boolean = userAgent.includes('Win');
const isARM: boolean =
userAgent.includes('ARM') || userAgent.includes('arm') || userAgent.includes('OS X');
const isX64: boolean =
userAgent.includes('x86_64') || userAgent.includes('Win64') || userAgent.includes('WOW64');
const isX86: boolean =
!isX64 &&
(userAgent.includes('i686') || userAgent.includes('i386') || userAgent.includes('Win32'));
const getDownloadUrl = (os: string, arch: string): string => {
const version = config.version as string;
const setup = os !== 'mac' ? 'Setup_' : '';
return `https://gh.llkk.cc/https://github.com/algerkong/AlgerMusicPlayer/releases/download/${version}/AlgerMusic_${version}_${setup}${arch}.${os === 'mac' ? 'dmg' : 'exe'}`;
};
const osType: string | null = isMac ? 'mac' : isWindows ? 'windows' : null;
const archType: string | null = isARM ? 'arm64' : isX64 ? 'x64' : isX86 ? 'x86' : null;
const downloadUrl: string | null = osType && archType ? getDownloadUrl(osType, archType) : null;
window.open(downloadUrl || 'https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
};
</script>
<style lang="scss" scoped>
.install-app-modal {
:deep(.n-modal) {
@apply max-w-sm;
}
.modal-content {
@apply p-4 pb-0;
.modal-header {
@apply flex items-center mb-6;
.app-icon {
@apply w-20 h-20 mr-4 rounded-2xl overflow-hidden;
img {
@apply w-full h-full object-cover;
}
}
.app-info {
@apply flex-1;
.app-name {
@apply text-xl font-bold mb-1;
}
.app-desc {
@apply text-sm text-gray-400;
}
}
}
.modal-actions {
@apply flex gap-3 mt-4;
.n-button {
@apply flex-1;
}
.cancel-btn {
@apply bg-gray-800 text-gray-300 border-none;
&:hover {
@apply bg-gray-700;
}
}
.install-btn {
@apply bg-green-600 border-none;
&:hover {
@apply bg-green-500;
}
}
}
}
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts" setup>
import { setAnimationClass } from '@/utils';
const props = defineProps({
showPop: {
type: Boolean,
default: false
},
showClose: {
type: Boolean,
default: true
}
});
const musicFullClass = computed(() => {
if (props.showPop) {
return setAnimationClass('animate__fadeInUp');
}
return setAnimationClass('animate__fadeOutDown');
});
</script>
<template>
<div v-show="props.showPop" class="pop-page" :class="musicFullClass">
<i v-if="props.showClose" class="iconfont icon-icon_error close"></i>
<img src="http://code.myalger.top/2000*2000.jpg,f054f0,0f2255" />
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
.pop-page {
height: 800px;
@apply absolute top-4 left-0 w-full;
background-color: #000000f0;
.close {
@apply absolute top-4 right-4 cursor-pointer text-white text-3xl;
}
}
</style>
@@ -0,0 +1,22 @@
<template>
<div v-if="isPlay" class="bottom" :style="{ height }"></div>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
const store = useStore();
const isPlay = computed(() => store.state.isPlay as boolean);
defineProps({
height: {
type: String,
default: undefined
}
});
</script>
<style lang="scss" scoped>
.bottom {
@apply h-28;
}
</style>
@@ -0,0 +1,140 @@
<template>
<div class="search-item" :class="item.type" @click="handleClick">
<div class="search-item-img">
<n-image
:src="getImgUrl(item.picUrl, item.type === 'mv' ? '320y180' : '100y100')"
lazy
preview-disabled
/>
<div v-if="item.type === 'mv'" class="play">
<i class="iconfont icon icon-play"></i>
</div>
</div>
<div class="search-item-info">
<p class="search-item-name">{{ item.name }}</p>
<p class="search-item-artist">{{ item.desc }}</p>
</div>
<MusicList
v-if="['专辑', 'playlist'].includes(item.type)"
v-model:show="showPop"
:name="item.name"
:song-list="songList"
:list-info="listInfo"
:cover="false"
/>
<mv-player
v-if="item.type === 'mv'"
v-model:show="showPop"
:current-mv="getCurrentMv()"
no-list
/>
</div>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
import { getAlbum, getListDetail } from '@/api/list';
import MvPlayer from '@/components/MvPlayer.vue';
import { audioService } from '@/services/audioService';
import { IMvItem } from '@/type/mv';
import { getImgUrl } from '@/utils';
import MusicList from '../MusicList.vue';
const props = defineProps<{
item: {
picUrl: string;
name: string;
desc: string;
type: string;
[key: string]: any;
};
}>();
const songList = ref<any[]>([]);
const showPop = ref(false);
const listInfo = ref<any>(null);
const getCurrentMv = () => {
return {
id: props.item.id,
name: props.item.name
} as unknown as IMvItem;
};
const store = useStore();
const handleClick = async () => {
listInfo.value = null;
if (props.item.type === '专辑') {
showPop.value = true;
const res = await getAlbum(props.item.id);
songList.value = res.data.songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || props.item.picUrl;
return song;
});
listInfo.value = {
...res.data.album,
creator: {
avatarUrl: res.data.album.artist.img1v1Url,
nickname: `${res.data.album.artist.name} - ${res.data.album.company}`
},
description: res.data.album.description
};
}
if (props.item.type === 'playlist') {
showPop.value = true;
const res = await getListDetail(props.item.id);
songList.value = res.data.playlist.tracks;
listInfo.value = res.data.playlist;
}
if (props.item.type === 'mv') {
store.commit('setIsPlay', false);
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
showPop.value = true;
}
};
</script>
<style scoped lang="scss">
.search-item {
@apply rounded-3xl p-3 flex items-center hover:bg-light-200 dark:hover:bg-gray-800 transition cursor-pointer;
margin: 0 10px;
.search-item-img {
@apply w-12 h-12 mr-4 rounded-2xl overflow-hidden;
}
.search-item-info {
@apply flex-1 overflow-hidden;
&-name {
@apply text-white text-sm text-center;
}
&-artist {
@apply text-gray-400 text-xs text-center;
}
}
}
.mv {
&:hover {
.play {
@apply opacity-60;
}
}
.search-item-img {
width: 160px;
height: 90px;
@apply rounded-lg relative;
}
.play {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity;
.icon {
@apply text-white text-5xl;
}
}
}
</style>
+304
View File
@@ -0,0 +1,304 @@
<template>
<div class="song-item" :class="{ 'song-mini': mini, 'song-list': list }">
<n-image
v-if="item.picUrl"
ref="songImg"
:src="getImgUrl(item.picUrl, '100y100')"
class="song-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous'
}"
@load="imageLoad"
/>
<div class="song-item-content">
<div v-if="list" class="song-item-content-wrapper">
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1">{{
item.name
}}</n-ellipsis>
<div class="song-item-content-divider">-</div>
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
<span v-for="(artists, artistsindex) in item.ar || item.song.artists" :key="artistsindex"
>{{ artists.name
}}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
>
</n-ellipsis>
</div>
<template v-else>
<div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="song-item-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
<span
v-for="(artists, artistsindex) in item.ar || item.song.artists"
:key="artistsindex"
>{{ artists.name
}}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
>
</n-ellipsis>
</div>
</template>
</div>
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
<div v-if="favorite" class="song-item-operating-like">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="toggleFavorite"
></i>
</div>
<div
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
:class="{ 'bg-green-600': isPlaying, animate__flipInY: playLoading }"
@click="playMusicEvent(item)"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import { audioService } from '@/services/audioService';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
import { getImageBackground } from '@/utils/linearColor';
const props = withDefaults(
defineProps<{
item: SongResult;
mini?: boolean;
list?: boolean;
favorite?: boolean;
}>(),
{
mini: false,
list: false,
favorite: true
}
);
const store = useStore();
const play = computed(() => store.state.play as boolean);
const playMusic = computed(() => store.state.playMusic);
const playLoading = computed(
() => playMusic.value.id === props.item.id && playMusic.value.playLoading
);
// 判断是否为正在播放的音乐
const isPlaying = computed(() => {
return playMusic.value.id === props.item.id;
});
const emits = defineEmits(['play']);
const songImageRef = useTemplateRef('songImg');
const imageLoad = async () => {
if (!songImageRef.value) {
return;
}
const { backgroundColor } = await getImageBackground(
(songImageRef.value as any).imageRef as unknown as HTMLImageElement
);
// eslint-disable-next-line vue/no-mutating-props
props.item.backgroundColor = backgroundColor;
};
// 播放音乐 设置音乐详情 打开音乐底栏
const playMusicEvent = async (item: SongResult) => {
if (playMusic.value.id === item.id) {
if (play.value) {
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
} else {
store.commit('setPlayMusic', true);
audioService.getCurrentSound()?.play();
}
return;
}
await store.commit('setPlay', item);
store.commit('setIsPlay', true);
emits('play', item);
};
// 判断是否已收藏
const isFavorite = computed(() => {
return store.state.favoriteList.includes(props.item.id);
});
// 切换收藏状态
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
if (isFavorite.value) {
store.commit('removeFromFavorite', props.item.id);
} else {
store.commit('addToFavorite', props.item.id);
}
};
</script>
<style lang="scss" scoped>
// 配置文字不可选中
.text-ellipsis {
width: 100%;
}
.song-item {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@apply rounded-3xl p-3 flex items-center transition bg-transparent dark:text-white text-gray-900;
&:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
&-img {
@apply w-12 h-12 rounded-2xl mr-4;
}
&-content {
@apply flex-1;
&-title {
@apply text-base text-gray-900 dark:text-white;
}
&-name {
@apply text-xs text-gray-500 dark:text-gray-400;
}
}
&-operating {
@apply flex items-center rounded-full ml-4 border dark:border-gray-700 border-gray-200 bg-light dark:bg-black;
.iconfont {
@apply text-xl;
}
.icon-likefill {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-red-500;
}
&-like {
@apply mr-2 cursor-pointer ml-4;
}
.like-active {
@apply text-red-500;
}
&-play {
@apply cursor-pointer rounded-full w-10 h-10 flex justify-center items-center transition
border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
&:hover,
&.bg-green-600 {
@apply bg-green-500 border-green-500 text-white;
}
}
}
}
.song-mini {
@apply p-2 rounded-2xl;
.song-item {
@apply p-0;
&-img {
@apply w-10 h-10 mr-2;
}
&-content {
@apply flex-1;
&-title {
@apply text-sm;
}
&-name {
@apply text-xs;
}
}
&-operating {
@apply pl-2;
.iconfont {
@apply text-base;
}
&-like {
@apply mr-1 ml-1;
}
&-play {
@apply w-8 h-8;
}
}
}
}
.song-list {
@apply p-2 rounded-lg mb-2 border dark:border-gray-800 border-gray-200;
&:hover {
@apply bg-gray-50 dark:bg-gray-800;
}
.song-item-img {
@apply w-10 h-10 rounded-lg mr-3;
}
.song-item-content {
@apply flex items-center flex-1;
&-wrapper {
@apply flex items-center flex-1 text-sm;
}
&-title {
@apply flex-shrink-0 max-w-[45%] text-gray-900 dark:text-white;
}
&-divider {
@apply mx-2 text-gray-500 dark:text-gray-400;
}
&-name {
@apply flex-1 min-w-0 text-gray-500 dark:text-gray-400;
}
}
.song-item-operating {
@apply flex items-center gap-2;
&-like {
@apply cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base text-gray-500 dark:text-gray-400 hover:text-red-500;
}
}
&-play {
@apply w-7 h-7 cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base;
}
}
}
}
</style>
@@ -0,0 +1,243 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:mask-closable="true"
class="update-app-modal"
style="width: 800px; max-width: 90vw"
>
<div class="modal-content">
<div class="modal-header">
<div class="app-icon">
<img src="@/assets/logo.png" alt="App Icon" />
</div>
<div class="app-info">
<h2 class="app-name">发现新版本 {{ updateInfo.latestVersion }}</h2>
<p class="app-desc mb-2">当前版本 {{ updateInfo.currentVersion }}</p>
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
</div>
</div>
<div class="update-info">
<div class="update-title">更新内容</div>
<n-scrollbar style="max-height: 300px">
<div class="update-body" v-html="parsedReleaseNotes"></div>
</n-scrollbar>
</div>
<div class="modal-actions">
<n-button class="cancel-btn" @click="closeModal">暂不更新</n-button>
<n-button type="primary" class="update-btn" @click="handleUpdate">立即更新</n-button>
</div>
<div class="modal-desc mt-4 text-center">
<p class="text-xs text-gray-400">
下载遇到问题
<a
class="text-green-500"
target="_blank"
href="https://github.com/algerkong/AlgerMusicPlayer/releases"
>GitHub</a
>
下载最新版本
</p>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue';
import { marked } from 'marked';
import { checkUpdate } from '@/utils';
import config from '../../../../package.json';
// 配置 marked
marked.setOptions({
breaks: true, // 支持 GitHub 风格的换行
gfm: true // 启用 GitHub 风格的 Markdown
});
interface ReleaseInfo {
tag_name: string;
body?: string;
html_url: string;
assets: Array<{
browser_download_url: string;
name: string;
}>;
}
const showModal = ref(false);
const noPrompt = ref(false);
const updateInfo = ref({
hasUpdate: false,
latestVersion: '',
currentVersion: config.version,
releaseInfo: null as ReleaseInfo | null
});
// 解析 Markdown
const parsedReleaseNotes = computed(() => {
if (!updateInfo.value.releaseInfo?.body) return '';
try {
return marked.parse(updateInfo.value.releaseInfo.body);
} catch (error) {
console.error('Error parsing markdown:', error);
return updateInfo.value.releaseInfo.body;
}
});
const closeModal = () => {
showModal.value = false;
if (noPrompt.value) {
localStorage.setItem('updatePromptDismissed', 'true');
}
};
const checkForUpdates = async () => {
try {
const result = await checkUpdate();
updateInfo.value = result;
// 如果有更新且用户没有选择不再提示,则显示弹窗
if (result.hasUpdate && localStorage.getItem('updatePromptDismissed') !== 'true') {
showModal.value = true;
}
} catch (error) {
console.error('检查更新失败:', error);
}
};
const handleUpdate = async () => {
const { userAgent } = navigator;
const isMac: boolean = userAgent.includes('Mac');
const isWindows: boolean = userAgent.includes('Win');
const isARM: boolean =
userAgent.includes('ARM') || userAgent.includes('arm') || userAgent.includes('OS X');
const isX64: boolean =
userAgent.includes('x86_64') || userAgent.includes('Win64') || userAgent.includes('WOW64');
const isX86: boolean =
!isX64 &&
(userAgent.includes('i686') || userAgent.includes('i386') || userAgent.includes('Win32'));
const getDownloadUrl = (os: string, arch: string): string => {
const version = updateInfo.value.latestVersion;
const setup = os !== 'mac' ? 'Setup_' : '';
return `https://gh.llkk.cc/https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusic_${version}_${setup}${arch}.${os === 'mac' ? 'dmg' : 'exe'}`;
};
const osType: string | null = isMac ? 'mac' : isWindows ? 'windows' : null;
const archType: string | null = isARM ? 'arm64' : isX64 ? 'x64' : isX86 ? 'x86' : null;
const downloadUrl: string | null = osType && archType ? getDownloadUrl(osType, archType) : null;
window.open(downloadUrl || 'https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
closeModal();
};
onMounted(() => {
checkForUpdates();
});
</script>
<style lang="scss" scoped>
.update-app-modal {
:deep(.n-modal) {
@apply max-w-4xl;
}
.modal-content {
@apply p-6 pb-4;
.modal-header {
@apply flex items-center mb-6;
.app-icon {
@apply w-24 h-24 mr-6 rounded-2xl overflow-hidden;
img {
@apply w-full h-full object-cover;
}
}
.app-info {
@apply flex-1;
.app-name {
@apply text-2xl font-bold mb-2;
}
.app-desc {
@apply text-base text-gray-400;
}
}
}
.update-info {
@apply mb-6 rounded-lg bg-gray-50 dark:bg-gray-800;
.update-title {
@apply text-base font-medium p-4 pb-2;
}
.update-body {
@apply p-4 pt-2 text-gray-600 dark:text-gray-300;
:deep(h1) {
@apply text-xl font-bold mb-3;
}
:deep(h2) {
@apply text-lg font-bold mb-3;
}
:deep(h3) {
@apply text-base font-bold mb-2;
}
:deep(p) {
@apply mb-3 leading-relaxed;
}
:deep(ul) {
@apply list-disc list-inside mb-3;
}
:deep(ol) {
@apply list-decimal list-inside mb-3;
}
:deep(li) {
@apply mb-2 leading-relaxed;
}
:deep(code) {
@apply px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200;
}
:deep(pre) {
@apply p-3 rounded bg-gray-100 dark:bg-gray-700 overflow-x-auto mb-3;
code {
@apply bg-transparent p-0;
}
}
:deep(blockquote) {
@apply pl-4 border-l-4 border-gray-200 dark:border-gray-600 mb-3;
}
:deep(a) {
@apply text-green-500 hover:text-green-600 dark:hover:text-green-400;
}
:deep(hr) {
@apply my-4 border-gray-200 dark:border-gray-600;
}
:deep(table) {
@apply w-full mb-3;
th, td {
@apply px-3 py-2 border border-gray-200 dark:border-gray-600;
}
th {
@apply bg-gray-100 dark:bg-gray-700;
}
}
}
}
.modal-actions {
@apply flex gap-4 mt-6;
.n-button {
@apply flex-1 text-base py-2;
}
.cancel-btn {
@apply bg-gray-800 text-gray-300 border-none;
&:hover {
@apply bg-gray-700;
}
}
.update-btn {
@apply bg-green-600 border-none;
&:hover {
@apply bg-green-500;
}
}
}
}
}
</style>