mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
Merge PR #88: feat(qq) QQ Music radar / personal FM stream
This commit is contained in:
commit
9bfe831022
6 files changed
+217
-84
No files matched your search
+40
-21
@@ -70,6 +70,7 @@ export class BotInstance extends EventEmitter {
|
||||
private autoPaused = false;
|
||||
private profileManager: BotProfileManager;
|
||||
private isFmMode = false;
|
||||
private fmProvider: MusicProvider | null = null;
|
||||
|
||||
constructor(options: BotInstanceOptions) {
|
||||
super();
|
||||
@@ -369,7 +370,7 @@ export class BotInstance extends EventEmitter {
|
||||
case "album":
|
||||
return this.cmdAlbum(cmd);
|
||||
case "fm":
|
||||
return this.cmdFm();
|
||||
return this.cmdFm(cmd);
|
||||
case "artist":
|
||||
return this.cmdArtist(cmd);
|
||||
case "vote":
|
||||
@@ -393,6 +394,11 @@ export class BotInstance extends EventEmitter {
|
||||
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
||||
}
|
||||
|
||||
private disableFmMode(): void {
|
||||
this.isFmMode = false;
|
||||
this.fmProvider = null;
|
||||
}
|
||||
|
||||
private getProvider(flags: Set<string>): MusicProvider {
|
||||
if (flags.has("b")) return this.bilibiliProvider;
|
||||
if (flags.has("q")) return this.qqProvider;
|
||||
@@ -443,10 +449,8 @@ export class BotInstance extends EventEmitter {
|
||||
platform: song.platform,
|
||||
coverUrl: song.coverUrl,
|
||||
});
|
||||
// Update bot presence (fire-and-forget — never blocks playback)
|
||||
this.profileManager.onSongChange(song).catch((err) => {
|
||||
this.logger.warn({ err }, "Profile update failed after song change");
|
||||
});
|
||||
// Keep TeamSpeak-side profile updates on the same path for play/next/FM.
|
||||
await this.syncProfileToSong(song);
|
||||
this.emit("stateChange");
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -455,6 +459,14 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
|
||||
try {
|
||||
await this.profileManager.onSongChange(song);
|
||||
} catch (err) {
|
||||
this.logger.warn({ err }, "Profile update failed after song change");
|
||||
}
|
||||
}
|
||||
|
||||
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
||||
if (!cmd.args) return "Usage: !play <song name or URL>";
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
@@ -464,7 +476,7 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
const song = result.songs[0];
|
||||
this.queue.clear();
|
||||
this.isFmMode = false;
|
||||
this.disableFmMode();
|
||||
this.queue.add({ ...song, platform: provider.platform });
|
||||
this.queue.play();
|
||||
|
||||
@@ -554,7 +566,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.player.stop();
|
||||
this.autoPaused = false;
|
||||
this.queue.clear();
|
||||
this.isFmMode = false;
|
||||
this.disableFmMode();
|
||||
this.profileManager.onSongChange(null).catch((err) => {
|
||||
this.logger.warn({ err }, "Profile restore failed on stop");
|
||||
});
|
||||
@@ -612,7 +624,7 @@ export class BotInstance extends EventEmitter {
|
||||
private cmdClear(): string {
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.isFmMode = false;
|
||||
this.disableFmMode();
|
||||
this.profileManager.onSongChange(null).catch((err) => {
|
||||
this.logger.warn({ err }, "Profile restore failed on clear");
|
||||
});
|
||||
@@ -685,7 +697,7 @@ export class BotInstance extends EventEmitter {
|
||||
if (songs.length === 0) return "Playlist is empty or not found";
|
||||
|
||||
this.queue.clear();
|
||||
this.isFmMode = false;
|
||||
this.disableFmMode();
|
||||
for (const song of songs) {
|
||||
this.queue.add({ ...song, platform: provider.platform });
|
||||
}
|
||||
@@ -720,7 +732,7 @@ export class BotInstance extends EventEmitter {
|
||||
if (songs.length === 0) return "Album is empty or not found";
|
||||
|
||||
this.queue.clear();
|
||||
this.isFmMode = false;
|
||||
this.disableFmMode();
|
||||
for (const song of songs) {
|
||||
this.queue.add({ ...song, platform: provider.platform });
|
||||
}
|
||||
@@ -730,26 +742,32 @@ export class BotInstance extends EventEmitter {
|
||||
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||
}
|
||||
|
||||
private async cmdFm(): Promise<string> {
|
||||
if (!this.neteaseProvider.getPersonalFm) {
|
||||
return "Personal FM is only available for NetEase Cloud Music";
|
||||
private async cmdFm(cmd: ParsedCommand): Promise<string> {
|
||||
return this.startFm(this.getProvider(cmd.flags));
|
||||
}
|
||||
|
||||
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> {
|
||||
if (!provider.getPersonalFm) {
|
||||
return `Personal FM is not available for ${provider.platform}`;
|
||||
}
|
||||
const songs = await this.neteaseProvider.getPersonalFm();
|
||||
const songs = await provider.getPersonalFm();
|
||||
if (songs.length === 0)
|
||||
return "No FM songs available (need to login first)";
|
||||
|
||||
this.queue.clear();
|
||||
for (const song of songs) {
|
||||
this.queue.add({ ...song, platform: "netease" });
|
||||
this.queue.add({ ...song, platform: provider.platform });
|
||||
}
|
||||
this.queue.setMode(PlayMode.Random);
|
||||
this.isFmMode = true;
|
||||
this.fmProvider = provider;
|
||||
this.player.resetFailures();
|
||||
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.emit("stateChange");
|
||||
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
||||
const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM";
|
||||
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
||||
}
|
||||
|
||||
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
|
||||
@@ -770,7 +788,7 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
|
||||
this.queue.clear();
|
||||
this.isFmMode = false;
|
||||
this.disableFmMode();
|
||||
for (const song of filtered) {
|
||||
this.queue.add({ ...song, platform: provider.platform });
|
||||
}
|
||||
@@ -784,14 +802,15 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
|
||||
private async refillFm(): Promise<void> {
|
||||
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
|
||||
const provider = this.fmProvider;
|
||||
if (!this.isFmMode || !provider?.getPersonalFm) return;
|
||||
try {
|
||||
const songs = await this.neteaseProvider.getPersonalFm();
|
||||
const songs = await provider.getPersonalFm();
|
||||
if (songs.length === 0) return;
|
||||
for (const song of songs) {
|
||||
this.queue.add({ ...song, platform: "netease" });
|
||||
this.queue.add({ ...song, platform: provider.platform });
|
||||
}
|
||||
this.logger.debug({ count: songs.length }, "FM queue refilled");
|
||||
this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled");
|
||||
} catch (err) {
|
||||
this.logger.error({ err }, "Failed to refill FM queue");
|
||||
}
|
||||
|
||||
+25
-1
@@ -1,7 +1,31 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mapQqAlbums } from "./qq.js";
|
||||
import { mapQqAlbums, mapQqSongs } from "./qq.js";
|
||||
|
||||
describe("QQ adapter", () => {
|
||||
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
||||
const out = mapQqSongs([
|
||||
{
|
||||
mid: "001abc",
|
||||
name: "Radar Song",
|
||||
singer: [{ name: "Singer A" }, { name: "Singer B" }],
|
||||
album: { name: "Album A", mid: "alb001" },
|
||||
interval: 243,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(out).toEqual([
|
||||
{
|
||||
id: "001abc",
|
||||
name: "Radar Song",
|
||||
artist: "Singer A / Singer B",
|
||||
album: "Album A",
|
||||
duration: 243,
|
||||
coverUrl: "https://y.gtimg.cn/music/photo_new/T002R300x300M000alb001.jpg",
|
||||
platform: "qq",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("mapQqAlbums maps albumMID-style raw entries", () => {
|
||||
const raw = [
|
||||
{
|
||||
|
||||
+106
-46
@@ -39,6 +39,24 @@ const qqFavApi = axios.create({
|
||||
headers: { referer: "https://y.qq.com/" },
|
||||
});
|
||||
|
||||
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((s) => {
|
||||
const albumMid = s.album?.mid ?? s.album?.pmid ?? s.albummid ?? s.albumMid ?? "";
|
||||
return {
|
||||
id: String(s.mid ?? s.songmid ?? s.songMID ?? s.id ?? s.songid ?? s.songId ?? ""),
|
||||
name: s.title ?? s.name ?? s.songname ?? "",
|
||||
artist: (s.singer ?? s.singers ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "),
|
||||
album: s.album?.name ?? s.album?.title ?? s.albumname ?? "",
|
||||
duration: s.interval ?? Math.round((s.duration ?? 0) / 1000),
|
||||
coverUrl: albumMid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
|
||||
: "",
|
||||
platform: "qq" as const,
|
||||
};
|
||||
}).filter((s) => s.id);
|
||||
}
|
||||
|
||||
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((a) => {
|
||||
@@ -72,6 +90,7 @@ export class QQMusicProvider implements MusicProvider {
|
||||
private api: AxiosInstance;
|
||||
private cookie = "";
|
||||
private quality = "exhigh";
|
||||
private radarPage = 1;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.api = axios.create({
|
||||
@@ -92,6 +111,30 @@ export class QQMusicProvider implements MusicProvider {
|
||||
return this.cookie ? { cookie: this.cookie } : {};
|
||||
}
|
||||
|
||||
private get directCookieHeaders(): Record<string, string> {
|
||||
return this.cookie ? { Cookie: this.cookie } : {};
|
||||
}
|
||||
|
||||
private buildMusicuPayload(module: string, method: string, param: Record<string, unknown>): Record<string, unknown> {
|
||||
const uinMatch = /(?:^|; )(?:uin|qqmusic_uin)=o?0?(\d+)/.exec(this.cookie);
|
||||
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
|
||||
return {
|
||||
comm: {
|
||||
ct: 24,
|
||||
cv: 4747474,
|
||||
platform: "yqq.json",
|
||||
uin: uinMatch ? uinMatch[1] : "0",
|
||||
g_tk: pSkeyMatch ? computeGtk(pSkeyMatch[1]) : 5381,
|
||||
format: "json",
|
||||
inCharset: "utf-8",
|
||||
outCharset: "utf-8",
|
||||
notice: 0,
|
||||
need_new_code: 1,
|
||||
},
|
||||
req_0: { module, method, param },
|
||||
};
|
||||
}
|
||||
|
||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
||||
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
||||
@@ -141,17 +184,7 @@ export class QQMusicProvider implements MusicProvider {
|
||||
res.data?.req_0?.data?.body?.song?.list ?? [];
|
||||
if (songList.length === 0) return null;
|
||||
|
||||
const songs: Song[] = songList.map((s: any) => ({
|
||||
id: String(s.mid ?? s.id),
|
||||
name: s.title ?? s.name ?? "",
|
||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.album?.name ?? s.album?.title ?? "",
|
||||
duration: s.interval ?? 0,
|
||||
coverUrl: s.album?.mid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
||||
: "",
|
||||
platform: "qq",
|
||||
}));
|
||||
const songs = mapQqSongs(songList);
|
||||
|
||||
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
|
||||
const albums = mapQqAlbums(albumList);
|
||||
@@ -203,17 +236,7 @@ export class QQMusicProvider implements MusicProvider {
|
||||
? (songRes.value.data?.data?.song?.list ?? [])
|
||||
: [];
|
||||
|
||||
const songs: Song[] = songList.map((s: any) => ({
|
||||
id: String(s.songmid ?? s.songid ?? ""),
|
||||
name: s.songname ?? s.name ?? "",
|
||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.albumname ?? s.album?.name ?? "",
|
||||
duration: s.interval ?? 0,
|
||||
coverUrl: s.albummid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
||||
: "",
|
||||
platform: "qq",
|
||||
}));
|
||||
const songs = mapQqSongs(songList);
|
||||
|
||||
const albumList: any[] =
|
||||
albumRes.status === "fulfilled"
|
||||
@@ -339,19 +362,7 @@ export class QQMusicProvider implements MusicProvider {
|
||||
});
|
||||
const cdlist = res.data?.response?.cdlist ?? [];
|
||||
if (cdlist.length === 0) return [];
|
||||
return (cdlist[0].songlist ?? []).map((s: any) => ({
|
||||
id: String(s.mid ?? s.songmid ?? s.songid),
|
||||
name: s.songname ?? s.name ?? "",
|
||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.albumname ?? "",
|
||||
duration: s.interval ?? 0,
|
||||
coverUrl: s.album?.mid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
||||
: s.albummid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
||||
: "",
|
||||
platform: "qq",
|
||||
}));
|
||||
return mapQqSongs(cdlist[0].songlist ?? []);
|
||||
}
|
||||
|
||||
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
||||
@@ -386,17 +397,7 @@ export class QQMusicProvider implements MusicProvider {
|
||||
const res = await this.api.get("/getAlbumInfo", {
|
||||
params: { albummid: albumId, ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.response?.data?.list ?? []).map((s: any) => ({
|
||||
id: String(s.songmid ?? s.songid),
|
||||
name: s.songname ?? "",
|
||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.albumname ?? "",
|
||||
duration: s.interval ?? 0,
|
||||
coverUrl: s.albummid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
||||
: "",
|
||||
platform: "qq",
|
||||
}));
|
||||
return mapQqSongs(res.data?.response?.data?.list ?? []);
|
||||
}
|
||||
|
||||
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||
@@ -522,6 +523,65 @@ export class QQMusicProvider implements MusicProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async getPersonalFm(): Promise<Song[]> {
|
||||
const radarSongs = await this.getRadarRecommendSongs();
|
||||
if (radarSongs.length > 0) return radarSongs;
|
||||
return this.getGuessRecommendSongs();
|
||||
}
|
||||
|
||||
private async getRadarRecommendSongs(): Promise<Song[]> {
|
||||
try {
|
||||
const page = this.radarPage;
|
||||
const res = await qqMusicuApi.post(
|
||||
"/cgi-bin/musicu.fcg",
|
||||
this.buildMusicuPayload(
|
||||
"music.recommend.TrackRelationServer",
|
||||
"GetRadarSong",
|
||||
{
|
||||
Page: page,
|
||||
ReqType: 0,
|
||||
FavSongs: [],
|
||||
EntranceSongs: [],
|
||||
}
|
||||
),
|
||||
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
|
||||
);
|
||||
const tracks = (res.data?.req_0?.data?.VecSongs ?? [])
|
||||
.map((item: any) => item?.Track)
|
||||
.filter(Boolean);
|
||||
const songs = mapQqSongs(tracks);
|
||||
if (songs.length > 0) {
|
||||
this.radarPage = page + 1;
|
||||
}
|
||||
return songs;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getGuessRecommendSongs(): Promise<Song[]> {
|
||||
try {
|
||||
const res = await qqMusicuApi.post(
|
||||
"/cgi-bin/musicu.fcg",
|
||||
this.buildMusicuPayload(
|
||||
"music.radioProxy.MbTrackRadioSvr",
|
||||
"get_radio_track",
|
||||
{
|
||||
id: 99,
|
||||
num: 5,
|
||||
from: 0,
|
||||
scene: 0,
|
||||
song_ids: [],
|
||||
}
|
||||
),
|
||||
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
|
||||
);
|
||||
return mapQqSongs(res.data?.req_0?.data?.Tracks ?? []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getUserPlaylists(): Promise<Playlist[]> {
|
||||
if (!this.cookie) return [];
|
||||
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
|
||||
|
||||
@@ -87,6 +87,22 @@ export function createPlayerRouter(
|
||||
router.post("/:botId/stop", simpleCommand("!stop"));
|
||||
router.post("/:botId/clear", simpleCommand("!clear"));
|
||||
|
||||
router.post("/:botId/fm", async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { platform } = req.body;
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
const message = await bot.startFm(provider);
|
||||
res.json({ ok: !message.startsWith("No FM songs") && !message.includes("not available"), message });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/volume", async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
|
||||
@@ -438,6 +438,17 @@ export const usePlayerStore = defineStore('player', {
|
||||
if (bot) bot.playMode = mode;
|
||||
},
|
||||
|
||||
async startFm(platform: Source = 'netease') {
|
||||
if (!this.activeBotId) return;
|
||||
const res = await axios.post(`/api/player/${this.activeBotId}/fm`, { platform });
|
||||
if (res.data?.message) {
|
||||
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||
}
|
||||
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||
this._syncAfterAction();
|
||||
this.fetchQueue();
|
||||
},
|
||||
|
||||
async fetchHomeData() {
|
||||
// Always check auth status first — if it changed since the cached
|
||||
// fetch (e.g., user logged in/out as a different account), the
|
||||
|
||||
+19
-16
@@ -21,7 +21,7 @@
|
||||
<!-- 私人FM -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">私人FM</h2>
|
||||
<div class="fm-card hover-scale" @click="playFm">
|
||||
<div class="fm-card hover-scale" @click="playFm('netease')">
|
||||
<div class="fm-icon-wrapper">
|
||||
<Icon icon="mdi:radio" class="fm-icon" />
|
||||
</div>
|
||||
@@ -31,6 +31,16 @@
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
<div v-if="store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
|
||||
<div class="fm-icon-wrapper qq">
|
||||
<Icon icon="mdi:radar" class="fm-icon" />
|
||||
</div>
|
||||
<div class="fm-info">
|
||||
<div class="fm-title">QQ音乐雷达</div>
|
||||
<div class="fm-desc">猜你喜欢 / 雷达推荐歌曲流</div>
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 每日推荐 -->
|
||||
@@ -126,8 +136,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
|
||||
import { usePlayerStore, type Source } from '../stores/player.js';
|
||||
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import SourceTabs from '../components/SourceTabs.vue';
|
||||
@@ -180,19 +189,8 @@ const visibleUserPlaylists = computed(() =>
|
||||
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
|
||||
);
|
||||
|
||||
async function playFm() {
|
||||
try {
|
||||
const res = await axios.get('/api/music/personal/fm');
|
||||
const songs: Song[] = res.data.songs;
|
||||
if (songs.length > 0) {
|
||||
await store.play(songs[0].name, songs[0].platform);
|
||||
for (let i = 1; i < songs.length; i++) {
|
||||
await store.addToQueue(songs[i].name, songs[i].platform);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
async function playFm(platform: Source) {
|
||||
await store.startFm(platform);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -318,6 +316,7 @@ onMounted(() => {
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
@@ -333,6 +332,10 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.qq {
|
||||
background: linear-gradient(135deg, var(--brand-qq), #17a2b8);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-icon {
|
||||
|
||||
Reference in new issue
Block a user