Files
BYW-VoiceHub/app/components/Admin/SiteConfigManager.vue
T

1264 lines
55 KiB
Vue

<template>
<div class="max-w-[1200px] mx-auto space-y-6 pb-24 px-2">
<!-- 顶部标题栏 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.pageTitle }}</h2>
<p class="text-xs text-text-tertiary mt-1 font-medium">
{{ locale.pageDescription }}
</p>
</div>
<div class="flex gap-3">
<button
:disabled="loading || saving"
class="flex items-center gap-2 px-5 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:cursor-not-allowed"
@click="resetForm"
>
<RotateCcw :size="14" /> {{ locale.reset }}
</button>
<button
:disabled="loading || saving"
class="flex items-center gap-2 px-8 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 disabled:cursor-not-allowed"
@click="saveConfig"
>
<template v-if="saving">
<AppSpinner :size="14" />
{{ locale.saving }}
</template>
<template v-else-if="saveSuccess"> <CheckCircle2 :size="14" /> {{ locale.saved }} </template>
<template v-else> <Save :size="14" /> {{ locale.saveConfig }} </template>
</button>
</div>
</div>
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
<AppSpinner :size="32" class="mb-4" />
<p class="text-text-tertiary text-sm">{{ locale.loading }}</p>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 基础信息 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-text-primary uppercase tracking-widest flex items-center gap-2 border-b border-border-secondary pb-4"
>
<Globe :size="16" class="text-primary" /> {{ locale.basicInfo }}
</h3>
<div class="space-y-4">
<div>
<label :class="labelClass">{{ locale.siteTitle }}</label>
<input
v-model="formData.siteTitle"
type="text"
:placeholder="locale.siteTitlePlaceholder"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.icpNumber }}</label>
<input
v-model="formData.icpNumber"
type="text"
:placeholder="locale.icpPlaceholder"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.gonganNumber }}</label>
<input
v-model="formData.gonganNumber"
type="text"
:placeholder="locale.gonganPlaceholder"
:class="inputClass"
/>
</div>
<div class="pt-2">
<div
class="flex items-center justify-between p-3 bg-bg-primary-50 border border-border-secondary rounded-xl"
>
<div>
<p class="text-xs font-bold text-text-primary">{{ locale.showBeianIcon }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.showBeianIconDesc }}</p>
</div>
<input
v-model="formData.showBeianIcon"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
</div>
<div>
<label :class="labelClass">{{ locale.siteDescription }}</label>
<textarea
v-model="formData.siteDescription"
:rows="3"
:placeholder="locale.siteDescriptionPlaceholder"
:class="[inputClass, 'resize-none']"
/>
</div>
<div class="pt-2">
<div
class="flex items-center justify-between p-3 bg-bg-primary-50 border border-border-secondary rounded-xl"
>
<div>
<p class="text-xs font-bold text-text-primary">{{ locale.statisticsCodeEnabled }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.statisticsCodeEnabledDesc }}</p>
</div>
<input
v-model="formData.statisticsCodeEnabled"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
</div>
<div>
<label :class="labelClass">{{ locale.statisticsCode }}</label>
<textarea
v-model="formData.statisticsCode"
:rows="5"
:placeholder="locale.statisticsCodePlaceholder"
:class="[inputClass, 'font-mono text-xs resize-y']"
:disabled="!formData.statisticsCodeEnabled"
/>
<p class="text-[10px] text-text-tertiary mt-1 leading-relaxed">{{ locale.statisticsCodeHint }}</p>
</div>
</div>
</section>
<!-- 视觉识别 -->
<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"
>
<ImageIcon :size="16" class="text-info" /> {{ locale.visualIdentity }}
</h3>
<div class="space-y-4">
<div>
<label :class="labelClass">{{ locale.siteLogoUrl }}</label>
<input
v-model="formData.siteLogoUrl"
type="text"
:placeholder="locale.siteLogoPlaceholder"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.schoolLogoHome }}</label>
<input
v-model="formData.schoolLogoHomeDarkUrl"
type="text"
:placeholder="locale.schoolLogoHomePlaceholder"
:class="inputClass"
/>
</div>
<div>
<label :class="labelClass">{{ locale.schoolLogoHomeLight }}</label>
<input
v-model="formData.schoolLogoHomeLightUrl"
type="text"
:disabled="!String(formData.schoolLogoHomeDarkUrl || '').trim()"
:placeholder="locale.schoolLogoHomeLightPlaceholder"
:class="[inputClass, 'disabled:cursor-not-allowed disabled:opacity-50']"
/>
</div>
<div>
<label :class="labelClass">{{ locale.schoolLogoPrint }}</label>
<input
v-model="formData.schoolLogoPrintUrl"
type="text"
:placeholder="locale.schoolLogoPrintPlaceholder"
:class="inputClass"
/>
</div>
</div>
</section>
<!-- 主题设置 -->
<section v-if="isSuperAdmin" :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">
<Palette :size="16" class="text-primary" /> {{ locale.themeSettings }}
</h3>
<div class="space-y-3">
<p class="text-[10px] text-text-tertiary">{{ locale.themeSettingsDesc }}</p>
<div
v-for="option in themeOptions"
:key="option.value"
class="flex items-center gap-3 rounded-xl border border-border-secondary bg-bg-primary-50 p-3"
>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-bold text-text-primary">{{ option.label }}</p>
<p v-if="option.value === 'System'" class="mt-0.5 text-[10px] text-text-tertiary">
{{ locale.systemThemeHint }}
</p>
</div>
<button
type="button"
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border transition-colors"
:class="formData.defaultTheme === option.value
? 'border-warning bg-warning-10 text-warning'
: 'border-border-secondary text-text-disabled hover:border-warning-30 hover:text-warning'"
:aria-label="locale.setDefaultTheme"
:aria-pressed="formData.defaultTheme === option.value"
@click="setDefaultTheme(option.value)"
>
<Star :size="15" :fill="formData.defaultTheme === option.value ? 'currentColor' : 'none'" />
</button>
<button
type="button"
class="relative h-6 w-12 shrink-0 rounded-full transition-colors"
:class="themeToggleClass(option.value)"
:aria-label="formData.enabledThemes.includes(option.value) ? locale.disableTheme : locale.enableTheme"
:aria-pressed="formData.enabledThemes.includes(option.value)"
:disabled="isThemeToggleLocked(option.value)"
@click="toggleTheme(option.value)"
>
<span
class="absolute left-1 top-1 h-4 w-4 rounded-full bg-white shadow transition-transform"
:class="formData.enabledThemes.includes(option.value) ? 'translate-x-6' : 'translate-x-0'"
/>
</button>
</div>
<p class="text-[10px] text-text-tertiary">{{ locale.defaultThemeDesc }}</p>
</div>
</section>
<!-- 投稿逻辑设置 -->
<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"
>
<Settings2 :size="16" class="text-warning" /> {{ locale.submissionLogic }}
</h3>
<div class="space-y-6">
<div
class="flex items-center justify-between p-3 bg-bg-primary-50 border border-border-secondary rounded-xl"
>
<div>
<p class="text-xs font-bold text-text-primary">{{ locale.enableCollaborative }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.enableCollaborativeDesc }}</p>
</div>
<input
v-model="formData.enableCollaborativeSubmission"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</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.enableRemarks }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.enableRemarksDesc }}</p>
</div>
<input
v-model="formData.enableSubmissionRemarks"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
<div
v-if="formData.enableSubmissionRemarks"
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.submissionNoteRequiresApproval }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.submissionNoteRequiresApprovalDesc }}</p>
</div>
<input
v-model="formData.submissionNoteRequiresApproval"
type="checkbox"
:disabled="!formData.enableSubmissionRemarks"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer disabled:cursor-not-allowed"
/>
</div>
</div>
</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.enableCardCodeRequests }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.enableCardCodeRequestsDesc }}</p>
</div>
<input
v-model="formData.enableCardCodeRequests"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
<div
v-if="formData.enableCardCodeRequests"
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.requireCardCodeForRequests }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.requireCardCodeForRequestsDesc }}</p>
</div>
<input
v-model="formData.requireCardCodeForRequests"
type="checkbox"
:disabled="!formData.enableCardCodeRequests"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer disabled:cursor-not-allowed"
/>
</div>
<div
v-if="formData.enableSubmissionLimit"
class="flex items-center justify-between p-3 bg-bg-primary border border-border-secondary rounded-xl transition-opacity"
>
<div class="pr-4">
<p class="text-xs font-bold text-text-primary">{{ locale.enableCardCodeLimitBypass }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5">
{{ locale.enableCardCodeLimitBypassDesc }}
</p>
</div>
<input
v-model="formData.enableCardCodeLimitBypass"
type="checkbox"
:disabled="!formData.enableCardCodeRequests || !formData.enableSubmissionLimit"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer disabled:cursor-not-allowed"
/>
</div>
</div>
</div>
<div
class="flex items-center justify-between p-3 bg-bg-primary-50 border border-border-secondary rounded-xl"
>
<div>
<p class="text-xs font-bold text-text-primary">{{ locale.enableReplay }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.enableReplayDesc }}</p>
</div>
<input
v-model="formData.enableReplayRequests"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</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.enableLimit }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.enableLimitDesc }}</p>
</div>
<input
v-model="formData.enableSubmissionLimit"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
<div
v-if="formData.enableSubmissionLimit"
class="ml-4 mr-2 mb-2 pl-4 border-l border-border-secondary space-y-2 transition-opacity"
>
<div class="grid grid-cols-3 gap-2 p-1 bg-bg-primary border border-border-secondary rounded-xl">
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
activeLimitTab === 'daily'
? 'bg-bg-tertiary text-primary shadow-sm'
: 'text-text-disabled hover:text-text-tertiary'
]"
:disabled="!formData.enableSubmissionLimit"
@click="handleLimitTypeChange('daily')"
>
{{ locale.dailyLimit }}
</button>
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
activeLimitTab === 'weekly'
? 'bg-bg-tertiary text-primary shadow-sm'
: 'text-text-disabled hover:text-text-tertiary'
]"
:disabled="!formData.enableSubmissionLimit"
@click="handleLimitTypeChange('weekly')"
>
{{ locale.weeklyLimit }}
</button>
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
activeLimitTab === 'monthly'
? 'bg-bg-tertiary text-primary shadow-sm'
: 'text-text-disabled hover:text-text-tertiary'
]"
:disabled="!formData.enableSubmissionLimit"
@click="handleLimitTypeChange('monthly')"
>
{{ locale.monthlyLimit }}
</button>
</div>
<div>
<label :class="labelClass">{{ currentLimitLabel }}</label>
<div class="relative">
<input
v-model.number="currentLimitValue"
type="number"
min="0"
:disabled="!formData.enableSubmissionLimit"
:class="[inputClass, 'disabled:cursor-not-allowed disabled:opacity-50']"
/>
<span
class="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-black text-text-secondary uppercase"
>{{ locale.limitUnit }}</span
>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 排期可见范围设置 -->
<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"
>
<CalendarRange :size="16" class="text-warning" /> {{ locale.scheduleVisibility }}
</h3>
<div class="space-y-4">
<div class="p-4 bg-bg-primary-50 border border-border-secondary rounded-xl space-y-4">
<p class="text-[10px] text-text-tertiary leading-relaxed">
{{ locale.scheduleVisibilityDesc }}
</p>
<div class="space-y-3">
<div class="flex items-center gap-3 p-3 bg-bg-primary border border-border-secondary rounded-xl">
<label class="flex items-center gap-2 shrink-0 cursor-pointer">
<span class="text-xs font-bold text-text-primary">{{ locale.daysBeforeEnabled }}</span>
<span
:class="[
'relative inline-flex h-5 w-10 items-center rounded-full transition-colors',
formData.scheduleDaysBeforeEnabled ? 'bg-primary' : 'bg-bg-tertiary'
]"
>
<input v-model="formData.scheduleDaysBeforeEnabled" type="checkbox" class="sr-only" />
<span
:class="[
'absolute top-1 h-3 w-3 rounded-full bg-bg-secondary transition-all',
formData.scheduleDaysBeforeEnabled ? 'left-6' : 'left-1'
]"
/>
</span>
</label>
<div class="relative flex-1">
<input
v-model.number="formData.scheduleDaysBefore"
:disabled="!formData.scheduleDaysBeforeEnabled"
type="number"
min="1"
max="730"
:class="inputClass"
/>
<span class="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-black text-text-secondary uppercase">{{ locale.daysUnit }}</span>
</div>
</div>
<p class="text-[10px] text-text-tertiary px-1">{{ locale.daysBeforeEnabledDesc }}</p>
<div class="flex items-center gap-3 p-3 bg-bg-primary border border-border-secondary rounded-xl">
<label class="flex items-center gap-2 shrink-0 cursor-pointer">
<span class="text-xs font-bold text-text-primary">{{ locale.daysAfterEnabled }}</span>
<span
:class="[
'relative inline-flex h-5 w-10 items-center rounded-full transition-colors',
formData.scheduleDaysAfterEnabled ? 'bg-primary' : 'bg-bg-tertiary'
]"
>
<input v-model="formData.scheduleDaysAfterEnabled" type="checkbox" class="sr-only" />
<span
:class="[
'absolute top-1 h-3 w-3 rounded-full bg-bg-secondary transition-all',
formData.scheduleDaysAfterEnabled ? 'left-6' : 'left-1'
]"
/>
</span>
</label>
<div class="relative flex-1">
<input
v-model.number="formData.scheduleDaysAfter"
:disabled="!formData.scheduleDaysAfterEnabled"
type="number"
min="1"
max="730"
:class="inputClass"
/>
<span class="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-black text-text-secondary uppercase">{{ locale.daysUnit }}</span>
</div>
</div>
<p class="text-[10px] text-text-tertiary px-1">{{ locale.daysAfterEnabledDesc }}</p>
</div>
</div>
</div>
</section>
<!-- 安全与隐私设置 -->
<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-error" /> {{ locale.securityPrivacy }}
</h3>
<div class="space-y-4">
<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.captchaEnabled }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.captchaEnabledDesc }}</p>
</div>
<input
id="captcha-enabled"
v-model="formData.captchaEnabled"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
<div
v-if="formData.captchaEnabled"
class="ml-4 mr-2 mb-2 pl-4 border-l border-border-secondary space-y-2 transition-opacity"
>
<div class="p-3 bg-bg-primary border border-border-secondary rounded-xl space-y-3">
<!-- 验证码类型选择 -->
<div>
<label class="block text-xs font-bold text-text-tertiary mb-2">{{ locale.captchaType }}</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="formData.captchaProvider"
type="radio"
value="graphic"
:disabled="!formData.captchaEnabled"
class="w-4 h-4 rounded-full border-border-secondary bg-bg-secondary cursor-pointer disabled:cursor-not-allowed"
/>
<span class="text-sm text-text-secondary">{{ locale.captchaGraphic }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="formData.captchaProvider"
type="radio"
value="turnstile"
:disabled="!formData.captchaEnabled"
class="w-4 h-4 rounded-full border-border-secondary bg-bg-secondary cursor-pointer disabled:cursor-not-allowed"
/>
<span class="text-sm text-text-secondary">{{ locale.captchaTurnstile }}</span>
</label>
</div>
</div>
<!-- 图形验证码配置 -->
<div v-if="formData.captchaProvider === 'graphic'">
<label class="block text-xs font-bold text-text-tertiary mb-2">{{ locale.captchaMaxFailures }}</label>
<input
v-model.number="formData.captchaMaxFailures"
type="number"
min="1"
:disabled="!formData.captchaEnabled"
:placeholder="locale.captchaMaxFailuresPlaceholder"
class="w-full max-w-[200px] bg-bg-secondary border border-border-secondary rounded-lg px-3 py-2 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all disabled:cursor-not-allowed disabled:opacity-50"
/>
<p class="text-[10px] text-text-tertiary mt-1">
{{ locale.captchaMaxFailuresDesc }}
</p>
</div>
<!-- Turnstile 配置 -->
<div v-if="formData.captchaProvider === 'turnstile'" class="space-y-3">
<div>
<label class="block text-xs font-bold text-text-tertiary mb-2">{{ locale.turnstileSiteKey }}</label>
<input
v-model="formData.turnstileSiteKey"
type="text"
:disabled="!formData.captchaEnabled"
:placeholder="locale.turnstileSiteKeyPlaceholder"
class="w-full bg-bg-secondary border border-border-secondary rounded-lg px-3 py-2 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div>
<label class="block text-xs font-bold text-text-tertiary mb-2">{{ locale.turnstileSecretKey }}</label>
<input
v-model="formData.turnstileSecretKey"
type="password"
:disabled="!formData.captchaEnabled"
:placeholder="locale.turnstileSecretKeyPlaceholder"
class="w-full bg-bg-secondary border border-border-secondary rounded-lg px-3 py-2 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all disabled:cursor-not-allowed disabled:opacity-50"
/>
<p class="text-[10px] text-text-tertiary mt-1">
{{ locale.turnstileSecretKeyDesc }}
</p>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between p-3 bg-bg-primary-50 border border-border-secondary rounded-xl">
<div class="pr-4">
<p class="text-xs font-bold text-text-primary">{{ locale.forcePasswordChangeOnFirstLogin }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.forcePasswordChangeOnFirstLoginDesc }}</p>
</div>
<input
id="force-password-change-first-login"
v-model="formData.forcePasswordChangeOnFirstLogin"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</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.allowRegister }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.allowRegisterDesc }}</p>
</div>
<input
id="allow-register"
v-model="formData.allowRegister"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
<div
v-if="formData.allowRegister || formData.allowOAuthRegistration"
class="ml-4 mr-2 mb-2 pl-4 border-l border-border-secondary space-y-2 transition-opacity"
>
<div
v-if="formData.allowRegister"
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.registerRequiresApproval }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.registerRequiresApprovalDesc }}</p>
</div>
<input
id="register-requires-approval"
v-model="formData.registerRequiresApproval"
type="checkbox"
:disabled="!formData.allowRegister"
class="w-5 h-5 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">{{ locale.registerEmailRequired }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.registerEmailRequiredDesc }}</p>
<p v-if="!formData.smtpEnabled" class="text-[10px] text-warning mt-1 leading-relaxed">{{ locale.registerEmailSmtpRequired }}</p>
</div>
<input
id="register-email-required"
v-model="formData.registerEmailRequired"
type="checkbox"
:disabled="(!formData.allowRegister && !formData.allowOAuthRegistration) || !formData.smtpEnabled"
class="w-5 h-5 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">{{ locale.registerRequiresGradeClass }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.registerRequiresGradeClassDesc }}</p>
</div>
<input
id="register-requires-grade-class"
v-model="formData.registerRequiresGradeClass"
type="checkbox"
:disabled="!formData.allowRegister && !formData.allowOAuthRegistration"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer disabled:cursor-not-allowed"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between p-3 bg-bg-primary-50 border border-border-secondary rounded-xl">
<div class="pr-4">
<p class="text-xs font-bold text-text-primary">{{ locale.showBlacklistKeywords }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.showBlacklistKeywordsDesc }}</p>
</div>
<input
id="show-keywords"
v-model="formData.showBlacklistKeywords"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
<div class="flex items-center justify-between p-3 bg-bg-primary-50 border border-border-secondary rounded-xl">
<div class="pr-4">
<p class="text-xs font-bold text-text-primary">{{ locale.hideStudentInfo }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">{{ locale.hideStudentInfoDesc }}</p>
</div>
<input
id="hide-students"
v-model="formData.hideStudentInfo"
type="checkbox"
class="w-5 h-5 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
<div class="flex items-center justify-between p-3 bg-bg-primary-50 border border-border-secondary rounded-xl">
<div class="pr-4">
<p class="text-xs font-bold text-text-primary">{{ locale.telemetryEnabled }}</p>
<p class="text-[10px] text-text-tertiary mt-0.5 leading-relaxed">
{{ locale.telemetryEnabledDesc }} <strong class="text-text-tertiary">{{ locale.telemetryPrivacy }}</strong>
</p>
</div>
<input
id="telemetry-enabled"
v-model="formData.telemetryEnabled"
type="checkbox"
class="w-5 h-5 shrink-0 rounded border-border-secondary bg-bg-secondary cursor-pointer"
/>
</div>
<div
class="p-4 bg-primary-5 border border-primary-10 rounded-xl flex items-start gap-3"
>
<AlertCircle class="text-primary shrink-0 mt-0.5" :size="14" />
<p class="text-[10px] text-text-tertiary leading-normal">
{{ locale.configWarning }}
</p>
</div>
</div>
</section>
<!-- 投稿须知 -->
<section
class="lg:col-span-2 bg-bg-secondary-40 border border-border-secondary rounded-2xl p-6 space-y-6"
>
<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"
>
<FileText :size="16" class="text-success" /> {{ locale.submissionGuidelines }}
</h3>
<div class="flex gap-1 bg-bg-primary rounded-lg p-1">
<button
:class="[
'px-3 py-1.5 text-[10px] font-bold rounded-md transition-all uppercase tracking-wider',
editMode === 'edit'
? 'bg-primary-hover text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
]"
@click="editMode = 'edit'"
>
{{ locale.guidelinesEdit }}
</button>
<button
:class="[
'px-3 py-1.5 text-[10px] font-bold rounded-md transition-all uppercase tracking-wider',
editMode === 'preview'
? 'bg-primary-hover text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
]"
@click="editMode = 'preview'"
>
{{ locale.guidelinesPreview }}
</button>
</div>
</div>
<textarea
v-if="editMode === 'edit'"
v-model="formData.submissionGuidelines"
:rows="6"
:placeholder="locale.guidelinesPlaceholder"
:class="[inputClass, 'font-mono text-xs leading-relaxed min-h-[150px]']"
/>
<div
v-else
class="guidelines-preview markdown-body w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-sm text-text-secondary leading-relaxed min-h-[150px] max-h-[400px] overflow-y-auto"
v-html="renderedPreview"
/>
</section>
<!-- OAuth 第三方登录配置 -->
<OAuthConfigManager v-model="formData" class="lg:col-span-2" />
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import {
Globe,
ImageIcon,
FileText,
Settings2,
Shield,
Save,
RotateCcw,
CheckCircle2,
AlertCircle,
Palette,
Star,
CalendarRange
} from '@lucide/vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import { useToast } from '~/composables/useToast'
import { joinThemeLogoUrl, splitThemeLogoUrl, useSiteConfig } from '~/composables/useSiteConfig'
import { useLocale } from '~/utils/locale'
import { useServerErrors } from '~/composables/useLocaleText'
import { renderMarkdown } from '~/utils/markdown'
import { getAggregateOAuthLoginTypesOrDefault } from '~/utils/oauth'
import { usePermissions } from '~/composables/usePermissions'
import { THEMES } from '~/composables/useTheme'
import OAuthConfigManager from './OAuthConfigManager.vue'
const { showToast: showNotification } = useToast()
const { refreshSiteConfig } = useSiteConfig()
const { siteConfig: locale, theme: themeLocale } = useLocale()
const { isSuperAdmin } = usePermissions()
const { localize: localizeServerError } = useServerErrors()
const themeOptions = computed(() => THEMES.map((value) => ({ value, label: themeLocale.value?.[value] || value })))
const setDefaultTheme = (theme) => {
formData.value.defaultTheme = theme
const enabled = new Set(formData.value.enabledThemes)
enabled.add(theme)
formData.value.enabledThemes = THEMES.filter((item) => enabled.has(item))
if (theme === 'System') {
const enabled = new Set(formData.value.enabledThemes)
enabled.add('System')
enabled.add('ClassicDark')
enabled.add('ClassicLight')
formData.value.enabledThemes = THEMES.filter((item) => enabled.has(item))
showNotification(locale.value?.systemThemeAutoEnabled || '跟随系统需要经典深色和经典浅色,已自动启用', 'info')
}
}
const isThemeToggleLocked = (theme) => {
if (formData.value.defaultTheme === theme) return true
if (formData.value.enabledThemes.length <= 1 && formData.value.enabledThemes.includes(theme)) return true
return (theme === 'ClassicDark' || theme === 'ClassicLight') && formData.value.enabledThemes.includes('System')
}
const themeToggleClass = (theme) => {
if (isThemeToggleLocked(theme)) return 'bg-primary-80 opacity-50 cursor-not-allowed'
return formData.value.enabledThemes.includes(theme) ? 'bg-primary' : 'bg-bg-quaternary'
}
const toggleTheme = (theme) => {
if (isThemeToggleLocked(theme)) return
const enabled = new Set(formData.value.enabledThemes)
if (enabled.has(theme)) {
if (enabled.size <= 1 || formData.value.defaultTheme === theme) return
if (theme === 'ClassicDark' || theme === 'ClassicLight') {
if (enabled.has('System')) {
showNotification(locale.value?.systemThemeRequiresBothClassic || '启用跟随系统时,经典深色和经典浅色必须同时启用', 'info')
return
}
}
enabled.delete(theme)
} else {
enabled.add(theme)
}
formData.value.enabledThemes = THEMES.filter((item) => enabled.has(item))
}
const parseJsonArray = (value, fallback) => {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value
if (!Array.isArray(parsed) || parsed.length === 0) return fallback
let themes = parsed.filter((item) => THEMES.includes(item))
// 脏数据防御:跟随系统依赖经典深色/浅色同时启用,缺失时剔除 System
if (themes.includes('System') && (!themes.includes('ClassicDark') || !themes.includes('ClassicLight'))) {
themes = themes.filter((item) => item !== 'System')
}
return themes.length > 0 ? themes : fallback
} catch {
return fallback
}
}
const loading = ref(true)
const saving = ref(false)
const saveSuccess = ref(false)
const editMode = ref('edit') // 投稿须知编辑/预览模式
// 投稿须知 Markdown 预览
const renderedPreview = computed(() => renderMarkdown(formData.value.submissionGuidelines))
// 样式类常量
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 defaultSubmissionGuidelines = computed(() => locale.value?.defaultSubmissionGuidelines || '请遵守校园广播站投稿规范。')
const formData = ref({
siteTitle: '',
siteLogoUrl: '',
schoolLogoHomeDarkUrl: '',
schoolLogoHomeLightUrl: '',
schoolLogoPrintUrl: '',
siteDescription: '',
submissionGuidelines: '',
icpNumber: '',
gonganNumber: '',
statisticsCode: '',
statisticsCodeEnabled: false,
showBeianIcon: false,
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false,
submissionNoteRequiresApproval: false,
enableReplayRequests: false,
enableSubmissionLimit: false,
// 点歌券点歌设置
enableCardCodeRequests: false,
requireCardCodeForRequests: false,
enableCardCodeLimitBypass: false,
dailySubmissionLimit: 5,
weeklySubmissionLimit: null,
monthlySubmissionLimit: null,
scheduleDaysBeforeEnabled: false,
scheduleDaysBefore: 1,
scheduleDaysAfterEnabled: false,
scheduleDaysAfter: 1,
showBlacklistKeywords: false,
hideStudentInfo: true,
forcePasswordChangeOnFirstLogin: false,
telemetryEnabled: true,
captchaEnabled: false,
captchaProvider: 'graphic',
turnstileSiteKey: '',
turnstileSecretKey: '',
captchaMaxFailures: 3,
allowRegister: false,
registerRequiresApproval: true,
oauthRegisterRequiresApproval: true,
registerEmailRequired: false,
registerRequiresGradeClass: false,
smtpEnabled: false,
allowOAuthRegistration: false,
oauthRedirectUri: '',
oauthStateSecret: '',
githubOAuthEnabled: false,
githubClientId: '',
githubClientSecret: '',
casdoorOAuthEnabled: false,
casdoorServerUrl: '',
casdoorClientId: '',
casdoorClientSecret: '',
casdoorOrganizationName: '',
googleOAuthEnabled: false,
googleClientId: '',
googleClientSecret: '',
aggregateOAuthEnabled: false,
aggregateOAuthAppId: '',
aggregateOAuthAppKey: '',
aggregateOAuthLoginType: ['qq'],
aggregateOAuthEndpoint: '',
customOAuthEnabled: false,
customOAuthDisplayName: '',
customOAuthAuthorizeUrl: '',
customOAuthTokenUrl: '',
customOAuthUserInfoUrl: '',
customOAuthScope: '',
customOAuthClientId: '',
customOAuthClientSecret: '',
customOAuthUserIdField: '',
customOAuthUsernameField: '',
customOAuthNameField: '',
customOAuthEmailField: '',
customOAuthAvatarField: '',
defaultTheme: 'System',
enabledThemes: [...THEMES]
})
const originalData = ref({})
// 当前限额类型和值的快捷访问
const activeLimitTab = ref('daily')
// 根据数据中的限额值同步当前激活的标签页
const syncActiveLimitTab = (data) => {
if (data.monthlySubmissionLimit != null) {
activeLimitTab.value = 'monthly'
} else if (data.weeklySubmissionLimit != null) {
activeLimitTab.value = 'weekly'
} else {
activeLimitTab.value = 'daily'
}
}
const currentLimitValue = computed({
get: () => {
if (activeLimitTab.value === 'monthly') return formData.value.monthlySubmissionLimit
return activeLimitTab.value === 'daily'
? formData.value.dailySubmissionLimit
: formData.value.weeklySubmissionLimit
},
set: (val) => {
if (activeLimitTab.value === 'monthly') {
formData.value.monthlySubmissionLimit = val
} else if (activeLimitTab.value === 'daily') {
formData.value.dailySubmissionLimit = val
} else {
formData.value.weeklySubmissionLimit = val
}
}
})
const currentLimitLabel = computed(() => {
const limitTypeLabel =
activeLimitTab.value === 'daily'
? locale.value?.dailyLimitLabel
: activeLimitTab.value === 'weekly'
? locale.value?.weeklyLimitLabel
: locale.value?.monthlyLimitLabel
return `${locale.value?.limitLabelPrefix || '当前启用:'}${limitTypeLabel || '未设置限额'}${locale.value?.limitLabelSuffix || '投稿限制'}`
})
// 加载配置
const loadConfig = async () => {
try {
loading.value = true
const response = await fetch('/api/admin/system-settings', {
credentials: 'include'
})
if (!response.ok) throw new Error(locale.value?.fetchFailed || 'Failed to load site config')
const data = await response.json()
syncActiveLimitTab(data)
const schoolLogoHome = splitThemeLogoUrl(data.schoolLogoHomeUrl)
formData.value = {
siteTitle: data.siteTitle || '',
siteLogoUrl: data.siteLogoUrl || '',
schoolLogoHomeDarkUrl: schoolLogoHome.dark,
schoolLogoHomeLightUrl: schoolLogoHome.light,
schoolLogoPrintUrl: data.schoolLogoPrintUrl || '',
siteDescription: data.siteDescription || '',
submissionGuidelines: data.submissionGuidelines || defaultSubmissionGuidelines.value,
icpNumber: data.icpNumber || '',
gonganNumber: data.gonganNumber || '',
statisticsCode: data.statisticsCode || '',
statisticsCodeEnabled: !!data.statisticsCodeEnabled,
showBeianIcon: !!data.showBeianIcon,
enableCollaborativeSubmission: data.enableCollaborativeSubmission !== false,
enableSubmissionRemarks: !!data.enableSubmissionRemarks,
submissionNoteRequiresApproval: !!data.submissionNoteRequiresApproval,
enableReplayRequests: !!data.enableReplayRequests,
enableSubmissionLimit: !!data.enableSubmissionLimit,
// 点歌券点歌设置
enableCardCodeRequests: !!data.enableCardCodeRequests,
requireCardCodeForRequests: !!data.requireCardCodeForRequests,
enableCardCodeLimitBypass: !!data.enableCardCodeLimitBypass,
dailySubmissionLimit: data.dailySubmissionLimit ?? 5,
weeklySubmissionLimit: data.weeklySubmissionLimit ?? null,
monthlySubmissionLimit: data.monthlySubmissionLimit ?? null,
scheduleDaysBeforeEnabled: data.scheduleDaysBeforeEnabled === true,
scheduleDaysBefore: data.scheduleDaysBefore ?? 1,
scheduleDaysAfterEnabled: data.scheduleDaysAfterEnabled === true,
scheduleDaysAfter: data.scheduleDaysAfter ?? 1,
showBlacklistKeywords: !!data.showBlacklistKeywords,
hideStudentInfo: data.hideStudentInfo ?? true,
forcePasswordChangeOnFirstLogin: data.forcePasswordChangeOnFirstLogin === true,
telemetryEnabled: !!data.telemetryEnabled,
captchaEnabled: !!data.captchaEnabled,
captchaProvider: data.captchaProvider || 'graphic',
turnstileSiteKey: data.turnstileSiteKey || '',
turnstileSecretKey: undefined,
captchaMaxFailures: data.captchaMaxFailures ?? 3,
allowOAuthRegistration: !!data.allowOAuthRegistration,
allowRegister: !!data.allowRegister,
registerRequiresApproval: data.registerRequiresApproval !== false,
oauthRegisterRequiresApproval: data.oauthRegisterRequiresApproval !== false,
registerEmailRequired: data.registerEmailRequired === true,
registerRequiresGradeClass: data.registerRequiresGradeClass === true,
smtpEnabled: !!data.smtpEnabled,
oauthRedirectUri: data.oauthRedirectUri || '',
oauthStateSecret: data.oauthStateSecret || '',
githubOAuthEnabled: !!data.githubOAuthEnabled,
githubClientId: data.githubClientId || '',
githubClientSecret: data.githubClientSecret || '',
casdoorOAuthEnabled: !!data.casdoorOAuthEnabled,
casdoorServerUrl: data.casdoorServerUrl || '',
casdoorClientId: data.casdoorClientId || '',
casdoorClientSecret: data.casdoorClientSecret || '',
casdoorOrganizationName: data.casdoorOrganizationName || '',
googleOAuthEnabled: !!data.googleOAuthEnabled,
googleClientId: data.googleClientId || '',
googleClientSecret: data.googleClientSecret || '',
aggregateOAuthEnabled: !!data.aggregateOAuthEnabled,
aggregateOAuthAppId: data.aggregateOAuthAppId || '',
aggregateOAuthAppKey: data.aggregateOAuthAppKey || '',
aggregateOAuthLoginType: getAggregateOAuthLoginTypesOrDefault(data.aggregateOAuthLoginType),
aggregateOAuthEndpoint: data.aggregateOAuthEndpoint || 'https://a.idcfx.net/connect.php',
customOAuthEnabled: !!data.customOAuthEnabled,
customOAuthDisplayName: data.customOAuthDisplayName || '',
customOAuthAuthorizeUrl: data.customOAuthAuthorizeUrl || '',
customOAuthTokenUrl: data.customOAuthTokenUrl || '',
customOAuthUserInfoUrl: data.customOAuthUserInfoUrl || '',
customOAuthScope: data.customOAuthScope || '',
customOAuthClientId: data.customOAuthClientId || '',
customOAuthClientSecret: data.customOAuthClientSecret || '',
customOAuthUserIdField: data.customOAuthUserIdField || '',
customOAuthUsernameField: data.customOAuthUsernameField || '',
customOAuthNameField: data.customOAuthNameField || '',
customOAuthEmailField: data.customOAuthEmailField || '',
customOAuthAvatarField: data.customOAuthAvatarField || '',
defaultTheme: data.defaultTheme || 'System',
enabledThemes: parseJsonArray(data.enabledThemes, [...THEMES])
}
originalData.value = JSON.parse(JSON.stringify(formData.value))
} catch (error) {
console.error('Failed to load site config:', error)
showNotification(locale.value?.loadFailed || '系统设置加载失败', 'error')
} finally {
loading.value = false
}
}
// 保存配置
const saveConfig = async () => {
try {
if (isSuperAdmin.value) {
if (!formData.value.enabledThemes.includes(formData.value.defaultTheme)) {
showNotification(locale.value?.defaultThemeMustBeEnabled || '默认主题必须处于启用状态', 'error')
return
}
if (formData.value.enabledThemes.includes('System') && (!formData.value.enabledThemes.includes('ClassicDark') || !formData.value.enabledThemes.includes('ClassicLight'))) {
showNotification(locale.value?.systemThemeRequiresBothClassic || '启用跟随系统时,经典深色和经典浅色必须同时启用', 'error')
return
}
}
saving.value = true
const schoolLogoHomeDarkUrl = (formData.value.schoolLogoHomeDarkUrl || '').trim()
const schoolLogoHomeLightUrl = schoolLogoHomeDarkUrl
? (formData.value.schoolLogoHomeLightUrl || '').trim()
: ''
const configToSave = {
...formData.value,
schoolLogoHomeUrl: joinThemeLogoUrl(
schoolLogoHomeDarkUrl,
schoolLogoHomeLightUrl
),
siteTitle: (formData.value.siteTitle || '').trim() || locale.value?.defaultSiteTitle || 'VoiceHub',
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/favicon.ico',
submissionGuidelines:
(formData.value.submissionGuidelines || '').trim() || defaultSubmissionGuidelines.value,
// 确保根据限额类型处理空值
dailySubmissionLimit:
activeLimitTab.value === 'daily' ? formData.value.dailySubmissionLimit : null,
weeklySubmissionLimit:
activeLimitTab.value === 'weekly' ? formData.value.weeklySubmissionLimit : null,
monthlySubmissionLimit:
activeLimitTab.value === 'monthly' ? formData.value.monthlySubmissionLimit : null,
...(isSuperAdmin.value
? {
defaultTheme: formData.value.defaultTheme,
enabledThemes: JSON.stringify(formData.value.enabledThemes)
}
: {})
}
if (!isSuperAdmin.value) {
delete configToSave.defaultTheme
delete configToSave.enabledThemes
}
delete configToSave.schoolLogoHomeDarkUrl
delete configToSave.schoolLogoHomeLightUrl
const response = await fetch('/api/admin/system-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(configToSave)
})
if (!response.ok) {
let message = locale.value?.saveFailed || '系统设置保存失败'
try {
const errorData = await response.json()
console.error('Site config API error response:', errorData)
message = localizeServerError(errorData, locale.value?.saveFailed || '系统设置保存失败')
} catch (parseError) {
console.error('Failed to parse site config API error:', parseError)
}
throw new Error(message)
}
saveSuccess.value = true
formData.value = {
...formData.value,
siteTitle: configToSave.siteTitle,
siteLogoUrl: configToSave.siteLogoUrl
}
originalData.value = JSON.parse(JSON.stringify(formData.value))
localStorage.setItem('voicehub.telemetryEnabled', configToSave.telemetryEnabled ? 'true' : 'false')
// 刷新前端模块级缓存,避免首页等页面继续使用旧配置
await refreshSiteConfig()
showNotification(locale.value?.saveSuccess || '系统设置已保存', 'success')
setTimeout(() => {
saveSuccess.value = false
}, 3000)
} catch (error) {
console.error('Failed to save site config:', error)
let message = locale.value?.saveFailedRetry || '系统设置保存失败,请稍后重试'
if (error?.message) {
message = localizeServerError(error, error.message)
}
showNotification(message, 'error')
} finally {
saving.value = false
}
}
// 处理限额类型变化
const handleLimitTypeChange = (type) => {
activeLimitTab.value = type
const limits = {
daily: { key: 'dailySubmissionLimit', default: 5 },
weekly: { key: 'weeklySubmissionLimit', default: 20 },
monthly: { key: 'monthlySubmissionLimit', default: 50 }
}
// 如果当前类型的限额为 null,则设置默认值
const targetLimit = limits[type]
if (formData.value[targetLimit.key] === null) {
formData.value[targetLimit.key] = targetLimit.default
}
}
// 重置表单
const resetForm = () => {
formData.value = JSON.parse(JSON.stringify(originalData.value))
syncActiveLimitTab(formData.value)
}
onMounted(loadConfig)
</script>
<style scoped>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
}
</style>