feat: add Monaco editor, API error handling, and CI/CD
Build and Deploy / build (push) Failing after 32s
Build and Deploy / docker (push) Has been skipped
Build and Deploy / deploy (push) Has been skipped

- Monaco Editor: script editor with syntax highlighting
- API: error handling with ApiError class and token management
- CI/CD: Gitea Actions workflow for build, Docker, and deploy
- Docker: multi-stage build with nginx
- nginx.conf: SPA fallback and API proxy
This commit is contained in:
2026-07-20 17:00:21 +00:00
parent 2f76060c39
commit 6ffa2ecdfb
9 changed files with 686 additions and 197 deletions
+95
View File
@@ -0,0 +1,95 @@
name: Build and Deploy
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
env:
REGISTRY: git.viaeon.com
IMAGE_NAME: admin/taskpool-react
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
cache-dependency-path: web/pnpm-lock.yaml
- name: Install dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Build
working-directory: web
run: pnpm build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: web/dist
docker:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: dist
path: web/dist
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
deploy:
needs: docker
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
password: ${{ secrets.SSH_PASSWORD }}
script: |
cd /opt/taskpool
docker compose pull
docker compose up -d
docker image prune -f
+32
View File
@@ -0,0 +1,32 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9 --activate
# Copy package files
COPY web/package.json web/pnpm-lock.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source
COPY web/ ./
# Build
RUN pnpm build
# Production stage
FROM nginx:alpine
# Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+18 -22
View File
@@ -1,25 +1,21 @@
version: '3.8'
services: services:
baihu: frontend:
build: image: git.viaeon.com/admin/taskpool-react:latest
context: .
dockerfile: docker/Dockerfile
ports: ports:
- "8052:8052" - "3000:80"
volumes: depends_on:
- ./data:/app/data - backend
- ./configs:/app/configs restart: unless-stopped
- ./envs:/app/envs
environment: backend:
- TZ=Asia/Shanghai image: git.viaeon.com/admin/taskpool:latest
# 以下环境变量可覆盖配置文件(可选) environment:
# - BH_SERVER_PORT=8052 - DB_TYPE=sqlite
# - BH_SERVER_HOST=0.0.0.0 - DB_PATH=/data/taskpool.db
# - BH_DB_TYPE=mysql volumes:
# - BH_DB_HOST=localhost - ./data:/data
# - BH_DB_PORT=3306 ports:
# - BH_DB_USER=root - "8080:8080"
# - BH_DB_PASSWORD=password
# - BH_DB_NAME=baihu
# - BH_DB_TABLE_PREFIX=baihu_
# - BH_SECRET=your_secret_key
restart: unless-stopped restart: unless-stopped
+32
View File
@@ -0,0 +1,32 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
gzip_min_length 1000;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+2 -1
View File
@@ -9,11 +9,12 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0",
"@tanstack/react-query": "^5.80.0", "@tanstack/react-query": "^5.80.0",
"@tanstack/react-router": "^1.120.0", "@tanstack/react-router": "^1.120.0",
"@tanstack/react-table": "^8.21.0", "@tanstack/react-table": "^8.21.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"react": "^19.2.7", "react": "^19.2.7",
+57
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.80.0 specifier: ^5.80.0
version: 5.101.2(react@19.2.7) version: 5.101.2(react@19.2.7)
@@ -85,6 +88,16 @@ packages:
'@emnapi/wasi-threads@1.2.2': '@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@monaco-editor/loader@1.7.0':
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
'@monaco-editor/react@4.7.0':
resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
peerDependencies:
monaco-editor: '>= 0.25.0 < 1'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@napi-rs/wasm-runtime@1.1.6': '@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies: peerDependencies:
@@ -276,6 +289,9 @@ packages:
'@types/react@19.2.17': '@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@vitejs/plugin-react@6.0.3': '@vitejs/plugin-react@6.0.3':
resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -381,6 +397,9 @@ packages:
dom-helpers@5.2.1: dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
electron-to-chromium@1.5.393: electron-to-chromium@1.5.393:
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
@@ -523,6 +542,14 @@ packages:
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
hasBin: true
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
motion-dom@11.18.1: motion-dom@11.18.1:
resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==}
@@ -619,6 +646,9 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
tailwindcss@4.3.3: tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
@@ -735,6 +765,17 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@monaco-editor/loader@1.7.0':
dependencies:
state-local: 1.0.7
'@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@monaco-editor/loader': 1.7.0
monaco-editor: 0.55.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies: dependencies:
'@emnapi/core': 1.11.1 '@emnapi/core': 1.11.1
@@ -878,6 +919,9 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/trusted-types@2.0.7':
optional: true
'@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@22.20.1))': '@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@22.20.1))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.1 '@rolldown/pluginutils': 1.0.1
@@ -963,6 +1007,10 @@ snapshots:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7
csstype: 3.2.3 csstype: 3.2.3
dompurify@3.2.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
electron-to-chromium@1.5.393: {} electron-to-chromium@1.5.393: {}
escalade@3.2.0: {} escalade@3.2.0: {}
@@ -1054,6 +1102,13 @@ snapshots:
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
marked@14.0.0: {}
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
marked: 14.0.0
motion-dom@11.18.1: motion-dom@11.18.1:
dependencies: dependencies:
motion-utils: 11.18.1 motion-utils: 11.18.1
@@ -1160,6 +1215,8 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
state-local@1.0.7: {}
tailwindcss@4.3.3: {} tailwindcss@4.3.3: {}
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
+84 -29
View File
@@ -1,21 +1,40 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import * as api from '@/api/endpoints' import * as api from './index'
import type * as types from './types'
// Auth // Re-export types
export type * from './types'
// Auth hooks
export function useUser() { export function useUser() {
return useQuery({ return useQuery({
queryKey: ['user'], queryKey: ['user'],
queryFn: api.getCurrentUser, queryFn: api.getCurrentUser,
retry: false, retry: false,
staleTime: 5 * 60 * 1000,
}) })
} }
// Dashboard export function useLogin() {
return useMutation({
mutationFn: ({ username, password }: { username: string; password: string }) =>
api.login(username, password),
})
}
export function useLogout() {
return useMutation({
mutationFn: api.logout,
})
}
// Dashboard hooks
export function useMonitorStats() { export function useMonitorStats() {
return useQuery({ return useQuery({
queryKey: ['monitor'], queryKey: ['monitor'],
queryFn: api.getMonitor, queryFn: api.getMonitorStats,
refetchInterval: 5000, refetchInterval: 5000,
retry: 2,
}) })
} }
@@ -27,8 +46,8 @@ export function useTaskStats() {
}) })
} }
// Tasks // Task hooks
export function useTasks(params?: { status?: string; tag?: string }) { export function useTasks(params?: Record<string, string>) {
return useQuery({ return useQuery({
queryKey: ['tasks', params], queryKey: ['tasks', params],
queryFn: () => api.getTasks(params), queryFn: () => api.getTasks(params),
@@ -47,15 +66,17 @@ export function useCreateTask() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: api.createTask, mutationFn: api.createTask,
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }), onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tasks'] })
qc.invalidateQueries({ queryKey: ['taskStats'] })
},
}) })
} }
export function useUpdateTask() { export function useUpdateTask() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: number; data: Parameters<typeof api.updateTask>[1] }) => mutationFn: ({ id, data }: { id: number; data: any }) => api.updateTask(id, data),
api.updateTask(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
}) })
} }
@@ -64,7 +85,10 @@ export function useDeleteTask() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: api.deleteTask, mutationFn: api.deleteTask,
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }), onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tasks'] })
qc.invalidateQueries({ queryKey: ['taskStats'] })
},
}) })
} }
@@ -76,7 +100,14 @@ export function useExecuteTask() {
}) })
} }
// Scripts export function useTags() {
return useQuery({
queryKey: ['tags'],
queryFn: api.getTags,
})
}
// Script hooks
export function useScripts() { export function useScripts() {
return useQuery({ return useQuery({
queryKey: ['scripts'], queryKey: ['scripts'],
@@ -100,6 +131,14 @@ export function useCreateScript() {
}) })
} }
export function useUpdateScript() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: any }) => api.updateScript(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['scripts'] }),
})
}
export function useDeleteScript() { export function useDeleteScript() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
@@ -108,15 +147,39 @@ export function useDeleteScript() {
}) })
} }
// Logs // Env hooks
export function useLogs(params?: { task_id?: number; status?: string; page?: number }) { export function useEnvs() {
return useQuery({
queryKey: ['envs'],
queryFn: api.getEnvs,
})
}
export function useCreateEnv() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.createEnv,
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
})
}
export function useDeleteEnv() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.deleteEnv,
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
})
}
// Log hooks
export function useLogs(params?: Record<string, string>) {
return useQuery({ return useQuery({
queryKey: ['logs', params], queryKey: ['logs', params],
queryFn: () => api.getLogs(params), queryFn: () => api.getLogs(params),
}) })
} }
// Interconnect // Node hooks
export function useNodes() { export function useNodes() {
return useQuery({ return useQuery({
queryKey: ['nodes'], queryKey: ['nodes'],
@@ -141,26 +204,18 @@ export function useDeleteNode() {
}) })
} }
// Env // Settings hooks
export function useEnvs() { export function useSettings() {
return useQuery({ return useQuery({
queryKey: ['envs'], queryKey: ['settings'],
queryFn: api.getEnvs, queryFn: api.getSettings,
}) })
} }
export function useCreateEnv() { export function useUpdateSettings() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: api.createEnv, mutationFn: api.updateSettings,
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
})
}
export function useDeleteEnv() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.deleteEnv,
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
}) })
} }
+160 -19
View File
@@ -2,32 +2,173 @@ const BASE_URL = (window as any).__BASE_URL__ || ''
const API_VERSION = (window as any).__API_VERSION__ || '/api/v1' const API_VERSION = (window as any).__API_VERSION__ || '/api/v1'
const API_BASE_URL = BASE_URL + API_VERSION const API_BASE_URL = BASE_URL + API_VERSION
export class ApiError extends Error {
code: number
data?: any
constructor(message: string, code: number, data?: any) {
super(message)
this.name = 'ApiError'
this.code = code
this.data = data
}
}
interface ApiResponse<T> { interface ApiResponse<T> {
code: number code: number
msg: string message: string
data: T data: T
} }
export async function request<T>(url: string, options?: RequestInit): Promise<T> { async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE_URL}${url}`, { const token = localStorage.getItem('token')
...options,
credentials: 'include', const headers: Record<string, string> = {
headers: { 'Content-Type': 'application/json',
'Content-Type': 'application/json', ...((options?.headers as Record<string, string>) || {}),
...options?.headers }
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
try {
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers,
})
// Handle non-JSON responses
const contentType = response.headers.get('content-type')
if (!contentType?.includes('application/json')) {
if (response.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
throw new ApiError('Unauthorized', 401)
}
if (!response.ok) {
throw new ApiError(`HTTP ${response.status}`, response.status)
}
return {} as T
} }
})
const json: ApiResponse<T> = await res.json() const result: ApiResponse<T> = await response.json()
if (json.code === 401) { // Business error
window.location.href = BASE_URL + '/login' if (result.code !== 0 && result.code !== 200) {
throw new Error(json.msg || 'Unauthorized') throw new ApiError(result.message || 'Request failed', result.code, result.data)
}
return result.data
} catch (error) {
if (error instanceof ApiError) {
throw error
}
// Network error
throw new ApiError(
error instanceof Error ? error.message : 'Network error',
0
)
} }
if (json.code !== 200) {
throw new Error(json.msg || 'Request failed')
}
return json.data
} }
// Auth
export const login = async (username: string, password: string) => {
const data = await request<{ token: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
localStorage.setItem('token', data.token)
return data
}
export const logout = async () => {
try {
await request('/auth/logout', { method: 'POST' })
} finally {
localStorage.removeItem('token')
}
}
export const getCurrentUser = () => request<{ id: number; username: string; role: string }>('/auth/me')
// Dashboard
export const getMonitorStats = () => request<any>('/monitor')
export const getTaskStats = () => request<{ total: number; running: number; scheduled: number }>('/taskstats')
export const getSentence = () => request<{ content: string; author: string }>('/sentence')
// Tasks
export const getTasks = (params?: Record<string, string>) => {
const query = params ? '?' + new URLSearchParams(params).toString() : ''
return request<any[]>(`/tasks${query}`)
}
export const getTask = (id: number) => request<any>(`/tasks/${id}`)
export const createTask = (data: any) => request<any>('/tasks', {
method: 'POST',
body: JSON.stringify(data),
})
export const updateTask = (id: number, data: any) => request<any>(`/tasks/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
export const deleteTask = (id: number) => request<void>(`/tasks/${id}`, { method: 'DELETE' })
export const executeTask = (id: number) => request<void>(`/execute/task/${id}`, { method: 'POST' })
export const getTags = () => request<string[]>('/tasks/tags')
// Scripts
export const getScripts = () => request<any[]>('/scripts')
export const getScript = (id: number) => request<any>(`/scripts/${id}`)
export const createScript = (data: any) => request<any>('/scripts', {
method: 'POST',
body: JSON.stringify(data),
})
export const updateScript = (id: number, data: any) => request<any>(`/scripts/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
export const deleteScript = (id: number) => request<void>(`/scripts/${id}`, { method: 'DELETE' })
// Environment Variables
export const getEnvs = () => request<any[]>('/env')
export const createEnv = (data: any) => request<any>('/env', {
method: 'POST',
body: JSON.stringify(data),
})
export const deleteEnv = (id: number) => request<void>(`/env/${id}`, { method: 'DELETE' })
// Logs
export const getLogs = (params?: Record<string, string>) => {
const query = params ? '?' + new URLSearchParams(params).toString() : ''
return request<any[]>(`/logs${query}`)
}
// Interconnect Nodes
export const getNodes = () => request<any[]>('/interconnect/nodes')
export const createNode = (data: any) => request<any>('/interconnect/nodes', {
method: 'POST',
body: JSON.stringify(data),
})
export const deleteNode = (id: string) => request<void>(`/interconnect/nodes/${id}`, { method: 'DELETE' })
// Settings
export const getSettings = () => request<any>('/settings')
export const updateSettings = (data: any) => request<any>('/settings', {
method: 'PUT',
body: JSON.stringify(data),
})
export { request }
+208 -128
View File
@@ -1,166 +1,246 @@
import { useScripts, useDeleteScript } from '@/api/hooks' import { useState } from 'react'
import { Plus, Trash2, FileCode, RefreshCw } from 'lucide-react' import Editor from '@monaco-editor/react'
import type { Script } from '@/api/types' import { useScripts, useCreateScript, useDeleteScript } from '@/api/hooks'
import { Plus, Trash2, Save, FileCode, Play, FolderOpen } from 'lucide-react'
const LANGUAGES = [
{ id: 'python', name: 'Python', extension: '.py', monaco: 'python' },
{ id: 'javascript', name: 'JavaScript', extension: '.js', monaco: 'javascript' },
{ id: 'typescript', name: 'TypeScript', extension: '.ts', monaco: 'typescript' },
{ id: 'bash', name: 'Bash', extension: '.sh', monaco: 'shell' },
{ id: 'go', name: 'Go', extension: '.go', monaco: 'go' },
]
export default function Scripts() { export default function Scripts() {
const { data: scripts, isLoading, refetch } = useScripts() const { data: scripts, isLoading, refetch } = useScripts()
const createScript = useCreateScript()
const deleteScript = useDeleteScript() const deleteScript = useDeleteScript()
const [selectedScript, setSelectedScript] = useState<any>(null)
const [code, setCode] = useState('')
const [language, setLanguage] = useState('python')
const [name, setName] = useState('')
const handleNewScript = () => {
setSelectedScript(null)
setCode('# New script\nprint("Hello World")')
setName('untitled')
setLanguage('python')
}
const handleSave = async () => {
await createScript.mutateAsync({
name,
filename: name + LANGUAGES.find(l => l.id === language)?.extension,
content: code,
language,
})
refetch()
}
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (confirm('Delete this script?')) { if (confirm('Delete this script?')) {
await deleteScript.mutateAsync(id) await deleteScript.mutateAsync(id)
if (selectedScript?.id === id) {
setSelectedScript(null)
setCode('')
}
refetch()
} }
} }
const handleSelectScript = (script: any) => {
setSelectedScript(script)
setCode(script.content || '')
setName(script.name)
setLanguage(script.language?.toLowerCase() || 'python')
}
if (isLoading) { if (isLoading) {
return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Loading...</div> return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Loading...</div>
} }
return ( return (
<div> <div style={{ display: 'flex', height: 'calc(100vh - 140px)', gap: 16 }}>
{/* Header */} {/* Sidebar */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}> <div style={{
<h1 style={{ fontSize: 24, fontWeight: 600 }}>Scripts</h1> width: 240,
<div style={{ display: 'flex', gap: 8 }}> flexShrink: 0,
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
display: 'flex',
flexDirection: 'column',
}}>
{/* Header */}
<div style={{
padding: 12,
borderBottom: '1px solid var(--border)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontWeight: 500, fontSize: 13 }}>Scripts</span>
<button <button
onClick={() => refetch()} onClick={handleNewScript}
title="New script"
style={{ style={{
width: 28,
height: 28,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 6, justifyContent: 'center',
padding: '8px 12px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 6,
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 13,
}}
>
<RefreshCw size={14} strokeWidth={1.5} />
Refresh
</button>
<button
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 12px',
background: 'var(--bg-accent)', background: 'var(--bg-accent)',
border: 'none', border: 'none',
borderRadius: 6, borderRadius: 4,
color: 'white', color: 'white',
cursor: 'pointer', cursor: 'pointer',
fontSize: 13,
}} }}
> >
<Plus size={14} strokeWidth={1.5} /> <Plus size={14} strokeWidth={1.5} />
Add Script
</button> </button>
</div> </div>
</div>
{/* Grid */} {/* List */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}> <div style={{ flex: 1, overflow: 'auto', padding: 8 }}>
{scripts?.map(script => ( {scripts?.map(script => (
<ScriptCard key={script.id} script={script} onDelete={() => handleDelete(script.id)} /> <div
))} key={script.id}
{(!scripts || scripts.length === 0) && ( onClick={() => handleSelectScript(script)}
<div style={{ style={{
gridColumn: '1 / -1', padding: '8px 12px',
padding: 48, borderRadius: 4,
textAlign: 'center', cursor: 'pointer',
color: 'var(--text-muted)', display: 'flex',
background: 'var(--bg-secondary)', alignItems: 'center',
border: '1px solid var(--border)', gap: 8,
borderRadius: 8, background: selectedScript?.id === script.id ? 'var(--bg-tertiary)' : 'transparent',
}}> marginBottom: 2,
No scripts found }}
</div> >
)} <FileCode size={14} strokeWidth={1.5} style={{ color: 'var(--text-muted)' }} />
</div> <span style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
</div> {script.name}
) </span>
} </div>
))}
function ScriptCard({ script, onDelete }: { script: Script; onDelete: () => void }) { {(!scripts || scripts.length === 0) && (
const formatDate = (date: string) => { <div style={{ padding: 20, textAlign: 'center', color: 'var(--text-muted)', fontSize: 12 }}>
return new Date(date).toLocaleDateString() No scripts
} </div>
)}
const getLanguageColor = (lang: string) => {
const colors: Record<string, string> = {
python: '#3776ab',
javascript: '#f7df1e',
typescript: '#3178c6',
go: '#00add8',
bash: '#4eaa25',
shell: '#4eaa25',
}
return colors[lang?.toLowerCase()] || '#6b7280'
}
return (
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: 16,
transition: 'border-color 0.15s',
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 40,
height: 40,
borderRadius: 6,
background: `${getLanguageColor(script.language)}15`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<FileCode size={20} strokeWidth={1.5} style={{ color: getLanguageColor(script.language) }} />
</div>
<div>
<div style={{ fontWeight: 500, marginBottom: 2 }}>{script.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: 'monospace' }}>{script.filename}</div>
</div>
</div> </div>
<button
onClick={onDelete}
title="Delete"
style={{
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
border: 'none',
borderRadius: 4,
color: 'var(--text-secondary)',
cursor: 'pointer',
}}
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}> {/* Editor */}
<span style={{ <div style={{
padding: '4px 8px', flex: 1,
background: `${getLanguageColor(script.language)}15`, display: 'flex',
borderRadius: 4, flexDirection: 'column',
fontSize: 11, background: 'var(--bg-secondary)',
color: getLanguageColor(script.language), border: '1px solid var(--border)',
fontWeight: 500, borderRadius: 8,
overflow: 'hidden',
}}>
{/* Toolbar */}
<div style={{
padding: '8px 12px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
gap: 12,
}}> }}>
{script.language || 'Unknown'} <input
</span> type="text"
</div> value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Script name"
style={{
padding: '6px 10px',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 4,
color: 'var(--text-primary)',
fontSize: 13,
width: 200,
}}
/>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
style={{
padding: '6px 10px',
background: 'var(--bg-primary)',
border: '1px solid var(--border)',
borderRadius: 4,
color: 'var(--text-primary)',
fontSize: 13,
}}
>
{LANGUAGES.map(lang => (
<option key={lang.id} value={lang.id}>{lang.name}</option>
))}
</select>
<div style={{ flex: 1 }} />
{selectedScript && (
<button
onClick={() => handleDelete(selectedScript.id)}
title="Delete"
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '6px 10px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: 4,
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 12,
}}
>
<Trash2 size={12} strokeWidth={1.5} />
Delete
</button>
)}
<button
onClick={handleSave}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '6px 12px',
background: 'var(--bg-accent)',
border: 'none',
borderRadius: 4,
color: 'white',
cursor: 'pointer',
fontSize: 12,
}}
>
<Save size={12} strokeWidth={1.5} />
Save
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}> {/* Monaco Editor */}
Updated: {formatDate(script.updated_at)} <div style={{ flex: 1 }}>
<Editor
height="100%"
language={LANGUAGES.find(l => l.id === language)?.monaco || 'python'}
value={code}
onChange={(value) => setCode(value || '')}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
fontFamily: 'JetBrains Mono, Menlo, monospace',
lineNumbers: 'on',
scrollBeyondLastLine: false,
padding: { top: 12 },
}}
/>
</div>
</div> </div>
</div> </div>
) )