refactor: migrate frontend from Vue 3 to React 19
Build-Deploy-Server / build (base, debian, ) (push) Failing after 3m7s
Build-Docker-ARM64 / build (base, debian, ) (push) Failing after 23s
Build-Docker-ARM64 / build (base-debian13, debian13, -debian13) (push) Failing after 10s
Build-Docker-ARM64 / build (base-minimal, minimal, -minimal) (push) Failing after 10s
Deploy VitePress to Pages / build (push) Has been cancelled
Deploy VitePress to Pages / Deploy (push) Has been cancelled
Build-Deploy-Server / build (base, debian, ) (push) Failing after 3m7s
Build-Docker-ARM64 / build (base, debian, ) (push) Failing after 23s
Build-Docker-ARM64 / build (base-debian13, debian13, -debian13) (push) Failing after 10s
Build-Docker-ARM64 / build (base-minimal, minimal, -minimal) (push) Failing after 10s
Deploy VitePress to Pages / build (push) Has been cancelled
Deploy VitePress to Pages / Deploy (push) Has been cancelled
- Replace Vue 3 with React 19 - Use TanStack Router and Query - MoonShadow-inspired layout with sidebar navigation - Lucide icons (stroke-width: 1.5) - Framer Motion animations - Theme support (dark/light) - Vite 7 + pnpm build system
This commit is contained in:
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/assets/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
+12
-17
@@ -1,17 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:," />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
<!-- 引入国内主流的 Google Fonts 加速 CDN (loli.net) 的 Noto Sans SC 字体 -->
|
||||
<link rel="stylesheet" href="https://fonts.loli.net/css2?family=Noto+Sans+SC:wght@100..900&display=swap" />
|
||||
</head>
|
||||
<!-- <h1>test-pack</h1> -->
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TaskPool</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-8026
File diff suppressed because it is too large
Load Diff
+22
-51
@@ -1,63 +1,34 @@
|
||||
{
|
||||
"name": "web-ui",
|
||||
"name": "taskpool-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@guolao/vue-monaco-editor": "^1.6.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@tanstack/react-query": "^5.80.0",
|
||||
"@tanstack/react-router": "^1.120.0",
|
||||
"@tanstack/react-table": "^8.21.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"ansi-to-vue3": "^0.1.2",
|
||||
"apexcharts": "^5.3.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cronstrue": "^3.14.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fzstd": "^0.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"pako": "^2.1.0",
|
||||
"radix-vue": "^1.9.17",
|
||||
"reka-ui": "^2.6.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vaul-vue": "^0.4.1",
|
||||
"vue": "^3.5.24",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"vue3-apexcharts": "^1.10.0",
|
||||
"xterm": "^5.3.0"
|
||||
"framer-motion": "^12.0.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"recharts": "^2.15.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.5",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-static-copy": "^2.3.0",
|
||||
"vue-tsc": "^3.1.4"
|
||||
},
|
||||
"overrides": {
|
||||
"vite-plugin-static-copy": {
|
||||
"vite": "$vite"
|
||||
},
|
||||
"esbuild": "^0.28.1"
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^8.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1208
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="4" fill="#3b82f6"/>
|
||||
<text x="16" y="22" text-anchor="middle" fill="white" font-family="system-ui" font-size="18" font-weight="700">T</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 267 B |
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { LogOut } from 'lucide-vue-next'
|
||||
import { activeInterconnectNodeId, activeInterconnectNodeName, setActiveInterconnectNodeId } from '@/api'
|
||||
|
||||
function exitTravel() {
|
||||
setActiveInterconnectNodeId('')
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 全局应用设备差异化垂直抗锯齿
|
||||
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.userAgent)
|
||||
document.documentElement.classList.add(isMac ? 'antialiased' : 'subpixel-antialiased')
|
||||
|
||||
// Windows 平台检测,用于在 CSS 中调整字体优先级
|
||||
const isWindows = /Win/.test(navigator.userAgent)
|
||||
if (isWindows) {
|
||||
document.documentElement.classList.add('is-windows')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<Toaster position="bottom-right" :duration="2000" />
|
||||
|
||||
<!-- 穿越子节点时显示的悬浮“返回主节点”控制条 -->
|
||||
<div v-if="activeInterconnectNodeId" class="fixed bottom-4 left-4 z-[9999] group">
|
||||
<button
|
||||
@click="exitTravel"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium
|
||||
bg-amber-500/85 hover:bg-amber-500 text-white shadow-md shadow-amber-500/10
|
||||
transition-all duration-300 border border-amber-400/10 backdrop-blur-sm
|
||||
hover:scale-105 active:scale-95 cursor-pointer"
|
||||
>
|
||||
<LogOut class="h-3 w-3 transition-transform group-hover:-translate-x-0.5" />
|
||||
<span>返回主节点 <template v-if="activeInterconnectNodeName">当前:({{ activeInterconnectNodeName }})</template></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
+11
-801
@@ -1,4 +1,3 @@
|
||||
// 获取 base URL(从后端注入的全局变量)
|
||||
const BASE_URL = (window as any).__BASE_URL__ || ''
|
||||
const API_VERSION = (window as any).__API_VERSION__ || '/api/v1'
|
||||
const API_BASE_URL = BASE_URL + API_VERSION
|
||||
@@ -9,815 +8,26 @@ interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface MonitorStats {
|
||||
env: {
|
||||
os: string
|
||||
arch: string
|
||||
go_version: string
|
||||
num_cpu: number
|
||||
goroutines: number
|
||||
}
|
||||
host: {
|
||||
cpu_percent: number
|
||||
mem_total: number
|
||||
mem_used: number
|
||||
mem_percent: number
|
||||
disk_total: number
|
||||
disk_used: number
|
||||
disk_percent: number
|
||||
uptime: number
|
||||
platform: string
|
||||
}
|
||||
mem: {
|
||||
alloc: number
|
||||
total_alloc: number
|
||||
sys: number
|
||||
lookups: number
|
||||
mallocs: number
|
||||
frees: number
|
||||
}
|
||||
heap: {
|
||||
heap_alloc: number
|
||||
heap_sys: number
|
||||
heap_idle: number
|
||||
heap_inuse: number
|
||||
heap_released: number
|
||||
heap_objects: number
|
||||
}
|
||||
gc: {
|
||||
next_gc: number
|
||||
last_gc: number
|
||||
pause_total_ns: number
|
||||
num_gc: number
|
||||
}
|
||||
scheduler: {
|
||||
scheduled: number
|
||||
running: number
|
||||
queue_size: number
|
||||
worker_count: number
|
||||
workers: {
|
||||
id: number
|
||||
status: string
|
||||
task_id?: string
|
||||
task_name?: string
|
||||
start_time?: number
|
||||
duration?: number
|
||||
}[]
|
||||
}
|
||||
}
|
||||
export let activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
|
||||
export let activeInterconnectNodeName = localStorage.getItem('activeInterconnectNodeName') || ''
|
||||
|
||||
export function setActiveInterconnectNodeId(id: string, name?: string) {
|
||||
activeInterconnectNodeId = id
|
||||
if (name !== undefined) {
|
||||
activeInterconnectNodeName = name
|
||||
}
|
||||
localStorage.removeItem('site_settings_cache')
|
||||
if (id) {
|
||||
localStorage.setItem('activeInterconnectNodeId', id)
|
||||
if (name) localStorage.setItem('activeInterconnectNodeName', name)
|
||||
document.cookie = `active_interconnect_node_id=${id}; path=/; max-age=${7 * 24 * 3600}`
|
||||
} else {
|
||||
localStorage.removeItem('activeInterconnectNodeId')
|
||||
localStorage.removeItem('activeInterconnectNodeName')
|
||||
activeInterconnectNodeName = ''
|
||||
document.cookie = `active_interconnect_node_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC`
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
credentials: 'include', // 携带 Cookie
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
throw err
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
})
|
||||
|
||||
const json: ApiResponse<T> = await res.json()
|
||||
|
||||
if (json.code === 401) {
|
||||
// 未登录或登录过期,跳转到登录页
|
||||
window.location.href = BASE_URL + '/login'
|
||||
throw new Error(json.msg || '请先登录')
|
||||
throw new Error(json.msg || 'Unauthorized')
|
||||
}
|
||||
|
||||
if (json.code !== 200) {
|
||||
throw new Error(json.msg || '请求失败')
|
||||
throw new Error(json.msg || 'Request failed')
|
||||
}
|
||||
|
||||
return json.data
|
||||
}
|
||||
|
||||
// 检查登录状态(不触发自动跳转)
|
||||
export async function checkAuth(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/auth/me`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
const json: ApiResponse<{ username: string }> = await res.json()
|
||||
return json.code === 200
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
login: (data: { username: string; password: string }) =>
|
||||
request<{ user: string }>('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
|
||||
logout: () => request('/auth/logout', { method: 'POST' }),
|
||||
me: () => request<{ username: string; role: string }>('/auth/me'),
|
||||
register: (data: { username: string; password: string; email: string }) =>
|
||||
request('/auth/register', { method: 'POST', body: JSON.stringify(data) })
|
||||
},
|
||||
tasks: {
|
||||
list: (params?: { page?: number; page_size?: number; name?: string; agent_id?: string; tags?: string; type?: string; sort_by?: string; order?: string }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.page) query.set('page', String(params.page))
|
||||
if (params?.page_size) query.set('page_size', String(params.page_size))
|
||||
if (params?.name) query.set('name', params.name)
|
||||
if (params?.tags) query.set('tags', params.tags)
|
||||
if (params?.agent_id) query.set('agent_id', params.agent_id)
|
||||
if (params?.type) query.set('type', params.type)
|
||||
if (params?.sort_by) query.set('sort_by', params.sort_by)
|
||||
if (params?.order) query.set('order', params.order)
|
||||
return request<TaskListResponse>(`/tasks?${query}`)
|
||||
},
|
||||
create: (data: Partial<Task>) => request<Task>('/tasks', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: Partial<Task>) => request<Task>(`/tasks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string, params?: { delete_files?: boolean }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.delete_files !== undefined) query.set('delete_files', String(params.delete_files))
|
||||
const queryString = query.toString()
|
||||
return request(`/tasks/${id}${queryString ? '?' + queryString : ''}`, { method: 'DELETE' })
|
||||
},
|
||||
batchDelete: (ids: string[]) => request<{ count: number }>('/tasks/batch-delete', { method: 'POST', body: JSON.stringify({ ids }) }),
|
||||
batchDeleteByQuery: (params?: { name?: string, agent_id?: string, tags?: string, type?: string }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.name) query.append('name', params.name)
|
||||
if (params?.agent_id) query.append('agent_id', params.agent_id)
|
||||
if (params?.tags) query.append('tags', params.tags)
|
||||
if (params?.type && params.type !== 'all') query.append('type', params.type)
|
||||
return request<{ count: number }>(`/tasks/batch-by-query?${query.toString()}`, { method: 'DELETE' })
|
||||
},
|
||||
execute: (id: string) => request<ExecutionResult>(`/execute/task/${id}`, { method: 'POST' }),
|
||||
stop: (logID: string) => request(`/tasks/stop/${logID}`, { method: 'POST' }),
|
||||
tags: () => request<string[]>('/tasks/tags')
|
||||
},
|
||||
scripts: {
|
||||
list: () => request<Script[]>('/scripts'),
|
||||
create: (data: Partial<Script>) => request<Script>('/scripts', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: Partial<Script>) => request<Script>(`/scripts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => request(`/scripts/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
env: {
|
||||
list: (params?: { page?: number; page_size?: number; name?: string; type?: string; tags?: string }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.page) query.set('page', String(params.page))
|
||||
if (params?.page_size) query.set('page_size', String(params.page_size))
|
||||
if (params?.name) query.set('name', params.name)
|
||||
if (params?.type && params.type !== 'all') query.set('type', params.type)
|
||||
if (params?.tags) query.set('tags', params.tags)
|
||||
return request<EnvListResponse>(`/env?${query}`)
|
||||
},
|
||||
tags: () => request<string[]>('/env/tags'),
|
||||
secretStatus: () => request<boolean>('/env/secret-status'),
|
||||
all: () => request<EnvVar[]>('/env/all'),
|
||||
tasks: (id: string) => request<Task[]>(`/env/${id}/tasks`),
|
||||
create: (data: Partial<EnvVar>) => request<EnvVar>('/env', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: Partial<EnvVar>) => request<EnvVar>(`/env/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string, force?: boolean) => {
|
||||
const query = force ? '?force=true' : ''
|
||||
return fetch(`${API_BASE_URL}/env/${id}${query}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
}).then(res => res.json() as Promise<ApiResponse<any>>)
|
||||
}
|
||||
},
|
||||
execute: {
|
||||
command: (command: string) => request('/execute/command', { method: 'POST', body: JSON.stringify({ command }) }),
|
||||
results: () => request('/execute/results')
|
||||
},
|
||||
logs: {
|
||||
list: (params?: { page?: number; page_size?: number; task_id?: string; task_name?: string; status?: string }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.page) query.set('page', String(params.page))
|
||||
if (params?.page_size) query.set('page_size', String(params.page_size))
|
||||
if (params?.task_id) query.set('task_id', params.task_id)
|
||||
if (params?.task_name) query.set('task_name', params.task_name)
|
||||
if (params?.status) query.set('status', params.status)
|
||||
return request<LogListResponse>(`/logs?${query}`)
|
||||
},
|
||||
get: (id: string) => request<LogDetail>(`/logs/${id}`),
|
||||
detail: (id: string) => request<LogDetail>(`/logs/${id}`),
|
||||
delete: (id: string) => request(`/logs/${id}`, { method: 'DELETE' }),
|
||||
clear: (taskId?: string) => request('/logs/clear', { method: 'POST', body: JSON.stringify({ task_id: taskId }) })
|
||||
},
|
||||
dashboard: {
|
||||
stats: () => request<Stats>('/stats'),
|
||||
sentence: () => request<{ sentence: string }>('/sentence'),
|
||||
sendStats: (days?: number) => request<DailyStats[]>(`/sendstats${days ? `?days=${days}` : ''}`),
|
||||
taskStats: (days?: number) => request<TaskStatsItem[]>(`/taskstats${days ? `?days=${days}` : ''}`)
|
||||
},
|
||||
settings: {
|
||||
getMonitor: () => request<MonitorStats>('/monitor'),
|
||||
changePassword: (data: { old_username?: string; username?: string; old_password: string; new_password?: string }) =>
|
||||
request('/settings/password', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getSite: () => request<SiteSettings>('/settings/site'),
|
||||
getPublicSite: () => request<{ title: string; subtitle: string; icon: string; demo_mode: boolean }>('/settings/public'),
|
||||
updateSite: (data: SiteSettings) =>
|
||||
request('/settings/site', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
generateOpenapiToken: () => request<{ token: string }>('/settings/site/openapi-token/generate', { method: 'POST' }),
|
||||
getScheduler: () => request<SchedulerSettings>('/settings/scheduler'),
|
||||
updateScheduler: (data: SchedulerSettings) =>
|
||||
request('/settings/scheduler', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
getPaths: () => request<{ scripts_dir: string }>('/settings/paths'),
|
||||
getAbout: () => request<AboutInfo>('/settings/about'),
|
||||
getChangelog: () => request<string>('/settings/changelog'),
|
||||
get: (section: string, key: string) => request<string>(`/settings/${section}/${key}`),
|
||||
getSection: (section: string) => request<Record<string, string>>(`/settings/${section}`),
|
||||
setSection: (section: string, values: Record<string, string>) =>
|
||||
request(`/settings/${section}`, { method: 'PUT', body: JSON.stringify(values) }),
|
||||
generateToken: (section: string, key: string) =>
|
||||
request<string>(`/settings/${section}/${key}/generate`, { method: 'POST' }),
|
||||
getLoginLogs: (params?: { page?: number; page_size?: number; username?: string }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.page) query.set('page', String(params.page))
|
||||
if (params?.page_size) query.set('page_size', String(params.page_size))
|
||||
if (params?.username) query.set('username', params.username)
|
||||
return request<LoginLogListResponse>(`/settings/loginlogs?${query}`)
|
||||
},
|
||||
createBackup: () => request('/settings/backup', { method: 'POST' }),
|
||||
getBackupStatus: () => request<{ has_backup: boolean; backup_time: string }>('/settings/backup/status'),
|
||||
downloadBackup: () => `${API_BASE_URL}/settings/backup/download`,
|
||||
restoreBackup: async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch(`${API_BASE_URL}/settings/restore`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
})
|
||||
const json: ApiResponse<null> = await res.json()
|
||||
if (json.code === 401) {
|
||||
window.location.href = BASE_URL + '/login'
|
||||
throw new Error('请先登录')
|
||||
}
|
||||
if (json.code !== 200) throw new Error(json.msg || '恢复失败')
|
||||
}
|
||||
},
|
||||
files: {
|
||||
tree: () => request<FileNode[]>('/files/tree'),
|
||||
getContent: (path: string) => request<{ path: string; content: string }>(`/files/content?path=${encodeURIComponent(path)}`),
|
||||
download: (path: string) => `${API_BASE_URL}/files/download?path=${encodeURIComponent(path)}`,
|
||||
downloadZip: (path: string) => `${API_BASE_URL}/files/download-zip?path=${encodeURIComponent(path)}`,
|
||||
saveContent: (path: string, content: string) => request('/files/content', { method: 'POST', body: JSON.stringify({ path, content }) }),
|
||||
create: (path: string, isDir: boolean) => request('/files/create', { method: 'POST', body: JSON.stringify({ path, isDir }) }),
|
||||
delete: (path: string) => request('/files/delete', { method: 'POST', body: JSON.stringify({ path }) }),
|
||||
rename: (oldPath: string, newPath: string) => request('/files/rename', { method: 'POST', body: JSON.stringify({ oldPath, newPath }) }),
|
||||
move: (oldPath: string, newPath: string) => request('/files/move', { method: 'POST', body: JSON.stringify({ oldPath, newPath }) }),
|
||||
copy: (sourcePath: string, targetPath: string) => request('/files/copy', { method: 'POST', body: JSON.stringify({ sourcePath, targetPath }) }),
|
||||
uploadArchive: async (file: File, targetPath?: string) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (targetPath) formData.append('path', targetPath)
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/files/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
})
|
||||
const json: ApiResponse<null> = await res.json()
|
||||
if (json.code === 401) {
|
||||
window.location.href = BASE_URL + '/login'
|
||||
throw new Error('请先登录')
|
||||
}
|
||||
if (json.code !== 200) throw new Error(json.msg || '上传失败')
|
||||
},
|
||||
uploadFiles: async (files: FileList, paths: string[], targetPath?: string) => {
|
||||
const formData = new FormData()
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
if (file) {
|
||||
formData.append('files', file)
|
||||
formData.append('paths', paths[i] || file.name)
|
||||
}
|
||||
}
|
||||
if (targetPath) formData.append('path', targetPath)
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/files/uploadfiles`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
})
|
||||
const json: ApiResponse<null> = await res.json()
|
||||
if (json.code === 401) {
|
||||
window.location.href = BASE_URL + '/login'
|
||||
throw new Error('请先登录')
|
||||
}
|
||||
if (json.code !== 200) throw new Error(json.msg || '上传失败')
|
||||
}
|
||||
},
|
||||
deps: {
|
||||
list: (params?: { language?: string; lang_version?: string }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.language) query.set('language', params.language)
|
||||
if (params?.lang_version) query.set('lang_version', params.lang_version)
|
||||
return request<Dependency[]>(`/deps?${query}`)
|
||||
},
|
||||
create: (data: { name: string; version?: string; language: string; lang_version?: string; remark?: string }) =>
|
||||
request<Dependency>('/deps', { method: 'POST', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => request(`/deps/${id}`, { method: 'DELETE' }),
|
||||
install: (data: any) => request<any>('/deps/install', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getInstallCmd: (data: any) => request<{ command: string }>('/deps/install-cmd', { method: 'POST', body: JSON.stringify(data) }),
|
||||
uninstall: (id: string, force?: boolean) => {
|
||||
const query = force ? '?force=true' : ''
|
||||
return request<any>(`/deps/uninstall/${id}${query}`, { method: 'POST' })
|
||||
},
|
||||
reinstall: (id: string) => request(`/deps/reinstall/${id}`, { method: 'POST' }),
|
||||
reinstallAll: (language: string, lang_version?: string) => {
|
||||
const query = new URLSearchParams({ language })
|
||||
if (lang_version) query.set('lang_version', lang_version)
|
||||
return request(`/deps/reinstall-all?${query}`, { method: 'POST' })
|
||||
},
|
||||
getReinstallAllCmd: (language: string, lang_version?: string) => {
|
||||
const query = new URLSearchParams({ language })
|
||||
if (lang_version) query.set('lang_version', lang_version)
|
||||
return request<{ command: string }>(`/deps/reinstall-all-cmd?${query}`, { method: 'POST' })
|
||||
},
|
||||
getBatchInstallCmd: (data: { items: { name: string; version?: string; language: string; lang_version?: string }[] }) =>
|
||||
request<{ command: string }>('/deps/batch-install-cmd', { method: 'POST', body: JSON.stringify(data) }),
|
||||
import: (data: { language: string; lang_version?: string; content: string; import_db?: boolean }) =>
|
||||
request<{ dependencies: Dependency[]; command: string }>('/deps/import', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getInstalled: (language: string, lang_version?: string) => {
|
||||
const query = new URLSearchParams({ language })
|
||||
if (lang_version) query.set('lang_version', lang_version)
|
||||
return request<Dependency[]>(`/deps/installed?${query}`)
|
||||
},
|
||||
getInstallSuggestCmd: (logID: string) => request<{ command: string }>(`/deps/install-suggest-cmd?log_id=${logID}`)
|
||||
},
|
||||
agents: {
|
||||
list: () => request<Agent[]>('/agents'),
|
||||
getVersion: () => request<{ version: string; platforms: { os: string; arch: string; filename: string }[] }>('/agents/version'),
|
||||
update: (id: string, data: { name: string; description?: string; enabled: boolean; scheduler_config: SchedulerConfig | null }) =>
|
||||
request('/agents/' + id, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => request('/agents/' + id, { method: 'DELETE' }),
|
||||
forceUpdate: (id: string) => request('/agents/' + id + '/update', { method: 'POST' }),
|
||||
downloadUrl: (os: string, arch: string) => `${API_BASE_URL}/agent/download?os=${os}&arch=${arch}`,
|
||||
// 令牌管理
|
||||
listTokens: () => request<AgentToken[]>('/agents/tokens'),
|
||||
createToken: (data: { remark?: string; max_uses?: number; expires_at?: string }) =>
|
||||
request<AgentToken>('/agents/tokens', { method: 'POST', body: JSON.stringify(data) }),
|
||||
deleteToken: (id: string) => request('/agents/tokens/' + id, { method: 'DELETE' }),
|
||||
updateToken: (id: string, data: { remark?: string; max_uses?: number; expires_at?: string }) =>
|
||||
request<AgentToken>('/agents/tokens/' + id, { method: 'PUT', body: JSON.stringify(data) })
|
||||
},
|
||||
mise: {
|
||||
list: () => request<MiseLanguage[]>('/mise/ls'),
|
||||
sync: () => request<void>('/mise/sync', { method: 'POST' }),
|
||||
plugins: () => request<string[]>('/mise/plugins'),
|
||||
versions: (plugin: string) => request<string[]>(`/mise/versions?plugin=${plugin}`),
|
||||
verifyCommand: (plugin: string, version: string) => request<{ command: string }>(`/mise/verify-cmd?plugin=${plugin}&version=${version}`),
|
||||
useGlobal: (plugin: string, version: string) => request<void>('/mise/use-global', { method: 'POST', body: JSON.stringify({ plugin, version }) }),
|
||||
unsetGlobal: (plugin: string, version: string) => request<void>('/mise/unset-global', { method: 'POST', body: JSON.stringify({ plugin, version }) }),
|
||||
getEnvs: () => request<Record<string, string>>('/mise/envs'),
|
||||
setEnv: (key: string, value: string) => request<void>('/mise/envs', { method: 'POST', body: JSON.stringify({ key, value }) }),
|
||||
unsetEnv: (key: string) => request<void>(`/mise/envs?key=${key}`, { method: 'DELETE' })
|
||||
},
|
||||
terminal: {
|
||||
cmds: () => request<{ name: string, description: string }[]>('/terminal/cmds')
|
||||
},
|
||||
notify: {
|
||||
getTypes: () => request<{ channel_types: ChannelType[]; event_types: EventType[] }>('/notify/types'),
|
||||
getChannels: () => request<NotifyChannel[]>('/notify/channels'),
|
||||
saveChannel: (data: Partial<NotifyChannel>) =>
|
||||
request('/notify/channels', { method: 'POST', body: JSON.stringify(data) }),
|
||||
deleteChannel: (id: string) => request('/notify/channels/' + id, { method: 'DELETE' }),
|
||||
testChannel: (data: Partial<NotifyChannel>) =>
|
||||
request<NotifyResult>('/notify/channels/test', { method: 'POST', body: JSON.stringify(data) }),
|
||||
|
||||
getBindings: () => request<NotifyBinding[]>('/notify/bindings'),
|
||||
saveBinding: (data: Partial<NotifyBinding>) =>
|
||||
request<NotifyBinding>('/notify/bindings', { method: 'POST', body: JSON.stringify(data) }),
|
||||
saveBindingsBatch: (data: { type: string; data_id: string; bindings: Partial<NotifyBinding>[] }) =>
|
||||
request('/notify/bindings/batch', { method: 'POST', body: JSON.stringify(data) }),
|
||||
deleteBinding: (id: string) => request('/notify/bindings/' + id, { method: 'DELETE' }),
|
||||
send: (data: { channel_id: string; title: string; text: string }) =>
|
||||
request<NotifyResult>('/notify/send', { method: 'POST', body: JSON.stringify(data) })
|
||||
},
|
||||
appLogs: {
|
||||
list: (params?: { page?: number; page_size?: number; category?: string; status?: string; level?: string; keyword?: string }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.page) query.set('page', String(params.page))
|
||||
if (params?.page_size) query.set('page_size', String(params.page_size))
|
||||
if (params?.category) query.set('category', params.category)
|
||||
if (params?.status) query.set('status', params.status)
|
||||
if (params?.level) query.set('level', params.level)
|
||||
if (params?.keyword) query.set('keyword', params.keyword)
|
||||
return request<AppLogListResponse>(`/app-logs?${query}`)
|
||||
},
|
||||
markAsRead: (data: { id?: string; category?: string }) => request('/app-logs/read', { method: 'POST', body: JSON.stringify(data) }),
|
||||
clear: (category: string) => request('/app-logs/clear', { method: 'POST', body: JSON.stringify({ category }) })
|
||||
},
|
||||
webui: {
|
||||
list: () => request<WebUI[]>('/webui'),
|
||||
upload: async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch(`${API_BASE_URL}/webui/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
})
|
||||
const json: ApiResponse<{ message: string, theme: string }> = await res.json()
|
||||
if (json.code === 401) {
|
||||
window.location.href = BASE_URL + '/login'
|
||||
throw new Error('请先登录')
|
||||
}
|
||||
if (json.code !== 200) throw new Error(json.msg || '上传失败')
|
||||
return json.data
|
||||
},
|
||||
setActive: (name: string) => request<{ message: string }>('/webui/active', { method: 'PUT', body: JSON.stringify({ name }) }),
|
||||
delete: (name: string) => request<{ message: string }>(`/webui/${name}`, { method: 'DELETE' })
|
||||
},
|
||||
system: {
|
||||
export: (data: { task_ids?: string[], env_ids?: string[] }) => request<any>('/system/export', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebUI {
|
||||
name: string
|
||||
version: string
|
||||
author: string
|
||||
description: string
|
||||
min_panel_version: string
|
||||
}
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
modTime: number
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
name: string
|
||||
remark: string
|
||||
command: string
|
||||
pre_command: string
|
||||
post_command: string
|
||||
tags: string
|
||||
type: string
|
||||
trigger_type: string
|
||||
config: string
|
||||
schedule: string
|
||||
timeout: number
|
||||
work_dir: string
|
||||
clean_config: string
|
||||
envs: string
|
||||
retry_count: number
|
||||
retry_interval: number
|
||||
random_range: number
|
||||
pin_type: 'none' | 'top'
|
||||
languages: { name: string; version: string }[]
|
||||
agent_id: string | null
|
||||
enabled: boolean
|
||||
last_run: string
|
||||
next_run: string
|
||||
running_status?: string
|
||||
repo_task_id?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface RepoConfig {
|
||||
source_type: string
|
||||
source_url: string
|
||||
target_path: string
|
||||
branch: string
|
||||
sparse_path: string
|
||||
single_file: boolean
|
||||
proxy: string
|
||||
proxy_url: string
|
||||
auth_token: string
|
||||
whitelist_paths?: string
|
||||
blacklist?: string
|
||||
dependence?: string
|
||||
extensions?: string
|
||||
auto_add_cron?: boolean
|
||||
commenttotask?: string
|
||||
concurrency?: number
|
||||
repo_source?: string
|
||||
repo_dir_name?: string
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
task_id: string
|
||||
log_id?: string
|
||||
success: boolean
|
||||
status?: string
|
||||
output?: string
|
||||
error?: string
|
||||
duration?: number
|
||||
exit_code?: number
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
}
|
||||
|
||||
export interface TaskListResponse {
|
||||
data: Task[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface Script {
|
||||
id: string
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface EnvVar {
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
remark: string
|
||||
type: string
|
||||
hidden: boolean
|
||||
enabled: boolean
|
||||
tags: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface EnvListResponse {
|
||||
data: EnvVar[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
tasks: number
|
||||
today_execs: number
|
||||
envs: number
|
||||
logs: number
|
||||
scheduled: number
|
||||
running: number
|
||||
}
|
||||
|
||||
|
||||
export interface TaskLog {
|
||||
id: string
|
||||
task_id: string
|
||||
task_name: string
|
||||
task_type: string
|
||||
command: string
|
||||
status: string
|
||||
duration: number
|
||||
error: string | null
|
||||
start_time: string | null
|
||||
end_time: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface LogListResponse {
|
||||
data: TaskLog[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface LogDetail {
|
||||
id: string
|
||||
task_id: string
|
||||
command: string
|
||||
output: string
|
||||
error: string | null
|
||||
status: string
|
||||
duration: number
|
||||
start_time: string | null
|
||||
end_time: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AboutInfo {
|
||||
version: string
|
||||
remote_version?: string
|
||||
build_time: string
|
||||
mem_usage: string
|
||||
goroutines: number
|
||||
uptime: string
|
||||
task_count: number
|
||||
log_count: number
|
||||
env_count: number
|
||||
}
|
||||
|
||||
export interface SiteSettings {
|
||||
title: string
|
||||
subtitle: string
|
||||
icon: string
|
||||
page_size: string
|
||||
cookie_days: string
|
||||
openapi_enabled?: boolean
|
||||
openapi_token?: string
|
||||
openapi_token_expire?: string
|
||||
system_notice_days?: string
|
||||
system_notice_max_count?: string
|
||||
push_log_days?: string
|
||||
push_log_max_count?: string
|
||||
login_log_days?: string
|
||||
login_log_max_count?: string
|
||||
scheduler_log_days?: string
|
||||
scheduler_log_max_count?: string
|
||||
active_webui?: string
|
||||
}
|
||||
|
||||
export interface SchedulerSettings {
|
||||
worker_count: string
|
||||
queue_size: string
|
||||
rate_interval: string
|
||||
}
|
||||
|
||||
|
||||
export interface LoginLog {
|
||||
id: string
|
||||
username: string
|
||||
ip: string
|
||||
user_agent: string
|
||||
status: string
|
||||
message: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface LoginLogListResponse {
|
||||
data: LoginLog[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface DailyStats {
|
||||
day: string
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export interface TaskStatsItem {
|
||||
task_id: string
|
||||
task_name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface Dependency {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
language: string
|
||||
lang_version: string
|
||||
remark: string
|
||||
log: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
token: string
|
||||
machine_id: string
|
||||
description: string
|
||||
status: string
|
||||
last_seen: string
|
||||
ip: string
|
||||
version: string
|
||||
build_time: string
|
||||
hostname: string
|
||||
os: string
|
||||
arch: string
|
||||
enabled: boolean
|
||||
scheduler_config: SchedulerConfig | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SchedulerConfig {
|
||||
worker_count: number
|
||||
queue_size: number
|
||||
rate_interval: number
|
||||
verbose: boolean
|
||||
strict_queue: boolean
|
||||
}
|
||||
|
||||
export interface AgentToken {
|
||||
id: string
|
||||
token: string
|
||||
remark: string
|
||||
max_uses: number
|
||||
used_count: number
|
||||
expires_at: string | null
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MiseLanguage {
|
||||
plugin: string
|
||||
version: string
|
||||
source: { type?: string; path?: string } | string
|
||||
is_global: boolean
|
||||
install_path?: string
|
||||
installed_at?: string // 安装日期
|
||||
}
|
||||
|
||||
export interface ChannelType {
|
||||
type: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface EventType {
|
||||
type: string
|
||||
label: string
|
||||
binding_type?: string
|
||||
}
|
||||
|
||||
export interface NotifyChannel {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
created_at?: string
|
||||
config: Record<string, string>
|
||||
}
|
||||
|
||||
export interface NotifyBinding {
|
||||
id: string
|
||||
type: string
|
||||
event: string
|
||||
way_id: string
|
||||
data_id: string
|
||||
extra?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface BindingExtra {
|
||||
enable_log: boolean
|
||||
log_limit: number
|
||||
}
|
||||
|
||||
export interface NotifyResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface AppLog {
|
||||
id: string
|
||||
category: string
|
||||
title: string
|
||||
content: string
|
||||
level: string
|
||||
status: string
|
||||
ref_id: string
|
||||
channel_name?: string
|
||||
error_msg: string
|
||||
created_at: string
|
||||
read_at: string | null
|
||||
}
|
||||
|
||||
export interface AppLogListResponse {
|
||||
data: AppLog[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const LOG_CATEGORY = {
|
||||
SYSTEM_NOTICE: 'system_notice',
|
||||
PUSH_LOG: 'push_log',
|
||||
LOGIN_LOG: 'login_log',
|
||||
SCHEDULER_LOG: 'scheduler_log'
|
||||
} as const
|
||||
|
||||
export const LOG_LEVEL = {
|
||||
INFO: 'info',
|
||||
WARNING: 'warning',
|
||||
ERROR: 'error'
|
||||
} as const
|
||||
|
||||
export const LOG_STATUS = {
|
||||
UNREAD: 'unread',
|
||||
READ: 'read',
|
||||
SUCCESS: 'success',
|
||||
FAILED: 'failed'
|
||||
} as const
|
||||
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { request } from '@/api'
|
||||
|
||||
export interface InterconnectNode {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
token: string
|
||||
remark: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
status?: string
|
||||
metrics?: {
|
||||
cpu_percent: number
|
||||
mem_percent: number
|
||||
disk_percent: number
|
||||
}
|
||||
last_heartbeat_at?: string
|
||||
}
|
||||
|
||||
export function getNodes() {
|
||||
return request<InterconnectNode[]>('/interconnect/nodes', { method: 'GET' })
|
||||
}
|
||||
|
||||
export function createNode(data: Partial<InterconnectNode>) {
|
||||
return request<InterconnectNode>('/interconnect/nodes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function updateNode(id: string, data: Partial<InterconnectNode>) {
|
||||
return request<InterconnectNode>(`/interconnect/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteNode(id: string) {
|
||||
return request<void>(`/interconnect/nodes/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function getNodeStatus(id: string) {
|
||||
return request<any>(`/interconnect/nodes/${id}/status`, { method: 'GET' })
|
||||
}
|
||||
|
||||
export function syncScript(node_ids: string[], filename: string, content: string) {
|
||||
return request<any[]>('/interconnect/sync/script', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ node_ids, filename, content })
|
||||
})
|
||||
}
|
||||
|
||||
export function syncEnv(node_ids: string[], envs: any[]) {
|
||||
return request<any[]>('/interconnect/sync/env', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ node_ids, envs })
|
||||
})
|
||||
}
|
||||
|
||||
export function syncTask(node_ids: string[], tasks: any[]) {
|
||||
return request<any[]>('/interconnect/sync/task', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ node_ids, tasks })
|
||||
})
|
||||
}
|
||||
|
||||
export function getChildStatus() {
|
||||
return request<{ parent_url: string; parent_token: string; connected: boolean }>('/interconnect/child/status', { method: 'GET' })
|
||||
}
|
||||
@@ -1,634 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@fontsource-variable/inter/index.css";
|
||||
|
||||
@import "@fontsource-variable/jetbrains-mono/index.css";
|
||||
|
||||
/* Sonner toast styles */
|
||||
[data-sonner-toaster] {
|
||||
--width: 356px;
|
||||
--border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast] {
|
||||
background: var(--popover) !important;
|
||||
color: var(--popover-foreground) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: var(--radius) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
font-family: var(--font-main) !important;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--breakpoint-3xl: 1921px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #252525;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #252525;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #252525;
|
||||
--primary: #333333;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f3f4f6;
|
||||
--secondary-foreground: #333333;
|
||||
--muted: #f3f4f6;
|
||||
--muted-foreground: #6b7280;
|
||||
--accent: #f3f4f6;
|
||||
--accent-foreground: #333333;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--input: #e5e7eb;
|
||||
--ring: #9ca3af;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #f9fafb;
|
||||
--sidebar-foreground: #252525;
|
||||
--sidebar-primary: #333333;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #f3f4f6;
|
||||
--sidebar-accent-foreground: #333333;
|
||||
--sidebar-border: #e5e7eb;
|
||||
--sidebar-ring: #9ca3af;
|
||||
|
||||
/* 字体栈定义 - 优先使用 Inter Variable 以获得最佳的可变字体支持 */
|
||||
--font-main: 'Inter Variable', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Source Han Sans CN", sans-serif;
|
||||
}
|
||||
|
||||
.is-windows {
|
||||
--font-main: 'Inter Variable', 'Inter', 'Noto Sans SC', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Source Han Sans CN", sans-serif;
|
||||
}
|
||||
|
||||
/* OKLCH support targeting modern browsers */
|
||||
@supports (color: oklch(1 0 0)) {
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #1a1a1a;
|
||||
--foreground: #f9fafb;
|
||||
--card: #242424;
|
||||
--card-foreground: #f9fafb;
|
||||
--popover: #242424;
|
||||
--popover-foreground: #f9fafb;
|
||||
--primary: #f3f4f6;
|
||||
--primary-foreground: #252525;
|
||||
--secondary: #2d2d2d;
|
||||
--secondary-foreground: #f9fafb;
|
||||
--muted: #2d2d2d;
|
||||
--muted-foreground: #9ca3af;
|
||||
--accent: #2d2d2d;
|
||||
--accent-foreground: #f9fafb;
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #f9fafb;
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--input: rgba(255, 255, 255, 0.15);
|
||||
--ring: #6b7280;
|
||||
--sidebar: #1a1a1a;
|
||||
--sidebar-foreground: #f9fafb;
|
||||
--sidebar-primary: #60a5fa;
|
||||
--sidebar-primary-foreground: #f9fafb;
|
||||
--sidebar-accent: #2d2d2d;
|
||||
--sidebar-accent-foreground: #f9fafb;
|
||||
--sidebar-border: rgba(255, 255, 255, 0.12);
|
||||
--sidebar-ring: #6b7280;
|
||||
}
|
||||
|
||||
@supports (color: oklch(1 0 0)) {
|
||||
.dark {
|
||||
--background: oklch(0.22 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.26 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.26 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.32 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.32 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.32 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--sidebar: oklch(0.26 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.32 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 12%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply outline-ring/50;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.border,
|
||||
[class*="border-"] {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* 增强字体栈与高级特性优化 */
|
||||
font-family: var(--font-main);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.011em;
|
||||
|
||||
/* Inter 字体高级特性优化:
|
||||
cv05: l 增加尾钩 (提升识别)
|
||||
cv08: G 增加尖角
|
||||
cv11: 单层小写 a
|
||||
ss01: 开口数字 (更现代)
|
||||
ss03: 圆形 0
|
||||
*/
|
||||
font-feature-settings: "cv05", "cv08", "cv11", "ss01", "ss03";
|
||||
font-optical-sizing: auto;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
input, textarea, button, select {
|
||||
font-family: var(--font-main) !important;
|
||||
}
|
||||
|
||||
.font-code,
|
||||
.font-mono,
|
||||
code,
|
||||
pre {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
移动端字体独立优化(< 640px)
|
||||
大屏保持不变,小屏单独增重、优化渲染
|
||||
======================================== */
|
||||
@media (max-width: 639px) {
|
||||
body {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: -0.011em;
|
||||
/* 移动端增强字体渲染 */
|
||||
font-family: var(--font-main);
|
||||
font-feature-settings: "cv05", "cv08", "cv11", "ss01", "ss03";
|
||||
font-optical-sizing: auto;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 标题类在小屏下保持稳健厚实 */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 正文与描述类文字回归 400,依靠 15.5px 的字号保证清晰度 */
|
||||
p,
|
||||
span:not([class*="icon"]),
|
||||
label,
|
||||
[class*="description"],
|
||||
[class*="muted"],
|
||||
.text-muted-foreground {
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 针对移动端小字保持适度加重 */
|
||||
.text-xs {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 14.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 交互性元素显著加厚,提升点击信心 */
|
||||
button,
|
||||
[role="button"] {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 表格单元格保持高对比度 */
|
||||
td, th {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高级感增强 - 微妙的渐变背景 */
|
||||
body {
|
||||
background-color: var(--background);
|
||||
background: linear-gradient(135deg, var(--background) 0%, #f9fafb 100%);
|
||||
background: linear-gradient(135deg, var(--background) 0%, oklch(from var(--background) calc(l * 0.98) c h) 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: var(--background);
|
||||
background: linear-gradient(135deg, var(--background) 0%, #252525 100%);
|
||||
background: linear-gradient(135deg, var(--background) 0%, oklch(from var(--background) calc(l * 1.05) c h) 100%);
|
||||
}
|
||||
|
||||
/* 卡片增强 - 添加微妙阴影 */
|
||||
[data-slot="card"] {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
[data-slot="card"]:hover {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.08);
|
||||
}
|
||||
|
||||
.dark [data-slot="card"] {
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
.dark [data-slot="card"]:hover {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* 按钮增强 - 更流畅的交互 */
|
||||
button,
|
||||
[role="button"] {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
button:active,
|
||||
[role="button"]:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 输入框增强 - 聚焦效果 */
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
box-shadow: 0 0 0 3px oklch(from var(--ring) l c h / 0.1);
|
||||
transition: box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 链接增强 */
|
||||
a {
|
||||
transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 表格增强 - 悬停效果 */
|
||||
table tbody tr {
|
||||
transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
table tbody tr:hover {
|
||||
background-color: oklch(from var(--muted) l c h / 0.5);
|
||||
}
|
||||
|
||||
/* 模态框和弹出层增强 - 背景模糊 */
|
||||
[role="dialog"]::backdrop,
|
||||
[data-state="open"]::backdrop {
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 代码块增强 */
|
||||
code,
|
||||
pre {
|
||||
background: oklch(from var(--muted) l c h / 0.5);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Windows 小字体优化 */
|
||||
.text-xs,
|
||||
.text-sm,
|
||||
[class*="text-xs"],
|
||||
[class*="text-sm"] {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* 表格和列表中的小字体优化 */
|
||||
table,
|
||||
[role="table"] {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 命令和路径文本优化 */
|
||||
code,
|
||||
pre,
|
||||
[class*="font-mono"] {
|
||||
-webkit-font-smoothing: auto;
|
||||
-moz-osx-font-smoothing: auto;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 徽章和标签增强 */
|
||||
[class*="badge"],
|
||||
[class*="tag"] {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
[class*="badge"]:hover,
|
||||
[class*="tag"]:hover {
|
||||
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* 加载动画增强 */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg,
|
||||
oklch(from var(--muted) l c h / 0.3) 0%,
|
||||
oklch(from var(--muted) l c h / 0.5) 50%,
|
||||
oklch(from var(--muted) l c h / 0.3) 100%);
|
||||
background-size: 1000px 100%;
|
||||
animation: shimmer 2s infinite linear;
|
||||
}
|
||||
|
||||
/* 滚动条美化增强 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(from var(--muted-foreground) l c h / 0.2);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(from var(--muted-foreground) l c h / 0.3);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: oklch(from var(--muted-foreground) l c h / 0.3);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(from var(--muted-foreground) l c h / 0.4);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* 选中文本样式 */
|
||||
::selection {
|
||||
background: oklch(from var(--primary) l c h / 0.2);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.dark ::selection {
|
||||
background: oklch(from var(--primary) l c h / 0.3);
|
||||
}
|
||||
|
||||
/* Number input spinner - Refined SVG version with more spacing */
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 22px;
|
||||
/* Increased width for better hit area and visibility */
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
/* Premium SVG Arrows: Increased gap between up and down arrows */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 17 5 5 5-5'/%3E%3Cpath d='m7 7 5-5 5 5'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 14px;
|
||||
transition: opacity 0.2s ease;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
/* Hover effect: Smoothly light up the arrows */
|
||||
input[type="number"]:hover::-webkit-inner-spin-button {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Light mode support */
|
||||
:root:not(.dark) input[type="number"]::-webkit-inner-spin-button {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 17 5 5 5-5'/%3E%3Cpath d='m7 7 5-5 5 5'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Date/Time picker enhancement */
|
||||
input[type="datetime-local"],
|
||||
input[type="date"],
|
||||
input[type="time"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark input[type="datetime-local"],
|
||||
.dark input[type="date"],
|
||||
.dark input[type="time"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
|
||||
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
input[type="time"]::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
input[type="datetime-local"]:hover::-webkit-calendar-picker-indicator,
|
||||
input[type="date"]:hover::-webkit-calendar-picker-indicator,
|
||||
input[type="time"]:hover::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.7 0 0 / 30%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.6 0 0 / 50%);
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 40%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.6 0 0 / 60%);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.7 0 0 / 30%) transparent;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar {
|
||||
scrollbar-color: oklch(0.5 0 0 / 40%) transparent;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* ApexCharts tooltip fix */
|
||||
.apexcharts-tooltip {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-title {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Folder, ChevronRight, ChevronDown, FolderOpen } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { api, type FileNode } from '@/api'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
fileTree?: FileNode[]
|
||||
defaultExpand?: string
|
||||
rootLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const loading = ref(false)
|
||||
const internalFileTree = ref<FileNode[]>([])
|
||||
const fileTree = computed(() => props.fileTree || internalFileTree.value)
|
||||
const expandedDirs = ref<Set<string>>(new Set())
|
||||
|
||||
watch(() => props.defaultExpand, (newVal) => {
|
||||
if (newVal) {
|
||||
const parts = newVal.split('/')
|
||||
let current = ''
|
||||
for (const part of parts) {
|
||||
current = current ? `${current}/${part}` : part
|
||||
expandedDirs.value.add(current)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
interface FlatDir {
|
||||
path: string
|
||||
name: string
|
||||
depth: number
|
||||
hasChildren: boolean
|
||||
}
|
||||
|
||||
// 将树形结构扁平化,只保留目录
|
||||
function flattenDirs(nodes: FileNode[], depth = 0): FlatDir[] {
|
||||
const result: FlatDir[] = []
|
||||
for (const node of nodes) {
|
||||
if (!node.isDir) continue
|
||||
const children = node.children?.filter(c => c.isDir) || []
|
||||
result.push({
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
depth,
|
||||
hasChildren: children.length > 0
|
||||
})
|
||||
if (expandedDirs.value.has(node.path) && node.children) {
|
||||
result.push(...flattenDirs(node.children, depth + 1))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const flatDirs = computed(() => flattenDirs(fileTree.value))
|
||||
|
||||
async function loadTree() {
|
||||
if (props.fileTree) return
|
||||
if (internalFileTree.value.length > 0) return
|
||||
loading.value = true
|
||||
try {
|
||||
internalFileTree.value = await api.files.tree()
|
||||
} catch {
|
||||
internalFileTree.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDir(path: string, e: Event) {
|
||||
e.stopPropagation()
|
||||
if (expandedDirs.value.has(path)) {
|
||||
expandedDirs.value.delete(path)
|
||||
} else {
|
||||
expandedDirs.value.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
function selectDir(path: string) {
|
||||
emit('update:modelValue', path)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function selectRoot() {
|
||||
emit('update:modelValue', '')
|
||||
open.value = false
|
||||
}
|
||||
|
||||
// 检查是否选中(支持绝对路径匹配)
|
||||
function isSelected(dirPath: string): boolean {
|
||||
if (!props.modelValue) return false
|
||||
// 直接匹配相对路径
|
||||
if (props.modelValue === dirPath) return true
|
||||
// 绝对路径以相对路径结尾
|
||||
if (props.modelValue.endsWith('/' + dirPath)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function getRootLabel() {
|
||||
return props.rootLabel || 'scripts (默认)'
|
||||
}
|
||||
|
||||
// 检查是否是默认目录
|
||||
function isDefaultSelected(): boolean {
|
||||
if (!props.modelValue || props.modelValue === '/') return true
|
||||
// 绝对路径以 /scripts 结尾且没有子目录
|
||||
if (props.modelValue.endsWith('/scripts') || props.modelValue.endsWith('/data/scripts')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
watch(open, (val) => {
|
||||
if (val) loadTree()
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (!props.modelValue || props.modelValue === '/') return props.placeholder || getRootLabel()
|
||||
if (isDefaultSelected() && !props.rootLabel) return 'scripts (默认)'
|
||||
return props.modelValue
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" class="w-full justify-start text-xs h-8 font-normal">
|
||||
<Folder class="h-3.5 w-3.5 mr-2 text-yellow-500 shrink-0" />
|
||||
<span class="truncate">{{ displayValue }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[300px] p-2" align="start">
|
||||
<div class="text-xs text-muted-foreground mb-2">选择工作目录</div>
|
||||
<div class="max-h-[240px] overflow-y-auto">
|
||||
<!-- 根目录选项 -->
|
||||
<div
|
||||
:class="[
|
||||
'flex items-center gap-1.5 py-1 px-2 rounded cursor-pointer text-xs',
|
||||
isDefaultSelected() ? 'bg-primary/10 text-primary' : 'hover:bg-muted'
|
||||
]"
|
||||
@click="selectRoot"
|
||||
>
|
||||
<FolderOpen class="h-3.5 w-3.5 text-yellow-500" />
|
||||
<span>{{ getRootLabel() }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 扁平化的目录列表 -->
|
||||
<div
|
||||
v-for="dir in flatDirs"
|
||||
:key="dir.path"
|
||||
:class="[
|
||||
'flex items-center gap-1 py-1 px-2 rounded cursor-pointer text-xs',
|
||||
isSelected(dir.path) ? 'bg-primary/10 text-primary' : 'hover:bg-muted'
|
||||
]"
|
||||
:style="{ paddingLeft: (dir.depth * 12 + 8) + 'px' }"
|
||||
@click="selectDir(dir.path)"
|
||||
>
|
||||
<span
|
||||
v-if="dir.hasChildren"
|
||||
class="shrink-0 cursor-pointer"
|
||||
@click="toggleDir(dir.path, $event)"
|
||||
>
|
||||
<ChevronDown v-if="expandedDirs.has(dir.path)" class="h-3 w-3" />
|
||||
<ChevronRight v-else class="h-3 w-3" />
|
||||
</span>
|
||||
<span v-else class="w-3 shrink-0" />
|
||||
<Folder class="h-3.5 w-3.5 text-yellow-500 shrink-0" />
|
||||
<span class="truncate">{{ dir.name }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-xs text-muted-foreground text-center py-4">
|
||||
加载中...
|
||||
</div>
|
||||
<div v-else-if="flatDirs.length === 0" class="text-xs text-muted-foreground text-center py-2">
|
||||
暂无子目录
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -1,133 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Folder, File, ChevronRight, ChevronDown, Trash2, Copy as CopyIcon, Download } from 'lucide-vue-next'
|
||||
import type { FileNode } from '@/api'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
defineOptions({
|
||||
name: 'FileTreeNode'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
node: FileNode
|
||||
expandedDirs: Set<string>
|
||||
selectedPath: string | null
|
||||
depth?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [node: FileNode]
|
||||
delete: [path: string]
|
||||
create: [parentDir: string]
|
||||
move: [oldPath: string, newPath: string]
|
||||
rename: [path: string]
|
||||
downloadFile: [path: string]
|
||||
downloadZip: [path: string]
|
||||
duplicate: [path: string]
|
||||
}>()
|
||||
|
||||
const depth = computed(() => props.depth ?? 0)
|
||||
const isExpanded = computed(() => props.expandedDirs.has(props.node.path))
|
||||
const isSelected = computed(() => props.selectedPath === props.node.path)
|
||||
const isDragOver = ref(false)
|
||||
|
||||
function handleSelect() {
|
||||
emit('select', props.node)
|
||||
}
|
||||
|
||||
|
||||
|
||||
function handleDragStart(e: DragEvent) {
|
||||
e.dataTransfer?.setData('text/plain', props.node.path)
|
||||
e.dataTransfer!.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
if (!props.node.isDir) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer!.dropEffect = 'move'
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
isDragOver.value = false
|
||||
if (!props.node.isDir) return
|
||||
|
||||
const sourcePath = e.dataTransfer?.getData('text/plain')
|
||||
if (!sourcePath || sourcePath === props.node.path) return
|
||||
|
||||
// 不能移动到自己的子目录
|
||||
if (props.node.path.startsWith(sourcePath + '/')) return
|
||||
|
||||
const fileName = sourcePath.split('/').pop()
|
||||
const newPath = props.node.path ? `${props.node.path}/${fileName}` : fileName
|
||||
|
||||
if (newPath !== sourcePath) {
|
||||
emit('move', sourcePath, newPath!)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div :class="[
|
||||
'flex items-center gap-1 py-0.5 px-1 rounded cursor-pointer text-xs hover:bg-muted group',
|
||||
isSelected && 'bg-accent',
|
||||
isDragOver && 'bg-blue-500/20 ring-1 ring-blue-500'
|
||||
]" :style="{ paddingLeft: depth * 12 + 4 + 'px' }" draggable="true" @click="handleSelect"
|
||||
@dragstart="handleDragStart" @dragover="handleDragOver" @dragleave="handleDragLeave" @drop="handleDrop">
|
||||
<template v-if="node.isDir">
|
||||
<ChevronDown v-if="isExpanded" class="h-3 w-3 flex-shrink-0" />
|
||||
<ChevronRight v-else class="h-3 w-3 flex-shrink-0" />
|
||||
</template>
|
||||
<span v-else class="w-3" />
|
||||
<Folder v-if="node.isDir" class="h-3 w-3 text-yellow-500 flex-shrink-0" />
|
||||
<File v-else class="h-3 w-3 text-blue-500 flex-shrink-0" />
|
||||
<TooltipProvider :delay-duration="400">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="truncate">{{ node.name }}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" :side-offset="5" class="max-w-[300px] break-all px-2 py-1">
|
||||
<p class="text-xs">{{ node.name }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div v-if="!node.isDir"
|
||||
class="opacity-0 group-hover:opacity-100 flex items-center gap-1 ml-auto shrink-0 pr-1 transition-opacity">
|
||||
<span @click.stop="$emit('duplicate', node.path)" class="cursor-pointer text-blue-500 hover:text-blue-500/80"
|
||||
title="复制">
|
||||
<CopyIcon class="h-3 w-3" />
|
||||
</span>
|
||||
<span @click.stop="$emit('delete', node.path)" class="cursor-pointer text-destructive hover:text-destructive/80"
|
||||
title="删除">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-else
|
||||
class="opacity-0 group-hover:opacity-100 flex items-center gap-1 ml-auto shrink-0 pr-1 transition-opacity">
|
||||
<span @click.stop="$emit('downloadZip', node.path)" class="cursor-pointer text-blue-500 hover:text-blue-500/80"
|
||||
title="下载为压缩包">
|
||||
<Download class="h-3 w-3" />
|
||||
</span>
|
||||
<span @click.stop="$emit('delete', node.path)" class="cursor-pointer text-destructive hover:text-destructive/80"
|
||||
title="删除">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="node.isDir && isExpanded && node.children">
|
||||
<FileTreeNode v-for="child in node.children" :key="child.path" :node="child" :expanded-dirs="expandedDirs"
|
||||
:selected-path="selectedPath" :depth="depth + 1" @select="$emit('select', $event)"
|
||||
@create="$emit('create', $event)" @move="(oldPath, newPath) => $emit('move', oldPath, newPath)"
|
||||
@rename="$emit('rename', $event)" @delete="$emit('delete', $event)"
|
||||
@download-file="$emit('downloadFile', $event)" @download-zip="$emit('downloadZip', $event)"
|
||||
@duplicate="$emit('duplicate', $event)" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { toast } from 'vue-sonner'
|
||||
import * as interconnectApi from '@/api/interconnect'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
description?: string
|
||||
actionLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', val: boolean): void
|
||||
(e: 'confirm', nodeIds: string[]): void
|
||||
}>()
|
||||
|
||||
const nodes = ref<interconnectApi.InterconnectNode[]>([])
|
||||
const selectedNodeIds = ref<Set<string>>(new Set())
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
|
||||
watch(() => props.open, async (val) => {
|
||||
if (val) {
|
||||
selectedNodeIds.value.clear()
|
||||
loading.value = true
|
||||
try {
|
||||
nodes.value = await interconnectApi.getNodes()
|
||||
} catch {
|
||||
toast.error('获取互联节点失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toggleNode(id: string) {
|
||||
if (selectedNodeIds.value.has(id)) {
|
||||
selectedNodeIds.value.delete(id)
|
||||
} else {
|
||||
selectedNodeIds.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (selectedNodeIds.value.size === 0) {
|
||||
toast.error('请至少选择一个目标节点')
|
||||
return
|
||||
}
|
||||
emit('confirm', Array.from(selectedNodeIds.value))
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
setSyncing: (val: boolean) => syncing.value = val,
|
||||
close: () => emit('update:open', false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription v-if="description">{{ description }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="py-4 max-h-[300px] overflow-y-auto">
|
||||
<div v-if="loading" class="text-center text-sm text-muted-foreground py-4">
|
||||
加载中...
|
||||
</div>
|
||||
<div v-else-if="nodes.length === 0" class="text-center text-sm text-muted-foreground py-4">
|
||||
暂无互联节点,请先在「互联管理」中添加节点。
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.id"
|
||||
class="flex items-center space-x-3 p-2 rounded-md border cursor-pointer hover:bg-secondary/50 transition-colors"
|
||||
@click="toggleNode(node.id)"
|
||||
>
|
||||
<Checkbox :checked="selectedNodeIds.has(node.id)" @update:checked="toggleNode(node.id)" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium leading-none">{{ node.name }}</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">{{ node.url }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="emit('update:open', false)" :disabled="syncing">取消</Button>
|
||||
<Button @click="handleConfirm" :disabled="nodes.length === 0 || syncing">
|
||||
<span v-if="syncing">处理中...</span>
|
||||
<span v-else>{{ actionLabel || '确认分发' }}</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
LayoutDashboard, FileCode, Settings, History,
|
||||
Server, Terminal, Bell, ChevronLeft, Sun, Moon, Menu, X
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from '@/context/ThemeContext'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ path: '/tasks', icon: FileCode, label: 'Tasks' },
|
||||
{ path: '/scripts', icon: FileCode, label: 'Scripts' },
|
||||
{ path: '/logs', icon: History, label: 'Logs' },
|
||||
{ path: '/interconnect', icon: Server, label: 'Interconnect' },
|
||||
{ path: '/terminal', icon: Terminal, label: 'Terminal' },
|
||||
{ path: '/settings', icon: Settings, label: 'Settings' },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768)
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
const ThemeIcon = theme === 'light' ? Sun : Moon
|
||||
|
||||
const sidebarWidth = sidebarCollapsed ? 48 : 200
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--bg-primary)', color: 'var(--text-primary)' }}>
|
||||
{/* Mobile Header */}
|
||||
{isMobile && (
|
||||
<header style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 48,
|
||||
background: 'var(--bg-secondary)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px',
|
||||
zIndex: 200,
|
||||
}}>
|
||||
<span style={{ fontWeight: 600 }}>TaskPool</span>
|
||||
<button onClick={() => setMenuOpen(!menuOpen)} style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
||||
{menuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMobile && menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 48,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'var(--bg-primary)',
|
||||
zIndex: 150,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{NAV_ITEMS.map(({ path, icon: Icon, label }) => {
|
||||
const isActive = location.pathname === path
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
onClick={() => { navigate({ to: path }); setMenuOpen(false) }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
background: isActive ? 'var(--bg-tertiary)' : 'var(--bg-secondary)',
|
||||
borderRadius: 4,
|
||||
border: 'none',
|
||||
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.5} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
{!isMobile && (
|
||||
<aside style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: sidebarWidth,
|
||||
background: 'var(--bg-secondary)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '12px 8px',
|
||||
transition: 'width 0.2s',
|
||||
zIndex: 100,
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{ padding: '4px 8px', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 28, height: 28, background: 'var(--bg-accent)', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ color: 'white', fontWeight: 700, fontSize: 14 }}>T</span>
|
||||
</div>
|
||||
{!sidebarCollapsed && <span style={{ fontWeight: 600, fontSize: 14 }}>TaskPool</span>}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{NAV_ITEMS.map(({ path, icon: Icon, label }) => {
|
||||
const isActive = location.pathname === path
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
onClick={() => navigate({ to: path })}
|
||||
title={sidebarCollapsed ? label : undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: sidebarCollapsed ? '8px' : '8px 12px',
|
||||
background: isActive ? 'var(--bg-tertiary)' : 'transparent',
|
||||
borderRadius: 4,
|
||||
border: 'none',
|
||||
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.5} />
|
||||
{!sidebarCollapsed && <span>{label}</span>}
|
||||
{isActive && sidebarCollapsed && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
width: 2,
|
||||
height: 16,
|
||||
background: 'var(--bg-accent)',
|
||||
borderRadius: '0 1px 1px 0',
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: sidebarCollapsed ? '8px' : '8px 12px',
|
||||
background: 'transparent',
|
||||
borderRadius: 4,
|
||||
border: 'none',
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<ThemeIcon size={18} strokeWidth={1.5} />
|
||||
{!sidebarCollapsed && <span>{theme === 'light' ? 'Dark' : 'Light'}</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: sidebarCollapsed ? '8px' : '8px 12px',
|
||||
background: 'transparent',
|
||||
borderRadius: 4,
|
||||
border: 'none',
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={1.5} style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none' }} />
|
||||
{!sidebarCollapsed && <span>Collapse</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main style={{
|
||||
marginLeft: isMobile ? 0 : sidebarWidth,
|
||||
marginTop: isMobile ? 48 : 0,
|
||||
flex: 1,
|
||||
minHeight: '100vh',
|
||||
padding: 24,
|
||||
transition: 'margin-left 0.2s',
|
||||
}}>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Status Bar */}
|
||||
<footer style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: isMobile ? 0 : sidebarWidth,
|
||||
right: 0,
|
||||
height: 24,
|
||||
background: 'var(--bg-secondary)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 12px',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-muted)',
|
||||
zIndex: 100,
|
||||
transition: 'left 0.2s',
|
||||
}}>
|
||||
<span>TaskPool v1.0</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<span>{new Date().toLocaleDateString()}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
minHeightClass?: string
|
||||
maxHeightClass?: string
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
placeholder: '',
|
||||
rows: 5,
|
||||
minHeightClass: 'min-h-16',
|
||||
maxHeightClass: 'max-h-40'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const valueTextareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const lineNumbersRef = ref<HTMLDivElement | null>(null)
|
||||
const lineMeasureRef = ref<HTMLDivElement | null>(null)
|
||||
const visualLineNumbers = ref<string[]>(['1'])
|
||||
let textareaResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function syncValueLineNumbers() {
|
||||
if (!valueTextareaRef.value || !lineNumbersRef.value) return
|
||||
lineNumbersRef.value.scrollTop = valueTextareaRef.value.scrollTop
|
||||
}
|
||||
|
||||
async function updateVisualLineNumbers() {
|
||||
await nextTick()
|
||||
|
||||
const textarea = valueTextareaRef.value
|
||||
const measure = lineMeasureRef.value
|
||||
if (!textarea || !measure) return
|
||||
|
||||
const style = window.getComputedStyle(textarea)
|
||||
const lineHeight = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.5 || 24
|
||||
const lines = String(props.modelValue ?? '').split('\n')
|
||||
|
||||
measure.style.width = `${textarea.clientWidth}px`
|
||||
measure.innerHTML = ''
|
||||
|
||||
const nextLineNumbers: string[] = []
|
||||
lines.forEach((line, index) => {
|
||||
const lineEl = document.createElement('div')
|
||||
lineEl.className = 'break-all whitespace-pre-wrap'
|
||||
lineEl.textContent = line || ' '
|
||||
measure.appendChild(lineEl)
|
||||
|
||||
const visualRows = Math.max(1, Math.round(lineEl.getBoundingClientRect().height / lineHeight))
|
||||
nextLineNumbers.push(String(index + 1))
|
||||
for (let i = 1; i < visualRows; i += 1) {
|
||||
nextLineNumbers.push('\u00A0')
|
||||
}
|
||||
})
|
||||
|
||||
visualLineNumbers.value = nextLineNumbers.length > 0 ? nextLineNumbers : ['1']
|
||||
syncValueLineNumbers()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
void updateVisualLineNumbers()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
updateVisualLineNumbers()
|
||||
if (valueTextareaRef.value) {
|
||||
textareaResizeObserver = new ResizeObserver(() => {
|
||||
void updateVisualLineNumbers()
|
||||
})
|
||||
textareaResizeObserver.observe(valueTextareaRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
textareaResizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
function onInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
// 暴露更新方法,供父组件弹窗打开等时机手动触发重绘
|
||||
defineExpose({
|
||||
updateVisualLineNumbers
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative flex min-w-0 overflow-hidden rounded-md border border-input bg-transparent shadow-xs focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]">
|
||||
<div ref="lineNumbersRef" :class="['flex w-6 shrink-0 flex-col overflow-hidden border-r border-border bg-muted/30 py-2 text-right font-mono text-[10px] leading-6 text-muted-foreground', maxHeightClass]">
|
||||
<span v-for="(line, index) in visualLineNumbers" :key="`${index}-${line}`" class="block h-6 px-1">{{ line }}</span>
|
||||
</div>
|
||||
<textarea
|
||||
ref="valueTextareaRef"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
:class="['w-full min-w-0 resize-none overflow-x-hidden bg-transparent pl-2 pr-3 py-2 text-sm leading-6 break-all whitespace-pre-wrap outline-none', minHeightClass, maxHeightClass]"
|
||||
@scroll="syncValueLineNumbers"
|
||||
/>
|
||||
<div aria-hidden="true" class="pointer-events-none absolute bottom-1.5 right-1.5 h-3.5 w-3.5 opacity-45">
|
||||
<span class="absolute bottom-0 right-0 h-px w-3 rotate-[-45deg] bg-border" />
|
||||
<span class="absolute bottom-1 right-0.5 h-px w-2 rotate-[-45deg] bg-border/80" />
|
||||
<span class="absolute bottom-2 right-1 h-px w-1 rotate-[-45deg] bg-border/60" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="lineMeasureRef"
|
||||
aria-hidden="true"
|
||||
:class="['pointer-events-none invisible fixed left-0 top-0 -z-10 break-all whitespace-pre-wrap px-2 py-2 text-sm leading-6', minHeightClass]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,225 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { AlertCircle, Loader2 } from 'lucide-vue-next'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
interface Props {
|
||||
content?: string
|
||||
loading?: boolean
|
||||
loadingText?: string
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
content: '',
|
||||
loading: false,
|
||||
loadingText: '正在获取日志内容',
|
||||
emptyTitle: '未检测到输出内容',
|
||||
emptyDescription: '此任务执行期间未产生标准输出(Stdout)或错误输出(Stderr)日志。'
|
||||
})
|
||||
|
||||
const terminalRef = ref<HTMLDivElement | null>(null)
|
||||
let terminal: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
let lastContentLength = 0
|
||||
let lastContent = ''
|
||||
|
||||
function getTheme() {
|
||||
const isDark = resolvedTheme.value === 'dark'
|
||||
return {
|
||||
background: isDark ? '#00000000' : '#e4e4e7',
|
||||
foreground: isDark ? '#d4d4d4' : '#333333',
|
||||
cursor: '#00000000',
|
||||
selectionBackground: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.2)',
|
||||
}
|
||||
}
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalRef.value) return
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.25,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
theme: getTheme(),
|
||||
allowTransparency: true,
|
||||
})
|
||||
|
||||
fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.open(terminalRef.value)
|
||||
|
||||
// 禁用手机端点击弹出键盘
|
||||
const textarea = terminalRef.value.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.readOnly = true
|
||||
}
|
||||
|
||||
// 支持 Ctrl+C 复制选中内容
|
||||
terminal.attachCustomKeyEventHandler((e) => {
|
||||
if (e.ctrlKey && e.code === 'KeyC' && e.type === 'keydown') {
|
||||
const selection = terminal?.getSelection()
|
||||
if (selection) {
|
||||
navigator.clipboard.writeText(selection)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
fitAddon?.fit()
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
if (props.content) {
|
||||
writeContent(props.content)
|
||||
}
|
||||
}
|
||||
|
||||
function writeContent(content: string) {
|
||||
if (!terminal) return
|
||||
|
||||
if (content.length > lastContentLength && content.substring(0, lastContentLength) === lastContent) {
|
||||
// 增量更新:只截取新的部分
|
||||
const diff = content.substring(lastContentLength)
|
||||
// 替换换行符,因为 xterm 需要 \r\n 才能回到行首
|
||||
const formattedDiff = diff.replace(/\r?\n/g, '\r\n')
|
||||
terminal.write(formattedDiff)
|
||||
} else {
|
||||
// 全量更新
|
||||
const formattedContent = content.replace(/\r?\n/g, '\r\n')
|
||||
terminal.clear()
|
||||
terminal.write(formattedContent)
|
||||
}
|
||||
|
||||
lastContentLength = content.length
|
||||
lastContent = content
|
||||
}
|
||||
|
||||
watch(resolvedTheme, () => {
|
||||
if (terminal) {
|
||||
terminal.options.theme = getTheme()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.content, (newContent) => {
|
||||
if (newContent) {
|
||||
nextTick(() => {
|
||||
writeContent(newContent)
|
||||
})
|
||||
} else {
|
||||
terminal?.clear()
|
||||
lastContentLength = 0
|
||||
lastContent = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 监听容器大小变化以适配 xterm
|
||||
onMounted(() => {
|
||||
if (terminalRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
try {
|
||||
fitAddon?.fit()
|
||||
} catch (e) {}
|
||||
})
|
||||
resizeObserver.observe(terminalRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
terminal?.dispose()
|
||||
terminal = null
|
||||
})
|
||||
|
||||
// 当不显示 loading 且有 content 时,初始化 terminal
|
||||
watch(
|
||||
() => (!props.loading && props.content && props.content.trim()),
|
||||
(shouldShow) => {
|
||||
if (shouldShow && !terminal) {
|
||||
nextTick(() => {
|
||||
initTerminal()
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col h-full w-full relative min-h-0 lg:min-h-full">
|
||||
<!-- 加载状态 -->
|
||||
<template v-if="loading">
|
||||
<div class="flex-1 flex flex-col items-center justify-center p-4 select-none text-center">
|
||||
<Loader2 class="h-10 w-10 animate-spin text-primary/30 mb-4" />
|
||||
<span class="text-sm text-muted-foreground font-medium animate-pulse">{{ loadingText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<template v-else-if="!content || !content.trim()">
|
||||
<div class="flex-1 flex flex-col items-center justify-center py-10 lg:py-0 select-none text-center min-h-[160px]">
|
||||
<div class="w-14 h-14 rounded-3xl bg-muted/20 flex items-center justify-center mb-4 border border-muted-foreground/10 mx-auto">
|
||||
<AlertCircle class="h-7 w-8 text-muted-foreground/20" />
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground font-medium">{{ emptyTitle }}</span>
|
||||
<p class="text-[11px] text-muted-foreground/40 mt-1.5 max-w-[280px] leading-relaxed mx-auto">
|
||||
{{ emptyDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 正常内容 -->
|
||||
<div
|
||||
v-show="!loading && content && content.trim()"
|
||||
class="flex-1 w-full p-2 overflow-hidden h-[200px] lg:h-full lg:min-h-0"
|
||||
>
|
||||
<div ref="terminalRef" class="w-full h-full log-terminal"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-terminal :deep(.xterm) {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.log-terminal :deep(.xterm-viewport) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(150, 150, 150, 0.3) transparent;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.log-terminal :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.log-terminal :deep(.xterm-viewport::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.log-terminal :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background: rgba(150, 150, 150, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-terminal :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||
background: rgba(150, 150, 150, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -1,236 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import {
|
||||
X, Trash2, Maximize2, Ban, Search
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import LogContent from './LogContent.vue'
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
import { TASK_STATUS } from '@/constants'
|
||||
import type { TaskLog } from '@/api'
|
||||
|
||||
interface Props {
|
||||
log: TaskLog | null
|
||||
content: string
|
||||
title?: string
|
||||
loading?: boolean
|
||||
isStopping?: boolean
|
||||
showClose?: boolean
|
||||
variant?: 'full' | 'simple'
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
log: null,
|
||||
content: '',
|
||||
title: '日志详情',
|
||||
loading: false,
|
||||
isStopping: false,
|
||||
showClose: true,
|
||||
variant: 'full',
|
||||
emptyTitle: undefined,
|
||||
emptyDescription: undefined
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'close': []
|
||||
'stop': []
|
||||
'delete': [id: string]
|
||||
'maximize': []
|
||||
}>()
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const currentDuration = ref(props.log?.duration || 0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms <= 0) return '0毫秒'
|
||||
if (ms < 1000) return `${ms.toFixed(0)}毫秒`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}秒`
|
||||
return `${(ms / 60000).toFixed(1)}分钟`
|
||||
}
|
||||
|
||||
const startTimer = () => {
|
||||
stopTimer()
|
||||
if (!props.log || props.log.status !== TASK_STATUS.RUNNING || !props.log.start_time) return
|
||||
|
||||
const startTime = new Date(props.log.start_time).getTime()
|
||||
timer = setInterval(() => {
|
||||
currentDuration.value = Date.now() - startTime
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startTimer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTimer()
|
||||
})
|
||||
|
||||
watch(() => props.log?.status, (newStatus) => {
|
||||
if (newStatus === TASK_STATUS.RUNNING) {
|
||||
startTimer()
|
||||
} else {
|
||||
stopTimer()
|
||||
if (props.log) currentDuration.value = props.log.duration
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.log?.duration, (newVal) => {
|
||||
if (props.log?.status !== TASK_STATUS.RUNNING) {
|
||||
currentDuration.value = newVal || 0
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="log" class="w-full h-full flex flex-col overflow-hidden bg-card">
|
||||
<!-- 头部菜单 (响应式两行布局) -->
|
||||
<div class="flex flex-col border-b bg-muted/20 shrink-0">
|
||||
<!-- 第一行: 标题与核心控制按钮 -->
|
||||
<div class="flex items-center justify-between px-4 gap-4"
|
||||
:class="variant === 'full' ? 'h-[28px]' : 'h-11'">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-xs font-medium text-foreground whitespace-nowrap truncate">{{ title }}</span>
|
||||
|
||||
<!-- Simple 模式下的状态显示 -->
|
||||
<StatusBadge v-if="variant === 'simple'" :status="log.status" />
|
||||
|
||||
<!-- 详情模式下的停止按钮 (始终在第一行进入) -->
|
||||
<Button v-if="log.status === TASK_STATUS.RUNNING && variant === 'full'" variant="destructive" size="sm"
|
||||
class="h-5 px-1.5 text-[9px] shrink-0 rounded-sm" :disabled="isStopping" @click="$emit('stop')">
|
||||
{{ isStopping ? '停止中' : '停止任务' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<!-- 极简模式桌面端显示的停止按钮 -->
|
||||
<Button v-if="log.status === TASK_STATUS.RUNNING && variant === 'simple'" variant="destructive" size="sm"
|
||||
class="h-5 px-1.5 text-[9px] rounded-sm hidden sm:flex" :disabled="isStopping" @click="$emit('stop')">
|
||||
{{ isStopping ? '停止中' : '停止任务' }}
|
||||
</Button>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Button v-if="variant === 'simple'" variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground"
|
||||
title="全屏切换" @click="$emit('maximize')">
|
||||
<Maximize2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
title="删除该日志" @click="$emit('delete', log.id)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button v-if="showClose" variant="ghost" size="icon" class="h-6 w-6" @click="$emit('close')" title="关闭">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行: 工具栏 (仅在极简模式的移动端展示) -->
|
||||
<div v-if="variant === 'simple'"
|
||||
class="flex items-center gap-2 px-4 pb-3 sm:hidden border-t pt-2 mt-0.5 border-dashed border-muted-foreground/10">
|
||||
|
||||
<!-- 极简模式移动端停止按钮 -->
|
||||
<Button v-if="log.status === TASK_STATUS.RUNNING" variant="destructive" size="sm"
|
||||
class="h-6 px-2 text-[10px] gap-1 flex-1 max-w-[100px] rounded-sm" :disabled="isStopping" @click="$emit('stop')">
|
||||
<Ban class="h-3 w-3" />
|
||||
{{ isStopping ? '停止中' : '停止' }}
|
||||
</Button>
|
||||
|
||||
<!-- 极简模式移动端搜索框 -->
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input v-model="searchKeyword" placeholder="搜索日志内容..." class="h-8 pl-8 w-full text-sm bg-background/50 border-none shadow-inner" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端搜索栏 (Simple 模式) -->
|
||||
<div v-if="variant === 'simple'" class="hidden sm:flex items-center px-4 pb-2 -mt-1">
|
||||
<div class="relative w-64">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="searchKeyword" placeholder="搜索内容..." class="h-7 pl-8 w-full text-xs bg-background/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务元数据 (仅在 Full 模式下展示) -->
|
||||
<div v-if="variant === 'full'"
|
||||
class="px-4 py-3 border-b space-y-2 text-sm text-foreground/80 shrink-0 overflow-y-auto max-h-[40vh]">
|
||||
<div class="flex justify-between items-center h-6">
|
||||
<span class="text-sm font-normal text-muted-foreground">任务名称</span>
|
||||
<span class="text-xs font-normal text-muted-foreground">{{ log.task_name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center h-8">
|
||||
<span class="text-sm font-normal text-muted-foreground">状态</span>
|
||||
<StatusBadge :status="log.status" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center h-6">
|
||||
<span class="text-sm font-normal text-muted-foreground">耗时</span>
|
||||
<span class="text-xs font-normal text-muted-foreground">{{ formatDuration(currentDuration) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center h-6">
|
||||
<span class="text-sm font-normal text-muted-foreground">开始时间</span>
|
||||
<span class="text-xs font-normal text-muted-foreground">{{ log.start_time || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center h-6">
|
||||
<span class="text-sm font-normal text-muted-foreground">结束时间</span>
|
||||
<span class="text-xs font-normal text-muted-foreground">{{ log.end_time || '-' }}</span>
|
||||
</div>
|
||||
<div class="pt-1.5 pb-1">
|
||||
<span class="text-sm font-normal text-muted-foreground block mb-1">执行命令</span>
|
||||
<code
|
||||
class="block font-mono bg-muted/40 px-3 py-2 rounded text-xs break-all border border-muted-foreground/10 leading-relaxed overflow-y-auto max-h-24 font-normal">
|
||||
{{ log.command }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志输出容器 -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden"
|
||||
:class="variant === 'simple' ? 'bg-black/5 dark:bg-white/5' : 'bg-black/[0.02] dark:bg-white/[0.02]'">
|
||||
<!-- 错误信息提示 -->
|
||||
<div v-if="log.error" class="px-4 py-3 border-b bg-red-500/5 space-y-2 text-sm shrink-0">
|
||||
<div class="flex items-center gap-2 text-red-500 font-medium">
|
||||
<XCircle class="h-4 w-4" />
|
||||
<span class="font-normal">系统错误</span>
|
||||
</div>
|
||||
<code class="block font-mono bg-red-500/10 text-red-600 px-2 py-1 rounded text-xs break-all">
|
||||
{{ log.error }}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<!-- 日志工具栏 (仅在 Full 模式展示,Simple 模式直接显示内容) -->
|
||||
<div v-if="variant === 'full'"
|
||||
class="px-4 py-1 text-sm text-muted-foreground border-b bg-muted/20 flex items-center justify-between shrink-0">
|
||||
<span class="text-sm font-normal text-muted-foreground text-[12px]">日志输出</span>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="$emit('maximize')" title="全屏查看">
|
||||
<Maximize2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<LogContent
|
||||
class="h-full"
|
||||
:content="content"
|
||||
:loading="loading"
|
||||
:empty-title="emptyTitle"
|
||||
:empty-description="emptyDescription || '此任务执行期间未产生标准输出日志'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
import Ansi from 'ansi-to-vue3'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: string
|
||||
fontSize?: number
|
||||
theme?: 'dark' | 'light'
|
||||
autoScroll?: boolean
|
||||
}>(),
|
||||
{
|
||||
fontSize: 12,
|
||||
theme: 'dark',
|
||||
autoScroll: true
|
||||
}
|
||||
)
|
||||
|
||||
const scrollContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const lightBackgroundClass = 'bg-zinc-100 text-zinc-900'
|
||||
const darkBackgroundClass = 'bg-zinc-950 text-zinc-300' // 稍微调亮一点暗色模式文字
|
||||
|
||||
// 自动滚动到底部逻辑
|
||||
const scrollToBottom = () => {
|
||||
if (props.autoScroll && scrollContainerRef.value) {
|
||||
scrollContainerRef.value.scrollTop = scrollContainerRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.content, () => {
|
||||
if (props.autoScroll) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoScroll) {
|
||||
setTimeout(scrollToBottom, 50)
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
fit: () => { },
|
||||
clear: () => { },
|
||||
scrollToBottom
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="scrollContainerRef"
|
||||
class="w-full h-full overflow-y-auto overflow-x-auto log-container native-scroll"
|
||||
:class="theme === 'dark' ? darkBackgroundClass : lightBackgroundClass"
|
||||
:style="{ fontSize: fontSize + 'px' }"
|
||||
><div class="inline-block min-w-full p-3 font-mono leading-normal whitespace-pre text-left"><Ansi>{{ content }}</Ansi></div></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.native-scroll {
|
||||
scrollbar-gutter: stable;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
/* 强制覆盖 ansi-to-vue3 可能生成的 code 样式 */
|
||||
:deep(code) {
|
||||
background: transparent !important;
|
||||
font-family: inherit;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
.native-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.native-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.native-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(128, 128, 128, 0.4);
|
||||
}
|
||||
|
||||
.native-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -1,132 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from '@/components/ui/select'
|
||||
import { MonitorDot } from 'lucide-vue-next'
|
||||
import { request, activeInterconnectNodeId, setActiveInterconnectNodeId, api } from '@/api'
|
||||
import type { InterconnectNode } from '@/api/interconnect'
|
||||
import { useEventBus } from '@vueuse/core'
|
||||
|
||||
const nodes = ref<InterconnectNode[]>([])
|
||||
const selectedNodeId = ref<string>('local')
|
||||
const isMaster = ref(false)
|
||||
|
||||
let timer: any = null
|
||||
|
||||
async function loadNodes() {
|
||||
try {
|
||||
const res = await request<InterconnectNode[]>('/interconnect/nodes')
|
||||
nodes.value = res || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load interconnect nodes', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeChange(val: any) {
|
||||
if (!val) return
|
||||
const strVal = String(val)
|
||||
selectedNodeId.value = strVal
|
||||
if (strVal === 'local') {
|
||||
setActiveInterconnectNodeId('')
|
||||
} else {
|
||||
const nodeName = nodes.value.find(n => n.id === strVal)?.name || ''
|
||||
setActiveInterconnectNodeId(strVal, nodeName)
|
||||
}
|
||||
// Refresh the current page to reload data from the new node
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
if (document.visibilityState === 'hidden') return
|
||||
loadNodes()
|
||||
timer = setInterval(loadNodes, 120000) // 放宽至 2 分钟一次
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (isMaster.value) {
|
||||
startPolling()
|
||||
}
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRoleAndInit() {
|
||||
try {
|
||||
const role = await api.settings.get('interconnect', 'interconnect_role')
|
||||
updateRoleState(role)
|
||||
} catch (error) {
|
||||
updateRoleState('none')
|
||||
}
|
||||
}
|
||||
|
||||
function updateRoleState(role: string) {
|
||||
const master = role === 'master'
|
||||
isMaster.value = master
|
||||
if (master) {
|
||||
if (activeInterconnectNodeId) {
|
||||
selectedNodeId.value = activeInterconnectNodeId
|
||||
} else {
|
||||
selectedNodeId.value = 'local'
|
||||
}
|
||||
startPolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
nodes.value = []
|
||||
selectedNodeId.value = 'local'
|
||||
// 只有在非“穿越状态”下,才可以因为角色改变而清除 LocalStorage/Cookie
|
||||
const isTraveling = !!document.cookie.match(new RegExp('(^| )active_interconnect_node_id=([^;]*)'))
|
||||
if (activeInterconnectNodeId && !isTraveling) {
|
||||
setActiveInterconnectNodeId('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const roleBus = useEventBus<string>('interconnect-role-changed')
|
||||
roleBus.on((newRole) => {
|
||||
updateRoleState(newRole)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await checkRoleAndInit()
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isMaster" class="flex items-center gap-2">
|
||||
<Select :model-value="selectedNodeId" @update:model-value="handleNodeChange">
|
||||
<SelectTrigger class="h-6 px-1.5 py-0 text-xs font-medium rounded-md border border-input/60 bg-background/50 hover:bg-accent hover:text-accent-foreground shadow-sm transition-all focus:ring-0 focus:ring-offset-0 w-auto gap-0.5 min-w-[70px] max-w-[120px]" :class="{'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25 hover:bg-amber-500/15': selectedNodeId !== 'local'}">
|
||||
<div class="flex items-center gap-0.5 min-w-0">
|
||||
<MonitorDot class="h-2.5 w-2.5 shrink-0" />
|
||||
<span class="truncate pr-0.5 text-[10px] leading-none">
|
||||
{{ selectedNodeId === 'local' ? '本机节点' : (nodes.find(n => n.id === selectedNodeId)?.name || '未知子节点') }}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="local" class="text-xs">
|
||||
本机节点
|
||||
</SelectItem>
|
||||
<SelectItem v-for="node in nodes" :key="node.id" :value="node.id" class="text-xs">
|
||||
{{ node.name }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { useSiteSettings } from '@/composables/useSiteSettings'
|
||||
|
||||
const props = defineProps<{
|
||||
total: number
|
||||
page: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:page': [page: number]
|
||||
}>()
|
||||
|
||||
const { pageSize } = useSiteSettings()
|
||||
|
||||
const totalPages = computed(() => Math.ceil(props.total / pageSize.value) || 1)
|
||||
|
||||
function prevPage() {
|
||||
if (props.page > 1) {
|
||||
emit('update:page', props.page - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (props.page < totalPages.value) {
|
||||
emit('update:page', props.page + 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="total > pageSize" class="flex items-center justify-between px-3 py-2 border-t text-xs text-muted-foreground">
|
||||
<span>共 {{ total }} 条</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="page <= 1" @click="prevPage">
|
||||
<ChevronLeft class="h-3 w-3" />
|
||||
</Button>
|
||||
<span class="px-2">{{ page }} / {{ totalPages }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="page >= totalPages" @click="nextPage">
|
||||
<ChevronRight class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { TASK_STATUS, TASK_STATUS_TEXT } from '@/constants'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
status: string
|
||||
showDot?: boolean
|
||||
}>(),
|
||||
{
|
||||
showDot: true
|
||||
}
|
||||
)
|
||||
|
||||
const badgeClasses = computed(() => {
|
||||
switch (props.status) {
|
||||
case TASK_STATUS.SUCCESS:
|
||||
return 'border-green-500/20 text-green-600 dark:text-green-400 bg-green-500/10 dark:bg-green-500/20'
|
||||
case TASK_STATUS.FAILED:
|
||||
return 'border-red-500/20 text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-500/20'
|
||||
case TASK_STATUS.RUNNING:
|
||||
return 'border-amber-500/20 text-amber-600 dark:text-amber-400 bg-amber-500/10 dark:bg-amber-500/20'
|
||||
case TASK_STATUS.PENDING:
|
||||
case 'queued':
|
||||
return 'border-blue-400/20 text-blue-500 dark:text-blue-400 bg-blue-400/10 dark:bg-blue-400/20'
|
||||
case TASK_STATUS.TIMEOUT:
|
||||
return 'border-orange-500/20 text-orange-600 dark:text-orange-400 bg-orange-500/10 dark:bg-orange-500/20'
|
||||
case TASK_STATUS.CANCELLED:
|
||||
return 'border-muted-foreground/10 text-muted-foreground bg-muted/50'
|
||||
default:
|
||||
return 'border-transparent text-secondary-foreground bg-secondary'
|
||||
}
|
||||
})
|
||||
|
||||
const dotClasses = computed(() => {
|
||||
switch (props.status) {
|
||||
case TASK_STATUS.SUCCESS:
|
||||
return 'bg-green-500'
|
||||
case TASK_STATUS.FAILED:
|
||||
return 'bg-red-500'
|
||||
case TASK_STATUS.RUNNING:
|
||||
return 'bg-amber-500'
|
||||
case TASK_STATUS.PENDING:
|
||||
case 'queued':
|
||||
return 'bg-blue-400'
|
||||
case TASK_STATUS.TIMEOUT:
|
||||
return 'bg-orange-500'
|
||||
case TASK_STATUS.CANCELLED:
|
||||
return 'bg-muted-foreground'
|
||||
default:
|
||||
return 'bg-muted-foreground'
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
return TASK_STATUS_TEXT[props.status as keyof typeof TASK_STATUS_TEXT] || props.status
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-0.5 text-[10px] font-normal rounded-full border transition-all duration-300 select-none"
|
||||
:class="badgeClasses"
|
||||
>
|
||||
<!-- 正在运行状态使用双层波纹微动效的呼吸灯 -->
|
||||
<span v-if="showDot && status === TASK_STATUS.RUNNING" class="relative flex h-1.5 w-1.5 shrink-0">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-500"></span>
|
||||
</span>
|
||||
<!-- 其他状态使用常规实心圆点 -->
|
||||
<span
|
||||
v-else-if="showDot"
|
||||
class="h-1.5 w-1.5 rounded-full shrink-0"
|
||||
:class="dotClasses"
|
||||
></span>
|
||||
|
||||
<span class="tracking-wide text-[10px]">{{ statusText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state: string
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
state: 'none'
|
||||
}
|
||||
)
|
||||
|
||||
const dotClasses = computed(() => {
|
||||
switch (props.state) {
|
||||
case 'running':
|
||||
return 'h-2 w-2 bg-amber-500 animate-pulse shadow-[0_0_8px_rgba(245,158,11,0.5)]'
|
||||
case 'pending':
|
||||
return 'h-2 w-2 bg-blue-400 animate-pulse'
|
||||
case 'success':
|
||||
case 'online':
|
||||
return 'h-1.5 w-1.5 bg-green-500'
|
||||
case 'failed':
|
||||
return 'h-1.5 w-1.5 bg-red-500'
|
||||
case 'timeout':
|
||||
return 'h-1.5 w-1.5 bg-orange-500'
|
||||
case 'cancelled':
|
||||
return 'h-1.5 w-1.5 bg-muted-foreground'
|
||||
case 'offline':
|
||||
case 'none':
|
||||
default:
|
||||
return 'h-1.5 w-1.5 bg-muted-foreground/20'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-full shrink-0" :class="dotClasses" :title="title"></div>
|
||||
</template>
|
||||
@@ -1,147 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Bell, Check } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { api, type AppLog, LOG_CATEGORY, LOG_STATUS } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
import { eventBus } from '@/utils/event-bus'
|
||||
import { LOG_EVENTS } from '@/constants'
|
||||
|
||||
const notices = ref<AppLog[]>([])
|
||||
const unreadCount = ref(0)
|
||||
const loading = ref(false)
|
||||
const open = ref(false)
|
||||
|
||||
async function fetchNotices() {
|
||||
try {
|
||||
const res = await api.appLogs.list({
|
||||
category: LOG_CATEGORY.SYSTEM_NOTICE,
|
||||
status: LOG_STATUS.UNREAD,
|
||||
page: 1,
|
||||
page_size: 50
|
||||
})
|
||||
notices.value = res.data || []
|
||||
unreadCount.value = notices.value.length
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch notices:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(id: string) {
|
||||
try {
|
||||
await api.appLogs.markAsRead({ id })
|
||||
notices.value = notices.value.filter((n: AppLog) => n.id !== id)
|
||||
unreadCount.value = notices.value.length
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '标记已读失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAsRead() {
|
||||
if (notices.value.length === 0) return
|
||||
loading.value = true
|
||||
try {
|
||||
await api.appLogs.markAsRead({ category: LOG_CATEGORY.SYSTEM_NOTICE })
|
||||
notices.value = []
|
||||
unreadCount.value = 0
|
||||
toast.success('已清空未读消息')
|
||||
open.value = false
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '全部已读失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let timer: number
|
||||
let unsubscribe: () => void
|
||||
|
||||
onMounted(() => {
|
||||
fetchNotices()
|
||||
|
||||
// 订阅 WebSocket 实时事件,在收到新系统日志通知时主动刷新
|
||||
unsubscribe = eventBus.subscribe((msg) => {
|
||||
if (msg.type === LOG_EVENTS.ADDED) {
|
||||
const log = msg.payload
|
||||
if (log && log.category === LOG_CATEGORY.SYSTEM_NOTICE) {
|
||||
fetchNotices()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 10 分钟一次的长轮询作为断连兜底
|
||||
timer = window.setInterval(fetchNotices, 10 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribe) unsubscribe()
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return format(new Date(dateStr), 'MM-dd HH:mm')
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function getLevelColor(level: string) {
|
||||
switch (level) {
|
||||
case 'error': return 'destructive'
|
||||
case 'warning': return 'warning'
|
||||
default: return 'secondary'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" class="relative">
|
||||
<Bell class="h-5 w-5" />
|
||||
<span v-if="unreadCount > 0"
|
||||
class="absolute top-1 right-1 h-2 w-2 rounded-full bg-red-500 animate-pulse"></span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-80 p-0" align="end">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b">
|
||||
<span class="font-medium text-sm">系统通知 <span class="text-xs text-muted-foreground ml-1" v-if="unreadCount">({{
|
||||
unreadCount }})</span></span>
|
||||
<Button variant="ghost" size="sm" class="h-auto p-0 text-xs text-muted-foreground hover:text-foreground"
|
||||
:disabled="loading || unreadCount === 0" @click="markAllAsRead">
|
||||
<Check class="h-3 w-3 mr-1" />
|
||||
全标已读
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea class="h-[300px]" v-if="notices.length > 0">
|
||||
<div class="flex flex-col">
|
||||
<div v-for="notice in notices" :key="notice.id"
|
||||
class="p-4 border-b last:border-0 hover:bg-muted/50 transition-colors group relative">
|
||||
<div class="flex items-start justify-between gap-2 mb-1 cursor-pointer" @click="markAsRead(notice.id)">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Badge :variant="getLevelColor(notice.level) as any" class="px-1.5 py-0 text-[10px]">{{ notice.level === 'error' ? '错误' : (notice.level === 'warning' ? '警告' : '提示') }}</Badge>
|
||||
<span class="text-sm font-medium truncate">{{ notice.title }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground line-clamp-2">{{ notice.content }}</p>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">{{ formatDate(notice.created_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div v-else class="py-8 text-center text-sm text-muted-foreground flex flex-col items-center">
|
||||
<Bell class="h-8 w-8 text-muted mb-2 opacity-20" />
|
||||
暂无新通知
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -1,159 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed, onUnmounted } from 'vue'
|
||||
import { api } from '@/api'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Loader2, Check } from 'lucide-vue-next'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
icon?: any
|
||||
multiple?: boolean
|
||||
clearOnSelect?: boolean
|
||||
fetchTags?: () => Promise<string[]>
|
||||
}>(), {
|
||||
multiple: false,
|
||||
clearOnSelect: false,
|
||||
fetchTags: () => api.tasks.tags()
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'enter'])
|
||||
|
||||
const open = ref(false)
|
||||
const allTags = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const inputValue = ref(props.modelValue)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
inputValue.value = newVal
|
||||
})
|
||||
|
||||
async function fetchTagsData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await props.fetchTags()
|
||||
allTags.value = res || []
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tags', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const currentTags = computed(() => {
|
||||
return (inputValue.value || '').split(',').map(t => t.trim()).filter(Boolean)
|
||||
})
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
const parts = (inputValue.value || '').split(',')
|
||||
const query = (parts[parts.length - 1] || '').trim().toLowerCase()
|
||||
|
||||
if (!query) {
|
||||
return allTags.value.slice(0, 10)
|
||||
}
|
||||
return allTags.value.filter(t => t.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
function selectTag(tag: string) {
|
||||
if (props.multiple) {
|
||||
const tags = [...currentTags.value]
|
||||
const index = tags.indexOf(tag)
|
||||
if (index > -1) {
|
||||
tags.splice(index, 1)
|
||||
} else {
|
||||
tags.push(tag)
|
||||
}
|
||||
// 多选模式下追加逗号,以便 filteredTags 计算 query 为空,从而显示所有候选
|
||||
inputValue.value = tags.length > 0 ? tags.join(',') + ',' : ''
|
||||
emit('update:modelValue', inputValue.value)
|
||||
} else {
|
||||
if (props.clearOnSelect) {
|
||||
inputValue.value = ''
|
||||
emit('update:modelValue', '')
|
||||
} else {
|
||||
inputValue.value = tag
|
||||
emit('update:modelValue', tag)
|
||||
}
|
||||
open.value = false
|
||||
// 即使清空了也发出 enter 事件,让父组件知道选中了一个值(如果需要通过 enter 处理)
|
||||
// 或者我们传递选中的 tag 给 enter
|
||||
emit('enter', tag)
|
||||
}
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
emit('update:modelValue', inputValue.value)
|
||||
if (!open.value && filteredTags.value.length > 0) {
|
||||
open.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
open.value = false
|
||||
emit('enter')
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTagsData()
|
||||
window.addEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative w-full">
|
||||
<div class="relative group">
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none"
|
||||
/>
|
||||
<Input
|
||||
v-model="inputValue"
|
||||
:placeholder="placeholder"
|
||||
:class="[icon ? 'pl-9' : 'pl-3', $attrs.class]"
|
||||
class="cursor-pointer"
|
||||
@input="onInput"
|
||||
@keydown.enter="onEnter"
|
||||
@click="open = true"
|
||||
@focus="open = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模拟下拉列表 -->
|
||||
<div
|
||||
v-if="open"
|
||||
class="absolute z-[100] top-full left-0 mt-1 w-full min-w-[200px] bg-popover text-popover-foreground rounded-md border shadow-xl p-1 animate-in fade-in zoom-in-95 duration-100"
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-4">
|
||||
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div v-else-if="filteredTags.length === 0" class="py-2 px-3 text-[11px] text-muted-foreground">
|
||||
无匹配标签
|
||||
</div>
|
||||
<div v-else class="max-h-[300px] overflow-y-auto space-y-0.5">
|
||||
<button
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag"
|
||||
class="w-full text-left px-3 py-2 text-xs rounded-md hover:bg-primary/10 hover:text-primary transition-colors flex items-center justify-between group/item"
|
||||
@mousedown.prevent="selectTag(tag)"
|
||||
>
|
||||
<span class="truncate font-medium">{{ tag }}</span>
|
||||
<Check v-if="currentTags.includes(tag)" class="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,45 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BaihuDialog from '@/components/ui/BaihuDialog.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
text: string
|
||||
title?: string
|
||||
disableDialog?: boolean
|
||||
}>(),
|
||||
{
|
||||
title: '详情',
|
||||
disableDialog: false
|
||||
}
|
||||
)
|
||||
|
||||
const showDialog = ref(false)
|
||||
|
||||
function handleClick() {
|
||||
if (props.disableDialog) return
|
||||
if (props.text && props.text !== '-') {
|
||||
showDialog.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-bind="$attrs"
|
||||
class="truncate block transition-colors"
|
||||
:class="[!disableDialog ? 'cursor-pointer hover:text-primary' : '']"
|
||||
:title="text || '-'"
|
||||
@click="e => { if (!disableDialog) { e.stopPropagation(); handleClick(); } }">
|
||||
{{ text || '-' }}
|
||||
</span>
|
||||
|
||||
<BaihuDialog v-model:open="showDialog" :title="title">
|
||||
<div class="max-h-[60vh] overflow-y-auto custom-scrollbar">
|
||||
<div class="p-4 bg-muted/30 rounded-xl border border-border/50">
|
||||
<p class="text-[13.5px] leading-relaxed text-foreground/90 break-all whitespace-pre-wrap">
|
||||
{{ text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaihuDialog>
|
||||
</template>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Sun, Moon, Monitor } from 'lucide-vue-next'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const { theme, setTheme } = useTheme()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8">
|
||||
<Sun v-if="theme === 'light'" class="h-4 w-4" />
|
||||
<Moon v-else-if="theme === 'dark'" class="h-4 w-4" />
|
||||
<Monitor v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="min-w-[90px]">
|
||||
<DropdownMenuItem @click="setTheme('light')" class="gap-2">
|
||||
<Sun class="h-4 w-4" />
|
||||
<span>浅色</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="setTheme('dark')" class="gap-2">
|
||||
<Moon class="h-4 w-4" />
|
||||
<span>深色</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="setTheme('system')" class="gap-2">
|
||||
<Monitor class="h-4 w-4" />
|
||||
<span>系统</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -1,341 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
fontSize?: number
|
||||
autoConnect?: boolean
|
||||
initialCommand?: string
|
||||
}>(),
|
||||
{
|
||||
fontSize: 13,
|
||||
autoConnect: true,
|
||||
initialCommand: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
connected: []
|
||||
disconnected: []
|
||||
success: []
|
||||
failed: []
|
||||
'status-change': [status: { text: string; type: 'success' | 'error' | 'info' } | null]
|
||||
}>()
|
||||
|
||||
const terminalRef = ref<HTMLDivElement | null>(null)
|
||||
let terminal: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let ws: WebSocket | null = null
|
||||
let isPtyMode = false
|
||||
let inputBuffer = ''
|
||||
let commandHistory: string[] = []
|
||||
let historyIndex = -1
|
||||
|
||||
const statusMessage = ref<{ text: string; type: 'success' | 'error' | 'info' } | null>(null)
|
||||
|
||||
watch(() => props.fontSize, (newVal) => {
|
||||
if (terminal && newVal) {
|
||||
terminal.options.fontSize = newVal
|
||||
handleResize()
|
||||
}
|
||||
})
|
||||
|
||||
watch(statusMessage, (newVal) => {
|
||||
emit('status-change', newVal)
|
||||
})
|
||||
|
||||
function initTerminal(forceConnect = false) {
|
||||
if (!terminalRef.value) return
|
||||
|
||||
// 清理旧终端与 DOM 内容
|
||||
if (terminal) {
|
||||
terminal.dispose()
|
||||
terminal = null
|
||||
}
|
||||
terminalRef.value.innerHTML = ''
|
||||
|
||||
// 确保旧的 WebSocket 完全关闭
|
||||
if (ws) {
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close()
|
||||
}
|
||||
ws = null
|
||||
}
|
||||
|
||||
inputBuffer = ''
|
||||
isPtyMode = false
|
||||
statusMessage.value = { text: '正在初始化终端...', type: 'info' }
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: props.fontSize,
|
||||
lineHeight: 1.25,
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
}
|
||||
})
|
||||
|
||||
fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.open(terminalRef.value)
|
||||
|
||||
// 延迟调用 fit,确保 DOM 已渲染
|
||||
setTimeout(() => {
|
||||
try {
|
||||
fitAddon?.fit()
|
||||
} catch (e) {
|
||||
// 忽略 fit 错误
|
||||
}
|
||||
}, 50)
|
||||
|
||||
// 支持 Ctrl+C 复制选中内容
|
||||
terminal.attachCustomKeyEventHandler((e) => {
|
||||
if (e.ctrlKey && e.code === 'KeyC' && e.type === 'keydown') {
|
||||
const selection = terminal?.getSelection()
|
||||
if (selection) {
|
||||
navigator.clipboard.writeText(selection)
|
||||
return false // 阻止默认的终端 Ctrl+C 行为
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
terminal.focus()
|
||||
|
||||
// autoConnect 或者强制连接时才连接
|
||||
if (props.autoConnect || forceConnect) {
|
||||
// 延迟连接,确保终端完全初始化和旧连接完全关闭
|
||||
setTimeout(() => {
|
||||
connectWebSocket()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// 清除当前输入行(Windows 模式用)
|
||||
function clearLine() {
|
||||
for (let i = 0; i < inputBuffer.length; i++) {
|
||||
terminal?.write('\b \b')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理用户输入
|
||||
terminal.onData((data) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
|
||||
if (isPtyMode) {
|
||||
ws.send(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (data === '\r') {
|
||||
terminal?.write('\r\n')
|
||||
if (inputBuffer.trim()) {
|
||||
commandHistory.push(inputBuffer)
|
||||
historyIndex = commandHistory.length
|
||||
ws.send(inputBuffer + '\r\n')
|
||||
}
|
||||
inputBuffer = ''
|
||||
} else if (data === '\x1b[A') {
|
||||
if (commandHistory.length > 0 && historyIndex > 0) {
|
||||
clearLine()
|
||||
historyIndex--
|
||||
inputBuffer = commandHistory[historyIndex] ?? ''
|
||||
terminal?.write(inputBuffer)
|
||||
}
|
||||
} else if (data === '\x1b[B') {
|
||||
clearLine()
|
||||
if (historyIndex < commandHistory.length - 1) {
|
||||
historyIndex++
|
||||
inputBuffer = commandHistory[historyIndex] ?? ''
|
||||
terminal?.write(inputBuffer)
|
||||
} else {
|
||||
historyIndex = commandHistory.length
|
||||
inputBuffer = ''
|
||||
}
|
||||
} else if (data === '\x7f' || data === '\b') {
|
||||
if (inputBuffer.length > 0) {
|
||||
inputBuffer = inputBuffer.slice(0, -1)
|
||||
terminal?.write('\b \b')
|
||||
}
|
||||
} else if (data === '\x03') {
|
||||
ws.send('\x03')
|
||||
inputBuffer = ''
|
||||
historyIndex = commandHistory.length
|
||||
terminal?.write('^C\r\n')
|
||||
} else if (data >= ' ' || data === '\t') {
|
||||
inputBuffer += data
|
||||
terminal?.write(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
// 如果已有连接,先关闭
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const baseUrl = (window as any).__BASE_URL__ || ''
|
||||
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
|
||||
const wsUrl = `${protocol}//${window.location.host}${baseUrl}${apiVersion}/terminal/ws`
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl)
|
||||
} catch {
|
||||
statusMessage.value = { text: '无法创建 WebSocket 连接', type: 'error' }
|
||||
return
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
statusMessage.value = { text: '已连接到终端', type: 'success' }
|
||||
emit('connected')
|
||||
|
||||
// 如果有初始命令,延迟发送(PTY 会自动回显命令)
|
||||
if (props.initialCommand) {
|
||||
setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(props.initialCommand + '\r')
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
terminal?.focus()
|
||||
// 连接成功后立即触发一次尺寸同步
|
||||
handleResize()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data === '__PTY_MODE__') {
|
||||
isPtyMode = true
|
||||
handleResize() // 关键:模式确认后立即同步一次尺寸
|
||||
return
|
||||
}
|
||||
if (event.data === '__PIPE_MODE__') {
|
||||
isPtyMode = false
|
||||
handleResize()
|
||||
return
|
||||
}
|
||||
terminal?.write(event.data)
|
||||
|
||||
// 检测结果标识(保持简单的单次消息检测,不使用持久缓冲区,避免重复触发通知)
|
||||
if (typeof event.data === 'string') {
|
||||
if (event.data.includes('__INSTALL_SUCCESS__')) {
|
||||
emit('success')
|
||||
} else if (event.data.includes('__INSTALL_FAILED__')) {
|
||||
emit('failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
statusMessage.value = { text: '连接已断开', type: 'error' }
|
||||
emit('disconnected')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
statusMessage.value = { text: '连接错误', type: 'error' }
|
||||
}
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
initTerminal(true)
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (ws) {
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close()
|
||||
}
|
||||
ws = null
|
||||
}
|
||||
if (terminal) {
|
||||
terminal.dispose()
|
||||
terminal = null
|
||||
}
|
||||
inputBuffer = ''
|
||||
isPtyMode = false
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
try {
|
||||
fitAddon?.fit()
|
||||
// 通知后端调整 PTY 尺寸
|
||||
if (isPtyMode && terminal && ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Terminal resize failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
reconnect,
|
||||
dispose,
|
||||
initTerminal
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
// 延迟初始化,确保 DOM 完全渲染
|
||||
setTimeout(initTerminal, 150)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="terminalRef" class="terminal-container w-full h-full !bg-[#1e1e1e]" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-container {
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4a4a4a #1e1e1e;
|
||||
background: #1e1e1e !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-screen) {
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background: #4a4a4a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
</style>
|
||||
@@ -1,136 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import * as Icons from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogClose
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
showClose?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
description: '',
|
||||
size: 'md',
|
||||
showClose: true,
|
||||
icon: ''
|
||||
})
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (!props.icon) return null
|
||||
return (Icons as any)[props.icon]
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'sm:max-w-[425px]',
|
||||
md: 'sm:max-w-[600px]',
|
||||
lg: 'sm:max-w-[800px]',
|
||||
xl: 'sm:max-w-[1000px]',
|
||||
full: 'sm:max-w-[95vw] h-[90vh]'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent
|
||||
:show-close-button="false"
|
||||
:class="[
|
||||
'overflow-hidden border-none p-0 bg-background/90 backdrop-blur-xl shadow-2xl ring-1 ring-black/5 dark:ring-white/10 rounded-xl',
|
||||
sizeClasses[size],
|
||||
'transition-all duration-300'
|
||||
]"
|
||||
>
|
||||
<!-- 统一的 Header 风格 -->
|
||||
<div class="flex items-center px-4 h-11 border-b bg-muted/20 shrink-0 gap-2 overflow-hidden">
|
||||
<!-- 图标 -->
|
||||
<component :is="iconComponent" v-if="iconComponent" class="h-4 w-4 text-primary shrink-0" />
|
||||
<!-- 标题 (a11y 语义) -->
|
||||
<DialogTitle class="text-[13px] font-normal text-muted-foreground tracking-wide shrink-0 whitespace-nowrap">
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<!-- 描述 / 副标题插槽 (占满剩余宽度,截断溢出) -->
|
||||
<div class="flex-1 min-w-0 overflow-hidden flex items-center justify-between gap-2">
|
||||
<div class="truncate">
|
||||
<DialogDescription v-if="description || $slots.description" class="truncate text-xs leading-none">
|
||||
<slot name="description">{{ description }}</slot>
|
||||
</DialogDescription>
|
||||
<DialogDescription v-else class="sr-only">
|
||||
{{ title || '提示对话框' }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<!-- 额外扩展插槽 -->
|
||||
<div v-if="$slots.extra" class="flex items-center shrink-0">
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 关闭按钮 -->
|
||||
<DialogClose
|
||||
v-if="showClose"
|
||||
class="h-7 w-7 rounded-md opacity-70 transition-all hover:opacity-100 hover:bg-muted flex items-center justify-center shrink-0 ml-auto -mr-1"
|
||||
>
|
||||
<X class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="sr-only">关闭</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 (保持整洁简洁) -->
|
||||
<div class="p-4 sm:p-6">
|
||||
<div class="animate-in fade-in slide-in-from-bottom-1 duration-400">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 底部插槽 -->
|
||||
<div v-if="$slots.footer" class="mt-8 flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 gap-2">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep([data-state='open']) {
|
||||
animation: baihu-dialog-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
:deep([data-state='closed']) {
|
||||
animation: baihu-dialog-out 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes baihu-dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes baihu-dialog-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogActionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogAction } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants, type ButtonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogActionProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "variant", "size")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps"
|
||||
:class="cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogCancelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogCancel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
@@ -1,44 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
data-slot="alert-dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
data-slot="alert-dialog-description"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogTitle } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
data-slot="alert-dialog-title"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||
import { AlertDialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
||||
@@ -1,9 +0,0 @@
|
||||
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { BadgeVariants } from "."
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "."
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants["variant"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 active:scale-90 active:bg-primary/20 dark:active:bg-primary/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
"icon": "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground flex flex-col rounded-xl border border-border py-6 shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-action"
|
||||
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-content"
|
||||
:class="cn('px-6 border-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-header"
|
||||
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 [&]:border-none [&]:outline-none', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
:class="cn('leading-none font-semibold border-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
export { default as Card } from "./Card.vue"
|
||||
export { default as CardAction } from "./CardAction.vue"
|
||||
export { default as CardContent } from "./CardContent.vue"
|
||||
export { default as CardDescription } from "./CardDescription.vue"
|
||||
export { default as CardFooter } from "./CardFooter.vue"
|
||||
export { default as CardHeader } from "./CardHeader.vue"
|
||||
export { default as CardTitle } from "./CardTitle.vue"
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="checkbox"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class)"
|
||||
>
|
||||
<CheckboxIndicator
|
||||
data-slot="checkbox-indicator"
|
||||
class="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<slot v-bind="slotProps">
|
||||
<Check class="size-3.5" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Checkbox } from "./Checkbox.vue"
|
||||
@@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui"
|
||||
import { DialogClose } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
@@ -1,83 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DialogOverlay from "./DialogOverlay.vue"
|
||||
import { onMounted, onUnmounted, ref } from "vue"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
|
||||
const contentRef = ref<any>(null)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
const contents = document.querySelectorAll('[data-slot="dialog-content"]')
|
||||
if (contents.length > 0) {
|
||||
const topContent = contents[contents.length - 1]
|
||||
const el = contentRef.value?.$el || contentRef.value
|
||||
if (el && el === topContent) {
|
||||
const closeBtn = el.querySelector('[data-slot="dialog-close"]') as HTMLElement
|
||||
if (closeBtn) {
|
||||
closeBtn.click()
|
||||
} else {
|
||||
emits('escapeKeyDown', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
ref="contentRef"
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg overflow-y-auto custom-scrollbar focus:outline-none',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogOverlay } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 py-4 custom-scrollbar data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg max-h-[calc(100vh-2rem)] gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="dialog-title"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-lg leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from "reka-ui"
|
||||
import { DialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger
|
||||
data-slot="dialog-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
@@ -1,10 +0,0 @@
|
||||
export { default as Dialog } from "./Dialog.vue"
|
||||
export { default as DialogClose } from "./DialogClose.vue"
|
||||
export { default as DialogContent } from "./DialogContent.vue"
|
||||
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||
export { default as DialogOverlay } from "./DialogOverlay.vue"
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerRootEmits, DrawerRootProps } from "vaul-vue"
|
||||
import { useForwardPropsEmits } from "reka-ui"
|
||||
import { DrawerRoot } from "vaul-vue"
|
||||
|
||||
const props = withDefaults(defineProps<DrawerRootProps>(), {
|
||||
shouldScaleBackground: true,
|
||||
})
|
||||
|
||||
const emits = defineEmits<DrawerRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DrawerRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="drawer"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DrawerRoot>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerCloseProps } from "vaul-vue"
|
||||
import { DrawerClose } from "vaul-vue"
|
||||
|
||||
const props = defineProps<DrawerCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DrawerClose
|
||||
data-slot="drawer-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DrawerClose>
|
||||
</template>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { useForwardPropsEmits } from "reka-ui"
|
||||
import { DrawerContent, DrawerPortal } from "vaul-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DrawerOverlay from "./DrawerOverlay.vue"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent
|
||||
data-slot="drawer-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn(
|
||||
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
|
||||
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg',
|
||||
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:lg:inset-x-auto data-[vaul-drawer-direction=bottom]:lg:left-[15%] data-[vaul-drawer-direction=bottom]:lg:right-[15%] data-[vaul-drawer-direction=bottom]:lg:rounded-t-xl',
|
||||
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:sm:max-w-sm',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<div class="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
<slot />
|
||||
</DrawerContent>
|
||||
</DrawerPortal>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerDescriptionProps } from "vaul-vue"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DrawerDescription } from "vaul-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DrawerDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DrawerDescription
|
||||
data-slot="drawer-description"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DrawerDescription>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
:class="cn('mt-auto flex flex-col gap-2 p-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DialogOverlayProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DrawerOverlay } from "vaul-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DrawerOverlay
|
||||
data-slot="drawer-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerTitleProps } from "vaul-vue"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DrawerTitle } from "vaul-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DrawerTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DrawerTitle
|
||||
data-slot="drawer-title"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-foreground font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DrawerTitle>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerTriggerProps } from "vaul-vue"
|
||||
import { DrawerTrigger } from "vaul-vue"
|
||||
|
||||
const props = defineProps<DrawerTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DrawerTrigger
|
||||
data-slot="drawer-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DrawerTrigger>
|
||||
</template>
|
||||
@@ -1,9 +0,0 @@
|
||||
export { default as Drawer } from "./Drawer.vue"
|
||||
export { default as DrawerClose } from "./DrawerClose.vue"
|
||||
export { default as DrawerContent } from "./DrawerContent.vue"
|
||||
export { default as DrawerDescription } from "./DrawerDescription.vue"
|
||||
export { default as DrawerFooter } from "./DrawerFooter.vue"
|
||||
export { default as DrawerHeader } from "./DrawerHeader.vue"
|
||||
export { default as DrawerOverlay } from "./DrawerOverlay.vue"
|
||||
export { default as DrawerTitle } from "./DrawerTitle.vue"
|
||||
export { default as DrawerTrigger } from "./DrawerTrigger.vue"
|
||||
@@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui"
|
||||
import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuRootProps>()
|
||||
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dropdown-menu"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class=" cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuGroupProps } from "reka-ui"
|
||||
import { DropdownMenuGroup } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuGroup
|
||||
data-slot="dropdown-menu-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuGroup>
|
||||
</template>
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}>(), {
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
data-slot="dropdown-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuLabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuLabel, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuLabel>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui"
|
||||
import {
|
||||
DropdownMenuRadioGroup,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuRadioGroupProps>()
|
||||
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuRadioGroup>
|
||||
</template>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Circle } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Circle class="size-2 fill-current" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSeparator,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSeparatorProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSeparator
|
||||
data-slot="dropdown-menu-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui"
|
||||
import {
|
||||
DropdownMenuSub,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuSubProps>()
|
||||
const emits = defineEmits<DropdownMenuSubEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronRight } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto size-4" />
|
||||
</DropdownMenuSubTrigger>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuTriggerProps } from "reka-ui"
|
||||
import { DropdownMenuTrigger, useForwardProps } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuTriggerProps>()
|
||||
|
||||
const forwardedProps = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuTrigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
</template>
|
||||
@@ -1,16 +0,0 @@
|
||||
export { default as DropdownMenu } from "./DropdownMenu.vue"
|
||||
|
||||
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"
|
||||
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
|
||||
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
|
||||
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
|
||||
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"
|
||||
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"
|
||||
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
|
||||
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"
|
||||
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"
|
||||
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"
|
||||
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"
|
||||
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
|
||||
export { DropdownMenuPortal } from "reka-ui"
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "update:modelValue", payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
data-slot="input"
|
||||
:class="cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
</template>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Input } from "./Input.vue"
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { LabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Label } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="label"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Label } from "./Label.vue"
|
||||
@@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverRootEmits, PopoverRootProps } from "reka-ui"
|
||||
import { PopoverRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverRootProps>()
|
||||
const emits = defineEmits<PopoverRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="popover"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverAnchorProps } from "reka-ui"
|
||||
import { PopoverAnchor } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverAnchorProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverAnchor
|
||||
data-slot="popover-anchor"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</PopoverAnchor>
|
||||
</template>
|
||||
@@ -1,45 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<PopoverContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
align: "center",
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
data-slot="popover-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverTriggerProps } from "reka-ui"
|
||||
import { PopoverTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverTrigger
|
||||
data-slot="popover-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as Popover } from "./Popover.vue"
|
||||
export { default as PopoverAnchor } from "./PopoverAnchor.vue"
|
||||
export { default as PopoverContent } from "./PopoverContent.vue"
|
||||
export { default as PopoverTrigger } from "./PopoverTrigger.vue"
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { RadioGroupRootEmits, RadioGroupRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { RadioGroupRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<RadioGroupRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<RadioGroupRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RadioGroupRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="radio-group"
|
||||
:class="cn('grid gap-3', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</RadioGroupRoot>
|
||||
</template>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { RadioGroupItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CircleIcon } from "lucide-vue-next"
|
||||
import {
|
||||
RadioGroupIndicator,
|
||||
RadioGroupItem,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<RadioGroupItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RadioGroupItem
|
||||
data-slot="radio-group-item"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<RadioGroupIndicator
|
||||
data-slot="radio-group-indicator"
|
||||
class="relative flex items-center justify-center"
|
||||
>
|
||||
<slot>
|
||||
<CircleIcon class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</slot>
|
||||
</RadioGroupIndicator>
|
||||
</RadioGroupItem>
|
||||
</template>
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as RadioGroup } from "./RadioGroup.vue"
|
||||
export { default as RadioGroupItem } from "./RadioGroupItem.vue"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user