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:
2026-07-26 08:43:52 +08:00
commit e6956aa001
397 changed files with 73621 additions and 0 deletions
+23
View File
@@ -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?
+5
View File
@@ -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).
+24
View File
@@ -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>
+2629
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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"
}
}
+1277
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#3b82f6"/>
<text x="16" y="22" text-anchor="middle" fill="white" font-family="system-ui" font-size="18" font-weight="700">T</text>
</svg>

After

Width:  |  Height:  |  Size: 267 B

+1
View File
@@ -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

+24
View File
@@ -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"
}
]
}
+109
View File
@@ -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' });
};
+44
View File
@@ -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)
}
}
+349
View File
@@ -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
},
})
}
+872
View File
@@ -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
+69
View File
@@ -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' })
}
+823
View File
@@ -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
+103
View File
@@ -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
}
+206
View File
@@ -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>
)
}
+17
View File
@@ -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>
)
}
+68
View File
@@ -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>
)
}
+44
View File
@@ -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>
)
}
+257
View File
@@ -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>
)
}
+448
View File
@@ -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>
)
}
+29
View File
@@ -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>
)
}
+24
View File
@@ -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}
/>
)
}
+30
View File
@@ -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>
)
}
+37
View File
@@ -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>
)
}
+8
View File
@@ -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>
)
}
+19
View File
@@ -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>
)
}
+20
View File
@@ -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'
+6
View File
@@ -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)} />
}
+8
View File
@@ -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>
)
}
+64
View File
@@ -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>
)
}
+29
View File
@@ -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>
)
}
+9
View File
@@ -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>
)
}
+204
View File
@@ -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>
+36
View File
@@ -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>
)
}
+31
View File
@@ -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>
)
}
+46
View File
@@ -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>
)
}
+6
View File
@@ -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)} />
}
+66
View File
@@ -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')
+62
View File
@@ -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
}
+28
View File
@@ -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])
}
+123
View File
@@ -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 }
}
+436
View File
@@ -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;
}
+21
View File
@@ -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
}
+120
View File
@@ -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()
+46
View File
@@ -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)
}
}
+26
View File
@@ -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
}
+103
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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
}
+146
View File
@@ -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
}
+59
View File
@@ -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}"`
}
+63
View File
@@ -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',
}
}
+51
View File
@@ -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),
}
+3
View File
@@ -0,0 +1,3 @@
export function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
+31
View File
@@ -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>
)
+458
View File
@@ -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>
)
}
+197
View File
@@ -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>
)
}
+495
View File
@@ -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>
)
}
+546
View File
@@ -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>
)
}
+465
View File
@@ -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>
)
}
+41
View File
@@ -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
+307
View File
@@ -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>
)
}
+310
View File
@@ -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>
)
}
+311
View File
@@ -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>
)
}
+407
View File
@@ -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>
)
}
+95
View File
@@ -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>
)
}
+93
View File
@@ -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>
)
}
+190
View File
@@ -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>
)
}
+462
View File
@@ -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>
)
}
+28
View File
@@ -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>
)
}
+578
View File
@@ -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>
)
}
+24
View File
@@ -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>
)
}
+484
View File
@@ -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
+424
View File
@@ -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>
)
}
+196
View File
@@ -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,
]),
])
+21
View File
@@ -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"]
}
+66
View File
@@ -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,
},
},
},
})