release: nexus btpanel session fix and app-v2

This commit is contained in:
Codex Release Bot
2026-07-08 22:31:31 +08:00
commit 1933f0af6e
2457 changed files with 350105 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Project Rules
## General
- Follow the existing code style and patterns.
- Use npm for running project commands.
- Keep code in TypeScript unless migration is required.
## Stack
- Framework: Vue 3 + Vite
- UI Library: Vuetify
- Enabled Features: Base setup
+75
View File
@@ -0,0 +1,75 @@
# frontend
Scaffolded with Vuetify CLI.
## ❗️ Documentation
- Primary docs: https://vuetifyjs.com/
- Getting started guide: https://vuetifyjs.com/en/getting-started/installation/
- Community support: https://community.vuetifyjs.com/
- Issue tracker: https://issues.vuetifyjs.com/
## 🧱 Stack
- Framework: Vue 3 + Vite
- UI Library: Vuetify
- Language: TypeScript
- Package manager: npm
## 🧭 Start Here
- Main entry: `src/main.ts`
- Main app component: `src/App.vue`
- Main styles: `src/styles/`
- Plugin setup: `src/plugins/`
## 📁 Project Structure
- `src/main.ts` — application entry point
- `src/App.vue` — root component
- `src/components/` — reusable Vue components
- `src/plugins/` — plugin registration and setup
- `src/styles/` — global styles and theme settings
- `public/` — static public files
## ✨ Enabled Features
- Base setup
## 💿 Install
Use your selected package manager (npm) to install dependencies:
```bash
npm install
```
## 🚀 Quick Start
```bash
npm install
npm run dev
```
## 🏗️ Build
```bash
npm run build
```
## 🧪 Available Scripts
- `npm run dev`
- `npm run build`
- `npm run preview`
- `npm run build-only`
- `npm run type-check`
## 💪 Support Vuetify Development
This project uses Vuetify - an MIT licensed Open Source project. We are glad to welcome contributors and any support for ongoing development:
- Contribute to Vuetify and ecosystem projects: https://github.com/vuetifyjs
- Request enterprise support: https://support.vuetifyjs.com/
- Sponsor on GitHub: https://github.com/sponsors/vuetifyjs
- Support on Open Collective: https://opencollective.com/vuetify
+44
View File
@@ -0,0 +1,44 @@
/**
* Worker-scoped auth: one API login per worker; persist rotated refresh cookies between tests.
*/
import { test as base, request } from '@playwright/test'
const BASE = (process.env.NEXUS_E2E_BASE || 'https://api.synaglobal.vip').replace(/\/$/, '')
const USER = process.env.NEXUS_E2E_USER || 'admin'
const PASS = process.env.NEXUS_E2E_PASSWORD || ''
/** @type {import('@playwright/test').StorageState | null} */
let workerStorageState = null
async function ensureWorkerStorageState() {
if (workerStorageState) return workerStorageState
if (!PASS) throw new Error('Set NEXUS_E2E_PASSWORD for E2E')
const ctx = await request.newContext({ baseURL: BASE, ignoreHTTPSErrors: true })
try {
const res = await ctx.post('/api/auth/login', {
data: { username: USER, password: PASS },
})
if (!res.ok()) {
throw new Error(`Worker login failed (${res.status()}): ${await res.text()}`)
}
workerStorageState = await ctx.storageState()
return workerStorageState
} finally {
await ctx.dispose()
}
}
export const test = base.extend({
context: async ({ browser }, use) => {
const storageState = await ensureWorkerStorageState()
const context = await browser.newContext({ storageState })
try {
await use(context)
} finally {
workerStorageState = await context.storageState()
await context.close()
}
},
})
export { expect } from '@playwright/test'
+130
View File
@@ -0,0 +1,130 @@
/**
* Full-site UI acceptance — every SPA route + primary controls (no destructive push).
* Run from frontend/: NEXUS_E2E_USER=admin NEXUS_E2E_PASSWORD=*** npm run test:e2e
*/
import { test, expect } from './fixtures.mjs'
import { APP, USER, login, nav } from './helpers.mjs'
test.describe.configure({ mode: 'serial' })
test('01 login + dashboard stats', async ({ page }) => {
await login(page)
await nav(page, '/')
await expect(page.locator('.v-card').first()).toBeVisible({ timeout: 20000 })
})
test('02 servers: list, batch select, add dialog', async ({ page }) => {
await login(page)
await nav(page, '/servers')
await page.getByRole('button', { name: '添加', exact: true }).click()
await expect(page.getByText('添加服务器')).toBeVisible()
await page.getByRole('button', { name: '取消' }).click()
const cbs = page.locator('table tbody tr input[type="checkbox"]')
if (await cbs.count() >= 2) {
await cbs.nth(0).check()
await cbs.nth(1).check()
await expect(page.getByText(/已选择\s*2\s*台/)).toBeVisible()
}
})
test('03 dashboard nav drawer', async ({ page }) => {
await login(page)
await nav(page, '/')
await expect(page.locator('.v-card').first()).toBeVisible({ timeout: 20000 })
await expect(page.getByRole('link', { name: '服务器' })).toBeVisible()
})
test('04 terminal page', async ({ page }) => {
await login(page)
await nav(page, '/terminal')
await expect(page.getByText(/终端|服务器|连接/).first()).toBeVisible({ timeout: 20000 })
})
test('05 files page', async ({ page }) => {
await login(page)
await nav(page, '/files')
await expect(page.getByText(/文件|远程|服务器/).first()).toBeVisible({ timeout: 20000 })
})
test('06 push: preview only', async ({ page }) => {
await login(page)
await nav(page, '/push')
await page.getByRole('button', { name: '预览' }).click()
await page.waitForTimeout(1500)
})
test('07 scripts: new dialog cancel', async ({ page }) => {
await login(page)
await nav(page, '/scripts')
await page.getByRole('button', { name: '新建脚本' }).click()
await expect(page.getByText('新建脚本').first()).toBeVisible()
await page.getByRole('button', { name: '取消' }).click()
})
test('08 credentials: tabs', async ({ page }) => {
await login(page)
await nav(page, '/servers')
await page.getByRole('button', { name: '凭据' }).click()
const dialog = page.getByRole('dialog')
await expect(dialog.getByText('凭据管理')).toBeVisible({ timeout: 20000 })
await page.getByRole('button', { name: 'SSH 密钥' }).click()
await page.getByRole('button', { name: '数据库凭据' }).click()
await page.getByRole('button', { name: '密码预设' }).click()
await expect(page.getByRole('dialog').getByRole('button', { name: '添加', exact: true })).toBeVisible()
})
test('09 schedules: new dialog cancel', async ({ page }) => {
await login(page)
await nav(page, '/schedules')
await page.getByRole('button', { name: '新建调度' }).click()
await expect(page.getByText('新建调度').first()).toBeVisible()
await page.getByRole('button', { name: '取消' }).click()
})
test('10 retries list', async ({ page }) => {
await login(page)
await nav(page, '/retries')
await expect(page.getByText(/重试|队列/).first()).toBeVisible({ timeout: 20000 })
})
test('11 commands: command, session and push log views', async ({ page }) => {
await login(page)
await nav(page, '/commands')
await expect(page.locator('main .v-card-title').filter({ hasText: '命令日志' })).toBeVisible({
timeout: 20000,
})
await page.locator('.v-card-title .v-select').first().click()
await page.locator('.v-overlay-container').getByText('推送日志').click()
await expect(page.locator('table').first()).toBeVisible()
await page.locator('.v-card-title .v-select').first().click()
await page.locator('.v-overlay-container').getByText('会话视图').click()
await expect(page.locator('table').first()).toBeVisible()
})
test('12 alerts filters', async ({ page }) => {
await login(page)
await nav(page, '/alerts')
await expect(page.getByText(/告警/).first()).toBeVisible({ timeout: 20000 })
})
test('13 audit pagination', async ({ page }) => {
await login(page)
await nav(page, '/audit')
await expect(page.getByText(/审计/).first()).toBeVisible({ timeout: 20000 })
})
test('14 settings sections', async ({ page }) => {
await login(page)
await nav(page, '/settings')
await expect(page.getByText(/系统|监控|Telegram|安全/).first()).toBeVisible({ timeout: 20000 })
})
test('15 theme toggle + logout', async ({ page }) => {
await login(page)
await nav(page, '/')
await page.getByText(USER, { exact: false }).first().click()
await page.getByText('切换主题').click()
await page.getByText(USER, { exact: false }).first().click()
await page.getByText('退出登录').click()
await expect(page).toHaveURL(/#\/login/, { timeout: 15000 })
})
+94
View File
@@ -0,0 +1,94 @@
/**
* Shared Playwright helpers for Nexus SPA E2E.
*/
import { expect } from '@playwright/test'
export const BASE = process.env.NEXUS_E2E_BASE || 'https://api.synaglobal.vip'
export const USER = process.env.NEXUS_E2E_USER || 'admin'
export const PASS = process.env.NEXUS_E2E_PASSWORD || ''
export const APP = `${BASE.replace(/\/$/, '')}/app/`
const SIDEBAR_LINKS = {
'/': '仪表盘',
'/servers': '服务器',
'/terminal': '终端',
'/files': '文件管理',
'/push': '推送',
'/scripts': '脚本库',
'/executions': '执行记录',
'/schedules': '调度',
'/retries': '重试队列',
'/commands': '命令日志',
'/alerts': '告警中心',
'/audit': '审计日志',
}
const E2E_ACCESS_TOKEN = process.env.NEXUS_E2E_ACCESS_TOKEN || ''
/** Mock refresh + profile so Pinia bootstraps with a pre-minted JWT (bypass login IP allowlist). */
export async function loginWithAccessToken(page, accessToken = E2E_ACCESS_TOKEN) {
if (!accessToken) {
throw new Error('Set NEXUS_E2E_ACCESS_TOKEN for token-based E2E login')
}
await page.route('**/api/auth/refresh', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ access_token: accessToken }),
})
})
await page.route('**/api/auth/me', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 1,
username: USER,
totp_enabled: false,
created_at: '2020-01-01T00:00:00Z',
}),
})
})
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
await expect(page.getByRole('button', { name: USER })).toBeVisible({ timeout: 15000 })
}
/** Restore session from worker refresh cookie (avoids parallel UI logins / lockout). */
export async function login(page) {
if (E2E_ACCESS_TOKEN) {
return loginWithAccessToken(page, E2E_ACCESS_TOKEN)
}
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
await expect(page.getByRole('button', { name: 'admin' })).toBeVisible({ timeout: 15000 })
}
/** Navigate via drawer clicks (reliable with Vue keep-alive; hash goto alone is flaky). */
export async function nav(page, hash) {
const path = hash.startsWith('/') ? hash : `/${hash}`
const routePath = path.split('?')[0]
const escaped = routePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
if (routePath === '/settings') {
await page.locator('.v-navigation-drawer').getByText('设置', { exact: true }).click()
} else {
const label = SIDEBAR_LINKS[routePath]
if (!label) throw new Error(`Unknown route for nav(): ${path}`)
await page.getByRole('link', { name: label, exact: true }).click()
}
await expect(page).toHaveURL(new RegExp(`#${escaped}(?:\\?|$)`), { timeout: 20000 })
if (path.includes('?')) {
await page.evaluate((p) => {
window.location.hash = `#${p}`
}, path)
await expect(page).toHaveURL(new RegExp(`#${escaped}\\?`), { timeout: 10000 })
}
await page.waitForLoadState('networkidle').catch(() => {})
}
/** ISO UTC bare string e.g. 2026-06-08T12:00:00Z — should not appear in display columns */
export const ISO_UTC_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/
+18
View File
@@ -0,0 +1,18 @@
/** E2E-ALT-002 — alert table times not raw ISO UTC (MCP-ALT-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { ISO_UTC_RE, login, nav } from '../helpers.mjs'
test('E2E-ALT-002 alert times not bare ISO UTC', async ({ page }) => {
await login(page)
await nav(page, '/alerts')
await expect(page.getByText(/告警/).first()).toBeVisible({ timeout: 20000 })
const cells = page.locator('table tbody td')
const count = await cells.count()
for (let i = 0; i < Math.min(count, 20); i++) {
const text = await cells.nth(i).innerText()
if (/\d{4}-\d{2}-\d{2}/.test(text)) {
expect(text).not.toMatch(ISO_UTC_RE)
}
}
})
+18
View File
@@ -0,0 +1,18 @@
/** E2E-AUD-002 — audit table Beijing time display (MCP-AUD-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { ISO_UTC_RE, login, nav } from '../helpers.mjs'
test('E2E-AUD-002 audit times not bare ISO UTC', async ({ page }) => {
await login(page)
await nav(page, '/audit')
await expect(page.getByText(/审计/).first()).toBeVisible({ timeout: 20000 })
const cells = page.locator('table tbody td')
const count = await cells.count()
for (let i = 0; i < Math.min(count, 20); i++) {
const text = await cells.nth(i).innerText()
if (/\d{4}-\d{2}-\d{2}/.test(text)) {
expect(text).not.toMatch(ISO_UTC_RE)
}
}
})
+14
View File
@@ -0,0 +1,14 @@
/** E2E-AUTH-001 — wrong password shows error, stays on login */
import { test, expect } from '@playwright/test'
import { APP, USER } from '../helpers.mjs'
test.use({ storageState: { cookies: [], origins: [] } })
test('E2E-AUTH-001 wrong password shows error', async ({ page }) => {
await page.goto(`${APP}#/login`)
await page.getByLabel('用户名').fill(USER)
await page.getByLabel('密码', { exact: true }).fill('definitely-wrong-password-e2e')
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL(/#\/login/, { timeout: 15000 })
await expect(page.getByText(/错误|失败|无效|不正确/).first()).toBeVisible({ timeout: 10000 })
})
+24
View File
@@ -0,0 +1,24 @@
/** E2E-CMD-002 — push log view time columns (MCP-CMD-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { ISO_UTC_RE, login, nav } from '../helpers.mjs'
test('E2E-CMD-002 push log times not bare ISO UTC', async ({ page }) => {
await login(page)
await nav(page, '/commands')
await expect(page.locator('main .v-card-title').filter({ hasText: '命令日志' })).toBeVisible({
timeout: 20000,
})
await page.locator('.v-card-title .v-select').first().click()
await page.locator('.v-overlay-container').getByText('推送日志').click()
await expect(page.locator('table').first()).toBeVisible()
const cells = page.locator('table tbody td')
const count = await cells.count()
for (let i = 0; i < Math.min(count, 20); i++) {
const text = await cells.nth(i).innerText()
if (/\d{4}-\d{2}-\d{2}/.test(text)) {
expect(text).not.toMatch(ISO_UTC_RE)
}
}
})
+15
View File
@@ -0,0 +1,15 @@
/** E2E-CRED-001 — credentials dialog required fields (MCP-CRED-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-CRED-001 password preset form validation', async ({ page }) => {
await login(page)
await nav(page, '/servers')
await page.getByRole('button', { name: '凭据' }).click()
const dialog = page.getByRole('dialog')
await expect(dialog.getByText('凭据管理')).toBeVisible({ timeout: 20000 })
await dialog.getByRole('button', { name: '添加', exact: true }).click()
await expect(dialog.getByText('新建密码预设')).toBeVisible()
await dialog.getByRole('button', { name: '保存' }).click()
await expect(page.getByText('请填写预设名称')).toBeVisible({ timeout: 5000 })
})
@@ -0,0 +1,10 @@
/** E2E-DASH-001 extended — dashboard stat cards visible (MCP-DASH-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-DASH-001 dashboard cards visible', async ({ page }) => {
await login(page)
await nav(page, '/')
await expect(page.locator('.v-card').first()).toBeVisible({ timeout: 20000 })
expect(await page.locator('.v-card').count()).toBeGreaterThan(0)
})
@@ -0,0 +1,22 @@
/** E2E-EXEC-002 — execution row expand when data exists (MCP-EXEC-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-EXEC-002 execution detail expand', async ({ page }) => {
await login(page)
await nav(page, '/executions')
await expect(page.getByRole('combobox', { name: '状态筛选' })).toBeVisible({ timeout: 20000 })
const rows = page.locator('tbody tr')
const count = await rows.count()
if (count === 0) {
await expect(page.getByText('暂无数据')).toBeVisible()
return
}
const expand = rows.first().locator('button').first()
if (await expand.isVisible()) {
await expand.click()
await expect(page.locator('.v-expansion-panel-text, pre, code').first()).toBeVisible({
timeout: 10000,
})
}
})
+11
View File
@@ -0,0 +1,11 @@
/** E2E-EXEC-001 — executions board loads with filters */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-EXEC-001 executions page table and filters', async ({ page }) => {
await login(page)
await nav(page, '/executions')
await expect(page.getByRole('combobox', { name: '类型' })).toBeVisible({ timeout: 20000 })
await expect(page.getByRole('combobox', { name: '状态筛选' })).toBeVisible()
await expect(page.locator('table').first()).toBeVisible()
})
+12
View File
@@ -0,0 +1,12 @@
/** E2E-FILE-001 — files page loads toolbar (MCP-FILE-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-FILE-001 files page server picker visible', async ({ page }) => {
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await login(page)
await nav(page, '/files')
await expect(page.getByLabel('选择服务器')).toBeVisible({ timeout: 20000 })
expect(errors).toEqual([])
})
+10
View File
@@ -0,0 +1,10 @@
/** E2E-INST-001 — install.html reachable (MCP-INST-001 step UI waived per product) */
import { test, expect } from '@playwright/test'
const BASE = process.env.NEXUS_E2E_BASE || 'http://127.0.0.1:8600'
test('E2E-INST-001 install.html reachable', async ({ page }) => {
const res = await page.goto(`${BASE.replace(/\/$/, '')}/app/install.html`)
expect(res?.status()).toBe(200)
await expect(page.getByText('Nexus 6.0 安装向导')).toBeVisible({ timeout: 15000 })
})
+12
View File
@@ -0,0 +1,12 @@
/** E2E-AUTH-002 / MCP-AUTH-001 — login page renders without submitting */
import { test, expect } from '@playwright/test'
import { APP } from '../helpers.mjs'
test.use({ storageState: { cookies: [], origins: [] } })
test('MCP-AUTH-001 login form visible', async ({ page }) => {
await page.goto(`${APP}#/login`)
await expect(page.getByLabel('用户名')).toBeVisible()
await expect(page.getByLabel('密码', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '登录' })).toBeVisible()
})
+79
View File
@@ -0,0 +1,79 @@
/** E2E-PUSH-002 — push submit ends submitting state quickly */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
const mockServer = {
id: 9001,
name: 'e2e-push-mock',
domain: '10.0.0.1',
target_path: '/var/www',
category: null,
is_online: true,
status: 'online',
}
test('E2E-PUSH-002 submitting clears after accepted response', async ({ page }) => {
await page.route('**/api/servers**', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [mockServer],
total: 1,
page: 1,
per_page: 200,
pages: 1,
}),
})
})
await page.route('**/api/sync/validate-source-path', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
valid: true,
path: '/tmp/nexus-e2e-src',
file_count: 1,
size_bytes: 100,
}),
})
})
await page.route('**/api/sync/files**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
accepted: true,
batch_id: 'aabbccddeeff',
total: 1,
completed: 0,
failed: 0,
cancelled: 0,
results: {},
}),
})
})
await login(page)
await nav(page, '/push')
await page.getByPlaceholder('/path/to/files').fill('/tmp/nexus-e2e-src')
await page.getByRole('button', { name: '验证路径' }).click()
await expect(page.getByText('e2e-push-mock')).toBeVisible({ timeout: 20000 })
await page.getByText('e2e-push-mock', { exact: true }).click()
const pushBtn = page.getByRole('button', { name: '推送', exact: true })
const postDone = page.waitForResponse(
(r) => r.request().method() === 'POST' && r.url().includes('/sync/files'),
{ timeout: 20000 },
)
await pushBtn.click()
const res = await postDone
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.accepted).toBe(true)
})
+11
View File
@@ -0,0 +1,11 @@
/** E2E-RET-001 — retries queue page (MCP-RET-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-RET-001 retries table without bulk retry actions', async ({ page }) => {
await login(page)
await nav(page, '/retries')
await expect(page.getByRole('combobox', { name: '状态筛选' })).toBeVisible({ timeout: 20000 })
const retryButtons = page.getByRole('button', { name: '重试', exact: true })
expect(await retryButtons.count()).toBe(0)
})
@@ -0,0 +1,12 @@
/** E2E-SCH-002 — schedule cycle Chinese labels (MCP-SCH-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-SCH-002 new schedule shows 开始执行周期', async ({ page }) => {
await login(page)
await nav(page, '/schedules')
await page.getByRole('button', { name: '新建调度' }).click()
await expect(page.getByText('开始执行周期').first()).toBeVisible()
await expect(page.getByText('新建调度').first()).toBeVisible()
await page.getByRole('button', { name: '取消' }).click()
})
@@ -0,0 +1,58 @@
/** E2E-SCH-003/004 — schedule operator timezone (Beijing wall clock) */
import { test, expect } from '../fixtures.mjs'
import { ISO_UTC_RE, login, nav } from '../helpers.mjs'
const mockSchedule = {
id: 99001,
name: 'e2e-beijing-0200',
schedule_type: 'push',
run_mode: 'cron',
cron_expr: '0 2 * * *',
fire_at: null,
source_path: '/tmp',
target_path: null,
server_ids: '[1]',
sync_mode: 'incremental',
enabled: true,
script_id: null,
script_content: null,
exec_timeout: 60,
long_task: false,
last_run_at: null,
/** UTC naive — Beijing next day 02:00 */
next_run: '2026-06-09 18:00:00',
created_at: '2026-06-09 10:00:00',
}
test('E2E-SCH-003 schedule dialog shows Beijing timezone hint', async ({ page }) => {
await login(page)
await nav(page, '/schedules')
await page.getByRole('button', { name: '新建调度' }).click()
await expect(page.getByText(/时间为北京时间(Asia\/Shanghai/).first()).toBeVisible()
await page.getByRole('button', { name: '取消' }).click()
})
test('E2E-SCH-004 next_run column shows Beijing 02:00 not bare ISO UTC', async ({ page }) => {
await page.route('**/api/schedules/**', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([mockSchedule]),
})
})
await login(page)
await nav(page, '/schedules')
await expect(page.getByText('e2e-beijing-0200')).toBeVisible()
const nextRunCell = page.locator('td').filter({ hasText: /2026-06-10 02:00:00/ })
await expect(nextRunCell.first()).toBeVisible()
const tableText = await page.locator('.v-data-table').innerText()
expect(tableText).not.toMatch(ISO_UTC_RE)
expect(tableText).not.toMatch(/10:00:00/)
})
+12
View File
@@ -0,0 +1,12 @@
/** E2E-SCR-001 — scripts library loads (MCP-SCR-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-SCR-001 scripts page toolbar without JS errors', async ({ page }) => {
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await login(page)
await nav(page, '/scripts')
await expect(page.getByRole('button', { name: '新建脚本' })).toBeVisible({ timeout: 20000 })
expect(errors).toEqual([])
})
@@ -0,0 +1,9 @@
/** E2E-SRV-003 — servers category filter chips (MCP-SRV-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-SRV-003 category filter visible', async ({ page }) => {
await login(page)
await nav(page, '/servers')
await expect(page.getByText('分类筛选')).toBeVisible({ timeout: 20000 })
})
+9
View File
@@ -0,0 +1,9 @@
/** E2E-SRV-002 — servers page has no CSV export button */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-SRV-002 no CSV export button', async ({ page }) => {
await login(page)
await nav(page, '/servers')
await expect(page.getByRole('button', { name: /导出|CSV/i })).toHaveCount(0)
})
+37
View File
@@ -0,0 +1,37 @@
/** E2E-SET-001 / MCP-SET-001 — settings save control + success toast */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-SET-001 settings page save control', async ({ page }) => {
await login(page)
await nav(page, '/settings')
await expect(page.getByText('系统设置')).toBeVisible({ timeout: 20000 })
await expect(page.getByRole('button', { name: '保存设置' })).toBeVisible()
})
test('E2E-SET-002 settings save success toast', async ({ page }) => {
await login(page)
await page.route(/\/api\/settings\//, async (route) => {
if (route.request().method() === 'PUT') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ key: 'system_name', value: 'Nexus' }),
})
return
}
await route.continue()
})
await nav(page, '/settings')
await expect(page.getByLabel('系统名称')).toBeVisible({ timeout: 20000 })
const title = page.getByLabel('系统标题')
if (!(await title.inputValue()).trim()) {
await title.fill('Nexus Ops E2E')
}
const saveReq = page.waitForRequest(
(req) => req.method() === 'PUT' && req.url().includes('/api/settings/'),
)
await page.getByRole('button', { name: '保存设置' }).click()
await saveReq
await expect(page.getByText('设置已保存')).toBeVisible({ timeout: 10000 })
})
+9
View File
@@ -0,0 +1,9 @@
/** E2E-TERM-001 — terminal idle state without live SSH (MCP-TERM-001沉淀) */
import { test, expect } from '../fixtures.mjs'
import { login, nav } from '../helpers.mjs'
test('E2E-TERM-001 terminal page idle prompt', async ({ page }) => {
await login(page)
await nav(page, '/terminal')
await expect(page.getByText('选择一台服务器开始会话')).toBeVisible({ timeout: 20000 })
})
@@ -0,0 +1,25 @@
/** Production smoke — watch slot TTL chips + metric bars */
import { test, expect } from '../fixtures.mjs'
import { loginWithAccessToken, nav } from '../helpers.mjs'
test('watch slot TTL chips and metric labels', async ({ page }) => {
const token = process.env.NEXUS_E2E_ACCESS_TOKEN || ''
test.skip(!token, 'NEXUS_E2E_ACCESS_TOKEN required')
await loginWithAccessToken(page, token)
await nav(page, '/servers')
await expect(page.getByText('默认 30 分')).toBeVisible({ timeout: 20000 })
await expect(page.getByText('30分').first()).toBeVisible()
await expect(page.getByText('1时').first()).toBeVisible()
const card = page.locator('.watch-slot-card--filled').first()
await expect(card).toBeVisible()
await expect(card.getByText('时长')).toBeVisible()
const switchEl = card.locator('.watch-slot-switch input[type="checkbox"]')
if (!(await switchEl.isChecked())) {
await card.locator('.watch-slot-switch').click()
await page.waitForTimeout(1500)
}
await expect(card.getByText('内存').first()).toBeVisible({ timeout: 15000 })
await expect(card.getByText('硬盘').first()).toBeVisible()
await expect(card.getByText('负载').first()).toBeVisible()
await expect(card.locator('.watch-metric-bar').first()).toBeVisible()
})
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+3
View File
@@ -0,0 +1,3 @@
import vuetify from 'eslint-config-vuetify'
export default vuetify()
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2932
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "frontend",
"private": true,
"type": "module",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"test:e2e": "playwright test e2e"
},
"dependencies": {
"@fontsource/roboto": "^5.2.10",
"@mdi/font": "7.4.47",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"echarts": "^6.1.0",
"monaco-editor": "^0.55.1",
"pinia": "^3.0.4",
"vue": "^3.5.30",
"vue-echarts": "^8.0.1",
"vue-router": "^4.6.4",
"vuetify": "^4.0.2"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tsconfig/node22": "^22.0.5",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"npm-run-all2": "^8.0.4",
"playwright": "^1.60.0",
"sass-embedded": "^1.98.0",
"typescript": "~5.9.3",
"unplugin-fonts": "^1.4.0",
"vite": "^8.0.0",
"vite-plugin-vuetify": "^2.1.3",
"vue-tsc": "^3.2.5"
},
"overrides": {
"unplugin-fonts": {
"vite": "^8.0.0"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('@playwright/test').PlaywrightTestConfig} */
export default {
testDir: './e2e',
timeout: 60000,
retries: 0,
use: {
baseURL: process.env.NEXUS_E2E_BASE || 'https://api.synaglobal.vip',
headless: true,
ignoreHTTPSErrors: true,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,2 @@
名称,地址,SSH端口,用户名,认证方式,密码,密钥路径,私钥内容,分类,目标路径,备注
web-01,192.168.1.10,22,root,password,your-password,,,商城,/www/wwwroot,Web服务器
1 名称 地址 SSH端口 用户名 认证方式 密码 密钥路径 私钥内容 分类 目标路径 备注
2 web-01 192.168.1.10 22 root password your-password 商城 /www/wwwroot Web服务器
+507
View File
@@ -0,0 +1,507 @@
<template>
<v-app>
<!-- 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">
<v-icon icon="mdi-server-network" size="24" />
</v-avatar>
<v-app-bar-title>Nexus</v-app-bar-title>
<template #append>
<v-btn class="text-none me-1 px-3" height="48" slim>
<template #prepend>
<v-avatar color="surface-light" size="32" start>
<v-icon icon="mdi-account" size="18" />
</v-avatar>
</template>
<span class="hidden-sm-and-down">{{ auth.admin?.username || '管理员' }}</span>
<v-menu activator="parent">
<v-list density="compact" nav>
<v-list-item
prepend-icon="mdi-theme-light-dark"
title="切换主题"
link
@click="toggleTheme"
/>
<v-list-item
prepend-icon="mdi-cog-outline"
title="设置"
link
@click="router.push('/settings')"
/>
<v-list-item
prepend-icon="mdi-logout"
title="退出登录"
link
@click="doLogout"
/>
</v-list>
</v-menu>
<template #append>
<v-icon icon="mdi-chevron-down" />
</template>
</v-btn>
</template>
</v-app-bar>
<!-- Navigation Drawer (隐藏于登录页) -->
<v-navigation-drawer
v-if="auth.isLoggedIn"
v-model="drawer"
color="surface"
width="250"
class="nexus-nav-drawer"
:temporary="mobile"
>
<v-list nav slim>
<v-list-subheader>运维</v-list-subheader>
<v-list-item
v-for="item in opsItems"
:key="item.to"
:prepend-icon="item.icon"
:title="item.title"
:to="item.to"
:active="route.path === item.to"
active-class="text-primary"
link
nav
slim
@click="navigateTo(item.to)"
@mouseenter="scheduleRoutePrefetch(item.to)"
@mouseleave="cancelRoutePrefetch(item.to)"
/>
</v-list>
<v-divider class="mx-3 my-1" />
<v-list nav slim>
<v-list-subheader>系统</v-list-subheader>
<v-list-item
v-for="item in sysItems"
:key="item.to"
:prepend-icon="item.icon"
:title="item.title"
:to="item.to"
:active="route.path === item.to"
active-class="text-primary"
link
nav
slim
@click="navigateTo(item.to)"
@mouseenter="scheduleRoutePrefetch(item.to)"
@mouseleave="cancelRoutePrefetch(item.to)"
/>
</v-list>
<template #append>
<v-list-item
:active="route.path === '/settings'"
prepend-icon="mdi-cog-outline"
title="设置"
active-class="text-primary"
class="ma-2"
link
nav
slim
@click="router.push('/settings')"
@mouseenter="scheduleRoutePrefetch('/settings')"
@mouseleave="cancelRoutePrefetch('/settings')"
/>
</template>
</v-navigation-drawer>
<!-- 脚本执行全局进度 -->
<div
v-if="auth.isLoggedIn && runningItems.length"
class="nexus-script-exec-bar"
>
<v-sheet
v-for="item in runningItems"
:key="item.executionId"
class="nexus-script-exec-bar__item px-4 py-2"
:class="{ 'nexus-script-exec-bar__item--clickable': item.taskKind === 'server_batch' }"
color="surface"
border
@click="item.taskKind === 'server_batch' ? openRunningBatchJob(item) : undefined"
>
<div class="d-flex align-center flex-wrap ga-2 mb-1">
<span class="text-body-2 font-weight-medium text-truncate">{{ item.label }}</span>
<v-chip size="x-small" variant="tonal" color="primary" label>#{{ item.executionId }}</v-chip>
<v-spacer />
<span class="text-caption text-medium-emphasis">
已进行 {{ item.done }} 剩余 {{ item.remaining }}
<span class="ml-2">({{ item.progress }})</span>
</span>
</div>
<v-progress-linear
:model-value="item.total ? Math.round((item.done / item.total) * 100) : 0"
color="primary"
height="6"
rounded
striped
stream
/>
</v-sheet>
</div>
<!-- 脚本提交 / 执行结束顶栏下方居中 -->
<div
v-if="auth.isLoggedIn && (scriptSubmitToast.visible || completeAlerts.length)"
class="nexus-top-center-script-notices"
>
<Transition name="nexus-script-submit-toast-fade">
<v-alert
v-if="scriptSubmitToast.visible"
key="script-submit"
:color="scriptSubmitToast.color"
variant="tonal"
density="compact"
class="nexus-top-center-script-notices__item"
role="status"
@click="dismissScriptSubmitToast()"
>
{{ scriptSubmitToast.text }}
</v-alert>
</Transition>
<v-alert
v-for="alert in completeAlerts"
:key="alert.executionId"
:class="[
'nexus-top-center-script-notices__item',
{ 'nexus-script-exec-alert--fading': alert.fading },
]"
:type="completeAlertType(alert.status)"
variant="tonal"
closable
density="compact"
@click="dismissCompleteAlert(alert.executionId)"
@click:close="dismissCompleteAlert(alert.executionId)"
>
<div class="d-flex align-center flex-wrap ga-2 justify-center">
<span>
{{ alert.label }}执行结束
成功 {{ alert.success }} / 失败 {{ alert.failed }} / {{ alert.total }}
</span>
<v-btn
size="small"
variant="text"
color="primary"
@click.stop="goToTaskDetail(alert)"
>
查看详情
</v-btn>
</div>
</v-alert>
</div>
<!-- Main Content -->
<v-main :class="mainLayoutClass" :style="mainContentStyle">
<router-view v-slot="{ Component, route: activeRoute }">
<keep-alive :include="cachedPageNames" :max="12">
<component :is="Component" :key="activeRoute.name ?? activeRoute.path" />
</keep-alive>
</router-view>
</v-main>
<!-- Snackbar -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
location="top"
class="nexus-top-center-snackbar"
multi-line
>
{{ snackbar.text }}
</v-snackbar>
</v-app>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted, provide, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTheme, useDisplay } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
import { useWebSocket } from '@/composables/useWebSocket'
import {
useScriptExecutionQueue,
onScriptExecutionComplete,
type ScriptExecCompleteAlert,
} from '@/composables/useScriptExecutionQueue'
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
import { executionRecordPath } from '@/utils/executionRecordRoute'
import { useScriptCompleteSound } from '@/composables/useScriptCompleteSound'
import { usePushCompleteSound } from '@/composables/usePushCompleteSound'
import {
scriptSubmitToastState as scriptSubmitToast,
dismissScriptSubmitToast,
} from '@/composables/useScriptSubmitToast'
import { clearCachedQueries } from '@/composables/useCachedQuery'
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
import { api } from '@/api'
const cachedPageNames = [...CACHED_PAGE_NAMES]
const route = useRoute()
const router = useRouter()
const theme = useTheme()
const auth = useAuthStore()
const ws = useWebSocket()
const { runningItems, completeAlerts, dismissCompleteAlert } = useScriptExecutionQueue()
const { playCompleteSound, syncFromServer: syncScriptSoundFromServer } = useScriptCompleteSound()
const { syncFromServer: syncPushSoundFromServer } = usePushCompleteSound()
function completeAlertType(status: string): 'success' | 'warning' | 'error' | 'info' {
if (status === 'completed') return 'success'
if (status === 'failed') return 'error'
if (status === 'partial') return 'warning'
return 'info'
}
function goToTaskDetail(alert: ScriptExecCompleteAlert) {
dismissCompleteAlert(alert.executionId)
if (alert.taskKind === 'server_batch') {
void openServerBatchJobResult(alert.executionId, alert.label)
return
}
router.push(executionRecordPath(alert.executionId, 'script')).catch(() => {})
}
function openRunningBatchJob(item: { executionId: number; label: string; taskKind?: string }) {
if (item.taskKind !== 'server_batch') return
void openServerBatchJobResult(item.executionId, item.label)
}
onMounted(() => {
onScriptExecutionComplete(() => playCompleteSound())
})
// Auto-connect WebSocket when logged in
watch(() => auth.isLoggedIn, (loggedIn) => {
if (loggedIn) {
ws.connect()
syncThemeFromServer()
syncScriptSoundFromServer()
syncPushSoundFromServer()
}
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 { mobile } = useDisplay()
/** 终端页监听菜单开合,在动画结束后 refit xterm */
provide('nexusDrawer', drawer)
const mainLayoutClass = computed(() => ({
'nexus-terminal-main': route.path === '/terminal',
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
}))
const mainContentStyle = computed(() => {
if (!auth.isLoggedIn) return undefined
const execRows = runningItems.value.length
const execExtra = execRows ? execRows * 52 : 0
if (!execExtra) return undefined
return { '--nexus-exec-bar-offset': `${execExtra}px` }
})
const opsItems = [
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
{ to: '/servers', title: '服务器', icon: 'mdi-server' },
{ to: '/terminal', title: '终端', icon: 'mdi-console' },
{ to: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
{ to: '/server-transfer', title: '跨服务器传输', icon: 'mdi-server-network' },
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' },
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' },
{ to: '/executions', title: '执行记录', icon: 'mdi-clipboard-play-outline' },
{ to: '/schedules', title: '调度', icon: 'mdi-clock-outline' },
{ to: '/retries', title: '重试队列', icon: 'mdi-refresh' },
]
const sysItems = [
{ to: '/commands', title: '命令日志', icon: 'mdi-console' },
{ to: '/alerts', title: '告警中心', icon: 'mdi-bell-outline' },
{ to: '/watch-metrics', title: '监测历史', icon: 'mdi-chart-line' },
{ to: '/audit', title: '审计日志', icon: 'mdi-clipboard-text-outline' },
]
const snackbar = reactive({ show: false, text: '', color: 'success', timeout: 3000 })
function navigateTo(to: string) {
if (route.path !== to) {
router.push(to).catch(() => {})
}
if (mobile.value) {
drawer.value = false
}
}
// ── Theme ──
// See toggleTheme() above — persists to both localStorage and MySQL
function doLogout() {
clearCachedQueries()
auth.logout()
router.push('/login')
}
// Expose snackbar globally
window.$snackbar = (text: string, color = 'success', options?: { timeout?: number }) => {
snackbar.text = text
snackbar.color = color
snackbar.timeout = options?.timeout ?? 3000
snackbar.show = true
}
</script>
<style>
/*
* 终端页:占满视口剩余高度;保留 --v-layout-top 顶栏留白(禁止 padding-top:0,否则标签栏被 App Bar 遮挡)
* 宽度随 --v-layout-left 随侧栏开合自动缩进
*/
.v-application .v-main.nexus-terminal-main {
display: flex;
flex-direction: column;
box-sizing: border-box;
height: 100dvh;
max-height: 100dvh;
min-height: 0;
max-width: 100%;
overflow: hidden;
padding-top: calc(var(--v-layout-top, 64px) + var(--nexus-exec-bar-offset, 0px)) !important;
padding-bottom: 0 !important;
transition:
padding 0.2s cubic-bezier(0.4, 0, 0.2, 1),
height 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.v-application .v-main.nexus-terminal-main > .v-container {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
max-width: 100%;
width: 100%;
padding: 0;
}
/* 仪表盘等业务页:随侧栏 --v-layout-left 缩进,表格可横向滚动 */
.v-application .v-main.nexus-page-main {
box-sizing: border-box;
padding-top: calc(var(--v-layout-top, 64px) + var(--nexus-exec-bar-offset, 0px));
min-height: calc(100dvh - var(--v-layout-top, 64px) - var(--nexus-exec-bar-offset, 0px));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
transition: padding 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.v-application .v-main.nexus-page-main > * {
min-width: 0;
max-width: 100%;
}
/* 侧栏菜单项过多时可滚动,避免「调度/重试队列」在视口外无法点击 */
.nexus-nav-drawer :deep(.v-navigation-drawer__content) {
overflow-y: auto;
}
.nexus-script-exec-bar {
position: fixed;
top: var(--v-layout-top, 64px);
left: var(--v-layout-left, 0);
right: 0;
z-index: 1005;
display: flex;
flex-direction: column;
gap: 0;
pointer-events: none;
}
.nexus-script-exec-bar__item {
pointer-events: auto;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.nexus-script-exec-bar__item--clickable {
cursor: pointer;
}
.nexus-script-exec-bar__item--clickable:hover {
background: rgba(var(--v-theme-primary), 0.04);
}
.nexus-top-center-script-notices {
position: fixed;
top: calc(var(--v-layout-top, 64px) + 8px);
left: 50%;
transform: translateX(-50%);
z-index: 2501;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(520px, calc(100vw - 32px));
pointer-events: none;
}
.nexus-top-center-script-notices__item {
pointer-events: auto;
width: 100%;
cursor: pointer;
text-align: center;
}
.nexus-script-exec-alert--fading {
opacity: 0;
transition: opacity 0.35s ease;
}
.nexus-script-submit-toast-fade-enter-active,
.nexus-script-submit-toast-fade-leave-active {
transition: opacity 0.2s ease;
}
.nexus-script-submit-toast-fade-enter-from,
.nexus-script-submit-toast-fade-leave-to {
opacity: 0;
}
/* 全局 Snackbar:顶栏下方水平居中(校验/警告/保存成功等) */
.v-application .v-snackbar.nexus-top-center-snackbar {
z-index: 10000 !important;
left: 50% !important;
right: auto !important;
transform: translateX(-50%);
top: calc(var(--v-layout-top, 64px) + 8px) !important;
bottom: auto !important;
justify-content: center;
max-width: min(520px, calc(100vw - 32px));
}
.v-application .v-snackbar.nexus-top-center-snackbar .v-snackbar__wrapper {
min-width: min(280px, calc(100vw - 48px));
}
</style>
+200
View File
@@ -0,0 +1,200 @@
import { api } from '@/api'
export interface BtPanelServerRow {
id: number
name: string
domain: string
category: string | null
is_online: boolean
status: 'online' | 'offline' | 'unknown'
base_url: string | null
api_key_set: boolean
verify_ssl: boolean
configured: boolean
auto_bootstrap?: boolean
bootstrap_state?: string
bootstrap_last_attempt_at?: string | null
bootstrap_next_attempt_at?: string | null
bootstrap_attempts?: number
bootstrap_last_error?: string | null
}
export interface BtPanelConfig {
base_url: string | null
api_key_set: boolean
verify_ssl: boolean
configured: boolean
bt_installed: boolean | null
auto_bootstrap?: boolean
bootstrap_state?: string
bootstrap_last_attempt_at?: string | null
bootstrap_next_attempt_at?: string | null
bootstrap_attempts?: number
bootstrap_last_error?: string | null
bootstrap_last_actions?: string[] | null
ok?: boolean
error?: string
}
export interface BtPanelGlobalSettings {
bt_panel_source_ip: string
bt_panel_auto_bootstrap_enabled: boolean
resolved_center_ip: string | null
}
export interface BatchJobResponse {
job_id: number
status: string
label: string
}
export function listBtServers() {
return api<BtPanelServerRow[]>('/btpanel/servers')
}
export function getBtConfig(serverId: number, options?: { refreshBtInstalled?: boolean }) {
const qs = options?.refreshBtInstalled ? '?refresh_bt_installed=true' : ''
return api<BtPanelConfig>(`/btpanel/servers/${serverId}/config${qs}`)
}
export function updateBtConfig(serverId: number, body: {
base_url?: string
api_key?: string
verify_ssl?: boolean
auto_bootstrap?: boolean
}) {
return api<BtPanelConfig>(`/btpanel/servers/${serverId}/config`, {
method: 'PUT',
body: JSON.stringify(body),
})
}
export function getBtGlobalSettings() {
return api<BtPanelGlobalSettings>('/btpanel/settings')
}
export function updateBtGlobalSettings(body: {
bt_panel_source_ip?: string
bt_panel_auto_bootstrap_enabled?: boolean
}) {
return api<BtPanelGlobalSettings>('/btpanel/settings', {
method: 'PUT',
body: JSON.stringify(body),
})
}
export function bootstrapBtServer(serverId: number) {
return api<BtPanelConfig>(`/btpanel/servers/${serverId}/bootstrap`, { method: 'POST', body: '{}' })
}
export function batchBootstrapBtServers(
serverIds: number[],
options?: { allUnconfigured?: boolean },
) {
return api<BatchJobResponse>('/btpanel/servers/batch-bootstrap', {
method: 'POST',
body: JSON.stringify({
server_ids: serverIds,
all_unconfigured: options?.allUnconfigured ?? false,
}),
})
}
export function createBtLoginUrl(serverId: number) {
return api<{ url: string; method: string; bootstrapped?: boolean }>(
`/btpanel/servers/${serverId}/login-url`,
{ method: 'POST', body: '{}' },
)
}
export function btSystemTotal(serverId: number) {
return api<Record<string, unknown>>(`/btpanel/servers/${serverId}/system/total`)
}
export function btSystemDisk(serverId: number) {
return api<unknown[]>(`/btpanel/servers/${serverId}/system/disk`)
}
export function btSystemNetwork(serverId: number) {
return api<Record<string, unknown>>(`/btpanel/servers/${serverId}/system/network`)
}
export function btListSites(serverId: number) {
return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/sites`)
}
export function btSiteStart(serverId: number, site_id: number, name: string) {
return api(`/btpanel/servers/${serverId}/sites/start`, {
method: 'POST',
body: JSON.stringify({ site_id, name }),
})
}
export function btSiteStop(serverId: number, site_id: number, name: string) {
return api(`/btpanel/servers/${serverId}/sites/stop`, {
method: 'POST',
body: JSON.stringify({ site_id, name }),
})
}
export function btPhpVersions(serverId: number) {
return api<unknown[]>(`/btpanel/servers/${serverId}/sites/php-versions`)
}
export function btCreateSite(serverId: number, body: Record<string, unknown>) {
return api(`/btpanel/servers/${serverId}/sites`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function btListDomains(serverId: number, siteId: number) {
return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/sites/${siteId}/domains`)
}
export function btAddDomain(serverId: number, body: Record<string, unknown>) {
return api(`/btpanel/servers/${serverId}/domains`, { method: 'POST', body: JSON.stringify(body) })
}
export function btDeleteDomain(serverId: number, body: Record<string, unknown>) {
return api(`/btpanel/servers/${serverId}/domains/delete`, { method: 'POST', body: JSON.stringify(body) })
}
export function btSslSites(serverId: number) {
return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/ssl/sites`)
}
export function btSetSsl(serverId: number, body: { siteName: string; key: string; csr: string }) {
return api(`/btpanel/servers/${serverId}/ssl/set`, { method: 'POST', body: JSON.stringify(body) })
}
export function btApplySsl(serverId: number, body: { domains: string; id: number; auth_type?: string }) {
return api(`/btpanel/servers/${serverId}/ssl/apply`, { method: 'POST', body: JSON.stringify(body) })
}
export function btListDatabases(serverId: number) {
return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/databases`)
}
export function btCreateDatabase(serverId: number, body: Record<string, unknown>) {
return api(`/btpanel/servers/${serverId}/databases`, { method: 'POST', body: JSON.stringify(body) })
}
export function btDbPassword(serverId: number, body: { id: number; name: string; password: string }) {
return api(`/btpanel/servers/${serverId}/databases/password`, { method: 'POST', body: JSON.stringify(body) })
}
export function btDbBackup(serverId: number, id: number) {
return api(`/btpanel/servers/${serverId}/databases/backup`, { method: 'POST', body: JSON.stringify({ id }) })
}
export function btCrontab(serverId: number) {
return api<unknown[] | { data?: unknown[] }>(`/btpanel/servers/${serverId}/crontab`)
}
export function btServiceAdmin(serverId: number, name: string, type: string) {
return api(`/btpanel/servers/${serverId}/services`, {
method: 'POST',
body: JSON.stringify({ name, type }),
})
}
+325
View File
@@ -0,0 +1,325 @@
/**
* api/index.ts — HTTP client with JWT + HttpOnly refresh cookie.
*/
import { useAuthStore } from '@/stores/auth'
import { formatValidationDetail } from '@/utils/validationErrorFormat'
const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
const AUTH_SKIP_REFRESH = new Set(['/auth/login', '/auth/refresh', '/auth/logout'])
function normalizeDetail(detail: unknown, fallback: string): string {
return formatValidationDetail(detail, fallback)
}
let refreshInFlight: Promise<boolean> | null = null
async function tryRefreshSession(): Promise<boolean> {
if (refreshInFlight) return refreshInFlight
refreshInFlight = (async () => {
try {
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
if (!res.ok) return false
const data = await res.json()
if (data?.access_token) {
useAuthStore().setAccessToken(data.access_token)
return true
}
return false
} catch {
return false
} finally {
refreshInFlight = null
}
})()
return refreshInFlight
}
function buildApiUrl(path: string, params?: Record<string, unknown>): string {
let url = `${BASE_URL}${path}`
if (params) {
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null) sp.set(k, String(v))
}
const qs = sp.toString()
if (qs) url += `?${qs}`
}
return url
}
function buildAuthHeaders(init: RequestInit): Record<string, string> {
const auth = useAuthStore()
const headers: Record<string, string> = {
...(init.headers as Record<string, string> || {}),
}
if (!(init.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
}
if (auth.token) {
headers['Authorization'] = `Bearer ${auth.token}`
}
return headers
}
/** Authenticated fetch with JWT refresh retry; returns raw Response. */
export async function fetchAuthed(
path: string,
opts: RequestInit & { params?: Record<string, unknown>; _retry?: boolean } = {},
): Promise<Response> {
const { params, _retry, ...init } = opts
const url = buildApiUrl(path, params)
const auth = useAuthStore()
// 主动续期:距过期 < 5min 且非 auth 路径,先刷新再发请求(消除 401 重试双请求)
if (!_retry && !AUTH_SKIP_REFRESH.has(path) && auth.token && auth.isTokenExpiringSoon(300)) {
await tryRefreshSession()
}
const res = await fetch(url, {
...init,
headers: buildAuthHeaders(init),
credentials: 'include',
})
if (res.status === 401) {
if (!AUTH_SKIP_REFRESH.has(path) && !_retry) {
const refreshed = await tryRefreshSession()
if (refreshed) {
return fetchAuthed(path, { ...opts, _retry: true })
}
auth.forceLogout()
throw new ApiError(401, '登录已过期,请重新登录')
}
const errText = await res.text()
let errBody: { detail?: unknown } = {}
try { errBody = JSON.parse(errText) } catch { errBody = { detail: errText } }
const msg = normalizeDetail(
errBody.detail,
AUTH_SKIP_REFRESH.has(path) ? '认证失败' : '登录已过期,请重新登录',
)
throw new ApiError(401, msg)
}
return res
}
export type FetchJsonResult<T> = {
status: number
data?: T
etag: string | null
}
/** Authenticated fetch with JSON body; preserves 304 and ETag (not used by `api()`). */
export async function fetchJson<T = unknown>(
path: string,
opts: RequestInit & { params?: Record<string, unknown> } = {},
): Promise<FetchJsonResult<T>> {
const res = await fetchAuthed(path, opts)
const etag = res.headers.get('ETag')
if (res.status === 202) {
const text = await res.text()
let data: unknown
try { data = JSON.parse(text) } catch { data = { message: text } }
const msg = (data as { detail?: string; message?: string })?.detail
|| (data as { message?: string })?.message
|| '请输入 TOTP 验证码'
throw new TotpRequiredError(msg)
}
if (res.status === 429) {
throw new ApiError(429, '登录尝试过多,账户已锁定 15 分钟')
}
if (res.status === 304) {
return { status: 304, etag }
}
if (res.status === 204) {
return { status: 204, etag, data: undefined as T }
}
const text = await res.text()
let data: unknown
try {
data = JSON.parse(text)
} catch {
data = text
}
if (!res.ok) {
const body = data as { detail?: unknown; message?: string }
const msg = normalizeDetail(body?.detail ?? body?.message, res.statusText)
const err = new ApiError(res.status, msg)
err.detail = body?.detail
throw err
}
return { status: res.status, data: data as T, etag }
}
async function throwApiErrorFromResponse(res: Response): Promise<never> {
const text = await res.text()
let data: unknown
try {
data = JSON.parse(text)
} catch {
data = text
}
const body = data as { detail?: unknown; message?: string }
const msg = normalizeDetail(body?.detail ?? body?.message, res.statusText)
const err = new ApiError(res.status, msg)
err.detail = body?.detail
throw err
}
function filenameFromContentDisposition(header: string | null): string | null {
if (!header) return null
const star = header.match(/filename\*=UTF-8''([^;]+)/i)
if (star?.[1]) {
try {
return decodeURIComponent(star[1])
} catch {
return star[1]
}
}
const plain = header.match(/filename="?([^";]+)"?/i)
return plain?.[1] ?? null
}
/** GET binary download (e.g. exported files). */
export async function downloadGet(path: string): Promise<{ blob: Blob; filename: string }> {
const res = await fetchAuthed(path, { method: 'GET' })
if (res.status === 202) {
throw new TotpRequiredError('请输入 TOTP 验证码')
}
if (res.status === 429) {
throw new ApiError(429, '登录尝试过多,账户已锁定 15 分钟')
}
if (!res.ok) {
await throwApiErrorFromResponse(res)
}
const blob = await res.blob()
const filename = filenameFromContentDisposition(res.headers.get('Content-Disposition')) || 'download'
return { blob, filename }
}
/** POST binary download (e.g. /sync/download StreamingResponse). */
export async function downloadPost(
path: string,
body?: unknown,
): Promise<{ blob: Blob; filename: string }> {
const res = await fetchAuthed(path, {
method: 'POST',
body: JSON.stringify(body ?? {}),
})
if (res.status === 202) {
throw new TotpRequiredError('请输入 TOTP 验证码')
}
if (res.status === 429) {
throw new ApiError(429, '登录尝试过多,账户已锁定 15 分钟')
}
if (!res.ok) {
await throwApiErrorFromResponse(res)
}
const blob = await res.blob()
const filename = filenameFromContentDisposition(res.headers.get('Content-Disposition')) || 'download'
return { blob, filename }
}
/** Raw fetch wrapper with JSON + JWT + refresh retry */
export async function api<T = any>(
path: string,
opts: RequestInit & { params?: Record<string, any>; _retry?: boolean } = {},
): Promise<T> {
const res = await fetchAuthed(path, opts)
if (res.status === 202) {
const text = await res.text()
let data: unknown
try { data = JSON.parse(text) } catch { data = { message: text } }
const msg = (data as { detail?: string; message?: string })?.detail
|| (data as { message?: string })?.message
|| '请输入 TOTP 验证码'
throw new TotpRequiredError(msg)
}
if (res.status === 429) {
throw new ApiError(429, '登录尝试过多,账户已锁定 15 分钟')
}
if (res.status === 204) return undefined as T
const text = await res.text()
let data: unknown
try {
data = JSON.parse(text)
} catch {
data = text
}
if (!res.ok) {
const body = data as { detail?: unknown; message?: string }
const msg = normalizeDetail(body?.detail ?? body?.message, res.statusText)
const err = new ApiError(res.status, msg)
err.detail = body?.detail
throw err
}
return data as T
}
export const http = {
get: <T = any>(path: string, params?: Record<string, any>) =>
api<T>(path, { method: 'GET', params }),
getList: async <T = any>(path: string, params?: Record<string, any>) => {
const data = await api<unknown>(path, { method: 'GET', params })
if (Array.isArray(data)) {
return { items: data as T[], total: data.length }
}
if (data && typeof data === 'object') {
const obj = data as { items?: T[]; total?: number }
if (Array.isArray(obj.items)) {
return { items: obj.items, total: obj.total ?? obj.items.length }
}
}
return { items: [] as T[], total: 0 }
},
post: <T = any>(path: string, body?: unknown) =>
api<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T = any>(path: string, body?: unknown) =>
api<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: <T = any>(path: string, body?: unknown) =>
api<T>(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: <T = any>(path: string) =>
api<T>(path, { method: 'DELETE' }),
upload: <T = any>(path: string, formData: FormData) =>
api<T>(path, { method: 'POST', body: formData }),
download: (path: string, body?: unknown) => downloadPost(path, body),
downloadGet: (path: string) => downloadGet(path),
}
export class ApiError extends Error {
detail?: unknown
constructor(public status: number, message: string) {
super(message)
this.name = 'ApiError'
}
}
export class TotpRequiredError extends Error {
constructor(message: string) {
super(message)
this.name = 'TotpRequiredError'
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M261.126 140.65L164.624 307.732L256.001 466L377.028 256.5L498.001 47H315.192L261.126 140.65Z" fill="#1697F6"/>
<path d="M135.027 256.5L141.365 267.518L231.64 111.178L268.731 47H256H14L135.027 256.5Z" fill="#AEDDFF"/>
<path d="M315.191 47C360.935 197.446 256 466 256 466L164.624 307.732L315.191 47Z" fill="#1867C0"/>
<path d="M268.731 47C76.0026 47 141.366 267.518 141.366 267.518L268.731 47Z" fill="#7BC6FF"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

@@ -0,0 +1,316 @@
<template>
<v-card
border
rounded="lg"
class="file-dir-tree d-flex flex-column"
:class="{ 'file-dir-tree--embedded': embedded }"
>
<v-card-title v-if="!embedded" class="text-subtitle-2 py-2 px-3 d-flex align-center">
<v-icon size="18" class="mr-1">mdi-file-tree</v-icon>
目录
<v-spacer />
<v-btn icon="mdi-refresh" size="x-small" variant="text" :disabled="!serverId" @click="reloadRoot" />
</v-card-title>
<div v-else class="file-dir-tree-embedded-bar d-flex align-center px-2 py-1">
<v-icon size="16" class="mr-1 text-medium-emphasis">mdi-file-tree</v-icon>
<span class="text-caption text-medium-emphasis">目录</span>
<v-spacer />
<v-btn icon="mdi-refresh" size="x-small" variant="text" :disabled="!serverId" @click="reloadRoot" />
</div>
<v-divider />
<v-card-text class="file-dir-tree-body pa-0 flex-grow-1">
<div v-if="!serverId" class="text-caption text-medium-emphasis pa-3">请先选择服务器</div>
<v-progress-linear v-else-if="rootLoading" indeterminate />
<div
v-else-if="visibleNodes.length === 0"
class="text-caption text-medium-emphasis pa-3"
>
暂无子目录可点刷新
</div>
<v-list v-else density="compact" nav class="py-0">
<div
v-for="node in visibleNodes"
:key="node.path"
class="file-dir-tree-row d-flex align-center"
:style="{ paddingLeft: `${8 + node.depth * 14}px` }"
>
<button
type="button"
class="file-dir-tree-toggle"
@click.stop="toggleNode(node)"
>
<v-progress-circular v-if="loadingPath === node.path" indeterminate size="14" width="2" />
<v-icon v-else size="16">
{{ expandedPaths.has(node.path) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
</button>
<button
type="button"
class="file-dir-tree-label text-body-2 text-truncate"
:class="{ 'file-dir-tree-label--active': normalizeRemotePath(currentPath) === node.path }"
@click="emit('navigate', node.path)"
@contextmenu.prevent="emit('paste-target', node.path)"
>
<v-icon size="14" class="mr-1">mdi-folder-outline</v-icon>
{{ node.name }}
</button>
</div>
</v-list>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { parseBrowseResponse } from '@/utils/fileBrowse'
import { DEFAULT_FILES_ROOT, joinRemotePath, normalizeRemotePath } from '@/utils/remotePath'
import type { FileEntry } from '@/types/api'
export interface DirTreeNode {
path: string
name: string
depth: number
hasChildren: boolean
loaded: boolean
}
const props = defineProps<{
serverId: number | null
currentPath: string
/** 树展示的根目录(默认宝塔站点根) */
rootPath?: string
/** 嵌入浮动编辑器侧栏:紧凑样式、填满高度 */
embedded?: boolean
}>()
const snackbar = useSnackbar()
const effectiveRoot = computed(() =>
normalizeRemotePath(props.rootPath || DEFAULT_FILES_ROOT),
)
const emit = defineEmits<{
navigate: [path: string]
'paste-target': [path: string]
}>()
const rootLoading = ref(false)
const loadingPath = ref<string | null>(null)
const expandedPaths = ref<Set<string>>(new Set())
const childrenByParent = ref<Map<string, DirTreeNode[]>>(new Map())
function replaceExpanded(mutator: (next: Set<string>) => void) {
const next = new Set(expandedPaths.value)
mutator(next)
expandedPaths.value = next
}
function replaceChildren(mutator: (next: Map<string, DirTreeNode[]>) => void) {
const next = new Map(childrenByParent.value)
mutator(next)
childrenByParent.value = next
}
function resetExpandedToRoot() {
expandedPaths.value = new Set([effectiveRoot.value])
}
const visibleNodes = computed(() => {
const out: DirTreeNode[] = []
const walk = (parentPath: string, depth: number) => {
const kids = childrenByParent.value.get(parentPath) ?? []
for (const node of kids) {
out.push({ ...node, depth })
if (expandedPaths.value.has(node.path)) {
walk(node.path, depth + 1)
}
}
}
walk(effectiveRoot.value, 0)
return out
})
async function fetchDirectories(path: string): Promise<DirTreeNode[]> {
if (!props.serverId) return []
const res = await http.post('/sync/browse', {
server_id: props.serverId,
path: normalizeRemotePath(path),
})
const { items } = parseBrowseResponse(res)
return items
.filter((e: FileEntry) => e.type === 'directory')
.sort((a, b) => a.name.localeCompare(b.name))
.map((e) => ({
path: joinRemotePath(path, e.name),
name: e.name,
depth: 0,
hasChildren: true,
loaded: false,
}))
}
async function loadChildren(parentPath: string) {
if (!props.serverId) return
loadingPath.value = parentPath
try {
const children = await fetchDirectories(parentPath)
replaceChildren((map) => {
map.set(parentPath, children)
for (const c of children) {
if (!map.has(c.path)) map.set(c.path, [])
}
})
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '加载目录失败'
snackbar(msg, 'error')
} finally {
loadingPath.value = null
}
}
async function reloadRoot() {
if (!props.serverId) return
rootLoading.value = true
childrenByParent.value = new Map()
resetExpandedToRoot()
const root = effectiveRoot.value
try {
await loadChildren(root)
replaceChildren((map) => {
for (const n of map.get(root) ?? []) {
if (!map.has(n.path)) map.set(n.path, [])
}
})
} finally {
rootLoading.value = false
}
}
async function toggleNode(node: DirTreeNode) {
if (expandedPaths.value.has(node.path)) {
replaceExpanded((s) => {
s.delete(node.path)
})
return
}
if (!childrenByParent.value.has(node.path)) {
await loadChildren(node.path)
}
replaceExpanded((s) => {
s.add(node.path)
})
}
function expandAncestors(path: string) {
const norm = normalizeRemotePath(path)
const root = effectiveRoot.value
if (norm === root) return
replaceExpanded((s) => {
s.add(root)
if (!norm.startsWith(root)) return
const rel = norm.slice(root.length).replace(/^\//, '')
if (!rel) return
let acc = root
for (const part of rel.split('/').filter(Boolean)) {
acc = joinRemotePath(acc, part)
s.add(acc)
}
})
}
watch(
() => [props.serverId, effectiveRoot.value] as const,
([id]) => {
if (id) void reloadRoot()
else {
childrenByParent.value = new Map()
expandedPaths.value = new Set()
}
},
{ immediate: true },
)
async function ensurePathVisible(targetPath: string) {
if (!props.serverId) return
const norm = normalizeRemotePath(targetPath)
const root = effectiveRoot.value
expandAncestors(norm)
if (!childrenByParent.value.has(root)) {
await loadChildren(root)
}
if (norm === root) return
if (!norm.startsWith(root)) return
const rel = norm.slice(root.length).replace(/^\//, '')
const parts = rel.split('/').filter(Boolean)
let acc = root
for (const part of parts) {
if (!childrenByParent.value.has(acc)) await loadChildren(acc)
acc = joinRemotePath(acc, part)
}
}
watch(
() => props.currentPath,
(path) => {
if (path) void ensurePathVisible(path)
},
{ immediate: true },
)
</script>
<style scoped>
.file-dir-tree {
min-height: 320px;
max-height: min(56vh, 560px);
}
.file-dir-tree-body {
overflow: auto;
}
.file-dir-tree-toggle {
width: 22px;
height: 22px;
border: none;
background: transparent;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.file-dir-tree-label {
flex: 1;
min-width: 0;
text-align: left;
border: none;
background: transparent;
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
}
.file-dir-tree-label:hover {
background: rgba(var(--v-theme-on-surface), 0.06);
}
.file-dir-tree-label--active {
color: rgb(var(--v-theme-primary));
font-weight: 600;
background: rgba(var(--v-theme-primary), 0.1);
}
.file-dir-tree--embedded {
min-height: 0;
max-height: none;
height: 100%;
border: none;
border-radius: 0;
}
.file-dir-tree--embedded .file-dir-tree-body {
flex: 1;
min-height: 120px;
overflow-y: auto;
}
.file-dir-tree-embedded-bar {
flex-shrink: 0;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,457 @@
<template>
<v-dialog :model-value="modelValue" max-width="520" @update:model-value="emit('update:modelValue', $event)">
<v-card border>
<v-card-title>修改权限</v-card-title>
<v-card-text>
<p v-if="fileName" class="text-body-2 mb-3 text-medium-emphasis">
{{ fileName }}
</p>
<p v-if="currentSummary" class="text-caption mb-4">
当前{{ currentSummary }}
</p>
<div class="d-flex flex-wrap ga-2 mb-4">
<v-chip
v-for="preset in visiblePresets"
:key="preset.mode + preset.label"
size="small"
variant="tonal"
color="primary"
@click="applyPreset(preset.mode)"
>
{{ preset.label }}
</v-chip>
</div>
<v-text-field
v-model="mode"
label="八进制权限"
variant="outlined"
density="compact"
placeholder="644 / 755"
:rules="[required('权限'), octalRule]"
class="mb-2"
@keydown.enter="submit"
/>
<v-text-field
v-model="owner"
label="属主(可选)"
variant="outlined"
density="compact"
placeholder="www"
:rules="[ownerRule]"
class="mb-2"
/>
<v-text-field
v-model="group"
label="属组(可选)"
variant="outlined"
density="compact"
placeholder="www"
:rules="[groupRule]"
class="mb-2"
/>
<v-checkbox
v-if="isDirectory"
v-model="recursive"
label="同时修改子项(递归 chmod/chown"
density="compact"
hide-details
color="warning"
class="mb-2"
/>
<v-divider class="my-4" />
<div class="d-flex align-center flex-wrap ga-2 mb-2">
<span class="text-caption text-medium-emphasis">提权能力</span>
<v-btn
size="small"
variant="tonal"
:loading="capabilityLoading"
:disabled="!serverId"
@click="runCapabilityCheck"
>
检测提权能力
</v-btn>
</div>
<v-alert
v-if="capability"
:type="capabilityAlertType"
variant="tonal"
density="compact"
class="mb-2"
>
<div class="text-body-2">{{ capability.message }}</div>
<div v-if="capability.ssh_user" class="text-caption mt-1">
SSH 用户{{ capability.ssh_user }}
· 策略{{ elevationLabel(capability.files_elevation) }}
</div>
</v-alert>
<p class="text-caption text-medium-emphasis mb-0">
root 用户写/chmod 失败时将按服务器策略尝试 <code>sudo -n</code>
{{ filesSudoersDocHint() }}
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="emit('update:modelValue', false)">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="loading" @click="submit">应用</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { FileEntry, FilesCapabilityResult, FilesElevationMode } from '@/types/api'
import { http } from '@/api'
import { CHMOD_PRESETS, filesSudoersDocHint, permissionTooltip } from '@/utils/fileMode'
const props = defineProps<{
modelValue: boolean
file: FileEntry | null
serverId?: number | null
loading?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
apply: [payload: { mode: string; owner?: string; group?: string; recursive?: boolean }]
}>()
const mode = ref('')
const owner = ref('')
const group = ref('')
const initialMode = ref('')
const initialOwner = ref('')
const initialGroup = ref('')
const capability = ref<FilesCapabilityResult | null>(null)
const capabilityLoading = ref(false)
const recursive = ref(false)
const isDirectory = computed(() => props.file?.type === 'directory')
const fileName = computed(() => props.file?.name ?? '')
const currentSummary = computed(() => (props.file ? permissionTooltip(props.file) : ''))
const visiblePresets = computed(() => {
const t = props.file?.type === 'directory' ? 'directory' : 'file'
return CHMOD_PRESETS.filter((p) => p.for === t || p.for === 'file')
})
const capabilityAlertType = computed(() => {
if (!capability.value) return 'info'
if (capability.value.is_root || (capability.value.can_sudo_nopasswd && capability.value.whitelist_ok)) {
return 'success'
}
if (capability.value.can_sudo_nopasswd) return 'warning'
return 'error'
})
watch(
() => [props.modelValue, props.file] as const,
([open, file]) => {
if (!open || !file) return
const fileOwner = file.owner || ''
const fileGroup = file.group || ''
mode.value = file.mode_octal || ''
owner.value = fileOwner
group.value = fileGroup && fileGroup !== fileOwner ? fileGroup : ''
initialMode.value = mode.value
initialOwner.value = fileOwner
initialGroup.value = fileGroup
recursive.value = false
capability.value = null
if (props.serverId) {
void runCapabilityCheck()
}
},
)
function elevationLabel(mode: FilesElevationMode | undefined) {
if (mode === 'off') return '关闭提权'
if (mode === 'always_sudo') return '始终 sudo'
return '自动 sudo'
}
async function runCapabilityCheck() {
if (!props.serverId) return
capabilityLoading.value = true
try {
capability.value = await http.get<FilesCapabilityResult>(
`/servers/${props.serverId}/files-capability`,
)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '检测失败'
capability.value = {
ssh_user: '',
is_root: false,
files_elevation: 'auto_sudo',
can_sudo_nopasswd: false,
whitelist_ok: false,
message: msg,
docs_path: 'docs/deploy/nexus-files-sudoers.example',
}
} finally {
capabilityLoading.value = false
}
}
function required(label: string) {
return (v: string) => !!String(v || '').trim() || `${label}不能为空`
}
function octalRule(v: string) {
return /^[0-7]{3,4}$/.test(String(v || '').trim()) || '格式: 34 位八进制'
}
const IDENT = /^[a-zA-Z0-9_.-]+$/
function ownerRule(v: string) {
const s = String(v || '').trim()
if (!s) return true
return IDENT.test(s) || '仅允许字母、数字、._-'
}
function groupRule(v: string) {
const s = String(v || '').trim()
if (!s) return true
if (!owner.value.trim()) return '请先填写属主'
return IDENT.test(s) || '仅允许字母、数字、._-'
}
function applyPreset(m: string) {
mode.value = m
}
function submit() {
const m = mode.value.trim()
if (!/^[0-7]{3,4}$/.test(m)) return
const o = owner.value.trim()
const g = group.value.trim()
const rec = isDirectory.value && recursive.value
const ownerChanged = o !== initialOwner.value.trim()
const groupChanged = g !== (initialGroup.value.trim() && initialGroup.value.trim() !== initialOwner.value.trim()
? initialGroup.value.trim()
: '')
if (rec) {
const name = props.file?.name ?? '该目录'
const ok = window.confirm(
`确认对目录「${name}」及其全部子项递归应用权限 ${m}?此操作影响范围大且不可自动撤销。`,
)
if (!ok) return
}
emit('apply', {
mode: m,
owner: ownerChanged || groupChanged ? (o || undefined) : undefined,
group: groupChanged ? (g || undefined) : undefined,
recursive: rec || undefined,
})
}
</script>
+254
View File
@@ -0,0 +1,254 @@
<template>
<v-dialog v-model="visible" fullscreen transition="dialog-bottom-transition" @after-enter="onDialogShown">
<v-card class="monaco-editor-card d-flex flex-column">
<v-toolbar density="compact" color="surface" class="flex-grow-0">
<v-btn icon="mdi-close" @click="requestClose" />
<v-toolbar-title class="text-body-2 text-truncate" style="max-width: 50vw;">
{{ fileName }}
<span class="text-medium-emphasis ml-1">{{ shortPath }}</span>
<span v-if="modified" class="text-error ml-1"></span>
</v-toolbar-title>
<v-spacer />
<v-chip size="x-small" variant="tonal" label class="mr-2">{{ language }}</v-chip>
<span v-if="lineCount" class="text-caption text-medium-emphasis mr-3">{{ lineCount }} </span>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-content-save"
:loading="saving"
:disabled="!modified || !ready"
@click="save"
>
保存
</v-btn>
<v-btn size="small" variant="text" icon="mdi-theme-light-dark" @click="toggleTheme" />
</v-toolbar>
<div class="monaco-editor-body flex-grow-1 position-relative">
<v-progress-linear v-if="!ready" indeterminate absolute class="monaco-editor-progress" />
<div ref="editorContainer" class="monaco-editor-host" />
</div>
<v-dialog v-model="showCloseConfirm" max-width="400">
<v-card border>
<v-card-title>未保存的更改</v-card-title>
<v-card-text>文件已修改但未保存确定要关闭吗</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showCloseConfirm = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="forceClose">不保存关闭</v-btn>
<v-btn color="primary" variant="flat" @click="saveThenClose">保存并关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { buildMonacoEditorOptions, initMonaco, toggleNexusEditorTheme } from '@/monaco/initMonaco'
import { detectEditorLanguage } from '@/utils/editorLanguage'
import type * as Monaco from 'monaco-editor'
const monaco = initMonaco()
const snackbar = useSnackbar()
const props = defineProps<{
modelValue: boolean
filePath: string
fileName: string
fileContent: string
serverId: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
}>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const editorContainer = ref<HTMLElement>()
const saving = ref(false)
const modified = ref(false)
const ready = ref(false)
const showCloseConfirm = ref(false)
const language = ref('plaintext')
const lineCount = ref(0)
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
let model: Monaco.editor.ITextModel | null = null
let originalContent = ''
let layoutObserver: ResizeObserver | null = null
const shortPath = computed(() => {
const p = props.filePath || ''
return p.length > 48 ? `${p.slice(-44)}` : p
})
function normalizeEol(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
function updateLineCount() {
if (!editor) {
lineCount.value = 0
return
}
lineCount.value = editor.getModel()?.getLineCount() ?? 0
}
function destroyEditor() {
layoutObserver?.disconnect()
layoutObserver = null
editor?.dispose()
editor = null
model?.dispose()
model = null
ready.value = false
}
function mountEditor() {
if (!editorContainer.value || !visible.value) return
destroyEditor()
ready.value = false
language.value = detectEditorLanguage(props.fileName, props.filePath)
originalContent = normalizeEol(props.fileContent ?? '')
modified.value = false
model = monaco.editor.createModel(originalContent, language.value, monaco.Uri.file(props.filePath || props.fileName))
editor = monaco.editor.create(editorContainer.value, {
...buildMonacoEditorOptions(),
model,
})
editor.onDidChangeModelContent(() => {
if (!editor) return
modified.value = normalizeEol(editor.getValue()) !== originalContent
updateLineCount()
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
save()
})
editor.addCommand(monaco.KeyCode.Escape, () => {
requestClose()
})
layoutObserver = new ResizeObserver(() => {
editor?.layout()
})
layoutObserver.observe(editorContainer.value)
ready.value = true
updateLineCount()
requestAnimationFrame(() => editor?.layout())
}
function onDialogShown() {
mountEditor()
}
function toggleTheme() {
toggleNexusEditorTheme(monaco)
}
async function save() {
if (!editor || !modified.value || !props.serverId) return
saving.value = true
try {
await http.post('/sync/write-file', {
server_id: props.serverId,
path: props.filePath,
content: editor.getValue(),
})
originalContent = normalizeEol(editor.getValue())
modified.value = false
snackbar('文件已保存')
emit('saved')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '保存失败'
snackbar(msg, 'error')
} finally {
saving.value = false
}
}
function requestClose() {
if (modified.value) {
showCloseConfirm.value = true
} else {
close()
}
}
function forceClose() {
showCloseConfirm.value = false
close()
}
async function saveThenClose() {
await save()
if (!modified.value) {
showCloseConfirm.value = false
close()
}
}
function close() {
destroyEditor()
visible.value = false
}
watch(
() => [props.modelValue, props.filePath, props.fileContent] as const,
async ([open]) => {
if (!open) {
destroyEditor()
return
}
await nextTick()
if (editorContainer.value?.offsetParent !== null) {
mountEditor()
}
},
)
onBeforeUnmount(() => {
destroyEditor()
})
</script>
<style scoped>
.monaco-editor-card {
height: 100vh;
overflow: hidden;
}
.monaco-editor-body {
min-height: 0;
}
.monaco-editor-host {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.monaco-editor-host :deep(.monaco-editor .view-line span) {
font-family: inherit;
}
.monaco-editor-progress {
z-index: 2;
}
</style>
@@ -0,0 +1,74 @@
<template>
<div ref="host" class="script-content-editor" />
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { buildMonacoEditorOptions, initMonaco } from '@/monaco/initMonaco'
import type * as Monaco from 'monaco-editor'
const props = defineProps<{
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const monaco = initMonaco()
const host = ref<HTMLElement>()
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
let syncing = false
function bindEditor() {
if (!host.value) return
editor?.dispose()
editor = monaco.editor.create(host.value, {
...buildMonacoEditorOptions({ language: 'shell', readOnly: props.readonly ?? false }),
value: props.modelValue || '',
})
editor.onDidChangeModelContent(() => {
if (syncing || !editor) return
emit('update:modelValue', editor.getValue())
})
}
watch(
() => props.modelValue,
(val) => {
if (!editor) return
const current = editor.getValue()
if (current === val) return
syncing = true
editor.setValue(val || '')
syncing = false
},
)
watch(
() => props.readonly,
(ro) => {
editor?.updateOptions({ readOnly: ro ?? false })
},
)
onMounted(() => {
bindEditor()
})
onBeforeUnmount(() => {
editor?.dispose()
editor = null
})
</script>
<style scoped>
.script-content-editor {
height: 360px;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
overflow: hidden;
}
</style>
@@ -0,0 +1,224 @@
<template>
<div
class="sh-field position-relative"
:class="{
'sh-field--disabled': disabled,
'sh-field--empty': !modelValue,
'sh-field--large': size === 'large',
}"
>
<div
v-if="modelValue"
ref="highlightRef"
class="sh-field__highlight font-mono"
:class="multiline ? 'sh-field__highlight--multi' : 'sh-field__highlight--single'"
aria-hidden="true"
v-html="highlighted"
/>
<component
:is="multiline ? 'textarea' : 'input'"
ref="inputRef"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:rows="multiline ? rows : undefined"
class="sh-field__input font-mono"
:class="multiline ? 'sh-field__input--multi' : 'sh-field__input--single'"
spellcheck="false"
autocomplete="off"
@input="onInput"
@keydown="onKeydown"
@scroll="syncHighlightScroll"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, nextTick, watch } from 'vue'
import { highlightShellToHtml } from '@/utils/shellHighlight'
const inputRef = ref<HTMLInputElement | HTMLTextAreaElement | null>(null)
const highlightRef = ref<HTMLElement | null>(null)
const props = withDefaults(
defineProps<{
modelValue: string
placeholder?: string
disabled?: boolean
multiline?: boolean
rows?: number
size?: 'default' | 'large'
}>(),
{
placeholder: '',
disabled: false,
multiline: false,
rows: 3,
size: 'default',
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
keydown: [event: KeyboardEvent]
}>()
const highlighted = computed(() => highlightShellToHtml(props.modelValue))
function syncHighlightScroll() {
const el = inputRef.value
const highlight = highlightRef.value
if (!el || !highlight || !props.multiline) return
highlight.scrollTop = el.scrollTop
highlight.scrollLeft = el.scrollLeft
}
watch(
() => props.modelValue,
() => {
nextTick(syncHighlightScroll)
},
)
function onInput(e: Event) {
const el = e.target as HTMLInputElement | HTMLTextAreaElement
emit('update:modelValue', el.value)
nextTick(syncHighlightScroll)
}
function onKeydown(e: KeyboardEvent) {
emit('keydown', e)
}
function getInputEl(): HTMLInputElement | HTMLTextAreaElement | null {
return inputRef.value
}
function focusInput() {
inputRef.value?.focus()
}
function getSelectionText(): string {
const el = inputRef.value
if (!el) return ''
return el.value.substring(el.selectionStart ?? 0, el.selectionEnd ?? 0)
}
function selectAllInput() {
const el = inputRef.value
if (!el) return
el.focus()
el.select()
}
async function insertAtSelection(text: string) {
const el = inputRef.value
if (!el) return
const start = el.selectionStart ?? el.value.length
const end = el.selectionEnd ?? start
const next = el.value.slice(0, start) + text + el.value.slice(end)
emit('update:modelValue', next)
await nextTick()
el.focus()
const pos = start + text.length
el.setSelectionRange(pos, pos)
}
defineExpose({
getInputEl,
focusInput,
getSelectionText,
selectAllInput,
insertAtSelection,
})
</script>
<style scoped>
.sh-field__highlight,
.sh-field__input {
font-size: 0.875rem;
line-height: 1.5;
padding: 8px 12px;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
}
.sh-field__highlight--single,
.sh-field__input--single {
min-height: 36px;
}
.sh-field__highlight--multi,
.sh-field__input--multi {
min-height: 72px;
resize: vertical;
overflow: auto;
}
.sh-field__highlight {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
border: 1px solid transparent;
}
.sh-field__highlight--multi {
overflow: hidden;
}
.sh-field__input {
position: relative;
z-index: 2;
background: transparent;
color: transparent;
caret-color: rgb(var(--v-theme-primary));
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
outline: none;
}
.sh-field__input:focus {
border-color: rgb(var(--v-theme-primary));
}
.sh-field--disabled .sh-field__input {
opacity: 0.5;
pointer-events: none;
}
.sh-field--empty .sh-field__input {
color: rgba(var(--v-theme-on-surface), 0.42);
}
.sh-field:not(.sh-field--empty) .sh-field__input {
color: transparent;
}
.sh-field--large .sh-field__highlight,
.sh-field--large .sh-field__input {
font-size: 1rem;
line-height: 1.55;
padding: 12px 14px;
}
.sh-field--large .sh-field__input--single {
min-height: 108px;
}
.sh-field--large .sh-field__highlight--single {
min-height: 108px;
}
.sh-field--large .sh-field__input--multi {
min-height: 108px;
}
.sh-field--large .sh-field__highlight--multi {
min-height: 108px;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
export interface StatCardItem {
subtitle: string
title: string
icon: string
color: string
/** Optional id for click handler (e.g. total | online | offline | alerts) */
key?: string
clickable?: boolean
active?: boolean
}
withDefaults(
defineProps<{
items: StatCardItem[]
/** 首屏加载:显示骨架 */
loading?: boolean
/** 点击刷新中:仅按钮 loading,不遮挡卡片 */
refreshing?: boolean
/** 显示右上角刷新按钮 */
showRefresh?: boolean
}>(),
{
loading: false,
refreshing: false,
showRefresh: true,
},
)
const emit = defineEmits<{
click: [key: string]
refresh: []
}>()
function onCardClick(item: StatCardItem) {
if (item.clickable === false || !item.key) return
emit('click', item.key)
}
</script>
<template>
<div class="stat-cards-row">
<div v-if="showRefresh" class="d-flex justify-end align-center mb-2">
<v-btn
icon="mdi-refresh"
size="small"
variant="text"
:loading="refreshing"
:disabled="loading"
title="刷新统计"
aria-label="刷新统计"
@click="emit('refresh')"
/>
</div>
<v-skeleton-loader v-if="loading" type="heading" class="mb-1" />
<v-row v-else>
<v-col
v-for="(item, i) in items"
:key="item.key ?? i"
cols="6"
:sm="items.length > 4 ? 4 : 3"
:lg="items.length > 4 ? 2 : 3"
>
<v-card
elevation="0"
rounded="xl"
border
class="stat-card"
:class="{
'stat-card--clickable': item.clickable !== false && item.key,
'stat-card--active': item.active,
'stat-card--refreshing': refreshing,
}"
:role="item.clickable !== false && item.key ? 'button' : undefined"
:tabindex="item.clickable !== false && item.key ? 0 : undefined"
@click="onCardClick(item)"
@keydown.enter.prevent="onCardClick(item)"
>
<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>
</div>
</template>
<style scoped>
.stat-card {
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;
}
.stat-card--refreshing {
opacity: 0.88;
}
.stat-card--clickable {
cursor: pointer;
}
.stat-card--clickable:focus-visible {
outline: 2px solid rgb(var(--v-theme-primary));
outline-offset: 2px;
}
.stat-card--active {
border-color: rgb(var(--v-theme-primary)) !important;
box-shadow: 0 0 0 1px rgba(var(--v-theme-primary), 0.35) !important;
}
.stat-card--clickable: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>
@@ -0,0 +1,209 @@
<template>
<v-card id="terminal-quick-commands" elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="d-flex align-center flex-wrap ga-2">
<v-icon class="mr-1">mdi-flash</v-icon>
终端快捷命令
<v-spacer />
<v-btn size="small" color="primary" variant="flat" prepend-icon="mdi-plus" @click="openCreate">
添加命令
</v-btn>
</v-card-title>
<v-divider />
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-4">
自定义命令显示在终端栏最前优先级最高终端页仅可点击执行在此添加编辑删除与调整顺序
</div>
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-3" />
<div v-if="customCommands().length === 0 && !loading" class="text-medium-emphasis text-body-2 mb-4">
暂无自定义命令点击添加命令创建
</div>
<v-list v-if="customCommands().length" density="compact" class="mb-4 pa-0">
<v-list-item
v-for="(cmd, index) in customCommands()"
:key="cmd.id"
border
rounded="lg"
class="mb-2"
>
<template #prepend>
<span class="text-caption text-medium-emphasis mr-2" style="min-width: 1.5rem">{{ index + 1 }}</span>
</template>
<v-list-item-title>{{ cmd.name }}</v-list-item-title>
<v-list-item-subtitle class="font-mono text-truncate">{{ cmd.cmd }}</v-list-item-subtitle>
<template #append>
<v-btn
icon="mdi-arrow-up"
size="x-small"
variant="text"
:disabled="index === 0 || savingOrder"
@click.stop="moveUp(index)"
/>
<v-btn
icon="mdi-arrow-down"
size="x-small"
variant="text"
:disabled="index === customCommands().length - 1 || savingOrder"
@click.stop="moveDown(index)"
/>
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click.stop="openEdit(cmd)" />
<v-btn icon="mdi-delete" size="x-small" variant="text" color="error" @click.stop="confirmRemove(cmd)" />
</template>
</v-list-item>
</v-list>
<div class="text-subtitle-2 mb-2">系统内置不可编辑</div>
<div class="d-flex flex-wrap ga-2">
<v-chip
v-for="cmd in builtinCommands()"
:key="cmd.id"
size="small"
variant="outlined"
label
>
{{ cmd.name }}
</v-chip>
</div>
</v-card-text>
<v-dialog v-model="showEditor" max-width="520">
<v-card border>
<v-card-title>{{ editingId ? '编辑命令' : '添加快捷命令' }}</v-card-title>
<v-card-text>
<v-text-field v-model="formName" label="显示名称" variant="outlined" density="compact" class="mb-3" />
<div class="text-body-2 text-medium-emphasis mb-2">命令内容发送时自动追加回车</div>
<ShellHighlightField v-model="formCmd" multiline :rows="4" placeholder="systemctl status nginx" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showEditor = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="saving" @click="save">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showDelete" max-width="400">
<v-card border>
<v-card-title>删除命令</v-card-title>
<v-card-text>确定删除{{ deletingName }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDelete = false">取消</v-btn>
<v-btn color="error" variant="flat" :loading="saving" @click="doDelete">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ShellHighlightField from '@/components/ShellHighlightField.vue'
import { useSnackbar } from '@/composables/useSnackbar'
import { useTerminalQuickCommands, type QuickCmdItem } from '@/composables/useTerminalQuickCommands'
const snackbar = useSnackbar()
const { commands, loading, load, create, update, remove, reorder, customCommands, builtinCommands } =
useTerminalQuickCommands()
const showEditor = ref(false)
const editingId = ref<number | null>(null)
const formName = ref('')
const formCmd = ref('')
const saving = ref(false)
const savingOrder = ref(false)
const showDelete = ref(false)
const deletingId = ref<number | null>(null)
const deletingName = ref('')
function openCreate() {
editingId.value = null
formName.value = ''
formCmd.value = ''
showEditor.value = true
}
function openEdit(cmd: QuickCmdItem) {
editingId.value = cmd.id
formName.value = cmd.name
formCmd.value = cmd.cmd.replace(/\r$/, '')
showEditor.value = true
}
async function save() {
if (!formName.value.trim() || !formCmd.value.trim()) {
snackbar('名称和命令不能为空', 'warning')
return
}
const cmdText = formCmd.value.trim()
saving.value = true
try {
if (editingId.value) await update(editingId.value, formName.value, cmdText)
else await create(formName.value, cmdText)
showEditor.value = false
snackbar('已保存')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
} finally {
saving.value = false
}
}
function confirmRemove(cmd: QuickCmdItem) {
deletingId.value = cmd.id
deletingName.value = cmd.name
showDelete.value = true
}
async function doDelete() {
if (!deletingId.value) return
saving.value = true
try {
await remove(deletingId.value)
showDelete.value = false
snackbar('已删除')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '删除失败', 'error')
} finally {
saving.value = false
}
}
async function moveUp(index: number) {
const list = customCommands()
if (index <= 0) return
const ids = list.map((c) => c.id)
const prev = ids[index - 1]
ids[index - 1] = ids[index]
ids[index] = prev
await applyOrder(ids)
}
async function moveDown(index: number) {
const list = customCommands()
if (index >= list.length - 1) return
const ids = list.map((c) => c.id)
const next = ids[index + 1]
ids[index + 1] = ids[index]
ids[index] = next
await applyOrder(ids)
}
async function applyOrder(ids: number[]) {
savingOrder.value = true
try {
await reorder(ids)
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '排序失败', 'error')
} finally {
savingOrder.value = false
}
}
onMounted(() => {
load().catch((e) => snackbar(e.message, 'error'))
})
</script>
@@ -0,0 +1,47 @@
<template>
<v-dialog :model-value="modelValue" max-width="520" @update:model-value="$emit('update:modelValue', $event)">
<v-card title="批量 SSH 获取宝塔 API">
<v-card-text>
<p class="mb-2">
将对 <strong>{{ targetCount }}</strong> 台服务器执行 SSH 引导开启子机
<code>api.json</code>写入 Nexus 加密凭据追加中心 IP 白名单
</p>
<p
v-if="mode === 'selected' && configuredSkipCount > 0"
class="text-caption text-medium-emphasis mb-2"
>
已选 {{ selectedTotal }} 其中 {{ configuredSkipCount }} API 已配置将自动跳过
</p>
<p class="text-caption text-medium-emphasis mb-0">
前提子机 root SSH 可达已装宝塔未装会标记为安装中并每 5 分钟重试
连接配置中需已设置中心调用 IP
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">取消</v-btn>
<v-btn color="primary" :loading="loading" @click="$emit('confirm')">开始</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
modelValue: boolean
mode: 'all' | 'selected'
targetCount: number
selectedTotal?: number
configuredSkipCount?: number
loading?: boolean
}>(), {
selectedTotal: 0,
configuredSkipCount: 0,
loading: false,
})
defineEmits<{
'update:modelValue': [open: boolean]
confirm: []
}>()
</script>
@@ -0,0 +1,46 @@
<template>
<v-container fluid class="pa-6">
<div class="d-flex flex-wrap align-center ga-3 mb-4">
<div>
<h1 class="text-h6 font-weight-bold">{{ title }}</h1>
<p v-if="subtitle" class="text-caption text-medium-emphasis mb-0">{{ subtitle }}</p>
</div>
<v-spacer />
<div style="min-width: 280px; max-width: 420px; flex: 1">
<BtPanelServerPicker
:model-value="serverId"
:servers="servers"
:loading="loading"
@update:model-value="setServerId"
/>
</div>
</div>
<v-alert v-if="props.requireServer && !serverId" type="info" variant="tonal" class="mb-4" text="请选择服务器" />
<slot :server-id="serverId" :server="selected()" />
</v-container>
</template>
<script setup lang="ts">
import { onMounted, provide } from 'vue'
import BtPanelServerPicker from '@/components/btpanel/BtPanelServerPicker.vue'
import { btPanelContextKey } from '@/composables/btpanel/btPanelContext'
import { useBtPanelServer } from '@/composables/btpanel/useBtPanelServer'
const props = withDefaults(defineProps<{
title: string
subtitle?: string
/** 为 false 时未选服务器也渲染默认插槽(如连接配置页的全局块) */
requireServer?: boolean
}>(), {
requireServer: true,
})
const ctx = useBtPanelServer()
const { servers, serverId, loading, loadServers, setServerId, selected } = ctx
provide(btPanelContextKey, ctx)
onMounted(() => { void loadServers() })
</script>
@@ -0,0 +1,47 @@
<template>
<v-select
:model-value="modelValue"
:items="items"
item-title="label"
item-value="id"
label="目标服务器"
density="compact"
variant="outlined"
hide-details
class="bt-panel-server-picker"
:loading="loading"
@update:model-value="$emit('update:modelValue', $event)"
>
<template #item="{ props, item }">
<v-list-item v-bind="props" :subtitle="itemHint(item)" />
</template>
</v-select>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { BtPanelServerRow } from '@/api/btpanel'
const props = defineProps<{
modelValue: number | null
servers: BtPanelServerRow[]
loading?: boolean
}>()
defineEmits<{ 'update:modelValue': [number | null] }>()
type PickerItem = { id: number; label: string; hint: string }
function itemHint(item: { raw?: PickerItem } | PickerItem): string {
if ('raw' in item && item.raw) return item.raw.hint
return 'hint' in item ? item.hint : ''
}
const items = computed<PickerItem[]>(() =>
props.servers.map(s => ({
id: s.id,
label: `${s.name} (${s.domain})`,
hint: s.configured ? '已配置宝塔 API' : '未配置 API(一键登录可走 SSH',
})),
)
</script>
@@ -0,0 +1,32 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="960"
scrollable
@update:model-value="emit('update:modelValue', $event)"
>
<v-card border rounded="lg">
<v-card-title class="d-flex align-center">
凭据管理
<v-spacer />
<v-btn icon="mdi-close" variant="text" size="small" @click="emit('update:modelValue', false)" />
</v-card-title>
<v-divider />
<v-card-text class="pa-4">
<CredentialsManager v-if="modelValue" :active="modelValue" />
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import CredentialsManager from '@/components/credentials/CredentialsManager.vue'
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>
@@ -0,0 +1,519 @@
<template>
<div class="credentials-manager">
<div class="d-flex align-center flex-wrap ga-2 mb-3">
<v-btn-toggle v-model="credTab" mandatory density="compact" variant="outlined">
<v-btn size="small" value="passwords">密码预设</v-btn>
<v-btn size="small" value="sshkeys">SSH 密钥</v-btn>
<v-btn size="small" value="db">数据库凭据</v-btn>
</v-btn-toggle>
<v-spacer />
<v-btn color="primary" variant="flat" size="small" prepend-icon="mdi-plus" @click="openAdd">添加</v-btn>
</div>
<template v-if="credTab === 'passwords'">
<p class="text-body-2 text-medium-emphasis mb-2">
保存常用 SSH 账号+密码模板快速添加服务器时将按顺序轮询所有预设尝试登录
</p>
<v-data-table
:items="passwords"
:headers="passwordHeaders"
:loading="loading"
hover
density="comfortable"
:items-per-page="20"
:items-per-page-options="dataTablePageOptions"
>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatPresetTime(item.created_at) }}</span>
</template>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editPassword(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('password', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无密码预设</div>
</template>
</v-data-table>
</template>
<template v-else-if="credTab === 'sshkeys'">
<p class="text-body-2 text-medium-emphasis mb-2">
上传本地 PEM 私钥文件 id_rsa.pem公钥由系统从私钥自动推导
</p>
<v-data-table
:items="sshKeys"
:headers="sshKeyHeaders"
:loading="loading"
hover
density="comfortable"
:items-per-page="20"
:items-per-page-options="dataTablePageOptions"
>
<template #item.private_key_set="{ item }">
<v-chip :color="item.private_key_set ? 'success' : 'grey'" size="small" variant="tonal" label>
{{ item.private_key_set ? '已设置' : '未设置' }}
</v-chip>
</template>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editSshKey(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('sshkey', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无 SSH 密钥</div>
</template>
</v-data-table>
</template>
<template v-else>
<v-data-table-server
:items="dbCreds"
:headers="dbCredHeaders"
:items-length="totalItems"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="dataTablePageOptions"
hover
density="comfortable"
@update:page="(p: number) => { page = p; loadDbCreds() }"
@update:items-per-page="onItemsPerPageChange"
>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" density="compact" @click="editDbCred(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDelete('db', item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无凭据</div>
</template>
</v-data-table-server>
</template>
<v-dialog v-model="showPasswordDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑密码预设' : '新建密码预设' }}</v-card-title>
<v-card-text>
<v-text-field
v-model="pwForm.name"
label="预设名称"
variant="outlined"
density="compact"
class="mb-2"
placeholder="如:生产环境 root 密码"
:rules="[required('预设名称')]"
/>
<v-text-field
v-model="pwForm.username"
label="SSH 用户名"
variant="outlined"
density="compact"
class="mb-2"
placeholder="root"
:rules="[required('SSH 用户名')]"
/>
<v-text-field
v-model="pwForm.password"
label="SSH 密码"
variant="outlined"
density="compact"
type="password"
:placeholder="editingId ? '留空表示不修改' : '必填'"
:rules="editingId ? [] : [required('SSH 密码')]"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showPasswordDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="savePassword">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showSshKeyDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑 SSH 密钥' : '新建 SSH 密钥' }}</v-card-title>
<v-card-text>
<v-text-field
v-model="skForm.name"
label="名称"
variant="outlined"
density="compact"
class="mb-2"
:rules="[required('名称')]"
/>
<v-text-field
v-model="skForm.username"
label="SSH 用户名"
variant="outlined"
density="compact"
class="mb-2"
placeholder="root"
:rules="[required('SSH 用户名')]"
/>
<v-file-input
v-model="skKeyFile"
label="私钥文件"
variant="outlined"
density="compact"
class="mb-2"
prepend-icon="mdi-file-key"
show-size
:clearable="true"
:hint="editingId ? '留空表示不更换私钥' : '选择 PEM 私钥文件(id_rsa、.pem、.key 等)'"
persistent-hint
@update:model-value="onSkKeyFileSelected"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showSshKeyDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="saveSshKey">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showDbCredDialog" max-width="500">
<v-card border>
<v-card-title>{{ editingId ? '编辑数据库凭据' : '新建数据库凭据' }}</v-card-title>
<v-card-text>
<v-text-field v-model="dbForm.name" label="名称" variant="outlined" density="compact" class="mb-2" />
<v-text-field
v-model="dbForm.host"
label="数据库主机"
variant="outlined"
density="compact"
class="mb-2"
hint="脚本 $DB_HOST;子机本机 MySQL 一般为 127.0.0.1"
persistent-hint
/>
<v-text-field v-model="dbForm.port" label="端口" variant="outlined" density="compact" type="number" class="mb-2" />
<v-text-field v-model="dbForm.username" label="用户名" variant="outlined" density="compact" class="mb-2" />
<v-text-field v-model="dbForm.password" label="密码" variant="outlined" density="compact" type="password" class="mb-2" />
<v-text-field v-model="dbForm.database" label="数据库" variant="outlined" density="compact" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDbCredDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="saveDbCred">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>确定要删除此凭据吗</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="doDelete">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { required } from '@/utils/validation'
import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import { formatDateTimeBeijing } from '@/utils/datetime'
defineOptions({ name: 'CredentialsManager' })
const props = defineProps<{
/** 对话框打开时为 true,触发数据加载 */
active?: boolean
}>()
const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
interface PasswordItem { id: number; name: string; username?: string; created_at?: string }
interface SshKeyItem { id: number; name: string; username?: string; private_key_set?: boolean }
interface DbCredItem { id: number; name: string; host: string; port: number; username: string; database?: string }
const loading = ref(false)
const credTab = ref<'passwords' | 'sshkeys' | 'db'>('passwords')
const editingId = ref<number | null>(null)
const page = ref(1)
const totalItems = ref(0)
const itemsPerPage = ref(20)
const passwords = ref<PasswordItem[]>([])
const showPasswordDialog = ref(false)
const pwForm = ref({ name: '', username: 'root', password: '' })
const passwordHeaders = headersWithoutSort([
{ title: '预设名称', key: 'name' },
{ title: '用户名', key: 'username', width: 120 },
{ title: '创建时间', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
])
const sshKeys = ref<SshKeyItem[]>([])
const showSshKeyDialog = ref(false)
const skForm = ref({ name: '', username: 'root' })
const skKeyFile = ref<File | File[] | null>(null)
const sshKeyHeaders = headersWithoutSort([
{ title: '名称', key: 'name' },
{ title: '用户名', key: 'username', width: 120 },
{ title: '私钥', key: 'private_key_set', width: 120 },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
])
const dbCreds = ref<DbCredItem[]>([])
const showDbCredDialog = ref(false)
const DEFAULT_DB_HOST = '127.0.0.1'
const dbForm = ref({ name: '', host: DEFAULT_DB_HOST, port: 3306, username: '', password: '', database: '' })
const dbCredHeaders = headersWithoutSort([
{ title: '名称', key: 'name' },
{ title: '主机', key: 'host' },
{ title: '端口', key: 'port', width: 80 },
{ title: '用户名', key: 'username' },
{ title: '数据库', key: 'database' },
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
])
const showDeleteDialog = ref(false)
const deleteType = ref('')
const deleteId = ref<number | null>(null)
function onItemsPerPageChange(n: number) {
itemsPerPage.value = n
page.value = 1
if (credTab.value === 'db') loadDbCreds()
}
function formatPresetTime(iso: string | undefined): string {
return formatDateTimeBeijing(iso)
}
async function loadPasswords(silent = false) {
if (!silent) loading.value = true
try {
const res = await http.getList<PasswordItem>('/presets/')
passwords.value = res.items
totalItems.value = res.total
} catch (e: unknown) {
passwords.value = []
snackbar(formatApiError(e, '加载密码预设失败'), 'error')
} finally {
if (!silent) loading.value = false
}
}
async function loadSshKeys(silent = false) {
if (!silent) loading.value = true
try {
const res = await http.getList<SshKeyItem>('/ssh-key-presets/')
sshKeys.value = res.items
totalItems.value = res.total
} catch (e: unknown) {
sshKeys.value = []
snackbar(formatApiError(e, '加载 SSH 密钥失败'), 'error')
} finally {
if (!silent) loading.value = false
}
}
async function loadDbCreds(silent = false) {
if (!silent) loading.value = true
try {
const res = await fetchPagePerPage<DbCredItem>('/scripts/credentials', {}, page.value, itemsPerPage.value)
dbCreds.value = res.items
totalItems.value = res.total
if (itemsPerPage.value === -1) page.value = 1
} catch (e: unknown) {
dbCreds.value = []
snackbar(formatApiError(e, '加载数据库凭据失败'), 'error')
} finally {
if (!silent) loading.value = false
}
}
function loadCurrentTab(silent = false) {
switch (credTab.value) {
case 'passwords': void loadPasswords(silent); break
case 'sshkeys': void loadSshKeys(silent); break
case 'db': void loadDbCreds(silent); break
}
}
watch(credTab, () => {
page.value = 1
loadCurrentTab()
})
watch(
() => props.active,
(active) => {
if (active) loadCurrentTab()
},
{ immediate: true },
)
function editPassword(item: PasswordItem) {
editingId.value = item.id
pwForm.value = { name: item.name, username: item.username || 'root', password: '' }
showPasswordDialog.value = true
}
async function savePassword() {
const name = pwForm.value.name.trim()
const password = pwForm.value.password
if (!name) {
snackbar('请填写预设名称', 'error')
return
}
if (!editingId.value && !password) {
snackbar('请填写 SSH 密码', 'error')
return
}
try {
const username = pwForm.value.username.trim() || 'root'
if (editingId.value) {
const body: { name: string; username: string; encrypted_pw?: string } = { name, username }
if (password) body.encrypted_pw = password
await http.put(`/presets/${editingId.value}`, body)
} else {
await http.post('/presets/', { name, username, encrypted_pw: password })
}
showPasswordDialog.value = false
loadPasswords()
snackbar('保存成功')
} catch (e: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
}
}
function pickSkKeyFile(files: File | File[] | null): File | null {
if (!files) return null
if (Array.isArray(files)) return files[0] ?? null
return files
}
function onSkKeyFileSelected(files: File | File[] | null) {
if (editingId.value || skForm.value.name.trim()) return
const file = pickSkKeyFile(files)
if (!file?.name) return
const base = file.name.replace(/\.(pem|key|ppk)$/i, '').trim()
if (base) skForm.value.name = base
}
async function readSkPrivateKeyFromFile(): Promise<string> {
const file = pickSkKeyFile(skKeyFile.value)
if (!file) return ''
const text = (await file.text()).trim()
if (!text.includes('PRIVATE KEY')) {
throw new Error('不是有效的 PEM 私钥文件')
}
return text
}
function resetSshKeyDialog() {
skForm.value = { name: '', username: 'root' }
skKeyFile.value = null
}
function editSshKey(item: SshKeyItem) {
editingId.value = item.id
skForm.value = { name: item.name, username: item.username || 'root' }
skKeyFile.value = null
showSshKeyDialog.value = true
}
async function saveSshKey() {
const name = skForm.value.name.trim()
const username = skForm.value.username.trim() || 'root'
if (!name) {
snackbar('请填写名称', 'error')
return
}
let privateKey = ''
try {
privateKey = await readSkPrivateKeyFromFile()
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '读取私钥文件失败', 'error')
return
}
if (!editingId.value && !privateKey) {
snackbar('请选择私钥文件', 'error')
return
}
try {
if (editingId.value) {
const body: { name: string; username: string; private_key?: string } = { name, username }
if (privateKey) body.private_key = privateKey
await http.put(`/ssh-key-presets/${editingId.value}`, body)
} else {
await http.post('/ssh-key-presets/', { name, username, private_key: privateKey })
}
showSshKeyDialog.value = false
loadSshKeys()
snackbar('保存成功')
} catch (e: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
}
}
function editDbCred(item: DbCredItem) {
editingId.value = item.id
dbForm.value = {
name: item.name,
host: item.host || DEFAULT_DB_HOST,
port: item.port || 3306,
username: item.username || '',
password: '',
database: item.database || '',
}
showDbCredDialog.value = true
}
async function saveDbCred() {
try {
if (editingId.value) await http.put(`/scripts/credentials/${editingId.value}`, dbForm.value)
else await http.post('/scripts/credentials', dbForm.value)
showDbCredDialog.value = false
loadDbCreds()
snackbar('保存成功')
} catch (e: unknown) {
snackbar(formatApiError(e, '保存失败'), 'error')
}
}
function openAdd() {
editingId.value = null
if (credTab.value === 'passwords') {
pwForm.value = { name: '', username: 'root', password: '' }
showPasswordDialog.value = true
} else if (credTab.value === 'sshkeys') {
resetSshKeyDialog()
showSshKeyDialog.value = true
} else {
dbForm.value = { name: '', host: DEFAULT_DB_HOST, port: 3306, username: '', password: '', database: '' }
showDbCredDialog.value = true
}
}
function confirmDelete(type: string, item: { id: number }) {
deleteType.value = type
deleteId.value = item.id
showDeleteDialog.value = true
}
async function doDelete() {
try {
if (deleteType.value === 'password') await http.delete(`/presets/${deleteId.value}`)
else if (deleteType.value === 'sshkey') await http.delete(`/ssh-key-presets/${deleteId.value}`)
else await http.delete(`/scripts/credentials/${deleteId.value}`)
showDeleteDialog.value = false
loadCurrentTab()
snackbar('已删除')
} catch (e: unknown) {
snackbar(formatApiError(e, '删除失败'), 'error')
}
}
</script>
@@ -0,0 +1,347 @@
<template>
<v-card elevation="0" rounded="lg" border class="fleet-trends-card">
<v-card-item>
<template #title>
<span class="text-h6">舰队资源趋势</span>
</template>
<template #subtitle>
<span class="text-medium-emphasis">
全库 Agent 均值 · {{ intervalMinutes }} 分钟采样 · 北京时间
<span v-if="lastUpdated"> · 更新于 {{ lastUpdated }}</span>
</span>
</template>
<template #append>
<div class="d-flex align-center flex-wrap ga-2">
<v-btn-toggle
v-model="hours"
density="compact"
variant="outlined"
divided
mandatory
@update:model-value="onHoursChange"
>
<v-btn :value="24" size="small">24 小时</v-btn>
<v-btn :value="168" size="small">7 </v-btn>
</v-btn-toggle>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-fullscreen"
:disabled="!hasData"
@click="fullscreen = true"
>
全屏
</v-btn>
<v-btn
icon="mdi-refresh"
size="small"
variant="text"
:loading="loading"
title="刷新"
@click="loadAll()"
/>
</div>
</template>
</v-card-item>
<v-card-text class="pt-0">
<div v-if="loading" class="py-2">
<v-skeleton-loader type="heading" class="mb-3" />
<v-skeleton-loader type="image" height="360" />
</div>
<div v-else-if="!hasData" class="text-center py-12">
<v-icon icon="mdi-chart-timeline-variant" size="48" color="disabled" class="mb-3" />
<div class="text-body-1 text-medium-emphasis">暂无历史采样数据</div>
<div class="text-caption text-medium-emphasis mt-1">
心跳落库后约 {{ intervalMinutes }} 分钟出现首条 24 小时后曲线更完整
</div>
</div>
<div v-else>
<div class="d-flex align-center flex-wrap ga-2 mb-3">
<v-chip size="small" variant="outlined" label>
{{ viewScopeLabel }}
</v-chip>
<v-chip v-if="isZoomed" size="small" color="primary" variant="tonal" label closable @click:close="resetZoom">
已缩放视图 · 点击关闭恢复全览
</v-chip>
</div>
<FleetTrendKpiStrip :items="kpiItems" class="mb-4" />
<FleetTrendEChart
:key="chartKey"
:points="points"
:hours="hours"
:thresholds="thresholds"
:zoom-start="zoomStart"
:zoom-end="zoomEnd"
@range-change="onRangeChange"
/>
</div>
</v-card-text>
</v-card>
<v-dialog v-model="fullscreen" fullscreen transition="dialog-bottom-transition">
<v-card>
<v-toolbar density="comfortable" border>
<v-toolbar-title>舰队资源趋势 · {{ hours >= 168 ? '7 天' : '24 小时' }}</v-toolbar-title>
<v-spacer />
<v-btn icon="mdi-close" @click="fullscreen = false" />
</v-toolbar>
<v-card-text class="pa-4 pa-md-6">
<FleetTrendKpiStrip :items="kpiItems" class="mb-4" />
<FleetTrendEChart
:points="points"
:hours="hours"
:thresholds="thresholds"
:height="420"
:zoom-start="zoomStart"
:zoom-end="zoomEnd"
@range-change="onRangeChange"
/>
<v-divider class="my-4" />
<div class="text-subtitle-2 mb-2">采样明细{{ points.length }} </div>
<v-data-table
:headers="tableHeaders"
:items="tableRows"
:items-per-page="15"
density="compact"
height="320"
fixed-header
class="fleet-trend-table"
>
<template #item.recorded_at="{ item }">
{{ formatPointTimeBeijing(item.recorded_at) }}
</template>
<template #item.cpu_avg="{ item }">
<span :class="thresholdClass(item.cpu_avg, thresholds.cpu)">{{ fmtPct(item.cpu_avg) }}</span>
</template>
<template #item.mem_avg="{ item }">
<span :class="thresholdClass(item.mem_avg, thresholds.mem)">{{ fmtPct(item.mem_avg) }}</span>
</template>
<template #item.disk_avg="{ item }">
<span :class="thresholdClass(item.disk_avg, thresholds.disk)">{{ fmtPct(item.disk_avg) }}</span>
</template>
<template #item.coverage="{ item }">
{{ formatCoverage(item) }}
</template>
<template #item.alert_count="{ item }">
<v-chip
v-if="(item.alert_count ?? 0) > 0"
size="x-small"
color="error"
variant="tonal"
label
>
{{ item.alert_count }}
</v-chip>
<span v-else class="text-medium-emphasis">0</span>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { http } from '@/api'
import type { SettingsResponse } from '@/types/api'
import FleetTrendKpiStrip, { type FleetKpiItem } from '@/components/dashboard/FleetTrendKpiStrip.vue'
import {
computeRangeStats,
formatPointTimeBeijing,
indexRangeFromZoom,
parseAlertThresholds,
type FleetAlertThresholds,
type FleetTrendPoint,
} from '@/utils/fleetTrendAnalytics'
import { formatDateTimeBeijing } from '@/utils/datetime'
const FleetTrendEChart = defineAsyncComponent(
() => import('@/components/dashboard/FleetTrendEChart.vue'),
)
interface FleetMetricsResponse {
hours: number
interval_minutes: number
points: FleetTrendPoint[]
}
const hours = ref(24)
const loading = ref(true)
const points = ref<FleetTrendPoint[]>([])
const intervalMinutes = ref(10)
const lastUpdated = ref('')
const fullscreen = ref(false)
const chartKey = ref(0)
const thresholds = ref<FleetAlertThresholds>({ cpu: 80, mem: 80, disk: 80 })
const zoomStart = ref<number | undefined>(undefined)
const zoomEnd = ref<number | undefined>(undefined)
const viewStartPercent = ref(0)
const viewEndPercent = ref(100)
const hasData = computed(() => points.value.length >= 2)
const isZoomed = computed(
() => viewStartPercent.value > 0.5 || viewEndPercent.value < 99.5,
)
const viewIndexRange = computed(() =>
indexRangeFromZoom(points.value.length, viewStartPercent.value, viewEndPercent.value),
)
const viewStats = computed(() =>
computeRangeStats(points.value, viewIndexRange.value.startIdx, viewIndexRange.value.endIdx),
)
const viewScopeLabel = computed(() => {
const { startIdx, endIdx } = viewIndexRange.value
const n = endIdx - startIdx + 1
if (!isZoomed.value) {
return hours.value >= 168 ? `全周期 ${n} 条采样` : `近 24 小时 · ${n}`
}
const from = formatPointTimeBeijing(points.value[startIdx]?.recorded_at ?? '').slice(5, 16)
const to = formatPointTimeBeijing(points.value[endIdx]?.recorded_at ?? '').slice(5, 16)
return `视图 ${from}${to} · ${n}`
})
const kpiItems = computed((): FleetKpiItem[] => [
{
key: 'cpu',
label: 'CPU',
unit: '%',
current: viewStats.value.cpu.current,
delta: viewStats.value.cpu.delta,
min: viewStats.value.cpu.min,
max: viewStats.value.cpu.max,
avg: viewStats.value.cpu.avg,
threshold: thresholds.value.cpu,
},
{
key: 'mem',
label: '内存',
unit: '%',
current: viewStats.value.mem.current,
delta: viewStats.value.mem.delta,
min: viewStats.value.mem.min,
max: viewStats.value.mem.max,
avg: viewStats.value.mem.avg,
threshold: thresholds.value.mem,
},
{
key: 'disk',
label: '磁盘',
unit: '%',
current: viewStats.value.disk.current,
delta: viewStats.value.disk.delta,
min: viewStats.value.disk.min,
max: viewStats.value.disk.max,
avg: viewStats.value.disk.avg,
threshold: thresholds.value.disk,
},
{
key: 'online',
label: '在线',
unit: '台',
current: viewStats.value.online.current,
delta: viewStats.value.online.delta,
min: viewStats.value.online.min,
max: viewStats.value.online.max,
avg: viewStats.value.online.avg,
},
])
const tableHeaders = [
{ title: '采样时间', key: 'recorded_at', sortable: false },
{ title: 'CPU', key: 'cpu_avg', align: 'end' as const, width: 72 },
{ title: '内存', key: 'mem_avg', align: 'end' as const, width: 72 },
{ title: '磁盘', key: 'disk_avg', align: 'end' as const, width: 72 },
{ title: '在线', key: 'online_count', align: 'end' as const, width: 64 },
{ title: '采样数', key: 'sample_count', align: 'end' as const, width: 72 },
{ title: '覆盖率', key: 'coverage', align: 'end' as const, width: 72, sortable: false },
{ title: '告警', key: 'alert_count', align: 'end' as const, width: 56 },
]
const tableRows = computed(() => [...points.value].reverse())
function fmtPct(v: number | null | undefined): string {
return v != null ? `${v}%` : '—'
}
function formatCoverage(p: FleetTrendPoint): string {
if (p.sample_count == null || p.online_count <= 0) return '—'
return `${Math.round((p.sample_count / p.online_count) * 100)}%`
}
function thresholdClass(v: number | null | undefined, threshold: number): string {
if (v == null) return ''
if (v >= threshold) return 'text-error font-weight-medium'
if (v >= threshold - 10) return 'text-warning'
return ''
}
function onRangeChange(payload: { startPercent: number; endPercent: number }) {
viewStartPercent.value = payload.startPercent
viewEndPercent.value = payload.endPercent
}
function resetZoom() {
zoomStart.value = hours.value >= 168 ? 65 : 0
zoomEnd.value = 100
viewStartPercent.value = zoomStart.value
viewEndPercent.value = 100
chartKey.value += 1
}
function onHoursChange() {
resetZoom()
void loadTrends()
}
async function loadThresholds() {
try {
const res = await http.get<SettingsResponse>('/settings/')
thresholds.value = parseAlertThresholds(Array.isArray(res) ? res : [])
} catch {
/* 默认 80% */
}
}
async function loadTrends() {
loading.value = true
try {
const data = await http.get<FleetMetricsResponse>('/servers/fleet-metrics', { hours: hours.value })
points.value = data.points || []
intervalMinutes.value = data.interval_minutes || 10
lastUpdated.value = formatDateTimeBeijing(new Date().toISOString()).slice(11, 19)
} catch {
points.value = []
} finally {
loading.value = false
}
}
async function loadAll() {
await Promise.all([loadTrends(), loadThresholds()])
}
onMounted(() => {
resetZoom()
void loadAll()
})
defineExpose({ reload: loadAll })
</script>
<style scoped>
.fleet-trends-card :deep(.v-card-item) {
padding-bottom: 8px;
}
.fleet-trend-table :deep(th) {
white-space: nowrap;
}
</style>
@@ -0,0 +1,518 @@
<template>
<v-chart
class="fleet-echart"
:style="{ height: `${height}px` }"
:option="chartOption"
:update-options="{ notMerge: true }"
autoresize
@datazoom="onDataZoom"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, ScatterChart } from 'echarts/charts'
import {
DataZoomComponent,
GridComponent,
LegendComponent,
MarkLineComponent,
MarkPointComponent,
ToolboxComponent,
TooltipComponent,
} from 'echarts/components'
import type { ScatterSeriesOption } from 'echarts/charts'
import VChart from 'vue-echarts'
import type { ComposeOption } from 'echarts/core'
import type { LineSeriesOption } from 'echarts/charts'
import type {
DataZoomComponentOption,
GridComponentOption,
LegendComponentOption,
MarkLineComponentOption,
MarkPointComponentOption,
ToolboxComponentOption,
TooltipComponentOption,
} from 'echarts/components'
import {
findNearestPointIndex,
formatPointTimeBeijing,
pointTimestampMs,
type FleetAlertThresholds,
type FleetTrendPoint,
} from '@/utils/fleetTrendAnalytics'
import { parseApiDateTime } from '@/utils/datetime'
const INTERVAL_MS = 10 * 60 * 1000
use([
CanvasRenderer,
LineChart,
ScatterChart,
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
ToolboxComponent,
MarkLineComponent,
MarkPointComponent,
])
type ECOption = ComposeOption<
| LineSeriesOption
| ScatterSeriesOption
| GridComponentOption
| TooltipComponentOption
| LegendComponentOption
| DataZoomComponentOption
| ToolboxComponentOption
| MarkLineComponentOption
| MarkPointComponentOption
>
const props = withDefaults(
defineProps<{
points: FleetTrendPoint[]
hours: number
thresholds: FleetAlertThresholds
height?: number
zoomStart?: number
zoomEnd?: number
}>(),
{
height: 360,
zoomStart: undefined,
zoomEnd: undefined,
},
)
const emit = defineEmits<{
'range-change': [payload: { startPercent: number; endPercent: number }]
}>()
const theme = useTheme()
function themeColor(key: 'primary' | 'warning' | 'success' | 'error'): string {
const c = theme.current.value.colors[key]
return typeof c === 'string' ? c : '#1976D2'
}
function toSeriesData(field: 'cpu_avg' | 'mem_avg' | 'disk_avg'): [number, number][] {
return props.points
.map((p) => {
const d = parseApiDateTime(p.recorded_at)
const v = p[field]
if (!d || v == null) return null
return [d.getTime(), v] as [number, number]
})
.filter((x): x is [number, number] => x != null)
}
function onlineSeriesData(): [number, number][] {
return props.points
.map((p) => {
const d = parseApiDateTime(p.recorded_at)
if (!d) return null
return [d.getTime(), p.online_count] as [number, number]
})
.filter((x): x is [number, number] => x != null)
}
function thresholdMarkLine(y: number, label: string, color: string): MarkLineComponentOption['data'] {
return [
{
yAxis: y,
name: label,
lineStyle: { color, type: 'dashed', width: 1, opacity: 0.75 },
label: {
formatter: `${label} ${y}%`,
color,
fontSize: 10,
position: 'insideEndTop',
},
},
]
}
function resourceMarkPoint(): MarkPointComponentOption {
return {
symbol: 'circle',
symbolSize: 8,
label: {
fontSize: 10,
formatter: (p) => {
const raw = (p as { value?: unknown }).value
const v = Array.isArray(raw) ? raw[1] : raw
return typeof v === 'number' ? `${v}%` : ''
},
},
data: [
{ type: 'max', name: '峰值' },
{ type: 'min', name: '谷值' },
],
}
}
function formatAxisTime(ts: number, detailed = false): string {
const bj = formatPointTimeBeijing(new Date(ts).toISOString())
if (detailed) return bj
return props.hours >= 168 ? bj.slice(5, 16) : bj.slice(5, 16)
}
function alertScatterData(): [number, number, number][] {
return props.points
.filter((p) => (p.alert_count ?? 0) > 0)
.map((p) => {
const ts = pointTimestampMs(p.recorded_at)
const y = Math.max(p.cpu_avg ?? 0, p.mem_avg ?? 0, p.disk_avg ?? 0, 8)
return [ts ?? 0, Math.min(y + 4, 99), p.alert_count ?? 0] as [number, number, number]
})
.filter((d) => d[0] > 0)
}
function buildAlertsHtml(p: FleetTrendPoint): string {
const alerts = p.alerts ?? []
if (!alerts.length) {
return '<div style="margin-top:8px;font-size:11px;opacity:0.65">本 10 分钟无告警</div>'
}
const lines = alerts.slice(0, 8).map((a) => {
const label = a.alert_type_label || a.alert_type
const kind = a.is_recovery ? '恢复' : '告警'
const color = a.is_recovery ? '#4CAF50' : '#F44336'
const time = a.created_at ? formatPointTimeBeijing(a.created_at).slice(11, 19) : ''
return `<div style="font-size:11px;line-height:1.45">
<span style="color:${color};font-weight:600">${kind}</span>
${a.server_name || '—'} · ${label} ${a.value || ''}
${time ? `<span style="opacity:0.7"> @ ${time}</span>` : ''}
</div>`
})
const more = alerts.length > 8 ? `<div style="font-size:11px;opacity:0.7">还有 ${alerts.length - 8} 条…</div>` : ''
return `<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(128,128,128,0.25)">
<div style="font-weight:600;font-size:12px;margin-bottom:4px">告警 ${alerts.length} 条(10 分钟窗口)</div>
${lines.join('')}${more}
</div>`
}
function buildTooltipHtml(p: FleetTrendPoint, idx: number): string {
const time = formatPointTimeBeijing(p.recorded_at)
const prev = idx > 0 ? props.points[idx - 1] : null
const row = (dot: string, label: string, val: number | null, prevVal: number | null, unit: string) => {
let delta = ''
if (val != null && prevVal != null) {
const d = Math.round((val - prevVal) * 10) / 10
const color = d > 0 ? '#FB8C00' : d < 0 ? '#4CAF50' : '#9E9E9E'
const sign = d > 0 ? '+' : ''
delta = `<span style="color:${color};font-size:11px;margin-left:6px">${sign}${d}${unit}</span>`
}
const display = val != null ? `<b>${val}${unit}</b>` : '—'
return `<tr>
<td style="padding:2px 8px 2px 0;white-space:nowrap">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${dot};margin-right:6px"></span>${label}
</td>
<td style="padding:2px 0;text-align:right">${display}${delta}</td>
</tr>`
}
const coverage =
p.sample_count != null && p.online_count > 0
? Math.round((p.sample_count / p.online_count) * 100)
: null
const meta = [
`在线 <b>${p.online_count}</b> 台`,
p.sample_count != null ? `采样 <b>${p.sample_count}</b>` : null,
coverage != null ? `覆盖率 <b>${coverage}%</b>` : null,
]
.filter(Boolean)
.join(' · ')
return `<div style="min-width:200px;line-height:1.5">
<div style="font-weight:600;font-size:13px;margin-bottom:8px">${time}</div>
<table style="width:100%;border-collapse:collapse;font-size:12px">${[
row(themeColor('primary'), 'CPU', p.cpu_avg, prev?.cpu_avg ?? null, '%'),
row(themeColor('warning'), '内存', p.mem_avg, prev?.mem_avg ?? null, '%'),
row('#9C27B0', '磁盘', p.disk_avg, prev?.disk_avg ?? null, '%'),
row(themeColor('success'), '在线', p.online_count, prev?.online_count ?? null, ' 台'),
].join('')}</table>
<div style="margin-top:8px;font-size:11px;opacity:0.75">${meta}</div>
${buildAlertsHtml(p)}
</div>`
}
const defaultZoomStart = computed(() => {
if (props.zoomStart != null) return props.zoomStart
return props.hours >= 168 ? 65 : 0
})
const defaultZoomEnd = computed(() => props.zoomEnd ?? 100)
const chartOption = computed((): ECOption => {
const dark = theme.current.value.dark
const text = dark ? 'rgba(255,255,255,0.78)' : 'rgba(0,0,0,0.65)'
const axisLine = dark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.1)'
const splitLine = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)'
const primary = themeColor('primary')
const warning = themeColor('warning')
const purple = '#9C27B0'
const success = themeColor('success')
const error = themeColor('error')
const alertScatter = alertScatterData()
return {
animationDuration: 600,
animationEasing: 'cubicOut',
color: [primary, warning, purple, success, error],
grid: {
left: 12,
right: 16,
top: 52,
bottom: 78,
containLabel: true,
},
legend: {
top: 6,
left: 'center',
textStyle: { color: text, fontSize: 12 },
itemWidth: 18,
itemHeight: 8,
itemGap: 20,
selected: {
在线台数: false,
告警: alertScatter.length > 0,
},
},
tooltip: {
trigger: 'axis',
confine: true,
axisPointer: {
type: 'cross',
snap: true,
crossStyle: { color: axisLine },
lineStyle: { color: primary, type: 'dashed', width: 1 },
label: {
backgroundColor: dark ? '#333' : '#666',
formatter: (params) => {
const axisDimension = (params as { axisDimension?: string }).axisDimension
const value = (params as { value?: number | string }).value
if (axisDimension === 'x' && value != null) {
return formatAxisTime(Number(value), true)
}
if (axisDimension === 'y' && value != null) {
return `${Math.round(Number(value) * 10) / 10}`
}
return ''
},
},
},
backgroundColor: dark ? 'rgba(28,28,32,0.97)' : 'rgba(255,255,255,0.98)',
borderColor: axisLine,
borderWidth: 1,
padding: [10, 12],
extraCssText: 'box-shadow:0 8px 24px rgba(0,0,0,0.18);border-radius:8px;',
textStyle: { color: dark ? '#eee' : '#222' },
formatter(params) {
const rows = Array.isArray(params) ? params : [params]
const first = rows[0] as { axisValue?: number | string }
const axisVal = first?.axisValue
if (axisVal == null) return ''
const ts = typeof axisVal === 'number' ? axisVal : Number(axisVal)
const idx = findNearestPointIndex(props.points, ts)
if (idx < 0 || idx >= props.points.length) return ''
return buildTooltipHtml(props.points[idx], idx)
},
},
toolbox: {
right: 12,
top: 4,
itemSize: 14,
iconStyle: { borderColor: text },
feature: {
dataZoom: { yAxisIndex: 'none', title: { zoom: '框选', back: '还原' } },
restore: { title: '重置' },
saveAsImage: { title: '导出图片', name: 'nexus-fleet-trend', pixelRatio: 2 },
},
},
dataZoom: [
{
type: 'inside',
start: defaultZoomStart.value,
end: defaultZoomEnd.value,
minValueSpan: INTERVAL_MS,
},
{
type: 'slider',
height: 24,
bottom: 10,
start: defaultZoomStart.value,
end: defaultZoomEnd.value,
borderColor: axisLine,
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)',
fillerColor: dark ? 'rgba(25,118,210,0.2)' : 'rgba(25,118,210,0.15)',
handleStyle: { color: primary, borderColor: primary },
moveHandleStyle: { color: primary },
textStyle: { color: text, fontSize: 10 },
labelFormatter: (value: number) => formatAxisTime(value, true),
},
],
xAxis: {
type: 'time',
minInterval: INTERVAL_MS,
maxInterval: props.hours >= 168 ? 6 * 60 * 60 * 1000 : 60 * 60 * 1000,
axisLine: { lineStyle: { color: axisLine } },
axisLabel: {
color: text,
fontSize: 10,
hideOverlap: true,
formatter: (value: number) => formatAxisTime(value, true),
},
axisTick: { show: false },
splitLine: {
show: true,
lineStyle: { color: splitLine, type: 'dotted' },
},
minorSplitLine: { show: false },
},
yAxis: [
{
type: 'value',
name: '利用率',
min: 0,
max: 100,
nameTextStyle: { color: text, fontSize: 11, padding: [0, 0, 0, 4] },
axisLabel: { color: text, fontSize: 10, formatter: '{value}%' },
splitLine: { lineStyle: { color: splitLine, type: 'dashed' } },
},
{
type: 'value',
name: '在线台数',
position: 'right',
minInterval: 1,
nameTextStyle: { color: text, fontSize: 11 },
axisLabel: { color: text, fontSize: 10 },
splitLine: { show: false },
},
],
series: [
{
name: 'CPU',
type: 'line',
smooth: 0.28,
showSymbol: false,
symbolSize: 7,
connectNulls: false,
emphasis: { focus: 'series', scale: true },
lineStyle: { width: 2.5 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${primary}40` },
{ offset: 1, color: `${primary}06` },
],
},
},
markLine: {
symbol: 'none',
silent: true,
data: thresholdMarkLine(props.thresholds.cpu, 'CPU 告警', error),
},
markPoint: resourceMarkPoint(),
data: toSeriesData('cpu_avg'),
},
{
name: '内存',
type: 'line',
smooth: 0.28,
showSymbol: false,
connectNulls: false,
emphasis: { focus: 'series' },
lineStyle: { width: 2 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${warning}35` },
{ offset: 1, color: `${warning}05` },
],
},
},
markLine: {
symbol: 'none',
silent: true,
data: thresholdMarkLine(props.thresholds.mem, '内存告警', error),
},
data: toSeriesData('mem_avg'),
},
{
name: '磁盘',
type: 'line',
smooth: 0.28,
showSymbol: false,
connectNulls: false,
emphasis: { focus: 'series' },
lineStyle: { width: 2 },
markLine: {
symbol: 'none',
silent: true,
data: thresholdMarkLine(props.thresholds.disk, '磁盘告警', error),
},
data: toSeriesData('disk_avg'),
},
{
name: '在线台数',
type: 'line',
yAxisIndex: 1,
smooth: 0.28,
showSymbol: false,
connectNulls: false,
emphasis: { focus: 'series' },
lineStyle: { width: 2, type: 'dashed', opacity: 0.85 },
data: onlineSeriesData(),
},
...(alertScatter.length
? [
{
name: '告警',
type: 'scatter' as const,
yAxisIndex: 0,
symbol: 'pin',
symbolSize: (data: unknown) => {
const raw = data as [number, number, number] | undefined
const count = raw?.[2] ?? 1
return Math.min(28, 12 + Number(count) * 3)
},
itemStyle: { color: error },
emphasis: { scale: 1.2 },
z: 12,
data: alertScatter,
},
]
: []),
],
}
})
function onDataZoom(evt: { batch?: { start?: number; end?: number }[]; start?: number; end?: number }) {
const batch = evt.batch?.[0]
const startPercent = batch?.start ?? evt.start ?? 0
const endPercent = batch?.end ?? evt.end ?? 100
emit('range-change', { startPercent, endPercent })
}
</script>
<style scoped>
.fleet-echart {
width: 100%;
}
</style>
@@ -0,0 +1,118 @@
<template>
<v-row dense class="fleet-kpi-strip">
<v-col v-for="item in items" :key="item.key" cols="6" sm="3">
<v-sheet rounded="lg" border class="pa-3 fleet-kpi-card" :class="`fleet-kpi-card--${item.key}`">
<div class="d-flex align-center justify-space-between mb-1">
<span class="text-caption text-medium-emphasis text-uppercase">{{ item.label }}</span>
<v-chip
v-if="item.delta != null"
size="x-small"
:color="deltaColor(item.delta)"
variant="tonal"
label
>
{{ formatDelta(item.delta, item.unit) }}
</v-chip>
</div>
<div class="d-flex align-end ga-2 mb-2">
<span class="text-h5 font-weight-bold" :class="valueColor(item)">
{{ formatValue(item.current, item.unit) }}
</span>
<span v-if="item.threshold != null" class="text-caption text-medium-emphasis pb-1">
阈值 {{ item.threshold }}%
</span>
</div>
<v-progress-linear
v-if="item.unit === '%' && item.current != null"
:model-value="item.current"
:color="barColor(item)"
height="6"
rounded
bg-opacity="0.15"
/>
<v-progress-linear
v-else-if="item.max != null && item.current != null && item.max > 0"
:model-value="(item.current / item.max) * 100"
color="success"
height="6"
rounded
bg-opacity="0.15"
/>
<div class="text-caption text-medium-emphasis mt-2">
区间 {{ fmtRange(item) }} · {{ formatValue(item.avg, item.unit) }}
</div>
</v-sheet>
</v-col>
</v-row>
</template>
<script setup lang="ts">
import { formatDelta } from '@/utils/fleetTrendAnalytics'
import type { FleetMetricKey } from '@/utils/fleetTrendAnalytics'
export interface FleetKpiItem {
key: FleetMetricKey
label: string
unit: '%' | '台'
current: number | null
delta: number | null
min: number | null
max: number | null
avg: number | null
threshold?: number
}
defineProps<{
items: FleetKpiItem[]
}>()
function formatValue(v: number | null, unit: string): string {
if (v == null) return '—'
return unit === '%' ? `${v}%` : `${v}`
}
function fmtRange(item: FleetKpiItem): string {
if (item.min == null || item.max == null) return '—'
if (item.unit === '%') return `${item.min}${item.max}%`
return `${item.min}${item.max}`
}
function deltaColor(delta: number): string {
if (delta > 0) return 'warning'
if (delta < 0) return 'success'
return 'default'
}
function valueColor(item: FleetKpiItem): string {
if (item.unit !== '%' || item.current == null || item.threshold == null) return ''
if (item.current >= item.threshold) return 'text-error'
if (item.current >= item.threshold - 10) return 'text-warning'
return 'text-success'
}
function barColor(item: FleetKpiItem): string {
if (item.current == null || item.threshold == null) return 'primary'
if (item.current >= item.threshold) return 'error'
if (item.current >= item.threshold - 10) return 'warning'
return 'primary'
}
</script>
<style scoped>
.fleet-kpi-card {
border-left-width: 3px !important;
border-left-style: solid !important;
}
.fleet-kpi-card--cpu {
border-left-color: rgb(var(--v-theme-primary)) !important;
}
.fleet-kpi-card--mem {
border-left-color: rgb(var(--v-theme-warning)) !important;
}
.fleet-kpi-card--disk {
border-left-color: #9c27b0 !important;
}
.fleet-kpi-card--online {
border-left-color: rgb(var(--v-theme-success)) !important;
}
</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-breadcrumbs :items="p.breadcrumbs" class="pa-0 mb-2">
<template #item="{ item, index }">
<v-breadcrumbs-item
:disabled="item.disabled"
:class="{ 'text-primary': !item.disabled, 'cursor-pointer': !item.disabled }"
@click="p.onBreadcrumbClick(index)"
>
{{ item.title }}
</v-breadcrumbs-item>
</template>
<template #divider>/</template>
</v-breadcrumbs>
<v-card v-if="p.selectedFileCount > 0" class="mb-4" color="primary" variant="tonal" rounded="lg">
<v-card-text class="d-flex align-center py-2">
<span class="text-body-2 mr-4">已选择 {{ p.selectedFileCount }} 个文件</span>
<v-spacer />
<v-btn size="small" variant="text" color="error" @click="p.batchDelete">批量删除</v-btn>
<v-btn size="small" variant="text" @click="p.selectedFiles = []">取消选择</v-btn>
</v-card-text>
</v-card>
</template>
@@ -0,0 +1,180 @@
<script setup lang="ts">
import FilePermissionDialog from '@/components/FilePermissionDialog.vue'
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-dialog v-model="p.showUpload" max-width="500">
<v-card border>
<v-card-title>上传文件</v-card-title>
<v-card-text>
<v-file-input v-model="p.uploadFiles" label="选择文件" variant="outlined" multiple show-size />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showUpload = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.uploading" @click="p.doUpload">上传</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showNewFile" max-width="400">
<v-card border>
<v-card-title>新建文件</v-card-title>
<v-card-text>
<v-text-field
v-model="p.newFileName"
label="文件名"
variant="outlined"
density="compact"
:rules="[p.validatePathSegment]"
placeholder="example.conf"
autofocus
@keydown.enter="p.doNewFile"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showNewFile = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doNewFile">创建</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showMkdir" max-width="400">
<v-card border>
<v-card-title>新建目录</v-card-title>
<v-card-text>
<v-text-field
v-model="p.mkdirName"
label="目录名"
variant="outlined"
density="compact"
:rules="[p.validatePathSegment]"
@keydown.enter="p.doMkdir"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showMkdir = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="p.doMkdir">创建</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showFileDelete" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>
确定要删除 <strong>{{ p.deletingFile?.name }}</strong>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showFileDelete = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="p.doDeleteFile">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showRename" max-width="400">
<v-card border>
<v-card-title>重命名</v-card-title>
<v-card-text>
<v-text-field
v-model="p.newName"
label="新名称"
variant="outlined"
density="compact"
:rules="[p.required('名称')]"
autofocus
@keydown.enter="p.doRename"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showRename = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="p.doRename">确定</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<FilePermissionDialog
v-model="p.showChmod"
:file="p.chmodFile"
:server-id="p.selectedServer"
:loading="p.chmodLoading"
@apply="p.doChmod"
/>
<v-dialog v-model="p.showCompress" max-width="440">
<v-card border>
<v-card-title>压缩</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">将压缩 {{ p.compressPaths.length }} 项到当前目录</p>
<v-text-field v-model="p.compressDest" label="压缩包路径" variant="outlined" density="compact" />
<v-select
v-model="p.compressFormat"
:items="[
{ title: 'tar.gz', value: 'tar.gz' },
{ title: 'zip', value: 'zip' },
]"
item-title="title"
item-value="value"
label="格式"
variant="outlined"
density="compact"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showCompress = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doCompress">压缩</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showDecompress" max-width="440">
<v-card border>
<v-card-title>解压</v-card-title>
<v-card-text>
<p class="text-body-2 mb-2 text-truncate">{{ p.decompressSource }}</p>
<v-text-field v-model="p.decompressDest" label="解压到目录" variant="outlined" density="compact" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showDecompress = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="p.actionLoading" @click="p.doDecompress">解压</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="p.showPreview" max-width="900" scrollable>
<v-card border>
<v-card-title class="d-flex align-center">
<span class="text-truncate">{{ p.previewTitle }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="p.showPreview = false" />
</v-card-title>
<v-divider />
<v-card-text style="max-height: 70vh">
<v-progress-linear v-if="p.previewLoading" indeterminate class="mb-2" />
<pre v-else class="files-preview-body text-body-2">{{ p.previewContent }}</pre>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="p.showPreview = false">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.files-preview-body {
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
margin: 0;
}
</style>
+257
View File
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
import {
FILES_TABLE_ITEMS_PER_PAGE,
FILES_TABLE_ITEMS_PER_PAGE_OPTIONS,
} from '@/composables/files/constants'
const p = useFilesPageContext()
</script>
<template>
<v-card
elevation="0"
border
rounded="lg"
class="position-relative"
:class="{ 'files-drop-active': p.dropActive }"
@dragenter.prevent="p.dropActive = true"
@dragover.prevent="p.dropActive = true"
@dragleave.prevent="p.dropActive = false"
@drop.prevent="p.onDropFiles"
>
<v-progress-linear
v-if="p.loadingVisible"
indeterminate
color="primary"
height="2"
class="files-table-progress"
/>
<v-data-table
v-model="p.selectedFiles"
:items="p.displayedFiles"
:headers="p.fileHeaders"
:loading="p.loadingVisible"
:items-per-page="FILES_TABLE_ITEMS_PER_PAGE"
:items-per-page-options="[...FILES_TABLE_ITEMS_PER_PAGE_OPTIONS]"
show-select
return-object
hover
density="compact"
item-value="name"
:row-props="p.fileRowProps"
>
<template #item.name="{ item }">
<div class="d-flex align-center ga-2 min-w-0">
<v-icon :color="p.entryIconColor(item)" size="18">
{{ p.entryIcon(item) }}
</v-icon>
<v-icon
v-if="p.isEntryLikelyUnreadable(item, p.sshUserForFiles)"
size="18"
color="warning"
title="当前 SSH 用户可能无法读取"
>
mdi-shield-lock
</v-icon>
<div class="min-w-0">
<span :class="{ 'font-weight-medium': item.type === 'directory' }">{{ item.name }}</span>
<div
v-if="item.type === 'symlink' && item.link_target"
class="text-caption text-medium-emphasis text-truncate"
>
{{ item.link_target }}
</div>
</div>
</div>
</template>
<template #item.owner="{ item }">
<v-tooltip :text="p.permissionTooltip(item)" location="top">
<template #activator="{ props: tipProps }">
<v-chip
v-bind="tipProps"
size="small"
variant="tonal"
:color="p.ownerChipColor(item.owner || '')"
class="text-none"
>
{{ p.formatOwnerChip(item) }}
</v-chip>
</template>
</v-tooltip>
</template>
<template #item.size="{ item }">
<span class="text-medium-emphasis">
{{ item.type === 'directory' ? '—' : p.formatSize(item.size) }}
</span>
</template>
<template #item.modified="{ item }">
<span class="text-medium-emphasis">{{ item.modified || '—' }}</span>
</template>
<template #item.actions="{ item }">
<div class="files-row-actions">
<v-btn
v-if="p.isRegularFile(item)"
variant="text"
size="x-small"
color="primary"
density="compact"
@click.stop="p.editFile(item)"
>
编辑
</v-btn>
<v-btn
v-if="p.isRegularFile(item)"
variant="text"
size="x-small"
density="compact"
@click.stop="p.previewFile(item)"
>
查看
</v-btn>
<v-btn
variant="text"
size="x-small"
color="error"
density="compact"
@click.stop="p.confirmDeleteFile(item)"
>
删除
</v-btn>
</div>
</template>
<template #no-data>
<div
v-if="p.pageReady && !p.loading && !p.loadingVisible && p.files.length === 0"
class="text-center text-medium-emphasis py-8"
>
<v-icon size="40" class="mb-2">mdi-folder-open-outline</v-icon>
<div>{{ p.emptyHint }}</div>
<div v-if="p.selectedServer" class="text-caption mt-2 text-disabled">
单击目录进入 · 双击文件编辑 · Ctrl+A 全选 · Ctrl+C/V 复制粘贴
</div>
</div>
</template>
</v-data-table>
</v-card>
<v-menu v-model="p.showContextMenu" :target="[p.contextX, p.contextY]" location="bottom start">
<v-list density="compact" nav>
<v-list-item
v-if="p.contextFile && p.contextFile.type === 'directory'"
prepend-icon="mdi-folder-open"
@click="p.contextAction('open')"
>
<v-list-item-title>打开</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
prepend-icon="mdi-pencil"
@click="p.contextAction('edit')"
>
<v-list-item-title>编辑</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
prepend-icon="mdi-eye"
@click="p.contextAction('preview')"
>
<v-list-item-title>查看</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile"
prepend-icon="mdi-content-copy"
@click="p.contextAction('copy')"
>
<v-list-item-title>复制 (Ctrl+C)</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile"
prepend-icon="mdi-content-cut"
@click="p.contextAction('cut')"
>
<v-list-item-title>剪切 (Ctrl+X)</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-content-paste"
:disabled="!p.canPasteHere"
@click="p.contextAction('paste')"
>
<v-list-item-title>粘贴到当前目录 (Ctrl+V)</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile?.type === 'directory' && p.canPasteHere"
prepend-icon="mdi-folder-arrow-down"
@click="p.contextAction('pasteInto')"
>
<v-list-item-title>粘贴到{{ p.contextFile.name }}</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-link-variant" @click="p.contextAction('copyPath')">
<v-list-item-title>复制路径</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-console" @click="p.contextAction('terminal')">
<v-list-item-title>终端</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item
v-if="p.contextFile"
prepend-icon="mdi-server-network"
@click="p.contextAction('sendToServer')"
>
<v-list-item-title>发送到其他服务器</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && (p.isRegularFile(p.contextFile) || p.contextFile.type === 'directory')"
prepend-icon="mdi-zip-box"
@click="p.contextAction('compress')"
>
<v-list-item-title>压缩</v-list-item-title>
</v-list-item>
<v-list-item
v-if="p.contextFile && p.isArchiveName(p.contextFile.name)"
prepend-icon="mdi-package-up"
@click="p.contextAction('decompress')"
>
<v-list-item-title>解压</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-file-rename-box" @click="p.contextAction('rename')">
<v-list-item-title>重命名</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-lock" @click="p.contextAction('chmod')">
<v-list-item-title>修改权限</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-delete" @click="p.contextAction('delete')">
<v-list-item-title class="text-error">删除</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<style scoped>
.files-table-progress {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
}
.files-drop-active {
outline: 2px dashed rgb(var(--v-theme-primary));
outline-offset: -2px;
}
.files-row-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 2px;
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-alert
v-if="p.filesAccessHint"
:type="p.filesAccessHint.type"
variant="tonal"
closable
prominent
density="comfortable"
class="mb-4"
icon="mdi-shield-lock-outline"
@click:close="p.dismissFilesAccessHint"
>
<div class="d-flex flex-wrap align-center justify-space-between ga-2">
<span>{{ p.filesAccessHint.text.split('目标机安装示例')[0].trim() }}</span>
<v-btn
v-if="p.sshUserForFiles && p.sshUserForFiles !== 'root'"
size="small"
variant="flat"
color="primary"
prepend-icon="mdi-auto-fix"
:loading="p.setupSudoLoading"
:disabled="p.setupSudoDone"
@click.stop="p.setupFilesSudo"
>
{{ p.setupSudoDone ? '已配置' : '一键配置 sudo 权限' }}
</v-btn>
</div>
</v-alert>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<div class="text-caption text-medium-emphasis mb-2 d-flex flex-wrap align-center ga-2">
<span>{{ p.statusSummary }}</span>
<v-chip v-if="p.browseError" size="x-small" color="warning" variant="tonal">
{{ p.browseError }}
</v-chip>
<v-chip
v-if="p.hasClipboard"
size="x-small"
color="info"
variant="tonal"
class="cursor-pointer"
@click="() => p.pasteClipboard()"
>
{{ p.clipboardSummary }} {{ p.pasteDestLabel }} · Ctrl+V 粘贴
</v-chip>
</div>
</template>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { useFilesPageContext } from '@/composables/files/filesPageContext'
const p = useFilesPageContext()
</script>
<template>
<v-card elevation="0" border rounded="lg" class="mb-4 pa-4">
<v-row align="center" dense>
<v-col cols="12" sm="4">
<v-autocomplete
v-model="p.selectedServer"
v-model:search="p.serverSearchQuery"
:items="p.filteredServerList"
:loading="p.serversLoading"
item-title="name"
item-value="id"
label="选择服务器"
placeholder="搜索名称 / 域名 / 路径 / ID"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-server"
:custom-filter="() => true"
@update:model-value="p.onServerChange"
>
<template #item="{ props: itemProps, item }">
<v-list-item
v-bind="itemProps"
:subtitle="item.domain || item.target_path"
>
<template #prepend>
<v-icon
:color="item.is_online ? 'success' : 'grey'"
size="8"
class="mr-2"
>
mdi-circle
</v-icon>
</template>
</v-list-item>
</template>
</v-autocomplete>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
v-model="p.currentPath"
label="路径"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-folder"
append-inner-icon="mdi-arrow-right"
@click:append-inner="p.browseCurrent"
@keydown.enter="p.browseCurrent"
/>
</v-col>
<v-col cols="12" sm="3">
<v-text-field
v-model="p.fileFilter"
label="筛选文件名"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
/>
</v-col>
<v-col cols="12" sm="3">
<v-select
v-model="p.extFilter"
:items="p.FILE_EXT_FILTERS"
item-title="title"
item-value="value"
label="类型"
variant="outlined"
density="compact"
hide-details
clearable
/>
</v-col>
<v-col cols="12" sm="2">
<v-select
v-model="p.sortBy"
:items="p.sortOptions"
item-title="title"
item-value="value"
label="排序"
variant="outlined"
density="compact"
hide-details
/>
</v-col>
<v-col cols="12" class="d-flex ga-2 flex-wrap justify-end">
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="p.showUpload = true">
上传
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-file-plus"
@click="p.openNewFileDialog()"
>
新建文件
</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-folder-plus" @click="p.showMkdir = true">
新建目录
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-zip-box"
:disabled="!p.selectedServer || !p.selectedFileCount"
@click="p.openCompressDialog()"
>
压缩
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-server-network"
:disabled="!p.selectedServer || !p.selectedFileCount"
@click="p.goToServerTransfer()"
>
发送到其他服务器
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-content-paste"
:disabled="!p.canPasteHere"
@click="() => p.pasteClipboard()"
>
粘贴
</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-arrow-up" :disabled="!p.canGoUp" @click="p.goUp">
上级
</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="p.browseCurrent">刷新</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-console"
:disabled="!p.selectedServer"
@click="p.openInTerminal"
>
终端
</v-btn>
</v-col>
</v-row>
</v-card>
</template>
@@ -0,0 +1,71 @@
<template>
<v-dialog :model-value="modelValue" max-width="520" @update:model-value="$emit('update:modelValue', $event)">
<v-card border>
<v-card-title>推送失败诊断 {{ targetName }}</v-card-title>
<v-card-text>
<v-progress-linear v-if="loading" indeterminate class="mb-3" />
<template v-else-if="result">
<v-list density="compact">
<v-list-item title="SSH 连接">
<template #append>
<v-icon :color="result.ssh_ok ? 'success' : 'error'">
{{ result.ssh_ok ? 'mdi-check' : 'mdi-close' }}
</v-icon>
</template>
</v-list-item>
<v-list-item v-if="result.disk_avail" :title="`磁盘可用: ${result.disk_avail}`" />
<v-list-item
v-if="result.disk_used_pct != null"
:title="`磁盘使用率: ${result.disk_used_pct}%`"
/>
<v-list-item title="目标路径存在">
<template #append>
<v-icon :color="result.path_exists ? 'success' : 'error'">
{{ result.path_exists ? 'mdi-check' : 'mdi-close' }}
</v-icon>
</template>
</v-list-item>
<v-list-item v-if="result.path_perms" :subtitle="result.path_perms" title="权限" />
<v-list-item title="目标路径可写">
<template #append>
<v-icon :color="result.path_writable ? 'success' : 'error'">
{{ result.path_writable ? 'mdi-check' : 'mdi-close' }}
</v-icon>
</template>
</v-list-item>
</v-list>
<v-alert
v-for="(err, i) in result.errors"
:key="i"
type="error"
density="compact"
variant="tonal"
class="mt-2"
>
{{ formatPushErrorMessage(err) }}
</v-alert>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { formatPushErrorMessage } from '@/composables/push/labels'
import type { DiagnoseResult } from '@/composables/push/types'
defineProps<{
modelValue: boolean
loading: boolean
result: DiagnoseResult | null
targetName?: string
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>
@@ -0,0 +1,164 @@
<template>
<v-card elevation="0" border rounded="lg">
<v-card-title class="d-flex flex-wrap align-center ga-2">
<span>推送历史</span>
<v-spacer />
<v-select
:model-value="statusFilter"
:items="statusOptions"
item-title="label"
item-value="value"
label="状态"
variant="outlined"
density="compact"
hide-details
clearable
style="max-width: 120px"
@update:model-value="$emit('update:statusFilter', $event); $emit('filter-change')"
/>
<v-select
:model-value="syncModeFilter"
:items="syncModeOptions"
item-title="label"
item-value="value"
label="模式"
variant="outlined"
density="compact"
hide-details
clearable
style="max-width: 120px"
@update:model-value="$emit('update:syncModeFilter', $event); $emit('filter-change')"
/>
<v-select
:model-value="serverFilter"
:items="serverOptions"
item-title="title"
item-value="value"
label="服务器"
variant="outlined"
density="compact"
hide-details
clearable
style="max-width: 180px"
@update:model-value="$emit('update:serverFilter', $event); $emit('filter-change')"
/>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-wrench"
:loading="reconciling"
@click="$emit('reconcile')"
>
修复卡住记录
</v-btn>
</v-card-title>
<v-data-table-server
:items="items"
:headers="headers"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="15"
hover
density="comfortable"
@update:page="$emit('update:page', $event); $emit('page-change')"
>
<template #item.server_name="{ item }">
<span
class="text-body-2 push-history-server-name"
:title="item.server_name ?? ''"
>{{ item.server_name }}</span>
</template>
<template #item.status="{ item }">
<v-chip :color="logStatusColor(item.status)" size="x-small" variant="tonal" label border="sm">
{{ logStatusLabel(item.status) }}
</v-chip>
</template>
<template #item.sync_mode="{ item }">
<span class="text-caption">{{ syncModeLabel(item.sync_mode) }}</span>
</template>
<template #item.started_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatLogTime(item.started_at) }}</span>
</template>
<template #item.push_permission="{ item }">
<span
class="text-caption"
:class="item.push_permission ? '' : 'text-medium-emphasis'"
:title="item.push_permission || '本次推送未配置 rsync 属主/权限修正'"
>{{ formatPushPermission(item.push_permission) }}</span>
</template>
<template #item.error_message="{ item }">
<span
v-if="item.error_message"
class="text-caption text-error text-truncate d-inline-block"
style="max-width: 200px"
:title="formatPushErrorMessage(item.error_message)"
>{{ formatPushErrorMessage(item.error_message) }}</span>
<span v-else class="text-caption text-medium-emphasis"></span>
</template>
<template #item.actions="{ item }">
<v-btn
v-if="item.status === 'failed'"
size="x-small"
variant="tonal"
prepend-icon="mdi-stethoscope"
@click="$emit('diagnose', item)"
>
诊断
</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
</template>
</v-data-table-server>
</v-card>
</template>
<script setup lang="ts">
import {
formatLogTime,
formatPushErrorMessage,
formatPushPermission,
logStatusColor,
logStatusLabel,
syncModeLabel,
} from '@/composables/push/labels'
import type { PushItem } from '@/types/api'
defineProps<{
items: PushItem[]
headers: { title: string; key: string; width?: number; sortable?: boolean }[]
total: number
loading: boolean
page: number
statusFilter: string | null
syncModeFilter: string | null
serverFilter: number | null
statusOptions: { label: string; value: string }[]
syncModeOptions: { label: string; value: string }[]
serverOptions: { title: string; value: number }[]
reconciling: boolean
}>()
defineEmits<{
'update:page': [page: number]
'update:statusFilter': [value: string | null]
'update:syncModeFilter': [value: string | null]
'update:serverFilter': [value: number | null]
'filter-change': []
'page-change': []
reconcile: []
diagnose: [item: PushItem]
}>()
</script>
<style scoped>
.push-history-server-name {
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
</style>
@@ -0,0 +1,104 @@
<template>
<v-dialog :model-value="modelValue" max-width="800" @update:model-value="$emit('update:modelValue', $event)">
<v-card border>
<v-card-title class="d-flex align-center">
推送预览
<v-spacer />
<span class="text-caption text-medium-emphasis">
<template v-if="loading">{{ previewDone }} / {{ previewTotal }} </template>
<template v-else>{{ results.length }} </template>
</span>
</v-card-title>
<v-card-text>
<v-progress-linear
v-if="loading"
:model-value="previewTotal ? Math.round((previewDone / previewTotal) * 100) : 0"
class="mb-3"
/>
<v-alert
v-if="largeBatch && !loading"
type="info"
density="compact"
variant="tonal"
class="mb-2"
>
已预览 {{ results.length }} 台服务器超过 {{ warnThreshold }} 台时推送耗时可能较长
</v-alert>
<div v-if="!loading" class="mb-2 text-body-2">
将推送到 <strong>{{ selectedCount }}</strong> 台服务器
</div>
<v-table v-if="results.length" density="compact">
<thead>
<tr>
<th>服务器</th>
<th>文件数</th>
<th>预计传输</th>
<th>大小</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<tr v-for="row in results" :key="row.server_id">
<td>
<span class="push-preview-server-name" :title="row.server_name">{{ row.server_name }}</span>
</td>
<td>{{ row.stats?.files_total ?? '—' }}</td>
<td>{{ row.stats?.files_transferred ?? '—' }}</td>
<td>{{ row.stats ? formatSize(row.stats.transfer_size_bytes ?? 0) : '—' }}</td>
<td>
<span v-if="row.error" class="text-error text-caption">{{ formatPushErrorMessage(row.error) }}</span>
<span
v-else-if="syncMode === 'full' && (row.stats?.files_deleted ?? 0) > 0"
class="text-warning text-caption"
>将删除 {{ row.stats?.files_deleted }} 个远端文件</span>
<span v-else class="text-caption text-medium-emphasis"></span>
</td>
</tr>
</tbody>
</v-table>
<v-alert v-if="errorCount > 0 && !loading" type="warning" density="compact" variant="tonal" class="mt-3">
{{ errorCount }} 台预览失败推送时这些服务器可能仍会失败
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">关闭</v-btn>
<v-btn color="primary" variant="flat" :disabled="loading" @click="$emit('confirm-push')">确认推送</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { formatPushErrorMessage, formatSize } from '@/composables/push/labels'
import type { PreviewServerRow, SyncMode } from '@/composables/push/types'
defineProps<{
modelValue: boolean
loading: boolean
results: PreviewServerRow[]
selectedCount: number
previewDone: number
previewTotal: number
largeBatch: boolean
warnThreshold: number
errorCount: number
syncMode: SyncMode
}>()
defineEmits<{
'update:modelValue': [value: boolean]
'confirm-push': []
}>()
</script>
<style scoped>
.push-preview-server-name {
display: inline-block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
</style>
@@ -0,0 +1,99 @@
<template>
<v-card v-if="items.length > 0" class="mb-4" elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center text-subtitle-1">
推送进度
<v-spacer />
<v-btn
v-if="failedRetryCount > 0 && !pushing"
size="small"
variant="tonal"
color="warning"
class="mr-2"
prepend-icon="mdi-refresh"
:loading="retryingAll"
@click="$emit('retry-all')"
>
重试全部失败 ({{ failedRetryCount }})
</v-btn>
<v-btn
v-if="batchId && items.some(s => s.status === 'running' || s.status === 'pending') && pushing"
size="small"
variant="tonal"
color="error"
class="mr-2"
:loading="cancelling"
@click="$emit('cancel')"
>
取消推送
</v-btn>
<v-chip size="small" variant="tonal" color="primary">
{{ items.filter(s => s.status === 'success').length }}/{{ items.length }} 完成
</v-chip>
</v-card-title>
<v-divider />
<v-card-text>
<v-progress-linear
v-if="pushing || totalProgress > 0"
:model-value="totalProgress"
color="primary"
height="8"
rounded
class="mb-3"
/>
<div v-if="items.length" class="text-caption text-medium-emphasis mb-2">
{{ doneCount }} / {{ items.length }} 台已结束
<span v-if="items.some(s => s.status === 'failed')">
· {{ items.filter(s => s.status === 'failed').length }} 失败
</span>
</div>
<v-list density="compact">
<v-list-item v-for="s in items" :key="s.id">
<template #title>
<span class="push-progress-name" :title="s.name">{{ s.name }}</span>
</template>
<template #append>
<v-icon v-if="s.status === 'pending'" color="grey">mdi-clock-outline</v-icon>
<v-icon v-else-if="s.status === 'running'" color="blue" class="mdi-spin">mdi-loading</v-icon>
<v-icon v-else-if="s.status === 'success'" color="green">mdi-check-circle</v-icon>
<v-icon v-else-if="s.status === 'cancelled'" color="grey">mdi-cancel</v-icon>
<v-icon v-else color="red">mdi-alert-circle</v-icon>
</template>
<template v-if="s.detail" #subtitle>
<span
:class="s.status === 'failed' ? 'text-error' : 'text-medium-emphasis'"
>{{ s.detail }}</span>
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import type { ServerProgress } from '@/composables/push/types'
defineProps<{
items: ServerProgress[]
batchId: string
pushing: boolean
cancelling: boolean
retryingAll: boolean
failedRetryCount: number
doneCount: number
totalProgress: number
}>()
defineEmits<{
cancel: []
'retry-all': []
}>()
</script>
<style scoped>
.push-progress-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,262 @@
<template>
<v-card elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="d-flex align-center flex-wrap ga-2">
<v-checkbox
:model-value="selectAll"
label="全选"
density="compact"
hide-details
class="mr-2"
@update:model-value="$emit('toggle-all')"
/>
<v-combobox
:model-value="serverSearch"
:items="searchHistory"
prepend-inner-icon="mdi-magnify"
label="搜索服务器..."
density="compact"
hide-details
variant="outlined"
rounded
clearable
:auto-select-first="false"
placeholder="输入后回车,或从历史选择"
style="max-width: 240px"
@update:model-value="onSearchUpdate"
@keydown.enter.prevent="$emit('search-commit')"
/>
<v-btn
icon="mdi-refresh"
variant="text"
size="small"
:loading="serversLoading"
title="刷新在线状态"
@click="$emit('refresh')"
/>
<v-spacer />
<v-btn-toggle
v-model="viewMode"
mandatory
density="compact"
color="primary"
variant="outlined"
divided
>
<v-btn value="grid" size="small" icon="mdi-view-grid" title="卡片视图" />
<v-btn value="list" size="small" icon="mdi-view-list" title="列表视图" />
</v-btn-toggle>
</v-card-title>
<v-divider />
<v-card-text class="pa-2">
<template v-if="serversByCategory.length === 0">
<div class="text-center text-medium-emphasis py-6">暂无服务器</div>
</template>
<template v-else>
<div v-for="group in serversByCategory" :key="group.category" class="mb-4">
<div class="d-flex align-center px-2 mb-2">
<v-btn
size="x-small"
variant="text"
:color="isCategoryAllSelected(group.category) ? 'primary' : 'default'"
@click="$emit('toggle-category', group.category)"
>
<v-icon size="16" class="mr-1">
{{ isCategoryAllSelected(group.category) ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline' }}
</v-icon>
{{ group.label }}
</v-btn>
<span class="text-caption text-medium-emphasis ml-1">
({{ group.servers.filter(s => selectedIds.has(s.id)).length }}/{{ group.servers.length }})
</span>
</div>
<!-- 卡片视图 -->
<v-row v-if="viewMode === 'grid'" dense>
<v-col v-for="s in group.servers" :key="s.id" cols="6" sm="4" md="3" lg="2">
<v-card
:color="selectedIds.has(s.id) ? 'primary' : undefined"
:variant="selectedIds.has(s.id) ? 'tonal' : 'outlined'"
rounded="lg"
class="pa-3 cursor-pointer push-server-card"
@click="$emit('toggle-server', s.id)"
>
<div class="d-flex align-center ga-2 min-w-0">
<v-icon :color="serverOnlineColor(s.is_online)" size="12" class="flex-shrink-0">mdi-circle</v-icon>
<span
class="text-body-2 font-weight-medium push-server-card__name"
:title="serverLabelTitle(s)"
>{{ s.name }}</span>
</div>
<div
class="text-caption text-medium-emphasis push-server-card__line"
:title="s.domain || ''"
>{{ s.domain }}</div>
<div
class="text-caption text-medium-emphasis push-server-card__line"
:title="s.target_path || '未配置目标路径'"
>
{{ s.target_path?.trim() || '未配置' }}
</div>
<div v-if="pushStatus[s.id]" class="text-caption mt-1">
<v-chip
:color="pushStatusChipColor(pushStatus[s.id])"
size="x-small"
variant="tonal"
label
border="sm"
>
{{ pushStatus[s.id] }}
</v-chip>
</div>
</v-card>
</v-col>
</v-row>
<!-- 列表视图 -->
<v-table v-else density="compact" class="push-server-list rounded-lg border">
<thead>
<tr>
<th class="push-server-list__col-check" />
<th class="push-server-list__col-online" />
<th>名称</th>
<th>域名</th>
<th>目标路径</th>
<th class="push-server-list__col-status">状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in group.servers"
:key="s.id"
class="push-server-list__row cursor-pointer"
:class="{ 'push-server-list__row--selected': selectedIds.has(s.id) }"
@click="$emit('toggle-server', s.id)"
>
<td @click.stop>
<v-checkbox
:model-value="selectedIds.has(s.id)"
density="compact"
hide-details
@update:model-value="$emit('toggle-server', s.id)"
/>
</td>
<td>
<v-icon :color="serverOnlineColor(s.is_online)" size="12">mdi-circle</v-icon>
</td>
<td class="push-server-list__name-cell">
<span :title="s.name">{{ s.name }}</span>
</td>
<td class="text-caption text-medium-emphasis push-server-list__ellipsis" :title="s.domain || ''">
{{ s.domain || '—' }}
</td>
<td class="text-caption text-medium-emphasis push-server-list__ellipsis" :title="s.target_path || '未配置'">
{{ s.target_path?.trim() || '未配置' }}
</td>
<td>
<v-chip
v-if="pushStatus[s.id]"
:color="pushStatusChipColor(pushStatus[s.id])"
size="x-small"
variant="tonal"
label
border="sm"
>
{{ pushStatus[s.id] }}
</v-chip>
<span v-else class="text-caption text-medium-emphasis"></span>
</td>
</tr>
</tbody>
</v-table>
</div>
</template>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { pushStatusChipColor, serverOnlineColor } from '@/composables/push/labels'
import { usePushServerViewMode } from '@/composables/push/usePushServerViewMode'
import type { PushServerItem } from '@/composables/push/types'
defineProps<{
selectAll: boolean
serverSearch: string
searchHistory: string[]
serversLoading: boolean
serversByCategory: { category: string; label: string; servers: PushServerItem[] }[]
selectedIds: Set<number>
pushStatus: Record<number, string>
isCategoryAllSelected: (category: string) => boolean
}>()
const emit = defineEmits<{
'update:serverSearch': [value: string | null]
'search-commit': []
'toggle-all': []
refresh: []
'toggle-category': [category: string]
'toggle-server': [id: number]
}>()
const { viewMode } = usePushServerViewMode()
function onSearchUpdate(val: string | null) {
emit('update:serverSearch', val)
}
function serverLabelTitle(s: PushServerItem): string {
const parts = [s.name]
if (s.domain?.trim()) parts.push(s.domain.trim())
return parts.join('\n')
}
</script>
<style scoped>
.push-server-card__name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-word;
min-width: 0;
flex: 1 1 auto;
line-height: 1.25;
}
.push-server-card__line {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.push-server-list__col-check {
width: 44px;
}
.push-server-list__col-online {
width: 28px;
}
.push-server-list__col-status {
width: 96px;
}
.push-server-list__row--selected {
background: rgba(var(--v-theme-primary), 0.08);
}
.push-server-list__name-cell {
max-width: 0;
width: 32%;
word-break: break-word;
}
.push-server-list__ellipsis {
max-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.push-server-list :deep(th:nth-child(4)),
.push-server-list :deep(td:nth-child(4)) {
width: 24%;
}
.push-server-list :deep(th:nth-child(5)),
.push-server-list :deep(td:nth-child(5)) {
width: 28%;
}
</style>
@@ -0,0 +1,198 @@
<template>
<v-card elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="d-flex align-center text-subtitle-1 flex-wrap ga-2">
暂存文件
<v-spacer />
<v-btn
v-if="selectedCount > 0"
size="small"
variant="tonal"
color="error"
prepend-icon="mdi-delete"
:loading="deleteLoading"
@click="$emit('batch-delete')"
>
删除选中 ({{ selectedCount }})
</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-refresh" :loading="loading" @click="$emit('refresh')">
刷新
</v-btn>
</v-card-title>
<v-card-subtitle class="text-caption pb-2">
{{ rootPath }}
<span v-if="relativePath"> / {{ relativePath }}</span>
</v-card-subtitle>
<v-divider />
<v-card-text class="pa-0">
<v-progress-linear v-if="loading" indeterminate />
<v-table v-else density="compact" hover>
<thead>
<tr>
<th style="width: 44px">
<v-checkbox-btn
:model-value="allSelected"
:indeterminate="someSelected"
:disabled="!entries.length"
density="compact"
hide-details
@update:model-value="$emit('toggle-select-all', $event)"
/>
</th>
<th>名称</th>
<th class="text-end" style="width: 100px">大小</th>
<th class="text-end" style="width: 160px">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="relativePath">
<td colspan="4">
<v-btn size="small" variant="text" prepend-icon="mdi-arrow-up" @click="$emit('go-root')">
返回根目录
</v-btn>
</td>
</tr>
<tr v-if="!entries.length">
<td colspan="4" class="text-medium-emphasis text-center py-4">目录为空</td>
</tr>
<tr v-for="entry in entries" :key="entry.name">
<td @click.stop>
<v-checkbox-btn
:model-value="isSelected(entry.name)"
density="compact"
hide-details
@update:model-value="$emit('toggle-select', entry.name, $event)"
/>
</td>
<td>
<div
class="d-flex align-center"
:class="{ 'cursor-pointer': entry.is_dir }"
@click="entry.is_dir ? $emit('open-dir', entry) : undefined"
>
<v-icon size="small" class="mr-2" :color="entry.is_dir ? 'primary' : undefined">
{{ entry.is_dir ? 'mdi-folder' : 'mdi-file-outline' }}
</v-icon>
<span>{{ entry.name }}</span>
</div>
</td>
<td class="text-end text-caption text-medium-emphasis">
{{ entry.is_dir ? '—' : formatSize(entry.size) }}
</td>
<td class="text-end">
<v-btn
v-if="!entry.is_dir"
size="x-small"
variant="text"
@click="$emit('preview', entry)"
>
预览
</v-btn>
<v-btn size="x-small" variant="text" @click="$emit('rename', entry)">重命名</v-btn>
<v-btn size="x-small" variant="text" color="error" @click="$emit('delete', entry)">删除</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
<v-dialog :model-value="renameOpen" max-width="420" @update:model-value="$emit('update:renameOpen', $event)">
<v-card border>
<v-card-title>重命名</v-card-title>
<v-card-text>
<v-text-field
:model-value="renameValue"
label="新名称"
variant="outlined"
density="compact"
hide-details
autofocus
@update:model-value="$emit('update:renameValue', $event)"
@keydown.enter.prevent="$emit('confirm-rename')"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:renameOpen', false)">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="renameLoading" @click="$emit('confirm-rename')">确定</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog :model-value="deleteOpen" max-width="480" @update:model-value="$emit('update:deleteOpen', $event)">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>
<template v-if="deleteTargets.length === 1">
确定删除{{ deleteTargets[0].name }}{{ deleteTargets[0].is_dir ? '将删除整个目录及其内容' : '' }}
</template>
<template v-else>
<div class="mb-2">确定删除以下 {{ deleteTargets.length }} </div>
<ul class="delete-target-list pl-4 mb-0">
<li v-for="item in deleteTargets.slice(0, 8)" :key="item.name">
{{ item.name }}{{ item.is_dir ? '目录' : '' }}
</li>
<li v-if="deleteTargets.length > 8"> 等共 {{ deleteTargets.length }} </li>
</ul>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:deleteOpen', false)">取消</v-btn>
<v-btn color="error" variant="flat" :loading="deleteLoading" @click="$emit('confirm-delete')">
{{ deleteTargets.length > 1 ? `删除 ${deleteTargets.length}` : '删除' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { formatSize } from '@/composables/push/labels'
import type { StagingEntry } from '@/composables/push/types'
defineProps<{
rootPath: string
relativePath: string
entries: StagingEntry[]
loading: boolean
selectedCount: number
allSelected: boolean
someSelected: boolean
isSelected: (name: string) => boolean
renameOpen: boolean
renameValue: string
renameLoading: boolean
deleteOpen: boolean
deleteTargets: StagingEntry[]
deleteLoading: boolean
}>()
defineEmits<{
refresh: []
'go-root': []
'open-dir': [entry: StagingEntry]
preview: [entry: StagingEntry]
rename: [entry: StagingEntry]
delete: [entry: StagingEntry]
'batch-delete': []
'toggle-select': [name: string, selected: boolean]
'toggle-select-all': [selected: boolean]
'confirm-rename': []
'confirm-delete': []
'update:renameOpen': [value: boolean]
'update:renameValue': [value: string]
'update:deleteOpen': [value: boolean]
}>()
</script>
<style scoped>
.cursor-pointer {
cursor: pointer;
}
.delete-target-list {
max-height: 200px;
overflow-y: auto;
}
</style>
@@ -0,0 +1,83 @@
<template>
<v-dialog :model-value="modelValue" max-width="720" @update:model-value="$emit('update:modelValue', $event)">
<v-card border>
<v-card-title class="text-subtitle-1">
{{ result?.name ?? '文件预览' }}
<span v-if="result" class="text-caption text-medium-emphasis ml-2">
({{ formatSize(result.size) }})
</span>
</v-card-title>
<v-card-text>
<v-progress-linear v-if="loading" indeterminate class="mb-3" />
<template v-else-if="result">
<v-alert
v-if="result.truncated"
type="info"
density="compact"
variant="tonal"
class="mb-2"
>
仅显示前 4KB 内容
</v-alert>
<v-alert
v-if="result.encoding_hint !== 'text'"
type="warning"
density="compact"
variant="tonal"
class="mb-2"
>
二进制文件以下为 Base64 片段
</v-alert>
<pre class="staging-preview-body">{{ previewText }}</pre>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize } from '@/composables/push/labels'
import type { StagingPreviewResult } from '@/composables/push/types'
const props = defineProps<{
modelValue: boolean
loading: boolean
result: StagingPreviewResult | null
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
const previewText = computed(() => {
const r = props.result
if (!r?.content_base64) return ''
try {
const raw = atob(r.content_base64)
if (r.encoding_hint === 'text') return raw
return r.content_base64
} catch {
return r.content_base64
}
})
</script>
<style scoped>
.staging-preview-body {
max-height: 420px;
overflow: auto;
margin: 0;
padding: 12px;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.04);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -0,0 +1,39 @@
<template>
<v-card elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="text-subtitle-1">同步模式</v-card-title>
<v-card-text>
<v-radio-group
:model-value="syncMode"
inline
density="compact"
@update:model-value="onSyncModeUpdate"
>
<v-radio label="增量同步" value="incremental" />
<v-radio label="全量同步" value="full" />
<v-radio label="校验和同步" value="checksum" />
</v-radio-group>
<v-alert v-if="syncMode === 'full'" type="warning" density="compact" variant="tonal" class="mt-2">
全量同步会删除目标目录中不存在于源目录的文件
</v-alert>
<div class="text-caption text-medium-emphasis mt-1">{{ syncModeDesc }}</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import type { SyncMode } from '@/composables/push/types'
defineProps<{
syncMode: SyncMode
syncModeDesc: string
}>()
const emit = defineEmits<{
'update:syncMode': [value: SyncMode]
}>()
/** Vuetify radio group may emit null when clearing; keep sync mode always defined. */
function onSyncModeUpdate(value: SyncMode | null) {
if (value != null) emit('update:syncMode', value)
}
</script>
@@ -0,0 +1,48 @@
<template>
<v-card elevation="0" border rounded="lg" class="mb-4 pa-4">
<v-row align="center" dense>
<v-col cols="12" sm="8">
<v-text-field
:model-value="targetPath"
label="目标路径"
placeholder="/www/wwwroot"
hint="留空则使用下方各服务器配置的路径;填写则统一覆盖所有选中服务器"
persistent-hint
variant="outlined"
density="compact"
prepend-inner-icon="mdi-folder-marker"
clearable
@update:model-value="$emit('update:targetPath', $event ?? '')"
/>
</v-col>
<v-col cols="12" sm="4" class="d-flex ga-2 justify-end">
<v-btn size="small" variant="tonal" prepend-icon="mdi-eye" @click="$emit('preview')">预览</v-btn>
<v-btn
size="small"
color="primary"
variant="flat"
prepend-icon="mdi-upload"
:loading="submitting"
:disabled="pushing"
@click="$emit('push')"
>
推送
</v-btn>
</v-col>
</v-row>
</v-card>
</template>
<script setup lang="ts">
defineProps<{
targetPath: string
submitting: boolean
pushing: boolean
}>()
defineEmits<{
'update:targetPath': [value: string]
preview: []
push: []
}>()
</script>
@@ -0,0 +1,135 @@
<template>
<v-card elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="text-subtitle-1">源文件</v-card-title>
<v-card-text>
<template v-if="!validatedSource">
<div v-if="!uploadedSource">
<div
class="border-dashed border rounded pa-6 text-center cursor-pointer"
:class="{ 'border-primary bg-primary-lighten-5': isDragging }"
@dragover.prevent="$emit('dragover')"
@dragleave="$emit('dragleave')"
@drop.prevent="$emit('drop', $event)"
@click="fileInputRef?.click()"
>
<v-icon size="48" color="grey">mdi-cloud-upload-outline</v-icon>
<div class="text-body-2 mt-2">拖拽文件到此处或点击选择</div>
<div class="text-caption text-medium-emphasis">
支持任意后缀文件可多选ZIP 拖拽/选择时原样上传
</div>
<div class="mt-3">
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-folder-zip"
:disabled="sourceUploading"
@click.stop="zipInputRef?.click()"
>
ZIP 解压上传
</v-btn>
</div>
</div>
<input
ref="fileInputRef"
type="file"
multiple
hidden
@change="$emit('file-select', $event)"
/>
<input
ref="zipInputRef"
type="file"
accept=".zip,application/zip"
hidden
@change="$emit('zip-extract-select', $event)"
/>
</div>
<div v-else class="d-flex align-center flex-wrap ga-2">
<v-icon class="mr-1">
{{ uploadedSource.kind === 'zip' ? 'mdi-folder-zip' : 'mdi-file-multiple' }}
</v-icon>
<span>
{{ uploadedSource.name }}
({{ uploadedSource.fileCount }} 个文件, {{ formatSize(uploadedSource.size) }})
</span>
<v-spacer />
<v-btn size="small" variant="text" color="error" @click="$emit('clear')">清除</v-btn>
</div>
<v-progress-linear v-if="sourceUploading" indeterminate class="mt-2" />
</template>
<v-alert v-else type="success" variant="tonal" density="compact" class="mb-0">
<div class="d-flex align-center flex-wrap ga-2">
<v-icon>mdi-folder-check</v-icon>
<span class="text-body-2">
{{ validatedSource.path }}
({{ validatedSource.fileCount }} 个文件, {{ formatSize(validatedSource.sizeBytes) }})
</span>
<v-spacer />
<v-btn size="small" variant="text" color="error" @click="$emit('clear-validated')">清除</v-btn>
</div>
</v-alert>
<v-divider v-if="!validatedSource" class="my-4" />
<div v-if="!validatedSource && !uploadedSource">
<div class="text-caption text-medium-emphasis mb-2">或输入 Nexus 主机源路径容器内绝对路径</div>
<div class="d-flex ga-2 align-start flex-wrap">
<v-text-field
:model-value="sourcePath"
placeholder="/path/to/files"
variant="outlined"
density="compact"
hide-details
prepend-inner-icon="mdi-folder"
class="flex-grow-1"
style="min-width: 240px"
@update:model-value="$emit('update:sourcePath', $event)"
@keydown.enter.prevent="$emit('validate')"
/>
<v-btn
variant="tonal"
color="primary"
:loading="sourcePathValidating"
:disabled="!sourcePath.trim()"
@click="$emit('validate')"
>
验证路径
</v-btn>
</div>
</div>
<div v-else-if="uploadedSource" class="text-caption text-medium-emphasis mt-2">
已使用上传暂存目录作为源可在下方管理文件或清除后改用主机源路径
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { formatSize } from '@/composables/push/labels'
import type { UploadedSource, ValidatedSource } from '@/composables/push/types'
defineProps<{
uploadedSource: UploadedSource | null
validatedSource: ValidatedSource | null
sourcePath: string
sourcePathValidating: boolean
isDragging: boolean
sourceUploading: boolean
}>()
defineEmits<{
dragover: []
dragleave: []
drop: [e: DragEvent]
'file-select': [e: Event]
'zip-extract-select': [e: Event]
clear: []
'clear-validated': []
'update:sourcePath': [value: string]
validate: []
}>()
const fileInputRef = ref<HTMLInputElement | null>(null)
const zipInputRef = ref<HTMLInputElement | null>(null)
</script>
@@ -0,0 +1,290 @@
<template>
<div class="schedule-cycle-picker">
<div class="text-caption text-medium-emphasis mb-2">开始执行周期</div>
<v-row dense class="mb-2 align-center">
<v-col cols="auto" class="cycle-type-col">
<span v-if="singleShot" class="cycle-type-fixed text-body-2">当天</span>
<v-select
v-else
:model-value="modelValue.type === 'custom' ? null : modelValue.type"
:items="cycleTypeOptions"
item-title="label"
item-value="value"
label="重复方式"
variant="outlined"
density="compact"
hide-details
class="cycle-type-select"
@update:model-value="onTypeChange"
/>
</v-col>
<!-- 当天 / N / 每周 / 每月: -->
<template v-if="showTimeOfDay">
<v-col cols="auto" class="time-col">
<v-text-field
:model-value="modelValue.hour"
label="时"
type="number"
min="0"
max="23"
variant="outlined"
density="compact"
hide-details
@update:model-value="patch({ hour: toNum($event, 0, 23) })"
/>
</v-col>
<v-col cols="auto" class="time-col">
<v-text-field
:model-value="modelValue.minute"
label="分"
type="number"
min="0"
max="59"
variant="outlined"
density="compact"
hide-details
@update:model-value="patch({ minute: toNum($event, 0, 59) })"
/>
</v-col>
</template>
<!-- 每小时 / N 小时第几分 -->
<template v-if="!singleShot && (modelValue.type === 'hour' || modelValue.type === 'hour-n')">
<v-col cols="auto" class="time-col-wide">
<v-text-field
:model-value="modelValue.minute"
label="第几分"
type="number"
min="0"
max="59"
variant="outlined"
density="compact"
hide-details
@update:model-value="patch({ minute: toNum($event, 0, 59) })"
/>
</v-col>
</template>
</v-row>
<div class="text-caption text-medium-emphasis mb-2">
{{ cycleHint }}
</div>
<!-- N -->
<v-text-field
v-if="!singleShot && modelValue.type === 'day-n'"
:model-value="modelValue.interval"
label="天数 N"
type="number"
min="1"
max="365"
variant="outlined"
density="compact"
hint="从每月 1 日起每 N 天执行(与宝塔一致)"
persistent-hint
class="mb-2"
@update:model-value="patch({ interval: toNum($event, 1, 365) })"
/>
<!-- N 小时 -->
<v-text-field
v-if="!singleShot && modelValue.type === 'hour-n'"
:model-value="modelValue.interval"
label="小时 N"
type="number"
min="1"
max="23"
variant="outlined"
density="compact"
class="mb-2"
@update:model-value="patch({ interval: toNum($event, 1, 23) })"
/>
<!-- N 分钟 -->
<v-text-field
v-if="!singleShot && modelValue.type === 'minute-n'"
:model-value="modelValue.interval"
label="分钟 N"
type="number"
:min="SCHEDULE_MIN_INTERVAL_MINUTES"
max="59"
variant="outlined"
density="compact"
hint="从整点起每 N 分钟触发一次"
persistent-hint
class="mb-2"
@update:model-value="patch({ interval: toNum($event, SCHEDULE_MIN_INTERVAL_MINUTES, 59) })"
/>
<!-- 每周 -->
<v-select
v-if="!singleShot && modelValue.type === 'week'"
:model-value="modelValue.week"
:items="WEEKDAY_OPTIONS"
item-title="label"
item-value="value"
label="星期"
variant="outlined"
density="compact"
class="mb-2"
@update:model-value="patch({ week: toNum($event, 0, 6) })"
/>
<!-- 每月 -->
<v-text-field
v-if="!singleShot && modelValue.type === 'month'"
:model-value="modelValue.monthDay"
label="每月几号"
type="number"
min="1"
max="31"
variant="outlined"
density="compact"
class="mb-2"
@update:model-value="patch({ monthDay: toNum($event, 1, 31) })"
/>
<!-- 无法反解析的历史 Cron -->
<template v-if="modelValue.type === 'custom'">
<v-alert type="warning" density="compact" variant="tonal" class="mb-2 text-caption">
当前 Cron 无法映射为可视化周期可保留原表达式或改用上方周期类型重建
</v-alert>
<v-text-field
:model-value="modelValue.customCron"
label="Cron 表达式(5 字段)"
variant="outlined"
density="compact"
class="mb-2"
@update:model-value="patch({ customCron: String($event ?? '') })"
/>
<v-btn size="small" variant="tonal" color="primary" class="mb-2" @click="switchToVisual">
改用可视化周期
</v-btn>
</template>
<div v-if="previewLabel" class="text-caption text-medium-emphasis">
预览{{ previewLabel }}
<span v-if="previewCron" class="ms-1">{{ previewCron }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import {
CYCLE_TYPE_OPTIONS,
WEEKDAY_OPTIONS,
buildCronFromCycle,
defaultCycleConfig,
formatCycleLabel,
SCHEDULE_MIN_INTERVAL_MINUTES,
type ScheduleCycleConfig,
type ScheduleCycleType,
} from '@/utils/scheduleCycle'
const props = defineProps<{
modelValue: ScheduleCycleConfig
/** 单次执行:在下一匹配时刻跑一次 */
singleShot?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: ScheduleCycleConfig]
}>()
const cycleTypeOptions = CYCLE_TYPE_OPTIONS
const singleShot = computed(() => props.singleShot === true)
const showTimeOfDay = computed(() => {
if (singleShot.value) return true
return ['day', 'day-n', 'week', 'month'].includes(props.modelValue.type)
})
const cycleHint = computed(() => {
const tz = '时间为北京时间(Asia/Shanghai'
if (singleShot.value) {
return `单次:当天指定时:分,在下一匹配时刻执行一次(不含日期选择)。${tz}`
}
return `循环:按重复方式与时:分持续执行。${tz}`
})
/** 单次模式固定为「当天」时:分,不出现「重复方式」 */
watch(
() => props.singleShot,
(isOnce) => {
if (!isOnce) return
if (props.modelValue.type === 'custom') return
if (props.modelValue.type === 'day') return
emit('update:modelValue', { ...defaultCycleConfig(), ...props.modelValue, type: 'day' })
},
{ immediate: true },
)
const previewLabel = computed(() => {
try {
return formatCycleLabel(props.modelValue)
} catch {
return ''
}
})
const previewCron = computed(() => {
try {
if (props.modelValue.type === 'custom' && !props.modelValue.customCron.trim()) return ''
return buildCronFromCycle(props.modelValue)
} catch {
return ''
}
})
function toNum(raw: unknown, min: number, max: number): number {
const n = Math.floor(Number(raw))
if (Number.isNaN(n)) return min
return Math.min(max, Math.max(min, n))
}
function patch(partial: Partial<ScheduleCycleConfig>) {
emit('update:modelValue', { ...props.modelValue, ...partial })
}
function onTypeChange(type: ScheduleCycleType | null) {
if (!type) return
const next = { ...defaultCycleConfig(), ...props.modelValue, type }
emit('update:modelValue', next)
}
function switchToVisual() {
emit('update:modelValue', { ...defaultCycleConfig(), type: 'day' })
}
</script>
<style scoped>
.cycle-type-col {
flex: 0 0 auto;
}
.cycle-type-select {
width: 7.5rem;
max-width: 7.5rem;
}
.cycle-type-fixed {
display: inline-flex;
align-items: center;
height: 40px;
padding: 0 4px;
font-weight: 500;
white-space: nowrap;
}
.time-col {
flex: 0 0 auto;
width: 4.5rem;
max-width: 4.5rem;
}
.time-col-wide {
flex: 0 0 auto;
width: 5.5rem;
max-width: 5.5rem;
}
</style>
@@ -0,0 +1,123 @@
<template>
<v-dialog :model-value="modelValue" max-width="640" scrollable @update:model-value="$emit('update:modelValue', $event)">
<v-card border>
<v-card-title>Agent 诊断 {{ targetName }}</v-card-title>
<v-card-text>
<v-progress-linear v-if="loading" indeterminate class="mb-3" />
<template v-else-if="result">
<v-list density="compact">
<v-list-item title="中心侧状态">
<template #append>
<v-chip
size="x-small"
variant="tonal"
:color="centralStatusColor(result.central_status)"
label
>
{{ centralStatusLabel(result.central_status) }}
</v-chip>
</template>
</v-list-item>
<v-list-item
v-if="result.last_heartbeat_redis"
:title="`Redis 心跳: ${result.last_heartbeat_redis}`"
/>
<v-list-item
v-if="result.last_heartbeat_db"
:title="`MySQL 心跳: ${result.last_heartbeat_db}`"
/>
<v-list-item
:title="`Agent 版本(库): ${result.agent_version_db || '—'}`"
/>
<template v-if="result.remote">
<v-divider class="my-2" />
<v-list-item title="SSH 连接">
<template #append>
<v-icon :color="result.remote.ssh_ok ? 'success' : 'error'">
{{ result.remote.ssh_ok ? 'mdi-check' : 'mdi-close' }}
</v-icon>
</template>
</v-list-item>
<v-list-item
v-if="result.remote.systemctl_active"
:title="`systemctl: ${result.remote.systemctl_active}`"
:subtitle="result.remote.systemctl_detail || undefined"
/>
<v-list-item
v-if="result.remote.central_health_http_code != null"
:title="`/health HTTP: ${result.remote.central_health_http_code}`"
/>
</template>
</v-list>
<v-alert
v-for="(err, i) in result.errors"
:key="i"
type="warning"
density="compact"
variant="tonal"
class="mt-2"
>
{{ err }}
</v-alert>
<v-alert
v-if="result.errors.length === 0"
type="success"
density="compact"
variant="tonal"
class="mt-2"
>
未发现明显异常
</v-alert>
<v-expansion-panels v-if="result.remote?.log_errors" class="mt-3" variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title>错误日志摘录</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption agent-diag-log">{{ result.remote.log_errors }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { AgentDiagnoseResult } from '@/types/agentDiagnose'
defineProps<{
modelValue: boolean
loading: boolean
result: AgentDiagnoseResult | null
targetName?: string
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
function centralStatusColor(status: string): string {
if (status === 'online') return 'success'
if (status === 'offline') return 'error'
return 'default'
}
function centralStatusLabel(status: string): string {
if (status === 'online') return '在线'
if (status === 'offline') return '离线'
return '未知'
}
</script>
<style scoped>
.agent-diag-log {
white-space: pre-wrap;
word-break: break-word;
max-height: 240px;
overflow: auto;
}
</style>
@@ -0,0 +1,84 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="560"
scrollable
@update:model-value="emit('update:modelValue', $event)"
>
<v-card border>
<v-card-title class="d-flex align-center">
{{ title }}
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
size="small"
:disabled="loading"
@click="emit('update:modelValue', false)"
/>
</v-card-title>
<v-divider />
<v-card-text class="pt-4">
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-2" />
<p v-if="loading" class="text-body-2 text-medium-emphasis mb-0">正在加载结果</p>
<template v-else-if="results.length">
<p class="text-body-2 mb-3">
成功 <span class="text-success font-weight-medium">{{ okCount }}</span>
失败 <span class="text-error font-weight-medium">{{ failCount }}</span>
</p>
<v-list density="compact" class="rounded border">
<v-list-item v-for="row in results" :key="row.server_id" class="py-1">
<template #prepend>
<v-icon :color="row.success ? 'success' : 'error'" size="18">
{{ row.success ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</template>
<v-list-item-title class="text-body-2">
{{ row.server_name?.trim() || '未知服务器' }}
</v-list-item-title>
<v-list-item-subtitle
v-if="row.success && row.stdout"
class="text-caption text-medium-emphasis"
>
{{ row.stdout }}
</v-list-item-subtitle>
<v-list-item-subtitle
v-else-if="!row.success && row.error"
class="text-caption text-error"
>
{{ row.error }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</template>
<p v-else class="text-medium-emphasis mb-0">无结果</p>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn color="primary" variant="flat" :disabled="loading" @click="emit('update:modelValue', false)">
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { BatchAgentResultItem } from '@/utils/serverSelection'
const props = defineProps<{
modelValue: boolean
title: string
loading: boolean
results: BatchAgentResultItem[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const okCount = computed(() => props.results.filter(r => r.success).length)
const failCount = computed(() => props.results.length - okCount.value)
</script>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { ServerAgentAction } from '@/types/api'
const props = defineProps<{
action?: ServerAgentAction | null
message?: string | null
compact?: boolean
loading?: boolean
}>()
defineEmits<{
install: []
upgrade: []
diagnose: []
}>()
function alertType(action: ServerAgentAction | null | undefined): 'info' | 'warning' | 'error' {
if (action === 'install') return 'info'
if (action === 'upgrade') return 'warning'
if (action === 'offline') return 'error'
return 'info'
}
</script>
<template>
<v-alert
v-if="props.action && props.action !== 'ok' && props.message"
:type="alertType(props.action)"
variant="tonal"
:density="props.compact ? 'compact' : 'default'"
class="mb-0"
>
<div class="d-flex flex-wrap align-center ga-2">
<span class="text-body-2">{{ props.message }}</span>
<v-spacer v-if="!props.compact" />
<div class="d-flex ga-1 flex-shrink-0">
<v-btn
v-if="props.action === 'install'"
size="small"
variant="flat"
color="primary"
:loading="props.loading"
@click="$emit('install')"
>
安装 Agent
</v-btn>
<v-btn
v-if="props.action === 'upgrade'"
size="small"
variant="flat"
color="warning"
:loading="props.loading"
@click="$emit('upgrade')"
>
升级 Agent
</v-btn>
<v-btn
v-if="props.action === 'offline'"
size="small"
variant="text"
@click="$emit('diagnose')"
>
诊断
</v-btn>
</div>
</div>
</v-alert>
</template>
@@ -0,0 +1,191 @@
<template>
<v-card
v-if="count > 0"
class="server-batch-bar mx-4 mb-2"
variant="flat"
rounded="lg"
border
>
<v-card-text class="server-batch-bar__inner py-3">
<div class="d-flex align-end flex-wrap ga-3">
<div class="server-batch-bar__section">
<span class="server-batch-bar__label">选择</span>
<v-chip
color="primary"
variant="flat"
size="default"
label
class="server-batch-bar__count"
prepend-icon="mdi-checkbox-marked-circle-outline"
>
已选 {{ count }}
</v-chip>
</div>
<v-divider vertical class="server-batch-bar__divider d-none d-sm-flex" />
<div class="server-batch-bar__section">
<span class="server-batch-bar__label">运维</span>
<div class="d-flex flex-wrap ga-2">
<v-btn size="default" variant="flat" color="primary" class="server-batch-bar__btn" prepend-icon="mdi-heart-pulse" @click="$emit('health-check')">
健康检查
</v-btn>
<v-btn size="default" variant="flat" color="primary" class="server-batch-bar__btn" prepend-icon="mdi-folder-search-outline" @click="$emit('detect-path')">
检测路径
</v-btn>
<v-btn
size="default"
variant="flat"
color="primary"
class="server-batch-bar__btn"
prepend-icon="mdi-stethoscope"
:loading="agentDiagnoseLoading"
@click="$emit('agent-diagnose')"
>
诊断
</v-btn>
</div>
</div>
<v-divider vertical class="server-batch-bar__divider d-none d-md-flex" />
<div class="server-batch-bar__section">
<span class="server-batch-bar__label">宝塔</span>
<div class="d-flex flex-wrap ga-2">
<v-btn
size="default"
variant="flat"
color="secondary"
class="server-batch-bar__btn"
prepend-icon="mdi-download-network"
:disabled="!btUnconfiguredCount"
:loading="btBatchLoading"
@click="$emit('bt-bootstrap-all')"
>
全部未配置 ({{ btUnconfiguredCount }})
</v-btn>
<v-btn
size="default"
variant="flat"
color="deep-orange"
class="server-batch-bar__btn"
prepend-icon="mdi-playlist-check"
:disabled="!btSelectedUnconfiguredCount"
:loading="btBatchLoading"
@click="$emit('bt-bootstrap-selected')"
>
选中获取 API ({{ btSelectedUnconfiguredCount }})
</v-btn>
</div>
</div>
<v-divider vertical class="server-batch-bar__divider d-none d-md-flex" />
<div class="server-batch-bar__section">
<span class="server-batch-bar__label">Agent</span>
<div class="d-flex flex-wrap ga-2">
<v-btn size="default" variant="flat" color="info" class="server-batch-bar__btn" prepend-icon="mdi-download" @click="$emit('install-agent')">
安装
</v-btn>
<v-btn size="default" variant="flat" color="warning" class="server-batch-bar__btn" prepend-icon="mdi-arrow-up-bold" @click="$emit('upgrade-agent')">
升级
</v-btn>
<v-btn size="default" variant="flat" color="error" class="server-batch-bar__btn" prepend-icon="mdi-package-down" @click="$emit('uninstall-agent')">
卸载
</v-btn>
</div>
</div>
<v-divider vertical class="server-batch-bar__divider d-none d-md-flex" />
<div class="server-batch-bar__section">
<span class="server-batch-bar__label">其他</span>
<div class="d-flex flex-wrap ga-2">
<v-btn size="default" variant="flat" color="secondary" class="server-batch-bar__btn" prepend-icon="mdi-tag-outline" @click="$emit('batch-category')">
修改分类
</v-btn>
<v-btn size="default" variant="flat" color="error" class="server-batch-bar__btn" prepend-icon="mdi-delete-sweep" @click="$emit('batch-delete')">
批量删除
</v-btn>
</div>
</div>
<v-spacer class="d-none d-lg-flex" />
<v-btn size="default" variant="outlined" class="server-batch-bar__btn" prepend-icon="mdi-close" @click="$emit('clear')">
取消选择
</v-btn>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
defineProps({
count: { type: Number, required: true },
agentDiagnoseLoading: { type: Boolean, default: false },
btUnconfiguredCount: { type: Number, default: 0 },
btSelectedUnconfiguredCount: { type: Number, default: 0 },
btBatchLoading: { type: Boolean, default: false },
})
defineEmits<{
'batch-category': []
'health-check': []
'detect-path': []
'agent-diagnose': []
'bt-bootstrap-all': []
'bt-bootstrap-selected': []
'install-agent': []
'upgrade-agent': []
'uninstall-agent': []
'batch-delete': []
clear: []
}>()
</script>
<style scoped>
.server-batch-bar {
background: rgb(var(--v-theme-surface));
border-color: rgba(var(--v-theme-on-surface), 0.16) !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.server-batch-bar__inner {
padding-inline: 16px !important;
}
.server-batch-bar__section {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.server-batch-bar__label {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(var(--v-theme-on-surface), 0.82);
line-height: 1.2;
}
.server-batch-bar__count {
font-size: 0.875rem;
font-weight: 600;
height: 36px;
}
.server-batch-bar__btn {
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.01em;
text-transform: none;
}
.server-batch-bar__divider {
align-self: stretch;
min-height: 48px;
opacity: 0.5;
}
</style>
@@ -0,0 +1,56 @@
<template>
<div>
<v-chip
v-if="searchActive"
size="small"
variant="tonal"
color="info"
label
class="mb-2"
>
全库搜索中含未设路径与连接失败已忽略分类/在线筛选
</v-chip>
<div class="d-flex align-center flex-wrap ga-2">
<span class="text-caption text-medium-emphasis mr-1">分类筛选</span>
<v-chip
:color="categoryFilter === '' ? 'primary' : undefined"
:variant="categoryFilter === '' ? 'flat' : 'outlined'"
size="small"
label
@click="$emit('select', '')"
>
全部 {{ totalCount }}
</v-chip>
<v-chip
v-for="cat in categories"
:key="cat.name"
:color="categoryFilter === cat.name ? 'primary' : undefined"
:variant="categoryFilter === cat.name ? 'flat' : 'outlined'"
size="small"
label
@click="$emit('select', cat.name)"
>
{{ cat.label }} {{ cat.count }}
</v-chip>
<v-progress-circular v-if="categoriesLoading" indeterminate size="16" width="2" />
<v-spacer />
<slot name="trailing" />
</div>
</div>
</template>
<script setup lang="ts">
import type { ServerCategoryOption } from '@/composables/useServerCategories'
defineProps<{
categories: ServerCategoryOption[]
categoryFilter: string
totalCount: number
categoriesLoading: boolean
searchActive: boolean
}>()
defineEmits<{
select: [name: string]
}>()
</script>
@@ -0,0 +1,165 @@
<template>
<div class="server-category-picker">
<div class="d-flex align-center flex-wrap ga-2 mb-2">
<v-checkbox
:model-value="selectAll"
label="全选"
density="compact"
hide-details
@update:model-value="toggleAll"
/>
<v-text-field
v-model="serverSearch"
prepend-inner-icon="mdi-magnify"
label="搜索服务器"
density="compact"
hide-details
variant="outlined"
rounded
style="max-width: 220px"
clearable
/>
<v-chip v-if="model.length > 0" size="small" color="primary" variant="tonal" label>
已选 {{ model.length }}
</v-chip>
<v-progress-circular v-if="loading" indeterminate size="18" width="2" />
</div>
<div class="picker-scroll" :style="{ maxHeight }">
<div v-if="!loading && serversByCategory.length === 0" class="text-center text-medium-emphasis py-4">
暂无服务器
</div>
<div v-for="group in serversByCategory" :key="group.category" class="mb-3">
<div class="d-flex align-center px-1 mb-1">
<v-btn
size="x-small"
variant="text"
:color="isCategoryAllSelected(group.category) ? 'primary' : 'default'"
@click="toggleCategory(group.category)"
>
<v-icon size="16" class="mr-1">
{{ isCategoryAllSelected(group.category) ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline' }}
</v-icon>
{{ group.label }}
</v-btn>
<span class="text-caption text-medium-emphasis ml-1">
({{ groupSelectedCount(group) }}/{{ group.servers.length }})
</span>
</div>
<v-row dense>
<v-col v-for="s in group.servers" :key="s.id" cols="12" sm="6">
<v-card
:color="selectedSet.has(s.id) ? 'primary' : undefined"
:variant="selectedSet.has(s.id) ? 'tonal' : 'outlined'"
rounded="lg"
class="pa-2 cursor-pointer"
@click="toggleServer(s.id)"
>
<div class="d-flex align-center ga-2">
<v-icon :color="serverOnlineColor(s.is_online)" size="10">mdi-circle</v-icon>
<span class="text-body-2 font-weight-medium text-truncate">{{ s.name }}</span>
</div>
<div class="text-caption text-medium-emphasis text-truncate">{{ s.domain }}</div>
</v-card>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { categoryDisplayLabel, groupServersByCategory } from '@/composables/useServerCategories'
import { serverOnlineColor } from '@/composables/push/labels'
import type { ServerBrief } from '@/composables/useServerList'
const model = defineModel<number[]>({ default: () => [] })
const props = withDefaults(
defineProps<{
servers: ServerBrief[]
loading?: boolean
maxHeight?: string
}>(),
{ loading: false, maxHeight: '280px' },
)
const serverSearch = ref('')
const selectedSet = computed(() => new Set(model.value))
const filteredServers = computed(() => {
const q = serverSearch.value.trim().toLowerCase()
if (!q) return props.servers
return props.servers.filter(
s =>
s.name.toLowerCase().includes(q) ||
(s.domain || '').toLowerCase().includes(q) ||
(s.category || '').toLowerCase().includes(q),
)
})
const serversByCategory = computed(() =>
groupServersByCategory(filteredServers.value).map(g => ({
...g,
servers: [...g.servers].sort((a, b) => a.name.localeCompare(b.name, 'zh')),
})),
)
const selectAll = computed(() => {
const visible = filteredServers.value
return visible.length > 0 && visible.every(s => selectedSet.value.has(s.id))
})
function groupSelectedCount(group: { servers: ServerBrief[] }): number {
return group.servers.filter(s => selectedSet.value.has(s.id)).length
}
function isCategoryAllSelected(category: string): boolean {
const group = serversByCategory.value.find(g => g.category === category)
if (!group || group.servers.length === 0) return false
return group.servers.every(s => selectedSet.value.has(s.id))
}
function emitIds(next: Set<number>) {
model.value = [...next]
}
function toggleServer(id: number) {
const next = new Set(model.value)
if (next.has(id)) next.delete(id)
else next.add(id)
emitIds(next)
}
function toggleCategory(category: string) {
const group = serversByCategory.value.find(g => g.category === category)
if (!group) return
const next = new Set(model.value)
const allSelected = group.servers.every(s => next.has(s.id))
for (const s of group.servers) {
if (allSelected) next.delete(s.id)
else next.add(s.id)
}
emitIds(next)
}
function toggleAll() {
const visible = filteredServers.value
const next = new Set(model.value)
const allSelected = visible.length > 0 && visible.every(s => next.has(s.id))
for (const s of visible) {
if (allSelected) next.delete(s.id)
else next.add(s.id)
}
emitIds(next)
}
</script>
<style scoped>
.picker-scroll {
overflow-y: auto;
padding-right: 4px;
}
</style>
@@ -0,0 +1,249 @@
<template>
<v-dialog :model-value="modelValue" max-width="720" scrollable persistent @update:model-value="emit('update:modelValue', $event)">
<v-card border>
<v-card-title class="d-flex align-center">
{{ isEditing ? '编辑服务器' : '添加服务器' }}
<v-spacer />
<v-btn icon="mdi-close" variant="text" size="small" :disabled="saving" @click="emit('update:modelValue', false)" />
</v-card-title>
<v-divider />
<v-card-text class="pt-4">
<v-progress-linear v-if="lookupsLoading" indeterminate color="primary" class="mb-3" />
<div class="text-subtitle-2 text-medium-emphasis mb-2">基本信息</div>
<v-row dense>
<v-col cols="12" sm="6">
<v-text-field
v-model="form.name"
label="名称"
variant="outlined"
density="compact"
:rules="[required('服务器名称')]"
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="form.domain"
label="域名 / IP"
variant="outlined"
density="compact"
placeholder="例:192.168.1.10"
:rules="[required('域名或 IP')]"
/>
</v-col>
<v-col cols="6" sm="4">
<v-text-field
v-model.number="form.port"
label="SSH 端口"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="6" sm="8">
<v-text-field
v-model="form.username"
label="SSH 用户名"
variant="outlined"
density="compact"
placeholder="root"
:rules="[required('SSH 用户名')]"
/>
</v-col>
</v-row>
<v-divider class="my-4" />
<div class="text-subtitle-2 text-medium-emphasis mb-2">认证方式</div>
<v-btn-toggle v-model="form.auth_method" mandatory density="compact" variant="outlined" class="mb-3">
<v-btn value="password" size="small">密码</v-btn>
<v-btn value="key" size="small">SSH 密钥</v-btn>
</v-btn-toggle>
<template v-if="form.auth_method === 'password'">
<v-select
v-model="form.preset_id"
:items="passwordPresets"
item-title="display"
item-value="id"
label="密码预设(可选)"
variant="outlined"
density="compact"
clearable
class="mb-2"
hint="选择后由服务端解密填入,无需再填密码框"
persistent-hint
/>
<v-text-field
v-if="!form.preset_id"
v-model="form.password"
label="SSH 密码"
variant="outlined"
density="compact"
type="password"
:placeholder="isEditing && editHints.password_set ? '留空表示不修改' : '安装 Agent 需可 SSH'"
:rules="passwordFieldRules"
/>
</template>
<template v-else>
<v-select
v-model="form.ssh_key_preset_id"
:items="sshKeyPresets"
item-title="display"
item-value="id"
label="SSH 密钥预设(可选)"
variant="outlined"
density="compact"
clearable
class="mb-2"
hint="选择后由服务端解密私钥,无需再粘贴"
persistent-hint
/>
<v-text-field
v-model="form.ssh_key_path"
label="密钥路径(可选)"
variant="outlined"
density="compact"
placeholder="~/.ssh/id_rsa"
class="mb-2"
/>
<v-textarea
v-if="!form.ssh_key_preset_id"
v-model="form.ssh_key_private"
label="私钥 PEM"
variant="outlined"
density="compact"
rows="4"
auto-grow
:placeholder="isEditing && editHints.ssh_key_private_set ? '留空表示不修改私钥' : '粘贴 -----BEGIN ... PRIVATE KEY-----'"
:rules="sshKeyFieldRules"
/>
</template>
<v-divider class="my-4" />
<div class="text-subtitle-2 text-medium-emphasis mb-2">部署</div>
<v-text-field
v-model="form.target_path"
label="目标路径"
variant="outlined"
density="compact"
placeholder="/www/wwwroot"
hint="文件同步与推送的根目录,须为绝对路径"
persistent-hint
class="mb-2"
:rules="[required('目标路径')]"
/>
<v-combobox
v-model="form.category"
:items="categoryOptions"
label="分类"
variant="outlined"
density="compact"
clearable
hint="可从已有分类选择或输入新分类"
persistent-hint
class="mb-2"
/>
<v-textarea
v-model="form.description"
label="备注"
variant="outlined"
density="compact"
rows="2"
auto-grow
hint="业务别名、tonex 对照名等;列表可快速编辑"
persistent-hint
/>
<v-expansion-panels variant="accordion" class="mt-4">
<v-expansion-panel title="高级选项">
<v-expansion-panel-text>
<v-select
v-model="form.platform_id"
:items="platforms"
item-title="name"
item-value="id"
label="平台"
variant="outlined"
density="compact"
clearable
class="mb-2"
/>
<v-select
v-model="form.node_id"
:items="nodes"
item-title="name"
item-value="id"
label="节点"
variant="outlined"
density="compact"
clearable
class="mb-2"
/>
<v-select
v-model="form.files_elevation"
:items="filesElevationItems"
item-title="title"
item-value="value"
label="文件管理提权"
variant="outlined"
density="compact"
hint="非 root 用户需目标机配置免密 sudo"
persistent-hint
class="mb-4"
/>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" :disabled="saving" @click="emit('update:modelValue', false)">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="saving" @click="emit('save')">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { required } from '@/utils/validation'
import type { ServerFormModel, PresetOption } from '@/composables/servers/useServerFormDialog'
import type { FilesElevationMode } from '@/types/api'
const props = defineProps<{
modelValue: boolean
form: ServerFormModel
isEditing: boolean
saving: boolean
lookupsLoading: boolean
passwordPresets: PresetOption[]
sshKeyPresets: PresetOption[]
categoryOptions: string[]
platforms: { id: number; name: string }[]
nodes: { id: number; name: string }[]
editHints: { password_set: boolean; ssh_key_private_set: boolean }
filesElevationItems: { title: string; value: FilesElevationMode }[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
save: []
}>()
const passwordFieldRules = computed(() => {
if (props.form.preset_id) return []
if (props.isEditing) return []
return [required('SSH 密码或选择密码预设')]
})
const sshKeyFieldRules = computed(() => {
if (props.form.ssh_key_preset_id) return []
if (props.isEditing) return []
return [required('私钥或选择 SSH 密钥预设')]
})
</script>
@@ -0,0 +1,313 @@
<template>
<div class="server-inline-detail pa-3 rounded-b-lg">
<ServerAgentHint
class="mb-3"
:action="displayServer.agent_action"
:message="displayServer.agent_action_message"
:loading="props.agentActionLoading"
@install="$emit('install-agent')"
@upgrade="$emit('upgrade-agent')"
@diagnose="$emit('diagnose-agent')"
/>
<div class="d-flex flex-wrap align-center ga-2 mb-3">
<v-chip
:color="statusChipColor(displayServer.status)"
size="x-small"
variant="tonal"
label
border="sm"
>
{{ statusLabel(displayServer.status) }}
</v-chip>
<span class="text-caption text-medium-emphasis">
心跳 {{ formatRelativeTime(displayServer.last_heartbeat) }}
</span>
<v-spacer />
<v-btn
v-if="refreshError"
variant="text"
size="x-small"
color="primary"
:loading="refreshing"
@click="() => refreshAll()"
>
重试
</v-btn>
</div>
<v-skeleton-loader v-if="refreshing && !detail" type="article" class="mb-3" />
<div v-else class="d-flex flex-wrap ga-4 mb-3 text-body-2">
<div>
<span class="text-caption text-medium-emphasis">CPU</span>
{{ displayServer.system_info?.cpu_usage ?? '—' }}%
</div>
<div>
<span class="text-caption text-medium-emphasis">内存</span>
{{ displayServer.system_info?.mem_usage ?? '—' }}%
</div>
<div>
<span class="text-caption text-medium-emphasis">磁盘</span>
{{ displayServer.system_info?.disk_usage ?? '—' }}%
</div>
<div>
<span class="text-caption text-medium-emphasis">Agent</span>
{{ displayServer.agent_version || '未安装' }}
</div>
</div>
<div v-if="refreshError && detail" class="text-caption text-warning mb-2">
实时数据可能滞后{{ refreshError }}
</div>
<ServerMetricSparklines
class="mb-3"
:points="metricPoints"
:offline-segments="offlineSegments"
:interval-minutes="metricIntervalMinutes"
:loading="metricsLoading"
:error="metricsError"
@retry="() => { const id = ++requestId; void loadMetrics(id) }"
/>
<div class="d-flex align-center ga-2 mb-2">
<span class="text-caption text-medium-emphasis shrink-0">备注</span>
<span class="text-body-2 text-truncate" :title="displayServer.description || '无备注'">
{{ displayServer.description?.trim() || '—' }}
</span>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
density="compact"
title="编辑备注"
@click.stop="$emit('edit-remark')"
/>
</div>
<div class="d-flex align-center ga-2 mb-2">
<span class="text-caption text-medium-emphasis shrink-0">目标路径</span>
<span class="text-body-2 text-truncate">
{{ displayServer.target_path?.trim() || '未配置(推送页留空时无法推送到此机)' }}
</span>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
density="compact"
title="编辑目标路径"
@click.stop="$emit('edit-path')"
/>
</div>
<div class="text-caption text-medium-emphasis mb-1">同步日志</div>
<v-skeleton-loader v-if="logsLoading" type="list-item-two-line" />
<div v-else-if="logsError" class="text-caption text-error py-1">{{ logsError }}</div>
<div v-else-if="!latestLog" class="text-caption text-medium-emphasis py-1">暂无同步日志</div>
<v-list-item
v-else
density="compact"
class="bg-transparent pa-0"
:subtitle="formatSyncLogTime(latestLog)"
:title="`${latestLog.source_path} → ${latestLog.target_path}`"
>
<template #append>
<v-chip size="x-small" :color="syncLogStatusColor(latestLog.status)" variant="tonal" label>
{{ syncLogStatusLabel(latestLog.status) }}
</v-chip>
</template>
</v-list-item>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { http } from '@/api'
import { formatPushPermission } from '@/composables/push/labels'
import { formatApiError } from '@/utils/apiError'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import type {
PushItem,
ServerApiItem,
ServerMetricOfflineSegment,
ServerMetricPoint,
ServerMetricsResponse,
} from '@/types/api'
import { formatDateTimeBeijing } from '@/utils/datetime'
import ServerMetricSparklines from '@/components/servers/ServerMetricSparklines.vue'
import ServerAgentHint from '@/components/servers/ServerAgentHint.vue'
const props = defineProps<{
server: ServerApiItem
active?: boolean
agentActionLoading?: boolean
}>()
defineEmits<{
'edit-path': []
'edit-remark': []
'install-agent': []
'upgrade-agent': []
'diagnose-agent': []
}>()
const detail = ref<ServerApiItem | null>(null)
const refreshing = ref(false)
const refreshError = ref('')
const logsLoading = ref(false)
const logsError = ref('')
const latestLog = ref<PushItem | null>(null)
const metricsLoading = ref(false)
const metricsError = ref('')
const metricPoints = ref<ServerMetricPoint[]>([])
const offlineSegments = ref<ServerMetricOfflineSegment[]>([])
const metricIntervalMinutes = ref(10)
let requestId = 0
const displayServer = computed(() => detail.value ?? props.server)
function syncLogStatusColor(status: string): string {
switch (status) {
case 'success': return 'success'
case 'failed': return 'error'
case 'running': return 'warning'
case 'cancelled': return 'grey'
default: return 'default'
}
}
function syncLogStatusLabel(status: string): string {
switch (status) {
case 'success': return '成功'
case 'failed': return '失败'
case 'running': return '进行中'
case 'cancelled': return '已取消'
default: return status
}
}
function formatSyncLogTime(log: PushItem): string {
const parts: string[] = []
if (log.started_at) parts.push(formatDateTimeBeijing(log.started_at))
if (log.sync_mode) parts.push(log.sync_mode)
if (log.trigger_type) parts.push(log.trigger_type)
if (log.push_permission) parts.push(`权限 ${formatPushPermission(log.push_permission)}`)
return parts.join(' · ') || '—'
}
function parseServerSyncLogs(res: unknown): PushItem[] {
if (Array.isArray(res)) return res as PushItem[]
if (res && typeof res === 'object' && Array.isArray((res as { items?: unknown }).items)) {
return (res as { items: PushItem[] }).items
}
return []
}
async function loadDetail(myId: number) {
try {
const res = await http.get<ServerApiItem>(`/servers/${props.server.id}`)
if (myId !== requestId) return
detail.value = res
refreshError.value = ''
} catch (e: unknown) {
if (myId !== requestId) return
detail.value = null
refreshError.value = formatApiError(e, '加载实时状态失败')
}
}
async function loadLogs(myId: number) {
logsLoading.value = true
logsError.value = ''
latestLog.value = null
try {
const res = await http.get<PushItem[] | { items: PushItem[] }>(
`/servers/${props.server.id}/logs`,
{ limit: 1 },
)
if (myId !== requestId) return
const items = parseServerSyncLogs(res)
latestLog.value = items[0] ?? null
} catch (e: unknown) {
if (myId !== requestId) return
logsError.value = formatApiError(e, '加载同步日志失败')
latestLog.value = null
} finally {
if (myId === requestId) logsLoading.value = false
}
}
async function loadMetrics(myId: number) {
metricsLoading.value = true
metricsError.value = ''
metricPoints.value = []
offlineSegments.value = []
try {
const res = await http.get<ServerMetricsResponse>(
`/servers/${props.server.id}/metrics`,
{ hours: 24 },
)
if (myId !== requestId) return
metricPoints.value = res.points ?? []
offlineSegments.value = res.offline_segments ?? []
metricIntervalMinutes.value = res.interval_minutes ?? 10
} catch (e: unknown) {
if (myId !== requestId) return
metricsError.value = formatApiError(e, '加载历史趋势失败')
} finally {
if (myId === requestId) metricsLoading.value = false
}
}
async function refreshAll() {
const myId = ++requestId
refreshing.value = true
refreshError.value = ''
try {
await Promise.all([loadDetail(myId), loadLogs(myId), loadMetrics(myId)])
} finally {
if (myId === requestId) refreshing.value = false
}
}
watch(
() => props.active,
(on) => {
if (on) void refreshAll()
},
)
watch(
() => props.server.id,
() => {
detail.value = null
metricPoints.value = []
offlineSegments.value = []
latestLog.value = null
if (props.active) void refreshAll()
},
)
onBeforeUnmount(() => {
requestId += 1
})
</script>
<style scoped>
.server-inline-detail {
background: color-mix(in srgb, rgb(var(--v-theme-surface)) 95%, rgb(var(--v-theme-on-surface)));
color: rgb(var(--v-theme-on-surface));
}
.server-inline-detail :deep(.v-list-item-title) {
color: rgb(var(--v-theme-on-surface));
}
.server-inline-detail :deep(.v-list-item-subtitle) {
color: rgba(var(--v-theme-on-surface), 0.72);
}
</style>
@@ -0,0 +1,115 @@
<template>
<div class="server-list-toolbar d-flex align-center flex-wrap ga-2 flex-grow-1">
<v-combobox
:model-value="searchDraft"
:items="searchHistory"
prepend-inner-icon="mdi-magnify"
label="搜索名称/IP(含未设路径)"
density="compact"
hide-details
rounded
clearable
:auto-select-first="false"
class="server-list-toolbar__search"
variant="outlined"
placeholder="输入后回车,或从历史选择"
@update:model-value="onSearchUpdate"
@keydown.enter.prevent="emit('commit-search')"
/>
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" size="small" @click="emit('add')">
添加
</v-btn>
<v-btn variant="tonal" prepend-icon="mdi-flash" size="small" @click="emit('quick-add')">
快速添加(IP)
</v-btn>
<v-btn variant="tonal" prepend-icon="mdi-ip-network" size="small" @click="emit('batch-add')">
批量添加
</v-btn>
<v-btn variant="tonal" prepend-icon="mdi-key-outline" size="small" @click="emit('credentials')">
凭据
</v-btn>
<v-btn
variant="tonal"
prepend-icon="mdi-folder-search"
size="small"
:disabled="detectPathDisabled"
title="请先勾选至少一台服务器"
@click="emit('detect-path')"
>
检测路径
</v-btn>
<v-btn
v-if="showRefresh"
variant="text"
size="small"
prepend-icon="mdi-refresh"
:loading="refreshLoading"
@click="emit('refresh')"
>
刷新
</v-btn>
<v-btn-toggle
v-if="showViewToggle"
:model-value="viewMode"
mandatory
density="compact"
color="primary"
variant="outlined"
divided
@update:model-value="emit('update:viewMode', $event)"
>
<v-btn value="table" size="small" prepend-icon="mdi-table">列表</v-btn>
<v-btn value="group" size="small" prepend-icon="mdi-folder-multiple">分类</v-btn>
</v-btn-toggle>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
searchDraft: string
searchHistory: readonly string[]
detectPathDisabled: boolean
showViewToggle?: boolean
viewMode?: 'table' | 'group'
showRefresh?: boolean
refreshLoading?: boolean
}>(),
{
showViewToggle: false,
showRefresh: false,
refreshLoading: false,
},
)
const emit = defineEmits<{
'update:searchDraft': [value: string]
'update:viewMode': [mode: 'table' | 'group']
'commit-search': []
'search-combobox-update': [value: string | null]
add: []
'quick-add': []
'batch-add': []
credentials: []
'detect-path': []
refresh: []
}>()
function onSearchUpdate(val: string | null) {
emit('update:searchDraft', val ?? '')
emit('search-combobox-update', val)
}
</script>
<style scoped>
.server-list-toolbar {
min-width: 0;
justify-content: flex-end;
}
.server-list-toolbar__search {
flex: 1 1 200px;
max-width: 240px;
min-width: 160px;
}
</style>
@@ -0,0 +1,124 @@
<template>
<div class="server-metric-sparklines">
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-caption text-medium-emphasis">
24h 趋势 {{ intervalMinutes }} 分钟采样
</span>
<v-btn
v-if="error"
variant="text"
size="x-small"
color="primary"
@click="$emit('retry')"
>
重试
</v-btn>
</div>
<v-skeleton-loader v-if="loading" type="image" height="120" class="rounded mb-2" />
<div v-else-if="error" class="text-caption text-error py-1 mb-2">{{ error }}</div>
<div v-else-if="!hasPoints" class="text-caption text-medium-emphasis py-1 mb-2">
暂无历史数据部署后约 10 分钟起陆续有采样
</div>
<template v-else>
<v-row dense>
<v-col cols="12" md="6">
<div class="d-flex justify-space-between text-caption mb-1">
<span class="text-medium-emphasis">CPU</span>
<span>{{ latestCpu }}</span>
</div>
<v-sparkline
:model-value="cpuSeries"
:labels="sparklineLabels"
color="primary"
height="48"
smooth
interactive
auto-draw
/>
</v-col>
<v-col cols="12" md="6">
<div class="d-flex justify-space-between text-caption mb-1">
<span class="text-medium-emphasis">内存</span>
<span>{{ latestMem }}</span>
</div>
<v-sparkline
:model-value="memSeries"
:labels="sparklineLabels"
color="secondary"
height="48"
smooth
interactive
auto-draw
/>
</v-col>
</v-row>
<div v-if="offlineSegments.length" class="d-flex flex-wrap ga-1 mt-2">
<v-chip
v-for="(seg, i) in offlineSegments"
:key="`${seg.from}-${seg.to}-${i}`"
size="x-small"
variant="tonal"
color="grey"
label
>
离线 {{ formatSegment(seg) }}
</v-chip>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ServerMetricOfflineSegment, ServerMetricPoint } from '@/types/api'
import { formatPointTimeBeijing } from '@/utils/fleetTrendAnalytics'
const props = defineProps<{
points: ServerMetricPoint[]
offlineSegments: ServerMetricOfflineSegment[]
intervalMinutes: number
loading?: boolean
error?: string
}>()
defineEmits<{
retry: []
}>()
const hasPoints = computed(() => props.points.length > 0)
const onlinePoints = computed(() =>
props.points.filter((p) => p.is_online && (p.cpu_pct != null || p.mem_pct != null)),
)
const sparklineLabels = computed(() =>
onlinePoints.value.map((p) => formatPointTimeBeijing(p.recorded_at)),
)
const cpuSeries = computed(() =>
onlinePoints.value.map((p) => p.cpu_pct ?? 0),
)
const memSeries = computed(() =>
onlinePoints.value.map((p) => p.mem_pct ?? 0),
)
const latestCpu = computed(() => {
const last = [...props.points].reverse().find((p) => p.is_online && p.cpu_pct != null)
return last?.cpu_pct != null ? `${last.cpu_pct}%` : '—'
})
const latestMem = computed(() => {
const last = [...props.points].reverse().find((p) => p.is_online && p.mem_pct != null)
return last?.mem_pct != null ? `${last.mem_pct}%` : '—'
})
function formatSegment(seg: ServerMetricOfflineSegment): string {
return `${formatPointTimeBeijing(seg.from)} ${formatPointTimeBeijing(seg.to)}`
}
</script>
@@ -0,0 +1,40 @@
<template>
<div class="server-name-cell d-flex align-start ga-1" @click.stop="emit('toggle')">
<v-icon size="small" class="mt-1 shrink-0">
{{ expanded ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<div class="server-name-cell__body min-width-0">
<div class="font-weight-medium text-primary text-truncate" :title="name">{{ name }}</div>
<div class="d-flex align-start ga-1 mt-1">
<span
v-if="remark"
class="server-name-cell__remark text-caption text-medium-emphasis"
:title="remark"
>{{ remark }}</span>
<span v-else class="text-caption text-disabled">无备注</span>
<v-btn
icon="mdi-pencil-outline"
size="x-small"
variant="text"
density="compact"
class="shrink-0 mt-n1"
title="编辑备注"
@click.stop="emit('edit-remark')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
name: string
remark?: string | null
expanded?: boolean
}>()
const emit = defineEmits<{
toggle: []
'edit-remark': []
}>()
</script>
@@ -0,0 +1,88 @@
<template>
<v-dialog v-model="showQuickAdd" max-width="480" persistent>
<v-card border>
<v-card-title>快速添加服务器 IP</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-3">
将按顺序轮询凭据管理中的全部密码预设与 SSH 密钥预设尝试登录首个成功即加入服务器列表
</p>
<v-text-field
v-model="quickAddForm.domain"
label="IP 或域名"
variant="outlined"
density="compact"
class="mb-2"
placeholder="192.168.1.10"
:rules="[v => !!v?.trim() || '必填']"
/>
<v-text-field
v-model.number="quickAddForm.port"
label="SSH 端口"
variant="outlined"
density="compact"
type="number"
class="mb-2"
/>
<v-text-field
v-model="quickAddForm.name"
label="显示名称(可选)"
variant="outlined"
density="compact"
hint="留空则使用 IP"
persistent-hint
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="quickAddLoading" @click="showQuickAdd = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="quickAddLoading" @click="submitQuickAdd">开始轮询</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showPollFailure" max-width="560">
<v-card border>
<v-card-title class="text-error">SSH 连接失败</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">已尝试全部凭据预设均未登录成功该服务器已加入连接失败列表可稍后重试</p>
<v-list v-if="pollFailureErrors.length" density="compact" class="bg-grey-lighten-4 rounded">
<v-list-item v-for="(err, i) in pollFailureErrors" :key="i">
<v-list-item-title class="text-body-2">
[{{ err.preset_type === 'password' ? '密码' : err.preset_type === 'system' ? '系统' : '密钥' }}]
{{ err.preset_name }} ({{ err.username }})
</v-list-item-title>
<v-list-item-subtitle class="text-error">{{ err.error }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<p v-else class="text-medium-emphasis">{{ pollFailureMessage }}</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="showPollFailure = false">知道了</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { useServerQuickAdd, type UseServerQuickAddOptions } from '@/composables/servers/useServerQuickAdd'
const props = defineProps<UseServerQuickAddOptions>()
const {
showQuickAdd,
quickAddLoading,
quickAddForm,
showPollFailure,
pollFailureErrors,
pollFailureMessage,
openQuickAdd,
submitQuickAdd,
showFailureDialog,
} = useServerQuickAdd({
onSuccess: (res) => props.onSuccess?.(res),
onPendingFailure: () => props.onPendingFailure?.(),
})
defineExpose({ open: openQuickAdd, showFailure: showFailureDialog })
</script>
@@ -0,0 +1,43 @@
<template>
<v-dialog :model-value="modelValue" max-width="560" persistent @update:model-value="emit('update:modelValue', $event)">
<v-card border>
<v-card-title class="text-h6">编辑备注</v-card-title>
<v-card-subtitle v-if="serverName" class="text-wrap pb-2">{{ serverName }}</v-card-subtitle>
<v-divider />
<v-card-text class="pt-4">
<v-textarea
:model-value="modelValueText"
label="备注"
variant="outlined"
density="comfortable"
rows="4"
auto-grow
hide-details
placeholder="tonex 对照名、业务别名等"
@update:model-value="emit('update:modelValueText', $event)"
/>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" :disabled="saving" @click="emit('update:modelValue', false)">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="saving" @click="emit('save')">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
modelValueText: string
serverName?: string
saving?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:modelValueText': [value: string]
save: []
}>()
</script>
@@ -0,0 +1,75 @@
<template>
<div class="server-row-actions d-flex align-center justify-end flex-nowrap ga-1">
<v-btn
size="small"
variant="tonal"
color="primary"
density="comfortable"
class="server-row-actions__btn"
prepend-icon="mdi-console"
title="WebSSH 终端"
@click.stop="$emit('terminal')"
>
终端
</v-btn>
<v-btn
size="small"
variant="tonal"
color="deep-orange"
density="comfortable"
class="server-row-actions__btn"
prepend-icon="mdi-login-variant"
title="一键登录宝塔面板"
:loading="btLoginLoading"
:disabled="btLoginLoading"
@click.stop="$emit('bt-login')"
>
一键登录
</v-btn>
<v-btn
size="small"
variant="tonal"
color="secondary"
density="comfortable"
class="server-row-actions__btn"
prepend-icon="mdi-folder-outline"
title="文件管理"
@click.stop="$emit('files')"
>
文件
</v-btn>
<v-btn
size="small"
variant="tonal"
density="comfortable"
class="server-row-actions__btn"
prepend-icon="mdi-pencil-outline"
title="编辑服务器"
@click.stop="$emit('edit')"
>
编辑
</v-btn>
</div>
</template>
<script setup lang="ts">
defineProps<{
btLoginLoading?: boolean
}>()
defineEmits<{
terminal: []
'bt-login': []
files: []
edit: []
}>()
</script>
<style scoped>
.server-row-actions__btn {
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.01em;
text-transform: none;
}
</style>
@@ -0,0 +1,363 @@
<template>
<v-card
id="unset-path-panel"
elevation="0"
rounded="lg"
class="my-5 unset-path-panel"
:class="{ 'unset-path-panel--highlight': highlight }"
border
>
<v-card-title class="server-panel-card-title d-flex align-center flex-wrap ga-2">
<span class="text-h6 font-weight-medium text-no-wrap">未设置路径服务器</span>
<v-chip v-if="total > 0" size="small" color="warning" variant="tonal" label>
{{ total }}
</v-chip>
<ServerListToolbarActions
v-model:search-draft="searchDraftModel"
v-model:view-mode="viewModeModel"
:search-history="searchHistory"
:detect-path-disabled="selectedCount === 0"
show-view-toggle
show-refresh
:refresh-loading="loading"
@commit-search="emit('commit-search')"
@search-combobox-update="emit('search-combobox-update', $event)"
@add="emit('add-server')"
@quick-add="emit('quick-add')"
@batch-add="emit('batch-add')"
@credentials="emit('open-credentials')"
@detect-path="emit('detect-path')"
@refresh="emit('refresh')"
/>
</v-card-title>
<v-card-text class="pt-0 pb-2">
<div class="d-flex align-center flex-wrap ga-2">
<v-spacer />
<v-btn
size="small"
variant="tonal"
:disabled="total === 0"
:loading="selectAllLoading"
@click="$emit('select-all-filtered')"
>
全选筛选结果 ({{ total }})
</v-btn>
</div>
</v-card-text>
<ServerBatchActionBar
:count="selectedCount"
:agent-diagnose-loading="agentDiagnoseLoading"
:bt-unconfigured-count="btUnconfiguredCount"
:bt-selected-unconfigured-count="btSelectedUnconfiguredCount"
:bt-batch-loading="btBatchLoading"
@batch-category="$emit('batch-category')"
@health-check="$emit('health-check')"
@detect-path="$emit('detect-path')"
@agent-diagnose="$emit('agent-diagnose')"
@bt-bootstrap-all="$emit('bt-bootstrap-all')"
@bt-bootstrap-selected="$emit('bt-bootstrap-selected')"
@install-agent="$emit('install-agent')"
@upgrade-agent="$emit('upgrade-agent')"
@uninstall-agent="$emit('uninstall-agent')"
@batch-delete="$emit('batch-delete')"
@clear="$emit('clear-selection')"
/>
<v-data-table-server
class="server-data-table"
:items="servers"
:headers="headers"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
:items-per-page-options="itemsPerPageOptions"
hover
density="comfortable"
item-value="id"
show-select
show-expand
:model-value="selectedItems"
:sort-by="sortBy"
v-model:expanded="expandedModel"
@update:model-value="$emit('update:selected-items', $event)"
@update:sort-by="$emit('update:sort-by', $event)"
@update:page="$emit('update:page', $event)"
@update:items-per-page="$emit('update:items-per-page', $event)"
>
<template #item.data-table-expand />
<template #item.status="{ item }">
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label border="sm">
{{ statusLabel(item.status) }}
</v-chip>
</template>
<template #item.name="{ item }">
<ServerNameWithRemark
:name="item.name"
:remark="item.description"
:expanded="isExpandedId(item.id)"
@toggle="$emit('toggle-expand', item)"
@edit-remark="$emit('edit-remark', item)"
/>
</template>
<template #item.domain="{ item }">
<div class="d-flex flex-column align-start py-1 ga-0">
<span class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</span>
<a
v-if="resolveServerSiteUrl(item)"
:href="resolveServerSiteUrl(item)!"
target="_blank"
rel="noopener noreferrer"
class="text-caption text-primary site-link"
:title="`打开 ${resolveServerSiteHost(item)}`"
@click.stop
>
{{ resolveServerSiteHost(item) }}
</a>
</div>
</template>
<template #item.category="{ item }">
<v-chip v-if="item.category" color="primary" variant="tonal" size="small" label>
{{ item.category }}
</v-chip>
<span v-else class="text-medium-emphasis"></span>
</template>
<template #item.target_path="{ item }">
<div v-if="editingPathId === item.id" class="d-flex align-center ga-1 server-path-cell" @click.stop>
<v-text-field
:model-value="editingPathValue"
density="compact"
variant="outlined"
hide-details
placeholder="/www/wwwroot"
class="flex-grow-1"
style="min-width: 140px"
@update:model-value="$emit('update:editing-path-value', $event)"
@keyup.enter="$emit('save-path', item.id)"
@keyup.esc="$emit('cancel-edit-path')"
/>
<v-btn
icon="mdi-check"
size="x-small"
variant="text"
color="primary"
:loading="savingPathId === item.id"
@click="$emit('save-path', item.id)"
/>
<v-btn icon="mdi-close" size="x-small" variant="text" @click="$emit('cancel-edit-path')" />
</div>
<div v-else class="d-flex align-center ga-1 server-path-cell">
<span
class="text-caption text-medium-emphasis server-path-cell__text text-truncate d-inline-block flex-grow-1"
:title="item.target_path || '未配置'"
>
{{ item.target_path?.trim() || '—' }}
</span>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
density="compact"
title="编辑目标路径"
@click.stop="$emit('edit-path', item)"
/>
</div>
</template>
<template #item.agent_version="{ item }">
<div>
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
<v-chip
v-if="item.agent_action === 'install'"
size="x-small"
color="info"
variant="tonal"
label
class="mt-1"
>
需安装
</v-chip>
<v-chip
v-else-if="item.agent_action === 'upgrade'"
size="x-small"
color="warning"
variant="tonal"
label
class="mt-1"
>
需升级
</v-chip>
</div>
</template>
<template #item.last_heartbeat="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatRelativeTime(item.last_heartbeat) }}</span>
</template>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.created_at) }}</span>
</template>
<template #item.actions="{ item }">
<ServerTableRowActions
:bt-login-loading="btLoginLoadingId === item.id"
@terminal="$emit('terminal', item)"
@bt-login="$emit('bt-login', item)"
@files="$emit('files', item)"
@edit="$emit('edit', item)"
/>
</template>
<template #expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length" class="pa-0">
<ServerInlineDetail
:server="item"
:active="isExpandedId(item.id)"
:agent-action-loading="agentActionLoadingId === item.id"
@edit-path="$emit('edit-path', item)"
@edit-remark="$emit('edit-remark', item)"
@install-agent="$emit('install-agent', item)"
@upgrade-agent="$emit('upgrade-agent', item)"
@diagnose-agent="$emit('diagnose-agent', item)"
/>
</td>
</tr>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无未设置路径的服务器</div>
</template>
</v-data-table-server>
</v-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ServerApiItem } from '@/types/api'
import type { TableSortItem } from '@/utils/serverTableSort'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import { formatDateTimeBeijing } from '@/utils/datetime'
import { normalizeServerIds } from '@/utils/serverSelection'
import ServerInlineDetail from '@/components/servers/ServerInlineDetail.vue'
import ServerBatchActionBar from '@/components/servers/ServerBatchActionBar.vue'
import ServerListToolbarActions from '@/components/servers/ServerListToolbarActions.vue'
import ServerTableRowActions from '@/components/servers/ServerTableRowActions.vue'
import ServerNameWithRemark from '@/components/servers/ServerNameWithRemark.vue'
import { SERVER_DATA_TABLE_HEADERS } from '@/constants/serverTableHeaders'
import { resolveServerSiteHost, resolveServerSiteUrl } from '@/utils/serverSiteUrl'
const props = defineProps<{
highlight?: boolean
servers: ServerApiItem[]
loading: boolean
total: number
page: number
itemsPerPage: number
itemsPerPageOptions: ReadonlyArray<{ readonly value: number; readonly title: string }>
sortBy: TableSortItem[]
expandedIds: number[]
selectedItems: Array<ServerApiItem | number>
selectAllLoading: boolean
editingPathId: number | null
editingPathValue: string
savingPathId: number | null
btLoginLoadingId: number | null
agentDiagnoseLoading: boolean
btUnconfiguredCount: number
btSelectedUnconfiguredCount: number
btBatchLoading: boolean
agentActionLoadingId: number | null
searchHistory: readonly string[]
}>()
const searchDraftModel = defineModel<string>('searchDraft', { required: true })
const viewModeModel = defineModel<'table' | 'group'>('viewMode', { required: true })
const emit = defineEmits<{
refresh: []
'commit-search': []
'search-combobox-update': [value: string | null]
'add-server': []
'quick-add': []
'batch-add': []
'open-credentials': []
'update:sort-by': [sortBy: TableSortItem[]]
'update:page': [page: number]
'update:items-per-page': [n: number]
'update:selected-items': [items: Array<ServerApiItem | number>]
'update:expanded-ids': [ids: number[]]
'update:editing-path-value': [value: string]
'select-all-filtered': []
'toggle-expand': [item: ServerApiItem]
terminal: [item: ServerApiItem]
'bt-login': [item: ServerApiItem]
files: [item: ServerApiItem]
edit: [item: ServerApiItem]
'edit-path': [item: ServerApiItem]
'edit-remark': [item: ServerApiItem]
'save-path': [serverId: number]
'cancel-edit-path': []
'batch-category': []
'health-check': []
'detect-path': []
'agent-diagnose': []
'bt-bootstrap-all': []
'bt-bootstrap-selected': []
'install-agent': [item?: ServerApiItem]
'upgrade-agent': [item?: ServerApiItem]
'diagnose-agent': [item: ServerApiItem]
'uninstall-agent': []
'batch-delete': []
'clear-selection': []
}>()
const headers = computed(() => {
const base = [...SERVER_DATA_TABLE_HEADERS]
const actionsIdx = base.findIndex((h) => h.key === 'actions')
const insertAt = actionsIdx >= 0 ? actionsIdx : base.length
base.splice(insertAt, 0, {
title: '添加时间',
key: 'created_at',
width: 148,
minWidth: 148,
})
return base
})
const selectedCount = computed(() => normalizeServerIds(props.selectedItems).length)
const expandedModel = computed({
get: () => props.expandedIds as unknown as readonly string[],
set: (ids: readonly string[]) => {
emit(
'update:expanded-ids',
ids.map((id) => Number(id)).filter((n) => !Number.isNaN(n)),
)
},
})
function isExpandedId(id: number): boolean {
return props.expandedIds.includes(id)
}
</script>
<style scoped>
.unset-path-panel--highlight {
border-color: rgb(var(--v-theme-warning)) !important;
box-shadow: 0 0 0 2px rgba(var(--v-theme-warning), 0.35) !important;
scroll-margin-top: 80px;
}
.server-panel-card-title {
white-space: normal;
overflow: visible;
}
</style>
@@ -0,0 +1,118 @@
<template>
<div
ref="containerRef"
class="flex-grow-1 terminal-area"
@contextmenu="$emit('context-menu', $event)"
>
<div
v-for="tab in sessions"
:key="tab.id"
:ref="(el) => onPaneRef(el, tab.id)"
class="terminal-area__pane"
:style="paneStyle(tab.id)"
/>
<div
v-if="showEmpty"
class="d-flex flex-column align-center justify-center fill-height pa-6 terminal-area__empty"
>
<v-icon size="56" color="primary" class="mb-3">mdi-console-network</v-icon>
<div class="text-h6 mb-1 text-center terminal-area__empty-title">WebSSH 终端</div>
<div class="text-body-2 mb-4 text-center terminal-area__empty-hint">
选择一台服务器开始会话或从服务器页点击终端
</div>
<slot name="empty-picker" />
</div>
<v-overlay
v-if="activeSession?.showOverlay"
:model-value="true"
contained
class="terminal-area__overlay d-flex align-center justify-center"
persistent
scrim="rgba(0, 0, 0, 0.72)"
>
<div class="text-center">
<div class="text-h6 mb-2">{{ activeSession.overlayMsg }}</div>
<div v-if="activeSession.reconnectCountdown" class="text-body-2 text-medium-emphasis mb-4">
{{ activeSession.reconnectCountdown }}
</div>
<div class="d-flex ga-3 justify-center">
<v-btn color="primary" variant="flat" @click="$emit('reconnect')">重新连接</v-btn>
<v-btn variant="outlined" @click="$emit('go-servers')">返回列表</v-btn>
</div>
</div>
</v-overlay>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { TermSessionData } from '@/composables/terminal/types'
const props = defineProps<{
sessions: TermSessionData[]
activeSessionId: string
activeSession: TermSessionData | null
showEmpty: boolean
}>()
const emit = defineEmits<{
'context-menu': [event: MouseEvent]
reconnect: []
'go-servers': []
'pane-ref': [el: unknown, sessionId: string]
}>()
const containerRef = ref<HTMLElement | null>(null)
function paneStyle(sessionId: string): Record<string, string | number> {
const active = sessionId === props.activeSessionId
return {
position: 'absolute',
top: '0',
right: '0',
bottom: '0',
left: '0',
zIndex: active ? 1 : 0,
pointerEvents: active ? 'auto' : 'none',
opacity: active ? 1 : 0,
}
}
function onPaneRef(el: unknown, sessionId: string) {
emit('pane-ref', el, sessionId)
}
defineExpose({ containerRef })
</script>
<style scoped>
.terminal-area {
position: relative;
min-height: 0;
overflow: hidden;
background: #000000;
}
.terminal-area__overlay {
z-index: 4;
}
.terminal-area__pane {
position: absolute;
inset: 0;
overflow: hidden;
}
.fill-height {
flex: 1 1 auto;
min-height: 200px;
}
.terminal-area__empty {
color: #e8e8e8;
}
.terminal-area__empty-title {
color: #e8e8e8;
}
.terminal-area__empty-hint {
color: #9aa0a6;
}
</style>
@@ -0,0 +1,106 @@
<template>
<div
class="d-flex align-start ga-2 px-3 py-2 shrink-0 cmd-bar cmd-bar--tall"
:class="{ 'cmd-bar--inactive': disabled }"
@contextmenu="$emit('context-menu', $event)"
>
<span class="cmd-bar__prompt font-mono text-primary shrink-0"></span>
<div class="flex-grow-1 cmd-bar__input-wrap">
<ShellHighlightField
ref="fieldRef"
:model-value="modelValue"
multiline
:rows="2"
size="large"
placeholder="输入命令,Enter 发送,Shift+Enter 换行"
:disabled="disabled"
@update:model-value="$emit('update:modelValue', $event)"
@keydown="$emit('keydown', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ShellHighlightField from '@/components/ShellHighlightField.vue'
defineProps<{
modelValue: string
disabled: boolean
}>()
defineEmits<{
'update:modelValue': [value: string]
keydown: [event: KeyboardEvent]
'context-menu': [event: MouseEvent]
}>()
const fieldRef = ref<InstanceType<typeof ShellHighlightField> | null>(null)
function getInputEl() {
return fieldRef.value?.getInputEl() ?? null
}
function getSelectionText() {
return fieldRef.value?.getSelectionText() ?? ''
}
function selectAllInput() {
fieldRef.value?.selectAllInput()
}
async function insertAtSelection(text: string) {
await fieldRef.value?.insertAtSelection(text)
}
defineExpose({
getInputEl,
getSelectionText,
selectAllInput,
insertAtSelection,
})
</script>
<style scoped>
.cmd-bar {
background: rgb(var(--v-theme-surface));
border-top: 1px solid rgb(var(--v-theme-surface-variant));
}
.cmd-bar--tall {
min-height: 72px;
}
.cmd-bar--inactive {
opacity: 0.55;
}
.cmd-bar__prompt {
font-size: 1.125rem;
line-height: 1.55;
padding-top: 12px;
}
.cmd-bar__input-wrap {
min-width: 0;
}
.font-mono {
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
}
:deep(.sh-keyword),
:deep(.sh-builtin),
:deep(.sh-sudo),
:deep(.sh-pipe),
:deep(.sh-operator),
:deep(.sh-redirect),
:deep(.sh-string),
:deep(.sh-flag),
:deep(.sh-variable),
:deep(.sh-backtick),
:deep(.sh-comment),
:deep(.sh-number),
:deep(.sh-path),
:deep(.sh-text) {
color: rgb(var(--v-theme-on-surface));
font-weight: inherit;
font-style: inherit;
}
</style>
@@ -0,0 +1,119 @@
<template>
<Teleport to="body">
<div v-show="open" class="terminal-ctx-host">
<div
class="terminal-ctx-backdrop"
@mousedown.prevent="emit('close')"
@contextmenu.prevent="emit('close')"
/>
<div
class="terminal-ctx-menu"
role="menu"
:style="style"
@mousedown.stop
>
<button type="button" class="terminal-ctx-menu__item" role="menuitem" @click="emit('copy')">
<v-icon size="20" icon="mdi-content-copy" />
<span>复制</span>
</button>
<button type="button" class="terminal-ctx-menu__item" role="menuitem" @click="emit('paste')">
<v-icon size="20" icon="mdi-content-paste" />
<span>粘贴</span>
</button>
<button type="button" class="terminal-ctx-menu__item" role="menuitem" @click="emit('select-all')">
<v-icon size="20" icon="mdi-select-all" />
<span>全选</span>
</button>
<template v-if="context === 'terminal'">
<div class="terminal-ctx-menu__divider" aria-hidden="true" />
<button type="button" class="terminal-ctx-menu__item" role="menuitem" @click="emit('clear')">
<v-icon size="20" icon="mdi-delete-sweep" />
<span>清屏</span>
</button>
<button
type="button"
class="terminal-ctx-menu__item terminal-ctx-menu__item--danger"
role="menuitem"
:disabled="!canDisconnect"
@click="emit('disconnect')"
>
<v-icon size="20" icon="mdi-logout" />
<span>断开连接</span>
</button>
</template>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
open: boolean
style: Record<string, string>
context: 'terminal' | 'command'
canDisconnect: boolean
}>()
const emit = defineEmits<{
close: []
copy: []
paste: []
'select-all': []
clear: []
disconnect: []
}>()
</script>
<style scoped>
.terminal-ctx-host {
pointer-events: none;
}
.terminal-ctx-backdrop {
position: fixed;
inset: 0;
z-index: 2400;
pointer-events: auto;
}
.terminal-ctx-menu {
position: fixed;
z-index: 2401;
min-width: 11.5rem;
padding: 4px 0;
pointer-events: auto;
background: #1e1e1e;
color: #e8e8e8;
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
}
.terminal-ctx-menu__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
border: none;
background: transparent;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
color: inherit;
text-align: left;
line-height: 1.25;
}
.terminal-ctx-menu__item:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.terminal-ctx-menu__item:disabled {
opacity: 0.45;
cursor: default;
}
.terminal-ctx-menu__item--danger {
color: #f44336;
}
.terminal-ctx-menu__divider {
height: 1px;
margin: 4px 0;
background: #333;
}
</style>
@@ -0,0 +1,66 @@
<template>
<div class="terminal-quick-bar shrink-0">
<span class="terminal-quick-bar__label">快捷命令</span>
<div class="d-flex align-center ga-1 flex-grow-1 overflow-x-auto min-w-0">
<v-chip
v-for="cmd in commands"
:key="cmd.id"
size="small"
:variant="cmd.is_builtin ? 'outlined' : 'flat'"
:color="cmd.is_builtin ? 'default' : 'primary'"
label
class="cursor-pointer terminal-quick-bar__chip"
@click="$emit('exec', cmd.cmd)"
>
{{ cmd.name }}
</v-chip>
</div>
<v-btn
size="small"
variant="text"
color="error"
prepend-icon="mdi-link-off"
:disabled="disconnectDisabled"
@click="$emit('disconnect')"
>
断开
</v-btn>
</div>
</template>
<script setup lang="ts">
import type { QuickCmdItem } from '@/composables/useTerminalQuickCommands'
defineProps<{
commands: QuickCmdItem[]
disconnectDisabled?: boolean
}>()
defineEmits<{
exec: [cmd: string]
disconnect: []
}>()
</script>
<style scoped>
.terminal-quick-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgb(var(--v-theme-surface));
border-top: 1px solid rgb(var(--v-theme-surface-variant));
}
.terminal-quick-bar__label {
font-size: 0.9375rem;
font-weight: 600;
flex-shrink: 0;
color: rgb(var(--v-theme-on-surface));
}
.terminal-quick-bar__chip {
flex-shrink: 0;
}
.cursor-pointer {
cursor: pointer;
}
</style>
@@ -0,0 +1,112 @@
<template>
<aside v-if="mode === 'sidebar'" class="terminal-server-rail d-flex flex-column h-100">
<div class="terminal-server-rail__head px-3 py-2 text-subtitle-2 font-weight-medium border-b">
服务器
<span class="text-caption text-medium-emphasis ml-1">({{ servers.length }})</span>
</div>
<div v-if="showSearch" class="terminal-server-rail__search px-2 pt-2 pb-1">
<PickerSearch
:search="search"
:search-history="searchHistory"
:autofocus="autofocus"
@update:search="$emit('update:search', $event)"
@search-commit="$emit('search-commit')"
/>
</div>
<v-skeleton-loader v-if="loading" type="list-item@8" class="px-2 pt-1" />
<v-list v-else density="compact" nav class="terminal-server-rail__list flex-grow-1 py-0 px-2">
<PickerListItems :servers="servers" @select="$emit('select', $event)" />
</v-list>
</aside>
<v-card v-else-if="mode === 'inline'" border rounded="lg" class="empty-server-picker" max-width="520" width="100%">
<v-card-text class="pa-3">
<PickerSearch
v-if="showSearch"
:search="search"
:search-history="searchHistory"
:autofocus="autofocus"
@update:search="$emit('update:search', $event)"
@search-commit="$emit('search-commit')"
/>
<p v-if="!showList && listHint" class="text-body-2 text-center mb-0 mt-2 text-medium-emphasis">
{{ listHint }}
</p>
<template v-if="showList">
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" :class="{ 'mt-3': showSearch }" />
<v-list v-else density="compact" class="empty-server-picker__list" nav>
<PickerListItems :servers="servers" @select="$emit('select', $event)" />
</v-list>
</template>
</v-card-text>
</v-card>
<template v-else>
<PickerSearch
:search="search"
:search-history="searchHistory"
class="mb-3"
@update:search="$emit('update:search', $event)"
@search-commit="$emit('search-commit')"
/>
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" />
<v-list v-else density="compact" max-height="300" style="overflow-y: auto">
<PickerListItems :servers="servers" @select="$emit('select', $event)" />
</v-list>
</template>
</template>
<script setup lang="ts">
import PickerListItems from '@/components/terminal/TerminalServerPickerList.vue'
import PickerSearch from '@/components/terminal/TerminalServerPickerSearch.vue'
import type { TerminalServerItem } from '@/composables/terminal/types'
withDefaults(
defineProps<{
servers: TerminalServerItem[]
search?: string
searchHistory?: string[]
loading?: boolean
mode?: 'inline' | 'dialog' | 'sidebar'
showSearch?: boolean
showList?: boolean
listHint?: string
autofocus?: boolean
}>(),
{
mode: 'inline',
loading: false,
search: '',
searchHistory: () => [],
showSearch: true,
showList: true,
listHint: '',
autofocus: false,
},
)
defineEmits<{
'update:search': [value: string | null]
'search-commit': []
select: [serverId: number]
}>()
</script>
<style scoped>
.empty-server-picker__list {
max-height: min(42vh, 320px);
overflow-y: auto;
}
.terminal-server-rail {
width: 280px;
flex-shrink: 0;
min-height: 0;
height: 100%;
background: rgb(var(--v-theme-surface));
border-left: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.terminal-server-rail__list {
overflow-y: auto;
min-height: 0;
}
</style>
@@ -0,0 +1,94 @@
<template>
<v-list-item
v-for="s in servers"
:key="s.id"
rounded="lg"
density="compact"
class="terminal-server-item"
:class="dark ? 'terminal-server-item--dark' : 'terminal-server-item--light'"
@click="$emit('select', s.id)"
>
<template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template>
<v-list-item-title class="text-body-2 text-truncate terminal-server-item__name" :title="s.name">
{{ s.name }}
</v-list-item-title>
<v-list-item-subtitle
v-if="s.domain"
class="text-caption text-truncate terminal-server-item__domain"
:title="s.domain"
>
{{ s.domain }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item
v-if="servers.length === 0"
density="compact"
class="terminal-server-item terminal-server-item__empty"
:class="dark ? 'terminal-server-item--dark' : 'terminal-server-item--light'"
>
暂无匹配的服务器
</v-list-item>
</template>
<script setup lang="ts">
import type { TerminalServerItem } from '@/composables/terminal/types'
defineProps<{
servers: TerminalServerItem[]
/** Sidebar uses dark chrome; inline/dialog use light card. */
dark?: boolean
}>()
defineEmits<{
select: [serverId: number]
}>()
</script>
<style scoped>
.terminal-server-item {
min-height: 40px !important;
padding-block: 2px;
}
.terminal-server-item :deep(.v-list-item__prepend) {
align-self: center;
}
.terminal-server-item--light :deep(.v-list-item-title),
.terminal-server-item--light .terminal-server-item__name {
color: #1a1a1a !important;
opacity: 1 !important;
}
.terminal-server-item--light :deep(.v-list-item-subtitle),
.terminal-server-item--light .terminal-server-item__domain,
.terminal-server-item--light.terminal-server-item__empty {
color: #616161 !important;
opacity: 1 !important;
}
.terminal-server-item--light:hover {
background: rgba(0, 0, 0, 0.04);
}
.terminal-server-item--dark :deep(.v-list-item-title),
.terminal-server-item--dark .terminal-server-item__name {
color: #ffffff !important;
opacity: 1 !important;
}
.terminal-server-item--dark :deep(.v-list-item-subtitle),
.terminal-server-item--dark .terminal-server-item__domain,
.terminal-server-item--dark.terminal-server-item__empty {
color: #b0bec5 !important;
opacity: 1 !important;
}
.terminal-server-item--dark:hover {
background: rgba(255, 255, 255, 0.08);
}
.terminal-server-item--dark.v-list-item--active {
background: rgba(144, 202, 249, 0.16) !important;
}
.terminal-server-item--dark.v-list-item--active :deep(.v-list-item-title),
.terminal-server-item--dark.v-list-item--active .terminal-server-item__name {
color: #ffffff !important;
}
</style>
@@ -0,0 +1,134 @@
<template>
<v-theme-provider :theme="dark ? 'dark' : 'light'" :with-background="dark" class="terminal-server-search-wrap">
<v-combobox
ref="comboboxRef"
class="terminal-server-search"
:class="{ 'terminal-server-search--dark': dark }"
v-model="draft"
:items="searchHistory"
prepend-inner-icon="mdi-magnify"
placeholder="搜索名称/域名,回车筛选"
variant="outlined"
density="compact"
hide-details
clearable
:autofocus="autofocus"
:auto-select-first="false"
:bg-color="dark ? '#1a1a1a' : '#ffffff'"
:base-color="dark ? '#888888' : '#666666'"
@keydown.enter.prevent="onEnter"
/>
</v-theme-provider>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
const props = defineProps<{
search: string
searchHistory: string[]
dark?: boolean
autofocus?: boolean
}>()
const emit = defineEmits<{
'update:search': [value: string | null]
'search-commit': []
}>()
const comboboxRef = ref<{ $el?: HTMLElement } | null>(null)
/** Shared parent draft — both combobox instances bind here for instant sync. */
const draft = computed({
get: () => props.search,
set: (value: string | null) => {
emit('update:search', value)
},
})
function onEnter() {
emit('search-commit')
}
watch(
() => props.autofocus,
(focus) => {
if (!focus) return
void nextTick(() => focusInput())
},
{ immediate: true },
)
function focusInput() {
const el = comboboxRef.value?.$el as HTMLElement | undefined
const input = el?.querySelector('input')
input?.focus()
}
defineExpose({ focusInput })
</script>
<style scoped>
.terminal-server-search-wrap {
display: block;
width: 100%;
border-radius: 4px;
}
.terminal-server-search :deep(.v-field) {
opacity: 1;
}
.terminal-server-search :deep(.v-field__input),
.terminal-server-search :deep(.v-combobox__selection-text),
.terminal-server-search :deep(input) {
color: #1a1a1a !important;
-webkit-text-fill-color: #1a1a1a !important;
caret-color: #1a1a1a !important;
}
.terminal-server-search :deep(input::placeholder) {
color: #616161 !important;
opacity: 1 !important;
-webkit-text-fill-color: #616161 !important;
}
.terminal-server-search :deep(.v-field__prepend-inner .v-icon),
.terminal-server-search :deep(.v-field__append-inner .v-icon) {
color: #616161 !important;
opacity: 1 !important;
}
.terminal-server-search--dark :deep(.v-field) {
background: #1a1a1a !important;
}
.terminal-server-search--dark :deep(.v-field__outline) {
--v-field-border-opacity: 1;
color: #666 !important;
}
.terminal-server-search--dark :deep(.v-field--focused .v-field__outline) {
color: #90caf9 !important;
}
.terminal-server-search--dark :deep(.v-field__input),
.terminal-server-search--dark :deep(.v-combobox__selection-text),
.terminal-server-search--dark :deep(input) {
color: #ffffff !important;
-webkit-text-fill-color: #ffffff !important;
caret-color: #90caf9 !important;
}
.terminal-server-search--dark :deep(input::placeholder) {
color: #b0bec5 !important;
opacity: 1 !important;
-webkit-text-fill-color: #b0bec5 !important;
}
.terminal-server-search--dark :deep(.v-field__prepend-inner .v-icon),
.terminal-server-search--dark :deep(.v-field__append-inner .v-icon) {
color: #b0bec5 !important;
opacity: 1 !important;
}
</style>
@@ -0,0 +1,91 @@
<template>
<div class="d-flex align-center px-2 py-1 shrink-0 terminal-tab-bar">
<div class="d-flex align-center ga-1 overflow-x-auto flex-grow-1 min-w-0">
<v-chip
v-for="tab in sessions"
:key="tab.id"
:variant="tab.id === activeSessionId ? 'flat' : 'outlined'"
:color="tab.id === activeSessionId ? 'primary' : 'default'"
size="small"
label
class="cursor-pointer terminal-tab-bar__chip"
@click="$emit('switch', tab.id)"
>
<template #prepend>
<v-icon
size="8"
:color="
tab.status === 'connected'
? 'success'
: tab.status === 'connecting' || tab.status === 'idle'
? 'warning'
: tab.status === 'error'
? 'error'
: 'grey'
"
>
mdi-circle
</v-icon>
</template>
<span class="text-truncate terminal-tab-bar__title">{{ tab.serverName }}</span>
<v-icon end size="12" class="ml-1" @click.stop="$emit('close', tab.id)">mdi-close</v-icon>
</v-chip>
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="$emit('new')">
新建会话
</v-btn>
</div>
<v-btn
size="small"
variant="tonal"
class="ml-1 shrink-0"
prepend-icon="mdi-flash"
@click="$emit('quick-add')"
>
<span class="hidden-sm-and-down">快速添加(IP)</span>
</v-btn>
<v-btn
size="small"
variant="text"
class="ml-1 shrink-0"
:prepend-icon="serverRailOpen ? 'mdi-dock-right' : 'mdi-dock-left'"
@click="$emit('toggle-rail')"
>
<span class="hidden-sm-and-down">{{ serverRailOpen ? '隐藏列表' : '显示列表' }}</span>
</v-btn>
</div>
</template>
<script setup lang="ts">
import type { TermSessionData } from '@/composables/terminal/types'
defineProps<{
sessions: TermSessionData[]
activeSessionId: string
serverRailOpen: boolean
}>()
defineEmits<{
switch: [sessionId: string]
close: [sessionId: string]
new: []
'quick-add': []
'toggle-rail': []
}>()
</script>
<style scoped>
.terminal-tab-bar {
background: rgb(var(--v-theme-surface));
border-bottom: 1px solid rgb(var(--v-theme-surface-variant));
min-height: 36px;
}
.terminal-tab-bar__title {
max-width: 140px;
}
.terminal-tab-bar__chip {
max-width: 180px;
}
.cursor-pointer {
cursor: pointer;
}
</style>
@@ -0,0 +1,184 @@
<template>
<div class="d-flex align-center justify-space-between px-3 py-1 shrink-0 terminal-toolbar">
<div class="d-flex align-center ga-2 min-w-0 terminal-toolbar__meta">
<div class="min-w-0">
<div class="text-body-2 font-weight-medium text-truncate">
{{ session?.serverName || '...' }}
</div>
<div
v-if="session?.serverDomain"
class="text-caption text-truncate text-medium-emphasis font-mono"
>
{{ session.serverDomain }}
</div>
</div>
<span
v-if="session?.cwd"
class="text-caption font-mono terminal-toolbar__cwd text-medium-emphasis hidden-sm-and-down"
:title="session.cwd"
>
{{ session.cwd }}
</span>
<v-chip v-if="session" :color="statusColor" variant="tonal" size="x-small" label>
<template #prepend>
<v-icon start size="8">mdi-circle</v-icon>
</template>
{{ statusLabel }}
</v-chip>
<span v-if="session?.uptime" class="text-caption text-medium-emphasis hidden-sm-and-down">
{{ session.uptime }}
</span>
<span
v-if="session?.pingMs !== null && session?.pingMs !== undefined"
class="text-caption text-medium-emphasis hidden-sm-and-down"
>
{{ session.pingMs }}ms
</span>
</div>
<div class="d-flex align-center ga-1 terminal-toolbar-actions shrink-0">
<div class="d-none d-md-flex align-center ga-2">
<v-btn
v-if="session"
variant="tonal"
color="deep-orange"
size="x-small"
prepend-icon="mdi-login-variant"
title="一键登录宝塔面板"
class="terminal-toolbar__bt-login"
:loading="btLoginLoading"
:disabled="btLoginLoading"
@click="$emit('bt-login')"
>
宝塔登录
</v-btn>
<span class="text-caption text-medium-emphasis shrink-0">字号</span>
<v-btn variant="tonal" size="x-small" @click="$emit('font-change', -1)"></v-btn>
<span class="text-body-2 font-weight-medium terminal-toolbar__font-size">
{{ fontSize }}
</span>
<v-btn variant="tonal" size="x-small" @click="$emit('font-change', 1)">+</v-btn>
<v-select
:model-value="scrollback"
:items="scrollbackOptions"
item-title="title"
item-value="value"
label="回滚"
density="compact"
hide-details
variant="outlined"
class="terminal-toolbar-scrollback"
@update:model-value="$emit('scrollback-change', $event)"
/>
</div>
<v-menu location="bottom end">
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
class="d-md-none"
size="x-small"
variant="tonal"
icon="mdi-cog-outline"
/>
</template>
<v-list density="compact" nav class="py-1">
<v-list-item
v-if="session"
:disabled="btLoginLoading"
@click="$emit('bt-login')"
>
<template #prepend>
<v-icon color="deep-orange">mdi-login-variant</v-icon>
</template>
<v-list-item-title>一键登录宝塔</v-list-item-title>
</v-list-item>
<v-divider v-if="session" class="my-1" />
<v-list-subheader>字号 {{ fontSize }}</v-list-subheader>
<v-list-item @click="$emit('font-change', -1)">
<v-list-item-title>缩小</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('font-change', 1)">
<v-list-item-title>放大</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-subheader>回滚行数</v-list-subheader>
<v-list-item
v-for="opt in scrollbackOptions"
:key="opt.value"
:active="scrollback === opt.value"
@click="$emit('scrollback-change', opt.value)"
>
<v-list-item-title>{{ opt.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn variant="tonal" size="x-small" icon="mdi-fullscreen" title="全屏" @click="$emit('fullscreen')" />
<v-btn
variant="tonal"
size="x-small"
color="error"
icon="mdi-link-off"
title="断开"
@click="$emit('disconnect')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
sessionStatusColor,
sessionStatusLabel,
type TermSessionData,
} from '@/composables/terminal/types'
import { scrollbackOptions } from '@/composables/terminal/useTerminalSettings'
const props = defineProps<{
session: TermSessionData | null
fontSize: number
scrollback: number
btLoginLoading?: boolean
}>()
defineEmits<{
'font-change': [delta: number]
'scrollback-change': [value: number]
fullscreen: []
disconnect: []
'bt-login': []
}>()
const statusLabel = computed(() =>
props.session ? sessionStatusLabel(props.session) : '',
)
const statusColor = computed(() =>
props.session ? sessionStatusColor(props.session) : 'grey',
)
</script>
<style scoped>
.terminal-toolbar {
background: rgb(var(--v-theme-surface));
border-bottom: 1px solid rgb(var(--v-theme-surface-variant));
min-height: 36px;
}
.terminal-toolbar__cwd {
max-width: min(28vw, 240px);
}
.terminal-toolbar__font-size {
min-width: 1.25rem;
text-align: center;
}
.terminal-toolbar-scrollback {
min-width: 6.5rem;
max-width: 7.5rem;
}
.terminal-toolbar__bt-login {
font-weight: 600;
letter-spacing: 0.01em;
text-transform: none;
}
.font-mono {
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
}
</style>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue'
import { metricRingColor } from '@/utils/watchFormat'
const props = withDefaults(defineProps<{
label: string
accent: 'cpu' | 'mem' | 'disk' | 'load'
value: number | null | undefined
/** 圆心文字,默认 `${round(value)}%` */
center?: string
hint?: string
size?: number
/** 暂停态占位:灰色细环、不可交互 */
dimmed?: boolean
}>(), {
size: 68,
dimmed: false,
})
const ringWidth = computed(() => Math.max(4, Math.round(props.size * (props.dimmed ? 0.08 : 0.1))))
const ringColor = computed(() => {
if (props.dimmed) return 'rgba(var(--v-theme-on-surface), 0.22)'
return metricRingColor(props.accent, props.value)
})
const centerText = computed(() => {
if (props.dimmed) return '—'
if (props.center != null && props.center !== '') return props.center
return props.value != null ? `${Math.round(props.value)}%` : '—'
})
const ringValue = computed(() => {
if (props.dimmed) return 0
if (props.value == null || Number.isNaN(props.value)) return 0
return Math.max(0, Math.min(100, props.value))
})
const showTooltip = computed(() => !props.dimmed && Boolean(props.hint))
const centerFontPx = computed(() => `${Math.max(11, Math.round(props.size * 0.2))}px`)
</script>
<template>
<v-tooltip :text="hint" location="top" :disabled="!showTooltip">
<template #activator="{ props: tipProps }">
<div
class="watch-metric-ring text-center"
:class="{ 'watch-metric-ring--dimmed': dimmed }"
v-bind="tipProps"
>
<v-progress-circular
:model-value="ringValue"
:size="size"
:width="ringWidth"
:color="ringColor"
bg-color="rgba(var(--v-theme-on-surface), 0.08)"
class="watch-metric-ring__circle"
>
<span class="watch-metric-ring__value" :style="{ color: ringColor }">{{ centerText }}</span>
</v-progress-circular>
<div class="watch-metric-ring__label text-caption text-medium-emphasis mt-1">{{ label }}</div>
</div>
</template>
</v-tooltip>
</template>
<style scoped>
.watch-metric-ring {
flex: 1 1 0;
min-width: 0;
}
.watch-metric-ring__circle {
margin: 0 auto;
}
.watch-metric-ring__value {
font-size: v-bind(centerFontPx);
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.watch-metric-ring__label {
letter-spacing: 0.02em;
}
.watch-metric-ring--dimmed .watch-metric-ring__label {
opacity: 0.55;
}
.watch-metric-ring--dimmed .watch-metric-ring__value {
color: rgba(var(--v-theme-on-surface), 0.38) !important;
font-weight: 600;
}
</style>
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { http } from '@/api'
import {
probeStatusColor,
probeStatusLabel,
formatProbeError,
probeSourceLabel,
PROBE_STATUS_FILTER_ITEMS,
metricPctClass,
formatProbePct,
} from '@/utils/watchFormat'
import { formatDateTimeBeijing } from '@/utils/datetime'
export interface ProbeRecordRow {
id: number
recorded_at?: string
server_id?: number
server_name?: string
source?: string
probe_status?: string
duration_ms?: number
cpu_pct?: number | null
mem_pct?: number | null
disk_pct?: number | null
error?: string | null
}
const props = defineProps<{
serverId?: number | null
sessionId?: number | null
hours?: number
}>()
const items = ref<ProbeRecordRow[]>([])
const total = ref(0)
const page = ref(1)
const loading = ref(false)
const statusFilter = ref<string | null>(null)
const headers = [
{ title: '时间', key: 'recorded_at', width: 168 },
{ title: '服务器', key: 'server_name', minWidth: 120 },
{ title: '状态', key: 'probe_status', width: 96 },
{ title: '来源', key: 'source', width: 72 },
{ title: 'CPU', key: 'cpu_pct', width: 68, align: 'end' as const },
{ title: '内存', key: 'mem_pct', width: 68, align: 'end' as const },
{ title: '硬盘', key: 'disk_pct', width: 68, align: 'end' as const },
{ title: '耗时', key: 'duration_ms', width: 80, align: 'end' as const },
{ title: '错误', key: 'error', minWidth: 160 },
]
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / 50)))
const showPagination = computed(() => total.value > 0)
async function load() {
loading.value = true
try {
const data = await http.get<{ items: ProbeRecordRow[]; total: number }>('/watch/probe-records', {
server_id: props.serverId ?? undefined,
session_id: props.sessionId ?? undefined,
probe_status: statusFilter.value ?? undefined,
hours: props.hours ?? 24,
page: page.value,
per_page: 50,
})
items.value = data.items || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
watch(
() => [props.serverId, props.sessionId, props.hours, page.value, statusFilter.value] as const,
() => { load() },
{ immediate: true },
)
</script>
<template>
<div class="watch-probe-records">
<div class="d-flex align-center flex-wrap ga-2 mb-3">
<v-select
v-model="statusFilter"
:items="PROBE_STATUS_FILTER_ITEMS"
item-title="title"
item-value="value"
label="探针状态"
density="compact"
hide-details
variant="outlined"
prepend-inner-icon="mdi-filter-outline"
class="watch-probe-records__filter"
@update:model-value="page = 1"
/>
<v-chip size="small" variant="tonal" label>
{{ total }}
</v-chip>
<v-spacer />
<v-btn size="small" variant="tonal" prepend-icon="mdi-refresh" :loading="loading" @click="load">
刷新
</v-btn>
</div>
<v-sheet border rounded="lg" class="watch-probe-records__table-wrap">
<v-data-table
:headers="headers"
:items="items"
:loading="loading"
density="comfortable"
item-value="id"
hide-default-footer
fixed-header
height="min(520px, 60vh)"
class="watch-probe-table"
>
<template #item.recorded_at="{ item }">
<span class="watch-probe-time">{{ formatDateTimeBeijing(item.recorded_at) }}</span>
</template>
<template #item.server_name="{ item }">
<span class="text-body-2">{{ item.server_name || `ID ${item.server_id}` }}</span>
</template>
<template #item.probe_status="{ item }">
<v-chip size="x-small" :color="probeStatusColor(item.probe_status)" variant="tonal" label>
{{ probeStatusLabel(item.probe_status) }}
</v-chip>
</template>
<template #item.source="{ item }">
<span class="text-caption text-medium-emphasis">{{ probeSourceLabel(item.source) }}</span>
</template>
<template #item.cpu_pct="{ item }">
<span :class="metricPctClass(item.cpu_pct)">{{ formatProbePct(item.cpu_pct) }}</span>
</template>
<template #item.mem_pct="{ item }">
<span :class="metricPctClass(item.mem_pct)">{{ formatProbePct(item.mem_pct) }}</span>
</template>
<template #item.disk_pct="{ item }">
<span :class="metricPctClass(item.disk_pct)">{{ formatProbePct(item.disk_pct) }}</span>
</template>
<template #item.duration_ms="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.duration_ms ?? '—' }} ms</span>
</template>
<template #item.error="{ item }">
<span
class="text-caption text-truncate d-inline-block watch-probe-error"
:title="formatProbeError(item.error, item.probe_status)"
>{{ formatProbeError(item.error, item.probe_status) }}</span>
</template>
<template #no-data>
<div class="text-center py-10">
<v-icon icon="mdi-database-off-outline" size="40" color="disabled" class="mb-2" />
<div class="text-body-2 text-medium-emphasis">当前时间窗口内无探针记录</div>
</div>
</template>
</v-data-table>
</v-sheet>
<div v-if="showPagination" class="d-flex justify-center align-center mt-4">
<v-pagination
v-model="page"
:length="pageCount"
:total-visible="7"
density="comfortable"
rounded="circle"
size="small"
/>
</div>
</div>
</template>
<style scoped>
.watch-probe-records__filter {
max-width: 200px;
}
.watch-probe-records__table-wrap {
overflow: hidden;
}
.watch-probe-table :deep(thead th) {
font-size: 0.75rem !important;
text-transform: none;
letter-spacing: 0;
background: rgba(var(--v-theme-surface-variant), 0.35);
}
.watch-probe-time {
font-variant-numeric: tabular-nums;
font-size: 0.8125rem;
white-space: nowrap;
}
.watch-probe-error {
max-width: 220px;
color: rgba(var(--v-theme-on-surface), 0.72);
}
</style>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { http } from '@/api'
import type { WatchSlot, WatchProcess, WatchMetrics } from '@/composables/useWatchPins'
import {
formatLoadTriple,
formatMemMb,
formatProcessCounts,
normalizeProcessBundle,
formatProbePct,
} from '@/utils/watchFormat'
const props = defineProps<{
modelValue: boolean
slotData: WatchSlot | null
}>()
const emit = defineEmits<{ 'update:modelValue': [v: boolean] }>()
const processBundle = ref<{ top_cpu: WatchProcess[]; top_mem: WatchProcess[] }>({ top_cpu: [], top_mem: [] })
const loading = ref(false)
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const metrics = computed(() => props.slotData?.metrics as WatchMetrics | null | undefined)
const loadLine = computed(() =>
formatLoadTriple(metrics.value?.load_1, metrics.value?.load_5, metrics.value?.load_15),
)
const processCountLine = computed(() =>
formatProcessCounts(metrics.value?.process_running, metrics.value?.process_total),
)
const perCpu = computed(() => metrics.value?.per_cpu_pct || [])
const memLines = computed(() => {
const m = metrics.value
if (!m) return []
const lines: Array<{ label: string; value: string }> = []
if (m.mem_free_mb != null) lines.push({ label: '空闲内存', value: formatMemMb(m.mem_free_mb) })
if (m.memory_used_gb != null) {
lines.push({ label: '已用', value: formatMemMb(Math.round(m.memory_used_gb * 1024)) })
}
if (m.memory_total_gb != null) {
lines.push({ label: '总内存', value: formatMemMb(Math.round(m.memory_total_gb * 1024)) })
}
if (m.mem_shared_mb != null) lines.push({ label: '共享', value: formatMemMb(m.mem_shared_mb) })
if (m.memory_available_gb != null) {
lines.push({ label: '可分配内存', value: formatMemMb(Math.round(m.memory_available_gb * 1024)) })
}
if (m.mem_buffers_mb != null || m.mem_cached_mb != null) {
lines.push({
label: 'buff/cache',
value: `${formatMemMb(m.mem_buffers_mb ?? 0)} / ${formatMemMb(m.mem_cached_mb ?? 0)}`,
})
}
return lines
})
async function loadProcesses() {
if (!props.slotData?.server_id) return
loading.value = true
try {
const data = await http.get<{ processes: WatchProcess[] | { top_cpu?: WatchProcess[]; top_mem?: WatchProcess[] } }>(
`/watch/processes/${props.slotData.server_id}`,
)
processBundle.value = normalizeProcessBundle(data.processes || props.slotData.processes)
} catch {
processBundle.value = normalizeProcessBundle(props.slotData.processes)
} finally {
loading.value = false
}
}
watch(
() => [open.value, props.slotData?.server_id] as const,
([isOpen]) => {
if (isOpen) loadProcesses()
},
)
</script>
<template>
<v-navigation-drawer
v-model="open"
location="right"
temporary
width="400"
class="watch-detail-drawer"
>
<v-toolbar density="compact" flat>
<v-toolbar-title class="text-subtitle-1">
服务器详情 · {{ slotData?.server_name }}
</v-toolbar-title>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="open = false" />
</v-toolbar>
<v-divider />
<v-progress-linear v-if="loading" indeterminate />
<div class="pa-3">
<section class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-1">最近 1 / 5 / 15 分钟平均负载</div>
<div class="text-body-2 font-weight-medium">{{ loadLine }}</div>
</section>
<section class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-1">活动进程数 / 总进程数</div>
<div class="text-body-2">{{ processCountLine }}</div>
</section>
<section v-if="perCpu.length" class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-2">各核心 CPU 占用</div>
<div class="d-flex flex-wrap ga-1">
<v-chip
v-for="(pct, idx) in perCpu"
:key="idx"
size="x-small"
variant="tonal"
:color="pct >= 80 ? 'error' : pct >= 50 ? 'warning' : 'primary'"
label
>
核心 {{ idx + 1 }} {{ formatProbePct(pct) }}
</v-chip>
</div>
</section>
<section v-if="memLines.length" class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-2">内存信息</div>
<div
v-for="row in memLines"
:key="row.label"
class="d-flex justify-space-between text-body-2 watch-detail-kv"
>
<span class="text-medium-emphasis">{{ row.label }}</span>
<span class="font-weight-medium">{{ row.value }}</span>
</div>
</section>
<section class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-2">CPU 占用率 Top5</div>
<v-list v-if="processBundle.top_cpu.length" density="compact" class="watch-detail-list py-0">
<v-list-item v-for="(p, i) in processBundle.top_cpu" :key="`cpu-${p.pid}-${i}`">
<v-list-item-title class="text-body-2 text-truncate">{{ p.name || '—' }}</v-list-item-title>
<v-list-item-subtitle>
PID {{ p.pid ?? '—' }} · CPU {{ formatProbePct(p.cpu_pct) }} · MEM {{ formatProbePct(p.mem_pct) }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="text-caption text-medium-emphasis">暂无数据 10 秒刷新</div>
</section>
<section class="watch-detail-section">
<div class="text-caption text-medium-emphasis mb-2">内存占用率 Top5</div>
<v-list v-if="processBundle.top_mem.length" density="compact" class="watch-detail-list py-0">
<v-list-item v-for="(p, i) in processBundle.top_mem" :key="`mem-${p.pid}-${i}`">
<v-list-item-title class="text-body-2 text-truncate">{{ p.name || '—' }}</v-list-item-title>
<v-list-item-subtitle>
PID {{ p.pid ?? '—' }} · MEM {{ formatProbePct(p.mem_pct) }} · CPU {{ formatProbePct(p.cpu_pct) }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="text-caption text-medium-emphasis">暂无数据 10 秒刷新</div>
</section>
</div>
</v-navigation-drawer>
</template>
<style scoped>
.watch-detail-section {
padding-bottom: 4px;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.watch-detail-section:last-child {
border-bottom: none;
}
.watch-detail-kv + .watch-detail-kv {
margin-top: 4px;
}
.watch-detail-list :deep(.v-list-item) {
min-height: 44px;
padding-inline: 0;
}
</style>
@@ -0,0 +1,445 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WatchSlot } from '@/composables/useWatchPins'
import {
WATCH_TTL_OPTIONS,
normalizeWatchTtlMinutes,
watchTtlShortLabel,
type WatchTtlMinutes,
} from '@/constants/watchTtl'
import { formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError, isWatchMetricsPending, formatLoadAvg, formatCpuSpec, formatMemSpec, formatMemSub, formatDiskSpec, formatDiskSub, formatWatchSystemBanner, formatUsagePair, loadUsagePct, loadStatusLabel } from '@/utils/watchFormat'
import ServerAgentHint from '@/components/servers/ServerAgentHint.vue'
import type { ServerAgentAction } from '@/types/api'
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
import WatchMetricRing from '@/components/watch/WatchMetricRing.vue'
const props = defineProps<{
slot: WatchSlot
psutilInstallLoading?: boolean
agentActionLoading?: boolean
}>()
const emit = defineEmits<{
remove: [slotIndex: number]
processes: [slot: WatchSlot]
history: [slot: WatchSlot]
pick: [slotIndex: number]
monitoring: [slotIndex: number, enabled: boolean]
ttl: [slotIndex: number, minutes: WatchTtlMinutes]
installPsutil: [slotIndex: number]
installAgent: [slotIndex: number]
upgradeAgent: [slotIndex: number]
}>()
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
const slotTtlMinutes = computed(() =>
normalizeWatchTtlMinutes(props.slot.ttl_minutes ?? (props.slot.ttl_hours === 24 ? 1440 : props.slot.ttl_hours === 8 ? 480 : undefined)),
)
const showPsutilInstall = computed(() =>
monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status),
)
const slotAgentAction = computed(() => props.slot.agent_action as ServerAgentAction | undefined)
const showAgentHint = computed(() =>
monitoringOn.value && slotAgentAction.value && slotAgentAction.value !== 'ok' && !!props.slot.agent_action_message,
)
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
const load1 = computed(() => props.slot.metrics?.load_1)
const load5 = computed(() => props.slot.metrics?.load_5)
const load15 = computed(() => props.slot.metrics?.load_15)
const loadBarPct = computed(() => loadUsagePct(load1.value, props.slot.metrics?.cpu_cores))
const loadRingCenter = computed(() => {
if (load1.value == null) return undefined
return formatLoadAvg(load1.value)
})
const loadRingHint = computed(() => {
const parts: string[] = []
if (load1.value != null) parts.push(`1 分钟 ${formatLoadAvg(load1.value)}`)
if (load5.value != null && load15.value != null) {
parts.push(`5 / 15 ${formatLoadAvg(load5.value)} / ${formatLoadAvg(load15.value)}`)
}
const status = loadStatusLabel(load1.value, props.slot.metrics?.cpu_cores)
if (status) parts.push(status)
return parts.join(' · ')
})
const systemBanner = computed(() => formatWatchSystemBanner(props.slot.metrics))
const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPending(props.slot.metrics))
const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
</script>
<template>
<v-card
v-if="!slot.empty"
elevation="0"
border
rounded="lg"
:link="false"
:ripple="false"
class="watch-slot-card pa-3 watch-slot-card--filled"
:class="{
'watch-slot-card--error': monitoringOn && slot.metrics?.probe_status && slot.metrics.probe_status !== 'ok',
'watch-slot-card--paused': !monitoringOn,
}"
role="button"
tabindex="0"
@click="monitoringOn ? emit('history', slot) : undefined"
@keydown.enter.prevent="monitoringOn ? emit('history', slot) : undefined"
>
<div class="watch-slot-header d-flex align-center mb-2" @click.stop>
<div class="text-subtitle-2 text-truncate flex-grow-1 min-width-0" :title="slot.server_name">
{{ slot.server_name || `ID ${slot.server_id}` }}
</div>
<div class="watch-slot-header-actions d-flex align-center flex-shrink-0">
<v-chip size="x-small" variant="tonal" :color="monitoringOn ? 'info' : 'warning'" label>
{{ slot.slot_index + 1 }}
</v-chip>
<v-menu location="bottom end" :close-on-content-click="true">
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-dots-vertical"
size="x-small"
variant="text"
class="watch-slot-menu-btn"
aria-label="槽位操作"
@click.stop
/>
</template>
<v-list density="compact" class="watch-slot-menu-list" min-width="168">
<v-list-item
v-if="monitoringOn && slotAgentAction === 'upgrade'"
prepend-icon="mdi-arrow-up-bold"
title="升级 Agent"
@click="emit('upgradeAgent', slot.slot_index)"
/>
<v-list-item
v-if="monitoringOn && slotAgentAction === 'install'"
prepend-icon="mdi-download-network"
title="安装 Agent"
@click="emit('installAgent', slot.slot_index)"
/>
<v-divider v-if="monitoringOn && (slotAgentAction === 'upgrade' || slotAgentAction === 'install')" class="my-1" />
<v-list-item
v-if="monitoringOn"
prepend-icon="mdi-pause-circle-outline"
title="暂停实时监测"
@click="emit('monitoring', slot.slot_index, false)"
/>
<v-list-item
v-else
prepend-icon="mdi-play-circle-outline"
title="恢复实时监测"
@click="emit('monitoring', slot.slot_index, true)"
/>
<v-divider class="my-1" />
<v-list-item
prepend-icon="mdi-close-circle-outline"
title="移除此槽"
base-color="error"
@click="emit('remove', slot.slot_index)"
/>
</v-list>
</v-menu>
</div>
</div>
<div v-if="monitoringOn" class="watch-ttl-row mb-2" @click.stop>
<span class="text-caption text-medium-emphasis watch-ttl-label">时长</span>
<div class="watch-ttl-chips d-flex flex-wrap ga-1">
<v-chip
v-for="opt in WATCH_TTL_OPTIONS"
:key="opt.minutes"
:color="slotTtlMinutes === opt.minutes ? 'primary' : undefined"
:variant="slotTtlMinutes === opt.minutes ? 'flat' : 'outlined'"
size="x-small"
label
class="watch-ttl-chip"
@click="emit('ttl', slot.slot_index, opt.minutes)"
>
{{ opt.label }}
</v-chip>
</div>
</div>
<ServerAgentHint
v-if="showAgentHint"
class="mb-2"
compact
:action="slotAgentAction"
:message="slot.agent_action_message"
:loading="props.agentActionLoading"
@install="emit('installAgent', slot.slot_index)"
@upgrade="emit('upgradeAgent', slot.slot_index)"
/>
<div
v-if="!monitoringOn"
class="watch-slot-paused-body flex-grow-1"
@click.stop
>
<div class="watch-slot-paused-cta text-center">
<v-icon icon="mdi-radar-off" size="40" class="text-medium-emphasis mb-3 watch-slot-paused-icon" />
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-play-circle-outline"
@click="emit('monitoring', slot.slot_index, true)"
>
恢复实时监测
</v-btn>
<div class="text-caption text-medium-emphasis mt-3 px-4">
槽位与子机绑定保留 · 关闭期间由 Agent 60 秒上报
</div>
</div>
</div>
<template v-else>
<div @click.stop>
<div
v-if="metricsPending"
class="watch-slot-waiting d-flex flex-column align-center justify-center text-center py-6"
>
<v-progress-circular indeterminate color="primary" size="36" width="3" />
<div class="text-body-2 mt-3">等待服务器回传信息</div>
<div class="text-caption text-medium-emphasis mt-1 px-2">
探针约每 5 秒轮询首次通常 515
</div>
</div>
<template v-else>
<v-chip
v-if="slot.metrics?.probe_status"
size="x-small"
:color="probeStatusColor(slot.metrics.probe_status)"
variant="tonal"
class="mb-2"
label
>
{{ probeStatusLabel(slot.metrics.probe_status) }}
</v-chip>
<div
v-if="systemBanner.short"
class="text-caption text-medium-emphasis mb-2 text-truncate"
:title="systemBanner.full"
>
{{ systemBanner.short }}
</div>
<div class="watch-metric-rings d-flex justify-space-between mb-2">
<WatchMetricRing
label="CPU"
accent="cpu"
:value="slot.metrics?.cpu_pct"
:hint="formatCpuSpec(slot.metrics)"
:size="56"
/>
<WatchMetricRing
label="内存"
accent="mem"
:value="slot.metrics?.mem_pct"
:hint="[formatMemSpec(slot.metrics), formatMemSub(slot.metrics)].filter(Boolean).join(' · ')"
:size="56"
/>
<WatchMetricRing
label="硬盘"
accent="disk"
:value="slot.metrics?.disk_pct"
:hint="[formatDiskSpec(slot.metrics), formatDiskSub(slot.metrics)].filter(Boolean).join(' · ')"
:size="56"
/>
<WatchMetricRing
label="负载"
accent="load"
:value="loadBarPct"
:center="loadRingCenter"
:hint="loadRingHint"
:size="56"
/>
</div>
<div v-if="showSwap" class="text-caption text-medium-emphasis mb-2 text-center">
交换分区
<span v-if="slot.metrics?.swap_used_gb != null && slot.metrics?.swap_total_gb != null">
· 已用 {{ formatUsagePair(slot.metrics.swap_used_gb, slot.metrics.swap_total_gb) }}
</span>
<span v-if="slot.metrics?.swap_pct != null"> · {{ slot.metrics.swap_pct }}%</span>
</div>
<v-divider class="my-2" />
<v-btn-toggle v-model="chartMetric" density="compact" variant="outlined" divided class="mb-1">
<v-btn value="cpu_pct" size="x-small">CPU</v-btn>
<v-btn value="mem_pct" size="x-small">内存</v-btn>
<v-btn value="disk_pct" size="x-small">硬盘</v-btn>
</v-btn-toggle>
<WatchSparkline :points="slot.sparkline || []" :metric="chartMetric" />
<div
v-if="slot.metrics?.error"
class="text-caption text-error mt-1"
:title="formatProbeError(slot.metrics.error, slot.metrics.probe_status)"
>
<div class="text-truncate">
{{ formatProbeError(slot.metrics.error, slot.metrics.probe_status) }}
</div>
<v-btn
v-if="showPsutilInstall"
size="x-small"
variant="tonal"
color="primary"
class="mt-1"
:loading="props.psutilInstallLoading"
@click.stop="emit('installPsutil', slot.slot_index)"
>
安装 psutilSSH
</v-btn>
</div>
<div class="d-flex align-center justify-space-between mt-2">
<span class="text-caption text-medium-emphasis">
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
</span>
<div class="d-flex ga-1">
<v-btn
v-if="monitoringOn"
size="x-small"
variant="text"
class="watch-slot-pause-btn"
@click.stop="emit('monitoring', slot.slot_index, false)"
>
暂停
</v-btn>
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">详情</v-btn>
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div>
</div>
</template>
<div v-if="metricsPending" class="d-flex align-center justify-space-between mt-2">
<span class="text-caption text-medium-emphasis">
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
</span>
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div>
</div>
</template>
</v-card>
<v-card
v-else
elevation="0"
border
rounded="lg"
:link="false"
:ripple="false"
class="watch-slot-card watch-slot-card--empty d-flex align-center justify-center pa-4"
variant="outlined"
role="button"
tabindex="0"
@click="emit('pick', slot.slot_index)"
@keydown.enter.prevent="emit('pick', slot.slot_index)"
>
<div class="text-center text-medium-emphasis">
<v-icon size="28" class="mb-1">mdi-plus-circle-outline</v-icon>
<div class="text-caption"> {{ slot.slot_index + 1 }} · 添加监测</div>
</div>
</v-card>
</template>
<style scoped>
.watch-slot-card {
min-height: 320px;
}
/* Vuetify v-card--link 会在 hover 时铺 v-card__overlay 灰层,此处仅保留外框高亮 */
.watch-slot-card :deep(.v-card__overlay) {
opacity: 0 !important;
}
.watch-slot-card--empty {
min-height: 220px;
border-style: dashed !important;
cursor: pointer;
transition: border-color 0.15s ease;
}
.watch-slot-card--empty:hover {
border-color: rgb(var(--v-theme-primary)) !important;
}
.watch-slot-card--filled {
cursor: pointer;
transition: border-color 0.15s ease;
display: flex;
flex-direction: column;
}
.watch-slot-card--filled:hover {
border-color: rgb(var(--v-theme-primary)) !important;
}
.watch-slot-card--error {
border-color: rgb(var(--v-theme-error)) !important;
}
.watch-slot-card--paused {
cursor: default;
min-height: 200px;
}
.watch-slot-card--paused:hover {
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.watch-slot-paused-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1 1 auto;
min-height: 140px;
}
.watch-slot-paused-cta {
width: 100%;
padding: 8px 0 12px;
}
.watch-slot-paused-icon {
opacity: 0.45;
}
.watch-slot-header-actions {
gap: 4px;
margin-left: 8px;
}
.watch-slot-menu-btn {
flex: 0 0 auto;
opacity: 0.72;
}
.watch-slot-menu-btn:hover {
opacity: 1;
}
.watch-slot-pause-btn {
color: rgba(var(--v-theme-warning), 0.95);
}
.watch-ttl-row {
display: flex;
align-items: flex-start;
gap: 6px;
}
.watch-ttl-label {
flex: 0 0 auto;
line-height: 24px;
}
.watch-ttl-chips {
flex: 1 1 auto;
min-width: 0;
}
.watch-ttl-chip {
cursor: pointer;
user-select: none;
}
.watch-slot-waiting {
min-height: 160px;
}
.watch-metric-rings {
gap: 2px;
}
.watch-metric-rings :deep(.watch-metric-ring__label) {
font-size: 11px;
white-space: nowrap;
}
</style>

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