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>
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"vuetify-mcp": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@vuetify/mcp", "--remote"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app>
|
<v-app>
|
||||||
<!-- ── App Bar ── -->
|
<!-- ── App Bar (隐藏于登录页) ── -->
|
||||||
<v-app-bar color="primary" elevation="0" flat class="ps-4">
|
<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-app-bar-nav-icon class="mr-3" @click="drawer = !drawer" />
|
||||||
|
|
||||||
<v-avatar color="surface" size="40" class="pa-1">
|
<v-avatar color="surface" size="40" class="pa-1">
|
||||||
@@ -165,8 +165,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
|
|
||||||
<!-- ── Navigation Drawer ── -->
|
<!-- ── Navigation Drawer (隐藏于登录页) ── -->
|
||||||
<v-navigation-drawer v-model="drawer" color="surface" width="250">
|
<v-navigation-drawer v-if="auth.isLoggedIn" v-model="drawer" color="surface" width="250">
|
||||||
<v-list nav slim>
|
<v-list nav slim>
|
||||||
<v-list-subheader>运维</v-list-subheader>
|
<v-list-subheader>运维</v-list-subheader>
|
||||||
|
|
||||||
@@ -219,10 +219,8 @@
|
|||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
|
|
||||||
<!-- ── Main Content ── -->
|
<!-- ── Main Content ── -->
|
||||||
<v-main scrollable>
|
<v-main>
|
||||||
<v-container>
|
<router-view />
|
||||||
<router-view />
|
|
||||||
</v-container>
|
|
||||||
</v-main>
|
</v-main>
|
||||||
|
|
||||||
<!-- ── Snackbar ── -->
|
<!-- ── Snackbar ── -->
|
||||||
@@ -233,12 +231,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, watch } from 'vue'
|
import { ref, reactive, watch, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
import { http } from '@/api'
|
import { api } from '@/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -248,10 +246,32 @@ const ws = useWebSocket()
|
|||||||
|
|
||||||
// Auto-connect WebSocket when logged in
|
// Auto-connect WebSocket when logged in
|
||||||
watch(() => auth.isLoggedIn, (loggedIn) => {
|
watch(() => auth.isLoggedIn, (loggedIn) => {
|
||||||
if (loggedIn) ws.connect()
|
if (loggedIn) { ws.connect(); syncThemeFromServer() }
|
||||||
else ws.disconnect()
|
else ws.disconnect()
|
||||||
}, { immediate: true })
|
}, { 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 drawer = ref(true)
|
||||||
|
|
||||||
const opsItems = [
|
const opsItems = [
|
||||||
@@ -331,11 +351,8 @@ function goToSchedules() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Theme ──
|
// ── Theme ──
|
||||||
function toggleTheme() {
|
// See toggleTheme() above — persists to both localStorage and MySQL
|
||||||
const newTheme = theme.global.current.value.dark ? 'light' : 'dark'
|
|
||||||
theme.global.name.value = newTheme
|
|
||||||
localStorage.setItem('nexus_theme', newTheme)
|
|
||||||
}
|
|
||||||
|
|
||||||
function doLogout() {
|
function doLogout() {
|
||||||
auth.logout()
|
auth.logout()
|
||||||
@@ -349,6 +366,9 @@ window.$snackbar = (text: string, color = 'success') => {
|
|||||||
snackbar.show = true
|
snackbar.show = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
onUnmounted(() => clearTimeout(searchTimer))
|
||||||
|
|
||||||
interface SearchResults {
|
interface SearchResults {
|
||||||
servers: { id: number; name: string; domain: string; category: string | null; is_online: boolean }[]
|
servers: { id: number; name: string; domain: string; category: string | null; is_online: boolean }[]
|
||||||
scripts: { id: number; name: string; category: string | null }[]
|
scripts: { id: number; name: string; category: string | null }[]
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ export const http = {
|
|||||||
get: <T = any>(path: string, params?: Record<string, any>) =>
|
get: <T = any>(path: string, params?: Record<string, any>) =>
|
||||||
api<T>(path, { method: 'GET', params }),
|
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) =>
|
post: <T = any>(path: string, body?: any) =>
|
||||||
api<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
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 FormData (multipart, auto Content-Type with boundary) */
|
||||||
upload: <T = any>(path: string, formData: FormData) =>
|
upload: <T = any>(path: string, formData: FormData) =>
|
||||||
api<T>(path, { method: 'POST', body: 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 {
|
export class ApiError extends Error {
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ import { registerPlugins } from '@/plugins'
|
|||||||
// Components
|
// Components
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
// Styles
|
// Roboto — latin-only, only weights Vuetify uses (300/400/500/700)
|
||||||
import 'unfonts.css'
|
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)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ const loading = ref(false)
|
|||||||
const viewMode = ref<'commands' | 'sessions'>('commands')
|
const viewMode = ref<'commands' | 'sessions'>('commands')
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const itemsPerPage = ref(30)
|
||||||
const serverFilter = ref<number | null>(null)
|
const serverFilter = ref<number | null>(null)
|
||||||
|
|
||||||
const commands = ref<CommandLogItem[]>([])
|
const commands = ref<CommandLogItem[]>([])
|
||||||
@@ -132,17 +133,21 @@ async function loadData() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (viewMode.value === 'commands') {
|
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,
|
server_id: serverFilter.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
per_page: itemsPerPage.value,
|
||||||
})
|
})
|
||||||
commands.value = items || []
|
commands.value = res.items
|
||||||
total.value = commands.value.length
|
total.value = res.total
|
||||||
} else {
|
} else {
|
||||||
const items = await http.get<SshSessionItem[]>('/assets/ssh-sessions', {
|
const res = await http.getList<SshSessionItem>('/assets/ssh-sessions', {
|
||||||
server_id: serverFilter.value || undefined,
|
server_id: serverFilter.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
per_page: itemsPerPage.value,
|
||||||
})
|
})
|
||||||
sessions.value = items || []
|
sessions.value = res.items
|
||||||
total.value = sessions.value.length
|
total.value = res.total
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
commands.value = []
|
commands.value = []
|
||||||
|
|||||||
@@ -232,36 +232,36 @@ const deleteId = ref<number | null>(null)
|
|||||||
async function loadPasswords() {
|
async function loadPasswords() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await http.get<{ items: PasswordItem[]; total: number }>('/presets/', {
|
const res = await http.getList<PasswordItem>('/presets/', {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
per_page: itemsPerPage.value,
|
per_page: itemsPerPage.value,
|
||||||
})
|
})
|
||||||
passwords.value = res.items || []
|
passwords.value = res.items
|
||||||
totalItems.value = res.total || 0
|
totalItems.value = res.total
|
||||||
} catch { passwords.value = [] }
|
} catch { passwords.value = [] }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
async function loadSshKeys() {
|
async function loadSshKeys() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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,
|
page: page.value,
|
||||||
per_page: itemsPerPage.value,
|
per_page: itemsPerPage.value,
|
||||||
})
|
})
|
||||||
sshKeys.value = res.items || []
|
sshKeys.value = res.items
|
||||||
totalItems.value = res.total || 0
|
totalItems.value = res.total
|
||||||
} catch { sshKeys.value = [] }
|
} catch { sshKeys.value = [] }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
async function loadDbCreds() {
|
async function loadDbCreds() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await http.get<{ items: DbCredItem[]; total: number }>('/scripts/credentials', {
|
const res = await http.getList<DbCredItem>('/scripts/credentials', {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
per_page: itemsPerPage.value,
|
per_page: itemsPerPage.value,
|
||||||
})
|
})
|
||||||
dbCreds.value = res.items || []
|
dbCreds.value = res.items
|
||||||
totalItems.value = res.total || 0
|
totalItems.value = res.total
|
||||||
} catch { dbCreds.value = [] }
|
} catch { dbCreds.value = [] }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container>
|
<v-container>
|
||||||
<!-- ── Stats Cards (v-list border two-line, same as official Gallery) ── -->
|
<!-- ── Stats Cards ── -->
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col
|
<v-col
|
||||||
v-for="(item, i) in statItems"
|
v-for="(item, i) in statItems"
|
||||||
:key="i"
|
:key="i"
|
||||||
cols="6"
|
cols="6"
|
||||||
md="3"
|
sm="3"
|
||||||
>
|
>
|
||||||
<v-list
|
<v-card
|
||||||
elevation="0"
|
elevation="0"
|
||||||
lines="two"
|
rounded="xl"
|
||||||
rounded="lg"
|
|
||||||
border
|
border
|
||||||
|
class="stat-card"
|
||||||
>
|
>
|
||||||
<v-list-item>
|
<v-card-text class="pa-4 d-flex align-center justify-space-between">
|
||||||
<v-list-item-title class="text-body-small">{{ item.subtitle }}</v-list-item-title>
|
<div>
|
||||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
<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>
|
||||||
<template #append>
|
</div>
|
||||||
<v-icon :color="item.color" :icon="item.icon" size="30" />
|
<v-avatar :color="item.color" size="48" rounded="lg" class="stat-icon">
|
||||||
</template>
|
<v-icon :icon="item.icon" size="24" color="white" />
|
||||||
</v-list-item>
|
</v-avatar>
|
||||||
</v-list>
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
@@ -245,6 +246,22 @@
|
|||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -356,7 +373,7 @@ async function loadRecentAudit() {
|
|||||||
async function loadSummary() {
|
async function loadSummary() {
|
||||||
try {
|
try {
|
||||||
// Load a larger set to compute fleet-wide stats
|
// 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 allServers = res.items || []
|
||||||
const fleetTotal = res.total || allServers.length
|
const fleetTotal = res.total || allServers.length
|
||||||
|
|
||||||
@@ -395,20 +412,20 @@ function openFiles(item: ServerApiItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ──
|
// ── 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(() => {
|
onMounted(() => {
|
||||||
loadStats()
|
loadStats()
|
||||||
loadServers()
|
loadServers()
|
||||||
loadAlerts()
|
loadAlerts()
|
||||||
loadSummary()
|
loadSummary()
|
||||||
loadRecentAudit()
|
loadRecentAudit()
|
||||||
|
|
||||||
// WebSocket: refresh stats when new alerts arrive
|
|
||||||
const ws = useWebSocket()
|
|
||||||
ws.connect()
|
|
||||||
watch(ws.alerts, () => {
|
|
||||||
loadStats()
|
|
||||||
loadAlerts()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
interface AlertItem {
|
interface AlertItem {
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ async function previewFile(item: FileEntry) {
|
|||||||
server_id: selectedServer.value,
|
server_id: selectedServer.value,
|
||||||
path: joinPath(currentPath.value, item.name),
|
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) {
|
} catch (e: any) {
|
||||||
snackbar(e.message || '读取失败', 'error')
|
snackbar(e.message || '读取失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -453,7 +453,7 @@ async function downloadFile(item: FileEntry) {
|
|||||||
try {
|
try {
|
||||||
await http.post('/sync/download', {
|
await http.post('/sync/download', {
|
||||||
server_id: selectedServer.value,
|
server_id: selectedServer.value,
|
||||||
remote_path: joinPath(currentPath.value, item.name),
|
path: joinPath(currentPath.value, item.name),
|
||||||
})
|
})
|
||||||
snackbar('下载已开始')
|
snackbar('下载已开始')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -1,105 +1,128 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="fill-height pa-0" style="background: rgb(var(--v-theme-background))">
|
<div class="login-page">
|
||||||
<v-row align="center" justify="center" no-gutters style="min-height: 100vh">
|
<v-container fluid class="fill-height pa-0">
|
||||||
<v-col cols="12" sm="8" md="5" lg="4">
|
<v-row no-gutters style="min-height: 100vh">
|
||||||
<v-card elevation="0" rounded="lg" class="pa-8" color="surface" border>
|
<!-- Left: Bing daily wallpaper -->
|
||||||
<!-- Logo -->
|
<v-col cols="12" md="7" class="d-none d-md-flex login-left">
|
||||||
<div class="text-center mb-8">
|
<div class="login-image" :style="{ backgroundImage: `url(${wallpaperUrl})` }">
|
||||||
<v-avatar color="primary" size="56" rounded="lg" class="mb-4">
|
<div class="login-image-overlay">
|
||||||
<v-icon icon="mdi-server-network" size="32" color="white" />
|
<div class="px-12">
|
||||||
</v-avatar>
|
<div class="d-flex align-center">
|
||||||
<div class="text-h5 font-weight-bold">Nexus</div>
|
<v-avatar color="white" size="48" rounded="lg" class="mr-3">
|
||||||
<div class="text-body-2 text-medium-emphasis mt-1">服务器运维管理平台</div>
|
<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>
|
</div>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
<!-- Error Alert -->
|
<!-- Right: Login card -->
|
||||||
<v-alert v-if="error" type="error" density="compact" class="mb-4" closable @click:close="error = ''" rounded="lg">
|
<v-col cols="12" md="5" class="d-flex align-center justify-center login-right">
|
||||||
{{ error }}
|
<v-card elevation="0" rounded="xl" class="login-card pa-10" max-width="420" border>
|
||||||
</v-alert>
|
<!-- 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 -->
|
<!-- Error -->
|
||||||
<v-alert v-if="lockoutUntil" type="warning" density="compact" class="mb-4" rounded="lg">
|
<v-alert v-if="error" type="error" density="compact" class="mb-4" closable @click:close="error = ''" rounded="lg" variant="tonal">
|
||||||
账户已锁定,请 {{ remainingMinutes }} 分钟后重试
|
{{ error }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<!-- Form -->
|
<!-- Lockout -->
|
||||||
<v-form @submit.prevent="doLogin">
|
<v-alert v-if="lockoutUntil" type="warning" density="compact" class="mb-4" rounded="lg" variant="tonal">
|
||||||
<v-text-field
|
账户已锁定,请 {{ remainingMinutes }} 分钟后重试
|
||||||
v-model="username"
|
</v-alert>
|
||||||
label="用户名"
|
|
||||||
variant="outlined"
|
|
||||||
density="comfortable"
|
|
||||||
prepend-inner-icon="mdi-account-outline"
|
|
||||||
:disabled="loading"
|
|
||||||
class="mb-3"
|
|
||||||
autocomplete="username"
|
|
||||||
rounded="lg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
<!-- Form -->
|
||||||
v-model="password"
|
<v-form @submit.prevent="doLogin">
|
||||||
label="密码"
|
<v-text-field
|
||||||
variant="outlined"
|
v-model="username"
|
||||||
density="comfortable"
|
label="用户名"
|
||||||
prepend-inner-icon="mdi-lock-outline"
|
variant="outlined"
|
||||||
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
density="comfortable"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
prepend-inner-icon="mdi-account-outline"
|
||||||
@click:append-inner="showPassword = !showPassword"
|
:disabled="loading"
|
||||||
:disabled="loading"
|
class="mb-4"
|
||||||
class="mb-3"
|
autocomplete="username"
|
||||||
autocomplete="current-password"
|
rounded="lg"
|
||||||
rounded="lg"
|
hide-details="auto"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-if="needTotp"
|
v-model="password"
|
||||||
v-model="totpCode"
|
label="密码"
|
||||||
label="TOTP 验证码"
|
variant="outlined"
|
||||||
variant="outlined"
|
density="comfortable"
|
||||||
density="comfortable"
|
prepend-inner-icon="mdi-lock-outline"
|
||||||
prepend-inner-icon="mdi-shield-key-outline"
|
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||||
:disabled="loading"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
class="mb-3"
|
@click:append-inner="showPassword = !showPassword"
|
||||||
autocomplete="one-time-code"
|
:disabled="loading"
|
||||||
rounded="lg"
|
class="mb-4"
|
||||||
/>
|
autocomplete="current-password"
|
||||||
|
rounded="lg"
|
||||||
|
hide-details="auto"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-btn
|
<v-text-field
|
||||||
type="submit"
|
v-if="needTotp"
|
||||||
color="primary"
|
v-model="totpCode"
|
||||||
variant="flat"
|
label="TOTP 验证码"
|
||||||
block
|
variant="outlined"
|
||||||
size="large"
|
density="comfortable"
|
||||||
:loading="loading"
|
prepend-inner-icon="mdi-shield-key-outline"
|
||||||
:disabled="!!lockoutUntil"
|
:disabled="loading"
|
||||||
rounded="lg"
|
class="mb-4"
|
||||||
class="mt-2"
|
autocomplete="one-time-code"
|
||||||
>
|
rounded="lg"
|
||||||
登录
|
hide-details="auto"
|
||||||
</v-btn>
|
/>
|
||||||
</v-form>
|
|
||||||
|
|
||||||
<!-- Failed attempts -->
|
<v-btn
|
||||||
<div v-if="failedAttempts > 0 && !lockoutUntil" class="text-caption text-warning text-center mt-4">
|
type="submit"
|
||||||
已失败 {{ failedAttempts }} 次(5 次后锁定 15 分钟)
|
color="primary"
|
||||||
</div>
|
variant="flat"
|
||||||
</v-card>
|
block
|
||||||
</v-col>
|
size="large"
|
||||||
</v-row>
|
:loading="loading"
|
||||||
</v-container>
|
: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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { ApiError } from '@/api'
|
import { ApiError, http } from '@/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
// ── State ──
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const totpCode = ref('')
|
const totpCode = ref('')
|
||||||
@@ -110,13 +133,27 @@ const needTotp = ref(false)
|
|||||||
const failedAttempts = ref(0)
|
const failedAttempts = ref(0)
|
||||||
const lockoutUntil = ref<string | null>(null)
|
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(() => {
|
const remainingMinutes = computed(() => {
|
||||||
if (!lockoutUntil.value) return 0
|
if (!lockoutUntil.value) return 0
|
||||||
const diff = new Date(lockoutUntil.value).getTime() - Date.now()
|
const diff = new Date(lockoutUntil.value).getTime() - Date.now()
|
||||||
return Math.max(1, Math.ceil(diff / 60000))
|
return Math.max(1, Math.ceil(diff / 60000))
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Login ──
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
if (!username.value || !password.value) {
|
if (!username.value || !password.value) {
|
||||||
error.value = '请输入用户名和密码'
|
error.value = '请输入用户名和密码'
|
||||||
@@ -133,8 +170,7 @@ async function doLogin() {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
if (e.status === 429) {
|
if (e.status === 429) {
|
||||||
const retryAfter = new Date(Date.now() + 15 * 60 * 1000).toISOString()
|
lockoutUntil.value = new Date(Date.now() + 15 * 60 * 1000).toISOString()
|
||||||
lockoutUntil.value = retryAfter
|
|
||||||
error.value = '登录尝试过多,账户已锁定 15 分钟'
|
error.value = '登录尝试过多,账户已锁定 15 分钟'
|
||||||
} else if (e.message.includes('TOTP') || e.message.includes('totp') || e.message.includes('验证码')) {
|
} else if (e.message.includes('TOTP') || e.message.includes('totp') || e.message.includes('验证码')) {
|
||||||
needTotp.value = true
|
needTotp.value = true
|
||||||
@@ -151,3 +187,44 @@ async function doLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -496,7 +496,8 @@ async function doPreview() {
|
|||||||
previewData.value = await http.post('/sync/preview', {
|
previewData.value = await http.post('/sync/preview', {
|
||||||
source_path: effectiveSourcePath(),
|
source_path: effectiveSourcePath(),
|
||||||
target_path: targetPath.value,
|
target_path: targetPath.value,
|
||||||
server_ids: [...selectedIds.value],
|
server_id: selectedIds.value[0],
|
||||||
|
sync_mode: syncMode.value,
|
||||||
})
|
})
|
||||||
showPreview.value = true
|
showPreview.value = true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -535,33 +536,36 @@ async function doPush() {
|
|||||||
connectProgressWs()
|
connectProgressWs()
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} 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
|
const total = ids.length
|
||||||
for (const sid of ids) {
|
const results = await Promise.allSettled(
|
||||||
try {
|
ids.map(async (sid) => {
|
||||||
await http.post('/sync/files', {
|
try {
|
||||||
source_path: effectiveSourcePath(),
|
await http.post('/sync/files', {
|
||||||
target_path: targetPath.value,
|
source_path: effectiveSourcePath(),
|
||||||
server_ids: [sid],
|
target_path: targetPath.value,
|
||||||
sync_mode: syncMode.value,
|
server_ids: [sid],
|
||||||
})
|
sync_mode: syncMode.value,
|
||||||
pushStatus.value[sid] = '成功'
|
})
|
||||||
const idx = wsProgressItems.value.findIndex(p => p.id === sid)
|
pushStatus.value[sid] = '成功'
|
||||||
if (idx !== -1) wsProgressItems.value[idx].status = 'success'
|
const idx = wsProgressItems.value.findIndex(p => p.id === sid)
|
||||||
} catch (innerErr) {
|
if (idx !== -1) wsProgressItems.value[idx].status = 'success'
|
||||||
pushStatus.value[sid] = '失败'
|
} catch (innerErr) {
|
||||||
const idx = wsProgressItems.value.findIndex(p => p.id === sid)
|
pushStatus.value[sid] = '失败'
|
||||||
if (idx !== -1) {
|
const idx = wsProgressItems.value.findIndex(p => p.id === sid)
|
||||||
wsProgressItems.value[idx].status = 'failed'
|
if (idx !== -1) {
|
||||||
wsProgressItems.value[idx].detail = (innerErr instanceof Error ? innerErr.message : '推送失败')
|
wsProgressItems.value[idx].status = 'failed'
|
||||||
|
wsProgressItems.value[idx].detail = (innerErr instanceof Error ? innerErr.message : '推送失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
completedCount.value++
|
||||||
completedCount.value++
|
pushProgress.value = Math.round((completedCount.value / total) * 100)
|
||||||
pushProgress.value = Math.round((completedCount.value / total) * 100)
|
})
|
||||||
}
|
)
|
||||||
pushing.value = false
|
pushing.value = false
|
||||||
loadLogs()
|
loadLogs()
|
||||||
snackbar(`推送完成:${completedCount.value} 台`)
|
const succeeded = results.filter(r => r.status === 'fulfilled').length
|
||||||
|
snackbar(`推送完成:${succeeded}/${total} 台成功`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,13 +124,13 @@ const headers = [
|
|||||||
async function loadRetries() {
|
async function loadRetries() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await http.get<PaginatedResponse<RetryItem>>('/retries/', {
|
const res = await http.getList<RetryItem>('/retries/', {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
status: statusFilter.value || undefined,
|
status: statusFilter.value || undefined,
|
||||||
})
|
})
|
||||||
retries.value = res.items || []
|
retries.value = res.items
|
||||||
total.value = res.total || 0
|
total.value = res.total
|
||||||
} catch { retries.value = [] }
|
} catch { retries.value = [] }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,12 +156,12 @@ const headers = [
|
|||||||
async function loadSchedules() {
|
async function loadSchedules() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await http.get<{ items: ScheduleItem[]; total: number }>('/schedules/', {
|
const res = await http.getList<ScheduleItem>('/schedules/', {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
per_page: itemsPerPage.value,
|
per_page: itemsPerPage.value,
|
||||||
})
|
})
|
||||||
schedules.value = res.items || []
|
schedules.value = res.items
|
||||||
totalItems.value = res.total || 0
|
totalItems.value = res.total
|
||||||
} catch { schedules.value = [] }
|
} catch { schedules.value = [] }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,12 +354,12 @@ const filteredScripts = computed(() => {
|
|||||||
async function loadScripts() {
|
async function loadScripts() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await http.get<{ items: ScriptItem[]; total: number }>('/scripts/', {
|
const res = await http.getList<ScriptItem>('/scripts/', {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
per_page: itemsPerPage.value,
|
per_page: itemsPerPage.value,
|
||||||
})
|
})
|
||||||
scripts.value = res.items || []
|
scripts.value = res.items
|
||||||
totalItems.value = res.total || 0
|
totalItems.value = res.total
|
||||||
} catch { scripts.value = [] }
|
} catch { scripts.value = [] }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,12 +305,12 @@ const stats = ref([
|
|||||||
// ── Data loading ──
|
// ── Data loading ──
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
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[0].value = String(s.total)
|
||||||
stats.value[1].value = String(s.online)
|
stats.value[1].value = String(s.online)
|
||||||
stats.value[1].sub = s.total ? `${((s.online / s.total) * 100).toFixed(1)}% 在线率` : ''
|
stats.value[1].sub = s.total ? `${((s.online / s.total) * 100).toFixed(1)}% 在线率` : ''
|
||||||
stats.value[2].value = String(s.offline)
|
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 */ }
|
} catch { /* dashboard loads without stats */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +406,7 @@ async function batchUninstallAgent() {
|
|||||||
// ── CSV export ──
|
// ── CSV export ──
|
||||||
async function exportCSV() {
|
async function exportCSV() {
|
||||||
try {
|
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 headers = ['ID', '名称', '地址', '端口', '用户名', '分类', '状态', 'Agent版本', '最后心跳']
|
||||||
const rows = (res.items || []).map(s => [
|
const rows = (res.items || []).map(s => [
|
||||||
s.id, s.name, s.domain, s.port, s.username, s.category || '', s.status,
|
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() {
|
async function saveServer() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
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) {
|
if (editing.value && editingId.value) {
|
||||||
await http.put(`/servers/${editingId.value}`, form.value)
|
await http.put(`/servers/${editingId.value}`, payload)
|
||||||
} else {
|
} else {
|
||||||
await http.post('/servers/', form.value)
|
await http.post('/servers/', payload)
|
||||||
}
|
}
|
||||||
showAdd.value = false
|
showAdd.value = false
|
||||||
resetForm()
|
resetForm()
|
||||||
|
|||||||
@@ -18,13 +18,13 @@
|
|||||||
<div class="text-subtitle-2 mb-3">告警阈值</div>
|
<div class="text-subtitle-2 mb-3">告警阈值</div>
|
||||||
<v-row dense>
|
<v-row dense>
|
||||||
<v-col cols="4">
|
<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>
|
||||||
<v-col cols="4">
|
<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>
|
||||||
<v-col cols="4">
|
<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-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
@@ -32,10 +32,10 @@
|
|||||||
<div class="text-subtitle-2 mb-3">连接池</div>
|
<div class="text-subtitle-2 mb-3">连接池</div>
|
||||||
<v-row dense>
|
<v-row dense>
|
||||||
<v-col cols="6">
|
<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>
|
||||||
<v-col cols="6">
|
<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-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -207,8 +207,8 @@ const auth = useAuthStore()
|
|||||||
const settingsLoading = ref(false)
|
const settingsLoading = ref(false)
|
||||||
const settings = ref({
|
const settings = ref({
|
||||||
system_name: '', system_title: '',
|
system_name: '', system_title: '',
|
||||||
cpu_threshold: 80, memory_threshold: 80, disk_threshold: 80,
|
cpu_alert_threshold: 80, mem_alert_threshold: 80, disk_alert_threshold: 80,
|
||||||
pool_size: 10, pool_overflow: 20,
|
db_pool_size: 10, db_max_overflow: 20,
|
||||||
telegram_bot_token: '', telegram_chat_id: '',
|
telegram_bot_token: '', telegram_chat_id: '',
|
||||||
})
|
})
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -277,7 +277,7 @@ async function saveSettings() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const entries = Object.entries(settings.value).filter(([key]) =>
|
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) {
|
for (const [key, value] of entries) {
|
||||||
await http.put(`/settings/${key}`, { value })
|
await http.put(`/settings/${key}`, { value })
|
||||||
@@ -352,8 +352,9 @@ async function doDisableTotp() {
|
|||||||
disablingTotp.value = true
|
disablingTotp.value = true
|
||||||
try {
|
try {
|
||||||
await http.post('/auth/totp/disable', {
|
await http.post('/auth/totp/disable', {
|
||||||
password: disableTotpPassword.value,
|
admin_id: auth.admin?.id,
|
||||||
code: disableTotpCode.value,
|
current_password: disableTotpPassword.value,
|
||||||
|
totp_code: disableTotpCode.value,
|
||||||
})
|
})
|
||||||
await auth.fetchProfile()
|
await auth.fetchProfile()
|
||||||
showDisableTotp.value = false
|
showDisableTotp.value = false
|
||||||
@@ -384,7 +385,7 @@ async function doRevealApiKey() {
|
|||||||
}
|
}
|
||||||
revealingKey.value = true
|
revealingKey.value = true
|
||||||
try {
|
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
|
apiKeyValue.value = res.api_key
|
||||||
showApiKey.value = true
|
showApiKey.value = true
|
||||||
showRevealDialog.value = false
|
showRevealDialog.value = false
|
||||||
|
|||||||
@@ -557,9 +557,11 @@ async function connectSession(idx: number) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Open WebSocket
|
// 2. Open WebSocket — use VITE_API_BASE so API origin is configurable
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const apiBase = import.meta.env.VITE_API_BASE || `${location.protocol}//${location.host}`
|
||||||
const wsUrl = `${proto}//${location.host}/ws/terminal/${s.serverId}?token=${encodeURIComponent(websshToken)}`
|
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)
|
const ws = new WebSocket(wsUrl)
|
||||||
s.ws = ws
|
s.ws = ws
|
||||||
|
|
||||||
@@ -941,18 +943,11 @@ onMounted(async () => {
|
|||||||
await loadServers()
|
await loadServers()
|
||||||
loadQuickCmds()
|
loadQuickCmds()
|
||||||
|
|
||||||
// Restore command history
|
|
||||||
let hist: string[] = []
|
|
||||||
try { hist = JSON.parse(localStorage.getItem(K_HIST) || '[]') } catch {}
|
|
||||||
|
|
||||||
// Restore persisted tabs (disconnected state)
|
// Restore persisted tabs (disconnected state)
|
||||||
restoreTabsFromStorage()
|
restoreTabsFromStorage()
|
||||||
|
|
||||||
if (initialServerId.value) {
|
if (initialServerId.value) {
|
||||||
await newSession(initialServerId.value)
|
await newSession(initialServerId.value)
|
||||||
if (sessions.value.length > 0) {
|
|
||||||
sessions.value[0].cmdHistory = hist
|
|
||||||
}
|
|
||||||
} else if (sessions.value.length === 0) {
|
} else if (sessions.value.length === 0) {
|
||||||
// No restored tabs and no server_id in query — show server selector
|
// No restored tabs and no server_id in query — show server selector
|
||||||
showServerDialog.value = true
|
showServerDialog.value = true
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Vuetify 默认主题 — light/dark 双主题,不自定义色值
|
* Vuetify 默认主题 — light/dark 双主题,不自定义色值
|
||||||
* 严格遵循 Vuetify 官方 Gallery 配色
|
* 严格遵循 Vuetify 官方 Gallery 配色
|
||||||
* 主题选择持久化到 localStorage,刷新后恢复
|
* 主题优先从 localStorage 恢复(快速),App.vue 初始化时从 MySQL 同步并覆盖。
|
||||||
*/
|
*/
|
||||||
import { createVuetify } from 'vuetify'
|
import { createVuetify } from 'vuetify'
|
||||||
import '@mdi/font/css/materialdesignicons.css'
|
import '@mdi/font/css/materialdesignicons.css'
|
||||||
|
|||||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -1,6 +1,5 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
import Vue from '@vitejs/plugin-vue'
|
import Vue from '@vitejs/plugin-vue'
|
||||||
import Fonts from 'unplugin-fonts/vite'
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||||
|
|
||||||
@@ -16,17 +15,6 @@ export default defineConfig({
|
|||||||
configFile: 'src/styles/settings.scss',
|
configFile: 'src/styles/settings.scss',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Fonts({
|
|
||||||
fontsource: {
|
|
||||||
families: [
|
|
||||||
{
|
|
||||||
name: 'Roboto',
|
|
||||||
weights: [100, 300, 400, 500, 700, 900],
|
|
||||||
styles: ['normal', 'italic'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
define: { 'process.env': {} },
|
define: { 'process.env': {} },
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -41,6 +41,7 @@ PUBLIC_PREFIXES = (
|
|||||||
"/api/auth/logout", # invalidates refresh token from body, no access JWT required
|
"/api/auth/logout", # invalidates refresh token from body, no access JWT required
|
||||||
"/api/agent/", # Agent uses X-API-Key header
|
"/api/agent/", # Agent uses X-API-Key header
|
||||||
"/api/install/", # install wizard (403 when already installed, except /status)
|
"/api/install/", # install wizard (403 when already installed, except /status)
|
||||||
|
"/api/settings/bing-wallpaper", # login page wallpaper proxy
|
||||||
"/health",
|
"/health",
|
||||||
"/ws/",
|
"/ws/",
|
||||||
"/docs",
|
"/docs",
|
||||||
|
|||||||
@@ -1261,3 +1261,25 @@ def _retry_to_dict(job: PushRetryJob) -> dict:
|
|||||||
"last_error": job.last_error,
|
"last_error": job.last_error,
|
||||||
"created_at": str(job.created_at) if job.created_at else None,
|
"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}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 109 KiB |