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>
|
||||
<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 }[]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Vuetify 默认主题 — light/dark 双主题,不自定义色值
|
||||
* 严格遵循 Vuetify 官方 Gallery 配色
|
||||
* 主题选择持久化到 localStorage,刷新后恢复
|
||||
* 主题优先从 localStorage 恢复(快速),App.vue 初始化时从 MySQL 同步并覆盖。
|
||||
*/
|
||||
import { createVuetify } from 'vuetify'
|
||||
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 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: {
|
||||
|
||||
|
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/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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
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 |