1112 lines
42 KiB
Vue
1112 lines
42 KiB
Vue
<template>
|
|
<div class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2">
|
|
<!-- 顶部标题和全局开关 -->
|
|
<div class="flex flex-col xl:flex-row xl:items-end justify-between gap-6">
|
|
<div class="space-y-1">
|
|
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
|
|
<p class="text-xs text-text-tertiary">{{ locale.desc }}</p>
|
|
</div>
|
|
|
|
<div
|
|
class="flex flex-wrap items-center gap-4 bg-bg-secondary-40 border border-border-secondary p-2 rounded-3xl"
|
|
>
|
|
<div class="flex items-center gap-3 px-3 border-r border-border-secondary-50 mr-1">
|
|
<span class="text-[10px] font-black text-text-tertiary uppercase tracking-widest"
|
|
>{{ locale.currentStatus }}</span
|
|
>
|
|
<span
|
|
:class="[
|
|
'px-2 py-0.5 rounded text-[10px] font-black uppercase transition-all',
|
|
hitRequestTime ? 'bg-success-10 text-success' : 'bg-error-10 text-error'
|
|
]"
|
|
>
|
|
{{ hitRequestTime ? locale.open : locale.closed }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3 px-2">
|
|
<span class="text-[10px] font-black text-text-tertiary uppercase tracking-widest"
|
|
>{{ locale.enableRequest }}</span
|
|
>
|
|
<button
|
|
:class="[
|
|
'relative w-10 h-5 rounded-full transition-colors',
|
|
enableRequest ? 'bg-primary-hover' : 'bg-bg-tertiary'
|
|
]"
|
|
@click="toggleGlobalRequest"
|
|
>
|
|
<div
|
|
:class="[
|
|
'absolute top-1 w-3 h-3 bg-bg-secondary rounded-full transition-all',
|
|
enableRequest ? 'left-6' : 'left-1'
|
|
]"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3 px-2 border-l border-border-secondary-50">
|
|
<span class="text-[10px] font-black text-text-tertiary uppercase tracking-widest"
|
|
>{{ locale.enableTimeLimit }}</span
|
|
>
|
|
<button
|
|
:class="[
|
|
'relative w-10 h-5 rounded-full transition-colors',
|
|
enableRequestTimeLimitation ? 'bg-primary-hover' : 'bg-bg-tertiary'
|
|
]"
|
|
@click="toggleTimeLimitation"
|
|
>
|
|
<div
|
|
:class="[
|
|
'absolute top-1 w-3 h-3 bg-bg-secondary rounded-full transition-all',
|
|
enableRequestTimeLimitation ? 'left-6' : 'left-1'
|
|
]"
|
|
/>
|
|
</button>
|
|
</div>
|
|
<div class="flex items-center gap-3 px-2 border-l border-border-secondary-50">
|
|
<span class="text-[10px] font-black text-text-tertiary uppercase tracking-widest"
|
|
>{{ locale.enableRestriction }}</span
|
|
>
|
|
<button
|
|
:disabled="restrictionSaving"
|
|
:class="[
|
|
'relative w-10 h-5 rounded-full transition-colors',
|
|
enableSubmissionRestriction ? 'bg-primary-hover' : 'bg-bg-tertiary',
|
|
'disabled:opacity-50 disabled:cursor-not-allowed'
|
|
]"
|
|
@click="toggleRestriction"
|
|
>
|
|
<div
|
|
:class="[
|
|
'absolute top-1 w-3 h-3 bg-bg-secondary rounded-full transition-all',
|
|
enableSubmissionRestriction ? 'left-6' : 'left-1'
|
|
]"
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 全局错误提示 -->
|
|
<div
|
|
v-if="error"
|
|
class="p-4 bg-error-10 border border-error-20 rounded-2xl flex items-center gap-3 text-error text-xs font-bold"
|
|
>
|
|
<AlertCircle :size="16" />
|
|
<span @click="error = ''" class="cursor-pointer hover:underline">{{ error }}</span>
|
|
</div>
|
|
|
|
<!-- 主要内容区域 -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
<!-- 添加时段按钮卡片 -->
|
|
<button
|
|
class="group relative h-full min-h-[220px] bg-bg-secondary-20 border-2 border-dashed border-border-secondary rounded-3xl flex flex-col items-center justify-center gap-4 hover:bg-primary-hover-5 hover:border-primary-40 transition-all active:scale-95"
|
|
@click="showAddForm = true"
|
|
>
|
|
<div
|
|
class="w-14 h-14 rounded-full bg-bg-secondary border border-border-secondary flex items-center justify-center text-text-tertiary group-hover:text-primary group-hover:bg-primary-hover-10 group-hover:border-primary-20 transition-all"
|
|
>
|
|
<Plus :size="28" />
|
|
</div>
|
|
<div class="text-center">
|
|
<h4
|
|
class="text-sm font-black text-text-tertiary group-hover:text-primary transition-colors uppercase tracking-widest"
|
|
>
|
|
{{ locale.addTime }}
|
|
</h4>
|
|
<p class="text-[10px] text-text-disabled mt-1">{{ locale.addTimeDesc }}</p>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- 时段列表 -->
|
|
<TransitionGroup
|
|
enter-active-class="transition duration-300 ease-out"
|
|
enter-from-class="opacity-0 scale-95"
|
|
enter-to-class="opacity-100 scale-100"
|
|
leave-active-class="transition duration-200 ease-in"
|
|
leave-from-class="opacity-100 scale-100"
|
|
leave-to-class="opacity-0 scale-95"
|
|
>
|
|
<div
|
|
v-for="slot in requestTimes"
|
|
:key="slot.id"
|
|
:class="[
|
|
'group bg-bg-secondary-40 border rounded-3xl p-8 space-y-6 transition-all hover:shadow-2xl hover:shadow-[0_25px_50px_var(--shadow-color-deep)]',
|
|
slot.enabled && !slot.past ? 'border-border-secondary' : 'border-border-secondary-40 opacity-60'
|
|
]"
|
|
>
|
|
<div class="flex items-start justify-between">
|
|
<div
|
|
:class="[
|
|
'p-3.5 rounded-2xl bg-bg-primary border border-border-secondary text-text-tertiary transition-colors flex items-center justify-center',
|
|
slot.enabled && !slot.past
|
|
? 'text-primary border-primary-20 shadow-lg shadow-[var(--primary-glow-10)]'
|
|
: ''
|
|
]"
|
|
>
|
|
<Calendar :size="22" />
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
:class="[
|
|
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
|
|
slot.past
|
|
? 'bg-error-10 text-error border-error-20'
|
|
: slot.enabled
|
|
? 'bg-success-10 text-success border-success-20'
|
|
: 'bg-bg-tertiary-50 text-text-disabled border-border-tertiary-50'
|
|
]"
|
|
>
|
|
{{ slot.past ? locale.expired : slot.enabled ? locale.enabled : locale.disabled }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<h4
|
|
class="text-lg font-black text-text-primary flex items-baseline gap-2 group-hover:text-primary transition-colors"
|
|
>
|
|
{{ slot.name }}
|
|
<span class="text-xs font-bold text-text-disabled tracking-tight"
|
|
>({{ slot.accepted }}/{{ slot.expected || '∞' }})</span
|
|
>
|
|
</h4>
|
|
<div class="mt-3 space-y-2">
|
|
<div class="flex items-center gap-2 text-text-tertiary font-bold">
|
|
<Clock :size="12" class="text-text-secondary" />
|
|
<span class="text-[10px] uppercase tracking-tighter truncate">{{
|
|
slot.startTime || locale.unlimited
|
|
}}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-text-tertiary font-bold">
|
|
<div class="w-3 h-[2px] bg-bg-tertiary ml-1.5" />
|
|
<span class="text-[10px] uppercase tracking-tighter truncate">{{
|
|
slot.endTime || locale.unlimited
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p
|
|
class="text-xs text-text-tertiary font-medium leading-relaxed min-h-[32px] line-clamp-2 italic"
|
|
>
|
|
{{ slot.description || locale.noDescription }}
|
|
</p>
|
|
|
|
<!-- 投稿进度条 -->
|
|
<div class="space-y-1.5">
|
|
<div
|
|
class="flex justify-between text-[9px] font-black text-text-disabled uppercase tracking-widest px-0.5"
|
|
>
|
|
<span>{{ locale.progress }}</span>
|
|
<span
|
|
>{{
|
|
slot.expected > 0
|
|
? Math.min(100, Math.round((slot.accepted / slot.expected) * 100))
|
|
: 0
|
|
}}%</span
|
|
>
|
|
</div>
|
|
<div
|
|
class="h-1.5 w-full bg-bg-primary rounded-full overflow-hidden border border-border-secondary-50"
|
|
>
|
|
<div
|
|
class="h-full transition-all duration-500"
|
|
:style="{
|
|
width: `${slot.expected > 0 ? Math.min(100, (slot.accepted / slot.expected) * 100) : 0}%`
|
|
}"
|
|
:class="
|
|
slot.expected > 0 && slot.accepted >= slot.expected
|
|
? 'bg-error'
|
|
: 'bg-primary'
|
|
"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="pt-6 border-t border-border-secondary-50 flex items-center justify-between">
|
|
<div class="flex gap-2">
|
|
<button
|
|
v-show="!slot.past"
|
|
class="p-2.5 bg-bg-primary border border-border-secondary rounded-xl text-text-tertiary hover:text-primary hover:border-primary-30 transition-all"
|
|
@click="editRequestTime(slot)"
|
|
>
|
|
<Edit2 :size="14" />
|
|
</button>
|
|
<button
|
|
class="p-2.5 bg-bg-primary border border-border-secondary rounded-xl text-text-tertiary hover:text-error hover:border-error-30 transition-all"
|
|
@click="confirmDelete(slot)"
|
|
>
|
|
<Trash2 :size="14" />
|
|
</button>
|
|
</div>
|
|
<button
|
|
v-show="!slot.past"
|
|
:class="[
|
|
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all',
|
|
slot.enabled
|
|
? 'bg-bg-tertiary text-text-tertiary hover:text-text-primary'
|
|
: 'bg-success text-text-primary shadow-lg shadow-[var(--success-glow-20)]'
|
|
]"
|
|
@click="toggleRequestTimeStatus(slot)"
|
|
>
|
|
<Power :size="12" />
|
|
{{ slot.enabled ? locale.disable : locale.enable }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</TransitionGroup>
|
|
</div>
|
|
|
|
<!-- 统计概览 -->
|
|
<div
|
|
class="bg-bg-secondary-20 border border-border-secondary rounded-3xl p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
|
>
|
|
<div v-for="(stat, i) in stats" :key="i" class="flex items-center gap-4 group">
|
|
<div
|
|
:class="[
|
|
'w-12 h-12 rounded-2xl bg-bg-primary border border-border-secondary flex items-center justify-center text-text-tertiary transition-colors',
|
|
stat.color === 'blue'
|
|
? 'group-hover:text-primary'
|
|
: stat.color === 'emerald'
|
|
? 'group-hover:text-success'
|
|
: stat.color === 'amber'
|
|
? 'group-hover:text-warning'
|
|
: 'group-hover:text-info'
|
|
]"
|
|
>
|
|
<component :is="stat.icon" :size="20" />
|
|
</div>
|
|
<div>
|
|
<p class="text-[10px] font-black text-text-disabled uppercase tracking-widest">
|
|
{{ stat.label }}
|
|
</p>
|
|
<h5 class="text-xl font-black text-text-primary">{{ stat.value }}</h5>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 重复投稿限制面板 -->
|
|
<div
|
|
v-if="isAdmin"
|
|
class="bg-bg-secondary-20 border border-border-secondary rounded-3xl p-8 space-y-6"
|
|
>
|
|
<div class="flex items-center justify-between border-b border-border-secondary-50 pb-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-10 h-10 rounded-2xl bg-warning-10 border border-warning-20 flex items-center justify-center text-warning">
|
|
<Ban :size="20" />
|
|
</div>
|
|
<div>
|
|
<h4 class="text-sm font-black text-text-primary uppercase tracking-widest">
|
|
{{ locale.restrictionPanelTitle }}
|
|
</h4>
|
|
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.restrictionPanelDesc }}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
:disabled="restrictionSaving"
|
|
class="flex items-center gap-2 px-5 py-2 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
@click="saveRestrictionSettings"
|
|
>
|
|
<Save :size="14" />
|
|
{{ locale.saveSettings }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div class="lg:col-span-1 space-y-4">
|
|
<label :class="['text-[10px] font-black text-text-disabled uppercase tracking-widest px-1 block mb-2']">
|
|
{{ locale.restrictionScope }}
|
|
</label>
|
|
<p class="text-[10px] text-text-tertiary px-1">{{ locale.restrictionScopeDesc }}</p>
|
|
<div class="flex gap-2 p-1 bg-bg-primary border border-border-secondary rounded-xl">
|
|
<button
|
|
:class="[
|
|
'flex-1 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
|
submissionRestrictionScope === 'self'
|
|
? 'bg-bg-tertiary text-primary shadow-sm'
|
|
: 'text-text-disabled hover:text-text-tertiary'
|
|
]"
|
|
@click="submissionRestrictionScope = 'self'"
|
|
>
|
|
{{ locale.restrictionScopeSelf }}
|
|
</button>
|
|
<button
|
|
:class="[
|
|
'flex-1 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
|
submissionRestrictionScope === 'all'
|
|
? 'bg-bg-tertiary text-primary shadow-sm'
|
|
: 'text-text-disabled hover:text-text-tertiary'
|
|
]"
|
|
@click="submissionRestrictionScope = 'all'"
|
|
>
|
|
{{ locale.restrictionScopeAll }}
|
|
</button>
|
|
</div>
|
|
<p class="text-[10px] text-text-disabled px-1 italic">{{ locale.restrictionPanelHint }}</p>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<label :class="['text-[10px] font-black text-text-disabled uppercase tracking-widest px-1 block mb-2']">
|
|
{{ locale.sameSongRestrictionHours }}
|
|
</label>
|
|
<p class="text-[10px] text-text-tertiary px-1">{{ locale.sameSongRestrictionHoursDesc }}</p>
|
|
<div class="relative">
|
|
<input
|
|
:value="sameSongRestrictionHours ?? ''"
|
|
@input="sameSongRestrictionHours = $event.target.value === '' ? null : Number($event.target.value)"
|
|
@blur="normalizeRestrictionHours('sameSong')"
|
|
type="number"
|
|
min="1"
|
|
max="720"
|
|
step="1"
|
|
placeholder="-"
|
|
class="w-56 bg-bg-primary border border-border-secondary rounded-2xl px-5 py-3 text-sm text-text-primary pr-16 focus:outline-none focus:border-primary-30"
|
|
/>
|
|
<span
|
|
class="absolute right-5 top-1/2 -translate-y-1/2 text-[10px] font-black text-text-secondary uppercase"
|
|
>{{ locale.restrictionUnit }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<label :class="['text-[10px] font-black text-text-disabled uppercase tracking-widest px-1 block mb-2']">
|
|
{{ locale.sameArtistRestrictionHours }}
|
|
</label>
|
|
<p class="text-[10px] text-text-tertiary px-1">{{ locale.sameArtistRestrictionHoursDesc }}</p>
|
|
<div class="relative">
|
|
<input
|
|
:value="sameArtistRestrictionHours ?? ''"
|
|
@input="sameArtistRestrictionHours = $event.target.value === '' ? null : Number($event.target.value)"
|
|
@blur="normalizeRestrictionHours('sameArtist')"
|
|
type="number"
|
|
min="1"
|
|
max="720"
|
|
step="1"
|
|
placeholder="-"
|
|
class="w-56 bg-bg-primary border border-border-secondary rounded-2xl px-5 py-3 text-sm text-text-primary pr-16 focus:outline-none focus:border-primary-30"
|
|
/>
|
|
<span
|
|
class="absolute right-5 top-1/2 -translate-y-1/2 text-[10px] font-black text-text-secondary uppercase"
|
|
>{{ locale.restrictionUnit }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 添加/编辑模态框 -->
|
|
<Transition
|
|
enter-active-class="transition duration-300 ease-out"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition duration-200 ease-in"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div
|
|
v-if="showAddForm || editingRequestTime"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
|
|
>
|
|
<div
|
|
class="w-full max-w-2xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl overflow-hidden"
|
|
>
|
|
<div class="px-8 py-6 border-b border-border-secondary-50 flex items-center justify-between">
|
|
<h3 class="text-xl font-black text-text-primary">
|
|
{{ editingRequestTime ? locale.editTitle : locale.addTitle }}
|
|
</h3>
|
|
<button class="text-text-tertiary hover:text-text-secondary transition-colors" @click="cancelForm">
|
|
<X :size="20" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="p-8 space-y-6 max-h-[70vh] overflow-y-auto">
|
|
<div class="space-y-2">
|
|
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
|
>{{ locale.name }}</label
|
|
>
|
|
<input
|
|
v-model="formData.name"
|
|
type="text"
|
|
:placeholder="locale.namePlaceholder"
|
|
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30"
|
|
>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div class="space-y-2">
|
|
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
|
>{{ locale.startDateTime }}</label
|
|
>
|
|
<input
|
|
v-model="formData.startTime"
|
|
type="datetime-local"
|
|
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30"
|
|
>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
|
>{{ locale.endDateTime }}</label
|
|
>
|
|
<input
|
|
v-model="formData.endTime"
|
|
type="datetime-local"
|
|
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div class="space-y-2">
|
|
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
|
>{{ locale.expectedCount }}</label
|
|
>
|
|
<div class="relative group">
|
|
<input
|
|
v-model="formData.expected"
|
|
type="number"
|
|
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30"
|
|
>
|
|
<div
|
|
class="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-text-disabled font-bold text-[10px]"
|
|
>
|
|
<Hash :size="12" />
|
|
{{ locale.songUnit }}
|
|
</div>
|
|
</div>
|
|
<p class="text-[9px] text-text-disabled px-1">{{ locale.expectedHint }}</p>
|
|
</div>
|
|
<div class="space-y-2 flex flex-col justify-center pt-2">
|
|
<label class="flex items-center gap-3 cursor-pointer group px-1">
|
|
<input
|
|
v-model="formData.enabled"
|
|
type="checkbox"
|
|
class="w-4.5 h-4.5 rounded-lg border-border-secondary bg-bg-primary"
|
|
>
|
|
<div>
|
|
<span
|
|
class="text-xs font-bold text-text-secondary group-hover:text-primary transition-colors"
|
|
>{{ locale.enableThisRequestTime }}</span
|
|
>
|
|
<p class="text-[9px] text-text-disabled font-medium">
|
|
{{ locale.enableThisRequestTimeHint }}
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
|
>{{ locale.descriptionOptional }}</label
|
|
>
|
|
<textarea
|
|
v-model="formData.description"
|
|
:placeholder="locale.descriptionPlaceholder"
|
|
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-4 text-sm text-text-primary focus:outline-none focus:border-primary-30 min-h-[100px] resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="formError"
|
|
class="p-4 bg-error-10 border border-error-20 rounded-2xl flex items-center gap-3 text-error text-xs font-bold"
|
|
>
|
|
<AlertCircle :size="16" />
|
|
{{ formError }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="px-8 py-6 bg-bg-secondary-50 border-t border-border-secondary-50 flex gap-3 justify-end">
|
|
<button
|
|
class="px-6 py-2.5 text-xs font-bold text-text-tertiary hover:text-text-secondary"
|
|
@click="cancelForm"
|
|
>
|
|
{{ locale.cancel }}
|
|
</button>
|
|
<button
|
|
:disabled="formSubmitting"
|
|
class="px-10 py-2.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 disabled:opacity-50"
|
|
@click="saveRequestTime"
|
|
>
|
|
{{ formSubmitting ? locale.saving : locale.saveSettings }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- 删除确认模态框 -->
|
|
<Transition
|
|
enter-active-class="transition duration-300 ease-out"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition duration-200 ease-in"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div
|
|
v-if="showDeleteConfirm"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
|
|
@click.self="showDeleteConfirm = false"
|
|
>
|
|
<div
|
|
class="w-full max-w-md bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl overflow-hidden p-8"
|
|
>
|
|
<div class="flex flex-col items-center space-y-6">
|
|
<div
|
|
class="w-16 h-16 rounded-[2rem] bg-error-10 text-error flex items-center justify-center border border-error-20 shadow-xl shadow-[var(--error-glow-10)]"
|
|
>
|
|
<Trash2 :size="28" />
|
|
</div>
|
|
<div class="text-center space-y-2 px-4">
|
|
<h4 class="text-lg font-bold text-text-primary">
|
|
{{ deleteConfirmTitleText }}
|
|
</h4>
|
|
<p class="text-xs text-text-tertiary leading-relaxed">
|
|
{{ locale.deleteConfirmPrefix }}
|
|
<span class="text-text-secondary font-bold">{{ locale.disable }}</span> {{ locale.deleteConfirmSuffix }}
|
|
</p>
|
|
</div>
|
|
<div class="flex gap-3 w-full pt-4">
|
|
<button
|
|
class="flex-1 px-4 py-3 bg-bg-secondary hover:bg-bg-tertiary text-text-tertiary text-xs font-black rounded-2xl transition-all"
|
|
@click="showDeleteConfirm = false"
|
|
>
|
|
{{ locale.keepTime }}
|
|
</button>
|
|
<button
|
|
:disabled="deleteInProgress"
|
|
class="flex-1 px-4 py-3 bg-error hover:bg-error text-text-primary text-xs font-black rounded-2xl shadow-xl shadow-[var(--error-glow-20)] transition-all active:scale-95 disabled:opacity-50"
|
|
@click="deleteRequestTime"
|
|
>
|
|
{{ deleteInProgress ? locale.deleting : locale.confirmDelete }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- 说明部分 -->
|
|
<div class="flex flex-col md:flex-row gap-6">
|
|
<div class="flex-1 bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-6 space-y-4">
|
|
<h4
|
|
class="text-xs font-black text-text-primary uppercase tracking-widest flex items-center gap-2"
|
|
>
|
|
<CheckCircle2 :size="14" class="text-success" /> {{ locale.priorityTitle }}
|
|
</h4>
|
|
<ul class="text-[11px] text-text-tertiary space-y-2 font-medium">
|
|
<li class="flex gap-2">
|
|
<span class="text-text-secondary font-black">1.</span>
|
|
<span
|
|
><span class="text-text-secondary">{{ locale.globalSwitch }}</span>
|
|
{{ locale.priorityGlobal }}</span
|
|
>
|
|
</li>
|
|
<li class="flex gap-2">
|
|
<span class="text-text-secondary font-black">2.</span>
|
|
<span
|
|
>{{ locale.priorityTimeLimitPrefix }} <span class="text-text-secondary">{{ locale.timeLimit }}</span> {{ locale.priorityTimeLimitMiddle }}
|
|
<span class="text-text-secondary">{{ locale.enabled }}</span> {{ locale.priorityTimeLimitAnd }}
|
|
<span class="text-text-secondary">{{ locale.expectedCountShort }}</span> {{ locale.priorityTimeLimitSuffix }}</span
|
|
>
|
|
</li>
|
|
<li class="flex gap-2">
|
|
<span class="text-text-secondary font-black">3.</span>
|
|
<span>{{ locale.priorityNoLimit }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="flex-1 bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-6 space-y-4">
|
|
<h4
|
|
class="text-xs font-black text-text-primary uppercase tracking-widest flex items-center gap-2"
|
|
>
|
|
<XCircle :size="14" class="text-error" /> {{ locale.noticeTitle }}
|
|
</h4>
|
|
<ul class="text-[11px] text-text-tertiary space-y-2 font-medium">
|
|
<li class="flex gap-2">
|
|
<span class="text-text-secondary font-black">•</span>
|
|
<span>{{ locale.noticeOverlap }}</span>
|
|
</li>
|
|
<li class="flex gap-2">
|
|
<span class="text-text-secondary font-black">•</span>
|
|
<span>{{ locale.noticeUnlimited }}</span>
|
|
</li>
|
|
<li class="flex gap-2">
|
|
<span class="text-text-secondary font-black">•</span>
|
|
<span>{{ locale.noticeExpired }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { onMounted, onUnmounted, reactive, ref, computed } from 'vue'
|
|
import { useAuth } from '~/composables/useAuth'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { useLocale } from '~/utils/locale'
|
|
import type { RequestTime } from '~/types'
|
|
import {
|
|
Plus,
|
|
Calendar,
|
|
Clock,
|
|
Edit2,
|
|
Trash2,
|
|
Power,
|
|
AlertCircle,
|
|
Hash,
|
|
Save,
|
|
Ban,
|
|
CheckCircle2,
|
|
XCircle,
|
|
X,
|
|
BarChart3,
|
|
Filter
|
|
} from '@lucide/vue'
|
|
|
|
const { getAuthConfig, isAdmin } = useAuth()
|
|
const toast = useToast()
|
|
const { admin } = useLocale()
|
|
const locale = computed(() => {
|
|
const base = admin.value?.requestTimeManager || {}
|
|
const emptyText = () => ''
|
|
return useSafeLocale({
|
|
...base,
|
|
errors: { ...(base.errors || {}) },
|
|
stats: { ...(base.stats || {}) },
|
|
deleteConfirmTitle: base.deleteConfirmTitle || emptyText
|
|
})
|
|
})
|
|
const getErrorMessage = (key, ...args) => formatLocale(locale.value?.errors?.[key], ...args)
|
|
|
|
const requestTimes = ref<RequestTime[]>([])
|
|
const loading = ref(false)
|
|
const error = ref('')
|
|
const showAddForm = ref(false)
|
|
const editingRequestTime = ref<RequestTime | null>(null)
|
|
const RequestTimeToDelete = ref<RequestTime | null>(null)
|
|
const showDeleteConfirm = ref(false)
|
|
const formSubmitting = ref(false)
|
|
const deleteInProgress = ref(false)
|
|
const formError = ref('')
|
|
const deleteConfirmTitleText = computed(() =>
|
|
formatLocale(locale.value?.deleteConfirmTitle, RequestTimeToDelete.value?.name || '')
|
|
)
|
|
const enableRequestTimeLimitation = ref(false)
|
|
const hitRequestTime = ref(false)
|
|
const enableRequest = ref(true)
|
|
const enableSubmissionRestriction = ref(false)
|
|
const submissionRestrictionScope = ref('all')
|
|
const sameSongRestrictionHours = ref<number | null>(null)
|
|
const sameArtistRestrictionHours = ref<number | null>(null)
|
|
const restrictionSaving = ref(false)
|
|
|
|
let refreshInterval: any = null
|
|
|
|
const formData = reactive({
|
|
id: 0,
|
|
name: '',
|
|
startTime: '',
|
|
endTime: '',
|
|
description: '',
|
|
enabled: true,
|
|
expected: 0
|
|
})
|
|
|
|
// 统计数据
|
|
const stats = computed(() => {
|
|
const activeSlots = requestTimes.value.filter((s) => s.enabled && !s.past)
|
|
|
|
// 检查是否有不限容量的活跃时段
|
|
const hasUnlimitedActiveSlot = activeSlots.some((s) => !s.expected || s.expected === 0)
|
|
|
|
const totalExpectedActive = activeSlots.reduce((acc, s) => acc + (s.expected || 0), 0)
|
|
const totalAcceptedActive = activeSlots.reduce((acc, s) => acc + s.accepted, 0)
|
|
|
|
// 累计已接收可以统计所有的(包括过去的),也可以只统计活跃的。通常累计是历史总计,但也可以分历史和当前。
|
|
// 原逻辑是统计所有 requestTimes 的 accepted。保持原逻辑。
|
|
const totalAcceptedAll = requestTimes.value.reduce((acc, s) => acc + s.accepted, 0)
|
|
|
|
return [
|
|
{
|
|
label: locale.value?.stats?.activeSlots || 'Active slots',
|
|
value: activeSlots.length.toString(),
|
|
icon: Clock,
|
|
color: 'blue'
|
|
},
|
|
{
|
|
label: locale.value?.stats?.totalAccepted || 'Total accepted',
|
|
value: totalAcceptedAll.toString(),
|
|
icon: BarChart3,
|
|
color: 'emerald'
|
|
},
|
|
{
|
|
label: locale.value?.stats?.totalCapacity || 'Total capacity',
|
|
value: activeSlots.length === 0
|
|
? (locale.value?.none || 'None')
|
|
: (hasUnlimitedActiveSlot ? (locale.value?.unlimited || 'Unlimited') : totalExpectedActive.toString()),
|
|
icon: Hash,
|
|
color: 'purple'
|
|
},
|
|
{
|
|
label: locale.value?.stats?.remainingCapacity || 'Remaining capacity',
|
|
value: activeSlots.length === 0
|
|
? (locale.value?.none || 'None')
|
|
: (hasUnlimitedActiveSlot
|
|
? (locale.value?.unlimited || 'Unlimited')
|
|
: Math.max(0, totalExpectedActive - totalAcceptedActive).toString()),
|
|
icon: Filter,
|
|
color: 'amber'
|
|
}
|
|
]
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await fetchRequestTimes()
|
|
await fetchSystemSettings()
|
|
await fetchRequestTimeHit()
|
|
|
|
// 每 30 秒自动刷新一次状态,以确保时间同步
|
|
refreshInterval = setInterval(fetchRequestTimeHit, 30000)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (refreshInterval) clearInterval(refreshInterval)
|
|
})
|
|
|
|
const fetchRequestTimes = async () => {
|
|
if (!isAdmin.value) {
|
|
error.value = locale.value.errors.adminOnly
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
error.value = ''
|
|
|
|
try {
|
|
const authConfig = getAuthConfig()
|
|
const response = await fetch('/api/admin/request-times', {
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
...authConfig
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}))
|
|
throw new Error(errorData.message || getErrorMessage('fetchFailedWithStatus', response.status))
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
requestTimes.value = data.sort((a: RequestTime, b: RequestTime) => {
|
|
if (a.past !== b.past) return a.past ? 1 : -1
|
|
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
|
|
const aHasTime = !!(a.startTime || a.endTime)
|
|
const bHasTime = !!(b.startTime || b.endTime)
|
|
if (aHasTime !== bHasTime) return aHasTime ? -1 : 1
|
|
if (aHasTime && bHasTime) {
|
|
if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime)
|
|
else if (a.startTime) return -1
|
|
else if (b.startTime) return 1
|
|
}
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
} catch (err: any) {
|
|
error.value = getThrownMessage(err) || locale.value.errors.fetchFailed
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const fetchSystemSettings = async () => {
|
|
if (!isAdmin.value) return
|
|
try {
|
|
const authConfig = getAuthConfig()
|
|
const response = await fetch('/api/admin/system-settings', {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...authConfig
|
|
})
|
|
if (!response.ok) return
|
|
const data = await response.json()
|
|
enableRequestTimeLimitation.value = data.enableRequestTimeLimitation
|
|
enableRequest.value = !data.forceBlockAllRequests
|
|
enableSubmissionRestriction.value = !!data.enableSubmissionRestriction
|
|
submissionRestrictionScope.value = data.submissionRestrictionScope || 'all'
|
|
sameSongRestrictionHours.value = data.sameSongRestrictionHours ?? null
|
|
sameArtistRestrictionHours.value = data.sameArtistRestrictionHours ?? null
|
|
} catch (err: any) {
|
|
console.error('获取系统设置失败:', getThrownMessage(err))
|
|
}
|
|
}
|
|
|
|
const fetchRequestTimeHit = async () => {
|
|
if (!isAdmin.value) return
|
|
try {
|
|
const authConfig = getAuthConfig()
|
|
const response = await fetch('/api/request-times', {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...authConfig
|
|
})
|
|
if (!response.ok) return
|
|
const data = await response.json()
|
|
hitRequestTime.value = data.hit
|
|
} catch (err: any) {
|
|
console.error('获取投稿开放状态失败:', getThrownMessage(err))
|
|
}
|
|
}
|
|
|
|
const toggleGlobalRequest = async () => {
|
|
enableRequest.value = !enableRequest.value
|
|
await updateSystemSettings()
|
|
await fetchRequestTimeHit()
|
|
}
|
|
|
|
const toggleTimeLimitation = async () => {
|
|
enableRequestTimeLimitation.value = !enableRequestTimeLimitation.value
|
|
await updateSystemSettings()
|
|
await fetchRequestTimeHit()
|
|
}
|
|
|
|
const updateSystemSettings = async () => {
|
|
if (!isAdmin.value) return
|
|
try {
|
|
const authConfig = getAuthConfig()
|
|
const response = await fetch('/api/admin/system-settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
enableRequestTimeLimitation: enableRequestTimeLimitation.value,
|
|
forceBlockAllRequests: !enableRequest.value
|
|
}),
|
|
...authConfig
|
|
})
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}))
|
|
throw new Error(errorData.message || getErrorMessage('updateSystemSettingsFailedWithStatus', response.status))
|
|
}
|
|
} catch (err: any) {
|
|
error.value = getThrownMessage(err) || locale.value.errors.updateSystemSettingsFailed
|
|
}
|
|
}
|
|
|
|
const normalizeRestrictionHours = (key: 'sameSong' | 'sameArtist') => {
|
|
const target = key === 'sameSong' ? sameSongRestrictionHours : sameArtistRestrictionHours
|
|
if (typeof target.value === 'number' && (Number.isNaN(target.value) || target.value < 1 || target.value > 720 || !Number.isInteger(target.value))) {
|
|
target.value = null
|
|
}
|
|
}
|
|
|
|
const toggleRestriction = async () => {
|
|
enableSubmissionRestriction.value = !enableSubmissionRestriction.value
|
|
// 关闭时清空时长,与服务端交叉校验保持一致
|
|
if (!enableSubmissionRestriction.value) {
|
|
sameSongRestrictionHours.value = null
|
|
sameArtistRestrictionHours.value = null
|
|
}
|
|
// 顶部开关立即保存,开启后无需再点面板保存按钮
|
|
await saveRestrictionSettings()
|
|
}
|
|
|
|
const saveRestrictionSettings = async () => {
|
|
normalizeRestrictionHours('sameSong')
|
|
normalizeRestrictionHours('sameArtist')
|
|
|
|
const enabled = enableSubmissionRestriction.value
|
|
const songHours = sameSongRestrictionHours.value
|
|
const artistHours = sameArtistRestrictionHours.value
|
|
|
|
try {
|
|
await submitRestrictionSettings({
|
|
enableSubmissionRestriction: enabled,
|
|
submissionRestrictionScope: submissionRestrictionScope.value,
|
|
sameSongRestrictionHours: enabled ? songHours : null,
|
|
sameArtistRestrictionHours: enabled ? artistHours : null
|
|
})
|
|
} catch {
|
|
error.value = locale.value.restrictionSaveFailed
|
|
}
|
|
}
|
|
|
|
const submitRestrictionSettings = async (body: any) => {
|
|
if (!isAdmin.value) return
|
|
restrictionSaving.value = true
|
|
try {
|
|
const authConfig = getAuthConfig()
|
|
const response = await fetch('/api/admin/system-settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
...authConfig
|
|
})
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}))
|
|
throw new Error(errorData.message || locale.value.restrictionSaveFailed)
|
|
}
|
|
await fetchSystemSettings()
|
|
error.value = ''
|
|
toast.success(locale.value.restrictionSaveSuccess)
|
|
} catch (err) {
|
|
try {
|
|
await fetchSystemSettings()
|
|
} catch {}
|
|
throw err
|
|
} finally {
|
|
restrictionSaving.value = false
|
|
}
|
|
}
|
|
|
|
const editRequestTime = (RequestTime: RequestTime) => {
|
|
editingRequestTime.value = RequestTime
|
|
Object.assign(formData, {
|
|
id: RequestTime.id,
|
|
name: RequestTime.name,
|
|
startTime: RequestTime.startTime,
|
|
endTime: RequestTime.endTime,
|
|
description: RequestTime.description || '',
|
|
enabled: RequestTime.enabled,
|
|
expected: RequestTime.expected || 0
|
|
})
|
|
}
|
|
|
|
const toggleRequestTimeStatus = async (RequestTime: RequestTime) => {
|
|
if (!isAdmin.value) return
|
|
try {
|
|
const authConfig = getAuthConfig()
|
|
const response = await fetch(`/api/admin/request-times/${RequestTime.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: !RequestTime.enabled }),
|
|
...authConfig
|
|
})
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}))
|
|
throw new Error(errorData.message || getErrorMessage('updateStatusFailedWithStatus', response.status))
|
|
}
|
|
await fetchRequestTimes()
|
|
await fetchRequestTimeHit()
|
|
} catch (err: any) {
|
|
error.value = getThrownMessage(err) || locale.value.errors.updateStatusFailed
|
|
}
|
|
}
|
|
|
|
const confirmDelete = (RequestTime: RequestTime) => {
|
|
RequestTimeToDelete.value = RequestTime
|
|
showDeleteConfirm.value = true
|
|
}
|
|
|
|
const deleteRequestTime = async () => {
|
|
if (!RequestTimeToDelete.value || !isAdmin.value) return
|
|
deleteInProgress.value = true
|
|
try {
|
|
const authConfig = getAuthConfig()
|
|
const response = await fetch(`/api/admin/request-times/${RequestTimeToDelete.value.id}`, {
|
|
method: 'DELETE',
|
|
...authConfig
|
|
})
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}))
|
|
throw new Error(errorData.message || getErrorMessage('deleteFailedWithStatus', response.status))
|
|
}
|
|
await fetchRequestTimes()
|
|
await fetchRequestTimeHit()
|
|
showDeleteConfirm.value = false
|
|
RequestTimeToDelete.value = null
|
|
} catch (err: any) {
|
|
error.value = getThrownMessage(err) || locale.value.errors.deleteFailed
|
|
} finally {
|
|
deleteInProgress.value = false
|
|
}
|
|
}
|
|
|
|
const saveRequestTime = async () => {
|
|
formError.value = ''
|
|
if (formData.startTime && formData.endTime && formData.startTime >= formData.endTime) {
|
|
formError.value = locale.value.errors.startBeforeEnd
|
|
return
|
|
}
|
|
if (!formData.name.trim()) {
|
|
formError.value = locale.value.errors.nameRequired
|
|
return
|
|
}
|
|
const isUpdate = !!editingRequestTime.value
|
|
const nameExists = requestTimes.value.some(
|
|
(pt) =>
|
|
pt.name.toLowerCase() === formData.name.trim().toLowerCase() &&
|
|
(!isUpdate || pt.id !== formData.id)
|
|
)
|
|
if (nameExists) {
|
|
formError.value = locale.value.errors.nameExists
|
|
return
|
|
}
|
|
formSubmitting.value = true
|
|
try {
|
|
const authConfig = getAuthConfig()
|
|
const response = await fetch(
|
|
isUpdate ? `/api/admin/request-times/${formData.id}` : '/api/admin/request-times',
|
|
{
|
|
method: isUpdate ? 'PUT' : 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: formData.name.trim(),
|
|
startTime: formData.startTime || null,
|
|
endTime: formData.endTime || null,
|
|
description: formData.description || null,
|
|
enabled: formData.enabled,
|
|
expected: formData.expected || 0
|
|
}),
|
|
...authConfig
|
|
}
|
|
)
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}))
|
|
throw new Error(
|
|
errorData.message || getErrorMessage('saveFailedWithStatus', isUpdate, response.status)
|
|
)
|
|
}
|
|
await fetchRequestTimes()
|
|
await fetchRequestTimeHit()
|
|
cancelForm()
|
|
} catch (err: any) {
|
|
formError.value = getThrownMessage(err) || locale.value.errors.saveFailed
|
|
} finally {
|
|
formSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
const cancelForm = () => {
|
|
showAddForm.value = false
|
|
editingRequestTime.value = null
|
|
formError.value = ''
|
|
Object.assign(formData, {
|
|
id: 0,
|
|
name: '',
|
|
startTime: '',
|
|
endTime: '',
|
|
description: '',
|
|
enabled: true,
|
|
expected: 0
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--panel-bg-alt);
|
|
border-radius: 10px;
|
|
}
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: var(--panel-bg-hover);
|
|
}
|
|
</style>
|