feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
- 新增 /api/admin/songs/batch-reject.post 批量驳回接口(事务清排期/投票/扣接纳数/黑名单去重/异步通知) - requireSongAdmin 三角色校验 + createApiError 统一错误包装 - scheduleDaysBefore/After 四字段排期可见范围限制(开放 API 与公开排期接口三处缓存路径 + DB 条件过滤) - submissionNoteAutoModeration 基于黑名单 KEYWORD/LANGUAGE/GENRE 自动判定留言 publicStatus,命中自动关闭 submissionNotePublic - 更新 songs/request.post 与 songs/[id]/update.put 接入自动审查逻辑
This commit is contained in:
@@ -19,7 +19,9 @@ const siteConfig = ref({
|
||||
siteVersion: '',
|
||||
submissionGuidelines: '',
|
||||
icpNumber: '',
|
||||
gonganNumber: ''
|
||||
gonganNumber: '',
|
||||
defaultTheme: null,
|
||||
enabledThemes: null
|
||||
})
|
||||
|
||||
const isLoaded = ref(false)
|
||||
@@ -57,7 +59,9 @@ export const useSiteConfig = () => {
|
||||
gonganNumber: '',
|
||||
enableReplayRequests: false,
|
||||
enableCollaborativeSubmission: true,
|
||||
enableSubmissionRemarks: false
|
||||
enableSubmissionRemarks: false,
|
||||
defaultTheme: 'System',
|
||||
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]'
|
||||
}
|
||||
isLoaded.value = true
|
||||
} finally {
|
||||
@@ -96,6 +100,32 @@ export const useSiteConfig = () => {
|
||||
const enableSubmissionRemarks = computed(() => siteConfig.value.enableSubmissionRemarks === true)
|
||||
const smtpEnabled = computed(() => !!siteConfig.value.smtpEnabled)
|
||||
|
||||
function parseEnabledThemes(raw) {
|
||||
if (Array.isArray(raw)) return raw.filter(Boolean)
|
||||
if (typeof raw === 'string') {
|
||||
const s = raw.trim()
|
||||
if (!s) return []
|
||||
if (s.startsWith('[') || s.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(s)
|
||||
if (Array.isArray(parsed)) return parsed.filter(Boolean)
|
||||
if (parsed && Array.isArray(parsed.items)) return parsed.items.filter(Boolean)
|
||||
} catch (_) {
|
||||
// fallback to comma split
|
||||
}
|
||||
}
|
||||
return s.split(',').map((x) => x.trim()).filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const defaultTheme = computed(() =>
|
||||
typeof siteConfig.value.defaultTheme === 'string' && siteConfig.value.defaultTheme
|
||||
? siteConfig.value.defaultTheme
|
||||
: null
|
||||
)
|
||||
const enabledThemes = computed(() => parseEnabledThemes(siteConfig.value.enabledThemes))
|
||||
|
||||
// 初始化配置(仅在客户端执行)
|
||||
const initSiteConfig = async () => {
|
||||
if (typeof window !== 'undefined' && !isLoaded.value) {
|
||||
@@ -126,6 +156,8 @@ export const useSiteConfig = () => {
|
||||
enableCollaborativeSubmission,
|
||||
enableSubmissionRemarks,
|
||||
smtpEnabled,
|
||||
defaultTheme,
|
||||
enabledThemes,
|
||||
fetchSiteConfig,
|
||||
initSiteConfig,
|
||||
refreshSiteConfig
|
||||
|
||||
+112
-44
@@ -1,4 +1,4 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'voicehub:theme'
|
||||
type ThemeName = string & {}
|
||||
@@ -15,6 +15,25 @@ function getMetaThemeColor() {
|
||||
return document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
|
||||
}
|
||||
|
||||
function parseEnabledThemesRaw(raw: unknown): string[] {
|
||||
if (Array.isArray(raw)) return raw.filter(Boolean) as string[]
|
||||
if (typeof raw === 'string') {
|
||||
const s = raw.trim()
|
||||
if (!s) return []
|
||||
if (s.startsWith('[') || s.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(s)
|
||||
if (Array.isArray(parsed)) return parsed.filter(Boolean) as string[]
|
||||
if (parsed && Array.isArray((parsed as any).items)) return (parsed as any).items.filter(Boolean) as string[]
|
||||
} catch (_) {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
return s.split(',').map((x) => x.trim()).filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function applyTheme(theme: string) {
|
||||
if (!isBrowser()) return
|
||||
|
||||
@@ -24,11 +43,12 @@ function applyTheme(theme: string) {
|
||||
root.setAttribute('data-theme', theme)
|
||||
if (appRoot) appRoot.setAttribute('data-theme', theme)
|
||||
|
||||
if (theme === 'dark') {
|
||||
const effective = resolveEffectiveTheme(theme)
|
||||
if (effective === 'dark') {
|
||||
root.classList.add('dark')
|
||||
root.classList.remove('light')
|
||||
root.style.colorScheme = 'dark'
|
||||
} else if (theme === 'light') {
|
||||
} else if (effective === 'light') {
|
||||
root.classList.add('light')
|
||||
root.classList.remove('dark')
|
||||
root.style.colorScheme = 'light'
|
||||
@@ -39,11 +59,7 @@ function applyTheme(theme: string) {
|
||||
|
||||
const meta = getMetaThemeColor()
|
||||
if (meta) {
|
||||
if (theme === 'light') {
|
||||
meta.content = '#f8fafc'
|
||||
} else {
|
||||
meta.content = '#111111'
|
||||
}
|
||||
meta.content = effective === 'light' ? '#f8fafc' : '#111111'
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -59,18 +75,37 @@ function detectSystemTheme(): 'dark' | 'light' {
|
||||
return prefersDark ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function resolveEffectiveTheme(theme: string): 'dark' | 'light' | string {
|
||||
if (theme === 'System' || theme === 'system') {
|
||||
return detectSystemTheme()
|
||||
}
|
||||
if (theme === 'dark' || theme.endsWith('Dark')) return 'dark'
|
||||
if (theme === 'light' || theme.endsWith('Light')) return 'light'
|
||||
return theme
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const config = useRuntimeConfig()
|
||||
const { isLoaded: siteConfigLoaded, defaultTheme: siteDefaultTheme, enabledThemes: siteEnabledThemes, initSiteConfig } = useSiteConfig()
|
||||
|
||||
const enabledThemes = computed<string[]>(() => {
|
||||
const list = (config.public?.enabledThemes as unknown) as string[] | undefined
|
||||
return Array.isArray(list) && list.length > 0 ? list : ['dark', 'light']
|
||||
if (siteConfigLoaded.value && siteEnabledThemes.value.length > 0) {
|
||||
return [...new Set(siteEnabledThemes.value)]
|
||||
}
|
||||
const fromRuntime = parseEnabledThemesRaw(config.public?.enabledThemes)
|
||||
if (fromRuntime.length > 0) return fromRuntime
|
||||
return ['System', 'dark', 'light']
|
||||
})
|
||||
|
||||
const defaultTheme = computed<string>(() => {
|
||||
const value = config.public?.defaultTheme as string | undefined
|
||||
if (value && enabledThemes.value.includes(value)) return value
|
||||
return enabledThemes.value.includes('dark') ? 'dark' : enabledThemes.value[0] || 'dark'
|
||||
if (siteConfigLoaded.value && siteDefaultTheme.value) {
|
||||
return siteDefaultTheme.value
|
||||
}
|
||||
const fromRuntime = config.public?.defaultTheme
|
||||
if (typeof fromRuntime === 'string' && fromRuntime) {
|
||||
return fromRuntime
|
||||
}
|
||||
return 'System'
|
||||
})
|
||||
|
||||
const theme = computed<string>({
|
||||
@@ -79,21 +114,22 @@ export function useTheme() {
|
||||
})
|
||||
|
||||
const isDark = computed(() => {
|
||||
const t = theme.value
|
||||
if (t === 'dark') return true
|
||||
if (t === 'light') return false
|
||||
return detectSystemTheme() === 'dark'
|
||||
return resolveEffectiveTheme(theme.value) === 'dark'
|
||||
})
|
||||
|
||||
function isThemeEnabled(name: string) {
|
||||
return enabledThemes.value.includes(name)
|
||||
}
|
||||
|
||||
function firstValidTheme(candidate: string): string {
|
||||
const list = enabledThemes.value
|
||||
if (list.includes(candidate)) return candidate
|
||||
if (list.includes(defaultTheme.value)) return defaultTheme.value
|
||||
return list[0] ?? 'System'
|
||||
}
|
||||
|
||||
function setTheme(next: string, { persist = true } = {}) {
|
||||
let candidate = next
|
||||
if (!enabledThemes.value.includes(candidate)) {
|
||||
candidate = defaultTheme.value
|
||||
}
|
||||
const candidate = firstValidTheme(next)
|
||||
|
||||
if (currentTheme.value === candidate && initialized.value) return
|
||||
|
||||
@@ -110,7 +146,7 @@ export function useTheme() {
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const order = enabledThemes.value.length >= 2 ? enabledThemes.value : ['dark', 'light']
|
||||
const order = enabledThemes.value.length >= 2 ? enabledThemes.value : ['System', 'dark', 'light']
|
||||
const current = theme.value
|
||||
const idx = order.indexOf(current)
|
||||
const next = order[(idx + 1) % order.length]
|
||||
@@ -121,8 +157,31 @@ export function useTheme() {
|
||||
setTheme(defaultTheme.value, { persist: false })
|
||||
}
|
||||
|
||||
function init() {
|
||||
function onSystemThemeChange() {
|
||||
const stored = (() => {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (!stored) {
|
||||
applyTheme(theme.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (initialized.value || !isBrowser()) return
|
||||
|
||||
if (!siteConfigLoaded.value) {
|
||||
try {
|
||||
await initSiteConfig()
|
||||
await nextTick()
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
initialized.value = true
|
||||
|
||||
let stored: string | null = null
|
||||
@@ -132,34 +191,43 @@ export function useTheme() {
|
||||
stored = null
|
||||
}
|
||||
|
||||
if (stored && enabledThemes.value.includes(stored)) {
|
||||
setTheme(stored)
|
||||
return
|
||||
if (stored) {
|
||||
const valid = firstValidTheme(stored)
|
||||
if (valid === stored || enabledThemes.value.includes(stored)) {
|
||||
setTheme(stored)
|
||||
watchSystemTheme()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setTheme(defaultTheme.value, { persist: false })
|
||||
watchSystemTheme()
|
||||
}
|
||||
|
||||
if (window.matchMedia) {
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = () => {
|
||||
let s: string | null = null
|
||||
try {
|
||||
s = localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
s = null
|
||||
}
|
||||
if (!s) {
|
||||
setTheme(defaultTheme.value, { persist: false })
|
||||
}
|
||||
}
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', onChange)
|
||||
} else if (typeof mql.addListener === 'function') {
|
||||
mql.addListener(onChange)
|
||||
}
|
||||
function watchSystemTheme() {
|
||||
if (!isBrowser() || !window.matchMedia) return
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', onSystemThemeChange)
|
||||
} else if (typeof (mql as any).addListener === 'function') {
|
||||
;(mql as any).addListener(onSystemThemeChange)
|
||||
}
|
||||
}
|
||||
|
||||
watch(defaultTheme, () => {
|
||||
if (!initialized.value) return
|
||||
const stored = (() => {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (!stored) {
|
||||
setTheme(defaultTheme.value, { persist: false })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
theme,
|
||||
currentTheme,
|
||||
|
||||
Reference in New Issue
Block a user