Files
BYW-VoiceHub/app/components/Admin/SiteConfigManager.vue
T
Henry_Du 4ce4649aaf
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s
初次提交
2026-09-06 08:19:02 +08:00

549 lines
20 KiB
Vue
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.
<template>
<div class="max-w-[1200px] mx-auto space-y-6 pb-24 px-2">
<!-- 顶部标题栏 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">站点配置</h2>
<p class="text-xs text-zinc-500 mt-1 font-medium">
管理站点全局属性、视觉识别、点歌逻辑及系统安全策略
</p>
</div>
<div class="flex gap-3">
<button
:disabled="loading || saving"
class="flex items-center gap-2 px-5 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@click="resetForm"
>
<RotateCcw :size="14" /> 重置
</button>
<button
:disabled="loading || saving"
class="flex items-center gap-2 px-8 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
@click="saveConfig"
>
<template v-if="saving">
<div
class="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"
/>
保存中...
</template>
<template v-else-if="saveSuccess"> <CheckCircle2 :size="14" /> 已保存 </template>
<template v-else> <Save :size="14" /> 保存配置 </template>
</button>
</div>
</div>
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
<div
class="w-8 h-8 border-4 border-zinc-800 border-t-blue-500 rounded-full animate-spin mb-4"
/>
<p class="text-zinc-500 text-sm">加载配置中...</p>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 基础信息 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<Globe :size="16" class="text-blue-500" /> 基础信息
</h3>
<div class="space-y-4">
<div>
<label :class="labelClass">站点标题</label>
<input
v-model="formData.siteTitle"
type="text"
placeholder="请输入站点标题"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">备案号 (ICP)</label>
<input
v-model="formData.icpNumber"
type="text"
placeholder="请输入备案号"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">公安联网备案号</label>
<input
v-model="formData.gonganNumber"
type="text"
placeholder="请输入公安备案号 (如:陕公网安备61011302001964号)"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">站点描述</label>
<textarea
v-model="formData.siteDescription"
:rows="3"
placeholder="请输入站点描述"
:class="[inputClass, 'resize-none']"
/>
</div>
<div>
<label :class="labelClass">站点版本号</label>
<input
v-model="formData.siteVersion"
type="text"
placeholder="如:v2.4.1 或 2026.04"
:class="inputClass"
>
</div>
</div>
</section>
<!-- 视觉识别 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<ImageIcon :size="16" class="text-purple-500" /> 视觉识别
</h3>
<div class="space-y-4">
<div>
<label :class="labelClass">站点 Logo URL</label>
<input
v-model="formData.siteLogoUrl"
type="text"
placeholder="请输入Logo图片URL"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">首页学校 Logo URL (大尺寸)</label>
<input
v-model="formData.schoolLogoHomeUrl"
type="text"
placeholder="请输入首页学校Logo URL"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">打印排期 Logo URL (小尺寸)</label>
<input
v-model="formData.schoolLogoPrintUrl"
type="text"
placeholder="请输入打印页学校Logo URL"
:class="inputClass"
>
</div>
</div>
</section>
<!-- 投稿须知 -->
<section
class="lg:col-span-2 bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 space-y-6"
>
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<FileText :size="16" class="text-emerald-500" /> 投稿须知
</h3>
<textarea
v-model="formData.submissionGuidelines"
:rows="6"
placeholder="请输入投稿须知内容"
:class="[inputClass, 'font-mono text-xs leading-relaxed min-h-[150px]']"
/>
</section>
<!-- 投稿逻辑设置 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<Settings2 :size="16" class="text-amber-500" /> 投稿逻辑设置
</h3>
<div class="space-y-6">
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用联合投稿</p>
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户添加联合投稿人并发起协作投稿</p>
</div>
<input
v-model="formData.enableCollaborativeSubmission"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用投稿备注留言</p>
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户在投稿时附加公开或仅管理员可见的备注</p>
</div>
<input
v-model="formData.enableSubmissionRemarks"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用重播申请</p>
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户对本学期已播放过的歌曲再次申请</p>
</div>
<input
v-model="formData.enableReplayRequests"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div class="space-y-4">
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用投稿限额</p>
<p class="text-[10px] text-zinc-500 mt-0.5">限制单个用户的点歌频率</p>
</div>
<input
v-model="formData.enableSubmissionLimit"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div v-if="formData.enableSubmissionLimit" class="space-y-4">
<div class="grid grid-cols-3 gap-2 p-1 bg-zinc-950 border border-zinc-800 rounded-xl">
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
currentLimitType === 'daily'
? 'bg-zinc-800 text-blue-400 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('daily')"
>
每日限额
</button>
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
currentLimitType === 'weekly'
? 'bg-zinc-800 text-blue-400 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('weekly')"
>
每周限额
</button>
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
currentLimitType === 'monthly'
? 'bg-zinc-800 text-blue-400 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('monthly')"
>
每月限额
</button>
</div>
<div>
<label :class="labelClass"
>{{ currentLimitType === 'daily' ? '单日' : (currentLimitType === 'weekly' ? '单周' : '单月') }}投稿上限</label
>
<div class="relative">
<input
v-model.number="currentLimitValue"
type="number"
min="0"
:class="inputClass"
>
<span
class="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-black text-zinc-700 uppercase"
>首 / 人</span
>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 安全与隐私设置 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<Shield :size="16" class="text-rose-500" /> 安全与隐私设置
</h3>
<div class="space-y-4">
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl space-y-4">
<div class="flex items-start gap-4">
<div class="shrink-0 pt-0.5">
<input
id="show-keywords"
v-model="formData.showBlacklistKeywords"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<label for="show-keywords" class="cursor-pointer">
<p class="text-xs font-bold text-zinc-200">显示黑名单具体关键词</p>
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
开启后,在投稿命中黑名单时将明确提示冲突关键词;关闭则仅提示“包含关键词”。
</p>
</label>
</div>
</div>
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl space-y-4">
<div class="flex items-start gap-4">
<div class="shrink-0 pt-0.5">
<input
id="hide-students"
v-model="formData.hideStudentInfo"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<label for="hide-students" class="cursor-pointer">
<p class="text-xs font-bold text-zinc-200">隐藏学生详细信息</p>
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
开启后,非管理员用户在前端点歌列表、排期预览中将无法查看投稿学生的完整学号与真实姓名。
</p>
</label>
</div>
</div>
<div
class="p-4 bg-blue-500/5 border border-blue-500/10 rounded-xl flex items-start gap-3"
>
<AlertCircle class="text-blue-500 shrink-0 mt-0.5" :size="14" />
<p class="text-[10px] text-zinc-500 leading-normal">
站点基础配置在保存后将立即对所有终端生效。请在修改关键业务逻辑(如投稿限额)前确保已知晓对现有用户的影响。
</p>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import {
Globe,
ImageIcon,
FileText,
Settings2,
Shield,
Save,
RotateCcw,
CheckCircle2,
AlertCircle
} from '@lucide/vue'
import { useToast } from '~/composables/useToast'
const { showToast: showNotification } = useToast()
const loading = ref(true)
const saving = ref(false)
const saveSuccess = ref(false)
// 样式类常量
const inputClass =
'w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all placeholder:text-zinc-800'
const labelClass = 'text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2'
const cardClass = 'bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 shadow-xl space-y-6'
const defaultSubmissionGuidelines = `1. 投稿时无需加入书名号
2. 除DJ外,其他类型歌曲均接收(包括小语种)
3. 禁止投递含有违规内容的歌曲
4. 点播的歌曲将由管理员进行审核
5. 审核通过后将安排在播放时段播出
6. 提交即表明我已阅读投稿须知并已知该歌曲有概率无法播出
7. 本系统仅提供音乐搜索和播放管理功能,不存储任何音乐文件。所有音乐内容均来自第三方音乐平台,版权归原平台及版权方所有。用户点歌时请确保遵守相关音乐平台的服务条款,尊重音乐作品版权。我们鼓励用户支持正版音乐,在官方平台购买和收听喜爱的音乐作品。
8. 最终解释权归广播站所有`
const formData = ref({
siteTitle: '',
siteLogoUrl: '',
schoolLogoHomeUrl: '',
schoolLogoPrintUrl: '',
siteDescription: '',
siteVersion: '',
submissionGuidelines: '',
icpNumber: '',
gonganNumber: '',
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false,
enableReplayRequests: false,
enableSubmissionLimit: false,
dailySubmissionLimit: 5,
weeklySubmissionLimit: null,
monthlySubmissionLimit: null,
showBlacklistKeywords: false,
hideStudentInfo: true
})
const originalData = ref({})
// 当前限额类型和值的快捷访问
const currentLimitType = computed(() => {
if (formData.value.monthlySubmissionLimit !== null) return 'monthly'
return formData.value.weeklySubmissionLimit !== null ? 'weekly' : 'daily'
})
const currentLimitValue = computed({
get: () => {
if (currentLimitType.value === 'monthly') return formData.value.monthlySubmissionLimit
return currentLimitType.value === 'daily'
? formData.value.dailySubmissionLimit
: formData.value.weeklySubmissionLimit
},
set: (val) => {
if (currentLimitType.value === 'monthly') {
formData.value.monthlySubmissionLimit = val
} else if (currentLimitType.value === 'daily') {
formData.value.dailySubmissionLimit = val
} else {
formData.value.weeklySubmissionLimit = val
}
}
})
// 加载配置
const loadConfig = async () => {
try {
loading.value = true
const response = await fetch('/api/admin/system-settings', {
credentials: 'include'
})
if (!response.ok) throw new Error('获取配置失败')
const data = await response.json()
formData.value = {
siteTitle: data.siteTitle || '',
siteLogoUrl: data.siteLogoUrl || '',
schoolLogoHomeUrl: data.schoolLogoHomeUrl || '',
schoolLogoPrintUrl: data.schoolLogoPrintUrl || '',
siteDescription: data.siteDescription || '',
siteVersion: data.siteVersion || '',
submissionGuidelines: data.submissionGuidelines || defaultSubmissionGuidelines,
icpNumber: data.icpNumber || '',
gonganNumber: data.gonganNumber || '',
enableCollaborativeSubmission: data.enableCollaborativeSubmission !== false,
enableSubmissionRemarks: !!data.enableSubmissionRemarks,
enableReplayRequests: !!data.enableReplayRequests,
enableSubmissionLimit: !!data.enableSubmissionLimit,
dailySubmissionLimit: data.dailySubmissionLimit ?? 5,
weeklySubmissionLimit: data.weeklySubmissionLimit ?? null,
monthlySubmissionLimit: data.monthlySubmissionLimit ?? null,
showBlacklistKeywords: !!data.showBlacklistKeywords,
hideStudentInfo: data.hideStudentInfo ?? true
}
originalData.value = JSON.parse(JSON.stringify(formData.value))
} catch (error) {
console.error('加载配置失败:', error)
showNotification('加载配置失败', 'error')
} finally {
loading.value = false
}
}
// 保存配置
const saveConfig = async () => {
try {
saving.value = true
const configToSave = {
...formData.value,
siteTitle: (formData.value.siteTitle || '').trim() || '校园广播站点歌系统',
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/images/site-logo.png',
siteVersion: (formData.value.siteVersion || '').trim() || null,
submissionGuidelines:
(formData.value.submissionGuidelines || '').trim() || defaultSubmissionGuidelines,
// 确保根据限额类型处理空值
dailySubmissionLimit:
currentLimitType.value === 'daily' ? formData.value.dailySubmissionLimit : null,
weeklySubmissionLimit:
currentLimitType.value === 'weekly' ? formData.value.weeklySubmissionLimit : null,
monthlySubmissionLimit:
currentLimitType.value === 'monthly' ? formData.value.monthlySubmissionLimit : null
}
const response = await fetch('/api/admin/system-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(configToSave)
})
if (!response.ok) throw new Error('保存配置失败')
saveSuccess.value = true
originalData.value = JSON.parse(JSON.stringify(formData.value))
showNotification('配置保存成功!', 'success')
setTimeout(() => {
saveSuccess.value = false
}, 3000)
} catch (error) {
console.error('保存配置失败:', error)
showNotification('保存配置失败,请重试', 'error')
} finally {
saving.value = false
}
}
// 处理限额类型变化
const handleLimitTypeChange = (type) => {
const limits = {
daily: { key: 'dailySubmissionLimit', default: 5 },
weekly: { key: 'weeklySubmissionLimit', default: 20 },
monthly: { key: 'monthlySubmissionLimit', default: 50 }
}
// 将非当前类型的限额设为 null
Object.keys(limits).forEach((limitType) => {
if (limitType !== type) {
formData.value[limits[limitType].key] = null
}
})
// 如果当前类型的限额为 null,则设置默认值
const targetLimit = limits[type]
if (formData.value[targetLimit.key] === null) {
formData.value[targetLimit.key] = targetLimit.default
}
}
// 重置表单
const resetForm = () => {
formData.value = JSON.parse(JSON.stringify(originalData.value))
}
onMounted(loadConfig)
</script>
<style scoped>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
}
</style>