style(frontend): 接入 stash-ui 设计系统
- 依赖 @stash/ui(file:../../stash-ui),统一引入 style.css 与 Toaster - 任务面板、表单、弹窗、日志、状态标签全部迁到组件库,App.css 只保留页面布局 - 移除自写 useFocusTrap,改用基于原生 dialog 的 Modal - 修复 LogViewer 流式追加时误判离开底部、导致不再自动跟随的问题 - 发布脚本检查 stash-ui 产物;README 补充组件库构建与 file: 依赖刷新说明
This commit is contained in:
@@ -6,7 +6,7 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台
|
|||||||
- 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程
|
- 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程
|
||||||
- SQLite 持久化,重启后任务与历史仍在
|
- SQLite 持久化,重启后任务与历史仍在
|
||||||
- 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示
|
- 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示
|
||||||
- 前端零外部依赖:样式与脚本随包自带;界面字体可选,未生成时自动回退系统字体
|
- 前端零外部网络依赖:样式与组件来自同级的 `stash-ui` 设计系统(`file:` 依赖,构建后随包自带);界面字体可选,未生成时自动回退系统字体
|
||||||
|
|
||||||
> 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。
|
> 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。
|
||||||
|
|
||||||
@@ -58,6 +58,10 @@ release/TaskPilot/
|
|||||||
### 方式二:本地开发
|
### 方式二:本地开发
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
# 前端依赖 @stash/ui(file:../../stash-ui),首次使用先在组件库构建 dist
|
||||||
|
npm --prefix ..\stash-ui install
|
||||||
|
npm --prefix ..\stash-ui run build
|
||||||
|
|
||||||
pnpm --dir frontend install
|
pnpm --dir frontend install
|
||||||
cargo run --manifest-path backend/Cargo.toml # 终端一:API,127.0.0.1:3000
|
cargo run --manifest-path backend/Cargo.toml # 终端一:API,127.0.0.1:3000
|
||||||
pnpm --dir frontend dev # 终端二:http://127.0.0.1:5173
|
pnpm --dir frontend dev # 终端二:http://127.0.0.1:5173
|
||||||
@@ -66,6 +70,9 @@ pnpm --dir frontend dev # 终端二:http://127.0.0.1:
|
|||||||
Vite 把 `/api` 代理到后端。改后端端口时(`TASK_PILOT_PORT`)需同步修改
|
Vite 把 `/api` 代理到后端。改后端端口时(`TASK_PILOT_PORT`)需同步修改
|
||||||
`frontend/vite.config.ts` 的代理目标;打包版同源,不需要改前端。
|
`frontend/vite.config.ts` 的代理目标;打包版同源,不需要改前端。
|
||||||
|
|
||||||
|
`@stash/ui` 走 `file:` 依赖(pnpm 会复制一份构建产物):组件库重新 `npm run build`
|
||||||
|
之后,需要再执行一次 `pnpm --dir frontend install` 才会同步新的 `dist`。
|
||||||
|
|
||||||
### 方式三:用 uv 跑官方示例与自检
|
### 方式三:用 uv 跑官方示例与自检
|
||||||
|
|
||||||
仓库自带一个 uv 管理的演示任务和一个端到端自检脚本:
|
仓库自带一个 uv 管理的演示任务和一个端到端自检脚本:
|
||||||
@@ -226,7 +233,10 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled
|
|||||||
- **任务表**:名称/ID、调度周期(含时区与**下次触发时间**)、类型、状态、超时、操作。
|
- **任务表**:名称/ID、调度周期(含时区与**下次触发时间**)、类型、状态、超时、操作。
|
||||||
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 5/10 次)、虚拟环境状态条、调用预览。
|
- **编辑器**:三类动态表单、Cron 预览(实时计算未来 5/10 次)、虚拟环境状态条、调用预览。
|
||||||
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
- **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。
|
||||||
- **日志抽屉**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。
|
||||||
|
- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自
|
||||||
|
`@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与
|
||||||
|
业务块(任务表单元格、日志控制台、表单栅格)。
|
||||||
- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用
|
- **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用
|
||||||
`LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用
|
`LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用
|
||||||
`tools/build-fonts.py` 生成,缺失时回退系统字体。
|
`tools/build-fonts.py` 生成,缺失时回退系统字体。
|
||||||
@@ -423,6 +433,7 @@ React (Vite) ──fetch──► Axum router ──security::api_guard(Origin +
|
|||||||
|
|
||||||
| 模块 | 职责 |
|
| 模块 | 职责 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| `@stash/ui`(同级 `../stash-ui`) | 设计令牌与通用组件:按钮、输入、表格、标签、弹窗、分页、Toast、排版 |
|
||||||
| `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 |
|
| `pages/TaskDashboard.tsx` | 列表、筛选、分页、指标卡、下次触发时间 |
|
||||||
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
|
| `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 |
|
||||||
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 |
|
| `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 |
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
|||||||
// Cron 预览是异步渲染的,等它稳定后再点击,避免点击坐标在弹窗重排中失效。
|
// Cron 预览是异步渲染的,等它稳定后再点击,避免点击坐标在弹窗重排中失效。
|
||||||
await expect(newTaskDialog.locator('.cron-run-item').first()).toBeVisible()
|
await expect(newTaskDialog.locator('.cron-run-item').first()).toBeVisible()
|
||||||
await newTaskDialog.getByRole('button', { name: '选择文件夹…' }).click()
|
await newTaskDialog.getByRole('button', { name: '选择文件夹…' }).click()
|
||||||
const directoryPicker = page.getByRole('dialog', { name: '选择项目工作目录' })
|
const directoryPicker = page.getByRole('dialog', { name: '选择工作目录' })
|
||||||
await expect(directoryPicker).toBeVisible()
|
await expect(directoryPicker).toBeVisible()
|
||||||
const directoryPathInput = directoryPicker.getByLabel('目录路径')
|
const directoryPathInput = directoryPicker.getByLabel('目录路径')
|
||||||
await directoryPathInput.fill(workingDir)
|
await directoryPathInput.fill(workingDir)
|
||||||
@@ -140,7 +140,7 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
|||||||
path: 'test-results/dashboard-log-drawer.png',
|
path: 'test-results/dashboard-log-drawer.png',
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
})
|
})
|
||||||
await drawer.getByRole('button', { name: '关闭运行日志' }).click()
|
await drawer.getByRole('button', { name: '关闭' }).click()
|
||||||
|
|
||||||
await page.getByRole('button', { name: '编辑' }).click()
|
await page.getByRole('button', { name: '编辑' }).click()
|
||||||
const editor = page.getByRole('dialog', {
|
const editor = page.getByRole('dialog', {
|
||||||
@@ -161,10 +161,10 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: '删除' }).click()
|
await page.getByRole('button', { name: '删除' }).click()
|
||||||
await page
|
await page
|
||||||
.getByRole('alertdialog')
|
.getByRole('dialog', { name: /删除任务/ })
|
||||||
.getByRole('button', { name: '确认删除' })
|
.getByRole('button', { name: '确认删除' })
|
||||||
.click()
|
.click()
|
||||||
await expect(page.getByRole('heading', { name: '还没有任务' })).toBeVisible()
|
await expect(page.getByText('还没有任务')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('keeps the dashboard usable on a narrow viewport', async ({
|
test('keeps the dashboard usable on a narrow viewport', async ({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.9.2",
|
"@hookform/resolvers": "^5.9.2",
|
||||||
|
"@stash/ui": "file:../../stash-ui",
|
||||||
"@tanstack/react-query": "^5.104.3",
|
"@tanstack/react-query": "^5.104.3",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
|||||||
Generated
+14
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.9.2
|
specifier: ^5.9.2
|
||||||
version: 5.9.2([email protected](@types/[email protected])([email protected]))([email protected])
|
version: 5.9.2([email protected](@types/[email protected])([email protected]))([email protected])
|
||||||
|
'@stash/ui':
|
||||||
|
specifier: file:../../stash-ui
|
||||||
|
version: file:../../stash-ui([email protected]([email protected]))([email protected])
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.104.3
|
specifier: ^5.104.3
|
||||||
version: 5.104.3([email protected])
|
version: 5.104.3([email protected])
|
||||||
@@ -477,6 +480,12 @@ packages:
|
|||||||
'@standard-schema/[email protected]':
|
'@standard-schema/[email protected]':
|
||||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
|
'@stash/ui@file:../../stash-ui':
|
||||||
|
resolution: {directory: ../../stash-ui, type: directory}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=19'
|
||||||
|
react-dom: '>=19'
|
||||||
|
|
||||||
'@tanstack/[email protected]':
|
'@tanstack/[email protected]':
|
||||||
resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==}
|
resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==}
|
||||||
|
|
||||||
@@ -1361,6 +1370,11 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/[email protected]': {}
|
'@standard-schema/[email protected]': {}
|
||||||
|
|
||||||
|
'@stash/ui@file:../../stash-ui([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
react: 19.3.1
|
||||||
|
react-dom: 19.3.1([email protected])
|
||||||
|
|
||||||
'@tanstack/[email protected]': {}
|
'@tanstack/[email protected]': {}
|
||||||
|
|
||||||
'@tanstack/[email protected]([email protected])':
|
'@tanstack/[email protected]([email protected])':
|
||||||
|
|||||||
+418
-1045
File diff suppressed because it is too large
Load Diff
@@ -299,7 +299,7 @@ describe('TaskDashboard', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog', {
|
const dialog = await screen.findByRole('dialog', {
|
||||||
name: '选择项目工作目录',
|
name: '选择工作目录',
|
||||||
})
|
})
|
||||||
await user.click(await within(dialog).findByRole('button', { name: 'D:\\' }))
|
await user.click(await within(dialog).findByRole('button', { name: 'D:\\' }))
|
||||||
await user.click(await within(dialog).findByRole('button', { name: 'Projects' }))
|
await user.click(await within(dialog).findByRole('button', { name: 'Projects' }))
|
||||||
@@ -307,7 +307,7 @@ describe('TaskDashboard', () => {
|
|||||||
expect(within(dialog).getByText('D:\\Projects\\sync')).toBeInTheDocument()
|
expect(within(dialog).getByText('D:\\Projects\\sync')).toBeInTheDocument()
|
||||||
await user.click(within(dialog).getByRole('button', { name: '使用此目录' }))
|
await user.click(within(dialog).getByRole('button', { name: '使用此目录' }))
|
||||||
|
|
||||||
expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull()
|
expect(screen.queryByRole('dialog', { name: '选择工作目录' })).toBeNull()
|
||||||
expect(screen.getByLabelText(/项目工作目录/)).toHaveValue(
|
expect(screen.getByLabelText(/项目工作目录/)).toHaveValue(
|
||||||
'D:\\Projects\\sync',
|
'D:\\Projects\\sync',
|
||||||
)
|
)
|
||||||
@@ -346,7 +346,7 @@ describe('TaskDashboard', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
|
||||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||||
const dialog = await screen.findByRole('dialog', {
|
const dialog = await screen.findByRole('dialog', {
|
||||||
name: '选择项目工作目录',
|
name: '选择工作目录',
|
||||||
})
|
})
|
||||||
await user.click(await within(dialog).findByRole('button', { name: 'D:\\' }))
|
await user.click(await within(dialog).findByRole('button', { name: 'D:\\' }))
|
||||||
await user.click(await within(dialog).findByRole('button', { name: 'Projects' }))
|
await user.click(await within(dialog).findByRole('button', { name: 'Projects' }))
|
||||||
@@ -380,12 +380,12 @@ describe('TaskDashboard', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog', {
|
const dialog = await screen.findByRole('dialog', {
|
||||||
name: '选择项目工作目录',
|
name: '选择工作目录',
|
||||||
})
|
})
|
||||||
await user.click(within(dialog).getByRole('button', { name: '取消' }))
|
await user.click(within(dialog).getByRole('button', { name: '取消' }))
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull(),
|
expect(screen.queryByRole('dialog', { name: '选择工作目录' })).toBeNull(),
|
||||||
)
|
)
|
||||||
expect(input).toHaveValue('D:\\Manual\\path')
|
expect(input).toHaveValue('D:\\Manual\\path')
|
||||||
})
|
})
|
||||||
@@ -413,7 +413,7 @@ describe('TaskDashboard', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
await user.click(screen.getByRole('button', { name: '选择文件夹…' }))
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog', {
|
const dialog = await screen.findByRole('dialog', {
|
||||||
name: '选择项目工作目录',
|
name: '选择工作目录',
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
await within(dialog).findByText('目录不存在: Z:\\nope'),
|
await within(dialog).findByText('目录不存在: Z:\\nope'),
|
||||||
|
|||||||
+10
-11
@@ -1,20 +1,19 @@
|
|||||||
import { Link, Route, Routes } from 'react-router-dom'
|
import { Button, Result } from '@stash/ui'
|
||||||
|
import { Route, Routes, useNavigate } from 'react-router-dom'
|
||||||
import { TaskDashboard } from './pages/TaskDashboard'
|
import { TaskDashboard } from './pages/TaskDashboard'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
function NotFoundPage() {
|
function NotFoundPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<header className="dashboard-header">
|
<Result
|
||||||
<div>
|
status="info"
|
||||||
<p className="eyebrow">404 / ROUTE NOT FOUND</p>
|
title="页面不存在"
|
||||||
<h1>页面不存在</h1>
|
description="该地址尚未配置到 TaskPilot 路由。"
|
||||||
<p className="subtitle">该地址尚未配置到 TaskPilot 路由。</p>
|
actions={<Button onClick={() => navigate('/')}>返回任务列表</Button>}
|
||||||
</div>
|
/>
|
||||||
</header>
|
|
||||||
<Link className="button button-primary" to="/">
|
|
||||||
返回任务列表
|
|
||||||
</Link>
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { Button, Modal, Text } from '@stash/ui'
|
||||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -10,6 +9,10 @@ interface ConfirmDialogProps {
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 危险操作确认。外壳交给组件库的 `Modal`(原生 dialog:焦点陷阱 / Esc / 背景 inert),
|
||||||
|
* 这里只负责文案与「取消在左、危险操作在右」的按钮顺序。
|
||||||
|
*/
|
||||||
export function ConfirmDialog({
|
export function ConfirmDialog({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
@@ -18,66 +21,31 @@ export function ConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
const dialogRef = useRef<HTMLElement>(null)
|
const requestClose = () => {
|
||||||
useFocusTrap(open, dialogRef)
|
if (!busy) {
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape' && !busy) {
|
|
||||||
onCancel()
|
onCancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
|
||||||
}, [busy, onCancel, open])
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
className="modal-overlay"
|
open={open}
|
||||||
onMouseDown={(event) => {
|
onClose={requestClose}
|
||||||
if (event.currentTarget === event.target && !busy) {
|
title={title}
|
||||||
onCancel()
|
size="sm"
|
||||||
}
|
closeOnOverlay={!busy}
|
||||||
}}
|
footer={
|
||||||
>
|
<>
|
||||||
<section
|
<Button variant="secondary" onClick={requestClose} disabled={busy}>
|
||||||
ref={dialogRef}
|
|
||||||
className="confirm-card"
|
|
||||||
tabIndex={-1}
|
|
||||||
role="alertdialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="confirm-title"
|
|
||||||
aria-describedby="confirm-description"
|
|
||||||
>
|
|
||||||
<p className="eyebrow">CONFIRM ACTION</p>
|
|
||||||
<h2 id="confirm-title">{title}</h2>
|
|
||||||
<p className="muted-copy">{description}</p>
|
|
||||||
<div className="modal-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button button-secondary"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="danger" onClick={onConfirm} loading={busy}>
|
||||||
type="button"
|
|
||||||
className="button button-danger"
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{busy ? '删除中...' : '确认删除'}
|
{busy ? '删除中...' : '确认删除'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
</section>
|
}
|
||||||
</div>
|
>
|
||||||
|
<Text tone="sub">{description}</Text>
|
||||||
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import {
|
||||||
|
Button,
|
||||||
|
Empty,
|
||||||
|
Icon,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Notice,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
} from '@stash/ui'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { listDirectories } from '../api/client'
|
import { listDirectories } from '../api/client'
|
||||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
|
||||||
|
|
||||||
interface DirectoryPickerDialogProps {
|
interface DirectoryPickerDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -13,6 +22,9 @@ interface DirectoryPickerDialogProps {
|
|||||||
/**
|
/**
|
||||||
* 页面内的目录浏览器:从盘符开始逐级进入文件夹,最终把绝对路径回填到表单。
|
* 页面内的目录浏览器:从盘符开始逐级进入文件夹,最终把绝对路径回填到表单。
|
||||||
* 浏览器拿不到系统文件对话框的真实路径,因此目录浏览由后端提供列表接口。
|
* 浏览器拿不到系统文件对话框的真实路径,因此目录浏览由后端提供列表接口。
|
||||||
|
*
|
||||||
|
* 外壳是组件库的 `Modal`,路径输入 / 按钮 / 空态 / 错误提示分别是
|
||||||
|
* `Input` / `Button` / `Empty` / `Notice`。
|
||||||
*/
|
*/
|
||||||
export function DirectoryPickerDialog({
|
export function DirectoryPickerDialog({
|
||||||
open,
|
open,
|
||||||
@@ -22,8 +34,6 @@ export function DirectoryPickerDialog({
|
|||||||
}: DirectoryPickerDialogProps) {
|
}: DirectoryPickerDialogProps) {
|
||||||
const [currentPath, setCurrentPath] = useState<string | null>(null)
|
const [currentPath, setCurrentPath] = useState<string | null>(null)
|
||||||
const [draftPath, setDraftPath] = useState('')
|
const [draftPath, setDraftPath] = useState('')
|
||||||
const dialogRef = useRef<HTMLElement>(null)
|
|
||||||
useFocusTrap(open, dialogRef, '[data-picker-path]')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -47,23 +57,6 @@ export function DirectoryPickerDialog({
|
|||||||
}
|
}
|
||||||
}, [listing.data?.path])
|
}, [listing.data?.path])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
|
||||||
}, [onClose, open])
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = listing.data?.entries ?? []
|
const entries = listing.data?.entries ?? []
|
||||||
const currentDirectory = listing.data?.path ?? null
|
const currentDirectory = listing.data?.path ?? null
|
||||||
const parent = listing.data?.parent ?? null
|
const parent = listing.data?.parent ?? null
|
||||||
@@ -74,53 +67,46 @@ export function DirectoryPickerDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
className="modal-overlay"
|
open={open}
|
||||||
onMouseDown={(event) => {
|
onClose={onClose}
|
||||||
if (event.currentTarget === event.target) {
|
title="选择工作目录"
|
||||||
|
size="lg"
|
||||||
|
className="directory-picker-modal"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<div className="directory-picker-summary">
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
将使用
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" tone="sub" truncate>
|
||||||
|
{currentDirectory ?? '尚未选择目录'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!currentDirectory}
|
||||||
|
onClick={() => {
|
||||||
|
if (currentDirectory) {
|
||||||
|
onSelect(currentDirectory)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section
|
使用此目录
|
||||||
ref={dialogRef}
|
</Button>
|
||||||
className="modal-card directory-picker"
|
</>
|
||||||
role="dialog"
|
}
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="directory-picker-title"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<header className="modal-header">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">WORKING DIRECTORY</p>
|
|
||||||
<h2 id="directory-picker-title">选择项目工作目录</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="关闭目录选择"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="directory-picker-toolbar">
|
<div className="directory-picker-toolbar">
|
||||||
<button
|
<Button variant="secondary" onClick={() => navigate(parent)} disabled={!parent}>
|
||||||
type="button"
|
|
||||||
className="button button-secondary button-small"
|
|
||||||
onClick={() => navigate(parent)}
|
|
||||||
disabled={!parent}
|
|
||||||
>
|
|
||||||
上一级
|
上一级
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="secondary" onClick={() => navigate(null)}>
|
||||||
type="button"
|
|
||||||
className="button button-secondary button-small"
|
|
||||||
onClick={() => navigate(null)}
|
|
||||||
>
|
|
||||||
盘符
|
盘符
|
||||||
</button>
|
</Button>
|
||||||
<form
|
<form
|
||||||
className="directory-path-form"
|
className="directory-path-form"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
@@ -128,13 +114,12 @@ export function DirectoryPickerDialog({
|
|||||||
navigate(draftPath.trim() || null)
|
navigate(draftPath.trim() || null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
className="field-input mono"
|
className="directory-path-input"
|
||||||
value={draftPath}
|
value={draftPath}
|
||||||
onChange={(event) => setDraftPath(event.target.value)}
|
onChange={(event) => setDraftPath(event.target.value)}
|
||||||
placeholder="输入或粘贴绝对路径,例如 D:\Projects\sync"
|
placeholder="输入或粘贴绝对路径,例如 D:\Projects\sync"
|
||||||
aria-label="目录路径"
|
aria-label="目录路径"
|
||||||
data-picker-path
|
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
@@ -142,18 +127,32 @@ export function DirectoryPickerDialog({
|
|||||||
|
|
||||||
<div className="directory-picker-body">
|
<div className="directory-picker-body">
|
||||||
{listing.isPending ? (
|
{listing.isPending ? (
|
||||||
<p className="muted-copy">正在读取目录…</p>
|
<div className="picker-status">
|
||||||
) : null}
|
<Spinner label="正在读取目录" />
|
||||||
{listing.isError ? (
|
<Text tone="sub" size="sm">
|
||||||
<div className="inline-alert inline-alert-error" role="alert">
|
正在读取目录…
|
||||||
{listing.error.message}
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!listing.isPending && !listing.isError && entries.length === 0 ? (
|
|
||||||
<p className="empty-inline">
|
{listing.isError ? (
|
||||||
{currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'}
|
<Notice
|
||||||
</p>
|
tone="error"
|
||||||
|
title="目录读取失败"
|
||||||
|
className="directory-picker-error"
|
||||||
|
>
|
||||||
|
{listing.error.message}
|
||||||
|
</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!listing.isPending && !listing.isError && entries.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
text={
|
||||||
|
currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{entries.length > 0 ? (
|
{entries.length > 0 ? (
|
||||||
<ul className="directory-list">
|
<ul className="directory-list">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
@@ -164,45 +163,20 @@ export function DirectoryPickerDialog({
|
|||||||
onClick={() => navigate(entry.path)}
|
onClick={() => navigate(entry.path)}
|
||||||
title={entry.path}
|
title={entry.path}
|
||||||
>
|
>
|
||||||
<span className="directory-icon" aria-hidden="true">
|
<Icon className="directory-icon" name="folder_more_line" />
|
||||||
📁
|
|
||||||
</span>
|
|
||||||
<span className="directory-name">{entry.name}</span>
|
<span className="directory-name">{entry.name}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{listing.data?.truncated ? (
|
{listing.data?.truncated ? (
|
||||||
<p className="field-hint">文件夹过多,仅列出前 500 个。</p>
|
<Text size="xs" tone="muted">
|
||||||
|
文件夹过多,仅列出前 500 个。
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
<footer className="directory-picker-footer">
|
|
||||||
<div className="directory-picker-summary">
|
|
||||||
<span className="section-label">将使用</span>
|
|
||||||
<code>{currentDirectory ?? '尚未选择目录'}</code>
|
|
||||||
</div>
|
|
||||||
<div className="modal-actions directory-picker-actions">
|
|
||||||
<button type="button" className="button button-secondary" onClick={onClose}>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button button-primary"
|
|
||||||
disabled={!currentDirectory}
|
|
||||||
onClick={() => {
|
|
||||||
if (currentDirectory) {
|
|
||||||
onSelect(currentDirectory)
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
使用此目录
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function LogViewer({
|
|||||||
const viewportRef = useRef<HTMLDivElement>(null)
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
const frameRef = useRef(0)
|
const frameRef = useRef(0)
|
||||||
const followingRef = useRef(true)
|
const followingRef = useRef(true)
|
||||||
|
const lastScrollTopRef = useRef(0)
|
||||||
const [following, setFollowing] = useState(true)
|
const [following, setFollowing] = useState(true)
|
||||||
const [metrics, setMetrics] = useState({ scrollTop: 0, height: 0 })
|
const [metrics, setMetrics] = useState({ scrollTop: 0, height: 0 })
|
||||||
|
|
||||||
@@ -62,23 +63,34 @@ export function LogViewer({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (frameRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
frameRef.current = requestAnimationFrame(() => {
|
|
||||||
frameRef.current = 0
|
|
||||||
const node = viewportRef.current
|
const node = viewportRef.current
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 只有「用户往上翻」才停止跟随。日志流式追加时 scrollTop 不变但内容变高,
|
||||||
|
// 此时不能把 following 关掉,否则后半段日志不会再自动贴底。
|
||||||
|
const previousTop = lastScrollTopRef.current
|
||||||
|
lastScrollTopRef.current = node.scrollTop
|
||||||
const atBottom = isNearBottom(
|
const atBottom = isNearBottom(
|
||||||
node.scrollTop,
|
node.scrollTop,
|
||||||
node.clientHeight,
|
node.clientHeight,
|
||||||
node.scrollHeight,
|
node.scrollHeight,
|
||||||
FOLLOW_THRESHOLD_PX,
|
FOLLOW_THRESHOLD_PX,
|
||||||
)
|
)
|
||||||
followingRef.current = atBottom
|
if (atBottom) {
|
||||||
setFollowing(atBottom)
|
followingRef.current = true
|
||||||
|
setFollowing(true)
|
||||||
|
} else if (node.scrollTop < previousTop) {
|
||||||
|
followingRef.current = false
|
||||||
|
setFollowing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可见窗口的重算仍然按帧节流,避免滚动时每像素都 setState。
|
||||||
|
if (frameRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
frameRef.current = requestAnimationFrame(() => {
|
||||||
|
frameRef.current = 0
|
||||||
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
@@ -93,6 +105,7 @@ export function LogViewer({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
node.scrollTop = node.scrollHeight
|
node.scrollTop = node.scrollHeight
|
||||||
|
lastScrollTopRef.current = node.scrollTop
|
||||||
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
||||||
}, [version])
|
}, [version])
|
||||||
|
|
||||||
@@ -102,6 +115,7 @@ export function LogViewer({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
node.scrollTop = node.scrollHeight
|
node.scrollTop = node.scrollHeight
|
||||||
|
lastScrollTopRef.current = node.scrollTop
|
||||||
followingRef.current = true
|
followingRef.current = true
|
||||||
setFollowing(true)
|
setFollowing(true)
|
||||||
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight })
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Empty,
|
||||||
|
Modal,
|
||||||
|
Notice,
|
||||||
|
Select,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from '@stash/ui'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
cancelRun,
|
cancelRun,
|
||||||
@@ -18,7 +28,6 @@ import {
|
|||||||
isTerminalRunStatus,
|
isTerminalRunStatus,
|
||||||
} from '../lib/format'
|
} from '../lib/format'
|
||||||
import { appendLogChunk, createLogBuffer, flushLogTail } from '../lib/logLines'
|
import { appendLogChunk, createLogBuffer, flushLogTail } from '../lib/logLines'
|
||||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
|
||||||
import { LogViewer } from './LogViewer'
|
import { LogViewer } from './LogViewer'
|
||||||
import { RunStatusBadge } from './StatusBadge'
|
import { RunStatusBadge } from './StatusBadge'
|
||||||
|
|
||||||
@@ -29,6 +38,31 @@ interface RunLogDrawerProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_FILTERS = [
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'queued', label: '排队中' },
|
||||||
|
{ value: 'running', label: '运行中' },
|
||||||
|
{ value: 'succeeded', label: '成功' },
|
||||||
|
{ value: 'failed', label: '失败' },
|
||||||
|
{ value: 'timed_out', label: '已超时' },
|
||||||
|
{ value: 'cancelled', label: '已取消' },
|
||||||
|
{ value: 'skipped', label: '已跳过' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TRIGGER_FILTERS = [
|
||||||
|
{ value: '', label: '全部触发' },
|
||||||
|
{ value: 'schedule', label: '定时' },
|
||||||
|
{ value: 'manual', label: '手动' },
|
||||||
|
{ value: 'retry', label: '重试' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行历史 + 日志面板。
|
||||||
|
*
|
||||||
|
* 外壳用组件库的 `Modal`(原生 dialog:焦点陷阱、Esc、背景 inert),
|
||||||
|
* 左栏是运行列表(`Select` 筛选 + 状态标签),右栏是详情与虚拟滚动日志。
|
||||||
|
* 日志读取本身仍是页面逻辑(SSE + 轮询兜底),不放进组件库。
|
||||||
|
*/
|
||||||
export function RunLogDrawer({
|
export function RunLogDrawer({
|
||||||
open,
|
open,
|
||||||
task,
|
task,
|
||||||
@@ -43,15 +77,9 @@ export function RunLogDrawer({
|
|||||||
const [historyTrigger, setHistoryTrigger] = useState<RunTrigger | ''>('')
|
const [historyTrigger, setHistoryTrigger] = useState<RunTrigger | ''>('')
|
||||||
const [sseConnected, setSseConnected] = useState(false)
|
const [sseConnected, setSseConnected] = useState(false)
|
||||||
const [logEvents] = useState(() => new EventTarget())
|
const [logEvents] = useState(() => new EventTarget())
|
||||||
const drawerRef = useRef<HTMLElement>(null)
|
|
||||||
useFocusTrap(open, drawerRef)
|
|
||||||
const runsQuery = useQuery({
|
const runsQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: ['task-runs', task?.id, historyStatus, historyTrigger],
|
||||||
'task-runs',
|
|
||||||
task?.id,
|
|
||||||
historyStatus,
|
|
||||||
historyTrigger,
|
|
||||||
],
|
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
listTaskRuns(
|
listTaskRuns(
|
||||||
task!.id,
|
task!.id,
|
||||||
@@ -117,9 +145,7 @@ export function RunLogDrawer({
|
|||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data) as ExecutionEvent
|
const payload = JSON.parse(event.data) as ExecutionEvent
|
||||||
if (payload.event === 'log') {
|
if (payload.event === 'log') {
|
||||||
logEvents.dispatchEvent(
|
logEvents.dispatchEvent(new CustomEvent('log', { detail: payload }))
|
||||||
new CustomEvent('log', { detail: payload }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setSseConnected(false)
|
setSseConnected(false)
|
||||||
@@ -142,116 +168,75 @@ export function RunLogDrawer({
|
|||||||
}
|
}
|
||||||
}, [logEvents, open, queryClient, task])
|
}, [logEvents, open, queryClient, task])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
|
||||||
}, [onClose, open])
|
|
||||||
|
|
||||||
if (!open || !task) {
|
if (!open || !task) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
className="modal-overlay drawer-overlay"
|
open={open}
|
||||||
onMouseDown={(event) => {
|
onClose={onClose}
|
||||||
if (event.currentTarget === event.target) {
|
title={task.name}
|
||||||
onClose()
|
size="lg"
|
||||||
}
|
className="run-log-modal"
|
||||||
}}
|
closeOnOverlay
|
||||||
>
|
>
|
||||||
<aside
|
<div className="run-log-body">
|
||||||
ref={drawerRef}
|
|
||||||
className="run-drawer"
|
|
||||||
tabIndex={-1}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="run-drawer-title"
|
|
||||||
>
|
|
||||||
<header className="modal-header">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">RUN HISTORY</p>
|
|
||||||
<h2 id="run-drawer-title">{task.name}</h2>
|
|
||||||
<p className="drawer-subtitle mono">{task.id}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="关闭运行日志"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="run-drawer-body">
|
|
||||||
<section className="run-history-panel">
|
<section className="run-history-panel">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="section-label">RUNS</p>
|
<Text className="section-label" size="xs" tone="muted">
|
||||||
<h3>最近运行</h3>
|
RUN HISTORY
|
||||||
|
</Text>
|
||||||
|
<Title level={3}>最近运行</Title>
|
||||||
|
<Text className="drawer-subtitle" size="xs" tone="muted">
|
||||||
|
{task.id}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="sm"
|
||||||
className="button button-secondary button-small"
|
variant="secondary"
|
||||||
onClick={() => void runsQuery.refetch()}
|
onClick={() => void runsQuery.refetch()}
|
||||||
disabled={runsQuery.isFetching}
|
loading={runsQuery.isFetching}
|
||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-filters">
|
<div className="history-filters">
|
||||||
<select
|
<Select
|
||||||
className="field-input compact-select"
|
className="history-select"
|
||||||
aria-label="运行状态筛选"
|
options={STATUS_FILTERS}
|
||||||
value={historyStatus}
|
value={historyStatus}
|
||||||
onChange={(event) => {
|
onChange={(value) => {
|
||||||
setHistoryStatus(event.target.value as RunStatus | '')
|
setHistoryStatus(value as RunStatus | '')
|
||||||
setSelectedRunId(null)
|
setSelectedRunId(null)
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="">全部状态</option>
|
<Select
|
||||||
<option value="queued">排队中</option>
|
className="history-select"
|
||||||
<option value="running">运行中</option>
|
options={TRIGGER_FILTERS}
|
||||||
<option value="succeeded">成功</option>
|
|
||||||
<option value="failed">失败</option>
|
|
||||||
<option value="timed_out">已超时</option>
|
|
||||||
<option value="cancelled">已取消</option>
|
|
||||||
<option value="skipped">已跳过</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="field-input compact-select"
|
|
||||||
aria-label="触发方式筛选"
|
|
||||||
value={historyTrigger}
|
value={historyTrigger}
|
||||||
onChange={(event) => {
|
onChange={(value) => {
|
||||||
setHistoryTrigger(event.target.value as RunTrigger | '')
|
setHistoryTrigger(value as RunTrigger | '')
|
||||||
setSelectedRunId(null)
|
setSelectedRunId(null)
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="">全部触发</option>
|
|
||||||
<option value="schedule">定时</option>
|
|
||||||
<option value="manual">手动</option>
|
|
||||||
<option value="retry">重试</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{runsQuery.isPending ? (
|
{runsQuery.isPending ? (
|
||||||
<p className="muted-copy">正在加载运行记录...</p>
|
<div className="panel-status">
|
||||||
) : runsQuery.isError ? (
|
<Spinner label="正在加载运行记录" />
|
||||||
<div className="inline-alert inline-alert-error">
|
<Text size="sm" tone="sub">
|
||||||
{runsQuery.error.message}
|
正在加载运行记录...
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
) : runsQuery.isError ? (
|
||||||
|
<Notice tone="error" title="运行记录加载失败">
|
||||||
|
{runsQuery.error.message}
|
||||||
|
</Notice>
|
||||||
) : runs.length === 0 ? (
|
) : runs.length === 0 ? (
|
||||||
<p className="empty-inline">该任务还没有运行记录。</p>
|
<Empty text="该任务还没有运行记录。" />
|
||||||
) : (
|
) : (
|
||||||
<div className="run-list">
|
<div className="run-list">
|
||||||
{runs.map((run) => (
|
{runs.map((run) => (
|
||||||
@@ -279,47 +264,52 @@ export function RunLogDrawer({
|
|||||||
<section className="run-detail-panel">
|
<section className="run-detail-panel">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="section-label">DETAIL</p>
|
<Text className="section-label" size="xs" tone="muted">
|
||||||
<h3>
|
DETAIL
|
||||||
|
</Text>
|
||||||
|
<Title level={3}>
|
||||||
{effectiveRunId ? `运行 #${effectiveRunId}` : '运行详情'}
|
{effectiveRunId ? `运行 #${effectiveRunId}` : '运行详情'}
|
||||||
</h3>
|
</Title>
|
||||||
</div>
|
</div>
|
||||||
{selectedRun ? (
|
{selectedRun ? (
|
||||||
<div className="run-detail-actions">
|
<div className="run-detail-actions">
|
||||||
<RunStatusBadge status={selectedRun.status} />
|
<RunStatusBadge status={selectedRun.status} />
|
||||||
{!isTerminalRunStatus(selectedRun.status) ? (
|
{!isTerminalRunStatus(selectedRun.status) ? (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="sm"
|
||||||
className="button button-danger button-small"
|
variant="danger"
|
||||||
disabled={
|
loading={
|
||||||
cancelMutation.isPending &&
|
cancelMutation.isPending &&
|
||||||
cancelMutation.variables === selectedRun.id
|
cancelMutation.variables === selectedRun.id
|
||||||
}
|
}
|
||||||
onClick={() => cancelMutation.mutate(selectedRun.id)}
|
onClick={() => cancelMutation.mutate(selectedRun.id)}
|
||||||
>
|
>
|
||||||
{cancelMutation.isPending ? '取消中...' : '取消运行'}
|
取消运行
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<a
|
<Button
|
||||||
className="button button-secondary button-small"
|
size="sm"
|
||||||
href={runLogDownloadUrl(selectedRun.id)}
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = runLogDownloadUrl(selectedRun.id)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
下载日志
|
下载日志
|
||||||
</a>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedRunQuery.isError ? (
|
{selectedRunQuery.isError ? (
|
||||||
<div className="inline-alert inline-alert-error">
|
<Notice tone="error" title="运行详情加载失败">
|
||||||
{selectedRunQuery.error.message}
|
{selectedRunQuery.error.message}
|
||||||
</div>
|
</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedRun ? (
|
{selectedRun ? (
|
||||||
<RunMetadata run={selectedRun} />
|
<RunMetadata run={selectedRun} />
|
||||||
) : (
|
) : (
|
||||||
<p className="empty-inline">选择一条运行记录查看日志。</p>
|
<Empty text="选择一条运行记录查看日志。" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{effectiveRunId ? (
|
{effectiveRunId ? (
|
||||||
@@ -335,8 +325,7 @@ export function RunLogDrawer({
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</Modal>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,10 +460,7 @@ function RunLogOutput({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onLog = (event: Event) => {
|
const onLog = (event: Event) => {
|
||||||
const payload = (event as CustomEvent<ExecutionEvent>).detail
|
const payload = (event as CustomEvent<ExecutionEvent>).detail
|
||||||
if (
|
if (payload.event !== 'log' || payload.data.run_id !== runId) {
|
||||||
payload.event !== 'log' ||
|
|
||||||
payload.data.run_id !== runId
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (payload.data.offset === offsetRef.current) {
|
if (payload.data.offset === offsetRef.current) {
|
||||||
@@ -511,12 +497,13 @@ function RunLogOutput({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="inline-alert inline-alert-error">
|
<Notice
|
||||||
{error}
|
tone="error"
|
||||||
<button
|
title="日志读取失败"
|
||||||
type="button"
|
className="log-viewer-error"
|
||||||
className="text-button"
|
action={{
|
||||||
onClick={() => {
|
label: '重试',
|
||||||
|
onClick: () => {
|
||||||
bufferRef.current = createLogBuffer()
|
bufferRef.current = createLogBuffer()
|
||||||
offsetRef.current = 0
|
offsetRef.current = 0
|
||||||
setPollComplete(false)
|
setPollComplete(false)
|
||||||
@@ -524,11 +511,11 @@ function RunLogOutput({
|
|||||||
setLines(bufferRef.current.lines)
|
setLines(bufferRef.current.lines)
|
||||||
setVersion((value) => value + 1)
|
setVersion((value) => value + 1)
|
||||||
setReloadKey((value) => value + 1)
|
setReloadKey((value) => value + 1)
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重试
|
{error}
|
||||||
</button>
|
</Notice>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
<LogViewer lines={lines} version={version} />
|
<LogViewer lines={lines} version={version} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
|
import { Tag, type TagTone } from '@stash/ui'
|
||||||
import type { RunStatus } from '../api/client'
|
import type { RunStatus } from '../api/client'
|
||||||
import { runStatusLabels } from '../lib/format'
|
import { runStatusLabels } from '../lib/format'
|
||||||
|
|
||||||
|
const RUN_STATUS_TONE: Record<RunStatus, TagTone> = {
|
||||||
|
queued: 'primary',
|
||||||
|
running: 'warning',
|
||||||
|
succeeded: 'success',
|
||||||
|
failed: 'danger',
|
||||||
|
timed_out: 'danger',
|
||||||
|
cancelled: 'neutral',
|
||||||
|
skipped: 'neutral',
|
||||||
|
}
|
||||||
|
|
||||||
interface RunStatusBadgeProps {
|
interface RunStatusBadgeProps {
|
||||||
status: RunStatus
|
status: RunStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RunStatusBadge({ status }: RunStatusBadgeProps) {
|
export function RunStatusBadge({ status }: RunStatusBadgeProps) {
|
||||||
return (
|
return (
|
||||||
<span className={`status-badge status-${status}`}>
|
<Tag
|
||||||
<span className="status-dot" aria-hidden="true" />
|
tone={RUN_STATUS_TONE[status]}
|
||||||
|
dot
|
||||||
|
className={`status-${status}`}
|
||||||
|
>
|
||||||
{runStatusLabels[status]}
|
{runStatusLabels[status]}
|
||||||
</span>
|
</Tag>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,11 +34,12 @@ interface EnabledBadgeProps {
|
|||||||
|
|
||||||
export function EnabledBadge({ enabled }: EnabledBadgeProps) {
|
export function EnabledBadge({ enabled }: EnabledBadgeProps) {
|
||||||
return (
|
return (
|
||||||
<span
|
<Tag
|
||||||
className={`status-badge ${enabled ? 'status-enabled' : 'status-disabled'}`}
|
tone={enabled ? 'success' : 'neutral'}
|
||||||
|
dot
|
||||||
|
className={enabled ? 'status-enabled' : 'status-disabled'}
|
||||||
>
|
>
|
||||||
<span className="status-dot" aria-hidden="true" />
|
|
||||||
{enabled ? '活跃中' : '已暂停'}
|
{enabled ? '活跃中' : '已暂停'}
|
||||||
</span>
|
</Tag>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
FormField,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Notice,
|
||||||
|
PasswordInput,
|
||||||
|
Select,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
TextButton,
|
||||||
|
} from '@stash/ui'
|
||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
type FieldPath,
|
type FieldPath,
|
||||||
type UseFormRegister,
|
type UseFormRegister,
|
||||||
useFieldArray,
|
useFieldArray,
|
||||||
@@ -23,7 +37,6 @@ import {
|
|||||||
type KeyValueFormValue,
|
type KeyValueFormValue,
|
||||||
type TaskFormValues,
|
type TaskFormValues,
|
||||||
} from '../lib/taskForm'
|
} from '../lib/taskForm'
|
||||||
import { useFocusTrap } from '../lib/useFocusTrap'
|
|
||||||
import { useDebouncedValue } from '../lib/useDebouncedValue'
|
import { useDebouncedValue } from '../lib/useDebouncedValue'
|
||||||
|
|
||||||
interface TaskEditorModalProps {
|
interface TaskEditorModalProps {
|
||||||
@@ -34,6 +47,34 @@ interface TaskEditorModalProps {
|
|||||||
onSubmit: (values: TaskFormValues) => Promise<void>
|
onSubmit: (values: TaskFormValues) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACTION_TYPE_OPTIONS = [
|
||||||
|
{ value: 'script', label: '执行脚本 (Python / Shell / Node / Lua)' },
|
||||||
|
{ value: 'http', label: '发起 HTTP 请求' },
|
||||||
|
{ value: 'process', label: '管理常驻/独立二进制' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const HTTP_METHOD_OPTIONS = [
|
||||||
|
{ value: 'GET', label: 'GET' },
|
||||||
|
{ value: 'POST', label: 'POST' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TIMEZONE_SUGGESTIONS = [
|
||||||
|
'Asia/Shanghai',
|
||||||
|
'Asia/Hong_Kong',
|
||||||
|
'UTC',
|
||||||
|
'Europe/London',
|
||||||
|
'America/New_York',
|
||||||
|
]
|
||||||
|
|
||||||
|
const CRON_PREVIEW_COUNTS = [3, 5, 8, 10]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务编辑器。
|
||||||
|
*
|
||||||
|
* 外壳是组件库的 `Modal`(原生 dialog:焦点陷阱、Esc、背景 inert),
|
||||||
|
* 字段排版用 `FormField`,控件用 `Input` / `Select` / `Textarea` / `Checkbox`,
|
||||||
|
* 这里只保留校验(react-hook-form + zod)与业务联动。
|
||||||
|
*/
|
||||||
export function TaskEditorModal({
|
export function TaskEditorModal({
|
||||||
open,
|
open,
|
||||||
task,
|
task,
|
||||||
@@ -44,9 +85,6 @@ export function TaskEditorModal({
|
|||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const dialogRef = useRef<HTMLElement>(null)
|
|
||||||
// 目录选择弹窗打开时让出焦点管理,避免两个焦点陷阱互相争夺 Tab。
|
|
||||||
useFocusTrap(open && !pickerOpen, dialogRef, '[data-dialog-autofocus]')
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
@@ -140,27 +178,20 @@ export function TaskEditorModal({
|
|||||||
setPickerOpen(false)
|
setPickerOpen(false)
|
||||||
}, [open, reset, task])
|
}, [open, reset, task])
|
||||||
|
|
||||||
|
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。
|
||||||
|
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const previousOverflow = document.body.style.overflow
|
document.getElementById(task ? 'task-name' : 'task-id')?.focus()
|
||||||
document.body.style.overflow = 'hidden'
|
}, [open, task])
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape' && !saving) {
|
const requestClose = () => {
|
||||||
|
if (!saving) {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKeyDown)
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = previousOverflow
|
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
|
||||||
}
|
|
||||||
}, [onClose, open, saving])
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = handleSubmit(async (values) => {
|
const submit = handleSubmit(async (values) => {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
@@ -184,108 +215,102 @@ export function TaskEditorModal({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
className="modal-overlay"
|
open={open}
|
||||||
onMouseDown={(event) => {
|
onClose={requestClose}
|
||||||
if (event.currentTarget === event.target && !saving) {
|
title={task ? `编辑任务 [${task.id}]` : '新建定时任务'}
|
||||||
onClose()
|
size="lg"
|
||||||
|
className="task-editor-modal"
|
||||||
|
closeOnOverlay={!saving}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={requestClose} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="task-editor-form" loading={saving}>
|
||||||
|
{task ? '保存修改' : '保存并热刷新'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<section
|
|
||||||
ref={dialogRef}
|
|
||||||
className="modal-card task-editor"
|
|
||||||
tabIndex={-1}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="task-editor-title"
|
|
||||||
>
|
|
||||||
<header className="modal-header">
|
|
||||||
<div>
|
|
||||||
<h2 id="task-editor-title">
|
|
||||||
{task ? `编辑任务 [${task.id}]` : '新建定时任务'}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={saving}
|
|
||||||
aria-label="关闭任务编辑器"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form
|
<form
|
||||||
id="task-editor-form"
|
id="task-editor-form"
|
||||||
className="modal-body"
|
className="task-editor-form"
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
{submitError ? (
|
{submitError ? (
|
||||||
<div className="inline-alert inline-alert-error" role="alert">
|
<Notice tone="error" title="保存失败">
|
||||||
{submitError}
|
{submitError}
|
||||||
</div>
|
</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<Field
|
<FormField
|
||||||
label="任务标识 (Task ID)"
|
label="任务标识 (Task ID)"
|
||||||
|
htmlFor="task-id"
|
||||||
error={errors.id?.message}
|
error={errors.id?.message}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
|
id="task-id"
|
||||||
{...register('id')}
|
{...register('id')}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="例如:sync_data"
|
placeholder="例如:sync_data"
|
||||||
disabled={Boolean(task)}
|
disabled={Boolean(task)}
|
||||||
data-dialog-autofocus
|
|
||||||
aria-invalid={Boolean(errors.id)}
|
aria-invalid={Boolean(errors.id)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="名称" error={errors.name?.message}>
|
<FormField label="名称" htmlFor="task-name" error={errors.name?.message}>
|
||||||
<input
|
<Input
|
||||||
|
id="task-name"
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
className="field-input"
|
|
||||||
placeholder="业务可读名称"
|
placeholder="业务可读名称"
|
||||||
aria-invalid={Boolean(errors.name)}
|
aria-invalid={Boolean(errors.name)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<div className="field">
|
<FormField
|
||||||
<div className="cron-header-row">
|
label="Cron 表达式"
|
||||||
<span className="field-label">Cron 表达式</span>
|
htmlFor="task-cron"
|
||||||
<div className="cron-n-selector">
|
error={errors.cron?.message}
|
||||||
<span>预览未来</span>
|
hint={
|
||||||
<select
|
errors.cron
|
||||||
value={cronPreviewCount}
|
? undefined
|
||||||
onChange={(event) =>
|
: '支持 5/6 位字段及 @daily、@hourly'
|
||||||
setCronPreviewCount(Number(event.target.value))
|
|
||||||
}
|
}
|
||||||
aria-label="Cron 预览次数"
|
|
||||||
>
|
>
|
||||||
<option value="3">3 次</option>
|
<Input
|
||||||
<option value="5">5 次</option>
|
id="task-cron"
|
||||||
<option value="8">8 次</option>
|
|
||||||
<option value="10">10 次</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
{...register('cron')}
|
{...register('cron')}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="0 0 2 * * *"
|
placeholder="0 0 2 * * *"
|
||||||
aria-invalid={Boolean(errors.cron)}
|
aria-invalid={Boolean(errors.cron)}
|
||||||
/>
|
/>
|
||||||
<div className="cron-preview-box" aria-live="polite">
|
<div className="cron-preview-box" aria-live="polite">
|
||||||
|
<div className="cron-preview-head">
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
预览未来运行时间
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
className="cron-count-select"
|
||||||
|
options={CRON_PREVIEW_COUNTS.map((count) => ({
|
||||||
|
value: String(count),
|
||||||
|
label: `${count} 次`,
|
||||||
|
}))}
|
||||||
|
value={String(cronPreviewCount)}
|
||||||
|
onChange={(value) => setCronPreviewCount(Number(value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{cronPreview.isFetching ? (
|
{cronPreview.isFetching ? (
|
||||||
<span className="cron-preview-message">正在计算...</span>
|
<Text size="sm" tone="sub">
|
||||||
|
正在计算...
|
||||||
|
</Text>
|
||||||
) : cronPreview.isError ? (
|
) : cronPreview.isError ? (
|
||||||
<span className="cron-preview-error">
|
<Text size="sm" className="cron-preview-error">
|
||||||
{cronPreview.error.message}
|
{cronPreview.error.message}
|
||||||
</span>
|
</Text>
|
||||||
) : cronPreview.data?.runs.length ? (
|
) : cronPreview.data?.runs.length ? (
|
||||||
cronPreview.data.runs.map((run, index) => (
|
cronPreview.data.runs.map((run, index) => (
|
||||||
<div className="cron-run-item" key={run}>
|
<div className="cron-run-item" key={run}>
|
||||||
@@ -294,171 +319,188 @@ export function TaskEditorModal({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="cron-preview-message">
|
<Text size="sm" tone="sub">
|
||||||
等待输入有效 Cron 表达式...
|
等待输入有效 Cron 表达式...
|
||||||
</span>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errors.cron?.message ? (
|
</FormField>
|
||||||
<span className="field-error">{errors.cron.message}</span>
|
|
||||||
) : (
|
<FormField
|
||||||
<span className="field-hint">
|
label="超时时间 (秒)"
|
||||||
支持 5/6 位字段及 @daily、@hourly
|
htmlFor="task-timeout"
|
||||||
</span>
|
error={errors.timeout_secs?.message}
|
||||||
)}
|
hint={
|
||||||
</div>
|
errors.timeout_secs
|
||||||
<Field label="超时时间 (秒)" error={errors.timeout_secs?.message}>
|
? undefined
|
||||||
<input
|
: '超时后将被 Windows Job Object 强制终止'
|
||||||
{...register('timeout_secs', { valueAsNumber: true })}
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="task-timeout"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min={1}
|
||||||
max="86400"
|
max={86_400}
|
||||||
className="field-input mono"
|
{...register('timeout_secs', { valueAsNumber: true })}
|
||||||
|
className="mono-input"
|
||||||
aria-invalid={Boolean(errors.timeout_secs)}
|
aria-invalid={Boolean(errors.timeout_secs)}
|
||||||
/>
|
/>
|
||||||
<span className="field-hint">
|
</FormField>
|
||||||
超时后将被 Windows Job Object 强制终止
|
|
||||||
</span>
|
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-grid form-grid-single">
|
<div className="form-grid form-grid-single">
|
||||||
<Field label="IANA 时区" error={errors.timezone?.message}>
|
<FormField
|
||||||
<input
|
label="IANA 时区"
|
||||||
|
htmlFor="task-timezone"
|
||||||
|
error={errors.timezone?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="task-timezone"
|
||||||
{...register('timezone')}
|
{...register('timezone')}
|
||||||
className="field-input"
|
|
||||||
list="timezone-options"
|
list="timezone-options"
|
||||||
placeholder="Asia/Shanghai"
|
placeholder="Asia/Shanghai"
|
||||||
aria-invalid={Boolean(errors.timezone)}
|
aria-invalid={Boolean(errors.timezone)}
|
||||||
/>
|
/>
|
||||||
<datalist id="timezone-options">
|
<datalist id="timezone-options">
|
||||||
<option value="Asia/Shanghai" />
|
{TIMEZONE_SUGGESTIONS.map((zone) => (
|
||||||
<option value="Asia/Hong_Kong" />
|
<option value={zone} key={zone} />
|
||||||
<option value="UTC" />
|
))}
|
||||||
<option value="Europe/London" />
|
|
||||||
<option value="America/New_York" />
|
|
||||||
</datalist>
|
</datalist>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="任务类型 (Action Type)">
|
|
||||||
<select {...register('actionType')} className="field-input">
|
<FormField label="任务类型 (Action Type)">
|
||||||
<option value="script">
|
<Controller
|
||||||
执行脚本 (Python / Shell / Node / Lua)
|
control={control}
|
||||||
</option>
|
name="actionType"
|
||||||
<option value="http">发起 HTTP 请求</option>
|
render={({ field }) => (
|
||||||
<option value="process">管理常驻/独立二进制</option>
|
<Select
|
||||||
</select>
|
className="form-select"
|
||||||
</Field>
|
options={ACTION_TYPE_OPTIONS}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actionType === 'script' ? (
|
{actionType === 'script' ? (
|
||||||
<div className="dynamic-fields">
|
<div className="dynamic-fields">
|
||||||
<div className="field">
|
<FormField
|
||||||
<label className="field-label" htmlFor="task-working-dir">
|
label="项目工作目录 (Working Directory)"
|
||||||
项目工作目录 (Working Directory)
|
htmlFor="task-working-dir"
|
||||||
</label>
|
error={errors.script?.working_dir?.message}
|
||||||
|
>
|
||||||
<div className="field-with-action">
|
<div className="field-with-action">
|
||||||
<input
|
<Input
|
||||||
id="task-working-dir"
|
id="task-working-dir"
|
||||||
{...register('script.working_dir')}
|
{...register('script.working_dir')}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="D:\Projects\sync"
|
placeholder="D:\Projects\sync"
|
||||||
aria-invalid={Boolean(errors.script?.working_dir)}
|
aria-invalid={Boolean(errors.script?.working_dir)}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
className="button button-secondary pick-directory-button"
|
|
||||||
// 不夺走输入框焦点:否则 blur 校验插入错误行会让表单瞬间下移,
|
// 不夺走输入框焦点:否则 blur 校验插入错误行会让表单瞬间下移,
|
||||||
// 点击可能落到移位后的其它元素上。
|
// 点击可能落到移位后的其它元素上。
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={() => setPickerOpen(true)}
|
onClick={() => setPickerOpen(true)}
|
||||||
>
|
>
|
||||||
选择文件夹…
|
选择文件夹…
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
{errors.script?.working_dir?.message ? (
|
|
||||||
<span className="field-error">
|
|
||||||
{errors.script.working_dir.message}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<VenvDetectorBar
|
<VenvDetectorBar
|
||||||
workingDir={debouncedWorkingDir}
|
workingDir={debouncedWorkingDir}
|
||||||
probe={venvProbe.data}
|
probe={venvProbe.data}
|
||||||
pending={venvProbe.isPending}
|
pending={venvProbe.isPending}
|
||||||
failed={venvProbe.isError}
|
failed={venvProbe.isError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<Field
|
<FormField
|
||||||
label="解释器"
|
label="解释器"
|
||||||
|
htmlFor="task-interpreter"
|
||||||
error={errors.script?.interpreter?.message}
|
error={errors.script?.interpreter?.message}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
|
id="task-interpreter"
|
||||||
{...register('script.interpreter')}
|
{...register('script.interpreter')}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="python.exe"
|
placeholder="python.exe"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field
|
<FormField
|
||||||
label="虚拟环境目录 (可选)"
|
label="虚拟环境目录 (可选)"
|
||||||
|
htmlFor="task-venv"
|
||||||
hint="留空时自动探测;填写后优先使用该目录"
|
hint="留空时自动探测;填写后优先使用该目录"
|
||||||
|
error={errors.script?.venv?.message}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
|
id="task-venv"
|
||||||
{...register('script.venv')}
|
{...register('script.venv')}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="例如:.venv"
|
placeholder="例如:.venv"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field
|
|
||||||
|
<FormField
|
||||||
label="脚本文件或相对路径"
|
label="脚本文件或相对路径"
|
||||||
|
htmlFor="task-script"
|
||||||
error={errors.script?.script?.message}
|
error={errors.script?.script?.message}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
|
id="task-script"
|
||||||
{...register('script.script')}
|
{...register('script.script')}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="main.py 或 scripts\sync.py"
|
placeholder="main.py 或 scripts\sync.py"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="collection-header">
|
<div className="collection-header">
|
||||||
<div>
|
<div>
|
||||||
<strong>运行参数</strong>
|
<Text strong size="sm">
|
||||||
<small>每行一个参数,不经过 Shell 拼接</small>
|
运行参数
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
每行一个参数,不经过 Shell 拼接
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
className="button button-secondary button-small"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => scriptArgs.append({ key: '', value: '' })}
|
||||||
scriptArgs.append({ key: '', value: '' })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
+ 添加参数
|
+ 添加参数
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="collection-list">
|
<div className="collection-list">
|
||||||
{scriptArgs.fields.map((field, index) => (
|
{scriptArgs.fields.map((field, index) => (
|
||||||
<div className="argument-row" key={field.id}>
|
<div className="argument-row" key={field.id}>
|
||||||
<input
|
<Input
|
||||||
{...register(`script.args.${index}.key`)}
|
{...register(`script.args.${index}.key`)}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="参数名 (如 --mode 或 -v)"
|
placeholder="参数名 (如 --mode 或 -v)"
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
{...register(`script.args.${index}.value`)}
|
{...register(`script.args.${index}.value`)}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="参数值 (可选,无值可留空)"
|
placeholder="参数值 (可选,无值可留空)"
|
||||||
/>
|
/>
|
||||||
<button
|
<TextButton
|
||||||
type="button"
|
tone="danger"
|
||||||
className="text-button danger-text"
|
|
||||||
onClick={() => scriptArgs.remove(index)}
|
onClick={() => scriptArgs.remove(index)}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{scriptArgs.fields.length === 0 ? (
|
{scriptArgs.fields.length === 0 ? (
|
||||||
<p className="empty-inline">暂无运行参数</p>
|
<Text size="sm" tone="muted">
|
||||||
|
暂无运行参数
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{errors.script?.args?.message ? (
|
{errors.script?.args?.message ? (
|
||||||
<span className="field-error">
|
<span className="field-error">
|
||||||
@@ -469,9 +511,9 @@ export function TaskEditorModal({
|
|||||||
|
|
||||||
<KeyValueEditor
|
<KeyValueEditor
|
||||||
title="环境变量"
|
title="环境变量"
|
||||||
fields={scriptEnv.fields}
|
|
||||||
registerKey={register}
|
|
||||||
name="script.env"
|
name="script.env"
|
||||||
|
fields={scriptEnv.fields}
|
||||||
|
registerField={register}
|
||||||
error={errors.script?.env?.message}
|
error={errors.script?.env?.message}
|
||||||
onAdd={() => scriptEnv.append({ key: '', value: '' })}
|
onAdd={() => scriptEnv.append({ key: '', value: '' })}
|
||||||
onRemove={scriptEnv.remove}
|
onRemove={scriptEnv.remove}
|
||||||
@@ -491,10 +533,7 @@ export function TaskEditorModal({
|
|||||||
{scriptPreview.env
|
{scriptPreview.env
|
||||||
.filter((item) => item.key.trim())
|
.filter((item) => item.key.trim())
|
||||||
.map((item, index) => (
|
.map((item, index) => (
|
||||||
<div
|
<div className="cli-line-env" key={`${item.key}-${index}`}>
|
||||||
className="cli-line-env"
|
|
||||||
key={`${item.key}-${index}`}
|
|
||||||
>
|
|
||||||
set {item.key.trim()}=
|
set {item.key.trim()}=
|
||||||
{isSensitiveName(item.key)
|
{isSensitiveName(item.key)
|
||||||
? '[REDACTED]'
|
? '[REDACTED]'
|
||||||
@@ -514,46 +553,66 @@ export function TaskEditorModal({
|
|||||||
{actionType === 'http' ? (
|
{actionType === 'http' ? (
|
||||||
<div className="dynamic-fields">
|
<div className="dynamic-fields">
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<Field label="请求 URL" error={errors.http?.url?.message}>
|
<FormField
|
||||||
<input
|
label="请求 URL"
|
||||||
|
htmlFor="task-http-url"
|
||||||
|
error={errors.http?.url?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="task-http-url"
|
||||||
{...register('http.url')}
|
{...register('http.url')}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="https://api.example.com/health"
|
placeholder="https://api.example.com/health"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="请求方式">
|
<FormField label="请求方式">
|
||||||
<select {...register('http.method')} className="field-input">
|
<Controller
|
||||||
<option value="GET">GET</option>
|
control={control}
|
||||||
<option value="POST">POST</option>
|
name="http.method"
|
||||||
</select>
|
render={({ field }) => (
|
||||||
</Field>
|
<Select
|
||||||
|
className="form-select"
|
||||||
|
options={HTTP_METHOD_OPTIONS}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<Field
|
<FormField
|
||||||
label="期望状态码"
|
label="期望状态码"
|
||||||
|
htmlFor="task-http-status"
|
||||||
hint="使用逗号分隔,例如:200, 204"
|
hint="使用逗号分隔,例如:200, 204"
|
||||||
error={errors.http?.expected_status?.message}
|
error={errors.http?.expected_status?.message}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
|
id="task-http-status"
|
||||||
{...register('http.expected_status')}
|
{...register('http.expected_status')}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="200"
|
placeholder="200"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="JSON Body" error={errors.http?.body?.message}>
|
<FormField
|
||||||
<textarea
|
label="JSON Body"
|
||||||
|
htmlFor="task-http-body"
|
||||||
|
error={errors.http?.body?.message}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
id="task-http-body"
|
||||||
{...register('http.body')}
|
{...register('http.body')}
|
||||||
className="field-input field-textarea mono"
|
|
||||||
rows={4}
|
rows={4}
|
||||||
|
className="mono-input"
|
||||||
placeholder='{"trigger":"manual"}'
|
placeholder='{"trigger":"manual"}'
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<KeyValueEditor
|
<KeyValueEditor
|
||||||
title="请求 Header"
|
title="请求 Header"
|
||||||
fields={httpHeaders.fields}
|
|
||||||
registerKey={register}
|
|
||||||
name="http.headers"
|
name="http.headers"
|
||||||
|
fields={httpHeaders.fields}
|
||||||
|
registerField={register}
|
||||||
error={errors.http?.headers?.message}
|
error={errors.http?.headers?.message}
|
||||||
onAdd={() => httpHeaders.append({ key: '', value: '' })}
|
onAdd={() => httpHeaders.append({ key: '', value: '' })}
|
||||||
onRemove={httpHeaders.remove}
|
onRemove={httpHeaders.remove}
|
||||||
@@ -564,63 +623,69 @@ export function TaskEditorModal({
|
|||||||
{actionType === 'process' ? (
|
{actionType === 'process' ? (
|
||||||
<div className="dynamic-fields">
|
<div className="dynamic-fields">
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<Field
|
<FormField
|
||||||
label="可执行程序"
|
label="可执行程序"
|
||||||
|
htmlFor="task-process-exe"
|
||||||
error={errors.process?.executable?.message}
|
error={errors.process?.executable?.message}
|
||||||
>
|
>
|
||||||
<input
|
<Input
|
||||||
|
id="task-process-exe"
|
||||||
{...register('process.executable')}
|
{...register('process.executable')}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="C:\Tools\exporter.exe"
|
placeholder="C:\Tools\exporter.exe"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="工作目录">
|
<FormField label="工作目录" htmlFor="task-process-dir">
|
||||||
<input
|
<Input
|
||||||
|
id="task-process-dir"
|
||||||
{...register('process.working_dir')}
|
{...register('process.working_dir')}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="可选"
|
placeholder="可选"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div className="collection-header">
|
<div className="collection-header">
|
||||||
<div>
|
<div>
|
||||||
<strong>启动参数</strong>
|
<Text strong size="sm">
|
||||||
<small>每行一个参数</small>
|
启动参数
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
每行一个参数
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
className="button button-secondary button-small"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => processArgs.append({ key: '', value: '' })}
|
||||||
processArgs.append({ key: '', value: '' })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
+ 添加参数
|
+ 添加参数
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="collection-list">
|
<div className="collection-list">
|
||||||
{processArgs.fields.map((field, index) => (
|
{processArgs.fields.map((field, index) => (
|
||||||
<div className="argument-row" key={field.id}>
|
<div className="argument-row" key={field.id}>
|
||||||
<input
|
<Input
|
||||||
{...register(`process.args.${index}.key`)}
|
{...register(`process.args.${index}.key`)}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="参数名 (如 --port)"
|
placeholder="参数名 (如 --port)"
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
{...register(`process.args.${index}.value`)}
|
{...register(`process.args.${index}.value`)}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="参数值 (可选)"
|
placeholder="参数值 (可选)"
|
||||||
/>
|
/>
|
||||||
<button
|
<TextButton
|
||||||
type="button"
|
tone="danger"
|
||||||
className="text-button danger-text"
|
|
||||||
onClick={() => processArgs.remove(index)}
|
onClick={() => processArgs.remove(index)}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{processArgs.fields.length === 0 ? (
|
{processArgs.fields.length === 0 ? (
|
||||||
<p className="empty-inline">暂无启动参数</p>
|
<Text size="sm" tone="muted">
|
||||||
|
暂无启动参数
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{errors.process?.args?.message ? (
|
{errors.process?.args?.message ? (
|
||||||
<span className="field-error">
|
<span className="field-error">
|
||||||
@@ -630,9 +695,9 @@ export function TaskEditorModal({
|
|||||||
</div>
|
</div>
|
||||||
<KeyValueEditor
|
<KeyValueEditor
|
||||||
title="环境变量"
|
title="环境变量"
|
||||||
fields={processEnv.fields}
|
|
||||||
registerKey={register}
|
|
||||||
name="process.env"
|
name="process.env"
|
||||||
|
fields={processEnv.fields}
|
||||||
|
registerField={register}
|
||||||
error={errors.process?.env?.message}
|
error={errors.process?.env?.message}
|
||||||
onAdd={() => processEnv.append({ key: '', value: '' })}
|
onAdd={() => processEnv.append({ key: '', value: '' })}
|
||||||
onRemove={processEnv.remove}
|
onRemove={processEnv.remove}
|
||||||
@@ -640,35 +705,29 @@ export function TaskEditorModal({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<label className="checkbox-field">
|
<Controller
|
||||||
<input type="checkbox" {...register('enabled')} />
|
control={control}
|
||||||
<span>
|
name="enabled"
|
||||||
<strong>立即激活此任务调度</strong>
|
render={({ field }) => (
|
||||||
<small>保存后立即注册到 Cron 调度器</small>
|
<Checkbox
|
||||||
|
className="form-toggle"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
label={
|
||||||
|
<span className="form-toggle-copy">
|
||||||
|
<Text strong size="sm">
|
||||||
|
立即激活此任务调度
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
保存后立即注册到 Cron 调度器
|
||||||
|
</Text>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<footer className="modal-footer">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button button-secondary"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
form="task-editor-form"
|
|
||||||
className="button button-primary"
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? '保存中...' : task ? '保存修改' : '保存并热刷新'}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DirectoryPickerDialog
|
<DirectoryPickerDialog
|
||||||
open={pickerOpen}
|
open={pickerOpen}
|
||||||
initialPath={scriptPreview.working_dir}
|
initialPath={scriptPreview.working_dir}
|
||||||
@@ -680,7 +739,7 @@ export function TaskEditorModal({
|
|||||||
}}
|
}}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,39 +863,21 @@ function isSensitiveName(name: string): boolean {
|
|||||||
].some((marker) => normalized.includes(marker))
|
].some((marker) => normalized.includes(marker))
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FieldProps {
|
|
||||||
label: string
|
|
||||||
error?: string
|
|
||||||
hint?: string
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, error, hint, children }: FieldProps) {
|
|
||||||
return (
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">{label}</span>
|
|
||||||
{children}
|
|
||||||
{error ? <span className="field-error">{error}</span> : null}
|
|
||||||
{!error && hint ? <span className="field-hint">{hint}</span> : null}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyValueEditorProps {
|
interface KeyValueEditorProps {
|
||||||
title: string
|
title: string
|
||||||
name: 'script.env' | 'http.headers' | 'process.env'
|
name: 'script.env' | 'http.headers' | 'process.env'
|
||||||
fields: Array<{ id: string; key: string; value: string }>
|
fields: Array<{ id: string; key: string; value: string }>
|
||||||
registerKey: UseFormRegister<TaskFormValues>
|
registerField: UseFormRegister<TaskFormValues>
|
||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
onRemove: (index: number) => void
|
onRemove: (index: number) => void
|
||||||
error?: string
|
error?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyValueEditor({
|
function KeyValueEditor({
|
||||||
title,
|
title,
|
||||||
name,
|
name,
|
||||||
fields,
|
fields,
|
||||||
registerKey,
|
registerField,
|
||||||
onAdd,
|
onAdd,
|
||||||
onRemove,
|
onRemove,
|
||||||
error,
|
error,
|
||||||
@@ -845,46 +886,46 @@ function KeyValueEditor({
|
|||||||
<div className="key-value-editor">
|
<div className="key-value-editor">
|
||||||
<div className="collection-header">
|
<div className="collection-header">
|
||||||
<div>
|
<div>
|
||||||
<strong>{title}</strong>
|
<Text strong size="sm">
|
||||||
<small>名称和值会原样提交给后端</small>
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
名称和值会原样提交给后端
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button variant="secondary" size="sm" onClick={onAdd}>
|
||||||
type="button"
|
|
||||||
className="button button-secondary button-small"
|
|
||||||
onClick={onAdd}
|
|
||||||
>
|
|
||||||
+ 添加
|
+ 添加
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="collection-list">
|
<div className="collection-list">
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<div className="key-value-row" key={field.id}>
|
<div className="key-value-row" key={field.id}>
|
||||||
<input
|
<Input
|
||||||
{...registerKey(
|
{...registerField(
|
||||||
`${name}.${index}.key` as FieldPath<TaskFormValues>,
|
`${name}.${index}.key` as FieldPath<TaskFormValues>,
|
||||||
)}
|
)}
|
||||||
className="field-input mono"
|
className="mono-input"
|
||||||
placeholder="名称"
|
placeholder="名称"
|
||||||
/>
|
/>
|
||||||
<input
|
<PasswordInput
|
||||||
{...registerKey(
|
{...registerField(
|
||||||
`${name}.${index}.value` as FieldPath<TaskFormValues>,
|
`${name}.${index}.value` as FieldPath<TaskFormValues>,
|
||||||
)}
|
)}
|
||||||
type="password"
|
icon={null}
|
||||||
autoComplete="off"
|
className="mono-input"
|
||||||
className="field-input mono"
|
|
||||||
placeholder="值"
|
placeholder="值"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<button
|
<TextButton tone="danger" onClick={() => onRemove(index)}>
|
||||||
type="button"
|
|
||||||
className="text-button danger-text"
|
|
||||||
onClick={() => onRemove(index)}
|
|
||||||
>
|
|
||||||
删除
|
删除
|
||||||
</button>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{fields.length === 0 ? <p className="empty-inline">暂无配置</p> : null}
|
{fields.length === 0 ? (
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
暂无配置
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
{error ? <span className="field-error">{error}</span> : null}
|
{error ? <span className="field-error">{error}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+50
-85
@@ -23,94 +23,45 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 页面级基础:字体 / 重置 / 全局焦点兜底。
|
||||||
|
* 颜色、间距、圆角、阴影一律取自 @stash/ui 的 --su-* 令牌,这里不再定义主题色。
|
||||||
|
*/
|
||||||
:root {
|
:root {
|
||||||
/* 设计令牌:对齐 Alright 主题(nicetheme) */
|
/* 展示字体:标题用 Outfit,不参与令牌体系,页面自己持有 */
|
||||||
--theme-primary: #3858f6;
|
--font-display: 'Outfit-Medium', var(--su-font-sans);
|
||||||
--theme-primary-soft: rgba(56, 88, 246, 0.1);
|
|
||||||
--theme-dark: #1d2129;
|
|
||||||
--theme-secondary: #2c343c;
|
|
||||||
--theme-muted: #4e5969;
|
|
||||||
--theme-light: #c9cdd4;
|
|
||||||
--theme-border: #eee;
|
|
||||||
--theme-border-soft: #f6f6f6;
|
|
||||||
--theme-bg: #f7f8fa;
|
|
||||||
--theme-success: #4abd86;
|
|
||||||
--theme-danger: #ff3e3e;
|
|
||||||
--theme-warning: #ff8338;
|
|
||||||
|
|
||||||
--bg-color: var(--theme-bg);
|
color: var(--su-text-main);
|
||||||
--card-bg: #ffffff;
|
background-color: var(--su-bg-page);
|
||||||
--text-main: var(--theme-dark);
|
font-family: var(--su-font-sans);
|
||||||
--text-muted: var(--theme-muted);
|
font-size: var(--su-font-size-md);
|
||||||
--text-light: var(--theme-light);
|
line-height: var(--su-line-height-md);
|
||||||
--primary: var(--theme-primary);
|
color-scheme: light;
|
||||||
--primary-hover: #2f49d6;
|
|
||||||
--primary-soft: var(--theme-primary-soft);
|
|
||||||
--border-color: var(--theme-border-soft);
|
|
||||||
--badge-bg: var(--theme-border-soft);
|
|
||||||
--radius-sm: 6px;
|
|
||||||
--radius-md: 12px;
|
|
||||||
--radius-lg: 24px;
|
|
||||||
--shadow-sm: 0 2px 8px rgba(27, 26, 49, 0.02);
|
|
||||||
--shadow-card: 0 6px 16px 0 rgba(27, 26, 49, 0.03);
|
|
||||||
--shadow-modal: 0 16px 48px rgba(27, 26, 49, 0.08);
|
|
||||||
--background: var(--bg-color);
|
|
||||||
--surface: var(--card-bg);
|
|
||||||
--surface-muted: #f7f8fa;
|
|
||||||
--text: var(--text-main);
|
|
||||||
--text-secondary: var(--text-muted);
|
|
||||||
--text-tertiary: #86909c;
|
|
||||||
--border: var(--theme-border);
|
|
||||||
--success: var(--theme-success);
|
|
||||||
--success-bg: rgba(74, 189, 134, 0.1);
|
|
||||||
--success-border: rgba(74, 189, 134, 0.3);
|
|
||||||
--danger: var(--theme-danger);
|
|
||||||
--danger-bg: rgba(255, 62, 62, 0.08);
|
|
||||||
--danger-border: rgba(255, 62, 62, 0.24);
|
|
||||||
--warning: var(--theme-warning);
|
|
||||||
--warning-bg: #fffbe8;
|
|
||||||
--shadow: var(--shadow-card);
|
|
||||||
--transition-base: 0.3s ease-in-out;
|
|
||||||
--font-sans:
|
|
||||||
'LXGW WenKai', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
||||||
'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei',
|
|
||||||
'Source Han Sans CN', sans-serif;
|
|
||||||
--font-display: 'Outfit-Medium', var(--font-sans);
|
|
||||||
--font-mono:
|
|
||||||
SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
|
||||||
monospace;
|
|
||||||
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--background);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3 {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html,
|
||||||
min-width: 320px;
|
body,
|
||||||
background: var(--background);
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
}
|
||||||
line-height: 1.5;
|
|
||||||
font-size: 14px;
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -128,20 +79,34 @@ button:disabled {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
::placeholder {
|
h1,
|
||||||
color: var(--text-light);
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 键盘焦点环由组件库统一提供;这里只给「非表单控件」补兜底。
|
||||||
|
表单控件不能加全局 outline:输入框点击时也会命中 :focus-visible,
|
||||||
|
会和组件自己的边框态叠成「双框」。 */
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible,
|
||||||
|
[tabindex]:not(input):focus-visible {
|
||||||
|
outline: var(--su-focus-ring);
|
||||||
|
outline-offset: var(--su-focus-ring-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
select:focus-visible {
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--theme-primary-soft);
|
background: var(--su-color-primary-soft);
|
||||||
color: var(--theme-dark);
|
color: var(--su-text-main);
|
||||||
}
|
|
||||||
|
|
||||||
:focus-visible {
|
|
||||||
outline: 3px solid rgba(56, 88, 246, 0.28);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { useEffect, type RefObject } from 'react'
|
|
||||||
|
|
||||||
const FOCUSABLE_SELECTOR = [
|
|
||||||
'a[href]',
|
|
||||||
'button:not([disabled])',
|
|
||||||
'input:not([disabled])',
|
|
||||||
'select:not([disabled])',
|
|
||||||
'textarea:not([disabled])',
|
|
||||||
'[tabindex]:not([tabindex="-1"])',
|
|
||||||
].join(',')
|
|
||||||
|
|
||||||
export function useFocusTrap(
|
|
||||||
open: boolean,
|
|
||||||
containerRef: RefObject<HTMLElement | null>,
|
|
||||||
initialFocusSelector?: string,
|
|
||||||
) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !containerRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const container = containerRef.current
|
|
||||||
const previousFocus =
|
|
||||||
document.activeElement instanceof HTMLElement
|
|
||||||
? document.activeElement
|
|
||||||
: null
|
|
||||||
const focusFirst = () => {
|
|
||||||
const initial = initialFocusSelector
|
|
||||||
? container.querySelector<HTMLElement>(initialFocusSelector)
|
|
||||||
: null
|
|
||||||
const first =
|
|
||||||
initial ?? container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
|
|
||||||
;(first ?? container).focus()
|
|
||||||
}
|
|
||||||
const frame = window.requestAnimationFrame(focusFirst)
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key !== 'Tab') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const focusable = Array.from(
|
|
||||||
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
|
||||||
).filter((element) => element.offsetParent !== null)
|
|
||||||
if (focusable.length === 0) {
|
|
||||||
event.preventDefault()
|
|
||||||
container.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const first = focusable[0]
|
|
||||||
const last = focusable[focusable.length - 1]
|
|
||||||
if (event.shiftKey && document.activeElement === first) {
|
|
||||||
event.preventDefault()
|
|
||||||
last.focus()
|
|
||||||
} else if (!event.shiftKey && document.activeElement === last) {
|
|
||||||
event.preventDefault()
|
|
||||||
first.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(frame)
|
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
|
||||||
previousFocus?.focus()
|
|
||||||
}
|
|
||||||
}, [containerRef, initialFocusSelector, open])
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,8 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { Toaster } from '@stash/ui'
|
||||||
|
import '@stash/ui/style.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
<Toaster />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -4,6 +4,26 @@ import {
|
|||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Empty,
|
||||||
|
Grid,
|
||||||
|
Notice,
|
||||||
|
Pagination,
|
||||||
|
Result,
|
||||||
|
SearchField,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spinner,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Text,
|
||||||
|
TextButton,
|
||||||
|
Title,
|
||||||
|
toast,
|
||||||
|
type TableColumn,
|
||||||
|
} from '@stash/ui'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
@@ -22,11 +42,10 @@ import {
|
|||||||
} from '../api/client'
|
} from '../api/client'
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||||
import { RunLogDrawer } from '../components/RunLogDrawer'
|
import { RunLogDrawer } from '../components/RunLogDrawer'
|
||||||
import {
|
import { EnabledBadge } from '../components/StatusBadge'
|
||||||
EnabledBadge,
|
|
||||||
} from '../components/StatusBadge'
|
|
||||||
import { TaskEditorModal } from '../components/TaskEditorModal'
|
import { TaskEditorModal } from '../components/TaskEditorModal'
|
||||||
import { actionTypeLabel, formatShortDateTime } from '../lib/format'
|
import { actionTypeLabel, formatShortDateTime } from '../lib/format'
|
||||||
|
import { useDebouncedValue } from '../lib/useDebouncedValue'
|
||||||
import {
|
import {
|
||||||
type TaskFormValues,
|
type TaskFormValues,
|
||||||
valuesToCreateRequest,
|
valuesToCreateRequest,
|
||||||
@@ -35,6 +54,28 @@ import {
|
|||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const ENABLED_OPTIONS = [
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'true', label: '已启用' },
|
||||||
|
{ value: 'false', label: '已停用' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ACTION_TYPE_OPTIONS = [
|
||||||
|
{ value: '', label: '全部类型' },
|
||||||
|
{ value: 'script', label: '脚本' },
|
||||||
|
{ value: 'http', label: 'HTTP' },
|
||||||
|
{ value: 'process', label: '进程' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TABLE_COLUMNS: TableColumn[] = [
|
||||||
|
{ key: 'name', title: '名称 / 任务标识', width: 260 },
|
||||||
|
{ key: 'schedule', title: '调度周期', width: 240 },
|
||||||
|
{ key: 'type', title: '类型', width: 96 },
|
||||||
|
{ key: 'status', title: '状态', width: 120 },
|
||||||
|
{ key: 'timeout', title: '超时配置', width: 100 },
|
||||||
|
{ key: 'actions', title: '操作', align: 'right', width: 300 },
|
||||||
|
]
|
||||||
|
|
||||||
export function TaskDashboard() {
|
export function TaskDashboard() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
@@ -47,8 +88,9 @@ export function TaskDashboard() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Task | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<Task | null>(null)
|
||||||
const [logTask, setLogTask] = useState<Task | null>(null)
|
const [logTask, setLogTask] = useState<Task | null>(null)
|
||||||
const [initialRunId, setInitialRunId] = useState<number | null>(null)
|
const [initialRunId, setInitialRunId] = useState<number | null>(null)
|
||||||
const [notice, setNotice] = useState<string | null>(null)
|
|
||||||
const [actionError, setActionError] = useState<string | null>(null)
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
|
const [queryDraft, setQueryDraft] = useState(q)
|
||||||
|
const debouncedQuery = useDebouncedValue(queryDraft, 300)
|
||||||
|
|
||||||
const enabled: boolean | undefined =
|
const enabled: boolean | undefined =
|
||||||
enabledValue === 'true'
|
enabledValue === 'true'
|
||||||
@@ -59,6 +101,7 @@ export function TaskDashboard() {
|
|||||||
const actionType = isActionType(actionTypeValue)
|
const actionType = isActionType(actionTypeValue)
|
||||||
? actionTypeValue
|
? actionTypeValue
|
||||||
: undefined
|
: undefined
|
||||||
|
const hasFilters = Boolean(q || enabledValue || actionTypeValue)
|
||||||
const filters: TaskFilters = {
|
const filters: TaskFilters = {
|
||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -105,7 +148,7 @@ export function TaskDashboard() {
|
|||||||
onSuccess: (task, variables) => {
|
onSuccess: (task, variables) => {
|
||||||
setEditorOpen(false)
|
setEditorOpen(false)
|
||||||
setEditingTask(null)
|
setEditingTask(null)
|
||||||
showNotice(
|
toast.success(
|
||||||
variables.task ? `任务 ${task.id} 已更新` : `任务 ${task.id} 已创建`,
|
variables.task ? `任务 ${task.id} 已更新` : `任务 ${task.id} 已创建`,
|
||||||
)
|
)
|
||||||
void invalidateTaskData(queryClient)
|
void invalidateTaskData(queryClient)
|
||||||
@@ -114,7 +157,7 @@ export function TaskDashboard() {
|
|||||||
const toggleMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: (task: Task) => setTaskEnabled(task.id, !task.enabled),
|
mutationFn: (task: Task) => setTaskEnabled(task.id, !task.enabled),
|
||||||
onSuccess: (task) => {
|
onSuccess: (task) => {
|
||||||
showNotice(task.enabled ? `已启用 ${task.id}` : `已停用 ${task.id}`)
|
toast.success(task.enabled ? `已启用 ${task.id}` : `已停用 ${task.id}`)
|
||||||
void invalidateTaskData(queryClient)
|
void invalidateTaskData(queryClient)
|
||||||
},
|
},
|
||||||
onError: (error) => setActionError(error.message),
|
onError: (error) => setActionError(error.message),
|
||||||
@@ -123,7 +166,7 @@ export function TaskDashboard() {
|
|||||||
mutationFn: (task: Task) => deleteTask(task.id),
|
mutationFn: (task: Task) => deleteTask(task.id),
|
||||||
onSuccess: (_data, task) => {
|
onSuccess: (_data, task) => {
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
showNotice(`任务 ${task.id} 已删除`)
|
toast.success(`任务 ${task.id} 已删除`)
|
||||||
void invalidateTaskData(queryClient)
|
void invalidateTaskData(queryClient)
|
||||||
},
|
},
|
||||||
onError: (error) => setActionError(error.message),
|
onError: (error) => setActionError(error.message),
|
||||||
@@ -134,7 +177,7 @@ export function TaskDashboard() {
|
|||||||
setLogTask(task)
|
setLogTask(task)
|
||||||
setInitialRunId(run.id)
|
setInitialRunId(run.id)
|
||||||
setActionError(null)
|
setActionError(null)
|
||||||
showNotice(
|
toast.success(
|
||||||
run.status === 'skipped'
|
run.status === 'skipped'
|
||||||
? `任务 ${task.id} 已有运行实例,本次触发已跳过`
|
? `任务 ${task.id} 已有运行实例,本次触发已跳过`
|
||||||
: `任务 ${task.id} 已提交运行`,
|
: `任务 ${task.id} 已提交运行`,
|
||||||
@@ -147,18 +190,24 @@ export function TaskDashboard() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!notice) {
|
setQueryDraft(q)
|
||||||
|
}, [q])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextQuery = debouncedQuery.trim()
|
||||||
|
if (nextQuery === q) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const timer = window.setTimeout(() => setNotice(null), 4_000)
|
updateFilters({ q: nextQuery || undefined, page: '1' })
|
||||||
return () => window.clearTimeout(timer)
|
// updateFilters 依赖当前 searchParams,每次渲染都是新的闭包;这里只跟随防抖后的输入。
|
||||||
}, [notice])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [debouncedQuery])
|
||||||
|
|
||||||
const tasks = tasksQuery.data?.items ?? []
|
const tasks = tasksQuery.data?.items ?? []
|
||||||
const total = tasksQuery.data?.total ?? 0
|
const total = tasksQuery.data?.total ?? 0
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
const updateFilters = (changes: Record<string, string | undefined>) => {
|
function updateFilters(changes: Record<string, string | undefined>) {
|
||||||
const next = new URLSearchParams(searchParams)
|
const next = new URLSearchParams(searchParams)
|
||||||
for (const [key, value] of Object.entries(changes)) {
|
for (const [key, value] of Object.entries(changes)) {
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -185,49 +234,106 @@ export function TaskDashboard() {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ['health'] })
|
void queryClient.invalidateQueries({ queryKey: ['health'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const showNotice = (message: string) => {
|
const rows = tasks.map((task) => {
|
||||||
setNotice(message)
|
const isRunning =
|
||||||
|
runMutation.isPending && runMutation.variables?.id === task.id
|
||||||
|
const isToggling =
|
||||||
|
toggleMutation.isPending && toggleMutation.variables?.id === task.id
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
name: (
|
||||||
|
<div className="task-name-cell">
|
||||||
|
<Text className="task-name" strong>
|
||||||
|
{task.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="task-id" size="xs" tone="muted">
|
||||||
|
{task.id}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
schedule: (
|
||||||
|
<div className="schedule-cell">
|
||||||
|
<code className="code-chip">{task.cron}</code>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
{task.timezone}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
{task.enabled
|
||||||
|
? nextRuns.get(task.id)
|
||||||
|
? `下次 ${formatShortDateTime(nextRuns.get(task.id))}`
|
||||||
|
: '调度中…'
|
||||||
|
: '未调度'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
type: (
|
||||||
|
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
|
||||||
|
),
|
||||||
|
status: <EnabledBadge enabled={task.enabled} />,
|
||||||
|
timeout: (
|
||||||
|
<Text size="sm" tone="sub">
|
||||||
|
{task.timeout_secs}s
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
actions: (
|
||||||
|
<Space justify="end" gap="xs" wrap>
|
||||||
|
<TextButton
|
||||||
|
disabled={isRunning}
|
||||||
|
title="立即运行"
|
||||||
|
onClick={() => runMutation.mutate(task)}
|
||||||
|
>
|
||||||
|
{isRunning ? '提交中' : '运行'}
|
||||||
|
</TextButton>
|
||||||
|
<TextButton
|
||||||
|
onClick={() => {
|
||||||
|
setLogTask(task)
|
||||||
|
setInitialRunId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
日志
|
||||||
|
</TextButton>
|
||||||
|
<TextButton onClick={() => openEdit(task)}>编辑</TextButton>
|
||||||
|
<TextButton
|
||||||
|
disabled={isToggling}
|
||||||
|
onClick={() => toggleMutation.mutate(task)}
|
||||||
|
>
|
||||||
|
{task.enabled ? '停用' : '启用'}
|
||||||
|
</TextButton>
|
||||||
|
<TextButton tone="danger" onClick={() => setDeleteTarget(task)}>
|
||||||
|
删除
|
||||||
|
</TextButton>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<header className="dashboard-header">
|
<header className="page-header">
|
||||||
<div className="header-title">
|
<div className="header-title">
|
||||||
<h1>TaskPilot</h1>
|
<p className="eyebrow">TASKPILOT / WINDOWS SCHEDULER</p>
|
||||||
<p className="subtitle">
|
<Title level={1}>TaskPilot</Title>
|
||||||
Rust 本地轻量化任务引擎 · Windows 原生调度中心
|
<Text tone="sub">Rust 本地轻量化任务引擎 · Windows 原生调度中心</Text>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="header-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button button-primary"
|
|
||||||
onClick={openCreate}
|
|
||||||
>
|
|
||||||
+ 新建任务
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Space gap="sm" wrap>
|
||||||
|
<Button onClick={openCreate}>+ 新建任务</Button>
|
||||||
|
</Space>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{notice ? (
|
|
||||||
<div className="toast-message" role="status">
|
|
||||||
{notice}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{actionError ? (
|
{actionError ? (
|
||||||
<div className="inline-alert inline-alert-error page-alert" role="alert">
|
<Notice
|
||||||
<span>{actionError}</span>
|
tone="error"
|
||||||
<button
|
title="操作失败"
|
||||||
type="button"
|
className="page-alert"
|
||||||
className="text-button"
|
action={{ label: '关闭', onClick: () => setActionError(null) }}
|
||||||
onClick={() => setActionError(null)}
|
|
||||||
>
|
>
|
||||||
关闭
|
{actionError}
|
||||||
</button>
|
</Notice>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="metrics-grid" aria-label="任务统计">
|
<section aria-label="任务统计" className="metrics-section">
|
||||||
|
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="md">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="已托管任务"
|
label="已托管任务"
|
||||||
value={statsQuery.data?.total}
|
value={statsQuery.data?.total}
|
||||||
@@ -243,272 +349,128 @@ export function TaskDashboard() {
|
|||||||
value={statsQuery.data?.script}
|
value={statsQuery.data?.script}
|
||||||
loading={statsQuery.isPending}
|
loading={statsQuery.isPending}
|
||||||
/>
|
/>
|
||||||
<article className="metric-card">
|
<MetricCard
|
||||||
<span>Rust 引擎</span>
|
label="Rust 引擎"
|
||||||
<strong className={healthQuery.isError ? 'metric-danger' : ''}>
|
value={
|
||||||
{healthQuery.isPending
|
healthQuery.isPending
|
||||||
? '...'
|
? '...'
|
||||||
: healthQuery.isError
|
: healthQuery.isError
|
||||||
? '离线'
|
? '离线'
|
||||||
: '在线'}
|
: '在线'
|
||||||
</strong>
|
}
|
||||||
</article>
|
loading={false}
|
||||||
|
danger={healthQuery.isError}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card task-card">
|
<Card padding="none" className="task-card">
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<form
|
<SearchField
|
||||||
className="search-form"
|
className="toolbar-search"
|
||||||
onSubmit={(event) => {
|
value={queryDraft}
|
||||||
event.preventDefault()
|
onChange={setQueryDraft}
|
||||||
const value = String(
|
|
||||||
new FormData(event.currentTarget).get('q') ?? '',
|
|
||||||
).trim()
|
|
||||||
updateFilters({ q: value || undefined, page: '1' })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="visually-hidden" htmlFor="task-search">
|
|
||||||
搜索任务
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="task-search"
|
|
||||||
key={q}
|
|
||||||
name="q"
|
|
||||||
className="field-input search-input"
|
|
||||||
defaultValue={q}
|
|
||||||
placeholder="搜索任务名称或 ID"
|
placeholder="搜索任务名称或 ID"
|
||||||
|
size="md"
|
||||||
|
maxWidth={420}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="button button-secondary">
|
|
||||||
搜索
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="filter-row">
|
<div className="toolbar-filters">
|
||||||
<label>
|
<Select
|
||||||
<span className="visually-hidden">启用状态</span>
|
className="filter-select"
|
||||||
<select
|
options={ENABLED_OPTIONS}
|
||||||
className="field-input compact-select"
|
|
||||||
value={enabledValue ?? ''}
|
value={enabledValue ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(value) =>
|
||||||
updateFilters({
|
updateFilters({
|
||||||
enabled: event.target.value || undefined,
|
enabled: value || undefined,
|
||||||
page: '1',
|
page: '1',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
<option value="">全部状态</option>
|
<Select
|
||||||
<option value="true">已启用</option>
|
className="filter-select"
|
||||||
<option value="false">已停用</option>
|
options={ACTION_TYPE_OPTIONS}
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className="visually-hidden">任务类型</span>
|
|
||||||
<select
|
|
||||||
className="field-input compact-select"
|
|
||||||
value={actionTypeValue ?? ''}
|
value={actionTypeValue ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(value) =>
|
||||||
updateFilters({
|
updateFilters({
|
||||||
action_type: event.target.value || undefined,
|
action_type: value || undefined,
|
||||||
page: '1',
|
page: '1',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
<option value="">全部类型</option>
|
{hasFilters ? (
|
||||||
<option value="script">脚本</option>
|
<TextButton onClick={() => setSearchParams({})}>
|
||||||
<option value="http">HTTP</option>
|
|
||||||
<option value="process">进程</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{(q || enabledValue || actionTypeValue) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-button"
|
|
||||||
onClick={() => {
|
|
||||||
setSearchParams({})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
清除筛选
|
清除筛选
|
||||||
</button>
|
</TextButton>
|
||||||
)}
|
) : null}
|
||||||
<button
|
<TextButton onClick={refreshAll} disabled={tasksQuery.isFetching}>
|
||||||
type="button"
|
|
||||||
className="text-button"
|
|
||||||
onClick={refreshAll}
|
|
||||||
disabled={tasksQuery.isFetching}
|
|
||||||
>
|
|
||||||
{tasksQuery.isFetching ? '同步中...' : '刷新'}
|
{tasksQuery.isFetching ? '同步中...' : '刷新'}
|
||||||
</button>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tasksQuery.isError ? (
|
{tasksQuery.isError ? (
|
||||||
<div className="state-panel state-error">
|
<Result
|
||||||
<h2>任务列表加载失败</h2>
|
status="error"
|
||||||
<p>{tasksQuery.error.message}</p>
|
title="任务列表加载失败"
|
||||||
<button
|
description={tasksQuery.error.message}
|
||||||
type="button"
|
actions={
|
||||||
className="button button-secondary"
|
<Button
|
||||||
|
variant="secondary"
|
||||||
onClick={() => void tasksQuery.refetch()}
|
onClick={() => void tasksQuery.refetch()}
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
) : tasksQuery.isPending ? (
|
) : tasksQuery.isPending ? (
|
||||||
<div className="state-panel">
|
<div className="state-block">
|
||||||
<span className="loading-mark" aria-hidden="true" />
|
<Spinner label="正在读取任务" />
|
||||||
<p>正在读取任务...</p>
|
<Text tone="sub">正在读取任务...</Text>
|
||||||
</div>
|
</div>
|
||||||
) : tasks.length === 0 ? (
|
) : tasks.length === 0 ? (
|
||||||
<div className="state-panel">
|
<Empty
|
||||||
<p className="section-label">EMPTY</p>
|
className="empty-state"
|
||||||
<h2>{q || enabledValue || actionTypeValue ? '没有匹配任务' : '还没有任务'}</h2>
|
text={
|
||||||
<p>
|
<span className="empty-copy">
|
||||||
{q || enabledValue || actionTypeValue
|
<Text as="span" size="lg" strong>
|
||||||
|
{hasFilters ? '没有匹配任务' : '还没有任务'}
|
||||||
|
</Text>
|
||||||
|
<Text as="span" size="md" tone="sub">
|
||||||
|
{hasFilters
|
||||||
? '请调整搜索条件后重试。'
|
? '请调整搜索条件后重试。'
|
||||||
: '创建第一个定时任务,开始使用真实调度能力。'}
|
: '创建第一个定时任务,开始使用真实调度能力。'}
|
||||||
</p>
|
</Text>
|
||||||
{!q && !enabledValue && !actionTypeValue ? (
|
</span>
|
||||||
<button
|
}
|
||||||
type="button"
|
action={
|
||||||
className="button button-primary"
|
hasFilters ? undefined : (
|
||||||
onClick={openCreate}
|
<Button onClick={openCreate}>新建任务</Button>
|
||||||
>
|
)
|
||||||
新建任务
|
}
|
||||||
</button>
|
/>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="table-container">
|
<Table
|
||||||
<table className="task-table">
|
className="task-table"
|
||||||
<thead>
|
columns={TABLE_COLUMNS}
|
||||||
<tr>
|
rows={rows}
|
||||||
<th>名称 / 任务标识</th>
|
size="md"
|
||||||
<th>调度周期</th>
|
/>
|
||||||
<th>类型</th>
|
<footer className="pagination-bar">
|
||||||
<th>状态</th>
|
<Text size="sm" tone="sub">
|
||||||
<th>超时配置</th>
|
|
||||||
<th className="align-right">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tasks.map((task) => {
|
|
||||||
const isRunning =
|
|
||||||
runMutation.isPending &&
|
|
||||||
runMutation.variables?.id === task.id
|
|
||||||
return (
|
|
||||||
<tr key={task.id}>
|
|
||||||
<td>
|
|
||||||
<div className="task-name">{task.name}</div>
|
|
||||||
<div className="task-id mono">{task.id}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<code>{task.cron}</code>
|
|
||||||
<div className="cell-note">{task.timezone}</div>
|
|
||||||
<div className="cell-note">
|
|
||||||
{task.enabled
|
|
||||||
? nextRuns.get(task.id)
|
|
||||||
? `下次 ${formatShortDateTime(nextRuns.get(task.id))}`
|
|
||||||
: '调度中…'
|
|
||||||
: '未调度'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="type-badge">
|
|
||||||
{actionTypeLabel(task.action.type)}
|
|
||||||
</span>
|
|
||||||
<div className="cell-note">
|
|
||||||
超时 {task.timeout_secs}s
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<EnabledBadge enabled={task.enabled} />
|
|
||||||
</td>
|
|
||||||
<td className="cell-note">超时 {task.timeout_secs}s</td>
|
|
||||||
<td>
|
|
||||||
<div className="row-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button button-secondary button-small"
|
|
||||||
disabled={isRunning}
|
|
||||||
title="立即运行"
|
|
||||||
onClick={() => runMutation.mutate(task)}
|
|
||||||
>
|
|
||||||
{isRunning ? '提交中' : '运行'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button button-secondary button-small"
|
|
||||||
onClick={() => {
|
|
||||||
setLogTask(task)
|
|
||||||
setInitialRunId(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
日志
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button button-secondary button-small"
|
|
||||||
onClick={() => openEdit(task)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button button-secondary button-small"
|
|
||||||
disabled={
|
|
||||||
toggleMutation.isPending &&
|
|
||||||
toggleMutation.variables?.id === task.id
|
|
||||||
}
|
|
||||||
onClick={() => toggleMutation.mutate(task)}
|
|
||||||
>
|
|
||||||
{task.enabled ? '停用' : '启用'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button button-ghost button-small danger-text"
|
|
||||||
onClick={() => setDeleteTarget(task)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<footer className="pagination">
|
|
||||||
<span>
|
|
||||||
共 {total} 个任务,第 {page} / {totalPages} 页
|
共 {total} 个任务,第 {page} / {totalPages} 页
|
||||||
</span>
|
</Text>
|
||||||
<div>
|
<Pagination
|
||||||
<button
|
page={page}
|
||||||
type="button"
|
total={total}
|
||||||
className="button button-secondary button-small"
|
pageSize={PAGE_SIZE}
|
||||||
disabled={page <= 1}
|
onChange={(next) => updateFilters({ page: String(next) })}
|
||||||
onClick={() =>
|
/>
|
||||||
updateFilters({ page: String(Math.max(1, page - 1)) })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button button-secondary button-small"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() =>
|
|
||||||
updateFilters({ page: String(Math.min(totalPages, page + 1)) })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Card>
|
||||||
|
|
||||||
<TaskEditorModal
|
<TaskEditorModal
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
@@ -557,16 +519,26 @@ export function TaskDashboard() {
|
|||||||
|
|
||||||
interface MetricCardProps {
|
interface MetricCardProps {
|
||||||
label: string
|
label: string
|
||||||
value: number | undefined
|
value: number | string | undefined
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
danger?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({ label, value, loading }: MetricCardProps) {
|
function MetricCard({ label, value, loading, danger = false }: MetricCardProps) {
|
||||||
return (
|
return (
|
||||||
<article className="metric-card">
|
<Card padding="md" className="metric-card" hoverable>
|
||||||
<span>{label}</span>
|
<Text tone="sub" size="sm">
|
||||||
<strong>{loading ? '...' : (value ?? '--')}</strong>
|
{label}
|
||||||
</article>
|
</Text>
|
||||||
|
<Title
|
||||||
|
level={1}
|
||||||
|
as="div"
|
||||||
|
weight="medium"
|
||||||
|
className={danger ? 'metric-value metric-danger' : 'metric-value'}
|
||||||
|
>
|
||||||
|
{loading ? '...' : (value ?? '--')}
|
||||||
|
</Title>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ import '@testing-library/jest-dom/vitest'
|
|||||||
import { cleanup } from '@testing-library/react'
|
import { cleanup } from '@testing-library/react'
|
||||||
import { afterEach, vi } from 'vitest'
|
import { afterEach, vi } from 'vitest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jsdom 30 还没有实现 `<dialog>` 的 showModal / close,
|
||||||
|
* 而 @stash/ui 的 Modal / Drawer 依赖它们(浏览器原生支持)。
|
||||||
|
* 这里补一个最小的开关实现:只维护 open 属性,焦点与 inert 由真实浏览器负责。
|
||||||
|
*/
|
||||||
|
const dialogPrototype = window.HTMLDialogElement?.prototype
|
||||||
|
if (dialogPrototype && typeof dialogPrototype.showModal !== 'function') {
|
||||||
|
dialogPrototype.showModal = function showModal(this: HTMLDialogElement) {
|
||||||
|
this.setAttribute('open', '')
|
||||||
|
}
|
||||||
|
dialogPrototype.close = function close(this: HTMLDialogElement) {
|
||||||
|
this.removeAttribute('open')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { defineConfig } from 'vite'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
// @stash/ui 是本地 file 依赖(软链),去重 React,避免加载两份运行时
|
||||||
|
resolve: {
|
||||||
|
dedupe: ['react', 'react-dom'],
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ function Assert-ExitCode {
|
|||||||
|
|
||||||
Push-Location $projectRoot
|
Push-Location $projectRoot
|
||||||
try {
|
try {
|
||||||
|
# 前端依赖同级 stash-ui(file:../../stash-ui):发布包要求先有组件库产物
|
||||||
|
$stashUiDist = [System.IO.Path]::GetFullPath((Join-Path $projectRoot '..\stash-ui\dist\stash-ui.js'))
|
||||||
|
if (-not (Test-Path -LiteralPath $stashUiDist)) {
|
||||||
|
throw "Missing @stash/ui build output: $stashUiDist. Run 'npm --prefix ..\stash-ui install' and 'npm --prefix ..\stash-ui run build' first."
|
||||||
|
}
|
||||||
|
|
||||||
pnpm --dir frontend build
|
pnpm --dir frontend build
|
||||||
Assert-ExitCode 'Frontend build'
|
Assert-ExitCode 'Frontend build'
|
||||||
cargo build --release --bin task-pilot-backend --manifest-path backend\Cargo.toml
|
cargo build --release --bin task-pilot-backend --manifest-path backend\Cargo.toml
|
||||||
|
|||||||
Reference in New Issue
Block a user