feat: 管理员后台配置 + PostgreSQL 支持

管理员后台:
- User 新增 IsAdmin,第一个注册的用户自动成为管理员;
  老库升级时自动把最早注册的用户提升为管理员
- 新增 AdminMiddleware 与 /api/v1/admin/config 读写接口
- 可改开放注册、检查间隔、规则重试时长、日志保留条数,保存即生效
- 检查间隔改动后自动重排定时任务,为此把 cron 抽成 handler/scheduler
- 监听地址、数据库、会话密钥等启动项不开放,接口只返回只读展示
- 设置页新增"系统配置"区块,仅管理员可见

PostgreSQL:
- 新增 database_driver / database_dsn 配置,默认仍为 sqlite
- postgres 用独立连接池(20/5/1h),sqlite 保持单连接避免写锁
- 启动时先 Ping,配置写错在启动阶段就报错而不是等到首次查询
- 支持 GOODBABY_DB_DRIVER / GOODBABY_DB_DSN 环境变量,
  只给 DSN 时自动按 postgres 处理

文档: 新增 docs/database.md(含 compose 示例与切换说明),README 补充管理员与数据库章节

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mei
2026-07-27 07:17:16 +08:00
co-authored by Claude Fable 5
parent 54d4fbe24a
commit ae7082f13c
15 changed files with 640 additions and 21 deletions
+7
View File
@@ -2,6 +2,8 @@ import { api } from './client'
import type {
Account,
AccountRequest,
AdminConfig,
AdminConfigResponse,
DashboardOverview,
ExecutionLog,
LogPage,
@@ -79,4 +81,9 @@ export const logApi = {
clear: () => api.delete<string>('/logs/'),
}
export const adminApi = {
getConfig: () => api.get<AdminConfigResponse>('/admin/config'),
updateConfig: (body: AdminConfig) => api.put<AdminConfig>('/admin/config', body),
}
export type { ExecutionLog }
+18
View File
@@ -9,6 +9,7 @@ export interface UserInfo {
id: number
create_at: number
username: string
is_admin: boolean
dingtalk_config: string | null
}
@@ -153,5 +154,22 @@ export interface SiteInfo {
check_interval_minutes: number
}
/** 管理员可在 WebUI 修改的系统配置 */
export interface AdminConfig {
enable_registry: boolean
timeout_duration_hours: number
check_interval_minutes: number
log_retain_count: number
}
export interface AdminConfigResponse {
config: AdminConfig
/** 只读的运行时信息,只能改配置文件 */
readonly: {
listen_addr: string
database_driver: string
}
}
/** 后端对 secret 字段的掩码占位符,原样提交时表示保留旧值 */
export const MASK_PLACEHOLDER = '********'
+160 -3
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { userApi } from '@/api'
import { adminApi, userApi } from '@/api'
import { ApiError } from '@/api/client'
import type { AdminConfig, AdminConfigResponse } from '@/api/types'
import { useUserStore } from '@/stores/user'
import { formatDateTime } from '@/utils/format'
import { useIsMobile } from '@/composables/useBreakpoint'
@@ -11,6 +12,50 @@ const isMobile = useIsMobile()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.user?.is_admin === true)
// ---- 系统配置(仅管理员) ----
const adminLoading = ref(false)
const adminSaving = ref(false)
const adminReadonly = ref<AdminConfigResponse['readonly'] | null>(null)
const adminConfig = ref<AdminConfig>({
enable_registry: true,
timeout_duration_hours: 6,
check_interval_minutes: 10,
log_retain_count: 500,
})
async function loadAdminConfig() {
if (!isAdmin.value) return
adminLoading.value = true
try {
const result = await adminApi.getConfig()
adminConfig.value = result.config
adminReadonly.value = result.readonly
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '读取系统配置失败')
} finally {
adminLoading.value = false
}
}
async function saveAdminConfig() {
adminSaving.value = true
try {
adminConfig.value = await adminApi.updateConfig(adminConfig.value)
ElMessage.success('系统配置已保存并生效')
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '保存失败')
} finally {
adminSaving.value = false
}
}
const driverLabels: Record<string, string> = {
sqlite: 'SQLite',
postgres: 'PostgreSQL',
}
// ---- 钉钉提醒配置 ----
const notifySaving = ref(false)
const dingtalk = ref({
@@ -96,6 +141,7 @@ onMounted(async () => {
await userStore.fetchUser()
}
loadNotifyFromUser()
await loadAdminConfig()
})
</script>
@@ -110,9 +156,12 @@ onMounted(async () => {
<el-card class="section">
<template #header>账号信息</template>
<el-descriptions :column="2">
<el-descriptions :column="isMobile ? 1 : 2">
<el-descriptions-item label="用户名">
{{ userStore.user?.username }}
<el-tag v-if="isAdmin" type="primary" size="small" effect="light" class="admin-tag">
管理员
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="注册时间">
{{ formatDateTime(userStore.user?.create_at ?? 0) }}
@@ -120,6 +169,79 @@ onMounted(async () => {
</el-descriptions>
</el-card>
<!-- 系统配置:仅管理员可见 -->
<el-card v-if="isAdmin" v-loading="adminLoading" class="section">
<template #header>系统配置</template>
<p class="muted">
这些配置对整个站点生效,保存后立即生效,无需重启。监听地址、数据库等启动项只能改配置文件。
</p>
<el-form
:label-width="isMobile ? 'auto' : '150px'"
:label-position="isMobile ? 'top' : 'right'"
style="max-width: 620px"
>
<el-form-item label="开放注册">
<el-switch v-model="adminConfig.enable_registry" />
<div class="muted">
关闭后新用户无法注册。系统内还没有任何用户时始终允许注册,避免全新部署无法创建账号。
</div>
</el-form-item>
<el-form-item label="检查间隔">
<el-input-number
v-model="adminConfig.check_interval_minutes"
:min="1"
:max="1440"
class="cfg-number"
/>
<span class="unit">分钟</span>
<div class="muted">多久检查一次定时器是否到期。间隔越短,触发越及时。</div>
</el-form-item>
<el-form-item label="规则重试时长">
<el-input-number
v-model="adminConfig.timeout_duration_hours"
:min="1"
:max="72"
class="cfg-number"
/>
<span class="unit">小时</span>
<div class="muted">规则执行失败后按指数退避重试的最长时间。</div>
</el-form-item>
<el-form-item label="日志保留条数">
<el-input-number
v-model="adminConfig.log_retain_count"
:min="0"
:max="100000"
:step="100"
class="cfg-number"
/>
<span class="unit">条 / 用户</span>
<div class="muted">超出后自动清理最旧的记录,0 表示不限制。</div>
</el-form-item>
<el-form-item v-if="adminReadonly" label="运行环境">
<div class="muted readonly-info">
<span>监听地址 <code class="mono">{{ adminReadonly.listen_addr }}</code></span>
<span>
数据库
<code class="mono">
{{ driverLabels[adminReadonly.database_driver] ?? adminReadonly.database_driver }}
</code>
</span>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="adminSaving" @click="saveAdminConfig">
保存系统配置
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="section">
<template #header>提醒渠道(钉钉机器人)</template>
<p class="muted">
@@ -175,4 +297,39 @@ onMounted(async () => {
.section {
margin-bottom: 16px;
}
.admin-tag {
margin-left: 6px;
}
.cfg-number {
width: 150px;
}
.unit {
margin-left: 8px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.readonly-info {
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
}
.readonly-info code {
color: var(--el-text-color-primary);
}
@media (max-width: 768px) {
.cfg-number {
width: 100%;
}
.unit {
display: inline-block;
margin: 6px 0 0;
}
}
</style>