release: nexus btpanel session fix and app-v2
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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/
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,3 @@
|
||||
import vuetify from 'eslint-config-vuetify'
|
||||
|
||||
export default vuetify()
|
||||
@@ -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>
|
||||
Generated
+2932
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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服务器
|
||||
|
@@ -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>
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
@@ -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 |
@@ -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()) || '格式: 3–4 位八进制'
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 秒轮询,首次通常 5–15 秒
|
||||
</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)"
|
||||
>
|
||||
安装 psutil(SSH)
|
||||
</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
Reference in New Issue
Block a user