From 3c1a603b90fdcd34b6fdb78c1a33c07e020e939e Mon Sep 17 00:00:00 2001 From: CNWei Date: Sun, 1 Nov 2026 23:54:35 +0800 Subject: [PATCH] =?UTF-8?q?style(frontend):=20=E6=8E=A5=E5=85=A5=20stash-u?= =?UTF-8?q?i=20=E8=AE=BE=E8=AE=A1=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 依赖 @stash/ui(file:../../stash-ui),统一引入 style.css 与 Toaster - 任务面板、表单、弹窗、日志、状态标签全部迁到组件库,App.css 只保留页面布局 - 移除自写 useFocusTrap,改用基于原生 dialog 的 Modal - 修复 LogViewer 流式追加时误判离开底部、导致不再自动跟随的问题 - 发布脚本检查 stash-ui 产物;README 补充组件库构建与 file: 依赖刷新说明 --- README.md | 15 +- frontend/e2e/dashboard.spec.ts | 8 +- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 14 + frontend/src/App.css | 1463 +++++------------ frontend/src/App.test.tsx | 12 +- frontend/src/App.tsx | 21 +- frontend/src/components/ConfirmDialog.tsx | 82 +- .../src/components/DirectoryPickerDialog.tsx | 266 ++- frontend/src/components/LogViewer.tsx | 38 +- frontend/src/components/RunLogDrawer.tsx | 403 +++-- frontend/src/components/StatusBadge.tsx | 29 +- frontend/src/components/TaskEditorModal.tsx | 1081 ++++++------ frontend/src/index.css | 135 +- frontend/src/lib/useFocusTrap.ts | 64 - frontend/src/main.tsx | 3 + frontend/src/pages/TaskDashboard.tsx | 600 ++++--- frontend/src/test/setup.ts | 15 + frontend/vite.config.ts | 4 + scripts/build-release.ps1 | 6 + 20 files changed, 1779 insertions(+), 2481 deletions(-) delete mode 100644 frontend/src/lib/useFocusTrap.ts diff --git a/README.md b/README.md index 9aa9978..5396363 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ TaskPilot 是一个面向 **Windows 单机** 的本地定时任务管理平台 - 三类任务:Python/Shell 脚本、HTTP 请求、二进制进程 - SQLite 持久化,重启后任务与历史仍在 - 运行日志实时推送(SSE),按窗口合并事件;断线自动回退轮询,长日志用虚拟滚动展示 -- 前端零外部依赖:样式与脚本随包自带;界面字体可选,未生成时自动回退系统字体 +- 前端零外部网络依赖:样式与组件来自同级的 `stash-ui` 设计系统(`file:` 依赖,构建后随包自带);界面字体可选,未生成时自动回退系统字体 > 根目录的 `task_pilot.html` 只是最初的单文件视觉原型,正式入口是 React 应用。 @@ -58,6 +58,10 @@ release/TaskPilot/ ### 方式二:本地开发 ```powershell +# 前端依赖 @stash/ui(file:../../stash-ui),首次使用先在组件库构建 dist +npm --prefix ..\stash-ui install +npm --prefix ..\stash-ui run build + pnpm --dir frontend install cargo run --manifest-path backend/Cargo.toml # 终端一:API,127.0.0.1:3000 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`)需同步修改 `frontend/vite.config.ts` 的代理目标;打包版同源,不需要改前端。 +`@stash/ui` 走 `file:` 依赖(pnpm 会复制一份构建产物):组件库重新 `npm run build` +之后,需要再执行一次 `pnpm --dir frontend install` 才会同步新的 `dist`。 + ### 方式三:用 uv 跑官方示例与自检 仓库自带一个 uv 管理的演示任务和一个端到端自检脚本: @@ -226,7 +233,10 @@ queued ──► running ──► succeeded / failed / timed_out / cancelled - **任务表**:名称/ID、调度周期(含时区与**下次触发时间**)、类型、状态、超时、操作。 - **编辑器**:三类动态表单、Cron 预览(实时计算未来 5/10 次)、虚拟环境状态条、调用预览。 - **目录选择**:页面内目录浏览器(盘符 → 逐级进入 → 使用此目录),同时保留手动输入。 -- **日志抽屉**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。 +- **日志弹窗**:运行历史筛选、SSE 实时日志、取消运行、下载日志、Escape/焦点陷阱。 +- **设计系统**:按钮、输入框、表格、标签、分页、弹窗、Toast、排版全部来自 + `@stash/ui`(同级目录 `../stash-ui`,`file:` 依赖);本仓库只保留页面级布局与 + 业务块(任务表单元格、日志控制台、表单栅格)。 - **视觉**:对齐 Alright 主题(`#3858f6` 主色、灰阶体系、0.3s 缓动);正文使用 `LXGW WenKai`、标题与数字使用 `Outfit-Medium`。字体二进制不入库,用 `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` | 列表、筛选、分页、指标卡、下次触发时间 | | `components/TaskEditorModal.tsx` | 三类动态表单、Cron 预览、虚拟环境状态条 | | `components/DirectoryPickerDialog.tsx` | 页面内目录浏览器 | diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index ee404d3..bcbf826 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -77,7 +77,7 @@ test('manages a task and reads a real run log', async ({ page, request }) => { // Cron 预览是异步渲染的,等它稳定后再点击,避免点击坐标在弹窗重排中失效。 await expect(newTaskDialog.locator('.cron-run-item').first()).toBeVisible() await newTaskDialog.getByRole('button', { name: '选择文件夹…' }).click() - const directoryPicker = page.getByRole('dialog', { name: '选择项目工作目录' }) + const directoryPicker = page.getByRole('dialog', { name: '选择工作目录' }) await expect(directoryPicker).toBeVisible() const directoryPathInput = directoryPicker.getByLabel('目录路径') 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', fullPage: true, }) - await drawer.getByRole('button', { name: '关闭运行日志' }).click() + await drawer.getByRole('button', { name: '关闭' }).click() await page.getByRole('button', { name: '编辑' }).click() 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('alertdialog') + .getByRole('dialog', { name: /删除任务/ }) .getByRole('button', { name: '确认删除' }) .click() - await expect(page.getByRole('heading', { name: '还没有任务' })).toBeVisible() + await expect(page.getByText('还没有任务')).toBeVisible() }) test('keeps the dashboard usable on a narrow viewport', async ({ diff --git a/frontend/package.json b/frontend/package.json index 305745b..f7de58b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.9.2", + "@stash/ui": "file:../../stash-ui", "@tanstack/react-query": "^5.104.3", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 50642fa..40a94d4 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@hookform/resolvers': specifier: ^5.9.2 version: 5.9.2(react-hook-form@7.90.0(@types/react@19.3.0)(react@19.3.1))(zod@4.6.7) + '@stash/ui': + specifier: file:../../stash-ui + version: file:../../stash-ui(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@tanstack/react-query': specifier: ^5.104.3 version: 5.104.3(react@19.3.1) @@ -477,6 +480,12 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stash/ui@file:../../stash-ui': + resolution: {directory: ../../stash-ui, type: directory} + peerDependencies: + react: '>=19' + react-dom: '>=19' + '@tanstack/query-core@5.104.3': resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==} @@ -1361,6 +1370,11 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@stash/ui@file:../../stash-ui(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + '@tanstack/query-core@5.104.3': {} '@tanstack/react-query@5.104.3(react@19.3.1)': diff --git a/frontend/src/App.css b/frontend/src/App.css index 3bd2beb..4a47124 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,774 +1,227 @@ +/* + * 页面级样式:只保留布局与页面专属块。 + * 控件外观(按钮 / 输入 / 表格 / 弹窗 / 标签 / 分页 …)全部来自 @stash/ui, + * 颜色与间距只引用 --su-* 令牌,不再另建一套主题变量。 + */ + .app-shell { + display: flex; width: min(1040px, calc(100% - 48px)); + flex-direction: column; + gap: 24px; margin: 0 auto; padding: 48px 0; } -.dashboard-header { +/* ---------- 页头 ---------- */ + +.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; - margin-bottom: 32px; + flex-wrap: wrap; } -.eyebrow, -.section-label { - margin: 0 0 10px; - color: var(--text-secondary); - font-family: var(--font-mono); +.header-title { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; +} + +.eyebrow { + color: var(--su-text-muted); + font-family: var(--su-font-mono); font-size: 11px; font-weight: 600; letter-spacing: 0.14em; + text-transform: uppercase; } -h1, -h2, -h3, -p { - margin-top: 0; -} - -h1 { - margin-bottom: 6px; - font-size: 26px; - font-weight: 700; - line-height: 1.2; - letter-spacing: -0.03em; -} - -h2 { - margin-bottom: 8px; - font-size: 22px; - letter-spacing: -0.025em; -} - -h3 { - margin-bottom: 0; - font-size: 17px; - letter-spacing: -0.02em; -} - -.subtitle { - margin-bottom: 0; - color: var(--text-secondary); - font-size: 13px; - line-height: 1.5; -} - -.header-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.connection-pill { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 40px; - border: 1px solid var(--success-border); - border-radius: 999px; - padding: 0 14px; - background: var(--success-bg); - color: var(--success); - font-size: 13px; - font-weight: 600; -} - -.connection-pill .status-dot { - background: currentColor; -} - -.connection-pill.is-offline { - border-color: var(--danger-border); - background: var(--danger-bg); - color: var(--danger); -} - -.button { - display: inline-flex; - min-height: 36px; - align-items: center; - justify-content: center; - border: 1px solid transparent; - border-radius: var(--radius-sm); - padding: 8px 16px; - font-size: 13px; - font-weight: 500; - text-decoration: none; - transition: - background-color var(--transition-base), - border-color var(--transition-base), - color var(--transition-base), - transform var(--transition-base), - opacity var(--transition-base); -} - -.button:hover:not(:disabled) { - transform: translateY(-1px); -} - -.button:disabled { - cursor: not-allowed; - opacity: 0.48; -} - -.button-primary { - background: var(--primary); - color: #fff; -} - -.button-primary:hover:not(:disabled) { - background: var(--primary); - opacity: 0.85; -} - -.button-secondary { - border-color: var(--border); - background: var(--surface); - color: var(--text); -} - -.button-secondary:hover:not(:disabled) { - border-color: var(--text-light); - background: var(--surface-muted); -} - -.button-danger { - background: var(--danger); - color: #fff; -} - -.button-danger:hover:not(:disabled) { - background: var(--danger); - opacity: 0.85; -} - -.button-ghost { - border-color: transparent; - background: transparent; -} - -.button-small { - min-height: 28px; - border-radius: 6px; - padding: 5px 11px; - font-size: 12px; -} - -.text-button { - border: 0; - padding: 3px; - background: transparent; - color: var(--text-secondary); - font-size: 12px; - text-decoration: underline; - text-underline-offset: 3px; -} - -.text-button:hover { - color: var(--primary); -} - -.danger-text { - color: var(--danger) !important; -} - -.toast-message { - position: fixed; - z-index: 80; - right: 24px; - bottom: 24px; - max-width: min(420px, calc(100vw - 48px)); - border: 1px solid var(--theme-secondary); - border-radius: 12px; - padding: 14px 18px; - background: var(--theme-dark); - color: #fff; - box-shadow: 0 16px 48px rgba(15, 23, 42, 0.2); - font-size: 13px; - animation: toast-in 180ms ease-out; -} - -@keyframes toast-in { - from { - opacity: 0; - transform: translateY(8px); - } -} - -.inline-alert { - border: 1px solid var(--border); - border-radius: 11px; - padding: 12px 14px; - background: var(--surface-muted); - color: var(--text-secondary); - font-size: 13px; - line-height: 1.6; -} - -.inline-alert-error { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - border-color: var(--danger-border); - background: var(--danger-bg); - color: var(--danger); -} - -.page-alert { - margin-bottom: 18px; -} - -.metrics-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 18px; - margin-bottom: 28px; -} +/* ---------- 指标卡 ---------- */ .metric-card { + display: flex; min-width: 0; - min-height: 0; - border: 1px solid var(--border); - border-radius: 12px; - padding: 20px; - background: var(--surface); - box-shadow: var(--shadow-sm); - transition: - transform 0.2s ease, - box-shadow 0.2s ease; + flex-direction: column; + gap: 8px; } -.metric-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-card); -} - -.metric-card > span { - color: var(--text-secondary); - font-size: 12px; - font-weight: 500; - letter-spacing: 0.02em; -} - -.metric-card > strong { - overflow: hidden; - display: block; - margin-top: 8px; +.metric-value { font-family: var(--font-display); font-size: 26px; - font-weight: 500; + line-height: 32px; letter-spacing: -0.03em; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; -} - -.metric-card > small { - display: none; } .metric-danger { - color: var(--danger); + color: var(--su-color-danger); } -.card { +/* ---------- 任务列表 ---------- */ + +.task-card { overflow: hidden; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--surface); - box-shadow: var(--shadow); +} + +/* 表格嵌在卡片里:去掉自身描边与圆角,避免「卡片套卡片」 */ +.task-table { + border: 0; + border-radius: 0; } .toolbar { display: flex; align-items: center; justify-content: space-between; - gap: 14px; - border-bottom: 1px solid var(--border); - padding: 12px 18px; - background: var(--surface-muted); + gap: 12px; + flex-wrap: wrap; + padding: 12px 16px; + border-bottom: 1px solid var(--su-border); + background-color: var(--su-bg-subtle); } -.search-form { - display: flex; - min-width: min(430px, 48vw); - gap: 8px; +.toolbar-search { + flex: 1 1 260px; + max-width: 420px; } -.search-form .button { - flex: none; - min-width: 68px; - white-space: nowrap; -} - -.filter-row { +.toolbar-filters { display: flex; align-items: center; - justify-content: flex-end; gap: 8px; + flex-wrap: wrap; } -.search-input { - width: 100%; +.filter-select { + min-width: 140px; } -.field-input { - width: 100%; - min-height: 38px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - outline: 0; - padding: 8px 12px; - font-size: 13px; - background: var(--surface); - color: var(--text); - transition: - border-color var(--transition-base), - box-shadow var(--transition-base); -} - -.field-input:hover { - border-color: var(--text-light); -} - -.field-input:focus { - border-color: var(--primary); - box-shadow: 0 0 0 3px var(--theme-primary-soft); -} - -.field-input:disabled { - background: var(--surface-muted); - color: var(--text-tertiary); -} - -.compact-select { - min-width: 112px; - padding-right: 30px; - font-size: 12px; -} - -.mono, -code { - font-family: var(--font-mono); -} - -code { - display: inline-block; - border: 1px solid var(--border); - border-radius: 6px; - padding: 3px 8px; - background: var(--badge-bg); - color: var(--theme-secondary); - font-size: 12px; -} - -.table-container { - width: 100%; - overflow-x: auto; -} - -.task-table { - width: 100%; - min-width: 980px; - border-collapse: collapse; - text-align: left; -} - -.task-table th { - padding: 14px 12px; - background: var(--surface-muted); - color: var(--text-tertiary); - font-family: var(--font-sans); - font-size: 11px; - font-weight: 500; - letter-spacing: 0.06em; - text-transform: uppercase; - white-space: nowrap; -} - -.task-table td { - border-top: 1px solid var(--border); - border-bottom: 1px solid var(--border); - padding: 16px 12px; - font-size: 14px; - vertical-align: middle; -} - -.task-table tbody tr { - transition: background-color var(--transition-base); -} - -.task-table tbody tr:hover { - background: var(--surface-muted); +.task-name-cell { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; } .task-name { + display: block; max-width: 240px; overflow: hidden; - font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } -.task-id { - margin-top: 2px; - color: var(--text-tertiary); - font-size: 12px; +.task-id, +.mono { + font-family: var(--su-font-mono); } -.cell-note { - margin-top: 4px; - color: var(--text-tertiary); - font-size: 11px; - white-space: nowrap; -} - -.align-right { - text-align: right !important; -} - -.row-actions { +.schedule-cell { display: flex; - justify-content: flex-end; + align-items: flex-start; + flex-direction: column; gap: 4px; - white-space: nowrap; } -.row-actions .button-small { - padding-right: 8px; - padding-left: 8px; -} - -.type-badge { - display: inline-flex; - padding: 0; - background: transparent; - color: var(--text-muted); +.code-chip { + display: inline-block; + padding: 2px 8px; + border: 1px solid var(--su-border); + border-radius: var(--su-radius-xs); + background-color: var(--su-bg-subtle); + color: var(--su-text-secondary); + font-family: var(--su-font-mono); font-size: 12px; - font-weight: 400; } -.status-badge { - display: inline-flex; - align-items: center; - gap: 6px; - border-radius: 999px; - padding: 4px 10px; - background: var(--badge-bg); - color: var(--text-muted); - font-size: 12px; - font-weight: 500; - white-space: nowrap; -} - -.status-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; -} - -.status-enabled, -.status-succeeded { - background: var(--success-bg); - color: #2b8a63; -} - -.status-disabled, -.status-cancelled, -.status-skipped { - background: var(--badge-bg); - color: var(--text-muted); -} - -.status-queued { - background: var(--primary-soft); - color: var(--primary); -} - -.status-running { - background: var(--warning-bg); - color: var(--warning); -} - -.status-running .status-dot { - animation: pulse 1.2s ease-in-out infinite; -} - -.status-failed, -.status-timed_out { - background: var(--danger-bg); - color: var(--danger); -} - -@keyframes pulse { - 50% { - opacity: 0.3; - transform: scale(0.7); - } -} - -.state-panel { +.state-block { display: flex; - min-height: 280px; + min-height: 240px; align-items: center; justify-content: center; flex-direction: column; + gap: 12px; padding: 40px 24px; - text-align: center; } -.state-panel h2 { - margin-bottom: 8px; -} - -.state-panel p { - max-width: 480px; - color: var(--text-secondary); - line-height: 1.7; -} - -.state-error h2 { - color: var(--danger); -} - -.loading-mark { - width: 26px; - height: 26px; - margin-bottom: 14px; - border: 2px solid var(--border); - border-top-color: var(--text); - border-radius: 50%; - animation: spin 700ms linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.pagination { - display: flex; - align-items: center; - justify-content: space-between; - border-top: 1px solid var(--border); - padding: 14px 20px; - color: var(--text-secondary); - font-size: 12px; -} - -.pagination > div { - display: flex; - gap: 6px; -} - -.modal-overlay { - position: fixed; - z-index: 100; - inset: 0; - display: flex; +.empty-state { + min-height: 240px; align-items: center; justify-content: center; - padding: 24px; - background: rgba(17, 24, 39, 0.25); - backdrop-filter: blur(8px); - animation: overlay-in 150ms ease-out; + padding: 40px 24px; } -@keyframes overlay-in { - from { - opacity: 0; - } -} - -.modal-card, -.confirm-card { - width: min(660px, 100%); - max-height: calc(100vh - 48px); - overflow: hidden; - border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: var(--surface); - box-shadow: var(--shadow-modal); - animation: modal-in 180ms ease-out; -} - -@keyframes modal-in { - from { - opacity: 0; - transform: translateY(10px) scale(0.985); - } -} - -.task-editor { +.empty-copy { display: flex; flex-direction: column; + align-items: center; + gap: 4px; } -.modal-header { +.pagination-bar { display: flex; align-items: center; justify-content: space-between; - gap: 20px; - border-bottom: 1px solid var(--border); - padding: 20px 28px; + gap: 12px; + flex-wrap: wrap; + padding: 12px 16px; + border-top: 1px solid var(--su-border); } -.modal-header h2 { - margin-bottom: 0; - font-size: 16px; - font-weight: 700; - letter-spacing: -0.02em; +/* ---------- 任务编辑器 ---------- */ + +.task-editor-modal { + width: min(760px, calc(100vw - 32px)); + max-width: 760px; + max-height: calc(100vh - 48px); + flex-direction: column; + overflow: hidden; } -.icon-button { - display: inline-flex; - width: 30px; - height: 30px; - flex: none; - align-items: center; - justify-content: center; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--text-secondary); - font-size: 20px; - line-height: 1; +/* display 只在打开时接管:否则会盖掉 UA 的 dialog:not([open]) { display: none }, + 未打开的弹窗会一直渲染在页面上并拦截点击。 */ +.task-editor-modal[open] { + display: flex; } -.icon-button:hover:not(:disabled) { - background: var(--surface-muted); - color: var(--text); -} - -.modal-body { - overflow-y: auto; - padding: 24px 28px; - max-height: 75vh; -} - -.task-editor .modal-body { +/* Modal 的正文层(唯一一个直接子 div)负责滚动,页头 / 页脚固定 */ +.task-editor-modal > div { min-height: 0; flex: 1; - max-height: none; + overflow-y: auto; +} + +.task-editor-modal > header, +.task-editor-modal > footer { + flex: none; +} + +.task-editor-form { + display: flex; + flex-direction: column; + gap: 16px; } .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; - margin-bottom: 16px; -} - -.form-grid-three { - grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 0.7fr); } .form-grid-single { grid-template-columns: 1fr; } -.cron-header-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; +.form-select { + width: 100%; } -.cron-n-selector { - display: flex; - align-items: center; - gap: 6px; - color: var(--text-secondary); - font-size: 12px; -} - -.cron-n-selector select { - border: 1px solid var(--border); - border-radius: 4px; - padding: 2px 6px; - background: #fff; - color: var(--text); - font: inherit; -} - -.cron-preview-box { - display: flex; - max-height: 160px; - flex-direction: column; - gap: 6px; - overflow-y: auto; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 10px 12px; - background: var(--surface-muted); -} - -.cron-run-item { - display: flex; - align-items: center; - gap: 8px; - color: var(--theme-secondary); - font-family: var(--font-mono); - font-size: 12px; -} - -.cron-run-index { - width: 28px; - color: var(--text-secondary); - font-size: 11px; - text-align: right; -} - -.cron-run-time { - border: 1px solid var(--border); - border-radius: 4px; - padding: 2px 8px; - background: #fff; - color: var(--text); - font-weight: 500; -} - -.cron-preview-message, -.cron-preview-error { - color: var(--text-secondary); - font-size: 12px; -} - -.cron-preview-error { - color: var(--danger); -} - -.field { - display: flex; - min-width: 0; - flex-direction: column; - gap: 7px; -} - -.field-label { - color: var(--theme-secondary); - font-size: 13px; - font-weight: 600; -} - -.field-hint, -.field-error { - font-size: 11px; - line-height: 1.5; -} - -.field-hint { - color: var(--text-tertiary); -} - -.field-error { - color: var(--danger); +.mono-input, +.mono-input input, +.mono-input textarea { + font-family: var(--su-font-mono); } .field-with-action { @@ -777,16 +230,61 @@ code { gap: 8px; } -.field-with-action .field-input { +.field-with-action > :first-child { min-width: 0; flex: 1; } -.pick-directory-button { - flex-shrink: 0; - min-height: 38px; - padding: 8px 14px; - white-space: nowrap; +.cron-preview-box { + display: flex; + max-height: 160px; + flex-direction: column; + gap: 6px; + overflow-y: auto; + border: 1px solid var(--su-border); + border-radius: var(--su-radius-sm); + padding: 10px 12px; + background-color: var(--su-bg-subtle); +} + +.cron-preview-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.cron-count-select { + min-width: 96px; +} + +.cron-run-item { + display: flex; + align-items: center; + gap: 8px; + color: var(--su-text-secondary); + font-family: var(--su-font-mono); + font-size: 12px; +} + +.cron-run-index { + width: 28px; + color: var(--su-text-muted); + font-size: 11px; + text-align: right; +} + +.cron-run-time { + border: 1px solid var(--su-border); + border-radius: var(--su-radius-xs); + padding: 2px 8px; + background-color: var(--su-bg-card); + color: var(--su-text-main); + font-weight: 500; +} + +.cron-preview-error { + color: var(--su-color-danger); } .env-detector-bar { @@ -795,11 +293,11 @@ code { align-items: center; justify-content: space-between; gap: 6px 12px; - margin-top: -1px; + margin-top: -8px; + border: 1px solid var(--su-border); + border-radius: var(--su-radius-sm); padding: 8px 12px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface); + background-color: var(--su-bg-card); font-size: 12px; } @@ -808,29 +306,29 @@ code { min-width: 0; align-items: center; gap: 6px; - color: var(--text-secondary); + color: var(--su-text-sub); font-weight: 500; } .env-status-badge.ok { - color: var(--success); + color: var(--su-color-success); } .env-status-badge.warn { - color: var(--warning); + color: var(--su-color-orange-dark); } .env-status-badge.pending { - color: var(--text-tertiary); + color: var(--su-text-muted); } .env-status-badge code { overflow: hidden; + border-radius: var(--su-radius-xs); padding: 1px 6px; - border-radius: 4px; - background: var(--badge-bg); - color: var(--text-main); - font-family: var(--font-mono); + background-color: var(--su-bg-subtle); + color: var(--su-text-main); + font-family: var(--su-font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; @@ -838,63 +336,18 @@ code { .env-detector-hint { flex-shrink: 0; - color: var(--text-tertiary); + color: var(--su-text-muted); font-size: 11px; } -.field-input[aria-invalid='true'] { - border-color: rgba(255, 62, 62, 0.55); - background: rgba(255, 62, 62, 0.04); -} - -.field-textarea { - min-height: 104px; - resize: vertical; -} - -.checkbox-field { - display: flex; - align-items: center; - align-self: end; - gap: 11px; - min-height: 48px; - border: 1px solid var(--border); - border: 0; - border-radius: var(--radius-sm); - padding: 8px 0; - background: transparent; - cursor: pointer; - margin-top: 6px; -} - -.checkbox-field input { - width: 16px; - height: 16px; - accent-color: var(--text); -} - -.checkbox-field span { +.dynamic-fields { display: flex; flex-direction: column; - gap: 2px; -} - -.checkbox-field strong { - font-size: 12px; -} - -.checkbox-field small, -.collection-header small { - color: var(--text-tertiary); - font-size: 10px; -} - -.dynamic-fields { - margin: 22px 0 0; - border: 1px solid var(--border); - border-radius: 12px; + gap: 16px; + border: 1px solid var(--su-border); + border-radius: var(--su-radius-md); padding: 20px; - background: var(--surface-muted); + background-color: var(--su-bg-subtle); } .collection-header { @@ -902,7 +355,6 @@ code { align-items: flex-start; justify-content: space-between; gap: 16px; - margin: 18px 0 10px; } .collection-header > div { @@ -911,42 +363,36 @@ code { gap: 3px; } -.collection-header strong { - font-size: 12px; -} - .collection-list { display: flex; flex-direction: column; gap: 8px; } -.argument-row, -.key-value-row { +.argument-row { display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr) auto; align-items: center; gap: 8px; } -.argument-row { - grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr) auto; -} - .key-value-row { + display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto; + align-items: center; + gap: 8px; } -.empty-inline { - margin: 0; - color: var(--text-tertiary); - font-size: 12px; +.field-error { + color: var(--su-color-danger); + font-size: var(--su-font-size-xs); + line-height: var(--su-line-height-xs); } .cli-preview { - margin-top: 18px; - border-radius: var(--radius-sm); + border-radius: var(--su-radius-sm); padding: 14px 18px; - background: var(--theme-dark); + background-color: var(--su-gray-8); } .cli-preview-header { @@ -955,94 +401,91 @@ code { margin-bottom: 8px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); padding-bottom: 6px; - color: var(--text-tertiary); + color: var(--su-gray-4); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; } .cli-preview-body { - font-family: var(--font-mono); + font-family: var(--su-font-mono); font-size: 12px; line-height: 1.6; } .cli-line-env { margin-bottom: 4px; - color: var(--text-tertiary); + color: var(--su-gray-4); } .cli-line-cmd { - color: var(--success); + color: var(--su-color-success); font-weight: 500; overflow-wrap: anywhere; } -.modal-footer { +.form-toggle { + align-items: flex-start; + padding: 4px 0; +} + +.form-toggle-copy { display: flex; - justify-content: flex-end; - gap: 8px; - border-top: 1px solid var(--border); - padding: 16px 28px; - background: var(--surface-muted); -} - -.confirm-card { - width: min(440px, 100%); - padding: 28px; -} - -.confirm-card .eyebrow { - margin-bottom: 12px; -} - -.muted-copy { - color: var(--text-secondary); - line-height: 1.7; -} - -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 24px; -} - -.directory-picker { - display: flex; - width: min(760px, 100%); flex-direction: column; + gap: 2px; +} + +/* ---------- 目录选择 ---------- */ + +.directory-picker-modal { + width: min(760px, calc(100vw - 32px)); + max-width: 760px; +} + +.directory-picker-modal > div { + padding: 0; } .directory-picker-toolbar { display: flex; align-items: center; gap: 8px; - border-bottom: 1px solid var(--border); - padding: 14px 24px; + border-bottom: 1px solid var(--su-border); + padding: 16px 24px; } .directory-path-form { - display: flex; min-width: 0; flex: 1; } +.directory-path-input { + width: 100%; +} + .directory-picker-body { - max-height: 46vh; min-height: 220px; - flex: 1; + max-height: 46vh; overflow-y: auto; padding: 12px 16px 16px; } +.picker-status { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px 0; +} + +.directory-picker-error { + margin: 8px 0; +} + .directory-list { display: flex; - margin: 0; - padding: 0; flex-direction: column; gap: 2px; - list-style: none; } .directory-row { @@ -1051,27 +494,22 @@ code { align-items: center; gap: 10px; border: 0; - border-radius: var(--radius-sm); + border-radius: var(--su-radius-sm); padding: 8px 10px; background: transparent; - color: var(--text); - cursor: pointer; - font-size: 13px; + color: var(--su-text-main); + font-size: var(--su-font-size-md); text-align: left; } .directory-row:hover { - background: var(--surface-muted); -} - -.directory-row:focus-visible { - outline: 2px solid var(--primary); - outline-offset: 1px; + background-color: var(--su-bg-subtle); } .directory-icon { flex-shrink: 0; - font-size: 13px; + color: var(--su-text-muted); + font-size: var(--su-font-size-base); } .directory-name { @@ -1080,102 +518,58 @@ code { white-space: nowrap; } -.directory-picker-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - border-top: 1px solid var(--border); - padding: 14px 24px; - background: var(--surface-muted); -} - .directory-picker-summary { display: flex; min-width: 0; flex-direction: column; gap: 4px; + margin-right: auto; } -.directory-picker-summary code { +/* ---------- 运行日志 ---------- */ + +.run-log-modal { + width: min(1040px, calc(100vw - 32px)); + max-width: 1040px; + max-height: calc(100vh - 48px); overflow: hidden; - color: var(--text-secondary); - font-family: var(--font-mono); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; } -.directory-picker-actions { - margin-top: 0; +.run-log-modal > div { + height: min(72vh, 760px); + padding: 0; } -.drawer-overlay { - align-items: center; - justify-content: center; - padding: 24px; -} - -.run-drawer { - display: flex; - width: min(1040px, 100%); - height: min(82vh, 820px); - flex-direction: column; - overflow: hidden; - border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: var(--surface); - box-shadow: var(--shadow-modal); - animation: drawer-in 220ms ease-out; -} - -@keyframes drawer-in { - from { - transform: translateY(8px) scale(0.985); - opacity: 0; - } -} - -.run-drawer .modal-header { - flex: none; -} - -.drawer-subtitle { - margin: 4px 0 0; - color: var(--text-tertiary); - font-size: 11px; -} - -.run-drawer-body { +.run-log-body { display: grid; + height: 100%; min-height: 0; - flex: 1; grid-template-columns: minmax(250px, 0.75fr) minmax(0, 1.7fr); } -.run-history-panel, -.run-detail-panel { +.run-history-panel { min-width: 0; overflow-y: auto; - padding: 22px; + border-right: 1px solid var(--su-border); + padding: 20px; + background-color: var(--su-bg-subtle); } -/* 日志面板吃掉详情区的剩余高度,自己内部滚动,"回到最新"按钮才不会被挤出可视区。 */ .run-detail-panel { display: flex; + min-width: 0; + min-height: 0; flex-direction: column; + overflow: hidden; + padding: 20px; } -.run-detail-panel > .section-heading, -.run-detail-panel > .run-metadata, -.run-detail-panel > .inline-alert, -.run-detail-panel > .empty-inline { +.run-detail-panel > * { flex: none; } -.run-history-panel { - border-right: 1px solid var(--border); - background: var(--surface-muted); +.run-detail-panel > .log-viewer-wrap { + flex: 1; } .section-heading { @@ -1183,11 +577,43 @@ code { align-items: flex-start; justify-content: space-between; gap: 16px; - margin-bottom: 16px; + margin-bottom: 14px; } -.section-heading .section-label { - margin-bottom: 5px; +.section-heading > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.section-label { + font-family: var(--su-font-mono); + font-size: 11px; + letter-spacing: 0.14em; +} + +.drawer-subtitle { + font-family: var(--su-font-mono); +} + +.history-filters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 12px; +} + +.history-select { + width: 100%; + min-width: 0; +} + +.panel-status { + display: flex; + align-items: center; + gap: 8px; + padding: 20px 0; } .run-list { @@ -1196,42 +622,27 @@ code { gap: 6px; } -.history-filters { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 7px; - margin-bottom: 12px; -} - -.run-detail-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 7px; - flex-wrap: wrap; -} - .run-list-item { display: flex; width: 100%; flex-direction: column; - gap: 8px; + gap: 6px; border: 1px solid transparent; - border-radius: 12px; - padding: 12px; + border-radius: var(--su-radius-md); + padding: 10px 12px; background: transparent; - color: var(--text); + color: var(--su-text-main); text-align: left; } .run-list-item:hover { - background: var(--surface); + background-color: var(--su-bg-card); } .run-list-item.is-selected { - border-color: var(--primary); - background: var(--primary-soft); - box-shadow: 0 4px 18px rgba(27, 26, 49, 0.06); + border-color: var(--su-color-primary); + background-color: var(--su-bg-card); + box-shadow: var(--su-shadow-card); } .run-list-main { @@ -1239,59 +650,75 @@ code { align-items: center; justify-content: space-between; gap: 8px; - font-size: 12px; + font-size: var(--su-font-size-sm); font-weight: 600; } .run-list-meta { - color: var(--text-tertiary); - font-size: 10px; - line-height: 1.5; + color: var(--su-text-muted); + font-size: 11px; + line-height: var(--su-line-height-sm); +} + +.run-detail-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; } .run-metadata { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; - margin: 0 0 18px; + gap: 8px; + margin-bottom: 14px; } .run-metadata > div { - border: 1px solid var(--border); - border-radius: 11px; - padding: 11px; - background: var(--surface-muted); + border: 1px solid var(--su-border); + border-radius: var(--su-radius-sm); + padding: 10px; + background-color: var(--su-bg-subtle); } .run-metadata dt { - margin-bottom: 5px; - color: var(--text-tertiary); + margin-bottom: 4px; + color: var(--su-text-muted); font-size: 10px; } .run-metadata dd { overflow-wrap: anywhere; margin: 0; - font-family: var(--font-mono); + font-family: var(--su-font-mono); font-size: 11px; } .run-error-metadata { grid-column: 1 / -1; - border-color: var(--danger-border) !important; - background: var(--danger-bg) !important; - color: var(--danger); + border-color: var(--su-color-danger-border); + background-color: var(--su-color-danger-soft); + color: var(--su-color-danger); } +.run-error-metadata dt, +.run-error-metadata dd { + color: inherit; +} + +/* 日志面板吃掉详情区的剩余高度,自己内部滚动,"回到最新"按钮才不会被挤出可视区。 */ .log-viewer-wrap { display: flex; - flex: 1; - flex-direction: column; min-height: 240px; + flex-direction: column; overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 14px; - background: var(--theme-dark); + border-radius: var(--su-radius-md); + background-color: var(--su-gray-8); +} + +.log-viewer-error { + margin: 10px; } .log-viewer-header { @@ -1301,23 +728,23 @@ code { justify-content: space-between; border-bottom: 1px solid rgba(255, 255, 255, 0.08); padding: 10px 14px; - color: var(--text-tertiary); - font-family: var(--font-mono); + color: var(--su-gray-4); + font-family: var(--su-font-mono); font-size: 10px; } .log-streaming { - color: var(--warning); + color: var(--su-color-orange-dark); } .log-complete { - color: var(--success); + color: var(--su-color-success); } .log-viewer-shell { position: relative; - flex: 1; min-height: 0; + flex: 1; } .log-viewer { @@ -1326,15 +753,15 @@ code { overflow: auto; margin: 0; padding: 0; - color: var(--text-light); - font-family: var(--font-mono); + color: var(--su-gray-4); + font-family: var(--su-font-mono); font-size: 12px; line-height: 20px; overscroll-behavior: contain; } .log-viewer:focus-visible { - outline: 2px solid var(--primary); + outline: 2px solid var(--su-color-primary); outline-offset: -2px; } @@ -1361,7 +788,7 @@ code { .log-empty { margin: 0; padding: 16px; - color: var(--text-tertiary); + color: var(--su-gray-4); } .log-jump-latest { @@ -1369,59 +796,23 @@ code { right: 16px; bottom: 14px; border: 1px solid rgba(255, 255, 255, 0.16); - border-radius: 999px; + border-radius: var(--su-radius-pill); padding: 6px 12px; - background: rgba(16, 18, 27, 0.88); - color: var(--text-light); - font-family: var(--font-mono); + background-color: var(--su-gray-7); + color: var(--su-gray-1); + font-family: var(--su-font-mono); font-size: 11px; - cursor: pointer; box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); } .log-jump-latest:hover { - background: rgba(30, 34, 50, 0.95); + background-color: var(--su-gray-6); } -.log-viewer-wrap .inline-alert { - margin: 10px; -} - -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - clip-path: inset(50%); - white-space: nowrap; -} +/* ---------- 响应式 ---------- */ @media (max-width: 1040px) { - .metrics-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .toolbar { - align-items: stretch; - flex-direction: column; - } - - .search-form { - width: 100%; - min-width: 0; - } - - .filter-row { - justify-content: flex-start; - flex-wrap: wrap; - } - - .run-drawer { - width: 100%; - } - - .run-drawer-body { + .run-log-body { grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.5fr); } } @@ -1429,123 +820,105 @@ code { @media (max-width: 760px) { .app-shell { width: min(100% - 24px, 1040px); + gap: 20px; padding: 24px 0 40px; } - .dashboard-header, - .header-actions { + .page-header { align-items: stretch; flex-direction: column; } - .header-actions { - gap: 8px; + .toolbar { + align-items: stretch; + flex-direction: column; } - .connection-pill { - align-self: flex-start; + .toolbar-search { + max-width: none; + flex: none; } - .metrics-grid { + .toolbar-filters { + align-items: stretch; + flex-direction: column; + } + + .filter-select { + width: 100%; + } + + .form-grid { grid-template-columns: 1fr; } - .metric-card { - min-height: 0; + .argument-row, + .key-value-row { + grid-template-columns: 1fr; } - .search-form { - flex-direction: column; - } - - .filter-row { - align-items: stretch; - } - - .filter-row label { - flex: 1; - } - - .compact-select { + .task-editor-modal { width: 100%; - } - - .pagination { - align-items: flex-start; - flex-direction: column; - gap: 12px; - } - - .modal-overlay { - padding: 0; - } - - .modal-card, - .confirm-card { - width: 100%; - max-height: 100vh; + max-width: none; + height: 100dvh; + max-height: 100dvh; border-radius: 0; } - .modal-body { - padding: 18px; + .directory-picker-modal { + width: 100%; + max-width: none; + max-height: 100dvh; + border-radius: 0; } - .modal-footer { - padding: 16px 18px; + .directory-picker-toolbar { + flex-wrap: wrap; } - .form-grid, - .form-grid-three { - grid-template-columns: 1fr; + .directory-path-form { + flex: 1 0 100%; } - .key-value-row, - .argument-row { - grid-template-columns: 1fr; + .run-log-modal { + width: 100%; + height: 100dvh; + max-height: 100dvh; + border-radius: 0; } - .run-drawer-body { - display: flex; + .run-log-modal > div { + height: auto; overflow-y: auto; + } + + .run-log-body { + display: flex; + height: auto; flex-direction: column; } .run-history-panel, .run-detail-panel { overflow: visible; - } - - .run-detail-panel { - display: block; - } - - .log-viewer-wrap { - flex: none; - min-height: 0; - } - - .log-viewer-shell { - flex: none; + border-right: 0; } .run-history-panel { - border-right: 0; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid var(--su-border); } .run-metadata { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .log-viewer { - height: auto; - min-height: 260px; - max-height: 60vh; + .log-viewer-wrap { + height: 50vh; + flex: none; } - .run-drawer { - height: 100%; - border-radius: 0; + .pagination-bar { + align-items: flex-start; + flex-direction: column; } } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 68531d0..768b72c 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -299,7 +299,7 @@ describe('TaskDashboard', () => { await user.click(screen.getByRole('button', { name: '选择文件夹…' })) 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: 'Projects' })) @@ -307,7 +307,7 @@ describe('TaskDashboard', () => { expect(within(dialog).getByText('D:\\Projects\\sync')).toBeInTheDocument() await user.click(within(dialog).getByRole('button', { name: '使用此目录' })) - expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull() + expect(screen.queryByRole('dialog', { name: '选择工作目录' })).toBeNull() expect(screen.getByLabelText(/项目工作目录/)).toHaveValue( 'D:\\Projects\\sync', ) @@ -346,7 +346,7 @@ describe('TaskDashboard', () => { await user.click(screen.getByRole('button', { name: '+ 新建任务' })) await user.click(screen.getByRole('button', { name: '选择文件夹…' })) 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: 'Projects' })) @@ -380,12 +380,12 @@ describe('TaskDashboard', () => { await user.click(screen.getByRole('button', { name: '选择文件夹…' })) const dialog = await screen.findByRole('dialog', { - name: '选择项目工作目录', + name: '选择工作目录', }) await user.click(within(dialog).getByRole('button', { name: '取消' })) await waitFor(() => - expect(screen.queryByRole('dialog', { name: '选择项目工作目录' })).toBeNull(), + expect(screen.queryByRole('dialog', { name: '选择工作目录' })).toBeNull(), ) expect(input).toHaveValue('D:\\Manual\\path') }) @@ -413,7 +413,7 @@ describe('TaskDashboard', () => { await user.click(screen.getByRole('button', { name: '选择文件夹…' })) const dialog = await screen.findByRole('dialog', { - name: '选择项目工作目录', + name: '选择工作目录', }) expect( await within(dialog).findByText('目录不存在: Z:\\nope'), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c6bab2a..fbdd2f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 './App.css' function NotFoundPage() { + const navigate = useNavigate() + return (
-
-
-

404 / ROUTE NOT FOUND

-

页面不存在

-

该地址尚未配置到 TaskPilot 路由。

-
-
- - 返回任务列表 - + navigate('/')}>返回任务列表} + />
) } diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx index 8f2bd5d..e2246ce 100644 --- a/frontend/src/components/ConfirmDialog.tsx +++ b/frontend/src/components/ConfirmDialog.tsx @@ -1,5 +1,4 @@ -import { useEffect, useRef } from 'react' -import { useFocusTrap } from '../lib/useFocusTrap' +import { Button, Modal, Text } from '@stash/ui' interface ConfirmDialogProps { open: boolean @@ -10,6 +9,10 @@ interface ConfirmDialogProps { onCancel: () => void } +/** + * 危险操作确认。外壳交给组件库的 `Modal`(原生 dialog:焦点陷阱 / Esc / 背景 inert), + * 这里只负责文案与「取消在左、危险操作在右」的按钮顺序。 + */ export function ConfirmDialog({ open, title, @@ -18,66 +21,31 @@ export function ConfirmDialog({ onConfirm, onCancel, }: ConfirmDialogProps) { - const dialogRef = useRef(null) - useFocusTrap(open, dialogRef) - useEffect(() => { - if (!open) { - return + const requestClose = () => { + if (!busy) { + onCancel() } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && !busy) { - onCancel() - } - } - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) - }, [busy, onCancel, open]) - - if (!open) { - return null } return ( -
{ - if (event.currentTarget === event.target && !busy) { - onCancel() - } - }} - > -
-

CONFIRM ACTION

-

{title}

-

{description}

-
- - + -
-
-
+ + + } + > + {description} + ) } diff --git a/frontend/src/components/DirectoryPickerDialog.tsx b/frontend/src/components/DirectoryPickerDialog.tsx index 171a104..a227aa3 100644 --- a/frontend/src/components/DirectoryPickerDialog.tsx +++ b/frontend/src/components/DirectoryPickerDialog.tsx @@ -1,7 +1,16 @@ 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 { useFocusTrap } from '../lib/useFocusTrap' interface DirectoryPickerDialogProps { open: boolean @@ -13,6 +22,9 @@ interface DirectoryPickerDialogProps { /** * 页面内的目录浏览器:从盘符开始逐级进入文件夹,最终把绝对路径回填到表单。 * 浏览器拿不到系统文件对话框的真实路径,因此目录浏览由后端提供列表接口。 + * + * 外壳是组件库的 `Modal`,路径输入 / 按钮 / 空态 / 错误提示分别是 + * `Input` / `Button` / `Empty` / `Notice`。 */ export function DirectoryPickerDialog({ open, @@ -22,8 +34,6 @@ export function DirectoryPickerDialog({ }: DirectoryPickerDialogProps) { const [currentPath, setCurrentPath] = useState(null) const [draftPath, setDraftPath] = useState('') - const dialogRef = useRef(null) - useFocusTrap(open, dialogRef, '[data-picker-path]') useEffect(() => { if (!open) { @@ -47,23 +57,6 @@ export function DirectoryPickerDialog({ } }, [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 currentDirectory = listing.data?.path ?? null const parent = listing.data?.parent ?? null @@ -74,135 +67,116 @@ export function DirectoryPickerDialog({ } return ( -
{ - if (event.currentTarget === event.target) { - onClose() - } - }} - > -
-
-
-

WORKING DIRECTORY

-

选择项目工作目录

+ +
+ + 将使用 + + + {currentDirectory ?? '尚未选择目录'} +
- -
- -
- - -
{ - event.preventDefault() - navigate(draftPath.trim() || null) + +
+ 使用此目录 + + + } + > +
+ + +
{ + event.preventDefault() + navigate(draftPath.trim() || null) + }} + > + setDraftPath(event.target.value)} + placeholder="输入或粘贴绝对路径,例如 D:\Projects\sync" + aria-label="目录路径" + spellCheck={false} + /> +
+
-
- {listing.isPending ? ( -

正在读取目录…

- ) : null} - {listing.isError ? ( -
- {listing.error.message} -
- ) : null} - {!listing.isPending && !listing.isError && entries.length === 0 ? ( -

- {currentDirectory ? '该目录下没有子文件夹' : '没有可用的盘符'} -

- ) : null} - {entries.length > 0 ? ( -
    - {entries.map((entry) => ( -
  • - -
  • - ))} -
- ) : null} - {listing.data?.truncated ? ( -

文件夹过多,仅列出前 500 个。

- ) : null} -
+
+ {listing.isPending ? ( +
+ + + 正在读取目录… + +
+ ) : null} -
-
- 将使用 - {currentDirectory ?? '尚未选择目录'} -
-
- - -
-
-
-
+ {listing.isError ? ( + + {listing.error.message} + + ) : null} + + {!listing.isPending && !listing.isError && entries.length === 0 ? ( + + ) : null} + + {entries.length > 0 ? ( +
    + {entries.map((entry) => ( +
  • + +
  • + ))} +
+ ) : null} + + {listing.data?.truncated ? ( + + 文件夹过多,仅列出前 500 个。 + + ) : null} + + ) } diff --git a/frontend/src/components/LogViewer.tsx b/frontend/src/components/LogViewer.tsx index 30610d6..1068b6c 100644 --- a/frontend/src/components/LogViewer.tsx +++ b/frontend/src/components/LogViewer.tsx @@ -29,6 +29,7 @@ export function LogViewer({ const viewportRef = useRef(null) const frameRef = useRef(0) const followingRef = useRef(true) + const lastScrollTopRef = useRef(0) const [following, setFollowing] = useState(true) const [metrics, setMetrics] = useState({ scrollTop: 0, height: 0 }) @@ -62,23 +63,34 @@ export function LogViewer({ }, []) const handleScroll = useCallback(() => { + const node = viewportRef.current + if (!node) { + return + } + // 只有「用户往上翻」才停止跟随。日志流式追加时 scrollTop 不变但内容变高, + // 此时不能把 following 关掉,否则后半段日志不会再自动贴底。 + const previousTop = lastScrollTopRef.current + lastScrollTopRef.current = node.scrollTop + const atBottom = isNearBottom( + node.scrollTop, + node.clientHeight, + node.scrollHeight, + FOLLOW_THRESHOLD_PX, + ) + if (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 - const node = viewportRef.current - if (!node) { - return - } - const atBottom = isNearBottom( - node.scrollTop, - node.clientHeight, - node.scrollHeight, - FOLLOW_THRESHOLD_PX, - ) - followingRef.current = atBottom - setFollowing(atBottom) setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight }) }) }, []) @@ -93,6 +105,7 @@ export function LogViewer({ return } node.scrollTop = node.scrollHeight + lastScrollTopRef.current = node.scrollTop setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight }) }, [version]) @@ -102,6 +115,7 @@ export function LogViewer({ return } node.scrollTop = node.scrollHeight + lastScrollTopRef.current = node.scrollTop followingRef.current = true setFollowing(true) setMetrics({ scrollTop: node.scrollTop, height: node.clientHeight }) diff --git a/frontend/src/components/RunLogDrawer.tsx b/frontend/src/components/RunLogDrawer.tsx index beb0811..6a8702c 100644 --- a/frontend/src/components/RunLogDrawer.tsx +++ b/frontend/src/components/RunLogDrawer.tsx @@ -1,4 +1,14 @@ 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 { cancelRun, @@ -18,7 +28,6 @@ import { isTerminalRunStatus, } from '../lib/format' import { appendLogChunk, createLogBuffer, flushLogTail } from '../lib/logLines' -import { useFocusTrap } from '../lib/useFocusTrap' import { LogViewer } from './LogViewer' import { RunStatusBadge } from './StatusBadge' @@ -29,6 +38,31 @@ interface RunLogDrawerProps { 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({ open, task, @@ -43,15 +77,9 @@ export function RunLogDrawer({ const [historyTrigger, setHistoryTrigger] = useState('') const [sseConnected, setSseConnected] = useState(false) const [logEvents] = useState(() => new EventTarget()) - const drawerRef = useRef(null) - useFocusTrap(open, drawerRef) + const runsQuery = useQuery({ - queryKey: [ - 'task-runs', - task?.id, - historyStatus, - historyTrigger, - ], + queryKey: ['task-runs', task?.id, historyStatus, historyTrigger], queryFn: ({ signal }) => listTaskRuns( task!.id, @@ -117,9 +145,7 @@ export function RunLogDrawer({ try { const payload = JSON.parse(event.data) as ExecutionEvent if (payload.event === 'log') { - logEvents.dispatchEvent( - new CustomEvent('log', { detail: payload }), - ) + logEvents.dispatchEvent(new CustomEvent('log', { detail: payload })) } } catch { setSseConnected(false) @@ -142,201 +168,164 @@ export function RunLogDrawer({ } }, [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) { return null } return ( -
{ - if (event.currentTarget === event.target) { - onClose() - } - }} + - -
+ + + {selectedRunQuery.isError ? ( + + {selectedRunQuery.error.message} + + ) : null} + + {selectedRun ? ( + + ) : ( + + )} + + {effectiveRunId ? ( + + ) : null} + + + ) } @@ -471,10 +460,7 @@ function RunLogOutput({ useEffect(() => { const onLog = (event: Event) => { const payload = (event as CustomEvent).detail - if ( - payload.event !== 'log' || - payload.data.run_id !== runId - ) { + if (payload.event !== 'log' || payload.data.run_id !== runId) { return } if (payload.data.offset === offsetRef.current) { @@ -511,12 +497,13 @@ function RunLogOutput({ {error ? ( -
- {error} - -
+ }, + }} + > + {error} + ) : null} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index 9aacec0..d0797ac 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -1,16 +1,30 @@ +import { Tag, type TagTone } from '@stash/ui' import type { RunStatus } from '../api/client' import { runStatusLabels } from '../lib/format' +const RUN_STATUS_TONE: Record = { + queued: 'primary', + running: 'warning', + succeeded: 'success', + failed: 'danger', + timed_out: 'danger', + cancelled: 'neutral', + skipped: 'neutral', +} + interface RunStatusBadgeProps { status: RunStatus } export function RunStatusBadge({ status }: RunStatusBadgeProps) { return ( - - + ) } @@ -20,11 +34,12 @@ interface EnabledBadgeProps { export function EnabledBadge({ enabled }: EnabledBadgeProps) { return ( - - + ) } diff --git a/frontend/src/components/TaskEditorModal.tsx b/frontend/src/components/TaskEditorModal.tsx index f42e2a2..5092713 100644 --- a/frontend/src/components/TaskEditorModal.tsx +++ b/frontend/src/components/TaskEditorModal.tsx @@ -1,7 +1,21 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useQuery } from '@tanstack/react-query' -import { useEffect, useRef, useState, type ReactNode } from 'react' 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 UseFormRegister, useFieldArray, @@ -23,7 +37,6 @@ import { type KeyValueFormValue, type TaskFormValues, } from '../lib/taskForm' -import { useFocusTrap } from '../lib/useFocusTrap' import { useDebouncedValue } from '../lib/useDebouncedValue' interface TaskEditorModalProps { @@ -34,6 +47,34 @@ interface TaskEditorModalProps { onSubmit: (values: TaskFormValues) => Promise } +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({ open, task, @@ -44,9 +85,6 @@ export function TaskEditorModal({ const [submitError, setSubmitError] = useState(null) const [cronPreviewCount, setCronPreviewCount] = useState(5) const [pickerOpen, setPickerOpen] = useState(false) - const dialogRef = useRef(null) - // 目录选择弹窗打开时让出焦点管理,避免两个焦点陷阱互相争夺 Tab。 - useFocusTrap(open && !pickerOpen, dialogRef, '[data-dialog-autofocus]') const { register, control, @@ -140,26 +178,19 @@ export function TaskEditorModal({ setPickerOpen(false) }, [open, reset, task]) + // 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。 + // 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。 useEffect(() => { if (!open) { return } - const previousOverflow = document.body.style.overflow - document.body.style.overflow = 'hidden' - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && !saving) { - onClose() - } - } - window.addEventListener('keydown', onKeyDown) - return () => { - document.body.style.overflow = previousOverflow - window.removeEventListener('keydown', onKeyDown) - } - }, [onClose, open, saving]) + document.getElementById(task ? 'task-name' : 'task-id')?.focus() + }, [open, task]) - if (!open) { - return null + const requestClose = () => { + if (!saving) { + onClose() + } } const submit = handleSubmit(async (values) => { @@ -184,490 +215,518 @@ export function TaskEditorModal({ }) return ( -
{ - if (event.currentTarget === event.target && !saving) { - onClose() - } - }} + + + + + } > -
-
- {submitError ? ( -
- {submitError} -
- ) : null} - -
- - - - - - -
- -
-
-
- Cron 表达式 -
- 预览未来 - -
+
+ + +
+
+ + 预览未来运行时间 + + -
- {cronPreview.isFetching ? ( - 正在计算... - ) : cronPreview.isError ? ( - - {cronPreview.error.message} - - ) : cronPreview.data?.runs.length ? ( - cronPreview.data.runs.map((run, index) => ( -
- #{index + 1} - {run} -
- )) - ) : ( - - 等待输入有效 Cron 表达式... - - )} -
- {errors.cron?.message ? ( - {errors.cron.message} + {cronPreview.isFetching ? ( + + 正在计算... + + ) : cronPreview.isError ? ( + + {cronPreview.error.message} + + ) : cronPreview.data?.runs.length ? ( + cronPreview.data.runs.map((run, index) => ( +
+ #{index + 1} + {run} +
+ )) ) : ( - - 支持 5/6 位字段及 @daily、@hourly - + + 等待输入有效 Cron 表达式... + )}
- - - - 超时后将被 Windows Job Object 强制终止 - - -
+
-
- - - - - - - - -
+ + + +
- {actionType === 'script' ? ( -
-
- -
- - -
- {errors.script?.working_dir?.message ? ( - - {errors.script.working_dir.message} - - ) : null} -
- -
- - - - - - -
- - + + + + {TIMEZONE_SUGGESTIONS.map((zone) => ( + + + + + ( + + -
-
- {scriptArgs.fields.map((field, index) => ( -
- - - -
- ))} - {scriptArgs.fields.length === 0 ? ( -

暂无运行参数

- ) : null} - {errors.script?.args?.message ? ( - - {errors.script.args.message} - - ) : null} + 选择文件夹… +
+ - scriptEnv.append({ key: '', value: '' })} - onRemove={scriptEnv.remove} + + +
+ + + + + + +
+ + + + -
-
- 调用预览 (Runtime Preview) - Windows 原生环境 +
+
+ + 运行参数 + + + 每行一个参数,不经过 Shell 拼接 + +
+ +
+
+ {scriptArgs.fields.map((field, index) => ( +
+ + + scriptArgs.remove(index)} + > + 删除 +
-
- {previewVirtualEnv ? ( -
- set VIRTUAL_ENV={previewVirtualEnv} + ))} + {scriptArgs.fields.length === 0 ? ( + + 暂无运行参数 + + ) : null} + {errors.script?.args?.message ? ( + + {errors.script.args.message} + + ) : null} +
+ + scriptEnv.append({ key: '', value: '' })} + onRemove={scriptEnv.remove} + /> + +
+
+ 调用预览 (Runtime Preview) + Windows 原生环境 +
+
+ {previewVirtualEnv ? ( +
+ set VIRTUAL_ENV={previewVirtualEnv} +
+ ) : null} + {scriptPreview.env + .filter((item) => item.key.trim()) + .map((item, index) => ( +
+ set {item.key.trim()}= + {isSensitiveName(item.key) + ? '[REDACTED]' + : item.value || '""'}
- ) : null} - {scriptPreview.env - .filter((item) => item.key.trim()) - .map((item, index) => ( -
- set {item.key.trim()}= - {isSensitiveName(item.key) - ? '[REDACTED]' - : item.value || '""'} -
- ))} -
- {formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '} - {scriptPreview.script.trim() || 'main.py'} - {formatArgumentPreview(scriptPreview.args)} -
+ ))} +
+ {formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '} + {scriptPreview.script.trim() || 'main.py'} + {formatArgumentPreview(scriptPreview.args)}
- ) : null} +
+ ) : null} - {actionType === 'http' ? ( -
-
- - - - - - -
-
- - - - -