feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+586
View File
@@ -0,0 +1,586 @@
<template>
<Teleport to="body">
<div v-if="visible" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-[var(--modal-overlay)] backdrop-blur-sm" @click="$emit('close')" />
<div class="relative bg-panel-bg-deepest border border-panel-bg-dark rounded-3xl w-full max-w-xl max-h-[90vh] overflow-y-auto shadow-2xl animate-in fade-in zoom-in duration-200">
<!-- 标题栏 -->
<div class="sticky top-0 z-10 bg-panel-bg-deepest-95 backdrop-blur-sm px-6 py-5 border-b border-panel-bg-dark flex items-center justify-between rounded-t-3xl">
<h3 class="text-lg font-black text-text-primary-lighter tracking-tight">{{ locale.title }}</h3>
<button class="p-2 hover:bg-panel-bg-dark rounded-xl transition-colors text-text-tertiary hover:text-text-primary-light" @click="$emit('close')">
<X class="w-5 h-5" />
</button>
</div>
<div class="px-6 py-5 space-y-6">
<!-- 总开关 -->
<div class="bg-panel-bg-darkest-50 border border-panel-bg-dark rounded-2xl p-5">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<p class="text-sm font-bold text-text-primary-light">{{ locale.masterSwitch.label }}</p>
<p class="text-[11px] text-text-tertiary leading-relaxed">{{ locale.masterSwitch.desc }}</p>
</div>
<button
type="button"
role="switch"
:aria-checked="masterEnabled"
:class="masterEnabled ? 'bg-primary' : 'bg-panel-bg-compact'"
class="relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200"
@click="masterEnabled = !masterEnabled"
>
<span :class="masterEnabled ? 'translate-x-[22px]' : 'translate-x-0.5'" class="inline-block h-5 w-5 rounded-full bg-panel-bg-raised shadow-sm transition-transform duration-200 mt-0.5" />
</button>
</div>
</div>
<!-- 备份方式 -->
<div>
<h4 class="text-xs font-black text-text-tertiary uppercase tracking-widest mb-3">{{ locale.methods.title }}</h4>
<div class="space-y-3">
<!-- S3 -->
<MethodCard
:enabled="masterEnabled && methods.s3.enabled"
:master-off="!masterEnabled"
:name="locale.methods.s3.name"
:desc="locale.methods.s3.desc"
@toggle="methods.s3.enabled = $event"
>
<template #icon><Cloud class="w-5 h-5" /></template>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<InputField :label="locale.methods.s3.endpoint" :placeholder="locale.methods.s3.endpointPlaceholder" v-model="methods.s3.endpoint" />
<InputField :label="locale.methods.s3.bucket" :placeholder="locale.methods.s3.bucketPlaceholder" v-model="methods.s3.bucket" />
</div>
<div class="grid grid-cols-2 gap-3">
<InputField :label="locale.methods.s3.region" :placeholder="locale.methods.s3.regionPlaceholder" v-model="methods.s3.region" />
<InputField :label="locale.methods.s3.pathPrefix" :placeholder="locale.methods.s3.pathPrefixPlaceholder" v-model="methods.s3.pathPrefix" />
</div>
<div class="grid grid-cols-2 gap-3">
<InputField :label="locale.methods.s3.accessKey" :placeholder="locale.methods.s3.accessKeyPlaceholder" v-model="methods.s3.accessKey" />
<PasswordField :label="locale.methods.s3.secretKey" :placeholder="locale.methods.s3.secretKeyPlaceholder" v-model="methods.s3.secretKey" />
</div>
<div class="flex gap-2">
<button
class="flex-1 py-2 text-xs font-bold rounded-xl transition-all uppercase tracking-widest disabled:opacity-40 disabled:cursor-not-allowed"
:class="testing === 's3' ? 'bg-panel-bg-compact text-text-secondary' : 'bg-panel-bg-dark hover:bg-panel-bg-compact text-text-muted hover:text-text-primary-light'"
:disabled="testing !== null"
@click="testConnection('s3')"
>
{{ testing === 's3' ? '测试中...' : locale.methods.s3.testConnection }}
</button>
</div>
</div>
</MethodCard>
<!-- WebDAV -->
<MethodCard
:enabled="masterEnabled && methods.webdav.enabled"
:master-off="!masterEnabled"
:name="locale.methods.webdav.name"
:desc="locale.methods.webdav.desc"
@toggle="methods.webdav.enabled = $event"
>
<template #icon><FolderOpen class="w-5 h-5" /></template>
<div class="space-y-3">
<InputField :label="locale.methods.webdav.url" :placeholder="locale.methods.webdav.urlPlaceholder" v-model="methods.webdav.url" />
<div class="grid grid-cols-2 gap-3">
<InputField :label="locale.methods.webdav.username" :placeholder="locale.methods.webdav.usernamePlaceholder" v-model="methods.webdav.username" />
<PasswordField :label="locale.methods.webdav.password" :placeholder="locale.methods.webdav.passwordPlaceholder" v-model="methods.webdav.password" />
</div>
<InputField :label="locale.methods.webdav.path" :placeholder="locale.methods.webdav.pathPlaceholder" v-model="methods.webdav.path" />
<button
class="w-full py-2 text-xs font-bold rounded-xl transition-all uppercase tracking-widest disabled:opacity-40 disabled:cursor-not-allowed"
:class="testing === 'webdav' ? 'bg-panel-bg-compact text-text-secondary' : 'bg-panel-bg-dark hover:bg-panel-bg-compact text-text-muted hover:text-text-primary-light'"
:disabled="testing !== null"
@click="testConnection('webdav')"
>
{{ testing === 'webdav' ? '测试中...' : locale.methods.webdav.testConnection }}
</button>
</div>
</MethodCard>
<!-- Telegram -->
<MethodCard
:enabled="masterEnabled && methods.telegram.enabled"
:master-off="!masterEnabled"
:name="locale.methods.telegram.name"
:desc="locale.methods.telegram.desc"
@toggle="methods.telegram.enabled = $event"
>
<template #icon><Send class="w-5 h-5" /></template>
<div class="space-y-3">
<PasswordField :label="locale.methods.telegram.botToken" :placeholder="locale.methods.telegram.botTokenPlaceholder" v-model="methods.telegram.botToken" />
<InputField :label="locale.methods.telegram.chatId" :placeholder="locale.methods.telegram.chatIdPlaceholder" v-model="methods.telegram.chatId" />
<button
class="w-full py-2 text-xs font-bold rounded-xl transition-all uppercase tracking-widest disabled:opacity-40 disabled:cursor-not-allowed"
:class="testing === 'telegram' ? 'bg-panel-bg-compact text-text-secondary' : 'bg-panel-bg-dark hover:bg-panel-bg-compact text-text-muted hover:text-text-primary-light'"
:disabled="testing !== null"
@click="testConnection('telegram')"
>
{{ testing === 'telegram' ? '发送中...' : locale.methods.telegram.testSend }}
</button>
</div>
</MethodCard>
<!-- 邮件 -->
<MethodCard
:enabled="masterEnabled && methods.email.enabled"
:master-off="!masterEnabled"
:name="locale.methods.email.name"
:desc="locale.methods.email.desc"
@toggle="methods.email.enabled = $event"
>
<template #icon><Mail class="w-5 h-5" /></template>
<div class="space-y-3">
<div class="p-3 bg-primary-5 border border-primary-10 rounded-xl flex items-start gap-2.5">
<Info class="text-primary shrink-0 mt-0.5 w-3.5 h-3.5" />
<p class="text-[11px] text-text-muted leading-relaxed">{{ locale.methods.email.smtpHint }}</p>
</div>
<InputField :label="locale.methods.email.recipient" :placeholder="locale.methods.email.recipientPlaceholder" v-model="methods.email.recipient" />
<button
class="w-full py-2 text-xs font-bold rounded-xl transition-all uppercase tracking-widest disabled:opacity-40 disabled:cursor-not-allowed"
:class="testing === 'email' ? 'bg-panel-bg-compact text-text-secondary' : 'bg-panel-bg-dark hover:bg-panel-bg-compact text-text-muted hover:text-text-primary-light'"
:disabled="testing !== null"
@click="testConnection('email')"
>
{{ testing === 'email' ? '发送中...' : locale.methods.email.testSend }}
</button>
</div>
</MethodCard>
</div>
</div>
<!-- API 触发端点(可折叠) -->
<CollapsibleSection :title="locale.endpoint.title">
<div class="space-y-4">
<!-- 提示 -->
<div class="p-3 bg-warning-10 border border-warning-10 rounded-xl flex items-start gap-2.5">
<Info class="text-warning shrink-0 mt-0.5 w-3.5 h-3.5" />
<p class="text-[11px] text-text-muted leading-relaxed">{{ locale.endpoint.hint }}</p>
</div>
<!-- 端点 URL -->
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest">{{ locale.endpoint.url }}</label>
<div class="flex gap-2">
<code class="flex-1 bg-panel-bg-darkest border border-panel-bg-dark rounded-xl px-4 py-2.5 text-xs text-primary font-mono break-all select-all">
{{ triggerEndpointUrl }}
</code>
<button class="px-3 py-2 bg-panel-bg-dark hover:bg-panel-bg-compact text-text-muted hover:text-text-primary-light rounded-xl transition-colors shrink-0 flex items-center justify-center" @click="copyToClipboard(triggerEndpointUrl)">
<Copy class="w-4 h-4" />
</button>
</div>
</div>
<!-- 触发方式选项卡 -->
<div class="flex gap-2 bg-panel-bg-darkest border border-panel-bg-dark rounded-xl p-1">
<button
v-for="method in triggerMethods"
:key="method.id"
:class="['flex-1 py-2 text-[11px] font-bold rounded-lg transition-all', activeTriggerMethod === method.id ? 'bg-panel-bg-dark text-text-primary-lighter' : 'text-text-tertiary hover:text-text-secondary']"
@click="activeTriggerMethod = method.id"
>
{{ method.label }}
</button>
</div>
<!-- curl -->
<div v-if="activeTriggerMethod === 'curl'" class="relative">
<pre class="bg-panel-bg-darkest border border-panel-bg-dark rounded-xl p-4 text-xs text-text-secondary font-mono overflow-x-auto"><code>curl -X POST "{{ triggerEndpointUrl }}" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type": "full"}'</code></pre>
<button class="absolute top-3 right-3 px-2 py-1.5 bg-panel-bg-dark hover:bg-panel-bg-compact text-text-tertiary hover:text-text-primary-light rounded-lg transition-colors flex items-center justify-center" @click="copyCurlCommand">
<Copy class="w-3.5 h-3.5" />
</button>
</div>
<!-- cron-job.org -->
<div v-if="activeTriggerMethod === 'cronjob'">
<div class="bg-panel-bg-darkest border border-panel-bg-dark rounded-xl p-4 space-y-2.5">
<div class="flex items-center gap-2 text-[11px] text-text-muted">
<span class="text-text-disabled shrink-0">URL:</span>
<code class="text-primary font-mono text-[10px] break-all">{{ triggerEndpointUrl }}</code>
</div>
<div class="flex items-center gap-2 text-[11px] text-text-muted">
<span class="text-text-disabled shrink-0">Method:</span>
<span class="text-text-secondary font-mono">POST</span>
</div>
<div class="flex items-center gap-2 text-[11px] text-text-muted">
<span class="text-text-disabled shrink-0">Header:</span>
<code class="text-success font-mono text-[10px]">X-API-Key: YOUR_API_KEY</code>
</div>
<div class="flex items-center gap-2 text-[11px] text-text-muted">
<span class="text-text-disabled shrink-0"></span>
<code class="text-success font-mono text-[10px]">Content-Type: application/json</code>
</div>
<div class="flex items-center gap-2 text-[11px] text-text-muted">
<span class="text-text-disabled shrink-0">Body:</span>
<code class="text-warning font-mono text-[10px]">{"type": "full"}</code>
</div>
</div>
<a href="https://cron-job.org" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 text-[11px] text-primary hover:text-primary transition-colors mt-2">
{{ locale.endpoint.cronjobLink }}
<ExternalLink class="w-3 h-3" />
</a>
</div>
<!-- GitHub Actions -->
<div v-if="activeTriggerMethod === 'github'" class="relative">
<pre v-pre class="bg-panel-bg-darkest border border-panel-bg-dark rounded-xl p-4 text-xs text-text-secondary font-mono overflow-x-auto"><code>name: Auto Backup
on:
schedule:
- cron: '0 3 * * *'
jobs:
backup:
runs-on: ubuntu-latest
steps:
- name: Trigger Backup
run: |
curl -X POST "${{ secrets.BACKUP_URL }}" \
-H "X-API-Key: ${{ secrets.API_KEY }}" \
-H "Content-Type: application/json" \
-d '{"type": "full"}'</code></pre>
<button class="absolute top-3 right-3 px-2 py-1.5 bg-panel-bg-dark hover:bg-panel-bg-compact text-text-tertiary hover:text-text-primary-light rounded-lg transition-colors flex items-center justify-center" @click="copyGithubAction">
<Copy class="w-3.5 h-3.5" />
</button>
</div>
<!-- Linux cron -->
<div v-if="activeTriggerMethod === 'cron'" class="relative">
<pre class="bg-panel-bg-darkest border border-panel-bg-dark rounded-xl p-4 text-xs text-text-secondary font-mono overflow-x-auto"><code># 编辑 crontab
crontab -e
# 添加以下行(每天凌晨3点执行备份)
0 3 * * * curl -X POST "{{ triggerEndpointUrl }}" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type": "full"}'</code></pre>
<button class="absolute top-3 right-3 px-2 py-1.5 bg-panel-bg-dark hover:bg-panel-bg-compact text-text-tertiary hover:text-text-primary-light rounded-lg transition-colors flex items-center justify-center" @click="copyCronCommand">
<Copy class="w-3.5 h-3.5" />
</button>
</div>
</div>
</CollapsibleSection>
<!-- 备份历史(可折叠) -->
<CollapsibleSection :title="locale.history.title">
<div v-if="historyLoading" class="p-6 text-center text-text-disabled">
<p class="text-xs">加载中...</p>
</div>
<div v-else-if="historyRecords.length === 0" class="p-6 text-center text-text-disabled">
<Clock class="w-10 h-10 mx-auto mb-3 opacity-30" />
<p class="text-xs font-medium">{{ locale.history.empty }}</p>
<p class="text-[10px] text-text-disabled mt-1">{{ locale.history.emptyHint }}</p>
</div>
<div v-else class="space-y-2">
<div v-for="record in historyRecords" :key="record.id" class="bg-panel-bg-darkest-50 border border-panel-bg-dark rounded-xl p-3">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span :class="getStatusInfo(record).cls" class="px-2 py-0.5 text-[10px] font-bold rounded-md border">
{{ getStatusInfo(record).text }}
</span>
<span class="text-[11px] text-text-muted font-mono">{{ record.filename }}</span>
</div>
<span class="text-[10px] text-text-disabled">{{ formatTime(record.createdAt) }}</span>
</div>
<div class="flex items-center gap-4 text-[10px] text-text-tertiary">
<span>{{ record.totalRecords }} 条记录</span>
<span>{{ formatSize(record.backupSize) }}</span>
<span>{{ record.triggeredBy === 'api' ? 'API 触发' : record.triggeredBy || '未知' }}</span>
</div>
<div v-if="record.methods && record.methods.length" class="flex gap-2 mt-2">
<span v-for="m in record.methods" :key="m.method" :class="m.success ? 'text-success' : 'text-error'" class="text-[10px]">
{{ m.method }} {{ m.success ? '✓' : '✗' }}<span v-if="!m.success && m.error" class="text-text-tertiary ml-0.5">({{ formatMethodError(m.error) }})</span>
</span>
</div>
</div>
</div>
<div v-if="historyRecords.length > 0" class="flex justify-end pt-2">
<button
class="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-text-tertiary hover:text-error bg-panel-bg-darkest-50 border border-panel-bg-dark hover:border-error-20 rounded-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="clearingHistory"
@click="clearHistory"
>
<Trash2 class="w-3.5 h-3.5" />
{{ clearingHistory ? '清空中...' : locale.history.clear || '清空历史' }}
</button>
</div>
</CollapsibleSection>
</div>
<!-- 底部操作栏 -->
<div class="sticky bottom-0 bg-panel-bg-darkest-95 backdrop-blur-sm px-6 py-4 border-t border-panel-bg-dark rounded-b-3xl flex gap-3 justify-end">
<button class="px-4 py-2 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest" @click="$emit('close')">
{{ locale.cancel }}
</button>
<button class="px-6 py-2 bg-primary hover:bg-primary text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed" :disabled="saving" @click="saveAll">
{{ saving ? '保存中...' : locale.saveAll }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { X, Copy, ExternalLink, Clock, Info, Cloud, FolderOpen, Send, Mail, Trash2 } from '@lucide/vue'
import { useLocale } from '~/utils/locale'
import { useToast } from '~/composables/useToast'
import CollapsibleSection from '~/components/UI/Common/CollapsibleSection.vue'
import InputField from '~/components/UI/Common/InputField.vue'
import PasswordField from '~/components/UI/Common/PasswordField.vue'
import MethodCard from '~/components/UI/Common/MethodCard.vue'
const props = defineProps({
visible: { type: Boolean, default: false }
})
defineEmits(['close'])
const { showToast } = useToast()
const { admin } = useLocale()
const locale = computed(() => admin.value?.databaseManager?.autoBackup || {})
const saving = ref(false)
const loading = ref(false)
const testing = ref(null)
const configuredSecrets = ref({})
const historyLoading = ref(false)
const clearingHistory = ref(false)
const historyRecords = ref([])
// 总开关
const masterEnabled = ref(false)
// 各备份方式
const methods = reactive({
s3: {
enabled: false,
endpoint: '',
bucket: '',
region: 'auto',
pathPrefix: 'voicehub-backups/',
accessKey: '',
secretKey: ''
},
webdav: {
enabled: false,
url: '',
username: '',
password: '',
path: 'voicehub-backups/'
},
telegram: {
enabled: false,
botToken: '',
chatId: ''
},
email: {
enabled: false,
recipient: ''
}
})
// 触发方式
const activeTriggerMethod = ref('curl')
const triggerMethods = computed(() => [
{ id: 'curl', label: locale.value?.endpoint?.curlTab || 'cURL' },
{ id: 'cronjob', label: locale.value?.endpoint?.cronjobTab || 'cron-job.org' },
{ id: 'github', label: locale.value?.endpoint?.githubTab || 'GitHub Actions' },
{ id: 'cron', label: locale.value?.endpoint?.cronTab || 'Linux Cron' }
])
const triggerEndpointUrl = computed(() => {
if (typeof window !== 'undefined') {
return `${window.location.origin}/api/open/backup/auto`
}
return 'https://your-domain.com/api/open/backup/auto'
})
// 密钥哨兵值:已配置密钥时填入输入框,用户可选中后删除
const SECRET_SENTINEL = '••••••••••••••••'
// 加载已有配置
const loadConfig = async () => {
loading.value = true
try {
const { data } = await $fetch('/api/admin/backup/auto-config')
if (data) {
masterEnabled.value = data.enabled
if (data.config) {
const cfg = data.config
configuredSecrets.value = cfg.configuredSecrets || {}
if (cfg.methods) {
for (const key of ['s3', 'webdav', 'telegram', 'email']) {
if (cfg.methods[key]) {
Object.assign(methods[key], cfg.methods[key])
}
}
}
// 已配置的密钥填入哨兵值,用户可选中删除
if (configuredSecrets.value.s3SecretKey) methods.s3.secretKey = SECRET_SENTINEL
if (configuredSecrets.value.webdavPassword) methods.webdav.password = SECRET_SENTINEL
if (configuredSecrets.value.telegramBotToken) methods.telegram.botToken = SECRET_SENTINEL
}
}
} catch (err) {
console.error('加载自动备份配置失败:', err)
} finally {
loading.value = false
}
}
// 弹窗打开时加载配置
watch(() => props.visible, (val) => {
if (val) {
loadConfig()
loadHistory()
}
})
const formatTime = (d) => {
if (!d) return ''
const date = new Date(d)
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const formatSize = (bytes) => {
if (!bytes) return '0 B'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const getStatusInfo = (record) => {
const methods = record.methods || []
if (methods.length === 0) {
return record.success
? { text: '成功', cls: 'bg-success-10 text-success border-success-10' }
: { text: '失败', cls: 'bg-error-10 text-error border-error-10' }
}
const allSuccess = methods.every((m) => m.success)
const allFailed = methods.every((m) => !m.success)
if (allSuccess) return { text: '成功', cls: 'bg-success-10 text-success border-success-10' }
if (allFailed) return { text: '失败', cls: 'bg-error-10 text-error border-error-10' }
return { text: '部分成功', cls: 'bg-warning-10 text-warning border-warning-10' }
}
const formatMethodError = (err) => {
if (!err) return ''
const match = err.match(/(\d{3})/)
return match ? match[1] : err.slice(0, 16)
}
const loadHistory = async () => {
historyLoading.value = true
try {
const { data } = await $fetch('/api/admin/backup/history')
historyRecords.value = data || []
} catch (err) {
console.error('加载备份历史失败:', err)
} finally {
historyLoading.value = false
}
}
const clearHistory = async () => {
clearingHistory.value = true
try {
const result = await $fetch('/api/admin/backup/history-clear', { method: 'POST' })
showToast(result.message || '备份历史已清空', 'success')
historyRecords.value = []
} catch (err) {
showToast(err?.data?.message || '清空失败', 'error')
} finally {
clearingHistory.value = false
}
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
showToast(locale.value?.messages?.copied || '已复制到剪贴板', 'success')
} catch {
showToast(locale.value?.messages?.copyFailed || '复制失败', 'error')
}
}
const copyCurlCommand = () => {
const url = triggerEndpointUrl.value
copyToClipboard(`curl -X POST "${url}" \\\n -H "X-API-Key: YOUR_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"type": "full"}'`)
}
const copyGithubAction = () => {
copyToClipboard(`name: Auto Backup\non:\n schedule:\n - cron: '0 3 * * *'\njobs:\n backup:\n runs-on: ubuntu-latest\n steps:\n - name: Trigger Backup\n run: |\n curl -X POST "\${ secrets.BACKUP_URL }" \\\n -H "X-API-Key: \${ secrets.API_KEY }" \\\n -H "Content-Type: application/json" \\\n -d '{"type": "full"}'`)
}
const copyCronCommand = () => {
const url = triggerEndpointUrl.value
copyToClipboard(`0 3 * * * curl -X POST "${url}" \\\n -H "X-API-Key: YOUR_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"type": "full"}'`)
}
const testConnection = async (type) => {
if (testing.value) return
const testLabels = {
s3: locale.value?.methods?.s3?.testConnection || '测试连接',
webdav: locale.value?.methods?.webdav?.testConnection || '测试连接',
telegram: locale.value?.methods?.telegram?.testSend || '测试发送',
email: locale.value?.methods?.email?.testSend || '测试发送'
}
testing.value = type
try {
const payload = { ...methods[type] }
// 哨兵值不发送,由后端从已存储配置中获取真实密钥
if (type === 's3' && payload.secretKey === SECRET_SENTINEL) delete payload.secretKey
if (type === 'webdav' && payload.password === SECRET_SENTINEL) delete payload.password
if (type === 'telegram' && payload.botToken === SECRET_SENTINEL) delete payload.botToken
const result = await $fetch(`/api/admin/backup/test-${type}`, {
method: 'POST',
body: payload
})
if (result.success) {
showToast(result.message || '测试成功', 'success')
} else {
showToast(result.message || '测试失败', 'error')
}
} catch (err) {
showToast(err?.data?.message || err?.message || '测试失败', 'error')
} finally {
testing.value = null
}
}
const saveAll = async () => {
saving.value = true
try {
const config = {
methods: {
s3: { ...methods.s3 },
webdav: { ...methods.webdav },
telegram: { ...methods.telegram },
email: { ...methods.email }
}
}
// 空值或哨兵值不覆盖已有密钥
for (const key of ['s3', 'webdav', 'telegram']) {
const m = config.methods[key]
if (key === 's3' && (!m.secretKey || m.secretKey === SECRET_SENTINEL)) delete m.secretKey
if (key === 'webdav' && (!m.password || m.password === SECRET_SENTINEL)) delete m.password
if (key === 'telegram' && (!m.botToken || m.botToken === SECRET_SENTINEL)) delete m.botToken
}
await $fetch('/api/admin/backup/auto-config', {
method: 'PUT',
body: { enabled: masterEnabled.value, config }
})
showToast(locale.value?.messages?.allSaved || '全部配置已保存', 'success')
} catch (err) {
showToast(err?.data?.message || err?.message || '保存失败', 'error')
} finally {
saving.value = false
}
}
</script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+673
View File
@@ -0,0 +1,673 @@
<template>
<div
class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2 animate-in fade-in slide-in-from-bottom-4 duration-700"
>
<!-- 页面标题 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-text-primary tracking-tight flex items-center gap-3">
{{ locale.title }}
</h2>
<p class="text-xs text-text-tertiary mt-1 font-medium">
{{ locale.desc }}
</p>
</div>
</div>
<!-- 添加黑名单项表单 -->
<section class="bg-bg-secondary-40 border border-border-secondary rounded-xl p-8 shadow-xl">
<h3 class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em] mb-6 px-1">
{{ locale.addTitle }}
</h3>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6 items-end">
<div class="xl:col-span-2 space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.type }}</label
>
<div class="grid grid-cols-2 gap-1 p-1 bg-bg-primary border border-border-secondary rounded-lg">
<button
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'SONG' ? 'bg-bg-tertiary text-primary' : 'text-text-disabled'"
@click="selectType('SONG')"
>
<Music :size="12" /> {{ locale.song }}
</button>
<button
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'KEYWORD' ? 'bg-bg-tertiary text-info' : 'text-text-disabled'"
@click="selectType('KEYWORD')"
>
<Type :size="12" /> {{ locale.keyword }}
</button>
<button
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'LANGUAGE' ? 'bg-bg-tertiary text-warning' : 'text-text-disabled'"
@click="selectType('LANGUAGE')"
>
<Languages :size="12" /> {{ locale.language }}
</button>
<button
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'GENRE' ? 'bg-bg-tertiary text-success' : 'text-text-disabled'"
@click="selectType('GENRE')"
>
<AudioLines :size="12" /> {{ locale.genre }}
</button>
</div>
</div>
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1">
{{ valueLabel }}
</label>
<input
v-if="newItem.type === 'SONG' || newItem.type === 'KEYWORD'"
v-model="newItem.value"
type="text"
:placeholder="
newItem.type === 'SONG' ? locale.songPlaceholder : locale.keywordPlaceholder
"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 placeholder:text-text-secondary transition-all"
>
<CustomSelect
v-else
v-model="newItem.value"
:options="typeValueOptions"
/>
<p
v-if="newItem.type === 'LANGUAGE' || newItem.type === 'GENRE'"
class="text-[10px] text-text-secondary px-1 leading-relaxed"
>
{{ newItem.type === 'LANGUAGE' ? locale.languageHint : locale.genreHint }}
</p>
</div>
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.reason }}</label
>
<input
v-model="newItem.reason"
type="text"
:placeholder="locale.reasonPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 placeholder:text-text-secondary transition-all"
>
</div>
<div class="xl:col-span-2">
<button
:disabled="!newItem.value || loading"
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs font-black rounded-lg shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
@click="addBlacklistItem"
>
<Plus v-if="!loading" :size="16" />
<AppSpinner :size="16" />
{{ loading ? locale.processing : locale.addButton }}
</button>
</div>
</div>
</section>
<!-- 筛选和搜索 -->
<div
class="bg-bg-secondary-20 border border-border-secondary rounded-xl p-4 flex flex-col md:flex-row gap-4 items-center"
>
<div class="flex-1 relative w-full">
<Search class="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary" :size="18" />
<input
v-model="filters.search"
type="text"
:placeholder="locale.searchPlaceholder"
class="w-full bg-bg-primary-50 border border-border-secondary rounded-lg pl-12 pr-4 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 placeholder:text-text-secondary transition-all"
@input="debounceSearch"
>
</div>
<div class="flex gap-3 w-full md:w-auto">
<CustomSelect
:label="locale.typeFilter"
v-model="filters.type"
:options="typeOptions"
label-key="label"
value-key="value"
class="flex-1 md:w-48"
@change="() => { pagination.page = 1; loadBlacklist(); }"
/>
<button
class="p-3 bg-bg-primary border border-border-secondary rounded-lg text-text-disabled hover:text-primary transition-all active:scale-95 flex items-center justify-center"
@click="loadBlacklist"
>
<Filter :size="18" />
</button>
</div>
</div>
<!-- 黑名单列表 -->
<div class="space-y-4">
<div class="flex items-center justify-between px-2">
<h4 class="text-[10px] font-black text-text-disabled uppercase tracking-widest">
{{ locale.listTitle(pagination.total) }}
</h4>
</div>
<div class="grid grid-cols-1 gap-3">
<TransitionGroup
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-200 ease-in absolute w-full"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-for="item in blacklist"
:key="item.id"
class="group flex flex-col lg:flex-row lg:items-center gap-6 p-6 bg-bg-secondary-30 border rounded-xl transition-all hover:border-border-tertiary"
:class="[item.isActive ? 'border-border-secondary' : 'border-border-secondary-40 opacity-60']"
>
<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="getTypeMeta(item.type).boxClass"
>
<component :is="getTypeMeta(item.type).icon" :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="getTypeMeta(item.type).badgeClass"
>
{{ getTypeMeta(item.type).label }}
</span>
<h5 class="text-base font-black text-text-primary truncate tracking-tight">
{{ item.value }}
</h5>
</div>
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2 text-text-tertiary font-bold text-[11px]">
<MessageSquare :size="12" class="text-text-secondary" />
{{ locale.reasonPrefix }} {{ item.reason || locale.noReason }}
</div>
<div
class="flex items-center gap-2 text-text-disabled font-bold text-[10px] uppercase tracking-tighter"
>
<Clock :size="11" class="text-text-primary" />
{{ locale.createdAt }} {{ formatDate(item.createdAt) }}
</div>
</div>
</div>
</div>
<div
class="flex items-center justify-end gap-3 pt-4 lg:pt-0 border-t lg:border-t-0 border-border-secondary-50"
>
<button
:disabled="loading"
class="flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
:class="[
item.isActive
? 'bg-bg-tertiary text-text-tertiary hover:text-warning hover:bg-warning-5 hover:border-warning-10 border border-transparent'
: 'bg-success text-text-primary shadow-lg shadow-[var(--success-glow-20)]'
]"
@click="toggleItemStatus(item)"
>
<Power :size="12" />
{{ item.isActive ? locale.disable : locale.enable }}
</button>
<button
:disabled="loading"
class="p-3 bg-bg-primary border border-border-secondary rounded-xl text-text-secondary hover:text-error hover:border-error-30 transition-all active:scale-95"
@click="deleteItem(item)"
>
<Trash2 :size="16" />
</button>
</div>
</div>
</TransitionGroup>
<!-- 加载状态 -->
<div
v-if="loading && blacklist.length === 0"
class="py-20 flex flex-col items-center justify-center space-y-4"
>
<AppSpinner :size="48" />
<p class="text-text-tertiary text-xs font-bold uppercase tracking-widest">{{ locale.loading }}</p>
</div>
<!-- 空状态 -->
<div
v-else-if="blacklist.length === 0"
class="py-20 flex flex-col items-center justify-center text-center space-y-4 bg-bg-secondary-10 border border-border-secondary-40 border-dashed rounded-xl"
>
<div
class="w-16 h-16 rounded-lg bg-bg-primary border border-border-secondary flex items-center justify-center text-text-primary shadow-xl"
>
<Ban :size="32" />
</div>
<div class="space-y-1 px-4">
<h6 class="text-sm font-bold text-text-disabled">{{ locale.emptyTitle }}</h6>
<p class="text-[10px] text-text-secondary font-bold uppercase tracking-widest">
{{ locale.emptyDesc }}
</p>
</div>
</div>
</div>
<!-- 分页控制 -->
<div v-if="pagination.pages > 1" class="flex items-center justify-center gap-4 mt-8">
<button
:disabled="pagination.page <= 1 || loading"
class="p-3 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-text-primary disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95 flex items-center justify-center"
@click="changePage(pagination.page - 1)"
>
<ChevronLeft :size="20" />
</button>
<div
class="px-6 py-3 bg-bg-secondary-50 border border-border-secondary rounded-lg text-[11px] font-black text-text-tertiary uppercase tracking-widest"
>
{{ locale.pagination(pagination.page, pagination.pages) }}
</div>
<button
:disabled="pagination.page >= pagination.pages || loading"
class="p-3 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-text-primary disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95 flex items-center justify-center"
@click="changePage(pagination.page + 1)"
>
<ChevronRight :size="20" />
</button>
</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="showDeleteDialog"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
>
<div
class="w-full max-w-md bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300"
@click.stop
>
<div class="flex flex-col items-center py-8 space-y-6 text-center px-8">
<div
class="w-16 h-16 rounded-lg bg-error-10 text-error flex items-center justify-center border border-error-20 shadow-2xl shadow-[var(--error-glow-10)]"
>
<Trash2 :size="28" />
</div>
<div class="space-y-2">
<h4 class="text-lg font-bold text-text-primary">{{ locale.removeTitle }}</h4>
<p class="text-xs text-text-tertiary leading-relaxed">
{{ locale.removeMessage(deleteTargetItem?.value || '') }}
</p>
</div>
<div class="flex gap-3 w-full pt-4">
<button
class="flex-1 px-4 py-4 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary text-[10px] font-black rounded-lg transition-all uppercase tracking-widest"
@click="showDeleteDialog = false"
>
{{ locale.cancel }}
</button>
<button
:disabled="loading"
class="flex-1 px-4 py-4 bg-error hover:bg-error text-text-primary text-[10px] font-black rounded-lg shadow-xl shadow-[var(--error-glow-20)] transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50"
@click="confirmDelete"
>
{{ loading ? locale.deleting : locale.confirmRemove }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, computed } from 'vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import {
ShieldAlert,
Music,
Type,
Languages,
AudioLines,
Plus,
Search,
Filter,
MessageSquare,
Clock,
Power,
Trash2,
Ban,
ChevronLeft,
ChevronRight,
ChevronDown
} from '@lucide/vue'
import { useToast } from '~/composables/useToast'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import {
BLACKLIST_LANGUAGE_VALUES,
BLACKLIST_GENRE_VALUES,
BLACKLIST_LANGUAGE_LABEL_EN,
BLACKLIST_GENRE_LABEL_EN
} from '~/utils/blacklist'
import { useLocale } from '~/utils/locale'
const { showToast: showNotification } = useToast()
const { admin, currentLocale } = useLocale()
const locale = computed(() => {
const base = admin.value?.blacklist || {}
return {
...base,
listTitle: (total) => formatLocaleValue(base.listTitle, total),
pagination: (page, pages) => formatLocaleValue(base.pagination, page, pages),
removeMessage: (value) => formatLocaleValue(base.removeMessage, value),
statusSuccess: (statusText) => formatLocaleValue(base.statusSuccess, statusText)
}
})
const blacklist = ref([])
const loading = ref(false)
const error = ref('')
const success = ref('')
// 删除对话框相关
const showDeleteDialog = ref(false)
const deleteTargetItem = ref(null)
const newItem = reactive({
type: 'SONG',
value: '',
reason: ''
})
const filters = reactive({
type: '',
search: ''
})
const pagination = reactive({
page: 1,
limit: 20,
total: 0,
pages: 0
})
const typeOptions = computed(() => [
{ label: locale.value?.allTypes || 'All Types', value: '' },
{ label: locale.value?.song || 'Song', value: 'SONG' },
{ label: locale.value?.keyword || 'Keyword', value: 'KEYWORD' },
{ label: locale.value?.language || 'Language', value: 'LANGUAGE' },
{ label: locale.value?.genre || 'Genre', value: 'GENRE' }
])
// 类型 → 图标/徽章/文案元数据
const getTypeMeta = (type) => {
switch (type) {
case 'SONG':
return {
icon: Music,
label: locale.value?.song || 'Song',
boxClass: 'bg-primary-hover-10 text-primary border-primary-20 shadow-lg shadow-[var(--primary-glow-5)]',
badgeClass: 'bg-primary-hover-10 text-primary border-primary-20'
}
case 'LANGUAGE':
return {
icon: Languages,
label: locale.value?.language || 'Language',
boxClass: 'bg-warning-10 text-warning border-warning-20 shadow-lg shadow-[var(--warning-glow-5)]',
badgeClass: 'bg-warning-10 text-warning border-warning-20'
}
case 'GENRE':
return {
icon: AudioLines,
label: locale.value?.genre || 'Genre',
boxClass: 'bg-success-10 text-success border-success-20 shadow-lg shadow-[var(--success-glow-5)]',
badgeClass: 'bg-success-10 text-success border-success-20'
}
default:
return {
icon: Type,
label: locale.value?.keyword || 'Keyword',
boxClass: 'bg-info-10 text-info border-info-20 shadow-lg shadow-[var(--info-glow-5)]',
badgeClass: 'bg-info-10 text-info border-info-20'
}
}
}
// 语种/曲风候选值选项(存储值为规范中文标签,英文界面附注显示名)
const localeValueLabel = (value, enMap) =>
currentLocale.value === 'en-US' ? `${enMap[value] || value} (${value})` : value
const typeValueOptions = computed(() => {
if (newItem.type === 'LANGUAGE') {
return BLACKLIST_LANGUAGE_VALUES.map((value) => ({
value,
label: localeValueLabel(value, BLACKLIST_LANGUAGE_LABEL_EN)
}))
}
if (newItem.type === 'GENRE') {
return BLACKLIST_GENRE_VALUES.map((value) => ({
value,
label: localeValueLabel(value, BLACKLIST_GENRE_LABEL_EN)
}))
}
return []
})
const valueLabel = computed(() => {
if (newItem.type === 'SONG') return locale.value?.songLabel
if (newItem.type === 'KEYWORD') return locale.value?.keywordLabel
if (newItem.type === 'LANGUAGE') return locale.value?.languageLabel
return locale.value?.genreLabel
})
// 切换类型时清空已选值
const selectType = (type) => {
if (newItem.type === type) return
newItem.type = type
newItem.value = ''
}
let searchTimeout = null
// 加载黑名单
const loadBlacklist = async () => {
loading.value = true
error.value = ''
try {
const params = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
...(filters.type && { type: filters.type }),
...(filters.search && { search: filters.search })
})
const response = await $fetch(`/api/admin/blacklist?${params}`, {
...useAuth().getAuthConfig()
})
blacklist.value = response.blacklist
Object.assign(pagination, response.pagination)
} catch (err) {
const fetchFailed = locale.value?.fetchFailed || 'Failed to load blacklist'
error.value = fetchFailed
console.error('获取黑名单失败:', err)
showNotification(`${fetchFailed}: ${getErrorMessage(err)}`, 'error')
} finally {
loading.value = false
}
}
// 处理类型筛选变更
// 添加黑名单项
const addBlacklistItem = async () => {
if (!newItem.value.trim()) return
loading.value = true
error.value = ''
success.value = ''
try {
await $fetch('/api/admin/blacklist', {
method: 'POST',
body: {
type: newItem.type,
value: newItem.value.trim(),
reason: newItem.reason.trim() || null
},
...useAuth().getAuthConfig()
})
showNotification(locale.value.addSuccess, 'success')
// 重置表单
newItem.value = ''
newItem.reason = ''
// 重新加载列表
pagination.page = 1
await loadBlacklist()
} catch (err) {
error.value = getErrorMessage(err) || locale.value.addFailed
console.error('添加黑名单项失败:', err)
showNotification(locale.value.addFailedPrefix + error.value, 'error')
} finally {
loading.value = false
}
}
// 切换项目状态
const toggleItemStatus = async (item) => {
loading.value = true
error.value = ''
try {
await $fetch(`/api/admin/blacklist/${item.id}`, {
method: 'PATCH',
body: {
isActive: !item.isActive
},
...useAuth().getAuthConfig()
})
item.isActive = !item.isActive
showNotification(locale.value.statusSuccess(item.isActive ? locale.value.enable : locale.value.disable), 'success')
} catch (err) {
error.value = locale.value.updateFailed
console.error('更新状态失败:', err)
showNotification(locale.value.updateFailed, 'error')
} finally {
loading.value = false
}
}
// 删除项目
const deleteItem = (item) => {
deleteTargetItem.value = item
showDeleteDialog.value = true
}
// 确认删除
const confirmDelete = async () => {
if (!deleteTargetItem.value) return
loading.value = true
error.value = ''
try {
await $fetch(`/api/admin/blacklist/${deleteTargetItem.value.id}`, {
method: 'DELETE',
...useAuth().getAuthConfig()
})
showNotification(locale.value.deleteSuccess, 'success')
await loadBlacklist()
} catch (err) {
error.value = locale.value.deleteFailed
console.error('删除失败:', err)
showNotification(`${locale.value.deleteFailed}: ${getErrorMessage(err)}`, 'error')
} finally {
loading.value = false
showDeleteDialog.value = false
deleteTargetItem.value = null
}
}
// 搜索防抖
const debounceSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadBlacklist()
}, 500)
}
// 切换页面
const changePage = (page) => {
pagination.page = page
loadBlacklist()
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString(currentLocale.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
onMounted(() => {
loadBlacklist()
})
</script>
<style scoped>
.animate-in {
animation-duration: 0.5s;
animation-fill-mode: both;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* 隐藏滚动条但保持功能 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--panel-bg-alt);
border-radius: 10px;
}
</style>
+822
View File
@@ -0,0 +1,822 @@
<template>
<div class="space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 class="text-lg font-black">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary mt-1 font-medium">
{{ locale.desc }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
class="flex items-center gap-2 px-4 py-2 rounded-xl border border-border-secondary bg-bg-secondary text-text-secondary text-xs font-bold hover:border-border-tertiary transition-all"
@click="refreshAll"
>
<RefreshCw :size="14" /> {{ locale.refresh }}
</button>
<button
class="flex items-center gap-2 px-4 py-2 rounded-xl border border-border-secondary bg-bg-secondary text-text-secondary text-xs font-bold hover:border-border-tertiary transition-all"
:disabled="exporting"
@click="exportCodes"
>
<Download :size="14" /> {{ exporting ? locale.exporting : exportButtonText }}
</button>
</div>
</div>
<div class="grid grid-cols-2 xl:grid-cols-4 gap-3">
<div v-for="item in stats" :key="item.label" class="rounded-2xl border border-border-secondary bg-bg-secondary-50 p-4">
<p class="text-[10px] font-black uppercase tracking-[0.24em] text-text-tertiary">{{ item.label }}</p>
<div class="mt-2 flex items-end justify-between gap-2">
<span class="text-2xl font-black text-text-primary">{{ item.value }}</span>
<span :class="['text-[10px] font-bold px-2 py-1 rounded-full', item.badgeClass]">{{ item.hint }}</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px] gap-6">
<section class="min-w-0 rounded-2xl border border-border-secondary bg-bg-secondary-40 p-5 space-y-5">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-1 flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-text-disabled" />
<input
v-model="filters.q"
type="text"
:placeholder="locale.searchPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-xl pl-9 pr-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40 transition-all"
@keyup.enter="fetchCodes(1)"
>
</div>
<CustomSelect
v-model="filters.status"
:label="locale.status"
:options="statusFilterOptions"
label-key="label"
value-key="value"
:placeholder="locale.allStatus"
class-name="min-w-[160px] sm:w-40"
@change="fetchCodes(1)"
/>
</div>
<button
class="text-xs font-bold text-text-tertiary hover:text-text-primary transition-colors"
@click="resetFilters"
>
{{ locale.clearFilters }}
</button>
</div>
<div v-if="selectedIds.length" class="flex flex-wrap items-center gap-2 rounded-xl border border-primary-20 bg-primary-5 p-3">
<span class="text-xs font-black text-primary">{{ getLocaleMessage('selectedItems', selectedIds.length) }}</span>
<CustomSelect
v-model="bulkStatus"
:options="bulkStatusOptions"
label-key="label"
value-key="value"
class-name="w-32"
/>
<button
class="px-3 py-2 rounded-lg bg-primary-hover text-text-primary text-xs font-bold hover:bg-primary transition-colors"
:disabled="saving"
@click="applyBulkStatus"
>
{{ locale.applyBulk }}
</button>
<button class="px-3 py-2 rounded-lg bg-bg-secondary text-text-secondary text-xs font-bold border border-border-secondary" @click="clearSelection">
{{ locale.cancelSelection }}
</button>
</div>
<div class="overflow-x-auto rounded-2xl border border-border-secondary">
<div v-if="loading" class="flex min-w-[880px] items-center justify-center bg-bg-primary-60 p-8 text-center text-sm text-text-tertiary">
{{ locale.loading }}
</div>
<table v-else class="min-w-[880px] table-fixed text-left text-sm">
<thead class="bg-bg-primary-80 text-text-tertiary">
<tr>
<th class="px-3 py-3 w-10">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-tertiary bg-bg-secondary text-primary"
:checked="allVisibleSelected"
:indeterminate="someVisibleSelected && !allVisibleSelected"
@change="toggleSelectAll"
>
</th>
<th class="px-3 py-3 w-16">ID</th>
<th class="px-3 py-3 w-64">{{ locale.cardCode }}</th>
<th class="px-3 py-3 w-28">{{ locale.status }}</th>
<th class="px-3 py-3 w-60">{{ locale.note }}</th>
<th class="px-3 py-3 w-40">{{ locale.time }}</th>
<th class="px-3 py-3 w-44">{{ locale.actions }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in codes" :key="item.id" class="border-t border-border-secondary-80 hover:bg-bg-primary-70 transition-colors">
<td class="px-3 py-3 align-top">
<input
type="checkbox"
class="h-4 w-4 rounded border-border-tertiary bg-bg-secondary text-primary"
:checked="selectedIds.includes(item.id)"
@change="toggleSelect(item.id)"
>
</td>
<td class="px-3 py-3 align-top text-text-tertiary">{{ item.id }}</td>
<td class="px-3 py-3 align-top font-mono text-text-primary">
<div class="flex max-w-full items-center gap-2 rounded-xl border border-border-secondary bg-bg-primary px-3 py-2">
<span class="block min-w-0 flex-1 truncate text-xs tracking-[0.08em]" :title="item.code">{{ item.code }}</span>
<button class="shrink-0 text-text-tertiary hover:text-text-primary transition-colors" :title="locale.copy" @click="copyCode(item.code)">
<Copy :size="14" />
</button>
</div>
</td>
<td class="px-3 py-3 align-top">
<span :class="['inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-black tracking-[0.2em]', statusMeta(item.status).class]">
{{ statusMeta(item.status).label }}
</span>
<p v-if="item.lockedAt || item.redeemedAt" class="mt-1 text-[10px] text-text-tertiary leading-relaxed">
<span v-if="item.lockedAt">{{ getLocaleMessage('lockedAt', formatDate(item.lockedAt)) }}</span>
<span v-if="item.redeemedAt">{{ item.lockedAt ? ' · ' : '' }}{{ getLocaleMessage('redeemedAt', formatDate(item.redeemedAt)) }}</span>
</p>
</td>
<td class="px-3 py-3 align-top">
<div class="space-y-2">
<textarea
v-model="item.noteDraft"
rows="2"
:placeholder="locale.note"
class="w-full resize-none rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40"
/>
<button class="text-[10px] font-black uppercase tracking-[0.2em] text-primary hover:text-primary transition-colors" @click="saveNote(item)">
{{ locale.saveNote }}
</button>
</div>
</td>
<td class="px-3 py-3 align-top text-[11px] text-text-tertiary leading-relaxed">
<p>{{ getLocaleMessage('createdAt', formatDate(item.createdAt)) }}</p>
<p>{{ getLocaleMessage('updatedAt', formatDate(item.updatedAt)) }}</p>
</td>
<td class="px-3 py-3 align-top">
<div class="flex flex-wrap gap-2">
<button class="rounded-lg border border-border-secondary bg-bg-primary px-2.5 py-1.5 text-[11px] font-bold text-text-secondary" @click="updateStatus([item.id], 'AVAILABLE')">{{ locale.available }}</button>
<button class="rounded-lg border border-border-secondary bg-bg-primary px-2.5 py-1.5 text-[11px] font-bold text-warning" @click="updateStatus([item.id], 'LOCKED')">{{ locale.locked }}</button>
<button class="rounded-lg border border-border-secondary bg-bg-primary px-2.5 py-1.5 text-[11px] font-bold text-success" @click="updateStatus([item.id], 'REDEEMED')">{{ locale.redeemed }}</button>
<button class="rounded-lg border border-border-secondary bg-bg-primary px-2.5 py-1.5 text-[11px] font-bold text-error" @click="updateStatus([item.id], 'INVALID')">{{ locale.invalid }}</button>
</div>
</td>
</tr>
<tr v-if="!codes.length">
<td colspan="7" class="px-3 py-10 text-center text-sm text-text-tertiary">{{ locale.empty }}</td>
</tr>
</tbody>
</table>
</div>
<Pagination
v-model:current-page="pagination.page"
:total-pages="pagination.totalPages"
:total-items="pagination.total"
:item-name="locale.itemName"
@change="fetchCodes"
/>
</section>
<section class="space-y-4">
<div class="rounded-2xl border border-border-secondary bg-bg-secondary-40 p-4 space-y-3">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-black text-text-primary">{{ locale.createTitle }}</h3>
<p class="mt-1 text-[11px] text-text-tertiary">{{ locale.createDesc }}</p>
</div>
<div class="inline-flex shrink-0 rounded-xl border border-border-secondary bg-bg-primary p-1">
<button
:class="['min-w-[72px] whitespace-nowrap px-2.5 py-1.5 rounded-lg text-xs font-bold transition-colors', createMode === 'manual' ? 'bg-bg-tertiary text-text-primary' : 'text-text-tertiary']"
@click="createMode = 'manual'"
>
{{ locale.manualImport }}
</button>
<button
:class="['min-w-[72px] whitespace-nowrap px-2.5 py-1.5 rounded-lg text-xs font-bold transition-colors', createMode === 'generate' ? 'bg-bg-tertiary text-text-primary' : 'text-text-tertiary']"
@click="createMode = 'generate'"
>
{{ locale.autoGenerate }}
</button>
</div>
</div>
<div v-if="createMode === 'manual'" class="space-y-3">
<textarea
v-model="manualCodes"
rows="4"
:placeholder="locale.manualPlaceholder"
class="w-full resize-none rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40"
/>
</div>
<div v-else class="grid grid-cols-2 gap-2.5">
<div>
<label class="mb-1.5 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.generateCount }}</label>
<input v-model.number="generateForm.count" type="number" min="1" class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-primary-40">
</div>
<div>
<label class="mb-1.5 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.prefix }}</label>
<input v-model="generateForm.prefix" type="text" :placeholder="locale.prefixPlaceholder" class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-primary-40">
</div>
<div>
<label class="mb-1.5 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.randomLength }}</label>
<input v-model.number="generateForm.length" type="number" min="4" max="32" class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-primary-40">
</div>
<div>
<label class="mb-1.5 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.charset }}</label>
<input v-model="generateForm.charset" type="text" class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-primary-40">
</div>
</div>
<div>
<label class="mb-1.5 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.note }}</label>
<input v-model="createNote" type="text" :placeholder="locale.bulkNotePlaceholder" class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40">
</div>
<div class="flex flex-wrap gap-2">
<button
class="flex items-center gap-2 rounded-xl bg-primary-hover px-3.5 py-2 text-xs font-black text-text-primary hover:bg-primary transition-colors disabled:opacity-50"
:disabled="saving"
@click="createCodes"
>
<Plus :size="14" /> {{ locale.createCardCode }}
</button>
<button class="rounded-xl border border-border-secondary bg-bg-primary px-3.5 py-2 text-xs font-bold text-text-secondary" @click="fillDemoCodes">
{{ locale.fillDemo }}
</button>
</div>
<div
v-if="createMode === 'generate' && lastGeneratedCodes.length"
class="rounded-2xl border border-border-secondary bg-bg-primary-60 p-3 space-y-3"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<p class="text-xs font-black text-text-primary">{{ getLocaleMessage('lastGeneratedTitle', lastGeneratedCodes.length) }}</p>
<p class="mt-1 text-[11px] text-text-tertiary">{{ locale.lastGeneratedDesc }}</p>
</div>
<button
class="flex items-center gap-2 rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-xs font-bold text-text-secondary hover:border-border-tertiary hover:text-text-primary transition-colors"
@click="copyLastGeneratedCodes"
>
<Copy :size="14" /> {{ locale.copyAll }}
</button>
</div>
<div class="max-h-32 overflow-y-auto rounded-xl border border-border-secondary bg-bg-primary p-3 font-mono text-xs leading-relaxed text-text-primary">
<p v-for="code in lastGeneratedCodes" :key="code" class="break-all">{{ code }}</p>
</div>
</div>
</div>
<div class="rounded-2xl border border-border-secondary bg-bg-secondary-40 p-4 space-y-3">
<h3 class="text-sm font-black text-text-primary uppercase tracking-[0.24em]">{{ locale.quickTipsTitle }}</h3>
<ul class="space-y-2 text-[12px] leading-relaxed text-text-tertiary">
<li v-for="tip in locale.quickTips" :key="tip" class="flex items-start gap-2">
<span class="shrink-0 text-text-disabled">·</span>
<span>{{ tip }}</span>
</li>
</ul>
</div>
</section>
</div>
<section class="rounded-2xl border border-border-secondary bg-bg-secondary-40 p-5 space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-black text-text-primary uppercase tracking-[0.24em]">{{ locale.redeemLogsTitle }}</h3>
<p class="mt-1 text-[11px] text-text-tertiary">{{ locale.redeemLogsDesc }}</p>
</div>
<button
class="rounded-xl border border-border-secondary bg-bg-primary px-3 py-2 text-xs font-bold text-text-secondary"
@click="fetchRedeemLogs"
>
{{ locale.refreshLogs }}
</button>
</div>
<div class="grid grid-cols-1 gap-3 xl:grid-cols-6">
<div class="xl:col-span-2">
<label class="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.keyword }}</label>
<input
v-model="logFilters.q"
type="text"
:placeholder="locale.logSearchPlaceholder"
class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40"
@keyup.enter="fetchRedeemLogs"
>
</div>
<div>
<label class="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.source }}</label>
<select
v-model="logFilters.source"
class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary focus:outline-none focus:border-primary-40"
>
<option value="">{{ locale.allSources }}</option>
<option value="ADMIN_MANUAL">{{ locale.sources?.adminManual || '手动核销' }}</option>
<option value="SCHEDULE_AUTO">{{ locale.sources?.scheduleAuto || '排期自动' }}</option>
<option value="SCHEDULE_REMOVE">{{ locale.sources?.scheduleRemove || '移除排期' }}</option>
</select>
</div>
<div>
<label class="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.redeemer }}</label>
<input
v-model="logFilters.redeemer"
type="text"
:placeholder="locale.redeemerPlaceholder"
class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary-40"
>
</div>
<div>
<label class="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.startDate }}</label>
<input
v-model="logFilters.startDate"
type="date"
class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary focus:outline-none focus:border-primary-40"
>
</div>
<div>
<label class="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-text-tertiary">{{ locale.endDate }}</label>
<input
v-model="logFilters.endDate"
type="date"
class="w-full rounded-xl border border-border-secondary bg-bg-primary px-3 py-2.5 text-sm text-text-primary focus:outline-none focus:border-primary-40"
>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
class="rounded-xl bg-primary-hover px-4 py-2 text-xs font-black text-text-primary hover:bg-primary transition-colors"
@click="fetchRedeemLogs"
>
{{ locale.queryLogs }}
</button>
<button
class="rounded-xl border border-border-secondary bg-bg-primary px-4 py-2 text-xs font-bold text-text-secondary"
@click="resetLogFilters"
>
{{ locale.clearConditions }}
</button>
</div>
<div v-if="logsLoading" class="rounded-xl border border-border-secondary bg-bg-primary-60 p-6 text-center text-sm text-text-tertiary">
{{ locale.loadingLogs }}
</div>
<div v-else class="overflow-hidden rounded-2xl border border-border-secondary">
<table class="min-w-full text-left text-sm">
<thead class="bg-bg-primary-80 text-text-tertiary">
<tr>
<th class="px-3 py-3 w-20">{{ locale.logId }}</th>
<th class="px-3 py-3">{{ locale.cardCode }}</th>
<th class="px-3 py-3 w-28">{{ locale.source }}</th>
<th class="px-3 py-3 w-56">{{ locale.redeemer }}</th>
<th class="px-3 py-3 w-40">{{ locale.redeemedTime }}</th>
<th class="px-3 py-3">{{ locale.relatedSong }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in redeemLogs"
:key="item.id"
class="border-t border-border-secondary-80 hover:bg-bg-primary-70 transition-colors"
>
<td class="px-3 py-3 text-text-tertiary">{{ item.id }}</td>
<td class="px-3 py-3 font-mono text-text-primary break-all">{{ item.code || item.codeSnapshot }}</td>
<td class="px-3 py-3">
<span :class="['inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-black tracking-[0.2em]', logSourceMeta(item.source).class]">
{{ logSourceMeta(item.source).label }}
</span>
</td>
<td class="px-3 py-3 text-text-secondary">
{{ item.redeemer?.name || locale.unknownUser }}
<span class="text-text-tertiary">({{ item.redeemer?.username || 'unknown' }})</span>
</td>
<td class="px-3 py-3 text-text-tertiary">{{ formatDate(item.redeemedAt) }}</td>
<td class="px-3 py-3 text-text-secondary">
<span v-if="item.song">{{ item.song.title }}<span class="text-text-tertiary">{{ item.song.artist ? ` · ${item.song.artist}` : '' }}</span></span>
<span v-else class="text-text-tertiary">—</span>
</td>
</tr>
<tr v-if="!redeemLogs.length">
<td colspan="6" class="px-3 py-8 text-center text-sm text-text-tertiary">{{ locale.noRedeemLogs }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { Copy, Download, Plus, RefreshCw, Search } from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import Pagination from '~/components/UI/Common/Pagination.vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
const { showToast } = useToast()
const { admin } = useLocale()
const locale = computed(() => admin.value?.cardCodesManager || {})
const { msg: getLocaleMessage, nested: getNestedMessage } = useLocaleText(locale)
const codes = ref([])
const redeemLogs = ref([])
const loading = ref(false)
const logsLoading = ref(false)
const saving = ref(false)
const exporting = ref(false)
const selectedIds = ref([])
const bulkStatus = ref('AVAILABLE')
const pagination = ref({ page: 1, limit: 10, total: 0, totalPages: 1 })
const cardStats = ref({ total: 0, available: 0, locked: 0, redeemed: 0 })
const filters = ref({ q: '', status: '' })
const logFilters = ref({ q: '', source: '', redeemer: '', startDate: '', endDate: '' })
const createMode = ref('manual')
const manualCodes = ref('')
const createNote = ref('')
const generateForm = ref({ count: 20, prefix: 'VH-', length: 10, charset: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' })
const lastGeneratedCodes = ref([])
const statusFilterOptions = computed(() => [
{ label: locale.value?.allStatus || 'All Status', value: '' },
{ label: locale.value?.available || 'Available', value: 'AVAILABLE' },
{ label: locale.value?.lockedStatus || 'Locked', value: 'LOCKED' },
{ label: locale.value?.redeemedStatus || 'Redeemed', value: 'REDEEMED' },
{ label: locale.value?.invalidStatus || 'Invalid', value: 'INVALID' }
])
const bulkStatusOptions = computed(() => [
{ label: locale.value?.setAvailable || 'Set Available', value: 'AVAILABLE' },
{ label: locale.value?.setLocked || 'Set Locked', value: 'LOCKED' },
{ label: locale.value?.setRedeemed || 'Set Redeemed', value: 'REDEEMED' },
{ label: locale.value?.setInvalid || 'Set Invalid', value: 'INVALID' }
])
const statusMeta = (status) => {
const statusMap = {
AVAILABLE: { label: locale.value?.available || 'Available', class: 'bg-success-10 text-success border border-success-20' },
LOCKED: { label: locale.value?.locked || 'Locked', class: 'bg-warning-10 text-warning border border-warning-20' },
REDEEMED: { label: locale.value?.redeemed || 'Redeemed', class: 'bg-primary-10 text-primary border border-primary-20' },
INVALID: { label: locale.value?.invalid || 'Invalid', class: 'bg-error-10 text-error border border-error-20' }
}
return statusMap[status] || { label: status || locale.value?.unknown || 'Unknown', class: 'bg-bg-quaternary-10 text-text-secondary border border-border-tertiary-20' }
}
const logSourceMeta = (source) => {
const sourceMap = {
ADMIN_MANUAL: { label: locale.value?.sources?.adminManual || 'Manual Redeem', class: 'bg-primary-10 text-primary border border-primary-20' },
ADMIN: { label: locale.value?.sources?.admin || 'Admin Action', class: 'bg-primary-10 text-primary border border-primary-20' },
SCHEDULE_AUTO: { label: locale.value?.sources?.scheduleAuto || 'Schedule Auto', class: 'bg-success-10 text-success border border-success-20' },
SCHEDULE_REMOVE: { label: locale.value?.sources?.scheduleRemove || 'Schedule Remove', class: 'bg-warning-10 text-warning border border-warning-20' },
SCHEDULE: { label: locale.value?.sources?.schedule || 'Schedule', class: 'bg-success-10 text-success border border-success-20' },
WITHDRAW: { label: locale.value?.sources?.withdraw || 'Withdraw', class: 'bg-warning-10 text-warning border border-warning-20' },
UNKNOWN: { label: locale.value?.unknown || 'Unknown', class: 'bg-bg-quaternary-10 text-text-secondary border border-border-tertiary-20' }
}
return sourceMap[source] || sourceMap.UNKNOWN
}
const stats = computed(() => {
const total = cardStats.value?.total ?? 0
const available = cardStats.value?.available ?? 0
const locked = cardStats.value?.locked ?? 0
const redeemed = cardStats.value?.redeemed ?? 0
return [
{ label: locale.value?.stats?.total || 'Total', value: total, hint: locale.value?.stats?.all || 'All', badgeClass: 'bg-bg-tertiary text-text-primary' },
{ label: locale.value?.stats?.available || 'Available', value: available, hint: locale.value?.stats?.unused || 'Unused', badgeClass: 'bg-success-10 text-success' },
{ label: locale.value?.stats?.locked || 'Locked', value: locked, hint: locale.value?.stats?.pendingRedeem || 'Pending redeem', badgeClass: 'bg-warning-10 text-warning' },
{ label: locale.value?.stats?.redeemed || 'Redeemed', value: redeemed, hint: locale.value?.stats?.completed || 'Completed', badgeClass: 'bg-primary-10 text-primary' }
]
})
const allVisibleSelected = computed(() => codes.value.length > 0 && codes.value.every((item) => selectedIds.value.includes(item.id)))
const someVisibleSelected = computed(() => codes.value.some((item) => selectedIds.value.includes(item.id)))
const exportButtonText = computed(() => (selectedIds.value.length ? getLocaleMessage('exportSelected', selectedIds.value.length) : getLocaleMessage('exportFiltered')))
const queryString = computed(() => {
const query = new URLSearchParams()
if (filters.value.q.trim()) query.set('q', filters.value.q.trim())
if (filters.value.status) query.set('status', filters.value.status)
query.set('page', String(pagination.value.page))
query.set('limit', String(pagination.value.limit))
return query.toString()
})
const normalizeRows = (rows) => rows.map((row) => {
const existing = codes.value.find((item) => item.id === row.id)
const hasUnsavedNote = existing && existing.noteDraft !== (existing.note || '')
return {
...row,
noteDraft: hasUnsavedNote ? existing.noteDraft : (row.note || '')
}
})
const fetchCodes = async (page = pagination.value.page) => {
const nextPage = Number(page)
pagination.value.page = Number.isFinite(nextPage) && nextPage > 0 ? nextPage : 1
loading.value = true
try {
const res = await $fetch(`/api/admin/card-codes?${queryString.value}`)
if (res?.success) {
codes.value = normalizeRows(res.data || [])
pagination.value = {
...pagination.value,
...(res.pagination || {}),
totalPages: Math.max(1, Number(res.pagination?.totalPages || 1))
}
cardStats.value = {
total: Number(res.stats?.total || 0),
available: Number(res.stats?.available || 0),
locked: Number(res.stats?.locked || 0),
redeemed: Number(res.stats?.redeemed || 0)
}
}
} catch (error) {
console.error('获取点歌券失败', error)
showToast(getNestedMessage('messages', 'fetchFailed'), 'error')
} finally {
loading.value = false
}
}
const fetchRedeemLogs = async () => {
logsLoading.value = true
try {
const query = new URLSearchParams()
query.set('limit', '80')
if (logFilters.value.q.trim()) query.set('q', logFilters.value.q.trim())
if (logFilters.value.source) query.set('source', logFilters.value.source)
if (logFilters.value.redeemer.trim()) query.set('redeemer', logFilters.value.redeemer.trim())
if (logFilters.value.startDate) query.set('startDate', logFilters.value.startDate)
if (logFilters.value.endDate) query.set('endDate', logFilters.value.endDate)
const res = await $fetch(`/api/admin/card-codes/redeem-logs?${query.toString()}`)
if (res?.success) {
redeemLogs.value = Array.isArray(res.data) ? res.data : []
}
} catch (error) {
console.error('获取点歌券兑换日志失败', error)
showToast(getNestedMessage('messages', 'fetchLogsFailed'), 'error')
} finally {
logsLoading.value = false
}
}
const resetLogFilters = async () => {
logFilters.value = { q: '', source: '', redeemer: '', startDate: '', endDate: '' }
await fetchRedeemLogs()
}
const refreshAll = async () => {
await Promise.all([fetchCodes(), fetchRedeemLogs()])
}
const resetFilters = async () => {
filters.value = { q: '', status: '' }
await fetchCodes(1)
}
const clearSelection = () => {
selectedIds.value = []
}
const toggleSelect = (id) => {
if (selectedIds.value.includes(id)) {
selectedIds.value = selectedIds.value.filter((itemId) => itemId !== id)
return
}
selectedIds.value = [...selectedIds.value, id]
}
const toggleSelectAll = () => {
if (allVisibleSelected.value) {
selectedIds.value = selectedIds.value.filter((id) => !codes.value.some((item) => item.id === id))
return
}
const next = new Set(selectedIds.value)
codes.value.forEach((item) => next.add(item.id))
selectedIds.value = [...next]
}
const formatDate = (value) => {
if (!value) return '—'
try {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(value))
} catch {
return String(value)
}
}
const copyCode = async (code) => {
try {
await navigator.clipboard.writeText(code)
showToast(getNestedMessage('messages', 'copied'), 'success')
} catch (error) {
console.error('复制失败', error)
showToast(getNestedMessage('messages', 'copyFailed'), 'error')
}
}
const copyLastGeneratedCodes = async () => {
if (!lastGeneratedCodes.value.length) {
showToast(getNestedMessage('messages', 'noGeneratedToCopy'), 'warning')
return
}
try {
await navigator.clipboard.writeText(lastGeneratedCodes.value.join('\n'))
showToast(getNestedMessage('messages', 'copiedGenerated', lastGeneratedCodes.value.length), 'success')
} catch (error) {
console.error('批量复制点歌券失败', error)
showToast(getNestedMessage('messages', 'copyFailed'), 'error')
}
}
const buildExportQuery = () => {
const query = new URLSearchParams()
if (selectedIds.value.length) {
query.set('ids', selectedIds.value.join(','))
return query
}
if (filters.value.q.trim()) query.set('q', filters.value.q.trim())
if (filters.value.status) query.set('status', filters.value.status)
return query
}
const exportCodes = async () => {
exporting.value = true
try {
const query = buildExportQuery()
const response = await fetch(`/api/admin/card-codes/export?${query.toString()}`, {
credentials: 'same-origin'
})
if (!response.ok) {
let message = getNestedMessage('messages', 'exportFailed')
try {
const errorBody = await response.json()
message = errorBody?.message || errorBody?.statusMessage || message
} catch {
// CSV 接口错误响应不一定是 JSON。
}
throw new Error(message)
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `card-codes-${new Date().toISOString().replaceAll(':', '-').slice(0, 19)}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
showToast(selectedIds.value.length ? getNestedMessage('messages', 'exportSelectedSuccess') : getNestedMessage('messages', 'exportFilteredSuccess'), 'success')
} catch (error) {
console.error('导出点歌券失败', error)
showToast(error?.message || getNestedMessage('messages', 'exportFailed'), 'error')
} finally {
exporting.value = false
}
}
const updateStatus = async (ids, status) => {
saving.value = true
try {
if (Array.isArray(ids) && ids.length === 1) {
// 单条更新走新的 PUT 接口,便于更精细的审计
const id = ids[0]
await $fetch(`/api/admin/card-codes/${id}`, {
method: 'PUT',
body: { status }
})
} else {
await $fetch('/api/admin/card-codes/update', {
method: 'POST',
body: { ids, status }
})
}
showToast(getNestedMessage('messages', 'statusUpdated'), 'success')
await Promise.all([fetchCodes(), fetchRedeemLogs()])
} catch (error) {
console.error('更新点歌券状态失败', error)
showToast(getNestedMessage('messages', 'updateStatusFailed'), 'error')
} finally {
saving.value = false
}
}
const saveNote = async (item) => {
saving.value = true
try {
await $fetch('/api/admin/card-codes/update', {
method: 'POST',
body: { id: item.id, note: item.noteDraft }
})
showToast(getNestedMessage('messages', 'noteSaved'), 'success')
item.note = item.noteDraft || null
} catch (error) {
console.error('保存备注失败', error)
showToast(getNestedMessage('messages', 'saveNoteFailed'), 'error')
} finally {
saving.value = false
}
}
const applyBulkStatus = async () => {
if (!selectedIds.value.length) return
await updateStatus(selectedIds.value, bulkStatus.value)
clearSelection()
}
const createCodes = async () => {
saving.value = true
try {
let body = { note: createNote.value.trim() }
if (createMode.value === 'manual') {
const list = manualCodes.value
.split(/[,,\n\s]+/)
.map((value) => value.trim())
.filter(Boolean)
if (!list.length) {
showToast(getNestedMessage('messages', 'inputRequired'), 'warning')
return
}
body = { ...body, codes: [...new Set(list)] }
} else {
body = {
...body,
count: Math.max(1, Number(createFormCount.value)),
prefix: generateForm.value.prefix,
length: Math.max(4, Number(generateForm.value.length)),
charset: generateForm.value.charset
}
}
const res = await $fetch('/api/admin/card-codes/create', {
method: 'POST',
body
})
const inserted = Array.isArray(res?.data) ? res.data.length : 0
const skipped = Number(res?.skipped || 0)
lastGeneratedCodes.value = createMode.value === 'generate'
? (Array.isArray(res?.data) ? res.data.map((item) => item.code).filter(Boolean) : [])
: []
showToast(skipped ? getNestedMessage('messages', 'createDone', inserted, skipped) : getNestedMessage('messages', 'createSuccess', inserted), 'success')
manualCodes.value = ''
createNote.value = ''
await fetchCodes()
} catch (error) {
console.error('创建点歌券失败', error)
showToast(error?.data?.message || error?.message || getNestedMessage('messages', 'createFailed'), 'error')
} finally {
saving.value = false
}
}
const createFormCount = computed(() => generateForm.value.count)
const fillDemoCodes = () => {
createMode.value = 'manual'
manualCodes.value = ['DEMO-2026-A001', 'DEMO-2026-A002', 'DEMO-2026-A003'].join('\n')
createNote.value = getLocaleMessage('demoNote')
}
onMounted(refreshAll)
</script>
<style scoped>
textarea {
scrollbar-width: thin;
scrollbar-color: var(--panel-bg-hover) transparent;
}
textarea::-webkit-scrollbar {
width: 4px;
}
textarea::-webkit-scrollbar-thumb {
background: var(--panel-bg-hover);
border-radius: 9999px;
}
</style>
File diff suppressed because it is too large Load Diff
+920
View File
@@ -0,0 +1,920 @@
<template>
<div class="max-w-[1200px] mx-auto space-y-8 pb-20 px-2">
<!-- 页面标题 -->
<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 font-medium">
{{ locale.desc }}
</p>
</div>
<!-- 操作卡片网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="card in cards"
:key="card.id"
:class="[
'group relative bg-bg-secondary-40 border border-border-secondary rounded-2xl p-8 transition-all hover:border-border-tertiary hover:shadow-2xl hover:shadow-[0_25px_50px_var(--shadow-color-deep)]',
card.isDanger ? 'hover:border-error-20' : ''
]"
>
<div class="flex flex-col h-full space-y-6">
<div class="flex items-center justify-between">
<div
:class="[
'p-3.5 rounded-2xl bg-bg-primary border border-border-secondary transition-all flex items-center justify-center',
card.isDanger
? 'text-error border-error-10'
: card.colorClass === 'primary'
? 'text-primary border-primary-20'
: card.colorClass === 'warning'
? 'text-warning border-warning-20'
: 'text-success border-success-20'
]"
>
<component :is="card.icon" class="w-6 h-6" />
</div>
<span
v-if="card.isDanger"
class="px-2 py-0.5 bg-error-10 text-error text-[9px] font-black uppercase tracking-widest border border-error-20 rounded"
>{{ locale.highRisk }}</span
>
</div>
<div class="flex-1 space-y-2">
<h3 class="text-lg font-bold text-text-primary group-hover:text-primary transition-colors">
{{ card.title }}
</h3>
<p class="text-xs text-text-tertiary leading-relaxed font-medium">
{{ card.desc }}
</p>
</div>
<button
:disabled="isLoading(card.id)"
class="w-full py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all active:scale-95 bg-bg-primary border border-border-secondary text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary hover:border-border-tertiary"
:class="
card.isDanger
? 'bg-bg-primary border border-error-30 text-error hover:bg-error hover:text-text-primary hover:border-error shadow-lg shadow-[var(--error-glow-5)]'
: ''
"
@click="openModal(card.id)"
>
<span v-if="isLoading(card.id)">{{ locale.executing }}</span>
<span v-else>{{ card.btnText }}</span>
</button>
</div>
<!-- 背景装饰 -->
<div
:class="[
'absolute -right-4 -bottom-4 w-32 h-32 blur-3xl opacity-0 group-hover:opacity-100 transition-opacity rounded-full pointer-events-none',
card.isDanger ? 'bg-error-5' : card.colorClass === 'primary' ? 'bg-primary-5' : card.colorClass === 'warning' ? 'bg-warning-5' : 'bg-success-5'
]"
/>
</div>
</div>
<!-- 维护建议 -->
<div class="bg-primary-hover-5 border border-primary-10 rounded-xl p-5 flex items-start gap-4">
<AlertCircle class="text-primary shrink-0 mt-0.5 w-[18px] h-[18px]" />
<div class="space-y-1">
<p class="text-[11px] font-bold text-text-secondary">{{ locale.maintenanceTitle }}</p>
<p class="text-[10px] text-text-tertiary leading-relaxed">
{{ locale.maintenanceDesc }}
</p>
</div>
</div>
<!-- 创建备份模态框 -->
<div
v-if="activeModal === 'backup'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.backupTitle }}</h3>
<button
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div class="space-y-2">
<p class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1">
{{ locale.selectContent }}
</p>
<div class="space-y-2">
<label
v-for="(item, i) in backupOptions"
:key="i"
class="flex items-start gap-4 p-4 bg-bg-primary-50 border border-border-secondary rounded-xl cursor-pointer hover:border-border-tertiary transition-all group"
>
<div class="shrink-0 mt-0.5">
<input
v-model="createForm[item.key]"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary"
>
</div>
<div>
<p
class="text-xs font-bold text-text-primary group-hover:text-primary transition-colors"
>
{{ item.label }}
</p>
<p class="text-[10px] text-text-disabled font-medium mt-0.5">{{ item.desc }}</p>
</div>
</label>
</div>
</div>
<div class="p-3 bg-primary-5 border border-primary-10 rounded-xl">
<p class="text-[10px] text-text-tertiary text-center italic">
{{ locale.backupHint }}
</p>
</div>
</div>
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
{{ locale.cancel }}
</button>
<button
:disabled="createLoading"
class="px-8 py-2 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="createBackup"
>
{{ createLoading ? locale.exporting : locale.startExport }}
</button>
</div>
</div>
</div>
<!-- 恢复备份模态框 -->
<div
v-if="activeModal === 'restore'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.restoreTitle }}</h3>
<button
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div
class="border-2 border-dashed border-border-secondary rounded-2xl p-10 flex flex-col items-center justify-center text-center group hover:border-success-50 hover:bg-success-5 transition-all cursor-pointer"
@click="$refs.fileInput.click()"
@dragover.prevent
@drop.prevent="handleFileDrop"
>
<Upload
class="w-8 h-8 text-text-secondary mb-4 group-hover:text-success transition-colors"
/>
<h5 class="text-sm font-bold text-text-secondary">
{{ selectedFile ? selectedFile.name : locale.selectFile }}
</h5>
<p class="text-[10px] text-text-disabled font-bold uppercase mt-1 tracking-widest">
{{ locale.fileHint }}
</p>
<input
ref="fileInput"
accept=".json"
class="hidden"
type="file"
@change="handleFileSelect"
>
</div>
<div class="space-y-3">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.restoreMode }}</label
>
<div class="grid grid-cols-2 gap-3">
<button
:class="[
'p-4 border rounded-xl text-left transition-all',
restoreForm.mode === 'merge'
? 'bg-bg-primary border-success-30'
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
]"
@click="restoreForm.mode = 'merge'"
>
<h6
:class="[
'text-xs font-bold',
restoreForm.mode === 'merge' ? 'text-success' : 'text-text-tertiary'
]"
>
{{ locale.restoreModes?.merge?.title ?? '合并恢复' }}
</h6>
<p class="text-[9px] text-text-disabled uppercase mt-0.5">{{ locale.restoreModes?.merge?.desc ?? '保留现有数据并合并备份内容' }}</p>
</button>
<button
:class="[
'p-4 border rounded-xl text-left transition-all',
restoreForm.mode === 'replace'
? 'bg-bg-primary border-success-30'
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
]"
@click="restoreForm.mode = 'replace'"
>
<h6
:class="[
'text-xs font-bold',
restoreForm.mode === 'replace' ? 'text-success' : 'text-text-tertiary'
]"
>
{{ locale.restoreModes?.replace?.title ?? '覆盖恢复' }}
</h6>
<p class="text-[9px] text-text-disabled uppercase mt-0.5">{{ locale.restoreModes?.replace?.desc ?? '清除现有数据后恢复备份内容' }}</p>
</button>
</div>
</div>
<div
v-if="restoreForm.mode === 'replace' && hasSuperAdminInBackup"
class="p-4 bg-bg-primary border border-border-secondary rounded-xl"
>
<label class="flex items-start gap-3 cursor-pointer">
<input
v-model="restoreForm.overwriteSuperAdmin"
type="checkbox"
class="mt-0.5 accent-emerald-500"
>
<div>
<p class="text-xs font-bold text-text-primary">{{ locale.overwriteSuperAdmin }}</p>
<p class="text-[10px] text-text-tertiary mt-1">
{{ locale.overwriteSuperAdminDesc }}
</p>
</div>
</label>
</div>
<div
class="p-4 bg-warning-5 border border-warning-10 rounded-xl flex items-start gap-3"
>
<AlertCircle class="text-warning shrink-0 mt-0.5 w-4 h-4" />
<p class="text-[10px] text-text-tertiary leading-normal font-medium">
{{ locale.replaceWarning }}
</p>
</div>
</div>
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
{{ locale.cancel }}
</button>
<button
:disabled="uploadLoading || !selectedFile"
class="px-8 py-2 bg-success hover:bg-success text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="restoreBackup"
>
{{ uploadLoading ? restoreProgress || locale.restoring : locale.confirmRestore }}
</button>
</div>
</div>
</div>
<!-- 重置序列模态框 -->
<div
v-if="activeModal === 'reset-seq'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.resetSequenceTitle }}</h3>
<button
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.selectTargetTable }}</label
>
<CustomSelect
v-model="sequenceForm.table"
:options="locale.tableOptions || defaultTableOptions"
label-key="label"
value-key="value"
class="w-full"
/>
</div>
<div class="p-6 bg-bg-primary-50 border border-border-secondary rounded-2xl space-y-4">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-warning-10 text-warning flex items-center justify-center"
>
<AlertCircle class="w-4 h-4" />
</div>
<h6 class="text-xs font-bold text-text-secondary uppercase tracking-widest">
{{ locale.sequenceHelpTitle }}
</h6>
</div>
<p class="text-[11px] text-text-tertiary leading-relaxed font-medium">
{{ locale.sequenceHelpDesc }}
</p>
</div>
</div>
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
{{ locale.cancel }}
</button>
<button
:disabled="sequenceLoading || !sequenceForm.table"
class="px-8 py-2 bg-warning hover:bg-warning text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="resetSequence"
>
{{ sequenceLoading ? locale.resetting : locale.executeReset }}
</button>
</div>
</div>
</div>
<!-- 重置数据库模态框 -->
<div
v-if="activeModal === 'reset-db'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-error tracking-tight">{{ locale.dangerResetTitle }}</h3>
<button
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div
class="p-6 bg-error-10 border border-error-20 rounded-2xl flex flex-col items-center text-center"
>
<Trash2 class="text-error mb-4 w-12 h-12" />
<h4 class="text-lg font-black text-error tracking-tight">
{{ locale.dangerResetHeading }}
</h4>
<p class="text-xs text-text-tertiary mt-2 font-medium leading-relaxed">
{{ locale.dangerResetPrefix }}
<span class="text-text-secondary font-bold"
>{{ locale.dangerResetScope }}</span
>。
</p>
</div>
<div class="space-y-3">
<label
class="text-[11px] font-black text-error-80 uppercase tracking-widest px-1 flex items-center justify-center gap-2"
>
{{ locale.confirmCodeLabel }}
</label>
<div
class="bg-bg-primary border border-error-30 rounded-xl px-4 py-3 font-mono text-[10px] text-error text-center select-all"
>
{{ CONFIRM_CODE }}
</div>
<input
v-model="resetConfirmText"
type="text"
:placeholder="locale.confirmCodePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-error-40 text-center font-mono placeholder:text-text-secondary"
>
</div>
<div class="grid grid-cols-2 gap-3 pt-2">
<button
class="py-3 bg-bg-secondary hover:bg-bg-tertiary text-text-tertiary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="activeModal = 'none'"
>
{{ locale.cancel }}
</button>
<button
:disabled="resetConfirmText !== CONFIRM_CODE || resetLoading"
class="py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg disabled:bg-bg-tertiary disabled:text-text-disabled disabled:cursor-not-allowed disabled:border-border-tertiary"
:class="
resetConfirmText === CONFIRM_CODE
? 'bg-error hover:bg-error text-text-primary shadow-[var(--error-glow-20)] active:scale-95'
: ''
"
@click="resetDatabase"
>
{{ resetLoading ? locale.resetting : locale.confirmResetDatabase }}
</button>
</div>
</div>
</div>
</div>
<!-- 自动备份设置 -->
<BackupAutoSettings
:visible="activeModal === 'auto-backup'"
@close="activeModal = 'none'"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { Download, Upload, RotateCw, Trash2, AlertCircle, CloudUpload, X } from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import BackupAutoSettings from '~/components/Admin/BackupAutoSettings.vue'
import { useToast } from '~/composables/useToast'
import { useAuth } from '~/composables/useAuth'
import { useLocale } from '~/utils/locale'
const { showToast: showNotification } = useToast()
const auth = useAuth()
const { admin } = useLocale()
const locale = computed(() => admin.value?.databaseManager || {})
const defaultTableOptions = [
{ label: '重置所有表', value: 'all' },
{ label: '歌曲表', value: 'Song' },
{ label: '用户表', value: 'User' },
{ label: '用户身份表', value: 'UserIdentity' },
{ label: '用户状态日志表', value: 'UserStatusLog' },
{ label: '投票表', value: 'Vote' },
{ label: '排期表', value: 'Schedule' },
{ label: '通知表', value: 'Notification' },
{ label: '通知设置表', value: 'NotificationSettings' },
{ label: '播放时段表', value: 'PlayTime' },
{ label: '学期表', value: 'Semester' },
{ label: '系统设置表', value: 'SystemSettings' },
{ label: '歌曲黑名单表', value: 'SongBlacklist' },
{ label: '歌曲重播申请表', value: 'SongReplayRequest' },
{ label: '投稿时段表', value: 'RequestTime' },
{ label: '邮件模板表', value: 'EmailTemplate' }
]
const getMessage = (key) => locale.value?.messages?.[key] ?? `数据库操作:${key}`
const getLogMessage = (key) => locale.value?.logs?.[key] || key
const formatString = (value, args) => {
if (typeof value !== 'string') return value
return value.replace(/{(\d+)}/g, (match, index) =>
args[index] !== undefined ? String(args[index]) : match
)
}
const getErrorMessage = (key, ...args) => {
const message = locale.value?.errors?.[key]
if (typeof message === 'function') return message(...args)
return formatString(message, args) || args.find(Boolean) || key
}
const getProgressMessage = (key, ...args) => {
const message = locale.value?.progress?.[key]
if (typeof message === 'function') return message(...args)
return formatString(message, args) || `数据库操作进行中:${key}`
}
// 状态
const activeModal = ref('none')
const createLoading = ref(false)
const uploadLoading = ref(false)
const sequenceLoading = ref(false)
const resetLoading = ref(false)
const selectedFile = ref(null)
const resetConfirmText = ref('')
const restoreProgress = ref('')
const hasSuperAdminInBackup = ref(false)
const CONFIRM_CODE = 'CONFIRM-DATABASE-RESET-OPERATION'
// 卡片配置
const cards = computed(() => [
{
id: 'backup',
title: locale.value?.cards?.backup?.title ?? '备份数据库',
desc: locale.value?.cards?.backup?.desc ?? '导出系统数据备份文件',
icon: Download,
colorClass: 'primary',
btnText: locale.value?.cards?.backup?.button ?? '创建备份'
},
{
id: 'restore',
title: locale.value?.cards?.restore?.title ?? '恢复数据库',
desc: locale.value?.cards?.restore?.desc ?? '从备份文件恢复系统数据',
icon: Upload,
colorClass: 'success',
btnText: locale.value?.cards?.restore?.button ?? '恢复备份'
},
{
id: 'reset-seq',
title: locale.value?.cards?.resetSeq?.title ?? '修复数据序列',
desc: locale.value?.cards?.resetSeq?.desc ?? '修复数据表自增序列',
icon: RotateCw,
colorClass: 'warning',
btnText: locale.value?.cards?.resetSeq?.button ?? '修复序列'
},
{
id: 'reset-db',
title: locale.value?.cards?.resetDb?.title ?? '重置数据库',
desc: locale.value?.cards?.resetDb?.desc ?? '清空除管理员外的系统数据',
icon: Trash2,
colorClass: 'error',
btnText: locale.value?.cards?.resetDb?.button ?? '重置数据库',
isDanger: true
},
{
id: 'auto-backup',
title: locale.value?.cards?.autoBackup?.title ?? '自动备份',
desc: locale.value?.cards?.autoBackup?.desc ?? '配置多种备份方式,通过外部触发实现定时自动备份',
icon: CloudUpload,
colorClass: 'success',
btnText: locale.value?.cards?.autoBackup?.button ?? '配置自动备份'
}
])
// 备份选项
const backupOptions = computed(() => [
{
key: 'includeSongs',
label: locale.value?.backupOptions?.songs?.label ?? '歌曲与排期数据',
desc: locale.value?.backupOptions?.songs?.desc ?? '包含歌曲、投稿及排期数据'
},
{
key: 'includeSystemData',
label: locale.value?.backupOptions?.system?.label ?? '系统配置',
desc: locale.value?.backupOptions?.system?.desc ?? '包含站点设置和全局参数'
},
{
key: 'includeUsers',
label: locale.value?.backupOptions?.users?.label ?? '用户账户',
desc: locale.value?.backupOptions?.users?.desc ?? '包含用户账户和权限数据'
}
])
// 表单数据
const createForm = ref({
includeSongs: true,
includeUsers: true,
includeSystemData: true
})
const restoreForm = ref({
mode: 'merge',
overwriteSuperAdmin: false
})
const sequenceForm = ref({
table: 'all'
})
// 辅助函数
const isLoading = (id) => {
if (id === 'backup') return createLoading.value
if (id === 'restore') return uploadLoading.value
if (id === 'reset-seq') return sequenceLoading.value
if (id === 'reset-db') return resetLoading.value
return false
}
const openModal = (id) => {
activeModal.value = id
if (id === 'reset-db') {
resetConfirmText.value = ''
}
}
// 文件处理
const parseBackupSuperAdmin = async (file) => {
try {
const fileContent = await file.text()
const backupData = JSON.parse(fileContent)
const users = Array.isArray(backupData?.data?.users) ? backupData.data.users : []
hasSuperAdminInBackup.value = users.some((item) => item?.role === 'SUPER_ADMIN')
if (!hasSuperAdminInBackup.value) {
restoreForm.value.overwriteSuperAdmin = false
}
} catch (error) {
hasSuperAdminInBackup.value = false
restoreForm.value.overwriteSuperAdmin = false
showNotification(getMessage('parseBackupFailed'), 'error')
console.error(getLogMessage('parseBackupFailed'), error)
}
}
const handleFileSelect = async (event) => {
const file = event.target.files[0]
if (file && file.type === 'application/json') {
selectedFile.value = file
await parseBackupSuperAdmin(file)
} else {
showNotification(getMessage('invalidJsonFile'), 'error')
}
}
const handleFileDrop = async (event) => {
const file = event.dataTransfer.files[0]
if (file && file.type === 'application/json') {
selectedFile.value = file
await parseBackupSuperAdmin(file)
} else {
showNotification(getMessage('invalidJsonFile'), 'error')
}
}
// API 操作
const createBackup = async () => {
createLoading.value = true
try {
let tables = 'all'
if (createForm.value.includeUsers && createForm.value.includeSongs) {
tables = 'all'
} else if (createForm.value.includeUsers) {
tables = 'users'
} else if (createForm.value.includeSongs) {
tables = ['songs', 'scheduleSongPool']
} else if (createForm.value.includeSystemData) {
tables = ['systemSettings']
} else {
throw new Error(getErrorMessage('noBackupContent'))
}
const response = await $fetch('/api/admin/backup/export', {
method: 'POST',
body: {
tables,
includeSystemData: createForm.value.includeSystemData
}
})
if (response.success && response.backup) {
if (response.backup.downloadMode === 'direct' && response.backup.data) {
const blob = new Blob([JSON.stringify(response.backup.data, null, 2)], {
type: 'application/json'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = response.backup.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification(getMessage('backupDownloaded'), 'success')
activeModal.value = 'none'
} else if (response.backup.downloadMode === 'file' && response.backup.filename) {
const downloadUrl = `/api/admin/backup/download/${response.backup.filename}`
const downloadResponse = await fetch(downloadUrl, {
method: 'GET',
credentials: 'include'
})
if (!downloadResponse.ok) throw new Error(getErrorMessage('downloadFailed', downloadResponse.status))
const blob = await downloadResponse.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = response.backup.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification(getMessage('backupDownloaded'), 'success')
activeModal.value = 'none'
}
} else {
throw new Error(response.message || getErrorMessage('createBackupFailed'))
}
} catch (error) {
console.error(getLogMessage('createBackupFailed'), error)
showNotification(getErrorMessage('createBackupFailedWithMessage', error?.message), 'error')
} finally {
createLoading.value = false
}
}
const restoreBackup = async () => {
if (!selectedFile.value) return showNotification(getMessage('selectBackupFile'), 'error')
uploadLoading.value = true
restoreProgress.value = getProgressMessage('readingFile')
try {
const fileContent = await selectedFile.value.text()
let backupData
try {
backupData = JSON.parse(fileContent)
} catch {
throw new Error(getErrorMessage('invalidBackupFormat'))
}
if (!backupData.data) throw new Error(getErrorMessage('missingBackupData'))
const fileHasSuperAdmin = hasSuperAdminInBackup.value
if (!fileHasSuperAdmin) {
restoreForm.value.overwriteSuperAdmin = false
}
let preservedSuperAdminIds = []
let temporaryPreservedUserId = null
if (restoreForm.value.mode === 'replace') {
restoreProgress.value = getProgressMessage('clearingData')
const clearResult = await $fetch('/api/admin/backup/clear', {
method: 'POST',
body: {
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
hasSuperAdminInBackup: fileHasSuperAdmin
}
})
if (!clearResult.success) throw new Error(clearResult.message || getErrorMessage('clearDataFailed'))
preservedSuperAdminIds = clearResult.preservedSuperAdminIds || []
temporaryPreservedUserId = clearResult.temporaryPreservedUserId || null
}
const tableOrder = [
'users',
'userIdentities',
'systemSettings',
'semesters',
'playTimes',
'songs',
'scheduleSongPool',
'votes',
'schedules',
'notificationSettings',
'notifications',
'songBlacklist',
'userStatusLogs'
]
const mappings = {
users: {},
songs: {},
meta: {
preservedSuperAdminIds,
temporaryPreservedUserId
}
}
const CHUNK_SIZE = 50
let totalProcessed = 0
let totalRecords = 0
for (const tableName of tableOrder) {
if (backupData.data[tableName] && Array.isArray(backupData.data[tableName])) {
totalRecords += backupData.data[tableName].length
}
}
for (const tableName of tableOrder) {
const records = backupData.data[tableName]
if (!records || !Array.isArray(records) || records.length === 0) continue
for (let i = 0; i < records.length; i += CHUNK_SIZE) {
const chunk = records.slice(i, i + CHUNK_SIZE)
const progressPercent = Math.round((totalProcessed / totalRecords) * 100)
restoreProgress.value = getProgressMessage(
'restoringTable',
tableName,
i + 1,
Math.min(i + CHUNK_SIZE, records.length),
records.length,
progressPercent
)
const response = await $fetch('/api/admin/backup/restore-chunk', {
method: 'POST',
body: {
tableName,
records: chunk,
mappings,
mode: restoreForm.value.mode,
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
hasSuperAdminInBackup: fileHasSuperAdmin
}
})
if (!response.success) throw new Error(response.message || getErrorMessage('restoreTableFailed', tableName))
if (response.newMappings) {
if (response.newMappings.users) Object.assign(mappings.users, response.newMappings.users)
if (response.newMappings.songs) Object.assign(mappings.songs, response.newMappings.songs)
}
totalProcessed += chunk.length
}
}
restoreProgress.value = getProgressMessage('fixingSequence')
const sequenceResult = await $fetch('/api/admin/fix-sequence', {
method: 'POST',
body: { table: 'all' }
})
if (!sequenceResult.success) {
throw new Error(sequenceResult.message || sequenceResult.error || getErrorMessage('fixSequenceFailed'))
}
restoreProgress.value = getProgressMessage('reloadingSmtp')
const smtpReloadResult = await $fetch('/api/admin/smtp/reload', {
method: 'POST'
})
if (!smtpReloadResult.success) {
throw new Error(smtpReloadResult.message || getErrorMessage('smtpReloadFailed'))
}
const shouldFinalizeTempUser =
restoreForm.value.mode === 'replace' &&
restoreForm.value.overwriteSuperAdmin &&
fileHasSuperAdmin &&
temporaryPreservedUserId
if (shouldFinalizeTempUser) {
const restoredUserIds = Object.values(mappings.users).map((id) => Number(id))
if (!restoredUserIds.includes(Number(temporaryPreservedUserId))) {
restoreProgress.value = getProgressMessage('finalizingAdmin')
await $fetch('/api/admin/backup/clear', {
method: 'POST',
body: {
finalizeTempUser: true
}
})
}
showNotification(getMessage('restoreSuccessRelogin'), 'success')
activeModal.value = 'none'
setTimeout(() => {
if (auth.logout) {
auth.logout()
}
localStorage.removeItem('auth-token')
localStorage.removeItem('user-info')
window.location.href = '/'
}, 1200)
return
}
showNotification(getMessage('restoreSuccess'), 'success')
activeModal.value = 'none'
} catch (error) {
console.error(getLogMessage('restoreBackupFailed'), error)
showNotification(getErrorMessage('restoreBackupFailedWithMessage', error?.message), 'error')
} finally {
uploadLoading.value = false
restoreProgress.value = ''
}
}
const resetSequence = async () => {
if (!sequenceForm.value.table) return
sequenceLoading.value = true
try {
const response = await $fetch('/api/admin/fix-sequence', {
method: 'POST',
body: { table: sequenceForm.value.table }
})
if (response.success) {
showNotification(response.message || getMessage('sequenceResetSuccess'), 'success')
activeModal.value = 'none'
} else {
throw new Error(response.error || response.message || getErrorMessage('resetFailed'))
}
} catch (error) {
console.error(getLogMessage('resetSequenceFailed'), error)
showNotification(getErrorMessage('resetSequenceFailedWithMessage', error?.message), 'error')
} finally {
sequenceLoading.value = false
}
}
const resetDatabase = async () => {
if (resetConfirmText.value !== CONFIRM_CODE) return
resetLoading.value = true
try {
const response = await $fetch('/api/admin/database/reset', { method: 'POST' })
if (response.success) {
showNotification(getMessage('databaseResetSuccess'), 'success')
activeModal.value = 'none'
setTimeout(() => window.location.reload(), 1500)
} else {
throw new Error(response.message || getErrorMessage('resetFailed'))
}
} catch (error) {
console.error(getLogMessage('resetDatabaseFailed'), error)
showNotification(getErrorMessage('resetDatabaseFailedWithMessage', error?.message), 'error')
} finally {
resetLoading.value = false
}
}
</script>
@@ -0,0 +1,365 @@
<template>
<div class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] overflow-hidden">
<!-- 头部 -->
<div class="flex items-center justify-between p-6 border-b border-border-secondary bg-bg-secondary-20">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-primary-hover-10 flex items-center justify-center border border-primary-20"
>
<Mail :size="20" class="text-primary" />
</div>
<div>
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">{{ locale.title }}</h3>
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.desc }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<button
v-if="selected"
class="flex items-center gap-2 px-4 py-2 bg-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-tertiary text-[11px] font-bold rounded-xl transition-all"
:disabled="saving"
@click="doPreview"
>
<Eye :size="14" /> {{ previewHtml ? locale.refreshPreview : locale.livePreview }}
</button>
<button
v-if="selected"
class="flex items-center gap-2 px-6 py-2 bg-primary-hover hover:bg-primary text-text-primary text-[11px] font-bold rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
:disabled="saving"
@click="save"
>
<Save :size="14" /> {{ saving ? locale.saving : locale.updateTemplate }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 min-h-[650px]">
<!-- 左侧:模板列表 -->
<div class="lg:col-span-3 border-r border-border-secondary bg-bg-secondary-10 flex flex-col">
<div class="p-4 border-b border-border-secondary-50">
<div class="relative">
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-text-disabled" />
<input
type="text"
:placeholder="locale.searchPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg pl-9 pr-3 py-2 text-[10px] text-text-tertiary focus:outline-none focus:border-primary-30"
>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
<button
v-for="t in templates"
:key="t.key"
class="w-full group flex flex-col gap-1 p-3 rounded-2xl transition-all text-left border"
:class="[
selectedKey === t.key
? 'bg-primary-hover-10 border-primary-20'
: 'bg-transparent border-transparent hover:bg-bg-tertiary-40'
]"
@click="select(t)"
>
<div class="flex items-center justify-between">
<span
class="text-[11px] font-black tracking-tight transition-colors"
:class="
selectedKey === t.key
? 'text-primary'
: 'text-text-secondary group-hover:text-text-primary'
"
>
{{ t.name }}
</span>
<div class="flex gap-1">
<span
v-if="t.isBuiltin && !t.isOverridden"
class="px-1.5 py-0.5 rounded-md bg-bg-tertiary text-[8px] font-black text-text-tertiary uppercase tracking-tighter"
>
{{ locale.builtin }}
</span>
<span
v-if="t.isOverridden"
class="px-1.5 py-0.5 rounded-md bg-success-10 text-[8px] font-black text-success uppercase tracking-tighter"
>
{{ locale.custom }}
</span>
</div>
</div>
<span class="text-[9px] font-bold text-text-disabled font-mono">{{ t.key }}</span>
</button>
</div>
</div>
<!-- 右侧:编辑器区域 -->
<div class="lg:col-span-9 flex flex-col bg-bg-primary-20 overflow-hidden">
<div v-if="selected" class="flex-1 overflow-y-auto custom-scrollbar p-8">
<div class="max-w-4xl mx-auto space-y-8">
<!-- 基础信息与主题 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<div class="flex items-center justify-between px-1">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
>{{ locale.templateName }}</label
>
</div>
<input
v-model="form.name"
type="text"
class="h-11 w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-xs text-text-primary focus:outline-none focus:border-primary-30"
>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between px-1">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
>{{ locale.emailSubject }}</label
>
<span class="text-[9px] text-text-tertiary font-bold uppercase">{{ locale.supportsVariables }}</span>
</div>
<input
v-model="form.subject"
type="text"
:placeholder="locale.subjectPlaceholder"
class="h-11 w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-xs text-text-primary focus:outline-none focus:border-primary-30"
>
</div>
</div>
<!-- 变量提示 -->
<div class="bg-primary-hover-5 border border-primary-10 rounded-2xl p-4 flex gap-4">
<div
class="w-8 h-8 rounded-xl bg-primary-10 flex items-center justify-center shrink-0"
>
<Info :size="16" class="text-primary" />
</div>
<div class="space-y-1">
<h4 class="text-[10px] font-black text-primary uppercase tracking-widest">
{{ locale.availableVariables }}
</h4>
<p class="text-[11px] text-text-tertiary leading-relaxed font-mono">
<template v-if="selected.key === 'verification.code'">
<span v-pre>{{ name }}, {{ email }}, {{ code }}, {{ expiresInMinutes }}</span>
</template>
<template v-else>
<span v-pre>{{ title }}, {{ message }}, {{ actionUrl }}, {{ fromName }}</span>
</template>
</p>
</div>
</div>
<!-- 内容编辑器 -->
<div class="space-y-3">
<div class="flex items-center justify-between px-1">
<div class="flex items-center gap-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
>{{ locale.htmlContent }}</label
>
<span
class="px-2 py-0.5 rounded-md bg-bg-secondary text-[8px] font-black text-text-tertiary uppercase"
>Handlebars</span
>
</div>
<button
v-if="selected.isOverridden"
class="flex items-center gap-1.5 text-[10px] font-bold text-error hover:text-error transition-colors"
@click="restore"
>
<RotateCcw :size="12" /> {{ locale.restoreDefault }}
</button>
</div>
<div class="relative group">
<div
class="absolute right-4 top-4 p-2 rounded-lg bg-bg-secondary-50 border border-border-secondary opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<Code :size="14" class="text-text-tertiary" />
</div>
<textarea
v-model="form.html"
rows="16"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl p-6 text-[11px] text-text-secondary font-mono leading-relaxed focus:outline-none focus:border-primary-30 custom-scrollbar"
placeholder="<html>..."
/>
</div>
</div>
<!-- 预览框 -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
>
<div v-if="previewHtml" class="space-y-4 pt-4 border-t border-border-secondary">
<div class="flex items-center justify-between px-1">
<label
class="text-[10px] font-black text-text-disabled uppercase tracking-widest flex items-center gap-2"
>
<Eye :size="14" /> {{ locale.livePreviewLabel }}
<span class="text-text-tertiary font-normal normal-case">{{ previewSubject }}</span>
</label>
<button
class="text-[10px] font-bold text-text-tertiary hover:text-text-secondary"
@click="previewHtml = ''"
>
{{ locale.hidePreview }}
</button>
</div>
<div class="rounded-2xl overflow-hidden border border-border-secondary bg-bg-secondary shadow-2xl">
<iframe :srcdoc="previewHtml" class="w-full h-[500px] border-none" />
</div>
</div>
</Transition>
</div>
</div>
<!-- 空状态 -->
<div
v-else
class="flex-1 flex flex-col items-center justify-center text-text-secondary space-y-4"
>
<div
class="w-16 h-16 rounded-3xl bg-bg-secondary-50 flex items-center justify-center border border-border-secondary-50"
>
<Mail :size="32" class="text-text-primary" />
</div>
<p class="text-xs font-bold tracking-widest uppercase">{{ locale.emptySelectTemplate }}</p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
import { Mail, Info, Save, RotateCcw, Eye, Code, Search, ChevronRight } from '@lucide/vue'
type TemplateItem = {
key: string
name: string
subject: string
html: string
isBuiltin: boolean
isOverridden?: boolean
}
const { success, error } = useToast()
const { admin } = useLocale()
const locale = computed(() => {
const base = admin.value?.emailTemplateManager || {}
return {
...base,
messages: { ...(base.messages || {}) },
errors: { ...(base.errors || {}) }
}
})
const getLocaleText = (section: string, key: string, fallback = '') => {
return locale.value?.[section]?.[key] || fallback
}
const templates = ref<TemplateItem[]>([])
const selectedKey = ref<string>('')
const form = ref<{ key: string; name: string; subject: string; html: string }>({
key: '',
name: '',
subject: '',
html: ''
})
const saving = ref(false)
const previewHtml = ref<string>('')
const previewSubject = ref<string>('')
const selected = computed(() => templates.value.find((t) => t.key === selectedKey.value))
async function loadList() {
const res: any = await $fetch('/api/admin/email-templates')
const list = Array.isArray(res?.templates) ? res.templates : []
templates.value = list.map((t: any) => ({
key: String(t.key),
name: String(t.name),
subject: String(t.subject),
html: String(t.html),
isBuiltin: !!t.isBuiltin,
isOverridden: !!t.isOverridden
}))
if (!selectedKey.value && templates.value.length) select(templates.value[0])
}
function select(t: TemplateItem) {
selectedKey.value = t.key
form.value = { key: t.key, name: t.name, subject: t.subject, html: t.html }
previewHtml.value = ''
previewSubject.value = ''
}
async function save() {
try {
saving.value = true
await $fetch('/api/admin/email-templates', { method: 'POST', body: form.value })
success(getLocaleText('messages', 'saved', '模板已保存'))
await loadList()
} catch (e: any) {
error(e?.data?.message || getLocaleText('errors', 'saveFailed', '保存失败'))
} finally {
saving.value = false
}
}
async function restore() {
try {
saving.value = true
await $fetch(`/api/admin/email-templates?key=${encodeURIComponent(form.value.key)}`, {
method: 'DELETE'
})
success(getLocaleText('messages', 'restored', '已恢复默认模板'))
await loadList()
} catch (e: any) {
error(e?.data?.message || getLocaleText('errors', 'restoreFailed', '恢复失败'))
} finally {
saving.value = false
}
}
async function doPreview() {
try {
const defaultData =
form.value.key === 'verification.code'
? {
name: locale.value?.previewData?.name || 'Example User',
email: 'example@school.edu',
code: '123456',
expiresInMinutes: 5
}
: {
title: locale.value?.previewData?.title || 'System Notification',
message: locale.value?.previewData?.message || 'This is preview content.\nLine breaks and links are supported.',
actionUrl: 'https://example.com'
}
const res = await $fetch('/api/admin/email-templates/preview', {
method: 'POST',
body: { key: form.value.key, data: defaultData }
})
previewHtml.value = res.html
previewSubject.value = res.subject
} catch (e: any) {
error(e?.data?.message || getLocaleText('errors', 'previewFailed', '预览失败'))
}
}
onMounted(() => {
loadList()
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--panel-bg-alt);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--panel-bg-hover);
}
</style>
+451
View File
@@ -0,0 +1,451 @@
<template>
<div class="space-y-6">
<!-- 头部 -->
<div>
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-sm text-text-tertiary mt-1">{{ locale.desc }}</p>
</div>
<!-- 左右分栏:左为生成操作,右为年级班级展示 -->
<div class="grid grid-cols-1 lg:grid-cols-[360px,1fr] gap-6 items-start">
<div class="space-y-6">
<!-- 新增与批量生成区 -->
<div class="bg-bg-secondary-40 border border-border-secondary rounded-2xl p-5 space-y-4">
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<label class="block text-xs font-bold text-text-secondary mb-1.5">{{ locale.gradeLabel }}</label>
<input
v-model="gradeInput"
type="text"
:placeholder="locale.gradePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-3 py-2.5 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary transition-colors"
@keydown.enter="handleAdd"
>
</div>
<div class="flex-1">
<label class="block text-xs font-bold text-text-secondary mb-1.5">{{ locale.classLabel }}</label>
<input
v-model="classInput"
type="text"
:placeholder="locale.classPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-3 py-2.5 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary transition-colors"
@keydown.enter="handleAdd"
>
</div>
<div class="flex items-end">
<button
type="button"
:disabled="adding || !gradeInput.trim() || !classInput.trim()"
class="w-full sm:w-auto px-6 py-2.5 bg-primary text-white text-xs font-black rounded-xl shadow-lg transition-all hover:opacity-90 active:scale-95 uppercase tracking-widest disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
@click="handleAdd"
>
<Plus v-if="!adding" :size="14" />
<RefreshCw v-else :size="14" class="animate-spin" />
{{ locale.addButton }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 border-t border-border-secondary pt-4">
<div>
<p class="text-sm font-black text-text-primary mb-2">{{ locale.batchTitle }}</p>
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<input
v-model="batchGradeInput"
type="text"
:placeholder="locale.batchGradePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-3 py-2.5 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary transition-colors"
>
</div>
<div class="flex-1">
<input
v-model="batchClassInput"
type="text"
:placeholder="locale.batchClassPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-3 py-2.5 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary transition-colors"
>
</div>
<div class="flex items-end">
<button
type="button"
:disabled="batchAdding || !batchGradeInput.trim() || !batchClassInput.trim()"
class="w-full sm:w-auto px-6 py-2.5 bg-bg-primary border border-border-secondary rounded-xl text-text-tertiary hover:text-primary transition-colors text-xs font-black uppercase tracking-widest disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
@click="handleBatchAdd"
>
<Sparkles v-if="!batchAdding" :size="14" />
<RefreshCw v-else :size="14" class="animate-spin" />
{{ locale.batchButton }}
</button>
</div>
</div>
</div>
<div class="lg:border-l lg:border-border-secondary lg:pl-5">
<p class="text-sm font-black text-text-primary mb-2">{{ locale.initTitle }}</p>
<p class="text-xs text-text-tertiary mb-3">{{ locale.initDesc }}</p>
<button
type="button"
:disabled="initLoading"
class="w-full sm:w-auto px-6 py-2.5 bg-bg-primary border border-border-secondary rounded-xl text-text-tertiary hover:text-primary transition-colors text-xs font-black uppercase tracking-widest disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
@click="handleInitialize"
>
<Users v-if="!initLoading" :size="14" />
<RefreshCw v-else :size="14" class="animate-spin" />
{{ locale.initButton }}
</button>
</div>
</div>
</div>
<!-- 使用说明 -->
<div class="bg-bg-secondary-40 border border-border-secondary rounded-2xl p-5">
<p class="text-xs font-black text-text-primary uppercase tracking-widest mb-3 flex items-center gap-1.5">
<Info :size="13" class="text-text-tertiary" />
{{ locale.guideTitle }}
</p>
<ul class="space-y-2.5 text-xs text-text-tertiary leading-relaxed">
<li class="flex gap-2">
<span class="shrink-0 w-4 h-4 mt-0.5 rounded bg-primary-10 text-primary text-[10px] font-black flex items-center justify-center">1</span>
{{ locale.guideSingle }}
</li>
<li class="flex gap-2">
<span class="shrink-0 w-4 h-4 mt-0.5 rounded bg-primary-10 text-primary text-[10px] font-black flex items-center justify-center">2</span>
{{ locale.guideBatchLine1 }}
</li>
<li class="flex gap-2">
<span class="shrink-0 w-4 h-4 mt-0.5 rounded bg-primary-10 text-primary text-[10px] font-black flex items-center justify-center">3</span>
{{ locale.guideBatchLine2 }}
</li>
<li class="flex gap-2">
<span class="shrink-0 w-4 h-4 mt-0.5 rounded bg-primary-10 text-primary text-[10px] font-black flex items-center justify-center">4</span>
{{ locale.guideDedup }}
</li>
</ul>
</div>
<div class="space-y-6">
<!-- 回退提示 -->
<div
v-if="fallbackMode"
class="p-4 bg-warning-10 border border-warning-20 rounded-xl text-xs text-warning font-bold"
>
{{ locale.fallbackHint }}
</div>
<!-- 年级分组卡片 -->
<div v-if="loading" class="p-8 flex justify-center">
<AppSpinner :size="28" />
</div>
<div v-else-if="groupedItems.length === 0" class="p-10 text-center bg-bg-secondary-40 border border-border-secondary rounded-2xl">
<div class="mx-auto w-12 h-12 rounded-xl bg-bg-primary flex items-center justify-center mb-3">
<GraduationCap :size="22" class="text-text-tertiary" />
</div>
<p class="text-sm font-bold text-text-tertiary">{{ locale.empty }}</p>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div
v-for="group in groupedItems"
:key="group.grade"
class="bg-bg-secondary-40 border border-border-secondary rounded-2xl overflow-hidden"
>
<!-- 年级头部 -->
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border-secondary">
<div class="flex items-center gap-2.5">
<GraduationCap :size="18" class="text-primary" />
<span class="text-sm font-black text-text-primary">{{ group.grade }}</span>
<span class="text-[10px] font-bold text-text-tertiary px-2 py-0.5 rounded-full bg-bg-primary">
{{ locale.classCount(group.items.length) }}
</span>
<span class="text-[10px] font-bold text-text-tertiary px-2 py-0.5 rounded-full bg-bg-primary flex items-center gap-1">
<Users :size="10" />
{{ locale.personCount(groupTotalUsers(group)) }}
</span>
</div>
<button
type="button"
class="p-2 rounded-lg text-text-tertiary hover:text-error hover:bg-error-10 transition-colors"
:title="locale.deleteGradeButton"
@click="openDeleteGradeConfirm(group)"
>
<Trash2 :size="15" />
</button>
</div>
<!-- 班级列表 -->
<div class="px-5 py-4 flex flex-wrap gap-2">
<div
v-for="item in group.items"
:key="item.id"
class="group/chip flex items-center gap-1.5 pl-3.5 pr-2 py-1.5 rounded-lg bg-bg-primary border border-border-secondary text-sm font-bold text-text-primary"
>
<span>{{ item.class }}</span>
<span v-if="item.userCount > 0" class="text-[11px] font-bold text-text-tertiary">
{{ locale.personCount(item.userCount) }}
</span>
<button
type="button"
class="p-0.5 rounded text-text-tertiary opacity-60 group-hover/chip:opacity-100 hover:text-error hover:bg-error-10 transition-all"
:title="locale.deleteButton"
@click="openDeleteConfirm(item)"
>
<X :size="13" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 单条删除确认 -->
<ConfirmDialog
:show="showDeleteConfirm"
:title="locale.gradeLabel + ' / ' + locale.classLabel"
:message="locale.deleteConfirm"
type="danger"
:confirm-text="locale.deleteButton"
:loading="deleting"
@confirm="confirmDelete"
@close="showDeleteConfirm = false"
/>
<!-- 整年级删除确认 -->
<ConfirmDialog
:show="showGradeDeleteConfirm"
:title="locale.deleteGradeTitle"
:message="gradeDeleteMessage"
type="danger"
:confirm-text="locale.deleteGradeButton"
:loading="deleting"
@confirm="confirmDeleteGrade"
@close="showGradeDeleteConfirm = false"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Plus, RefreshCw, Trash2, GraduationCap, Sparkles, X, Users, Info } from '@lucide/vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useLocale } from '~/utils/locale'
import { useToast } from '~/composables/useToast'
import { parseClassInput } from '~/utils/grade-class-input'
const { admin } = useLocale()
const locale = computed(() => admin.value?.gradeClass || {})
const { showToast: showNotification } = useToast()
const items = ref([])
const loading = ref(false)
const adding = ref(false)
const deleting = ref(false)
const gradeInput = ref('')
const classInput = ref('')
const batchGradeInput = ref('')
const batchClassInput = ref('')
const batchAdding = ref(false)
const initLoading = ref(false)
const showDeleteConfirm = ref(false)
const targetItem = ref(null)
const showGradeDeleteConfirm = ref(false)
const targetGrade = ref('')
// 配置表为空时前端标记回退模式(后端数据源已兜底,此处仅作提示)
const fallbackMode = ref(false)
// 按年级分组(保持后端 smartSort 顺序)
const groupedItems = computed(() => {
const groups = new Map()
for (const item of items.value) {
if (!groups.has(item.grade)) {
groups.set(item.grade, { grade: item.grade, items: [] })
}
groups.get(item.grade).items.push(item)
}
return [...groups.values()]
})
const gradeDeleteMessage = computed(() =>
locale.value.deleteGradeMessage(targetGrade.value, targetGradeCount.value)
)
const targetGradeCount = computed(() => {
const group = groupedItems.value.find((g) => g.grade === targetGrade.value)
return group?.items.length || 0
})
const groupTotalUsers = (group) =>
group.items.reduce((sum, item) => sum + (item.userCount || 0), 0)
const getErrorMessage = (error) =>
error?.data?.message || error?.message || error?.statusMessage || ''
const loadItems = async () => {
loading.value = true
try {
const response = await $fetch('/api/admin/grade-class')
if (response.success) {
items.value = response.items || []
fallbackMode.value = items.value.length === 0
}
} catch (error) {
showNotification(`${locale.value.loadFailed}: ${getErrorMessage(error)}`, 'error')
} finally {
loading.value = false
}
}
const handleAdd = async () => {
const grade = gradeInput.value.trim()
const studentClass = classInput.value.trim()
if (!grade || !studentClass) return
adding.value = true
try {
const response = await $fetch('/api/admin/grade-class', {
method: 'POST',
body: { grade, class: studentClass }
})
if (response.success) {
gradeInput.value = ''
classInput.value = ''
await loadItems()
showNotification(locale.value.addSuccess, 'success')
}
} catch (error) {
if (error.statusCode === 409) {
showNotification(locale.value.duplicate, 'warning')
} else {
showNotification(`${locale.value.addFailed}: ${getErrorMessage(error)}`, 'error')
}
} finally {
adding.value = false
}
}
const handleBatchAdd = async () => {
const grades = batchGradeInput.value
.split(/[,,\n;;]/)
.map((item) => item.trim())
.filter(Boolean)
const classes = parseClassInput(batchClassInput.value)
if (grades.length === 0 || classes.length === 0) {
showNotification(locale.value.batchInvalid, 'warning')
return
}
// 后端批量上限 500,超限直接拦截
const totalItems = grades.length * classes.length
if (totalItems > 500) {
showNotification(locale.value.batchLimit, 'warning')
return
}
const itemsToSend = []
for (const grade of grades) {
for (const studentClass of classes) {
itemsToSend.push({ grade, class: studentClass })
}
}
batchAdding.value = true
try {
const response = await $fetch('/api/admin/grade-class', {
method: 'POST',
body: { items: itemsToSend }
})
if (response.success) {
batchGradeInput.value = ''
batchClassInput.value = ''
await loadItems()
showNotification(
locale.value.batchResult(response.added || 0, response.skipped || 0),
'success'
)
}
} catch (error) {
showNotification(`${locale.value.batchFailed}: ${getErrorMessage(error)}`, 'error')
} finally {
batchAdding.value = false
}
}
const handleInitialize = async () => {
initLoading.value = true
try {
const response = await $fetch('/api/admin/grade-class/initialize', {
method: 'POST'
})
if (response.success) {
await loadItems()
showNotification(
locale.value.initResult(response.added || 0, response.skipped || 0),
'success'
)
}
} catch (error) {
showNotification(`${locale.value.initFailed}: ${getErrorMessage(error)}`, 'error')
} finally {
initLoading.value = false
}
}
const openDeleteConfirm = (item) => {
targetItem.value = item
showDeleteConfirm.value = true
}
const confirmDelete = async () => {
if (!targetItem.value) return
deleting.value = true
try {
const response = await $fetch(`/api/admin/grade-class/${targetItem.value.id}`, {
method: 'DELETE'
})
if (response.success) {
showDeleteConfirm.value = false
await loadItems()
showNotification(locale.value.deleteSuccess, 'success')
}
} catch (error) {
showNotification(`${locale.value.deleteFailed}: ${getErrorMessage(error)}`, 'error')
} finally {
deleting.value = false
}
}
const openDeleteGradeConfirm = (group) => {
targetGrade.value = group.grade
showGradeDeleteConfirm.value = true
}
const confirmDeleteGrade = async () => {
if (!targetGrade.value) return
deleting.value = true
try {
const response = await $fetch('/api/admin/grade-class/by-grade', {
method: 'DELETE',
query: { grade: targetGrade.value }
})
if (response.success) {
showGradeDeleteConfirm.value = false
await loadItems()
showNotification(locale.value.deleteGradeSuccess, 'success')
}
} catch (error) {
showNotification(`${locale.value.deleteFailed}: ${getErrorMessage(error)}`, 'error')
} finally {
deleting.value = false
}
}
onMounted(loadItems)
</script>
@@ -0,0 +1,361 @@
<template>
<div class="max-w-[900px] mx-auto space-y-6 pb-20 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-text-primary tracking-tight">{{ t.title }}</h2>
<p class="text-xs text-text-tertiary mt-1">{{ t.description }}</p>
</div>
<div class="flex items-center gap-3">
<button
:disabled="loading || saving"
class="flex items-center gap-2 px-4 py-2 bg-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-tertiary text-xs font-bold rounded-xl transition-all disabled:opacity-50"
@click="resetForm"
>
<RotateCcw :size="14" /> {{ t.reset }}
</button>
<button
:disabled="loading || saving"
class="flex items-center gap-2 px-6 py-2 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95 disabled:opacity-50"
@click="saveConfig"
>
<template v-if="saving">
<AppSpinner :size="14" /> {{ t.saving }}
</template>
<template v-else-if="saveSuccess">
<CheckCircle2 :size="14" /> {{ t.saved }}
</template>
<template v-else>
<Save :size="14" /> {{ t.saveConfig }}
</template>
</button>
</div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
class="flex flex-col items-center justify-center py-20 bg-bg-secondary-20 border border-border-secondary-50 rounded-2xl space-y-4"
>
<AppSpinner :size="40" />
<p class="text-xs font-black text-text-tertiary uppercase tracking-widest">{{ t.loading }}</p>
</div>
<div v-else class="space-y-6">
<!-- 平台开关 -->
<div class="bg-bg-secondary-40 border border-border-secondary rounded-2xl p-6 shadow-xl space-y-5">
<div class="flex items-center justify-between border-b border-border-secondary pb-4">
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest flex items-center gap-2">
<ToggleLeft :size="16" class="text-warning" /> {{ t.switchTitle }}
</h3>
<button
class="flex items-center gap-2 px-3 py-1.5 text-[10px] font-bold rounded-lg border border-border-secondary text-text-tertiary hover:text-text-primary hover:border-border-tertiary transition-all"
@click="resetEnabledPlatforms"
>
<RotateCcw :size="12" /> {{ t.reset }}
</button>
</div>
<p class="text-[10px] text-text-tertiary">{{ t.switchDesc }}</p>
<div class="grid grid-cols-2 gap-3">
<div
v-for="pf in platformOrder"
:key="pf"
:class="[
'flex items-center justify-between p-3 border rounded-xl transition-all',
enabledPlatforms.includes(pf)
? 'border-primary-10 bg-primary-5'
: 'border-border-secondary bg-bg-secondary-60 opacity-60'
]"
>
<div class="flex items-center gap-3">
<span class="text-xs font-bold text-text-primary">{{ getPlatformLabel(pf) }}</span>
</div>
<button
:class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-all cursor-pointer',
enabledPlatforms.includes(pf) ? 'bg-primary' : 'bg-bg-tertiary-70'
]"
@click="togglePlatform(pf)"
>
<span
:class="[
'inline-block h-3.5 w-3.5 rounded-full bg-bg-primary shadow transition-transform',
enabledPlatforms.includes(pf) ? 'translate-x-5' : 'translate-x-1'
]"
/>
</button>
</div>
</div>
</div>
<!-- 平台排序 -->
<div class="bg-bg-secondary-40 border border-border-secondary rounded-2xl p-6 shadow-xl space-y-5">
<div class="flex items-center justify-between border-b border-border-secondary pb-4">
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest flex items-center gap-2">
<GripVertical :size="16" class="text-primary" /> {{ t.orderTitle }}
</h3>
<button
class="flex items-center gap-2 px-3 py-1.5 text-[10px] font-bold rounded-lg border border-border-secondary text-text-tertiary hover:text-text-primary hover:border-border-tertiary transition-all"
@click="resetPlatformOrder"
>
<RotateCcw :size="12" /> {{ t.resetOrder }}
</button>
</div>
<p class="text-[10px] text-text-tertiary">{{ t.orderDesc }}</p>
<div class="space-y-2">
<TransitionGroup name="platform-order" tag="div" class="space-y-2">
<div
v-for="(pf, idx) in platformOrder"
:key="pf"
:class="[
'flex items-center gap-3 p-3 border rounded-xl transition-all cursor-move select-none',
dragOverIndex === idx ? 'border-t-2 border-t-primary' : '',
enabledPlatforms.includes(pf)
? 'border-border-secondary bg-bg-secondary-40'
: 'border-border-tertiary bg-bg-secondary-70 opacity-60'
]"
draggable="true"
@dragstart="handleDragStart($event, idx)"
@dragend="handleDragEnd($event)"
@dragover.prevent
@dragenter.prevent="handleDragEnter($event, idx)"
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop($event, idx)"
>
<span class="text-[10px] font-black text-text-tertiary w-5 text-center">{{ idx + 1 }}</span>
<div class="flex items-center justify-center text-text-disabled hover:text-text-primary">
<GripVertical :size="14" />
</div>
<div class="flex items-center gap-2 flex-1">
<span class="text-xs font-medium text-text-primary">{{ getPlatformLabel(pf) }}</span>
<span
:class="[
'text-[10px] px-1.5 py-0.5 rounded font-medium',
enabledPlatforms.includes(pf)
? 'bg-primary-5 text-primary'
: 'bg-bg-tertiary-70 text-text-tertiary'
]"
>
{{ enabledPlatforms.includes(pf) ? platformStatusText.enabled : platformStatusText.disabled }}
</span>
</div>
</div>
</TransitionGroup>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import {
ToggleLeft,
GripVertical,
Save,
RotateCcw,
CheckCircle2
} from '@lucide/vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import { usePlatformConfig } from '~/composables/usePlatformConfig'
import { useLocale } from '~/utils/locale'
import { useSafeLocale } from '~/composables/useSafeLocale'
import { useLocaleText, useServerErrors } from '~/composables/useLocaleText'
import { DEFAULT_PLATFORMS, getPlatformDisplayName } from '~/utils/platforms'
const { admin, siteConfig, currentLocale } = useLocale()
const { refreshPlatformConfig } = usePlatformConfig()
const { localize } = useServerErrors()
const showNotification = (msg, type) => {
if (window.$showNotification) {
window.$showNotification(msg, type)
}
}
// 页面文案(音源控制页专属),使用 useSafeLocale + useLocaleText 模式
const locale = computed(() => useSafeLocale(admin.value?.musicSourceController || {}))
const { t: callT } = useLocaleText(locale)
const t = computed(() => ({
title: callT('title'),
description: callT('description'),
saveConfig: callT('saveConfig'),
saving: callT('saving'),
saved: callT('saved'),
reset: callT('reset'),
loading: callT('loading'),
fetchFailed: callT('fetchFailed'),
saveSuccess: callT('saveSuccess'),
saveFailedRetry: callT('saveFailedRetry'),
switchTitle: callT('switchTitle'),
switchDesc: callT('switchDesc'),
orderTitle: callT('orderTitle'),
orderDesc: callT('orderDesc'),
resetOrder: callT('resetOrder'),
mustKeepOne: callT('mustKeepOne')
}))
const siteLocale = computed(() => useSafeLocale(siteConfig.value || {}))
const { t: siteT } = useLocaleText(siteLocale)
const platformStatusText = computed(() => ({
enabled: siteT('platformEnabled', '已启用'),
disabled: siteT('platformDisabled', '已禁用')
}))
const loading = ref(true)
const saving = ref(false)
const saveSuccess = ref(false)
const enabledPlatforms = ref([...DEFAULT_PLATFORMS])
const platformOrder = ref([...DEFAULT_PLATFORMS])
const parsePlatformArray = (value) => {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value
if (!Array.isArray(parsed)) return [...DEFAULT_PLATFORMS]
const valid = parsed.filter((p) => DEFAULT_PLATFORMS.includes(p))
return valid.length > 0 ? valid : [...DEFAULT_PLATFORMS]
} catch {
return [...DEFAULT_PLATFORMS]
}
}
const getPlatformLabel = (key) => getPlatformDisplayName(key, siteConfig.value, currentLocale.value)
// 拖拽排序(参考排期管理序列的交互模式:拖拽中仅标记插入位置,drop 时重排,配合 TransitionGroup 动画)
const dragOverIndex = ref(-1)
let draggedIndex = -1
const handleDragStart = (e, idx) => {
draggedIndex = idx
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(idx))
setTimeout(() => {
e.target.classList.add('opacity-50')
}, 0)
}
const handleDragEnter = (e, idx) => {
e.preventDefault()
dragOverIndex.value = idx
}
const handleDragLeave = (e) => {
if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget)) {
dragOverIndex.value = -1
}
}
const handleDrop = (e, idx) => {
e.preventDefault()
dragOverIndex.value = -1
if (draggedIndex === -1 || draggedIndex === idx) return
const order = [...platformOrder.value]
const [item] = order.splice(draggedIndex, 1)
order.splice(idx, 0, item)
platformOrder.value = order
}
const handleDragEnd = (e) => {
e.target.classList.remove('opacity-50')
draggedIndex = -1
dragOverIndex.value = -1
}
const togglePlatform = (pf) => {
const enabled = enabledPlatforms.value
if (enabled.length <= 1 && enabled.includes(pf)) {
showNotification(t.value.mustKeepOne || '至少保留一个平台启用', 'warning')
return
}
if (enabled.includes(pf)) {
enabledPlatforms.value = enabled.filter((p) => p !== pf)
} else {
enabledPlatforms.value = [...enabled, pf]
}
}
// 重置启用状态到默认值
const resetEnabledPlatforms = () => {
enabledPlatforms.value = [...DEFAULT_PLATFORMS]
}
// 重置排序到默认值
const resetPlatformOrder = () => {
platformOrder.value = [...DEFAULT_PLATFORMS]
}
// 加载配置
const loadConfig = async () => {
try {
loading.value = true
const data = await $fetch('/api/admin/system-settings', { credentials: 'include' })
enabledPlatforms.value = parsePlatformArray(data.enabledPlatforms)
platformOrder.value = parsePlatformArray(data.platformOrder)
} catch (error) {
console.error('加载音源配置失败:', error)
showNotification(t.value.fetchFailed || '加载配置失败', 'error')
enabledPlatforms.value = [...DEFAULT_PLATFORMS]
platformOrder.value = [...DEFAULT_PLATFORMS]
} finally {
loading.value = false
}
}
// 保存配置
const saveConfig = async () => {
try {
saving.value = true
const configToSave = {
enabledPlatforms: JSON.stringify(enabledPlatforms.value),
platformOrder: JSON.stringify(platformOrder.value)
}
await $fetch('/api/admin/system-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: configToSave
})
saveSuccess.value = true
showNotification(t.value.saveSuccess || '音源配置已保存', 'success')
// 刷新全局平台配置,确保 RequestForm 和搜索逻辑立即生效
await refreshPlatformConfig()
setTimeout(() => { saveSuccess.value = false }, 3000)
} catch (error) {
console.error('保存音源配置失败:', error)
const msg = localize(error, t.value.saveFailedRetry || '保存失败')
showNotification(msg, 'error')
} finally {
saving.value = false
}
}
// 重置表单:重新从服务器加载原始数据
const resetForm = () => {
loadConfig()
}
onMounted(loadConfig)
</script>
<style scoped>
/* 平台排序拖拽过渡动画 */
.platform-order-move,
.platform-order-enter-active,
.platform-order-leave-active {
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
.platform-order-enter-from,
.platform-order-leave-to {
opacity: 0;
transform: translateY(20px);
}
.platform-order-leave-active {
position: absolute;
width: 100%;
}
</style>
File diff suppressed because it is too large Load Diff
+891
View File
@@ -0,0 +1,891 @@
<template>
<div class="max-w-[1400px] mx-auto space-y-8 pb-20">
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary mt-1">{{ locale.desc }}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
<!-- 左侧:编辑区 -->
<div class="lg:col-span-7 space-y-6">
<div class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-8 shadow-xl">
<div class="space-y-6">
<!-- 标题 -->
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em] px-1"
>{{ locale.notificationTitle }}</label
>
<input
v-model="form.title"
type="text"
:placeholder="locale.titlePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-3.5 text-sm focus:outline-none focus:border-primary-30 transition-all text-text-primary placeholder:text-text-primary"
>
</div>
<!-- 内容 -->
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em] px-1"
>{{ locale.notificationContent }}</label
>
<textarea
v-model="form.content"
:placeholder="locale.contentPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-primary-30 transition-all text-text-primary placeholder:text-text-primary min-h-[160px] resize-none"
/>
<p class="px-1 text-[10px] font-medium text-text-disabled">
{{ locale.markdownHint }}
</p>
</div>
<!-- 重要通知开关 -->
<label
class="flex cursor-pointer items-start gap-4 rounded-2xl border p-4 shadow-sm transition-colors"
:class="
form.important
? 'border-primary-300 bg-primary-10'
: 'border-border-tertiary bg-bg-primary hover:border-border-quaternary hover:bg-bg-secondary'
"
>
<span
class="relative mt-0.5 inline-block h-6 w-11 shrink-0"
>
<input v-model="form.important" type="checkbox" class="peer sr-only">
<span
class="absolute inset-0 rounded-full bg-border-tertiary transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-primary-300"
/>
<span
class="absolute left-1 top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform peer-checked:translate-x-5"
/>
</span>
<span class="min-w-0">
<span class="flex items-center gap-2 text-sm font-black text-text-primary">
<AlertTriangle
:size="16"
:class="form.important ? 'text-primary' : 'text-text-tertiary'"
/>
{{ locale.importantToggleTitle }}
</span>
<span class="mt-1 block text-xs leading-relaxed text-text-secondary">
{{ locale.importantToggleDescription }}
</span>
</span>
</label>
<!-- 范围选择 -->
<div class="space-y-3 pt-4 border-t border-border-secondary-50">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em] px-1"
>{{ locale.scope }}</label
>
<div class="flex flex-wrap gap-2">
<button
v-for="opt in targetOptions"
:key="opt.id"
:class="[
'flex items-center gap-2 px-4 py-2.5 rounded-xl text-[11px] font-black transition-all border',
form.scope === opt.id
? 'bg-primary-hover border-primary text-text-primary shadow-lg shadow-[var(--primary-glow)]'
: 'bg-bg-primary border-border-secondary text-text-tertiary hover:border-border-tertiary hover:text-text-secondary'
]"
@click="form.scope = opt.id"
>
<component :is="opt.icon" :size="14" />
{{ opt.label }}
</button>
</div>
</div>
<!-- 动态配置区 -->
<div class="pt-2">
<Transition name="fade" mode="out-in">
<div
v-if="form.scope === 'ALL'"
key="all"
class="p-4 bg-primary-5 border border-primary-10 rounded-2xl flex items-center gap-4"
>
<Info class="text-primary shrink-0" :size="18" />
<p class="text-[11px] font-bold text-text-tertiary">
{{ locale.allUsersHint }}
</p>
</div>
<div v-else-if="form.scope === 'GRADE'" key="grade" class="space-y-4">
<div class="space-y-1.5">
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.selectGrade }}</span
>
<CustomSelect
v-model="form.grade"
:options="gradeOptions"
label-key="label"
value-key="value"
:placeholder="locale.selectGradePlaceholder"
class-name="w-full md:w-64"
/>
</div>
</div>
<div
v-else-if="form.scope === 'CLASS'"
key="class"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="space-y-1.5">
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.grade }}</span
>
<CustomSelect
v-model="form.classGrade"
:options="gradeOptions"
label-key="label"
value-key="value"
:placeholder="locale.selectGradePlaceholder"
class-name="w-full"
/>
</div>
<div class="space-y-1.5">
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.className }}</span
>
<CustomSelect
v-model="form.className"
:options="classOptionsForClassScope"
label-key="label"
value-key="value"
:placeholder="locale.selectClassPlaceholder"
class-name="w-full"
:disabled="!form.classGrade"
/>
</div>
</div>
<div v-else-if="form.scope === 'MULTI_CLASS'" key="multi" class="space-y-4">
<div class="p-6 bg-bg-primary-50 border border-border-secondary border-dashed rounded-2xl">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<CustomSelect
v-model="multiClassForm.grade"
:options="gradeOptions"
label-key="label"
value-key="value"
:placeholder="locale.selectGradePlaceholder"
class-name="w-full"
/>
<div class="flex gap-2">
<CustomSelect
v-model="multiClassForm.class"
:options="classOptionsForMultiClassScope"
label-key="label"
value-key="value"
:placeholder="locale.selectClassPlaceholder"
class-name="flex-1"
:disabled="!multiClassForm.grade"
/>
<button
:disabled="!canAddClass"
class="px-4 bg-bg-tertiary hover:bg-bg-quaternary disabled:opacity-50 disabled:cursor-not-allowed text-text-tertiary font-bold rounded-xl text-xs transition-all"
@click="addClassToSelection"
>
{{ locale.add }}
</button>
</div>
</div>
<div v-if="form.selectedClasses.length > 0" class="flex flex-wrap gap-2">
<div
v-for="(cls, index) in form.selectedClasses"
:key="index"
class="flex items-center gap-2 px-3 py-1.5 bg-bg-secondary border border-border-secondary rounded-lg group"
>
<span class="text-[10px] font-bold text-text-tertiary"
>{{ cls.grade }} {{ cls.class }}</span
>
<button
class="text-text-disabled hover:text-error transition-colors"
@click="removeClassFromSelection(index)"
>
<X :size="12" />
</button>
</div>
</div>
<div v-else class="text-center py-4">
<p class="text-[10px] font-black text-text-secondary uppercase tracking-widest">
{{ locale.noClassSelected }}
</p>
</div>
</div>
</div>
<div v-else-if="form.scope === 'SPECIFIC_USERS'" key="specific" class="space-y-4">
<div class="relative flex items-center">
<Search
v-if="!userSearchLoading"
class="absolute left-4 text-text-secondary pointer-events-none"
:size="16"
/>
<Loader2
v-else
class="absolute left-4 text-primary animate-spin pointer-events-none"
:size="16"
/>
<input
v-model="userSearchQuery"
type="text"
:placeholder="locale.userSearchPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-primary-30 transition-all text-text-primary"
@input="onUserSearchInput"
>
</div>
<!-- 搜索结果 -->
<div
v-if="showUserSearchResults && userSearchResults.length > 0"
class="bg-bg-primary border border-border-secondary rounded-2xl overflow-hidden max-h-[300px] overflow-y-auto custom-scrollbar"
>
<div class="px-4 py-2 border-b border-border-secondary-60 bg-bg-secondary-40">
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest"
>{{ getLocaleMessage('searchResults', userSearchResults.length) }}</span
>
</div>
<div
v-for="user in userSearchResults"
:key="user.id"
class="flex items-center justify-between p-4 border-b border-border-secondary-30 last:border-0 hover:bg-bg-tertiary-30 transition-all"
>
<div>
<h5 class="text-sm font-bold text-text-primary">
{{ user.name || user.username }}
</h5>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[10px] text-primary font-black"
>@{{ user.username }}</span
>
<span
v-if="user.grade && user.class"
class="text-[10px] text-text-disabled font-bold uppercase tracking-widest"
>{{ user.grade }} {{ user.class }}</span
>
<span class="text-[10px] text-text-secondary font-black">{{
getRoleText(user.role)
}}</span>
</div>
</div>
<button
:disabled="isUserSelected(user.id)"
class="px-3 py-1.5 bg-bg-tertiary hover:bg-primary-hover hover:text-text-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-tertiary text-[10px] font-black rounded-lg transition-all uppercase"
@click="addUserToSelection(user)"
>
{{ isUserSelected(user.id) ? locale.selected : locale.select }}
</button>
</div>
</div>
<!-- 已选择的用户 -->
<div v-if="form.selectedUsers.length > 0" class="space-y-3">
<div class="flex items-center justify-between px-1">
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest"
>{{ getLocaleMessage('selectedUsers', form.selectedUsers.length) }}</span
>
<button
class="text-[9px] font-black text-error-70 hover:text-error uppercase tracking-widest transition-colors"
@click="clearAllSelectedUsers"
>
{{ locale.clearAll }}
</button>
</div>
<div class="flex flex-wrap gap-2">
<div
v-for="(user, index) in form.selectedUsers"
:key="user.id"
class="flex items-center gap-2 px-3 py-1.5 bg-bg-secondary border border-border-secondary rounded-lg group"
>
<div class="flex flex-col">
<span class="text-[10px] font-bold text-text-secondary leading-none">{{
user.name || user.username
}}</span>
<span class="text-[8px] text-text-disabled">@{{ user.username }}</span>
</div>
<button
class="text-text-disabled hover:text-error transition-colors"
@click="removeUserFromSelection(index)"
>
<X :size="12" />
</button>
</div>
</div>
</div>
<div v-else class="text-center py-4">
<p class="text-[10px] font-black text-text-secondary uppercase tracking-widest">
{{ locale.noUsersSelected }}
</p>
</div>
</div>
</Transition>
</div>
</div>
<!-- 错误和成功提示 -->
<div v-if="error || success" class="mt-6">
<div
v-if="error"
class="p-4 bg-error-5 border border-error-10 rounded-2xl flex items-center gap-3"
>
<AlertCircle class="text-error shrink-0" :size="18" />
<p class="text-xs font-bold text-error">{{ error }}</p>
</div>
<div
v-if="success"
class="p-4 bg-success-5 border border-success-10 rounded-2xl flex items-center gap-3"
>
<Check class="text-success shrink-0" :size="18" />
<p class="text-xs font-bold text-success">{{ success }}</p>
</div>
</div>
<div class="mt-10 flex justify-end">
<button
:disabled="loading || !isFormValid"
:class="[
'flex items-center gap-2 px-10 py-4 bg-bg-primary border border-border-secondary text-xs font-black rounded-2xl transition-all uppercase tracking-[0.2em] shadow-lg',
loading || !isFormValid
? 'text-text-primary cursor-not-allowed'
: 'text-text-tertiary hover:text-text-primary hover:border-border-tertiary hover:bg-bg-secondary'
]"
@click="sendNotification"
>
<Loader2 v-if="loading" class="animate-spin" :size="16" />
<Send v-else :size="16" />
{{ loading ? locale.sending : locale.send }}
</button>
</div>
</div>
</div>
<!-- 右侧:预览区 -->
<div class="lg:col-span-5 space-y-6 lg:sticky lg:top-8">
<div
class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-8 shadow-xl flex flex-col h-full"
>
<h3
class="text-sm font-black text-text-primary uppercase tracking-widest mb-6 flex items-center gap-2"
>
<Eye :size="16" class="text-primary" /> {{ locale.previewTitle }}
</h3>
<div class="flex flex-1 flex-col items-center justify-center p-1 sm:p-4">
<!-- 重要通知预览 -->
<section
v-if="form.important"
class="w-full max-w-[320px] overflow-hidden rounded-2xl border border-primary-300 bg-bg-primary shadow-2xl"
>
<div class="h-1.5 w-full bg-warning" />
<div class="p-5 space-y-5">
<header class="flex items-start gap-3">
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-warning-300 bg-warning-10 text-warning"
aria-hidden="true"
>
<Icon name="bell-ring" :size="17" />
</div>
<div class="min-w-0 flex-1">
<h4
class="break-words text-sm font-black"
:class="form.title ? 'text-text-primary' : 'italic text-text-disabled'"
>
{{ form.title || locale.previewTitlePlaceholder }}
</h4>
<p class="mt-1 truncate text-[11px] font-black text-text-disabled">
{{ locale.senderLabel }}:{{ previewSenderName }}
</p>
</div>
</header>
<div
v-if="form.content"
class="markdown-body max-h-64 overflow-y-auto text-sm leading-relaxed text-text-tertiary"
v-html="previewContent"
/>
<p v-else class="text-sm italic leading-relaxed text-text-disabled">
{{ locale.previewContentPlaceholder }}
</p>
<footer class="flex items-center justify-between gap-4 border-t border-border-secondary-50 pt-4">
<div class="flex min-w-0 items-center gap-1.5 text-text-secondary">
<Users :size="13" class="shrink-0" aria-hidden="true" />
<span class="truncate text-[10px] font-black text-text-disabled">
{{ getLocaleMessage('previewScope', scopeDescription) }}
</span>
</div>
<MessageSquare :size="17" class="shrink-0 text-warning" aria-hidden="true" />
</footer>
</div>
</section>
<!-- 普通通知预览 -->
<article
v-else
class="w-full max-w-[320px] overflow-hidden rounded-2xl border border-border-secondary bg-bg-primary shadow-2xl"
>
<div class="h-1.5 w-full bg-primary" />
<div class="p-5 space-y-5">
<header class="flex items-start gap-3">
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-primary-20 bg-primary-hover-10 text-primary"
aria-hidden="true"
>
<Icon name="bell" :size="17" />
</div>
<div class="min-w-0 flex-1">
<h4
class="break-words text-sm font-black"
:class="form.title ? 'text-text-primary' : 'italic text-text-disabled'"
>
{{ form.title || locale.previewTitlePlaceholder }}
</h4>
<p class="mt-1 truncate text-[11px] font-black text-text-disabled">
{{ locale.senderLabel }}:{{ previewSenderName }}
</p>
</div>
</header>
<div
v-if="form.content"
class="markdown-body max-h-64 overflow-y-auto text-sm leading-relaxed text-text-tertiary"
v-html="previewContent"
/>
<p v-else class="text-sm italic leading-relaxed text-text-disabled">
{{ locale.previewContentPlaceholder }}
</p>
<footer class="flex items-center justify-between gap-4 border-t border-border-secondary-50 pt-4">
<div class="flex min-w-0 items-center gap-1.5 text-text-secondary">
<Users :size="13" class="shrink-0" aria-hidden="true" />
<span class="truncate text-[10px] font-black text-text-disabled">
{{ getLocaleMessage('previewScope', scopeDescription) }}
</span>
</div>
<MessageSquare :size="17" class="shrink-0 text-primary" aria-hidden="true" />
</footer>
</div>
</article>
<div
class="mt-6 flex w-full max-w-[320px] items-start gap-3 rounded-lg border border-warning-10 bg-warning-5 p-3"
>
<AlertCircle :size="14" class="mt-0.5 shrink-0 text-warning" />
<p class="text-[10px] font-black text-text-tertiary leading-normal">
{{ locale.previewHint }}
</p>
</div>
</div>
</div>
</div>
</div>
<NotificationHistory :refresh-key="historyRefreshKey" />
</div>
</template>
<script setup>
import { computed, ref, onUnmounted, onMounted, watch } from 'vue'
import {
Send,
Users,
GraduationCap,
LayoutGrid,
User,
Search,
Info,
X,
Check,
Plus,
AlertCircle,
AlertTriangle,
Eye,
MessageSquare,
Loader2
} from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import Icon from '~/components/UI/Icon.vue'
import NotificationHistory from '~/components/Admin/NotificationHistory.vue'
import { useAuth } from '~/composables/useAuth'
import { useAdmin } from '~/composables/useAdmin'
import { useUserFilters } from '~/composables/useUserFilters'
import { useLocale } from '~/utils/locale'
import { renderMarkdown } from '~/utils/markdown'
const { user: authUser, isAdmin, getAuthConfig } = useAuth()
const { sendAdminNotification } = useAdmin()
const userFilters = useUserFilters()
const { admin } = useLocale()
const locale = computed(() => admin.value?.notificationSender || {})
const { msg: getLocaleMessage, nested: getNestedMessage } = useLocaleText(locale)
const previewSenderName = computed(
() =>
authUser.value?.name?.trim() ||
authUser.value?.username?.trim() ||
locale.value.previewSender
)
onMounted(() => {
userFilters.fetchOptions()
})
const gradeOptions = computed(() => {
return userFilters.getAvailableGrades().map(g => ({ label: g, value: g }))
})
// 表单数据
const form = ref({
title: '',
content: '',
important: false,
scope: 'ALL', // 'ALL', 'GRADE', 'CLASS', 'MULTI_CLASS', 'SPECIFIC_USERS'
grade: '',
classGrade: '',
className: '',
selectedClasses: [], // 用于多班级选择
selectedUsers: [] // 用于指定用户选择
})
// 多班级选择表单
const multiClassForm = ref({
grade: '',
class: ''
})
const classOptionsForClassScope = computed(() => {
const classes = userFilters.getAvailableClasses(undefined, form.value.classGrade)
return classes.map(c => ({ label: c, value: c }))
})
const classOptionsForMultiClassScope = computed(() => {
const classes = userFilters.getAvailableClasses(undefined, multiClassForm.value.grade)
return classes.map(c => ({ label: c, value: c }))
})
watch(() => form.value.classGrade, () => {
form.value.className = ''
})
watch(() => multiClassForm.value.grade, () => {
multiClassForm.value.class = ''
})
const loading = ref(false)
const error = ref('')
const success = ref('')
const historyRefreshKey = ref(0)
const previewContent = computed(() => renderMarkdown(form.value.content))
// 用户搜索相关
const userSearchQuery = ref('')
const userSearchResults = ref([])
const showUserSearchResults = ref(false)
const userSearchLoading = ref(false)
let userSearchTimeout = null
const targetOptions = computed(() => [
{ id: 'ALL', label: getNestedMessage('targets', 'all'), icon: Users },
{ id: 'GRADE', label: getNestedMessage('targets', 'grade'), icon: GraduationCap },
{ id: 'CLASS', label: getNestedMessage('targets', 'class'), icon: LayoutGrid },
{ id: 'MULTI_CLASS', label: getNestedMessage('targets', 'multiClass'), icon: Plus },
{ id: 'SPECIFIC_USERS', label: getNestedMessage('targets', 'specificUsers'), icon: User }
])
// 判断是否可以添加班级
const canAddClass = computed(() => {
return multiClassForm.value.grade && multiClassForm.value.class
})
// 添加班级到选择列表
const addClassToSelection = () => {
if (!canAddClass.value) return
// 检查是否已经选择了这个班级
const isDuplicate = form.value.selectedClasses.some(
(cls) => cls.grade === multiClassForm.value.grade && cls.class === multiClassForm.value.class
)
if (!isDuplicate) {
form.value.selectedClasses.push({
grade: multiClassForm.value.grade,
class: multiClassForm.value.class
})
// 清空输入
multiClassForm.value.class = ''
}
}
// 从选择列表中移除班级
const removeClassFromSelection = (index) => {
form.value.selectedClasses.splice(index, 1)
}
// 用户搜索输入处理(防抖)
const onUserSearchInput = () => {
clearTimeout(userSearchTimeout)
if (!userSearchQuery.value.trim()) {
userSearchResults.value = []
showUserSearchResults.value = false
return
}
userSearchTimeout = setTimeout(async () => {
await searchUsers(userSearchQuery.value.trim())
}, 300)
}
// 搜索用户API调用
const searchUsers = async (query) => {
if (!query) return
try {
userSearchLoading.value = true
const response = await $fetch('/api/admin/users', {
method: 'GET',
query: {
search: query,
limit: 20
},
...getAuthConfig()
})
if (response.success) {
userSearchResults.value = response.users || []
showUserSearchResults.value = true
}
} catch (err) {
console.error('搜索用户失败:', err)
userSearchResults.value = []
showUserSearchResults.value = false
} finally {
userSearchLoading.value = false
}
}
// 检查用户是否已被选择
const isUserSelected = (userId) => {
return form.value.selectedUsers.some((user) => user.id === userId)
}
// 添加用户到选择列表
const addUserToSelection = (user) => {
if (isUserSelected(user.id)) return
form.value.selectedUsers.push({
id: user.id,
name: user.name,
username: user.username,
grade: user.grade,
class: user.class,
role: user.role
})
// 清空搜索
userSearchQuery.value = ''
userSearchResults.value = []
showUserSearchResults.value = false
}
// 从选择列表中移除用户
const removeUserFromSelection = (index) => {
form.value.selectedUsers.splice(index, 1)
}
// 清空所有已选用户
const clearAllSelectedUsers = () => {
form.value.selectedUsers = []
}
// 获取角色文本
const getRoleText = (role) => {
const roleMap = {
admin: getNestedMessage('roles', 'admin'),
teacher: getNestedMessage('roles', 'teacher'),
student: getNestedMessage('roles', 'student')
}
return roleMap[role] || role
}
// 表单验证
const isFormValid = computed(() => {
if (!form.value.title || !form.value.content) {
return false
}
if (form.value.scope === 'GRADE' && !form.value.grade) {
return false
}
if (form.value.scope === 'CLASS' && (!form.value.classGrade || !form.value.className)) {
return false
}
if (form.value.scope === 'MULTI_CLASS' && form.value.selectedClasses.length === 0) {
return false
}
if (form.value.scope === 'SPECIFIC_USERS' && form.value.selectedUsers.length === 0) {
return false
}
return true
})
// 范围描述
const scopeDescription = computed(() => {
switch (form.value.scope) {
case 'ALL':
return getNestedMessage('scopeDescriptions', 'all')
case 'GRADE':
return form.value.grade
? getNestedMessage('scopeDescriptions', 'grade', form.value.grade)
: getNestedMessage('scopeDescriptions', 'selectGrade')
case 'CLASS':
return form.value.classGrade && form.value.className
? getNestedMessage('scopeDescriptions', 'class', form.value.classGrade, form.value.className)
: getNestedMessage('scopeDescriptions', 'selectClass')
case 'MULTI_CLASS':
return form.value.selectedClasses.length > 0
? getNestedMessage('scopeDescriptions', 'multiClass', form.value.selectedClasses.length)
: getNestedMessage('scopeDescriptions', 'selectClass')
case 'SPECIFIC_USERS':
return form.value.selectedUsers.length > 0
? getNestedMessage('scopeDescriptions', 'specificUsers', form.value.selectedUsers.length)
: getNestedMessage('scopeDescriptions', 'selectUsers')
default:
return ''
}
})
// 发送通知
const sendNotification = async () => {
if (!isAdmin.value) {
error.value = getNestedMessage('errors', 'adminOnly')
return
}
if (!isFormValid.value) {
error.value = getNestedMessage('errors', 'incomplete')
return
}
loading.value = true
error.value = ''
success.value = ''
try {
// 构建请求数据
const notificationData = {
title: form.value.title,
content: form.value.content,
important: form.value.important,
scope: form.value.scope,
filter: {}
}
// 添加过滤条件
if (form.value.scope === 'GRADE') {
notificationData.filter.grade = form.value.grade
} else if (form.value.scope === 'CLASS') {
notificationData.filter.grade = form.value.classGrade
notificationData.filter.class = form.value.className
} else if (form.value.scope === 'MULTI_CLASS') {
notificationData.filter.classes = form.value.selectedClasses
} else if (form.value.scope === 'SPECIFIC_USERS') {
notificationData.filter.userIds = form.value.selectedUsers.map((user) => user.id)
}
// 发送通知
const result = await sendAdminNotification(notificationData)
if (result && result.success) {
success.value = getNestedMessage('messages', 'sendSuccess', result.sentCount)
historyRefreshKey.value += 1
// 3秒后自动隐藏成功提示
setTimeout(() => {
success.value = ''
}, 3000)
// 清空表单
form.value = {
title: '',
content: '',
important: false,
scope: 'ALL',
grade: '',
classGrade: '',
className: '',
selectedClasses: [],
selectedUsers: []
}
multiClassForm.value = {
grade: '',
class: ''
}
// 清空用户搜索相关状态
userSearchQuery.value = ''
userSearchResults.value = []
showUserSearchResults.value = false
userSearchLoading.value = false
clearTimeout(userSearchTimeout)
} else {
throw new Error(result?.message || getNestedMessage('errors', 'sendFailed'))
}
} catch (err) {
error.value = err.message || getNestedMessage('errors', 'sendError')
console.error('发送通知错误:', err)
// 3秒后自动隐藏错误提示
setTimeout(() => {
error.value = ''
}, 3000)
} finally {
loading.value = false
}
}
onUnmounted(() => {
if (userSearchTimeout) {
clearTimeout(userSearchTimeout)
}
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--panel-bg-alt);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--panel-bg-hover);
}
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateX(-10px);
}
.fade-leave-to {
opacity: 0;
transform: translateX(10px);
}
</style>
+231
View File
@@ -0,0 +1,231 @@
<template>
<Teleport to="body">
<Transition
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-if="show"
class="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
@click="$emit('close')"
>
<div
class="bg-bg-secondary border border-border-secondary w-full max-w-2xl rounded-2xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh]"
@click.stop
>
<div
class="p-8 pb-4 flex items-center justify-between border-b border-border-secondary-50"
>
<div>
<h3
class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3"
>
<div
class="w-10 h-10 rounded-xl bg-success-10 flex items-center justify-center text-success"
>
<Link :size="20" />
</div>
{{ locale.oauthBindingsTitle }}
</h3>
<p class="text-xs text-text-tertiary mt-1 ml-13">{{ locale.oauthBindingsDesc }}</p>
</div>
<button
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary rounded-xl transition-all"
@click="$emit('close')"
>
<X :size="20" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-8 pt-6 custom-scrollbar">
<div
v-if="identities.length === 0"
class="py-16 flex flex-col items-center justify-center gap-3 text-text-disabled"
>
<Link :size="28" class="opacity-60" />
<p class="text-xs font-medium">{{ locale.oauthBindingsEmpty }}</p>
</div>
<div v-else class="space-y-4">
<div
v-for="(identity, index) in identities"
:key="identity.id ?? `${identity.provider}-${identity.providerUserId}-${index}`"
class="p-5 bg-bg-primary-50 border border-border-secondary-50 rounded-2xl space-y-4"
>
<div class="flex items-center gap-4">
<div
class="w-11 h-11 rounded-xl bg-bg-primary border border-border-secondary flex items-center justify-center text-text-secondary shrink-0 overflow-hidden"
>
<AuthProvidersGitHubIcon
v-if="identity.provider === 'github'"
class="w-5 h-5"
/>
<AuthProvidersCasdoorIcon
v-else-if="identity.provider === 'casdoor'"
class="w-5 h-5"
/>
<AuthProvidersGoogleIcon
v-else-if="identity.provider === 'google'"
class="w-5 h-5"
/>
<Icon
v-else-if="isAggregateProvider(identity.provider)"
:name="
getAggregateOAuthLoginTypeIcon(getAggregateLoginType(identity.provider))
"
:size="22"
/>
<Fingerprint v-else-if="identity.provider === 'webauthn'" :size="20" />
<ShieldCheck v-else-if="identity.provider === 'totp'" :size="20" />
<Link v-else :size="20" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-black text-text-primary truncate">
{{ getOAuthProviderName(identity.provider) }}
</div>
<div class="text-xs text-text-tertiary mt-0.5 truncate">
{{ getIdentityDisplayName(identity) }}
</div>
</div>
<div
class="w-12 h-12 rounded-xl bg-bg-primary border border-border-secondary overflow-hidden flex items-center justify-center text-text-secondary shrink-0"
>
<img
v-if="identity.avatar && !failedAvatarIds.includes(identity.id)"
:src="identity.avatar"
:alt="getIdentityDisplayName(identity)"
class="w-full h-full object-cover"
loading="lazy"
@error="markAvatarFailed(identity.id)"
>
<Icon v-else name="user" :size="20" class="text-text-disabled" />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30">
<div
class="text-[10px] font-black text-text-disabled uppercase tracking-tighter"
>
{{ locale.oauthProvider }}
</div>
<div class="text-xs font-bold text-text-secondary mt-1 break-all">
{{ getOAuthProviderName(identity.provider) }}
</div>
</div>
<div class="p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30">
<div
class="text-[10px] font-black text-text-disabled uppercase tracking-tighter"
>
{{ locale.oauthUsername }}
</div>
<div class="text-xs font-bold text-text-secondary mt-1 break-all">
{{ getIdentityDisplayName(identity) }}
</div>
</div>
<div class="p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30">
<div
class="text-[10px] font-black text-text-disabled uppercase tracking-tighter"
>
{{ locale.oauthUserId }}
</div>
<div class="text-xs font-bold text-text-secondary mt-1 break-all">
{{ getIdentityUserId(identity) }}
</div>
</div>
<div class="p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30">
<div
class="text-[10px] font-black text-text-disabled uppercase tracking-tighter"
>
{{ locale.oauthBoundAt }}
</div>
<div class="text-xs font-bold text-text-secondary mt-1">
{{ formatDateTime(identity.createdAt) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, ref } from 'vue'
import { Fingerprint, Link, ShieldCheck, X } from '@lucide/vue'
import Icon from '~/components/UI/Icon.vue'
import AuthProvidersCasdoorIcon from '~/components/Auth/Providers/Casdoor/Icon.vue'
import AuthProvidersGitHubIcon from '~/components/Auth/Providers/GitHub/Icon.vue'
import AuthProvidersGoogleIcon from '~/components/Auth/Providers/Google/Icon.vue'
import { getAggregateOAuthLoginTypeIcon, getOAuthProviderName } from '~/utils/oauth'
import { useLocale } from '~/utils/locale'
defineProps({
show: {
type: Boolean,
default: false
},
identities: {
type: Array,
default: () => []
}
})
defineEmits(['close'])
const { admin, currentLocale } = useLocale()
const locale = computed(() => admin.value?.userManager?.detail || {})
const failedAvatarIds = ref([])
const isAggregateProvider = (provider) =>
String(provider || '')
.toLowerCase()
.startsWith('aggregate:')
const getAggregateLoginType = (provider) => String(provider || '').replace(/^aggregate:/i, '')
const getIdentityDisplayName = (identity) => {
if (identity.provider === 'webauthn') {
try {
const data = JSON.parse(identity.providerUsername)
return data?.label || locale.value.oauthUnknownDevice || '未知设备'
} catch {
return locale.value.oauthUnknownDevice || '未知设备'
}
}
if (identity.provider === 'totp') return locale.value.oauthTotpLabel || '双重认证'
return identity.providerUsername || locale.value.oauthNoUsername || '未提供用户名'
}
const getIdentityUserId = (identity) => {
if (identity.provider === 'totp') return locale.value.oauthIdHidden || '已隐藏'
return identity.providerUserId || locale.value.oauthNoId || '未提供 ID'
}
const markAvatarFailed = (id) => {
if (id == null || failedAvatarIds.value.includes(id)) return
failedAvatarIds.value = [...failedAvatarIds.value, id]
}
const formatDateTime = (value) => {
if (!value) return locale.value.oauthUnknownTime || '未知时间'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return locale.value.oauthUnknownTime || '未知时间'
return new Intl.DateTimeFormat(currentLocale.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date)
}
</script>
+575
View File
@@ -0,0 +1,575 @@
<template>
<section :class="cardClass">
<h3
class="text-sm font-black text-text-primary uppercase tracking-widest flex items-center gap-2 border-b border-border-secondary pb-4"
>
<Shield :size="16" class="text-success" /> {{ locale.title }}
</h3>
<div class="flex items-start gap-3 p-4 bg-primary-5 border border-primary-10 rounded-xl">
<AlertCircle :size="14" class="text-primary shrink-0 mt-0.5" />
<p class="text-[10px] text-text-tertiary leading-relaxed">
{{
locale.runtimeConfigNotice ||
'OAuth 运行时配置已迁移到后台。环境变量仅用于兼容旧部署和一键导入,导入后以此页面保存的配置为准。'
}}
</p>
</div>
<!-- 基础配置 -->
<div class="space-y-4 mb-6 pb-6 border-b border-border-secondary">
<div class="flex items-center justify-between">
<h4 class="text-xs font-bold text-text-tertiary uppercase tracking-widest">{{ locale.baseSettings }}</h4>
<button
v-if="envData.hasBaseConfig"
type="button"
class="text-[10px] px-2 py-1 bg-primary-10 text-primary hover:bg-primary-20 border border-primary-20 rounded-md transition-colors font-bold flex items-center gap-1"
@click="importEnvData('base')"
>
<Download :size="12" />
{{ locale.importEnv }}
</button>
</div>
<div class="rounded-xl border border-border-secondary bg-bg-primary-50">
<div class="flex items-center justify-between p-3">
<div class="pr-4">
<p class="text-xs font-bold text-text-primary">{{ locale.allowRegistration }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.allowRegistrationDesc }}</p>
</div>
<div class="flex items-center gap-2">
<span
:class="[
'text-[10px] font-bold',
formData.allowOAuthRegistration ? 'text-success' : 'text-text-tertiary'
]"
>
{{ formData.allowOAuthRegistration ? locale.allowed : locale.notAllowed }}
</span>
<input
v-model="formData.allowOAuthRegistration"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary accent-green-600 cursor-pointer"
/>
</div>
</div>
<div
v-if="formData.allowOAuthRegistration"
class="ml-4 mr-2 mb-2 pl-4 border-l border-border-secondary space-y-2 transition-opacity"
>
<div class="flex items-center justify-between p-3 bg-bg-primary border border-border-secondary rounded-xl">
<div class="pr-4">
<p class="text-xs font-bold text-text-primary">{{ locale.oauthRegisterRequiresApproval }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.oauthRegisterRequiresApprovalDesc }}</p>
</div>
<input
v-model="formData.oauthRegisterRequiresApproval"
type="checkbox"
:disabled="!formData.allowOAuthRegistration"
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary cursor-pointer disabled:cursor-not-allowed"
/>
</div>
<div class="flex items-center justify-between p-3 bg-bg-primary border border-border-secondary rounded-xl">
<div class="pr-4">
<p class="text-xs font-bold text-text-primary">{{ siteLocale.registerRequiresGradeClass }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ siteLocale.registerRequiresGradeClassDesc }}</p>
</div>
<input
v-model="formData.registerRequiresGradeClass"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
</div>
</div>
<div>
<label :class="labelClass">{{ locale.redirectUri }}</label>
<p class="text-[10px] text-text-disabled px-1 mb-2">
{{ locale.redirectExample }} <code class="bg-bg-primary px-2 py-1 rounded">https://yourdomain.com/api/auth/[provider]/callback</code>
</p>
<input
v-model="formData.oauthRedirectUri"
type="text"
placeholder="https://yourdomain.com/api/auth/[provider]/callback"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.stateSecret }}</label>
<p class="text-[10px] text-text-disabled px-1 mb-2">{{ locale.stateSecretDesc }}</p>
<div class="flex gap-2">
<input
v-model="formData.oauthStateSecret"
:type="showSecrets.state ? 'text' : 'password'"
:placeholder="locale.stateSecretPlaceholder"
:class="inputClass"
/>
<button
type="button"
class="px-4 py-2.5 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary text-xs font-bold rounded-xl transition-all"
@click="showSecrets.state = !showSecrets.state"
>
{{ showSecrets.state ? locale.hide : locale.show }}
</button>
</div>
</div>
</div>
<!-- 循环渲染的基础 OAuth 提供商配置 -->
<AdminProviderConfigSection
v-for="provider in oauthProviders"
:key="provider.id"
:title="provider.title"
:has-env-config="provider.hasEnvConfig"
v-model:enabled="formData[provider.enabledKey]"
v-model:clientId="formData[provider.clientIdKey]"
v-model:clientSecret="formData[provider.clientSecretKey]"
:client-id-label="provider.clientIdLabel"
:client-id-placeholder="provider.clientIdPlaceholder"
:client-secret-label="provider.clientSecretLabel"
:client-secret-placeholder="provider.clientSecretPlaceholder"
:doc-url="provider.docUrl"
:doc-label="provider.docLabel"
@import-env="importEnvData(provider.id)"
>
<template #before-fields v-if="provider.id === 'casdoor'">
<div>
<label :class="labelClass">{{ locale.casdoorServerUrl }}</label>
<input
v-model="formData.casdoorServerUrl"
type="text"
placeholder="https://casdoor.example.com"
:class="inputClass"
/>
</div>
</template>
<template #after-fields v-if="provider.id === 'casdoor'">
<div>
<label :class="labelClass">{{ locale.casdoorOrganizationName }}</label>
<input
v-model="formData.casdoorOrganizationName"
type="text"
:placeholder="locale.organizationPlaceholder"
:class="inputClass"
/>
</div>
</template>
<template #after-fields v-else-if="provider.id === 'aggregate'">
<div>
<CustomSelect
v-model="formData.aggregateOAuthLoginType"
:label="locale.aggregateLoginTypeLabel || '登录方式'"
:options="aggregateLoginTypes"
:placeholder="locale.aggregateLoginTypePlaceholder || '请选择至少一种登录方式'"
multiple
/>
<p class="text-[10px] text-text-disabled px-1 mt-2">
{{
locale.aggregateLoginTypeDesc ||
'请选择当前聚合登录服务已接入并开通的登录方式;启用服务商尚未支持的平台会导致授权失败。每种登录方式会独立记录账号绑定关系。'
}}
</p>
</div>
<div>
<label :class="labelClass">{{ locale.aggregateEndpointLabel || '接口地址' }}</label>
<input
v-model="formData.aggregateOAuthEndpoint"
type="url"
placeholder="https://example.com/connect.php"
:class="inputClass"
/>
<p class="text-[10px] text-text-disabled px-1 mt-2">
{{
locale.aggregateEndpointDesc ||
'兼容彩虹聚合登录协议的服务端 connect.php 地址;公网应使用 HTTPS,可信内网可使用 HTTP。'
}}
</p>
</div>
<div class="pt-2 border-t border-border-secondary/60">
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 bg-error-5 border border-error-15 rounded-xl"
>
<div>
<p class="text-xs font-bold text-error">
{{ locale.clearBindingsTitle || '清除所有聚合登录绑定' }}
</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">
{{
locale.clearBindingsDesc ||
'更换聚合登录提供商时使用,将清除系统中所有用户的聚合登录绑定关系并要求其重新绑定。'
}}
</p>
</div>
<button
type="button"
:disabled="clearingBindings"
class="shrink-0 px-3.5 py-2 bg-error-10 hover:bg-error-20 text-error border border-error-20 text-xs font-bold rounded-xl transition-all flex items-center gap-1.5 self-start sm:self-auto disabled:opacity-50 disabled:cursor-not-allowed"
@click="showClearConfirm = true"
>
<Trash2 :size="13" />
{{ locale.clearBindingsBtn || '清除绑定' }}
</button>
</div>
</div>
</template>
</AdminProviderConfigSection>
<!-- Custom OAuth2 -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-xs font-bold text-text-tertiary uppercase tracking-widest">{{ locale.customOAuth }}</h4>
<div class="flex items-center gap-2">
<span
:class="[
'text-[10px] font-bold',
formData.customOAuthEnabled ? 'text-success' : 'text-error'
]"
>
{{ formData.customOAuthEnabled ? locale.enabled : locale.disabled }}
</span>
<input
v-model="formData.customOAuthEnabled"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary accent-green-600 cursor-pointer"
/>
</div>
</div>
<div v-if="formData.customOAuthEnabled" class="space-y-4">
<div>
<label :class="labelClass">{{ locale.displayName }}</label>
<input
v-model="formData.customOAuthDisplayName"
type="text"
:placeholder="locale.displayNamePlaceholder"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.authorizeUrl }}</label>
<input
v-model="formData.customOAuthAuthorizeUrl"
type="text"
placeholder="https://oauth.example.com/authorize"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.tokenUrl }}</label>
<input
v-model="formData.customOAuthTokenUrl"
type="text"
placeholder="https://oauth.example.com/token"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.userInfoUrl }}</label>
<input
v-model="formData.customOAuthUserInfoUrl"
type="text"
placeholder="https://oauth.example.com/userinfo"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.scope }}</label>
<input
v-model="formData.customOAuthScope"
type="text"
placeholder="openid profile email"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.clientId }}</label>
<input
v-model="formData.customOAuthClientId"
type="text"
:placeholder="locale.clientIdPlaceholder"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.clientSecret }}</label>
<div class="flex gap-2">
<input
v-model="formData.customOAuthClientSecret"
:type="showSecrets.custom ? 'text' : 'password'"
:placeholder="showSecrets.custom ? locale.clientSecretPlaceholder : '••••••••••••••••'"
:class="inputClass"
/>
<button
type="button"
class="px-4 py-2.5 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary text-xs font-bold rounded-xl transition-all"
@click="showSecrets.custom = !showSecrets.custom"
>
{{ showSecrets.custom ? locale.hide : locale.show }}
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label :class="labelClass">{{ locale.userIdField }}</label>
<input
v-model="formData.customOAuthUserIdField"
type="text"
placeholder="sub"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.usernameField }}</label>
<input
v-model="formData.customOAuthUsernameField"
type="text"
placeholder="preferred_username"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.nameField }}</label>
<input
v-model="formData.customOAuthNameField"
type="text"
placeholder="name"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.emailField }}</label>
<input
v-model="formData.customOAuthEmailField"
type="text"
placeholder="email"
:class="inputClass"
/>
</div>
<div class="md:col-span-2">
<label :class="labelClass">{{ locale.avatarField }}</label>
<input
v-model="formData.customOAuthAvatarField"
type="text"
placeholder="picture"
:class="inputClass"
/>
</div>
</div>
</div>
</div>
<!-- 信息提示 -->
<div
class="mt-6 p-4 bg-warning-5 border border-warning-10 rounded-xl flex items-start gap-3"
>
<AlertCircle class="text-warning shrink-0 mt-0.5" :size="14" />
<div class="text-[10px] text-text-tertiary leading-relaxed space-y-1">
<p>
{{ locale.brokerHintPrefix }}
<a href="https://github.com/laoshuikaixue/VoiceHub-Auth-Broker" target="_blank" class="text-primary hover:underline">VoiceHub-Auth-Broker</a>
{{ locale.brokerHintSuffix }}
</p>
</div>
</div>
<!-- 清除聚合登录绑定二次确认弹窗 -->
<ConfirmDialog
v-model:show="showClearConfirm"
type="danger"
:title="locale.clearBindingsConfirmTitle || '确认清除所有聚合登录绑定?'"
:message="
locale.clearBindingsConfirmMessage ||
'此操作将永久清除系统中所有用户的聚合登录绑定关系。\n若更换了聚合登录提供商,旧数据已失效,清除后用户需重新绑定方可通过聚合登录访问账号。\n此操作不可撤销,是否继续?'
"
:confirm-text="locale.clearBindingsBtn || '清除绑定'"
:loading="clearingBindings"
@confirm="handleClearBindings"
@cancel="showClearConfirm = false"
/>
</section>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { AlertCircle, Shield, Download, Trash2 } from '@lucide/vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
import { useServerErrors, useLocaleText } from '~/composables/useLocaleText'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import {
AGGREGATE_OAUTH_LOGIN_TYPE_OPTIONS,
getAggregateOAuthLoginTypesOrDefault
} from '~/utils/oauth'
const props = defineProps({
modelValue: {
type: Object,
required: true
}
})
const emits = defineEmits(['update:modelValue'])
const { showToast } = useToast()
const { admin, siteConfig: siteLocale } = useLocale()
const { localize: localizeServerError } = useServerErrors()
const locale = computed(() => admin.value?.oauthConfig || {})
const { t: callLocale } = useLocaleText(locale)
const getLogMessage = (key) => locale.value?.logs?.[key] || key
const inputClass =
'w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:border-primary-30 transition-all placeholder:text-text-primary'
const labelClass = 'text-[10px] font-black text-text-disabled uppercase tracking-widest px-1 block mb-2'
const cardClass = 'bg-bg-secondary-40 border border-border-secondary rounded-2xl p-6 shadow-xl space-y-6'
const showSecrets = ref({
state: false,
custom: false
})
const showClearConfirm = ref(false)
const clearingBindings = ref(false)
const formData = computed({
get: () => props.modelValue,
set: (val) => emits('update:modelValue', val)
})
const envData = ref({
hasBaseConfig: false,
hasGithubConfig: false,
hasCasdoorConfig: false,
hasGoogleConfig: false,
hasAggregateConfig: false
})
const oauthProviders = computed(() => [
{
id: 'github',
title: locale.value?.githubTitle || 'GitHub OAuth',
hasEnvConfig: envData.value.hasGithubConfig,
enabledKey: 'githubOAuthEnabled',
clientIdKey: 'githubClientId',
clientSecretKey: 'githubClientSecret',
clientIdLabel: locale.value?.githubClientId || 'GitHub Client ID',
clientIdPlaceholder: locale.value?.githubClientIdPlaceholder || 'Enter GitHub Client ID',
clientSecretLabel: locale.value?.githubClientSecret || 'GitHub Client Secret',
clientSecretPlaceholder: locale.value?.githubClientSecretPlaceholder || 'Enter GitHub Client Secret',
},
{
id: 'casdoor',
title: locale.value?.casdoorTitle || 'Casdoor OAuth',
hasEnvConfig: envData.value.hasCasdoorConfig,
enabledKey: 'casdoorOAuthEnabled',
clientIdKey: 'casdoorClientId',
clientSecretKey: 'casdoorClientSecret',
clientIdLabel: locale.value?.casdoorClientId || 'Casdoor Client ID',
clientIdPlaceholder: locale.value?.clientIdPlaceholder || 'Enter Client ID',
clientSecretLabel: locale.value?.casdoorClientSecret || 'Casdoor Client Secret',
clientSecretPlaceholder: locale.value?.clientSecretPlaceholder || 'Enter Client Secret',
},
{
id: 'google',
title: locale.value?.googleTitle || 'Google OAuth',
hasEnvConfig: envData.value.hasGoogleConfig,
enabledKey: 'googleOAuthEnabled',
clientIdKey: 'googleClientId',
clientSecretKey: 'googleClientSecret',
clientIdLabel: locale.value?.googleClientId || 'Google Client ID',
clientIdPlaceholder: locale.value?.googleClientIdPlaceholder || 'Enter Google Client ID',
clientSecretLabel: locale.value?.googleClientSecret || 'Google Client Secret',
clientSecretPlaceholder: locale.value?.googleClientSecretPlaceholder || 'Enter Google Client Secret',
},
{
id: 'aggregate',
title: locale.value?.aggregateTitle || '聚合登录',
hasEnvConfig: envData.value.hasAggregateConfig,
enabledKey: 'aggregateOAuthEnabled',
clientIdKey: 'aggregateOAuthAppId',
clientSecretKey: 'aggregateOAuthAppKey',
clientIdLabel: 'AppID',
clientIdPlaceholder: locale.value?.aggregateClientIdPlaceholder || '输入聚合登录 AppID',
clientSecretLabel: 'AppKey',
clientSecretPlaceholder:
locale.value?.aggregateClientSecretPlaceholder || '输入聚合登录 AppKey'
}
])
const aggregateLoginTypes = computed(() =>
AGGREGATE_OAUTH_LOGIN_TYPE_OPTIONS.map((option) => ({
...option,
label: locale.value?.aggregateLoginTypes?.[option.value] || option.label
}))
)
const fetchEnvData = async () => {
try {
const data = await $fetch('/api/admin/system-settings/env-oauth')
envData.value = data
} catch (e) {
console.error(getLogMessage('fetchEnvFailed'), e)
}
}
const importEnvData = async (provider) => {
try {
const data = await $fetch('/api/admin/system-settings/env-oauth-import', {
method: 'POST',
body: { provider }
})
const importedData = { ...data }
if (provider === 'aggregate') {
importedData.aggregateOAuthLoginType = getAggregateOAuthLoginTypesOrDefault(
data.aggregateOAuthLoginType
)
}
formData.value = {
...formData.value,
...importedData
}
} catch (e) {
console.error(getLogMessage('importEnvFailed'), e)
showToast(locale.value?.importFailed || 'OAuth 配置导入失败', 'error')
}
}
const handleClearBindings = async () => {
clearingBindings.value = true
try {
const res = await $fetch('/api/admin/system-settings/clear-aggregate-bindings', {
method: 'POST'
})
showClearConfirm.value = false
showToast(callLocale('clearBindingsSuccess', `已成功清除 ${res.count} 条聚合登录绑定数据(影响 ${res.usersAffected} 位用户)`, res.count, res.usersAffected), 'success')
} catch (err) {
console.error('清除聚合登录绑定失败:', err)
showToast(
localizeServerError(err) || locale.value?.clearBindingsFailed || '清除聚合登录绑定失败',
'error'
)
} finally {
clearingBindings.value = false
}
}
onMounted(() => {
fetchEnvData()
})
</script>
+461
View File
@@ -0,0 +1,461 @@
<template>
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<!-- 统计卡片网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6">
<div
v-for="(stat, i) in statCards"
:key="i"
class="bg-bg-secondary-40 border border-border-secondary rounded-2xl p-6 relative overflow-hidden group hover:border-border-tertiary transition-all shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
>
<div class="flex justify-between items-start mb-4">
<div
:class="[
'p-3 rounded-xl border flex items-center justify-center',
stat.color === 'blue'
? 'bg-primary-10 text-primary border-primary-20'
: stat.color === 'emerald'
? 'bg-success-10 text-success border-success-20'
: stat.color === 'pink'
? 'bg-color-pink-alpha-10 text-color-pink border-color-pink-alpha-10'
: stat.color === 'warning'
? 'bg-warning-10 text-warning border-warning-20'
: 'bg-bg-quaternary-10 text-text-tertiary border-border-tertiary-20'
]"
>
<component :is="stat.icon" :size="24" />
</div>
<div
v-if="stat.trend"
:class="[
'flex items-center gap-1 text-[11px] font-bold',
stat.trendDown ? 'text-error' : 'text-success'
]"
>
<TrendingDown v-if="stat.trendDown" :size="12" />
<TrendingUp v-else :size="12" />
{{ stat.trend }}
</div>
</div>
<div>
<p class="text-text-tertiary text-sm font-medium">{{ stat.label }}</p>
<h4 class="text-3xl font-bold tracking-tight text-text-primary">{{ stat.value }}</h4>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- 最近活动 -->
<div
class="lg:col-span-5 bg-bg-secondary-40 border border-border-secondary rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
>
<div class="px-6 py-5 border-b border-border-secondary flex items-center justify-between">
<h3 class="text-lg font-bold flex items-center gap-2">
<Activity :size="18" class="text-primary" /> {{ locale.recentActivities }}
</h3>
<button
class="p-2 text-text-tertiary hover:text-text-secondary transition-colors"
:disabled="loadingActivities"
@click="refreshActivities"
>
<RefreshCw v-if="!loadingActivities" :size="16" />
<Loader2 v-else :size="16" class="animate-spin" />
</button>
</div>
<div
class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1 min-h-[380px] max-h-[500px]"
>
<div
v-if="loadingActivities && recentActivities.length === 0"
class="flex flex-col items-center justify-center h-full text-text-tertiary gap-3 py-20"
>
<Loader2 :size="24" class="animate-spin" />
<span class="text-sm">{{ locale.loading }}</span>
</div>
<div
v-else-if="recentActivities.length === 0"
class="flex flex-col items-center justify-center h-full text-text-tertiary gap-3 py-20"
>
<Inbox :size="24" />
<span class="text-sm">{{ locale.noActivities }}</span>
</div>
<template v-else>
<div
v-for="(activity, idx) in recentActivities"
:key="idx"
class="flex items-start gap-4 p-4 rounded-2xl hover:bg-bg-tertiary-40 transition-all cursor-pointer group"
>
<div
:class="[
'shrink-0 w-10 h-10 rounded-xl flex items-center justify-center border shadow-sm',
getActivityStyle(activity.type).bg
]"
>
<component :is="getActivityStyle(activity.type).icon" :size="18" />
</div>
<div class="flex-1 min-w-0">
<h5
class="font-bold text-sm text-text-primary group-hover:text-primary transition-colors"
>
{{ activity.title }}
</h5>
<p class="text-xs text-text-tertiary truncate mt-1">{{ activity.description }}</p>
<div class="flex items-center gap-1.5 mt-2">
<Clock :size="10" class="text-text-disabled" />
<span class="text-[10px] text-text-disabled font-medium uppercase tracking-wider">{{
formatTime(activity.createdAt)
}}</span>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 系统状态 -->
<div
class="lg:col-span-4 bg-bg-secondary-40 border border-border-secondary rounded-xl overflow-hidden flex flex-col shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
>
<div class="px-6 py-5 border-b border-border-secondary flex items-center justify-between">
<h3 class="text-lg font-bold flex items-center gap-2">
<ShieldCheck :size="18" class="text-success" /> {{ locale.systemStatus }}
</h3>
<span
:class="[
'px-3 py-1 text-[10px] font-black uppercase rounded-full border',
systemStatus.online
? 'bg-success-10 text-success border-success-20'
: 'bg-error-10 text-error border-error-20'
]"
>
{{ systemStatus.online ? locale.online : locale.offline }}
</span>
</div>
<div class="p-6 space-y-6">
<div
v-for="(status, i) in statusItems"
:key="i"
class="flex items-center justify-between group"
>
<div class="flex items-center gap-3">
<div
:class="[
'w-1.5 h-1.5 rounded-full',
status.active
? 'bg-success shadow-[0_0_10px_var(--success-70)]'
: 'bg-bg-quaternary'
]"
/>
<span class="text-xs font-semibold text-text-tertiary">{{ status.label }}</span>
</div>
<span class="text-xs font-bold text-text-primary">{{ status.value }}</span>
</div>
</div>
<div class="mt-auto border-t border-border-secondary px-6 py-4 flex flex-col items-center justify-center gap-1 text-center">
<span class="text-[10px] font-medium uppercase tracking-[0.3em] text-text-disabled">
{{ locale.instanceId }}
</span>
<button
type="button"
class="max-w-full text-xs text-text-tertiary hover:text-text-secondary transition-colors break-all leading-relaxed"
:title="instanceId || locale.noInstanceId"
:disabled="!instanceId"
@click="copyInstanceId"
>
{{ instanceId || locale.noInstanceId }}
</button>
</div>
</div>
<!-- 快速操作 -->
<div
class="lg:col-span-3 bg-bg-secondary-40 border border-border-secondary rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
>
<div class="px-6 py-5 border-b border-border-secondary">
<h3 class="text-lg font-bold flex items-center gap-2">
<Zap :size="18" class="text-warning" /> {{ locale.quickActions }}
</h3>
</div>
<div class="p-6 space-y-3">
<button
v-for="(action, i) in quickActions"
:key="i"
:class="[
'w-full flex items-center gap-3 px-5 py-4 rounded-lg border font-bold text-sm transition-all text-left group',
action.primary
? 'bg-primary-hover border-primary text-text-primary shadow-xl shadow-[var(--primary-glow)] hover:bg-primary'
: 'bg-bg-primary-40 border-border-secondary text-text-tertiary hover:border-border-tertiary hover:text-text-primary'
]"
@click="navigateTo(action.id)"
>
<component :is="action.icon" :size="18" />
{{ action.label }}
<ExternalLink v-if="action.primary" :size="14" class="ml-auto opacity-50" />
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import {
Activity,
Ban,
Bell,
Calendar,
Clock,
ExternalLink,
Heart,
Inbox,
Loader2,
Music,
RefreshCw,
ShieldCheck,
TrendingDown,
TrendingUp,
Users,
Zap
} from '@lucide/vue'
import packageJson from '~~/package.json'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
const emit = defineEmits(['navigate'])
const { success: showSuccess, error: showError } = useToast()
const { admin } = useLocale()
const locale = computed(() => useSafeLocale(admin.value?.overview || {}))
const { format: formatLocaleValue } = useLocaleText(locale)
const systemVersion = ref(packageJson.version)
const stats = ref({
totalSongs: 0,
totalUsers: 0,
todaySchedules: 0,
weeklyRequests: 0,
songsChange: 0,
usersChange: 0,
requestsChange: 0,
totalSchedules: 0,
currentSemester: '',
blacklistCount: 0
})
const recentActivities = ref([])
const loadingActivities = ref(false)
const instanceId = ref('')
const systemStatus = ref({
online: true,
database: true,
api: true
})
// 统计卡片数据
const statCards = computed(() => [
{
label: locale.value.statCards?.totalSongs || '总歌曲数',
value: formatNumber(stats.value.totalSongs),
icon: Music,
color: 'blue',
trend: stats.value.songsChange !== 0 ? `${Math.abs(stats.value.songsChange)}%` : null,
trendDown: stats.value.songsChange < 0
},
{
label: locale.value.statCards?.registeredUsers || '注册用户',
value: formatNumber(stats.value.totalUsers),
icon: Users,
color: 'emerald'
},
{
label: locale.value.statCards?.todaySchedules || '今日排班',
value: formatNumber(stats.value.todaySchedules),
icon: Calendar,
color: 'warning'
},
{
label: locale.value.statCards?.weeklyRequests || '本周点歌',
value: formatNumber(stats.value.weeklyRequests),
icon: Heart,
color: 'pink',
trend: stats.value.requestsChange !== 0 ? `${Math.abs(stats.value.requestsChange)}%` : null,
trendDown: stats.value.requestsChange < 0
}
])
// 系统状态项
const statusItems = computed(() => [
{
label: locale.value.statusItems?.database || '数据库',
value: systemStatus.value.database ? locale.value.normal || '正常' : locale.value.abnormal || '异常',
active: systemStatus.value.database
},
{
label: locale.value.statusItems?.api || 'API',
value: systemStatus.value.api ? locale.value.normal || '正常' : locale.value.abnormal || '异常',
active: systemStatus.value.api
},
{
label: locale.value.statusItems?.semester || '当前学期',
value: stats.value.currentSemester || locale.value.unset || '未设置',
active: !!stats.value.currentSemester
},
{
label: locale.value.statusItems?.blacklist || '黑名单',
value: formatLocaleValue(locale.value.itemUnit, String(stats.value.blacklistCount), stats.value.blacklistCount),
active: stats.value.blacklistCount >= 0
},
{ label: locale.value.statusItems?.version || '版本', value: `v${systemVersion.value}`, active: true }
])
// 快速操作
const quickActions = computed(() => [
{ label: locale.value.actions?.schedule || '排班管理', icon: Calendar, id: 'schedule', primary: true },
{ label: locale.value.actions?.users || '用户管理', icon: Users, id: 'users' },
{ label: locale.value.actions?.notifications || '通知管理', icon: Bell, id: 'notifications' },
{ label: locale.value.actions?.blacklist || '黑名单', icon: Ban, id: 'blacklist' }
])
const formatNumber = (num) => {
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
return num.toString()
}
const getActivityStyle = (type) => {
const styles = {
song: { icon: Music, bg: 'bg-info-10 text-info border-info-20' },
user: { icon: Users, bg: 'bg-color-pink-alpha-10 text-color-pink border-color-pink-alpha-20' },
schedule: { icon: Calendar, bg: 'bg-primary-10 text-primary border-primary-20' }
}
return styles[type] || { icon: Activity, bg: 'bg-bg-quaternary-10 text-text-tertiary border-border-tertiary-20' }
}
const loadStats = async () => {
try {
const response = await $fetch('/api/admin/stats', {
...useAuth().getAuthConfig()
})
stats.value = response
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
const loadSystemStatus = async () => {
try {
const response = await $fetch('/api/system/status')
systemStatus.value.online = response?.status === 'ok'
systemStatus.value.database = !!response?.database?.connected
systemStatus.value.api = response?.status === 'ok'
instanceId.value = response?.instance?.instanceId || ''
} catch (error) {
console.error('加载系统状态失败:', error)
systemStatus.value.online = false
systemStatus.value.database = false
systemStatus.value.api = false
instanceId.value = ''
}
}
const copyToClipboard = async (text) => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return true
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const copied = document.execCommand('copy')
document.body.removeChild(textarea)
return copied
}
const copyInstanceId = async () => {
if (!instanceId.value) {
showError(locale.value.noInstanceId || '暂无实例 ID')
return
}
try {
const copied = await copyToClipboard(instanceId.value)
if (copied) {
showSuccess(locale.value.copied || '已复制')
} else {
showError(locale.value.copyFailed || '复制失败')
}
} catch (error) {
console.error('复制实例 ID 失败:', error)
showError(locale.value.copyFailed || '复制失败')
}
}
const loadRecentActivities = async () => {
loadingActivities.value = true
try {
const response = await $fetch('/api/admin/activities', {
...useAuth().getAuthConfig()
})
recentActivities.value = response.activities || []
} catch (error) {
console.error('加载活动记录失败:', error)
recentActivities.value = []
} finally {
loadingActivities.value = false
}
}
const refreshActivities = () => {
loadRecentActivities()
}
const formatTime = (dateString) => {
const date = new Date(dateString)
const now = getSyncedDate()
const diff = now - date
if (diff < 60000) return locale.value?.justNow || '刚刚'
if (diff < 3600000) {
const minutes = Math.floor(diff / 60000)
return formatLocaleValue(locale.value?.minutesAgo, `${minutes}分钟前`, minutes)
}
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000)
return formatLocaleValue(locale.value?.hoursAgo, `${hours}小时前`, hours)
}
const days = Math.floor(diff / 86400000)
return formatLocaleValue(locale.value?.daysAgo, `${days}天前`, days)
}
const navigateTo = (tab) => {
emit('navigate', tab)
}
onMounted(() => {
loadStats()
loadSystemStatus()
loadRecentActivities()
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--panel-bg-alt);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--panel-bg-hover);
}
</style>
+712
View File
@@ -0,0 +1,712 @@
<template>
<div class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2">
<!-- 页面标题 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary mt-1">
{{ locale.desc }}
</p>
</div>
<div class="flex items-center gap-3">
<div
class="bg-bg-secondary-40 border border-border-secondary rounded-xl px-4 py-2 flex items-center gap-3"
>
<span class="text-[10px] font-black text-text-tertiary uppercase tracking-widest"
>{{ locale.enableSelection }}</span
>
<button
:class="[
'relative w-10 h-5 rounded-full transition-colors',
enablePlayTimeSelection ? 'bg-primary-hover' : 'bg-bg-tertiary'
]"
@click="toggleGlobalEnabled"
>
<div
:class="[
'absolute top-1 w-3 h-3 bg-bg-secondary rounded-full transition-all',
enablePlayTimeSelection ? 'left-6' : 'left-1'
]"
/>
</button>
</div>
<button
class="flex items-center gap-2 px-6 py-2.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-lg shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
@click="openAddForm"
>
<Plus :size="14" /> {{ locale.add }}
</button>
</div>
</div>
<!-- 信息卡片 -->
<div class="bg-primary-hover-5 border border-primary-10 rounded-xl p-5 flex items-start gap-4">
<Info class="text-primary shrink-0 mt-0.5" :size="18" />
<div class="space-y-1">
<p class="text-xs font-bold text-text-secondary">{{ locale.aboutTitle }}</p>
<p class="text-[11px] text-text-tertiary leading-relaxed">
{{ locale.aboutDesc }}
</p>
</div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
class="flex flex-col items-center justify-center py-20 space-y-4 bg-bg-secondary-20 border border-border-secondary-50 rounded-xl"
>
<AppSpinner :size="40" />
<p class="text-xs font-black text-text-tertiary uppercase tracking-widest">{{ locale.loading }}</p>
</div>
<!-- 错误状态 -->
<div
v-else-if="error"
class="bg-error-5 border border-error-10 rounded-xl p-5 flex items-start gap-4"
>
<AlertCircle class="text-error shrink-0 mt-0.5" :size="18" />
<div class="space-y-1">
<p class="text-xs font-bold text-text-secondary">{{ locale.fetchFailed }}</p>
<p class="text-[11px] text-text-tertiary leading-relaxed">{{ error }}</p>
</div>
</div>
<!-- 空状态 -->
<div
v-else-if="playTimes.length === 0"
class="flex flex-col items-center justify-center py-20 space-y-6 bg-bg-secondary-20 border border-border-secondary-50 rounded-xl"
>
<div class="p-6 rounded-xl bg-bg-primary border border-border-secondary text-text-secondary">
<Clock :size="48" />
</div>
<div class="text-center space-y-2">
<h3 class="text-lg font-bold text-text-primary">{{ locale.emptyTitle }}</h3>
<p class="text-xs text-text-tertiary">{{ locale.emptyDesc }}</p>
</div>
</div>
<!-- 时段网格 -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<TransitionGroup name="list">
<div
v-for="playTime in playTimes"
:key="playTime.id"
:class="[
'group relative bg-bg-secondary-30 border rounded-xl p-8 transition-all hover:shadow-2xl hover:shadow-[0_25px_50px_var(--shadow-color-deep)]',
playTime.enabled ? 'border-border-secondary-80' : 'border-border-secondary-40 opacity-60'
]"
>
<div class="flex items-start justify-between mb-6">
<div
:class="[
'p-3 rounded-xl bg-bg-primary border border-border-secondary transition-all flex items-center justify-center',
playTime.enabled ? 'text-primary border-primary-20' : 'text-text-secondary'
]"
>
<Clock :size="20" />
</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',
playTime.enabled
? 'bg-success-10 text-success border-success-20'
: 'bg-bg-tertiary-50 text-text-disabled border-border-tertiary-50'
]"
>
{{ playTime.enabled ? locale.enabled : locale.disabled }}
</span>
<button class="p-1.5 text-text-secondary hover:text-text-tertiary">
<MoreVertical :size="14" />
</button>
</div>
</div>
<div class="space-y-4">
<div>
<h4
class="text-lg font-black text-text-primary group-hover:text-primary transition-colors"
>
{{ playTime.name }}
</h4>
<div
class="flex items-center gap-2 mt-1.5 text-primary-80 font-black tracking-tighter"
>
<span class="text-xl">{{ playTime.startTime || locale.unlimited }}</span>
<div class="w-4 h-[2px] bg-bg-tertiary" />
<span class="text-xl">{{ playTime.endTime || locale.unlimited }}</span>
</div>
</div>
<p class="text-xs text-text-tertiary font-medium leading-relaxed min-h-[32px] line-clamp-2">
{{ playTime.description || locale.noDescription }}
</p>
</div>
<div class="mt-8 pt-6 border-t border-border-secondary-50 flex items-center justify-between">
<div class="flex gap-2">
<button
class="p-2.5 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-primary hover:border-primary-30 transition-all"
@click="editPlayTime(playTime)"
>
<Edit2 :size="14" />
</button>
<button
class="p-2.5 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-error hover:border-error-30 transition-all"
@click="confirmDelete(playTime)"
>
<Trash2 :size="14" />
</button>
</div>
<button
:class="[
'flex items-center gap-2 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
playTime.enabled
? 'bg-bg-tertiary text-text-tertiary hover:text-text-primary'
: 'bg-success text-text-primary shadow-lg shadow-[var(--success-glow-20)]'
]"
@click="togglePlayTimeStatus(playTime)"
>
<Power :size="12" />
{{ playTime.enabled ? locale.disable : locale.enable }}
</button>
</div>
</div>
</TransitionGroup>
</div>
<!-- 添加/编辑弹窗 -->
<Transition name="modal">
<div
v-if="showAddForm || editingPlayTime"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="cancelForm" />
<div
class="relative w-full max-w-lg bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
>
<div class="p-8">
<div class="flex items-center justify-between mb-8">
<h3 class="text-xl font-black text-text-primary tracking-tight">
{{ editingPlayTime ? locale.editTitle : locale.addTitle }}
</h3>
<button
class="p-2 text-text-tertiary hover:text-text-primary transition-colors"
@click="cancelForm"
>
<X :size="20" />
</button>
</div>
<div class="space-y-6">
<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-lg px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30"
>
</div>
<div class="grid 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.startTimeOptional }}</label
>
<div class="relative">
<input
v-model="formData.startTime"
type="time"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30 appearance-none"
>
<Clock
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none"
:size="14"
/>
</div>
<p class="text-[9px] text-text-disabled px-1">{{ locale.startTimeHint }}</p>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.endTimeOptional }}</label
>
<div class="relative">
<input
v-model="formData.endTime"
type="time"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30 appearance-none"
>
<Clock
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none"
:size="14"
/>
</div>
<p class="text-[9px] text-text-disabled px-1">{{ locale.endTimeHint }}</p>
</div>
</div>
<div class="space-y-2">
<label
class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1 flex items-center gap-2"
>
<AlignLeft :size="10" /> {{ locale.descriptionOptional }}
</label>
<textarea
v-model="formData.description"
:placeholder="locale.descriptionPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-4 text-sm text-text-primary focus:outline-none focus:border-primary-30 min-h-[100px] resize-none"
/>
</div>
<label class="flex items-center gap-3 cursor-pointer group px-1">
<input
v-model="formData.enabled"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-primary"
>
<span
class="text-xs font-bold text-text-secondary group-hover:text-primary transition-colors"
>{{ locale.enableThisPlayTime }}</span
>
</label>
<div
v-if="formError"
class="bg-error-10 border border-error-20 rounded-lg p-3 text-[11px] text-error"
>
{{ formError }}
</div>
</div>
</div>
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary flex justify-end gap-3">
<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-8 py-2.5 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs font-black rounded-lg shadow-lg transition-all active:scale-95"
@click="savePlayTime"
>
{{ formSubmitting ? locale.saving : locale.saveSettings }}
</button>
</div>
</div>
</div>
</Transition>
<!-- 删除确认弹窗 -->
<Transition name="modal">
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm"
@click="showDeleteConfirm = false"
/>
<div
class="relative w-full max-w-sm bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
>
<div class="p-8">
<div class="flex flex-col items-center py-4 space-y-6">
<div
class="w-16 h-16 rounded-xl bg-error-10 text-error flex items-center justify-center border border-error-10 shadow-xl shadow-[var(--error-glow-5)]"
>
<AlertCircle :size="32" />
</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.deleteConfirmDesc }}
</p>
</div>
<div class="flex gap-3 w-full pt-4">
<button
class="flex-1 px-4 py-3 bg-bg-primary border border-border-secondary text-text-tertiary text-xs font-black rounded-lg transition-all hover:bg-bg-tertiary"
@click="showDeleteConfirm = false"
>
{{ locale.cancel }}
</button>
<button
:disabled="deleteInProgress"
class="flex-1 px-4 py-3 bg-error hover:bg-error disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs font-black rounded-lg shadow-xl shadow-[var(--error-glow-20)] transition-all active:scale-95"
@click="deletePlayTime"
>
{{ deleteInProgress ? locale.deleting : locale.confirmDelete }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useAuth } from '~/composables/useAuth'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
import type { PlayTime } from '~/types'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import {
Plus,
Clock,
Edit2,
Trash2,
MoreVertical,
Power,
Info,
AlertCircle,
X,
AlignLeft
} from '@lucide/vue'
const { getAuthConfig, isAdmin } = useAuth()
const { showToast: showNotification } = useToast()
const { admin } = useLocale()
const locale = computed(() => {
const base = admin.value?.playTimeManager || {}
const emptyText = () => ''
return useSafeLocale({
...base,
errors: { ...(base.errors || {}) },
messages: { ...(base.messages || {}) },
deleteConfirmTitle: base.deleteConfirmTitle || emptyText
})
})
const playTimes = ref<PlayTime[]>([])
const loading = ref(false)
const error = ref('')
const showAddForm = ref(false)
const editingPlayTime = ref<PlayTime | null>(null)
const playTimeToDelete = ref<PlayTime | null>(null)
const showDeleteConfirm = ref(false)
const formSubmitting = ref(false)
const deleteInProgress = ref(false)
const formError = ref('')
const enablePlayTimeSelection = ref(false)
const deleteConfirmTitleText = computed(() => {
const title = locale.value.deleteConfirmTitle
const name = playTimeToDelete.value?.name || ''
if (typeof title === 'function') return title(name)
return (title || '').replace('{0}', name)
})
// 表单数据
const formData = reactive({
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true
})
// 初始化
onMounted(async () => {
await fetchPlayTimes()
await fetchSystemSettings()
})
// 获取播出时段列表
const fetchPlayTimes = async () => {
if (!isAdmin.value) {
error.value = locale.value.errors.adminOnly
return
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/play-times', {
...authConfig
})
// 自定义排序:先按启用状态排序,然后有时间的排在前面,没有时间的排在后面
playTimes.value = data.sort((a: PlayTime, b: PlayTime) => {
// 首先按启用状态排序
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 = getErrorMessage(err) || locale.value.errors.fetchPlayTimesFailed
} finally {
loading.value = false
}
}
// 获取系统设置
const fetchSystemSettings = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/system-settings', {
...authConfig
})
enablePlayTimeSelection.value = data.enablePlayTimeSelection
} catch (err: any) {
console.error('获取系统设置失败:', getErrorMessage(err))
}
}
// 切换全局启用状态
const toggleGlobalEnabled = async () => {
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
await updateSystemSettings()
}
// 更新系统设置
const updateSystemSettings = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
await $fetch('/api/admin/system-settings', {
method: 'POST',
body: {
enablePlayTimeSelection: enablePlayTimeSelection.value
},
...authConfig
})
showNotification(locale.value.messages.systemSettingsUpdated, 'success')
} catch (err: any) {
error.value = getErrorMessage(err) || locale.value.errors.updateSystemSettingsFailed
showNotification(error.value, 'error')
// 如果失败,恢复状态
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
}
}
// 打开添加表单
const openAddForm = () => {
showAddForm.value = true
Object.assign(formData, {
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true
})
}
// 编辑播出时段
const editPlayTime = (playTime: PlayTime) => {
editingPlayTime.value = playTime
Object.assign(formData, {
id: playTime.id,
name: playTime.name,
startTime: playTime.startTime || '',
endTime: playTime.endTime || '',
description: playTime.description || '',
enabled: playTime.enabled
})
}
// 切换播出时段状态
const togglePlayTimeStatus = async (playTime: PlayTime) => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
await $fetch(`/api/admin/play-times/${playTime.id}`, {
method: 'PATCH',
body: {
enabled: !playTime.enabled
},
...authConfig
})
// 更新本地数据
await fetchPlayTimes()
showNotification(
playTime.enabled
? (locale.value?.messages?.playTimeDisabled || 'Play time disabled')
: (locale.value?.messages?.playTimeEnabled || 'Play time enabled'),
'success'
)
} catch (err: any) {
error.value = getErrorMessage(err) || locale.value.errors.updatePlayTimeStatusFailed
showNotification(error.value, 'error')
}
}
// 确认删除
const confirmDelete = (playTime: PlayTime) => {
playTimeToDelete.value = playTime
showDeleteConfirm.value = true
}
// 删除播出时段
const deletePlayTime = async () => {
if (!playTimeToDelete.value || !isAdmin.value) return
deleteInProgress.value = true
try {
const authConfig = getAuthConfig()
await $fetch(`/api/admin/play-times/${playTimeToDelete.value.id}`, {
method: 'DELETE',
...authConfig
})
// 更新本地数据
await fetchPlayTimes()
showDeleteConfirm.value = false
playTimeToDelete.value = null
showNotification(locale.value?.messages?.playTimeDeleted || 'Play time deleted', 'success')
} catch (err: any) {
error.value = getErrorMessage(err) || locale.value.errors.deletePlayTimeFailed
showNotification(error.value, 'error')
} finally {
deleteInProgress.value = false
}
}
// 保存播出时段
const savePlayTime = 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 = !!editingPlayTime.value
const nameExists = playTimes.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()
await $fetch(isUpdate ? `/api/admin/play-times/${formData.id}` : '/api/admin/play-times', {
method: isUpdate ? 'PUT' : 'POST',
body: {
name: formData.name.trim(),
startTime: formData.startTime || null,
endTime: formData.endTime || null,
description: formData.description || null,
enabled: formData.enabled
},
...authConfig
})
// 更新本地数据
await fetchPlayTimes()
cancelForm()
showNotification(
isUpdate
? (locale.value?.messages?.playTimeUpdated || 'Play time updated')
: (locale.value?.messages?.playTimeCreated || 'Play time created'),
'success'
)
} catch (err: any) {
formError.value = getErrorMessage(err) || locale.value.errors.savePlayTimeFailed
showNotification(formError.value, 'error')
} finally {
formSubmitting.value = false
}
}
// 取消表单
const cancelForm = () => {
showAddForm.value = false
editingPlayTime.value = null
formError.value = ''
// 重置表单数据
Object.assign(formData, {
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true
})
}
</script>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: scale(0.95);
}
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .relative,
.modal-leave-active .relative {
transition: all 0.3s ease;
}
.modal-enter-from .relative,
.modal-leave-to .relative {
transform: scale(0.95);
opacity: 0;
}
</style>
@@ -0,0 +1,114 @@
<template>
<div class="space-y-4 mb-6 pb-6 border-b border-border-secondary">
<div class="flex items-center justify-between">
<h4 class="text-xs font-bold text-text-tertiary uppercase tracking-widest">{{ title }}</h4>
<div class="flex items-center gap-4">
<a
v-if="docUrl"
:href="docUrl"
target="_blank"
rel="noopener noreferrer"
class="text-[10px] px-2 py-1 bg-primary-10 text-primary hover:bg-primary-20 border border-primary-20 rounded-md transition-colors font-bold flex items-center gap-1"
>
{{ docLabel || '查看文档' }}
</a>
<button
v-if="hasEnvConfig"
type="button"
class="text-[10px] px-2 py-1 bg-primary-10 text-primary hover:bg-primary-20 border border-primary-20 rounded-md transition-colors font-bold flex items-center gap-1"
@click="$emit('import-env')"
>
<Download :size="12" />
{{ locale.importEnv }}
</button>
<div class="flex items-center gap-2">
<span
:class="[
'text-[10px] font-bold',
enabled ? 'text-success' : 'text-error'
]"
>
{{ enabled ? locale.enabled : locale.disabled }}
</span>
<input
:checked="enabled"
@change="$emit('update:enabled', $event.target.checked)"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary accent-green-600 cursor-pointer"
>
</div>
</div>
</div>
<div v-if="enabled" class="space-y-4">
<slot name="before-fields" />
<div>
<label :class="labelClass">{{ clientIdLabel }}</label>
<input
:value="clientId"
@input="$emit('update:clientId', $event.target.value)"
type="text"
:placeholder="clientIdPlaceholder"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">{{ clientSecretLabel }}</label>
<div class="flex gap-2">
<input
:value="clientSecret"
@input="$emit('update:clientSecret', $event.target.value)"
:type="showSecret ? 'text' : 'password'"
:placeholder="showSecret ? clientSecretPlaceholder : '••••••••••••••••'"
:class="inputClass"
>
<button
type="button"
class="px-4 py-2.5 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary text-xs font-bold rounded-xl transition-all"
@click="showSecret = !showSecret"
>
{{ showSecret ? locale.hide : locale.show }}
</button>
</div>
</div>
<slot name="after-fields" />
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { Download } from '@lucide/vue'
import { useLocale } from '~/utils/locale'
const props = defineProps({
title: String,
hasEnvConfig: Boolean,
enabled: Boolean,
clientId: String,
clientSecret: String,
clientIdLabel: String,
clientIdPlaceholder: String,
clientSecretLabel: String,
clientSecretPlaceholder: String,
docUrl: String,
docLabel: String,
})
const emit = defineEmits([
'import-env',
'update:enabled',
'update:clientId',
'update:clientSecret'
])
const showSecret = ref(false)
const { admin } = useLocale()
const locale = computed(() => admin.value?.oauthConfig || {})
const inputClass = 'w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:border-primary-30 transition-all placeholder:text-text-primary'
const labelClass = 'text-[10px] font-black text-text-disabled uppercase tracking-widest px-1 block mb-2'
</script>
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
<template>
<div
class="backdrop-blur-md p-6 rounded-xl border border-primary-10 bg-bg-tertiary-70 shadow-2xl max-w-[400px] mx-auto text-text-primary transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-[0_15px_30px_var(--modal-hover-shadow)]"
>
<h3 class="mb-6 pb-2 border-b border-primary-10 text-text-primary font-bold text-lg">
{{ scheduleTitle }}
</h3>
<form @submit.prevent="handleSubmit">
<div class="mb-4">
<label class="block mb-2 font-medium text-text-primary" for="playDate">{{ locale.playDate }}</label>
<input
id="playDate"
v-model="playDate"
class="w-full p-3 border border-primary-10 rounded-lg text-base bg-bg-tertiary-50 text-text-primary outline-none transition-colors duration-150 focus:border-primary focus:shadow-[0_0_0_2px_var(--primary-light)]"
required
type="date"
>
</div>
<!-- 播出时段选择 -->
<div v-if="playTimeEnabled" class="mb-4">
<CustomSelect
v-model="playTimeId"
:options="playTimeOptions"
:label="locale.playTime"
:placeholder="locale.unspecified"
class-name="w-full"
/>
<div
v-if="song?.preferredPlayTime"
class="mt-3 p-3 bg-bg-secondary-5 rounded-lg text-sm flex items-start gap-2"
>
<div class="text-base">💡</div>
<div>
{{ locale.preferredPlayTime }}
<span class="font-medium text-info">
{{ song.preferredPlayTime.name }}
<template v-if="song.preferredPlayTime.startTime || song.preferredPlayTime.endTime">
({{ formatPlayTimeRange(song.preferredPlayTime) }})
</template>
</span>
</div>
</div>
</div>
<div v-if="error" class="p-3 mt-4 bg-error-10 text-error rounded-lg">
{{ error }}
</div>
<div class="flex justify-between gap-4 mt-6">
<button
class="flex-1 p-3 border border-primary-10 rounded-lg text-base cursor-pointer transition-all duration-200 bg-bg-secondary-10 text-text-primary hover:bg-bg-secondary-15"
type="button"
@click="$emit('cancel')"
>
{{ commonLocale.cancel }}
</button>
<button
:disabled="loading"
class="flex-1 p-3 border-none rounded-lg text-base cursor-pointer transition-all duration-200 bg-info text-text-primary hover:bg-info hover:-translate-y-px disabled:bg-info-50 disabled:cursor-not-allowed disabled:transform-none"
type="submit"
>
{{ loading ? locale.creating : locale.create }}
</button>
</div>
</form>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue'
import { useSongs } from '~/composables/useSongs'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { useLocale } from '~/utils/locale'
const props = defineProps({
song: {
type: Object,
required: true
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['schedule', 'cancel'])
const playDate = ref('')
const playTimeId = ref('')
const error = ref('')
const playTimes = ref([])
const { playTimeEnabled } = useSongs()
const { common } = useLocale()
const commonLocale = computed(() => common.value || {})
const locale = computed(() => useSafeLocale(common.value?.scheduleForm || {}))
const scheduleTitle = computed(() => {
const title = locale.value?.title
if (typeof title === 'string') return title.replace(/{0}/g, props.song?.title || '')
if (typeof title === 'function') return title(props.song?.title || '')
return ''
})
// 转换播出时段为 CustomSelect 选项格式
const playTimeOptions = computed(() => {
const options = [{ label: locale.value.unspecified, value: '' }]
if (playTimes.value && playTimes.value.length > 0) {
playTimes.value.forEach((pt) => {
options.push({
label: `${pt.name} (${pt.startTime} - ${pt.endTime})`,
value: pt.id
})
})
}
return options
})
// 初始化
onMounted(async () => {
await fetchPlayTimes()
// 如果歌曲有期望的播出时段,默认选择该时段
if (props.song?.preferredPlayTimeId) {
playTimeId.value = props.song.preferredPlayTimeId
}
})
// 获取播出时段
const fetchPlayTimes = async () => {
try {
// 使用useSongs中的方法获取播放时段
await useSongs().fetchPlayTimes()
const response = await fetch('/api/admin/play-times')
if (response.ok) {
const data = await response.json()
// 只显示启用的播放时段
playTimes.value = data.filter((pt) => pt.enabled)
}
} catch (err) {
console.error('获取播出时段失败:', err)
}
}
// 格式化播出时段时间范围
const formatPlayTimeRange = (playTime) => {
if (!playTime) return ''
if (playTime.startTime && playTime.endTime) {
return `${playTime.startTime} - ${playTime.endTime}`
} else if (playTime.startTime) {
return formatLocale(locale.value.startAt, playTime.startTime) || playTime.startTime
} else if (playTime.endTime) {
return formatLocale(locale.value.endAt, playTime.endTime) || playTime.endTime
}
return locale.value.unlimited
}
const handleSubmit = () => {
error.value = ''
if (!playDate.value) {
error.value = locale.value.dateRequired
return
}
const selectedDate = new Date(playDate.value)
// 播出时段ID需要转换为数字或null
const schedulePlayTimeId = playTimeId.value ? parseInt(playTimeId.value) : null
emit('schedule', {
songId: props.song.id,
playDate: selectedDate,
playTimeId: schedulePlayTimeId
})
}
</script>
+437
View File
@@ -0,0 +1,437 @@
<template>
<div class="schedule-item-print" :class="{ 'is-narrow': narrow }">
<div class="item-content">
<div v-if="settings.showSequence || settings.showCover" class="item-leading">
<!-- 序号 -->
<div v-if="settings.showSequence" class="sequence-number">
{{ schedule.sequence || 1 }}
</div>
<!-- 歌曲封面 -->
<div v-if="settings.showCover" class="cover-section">
<img
v-if="schedule.song.cover"
:alt="schedule.song.title"
:data-original-src="convertToHttps(schedule.song.cover)"
:src="convertToHttps(schedule.song.cover)"
class="song-cover"
referrerpolicy="no-referrer"
@error="handleImageError"
>
<div class="cover-placeholder" :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" />
<path d="m21 12-6-3-6 3-6-3" />
</svg>
</div>
</div>
</div>
<!-- 歌曲信息 -->
<div class="song-info">
<div v-if="settings.showTitle" class="song-title">
<span class="title-text">{{ schedule.song.title }}</span>
<!-- 重播标识 -->
<span v-if="schedule.replayRequestId != null" class="replay-badge-print"> {{ locale.replay }} </span>
<!-- 跨学期标识 -->
<span
v-if="
settings.currentSemester &&
schedule.song.semester &&
schedule.song.semester !== settings.currentSemester
"
class="cross-semester-badge-print"
>
{{ locale.crossSemester }}
</span>
</div>
<div v-if="settings.showArtist" class="song-artist">
{{ schedule.song.artist }}
</div>
</div>
<div v-if="settings.showRequester || settings.showVotes" class="item-trailing">
<!-- 投稿人信息(重播歌曲不显示申请人,只显示原投稿人) -->
<div v-if="settings.showRequester" class="requester-info">
<span class="label">{{ locale.requesterPrefix }}</span>
<span class="value">
{{ schedule.song.requester }}
<span v-if="schedule.song.collaborators && schedule.song.collaborators.length > 0">
& {{ schedule.song.collaborators.map((c) => c.displayName || c.name).join(' & ') }}
</span>
</span>
</div>
<!-- 人数信息 -->
<div v-if="settings.showVotes" class="votes-info">
<span v-if="schedule.replayRequestId != null" class="label">{{ locale.replayRequestsPrefix }}</span>
<span v-else class="label">{{ locale.popularityPrefix }}</span>
<span class="value">{{
schedule.replayRequestId != null
? locale.replayCount(schedule.song.replayRequestCount)
: schedule.song.voteCount || 0
}}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
import { convertToHttps } from '~/utils/url'
import { useLocale } from '~/utils/locale'
const { admin } = useLocale()
const locale = computed(() => admin.value.schedulePrinter)
// 定义props
defineProps({
schedule: {
type: Object,
required: true
},
settings: {
type: Object,
required: true
},
narrow: {
type: Boolean,
default: false
}
})
// 处理图片加载错误
const handleImageError = (event) => {
event.target.style.display = 'none'
event.target.nextElementSibling?.classList.add('show')
}
</script>
<style scoped>
.schedule-item-print {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #d1d5db;
page-break-inside: avoid;
break-inside: avoid;
}
.item-content {
display: flex;
align-items: center;
width: 100%;
gap: 12px;
min-width: 0;
}
.item-leading {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.item-trailing {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.sequence-number {
width: 30px;
height: 30px;
background: #f0f0f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
color: #1a1a1a;
flex-shrink: 0;
}
.cover-section {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.song-cover {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
.cover-placeholder {
width: 100%;
height: 100%;
background: #f5f5f5;
border-radius: 4px;
display: none;
align-items: center;
justify-content: center;
color: #6c757d;
}
.cover-placeholder.show {
display: flex;
}
.cover-placeholder svg {
width: 20px;
height: 20px;
}
.song-info {
flex: 1;
min-width: 0;
}
.song-title {
font-weight: bold;
font-size: 16px;
color: #1a1a1a;
margin-bottom: 2px;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
/* 歌名文本块:text-overflow 只能作用在块容器上,故单独承载裁剪 */
.title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* 打印用重播标识 */
.replay-badge-print {
display: inline-block;
padding: 1px 4px;
background: #f8f9fa;
border: 1px solid #0b5afe;
border-radius: 3px;
color: #2563eb;
font-size: 10px;
font-weight: bold;
flex-shrink: 0;
}
/* 跨学期标识 */
.cross-semester-badge-print {
display: inline-block;
padding: 1px 4px;
background: #f5f5f5;
border: 1px solid #d1d5db;
color: #636366;
font-size: 10px;
border-radius: 2px;
font-weight: normal;
margin-left: 4px;
vertical-align: middle;
flex-shrink: 0;
}
.song-artist {
font-size: 14px;
color: #636366;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.requester-info,
.votes-info {
display: flex;
align-items: center;
font-size: 12px;
color: #636366;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.label {
font-weight: 500;
margin-right: 4px;
flex-shrink: 0;
}
.value {
color: #1a1a1a;
overflow: hidden;
text-overflow: ellipsis;
}
/* 两列窄版排布 */
.schedule-item-print.is-narrow {
padding: 6px 0;
}
.is-narrow .item-content,
.is-narrow .item-leading {
gap: 8px;
}
.is-narrow .sequence-number {
width: 22px;
height: 22px;
font-size: 12px;
}
.is-narrow .cover-section {
width: 32px;
height: 32px;
}
.is-narrow .cover-placeholder svg {
width: 16px;
height: 16px;
}
.is-narrow .song-title {
font-size: 13px;
gap: 3px;
}
.is-narrow .song-artist {
font-size: 11px;
}
.is-narrow .replay-badge-print,
.is-narrow .cross-semester-badge-print {
font-size: 9px;
padding: 0 3px;
}
/* 人数与歌名同行、投稿人与歌手同行 */
.is-narrow .item-trailing {
flex-direction: column;
align-items: flex-end;
gap: 0;
min-width: 0;
max-width: 45%;
}
.is-narrow .votes-info {
order: -1;
}
.is-narrow .requester-info,
.is-narrow .votes-info {
font-size: 11px;
justify-content: flex-end;
max-width: 100%;
}
/* 打印样式 */
@media print {
.schedule-item-print {
color: #1a1a1a !important;
background: #ffffff !important;
width: 100% !important;
max-width: none !important;
box-sizing: border-box !important;
}
.item-content {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
width: 100% !important;
gap: 12px !important;
}
.sequence-number {
background: #f0f0f0 !important;
color: #1a1a1a !important;
width: 30px !important;
height: 30px !important;
flex-shrink: 0 !important;
}
.cover-section {
width: 40px !important;
height: 40px !important;
flex-shrink: 0 !important;
}
.song-info {
flex: 1 !important;
min-width: 0 !important;
}
.song-title {
color: #1a1a1a !important;
font-size: 16px !important;
white-space: nowrap !important;
}
.title-text {
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.song-artist,
.label,
.value {
color: #3a3a3c !important;
}
.time-range {
color: #636366 !important;
}
.requester-info,
.votes-info,
.playtime-info {
display: flex !important;
align-items: center !important;
font-size: 12px !important;
white-space: nowrap !important;
flex-shrink: 0 !important;
}
}
/* 紧凑模式 */
.compact .schedule-item-print {
padding: 4px 0;
}
.compact .item-content {
gap: 8px;
}
.compact .sequence-number {
width: 24px;
height: 24px;
font-size: 12px;
}
.compact .cover-section {
width: 32px;
height: 32px;
}
.compact .song-title {
font-size: 14px;
}
.compact .song-artist {
font-size: 12px;
}
.compact .requester-info,
.compact .votes-info,
.compact .playtime-info {
font-size: 11px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,492 @@
<template>
<Teleport to="body">
<Transition
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-if="show"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
@click.self="close"
>
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
<div
class="relative w-full max-w-md bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
@click.stop
>
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-border-secondary-50">
<h3 class="text-lg font-black text-text-primary tracking-tight">{{ locale.title }}</h3>
<button
class="p-2 rounded-xl bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all flex items-center justify-center"
@click="close"
>
<X class="w-4 h-4" />
</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto p-6 space-y-6">
<!-- Default Playlists -->
<div class="space-y-3">
<label class="text-xs font-bold text-text-tertiary uppercase tracking-widest">
{{ locale.defaultPlaylists }}
</label>
<div class="grid grid-cols-2 gap-2">
<button
v-for="playlist in defaultPlaylists"
:key="playlist.id"
:class="[
'relative p-3 rounded-xl border text-sm font-medium transition-all text-left flex items-center gap-3 overflow-hidden group',
selectedIds.includes(playlist.id)
? 'bg-primary-10 border-primary-30 text-primary'
: 'bg-bg-primary border-border-secondary text-text-secondary hover:border-border-tertiary'
]"
@click="togglePlaylist(playlist.id)"
>
<div class="w-10 h-10 rounded-lg overflow-hidden bg-bg-tertiary-50 border border-border-tertiary-50 flex-shrink-0 relative">
<img
v-if="playlist.coverImgUrl"
:src="convertToHttps(playlist.coverImgUrl)"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
referrerpolicy="no-referrer"
alt=""
/>
<div v-else class="w-full h-full flex items-center justify-center text-text-tertiary">
<Music2 class="w-4 h-4 opacity-50" />
</div>
</div>
<span class="flex-1 truncate z-10">{{ getDefaultPlaylistName(playlist.id) }}</span>
<Check v-if="selectedIds.includes(playlist.id)" class="w-4 h-4 flex-shrink-0 z-10" />
</button>
</div>
</div>
<!-- Custom Playlists -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="text-xs font-bold text-text-tertiary uppercase tracking-widest">
{{ locale.customPlaylists }}
</label>
<button
class="p-1.5 rounded-lg bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary transition-all flex items-center gap-1.5 text-[10px] font-bold"
@click="refreshCustomPlaylists"
:disabled="isRefreshingCustom"
:title="locale.refreshTitle"
>
<RefreshCw class="w-3 h-3" :class="{ 'animate-spin': isRefreshingCustom }" />
<span>{{ locale.refresh }}</span>
</button>
</div>
<div class="space-y-2">
<div
v-for="(item, index) in customPlaylists"
:key="item._key"
class="flex items-center gap-2"
>
<div class="flex-1 relative">
<div class="absolute left-2.5 top-1/2 -translate-y-1/2 w-5 h-5 rounded overflow-hidden bg-bg-tertiary border border-border-tertiary-50 flex items-center justify-center flex-shrink-0">
<img
v-if="item.coverImgUrl"
:src="convertToHttps(item.coverImgUrl)"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
alt=""
/>
<Loader2 v-else-if="item.loading" class="w-3 h-3 text-text-tertiary animate-spin" />
<Music2 v-else class="w-3 h-3 text-text-disabled" />
</div>
<input
v-model="item.inputValue"
type="text"
:placeholder="locale.playlistPlaceholder"
class="w-full pl-9 pr-4 py-2.5 bg-bg-primary border border-border-secondary rounded-xl text-sm focus:outline-none focus:border-primary-30 text-text-primary transition-all"
@input="handleCustomInputChange(index)"
/>
<div v-if="item.name" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-text-tertiary truncate max-w-[100px] pointer-events-none">
{{ item.name }}
</div>
</div>
<button
class="p-2.5 rounded-xl bg-error-10 text-error hover:bg-error-20 border border-error-20 transition-all flex-shrink-0 flex items-center justify-center"
@click="removeCustomPlaylist(index)"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
<button
class="w-full px-4 py-2.5 rounded-xl border border-dashed border-border-tertiary text-text-tertiary hover:border-border-tertiary hover:text-text-secondary text-sm font-medium transition-all flex items-center justify-center gap-2"
@click="addCustomPlaylist"
>
<Plus class="w-4 h-4" />
<span>{{ locale.addCustomPlaylist }}</span>
</button>
</div>
</div>
</div>
<!-- Footer -->
<div class="p-6 border-t border-border-secondary-50 bg-bg-secondary-50 flex items-center gap-3">
<button
class="flex-1 px-4 py-3 rounded-xl bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-sm font-bold transition-all"
@click="clearSelection"
>
{{ locale.clearFilter }}
</button>
<button
class="flex-1 px-4 py-3 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-sm font-bold transition-all shadow-lg shadow-[var(--primary-glow)] disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isApplying || isAnyCustomLoading"
@click="applyFilter"
>
<span v-if="isApplying" class="flex items-center justify-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
{{ locale.fetching }}
</span>
<span v-else>{{ locale.applyFilter }}</span>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, watch, onUnmounted } from 'vue'
import { X, Check, Plus, Trash2, Loader2, Music2, RefreshCw } from '@lucide/vue'
import { getPlaylistDetail } from '~/utils/neteaseApi'
import { convertToHttps, getNeteaseCookie } from '~/utils/url'
import { useLocale } from '~/utils/locale'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'apply', playlistIds: string[], playlistTracks: Record<string, string[]>, playlistNames: Record<string, string>): void
}>()
interface CustomPlaylist {
_key: number
inputValue: string
id: string
name: string
coverImgUrl: string
loading: boolean
trackIds?: string[]
}
const { admin } = useLocale()
const scheduleLocale = computed(() => admin.value?.scheduleManager || {})
const locale = computed(() => scheduleLocale.value?.playlistFilterModal || {})
const defaultPlaylists = ref([
{ id: '19723756', coverImgUrl: '', trackIds: [] as string[] },
{ id: '3779629', coverImgUrl: '', trackIds: [] as string[] },
{ id: '2884035', coverImgUrl: '', trackIds: [] as string[] },
{ id: '3778678', coverImgUrl: '', trackIds: [] as string[] }
])
const getDefaultPlaylistName = (id: string) => {
const names: Record<string, string | undefined> = {
'19723756': locale.value?.defaultNames?.soaring,
'3779629': locale.value?.defaultNames?.newSongs,
'2884035': locale.value?.defaultNames?.original,
'3778678': locale.value?.defaultNames?.hotSongs
}
return names[id] || formatLocaleValue(scheduleLocale.value?.playlistName, id) || id
}
const selectedIds = ref<string[]>([])
const customPlaylists = ref<CustomPlaylist[]>([])
const isApplying = ref(false)
const isRefreshingCustom = ref(false)
let nextCustomKey = 0
const isAnyCustomLoading = computed(() => {
return customPlaylists.value.some(p => p.loading)
})
// 防抖定时器
const debounceTimers = ref<Record<number, ReturnType<typeof setTimeout>>>({})
const STORAGE_KEY = 'voicehub_schedule_playlist_filter'
// 解析网易云链接中的 ID
const extractPlaylistId = (input: string): string => {
const urlPattern = /[?&]id=(\d+)/
const match = input.match(urlPattern)
if (match && match[1]) {
return match[1]
}
const digitPattern = /^\d+$/
if (digitPattern.test(input.trim())) {
return input.trim()
}
return ''
}
// 获取特定歌单的详细信息
const fetchPlaylistInfo = async (id: string) => {
const cookie = getNeteaseCookie()
try {
const res = await getPlaylistDetail(id, cookie)
if (res && res.code === 200 && res.body?.playlist) {
const playlistData = res.body.playlist
const trackIds = playlistData.trackIds ? playlistData.trackIds.map((t: any) => t.id.toString()) : []
return {
name: playlistData.name,
coverImgUrl: playlistData.coverImgUrl,
trackIds
}
}
} catch (err) {
console.error(`获取歌单 ${id} 详情失败:`, err)
}
return null
}
const handleCustomInputChange = (index: number) => {
const item = customPlaylists.value[index]
if (!item) return
const extractedId = extractPlaylistId(item.inputValue)
// 如果提取到了ID且输入值包含 URL 结构,将输入框内容替换为提取出的 ID
if (extractedId && item.inputValue !== extractedId && /[?&]id=/.test(item.inputValue)) {
item.inputValue = extractedId
}
// 清除旧状态
item.name = ''
item.coverImgUrl = ''
item.id = extractedId
item.trackIds = []
const currentKey = item._key
if (debounceTimers.value[currentKey]) {
clearTimeout(debounceTimers.value[currentKey])
}
if (!extractedId) {
item.loading = false
return
}
item.loading = true
// 500ms 防抖
debounceTimers.value[currentKey] = setTimeout(async () => {
// 使用提取时的 ID 进行校验,防止竞态条件 (stale fetch)
const result = await fetchPlaylistInfo(extractedId)
// 找到当前 key 对应的 item(可能位置已经改变)
const currentIndex = customPlaylists.value.findIndex(p => p._key === currentKey)
if (currentIndex !== -1) {
const currentItem = customPlaylists.value[currentIndex]
// 只有当当前的输入 ID 仍然匹配时才更新
if (currentItem.id === extractedId) {
currentItem.loading = false
if (result) {
currentItem.name = result.name
currentItem.coverImgUrl = result.coverImgUrl
currentItem.trackIds = result.trackIds
}
}
}
}, 500)
}
const refreshCustomPlaylists = async () => {
if (isRefreshingCustom.value) return
isRefreshingCustom.value = true
try {
const promises = customPlaylists.value.map(async (item) => {
if (item.id) {
item.loading = true
const result = await fetchPlaylistInfo(item.id)
// 由于是异步的,确保元素还在并且 ID 没变
const currentItem = customPlaylists.value.find(p => p._key === item._key)
if (currentItem && currentItem.id === item.id) {
currentItem.loading = false
if (result) {
currentItem.name = result.name
currentItem.coverImgUrl = result.coverImgUrl
currentItem.trackIds = result.trackIds
}
}
}
})
await Promise.all(promises)
} finally {
isRefreshingCustom.value = false
}
}
onMounted(async () => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const parsed = JSON.parse(saved)
if (Array.isArray(parsed.selectedIds)) selectedIds.value = parsed.selectedIds
if (Array.isArray(parsed.customPlaylists)) {
// 只恢复值,状态重置
customPlaylists.value = parsed.customPlaylists.map((p: any) => ({
_key: nextCustomKey++,
inputValue: p.inputValue || '',
id: p.id || extractPlaylistId(p.inputValue || ''),
name: p.name || '',
coverImgUrl: p.coverImgUrl || '',
loading: false,
trackIds: p.trackIds || []
}))
}
}
} catch (e) {
console.error('无法解析本地保存的过滤设置', e)
}
const cookie = getNeteaseCookie()
await Promise.all(defaultPlaylists.value.map(async (playlist) => {
try {
const res = await getPlaylistDetail(playlist.id, cookie)
if (res && res.code === 200 && res.body?.playlist) {
if (res.body.playlist.coverImgUrl) {
playlist.coverImgUrl = res.body.playlist.coverImgUrl
}
if (res.body.playlist.trackIds) {
playlist.trackIds = res.body.playlist.trackIds.map((t: any) => t.id.toString())
}
}
} catch (err) {
console.error(`获取榜单 ${getDefaultPlaylistName(playlist.id)} 详情失败:`, err)
}
}))
})
onUnmounted(() => {
// 组件卸载时清除所有防抖定时器
Object.values(debounceTimers.value).forEach(timer => clearTimeout(timer))
debounceTimers.value = {}
})
watch([selectedIds, customPlaylists], ([newSelected, newCustom]) => {
// 保存时剔除 loading 状态和体积过大的 trackIds 数据
const customToSave = newCustom.map(c => ({
inputValue: c.inputValue,
id: c.id,
name: c.name,
coverImgUrl: c.coverImgUrl
}))
localStorage.setItem(STORAGE_KEY, JSON.stringify({
selectedIds: newSelected,
customPlaylists: customToSave
}))
}, { deep: true })
const togglePlaylist = (id: string) => {
const index = selectedIds.value.indexOf(id)
if (index === -1) {
selectedIds.value.push(id)
} else {
selectedIds.value.splice(index, 1)
}
}
const addCustomPlaylist = () => {
customPlaylists.value.push({
_key: nextCustomKey++,
inputValue: '',
id: '',
name: '',
coverImgUrl: '',
loading: false
})
}
const removeCustomPlaylist = (index: number) => {
const item = customPlaylists.value[index]
if (!item) return
const key = item._key
if (debounceTimers.value[key]) {
clearTimeout(debounceTimers.value[key])
delete debounceTimers.value[key]
}
customPlaylists.value.splice(index, 1)
}
const close = () => {
emit('update:show', false)
}
const clearSelection = () => {
selectedIds.value = []
customPlaylists.value = []
emit('apply', [], {}, {})
close()
}
const applyFilter = async () => {
isApplying.value = true
try {
const allIds: string[] = [...selectedIds.value]
const trackIdsMap: Record<string, string[]> = {}
const playlistNamesMap: Record<string, string> = {}
// 收集预设榜单的 trackIds 和名称
for (const id of selectedIds.value) {
const defaultPl = defaultPlaylists.value.find(p => p.id === id)
if (defaultPl) {
playlistNamesMap[id] = getDefaultPlaylistName(id)
if (defaultPl.trackIds && defaultPl.trackIds.length > 0) {
trackIdsMap[id] = defaultPl.trackIds
}
}
}
// 收集有效的自定义歌单的 trackIds 和名称
for (const item of customPlaylists.value) {
if (item.id) {
allIds.push(item.id)
if (item.name) {
playlistNamesMap[item.id] = item.name
} else {
playlistNamesMap[item.id] = formatLocaleValue(scheduleLocale.value?.playlistName, item.id) || item.id
}
if (item.trackIds && item.trackIds.length > 0) {
trackIdsMap[item.id] = item.trackIds
}
}
}
// 去重
const uniqueIds = [...new Set(allIds)]
emit('apply', uniqueIds, trackIdsMap, playlistNamesMap)
close()
} finally {
isApplying.value = false
}
}
</script>
File diff suppressed because it is too large Load Diff
+376
View File
@@ -0,0 +1,376 @@
<template>
<table class="schedule-timetable">
<thead>
<tr>
<th v-if="settings.showSequence" class="col-sequence">{{ locale.sequence }}</th>
<th v-for="date in dates" :key="date" class="col-date">
{{ formatDate(date) }}
</th>
</tr>
</thead>
<template v-if="hasPlayTimes">
<tbody v-for="pt in sortedPlayTimes" :key="pt.key">
<tr class="playtime-header-row">
<td :colspan="dates.length + (settings.showSequence ? 1 : 0)" class="playtime-header">
{{ pt.key }}
</td>
</tr>
<tr v-for="rowIndex in getMaxRowsForPlayTime(pt.key)" :key="`${pt.key}-${rowIndex}`">
<td v-if="settings.showSequence" class="col-sequence">{{ rowIndex }}</td>
<td v-for="date in dates" :key="date" class="song-cell">
<div v-if="getSchedule(date, pt.key, rowIndex - 1)" class="song-item">
<img
v-if="settings.showCover && getSchedule(date, pt.key, rowIndex - 1).song.cover"
:src="convertToHttps(getSchedule(date, pt.key, rowIndex - 1).song.cover)"
:data-original-src="convertToHttps(getSchedule(date, pt.key, rowIndex - 1).song.cover)"
class="song-cover"
@error="handleImageError"
>
<div class="song-details">
<div v-if="settings.showTitle" class="song-title">
<span class="title-text">{{ getSchedule(date, pt.key, rowIndex - 1).song.title }}</span>
<!-- 重播标识 -->
<span v-if="getSchedule(date, pt.key, rowIndex - 1).replayRequestId != null" class="replay-badge-print"> {{ locale.replay }} </span>
<!-- 跨学期标识 -->
<span v-if="settings.currentSemester && getSchedule(date, pt.key, rowIndex - 1).song.semester && getSchedule(date, pt.key, rowIndex - 1).song.semester !== settings.currentSemester" class="cross-semester-badge-print"> {{ locale.crossSemester }} </span>
</div>
<div v-if="settings.showArtist" class="song-artist">
{{ getSchedule(date, pt.key, rowIndex - 1).song.artist }}
</div>
<div v-if="settings.showRequester || settings.showVotes" class="song-meta">
<span v-if="settings.showRequester" class="song-requester">
{{ getSchedule(date, pt.key, rowIndex - 1).song.requester }}
</span>
<span v-if="settings.showRequester && settings.showVotes" class="meta-divider">|</span>
<span v-if="settings.showVotes" class="song-votes">
{{ formatVotes(getSchedule(date, pt.key, rowIndex - 1)) }}
</span>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</template>
<template v-else>
<tbody>
<tr v-for="rowIndex in maxRowsAll" :key="rowIndex">
<td v-if="settings.showSequence" class="col-sequence">{{ rowIndex }}</td>
<td v-for="date in dates" :key="date" class="song-cell">
<div v-if="getScheduleAll(date, rowIndex - 1)" class="song-item">
<img
v-if="settings.showCover && getScheduleAll(date, rowIndex - 1).song.cover"
:src="convertToHttps(getScheduleAll(date, rowIndex - 1).song.cover)"
:data-original-src="convertToHttps(getScheduleAll(date, rowIndex - 1).song.cover)"
class="song-cover"
@error="handleImageError"
>
<div class="song-details">
<div v-if="settings.showTitle" class="song-title">
<span class="title-text">{{ getScheduleAll(date, rowIndex - 1).song.title }}</span>
<!-- 重播标识 -->
<span v-if="getScheduleAll(date, rowIndex - 1).replayRequestId != null" class="replay-badge-print"> {{ locale.replay }} </span>
<!-- 跨学期标识 -->
<span v-if="settings.currentSemester && getScheduleAll(date, rowIndex - 1).song.semester && getScheduleAll(date, rowIndex - 1).song.semester !== settings.currentSemester" class="cross-semester-badge-print"> {{ locale.crossSemester }} </span>
</div>
<div v-if="settings.showArtist" class="song-artist">
{{ getScheduleAll(date, rowIndex - 1).song.artist }}
</div>
<div v-if="settings.showRequester || settings.showVotes" class="song-meta">
<span v-if="settings.showRequester" class="song-requester">
{{ getScheduleAll(date, rowIndex - 1).song.requester }}
</span>
<span v-if="settings.showRequester && settings.showVotes" class="meta-divider">|</span>
<span v-if="settings.showVotes" class="song-votes">
{{ formatVotes(getScheduleAll(date, rowIndex - 1)) }}
</span>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</template>
</table>
</template>
<script setup>
import { computed } from 'vue'
import { convertToHttps } from '~/utils/url'
import { useLocale } from '~/utils/locale'
const { admin } = useLocale()
const locale = computed(() => admin.value.schedulePrinter)
const formatVotes = (schedule) => schedule.replayRequestId != null
? locale.value.replayCountShort(schedule.song.replayRequestCount)
: locale.value.popularityCountShort(schedule.song.voteCount || 0)
const props = defineProps({
groupedSchedules: {
type: Object,
required: true
},
settings: {
type: Object,
required: true
}
})
const dates = computed(() => Object.keys(props.groupedSchedules).sort())
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = dateStr.includes('-') && !dateStr.includes('T')
? new Date(dateStr.replace(/-/g, '/'))
: new Date(dateStr)
const month = date.getMonth() + 1
const day = date.getDate()
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekday = weekdays[date.getDay()]
return `${month}月${day}日 ${weekday}`
}
// playTimes logic
const playTimesMap = computed(() => {
const map = {}
Object.values(props.groupedSchedules).forEach(dateGroup => {
Object.entries(dateGroup.playTimes).forEach(([key, value]) => {
if (!map[key]) {
map[key] = { key, playTime: value.playTime }
}
})
})
return map
})
const getPlayTimeSortWeight = (playTime) => {
if (!playTime || !playTime.startTime) return 9999
const [hours, minutes] = playTime.startTime.split(':').map(Number)
return hours * 60 + minutes
}
const sortedPlayTimes = computed(() => {
return Object.values(playTimesMap.value).sort((a, b) => {
return getPlayTimeSortWeight(a.playTime) - getPlayTimeSortWeight(b.playTime)
})
})
const hasPlayTimes = computed(() => {
if (!props.settings.showPlayTime) return false
const keys = Object.keys(playTimesMap.value)
if (keys.length > 1) return true
if (keys.length === 1 && keys[0] !== '未指定时段') return true
return false
})
const getMaxRowsForPlayTime = (playTimeKey) => {
let max = 0
dates.value.forEach(date => {
const pt = props.groupedSchedules[date].playTimes[playTimeKey]
if (pt && pt.schedules.length > max) {
max = pt.schedules.length
}
})
return max
}
const getSchedule = (date, playTimeKey, index) => {
const pt = props.groupedSchedules[date]?.playTimes[playTimeKey]
if (pt && pt.schedules[index]) return pt.schedules[index]
return null
}
const maxRowsAll = computed(() => {
let max = 0
dates.value.forEach(date => {
const len = props.groupedSchedules[date].allSchedules.length
if (len > max) max = len
})
return max
})
const getScheduleAll = (date, index) => {
const group = props.groupedSchedules[date]
if (group && group.allSchedules[index]) return group.allSchedules[index]
return null
}
const handleImageError = (event) => {
event.target.style.display = 'none'
}
</script>
<style scoped>
.schedule-timetable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
page-break-inside: auto;
table-layout: fixed;
}
.schedule-timetable tr {
page-break-inside: avoid;
page-break-after: auto;
}
.schedule-timetable th,
.schedule-timetable td {
border: 1px solid #d1d5db;
padding: 8px;
vertical-align: top;
overflow: hidden;
}
.schedule-timetable th {
background: #f3f4f6;
font-weight: bold;
text-align: center;
color: #1a1a1a;
}
.col-sequence {
width: 30px;
text-align: center !important;
vertical-align: middle !important;
}
.col-date {
min-width: 120px;
font-size: 15px;
padding: 12px 8px !important;
text-align: center !important;
vertical-align: middle !important;
}
.playtime-header-row {
background: #f8f9fa;
}
.playtime-header {
font-weight: bold;
color: #2563eb;
text-align: center !important;
padding: 6px !important;
}
.song-item {
display: flex;
gap: 8px;
align-items: flex-start;
}
.song-cover {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.song-details {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.song-title {
font-weight: bold;
color: #1a1a1a;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
}
.title-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 打印用重播标识 */
.replay-badge-print {
display: inline-block;
padding: 1px 4px;
background: #f8f9fa;
border: 1px solid #0b5afe;
border-radius: 3px;
color: #2563eb;
font-size: 10px;
font-weight: bold;
flex-shrink: 0;
margin-left: 4px;
}
/* 跨学期标识 */
.cross-semester-badge-print {
display: inline-block;
padding: 1px 4px;
background: #f5f5f5;
border: 1px solid #d1d5db;
color: #636366;
font-size: 10px;
border-radius: 2px;
font-weight: normal;
margin-left: 4px;
flex-shrink: 0;
}
.song-artist {
color: #636366;
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-meta {
display: flex;
align-items: center;
gap: 4px;
color: #636366;
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-requester,
.song-votes {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta-divider {
color: #d1d5db;
font-size: 8px;
}
@media print {
.schedule-timetable {
color: #1a1a1a !important;
}
.schedule-timetable th,
.schedule-timetable td {
border-color: #d1d5db !important;
}
.schedule-timetable th {
background: #f3f4f6 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.playtime-header-row {
background: #f8f9fa !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>
+711
View File
@@ -0,0 +1,711 @@
<template>
<div class="max-w-[1200px] mx-auto space-y-10 pb-20 px-2">
<!-- 顶部标题栏 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary mt-1">
{{ locale.desc }}
</p>
</div>
<button
class="flex items-center gap-2 px-6 py-2.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
@click="openModal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{{ locale.add }}
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10">
<!-- 左侧栏:当前活跃学期 -->
<div class="lg:col-span-5 space-y-6">
<div class="flex items-center gap-2 px-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-warning fill-warning"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
<h3 class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em]">
{{ locale.currentActive }}
</h3>
</div>
<div
v-if="currentSemester"
class="relative bg-gradient-to-br from-primary to-primary-hover rounded-3xl p-10 shadow-2xl shadow-[var(--primary-glow)] overflow-hidden group"
>
<div class="relative z-10 space-y-6">
<div
class="w-16 h-16 rounded-3xl bg-bg-secondary-10 backdrop-blur-md flex items-center justify-center text-text-primary border border-primary-20"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</div>
<div class="space-y-2">
<h4 class="text-3xl font-black text-text-primary tracking-tight leading-tight">
{{ currentSemester.name }}
</h4>
<div class="flex items-center gap-2 text-text-primary-80 font-bold text-xs">
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
{{ getLocaleMessage('createdAt', formatDate(currentSemester.createdAt)) }}
</div>
</div>
<div class="pt-4">
<span
class="px-4 py-2 bg-bg-secondary-20 text-text-primary text-[10px] font-black uppercase tracking-widest rounded-xl shadow-lg"
>
{{ locale.inProgress }}
</span>
</div>
</div>
<!-- 装饰性背景元素 -->
<div class="absolute -right-10 -bottom-10 w-64 h-64 bg-bg-secondary-5 blur-3xl rounded-full" />
<div class="absolute top-10 right-10 opacity-10">
<svg
xmlns="http://www.w3.org/2000/svg"
width="160"
height="160"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-text-primary"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</div>
</div>
<div
v-else
class="bg-bg-secondary-30 border border-border-secondary border-dashed rounded-3xl p-20 flex flex-col items-center justify-center text-center gap-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-text-secondary"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-sm font-bold text-text-disabled">{{ locale.noCurrent }}</p>
<button class="text-xs text-primary font-black hover:underline" @click="openModal">
{{ locale.createNow }}
</button>
</div>
<div class="p-6 bg-bg-secondary-20 border border-border-secondary rounded-3xl flex items-start gap-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-text-tertiary shrink-0 mt-0.5"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<div class="space-y-1">
<p class="text-xs font-bold text-text-tertiary">{{ locale.switchTitle }}</p>
<p class="text-[11px] text-text-disabled leading-relaxed">
{{ locale.switchDesc }}
</p>
</div>
</div>
</div>
<!-- 右侧栏:学期历史记录 -->
<div class="lg:col-span-7 space-y-6">
<div class="flex items-center justify-between px-1">
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-text-disabled"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M12 7v5l4 2" />
</svg>
<h3 class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em]">
{{ locale.historyTitle }}
</h3>
</div>
<span class="text-[10px] font-black text-text-secondary">{{ getLocaleMessage('historyCount', semesters.length) }}</span>
</div>
<div class="space-y-4">
<div
v-if="loading && semesters.length === 0"
class="text-center py-20 bg-bg-secondary-10 border border-border-secondary border-dashed rounded-[2rem]"
>
<p class="text-xs font-bold text-text-disabled">{{ locale.loading }}</p>
</div>
<div
v-for="sem in sortedSemesters"
:key="sem.id"
class="group flex items-center justify-between p-6 rounded-[2rem] border transition-all"
:class="
sem.isActive
? 'bg-primary-hover-5 border-primary-30'
: 'bg-bg-secondary-40 border-border-secondary hover:border-border-tertiary'
"
>
<div class="flex items-center gap-5">
<div
class="w-12 h-12 rounded-2xl flex items-center justify-center transition-all"
:class="
sem.isActive
? 'bg-primary-hover text-text-primary shadow-lg shadow-[var(--primary-glow)]'
: 'bg-bg-tertiary text-text-disabled border border-border-tertiary'
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="21 8 21 21 3 21 3 8" />
<rect x="1" y="3" width="22" height="5" />
<line x1="10" y1="12" x2="14" y2="12" />
</svg>
</div>
<div>
<h5
class="font-bold transition-colors"
:class="
sem.isActive ? 'text-primary' : 'text-text-primary group-hover:text-primary'
"
>
{{ sem.name }}
</h5>
<p class="text-[10px] text-text-disabled font-medium uppercase tracking-widest mt-0.5">
{{ getLocaleMessage('createdAt', formatDate(sem.createdAt)) }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<div
v-if="sem.isActive"
class="flex items-center gap-2 px-4 py-2 bg-success-10 text-success rounded-xl border border-success-20"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span class="text-[10px] font-black uppercase tracking-widest">{{ locale.currentSemester }}</span>
</div>
<button
v-else
:disabled="loading"
class="flex items-center gap-2 px-4 py-2 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary hover:text-text-primary text-[10px] font-black rounded-xl border border-border-tertiary transition-all uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="setActive(sem.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
{{ locale.setCurrent }}
</button>
<button
:disabled="loading"
class="p-2.5 text-text-secondary hover:text-primary hover:bg-primary-10 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
:title="locale.editSemester"
@click="openEditModal(sem)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
</svg>
</button>
<button
v-if="!sem.isActive"
:disabled="loading"
class="p-2.5 text-text-secondary hover:text-error hover:bg-error-10 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
:title="locale.deleteSemester"
@click="deleteSemester(sem.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</div>
</div>
<div
v-if="semesters.length === 0 && !loading"
class="text-center py-20 bg-bg-secondary-10 border border-border-secondary border-dashed rounded-[2rem]"
>
<p class="text-xs font-bold text-text-disabled">{{ locale.empty }}</p>
</div>
</div>
</div>
</div>
<!-- 添加/修改学期弹窗 -->
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-primary-60 backdrop-blur-sm"
>
<div
class="w-full max-w-md bg-bg-secondary rounded-3xl border border-border-secondary shadow-2xl overflow-hidden"
@click.stop
>
<div class="p-8 space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary tracking-tight">
{{ isEditing ? locale.editSemester : locale.add }}
</h3>
<button
class="p-2 text-text-tertiary hover:text-text-primary transition-colors"
@click="closeModal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.semesterName }}</label
>
<input
v-model="semesterForm.name"
type="text"
:placeholder="locale.namePlaceholder"
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 font-bold transition-all"
>
</div>
<label v-if="!isEditing" class="flex items-center gap-3 cursor-pointer group px-1">
<input
v-model="semesterForm.isActive"
type="checkbox"
class="w-4.5 h-4.5 rounded-lg border-border-secondary bg-bg-primary transition-all"
>
<div>
<span
class="text-xs font-bold text-text-secondary group-hover:text-primary transition-colors"
>{{ locale.setAsActive }}</span
>
<p class="text-[10px] text-text-disabled font-medium">
{{ locale.setAsActiveHint }}
</p>
</div>
</label>
<div class="p-4 bg-bg-primary border border-border-secondary rounded-2xl flex items-start gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-warning shrink-0 mt-0.5"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-[10px] text-text-tertiary leading-normal">
{{ locale.namingTip }}
</p>
</div>
</div>
</div>
<div class="p-6 bg-bg-tertiary-50 border-t border-border-secondary flex gap-3 justify-end">
<button
class="px-6 py-2.5 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors"
@click="closeModal"
>
{{ locale.cancel }}
</button>
<button
:disabled="submitting || !semesterForm.name.trim()"
class="px-8 py-2.5 bg-primary-hover hover:bg-primary disabled:bg-primary-hover disabled:opacity-50 text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95"
@click="handleSubmit"
>
{{ submitting ? (isEditing ? locale.saving : locale.creating) : (isEditing ? locale.saveChanges : locale.createSemester) }}
</button>
</div>
</div>
</div>
<!-- 确认删除对话框 -->
<ConfirmDialog
:loading="loading"
:show="showDeleteDialog"
:cancel-text="locale.cancel"
:confirm-text="locale.confirmDelete"
:message="deleteMessage"
:title="locale.deleteDialogTitle"
type="danger"
@close="showDeleteDialog = false"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
const { showToast: showNotification } = useToast()
const { admin } = useLocale()
const locale = computed(() => admin.value?.semesterManager || {})
const { msg: getLocaleMessage, nested: getNestedMessage } = useLocaleText(locale)
const {
semesters,
currentSemester,
loading,
error,
fetchSemesters,
fetchCurrentSemester,
createSemester,
setActiveSemester,
updateSemester,
deleteSemester: deleteSemesterAPI
} = useSemesters()
const showModal = ref(false)
const isEditing = ref(false)
const editSemesterId = ref(null)
const originalSemesterName = ref('')
const showDeleteDialog = ref(false)
const deleteTargetId = ref(null)
const deleteTargetName = ref('')
const submitting = ref(false)
const semesterForm = ref({
name: '',
isActive: false
})
// 按创建时间排序
const sortedSemesters = computed(() => {
return [...semesters.value].sort((a, b) => {
return new Date(b.createdAt) - new Date(a.createdAt)
})
})
// 删除确认信息
const deleteMessage = computed(() => {
return getLocaleMessage('deleteMessage', deleteTargetName.value)
})
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
// 设置活跃学期
const setActive = async (semesterId) => {
try {
const success = await setActiveSemester(semesterId)
// 注意:这里需要重新获取error,因为useSemesters内部可能没有正确更新error的响应式引用
// 或者直接依据success返回值判断
if (success) {
showNotification(getNestedMessage('messages', 'activeSet'), 'success')
} else {
// 如果setActiveSemester返回false,尝试直接显示一个通用错误,因为error可能为空
showNotification(error.value || getNestedMessage('errors', 'setActiveFailedRetry'), 'error')
}
} catch (err) {
showNotification(err.message || getNestedMessage('errors', 'setFailed'), 'error')
}
}
// 准备删除学期
const deleteSemester = (semesterId) => {
const semester = semesters.value.find((s) => s.id === semesterId)
if (semester) {
deleteTargetId.value = semesterId
deleteTargetName.value = semester.name
showDeleteDialog.value = true
}
}
// 确认删除
const confirmDelete = async () => {
if (!deleteTargetId.value) return
try {
const success = await deleteSemesterAPI(deleteTargetId.value)
if (success) {
showNotification(getNestedMessage('messages', 'deleted'), 'success')
} else {
showNotification(error.value || getNestedMessage('errors', 'deleteFailed'), 'error')
}
} catch (err) {
showNotification(err.message || getNestedMessage('errors', 'deleteFailedShort'), 'error')
}
showDeleteDialog.value = false
deleteTargetId.value = null
deleteTargetName.value = ''
}
// 处理表单提交
const handleSubmit = async () => {
const normalizedName = semesterForm.value.name.trim()
if (!normalizedName) return
if (isEditing.value && normalizedName === originalSemesterName.value) {
showNotification(getNestedMessage('messages', 'nameUnchanged'), 'info')
closeModal()
return
}
submitting.value = true
try {
if (isEditing.value && editSemesterId.value) {
// 修改学期
const success = await updateSemester(editSemesterId.value, {
name: normalizedName
})
if (success) {
showNotification(getNestedMessage('messages', 'updated'), 'success')
closeModal()
} else {
showNotification(error.value || getNestedMessage('errors', 'updateFailed'), 'error')
}
} else {
// 创建学期
const result = await createSemester({
name: normalizedName,
isActive: semesterForm.value.isActive
})
if (result) {
showNotification(getNestedMessage('messages', 'created'), 'success')
closeModal()
} else {
showNotification(error.value || getNestedMessage('errors', 'createFailed'), 'error')
}
}
} catch (err) {
console.error(isEditing.value ? '修改学期失败:' : '创建学期失败:', err)
showNotification(
err.message || (isEditing.value ? getNestedMessage('errors', 'updateFailed') : getNestedMessage('errors', 'createFailed')),
'error'
)
} finally {
submitting.value = false
}
}
// 关闭弹窗
const closeModal = () => {
showModal.value = false
isEditing.value = false
editSemesterId.value = null
originalSemesterName.value = ''
semesterForm.value = {
name: '',
isActive: false
}
}
const getRecommendedName = () => {
const now = getSyncedDate()
const year = now.getFullYear()
const month = now.getMonth() + 1
let academicYear = year
let term = locale.value?.terms?.first || '第一学期'
if (month >= 3 && month <= 8) {
academicYear = year - 1
term = locale.value?.terms?.second || '第二学期'
} else if (month <= 2) {
academicYear = year - 1
term = locale.value?.terms?.first || '第一学期'
} else {
academicYear = year
term = locale.value?.terms?.first || '第一学期'
}
return getLocaleMessage('recommendedName', academicYear, term)
}
const openModal = () => {
isEditing.value = false
editSemesterId.value = null
semesterForm.value.name = getRecommendedName()
semesterForm.value.isActive = false
showModal.value = true
}
const openEditModal = (sem) => {
isEditing.value = true
editSemesterId.value = sem.id
originalSemesterName.value = sem.name
semesterForm.value.name = sem.name
semesterForm.value.isActive = sem.isActive
showModal.value = true
}
// 组件挂载时获取数据
onMounted(async () => {
await Promise.all([fetchCurrentSemester(), fetchSemesters()])
})
</script>
<style scoped></style>
+265
View File
@@ -0,0 +1,265 @@
<!-- 侧边栏组件 -->
<template>
<aside
:class="[
'fixed inset-y-0 left-0 z-50 w-64 bg-bg-primary border-r border-border-secondary transform transition-transform duration-300 ease-in-out lg:translate-x-0',
isOpen ? 'translate-x-0' : '-translate-x-full'
]"
>
<div class="flex flex-col h-full p-4">
<!-- 品牌标识区域 -->
<div class="flex items-center px-2 mb-6 mt-2">
<NuxtLink to="/" class="flex items-center gap-2.5 group">
<!-- Logo 图标 -->
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
<img src="/assets/logo.png" alt="VoiceHub Logo" class="w-8 h-8 object-contain" >
</div>
<!-- 品牌文字 -->
<div class="flex flex-col justify-center">
<h1 class="font-bold text-lg text-text-primary leading-none tracking-tight">VoiceHub</h1>
<p
class="text-[10px] text-text-tertiary mt-1.5 uppercase tracking-widest font-bold leading-none"
>
{{ locale.console }}
</p>
</div>
</NuxtLink>
</div>
<!-- 导航菜单区域 -->
<nav class="flex-1 space-y-6 overflow-y-auto pr-2 custom-scrollbar">
<div v-for="(group, idx) in menuGroups" :key="idx" class="space-y-1">
<template v-if="shouldShowGroup(group)">
<!-- 分组标题 -->
<h3 class="px-3 text-[10px] font-bold text-text-disabled uppercase tracking-[0.2em] mb-2">
{{ group.section }}
</h3>
<!-- 菜单项列表 -->
<template v-for="item in group.items" :key="item.id">
<button
v-if="permissions.canAccessPage(item.permissionId || item.id)"
:class="[
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-[13px] font-bold transition-all group border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-10 focus-visible:ring-inset',
activeTab === item.id
? 'bg-primary-hover-10 text-primary border-primary-20'
: 'text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary-40 border-transparent'
]"
@click="onNavigate(item.id)"
>
<!-- 菜单图标 -->
<component
:is="item.icon"
:size="18"
:class="
activeTab === item.id
? 'text-primary'
: 'text-text-tertiary group-hover:text-text-secondary'
"
/>
<span class="truncate">{{ item.label }}</span>
<!-- 选中状态指示器 -->
<div
v-if="activeTab === item.id"
class="ml-auto w-1 h-1 bg-primary rounded-full shadow-[0_0_8px_var(--primary-60)]"
/>
</button>
</template>
</template>
</div>
</nav>
<!-- 用户信息及退出登录 -->
<div class="mt-4 pt-4 border-t border-border-secondary">
<div
class="flex items-center gap-3 p-3 rounded-lg bg-bg-secondary-50 border border-border-secondary-50 hover:bg-bg-tertiary-30 transition-colors"
>
<!-- 用户头像/首字母 -->
<img
v-if="currentUser?.avatar && !avatarError"
:src="currentUser.avatar"
class="w-10 h-10 rounded-lg object-cover border border-border-tertiary shrink-0"
@error="avatarError = true"
>
<div
v-else
class="w-10 h-10 rounded-lg bg-bg-tertiary flex items-center justify-center text-text-tertiary font-bold border border-border-tertiary shrink-0"
>
{{ (currentUser?.name || locale.avatarFallback || '管').charAt(0) }}
</div>
<!-- 用户详细信息 -->
<div class="flex-1 min-w-0">
<p class="text-xs font-black truncate text-text-primary">
{{ currentUser?.name || locale.adminFallback }}
</p>
<p
class="text-[10px] text-text-tertiary truncate uppercase tracking-wider font-medium mt-0.5"
>
{{ getRoleDisplayName(currentUser?.role || 'ADMIN') }}
</p>
</div>
<!-- 退出按钮 -->
<button
class="p-2 text-text-disabled hover:text-error hover:bg-error-10 rounded-lg transition-all"
:title="locale.logout"
@click="$emit('logout')"
>
<LogOut :size="16" />
</button>
</div>
</div>
</div>
</aside>
</template>
<script setup>
/**
* 后台管理侧边栏组件
*/
import {
LayoutDashboard,
CalendarDays,
Printer,
Music2,
BarChart3,
Users,
Key,
Bell,
Mail,
LogOut,
Clock,
FileEdit,
BookOpen,
Ban,
ListMusic,
Globe,
Database,
Lock,
Ticket,
GraduationCap
} from '@lucide/vue'
import { useLocale } from '~/utils/locale'
const avatarError = ref(false)
const { admin } = useLocale()
const locale = computed(() => admin.value?.sidebar || {})
const props = defineProps({
// 侧边栏是否打开(移动端)
isOpen: Boolean,
// 当前激活的标签页 ID
activeTab: String,
// 当前登录用户信息
currentUser: Object,
// 权限控制对象
permissions: Object,
// 站点标题
siteTitle: String
})
watch(
() => props.currentUser?.avatar,
() => {
avatarError.value = false
}
)
const emit = defineEmits(['navigate', 'close', 'logout'])
// 菜单分组配置
const menuGroups = computed(() => [
{
section: locale.value.sections?.overview || '概览',
items: [{ icon: LayoutDashboard, label: locale.value.menu?.overview || '数据概览', id: 'overview' }]
},
{
section: locale.value.sections?.content || '内容管理',
items: [
{ icon: CalendarDays, label: locale.value.menu?.schedule || '排班管理', id: 'schedule' },
{ icon: Printer, label: locale.value.menu?.print || '打印管理', id: 'print' },
{ icon: Music2, label: locale.value.menu?.songs || '点歌管理', id: 'songs' },
{
icon: BarChart3,
label: locale.value.menu?.dataAnalysis || '数据分析',
id: 'data-analysis',
permissionId: 'data-analysis'
}
]
},
{
section: locale.value.sections?.users || '用户管理',
items: [
{ icon: Users, label: locale.value.menu?.users || '用户管理', id: 'users' },
{ icon: GraduationCap, label: locale.value.menu?.gradeClass || '年级班级', id: 'grade-class' }
]
},
{
section: locale.value.sections?.api || 'API',
items: [{ icon: Key, label: locale.value.menu?.apiKeys || 'API 密钥', id: 'api-keys' }]
},
{
section: locale.value.sections?.system || '系统设置',
items: [
{ icon: Bell, label: locale.value.menu?.notifications || '通知管理', id: 'notifications' },
{ icon: Mail, label: locale.value.menu?.smtpConfig || 'SMTP 配置', id: 'smtp-config' },
{ icon: Clock, label: locale.value.menu?.playtimes || '播放时段', id: 'playtimes' },
{ icon: FileEdit, label: locale.value.menu?.requestTimes || '点歌时段', id: 'request-times' },
{ icon: BookOpen, label: locale.value.menu?.semesters || '学期管理', id: 'semesters' },
{ icon: Ban, label: locale.value.menu?.blacklist || '黑名单', id: 'blacklist' },
{ icon: Ticket, label: locale.value.menu?.cardCodes || '卡密管理', id: 'card-codes' },
{ icon: ListMusic, label: locale.value.menu?.musicSource || '音源控制', id: 'music-source' },
{ icon: Globe, label: locale.value.menu?.siteConfig || '站点配置', id: 'site-config' },
{ icon: Database, label: locale.value.menu?.database || '数据库', id: 'database' }
]
},
{
section: locale.value.sections?.account || '账户',
items: [{ icon: Lock, label: locale.value.menu?.password || '修改密码', id: 'password' }]
}
])
/**
* 判断是否应该显示该菜单组
* @param {Object} group 菜单组对象
*/
const shouldShowGroup = (group) => {
if (!props.permissions) return true
return group.items.some((item) => props.permissions.canAccessPage(item.permissionId || item.id))
}
/**
* 导航点击处理
* @param {string} id 菜单项 ID
*/
const onNavigate = (id) => {
if (id === 'password') {
navigateTo('/change-password')
return
}
emit('navigate', id)
}
/**
* 获取角色显示名称
* @param {string} role 角色标识
*/
const getRoleDisplayName = (role) => {
return locale.value.roles?.[role] || role
}
</script>
<style scoped>
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--panel-bg-alt);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--panel-bg-hover);
}
</style>
File diff suppressed because it is too large Load Diff
+418
View File
@@ -0,0 +1,418 @@
<template>
<div class="max-w-[1400px] mx-auto space-y-10 pb-20 px-2">
<!-- 头部区域 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary mt-1">
{{ locale.desc }}
</p>
</div>
<div class="flex items-center gap-3">
<button
class="flex items-center gap-2 px-6 py-2 bg-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-secondary text-xs font-bold rounded-xl transition-all active:scale-95 disabled:opacity-50"
:disabled="reloading"
@click="reloadSmtpConfig"
>
<RotateCw :size="14" :class="reloading ? 'animate-spin' : ''" />
{{ reloading ? locale.reloading : locale.reload }}
</button>
<button
class="flex items-center gap-2 px-6 py-2 bg-primary-hover hover:bg-primary text-text-primary text-xs font-bold rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
:disabled="saving"
@click="saveConfig"
>
<Save :size="14" /> {{ saving ? locale.saving : locale.save }}
</button>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8">
<!-- 左侧栏:SMTP 设置与测试 -->
<div class="xl:col-span-4 space-y-8">
<!-- SMTP 核心设置 -->
<section class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-6 space-y-6">
<div class="flex items-center justify-between">
<h3
class="text-sm font-black text-text-primary uppercase tracking-widest flex items-center gap-2"
>
<Server :size="16" class="text-primary" /> {{ locale.serviceConfig }}
</h3>
<button
class="relative w-10 h-5 rounded-full transition-colors"
:class="config.smtpEnabled ? 'bg-primary-hover' : 'bg-bg-tertiary'"
@click="config.smtpEnabled = !config.smtpEnabled"
>
<div
class="absolute top-1 w-3 h-3 bg-bg-secondary rounded-full transition-all"
:class="config.smtpEnabled ? 'left-6' : 'left-1'"
/>
</button>
</div>
<div class="space-y-4">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.smtpHost }}</label
>
<input
v-model="config.smtpHost"
type="text"
placeholder="smtp.example.com"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.port }}</label
>
<input
v-model.number="config.smtpPort"
type="number"
placeholder="587"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.secure }}</label
>
<CustomSelect
:model-value="config.smtpSecure ? secureOptionLabels.ssl : secureOptionLabels.none"
:options="[secureOptionLabels.ssl, secureOptionLabels.none]"
class="w-full"
@update:model-value="(val) => (config.smtpSecure = val === secureOptionLabels.ssl)"
/>
</div>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.username }}</label
>
<input
v-model="config.smtpUsername"
type="text"
placeholder="your-email@example.com"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.fromEmail }}</label
>
<input
v-model="config.smtpFromEmail"
type="text"
:placeholder="config.smtpUsername || 'your-email@example.com'"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
>
<p class="text-[9px] text-text-tertiary px-1 italic">
{{ locale.fromEmailHint }}
</p>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.password }}</label
>
<input
v-model="config.smtpPassword"
type="password"
placeholder="••••••••••••"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.fromName }}</label
>
<input
v-model="config.smtpFromName"
type="text"
:placeholder="locale.defaultFromName"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
>
</div>
</div>
</section>
<!-- SMTP 测试模块 -->
<section class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-6 space-y-6">
<h3
class="text-sm font-black text-text-primary uppercase tracking-widest flex items-center gap-2"
>
<Send :size="16" class="text-success" /> {{ locale.serviceTest }}
</h3>
<div class="space-y-4">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.testEmail }}</label
>
<div class="flex gap-2">
<input
v-model="testEmail"
type="email"
:placeholder="locale.testEmailPlaceholder"
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
>
<button
class="flex items-center gap-2 px-4 py-2 bg-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-tertiary text-xs font-bold rounded-xl transition-all disabled:opacity-50"
:disabled="testing || !testEmail || !config.smtpEnabled"
@click="sendTestEmail"
>
<Check v-if="testResult?.success" :size="14" class="text-success" />
<Send v-else :size="14" />
{{ testing ? locale.sending : testResult?.success ? locale.sent : locale.send }}
</button>
</div>
</div>
<div v-if="testResult">
<div
class="flex items-center gap-2 p-3 rounded-xl text-xs"
:class="
testResult.success
? 'bg-success-10 text-success border border-success-20'
: 'bg-error-10 text-error border border-error-20'
"
>
<CheckCircle v-if="testResult.success" :size="14" />
<XCircle v-else :size="14" />
<div class="flex flex-col gap-0.5">
<p class="font-bold">{{ testResult.message }}</p>
<p v-if="testResult.detail" class="text-[10px] opacity-80 break-all font-mono">
{{ testResult.detail }}
</p>
</div>
</div>
</div>
</div>
</section>
</div>
<!-- 右侧栏:模板编辑器 -->
<div class="xl:col-span-8">
<EmailTemplateManager />
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
import EmailTemplateManager from '~/components/Admin/EmailTemplateManager.vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { Server, Save, Check, Send, CheckCircle, XCircle, RotateCw } from '@lucide/vue'
const { showToast: showNotification } = useToast()
const { admin } = useLocale()
const locale = computed(() => admin.value?.smtpManager || {})
const getLocaleMessage = (key) => locale.value?.[key] || `SMTP 配置:${key}`
const getOptionText = (value, fallback) => {
if (typeof value === 'function') return value() || fallback
if (typeof value === 'string') return value || fallback
return fallback
}
const secureOptionLabels = computed(() => ({
ssl: getOptionText(locale.value?.secureOptions?.ssl, 'SSL/TLS'),
none: getOptionText(locale.value?.secureOptions?.none, 'None')
}))
// 响应式数据
const config = ref({
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUsername: '',
smtpPassword: '',
smtpFromEmail: '',
smtpFromName: getLocaleMessage('defaultFromName')
})
const testEmail = ref('')
const testing = ref(false)
const saving = ref(false)
const reloading = ref(false)
const testResult = ref(null)
const originalConfig = ref({})
// 将服务端返回的固定文案映射为当前语言,避免后端中文穿透到前端界面。
const getLocalizedServerMessage = (message) => {
if (!message) {
return ''
}
const serverMessages = locale.value?.serverMessages
if (!serverMessages) return message
const rawMessages = serverMessages.raw
if (rawMessages) {
const key = Object.keys(rawMessages).find((item) => rawMessages[item] === message)
return key ? serverMessages[key] : message
}
const matchedEntry = Object.values(serverMessages).find((entry) => entry?.raw === message)
return matchedEntry?.text || message
}
const localizeSmtpResult = (response) => ({
...response,
message: getLocalizedServerMessage(response?.message) || response?.message
})
// 加载配置
const loadConfig = async () => {
try {
const response = await $fetch('/api/admin/system-settings')
config.value = {
smtpEnabled: response.smtpEnabled || false,
smtpHost: response.smtpHost || '',
smtpPort: response.smtpPort || 587,
smtpSecure: response.smtpSecure || false,
smtpUsername: response.smtpUsername || '',
smtpPassword: response.smtpPassword || '',
smtpFromEmail: response.smtpFromEmail || '',
smtpFromName: response.smtpFromName || getLocaleMessage('defaultFromName')
}
// 保存原始配置用于重置
originalConfig.value = { ...config.value }
} catch (error) {
console.error('加载SMTP配置失败:', error)
showNotification(getLocaleMessage('loadFailed'), 'error')
}
}
// 保存配置
const saveConfig = async () => {
if (config.value.smtpEnabled) {
// 验证必填字段
if (!config.value.smtpHost || !config.value.smtpUsername || !config.value.smtpPassword) {
showNotification(getLocaleMessage('incompleteConfig'), 'error')
return
}
}
saving.value = true
try {
await $fetch('/api/admin/system-settings', {
method: 'POST',
body: config.value
})
originalConfig.value = { ...config.value }
showNotification(getLocaleMessage('saveSuccess'), 'success')
} catch (error) {
console.error('保存SMTP配置失败:', error)
showNotification(getLocalizedServerMessage(error.data?.message) || getLocaleMessage('saveFailed'), 'error')
} finally {
saving.value = false
}
}
// 重置配置
const resetConfig = () => {
config.value = { ...originalConfig.value }
testResult.value = null
showNotification(getLocaleMessage('resetSuccess'), 'info')
}
const reloadSmtpConfig = async () => {
reloading.value = true
try {
const response = await $fetch('/api/admin/smtp/reload', {
method: 'POST'
})
if (!response.success) {
throw new Error(getLocalizedServerMessage(response.message) || getLocaleMessage('reloadFailed'))
}
showNotification(getLocalizedServerMessage(response.message) || getLocaleMessage('reloadSuccess'), 'success')
} catch (error) {
console.error('重载SMTP配置失败:', error)
showNotification(
getLocalizedServerMessage(error.data?.message || error.message) || getLocaleMessage('reloadFailed'),
'error'
)
} finally {
reloading.value = false
}
}
// 测试连接
const testConnection = async () => {
if (!config.value.smtpEnabled || !config.value.smtpHost) {
showNotification(getLocaleMessage('serverRequired'), 'error')
return
}
testing.value = true
testResult.value = null
try {
const response = await $fetch('/api/admin/smtp/test-connection', {
method: 'POST',
body: config.value
})
testResult.value = localizeSmtpResult(response)
} catch (error) {
console.error('测试连接失败:', error)
testResult.value = {
success: false,
message:
getLocalizedServerMessage(error.data?.message) || getLocaleMessage('testConnectionFailed')
}
} finally {
testing.value = false
}
}
// 发送测试邮件
const sendTestEmail = async () => {
if (!testEmail.value) {
showNotification(getLocaleMessage('testEmailRequired'), 'error')
return
}
testing.value = true
testResult.value = null
try {
const response = await $fetch('/api/admin/smtp/test-email', {
method: 'POST',
body: {
...config.value,
testEmail: testEmail.value
}
})
testResult.value = localizeSmtpResult(response)
if (response.success) {
showNotification(getLocaleMessage('testEmailSuccess'), 'success')
setTimeout(() => {
testResult.value = null
}, 5000)
}
} catch (error) {
console.error('发送测试邮件失败:', error)
testResult.value = {
success: false,
message: getLocalizedServerMessage(error.data?.message) || getLocaleMessage('testEmailFailed')
}
} finally {
testing.value = false
}
}
// 生命周期
onMounted(() => {
loadConfig()
})
</script>
<style scoped></style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
<template>
<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="show"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
@click="close"
>
<div
class="w-full max-w-lg bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
@click.stop
>
<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">{{ locale.title }}</h3>
<button class="text-text-tertiary hover:text-text-secondary transition-colors" @click="close">
<X :size="20" />
</button>
</div>
<div class="p-8 space-y-4">
<div class="flex items-center gap-3">
<p class="text-xs text-text-tertiary font-medium">{{ songTitle }}</p>
<span
v-if="noteStatus === 'pending'"
class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border bg-warning-10 text-warning border-warning-20"
>
{{ locale.statusPending }}
</span>
<span
v-else-if="noteStatus === 'rejected'"
class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border bg-error-10 text-error border-error-20"
>
{{ locale.statusRejected }}
</span>
<label
v-else
for="is-public-checkbox"
class="flex items-center gap-2 cursor-pointer group"
:class="{ 'opacity-50': isUpdatingPublic }"
>
<input
id="is-public-checkbox"
type="checkbox"
:checked="isPublic"
:disabled="isUpdatingPublic"
@change="$emit('update:isPublic', $event.target.checked)"
class="w-4 h-4 rounded border-border-secondary bg-bg-primary cursor-pointer disabled:cursor-not-allowed"
/>
<span
:class="[
'px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border transition-colors',
isPublic
? 'bg-primary-10 text-primary border-primary-20 group-hover:bg-primary-20'
: 'bg-warning-10 text-warning border-warning-20 group-hover:bg-warning-20'
]"
>
{{ isPublic ? locale.publicRemark : locale.adminOnly }}
</span>
<span v-if="isUpdatingPublic" class="text-[10px] text-text-tertiary animate-pulse">{{ locale.updating }}</span>
</label>
</div>
<div class="bg-bg-primary-50 border border-border-secondary-50 rounded-xl p-4">
<p class="text-sm text-text-primary leading-relaxed whitespace-pre-wrap">
{{ content }}
</p>
</div>
<!-- 审核操作:待审可通过/拒绝;已拒绝可重新通过 -->
<div v-if="noteStatus === 'pending' || noteStatus === 'rejected'" class="flex justify-end gap-2 pt-2">
<button
v-if="noteStatus === 'pending'"
type="button"
:disabled="isUpdatingPublic"
class="px-4 py-2 rounded-lg bg-error-10 text-error border border-error-20 hover:bg-error-20 transition-colors text-xs font-black uppercase tracking-widest disabled:opacity-40"
@click="$emit('reject')"
>
{{ locale.rejectButton }}
</button>
<button
type="button"
:disabled="isUpdatingPublic"
class="px-4 py-2 rounded-lg bg-primary text-white hover:opacity-90 transition-opacity text-xs font-black uppercase tracking-widest disabled:opacity-40"
@click="$emit('approve')"
>
{{ locale.approveButton }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { computed } from 'vue'
import { X } from '@lucide/vue'
import { useLocale } from '~/utils/locale'
defineProps({
show: {
type: Boolean,
default: false
},
songTitle: {
type: String,
default: ''
},
content: {
type: String,
default: ''
},
isPublic: {
type: Boolean,
default: true
},
isUpdatingPublic: {
type: Boolean,
default: false
},
noteStatus: {
type: String,
default: null
}
})
const emit = defineEmits(['close', 'update:isPublic', 'approve', 'reject'])
const { admin } = useLocale()
const locale = computed(() => admin.value?.scheduleManager?.remarkDialog || {})
const close = () => {
emit('close')
}
</script>
+293
View File
@@ -0,0 +1,293 @@
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="show" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4" @click.self="closeModal">
<div class="w-full max-w-md max-h-[90vh] overflow-y-auto bg-bg-primary rounded-2xl border border-border-secondary shadow-2xl">
<!-- 头部 -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border-secondary">
<h3 class="text-lg font-black text-text-primary flex items-center gap-2">
<ClipboardCheck :size="18" />
{{ locale.approval.title }}
</h3>
<button type="button" class="p-1.5 text-text-tertiary hover:text-text-primary transition-colors" @click="closeModal">
<X :size="18" />
</button>
</div>
<div class="px-6 py-5 space-y-4">
<!-- 注册基本信息 -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-bg-secondary-40 rounded-xl px-4 py-3">
<p class="text-[11px] font-black uppercase tracking-widest text-text-tertiary">{{ locale.approval.username }}</p>
<p class="text-sm font-bold text-text-primary mt-1 break-all">@{{ user?.username || '-' }}</p>
</div>
<div class="bg-bg-secondary-40 rounded-xl px-4 py-3">
<p class="text-[11px] font-black uppercase tracking-widest text-text-tertiary">{{ locale.approval.registeredAt }}</p>
<p class="text-sm font-bold text-text-primary mt-1">{{ formatDate(user?.createdAt) }}</p>
</div>
</div>
<!-- 注册备注(只读展示) -->
<div class="bg-bg-secondary-40 rounded-xl px-4 py-3">
<p class="text-[11px] font-black uppercase tracking-widest text-text-tertiary">{{ locale.approval.remark }}</p>
<p class="text-sm font-bold text-text-primary mt-1 whitespace-pre-wrap break-all">{{ user?.remark || locale.approval.emptyRemark }}</p>
</div>
<!-- 可编辑字段 -->
<div class="space-y-3">
<p class="text-xs text-text-secondary">{{ locale.approval.editHint }}</p>
<div class="form-group">
<label class="block text-xs font-bold text-text-secondary mb-1.5" for="approval-name">{{ locale.approval.name }}</label>
<input
id="approval-name"
v-model="form.name"
class="w-full px-3 py-2.5 rounded-xl bg-bg-secondary border border-border-secondary text-text-primary text-sm focus:outline-none focus:border-primary transition-colors"
type="text"
>
</div>
<div class="grid grid-cols-2 gap-3">
<CustomSelect v-model="form.grade" :options="gradeSelectOptions" :label="locale.approval.grade" @change="handleGradeChange" />
<CustomSelect
v-model="form.class"
:options="classSelectOptions"
:label="locale.approval.class"
:disabled="!form.grade"
/>
</div>
<div class="form-group">
<label class="block text-xs font-bold text-text-secondary mb-1.5" for="approval-remark">{{ locale.approval.remark }}</label>
<input
id="approval-remark"
v-model="form.remark"
class="w-full px-3 py-2.5 rounded-xl bg-bg-secondary border border-border-secondary text-text-primary text-sm focus:outline-none focus:border-primary transition-colors"
type="text"
maxlength="200"
>
</div>
</div>
<!-- 拒绝理由(可选) -->
<div class="form-group">
<label class="block text-xs font-bold text-text-secondary mb-1.5" for="approval-reason">{{ locale.approval.rejectReason }}</label>
<input
id="approval-reason"
v-model="rejectReason"
class="w-full px-3 py-2.5 rounded-xl bg-bg-secondary border border-border-secondary text-text-primary text-sm focus:outline-none focus:border-primary transition-colors"
type="text"
:placeholder="locale.approval.rejectReasonPlaceholder"
maxlength="200"
>
</div>
<div v-if="error" class="text-xs text-error font-bold">{{ error }}</div>
</div>
<!-- 底部操作 -->
<div class="flex items-center justify-end gap-2 px-6 py-4 border-t border-border-secondary">
<button
type="button"
:disabled="loading"
class="px-4 py-2 rounded-xl bg-bg-secondary border border-border-secondary text-text-tertiary hover:text-text-primary transition-colors disabled:opacity-40"
@click="closeModal"
>
{{ locale.actions.cancel }}
</button>
<button
type="button"
:disabled="loading"
class="px-4 py-2 rounded-xl bg-error-10 text-error border border-error-20 hover:bg-error-20 transition-colors disabled:opacity-40"
@click="handleReject"
>
{{ locale.approval.reject }}
</button>
<button
type="button"
:disabled="loading"
class="px-4 py-2 rounded-xl bg-primary text-white hover:opacity-90 transition-opacity disabled:opacity-40 flex items-center gap-1.5"
@click="handleApprove"
>
<RefreshCw v-if="loading" :size="14" class="animate-spin" />
{{ locale.approval.approve }}
</button>
</div>
</div>
</div>
</Transition>
<ConfirmDialog
:show="showRejectConfirm"
:title="locale.approval.title"
:message="locale.approval.confirmReject"
type="danger"
:confirm-text="locale.approval.reject"
:loading="loading"
@confirm="confirmReject"
@close="showRejectConfirm = false"
/>
</Teleport>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ClipboardCheck, RefreshCw, X } from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useLocale } from '~/utils/locale'
import { useServerErrors } from '~/composables/useLocaleText'
const props = defineProps({
show: { type: Boolean, default: false },
user: { type: Object, default: null }
})
const emit = defineEmits(['close', 'success'])
const { admin } = useLocale()
const locale = computed(() => admin.value?.userManager || {})
const { localize: localizeServerError } = useServerErrors()
const form = ref({ name: '', grade: '', class: '', remark: '' })
const rejectReason = ref('')
const error = ref('')
const loading = ref(false)
const showRejectConfirm = ref(false)
const classOptions = ref([])
const classOptionsLoaded = ref(false)
const gradeSelectOptions = computed(() => [
{ label: locale.value?.approval?.gradeNotSet || '不设置', value: '' },
...[...new Set(classOptions.value.map((item) => item.grade))].map((option) => ({ label: option, value: option }))
])
const classSelectOptions = computed(() =>
classOptions.value
.filter((item) => item.grade === form.value.grade)
.map((item) => ({ label: item.class, value: item.class }))
)
const formatDate = (value) => {
if (!value) return '-'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return '-'
return d.toLocaleString()
}
const handleGradeChange = () => {
form.value.class = ''
}
const fetchClassOptions = async () => {
if (classOptionsLoaded.value) return
try {
const response = await $fetch('/api/auth/grade-class-options')
if (response.success) {
classOptions.value = response.classes || []
classOptionsLoaded.value = true
}
} catch (e) {
console.error('获取年级班级选项失败:', e)
}
}
const resetForm = () => {
form.value = {
name: props.user?.name || '',
grade: props.user?.grade || '',
class: props.user?.class || '',
remark: props.user?.remark || ''
}
rejectReason.value = ''
error.value = ''
showRejectConfirm.value = false
}
watch(
() => props.show,
(visible) => {
if (visible) {
resetForm()
void fetchClassOptions()
}
}
)
const closeModal = () => {
if (loading.value) return
emit('close')
}
const handleApprove = async () => {
if (!props.user) return
error.value = ''
loading.value = true
try {
const response = await $fetch(`/api/admin/users/${props.user.id}/approval`, {
method: 'POST',
body: {
action: 'approve',
name: form.value.name.trim(),
grade: form.value.grade,
class: form.value.class,
remark: form.value.remark.trim()
}
})
if (response.success) {
emit('success', { action: 'approve', user: props.user })
emit('close')
}
} catch (err) {
error.value = localizeServerError(err, locale.value.approval?.approveFailed)
} finally {
loading.value = false
}
}
const handleReject = () => {
if (!props.user) return
error.value = ''
showRejectConfirm.value = true
}
const confirmReject = async () => {
if (!props.user) return
loading.value = true
try {
const response = await $fetch(`/api/admin/users/${props.user.id}/approval`, {
method: 'POST',
body: {
action: 'reject',
reason: rejectReason.value.trim()
}
})
if (response.success) {
showRejectConfirm.value = false
emit('success', { action: 'reject', user: props.user })
emit('close')
}
} catch (err) {
error.value = localizeServerError(err, locale.value.approval?.rejectFailed)
showRejectConfirm.value = false
} finally {
loading.value = false
}
}
</script>
<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>
File diff suppressed because it is too large Load Diff
+489
View File
@@ -0,0 +1,489 @@
<template>
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
@click="handleOverlayClick"
>
<!-- 遮罩层 -->
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
<!-- 模态框面板 -->
<transition
enter-active-class="transition ease-out duration-300"
enter-from-class="opacity-0 scale-95 translate-y-4"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition ease-in duration-200"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-4"
>
<div
v-if="show"
class="relative w-full max-w-4xl max-h-[85vh] flex flex-col bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
@click.stop
>
<!-- 头部 -->
<div
class="flex items-center justify-between px-6 py-4 border-b border-border-secondary bg-bg-secondary"
>
<h3 class="text-lg font-semibold text-text-primary">{{ locale.title }}</h3>
<button
class="p-2 text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary rounded-lg transition-colors"
@click="$emit('close')"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- 主体内容 -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden bg-bg-secondary">
<!-- 加载状态 -->
<div v-if="loading" class="flex-1 flex flex-col items-center justify-center">
<LoadingState :message="locale.loading" spinner-type="circle" />
</div>
<!-- 错误状态 -->
<div
v-else-if="error"
class="flex-1 flex flex-col items-center justify-center text-text-tertiary gap-4"
>
<div class="text-4xl">⚠️</div>
<p>{{ error }}</p>
<button
class="px-4 py-2 bg-primary-hover hover:bg-primary text-text-primary rounded-lg transition-colors text-sm font-medium"
@click="retryFetch"
>
{{ commonLocale.retry }}
</button>
</div>
<!-- 内容 -->
<div v-else-if="userSongs" class="flex-1 flex flex-col min-h-0">
<!-- 用户信息头部 -->
<div class="px-6 py-4 bg-bg-secondary border-b border-border-secondary">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3">
<h4 class="text-base font-medium text-text-primary">{{ userSongs.user.name }}</h4>
<span class="text-sm text-text-tertiary font-mono">{{
userSongs.user.username
}}</span>
</div>
<p
v-if="userSongs.user.grade || userSongs.user.class"
class="text-sm text-text-tertiary"
>
{{ userSongs.user.grade || '' }} {{ userSongs.user.class || '' }}
</p>
</div>
</div>
<!-- 工具栏 (标签页 + 过滤) -->
<div
class="flex flex-col sm:flex-row items-center justify-between border-b border-border-secondary bg-bg-secondary-50"
>
<!-- 标签页 -->
<div class="flex w-full sm:w-auto overflow-x-auto no-scrollbar">
<button
v-for="tab in tabs"
:key="tab.id"
class="relative px-6 py-3 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0"
:class="
activeTab === tab.id
? 'text-primary'
: 'text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary-50'
"
@click="activeTab = tab.id"
>
{{ tab.label }}
<span
class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full"
:class="
activeTab === tab.id
? 'bg-primary-10 text-primary'
: 'bg-bg-tertiary text-text-tertiary'
"
>
{{ getTabCount(tab.id) }}
</span>
<!-- 激活指示器 -->
<div
v-if="activeTab === tab.id"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"
/>
</button>
</div>
<!-- 学期过滤器 -->
<div
class="w-full sm:w-auto px-4 py-2 border-t sm:border-t-0 sm:border-l border-border-secondary flex items-center gap-3 bg-bg-secondary-30"
>
<CustomSelect
v-model="selectedSemester"
:options="[{ label: locale.allSemesters, value: null }, ...semesterOptions]"
:label="locale.semesterFilter"
:placeholder="locale.allSemesters"
class-name="w-full sm:w-48"
/>
</div>
</div>
<!-- 歌曲列表 -->
<div class="flex-1 overflow-y-auto p-4 sm:p-6 space-y-3 custom-scrollbar">
<div
v-if="filteredSongs.length === 0"
class="flex flex-col items-center justify-center py-20 text-text-tertiary"
>
<div class="text-5xl mb-4 opacity-50">{{ activeTabIcon }}</div>
<p>{{ locale.empty }}</p>
</div>
<div
v-for="song in filteredSongs"
:key="song.id"
class="group flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-lg border border-border-secondary bg-bg-tertiary-20 hover:bg-bg-tertiary-50 hover:border-border-tertiary transition-all duration-200"
>
<!-- 歌曲信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h5 class="text-sm font-medium text-text-primary truncate">{{ song.title }}</h5>
<span
v-if="song.semester"
class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-bg-tertiary text-text-tertiary border border-border-tertiary"
>
{{ song.semester }}
</span>
</div>
<div class="text-sm text-text-tertiary truncate">{{ song.artist }}</div>
<!-- Meta Info (Mobile) -->
<div
class="flex flex-wrap items-center gap-3 mt-3 sm:hidden text-xs text-text-tertiary"
>
<span>{{ getMetaTime(song) }}</span>
<span v-if="song.voteCount !== undefined">{{ formatLocale(locale.votes, song.voteCount) }}</span>
<span v-if="song.requestCount !== undefined"
>{{ formatLocale(locale.requests, song.requestCount) }}</span
>
</div>
</div>
<!-- 状态与元数据 (桌面端) -->
<div class="flex items-center gap-4 sm:gap-6">
<!-- 状态标签 -->
<div class="flex-shrink-0">
<span
class="px-2.5 py-1 rounded-full text-xs font-medium border"
:class="getStatusClasses(song)"
>
{{ getStatusText(song) }}
</span>
</div>
<!-- Meta Info (Desktop) -->
<div class="hidden sm:flex flex-col items-end gap-0.5 min-w-[100px]">
<span class="text-xs text-text-tertiary">{{ getMetaTime(song) }}</span>
<div class="flex items-center gap-2">
<span v-if="song.voteCount !== undefined" class="text-xs text-text-tertiary"
>{{ formatLocale(locale.votes, song.voteCount) }}</span
>
<span v-if="song.requestCount !== undefined" class="text-xs text-text-tertiary"
>{{ formatLocale(locale.requests, song.requestCount) }}</span
>
</div>
</div>
<!-- Submitter Info (if available) -->
<div v-if="song.requester" class="hidden sm:block text-right min-w-[80px]">
<div class="text-xs text-text-secondary">{{ song.requester.name }}</div>
<div class="text-[10px] text-text-tertiary">
{{ song.requester.grade }}{{ song.requester.class }}
</div>
</div>
</div>
<!-- 投稿人信息 (移动端) -->
<div
v-if="song.requester"
class="sm:hidden pt-3 mt-1 border-t border-border-secondary-50 flex justify-between items-center text-xs"
>
<span class="text-text-tertiary">{{ locale.requester }}</span>
<span class="text-text-secondary">
{{ song.requester.name }}
<span class="text-text-tertiary ml-1">
{{ song.requester.grade }}{{ song.requester.class }}
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</transition>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useSemesters } from '~/composables/useSemesters'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import Icon from '~/components/UI/Icon.vue'
import LoadingState from '~/components/UI/Common/LoadingState.vue'
import { useLocale } from '~/utils/locale'
const props = defineProps({
show: {
type: Boolean,
default: false
},
userId: {
type: Number,
default: null
}
})
const emit = defineEmits(['close'])
// Data
const loading = ref(false)
const error = ref('')
const userSongs = ref(null)
const activeTab = ref('submitted')
const selectedSemester = ref(null)
// Composables
const { semesters, fetchSemesterOptions } = useSemesters()
const { common, currentLocale } = useLocale()
const commonLocale = computed(() => common.value || {})
const locale = computed(() => {
const base = common.value?.userSongsModal || {}
return {
...base,
tabs: { ...(base.tabs || {}) },
statuses: { ...(base.statuses || {}) }
}
})
// Computed
const semesterOptions = computed(() => {
return semesters.value.map((s) => ({
label: s.name,
value: s.name
}))
})
const tabs = computed(() => [
{ id: 'submitted', label: locale.value.tabs.submitted },
{ id: 'voted', label: locale.value.tabs.voted },
{ id: 'replay', label: locale.value.tabs.replay }
])
const activeTabIcon = computed(() => {
switch (activeTab.value) {
case 'submitted':
return '🎵'
case 'voted':
return '❤️'
case 'replay':
return '🔁'
default:
return '📄'
}
})
const currentSongs = computed(() => {
if (!userSongs.value) return []
switch (activeTab.value) {
case 'submitted':
return userSongs.value.submittedSongs || []
case 'voted':
return userSongs.value.votedSongs || []
case 'replay':
return userSongs.value.replayRequestedSongs || []
default:
return []
}
})
const filteredSongs = computed(() => {
let songs = currentSongs.value
if (selectedSemester.value) {
songs = songs.filter((song) => song.semester === selectedSemester.value)
}
return songs
})
// Methods
const getTabCount = (tabId) => {
if (!userSongs.value) return 0
let songs = []
switch (tabId) {
case 'submitted':
songs = userSongs.value.submittedSongs || []
break
case 'voted':
songs = userSongs.value.votedSongs || []
break
case 'replay':
songs = userSongs.value.replayRequestedSongs || []
break
default:
return 0
}
if (selectedSemester.value) {
songs = songs.filter((song) => song.semester === selectedSemester.value)
}
return songs.length
}
const fetchUserSongs = async () => {
if (!props.userId) return
loading.value = true
error.value = ''
try {
const auth = useAuth()
// 并行获取歌曲数据和学期列表
const promises = [$fetch(`/api/admin/users/${props.userId}/songs`, { ...auth.getAuthConfig() })]
// 如果学期列表为空,尝试获取
if (semesters.value.length === 0) {
promises.push(fetchSemesterOptions())
}
const [songsResponse] = await Promise.all(promises)
userSongs.value = songsResponse
} catch (err) {
console.error('获取用户歌曲信息失败:', err)
error.value = err.data?.message || locale.value.fetchFailed
} finally {
loading.value = false
}
}
const retryFetch = () => {
fetchUserSongs()
}
const handleOverlayClick = () => {
emit('close')
}
// Helpers
const formatTimeAgo = (key, value) => {
const message = commonLocale.value?.time?.[key]
if (typeof message === 'function') return message(value)
if (typeof message === 'string') return message.replace(/{(\d+)}/g, (match, index) => index === '0' ? String(value) : match)
return ''
}
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
const now = getSyncedDate()
const diff = now - date
if (diff < 60000) return commonLocale.value?.time?.justNow || ''
if (diff < 3600000) return formatTimeAgo('minutesAgo', Math.floor(diff / 60000))
if (diff < 86400000) return formatTimeAgo('hoursAgo', Math.floor(diff / 3600000))
if (diff < 86400000 * 7) return formatTimeAgo('daysAgo', Math.floor(diff / 86400000))
return date.toLocaleDateString(currentLocale.value, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const getMetaTime = (song) => {
if (activeTab.value === 'submitted') return formatDate(song.createdAt)
if (activeTab.value === 'voted') return `${formatDate(song.votedAt)} ${locale.value.voted}`
if (activeTab.value === 'replay') return `${formatDate(song.requestedAt)} ${locale.value.requested}`
return ''
}
const getStatusText = (song) => {
if (song.played) return locale.value.statuses.played
if (song.scheduled) return locale.value.statuses.scheduled
return locale.value.statuses.pending
}
const getStatusClasses = (song) => {
if (song.played) {
return 'bg-success-10 text-success border-success-20'
}
if (song.scheduled) {
return 'bg-primary-10 text-primary border-primary-20'
}
return 'bg-warning-10 text-warning border-warning-20'
}
// 监听器
watch(
() => props.userId,
(newUserId) => {
if (newUserId && props.show) {
fetchUserSongs()
}
}
)
watch(
() => props.show,
(newShow) => {
if (newShow && props.userId) {
fetchUserSongs()
} else if (!newShow) {
// 关闭时重置状态
userSongs.value = null
error.value = ''
activeTab.value = 'submitted'
selectedSemester.value = null // 可选:关闭时重置过滤器
}
}
)
</script>
<style scoped>
/* 列表的自定义滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--usersongs-scroll-thumb);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--usersongs-scroll-thumb-hover);
}
/* 标签页隐藏滚动条 */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
+293
View File
@@ -0,0 +1,293 @@
<template>
<Teleport to="body">
<Transition
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-if="show"
class="fixed inset-0 z-[90] flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
@click.self="closeModal"
>
<div
class="bg-bg-secondary border border-border-secondary w-full max-w-xl rounded-2xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh]"
>
<!-- 头部 -->
<div
class="p-6 pb-4 flex items-center justify-between border-b border-border-secondary-50"
>
<div class="min-w-0">
<h3
class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3"
>
<div
class="w-10 h-10 rounded-xl bg-primary-hover-10 flex items-center justify-center text-primary shrink-0"
>
<Icon name="users" :size="20" />
</div>
{{ locale.title }}
</h3>
<p v-if="songInfo" class="text-xs text-text-tertiary mt-1 ml-13 truncate">
{{ songInfo.title }}
</p>
</div>
<button
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary rounded-xl transition-all shrink-0"
@click="closeModal"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- 主体 -->
<div class="flex-1 overflow-y-auto p-6 custom-scrollbar">
<!-- 歌曲信息 -->
<div
v-if="songInfo"
class="mb-5 p-5 bg-bg-primary-50 border border-border-secondary-50 rounded-xl"
>
<div class="flex items-start gap-4">
<div
class="w-11 h-11 rounded-xl bg-error-10 flex items-center justify-center text-error shrink-0"
>
<Icon name="music" :size="20" />
</div>
<div class="flex-1 min-w-0">
<h4 class="text-base font-black text-text-primary truncate">
{{ songInfo.title }}
</h4>
<p class="text-xs text-text-tertiary mt-1 truncate">{{ songInfo.artist }}</p>
</div>
<div
class="flex items-center gap-1.5 px-3 py-1.5 bg-error-10 border border-error-20 rounded-full text-error shrink-0"
>
<Icon name="heart" :size="12" class="fill-current" />
<span class="text-xs font-black">{{
formatLocale(locale.votes, totalVotes)
}}</span>
</div>
</div>
</div>
<!-- 加载状态 -->
<LoadingState v-if="loading" :message="locale.loading" spinner-type="circle" />
<!-- 错误状态 -->
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-16 text-center px-8"
>
<div
class="w-16 h-16 rounded-2xl bg-error-10 flex items-center justify-center text-error mb-4"
>
<Icon name="alert-triangle" :size="32" />
</div>
<p class="text-sm text-text-tertiary mb-6">{{ error }}</p>
<button
class="px-6 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-primary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="fetchVoters"
>
{{ commonLocale.retry }}
</button>
</div>
<!-- 投票人员列表 -->
<div v-else-if="voters.length > 0">
<div class="flex items-center justify-between mb-3 px-1">
<div
class="flex items-center gap-2 text-[10px] font-black text-text-tertiary uppercase tracking-widest"
>
<Icon name="users" :size="12" class="text-primary" />
{{ formatLocale(locale.voters, voters.length) }}
</div>
<div class="text-[10px] font-black text-text-disabled uppercase tracking-widest">
{{ formatLocale(locale.votes, totalVotes) }}
</div>
</div>
<div class="space-y-2 max-h-[45vh] overflow-y-auto custom-scrollbar pr-1">
<div
v-for="(voter, index) in voters"
:key="voter.id"
class="flex items-center gap-4 p-3 rounded-xl border border-transparent hover:bg-bg-tertiary-50 hover:border-border-secondary transition-all"
>
<div
class="w-10 h-10 rounded-xl bg-bg-tertiary border border-border-secondary flex items-center justify-center text-text-secondary text-sm font-black shrink-0"
>
{{ getAvatarText(voter.name) }}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-black text-text-primary truncate">
{{ voter.name }}
</div>
<div
class="flex items-center gap-1.5 text-[10px] text-text-disabled mt-0.5 font-medium"
>
<Icon name="clock" :size="11" />
{{ formatVoteTime(voter.votedAt) }}
</div>
</div>
<div
class="text-[10px] font-black text-text-disabled px-2 py-1 bg-bg-tertiary rounded-lg shrink-0"
>
#{{ index + 1 }}
</div>
</div>
</div>
</div>
<!-- 无投票状态 -->
<div v-else class="flex flex-col items-center justify-center py-16 text-text-disabled">
<Icon name="heart" :size="48" class="mb-4 opacity-20" />
<p class="text-sm font-bold">{{ locale.empty }}</p>
</div>
</div>
<!-- 底部 -->
<div
class="p-6 border-t border-border-secondary-50 bg-bg-secondary-50 flex items-center justify-end"
>
<button
class="px-6 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="closeModal"
>
{{ commonLocale.close }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import LoadingState from '~/components/UI/Common/LoadingState.vue'
import { useLocale } from '~/utils/locale'
import { useServerErrors } from '~/composables/useLocaleText'
// Props
const props = defineProps({
show: {
type: Boolean,
default: false
},
songId: {
type: Number,
default: null
}
})
// Emits
const emit = defineEmits(['close'])
// 响应式数据
const loading = ref(false)
const error = ref('')
const songInfo = ref(null)
const voters = ref([])
const totalVotes = ref(0)
const { common, currentLocale } = useLocale()
const commonLocale = computed(() => common.value || {})
const locale = computed(() => common.value?.votersModal || {})
const { localize: localizeServerError } = useServerErrors()
// 方法
const closeModal = () => {
emit('close')
}
const fetchVoters = async () => {
if (!props.songId) return
loading.value = true
error.value = ''
try {
const response = await $fetch(`/api/songs/${props.songId}/voters`)
songInfo.value = response.song
voters.value = response.voters || []
totalVotes.value = response.totalVotes || 0
} catch (err) {
console.error('获取投票人员失败:', err)
error.value = localizeServerError(err, locale.value.fetchFailed)
} finally {
loading.value = false
}
}
const getAvatarText = (name) => {
if (!name) return '?'
// 提取中文姓名的最后一个字符,或英文名的首字母
const cleanName = name.replace(/[((].*[))]/, '').trim()
return cleanName.slice(-1).toUpperCase()
}
const formatTimeAgo = (key, value) => {
const message = commonLocale.value?.time?.[key]
if (typeof message === 'function') return message(value)
if (typeof message === 'string')
return message.replace(/{(\d+)}/g, (match, index) => (index === '0' ? String(value) : match))
return ''
}
const formatVoteTime = (dateString) => {
const date = new Date(dateString)
const now = getSyncedDate()
const diff = now - date
if (diff < 60000) return commonLocale.value?.time?.justNow || ''
if (diff < 3600000) return formatTimeAgo('minutesAgo', Math.floor(diff / 60000))
if (diff < 86400000) return formatTimeAgo('hoursAgo', Math.floor(diff / 3600000))
if (diff < 604800000) return formatTimeAgo('daysAgo', Math.floor(diff / 86400000))
return date.toLocaleDateString(currentLocale.value, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// 监听弹窗显示状态
watch(
() => props.show,
(newShow) => {
if (newShow && props.songId) {
fetchVoters()
} else {
// 重置数据
songInfo.value = null
voters.value = []
totalVotes.value = 0
error.value = ''
}
}
)
</script>
<style scoped>
/* 列表自定义滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--text-muted);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--text-muted);
}
</style>