Initial commit: TaskPool React panel
- React frontend with route-level code splitting - Backend rebranded from Baihu to TaskPool - DB brand migration script and local compatibility
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# Logs
|
||||
# logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0d0d0d" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<meta name="description" content="任务池 - 轻量高性能自动化任务调度平台">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="任务池">
|
||||
<title>任务池</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="apple-touch-icon" href="/logo.svg">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2629
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "taskpool-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.80.0",
|
||||
"@tanstack/react-router": "^1.120.0",
|
||||
"@tanstack/react-table": "^8.21.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"recharts": "^2.15.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^8.1.5"
|
||||
}
|
||||
}
|
||||
Generated
+1277
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="4" fill="#3b82f6"/>
|
||||
<text x="16" y="22" text-anchor="middle" fill="white" font-family="system-ui" font-size="18" font-weight="700">T</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 267 B |
@@ -0,0 +1 @@
|
||||
<svg t="1766107903919" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1942" width="200" height="200"><path d="M884.992 273.05984c4.10624 0 5.0688-2.36544 2.10944-5.25312 0 0-64.28672-65.55648-111.7696-75.55072-47.48288-9.984-45.47584-59.37152-80.56832-75.02848-72.0896-32.16384-158.6176-34.3552-158.6176-34.3552s-91.37152-4.92544-138.752-9.89184c-19.46624-2.03776-54.46656-9.58464-54.46656-9.58464-4.0448-0.84992-10.63936-1.19808-14.66368-0.44032 0 0-30.63808-0.07168-44.30848 43.35616-8.8576 28.11904 1.792 104.79616 1.792 104.79616 1.46432 12.27776-3.42016 30.21824-10.72128 40.20224 0 0-36.77184 46.03904-58.9312 100.34176-22.15936 54.30272 118.15936 145.05984 208.98816 205.27104C507.82208 613.89824 502.03648 743.424 502.03648 743.424s-74.19904-96.75776-194.00704-156.9792C188.2112 526.22336 150.30272 442.23488 150.30272 442.23488c-2.89792-5.45792-5.94944-4.94592-6.71744 1.21856 0 0-15.1552 91.61728 16.25088 147.8144 70.92224 126.88384 141.74208 112.88576 197.03808 183.27552s54.272 164.9152 54.272 164.9152-97.62816-141.29152-235.66336-205.55776c-91.53536-61.27616-74.7008-125.91104-85.49376-101.85728-10.79296 24.05376 26.73664 192.41984 65.40288 222.2592 80.57856 62.18752 94.16704 101.98016 94.16704 101.98016h175.53408S544.512 814.85824 572.928 725.73952c44.41088-139.30496 40.20224-191.26272 40.20224-191.26272 0.08192-8.22272 6.81984-14.19264 14.98112-13.29152 0 0 46.45888 4.64896 66.64192 9.68704 23.53152 5.86752 55.35744 26.20416 55.35744 26.20416 3.49184 2.14016 7.39328 0.68608 8.69376-3.1744l34.4576-102.77888c1.30048-3.8912-0.63488-5.51936-4.352-3.75808 0 0-45.37344 25.09824-88.17664 12.1856-20.15232-6.08256-59.60704-14.82752-74.69056-32.6656-16.95744-20.03968-15.59552-71.3728 26.66496-79.21664 48.0256-8.9088 33.13664 15.14496 65.91488 24.64768 27.42272 7.95648 22.29248-1.69984 26.69568 5.21216 1.67936 2.63168 0.38912 32.65536 0.38912 32.65536-0.21504 6.144 3.39968 7.84384 8.0384 3.79904l41.89184-36.46464c17.37728-11.24352 30.86336-0.57344 48.24064-11.81696 14.73536-9.53344 29.58336-43.66336 29.58336-43.66336 4.48512-9.24672 0.60416-20.41856-8.63232-24.92416l-42.58816-20.80768c-3.69664-1.80224-3.38944-3.26656 0.74752-3.26656h62.0032zM422.54336 123.87328s-49.88928 50.7392-74.5472 50.66752c-24.65792-0.07168-33.24928-56.12544-3.92192-56.12544 18.00192 0 76.32896 0.21504 76.32896 0.21504 4.13696 0.02048 5.0688 2.36544 2.14016 5.24288z m123.09504 249.64096s-3.31776-25.53856-33.16736-40.05888c-29.8496-14.52032-52.5312-41.13408-59.648-68.95616-12.1856-54.8864 48.29184-104.192 48.29184-104.192s-30.1056 73.6768 3.5328 106.60864c54.272 53.12512 40.99072 106.5984 40.99072 106.5984z m155.56608-164.46464c-7.94624 10.32192-9.60512 37.92896-59.2384 20.736-49.63328-17.2032-71.00416-54.5792-71.00416-54.5792-2.27328-3.40992-0.79872-6.49216 3.328-6.8096 0 0 43.55072-5.03808 80.06656 6.44096 24.91392 7.82336 54.79424 23.88992 46.848 34.21184z" fill="#272636" p-id="1943"></path><path d="M366.30528 259.26656c1.05472-1.76128 1.51552-1.57696 1.19808 0.43008 0 0-7.55712 19.0464 22.8864 87.63392 27.0848 61.02016 87.49056 68.7104 118.66112 98.23232 55.808 52.82816 53.52448 123.45344 53.52448 123.45344s-25.82528-49.85856-85.98528-78.83776-92.6208-50.52416-127.7952-85.77024c-45.02528-45.12768 17.5104-145.14176 17.5104-145.14176zM500.0704 961.44384h134.49216s95.8464-138.07616 46.68416-291.25632c-16.19968-50.46272-43.45856-80.00512-43.45856-80.00512-5.18144-6.38976-8.89856-4.88448-8.448 3.34848 0 0 9.15456 99.80928-19.44576 194.00704-28.60032 94.208-109.824 173.90592-109.824 173.90592zM681.61536 956.8768h105.24672s22.38464-73.5232 16.61952-130.21184c-9.30816-91.57632-48.31232-132.72064-48.31232-132.72064-3.80928-4.77184-6.0928-3.67616-5.2224 2.38592 0 0 16.75264 89.1904-14.4896 150.1184-30.9248 60.30336-53.84192 110.42816-53.84192 110.42816zM869.30432 811.35616c-2.88768-2.9184-4.80256-1.95584-4.38272 2.10944 0 0 6.79936 44.99456-7.76192 81.22368-10.12736 25.1904-28.91776 58.60352-28.91776 58.60352h107.17184s5.34528-50.31936-19.89632-83.97824c-11.56096-23.99232-46.21312-57.9584-46.21312-57.9584z" fill="#272636" p-id="1944"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "任务池",
|
||||
"short_name": "TaskPool",
|
||||
"description": "轻量高性能自动化任务调度平台",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d0d0d",
|
||||
"theme_color": "#0d0d0d",
|
||||
"lang": "zh-CN",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* TaskPool System Event SharedWorker
|
||||
*
|
||||
* 这个 Worker 维护全浏览器唯一的一个 WebSocket 连接,
|
||||
* 并通过 postMessage 将收到的事件同步给所有打开的标签页。
|
||||
*/
|
||||
|
||||
let ws = null;
|
||||
const ports = new Set();
|
||||
let reconnectTimer = null;
|
||||
let lockReconnect = false;
|
||||
|
||||
// WebSocket 配置 (由第一个连接的页面通过消息传过来,或者使用默认值)
|
||||
let wsUrl = '';
|
||||
|
||||
/**
|
||||
* 建立 WebSocket 连接
|
||||
*/
|
||||
function connect() {
|
||||
if (!wsUrl || ws) return;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[SharedWorker] WebSocket 已连接');
|
||||
lockReconnect = false;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// 广播消息给所有连接的端口
|
||||
broadcast(event.data);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[SharedWorker] WebSocket 已断开');
|
||||
ws = null;
|
||||
reconnect();
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('[SharedWorker] WebSocket 错误:', err);
|
||||
ws = null;
|
||||
reconnect();
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[SharedWorker] 建立连接失败:', e);
|
||||
reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重连逻辑
|
||||
*/
|
||||
function reconnect() {
|
||||
if (lockReconnect) return;
|
||||
lockReconnect = true;
|
||||
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connect();
|
||||
lockReconnect = false;
|
||||
}, 5000); // 5秒后尝试重连
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播消息给所有活跃标签页
|
||||
*/
|
||||
function broadcast(data) {
|
||||
const msg = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
ports.forEach(port => {
|
||||
try {
|
||||
port.postMessage(msg);
|
||||
} catch (e) {
|
||||
// 如果端口失效,移除它
|
||||
ports.delete(port);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 SharedWorker 连接
|
||||
*/
|
||||
self.onconnect = (e) => {
|
||||
const port = e.ports[0];
|
||||
ports.add(port);
|
||||
|
||||
port.onmessage = (event) => {
|
||||
const { type, data } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'init':
|
||||
// 初始化配置
|
||||
if (data.url) {
|
||||
wsUrl = data.url;
|
||||
if (!ws) connect();
|
||||
}
|
||||
break;
|
||||
case 'ping':
|
||||
port.postMessage({ type: 'pong' });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
port.start();
|
||||
|
||||
// 发送一条初始消息确认连接成功
|
||||
port.postMessage({ type: 'worker_ready' });
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const root = new URL('../_ref/', import.meta.url)
|
||||
await mkdir(root, { recursive: true })
|
||||
|
||||
const files = [
|
||||
['tasks-Tasks.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/Tasks.vue'],
|
||||
['tasks-TaskDialog.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/TaskDialog.vue'],
|
||||
['tasks-RepoDialog.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/RepoDialog.vue'],
|
||||
['tasks-TaskAdvancedConfig.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/components/TaskAdvancedConfig.vue'],
|
||||
['tasks-TaskCronConfig.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/components/TaskCronConfig.vue'],
|
||||
['tasks-TaskLangConfig.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/components/TaskLangConfig.vue'],
|
||||
['tasks-TaskNotificationConfig.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/components/TaskNotificationConfig.vue'],
|
||||
['history-History.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/history/History.vue'],
|
||||
['agents-Agents.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/agents/Agents.vue'],
|
||||
['editor-Editor.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/editor/Editor.vue'],
|
||||
['env-Environments.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/environments/Environments.vue'],
|
||||
['notify-Notify.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/notify/Notify.vue'],
|
||||
['settings-Settings.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/settings/Settings.vue'],
|
||||
['dashboard-Dashboard.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/dashboard/Dashboard.vue'],
|
||||
['monitor-Monitor.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/monitor/Monitor.vue'],
|
||||
['languages-Languages.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/languages/Languages.vue'],
|
||||
['deps-Dependencies.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/dependencies/Dependencies.vue'],
|
||||
['interconnect-index.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/interconnect/index.vue'],
|
||||
['logs-MessageLogs.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/logs/MessageLogs.vue'],
|
||||
['terminal-Terminal.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/terminal/Terminal.vue'],
|
||||
['MainLayout.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/layouts/MainLayout.vue'],
|
||||
['LogDetailCard.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/components/LogDetailCard.vue'],
|
||||
['NodeSwitcher.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/components/NodeSwitcher.vue'],
|
||||
['api-index.ts', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/api/index.ts'],
|
||||
]
|
||||
|
||||
for (const [name, url] of files) {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
const text = await res.text()
|
||||
await writeFile(new URL(name, root), text, 'utf8')
|
||||
console.log('OK', name, text.length)
|
||||
} catch (e) {
|
||||
console.error('FAIL', name, e.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from './index'
|
||||
import * as interconnect from './interconnect'
|
||||
|
||||
export function useUser(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: () => api.auth.me(),
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: { username: string; password: string }) => api.auth.login(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['user'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => api.auth.logout(),
|
||||
onSuccess: () => {
|
||||
qc.clear()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStats() {
|
||||
return useQuery({
|
||||
queryKey: ['stats'],
|
||||
queryFn: () => api.dashboard.stats(),
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSentence() {
|
||||
return useQuery({
|
||||
queryKey: ['sentence'],
|
||||
queryFn: () => api.dashboard.sentence(),
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSendStats(days?: number) {
|
||||
return useQuery({
|
||||
queryKey: ['sendStats', days],
|
||||
queryFn: async () => {
|
||||
const data = await api.dashboard.sendStats(days)
|
||||
return Array.isArray(data) ? data : []
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTaskStats(days?: number) {
|
||||
return useQuery({
|
||||
queryKey: ['taskStats', days],
|
||||
queryFn: async () => {
|
||||
const data = await api.dashboard.taskStats(days)
|
||||
return Array.isArray(data) ? data : []
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMonitorStats() {
|
||||
return useQuery({
|
||||
queryKey: ['monitor'],
|
||||
queryFn: () => api.settings.getMonitor(),
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useTasks(params?: Parameters<typeof api.tasks.list>[0]) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', params],
|
||||
queryFn: async () => {
|
||||
const data = await api.tasks.list(params)
|
||||
return {
|
||||
...data,
|
||||
data: Array.isArray(data?.data) ? data.data : [],
|
||||
total: data?.total ?? 0,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTaskTags() {
|
||||
return useQuery({
|
||||
queryKey: ['taskTags'],
|
||||
queryFn: () => api.tasks.tags(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateTask() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: api.tasks.create,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTask() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => api.tasks.update(id, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteTask() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.tasks.delete(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useExecuteTask() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.tasks.execute(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['tasks'] })
|
||||
qc.invalidateQueries({ queryKey: ['logs'] })
|
||||
qc.invalidateQueries({ queryKey: ['stats'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogs(params?: Parameters<typeof api.logs.list>[0]) {
|
||||
return useQuery({
|
||||
queryKey: ['logs', params],
|
||||
queryFn: () => api.logs.list(params),
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogDetail(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['log', id],
|
||||
queryFn: () => api.logs.get(id),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useEnvs(params?: Parameters<typeof api.env.list>[0]) {
|
||||
return useQuery({
|
||||
queryKey: ['envs', params],
|
||||
queryFn: () => api.env.list(params),
|
||||
})
|
||||
}
|
||||
|
||||
export function useEnvTags() {
|
||||
return useQuery({
|
||||
queryKey: ['envTags'],
|
||||
queryFn: () => api.env.tags(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateEnv() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: api.env.create,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateEnv() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => api.env.update(id, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteEnv() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.env.delete(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useFileTree() {
|
||||
return useQuery({
|
||||
queryKey: ['fileTree'],
|
||||
queryFn: () => api.files.tree(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgents() {
|
||||
return useQuery({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => api.agents.list(),
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgentTokens() {
|
||||
return useQuery({
|
||||
queryKey: ['agentTokens'],
|
||||
queryFn: () => api.agents.listTokens(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMiseList() {
|
||||
return useQuery({
|
||||
queryKey: ['mise'],
|
||||
queryFn: () => api.mise.list(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeps(params?: Parameters<typeof api.deps.list>[0]) {
|
||||
return useQuery({
|
||||
queryKey: ['deps', params],
|
||||
queryFn: () => api.deps.list(params),
|
||||
})
|
||||
}
|
||||
|
||||
export function useNotifyChannels() {
|
||||
return useQuery({
|
||||
queryKey: ['notifyChannels'],
|
||||
queryFn: () => api.notify.getChannels(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useNotifyBindings() {
|
||||
return useQuery({
|
||||
queryKey: ['notifyBindings'],
|
||||
queryFn: () => api.notify.getBindings(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useNotifyTypes() {
|
||||
return useQuery({
|
||||
queryKey: ['notifyTypes'],
|
||||
queryFn: () => api.notify.getTypes(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useAppLogs(params?: Parameters<typeof api.appLogs.list>[0]) {
|
||||
return useQuery({
|
||||
queryKey: ['appLogs', params],
|
||||
queryFn: async () => {
|
||||
const data = await api.appLogs.list(params)
|
||||
return {
|
||||
...data,
|
||||
data: Array.isArray(data?.data) ? data.data : [],
|
||||
total: data?.total ?? 0,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSiteSettings() {
|
||||
return useQuery({
|
||||
queryKey: ['siteSettings'],
|
||||
queryFn: () => api.settings.getSite(),
|
||||
})
|
||||
}
|
||||
|
||||
export function usePublicSite() {
|
||||
return useQuery({
|
||||
queryKey: ['publicSite'],
|
||||
queryFn: () => api.settings.getPublicSite(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSchedulerSettings() {
|
||||
return useQuery({
|
||||
queryKey: ['schedulerSettings'],
|
||||
queryFn: () => api.settings.getScheduler(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useAbout() {
|
||||
return useQuery({
|
||||
queryKey: ['about'],
|
||||
queryFn: () => api.settings.getAbout(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useNodes() {
|
||||
return useQuery({
|
||||
queryKey: ['nodes'],
|
||||
queryFn: () => interconnect.getNodes(),
|
||||
refetchInterval: 15000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateNode() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: interconnect.createNode,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['nodes'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteNode() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: interconnect.deleteNode,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['nodes'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useTerminalCmds() {
|
||||
return useQuery({
|
||||
queryKey: ['terminalCmds'],
|
||||
queryFn: () => api.terminal.cmds(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useInstallStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['installStatus'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/v1/install/status', { credentials: 'include' })
|
||||
if (!res.ok) throw new Error('获取安装状态失败')
|
||||
return res.json() as Promise<{ installed: boolean; has_admin?: boolean; message?: string }>
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useInstall() {
|
||||
return useMutation({
|
||||
mutationFn: async (data: Record<string, any>) => {
|
||||
const res = await fetch('/api/v1/install', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error || json.msg || '安装失败')
|
||||
return json
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,872 @@
|
||||
// 获取 base URL(从后端注入的全局变量)
|
||||
const BASE_URL = (window as any).__BASE_URL__ || ''
|
||||
const API_VERSION = (window as any).__API_VERSION__ || '/api/v1'
|
||||
const API_BASE_URL = BASE_URL + API_VERSION
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: number
|
||||
msg: string
|
||||
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`
|
||||
}
|
||||
}
|
||||
|
||||
let redirectingToLogin = false
|
||||
|
||||
function redirectToLogin() {
|
||||
const loginPath = `${BASE_URL}/login`
|
||||
if (redirectingToLogin) return
|
||||
if (window.location.pathname === `${BASE_URL}/login` || window.location.pathname.endsWith('/login')) return
|
||||
redirectingToLogin = true
|
||||
window.location.href = loginPath
|
||||
}
|
||||
|
||||
async function parseApiResponse<T>(res: Response, url: string): Promise<ApiResponse<T>> {
|
||||
const text = await res.text()
|
||||
const trimmed = text.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
if (res.status === 502 || res.status === 503 || res.status === 504) {
|
||||
throw new Error('后端服务不可用,请确认已启动 TaskPool server(默认 :8052)')
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`请求失败(HTTP ${res.status})`)
|
||||
}
|
||||
throw new Error('服务器返回了空响应')
|
||||
}
|
||||
|
||||
let json: ApiResponse<T>
|
||||
try {
|
||||
json = JSON.parse(trimmed) as ApiResponse<T>
|
||||
} catch {
|
||||
if (res.status === 502 || res.status === 503 || res.status === 504) {
|
||||
throw new Error('后端服务不可用,请确认已启动 TaskPool server(默认 :8052)')
|
||||
}
|
||||
throw new Error(`服务器返回了非 JSON 响应(HTTP ${res.status})`)
|
||||
}
|
||||
|
||||
if (json.code === 401) {
|
||||
// 公开接口不应触发硬跳转;其它接口未登录时跳转登录页
|
||||
const isPublic =
|
||||
url.startsWith('/settings/public') || url.startsWith('/auth/login') || url.startsWith('/install')
|
||||
if (!isPublic) redirectToLogin()
|
||||
throw new Error(json.msg || '请先登录')
|
||||
}
|
||||
|
||||
if (typeof json.code === 'number' && json.code !== 200) {
|
||||
throw new Error(json.msg || '请求失败')
|
||||
}
|
||||
|
||||
// 兼容非标准成功体:HTTP 非 2xx 时优先报状态码
|
||||
if (!res.ok && (json as any)?.code === undefined) {
|
||||
throw new Error(json.msg || `请求失败(HTTP ${res.status})`)
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
export async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
credentials: 'include', // 携带 Cookie
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
} catch (err: any) {
|
||||
// 路由切换 / React Query 取消请求时,浏览器会报 net::ERR_ABORTED
|
||||
if (err?.name === 'AbortError' || options?.signal?.aborted) {
|
||||
throw err
|
||||
}
|
||||
throw new Error(err?.message || '网络请求失败,请检查后端是否已启动')
|
||||
}
|
||||
|
||||
const json = await parseApiResponse<T>(res, url)
|
||||
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 text = await res.text()
|
||||
if (!text.trim()) return false
|
||||
const json = JSON.parse(text) as ApiResponse<{ username: string }>
|
||||
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
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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' })
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
// 获取 base URL(从后端注入的全局变量)
|
||||
const BASE_URL = (window as any).__BASE_URL__ || ''
|
||||
const API_VERSION = (window as any).__API_VERSION__ || '/api/v1'
|
||||
const API_BASE_URL = BASE_URL + API_VERSION
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface MonitorStats {
|
||||
env: {
|
||||
os: string
|
||||
arch: string
|
||||
go_version: string
|
||||
num_cpu: number
|
||||
goroutines: number
|
||||
}
|
||||
host: {
|
||||
cpu_percent: number
|
||||
mem_total: number
|
||||
mem_used: number
|
||||
mem_percent: number
|
||||
disk_total: number
|
||||
disk_used: number
|
||||
disk_percent: number
|
||||
uptime: number
|
||||
platform: string
|
||||
}
|
||||
mem: {
|
||||
alloc: number
|
||||
total_alloc: number
|
||||
sys: number
|
||||
lookups: number
|
||||
mallocs: number
|
||||
frees: number
|
||||
}
|
||||
heap: {
|
||||
heap_alloc: number
|
||||
heap_sys: number
|
||||
heap_idle: number
|
||||
heap_inuse: number
|
||||
heap_released: number
|
||||
heap_objects: number
|
||||
}
|
||||
gc: {
|
||||
next_gc: number
|
||||
last_gc: number
|
||||
pause_total_ns: number
|
||||
num_gc: number
|
||||
}
|
||||
scheduler: {
|
||||
scheduled: number
|
||||
running: number
|
||||
queue_size: number
|
||||
worker_count: number
|
||||
workers: {
|
||||
id: number
|
||||
status: string
|
||||
task_id?: string
|
||||
task_name?: string
|
||||
start_time?: number
|
||||
duration?: number
|
||||
}[]
|
||||
}
|
||||
}
|
||||
export let activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
|
||||
export let activeInterconnectNodeName = localStorage.getItem('activeInterconnectNodeName') || ''
|
||||
|
||||
export function setActiveInterconnectNodeId(id: string, name?: string) {
|
||||
activeInterconnectNodeId = id
|
||||
if (name !== undefined) {
|
||||
activeInterconnectNodeName = name
|
||||
}
|
||||
localStorage.removeItem('site_settings_cache')
|
||||
if (id) {
|
||||
localStorage.setItem('activeInterconnectNodeId', id)
|
||||
if (name) localStorage.setItem('activeInterconnectNodeName', name)
|
||||
document.cookie = `active_interconnect_node_id=${id}; path=/; max-age=${7 * 24 * 3600}`
|
||||
} else {
|
||||
localStorage.removeItem('activeInterconnectNodeId')
|
||||
localStorage.removeItem('activeInterconnectNodeName')
|
||||
activeInterconnectNodeName = ''
|
||||
document.cookie = `active_interconnect_node_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC`
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
credentials: 'include', // 携带 Cookie
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
throw err
|
||||
}
|
||||
|
||||
const json: ApiResponse<T> = await res.json()
|
||||
|
||||
if (json.code === 401) {
|
||||
// 未登录或登录过期,跳转到登录页
|
||||
window.location.href = BASE_URL + '/login'
|
||||
throw new Error(json.msg || '请先登录')
|
||||
}
|
||||
|
||||
if (json.code !== 200) {
|
||||
throw new Error(json.msg || '请求失败')
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// API Types for TaskPool
|
||||
|
||||
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
|
||||
}
|
||||
scheduler: {
|
||||
scheduled: number
|
||||
running: number
|
||||
queue_size: number
|
||||
worker_count: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number
|
||||
name: string
|
||||
script: string
|
||||
cron: string
|
||||
status: string
|
||||
tags: string[]
|
||||
env_ids: number[]
|
||||
language: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_run?: string
|
||||
next_run?: string
|
||||
}
|
||||
|
||||
export interface Script {
|
||||
id: number
|
||||
name: string
|
||||
filename: string
|
||||
content: string
|
||||
language: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface EnvVar {
|
||||
id: number
|
||||
key: string
|
||||
value: string
|
||||
tags: string[]
|
||||
remark: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: number
|
||||
task_id: number
|
||||
task_name: string
|
||||
status: string
|
||||
output: string
|
||||
started_at: string
|
||||
finished_at: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface InterconnectNode {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
token: string
|
||||
remark: string
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
metrics?: {
|
||||
cpu_percent: number
|
||||
mem_percent: number
|
||||
disk_percent: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
site_name: string
|
||||
site_url: string
|
||||
theme: string
|
||||
language: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
FileCode,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
LogOut,
|
||||
Menu,
|
||||
Moon,
|
||||
Network,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
Sun,
|
||||
Terminal,
|
||||
Variable,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from '@/context/ThemeContext'
|
||||
import { api } from '@/api'
|
||||
import { usePublicSite, useSentence } from '@/api/hooks'
|
||||
import { NodeSwitcher } from '@/components/business/NodeSwitcher'
|
||||
import { SystemNotice } from '@/components/business/SystemNotice'
|
||||
import { ToastHost } from '@/components/business/ToastHost'
|
||||
import { resetAuthCache } from '@/lib/auth'
|
||||
import { eventBus } from '@/lib/event-bus'
|
||||
import { Button, cn } from '@/components/ui'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/', icon: LayoutDashboard, label: '数据仪表', exact: true },
|
||||
{ path: '/tasks', icon: ListTodo, label: '定时任务', exact: true },
|
||||
{ path: '/agents', icon: Server, label: '远程执行', exact: true },
|
||||
{ path: '/editor', icon: FileCode, label: '脚本编辑', exact: false },
|
||||
{ path: '/history', icon: ScrollText, label: '执行历史', exact: true },
|
||||
{ path: '/environments', icon: Variable, label: '变量机密', exact: true },
|
||||
{ path: '/languages', icon: Globe, label: '语言依赖', exact: false },
|
||||
{ path: '/terminal', icon: Terminal, label: '终端命令', exact: true },
|
||||
{ path: '/notify', icon: Bell, label: '消息推送', exact: true },
|
||||
{ path: '/logs', icon: KeyRound, label: '运行日志', exact: true },
|
||||
{ path: '/monitor', icon: Activity, label: '系统监控', exact: true },
|
||||
{ path: '/interconnect', icon: Network, label: '互联管理', exact: true },
|
||||
{ path: '/settings', icon: Settings, label: '系统设置', exact: true },
|
||||
] as const
|
||||
|
||||
function isActive(pathname: string, path: string, exact: boolean) {
|
||||
if (exact) return pathname === path
|
||||
return pathname === path || pathname.startsWith(path + '/')
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { data: site } = usePublicSite()
|
||||
const { data: sentenceData } = useSentence()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
const title = site?.title || '任务池'
|
||||
const sentence = sentenceData?.sentence || '欢迎使用任务池'
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title
|
||||
const icon = site?.icon
|
||||
if (icon) {
|
||||
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.href = icon
|
||||
}
|
||||
}, [title, site?.icon])
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 1024)
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
eventBus.init()
|
||||
}, [])
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api.auth.logout()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resetAuthCache()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
||||
{isMobile && menuOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-[1px]" onClick={() => setMenuOpen(false)} />
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 flex w-[200px] shrink-0 flex-col border-r border-[var(--border)] bg-[var(--bg-secondary)] transition-transform duration-200 lg:static lg:translate-x-0',
|
||||
isMobile && !menuOpen ? '-translate-x-full' : 'translate-x-0',
|
||||
)}
|
||||
>
|
||||
<div className="relative flex h-14 items-center gap-2.5 border-b border-[var(--border)] px-4">
|
||||
<div className="ms-on-accent flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md bg-[var(--bg-accent)] text-[13px] font-bold text-[var(--text-on-accent)]">
|
||||
T
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-semibold tracking-tight">{title}</div>
|
||||
<div className="truncate text-[10px] text-[var(--text-muted)]">TaskPool</div>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<button
|
||||
className="rounded-md p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<X size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2.5 py-3">
|
||||
{NAV_ITEMS.map(({ path, icon: Icon, label, exact }) => {
|
||||
const active = isActive(location.pathname, path, exact)
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
onClick={() => navigate({ to: path })}
|
||||
className={cn(
|
||||
'group flex h-9 w-full items-center gap-2.5 rounded-md px-2.5 text-[13px] transition-[background,color,border-color] duration-150',
|
||||
active
|
||||
? 'border border-[var(--border-hover)] bg-[var(--bg-tertiary)] font-medium text-[var(--text-primary)]'
|
||||
: 'border border-transparent text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={15}
|
||||
strokeWidth={1.5}
|
||||
className={cn(active ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)] group-hover:text-[var(--text-primary)]')}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
{active ? <span className="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--bg-accent)]" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="space-y-1 border-t border-[var(--border)] p-2.5">
|
||||
<Button variant="ghost" className="h-9 w-full justify-start gap-2.5 px-2.5 text-[13px]" onClick={logout}>
|
||||
<LogOut size={15} strokeWidth={1.5} />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-secondary)_92%,transparent)] px-4 backdrop-blur-md">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{isMobile && (
|
||||
<button
|
||||
className="rounded-md p-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
>
|
||||
<Menu size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
<p className="truncate text-[13px] text-[var(--text-muted)]" title={sentence}>
|
||||
{sentence}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NodeSwitcher />
|
||||
<SystemNotice />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'light' ? '深色模式' : '浅色模式'}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{theme === 'light' ? <Moon size={15} strokeWidth={1.5} /> : <Sun size={15} strokeWidth={1.5} />}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="ms-page flex-1 overflow-y-auto p-4 lg:p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<ToastHost />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Ban, Maximize2, Trash2, X } from 'lucide-react'
|
||||
import type { TaskLog } from '@/api'
|
||||
import { TASK_STATUS, TASK_STATUS_TEXT } from '@/constants'
|
||||
import { formatDateTime, formatDuration } from '@/lib/format'
|
||||
import { ansiToHtml } from '@/lib/log-render'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { StatusDot } from '@/components/business/StatusDot'
|
||||
|
||||
export function LogDetailPanel({
|
||||
log,
|
||||
content,
|
||||
loading,
|
||||
isStopping,
|
||||
onClose,
|
||||
onStop,
|
||||
onDelete,
|
||||
onMaximize,
|
||||
}: {
|
||||
log: TaskLog | null
|
||||
content: string
|
||||
loading?: boolean
|
||||
isStopping?: boolean
|
||||
onClose?: () => void
|
||||
onStop?: () => void
|
||||
onDelete?: (id: string) => void
|
||||
onMaximize?: () => void
|
||||
}) {
|
||||
const html = useMemo(() => ansiToHtml(content || ''), [content])
|
||||
|
||||
if (!log) {
|
||||
return <div className="p-6 text-sm text-[var(--text-muted)]">选择一条记录查看详情</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[360px] flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<StatusDot state={log.status} />
|
||||
<span className="truncate text-sm font-medium">日志详情</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">{TASK_STATUS_TEXT[log.status] || log.status}</span>
|
||||
{log.status === TASK_STATUS.RUNNING ? (
|
||||
<Button size="sm" variant="danger" disabled={isStopping} onClick={onStop}>
|
||||
<Ban size={12} />
|
||||
{isStopping ? '停止中' : '停止'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onMaximize ? (
|
||||
<Button size="icon" variant="ghost" title="全屏" onClick={onMaximize}>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDelete && log.id ? (
|
||||
<Button size="icon" variant="ghost" title="删除" onClick={() => onDelete(log.id)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
) : null}
|
||||
{onClose ? (
|
||||
<Button size="icon" variant="ghost" title="关闭" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-b border-[var(--border)] px-4 py-3 text-sm">
|
||||
<Row label="任务名称" value={log.task_name || log.task_id} />
|
||||
<Row label="状态" value={TASK_STATUS_TEXT[log.status] || log.status} />
|
||||
<Row label="耗时" value={formatDuration(log.duration)} />
|
||||
<Row label="开始" value={formatDateTime(log.start_time)} />
|
||||
<Row label="结束" value={formatDateTime(log.end_time)} />
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-[var(--text-muted)]">执行命令</div>
|
||||
<pre className="max-h-24 overflow-auto rounded-md bg-[var(--bg-primary)] p-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{log.command || '-'}
|
||||
</pre>
|
||||
</div>
|
||||
{log.error ? (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-[var(--danger)]">错误</div>
|
||||
<pre className="overflow-auto rounded-md bg-[color-mix(in_srgb,var(--danger)_10%,transparent)] p-2 font-mono text-xs text-[var(--danger)] whitespace-pre-wrap">
|
||||
{log.error}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-b border-[var(--border)] px-4 py-1.5 text-xs text-[var(--text-muted)]">
|
||||
<span>日志输出</span>
|
||||
{loading ? <span>加载中...</span> : null}
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 overflow-auto bg-[var(--bg-primary)] p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap text-[var(--text-secondary)]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: html || (loading ? '加载中...' : '(无输出)'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-[var(--text-muted)]">{label}</span>
|
||||
<span className="truncate text-xs text-[var(--text-secondary)]">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { MonitorDot } from 'lucide-react'
|
||||
import {
|
||||
activeInterconnectNodeId,
|
||||
api,
|
||||
setActiveInterconnectNodeId,
|
||||
} from '@/api'
|
||||
import { getNodes, type InterconnectNode } from '@/api/interconnect'
|
||||
import { useEventBus } from '@/hooks/useEventBus'
|
||||
import { SYSTEM_EVENTS } from '@/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function NodeSwitcher() {
|
||||
const [nodes, setNodes] = useState<InterconnectNode[]>([])
|
||||
const [selected, setSelected] = useState(activeInterconnectNodeId || 'local')
|
||||
const [isMaster, setIsMaster] = useState(false)
|
||||
|
||||
const fetchNodes = useCallback(async () => {
|
||||
try {
|
||||
const res = await getNodes()
|
||||
setNodes(Array.isArray(res) ? res : [])
|
||||
} catch {
|
||||
setNodes([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateRoleState = useCallback(
|
||||
(role: string) => {
|
||||
const master = role === 'master'
|
||||
setIsMaster(master)
|
||||
if (master) {
|
||||
setSelected(activeInterconnectNodeId || 'local')
|
||||
fetchNodes()
|
||||
} else {
|
||||
setNodes([])
|
||||
setSelected('local')
|
||||
const traveling = !!document.cookie.match(/(?:^| )active_interconnect_node_id=([^;]*)/)
|
||||
if (activeInterconnectNodeId && !traveling) {
|
||||
setActiveInterconnectNodeId('')
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchNodes],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
const role = await api.settings.get('interconnect', 'interconnect_role')
|
||||
updateRoleState(role || 'none')
|
||||
} catch {
|
||||
updateRoleState('none')
|
||||
}
|
||||
})()
|
||||
}, [updateRoleState])
|
||||
|
||||
useEventBus(SYSTEM_EVENTS.INTERCONNECT_ROLE_CHANGED, (role) => {
|
||||
updateRoleState(String(role || 'none'))
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMaster) return
|
||||
const t = window.setInterval(fetchNodes, 120000)
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === 'visible') fetchNodes()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
return () => {
|
||||
clearInterval(t)
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
}
|
||||
}, [isMaster, fetchNodes])
|
||||
|
||||
if (!isMaster) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-8 max-w-[150px] items-center gap-1 rounded-md border px-2 text-[11px]',
|
||||
selected !== 'local'
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
||||
: 'border-[var(--border)] bg-[var(--bg-secondary)] text-[var(--text-secondary)]',
|
||||
)}
|
||||
>
|
||||
<MonitorDot size={12} className="shrink-0" />
|
||||
<select
|
||||
className="max-w-[120px] truncate border-0 bg-transparent text-[11px] outline-none"
|
||||
value={selected}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setSelected(val)
|
||||
if (val === 'local') {
|
||||
setActiveInterconnectNodeId('')
|
||||
} else {
|
||||
const name = nodes.find((n) => n.id === val)?.name || ''
|
||||
setActiveInterconnectNodeId(val, name)
|
||||
}
|
||||
window.location.reload()
|
||||
}}
|
||||
>
|
||||
<option value="local">本机节点</option>
|
||||
{nodes.map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{n.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { TASK_STATUS } from '@/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function StatusDot({ state, className }: { state?: string; className?: string }) {
|
||||
const color =
|
||||
state === TASK_STATUS.SUCCESS
|
||||
? 'bg-[var(--success)]'
|
||||
: state === TASK_STATUS.FAILED || state === TASK_STATUS.TIMEOUT
|
||||
? 'bg-[var(--danger)]'
|
||||
: state === TASK_STATUS.RUNNING
|
||||
? 'bg-[var(--warning)] animate-pulse'
|
||||
: state === TASK_STATUS.CANCELLED
|
||||
? 'bg-[var(--text-muted)]'
|
||||
: 'bg-[var(--text-muted)]'
|
||||
|
||||
return <span className={cn('inline-block h-2 w-2 shrink-0 rounded-full', color, className)} />
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Bell, Check } from 'lucide-react'
|
||||
import { api, LOG_CATEGORY, LOG_STATUS, type AppLog } from '@/api'
|
||||
import { LOG_EVENTS } from '@/constants'
|
||||
import { useEventBus } from '@/hooks/useEventBus'
|
||||
import { Button } from '@/components/ui'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SystemNotice() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [notices, setNotices] = useState<AppLog[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchNotices = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.appLogs.list({
|
||||
category: LOG_CATEGORY.SYSTEM_NOTICE,
|
||||
status: LOG_STATUS.UNREAD,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
})
|
||||
setNotices(Array.isArray(res?.data) ? res.data : [])
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotices()
|
||||
const t = window.setInterval(fetchNotices, 10 * 60 * 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [fetchNotices])
|
||||
|
||||
useEventBus(LOG_EVENTS.ADDED, (log) => {
|
||||
if (log?.category === LOG_CATEGORY.SYSTEM_NOTICE) fetchNotices()
|
||||
})
|
||||
|
||||
async function markAsRead(id: string) {
|
||||
try {
|
||||
await api.appLogs.markAsRead({ id })
|
||||
setNotices((list) => list.filter((n) => n.id !== id))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAsRead() {
|
||||
if (!notices.length) return
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.appLogs.markAsRead({ category: LOG_CATEGORY.SYSTEM_NOTICE })
|
||||
setNotices([])
|
||||
setOpen(false)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const unread = notices.length
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="relative h-8 w-8"
|
||||
title="系统通知"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Bell size={15} strokeWidth={1.5} />
|
||||
{unread > 0 ? (
|
||||
<span className="absolute right-1 top-1 h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
||||
) : null}
|
||||
</Button>
|
||||
|
||||
{open ? (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-10 z-50 w-80 overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] shadow-[var(--shadow-md)]">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border)] px-3 py-2">
|
||||
<span className="text-sm font-medium">
|
||||
系统通知
|
||||
{unread ? <span className="ml-1 text-xs text-[var(--text-muted)]">({unread})</span> : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:opacity-40"
|
||||
disabled={loading || unread === 0}
|
||||
onClick={markAllAsRead}
|
||||
>
|
||||
<Check size={12} />
|
||||
全标已读
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{notices.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-10 text-sm text-[var(--text-muted)]">
|
||||
<Bell size={28} className="mb-2 opacity-20" />
|
||||
暂无新通知
|
||||
</div>
|
||||
) : (
|
||||
notices.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
className="block w-full border-b border-[var(--border)] px-3 py-3 text-left last:border-0 hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => markAsRead(n.id)}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[10px]',
|
||||
n.level === 'error'
|
||||
? 'bg-red-500/15 text-red-500'
|
||||
: n.level === 'warning'
|
||||
? 'bg-amber-500/15 text-amber-500'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-muted)]',
|
||||
)}
|
||||
>
|
||||
{n.level === 'error' ? '错误' : n.level === 'warning' ? '警告' : '提示'}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium">{n.title}</span>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-xs text-[var(--text-muted)]">{n.content}</p>
|
||||
<p className="mt-1 text-[10px] text-[var(--text-muted)]">{formatDateTime(n.created_at)}</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, type KeyboardEvent } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function TagInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '输入标签后回车',
|
||||
className,
|
||||
}: {
|
||||
value?: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const tags = (value || '')
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
function commit(next: string[]) {
|
||||
onChange(next.join(','))
|
||||
}
|
||||
|
||||
function addTag(raw: string) {
|
||||
const t = raw.trim()
|
||||
if (!t || tags.includes(t)) {
|
||||
setDraft('')
|
||||
return
|
||||
}
|
||||
commit([...tags, t])
|
||||
setDraft('')
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
addTag(draft)
|
||||
} else if (e.key === 'Backspace' && !draft && tags.length) {
|
||||
commit(tags.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('ui-control flex min-h-9 flex-wrap items-center gap-1.5 px-2 py-1', className)}>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded bg-[var(--bg-selected)] px-1.5 py-0.5 text-xs text-[var(--text-primary)]"
|
||||
>
|
||||
{tag}
|
||||
<button type="button" className="opacity-70 hover:opacity-100" onClick={() => commit(tags.filter((t) => t !== tag))}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-[var(--text-muted)]"
|
||||
value={draft}
|
||||
placeholder={tags.length ? '' : placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => draft && addTag(draft)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { dismiss, subscribeToast, type ToastItem } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const tone: Record<ToastItem['type'], string> = {
|
||||
success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
error: 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400',
|
||||
info: 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
||||
warning: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
||||
}
|
||||
|
||||
export function ToastHost() {
|
||||
const [items, setItems] = useState<ToastItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToast(setItems)
|
||||
}, [])
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[500] flex w-[min(360px,calc(100vw-2rem))] flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-[var(--shadow-md)] backdrop-blur-sm',
|
||||
tone[item.type],
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 break-words">{item.message}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 opacity-70 hover:opacity-100"
|
||||
onClick={() => dismiss(item.id)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type RepoConfig, type Task } from '@/api'
|
||||
import { TASK_TYPE } from '@/constants'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Field } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Modal } from '@/components/ui/modal'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
function parseRepoConfig(task?: Partial<Task>): Partial<RepoConfig> {
|
||||
try {
|
||||
if (task?.config) {
|
||||
const cfg = JSON.parse(task.config)
|
||||
return cfg.repo || cfg || {}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function RepoDialog({
|
||||
open,
|
||||
task,
|
||||
isEdit,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
task?: Partial<Task>
|
||||
isEdit: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [remark, setRemark] = useState('')
|
||||
const [schedule, setSchedule] = useState('0 0 0 * * *')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [repo, setRepo] = useState<Partial<RepoConfig>>({
|
||||
source_type: 'git',
|
||||
source_url: '',
|
||||
target_path: '',
|
||||
branch: 'main',
|
||||
sparse_path: '',
|
||||
single_file: false,
|
||||
proxy: '',
|
||||
proxy_url: '',
|
||||
auth_token: '',
|
||||
auto_add_cron: true,
|
||||
whitelist_paths: '',
|
||||
blacklist: '',
|
||||
extensions: '',
|
||||
dependence: '',
|
||||
concurrency: 1,
|
||||
commenttotask: '',
|
||||
})
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setError('')
|
||||
setName(task?.name || '')
|
||||
setRemark(task?.remark || '')
|
||||
setSchedule(task?.schedule || '0 0 0 * * *')
|
||||
setEnabled(task?.enabled !== false)
|
||||
const parsed = parseRepoConfig(task)
|
||||
setRepo({
|
||||
source_type: 'git',
|
||||
branch: 'main',
|
||||
auto_add_cron: true,
|
||||
single_file: false,
|
||||
concurrency: 1,
|
||||
whitelist_paths: '',
|
||||
blacklist: '',
|
||||
extensions: '',
|
||||
dependence: '',
|
||||
commenttotask: '',
|
||||
...parsed,
|
||||
})
|
||||
setShowAdvanced(
|
||||
!!(
|
||||
parsed.whitelist_paths ||
|
||||
parsed.blacklist ||
|
||||
parsed.extensions ||
|
||||
parsed.dependence ||
|
||||
parsed.commenttotask ||
|
||||
(parsed.concurrency && parsed.concurrency !== 1) ||
|
||||
parsed.single_file
|
||||
),
|
||||
)
|
||||
}, [open, task])
|
||||
|
||||
function patchRepo<K extends keyof RepoConfig>(key: K, value: RepoConfig[K]) {
|
||||
setRepo((r) => ({ ...r, [key]: value }))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!name.trim()) {
|
||||
setError('请填写任务名称')
|
||||
return
|
||||
}
|
||||
if (!repo.source_url?.trim()) {
|
||||
setError('请填写仓库地址')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const config = JSON.stringify({
|
||||
...(task?.config ? (() => { try { return JSON.parse(task.config!) } catch { return {} } })() : {}),
|
||||
repo,
|
||||
})
|
||||
const payload: Partial<Task> = {
|
||||
...task,
|
||||
name: name.trim(),
|
||||
remark,
|
||||
schedule,
|
||||
enabled,
|
||||
type: TASK_TYPE.REPO,
|
||||
trigger_type: 'cron',
|
||||
command: `repo-sync:${repo.source_url}`,
|
||||
config,
|
||||
}
|
||||
if (isEdit && task?.id) {
|
||||
await api.tasks.update(task.id, payload)
|
||||
} else {
|
||||
await api.tasks.create(payload)
|
||||
}
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (e: any) {
|
||||
setError(e?.message || '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '编辑仓库任务' : '新建仓库同步'}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error ? <div className="rounded-md bg-[color-mix(in_srgb,var(--danger)_14%,transparent)] px-3 py-2 text-sm text-[var(--danger)]">{error}</div> : null}
|
||||
<Field label="任务名称">
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="备注">
|
||||
<Input value={remark} onChange={(e) => setRemark(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="仓库地址">
|
||||
<Input value={repo.source_url || ''} onChange={(e) => patchRepo('source_url', e.target.value)} placeholder="https://github.com/user/repo.git" />
|
||||
</Field>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="分支">
|
||||
<Input value={repo.branch || ''} onChange={(e) => patchRepo('branch', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="目标路径">
|
||||
<Input value={repo.target_path || ''} onChange={(e) => patchRepo('target_path', e.target.value)} placeholder="可选" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Sparse 路径">
|
||||
<Input value={repo.sparse_path || ''} onChange={(e) => patchRepo('sparse_path', e.target.value)} placeholder="可选,稀疏检出路径" />
|
||||
</Field>
|
||||
<Field label="Auth Token">
|
||||
<Input value={repo.auth_token || ''} onChange={(e) => patchRepo('auth_token', e.target.value)} placeholder="私有仓库 Token" />
|
||||
</Field>
|
||||
<Field label="代理 URL">
|
||||
<Input value={repo.proxy_url || ''} onChange={(e) => patchRepo('proxy_url', e.target.value)} placeholder="可选" />
|
||||
</Field>
|
||||
<Field label="Cron">
|
||||
<Input value={schedule} onChange={(e) => setSchedule(e.target.value)} />
|
||||
</Field>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={!!repo.auto_add_cron} onCheckedChange={(v) => patchRepo('auto_add_cron', v)} />
|
||||
<span>同步后自动添加定时任务</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={!!repo.single_file} onCheckedChange={(v) => patchRepo('single_file', v)} />
|
||||
<span>单文件模式</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
>
|
||||
{showAdvanced ? '收起高级配置' : '展开高级配置(白/黑名单、扩展名、并发等)'}
|
||||
</button>
|
||||
{showAdvanced ? (
|
||||
<div className="space-y-3 rounded-md border border-[var(--border)] p-3">
|
||||
<Field label="白名单路径">
|
||||
<Textarea
|
||||
value={repo.whitelist_paths || ''}
|
||||
onChange={(e) => patchRepo('whitelist_paths', e.target.value)}
|
||||
placeholder="多行或逗号分隔,仅同步匹配路径"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="黑名单">
|
||||
<Textarea
|
||||
value={repo.blacklist || ''}
|
||||
onChange={(e) => patchRepo('blacklist', e.target.value)}
|
||||
placeholder="排除路径/文件,多行或逗号分隔"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="扩展名过滤">
|
||||
<Input
|
||||
value={repo.extensions || ''}
|
||||
onChange={(e) => patchRepo('extensions', e.target.value)}
|
||||
placeholder="例如 js,py,sh(留空不限)"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="依赖保留 / 说明">
|
||||
<Textarea
|
||||
value={repo.dependence || ''}
|
||||
onChange={(e) => patchRepo('dependence', e.target.value)}
|
||||
placeholder="可选,依赖安装说明或保留策略"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="注释转任务">
|
||||
<Input
|
||||
value={repo.commenttotask || ''}
|
||||
onChange={(e) => patchRepo('commenttotask', e.target.value)}
|
||||
placeholder="可选,脚本注释解析规则"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="并发数">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={String(repo.concurrency ?? 1)}
|
||||
onChange={(e) => patchRepo('concurrency', Math.max(1, Number(e.target.value) || 1))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
|
||||
<span className="text-sm">启用</span>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api, type Agent, type EnvVar, type Task } from '@/api'
|
||||
import { PATHS, TRIGGER_TYPE } from '@/constants'
|
||||
import { TagInput } from '@/components/business/TagInput'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Field } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Modal } from '@/components/ui/modal'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
type LangItem = { name: string; version: string }
|
||||
|
||||
const emptyForm = (): Partial<Task> => ({
|
||||
name: '',
|
||||
remark: '',
|
||||
command: '',
|
||||
pre_command: '',
|
||||
post_command: '',
|
||||
schedule: '0 * * * * *',
|
||||
timeout: 30,
|
||||
work_dir: '',
|
||||
tags: '',
|
||||
type: 'task',
|
||||
trigger_type: TRIGGER_TYPE.CRON,
|
||||
enabled: true,
|
||||
clean_config: '',
|
||||
envs: '',
|
||||
retry_count: 0,
|
||||
retry_interval: 0,
|
||||
random_range: 0,
|
||||
pin_type: 'none',
|
||||
languages: [],
|
||||
agent_id: null,
|
||||
config: '{}',
|
||||
})
|
||||
|
||||
export function TaskDialog({
|
||||
open,
|
||||
task,
|
||||
isEdit,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
task?: Partial<Task>
|
||||
isEdit: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<Partial<Task>>(emptyForm())
|
||||
const [agents, setAgents] = useState<Agent[]>([])
|
||||
const [envs, setEnvs] = useState<EnvVar[]>([])
|
||||
const [selectedEnvIds, setSelectedEnvIds] = useState<string[]>([])
|
||||
const [agentId, setAgentId] = useState('local')
|
||||
const [triggerType, setTriggerType] = useState<string>(TRIGGER_TYPE.CRON)
|
||||
const [langs, setLangs] = useState<LangItem[]>([])
|
||||
const [langName, setLangName] = useState('python')
|
||||
const [langVersion, setLangVersion] = useState('')
|
||||
const [allEnvs, setAllEnvs] = useState(false)
|
||||
const [commentToTask, setCommentToTask] = useState(false)
|
||||
const [scriptsDir, setScriptsDir] = useState(PATHS.SCRIPTS_DIR)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notifyChannels, setNotifyChannels] = useState<{ id: string; name: string }[]>([])
|
||||
const [notifyChannelId, setNotifyChannelId] = useState('')
|
||||
const [notifyEvents, setNotifyEvents] = useState('success,failed')
|
||||
|
||||
const onlineAgents = useMemo(() => agents.filter((a) => a.enabled !== false), [agents])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setError('')
|
||||
const base = { ...emptyForm(), ...task }
|
||||
setForm(base)
|
||||
setAgentId(task?.agent_id ? String(task.agent_id) : 'local')
|
||||
setTriggerType(task?.trigger_type || TRIGGER_TYPE.CRON)
|
||||
setSelectedEnvIds(
|
||||
task?.envs
|
||||
? task.envs
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
)
|
||||
setLangs(Array.isArray(task?.languages) ? task!.languages!.map((l) => ({ name: l.name, version: l.version })) : [])
|
||||
try {
|
||||
const cfg = JSON.parse(task?.config || '{}')
|
||||
setAllEnvs(!!cfg['$task_all_envs'])
|
||||
setCommentToTask(!!cfg['$task_comment_to_task'])
|
||||
} catch {
|
||||
setAllEnvs(false)
|
||||
setCommentToTask(false)
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const [envList, agentList, paths, channels, bindings] = await Promise.all([
|
||||
api.env.all().catch(() => []),
|
||||
api.agents.list().catch(() => []),
|
||||
api.settings.getPaths().catch(() => ({ scripts_dir: PATHS.SCRIPTS_DIR })),
|
||||
api.notify.getChannels().catch(() => []),
|
||||
isEdit && task?.id ? api.notify.getBindings().catch(() => []) : Promise.resolve([]),
|
||||
])
|
||||
setEnvs(Array.isArray(envList) ? envList : [])
|
||||
setAgents(Array.isArray(agentList) ? agentList : [])
|
||||
setScriptsDir(paths?.scripts_dir || PATHS.SCRIPTS_DIR)
|
||||
setNotifyChannels(
|
||||
(Array.isArray(channels) ? channels : []).map((c: any) => ({ id: c.id, name: c.name || c.id })),
|
||||
)
|
||||
if (isEdit && task?.id && Array.isArray(bindings)) {
|
||||
const mine = bindings.filter((b: any) => b.data_id === task.id && (b.type === 'task' || !b.type))
|
||||
if (mine[0]) {
|
||||
const b = mine[0] as any
|
||||
setNotifyChannelId(b.way_id || b.channel_id || '')
|
||||
setNotifyEvents(b.event || b.events || 'success,failed')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})()
|
||||
}, [open, task, isEdit])
|
||||
|
||||
function patch<K extends keyof Task>(key: K, value: Task[K]) {
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
}
|
||||
|
||||
function encodeLocalWorkDir(workDir?: string | null) {
|
||||
const value = workDir?.trim() || ''
|
||||
if (!value) return PATHS.SCRIPTS_DIR_PLACEHOLDER
|
||||
if (value === PATHS.SCRIPTS_DIR_PLACEHOLDER || value.startsWith(`${PATHS.SCRIPTS_DIR_PLACEHOLDER}/`)) return value
|
||||
const base = scriptsDir || PATHS.SCRIPTS_DIR
|
||||
if (value === base) return PATHS.SCRIPTS_DIR_PLACEHOLDER
|
||||
if (value.startsWith(`${base}/`)) return `${PATHS.SCRIPTS_DIR_PLACEHOLDER}/${value.slice(base.length + 1)}`
|
||||
return `${PATHS.SCRIPTS_DIR_PLACEHOLDER}/${value.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name?.trim()) {
|
||||
setError('请填写任务名称')
|
||||
return
|
||||
}
|
||||
if (!form.command?.trim() && form.type !== 'repo') {
|
||||
setError('请填写执行命令')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
let config: Record<string, any> = {}
|
||||
try {
|
||||
config = form.config ? JSON.parse(form.config) : {}
|
||||
} catch {
|
||||
config = {}
|
||||
}
|
||||
config['$task_all_envs'] = allEnvs
|
||||
config['$task_comment_to_task'] = commentToTask
|
||||
|
||||
const payload: Partial<Task> = {
|
||||
...form,
|
||||
name: form.name!.trim(),
|
||||
type: form.type || 'task',
|
||||
trigger_type: triggerType,
|
||||
agent_id: agentId === 'local' ? null : agentId,
|
||||
envs: selectedEnvIds.join(','),
|
||||
languages: langs,
|
||||
config: JSON.stringify(config),
|
||||
work_dir: agentId === 'local' ? encodeLocalWorkDir(form.work_dir) : form.work_dir || '',
|
||||
}
|
||||
|
||||
let saved: Task
|
||||
if (isEdit && form.id) {
|
||||
saved = await api.tasks.update(form.id, payload)
|
||||
} else {
|
||||
saved = await api.tasks.create(payload)
|
||||
}
|
||||
|
||||
if (notifyChannelId && saved?.id) {
|
||||
try {
|
||||
const events = notifyEvents
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
await api.notify.saveBindingsBatch({
|
||||
type: 'task',
|
||||
data_id: saved.id,
|
||||
bindings: events.map((event) => ({
|
||||
type: 'task',
|
||||
event,
|
||||
way_id: notifyChannelId,
|
||||
data_id: saved.id,
|
||||
})),
|
||||
})
|
||||
} catch {
|
||||
// 通知绑定失败不阻断任务保存
|
||||
}
|
||||
}
|
||||
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (e: any) {
|
||||
setError(e?.message || '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '编辑任务' : '新建任务'}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error ? <div className="rounded-md bg-[color-mix(in_srgb,var(--danger)_14%,transparent)] px-3 py-2 text-sm text-[var(--danger)]">{error}</div> : null}
|
||||
|
||||
<Section title="基本信息">
|
||||
<Field label="任务名称">
|
||||
<Input value={form.name || ''} onChange={(e) => patch('name', e.target.value)} placeholder="输入任务名称" />
|
||||
</Field>
|
||||
<Field label="备注">
|
||||
<Input value={form.remark || ''} onChange={(e) => patch('remark', e.target.value)} placeholder="可选" />
|
||||
</Field>
|
||||
<Field label="标签">
|
||||
<TagInput value={form.tags || ''} onChange={(v) => patch('tags', v)} />
|
||||
</Field>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="执行位置">
|
||||
<Select value={agentId} onChange={(e) => setAgentId(e.target.value)}>
|
||||
<option value="local">本地执行</option>
|
||||
{onlineAgents.map((a) => (
|
||||
<option key={a.id} value={String(a.id)}>
|
||||
{a.name || a.id} {a.status === 'online' ? '(在线)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="触发方式">
|
||||
<Select value={triggerType} onChange={(e) => setTriggerType(e.target.value)}>
|
||||
<option value={TRIGGER_TYPE.CRON}>定时周期</option>
|
||||
<option value={TRIGGER_TYPE.TASKPOOL_STARTUP}>系统启动</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="执行配置">
|
||||
{agentId === 'local' ? (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-md border border-amber-500/20 bg-amber-500/5 p-3 text-xs text-amber-600 dark:text-amber-400">
|
||||
请先在「语言依赖」中安装运行时。脚本执行将自动注入对应环境。
|
||||
</div>
|
||||
<Field label="语言环境">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={langName} onChange={(e) => setLangName(e.target.value)} className="max-w-[140px]">
|
||||
{['python', 'node', 'go', 'php', 'ruby', 'java', 'bun', 'deno'].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input className="max-w-[140px]" placeholder="版本,可空" value={langVersion} onChange={(e) => setLangVersion(e.target.value)} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (!langName) return
|
||||
setLangs((prev) => [...prev, { name: langName, version: langVersion }])
|
||||
setLangVersion('')
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{langs.map((l, i) => (
|
||||
<button
|
||||
key={`${l.name}-${l.version}-${i}`}
|
||||
type="button"
|
||||
className="rounded bg-[var(--bg-selected)] px-2 py-0.5 text-xs"
|
||||
onClick={() => setLangs((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
title="点击移除"
|
||||
>
|
||||
{l.name}
|
||||
{l.version ? `@${l.version}` : ''} ×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{triggerType === TRIGGER_TYPE.CRON ? (
|
||||
<Field label="Cron 表达式">
|
||||
<Input
|
||||
value={form.schedule || ''}
|
||||
onChange={(e) => patch('schedule', e.target.value)}
|
||||
placeholder="秒 分 时 日 月 周,如 0 */5 * * * *"
|
||||
/>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{[
|
||||
['每分钟', '0 * * * * *'],
|
||||
['每小时', '0 0 * * * *'],
|
||||
['每天0点', '0 0 0 * * *'],
|
||||
['每5分钟', '0 */5 * * * *'],
|
||||
].map(([label, expr]) => (
|
||||
<Button key={expr} type="button" size="sm" variant="ghost" onClick={() => patch('schedule', expr)}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field label="执行命令">
|
||||
<Textarea
|
||||
className="min-h-28 font-mono text-xs"
|
||||
value={form.command || ''}
|
||||
onChange={(e) => patch('command', e.target.value)}
|
||||
placeholder="例如: python main.py"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="前置命令">
|
||||
<Textarea value={form.pre_command || ''} onChange={(e) => patch('pre_command', e.target.value)} placeholder="可选" />
|
||||
</Field>
|
||||
<Field label="后置命令">
|
||||
<Textarea value={form.post_command || ''} onChange={(e) => patch('post_command', e.target.value)} placeholder="可选" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="工作目录">
|
||||
<Input value={form.work_dir || ''} onChange={(e) => patch('work_dir', e.target.value)} placeholder={agentId === 'local' ? '相对 scripts 目录,可空' : '远程工作目录'} />
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="环境变量">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={allEnvs} onCheckedChange={setAllEnvs} />
|
||||
<span>注入全部环境变量</span>
|
||||
</div>
|
||||
{!allEnvs ? (
|
||||
<>
|
||||
<Select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const id = e.target.value
|
||||
if (id && !selectedEnvIds.includes(id)) setSelectedEnvIds((ids) => [...ids, id])
|
||||
}}
|
||||
>
|
||||
<option value="">添加变量...</option>
|
||||
{envs
|
||||
.filter((e) => !selectedEnvIds.includes(e.id))
|
||||
.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.name}
|
||||
{e.remark ? ` · ${e.remark}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{selectedEnvIds.map((id) => {
|
||||
const env = envs.find((e) => e.id === id)
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className="rounded bg-[var(--bg-selected)] px-2 py-0.5 text-xs"
|
||||
onClick={() => setSelectedEnvIds((ids) => ids.filter((x) => x !== id))}
|
||||
>
|
||||
{env?.name || id} ×
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section title="高级配置">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Field label="超时(秒)">
|
||||
<Input type="number" value={form.timeout ?? 30} onChange={(e) => patch('timeout', Number(e.target.value) || 0)} />
|
||||
</Field>
|
||||
<Field label="重试次数">
|
||||
<Input type="number" value={form.retry_count ?? 0} onChange={(e) => patch('retry_count', Number(e.target.value) || 0)} />
|
||||
</Field>
|
||||
<Field label="重试间隔(秒)">
|
||||
<Input type="number" value={form.retry_interval ?? 0} onChange={(e) => patch('retry_interval', Number(e.target.value) || 0)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="随机延迟(秒)">
|
||||
<Input type="number" value={form.random_range ?? 0} onChange={(e) => patch('random_range', Number(e.target.value) || 0)} />
|
||||
</Field>
|
||||
<div className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
|
||||
<span className="text-sm">注释解析为任务参数</span>
|
||||
<Switch checked={commentToTask} onCheckedChange={setCommentToTask} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
|
||||
<span className="text-sm">启用任务</span>
|
||||
<Switch checked={!!form.enabled} onCheckedChange={(v) => patch('enabled', v)} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="消息通知">
|
||||
<Field label="推送渠道">
|
||||
<Select value={notifyChannelId} onChange={(e) => setNotifyChannelId(e.target.value)}>
|
||||
<option value="">不绑定</option>
|
||||
{notifyChannels.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
{notifyChannelId ? (
|
||||
<Field label="事件(逗号分隔)">
|
||||
<Input value={notifyEvents} onChange={(e) => setNotifyEvents(e.target.value)} placeholder="success,failed,timeout" />
|
||||
</Field>
|
||||
) : null}
|
||||
</Section>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="space-y-3 border-b border-[var(--border)] pb-4 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3.5 w-1 rounded-full bg-[var(--bg-accent)]" />
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
</div>
|
||||
<div className="space-y-3 pl-3">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type BadgeTone = 'default' | 'success' | 'warn' | 'danger' | 'info'
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
tone = 'default',
|
||||
}: {
|
||||
children: ReactNode
|
||||
tone?: BadgeTone
|
||||
}) {
|
||||
const tones: Record<BadgeTone, string> = {
|
||||
default: 'border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
|
||||
success:
|
||||
'border-[color-mix(in_srgb,var(--success)_25%,transparent)] bg-[color-mix(in_srgb,var(--success)_12%,transparent)] text-[var(--success)]',
|
||||
warn:
|
||||
'border-[color-mix(in_srgb,var(--warning)_25%,transparent)] bg-[color-mix(in_srgb,var(--warning)_12%,transparent)] text-[var(--warning)]',
|
||||
danger:
|
||||
'border-[color-mix(in_srgb,var(--danger)_25%,transparent)] bg-[color-mix(in_srgb,var(--danger)_12%,transparent)] text-[var(--danger)]',
|
||||
info:
|
||||
'border-[color-mix(in_srgb,var(--info)_25%,transparent)] bg-[color-mix(in_srgb,var(--info)_12%,transparent)] text-[var(--info)]',
|
||||
}
|
||||
return (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium', tones[tone])}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'danger' | 'outline'
|
||||
export type ButtonSize = 'sm' | 'md' | 'icon'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn('ui-btn', `ui-btn-${variant}`, `ui-btn-${size}`, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
hoverable,
|
||||
onClick,
|
||||
...rest
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
hoverable?: boolean
|
||||
onClick?: HTMLAttributes<HTMLDivElement>['onClick']
|
||||
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'className' | 'onClick'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-[var(--border)] bg-[var(--bg-secondary)] shadow-[var(--shadow-sm)] transition-[background,border-color] duration-150',
|
||||
hoverable && 'hover:border-[var(--border-hover)] hover:bg-[var(--bg-tertiary)]',
|
||||
onClick && 'cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
className,
|
||||
id,
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
id?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex h-4 w-4 items-center justify-center rounded border transition',
|
||||
checked
|
||||
? 'border-transparent bg-[var(--bg-accent)] text-[var(--text-on-accent)]'
|
||||
: 'border-[var(--border-hover)] bg-[var(--bg-secondary)]',
|
||||
disabled && 'opacity-55 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
>
|
||||
{checked ? <Check size={12} strokeWidth={2.5} /> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function Empty({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="px-4 py-12 text-center">
|
||||
<div className="mx-auto mb-3 h-10 w-10 rounded-lg border border-[var(--border)] bg-[var(--bg-tertiary)] opacity-70" />
|
||||
<p className="text-sm text-[var(--text-muted)]">{text}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
children,
|
||||
hint,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--text-secondary)]">{label}</span>
|
||||
{children}
|
||||
{hint ? <span className="block text-[11px] text-[var(--text-muted)]">{hint}</span> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export { cn } from '@/lib/utils'
|
||||
export { Button } from './button'
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize } from './button'
|
||||
export { Input } from './input'
|
||||
export { Textarea } from './textarea'
|
||||
export { Select } from './select'
|
||||
export type { SelectProps, SelectOption } from './select'
|
||||
export { Card } from './card'
|
||||
export { Badge } from './badge'
|
||||
export type { BadgeTone } from './badge'
|
||||
export { Modal } from './modal'
|
||||
export { Table, Th, Td } from './table'
|
||||
export { Field } from './field'
|
||||
export { PageHeader } from './page-header'
|
||||
export { Empty } from './empty'
|
||||
export { Loading } from './loading'
|
||||
export { SectionLabel } from './section-label'
|
||||
export { Switch } from './switch'
|
||||
export { Tabs } from './tabs'
|
||||
export { Checkbox } from './checkbox'
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { InputHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={cn('ui-control h-9 px-3', className)} />
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function Loading({ text = '加载中...' }: { text?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-12 text-sm text-[var(--text-muted)]">
|
||||
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-[var(--border-hover)] border-t-[var(--text-secondary)]" />
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
footer,
|
||||
className,
|
||||
size = 'md',
|
||||
}: {
|
||||
open: boolean
|
||||
title: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
size?: 'md' | 'lg' | 'xl' | 'full'
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
const sizes = {
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
full: 'max-w-[95vw]',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/55 p-4 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`max-h-[90vh] w-full overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-secondary)] shadow-[var(--shadow-lg)] ${sizes[size]} ${className || ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--border)] px-4 py-3">
|
||||
<h3 className="text-[15px] font-semibold tracking-tight">{title}</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 p-4">{children}</div>
|
||||
{footer ? (
|
||||
<div className="flex justify-end gap-2 border-t border-[var(--border)] px-4 py-3">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
subtitle?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-baseline gap-3">
|
||||
<h1 className="m-0 text-2xl font-bold leading-tight tracking-tight text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle ? <span className="text-[13px] text-[var(--text-muted)]">{subtitle}</span> : null}
|
||||
</div>
|
||||
<div className="h-[3px] w-8 rounded-sm bg-[var(--bg-accent)]" />
|
||||
{description ? <p className="mt-2.5 text-sm text-[var(--text-muted)]">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-1.5">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function SectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<h2 className="mb-3 text-[12px] font-medium uppercase tracking-wide text-[var(--text-muted)]">
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type ChangeHandler = (event: { target: { value: string } }) => void
|
||||
|
||||
export interface SelectProps {
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onChange?: ChangeHandler
|
||||
onValueChange?: (value: string) => void
|
||||
options?: SelectOption[]
|
||||
children?: ReactNode
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
name?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
function parseOptionsFromChildren(children: ReactNode): SelectOption[] {
|
||||
const list: SelectOption[] = []
|
||||
Children.forEach(children, (child) => {
|
||||
if (!isValidElement(child)) return
|
||||
if (child.type !== 'option') return
|
||||
const props = child.props as {
|
||||
value?: string | number
|
||||
disabled?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
list.push({
|
||||
value: props.value == null ? '' : String(props.value),
|
||||
label: props.children,
|
||||
disabled: Boolean(props.disabled),
|
||||
})
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
defaultValue = '',
|
||||
onChange,
|
||||
onValueChange,
|
||||
options,
|
||||
children,
|
||||
placeholder = '请选择',
|
||||
className,
|
||||
disabled,
|
||||
name,
|
||||
id,
|
||||
}: SelectProps) {
|
||||
const reactId = useId()
|
||||
const listboxId = id || `select-${reactId}`
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [uncontrolled, setUncontrolled] = useState(defaultValue)
|
||||
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
if (options?.length) return options
|
||||
return parseOptionsFromChildren(children)
|
||||
}, [options, children])
|
||||
|
||||
const current = value !== undefined ? value : uncontrolled
|
||||
const selected = parsed.find((o) => o.value === current)
|
||||
const display = selected?.label ?? (current ? current : null)
|
||||
|
||||
function commit(next: string) {
|
||||
if (value === undefined) setUncontrolled(next)
|
||||
onValueChange?.(next)
|
||||
onChange?.({ target: { value: next } })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function updateMenuPosition() {
|
||||
const el = triggerRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const viewportH = window.innerHeight
|
||||
const spaceBelow = viewportH - rect.bottom
|
||||
const openUp = spaceBelow < 220 && rect.top > spaceBelow
|
||||
setMenuStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 140),
|
||||
zIndex: 80,
|
||||
...(openUp
|
||||
? { bottom: viewportH - rect.top + 6, top: 'auto' }
|
||||
: { top: rect.bottom + 6, bottom: 'auto' }),
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
updateMenuPosition()
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
const t = e.target as Node
|
||||
if (rootRef.current?.contains(t) || menuRef.current?.contains(t)) return
|
||||
setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
const onReposition = () => updateMenuPosition()
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
document.addEventListener('keydown', onKey)
|
||||
window.addEventListener('resize', onReposition)
|
||||
window.addEventListener('scroll', onReposition, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDoc)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('resize', onReposition)
|
||||
window.removeEventListener('scroll', onReposition, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={cn('relative w-full', className)}>
|
||||
{name ? <input type="hidden" name={name} value={current} /> : null}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={listboxId}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className={cn('ui-control ui-select-trigger h-9 px-3 text-left', open && 'ui-control-open')}
|
||||
onClick={() => {
|
||||
if (disabled) return
|
||||
setOpen((v) => !v)
|
||||
}}
|
||||
>
|
||||
<span className={cn('min-w-0 flex-1 truncate', !display && 'text-[var(--text-muted)]')}>
|
||||
{display ?? placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
strokeWidth={1.5}
|
||||
className={cn('shrink-0 text-[var(--text-muted)] transition-transform', open && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
aria-labelledby={listboxId}
|
||||
className="ui-select-menu"
|
||||
style={menuStyle}
|
||||
>
|
||||
{parsed.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-[var(--text-muted)]">暂无选项</div>
|
||||
) : (
|
||||
parsed.map((opt) => {
|
||||
const active = opt.value === current
|
||||
return (
|
||||
<button
|
||||
key={`${opt.value}__${String(opt.label)}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
disabled={opt.disabled}
|
||||
className={cn('ui-select-item', active && 'is-active')}
|
||||
onClick={() => {
|
||||
if (opt.disabled) return
|
||||
commit(opt.value)
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
|
||||
{active ? <Check size={14} strokeWidth={1.75} className="shrink-0 opacity-90" /> : null}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 兼容旧的原生 option 写法,无需改调用方 */
|
||||
export type NativeSelectProps = ButtonHTMLAttributes<HTMLButtonElement>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition',
|
||||
checked ? 'border-transparent bg-[var(--bg-accent)]' : 'border-[var(--border)] bg-[var(--bg-tertiary)]',
|
||||
disabled && 'opacity-55 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform',
|
||||
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Table({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">{children}</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Th({ children, className }: { children?: ReactNode; className?: string }) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'border-b border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--text-muted)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
export function Td({ children, className }: { children?: ReactNode; className?: string }) {
|
||||
return (
|
||||
<td className={cn('border-b border-[var(--border)] px-3 py-2.5 align-middle text-[var(--text-primary)]', className)}>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Tabs({
|
||||
value,
|
||||
onValueChange,
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
value: string
|
||||
onValueChange: (v: string) => void
|
||||
items: { value: string; label: string }[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-w-full items-center gap-1.5 overflow-x-auto rounded-lg border border-[var(--border)] bg-[var(--bg-tertiary)] p-1.5',
|
||||
'[scrollbar-width:thin]',
|
||||
className,
|
||||
)}
|
||||
role="tablist"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const active = value === item.value
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={cn(
|
||||
'relative shrink-0 rounded-md px-3.5 py-1.5 text-[13px] font-medium whitespace-nowrap transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]',
|
||||
active
|
||||
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-[var(--shadow-sm)] ring-1 ring-[var(--border)]'
|
||||
: 'text-[var(--text-muted)] hover:bg-[var(--bg-hover)]/60 hover:text-[var(--text-secondary)]',
|
||||
)}
|
||||
onClick={() => onValueChange(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return <textarea {...props} className={cn('ui-control min-h-24 resize-y px-3 py-2', className)} />
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export const TASK_TYPE = {
|
||||
NORMAL: 'task',
|
||||
REPO: 'repo',
|
||||
} as const
|
||||
|
||||
export const TRIGGER_TYPE = {
|
||||
CRON: 'cron',
|
||||
TASKPOOL_STARTUP: 'taskpool_startup',
|
||||
/** 兼容存量任务 */
|
||||
LEGACY_STARTUP: 'baihu_startup',
|
||||
} as const
|
||||
|
||||
export const TASK_STATUS = {
|
||||
RUNNING: 'running',
|
||||
SUCCESS: 'success',
|
||||
FAILED: 'failed',
|
||||
TIMEOUT: 'timeout',
|
||||
CANCELLED: 'cancelled',
|
||||
} as const
|
||||
|
||||
export const TASK_STATUS_TEXT: Record<string, string> = {
|
||||
running: '运行中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
timeout: '超时',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
|
||||
export const AGENT_STATUS = {
|
||||
ONLINE: 'online',
|
||||
OFFLINE: 'offline',
|
||||
} as const
|
||||
|
||||
export const PATHS = {
|
||||
SCRIPTS_DIR: 'data/scripts',
|
||||
SCRIPTS_DIR_PLACEHOLDER: '${scripts_dir}',
|
||||
}
|
||||
|
||||
export const ENV_TYPE = {
|
||||
NORMAL: 'normal',
|
||||
SECRET: 'secret',
|
||||
} as const
|
||||
|
||||
/** 任务实时事件(WebSocket) */
|
||||
export const TASK_EVENTS = {
|
||||
SUCCESS: 'task_success',
|
||||
FAILED: 'task_failed',
|
||||
TIMEOUT: 'task_timeout',
|
||||
RUNNING: 'task_running',
|
||||
QUEUED: 'task_queued',
|
||||
CANCELLED: 'task_cancelled',
|
||||
} as const
|
||||
|
||||
/** 系统日志事件 */
|
||||
export const LOG_EVENTS = {
|
||||
ADDED: 'app_log_added',
|
||||
} as const
|
||||
|
||||
/** 系统事件 */
|
||||
export const SYSTEM_EVENTS = {
|
||||
INTERCONNECT_CHILD_STATUS: 'interconnect_child_status',
|
||||
INTERCONNECT_ROLE_CHANGED: 'interconnect-role-changed',
|
||||
} as const
|
||||
|
||||
export const API_BASE =
|
||||
((window as any).__BASE_URL__ || '') + ((window as any).__API_VERSION__ || '/api/v1')
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||
|
||||
export type ThemeId = 'dark' | 'light'
|
||||
|
||||
/** MoonShadow Minimal Design tokens (runtime helpers) */
|
||||
export const RADIUS = {
|
||||
xs: '4px',
|
||||
sm: '6px',
|
||||
md: '8px',
|
||||
lg: '12px',
|
||||
xl: '16px',
|
||||
} as const
|
||||
|
||||
export const FONT = {
|
||||
ui: "var(--font-ui)",
|
||||
mono: "var(--font-mono)",
|
||||
} as const
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: ThemeId
|
||||
setTheme: (theme: ThemeId) => void
|
||||
toggleTheme: () => void
|
||||
radius: typeof RADIUS
|
||||
font: typeof FONT
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | null>(null)
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<ThemeId>(() => {
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
const saved = localStorage.getItem('theme') as ThemeId | null
|
||||
return saved === 'light' || saved === 'dark' ? saved : 'dark'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('theme', theme)
|
||||
const color = theme === 'light' ? '#ffffff' : '#0d0d0d'
|
||||
let meta = document.querySelector('meta[name="theme-color"]:not([media])') as HTMLMetaElement | null
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta')
|
||||
meta.name = 'theme-color'
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
meta.content = color
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'))
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme, radius: RADIUS, font: FONT }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react'
|
||||
import { eventBus, type WSMessage } from '@/lib/event-bus'
|
||||
|
||||
/**
|
||||
* 订阅系统事件总线(SharedWorker/WebSocket)
|
||||
*/
|
||||
export function useEventBus(
|
||||
eventType: string | string[] | null,
|
||||
handler: (payload: any, type: string) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
eventBus.init()
|
||||
const onMessage = (msg: WSMessage) => {
|
||||
if (!eventType) {
|
||||
handler(msg.payload, msg.type)
|
||||
return
|
||||
}
|
||||
const types = Array.isArray(eventType) ? eventType : [eventType]
|
||||
if (types.includes(msg.type)) {
|
||||
handler(msg.payload, msg.type)
|
||||
}
|
||||
}
|
||||
const unsubscribe = eventBus.subscribe(onMessage)
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
}, [eventType, handler])
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type TaskLog } from '@/api'
|
||||
import { TASK_STATUS } from '@/constants'
|
||||
|
||||
/**
|
||||
* 拉取日志详情;运行中任务走 SSE `/logs/sse?log_id=`
|
||||
*/
|
||||
export function useLogStream(log: TaskLog | null | undefined) {
|
||||
const [content, setContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [liveLog, setLiveLog] = useState<TaskLog | null>(null)
|
||||
const sourceRef = useRef<EventSource | null>(null)
|
||||
const bufferRef = useRef<string[]>([])
|
||||
const flushRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const durationRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
function cleanup() {
|
||||
sourceRef.current?.close()
|
||||
sourceRef.current = null
|
||||
if (flushRef.current) {
|
||||
clearInterval(flushRef.current)
|
||||
flushRef.current = null
|
||||
}
|
||||
if (durationRef.current) {
|
||||
clearInterval(durationRef.current)
|
||||
durationRef.current = null
|
||||
}
|
||||
bufferRef.current = []
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
cleanup()
|
||||
setContent('')
|
||||
setLiveLog(log || null)
|
||||
if (!log?.id) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function start() {
|
||||
setLoading(true)
|
||||
if (log!.status !== TASK_STATUS.RUNNING) {
|
||||
try {
|
||||
const detail = await api.logs.get(log!.id)
|
||||
if (!cancelled) {
|
||||
setContent(detail.output || detail.error || '')
|
||||
setLiveLog({ ...log!, ...detail, task_name: log!.task_name })
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setContent('加载日志失败')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// running: SSE
|
||||
setContent('')
|
||||
setLoading(false)
|
||||
const protocol = window.location.protocol
|
||||
const host = window.location.host
|
||||
const baseUrl = (window as any).__BASE_URL__ || ''
|
||||
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
|
||||
const sseUrl = `${protocol}//${host}${baseUrl}${apiVersion}/logs/sse?log_id=${log!.id}`
|
||||
const es = new EventSource(sseUrl, { withCredentials: true } as EventSourceInit)
|
||||
sourceRef.current = es
|
||||
|
||||
es.onmessage = (event) => {
|
||||
let text = ''
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
text = data.text || ''
|
||||
} catch {
|
||||
text = event.data
|
||||
}
|
||||
bufferRef.current.push(text)
|
||||
if (!flushRef.current) {
|
||||
flushRef.current = setInterval(() => {
|
||||
if (bufferRef.current.length) {
|
||||
const chunk = bufferRef.current.join('')
|
||||
bufferRef.current = []
|
||||
setContent((prev) => prev + chunk)
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
es.onerror = () => {
|
||||
cleanup()
|
||||
// 结束后再拉一次完整日志
|
||||
api.logs
|
||||
.get(log!.id)
|
||||
.then((detail) => {
|
||||
if (!cancelled) {
|
||||
setContent(detail.output || detail.error || '')
|
||||
setLiveLog((prev) => (prev ? { ...prev, ...detail, status: detail.status } : prev))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
durationRef.current = setInterval(() => {
|
||||
setLiveLog((prev) => {
|
||||
if (!prev || prev.status !== TASK_STATUS.RUNNING) return prev
|
||||
if (prev.start_time && prev.start_time !== '-') {
|
||||
const startMs = new Date(prev.start_time).getTime()
|
||||
if (!Number.isNaN(startMs)) {
|
||||
return { ...prev, duration: Date.now() - startMs }
|
||||
}
|
||||
}
|
||||
return { ...prev, duration: (prev.duration || 0) + 1000 }
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
start()
|
||||
return () => {
|
||||
cancelled = true
|
||||
cleanup()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [log?.id, log?.status])
|
||||
|
||||
return { content, loading, liveLog, setLiveLog }
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-bg-primary: var(--bg-primary);
|
||||
--color-bg-secondary: var(--bg-secondary);
|
||||
--color-bg-tertiary: var(--bg-tertiary);
|
||||
--color-bg-hover: var(--bg-hover);
|
||||
--color-bg-selected: var(--bg-selected);
|
||||
--color-bg-accent: var(--bg-accent);
|
||||
--color-bg-accent-hover: var(--bg-accent-hover);
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-text-accent: var(--text-accent);
|
||||
--color-border: var(--border);
|
||||
--color-border-hover: var(--border-hover);
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* MoonShadow Minimal — Dark */
|
||||
:root,
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #0d0d0d;
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-tertiary: #262626;
|
||||
--bg-hover: #333333;
|
||||
--bg-selected: #404040;
|
||||
--bg-accent: #c0c0c0;
|
||||
--bg-accent-hover: #d4d4d4;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a3a3a3;
|
||||
--text-muted: #737373;
|
||||
--text-accent: #e5e5e5;
|
||||
--text-on-accent: #0d0d0d;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-hover: rgba(255, 255, 255, 0.15);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
--focus-ring: rgba(192, 192, 192, 0.35);
|
||||
--success: #34d399;
|
||||
--warning: #fbbf24;
|
||||
--danger: #f87171;
|
||||
--info: #60a5fa;
|
||||
}
|
||||
|
||||
/* MoonShadow Minimal — Light */
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--bg-tertiary: #e9ecef;
|
||||
--bg-hover: #dee2e6;
|
||||
--bg-selected: #ced4da;
|
||||
--bg-accent: #212529;
|
||||
--bg-accent-hover: #343a40;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6c757d;
|
||||
--text-muted: #adb5bd;
|
||||
--text-accent: #212529;
|
||||
--text-on-accent: #ffffff;
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-hover: rgba(0, 0, 0, 0.15);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.12);
|
||||
--focus-ring: rgba(33, 37, 41, 0.2);
|
||||
--success: #059669;
|
||||
--warning: #d97706;
|
||||
--danger: #dc2626;
|
||||
--info: #2563eb;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 选中态使用 accent 对比色,避免 logo/按钮上的 on-accent 白字被改成主文字色后“消失” */
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--bg-accent) 55%, transparent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
/* 已是 on-accent 表面时,选中保持原文字色并提高背景对比 */
|
||||
.ms-on-accent::selection,
|
||||
.ms-on-accent *::selection {
|
||||
background: color-mix(in srgb, var(--text-on-accent) 28%, transparent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
/* subtle page enter */
|
||||
@keyframes ms-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ms-page {
|
||||
animation: ms-fade-in 0.28s ease both;
|
||||
}
|
||||
|
||||
/* progress bar */
|
||||
.ms-progress {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ms-progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
/* Form controls — reliable focus ring (avoid broken arbitrary Tailwind shadow) */
|
||||
.ui-control {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
outline: none;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.ui-control::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ui-control:hover:not(:disabled):not(:focus) {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.ui-control:focus,
|
||||
.ui-control:focus-visible {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--bg-accent);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.ui-control:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-control-open {
|
||||
border-color: var(--bg-accent) !important;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Primary button — solid accent, always readable */
|
||||
.ui-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
opacity 0.15s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
.ui-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.ui-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-btn-default {
|
||||
background: var(--bg-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.ui-btn-default:hover:not(:disabled) {
|
||||
background: var(--bg-accent-hover);
|
||||
}
|
||||
|
||||
.ui-btn-secondary {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ui-btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--border-hover);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ui-btn-outline {
|
||||
border-color: var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ui-btn-outline:hover:not(:disabled) {
|
||||
border-color: var(--border-hover);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ui-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ui-btn-ghost:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ui-btn-danger {
|
||||
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-btn-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 22%, transparent);
|
||||
}
|
||||
|
||||
.ui-btn-sm {
|
||||
height: 2rem;
|
||||
padding: 0 0.625rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ui-btn-md {
|
||||
height: 2.25rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.ui-btn-icon {
|
||||
height: 2.25rem;
|
||||
width: 2.25rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Custom select menu */
|
||||
.ui-select-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-select-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ui-select-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
min-height: 2rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ui-select-item + .ui-select-item {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ui-select-item:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.ui-select-item.is-active {
|
||||
background: var(--bg-selected);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ui-select-item:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* xterm 终端:跟随主题,暗色保证高对比 */
|
||||
.terminal-host {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .terminal-host,
|
||||
:root .terminal-host {
|
||||
/* 兜底:即使 xterm 单元格未着色,也用浅色字 */
|
||||
color: #f0f0f0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
[data-theme='light'] .terminal-host {
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.terminal-host .xterm {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.terminal-host .xterm-viewport {
|
||||
overflow-y: auto !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.terminal-host .xterm-screen {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* 不要强制 color:inherit,避免覆盖 xterm 单元格前景色 */
|
||||
.terminal-host .xterm-helper-textarea {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 避免全局 selection 样式污染终端选区 */
|
||||
.terminal-host ::selection,
|
||||
.terminal-host .xterm ::selection {
|
||||
background: color-mix(in srgb, var(--bg-accent) 40%, transparent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme='light'] .terminal-host ::selection,
|
||||
[data-theme='light'] .terminal-host .xterm ::selection {
|
||||
background: color-mix(in srgb, var(--bg-accent) 22%, transparent);
|
||||
color: #212529;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { checkAuth } from '@/api'
|
||||
|
||||
let authChecked = false
|
||||
let isAuth = false
|
||||
|
||||
export async function getAuthStatus(force = false): Promise<boolean> {
|
||||
if (!force && authChecked) return isAuth
|
||||
isAuth = await checkAuth()
|
||||
authChecked = true
|
||||
return isAuth
|
||||
}
|
||||
|
||||
export function resetAuthCache() {
|
||||
authChecked = false
|
||||
isAuth = false
|
||||
}
|
||||
|
||||
export function setAuthCache(status: boolean) {
|
||||
authChecked = true
|
||||
isAuth = status
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 系统事件总线驱动(SharedWorker + WebSocket 降级)
|
||||
* 系统事件总线
|
||||
*/
|
||||
|
||||
export interface WSMessage {
|
||||
type: string
|
||||
timestamp?: number
|
||||
payload?: any
|
||||
}
|
||||
|
||||
export type MessageHandler = (msg: WSMessage) => void
|
||||
|
||||
class EventBusDriver {
|
||||
private wsUrl = ''
|
||||
private workerPath = ''
|
||||
private handlers = new Set<MessageHandler>()
|
||||
private worker: SharedWorker | null = null
|
||||
private socket: WebSocket | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private initialized = false
|
||||
|
||||
constructor() {
|
||||
const baseUrl = (window as any).__BASE_URL__ || ''
|
||||
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
|
||||
let host = window.location.host
|
||||
let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
|
||||
if (baseUrl.startsWith('http')) {
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
host = url.host
|
||||
protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const path = baseUrl.startsWith('http') ? '' : baseUrl
|
||||
this.wsUrl = `${protocol}//${host}${path}${apiVersion}/ws/events`
|
||||
this.workerPath = `${path}/workers/event-worker.js`.replace(/\/{2,}/g, '/').replace(':/', '://')
|
||||
if (!this.workerPath.startsWith('/') && !this.workerPath.startsWith('http')) {
|
||||
this.workerPath = `/${this.workerPath}`
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.initialized) return
|
||||
this.initialized = true
|
||||
if (typeof window !== 'undefined' && 'SharedWorker' in window) {
|
||||
this.initSharedWorker()
|
||||
} else {
|
||||
this.initStandardWebSocket()
|
||||
}
|
||||
}
|
||||
|
||||
private initSharedWorker() {
|
||||
try {
|
||||
this.worker = new SharedWorker(this.workerPath)
|
||||
this.worker.port.postMessage({ type: 'init', data: { url: this.wsUrl } })
|
||||
this.worker.port.onmessage = (e) => this.emit(e.data)
|
||||
this.worker.port.start()
|
||||
} catch {
|
||||
this.initStandardWebSocket()
|
||||
}
|
||||
}
|
||||
|
||||
private initStandardWebSocket() {
|
||||
if (this.socket) return
|
||||
try {
|
||||
this.socket = new WebSocket(this.wsUrl)
|
||||
this.socket.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
this.emit(data)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.socket.onclose = () => {
|
||||
this.socket = null
|
||||
this.reconnect()
|
||||
}
|
||||
this.socket.onerror = () => {
|
||||
this.socket = null
|
||||
this.reconnect()
|
||||
}
|
||||
} catch {
|
||||
this.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private reconnect() {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.initialized = false
|
||||
this.init()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
private emit(msg: WSMessage) {
|
||||
if (!msg || !msg.type) return
|
||||
this.handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(msg)
|
||||
} catch {
|
||||
// ignore handler errors
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(handler: MessageHandler) {
|
||||
this.handlers.add(handler)
|
||||
return () => {
|
||||
this.handlers.delete(handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBusDriver()
|
||||
@@ -0,0 +1,46 @@
|
||||
export function formatDuration(ms?: number | null): string {
|
||||
if (ms == null || Number.isNaN(ms)) return '-'
|
||||
if (ms < 1000) return `${Math.round(ms)}毫秒`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}秒`
|
||||
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}分钟`
|
||||
return `${(ms / 3600000).toFixed(1)}小时`
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null): string {
|
||||
if (!value || value === '-') return '-'
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return value
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
/** 简单 cron 说明(6 段秒级或 5 段) */
|
||||
export function getCronDescription(expr?: string | null): string {
|
||||
if (!expr?.trim()) return '未设置'
|
||||
const parts = expr.trim().split(/\s+/)
|
||||
if (parts.length < 5) return expr
|
||||
// 常见快捷
|
||||
if (expr === '0 * * * * *' || expr === '* * * * *') return '每分钟'
|
||||
if (expr === '0 0 * * * *' || expr === '0 * * * *') return '每小时'
|
||||
if (expr === '0 0 0 * * *' || expr === '0 0 * * *') return '每天 0 点'
|
||||
if (expr.startsWith('*/')) return `周期: ${expr}`
|
||||
return expr
|
||||
}
|
||||
|
||||
export function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
return navigator.clipboard.writeText(text).then(() => true).catch(() => false)
|
||||
}
|
||||
try {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
return Promise.resolve(ok)
|
||||
} catch {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { lazy, Suspense, type ComponentType, type ReactNode } from 'react'
|
||||
import { Loading } from '@/components/ui'
|
||||
|
||||
/** 路由级懒加载包装 */
|
||||
export function lazyPage(factory: () => Promise<{ default: ComponentType<any> }>, fallback?: ReactNode) {
|
||||
const Comp = lazy(factory)
|
||||
|
||||
function LazyRoute() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
fallback ?? (
|
||||
<div className="flex min-h-[240px] items-center justify-center p-8">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Comp />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
LazyRoute.displayName = 'LazyRoute'
|
||||
return LazyRoute
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 日志内容处理:base64 压缩解压 + ANSI 转 HTML
|
||||
*/
|
||||
|
||||
/** 尝试将后端可能返回的压缩/base64 内容还原为明文 */
|
||||
export function decodeLogContent(raw: string): string {
|
||||
if (!raw) return ''
|
||||
// 若包含 ANSI 或明显是明文,直接返回
|
||||
if (raw.includes('\u001b[') || raw.includes('\n') || raw.length < 40) return raw
|
||||
|
||||
// 纯 base64 形态尝试解码
|
||||
const compact = raw.replace(/\s+/g, '')
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(compact) || compact.length % 4 !== 0) return raw
|
||||
|
||||
try {
|
||||
const binary = atob(compact)
|
||||
// 尝试 inflate(部分后端用 gzip/deflate 再 base64)
|
||||
if (typeof DecompressionStream !== 'undefined') {
|
||||
// 同步路径:先按 UTF-8 解码;若失败再当明文 base64 文本
|
||||
try {
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
||||
// 非 gzip 魔数则直接当 UTF-8
|
||||
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
||||
// 异步不适合这里,回退文本
|
||||
}
|
||||
return new TextDecoder().decode(bytes)
|
||||
} catch {
|
||||
return binary
|
||||
}
|
||||
}
|
||||
return decodeURIComponent(escape(binary))
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
/** 转义 HTML */
|
||||
function escapeHtml(s: string) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
const ANSI_COLORS: Record<string, string> = {
|
||||
'30': '#000',
|
||||
'31': '#ef4444',
|
||||
'32': '#22c55e',
|
||||
'33': '#eab308',
|
||||
'34': '#3b82f6',
|
||||
'35': '#a855f7',
|
||||
'36': '#06b6d4',
|
||||
'37': '#e5e7eb',
|
||||
'90': '#6b7280',
|
||||
'91': '#f87171',
|
||||
'92': '#4ade80',
|
||||
'93': '#facc15',
|
||||
'94': '#60a5fa',
|
||||
'95': '#c084fc',
|
||||
'96': '#22d3ee',
|
||||
'97': '#f9fafb',
|
||||
}
|
||||
|
||||
/** ANSI → HTML(轻量实现,覆盖常用前景色/粗体/重置) */
|
||||
export function ansiToHtml(input: string): string {
|
||||
const text = decodeLogContent(input)
|
||||
if (!text.includes('\u001b[') && !text.includes('\x1b[')) {
|
||||
return escapeHtml(text)
|
||||
}
|
||||
|
||||
let html = ''
|
||||
let open = false
|
||||
let style = ''
|
||||
const re = /\x1b\[([0-9;]*)m/g
|
||||
let last = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text))) {
|
||||
const chunk = text.slice(last, m.index)
|
||||
if (chunk) html += open ? `<span style="${style}">${escapeHtml(chunk)}</span>` : escapeHtml(chunk)
|
||||
last = m.index + m[0].length
|
||||
const codes = (m[1] || '0').split(';').filter(Boolean)
|
||||
if (codes.length === 0 || codes.includes('0')) {
|
||||
open = false
|
||||
style = ''
|
||||
continue
|
||||
}
|
||||
const parts: string[] = []
|
||||
for (const c of codes) {
|
||||
if (c === '1') parts.push('font-weight:700')
|
||||
else if (c === '2') parts.push('opacity:.75')
|
||||
else if (c === '4') parts.push('text-decoration:underline')
|
||||
else if (ANSI_COLORS[c]) parts.push(`color:${ANSI_COLORS[c]}`)
|
||||
}
|
||||
if (parts.length) {
|
||||
open = true
|
||||
style = parts.join(';')
|
||||
}
|
||||
}
|
||||
const rest = text.slice(last)
|
||||
if (rest) html += open ? `<span style="${style}">${escapeHtml(rest)}</span>` : escapeHtml(rest)
|
||||
return html
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/** 渠道配置字段(对齐原版 channelConfigFields) */
|
||||
export type NotifyField = {
|
||||
key: string
|
||||
label: string
|
||||
required: boolean
|
||||
placeholder?: string
|
||||
type?: 'text' | 'textarea' | 'note'
|
||||
}
|
||||
|
||||
export const CHANNEL_CONFIG_FIELDS: Record<string, NotifyField[]> = {
|
||||
Telegram: [
|
||||
{ key: 'bot_token', label: 'Bot Token', required: true, placeholder: '从 @BotFather 获取' },
|
||||
{ key: 'chat_id', label: 'Chat ID', required: true, placeholder: '聊天/群组 ID' },
|
||||
{ key: 'api_host', label: 'API 地址', required: false, placeholder: '自定义 API 地址,留空使用官方' },
|
||||
{ key: 'proxy_url', label: '代理地址', required: false, placeholder: 'http/https/socks5 代理' },
|
||||
],
|
||||
Bark: [
|
||||
{ key: 'server', label: '服务地址', required: false, placeholder: '默认 https://api.day.app' },
|
||||
{ key: 'push_key', label: 'Push Key', required: true, placeholder: 'Bark Push Key' },
|
||||
{ key: 'proxy_url', label: '代理地址', required: false, placeholder: 'http/https/socks5 代理' },
|
||||
{ key: 'sound', label: '推送声音', required: false, placeholder: '留空使用默认' },
|
||||
{ key: 'badge', label: '角标数量', required: false, placeholder: '例如 1' },
|
||||
{ key: 'group', label: '推送分组', required: false },
|
||||
{ key: 'icon', label: '推送图标', required: false, placeholder: '图标 URL' },
|
||||
{ key: 'level', label: '时效性', required: false, placeholder: 'active / timeSensitive / passive' },
|
||||
{ key: 'url', label: '跳转URL', required: false },
|
||||
{ key: 'copy', label: '复制内容', required: false },
|
||||
{ key: 'auto_copy', label: '自动复制', required: false, placeholder: '1 表示开启' },
|
||||
],
|
||||
Dtalk: [
|
||||
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '钉钉机器人 access_token' },
|
||||
{ key: 'secret', label: '加签秘钥', required: false, placeholder: '可选' },
|
||||
],
|
||||
QyWeiXin: [
|
||||
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '企业微信机器人 Key' },
|
||||
],
|
||||
Feishu: [
|
||||
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '飞书机器人 access_token' },
|
||||
{ key: 'secret', label: '加签秘钥', required: false, placeholder: '可选' },
|
||||
],
|
||||
Custom: [
|
||||
{ key: 'webhook', label: 'Webhook URL', required: true, placeholder: 'https://...' },
|
||||
{ key: 'headers', label: '请求头', required: false, placeholder: 'JSON 格式', type: 'textarea' },
|
||||
{ key: 'body', label: '请求体模板', required: false, placeholder: '使用 TEXT 作为消息内容占位符', type: 'textarea' },
|
||||
],
|
||||
Ntfy: [
|
||||
{ key: 'topic', label: 'Topic', required: true },
|
||||
{ key: 'url', label: 'API 地址', required: false, placeholder: '默认 https://ntfy.sh' },
|
||||
{ key: 'priority', label: '优先级', required: false, placeholder: '1-5' },
|
||||
{ key: 'icon', label: '图标 URL', required: false },
|
||||
{ key: 'token', label: 'Token', required: false },
|
||||
{ key: 'username', label: '用户名', required: false },
|
||||
{ key: 'password', label: '密码', required: false },
|
||||
],
|
||||
Gotify: [
|
||||
{ key: 'url', label: '服务地址', required: true, placeholder: 'https://gotify.example.com' },
|
||||
{ key: 'token', label: 'Token', required: true },
|
||||
{ key: 'priority', label: '优先级', required: false, placeholder: '0-10' },
|
||||
],
|
||||
PushMe: [
|
||||
{ key: 'push_key', label: 'Push Key', required: true },
|
||||
{ key: 'url', label: 'API 地址', required: false, placeholder: '默认 https://push.i-i.me' },
|
||||
{ key: 'type', label: '类型', required: false },
|
||||
],
|
||||
Email: [
|
||||
{ key: 'server', label: 'SMTP 服务器', required: true, placeholder: 'smtp.example.com' },
|
||||
{ key: 'port', label: '端口', required: true, placeholder: '465' },
|
||||
{ key: 'account', label: '邮箱账号', required: true },
|
||||
{ key: 'passwd', label: '邮箱密码', required: true },
|
||||
{ key: 'from_name', label: '发信人名称', required: false },
|
||||
{ key: 'to_account', label: '收件邮箱', required: true },
|
||||
],
|
||||
AliyunSMS: [
|
||||
{ key: 'access_key_id', label: 'AccessKeyId', required: true },
|
||||
{ key: 'access_key_secret', label: 'AccessKeySecret', required: true },
|
||||
{ key: 'sign_name', label: '短信签名', required: true },
|
||||
{ key: 'region_id', label: '区域ID', required: false, placeholder: '默认 cn-hangzhou' },
|
||||
{ key: 'phone_number', label: '手机号码', required: true },
|
||||
{ key: 'template_code', label: '短信模板 CODE', required: true },
|
||||
],
|
||||
PushPlus: [
|
||||
{ key: 'token', label: 'Token', required: true, placeholder: 'PushPlus Token' },
|
||||
{ key: 'topic', label: '群组编码', required: false },
|
||||
{ key: 'template', label: '推送模板', required: false, placeholder: 'html, txt, json, markdown' },
|
||||
{ key: 'channel', label: '推送渠道', required: false, placeholder: 'wechat, dingding, feishu, mail等' },
|
||||
{ key: 'webhook', label: 'Webhook', required: false },
|
||||
{ key: 'callback_url', label: '回调地址', required: false },
|
||||
{ key: 'to', label: '好友令牌', required: false },
|
||||
],
|
||||
VoceChat: [
|
||||
{ key: 'server', label: '服务地址', required: true, placeholder: 'https://vocechat.yourdomain.com' },
|
||||
{ key: 'api_key', label: 'API Key', required: true, placeholder: 'Bot API Key' },
|
||||
{ key: 'target_id', label: '目标 ID', required: true, placeholder: 'uid 或 gid' },
|
||||
{ key: 'target_type', label: '目标类型', required: false, placeholder: 'user (默认) / group' },
|
||||
],
|
||||
WxPusher: [
|
||||
{ key: 'app_token', label: 'AppToken', required: true, placeholder: 'AT_...' },
|
||||
{ key: 'uids', label: 'UIDs', required: false, placeholder: '用户 UID,多个用逗号分隔' },
|
||||
{ key: 'topic_ids', label: 'TopicIDs', required: false, placeholder: '主题 ID,多个用逗号分隔' },
|
||||
{ key: 'verify_pay_type', label: '付费验证', required: false, placeholder: '0:不验证, 1:仅付费, 2:仅未订阅/过期' },
|
||||
],
|
||||
// 兼容小写 type
|
||||
telegram: [
|
||||
{ key: 'bot_token', label: 'Bot Token', required: true },
|
||||
{ key: 'chat_id', label: 'Chat ID', required: true },
|
||||
{ key: 'api_host', label: 'API 地址', required: false },
|
||||
{ key: 'proxy_url', label: '代理地址', required: false },
|
||||
],
|
||||
webhook: [
|
||||
{ key: 'webhook', label: 'Webhook URL', required: true },
|
||||
{ key: 'headers', label: '请求头', required: false, type: 'textarea' },
|
||||
{ key: 'body', label: '请求体模板', required: false, type: 'textarea' },
|
||||
],
|
||||
}
|
||||
|
||||
const TYPE_ALIASES: Record<string, string> = {
|
||||
PushMes: 'PushMe',
|
||||
pushme: 'PushMe',
|
||||
pushmes: 'PushMe',
|
||||
webhook: 'Custom',
|
||||
custom: 'Custom',
|
||||
}
|
||||
|
||||
export function getChannelFields(type: string): NotifyField[] {
|
||||
if (!type) return []
|
||||
const normalized = TYPE_ALIASES[type] || type
|
||||
return (
|
||||
CHANNEL_CONFIG_FIELDS[normalized] ||
|
||||
CHANNEL_CONFIG_FIELDS[type] ||
|
||||
CHANNEL_CONFIG_FIELDS[type.charAt(0).toUpperCase() + type.slice(1)] ||
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
export function emptyConfigForType(type: string, existing: Record<string, any> = {}) {
|
||||
const fields = getChannelFields(type)
|
||||
const cfg: Record<string, string> = {}
|
||||
for (const f of fields) {
|
||||
cfg[f.key] = existing[f.key] != null ? String(existing[f.key]) : ''
|
||||
}
|
||||
// 保留未知字段
|
||||
Object.keys(existing || {}).forEach((k) => {
|
||||
if (cfg[k] === undefined) cfg[k] = String(existing[k] ?? '')
|
||||
})
|
||||
return cfg
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Task } from '@/api'
|
||||
import { TASK_TYPE } from '@/constants'
|
||||
|
||||
/** 从仓库任务生成可复制的同步指令 */
|
||||
export function generateTaskPoolCommand(task: Partial<Task> | null | undefined): string {
|
||||
if (!task || task.type !== TASK_TYPE.REPO) return ''
|
||||
let repo: Record<string, any> = {}
|
||||
try {
|
||||
const cfg = JSON.parse(task.config || '{}')
|
||||
repo = cfg.repo || cfg || {}
|
||||
} catch {
|
||||
repo = {}
|
||||
}
|
||||
const url = String(repo.source_url || '').trim()
|
||||
if (!url) return ''
|
||||
|
||||
const branch = String(repo.branch || 'main').trim() || 'main'
|
||||
const target = String(repo.target_path || '').trim()
|
||||
const sparse = String(repo.sparse_path || '').trim()
|
||||
const proxy = String(repo.proxy_url || repo.proxy || '').trim()
|
||||
const token = String(repo.auth_token || '').trim()
|
||||
const single = !!repo.single_file
|
||||
|
||||
const parts = ['taskpool repo-sync', `--url "${url}"`, `--branch "${branch}"`]
|
||||
if (target) parts.push(`--target "${target}"`)
|
||||
if (sparse) parts.push(`--sparse "${sparse}"`)
|
||||
if (proxy) parts.push(`--proxy "${proxy}"`)
|
||||
if (token) parts.push(`--token "***"`)
|
||||
if (single) parts.push('--single-file')
|
||||
if (task.schedule) parts.push(`--cron "${task.schedule}"`)
|
||||
if (task.name) parts.push(`--name "${task.name}"`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/** 构建脚本执行命令(对齐原版 buildExecutionCommand) */
|
||||
export function buildExecutionCommand(
|
||||
filePath: string,
|
||||
envs: { plugin: string; version: string }[],
|
||||
scriptsDir = 'scripts',
|
||||
) {
|
||||
const rel = filePath.replace(/^\/+/, '')
|
||||
const full = scriptsDir ? `${scriptsDir.replace(/\/$/, '')}/${rel}` : rel
|
||||
const ext = rel.split('.').pop()?.toLowerCase() || ''
|
||||
const misePrefix =
|
||||
envs.length > 0
|
||||
? `mise exec ${envs.map((e) => `${e.plugin}@${e.version}`).join(' ')} -- `
|
||||
: ''
|
||||
|
||||
if (ext === 'py') return `${misePrefix}python "${full}"`
|
||||
if (ext === 'js' || ext === 'mjs' || ext === 'cjs') return `${misePrefix}node "${full}"`
|
||||
if (ext === 'ts') return `${misePrefix}npx tsx "${full}"`
|
||||
if (ext === 'go') return `${misePrefix}go run "${full}"`
|
||||
if (ext === 'rb') return `${misePrefix}ruby "${full}"`
|
||||
if (ext === 'php') return `${misePrefix}php "${full}"`
|
||||
if (ext === 'rs') return `${misePrefix}cargo run --manifest-path "${full}"`
|
||||
if (ext === 'ps1') return `powershell -ExecutionPolicy Bypass -File "${full}"`
|
||||
if (ext === 'bat' || ext === 'cmd') return `"${full}"`
|
||||
return `${misePrefix}bash "${full}"`
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ITheme } from '@xterm/xterm'
|
||||
import type { ThemeId } from '@/context/ThemeContext'
|
||||
|
||||
/**
|
||||
* 终端配色:与面板主题同系,但保证终端可读性
|
||||
* - 使用固定色板,不读 CSS 变量(避免切换瞬间读到旧值)
|
||||
* - 暗色:高对比浅字;亮色:深字浅底
|
||||
*/
|
||||
export function getTerminalTheme(theme: ThemeId): ITheme {
|
||||
if (theme === 'light') {
|
||||
return {
|
||||
background: '#f8f9fa',
|
||||
foreground: '#212529',
|
||||
cursor: '#212529',
|
||||
cursorAccent: '#f8f9fa',
|
||||
selectionBackground: 'rgba(33, 37, 41, 0.18)',
|
||||
selectionForeground: '#212529',
|
||||
selectionInactiveBackground: 'rgba(33, 37, 41, 0.1)',
|
||||
black: '#212529',
|
||||
red: '#dc2626',
|
||||
green: '#059669',
|
||||
yellow: '#d97706',
|
||||
blue: '#2563eb',
|
||||
magenta: '#7c3aed',
|
||||
cyan: '#0e7490',
|
||||
white: '#495057',
|
||||
brightBlack: '#868e96',
|
||||
brightRed: '#e03131',
|
||||
brightGreen: '#2f9e44',
|
||||
brightYellow: '#e67700',
|
||||
brightBlue: '#1c7ed6',
|
||||
brightMagenta: '#9c36b5',
|
||||
brightCyan: '#0c8599',
|
||||
brightWhite: '#212529',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
background: '#1a1a1a',
|
||||
foreground: '#f0f0f0',
|
||||
cursor: '#c0c0c0',
|
||||
cursorAccent: '#1a1a1a',
|
||||
selectionBackground: 'rgba(192, 192, 192, 0.35)',
|
||||
selectionForeground: '#ffffff',
|
||||
selectionInactiveBackground: 'rgba(192, 192, 192, 0.2)',
|
||||
black: '#4a4a4a',
|
||||
red: '#ff8a80',
|
||||
green: '#69f0ae',
|
||||
yellow: '#ffd54f',
|
||||
blue: '#82b1ff',
|
||||
magenta: '#e1bee7',
|
||||
cyan: '#80deea',
|
||||
white: '#e8e8e8',
|
||||
brightBlack: '#b0b0b0',
|
||||
brightRed: '#ff5252',
|
||||
brightGreen: '#69f0ae',
|
||||
brightYellow: '#ffee58',
|
||||
brightBlue: '#448aff',
|
||||
brightMagenta: '#e040fb',
|
||||
brightCyan: '#18ffff',
|
||||
brightWhite: '#ffffff',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
type: ToastType
|
||||
message: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
type Listener = (items: ToastItem[]) => void
|
||||
|
||||
let seq = 1
|
||||
let items: ToastItem[] = []
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((fn) => fn([...items]))
|
||||
}
|
||||
|
||||
function push(type: ToastType, message: string, duration = 3000) {
|
||||
const id = seq++
|
||||
const item: ToastItem = { id, type, message, duration }
|
||||
items = [...items, item]
|
||||
emit()
|
||||
if (duration > 0) {
|
||||
window.setTimeout(() => {
|
||||
dismiss(id)
|
||||
}, duration)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export function dismiss(id: number) {
|
||||
items = items.filter((t) => t.id !== id)
|
||||
emit()
|
||||
}
|
||||
|
||||
export function subscribeToast(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
listener([...items])
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, duration?: number) => push('success', message, duration),
|
||||
error: (message: string, duration?: number) => push('error', message, duration ?? 4000),
|
||||
info: (message: string, duration?: number) => push('info', message, duration),
|
||||
warning: (message: string, duration?: number) => push('warning', message, duration),
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ThemeProvider } from '@/context/ThemeContext'
|
||||
import { routeTree } from '@/routeTree.gen'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const router = createRouter({ routeTree })
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register { router: typeof router }
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,458 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Copy, Download, Pencil, RefreshCw, Server, Ticket, Trash2 } from 'lucide-react'
|
||||
import { api, type Agent, type AgentToken, type SchedulerConfig } from '@/api'
|
||||
import { useAgents, useAgentTokens } from '@/api/hooks'
|
||||
import { Button, Card, Input, Modal, PageHeader, Switch, Tabs } from '@/components/ui'
|
||||
import { AGENT_STATUS } from '@/constants'
|
||||
import { copyToClipboard, formatDateTime } from '@/lib/format'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const emptyScheduler = (): SchedulerConfig => ({
|
||||
worker_count: 4,
|
||||
queue_size: 100,
|
||||
rate_interval: 0,
|
||||
verbose: false,
|
||||
strict_queue: false,
|
||||
})
|
||||
|
||||
export default function Agents() {
|
||||
const [tab, setTab] = useState<'agents' | 'tokens'>('agents')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [editAgent, setEditAgent] = useState<Agent | null>(null)
|
||||
const [detailAgent, setDetailAgent] = useState<Agent | null>(null)
|
||||
const [downloadOpen, setDownloadOpen] = useState(false)
|
||||
const [tokenFormOpen, setTokenFormOpen] = useState(false)
|
||||
const [editingToken, setEditingToken] = useState<AgentToken | null>(null)
|
||||
const [agentForm, setAgentForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
useScheduler: false,
|
||||
scheduler: emptyScheduler(),
|
||||
})
|
||||
const [tokenForm, setTokenForm] = useState({ remark: '', max_uses: '', expires_at: '' })
|
||||
const [message, setMessage] = useState('')
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: agentsData, isLoading, refetch, isFetching } = useAgents()
|
||||
const { data: tokensData, refetch: refetchTokens } = useAgentTokens()
|
||||
const agents = Array.isArray(agentsData) ? agentsData : []
|
||||
const tokens = Array.isArray(tokensData) ? tokensData : []
|
||||
|
||||
const { data: versionInfo } = useQuery({
|
||||
queryKey: ['agentVersion'],
|
||||
queryFn: () => api.agents.getVersion(),
|
||||
})
|
||||
const platforms = Array.isArray(versionInfo?.platforms) ? versionInfo.platforms : []
|
||||
|
||||
const saveAgent = useMutation({
|
||||
mutationFn: () =>
|
||||
api.agents.update(editAgent!.id, {
|
||||
name: agentForm.name,
|
||||
description: agentForm.description,
|
||||
enabled: agentForm.enabled,
|
||||
scheduler_config: agentForm.useScheduler ? agentForm.scheduler : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setEditAgent(null)
|
||||
setMessage('Agent 已保存')
|
||||
qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: any) => setMessage(e?.message || '保存失败'),
|
||||
})
|
||||
|
||||
const deleteAgent = useMutation({
|
||||
mutationFn: (id: string) => api.agents.delete(id),
|
||||
onSuccess: () => {
|
||||
setMessage('已删除 Agent')
|
||||
qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
})
|
||||
|
||||
const forceUpdate = useMutation({
|
||||
mutationFn: (id: string) => api.agents.forceUpdate(id),
|
||||
onSuccess: () => setMessage('已发送强制更新'),
|
||||
onError: (e: any) => setMessage(e?.message || '更新失败'),
|
||||
})
|
||||
|
||||
const saveToken = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
remark: tokenForm.remark || undefined,
|
||||
max_uses: tokenForm.max_uses ? Number(tokenForm.max_uses) : undefined,
|
||||
expires_at: tokenForm.expires_at || undefined,
|
||||
}
|
||||
if (editingToken) return api.agents.updateToken(editingToken.id, payload)
|
||||
return api.agents.createToken(payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setTokenFormOpen(false)
|
||||
setEditingToken(null)
|
||||
setTokenForm({ remark: '', max_uses: '', expires_at: '' })
|
||||
setMessage(editingToken ? '令牌已更新' : '令牌已创建')
|
||||
qc.invalidateQueries({ queryKey: ['agentTokens'] })
|
||||
},
|
||||
onError: (e: any) => setMessage(e?.message || '操作失败'),
|
||||
})
|
||||
|
||||
const deleteToken = useMutation({
|
||||
mutationFn: (id: string) => api.agents.deleteToken(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['agentTokens'] }),
|
||||
})
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!keyword) return agents
|
||||
const q = keyword.toLowerCase()
|
||||
return agents.filter((a) =>
|
||||
[a.name, a.hostname, a.ip, a.os, a.arch, a.version]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(q)),
|
||||
)
|
||||
}, [agents, keyword])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editAgent) return
|
||||
setAgentForm({
|
||||
name: editAgent.name || '',
|
||||
description: editAgent.description || '',
|
||||
enabled: editAgent.enabled !== false,
|
||||
useScheduler: !!editAgent.scheduler_config,
|
||||
scheduler: editAgent.scheduler_config || emptyScheduler(),
|
||||
})
|
||||
}, [editAgent])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="远程执行"
|
||||
description="管理 Agent 节点与接入令牌"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
refetch()
|
||||
refetchTokens()
|
||||
}}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setDownloadOpen(true)}>
|
||||
<Download size={14} />
|
||||
下载 Agent
|
||||
</Button>
|
||||
{tab === 'tokens' ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingToken(null)
|
||||
setTokenForm({ remark: '', max_uses: '', expires_at: '' })
|
||||
setTokenFormOpen(true)
|
||||
}}
|
||||
>
|
||||
<Ticket size={14} />
|
||||
创建令牌
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<div className="mb-3 rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)] px-3 py-2 text-sm">{message}</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as 'agents' | 'tokens')}
|
||||
items={[
|
||||
{ value: 'agents', label: '节点列表' },
|
||||
{ value: 'tokens', label: '接入令牌' },
|
||||
]}
|
||||
/>
|
||||
{tab === 'agents' ? (
|
||||
<Input className="max-w-xs" placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||
) : null}
|
||||
{versionInfo?.version ? (
|
||||
<span className="text-sm text-[var(--text-muted)]">Agent 版本: {versionInfo.version}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{tab === 'agents' ? (
|
||||
<Card className="overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-[var(--text-muted)]">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-[var(--text-muted)]">
|
||||
<Server className="mx-auto mb-2 opacity-50" size={28} />
|
||||
暂无 Agent,请先创建令牌并下载安装
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{filtered.map((agent) => (
|
||||
<div key={agent.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<button className="min-w-0 text-left" onClick={() => setDetailAgent(agent)}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{agent.name || agent.hostname || agent.id}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-xs',
|
||||
agent.status === AGENT_STATUS.ONLINE
|
||||
? 'bg-green-500/15 text-green-500'
|
||||
: 'bg-zinc-500/15 text-zinc-400',
|
||||
)}
|
||||
>
|
||||
{agent.status || 'unknown'}
|
||||
</span>
|
||||
{!agent.enabled ? (
|
||||
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-xs text-amber-500">已禁用</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--text-muted)]">
|
||||
{[agent.ip, agent.os, agent.arch, agent.version].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
{agent.last_seen ? (
|
||||
<div className="mt-1 text-xs text-[var(--text-muted)]">最近在线: {formatDateTime(agent.last_seen)}</div>
|
||||
) : null}
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditAgent(agent)}>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => forceUpdate.mutate(agent.id)} disabled={forceUpdate.isPending}>
|
||||
强制更新
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (confirm('确认删除该 Agent?')) deleteAgent.mutate(agent.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="overflow-hidden">
|
||||
{tokens.length === 0 ? (
|
||||
<div className="p-6 text-[var(--text-muted)]">暂无令牌</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="break-all font-mono text-sm">{token.token}</div>
|
||||
<div className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
备注: {token.remark || '-'} · 使用 {token.used_count}/{token.max_uses || '∞'}
|
||||
{token.expires_at ? ` · 过期 ${formatDateTime(token.expires_at)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
await copyToClipboard(token.token)
|
||||
setMessage('令牌已复制')
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setEditingToken(token)
|
||||
setTokenForm({
|
||||
remark: token.remark || '',
|
||||
max_uses: token.max_uses ? String(token.max_uses) : '',
|
||||
expires_at: token.expires_at || '',
|
||||
})
|
||||
setTokenFormOpen(true)
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (confirm('确认删除该令牌?')) deleteToken.mutate(token.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={!!editAgent}
|
||||
title="编辑 Agent"
|
||||
onClose={() => setEditAgent(null)}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setEditAgent(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => saveAgent.mutate()} disabled={saveAgent.isPending || !agentForm.name.trim()}>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input placeholder="名称" value={agentForm.name} onChange={(e) => setAgentForm((f) => ({ ...f, name: e.target.value }))} />
|
||||
<Input
|
||||
placeholder="描述"
|
||||
value={agentForm.description}
|
||||
onChange={(e) => setAgentForm((f) => ({ ...f, description: e.target.value }))}
|
||||
/>
|
||||
<label className="flex items-center justify-between text-sm">
|
||||
<span>启用</span>
|
||||
<Switch checked={agentForm.enabled} onCheckedChange={(v) => setAgentForm((f) => ({ ...f, enabled: v }))} />
|
||||
</label>
|
||||
<label className="flex items-center justify-between text-sm">
|
||||
<span>自定义调度配置</span>
|
||||
<Switch
|
||||
checked={agentForm.useScheduler}
|
||||
onCheckedChange={(v) => setAgentForm((f) => ({ ...f, useScheduler: v }))}
|
||||
/>
|
||||
</label>
|
||||
{agentForm.useScheduler ? (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Worker"
|
||||
value={agentForm.scheduler.worker_count}
|
||||
onChange={(e) =>
|
||||
setAgentForm((f) => ({
|
||||
...f,
|
||||
scheduler: { ...f.scheduler, worker_count: Number(e.target.value) || 0 },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="队列"
|
||||
value={agentForm.scheduler.queue_size}
|
||||
onChange={(e) =>
|
||||
setAgentForm((f) => ({
|
||||
...f,
|
||||
scheduler: { ...f.scheduler, queue_size: Number(e.target.value) || 0 },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="限速间隔"
|
||||
value={agentForm.scheduler.rate_interval}
|
||||
onChange={(e) =>
|
||||
setAgentForm((f) => ({
|
||||
...f,
|
||||
scheduler: { ...f.scheduler, rate_interval: Number(e.target.value) || 0 },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!detailAgent} title="Agent 详情" onClose={() => setDetailAgent(null)} size="lg">
|
||||
{detailAgent ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row label="ID" value={detailAgent.id} />
|
||||
<Row label="名称" value={detailAgent.name} />
|
||||
<Row label="主机" value={detailAgent.hostname} />
|
||||
<Row label="IP" value={detailAgent.ip} />
|
||||
<Row label="系统" value={`${detailAgent.os || '-'} / ${detailAgent.arch || '-'}`} />
|
||||
<Row label="版本" value={detailAgent.version} />
|
||||
<Row label="状态" value={detailAgent.status} />
|
||||
<Row label="最近在线" value={formatDateTime(detailAgent.last_seen)} />
|
||||
<Row label="描述" value={detailAgent.description || '-'} />
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal open={downloadOpen} title="下载 Agent" onClose={() => setDownloadOpen(false)} size="lg">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
当前版本: {versionInfo?.version || '-'}。选择平台下载后,使用接入令牌注册到本面板。
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{platforms.length === 0 ? (
|
||||
<div className="text-sm text-[var(--text-muted)]">暂无可用平台包</div>
|
||||
) : (
|
||||
platforms.map((p) => (
|
||||
<a key={p.filename} href={api.agents.downloadUrl(p.os, p.arch)} className="inline-flex">
|
||||
<Button variant="secondary" className="w-full justify-start">
|
||||
<Download size={14} />
|
||||
{p.os}/{p.arch}
|
||||
<span className="ml-auto text-xs text-[var(--text-muted)]">{p.filename}</span>
|
||||
</Button>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={tokenFormOpen}
|
||||
title={editingToken ? '编辑令牌' : '创建令牌'}
|
||||
onClose={() => setTokenFormOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setTokenFormOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => saveToken.mutate()} disabled={saveToken.isPending}>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input placeholder="备注" value={tokenForm.remark} onChange={(e) => setTokenForm((f) => ({ ...f, remark: e.target.value }))} />
|
||||
<Input
|
||||
placeholder="最大使用次数(空=无限)"
|
||||
value={tokenForm.max_uses}
|
||||
onChange={(e) => setTokenForm((f) => ({ ...f, max_uses: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="过期时间(可选,后端可解析格式)"
|
||||
value={tokenForm.expires_at}
|
||||
onChange={(e) => setTokenForm((f) => ({ ...f, expires_at: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div className="flex gap-3 border-b border-[var(--border)] py-2 last:border-0">
|
||||
<span className="w-24 shrink-0 text-[var(--text-muted)]">{label}</span>
|
||||
<span className="min-w-0 break-all">{value || '-'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
ListTodo,
|
||||
Play,
|
||||
ScrollText,
|
||||
Variable,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { useSendStats, useStats, useTaskStats } from '@/api/hooks'
|
||||
import { Card, Loading, PageHeader, SectionLabel } from '@/components/ui'
|
||||
import { useTheme } from '@/context/ThemeContext'
|
||||
|
||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16']
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
const { theme } = useTheme()
|
||||
const isDark = theme !== 'light'
|
||||
const { data: stats, isLoading: statsLoading } = useStats()
|
||||
const { data: sendStatsData } = useSendStats(30)
|
||||
const { data: taskStatsData } = useTaskStats(30)
|
||||
|
||||
const sendStats = Array.isArray(sendStatsData) ? sendStatsData : []
|
||||
const taskStats = Array.isArray(taskStatsData) ? taskStatsData : []
|
||||
|
||||
const lineData = useMemo(
|
||||
() =>
|
||||
sendStats.map((item) => ({
|
||||
day: item.day?.slice(5) || item.day,
|
||||
total: item.total || 0,
|
||||
success: item.success || 0,
|
||||
failed: item.failed || 0,
|
||||
})),
|
||||
[sendStats],
|
||||
)
|
||||
|
||||
const pieData = useMemo(
|
||||
() =>
|
||||
taskStats.slice(0, 8).map((item) => ({
|
||||
name: item.task_name || item.task_id,
|
||||
value: item.count || 0,
|
||||
})),
|
||||
[taskStats],
|
||||
)
|
||||
|
||||
const axisColor = isDark ? '#94a3b8' : '#64748b'
|
||||
const gridColor = isDark ? '#334155' : '#e2e8f0'
|
||||
|
||||
if (statsLoading) {
|
||||
return <Loading text="加载仪表盘..." />
|
||||
}
|
||||
|
||||
const statItems = [
|
||||
{ key: 'today_execs', label: '今日执行', icon: Play, value: stats?.today_execs || 0, route: '/history' as const },
|
||||
{ key: 'tasks', label: '任务总数', icon: ListTodo, value: stats?.tasks || 0, route: '/tasks' as const },
|
||||
{ key: 'envs', label: '环境变量', icon: Variable, value: stats?.envs || 0, route: '/environments' as const },
|
||||
{ key: 'logs', label: '日志总数', icon: ScrollText, value: stats?.logs || 0, route: '/history' as const },
|
||||
{ key: 'scheduled', label: '调度注册', icon: Clock, value: stats?.scheduled || 0, route: '/tasks' as const },
|
||||
{ key: 'running', label: '正在运行', icon: Activity, value: stats?.running || 0, route: '/history' as const, search: { status: 'running' } as const },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="数据仪表" subtitle="Overview" description="系统概览与任务统计" />
|
||||
|
||||
<section className="mb-7">
|
||||
<SectionLabel>任务概览</SectionLabel>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{statItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="text-left"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: item.route,
|
||||
search: 'search' in item ? item.search : undefined,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<Card hoverable className="p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[12px] font-medium text-[var(--text-muted)]">{item.label}</span>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)]">
|
||||
<item.icon size={15} strokeWidth={1.5} className="text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[22px] font-bold tracking-tight text-[var(--text-primary)]">{item.value}</div>
|
||||
</Card>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-7 grid gap-3 xl:grid-cols-[1.4fr_1fr]">
|
||||
<Card className="p-4">
|
||||
<SectionLabel>近 30 天执行趋势</SectionLabel>
|
||||
<div className="h-[300px] w-full">
|
||||
{lineData.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-[var(--text-muted)]">暂无统计</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={lineData} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke={gridColor} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="day" tick={{ fill: axisColor, fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: axisColor, fontSize: 11 }} axisLine={false} tickLine={false} width={36} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: isDark ? '#0f172a' : '#fff',
|
||||
border: `1px solid ${gridColor}`,
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="total" name="执行总数" stroke="#3b82f6" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="success" name="执行成功" stroke="#10b981" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="failed" name="执行失败" stroke="#ef4444" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<SectionLabel>任务执行占比</SectionLabel>
|
||||
<div className="h-[300px] w-full">
|
||||
{pieData.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-[var(--text-muted)]">暂无统计</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={55} outerRadius={95} paddingAngle={2}>
|
||||
{pieData.map((_, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: isDark ? '#0f172a' : '#fff',
|
||||
border: `1px solid ${gridColor}`,
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="border-b border-[var(--border)] px-4 py-3 text-[12px] font-medium uppercase tracking-wide text-[var(--text-muted)]">
|
||||
近 30 天任务执行 Top
|
||||
</div>
|
||||
{taskStats.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-[var(--text-muted)]">暂无统计</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{taskStats.slice(0, 10).map((item, idx) => (
|
||||
<div key={item.task_id || idx} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="w-5 shrink-0 font-mono text-[11px] text-[var(--text-muted)]">
|
||||
{String(idx + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="truncate pr-3">{item.task_name || item.task_id}</span>
|
||||
</div>
|
||||
<span className="rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)] px-2 py-0.5 font-mono text-[12px] text-[var(--text-secondary)]">
|
||||
{item.count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import {
|
||||
ChevronLeft,
|
||||
Download,
|
||||
FileUp,
|
||||
Package,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
Search,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { api, type Dependency } from '@/api'
|
||||
import { useDeps, useMiseList } from '@/api/hooks'
|
||||
import { Button, Card, Checkbox, Input, Modal, PageHeader, Textarea } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const LANG_LABEL: Record<string, string> = {
|
||||
python: 'Python',
|
||||
node: 'Node.js',
|
||||
ruby: 'Ruby',
|
||||
go: 'Go',
|
||||
rust: 'Rust',
|
||||
bun: 'Bun',
|
||||
php: 'PHP',
|
||||
deno: 'Deno',
|
||||
system: 'System',
|
||||
}
|
||||
|
||||
export default function Dependencies() {
|
||||
const navigate = useNavigate()
|
||||
const search = useSearch({ strict: false }) as { language?: string; version?: string }
|
||||
const queryLang = search?.language || ''
|
||||
const queryVersion = search?.version || ''
|
||||
|
||||
const [activeTab, setActiveTab] = useState(queryLang || 'python')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [installOpen, setInstallOpen] = useState(false)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [logOpen, setLogOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Dependency | null>(null)
|
||||
const [forceDelete, setForceDelete] = useState(false)
|
||||
const [logContent, setLogContent] = useState('')
|
||||
const [logName, setLogName] = useState('')
|
||||
const [pkgName, setPkgName] = useState('')
|
||||
const [pkgVersion, setPkgVersion] = useState('')
|
||||
const [pkgRemark, setPkgRemark] = useState('')
|
||||
const [manifest, setManifest] = useState('')
|
||||
const [importDb, setImportDb] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const language = queryLang || activeTab
|
||||
const langVersion = queryVersion || undefined
|
||||
|
||||
const { data: miseData } = useMiseList()
|
||||
const { data: depsData, isLoading, refetch, isFetching } = useDeps({
|
||||
language,
|
||||
lang_version: langVersion,
|
||||
})
|
||||
|
||||
const miseList = Array.isArray(miseData) ? miseData : []
|
||||
const deps = Array.isArray(depsData) ? depsData : []
|
||||
const installedLangs = useMemo(
|
||||
() => [...new Set(miseList.map((l) => l.plugin))].sort(),
|
||||
[miseList],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (queryLang) setActiveTab(queryLang)
|
||||
}, [queryLang])
|
||||
|
||||
useEffect(() => {
|
||||
if (queryLang) return
|
||||
if (installedLangs.length && !installedLangs.includes(activeTab) && activeTab !== 'system') {
|
||||
setActiveTab(installedLangs[0]!)
|
||||
}
|
||||
}, [installedLangs, activeTab, queryLang])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = deps.filter((d) => !language || d.language === language)
|
||||
if (!keyword.trim()) return list
|
||||
const q = keyword.toLowerCase()
|
||||
return list.filter((d) => d.name.toLowerCase().includes(q) || (d.remark || '').toLowerCase().includes(q))
|
||||
}, [deps, language, keyword])
|
||||
|
||||
const allSelected = filtered.length > 0 && filtered.every((d) => selected.includes(d.id))
|
||||
|
||||
function toggleAll(checked: boolean) {
|
||||
setSelected(checked ? filtered.map((d) => d.id) : [])
|
||||
}
|
||||
|
||||
function toggleOne(id: string, checked: boolean) {
|
||||
setSelected((prev) => (checked ? [...new Set([...prev, id])] : prev.filter((x) => x !== id)))
|
||||
}
|
||||
|
||||
async function installPackage() {
|
||||
if (!pkgName.trim()) {
|
||||
setMessage('请输入包名')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setMessage('')
|
||||
try {
|
||||
await api.deps.install({
|
||||
name: pkgName.trim(),
|
||||
version: pkgVersion.trim() || undefined,
|
||||
remark: pkgRemark.trim() || undefined,
|
||||
language,
|
||||
lang_version: langVersion,
|
||||
})
|
||||
setInstallOpen(false)
|
||||
setPkgName('')
|
||||
setPkgVersion('')
|
||||
setPkgRemark('')
|
||||
setMessage('安装指令已发送')
|
||||
toast.success('安装指令已发送')
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '安装失败')
|
||||
toast.error(e?.message || '安装失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstall() {
|
||||
if (!deleteTarget) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.deps.uninstall(deleteTarget.id, forceDelete)
|
||||
setDeleteTarget(null)
|
||||
setForceDelete(false)
|
||||
const msg = forceDelete ? '记录已强制移除' : '卸载成功'
|
||||
setMessage(msg)
|
||||
toast.success(msg)
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '卸载失败')
|
||||
toast.error(e?.message || '卸载失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function reinstall(dep: Dependency) {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.deps.reinstall(dep.id)
|
||||
setMessage(`已发送重装指令: ${dep.name}`)
|
||||
toast.success(`已发送重装指令: ${dep.name}`)
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '重装失败')
|
||||
toast.error(e?.message || '重装失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function reinstallAll() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.deps.reinstallAll(language, langVersion)
|
||||
setMessage('全部重装指令已发送')
|
||||
toast.success('全部重装指令已发送')
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '全部重装失败')
|
||||
toast.error(e?.message || '全部重装失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function batchInstall() {
|
||||
if (!selected.length) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const items = deps
|
||||
.filter((d) => selected.includes(d.id))
|
||||
.map((d) => ({
|
||||
name: d.name,
|
||||
version: d.version || undefined,
|
||||
language: d.language,
|
||||
lang_version: d.lang_version || undefined,
|
||||
}))
|
||||
const res = await api.deps.getBatchInstallCmd({ items })
|
||||
setSelected([])
|
||||
if (res.command) {
|
||||
setLogName('批量安装命令')
|
||||
setLogContent(res.command)
|
||||
setLogOpen(true)
|
||||
setMessage('已生成批量安装命令,可复制到终端执行')
|
||||
toast.success('已生成批量安装命令')
|
||||
}
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '生成批量命令失败')
|
||||
toast.error(e?.message || '生成批量命令失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function importManifest() {
|
||||
if (!manifest.trim()) {
|
||||
setMessage('请输入清单内容')
|
||||
toast.warning('请输入清单内容')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.deps.import({
|
||||
language,
|
||||
lang_version: langVersion,
|
||||
content: manifest.trim(),
|
||||
import_db: importDb,
|
||||
})
|
||||
setImportOpen(false)
|
||||
setManifest('')
|
||||
if (res.command) {
|
||||
setLogName('导入安装命令')
|
||||
setLogContent(res.command)
|
||||
setLogOpen(true)
|
||||
}
|
||||
setMessage('依赖清单解析完成')
|
||||
toast.success('依赖清单解析完成')
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '导入失败')
|
||||
toast.error(e?.message || '导入失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = queryLang
|
||||
? [{ value: queryLang, label: LANG_LABEL[queryLang] || queryLang }]
|
||||
: [
|
||||
...installedLangs.map((l) => ({ value: l, label: LANG_LABEL[l] || l })),
|
||||
{ value: 'system', label: 'System' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="依赖管理"
|
||||
description="管理工具运行环境的依赖包"
|
||||
actions={
|
||||
<>
|
||||
{queryLang ? (
|
||||
<Button variant="secondary" onClick={() => navigate({ to: '/languages' })}>
|
||||
<ChevronLeft size={14} />
|
||||
返回语言
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setImportOpen(true)}
|
||||
disabled={language !== 'python' && language !== 'node'}
|
||||
>
|
||||
<FileUp size={14} />
|
||||
导入清单
|
||||
</Button>
|
||||
<Button onClick={() => setInstallOpen(true)}>
|
||||
<Download size={14} />
|
||||
安装包
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<div className="mb-3 rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)] px-3 py-2 text-sm text-[var(--text-secondary)]">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{queryLang && queryVersion ? (
|
||||
<Card className="mb-4 flex items-center justify-between gap-3 p-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Package size={16} className="text-[var(--text-muted)]" />
|
||||
正在管理环境:
|
||||
<span className="font-mono font-medium">
|
||||
{queryLang}@{queryVersion}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded bg-[var(--bg-tertiary)] px-2 py-0.5 text-xs text-[var(--text-muted)]">隔离环境</span>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!queryLang ? (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{tabs.map((t) => (
|
||||
<Button
|
||||
key={t.value}
|
||||
size="sm"
|
||||
variant={activeTab === t.value ? 'default' : 'secondary'}
|
||||
onClick={() => {
|
||||
setActiveTab(t.value)
|
||||
setSelected([])
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--border)] px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded bg-[var(--bg-tertiary)] px-2 py-1 text-xs text-[var(--text-muted)]">
|
||||
{filtered.length} 个包
|
||||
</span>
|
||||
<div className="relative min-w-[180px]">
|
||||
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<Input className="pl-9" placeholder="搜索包名" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" disabled={!selected.length || busy} onClick={batchInstall}>
|
||||
批量安装命令
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={!filtered.length || busy} onClick={reinstallAll}>
|
||||
<RotateCw size={14} />
|
||||
全部重装
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-b border-[var(--border)] bg-[var(--bg-tertiary)]/40 px-4 py-2 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">
|
||||
<Checkbox checked={allSelected} onCheckedChange={(v) => toggleAll(v)} />
|
||||
<span className="hidden w-12 sm:block">序号</span>
|
||||
<span className="flex-1">包名</span>
|
||||
<span className="w-24">版本</span>
|
||||
<span className="hidden w-40 md:block">备注</span>
|
||||
<span className="w-36 text-right">操作</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-sm text-[var(--text-muted)]">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-[var(--text-muted)]">
|
||||
<Package className="mx-auto mb-2 opacity-50" size={28} />
|
||||
{keyword ? '无匹配结果' : '暂无依赖包'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{filtered.map((dep, index) => (
|
||||
<div
|
||||
key={dep.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-2.5 text-sm',
|
||||
selected.includes(dep.id) && 'bg-[var(--bg-tertiary)]/50',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.includes(dep.id)}
|
||||
onCheckedChange={(v) => toggleOne(dep.id, v)}
|
||||
/>
|
||||
<span className="hidden w-12 font-mono text-xs text-[var(--text-muted)] sm:block">{index + 1}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{dep.name}</div>
|
||||
<div className="truncate text-xs text-[var(--text-muted)] md:hidden">{dep.remark || '-'}</div>
|
||||
</div>
|
||||
<span className="w-24 truncate font-mono text-xs text-[var(--text-muted)]">{dep.version || '-'}</span>
|
||||
<span className="hidden w-40 truncate text-xs text-[var(--text-muted)] md:block">{dep.remark || '-'}</span>
|
||||
<div className="flex w-36 justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setLogName(dep.name)
|
||||
setLogContent(dep.log || '暂无日志')
|
||||
setLogOpen(true)
|
||||
}}
|
||||
>
|
||||
日志
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => reinstall(dep)}>
|
||||
重装
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
setDeleteTarget(dep)
|
||||
setForceDelete(false)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={installOpen}
|
||||
title="安装依赖包"
|
||||
onClose={() => setInstallOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setInstallOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={installPackage} disabled={busy}>
|
||||
{busy ? '提交中...' : '安装'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input placeholder="包名 *" value={pkgName} onChange={(e) => setPkgName(e.target.value)} />
|
||||
<Input placeholder="版本(可选)" value={pkgVersion} onChange={(e) => setPkgVersion(e.target.value)} />
|
||||
<Input placeholder="备注(可选)" value={pkgRemark} onChange={(e) => setPkgRemark(e.target.value)} />
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
目标环境: {language}
|
||||
{langVersion ? `@${langVersion}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={importOpen}
|
||||
title="导入依赖清单"
|
||||
onClose={() => setImportOpen(false)}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setImportOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={importManifest} disabled={busy}>
|
||||
{busy ? '解析中...' : '导入'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder={language === 'python' ? 'requirements.txt 内容' : 'package.json dependencies 或 package list'}
|
||||
value={manifest}
|
||||
onChange={(e) => setManifest(e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={importDb} onCheckedChange={setImportDb} />
|
||||
同时写入依赖数据库
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={!!deleteTarget}
|
||||
title="卸载依赖"
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="danger" onClick={uninstall} disabled={busy}>
|
||||
确认卸载
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
确认卸载 <strong>{deleteTarget?.name}</strong>?
|
||||
</p>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={forceDelete} onCheckedChange={setForceDelete} />
|
||||
强制删除记录(跳过实际卸载)
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={logOpen} title={logName || '日志'} onClose={() => setLogOpen(false)} size="lg">
|
||||
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-md bg-[var(--bg-primary)] p-3 font-mono text-xs text-[var(--text-secondary)]">
|
||||
{logContent || '(无内容)'}
|
||||
</pre>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import Editor from '@monaco-editor/react'
|
||||
import {
|
||||
Archive,
|
||||
Copy,
|
||||
Download,
|
||||
FilePlus2,
|
||||
FolderInput,
|
||||
FolderPlus,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import type { FileNode, MiseLanguage } from '@/api'
|
||||
import { api } from '@/api'
|
||||
import { useFileTree } from '@/api/hooks'
|
||||
import { Button, Card, Input, Modal, PageHeader, Select } from '@/components/ui'
|
||||
import { PATHS } from '@/constants'
|
||||
import { useTheme } from '@/context/ThemeContext'
|
||||
import { copyToClipboard } from '@/lib/format'
|
||||
import { buildExecutionCommand } from '@/lib/repo-parser'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function flattenTree(nodes: FileNode[], depth = 0): Array<FileNode & { depth: number }> {
|
||||
const result: Array<FileNode & { depth: number }> = []
|
||||
for (const node of nodes || []) {
|
||||
result.push({ ...node, depth })
|
||||
if (node.children?.length) result.push(...flattenTree(node.children, depth + 1))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function guessLanguage(path: string) {
|
||||
const ext = path.split('.').pop()?.toLowerCase() || ''
|
||||
const map: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
py: 'python',
|
||||
go: 'go',
|
||||
sh: 'shell',
|
||||
bash: 'shell',
|
||||
json: 'json',
|
||||
yml: 'yaml',
|
||||
yaml: 'yaml',
|
||||
md: 'markdown',
|
||||
html: 'html',
|
||||
css: 'css',
|
||||
php: 'php',
|
||||
rb: 'ruby',
|
||||
rs: 'rust',
|
||||
}
|
||||
return map[ext] || 'shell'
|
||||
}
|
||||
|
||||
export default function ScriptEditor() {
|
||||
const { theme } = useTheme()
|
||||
const navigate = useNavigate()
|
||||
const search = useSearch({ strict: false }) as { file?: string }
|
||||
const { data: treeData, isLoading, refetch, isFetching } = useFileTree()
|
||||
const tree = Array.isArray(treeData) ? treeData : []
|
||||
const [selectedPath, setSelectedPath] = useState(search.file || '')
|
||||
const [content, setContent] = useState('')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [loadingContent, setLoadingContent] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [runOpen, setRunOpen] = useState(false)
|
||||
const [langs, setLangs] = useState<MiseLanguage[]>([])
|
||||
const [runPlugin, setRunPlugin] = useState('')
|
||||
const [runVersion, setRunVersion] = useState('')
|
||||
const [scriptsDir, setScriptsDir] = useState(PATHS.SCRIPTS_DIR)
|
||||
const [pathOpOpen, setPathOpOpen] = useState(false)
|
||||
const [pathOpMode, setPathOpMode] = useState<'move' | 'copy'>('move')
|
||||
const [pathOpTarget, setPathOpTarget] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
const lastLoadedPath = useRef('')
|
||||
|
||||
const flat = useMemo(() => flattenTree(tree), [tree])
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter.trim()) return flat
|
||||
const q = filter.toLowerCase()
|
||||
return flat.filter((n) => n.path.toLowerCase().includes(q) || n.name.toLowerCase().includes(q))
|
||||
}, [flat, filter])
|
||||
|
||||
const pluginVersions = useMemo(() => {
|
||||
const map: Record<string, string[]> = {}
|
||||
langs.forEach((l) => {
|
||||
if (!l.plugin) return
|
||||
if (!map[l.plugin]) map[l.plugin] = []
|
||||
if (l.version && !map[l.plugin]!.includes(l.version)) map[l.plugin]!.push(l.version)
|
||||
})
|
||||
return map
|
||||
}, [langs])
|
||||
|
||||
useEffect(() => {
|
||||
if (search.file && search.file !== selectedPath) setSelectedPath(search.file)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search.file])
|
||||
|
||||
useEffect(() => {
|
||||
api.settings
|
||||
.getPaths()
|
||||
.then((p) => setScriptsDir(p?.scripts_dir || PATHS.SCRIPTS_DIR))
|
||||
.catch(() => setScriptsDir(PATHS.SCRIPTS_DIR))
|
||||
api.mise
|
||||
.list()
|
||||
.then((list) => setLangs(Array.isArray(list) ? list : []))
|
||||
.catch(() => setLangs([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPath) return
|
||||
const node = flat.find((f) => f.path === selectedPath)
|
||||
if (node?.isDir) return
|
||||
if (lastLoadedPath.current === selectedPath && content) return
|
||||
let cancelled = false
|
||||
setLoadingContent(true)
|
||||
api.files
|
||||
.getContent(selectedPath)
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setContent(res.content || '')
|
||||
setDirty(false)
|
||||
lastLoadedPath.current = selectedPath
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setContent('')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingContent(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedPath, flat])
|
||||
|
||||
function selectPath(path: string) {
|
||||
if (dirty && selectedPath && selectedPath !== path) {
|
||||
if (!confirm('当前文件未保存,确认切换?')) return
|
||||
}
|
||||
setSelectedPath(path)
|
||||
navigate({ to: '/editor', search: { file: path || undefined } })
|
||||
}
|
||||
|
||||
function openRunDialog() {
|
||||
if (!selectedPath || flat.find((f) => f.path === selectedPath)?.isDir) return
|
||||
const ext = selectedPath.split('.').pop()?.toLowerCase() || ''
|
||||
const extToLang: Record<string, string> = { py: 'python', js: 'node', mjs: 'node', ts: 'node', go: 'go' }
|
||||
const inferred = extToLang[ext] || Object.keys(pluginVersions)[0] || ''
|
||||
setRunPlugin(inferred)
|
||||
setRunVersion(pluginVersions[inferred]?.[0] || '')
|
||||
setRunOpen(true)
|
||||
}
|
||||
|
||||
function confirmRun() {
|
||||
if (!selectedPath) return
|
||||
const envs = runPlugin && runVersion ? [{ plugin: runPlugin, version: runVersion }] : []
|
||||
const cmd = buildExecutionCommand(selectedPath, envs, scriptsDir)
|
||||
try {
|
||||
sessionStorage.setItem('taskpool_terminal_cmd', cmd)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setRunOpen(false)
|
||||
navigate({ to: '/terminal' })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!selectedPath) return
|
||||
try {
|
||||
await api.files.saveContent(selectedPath, content)
|
||||
setDirty(false)
|
||||
setMessage('已保存')
|
||||
toast.success('已保存')
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '保存失败')
|
||||
toast.error(e?.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function currentDir() {
|
||||
const node = flat.find((f) => f.path === selectedPath)
|
||||
if (node?.isDir) return selectedPath
|
||||
if (selectedPath.includes('/')) return selectedPath.slice(0, selectedPath.lastIndexOf('/'))
|
||||
return ''
|
||||
}
|
||||
|
||||
async function createFile(isDir: boolean) {
|
||||
if (!newName.trim()) return
|
||||
const base = currentDir()
|
||||
const path = base ? `${base}/${newName.trim()}` : newName.trim()
|
||||
try {
|
||||
await api.files.create(path, isDir)
|
||||
setNewName('')
|
||||
refetch()
|
||||
if (!isDir) selectPath(path)
|
||||
setMessage(isDir ? '目录已创建' : '文件已创建')
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSelected() {
|
||||
if (!selectedPath) return
|
||||
if (!confirm(`确认删除 ${selectedPath}?`)) return
|
||||
try {
|
||||
await api.files.delete(selectedPath)
|
||||
setSelectedPath('')
|
||||
setContent('')
|
||||
setDirty(false)
|
||||
refetch()
|
||||
setMessage('已删除')
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function renameSelected() {
|
||||
if (!selectedPath || !renameValue.trim()) return
|
||||
const parent = selectedPath.includes('/') ? selectedPath.slice(0, selectedPath.lastIndexOf('/')) : ''
|
||||
const newPath = parent ? `${parent}/${renameValue.trim()}` : renameValue.trim()
|
||||
try {
|
||||
await api.files.rename(selectedPath, newPath)
|
||||
lastLoadedPath.current = ''
|
||||
selectPath(newPath)
|
||||
setRenameOpen(false)
|
||||
refetch()
|
||||
setMessage('已重命名')
|
||||
toast.success('已重命名')
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '重命名失败')
|
||||
toast.error(e?.message || '重命名失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openPathOp(mode: 'move' | 'copy') {
|
||||
if (!selectedPath) return
|
||||
setPathOpMode(mode)
|
||||
setPathOpTarget(selectedPath)
|
||||
setPathOpOpen(true)
|
||||
}
|
||||
|
||||
async function confirmPathOp() {
|
||||
if (!selectedPath || !pathOpTarget.trim()) return
|
||||
try {
|
||||
if (pathOpMode === 'move') {
|
||||
await api.files.move(selectedPath, pathOpTarget.trim())
|
||||
lastLoadedPath.current = ''
|
||||
selectPath(pathOpTarget.trim())
|
||||
setMessage('已移动')
|
||||
toast.success('已移动')
|
||||
} else {
|
||||
await api.files.copy(selectedPath, pathOpTarget.trim())
|
||||
setMessage('已复制')
|
||||
toast.success('已复制')
|
||||
}
|
||||
setPathOpOpen(false)
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || (pathOpMode === 'move' ? '移动失败' : '复制失败'))
|
||||
toast.error(e?.message || (pathOpMode === 'move' ? '移动失败' : '复制失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFiles(files: FileList | null, asFolder = false) {
|
||||
if (!files?.length) return
|
||||
const target = currentDir()
|
||||
try {
|
||||
if (asFolder) {
|
||||
const paths: string[] = []
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i]!
|
||||
paths.push((f as any).webkitRelativePath || f.name)
|
||||
}
|
||||
await api.files.uploadFiles(files, paths, target || undefined)
|
||||
} else if (files.length === 1 && /\.(zip|tar|gz|tgz)$/i.test(files[0]!.name)) {
|
||||
await api.files.uploadArchive(files[0]!, target || undefined)
|
||||
} else {
|
||||
const paths = Array.from(files).map((f) => f.name)
|
||||
await api.files.uploadFiles(files, paths, target || undefined)
|
||||
}
|
||||
refetch()
|
||||
setMessage('上传完成')
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="脚本编辑"
|
||||
description="浏览脚本目录并在线编辑"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={save} disabled={!selectedPath || !dirty}>
|
||||
<Save size={14} />
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<div className="mb-3 rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)] px-3 py-2 text-sm">{message}</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[300px_1fr]">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="space-y-2 border-b border-[var(--border)] p-3">
|
||||
<Input placeholder="过滤文件" value={filter} onChange={(e) => setFilter(e.target.value)} />
|
||||
<Input placeholder="新建名称" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => createFile(false)}>
|
||||
<FilePlus2 size={14} />
|
||||
文件
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => createFile(true)}>
|
||||
<FolderPlus size={14} />
|
||||
目录
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!selectedPath}
|
||||
onClick={() => {
|
||||
setRenameValue(selectedPath.split('/').pop() || '')
|
||||
setRenameOpen(true)
|
||||
}}
|
||||
>
|
||||
重命名
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={!selectedPath} onClick={() => openPathOp('move')}>
|
||||
<FolderInput size={14} />
|
||||
移动
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={!selectedPath} onClick={() => openPathOp('copy')}>
|
||||
<Copy size={14} />
|
||||
复制
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={removeSelected} disabled={!selectedPath}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={(e) => uploadFiles(e.target.files)} />
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
// @ts-expect-error webkitdirectory
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
onChange={(e) => uploadFiles(e.target.files, true)}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload size={14} />
|
||||
上传
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => folderInputRef.current?.click()}>
|
||||
上传目录
|
||||
</Button>
|
||||
{selectedPath && !flat.find((f) => f.path === selectedPath)?.isDir ? (
|
||||
<a href={api.files.download(selectedPath)} className="inline-flex">
|
||||
<Button size="sm" variant="secondary">
|
||||
<Download size={14} />
|
||||
下载
|
||||
</Button>
|
||||
</a>
|
||||
) : null}
|
||||
{selectedPath ? (
|
||||
<a href={api.files.downloadZip(selectedPath)} className="inline-flex">
|
||||
<Button size="sm" variant="secondary">
|
||||
<Archive size={14} />
|
||||
打包下载
|
||||
</Button>
|
||||
</a>
|
||||
) : null}
|
||||
{selectedPath && !flat.find((f) => f.path === selectedPath)?.isDir ? (
|
||||
<Button size="sm" variant="secondary" onClick={openRunDialog}>
|
||||
<Play size={14} />
|
||||
运行
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[70vh] overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-sm text-[var(--text-muted)]">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-4 text-sm text-[var(--text-muted)]">暂无文件</div>
|
||||
) : (
|
||||
filtered.map((node) => (
|
||||
<button
|
||||
key={node.path}
|
||||
className={cn(
|
||||
'block w-full truncate px-3 py-1.5 text-left text-sm hover:bg-[var(--bg-hover)]',
|
||||
selectedPath === node.path && 'bg-[var(--bg-tertiary)]',
|
||||
)}
|
||||
style={{ paddingLeft: 12 + node.depth * 14 }}
|
||||
onClick={() => setSelectedPath(node.path)}
|
||||
title={node.path}
|
||||
>
|
||||
{node.isDir ? '📁 ' : '📄 '}
|
||||
{node.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-2 text-sm text-[var(--text-muted)]">
|
||||
<span className="truncate">{selectedPath || '未选择文件'}{dirty ? ' · 未保存' : ''}</span>
|
||||
{selectedPath && !flat.find((f) => f.path === selectedPath)?.isDir ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
await copyToClipboard(content)
|
||||
setMessage('内容已复制')
|
||||
}}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="h-[70vh]">
|
||||
{loadingContent ? (
|
||||
<div className="p-4 text-sm text-[var(--text-muted)]">加载内容...</div>
|
||||
) : selectedPath && !flat.find((f) => f.path === selectedPath)?.isDir ? (
|
||||
<Editor
|
||||
height="100%"
|
||||
theme={theme === 'light' ? 'light' : 'vs-dark'}
|
||||
language={guessLanguage(selectedPath)}
|
||||
value={content}
|
||||
onChange={(v) => {
|
||||
setContent(v || '')
|
||||
setDirty(true)
|
||||
}}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
wordWrap: 'on',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-sm text-[var(--text-muted)]">
|
||||
{selectedPath ? '已选择目录,可在此目录下新建文件或上传' : '从左侧选择文件开始编辑'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={renameOpen}
|
||||
title="重命名"
|
||||
onClose={() => setRenameOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setRenameOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={renameSelected}>确定</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input value={renameValue} onChange={(e) => setRenameValue(e.target.value)} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={pathOpOpen}
|
||||
title={pathOpMode === 'move' ? '移动到' : '复制到'}
|
||||
onClose={() => setPathOpOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setPathOpOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={confirmPathOp}>确定</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-[var(--text-muted)]">源路径: {selectedPath}</div>
|
||||
<Input value={pathOpTarget} onChange={(e) => setPathOpTarget(e.target.value)} placeholder="目标路径" />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={runOpen}
|
||||
title="运行脚本"
|
||||
onClose={() => setRunOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setRunOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={confirmRun}>
|
||||
<Play size={14} />
|
||||
去终端运行
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-[var(--text-secondary)]">{selectedPath}</div>
|
||||
<Select value={runPlugin} onChange={(e) => {
|
||||
const plugin = e.target.value
|
||||
setRunPlugin(plugin)
|
||||
setRunVersion(pluginVersions[plugin]?.[0] || '')
|
||||
}}>
|
||||
<option value="">不指定运行时</option>
|
||||
{Object.keys(pluginVersions).map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</Select>
|
||||
{runPlugin ? (
|
||||
<Select value={runVersion} onChange={(e) => setRunVersion(e.target.value)}>
|
||||
{(pluginVersions[runPlugin] || []).map((v) => (
|
||||
<option key={v} value={v}>{v}</option>
|
||||
))}
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Eye, EyeOff, Link2, Pencil, Plus, RefreshCw, Search, Share2, Shield, Terminal, Trash2 } from 'lucide-react'
|
||||
import type { EnvVar, Task } from '@/api'
|
||||
import { api } from '@/api'
|
||||
import { getNodes, type InterconnectNode } from '@/api/interconnect'
|
||||
import * as interconnect from '@/api/interconnect'
|
||||
import { useCreateEnv, useDeleteEnv, useEnvs, useUpdateEnv } from '@/api/hooks'
|
||||
import { TagInput } from '@/components/business/TagInput'
|
||||
import { Button, Card, Input, Modal, PageHeader, Select, Switch, Tabs, Textarea } from '@/components/ui'
|
||||
import { ENV_TYPE } from '@/constants'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
type EnvForm = {
|
||||
name: string
|
||||
value: string
|
||||
remark: string
|
||||
type: string
|
||||
tags: string
|
||||
enabled: boolean
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
const emptyForm = (type: string = ENV_TYPE.NORMAL): EnvForm => ({
|
||||
name: '',
|
||||
value: '',
|
||||
remark: '',
|
||||
type,
|
||||
tags: '',
|
||||
enabled: true,
|
||||
hidden: type === ENV_TYPE.SECRET,
|
||||
})
|
||||
|
||||
export default function Environments() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const n = Number(localStorage.getItem('envs_page_size') || '20')
|
||||
return [10, 20, 50, 100].includes(n) ? n : 20
|
||||
})
|
||||
const [name, setName] = useState('')
|
||||
const [debouncedName, setDebouncedName] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const [type, setType] = useState<string>(ENV_TYPE.NORMAL)
|
||||
const [showValues, setShowValues] = useState<Record<string, boolean>>({})
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<EnvVar | null>(null)
|
||||
const [form, setForm] = useState<EnvForm>(emptyForm())
|
||||
const [secretOk, setSecretOk] = useState(true)
|
||||
const [showSecretTip, setShowSecretTip] = useState(() => localStorage.getItem('bhp_hide_secret_tip') !== 'true')
|
||||
const [tasksOpen, setTasksOpen] = useState(false)
|
||||
const [relatedTasks, setRelatedTasks] = useState<Task[]>([])
|
||||
const [relatedName, setRelatedName] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [syncOpen, setSyncOpen] = useState(false)
|
||||
const [syncEnv, setSyncEnv] = useState<EnvVar | null>(null)
|
||||
const [syncNodes, setSyncNodes] = useState<InterconnectNode[]>([])
|
||||
const [syncSelected, setSyncSelected] = useState<string[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useEnvs({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
name: debouncedName || undefined,
|
||||
type,
|
||||
tags: tags || undefined,
|
||||
})
|
||||
const createEnv = useCreateEnv()
|
||||
const updateEnv = useUpdateEnv()
|
||||
const deleteEnv = useDeleteEnv()
|
||||
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const total = data?.total || 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const title = useMemo(() => (type === ENV_TYPE.SECRET ? '机密管理' : '环境变量'), [type])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('envs_page_size', String(pageSize))
|
||||
}, [pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setDebouncedName(name)
|
||||
setPage(1)
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [name])
|
||||
|
||||
useEffect(() => {
|
||||
if (type !== ENV_TYPE.SECRET) return
|
||||
api.env
|
||||
.secretStatus()
|
||||
.then(setSecretOk)
|
||||
.catch(() => setSecretOk(true))
|
||||
}, [type])
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setForm(emptyForm(type))
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(env: EnvVar) {
|
||||
setEditing(env)
|
||||
setForm({
|
||||
name: env.name,
|
||||
value: env.value || '',
|
||||
remark: env.remark || '',
|
||||
type: env.type || type,
|
||||
tags: env.tags || '',
|
||||
enabled: env.enabled !== false,
|
||||
hidden: !!env.hidden,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim()) {
|
||||
setMessage('请输入名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload: Partial<EnvVar> = {
|
||||
name: form.name.trim(),
|
||||
value: form.value,
|
||||
remark: form.remark,
|
||||
type: form.type,
|
||||
tags: form.tags,
|
||||
enabled: form.enabled,
|
||||
hidden: form.type === ENV_TYPE.SECRET ? true : form.hidden,
|
||||
}
|
||||
if (editing) {
|
||||
await updateEnv.mutateAsync({ id: editing.id, data: { ...editing, ...payload } })
|
||||
} else {
|
||||
await createEnv.mutateAsync(payload)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
setMessage(editing ? '已更新' : '已创建')
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(env: EnvVar) {
|
||||
await updateEnv.mutateAsync({ id: env.id, data: { ...env, enabled: !env.enabled } })
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('确认删除该变量?')) return
|
||||
try {
|
||||
const res = await deleteEnv.mutateAsync(id)
|
||||
if (res && typeof res === 'object' && 'code' in res && (res as any).code !== 200) {
|
||||
throw new Error((res as any).msg || '删除失败')
|
||||
}
|
||||
toast.success('已删除')
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '删除失败')
|
||||
toast.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function openTasks(env: EnvVar) {
|
||||
setRelatedName(env.name)
|
||||
setTasksOpen(true)
|
||||
try {
|
||||
const list = await api.env.tasks(env.id)
|
||||
setRelatedTasks(Array.isArray(list) ? list : [])
|
||||
} catch {
|
||||
setRelatedTasks([])
|
||||
}
|
||||
}
|
||||
|
||||
async function openSyncDialog(env: EnvVar) {
|
||||
setSyncEnv(env)
|
||||
setSyncSelected([])
|
||||
setSyncOpen(true)
|
||||
try {
|
||||
const nodes = await getNodes()
|
||||
setSyncNodes(Array.isArray(nodes) ? nodes : [])
|
||||
} catch {
|
||||
setSyncNodes([])
|
||||
setMessage('获取互联节点失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSyncEnv() {
|
||||
if (!syncEnv || syncSelected.length === 0) {
|
||||
setMessage('请选择至少一个目标节点')
|
||||
return
|
||||
}
|
||||
setSyncing(true)
|
||||
try {
|
||||
await interconnect.syncEnv(syncSelected, [syncEnv])
|
||||
setMessage('变量已同步到所选节点')
|
||||
toast.success('变量同步成功')
|
||||
setSyncOpen(false)
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '同步失败')
|
||||
toast.error(e?.message || '同步失败')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
function maskValue(value: string) {
|
||||
return '•'.repeat(Math.min(value?.length || 8, 20))
|
||||
}
|
||||
|
||||
function closeSecretTip() {
|
||||
setShowSecretTip(false)
|
||||
localStorage.setItem('bhp_hide_secret_tip', 'true')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={type === ENV_TYPE.SECRET ? '管理加密敏感数据' : '管理脚本执行时的环境变量'}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={openCreate} disabled={type === ENV_TYPE.SECRET && !secretOk}>
|
||||
<Plus size={14} />
|
||||
新建{type === ENV_TYPE.SECRET ? '机密' : '变量'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<div className="mb-3 rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)] px-3 py-2 text-sm">{message}</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Tabs
|
||||
value={type}
|
||||
onValueChange={(v) => {
|
||||
setType(v)
|
||||
setPage(1)
|
||||
}}
|
||||
items={[
|
||||
{ value: ENV_TYPE.NORMAL, label: '变量' },
|
||||
{ value: ENV_TYPE.SECRET, label: '机密' },
|
||||
]}
|
||||
/>
|
||||
<div className="relative min-w-[180px] flex-1 max-w-xs">
|
||||
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<Input className="pl-9" placeholder="搜索名称" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="min-w-[160px] max-w-xs flex-1">
|
||||
<TagInput value={tags} onChange={setTags} placeholder="标签过滤(回车添加)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === ENV_TYPE.SECRET && secretOk && showSecretTip ? (
|
||||
<div className="mb-4 flex items-start justify-between gap-2 rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
<span>
|
||||
<Shield size={14} className="mr-1 inline" />
|
||||
机密仅在正式任务执行时解密注入,测试运行与临时终端无法获取。
|
||||
</span>
|
||||
<button className="text-xs opacity-70" onClick={closeSecretTip}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === ENV_TYPE.SECRET && !secretOk ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Shield className="mx-auto mb-3 text-red-400" size={32} />
|
||||
<div className="font-medium">未配置加密密钥</div>
|
||||
<p className="mt-2 text-sm text-[var(--text-muted)]">
|
||||
请在启动时配置 <code className="font-mono">TASKPOOL_SECRET_KEY</code> 环境变量后再使用机密功能。
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="border-b border-[var(--border)] px-4 py-3 text-sm text-[var(--text-muted)]">
|
||||
共 {total} 条 · 第 {page}/{totalPages} 页
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-[var(--text-muted)]">加载中...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-[var(--text-muted)]">
|
||||
{type === ENV_TYPE.SECRET ? <Shield className="mx-auto mb-2 opacity-50" size={28} /> : <Terminal className="mx-auto mb-2 opacity-50" size={28} />}
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{items.map((env) => {
|
||||
const visible = showValues[env.id] ?? !env.hidden
|
||||
return (
|
||||
<div key={env.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{env.name}</span>
|
||||
{!env.enabled ? (
|
||||
<span className="rounded bg-zinc-500/15 px-1.5 py-0.5 text-xs text-zinc-400">禁用</span>
|
||||
) : null}
|
||||
{env.tags
|
||||
? env.tags
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((t) => (
|
||||
<span key={t} className="rounded bg-[var(--bg-tertiary)] px-1.5 py-0.5 text-xs text-[var(--text-muted)]">
|
||||
{t.trim()}
|
||||
</span>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
<div className="mt-1 break-all font-mono text-xs text-[var(--text-secondary)]">
|
||||
{visible ? env.value || '(空)' : maskValue(env.value || '')}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
{env.remark || '-'} · {formatDateTime(env.updated_at || env.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Switch checked={env.enabled !== false} onCheckedChange={() => toggleEnabled(env)} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowValues((s) => ({ ...s, [env.id]: !visible }))}
|
||||
>
|
||||
{visible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => openTasks(env)} title="关联任务">
|
||||
<Link2 size={14} />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => openSyncDialog(env)} title="同步到节点">
|
||||
<Share2 size={14} />
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => openEdit(env)}>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => handleDelete(env.id)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--border)] px-4 py-3">
|
||||
<span className="text-sm text-[var(--text-muted)]">
|
||||
共 {total} 条 · 第 {page}/{totalPages} 页
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
className="w-[110px]"
|
||||
value={String(pageSize)}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value) || 20)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<option value="10">10 条/页</option>
|
||||
<option value="20">20 条/页</option>
|
||||
<option value="50">50 条/页</option>
|
||||
<option value="100">100 条/页</option>
|
||||
</Select>
|
||||
<Button variant="secondary" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={dialogOpen}
|
||||
title={editing ? '编辑变量' : '新建变量'}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={save} disabled={createEnv.isPending || updateEnv.isPending}>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input placeholder="名称 *" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
|
||||
<Textarea placeholder="值" value={form.value} onChange={(e) => setForm((f) => ({ ...f, value: e.target.value }))} />
|
||||
<Input placeholder="备注" value={form.remark} onChange={(e) => setForm((f) => ({ ...f, remark: e.target.value }))} />
|
||||
<TagInput value={form.tags} onChange={(v) => setForm((f) => ({ ...f, tags: v }))} placeholder="标签" />
|
||||
<label className="flex items-center justify-between text-sm">
|
||||
<span>启用</span>
|
||||
<Switch checked={form.enabled} onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))} />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={tasksOpen} title={`关联任务 · ${relatedName}`} onClose={() => setTasksOpen(false)} size="lg">
|
||||
{relatedTasks.length === 0 ? (
|
||||
<div className="text-sm text-[var(--text-muted)]">暂无关联任务</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{relatedTasks.map((t) => (
|
||||
<div key={t.id} className="py-2 text-sm">
|
||||
<div className="font-medium">{t.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{t.id}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={syncOpen}
|
||||
title="同步变量到节点"
|
||||
onClose={() => setSyncOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setSyncOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={confirmSyncEnv} disabled={syncing || syncSelected.length === 0}>
|
||||
{syncing ? '同步中...' : '确认同步'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
将变量 <span className="font-medium">{syncEnv?.name}</span> 同步到所选子节点
|
||||
</p>
|
||||
{syncNodes.length === 0 ? (
|
||||
<div className="text-sm text-[var(--text-muted)]">暂无可用节点,请先在互联管理中添加</div>
|
||||
) : (
|
||||
<div className="max-h-56 space-y-2 overflow-y-auto">
|
||||
{syncNodes.map((node) => {
|
||||
const checked = syncSelected.includes(node.id)
|
||||
return (
|
||||
<label key={node.id} className="flex cursor-pointer items-center gap-2 rounded border border-[var(--border)] px-3 py-2 text-sm hover:bg-[var(--bg-hover)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
setSyncSelected((prev) => (e.target.checked ? [...prev, node.id] : prev.filter((id) => id !== node.id)))
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{node.name}</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">{node.status || 'unknown'}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ErrorComponentProps } from '@tanstack/react-router'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { AlertTriangle, Home, RefreshCw } from 'lucide-react'
|
||||
import { Button, Card } from '@/components/ui'
|
||||
|
||||
export function ErrorPage({ error, reset }: ErrorComponentProps) {
|
||||
const navigate = useNavigate()
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: '发生未知错误'
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--bg-primary)] p-6 text-[var(--text-primary)]">
|
||||
<Card className="w-full max-w-md p-8 text-center shadow-[var(--shadow-md)]">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--danger)_14%,transparent)] text-[var(--danger)]">
|
||||
<AlertTriangle size={28} strokeWidth={1.5} />
|
||||
</div>
|
||||
<h1 className="mb-2 text-xl font-semibold tracking-tight">页面出错了</h1>
|
||||
<p className="mb-1 text-sm text-[var(--text-muted)]">渲染或加载过程中发生异常</p>
|
||||
<div className="mb-6 rounded-md border border-[var(--border)] bg-[var(--bg-primary)] px-3 py-2 text-left font-mono text-xs break-all text-[var(--danger)]">
|
||||
{message}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="secondary" onClick={() => reset()}>
|
||||
<RefreshCw size={14} />
|
||||
重试
|
||||
</Button>
|
||||
<Button onClick={() => navigate({ to: '/' })}>
|
||||
<Home size={14} />
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorPage
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearch } from '@tanstack/react-router'
|
||||
import { GitBranch, RefreshCw, Search, Terminal, Trash2 } from 'lucide-react'
|
||||
import { api, type TaskLog } from '@/api'
|
||||
import { useLogs } from '@/api/hooks'
|
||||
import { LogDetailPanel } from '@/components/business/LogDetailPanel'
|
||||
import { StatusDot } from '@/components/business/StatusDot'
|
||||
import { Button, Card, Input, Modal, PageHeader, Select } from '@/components/ui'
|
||||
import { TASK_EVENTS, TASK_STATUS_TEXT, TASK_TYPE } from '@/constants'
|
||||
import { useEventBus } from '@/hooks/useEventBus'
|
||||
import { useLogStream } from '@/hooks/useLogStream'
|
||||
import { formatDateTime, formatDuration } from '@/lib/format'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function History() {
|
||||
const search = useSearch({ strict: false }) as { status?: string; task_name?: string; task_id?: string }
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const n = Number(localStorage.getItem('history_page_size') || '20')
|
||||
return [10, 20, 50, 100].includes(n) ? n : 20
|
||||
})
|
||||
const [status, setStatus] = useState(search.status || 'all')
|
||||
const [taskName, setTaskName] = useState(search.task_name || '')
|
||||
const [debouncedName, setDebouncedName] = useState(search.task_name || '')
|
||||
const [taskId, setTaskId] = useState(search.task_id || '')
|
||||
const [selected, setSelected] = useState<TaskLog | null>(null)
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [clearOpen, setClearOpen] = useState(false)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [stopping, setStopping] = useState(false)
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useLogs({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
status: status === 'all' ? undefined : status,
|
||||
task_name: debouncedName || undefined,
|
||||
task_id: taskId || undefined,
|
||||
})
|
||||
const { content, loading, liveLog } = useLogStream(selected)
|
||||
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const total = data?.total || 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('history_page_size', String(pageSize))
|
||||
}, [pageSize])
|
||||
|
||||
const onTaskEvent = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
useEventBus(
|
||||
[TASK_EVENTS.SUCCESS, TASK_EVENTS.FAILED, TASK_EVENTS.TIMEOUT, TASK_EVENTS.RUNNING, TASK_EVENTS.QUEUED, TASK_EVENTS.CANCELLED],
|
||||
onTaskEvent,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (search.status) setStatus(search.status)
|
||||
if (search.task_name) {
|
||||
setTaskName(search.task_name)
|
||||
setDebouncedName(search.task_name)
|
||||
}
|
||||
if (search.task_id !== undefined) setTaskId(search.task_id || '')
|
||||
}, [search.status, search.task_name, search.task_id])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setDebouncedName(taskName)
|
||||
setPage(1)
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [taskName])
|
||||
|
||||
async function stopTask() {
|
||||
const id = liveLog?.id || selected?.id
|
||||
if (!id) return
|
||||
setStopping(true)
|
||||
try {
|
||||
await api.tasks.stop(id)
|
||||
toast.success('已发送停止指令')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '停止失败')
|
||||
} finally {
|
||||
setStopping(false)
|
||||
refetch()
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLogs() {
|
||||
try {
|
||||
await api.logs.clear()
|
||||
setClearOpen(false)
|
||||
setSelected(null)
|
||||
refetch()
|
||||
toast.success('日志已清空')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '清空失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLog() {
|
||||
if (!deleteId) return
|
||||
try {
|
||||
await api.logs.delete(deleteId)
|
||||
if (selected?.id === deleteId) setSelected(null)
|
||||
setDeleteId(null)
|
||||
refetch()
|
||||
toast.success('记录已删除')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const detailLog = liveLog || selected
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="执行历史"
|
||||
description="查看任务执行记录和日志"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setClearOpen(true)}>
|
||||
<Trash2 size={14} />
|
||||
清空日志
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<Input className="pl-9" placeholder="搜索任务..." value={taskName} onChange={(e) => setTaskName(e.target.value)} />
|
||||
</div>
|
||||
<Select
|
||||
className="max-w-[160px]"
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<option value="all">所有状态</option>
|
||||
{Object.entries(TASK_STATUS_TEXT).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{taskId ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setTaskId('')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
清除任务筛选: {taskId.slice(0, 8)}...
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr] lg:min-h-[520px]">
|
||||
<Card className={cn('flex min-h-[320px] flex-col overflow-hidden', selected ? 'hidden lg:flex' : 'flex')}>
|
||||
<div className="hidden border-b border-[var(--border)] bg-[var(--bg-tertiary)]/50 px-4 py-2 text-xs text-[var(--text-muted)] sm:grid sm:grid-cols-[56px_40px_1fr_80px_140px_36px] sm:gap-2">
|
||||
<span>序号</span>
|
||||
<span className="text-center">类型</span>
|
||||
<span>任务</span>
|
||||
<span className="text-right">耗时</span>
|
||||
<span className="text-right">时间</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className="flex-1 divide-y divide-[var(--border)] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-[var(--text-muted)]">加载中...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-6 text-[var(--text-muted)]">暂无日志</div>
|
||||
) : (
|
||||
items.map((log, index) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={cn(
|
||||
'cursor-pointer px-3 py-2.5 transition hover:bg-[var(--bg-hover)] sm:grid sm:grid-cols-[56px_40px_1fr_80px_140px_36px] sm:items-center sm:gap-2',
|
||||
selected?.id === log.id && 'bg-[var(--bg-tertiary)]',
|
||||
)}
|
||||
onClick={() => setSelected(log)}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:contents">
|
||||
<span className="inline-flex items-center gap-2 text-[11px] tabular-nums text-[var(--text-muted)]">
|
||||
<StatusDot state={log.status} />#{total - (page - 1) * 20 - index}
|
||||
</span>
|
||||
<span className="hidden justify-center sm:flex" title={log.task_type === TASK_TYPE.REPO ? '仓库同步' : '普通任务'}>
|
||||
{log.task_type === TASK_TYPE.REPO ? <GitBranch size={14} /> : <Terminal size={14} />}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{log.task_name || log.task_id}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] text-[var(--text-muted)]">{log.command}</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--text-muted)] sm:hidden">
|
||||
{TASK_STATUS_TEXT[log.status] || log.status} · {formatDuration(log.duration)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="hidden text-right text-xs text-[var(--text-muted)] sm:block">{formatDuration(log.duration)}</span>
|
||||
<span className="hidden text-right text-[11px] text-[var(--text-muted)] sm:block">{formatDateTime(log.start_time || log.created_at)}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="ml-auto sm:ml-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(log.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-[var(--border)] px-4 py-3">
|
||||
<span className="text-sm text-[var(--text-muted)]">
|
||||
共 {total} 条 · 第 {page}/{totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={cn('min-h-[320px] overflow-hidden', !selected && 'hidden lg:block')}>
|
||||
<LogDetailPanel
|
||||
log={detailLog}
|
||||
content={content}
|
||||
loading={loading}
|
||||
isStopping={stopping}
|
||||
onClose={() => setSelected(null)}
|
||||
onStop={stopTask}
|
||||
onDelete={(id) => setDeleteId(id)}
|
||||
onMaximize={() => setFullscreen(true)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal open={fullscreen} title="日志全屏" onClose={() => setFullscreen(false)} size="full">
|
||||
<div className="h-[75vh] -m-4">
|
||||
<LogDetailPanel
|
||||
log={detailLog}
|
||||
content={content}
|
||||
loading={loading}
|
||||
isStopping={stopping}
|
||||
onClose={() => setFullscreen(false)}
|
||||
onStop={stopTask}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={clearOpen}
|
||||
title="清空日志"
|
||||
onClose={() => setClearOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setClearOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="danger" onClick={clearLogs}>
|
||||
确认清空
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-[var(--text-secondary)]">确认清空全部执行历史?此操作不可恢复。</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={!!deleteId}
|
||||
title="删除日志"
|
||||
onClose={() => setDeleteId(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="danger" onClick={deleteLog}>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-[var(--text-secondary)]">确认删除该条执行记录?</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { AlertCircle, CheckCircle, ChevronDown, ChevronRight, Database, Lock, Server, User } from 'lucide-react'
|
||||
import { useInstall, useInstallStatus } from '@/api/hooks'
|
||||
import { Button, Card, Input, Switch } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function Install() {
|
||||
const navigate = useNavigate()
|
||||
const { data: statusData, isLoading: statusLoading } = useInstallStatus()
|
||||
const installMutation = useInstall()
|
||||
|
||||
const [step, setStep] = useState(1) // 1: 数据库 2: 管理员
|
||||
const [dbType, setDbType] = useState<'sqlite' | 'mysql'>('sqlite')
|
||||
const [showRedis, setShowRedis] = useState(false)
|
||||
|
||||
const [dbHost, setDbHost] = useState('localhost')
|
||||
const [dbPort, setDbPort] = useState('3306')
|
||||
const [dbUser, setDbUser] = useState('root')
|
||||
const [dbPassword, setDbPassword] = useState('')
|
||||
const [dbName, setDbName] = useState('taskpool')
|
||||
const [dbPath, setDbPath] = useState('')
|
||||
|
||||
const [redisEnabled, setRedisEnabled] = useState(false)
|
||||
const [redisHost, setRedisHost] = useState('localhost')
|
||||
const [redisPort, setRedisPort] = useState('6379')
|
||||
const [redisPassword, setRedisPassword] = useState('')
|
||||
const [redisDB] = useState('0')
|
||||
|
||||
const [adminUsername, setAdminUsername] = useState('admin')
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
const [adminEmail, setAdminEmail] = useState('')
|
||||
const [siteTitle, setSiteTitle] = useState('任务池')
|
||||
const [siteSubtitle, setSiteSubtitle] = useState('自动化任务调度平台')
|
||||
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (statusData?.installed && !success) {
|
||||
navigate({ to: '/login' })
|
||||
}
|
||||
}, [statusData?.installed, success, navigate])
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--bg-primary)] text-[var(--text-muted)]">
|
||||
加载中...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (statusData?.installed && !success) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleInstall() {
|
||||
setError('')
|
||||
if (!adminUsername.trim()) {
|
||||
setError('请填写管理员用户名')
|
||||
return
|
||||
}
|
||||
if (adminPassword.length < 6) {
|
||||
setError('密码至少需要 6 位')
|
||||
return
|
||||
}
|
||||
if (dbType === 'mysql' && (!dbHost.trim() || !dbName.trim() || !dbUser.trim())) {
|
||||
setError('请完善 MySQL 连接信息')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await installMutation.mutateAsync({
|
||||
db_type: dbType,
|
||||
db_host: dbType === 'mysql' ? dbHost : undefined,
|
||||
db_port: dbType === 'mysql' ? parseInt(dbPort, 10) : undefined,
|
||||
db_user: dbType === 'mysql' ? dbUser : undefined,
|
||||
db_password: dbType === 'mysql' ? dbPassword : undefined,
|
||||
db_name: dbType === 'mysql' ? dbName : undefined,
|
||||
db_path: dbType === 'sqlite' ? dbPath || undefined : undefined,
|
||||
redis_enabled: redisEnabled,
|
||||
redis_host: redisEnabled ? redisHost : undefined,
|
||||
redis_port: redisEnabled ? parseInt(redisPort, 10) : undefined,
|
||||
redis_password: redisEnabled ? redisPassword : undefined,
|
||||
redis_db: redisEnabled ? parseInt(redisDB, 10) : undefined,
|
||||
admin_username: adminUsername.trim(),
|
||||
admin_password: adminPassword,
|
||||
admin_email: adminEmail || undefined,
|
||||
site_title: siteTitle || '任务池',
|
||||
site_subtitle: siteSubtitle,
|
||||
})
|
||||
setSuccess(true)
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '安装失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--bg-primary)] p-5 text-[var(--text-primary)]">
|
||||
<Card className="w-full max-w-lg p-8">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="ms-on-accent mx-auto mb-4 flex h-14 w-14 select-none items-center justify-center rounded-xl bg-[var(--bg-accent)] text-xl font-bold text-[var(--text-on-accent)]">T</div>
|
||||
<h1 className="mb-2 text-2xl font-semibold">安装向导</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">配置数据库与管理员账号,完成初始化</p>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className="text-center">
|
||||
<CheckCircle size={48} className="mx-auto mb-4 text-[var(--success)]" strokeWidth={1.5} />
|
||||
<h2 className="mb-2 text-xl font-semibold">安装成功</h2>
|
||||
<p className="mb-6 text-sm text-[var(--text-muted)]">可使用管理员账号登录系统</p>
|
||||
<Button className="w-full" onClick={() => navigate({ to: '/login' })}>
|
||||
前往登录
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6 flex gap-2">
|
||||
{[1, 2].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={cn('h-1 flex-1 rounded-full', s <= step ? 'bg-[var(--bg-accent)]' : 'bg-[var(--border)]')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md bg-[color-mix(in_srgb,var(--danger)_14%,transparent)] px-3 py-2 text-sm text-[var(--danger)]">
|
||||
<AlertCircle size={16} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">数据库配置</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDbType('sqlite')}
|
||||
className={cn(
|
||||
'rounded-lg border p-4 text-left transition-colors',
|
||||
dbType === 'sqlite'
|
||||
? 'border-[var(--bg-accent)] bg-[var(--bg-accent)] text-[var(--text-on-accent)]'
|
||||
: 'border-[var(--border)] bg-[var(--bg-primary)] hover:bg-[var(--bg-hover)]',
|
||||
)}
|
||||
>
|
||||
<Database size={20} className="mb-2" strokeWidth={1.5} />
|
||||
<div className="font-medium">SQLite</div>
|
||||
<div className={cn('mt-1 text-xs', dbType === 'sqlite' ? 'opacity-80' : 'text-[var(--text-muted)]')}>
|
||||
轻量,开箱即用
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDbType('mysql')}
|
||||
className={cn(
|
||||
'rounded-lg border p-4 text-left transition-colors',
|
||||
dbType === 'mysql'
|
||||
? 'border-[var(--bg-accent)] bg-[var(--bg-accent)] text-[var(--text-on-accent)]'
|
||||
: 'border-[var(--border)] bg-[var(--bg-primary)] hover:bg-[var(--bg-hover)]',
|
||||
)}
|
||||
>
|
||||
<Server size={20} className="mb-2" strokeWidth={1.5} />
|
||||
<div className="font-medium">MySQL</div>
|
||||
<div className={cn('mt-1 text-xs', dbType === 'mysql' ? 'opacity-80' : 'text-[var(--text-muted)]')}>
|
||||
适合生产环境
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dbType === 'sqlite' ? (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">数据库路径(可选)</label>
|
||||
<Input value={dbPath} onChange={(e) => setDbPath(e.target.value)} placeholder="默认 data/taskpool.db" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">主机</label>
|
||||
<Input value={dbHost} onChange={(e) => setDbHost(e.target.value)} placeholder="localhost" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">端口</label>
|
||||
<Input value={dbPort} onChange={(e) => setDbPort(e.target.value)} placeholder="3306" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">用户名</label>
|
||||
<Input value={dbUser} onChange={(e) => setDbUser(e.target.value)} placeholder="root" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">密码</label>
|
||||
<Input type="password" value={dbPassword} onChange={(e) => setDbPassword(e.target.value)} placeholder="数据库密码" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">数据库名</label>
|
||||
<Input value={dbName} onChange={(e) => setDbName(e.target.value)} placeholder="taskpool" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-sm text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
onClick={() => setShowRedis((v) => !v)}
|
||||
>
|
||||
{showRedis ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
Redis 配置(可选)
|
||||
</button>
|
||||
{showRedis ? (
|
||||
<div className="mt-3 space-y-3 rounded-lg border border-[var(--border)] bg-[var(--bg-primary)] p-4">
|
||||
<label className="flex items-center justify-between text-sm">
|
||||
<span>启用 Redis</span>
|
||||
<Switch checked={redisEnabled} onCheckedChange={setRedisEnabled} />
|
||||
</label>
|
||||
{redisEnabled ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">主机</label>
|
||||
<Input value={redisHost} onChange={(e) => setRedisHost(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">端口</label>
|
||||
<Input value={redisPort} onChange={(e) => setRedisPort(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">密码</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={redisPassword}
|
||||
onChange={(e) => setRedisPassword(e.target.value)}
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">管理员账号</h2>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">用户名</label>
|
||||
<div className="relative">
|
||||
<User size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<Input className="pl-10" value={adminUsername} onChange={(e) => setAdminUsername(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">密码</label>
|
||||
<div className="relative">
|
||||
<Lock size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
type="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">邮箱(可选)</label>
|
||||
<Input type="email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} placeholder="admin@example.com" />
|
||||
</div>
|
||||
<div className="border-t border-[var(--border)] pt-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">站点设置</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">站点标题</label>
|
||||
<Input value={siteTitle} onChange={(e) => setSiteTitle(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">站点副标题</label>
|
||||
<Input value={siteSubtitle} onChange={(e) => setSiteSubtitle(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex gap-3">
|
||||
{step > 1 ? (
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setStep((s) => s - 1)}>
|
||||
上一步
|
||||
</Button>
|
||||
) : null}
|
||||
{step < 2 ? (
|
||||
<Button className="flex-1" onClick={() => setStep(2)}>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="flex-1" onClick={handleInstall} disabled={installMutation.isPending}>
|
||||
{installMutation.isPending ? '安装中...' : '完成安装'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Network, Plus, RefreshCw, Server, Trash2, Wifi, WifiOff } from 'lucide-react'
|
||||
import { api } from '@/api'
|
||||
import * as interconnect from '@/api/interconnect'
|
||||
import { useCreateNode, useDeleteNode, useNodes } from '@/api/hooks'
|
||||
import { setActiveInterconnectNodeId, activeInterconnectNodeId } from '@/api'
|
||||
import { Button, Card, Input, Modal, PageHeader, Textarea } from '@/components/ui'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
type Role = 'master' | 'child' | 'none'
|
||||
|
||||
export default function Interconnect() {
|
||||
const [role, setRole] = useState<Role>('none')
|
||||
const [loadingRole, setLoadingRole] = useState(true)
|
||||
const [message, setMessage] = useState('')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', url: '', token: '', remark: '' })
|
||||
const [childForm, setChildForm] = useState({ parent_url: '', parent_token: '' })
|
||||
const [childStatus, setChildStatus] = useState<{ parent_url: string; parent_token: string; connected: boolean } | null>(null)
|
||||
const [activeId, setActiveId] = useState(activeInterconnectNodeId)
|
||||
|
||||
const { data: nodesData, isLoading, refetch, isFetching } = useNodes()
|
||||
const createNode = useCreateNode()
|
||||
const deleteNode = useDeleteNode()
|
||||
const nodes = Array.isArray(nodesData) ? nodesData : []
|
||||
|
||||
async function fetchRole() {
|
||||
setLoadingRole(true)
|
||||
try {
|
||||
const r = await api.settings.get('interconnect', 'interconnect_role')
|
||||
setRole(r === 'master' || r === 'child' ? r : 'none')
|
||||
if (r === 'child') {
|
||||
const st = await interconnect.getChildStatus().catch(() => null)
|
||||
setChildStatus(st)
|
||||
if (st) {
|
||||
setChildForm({
|
||||
parent_url: st.parent_url || '',
|
||||
parent_token: st.parent_token || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setRole('none')
|
||||
} finally {
|
||||
setLoadingRole(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchRole()
|
||||
}, [])
|
||||
|
||||
async function changeRole(next: Role) {
|
||||
try {
|
||||
await api.settings.setSection('interconnect', { interconnect_role: next })
|
||||
if (next === 'none') {
|
||||
await api.settings.setSection('interconnect', {
|
||||
interconnect_parent_url: '',
|
||||
interconnect_parent_token: '',
|
||||
})
|
||||
setActiveInterconnectNodeId('')
|
||||
setActiveId('')
|
||||
}
|
||||
setRole(next)
|
||||
setMessage(next === 'none' ? '已取消互联角色' : `已切换为${next === 'master' ? '主' : '子'}节点`)
|
||||
toast.success(next === 'none' ? '已取消互联角色' : `已切换为${next === 'master' ? '主' : '子'}节点`)
|
||||
if (next === 'child') {
|
||||
const st = await interconnect.getChildStatus().catch(() => null)
|
||||
setChildStatus(st)
|
||||
}
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '角色设置失败')
|
||||
toast.error(e?.message || '角色设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChildConfig() {
|
||||
try {
|
||||
await api.settings.setSection('interconnect', {
|
||||
interconnect_role: 'child',
|
||||
interconnect_parent_url: childForm.parent_url,
|
||||
interconnect_parent_token: childForm.parent_token,
|
||||
})
|
||||
setMessage('子节点配置已保存')
|
||||
toast.success('子节点配置已保存')
|
||||
const st = await interconnect.getChildStatus().catch(() => null)
|
||||
setChildStatus(st)
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '保存失败')
|
||||
toast.error(e?.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
setMessage('请填写名称与 URL')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await createNode.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
token: form.token,
|
||||
remark: form.remark,
|
||||
})
|
||||
setCreateOpen(false)
|
||||
setForm({ name: '', url: '', token: '', remark: '' })
|
||||
setMessage('节点已添加')
|
||||
toast.success('节点已添加')
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '添加失败')
|
||||
toast.error(e?.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshNodeStatus(id: string) {
|
||||
try {
|
||||
await interconnect.getNodeStatus(id)
|
||||
refetch()
|
||||
toast.success('节点状态已刷新')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '刷新状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
function switchActive(id: string, name?: string) {
|
||||
const next = activeId === id ? '' : id
|
||||
setActiveInterconnectNodeId(next, name)
|
||||
setActiveId(next)
|
||||
setMessage(next ? `已切换到节点: ${name || id}` : '已切回本机')
|
||||
toast.info(next ? `已切换到节点: ${name || id}` : '已切回本机')
|
||||
if (next) window.location.reload()
|
||||
}
|
||||
|
||||
if (loadingRole) {
|
||||
return <div className="p-8 text-sm text-[var(--text-muted)]">正在载入互联配置...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="互联管理"
|
||||
description="多面板主从互联与节点切换"
|
||||
actions={
|
||||
role === 'master' ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={14} />
|
||||
添加节点
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<div className="mb-3 rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)] px-3 py-2 text-sm">{message}</div>
|
||||
) : null}
|
||||
|
||||
{role === 'none' ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="cursor-pointer space-y-3 p-6 transition hover:border-[var(--border-hover)]" onClick={() => changeRole('master')}>
|
||||
<Server size={28} className="text-[var(--text-muted)]" />
|
||||
<div className="text-lg font-semibold">作为主节点</div>
|
||||
<p className="text-sm text-[var(--text-muted)]">管理并连接多个子面板,可同步任务/变量并切换操作目标。</p>
|
||||
</Card>
|
||||
<Card className="cursor-pointer space-y-3 p-6 transition hover:border-[var(--border-hover)]" onClick={() => changeRole('child')}>
|
||||
<Network size={28} className="text-[var(--text-muted)]" />
|
||||
<div className="text-lg font-semibold">作为子节点</div>
|
||||
<p className="text-sm text-[var(--text-muted)]">接入已有主面板,接受主节点管理与同步。</p>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{role === 'master' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm text-[var(--text-muted)]">
|
||||
当前角色: 主节点
|
||||
{activeId ? ` · 操作目标: 远程节点 ${activeId}` : ' · 操作目标: 本机'}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('取消主节点角色将断开所有子节点控制,是否继续?')) changeRole('none')
|
||||
}}
|
||||
>
|
||||
取消角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-[var(--text-muted)]">加载中...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<Card className="p-8 text-center text-sm text-[var(--text-muted)]">暂无子节点,点击「添加节点」开始</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{nodes.map((node) => {
|
||||
const online = node.status === 'connected' || node.status === 'online'
|
||||
const active = activeId === node.id
|
||||
return (
|
||||
<Card key={node.id} className={`p-4 ${active ? 'border-[var(--bg-accent)]' : ''}`}>
|
||||
<div className="mb-3 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{node.name}</div>
|
||||
<div className="truncate text-xs text-[var(--text-muted)]">{node.url}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (confirm('确认删除该节点?')) deleteNode.mutate(node.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center gap-2 text-xs">
|
||||
{online ? <Wifi size={14} className="text-green-500" /> : <WifiOff size={14} className="text-red-400" />}
|
||||
<span className={online ? 'text-green-500' : 'text-red-400'}>{online ? '在线' : '离线'}</span>
|
||||
{node.last_heartbeat_at ? (
|
||||
<span className="text-[var(--text-muted)]">· {formatDateTime(node.last_heartbeat_at)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{node.metrics ? (
|
||||
<div className="mb-3 grid grid-cols-3 gap-2 text-xs text-[var(--text-muted)]">
|
||||
<div>CPU {node.metrics.cpu_percent?.toFixed?.(0) ?? '-'}%</div>
|
||||
<div>MEM {node.metrics.mem_percent?.toFixed?.(0) ?? '-'}%</div>
|
||||
<div>DISK {node.metrics.disk_percent?.toFixed?.(0) ?? '-'}%</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" className="flex-1" onClick={() => refreshNodeStatus(node.id)}>
|
||||
状态
|
||||
</Button>
|
||||
<Button size="sm" variant={active ? 'default' : 'secondary'} className="flex-[2]" onClick={() => switchActive(node.id, node.name)}>
|
||||
{active ? '当前操作目标' : '切换到此节点'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{role === 'child' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm text-[var(--text-muted)]">
|
||||
当前角色: 子节点 · {childStatus?.connected ? '已连接主节点' : '未连接'}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('取消配置将断开与主节点的连接,是否继续?')) changeRole('none')
|
||||
}}
|
||||
>
|
||||
取消角色
|
||||
</Button>
|
||||
</div>
|
||||
<Card className="max-w-xl space-y-3 p-4">
|
||||
<Input
|
||||
placeholder="主节点 URL"
|
||||
value={childForm.parent_url}
|
||||
onChange={(e) => setChildForm((f) => ({ ...f, parent_url: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="主节点 Token"
|
||||
value={childForm.parent_token}
|
||||
onChange={(e) => setChildForm((f) => ({ ...f, parent_token: e.target.value }))}
|
||||
/>
|
||||
<Button onClick={saveChildConfig}>保存配置</Button>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title="添加子节点"
|
||||
onClose={() => setCreateOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setCreateOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={createNode.isPending}>
|
||||
添加
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input placeholder="名称 *" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
|
||||
<Input placeholder="URL *" value={form.url} onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))} />
|
||||
<Input placeholder="Token" value={form.token} onChange={(e) => setForm((f) => ({ ...f, token: e.target.value }))} />
|
||||
<Textarea placeholder="备注" value={form.remark} onChange={(e) => setForm((f) => ({ ...f, remark: e.target.value }))} />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Package,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Terminal as TerminalIcon,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { api, type MiseLanguage } from '@/api'
|
||||
import { useMiseList } from '@/api/hooks'
|
||||
import { Button, Card, Input, Modal, PageHeader, Select, Tabs } from '@/components/ui'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DisplayLang = MiseLanguage & { sourceText: string }
|
||||
|
||||
function sourceText(source: MiseLanguage['source']) {
|
||||
if (!source) return '-'
|
||||
if (typeof source === 'string') return source
|
||||
return source.path || source.type || '-'
|
||||
}
|
||||
|
||||
export default function Languages() {
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState<'runtimes' | 'envs'>('runtimes')
|
||||
const [installOpen, setInstallOpen] = useState(false)
|
||||
const [plugins, setPlugins] = useState<string[]>([])
|
||||
const [versions, setVersions] = useState<string[]>([])
|
||||
const [plugin, setPlugin] = useState('python')
|
||||
const [version, setVersion] = useState('')
|
||||
const [cmdOpen, setCmdOpen] = useState(false)
|
||||
const [command, setCommand] = useState('')
|
||||
const [envs, setEnvs] = useState<Record<string, string>>({})
|
||||
const [envKey, setEnvKey] = useState('')
|
||||
const [envValue, setEnvValue] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [showTip, setShowTip] = useState(() => localStorage.getItem('taskpool_show_mise_extension_tip') !== '0')
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useMiseList()
|
||||
const list: DisplayLang[] = useMemo(
|
||||
() =>
|
||||
(Array.isArray(data) ? data : []).map((item) => ({
|
||||
...item,
|
||||
sourceText: sourceText(item.source),
|
||||
})),
|
||||
[data],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'envs') return
|
||||
api.mise
|
||||
.getEnvs()
|
||||
.then((res) => setEnvs(res || {}))
|
||||
.catch(() => setEnvs({}))
|
||||
}, [tab])
|
||||
|
||||
useEffect(() => {
|
||||
if (!installOpen) return
|
||||
api.mise
|
||||
.plugins()
|
||||
.then((res) => {
|
||||
const arr = Array.isArray(res) ? res : []
|
||||
setPlugins(arr)
|
||||
if (arr.length && !arr.includes(plugin)) setPlugin(arr[0]!)
|
||||
})
|
||||
.catch(() => setPlugins(['python', 'node', 'go', 'php', 'ruby']))
|
||||
}, [installOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!installOpen || !plugin) return
|
||||
setVersions([])
|
||||
setVersion('')
|
||||
api.mise
|
||||
.versions(plugin)
|
||||
.then((res) => {
|
||||
const arr = Array.isArray(res) ? res : []
|
||||
setVersions(arr)
|
||||
if (arr[0]) setVersion(arr[0])
|
||||
})
|
||||
.catch(() => setVersions([]))
|
||||
}, [installOpen, plugin])
|
||||
|
||||
async function sync() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.mise.sync()
|
||||
setMessage('本地环境同步成功')
|
||||
toast.success('本地环境同步成功')
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '同步失败')
|
||||
toast.error(e?.message || '同步失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openInstallCmd() {
|
||||
if (!plugin || !version) return
|
||||
setCommand(`mise install ${plugin}@${version}`)
|
||||
setInstallOpen(false)
|
||||
setCmdOpen(true)
|
||||
}
|
||||
|
||||
function uninstallCmd(lang: DisplayLang) {
|
||||
setCommand(`mise uninstall ${lang.plugin}@${lang.version}`)
|
||||
setCmdOpen(true)
|
||||
}
|
||||
|
||||
async function verify(lang: DisplayLang) {
|
||||
try {
|
||||
const res = await api.mise.verifyCommand(lang.plugin, lang.version)
|
||||
setCommand(res.command)
|
||||
setCmdOpen(true)
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '获取验证命令失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDefault(lang: DisplayLang) {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (lang.is_global) {
|
||||
await api.mise.unsetGlobal(lang.plugin, lang.version)
|
||||
setMessage(`已取消 ${lang.plugin} 的全局默认`)
|
||||
} else {
|
||||
await api.mise.useGlobal(lang.plugin, lang.version)
|
||||
setMessage(`已将 ${lang.plugin} ${lang.version} 设为全局默认`)
|
||||
}
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '操作失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEnv() {
|
||||
if (!envKey.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.mise.setEnv(envKey.trim(), envValue)
|
||||
setEnvKey('')
|
||||
setEnvValue('')
|
||||
const res = await api.mise.getEnvs()
|
||||
setEnvs(res || {})
|
||||
setMessage('环境变量已保存')
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '保存失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEnv(key: string) {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.mise.unsetEnv(key)
|
||||
const res = await api.mise.getEnvs()
|
||||
setEnvs(res || {})
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '删除失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function closeTip() {
|
||||
setShowTip(false)
|
||||
localStorage.setItem('taskpool_show_mise_extension_tip', '0')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="语言依赖"
|
||||
description="管理编程语言及相关包依赖 (Mise)"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={sync} disabled={busy}>
|
||||
同步
|
||||
</Button>
|
||||
<Button onClick={() => setInstallOpen(true)}>
|
||||
<Plus size={14} />
|
||||
新增语言
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<div className="mb-3 rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)] px-3 py-2 text-sm">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
<Info size={16} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
<b>设为默认</b>:将选定版本设为系统全局默认,未指定环境的任务将调用此版本。
|
||||
</span>
|
||||
</div>
|
||||
{showTip ? (
|
||||
<div className="flex items-start justify-between gap-2 rounded-lg border border-blue-500/20 bg-blue-500/10 px-3 py-2 text-sm text-blue-600 dark:text-blue-400">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info size={16} className="mt-0.5 shrink-0" />
|
||||
<span>Mise 也可管理其它工具插件,可在终端安装后通过同步展示。</span>
|
||||
</div>
|
||||
<button className="text-xs opacity-70 hover:opacity-100" onClick={closeTip}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as 'runtimes' | 'envs')}
|
||||
items={[
|
||||
{ value: 'runtimes', label: '运行时' },
|
||||
{ value: 'envs', label: '加速/环境' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === 'runtimes' ? (
|
||||
<Card className="overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-[var(--text-muted)]">加载中...</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-[var(--text-muted)]">
|
||||
<Boxes className="mx-auto mb-2 opacity-50" size={28} />
|
||||
暂无语言运行时,点击「新增语言」安装
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{list.map((lang, idx) => (
|
||||
<div
|
||||
key={`${lang.plugin}-${lang.version}-${idx}`}
|
||||
className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{lang.plugin} {lang.version}
|
||||
</span>
|
||||
{lang.is_global ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-green-500/15 px-1.5 py-0.5 text-xs text-green-500">
|
||||
<CheckCircle2 size={12} />
|
||||
默认
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
来源: {lang.sourceText} · {lang.install_path || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/dependencies',
|
||||
search: { language: lang.plugin, version: lang.version },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Package size={14} />
|
||||
依赖
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => toggleDefault(lang)} disabled={busy}>
|
||||
{lang.is_global ? '取消默认' : '设为默认'}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => verify(lang)}>
|
||||
<TerminalIcon size={14} />
|
||||
验证
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => uninstallCmd(lang)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Card className="space-y-3 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Zap size={16} />
|
||||
Mise 环境变量(镜像加速等)
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||
<Input placeholder="KEY" value={envKey} onChange={(e) => setEnvKey(e.target.value)} />
|
||||
<Input placeholder="VALUE" value={envValue} onChange={(e) => setEnvValue(e.target.value)} />
|
||||
<Button onClick={saveEnv} disabled={busy || !envKey.trim()}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="overflow-hidden">
|
||||
{Object.keys(envs).length === 0 ? (
|
||||
<div className="p-6 text-sm text-[var(--text-muted)]">暂无环境变量</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{Object.entries(envs).map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between gap-3 p-4 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono font-medium">{k}</div>
|
||||
<div className="truncate text-xs text-[var(--text-muted)]">{v}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="danger" onClick={() => removeEnv(k)} disabled={busy}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={installOpen}
|
||||
title="安装语言运行时"
|
||||
onClose={() => setInstallOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setInstallOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={openInstallCmd} disabled={!plugin || !version}>
|
||||
生成安装命令
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Select value={plugin} onChange={(e) => setPlugin(e.target.value)}>
|
||||
{(plugins.length ? plugins : ['python', 'node', 'go', 'php', 'ruby']).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={version} onChange={(e) => setVersion(e.target.value)}>
|
||||
{versions.length === 0 ? <option value="">加载版本中...</option> : null}
|
||||
{versions.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={cmdOpen}
|
||||
title="终端命令"
|
||||
onClose={() => setCmdOpen(false)}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command)
|
||||
setMessage('命令已复制')
|
||||
} catch {
|
||||
setMessage('复制失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setCmdOpen(false)
|
||||
await sync()
|
||||
}}
|
||||
>
|
||||
关闭并同步
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<pre className={cn('overflow-auto rounded-md bg-[var(--bg-primary)] p-3 font-mono text-xs')}>{command}</pre>
|
||||
<p className="mt-2 text-xs text-[var(--text-muted)]">请在「终端命令」页面或系统终端执行上述命令。</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { AlertCircle, Lock, User } from 'lucide-react'
|
||||
import { useLogin, usePublicSite } from '@/api/hooks'
|
||||
import { setAuthCache } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const loginMutation = useLogin()
|
||||
// 公开站点配置失败不影响登录页本身
|
||||
const { data: site } = usePublicSite()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const title = site?.title || '任务池'
|
||||
const subtitle = site?.subtitle || '登录到管理面板'
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title
|
||||
}, [title])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
try {
|
||||
await loginMutation.mutateAsync({ username, password })
|
||||
setAuthCache(true)
|
||||
navigate({ to: '/' })
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--bg-primary)] p-5 text-[var(--text-primary)]">
|
||||
<Card className="w-full max-w-md p-8">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="ms-on-accent mx-auto mb-4 flex h-14 w-14 select-none items-center justify-center rounded-xl bg-[var(--bg-accent)] text-xl font-bold text-[var(--text-on-accent)] shadow-sm">
|
||||
T
|
||||
</div>
|
||||
<h1 className="mb-2 text-2xl font-semibold">{title}</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-5 flex items-center gap-2 rounded-md bg-[color-mix(in_srgb,var(--danger)_14%,transparent)] px-3 py-2 text-sm text-[var(--danger)]">
|
||||
<AlertCircle size={16} strokeWidth={1.5} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">用户名</label>
|
||||
<div className="relative">
|
||||
<User size={16} strokeWidth={1.5} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--text-secondary)]">密码</label>
|
||||
<div className="relative">
|
||||
<Lock size={16} strokeWidth={1.5} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="mt-2 w-full" disabled={loginMutation.isPending}>
|
||||
{loginMutation.isPending ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CheckCircle, Clock, RefreshCw, XCircle } from 'lucide-react'
|
||||
import type { TaskLog } from '@/api'
|
||||
import { useLogs } from '@/api/hooks'
|
||||
import { Button, Card, PageHeader, Select } from '@/components/ui'
|
||||
import { formatDateTime, formatDuration } from '@/lib/format'
|
||||
|
||||
export default function Logs() {
|
||||
const navigate = useNavigate()
|
||||
const [status, setStatus] = useState('')
|
||||
const { data, isLoading, refetch, isFetching } = useLogs({
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
status: status || undefined,
|
||||
})
|
||||
|
||||
const logs = useMemo(() => {
|
||||
if (Array.isArray(data)) return data as TaskLog[]
|
||||
if (data && Array.isArray((data as any).data)) return (data as any).data as TaskLog[]
|
||||
return [] as TaskLog[]
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="执行日志"
|
||||
description="任务执行记录(简版,完整能力见历史记录)"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => navigate({ to: '/history', search: {} as any })}>
|
||||
打开历史记录
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="running">运行中</option>
|
||||
<option value="timeout">超时</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<Card className="p-6 text-sm text-[var(--text-muted)]">加载中...</Card>
|
||||
) : logs.length === 0 ? (
|
||||
<Card className="p-8 text-center text-sm text-[var(--text-muted)]">暂无日志</Card>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<Card key={log.id} className="p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{log.status === 'success' ? (
|
||||
<CheckCircle size={18} className="shrink-0 text-emerald-500" />
|
||||
) : log.status === 'failed' || log.status === 'timeout' ? (
|
||||
<XCircle size={18} className="shrink-0 text-red-500" />
|
||||
) : (
|
||||
<Clock size={18} className="shrink-0 text-amber-500" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{log.task_name || log.task_id}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{formatDateTime(log.start_time || log.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="rounded bg-[var(--bg-tertiary)] px-2 py-0.5">{log.status}</span>
|
||||
{log.duration > 0 ? <span className="text-[var(--text-muted)]">{formatDuration(log.duration)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{log.command ? (
|
||||
<div className="rounded bg-[var(--bg-primary)] p-2 font-mono text-xs text-[var(--text-secondary)]">
|
||||
{log.command}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { RefreshCw, Search, Trash2 } from 'lucide-react'
|
||||
import { LOG_CATEGORY } from '@/api'
|
||||
import { api } from '@/api'
|
||||
import { useAppLogs } from '@/api/hooks'
|
||||
import { Button, Card, Input, Modal, PageHeader, Select, Tabs } from '@/components/ui'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
const TABS = [
|
||||
{ value: LOG_CATEGORY.SYSTEM_NOTICE, label: '系统事件' },
|
||||
{ value: LOG_CATEGORY.PUSH_LOG, label: '推送日志' },
|
||||
{ value: LOG_CATEGORY.LOGIN_LOG, label: '登录日志' },
|
||||
{ value: LOG_CATEGORY.SCHEDULER_LOG, label: '调度日志' },
|
||||
]
|
||||
|
||||
export default function MessageLogs() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [category, setCategory] = useState<string>(LOG_CATEGORY.SYSTEM_NOTICE)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState('')
|
||||
const [level, setLevel] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [clearOpen, setClearOpen] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setDebouncedKeyword(keyword)
|
||||
setPage(1)
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [keyword])
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useAppLogs({
|
||||
page,
|
||||
page_size: 20,
|
||||
category,
|
||||
keyword: debouncedKeyword || undefined,
|
||||
level: level || undefined,
|
||||
status: status || undefined,
|
||||
})
|
||||
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const total = data?.total || 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / 20))
|
||||
|
||||
async function clearLogs() {
|
||||
try {
|
||||
await api.appLogs.clear(category)
|
||||
setClearOpen(false)
|
||||
setMessage('已清空当前分类日志')
|
||||
toast.success('已清空当前分类日志')
|
||||
refetch()
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '清空失败')
|
||||
toast.error(e?.message || '清空失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(id: string) {
|
||||
try {
|
||||
await api.appLogs.markAsRead({ id })
|
||||
refetch()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="运行日志"
|
||||
description="系统通知、推送、登录与调度日志"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
{category !== LOG_CATEGORY.LOGIN_LOG ? (
|
||||
<Button variant="danger" onClick={() => setClearOpen(true)}>
|
||||
<Trash2 size={14} />
|
||||
清空
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<div className="mb-3 rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)] px-3 py-2 text-sm">{message}</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Tabs
|
||||
value={category}
|
||||
onValueChange={(v) => {
|
||||
setCategory(v)
|
||||
setPage(1)
|
||||
setLevel('')
|
||||
setStatus('')
|
||||
}}
|
||||
items={TABS}
|
||||
/>
|
||||
<div className="relative min-w-[180px] flex-1 max-w-xs">
|
||||
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder={category === LOG_CATEGORY.LOGIN_LOG ? '搜索用户名' : '关键词'}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{category === LOG_CATEGORY.SYSTEM_NOTICE || category === LOG_CATEGORY.SCHEDULER_LOG ? (
|
||||
<Select value={level} onChange={(e) => { setLevel(e.target.value); setPage(1) }}>
|
||||
<option value="">全部级别</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="error">error</option>
|
||||
</Select>
|
||||
) : null}
|
||||
{category === LOG_CATEGORY.PUSH_LOG ? (
|
||||
<Select value={status} onChange={(e) => { setStatus(e.target.value); setPage(1) }}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">success</option>
|
||||
<option value="failed">failed</option>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-[var(--text-muted)]">加载中...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-6 text-[var(--text-muted)]">暂无日志</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{items.map((log) => (
|
||||
<div key={log.id} className="p-4" onClick={() => markRead(log.id)}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{log.title || log.category}</span>
|
||||
<span className="rounded bg-[var(--bg-tertiary)] px-1.5 py-0.5 text-xs text-[var(--text-muted)]">{log.level || log.status}</span>
|
||||
{log.channel_name ? (
|
||||
<span className="rounded bg-[var(--bg-tertiary)] px-1.5 py-0.5 text-xs text-[var(--text-muted)]">{log.channel_name}</span>
|
||||
) : null}
|
||||
<span className="text-xs text-[var(--text-muted)]">{formatDateTime(log.created_at)}</span>
|
||||
</div>
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm text-[var(--text-secondary)]">
|
||||
{log.content || log.error_msg || '-'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-t border-[var(--border)] px-4 py-3">
|
||||
<span className="text-sm text-[var(--text-muted)]">
|
||||
共 {total} 条 · 第 {page}/{totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={clearOpen}
|
||||
title="清空日志"
|
||||
onClose={() => setClearOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setClearOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="danger" onClick={clearLogs}>
|
||||
确认清空
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm">确认清空当前分类的全部日志?此操作不可恢复。</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { Activity, Cpu, HardDrive, MemoryStick, RefreshCw } from 'lucide-react'
|
||||
import type { MonitorStats } from '@/api'
|
||||
import { useMonitorStats } from '@/api/hooks'
|
||||
import { Button, Card, PageHeader, SectionLabel, Tabs } from '@/components/ui'
|
||||
import { useTheme } from '@/context/ThemeContext'
|
||||
import { formatDuration } from '@/lib/format'
|
||||
|
||||
const HISTORY_SIZE = 60
|
||||
|
||||
type HistoryPoint = {
|
||||
time: string
|
||||
goroutines: number
|
||||
alloc: number
|
||||
sys: number
|
||||
gcPause: number
|
||||
scheduled: number
|
||||
running: number
|
||||
queue: number
|
||||
}
|
||||
|
||||
function formatBytes(bytes = 0) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
function formatUptime(seconds = 0) {
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (d > 0) return `${d}天 ${h}小时`
|
||||
if (h > 0) return `${h}小时 ${m}分钟`
|
||||
return `${m}分钟`
|
||||
}
|
||||
|
||||
function Progress({ value }: { value: number }) {
|
||||
const v = Math.max(0, Math.min(100, value || 0))
|
||||
const color = v > 85 ? 'bg-red-500' : v > 65 ? 'bg-amber-500' : 'bg-[var(--bg-accent)]'
|
||||
return (
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--bg-tertiary)]">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function pushHistory(prev: HistoryPoint[], next: HistoryPoint) {
|
||||
const list = [...prev, next]
|
||||
if (list.length > HISTORY_SIZE) list.splice(0, list.length - HISTORY_SIZE)
|
||||
return list
|
||||
}
|
||||
|
||||
export default function Monitor() {
|
||||
const { theme } = useTheme()
|
||||
const isDark = theme !== 'light'
|
||||
const [tab, setTab] = useState(() => localStorage.getItem('monitor_active_tab') || 'overview')
|
||||
const [live, setLive] = useState<MonitorStats | null>(null)
|
||||
const [history, setHistory] = useState<HistoryPoint[]>([])
|
||||
const [sseConnected, setSseConnected] = useState(false)
|
||||
const lastPauseNs = useRef(0)
|
||||
const { data, isLoading, refetch, isFetching } = useMonitorStats()
|
||||
|
||||
const stats = live || data || null
|
||||
const axisColor = isDark ? '#94a3b8' : '#64748b'
|
||||
const gridColor = isDark ? '#334155' : '#e2e8f0'
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('monitor_active_tab', tab)
|
||||
}, [tab])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
setLive((prev) => prev || data)
|
||||
// 无 SSE 时用轮询快照补充首点,避免图表长期空白
|
||||
setHistory((prev) => {
|
||||
if (prev.length > 0) return prev
|
||||
return pushHistory(prev, {
|
||||
time: new Date().toLocaleTimeString('en-US', { hour12: false }),
|
||||
goroutines: data.env?.goroutines || 0,
|
||||
alloc: Number(((data.mem?.alloc || 0) / 1024 / 1024).toFixed(2)),
|
||||
sys: Number(((data.mem?.sys || 0) / 1024 / 1024).toFixed(2)),
|
||||
gcPause: 0,
|
||||
scheduled: data.scheduler?.scheduled || 0,
|
||||
running: data.scheduler?.running || 0,
|
||||
queue: data.scheduler?.queue_size || 0,
|
||||
cpu: Number((data.host?.cpu_percent || 0).toFixed(1)),
|
||||
mem: Number((data.host?.mem_percent || 0).toFixed(1)),
|
||||
disk: Number((data.host?.disk_percent || 0).toFixed(1)),
|
||||
})
|
||||
})
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
const baseUrl = (window as any).__BASE_URL__ || ''
|
||||
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
|
||||
const url = `${baseUrl}${apiVersion}/monitor/sse`
|
||||
const es = new EventSource(url, { withCredentials: true } as EventSourceInit)
|
||||
|
||||
es.onopen = () => setSseConnected(true)
|
||||
es.onerror = () => setSseConnected(false)
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
const res: MonitorStats | undefined = payload?.data || payload
|
||||
if (!res?.host || !res?.env) return
|
||||
setLive(res)
|
||||
setSseConnected(true)
|
||||
|
||||
let pauseDelta = 0
|
||||
const pauseTotal = res.gc?.pause_total_ns || 0
|
||||
if (lastPauseNs.current > 0 && pauseTotal >= lastPauseNs.current) {
|
||||
pauseDelta = Number(((pauseTotal - lastPauseNs.current) / 1_000_000).toFixed(2))
|
||||
}
|
||||
lastPauseNs.current = pauseTotal
|
||||
|
||||
const point: HistoryPoint = {
|
||||
time: new Date().toLocaleTimeString('en-US', { hour12: false }),
|
||||
goroutines: res.env.goroutines || 0,
|
||||
alloc: Number(((res.mem?.alloc || 0) / 1024 / 1024).toFixed(2)),
|
||||
sys: Number(((res.mem?.sys || 0) / 1024 / 1024).toFixed(2)),
|
||||
gcPause: pauseDelta,
|
||||
scheduled: res.scheduler?.scheduled || 0,
|
||||
running: res.scheduler?.running || 0,
|
||||
queue: res.scheduler?.queue_size || 0,
|
||||
cpu: Number((res.host?.cpu_percent || 0).toFixed(1)),
|
||||
mem: Number((res.host?.mem_percent || 0).toFixed(1)),
|
||||
disk: Number((res.host?.disk_percent || 0).toFixed(1)),
|
||||
}
|
||||
setHistory((prev) => pushHistory(prev, point))
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
es.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const chartData = useMemo(() => history, [history])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="系统监控"
|
||||
description="主机资源、运行时与调度器状态"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs ${sseConnected ? 'text-emerald-500' : 'text-[var(--text-muted)]'}`}>
|
||||
{sseConnected ? 'SSE 已连接' : 'SSE 重连中'}
|
||||
</span>
|
||||
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw size={14} className={isFetching ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
items={[
|
||||
{ value: 'overview', label: '概览' },
|
||||
{ value: 'charts', label: '时序图' },
|
||||
{ value: 'runtime', label: '运行时' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading && !stats ? (
|
||||
<div className="text-[var(--text-muted)]">加载中...</div>
|
||||
) : !stats ? (
|
||||
<div className="text-[var(--text-muted)]">暂无监控数据</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{(tab === 'overview' || tab === 'runtime') && (
|
||||
<section>
|
||||
<SectionLabel>主机资源</SectionLabel>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||
<Cpu size={14} /> CPU
|
||||
</div>
|
||||
<div className="text-2xl font-semibold">{stats.host.cpu_percent?.toFixed(1) || 0}%</div>
|
||||
<Progress value={stats.host.cpu_percent || 0} />
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||
<MemoryStick size={14} /> 内存
|
||||
</div>
|
||||
<div className="text-2xl font-semibold">{stats.host.mem_percent?.toFixed(1) || 0}%</div>
|
||||
<div className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
{formatBytes(stats.host.mem_used)} / {formatBytes(stats.host.mem_total)}
|
||||
</div>
|
||||
<Progress value={stats.host.mem_percent || 0} />
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||
<HardDrive size={14} /> 磁盘
|
||||
</div>
|
||||
<div className="text-2xl font-semibold">{stats.host.disk_percent?.toFixed(1) || 0}%</div>
|
||||
<div className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
{formatBytes(stats.host.disk_used)} / {formatBytes(stats.host.disk_total)}
|
||||
</div>
|
||||
<Progress value={stats.host.disk_percent || 0} />
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||
<Activity size={14} /> 运行时间
|
||||
</div>
|
||||
<div className="text-2xl font-semibold">{formatUptime(stats.host.uptime)}</div>
|
||||
<div className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
{stats.env.os} / {stats.env.arch}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'charts' ? (
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<ChartCard title="Goroutines">
|
||||
<LineSeries data={chartData} dataKey="goroutines" color="#2563eb" name="协程数" axisColor={axisColor} gridColor={gridColor} isDark={isDark} />
|
||||
</ChartCard>
|
||||
<ChartCard title="内存 (MB)">
|
||||
<MultiLine
|
||||
data={chartData}
|
||||
series={[
|
||||
{ dataKey: 'alloc', color: '#059669', name: 'Alloc' },
|
||||
{ dataKey: 'sys', color: '#d97706', name: 'Sys' },
|
||||
]}
|
||||
axisColor={axisColor}
|
||||
gridColor={gridColor}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard title="GC Pause (ms)">
|
||||
<BarSeries data={chartData} dataKey="gcPause" color="#ea580c" name="GC Pause" axisColor={axisColor} gridColor={gridColor} isDark={isDark} />
|
||||
</ChartCard>
|
||||
<ChartCard title="调度器">
|
||||
<MultiLine
|
||||
data={chartData}
|
||||
series={[
|
||||
{ dataKey: 'scheduled', color: '#3b82f6', name: '已调度' },
|
||||
{ dataKey: 'running', color: '#10b981', name: '运行中' },
|
||||
{ dataKey: 'queue', color: '#f59e0b', name: '队列' },
|
||||
]}
|
||||
axisColor={axisColor}
|
||||
gridColor={gridColor}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</ChartCard>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{(tab === 'overview' || tab === 'runtime') && (
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
<Card className="p-4">
|
||||
<SectionLabel>运行时</SectionLabel>
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row label="Go" value={stats.env.go_version} />
|
||||
<Row label="CPU 核心" value={String(stats.env.num_cpu)} />
|
||||
<Row label="Goroutines" value={String(stats.env.goroutines)} />
|
||||
<Row label="Alloc" value={formatBytes(stats.mem?.alloc || 0)} />
|
||||
<Row label="Sys" value={formatBytes(stats.mem?.sys || 0)} />
|
||||
<Row label="Heap Inuse" value={formatBytes(stats.heap?.heap_inuse || 0)} />
|
||||
<Row label="GC 次数" value={String(stats.gc?.num_gc || 0)} />
|
||||
<Row label="GC 总停顿" value={`${((stats.gc?.pause_total_ns || 0) / 1_000_000).toFixed(2)} ms`} />
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<SectionLabel>调度器</SectionLabel>
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Metric label="Worker" value={stats.scheduler.worker_count} />
|
||||
<Metric label="队列" value={stats.scheduler.queue_size} />
|
||||
<Metric label="运行中" value={stats.scheduler.running} />
|
||||
<Metric label="已调度" value={stats.scheduler.scheduled} />
|
||||
</div>
|
||||
<div className="border-t border-[var(--border)] pt-3">
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">Worker 列表</div>
|
||||
{(Array.isArray(stats.scheduler.workers) ? stats.scheduler.workers : []).length === 0 ? (
|
||||
<div className="text-sm text-[var(--text-muted)]">暂无 Worker 数据</div>
|
||||
) : (
|
||||
<div className="max-h-64 space-y-2 overflow-auto">
|
||||
{(Array.isArray(stats.scheduler.workers) ? stats.scheduler.workers : []).map((w) => (
|
||||
<div key={w.id} className="flex items-center justify-between rounded-md bg-[var(--bg-tertiary)]/50 px-3 py-2 text-sm">
|
||||
<div>
|
||||
#{w.id} · {w.status}
|
||||
{w.duration ? <span className="ml-2 text-xs text-[var(--text-muted)]">{formatDuration(w.duration)}</span> : null}
|
||||
</div>
|
||||
<div className="truncate pl-3 text-[var(--text-muted)]">{w.task_name || w.task_id || '-'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<SectionLabel>{title}</SectionLabel>
|
||||
<div className="h-[240px] w-full">
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function LineSeries({
|
||||
data,
|
||||
dataKey,
|
||||
color,
|
||||
name,
|
||||
axisColor,
|
||||
gridColor,
|
||||
isDark,
|
||||
}: {
|
||||
data: HistoryPoint[]
|
||||
dataKey: keyof HistoryPoint
|
||||
color: string
|
||||
name: string
|
||||
axisColor: string
|
||||
gridColor: string
|
||||
isDark: boolean
|
||||
}) {
|
||||
if (!data.length) return <EmptyChart />
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid stroke={gridColor} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="time" tick={{ fill: axisColor, fontSize: 10 }} minTickGap={24} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: axisColor, fontSize: 10 }} width={36} axisLine={false} tickLine={false} />
|
||||
<Tooltip contentStyle={tooltipStyle(isDark, gridColor)} />
|
||||
<Line type="monotone" dataKey={dataKey as string} name={name} stroke={color} strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function MultiLine({
|
||||
data,
|
||||
series,
|
||||
axisColor,
|
||||
gridColor,
|
||||
isDark,
|
||||
}: {
|
||||
data: HistoryPoint[]
|
||||
series: { dataKey: keyof HistoryPoint; color: string; name: string }[]
|
||||
axisColor: string
|
||||
gridColor: string
|
||||
isDark: boolean
|
||||
}) {
|
||||
if (!data.length) return <EmptyChart />
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid stroke={gridColor} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="time" tick={{ fill: axisColor, fontSize: 10 }} minTickGap={24} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: axisColor, fontSize: 10 }} width={36} axisLine={false} tickLine={false} />
|
||||
<Tooltip contentStyle={tooltipStyle(isDark, gridColor)} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{series.map((s) => (
|
||||
<Line
|
||||
key={String(s.dataKey)}
|
||||
type="monotone"
|
||||
dataKey={s.dataKey as string}
|
||||
name={s.name}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function BarSeries({
|
||||
data,
|
||||
dataKey,
|
||||
color,
|
||||
name,
|
||||
axisColor,
|
||||
gridColor,
|
||||
isDark,
|
||||
}: {
|
||||
data: HistoryPoint[]
|
||||
dataKey: keyof HistoryPoint
|
||||
color: string
|
||||
name: string
|
||||
axisColor: string
|
||||
gridColor: string
|
||||
isDark: boolean
|
||||
}) {
|
||||
if (!data.length) return <EmptyChart />
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid stroke={gridColor} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="time" tick={{ fill: axisColor, fontSize: 10 }} minTickGap={24} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: axisColor, fontSize: 10 }} width={36} axisLine={false} tickLine={false} />
|
||||
<Tooltip contentStyle={tooltipStyle(isDark, gridColor)} />
|
||||
<Bar dataKey={dataKey as string} name={name} fill={color} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyChart() {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-[var(--text-muted)]">等待实时数据...</div>
|
||||
}
|
||||
|
||||
function tooltipStyle(isDark: boolean, gridColor: string) {
|
||||
return {
|
||||
background: isDark ? '#0f172a' : '#fff',
|
||||
border: `1px solid ${gridColor}`,
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value?: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3 border-b border-[var(--border)] py-1.5 last:border-0">
|
||||
<span className="text-[var(--text-muted)]">{label}</span>
|
||||
<span className="font-mono text-xs">{value || '-'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value?: number }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{label}</div>
|
||||
<div className="text-lg font-semibold">{value ?? 0}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { FileQuestion, Home } from 'lucide-react'
|
||||
import { Button, Card } from '@/components/ui'
|
||||
|
||||
export default function NotFound() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--bg-primary)] p-6 text-[var(--text-primary)]">
|
||||
<Card className="w-full max-w-md p-8 text-center shadow-[var(--shadow-md)]">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--bg-tertiary)] text-[var(--text-muted)]">
|
||||
<FileQuestion size={28} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="mb-1 text-4xl font-bold tracking-tight text-[var(--text-primary)]">404</div>
|
||||
<h1 className="mb-2 text-lg font-semibold">页面不存在</h1>
|
||||
<p className="mb-6 text-sm text-[var(--text-muted)]">你访问的路径无效,或资源已被移动/删除</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="secondary" onClick={() => window.history.back()}>
|
||||
返回上页
|
||||
</Button>
|
||||
<Button onClick={() => navigate({ to: '/' })}>
|
||||
<Home size={14} />
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Code, Copy, KeyRound, Link2, Pencil, Plus, RefreshCw, Send, Trash2 } from 'lucide-react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api, type NotifyBinding, type NotifyChannel, type Task } from '@/api'
|
||||
import { useNotifyBindings, useNotifyChannels, useNotifyTypes } from '@/api/hooks'
|
||||
import { Button, Card, Field, Input, Modal, PageHeader, Select, Switch, Tabs, Textarea } from '@/components/ui'
|
||||
import { emptyConfigForType, getChannelFields } from '@/lib/notify-fields'
|
||||
import { copyToClipboard } from '@/lib/format'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export default function Notify() {
|
||||
const [tab, setTab] = useState<'channels' | 'bindings' | 'templates' | 'api'>('channels')
|
||||
const [channelOpen, setChannelOpen] = useState(false)
|
||||
const [bindingOpen, setBindingOpen] = useState(false)
|
||||
const [editingChannel, setEditingChannel] = useState<NotifyChannel | null>(null)
|
||||
const [channelForm, setChannelForm] = useState<{
|
||||
name: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
config: Record<string, string>
|
||||
}>({
|
||||
name: '',
|
||||
type: 'Telegram',
|
||||
enabled: true,
|
||||
config: emptyConfigForType('Telegram'),
|
||||
})
|
||||
const [bindingForm, setBindingForm] = useState({
|
||||
type: 'system',
|
||||
event: 'task_success',
|
||||
way_id: '',
|
||||
data_id: '',
|
||||
})
|
||||
const [testForm, setTestForm] = useState({ channel_id: '', title: '测试推送', text: '这是一条测试消息' })
|
||||
const [template, setTemplate] = useState({ title: '{{task_name}} {{status}}', body: '{{output}}' })
|
||||
const [message, setMessage] = useState('')
|
||||
const [apiToken, setApiToken] = useState('')
|
||||
const [notifyToken, setNotifyToken] = useState('')
|
||||
const [taskOptions, setTaskOptions] = useState<Task[]>([])
|
||||
const [generatingToken, setGeneratingToken] = useState(false)
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: channelsData, isLoading, refetch } = useNotifyChannels()
|
||||
const { data: bindingsData, refetch: refetchBindings } = useNotifyBindings()
|
||||
const { data: types } = useNotifyTypes()
|
||||
const channels = Array.isArray(channelsData) ? channelsData : []
|
||||
const bindings = Array.isArray(bindingsData) ? bindingsData : []
|
||||
const channelTypes = useMemo(() => {
|
||||
const list = Array.isArray(types?.channel_types) ? types!.channel_types : []
|
||||
if (list.length) return list
|
||||
return [
|
||||
{ type: 'Telegram', label: 'Telegram' },
|
||||
{ type: 'Bark', label: 'Bark' },
|
||||
{ type: 'Dtalk', label: '钉钉' },
|
||||
{ type: 'QyWeiXin', label: '企业微信' },
|
||||
{ type: 'Feishu', label: '飞书' },
|
||||
{ type: 'Custom', label: '自定义 Webhook' },
|
||||
{ type: 'Ntfy', label: 'Ntfy' },
|
||||
{ type: 'Gotify', label: 'Gotify' },
|
||||
{ type: 'Email', label: 'Email' },
|
||||
{ type: 'PushPlus', label: 'PushPlus' },
|
||||
{ type: 'PushMe', label: 'PushMe' },
|
||||
{ type: 'PushMes', label: 'PushMe' },
|
||||
{ type: 'VoceChat', label: 'VoceChat' },
|
||||
{ type: 'WxPusher', label: 'WxPusher' },
|
||||
{ type: 'AliyunSMS', label: '阿里云短信' },
|
||||
]
|
||||
}, [types])
|
||||
const eventTypes = useMemo(
|
||||
() =>
|
||||
Array.isArray(types?.event_types)
|
||||
? types!.event_types
|
||||
: [
|
||||
{ type: 'task_success', label: '任务成功' },
|
||||
{ type: 'task_failed', label: '任务失败' },
|
||||
{ type: 'task_timeout', label: '任务超时' },
|
||||
],
|
||||
[types],
|
||||
)
|
||||
|
||||
const fields = getChannelFields(channelForm.type)
|
||||
|
||||
useEffect(() => {
|
||||
api.settings
|
||||
.getSite()
|
||||
.then((s: any) => setApiToken(s?.openapi_token || ''))
|
||||
.catch(() => setApiToken(''))
|
||||
api.settings
|
||||
.get('notify', 'notify_token')
|
||||
.then((t) => setNotifyToken(typeof t === 'string' ? t : ''))
|
||||
.catch(() => setNotifyToken(''))
|
||||
api.tasks
|
||||
.list({ page: 1, page_size: 1000 })
|
||||
.then((res) => setTaskOptions(Array.isArray(res?.data) ? res.data : []))
|
||||
.catch(() => setTaskOptions([]))
|
||||
}, [])
|
||||
|
||||
const saveChannel = useMutation({
|
||||
mutationFn: () =>
|
||||
api.notify.saveChannel({
|
||||
id: editingChannel?.id,
|
||||
name: channelForm.name,
|
||||
type: channelForm.type,
|
||||
enabled: channelForm.enabled,
|
||||
config: channelForm.config,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setChannelOpen(false)
|
||||
setEditingChannel(null)
|
||||
setMessage('渠道已保存')
|
||||
toast.success('渠道已保存')
|
||||
qc.invalidateQueries({ queryKey: ['notifyChannels'] })
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '保存失败')
|
||||
toast.error(e?.message || '保存失败')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteChannel = useMutation({
|
||||
mutationFn: (id: string) => api.notify.deleteChannel(id),
|
||||
onSuccess: () => {
|
||||
setMessage('渠道已删除')
|
||||
toast.success('渠道已删除')
|
||||
qc.invalidateQueries({ queryKey: ['notifyChannels'] })
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '删除失败')
|
||||
toast.error(e?.message || '删除失败')
|
||||
},
|
||||
})
|
||||
|
||||
const saveBinding = useMutation({
|
||||
mutationFn: () =>
|
||||
api.notify.saveBinding({
|
||||
type: bindingForm.type,
|
||||
event: bindingForm.event,
|
||||
way_id: bindingForm.way_id,
|
||||
data_id: bindingForm.data_id,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setBindingOpen(false)
|
||||
setMessage('绑定已保存')
|
||||
toast.success('绑定已保存')
|
||||
qc.invalidateQueries({ queryKey: ['notifyBindings'] })
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '保存失败')
|
||||
toast.error(e?.message || '保存失败')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteBinding = useMutation({
|
||||
mutationFn: (id: string) => api.notify.deleteBinding(id),
|
||||
onSuccess: () => {
|
||||
toast.success('绑定已删除')
|
||||
qc.invalidateQueries({ queryKey: ['notifyBindings'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.message || '删除失败'),
|
||||
})
|
||||
|
||||
const testSend = useMutation({
|
||||
mutationFn: () => api.notify.send(testForm),
|
||||
onSuccess: (res) => {
|
||||
if (res?.success === false) {
|
||||
const msg = res.error || '发送失败'
|
||||
setMessage(msg)
|
||||
toast.error(msg)
|
||||
} else {
|
||||
setMessage('测试消息已发送')
|
||||
toast.success('测试消息已发送')
|
||||
}
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '发送失败')
|
||||
toast.error(e?.message || '发送失败')
|
||||
},
|
||||
})
|
||||
|
||||
async function generateNotifyToken() {
|
||||
setGeneratingToken(true)
|
||||
try {
|
||||
const token = await api.settings.generateToken('notify', 'notify_token')
|
||||
setNotifyToken(typeof token === 'string' ? token : '')
|
||||
toast.success('Notify Token 已生成')
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '生成失败')
|
||||
} finally {
|
||||
setGeneratingToken(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string, okMsg: string) {
|
||||
if (!text) {
|
||||
toast.warning('内容为空')
|
||||
return
|
||||
}
|
||||
const ok = await copyToClipboard(text)
|
||||
if (ok) toast.success(okMsg)
|
||||
else toast.error('复制失败')
|
||||
}
|
||||
|
||||
function openCreateChannel() {
|
||||
const type = channelTypes[0]?.type || 'Telegram'
|
||||
setEditingChannel(null)
|
||||
setChannelForm({
|
||||
name: '',
|
||||
type,
|
||||
enabled: true,
|
||||
config: emptyConfigForType(type),
|
||||
})
|
||||
setChannelOpen(true)
|
||||
}
|
||||
|
||||
function openEditChannel(ch: NotifyChannel) {
|
||||
setEditingChannel(ch)
|
||||
setChannelForm({
|
||||
name: ch.name,
|
||||
type: ch.type,
|
||||
enabled: ch.enabled !== false,
|
||||
config: emptyConfigForType(ch.type, ch.config || {}),
|
||||
})
|
||||
setChannelOpen(true)
|
||||
}
|
||||
|
||||
async function testChannel(ch: NotifyChannel) {
|
||||
try {
|
||||
const res = await api.notify.testChannel(ch)
|
||||
const msg = res?.success === false ? res.error || '测试失败' : '测试发送成功'
|
||||
setMessage(msg)
|
||||
if (res?.success === false) toast.error(msg)
|
||||
else toast.success(msg)
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '测试失败')
|
||||
toast.error(e?.message || '测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="消息推送"
|
||||
description="配置通知渠道、事件绑定与模板"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
refetch()
|
||||
refetchBindings()
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
刷新
|
||||
</Button>
|
||||
{tab === 'channels' ? (
|
||||
<Button onClick={openCreateChannel}>
|
||||
<Plus size={14} />
|
||||
新建渠道
|
||||
</Button>
|
||||
) : null}
|
||||
{tab === 'bindings' ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setBindingForm({
|
||||
type: 'system',
|
||||
event: eventTypes[0]?.type || 'task_success',
|
||||
way_id: channels[0]?.id || '',
|
||||
data_id: '',
|
||||
})
|
||||
setBindingOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
新建绑定
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<div className="mb-3 rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)] px-3 py-2 text-sm">{message}</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as any)}
|
||||
items={[
|
||||
{ value: 'channels', label: '渠道' },
|
||||
{ value: 'bindings', label: '事件绑定' },
|
||||
{ value: 'templates', label: '模板' },
|
||||
{ value: 'api', label: 'API 用法' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === 'channels' ? (
|
||||
isLoading ? (
|
||||
<div className="text-sm text-[var(--text-muted)]">加载中...</div>
|
||||
) : channels.length === 0 ? (
|
||||
<Card className="p-8 text-center text-sm text-[var(--text-muted)]">暂无渠道,点击新建</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{channels.map((ch) => (
|
||||
<Card key={ch.id} className="p-4">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-medium">{ch.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{ch.type}</div>
|
||||
</div>
|
||||
<span className={`text-xs ${ch.enabled === false ? 'text-[var(--text-muted)]' : 'text-emerald-500'}`}>
|
||||
{ch.enabled === false ? '停用' : '启用'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => openEditChannel(ch)}>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => testChannel(ch)}>
|
||||
<Send size={14} />
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (confirm('确认删除该渠道?')) deleteChannel.mutate(ch.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{tab === 'bindings' ? (
|
||||
<Card className="overflow-hidden">
|
||||
{bindings.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-[var(--text-muted)]">暂无绑定</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{bindings.map((b: NotifyBinding) => (
|
||||
<div key={b.id} className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{b.event} · {b.type}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
渠道: {channels.find((c) => c.id === b.way_id)?.name || b.way_id}
|
||||
{b.data_id ? ` · 数据: ${b.data_id}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="danger" onClick={() => deleteBinding.mutate(b.id)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{tab === 'templates' ? (
|
||||
<Card className="space-y-3 p-4">
|
||||
<div className="text-sm text-[var(--text-muted)]">消息模板(本地预览/参考,实际以服务端模板为准)</div>
|
||||
<Field label="标题模板">
|
||||
<Input value={template.title} onChange={(e) => setTemplate((t) => ({ ...t, title: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="正文模板">
|
||||
<Textarea className="min-h-[120px]" value={template.body} onChange={(e) => setTemplate((t) => ({ ...t, body: e.target.value }))} />
|
||||
</Field>
|
||||
<div className="rounded-md bg-[var(--bg-primary)] p-3 text-xs text-[var(--text-muted)]">
|
||||
可用变量示例: {'{{task_name}}'} {'{{status}}'} {'{{output}}'} {'{{error}}'} {'{{duration}}'}
|
||||
</div>
|
||||
<div className="border-t border-[var(--border)] pt-3">
|
||||
<div className="mb-2 text-sm font-medium">快速测试发送</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<Select value={testForm.channel_id} onChange={(e) => setTestForm((f) => ({ ...f, channel_id: e.target.value }))}>
|
||||
<option value="">选择渠道</option>
|
||||
{channels.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input value={testForm.title} onChange={(e) => setTestForm((f) => ({ ...f, title: e.target.value }))} placeholder="标题" />
|
||||
</div>
|
||||
<Textarea className="mt-2" value={testForm.text} onChange={(e) => setTestForm((f) => ({ ...f, text: e.target.value }))} />
|
||||
<Button className="mt-2" disabled={!testForm.channel_id || testSend.isPending} onClick={() => testSend.mutate()}>
|
||||
<Send size={14} />
|
||||
发送测试
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{tab === 'api' ? (
|
||||
<Card className="space-y-3 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Code size={16} /> OpenAPI 推送用法
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-muted)]">Token: {apiToken || '(未生成,请到系统设置开启 OpenAPI)'}</div>
|
||||
<pre className="overflow-auto rounded-md bg-[var(--bg-primary)] p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{`POST /api/v1/notify/send
|
||||
Header: Authorization: Bearer <openapi_token>
|
||||
Body:
|
||||
{
|
||||
"channel_id": "<渠道ID>",
|
||||
"title": "标题",
|
||||
"text": "内容"
|
||||
}`}
|
||||
</pre>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--text-muted)]">
|
||||
<Link2 size={12} /> 也可在「系统设置 → 站点」中管理 OpenAPI Token
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
open={channelOpen}
|
||||
title={editingChannel ? '编辑渠道' : '新建渠道'}
|
||||
onClose={() => setChannelOpen(false)}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setChannelOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!channelForm.name || !channelForm.type || saveChannel.isPending}
|
||||
onClick={() => {
|
||||
const missing = fields.filter((f) => f.required && !channelForm.config[f.key]?.trim())
|
||||
if (missing.length) {
|
||||
setMessage(`请填写必填项: ${missing.map((m) => m.label).join(', ')}`)
|
||||
return
|
||||
}
|
||||
saveChannel.mutate()
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Field label="名称">
|
||||
<Input value={channelForm.name} onChange={(e) => setChannelForm((f) => ({ ...f, name: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="类型">
|
||||
<Select
|
||||
value={channelForm.type}
|
||||
onChange={(e) => {
|
||||
const type = e.target.value
|
||||
setChannelForm((f) => ({
|
||||
...f,
|
||||
type,
|
||||
config: emptyConfigForType(type, f.config),
|
||||
}))
|
||||
}}
|
||||
>
|
||||
{channelTypes.map((t) => (
|
||||
<option key={t.type} value={t.type}>
|
||||
{t.label || t.type}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
{fields.map((field) => (
|
||||
<Field key={field.key} label={`${field.label}${field.required ? ' *' : ''}`}>
|
||||
{field.type === 'textarea' ? (
|
||||
<Textarea
|
||||
value={channelForm.config[field.key] || ''}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(e) =>
|
||||
setChannelForm((f) => ({
|
||||
...f,
|
||||
config: { ...f.config, [field.key]: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={channelForm.config[field.key] || ''}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(e) =>
|
||||
setChannelForm((f) => ({
|
||||
...f,
|
||||
config: { ...f.config, [field.key]: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
{!fields.length ? (
|
||||
<Field label="额外配置 (JSON)">
|
||||
<Textarea
|
||||
value={channelForm.config.extra || ''}
|
||||
placeholder='{"key":"value"}'
|
||||
onChange={(e) =>
|
||||
setChannelForm((f) => ({
|
||||
...f,
|
||||
config: { ...f.config, extra: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
<label className="flex items-center justify-between text-sm">
|
||||
<span>启用</span>
|
||||
<Switch checked={channelForm.enabled} onCheckedChange={(v) => setChannelForm((f) => ({ ...f, enabled: v }))} />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={bindingOpen}
|
||||
title="新建绑定"
|
||||
onClose={() => setBindingOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setBindingOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={!bindingForm.way_id || saveBinding.isPending} onClick={() => saveBinding.mutate()}>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Field label="类型">
|
||||
<Select value={bindingForm.type} onChange={(e) => setBindingForm((f) => ({ ...f, type: e.target.value }))}>
|
||||
<option value="system">系统</option>
|
||||
<option value="task">任务</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="事件">
|
||||
<Select value={bindingForm.event} onChange={(e) => setBindingForm((f) => ({ ...f, event: e.target.value }))}>
|
||||
{eventTypes.map((e) => (
|
||||
<option key={e.type} value={e.type}>
|
||||
{e.label || e.type}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="渠道">
|
||||
<Select value={bindingForm.way_id} onChange={(e) => setBindingForm((f) => ({ ...f, way_id: e.target.value }))}>
|
||||
<option value="">选择渠道</option>
|
||||
{channels.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
{bindingForm.type === 'task' ? (
|
||||
<Field label="任务(可选,留空表示全部任务)">
|
||||
<Select value={bindingForm.data_id} onChange={(e) => setBindingForm((f) => ({ ...f, data_id: e.target.value }))}>
|
||||
<option value="">全部任务</option>
|
||||
{taskOptions.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { FileCode } from 'lucide-react'
|
||||
import { Button, Card, PageHeader } from '@/components/ui'
|
||||
|
||||
/** 兼容旧路由:脚本管理已合并到编辑器 */
|
||||
export default function Scripts() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
navigate({ to: '/editor' })
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="脚本管理" description="已合并到脚本编辑器" />
|
||||
<Card className="p-8 text-center">
|
||||
<FileCode className="mx-auto mb-3 opacity-50" size={32} />
|
||||
<p className="mb-4 text-sm text-[var(--text-muted)]">正在跳转到脚本编辑器...</p>
|
||||
<Button onClick={() => navigate({ to: '/editor' })}>打开编辑器</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Download, ExternalLink, RefreshCw, Trash2, Upload, UploadCloud } from 'lucide-react'
|
||||
import { api, type WebUI } from '@/api'
|
||||
import { useAbout, useSchedulerSettings, useSiteSettings } from '@/api/hooks'
|
||||
import { useTheme } from '@/context/ThemeContext'
|
||||
import { Button, Card, Input, PageHeader, Switch, Tabs, Textarea } from '@/components/ui'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export default function Settings() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const qc = useQueryClient()
|
||||
const { data: site } = useSiteSettings()
|
||||
const { data: scheduler } = useSchedulerSettings()
|
||||
const { data: about } = useAbout()
|
||||
const [tab, setTab] = useState('site')
|
||||
const [siteForm, setSiteForm] = useState({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
page_size: '20',
|
||||
cookie_days: '7',
|
||||
openapi_enabled: false,
|
||||
openapi_token: '',
|
||||
system_notice_days: '7',
|
||||
system_notice_max_count: '1000',
|
||||
push_log_days: '7',
|
||||
push_log_max_count: '1000',
|
||||
login_log_days: '30',
|
||||
login_log_max_count: '1000',
|
||||
scheduler_log_days: '7',
|
||||
scheduler_log_max_count: '1000',
|
||||
})
|
||||
const [schedulerForm, setSchedulerForm] = useState({ worker_count: '4', queue_size: '100', rate_interval: '0' })
|
||||
const [passwordForm, setPasswordForm] = useState({ old_password: '', new_password: '', username: '' })
|
||||
const [changelog, setChangelog] = useState('')
|
||||
const [backupStatus, setBackupStatus] = useState<{ has_backup: boolean; backup_time: string } | null>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
const webuiInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data: loginLogs } = useQuery({
|
||||
queryKey: ['loginLogs'],
|
||||
queryFn: () => api.settings.getLoginLogs({ page: 1, page_size: 20 }),
|
||||
enabled: tab === 'security',
|
||||
})
|
||||
|
||||
const { data: webuis, refetch: refetchWebui, isFetching: webuiFetching } = useQuery({
|
||||
queryKey: ['webuis'],
|
||||
queryFn: async () => {
|
||||
const list = await api.webui.list()
|
||||
return Array.isArray(list) ? list : []
|
||||
},
|
||||
enabled: tab === 'webui',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (site) {
|
||||
setSiteForm({
|
||||
title: site.title || '',
|
||||
subtitle: site.subtitle || '',
|
||||
page_size: site.page_size || '20',
|
||||
cookie_days: site.cookie_days || '7',
|
||||
openapi_enabled: !!site.openapi_enabled,
|
||||
openapi_token: site.openapi_token || '',
|
||||
system_notice_days: site.system_notice_days || '7',
|
||||
system_notice_max_count: site.system_notice_max_count || '1000',
|
||||
push_log_days: site.push_log_days || '7',
|
||||
push_log_max_count: site.push_log_max_count || '1000',
|
||||
login_log_days: site.login_log_days || '30',
|
||||
login_log_max_count: site.login_log_max_count || '1000',
|
||||
scheduler_log_days: site.scheduler_log_days || '7',
|
||||
scheduler_log_max_count: site.scheduler_log_max_count || '1000',
|
||||
})
|
||||
}
|
||||
}, [site])
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduler) {
|
||||
setSchedulerForm({
|
||||
worker_count: scheduler.worker_count || '4',
|
||||
queue_size: scheduler.queue_size || '100',
|
||||
rate_interval: scheduler.rate_interval || '0',
|
||||
})
|
||||
}
|
||||
}, [scheduler])
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'about' && tab !== 'backup') return
|
||||
if (tab === 'about') {
|
||||
api.settings
|
||||
.getChangelog()
|
||||
.then((t) => setChangelog(typeof t === 'string' ? t : ''))
|
||||
.catch(() => setChangelog(''))
|
||||
}
|
||||
api.settings
|
||||
.getBackupStatus()
|
||||
.then(setBackupStatus)
|
||||
.catch(() => setBackupStatus(null))
|
||||
}, [tab])
|
||||
|
||||
const saveSite = useMutation({
|
||||
mutationFn: () =>
|
||||
api.settings.updateSite({
|
||||
...(site || {}),
|
||||
...siteForm,
|
||||
openapi_enabled: siteForm.openapi_enabled,
|
||||
} as any),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['siteSettings'] })
|
||||
qc.invalidateQueries({ queryKey: ['publicSite'] })
|
||||
setMessage('站点设置已保存')
|
||||
toast.success('站点设置已保存')
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '保存失败')
|
||||
toast.error(e?.message || '保存失败')
|
||||
},
|
||||
})
|
||||
|
||||
const saveScheduler = useMutation({
|
||||
mutationFn: () => api.settings.updateScheduler(schedulerForm),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['schedulerSettings'] })
|
||||
setMessage('调度设置已保存')
|
||||
toast.success('调度设置已保存')
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '保存失败')
|
||||
toast.error(e?.message || '保存失败')
|
||||
},
|
||||
})
|
||||
|
||||
const changePassword = useMutation({
|
||||
mutationFn: () =>
|
||||
api.settings.changePassword({
|
||||
old_password: passwordForm.old_password,
|
||||
new_password: passwordForm.new_password || undefined,
|
||||
username: passwordForm.username || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setPasswordForm({ old_password: '', new_password: '', username: '' })
|
||||
setMessage('账号信息已更新')
|
||||
toast.success('账号信息已更新')
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '更新失败')
|
||||
toast.error(e?.message || '更新失败')
|
||||
},
|
||||
})
|
||||
|
||||
const genToken = useMutation({
|
||||
mutationFn: () => api.settings.generateOpenapiToken(),
|
||||
onSuccess: (res) => {
|
||||
setSiteForm((f) => ({ ...f, openapi_token: res.token || '' }))
|
||||
setMessage('OpenAPI Token 已生成')
|
||||
toast.success('OpenAPI Token 已生成')
|
||||
qc.invalidateQueries({ queryKey: ['siteSettings'] })
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '生成失败')
|
||||
toast.error(e?.message || '生成失败')
|
||||
},
|
||||
})
|
||||
|
||||
const createBackup = useMutation({
|
||||
mutationFn: () => api.settings.createBackup(),
|
||||
onSuccess: async () => {
|
||||
setMessage('备份已创建')
|
||||
toast.success('备份已创建')
|
||||
setBackupStatus(await api.settings.getBackupStatus())
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '备份失败')
|
||||
toast.error(e?.message || '备份失败')
|
||||
},
|
||||
})
|
||||
|
||||
const uploadWebui = useMutation({
|
||||
mutationFn: (file: File) => api.webui.upload(file),
|
||||
onSuccess: (res) => {
|
||||
const msg = res?.message || 'WebUI 上传成功'
|
||||
setMessage(msg)
|
||||
toast.success(msg)
|
||||
refetchWebui()
|
||||
qc.invalidateQueries({ queryKey: ['siteSettings'] })
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '上传失败')
|
||||
toast.error(e?.message || '上传失败')
|
||||
},
|
||||
})
|
||||
|
||||
const activateWebui = useMutation({
|
||||
mutationFn: (name: string) => api.webui.setActive(name),
|
||||
onSuccess: (res) => {
|
||||
const msg = res?.message || '已切换前端主题'
|
||||
setMessage(msg)
|
||||
toast.success(msg)
|
||||
refetchWebui()
|
||||
qc.invalidateQueries({ queryKey: ['siteSettings'] })
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '切换失败')
|
||||
toast.error(e?.message || '切换失败')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteWebui = useMutation({
|
||||
mutationFn: (name: string) => api.webui.delete(name),
|
||||
onSuccess: (res) => {
|
||||
const msg = res?.message || '已删除 WebUI'
|
||||
setMessage(msg)
|
||||
toast.success(msg)
|
||||
refetchWebui()
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setMessage(e?.message || '删除失败')
|
||||
toast.error(e?.message || '删除失败')
|
||||
},
|
||||
})
|
||||
|
||||
async function restoreBackup(file?: File | null) {
|
||||
if (!file) return
|
||||
try {
|
||||
await api.settings.restoreBackup(file)
|
||||
setMessage('恢复成功,建议刷新页面')
|
||||
toast.success('恢复成功,建议刷新页面')
|
||||
} catch (e: any) {
|
||||
setMessage(e?.message || '恢复失败')
|
||||
toast.error(e?.message || '恢复失败')
|
||||
}
|
||||
}
|
||||
|
||||
const logs = Array.isArray(loginLogs?.data) ? loginLogs.data : []
|
||||
const webuiList: WebUI[] = Array.isArray(webuis) ? webuis : []
|
||||
const activeWebui = site?.active_webui || ''
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-4">
|
||||
<PageHeader title="系统设置" description="站点、调度、安全、前端定制与备份" />
|
||||
{message ? <div className="rounded-md bg-blue-500/10 px-3 py-2 text-sm text-blue-400">{message}</div> : null}
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
items={[
|
||||
{ value: 'site', label: '站点' },
|
||||
{ value: 'webui', label: '前端定制' },
|
||||
{ value: 'scheduler', label: '调度' },
|
||||
{ value: 'security', label: '安全' },
|
||||
{ value: 'backup', label: '备份' },
|
||||
{ value: 'about', label: '关于' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{tab === 'site' ? (
|
||||
<div className="space-y-4">
|
||||
<Card className="space-y-3 p-4">
|
||||
<h2 className="font-medium">外观</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant={theme === 'light' ? 'default' : 'secondary'} onClick={() => setTheme('light')}>
|
||||
浅色
|
||||
</Button>
|
||||
<Button variant={theme === 'dark' ? 'default' : 'secondary'} onClick={() => setTheme('dark')}>
|
||||
深色
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="space-y-3 p-4">
|
||||
<h2 className="font-medium">站点设置</h2>
|
||||
<Input placeholder="标题" value={siteForm.title} onChange={(e) => setSiteForm((f) => ({ ...f, title: e.target.value }))} />
|
||||
<Input placeholder="副标题" value={siteForm.subtitle} onChange={(e) => setSiteForm((f) => ({ ...f, subtitle: e.target.value }))} />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input placeholder="分页大小" value={siteForm.page_size} onChange={(e) => setSiteForm((f) => ({ ...f, page_size: e.target.value }))} />
|
||||
<Input placeholder="Cookie 天数" value={siteForm.cookie_days} onChange={(e) => setSiteForm((f) => ({ ...f, cookie_days: e.target.value }))} />
|
||||
</div>
|
||||
<label className="flex items-center justify-between text-sm">
|
||||
<span>启用 OpenAPI</span>
|
||||
<Switch checked={siteForm.openapi_enabled} onCheckedChange={(v) => setSiteForm((f) => ({ ...f, openapi_enabled: v }))} />
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input className="min-w-[220px] flex-1 font-mono text-xs" readOnly value={siteForm.openapi_token} placeholder="OpenAPI Token" />
|
||||
<Button variant="secondary" onClick={() => genToken.mutate()} disabled={genToken.isPending}>
|
||||
生成 Token
|
||||
</Button>
|
||||
</div>
|
||||
<h3 className="pt-2 text-sm font-medium text-[var(--text-secondary)]">日志保留</h3>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input placeholder="系统通知保留天" value={siteForm.system_notice_days} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_days: e.target.value }))} />
|
||||
<Input placeholder="系统通知最大条数" value={siteForm.system_notice_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_max_count: e.target.value }))} />
|
||||
<Input placeholder="推送日志保留天" value={siteForm.push_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_days: e.target.value }))} />
|
||||
<Input placeholder="推送日志最大条数" value={siteForm.push_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_max_count: e.target.value }))} />
|
||||
<Input placeholder="登录日志保留天" value={siteForm.login_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_days: e.target.value }))} />
|
||||
<Input placeholder="登录日志最大条数" value={siteForm.login_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_max_count: e.target.value }))} />
|
||||
<Input placeholder="调度日志保留天" value={siteForm.scheduler_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_days: e.target.value }))} />
|
||||
<Input placeholder="调度日志最大条数" value={siteForm.scheduler_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_max_count: e.target.value }))} />
|
||||
</div>
|
||||
<Button onClick={() => saveSite.mutate()} disabled={saveSite.isPending}>
|
||||
保存站点设置
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === 'webui' ? (
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-medium">自定义前端包 (WebUI)</h2>
|
||||
<p className="mt-1 text-sm text-[var(--text-muted)]">上传并切换自定义前端资源包,实现深度定制</p>
|
||||
<a
|
||||
href="https://engigu.github.io/taskpool/guide/webui"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
开发文档 <ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => refetchWebui()} disabled={webuiFetching}>
|
||||
<RefreshCw size={14} className={webuiFetching ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => webuiInputRef.current?.click()} disabled={uploadWebui.isPending}>
|
||||
<UploadCloud size={14} />
|
||||
{uploadWebui.isPending ? '上传中...' : '上传资源包'}
|
||||
</Button>
|
||||
<input
|
||||
ref={webuiInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadWebui.mutate(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webuiList.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-[var(--border)] p-8 text-center text-sm text-[var(--text-muted)]">
|
||||
暂无自定义前端包,可上传 zip 资源包
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{webuiList.map((item) => {
|
||||
const active = activeWebui === item.name
|
||||
return (
|
||||
<div key={item.name} className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-[var(--border)] p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
{active ? (
|
||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] text-emerald-500">当前</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
v{item.version || '-'} · {item.author || '未知作者'}
|
||||
{item.min_panel_version ? ` · 最低面板 ${item.min_panel_version}` : ''}
|
||||
</div>
|
||||
{item.description ? <div className="mt-1 text-sm text-[var(--text-secondary)]">{item.description}</div> : null}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" disabled={active || activateWebui.isPending} onClick={() => activateWebui.mutate(item.name)}>
|
||||
{active ? '使用中' : '启用'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={deleteWebui.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`确认删除前端包 ${item.name}?`)) deleteWebui.mutate(item.name)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{tab === 'scheduler' ? (
|
||||
<Card className="space-y-3 p-4">
|
||||
<h2 className="font-medium">调度设置</h2>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Input placeholder="Worker 数" value={schedulerForm.worker_count} onChange={(e) => setSchedulerForm((f) => ({ ...f, worker_count: e.target.value }))} />
|
||||
<Input placeholder="队列大小" value={schedulerForm.queue_size} onChange={(e) => setSchedulerForm((f) => ({ ...f, queue_size: e.target.value }))} />
|
||||
<Input placeholder="限速间隔" value={schedulerForm.rate_interval} onChange={(e) => setSchedulerForm((f) => ({ ...f, rate_interval: e.target.value }))} />
|
||||
</div>
|
||||
<Button onClick={() => saveScheduler.mutate()} disabled={saveScheduler.isPending}>
|
||||
保存调度设置
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{tab === 'security' ? (
|
||||
<div className="space-y-4">
|
||||
<Card className="space-y-3 p-4">
|
||||
<h2 className="font-medium">账号安全</h2>
|
||||
<Input placeholder="新用户名(可选)" value={passwordForm.username} onChange={(e) => setPasswordForm((f) => ({ ...f, username: e.target.value }))} />
|
||||
<Input type="password" placeholder="旧密码" value={passwordForm.old_password} onChange={(e) => setPasswordForm((f) => ({ ...f, old_password: e.target.value }))} />
|
||||
<Input type="password" placeholder="新密码(可选)" value={passwordForm.new_password} onChange={(e) => setPasswordForm((f) => ({ ...f, new_password: e.target.value }))} />
|
||||
<Button onClick={() => changePassword.mutate()} disabled={!passwordForm.old_password || changePassword.isPending}>
|
||||
更新账号
|
||||
</Button>
|
||||
</Card>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="border-b border-[var(--border)] px-4 py-3 text-sm font-medium">最近登录</div>
|
||||
{logs.length === 0 ? (
|
||||
<div className="p-4 text-sm text-[var(--text-muted)]">暂无登录日志</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="px-4 py-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="font-medium">{log.username}</span>
|
||||
<span className="text-[var(--text-muted)]">{log.ip}</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">{log.status}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
{log.message} · {log.created_at}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === 'backup' ? (
|
||||
<Card className="space-y-3 p-4">
|
||||
<h2 className="font-medium">备份与恢复</h2>
|
||||
<div className="text-sm text-[var(--text-muted)]">
|
||||
{backupStatus?.has_backup ? `最近备份: ${backupStatus.backup_time}` : '暂无备份'}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => createBackup.mutate()} disabled={createBackup.isPending}>
|
||||
<RefreshCw size={14} />
|
||||
创建备份
|
||||
</Button>
|
||||
<a href={api.settings.downloadBackup()} className="inline-flex">
|
||||
<Button variant="secondary">
|
||||
<Download size={14} />
|
||||
下载备份
|
||||
</Button>
|
||||
</a>
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-2 text-sm hover:bg-[var(--bg-hover)]">
|
||||
<Upload size={14} />
|
||||
恢复备份
|
||||
<input type="file" className="hidden" onChange={(e) => restoreBackup(e.target.files?.[0])} />
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{tab === 'about' ? (
|
||||
<div className="space-y-4">
|
||||
<Card className="space-y-2 p-4 text-sm">
|
||||
<h2 className="font-medium">关于</h2>
|
||||
<div className="text-[var(--text-muted)]">版本: {about?.version || '-'}</div>
|
||||
<div className="text-[var(--text-muted)]">远程版本: {about?.remote_version || '-'}</div>
|
||||
<div className="text-[var(--text-muted)]">构建时间: {about?.build_time || '-'}</div>
|
||||
<div className="text-[var(--text-muted)]">运行时间: {about?.uptime || '-'}</div>
|
||||
<div className="text-[var(--text-muted)]">内存: {about?.mem_usage || '-'}</div>
|
||||
<div className="text-[var(--text-muted)]">
|
||||
任务/日志/变量: {about?.task_count ?? '-'} / {about?.log_count ?? '-'} / {about?.env_count ?? '-'}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<h2 className="mb-2 font-medium">更新日志</h2>
|
||||
<Textarea className="min-h-[240px] font-mono text-xs" readOnly value={changelog || '暂无'} />
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal as XTerminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { Copy, Info, RefreshCw } from 'lucide-react'
|
||||
import { useTerminalCmds } from '@/api/hooks'
|
||||
import { Button } from '@/components/ui'
|
||||
import { useTheme, type ThemeId } from '@/context/ThemeContext'
|
||||
import { getTerminalTheme } from '@/lib/terminal-theme'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
type StatusState = { text: string; type: 'success' | 'error' | 'info' } | null
|
||||
|
||||
/** xterm 要求 theme 必须是新对象引用才会生效 */
|
||||
function applyXtermTheme(term: XTerminal, themeId: ThemeId, host?: HTMLElement | null) {
|
||||
const next = { ...getTerminalTheme(themeId) }
|
||||
// 文档明确:对象选项需新引用,且可用 options 批量赋值
|
||||
term.options = { theme: next }
|
||||
if (host) {
|
||||
host.style.backgroundColor = String(next.background || '')
|
||||
host.style.color = String(next.foreground || '')
|
||||
host.dataset.termTheme = themeId
|
||||
}
|
||||
try {
|
||||
// 强制重绘全部可见行
|
||||
const rows = Math.max(1, term.rows)
|
||||
term.refresh(0, rows - 1)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { theme } = useTheme()
|
||||
const themeRef = useRef<ThemeId>(theme)
|
||||
themeRef.current = theme
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const termRef = useRef<XTerminal | null>(null)
|
||||
const fitRef = useRef<FitAddon | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const isPtyModeRef = useRef(false)
|
||||
const inputBufferRef = useRef('')
|
||||
const historyRef = useRef<string[]>([])
|
||||
const historyIndexRef = useRef(-1)
|
||||
const [status, setStatus] = useState<StatusState>({ text: '正在初始化终端...', type: 'info' })
|
||||
const [reconnecting, setReconnecting] = useState(false)
|
||||
const [copied, setCopied] = useState('')
|
||||
const [showCmds, setShowCmds] = useState(false)
|
||||
const { data: cmdsData } = useTerminalCmds()
|
||||
const cmds = Array.isArray(cmdsData) ? cmdsData : []
|
||||
|
||||
function safeFit() {
|
||||
try {
|
||||
const term = termRef.current
|
||||
const fit = fitRef.current
|
||||
const el = containerRef.current
|
||||
if (!term || !fit || !el) return
|
||||
if (el.clientWidth < 10 || el.clientHeight < 10) return
|
||||
fit.fit()
|
||||
if (isPtyModeRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function disposeSocket() {
|
||||
const ws = wsRef.current
|
||||
if (ws) {
|
||||
try {
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
wsRef.current = null
|
||||
}
|
||||
|
||||
function disposeTerminal() {
|
||||
disposeSocket()
|
||||
try {
|
||||
termRef.current?.dispose()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
termRef.current = null
|
||||
fitRef.current = null
|
||||
isPtyModeRef.current = false
|
||||
inputBufferRef.current = ''
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
disposeSocket()
|
||||
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`
|
||||
|
||||
let ws: WebSocket
|
||||
try {
|
||||
ws = new WebSocket(wsUrl)
|
||||
} catch {
|
||||
setStatus({ text: '无法创建 WebSocket 连接', type: 'error' })
|
||||
return
|
||||
}
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus({ text: '已连接到终端', type: 'success' })
|
||||
// 连接后再次套用当前主题,防止初始化时序问题
|
||||
if (termRef.current) applyXtermTheme(termRef.current, themeRef.current, containerRef.current)
|
||||
safeFit()
|
||||
termRef.current?.focus()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = event.data
|
||||
if (data === '__PTY_MODE__') {
|
||||
isPtyModeRef.current = true
|
||||
safeFit()
|
||||
return
|
||||
}
|
||||
if (data === '__PIPE_MODE__') {
|
||||
isPtyModeRef.current = false
|
||||
safeFit()
|
||||
return
|
||||
}
|
||||
termRef.current?.write(String(data))
|
||||
}
|
||||
|
||||
ws.onclose = () => setStatus({ text: '连接已断开', type: 'error' })
|
||||
ws.onerror = () => setStatus({ text: '连接错误', type: 'error' })
|
||||
}
|
||||
|
||||
function initTerminal() {
|
||||
if (!containerRef.current) return
|
||||
disposeTerminal()
|
||||
containerRef.current.innerHTML = ''
|
||||
|
||||
setStatus({ text: '正在初始化终端...', type: 'info' })
|
||||
|
||||
const currentTheme = themeRef.current
|
||||
const termTheme = { ...getTerminalTheme(currentTheme) }
|
||||
|
||||
const term = new XTerminal({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: 0.4,
|
||||
fontWeight: '400',
|
||||
fontWeightBold: '700',
|
||||
fontFamily: 'var(--font-mono), ui-monospace, SFMono-Regular, Consolas, Menlo, monospace',
|
||||
scrollback: 5000,
|
||||
drawBoldTextInBrightColors: true,
|
||||
theme: termTheme,
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.open(containerRef.current)
|
||||
applyXtermTheme(term, currentTheme, containerRef.current)
|
||||
|
||||
const xtermEl = containerRef.current.querySelector('.xterm') as HTMLElement | null
|
||||
if (xtermEl) {
|
||||
xtermEl.style.height = '100%'
|
||||
xtermEl.style.width = '100%'
|
||||
}
|
||||
termRef.current = term
|
||||
fitRef.current = fitAddon
|
||||
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if (e.ctrlKey && e.code === 'KeyC' && e.type === 'keydown') {
|
||||
const selection = term.getSelection()
|
||||
if (selection) {
|
||||
navigator.clipboard
|
||||
.writeText(selection)
|
||||
.then(() => {
|
||||
setCopied('已复制')
|
||||
toast.success('已复制选中内容')
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('复制失败')
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
term.onData((data) => {
|
||||
const ws = wsRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
|
||||
if (isPtyModeRef.current) {
|
||||
ws.send(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (data === '\r') {
|
||||
term.write('\r\n')
|
||||
if (inputBufferRef.current.trim()) {
|
||||
historyRef.current.push(inputBufferRef.current)
|
||||
historyIndexRef.current = historyRef.current.length
|
||||
ws.send(inputBufferRef.current + '\r\n')
|
||||
}
|
||||
inputBufferRef.current = ''
|
||||
} else if (data === '\x1b[A') {
|
||||
if (historyRef.current.length > 0 && historyIndexRef.current > 0) {
|
||||
for (let i = 0; i < inputBufferRef.current.length; i++) term.write('\b \b')
|
||||
historyIndexRef.current--
|
||||
inputBufferRef.current = historyRef.current[historyIndexRef.current] ?? ''
|
||||
term.write(inputBufferRef.current)
|
||||
}
|
||||
} else if (data === '\x1b[B') {
|
||||
for (let i = 0; i < inputBufferRef.current.length; i++) term.write('\b \b')
|
||||
if (historyIndexRef.current < historyRef.current.length - 1) {
|
||||
historyIndexRef.current++
|
||||
inputBufferRef.current = historyRef.current[historyIndexRef.current] ?? ''
|
||||
term.write(inputBufferRef.current)
|
||||
} else {
|
||||
historyIndexRef.current = historyRef.current.length
|
||||
inputBufferRef.current = ''
|
||||
}
|
||||
} else if (data === '\x7f' || data === '\b') {
|
||||
if (inputBufferRef.current.length > 0) {
|
||||
inputBufferRef.current = inputBufferRef.current.slice(0, -1)
|
||||
term.write('\b \b')
|
||||
}
|
||||
} else if (data === '\x03') {
|
||||
ws.send('\x03')
|
||||
inputBufferRef.current = ''
|
||||
historyIndexRef.current = historyRef.current.length
|
||||
term.write('^C\r\n')
|
||||
} else if (data === '\x0c') {
|
||||
if (!inputBufferRef.current) {
|
||||
ws.send('clear\r\n')
|
||||
} else {
|
||||
term.clear()
|
||||
term.write(inputBufferRef.current)
|
||||
}
|
||||
} else if (data >= ' ' || data === '\t') {
|
||||
inputBufferRef.current += data
|
||||
term.write(data)
|
||||
}
|
||||
})
|
||||
|
||||
window.setTimeout(() => {
|
||||
// 再套一次主题(确保 open 后 canvas/dom 渲染器吃到)
|
||||
applyXtermTheme(term, themeRef.current, containerRef.current)
|
||||
safeFit()
|
||||
term.focus()
|
||||
window.setTimeout(() => {
|
||||
connectWebSocket()
|
||||
try {
|
||||
const pending = sessionStorage.getItem('taskpool_terminal_cmd')
|
||||
if (pending) {
|
||||
sessionStorage.removeItem('taskpool_terminal_cmd')
|
||||
inputBufferRef.current = pending
|
||||
term.write(pending)
|
||||
setStatus({ text: '已预填命令,按回车执行', type: 'info' })
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 150)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
setReconnecting(true)
|
||||
initTerminal()
|
||||
window.setTimeout(() => setReconnecting(false), 1000)
|
||||
}
|
||||
|
||||
async function copyCmd(name: string) {
|
||||
const text = `taskpool ${name}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(text)
|
||||
toast.success(`已复制: ${text}`)
|
||||
window.setTimeout(() => setCopied((c) => (c === text ? '' : c)), 1500)
|
||||
} catch {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化终端(只建一次实例)
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(initTerminal, 120)
|
||||
const onResize = () => safeFit()
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
window.removeEventListener('resize', onResize)
|
||||
disposeTerminal()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 主题切换:等 data-theme / CSS 变量落盘后再套用 xterm 主题
|
||||
useEffect(() => {
|
||||
themeRef.current = theme
|
||||
let cancelled = false
|
||||
const run = () => {
|
||||
if (cancelled) return
|
||||
const term = termRef.current
|
||||
if (!term) return
|
||||
applyXtermTheme(term, theme, containerRef.current)
|
||||
}
|
||||
// 双 rAF:确保 ThemeProvider 已写入 data-theme,且浏览器完成样式计算
|
||||
const id1 = window.requestAnimationFrame(() => {
|
||||
const id2 = window.requestAnimationFrame(run)
|
||||
// 再补一帧延迟,覆盖部分浏览器样式批处理
|
||||
window.setTimeout(run, 0)
|
||||
void id2
|
||||
})
|
||||
// 监听 html[data-theme] 变化作为兜底(顶栏切换一定改这个)
|
||||
const root = document.documentElement
|
||||
const mo = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (m.type === 'attributes' && m.attributeName === 'data-theme') {
|
||||
run()
|
||||
}
|
||||
}
|
||||
})
|
||||
mo.observe(root, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.cancelAnimationFrame(id1)
|
||||
mo.disconnect()
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
const statusColor =
|
||||
status?.type === 'success'
|
||||
? 'text-[var(--success)]'
|
||||
: status?.type === 'error'
|
||||
? 'text-[var(--danger)]'
|
||||
: 'text-[var(--info)]'
|
||||
const statusDot =
|
||||
status?.type === 'success'
|
||||
? 'bg-[var(--success)]'
|
||||
: status?.type === 'error'
|
||||
? 'bg-[var(--danger)]'
|
||||
: 'bg-[var(--info)]'
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-7.5rem)] min-h-[420px] gap-3 overflow-hidden">
|
||||
{/* 左侧:内置命令常驻列表 */}
|
||||
<aside className="flex w-[280px] shrink-0 flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-secondary)]">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--border)] px-3 py-2.5">
|
||||
<div className="text-xs font-medium text-[var(--text-primary)]">内置命令</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)]">{cmds.length}</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{cmds.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-xs text-[var(--text-muted)]">暂无命令</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--border)]">
|
||||
{cmds.map((cmd) => {
|
||||
const full = `taskpool ${cmd.name}`
|
||||
const isCopied = copied === full
|
||||
return (
|
||||
<li key={cmd.name}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-start gap-2 px-3 py-2.5 text-left transition-colors hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => copyCmd(cmd.name)}
|
||||
title="点击复制命令"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<code className="mb-1 inline-block max-w-full truncate rounded border border-[var(--border)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--text-accent)]">
|
||||
{full}
|
||||
</code>
|
||||
<p className="line-clamp-2 text-[12px] leading-5 text-[var(--text-secondary)]">
|
||||
{cmd.description || '暂无说明'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center text-[var(--text-muted)]">
|
||||
{isCopied ? (
|
||||
<span className="text-[10px] whitespace-nowrap text-[var(--success)]">已复制</span>
|
||||
) : (
|
||||
<Copy size={13} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-[var(--border)] px-3 py-2 text-[10px] text-[var(--text-muted)]">
|
||||
点击命令即可复制
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右侧:终端 */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-secondary)]">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--border)] px-3 py-2">
|
||||
<span className="text-xs font-medium text-[var(--text-primary)]">终端</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{status ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${statusDot}`} />
|
||||
<span className={`text-[11px] font-medium ${statusColor}`}>{status.text}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" onClick={reconnect} disabled={reconnecting} title="重新连接">
|
||||
<RefreshCw size={14} className={reconnecting ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-[var(--bg-secondary)]" data-theme-sync={theme}>
|
||||
<div ref={containerRef} className="terminal-host absolute inset-0 h-full w-full px-3 py-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { createRootRoute, createRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||
import Layout from '@/components/Layout'
|
||||
import { lazyPage } from '@/lib/lazy-page'
|
||||
import { getAuthStatus } from '@/lib/auth'
|
||||
|
||||
// 轻量入口页直接加载,其余路由懒加载以降低主包
|
||||
import Login from '@/pages/Login'
|
||||
import Install from '@/pages/Install'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
import { ErrorPage } from '@/pages/ErrorPage'
|
||||
|
||||
const Dashboard = lazyPage(() => import('@/pages/Dashboard'))
|
||||
const Tasks = lazyPage(() => import('@/pages/Tasks'))
|
||||
const Editor = lazyPage(() => import('@/pages/Editor'))
|
||||
const Environments = lazyPage(() => import('@/pages/Environments'))
|
||||
const Languages = lazyPage(() => import('@/pages/Languages'))
|
||||
const Dependencies = lazyPage(() => import('@/pages/Dependencies'))
|
||||
const Agents = lazyPage(() => import('@/pages/Agents'))
|
||||
const History = lazyPage(() => import('@/pages/History'))
|
||||
const MessageLogs = lazyPage(() => import('@/pages/MessageLogs'))
|
||||
const Terminal = lazyPage(() => import('@/pages/Terminal'))
|
||||
const Notify = lazyPage(() => import('@/pages/Notify'))
|
||||
const Monitor = lazyPage(() => import('@/pages/Monitor'))
|
||||
const Interconnect = lazyPage(() => import('@/pages/Interconnect'))
|
||||
const Settings = lazyPage(() => import('@/pages/Settings'))
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: Outlet,
|
||||
errorComponent: ErrorPage,
|
||||
notFoundComponent: NotFound,
|
||||
})
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/login',
|
||||
component: Login,
|
||||
beforeLoad: async () => {
|
||||
const isAuthenticated = await getAuthStatus(true)
|
||||
if (isAuthenticated) {
|
||||
throw redirect({ to: '/' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const installRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/install',
|
||||
component: Install,
|
||||
})
|
||||
|
||||
// pathless layout,避免与 dashboard 的 path `/` 产生重复路由 id
|
||||
const layoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'app',
|
||||
component: Layout,
|
||||
beforeLoad: async () => {
|
||||
const isAuthenticated = await getAuthStatus()
|
||||
if (!isAuthenticated) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const dashboardRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/',
|
||||
component: Dashboard,
|
||||
})
|
||||
|
||||
const tasksRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/tasks',
|
||||
component: Tasks,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
agent_id: typeof search.agent_id === 'string' ? search.agent_id : undefined,
|
||||
type: typeof search.type === 'string' ? search.type : undefined,
|
||||
tags: typeof search.tags === 'string' ? search.tags : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const agentsRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/agents',
|
||||
component: Agents,
|
||||
})
|
||||
|
||||
const editorRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/editor',
|
||||
component: Editor,
|
||||
})
|
||||
|
||||
const historyRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/history',
|
||||
component: History,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
status: typeof search.status === 'string' ? search.status : undefined,
|
||||
task_name: typeof search.task_name === 'string' ? search.task_name : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const environmentsRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/environments',
|
||||
component: Environments,
|
||||
})
|
||||
|
||||
const languagesRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/languages',
|
||||
component: Languages,
|
||||
})
|
||||
|
||||
const dependenciesRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/dependencies',
|
||||
component: Dependencies,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
language: typeof search.language === 'string' ? search.language : undefined,
|
||||
version: typeof search.version === 'string' ? search.version : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const terminalRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/terminal',
|
||||
component: Terminal,
|
||||
})
|
||||
|
||||
const notifyRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/notify',
|
||||
component: Notify,
|
||||
})
|
||||
|
||||
const logsRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/logs',
|
||||
component: MessageLogs,
|
||||
})
|
||||
|
||||
const monitorRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/monitor',
|
||||
component: Monitor,
|
||||
})
|
||||
|
||||
const interconnectRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/interconnect',
|
||||
component: Interconnect,
|
||||
})
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => layoutRoute,
|
||||
path: '/settings',
|
||||
component: Settings,
|
||||
})
|
||||
|
||||
const notFoundRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/404',
|
||||
component: NotFound,
|
||||
})
|
||||
|
||||
const catchAllRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '$',
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/404' })
|
||||
},
|
||||
})
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
installRoute,
|
||||
notFoundRoute,
|
||||
catchAllRoute,
|
||||
layoutRoute.addChildren([
|
||||
dashboardRoute,
|
||||
tasksRoute,
|
||||
agentsRoute,
|
||||
editorRoute,
|
||||
historyRoute,
|
||||
environmentsRoute,
|
||||
languagesRoute,
|
||||
dependenciesRoute,
|
||||
terminalRoute,
|
||||
notifyRoute,
|
||||
logsRoute,
|
||||
monitorRoute,
|
||||
interconnectRoute,
|
||||
settingsRoute,
|
||||
]),
|
||||
])
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
root: '.',
|
||||
publicDir: 'public',
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
chunkSizeWarningLimit: 700,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
{
|
||||
name: 'vendor-react',
|
||||
test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/,
|
||||
},
|
||||
{
|
||||
name: 'vendor-router',
|
||||
test: /node_modules[\\/]@tanstack[\\/]/,
|
||||
},
|
||||
{
|
||||
name: 'vendor-charts',
|
||||
test: /node_modules[\\/](recharts|d3-|victory-|lodash)[\\/]/,
|
||||
},
|
||||
{
|
||||
name: 'vendor-monaco',
|
||||
test: /node_modules[\\/](@monaco-editor|monaco-editor)[\\/]/,
|
||||
},
|
||||
{
|
||||
name: 'vendor-xterm',
|
||||
test: /node_modules[\\/](@xterm|xterm)[\\/]/,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
strictPort: true,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8052',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
'/open2api': {
|
||||
target: 'http://127.0.0.1:8052',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'http://127.0.0.1:8052',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user