mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
Merge pull request #62 from NoSetViolin/main
fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
This commit is contained in:
5 files changed
+374
-56
No files matched your search
@@ -19,8 +19,6 @@
|
||||
"Read(//tmp/**)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push * main)",
|
||||
"Bash(git push * master)",
|
||||
"Bash(git push --force *)",
|
||||
"Bash(rm -rf /)"
|
||||
]
|
||||
|
||||
+20
-2
@@ -638,9 +638,27 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
|
||||
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
|
||||
if (!cmd.args) return "Usage: !album <album ID>";
|
||||
if (!cmd.args) return "Usage: !album <album name or ID>";
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
const songs = await provider.getAlbumSongs(cmd.args);
|
||||
|
||||
const id = this.extractId(cmd.args);
|
||||
const isNumericId = /^\d+$/.test(cmd.args.trim());
|
||||
|
||||
let albumId: string;
|
||||
|
||||
if (isNumericId || id !== cmd.args) {
|
||||
// Input is a numeric ID or URL containing an ID — use directly
|
||||
albumId = id;
|
||||
} else {
|
||||
// Name-based search
|
||||
const result = await provider.search(cmd.args);
|
||||
const albums = result.albums ?? [];
|
||||
if (albums.length === 0)
|
||||
return `No albums found for: ${cmd.args}`;
|
||||
albumId = albums[0].id;
|
||||
}
|
||||
|
||||
const songs = await provider.getAlbumSongs(albumId);
|
||||
if (songs.length === 0) return "Album is empty or not found";
|
||||
|
||||
this.queue.clear();
|
||||
|
||||
@@ -112,12 +112,12 @@ export class NeteaseProvider implements MusicProvider {
|
||||
params: {
|
||||
keywords: query,
|
||||
type: 1000,
|
||||
limit: 5,
|
||||
limit: 10,
|
||||
...this.cookieParams,
|
||||
},
|
||||
}),
|
||||
this.api.get("/cloudsearch", {
|
||||
params: { keywords: query, type: 10, limit: 5, ...this.cookieParams },
|
||||
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
+133
-27
@@ -12,14 +12,25 @@ import type {
|
||||
} from "./provider.js";
|
||||
import { parseLyrics } from "./netease.js";
|
||||
|
||||
// Direct QQ Music API client — bypasses the local API server for search
|
||||
// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint.
|
||||
const qqDirectApi = axios.create({
|
||||
// Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request
|
||||
// batch). Was broken ca. 2026-05 due to two upstream API changes:
|
||||
// 1. searchid param must NOT be present (causes all lists to be empty)
|
||||
// 2. num_per_page must be >= 10 (lower values return empty)
|
||||
// Both fixes applied per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
||||
const qqMusicuApi = axios.create({
|
||||
baseURL: "https://u.y.qq.com",
|
||||
timeout: 10000,
|
||||
headers: { referer: "https://y.qq.com" },
|
||||
});
|
||||
|
||||
// Fallback search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic
|
||||
// endpoint, song + album only, no playlist support).
|
||||
const qqSearchApi = axios.create({
|
||||
baseURL: "https://c.y.qq.com",
|
||||
timeout: 10000,
|
||||
headers: { referer: "https://y.qq.com" },
|
||||
});
|
||||
|
||||
// Direct client for c.y.qq.com endpoints (collected playlists / favorites).
|
||||
// The bundled qq-music-api wrapper doesn't expose these endpoints.
|
||||
const qqFavApi = axios.create({
|
||||
@@ -82,37 +93,132 @@ export class QQMusicProvider implements MusicProvider {
|
||||
}
|
||||
|
||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||
const reqData = JSON.stringify({
|
||||
req_0: {
|
||||
module: "music.search.SearchCgiService",
|
||||
method: "DoSearchForQQMusicDesktop",
|
||||
param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 },
|
||||
},
|
||||
req_album: {
|
||||
module: "music.search.SearchCgiService",
|
||||
method: "DoSearchForQQMusicDesktop",
|
||||
param: { searchid: "1", query, num_per_page: 5, search_type: 8 },
|
||||
},
|
||||
});
|
||||
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
|
||||
params: { format: "json", data: reqData },
|
||||
});
|
||||
const list: any[] =
|
||||
res.data?.req_0?.data?.body?.song?.list ?? [];
|
||||
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
||||
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
||||
// (removed searchid, num_per_page >= 10, corrected search_type values).
|
||||
const primary = await this.searchViaMusicuFcg(query, limit);
|
||||
if (primary) return primary;
|
||||
|
||||
const songs: Song[] = list.map((s: any) => ({
|
||||
id: String(s.mid ?? s.id),
|
||||
name: s.title ?? s.name ?? "",
|
||||
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
|
||||
// no playlist support). Kept as redundancy.
|
||||
return this.searchViaClientSearchCp(query, limit);
|
||||
}
|
||||
|
||||
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
|
||||
*
|
||||
* Two upstream API changes (2026-05) required fixes:
|
||||
* 1. Omit `searchid` — its presence now causes all lists to be empty.
|
||||
* 2. `num_per_page` >= 10 — lower values return empty.
|
||||
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
|
||||
private async searchViaMusicuFcg(
|
||||
query: string,
|
||||
limit: number
|
||||
): Promise<SearchResult | null> {
|
||||
try {
|
||||
const numPerPage = Math.max(10, Math.min(limit, 50));
|
||||
const reqData = JSON.stringify({
|
||||
req_0: {
|
||||
module: "music.search.SearchCgiService",
|
||||
method: "DoSearchForQQMusicDesktop",
|
||||
param: { query, num_per_page: numPerPage, search_type: 0 },
|
||||
},
|
||||
req_album: {
|
||||
module: "music.search.SearchCgiService",
|
||||
method: "DoSearchForQQMusicDesktop",
|
||||
param: { query, num_per_page: 10, search_type: 2 },
|
||||
},
|
||||
req_playlist: {
|
||||
module: "music.search.SearchCgiService",
|
||||
method: "DoSearchForQQMusicDesktop",
|
||||
param: { query, num_per_page: 10, search_type: 3 },
|
||||
},
|
||||
});
|
||||
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
|
||||
params: { format: "json", data: reqData },
|
||||
});
|
||||
|
||||
const songList: any[] =
|
||||
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 albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
|
||||
const albums = mapQqAlbums(albumList);
|
||||
|
||||
const playlistList: any[] = res.data?.req_playlist?.data?.body?.songlist?.list ?? [];
|
||||
const playlists: Playlist[] = playlistList.map((p: any) => ({
|
||||
id: String(p.dissid ?? p.id ?? ""),
|
||||
name: p.dissname ?? p.title ?? "",
|
||||
coverUrl: p.imgurl ?? p.logo ?? "",
|
||||
songCount: p.songnum ?? p.song_count ?? 0,
|
||||
platform: "qq" as const,
|
||||
}));
|
||||
|
||||
return { songs, playlists, albums };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
|
||||
private async searchViaClientSearchCp(
|
||||
query: string,
|
||||
limit: number
|
||||
): Promise<SearchResult> {
|
||||
const songParams = {
|
||||
w: query,
|
||||
format: "json",
|
||||
p: 1,
|
||||
n: Math.min(limit, 50),
|
||||
type: 0,
|
||||
cr: 1,
|
||||
};
|
||||
const albumParams = {
|
||||
w: query,
|
||||
format: "json",
|
||||
p: 1,
|
||||
n: 5,
|
||||
t: 8,
|
||||
cr: 1,
|
||||
};
|
||||
|
||||
const [songRes, albumRes] = await Promise.allSettled([
|
||||
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }),
|
||||
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: albumParams }),
|
||||
]);
|
||||
|
||||
const songList: any[] =
|
||||
songRes.status === "fulfilled"
|
||||
? (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.album?.name ?? s.album?.title ?? "",
|
||||
album: s.albumname ?? s.album?.name ?? "",
|
||||
duration: s.interval ?? 0,
|
||||
coverUrl: s.album?.mid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
||||
coverUrl: s.albummid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
||||
: "",
|
||||
platform: "qq",
|
||||
}));
|
||||
|
||||
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
|
||||
const albumList: any[] =
|
||||
albumRes.status === "fulfilled"
|
||||
? (albumRes.value.data?.data?.album?.list ?? [])
|
||||
: [];
|
||||
const albums = mapQqAlbums(albumList);
|
||||
|
||||
return { songs, playlists: [], albums };
|
||||
|
||||
+219
-23
@@ -20,42 +20,89 @@
|
||||
|
||||
<div v-if="loading" class="loading">搜索中...</div>
|
||||
|
||||
<template v-else-if="songs.length || albums.length || playlists.length">
|
||||
<section v-if="albums.length" class="result-section">
|
||||
<h2 class="section-title">专辑</h2>
|
||||
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
|
||||
<div class="source-bar">
|
||||
<button
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'netease' }"
|
||||
@click="selectedSource = 'netease'"
|
||||
>网易云</button>
|
||||
<button
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'qq' }"
|
||||
@click="selectedSource = 'qq'"
|
||||
>QQ</button>
|
||||
<button
|
||||
class="source-btn"
|
||||
:class="{ active: selectedSource === 'bilibili' }"
|
||||
@click="selectedSource = 'bilibili'"
|
||||
>B站</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-bar">
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'songs' }"
|
||||
@click="activeTab = 'songs'"
|
||||
>
|
||||
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedSource !== 'bilibili'"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'albums' }"
|
||||
@click="activeTab = 'albums'"
|
||||
>
|
||||
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedSource !== 'bilibili'"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'playlists' }"
|
||||
@click="activeTab = 'playlists'"
|
||||
>
|
||||
歌单<span class="tab-count">{{ filteredPlaylists.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="activeTab === 'albums' && filteredAlbums.length" class="result-section">
|
||||
<div class="card-grid">
|
||||
<router-link
|
||||
v-for="al in albums"
|
||||
v-for="al in filteredAlbums"
|
||||
:key="`${al.platform}-${al.id}`"
|
||||
:to="`/album/${al.id}?platform=${al.platform}`"
|
||||
class="card hover-scale"
|
||||
>
|
||||
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="card-name">{{ al.name }}</div>
|
||||
<div class="card-name">
|
||||
{{ al.name }}
|
||||
<span class="platform-badge" :class="badgeClass(al.platform)">{{ badgeLabel(al.platform) }}</span>
|
||||
</div>
|
||||
<div class="card-sub">{{ al.artist }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="playlists.length" class="result-section">
|
||||
<h2 class="section-title">歌单</h2>
|
||||
<section v-if="activeTab === 'playlists' && filteredPlaylists.length" class="result-section">
|
||||
<div class="card-grid">
|
||||
<router-link
|
||||
v-for="pl in playlists"
|
||||
v-for="pl in filteredPlaylists"
|
||||
:key="`${pl.platform}-${pl.id}`"
|
||||
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
||||
class="card hover-scale"
|
||||
>
|
||||
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="card-name">{{ pl.name }}</div>
|
||||
<div class="card-name">
|
||||
{{ pl.name }}
|
||||
<span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="songs.length" class="result-section">
|
||||
<h2 class="section-title">单曲</h2>
|
||||
<section v-if="activeTab === 'songs' && filteredSongs.length" class="result-section">
|
||||
<SongCard
|
||||
v-for="(song, i) in songs"
|
||||
v-for="(song, i) in filteredSongs"
|
||||
:key="`${song.platform}-${song.id}`"
|
||||
:song="song"
|
||||
:index="i + 1"
|
||||
@@ -72,8 +119,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
@@ -83,34 +130,87 @@ import CoverArt from '../components/CoverArt.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const SOURCE_STORAGE_KEY = 'search-source';
|
||||
|
||||
function loadSource(): 'netease' | 'qq' | 'bilibili' {
|
||||
try {
|
||||
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
|
||||
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili') return stored;
|
||||
} catch { /* localStorage blocked */ }
|
||||
return 'netease';
|
||||
}
|
||||
|
||||
const query = ref((route.query.q as string) || '');
|
||||
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
|
||||
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource());
|
||||
|
||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
|
||||
const songs = ref<Song[]>([]);
|
||||
const albums = ref<Album[]>([]);
|
||||
const playlists = ref<Playlist[]>([]);
|
||||
const allSongs = ref<Song[]>([]);
|
||||
const allAlbums = ref<Album[]>([]);
|
||||
const allPlaylists = ref<Playlist[]>([]);
|
||||
const loading = ref(false);
|
||||
const searched = ref(false);
|
||||
|
||||
const filteredSongs = computed(() =>
|
||||
allSongs.value.filter((s) => s.platform === selectedSource.value)
|
||||
);
|
||||
|
||||
const filteredAlbums = computed(() =>
|
||||
allAlbums.value.filter((a) => a.platform === selectedSource.value)
|
||||
);
|
||||
|
||||
const filteredPlaylists = computed(() =>
|
||||
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
|
||||
);
|
||||
|
||||
// Persist source preference
|
||||
watch(selectedSource, (src) => {
|
||||
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// B站 has no albums/playlists — force songs tab when switching to B站
|
||||
watch(selectedSource, (src) => {
|
||||
if (src === 'bilibili' && activeTab.value !== 'songs') {
|
||||
activeTab.value = 'songs';
|
||||
}
|
||||
});
|
||||
|
||||
async function doSearch() {
|
||||
if (!query.value.trim()) return;
|
||||
loading.value = true;
|
||||
searched.value = true;
|
||||
activeTab.value = 'songs';
|
||||
router.replace({ query: { q: query.value } });
|
||||
try {
|
||||
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
||||
songs.value = res.data.songs ?? [];
|
||||
albums.value = res.data.albums ?? [];
|
||||
playlists.value = res.data.playlists ?? [];
|
||||
allSongs.value = res.data.songs ?? [];
|
||||
allAlbums.value = res.data.albums ?? [];
|
||||
allPlaylists.value = res.data.playlists ?? [];
|
||||
} catch {
|
||||
songs.value = []; albums.value = []; playlists.value = [];
|
||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function badgeLabel(platform: string): string {
|
||||
if (platform === 'qq') return 'QQ';
|
||||
if (platform === 'bilibili') return 'B站';
|
||||
if (platform === 'youtube') return 'YouTube';
|
||||
return '网易云';
|
||||
}
|
||||
|
||||
function badgeClass(platform: string): string {
|
||||
if (platform === 'qq') return 'badge-qq';
|
||||
if (platform === 'bilibili') return 'badge-bilibili';
|
||||
if (platform === 'youtube') return 'badge-youtube';
|
||||
return 'badge-netease';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (query.value) doSearch();
|
||||
});
|
||||
@@ -180,14 +280,80 @@ onMounted(() => {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.source-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.source-btn {
|
||||
padding: 5px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
font-weight: var(--fw-semi);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover { color: var(--text-primary); }
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
background: rgba(51, 94, 234, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 24px;
|
||||
padding: 4px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 20px;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
font-weight: var(--fw-semi);
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover { color: var(--text-primary); }
|
||||
|
||||
&.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
.tab-count { opacity: 0.85; }
|
||||
}
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
margin-left: 5px;
|
||||
opacity: 0.55;
|
||||
font-weight: var(--fw-regular);
|
||||
font-size: 13px;
|
||||
|
||||
&::before { content: '('; }
|
||||
&::after { content: ')'; }
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-bottom: 32px;
|
||||
.section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
gap: 16px 28px;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
@@ -198,4 +364,34 @@ onMounted(() => {
|
||||
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
|
||||
.card-sub { font-size: 12px; opacity: 0.6; }
|
||||
}
|
||||
|
||||
.platform-badge {
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--fs-micro);
|
||||
font-weight: var(--fw-semi);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.badge-netease {
|
||||
background: var(--brand-netease-15);
|
||||
color: var(--brand-netease);
|
||||
}
|
||||
|
||||
.badge-qq {
|
||||
background: var(--brand-qq-15);
|
||||
color: var(--brand-qq);
|
||||
}
|
||||
|
||||
.badge-bilibili {
|
||||
background: var(--brand-bilibili-15);
|
||||
color: var(--brand-bilibili);
|
||||
}
|
||||
|
||||
.badge-youtube {
|
||||
background: var(--brand-youtube-12);
|
||||
color: var(--brand-youtube);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user