Compare commits

...

35 Commits

Author SHA1 Message Date
sqj
9341c57278 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 15:59:20 +08:00
sqj
15a76a5313 1. 支持暗黑主题 2. 调整插件页面ui 2025-10-03 15:42:22 +08:00
时迁酱
6ee7e6dc99 Update README.md 2025-10-01 19:32:10 +08:00
时迁酱
630ec056db Update README.md 2025-10-01 11:23:42 +08:00
时迁酱
d739e60930 Delete docs/guide/design.md 2025-10-01 11:19:05 +08:00
时迁酱
86ea8b6797 Update config.mts 2025-10-01 11:18:13 +08:00
sqj
3d87fa145f docs 2025-09-30 13:46:27 +08:00
sqj
6fa4e21d40 feat: 新增插件在线导入 2025-09-29 21:06:26 +08:00
sqj
4c11c19139 feat: 新增插件在线导入 2025-09-29 20:40:10 +08:00
sqj
f82d4271a7 doc:ship 2025-09-28 23:02:32 +08:00
sqj
42dcf52d59 Merge branch 'main' of https://github.com/timeshiftsauce/CeruMusic 2025-09-28 22:20:41 +08:00
sqj
d9d2bdab93 fix website 2025-09-28 22:19:45 +08:00
sqj
6060aa2ef4 deleted docs 2025-09-28 21:28:15 +08:00
时迁酱
0b2e8eef64 Delete .github/workflows/deploydocs.yml 2025-09-28 21:27:59 +08:00
sqj
57de7b49e8 fix:优化播放列表,单击播放,右键菜单,播放进度调粗细 2025-09-28 21:22:27 +08:00
sqj
42e17e83e7 fix:优化播放列表,单击播放,右键菜单 2025-09-28 21:00:35 +08:00
sqj
380c273329 fix: workflow 2025-09-27 16:36:43 +08:00
sqj
6f56f5e240 fix: flac格式使用ffmpeg 修复高音质下载失效 2025-09-27 08:07:49 +08:00
sqj
7af7779e5c fix: flac格式使用ffmpeg 2025-09-27 07:37:20 +08:00
sqj
669a348218 fix: flac格式使用ffmpeg 2025-09-27 07:35:42 +08:00
sqj
f02264c80c 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-27 00:14:45 +08:00
sqj
d0d5f918bd 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:48:21 +08:00
sqj
761d265d18 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:27:50 +08:00
sqj
204df64535 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:25:22 +08:00
sqj
cc814eddbd 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:08:18 +08:00
sqj
51df14a9e9 1. 歌单
- 新增右键移除歌曲
   - local 页歌单右键操作
   - 歌单页支持修改封面
2. debug:右键菜单二级菜单位置决策
2025-09-25 19:56:45 +08:00
sqj
2473b36928 feat:列表新增右键菜单;fix:播放列表滚动条,搜索页切换源重新加载 2025-09-25 02:43:02 +08:00
sqj
dbba7a3d26 1. 软件启动位置 宽高记忆 限制软件最大宽高
2. debug: 修复歌曲音质支持短缺问题
2025-09-22 19:36:07 +08:00
sqj
a817865bd8 Merge branch 'main' of https://github.com/timeshiftsauce/CeruMusic 2025-09-22 19:34:58 +08:00
sqj
c4a4d26bd8 1. 软件启动位置 宽高记忆 限制软件最大宽高
2. debug: 修复歌曲音质支持短缺问题
2025-09-22 19:34:06 +08:00
时迁酱
dfa36d872e Update README.md 2025-09-22 12:08:35 +08:00
sqj
995859e661 1 2025-09-22 03:54:49 +08:00
sqj
34fb0f7c2f fix:qqLyric 2025-09-22 03:41:08 +08:00
sqj
191ba1e199 feat:点击搜索框的 源图标实现快速切换 兼容多平台歌单导入 fix:列表删除按钮冒泡 2025-09-21 18:36:18 +08:00
sqj
324e81c0dc feat:点击搜索框的 源图标实现快速切换 兼容多平台歌单导入 fix:列表删除按钮冒泡 2025-09-21 18:23:54 +08:00
88 changed files with 10931 additions and 37878 deletions

View File

@@ -1,66 +0,0 @@
# 构建 VitePress 站点并将其部署到 GitHub Pages 的示例工作流程
#
name: Deploy VitePress site to Pages
on:
# 在针对 `main` 分支的推送上运行。如果你
# 使用 `master` 分支作为默认分支,请将其更改为 `master`
push:
branches: [main]
# 允许你从 Actions 选项卡手动运行此工作流程
workflow_dispatch:
# 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# 只允许同时进行一次部署,跳过正在运行和最新队列之间的运行队列
# 但是,不要取消正在进行的运行,因为我们希望允许这些生产部署完成
concurrency:
group: pages
cancel-in-progress: false
jobs:
# 构建工作
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # 如果未启用 lastUpdated则不需要
- uses: pnpm/action-setup@v3 # 如果使用 pnpm请取消此区域注释
with:
version: 9
# - uses: oven-sh/setup-bun@v1 # 如果使用 Bun请取消注释
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm # 或 pnpm / yarn
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Install dependencies
run: pnpm add -D vitepress@next # 或 pnpm install / yarn install / bun install
- name: Build with VitePress
run: pnpm run docs:build # 或 pnpm docs:build / yarn docs:build / bun run docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/.vitepress/dist
# 部署工作
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -39,7 +39,7 @@ jobs:
- name: Build Electron App for macos
if: matrix.os == 'macos-latest' # 只在macOS上运行
run: |
yarn run build:mac
yarn run build:mac:universal
- name: Build Electron App for linux
if: matrix.os == 'ubuntu-latest' # 只在Linux上运行
@@ -70,146 +70,4 @@ jobs:
uses: softprops/action-gh-release@v1
with:
files: 'dist/**' # 将dist目录下所有文件添加到release
# 新增:自动同步到 WebDAV
sync-to-webdav:
name: Sync to WebDAV
needs: release # 等待 release 任务完成
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') # 只在标签推送时执行
steps:
- name: Wait for release to be ready
run: |
echo "等待 Release 准备就绪..."
sleep 30 # 等待30秒确保 release 完全创建
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y curl jq
- name: Get latest release info
id: get-release
run: |
# 获取当前标签对应的 release 信息
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
# 获取 release 详细信息
response=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG_NAME")
release_id=$(echo "$response" | jq -r '.id')
echo "release_id=$release_id" >> $GITHUB_OUTPUT
echo "找到 Release ID: $release_id"
- name: Sync release to WebDAV
run: |
TAG_NAME="${{ steps.get-release.outputs.tag_name }}"
RELEASE_ID="${{ steps.get-release.outputs.release_id }}"
echo "🚀 开始同步版本 $TAG_NAME 到 WebDAV..."
echo "Release ID: $RELEASE_ID"
echo "WebDAV 根路径: ${{ secrets.WEBDAV_BASE_URL }}/yd/ceru"
# 获取该release的所有资源文件
assets_json=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID/assets")
assets_count=$(echo "$assets_json" | jq '. | length')
echo "找到 $assets_count 个资源文件"
if [ "$assets_count" -eq 0 ]; then
echo "⚠️ 该版本没有资源文件,跳过同步"
exit 0
fi
# 先创建版本目录
dir_path="/yd/ceru/$TAG_NAME"
dir_url="${{ secrets.WEBDAV_BASE_URL }}$dir_path"
echo "创建版本目录: $dir_path"
curl -s -X MKCOL \
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
"$dir_url" || echo "目录可能已存在"
# 处理每个asset
success_count=0
failed_count=0
for i in $(seq 0 $(($assets_count - 1))); do
asset=$(echo "$assets_json" | jq -c ".[$i]")
asset_name=$(echo "$asset" | jq -r '.name')
asset_url=$(echo "$asset" | jq -r '.url')
asset_size=$(echo "$asset" | jq -r '.size')
echo "📦 处理资源: $asset_name (大小: $asset_size bytes)"
# 下载资源文件
safe_filename="./temp_${TAG_NAME}_$(date +%s)_$i"
if ! curl -sL -o "$safe_filename" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/octet-stream" \
"$asset_url"; then
echo "❌ 下载失败: $asset_name"
failed_count=$((failed_count + 1))
continue
fi
if [ -f "$safe_filename" ]; then
actual_size=$(wc -c < "$safe_filename")
if [ "$actual_size" -ne "$asset_size" ]; then
echo "❌ 文件大小不匹配: $asset_name (期望: $asset_size, 实际: $actual_size)"
rm -f "$safe_filename"
failed_count=$((failed_count + 1))
continue
fi
echo "⬆️ 上传到 WebDAV: $asset_name"
# 构建远程路径
remote_path="/yd/ceru/$TAG_NAME/$asset_name"
full_url="${{ secrets.WEBDAV_BASE_URL }}$remote_path"
# 使用 WebDAV PUT 方法上传文件
if curl -s -f -X PUT \
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
-T "$safe_filename" \
"$full_url"; then
echo "✅ 上传成功: $asset_name"
success_count=$((success_count + 1))
else
echo "❌ 上传失败: $asset_name"
failed_count=$((failed_count + 1))
fi
# 清理临时文件
rm -f "$safe_filename"
echo "----------------------------------------"
else
echo "❌ 临时文件不存在: $safe_filename"
failed_count=$((failed_count + 1))
fi
done
echo "========================================"
echo "🎉 同步完成!"
echo "成功: $success_count 个文件"
echo "失败: $failed_count 个文件"
echo "总计: $assets_count 个文件"
- name: Notify completion
if: always()
run: |
if [ "${{ job.status }}" == "success" ]; then
echo "✅ 版本 ${{ steps.get-release.outputs.tag_name }} 已成功同步到 alist"
else
echo "❌ 版本 ${{ steps.get-release.outputs.tag_name }} 同步失败"
fi
body: ${{ github.event.head_commit.message }} # 自动将commit message写入release描述

View File

@@ -1,275 +0,0 @@
import {
useMediaQuery
} from "./chunk-B6YPYVPP.js";
import {
computed,
ref,
shallowRef,
watch
} from "./chunk-I4O5PVBA.js";
// node_modules/vitepress/dist/client/theme-default/index.js
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/fonts.css";
// node_modules/vitepress/dist/client/theme-default/without-fonts.js
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/vars.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/base.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/icons.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/utils.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/custom-block.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code-group.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-doc.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-sponsor.css";
import VPBadge from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import Layout from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/Layout.vue";
import { default as default2 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import { default as default3 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPButton.vue";
import { default as default4 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPDocAsideSponsors.vue";
import { default as default5 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPFeatures.vue";
import { default as default6 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeContent.vue";
import { default as default7 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeFeatures.vue";
import { default as default8 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeHero.vue";
import { default as default9 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeSponsors.vue";
import { default as default10 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPImage.vue";
import { default as default11 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPLink.vue";
import { default as default12 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPNavBarSearch.vue";
import { default as default13 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPSocialLink.vue";
import { default as default14 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPSocialLinks.vue";
import { default as default15 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPSponsors.vue";
import { default as default16 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamMembers.vue";
import { default as default17 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamPage.vue";
import { default as default18 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageSection.vue";
import { default as default19 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageTitle.vue";
// node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
import { onContentUpdated } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/composables/outline.js
import { getScrollOffset } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/support/utils.js
import { withBase } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/composables/data.js
import { useData as useData$ } from "vitepress";
var useData = useData$;
// node_modules/vitepress/dist/client/theme-default/support/utils.js
function ensureStartingSlash(path) {
return path.startsWith("/") ? path : `/${path}`;
}
// node_modules/vitepress/dist/client/theme-default/support/sidebar.js
function getSidebar(_sidebar, path) {
if (Array.isArray(_sidebar))
return addBase(_sidebar);
if (_sidebar == null)
return [];
path = ensureStartingSlash(path);
const dir = Object.keys(_sidebar).sort((a, b) => {
return b.split("/").length - a.split("/").length;
}).find((dir2) => {
return path.startsWith(ensureStartingSlash(dir2));
});
const sidebar = dir ? _sidebar[dir] : [];
return Array.isArray(sidebar) ? addBase(sidebar) : addBase(sidebar.items, sidebar.base);
}
function getSidebarGroups(sidebar) {
const groups = [];
let lastGroupIndex = 0;
for (const index in sidebar) {
const item = sidebar[index];
if (item.items) {
lastGroupIndex = groups.push(item);
continue;
}
if (!groups[lastGroupIndex]) {
groups.push({ items: [] });
}
groups[lastGroupIndex].items.push(item);
}
return groups;
}
function addBase(items, _base) {
return [...items].map((_item) => {
const item = { ..._item };
const base = item.base || _base;
if (base && item.link)
item.link = base + item.link;
if (item.items)
item.items = addBase(item.items, base);
return item;
});
}
// node_modules/vitepress/dist/client/theme-default/composables/sidebar.js
function useSidebar() {
const { frontmatter, page, theme: theme2 } = useData();
const is960 = useMediaQuery("(min-width: 960px)");
const isOpen = ref(false);
const _sidebar = computed(() => {
const sidebarConfig = theme2.value.sidebar;
const relativePath = page.value.relativePath;
return sidebarConfig ? getSidebar(sidebarConfig, relativePath) : [];
});
const sidebar = ref(_sidebar.value);
watch(_sidebar, (next, prev) => {
if (JSON.stringify(next) !== JSON.stringify(prev))
sidebar.value = _sidebar.value;
});
const hasSidebar = computed(() => {
return frontmatter.value.sidebar !== false && sidebar.value.length > 0 && frontmatter.value.layout !== "home";
});
const leftAside = computed(() => {
if (hasAside)
return frontmatter.value.aside == null ? theme2.value.aside === "left" : frontmatter.value.aside === "left";
return false;
});
const hasAside = computed(() => {
if (frontmatter.value.layout === "home")
return false;
if (frontmatter.value.aside != null)
return !!frontmatter.value.aside;
return theme2.value.aside !== false;
});
const isSidebarEnabled = computed(() => hasSidebar.value && is960.value);
const sidebarGroups = computed(() => {
return hasSidebar.value ? getSidebarGroups(sidebar.value) : [];
});
function open() {
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
function toggle() {
isOpen.value ? close() : open();
}
return {
isOpen,
sidebar,
sidebarGroups,
hasSidebar,
hasAside,
leftAside,
isSidebarEnabled,
open,
close,
toggle
};
}
// node_modules/vitepress/dist/client/theme-default/composables/outline.js
var ignoreRE = /\b(?:VPBadge|header-anchor|footnote-ref|ignore-header)\b/;
var resolvedHeaders = [];
function getHeaders(range) {
const headers = [
...document.querySelectorAll(".VPDoc :where(h1,h2,h3,h4,h5,h6)")
].filter((el) => el.id && el.hasChildNodes()).map((el) => {
const level = Number(el.tagName[1]);
return {
element: el,
title: serializeHeader(el),
link: "#" + el.id,
level
};
});
return resolveHeaders(headers, range);
}
function serializeHeader(h) {
let ret = "";
for (const node of h.childNodes) {
if (node.nodeType === 1) {
if (ignoreRE.test(node.className))
continue;
ret += node.textContent;
} else if (node.nodeType === 3) {
ret += node.textContent;
}
}
return ret.trim();
}
function resolveHeaders(headers, range) {
if (range === false) {
return [];
}
const levelsRange = (typeof range === "object" && !Array.isArray(range) ? range.level : range) || 2;
const [high, low] = typeof levelsRange === "number" ? [levelsRange, levelsRange] : levelsRange === "deep" ? [2, 6] : levelsRange;
return buildTree(headers, high, low);
}
function buildTree(data, min, max) {
resolvedHeaders.length = 0;
const result = [];
const stack = [];
data.forEach((item) => {
const node = { ...item, children: [] };
let parent = stack[stack.length - 1];
while (parent && parent.level >= node.level) {
stack.pop();
parent = stack[stack.length - 1];
}
if (node.element.classList.contains("ignore-header") || parent && "shouldIgnore" in parent) {
stack.push({ level: node.level, shouldIgnore: true });
return;
}
if (node.level > max || node.level < min)
return;
resolvedHeaders.push({ element: node.element, link: node.link });
if (parent)
parent.children.push(node);
else
result.push(node);
stack.push(node);
});
return result;
}
// node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
function useLocalNav() {
const { theme: theme2, frontmatter } = useData();
const headers = shallowRef([]);
const hasLocalNav = computed(() => {
return headers.value.length > 0;
});
onContentUpdated(() => {
headers.value = getHeaders(frontmatter.value.outline ?? theme2.value.outline);
});
return {
headers,
hasLocalNav
};
}
// node_modules/vitepress/dist/client/theme-default/without-fonts.js
var theme = {
Layout,
enhanceApp: ({ app }) => {
app.component("Badge", VPBadge);
}
};
var without_fonts_default = theme;
export {
default2 as VPBadge,
default3 as VPButton,
default4 as VPDocAsideSponsors,
default5 as VPFeatures,
default6 as VPHomeContent,
default7 as VPHomeFeatures,
default8 as VPHomeHero,
default9 as VPHomeSponsors,
default10 as VPImage,
default11 as VPLink,
default12 as VPNavBarSearch,
default13 as VPSocialLink,
default14 as VPSocialLinks,
default15 as VPSponsors,
default16 as VPTeamMembers,
default17 as VPTeamPage,
default18 as VPTeamPageSection,
default19 as VPTeamPageTitle,
without_fonts_default as default,
useLocalNav,
useSidebar
};
//# sourceMappingURL=@theme_index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1,40 +0,0 @@
{
"hash": "99cf66da",
"configHash": "acc3a95b",
"lockfileHash": "6f0f9736",
"browserHash": "6e863def",
"optimized": {
"vue": {
"src": "../../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "4f939392",
"needsInterop": false
},
"vitepress > @vue/devtools-api": {
"src": "../../../node_modules/@vue/devtools-api/dist/index.js",
"file": "vitepress___@vue_devtools-api.js",
"fileHash": "fcdf6679",
"needsInterop": false
},
"vitepress > @vueuse/core": {
"src": "../../../node_modules/@vueuse/core/index.mjs",
"file": "vitepress___@vueuse_core.js",
"fileHash": "f6cccf57",
"needsInterop": false
},
"@theme/index": {
"src": "../../../node_modules/vitepress/dist/client/theme-default/index.js",
"file": "@theme_index.js",
"fileHash": "1995bc33",
"needsInterop": false
}
},
"chunks": {
"chunk-B6YPYVPP": {
"file": "chunk-B6YPYVPP.js"
},
"chunk-I4O5PVBA": {
"file": "chunk-I4O5PVBA.js"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,583 +0,0 @@
import {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
} from "./chunk-B6YPYVPP.js";
import "./chunk-I4O5PVBA.js";
export {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
computedAsync as asyncComputed,
refAutoReset as autoResetRef,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
computedWithControl as controlledComputed,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
reactify as createReactiveFn,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
refDebounced as debouncedRef,
watchDebounced as debouncedWatch,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
computedEager as eagerComputed,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
watchIgnorable as ignorableWatch,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
watchPausable as pausableWatch,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
refThrottled as throttledRef,
watchThrottled as throttledWatch,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
refDebounced as useDebounce,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
refThrottled as useThrottle,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
};
//# sourceMappingURL=vitepress___@vueuse_core.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@@ -1,343 +0,0 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-I4O5PVBA.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

215
.workflow/main copy.yml Normal file
View File

@@ -0,0 +1,215 @@
name: AutoBuild # 工作流的名称
permissions:
contents: write # 给予写入仓库内容的权限
on:
push:
tags:
- 'v*' # 当推送以v开头的标签时触发此工作流
workflow_dispatch:
jobs:
release:
name: build and release electron app # 任务名称
runs-on: ${{ matrix.os }} # 在matrix.os定义的操作系统上运行
strategy:
fail-fast: false # 如果一个任务失败,其他任务继续运行
matrix:
os: [windows-latest, macos-latest, ubuntu-latest] # 在Windows和macOS上运行任务
steps:
- name: Check out git repository
uses: actions/checkout@v4 # 检出代码仓库
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22 # 安装Node.js 22 这里node环境是能够运行代码的环境
- name: Install Dependencies
run: |
npm i -g yarn
yarn install # 安装项目依赖
- name: Build Electron App for windows
if: matrix.os == 'windows-latest' # 只在Windows上运行
run: yarn run build:win # 构建Windows版应用
- name: Build Electron App for macos
if: matrix.os == 'macos-latest' # 只在macOS上运行
run: |
yarn run build:mac
- name: Build Electron App for linux
if: matrix.os == 'ubuntu-latest' # 只在Linux上运行
run: yarn run build:linux # 构建Linux版应用
- name: Cleanup Artifacts for Windows
if: matrix.os == 'windows-latest'
run: |
npx del-cli "dist/*" "!dist/*.exe" "!dist/*.zip" "!dist/*.yml" # 清理Windows构建产物,只保留特定文件
- name: Cleanup Artifacts for MacOS
if: matrix.os == 'macos-latest'
run: |
npx del-cli "dist/*" "!dist/(*.dmg|*.zip|latest*.yml)" # 清理macOS构建产物,只保留特定文件
- name: Cleanup Artifacts for Linux
if: matrix.os == 'ubuntu-latest'
run: |
npx del-cli "dist/*" "!dist/(*.AppImage|*.deb|*.snap|latest*.yml)" # 清理Linux构建产物,只保留特定文件
- name: upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}
path: dist # 上传构建产物作为工作流artifact
- name: release
uses: softprops/action-gh-release@v1
with:
files: 'dist/**' # 将dist目录下所有文件添加到release
# 新增:自动同步到 WebDAV
sync-to-webdav:
name: Sync to WebDAV
needs: release # 等待 release 任务完成
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') # 只在标签推送时执行
steps:
- name: Wait for release to be ready
run: |
echo "等待 Release 准备就绪..."
sleep 30 # 等待30秒确保 release 完全创建
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y curl jq
- name: Get latest release info
id: get-release
run: |
# 获取当前标签对应的 release 信息
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
# 获取 release 详细信息
response=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG_NAME")
release_id=$(echo "$response" | jq -r '.id')
echo "release_id=$release_id" >> $GITHUB_OUTPUT
echo "找到 Release ID: $release_id"
- name: Sync release to WebDAV
run: |
TAG_NAME="${{ steps.get-release.outputs.tag_name }}"
RELEASE_ID="${{ steps.get-release.outputs.release_id }}"
echo "🚀 开始同步版本 $TAG_NAME 到 WebDAV..."
echo "Release ID: $RELEASE_ID"
echo "WebDAV 根路径: ${{ secrets.WEBDAV_BASE_URL }}/yd/ceru"
# 获取该release的所有资源文件
assets_json=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID/assets")
assets_count=$(echo "$assets_json" | jq '. | length')
echo "找到 $assets_count 个资源文件"
if [ "$assets_count" -eq 0 ]; then
echo "⚠️ 该版本没有资源文件,跳过同步"
exit 0
fi
# 先创建版本目录
dir_path="/yd/ceru/$TAG_NAME"
dir_url="${{ secrets.WEBDAV_BASE_URL }}$dir_path"
echo "创建版本目录: $dir_path"
curl -s -X MKCOL \
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
"$dir_url" || echo "目录可能已存在"
# 处理每个asset
success_count=0
failed_count=0
for i in $(seq 0 $(($assets_count - 1))); do
asset=$(echo "$assets_json" | jq -c ".[$i]")
asset_name=$(echo "$asset" | jq -r '.name')
asset_url=$(echo "$asset" | jq -r '.url')
asset_size=$(echo "$asset" | jq -r '.size')
echo "📦 处理资源: $asset_name (大小: $asset_size bytes)"
# 下载资源文件
safe_filename="./temp_${TAG_NAME}_$(date +%s)_$i"
if ! curl -sL -o "$safe_filename" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/octet-stream" \
"$asset_url"; then
echo "❌ 下载失败: $asset_name"
failed_count=$((failed_count + 1))
continue
fi
if [ -f "$safe_filename" ]; then
actual_size=$(wc -c < "$safe_filename")
if [ "$actual_size" -ne "$asset_size" ]; then
echo "❌ 文件大小不匹配: $asset_name (期望: $asset_size, 实际: $actual_size)"
rm -f "$safe_filename"
failed_count=$((failed_count + 1))
continue
fi
echo "⬆️ 上传到 WebDAV: $asset_name"
# 构建远程路径
remote_path="/yd/ceru/$TAG_NAME/$asset_name"
full_url="${{ secrets.WEBDAV_BASE_URL }}$remote_path"
# 使用 WebDAV PUT 方法上传文件
if curl -s -f -X PUT \
-u "${{ secrets.WEBDAV_USERNAME }}:${{ secrets.WEBDAV_PASSWORD }}" \
-T "$safe_filename" \
"$full_url"; then
echo "✅ 上传成功: $asset_name"
success_count=$((success_count + 1))
else
echo "❌ 上传失败: $asset_name"
failed_count=$((failed_count + 1))
fi
# 清理临时文件
rm -f "$safe_filename"
echo "----------------------------------------"
else
echo "❌ 临时文件不存在: $safe_filename"
failed_count=$((failed_count + 1))
fi
done
echo "========================================"
echo "🎉 同步完成!"
echo "成功: $success_count 个文件"
echo "失败: $failed_count 个文件"
echo "总计: $assets_count 个文件"
- name: Notify completion
if: always()
run: |
if [ "${{ job.status }}" == "success" ]; then
echo "✅ 版本 ${{ steps.get-release.outputs.tag_name }} 已成功同步到 alist"
else
echo "❌ 版本 ${{ steps.get-release.outputs.tag_name }} 同步失败"
fi

133
README.md
View File

@@ -11,6 +11,7 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=timeshiftsauce/CeruMusic&type=Date)](https://www.star-history.com/#timeshiftsauce/CeruMusic&Date)
## 技术栈
- **Electron**:用于构建跨平台桌面应用
@@ -19,23 +20,14 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
- **Pinia**:状态管理工具
- **Vite**:快速的前端构建工具
- **CeruPlugins**:音乐插件运行环境(仅提供框架,不包含默认插件)
- **AMLL**:音乐生态辅助模块(仅提供功能接口,不关联具体音乐数据源)
- **AMLL**:音乐生态(歌词渲染等)辅助模块(仅提供功能接口,不关联具体音乐数据源)
## 项目结构
```ast
CeruMuisc/
├── .github/
│ └── workflows/
│ ├── auto-sync-release.yml
│ ├── deploydocs.yml
│ ├── main.yml
│ ├── sync-releases-to-webdav.yml
│ └── uploadpan.yml
├── scripts/
│ ├── auth-test.js
│ ├── genAst.js
│ └── test-alist.js
├── src/
│ ├── common/
│ │ ├── types/
@@ -55,6 +47,7 @@ CeruMuisc/
│ │ │ ├── autoUpdate.ts
│ │ │ ├── directorySettings.ts
│ │ │ ├── musicCache.ts
│ │ │ ├── pluginNotice.ts
│ │ │ └── songList.ts
│ │ ├── services/
│ │ │ ├── music/
@@ -76,91 +69,10 @@ CeruMuisc/
│ │ │ ├── songList/
│ │ │ │ ├── ManageSongList.ts
│ │ │ │ └── PlayListSongs.ts
│ │ │ ── ai-service.ts
│ │ │ ── ai-service.ts
│ │ │ └── ConfigManager.ts
│ │ ├── utils/
│ │ │ ├── musicSdk/
│ │ │ │ ├── kg/
│ │ │ │ │ ├── temp/
│ │ │ │ │ │ ├── musicSearch-new.js
│ │ │ │ │ │ └── songList-new.js
│ │ │ │ │ ├── vendors/
│ │ │ │ │ │ └── infSign.min.js
│ │ │ │ │ ├── album.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── pic.js
│ │ │ │ │ ├── singer.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ ├── tipSearch.js
│ │ │ │ │ └── util.js
│ │ │ │ ├── kw/
│ │ │ │ │ ├── album.js
│ │ │ │ │ ├── api-temp.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── kwdecode.ts
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── pic.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ ├── tipSearch.js
│ │ │ │ │ └── util.js
│ │ │ │ ├── mg/
│ │ │ │ │ ├── temp/
│ │ │ │ │ │ └── leaderboard-old.js
│ │ │ │ │ ├── utils/
│ │ │ │ │ │ ├── index.js
│ │ │ │ │ │ └── mrc.js
│ │ │ │ │ ├── album.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── pic.js
│ │ │ │ │ ├── songId.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ └── tipSearch.js
│ │ │ │ ├── tx/
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── singer.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ └── tipSearch.js
│ │ │ │ ├── wy/
│ │ │ │ │ ├── utils/
│ │ │ │ │ │ ├── crypto.js
│ │ │ │ │ │ └── index.js
│ │ │ │ │ ├── api-test.js
│ │ │ │ │ ├── comment.js
│ │ │ │ │ ├── hotSearch.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── leaderboard.js
│ │ │ │ │ ├── lyric.js
│ │ │ │ │ ├── musicDetail.js
│ │ │ │ │ ├── musicInfo.js
│ │ │ │ │ ├── musicSearch.js
│ │ │ │ │ ├── singer.js
│ │ │ │ │ ├── songList.js
│ │ │ │ │ └── tipSearch.js
│ │ │ │ ├── api-source-info.ts
│ │ │ │ ├── index.js
│ │ │ │ ├── options.js
@@ -189,6 +101,16 @@ CeruMuisc/
│ │ │ ├── components/
│ │ │ │ ├── AI/
│ │ │ │ │ └── FloatBall.vue
│ │ │ │ ├── ContextMenu/
│ │ │ │ │ ├── composables.ts
│ │ │ │ │ ├── ContextMenu.vue
│ │ │ │ │ ├── demo.vue
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── README.md
│ │ │ │ │ ├── types.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── layout/
│ │ │ │ │ └── HomeLayout.vue
│ │ │ │ ├── Music/
│ │ │ │ │ └── SongVirtualList.vue
│ │ │ │ ├── Play/
@@ -199,14 +121,14 @@ CeruMuisc/
│ │ │ │ │ ├── PlaylistDrawer.vue
│ │ │ │ │ ├── PlayMusic.vue
│ │ │ │ │ └── ShaderBackground.vue
│ │ │ │ ├── Search/
│ │ │ │ │ └── SearchComponent.vue
│ │ │ │ ├── Settings/
│ │ │ │ │ ├── AIFloatBallSettings.vue
│ │ │ │ │ ├── DirectorySettings.vue
│ │ │ │ │ ├── MusicCache.vue
│ │ │ │ │ ├── PlaylistSettings.vue
│ │ │ │ │ ├── plugins.vue
│ │ │ │ │ └── UpdateSettings.vue
│ │ │ │ ├── PluginNoticeDialog.vue
│ │ │ │ ├── ThemeSelector.vue
│ │ │ │ ├── TitleBarControls.vue
│ │ │ │ ├── UpdateExample.vue
@@ -214,8 +136,6 @@ CeruMuisc/
│ │ │ │ └── Versions.vue
│ │ │ ├── composables/
│ │ │ │ └── useAutoUpdate.ts
│ │ │ ├── layout/
│ │ │ │ └── index.vue
│ │ │ ├── router/
│ │ │ │ └── index.ts
│ │ │ ├── services/
@@ -254,10 +174,10 @@ CeruMuisc/
│ │ │ │ │ ├── recent.vue
│ │ │ │ │ └── search.vue
│ │ │ │ ├── settings/
│ │ │ │ │ ── index.vue
│ │ │ │ │ └── plugins.vue
│ │ │ │ └── welcome/
│ │ │ │ └── index.vue
│ │ │ │ │ ── index.vue
│ │ │ │ ├── welcome/
│ │ │ │ │ └── index.vue
│ │ │ │ └── ThemeDemo.vue
│ │ │ ├── App.vue
│ │ │ ├── env.d.ts
│ │ │ └── main.ts
@@ -351,8 +271,8 @@ CeruMuisc/
## 文档与资源
- [产品设计文档](https://www.doubao.com/thread/docs/design.md):涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
- [插件开发文档](https://www.doubao.com/thread/docs/CeruMusic插件开发文档.md):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
- 产品设计文档:涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
- [插件开发文档](https://ceru.docs.shiqianjiang.cn/guide/CeruMusicPluginDev.html):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
## 开源许可
@@ -363,7 +283,7 @@ CeruMuisc/
欢迎开发者贡献代码与反馈建议,贡献内容需符合以下要求:
1. 仅涉及播放器框架功能优化、bug 修复、文档完善,不包含任何音乐数据源相关代码。
2. 遵循 [Git 提交规范](https://www.doubao.com/thread/docs/design.md#git提交规范) 并确保代码符合项目风格指南。
2. 遵循 [Git 提交规范](#) 并确保代码符合项目风格指南。
3. 贡献的代码需无第三方版权纠纷,且不违反开源许可协议。
## 联系方式
@@ -430,3 +350,8 @@ CeruMuisc/
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
<img src="assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
## 联系
关于项目问题也可联系
邮箱sqj@shiqianjiang.cn

View File

@@ -7,8 +7,8 @@ export default defineConfig({
base: '/',
description:
'Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。',
markdown:{
config(md){
markdown: {
config(md) {
md.use(note)
}
},
@@ -28,12 +28,10 @@ export default defineConfig({
{ text: '安装教程', link: '/guide/' },
{
text: '使用教程',
items: [
{ text: '音乐播放列表', link: '/guide/used/playList' },
]
items: [{ text: '音乐播放列表', link: '/guide/used/playList' }]
},
{ text: '软件设计文档', link: '/guide/design' },
{ text: '更新日志', link: '/guide/updateLog' }
{ text: '更新日志', link: '/guide/updateLog' },
{ text: '更新计划', link: '/guide/update' }
]
},
{
@@ -42,6 +40,10 @@ export default defineConfig({
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
]
},
{
text: '鸣谢名单',
link: '/guide/sponsorship'
}
],
@@ -63,21 +65,20 @@ export default defineConfig({
provider: 'local'
},
outline: {
level: [2,4],
level: [2, 4],
label: '文章导航'
},
docFooter: {
next: '下一篇',
prev: '上一篇'
},
lastUpdatedText: '上次更新',
lastUpdatedText: '上次更新'
},
sitemap: {
hostname: 'https://ceru.docs.shiqianjiang.cn'
},
lastUpdated: true,
head: [['link', { rel: 'icon', href: '/logo.svg' }]],
head: [['link', { rel: 'icon', href: '/logo.svg' }]]
})
console.log(process.env.BASE_URL_DOCS)
// Smooth scrolling functions

View File

@@ -168,15 +168,15 @@ html.dark #app {
// --autonum-h3: counter(h1) "." counter(h2) "." counter(h3) ". ";
// --autonum-h4: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) ". ";
// --autonum-h5: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) ". ";
// --autonum-h6: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". ";
// --autonum-h6: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". ";
/* 下面是文章内Toc目录自动编号与上面一样即可 */
// --autonum-h1toc: counter(h1toc) ". ";
// --autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
// --autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
// --autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
// --autonum-h5toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) ". ";
// --autonum-h6toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) "." counter(h6toc) ". ";
// --autonum-h1toc: counter(h1toc) ". ";
// --autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
// --autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
// --autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
// --autonum-h5toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) ". ";
// --autonum-h6toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) "." counter(h6toc) ". ";
/* 主题颜色 */
@@ -284,4 +284,4 @@ html .vp-doc div[class*='language-'] pre {
}
.VPDoc.has-aside .content-container {
max-width: none !important;
}
}

View File

@@ -3,6 +3,7 @@
## 概述
CeruMusic 支持两种类型的插件:
1. **CeruMusic 原生插件**:基于 CeruMusic API 的插件格式
2. **LX 兼容插件**:兼容 LX Music 的事件驱动插件格式
@@ -64,11 +65,11 @@ const pluginInfo = {
const sources = {
kw:{
name: "酷我音乐",
qualities: ['128k', '320k', 'flac', 'flac24bit']
qualitys: ['128k', '320k', 'flac', 'flac24bit']
},
tx:{
name: "QQ音乐",
qualities: ['128k', '320k', 'flac']
name: "QQ音乐",
qualitys: ['128k', '320k', 'flac']
}
};
@@ -84,7 +85,7 @@ async function musicUrl(source, musicInfo, quality) {
},
body: JSON.stringify({
id: musicInfo.id,
quality: quality
qualitys: quality
})
});
@@ -132,23 +133,20 @@ module.exports = {
> #### PS:
>
> - `sources key` 取值
>
> - wy 网易云音乐 |
> - tx QQ音乐 |
> - kg 酷狗音乐 |
> - mg 咪咕音乐 |
> - tx QQ音乐 |
> - kg 酷狗音乐 |
> - mg 咪咕音乐 |
> - kw 酷我音乐
>
> - 导出
>
> ```javascript
> module.exports = {
> sources, // 你的音源支持
> };
> sources // 你的音源支持
> }
> ```
>
> - 支持的音质 ` sources.qualities: ['128k', '320k', 'flac']`
>
> - 支持的音质 ` sources.qualitys: ['128k', '320k', 'flac']`
> - `128k`: 128kbps
> - `320k`: 320kbps
> - `flac`: FLAC 无损
@@ -157,8 +155,6 @@ module.exports = {
> - `atmos`: 杜比全景声
> - `master`: 母带音质
### CeruMusic API 参考
#### cerumusic.request(url, options)
@@ -166,6 +162,7 @@ module.exports = {
HTTP 请求方法,返回 Promise。
**参数:**
- `url` (string): 请求地址
- `options` (object): 请求选项
- `method`: 请求方法 (GET, POST, PUT, DELETE 等)
@@ -174,6 +171,7 @@ HTTP 请求方法,返回 Promise。
- `timeout`: 超时时间(毫秒)
**返回值:**
```javascript
{
statusCode: 200,
@@ -206,16 +204,17 @@ cerumusic.utils.crypto.rsaEncrypt(data, key)
cerumusic.NoticeCenter('info', {
title: '通知标题',
content: '通知内容',
url: 'https://example.com', // 可选 当通知为update 版本跟新可传
url: 'https://example.com', // 可选 当通知为update 版本跟新可传
version: '版本号', // 当通知为update 版本跟新可传
pluginInfo: {
name: '插件名称',
type: 'cr', // 固定唯一标识
}// 当通知为update 版本跟新可传
});
type: 'cr' // 固定唯一标识
} // 当通知为update 版本跟新可传
})
```
**通知类型:**
- `'info'`: 信息通知
- `'success'`: 成功通知
- `'warn'`: 警告通知
@@ -247,46 +246,47 @@ const qualitys = {
'128k': '128',
'320k': '320',
flac: 'flac',
flac24bit: 'flac24bit',
flac24bit: 'flac24bit'
},
local: {},
local: {}
}
// HTTP 请求封装
const httpRequest = (url, options) => new Promise((resolve, reject) => {
request(url, options, (err, resp) => {
if (err) return reject(err)
resolve(resp.body)
const httpRequest = (url, options) =>
new Promise((resolve, reject) => {
request(url, options, (err, resp) => {
if (err) return reject(err)
resolve(resp.body)
})
})
})
// API 实现
const apis = {
kw: {
musicUrl({ songmid }, quality) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return data.url
})
},
}
},
local: {
musicUrl(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return data.url
})
},
pic(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return data.url
})
},
lyric(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return {
lyric: '...', // 歌曲歌词
tlyric: '...', // 翻译歌词,没有可为 null
rlyric: '...', // 罗马音歌词,没有可为 null
lxlyric: '...', // lx 逐字歌词,没有可为 null
lxlyric: '...' // lx 逐字歌词,没有可为 null
}
})
}
@@ -313,15 +313,15 @@ send(EVENT_NAMES.inited, {
name: '酷我音乐',
type: 'music',
actions: ['musicUrl'],
qualitys: ['128k', '320k', 'flac', 'flac24bit'],
qualitys: ['128k', '320k', 'flac', 'flac24bit']
},
local: {
name: '本地音乐',
type: 'music',
actions: ['musicUrl', 'lyric', 'pic'],
qualitys: [],
},
},
qualitys: []
}
}
})
```
@@ -342,8 +342,8 @@ send(EVENT_NAMES.inited, {
```javascript
lx.on(lx.EVENT_NAMES.request, ({ source, action, info }) => {
// 必须返回 Promise
return Promise.resolve(result);
});
return Promise.resolve(result)
})
```
#### globalThis.lx.send(eventName, data)
@@ -369,18 +369,22 @@ lx.send(lx.EVENT_NAMES.updateAlert, {
HTTP 请求方法:
```javascript
lx.request('https://api.example.com', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
timeout: 10000
}, (err, resp) => {
if (err) {
console.error('请求失败:', err);
return;
lx.request(
'https://api.example.com',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
timeout: 10000
},
(err, resp) => {
if (err) {
console.error('请求失败:', err)
return
}
console.log('响应:', resp.body)
}
console.log('响应:', resp.body);
});
)
```
#### globalThis.lx.utils
@@ -433,28 +437,28 @@ async function musicUrl(source, musicInfo, quality) {
try {
// 参数验证
if (!musicInfo || !musicInfo.id) {
throw new Error('音乐信息不完整');
throw new Error('音乐信息不完整')
}
// API 调用
const result = await cerumusic.request(url, options);
const result = await cerumusic.request(url, options)
// 结果验证
if (!result || result.statusCode !== 200) {
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`);
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`)
}
if (!result.body || !result.body.url) {
throw new Error('返回数据格式错误');
throw new Error('返回数据格式错误')
}
return result.body.url;
return result.body.url
} catch (error) {
// 记录错误日志
console.error(`[${source}] 获取音乐链接失败:`, error.message);
console.error(`[${source}] 获取音乐链接失败:`, error.message)
// 重新抛出错误供上层处理
throw new Error(`获取 ${source} 音乐链接失败: ${error.message}`);
throw new Error(`获取 ${source} 音乐链接失败: ${error.message}`)
}
}
```
@@ -473,9 +477,9 @@ async function musicUrl(source, musicInfo, quality) {
### 1. 使用 console.log
```javascript
console.log('[插件名] 调试信息:', data);
console.warn('[插件名] 警告信息:', warning);
console.error('[插件名] 错误信息:', error);
console.log('[插件名] 调试信息:', data)
console.warn('[插件名] 警告信息:', warning)
console.error('[插件名] 错误信息:', error)
```
### 2. LX 插件开发者工具
@@ -491,8 +495,8 @@ send(EVENT_NAMES.inited, {
```javascript
process.on('unhandledRejection', (reason, promise) => {
console.error('未处理的 Promise 拒绝:', reason);
});
console.error('未处理的 Promise 拒绝:', reason)
})
```
---
@@ -502,17 +506,17 @@ process.on('unhandledRejection', (reason, promise) => {
### 1. 请求缓存
```javascript
const cache = new Map();
const cache = new Map()
async function getCachedData(key, fetcher, ttl = 300000) {
const cached = cache.get(key);
const cached = cache.get(key)
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
return cached.data
}
const data = await fetcher();
cache.set(key, { data, timestamp: Date.now() });
return data;
const data = await fetcher()
cache.set(key, { data, timestamp: Date.now() })
return data
}
```
@@ -521,21 +525,21 @@ async function getCachedData(key, fetcher, ttl = 300000) {
```javascript
const result = await cerumusic.request(url, {
timeout: 10000 // 10秒超时
});
})
```
### 3. 并发控制
```javascript
// 限制并发请求数量
const semaphore = new Semaphore(3); // 最多3个并发请求
const semaphore = new Semaphore(3) // 最多3个并发请求
async function limitedRequest(url, options) {
await semaphore.acquire();
await semaphore.acquire()
try {
return await cerumusic.request(url, options);
return await cerumusic.request(url, options)
} finally {
semaphore.release();
semaphore.release()
}
}
```
@@ -549,14 +553,14 @@ async function limitedRequest(url, options) {
```javascript
function validateMusicInfo(musicInfo) {
if (!musicInfo || typeof musicInfo !== 'object') {
throw new Error('音乐信息格式错误');
throw new Error('音乐信息格式错误')
}
if (!musicInfo.id || typeof musicInfo.id !== 'string') {
throw new Error('音乐 ID 无效');
throw new Error('音乐 ID 无效')
}
return true;
return true
}
```
@@ -565,10 +569,10 @@ function validateMusicInfo(musicInfo) {
```javascript
function isValidUrl(url) {
try {
const urlObj = new URL(url);
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
const urlObj = new URL(url)
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
} catch {
return false;
return false
}
}
```
@@ -581,7 +585,7 @@ console.log('请求参数:', {
...params,
token: '***', // 隐藏敏感信息
password: '***'
});
})
```
---
@@ -605,13 +609,13 @@ async function testMusicUrl() {
id: 'test123',
name: '测试歌曲',
artist: '测试歌手'
};
}
try {
const url = await musicUrl('kw', testMusicInfo, '320k');
console.log('测试通过:', url);
const url = await musicUrl('kw', testMusicInfo, '320k')
console.log('测试通过:', url)
} catch (error) {
console.error('测试失败:', error);
console.error('测试失败:', error)
}
}
```
@@ -619,6 +623,7 @@ async function testMusicUrl() {
### 3. 版本管理
使用语义化版本号:
- `1.0.0`: 主版本.次版本.修订版本
- 主版本:不兼容的 API 修改
- 次版本:向下兼容的功能性新增
@@ -631,6 +636,7 @@ async function testMusicUrl() {
### Q: 插件加载失败怎么办?
A: 检查以下几点:
1. 文件编码是否为 UTF-8
2. 插件信息注释格式是否正确
3. JavaScript 语法是否有错误
@@ -645,20 +651,21 @@ A: CeruMusic 的请求方法不受浏览器跨域限制,可以直接请求任
A: 使用 `cerumusic.NoticeCenter` 事件通知用户更新:
```javascript
cerumusic.NoticeCenter('update',{
title:'新版本更新',
content:'xxxx',
cerumusic.NoticeCenter('update', {
title: '新版本更新',
content: 'xxxx',
version: 'v1.0.3',
url:'https://shiqianjiang.cn',
pluginInfo:{
type:'cr'
url: 'https://shiqianjiang.cn',
pluginInfo: {
type: 'cr'
}
})
```
### Q: 如何调试插件?
A:
A:
1. 使用 `console.log` 输出调试信息 可在设置—>插件管理—>日志 查看调试
2. LX 插件可以设置 `openDevTools: true` 打开开发者工具
3. 查看 CeruMusic 的插件日志
@@ -668,5 +675,6 @@ A:
## 技术支持
如有问题或建议,请通过以下方式联系:
- GitHub Issues: [CeruMusic Issues](https://github.com/timeshiftsauce/CeruMusic/issues)
- Blog (最好登录,否则需要审核): [CeruMusic Blog](https://shiqianjiang.cn/blog/4966904626407280640)

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,600 +0,0 @@
# Ceru Music 产品设计文档
## 项目概述
Ceru Music 是一个基于 Electron + Vue 3 的跨平台桌面音乐播放器,支持多音乐平台数据源,提供流畅的音乐播放体验。
## 项目架构
### 技术栈
- **前端框架**: Vue 3 + TypeScript + Composition API
- **桌面框架**: Electron (v37.2.3)
- **UI组件库**: TDesign Vue Next (v1.15.2)
- ![image-20250813180317221](..\assets\image-20250813180317221.png)
- **状态管理**: Pinia (v3.0.3)
- **路由管理**: Vue Router (v4.5.1)
- **构建工具**: Vite + electron-vite
- **包管理器**: PNPM
- **Node pnpm 版本**
```bash
PS D:\code\Ceru-Music> node -v
v22.17.0
PS D:\code\Ceru-Music> pnpm -v
10.14.0
```
-
### 架构设计
```asp
Ceru Music
├── 主进程 (Main Process)
│ ├── 应用生命周期管理
│ ├── 窗口管理
│ ├── 系统集成 (托盘、快捷键)
│ └── 文件系统操作
├── 渲染进程 (Renderer Process)
│ ├── Vue 3 应用
│ ├── 用户界面
│ ├── 音乐播放控制
│ └── 数据展示
└── 预加载脚本 (Preload Script)
└── 安全的 IPC 通信桥梁
```
### 目录结构
```
src/
├── main/ # 主进程代码
│ ├── index.ts # 主进程入口
│ ├── window.ts # 窗口管理
│ └── services/ # 主进程服务
├── preload/ # 预加载脚本
│ └── index.ts # IPC 通信接口
└── renderer/ # 渲染进程 (Vue 应用)
├── src/
│ ├── components/ # Vue 组件
│ ├── views/ # 页面视图
│ ├── stores/ # Pinia 状态管理
│ ├── services/ # API 服务
│ ├── utils/ # 工具函数
│ └── types/ # TypeScript 类型定义
└── index.html # 应用入口
```
## 项目开发使用方式
### 开发环境启动
```bash
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 代码检查
pnpm lint
# 类型检查
pnpm typecheck
```
### 构建打包
```bash
# 构建当前平台
pnpm build
# 构建 Windows 版本
pnpm build:win
# 构建 macOS 版本
pnpm build:mac
# 构建 Linux 版本
pnpm build:linux
```
## 音乐数据源接口设计
### 接口1: 网易云音乐原生接口 (主要数据源)
#### 获取音乐信息
- **请求地址**: `https://music.163.com/api/song/detail`
- **请求参数**: `ids=[ID1,ID2,ID3,...]` 音乐ID列表
- **示例**: `https://music.163.com/api/song/detail?ids=[36270426]`
#### 获取音乐直链
- **请求地址**: `https://music.163.com/song/media/outer/url`
- **请求参数**: `id=123` 音乐ID
- **示例**: `https://music.163.com/song/media/outer/url?id=36270426.mp3`
#### 获取歌词
- **请求地址**: `https://music.163.com/api/song/lyric`
- **请求参数**:
- `id=123` 音乐ID
- `lv=-1` 获取歌词
- `yv=-1` 获取逐字歌词
- `tv=-1` 获取歌词翻译
- **示例**: `https://music.163.com/api/song/lyric?id=36270426&lv=-1&yv=-1&tv=-1`
#### 搜索歌曲
- **请求地址**: `https://music.163.com/api/search/get/web`
- **请求参数**:
- `s` 歌名
- `type=1` 搜索类型
- `offset=0` 偏移量
- `limit=10` 搜索结果数量
- **示例**: `https://music.163.com/api/search/get/web?s=来自天堂的魔鬼&type=1&offset=0&limit=10`
### 接口2: Meting API (备用数据源)
#### 参数说明
- **server**: 数据源
- `netease` 网易云音乐(默认)
- `tencent` QQ音乐
- **type**: 类型
- `name` 歌曲名
- `artist` 歌手
- `url` 链接
- `pic` 封面
- `lrc` 歌词
- `song` 单曲
- `playlist` 歌单
- **id**: 类型ID封面ID/单曲ID/歌单ID
#### 使用示例
```
https://api.qijieya.cn/meting/?type=url&id=1969519579
https://api.qijieya.cn/meting/?type=song&id=591321
https://api.qijieya.cn/meting/?type=playlist&id=2619366284
```
### 接口3: 备选接口
- **地址**: https://doc.vkeys.cn/api-doc/
- **说明**: 不建议使用,延迟较高
### 接口4: 自部署接口 (备用)
- **地址**: `https://music.shiqianjiang.cn?id=你是我的风景&server=netease`
- **说明**: 不支持分页,用于获取歌曲源、歌词源等
- **文档**: [API文档](./api.md)
## 核心功能设计
### 通用请求函数设计
```typescript
// 音乐服务接口定义
interface MusicService {
search({keyword: string, page?: number, limit?: number}): Promise<SearchResult>
getSongDetail({id: string)}: Promise<SongDetail>
getSongUrl({id: string}): Promise<string>
getLyric({id: string}): Promise<LyricData>
getPlaylist({id: string}): Promise<PlaylistData>
}
// 通用请求函数
async function request(method: string, ...args: any{},isLoading=false): Promise<any> {
try {
switch (method) {
case 'search':
return await musicService.search(args)
case 'getSongDetail':
return await musicService.getSongDetail(args)
case 'getSongUrl':
return await musicService.getSongUrl(args)
case 'getLyric':
return await musicService.getLyric(args)
default:
throw new Error(`未知的方法: ${method}`)
}
} catch (error) {
console.error(`请求失败: ${method}`, error)
throw error
}
}
// 使用示例
request('search', '周杰伦', 1, 20).then((result) => {
console.log('搜索结果:', result)
})
```
### 状态管理设计 (Pinia + LocalStorage)
```typescript
// stores/music.ts
import { defineStore } from 'pinia'
export const useMusicStore = defineStore('music', {
state: () => ({
// 当前播放歌曲
currentSong: null as Song | null,
// 播放列表
playlist: [] as Song[],
// 播放状态
isPlaying: false,
// 播放模式 (顺序、随机、单曲循环)
playMode: 'order' as 'order' | 'random' | 'repeat',
// 音量
volume: 0.8,
// 播放进度
currentTime: 0,
duration: 0
}),
actions: {
// 播放歌曲
async playSong(song: Song) {
this.currentSong = song
this.isPlaying = true
this.saveToStorage()
},
// 添加到播放列表
addToPlaylist(songs: Song[]) {
this.playlist.push(...songs)
this.saveToStorage()
},
// 保存到本地存储
saveToStorage() {
localStorage.setItem(
'music-state',
JSON.stringify({
currentSong: this.currentSong,
playlist: this.playlist,
playMode: this.playMode,
volume: this.volume
})
)
},
// 从本地存储恢复
loadFromStorage() {
const saved = localStorage.getItem('music-state')
if (saved) {
const state = JSON.parse(saved)
Object.assign(this, state)
}
}
}
})
```
### 虚拟滚动列表设计
使用 TDesign 的虚拟滚动组件展示大量歌曲数据:
```vue
<template>
<t-virtual-scroll :data="songList" :height="600" :item-height="60" :buffer="10">
<template #default="{ data: song, index }">
<div class="song-item" @click="playSong(song)">
<div class="song-cover">
<img :src="song.pic" :alt="song.name" />
</div>
<div class="song-info">
<div class="song-name">{{ song.name }}</div>
<div class="song-artist">{{ song.artist }}</div>
</div>
<div class="song-duration">{{ formatTime(song.duration) }}</div>
</div>
</template>
</t-virtual-scroll>
</template>
```
### 本地数据存储设计
#### 播放列表存储
```typescript
// 方案1: LocalStorage (简单方案)
class PlaylistStorage {
private key = 'ceru-playlists'
save(playlists: Playlist[]) {
localStorage.setItem(this.key, JSON.stringify(playlists))
}
load(): Playlist[] {
const data = localStorage.getItem(this.key)
return data ? JSON.parse(data) : []
}
}
// 方案2: Node.js 文件存储 (最优方案,支持分享)
class FileStorage {
private filePath = path.join(app.getPath('userData'), 'playlists.json')
async save(playlists: Playlist[]) {
await fs.writeFile(this.filePath, JSON.stringify(playlists, null, 2))
}
async load(): Promise<Playlist[]> {
try {
const data = await fs.readFile(this.filePath, 'utf-8')
return JSON.parse(data)
} catch {
return []
}
}
// 导出播放列表
async export(playlist: Playlist, exportPath: string) {
await fs.writeFile(exportPath, JSON.stringify(playlist, null, 2))
}
// 导入播放列表
async import(importPath: string): Promise<Playlist> {
const data = await fs.readFile(importPath, 'utf-8')
return JSON.parse(data)
}
}
```
## 用户体验设计
### 首次启动流程
```typescript
// stores/app.ts
export const useAppStore = defineStore('app', {
state: () => ({
isFirstLaunch: true,
hasCompletedWelcome: false,
userPreferences: {
theme: 'auto' as 'light' | 'dark' | 'auto',
language: 'zh-CN',
defaultMusicSource: 'netease',
autoPlay: false
}
}),
actions: {
checkFirstLaunch() {
const hasLaunched = localStorage.getItem('has-launched')
this.isFirstLaunch = !hasLaunched
if (this.isFirstLaunch) {
// 跳转到欢迎页面
router.push('/welcome')
} else {
// 加载用户配置
this.loadUserPreferences()
router.push('/home')
}
},
completeWelcome(preferences?: Partial<UserPreferences>) {
if (preferences) {
Object.assign(this.userPreferences, preferences)
}
this.hasCompletedWelcome = true
localStorage.setItem('has-launched', 'true')
localStorage.setItem('user-preferences', JSON.stringify(this.userPreferences))
router.push('/home')
}
}
})
```
### 欢迎页面设计
![image-20250813180856660](..\assets\image-20250813180856660.png)
```vue
<template>
<div class="welcome-container">
<t-steps :current="currentStep" class="welcome-steps">
<t-step title="欢迎使用" content="欢迎使用 Ceru Music" />
<t-step title="基础设置" content="配置您的偏好设置" />
<t-step title="完成设置" content="开始您的音乐之旅" />
</t-steps>
<transition name="slide" mode="out-in">
<component :is="currentStepComponent" @next="nextStep" @skip="skipWelcome" />
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import WelcomeStep1 from './steps/WelcomeStep1.vue'
import WelcomeStep2 from './steps/WelcomeStep2.vue'
import WelcomeStep3 from './steps/WelcomeStep3.vue'
const currentStep = ref(0)
const steps = [WelcomeStep1, WelcomeStep2, WelcomeStep3]
const currentStepComponent = computed(() => steps[currentStep.value])
function nextStep() {
if (currentStep.value < steps.length - 1) {
currentStep.value++
} else {
completeWelcome()
}
}
function skipWelcome() {
appStore.completeWelcome()
}
</script>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from {
opacity: 0;
transform: translateX(30px);
}
.slide-leave-to {
opacity: 0;
transform: translateX(-30px);
}
</style>
```
##### 界面UI参考
![..\assets\image-20250813180944752.png)
## 页面动画设计
### 路由过渡动画
```vue
<template>
<router-view v-slot="{ Component, route }">
<transition :name="getTransitionName(route)" mode="out-in">
<component :is="Component" :key="route.path" />
</transition>
</router-view>
</template>
<script setup lang="ts">
function getTransitionName(route: any) {
// 根据路由层级决定动画方向
const depth = route.path.split('/').length
return depth > 2 ? 'slide-left' : 'slide-right'
}
</script>
<style>
/* 滑动动画 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(100%);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-100%);
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-100%);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(100%);
}
/* 淡入淡出动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
```
## 核心组件设计
### 音乐播放器组件
```vue
<template>
<div class="music-player">
<div class="player-info">
<img :src="currentSong?.pic" class="song-cover" />
<div class="song-details">
<div class="song-name">{{ currentSong?.name }}</div>
<div class="song-artist">{{ currentSong?.artist }}</div>
</div>
</div>
<div class="player-controls">
<t-button variant="text" @click="previousSong">
<t-icon name="skip-previous" />
</t-button>
<t-button :variant="isPlaying ? 'filled' : 'outline'" @click="togglePlay">
<t-icon :name="isPlaying ? 'pause' : 'play'" />
</t-button>
<t-button variant="text" @click="nextSong">
<t-icon name="skip-next" />
</t-button>
</div>
<div class="player-progress">
<span class="time-current">{{ formatTime(currentTime) }}</span>
<t-slider v-model="progress" :max="duration" @change="seekTo" class="progress-slider" />
<span class="time-duration">{{ formatTime(duration) }}</span>
</div>
</div>
</template>
```
## 开发规范
### 代码规范
- 使用 TypeScript 进行类型检查
- 遵循 ESLint 配置的代码规范
- 使用 Prettier 进行代码格式化
- 组件命名使用 PascalCase
- 文件命名使用 kebab-case
### Git 提交规范
```
feat: 新功能
fix: 修复bug
docs: 文档更新
style: 代码格式调整
refactor: 代码重构
test: 测试相关
chore: 构建过程或辅助工具的变动
```
### 性能优化
- 使用虚拟滚动处理大列表
- 图片懒加载
- 组件按需加载
- 音频预加载和缓存
- 防抖和节流优化用户交互
## 待补充功能
1. **歌词显示**: 滚动歌词、逐字高亮
2. **音效处理**: 均衡器、音效增强
3. **主题系统**: 多主题切换、自定义主题
4. **快捷键**: 全局快捷键支持
5. **系统集成**: 媒体键支持、系统通知
6. **云同步**: 播放列表云端同步
7. **插件系统**: 支持第三方插件扩展
8. **音乐推荐**: 基于听歌历史的智能推荐
---
_本设计文档将随着项目开发进度持续更新和完善。_

16
docs/guide/sponsorship.md Normal file
View File

@@ -0,0 +1,16 @@
# 赞助名单
## 鸣谢
| 昵称 | 赞助金额 |
| :------------------------: | :------: |
| **群友**:可惜情绪落泪零碎 | 6.66 |
| **群友**:🍀 | 5 |
| **群友**:涟漪 | 50 |
| **作者朋友** | 188 |
| **群友**:我叫阿狸 | 3 |
| RiseSun | 9.9 |
| **b站小友**:光牙阿普斯木兰 | 5 |
| 青禾 | 8.8 |
据不完全统计 如有疏漏可联系sqj@shiqianjiang.cn

16
docs/guide/update.md Normal file
View File

@@ -0,0 +1,16 @@
# 我的-更新计划-欢迎issue
- [ ] 搜索框可以弄对应的音乐源的排行榜 搜索联想
- [ ] 导航上面这几个按钮可以稍微优化一下
- [x] 支持在线导入插件
- [ ] 大熊好暖: 09-21 12:52:25 在搜索歌曲出来后 大熊好暖: 09-21 12:52:34 一般都是双击播放 大熊好暖: 09-21 12:54:16 这个是双击加入播放列表 可以优化 或者可以把这个双击做一个选项,让人选择
- [ ] 播放页 样式模板选择 歌词背景律动很有沉浸感,但是黑胶唱片的专辑样式不是很喜欢,希望增加一个选项如图二一样只有圆形专辑封面
- [x] 点击搜索框的 源图标实现快速切换
- [ ] ai功能完善
- [ ] 支持歌词隐藏
- [x] 兼容多平台歌单导入
- [x] 软件能不能记住上次打开的窗口大小,每次都要手动拉
- [x] 歌单右键菜单
- [x] 播放列表滚动条适配
- [x] 暗色主题
- [x] 歌单页支持修改封面

View File

@@ -1,11 +1,63 @@
# 澜音版本更新日志
## 日志
- ###### 2025-9-17 **(V1.3.2)**
- ###### 2025-10-3 (v1.3.12)
1. 支持暗黑主题
2. 调整插件页面ui
- ###### 2025-9-29 (v1.3.11)
1. 新增插件在线导入
- ###### 2025-9-28 (v1.3.10)
1. 优化播放列表
2. 单击播放
3. 右键菜单
4. 调整播放进度调粗细
- ###### 2025-09-27 (v1.3.9)
1. debug:flac格式使用ffmpeg
2. 修复高音质下载失效
- ###### 2025-9-26 (v1.3.8)
1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
- ###### 2025-9-25 (v1.3.7)
1. 歌单
- 新增右键移除歌曲
- local 页歌单右键操作
- 歌单页支持修改封面
2. debug右键菜单二级菜单位置决策
- ###### 2025-9-22 (v1.3.6)
1. 歌单列表可以右键操作
- 播放
- 下载
- 添加到歌单
- 添加到播放列表
2. 播放列表滚动条
3. 搜索页切换源重新加载
- ###### 2025-9-22 (v1.3.5)
1. 软件启动位置 宽高记忆 限制软件最大宽高
2. debug: 修复歌曲音质支持短缺问题
- ###### 2025-9-21 (v1.3.4)
1. 紧急修复QQ音乐歌词失效问题
- ###### 2025-9-21(v1.3.3)
1. 兼容多平台歌单导入
2. 点击搜索框的 源图标实现快速切换
3. debug: fix:列表删除按钮冒泡
- ###### 2025-9-17 **(v1.3.2)**
1. 目录结构调整
2. **支持插件更新提示**
@@ -13,13 +65,11 @@
**洛雪** 插件请手动重装适配
3. **debug**
- SMTC 问题
- 歌曲缓存播放多次请求和多次缓存问题
- ###### 2025-9-17 **V1.3.1**
- ###### 2025-9-17 **v1.3.1**
1. **设置功能页**
- 缓存路径支持自定义
- 下载路径支持自定义
@@ -27,4 +77,4 @@
- 播放页面唱针可以拖动问题
- 播放按钮加载中 因为自动下一曲 导致动画变形问题
- **SMTC** 功能 系统显示**未知应用**问题
- 播放页歌词**字体粗细**偶现丢失问题
- 播放页歌词**字体粗细**偶现丢失问题

View File

@@ -23,6 +23,4 @@
歌单将自动选取第一首 **有效封面**[^1] 为歌单
[^1]: url正确的歌曲封面
[^1]: url正确的歌曲封面

View File

@@ -2,24 +2,24 @@
// 这个文件可以用来测试 NoticeCenter 功能
const pluginInfo = {
name: "测试通知插件",
version: "1.0.0",
author: "CeruMusic Team",
description: "用于测试插件通知功能的示例插件",
type: "cr"
name: '测试通知插件',
version: '1.0.0',
author: 'CeruMusic Team',
description: '用于测试插件通知功能的示例插件',
type: 'cr'
}
const sources = [
{
name: "test",
qualities: ["128k", "320k"]
name: 'test',
qualities: ['128k', '320k']
}
]
// 模拟音乐URL获取函数
async function musicUrl(source, musicInfo, quality) {
console.log('测试插件获取音乐URL')
// 测试不同类型的通知
setTimeout(() => {
// 测试信息通知
@@ -29,7 +29,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '插件正在正常工作'
})
}, 1000)
setTimeout(() => {
// 测试警告通知
this.cerumusic.NoticeCenter('warning', {
@@ -38,7 +38,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '请注意某些设置'
})
}, 2000)
setTimeout(() => {
// 测试成功通知
this.cerumusic.NoticeCenter('success', {
@@ -47,7 +47,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '音乐URL获取成功'
})
}, 3000)
setTimeout(() => {
// 测试更新通知
this.cerumusic.NoticeCenter('update', {
@@ -62,7 +62,7 @@ async function musicUrl(source, musicInfo, quality) {
}
})
}, 4000)
setTimeout(() => {
// 测试错误通知
this.cerumusic.NoticeCenter('error', {
@@ -71,7 +71,7 @@ async function musicUrl(source, musicInfo, quality) {
error: '模拟的错误信息'
})
}, 5000)
// 返回一个测试URL
return 'https://example.com/test-music.mp3'
}
@@ -81,4 +81,4 @@ module.exports = {
pluginInfo,
sources,
musicUrl
}
}

View File

@@ -81,27 +81,27 @@ this.cerumusic.NoticeCenter('update', {
#### 通用参数 (data 对象)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| title | string | 否 | 通知标题,不提供时使用默认标题 |
| message | string | 否 | 通知消息内容 |
| content | string | 否 | 详细内容(与 message 二选一) |
| 参数 | 类型 | 必填 | 说明 |
| ------- | ------ | ---- | ------------------------------ |
| title | string | 否 | 通知标题,不提供时使用默认标题 |
| message | string | 否 | 通知消息内容 |
| content | string | 否 | 详细内容(与 message 二选一) |
#### 更新通知特有参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| url | string | 是 | 插件更新下载链接 |
| version | string | 否 | 新版本号 |
| pluginInfo.name | string | 否 | 插件名称 |
| pluginInfo.type | 'cr' \| 'lx' | 否 | 插件类型 |
| pluginInfo.forcedUpdate | boolean | 否 | 是否强制更新 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------------ | ---- | ---------------- |
| url | string | 是 | 插件更新下载链接 |
| version | string | 否 | 新版本号 |
| pluginInfo.name | string | 否 | 插件名称 |
| pluginInfo.type | 'cr' \| 'lx' | 否 | 插件类型 |
| pluginInfo.forcedUpdate | boolean | 否 | 是否强制更新 |
#### 错误通知特有参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| error | string | 否 | 具体错误信息 |
| 参数 | 类型 | 必填 | 说明 |
| ----- | ------ | ---- | ------------ |
| error | string | 否 | 具体错误信息 |
## 实现原理
@@ -208,8 +208,9 @@ window.api.on('plugin-notice', (_, notice) => {
## 更新日志
### v1.0.0 (2025-09-20)
- ✨ 初始版本发布
- ✨ 支持 5 种通知类型
- ✨ 完整的 TypeScript 类型定义
- ✨ 响应式设计和深色主题支持
- ✨ 完善的错误处理机制
- ✨ 完善的错误处理机制

View File

@@ -12,6 +12,7 @@ files:
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
- node_modules/ffmpeg-static/**
win:
executableName: ceru-music
icon: 'resources/icons/icon.ico'
@@ -20,18 +21,16 @@ win:
arch:
- x64
- ia32
# 简化版本信息设置避免rcedit错误
- target: zip
arch:
- x64
- ia32
fileAssociations:
- ext: cerumusic
name: CeruMusic File
description: CeruMusic playlist file
# 如果有证书文件,取消注释以下配置
# certificateFile: path/to/certificate.p12
# certificatePassword: your-password
# 或者使用证书存储
# certificateSubjectName: "Your Company Name"
nsis:
artifactName: ${name}-${version}-${arch}-setup.${ext}
artifactName: ${name}-${version}-win-${arch}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
@@ -43,23 +42,40 @@ nsis:
mac:
icon: 'resources/icons/icon.icns'
entitlementsInherit: build/entitlements.mac.plist
target:
- target: dmg
arch:
- universal
- target: zip
arch:
- universal
extendInfo:
- NSDocumentsFolderUsageDescription: 需要访问文档文件夹来保存和打开您创建的文件。
- NSDownloadsFolderUsageDescription: 需要访问下载文件夹来管理您下载的内容
- NSDownloadsFolderUsageDescription: 需要访问下载文件夹来管理您下载的歌曲
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${version}-${arch}.${ext}
title: ${productName}
linux:
icon: 'resources/icons'
target:
- AppImage
- snap
- deb
- target: AppImage
arch:
- x64
- target: snap
arch:
- x64
- target: deb
arch:
- x64
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${version}-linux-${arch}.${ext}
snap:
artifactName: ${name}-${version}-linux-${arch}.${ext}
deb:
artifactName: ${name}-${version}-linux-${arch}.${ext}
npmRebuild: false
publish:
provider: generic

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.3.2",
"version": "1.3.12",
"description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js",
"author": "sqj,wldss,star",
@@ -8,7 +8,7 @@
"homepage": "https://ceru.docs.shiqianjiang.cn",
"scripts": {
"format": "prettier --write .",
"lint": "eslint --cache . --fix",
"lint": "eslint --cache . --fix && yarn typecheck",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "yarn run typecheck:node && yarn run typecheck:web",
@@ -18,9 +18,12 @@
"onlybuild": "electron-vite build && electron-builder --win --x64",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "yarn run build && electron-builder --dir",
"build:win": "yarn run build && electron-builder --win --config --publish never",
"build:win": "yarn run build && electron-builder --win --x64 --config --publish never",
"build:win32": "yarn run build && electron-builder --win --ia32 --config --publish never",
"build:mac": "yarn run build && electron-builder --mac --config --publish never",
"build:mac:intel": "yarn run build && electron-builder --mac --x64 --config --publish never",
"build:mac:arm64": "yarn run build && electron-builder --mac --arm64 --config --publish never",
"build:mac:universal": "yarn run build && electron-builder --mac --universal --config --publish never",
"build:linux": "yarn run build && electron-builder --linux --config --publish never",
"build:deps": "electron-builder install-app-deps && yarn run build && electron-builder --win --x64 --config",
"buildico": "electron-icon-builder --input=./resources/logo.png --output=resources --flatten",
@@ -44,6 +47,7 @@
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/needle": "^3.3.0",
"NeteaseCloudMusicApi": "^4.27.0",
"animate.css": "^4.1.1",
@@ -53,6 +57,8 @@
"dompurify": "^3.2.6",
"electron-log": "^5.4.3",
"electron-updater": "^6.3.9",
"ffmpeg-static": "^5.2.0",
"fluent-ffmpeg": "^2.1.3",
"hpagent": "^1.2.0",
"iconv-lite": "^0.7.0",
"jss": "^10.10.0",
@@ -63,7 +69,9 @@
"mitt": "^3.0.1",
"needle": "^3.3.1",
"node-fetch": "2",
"node-id3": "^0.2.9",
"pinia": "^3.0.3",
"tdesign-icons-vue-next": "^0.4.1",
"tdesign-vue-next": "^1.15.2",
"vue-router": "^4.5.1",
"zlib": "^1.0.5"

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
version: "1.0"
version: '1.0'
profile:
name: qodana.starter

View File

@@ -1,55 +1,79 @@
const fs = require('fs');
const path = require('path');
const fs = require('fs')
const path = require('path')
function generateTree(
dir,
prefix = '',
isLast = true,
excludeDirs = [
'node_modules',
'dist',
'out',
'.git',
'.kiro',
'.idea',
'.codebuddy',
'.vscode',
'.workflow',
'assets',
'resources',
'docs'
]
) {
const basename = path.basename(dir)
function generateTree(dir, prefix = '', isLast = true, excludeDirs = ['node_modules', 'dist', 'out', '.git','.kiro','.idea','.codebuddy','.vscode','.workflow','assets','resources','docs']) {
const basename = path.basename(dir);
// 跳过排除的目录和隐藏文件
if (basename.startsWith('.') && basename !== '.' && basename !== '..' && !['.github', '.workflow'].includes(basename)) {
return;
if (
basename.startsWith('.') &&
basename !== '.' &&
basename !== '..' &&
!['.github', '.workflow'].includes(basename)
) {
return
}
if (excludeDirs.includes(basename)) {
return;
return
}
// 当前项目显示
if (prefix === '') {
console.log(`${basename}/`);
console.log(`${basename}/`)
} else {
const connector = isLast ? '└── ' : '├── ';
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename;
console.log(prefix + connector + displayName);
const connector = isLast ? '└── ' : '├── '
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename
console.log(prefix + connector + displayName)
}
if (!fs.statSync(dir).isDirectory()) {
return;
return
}
try {
const items = fs.readdirSync(dir)
.filter(item => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
.filter(item => !excludeDirs.includes(item))
const items = fs
.readdirSync(dir)
.filter((item) => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
.filter((item) => !excludeDirs.includes(item))
.sort((a, b) => {
// 目录排在前面,文件排在后面
const aIsDir = fs.statSync(path.join(dir, a)).isDirectory();
const bIsDir = fs.statSync(path.join(dir, b)).isDirectory();
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.localeCompare(b);
});
const newPrefix = prefix + (isLast ? ' ' : '│ ');
const aIsDir = fs.statSync(path.join(dir, a)).isDirectory()
const bIsDir = fs.statSync(path.join(dir, b)).isDirectory()
if (aIsDir && !bIsDir) return -1
if (!aIsDir && bIsDir) return 1
return a.localeCompare(b)
})
const newPrefix = prefix + (isLast ? ' ' : '│ ')
items.forEach((item, index) => {
const isLastItem = index === items.length - 1;
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs);
});
const isLastItem = index === items.length - 1
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs)
})
} catch (error) {
console.error(`Error reading directory: ${dir}`, error.message);
console.error(`Error reading directory: ${dir}`, error.message)
}
}
// 使用示例
const targetDir = process.argv[2] || '.';
console.log('项目文件结构:');
generateTree(targetDir);
const targetDir = process.argv[2] || '.'
console.log('项目文件结构:')
generateTree(targetDir)

View File

@@ -1,61 +1,19 @@
import { ipcMain, dialog, app } from 'electron'
import { join } from 'path'
import fs from 'fs'
import { promisify } from 'util'
const mkdir = promisify(fs.mkdir)
const access = promisify(fs.access)
export const CONFIG_NAME = 'sqj_config.json'
// 默认目录配置
const getDefaultDirectories = () => {
const userDataPath = app.getPath('userData')
return {
cacheDir: join(userDataPath, 'music-cache'),
downloadDir: join(app.getPath('music'), 'CeruMusic/songs')
}
}
// 确保目录存在
const ensureDirectoryExists = async (dirPath: string) => {
try {
await access(dirPath)
} catch {
await mkdir(dirPath, { recursive: true })
}
}
import { ipcMain, dialog } from 'electron'
import { configManager } from '../services/ConfigManager'
// 获取当前目录配置
ipcMain.handle('directory-settings:get-directories', async () => {
try {
const defaults = getDefaultDirectories()
// 从配置文件读取用户设置的目录
const configPath = join(app.getPath('userData'), CONFIG_NAME)
let userConfig: any = {}
try {
const configData = fs.readFileSync(configPath, 'utf-8')
userConfig = JSON.parse(configData)
} catch {
// 配置文件不存在或读取失败,使用默认配置
}
const directories = {
cacheDir: userConfig.cacheDir || defaults.cacheDir,
downloadDir: userConfig.downloadDir || defaults.downloadDir
}
const directories = configManager.getDirectories()
// 确保目录存在
await ensureDirectoryExists(directories.cacheDir)
await ensureDirectoryExists(directories.downloadDir)
await configManager.ensureDirectoryExists(directories.cacheDir)
await configManager.ensureDirectoryExists(directories.downloadDir)
return directories
} catch (error) {
console.error('获取目录配置失败:', error)
const defaults = getDefaultDirectories()
return defaults
return configManager.getDirectories() // 返回默认配置
}
})
@@ -70,7 +28,7 @@ ipcMain.handle('directory-settings:select-cache-dir', async () => {
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0]
await ensureDirectoryExists(selectedPath)
await configManager.ensureDirectoryExists(selectedPath)
return { success: true, path: selectedPath }
}
@@ -92,7 +50,7 @@ ipcMain.handle('directory-settings:select-download-dir', async () => {
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0]
await ensureDirectoryExists(selectedPath)
await configManager.ensureDirectoryExists(selectedPath)
return { success: true, path: selectedPath }
}
@@ -106,16 +64,8 @@ ipcMain.handle('directory-settings:select-download-dir', async () => {
// 保存目录配置
ipcMain.handle('directory-settings:save-directories', async (_, directories) => {
try {
const configPath = join(app.getPath('userData'), CONFIG_NAME)
// 确保目录存在
await ensureDirectoryExists(directories.cacheDir)
await ensureDirectoryExists(directories.downloadDir)
// 保存配置
fs.writeFileSync(configPath, JSON.stringify(directories, null, 2))
return { success: true, message: '目录配置已保存' }
const success = await configManager.saveDirectories(directories)
return { success, message: success ? '目录配置已保存' : '保存配置失败' }
} catch (error) {
console.error('保存目录配置失败:', error)
return { success: false, message: '保存配置失败' }
@@ -125,21 +75,19 @@ ipcMain.handle('directory-settings:save-directories', async (_, directories) =>
// 重置为默认目录
ipcMain.handle('directory-settings:reset-directories', async () => {
try {
const defaults = getDefaultDirectories()
const configPath = join(app.getPath('userData'), CONFIG_NAME)
// 重置目录配置
configManager.delete('cacheDir')
configManager.delete('downloadDir')
configManager.saveConfig()
// 删除配置文件
try {
fs.unlinkSync(configPath)
} catch {
// 文件不存在,忽略错误
}
// 获取默认目录
const directories = configManager.getDirectories()
// 确保默认目录存在
await ensureDirectoryExists(defaults.cacheDir)
await ensureDirectoryExists(defaults.downloadDir)
await configManager.ensureDirectoryExists(directories.cacheDir)
await configManager.ensureDirectoryExists(directories.downloadDir)
return { success: true, directories: defaults }
return { success: true, directories }
} catch (error) {
console.error('重置目录配置失败:', error)
return { success: false, message: '重置配置失败' }
@@ -161,6 +109,9 @@ ipcMain.handle('directory-settings:open-directory', async (_, dirPath) => {
// 获取目录大小
ipcMain.handle('directory-settings:get-directory-size', async (_, dirPath) => {
try {
const fs = require('fs')
const { join } = require('path')
const getDirectorySize = (dirPath: string): number => {
let totalSize = 0

View File

@@ -1,4 +1,5 @@
import { app, shell, BrowserWindow, ipcMain, Tray, Menu } from 'electron'
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, screen } from 'electron'
import { configManager } from './services/ConfigManager'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/logo.png?asset'
@@ -89,20 +90,27 @@ function createTray(): void {
function createWindow(): void {
// return
// Create the browser window.
mainWindow = new BrowserWindow({
// 获取保存的窗口位置和大小
const savedBounds = configManager.getWindowBounds()
// 获取屏幕尺寸
const primaryDisplay = screen.getPrimaryDisplay()
// 使用完整屏幕尺寸而不是工作区域,以支持真正的全屏模式
const { width: screenWidth, height: screenHeight } = primaryDisplay.size
// 默认窗口配置
const defaultOptions = {
width: 1100,
height: 750,
minWidth: 1100,
minHeight: 670,
maxWidth: screenWidth,
maxHeight: screenHeight,
show: false,
center: true,
center: !savedBounds, // 如果有保存的位置,则不居中
autoHideMenuBar: true,
// alwaysOnTop: true,
// 移除最大宽高限制,以便全屏模式能够铺满整个屏幕
titleBarStyle: 'hidden',
titleBarStyle: 'hidden' as const,
...(process.platform === 'linux' ? { icon } : {}),
// ...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
icon: path.join(__dirname, '../../resources/logo.ico'),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
@@ -112,9 +120,57 @@ function createWindow(): void {
contextIsolation: false,
backgroundThrottling: false
}
})
}
// 如果有保存的窗口位置和大小,则使用保存的值
if (savedBounds) {
Object.assign(defaultOptions, savedBounds)
}
// Create the browser window.
mainWindow = new BrowserWindow(defaultOptions)
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
// 监听窗口移动和调整大小事件,保存窗口位置和大小
mainWindow.on('moved', () => {
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
const bounds = mainWindow.getBounds()
configManager.saveWindowBounds(bounds)
}
})
mainWindow.on('resized', () => {
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
const bounds = mainWindow.getBounds()
// 获取当前屏幕尺寸
const { screen } = require('electron')
const currentDisplay = screen.getDisplayMatching(bounds)
const { width: screenWidth, height: screenHeight } = currentDisplay.workAreaSize
// 确保窗口不超过屏幕尺寸
let needResize = false
const newBounds = { ...bounds }
if (bounds.width > screenWidth) {
newBounds.width = screenWidth
needResize = true
}
if (bounds.height > screenHeight) {
newBounds.height = screenHeight
needResize = true
}
// 如果需要调整大小,应用新的尺寸
if (needResize) {
mainWindow.setBounds(newBounds)
}
configManager.saveWindowBounds(newBounds)
}
})
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
})
@@ -159,6 +215,15 @@ ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any
}
})
ipcMain.handle('service-plugin-downloadAndAddPlugin', async (_, url, type): Promise<any> => {
try {
return await pluginService.downloadAndAddPlugin(url, type)
} catch (error: any) {
console.error('Error downloading and adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-addPlugin', async (_, pluginCode, pluginName): Promise<any> => {
try {
return await pluginService.addPlugin(pluginCode, pluginName)

View File

@@ -0,0 +1,162 @@
import { app } from 'electron'
import { join } from 'path'
import fs from 'fs'
import { promisify } from 'util'
const mkdir = promisify(fs.mkdir)
const access = promisify(fs.access)
export const CONFIG_NAME = 'sqj_config.json'
// 配置管理器类
export class ConfigManager {
private static instance: ConfigManager
private configPath: string
private config: Record<string, any> = {}
private constructor() {
this.configPath = join(app.getPath('userData'), CONFIG_NAME)
this.loadConfig()
}
// 单例模式获取实例
public static getInstance(): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager()
}
return ConfigManager.instance
}
// 加载配置
private loadConfig(): void {
try {
if (fs.existsSync(this.configPath)) {
const configData = fs.readFileSync(this.configPath, 'utf-8')
this.config = JSON.parse(configData)
}
} catch (error) {
console.error('加载配置失败:', error)
this.config = {}
}
}
// 保存配置
public saveConfig(): boolean {
try {
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2))
return true
} catch (error) {
console.error('保存配置失败:', error)
return false
}
}
// 获取配置项
public get<T>(key: string, defaultValue?: T): T {
const value = this.config[key]
return value !== undefined ? value : (defaultValue as T)
}
// 设置配置项
public set<T>(key: string, value: T): void {
this.config[key] = value
}
// 删除配置项
public delete(key: string): void {
delete this.config[key]
}
// 重置所有配置
public reset(): void {
this.config = {}
this.saveConfig()
}
// 获取所有配置
public getAll(): Record<string, any> {
return { ...this.config }
}
// 确保目录存在
public async ensureDirectoryExists(dirPath: string): Promise<void> {
try {
await access(dirPath)
} catch {
await mkdir(dirPath, { recursive: true })
}
}
// 获取目录配置
public getDirectories() {
const userDataPath = app.getPath('userData')
const defaults = {
cacheDir: join(userDataPath, 'music-cache'),
downloadDir: join(app.getPath('music'), 'CeruMusic/songs')
}
return {
cacheDir: this.get('cacheDir', defaults.cacheDir),
downloadDir: this.get('downloadDir', defaults.downloadDir)
}
}
// 保存目录配置
public async saveDirectories(directories: {
cacheDir: string
downloadDir: string
}): Promise<boolean> {
try {
await this.ensureDirectoryExists(directories.cacheDir)
await this.ensureDirectoryExists(directories.downloadDir)
this.set('cacheDir', directories.cacheDir)
this.set('downloadDir', directories.downloadDir)
return this.saveConfig()
} catch (error) {
console.error('保存目录配置失败:', error)
return false
}
}
// 保存窗口位置和大小
public saveWindowBounds(bounds: { x: number; y: number; width: number; height: number }): void {
this.set('windowBounds', bounds)
this.saveConfig()
}
// 获取窗口位置和大小,确保窗口完全在屏幕内
public getWindowBounds(): { x: number; y: number; width: number; height: number } | null {
const bounds = this.get<{ x: number; y: number; width: number; height: number } | null>(
'windowBounds',
null
)
if (bounds) {
const { screen } = require('electron')
// 获取主显示器
const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
// 确保窗口在屏幕内
if (bounds.x < 0) bounds.x = 0
if (bounds.y < 0) bounds.y = 0
// 确保窗口右侧不超出屏幕
if (bounds.x + bounds.width > screenWidth) {
bounds.x = Math.max(0, screenWidth - bounds.width)
}
// 确保窗口底部不超出屏幕
if (bounds.y + bounds.height > screenHeight) {
bounds.y = Math.max(0, screenHeight - bounds.height)
}
}
return bounds
}
}
// 导出单例实例
export const configManager = ConfigManager.getInstance()

View File

@@ -1,9 +1,8 @@
import { app } from 'electron'
import * as path from 'path'
import * as fs from 'fs/promises'
import * as crypto from 'crypto'
import axios from 'axios'
import { CONFIG_NAME } from '../../events/directorySettings'
import { configManager } from '../ConfigManager'
export class MusicCacheService {
private cacheIndex: Map<string, string> = new Map()
@@ -13,21 +12,9 @@ export class MusicCacheService {
}
private getCacheDirectory(): string {
try {
// 尝试从配置文件读取自定义缓存目录
const configPath = path.join(app.getPath('userData'), CONFIG_NAME)
const configData = require('fs').readFileSync(configPath, 'utf-8')
const config = JSON.parse(configData)
if (config.cacheDir && typeof config.cacheDir === 'string') {
return config.cacheDir
}
} catch {
// 配置文件不存在或读取失败,使用默认目录
}
// 默认缓存目录
return path.join(app.getPath('userData'), 'music-cache')
// 使用配置管理服务获取缓存目录
const directories = configManager.getDirectories()
return directories.cacheDir
}
// 动态获取缓存目录

View File

@@ -18,11 +18,417 @@ import fsPromise from 'fs/promises'
import axios from 'axios'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'url'
import { app } from 'electron'
import { CONFIG_NAME } from '../../events/directorySettings'
import { configManager } from '../ConfigManager'
import NodeID3 from 'node-id3'
import ffmpegStatic from 'ffmpeg-static'
import ffmpeg from 'fluent-ffmpeg'
const fileLock: Record<string, boolean> = {}
/**
* 转换LRC格式
* 将带有字符位置信息的LRC格式转换为标准的逐字时间戳格式
* @param lrcContent 原始LRC内容
* @returns 转换后的LRC内容
*/
function convertLrcFormat(lrcContent: string): string {
if (!lrcContent) return ''
const lines = lrcContent.split('\n')
const convertedLines: string[] = []
for (const line of lines) {
// 跳过空行
if (!line.trim()) {
convertedLines.push(line)
continue
}
// 检查是否是新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
const newFormatMatch = line.match(/^\[(\d+),(\d+)\](.*)$/)
if (newFormatMatch) {
const [, startTimeMs, , content] = newFormatMatch
const convertedLine = convertNewFormat(parseInt(startTimeMs), content)
convertedLines.push(convertedLine)
continue
}
// 检查是否是旧格式:[mm:ss.xxx]字符(偏移,持续时间)
const oldFormatMatch = line.match(/^\[(\d{2}:\d{2}\.\d{3})\](.*)$/)
if (oldFormatMatch) {
const [, timestamp, content] = oldFormatMatch
// 如果内容中没有位置信息,直接返回原行
if (!content.includes('(') || !content.includes(')')) {
convertedLines.push(line)
continue
}
const convertedLine = convertOldFormat(timestamp, content)
convertedLines.push(convertedLine)
continue
}
// 其他行直接保留
convertedLines.push(line)
}
return convertedLines.join('\n')
}
/**
* 将毫秒时间戳格式化为 mm:ss.xxx 格式
* @param timeMs 毫秒时间戳
* @returns 格式化的时间字符串
*/
function formatTimestamp(timeMs: number): string {
const minutes = Math.floor(timeMs / 60000)
const seconds = Math.floor((timeMs % 60000) / 1000)
const milliseconds = timeMs % 1000
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
}
/**
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
*/
function convertNewFormat(baseTimeMs: number, content: string): string {
const baseTimestamp = formatTimestamp(baseTimeMs)
let convertedContent = `<${baseTimestamp}>`
// 匹配模式:(开始时间,字符持续时间,0)字符
const charPattern = /\((\d+),(\d+),(\d+)\)([^(]*?)(?=\(|$)/g
let match
let isFirstChar = true
while ((match = charPattern.exec(content)) !== null) {
const [, charStartMs, , , char] = match
const charTimeMs = parseInt(charStartMs)
const charTimestamp = formatTimestamp(charTimeMs)
if (isFirstChar) {
// 第一个字符直接添加
convertedContent += char.trim()
isFirstChar = false
} else {
convertedContent += `<${charTimestamp}>${char.trim()}`
}
}
return `[${baseTimestamp}]${convertedContent}`
}
/**
* 转换旧格式:[mm:ss.xxx]字符(偏移,持续时间)
*/
function convertOldFormat(timestamp: string, content: string): string {
// 解析基础时间戳(毫秒)
const [minutes, seconds] = timestamp.split(':')
const [sec, ms] = seconds.split('.')
const baseTimeMs = parseInt(minutes) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(ms)
let convertedContent = `<${timestamp}>`
// 匹配所有字符(偏移,持续时间)的模式
const charPattern = /([^()]+)\((\d+),(\d+)\)/g
let match
let lastIndex = 0
let isFirstChar = true
while ((match = charPattern.exec(content)) !== null) {
const [fullMatch, char, offsetMs, _durationMs] = match
const charTimeMs = baseTimeMs + parseInt(offsetMs)
const charTimestamp = formatTimestamp(charTimeMs)
// 添加匹配前的普通文本
if (match.index > lastIndex) {
const beforeText = content.substring(lastIndex, match.index)
if (beforeText.trim()) {
convertedContent += beforeText
}
}
// 添加带时间戳的字符
if (isFirstChar) {
// 第一个字符直接添加,不需要额外的时间戳
convertedContent += char
isFirstChar = false
} else {
convertedContent += `<${charTimestamp}>${char}`
}
lastIndex = match.index + fullMatch.length
}
// 添加剩余的普通文本
if (lastIndex < content.length) {
const remainingText = content.substring(lastIndex)
if (remainingText.trim()) {
convertedContent += remainingText
}
}
return `[${timestamp}]${convertedContent}`
}
// 写入音频标签的辅助函数
async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) {
try {
console.log('开始写入音频标签:', filePath, tagWriteOptions, songInfo)
// 获取文件扩展名来判断格式
const fileExtension = path.extname(filePath).toLowerCase().substring(1)
console.log('文件格式:', fileExtension)
// 根据文件格式选择不同的标签写入方法
if (fileExtension === 'mp3') {
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
} else if (['flac', 'ogg', 'opus'].includes(fileExtension)) {
await writeVorbisCommentTags(filePath, songInfo, tagWriteOptions)
} else if (['m4a', 'mp4', 'aac'].includes(fileExtension)) {
await writeMP4Tags(filePath, songInfo, tagWriteOptions)
} else {
console.warn('不支持的音频格式:', fileExtension)
// 尝试使用 NodeID3 作为后备方案
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
}
} catch (error) {
console.error('写入音频标签时发生错误:', error)
throw error
}
}
// MP3 格式标签写入
async function writeMP3Tags(filePath: string, songInfo: any, tagWriteOptions: any) {
const tags: any = {}
// 写入基础信息
if (tagWriteOptions.basicInfo) {
tags.title = songInfo.name || ''
tags.artist = songInfo.singer || ''
tags.album = songInfo.albumName || ''
tags.year = songInfo.year || ''
tags.genre = songInfo.genre || ''
}
// 写入歌词
if (tagWriteOptions.lyrics && songInfo.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
tags.unsynchronisedLyrics = {
language: 'chi',
shortText: 'Lyrics',
text: convertedLrc
}
}
// 写入封面
if (tagWriteOptions.cover && songInfo.img) {
try {
const coverResponse = await axios({
method: 'GET',
url: songInfo.img,
responseType: 'arraybuffer',
timeout: 10000
})
if (coverResponse.data) {
tags.image = {
mime: 'image/jpeg',
type: {
id: 3,
name: 'front cover'
},
description: 'Cover',
imageBuffer: Buffer.from(coverResponse.data)
}
}
} catch (coverError) {
console.warn('获取封面失败:', coverError)
}
}
// 写入标签到文件
if (Object.keys(tags).length > 0) {
const success = NodeID3.write(tags, filePath)
if (success) {
console.log('MP3音频标签写入成功:', filePath)
} else {
console.warn('MP3音频标签写入失败:', filePath)
}
}
}
// FLAC/OGG 格式标签写入 (使用 Vorbis Comment)
async function writeVorbisCommentTags(filePath: string, songInfo: any, tagWriteOptions: any) {
try {
console.log('开始写入 FLAC 标签:', filePath)
// 准备新的标签数据
const newTags: any = {}
// 写入基础信息
if (tagWriteOptions.basicInfo) {
if (songInfo.name) newTags.TITLE = songInfo.name
if (songInfo.singer) newTags.ARTIST = songInfo.singer
if (songInfo.albumName) newTags.ALBUM = songInfo.albumName
if (songInfo.year) newTags.DATE = songInfo.year.toString()
if (songInfo.genre) newTags.GENRE = songInfo.genre
}
// 写入歌词
if (tagWriteOptions.lyrics && songInfo.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
newTags.LYRICS = convertedLrc
}
console.log('准备写入的标签:', newTags)
// 使用 ffmpeg-static 写入 FLAC 标签
if (path.extname(filePath).toLowerCase() === '.flac') {
await writeFLACTagsWithFFmpeg(filePath, newTags, songInfo, tagWriteOptions)
} else {
console.warn('暂不支持该格式的标签写入:', path.extname(filePath))
}
} catch (error) {
console.error('写入 Vorbis Comment 标签失败:', error)
throw error
}
}
// 使用 fluent-ffmpeg 写入 FLAC 标签
async function writeFLACTagsWithFFmpeg(
filePath: string,
tags: any,
songInfo: any,
tagWriteOptions: any
) {
let tempOutputPath: string | null = null
let tempCoverPath: string | null = null
try {
if (!ffmpegStatic) {
throw new Error('ffmpeg-static 不可用')
}
ffmpeg.setFfmpegPath(ffmpegStatic.replace('app.asar', 'app.asar.unpacked'))
// 创建临时输出文件
tempOutputPath = filePath + '.temp.flac'
// 创建 fluent-ffmpeg 实例
let command = ffmpeg(filePath)
.audioCodec('copy') // 复制音频编解码器,不重新编码
.output(tempOutputPath)
// 添加元数据标签
for (const [key, value] of Object.entries(tags)) {
if (value) {
// fluent-ffmpeg 会自动处理特殊字符转义
command = command.outputOptions(['-metadata', `${key}=${value}`])
}
}
// 处理封面
if (tagWriteOptions.cover && songInfo.img) {
try {
console.log('开始下载封面:', songInfo.img)
const coverResponse = await axios({
method: 'GET',
url: songInfo.img,
responseType: 'arraybuffer',
timeout: 10000
})
if (coverResponse.data) {
// 保存临时封面文件
tempCoverPath = path.join(path.dirname(filePath), 'temp_cover.jpg')
await fsPromise.writeFile(tempCoverPath, Buffer.from(coverResponse.data))
// 添加封面作为输入
command = command.input(tempCoverPath).outputOptions([
'-map',
'0:a', // 映射原始文件的音频流
'-map',
'1:v', // 映射封面的视频流
'-c:v',
'copy', // 复制视频编解码器
'-disposition:v:0',
'attached_pic' // 设置为附加图片
])
console.log('封面已添加到命令中')
}
} catch (coverError) {
console.warn(
'下载封面失败,跳过封面写入:',
coverError instanceof Error ? coverError.message : coverError
)
}
}
// 执行 ffmpeg 命令
await new Promise<void>((resolve, reject) => {
command
.on('start', () => {
console.log('执行 ffmpeg 命令')
})
.on('progress', (progress) => {
if (progress.percent) {
console.log('处理进度:', Math.round(progress.percent) + '%')
}
})
.on('end', () => {
console.log('ffmpeg 处理完成')
resolve()
})
.on('error', (err, _, stderr) => {
console.error('ffmpeg 错误:', err.message)
if (stderr) {
console.error('ffmpeg stderr:', stderr)
}
reject(new Error(`ffmpeg 处理失败: ${err.message}`))
})
.run()
})
// 检查临时文件是否创建成功
if (!fs.existsSync(tempOutputPath)) {
throw new Error('ffmpeg 未能创建输出文件')
}
// 替换原文件
await fsPromise.rename(tempOutputPath, filePath)
tempOutputPath = null // 标记已处理,避免重复清理
console.log('使用 fluent-ffmpeg 写入 FLAC 标签成功:', filePath)
} catch (error) {
console.error('使用 fluent-ffmpeg 写入 FLAC 标签失败:', error)
throw error
} finally {
// 清理所有临时文件
const filesToClean = [tempOutputPath, tempCoverPath].filter(Boolean) as string[]
for (const tempFile of filesToClean) {
try {
if (fs.existsSync(tempFile)) {
await fsPromise.unlink(tempFile)
console.log('已清理临时文件:', tempFile)
}
} catch (cleanupError) {
console.warn(
'清理临时文件失败:',
tempFile,
cleanupError instanceof Error ? cleanupError.message : cleanupError
)
}
}
}
}
// MP4/M4A 格式标签写入
async function writeMP4Tags(filePath: string, _songInfo: any, _tagWriteOptions: any) {
console.log('MP4/M4A 格式标签写入暂未实现:', filePath)
// 可以使用 ffmpeg 或其他工具实现
}
function main(source: string) {
const Api = musicSdk[source]
return {
@@ -85,29 +491,27 @@ function main(source: string) {
},
async getPlaylistDetail({ id, page }: GetSongListDetailsArg) {
// 酷狗音乐特殊处理直接调用getUserListDetail
if (source === 'kg' && /https?:\/\//.test(id)) {
return (await Api.songList.getUserListDetail(id, page)) as PlaylistDetailResult
}
return (await Api.songList.getListDetail(id, page)) as PlaylistDetailResult
},
async downloadSingleSong({ pluginId, songInfo, quality }: DownloadSingleSongArgs) {
async downloadSingleSong({
pluginId,
songInfo,
quality,
tagWriteOptions
}: DownloadSingleSongArgs) {
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
// 获取自定义下载目录
const getDownloadDirectory = (): string => {
try {
const configPath = path.join(app.getPath('userData'), CONFIG_NAME)
const configData = fs.readFileSync(configPath, 'utf-8')
const config = JSON.parse(configData)
if (config.downloadDir && typeof config.downloadDir === 'string') {
return config.downloadDir
}
} catch {
// 配置文件不存在或读取失败,使用默认目录
}
// 默认下载目录
return path.join(app.getPath('music'), 'CeruMusic/songs')
// 使用配置管理服务获取下载目录
const directories = configManager.getDirectories()
return directories.downloadDir
}
// 从URL中提取文件扩展名如果没有则默认为mp3
@@ -175,6 +579,16 @@ function main(source: string) {
delete fileLock[songPath]
}
// 写入标签信息
if (tagWriteOptions && fs.existsSync(songPath)) {
try {
await writeAudioTags(songPath, songInfo, tagWriteOptions)
} catch (error) {
console.warn('写入音频标签失败:', error)
throw ffmpegStatic
}
}
return {
message: '下载成功',
path: songPath

View File

@@ -90,6 +90,13 @@ export interface PlaylistDetailResult {
info: PlaylistInfo
}
export interface TagWriteOptions {
basicInfo?: boolean
cover?: boolean
lyrics?: boolean
}
export interface DownloadSingleSongArgs extends GetMusicUrlArg {
path?: string
tagWriteOptions?: TagWriteOptions
}

View File

@@ -4,6 +4,7 @@ import fsPromise from 'fs/promises'
import { randomUUID } from 'crypto'
import { dialog } from 'electron'
import { getAppDirPath } from '../../utils/path'
import axios from 'axios'
import CeruMusicPluginHost from './manager/CeruMusicPluginHost'
import convertEventDrivenPlugin from './manager/converter-event-driven'
@@ -223,6 +224,60 @@ const pluginService = {
})
},
async downloadAndAddPlugin(url: string, type: 'lx' | 'cr') {
try {
// 验证URL
if (!url || typeof url !== 'string') {
throw new Error('无效的URL地址')
}
// 下载文件
let pluginCode = await this.downloadFile(url)
// 生成临时文件名
const fileName = `downloaded_${Date.now()}.js`
if (type == 'lx') {
pluginCode = convertEventDrivenPlugin(pluginCode)
}
// 调用现有的添加插件方法
return await this.addPlugin(pluginCode, fileName)
} catch (error: any) {
console.error('下载并添加插件失败:', error)
return { error: error.message || '下载插件失败' }
}
},
async downloadFile(url: string): Promise<string> {
try {
const response = await axios.get(url, {
timeout: 30000, // 30秒超时
responseType: 'text',
headers: {
'User-Agent': 'CeruMusic/1.0'
}
})
if (response.status !== 200) {
throw new Error(`下载失败: HTTP ${response.status}`)
}
const data = response.data
if (!data || !data.trim()) {
throw new Error('下载的文件内容为空')
}
return data
} catch (error: any) {
if (error.response) {
throw new Error(`下载失败: HTTP ${error.response.status}`)
} else if (error.request) {
throw new Error('网络错误: 无法连接到服务器')
} else {
throw new Error(`下载错误: ${error.message}`)
}
}
},
async getPluginLog(pluginId: string) {
return await getLog(pluginId)
}

View File

@@ -24,5 +24,4 @@ const kg = {
return `https://www.kugou.com/song/#hash=${songInfo.hash}&album_id=${songInfo.albumId}`
}
}
export default kg

View File

@@ -21,5 +21,4 @@ const tx = {
return `https://y.qq.com/n/yqq/song/${songInfo.songmid}.html`
}
}
export default tx

View File

@@ -1,10 +1,31 @@
import qrcDecrypt from './qrc-decrypt'
import { httpFetch } from '../../request'
import getMusicInfo from './musicInfo'
const songIdMap = new Map()
const promises = new Map()
const decode = qrcDecrypt()
export default {
rxps: {
info: /^{"/,
lineTime: /^\[(\d+),\d+\]/,
lineTime2: /^\[([\d:.]+)\]/,
wordTime: /\(\d+,\d+,\d+\)/,
wordTimeAll: /(\(\d+,\d+,\d+\))/g,
timeLabelFixRxp: /(?:\.0+|0+)$/
},
msFormat(timeMs) {
if (Number.isNaN(timeMs)) return ''
let ms = timeMs % 1000
timeMs /= 1000
let m = parseInt(timeMs / 60)
.toString()
.padStart(2, '0')
timeMs %= 60
let s = parseInt(timeMs).toString().padStart(2, '0')
return `[${m}:${s}.${String(ms).padStart(3, '0')}]`
},
successCode: 0,
async getSongId({ songId, songmid }) {
if (songId) return songId
@@ -17,6 +38,179 @@ export default {
promises.delete(songmid)
return info.songId
},
removeTag(str) {
return str.replace(/^[\S\s]*?LyricContent="/, '').replace(/"\/>[\S\s]*?$/, '')
},
parseCeru(lrc) {
lrc = lrc.trim()
lrc = lrc.replace(/\r/g, '')
if (!lrc) return { lyric: '', lxlyric: '' }
const lines = lrc.split('\n')
const lxlrcLines = []
const lrcLines = []
for (let line of lines) {
line = line.trim()
let result = this.rxps.lineTime.exec(line)
if (!result) {
if (line.startsWith('[offset')) {
lxlrcLines.push(line)
lrcLines.push(line)
continue
}
if (this.rxps.lineTime2.test(line)) {
// lxlrcLines.push(line)
lrcLines.push(line)
}
continue
}
const startMsTime = parseInt(result[1])
const startTimeStr = this.msFormat(startMsTime)
if (!startTimeStr) continue
let words = line.replace(this.rxps.lineTime, '')
lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`)
let times = words.match(this.rxps.wordTimeAll)
if (!times) continue
let currentStart = startMsTime
const processedTimes = []
times.forEach((time) => {
const result = /\((\d+),(\d+),(\d+)\)/.exec(time)
const duration = parseInt(result[2])
processedTimes.push(`(${currentStart},${duration},0)`)
currentStart += duration
})
const wordArr = words.split(this.rxps.wordTime)
const newWords = processedTimes.map((time, index) => `${time}${wordArr[index]}`).join('')
lxlrcLines.push(`${startTimeStr}${newWords}`)
}
return {
lyric: lrcLines.join('\n'),
lxlyric: lxlrcLines.join('\n')
}
},
getIntv(interval) {
if (!interval) return 0
if (!interval.includes('.')) interval += '.0'
let arr = interval.split(/:|\./)
while (arr.length < 3) arr.unshift('0')
const [m, s, ms] = arr
return parseInt(m) * 3600000 + parseInt(s) * 1000 + parseInt(ms)
},
fixRlrcTimeTag(rlrc, lrc) {
// console.log(lrc)
// console.log(rlrc)
const rlrcLines = rlrc.split('\n')
let lrcLines = lrc.split('\n')
// let temp = []
let newLrc = []
rlrcLines.forEach((line) => {
const result = this.rxps.lineTime2.exec(line)
if (!result) return
const words = line.replace(this.rxps.lineTime2, '')
if (!words.trim()) return
const t1 = this.getIntv(result[1])
while (lrcLines.length) {
const lrcLine = lrcLines.shift()
const lrcLineResult = this.rxps.lineTime2.exec(lrcLine)
if (!lrcLineResult) continue
const t2 = this.getIntv(lrcLineResult[1])
if (Math.abs(t1 - t2) < 100) {
newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0]))
break
}
// temp.push(line)
}
// lrcLines = [...temp, ...lrcLines]
// temp = []
})
return newLrc.join('\n')
},
fixTlrcTimeTag(tlrc, lrc) {
// console.log(lrc)
// console.log(tlrc)
const tlrcLines = tlrc.split('\n')
let lrcLines = lrc.split('\n')
// let temp = []
let newLrc = []
tlrcLines.forEach((line) => {
const result = this.rxps.lineTime2.exec(line)
if (!result) return
const words = line.replace(this.rxps.lineTime2, '')
if (!words.trim()) return
let time = result[1]
if (time.includes('.')) {
time += ''.padStart(3 - time.split('.')[1].length, '0')
}
const t1 = this.getIntv(time)
while (lrcLines.length) {
const lrcLine = lrcLines.shift()
const lrcLineResult = this.rxps.lineTime2.exec(lrcLine)
if (!lrcLineResult) continue
const t2 = this.getIntv(lrcLineResult[1])
if (Math.abs(t1 - t2) < 100) {
newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0]))
break
}
// temp.push(line)
}
// lrcLines = [...temp, ...lrcLines]
// temp = []
})
return newLrc.join('\n')
},
parse(lrc, tlrc, rlrc) {
const info = {
lyric: '',
tlyric: '',
rlyric: '',
crlyric: ''
}
if (lrc) {
let { lyric } = this.parseCeru(this.removeTag(lrc))
info.lyric = lyric
info.crlyric = lrc
}
if (rlrc) info.rlyric = this.fixRlrcTimeTag(this.parseRlyric(this.removeTag(rlrc)), info.lyric)
if (tlrc) info.tlyric = this.fixTlrcTimeTag(tlrc, info.lyric)
return info
},
parseRlyric(lrc) {
lrc = lrc.trim()
lrc = lrc.replace(/\r/g, '')
if (!lrc) return { lyric: '', lxlyric: '' }
const lines = lrc.split('\n')
const lrcLines = []
for (let line of lines) {
line = line.trim()
let result = this.rxps.lineTime.exec(line)
if (!result) continue
const startMsTime = parseInt(result[1])
const startTimeStr = this.msFormat(startMsTime)
if (!startTimeStr) continue
let words = line.replace(this.rxps.lineTime, '')
lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`)
}
return lrcLines.join('\n')
},
parseLyric(lrc, tlrc, rlrc) {
return this.parse(decode(lrc), decode(tlrc), decode(rlrc))
},
getLyric(mInfo, retryNum = 0) {
if (retryNum > 3) return Promise.reject(new Error('Get lyric failed'))

View File

@@ -0,0 +1,521 @@
import zlib from 'zlib'
export default () => {
const ENCRYPT = 1
const DECRYPT = 0
const sbox = [
// sbox1
[
14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12,
11, 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1,
7, 5, 11, 3, 14, 10, 0, 6, 13
],
// sbox2
[
15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 15, 12, 0, 1, 10,
6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2,
11, 6, 7, 12, 0, 5, 14, 9
],
// sbox3
[
10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14,
12, 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7,
4, 15, 14, 3, 11, 5, 2, 12
],
// sbox4
[
7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12,
1, 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 10, 13,
8, 9, 4, 5, 11, 12, 7, 2, 14
],
// sbox5
[
2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15,
10, 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2,
13, 6, 15, 0, 9, 10, 4, 5, 3
],
// sbox6
[
12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14,
0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10,
11, 14, 1, 7, 6, 0, 8, 13
],
// sbox7
[
4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12,
2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7,
9, 5, 0, 15, 14, 2, 3, 12
],
// sbox8
[
13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11,
0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13,
15, 12, 9, 0, 3, 5, 6, 11
]
]
/**
* 从 Buffer 中提取指定位置的位,并左移指定偏移量
* @param {Buffer} a - Buffer
* @param {number} b - 要提取的位索引
* @param {number} c - 位提取后的偏移量
* @returns {number} 提取后的位
*/
function bitnum(a, b, c) {
const byteIndex = Math.floor(b / 32) * 4 + 3 - Math.floor((b % 32) / 8)
const bitInByte = 7 - (b % 8)
const bit = (a[byteIndex] >> bitInByte) & 1
return bit << c
}
/**
* 从整数中提取指定位置的位,并左移指定偏移量
* @param {number} a - 整数
* @param {number} b - 要提取的位索引
* @param {number} c - 位提取后的偏移量
* @returns {number} 提取后的位
*/
function bitnum_intr(a, b, c) {
return (((a >>> (31 - b)) & 1) << c) | 0
}
/**
* 从整数中提取指定位置的位,并右移指定偏移量
* @param {number} a - 整数
* @param {number} b - 要提取的位索引
* @param {number} c - 位提取后的偏移量
* @returns {number} 提取后的位
*/
function bitnum_intl(a, b, c) {
return (((a << b) & 0x80000000) >>> c) | 0
}
/**
* 对输入整数进行位运算,重新组合位
* @param {number} a - 整数
* @returns {number} 重新组合后的位
*/
function sbox_bit(a) {
return (a & 32) | ((a & 31) >> 1) | ((a & 1) << 4) | 0
}
/**
* 初始置换
* @param {Buffer} input_data - 输入 Buffer
* @returns {[number, number]} 初始置换后的两个32位整数
*/
function initial_permutation(input_data) {
const s0 =
bitnum(input_data, 57, 31) |
bitnum(input_data, 49, 30) |
bitnum(input_data, 41, 29) |
bitnum(input_data, 33, 28) |
bitnum(input_data, 25, 27) |
bitnum(input_data, 17, 26) |
bitnum(input_data, 9, 25) |
bitnum(input_data, 1, 24) |
bitnum(input_data, 59, 23) |
bitnum(input_data, 51, 22) |
bitnum(input_data, 43, 21) |
bitnum(input_data, 35, 20) |
bitnum(input_data, 27, 19) |
bitnum(input_data, 19, 18) |
bitnum(input_data, 11, 17) |
bitnum(input_data, 3, 16) |
bitnum(input_data, 61, 15) |
bitnum(input_data, 53, 14) |
bitnum(input_data, 45, 13) |
bitnum(input_data, 37, 12) |
bitnum(input_data, 29, 11) |
bitnum(input_data, 21, 10) |
bitnum(input_data, 13, 9) |
bitnum(input_data, 5, 8) |
bitnum(input_data, 63, 7) |
bitnum(input_data, 55, 6) |
bitnum(input_data, 47, 5) |
bitnum(input_data, 39, 4) |
bitnum(input_data, 31, 3) |
bitnum(input_data, 23, 2) |
bitnum(input_data, 15, 1) |
bitnum(input_data, 7, 0) |
0
const s1 =
bitnum(input_data, 56, 31) |
bitnum(input_data, 48, 30) |
bitnum(input_data, 40, 29) |
bitnum(input_data, 32, 28) |
bitnum(input_data, 24, 27) |
bitnum(input_data, 16, 26) |
bitnum(input_data, 8, 25) |
bitnum(input_data, 0, 24) |
bitnum(input_data, 58, 23) |
bitnum(input_data, 50, 22) |
bitnum(input_data, 42, 21) |
bitnum(input_data, 34, 20) |
bitnum(input_data, 26, 19) |
bitnum(input_data, 18, 18) |
bitnum(input_data, 10, 17) |
bitnum(input_data, 2, 16) |
bitnum(input_data, 60, 15) |
bitnum(input_data, 52, 14) |
bitnum(input_data, 44, 13) |
bitnum(input_data, 36, 12) |
bitnum(input_data, 28, 11) |
bitnum(input_data, 20, 10) |
bitnum(input_data, 12, 9) |
bitnum(input_data, 4, 8) |
bitnum(input_data, 62, 7) |
bitnum(input_data, 54, 6) |
bitnum(input_data, 46, 5) |
bitnum(input_data, 38, 4) |
bitnum(input_data, 30, 3) |
bitnum(input_data, 22, 2) |
bitnum(input_data, 14, 1) |
bitnum(input_data, 6, 0) |
0
return [s0, s1]
}
/**
* 逆初始置换
* @param {number} s0 - 32位整数
* @param {number} s1 - 32位整数
* @returns {Buffer} 逆初始置换后的 Buffer
*/
function inverse_permutation(s0, s1) {
const data = Buffer.alloc(8)
data[3] =
bitnum_intr(s1, 7, 7) |
bitnum_intr(s0, 7, 6) |
bitnum_intr(s1, 15, 5) |
bitnum_intr(s0, 15, 4) |
bitnum_intr(s1, 23, 3) |
bitnum_intr(s0, 23, 2) |
bitnum_intr(s1, 31, 1) |
bitnum_intr(s0, 31, 0) |
0
data[2] =
bitnum_intr(s1, 6, 7) |
bitnum_intr(s0, 6, 6) |
bitnum_intr(s1, 14, 5) |
bitnum_intr(s0, 14, 4) |
bitnum_intr(s1, 22, 3) |
bitnum_intr(s0, 22, 2) |
bitnum_intr(s1, 30, 1) |
bitnum_intr(s0, 30, 0) |
0
data[1] =
bitnum_intr(s1, 5, 7) |
bitnum_intr(s0, 5, 6) |
bitnum_intr(s1, 13, 5) |
bitnum_intr(s0, 13, 4) |
bitnum_intr(s1, 21, 3) |
bitnum_intr(s0, 21, 2) |
bitnum_intr(s1, 29, 1) |
bitnum_intr(s0, 29, 0) |
0
data[0] =
bitnum_intr(s1, 4, 7) |
bitnum_intr(s0, 4, 6) |
bitnum_intr(s1, 12, 5) |
bitnum_intr(s0, 12, 4) |
bitnum_intr(s1, 20, 3) |
bitnum_intr(s0, 20, 2) |
bitnum_intr(s1, 28, 1) |
bitnum_intr(s0, 28, 0) |
0
data[7] =
bitnum_intr(s1, 3, 7) |
bitnum_intr(s0, 3, 6) |
bitnum_intr(s1, 11, 5) |
bitnum_intr(s0, 11, 4) |
bitnum_intr(s1, 19, 3) |
bitnum_intr(s0, 19, 2) |
bitnum_intr(s1, 27, 1) |
bitnum_intr(s0, 27, 0) |
0
data[6] =
bitnum_intr(s1, 2, 7) |
bitnum_intr(s0, 2, 6) |
bitnum_intr(s1, 10, 5) |
bitnum_intr(s0, 10, 4) |
bitnum_intr(s1, 18, 3) |
bitnum_intr(s0, 18, 2) |
bitnum_intr(s1, 26, 1) |
bitnum_intr(s0, 26, 0) |
0
data[5] =
bitnum_intr(s1, 1, 7) |
bitnum_intr(s0, 1, 6) |
bitnum_intr(s1, 9, 5) |
bitnum_intr(s0, 9, 4) |
bitnum_intr(s1, 17, 3) |
bitnum_intr(s0, 17, 2) |
bitnum_intr(s1, 25, 1) |
bitnum_intr(s0, 25, 0) |
0
data[4] =
bitnum_intr(s1, 0, 7) |
bitnum_intr(s0, 0, 6) |
bitnum_intr(s1, 8, 5) |
bitnum_intr(s0, 8, 4) |
bitnum_intr(s1, 16, 3) |
bitnum_intr(s0, 16, 2) |
bitnum_intr(s1, 24, 1) |
bitnum_intr(s0, 24, 0) |
0
return data
}
/**
* Triple-DES F函数
* @param {number} state - 输入
* @param {number[]} key - 密钥
* @returns {number} 输出
*/
function f(state, key) {
state = state | 0
const t1 =
bitnum_intl(state, 31, 0) |
(((state & 0xf0000000) >>> 1) | 0) |
bitnum_intl(state, 4, 5) |
bitnum_intl(state, 3, 6) |
(((state & 0x0f000000) >>> 3) | 0) |
bitnum_intl(state, 8, 11) |
bitnum_intl(state, 7, 12) |
(((state & 0x00f00000) >>> 5) | 0) |
bitnum_intl(state, 12, 17) |
bitnum_intl(state, 11, 18) |
(((state & 0x000f0000) >>> 7) | 0) |
bitnum_intl(state, 16, 23) |
0
const t2 =
bitnum_intl(state, 15, 0) |
(((state & 0x0000f000) << 15) | 0) |
bitnum_intl(state, 20, 5) |
bitnum_intl(state, 19, 6) |
(((state & 0x00000f00) << 13) | 0) |
bitnum_intl(state, 24, 11) |
bitnum_intl(state, 23, 12) |
(((state & 0x000000f0) << 11) | 0) |
bitnum_intl(state, 28, 17) |
bitnum_intl(state, 27, 18) |
(((state & 0x0000000f) << 9) | 0) |
bitnum_intl(state, 0, 23) |
0
const _lrgstate = [
(t1 >>> 24) & 0xff,
(t1 >>> 16) & 0xff,
(t1 >>> 8) & 0xff,
(t2 >>> 24) & 0xff,
(t2 >>> 16) & 0xff,
(t2 >>> 8) & 0xff
]
const lrgstate = _lrgstate.map((val, i) => val ^ key[i])
const newState =
(sbox[0][sbox_bit(lrgstate[0] >>> 2)] << 28) |
(sbox[1][sbox_bit(((lrgstate[0] & 0x03) << 4) | (lrgstate[1] >>> 4))] << 24) |
(sbox[2][sbox_bit(((lrgstate[1] & 0x0f) << 2) | (lrgstate[2] >>> 6))] << 20) |
(sbox[3][sbox_bit(lrgstate[2] & 0x3f)] << 16) |
(sbox[4][sbox_bit(lrgstate[3] >>> 2)] << 12) |
(sbox[5][sbox_bit(((lrgstate[3] & 0x03) << 4) | (lrgstate[4] >>> 4))] << 8) |
(sbox[6][sbox_bit(((lrgstate[4] & 0x0f) << 2) | (lrgstate[5] >>> 6))] << 4) |
sbox[7][sbox_bit(lrgstate[5] & 0x3f)] |
0
return (
bitnum_intl(newState, 15, 0) |
bitnum_intl(newState, 6, 1) |
bitnum_intl(newState, 19, 2) |
bitnum_intl(newState, 20, 3) |
bitnum_intl(newState, 28, 4) |
bitnum_intl(newState, 11, 5) |
bitnum_intl(newState, 27, 6) |
bitnum_intl(newState, 16, 7) |
bitnum_intl(newState, 0, 8) |
bitnum_intl(newState, 14, 9) |
bitnum_intl(newState, 22, 10) |
bitnum_intl(newState, 25, 11) |
bitnum_intl(newState, 4, 12) |
bitnum_intl(newState, 17, 13) |
bitnum_intl(newState, 30, 14) |
bitnum_intl(newState, 9, 15) |
bitnum_intl(newState, 1, 16) |
bitnum_intl(newState, 7, 17) |
bitnum_intl(newState, 23, 18) |
bitnum_intl(newState, 13, 19) |
bitnum_intl(newState, 31, 20) |
bitnum_intl(newState, 26, 21) |
bitnum_intl(newState, 2, 22) |
bitnum_intl(newState, 8, 23) |
bitnum_intl(newState, 18, 24) |
bitnum_intl(newState, 12, 25) |
bitnum_intl(newState, 29, 26) |
bitnum_intl(newState, 5, 27) |
bitnum_intl(newState, 21, 28) |
bitnum_intl(newState, 10, 29) |
bitnum_intl(newState, 3, 30) |
bitnum_intl(newState, 24, 31) |
0
)
}
/**
* TripleDES 加密/解密算法 (单块)
* @param {Buffer} input_data - 输入 Buffer
* @param {number[][]} key - 密钥
* @returns {Buffer} 加/解密后的 Buffer
*/
function crypt(input_data, key) {
let [s0, s1] = initial_permutation(input_data)
for (let idx = 0; idx < 15; idx++) {
const previous_s1 = s1
s1 = (f(s1, key[idx]) ^ s0) | 0
s0 = previous_s1
}
s0 = (f(s1, key[15]) ^ s0) | 0
return inverse_permutation(s0, s1)
}
/**
* TripleDES 密钥扩展算法
* @param {Buffer} key - 密钥
* @param {number} mode - 模式 (ENCRYPT/DECRYPT)
* @returns {number[][]} 密钥扩展
*/
function key_schedule(key, mode) {
const schedule = Array.from({ length: 16 }, () => Array(6).fill(0))
const key_rnd_shift = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]
const key_perm_c = [
56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59,
51, 43, 35
]
const key_perm_d = [
62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4,
27, 19, 11, 3
]
const key_compression = [
13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, 40, 51,
30, 36, 46, 54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, 28, 31
]
let c = 0,
d = 0
for (let i = 0; i < 28; i++) {
c |= bitnum(key, key_perm_c[i], 31 - i)
d |= bitnum(key, key_perm_d[i], 31 - i)
}
c = c | 0
d = d | 0
for (let i = 0; i < 16; i++) {
const shift = key_rnd_shift[i]
c = (((c << shift) | (c >>> (28 - shift))) & 0xfffffff0) | 0
d = (((d << shift) | (d >>> (28 - shift))) & 0xfffffff0) | 0
const togen = mode === DECRYPT ? 15 - i : i
schedule[togen] = Array(6).fill(0)
for (let j = 0; j < 24; j++) {
schedule[togen][Math.floor(j / 8)] |= bitnum_intr(c, key_compression[j], 7 - (j % 8))
}
for (let j = 24; j < 48; j++) {
schedule[togen][Math.floor(j / 8)] |= bitnum_intr(d, key_compression[j] - 27, 7 - (j % 8))
}
}
return schedule
}
/**
* TripleDES 密钥设置
* @param {Buffer} key - 密钥
* @param {number} mode - 模式
* @returns {number[][][]} 密钥设置
*/
function tripledes_key_setup(key, mode) {
if (mode === ENCRYPT) {
return [
key_schedule(key.slice(0, 8), ENCRYPT),
key_schedule(key.slice(8, 16), DECRYPT),
key_schedule(key.slice(16, 24), ENCRYPT)
]
}
return [
key_schedule(key.slice(16, 24), DECRYPT),
key_schedule(key.slice(8, 16), ENCRYPT),
key_schedule(key.slice(0, 8), DECRYPT)
]
}
/**
* TripleDES 加密/解密算法 (完整)
* @param {Buffer} data - 输入 Buffer
* @param {number[][][]} key - 密钥
* @returns {Buffer} 加/解密后的 Buffer
*/
function tripledes_crypt(data, key) {
let result = data
for (let i = 0; i < 3; i++) {
result = crypt(result, key[i])
}
return result
}
/**
* QRC解密主函数
* @param {string | Buffer} encrypted_qrc - 加密的QRC内容 (十六进制字符串或Buffer)
* @returns {string} 解密后的UTF-8字符串
*/
function qrc_decrypt(encrypted_qrc) {
if (!encrypted_qrc) {
return ''
}
let input_buffer
if (typeof encrypted_qrc === 'string') {
input_buffer = Buffer.from(encrypted_qrc, 'hex')
} else if (Buffer.isBuffer(encrypted_qrc)) {
input_buffer = encrypted_qrc
} else {
throw new Error('无效的加密数据类型')
}
try {
const decrypted_chunks = []
const key = Buffer.from('!@#)(*$%123ZXC!@!@#)(NHL')
const schedule = tripledes_key_setup(key, DECRYPT)
for (let i = 0; i < input_buffer.length; i += 8) {
const chunk = input_buffer.slice(i, i + 8)
if (chunk.length < 8) {
// 如果最后一块不足8字节DES无法处理但QRC格式应该是8的倍数
// 这里可以根据实际情况决定如何处理,例如抛出错误或填充
// 根据原始代码行为这里假设输入总是8字节的倍数
console.warn('警告: 数据末尾存在不足8字节的块可能导致解密不完整。')
continue
}
decrypted_chunks.push(tripledes_crypt(chunk, schedule))
}
const data = Buffer.concat(decrypted_chunks)
const decompressed = zlib.unzipSync(data)
return decompressed.toString('utf-8')
} catch (e) {
throw new Error(`解密失败: ${e.message}`)
}
}
// 导出主函数
return qrc_decrypt
}

View File

@@ -68,6 +68,7 @@ interface CustomAPI {
// 插件管理API
plugins: {
selectAndAddPlugin: (type: 'lx' | 'cr') => Promise<any>
downloadAndAddPlugin: (url: string, type: 'lx' | 'cr') => Promise<any>
uninstallPlugin(pluginId: string): ApiResult | PromiseLike<ApiResult>
addPlugin: (pluginCode: string, pluginName: string) => Promise<any>
getPluginById: (id: string) => Promise<any>
@@ -79,7 +80,7 @@ interface CustomAPI {
start: () => undefined
stop: () => undefined
}
// 目录设置API
directorySettings: {
getDirectories: () => Promise<{
@@ -96,10 +97,7 @@ interface CustomAPI {
path?: string
message?: string
}>
saveDirectories: (directories: {
cacheDir: string
downloadDir: string
}) => Promise<{
saveDirectories: (directories: { cacheDir: string; downloadDir: string }) => Promise<{
success: boolean
message: string
}>
@@ -119,15 +117,14 @@ interface CustomAPI {
size: number
formatted: string
}>
}
// 用户配置API
getUserConfig: () => Promise<any>
pluginNotice: {
onPluginNotice: (listener: (...args: any[]) => void) => ()=>void
}
onPluginNotice: (listener: (...args: any[]) => void) => () => void
}
}
declare global {

View File

@@ -37,6 +37,8 @@ const api = {
plugins: {
selectAndAddPlugin: (type: 'lx' | 'cr') =>
ipcRenderer.invoke('service-plugin-selectAndAddPlugin', type),
downloadAndAddPlugin: (url: string, type: 'lx' | 'cr') =>
ipcRenderer.invoke('service-plugin-downloadAndAddPlugin', url, type),
addPlugin: (pluginCode: string, pluginName: string) =>
ipcRenderer.invoke('service-plugin-addPlugin', pluginCode, pluginName),
getPluginById: (id: string) => ipcRenderer.invoke('service-plugin-getPluginById', id),

View File

@@ -6,5 +6,5 @@
// biome-ignore lint: disable
export {}
declare global {
const DialogPlugin: (typeof import('tdesign-vue-next'))['DialogPlugin']
}

View File

@@ -10,6 +10,8 @@ declare module 'vue' {
export interface GlobalComponents {
AIFloatBallSettings: typeof import('./src/components/Settings/AIFloatBallSettings.vue')['default']
AudioVisualizer: typeof import('./src/components/Play/AudioVisualizer.vue')['default']
ContextMenu: typeof import('./src/components/ContextMenu/ContextMenu.vue')['default']
Demo: typeof import('./src/components/ContextMenu/demo.vue')['default']
DirectorySettings: typeof import('./src/components/Settings/DirectorySettings.vue')['default']
FloatBall: typeof import('./src/components/AI/FloatBall.vue')['default']
FullPlay: typeof import('./src/components/Play/FullPlay.vue')['default']
@@ -21,12 +23,43 @@ declare module 'vue' {
PlaylistSettings: typeof import('./src/components/Settings/PlaylistSettings.vue')['default']
PlayMusic: typeof import('./src/components/Play/PlayMusic.vue')['default']
PluginNoticeDialog: typeof import('./src/components/PluginNoticeDialog.vue')['default']
Plugins: typeof import('./src/components/Settings/plugins.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default']
TAlert: typeof import('tdesign-vue-next')['Alert']
TAside: typeof import('tdesign-vue-next')['Aside']
TBadge: typeof import('tdesign-vue-next')['Badge']
TButton: typeof import('tdesign-vue-next')['Button']
TCard: typeof import('tdesign-vue-next')['Card']
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
TContent: typeof import('tdesign-vue-next')['Content']
TDialog: typeof import('tdesign-vue-next')['Dialog']
TDivider: typeof import('tdesign-vue-next')['Divider']
TDrawer: typeof import('tdesign-vue-next')['Drawer']
TDropdown: typeof import('tdesign-vue-next')['Dropdown']
TEmpty: typeof import('tdesign-vue-next')['Empty']
TForm: typeof import('tdesign-vue-next')['Form']
TFormItem: typeof import('tdesign-vue-next')['FormItem']
ThemeSelector: typeof import('./src/components/ThemeSelector.vue')['default']
TIcon: typeof import('tdesign-vue-next')['Icon']
TImage: typeof import('tdesign-vue-next')['Image']
TInput: typeof import('tdesign-vue-next')['Input']
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
TLayout: typeof import('tdesign-vue-next')['Layout']
TLoading: typeof import('tdesign-vue-next')['Loading']
TOption: typeof import('tdesign-vue-next')['Option']
TRadioButton: typeof import('tdesign-vue-next')['RadioButton']
TRadioGroup: typeof import('tdesign-vue-next')['RadioGroup']
TSelect: typeof import('tdesign-vue-next')['Select']
TSlider: typeof import('tdesign-vue-next')['Slider']
TSwitch: typeof import('tdesign-vue-next')['Switch']
TTabPanel: typeof import('tdesign-vue-next')['TabPanel']
TTabs: typeof import('tdesign-vue-next')['Tabs']
TTag: typeof import('tdesign-vue-next')['Tag']
TTextarea: typeof import('tdesign-vue-next')['Textarea']
TTooltip: typeof import('tdesign-vue-next')['Tooltip']
UpdateExample: typeof import('./src/components/UpdateExample.vue')['default']
UpdateProgress: typeof import('./src/components/UpdateProgress.vue')['default']
UpdateSettings: typeof import('./src/components/Settings/UpdateSettings.vue')['default']

View File

@@ -25,6 +25,7 @@ import './assets/theme/cyan.css'
onMounted(() => {
userInfo.init()
setupSystemThemeListener()
loadSavedTheme()
// 应用启动后延迟3秒检查更新避免影响启动速度
@@ -44,24 +45,70 @@ const themes = [
const loadSavedTheme = () => {
const savedTheme = localStorage.getItem('selected-theme')
const savedDarkMode = localStorage.getItem('dark-mode')
let themeName = 'default'
let isDarkMode = false
if (savedTheme && themes.some((t) => t.name === savedTheme)) {
applyTheme(savedTheme)
themeName = savedTheme
}
if (savedDarkMode !== null) {
isDarkMode = savedDarkMode === 'true'
} else {
// 如果没有保存的设置,检测系统偏好
isDarkMode = detectSystemTheme()
}
applyTheme(themeName, isDarkMode)
}
const applyTheme = (themeName) => {
const applyTheme = (themeName, darkMode = false) => {
const documentElement = document.documentElement
// 移除之前的主题
// 移除之前的主题属性
documentElement.removeAttribute('theme-mode')
documentElement.removeAttribute('data-theme')
// 应用主题(如果不是默认主题)
// 应用主题色彩
if (themeName !== 'default') {
documentElement.setAttribute('theme-mode', themeName)
}
// 应用明暗模式
if (darkMode) {
documentElement.setAttribute('data-theme', 'dark')
} else {
documentElement.setAttribute('data-theme', 'light')
}
// 保存到本地存储
localStorage.setItem('selected-theme', themeName)
localStorage.setItem('dark-mode', darkMode.toString())
}
// 检测系统主题偏好
const detectSystemTheme = () => {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return true
}
return false
}
// 监听系统主题变化
const setupSystemThemeListener = () => {
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', (e) => {
const savedDarkMode = localStorage.getItem('dark-mode')
// 如果用户没有手动设置暗色模式,则跟随系统主题
if (savedDarkMode === null) {
const savedTheme = localStorage.getItem('selected-theme') || 'default'
applyTheme(savedTheme, e.matches)
}
})
}
}
</script>

View File

@@ -60,23 +60,23 @@ body {
}
&::-webkit-scrollbar-track {
background: #f1f5f9;
background: var(--td-scroll-track-color);
border-radius: 0.1875rem;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
background: var(--td-scrollbar-color);
border-radius: 0.1875rem;
transition: background-color 0.2s ease;
&:hover {
background: #94a3b8;
background: var(--td-scrollbar-hover-color);
}
}
/* Firefox 滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
scrollbar-color: var(--td-scrollbar-color) var(--td-scroll-track-color);
}
.t-dialog__mask{
.t-dialog__mask {
backdrop-filter: blur(5px);
}

View File

@@ -1,15 +1,19 @@
:root,
:root[theme-mode='light'] {
--td-brand-color-1: #e2fae2;
--td-brand-color-2: #c5f4cb;
--td-brand-color-3: #91dca1;
--td-brand-color-4: #55c277;
--td-brand-color-5: #2ba55b;
--td-brand-color-6: #008942;
--td-brand-color-7: #006d33;
--td-brand-color-8: #005426;
--td-brand-color-9: #003c19;
--td-brand-color-10: #00260d;
:root[data-theme='light'],
:root:not([data-theme]) {
--hover-nav-color: #f3f4f6;
--hover-nav-text: #6b7280;
--hover-nav-text-hover: #111827;
--td-brand-color-1: #ddfbdd;
--td-brand-color-2: #bdf6c3;
--td-brand-color-3: #80df94;
--td-brand-color-4: #03de6d;
--td-brand-color-5: #00a74d;
--td-brand-color-6: #00893e;
--td-brand-color-7: #006d2f;
--td-brand-color-8: #005423;
--td-brand-color-9: #003c16;
--td-brand-color-10: #00260b;
--td-brand-color-light: var(--td-brand-color-1);
--td-brand-color-focus: var(--td-brand-color-2);
--td-brand-color-disabled: var(--td-brand-color-3);
@@ -64,16 +68,16 @@
--td-success-color-active: var(--td-success-color-6);
--td-success-color-disabled: var(--td-success-color-3);
--td-success-color-light: var(--td-success-color-1);
--td-gray-color-1: #f0f4f1;
--td-gray-color-2: #e9efeb;
--td-gray-color-3: #e1eae4;
--td-gray-color-4: #d4dfd8;
--td-gray-color-5: #bdc8c1;
--td-gray-color-6: #9ca8a1;
--td-gray-color-7: #808d86;
--td-gray-color-8: #6d7873;
--td-gray-color-9: #565f5b;
--td-gray-color-10: #454c48;
--td-gray-color-1: #f3f3f3;
--td-gray-color-2: #eee;
--td-gray-color-3: #e7e7e7;
--td-gray-color-4: #dcdcdc;
--td-gray-color-5: #c5c5c5;
--td-gray-color-6: #a6a6a6;
--td-gray-color-7: #8b8b8b;
--td-gray-color-8: #777;
--td-gray-color-9: #5e5e5e;
--td-gray-color-10: #4b4b4b;
--td-gray-color-11: #383838;
--td-gray-color-12: #2c2c2c;
--td-gray-color-13: #242424;
@@ -133,21 +137,273 @@
--td-shadow-inset-right: inset 0.5px 0 0 #dcdcdc;
--td-shadow-inset-bottom: inset 0 -0.5px 0 #dcdcdc;
--td-shadow-inset-left: inset -0.5px 0 0 #dcdcdc;
--td-mask-active: rgba(255, 255, 255, 0.6);
--td-mask-active: rgba(0, 0, 0, 0.6);
--td-mask-disabled: rgba(255, 255, 255, 0.6);
/* 通用颜色变量 - 亮色主题 */
--theme-bg-primary: #ffffff;
--theme-bg-secondary: #f8f9fa;
--theme-bg-tertiary: #fafafa;
--theme-text-primary: #111827;
--theme-text-secondary: #6b7280;
--theme-text-tertiary: #9ca3af;
--theme-text-muted: #9ca3af;
--theme-text-disabled: #666666;
--theme-border-light: #f3f4f6;
--theme-border-medium: #e5e7eb;
--theme-border-strong: #f3f3f3;
--theme-border: #e5e7eb;
--theme-hover-bg: #f9fafb;
--theme-overlay: rgba(0, 0, 0, 0.7);
--theme-shadow-light: 0 2px 8px rgba(0, 0, 0, 0.06);
--theme-shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.1);
--theme-shadow-hover: 0 8px 25px rgba(0, 0, 0, 0.12), 0 4px 10px rgba(0, 0, 0, 0.08);
--theme-card-bg: #ffffff;
--theme-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--theme-card-shadow-hover: 0 8px 25px rgba(0, 0, 0, 0.15);
--theme-header-bg: #f9fafb;
--theme-badge-bg: #f3f4f6;
--theme-tips-bg: linear-gradient(135deg, #f8fafc, #f1f5f9);
--theme-warning-bg: #fef3c7;
--theme-warning-text: #d97706;
--theme-code-bg: rgba(255, 255, 255, 0.6);
--theme-code-hover-bg: rgba(255, 255, 255, 0.9);
--theme-note-bg: rgba(255, 255, 255, 0.5);
/* Find 页面专用变量 - 亮色主题 */
--find-bg-primary: var(--theme-bg-primary);
--find-bg-secondary: var(--theme-bg-secondary);
--find-text-primary: var(--theme-text-primary);
--find-text-secondary: var(--theme-text-secondary);
--find-text-muted: var(--theme-text-muted);
--find-card-bg: var(--theme-bg-primary);
--find-song-count-bg: rgba(156, 163, 175, 0.1);
--find-card-info-bg: rgba(255, 255, 255, 0.95);
--find-card-shadow: var(--theme-shadow-light), 0 1px 4px rgba(0, 0, 0, 0.04);
--find-card-shadow-hover: var(--theme-shadow-hover);
--find-song-bg: var(--theme-bg-primary);
--find-song-hover-bg: var(--theme-hover-bg);
--find-border-color: var(--theme-border-light);
--find-meta-border: rgba(229, 231, 235, 0.5);
/* HomeLayout 页面专用变量 - 亮色主题 */
--home-nav-btn-color: #3d4043;
--home-nav-btn-hover: var(--theme-text-primary);
--home-source-selector-hover: var(--theme-border-light);
--home-source-list-bg: var(--theme-bg-primary);
--home-source-list-border: var(--theme-border-medium);
--home-source-list-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--home-source-item-hover: var(--theme-border-light);
--home-scrollbar-track: #f1f5f9;
--home-scrollbar-thumb: #cbd5e1;
--home-scrollbar-thumb-hover: #94a3b8;
--home-scrollbar-color: #cbd5e1 #f1f5f9;
/* List 页面专用变量 - 亮色主题 */
--list-bg-primary: var(--theme-bg-tertiary);
--list-content-bg: var(--theme-bg-primary);
--list-header-bg: var(--theme-bg-primary);
--list-header-shadow: var(--theme-shadow-medium);
--list-content-shadow: var(--theme-shadow-light);
--list-title-color: var(--theme-text-primary);
--list-author-color: var(--theme-text-secondary);
--list-stats-color: var(--theme-text-muted);
--list-loading-text: var(--theme-text-disabled);
--list-loading-border: var(--theme-border-strong);
--list-loading-spinner: var(--td-brand-color);
--list-cover-overlay: var(--theme-overlay);
/* SongVirtualList 组件专用变量 - 亮色主题 */
--song-list-header-bg: #fafafa;
--song-list-header-border: #e9e9e9;
--song-list-header-text: #999999;
--song-list-content-bg: var(--theme-bg-primary);
--song-list-item-border: #f5f5f5;
--song-list-item-hover: #f5f5f5;
--song-list-item-current: #f0f7ff;
--song-list-item-playing: #e6f7ff;
--song-list-track-number: #999999;
--song-list-title-color: #333333;
--song-list-title-hover: var(--td-brand-color);
--song-list-artist-color: #999999;
--song-list-album-color: #999999;
--song-list-album-hover: var(--td-brand-color);
--song-list-duration-color: #999999;
--song-list-btn-color: #cccccc;
--song-list-btn-hover: var(--td-brand-color);
--song-list-btn-bg-hover: var(--td-brand-color-light);
--song-list-quality-bg: #fff7e6;
--song-list-quality-color: #fa8c16;
/* Search 页面专用变量 - 亮色主题 */
--search-bg: var(--theme-bg-tertiary);
--search-title-color: #333333;
--search-keyword-color: var(--td-brand-color);
--search-info-color: #999999;
--search-content-bg: var(--theme-bg-primary);
--search-content-shadow: var(--theme-shadow-light);
--search-empty-title: #333333;
--search-empty-text: #999999;
--search-loading-text: #666666;
--search-loading-border: #f3f3f3;
--search-loading-spinner: var(--td-brand-color);
/* Recent 页面专用变量 - 亮色主题 */
--recent-bg: var(--theme-bg-tertiary);
--recent-title-color: #111827;
--recent-subtitle-color: #6b7280;
--recent-section-title: #111827;
--recent-card-bg: var(--theme-bg-primary);
--recent-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--recent-card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.15);
--recent-playlist-title: #111827;
--recent-playlist-desc: #6b7280;
--recent-playlist-meta: #9ca3af;
--recent-song-item-border: #f3f4f6;
--recent-song-item-hover: #f9fafb;
--recent-song-index: #6b7280;
--recent-song-title: #111827;
--recent-song-artist: #6b7280;
--recent-song-stats: #6b7280;
--recent-song-duration: #6b7280;
--recent-empty-icon: #d1d5db;
--recent-empty-title: #111827;
--recent-empty-text: #6b7280;
/* Local 页面专用变量 - 亮色主题 */
--local-bg: var(--theme-bg-tertiary);
--local-text-primary: var(--theme-text-primary);
--local-text-secondary: var(--theme-text-secondary);
--local-text-tertiary: var(--theme-text-tertiary);
--local-card-bg: var(--theme-card-bg);
--local-card-shadow: var(--theme-card-shadow);
--local-card-shadow-hover: var(--theme-card-shadow-hover);
--local-border: var(--theme-border);
--local-hover-bg: var(--theme-hover-bg);
--local-header-bg: var(--theme-header-bg);
--local-badge-bg: var(--theme-badge-bg);
--local-tips-bg: var(--theme-tips-bg);
--local-warning-bg: var(--theme-warning-bg);
--local-warning-text: var(--theme-warning-text);
--local-code-bg: var(--theme-code-bg);
--local-code-hover-bg: var(--theme-code-hover-bg);
--local-note-bg: var(--theme-note-bg);
/* Welcome 页面专用变量 - 亮色主题 */
--welcome-bg: #ffffff;
--welcome-subtitle-color: #666666;
--welcome-loading-text: #888888;
--welcome-progress-bg: #f0f0f0;
--welcome-tag-bg: #b8f1ce;
--welcome-tag-border: #e9ecef;
--welcome-tag-color: #333333;
--welcome-version-color: #9e9e9e;
/* TitleBarControls 组件专用变量 - 亮色主题 */
--titlebar-icon-color: #111827;
--titlebar-icon-hover: #111827;
--titlebar-btn-hover-bg: #f3f4f6;
--titlebar-close-hover-bg: #fee2e2;
--titlebar-close-hover-color: #dc2626;
/* Settings 页面专用变量 - 亮色主题 */
--settings-main-bg: #f8fafc;
--settings-header-bg: #ffffff;
--settings-sidebar-bg: #ffffff;
--settings-sidebar-border: #e2e8f0;
--settings-nav-hover-bg: #f1f5f9;
--settings-nav-active-bg: var(--td-brand-color-1);
--settings-nav-active-border: var(--td-brand-color-5);
--settings-nav-icon-color: #64748b;
--settings-nav-icon-active: var(--td-brand-color-5);
--settings-nav-label-color: #334155;
--settings-nav-label-active: var(--td-brand-color-6);
--settings-nav-desc-color: #64748b;
--settings-content-bg: #f8fafc;
--settings-group-bg: #ffffff;
--settings-group-border: #e2e8f0;
--settings-group-shadow: rgba(0, 0, 0, 0.1);
--settings-text-primary: #1e293b;
--settings-text-secondary: #64748b;
--settings-preview-bg: #f8fafc;
--settings-preview-border: #e2e8f0;
--settings-mock-titlebar-bg: #f6f6f6;
--settings-mock-titlebar-border: #d1d5db;
--settings-feature-bg: #f8fafc;
--settings-feature-border: #e2e8f0;
--settings-api-tips-bg: #f8fafc;
--settings-api-tips-border: #e2e8f0;
--settings-source-card-bg: #ffffff;
--settings-source-card-border: #e2e8f0;
--settings-source-card-hover-border: var(--td-brand-color-3);
--settings-source-card-active-border: var(--td-brand-color-5);
--settings-source-card-active-bg: var(--td-brand-color-1);
--settings-source-icon-bg: #f1f5f9;
--settings-quality-container-bg: #f8fafc;
--settings-quality-container-border: #e2e8f0;
--settings-status-item-bg: #f8fafc;
--settings-status-item-border: #e2e8f0;
--settings-plugin-prompt-bg: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
--settings-plugin-prompt-border: #cbd5e1;
--settings-tech-item-bg: #f8fafc;
--settings-tech-item-border: #e2e8f0;
--settings-developer-item-bg: #f8fafc;
--settings-developer-item-border: #e2e8f0;
--settings-tag-option-bg: #f8fafc;
--settings-tag-option-border: #e2e8f0;
--settings-tag-status-bg: #f8fafc;
--settings-tag-status-border: #e2e8f0;
/* Plugins 组件专用变量 - 亮色主题 */
--plugins-bg: var(--theme-bg-tertiary);
--plugins-container-bg: var(--theme-bg-primary);
--plugins-header-bg: var(--theme-bg-primary);
--plugins-text-primary: var(--theme-text-primary);
--plugins-text-secondary: var(--theme-text-secondary);
--plugins-text-muted: var(--theme-text-muted);
--plugins-border: var(--theme-border);
--plugins-card-bg: var(--theme-card-bg);
--plugins-card-shadow: var(--theme-card-shadow);
--plugins-card-shadow-hover: var(--theme-card-shadow-hover);
--plugins-card-selected-bg: #e8f5e8;
--plugins-card-selected-border: #28a745;
--plugins-loading-spinner: var(--td-brand-color);
--plugins-error-color: #dc3545;
--plugins-success-color: #28a745;
--plugins-console-bg: #1e1e1e;
--plugins-console-header-bg: #2d2d2d;
--plugins-console-border: #404040;
--plugins-console-text: #ffffff;
--plugins-console-prompt: var(--td-brand-color);
--plugins-console-path: #8a8a8a;
--plugins-console-time: #666666;
--plugins-console-scrollbar-track: #2d2d2d;
--plugins-console-scrollbar-thumb: #555555;
--plugins-console-scrollbar-thumb-hover: #666666;
--plugins-log-error: #ff6b6b;
--plugins-log-warn: #ffd93d;
--plugins-log-info: #74b9ff;
--plugins-log-debug: #a29bfe;
--plugins-mac-close: #ff5f57;
--plugins-mac-minimize: #ffbd2e;
--plugins-mac-maximize: #28ca42;
}
:root[theme-mode='dark'] {
--td-brand-color-1: #2ba55b20;
--td-brand-color-2: #003c19;
--td-brand-color-3: #005426;
--td-brand-color-4: #006d33;
--td-brand-color-5: #008942;
--td-brand-color-6: #2ba55b;
--td-brand-color-7: #4cd47c;
--td-brand-color-8: #91dca1;
--td-brand-color-9: #c5f4cb;
--td-brand-color-10: #e2fae2;
:root[data-theme='dark'] {
--hover-nav-color: #ffffff18;
--hover-nav-text: #a5a5a5;
--hover-nav-text-hover: #f3f4f6;
--td-brand-color-1: #00a74d20;
--td-brand-color-2: #003c16;
--td-brand-color-3: #005423;
--td-brand-color-4: #006d2f;
--td-brand-color-5: #00893e;
--td-brand-color-6: #00a74d;
--td-brand-color-7: #03de6d;
--td-brand-color-8: #80df94;
--td-brand-color-9: #bdf6c3;
--td-brand-color-10: #ddfbdd;
--td-brand-color-light: var(--td-brand-color-1);
--td-brand-color-focus: var(--td-brand-color-2);
--td-brand-color-disabled: var(--td-brand-color-3);
@@ -184,16 +440,16 @@
--td-success-color-8: #80d2b6;
--td-success-color-9: #b4e1d3;
--td-success-color-10: #deede8;
--td-gray-color-1: #f0f4f1;
--td-gray-color-2: #e9efeb;
--td-gray-color-3: #e1eae4;
--td-gray-color-4: #d4dfd8;
--td-gray-color-5: #bdc8c1;
--td-gray-color-6: #9ca8a1;
--td-gray-color-7: #808d86;
--td-gray-color-8: #6d7873;
--td-gray-color-9: #565f5b;
--td-gray-color-10: #454c48;
--td-gray-color-1: #f3f3f3;
--td-gray-color-2: #eee;
--td-gray-color-3: #e7e7e7;
--td-gray-color-4: #dcdcdc;
--td-gray-color-5: #c5c5c5;
--td-gray-color-6: #a6a6a6;
--td-gray-color-7: #8b8b8b;
--td-gray-color-8: #777;
--td-gray-color-9: #5e5e5e;
--td-gray-color-10: #4b4b4b;
--td-gray-color-11: #383838;
--td-gray-color-12: #2c2c2c;
--td-gray-color-13: #242424;
@@ -244,8 +500,259 @@
--td-bg-color-specialcomponent: transparent;
--td-border-level-1-color: var(--td-gray-color-11);
--td-border-level-2-color: var(--td-gray-color-9);
--td-mask-active: rgba(0, 0, 0, 0.1);
--td-mask-active: rgba(0, 0, 0, 0.4);
--td-mask-disabled: rgba(0, 0, 0, 0.6);
/* 通用颜色变量 - 暗色主题 */
--theme-bg-primary: #2d2d2d;
--theme-bg-secondary: #1a1a1a;
--theme-bg-tertiary: #1a1a1a;
--theme-text-primary: #ffffff;
--theme-text-secondary: #b3b3b3;
--theme-text-tertiary: #8a8a8a;
--theme-text-muted: #8a8a8a;
--theme-text-disabled: #b3b3b3;
--theme-border-light: #404040;
--theme-border-medium: #404040;
--theme-border-strong: #404040;
--theme-border: #404040;
--theme-hover-bg: #3a3a3a;
--theme-overlay: rgba(0, 0, 0, 0.8);
--theme-shadow-light: 0 2px 8px rgba(0, 0, 0, 0.2);
--theme-shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.3);
--theme-shadow-hover: 0 8px 25px rgba(0, 0, 0, 0.4), 0 4px 10px rgba(0, 0, 0, 0.3);
--theme-card-bg: #2d2d2d;
--theme-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--theme-card-shadow-hover: 0 8px 25px rgba(0, 0, 0, 0.4);
--theme-header-bg: #2a2a2a;
--theme-badge-bg: #404040;
--theme-tips-bg: linear-gradient(135deg, #2a2a2a, #333333);
--theme-warning-bg: #4a3a2a;
--theme-warning-text: #ffa726;
--theme-code-bg: rgba(255, 255, 255, 0.1);
--theme-code-hover-bg: rgba(255, 255, 255, 0.15);
--theme-note-bg: rgba(255, 255, 255, 0.08);
/* Find 页面专用变量 - 暗色主题 */
--find-bg-primary: var(--theme-bg-secondary);
--find-bg-secondary: var(--theme-bg-primary);
--find-text-primary: var(--theme-text-primary);
--find-text-secondary: var(--theme-text-secondary);
--find-text-muted: var(--theme-text-muted);
--find-card-bg: var(--theme-bg-primary);
--find-song-count-bg: rgba(255, 255, 255, 0.1);
--find-card-info-bg: rgba(45, 45, 45, 0.95);
--find-card-shadow: var(--theme-shadow-medium), 0 1px 4px rgba(0, 0, 0, 0.2);
--find-card-shadow-hover: var(--theme-shadow-hover);
--find-song-bg: var(--theme-bg-primary);
--find-song-hover-bg: var(--theme-hover-bg);
--find-border-color: var(--theme-border-light);
--find-meta-border: rgba(64, 64, 64, 0.5);
/* HomeLayout 页面专用变量 - 暗色主题 */
--home-nav-btn-color: var(--theme-text-secondary);
--home-nav-btn-hover: var(--theme-text-primary);
--home-source-selector-hover: var(--theme-hover-bg);
--home-source-list-bg: var(--theme-bg-primary);
--home-source-list-border: var(--theme-border-light);
--home-source-list-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
--home-source-item-hover: var(--theme-hover-bg);
--home-scrollbar-track: var(--theme-bg-primary);
--home-scrollbar-thumb: #4a4a4a;
--home-scrollbar-thumb-hover: #5a5a5a;
--home-scrollbar-color: #4a4a4a var(--theme-bg-primary);
/* List 页面专用变量 - 暗色主题 */
--list-bg-primary: var(--theme-bg-tertiary);
--list-content-bg: var(--theme-bg-primary);
--list-header-bg: var(--theme-bg-primary);
--list-header-shadow: var(--theme-shadow-medium);
--list-content-shadow: var(--theme-shadow-light);
--list-title-color: var(--theme-text-primary);
--list-author-color: var(--theme-text-secondary);
--list-stats-color: var(--theme-text-muted);
--list-loading-text: var(--theme-text-disabled);
--list-loading-border: var(--theme-border-strong);
--list-loading-spinner: var(--td-brand-color);
--list-cover-overlay: var(--theme-overlay);
/* SongVirtualList 组件专用变量 - 暗色主题 */
--song-list-header-bg: #2a2a2a;
--song-list-header-border: #404040;
--song-list-header-text: #8a8a8a;
--song-list-content-bg: var(--theme-bg-primary);
--song-list-item-border: #3a3a3a;
--song-list-item-hover: var(--theme-hover-bg);
--song-list-item-current: #1a3a5a;
--song-list-item-playing: #1a4a6a;
--song-list-track-number: #8a8a8a;
--song-list-title-color: var(--theme-text-primary);
--song-list-title-hover: var(--td-brand-color);
--song-list-artist-color: var(--theme-text-muted);
--song-list-album-color: var(--theme-text-muted);
--song-list-album-hover: var(--td-brand-color);
--song-list-duration-color: var(--theme-text-muted);
--song-list-btn-color: #666666;
--song-list-btn-hover: var(--td-brand-color);
--song-list-btn-bg-hover: var(--td-brand-color-light);
--song-list-quality-bg: #3a2a1a;
--song-list-quality-color: #fa8c16;
/* Search 页面专用变量 - 暗色主题 */
--search-bg: var(--theme-bg-tertiary);
--search-title-color: var(--theme-text-primary);
--search-keyword-color: var(--td-brand-color);
--search-info-color: var(--theme-text-muted);
--search-content-bg: var(--theme-bg-primary);
--search-content-shadow: var(--theme-shadow-light);
--search-empty-title: var(--theme-text-primary);
--search-empty-text: var(--theme-text-muted);
--search-loading-text: var(--theme-text-secondary);
--search-loading-border: var(--theme-border-light);
--search-loading-spinner: var(--td-brand-color);
/* Recent 页面专用变量 - 暗色主题 */
--recent-bg: var(--theme-bg-tertiary);
--recent-title-color: var(--theme-text-primary);
--recent-subtitle-color: var(--theme-text-secondary);
--recent-section-title: var(--theme-text-primary);
--recent-card-bg: var(--theme-bg-primary);
--recent-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--recent-card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.4);
--recent-playlist-title: var(--theme-text-primary);
--recent-playlist-desc: var(--theme-text-secondary);
--recent-playlist-meta: var(--theme-text-muted);
--recent-song-item-border: var(--theme-border-light);
--recent-song-item-hover: var(--theme-hover-bg);
--recent-song-index: var(--theme-text-secondary);
--recent-song-title: var(--theme-text-primary);
--recent-song-artist: var(--theme-text-secondary);
--recent-song-stats: var(--theme-text-secondary);
--recent-song-duration: var(--theme-text-secondary);
--recent-empty-icon: #666666;
--recent-empty-title: var(--theme-text-primary);
--recent-empty-text: var(--theme-text-secondary);
/* Local 页面专用变量 - 暗色主题 */
--local-bg: var(--theme-bg-tertiary);
--local-text-primary: var(--theme-text-primary);
--local-text-secondary: var(--theme-text-secondary);
--local-text-tertiary: var(--theme-text-tertiary);
--local-card-bg: var(--theme-card-bg);
--local-card-shadow: var(--theme-card-shadow);
--local-card-shadow-hover: var(--theme-card-shadow-hover);
--local-border: var(--theme-border);
--local-hover-bg: var(--theme-hover-bg);
--local-header-bg: var(--theme-header-bg);
--local-badge-bg: var(--theme-badge-bg);
--local-tips-bg: var(--theme-tips-bg);
--local-warning-bg: var(--theme-warning-bg);
--local-warning-text: var(--theme-warning-text);
--local-code-bg: var(--theme-code-bg);
--local-code-hover-bg: var(--theme-code-hover-bg);
--local-note-bg: var(--theme-note-bg);
/* Welcome 页面专用变量 - 暗色主题 */
--welcome-bg: #1a1a1a;
--welcome-subtitle-color: #999999;
--welcome-loading-text: #aaaaaa;
--welcome-progress-bg: #333333;
--welcome-tag-bg: #2d2d2d;
--welcome-tag-border: #404040;
--welcome-tag-color: #cccccc;
--welcome-version-color: #666666;
/* TitleBarControls 组件专用变量 - 暗色主题 */
--titlebar-icon-color: #ffffff;
--titlebar-icon-hover: #ffffff;
--titlebar-btn-hover-bg: #3a3a3a;
--titlebar-close-hover-bg: #4a2a2a;
--titlebar-close-hover-color: #ff6b6b;
/* Settings 页面专用变量 - 暗色主题 */
--settings-main-bg: #1a1a1a;
--settings-header-bg: #2d2d2d;
--settings-sidebar-bg: #2d2d2d;
--settings-sidebar-border: #404040;
--settings-nav-hover-bg: #3a3a3a;
--settings-nav-active-bg: var(--td-brand-color-1);
--settings-nav-active-border: var(--td-brand-color-5);
--settings-nav-icon-color: #8a8a8a;
--settings-nav-icon-active: var(--td-brand-color-5);
--settings-nav-label-color: #ffffff;
--settings-nav-label-active: var(--td-brand-color-6);
--settings-nav-desc-color: #8a8a8a;
--settings-content-bg: #1a1a1a;
--settings-group-bg: #2d2d2d;
--settings-group-border: #404040;
--settings-group-shadow: rgba(0, 0, 0, 0.3);
--settings-text-primary: #ffffff;
--settings-text-secondary: #b3b3b3;
--settings-text-tertiary: #8a8a8a;
--settings-footer-bg: #2d2d2d;
--settings-version-bg: #404040;
--settings-preview-bg: #2a2a2a;
--settings-preview-border: #404040;
--settings-mock-titlebar-bg: #333333;
--settings-mock-titlebar-border: #555555;
--settings-feature-bg: #2a2a2a;
--settings-feature-border: #404040;
--settings-api-tips-bg: #2a2a2a;
--settings-api-tips-border: #404040;
--settings-source-card-bg: #2d2d2d;
--settings-source-card-border: #404040;
--settings-source-card-hover-border: var(--td-brand-color-3);
--settings-source-card-active-border: var(--td-brand-color-5);
--settings-source-card-active-bg: var(--td-brand-color-1);
--settings-source-icon-bg: #3a3a3a;
--settings-quality-container-bg: #2a2a2a;
--settings-quality-container-border: #404040;
--settings-status-item-bg: #2a2a2a;
--settings-status-item-border: #404040;
--settings-plugin-prompt-bg: linear-gradient(135deg, #2a2a2a 0%, #333333 100%);
--settings-plugin-prompt-border: #555555;
--settings-tech-item-bg: #2a2a2a;
--settings-tech-item-border: #404040;
--settings-developer-item-bg: #2a2a2a;
--settings-developer-item-border: #404040;
--settings-tag-option-bg: #2a2a2a;
--settings-tag-option-border: #404040;
--settings-tag-status-bg: #2a2a2a;
--settings-tag-status-border: #404040;
/* Plugins 组件专用变量 - 暗色主题 */
--plugins-bg: var(--theme-bg-tertiary);
--plugins-container-bg: var(--theme-bg-primary);
--plugins-header-bg: var(--theme-bg-primary);
--plugins-text-primary: var(--theme-text-primary);
--plugins-text-secondary: var(--theme-text-secondary);
--plugins-text-muted: var(--theme-text-muted);
--plugins-border: var(--theme-border);
--plugins-card-bg: var(--theme-card-bg);
--plugins-card-shadow: var(--theme-card-shadow);
--plugins-card-shadow-hover: var(--theme-card-shadow-hover);
--plugins-card-selected-bg: #1a3a1a;
--plugins-card-selected-border: #28a745;
--plugins-loading-spinner: var(--td-brand-color);
--plugins-error-color: #ff6b6b;
--plugins-success-color: #4ade80;
--plugins-console-bg: #0d1117;
--plugins-console-header-bg: #161b22;
--plugins-console-border: #30363d;
--plugins-console-text: #f0f6fc;
--plugins-console-prompt: var(--td-brand-color);
--plugins-console-path: #7d8590;
--plugins-console-time: #6e7681;
--plugins-console-scrollbar-track: #161b22;
--plugins-console-scrollbar-thumb: #30363d;
--plugins-console-scrollbar-thumb-hover: #484f58;
--plugins-log-error: #ff7b72;
--plugins-log-warn: #f0d852;
--plugins-log-info: #79c0ff;
--plugins-log-debug: #d2a8ff;
--plugins-mac-close: #ff5f57;
--plugins-mac-minimize: #ffbd2e;
--plugins-mac-maximize: #28ca42;
}
:root {

View File

@@ -1,4 +1,5 @@
:root[theme-mode='blue'] {
:root[theme-mode='blue'],
:root[theme-mode='blue'][data-theme='light'] {
--td-brand-color-1: #ecf4ff;
--td-brand-color-2: #cde5ff;
--td-brand-color-3: #9aceff;
@@ -136,7 +137,7 @@
--td-mask-disabled: rgba(255, 255, 255, 0.6);
}
:root[theme-mode='dark'] {
:root[theme-mode='blue'][data-theme='dark'] {
--td-brand-color-1: #3198e220;
--td-brand-color-2: #003355;
--td-brand-color-3: #004a77;

View File

@@ -1,4 +1,5 @@
:root[theme-mode='cyan'] {
:root[theme-mode='cyan'][data-theme='light'],
:root[theme-mode='cyan']:not([data-theme]) {
--td-brand-color-1: #e3fcf8;
--td-brand-color-2: #beefe9;
--td-brand-color-3: #86dad1;
@@ -136,7 +137,7 @@
--td-mask-disabled: rgba(255, 255, 255, 0.6);
}
:root[theme-mode='dark'] {
:root[theme-mode='cyan'][data-theme='dark'] {
--td-brand-color-1: #00a59b20;
--td-brand-color-2: #003b36;
--td-brand-color-3: #00524c;

View File

@@ -1,4 +1,5 @@
:root[theme-mode='orange'] {
:root[theme-mode='orange'][data-theme='light'],
:root[theme-mode='orange']:not([data-theme]) {
--td-brand-color-1: #fff1ea;
--td-brand-color-2: #ffd9c5;
--td-brand-color-3: #ffb991;
@@ -136,7 +137,7 @@
--td-mask-disabled: rgba(255, 255, 255, 0.6);
}
:root[theme-mode='dark'] {
:root[theme-mode='orange'][data-theme='dark'] {
--td-brand-color-1: #e4722820;
--td-brand-color-2: #552100;
--td-brand-color-3: #753000;

View File

@@ -1,4 +1,5 @@
:root[theme-mode='pink'] {
:root[theme-mode='pink'][data-theme='light'],
:root[theme-mode='pink']:not([data-theme]) {
--td-brand-color-1: #fff0f1;
--td-brand-color-2: #ffd8dd;
--td-brand-color-3: #ffb7c1;
@@ -136,7 +137,7 @@
--td-mask-disabled: rgba(255, 255, 255, 0.6);
}
:root[theme-mode='dark'] {
:root[theme-mode='pink'][data-theme='dark'] {
--td-brand-color-1: #ff547920;
--td-brand-color-2: #690021;
--td-brand-color-3: #8d1135;

View File

@@ -0,0 +1,811 @@
<template>
<Teleport v-if="visible" to="body">
<!-- 遮罩层 -->
<div
class="context-menu-backdrop"
@click="handleBackdropClick"
@contextmenu="handleBackdropContextMenu"
></div>
<!-- 右键菜单容器 -->
<div
ref="menuRef"
class="context-menu"
:class="[className, { 'context-menu--scrolling': isScrolling }]"
:style="menuStyle"
@mouseleave="handleMouseLeave"
@wheel="handleWheel"
>
<!-- 菜单项列表容器 -->
<div
ref="scrollContainer"
class="context-menu__scroll-container"
:style="scrollContainerStyle"
>
<!-- 菜单项列表 -->
<ul class="context-menu__list">
<li
v-for="item in visibleItems"
:key="item.id"
class="context-menu__item"
:class="[
{
'context-menu__item--disabled': item.disabled,
'context-menu__item--separator': item.separator,
'context-menu__item--has-children': item.children && item.children.length > 0
},
item.className
]"
@mouseenter="handleItemMouseEnter(item, $event)"
@mouseleave="handleItemMouseLeave(item)"
@click="handleItemClick(item, $event)"
>
<!-- 分隔线 -->
<div v-if="item.separator" class="context-menu__separator"></div>
<!-- 普通菜单项 -->
<template v-else>
<!-- 图标 -->
<div v-if="item.icon" class="context-menu__icon">
<component :is="item.icon" size="16" />
</div>
<!-- 标签 -->
<span class="context-menu__label">{{ item.label }}</span>
<!-- 子菜单箭头 -->
<div v-if="item.children && item.children.length > 0" class="context-menu__arrow">
<chevron-right-icon
:fill-color="'transparent'"
:stroke-color="'#000000'"
:stroke-width="1.5"
/>
</div>
</template>
</li>
</ul>
</div>
<!-- 滚动指示器 -->
<div v-if="showScrollIndicator" class="context-menu__scroll-indicator">
<div
class="context-menu__scroll-indicator-top"
:class="{ 'context-menu__scroll-indicator--visible': canScrollUp }"
></div>
<div
class="context-menu__scroll-indicator-bottom"
:class="{ 'context-menu__scroll-indicator--visible': canScrollDown }"
></div>
</div>
<!-- 子菜单 -->
<div
v-if="activeSubmenu"
class="context-menu__submenu-wrapper"
:style="submenuWrapperStyle"
@mouseenter="handleSubmenuMouseEnter"
@mouseleave="handleSubmenuMouseLeave"
>
<ContextMenu
ref="submenuRef"
:visible="true"
:position="submenuPosition"
:items="activeSubmenu.children || []"
:width="width"
:max-height="Math.min(maxHeight, 300)"
:z-index="zIndex + 1"
@item-click="handleSubmenuItemClick"
@close="closeSubmenu"
/>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted, type CSSProperties } from 'vue'
import type {
ContextMenuProps,
ContextMenuItem,
ContextMenuPosition,
EdgeDetectionConfig,
AnimationConfig,
ScrollConfig
} from './types'
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
// 默认配置
const DEFAULT_EDGE_CONFIG: EdgeDetectionConfig = {
threshold: 10,
enabled: true
}
const DEFAULT_ANIMATION_CONFIG: AnimationConfig = {
duration: 200,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
enabled: true
}
const DEFAULT_SCROLL_CONFIG: ScrollConfig = {
scrollbarWidth: 6,
scrollSpeed: 40,
showScrollbar: true
}
// 组件属性
const props = withDefaults(defineProps<ContextMenuProps>(), {
visible: false,
position: () => ({ x: 0, y: 0 }),
items: () => [],
className: '',
width: 200,
maxHeight: 400,
zIndex: 1000
})
const emit = defineEmits<{
'update:visible': [value: boolean]
close: []
'item-click': [item: ContextMenuItem, event: MouseEvent]
}>()
// 响应式引用
const menuRef = ref<HTMLElement>()
const scrollContainer = ref<HTMLElement>()
const submenuRef = ref<any>()
// 状态管理
const isScrolling = ref(false)
const scrollTop = ref(0)
const scrollHeight = ref(0)
const clientHeight = ref(0)
const activeSubmenu = ref<ContextMenuItem | null>(null)
const submenuPosition = ref<ContextMenuPosition>({ x: 0, y: 0 })
const submenuTimer = ref<NodeJS.Timeout>()
const submenuMaxHeight = ref(300)
// 计算属性
const menuStyle = computed((): CSSProperties => {
const style: CSSProperties = {
'--menu-width': `${props.width}px`,
'--menu-max-height': `${props.maxHeight}px`,
'--menu-z-index': props.zIndex,
'--animation-duration': `${DEFAULT_ANIMATION_CONFIG.duration}ms`,
'--animation-easing': DEFAULT_ANIMATION_CONFIG.easing
}
if (!menuRef.value) {
return {
...style,
left: `${props.position.x}px`,
top: `${props.position.y}px`
}
}
const adjustedPosition = adjustMenuPosition(props.position)
return {
...style,
left: `${adjustedPosition.x}px`,
top: `${adjustedPosition.y}px`
}
})
const scrollContainerStyle = computed((): CSSProperties => {
return {
maxHeight: `${props.maxHeight}px`,
transform: `translateY(-${scrollTop.value}px)`
}
})
const visibleItems = computed(() => {
return props.items.filter((item) => {
// 显示所有非分隔线项目
if (!item.separator) return true
// 显示所有分隔线项目无论是否有label
return true
})
})
const showScrollIndicator = computed(() => {
return DEFAULT_SCROLL_CONFIG.showScrollbar && scrollHeight.value > clientHeight.value
})
const canScrollUp = computed(() => scrollTop.value > 0)
const canScrollDown = computed(() => scrollTop.value < scrollHeight.value - clientHeight.value)
const submenuWrapperStyle = computed((): CSSProperties => {
return {
position: 'fixed',
zIndex: props.zIndex + 1,
maxHeight: `${submenuMaxHeight.value}px`
}
})
// 监听器
watch(
() => props.visible,
(newVisible) => {
if (newVisible) {
nextTick(() => {
initializeScroll()
updateSubmenuPosition()
})
} else {
closeSubmenu()
resetScroll()
}
}
)
watch(
() => props.items,
() => {
if (props.visible) {
nextTick(initializeScroll)
}
}
)
// 生命周期
onMounted(() => {
document.addEventListener('keydown', handleKeyDown)
window.addEventListener('resize', handleWindowResize)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('resize', handleWindowResize)
clearTimeout(submenuTimer.value)
})
// 方法定义
const adjustMenuPosition = (position: ContextMenuPosition): ContextMenuPosition => {
if (!DEFAULT_EDGE_CONFIG.enabled || !menuRef.value) {
return position
}
const menuRect = menuRef.value.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const threshold = DEFAULT_EDGE_CONFIG.threshold
let adjustedX = position.x
let adjustedY = position.y
// 水平边缘检测
if (position.x + menuRect.width > viewportWidth - threshold) {
adjustedX = viewportWidth - menuRect.width - threshold
} else if (position.x < threshold) {
adjustedX = threshold
}
// 垂直边缘检测
if (position.y + menuRect.height > viewportHeight - threshold) {
adjustedY = viewportHeight - menuRect.height - threshold
} else if (position.y < threshold) {
adjustedY = threshold
}
return { x: adjustedX, y: adjustedY }
}
const initializeScroll = () => {
if (!scrollContainer.value) return
const container = scrollContainer.value
scrollHeight.value = container.scrollHeight
clientHeight.value = container.clientHeight
scrollTop.value = 0
}
const resetScroll = () => {
scrollTop.value = 0
scrollHeight.value = 0
clientHeight.value = 0
}
const scrollTo = (targetScrollTop: number) => {
const maxScrollTop = scrollHeight.value - clientHeight.value
scrollTop.value = Math.max(0, Math.min(targetScrollTop, maxScrollTop))
}
const scrollBy = (delta: number) => {
scrollTo(scrollTop.value + delta)
}
const handleWheel = (event: WheelEvent) => {
if (!showScrollIndicator.value) return
event.preventDefault()
event.stopPropagation()
const delta =
event.deltaY > 0 ? DEFAULT_SCROLL_CONFIG.scrollSpeed : -DEFAULT_SCROLL_CONFIG.scrollSpeed
scrollBy(delta)
isScrolling.value = true
clearTimeout(submenuTimer.value)
submenuTimer.value = setTimeout(() => {
isScrolling.value = false
}, 150)
}
const handleItemMouseEnter = (item: ContextMenuItem, event: MouseEvent) => {
if (item.disabled || item.separator) return
// 清除之前的子菜单定时器
clearTimeout(submenuTimer.value)
if (item.children && item.children.length > 0) {
submenuTimer.value = setTimeout(() => {
openSubmenu(item, event)
}, 200)
} else {
closeSubmenu()
}
}
const handleItemMouseLeave = (item: ContextMenuItem) => {
if (item.children && item.children.length > 0) {
clearTimeout(submenuTimer.value)
}
}
const handleItemClick = (item: ContextMenuItem, event: MouseEvent) => {
if (item.disabled || item.separator) return
// 调用菜单项的点击回调
if (item.onClick) {
item.onClick(item, event)
}
// 发射组件事件
emit('item-click', item, event)
// 如果没有子菜单,关闭菜单
if (!item.children || item.children.length === 0) {
closeMenu()
}
}
const handleSubmenuItemClick = (item: ContextMenuItem, event: MouseEvent) => {
emit('item-click', item, event)
closeMenu()
}
const openSubmenu = (item: ContextMenuItem, _event: MouseEvent) => {
if (!menuRef.value) return
// 如果是相同的子菜单,不需要重新计算位置
if (activeSubmenu.value && activeSubmenu.value.id === item.id) {
return
}
// 移除未使用的变量声明
activeSubmenu.value = item
nextTick(() => {
updateSubmenuPosition()
})
}
const closeSubmenu = () => {
activeSubmenu.value = null
clearTimeout(submenuTimer.value)
}
const updateSubmenuPosition = () => {
if (!menuRef.value || !activeSubmenu.value) return
const menuRect = menuRef.value.getBoundingClientRect()
// 初始位置:显示在右侧
const x = menuRect.right
const y = menuRect.top
// 先设置初始位置,让子菜单渲染
submenuPosition.value = { x, y }
// 等待子菜单渲染完成后调整位置
setTimeout(() => {
// 子菜单通过 Teleport 渲染到 body 中,需要在 body 中查找
// 查找所有的 context-menu 元素,找到 z-index 最高的(即子菜单)
const allMenus = document.querySelectorAll('.context-menu')
console.log('All menus found:', allMenus.length)
let submenuEl: Element | null = null
let maxZIndex = props.zIndex
allMenus.forEach((menu) => {
const style = window.getComputedStyle(menu)
const zIndex = parseInt(style.zIndex) || 0
console.log('Menu z-index:', zIndex, 'Current max:', maxZIndex)
if (zIndex > maxZIndex) {
maxZIndex = zIndex
submenuEl = menu as Element
}
})
console.log('Found submenu:', submenuEl)
if (submenuEl) {
const submenuRect = (submenuEl as HTMLElement).getBoundingClientRect()
console.log('submenuRect:', submenuRect)
if (submenuRect.width > 0) {
// 计算包含滚动条的实际宽度
const scrollContainer = (submenuEl as HTMLElement).querySelector(
'.context-menu__scroll-container'
) as HTMLElement | null
let actualWidth = submenuRect.width
if (scrollContainer) {
// 检查是否有滚动条
const hasScrollbar = scrollContainer.scrollHeight > scrollContainer.clientHeight
if (hasScrollbar) {
// 添加滚动条宽度通常是6-17px这里使用默认的6px
const scrollbarWidth = scrollContainer.offsetWidth - scrollContainer.clientWidth
actualWidth += scrollbarWidth
console.log('Added scrollbar width:', scrollbarWidth, 'Total width:', actualWidth)
}
}
adjustSubmenuPosition(actualWidth)
} else {
// 如果宽度为0再等一下
setTimeout(() => {
const retryRect = (submenuEl as HTMLElement).getBoundingClientRect()
console.log('retryRect:', retryRect)
if (retryRect.width > 0) {
// 重试时也要考虑滚动条
const scrollContainer = (submenuEl as HTMLElement).querySelector(
'.context-menu__scroll-container'
) as HTMLElement | null
let actualWidth = retryRect.width
if (scrollContainer) {
const hasScrollbar = scrollContainer.scrollHeight > scrollContainer.clientHeight
if (hasScrollbar) {
const scrollbarWidth = scrollContainer.offsetWidth - scrollContainer.clientWidth
actualWidth += scrollbarWidth
}
}
adjustSubmenuPosition(actualWidth)
}
}, 50)
}
}
}, 0)
}
// 提取位置调整逻辑为独立函数
const adjustSubmenuPosition = (submenuWidth: number) => {
if (!menuRef.value || !activeSubmenu.value) return
const menuRect = menuRef.value.getBoundingClientRect()
const viewportWidth = window.innerWidth
const threshold = 10
// 重新计算位置
let adjustedX = menuRect.right
const y = menuRect.top
// 检查右侧是否有足够空间显示子菜单
if (adjustedX + submenuWidth > viewportWidth - threshold) {
// 如果右侧空间不足显示在左侧父元素的left - 子菜单宽度
adjustedX = menuRect.left - submenuWidth
}
// 确保子菜单不会超出左边界
if (adjustedX < threshold) {
adjustedX = threshold
}
console.log('Final position:', { x: adjustedX, y })
// 更新最终位置
submenuPosition.value = { x: adjustedX, y }
}
const handleBackdropClick = () => {
closeMenu()
}
const handleBackdropContextMenu = (event: MouseEvent) => {
event.preventDefault()
closeMenu()
}
const handleMouseLeave = () => {
clearTimeout(submenuTimer.value)
}
const handleSubmenuMouseEnter = () => {
// 鼠标进入子菜单区域,清除关闭定时器
clearTimeout(submenuTimer.value)
}
const handleSubmenuMouseLeave = () => {
// 鼠标离开子菜单区域,延迟关闭子菜单
submenuTimer.value = setTimeout(() => {
closeSubmenu()
}, 100)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (!props.visible) return
switch (event.key) {
case 'Escape':
event.preventDefault()
closeMenu()
break
case 'ArrowUp':
event.preventDefault()
scrollBy(-DEFAULT_SCROLL_CONFIG.scrollSpeed)
break
case 'ArrowDown':
event.preventDefault()
scrollBy(DEFAULT_SCROLL_CONFIG.scrollSpeed)
break
}
}
const handleWindowResize = () => {
if (props.visible) {
closeMenu()
}
}
const closeMenu = () => {
emit('update:visible', false)
emit('close')
}
// 暴露给父组件的方法
defineExpose({
updatePosition: (_position: ContextMenuPosition) => {
// 位置更新逻辑
},
updateItems: (_items: ContextMenuItem[]) => {
// 菜单项更新逻辑
},
hide: closeMenu
})
</script>
<style scoped>
.context-menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: calc(var(--menu-z-index) - 1);
background: transparent;
}
.context-menu {
position: fixed;
min-width: var(--menu-width);
max-width: 300px;
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
z-index: var(--menu-z-index);
overflow: auto;
animation: contextMenuEnter var(--animation-duration) var(--animation-easing);
}
.context-menu--scrolling {
pointer-events: auto;
}
.context-menu__scroll-container {
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
/* scrollbar-color: rgba(255, 255, 255, 0.3) transparent; */
transition: transform 0.15s ease;
}
.context-menu__scroll-container::-webkit-scrollbar {
width: 6px;
}
/*
.context-menu__scroll-container::-webkit-scrollbar-track {
background: transparent;
}
.context-menu__scroll-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
.context-menu__scroll-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
} */
.context-menu__list {
list-style: none;
margin: 0;
padding: 4px 0;
min-width: 100%;
}
.context-menu__item {
position: relative;
display: flex;
align-items: center;
padding: 8px 12px;
margin: 0 4px;
border-radius: 4px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
min-height: 32px;
box-sizing: border-box;
}
.context-menu__item:hover:not(.context-menu__item--disabled):not(.context-menu__item--separator) {
background: #f5f5f5;
}
.context-menu__item--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.context-menu__item--separator {
padding: 0;
margin: 4px 0;
cursor: default;
height: auto;
min-height: auto;
}
.context-menu__item--has-children {
padding-right: 24px;
}
.context-menu__icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 8px;
color: #666;
flex-shrink: 0;
}
.context-menu__label {
flex: 1;
font-size: 13px;
color: #333;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-menu__arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
justify-content: center;
align-items: center;
display: flex;
color: #999;
}
.context-menu__arrow-icon {
font-size: 10px;
font-style: normal;
}
.context-menu__separator {
height: 1px;
width: 100%;
background: #e0e0e0;
margin: 0 8px;
opacity: 0.8;
}
.context-menu__scroll-indicator {
position: absolute;
right: 2px;
top: 4px;
bottom: 4px;
width: var(--scrollbar-width, 6px);
pointer-events: none;
}
.context-menu__scroll-indicator-top,
.context-menu__scroll-indicator-bottom {
position: absolute;
left: 0;
width: 100%;
height: 20px;
opacity: 0;
transition: opacity 0.2s ease;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.9), transparent);
}
.context-menu__scroll-indicator-top {
top: 0;
transform: rotate(180deg);
}
.context-menu__scroll-indicator-bottom {
bottom: 0;
}
.context-menu__scroll-indicator--visible {
opacity: 1;
}
/* 动画 */
@keyframes contextMenuEnter {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.context-menu {
min-width: 180px;
max-width: 280px;
border-radius: 6px;
}
.context-menu__item {
padding: 10px 12px;
min-height: 36px;
}
.context-menu__label {
font-size: 14px;
}
}
/* 暗色主题支持 */
@media (prefers-color-scheme: dark) {
.context-menu {
background: #2d2d2d;
border-color: #404040;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.context-menu__item:hover:not(.context-menu__item--disabled):not(.context-menu__item--separator) {
background: #404040;
}
.context-menu__icon {
color: #ccc;
}
.context-menu__label {
color: #e0e0e0;
}
.context-menu__separator {
background: #555555;
opacity: 0.9;
}
.context-menu__scroll-indicator-top,
.context-menu__scroll-indicator-bottom {
background: linear-gradient(to bottom, rgba(45, 45, 45, 0.9), transparent);
}
}
</style>

View File

@@ -0,0 +1,244 @@
# 自定义右键菜单组件
一个功能完整、可扩展的自定义右键菜单组件,专为歌曲列表等场景设计。
## 特性
-**精确的边缘点击判定** - 智能计算位置,确保菜单始终在可视区域内
-**滚动支持** - 支持菜单项过多时的滚动选择
-**可扩展性** - 易于添加新的菜单项和功能
-**平滑动画** - 流畅的显示/隐藏动画效果
-**自适应显示** - 在不同屏幕尺寸下自动适配
-**完整TypeScript支持** - 提供完整的类型定义
## 安装和使用
### 基本使用
```vue
<template>
<div @contextmenu.prevent="handleContextMenu">
<!-- 你的内容 -->
</div>
<ContextMenu
v-model:visible="menuVisible"
:items="menuItems"
:position="menuPosition"
@item-click="handleMenuItemClick"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ContextMenu from './ContextMenu/ContextMenu.vue'
import { createMenuItem, createSeparator } from './ContextMenu/utils'
import type { ContextMenuItem } from './ContextMenu/types'
const menuVisible = ref(false)
const menuPosition = ref({ x: 0, y: 0 })
const menuItems = ref<ContextMenuItem[]>([
createMenuItem('play', '播放', {
onClick: (item, event) => console.log('播放点击')
}),
createSeparator(),
createMenuItem('download', '下载')
])
</script>
```
### 在歌曲列表中使用
```vue
<template>
<div class="song-list">
<div
v-for="song in songs"
:key="song.id"
class="song-item"
@contextmenu.prevent="handleSongContextMenu(song, $event)"
>
{{ song.name }}
</div>
</div>
<ContextMenu
v-model:visible="contextMenuVisible"
:items="contextMenuItems"
:position="contextMenuPosition"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ContextMenu from './ContextMenu/ContextMenu.vue'
import { createMenuItem, createSeparator } from './ContextMenu/utils'
const contextMenuVisible = ref(false)
const contextMenuPosition = ref({ x: 0, y: 0 })
const currentSong = ref(null)
const contextMenuItems = computed(() => [
createMenuItem('play', '播放', {
onClick: () => playSong(currentSong.value)
}),
createMenuItem('addToPlaylist', '添加到播放列表'),
createSeparator(),
createMenuItem('download', '下载')
])
const handleSongContextMenu = (song, event) => {
currentSong.value = song
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
contextMenuVisible.value = true
}
</script>
```
## API 文档
### ContextMenu 组件属性
| 属性 | 类型 | 默认值 | 说明 |
| --------- | ------------------- | --------- | ----------------- |
| visible | boolean | false | 控制菜单显示/隐藏 |
| items | ContextMenuItem[] | [] | 菜单项配置数组 |
| position | ContextMenuPosition | {x:0,y:0} | 菜单位置坐标 |
| maxHeight | number | 400 | 菜单最大高度 |
| zIndex | number | 1000 | 菜单层级 |
### ContextMenuItem 类型
```typescript
interface ContextMenuItem {
id: string
label: string
icon?: any
disabled?: boolean
separator?: boolean
children?: ContextMenuItem[]
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
className?: string
}
```
### 工具函数
#### createMenuItem
创建标准菜单项
```typescript
createMenuItem(id: string, label: string, options?: {
icon?: any
disabled?: boolean
separator?: boolean
children?: ContextMenuItem[]
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
className?: string
}): ContextMenuItem
```
#### createSeparator
创建分隔线
```typescript
createSeparator(): ContextMenuItem
```
#### calculateMenuPosition
智能计算菜单位置
```typescript
calculateMenuPosition(
event: MouseEvent,
menuWidth?: number,
menuHeight?: number
): ContextMenuPosition
```
## 高级用法
### 子菜单支持
```typescript
const menuItems = [
createMenuItem('playlist', '添加到歌单', {
children: [
createMenuItem('playlist1', '我的最爱'),
createMenuItem('playlist2', '开车音乐'),
createSeparator(),
createMenuItem('newPlaylist', '新建歌单')
]
})
]
```
### 动态菜单项
```typescript
const dynamicMenuItems = computed(() => {
const items = [createMenuItem('play', '播放')]
if (user.value.isPremium) {
items.push(createMenuItem('download', '下载高音质'))
}
return items
})
```
### 自定义样式
```typescript
const menuItems = [
createMenuItem('danger', '删除歌曲', {
className: 'danger-item'
})
]
```
```css
.danger-item {
color: #ff4d4f;
}
.danger-item:hover {
background-color: #fff2f0;
}
```
## 最佳实践
1. **使用防抖处理频繁的右键事件**
2. **合理设置菜单最大高度,避免过长滚动**
3. **为重要操作添加确认对话框**
4. **根据用户权限动态显示菜单项**
5. **在移动端考虑触摸替代方案**
## 浏览器兼容性
- Chrome 60+
- Firefox 55+
- Safari 12+
- Edge 79+
## 故障排除
### 菜单位置不正确
确保使用 `calculateMenuPosition` 函数计算位置。
### 菜单项点击无效
检查 `onClick` 回调函数是否正确绑定。
### 样式冲突
使用 `className` 属性添加自定义样式类。
## 贡献指南
欢迎提交 Issue 和 Pull Request 来改进这个组件。

View File

@@ -0,0 +1,397 @@
import { ref, computed, type Ref } from 'vue'
import type { ContextMenuItem, ContextMenuPosition } from './types'
import { createMenuItem, createSeparator } from './utils'
/**
* 右键菜单组合式函数
*/
export function useContextMenu() {
const visible = ref(false)
const position = ref<ContextMenuPosition>({ x: 0, y: 0 })
const items = ref<ContextMenuItem[]>([])
const currentData = ref<any>(null)
/**
* 显示菜单
*/
const show = (event: MouseEvent, menuItems: ContextMenuItem[], data?: any) => {
event.preventDefault()
event.stopPropagation()
position.value = {
x: event.clientX,
y: event.clientY
}
items.value = menuItems
currentData.value = data
visible.value = true
}
/**
* 隐藏菜单
*/
const hide = () => {
visible.value = false
currentData.value = null
}
/**
* 更新菜单位置
*/
const updatePosition = (newPosition: ContextMenuPosition) => {
position.value = newPosition
}
/**
* 更新菜单项
*/
const updateItems = (newItems: ContextMenuItem[]) => {
items.value = newItems
}
/**
* 处理菜单项点击
*/
const handleItemClick = (item: ContextMenuItem, event: MouseEvent) => {
if (item.onClick) {
item.onClick(item, event)
}
hide()
}
return {
// 状态
visible: computed(() => visible.value),
position: computed(() => position.value),
items: computed(() => items.value),
currentData: computed(() => currentData.value),
// 方法
show,
hide,
updatePosition,
updateItems,
handleItemClick
}
}
/**
* 歌曲相关的右键菜单配置
*/
export function useSongContextMenu() {
const { show, hide, ...rest } = useContextMenu()
/**
* 显示歌曲右键菜单
*/
const showSongMenu = (
event: MouseEvent,
song: any,
options?: {
showPlay?: boolean
showAddToPlaylist?: boolean
showDownload?: boolean
showAddToSongList?: boolean
playlists?: any[]
onPlay?: (song: any) => void
onAddToPlaylist?: (song: any) => void
onDownload?: (song: any) => void
onAddToSongList?: (song: any, playlist: any) => void
}
) => {
const {
showPlay = true,
showAddToPlaylist = true,
showDownload = true,
showAddToSongList = true,
playlists = [],
onPlay,
onAddToPlaylist,
onDownload,
onAddToSongList
} = options || {}
const menuItems: ContextMenuItem[] = []
// 播放
if (showPlay) {
menuItems.push(
createMenuItem('play', '播放', {
onClick: () => onPlay?.(song)
})
)
}
// 添加到播放列表
if (showAddToPlaylist) {
menuItems.push(
createMenuItem('addToPlaylist', '添加到播放列表', {
onClick: () => onAddToPlaylist?.(song)
})
)
}
// 添加到歌单(如果有歌单)
if (showAddToSongList && playlists.length > 0) {
menuItems.push(
createMenuItem('addToSongList', '加入歌单', {
children: playlists.map((playlist) =>
createMenuItem(`playlist_${playlist.id}`, playlist.name, {
onClick: () => onAddToSongList?.(song, playlist)
})
)
})
)
}
// 分隔线
if (menuItems.length > 0) {
menuItems.push(createSeparator())
}
// 下载
if (showDownload) {
menuItems.push(
createMenuItem('download', '下载', {
onClick: () => onDownload?.(song)
})
)
}
show(event, menuItems, song)
}
return {
...rest,
showSongMenu,
hide
}
}
/**
* 列表项右键菜单配置
*/
export function useListItemContextMenu() {
const { show, hide, ...rest } = useContextMenu()
/**
* 显示列表项右键菜单
*/
const showListItemMenu = (
event: MouseEvent,
item: any,
options?: {
showEdit?: boolean
showDelete?: boolean
showCopy?: boolean
showProperties?: boolean
onEdit?: (item: any) => void
onDelete?: (item: any) => void
onCopy?: (item: any) => void
onProperties?: (item: any) => void
}
) => {
const {
showEdit = true,
showDelete = true,
showCopy = false,
showProperties = false,
onEdit,
onDelete,
onCopy,
onProperties
} = options || {}
const menuItems: ContextMenuItem[] = []
// 编辑
if (showEdit) {
menuItems.push(
createMenuItem('edit', '编辑', {
onClick: () => onEdit?.(item)
})
)
}
// 复制
if (showCopy) {
menuItems.push(
createMenuItem('copy', '复制', {
onClick: () => onCopy?.(item)
})
)
}
// 分隔线
if (menuItems.length > 0 && (showDelete || showProperties)) {
menuItems.push(createSeparator())
}
// 删除
if (showDelete) {
menuItems.push(
createMenuItem('delete', '删除', {
onClick: () => onDelete?.(item)
})
)
}
// 属性
if (showProperties) {
menuItems.push(
createMenuItem('properties', '属性', {
onClick: () => onProperties?.(item)
})
)
}
show(event, menuItems, item)
}
return {
...rest,
showListItemMenu,
hide
}
}
/**
* 文本选择右键菜单配置
*/
export function useTextSelectionContextMenu() {
const { show, hide, ...rest } = useContextMenu()
/**
* 显示文本选择右键菜单
*/
const showTextSelectionMenu = (
event: MouseEvent,
selectedText: string,
options?: {
showCopy?: boolean
showSearch?: boolean
showTranslate?: boolean
onCopy?: (text: string) => void
onSearch?: (text: string) => void
onTranslate?: (text: string) => void
}
) => {
const {
showCopy = true,
showSearch = true,
showTranslate = false,
onCopy,
onSearch,
onTranslate
} = options || {}
const menuItems: ContextMenuItem[] = []
// 复制
if (showCopy) {
menuItems.push(
createMenuItem('copy', '复制', {
onClick: () => onCopy?.(selectedText)
})
)
}
// 搜索
if (showSearch) {
menuItems.push(
createMenuItem('search', '搜索', {
onClick: () => onSearch?.(selectedText)
})
)
}
// 翻译
if (showTranslate) {
menuItems.push(
createMenuItem('translate', '翻译', {
onClick: () => onTranslate?.(selectedText)
})
)
}
show(event, menuItems, selectedText)
}
return {
...rest,
showTextSelectionMenu,
hide
}
}
/**
* 创建可复用的菜单配置
*/
export function createMenuConfig<T = any>(config: {
items: ContextMenuItem[]
onItemClick?: (item: ContextMenuItem, data: T, event: MouseEvent) => void
onShow?: (data: T) => void
onHide?: () => void
}) {
const { items, onItemClick, onShow, onHide } = config
return {
items: ref([...items]),
show: (_event: MouseEvent, data: T) => {
onShow?.(data)
},
handleItemClick: (item: ContextMenuItem, event: MouseEvent, data: T) => {
onItemClick?.(item, data, event)
},
hide: () => {
onHide?.()
}
}
}
/**
* 菜单项可见性控制
*/
export function useMenuVisibility<T extends ContextMenuItem>(
items: Ref<T[]>,
predicate: (item: T) => boolean
) {
const visibleItems = computed(() => items.value.filter(predicate))
const hasVisibleItems = computed(() => visibleItems.value.length > 0)
return {
visibleItems,
hasVisibleItems
}
}
/**
* 菜单项动态启用/禁用控制
*/
export function useMenuItemsState<T extends ContextMenuItem>(
items: Ref<T[]>,
getState: (item: T) => { disabled?: boolean; visible?: boolean }
) {
const processedItems = computed(() =>
items.value
.map((item) => {
const state = getState(item)
return {
...item,
disabled: state.disabled ?? item.disabled,
// 如果visible为false完全移除该项
...(state.visible === false ? { _hidden: true } : {})
}
})
.filter((item) => !(item as any)._hidden)
)
return {
processedItems
}
}

View File

@@ -0,0 +1,199 @@
<template>
<div class="demo-container">
<h1>右键菜单组件演示</h1>
<!-- 测试区域 -->
<div class="test-area">
<div
class="test-box"
style="width: 300px; height: 200px; border: 2px dashed #ccc; padding: 20px"
@contextmenu.prevent="handleContextMenu($event)"
>
<p>在此区域右键点击测试菜单</p>
<p>菜单项数量{{ menuItems.length }}</p>
</div>
</div>
<!-- 右键菜单 -->
<ContextMenu
v-model:visible="menuVisible"
:position="menuPosition"
:items="menuItems"
:max-height="200"
@item-click="handleMenuItemClick"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ContextMenu from './ContextMenu.vue'
import type { ContextMenuItem } from './types'
const menuVisible = ref(false)
const menuPosition = ref({ x: 0, y: 0 })
// 创建大量菜单项用于测试滚动
const menuItems = ref<ContextMenuItem[]>([
{
id: 'play',
label: '播放',
icon: '▶'
},
{
id: 'pause',
label: '暂停',
icon: '⏸'
},
{
id: 'separator-1',
separator: true
},
{
id: 'add-to-playlist',
label: '添加到播放列表',
icon: ''
},
{
id: 'remove-from-playlist',
label: '从播放列表移除',
icon: ''
},
{
id: 'separator-2',
separator: true
},
{
id: 'download',
label: '下载歌曲',
icon: '⬇️'
},
{
id: 'share',
label: '分享',
icon: '↗️'
},
{
id: 'separator-3',
separator: true
},
{
id: 'info',
label: '歌曲信息',
icon: ''
},
{
id: 'edit-tags',
label: '编辑标签',
icon: '✏️'
},
{
id: 'separator-4',
separator: true
},
{
id: 'rate-1',
label: '评分:★☆☆☆☆',
icon: '⭐'
},
{
id: 'rate-2',
label: '评分:★★☆☆☆',
icon: '⭐'
},
{
id: 'rate-3',
label: '评分:★★★☆☆',
icon: '⭐'
},
{
id: 'rate-4',
label: '评分:★★★★☆',
icon: '⭐'
},
{
id: 'rate-5',
label: '评分:★★★★★',
icon: '⭐'
},
{
id: 'separator-5',
separator: true
},
{
id: 'create-station',
label: '创建电台',
icon: '📻'
},
{
id: 'similar-songs',
label: '相似歌曲',
icon: '🎵'
},
{
id: 'separator-6',
separator: true
},
{
id: 'copy-link',
label: '复制链接',
icon: '🔗'
},
{
id: 'properties',
label: '属性',
icon: '📋'
},
{
id: 'separator-7',
separator: true
},
{
id: 'delete',
label: '删除',
icon: '🗑️',
className: 'danger'
}
])
const handleContextMenu = (event: MouseEvent) => {
menuPosition.value = {
x: event.clientX,
y: event.clientY
}
menuVisible.value = true
}
const handleMenuItemClick = (item: ContextMenuItem) => {
console.log('菜单项点击:', item.label)
// 这里可以添加具体的菜单项处理逻辑
}
</script>
<style scoped>
.demo-container {
padding: 20px;
font-family: Arial, sans-serif;
}
.test-area {
margin: 20px 0;
}
.test-box {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
transition: background-color 0.2s;
}
.test-box:hover {
background-color: #f5f5f5;
}
.danger {
color: #ff4444 !important;
}
</style>

View File

@@ -0,0 +1,4 @@
export { default as ContextMenu } from './ContextMenu.vue'
export * from './types'
export * from './utils'
export * from './composables'

View File

@@ -0,0 +1,101 @@
/**
* 右键菜单位置类型定义
*/
export interface ContextMenuPosition {
x: number
y: number
}
/**
* 右键菜单项类型定义
*/
export interface ContextMenuItem {
/** 菜单项唯一标识 */
id: string
/** 显示文本 */
label?: string
/** 图标组件 */
icon?: any
/** 是否禁用 */
disabled?: boolean
/** 是否显示分隔线 */
separator?: boolean
/** 子菜单项 */
children?: ContextMenuItem[]
/** 点击回调函数 */
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
/** 自定义CSS类名 */
className?: string
}
/**
* 右键菜单配置属性
*/
export interface ContextMenuProps {
/** 是否显示菜单 */
visible: boolean
/** 菜单位置 */
position: ContextMenuPosition
/** 菜单项列表 */
items: ContextMenuItem[]
/** 自定义CSS类名 */
className?: string
/** 菜单宽度 */
width?: number
/** 最大高度(超出时显示滚动条) */
maxHeight?: number
/** 菜单层级 */
zIndex?: number
/** 关闭菜单回调 */
onClose?: () => void
/** 菜单项点击回调 */
onItemClick?: (item: ContextMenuItem, event: MouseEvent) => void
}
/**
* 边缘检测配置
*/
export interface EdgeDetectionConfig {
/** 距离边缘的阈值(像素) */
threshold: number
/** 是否启用边缘检测 */
enabled: boolean
}
/**
* 动画配置
*/
export interface AnimationConfig {
/** 动画持续时间(毫秒) */
duration: number
/** 动画缓动函数 */
easing: string
/** 是否启用动画 */
enabled: boolean
}
/**
* 滚动配置
*/
export interface ScrollConfig {
/** 滚动条宽度 */
scrollbarWidth: number
/** 滚动速度 */
scrollSpeed: number
/** 是否显示滚动条 */
showScrollbar: boolean
}
/**
* 右键菜单实例方法
*/
export interface ContextMenuInstance {
/** 显示菜单 */
show: (position: ContextMenuPosition, items?: ContextMenuItem[]) => void
/** 隐藏菜单 */
hide: () => void
/** 更新菜单位置 */
updatePosition: (position: ContextMenuPosition) => void
/** 更新菜单项 */
updateItems: (items: ContextMenuItem[]) => void
}

View File

@@ -0,0 +1,266 @@
import type { ContextMenuItem, ContextMenuPosition } from './types'
/**
* 创建标准菜单项
*/
export function createMenuItem(
id: string,
label: string,
options?: {
icon?: any
disabled?: boolean
separator?: boolean
children?: ContextMenuItem[]
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
className?: string
}
): ContextMenuItem {
return {
id,
label,
icon: options?.icon,
disabled: options?.disabled || false,
separator: options?.separator || false,
children: options?.children,
onClick: options?.onClick,
className: options?.className
}
}
/**
* 创建分隔线菜单项
*/
export function createSeparator(): ContextMenuItem {
return {
id: `separator-${Date.now()}`,
label: '',
separator: true
}
}
/**
* 计算菜单位置,确保在可视区域内
*/
export function calculateMenuPosition(
event: MouseEvent,
menuWidth: number = 200,
menuHeight: number = 400
): ContextMenuPosition {
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const threshold = 10
let x = event.clientX
let y = event.clientY
// 水平边缘检测
if (x + menuWidth > viewportWidth - threshold) {
x = viewportWidth - menuWidth - threshold
} else if (x < threshold) {
x = threshold
}
// 垂直边缘检测
if (y + menuHeight > viewportHeight - threshold) {
y = viewportHeight - menuHeight - threshold
} else if (y < threshold) {
y = threshold
}
return { x, y }
}
/**
* 防抖函数
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout
return (...args: Parameters<T>) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => func(...args), delay)
}
}
/**
* 节流函数
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args)
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
}
}
}
/**
* 深度克隆菜单项(避免引用问题)
*/
export function cloneMenuItem(item: ContextMenuItem): ContextMenuItem {
return {
...item,
children: item.children ? item.children.map(cloneMenuItem) : undefined
}
}
/**
* 扁平化菜单项(用于搜索等功能)
*/
export function flattenMenuItems(items: ContextMenuItem[]): ContextMenuItem[] {
const result: ContextMenuItem[] = []
items.forEach((item) => {
result.push(item)
if (item.children && item.children.length > 0) {
result.push(...flattenMenuItems(item.children))
}
})
return result
}
/**
* 根据ID查找菜单项
*/
export function findMenuItemById(items: ContextMenuItem[], id: string): ContextMenuItem | null {
for (const item of items) {
if (item.id === id) {
return item
}
if (item.children && item.children.length > 0) {
const found = findMenuItemById(item.children, id)
if (found) {
return found
}
}
}
return null
}
/**
* 验证菜单项配置
*/
export function validateMenuItem(item: ContextMenuItem): boolean {
if (!item.id || typeof item.id !== 'string') {
console.warn('菜单项必须包含有效的id字段')
return false
}
if (!item.separator && (!item.label || typeof item.label !== 'string')) {
console.warn('非分隔线菜单项必须包含有效的label字段')
return false
}
if (item.children && !Array.isArray(item.children)) {
console.warn('children字段必须是数组')
return false
}
return true
}
/**
* 验证菜单项列表
*/
export function validateMenuItems(items: ContextMenuItem[]): boolean {
if (!Array.isArray(items)) {
console.warn('菜单项列表必须是数组')
return false
}
return items.every(validateMenuItem)
}
/**
* 过滤可见菜单项(移除禁用项和空分隔线)
*/
export function filterVisibleItems(items: ContextMenuItem[]): ContextMenuItem[] {
return items.filter((item) => {
if (item.disabled) return false
if (item.separator && !item.label) return true // 保留纯分隔线
return true
})
}
/**
* 菜单项排序工具
*/
export function sortMenuItems(
items: ContextMenuItem[],
compareFn?: (a: ContextMenuItem, b: ContextMenuItem) => number
): ContextMenuItem[] {
const sorted = [...items]
sorted.sort(
compareFn ||
((a, b) => {
if (!a.label || !b.label) return 0
return a.label.localeCompare(b.label)
})
)
// 递归排序子菜单
return sorted.map((item) => ({
...item,
children: item.children ? sortMenuItems(item.children, compareFn) : undefined
}))
}
/**
* 菜单项分组工具
*/
export function groupMenuItems(items: ContextMenuItem[], groupSize: number = 5): ContextMenuItem[] {
const result: ContextMenuItem[] = []
let currentGroup: ContextMenuItem[] = []
items.forEach((item, index) => {
currentGroup.push(item)
if (currentGroup.length >= groupSize || index === items.length - 1) {
if (currentGroup.length > 0) {
result.push(...currentGroup)
if (index < items.length - 1) {
result.push(createSeparator())
}
currentGroup = []
}
}
})
return result
}
/**
* 菜单项搜索工具
*/
export function searchMenuItems(items: ContextMenuItem[], searchText: string): ContextMenuItem[] {
if (!searchText.trim()) return items
const lowerSearchText = searchText.toLowerCase()
return items.filter((item) => {
if (item.separator) return true
if (!item.label) return false
const matches = item.label.toLowerCase().includes(lowerSearchText)
if (matches) return true
if (item.children && item.children.length > 0) {
const matchingChildren = searchMenuItems(item.children, searchText)
if (matchingChildren.length > 0) {
item.children = matchingChildren
return true
}
}
return false
})
}

View File

@@ -2,7 +2,7 @@
<div class="song-virtual-list">
<!-- 表头 -->
<div class="list-header">
<div v-if="showIndex" class="col-index"></div>
<div v-if="showIndex" class="col-index">#</div>
<div class="col-title">标题</div>
<div v-if="showAlbum" class="col-album">专辑</div>
<div class="col-like">喜欢</div>
@@ -19,6 +19,7 @@
class="song-item"
@mouseenter="hoveredSong = song.id || song.songmid"
@mouseleave="hoveredSong = null"
@contextmenu="handleContextMenu($event, song)"
>
<!-- 序号或播放状态图标 -->
<div v-if="showIndex" class="col-index">
@@ -33,7 +34,7 @@
</div>
<!-- 歌曲信息 -->
<div class="col-title" @dblclick="handleAddToPlaylist(song)">
<div class="col-title" @click="handleSongClick(song)">
<div v-if="song.img" class="song-cover">
<img :src="song.img" loading="lazy" alt="封面" />
</div>
@@ -90,12 +91,33 @@
</div>
</div>
</div>
<!-- 右键菜单 -->
<ContextMenu
v-model:visible="contextMenuVisible"
:position="contextMenuPosition"
:items="contextMenuItems"
@item-click="handleContextMenuItemClick"
@close="closeContextMenu"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { DownloadIcon } from 'tdesign-icons-vue-next'
import { ref, computed, onMounted, onUnmounted, nextTick, toRaw } from 'vue'
import {
DownloadIcon,
PlayCircleIcon,
AddIcon,
FolderIcon,
DeleteIcon
} from 'tdesign-icons-vue-next'
import ContextMenu from '../ContextMenu/ContextMenu.vue'
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
import type { ContextMenuItem, ContextMenuPosition } from '../ContextMenu/types'
import songListAPI from '@renderer/api/songList'
import type { SongList } from '@common/types/songList'
import { MessagePlugin } from 'tdesign-vue-next'
interface Song {
id?: number
@@ -120,6 +142,8 @@ interface Props {
showIndex?: boolean
showAlbum?: boolean
showDuration?: boolean
isLocalPlaylist?: boolean
playlistId?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -127,10 +151,19 @@ const props = withDefaults(defineProps<Props>(), {
isPlaying: false,
showIndex: true,
showAlbum: true,
showDuration: true
showDuration: true,
isLocalPlaylist: false,
playlistId: ''
})
const emit = defineEmits(['play', 'pause', 'addToPlaylist', 'download', 'scroll'])
const emit = defineEmits([
'play',
'pause',
'addToPlaylist',
'download',
'scroll',
'removeFromLocalPlaylist'
])
// 虚拟滚动相关状态
const scrollContainer = ref<HTMLElement>()
@@ -142,6 +175,19 @@ const scrollTop = ref(0)
const visibleStartIndex = ref(0)
const visibleEndIndex = ref(0)
// 点击防抖相关状态
let clickTimer: NodeJS.Timeout | null = null
let lastClickTime = 0
const doubleClickDelay = 300 // 300ms 内的第二次点击视为双击
// 右键菜单相关状态
const contextMenuVisible = ref(false)
const contextMenuPosition = ref<ContextMenuPosition>({ x: 0, y: 0 })
const contextMenuSong = ref<Song | null>(null)
// 歌单列表
const playlists = ref<SongList[]>([])
// 计算总高度
const totalHeight = computed(() => props.songs.length * itemHeight)
@@ -168,21 +214,9 @@ const visibleItems = computed(() => {
return props.songs.slice(visibleStartIndex.value, visibleEndIndex.value)
})
// 判断是否为当前歌曲
const isCurrentSong = (song: Song) => {
return (
props.currentSong &&
(song.id === props.currentSong.id || song.songmid === props.currentSong.songmid)
)
}
// 处理播放
const handlePlay = (song: Song) => {
if (isCurrentSong(song) && props.isPlaying) {
emit('pause')
} else {
emit('play', song)
}
emit('play', song)
}
// 处理添加到播放列表
@@ -190,6 +224,31 @@ const handleAddToPlaylist = (song: Song) => {
emit('addToPlaylist', song)
}
// 处理歌曲点击事件
const handleSongClick = (song: Song) => {
const currentTime = Date.now()
const timeDiff = currentTime - lastClickTime
// 清除之前的定时器
if (clickTimer) {
clearTimeout(clickTimer)
clickTimer = null
}
if (timeDiff < doubleClickDelay && timeDiff > 0) {
// 双击:立即执行播放操作
handlePlay(song)
lastClickTime = 0 // 重置时间,防止三击
} else {
// 单击:延迟执行添加到播放列表
lastClickTime = currentTime
clickTimer = setTimeout(() => {
handleAddToPlaylist(song)
clickTimer = null
}, doubleClickDelay)
}
}
// 格式化时长
const formatDuration = (duration: string | number) => {
if (!duration) return '--:--'
@@ -236,6 +295,131 @@ const onScroll = (event: Event) => {
emit('scroll', event)
}
// 右键菜单项配置
const contextMenuItems = computed((): ContextMenuItem[] => {
const baseItems: ContextMenuItem[] = [
createMenuItem('play', '播放', {
icon: PlayCircleIcon,
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
if (contextMenuSong.value) {
handlePlay(contextMenuSong.value)
}
}
}),
createMenuItem('addToPlaylist', '添加到播放列表', {
icon: AddIcon,
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
if (contextMenuSong.value) {
handleAddToPlaylist(contextMenuSong.value)
}
}
})
]
// 如果有歌单,添加"加入歌单"子菜单
if (playlists.value.length > 0) {
baseItems.push(
createMenuItem('addToSongList', '加入歌单', {
icon: FolderIcon,
children: playlists.value.map((playlist) =>
createMenuItem(`playlist_${playlist.id}`, playlist.name, {
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
if (contextMenuSong.value) {
handleAddToSongList(contextMenuSong.value, playlist)
}
}
})
)
})
)
}
baseItems.push(
createMenuItem('download', '下载', {
icon: DownloadIcon,
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
if (contextMenuSong.value) {
emit('download', contextMenuSong.value)
}
}
})
)
// 如果是本地歌单,添加"移出本地歌单"选项
if (props.isLocalPlaylist) {
// 添加分隔线
baseItems.push(createSeparator())
baseItems.push(
createMenuItem('removeFromLocalPlaylist', '移出当前歌单', {
icon: DeleteIcon,
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
if (contextMenuSong.value) {
emit('removeFromLocalPlaylist', contextMenuSong.value)
}
}
})
)
}
return baseItems
})
// 处理右键菜单
const handleContextMenu = (event: MouseEvent, song: Song) => {
event.preventDefault()
event.stopPropagation()
// 设置菜单数据
contextMenuSong.value = song
// 使用智能位置计算,确保菜单在可视区域内
contextMenuPosition.value = calculateMenuPosition(event, 240, 300)
// 直接显示菜单
contextMenuVisible.value = true
}
// 处理右键菜单项点击
const handleContextMenuItemClick = (_item: ContextMenuItem, _event: MouseEvent) => {
// 菜单项的 onClick 回调已经在 ContextMenuItem 组件中调用
// 这里不需要额外关闭菜单ContextMenu 组件会处理关闭逻辑
// 避免重复关闭导致菜单显示问题
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenuVisible.value = false
contextMenuSong.value = null
}
// 加载歌单列表
const loadPlaylists = async () => {
try {
const result = await songListAPI.getAll()
if (result.success) {
playlists.value = result.data || []
} else {
console.error('加载歌单失败:', result.error)
}
} catch (error) {
console.error('加载歌单失败:', error)
}
}
// 添加歌曲到歌单
const handleAddToSongList = async (song: Song, playlist: SongList) => {
try {
const result = await songListAPI.addSongs(playlist.id, [toRaw(song) as any])
if (result.success) {
MessagePlugin.success(`已将"${song.name}"添加到歌单"${playlist.name}"`)
} else {
MessagePlugin.error(result.error || '添加到歌单失败')
}
} catch (error) {
console.error('添加到歌单失败:', error)
MessagePlugin.error('添加到歌单失败')
}
}
onMounted(() => {
// 组件挂载后触发一次重新计算
nextTick(() => {
@@ -245,6 +429,17 @@ onMounted(() => {
onScroll(event)
}
})
// 加载歌单列表
loadPlaylists()
// 监听歌单变化事件
window.addEventListener('playlist-updated', loadPlaylists)
})
onUnmounted(() => {
// 清理事件监听器
window.removeEventListener('playlist-updated', loadPlaylists)
})
</script>
@@ -269,10 +464,10 @@ onMounted(() => {
display: grid;
grid-template-columns: 60px 1fr 200px 60px 80px;
padding: 8px 20px;
background: #fafafa;
border-bottom: 1px solid #e9e9e9;
background: var(--song-list-header-bg);
border-bottom: 1px solid var(--song-list-header-border);
font-size: 12px;
color: #999;
color: var(--song-list-header-text);
flex-shrink: 0;
height: 40px;
box-sizing: border-box;
@@ -286,7 +481,7 @@ onMounted(() => {
}
.col-title {
padding-left: 10px;
padding-left: 20px;
display: flex;
align-items: center;
}
@@ -314,7 +509,7 @@ onMounted(() => {
}
.virtual-scroll-container {
background: #fff;
background: var(--song-list-content-bg);
overflow-y: auto;
position: relative;
flex: 1;
@@ -335,24 +530,32 @@ onMounted(() => {
display: grid;
grid-template-columns: 60px 1fr 200px 60px 80px;
padding: 8px 20px;
border-bottom: 1px solid #f5f5f5;
border-bottom: 1px solid var(--song-list-item-border);
cursor: pointer;
transition: background-color 0.2s ease;
height: 64px;
&:hover,
&.is-hovered {
background: #f5f5f5;
background: var(--song-list-item-hover);
.col-title .song-info .song-title {
color: var(--song-list-title-hover);
}
.col-album .album-name {
color: var(--song-list-album-hover);
}
}
&.is-current {
background: #f0f7ff;
color: #507daf;
background: var(--song-list-item-current);
color: var(--song-list-btn-hover);
}
&.is-playing {
background: #e6f7ff;
color: #507daf;
background: var(--song-list-item-playing);
color: var(--song-list-btn-hover);
}
.col-index {
@@ -363,7 +566,7 @@ onMounted(() => {
.track-number {
font-size: 14px;
color: #999;
color: var(--song-list-track-number);
font-variant-numeric: tabular-nums;
width: 100%;
text-align: center;
@@ -373,7 +576,7 @@ onMounted(() => {
background: none;
border: none;
cursor: pointer;
color: #507daf;
color: var(--song-list-btn-hover);
font-size: 16px;
padding: 8px;
border-radius: 50%;
@@ -386,8 +589,8 @@ onMounted(() => {
font-style: none;
&:hover {
background: rgba(80, 125, 175, 0.1);
color: #3a5d8f;
background: var(--song-list-btn-bg-hover);
color: var(--song-list-btn-hover);
}
i {
@@ -429,21 +632,18 @@ onMounted(() => {
.song-title {
font-size: 14px;
color: #333;
color: var(--song-list-title-color);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
&:hover {
color: #507daf;
}
transition: color 0.2s ease;
}
.song-artist {
font-size: 12px;
color: #999;
color: var(--song-list-artist-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -453,8 +653,8 @@ onMounted(() => {
gap: 4px;
.quality-tag {
background: #fff7e6;
color: #fa8c16;
background: var(--song-list-quality-bg);
color: var(--song-list-quality-color);
padding: 1px 4px;
border-radius: 2px;
font-size: 10px;
@@ -472,16 +672,13 @@ onMounted(() => {
.album-name {
font-size: 12px;
color: #999;
color: var(--song-list-album-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
&:hover {
color: #507daf;
cursor: pointer;
}
transition: color 0.2s ease;
cursor: pointer;
}
}
@@ -495,7 +692,7 @@ onMounted(() => {
background: none;
border: none;
cursor: pointer;
color: #ccc;
color: var(--song-list-btn-color);
padding: 8px;
border-radius: 50%;
transition: all 0.2s;
@@ -506,8 +703,8 @@ onMounted(() => {
justify-content: center;
&:hover {
color: #507daf;
background: rgba(80, 125, 175, 0.1);
color: var(--song-list-btn-hover);
background: var(--song-list-btn-bg-hover);
}
i {
@@ -532,7 +729,7 @@ onMounted(() => {
.duration {
font-size: 12px;
color: #999;
color: var(--song-list-duration-color);
font-variant-numeric: tabular-nums;
min-width: 35px;
text-align: center;
@@ -548,7 +745,7 @@ onMounted(() => {
background: none;
border: none;
cursor: pointer;
color: #ccc;
color: var(--song-list-btn-color);
padding: 6px;
border-radius: 50%;
transition: all 0.2s;
@@ -559,8 +756,8 @@ onMounted(() => {
justify-content: center;
&:hover {
color: #507daf;
background: rgba(80, 125, 175, 0.1);
color: var(--song-list-btn-hover);
background: var(--song-list-btn-bg-hover);
}
i {

View File

@@ -16,7 +16,12 @@ import { shouldUseBlackText } from '@renderer/utils/color/contrastColor'
import { ControlAudioStore } from '@renderer/store/ControlAudio'
import { Fullscreen1Icon, FullscreenExit1Icon, ChevronDownIcon } from 'tdesign-icons-vue-next'
// 直接从包路径导入,避免 WebAssembly 导入问题
import { parseYrc, parseLrc, parseTTML } from '@applemusic-like-lyrics/lyric/pkg/amll_lyric.js'
import {
parseYrc,
parseLrc,
parseTTML,
parseQrc
} from '@applemusic-like-lyrics/lyric/pkg/amll_lyric.js'
import _ from 'lodash'
import { storeToRefs } from 'pinia'
@@ -151,7 +156,11 @@ watch(
if (lyricData.crlyric) {
// 使用逐字歌词
lyricText = lyricData.crlyric
parsedLyrics = parseYrc(lyricText)
if (source === 'tx') {
parsedLyrics = parseQrc(lyricText)
} else {
parsedLyrics = parseYrc(lyricText)
}
console.log(`使用${source}逐字歌词`, parsedLyrics)
} else if (lyricData.lyric) {
lyricText = lyricData.lyric
@@ -385,8 +394,10 @@ const lightMainColor = computed(() => {
.fullscreen-btn,
.putawayscreen-btn {
position: absolute;
top: 40px;
left: 40px;
-webkit-app-region: no-drag;
top: 25px;
left: 30px;
padding: 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
@@ -433,7 +444,7 @@ const lightMainColor = computed(() => {
}
.putawayscreen-btn {
left: 100px;
left: 90px;
}
.full-play {
@@ -459,8 +470,8 @@ const lightMainColor = computed(() => {
.top {
position: absolute;
width: calc(100% - 200px);
margin-left: 200px;
z-index: 1;
right: 0;
padding: 30px 30px;
padding-bottom: 10px;
}
@@ -682,8 +693,8 @@ const lightMainColor = computed(() => {
// bottom: max(2vw, 29px);
height: 200%;
transform: translateY(-25%);
height: 100%;
// transform: translateY(-25%);
* [class^='lyricMainLine'] {
font-weight: 600 !important;

View File

@@ -1011,7 +1011,7 @@ watch(showFullPlay, (val) => {
/* 进度条样式 */
.progress-bar-container {
width: 100%;
height: 2px;
height: 4px;
position: absolute;
// padding-top: 2px;
cursor: pointer;
@@ -1019,7 +1019,7 @@ watch(showFullPlay, (val) => {
&:has(.progress-handle.dragging, *:hover) {
// margin-bottom: 0;
height: 4px;
height: 6px;
}
.progress-bar {

View File

@@ -2,6 +2,8 @@
import { ref, computed, nextTick, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { MessagePlugin, Popconfirm } from 'tdesign-vue-next'
import { LocationIcon, DeleteIcon } from 'tdesign-icons-vue-next'
import type { SongList } from '@renderer/types/audio'
// Props
@@ -124,10 +126,20 @@ const currentOperatingSong = ref<any>(null)
// 统一的鼠标/触摸事件处理
const handleMouseDown = (event: MouseEvent, index: number, song: any) => {
// 检查是否点击了删除按钮或其子元素
const target = event.target as HTMLElement
if (target.closest('.song-remove')) {
return // 如果点击的是删除按钮,直接返回,不处理拖拽逻辑
}
handlePointerStart(event, index, song, false)
}
const handleTouchStart = (event: TouchEvent, index: number, song: any) => {
// 检查是否点击了删除按钮或其子元素
const target = event.target as HTMLElement
if (target.closest('.song-remove')) {
return // 如果点击的是删除按钮,直接返回,不处理拖拽逻辑
}
handlePointerStart(event, index, song, true)
}
@@ -403,6 +415,33 @@ onUnmounted(() => {
stopAutoScroll()
})
// 清空播放列表
const handleClearPlaylist = () => {
if (list.value.length === 0) {
MessagePlugin.warning('播放列表已为空')
return
}
localUserStore.clearList()
MessagePlugin.success('播放列表已清空')
}
// 定位到当前播放歌曲
const handleLocateCurrentSong = () => {
if (!props.currentSongId) {
MessagePlugin.info('当前没有正在播放的歌曲')
return
}
const currentSongExists = list.value.some((song) => song.songmid === props.currentSongId)
if (!currentSongExists) {
MessagePlugin.warning('当前播放的歌曲不在播放列表中')
return
}
scrollToCurrentSong()
}
// 暴露方法给父组件
defineExpose({
scrollToCurrentSong
@@ -474,6 +513,35 @@ defineExpose({
</div>
</TransitionGroup>
</div>
<!-- 底部操作按钮 -->
<div v-if="list.length > 0" class="playlist-footer">
<button
class="playlist-action-btn locate-btn"
:disabled="!currentSongId"
@click="handleLocateCurrentSong"
>
<LocationIcon size="16" />
<span>定位当前播放</span>
</button>
<Popconfirm
content="确定要清空播放列表吗?此操作不可撤销。"
:confirm-btn="{ content: '确认清空', theme: 'danger' }"
cancel-btn="取消"
placement="top"
theme="warning"
:popup-props="{
zIndex: 9999,
overlayStyle: { zIndex: 9998 }
}"
@confirm="handleClearPlaylist"
>
<button class="playlist-action-btn clear-btn">
<DeleteIcon size="16" />
<span>清空播放列表</span>
</button>
</Popconfirm>
</div>
</div>
</transition>
</template>
@@ -499,6 +567,7 @@ defineExpose({
flex-direction: column;
color: #333;
transform: translateX(0);
overflow: hidden;
/* 初始位置 */
}
@@ -527,6 +596,34 @@ defineExpose({
color: #ccc;
}
/* 全屏模式下的滚动条样式 - 只显示滑块 */
.playlist-container .playlist-content {
scrollbar-arrow-color: transparent;
scrollbar-width: thin;
scrollbar-color: rgba(91, 91, 91, 0.3) transparent;
}
.playlist-container.full-screen-mode .playlist-content {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar {
width: 8px;
}
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar-track {
background: transparent;
}
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.playlist-container.full-screen-mode .playlist-song:hover {
background-color: rgba(255, 255, 255, 0.1);
}
@@ -579,7 +676,7 @@ defineExpose({
.playlist-content {
flex: 1;
overflow-y: auto;
scrollbar-width: none;
// scrollbar-width: none;
margin: 10px 0;
padding: 0 8px;
}
@@ -797,6 +894,153 @@ defineExpose({
transform: translateX(100%);
}
/* 播放列表底部操作按钮 */
.playlist-footer {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
flex-shrink: 0;
// background: rgba(255, 255, 255, 0.3);
// backdrop-filter: blur(10px);
}
.playlist-container.full-screen-mode .playlist-footer {
border-top: 1px solid rgba(255, 255, 255, 0.1);
// background: rgba(0, 0, 0, 0.2);
}
.playlist-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(10px);
-webkit-app-region: no-drag;
}
.locate-btn {
background: rgba(35, 115, 206, 0.1);
color: #2373ce;
border: 1px solid rgba(35, 115, 206, 0.2);
}
.locate-btn:hover:not(:disabled) {
background: rgba(35, 115, 206, 0.15);
border-color: rgba(35, 115, 206, 0.3);
transform: translateY(-1px);
}
.locate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
color: #999;
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.1);
}
.clear-btn {
background: rgba(229, 72, 77, 0.1);
color: #e5484d;
border: 1px solid rgba(229, 72, 77, 0.2);
}
.clear-btn:hover {
background: rgba(229, 72, 77, 0.15);
border-color: rgba(229, 72, 77, 0.3);
transform: translateY(-1px);
}
// /* 全屏模式下的按钮样式 */
// .playlist-container.full-screen-mode .locate-btn {
// background: rgba(255, 255, 255, 0.1);
// color: #87ceeb;
// border-color: rgba(255, 255, 255, 0.2);
// }
// .playlist-container.full-screen-mode .locate-btn:hover:not(:disabled) {
// background: rgba(255, 255, 255, 0.15);
// border-color: rgba(255, 255, 255, 0.3);
// }
// .playlist-container.full-screen-mode .locate-btn:disabled {
// color: #666;
// background: rgba(255, 255, 255, 0.05);
// border-color: rgba(255, 255, 255, 0.1);
// }
// .playlist-container.full-screen-mode .clear-btn {
// background: rgba(255, 255, 255, 0.1);
// color: #ff6b6b;
// border-color: rgba(255, 255, 255, 0.2);
// }
// .playlist-container.full-screen-mode .clear-btn:hover {
// background: rgba(255, 255, 255, 0.15);
// border-color: rgba(255, 255, 255, 0.3);
// }
// /* Popconfirm 样式适配 */
// .playlist-container :deep(.t-popup__content) {
// background: rgba(255, 255, 255, 0.95) !important;
// backdrop-filter: blur(20px) !important;
// border: 1px solid rgba(0, 0, 0, 0.1) !important;
// box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
// border-radius: 8px !important;
// }
// .playlist-container.full-screen-mode :deep(.t-popup__content) {
// background: rgba(0, 0, 0, 0.85) !important;
// backdrop-filter: blur(20px) !important;
// border: 1px solid rgba(255, 255, 255, 0.2) !important;
// box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
// }
// .playlist-container.full-screen-mode :deep(.t-popconfirm__content) {
// color: #fff !important;
// }
// .playlist-container.full-screen-mode :deep(.t-button--theme-default) {
// background: rgba(255, 255, 255, 0.1) !important;
// border-color: rgba(255, 255, 255, 0.2) !important;
// color: #fff !important;
// }
// .playlist-container.full-screen-mode :deep(.t-button--theme-default:hover) {
// background: rgba(255, 255, 255, 0.15) !important;
// border-color: rgba(255, 255, 255, 0.3) !important;
// }
// .playlist-container.full-screen-mode :deep(.t-button--theme-danger) {
// background: rgba(229, 72, 77, 0.8) !important;
// border-color: rgba(229, 72, 77, 0.9) !important;
// }
// .playlist-container.full-screen-mode :deep(.t-button--theme-danger:hover) {
// background: rgba(229, 72, 77, 0.9) !important;
// border-color: rgba(229, 72, 77, 1) !important;
// }
// /* 普通模式下的按钮样式优化 */
// .playlist-container :deep(.t-button--theme-danger) {
// background: rgba(229, 72, 77, 0.1) !important;
// border-color: rgba(229, 72, 77, 0.3) !important;
// color: #e5484d !important;
// }
// .playlist-container :deep(.t-button--theme-danger:hover) {
// background: rgba(229, 72, 77, 0.15) !important;
// border-color: rgba(229, 72, 77, 0.4) !important;
// }
/* 响应式设计 */
@media (max-width: 768px) {
.playlist-container {
@@ -804,5 +1048,22 @@ defineExpose({
right: 0;
border-radius: 8px 8px 0 0;
}
.playlist-footer {
padding: 10px 12px;
gap: 6px;
}
.playlist-action-btn {
padding: 6px 10px;
font-size: 12px;
}
// /* 移动端 Popconfirm 适配 */
// :deep(.playlist-popconfirm .t-popup__content),
// :deep(.playlist-popconfirm-fullscreen .t-popup__content) {
// max-width: 280px;
// font-size: 14px;
// }
}
</style>

View File

@@ -1,32 +1,73 @@
<template>
<div class="page">
<TitleBarControls title="插件管理" :show-back="true" class="header"></TitleBarControls>
<!-- <TitleBarControls title="插件管理" :show-back="true" class="header"></TitleBarControls> -->
<div class="plugins-container">
<h2>插件管理</h2>
<div class="plugin-actions-hearder">
<h2>插件管理</h2>
<div class="plugin-actions">
<t-button theme="primary" @click="plugTypeDialog = true">
<template #icon><t-icon name="add" /></template> 添加插件
</t-button>
<t-dialog
:visible="plugTypeDialog"
:close-btn="true"
confirm-btn="确定"
cancel-btn="取消"
:on-confirm="addPlug"
:on-close="() => (plugTypeDialog = false)"
>
<template #header>请选择你的插件类别</template>
<template #body>
<t-radio-group v-model="type" variant="primary-filled" default-value="cr">
<t-radio-button value="cr">澜音插件</t-radio-button>
<t-radio-button value="lx">洛雪插件</t-radio-button>
</t-radio-group>
</template>
</t-dialog>
<t-button theme="default" @click="refreshPlugins">
<template #icon><t-icon name="refresh" /></template> 刷新
</t-button>
<div class="plugin-actions" style="flex-direction: row">
<t-button theme="primary" @click="plugTypeDialog = true">
<template #icon><t-icon name="add" /></template> 添加插件
</t-button>
<t-dialog
:visible="plugTypeDialog"
:close-btn="true"
confirm-btn="下一步"
cancel-btn="取消"
:on-confirm="showImportMethodDialog"
:on-close="() => (plugTypeDialog = false)"
>
<template #header>请选择你的插件类别</template>
<template #body>
<t-radio-group v-model="type" variant="primary-filled" default-value="cr">
<t-radio-button value="cr">澜音插件</t-radio-button>
<t-radio-button value="lx">洛雪插件</t-radio-button>
</t-radio-group>
</template>
</t-dialog>
<!-- 导入方式选择对话框 -->
<t-dialog
:visible="importMethodDialog"
:close-btn="true"
confirm-btn="确定"
cancel-btn="返回"
:on-confirm="handleImport"
:on-close="() => (importMethodDialog = false)"
:on-cancel="backToTypeSelection"
>
<template #header>选择导入方式</template>
<template #body>
<div class="import-method-container">
<t-radio-group
v-model="importMethod"
variant="primary-filled"
default-value="local"
>
<t-radio-button value="local">本地导入</t-radio-button>
<t-radio-button value="online">在线导入</t-radio-button>
</t-radio-group>
<div v-if="importMethod === 'online'" class="online-input-container">
<t-input
v-model="onlineUrl"
placeholder="请输入插件下载地址"
size="large"
style="margin-top: 15px"
/>
<p class="hint-text">支持 HTTP/HTTPS 链接插件文件应为 .js .zip 格式</p>
</div>
<div v-else class="local-hint-container">
<p class="hint-text">将从本地文件选择插件文件进行导入</p>
</div>
</div>
</template>
</t-dialog>
<t-button theme="default" @click="refreshPlugins">
<template #icon><t-icon name="refresh" /></template> 刷新
</t-button>
</div>
</div>
<div v-if="loading" class="loading">
@@ -179,7 +220,6 @@
</template>
<script setup lang="ts">
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
import { ref, onMounted, nextTick } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
@@ -215,7 +255,10 @@ const plugins = ref<Plugin[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const plugTypeDialog = ref(false)
const importMethodDialog = ref(false)
const type = ref<'lx' | 'cr'>('cr')
const importMethod = ref<'local' | 'online'>('local')
const onlineUrl = ref('')
//
const logDialogVisible = ref(false)
@@ -327,14 +370,52 @@ async function getPlugins() {
}
}
async function addPlug() {
try {
// API
plugTypeDialog.value = false
console.log(type.value)
const result = (await window.api.plugins.selectAndAddPlugin(type.value)) as ApiResult
//
function showImportMethodDialog() {
plugTypeDialog.value = false
importMethodDialog.value = true
}
//
//
function backToTypeSelection() {
importMethodDialog.value = false
plugTypeDialog.value = true
onlineUrl.value = '' // 线
}
//
async function handleImport() {
try {
importMethodDialog.value = false
let result: ApiResult
if (importMethod.value === 'local') {
// API
result = (await window.api.plugins.selectAndAddPlugin(type.value)) as ApiResult
} else {
// 线线API
if (!onlineUrl.value.trim()) {
MessagePlugin.warning('请输入插件下载地址')
importMethodDialog.value = true
return
}
// URL
try {
new URL(onlineUrl.value)
} catch {
MessagePlugin.warning('请输入有效的URL地址')
importMethodDialog.value = true
return
}
result = (await window.api.plugins.downloadAndAddPlugin(
onlineUrl.value,
type.value
)) as ApiResult
}
//
if (result && result.canceled) {
return
}
@@ -353,6 +434,9 @@ async function addPlug() {
MessagePlugin.success('插件安装成功!')
}
}
//
onlineUrl.value = ''
} catch (err: any) {
console.error('安装插件失败:', err)
MessagePlugin.error(`安装插件失败: ${err.message || '未知错误'}`)
@@ -513,35 +597,62 @@ onMounted(async () => {
.page {
display: flex;
flex-direction: column;
height: 100vh;
// height: 100%;
// max-height: 100vh;
background: var(--plugins-bg);
color: var(--plugins-text-primary);
overflow: hidden;
h2 {
font-weight: 600;
color: var(--plugins-text-primary);
margin: 0 0 16px 0;
}
}
.header {
-webkit-app-region: drag;
display: flex;
align-items: center;
background-color: #fff;
background-color: var(--plugins-header-bg);
padding: 1.5rem;
position: sticky;
z-index: 1000;
top: 0;
left: 0;
right: 0;
border-bottom: 1px solid var(--plugins-border);
flex-shrink: 0;
}
.plugins-container {
flex: 1;
padding: 20px;
padding: 24px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow: hidden;
min-height: 0;
background: var(--plugins-bg);
}
.plugin-actions-hearder {
margin-bottom: 24px;
flex-shrink: 0;
h2 {
margin-bottom: 16px;
font-size: 24px;
font-weight: 600;
color: var(--plugins-text-primary);
}
}
.plugin-actions {
display: flex;
gap: 10px;
margin-bottom: 20px;
gap: 12px;
margin-top: 16px;
}
.loading {
@@ -549,17 +660,25 @@ onMounted(async () => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
padding: 60px 0;
background: var(--plugins-container-bg);
border-radius: 12px;
margin: 20px 0;
}
.spinner {
width: 30px;
height: 30px;
border: 3px solid rgba(0, 0, 0, 0.1);
width: 32px;
height: 32px;
border: 3px solid var(--plugins-border);
border-radius: 50%;
border-top-color: var(--color-primary, #007bff);
border-top-color: var(--plugins-loading-spinner);
animation: spin 1s ease-in-out infinite;
margin-bottom: 10px;
margin-bottom: 16px;
}
.loading span {
color: var(--plugins-text-secondary);
font-size: 14px;
}
.error-state {
@@ -567,15 +686,26 @@ onMounted(async () => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
color: #666;
padding: 60px 0;
color: var(--plugins-text-secondary);
background: var(--plugins-container-bg);
border-radius: 12px;
margin: 20px 0;
}
.error-state p {
color: var(--plugins-text-primary);
font-size: 16px;
margin: 8px 0;
}
.error-message {
color: #dc3545;
margin-bottom: 15px;
color: var(--plugins-error-color);
margin-bottom: 20px;
text-align: center;
max-width: 80%;
font-size: 14px;
line-height: 1.5;
}
@keyframes spin {
@@ -589,103 +719,167 @@ onMounted(async () => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
color: #666;
padding: 60px 0;
color: var(--plugins-text-secondary);
background: var(--plugins-container-bg);
border-radius: 12px;
margin: 20px 0;
}
.empty-state p {
color: var(--plugins-text-primary);
font-size: 16px;
margin: 8px 0;
}
.hint {
font-size: 0.9em;
color: #999;
font-size: 14px;
color: var(--plugins-text-muted);
line-height: 1.5;
}
.plugin-list {
display: flex;
flex-direction: column;
gap: 15px;
gap: 16px;
flex: 1;
overflow-y: auto;
// overflow-y: auto;
// overflow-x: hidden;
min-height: 0;
max-height: 100%;
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--plugins-bg);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: var(--plugins-border);
border-radius: 3px;
&:hover {
background: var(--plugins-text-muted);
}
}
}
.plugin-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-radius: 8px;
background-color: var(--color-background-soft, #f8f9fa);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
align-items: flex-start;
padding: 20px;
border-radius: 12px;
background-color: var(--plugins-card-bg);
box-shadow: var(--plugins-card-shadow);
transition: all 0.3s ease;
border: 2px solid transparent;
position: relative;
// &:hover {
// box-shadow: var(--plugins-card-shadow-hover);
// transform: translateY(-2px);
// }
}
.plugin-item.selected {
background-color: #e8f5e8;
border: 2px solid #28a745;
background-color: var(--plugins-card-selected-bg);
border: 2px solid var(--plugins-card-selected-border);
// &::before {
// content: '';
// position: absolute;
// top: 0;
// left: 0;
// right: 0;
// height: 3px;
// background: linear-gradient(90deg, var(--plugins-card-selected-border), var(--td-brand-color));
// border-radius: 12px 12px 0 0;
// }
}
.plugin-info {
flex: 1;
margin-right: 20px;
}
.plugin-info h3 {
margin: 0 0 5px 0;
font-size: 1.1em;
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
color: var(--plugins-text-primary);
line-height: 1.4;
}
.version {
font-size: 0.8em;
color: #666;
font-weight: normal;
font-size: 12px;
color: var(--plugins-text-muted);
font-weight: 500;
background: var(--plugins-border);
padding: 2px 8px;
border-radius: 6px;
}
.current-tag {
background-color: var(--td-brand-color-5);
background: linear-gradient(135deg, var(--td-brand-color-5), var(--td-brand-color-6));
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75em;
font-weight: normal;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
box-shadow: 0 2px 4px rgba(0, 167, 77, 0.2);
}
.author {
margin: 0 0 5px 0;
font-size: 0.9em;
color: #666;
margin: 0 0 8px 0;
font-size: 14px;
color: var(--plugins-text-secondary);
}
.description {
margin: 0 0 8px 0;
font-size: 0.9em;
margin: 0 0 12px 0;
font-size: 14px;
color: var(--plugins-text-secondary);
line-height: 1.5;
max-width: 500px;
}
.plugin-sources {
display: flex;
flex-wrap: wrap;
gap: 5px;
gap: 8px;
align-items: center;
margin-top: 5px;
margin-top: 8px;
}
.source-label {
font-size: 0.85em;
color: #666;
font-size: 13px;
color: var(--plugins-text-muted);
font-weight: 500;
}
.source-tag {
background-color: var(--color-primary, #007bff);
background: linear-gradient(135deg, var(--td-brand-color-4), var(--td-brand-color-5));
color: white;
padding: 2px 8px;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8em;
font-size: 12px;
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 167, 77, 0.2);
}
.plugin-actions {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 120px;
}
/* 日志弹窗样式 */
@@ -693,15 +887,16 @@ onMounted(async () => {
height: 80vh;
.t-dialog {
background: #1e1e1e;
background: var(--plugins-console-bg);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
box-shadow: var(--plugins-dialog-shadow, 0 20px 60px rgba(0, 0, 0, 0.4));
overflow: hidden;
border: 1px solid var(--plugins-console-border);
}
.t-dialog__header {
background: #2d2d2d;
border-bottom: 1px solid #404040;
background: var(--plugins-console-header-bg);
border-bottom: 1px solid var(--plugins-console-border);
padding: 0;
border-radius: 12px 12px 0 0;
overflow: hidden;
@@ -709,12 +904,11 @@ onMounted(async () => {
.t-dialog__body {
padding: 0;
background: #1e1e1e;
border-left: 2px solid #272727;
border-right: 2px solid #272727;
border-bottom: 2px solid #272727;
background: var(--plugins-console-bg);
border-left: 2px solid var(--plugins-console-border);
border-right: 2px solid var(--plugins-console-border);
border-bottom: 2px solid var(--plugins-console-border);
border-radius: 0 0 12px 12px;
// max-height: 600px;
overflow: hidden;
}
}
@@ -723,7 +917,10 @@ onMounted(async () => {
display: flex;
align-items: center;
padding: 12px 20px;
background: linear-gradient(135deg, #2d2d2d 0%, #1e1e1e 100%);
background: var(
--plugins-dialog-header-bg,
linear-gradient(135deg, var(--plugins-console-header-bg) 0%, var(--plugins-console-bg) 100%)
);
min-height: 48px;
width: 100%;
@@ -731,14 +928,14 @@ onMounted(async () => {
display: flex;
align-items: center;
gap: 8px;
color: #ffffff;
color: var(--plugins-console-text);
font-weight: 600;
font-size: 14px;
flex: 1;
.iconfont {
font-size: 16px;
color: #00d4aa;
color: var(--plugins-console-prompt);
}
}
@@ -749,15 +946,15 @@ onMounted(async () => {
:deep(.t-button) {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #ffffff;
border: 1px solid var(--plugins-console-border);
color: var(--plugins-console-text);
font-size: 12px;
padding: 4px 12px;
height: auto;
&:hover {
background: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.3);
border-color: var(--plugins-console-prompt);
}
.t-icon {
@@ -779,7 +976,7 @@ onMounted(async () => {
transition: all 0.2s ease;
&.close {
background: #ff5f57;
background: var(--plugins-mac-close);
&:hover {
background: #ff3b30;
@@ -787,7 +984,7 @@ onMounted(async () => {
}
&.minimize {
background: #ffbd2e;
background: var(--plugins-mac-minimize);
&:hover {
background: #ff9500;
@@ -795,7 +992,7 @@ onMounted(async () => {
}
&.maximize {
background: #28ca42;
background: var(--plugins-mac-maximize);
&:hover {
background: #30d158;
@@ -806,21 +1003,20 @@ onMounted(async () => {
}
.console-container {
background: #1e1e1e;
color: #ffffff;
background: var(--plugins-console-bg);
color: var(--plugins-console-text);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.4;
height: calc(80vh - 64px - 48px);
// max-height: 500px;
min-height: 300px;
display: flex;
flex-direction: column;
}
.console-header {
background: #2d2d2d;
border-bottom: 1px solid #404040;
background: var(--plugins-console-header-bg);
border-bottom: 1px solid var(--plugins-console-border);
padding: 8px 16px;
flex-shrink: 0;
@@ -829,17 +1025,18 @@ onMounted(async () => {
align-items: center;
gap: 12px;
font-size: 12px;
.console-prompt {
color: var(--td-brand-color-5);
color: var(--plugins-console-prompt);
font-weight: bold;
}
.console-path {
color: #8a8a8a;
color: var(--plugins-console-path);
}
.console-time {
color: #666666;
color: var(--plugins-console-time);
margin-left: auto;
}
}
@@ -848,9 +1045,9 @@ onMounted(async () => {
.console-content {
flex: 1;
overflow-y: auto;
scrollbar-color: #555555 #2d2d2d;
scrollbar-color: var(--plugins-console-scrollbar-thumb) var(--plugins-console-scrollbar-track);
padding: 16px;
background: #1e1e1e;
background: var(--plugins-console-bg);
position: relative;
&.loading {
@@ -865,15 +1062,15 @@ onMounted(async () => {
}
&::-webkit-scrollbar-track {
background: #2d2d2d;
background: var(--plugins-console-scrollbar-track);
}
&::-webkit-scrollbar-thumb {
background: #555555;
background: var(--plugins-console-scrollbar-thumb);
border-radius: 4px;
&:hover {
background: #666666;
background: var(--plugins-console-scrollbar-thumb-hover);
}
}
}
@@ -883,13 +1080,13 @@ onMounted(async () => {
flex-direction: column;
align-items: center;
gap: 12px;
color: #8a8a8a;
color: var(--plugins-console-path);
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #404040;
border-top: 2px solid #00d4aa;
border: 2px solid var(--plugins-console-border);
border-top: 2px solid var(--plugins-console-prompt);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@@ -899,11 +1096,11 @@ onMounted(async () => {
display: flex;
align-items: center;
gap: 8px;
color: #ff6b6b;
color: var(--plugins-log-error);
padding: 12px;
background: rgba(255, 107, 107, 0.1);
border-radius: 6px;
border-left: 4px solid #ff6b6b;
border-left: 4px solid var(--plugins-log-error);
.error-icon {
font-size: 16px;
@@ -916,7 +1113,7 @@ onMounted(async () => {
align-items: center;
justify-content: center;
gap: 8px;
color: #8a8a8a;
color: var(--plugins-console-path);
height: 200px;
.empty-icon {
@@ -938,7 +1135,7 @@ onMounted(async () => {
}
.log-timestamp {
color: #666666;
color: var(--plugins-console-time);
font-size: 11px;
width: 80px;
text-align: center;
@@ -957,47 +1154,47 @@ onMounted(async () => {
/* 不同日志级别的颜色 */
&.log-error {
.log-content {
color: #ff6b6b;
color: var(--plugins-log-error);
}
.log-timestamp {
color: #ff6b6b;
color: var(--plugins-log-error);
}
}
&.log-warn {
.log-content {
color: #ffd93d;
color: var(--plugins-log-warn);
}
.log-timestamp {
color: #ffd93d;
color: var(--plugins-log-warn);
}
}
&.log-info {
.log-content {
color: #74b9ff;
color: var(--plugins-log-info);
}
.log-timestamp {
color: #74b9ff;
color: var(--plugins-log-info);
}
}
&.log-debug {
.log-content {
color: #a29bfe;
color: var(--plugins-log-debug);
}
.log-timestamp {
color: #a29bfe;
color: var(--plugins-log-debug);
}
}
&.log-default {
.log-content {
color: #ffffff;
color: var(--plugins-console-text);
}
}
}
@@ -1013,8 +1210,51 @@ onMounted(async () => {
}
}
/* 导入方式选择样式 */
.import-method-container {
padding: 16px 0;
}
.online-input-container {
margin-top: 16px;
}
.hint-text {
font-size: 13px;
color: var(--plugins-text-muted);
margin-top: 8px;
line-height: 1.5;
}
.local-hint-container {
margin-top: 16px;
padding: 12px;
background: var(--plugins-border);
border-radius: 8px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.plugins-container {
padding: 16px;
}
.plugin-item {
flex-direction: column;
align-items: stretch;
gap: 16px;
.plugin-info {
margin-right: 0;
}
.plugin-actions {
flex-direction: row;
justify-content: flex-end;
min-width: auto;
}
}
:deep(.log-dialog) {
.t-dialog {
width: 95% !important;

View File

@@ -1,54 +1,35 @@
<!--
主题选择器组件 - 支持暗色模式切换
-->
<template>
<div class="theme-selector">
<div class="theme-selector-trigger" @click="toggleDropdown">
<div class="current-theme">
<div class="theme-color-preview" :style="{ backgroundColor: currentThemeColor }"></div>
<span class="theme-name">{{ currentThemeName }}</span>
</div>
<svg
class="dropdown-icon"
:class="{ rotated: isDropdownOpen }"
viewBox="0 0 24 24"
width="16"
height="16"
<div class="theme-options">
<div
v-for="theme in themes"
:key="theme.name"
class="theme-option"
:class="{ active: currentTheme === theme.name }"
@click="selectTheme(theme.name)"
>
<path d="M7 10l5 5 5-5z" fill="currentColor" />
</svg>
<div class="theme-preview" :style="{ backgroundColor: theme.color }"></div>
<span class="theme-label">{{ theme.label }}</span>
</div>
</div>
<transition name="dropdown">
<div v-if="isDropdownOpen" class="theme-dropdown">
<div
v-for="theme in themes"
:key="theme.name"
class="theme-option"
:class="{ active: currentTheme === theme.name }"
@click="selectTheme(theme.name)"
>
<div class="theme-color-dot" :style="{ backgroundColor: theme.color }"></div>
<span class="theme-label">{{ theme.label }}</span>
<svg
v-if="currentTheme === theme.name"
class="check-icon"
viewBox="0 0 24 24"
width="16"
height="16"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" fill="currentColor" />
</svg>
</div>
</div>
</transition>
<div class="dark-mode-toggle">
<label class="toggle-switch">
<input type="checkbox" :checked="isDarkMode" @change="toggleDarkMode" />
<span class="slider"></span>
<span class="toggle-label">暗色模式</span>
</label>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const isDropdownOpen = ref(false)
const currentTheme = ref('default')
// 基于现有主题文件的配置
// 主题配置
const themes = [
{ name: 'default', label: '默认', color: '#2ba55b' },
{ name: 'pink', label: '粉色', color: '#fc5e7e' },
@@ -57,209 +38,203 @@ const themes = [
{ name: 'orange', label: '橙色', color: '#fb9458' }
]
const loadSavedTheme = () => {
const savedTheme = localStorage.getItem('selected-theme')
if (savedTheme && themes.some((t) => t.name === savedTheme)) {
currentTheme.value = savedTheme
applyTheme(savedTheme)
}
}
const currentTheme = ref('default')
const isDarkMode = ref(false)
const applyTheme = (themeName) => {
// 应用主题
const applyTheme = (themeName: string, darkMode: boolean = false) => {
const documentElement = document.documentElement
// 移除之前的主题
// 移除之前的主题属性
documentElement.removeAttribute('theme-mode')
documentElement.removeAttribute('data-theme')
// 应用主题(如果不是默认主题)
// 应用主题色彩
if (themeName !== 'default') {
documentElement.setAttribute('theme-mode', themeName)
}
// 保存到本地存储
localStorage.setItem('selected-theme', themeName)
}
const currentThemeColor = computed(() => {
const theme = themes.find((t) => t.name === currentTheme.value)
return theme ? theme.color : '#2ba55b'
})
const currentThemeName = computed(() => {
const theme = themes.find((t) => t.name === currentTheme.value)
return theme ? theme.label : '默认'
})
const toggleDropdown = () => {
isDropdownOpen.value = !isDropdownOpen.value
}
const selectTheme = (themeName) => {
if (themeName === currentTheme.value) {
isDropdownOpen.value = false
return
// 应用明暗模式
if (darkMode) {
documentElement.setAttribute('data-theme', 'dark')
} else {
documentElement.setAttribute('data-theme', 'light')
}
currentTheme.value = themeName
applyTheme(themeName)
isDropdownOpen.value = false
// 保存到本地存储
localStorage.setItem('selected-theme', themeName)
localStorage.setItem('dark-mode', darkMode.toString())
}
const handleClickOutside = (event) => {
const themeSelector = event.target.closest('.theme-selector')
if (!themeSelector) {
isDropdownOpen.value = false
// 选择主题
const selectTheme = (themeName: string) => {
currentTheme.value = themeName
applyTheme(themeName, isDarkMode.value)
}
// 切换暗色模式
const toggleDarkMode = () => {
isDarkMode.value = !isDarkMode.value
applyTheme(currentTheme.value, isDarkMode.value)
}
// 检测系统主题偏好
const detectSystemTheme = () => {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return true
}
return false
}
// 加载保存的设置
const loadSavedSettings = () => {
const savedTheme = localStorage.getItem('selected-theme')
const savedDarkMode = localStorage.getItem('dark-mode')
if (savedTheme && themes.some((t) => t.name === savedTheme)) {
currentTheme.value = savedTheme
}
if (savedDarkMode !== null) {
isDarkMode.value = savedDarkMode === 'true'
} else {
// 如果没有保存的设置,检测系统偏好
isDarkMode.value = detectSystemTheme()
}
applyTheme(currentTheme.value, isDarkMode.value)
}
// 监听系统主题变化
const setupSystemThemeListener = () => {
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', (e) => {
const savedDarkMode = localStorage.getItem('dark-mode')
// 如果用户没有手动设置暗色模式,则跟随系统主题
if (savedDarkMode === null) {
isDarkMode.value = e.matches
applyTheme(currentTheme.value, isDarkMode.value)
}
})
}
}
onMounted(() => {
loadSavedTheme()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
setupSystemThemeListener()
loadSavedSettings()
})
</script>
<style scoped>
.theme-selector {
position: relative;
display: inline-block;
width: 200px;
padding: 16px;
background: var(--td-bg-color-container);
border-radius: var(--td-radius-medium);
border: 1px solid var(--td-border-level-1-color);
}
.theme-selector-trigger {
.theme-options {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--td-bg-color-container, #ffffff);
border: 1px solid var(--td-component-border, #e2e8f0);
border-radius: var(--td-radius-medium, 6px);
cursor: pointer;
transition: all 0.2s ease;
min-width: 120px;
}
.theme-selector-trigger:hover {
background: var(--td-bg-color-container-hover, #f8fafc);
border-color: var(--td-brand-color-hover, #cbd5e1);
}
.current-theme {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.theme-color-preview {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--td-bg-color-container, #ffffff);
box-shadow: 0 0 0 1px var(--td-component-border, #e2e8f0);
}
.theme-name {
font-size: 14px;
color: var(--td-text-color-primary, #1e293b);
font-weight: 500;
}
.dropdown-icon {
color: var(--td-text-color-secondary, #64748b);
transition: transform 0.2s ease;
}
.dropdown-icon.rotated {
transform: rotate(180deg);
}
.theme-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: var(--td-bg-color-container, #ffffff);
border: 1px solid var(--td-component-border, #e2e8f0);
border-radius: var(--td-radius-medium, 6px);
box-shadow: var(
--td-shadow-2,
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06)
);
z-index: 1000;
overflow: hidden;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.theme-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 12px 16px;
gap: 8px;
padding: 12px;
border-radius: var(--td-radius-medium);
cursor: pointer;
transition: background-color 0.2s ease;
transition: all 0.2s ease;
border: 2px solid transparent;
}
.theme-option:hover {
background: var(--td-bg-color-container-hover, #f8fafc);
background: var(--td-bg-color-container-hover);
}
.theme-option.active {
background: var(--td-brand-color-light, #eff6ff);
color: var(--td-text-color-primary, #1e293b);
border-color: var(--td-brand-color);
background: var(--td-brand-color-light);
}
.theme-color-dot {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--td-bg-color-container, #ffffff);
box-shadow: 0 0 0 1px var(--td-component-border, #e2e8f0);
.theme-preview {
width: 32px;
height: 32px;
border-radius: var(--td-radius-circle);
border: 2px solid var(--td-border-level-1-color);
}
.theme-label {
flex: 1;
font-size: 14px;
color: var(--td-text-color-primary, #1e293b);
font-size: var(--td-font-size-body-small);
color: var(--td-text-color-primary);
font-weight: 500;
}
.check-icon {
color: var(--td-brand-color, #3b82f6);
.dark-mode-toggle {
padding-top: 16px;
border-top: 1px solid var(--td-border-level-1-color);
}
/* 下拉动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
.toggle-switch {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.dropdown-enter-from {
opacity: 0;
transform: translateY(-8px) scale(0.95);
.toggle-switch input {
display: none;
}
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px) scale(0.95);
.slider {
position: relative;
width: 44px;
height: 24px;
background: var(--td-bg-color-component);
border-radius: 12px;
transition: background-color 0.2s ease;
border: 1px solid var(--td-border-level-1-color);
}
/* 响应式设计 */
@media (max-width: 640px) {
.theme-selector-trigger {
min-width: 100px;
padding: 6px 10px;
}
.slider::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: var(--td-bg-color-container);
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: var(--td-shadow-1);
}
.theme-name {
font-size: 13px;
}
.toggle-switch input:checked + .slider {
background: var(--td-brand-color);
}
.theme-option {
padding: 10px 14px;
}
.toggle-switch input:checked + .slider::before {
transform: translateX(20px);
}
.toggle-label {
font-size: var(--td-font-size-body-medium);
color: var(--td-text-color-primary);
font-weight: 500;
}
/* 暗色模式下的特殊样式 */
:root[theme-mode='dark'] .theme-selector {
background: var(--td-bg-color-container);
border-color: var(--td-border-level-1-color);
}
:root[theme-mode='dark'] .theme-preview {
border-color: var(--td-border-level-2-color);
}
</style>

View File

@@ -9,7 +9,7 @@ const props = withDefaults(defineProps<Props>(), {
showSettings: true,
showBack: false,
title: '',
color: 'black'
color: 'var(--titlebar-btn-text-color)'
})
const Store = LocalUserDetailStore()
const { userInfo } = storeToRefs(Store)
@@ -202,7 +202,7 @@ const handleBack = (): void => {
}
&:hover .iconfont {
color: #111827;
color: v-bind(color) !important;
}
}
@@ -222,12 +222,20 @@ const handleBack = (): void => {
-webkit-app-region: no-drag;
margin-right: 0.5rem;
&:hover {
background-color: #f3f4f6;
background-color: var(--titlebar-btn-hover-bg);
}
}
}
.title-box {
flex: 1;
p {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--settings-text-primary, v-bind(color));
line-height: 1.2;
}
}
}
@@ -235,7 +243,7 @@ const handleBack = (): void => {
margin-right: 0.5rem;
&:hover {
background-color: #f3f4f6;
background-color: var(--titlebar-btn-hover-bg);
}
}
@@ -251,23 +259,23 @@ const handleBack = (): void => {
}
&:hover {
background-color: #f3f4f6;
background-color: var(--titlebar-btn-hover-bg);
}
}
.minimize-btn:hover {
background-color: #f3f4f6;
background-color: var(--titlebar-btn-hover-bg);
}
.maximize-btn:hover {
background-color: #f3f4f6;
background-color: var(--titlebar-btn-hover-bg);
}
.close-btn:hover {
background-color: #fee2e2;
background-color: var(--titlebar-close-hover-bg);
.iconfont {
color: #dc2626;
color: v-bind(color) !important;
}
}
}

View File

@@ -11,3 +11,53 @@ const versions = reactive({ ...window.electron.process.versions })
<li class="node-version">Node v{{ versions.node }}</li>
</ul>
</template>
<style lang="scss" scoped>
.versions {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--settings-tech-item-bg, #f8fafc);
border-radius: 0.5rem;
border: 1px solid var(--settings-tech-item-border, #e2e8f0);
color: var(--settings-text-primary, #1e293b);
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
background: var(--settings-nav-hover-bg, #f1f5f9);
}
}
.electron-version {
&::before {
content: '⚡';
margin-right: 0.5rem;
}
}
.chrome-version {
&::before {
content: '🌐';
margin-right: 0.5rem;
}
}
.node-version {
&::before {
content: '🟢';
margin-right: 0.5rem;
}
}
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
import { SearchIcon } from 'tdesign-icons-vue-next'
import { onMounted, ref, watchEffect } from 'vue'
import { onMounted, ref, watchEffect, computed } from 'vue'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { useRouter } from 'vue-router'
import { searchValue } from '@renderer/store/search'
@@ -45,6 +45,72 @@ const menuList: MenuItem[] = [
]
const menuActive = ref(0)
const router = useRouter()
const source_list_show = ref(false)
// 检查是否有插件数据
const hasPluginData = computed(() => {
const LocalUserDetail = LocalUserDetailStore()
return !!(
LocalUserDetail.userInfo.pluginId &&
LocalUserDetail.userInfo.supportedSources &&
Object.keys(LocalUserDetail.userInfo.supportedSources).length > 0
)
})
// 音源名称映射
const sourceNames = {
wy: '网易云音乐',
kg: '酷狗音乐',
mg: '咪咕音乐',
tx: 'QQ音乐',
kw: '酷我音乐'
}
// 动态音源列表数据基于supportedSources
const sourceList = computed(() => {
const LocalUserDetail = LocalUserDetailStore()
const supportedSources = LocalUserDetail.userInfo.supportedSources
if (!supportedSources) return []
return Object.keys(supportedSources).map((key) => ({
key,
name: sourceNames[key] || key,
icon: sourceicon[key] || key
}))
})
// 切换音源选择器显示状态
const toggleSourceList = () => {
source_list_show.value = !source_list_show.value
}
// 选择音源
const selectSource = (sourceKey: string) => {
if (!hasPluginData.value) return
const LocalUserDetail = LocalUserDetailStore()
LocalUserDetail.userInfo.selectSources = sourceKey
// 自动选择该音源的最高音质
const sourceDetail = LocalUserDetail.userInfo.supportedSources?.[sourceKey]
if (sourceDetail && sourceDetail.qualitys && sourceDetail.qualitys.length > 0) {
const currentQuality = LocalUserDetail.userInfo.selectQuality
if (!currentQuality || !sourceDetail.qualitys.includes(currentQuality)) {
LocalUserDetail.userInfo.selectQuality =
sourceDetail.qualitys[sourceDetail.qualitys.length - 1]
}
}
// 更新音源图标
source.value = sourceicon[sourceKey]
source_list_show.value = false
}
// 点击遮罩关闭音源选择器
const handleMaskClick = () => {
source_list_show.value = false
}
const handleClick = (index: number): void => {
menuActive.value = index
@@ -98,7 +164,7 @@ const handleKeyDown = () => {
<i class="iconfont icon-music"></i>
</div>
<p class="app-title">
<span style="color: #000; font-weight: 800">Ceru Music</span>
<span style="font-weight: 800">Ceru Music</span>
</p>
</div>
@@ -132,9 +198,34 @@ const handleKeyDown = () => {
<div class="search-container">
<div class="search-input">
<svg class="icon" aria-hidden="true">
<use :xlink:href="`#icon-${source}`"></use>
</svg>
<div class="source-selector" @click="toggleSourceList">
<svg class="icon" aria-hidden="true">
<use :xlink:href="`#icon-${source}`"></use>
</svg>
</div>
<!-- 透明遮罩 -->
<transition name="mask">
<div v-if="source_list_show" class="source-mask" @click="handleMaskClick"></div>
</transition>
<!-- 音源选择列表 -->
<transition name="source">
<div v-if="source_list_show" class="source-list">
<div class="items">
<div
v-for="item in sourceList"
:key="item.key"
class="source-item"
:class="{ active: source === item.icon }"
@click="selectSource(item.key)"
>
<svg class="source-icon" aria-hidden="true">
<use :xlink:href="`#icon-${item.icon}`"></use>
</svg>
<span class="source-name">{{ item.name }}</span>
</div>
</div>
</div>
</transition>
<t-input
v-model="keyword"
placeholder="搜索音乐、歌手"
@@ -149,13 +240,13 @@ const handleKeyDown = () => {
style="display: flex; align-items: center; justify-content: center"
@click="handleSearch"
>
<SearchIcon style="font-size: 16px; color: #000" />
<SearchIcon style="font-size: 16px; color: var(--td-text-color-primary)" />
</t-button>
</template>
</t-input>
</div>
<TitleBarControls :color="'#000'"></TitleBarControls>
<TitleBarControls></TitleBarControls>
</div>
</div>
@@ -173,6 +264,34 @@ const handleKeyDown = () => {
position: absolute;
width: 100%;
}
// 音源选择器过渡动画
.source-enter-active,
.source-leave-active {
transition: all 0.2s ease;
}
.source-enter-from {
opacity: 0;
transform: translateY(-0.5rem);
}
.source-leave-to {
opacity: 0;
transform: translateY(-0.5rem);
}
// 遮罩过渡动画
.mask-enter-active,
.mask-leave-active {
transition: opacity 0.2s ease;
}
.mask-enter-from,
.mask-leave-to {
opacity: 0;
}
.home-container {
height: calc(100vh - var(--play-bottom-height));
overflow-y: hidden;
@@ -186,8 +305,12 @@ const handleKeyDown = () => {
.sidebar {
width: 15rem;
background-image: linear-gradient(to bottom, var(--td-brand-color-4) -140vh, #ffffff 30vh);
border-right: 0.0625rem solid #e5e7eb;
background-image: linear-gradient(
to bottom,
var(--td-brand-color-4) -140vh,
var(--td-bg-color-container) 30vh
);
border-right: 0.0625rem solid var(--td-border-level-1-color);
flex-shrink: 0;
.sidebar-content {
@@ -211,18 +334,17 @@ const handleKeyDown = () => {
.iconfont {
font-size: 1.25rem;
color: white;
color: #fff;
}
}
.app-title {
font-weight: 500;
font-size: 1.125rem;
color: #111827;
color: var(--td-text-color-primary);
span {
font-weight: 500;
color: #b8f0cc;
}
}
}
@@ -244,22 +366,30 @@ const handleKeyDown = () => {
margin-right: 0.75rem;
font-size: 1rem;
}
div {
display: none !important;
visibility: hidden;
}
&.active {
background-color: var(--td-brand-color-4);
color: rgb(255, 255, 255);
color: var(--td-text-color-anti);
&:active {
background-color: var(--td-brand-color-5) !important;
}
&:hover {
background-color: var(--td-brand-color-5);
background-color: var(--td-brand-color-5) !important;
}
}
&:not(.active) {
color: #6b7280;
color: var(--hover-nav-text);
// color: var(--td-text-color-secondary);
&:hover {
color: #111827;
background-color: #f3f4f6;
color: var(--hover-nav-text-hover);
background-color: var(--hover-nav-color);
}
}
}
@@ -274,7 +404,11 @@ const handleKeyDown = () => {
.content {
padding: 0;
background-image: linear-gradient(to bottom, var(--td-brand-color-4) -110vh, #ffffff 15vh);
background-image: linear-gradient(
to bottom,
var(--td-brand-color-4) -110vh,
var(--td-bg-color-container) 15vh
);
display: flex;
flex: 1;
@@ -296,11 +430,11 @@ const handleKeyDown = () => {
.iconfont {
font-size: 1rem;
color: #3d4043;
color: var(--home-nav-btn-color);
}
&:hover .iconfont {
color: #111827;
color: var(--home-nav-btn-hover);
}
}
@@ -319,27 +453,141 @@ const handleKeyDown = () => {
width: min(18.75rem, 400px);
margin-right: 0.5rem;
border-radius: 1.25rem !important;
background-color: #fff;
overflow: hidden;
background-color: var(--td-bg-color-container);
overflow: visible;
position: relative;
&:has(input:focus) {
width: max(18.75rem, 400px);
}
.source-selector {
display: flex;
align-items: center;
cursor: pointer;
box-sizing: border-box;
padding: 0.25rem;
aspect-ratio: 1 / 1;
border-radius: 999px;
overflow: hidden;
transition: background-color 0.2s;
&:hover {
background-color: var(--home-source-selector-hover);
}
.source-arrow {
margin-left: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
transition: transform 0.2s;
&.rotated {
transform: rotate(180deg);
}
}
}
.source-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999999;
background: transparent;
cursor: pointer;
}
.source-list {
position: absolute;
top: 100%;
left: 0;
z-index: 10000000;
background: var(--home-source-list-bg);
border: 1px solid var(--home-source-list-border);
border-radius: 0.5rem;
box-shadow: var(--home-source-list-shadow);
min-width: 10rem;
overflow-y: hidden;
margin-top: 0.25rem;
padding: 0.5em;
.items {
max-height: 12rem;
overflow-y: auto;
// 隐藏滚动条
&::-webkit-scrollbar {
width: 0;
height: 0;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: transparent;
}
// Firefox 隐藏滚动条
scrollbar-width: none;
}
.source-item {
border-radius: 5px;
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
margin-bottom: 5px;
cursor: pointer;
transition: background-color 0.2s;
&:last-child {
margin: 0;
}
&:hover {
background-color: var(--home-source-item-hover);
}
&.active {
background-color: var(--td-brand-color-1);
color: var(--td-brand-color);
}
.source-icon {
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
}
.source-name {
font-size: 0.875rem;
white-space: nowrap;
}
}
}
}
:deep(.t-input) {
border-radius: 0rem !important;
border: none;
box-shadow: none;
&.t-input--suffix {
padding-right: 0 !important;
}
}
.settings-btn {
.iconfont {
font-size: 1rem;
color: #6b7280;
color: var(--td-text-color-secondary);
}
&:hover .iconfont {
color: #111827;
color: var(--td-text-color-primary);
}
}
}
@@ -358,23 +606,23 @@ const handleKeyDown = () => {
}
&::-webkit-scrollbar-track {
background: #f1f5f9;
background: var(--home-scrollbar-track);
border-radius: 0.1875rem;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
background: var(--home-scrollbar-thumb);
border-radius: 0.1875rem;
transition: background-color 0.2s ease;
&:hover {
background: #94a3b8;
background: var(--home-scrollbar-thumb-hover);
}
}
/* Firefox 滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
scrollbar-color: var(--home-scrollbar-color);
}
}
</style>

View File

@@ -46,12 +46,12 @@ const routes: RouteRecordRaw[] = [
transitionOut: 'animate__fadeOut'
},
component: () => import('@renderer/views/settings/index.vue')
},
{
path: '/plugins',
name: 'plugins',
component: () => import('@renderer/views/settings/plugins.vue')
}
// {
// path: '/plugins',
// name: 'plugins',
// component: () => import('@renderer/views/settings/plugins.vue')
// }
]
function setAnimate(routerObj: RouteRecordRaw[]) {
for (let i = 0; i < routerObj.length; i++) {

View File

@@ -1,12 +1,19 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface TagWriteOptions {
basicInfo: boolean // 基础信息(标题、艺术家、专辑)
cover: boolean // 封面
lyrics: boolean // 普通歌词
}
export interface SettingsState {
showFloatBall: boolean
directories?: {
cacheDir: string
downloadDir: string
}
tagWriteOptions?: TagWriteOptions
}
export const useSettingsStore = defineStore('settings', () => {
@@ -23,7 +30,12 @@ export const useSettingsStore = defineStore('settings', () => {
// 默认设置
return {
showFloatBall: true
showFloatBall: true,
tagWriteOptions: {
basicInfo: true,
cover: true,
lyrics: true
}
}
}

View File

@@ -1,6 +1,7 @@
import { NotifyPlugin, MessagePlugin } from 'tdesign-vue-next'
import { NotifyPlugin, MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { toRaw } from 'vue'
import { useSettingsStore } from '@renderer/store/Settings'
import { toRaw, h } from 'vue'
interface MusicItem {
singer: string
@@ -12,7 +13,7 @@ interface MusicItem {
songmid: number
img: string
lrc: null | string
types: string[]
types: Array<{ type: string; size: string }>
_types: Record<string, any>
typeUrl: Record<string, any>
}
@@ -29,26 +30,236 @@ const qualityMap: Record<string, string> = {
}
const qualityKey = Object.keys(qualityMap)
// 创建音质选择弹窗
function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<string | null> {
return new Promise((resolve) => {
const LocalUserDetail = LocalUserDetailStore()
// 获取歌曲支持的音质列表
const availableQualities = songInfo.types || []
// 检查用户设置的音质是否为特殊音质
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(userQuality)
// 如果是特殊音质且用户支持,添加到选项中(不管歌曲是否有这个音质)
const qualityOptions = [...availableQualities]
if (isSpecialQuality && LocalUserDetail.userSource.quality === userQuality) {
const hasSpecialQuality = availableQualities.some((q) => q.type === userQuality)
if (!hasSpecialQuality) {
qualityOptions.push({ type: userQuality, size: '源站无法得知此音质的文件大小' })
}
}
// 按音质优先级排序
qualityOptions.sort((a, b) => {
const aIndex = qualityKey.indexOf(a.type)
const bIndex = qualityKey.indexOf(b.type)
return bIndex - aIndex // 降序排列,高音质在前
})
const dialog = DialogPlugin.confirm({
header: '选择下载音质(可滚动)',
width: 400,
placement: 'center',
body: () =>
h(
'div',
{
class: 'quality-selector'
},
[
h(
'div',
{
class: 'quality-list',
style: {
maxHeight:
'max(calc(calc(70vh - 2 * var(--td-comp-paddingTB-xxl)) - 24px - 32px - 32px),100px)',
overflow: 'auto',
scrollbarWidth: 'none',
msOverflowStyle: 'none'
}
},
qualityOptions.map((quality) =>
h(
'div',
{
key: quality.type,
class: 'quality-item',
style: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
margin: '8px 0',
border: '1px solid #e7e7e7',
borderRadius: '6px',
cursor: 'pointer',
transition: 'all 0.2s ease',
backgroundColor: quality.type === userQuality ? '#e6f7ff' : '#fff'
},
onClick: () => {
dialog.destroy()
resolve(quality.type)
},
onMouseenter: (e: MouseEvent) => {
const target = e.target as HTMLElement
target.style.backgroundColor = '#f0f9ff'
target.style.borderColor = '#1890ff'
},
onMouseleave: (e: MouseEvent) => {
const target = e.target as HTMLElement
target.style.backgroundColor =
quality.type === userQuality ? '#e6f7ff' : '#fff'
target.style.borderColor = '#e7e7e7'
}
},
[
h('div', { class: 'quality-info' }, [
h(
'div',
{
style: {
fontWeight: '500',
fontSize: '14px',
color: quality.type === userQuality ? '#1890ff' : '#333'
}
},
qualityMap[quality.type] || quality.type
),
h(
'div',
{
style: {
fontSize: '12px',
color: '#999',
marginTop: '2px'
}
},
quality.type.toUpperCase()
)
]),
h(
'div',
{
class: 'quality-size',
style: {
fontSize: '12px',
color: '#666',
fontWeight: '500'
}
},
quality.size
)
]
)
)
)
]
),
confirmBtn: null,
cancelBtn: null,
footer: false
})
})
}
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
try {
const LocalUserDetail = LocalUserDetailStore()
let quality = LocalUserDetail.userSource.quality as string
if (
qualityKey.indexOf(quality) >
qualityKey.indexOf(
(songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
)
) {
quality = (songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
const userQuality = LocalUserDetail.userSource.quality as string
const settingsStore = useSettingsStore()
// 获取歌词
const { crlyric, lyric } = await window.api.music.requestSdk('getLyric', {
source: toRaw(songInfo.source),
songInfo: toRaw(songInfo) as any
})
console.log(songInfo)
songInfo.lrc = crlyric && songInfo.source !== 'tx' ? crlyric : lyric
// 显示音质选择弹窗
const selectedQuality = await createQualityDialog(songInfo, userQuality)
// 如果用户取消选择,直接返回
if (!selectedQuality) {
return
}
let quality = selectedQuality
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
// 如果选择的是特殊音质,先尝试下载
if (isSpecialQuality) {
try {
console.log(`尝试下载特殊音质: ${quality} - ${qualityMap[quality]}`)
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
const specialResult = await window.api.music.requestSdk('downloadSingleSong', {
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
source: songInfo.source,
quality,
songInfo: toRaw(songInfo) as any,
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
})
;(await tip).close()
// 如果成功获取特殊音质链接,处理结果并返回
if (specialResult) {
if (!Object.hasOwn(specialResult, 'path')) {
MessagePlugin.info(specialResult.message)
} else {
await NotifyPlugin.success({
title: '下载成功',
content: `${specialResult.message} 保存位置: ${specialResult.path}`
})
}
return
}
console.log(`下载${qualityMap[quality]}音质失败,重新选择音质`)
MessagePlugin.error('该音质下载失败,请重新选择音质')
// 特殊音质下载失败,重新弹出选择框
const retryQuality = await createQualityDialog(songInfo, userQuality)
if (!retryQuality) {
return
}
quality = retryQuality
} catch (specialError) {
console.log(`下载${qualityMap[quality]}音质出错:`, specialError)
MessagePlugin.error('该音质下载失败,请重新选择音质')
// 特殊音质下载出错,重新弹出选择框
const retryQuality = await createQualityDialog(songInfo, userQuality)
if (!retryQuality) {
return
}
quality = retryQuality
}
}
// 检查选择的音质是否超出歌曲支持的最高音质
const songMaxQuality = songInfo.types[songInfo.types.length - 1]?.type
if (songMaxQuality && qualityKey.indexOf(quality) > qualityKey.indexOf(songMaxQuality)) {
quality = songMaxQuality
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${qualityMap[quality]}`)
}
console.log(`使用音质下载: ${quality} - ${qualityMap[quality]}`)
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
const result = await window.api.music.requestSdk('downloadSingleSong', {
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
source: songInfo.source,
quality,
songInfo: toRaw(songInfo)
songInfo: toRaw(songInfo) as any,
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
})
;(await tip).close()
if (!Object.hasOwn(result, 'path')) {
MessagePlugin.info(result.message)
} else {

View File

@@ -37,19 +37,54 @@ export async function getSongRealUrl(song: SongList): Promise<string> {
const LocalUserDetail = LocalUserDetailStore()
// 通过统一的request方法获取真实的播放URL
let quality = LocalUserDetail.userSource.quality as string
// 检查是否为特殊音质(高清臻音、全景环绕或超清母带)
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
// 如果是特殊音质,先尝试获取对应链接
if (isSpecialQuality) {
try {
console.log(`尝试获取特殊音质: ${quality} - ${qualityMap[quality]}`)
const specialUrlData = await window.api.music.requestSdk('getMusicUrl', {
pluginId: LocalUserDetail.userSource.pluginId as unknown as string,
source: song.source,
songInfo: song as any,
quality
})
// 如果成功获取特殊音质链接,直接返回
if (
typeof specialUrlData === 'string' ||
(typeof specialUrlData === 'object' && !specialUrlData.error)
) {
console.log(`成功获取${qualityMap[quality]}链接`)
return specialUrlData as string
}
console.log(`获取${qualityMap[quality]}链接失败,回退到标准逻辑`)
// 如果获取特殊音质失败,继续执行原有逻辑
} catch (specialError) {
console.log(`获取${qualityMap[quality]}链接出错,回退到标准逻辑:`, specialError)
// 特殊音质获取失败,继续执行原有逻辑
}
}
// 原有逻辑:检查歌曲支持的最高音质
if (
qualityKey.indexOf(quality) >
qualityKey.indexOf((song.types[song.types.length - 1] as unknown as { type: any }).type)
) {
quality = (song.types[song.types.length - 1] as unknown as { type: any }).type
}
console.log(quality)
console.log(`使用音质: ${quality} - ${qualityMap[quality]}`)
const urlData = await window.api.music.requestSdk('getMusicUrl', {
pluginId: LocalUserDetail.userSource.pluginId as unknown as string,
source: song.source,
songInfo: song as any,
quality
})
console.log(urlData)
if (typeof urlData === 'object' && urlData.error) {
throw new Error(urlData.error)

View File

@@ -0,0 +1,368 @@
<!--
主题演示页面 - 展示暗色模式适配效果
-->
<template>
<div class="theme-demo">
<div class="demo-header">
<h1 class="demo-title">主题演示</h1>
<p class="demo-description">测试不同主题和暗色模式的效果</p>
</div>
<div class="demo-content">
<!-- 主题选择器 -->
<div class="demo-section">
<h2 class="section-title">主题选择</h2>
<ThemeSelector />
</div>
<!-- 组件演示 -->
<div class="demo-section">
<h2 class="section-title">组件演示</h2>
<div class="component-grid">
<!-- 按钮演示 -->
<div class="component-item">
<h3 class="component-title">按钮</h3>
<div class="button-group">
<button class="btn btn-primary">主要按钮</button>
<button class="btn btn-secondary">次要按钮</button>
<button class="btn btn-outline">轮廓按钮</button>
</div>
</div>
<!-- 输入框演示 -->
<div class="component-item">
<h3 class="component-title">输入框</h3>
<div class="input-group">
<input type="text" class="input" placeholder="请输入内容" />
<textarea class="textarea" placeholder="多行文本输入"></textarea>
</div>
</div>
<!-- 卡片演示 -->
<div class="component-item">
<h3 class="component-title">卡片</h3>
<div class="card">
<div class="card-header">
<h4 class="card-title">卡片标题</h4>
</div>
<div class="card-body">
<p class="card-text">这是卡片内容用于展示暗色模式下的效果</p>
<div class="card-actions">
<button class="btn btn-small btn-primary">操作</button>
<button class="btn btn-small btn-outline">取消</button>
</div>
</div>
</div>
</div>
<!-- 列表演示 -->
<div class="component-item">
<h3 class="component-title">列表</h3>
<div class="list">
<div class="list-item">
<div class="list-content">
<div class="list-title">列表项 1</div>
<div class="list-description">这是列表项的描述文本</div>
</div>
<div class="list-action">
<button class="btn btn-small btn-outline">编辑</button>
</div>
</div>
<div class="list-item">
<div class="list-content">
<div class="list-title">列表项 2</div>
<div class="list-description">这是另一个列表项的描述</div>
</div>
<div class="list-action">
<button class="btn btn-small btn-outline">编辑</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 颜色演示 -->
<div class="demo-section">
<h2 class="section-title">颜色系统</h2>
<div class="color-grid">
<div class="color-item">
<div class="color-swatch color-primary"></div>
<span class="color-label">主色</span>
</div>
<div class="color-item">
<div class="color-swatch color-success"></div>
<span class="color-label">成功</span>
</div>
<div class="color-item">
<div class="color-swatch color-warning"></div>
<span class="color-label">警告</span>
</div>
<div class="color-item">
<div class="color-swatch color-error"></div>
<span class="color-label">错误</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ThemeSelector from '../components/ThemeSelector.vue'
</script>
<style scoped>
.theme-demo {
min-height: 100vh;
background: var(--td-bg-color-page);
padding: 24px;
}
.demo-header {
text-align: center;
margin-bottom: 32px;
}
.demo-title {
font-size: var(--td-font-size-headline-large);
color: var(--td-text-color-primary);
margin-bottom: 8px;
}
.demo-description {
font-size: var(--td-font-size-body-large);
color: var(--td-text-color-secondary);
}
.demo-content {
max-width: 1200px;
margin: 0 auto;
}
.demo-section {
margin-bottom: 32px;
background: var(--td-bg-color-container);
border-radius: var(--td-radius-large);
padding: 24px;
border: 1px solid var(--td-border-level-1-color);
}
.section-title {
font-size: var(--td-font-size-title-large);
color: var(--td-text-color-primary);
margin-bottom: 16px;
}
.component-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.component-item {
padding: 16px;
background: var(--td-bg-color-secondarycontainer);
border-radius: var(--td-radius-medium);
border: 1px solid var(--td-border-level-1-color);
}
.component-title {
font-size: var(--td-font-size-title-medium);
color: var(--td-text-color-primary);
margin-bottom: 12px;
}
/* 按钮样式 */
.button-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.btn {
padding: 8px 16px;
border-radius: var(--td-radius-medium);
border: 1px solid transparent;
font-size: var(--td-font-size-body-medium);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-small {
padding: 4px 8px;
font-size: var(--td-font-size-body-small);
}
.btn-primary {
background: var(--td-brand-color);
color: var(--td-text-color-anti);
border-color: var(--td-brand-color);
}
.btn-primary:hover {
background: var(--td-brand-color-hover);
}
.btn-secondary {
background: var(--td-bg-color-component);
color: var(--td-text-color-primary);
border-color: var(--td-component-border);
}
.btn-secondary:hover {
background: var(--td-bg-color-component-hover);
}
.btn-outline {
background: transparent;
color: var(--td-brand-color);
border-color: var(--td-brand-color);
}
.btn-outline:hover {
background: var(--td-brand-color-light);
}
/* 输入框样式 */
.input-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.input,
.textarea {
padding: 8px 12px;
border: 1px solid var(--td-component-border);
border-radius: var(--td-radius-medium);
background: var(--td-bg-color-container);
color: var(--td-text-color-primary);
font-size: var(--td-font-size-body-medium);
}
.input:focus,
.textarea:focus {
outline: none;
border-color: var(--td-brand-color);
box-shadow: 0 0 0 2px var(--td-brand-color-light);
}
.textarea {
min-height: 80px;
resize: vertical;
}
/* 卡片样式 */
.card {
background: var(--td-bg-color-container);
border: 1px solid var(--td-border-level-1-color);
border-radius: var(--td-radius-medium);
overflow: hidden;
}
.card-header {
padding: 16px;
background: var(--td-bg-color-secondarycontainer);
border-bottom: 1px solid var(--td-border-level-1-color);
}
.card-title {
font-size: var(--td-font-size-title-medium);
color: var(--td-text-color-primary);
margin: 0;
}
.card-body {
padding: 16px;
}
.card-text {
color: var(--td-text-color-secondary);
margin-bottom: 16px;
}
.card-actions {
display: flex;
gap: 8px;
}
/* 列表样式 */
.list {
background: var(--td-bg-color-container);
border: 1px solid var(--td-border-level-1-color);
border-radius: var(--td-radius-medium);
overflow: hidden;
}
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--td-border-level-1-color);
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background: var(--td-bg-color-container-hover);
}
.list-title {
font-size: var(--td-font-size-body-medium);
color: var(--td-text-color-primary);
font-weight: 500;
margin-bottom: 4px;
}
.list-description {
font-size: var(--td-font-size-body-small);
color: var(--td-text-color-secondary);
}
/* 颜色演示 */
.color-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
}
.color-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.color-swatch {
width: 60px;
height: 60px;
border-radius: var(--td-radius-medium);
border: 1px solid var(--td-border-level-1-color);
}
.color-primary {
background: var(--td-brand-color);
}
.color-success {
background: var(--td-success-color);
}
.color-warning {
background: var(--td-warning-color);
}
.color-error {
background: var(--td-error-color);
}
.color-label {
font-size: var(--td-font-size-body-small);
color: var(--td-text-color-secondary);
}
</style>

View File

@@ -2,6 +2,7 @@
import { ref, onMounted, watch, WatchHandle, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { storeToRefs } from 'pinia'
import { extractDominantColor } from '../../utils/color/colorExtractor'
// 路由实例
@@ -18,25 +19,16 @@ const textColors = ref<string[]>([])
const hotSongs: any = ref([])
let watchSource: WatchHandle | null = null
// 获取热门歌单数据
const fetchHotSonglist = async () => {
const LocalUserDetail = LocalUserDetailStore()
watchSource = watch(
LocalUserDetail.userSource,
() => {
if (LocalUserDetail.userSource.source) {
fetchHotSonglist()
}
},
{ deep: true }
)
try {
loading.value = true
error.value = ''
// 调用真实 API 获取热门歌单
const result = await window.api.music.requestSdk('getHotSonglist', {
source: LocalUserDetail.userSource.source
source: userSource.value.source
})
if (result && result.list) {
recommendPlaylists.value = result.list.map((item: any) => ({
@@ -112,13 +104,27 @@ const playSong = (song: any): void => {
console.log('播放歌曲:', song.title)
}
// 获取 store 实例和响应式引用
const LocalUserDetail = LocalUserDetailStore()
const { userSource } = storeToRefs(LocalUserDetail)
// 组件挂载时获取数据
onMounted(() => {
fetchHotSonglist()
// 设置音源变化监听器
watchSource = watch(
userSource,
(newSource) => {
if (newSource.source) {
fetchHotSonglist()
}
},
{ deep: true, immediate: true }
)
})
onUnmounted(() => {
if (watchSource) {
watchSource()
watchSource = null
}
})
</script>
@@ -222,14 +228,14 @@ onUnmounted(() => {
margin-bottom: 2rem;
h2 {
color: #111827;
color: var(--td-text-color-primary);
margin-bottom: 0.5rem;
font-size: 1.875rem;
font-weight: 600;
}
p {
color: #6b7280;
color: var(--find-text-secondary);
font-size: 1rem;
}
}
@@ -238,7 +244,7 @@ onUnmounted(() => {
margin-bottom: 3rem;
.section-title {
color: #111827;
color: var(--td-text-color-primary);
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1.5rem;
@@ -287,12 +293,10 @@ onUnmounted(() => {
.playlist-card {
// 卡片样式
background: #fff;
background: var(--find-card-bg);
border-radius: 1rem;
overflow: hidden;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.06),
0 1px 4px rgba(0, 0, 0, 0.04);
box-shadow: var(--find-card-shadow);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
position: relative;
@@ -300,9 +304,7 @@ onUnmounted(() => {
// 现代化悬浮效果
&:hover {
transform: translateY(-4px) scale(1.02);
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.12),
0 4px 10px rgba(0, 0, 0, 0.08);
box-shadow: var(--find-card-shadow-hover);
.playlist-cover::after {
opacity: 1;
@@ -311,7 +313,7 @@ onUnmounted(() => {
.playlist-info {
backdrop-filter: blur(8px);
background-color: var(--hover-bg-color);
color: #111827;
color: var(--find-text-primary);
.playlist-title {
color: var(--hover-text-color);
}
@@ -371,7 +373,7 @@ onUnmounted(() => {
.playlist-info {
padding: 1.25rem 1rem;
position: relative;
background: rgba(255, 255, 255, 0.95);
background: var(--find-card-info-bg);
backdrop-filter: blur(4px);
transition: all 0.3s ease;
@@ -379,7 +381,7 @@ onUnmounted(() => {
.playlist-title {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
color: var(--find-text-primary);
margin-bottom: 0.5rem;
line-height: 1.4;
display: -webkit-box;
@@ -392,7 +394,7 @@ onUnmounted(() => {
.playlist-desc {
font-size: 0.875rem;
color: #6b7280;
color: var(--find-text-secondary);
margin-bottom: 0.75rem;
line-height: 1.5;
display: -webkit-box;
@@ -410,13 +412,13 @@ onUnmounted(() => {
gap: 0.5rem;
margin-top: auto; // 推到底部
padding-top: 0.5rem;
border-top: 1px solid rgba(229, 231, 235, 0.5);
border-top: 1px solid var(--find-meta-border);
transition: color 0.3s ease;
}
.play-count {
font-size: 0.75rem;
color: #9ca3af;
color: var(--find-text-muted);
display: flex;
align-items: center;
gap: 0.25rem;
@@ -431,9 +433,9 @@ onUnmounted(() => {
.song-count {
font-size: 0.75rem;
color: #9ca3af;
color: var(--find-text-muted);
font-weight: 500;
background: rgba(156, 163, 175, 0.1);
background: var(--find-song-count-bg);
padding: 0.125rem 0.5rem;
border-radius: 0.375rem;
transition: color 0.3s ease;
@@ -441,7 +443,7 @@ onUnmounted(() => {
.playlist-author {
font-size: 0.75rem;
color: #6b7280;
color: var(--find-text-secondary);
font-style: italic;
margin-top: 0.25rem;
opacity: 0.8;
@@ -451,17 +453,17 @@ onUnmounted(() => {
}
.song-list {
background: #fff;
background: var(--find-song-bg);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
box-shadow: var(--td-shadow-1);
}
.song-item {
display: flex;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid #f3f4f6;
border-bottom: 1px solid var(--find-border-color);
cursor: pointer;
transition: background-color 0.2s ease;
@@ -470,14 +472,14 @@ onUnmounted(() => {
}
&:hover {
background-color: #f9fafb;
background-color: var(--find-song-hover-bg);
}
.song-index {
width: 2rem;
text-align: center;
font-size: 0.875rem;
color: #6b7280;
color: var(--find-text-secondary);
font-weight: 500;
}
@@ -488,19 +490,19 @@ onUnmounted(() => {
.song-title {
font-size: 0.875rem;
font-weight: 500;
color: #111827;
color: var(--find-text-primary);
margin-bottom: 0.25rem;
}
.song-artist {
font-size: 0.75rem;
color: #6b7280;
color: var(--find-text-secondary);
}
}
.song-duration {
font-size: 0.75rem;
color: #6b7280;
color: var(--find-text-secondary);
margin-right: 1rem;
}

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, toRaw } from 'vue'
import { ref, onMounted, onUnmounted, toRaw, computed } from 'vue'
import { useRoute } from 'vue-router'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { downloadSingleSong } from '@renderer/utils/audio/download'
import SongVirtualList from '@renderer/components/Music/SongVirtualList.vue'
interface MusicItem {
singer: string
@@ -191,7 +190,7 @@ const handlePause = () => {
}
}
const handleDownload = (song: MusicItem) => {
const handleDownload = (song: any) => {
downloadSingleSong(song)
}
@@ -202,6 +201,109 @@ const handleAddToPlaylist = (song: MusicItem) => {
}
}
// 从本地歌单移出歌曲
const handleRemoveFromLocalPlaylist = async (song: MusicItem) => {
try {
const result = await window.api.songList.removeSongs(playlistInfo.value.id, [song.songmid])
if (result.success) {
// 从当前歌曲列表中移除
const index = songs.value.findIndex((s) => s.songmid === song.songmid)
if (index !== -1) {
songs.value.splice(index, 1)
// 更新歌单信息中的歌曲总数
playlistInfo.value.total = songs.value.length
}
MessagePlugin.success(`已将"${song.name}"从歌单中移出`)
} else {
MessagePlugin.error(result.error || '移出歌曲失败')
}
} catch (error) {
console.error('移出歌曲失败:', error)
MessagePlugin.error('移出歌曲失败')
}
}
// 检查是否是本地歌单
const isLocalPlaylist = computed(() => {
return route.query.type === 'local' || route.query.source === 'local'
})
// 文件选择器引用
const fileInputRef = ref<HTMLInputElement | null>(null)
// 滚动相关状态
const scrollY = ref(0)
const isHeaderCompact = ref(false)
const scrollContainer = ref<HTMLElement | null>(null)
const songListRef = ref<any>(null)
// 点击封面修改图片(仅本地歌单)
const handleCoverClick = () => {
if (!isLocalPlaylist.value) return
// 触发文件选择器
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
// 处理文件选择
const handleFileSelect = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
// 检查文件类型
if (!file.type.startsWith('image/')) {
MessagePlugin.error('请选择图片文件')
return
}
// 检查文件大小限制为5MB
if (file.size > 5 * 1024 * 1024) {
MessagePlugin.error('图片文件大小不能超过5MB')
return
}
try {
// 读取文件为base64
const reader = new FileReader()
reader.onload = async (e) => {
const base64Data = e.target?.result as string
try {
// 调用API更新歌单封面
const result = await window.api.songList.updateCover(playlistInfo.value.id, base64Data)
if (result.success) {
// 更新本地显示的封面
playlistInfo.value.cover = base64Data
MessagePlugin.success('封面更新成功')
} else {
MessagePlugin.error(result.error || '封面更新失败')
}
} catch (error) {
console.error('更新封面失败:', error)
MessagePlugin.error('封面更新失败')
}
}
reader.onerror = () => {
MessagePlugin.error('读取图片文件失败')
}
reader.readAsDataURL(file)
} catch (error) {
console.error('处理图片文件失败:', error)
MessagePlugin.error('处理图片文件失败')
}
// 清空文件选择器的值,以便可以重复选择同一个文件
target.value = ''
}
// 替换播放列表的通用函数
const replacePlaylist = (songsToReplace: MusicItem[], shouldShuffle = false) => {
if (!(window as any).musicEmitter) {
@@ -287,28 +389,89 @@ const handleShufflePlaylist = () => {
}
})
}
// 滚动事件处理
const handleScroll = (event?: Event) => {
let scrollTop = 0
if (event && event.target) {
scrollTop = (event.target as HTMLElement).scrollTop
} else if (scrollContainer.value) {
scrollTop = scrollContainer.value.scrollTop
}
scrollY.value = scrollTop
// 当滚动超过100px时启用紧凑模式
isHeaderCompact.value = scrollY.value > 100
}
// 组件挂载时获取数据
onMounted(() => {
fetchPlaylistSongs()
// 延迟添加滚动事件监听,等待 SongVirtualList 组件渲染完成
setTimeout(() => {
// 查找 SongVirtualList 内部的虚拟滚动容器
const virtualListContainer = document.querySelector('.virtual-scroll-container')
if (virtualListContainer) {
scrollContainer.value = virtualListContainer as HTMLElement
virtualListContainer.addEventListener('scroll', handleScroll, { passive: true })
console.log('滚动监听器已添加到:', virtualListContainer)
} else {
console.warn('未找到虚拟滚动容器')
}
}, 200)
})
// 组件卸载时清理事件监听
onUnmounted(() => {
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll)
}
})
</script>
<template>
<div class="list-container">
<!-- 固定头部区域 -->
<div class="fixed-header">
<div class="fixed-header" :class="{ compact: isHeaderCompact }">
<!-- 歌单信息 -->
<div class="playlist-header">
<div class="playlist-cover">
<div class="playlist-header" :class="{ compact: isHeaderCompact }">
<div
class="playlist-cover"
:class="{ clickable: isLocalPlaylist }"
@click="handleCoverClick"
>
<img :src="playlistInfo.cover" :alt="playlistInfo.title" />
<!-- 本地歌单显示编辑提示 -->
<div v-if="isLocalPlaylist" class="cover-overlay">
<svg class="edit-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
<span>点击修改封面</span>
</div>
</div>
<!-- 隐藏的文件选择器 -->
<input
ref="fileInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleFileSelect"
/>
<div class="playlist-details">
<h1 class="playlist-title">{{ playlistInfo.title }}</h1>
<p class="playlist-author">by {{ playlistInfo.author }}</p>
<p class="playlist-stats">{{ playlistInfo.total }} 首歌曲</p>
<p class="playlist-author" :class="{ hidden: isHeaderCompact }">
by {{ playlistInfo.author }}
</p>
<p class="playlist-stats" :class="{ hidden: isHeaderCompact }">
{{ playlistInfo.total || songs.length }} 首歌曲
</p>
<!-- 播放控制按钮 -->
<div class="playlist-actions">
<div class="playlist-actions" :class="{ compact: isHeaderCompact }">
<t-button
theme="primary"
size="medium"
@@ -356,16 +519,21 @@ onMounted(() => {
<div v-else class="song-list-wrapper">
<SongVirtualList
ref="songListRef"
:songs="songs"
:current-song="currentSong"
:is-playing="isPlaying"
:show-index="true"
:show-album="true"
:show-duration="true"
:is-local-playlist="isLocalPlaylist"
:playlist-id="playlistInfo.id"
@play="handlePlay"
@pause="handlePause"
@download="handleDownload"
@add-to-playlist="handleAddToPlaylist"
@remove-from-local-playlist="handleRemoveFromLocalPlaylist"
@scroll="handleScroll"
/>
</div>
</div>
@@ -375,8 +543,7 @@ onMounted(() => {
<style lang="scss" scoped>
.list-container {
box-sizing: border-box;
background: #fafafa;
box-sizing: border-box;
// background: var(--list-bg-primary);
width: 100%;
padding: 20px;
height: 100%;
@@ -389,10 +556,10 @@ onMounted(() => {
}
.scrollable-content {
background: #fff;
background: var(--list-content-bg);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
box-shadow: var(--list-content-shadow);
flex: 1;
min-height: 0;
display: flex;
@@ -412,8 +579,8 @@ onMounted(() => {
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #507daf;
border: 4px solid var(--list-loading-border);
border-top: 4px solid var(--list-loading-spinner);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
@@ -421,7 +588,7 @@ onMounted(() => {
p {
font-size: 14px;
color: #666;
color: var(--list-loading-text);
margin: 0;
}
}
@@ -441,59 +608,163 @@ onMounted(() => {
align-items: center;
gap: 1.5rem;
padding: 1.5rem;
background: #fff;
background: var(--list-header-bg);
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--list-header-shadow);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.compact {
padding: 1rem;
gap: 1rem;
}
&.compact .playlist-cover {
width: 80px !important;
height: 80px !important;
}
.playlist-cover {
width: 120px;
height: 120px;
border-radius: 0.5rem;
overflow: hidden;
flex-shrink: 0;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
// 本地歌单封面可点击样式
&.clickable {
cursor: pointer;
&:hover {
.cover-overlay {
opacity: 1;
}
img {
transform: scale(1.05);
}
}
}
.cover-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--list-cover-overlay);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
color: white;
font-size: 12px;
text-align: center;
padding: 8px;
.edit-icon {
width: 24px;
height: 24px;
margin-bottom: 4px;
}
span {
font-weight: 500;
line-height: 1.2;
}
}
}
.playlist-details {
flex: 1;
.playlist-title {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
color: var(--list-title-color);
margin: 0 0 0.5rem 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.playlist-header.compact & {
font-size: 1.25rem;
margin: 0 0 0.25rem 0;
}
}
.playlist-author {
font-size: 1rem;
color: #6b7280;
color: var(--list-author-color);
margin: 0 0 0.5rem 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 1;
transform: translateY(0);
&.hidden {
opacity: 0;
transform: translateY(-10px);
margin: 0;
height: 0;
overflow: hidden;
}
}
.playlist-stats {
font-size: 0.875rem;
color: #9ca3af;
color: var(--list-stats-color);
margin: 0 0 1rem 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 1;
transform: translateY(0);
&.hidden {
opacity: 0;
transform: translateY(-10px);
margin: 0;
height: 0;
overflow: hidden;
}
}
.playlist-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.compact {
margin-top: 0.5rem;
gap: 0.5rem;
}
.play-btn,
.shuffle-btn {
min-width: 120px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.playlist-actions.compact & {
min-width: 100px;
padding: 6px 12px;
font-size: 0.875rem;
}
.play-icon,
.shuffle-icon {
width: 16px;
height: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.playlist-actions.compact & {
width: 14px;
height: 14px;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -206,9 +206,11 @@ const formatPlayTime = (timeStr: string): string => {
<style lang="scss" scoped>
.recent-container {
// background: var(--recent-bg);
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
min-height: 100vh;
}
.page-header {
@@ -219,14 +221,14 @@ const formatPlayTime = (timeStr: string): string => {
.header-left {
h2 {
color: #111827;
color: var(--recent-title-color);
margin-bottom: 0.5rem;
font-size: 1.875rem;
font-weight: 600;
}
p {
color: #6b7280;
color: var(--recent-subtitle-color);
font-size: 1rem;
}
}
@@ -236,7 +238,7 @@ const formatPlayTime = (timeStr: string): string => {
margin-bottom: 3rem;
.section-title {
color: #111827;
color: var(--recent-section-title);
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1.5rem;
@@ -253,15 +255,15 @@ const formatPlayTime = (timeStr: string): string => {
display: flex;
align-items: center;
padding: 1rem;
background: #fff;
background: var(--recent-card-bg);
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
box-shadow: var(--recent-card-shadow);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: var(--recent-card-shadow-hover);
.play-overlay {
opacity: 1;
@@ -308,13 +310,13 @@ const formatPlayTime = (timeStr: string): string => {
.playlist-title {
font-size: 1rem;
font-weight: 600;
color: #111827;
color: var(--recent-playlist-title);
margin-bottom: 0.25rem;
}
.playlist-desc {
font-size: 0.875rem;
color: #6b7280;
color: var(--recent-playlist-desc);
margin-bottom: 0.5rem;
}
@@ -322,7 +324,7 @@ const formatPlayTime = (timeStr: string): string => {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #9ca3af;
color: var(--recent-playlist-meta);
span {
&:not(:last-child)::after {
@@ -336,17 +338,17 @@ const formatPlayTime = (timeStr: string): string => {
}
.song-list {
background: #fff;
background: var(--recent-card-bg);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
box-shadow: var(--recent-card-shadow);
}
.song-item {
display: flex;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid #f3f4f6;
border-bottom: 1px solid var(--recent-song-item-border);
cursor: pointer;
transition: background-color 0.2s ease;
@@ -355,7 +357,7 @@ const formatPlayTime = (timeStr: string): string => {
}
&:hover {
background-color: #f9fafb;
background-color: var(--recent-song-item-hover);
.song-actions {
opacity: 1;
@@ -366,7 +368,7 @@ const formatPlayTime = (timeStr: string): string => {
width: 2rem;
text-align: center;
font-size: 0.875rem;
color: #6b7280;
color: var(--recent-song-index);
font-weight: 500;
}
@@ -377,13 +379,13 @@ const formatPlayTime = (timeStr: string): string => {
.song-title {
font-size: 0.875rem;
font-weight: 500;
color: #111827;
color: var(--recent-song-title);
margin-bottom: 0.25rem;
}
.song-artist {
font-size: 0.75rem;
color: #6b7280;
color: var(--recent-song-artist);
}
}
@@ -393,19 +395,19 @@ const formatPlayTime = (timeStr: string): string => {
.play-count {
font-size: 0.75rem;
color: #6b7280;
color: var(--recent-song-stats);
margin-bottom: 0.125rem;
}
.play-time {
font-size: 0.75rem;
color: #9ca3af;
color: var(--recent-playlist-meta);
}
}
.song-duration {
font-size: 0.75rem;
color: #6b7280;
color: var(--recent-song-duration);
margin-right: 1rem;
font-variant-numeric: tabular-nums;
}
@@ -427,19 +429,19 @@ const formatPlayTime = (timeStr: string): string => {
.iconfont {
font-size: 4rem;
color: #d1d5db;
color: var(--recent-empty-icon);
}
}
h3 {
color: #111827;
color: var(--recent-empty-title);
margin-bottom: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
}
p {
color: #6b7280;
color: var(--recent-empty-text);
}
}
</style>

View File

@@ -34,6 +34,8 @@ const isPlaying = ref(false)
const search = searchValue()
onMounted(async () => {
const localUserStore = LocalUserDetailStore()
watch(
search,
async () => {
@@ -42,6 +44,17 @@ onMounted(async () => {
},
{ immediate: true }
)
// 监听 userSource 变化,重新加载页面
watch(
() => localUserStore.userSource,
async () => {
if (keyword.value.trim()) {
await performSearch(true)
}
},
{ deep: true }
)
})
// 执行搜索
@@ -133,7 +146,7 @@ const handlePause = () => {
}
}
const handleDownload = (song: MusicItem) => {
const handleDownload = (song: any) => {
downloadSingleSong(song)
}
@@ -204,8 +217,8 @@ const handleScroll = (event: Event) => {
<style lang="scss" scoped>
.search-container {
background: #fafafa;
box-sizing: border-box;
// background: var(--search-bg);
width: 100%;
padding: 20px;
height: 100%;
@@ -219,25 +232,25 @@ const handleScroll = (event: Event) => {
.search-title {
font-size: 24px;
font-weight: normal;
color: #333;
color: var(--search-title-color);
margin: 0 0 8px 0;
.keyword {
color: #507daf;
color: var(--search-keyword-color);
}
}
.result-info {
font-size: 12px;
color: #999;
color: var(--search-info-color);
}
}
.song-list-wrapper {
background: #fff;
background: var(--search-content-bg);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
box-shadow: var(--search-content-shadow);
flex: 1;
min-height: 0;
display: flex;
@@ -261,14 +274,14 @@ const handleScroll = (event: Event) => {
h3 {
font-size: 16px;
color: #333;
color: var(--search-empty-title);
margin: 0 0 8px 0;
font-weight: normal;
}
p {
font-size: 12px;
color: #999;
color: var(--search-empty-text);
margin: 0;
}
}
@@ -279,8 +292,8 @@ const handleScroll = (event: Event) => {
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #507daf;
border: 4px solid var(--search-loading-border);
border-top: 4px solid var(--search-loading-spinner);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
@@ -288,7 +301,7 @@ const handleScroll = (event: Event) => {
p {
font-size: 14px;
color: #666;
color: var(--search-loading-text);
margin: 0;
}
}

View File

@@ -14,16 +14,20 @@ import {
SaveIcon
} from 'tdesign-icons-vue-next'
import fonts from '@renderer/assets/icon_font/icons'
import { useRouter } from 'vue-router'
import DirectorySettings from '@renderer/components/Settings/DirectorySettings.vue'
import MusicCache from '@renderer/components/Settings/MusicCache.vue'
import AIFloatBallSettings from '@renderer/components/Settings/AIFloatBallSettings.vue'
import ThemeSelector from '@renderer/components/ThemeSelector.vue'
import Versions from '@renderer/components/Versions.vue'
import { useAutoUpdate } from '@renderer/composables/useAutoUpdate'
import { useSettingsStore } from '@renderer/store/Settings'
const Store = LocalUserDetailStore()
const { userInfo } = storeToRefs(Store)
// 设置存储
const settingsStore = useSettingsStore()
const { settings } = storeToRefs(settingsStore)
// 当前选择的设置分类
const activeCategory = ref<string>('appearance')
// 应用版本号
@@ -175,9 +179,8 @@ const clearAPIKey = (): void => {
console.log('DeepSeek API Key 已清空')
}
const router = useRouter()
const goPlugin = () => {
router.push('/plugins')
switchCategory('plugins')
}
// 音乐源和音质配置相关
@@ -206,19 +209,25 @@ const qualitySliderValue = ref(0)
const qualityMarks = computed(() => {
const marks: Record<number, string> = {}
currentSourceQualities.value.forEach((quality, index) => {
marks[index] = getQualityDisplayName(quality)
marks[index] = String(getQualityDisplayName(quality))
})
return marks
})
// 监听当前选择的音质,更新滑块位置
watch(
() => userInfo.value.selectQuality,
(newQuality) => {
if (newQuality && currentSourceQualities.value.length > 0) {
const index = currentSourceQualities.value.indexOf(newQuality)
[() => userInfo.value.selectQuality, () => currentSourceQualities.value],
([newQuality, qualities]) => {
if (qualities.length > 0 && newQuality) {
// 检查当前选择的音质是否在新平台的支持列表中
const index = qualities.indexOf(newQuality)
if (index !== -1) {
qualitySliderValue.value = index
} else {
// 如果当前音质不在支持列表中,选择默认音质
console.log('当前音质不在支持列表中,选择默认音质')
// 选择最高音质
userInfo.value.selectQuality = qualities[qualities.length - 1]
}
}
},
@@ -234,7 +243,11 @@ const selectSource = (sourceKey: string) => {
// 自动选择该音源的最高音质
const source = userInfo.value.supportedSources?.[sourceKey]
if (source && source.qualitys && source.qualitys.length > 0) {
userInfo.value.selectQuality = source.qualitys[source.qualitys.length - 1]
// 检查当前选择的音质是否在新平台的支持列表中
const currentQuality = userInfo.value.selectQuality
if (!currentQuality || !source.qualitys.includes(currentQuality)) {
userInfo.value.selectQuality = source.qualitys[source.qualitys.length - 1]
}
}
}
@@ -298,6 +311,30 @@ const getCurrentSourceName = () => {
const openLink = (url: string) => {
window.open(url, '_blank')
}
// 标签写入选项
const tagWriteOptions = ref({
basicInfo: settings.value.tagWriteOptions?.basicInfo ?? true,
cover: settings.value.tagWriteOptions?.cover ?? true,
lyrics: settings.value.tagWriteOptions?.lyrics ?? true
})
// 更新标签写入选项
const updateTagWriteOptions = () => {
settingsStore.updateSettings({
tagWriteOptions: { ...tagWriteOptions.value }
})
}
// 获取标签选项状态描述
const getTagOptionsStatus = () => {
const enabled: string[] = []
if (tagWriteOptions.value.basicInfo) enabled.push('基础信息')
if (tagWriteOptions.value.cover) enabled.push('封面')
if (tagWriteOptions.value.lyrics) enabled.push('歌词')
return enabled.length > 0 ? enabled.join('、') : '未选择任何选项'
}
</script>
<template>
@@ -458,14 +495,15 @@ const openLink = (url: string) => {
<!-- 插件管理 -->
<div v-else-if="activeCategory === 'plugins'" key="plugins" class="settings-section">
<div class="setting-group">
<!-- <div class="setting-group">
<h3>插件管理</h3>
<p>管理和配置应用插件扩展音乐播放器功能</p>
<t-button theme="primary" @click="goPlugin">
<TreeRoundDotIcon style="margin-right: 0.5em" />
打开插件管理
</t-button>
</div>
</div> -->
<plugins />
</div>
<!-- 音乐源配置 -->
@@ -572,6 +610,44 @@ const openLink = (url: string) => {
<div style="margin-top: 20px" class="setting-group">
<MusicCache ref="musicCacheRef" @cache-cleared="handleCacheCleared" />
</div>
<!-- 标签写入设置 -->
<div class="setting-group">
<h3>下载标签写入设置</h3>
<p>选择下载歌曲时要写入的标签信息</p>
<div class="tag-options">
<div class="tag-option">
<t-checkbox v-model="tagWriteOptions.basicInfo" @change="updateTagWriteOptions">
基础信息
</t-checkbox>
<p class="option-desc">包括歌曲标题艺术家专辑名称等基本信息</p>
</div>
<div class="tag-option">
<t-checkbox v-model="tagWriteOptions.cover" @change="updateTagWriteOptions">
封面
</t-checkbox>
<p class="option-desc">将专辑封面嵌入到音频文件中</p>
</div>
<div class="tag-option">
<t-checkbox v-model="tagWriteOptions.lyrics" @change="updateTagWriteOptions">
普通歌词
</t-checkbox>
<p class="option-desc">将歌词信息写入到音频文件的标签中</p>
</div>
</div>
<div class="tag-options-status">
<div class="status-summary">
<span class="status-label">当前配置</span>
<span class="status-value">
{{ getTagOptionsStatus() }}
</span>
</div>
</div>
</div>
</div>
<!-- 关于页面 -->
@@ -735,6 +811,44 @@ const openLink = (url: string) => {
禁止修改后用于侵犯第三方权益的场景
</p>
</div>
<div class="notice-item">
<h4>🚫 使用限制</h4>
<p>
本项目仅允许用于非商业纯技术学习目的禁止用于任何商业运营盈利活动
禁止修改后用于侵犯第三方权益的场景
</p>
</div>
</div>
<h3 style="margin-top: 2rem">关于我们</h3>
<div class="legal-notice">
<div class="notice-item">
<h4>😊 时迁酱</h4>
<p>
你好呀好呀我是 (时迁酱)
<br />
一枚普普通通的高中生因为好奇+喜欢悄悄自学了一点编程
<br />
<br />
没想到今天你能用上我做的软件澜音它其实是我学 Electron
时孵出来的小demo
<br />
看到它真的能运行还有人愿意用我真的超级开心骄傲的💖
<br />
<br />
当然啦平时还是要乖乖写作业上课哒但我还是会继续挤出时间让澜音慢慢长大越走越远哒💪
<br />
<br />
如果你也喜欢它或者想给我加点零食鼓励🧋欢迎打赏赞助哟谢谢可爱的你
<img
src="https://oss.shiqianjiang.cn/storage/default/20250907/image-2025082711173bb1bba3608ef15d0e1fb485f80f29c728186.png"
alt="赞赏"
style="width: 100%; padding: 20px 30%"
/>
什么你也想学习编程我教你吖QQ:2115295703
</p>
<br />
<h4>...待补充</h4>
</div>
</div>
</div>
@@ -744,8 +858,8 @@ const openLink = (url: string) => {
<div class="contact-info">
<p>如有技术问题或合作意向仅限技术交流请通过以下方式联系</p>
<div class="contact-actions">
<t-button theme="primary" @click="openLink('https://qm.qq.com/q/IDpQnbGd06')">
官方QQ群
<t-button theme="primary" @click="openLink('https://qm.qq.com/q/8c25dPfylG')">
官方QQ群(1057783951)
</t-button>
<t-button
theme="primary"
@@ -776,22 +890,22 @@ const openLink = (url: string) => {
height: 100vh;
display: flex;
flex-direction: column;
background: #f8fafc;
background: var(--settings-main-bg);
}
.header {
-webkit-app-region: drag;
display: flex;
align-items: center;
background: #ffffff;
background: var(--settings-header-bg);
padding: 1.5rem;
// border-bottom: 1px solid #e2e8f0;
// box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
// border-bottom: 1px solid var(--settings-sidebar-border);
// box-shadow: 0 1px 3px var(--settings-group-shadow);
z-index: 1000;
}
.settings-layout {
margin: 0px 6px;
// margin: 0px 6px;
display: flex;
flex: 1;
@@ -801,22 +915,22 @@ const openLink = (url: string) => {
// 左侧导航栏
.sidebar {
width: 280px;
background: #ffffff;
// border-right: 1px solid #e2e8f0;
background: var(--settings-sidebar-bg);
// border-right: 1px solid var(--settings-sidebar-border);
padding-right: 5px;
display: flex;
flex-direction: column;
overflow-y: auto;
padding-left: 5px;
.sidebar-header {
// padding: 1.5rem;
border-bottom: 1px solid #e2e8f0;
border-bottom: 1px solid var(--settings-sidebar-border);
h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
color: var(--settings-text-primary);
}
}
@@ -836,19 +950,19 @@ const openLink = (url: string) => {
border-left: 3px solid transparent;
&:hover {
background: #f1f5f9;
background: var(--settings-nav-hover-bg);
}
&.active {
background: var(--td-brand-color-1);
border-left-color: var(--td-brand-color-5);
background: var(--settings-nav-active-bg);
border-left-color: var(--settings-nav-active-border);
.nav-icon {
color: var(--td-brand-color-5);
color: var(--settings-nav-icon-active);
}
.nav-label {
color: var(--td-brand-color-6);
color: var(--settings-nav-label-active);
font-weight: 600;
}
}
@@ -856,7 +970,7 @@ const openLink = (url: string) => {
.nav-icon {
width: 20px;
height: 20px;
color: #64748b;
color: var(--settings-nav-icon-color);
display: flex;
justify-content: center;
align-items: center;
@@ -876,14 +990,14 @@ const openLink = (url: string) => {
.nav-label {
font-size: 0.875rem;
font-weight: 500;
color: #334155;
color: var(--settings-nav-label-color);
margin-bottom: 0.125rem;
transition: color 0.2s ease;
}
.nav-description {
font-size: 0.75rem;
color: #64748b;
color: var(--settings-nav-desc-color);
line-height: 1.3;
}
}
@@ -900,19 +1014,19 @@ const openLink = (url: string) => {
.panel-header {
padding: 2rem 2rem 1rem;
background: #ffffff;
border-bottom: 1px solid #e2e8f0;
background: var(--settings-header-bg);
border-bottom: 1px solid var(--settings-sidebar-border);
h2 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
color: var(--settings-text-primary);
}
p {
margin: 0;
color: #64748b;
color: var(--settings-text-secondary);
font-size: 0.875rem;
}
}
@@ -921,30 +1035,30 @@ const openLink = (url: string) => {
flex: 1;
overflow-y: auto;
// padding: 2rem;
background: #f8fafc;
background: var(--settings-main-bg);
}
}
// 设置区域
.settings-section {
.setting-group {
background: #ffffff;
background: var(--settings-group-bg);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid var(--settings-group-border);
box-shadow: 0 1px 3px var(--settings-group-shadow);
h3 {
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
color: var(--settings-text-primary);
}
> p {
margin: 0 0 1.5rem;
color: #64748b;
color: var(--settings-text-secondary);
font-size: 0.875rem;
}
}
@@ -964,16 +1078,16 @@ const openLink = (url: string) => {
gap: 1rem;
.preview-item {
background: #f8fafc;
background: var(--settings-preview-bg);
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
border: 1px solid var(--settings-preview-border);
h4 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
color: var(--settings-text-primary);
}
}
}
@@ -983,13 +1097,13 @@ const openLink = (url: string) => {
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: #f6f6f6;
background: var(--settings-mock-titlebar-bg);
border-radius: 0.375rem;
border: 1px solid #d1d5db;
border: 1px solid var(--settings-mock-titlebar-border);
.mock-title {
font-weight: 500;
color: #374151;
color: var(--settings-text-primary);
font-size: 0.875rem;
}
}
@@ -1005,9 +1119,9 @@ const openLink = (url: string) => {
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background: #f8fafc;
background: var(--settings-feature-bg);
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
border: 1px solid var(--settings-feature-border);
.iconfont {
font-size: 1.125rem;
@@ -1020,13 +1134,13 @@ const openLink = (url: string) => {
strong {
display: block;
color: #1e293b;
color: var(--settings-text-primary);
margin-bottom: 0.25rem;
font-weight: 600;
}
p {
color: #64748b;
color: var(--settings-text-secondary);
font-size: 0.875rem;
margin: 0;
line-height: 1.4;
@@ -1043,7 +1157,7 @@ const openLink = (url: string) => {
label {
display: block;
font-weight: 600;
color: #1e293b;
color: var(--settings-text-primary);
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
@@ -1089,19 +1203,19 @@ const openLink = (url: string) => {
.status-text {
font-size: 0.875rem;
color: #64748b;
color: var(--settings-text-secondary);
}
}
}
.api-key-tips {
background: #f8fafc;
background: var(--settings-api-tips-bg);
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
border: 1px solid var(--settings-api-tips-border);
h4 {
color: #1e293b;
color: var(--settings-text-primary);
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: 600;
@@ -1112,7 +1226,7 @@ const openLink = (url: string) => {
padding-left: 1.25rem;
li {
color: #64748b;
color: var(--settings-text-secondary);
font-size: 0.875rem;
margin-bottom: 0.5rem;
@@ -1171,32 +1285,32 @@ const openLink = (url: string) => {
align-items: center;
gap: 1rem;
padding: 1rem;
background: #ffffff;
border: 2px solid #e2e8f0;
background: var(--settings-source-card-bg);
border: 2px solid var(--settings-source-card-border);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--td-brand-color-3);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border-color: var(--settings-source-card-hover-border);
box-shadow: 0 4px 6px -1px var(--settings-group-shadow);
}
&.active {
border-color: var(--td-brand-color-5);
background: var(--td-brand-color-1);
border-color: var(--settings-source-card-active-border);
background: var(--settings-source-card-active-bg);
box-shadow: 0 0 0 3px var(--td-brand-color-2);
}
.source-icon {
width: 2.5rem;
height: 2.5rem;
background: #f1f5f9;
background: var(--settings-source-icon-bg);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
color: var(--settings-text-secondary);
}
.source-info {
@@ -1205,13 +1319,13 @@ const openLink = (url: string) => {
.source-name {
font-weight: 600;
font-size: 0.875rem;
color: #1e293b;
color: var(--settings-text-primary);
margin-bottom: 0.125rem;
}
.source-type {
font-size: 0.75rem;
color: #64748b;
color: var(--settings-text-secondary);
}
}
@@ -1222,10 +1336,10 @@ const openLink = (url: string) => {
}
.quality-slider-container {
background: #f8fafc;
background: var(--settings-quality-container-bg);
padding: 1.5rem;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
border: 1px solid var(--settings-quality-container-border);
.quality-slider {
margin-bottom: 1rem;
@@ -1242,12 +1356,12 @@ const openLink = (url: string) => {
&:first-child {
font-size: 1rem;
font-weight: 600;
color: #1e293b;
color: var(--settings-text-primary);
}
&.quality-hint {
font-size: 0.875rem;
color: #64748b;
color: var(--settings-text-secondary);
}
}
}
@@ -1262,19 +1376,19 @@ const openLink = (url: string) => {
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #f8fafc;
background: var(--settings-status-item-bg);
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
border: 1px solid var(--settings-status-item-border);
.status-label {
font-weight: 500;
color: #64748b;
color: var(--settings-text-secondary);
font-size: 0.875rem;
}
.status-value {
font-weight: 600;
color: #1e293b;
color: var(--settings-text-primary);
font-size: 0.875rem;
}
}
@@ -1287,9 +1401,9 @@ const openLink = (url: string) => {
align-items: center;
gap: 1.5rem;
padding: 2rem;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
background: var(--settings-plugin-prompt-bg);
border-radius: 1rem;
border: 2px dashed #cbd5e1;
border: 2px dashed var(--settings-plugin-prompt-border);
.prompt-icon {
width: 3rem;
@@ -1308,14 +1422,14 @@ const openLink = (url: string) => {
flex: 1;
h4 {
color: #1e293b;
color: var(--settings-text-primary);
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
}
p {
color: #64748b;
color: var(--settings-text-secondary);
margin: 0 0 1.5rem 0;
line-height: 1.5;
}
@@ -1555,7 +1669,7 @@ const openLink = (url: string) => {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
color: var(--settings-text-primary);
}
.app-version {
@@ -1578,7 +1692,7 @@ const openLink = (url: string) => {
.app-description {
margin: 0;
color: #64748b;
color: var(--settings-text-secondary);
line-height: 1.5;
}
}
@@ -1594,19 +1708,19 @@ const openLink = (url: string) => {
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f8fafc;
background: var(--settings-tech-item-bg);
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
border: 1px solid var(--settings-tech-item-border);
transition: 0.3s;
.tech-name {
font-weight: 600;
color: #1e293b;
color: var(--settings-text-primary);
}
.tech-desc {
font-size: 0.875rem;
color: #64748b;
color: var(--settings-text-secondary);
}
&.link:hover {
@@ -1631,13 +1745,13 @@ const openLink = (url: string) => {
align-items: center;
gap: 1rem;
padding: 1rem;
background: #f8fafc;
background: var(--settings-developer-item-bg);
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
border: 1px solid var(--settings-developer-item-border);
transition: all 0.2s ease;
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 6px -1px var(--settings-group-shadow);
}
.developer-avatar {
@@ -1661,13 +1775,13 @@ const openLink = (url: string) => {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
color: var(--settings-text-primary);
}
p {
margin: 0;
font-size: 0.875rem;
color: #64748b;
color: var(--settings-text-secondary);
}
}
}
@@ -1676,7 +1790,7 @@ const openLink = (url: string) => {
.license-info {
p {
margin: 0 0 1rem;
color: #64748b;
color: var(--settings-text-secondary);
line-height: 1.5;
}
@@ -1698,13 +1812,13 @@ const openLink = (url: string) => {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
color: var(--settings-text-primary);
}
p {
margin: 0;
font-size: 0.875rem;
color: #64748b;
color: var(--settings-text-secondary);
line-height: 1.5;
}
}
@@ -1735,6 +1849,53 @@ const openLink = (url: string) => {
}
}
// 标签写入设置样式
.tag-options {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
.tag-option {
padding: 1rem;
background: var(--settings-tag-option-bg);
border-radius: 0.5rem;
border: 1px solid var(--settings-tag-option-border);
.option-desc {
margin: 0.5rem 0 0 1.5rem;
font-size: 0.875rem;
color: var(--settings-text-secondary);
line-height: 1.4;
}
}
}
.tag-options-status {
background: var(--settings-tag-status-bg);
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid var(--settings-tag-status-border);
.status-summary {
display: flex;
align-items: center;
gap: 0.5rem;
.status-label {
font-weight: 500;
color: var(--settings-text-secondary);
font-size: 0.875rem;
}
.status-value {
font-weight: 600;
color: var(--settings-text-primary);
font-size: 0.875rem;
}
}
}
// 响应式适配
@media (max-width: 768px) {
.app-header {

View File

@@ -59,7 +59,7 @@ onMounted(async () => {
.welcome-container {
width: 100vw;
height: 100vh;
background: #ffffff;
background: var(--welcome-bg);
display: flex;
align-items: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
@@ -131,7 +131,7 @@ onMounted(async () => {
.brand-subtitle {
font-size: 1.5rem;
color: #666666;
color: var(--welcome-subtitle-color);
margin: 1rem 0 5rem 0;
font-weight: 400;
}
@@ -144,7 +144,7 @@ onMounted(async () => {
.progress-bar {
width: 100%;
height: 4px;
background: #f0f0f0;
background: var(--welcome-progress-bg);
border-radius: 2px;
overflow: hidden;
margin-bottom: 1rem;
@@ -159,7 +159,7 @@ onMounted(async () => {
.loading-text {
font-size: 0.9rem;
color: #888888;
color: var(--welcome-loading-text);
margin: 0;
font-weight: 400;
}
@@ -173,11 +173,11 @@ onMounted(async () => {
.tag {
padding: 0.4rem 0.8rem;
background: #b8f1ce;
border: 1px solid #e9ecef;
background: var(--welcome-tag-bg);
border: 1px solid var(--welcome-tag-border);
border-radius: 20px;
font-size: 0.8rem;
color: #333333;
color: var(--welcome-tag-color);
transition: all 0.3s ease;
opacity: 0.5;
}
@@ -197,7 +197,7 @@ onMounted(async () => {
bottom: 2rem;
right: 2rem;
font-size: 0.8rem;
color: #9e9e9e;
color: var(--welcome-version-color);
font-weight: 300;
}
@@ -273,32 +273,5 @@ onMounted(async () => {
}
} */
/* 暗色主题适配 */
@media (prefers-color-scheme: dark) {
.welcome-container {
background: #1a1a1a;
}
.brand-subtitle {
color: #999999;
}
.loading-text {
color: #aaaaaa;
}
.progress-bar {
background: #333333;
}
.tag {
background: #2d2d2d;
border-color: #404040;
color: #cccccc;
}
.version-info {
color: #666666;
}
}
/* 暗色主题适配已通过 CSS 变量实现 */
</style>

View File

@@ -23,7 +23,7 @@
<div class="nav-links">
<a href="#features">功能特色</a>
<a href="#download">下载</a>
<a href="./CeruUse.html" target="_blank">文档</a>
<a href="https://ceru.docs.shiqianjiang.cn/" target="_blank">文档</a>
</div>
</div>
</nav>

View File

@@ -11,163 +11,52 @@ function scrollToFeatures() {
})
}
// Alist API configuration
const ALIST_BASE_URL = 'https://alist.shiqianjiang.cn'
const ALIST_USERNAME = 'ceruupdate'
const ALIST_PASSWORD = '123456'
// GitHub repository configuration (for fallback)
// GitHub repository configuration
const GITHUB_REPO = 'timeshiftsauce/CeruMusic'
const GITHUB_PROXY = 'https://gh-proxy.com/'
const GITHUB_API_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`
const GITHUB_RELEASES_API_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases`
// Cache for release data
let releaseData = null
let releaseDataTimestamp = null
let alistToken = null
let allReleasesData = null
let allReleasesTimestamp = null
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
// Alist authentication
async function getAlistToken() {
if (alistToken) {
return alistToken
// Get all releases from GitHub API
async function getAllReleases() {
// Check cache first
const now = Date.now()
if (allReleasesData && allReleasesTimestamp && now - allReleasesTimestamp < CACHE_DURATION) {
return allReleasesData
}
try {
const response = await fetch(`${ALIST_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: ALIST_USERNAME,
password: ALIST_PASSWORD
})
})
const response = await fetch(GITHUB_RELEASES_API_URL)
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`)
}
const data = await response.json()
if (data.code === 200) {
alistToken = data.data.token
return alistToken
} else {
throw new Error(`Alist authentication failed: ${data.message}`)
}
// Filter and sort releases by version
const releases = data
.filter((release) => !release.draft && !release.prerelease)
.sort((a, b) => compareVersions(b.tag_name, a.tag_name))
// Cache the data
allReleasesData = releases
allReleasesTimestamp = now
return releases
} catch (error) {
console.error('Alist authentication error:', error)
throw error
}
}
// Get available versions from Alist
async function getAlistVersions() {
try {
const token = await getAlistToken()
const response = await fetch(`${ALIST_BASE_URL}/api/fs/list`, {
method: 'POST',
headers: {
Authorization: token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: '/',
password: '',
page: 1,
per_page: 100,
refresh: false
})
})
const data = await response.json()
if (data.code === 200) {
// Filter directories that look like version numbers
const versions = data.data.content
.filter((item) => item.is_dir && /^v?\d+\.\d+\.\d+/.test(item.name))
.sort((a, b) => b.name.localeCompare(a.name)) // Sort by version desc
return versions
} else {
throw new Error(`Failed to get versions: ${data.message}`)
}
} catch (error) {
console.error('Failed to get Alist versions:', error)
console.error('Failed to fetch releases data:', error)
return []
}
}
// Get files in a specific version directory
async function getAlistVersionFiles(version) {
try {
const token = await getAlistToken()
const response = await fetch(`${ALIST_BASE_URL}/api/fs/list`, {
method: 'POST',
headers: {
Authorization: token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: `/${version}`,
password: '',
page: 1,
per_page: 100,
refresh: false
})
})
const data = await response.json()
if (data.code === 200) {
return data.data.content.filter((item) => !item.is_dir)
} else {
throw new Error(`Failed to get version files: ${data.message}`)
}
} catch (error) {
console.error('Failed to get version files:', error)
return []
}
}
// Get direct download URL from Alist
async function getAlistDownloadUrl(version, fileName) {
try {
const token = await getAlistToken()
const filePath = `/${version}/${fileName}`
const response = await fetch(`${ALIST_BASE_URL}/api/fs/get`, {
method: 'POST',
headers: {
Authorization: token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: filePath
})
})
const data = await response.json()
if (data.code === 200) {
const fileInfo = data.data
// Try different URL formats
if (fileInfo.raw_url) {
return fileInfo.raw_url
} else if (fileInfo.sign) {
return `${ALIST_BASE_URL}/d${filePath}?sign=${fileInfo.sign}`
} else {
return `${ALIST_BASE_URL}/d${filePath}`
}
} else {
throw new Error(`Failed to get download URL: ${data.message}`)
}
} catch (error) {
console.error('Failed to get Alist download URL:', error)
throw error
}
}
// Download functionality
async function downloadApp(platform) {
const button = event.target
@@ -183,55 +72,51 @@ async function downloadApp(platform) {
button.disabled = true
try {
// Try Alist first
const versions = await getAlistVersions()
// Detect user's architecture for better matching
const userArch = detectArchitecture()
if (versions.length > 0) {
const latestVersion = versions[0]
const files = await getAlistVersionFiles(latestVersion.name)
// Get latest release from GitHub
const release = await getLatestRelease()
// Find the appropriate file for the platform
const fileName = findFileForPlatform(files, platform)
if (fileName) {
const downloadUrl = await getAlistDownloadUrl(latestVersion.name, fileName)
// Show success notification
showNotification(
`正在下载 ${getPlatformName(platform)} 版本 ${latestVersion.name}...`,
'success'
)
// Start download
window.open(downloadUrl, '_blank')
// Track download
trackDownload(platform, latestVersion.name)
return // Success, exit function
}
if (!release) {
throw new Error('无法获取最新版本信息')
}
// Fallback to GitHub if Alist fails
console.log('Alist download failed, trying GitHub fallback...')
await downloadFromGitHub(platform)
const downloadUrl = findDownloadAsset(release.assets, platform, userArch)
if (!downloadUrl) {
throw new Error(`暂无 ${getPlatformName(platform)} 版本下载`)
}
// Find the asset to get architecture info
const asset = release.assets.find((a) => a.browser_download_url === downloadUrl)
const archInfo = asset ? getArchitectureInfo(asset.name) : ''
// Show success notification
showNotification(
`正在下载 ${getPlatformName(platform)} ${archInfo} 版本 ${release.tag_name}...`,
'success'
)
// Use proxy for download if it's a GitHub URL
const finalDownloadUrl = downloadUrl.includes('github.com')
? `${GITHUB_PROXY}${downloadUrl}`
: downloadUrl
// Start download
window.open(finalDownloadUrl, '_blank')
// Track download
trackDownload(platform, release.tag_name, asset ? asset.name : '')
} catch (error) {
console.error('Download error:', error)
showNotification(`下载失败: ${error.message}`, 'error')
// Try GitHub fallback
try {
console.log('Trying GitHub fallback...')
await downloadFromGitHub(platform)
} catch (fallbackError) {
console.error('GitHub fallback also failed:', fallbackError)
showNotification(`下载失败: ${error.message}`, 'error')
// Final fallback to GitHub releases page
setTimeout(() => {
showNotification('正在跳转到GitHub下载页面...', 'info')
window.open(`https://github.com/${GITHUB_REPO}/releases/latest`, '_blank')
}, 2000)
}
// Fallback to GitHub releases page
setTimeout(() => {
showNotification('正在跳转到GitHub下载页面...', 'info')
window.open(`https://github.com/${GITHUB_REPO}/releases/latest`, '_blank')
}, 2000)
} finally {
// Restore button state
setTimeout(() => {
@@ -241,30 +126,6 @@ async function downloadApp(platform) {
}
}
// GitHub fallback function
async function downloadFromGitHub(platform) {
const release = await getLatestRelease()
if (!release) {
throw new Error('无法获取最新版本信息')
}
const downloadUrl = findDownloadAsset(release.assets, platform)
if (!downloadUrl) {
throw new Error(`暂无 ${getPlatformName(platform)} 版本下载`)
}
// Show success notification
showNotification(`正在下载 ${getPlatformName(platform)} 版本 v${release.tag_name}...`, 'success')
// Start download
window.open(downloadUrl, '_blank')
// Track download
trackDownload(platform, release.tag_name)
}
// Get latest release from GitHub API
async function getLatestRelease() {
// Check cache first
@@ -293,81 +154,6 @@ async function getLatestRelease() {
}
}
// Find appropriate file for platform from Alist files
function findFileForPlatform(files, platform) {
if (!files || !Array.isArray(files)) {
return null
}
// Filter out unwanted files (yml, yaml, txt, md, etc.)
const filteredFiles = files.filter((file) => {
const name = file.name.toLowerCase()
return (
!name.endsWith('.yml') &&
!name.endsWith('.yaml') &&
!name.endsWith('.txt') &&
!name.endsWith('.md') &&
!name.endsWith('.json') &&
!name.includes('latest') &&
!name.includes('blockmap')
)
})
// Define file patterns for each platform (ordered by priority)
const patterns = {
windows: [
/ceru-music.*setup\\.exe$/i,
/\\.exe$/i,
/windows.*\\.zip$/i,
/win32.*\\.zip$/i,
/win.*x64.*\\.zip$/i
],
macos: [
/ceru-music.*\\.dmg$/i,
/\\.dmg$/i,
/darwin.*\\.zip$/i,
/macos.*\\.zip$/i,
/mac.*\\.zip$/i,
/osx.*\\.zip$/i
],
linux: [
/ceru-music.*amd64\\.deb$/i,
/\\.deb$/i,
/\\.AppImage$/i,
/linux.*\\.zip$/i,
/linux.*\\.tar\\.gz$/i,
/\\.rpm$/i
]
}
const platformPatterns = patterns[platform] || []
// Try to find exact match
for (const pattern of platformPatterns) {
const file = filteredFiles.find((file) => pattern.test(file.name))
if (file) {
return file.name
}
}
// Fallback: look for any file that might match the platform
const fallbackPatterns = {
windows: /win|exe/i,
macos: /mac|darwin|dmg/i,
linux: /linux|appimage|deb|rpm/i
}
const fallbackPattern = fallbackPatterns[platform]
if (fallbackPattern) {
const file = filteredFiles.find((file) => fallbackPattern.test(file.name))
if (file) {
return file.name
}
}
return null
}
// Find appropriate download asset based on platform
function findDownloadAsset(assets, platform) {
if (!assets || !Array.isArray(assets)) {
@@ -391,21 +177,33 @@ function findDownloadAsset(assets, platform) {
// Define file patterns for each platform (ordered by priority)
const patterns = {
windows: [
/ceru-music.*win.*x64.*setup\.exe$/i,
/ceru-music.*win.*ia32.*setup\.exe$/i,
/ceru-music.*setup\.exe$/i,
/\.exe$/i,
/ceru-music.*win.*x64.*\.zip$/i,
/ceru-music.*win.*ia32.*\.zip$/i,
/windows.*\.zip$/i,
/win32.*\.zip$/i,
/win.*x64.*\.zip$/i
],
macos: [
/ceru-music.*universal\.dmg$/i,
/ceru-music.*arm64\.dmg$/i,
/ceru-music.*x64\.dmg$/i,
/ceru-music.*\.dmg$/i,
/\.dmg$/i,
/ceru-music.*universal\.zip$/i,
/ceru-music.*arm64\.zip$/i,
/ceru-music.*x64\.zip$/i,
/darwin.*\.zip$/i,
/macos.*\.zip$/i,
/mac.*\.zip$/i,
/osx.*\.zip$/i
],
linux: [
/ceru-music.*linux.*x64\.deb$/i,
/ceru-music.*linux.*x64\.AppImage$/i,
/ceru-music.*amd64\.deb$/i,
/\.deb$/i,
/\.AppImage$/i,
@@ -604,7 +402,7 @@ function setupAnimations() {
})
}
// Auto-detect user's operating system
// Auto-detect user's operating system and architecture
function detectOS() {
const userAgent = navigator.userAgent.toLowerCase()
if (userAgent.includes('win')) return 'windows'
@@ -613,9 +411,52 @@ function detectOS() {
return 'windows' // default
}
// Detect user's architecture
function detectArchitecture() {
const userAgent = navigator.userAgent.toLowerCase()
const platform = navigator.platform.toLowerCase()
// For macOS, detect Apple Silicon vs Intel
if (userAgent.includes('mac')) {
// Check for Apple Silicon indicators
if (userAgent.includes('arm') || platform.includes('arm')) {
return 'arm64'
}
// Default to universal for macOS (works on both Intel and Apple Silicon)
return 'universal'
}
// For Windows, detect 32-bit vs 64-bit
if (userAgent.includes('win')) {
if (userAgent.includes('wow64') || userAgent.includes('win64') || userAgent.includes('x64')) {
return 'x64'
}
return 'ia32'
}
// For Linux, assume 64-bit
if (userAgent.includes('linux')) {
return 'x64'
}
return 'x64' // default
}
// Get architecture display name
function getArchitectureName(arch) {
const names = {
x64: '64位',
ia32: '32位',
arm64: 'Apple Silicon',
universal: 'Universal (Intel + Apple Silicon)'
}
return names[arch] || arch
}
// Highlight user's OS download option
function highlightUserOS() {
const userOS = detectOS()
const userArch = detectArchitecture()
const downloadCards = document.querySelectorAll('.download-card')
downloadCards.forEach((card, index) => {
@@ -624,10 +465,11 @@ function highlightUserOS() {
card.style.border = '2px solid var(--primary-color)'
card.style.transform = 'scale(1.02)'
// Add "推荐" badge
// Add "推荐" badge with architecture info
const badge = document.createElement('div')
badge.className = 'recommended-badge'
badge.textContent = '推荐'
const archName = getArchitectureName(userArch)
badge.textContent = `推荐 (${archName})`
badge.style.cssText = `
position: absolute;
top: -10px;
@@ -638,9 +480,22 @@ function highlightUserOS() {
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
`
card.style.position = 'relative'
card.appendChild(badge)
// Add architecture info to the card description
const description = card.querySelector('p')
if (description && userOS === 'macos') {
if (userArch === 'arm64') {
description.innerHTML +=
'<br><small style="color: var(--text-muted);">检测到 Apple Silicon Mac推荐 Universal 版本</small>'
} else if (userArch === 'universal') {
description.innerHTML +=
'<br><small style="color: var(--text-muted);">Universal 版本兼容 Intel 和 Apple Silicon Mac</small>'
}
}
}
})
}
@@ -741,36 +596,7 @@ window.addEventListener('error', (e) => {
// Update version information on page
async function updateVersionInfo() {
try {
// Try to get version info from Alist first
const versions = await getAlistVersions()
if (versions.length > 0) {
const latestVersion = versions[0]
const versionElement = document.querySelector('.version')
const versionInfoElement = document.querySelector('.version-info p')
if (versionElement) {
versionElement.textContent = latestVersion.name
}
if (versionInfoElement) {
const modifyDate = new Date(latestVersion.modified)
const formattedDate = modifyDate.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long'
})
versionInfoElement.innerHTML = `当前版本: <span class="version">${latestVersion.name}</span> | 更新时间: ${formattedDate}`
}
// Update download button text with file info from Alist
const files = await getAlistVersionFiles(latestVersion.name)
updateDownloadButtonsWithAlistFiles(files)
return // Success, exit function
}
// Fallback to GitHub if Alist fails
console.log('Alist version info failed, trying GitHub fallback...')
// Get latest release from GitHub
const release = await getLatestRelease()
if (release) {
const versionElement = document.querySelector('.version')
@@ -784,9 +610,14 @@ async function updateVersionInfo() {
const publishDate = new Date(release.published_at)
const formattedDate = publishDate.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long'
month: 'long',
day: 'numeric'
})
versionInfoElement.innerHTML = `当前版本: <span class="version">${release.tag_name}</span> | 更新时间: ${formattedDate}`
const formattedTime = publishDate.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
versionInfoElement.innerHTML = `当前版本: <span class="version">${release.tag_name}</span> | 更新时间: ${formattedDate} ${formattedTime}`
}
// Update download button text with file sizes if available
@@ -797,30 +628,98 @@ async function updateVersionInfo() {
}
}
// Update download buttons with Alist file information
function updateDownloadButtonsWithAlistFiles(files) {
if (!files || !Array.isArray(files)) return
// Find asset for platform (helper function)
function findAssetForPlatform(assets, platform) {
const userArch = detectArchitecture()
const downloadCards = document.querySelectorAll('.download-card')
const platforms = ['windows', 'macos', 'linux']
downloadCards.forEach((card, index) => {
const platform = platforms[index]
const fileName = findFileForPlatform(files, platform)
if (fileName) {
const file = files.find((f) => f.name === fileName)
const button = card.querySelector('.btn-download')
const sizeText = formatFileSize(file.size)
const originalText = button.innerHTML
// Add file size info
button.innerHTML = originalText.replace(
/下载 \..*?$/,
`下载 .${getFileExtension(fileName)} (${sizeText})`
)
}
// Filter out unwanted files
const filteredAssets = assets.filter((asset) => {
const name = asset.name.toLowerCase()
return (
!name.endsWith('.yml') &&
!name.endsWith('.yaml') &&
!name.endsWith('.txt') &&
!name.endsWith('.md') &&
!name.endsWith('.json') &&
!name.includes('latest') &&
!name.includes('blockmap')
)
})
// Define architecture-specific patterns for each platform
const archPatterns = {
windows: {
x64: [
/ceru-music.*x64.*setup\.exe$/i,
/ceru-music.*win.*x64.*setup\.exe$/i,
/ceru-music.*x64.*\.zip$/i,
/ceru-music.*win.*x64.*\.zip$/i
],
ia32: [
/ceru-music.*ia32.*setup\.exe$/i,
/ceru-music.*win.*ia32.*setup\.exe$/i,
/ceru-music.*ia32.*\.zip$/i,
/ceru-music.*win.*ia32.*\.zip$/i
],
fallback: [/ceru-music.*setup\.exe$/i, /\.exe$/i, /windows.*\.zip$/i, /win.*\.zip$/i]
},
macos: {
universal: [/ceru-music.*universal\.dmg$/i, /ceru-music.*universal\.zip$/i],
arm64: [
/ceru-music.*arm64\.dmg$/i,
/ceru-music.*arm64\.zip$/i,
/ceru-music.*universal\.dmg$/i,
/ceru-music.*universal\.zip$/i
],
x64: [
/ceru-music.*x64\.dmg$/i,
/ceru-music.*x64\.zip$/i,
/ceru-music.*universal\.dmg$/i,
/ceru-music.*universal\.zip$/i
],
fallback: [
/ceru-music.*\.dmg$/i,
/\.dmg$/i,
/darwin.*\.zip$/i,
/macos.*\.zip$/i,
/mac.*\.zip$/i
]
},
linux: {
x64: [
/ceru-music.*linux.*x64\.AppImage$/i,
/ceru-music.*linux.*x64\.deb$/i,
/ceru-music.*x64\.AppImage$/i,
/ceru-music.*x64\.deb$/i
],
fallback: [
/ceru-music.*\.AppImage$/i,
/ceru-music.*\.deb$/i,
/\.AppImage$/i,
/\.deb$/i,
/linux.*\.zip$/i
]
}
}
const platformArchPatterns = archPatterns[platform]
if (!platformArchPatterns) return null
// Try architecture-specific patterns first
const archSpecificPatterns = platformArchPatterns[userArch] || []
for (const pattern of archSpecificPatterns) {
const asset = filteredAssets.find((asset) => pattern.test(asset.name))
if (asset) return asset
}
// Try fallback patterns
const fallbackPatterns = platformArchPatterns.fallback || []
for (const pattern of fallbackPatterns) {
const asset = filteredAssets.find((asset) => pattern.test(asset.name))
if (asset) return asset
}
return null
}
// Update download buttons with asset information
@@ -848,19 +747,116 @@ function updateDownloadButtonsWithAssets(assets) {
})
}
// Helper function to find asset for platform
function findAssetForPlatform(assets, platform) {
const patterns = {
windows: [/\.exe$/i, /windows.*\.zip$/i, /win32.*\.zip$/i],
macos: [/\.dmg$/i, /darwin.*\.zip$/i, /macos.*\.zip$/i],
linux: [/\.AppImage$/i, /linux.*\.zip$/i, /\.deb$/i]
// Find appropriate download asset based on platform and architecture
function findDownloadAsset(assets, platform, userArch = null) {
if (!assets || !Array.isArray(assets)) {
return null
}
const platformPatterns = patterns[platform] || []
if (!userArch) {
userArch = detectArchitecture()
}
for (const pattern of platformPatterns) {
const asset = assets.find((asset) => pattern.test(asset.name))
if (asset) return asset
// Filter out unwanted files
const filteredAssets = assets.filter((asset) => {
const name = asset.name.toLowerCase()
return (
!name.endsWith('.yml') &&
!name.endsWith('.yaml') &&
!name.endsWith('.txt') &&
!name.endsWith('.md') &&
!name.endsWith('.json') &&
!name.includes('latest') &&
!name.includes('blockmap')
)
})
// Define architecture-specific patterns for each platform
const archPatterns = {
windows: {
x64: [
/ceru-music.*x64.*setup\.exe$/i,
/ceru-music.*win.*x64.*setup\.exe$/i,
/ceru-music.*x64.*\.zip$/i,
/ceru-music.*win.*x64.*\.zip$/i
],
ia32: [
/ceru-music.*ia32.*setup\.exe$/i,
/ceru-music.*win.*ia32.*setup\.exe$/i,
/ceru-music.*ia32.*\.zip$/i,
/ceru-music.*win.*ia32.*\.zip$/i
],
fallback: [/ceru-music.*setup\.exe$/i, /\.exe$/i, /windows.*\.zip$/i, /win.*\.zip$/i]
},
macos: {
universal: [/ceru-music.*universal\.dmg$/i, /ceru-music.*universal\.zip$/i],
arm64: [
/ceru-music.*arm64\.dmg$/i,
/ceru-music.*arm64\.zip$/i,
/ceru-music.*universal\.dmg$/i,
/ceru-music.*universal\.zip$/i
],
x64: [
/ceru-music.*x64\.dmg$/i,
/ceru-music.*x64\.zip$/i,
/ceru-music.*universal\.dmg$/i,
/ceru-music.*universal\.zip$/i
],
fallback: [
/ceru-music.*\.dmg$/i,
/\.dmg$/i,
/darwin.*\.zip$/i,
/macos.*\.zip$/i,
/mac.*\.zip$/i
]
},
linux: {
x64: [
/ceru-music.*linux.*x64\.AppImage$/i,
/ceru-music.*linux.*x64\.deb$/i,
/ceru-music.*x64\.AppImage$/i,
/ceru-music.*x64\.deb$/i
],
fallback: [
/ceru-music.*\.AppImage$/i,
/ceru-music.*\.deb$/i,
/\.AppImage$/i,
/\.deb$/i,
/linux.*\.zip$/i
]
}
}
const platformArchPatterns = archPatterns[platform]
if (!platformArchPatterns) {
return null
}
// Try architecture-specific patterns first
const archSpecificPatterns = platformArchPatterns[userArch] || []
for (const pattern of archSpecificPatterns) {
const asset = filteredAssets.find((asset) => pattern.test(asset.name))
if (asset) return asset.browser_download_url
}
// Try fallback patterns
const fallbackPatterns = platformArchPatterns.fallback || []
for (const pattern of fallbackPatterns) {
const asset = filteredAssets.find((asset) => pattern.test(asset.name))
if (asset) return asset.browser_download_url
}
// Final fallback: look for any asset that might match the platform
const finalFallbackPatterns = {
windows: /win|exe/i,
macos: /mac|darwin|dmg/i,
linux: /linux|appimage|deb|rpm/i
}
const finalPattern = finalFallbackPatterns[platform]
if (finalPattern) {
const asset = filteredAssets.find((asset) => finalPattern.test(asset.name))
if (asset) return asset.browser_download_url
}
return null
@@ -882,19 +878,60 @@ function formatFileSize(bytes) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
// Get architecture information from filename
function getArchitectureInfo(filename) {
if (!filename) return ''
const name = filename.toLowerCase()
if (name.includes('universal')) return '(Universal)'
if (name.includes('arm64')) return '(Apple Silicon)'
if (name.includes('x64')) return '(64位)'
if (name.includes('ia32')) return '(32位)'
if (name.includes('win') && name.includes('x64')) return '(64位)'
if (name.includes('win') && name.includes('ia32')) return '(32位)'
if (name.includes('linux') && name.includes('x64')) return '(64位)'
return ''
}
// Analytics tracking (placeholder)
function trackDownload(platform, version) {
function trackDownload(platform, version, filename = '') {
// Add your analytics tracking code here
console.log(`Download tracked: ${platform} v${version}`)
const archInfo = getArchitectureInfo(filename)
// Example: Google Analytics
// gtag('event', 'download', {
// 'event_category': 'software',
// 'event_label': platform,
// 'event_label': `${platform}_${archInfo}`,
// 'value': version
// });
}
// Version comparison function to handle complex version numbers like v1.3.10, v1.3.3.1
function compareVersions(a, b) {
// Remove 'v' prefix if present
const versionA = a.replace(/^v/, '')
const versionB = b.replace(/^v/, '')
// Split version numbers into parts
const partsA = versionA.split('.').map((num) => parseInt(num, 10))
const partsB = versionB.split('.').map((num) => parseInt(num, 10))
// Compare each part
const maxLength = Math.max(partsA.length, partsB.length)
for (let i = 0; i < maxLength; i++) {
const partA = partsA[i] || 0
const partB = partsB[i] || 0
if (partA > partB) return 1
if (partA < partB) return -1
}
return 0
}
// Add GitHub link functionality
function addGitHubLinks() {
// Add GitHub link to footer if not exists