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

- 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:
2026-07-19 12:36:47 +00:00
parent ce6024015f
commit e734026078
243 changed files with 1850 additions and 32354 deletions
-21
View File
@@ -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
View File
@@ -1,17 +1,12 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<link rel="icon" href="data:," /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>TaskPool</title>
<title></title> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- 引入国内主流的 Google Fonts 加速 CDN (loli.net) 的 Noto Sans SC 字体 --> </head>
<link rel="stylesheet" href="https://fonts.loli.net/css2?family=Noto+Sans+SC:wght@100..900&display=swap" /> <body>
</head> <div id="root"></div>
<!-- <h1>test-pack</h1> --> </body>
</html>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
-8026
View File
File diff suppressed because it is too large Load Diff
+22 -51
View File
@@ -1,63 +1,34 @@
{ {
"name": "web-ui", "name": "taskpool-web",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host",
"build": "vue-tsc -b && vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8", "@tanstack/react-query": "^5.80.0",
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@tanstack/react-router": "^1.120.0",
"@guolao/vue-monaco-editor": "^1.6.0", "@tanstack/react-table": "^8.21.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"ansi-to-html": "^0.7.2", "framer-motion": "^12.0.0",
"ansi-to-vue3": "^0.1.2", "lucide-react": "^0.525.0",
"apexcharts": "^5.3.6", "react": "^19.2.7",
"chart.js": "^4.5.1", "react-dom": "^19.2.7",
"class-variance-authority": "^0.7.1", "recharts": "^2.15.0",
"clsx": "^2.1.1", "zustand": "^5.0.0"
"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"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^22.0.0",
"@types/pako": "^2.0.4", "@types/react": "^19.1.0",
"@vitejs/plugin-vue": "^6.0.1", "@types/react-dom": "^19.1.0",
"@vue/tsconfig": "^0.8.1", "@vitejs/plugin-react": "^6.0.3",
"tw-animate-css": "^1.4.0", "autoprefixer": "^10.4.0",
"typescript": "~5.9.3", "postcss": "^8.5.0",
"vite": "^7.3.5", "tailwindcss": "^4.1.0",
"vite-plugin-pwa": "^1.2.0", "typescript": "^5.9.0",
"vite-plugin-static-copy": "^2.3.0", "vite": "^8.1.5"
"vue-tsc": "^3.1.4"
},
"overrides": {
"vite-plugin-static-copy": {
"vite": "$vite"
},
"esbuild": "^0.28.1"
} }
} }
+1208
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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

-43
View File
@@ -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
View File
@@ -1,4 +1,3 @@
// 获取 base URL(从后端注入的全局变量)
const BASE_URL = (window as any).__BASE_URL__ || '' const BASE_URL = (window as any).__BASE_URL__ || ''
const API_VERSION = (window as any).__API_VERSION__ || '/api/v1' const API_VERSION = (window as any).__API_VERSION__ || '/api/v1'
const API_BASE_URL = BASE_URL + API_VERSION const API_BASE_URL = BASE_URL + API_VERSION
@@ -9,815 +8,26 @@ interface ApiResponse<T> {
data: 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> { export async function request<T>(url: string, options?: RequestInit): Promise<T> {
let res: Response const res = await fetch(`${API_BASE_URL}${url}`, {
try { ...options,
res = await fetch(`${API_BASE_URL}${url}`, { credentials: 'include',
...options, headers: {
credentials: 'include', // 携带 Cookie 'Content-Type': 'application/json',
headers: { ...options?.headers
'Content-Type': 'application/json', }
...options?.headers })
}
})
} catch (err: any) {
throw err
}
const json: ApiResponse<T> = await res.json() const json: ApiResponse<T> = await res.json()
if (json.code === 401) { if (json.code === 401) {
// 未登录或登录过期,跳转到登录页
window.location.href = BASE_URL + '/login' window.location.href = BASE_URL + '/login'
throw new Error(json.msg || '请先登录') throw new Error(json.msg || 'Unauthorized')
} }
if (json.code !== 200) { if (json.code !== 200) {
throw new Error(json.msg || '请求失败') throw new Error(json.msg || 'Request failed')
} }
return json.data 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
-69
View File
@@ -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' })
}
-634
View File
@@ -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;
}
-186
View File
@@ -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>
-133
View File
@@ -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>
+255
View File
@@ -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>
)
}
-121
View File
@@ -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>
-225
View File
@@ -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>
-236
View File
@@ -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>
-91
View File
@@ -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>
-132
View File
@@ -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>
-46
View File
@@ -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>
-79
View File
@@ -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>
-39
View File
@@ -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>
-147
View File
@@ -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>
-159
View File
@@ -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>
-45
View File
@@ -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>
-39
View File
@@ -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>
-341
View File
@@ -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>
-136
View File
@@ -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"
-26
View File
@@ -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>
-26
View File
@@ -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>
-29
View File
@@ -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>
-38
View File
@@ -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>
-22
View File
@@ -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>
-17
View File
@@ -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>
-17
View File
@@ -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>
-17
View File
@@ -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>
-17
View File
@@ -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>
-7
View File
@@ -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
View File
@@ -1 +0,0 @@
export { default as Checkbox } from "./Checkbox.vue"
-19
View File
@@ -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>
-10
View File
@@ -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"
-23
View File
@@ -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>
-9
View File
@@ -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"
-33
View File
@@ -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
View File
@@ -1 +0,0 @@
export { default as Input } from "./Input.vue"
-26
View File
@@ -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
View File
@@ -1 +0,0 @@
export { default as Label } from "./Label.vue"
-19
View File
@@ -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>
-4
View File
@@ -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