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:
2026-11-01 23:54:35 +08:00
parent 07ca14ea34
commit 3c1a603b90
20 changed files with 1779 additions and 2481 deletions
+13 -2
View File
@@ -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` | 页面内目录浏览器 |
+4 -4
View File
@@ -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 ({
+1
View File
@@ -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",
+14
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -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
View File
@@ -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>
) )
} }
+24 -56
View File
@@ -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>
) )
} }
+77 -103
View File
@@ -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>
) )
} }
+21 -7
View File
@@ -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 })
+113 -126
View File
@@ -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>
+22 -7
View File
@@ -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>
) )
} }
+334 -293
View File
@@ -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
View File
@@ -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;
} }
-64
View File
@@ -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])
}
+3
View File
@@ -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>,
+249 -277
View File
@@ -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>
) )
} }
+15
View File
@@ -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()
+4
View File
@@ -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,
+6
View File
@@ -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