From 6ffa2ecdfba33b681eafe34226960d791f5d3e63 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 20 Jul 2026 17:00:21 +0000 Subject: [PATCH] feat: add Monaco editor, API error handling, and CI/CD - 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 --- .gitea/workflows/build.yml | 95 +++++++++++ Dockerfile | 32 ++++ docker-compose.yml | 38 ++--- nginx.conf | 32 ++++ web/package.json | 3 +- web/pnpm-lock.yaml | 57 +++++++ web/src/api/hooks.ts | 113 +++++++++---- web/src/api/index.ts | 177 +++++++++++++++++-- web/src/pages/Scripts.tsx | 336 +++++++++++++++++++++++-------------- 9 files changed, 686 insertions(+), 197 deletions(-) create mode 100644 .gitea/workflows/build.yml create mode 100644 Dockerfile create mode 100644 nginx.conf diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..ae9d718 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f07212c --- /dev/null +++ b/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 2bc6754..3478260 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,21 @@ +version: '3.8' + services: - baihu: - build: - context: . - dockerfile: docker/Dockerfile + frontend: + image: git.viaeon.com/admin/taskpool-react:latest ports: - - "8052:8052" - volumes: - - ./data:/app/data - - ./configs:/app/configs - - ./envs:/app/envs + - "3000:80" + depends_on: + - backend + restart: unless-stopped + + backend: + image: git.viaeon.com/admin/taskpool:latest environment: - - TZ=Asia/Shanghai - # 以下环境变量可覆盖配置文件(可选) - # - BH_SERVER_PORT=8052 - # - BH_SERVER_HOST=0.0.0.0 - # - BH_DB_TYPE=mysql - # - BH_DB_HOST=localhost - # - BH_DB_PORT=3306 - # - BH_DB_USER=root - # - BH_DB_PASSWORD=password - # - BH_DB_NAME=baihu - # - BH_DB_TABLE_PREFIX=baihu_ - # - BH_SECRET=your_secret_key + - DB_TYPE=sqlite + - DB_PATH=/data/taskpool.db + volumes: + - ./data:/data + ports: + - "8080:8080" restart: unless-stopped \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..e566184 --- /dev/null +++ b/nginx.conf @@ -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"; + } +} \ No newline at end of file diff --git a/web/package.json b/web/package.json index eca4072..bbceffd 100644 --- a/web/package.json +++ b/web/package.json @@ -9,11 +9,12 @@ "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/xterm": "^5.5.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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index bf727d5..a617215 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: 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': specifier: ^5.80.0 version: 5.101.2(react@19.2.7) @@ -85,6 +88,16 @@ packages: '@emnapi/wasi-threads@1.2.2': 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': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -276,6 +289,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vitejs/plugin-react@6.0.3': resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -381,6 +397,9 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + electron-to-chromium@1.5.393: resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} @@ -523,6 +542,14 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} @@ -619,6 +646,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -735,6 +765,17 @@ snapshots: tslib: 2.8.1 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)': dependencies: '@emnapi/core': 1.11.1 @@ -878,6 +919,9 @@ snapshots: dependencies: 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))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -963,6 +1007,10 @@ snapshots: '@babel/runtime': 7.29.7 csstype: 3.2.3 + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + electron-to-chromium@1.5.393: {} escalade@3.2.0: {} @@ -1054,6 +1102,13 @@ snapshots: dependencies: 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: dependencies: motion-utils: 11.18.1 @@ -1160,6 +1215,8 @@ snapshots: source-map-js@1.2.1: {} + state-local@1.0.7: {} + tailwindcss@4.3.3: {} tiny-invariant@1.3.3: {} diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts index 9fc108e..ed0c5ae 100644 --- a/web/src/api/hooks.ts +++ b/web/src/api/hooks.ts @@ -1,21 +1,40 @@ 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() { return useQuery({ queryKey: ['user'], queryFn: api.getCurrentUser, 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() { return useQuery({ queryKey: ['monitor'], - queryFn: api.getMonitor, + queryFn: api.getMonitorStats, refetchInterval: 5000, + retry: 2, }) } @@ -27,8 +46,8 @@ export function useTaskStats() { }) } -// Tasks -export function useTasks(params?: { status?: string; tag?: string }) { +// Task hooks +export function useTasks(params?: Record) { return useQuery({ queryKey: ['tasks', params], queryFn: () => api.getTasks(params), @@ -47,15 +66,17 @@ export function useCreateTask() { const qc = useQueryClient() return useMutation({ mutationFn: api.createTask, - onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tasks'] }) + qc.invalidateQueries({ queryKey: ['taskStats'] }) + }, }) } export function useUpdateTask() { const qc = useQueryClient() return useMutation({ - mutationFn: ({ id, data }: { id: number; data: Parameters[1] }) => - api.updateTask(id, data), + mutationFn: ({ id, data }: { id: number; data: any }) => api.updateTask(id, data), onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }), }) } @@ -64,7 +85,10 @@ export function useDeleteTask() { const qc = useQueryClient() return useMutation({ 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() { return useQuery({ 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() { const qc = useQueryClient() return useMutation({ @@ -108,15 +147,39 @@ export function useDeleteScript() { }) } -// Logs -export function useLogs(params?: { task_id?: number; status?: string; page?: number }) { +// Env hooks +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) { return useQuery({ queryKey: ['logs', params], queryFn: () => api.getLogs(params), }) } -// Interconnect +// Node hooks export function useNodes() { return useQuery({ queryKey: ['nodes'], @@ -141,26 +204,18 @@ export function useDeleteNode() { }) } -// Env -export function useEnvs() { +// Settings hooks +export function useSettings() { return useQuery({ - queryKey: ['envs'], - queryFn: api.getEnvs, + queryKey: ['settings'], + queryFn: api.getSettings, }) } -export function useCreateEnv() { +export function useUpdateSettings() { 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'] }), + mutationFn: api.updateSettings, + onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }), }) } \ No newline at end of file diff --git a/web/src/api/index.ts b/web/src/api/index.ts index b0ed5ff..746d903 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -2,32 +2,173 @@ 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 +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 { code: number - msg: string + message: string data: T } -export async function request(url: string, options?: RequestInit): Promise { - const res = await fetch(`${API_BASE_URL}${url}`, { - ...options, - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...options?.headers +async function request(url: string, options?: RequestInit): Promise { + const token = localStorage.getItem('token') + + const headers: Record = { + 'Content-Type': 'application/json', + ...((options?.headers as Record) || {}), + } + + 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 result: ApiResponse = await response.json() + + // Business error + if (result.code !== 0 && result.code !== 200) { + 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 + ) + } +} + +// 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 +} - const json: ApiResponse = await res.json() - - if (json.code === 401) { - window.location.href = BASE_URL + '/login' - throw new Error(json.msg || 'Unauthorized') +export const logout = async () => { + try { + await request('/auth/logout', { method: 'POST' }) + } finally { + localStorage.removeItem('token') } +} - if (json.code !== 200) { - throw new Error(json.msg || 'Request failed') - } +export const getCurrentUser = () => request<{ id: number; username: string; role: string }>('/auth/me') - return json.data -} \ No newline at end of file +// Dashboard +export const getMonitorStats = () => request('/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) => { + const query = params ? '?' + new URLSearchParams(params).toString() : '' + return request(`/tasks${query}`) +} + +export const getTask = (id: number) => request(`/tasks/${id}`) + +export const createTask = (data: any) => request('/tasks', { + method: 'POST', + body: JSON.stringify(data), +}) + +export const updateTask = (id: number, data: any) => request(`/tasks/${id}`, { + method: 'PUT', + body: JSON.stringify(data), +}) + +export const deleteTask = (id: number) => request(`/tasks/${id}`, { method: 'DELETE' }) + +export const executeTask = (id: number) => request(`/execute/task/${id}`, { method: 'POST' }) + +export const getTags = () => request('/tasks/tags') + +// Scripts +export const getScripts = () => request('/scripts') + +export const getScript = (id: number) => request(`/scripts/${id}`) + +export const createScript = (data: any) => request('/scripts', { + method: 'POST', + body: JSON.stringify(data), +}) + +export const updateScript = (id: number, data: any) => request(`/scripts/${id}`, { + method: 'PUT', + body: JSON.stringify(data), +}) + +export const deleteScript = (id: number) => request(`/scripts/${id}`, { method: 'DELETE' }) + +// Environment Variables +export const getEnvs = () => request('/env') + +export const createEnv = (data: any) => request('/env', { + method: 'POST', + body: JSON.stringify(data), +}) + +export const deleteEnv = (id: number) => request(`/env/${id}`, { method: 'DELETE' }) + +// Logs +export const getLogs = (params?: Record) => { + const query = params ? '?' + new URLSearchParams(params).toString() : '' + return request(`/logs${query}`) +} + +// Interconnect Nodes +export const getNodes = () => request('/interconnect/nodes') + +export const createNode = (data: any) => request('/interconnect/nodes', { + method: 'POST', + body: JSON.stringify(data), +}) + +export const deleteNode = (id: string) => request(`/interconnect/nodes/${id}`, { method: 'DELETE' }) + +// Settings +export const getSettings = () => request('/settings') +export const updateSettings = (data: any) => request('/settings', { + method: 'PUT', + body: JSON.stringify(data), +}) + +export { request } \ No newline at end of file diff --git a/web/src/pages/Scripts.tsx b/web/src/pages/Scripts.tsx index 43853ac..aacd5e1 100644 --- a/web/src/pages/Scripts.tsx +++ b/web/src/pages/Scripts.tsx @@ -1,166 +1,246 @@ -import { useScripts, useDeleteScript } from '@/api/hooks' -import { Plus, Trash2, FileCode, RefreshCw } from 'lucide-react' -import type { Script } from '@/api/types' +import { useState } from 'react' +import Editor from '@monaco-editor/react' +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() { const { data: scripts, isLoading, refetch } = useScripts() + const createScript = useCreateScript() const deleteScript = useDeleteScript() + + const [selectedScript, setSelectedScript] = useState(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) => { if (confirm('Delete this script?')) { 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) { return
Loading...
} return ( -
- {/* Header */} -
-

Scripts

-
+
+ {/* Sidebar */} +
+ {/* Header */} +
+ Scripts -
-
- {/* Grid */} -
- {scripts?.map(script => ( - handleDelete(script.id)} /> - ))} - {(!scripts || scripts.length === 0) && ( -
- No scripts found -
- )} -
-
- ) -} - -function ScriptCard({ script, onDelete }: { script: Script; onDelete: () => void }) { - const formatDate = (date: string) => { - return new Date(date).toLocaleDateString() - } - - const getLanguageColor = (lang: string) => { - const colors: Record = { - python: '#3776ab', - javascript: '#f7df1e', - typescript: '#3178c6', - go: '#00add8', - bash: '#4eaa25', - shell: '#4eaa25', - } - return colors[lang?.toLowerCase()] || '#6b7280' - } - - return ( -
-
-
-
- -
-
-
{script.name}
-
{script.filename}
-
+ {/* List */} +
+ {scripts?.map(script => ( +
handleSelectScript(script)} + style={{ + padding: '8px 12px', + borderRadius: 4, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 8, + background: selectedScript?.id === script.id ? 'var(--bg-tertiary)' : 'transparent', + marginBottom: 2, + }} + > + + + {script.name} + +
+ ))} + {(!scripts || scripts.length === 0) && ( +
+ No scripts +
+ )}
-
-
- + {/* Toolbar */} +
- {script.language || 'Unknown'} - -
+ 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, + }} + /> + +
+ {selectedScript && ( + + )} + +
-
- Updated: {formatDate(script.updated_at)} + {/* Monaco Editor */} +
+ 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 }, + }} + /> +
)