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:
@@ -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 }
|
||||
|
||||
@@ -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 = '********'
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user