feat: add Web UI for notification config (feishu + email)

- New config/notify_store.go: persistent notification config (notify.json)
- New API: GET/PUT /api/notify/config, POST /api/notify/test-feishu, POST /api/notify/test-email
- New Vue page: NotifyConfig.vue with feishu webhook and SMTP settings
- Updated cron/init to read from persistent store instead of env vars
- Password masked as ******** in API responses, preserved on updates
This commit is contained in:
2026-07-23 16:31:04 +08:00
parent 2d70a15307
commit bbf09fbc8d
9 changed files with 627 additions and 42 deletions
+4
View File
@@ -49,6 +49,10 @@
<el-icon><Plus /></el-icon>
<span>申请证书</span>
</el-menu-item>
<el-menu-item index="/notify">
<el-icon><Bell /></el-icon>
<span>通知配置</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
+22
View File
@@ -77,6 +77,20 @@ export interface LoginResponse {
expires_at: number
}
export interface NotifyConfig {
feishu_enabled: boolean
feishu_url: string
smtp_enabled: boolean
smtp_host: string
smtp_port: string
smtp_user: string
smtp_password: string
smtp_from: string
notify_to: string
expiry_days: number
updated_at: string
}
// Auth API
export const authApi = {
login: (username: string, password: string) =>
@@ -114,3 +128,11 @@ export const certApi = {
checkRenewals: () => api.get('/renewals/check'),
stats: () => api.get<Stats>('/stats'),
}
// Notification config API
export const notifyApi = {
get: () => api.get<NotifyConfig>('/notify/config'),
update: (data: Partial<NotifyConfig>) => api.put('/notify/config', data),
testFeishu: () => api.post('/notify/test-feishu'),
testEmail: () => api.post('/notify/test-email'),
}
+7
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
import CertList from '../views/CertList.vue'
import CertCreate from '../views/CertCreate.vue'
import NotifyConfig from '../views/NotifyConfig.vue'
import Login from '../views/Login.vue'
import { isLoggedIn } from '../api'
@@ -32,6 +33,12 @@ const router = createRouter({
component: CertCreate,
meta: { requiresAuth: true },
},
{
path: '/notify',
name: 'NotifyConfig',
component: NotifyConfig,
meta: { requiresAuth: true },
},
],
})
+272
View File
@@ -0,0 +1,272 @@
<template>
<div class="notify-settings">
<h2 class="page-title">通知配置</h2>
<el-row :gutter="20">
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span><el-icon style="margin-right: 8px"><ChatDotSquare /></el-icon>飞书机器人</span>
<el-switch v-model="form.feishu_enabled" />
</div>
</template>
<el-form label-width="120px">
<el-form-item label="Webhook URL">
<el-input
v-model="form.feishu_url"
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx"
:disabled="!form.feishu_enabled"
/>
</el-form-item>
<el-form-item>
<el-button
type="success"
size="small"
:disabled="!form.feishu_enabled || !form.feishu_url"
:loading="testingFeishu"
@click="handleTestFeishu"
>
发送测试
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span><el-icon style="margin-right: 8px"><Message /></el-icon>邮件通知</span>
<el-switch v-model="form.smtp_enabled" />
</div>
</template>
<el-form label-width="120px">
<el-form-item label="SMTP 主机">
<el-input v-model="form.smtp_host" placeholder="smtp.example.com" :disabled="!form.smtp_enabled" />
</el-form-item>
<el-form-item label="SMTP 端口">
<el-input-number v-model="smtpPortNumber" :min="25" :max="587" :disabled="!form.smtp_enabled" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="form.smtp_user" placeholder="user@example.com" :disabled="!form.smtp_enabled" />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.smtp_password"
type="password"
show-password
:placeholder="hasPassword ? '留空保持不变' : '输入SMTP密码'"
:disabled="!form.smtp_enabled"
/>
</el-form-item>
<el-form-item label="发件人">
<el-input v-model="form.smtp_from" placeholder="AutoSSL <noreply@example.com>" :disabled="!form.smtp_enabled" />
</el-form-item>
<el-form-item label="收件人">
<el-input
v-model="form.notify_to"
placeholder="admin@example.com,ops@example.com"
:disabled="!form.smtp_enabled"
/>
<div class="form-tip">多个邮箱用逗号分隔</div>
</el-form-item>
<el-form-item>
<el-button
type="success"
size="small"
:disabled="!form.smtp_enabled || !form.smtp_host || !form.notify_to"
:loading="testingEmail"
@click="handleTestEmail"
>
发送测试
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top: 20px">
<template #header>
<div class="card-header">
<span><el-icon style="margin-right: 8px"><Setting /></el-icon>通知规则</span>
</div>
</template>
<el-form :inline="true" label-width="140px">
<el-form-item label="到期前提醒天数">
<el-input-number v-model="form.expiry_days" :min="7" :max="90" />
</el-form-item>
</el-form>
</el-card>
<div style="margin-top: 20px; text-align: center">
<el-button type="primary" size="large" :loading="saving" :icon="Check" @click="handleSave">
保存配置
</el-button>
</div>
<!-- 通知状态说明 -->
<el-card style="margin-top: 20px">
<template #header>
<span><el-icon style="margin-right: 8px"><InfoFilled /></el-icon>通知说明</span>
</template>
<div class="notify-info">
<p>1. 配置保存后自动持久化到 <code>data/notify.json</code> 文件</p>
<p>2. 系统每天上午 <strong>8:00</strong> 自动检查证书到期情况并发送通知</p>
<p>3. 启动后 <strong>10 </strong> 也会执行一次初始检查</p>
<p>4. 环境变量 <code>FEISHU_WEBHOOK_URL</code> / <code>SMTP_*</code> 会覆盖文件配置向后兼容</p>
<p>5. 点击"发送测试"可验证通知通道是否配置正确</p>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { notifyApi } from '../api'
import { ElMessage } from 'element-plus'
import { Check, ChatDotSquare, Message, Setting, InfoFilled } from '@element-plus/icons-vue'
const saving = ref(false)
const testingFeishu = ref(false)
const testingEmail = ref(false)
const loading = ref(true)
const hasPassword = ref(false)
const form = reactive({
feishu_enabled: false,
feishu_url: '',
smtp_enabled: false,
smtp_host: '',
smtp_port: '587',
smtp_user: '',
smtp_password: '',
smtp_from: '',
notify_to: '',
expiry_days: 30,
})
const smtpPortNumber = computed({
get: () => parseInt(form.smtp_port || '587'),
set: (v: number) => { form.smtp_port = String(v) },
})
const loadConfig = async () => {
try {
const res = await notifyApi.get()
const data = res.data as any
form.feishu_enabled = data.feishu_enabled || false
form.feishu_url = data.feishu_url || ''
form.smtp_enabled = data.smtp_enabled || false
form.smtp_host = data.smtp_host || ''
form.smtp_port = data.smtp_port || '587'
form.smtp_user = data.smtp_user || ''
form.smtp_password = data.smtp_password || ''
form.smtp_from = data.smtp_from || ''
form.notify_to = data.notify_to || ''
form.expiry_days = data.expiry_days || 30
hasPassword.value = form.smtp_password === '********'
// Reset password field to empty if it's masked - user can leave blank to keep
if (form.smtp_password === '********') {
form.smtp_password = ''
}
} catch (e: any) {
ElMessage.error('加载配置失败: ' + (e.response?.data?.error || e.message))
} finally {
loading.value = false
}
}
const handleSave = async () => {
saving.value = true
try {
const payload = {
feishu_enabled: form.feishu_enabled,
feishu_url: form.feishu_url,
smtp_enabled: form.smtp_enabled,
smtp_host: form.smtp_host,
smtp_port: form.smtp_port,
smtp_user: form.smtp_user,
smtp_password: form.smtp_password || '********',
smtp_from: form.smtp_from,
notify_to: form.notify_to,
expiry_days: form.expiry_days,
}
await notifyApi.update(payload)
ElMessage.success('通知配置已保存')
// Reload to refresh masked state
await loadConfig()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.response?.data?.error || e.message))
} finally {
saving.value = false
}
}
const handleTestFeishu = async () => {
testingFeishu.value = true
try {
await notifyApi.testFeishu()
ElMessage.success('飞书测试消息发送成功')
} catch (e: any) {
ElMessage.error('飞书测试失败: ' + (e.response?.data?.error || e.message))
} finally {
testingFeishu.value = false
}
}
const handleTestEmail = async () => {
testingEmail.value = true
try {
await notifyApi.testEmail()
ElMessage.success('邮件测试消息发送成功')
} catch (e: any) {
ElMessage.error('邮件测试失败: ' + (e.response?.data?.error || e.message))
} finally {
testingEmail.value = false
}
}
onMounted(loadConfig)
</script>
<style scoped>
.notify-settings {
max-width: 1200px;
margin: 0 auto;
}
.page-title {
margin-bottom: 20px;
font-size: 24px;
color: #1a1a2e;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.form-tip {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.notify-info {
line-height: 2;
color: #555;
}
.notify-info code {
background: #f5f5f5;
padding: 2px 8px;
border-radius: 3px;
font-size: 13px;
color: #1890ff;
}
</style>