fix: add Bing wallpaper proxy endpoint, make it public (no JWT required)

- Added GET /api/settings/bing-wallpaper — proxies cn.bing.com HPImageArchive
  to avoid CORS issues, returns {url} for the daily wallpaper
- Added /api/settings/bing-wallpaper to PUBLIC_PREFIXES in auth_jwt.py
  so the login page can fetch it without authentication
- Login page now fetches wallpaper via backend proxy instead of direct CORS-blocked fetch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-31 20:12:09 +08:00
parent 24aa8494e5
commit e084d90c61
121 changed files with 1836 additions and 216 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "Nexus Preview",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8765", "--directory", "web/app"],
"port": 8765
},
{
"name": "Frontend Dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"vuetify-mcp": {
"command": "npx",
"args": ["-y", "@vuetify/mcp", "--remote"]
}
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,152 @@
# Nexus Vue 3 + Nuxt UI 前端重设计方案
## 技术栈
- **框架**: Vue 3.5+ (Composition API + `<script setup>`)
- **构建**: Vite 6 (多页应用 MPA 模式)
- **UI 库**: Nuxt UI v4 (125+ 组件, MIT)
- **CSS**: Tailwind CSS v4
- **图标**: Iconify (Nuxt UI 内置 200k+ 图标)
- **暗色模式**: Nuxt UI 内置 `@nuxtjs/color-mode`
- **语言**: TypeScript
## 前端目录结构
```
frontend/
├── index.html # Vite 入口
├── package.json
├── vite.config.ts # MPA 多页配置
├── tsconfig.json
├── src/
│ ├── main.ts # Vue app 初始化 + Nuxt UI 注册
│ ├── App.vue # 根布局(侧边栏 + header + router-view
│ ├── router.ts # Vue Router (hash 模式)
│ ├── stores/ # Pinia 状态管理
│ │ ├── auth.ts # JWT 令牌 + 用户信息
│ │ └── theme.ts # 暗色/亮色切换
│ ├── composables/ # 可复用逻辑
│ │ ├── useApi.ts # fetch 封装 (JWT + 自动刷新)
│ │ ├── useToast.ts # Toast 通知
│ │ └── useWebSocket.ts # WebSocket 连接
│ ├── components/ # 共享组件
│ │ ├── ui/ # Nuxt UI 包装
│ │ ├── layout/
│ │ │ ├── AppSidebar.vue
│ │ │ ├── AppHeader.vue
│ │ │ └── AppLayout.vue
│ │ └── common/
│ │ ├── ServerStatusBadge.vue
│ │ ├── ConfirmModal.vue
│ │ └── EmptyState.vue
│ ├── pages/ # 页面组件
│ │ ├── LoginPage.vue
│ │ ├── DashboardPage.vue
│ │ ├── ServersPage.vue
│ │ ├── ServerDetailPage.vue
│ │ ├── FilesPage.vue
│ │ ├── PushPage.vue
│ │ ├── ScriptsPage.vue
│ │ ├── ScriptExecutionsPage.vue
│ │ ├── CredentialsPage.vue
│ │ ├── SchedulesPage.vue
│ │ ├── RetriesPage.vue
│ │ ├── CommandsPage.vue
│ │ ├── AlertsPage.vue
│ │ ├── AuditPage.vue
│ │ ├── SettingsPage.vue
│ │ └── TerminalPage.vue
│ └── types/ # TypeScript 类型
│ ├── api.ts # API 响应类型
│ └── models.ts # 数据模型
└── public/
└── favicon.svg
```
## 页面路由
| 路径 | 页面 | 原 HTML |
|------|------|---------|
| `/` | DashboardPage | index.html |
| `/servers` | ServersPage | servers.html |
| `/servers/:id` | ServerDetailPage | servers.html detail panel |
| `/files` | FilesPage | files.html |
| `/push` | PushPage | push.html |
| `/scripts` | ScriptsPage | scripts.html |
| `/scripts/executions` | ScriptExecutionsPage | script-executions.html |
| `/credentials` | CredentialsPage | credentials.html |
| `/schedules` | SchedulesPage | schedules.html |
| `/retries` | RetriesPage | retries.html |
| `/commands` | CommandsPage | commands.html |
| `/alerts` | AlertsPage | alerts.html |
| `/audit` | AuditPage | audit.html |
| `/settings` | SettingsPage | settings.html |
| `/terminal/:serverId` | TerminalPage | terminal.html |
| `/login` | LoginPage | login.html |
## Nuxt UI 组件映射
| 当前 HTML 模式 | Nuxt UI 组件 |
|---------------|-------------|
| 侧边栏 | `UNavigationMenu` |
| Header | `UHeader` |
| 表格 | `UTable` |
| 分页 | `UPagination` |
| 按钮 | `UButton` |
| 输入框 | `UInput` |
| Select | `USelect` / `USelectMenu` |
| Textarea | `UTextarea` |
| Modal | `UModal` |
| 卡片 | `UCard` |
| 标签页 | `UTabs` |
| Toast | Nuxt UI toast |
| 下拉菜单 | `UDropdownMenu` |
| 开关 | `UToggle` |
| 复选框 | `UCheckbox` |
| 面包屑 | `UBreadcrumb` |
| 头像 | `UAvatar` |
| 进度条 | `UProgress` |
| 骨架屏 | `USkeleton` |
| 空状态 | `UEmptyState` |
## 与原 HTML 版本的差异
1. **组件化**:一个 `UButton` 替换 10 行内联 Tailwind
2. **类型安全**API 响应有 TypeScript 类型
3. **开发体验**HMR 热更新,不用手动刷新
4. **代码量**:预估减少 60% (1200 行 servers.html → ~400 行 ServersPage.vue)
5. **部署**`npm run build``dist/` → scp 到服务器
6. **后端不变**:所有 Python API 完全不动
## 实施计划
### Phase 1: 项目脚手架
- vite + vue + nuxt-ui 初始化
- 路由 + 暗色模式 + API 封装
### Phase 2: 逐页面迁移(按复杂度排序)
1. LoginPage.vue(最简单)
2. DashboardPage.vue
3. ServersPage.vue + ServerDetailPage.vue(最复杂)
4. SettingsPage.vue
5. ScriptsPage.vue
6. PushPage.vue
7. FilesPage.vue
8. SchedulesPage.vue
9. CredentialsPage.vue
10. RetriesPage.vue
11. CommandsPage.vue
12. AlertsPage.vue
13. AuditPage.vue
14. TerminalPage.vue
### Phase 3: 部署
- build → dist/
- scp 到服务器 `/www/wwwroot/api.synaglobal.vip/app/`
- 后端静态文件路径指向 dist/
## 不改变的部分
- ✅ 所有 Python 后端 API
- ✅ API 端点路径
- ✅ JWT 认证流程
- ✅ 部署域名 https://api.synaglobal.vip
+36 -16
View File
@@ -1,7 +1,7 @@
<template>
<v-app>
<!-- App Bar -->
<v-app-bar color="primary" elevation="0" flat class="ps-4">
<!-- App Bar (隐藏于登录页) -->
<v-app-bar v-if="auth.isLoggedIn" color="primary" elevation="0" flat class="ps-4">
<v-app-bar-nav-icon class="mr-3" @click="drawer = !drawer" />
<v-avatar color="surface" size="40" class="pa-1">
@@ -165,8 +165,8 @@
</template>
</v-app-bar>
<!-- Navigation Drawer -->
<v-navigation-drawer v-model="drawer" color="surface" width="250">
<!-- Navigation Drawer (隐藏于登录页) -->
<v-navigation-drawer v-if="auth.isLoggedIn" v-model="drawer" color="surface" width="250">
<v-list nav slim>
<v-list-subheader>运维</v-list-subheader>
@@ -219,10 +219,8 @@
</v-navigation-drawer>
<!-- Main Content -->
<v-main scrollable>
<v-container>
<router-view />
</v-container>
<v-main>
<router-view />
</v-main>
<!-- Snackbar -->
@@ -233,12 +231,12 @@
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ref, reactive, watch, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTheme } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
import { useWebSocket } from '@/composables/useWebSocket'
import { http } from '@/api'
import { api } from '@/api'
const route = useRoute()
const router = useRouter()
@@ -248,10 +246,32 @@ const ws = useWebSocket()
// Auto-connect WebSocket when logged in
watch(() => auth.isLoggedIn, (loggedIn) => {
if (loggedIn) ws.connect()
if (loggedIn) { ws.connect(); syncThemeFromServer() }
else ws.disconnect()
}, { immediate: true })
// ── Theme: localStorage + MySQL ──
async function syncThemeFromServer() {
if (!auth.isLoggedIn) return
try {
const data = await api<{ value: string }>('/settings/theme')
if (data.value === 'dark' || data.value === 'light') {
theme.global.name.value = data.value
localStorage.setItem('nexus_theme', data.value)
}
} catch { /* non-critical — keep localStorage value */ }
}
async function toggleTheme() {
const newTheme = theme.global.current.value.dark ? 'light' : 'dark'
theme.global.name.value = newTheme
localStorage.setItem('nexus_theme', newTheme)
// Persist to MySQL (fire-and-forget)
try {
await api('/settings/theme', { method: 'PUT', body: JSON.stringify({ value: newTheme }) })
} catch { /* keep localStorage as fallback */ }
}
const drawer = ref(true)
const opsItems = [
@@ -331,11 +351,8 @@ function goToSchedules() {
}
// ── Theme ──
function toggleTheme() {
const newTheme = theme.global.current.value.dark ? 'light' : 'dark'
theme.global.name.value = newTheme
localStorage.setItem('nexus_theme', newTheme)
}
// See toggleTheme() above — persists to both localStorage and MySQL
function doLogout() {
auth.logout()
@@ -349,6 +366,9 @@ window.$snackbar = (text: string, color = 'success') => {
snackbar.show = true
}
// Cleanup
onUnmounted(() => clearTimeout(searchTimer))
interface SearchResults {
servers: { id: number; name: string; domain: string; category: string | null; is_online: boolean }[]
scripts: { id: number; name: string; category: string | null }[]
+13
View File
@@ -88,6 +88,13 @@ export const http = {
get: <T = any>(path: string, params?: Record<string, any>) =>
api<T>(path, { method: 'GET', params }),
/** GET a list endpoint that returns a bare array (not {items,total}).
* Wraps the result in {items, total} so callers don't need to care. */
getList: async <T = any>(path: string, params?: Record<string, any>) => {
const arr = await api<T[]>(path, { method: 'GET', params })
return { items: (arr || []) as T[], total: (arr || []).length }
},
post: <T = any>(path: string, body?: any) =>
api<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
@@ -100,6 +107,12 @@ export const http = {
/** Upload FormData (multipart, auto Content-Type with boundary) */
upload: <T = any>(path: string, formData: FormData) =>
api<T>(path, { method: 'POST', body: formData }),
/** GET a list that returns a bare array — wraps in {items, total} */
getList: async <T = any>(path: string, params?: Record<string, any>) => {
const arr = await api<T[]>(path, { method: 'GET', params })
return { items: (arr || []) as T[], total: (arr || []).length }
},
}
export class ApiError extends Error {
+7 -2
View File
@@ -13,8 +13,13 @@ import { registerPlugins } from '@/plugins'
// Components
import App from './App.vue'
// Styles
import 'unfonts.css'
// Roboto — latin-only, only weights Vuetify uses (300/400/500/700)
import '@fontsource/roboto/latin-300.css'
import '@fontsource/roboto/latin-400.css'
import '@fontsource/roboto/latin-500.css'
import '@fontsource/roboto/latin-700.css'
// NOTE: Roboto has NO Chinese glyphs. CJK text renders via browser fallback
// to the system's default Chinese font (e.g. Microsoft YaHei on Windows).
const app = createApp(App)
+11 -6
View File
@@ -108,6 +108,7 @@ const loading = ref(false)
const viewMode = ref<'commands' | 'sessions'>('commands')
const page = ref(1)
const total = ref(0)
const itemsPerPage = ref(30)
const serverFilter = ref<number | null>(null)
const commands = ref<CommandLogItem[]>([])
@@ -132,17 +133,21 @@ async function loadData() {
loading.value = true
try {
if (viewMode.value === 'commands') {
const items = await http.get<CommandLogItem[]>('/assets/command-logs', {
const res = await http.getList<CommandLogItem>('/assets/command-logs', {
server_id: serverFilter.value || undefined,
page: page.value,
per_page: itemsPerPage.value,
})
commands.value = items || []
total.value = commands.value.length
commands.value = res.items
total.value = res.total
} else {
const items = await http.get<SshSessionItem[]>('/assets/ssh-sessions', {
const res = await http.getList<SshSessionItem>('/assets/ssh-sessions', {
server_id: serverFilter.value || undefined,
page: page.value,
per_page: itemsPerPage.value,
})
sessions.value = items || []
total.value = sessions.value.length
sessions.value = res.items
total.value = res.total
}
} catch {
commands.value = []
+9 -9
View File
@@ -232,36 +232,36 @@ const deleteId = ref<number | null>(null)
async function loadPasswords() {
loading.value = true
try {
const res = await http.get<{ items: PasswordItem[]; total: number }>('/presets/', {
const res = await http.getList<PasswordItem>('/presets/', {
page: page.value,
per_page: itemsPerPage.value,
})
passwords.value = res.items || []
totalItems.value = res.total || 0
passwords.value = res.items
totalItems.value = res.total
} catch { passwords.value = [] }
finally { loading.value = false }
}
async function loadSshKeys() {
loading.value = true
try {
const res = await http.get<{ items: SshKeyItem[]; total: number }>('/ssh-key-presets/', {
const res = await http.getList<SshKeyItem>('/ssh-key-presets/', {
page: page.value,
per_page: itemsPerPage.value,
})
sshKeys.value = res.items || []
totalItems.value = res.total || 0
sshKeys.value = res.items
totalItems.value = res.total
} catch { sshKeys.value = [] }
finally { loading.value = false }
}
async function loadDbCreds() {
loading.value = true
try {
const res = await http.get<{ items: DbCredItem[]; total: number }>('/scripts/credentials', {
const res = await http.getList<DbCredItem>('/scripts/credentials', {
page: page.value,
per_page: itemsPerPage.value,
})
dbCreds.value = res.items || []
totalItems.value = res.total || 0
dbCreds.value = res.items
totalItems.value = res.total
} catch { dbCreds.value = [] }
finally { loading.value = false }
}
+40 -23
View File
@@ -1,28 +1,29 @@
<template>
<v-container>
<!-- Stats Cards (v-list border two-line, same as official Gallery) -->
<!-- Stats Cards -->
<v-row>
<v-col
v-for="(item, i) in statItems"
:key="i"
cols="6"
md="3"
sm="3"
>
<v-list
<v-card
elevation="0"
lines="two"
rounded="lg"
rounded="xl"
border
class="stat-card"
>
<v-list-item>
<v-list-item-title class="text-body-small">{{ item.subtitle }}</v-list-item-title>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<template #append>
<v-icon :color="item.color" :icon="item.icon" size="30" />
</template>
</v-list-item>
</v-list>
<v-card-text class="pa-4 d-flex align-center justify-space-between">
<div>
<div class="text-caption text-medium-emphasis mb-1" style="font-size: 14px; letter-spacing: 0.5px; text-transform: uppercase">{{ item.subtitle }}</div>
<div class="font-weight-bold" :class="'text-' + item.color" style="font-size: 36px; line-height: 1.1">{{ item.title }}</div>
</div>
<v-avatar :color="item.color" size="48" rounded="lg" class="stat-icon">
<v-icon :icon="item.icon" size="24" color="white" />
</v-avatar>
</v-card-text>
</v-card>
</v-col>
</v-row>
@@ -245,6 +246,22 @@
</v-container>
</template>
<style scoped>
.stat-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 24px rgba(var(--v-theme-primary), 0.08) !important;
}
.stat-icon {
transition: transform 0.3s ease;
}
.stat-card:hover .stat-icon {
transform: scale(1.1);
}
</style>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
@@ -356,7 +373,7 @@ async function loadRecentAudit() {
async function loadSummary() {
try {
// Load a larger set to compute fleet-wide stats
const res = await http.get<{ items: ServerApiItem[]; total: number }>('/servers/', { per_page: 500 })
const res = await http.get<{ items: ServerApiItem[]; total: number }>('/servers/', { per_page: 200 })
const allServers = res.items || []
const fleetTotal = res.total || allServers.length
@@ -395,20 +412,20 @@ function openFiles(item: ServerApiItem) {
}
// ── Init ──
// NOTE: WebSocket is managed by App.vue (connected when auth.isLoggedIn=true),
// so we reuse that global singleton instead of creating a second connection.
const ws = useWebSocket()
watch(ws.alerts, () => {
loadStats()
loadAlerts()
})
onMounted(() => {
loadStats()
loadServers()
loadAlerts()
loadSummary()
loadRecentAudit()
// WebSocket: refresh stats when new alerts arrive
const ws = useWebSocket()
ws.connect()
watch(ws.alerts, () => {
loadStats()
loadAlerts()
})
})
interface AlertItem {
+2 -2
View File
@@ -419,7 +419,7 @@ async function previewFile(item: FileEntry) {
server_id: selectedServer.value,
path: joinPath(currentPath.value, item.name),
})
alert(typeof res === 'string' ? res : JSON.stringify(res, null, 2))
snackbar(typeof res === 'string' ? res : JSON.stringify(res, null, 2), 'info')
} catch (e: any) {
snackbar(e.message || '读取失败', 'error')
} finally {
@@ -453,7 +453,7 @@ async function downloadFile(item: FileEntry) {
try {
await http.post('/sync/download', {
server_id: selectedServer.value,
remote_path: joinPath(currentPath.value, item.name),
path: joinPath(currentPath.value, item.name),
})
snackbar('下载已开始')
} catch (e: any) {
+163 -86
View File
@@ -1,105 +1,128 @@
<template>
<v-container fluid class="fill-height pa-0" style="background: rgb(var(--v-theme-background))">
<v-row align="center" justify="center" no-gutters style="min-height: 100vh">
<v-col cols="12" sm="8" md="5" lg="4">
<v-card elevation="0" rounded="lg" class="pa-8" color="surface" border>
<!-- Logo -->
<div class="text-center mb-8">
<v-avatar color="primary" size="56" rounded="lg" class="mb-4">
<v-icon icon="mdi-server-network" size="32" color="white" />
</v-avatar>
<div class="text-h5 font-weight-bold">Nexus</div>
<div class="text-body-2 text-medium-emphasis mt-1">服务器运维管理平台</div>
<div class="login-page">
<v-container fluid class="fill-height pa-0">
<v-row no-gutters style="min-height: 100vh">
<!-- Left: Bing daily wallpaper -->
<v-col cols="12" md="7" class="d-none d-md-flex login-left">
<div class="login-image" :style="{ backgroundImage: `url(${wallpaperUrl})` }">
<div class="login-image-overlay">
<div class="px-12">
<div class="d-flex align-center">
<v-avatar color="white" size="48" rounded="lg" class="mr-3">
<v-icon icon="mdi-server-network" size="28" color="primary" />
</v-avatar>
<span class="text-h4 font-weight-bold text-white">Nexus</span>
</div>
</div>
</div>
</div>
</v-col>
<!-- Error Alert -->
<v-alert v-if="error" type="error" density="compact" class="mb-4" closable @click:close="error = ''" rounded="lg">
{{ error }}
</v-alert>
<!-- Right: Login card -->
<v-col cols="12" md="5" class="d-flex align-center justify-center login-right">
<v-card elevation="0" rounded="xl" class="login-card pa-10" max-width="420" border>
<!-- Logo -->
<div class="text-center mb-8">
<v-avatar color="primary" size="56" rounded="xl" class="mb-4">
<v-icon icon="mdi-server-network" size="30" color="white" />
</v-avatar>
<h1 class="text-h5 font-weight-bold mb-1">登录 Nexus</h1>
<p class="text-body-2 text-medium-emphasis">服务器运维管理平台</p>
</div>
<!-- Lockout Warning -->
<v-alert v-if="lockoutUntil" type="warning" density="compact" class="mb-4" rounded="lg">
账户已锁定 {{ remainingMinutes }} 分钟后重试
</v-alert>
<!-- Error -->
<v-alert v-if="error" type="error" density="compact" class="mb-4" closable @click:close="error = ''" rounded="lg" variant="tonal">
{{ error }}
</v-alert>
<!-- Form -->
<v-form @submit.prevent="doLogin">
<v-text-field
v-model="username"
label="用户名"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-account-outline"
:disabled="loading"
class="mb-3"
autocomplete="username"
rounded="lg"
/>
<!-- Lockout -->
<v-alert v-if="lockoutUntil" type="warning" density="compact" class="mb-4" rounded="lg" variant="tonal">
账户已锁定 {{ remainingMinutes }} 分钟后重试
</v-alert>
<v-text-field
v-model="password"
label="密码"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:type="showPassword ? 'text' : 'password'"
@click:append-inner="showPassword = !showPassword"
:disabled="loading"
class="mb-3"
autocomplete="current-password"
rounded="lg"
/>
<!-- Form -->
<v-form @submit.prevent="doLogin">
<v-text-field
v-model="username"
label="用户名"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-account-outline"
:disabled="loading"
class="mb-4"
autocomplete="username"
rounded="lg"
hide-details="auto"
/>
<v-text-field
v-if="needTotp"
v-model="totpCode"
label="TOTP 验证码"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-shield-key-outline"
:disabled="loading"
class="mb-3"
autocomplete="one-time-code"
rounded="lg"
/>
<v-text-field
v-model="password"
label="密码"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:type="showPassword ? 'text' : 'password'"
@click:append-inner="showPassword = !showPassword"
:disabled="loading"
class="mb-4"
autocomplete="current-password"
rounded="lg"
hide-details="auto"
/>
<v-btn
type="submit"
color="primary"
variant="flat"
block
size="large"
:loading="loading"
:disabled="!!lockoutUntil"
rounded="lg"
class="mt-2"
>
登录
</v-btn>
</v-form>
<v-text-field
v-if="needTotp"
v-model="totpCode"
label="TOTP 验证码"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-shield-key-outline"
:disabled="loading"
class="mb-4"
autocomplete="one-time-code"
rounded="lg"
hide-details="auto"
/>
<!-- Failed attempts -->
<div v-if="failedAttempts > 0 && !lockoutUntil" class="text-caption text-warning text-center mt-4">
已失败 {{ failedAttempts }} 5 次后锁定 15 分钟
</div>
</v-card>
</v-col>
</v-row>
</v-container>
<v-btn
type="submit"
color="primary"
variant="flat"
block
size="large"
:loading="loading"
:disabled="!!lockoutUntil"
rounded="lg"
class="mt-2"
height="48"
>
登录
</v-btn>
</v-form>
<div v-if="failedAttempts > 0 && !lockoutUntil" class="text-center mt-4">
<v-chip size="small" variant="tonal" color="warning" label>
已失败 {{ failedAttempts }}
</v-chip>
</div>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ApiError } from '@/api'
import { ApiError, http } from '@/api'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
// ── State ──
const username = ref('')
const password = ref('')
const totpCode = ref('')
@@ -110,13 +133,27 @@ const needTotp = ref(false)
const failedAttempts = ref(0)
const lockoutUntil = ref<string | null>(null)
// ── Bing daily wallpaper ──
const wallpaperUrl = ref('')
async function loadWallpaper() {
try {
// Bing daily image — proxied through our backend to avoid CORS
const data = await http.get<{ url?: string }>('/settings/bing-wallpaper')
if (data?.url) {
wallpaperUrl.value = data.url
}
} catch { /* keep empty, gradient fallback shown */ }
}
onMounted(() => loadWallpaper())
const remainingMinutes = computed(() => {
if (!lockoutUntil.value) return 0
const diff = new Date(lockoutUntil.value).getTime() - Date.now()
return Math.max(1, Math.ceil(diff / 60000))
})
// ── Login ──
async function doLogin() {
if (!username.value || !password.value) {
error.value = '请输入用户名和密码'
@@ -133,8 +170,7 @@ async function doLogin() {
} catch (e: any) {
if (e instanceof ApiError) {
if (e.status === 429) {
const retryAfter = new Date(Date.now() + 15 * 60 * 1000).toISOString()
lockoutUntil.value = retryAfter
lockoutUntil.value = new Date(Date.now() + 15 * 60 * 1000).toISOString()
error.value = '登录尝试过多,账户已锁定 15 分钟'
} else if (e.message.includes('TOTP') || e.message.includes('totp') || e.message.includes('验证码')) {
needTotp.value = true
@@ -151,3 +187,44 @@ async function doLogin() {
}
}
</script>
<style scoped>
.login-page {
width: 100%;
min-height: 100vh;
background: rgb(var(--v-theme-background));
}
/* Left panel */
.login-left {
position: relative;
overflow: hidden;
}
.login-image {
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.9) 0%, rgb(21, 101, 192) 50%, rgb(13, 71, 161) 100%);
background-size: cover;
background-position: center;
display: flex;
align-items: center;
}
.login-image-overlay {
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0.2) 100%);
display: flex;
align-items: center;
}
/* Right panel */
.login-right {
background: rgb(var(--v-theme-background));
}
.login-card {
width: 100%;
}
</style>
+28 -24
View File
@@ -496,7 +496,8 @@ async function doPreview() {
previewData.value = await http.post('/sync/preview', {
source_path: effectiveSourcePath(),
target_path: targetPath.value,
server_ids: [...selectedIds.value],
server_id: selectedIds.value[0],
sync_mode: syncMode.value,
})
showPreview.value = true
} catch (e: any) {
@@ -535,33 +536,36 @@ async function doPush() {
connectProgressWs()
}
} catch (e: any) {
// Batch push failed — fall back to per-server push
// Batch push failed — fall back to per-server push (parallel, not sequential)
const total = ids.length
for (const sid of ids) {
try {
await http.post('/sync/files', {
source_path: effectiveSourcePath(),
target_path: targetPath.value,
server_ids: [sid],
sync_mode: syncMode.value,
})
pushStatus.value[sid] = '成功'
const idx = wsProgressItems.value.findIndex(p => p.id === sid)
if (idx !== -1) wsProgressItems.value[idx].status = 'success'
} catch (innerErr) {
pushStatus.value[sid] = '失败'
const idx = wsProgressItems.value.findIndex(p => p.id === sid)
if (idx !== -1) {
wsProgressItems.value[idx].status = 'failed'
wsProgressItems.value[idx].detail = (innerErr instanceof Error ? innerErr.message : '推送失败')
const results = await Promise.allSettled(
ids.map(async (sid) => {
try {
await http.post('/sync/files', {
source_path: effectiveSourcePath(),
target_path: targetPath.value,
server_ids: [sid],
sync_mode: syncMode.value,
})
pushStatus.value[sid] = '成功'
const idx = wsProgressItems.value.findIndex(p => p.id === sid)
if (idx !== -1) wsProgressItems.value[idx].status = 'success'
} catch (innerErr) {
pushStatus.value[sid] = '失败'
const idx = wsProgressItems.value.findIndex(p => p.id === sid)
if (idx !== -1) {
wsProgressItems.value[idx].status = 'failed'
wsProgressItems.value[idx].detail = (innerErr instanceof Error ? innerErr.message : '推送失败')
}
}
}
completedCount.value++
pushProgress.value = Math.round((completedCount.value / total) * 100)
}
completedCount.value++
pushProgress.value = Math.round((completedCount.value / total) * 100)
})
)
pushing.value = false
loadLogs()
snackbar(`推送完成:${completedCount.value}`)
const succeeded = results.filter(r => r.status === 'fulfilled').length
snackbar(`推送完成:${succeeded}/${total} 台成功`)
return
}
+3 -3
View File
@@ -124,13 +124,13 @@ const headers = [
async function loadRetries() {
loading.value = true
try {
const res = await http.get<PaginatedResponse<RetryItem>>('/retries/', {
const res = await http.getList<RetryItem>('/retries/', {
page: page.value,
per_page: 20,
status: statusFilter.value || undefined,
})
retries.value = res.items || []
total.value = res.total || 0
retries.value = res.items
total.value = res.total
} catch { retries.value = [] }
finally { loading.value = false }
}
+3 -3
View File
@@ -156,12 +156,12 @@ const headers = [
async function loadSchedules() {
loading.value = true
try {
const res = await http.get<{ items: ScheduleItem[]; total: number }>('/schedules/', {
const res = await http.getList<ScheduleItem>('/schedules/', {
page: page.value,
per_page: itemsPerPage.value,
})
schedules.value = res.items || []
totalItems.value = res.total || 0
schedules.value = res.items
totalItems.value = res.total
} catch { schedules.value = [] }
finally { loading.value = false }
}
+3 -3
View File
@@ -354,12 +354,12 @@ const filteredScripts = computed(() => {
async function loadScripts() {
loading.value = true
try {
const res = await http.get<{ items: ScriptItem[]; total: number }>('/scripts/', {
const res = await http.getList<ScriptItem>('/scripts/', {
page: page.value,
per_page: itemsPerPage.value,
})
scripts.value = res.items || []
totalItems.value = res.total || 0
scripts.value = res.items
totalItems.value = res.total
} catch { scripts.value = [] }
finally { loading.value = false }
}
+14 -5
View File
@@ -305,12 +305,12 @@ const stats = ref([
// ── Data loading ──
async function loadStats() {
try {
const s = await http.get<{ total: number; online: number; offline: number; alerting: number }>('/servers/stats')
const s = await http.get<{ total: number; online: number; offline: number; alerts: number }>('/servers/stats')
stats.value[0].value = String(s.total)
stats.value[1].value = String(s.online)
stats.value[1].sub = s.total ? `${((s.online / s.total) * 100).toFixed(1)}% 在线率` : ''
stats.value[2].value = String(s.offline)
stats.value[3].value = String(s.alerting)
stats.value[3].value = String(s.alerts)
} catch { /* dashboard loads without stats */ }
}
@@ -406,7 +406,7 @@ async function batchUninstallAgent() {
// ── CSV export ──
async function exportCSV() {
try {
const res = await http.get<{ items: ServerApiItem[] }>('/servers/', { per_page: 5000 })
const res = await http.getList<ServerApiItem>('/servers/', { per_page: 5000 })
const headers = ['ID', '名称', '地址', '端口', '用户名', '分类', '状态', 'Agent版本', '最后心跳']
const rows = (res.items || []).map(s => [
s.id, s.name, s.domain, s.port, s.username, s.category || '', s.status,
@@ -479,10 +479,19 @@ function confirmDelete(item: ServerApiItem) {
async function saveServer() {
saving.value = true
try {
const payload = {
name: form.value.name,
domain: form.value.address,
username: form.value.ssh_user,
port: form.value.ssh_port,
category: form.value.category || undefined,
platform_id: form.value.platform_id || undefined,
password: form.value.password || undefined,
}
if (editing.value && editingId.value) {
await http.put(`/servers/${editingId.value}`, form.value)
await http.put(`/servers/${editingId.value}`, payload)
} else {
await http.post('/servers/', form.value)
await http.post('/servers/', payload)
}
showAdd.value = false
resetForm()
+12 -11
View File
@@ -18,13 +18,13 @@
<div class="text-subtitle-2 mb-3">告警阈值</div>
<v-row dense>
<v-col cols="4">
<v-text-field v-model="settings.cpu_threshold" label="CPU %" variant="outlined" density="compact" type="number" :rules="[required('阈值'), numberRange(1, 100, '阈值')]" />
<v-text-field v-model="settings.cpu_alert_threshold" label="CPU %" variant="outlined" density="compact" type="number" :rules="[required('阈值'), numberRange(1, 100, '阈值')]" />
</v-col>
<v-col cols="4">
<v-text-field v-model="settings.memory_threshold" label="内存 %" variant="outlined" density="compact" type="number" :rules="[required('阈值'), numberRange(1, 100, '阈值')]" />
<v-text-field v-model="settings.mem_alert_threshold" label="内存 %" variant="outlined" density="compact" type="number" :rules="[required('阈值'), numberRange(1, 100, '阈值')]" />
</v-col>
<v-col cols="4">
<v-text-field v-model="settings.disk_threshold" label="磁盘 %" variant="outlined" density="compact" type="number" :rules="[required('阈值'), numberRange(1, 100, '阈值')]" />
<v-text-field v-model="settings.disk_alert_threshold" label="磁盘 %" variant="outlined" density="compact" type="number" :rules="[required('阈值'), numberRange(1, 100, '阈值')]" />
</v-col>
</v-row>
@@ -32,10 +32,10 @@
<div class="text-subtitle-2 mb-3">连接池</div>
<v-row dense>
<v-col cols="6">
<v-text-field v-model="settings.pool_size" label="连接池大小" variant="outlined" density="compact" type="number" :rules="[required(), isNumber()]" />
<v-text-field v-model="settings.db_pool_size" label="连接池大小" variant="outlined" density="compact" type="number" :rules="[required(), isNumber()]" />
</v-col>
<v-col cols="6">
<v-text-field v-model="settings.pool_overflow" label="溢出连接数" variant="outlined" density="compact" type="number" :rules="[required(), isNumber()]" />
<v-text-field v-model="settings.db_max_overflow" label="溢出连接数" variant="outlined" density="compact" type="number" :rules="[required(), isNumber()]" />
</v-col>
</v-row>
</v-card-text>
@@ -207,8 +207,8 @@ const auth = useAuthStore()
const settingsLoading = ref(false)
const settings = ref({
system_name: '', system_title: '',
cpu_threshold: 80, memory_threshold: 80, disk_threshold: 80,
pool_size: 10, pool_overflow: 20,
cpu_alert_threshold: 80, mem_alert_threshold: 80, disk_alert_threshold: 80,
db_pool_size: 10, db_max_overflow: 20,
telegram_bot_token: '', telegram_chat_id: '',
})
const saving = ref(false)
@@ -277,7 +277,7 @@ async function saveSettings() {
saving.value = true
try {
const entries = Object.entries(settings.value).filter(([key]) =>
['system_name', 'system_title', 'cpu_threshold', 'memory_threshold', 'disk_threshold', 'pool_size', 'pool_overflow', 'telegram_bot_token', 'telegram_chat_id'].includes(key)
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow', 'telegram_bot_token', 'telegram_chat_id'].includes(key)
)
for (const [key, value] of entries) {
await http.put(`/settings/${key}`, { value })
@@ -352,8 +352,9 @@ async function doDisableTotp() {
disablingTotp.value = true
try {
await http.post('/auth/totp/disable', {
password: disableTotpPassword.value,
code: disableTotpCode.value,
admin_id: auth.admin?.id,
current_password: disableTotpPassword.value,
totp_code: disableTotpCode.value,
})
await auth.fetchProfile()
showDisableTotp.value = false
@@ -384,7 +385,7 @@ async function doRevealApiKey() {
}
revealingKey.value = true
try {
const res = await http.post<{ api_key: string }>('/settings/api-key/reveal', { password: revealPassword.value })
const res = await http.post<{ api_key: string }>('/settings/api-key/reveal', { current_password: revealPassword.value })
apiKeyValue.value = res.api_key
showApiKey.value = true
showRevealDialog.value = false
+5 -10
View File
@@ -557,9 +557,11 @@ async function connectSession(idx: number) {
return
}
// 2. Open WebSocket
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${proto}//${location.host}/ws/terminal/${s.serverId}?token=${encodeURIComponent(websshToken)}`
// 2. Open WebSocket — use VITE_API_BASE so API origin is configurable
const apiBase = import.meta.env.VITE_API_BASE || `${location.protocol}//${location.host}`
const apiUrl = new URL(apiBase)
const wsProto = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProto}//${apiUrl.host}/ws/terminal/${s.serverId}?token=${encodeURIComponent(websshToken)}`
const ws = new WebSocket(wsUrl)
s.ws = ws
@@ -941,18 +943,11 @@ onMounted(async () => {
await loadServers()
loadQuickCmds()
// Restore command history
let hist: string[] = []
try { hist = JSON.parse(localStorage.getItem(K_HIST) || '[]') } catch {}
// Restore persisted tabs (disconnected state)
restoreTabsFromStorage()
if (initialServerId.value) {
await newSession(initialServerId.value)
if (sessions.value.length > 0) {
sessions.value[0].cmdHistory = hist
}
} else if (sessions.value.length === 0) {
// No restored tabs and no server_id in query — show server selector
showServerDialog.value = true
+1 -1
View File
@@ -3,7 +3,7 @@
*
* Vuetify light/dark
* Vuetify Gallery
* localStorage
* localStorage App.vue MySQL
*/
import { createVuetify } from 'vuetify'
import '@mdi/font/css/materialdesignicons.css'
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

-12
View File
@@ -1,6 +1,5 @@
import { fileURLToPath, URL } from 'node:url'
import Vue from '@vitejs/plugin-vue'
import Fonts from 'unplugin-fonts/vite'
import { defineConfig } from 'vite'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
@@ -16,17 +15,6 @@ export default defineConfig({
configFile: 'src/styles/settings.scss',
},
}),
Fonts({
fontsource: {
families: [
{
name: 'Roboto',
weights: [100, 300, 400, 500, 700, 900],
styles: ['normal', 'italic'],
},
],
},
}),
],
define: { 'process.env': {} },
resolve: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+1
View File
@@ -41,6 +41,7 @@ PUBLIC_PREFIXES = (
"/api/auth/logout", # invalidates refresh token from body, no access JWT required
"/api/agent/", # Agent uses X-API-Key header
"/api/install/", # install wizard (403 when already installed, except /status)
"/api/settings/bing-wallpaper", # login page wallpaper proxy
"/health",
"/ws/",
"/docs",
+22
View File
@@ -1261,3 +1261,25 @@ def _retry_to_dict(job: PushRetryJob) -> dict:
"last_error": job.last_error,
"created_at": str(job.created_at) if job.created_at else None,
}
# ── Bing Daily Wallpaper Proxy (avoids CORS, returns image URL) ──
@router.get("/bing-wallpaper", response_model=dict)
async def bing_wallpaper():
"""Proxy the Bing HPImageArchive API and return the daily wallpaper URL.
No auth required used by the login page."""
import httpx
try:
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(
"https://cn.bing.com/HPImageArchive.aspx",
params={"format": "js", "idx": 0, "n": 1, "mkt": "zh-CN"},
)
data = resp.json()
img = (data.get("images") or [{}])[0]
url = img.get("url")
if url:
return {"url": f"https://cn.bing.com{url}"}
except Exception:
pass
return {"url": None}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Some files were not shown because too many files have changed in this diff Show More