feat: 实现主题、黑名单、排程等14项功能迭代
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 1m24s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s

- style(theme): 修复浅色模式不生效问题,为 ClassicLight 和 ModernLight 增加独立差异化 CSS 变量定义(主色/圆角/阴影/背景对比度)
- feat(blacklist): 后端放开 LANGUAGE/GENRE 类型校验;check API 新增语种(CJK/拉丁/假名/谚文/西里尔/阿拉伯)和曲风关键词匹配逻辑;前端 BlacklistManager UI 增加 语种/曲风 四类型切换、类型筛选、图标与标签颜色映射、输入提示
- fix(DataAnalysisPanel): 活跃用户列表容器加 max-h-[400px] + overflow-y-auto + 底部渐变遮罩;tooltip 加 max-height + truncate 防止超高;头像失败回退首字母
- feat(admin): 新增 user-avatar.ts 工具(OAuth→Gravatar→DiceBear 三级兜底);active-users/realtime/users 列表返回 avatarUrl;DataAnalysisPanel/UserManager 展示圆形头像;权限分级 ROLE_LEVELS,SUPER_ADMIN 全权限,ADMIN 仅可管理 ≤ USER 级用户的密码与角色,前端按钮联动禁用
- fix+style(schedule): ScheduleManager 标签页 sticky min-h 防抖、间距调优、表单分组、移动端 padding 统一
- feat(schedule): 自动排期新增「按投稿人数优先覆盖」策略(算法:requester + collaborators 总数降序),支持 CustomSelect 切换;新增 localStorage voicehub:schedule:auto-config 持久化 7 项配置(策略/时段/年级/排序/标签/搜索/学期)500ms debounce
- fix(schedule): songs/index.get 新增 usePool 参数,从 schedule_song_pool(status=PENDING,targetPlayDate IS NULL) 取歌并去重,排除已排期 songs.played=false;schedule.post 和 draft-bulk 分配后更新池状态为 ASSIGNED 避免重复分配
- feat(player): ScheduleManager 新增「构建播放队列」按钮,按 playDate→时段→sequence 排序后经 useAudioPlayer.playSong 整列载入并 toast 反馈
- fix(db): db.ts 检测 NETLIFY/VERCEL serverless 环境使用最小池 max=1 idle_timeout=1 keep_alive=false,Node Server 保持 max=10
- feat(site-config): site-config.get.ts 与 useSiteConfig.js 导出 statisticsCode 和 statisticsCodeEnabled;index.vue 挂载后解析 HTML 动态重插 script 元素确保第三方统计执行
- fix(schedule-printer): ScheduleItemPrint 封面 aspect-square object-cover;print-fix.css 打印时强制 48px×48px contain、去除圆角阴影
- fix(songRequest): 新增 server/utils/url.ts normalizeCoverUrl(trim/去零宽空格/相对协议补 https/非 http 丢弃/URL.parse 兜底);request.post/add.post/import.post 三处写入 cover/coverUrl 前统一规范化
This commit is contained in:
2026-09-12 08:40:59 +08:00
parent d09d12828d
commit d818819fff
15 changed files with 520 additions and 63 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ export default defineEventHandler(async (event) => {
})
}
if (!['SONG', 'KEYWORD'].includes(type)) {
if (!['SONG', 'KEYWORD', 'LANGUAGE', 'GENRE'].includes(type)) {
throw createError({
statusCode: 400,
message: '无效的黑名单类型'
+71 -10
View File
@@ -3,9 +3,39 @@ import { db } from '~/drizzle/db'
import { songBlacklists, systemSettings } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
function isCJK(text: string): boolean {
return /[\u4e00-\u9fa5]/.test(text)
}
function isLatin(text: string): boolean {
return /[A-Za-z]/.test(text)
}
function isKana(text: string): boolean {
return /[\u3040-\u30ff]/.test(text)
}
function isHangul(text: string): boolean {
return /[\uac00-\ud7af]/.test(text)
}
function isCyrillic(text: string): boolean {
return /[\u0400-\u04ff]/.test(text)
}
function isArabic(text: string): boolean {
return /[\u0600-\u06ff]/.test(text)
}
function detectLanguages(text: string): string[] {
const result: string[] = []
if (!text) return result
if (isCJK(text)) result.push('zh', '中文', 'chinese', 'cjk')
if (isLatin(text)) result.push('en', '英文', 'english', 'latin')
if (isKana(text)) result.push('ja', '日文', 'japanese')
if (isHangul(text)) result.push('ko', '韩文', 'korean')
if (isCyrillic(text)) result.push('ru', '俄文', 'russian', 'cyrillic')
if (isArabic(text)) result.push('ar', '阿拉伯文', 'arabic')
return result
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { title, artist } = body
const { title, artist, genre } = body
if (!title) {
throw createError({
@@ -15,24 +45,31 @@ export default defineEventHandler(async (event) => {
}
try {
// 获取系统设置
const systemSettingsResult = await db.select().from(systemSettings).limit(1)
const systemSettingsData = systemSettingsResult[0]
const showBlacklistKeywords = systemSettingsData?.showBlacklistKeywords ?? false
// 获取所有活跃的黑名单项
const blacklistItems = await db
.select()
.from(songBlacklists)
.where(eq(songBlacklists.isActive, true))
const songFullName = `${title} - ${artist || ''}`.toLowerCase()
const blocked = []
const genreLower = (genre || '').toLowerCase()
const combinedText = `${title} ${artist || ''} ${genre || ''}`
const textLanguages = new Set([
...detectLanguages(title || ''),
...detectLanguages(artist || ''),
...detectLanguages(genre || '')
])
const blocked: Array<{ type: string; value: string | null; reason: string }> = []
for (const item of blacklistItems) {
const valueLower = item.value.toLowerCase()
if (item.type === 'SONG') {
// 检查具体歌曲
if (songFullName.includes(item.value.toLowerCase())) {
if (songFullName.includes(valueLower)) {
blocked.push({
type: 'song',
value: item.value,
@@ -40,15 +77,38 @@ export default defineEventHandler(async (event) => {
})
}
} else if (item.type === 'KEYWORD') {
// 检查关键词
if (songFullName.includes(item.value.toLowerCase())) {
if (songFullName.includes(valueLower) || (genreLower && genreLower.includes(valueLower))) {
blocked.push({
type: 'keyword',
value: showBlacklistKeywords ? item.value : null, // 根据设置决定是否显示具体关键词
value: showBlacklistKeywords ? item.value : null,
reason:
item.reason || (showBlacklistKeywords ? `包含关键词:${item.value}` : '包含关键词')
})
}
} else if (item.type === 'LANGUAGE') {
const tokens = [
valueLower,
...valueLower.split(/[,,\/|\s]+/).map((s) => s.trim()).filter(Boolean)
]
const hit = tokens.some((lang) => textLanguages.has(lang))
if (hit) {
blocked.push({
type: 'language',
value: showBlacklistKeywords ? item.value : null,
reason: item.reason || `该语种歌曲已被限制:${showBlacklistKeywords ? item.value : ''}`.trim()
})
}
} else if (item.type === 'GENRE') {
const combinedForGenre = `${genreLower} ${combinedText}`.toLowerCase()
const tokens = valueLower.split(/[,,\/|\s]+/).map((s) => s.trim()).filter(Boolean)
const hit = tokens.some((tok) => combinedForGenre.includes(tok))
if (hit) {
blocked.push({
type: 'genre',
value: showBlacklistKeywords ? item.value : null,
reason: item.reason || `该曲风歌曲已被限制:${showBlacklistKeywords ? item.value : ''}`.trim()
})
}
}
}
@@ -57,7 +117,8 @@ export default defineEventHandler(async (event) => {
reasons: blocked,
song: {
title,
artist
artist,
genre: genre || null
}
}
} catch (error) {
+3 -1
View File
@@ -33,7 +33,9 @@ const publicFields = [
'scheduleDaysBeforeEnabled',
'scheduleDaysBefore',
'scheduleDaysAfterEnabled',
'scheduleDaysAfter'
'scheduleDaysAfter',
'statisticsCode',
'statisticsCodeEnabled'
]
const filterPublicSettings = (data: any) => {
+5 -2
View File
@@ -2,6 +2,7 @@ import { db } from '~/drizzle/db'
import { songs, users } from '~/drizzle/schema'
import { and, eq, or } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService'
import { normalizeCoverUrl } from '~~/server/utils/url'
export default defineEventHandler(async (event) => {
try {
@@ -87,6 +88,8 @@ export default defineEventHandler(async (event) => {
})
}
const normalizedCover = normalizeCoverUrl(cover, `add-song:${title || ''}`)
// 创建歌曲
const newSongResult = await db
.insert(songs)
@@ -99,8 +102,8 @@ export default defineEventHandler(async (event) => {
musicPlatform: musicPlatform || null,
musicId: musicId || null,
kgHash: musicPlatform === 'kugou' ? musicId : null,
cover: cover || null,
coverUrl: cover || null,
cover: normalizedCover,
coverUrl: normalizedCover,
playUrl: playUrl || null
})
.returning()
+5 -1
View File
@@ -2,6 +2,7 @@ import { defineEventHandler, createError, readBody } from 'h3'
import { db } from '~/drizzle/db'
import { songs, semesters, songBlacklists } from '~/drizzle/schema'
import { eq, inArray, and, asc } from 'drizzle-orm'
import { normalizeCoverUrl } from '~~/server/utils/url'
export default defineEventHandler(async (event) => {
const user = event.context.user
@@ -120,6 +121,8 @@ export default defineEventHandler(async (event) => {
continue
}
const normalizedCover = normalizeCoverUrl(song.cover, `import-song:${song.title || ''}`)
// 3. 准备插入数据
songsToInsert.push({
title: song.title,
@@ -129,7 +132,8 @@ export default defineEventHandler(async (event) => {
playedAt: null,
semester: currentSemesterName,
preferredPlayTimeId: null, // 重置偏好时间
cover: song.cover,
cover: normalizedCover,
coverUrl: normalizedCover,
playUrl: song.playUrl,
musicPlatform: song.musicPlatform,
musicId: song.musicId,
+5 -2
View File
@@ -14,6 +14,7 @@ import { createCollaborationInvitationNotification } from '~~/server/services/no
import { isLimitReached } from '~~/server/utils/submissionLimit'
import { getBeijingTimeISOString } from '~/utils/timeUtils'
import { moderateSubmissionNote } from '~~/server/utils/submissionNoteAutoModeration'
import { normalizeCoverUrl } from '~~/server/utils/url'
export default defineEventHandler(async (event) => {
// 检查用户认证
@@ -390,6 +391,8 @@ export default defineEventHandler(async (event) => {
}
}
const normalizedCover = normalizeCoverUrl(body.cover, `request-song:${body.title || ''}`)
const songResult = await tx
.insert(songs)
.values({
@@ -399,8 +402,8 @@ export default defineEventHandler(async (event) => {
requesterId: user.id,
preferredPlayTimeId: preferredPlayTime?.id || null,
semester: currentSemester, // 使用外部获取的学期名称
cover: body.cover || null,
coverUrl: body.cover || null,
cover: normalizedCover,
coverUrl: normalizedCover,
musicPlatform: isBilibili ? 'bilibili' : (body.musicPlatform || null),
musicId: finalMusicId,
kgHash: body.musicPlatform === 'kugou' ? body.musicId : null,
+1
View File
@@ -64,6 +64,7 @@ export const SYSTEM_SETTINGS_DEFAULTS = {
autoBackupEnabled: false,
autoBackupIntervalHours: 24,
autoBackupRetentionDays: 30,
statisticsCode: '',
statisticsCodeEnabled: false,
defaultTheme: 'ClassicLight',
enabledThemes: '["ClassicLight","ModernLight"]'
+41
View File
@@ -0,0 +1,41 @@
/**
* 规范化 URL:
* - trim()、去掉多余空格
* - 验证必须是 http(s):// 开头
* - 非法 URL 返回 null 并记录 warning log
* - 酷狗返回的相对路径 (//xxx) 补全为 https:// 协议头
*
* @param rawUrl 原始 URL
* @param context 日志上下文(如歌曲名、API名)
* @returns 规范化后的 URL,或 null(非法或空)
*/
export function normalizeCoverUrl(rawUrl: any, context: string = ''): string | null {
if (rawUrl === null || rawUrl === undefined) return null
let url = String(rawUrl)
.trim()
.replace(/[\u200B-\u200D\uFEFF]/g, '') // 去除零宽字符
.replace(/\s+/g, '') // 去除所有内部空格
if (!url) return null
// 酷狗/部分平台返回的相对协议路径://xxx.xxx.com/... => https://xxx.xxx.com/...
if (url.startsWith('//')) {
url = 'https:' + url
console.warn(`[normalizeCoverUrl] ${context ? context + ' ' : ''}相对协议路径已补全为 https: ${url}`)
}
// 相对路径 /xxx 或 纯路径不合法,直接 null
if (!/^https?:\/\//i.test(url)) {
console.warn(`[normalizeCoverUrl] ${context ? context + ' ' : ''}非法封面URL (非http(s)开头),已置为null: raw="${rawUrl}"`)
return null
}
try {
new URL(url)
return url
} catch (e: any) {
console.warn(`[normalizeCoverUrl] ${context ? context + ' ' : ''}URL解析失败,已置为null: "${url}" error=${e?.message || e}`)
return null
}
}