新增后端管理页面

This commit is contained in:
2026-01-28 15:35:01 +08:00
parent 5334ee9d41
commit 62ac723c95
11 changed files with 1515 additions and 51 deletions

View File

@@ -15,6 +15,7 @@ BingPaper 的前端 Web 应用,使用 Vue 3 + TypeScript + Vite 构建。
- ⚡ 浏览器缓存优化(内容哈希 + 代码分割)
- 🌐 支持自定义后端路径
- 📁 自动输出到上级目录的 web 文件夹
- 🔐 完整的管理后台系统Token 管理、定时任务、系统配置)
## 快速开始
@@ -93,6 +94,77 @@ const images = await bingPaperApi.getImages({ limit: 10 })
## 项目结构
```
src/
├── assets/ # 静态资源
├── components/ # Vue 组件
│ └── ui/ # shadcn-vue UI 组件库
├── composables/ # 可组合函数
│ └── useImages.ts # 图片管理相关逻辑
├── lib/ # 核心库
│ ├── api-config.ts # API 配置
│ ├── api-service.ts # API 服务类
│ ├── api-types.ts # TypeScript 类型定义
│ ├── http-client.ts # HTTP 客户端
│ └── utils.ts # 工具函数
├── router/ # 路由配置
│ └── index.ts
├── views/ # 页面组件
│ ├── Home.vue # 首页
│ ├── ImageView.vue # 图片详情页
│ ├── ApiDocs.vue # API 文档页
│ ├── AdminLogin.vue # 管理员登录页
│ ├── Admin.vue # 管理后台主页面
│ ├── AdminTokens.vue # Token 管理
│ ├── AdminTasks.vue # 定时任务管理
│ └── AdminConfig.vue # 系统配置管理
├── App.vue # 根组件
└── main.ts # 入口文件
```
## 管理后台
访问 `/admin/login` 进入管理后台登录页面。
### 功能模块
#### 1. Token 管理
- 查看所有 API Token
- 创建新的 Token支持设置过期时间
- 启用/禁用 Token
- 删除 Token
#### 2. 定时任务管理
- 手动触发图片抓取(可指定抓取天数)
- 手动触发旧图片清理
- 查看任务执行历史
#### 3. 系统配置管理
- **JSON 编辑模式**:直接编辑配置 JSON
- **表单编辑模式**:通过友好的表单界面修改配置
- 支持的配置项:
- 服务器配置(端口、基础 URL
- API 模式(本地/重定向)
- 定时任务配置
- 数据库配置
- 存储配置(本地/S3/WebDAV
- 图片保留策略
- Token 配置
- 日志配置
- 功能特性开关
#### 4. 密码管理
- 修改管理员密码
- 安全退出登录
### 安全特性
- 基于 JWT Token 的身份验证
- Token 过期自动跳转登录页
- 路由守卫保护管理页面
- 密码修改后强制重新登录
## 项目结构
```
src/
├── lib/ # 核心库

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import 'vue-sonner/style.css'
import { Toaster } from '@/components/ui/sonner'
</script>
<template>
<div id="app">
<RouterView />
<Toaster />
</div>
</template>

View File

@@ -181,6 +181,9 @@ export class BingPaperApiService {
// 导出默认实例
export const bingPaperApi = new BingPaperApiService()
// 为了兼容性,也导出为 apiService
export const apiService = bingPaperApi
// 导出便捷方法
export const {
login,

View File

@@ -50,97 +50,97 @@ export interface ChangePasswordRequest {
// ===== 配置相关 =====
export interface Config {
admin: AdminConfig
api: APIConfig
cron: CronConfig
db: DBConfig
feature: FeatureConfig
log: LogConfig
retention: RetentionConfig
server: ServerConfig
storage: StorageConfig
token: TokenConfig
web: WebConfig
Server: ServerConfig
Log: LogConfig
API: APIConfig
Cron: CronConfig
Retention: RetentionConfig
DB: DBConfig
Storage: StorageConfig
Admin: AdminConfig
Token: TokenConfig
Feature: FeatureConfig
Web: WebConfig
}
export interface AdminConfig {
passwordBcrypt: string
PasswordBcrypt: string
}
export interface APIConfig {
mode: string // 'local' | 'redirect'
Mode: string // 'local' | 'redirect'
}
export interface CronConfig {
enabled: boolean
dailySpec: string
Enabled: boolean
DailySpec: string
}
export interface DBConfig {
type: string // 'sqlite' | 'mysql' | 'postgres'
dsn: string
Type: string // 'sqlite' | 'mysql' | 'postgres'
DSN: string
}
export interface FeatureConfig {
writeDailyFiles: boolean
WriteDailyFiles: boolean
}
export interface LogConfig {
level: string
filename: string
dbfilename: string
dblogLevel: string
logConsole: boolean
showDBLog: boolean
maxSize: number
maxAge: number
maxBackups: number
compress: boolean
Level: string
Filename: string
DBFilename: string
DBLogLevel: string
LogConsole: boolean
ShowDBLog: boolean
MaxSize: number
MaxAge: number
MaxBackups: number
Compress: boolean
}
export interface RetentionConfig {
days: number
Days: number
}
export interface ServerConfig {
port: number
baseURL: string
Port: number
BaseURL: string
}
export interface StorageConfig {
type: string // 'local' | 's3' | 'webdav'
local: LocalConfig
s3: S3Config
webDAV: WebDAVConfig
Type: string // 'local' | 's3' | 'webdav'
Local: LocalConfig
S3: S3Config
WebDAV: WebDAVConfig
}
export interface LocalConfig {
root: string
Root: string
}
export interface S3Config {
endpoint: string
accessKey: string
secretKey: string
bucket: string
region: string
forcePathStyle: boolean
publicURLPrefix: string
Endpoint: string
AccessKey: string
SecretKey: string
Bucket: string
Region: string
ForcePathStyle: boolean
PublicURLPrefix: string
}
export interface WebDAVConfig {
url: string
username: string
password: string
publicURLPrefix: string
URL: string
Username: string
Password: string
PublicURLPrefix: string
}
export interface TokenConfig {
defaultTTL: string
DefaultTTL: string
}
export interface WebConfig {
path: string
Path: string
}
// ===== 图片相关 =====
@@ -157,9 +157,18 @@ export interface ImageMeta {
url?: string
variant?: string
format?: string
variants?: ImageVariantResp[] // 图片变体列表
[key: string]: any
}
export interface ImageVariantResp {
variant: string // 分辨率变体 (UHD, 1920x1080, 等)
format: string // 格式 (jpg)
url: string // 访问 URL
storage_key: string // 存储键
size: number // 文件大小(字节)
}
export interface ImageListParams extends PaginationParams {
page?: number // 页码从1开始
page_size?: number // 每页数量

View File

@@ -108,11 +108,18 @@ export class ApiClient {
// 检查响应状态
if (!response.ok) {
const errorData = await this.parseResponse(response)
throw new ApiError(
const apiError = new ApiError(
errorData?.message || `HTTP ${response.status}: ${response.statusText}`,
response.status,
errorData
)
// 401 未授权错误,自动跳转到登录页
if (response.status === 401) {
this.handle401Error()
}
throw apiError
}
return await this.parseResponse(response)
@@ -130,6 +137,24 @@ export class ApiClient {
}
}
/**
* 处理 401 错误
*/
private handle401Error() {
// 清除本地存储的 token
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_token_expires')
this.clearAuthToken()
// 只有在管理页面时才跳转到登录页
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/admin')) {
// 避免重复跳转
if (!window.location.pathname.includes('/admin/login')) {
window.location.href = '/admin/login'
}
}
}
/**
* 解析响应数据
*/

View File

@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import ImageView from '@/views/ImageView.vue'
import ApiDocs from '@/views/ApiDocs.vue'
import AdminLogin from '@/views/AdminLogin.vue'
import Admin from '@/views/Admin.vue'
const router = createRouter({
history: createWebHistory(),
@@ -29,13 +31,54 @@ const router = createRouter({
meta: {
title: 'API 文档'
}
},
{
path: '/admin/login',
name: 'AdminLogin',
component: AdminLogin,
meta: {
title: '管理员登录'
}
},
{
path: '/admin',
name: 'Admin',
component: Admin,
meta: {
title: '管理后台',
requiresAuth: true
}
}
]
})
// 路由守卫 - 更新页面标题
// 路由守卫 - 更新页面标题和认证检查
router.beforeEach((to, _from, next) => {
document.title = (to.meta.title as string) || '必应每日一图'
// 检查是否需要认证
if (to.meta.requiresAuth) {
const token = localStorage.getItem('admin_token')
if (!token) {
// 未登录,重定向到登录页
next('/admin/login')
return
}
// 检查 token 是否过期
const expiresAt = localStorage.getItem('admin_token_expires')
if (expiresAt) {
const expireDate = new Date(expiresAt)
if (expireDate < new Date()) {
// token 已过期,清除并重定向到登录页
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_token_expires')
next('/admin/login')
return
}
}
}
next()
})

199
webapp/src/views/Admin.vue Normal file
View File

@@ -0,0 +1,199 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- 顶部导航栏 -->
<header class="bg-white border-b">
<div class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<h1 class="text-xl font-bold">BingPaper 管理后台</h1>
</div>
<div class="flex items-center gap-4">
<Button variant="outline" size="sm" @click="showPasswordDialog = true">
修改密码
</Button>
<Button variant="destructive" size="sm" @click="handleLogout">
退出登录
</Button>
</div>
</div>
</div>
</header>
<!-- 主内容区 -->
<div class="container mx-auto px-4 py-6">
<Tabs v-model="activeTab" class="space-y-4">
<TabsList class="grid w-full grid-cols-3 lg:w-[400px]">
<TabsTrigger value="tokens">Token 管理</TabsTrigger>
<TabsTrigger value="tasks">定时任务</TabsTrigger>
<TabsTrigger value="config">系统配置</TabsTrigger>
</TabsList>
<TabsContent value="tokens" class="space-y-4">
<AdminTokens />
</TabsContent>
<TabsContent value="tasks" class="space-y-4">
<AdminTasks />
</TabsContent>
<TabsContent value="config" class="space-y-4">
<AdminConfig />
</TabsContent>
</Tabs>
</div>
<!-- 修改密码对话框 -->
<Dialog v-model:open="showPasswordDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>修改管理员密码</DialogTitle>
<DialogDescription>
请输入旧密码和新密码
</DialogDescription>
</DialogHeader>
<form @submit.prevent="handleChangePassword" class="space-y-4">
<div class="space-y-2">
<Label for="old-password">旧密码</Label>
<Input
id="old-password"
v-model="passwordForm.oldPassword"
type="password"
required
/>
</div>
<div class="space-y-2">
<Label for="new-password">新密码</Label>
<Input
id="new-password"
v-model="passwordForm.newPassword"
type="password"
required
/>
</div>
<div class="space-y-2">
<Label for="confirm-password">确认新密码</Label>
<Input
id="confirm-password"
v-model="passwordForm.confirmPassword"
type="password"
required
/>
</div>
<div v-if="passwordError" class="text-sm text-red-600">
{{ passwordError }}
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showPasswordDialog = false">
取消
</Button>
<Button type="submit" :disabled="passwordLoading">
{{ passwordLoading ? '提交中...' : '确认修改' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { apiService } from '@/lib/api-service'
import { apiClient } from '@/lib/http-client'
import AdminTokens from './AdminTokens.vue'
import AdminTasks from './AdminTasks.vue'
import AdminConfig from './AdminConfig.vue'
const router = useRouter()
const activeTab = ref('tokens')
const showPasswordDialog = ref(false)
const passwordForm = ref({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const passwordLoading = ref(false)
const passwordError = ref('')
// 检查认证状态
const checkAuth = () => {
const token = localStorage.getItem('admin_token')
if (!token) {
router.push('/admin/login')
return false
}
// 设置认证头
apiClient.setAuthToken(token)
// 检查是否过期
const expiresAt = localStorage.getItem('admin_token_expires')
if (expiresAt) {
const expireDate = new Date(expiresAt)
if (expireDate < new Date()) {
toast.warning('登录已过期,请重新登录')
handleLogout()
return false
}
}
return true
}
const handleLogout = () => {
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_token_expires')
apiClient.clearAuthToken()
router.push('/admin/login')
}
const handleChangePassword = async () => {
passwordError.value = ''
// 验证新密码
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
passwordError.value = '两次输入的新密码不一致'
return
}
if (passwordForm.value.newPassword.length < 6) {
passwordError.value = '新密码长度至少为 6 位'
return
}
passwordLoading.value = true
try {
await apiService.changePassword({
old_password: passwordForm.value.oldPassword,
new_password: passwordForm.value.newPassword
})
toast.success('密码修改成功,请重新登录')
showPasswordDialog.value = false
passwordForm.value = {
oldPassword: '',
newPassword: '',
confirmPassword: ''
}
handleLogout()
} catch (err: any) {
passwordError.value = err.message || '密码修改失败'
console.error('修改密码失败:', err)
} finally {
passwordLoading.value = false
}
}
onMounted(() => {
checkAuth()
})
</script>

View File

@@ -0,0 +1,607 @@
<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>

View File

@@ -0,0 +1,79 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-4">
<Card class="w-full max-w-md">
<CardHeader class="space-y-1">
<CardTitle class="text-2xl font-bold text-center">管理员登录</CardTitle>
<CardDescription class="text-center">
输入管理员密码以访问后台管理系统
</CardDescription>
</CardHeader>
<CardContent>
<form @submit.prevent="handleLogin" class="space-y-4">
<div class="space-y-2">
<Label for="password">密码</Label>
<Input
id="password"
v-model="password"
type="password"
placeholder="请输入管理员密码"
required
:disabled="loading"
/>
</div>
<div v-if="error" class="text-sm text-red-600 bg-red-50 p-3 rounded-md">
{{ error }}
</div>
<Button type="submit" class="w-full" :disabled="loading">
<span v-if="loading">登录中...</span>
<span v-else>登录</span>
</Button>
</form>
</CardContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { apiService } from '@/lib/api-service'
import { apiClient } from '@/lib/http-client'
const router = useRouter()
const password = ref('')
const loading = ref(false)
const error = ref('')
const handleLogin = async () => {
error.value = ''
loading.value = true
try {
const response = await apiService.login({ password: password.value })
// 保存 token 到 localStorage
localStorage.setItem('admin_token', response.token)
localStorage.setItem('admin_token_expires', response.expires_at || '')
// 设置 HTTP 客户端的认证头
apiClient.setAuthToken(response.token)
toast.success('登录成功')
// 跳转到管理后台
router.push('/admin')
} catch (err: any) {
console.error('登录失败:', err)
error.value = err.message || '登录失败,请检查密码'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,189 @@
<template>
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold mb-4">定时任务管理</h3>
<p class="text-sm text-gray-600 mb-4">
手动触发图片抓取和清理任务
</p>
</div>
<!-- 手动抓取 -->
<Card>
<CardHeader>
<CardTitle>手动抓取图片</CardTitle>
<CardDescription>
立即从 Bing 抓取最新的图片
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="fetch-days">抓取天数</Label>
<Input
id="fetch-days"
v-model.number="fetchDays"
type="number"
min="1"
max="30"
placeholder="输入要抓取的天数,默认 1 天"
/>
<p class="text-xs text-gray-500">
指定要抓取的天数包括今天最多 30
</p>
</div>
<Button
@click="handleManualFetch"
:disabled="fetchLoading"
class="w-full sm:w-auto"
>
{{ fetchLoading ? '抓取中...' : '开始抓取' }}
</Button>
</CardContent>
</Card>
<!-- 手动清理 -->
<Card>
<CardHeader>
<CardTitle>手动清理旧图片</CardTitle>
<CardDescription>
清理超过保留期限的旧图片
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<p class="text-sm text-gray-600">
根据系统配置的保留天数清理过期的图片文件和数据库记录
</p>
<Button
@click="handleManualCleanup"
:disabled="cleanupLoading"
variant="destructive"
class="w-full sm:w-auto"
>
{{ cleanupLoading ? '清理中...' : '开始清理' }}
</Button>
</CardContent>
</Card>
<!-- 任务历史记录 -->
<Card>
<CardHeader>
<CardTitle>任务执行历史</CardTitle>
<CardDescription>
最近的任务执行记录
</CardDescription>
</CardHeader>
<CardContent>
<div v-if="taskHistory.length === 0" class="text-center py-8 text-gray-500">
暂无执行记录
</div>
<div v-else class="space-y-2">
<div
v-for="(task, index) in taskHistory"
:key="index"
class="flex items-center justify-between p-3 border rounded-md"
>
<div class="flex-1">
<div class="flex items-center gap-2">
<Badge :variant="task.success ? 'default' : 'destructive'">
{{ task.type }}
</Badge>
<span class="text-sm">{{ task.message }}</span>
</div>
<div class="text-xs text-gray-500 mt-1">
{{ task.timestamp }}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { apiService } from '@/lib/api-service'
interface TaskRecord {
type: string
success: boolean
message: string
timestamp: string
}
const fetchDays = ref<number>(1)
const fetchLoading = ref(false)
const cleanupLoading = ref(false)
const taskHistory = ref<TaskRecord[]>([])
const handleManualFetch = async () => {
fetchLoading.value = true
try {
const response = await apiService.manualFetch({ n: fetchDays.value })
toast.success(response.message || '抓取任务已启动')
// 添加到历史记录
taskHistory.value.unshift({
type: '图片抓取',
success: true,
message: `抓取 ${fetchDays.value} 天的图片`,
timestamp: new Date().toLocaleString('zh-CN')
})
// 只保留最近 10 条记录
if (taskHistory.value.length > 10) {
taskHistory.value = taskHistory.value.slice(0, 10)
}
} catch (err: any) {
toast.error(err.message || '抓取失败')
taskHistory.value.unshift({
type: '图片抓取',
success: false,
message: err.message || '抓取失败',
timestamp: new Date().toLocaleString('zh-CN')
})
} finally {
fetchLoading.value = false
}
}
const handleManualCleanup = async () => {
cleanupLoading.value = true
try {
const response = await apiService.manualCleanup()
toast.success(response.message || '清理任务已完成')
taskHistory.value.unshift({
type: '清理任务',
success: true,
message: '清理旧图片',
timestamp: new Date().toLocaleString('zh-CN')
})
if (taskHistory.value.length > 10) {
taskHistory.value = taskHistory.value.slice(0, 10)
}
} catch (err: any) {
toast.error(err.message || '清理失败')
taskHistory.value.unshift({
type: '清理任务',
success: false,
message: err.message || '清理失败',
timestamp: new Date().toLocaleString('zh-CN')
})
} finally {
cleanupLoading.value = false
}
}
</script>

View File

@@ -0,0 +1,236 @@
<template>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Token 管理</h3>
<Button @click="showCreateDialog = true">
<span>创建 Token</span>
</Button>
</div>
<div v-if="loading" class="text-center py-8">
<p class="text-gray-500">加载中...</p>
</div>
<div v-else-if="error" class="text-red-600 bg-red-50 p-4 rounded-md">
{{ error }}
</div>
<div v-else-if="tokens.length === 0" class="text-center py-8 text-gray-500">
暂无 Token点击上方按钮创建
</div>
<div v-else class="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>名称</TableHead>
<TableHead>Token</TableHead>
<TableHead>状态</TableHead>
<TableHead>过期时间</TableHead>
<TableHead>创建时间</TableHead>
<TableHead class="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="token in tokens" :key="token.id">
<TableCell>{{ token.id }}</TableCell>
<TableCell>{{ token.name }}</TableCell>
<TableCell>
<code class="text-xs bg-gray-100 px-2 py-1 rounded">
{{ token.token.substring(0, 20) }}...
</code>
</TableCell>
<TableCell>
<Badge :variant="token.disabled ? 'destructive' : 'default'">
{{ token.disabled ? '已禁用' : '启用' }}
</Badge>
</TableCell>
<TableCell>{{ formatDate(token.expires_at) }}</TableCell>
<TableCell>{{ formatDate(token.created_at) }}</TableCell>
<TableCell class="text-right space-x-2">
<Button
size="sm"
variant="outline"
@click="toggleTokenStatus(token)"
>
{{ token.disabled ? '启用' : '禁用' }}
</Button>
<Button
size="sm"
variant="destructive"
@click="handleDeleteToken(token)"
>
删除
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 创建 Token 对话框 -->
<Dialog v-model:open="showCreateDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>创建 Token</DialogTitle>
<DialogDescription>
创建新的 API Token 用于访问接口
</DialogDescription>
</DialogHeader>
<form @submit.prevent="handleCreateToken" class="space-y-4">
<div class="space-y-2">
<Label for="name">名称</Label>
<Input
id="name"
v-model="createForm.name"
placeholder="输入 Token 名称"
required
/>
</div>
<div class="space-y-2">
<Label for="expires_in">过期时间</Label>
<Input
id="expires_in"
v-model="createForm.expires_in"
placeholder="例如: 168h (7天), 720h (30天)"
/>
<p class="text-xs text-gray-500">留空表示永不过期</p>
</div>
<div v-if="createError" class="text-sm text-red-600">
{{ createError }}
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="showCreateDialog = false">
取消
</Button>
<Button type="submit" :disabled="createLoading">
{{ createLoading ? '创建中...' : '创建' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- 删除确认对话框 -->
<AlertDialog v-model:open="showDeleteDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除 Token "{{ deleteTarget?.name }}" 此操作无法撤销
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="confirmDelete" class="bg-red-600 hover:bg-red-700">
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { apiService } from '@/lib/api-service'
import type { Token } from '@/lib/api-types'
const tokens = ref<Token[]>([])
const loading = ref(false)
const error = ref('')
const showCreateDialog = ref(false)
const createForm = ref({
name: '',
expires_in: ''
})
const createLoading = ref(false)
const createError = ref('')
const showDeleteDialog = ref(false)
const deleteTarget = ref<Token | null>(null)
const fetchTokens = async () => {
loading.value = true
error.value = ''
try {
tokens.value = await apiService.getTokens()
} catch (err: any) {
error.value = err.message || '获取 Token 列表失败'
console.error('获取 Token 失败:', err)
} finally {
loading.value = false
}
}
const handleCreateToken = async () => {
createLoading.value = true
createError.value = ''
try {
await apiService.createToken(createForm.value)
showCreateDialog.value = false
createForm.value = { name: '', expires_in: '' }
toast.success('Token 创建成功')
await fetchTokens()
} catch (err: any) {
createError.value = err.message || '创建 Token 失败'
console.error('创建 Token 失败:', err)
} finally {
createLoading.value = false
}
}
const toggleTokenStatus = async (token: Token) => {
try {
await apiService.updateToken(token.id, { disabled: !token.disabled })
toast.success(`Token 已${token.disabled ? '启用' : '禁用'}`)
await fetchTokens()
} catch (err: any) {
console.error('更新 Token 状态失败:', err)
toast.error(err.message || '更新失败')
}
}
const handleDeleteToken = (token: Token) => {
deleteTarget.value = token
showDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deleteTarget.value) return
try {
await apiService.deleteToken(deleteTarget.value.id)
showDeleteDialog.value = false
deleteTarget.value = null
toast.success('Token 删除成功')
await fetchTokens()
} catch (err: any) {
console.error('删除 Token 失败:', err)
toast.error(err.message || '删除失败')
}
}
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
try {
return new Date(dateStr).toLocaleString('zh-CN')
} catch {
return dateStr
}
}
onMounted(() => {
fetchTokens()
})
</script>