feat: align with upstream 1.5.9.9
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user