mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-03-08 07:19:32 +08:00
608 lines
20 KiB
Vue
608 lines
20 KiB
Vue
<template>
|
||
<div class="space-y-6">
|
||
<div class="flex justify-between items-center">
|
||
<h3 class="text-lg font-semibold">系统配置</h3>
|
||
<div class="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
@click="editMode = editMode === 'json' ? 'form' : 'json'"
|
||
>
|
||
切换到{{ editMode === 'json' ? '表单' : 'JSON' }}编辑
|
||
</Button>
|
||
<Button @click="handleSaveConfig" :disabled="saveLoading">
|
||
{{ saveLoading ? '保存中...' : '保存配置' }}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="loading" class="text-center py-8">
|
||
<p class="text-gray-500">加载配置中...</p>
|
||
</div>
|
||
|
||
<div v-else-if="loadError" class="text-red-600 bg-red-50 p-4 rounded-md">
|
||
{{ loadError }}
|
||
</div>
|
||
|
||
<div v-else>
|
||
<!-- JSON 编辑模式 -->
|
||
<Card v-if="editMode === 'json'">
|
||
<CardHeader>
|
||
<div class="flex justify-between items-start">
|
||
<div>
|
||
<CardTitle>JSON 配置编辑器</CardTitle>
|
||
<CardDescription>
|
||
直接编辑配置 JSON,请确保格式正确
|
||
</CardDescription>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
@click="formatJson"
|
||
:disabled="!configJson.trim()"
|
||
>
|
||
格式化 JSON
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Textarea
|
||
v-model="configJson"
|
||
class="font-mono text-sm min-h-[500px]"
|
||
:class="{ 'border-red-500': jsonError }"
|
||
placeholder="配置 JSON"
|
||
/>
|
||
<div v-if="jsonError" class="mt-2 text-sm text-red-600 bg-red-50 p-2 rounded">
|
||
❌ {{ jsonError }}
|
||
</div>
|
||
<div v-else-if="isValidJson" class="mt-2 text-sm text-green-600">
|
||
✓ JSON 格式正确
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 表单编辑模式 -->
|
||
<div v-else class="space-y-4">
|
||
<!-- 服务器配置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>服务器配置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="space-y-2">
|
||
<Label>端口</Label>
|
||
<Input v-model.number="config.Server.Port" type="number" />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>基础 URL</Label>
|
||
<Input v-model="config.Server.BaseURL" placeholder="http://localhost:8080" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- API 配置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>API 配置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="space-y-2">
|
||
<Label>API 模式</Label>
|
||
<Select v-model="config.API.Mode">
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择 API 模式" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="local">本地 (local)</SelectItem>
|
||
<SelectItem value="redirect">重定向 (redirect)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p class="text-xs text-gray-500">
|
||
local: 直接返回图片流; redirect: 重定向到存储位置
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 定时任务配置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>定时任务配置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="flex items-center gap-2">
|
||
<Label for="cron-enabled">启用定时任务</Label>
|
||
<Switch
|
||
id="cron-enabled"
|
||
v-model="config.Cron.Enabled"
|
||
/>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>定时表达式 (Cron)</Label>
|
||
<Input v-model="config.Cron.DailySpec" placeholder="0 9 * * *" />
|
||
<p class="text-xs text-gray-500">
|
||
例如: "0 9 * * *" 表示每天 9:00 执行
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 数据库配置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>数据库配置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="space-y-2">
|
||
<Label>数据库类型</Label>
|
||
<Select v-model="config.DB.Type">
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择数据库类型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="sqlite">SQLite</SelectItem>
|
||
<SelectItem value="mysql">MySQL</SelectItem>
|
||
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>DSN (数据源名称)</Label>
|
||
<Input
|
||
v-model="config.DB.DSN"
|
||
placeholder="数据库连接字符串"
|
||
:class="{ 'border-red-500': dsnError }"
|
||
@blur="validateDSN"
|
||
/>
|
||
<p v-if="dsnExamples" class="text-xs text-gray-500">
|
||
💡 示例: {{ dsnExamples }}
|
||
</p>
|
||
<p v-if="dsnError" class="text-xs text-red-600">
|
||
❌ {{ dsnError }}
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 存储配置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>存储配置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="space-y-2">
|
||
<Label>存储类型</Label>
|
||
<Select v-model="config.Storage.Type">
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择存储类型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="local">本地存储</SelectItem>
|
||
<SelectItem value="s3">S3 存储</SelectItem>
|
||
<SelectItem value="webdav">WebDAV</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<!-- 本地存储配置 -->
|
||
<div v-if="config.Storage.Type === 'local'" class="space-y-2">
|
||
<Label>本地存储路径</Label>
|
||
<Input v-model="config.Storage.Local.Root" placeholder="./data/images" />
|
||
</div>
|
||
|
||
<!-- S3 存储配置 -->
|
||
<div v-if="config.Storage.Type === 's3'" class="space-y-4">
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="space-y-2">
|
||
<Label>Endpoint</Label>
|
||
<Input v-model="config.Storage.S3.Endpoint" />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>Region</Label>
|
||
<Input v-model="config.Storage.S3.Region" />
|
||
</div>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>Bucket</Label>
|
||
<Input v-model="config.Storage.S3.Bucket" />
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="space-y-2">
|
||
<Label>Access Key</Label>
|
||
<Input v-model="config.Storage.S3.AccessKey" type="password" />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>Secret Key</Label>
|
||
<Input v-model="config.Storage.S3.SecretKey" type="password" />
|
||
</div>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>公开 URL 前缀</Label>
|
||
<Input v-model="config.Storage.S3.PublicURLPrefix" />
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<Label for="s3-force-path">强制路径样式</Label>
|
||
<Switch
|
||
id="s3-force-path"
|
||
v-model="config.Storage.S3.ForcePathStyle"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WebDAV 配置 -->
|
||
<div v-if="config.Storage.Type === 'webdav'" class="space-y-4">
|
||
<div class="space-y-2">
|
||
<Label>WebDAV URL</Label>
|
||
<Input v-model="config.Storage.WebDAV.URL" />
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="space-y-2">
|
||
<Label>用户名</Label>
|
||
<Input v-model="config.Storage.WebDAV.Username" />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>密码</Label>
|
||
<Input v-model="config.Storage.WebDAV.Password" type="password" />
|
||
</div>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>公开 URL 前缀</Label>
|
||
<Input v-model="config.Storage.WebDAV.PublicURLPrefix" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 保留策略配置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>图片保留策略</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="space-y-2">
|
||
<Label>保留天数</Label>
|
||
<Input v-model.number="config.Retention.Days" type="number" min="1" />
|
||
<p class="text-xs text-gray-500">
|
||
超过指定天数的图片将被自动清理
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- Token 配置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Token 配置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="space-y-2">
|
||
<Label>默认过期时间 (TTL)</Label>
|
||
<Input v-model="config.Token.DefaultTTL" placeholder="168h" />
|
||
<p class="text-xs text-gray-500">
|
||
例如: 168h (7天), 720h (30天)
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 日志配置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>日志配置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="space-y-2">
|
||
<Label>日志级别</Label>
|
||
<Select v-model="config.Log.Level">
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择日志级别" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="debug">Debug</SelectItem>
|
||
<SelectItem value="info">Info</SelectItem>
|
||
<SelectItem value="warn">Warn</SelectItem>
|
||
<SelectItem value="error">Error</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>日志文件</Label>
|
||
<Input v-model="config.Log.Filename" />
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="space-y-2">
|
||
<Label>数据库日志文件</Label>
|
||
<Input v-model="config.Log.DBFilename" />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>数据库日志级别</Label>
|
||
<Select v-model="config.Log.DBLogLevel">
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择数据库日志级别" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="debug">Debug</SelectItem>
|
||
<SelectItem value="info">Info</SelectItem>
|
||
<SelectItem value="warn">Warn</SelectItem>
|
||
<SelectItem value="error">Error</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-3 gap-4">
|
||
<div class="flex items-center gap-2">
|
||
<Label for="log-console">输出到控制台</Label>
|
||
<Switch
|
||
id="log-console"
|
||
v-model="config.Log.LogConsole"
|
||
/>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<Label for="log-show-db">显示数据库日志</Label>
|
||
<Switch
|
||
id="log-show-db"
|
||
v-model="config.Log.ShowDBLog"
|
||
/>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<Label for="log-compress">压缩旧日志</Label>
|
||
<Switch
|
||
id="log-compress"
|
||
v-model="config.Log.Compress"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-3 gap-4">
|
||
<div class="space-y-2">
|
||
<Label>单文件大小 (MB)</Label>
|
||
<Input v-model.number="config.Log.MaxSize" type="number" />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>最大文件数</Label>
|
||
<Input v-model.number="config.Log.MaxBackups" type="number" />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>保留天数</Label>
|
||
<Input v-model.number="config.Log.MaxAge" type="number" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 功能特性配置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>功能特性</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="flex items-center gap-2">
|
||
<Label for="feature-write-daily">写入每日文件</Label>
|
||
<Switch
|
||
id="feature-write-daily"
|
||
v-model="config.Feature.WriteDailyFiles"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- Web 配置 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Web 前端配置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="space-y-2">
|
||
<Label>前端静态文件路径</Label>
|
||
<Input v-model="config.Web.Path" placeholder="./webapp/dist" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, watch, computed } from 'vue'
|
||
import { toast } from 'vue-sonner'
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||
import { apiService } from '@/lib/api-service'
|
||
import type { Config } from '@/lib/api-types'
|
||
|
||
const editMode = ref<'json' | 'form'>('form')
|
||
const loading = ref(false)
|
||
const loadError = ref('')
|
||
const saveLoading = ref(false)
|
||
const dsnError = ref('')
|
||
|
||
const config = ref<Config>({
|
||
Admin: { PasswordBcrypt: '' },
|
||
API: { Mode: 'local' },
|
||
Cron: { Enabled: true, DailySpec: '0 9 * * *' },
|
||
DB: { Type: 'sqlite', DSN: '' },
|
||
Feature: { WriteDailyFiles: true },
|
||
Log: {
|
||
Level: 'info',
|
||
Filename: '',
|
||
DBFilename: '',
|
||
DBLogLevel: 'warn',
|
||
LogConsole: true,
|
||
ShowDBLog: false,
|
||
MaxSize: 10,
|
||
MaxAge: 30,
|
||
MaxBackups: 10,
|
||
Compress: true
|
||
},
|
||
Retention: { Days: 30 },
|
||
Server: { Port: 8080, BaseURL: '' },
|
||
Storage: {
|
||
Type: 'local',
|
||
Local: { Root: './data/images' },
|
||
S3: {
|
||
Endpoint: '',
|
||
AccessKey: '',
|
||
SecretKey: '',
|
||
Bucket: '',
|
||
Region: '',
|
||
ForcePathStyle: false,
|
||
PublicURLPrefix: ''
|
||
},
|
||
WebDAV: {
|
||
URL: '',
|
||
Username: '',
|
||
Password: '',
|
||
PublicURLPrefix: ''
|
||
}
|
||
},
|
||
Token: { DefaultTTL: '168h' },
|
||
Web: { Path: './webapp/dist' }
|
||
})
|
||
|
||
const configJson = ref('')
|
||
const jsonError = ref('')
|
||
|
||
// DSN 示例
|
||
const dsnExamples = computed(() => {
|
||
switch (config.value.DB.Type) {
|
||
case 'sqlite':
|
||
return 'data/bing_paper.db 或 file:data/bing_paper.db?cache=shared'
|
||
case 'mysql':
|
||
return 'user:password@tcp(localhost:3306)/dbname?charset=utf8mb4&parseTime=True'
|
||
case 'postgres':
|
||
return 'host=localhost user=postgres password=secret dbname=mydb port=5432 sslmode=disable'
|
||
default:
|
||
return ''
|
||
}
|
||
})
|
||
|
||
// 验证 DSN
|
||
const validateDSN = () => {
|
||
dsnError.value = ''
|
||
const dsn = config.value.DB.DSN.trim()
|
||
|
||
if (!dsn) {
|
||
dsnError.value = 'DSN 不能为空'
|
||
return false
|
||
}
|
||
|
||
switch (config.value.DB.Type) {
|
||
case 'mysql':
|
||
if (!dsn.includes('@tcp(') && !dsn.includes('://')) {
|
||
dsnError.value = 'MySQL DSN 格式不正确,应包含 @tcp( 或使用 URI 格式'
|
||
return false
|
||
}
|
||
break
|
||
case 'postgres':
|
||
if (!dsn.includes('host=') && !dsn.includes('://')) {
|
||
dsnError.value = 'PostgreSQL DSN 格式不正确,应包含 host= 或使用 URI 格式'
|
||
return false
|
||
}
|
||
break
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// 格式化 JSON
|
||
const formatJson = () => {
|
||
try {
|
||
const parsed = JSON.parse(configJson.value)
|
||
configJson.value = JSON.stringify(parsed, null, 2)
|
||
jsonError.value = ''
|
||
toast.success('JSON 格式化成功')
|
||
} catch (err: any) {
|
||
jsonError.value = 'JSON 格式错误: ' + err.message
|
||
toast.error('JSON 格式错误')
|
||
}
|
||
}
|
||
|
||
// 验证 JSON 是否有效
|
||
const isValidJson = computed(() => {
|
||
if (!configJson.value.trim()) return false
|
||
try {
|
||
JSON.parse(configJson.value)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
})
|
||
|
||
const fetchConfig = async () => {
|
||
loading.value = true
|
||
loadError.value = ''
|
||
try {
|
||
const data = await apiService.getConfig()
|
||
config.value = data
|
||
configJson.value = JSON.stringify(data, null, 2)
|
||
} catch (err: any) {
|
||
loadError.value = err.message || '获取配置失败'
|
||
console.error('获取配置失败:', err)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 监听表单变化更新 JSON
|
||
watch(config, (newConfig) => {
|
||
if (editMode.value === 'form') {
|
||
configJson.value = JSON.stringify(newConfig, null, 2)
|
||
}
|
||
}, { deep: true })
|
||
|
||
// 监听 JSON 变化更新表单
|
||
watch(configJson, (newJson) => {
|
||
if (editMode.value === 'json') {
|
||
try {
|
||
const parsed = JSON.parse(newJson)
|
||
config.value = parsed
|
||
jsonError.value = ''
|
||
} catch (err: any) {
|
||
jsonError.value = err.message
|
||
}
|
||
}
|
||
})
|
||
|
||
const handleSaveConfig = async () => {
|
||
saveLoading.value = true
|
||
|
||
try {
|
||
// 如果是 JSON 模式,先验证格式
|
||
if (editMode.value === 'json') {
|
||
if (!isValidJson.value) {
|
||
throw new Error('JSON 格式不正确,请检查语法')
|
||
}
|
||
config.value = JSON.parse(configJson.value)
|
||
} else {
|
||
// 表单模式下验证 DSN
|
||
if (!validateDSN()) {
|
||
throw new Error('DSN 格式不正确: ' + dsnError.value)
|
||
}
|
||
}
|
||
|
||
await apiService.updateConfig(config.value)
|
||
toast.success('配置保存成功')
|
||
|
||
// 重新加载配置
|
||
await fetchConfig()
|
||
} catch (err: any) {
|
||
toast.error(err.message || '保存配置失败')
|
||
console.error('保存配置失败:', err)
|
||
} finally {
|
||
saveLoading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchConfig()
|
||
})
|
||
</script>
|