mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
feat(email): add admin notification settings
This commit is contained in:
@@ -16,6 +16,7 @@ import AdminTemplatesView from '@/views/admin/AdminTemplatesView.vue'
|
||||
import AdminRecordsView from '@/views/admin/AdminRecordsView.vue'
|
||||
import AdminLogsView from '@/views/admin/AdminLogsView.vue'
|
||||
import AdminStatsView from '@/views/admin/AdminStatsView.vue'
|
||||
import AdminEmailSettingsView from '@/views/admin/AdminEmailSettingsView.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuth()
|
||||
@@ -46,6 +47,8 @@ const view = computed(() => {
|
||||
return AdminLogsView
|
||||
case 'admin-stats':
|
||||
return AdminStatsView
|
||||
case 'admin-email-settings':
|
||||
return AdminEmailSettingsView
|
||||
default:
|
||||
return NotFoundView
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
CheckInStartResponse,
|
||||
CreateTaskFromTemplatePayload,
|
||||
CronValidation,
|
||||
EmailNotificationSettings,
|
||||
EmailNotificationSettingsUpdate,
|
||||
LoginResponse,
|
||||
LogsResponse,
|
||||
PaginatedResponse,
|
||||
@@ -118,6 +120,9 @@ export const adminApi = {
|
||||
),
|
||||
batchCheckIn: (task_ids: number[]) =>
|
||||
apiClient.post<unknown>('/api/admin/batch_check_in', { task_ids }),
|
||||
emailSettings: () => apiClient.get<EmailNotificationSettings>('/api/admin/email_settings'),
|
||||
updateEmailSettings: (payload: EmailNotificationSettingsUpdate) =>
|
||||
apiClient.put<EmailNotificationSettings>('/api/admin/email_settings', payload),
|
||||
}
|
||||
|
||||
export type * from './types'
|
||||
|
||||
@@ -202,6 +202,30 @@ export interface LogsResponse {
|
||||
logs: string
|
||||
}
|
||||
|
||||
export interface EmailNotificationSettings {
|
||||
id: number
|
||||
smtp_server: string
|
||||
smtp_port: number
|
||||
smtp_sender_email: string
|
||||
smtp_use_ssl: boolean
|
||||
notify_token_expiring: boolean
|
||||
notify_check_in_success: boolean
|
||||
has_smtp_sender_password: boolean
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
export interface EmailNotificationSettingsUpdate {
|
||||
smtp_server: string
|
||||
smtp_port: number
|
||||
smtp_sender_email: string
|
||||
smtp_use_ssl: boolean
|
||||
notify_token_expiring: boolean
|
||||
notify_check_in_success: boolean
|
||||
smtp_sender_password?: string
|
||||
clear_smtp_sender_password?: boolean
|
||||
}
|
||||
|
||||
export interface CronValidation {
|
||||
valid: boolean
|
||||
message: string
|
||||
|
||||
@@ -14,6 +14,7 @@ export type RouteKey =
|
||||
| 'admin-records'
|
||||
| 'admin-logs'
|
||||
| 'admin-stats'
|
||||
| 'admin-email-settings'
|
||||
| 'not-found'
|
||||
|
||||
export interface AppRoute {
|
||||
@@ -67,6 +68,13 @@ export const routes: AppRoute[] = [
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
},
|
||||
{
|
||||
key: 'admin-email-settings',
|
||||
path: '/admin/email-settings',
|
||||
title: '邮件设置',
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
},
|
||||
{ key: 'not-found', path: '/:pathMatch(.*)*', title: '页面未找到' },
|
||||
]
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Mail,
|
||||
Menu,
|
||||
Monitor,
|
||||
MoonStar,
|
||||
@@ -43,6 +44,7 @@ const adminLinks = [
|
||||
{ path: '/admin/records', label: '全量记录', icon: ScrollText },
|
||||
{ path: '/admin/logs', label: '日志', icon: Shield },
|
||||
{ path: '/admin/stats', label: '统计', icon: BarChart3 },
|
||||
{ path: '/admin/email-settings', label: '邮件', icon: Mail },
|
||||
]
|
||||
|
||||
const title = computed(() => router.current.value.title)
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { Mail, RefreshCw, Save } from 'lucide-vue-next'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { adminApi, type EmailNotificationSettings } from '@/api'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import {
|
||||
alertClass,
|
||||
cardClass,
|
||||
inputClass,
|
||||
labelClass,
|
||||
sectionHeaderClass,
|
||||
toneClass,
|
||||
} from '@/components/ui'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { extractErrorMessage, formatFullDateTime } from '@/utils/format'
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const savedMessage = ref('')
|
||||
const settings = ref<EmailNotificationSettings | null>(null)
|
||||
const form = reactive({
|
||||
smtp_server: '',
|
||||
smtp_port: 465,
|
||||
smtp_sender_email: '',
|
||||
smtp_sender_password: '',
|
||||
clear_smtp_sender_password: false,
|
||||
smtp_use_ssl: true,
|
||||
notify_token_expiring: true,
|
||||
notify_check_in_success: true,
|
||||
})
|
||||
|
||||
const passwordState = computed(() => {
|
||||
if (form.clear_smtp_sender_password) return '保存后清空'
|
||||
if (form.smtp_sender_password) return '保存后替换'
|
||||
if (settings.value?.has_smtp_sender_password) return '已保存'
|
||||
return '未设置'
|
||||
})
|
||||
|
||||
function hydrate(next: EmailNotificationSettings) {
|
||||
settings.value = next
|
||||
form.smtp_server = next.smtp_server
|
||||
form.smtp_port = next.smtp_port
|
||||
form.smtp_sender_email = next.smtp_sender_email
|
||||
form.smtp_sender_password = ''
|
||||
form.clear_smtp_sender_password = false
|
||||
form.smtp_use_ssl = next.smtp_use_ssl
|
||||
form.notify_token_expiring = next.notify_token_expiring
|
||||
form.notify_check_in_success = next.notify_check_in_success
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
savedMessage.value = ''
|
||||
try {
|
||||
hydrate(await adminApi.emailSettings())
|
||||
} catch (err) {
|
||||
error.value = extractErrorMessage(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
savedMessage.value = ''
|
||||
try {
|
||||
const next = await adminApi.updateEmailSettings({
|
||||
smtp_server: form.smtp_server,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_sender_email: form.smtp_sender_email,
|
||||
smtp_use_ssl: form.smtp_use_ssl,
|
||||
notify_token_expiring: form.notify_token_expiring,
|
||||
notify_check_in_success: form.notify_check_in_success,
|
||||
smtp_sender_password: form.smtp_sender_password || undefined,
|
||||
clear_smtp_sender_password: form.clear_smtp_sender_password,
|
||||
})
|
||||
hydrate(next)
|
||||
savedMessage.value = '邮件设置已保存'
|
||||
} catch (err) {
|
||||
error.value = extractErrorMessage(err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateBlock v-if="loading" title="正在加载邮件设置" type="loading" />
|
||||
<StateBlock
|
||||
v-else-if="error && !settings"
|
||||
title="邮件设置加载失败"
|
||||
:description="error"
|
||||
type="error"
|
||||
action-label="重试"
|
||||
@action="load"
|
||||
/>
|
||||
<form v-else class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]" @submit.prevent="save">
|
||||
<section :class="[cardClass, 'min-w-0 overflow-hidden']">
|
||||
<div :class="sectionHeaderClass">
|
||||
<div>
|
||||
<h2 class="font-semibold">SMTP 配置</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span :class="toneClass(settings?.has_smtp_sender_password ? 'success' : 'warning')">
|
||||
密码{{ passwordState }}
|
||||
</span>
|
||||
<Button variant="outline" type="button" :disabled="saving" @click="load">
|
||||
<RefreshCw class="size-4" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="submit" :disabled="saving">
|
||||
<Save class="size-4" />
|
||||
{{ saving ? '保存中' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 p-4 md:grid-cols-2">
|
||||
<label class="grid gap-2 md:col-span-2">
|
||||
<span :class="labelClass">SMTP SERVER</span>
|
||||
<input
|
||||
v-model.trim="form.smtp_server"
|
||||
:class="inputClass"
|
||||
maxlength="255"
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span :class="labelClass">SMTP PORT</span>
|
||||
<input
|
||||
v-model.number="form.smtp_port"
|
||||
:class="inputClass"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span :class="labelClass">发件邮箱</span>
|
||||
<input
|
||||
v-model.trim="form.smtp_sender_email"
|
||||
:class="inputClass"
|
||||
type="email"
|
||||
maxlength="255"
|
||||
placeholder="mailer@example.com"
|
||||
/>
|
||||
</label>
|
||||
<label class="grid gap-2 md:col-span-2">
|
||||
<span :class="labelClass">SMTP 密码</span>
|
||||
<input
|
||||
v-model="form.smtp_sender_password"
|
||||
:class="inputClass"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
maxlength="500"
|
||||
placeholder="留空则保持现有密码"
|
||||
:disabled="form.clear_smtp_sender_password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="grid gap-3 rounded-lg border border-border bg-background p-3 md:col-span-2 sm:grid-cols-2"
|
||||
>
|
||||
<label class="flex items-center gap-3 text-sm font-medium">
|
||||
<input v-model="form.smtp_use_ssl" type="checkbox" class="size-4 accent-primary" />
|
||||
使用 SSL 连接 SMTP
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm font-medium">
|
||||
<input
|
||||
v-model="form.clear_smtp_sender_password"
|
||||
type="checkbox"
|
||||
class="size-4 accent-primary"
|
||||
:disabled="Boolean(form.smtp_sender_password)"
|
||||
/>
|
||||
保存时清空 SMTP 密码
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside :class="[cardClass, 'grid h-fit gap-4 overflow-hidden xl:sticky xl:top-20']">
|
||||
<div class="border-b border-border bg-muted/55 px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Mail class="size-4 text-muted-foreground" />
|
||||
<h2 class="font-semibold">通知策略</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 p-4">
|
||||
<label class="grid gap-2 rounded-lg border border-border bg-background p-3">
|
||||
<span class="flex items-center justify-between gap-3 text-sm font-medium">
|
||||
Token 即将过期提醒
|
||||
<input
|
||||
v-model="form.notify_token_expiring"
|
||||
type="checkbox"
|
||||
class="size-4 accent-primary"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">只影响过期前提醒,不影响已过期通知。</span>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2 rounded-lg border border-border bg-background p-3">
|
||||
<span class="flex items-center justify-between gap-3 text-sm font-medium">
|
||||
打卡成功通知
|
||||
<input
|
||||
v-model="form.notify_check_in_success"
|
||||
type="checkbox"
|
||||
class="size-4 accent-primary"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground"
|
||||
>关闭后仅跳过成功邮件,失败邮件仍会发送。</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<div v-if="settings?.updated_at" class="text-sm text-muted-foreground">
|
||||
上次保存:{{ formatFullDateTime(settings.updated_at) }}
|
||||
</div>
|
||||
|
||||
<div v-if="savedMessage" :class="alertClass.success">{{ savedMessage }}</div>
|
||||
<div v-if="error" :class="alertClass.danger">{{ error }}</div>
|
||||
</div>
|
||||
</aside>
|
||||
</form>
|
||||
</template>
|
||||
Reference in New Issue
Block a user