diff --git a/app/assets/css/print-fix.css b/app/assets/css/print-fix.css index 1805e4b..6ef1703 100644 --- a/app/assets/css/print-fix.css +++ b/app/assets/css/print-fix.css @@ -97,18 +97,29 @@ font-size: 14px !important; } - /* 封面 */ + /* 封面 - 打印模式下固定尺寸,移除圆角阴影,使用 contain 防止裁切 */ .schedule-item-print .cover-section { - width: 40px !important; - height: 40px !important; + width: 48px !important; + height: 48px !important; + aspect-ratio: 1 / 1 !important; flex-shrink: 0 !important; } - .schedule-item-print .song-cover, + .schedule-item-print .song-cover { + width: 100% !important; + height: 100% !important; + object-fit: contain !important; + border-radius: 0 !important; + box-shadow: none !important; + } + .schedule-item-print .cover-placeholder { width: 100% !important; height: 100% !important; - border-radius: 4px !important; + aspect-ratio: 1 / 1 !important; + object-fit: contain !important; + border-radius: 0 !important; + box-shadow: none !important; } /* 歌曲信息 */ diff --git a/app/assets/css/variables.css b/app/assets/css/variables.css index 30cf9f1..30dce2a 100644 --- a/app/assets/css/variables.css +++ b/app/assets/css/variables.css @@ -203,7 +203,9 @@ --scrollbar-thumb-hover: #4a4a4a; } -:root[data-theme='light'] { +:root[data-theme='light'], +:root[data-theme='ClassicLight'], +:root[data-theme='ModernLight'] { --bg-primary: #f8fafc; --bg-secondary: #f1f5f9; --bg-tertiary: #e2e8f0; @@ -223,6 +225,177 @@ --modal-backdrop: rgba(0, 0, 0, 0.4); --scrollbar-thumb-hover: #94a3b8; + + --btn-secondary-bg: #f1f5f9; + --btn-secondary-hover: #e2e8f0; + --btn-secondary-text: #0f172a; + --btn-secondary-border: #cbd5e1; + + --card-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +:root[data-theme='ClassicLight'] { + --primary: #1e40af; + --primary-hover: #1e3a8a; + --primary-light: rgba(30, 64, 175, 0.08); + --primary-border: rgba(30, 64, 175, 0.15); + + --bg-primary: #ffffff; + --bg-secondary: #f5f7fa; + --bg-tertiary: #edf0f5; + --bg-quaternary: #d9dee7; + --bg-hover: #f0f3f8; + + --success: #059669; + --success-hover: #047857; + --success-light: rgba(5, 150, 105, 0.08); + --success-border: rgba(5, 150, 105, 0.15); + + --warning: #d97706; + --warning-hover: #b45309; + --warning-light: rgba(217, 119, 6, 0.08); + --warning-border: rgba(217, 119, 6, 0.15); + + --error: #dc2626; + --error-hover: #b91c1c; + --error-light: rgba(220, 38, 38, 0.08); + --error-border: rgba(220, 38, 38, 0.15); + + --info: #2563eb; + --info-hover: #1d4ed8; + --info-light: rgba(37, 99, 235, 0.08); + --info-border: rgba(37, 99, 235, 0.15); + + --text-primary: #1a2332; + --text-secondary: #4a5568; + --text-tertiary: #6b7280; + --text-quaternary: #9ca3af; + --text-disabled: #c5cbd3; + + --border-primary: #e5e7eb; + --border-secondary: #d1d5db; + --border-tertiary: #9ca3af; + --border-quaternary: #6b7280; + + --input-bg: #ffffff; + --input-border: #d1d5db; + --input-border-focus: #1e40af; + --input-text: #1a2332; + --input-placeholder: #9ca3af; + --input-shadow-focus: 0 0 0 3px rgba(30, 64, 175, 0.1); + + --card-bg: #ffffff; + --card-border: #e5e7eb; + --card-hover-border: #d1d5db; + + --table-header-bg: #f5f7fa; + --table-row-hover: #f9fafb; + --table-border: #e5e7eb; + --table-selected: rgba(30, 64, 175, 0.06); + + --scrollbar-track: #f1f5f9; + --scrollbar-thumb: #cbd5e1; + --scrollbar-thumb-hover: #94a3b8; + + --status-pending: #ca8a04; + --status-pending-light: rgba(202, 138, 4, 0.08); + --status-approved: #059669; + --status-approved-light: rgba(5, 150, 105, 0.08); + --status-played: #6b7280; + --status-played-light: rgba(107, 114, 128, 0.08); + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -2px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.06), 0 4px 6px -4px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.06), 0 8px 10px -6px rgba(0, 0, 0, 0.04); + + --radius-sm: 3px; + --radius-md: 5px; + --radius-lg: 6px; + --radius-xl: 8px; + --radius-2xl: 10px; +} + +:root[data-theme='ModernLight'] { + --primary: #4f46e5; + --primary-hover: #4338ca; + --primary-light: rgba(79, 70, 229, 0.1); + --primary-border: rgba(79, 70, 229, 0.2); + + --bg-primary: #fafafa; + --bg-secondary: #f4f4f5; + --bg-tertiary: #e4e4e7; + --bg-quaternary: #d4d4d8; + --bg-hover: #eeeeef; + + --success: #16a34a; + --success-hover: #15803d; + --success-light: rgba(22, 163, 74, 0.1); + --success-border: rgba(22, 163, 74, 0.2); + + --warning: #ea580c; + --warning-hover: #c2410c; + --warning-light: rgba(234, 88, 12, 0.1); + --warning-border: rgba(234, 88, 12, 0.2); + + --error: #e11d48; + --error-hover: #be123c; + --error-light: rgba(225, 29, 72, 0.1); + --error-border: rgba(225, 29, 72, 0.2); + + --info: #0ea5e9; + --info-hover: #0284c7; + --info-light: rgba(14, 165, 233, 0.1); + --info-border: rgba(14, 165, 233, 0.2); + + --text-primary: #09090b; + --text-secondary: #3f3f46; + --text-tertiary: #71717a; + --text-quaternary: #a1a1aa; + --text-disabled: #d4d4d8; + + --border-primary: #e4e4e7; + --border-secondary: #d4d4d8; + --border-tertiary: #a1a1aa; + --border-quaternary: #71717a; + + --input-bg: #ffffff; + --input-border: #e4e4e7; + --input-border-focus: #4f46e5; + --input-text: #09090b; + --input-placeholder: #a1a1aa; + --input-shadow-focus: 0 0 0 3px rgba(79, 70, 229, 0.12); + + --card-bg: #ffffff; + --card-border: #e4e4e7; + --card-hover-border: #d4d4d8; + + --table-header-bg: #f4f4f5; + --table-row-hover: #fafafa; + --table-border: #e4e4e7; + --table-selected: rgba(79, 70, 229, 0.08); + + --scrollbar-track: #f4f4f5; + --scrollbar-thumb: #d4d4d8; + --scrollbar-thumb-hover: #a1a1aa; + + --status-pending: #eab308; + --status-pending-light: rgba(234, 179, 8, 0.12); + --status-approved: #22c55e; + --status-approved-light: rgba(34, 197, 94, 0.12); + --status-played: #71717a; + --status-played-light: rgba(113, 113, 122, 0.12); + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03); + --shadow-md: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 3px 0 rgba(0, 0, 0, 0.03); + --shadow-lg: 0 8px 24px -4px rgba(0, 0, 0, 0.06), 0 2px 8px -4px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 16px 40px -8px rgba(0, 0, 0, 0.08), 0 4px 16px -8px rgba(0, 0, 0, 0.05); + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 10px; + --radius-2xl: 12px; } /* 深色主题特定调整 */ diff --git a/app/components/Admin/BlacklistManager.vue b/app/components/Admin/BlacklistManager.vue index 17c450e..1e3196d 100644 --- a/app/components/Admin/BlacklistManager.vue +++ b/app/components/Admin/BlacklistManager.vue @@ -20,17 +20,17 @@ 添加黑名单项
-
+
-
+
+ +
+
+

+ + +

+
+
@@ -142,27 +165,21 @@
- + + +
- {{ item.type === 'SONG' ? '具体歌曲' : '关键词' }} + {{ getTypeLabel(item.type) }}
{{ item.value }} @@ -334,7 +351,9 @@ import { Ban, ChevronLeft, ChevronRight, - ChevronDown + ChevronDown, + Languages, + Disc3 } from '@lucide/vue' import { useToast } from '~/composables/useToast' import CustomSelect from '~/components/UI/Common/CustomSelect.vue' @@ -371,9 +390,65 @@ const pagination = reactive({ const typeFilterLabel = computed(() => { if (filters.type === 'SONG') return '具体歌曲' if (filters.type === 'KEYWORD') return '关键词' + if (filters.type === 'LANGUAGE') return '语种' + if (filters.type === 'GENRE') return '曲风' return '全部类型' }) +const typeValueLabel = computed(() => { + if (newItem.type === 'SONG') return '歌曲名称 - 艺术家' + if (newItem.type === 'KEYWORD') return '关键词内容' + if (newItem.type === 'LANGUAGE') return '语种名称' + if (newItem.type === 'GENRE') return '曲风名称' + return '内容' +}) + +const typePlaceholder = computed(() => { + if (newItem.type === 'SONG') return '例如: 歌曲名 - 艺术家' + if (newItem.type === 'KEYWORD') return '例如: 敏感词、特定风格' + if (newItem.type === 'LANGUAGE') return '例如: 日文 或 中文,英文,日文' + if (newItem.type === 'GENRE') return '例如: DJ 或 流行,摇滚,rap' + return '请输入内容' +}) + +const getTypeLabel = (type: string) => { + if (type === 'SONG') return '具体歌曲' + if (type === 'KEYWORD') return '关键词' + if (type === 'LANGUAGE') return '语种' + if (type === 'GENRE') return '曲风' + return type +} + +const getTypeIconClass = (type: string) => { + switch (type) { + case 'SONG': + return 'bg-blue-600/10 text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/5' + case 'KEYWORD': + return 'bg-purple-600/10 text-purple-500 border-purple-500/20 shadow-lg shadow-purple-900/5' + case 'LANGUAGE': + return 'bg-amber-600/10 text-amber-500 border-amber-500/20 shadow-lg shadow-amber-900/5' + case 'GENRE': + return 'bg-emerald-600/10 text-emerald-500 border-emerald-500/20 shadow-lg shadow-emerald-900/5' + default: + return 'bg-zinc-600/10 text-zinc-500 border-zinc-500/20' + } +} + +const getTypeBadgeClass = (type: string) => { + switch (type) { + case 'SONG': + return 'bg-blue-600/10 text-blue-400 border-blue-500/20' + case 'KEYWORD': + return 'bg-purple-600/10 text-purple-400 border-purple-500/20' + case 'LANGUAGE': + return 'bg-amber-600/10 text-amber-400 border-amber-500/20' + case 'GENRE': + return 'bg-emerald-600/10 text-emerald-400 border-emerald-500/20' + default: + return 'bg-zinc-600/10 text-zinc-400 border-zinc-500/20' + } +} + let searchTimeout = null // 加载黑名单 @@ -404,10 +479,11 @@ const loadBlacklist = async () => { } } -// 处理类型筛选变更 const handleTypeFilterChange = (label) => { if (label === '具体歌曲') filters.type = 'SONG' else if (label === '关键词') filters.type = 'KEYWORD' + else if (label === '语种') filters.type = 'LANGUAGE' + else if (label === '曲风') filters.type = 'GENRE' else filters.type = '' pagination.page = 1 diff --git a/app/components/Admin/ScheduleItemPrint.vue b/app/components/Admin/ScheduleItemPrint.vue index 5d131b1..51b2c1b 100644 --- a/app/components/Admin/ScheduleItemPrint.vue +++ b/app/components/Admin/ScheduleItemPrint.vue @@ -7,16 +7,16 @@
-
+
-
+
@@ -119,6 +119,7 @@ const handleImageError = (event) => { .cover-section { width: 40px; height: 40px; + aspect-ratio: 1 / 1; flex-shrink: 0; } @@ -127,11 +128,14 @@ const handleImageError = (event) => { height: 100%; object-fit: cover; border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .cover-placeholder { width: 100%; height: 100%; + aspect-ratio: 1 / 1; + object-fit: contain; background: #f5f5f5; border-radius: 4px; display: none; @@ -233,11 +237,27 @@ const handleImageError = (event) => { } .cover-section { - width: 40px !important; - height: 40px !important; + width: 48px !important; + height: 48px !important; + aspect-ratio: 1 / 1 !important; flex-shrink: 0 !important; } + .song-cover { + width: 100% !important; + height: 100% !important; + object-fit: contain !important; + border-radius: 0 !important; + box-shadow: none !important; + } + + .cover-placeholder { + width: 100% !important; + height: 100% !important; + object-fit: contain !important; + border-radius: 0 !important; + } + .song-info { flex: 1 !important; min-width: 0 !important; diff --git a/app/composables/useSiteConfig.js b/app/composables/useSiteConfig.js index 3971ae7..fcc8598 100644 --- a/app/composables/useSiteConfig.js +++ b/app/composables/useSiteConfig.js @@ -21,7 +21,9 @@ const siteConfig = ref({ icpNumber: '', gonganNumber: '', defaultTheme: null, - enabledThemes: null + enabledThemes: null, + statisticsCode: '', + statisticsCodeEnabled: false }) const isLoaded = ref(false) @@ -61,7 +63,9 @@ export const useSiteConfig = () => { enableCollaborativeSubmission: true, enableSubmissionRemarks: false, defaultTheme: 'ClassicLight', - enabledThemes: '["ClassicLight","ModernLight"]' + enabledThemes: '["ClassicLight","ModernLight"]', + statisticsCode: '', + statisticsCodeEnabled: false } isLoaded.value = true } finally { @@ -99,6 +103,8 @@ export const useSiteConfig = () => { ) const enableSubmissionRemarks = computed(() => siteConfig.value.enableSubmissionRemarks === true) const smtpEnabled = computed(() => !!siteConfig.value.smtpEnabled) + const statisticsCode = computed(() => siteConfig.value.statisticsCode || '') + const statisticsCodeEnabled = computed(() => !!siteConfig.value.statisticsCodeEnabled) function parseEnabledThemes(raw) { if (Array.isArray(raw)) return raw.filter(Boolean) @@ -158,6 +164,8 @@ export const useSiteConfig = () => { smtpEnabled, defaultTheme, enabledThemes, + statisticsCode, + statisticsCodeEnabled, fetchSiteConfig, initSiteConfig, refreshSiteConfig diff --git a/app/drizzle/db.ts b/app/drizzle/db.ts index 6d2b88f..abe7f4d 100644 --- a/app/drizzle/db.ts +++ b/app/drizzle/db.ts @@ -20,17 +20,21 @@ const connectionString = process.env.DATABASE_URL; // 检测数据库类型 const isNeonDatabase = connectionString.includes('neon.tech') || connectionString.includes('neon.database.com'); -// 根据数据库类型选择配置 +// 检测 Serverless 部署环境 +const isServerlessEnv = process.env.NETLIFY === 'true' || !!process.env.VERCEL; + +// 根据数据库类型和部署环境选择配置 const getDatabaseConfig = () => { - if (isNeonDatabase) { - // Neon Database Serverless 优化配置 + if (isNeonDatabase || isServerlessEnv) { + // Neon Database / Serverless 环境 (Netlify/Vercel) 优化配置 return { - max: 1, // Serverless 环境下每个实例保持最小连接数,利用 Neon 自身的连接池 - idle_timeout: 0, // 立即释放空闲连接,适应 Serverless 的快速冻结特性 - connect_timeout: 10, // Neon 连接速度快,减少超时时间 + max: 1, // Serverless 环境下每个实例保持最小连接数,利用平台自身的连接池 + idle_timeout: 1, // 尽快释放空闲连接,适应 Serverless 的快速冻结特性 + connect_timeout: 10, // 减少连接超时时间 max_lifetime: 3600, // 连接最大生命周期(1小时) - ssl: 'require', // Neon 默认需要 SSL + ssl: isNeonDatabase ? 'require' : (connectionString.includes('sslmode=require') || connectionString.includes('ssl=true') ? 'require' : false), prepare: false, // 禁用预处理语句以提高兼容性 + keep_alive: false, // 禁用 TCP keep-alive,避免 Serverless 环境下连接残留 transform: { undefined: null, // 将undefined转换为null }, @@ -41,11 +45,11 @@ const getDatabaseConfig = () => { debug: process.env.NODE_ENV === 'development' && process.env.DEBUG_SQL === 'true' }; } else { - // 标准 PostgreSQL 数据库配置 + // 标准 PostgreSQL 数据库配置(本地开发 / 常规 Node Server) return { - max: process.env.NODE_ENV === 'production' ? 10 : 5, // 普通PostgreSQL可以支持更多连接 - idle_timeout: 20, // 增加空闲超时时间 - connect_timeout: 30, // 增加连接超时时间以适应网络延迟 + max: 10, // 普通PostgreSQL可以支持更多连接 + idle_timeout: 20, // 空闲超时时间 + connect_timeout: 30, // 连接超时时间以适应网络延迟 max_lifetime: 3600, // 连接最大生命周期(1小时) ssl: connectionString.includes('sslmode=require') || connectionString.includes('ssl=true') ? 'require' : false, prepare: false, // 禁用预处理语句以提高兼容性 diff --git a/app/pages/index.vue b/app/pages/index.vue index 33fe93f..df5d8a9 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -653,6 +653,13 @@
+ + @@ -680,6 +687,8 @@ const { guidelines: submissionGuidelines, icp: icpNumber, schoolLogoHomeUrl, + statisticsCode, + statisticsCodeEnabled, initSiteConfig } = useSiteConfig() @@ -1174,6 +1183,15 @@ onMounted(async () => { } }) } + + // 注入站点统计代码(兜底方案:动态插入 script 到 body 末尾) + if (statisticsCodeEnabled.value && statisticsCode.value) { + try { + injectStatisticsCode(statisticsCode.value) + } catch (e) { + console.warn('统计代码注入失败:', e) + } + } } catch (error) { console.error('首页初始化失败:', error) await Promise.allSettled([songs.fetchPublicSchedules(), songs.fetchSongCount()]) @@ -1522,6 +1540,38 @@ const showLoginNotice = () => { } } +// 注入站点统计代码(兜底方案:动态解析并插入 script 元素到 body 末尾) +const injectStatisticsCode = (code) => { + if (typeof document === 'undefined' || !code) return + + // 先尝试用临时容器解析 HTML,提取其中的 script 标签 + const tempDiv = document.createElement('div') + tempDiv.innerHTML = code + + // 将非 script 的元素先附加到容器中 + const container = document.getElementById('site-statistics-code') + if (container) { + Array.from(tempDiv.childNodes).forEach((node) => { + if (node.nodeType === 1 && node.tagName && node.tagName.toLowerCase() !== 'script') { + container.appendChild(node.cloneNode(true)) + } + }) + } + + // 单独处理 script 标签:动态创建并插入到 body 末尾,确保能正确执行 + const scripts = tempDiv.querySelectorAll('script') + scripts.forEach((oldScript) => { + const newScript = document.createElement('script') + Array.from(oldScript.attributes).forEach((attr) => { + newScript.setAttribute(attr.name, attr.value) + }) + if (oldScript.textContent) { + newScript.textContent = oldScript.textContent + } + document.body.appendChild(newScript) + }) +} + // 检查用户是否需要修改密码 const checkPasswordChangeRequired = async (user = null) => { try { diff --git a/server/api/admin/blacklist/index.post.ts b/server/api/admin/blacklist/index.post.ts index 515328a..cded730 100644 --- a/server/api/admin/blacklist/index.post.ts +++ b/server/api/admin/blacklist/index.post.ts @@ -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: '无效的黑名单类型' diff --git a/server/api/blacklist/check.post.ts b/server/api/blacklist/check.post.ts index b0a2922..f666348 100644 --- a/server/api/blacklist/check.post.ts +++ b/server/api/blacklist/check.post.ts @@ -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) { diff --git a/server/api/site-config.get.ts b/server/api/site-config.get.ts index d0244a7..0b427a6 100644 --- a/server/api/site-config.get.ts +++ b/server/api/site-config.get.ts @@ -33,7 +33,9 @@ const publicFields = [ 'scheduleDaysBeforeEnabled', 'scheduleDaysBefore', 'scheduleDaysAfterEnabled', - 'scheduleDaysAfter' + 'scheduleDaysAfter', + 'statisticsCode', + 'statisticsCodeEnabled' ] const filterPublicSettings = (data: any) => { diff --git a/server/api/songs/add.post.ts b/server/api/songs/add.post.ts index ee7850c..b625176 100644 --- a/server/api/songs/add.post.ts +++ b/server/api/songs/add.post.ts @@ -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() diff --git a/server/api/songs/import.post.ts b/server/api/songs/import.post.ts index fb48f03..6224ace 100644 --- a/server/api/songs/import.post.ts +++ b/server/api/songs/import.post.ts @@ -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, diff --git a/server/api/songs/request.post.ts b/server/api/songs/request.post.ts index abf422f..22f9761 100644 --- a/server/api/songs/request.post.ts +++ b/server/api/songs/request.post.ts @@ -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, diff --git a/server/utils/system-settings-defaults.ts b/server/utils/system-settings-defaults.ts index df6a405..2627dca 100644 --- a/server/utils/system-settings-defaults.ts +++ b/server/utils/system-settings-defaults.ts @@ -64,6 +64,7 @@ export const SYSTEM_SETTINGS_DEFAULTS = { autoBackupEnabled: false, autoBackupIntervalHours: 24, autoBackupRetentionDays: 30, + statisticsCode: '', statisticsCodeEnabled: false, defaultTheme: 'ClassicLight', enabledThemes: '["ClassicLight","ModernLight"]' diff --git a/server/utils/url.ts b/server/utils/url.ts new file mode 100644 index 0000000..ad3c3c5 --- /dev/null +++ b/server/utils/url.ts @@ -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 + } +}