本地收藏功能

This commit is contained in:
Kun-ovO committed 2026-05-31 23:27:02 +08:00
1 parent a861b41809
commit b2de607391
9 files changed
+387

No files matched your search

+65
View File
@@ -51,6 +51,17 @@ export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
nowPlayingMsgEnabled: true, nowPlayingMsgEnabled: true,
}; };
export interface FavoritePlaylist {
id: number;
userId: string;
platform: string;
playlistId: string;
name: string;
coverUrl: string;
songCount: number;
createdAt: string;
}
export interface BotDatabase { export interface BotDatabase {
db: Database.Database; db: Database.Database;
addPlayHistory(entry: PlayHistoryEntry): void; addPlayHistory(entry: PlayHistoryEntry): void;
@@ -62,6 +73,10 @@ export interface BotDatabase {
saveProfileConfig(botId: string, config: ProfileConfig): void; saveProfileConfig(botId: string, config: ProfileConfig): void;
getCustomAvatarPath(botId: string): string | null; getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void; setCustomAvatarPath(botId: string, path: string | null): void;
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
getFavorites(userId: string): FavoritePlaylist[];
isFavorited(userId: string, playlistId: string, platform: string): boolean;
close(): void; close(): void;
} }
@@ -165,6 +180,20 @@ function initTables(db: Database.Database): void {
action TEXT NOT NULL action TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC); CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
CREATE TABLE IF NOT EXISTS favorite_playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId TEXT NOT NULL,
platform TEXT NOT NULL,
playlistId TEXT NOT NULL,
name TEXT NOT NULL,
coverUrl TEXT NOT NULL DEFAULT '',
songCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(userId, platform, playlistId)
);
CREATE INDEX IF NOT EXISTS idx_favorites_userId ON favorite_playlists(userId);
`); `);
} }
@@ -226,6 +255,24 @@ export function createDatabase(dbPath: string): BotDatabase {
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`); const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`); const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
const insertFavorite = db.prepare(`
INSERT INTO favorite_playlists (userId, platform, playlistId, name, coverUrl, songCount)
VALUES (@userId, @platform, @playlistId, @name, @coverUrl, @songCount)
`);
const deleteFavorite = db.prepare(`
DELETE FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
const selectFavorites = db.prepare(`
SELECT id, userId, platform, playlistId, name, coverUrl, songCount, createdAt
FROM favorite_playlists WHERE userId = ? ORDER BY createdAt DESC
`);
const checkFavorited = db.prepare(`
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
return { return {
db, db,
@@ -297,6 +344,24 @@ export function createDatabase(dbPath: string): BotDatabase {
updateCustomAvatar.run(path, botId); updateCustomAvatar.run(path, botId);
}, },
addFavorite(userId, playlist) {
insertFavorite.run({ userId, ...playlist });
},
removeFavorite(userId, playlistId, platform) {
const result = deleteFavorite.run(userId, playlistId, platform);
return result.changes > 0;
},
getFavorites(userId) {
return selectFavorites.all(userId) as FavoritePlaylist[];
},
isFavorited(userId, playlistId, platform) {
const row = checkFavorited.get(userId, playlistId, platform);
return row !== undefined;
},
close() { close() {
db.close(); db.close();
}, },
+76
View File
@@ -0,0 +1,76 @@
import { Router } from "express";
import type { BotDatabase } from "../../data/database.js";
import type { Logger } from "../../logger.js";
export function createFavoritesRouter(database: BotDatabase, logger: Logger): Router {
const router = Router();
// GET /api/favorites — 获取当前用户的所有收藏
router.get("/", (req, res) => {
const userId = req.user!.id;
const favorites = database.getFavorites(userId);
res.json({ favorites });
});
// POST /api/favorites — 添加收藏
router.post("/", (req, res) => {
const userId = req.user!.id;
const { platform, playlistId, name, coverUrl, songCount } = req.body;
if (!platform || !playlistId || !name) {
res.status(400).json({ error: "platform, playlistId, name are required" });
return;
}
try {
database.addFavorite(userId, {
platform,
playlistId,
name,
coverUrl: coverUrl ?? "",
songCount: songCount ?? 0,
});
logger.info({ userId, platform, playlistId, name }, "Playlist favorited");
res.json({ success: true });
} catch (err: unknown) {
const e = err as { code?: string };
if (e?.code === "SQLITE_CONSTRAINT_UNIQUE") {
res.status(409).json({ error: "already favorited" });
return;
}
logger.error({ err }, "Failed to add favorite");
res.status(500).json({ error: "internal error" });
}
});
// DELETE /api/favorites/:id — 取消收藏(只允许删除自己的)
router.delete("/:id", (req, res) => {
const userId = req.user!.id;
const favId = parseInt(req.params.id, 10);
if (isNaN(favId)) {
res.status(400).json({ error: "invalid id" });
return;
}
const favorites = database.getFavorites(userId);
const fav = favorites.find((f) => f.id === favId);
if (!fav) {
res.status(404).json({ error: "favorite not found" });
return;
}
database.removeFavorite(userId, fav.playlistId, fav.platform);
logger.info({ userId, playlistId: fav.playlistId, platform: fav.platform }, "Playlist unfavorited");
res.json({ success: true });
});
// GET /api/favorites/check?platform=netease&playlistId=xxx — 检查是否已收藏
router.get("/check", (req, res) => {
const userId = req.user!.id;
const { platform, playlistId } = req.query;
if (!platform || !playlistId) {
res.status(400).json({ error: "platform and playlistId required" });
return;
}
const favorited = database.isFavorited(userId, playlistId as string, platform as string);
res.json({ favorited });
});
return router;
}
+3
View File
@@ -18,6 +18,7 @@ import { createSessionRouter } from "./api/session.js";
import { createUsersRouter } from "./api/users.js"; import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js"; import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js"; import { createAuditRouter } from "./api/audit.js";
import { createFavoritesRouter } from "./api/favorites.js";
import { setupWebSocket } from "./websocket.js"; import { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js"; import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js"; import { createSessionStore } from "../data/sessions.js";
@@ -123,6 +124,8 @@ export function createWebServer(options: WebServerOptions): WebServer {
"/api/auth", "/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
); );
app.use("/api/favorites", createFavoritesRouter(options.database, logger));
// admin-only routes // admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger)); app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger));
app.use("/api/audit", requireAdmin, createAuditRouter(audit)); app.use("/api/audit", requireAdmin, createAuditRouter(audit));
+19
View File
@@ -0,0 +1,19 @@
@echo off
title TSMusicBot
:: Check node
where node >nul 2>&1
if errorlevel 1 (
echo Node.js not found. Run scripts\setup.bat first.
pause
exit /b 1
)
echo Starting TSMusicBot...
echo WebUI: http://localhost:3000
echo Press Ctrl+C to stop.
echo.
node dist\index.js
pause
+42
View File
@@ -34,6 +34,17 @@ export interface PlaylistItem {
platform: string; platform: string;
} }
export interface FavoritePlaylist {
id: number;
userId: string;
platform: string;
playlistId: string;
name: string;
coverUrl: string;
songCount: number;
createdAt: string;
}
interface TimingState { interface TimingState {
serverElapsed: number; serverElapsed: number;
serverSyncTime: number; serverSyncTime: number;
@@ -64,6 +75,9 @@ export const usePlayerStore = defineStore('player', {
authStatus: { netease: false, qq: false }, authStatus: { netease: false, qq: false },
lastFetchTime: 0, lastFetchTime: 0,
// Favorited playlists (fetched from server, isolated per WebUI user)
favoritedPlaylists: [] as FavoritePlaylist[],
// Transient notification for surfacing failures (e.g., "song not playable") // Transient notification for surfacing failures (e.g., "song not playable")
// to a global Toast. Bumped `id` triggers re-render of the same message. // to a global Toast. Bumped `id` triggers re-render of the same message.
notification: null as { id: number; message: string; type: 'error' | 'info' } | null, notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
@@ -397,6 +411,31 @@ export const usePlayerStore = defineStore('player', {
if (bot) bot.playMode = mode; if (bot) bot.playMode = mode;
}, },
async fetchFavorites() {
try {
const res = await axios.get('/api/favorites');
this.favoritedPlaylists = res.data.favorites ?? [];
} catch {
// not critical
}
},
async addFavorite(playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }) {
await axios.post('/api/favorites', playlist);
await this.fetchFavorites();
this.notify('已收藏', 'info');
},
async removeFavorite(id: number) {
await axios.delete(`/api/favorites/${id}`);
await this.fetchFavorites();
this.notify('已取消收藏', 'info');
},
isFavorited(playlistId: string, platform: string): boolean {
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
},
async fetchHomeData() { async fetchHomeData() {
// Always check auth status first — if it changed since the cached // Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the // fetch (e.g., user logged in/out as a different account), the
@@ -484,6 +523,9 @@ export const usePlayerStore = defineStore('player', {
if (authOk) { if (authOk) {
this.lastFetchTime = Date.now(); this.lastFetchTime = Date.now();
} }
// Fetch favorites in parallel — not cached by TTL; always fresh
this.fetchFavorites();
}, },
}, },
}); });
+22
View File
@@ -72,6 +72,27 @@
</div> </div>
</section> </section>
<!-- 我的收藏 -->
<section class="section" v-if="store.favoritedPlaylists.length > 0">
<h2 class="section-title">
<Icon icon="mdi:heart" style="color: var(--color-primary)" />
我的收藏
<span class="section-count">{{ store.favoritedPlaylists.length }}</span>
</h2>
<div class="playlist-grid">
<RouterLink
v-for="fav in store.favoritedPlaylists"
:key="fav.id"
:to="`/playlist/${fav.playlistId}?platform=${fav.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="fav.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ fav.name }}</div>
<div class="playlist-count">{{ fav.songCount }} 首</div>
</RouterLink>
</div>
</section>
<!-- 我的歌单 --> <!-- 我的歌单 -->
<section class="section" v-if="userAvailable.length > 0"> <section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title"> <h2 class="section-title">
@@ -125,6 +146,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import axios from 'axios'; import axios from 'axios';
import { usePlayerStore, type Song, type Source } from '../stores/player.js'; import { usePlayerStore, type Song, type Source } from '../stores/player.js';
+22
View File
@@ -2,6 +2,27 @@
<div class="library-page"> <div class="library-page">
<h1 class="page-title">音乐库</h1> <h1 class="page-title">音乐库</h1>
<!-- 我的收藏 -->
<section class="section" v-if="store.favoritedPlaylists.length > 0">
<h2 class="section-title">
<Icon icon="mdi:heart" style="color: var(--color-primary)" />
我的收藏
<span class="section-count">{{ store.favoritedPlaylists.length }}</span>
</h2>
<div class="playlist-grid">
<RouterLink
v-for="fav in store.favoritedPlaylists"
:key="fav.id"
:to="`/playlist/${fav.playlistId}?platform=${fav.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="fav.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ fav.name }}</div>
<div class="playlist-count">{{ fav.songCount }} 首</div>
</RouterLink>
</div>
</section>
<!-- 我的歌单 --> <!-- 我的歌单 -->
<section class="section" v-if="userAvailable.length > 0"> <section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title"> <h2 class="section-title">
@@ -51,6 +72,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import axios from 'axios'; import axios from 'axios';
import { usePlayerStore, type Song, type Source } from '../stores/player.js'; import { usePlayerStore, type Song, type Source } from '../stores/player.js';
+76
View File
@@ -16,10 +16,21 @@
<div class="playlist-stats"> <div class="playlist-stats">
{{ songs.length }} 首歌曲 {{ songs.length }} 首歌曲
</div> </div>
<div class="playlist-actions">
<button class="play-all-btn" @click="playAll"> <button class="play-all-btn" @click="playAll">
<Icon icon="mdi:play" /> <Icon icon="mdi:play" />
播放全部 播放全部
</button> </button>
<button
v-if="kind === 'playlist'"
class="fav-btn"
:class="{ favorited }"
@click="toggleFavorite"
>
<Icon :icon="favorited ? 'mdi:heart' : 'mdi:heart-outline'" />
{{ favorited ? '已收藏' : '收藏' }}
</button>
</div>
</div> </div>
</div> </div>
@@ -69,6 +80,7 @@ const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
const playlist = ref<PlaylistDetail | null>(null); const playlist = ref<PlaylistDetail | null>(null);
const songs = ref<Song[]>([]); const songs = ref<Song[]>([]);
const loading = ref(true); const loading = ref(true);
const favorited = ref(false);
async function playAll() { async function playAll() {
const id = route.params.id as string; const id = route.params.id as string;
@@ -126,8 +138,35 @@ onMounted(async () => {
} }
} }
songs.value = songList; songs.value = songList;
// Check favorite status after songs are resolved
if (kind === 'playlist') {
favorited.value = store.isFavorited(id, platform);
}
loading.value = false; loading.value = false;
}); });
async function toggleFavorite() {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
if (favorited.value) {
const fav = store.favoritedPlaylists.find((f) => f.playlistId === id && f.platform === platform);
if (fav) {
await store.removeFavorite(fav.id);
favorited.value = false;
}
} else {
await store.addFavorite({
platform,
playlistId: id,
name: playlist.value?.name ?? '未知歌单',
coverUrl: playlist.value?.coverUrl ?? '',
songCount: songs.value.length,
});
favorited.value = true;
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -193,6 +232,43 @@ onMounted(async () => {
&:active { transform: scale(0.96); } &:active { transform: scale(0.96); }
} }
.playlist-actions {
display: flex;
align-items: center;
gap: 12px;
}
.fav-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
font-size: 14px;
font-weight: 500;
transition: all var(--transition-fast);
cursor: pointer;
&:hover {
color: var(--color-primary);
border-color: var(--color-primary);
background: var(--color-primary-8);
}
&.favorited {
color: #e74c3c;
border-color: #e74c3c;
background: rgba(231, 76, 60, 0.08);
&:hover {
background: rgba(231, 76, 60, 0.15);
}
}
}
.song-list { .song-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+62
View File
@@ -92,6 +92,13 @@
class="card hover-scale" class="card hover-scale"
> >
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" /> <CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<button
class="fav-badge"
:class="{ favorited: isFav(pl) }"
@click.prevent.stop="toggleFavPlaylist(pl)"
>
<Icon :icon="isFav(pl) ? 'mdi:heart' : 'mdi:heart-outline'" />
</button>
<div class="card-name"> <div class="card-name">
{{ pl.name }} {{ pl.name }}
<span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span> <span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span>
@@ -179,6 +186,25 @@ watch(selectedSource, (src) => {
} }
}); });
function isFav(pl: { id: string; platform: string }): boolean {
return store.isFavorited(pl.id, pl.platform);
}
async function toggleFavPlaylist(pl: { id: string; platform: string; name: string; coverUrl: string; songCount?: number }) {
if (isFav(pl)) {
const fav = store.favoritedPlaylists.find((f) => f.playlistId === pl.id && f.platform === pl.platform);
if (fav) await store.removeFavorite(fav.id);
} else {
await store.addFavorite({
platform: pl.platform,
playlistId: pl.id,
name: pl.name,
coverUrl: pl.coverUrl,
songCount: pl.songCount ?? 0,
});
}
}
async function doSearch() { async function doSearch() {
if (!query.value.trim()) return; if (!query.value.trim()) return;
loading.value = true; loading.value = true;
@@ -356,6 +382,7 @@ onMounted(() => {
gap: 16px 28px; gap: 16px 28px;
} }
.card { .card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
@@ -394,4 +421,39 @@ onMounted(() => {
background: var(--brand-youtube-12); background: var(--brand-youtube-12);
color: var(--brand-youtube); color: var(--brand-youtube);
} }
.fav-badge {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.7);
font-size: 16px;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast);
z-index: 2;
.card:hover & {
opacity: 1;
}
&.favorited {
color: #e74c3c;
opacity: 1;
}
&:hover {
color: #e74c3c;
background: rgba(0, 0, 0, 0.7);
}
}
</style> </style>