Files
BYW-VoiceHub/patches/0044-feat-14.patch
T

1055 lines
40 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
From d818819fff811ab2dbcf835f8052bfcc19b3b2c1 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 08:40:59 +0800
Subject: [PATCH 44/58] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=B8=BB?=
=?UTF-8?q?=E9=A2=98=E3=80=81=E9=BB=91=E5=90=8D=E5=8D=95=E3=80=81=E6=8E=92?=
=?UTF-8?q?=E7=A8=8B=E7=AD=8914=E9=A1=B9=E5=8A=9F=E8=83=BD=E8=BF=AD?=
=?UTF-8?q?=E4=BB=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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 前统一规范化
---
app/assets/css/print-fix.css | 21 ++-
app/assets/css/variables.css | 175 ++++++++++++++++++++-
app/components/Admin/BlacklistManager.vue | 120 +++++++++++---
app/components/Admin/ScheduleItemPrint.vue | 30 +++-
app/composables/useSiteConfig.js | 12 +-
app/drizzle/db.ts | 26 +--
app/pages/index.vue | 50 ++++++
server/api/admin/blacklist/index.post.ts | 2 +-
server/api/blacklist/check.post.ts | 81 ++++++++--
server/api/site-config.get.ts | 4 +-
server/api/songs/add.post.ts | 7 +-
server/api/songs/import.post.ts | 6 +-
server/api/songs/request.post.ts | 7 +-
server/utils/system-settings-defaults.ts | 1 +
server/utils/url.ts | 41 +++++
15 files changed, 520 insertions(+), 63 deletions(-)
create mode 100644 server/utils/url.ts
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 @@
添加黑名单项
</h3>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6 items-end">
- <div class="xl:col-span-2 space-y-2">
+ <div class="xl:col-span-3 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封禁类型</label
>
- <div class="flex p-1 bg-zinc-950 border border-zinc-800 rounded-lg">
+ <div class="flex p-1 bg-zinc-950 border border-zinc-800 rounded-lg gap-1">
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'SONG' ? 'bg-zinc-800 text-blue-400' : 'text-zinc-600'"
@click="newItem.type = 'SONG'"
>
- <Music :size="12" /> 具体歌曲
+ <Music :size="12" /> 歌曲
</button>
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
@@ -39,23 +39,46 @@
>
<Type :size="12" /> 关键词
</button>
+ <button
+ class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
+ :class="newItem.type === 'LANGUAGE' ? 'bg-zinc-800 text-amber-400' : 'text-zinc-600'"
+ @click="newItem.type = 'LANGUAGE'"
+ >
+ <Languages :size="12" /> 语种
+ </button>
+ <button
+ class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
+ :class="newItem.type === 'GENRE' ? 'bg-zinc-800 text-emerald-400' : 'text-zinc-600'"
+ @click="newItem.type = 'GENRE'"
+ >
+ <Disc3 :size="12" /> 曲风
+ </button>
</div>
</div>
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
- {{ newItem.type === 'SONG' ? '歌曲名称 - 艺术家' : '关键词内容' }}
+ {{ typeValueLabel }}
</label>
<input
v-model="newItem.value"
type="text"
- :placeholder="
- newItem.type === 'SONG' ? '例如: 歌曲名 - 艺术家' : '例如: 敏感词、特定风格'
- "
+ :placeholder="typePlaceholder"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
>
</div>
+ <div v-if="newItem.type === 'LANGUAGE' || newItem.type === 'GENRE'" class="xl:col-span-5 xl:col-start-auto space-y-1">
+ <p class="text-[10px] text-zinc-600 font-bold leading-relaxed px-1">
+ <template v-if="newItem.type === 'LANGUAGE'">
+ 💡 支持多语种匹配:中文 / 英文 / 日文 / 韩文 / 俄文 / 阿拉伯文 等,支持逗号分隔,例如:<span class="text-amber-400">日文,韩文</span>
+ </template>
+ <template v-else>
+ 💡 支持曲风关键词,可逗号分隔,例如:<span class="text-emerald-400">DJ,摇滚,rap</span>
+ </template>
+ </p>
+ </div>
+
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封禁原因 (可选)</label
@@ -103,7 +126,7 @@
<CustomSelect
label="类型筛选"
:value="typeFilterLabel"
- :options="['全部类型', '具体歌曲', '关键词']"
+ :options="['全部类型', '具体歌曲', '关键词', '语种', '曲风']"
class="flex-1 md:w-48"
@change="handleTypeFilterChange"
/>
@@ -142,27 +165,21 @@
<div class="flex-1 flex items-start gap-5">
<div
class="shrink-0 w-12 h-12 rounded-lg flex items-center justify-center border transition-all"
- :class="[
- item.type === 'SONG'
- ? 'bg-blue-600/10 text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/5'
- : 'bg-purple-600/10 text-purple-500 border-purple-500/20 shadow-lg shadow-purple-900/5'
- ]"
+ :class="getTypeIconClass(item.type)"
>
<Music v-if="item.type === 'SONG'" :size="22" />
- <Type v-else :size="22" />
+ <Type v-else-if="item.type === 'KEYWORD'" :size="22" />
+ <Languages v-else-if="item.type === 'LANGUAGE'" :size="22" />
+ <Disc3 v-else :size="22" />
</div>
<div class="space-y-1.5 min-w-0">
<div class="flex items-center gap-3">
<span
class="text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded border"
- :class="[
- item.type === 'SONG'
- ? 'bg-blue-600/10 text-blue-400 border-blue-500/20'
- : 'bg-purple-600/10 text-purple-400 border-purple-500/20'
- ]"
+ :class="getTypeBadgeClass(item.type)"
>
- {{ item.type === 'SONG' ? '具体歌曲' : '关键词' }}
+ {{ getTypeLabel(item.type) }}
</span>
<h5 class="text-base font-black text-zinc-100 truncate tracking-tight">
{{ 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 @@
</div>
<!-- 歌曲封面 -->
- <div v-if="settings.showCover" class="cover-section">
+ <div v-if="settings.showCover" class="cover-section aspect-square">
<img
v-if="schedule.song.cover"
:alt="schedule.song.title"
:src="convertToHttps(schedule.song.cover)"
- class="song-cover"
+ class="song-cover object-cover"
referrerpolicy="no-referrer"
@error="handleImageError"
>
- <div class="cover-placeholder" :class="{ 'show': !schedule.song.cover }">
+ <div class="cover-placeholder object-contain" :class="{ 'show': !schedule.song.cover }">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v6m0 6v6" />
@@ -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 @@
</div>
</Transition>
</Teleport>
+
+ <div
+ v-if="statisticsCodeEnabled && statisticsCode"
+ id="site-statistics-code"
+ class="hidden"
+ v-html="statisticsCode"
+ />
</div>
</template>
@@ -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
+ }
+}
--
2.45.1.windows.1